Coverage for cc_modules/cc_forms.py: 52%

2283 statements  

« prev     ^ index     » next       coverage.py v6.5.0, created at 2022-11-08 23:14 +0000

1#!/usr/bin/env python 

2 

3""" 

4camcops_server/cc_modules/cc_forms.py 

5 

6=============================================================================== 

7 

8 Copyright (C) 2012, University of Cambridge, Department of Psychiatry. 

9 Created by Rudolf Cardinal (rnc1001@cam.ac.uk). 

10 

11 This file is part of CamCOPS. 

12 

13 CamCOPS is free software: you can redistribute it and/or modify 

14 it under the terms of the GNU General Public License as published by 

15 the Free Software Foundation, either version 3 of the License, or 

16 (at your option) any later version. 

17 

18 CamCOPS is distributed in the hope that it will be useful, 

19 but WITHOUT ANY WARRANTY; without even the implied warranty of 

20 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 

21 GNU General Public License for more details. 

22 

23 You should have received a copy of the GNU General Public License 

24 along with CamCOPS. If not, see <https://www.gnu.org/licenses/>. 

25 

26=============================================================================== 

27 

28.. _Deform: https://docs.pylonsproject.org/projects/deform/en/latest/ 

29 

30**Forms for use by the web front end.** 

31 

32*COLANDER NODES, NULLS, AND VALIDATION* 

33 

34- Surprisingly tricky. 

35- Nodes must be validly intialized with NO USER-DEFINED PARAMETERS to __init__; 

36 the Deform framework clones them. 

37- A null appstruct is used to initialize nodes as Forms are created. 

38 Therefore, the "default" value must be acceptable to the underlying type's 

39 serialize() function. Note in particular that "default = None" is not 

40 acceptable to Integer. Having no default is fine, though. 

41- In general, flexible inheritance is very hard to implement. 

42 

43- Note that this error: 

44 

45 .. code-block:: none 

46 

47 AttributeError: 'EditTaskFilterSchema' object has no attribute 'typ' 

48 

49 means you have failed to call super().__init__() properly from __init__(). 

50 

51- When creating a schema, its members seem to have to be created in the class 

52 declaration as class properties, not in __init__(). 

53 

54*ACCESSING THE PYRAMID REQUEST IN FORMS AND SCHEMAS* 

55 

56We often want to be able to access the request for translation purposes, or 

57sometimes more specialized reasons. 

58 

59Forms are created dynamically as simple Python objects. So, for a 

60:class:`deform.form.Form`, just add a ``request`` parameter to the constructor, 

61and pass it when you create the form. An example is 

62:class:`camcops_server.cc_modules.cc_forms.DeleteCancelForm`. 

63 

64For a :class:`colander.Schema` and :class:`colander.SchemaNode`, construction 

65is separate from binding. The schema nodes are created as part of a schema 

66class, not a schema instance. The schema is created by the form, and then bound 

67to a request. Access to the request is therefore via the :func:`after_bind` 

68callback function, offered by colander, via the ``kw`` parameter or 

69``self.bindings``. We use ``Binding.REQUEST`` as a standard key for this 

70dictionary. The bindings are also available in :func:`validator` and similar 

71functions, as ``self.bindings``. 

72 

73All forms containing any schema that needs to see the request should have this 

74sort of ``__init__`` function: 

75 

76.. code-block:: python 

77 

78 class SomeForm(...): 

79 def __init__(...): 

80 schema = schema_class().bind(request=request) 

81 super().__init__( 

82 schema, 

83 ..., 

84 **kwargs 

85 ) 

86 

87The simplest thing, therefore, is for all forms to do this. Some of our forms 

88use a form superclass that does this via the ``schema_class`` argument (which 

89is not part of colander, so if you see that, the superclass should do the work 

90of binding a request). 

91 

92For translation, throughout there will be ``_ = self.gettext`` or ``_ = 

93request.gettext``. 

94 

95Form titles need to be dynamically written via 

96:class:`cardinal_pythonlib.deform_utils.DynamicDescriptionsForm` or similar. 

97 

98.. glossary:: 

99 

100 cstruct 

101 See `cstruct 

102 <https://docs.pylonsproject.org/projects/deform/en/latest/glossary.html#term-cstruct>`_ 

103 in the Deform_ docs. 

104 

105 Colander 

106 See `Colander 

107 <https://docs.pylonsproject.org/projects/deform/en/latest/glossary.html#term-colander>`_ 

108 in the Deform_ docs. 

109 

110 field 

111 See `field 

112 <https://docs.pylonsproject.org/projects/deform/en/latest/glossary.html#term-field>`_ 

113 in the Deform_ docs. 

114 

115 Peppercorn 

116 See `Peppercorn 

117 <https://docs.pylonsproject.org/projects/deform/en/latest/glossary.html#term-peppercorn>`_ 

118 in the Deform_ docs. 

119 

120 pstruct 

121 See `pstruct 

122 <https://docs.pylonsproject.org/projects/deform/en/latest/glossary.html#term-pstruct>`_ 

123 in the Deform_ docs. 

124 

125""" # noqa 

126 

127from io import BytesIO 

128import json 

129import logging 

130import os 

131from typing import ( 

132 Any, 

133 Callable, 

134 Dict, 

135 List, 

136 Optional, 

137 Tuple, 

138 Type, 

139 TYPE_CHECKING, 

140 Union, 

141) 

142 

143from cardinal_pythonlib.colander_utils import ( 

144 AllowNoneType, 

145 BooleanNode, 

146 DateSelectorNode, 

147 DateTimeSelectorNode, 

148 DEFAULT_WIDGET_DATE_OPTIONS_FOR_PENDULUM, 

149 DEFAULT_WIDGET_TIME_OPTIONS_FOR_PENDULUM, 

150 get_child_node, 

151 get_values_and_permissible, 

152 HiddenIntegerNode, 

153 HiddenStringNode, 

154 MandatoryEmailNode, 

155 MandatoryStringNode, 

156 OptionalEmailNode, 

157 OptionalIntNode, 

158 OptionalPendulumNode, 

159 OptionalStringNode, 

160 ValidateDangerousOperationNode, 

161) 

162from cardinal_pythonlib.deform_utils import ( 

163 DynamicDescriptionsForm, 

164 InformativeForm, 

165) 

166from cardinal_pythonlib.httpconst import HttpMethod 

167from cardinal_pythonlib.logs import BraceStyleAdapter 

168from cardinal_pythonlib.sqlalchemy.dialect import SqlaDialectName 

169from cardinal_pythonlib.sqlalchemy.orm_query import CountStarSpecializedQuery 

170 

171# noinspection PyProtectedMember 

172from colander import ( 

173 Boolean, 

174 Date, 

175 drop, 

176 Integer, 

177 Invalid, 

178 Length, 

179 MappingSchema, 

180 null, 

181 OneOf, 

182 Range, 

183 Schema, 

184 SchemaNode, 

185 SchemaType, 

186 SequenceSchema, 

187 Set, 

188 String, 

189 _null, 

190 url, 

191) 

192from deform.form import Button 

193from deform.widget import ( 

194 CheckboxChoiceWidget, 

195 CheckedPasswordWidget, 

196 # DateInputWidget, 

197 DateTimeInputWidget, 

198 FormWidget, 

199 HiddenWidget, 

200 MappingWidget, 

201 PasswordWidget, 

202 RadioChoiceWidget, 

203 RichTextWidget, 

204 SelectWidget, 

205 SequenceWidget, 

206 TextAreaWidget, 

207 TextInputWidget, 

208 Widget, 

209) 

210 

211from pendulum import Duration 

212import phonenumbers 

213import pyotp 

214import qrcode 

215import qrcode.image.svg 

216 

217# import as LITTLE AS POSSIBLE; this is used by lots of modules 

218# We use some delayed imports here (search for "delayed import") 

219from camcops_server.cc_modules.cc_baseconstants import ( 

220 DEFORM_SUPPORTS_CSP_NONCE, 

221 TEMPLATE_DIR, 

222) 

223from camcops_server.cc_modules.cc_constants import ( 

224 ConfigParamSite, 

225 DEFAULT_ROWS_PER_PAGE, 

226 MfaMethod, 

227 MINIMUM_PASSWORD_LENGTH, 

228 SEX_OTHER_UNSPECIFIED, 

229 SEX_FEMALE, 

230 SEX_MALE, 

231 StringLengths, 

232 USER_NAME_FOR_SYSTEM, 

233) 

234from camcops_server.cc_modules.cc_group import Group 

235from camcops_server.cc_modules.cc_idnumdef import ( 

236 IdNumDefinition, 

237 ID_NUM_VALIDATION_METHOD_CHOICES, 

238 validate_id_number, 

239) 

240from camcops_server.cc_modules.cc_ipuse import IpUse 

241from camcops_server.cc_modules.cc_language import ( 

242 DEFAULT_LOCALE, 

243 POSSIBLE_LOCALES, 

244 POSSIBLE_LOCALES_WITH_DESCRIPTIONS, 

245) 

246from camcops_server.cc_modules.cc_patient import Patient 

247from camcops_server.cc_modules.cc_patientidnum import PatientIdNum 

248from camcops_server.cc_modules.cc_policy import ( 

249 TABLET_ID_POLICY_STR, 

250 TokenizedPolicy, 

251) 

252from camcops_server.cc_modules.cc_pyramid import FormAction, ViewArg, ViewParam 

253from camcops_server.cc_modules.cc_task import tablename_to_task_class_dict 

254from camcops_server.cc_modules.cc_taskschedule import ( 

255 TaskSchedule, 

256 TaskScheduleEmailTemplateFormatter, 

257) 

258from camcops_server.cc_modules.cc_validators import ( 

259 ALPHANUM_UNDERSCORE_CHAR, 

260 validate_anything, 

261 validate_by_char_and_length, 

262 validate_download_filename, 

263 validate_group_name, 

264 validate_hl7_aa, 

265 validate_hl7_id_type, 

266 validate_ip_address, 

267 validate_new_password, 

268 validate_redirect_url, 

269 validate_username, 

270) 

271 

272if TYPE_CHECKING: 

273 from deform.field import Field 

274 from camcops_server.cc_modules.cc_request import CamcopsRequest 

275 from camcops_server.cc_modules.cc_task import Task 

276 from camcops_server.cc_modules.cc_user import User 

277 

278log = BraceStyleAdapter(logging.getLogger(__name__)) 

279 

280ColanderNullType = _null 

281ValidatorType = Callable[[SchemaNode, Any], None] # called as v(node, value) 

282 

283 

284# ============================================================================= 

285# Debugging options 

286# ============================================================================= 

287 

288DEBUG_CSRF_CHECK = False 

289 

290if DEBUG_CSRF_CHECK: 

291 log.warning("Debugging options enabled!") 

292 

293 

294# ============================================================================= 

295# Constants 

296# ============================================================================= 

297 

298DEFORM_ACCORDION_BUG = True 

299# If you have a sequence containing an accordion (e.g. advanced JSON settings), 

300# then when you add a new node (e.g. "Add Task schedule") then the newly 

301# created node's accordion won't open out. 

302# https://github.com/Pylons/deform/issues/347 

303 

304 

305class Binding(object): 

306 """ 

307 Keys used for binding dictionaries with Colander schemas (schemata). 

308 

309 Must match ``kwargs`` of calls to ``bind()`` function of each ``Schema``. 

310 """ 

311 

312 GROUP = "group" 

313 OPEN_ADMIN = "open_admin" 

314 OPEN_WHAT = "open_what" 

315 OPEN_WHEN = "open_when" 

316 OPEN_WHO = "open_who" 

317 REQUEST = "request" 

318 TRACKER_TASKS_ONLY = "tracker_tasks_only" 

319 USER = "user" 

320 

321 

322class BootstrapCssClasses(object): 

323 """ 

324 Constants from Bootstrap to control display. 

325 """ 

326 

327 FORM_INLINE = "form-inline" 

328 RADIO_INLINE = "radio-inline" 

329 LIST_INLINE = "list-inline" 

330 CHECKBOX_INLINE = "checkbox-inline" 

331 

332 

333AUTOCOMPLETE_ATTR = "autocomplete" 

334 

335 

336class AutocompleteAttrValues(object): 

337 """ 

338 Some values for the HTML "autocomplete" attribute, as per 

339 https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/autocomplete. 

340 Not all are used. 

341 """ 

342 

343 BDAY = "bday" 

344 CURRENT_PASSWORD = "current-password" 

345 EMAIL = "email" 

346 FAMILY_NAME = "family-name" 

347 GIVEN_NAME = "given-name" 

348 NEW_PASSWORD = "new-password" 

349 OFF = "off" 

350 ON = "on" # browser decides 

351 STREET_ADDRESS = "stree-address" 

352 USERNAME = "username" 

353 

354 

355def get_tinymce_options(request: "CamcopsRequest") -> Dict[str, Any]: 

356 return { 

357 "content_css": "static/tinymce/custom_content.css", 

358 "menubar": "false", 

359 "plugins": "link", 

360 "toolbar": ( 

361 "undo redo | bold italic underline | link | " 

362 "bullist numlist | " 

363 "alignleft aligncenter alignright alignjustify | " 

364 "outdent indent" 

365 ), 

366 "language": request.language_iso_639_1, 

367 } 

368 

369 

370# ============================================================================= 

371# Common phrases for translation 

372# ============================================================================= 

373 

374 

375def or_join_description(request: "CamcopsRequest") -> str: 

376 _ = request.gettext 

377 return _("If you specify more than one, they will be joined with OR.") 

378 

379 

380def change_password_title(request: "CamcopsRequest") -> str: 

381 _ = request.gettext 

382 return _("Change password") 

383 

384 

385def sex_choices(request: "CamcopsRequest") -> List[Tuple[str, str]]: 

386 _ = request.gettext 

387 return [ 

388 (SEX_FEMALE, _("Female (F)")), 

389 (SEX_MALE, _("Male (M)")), 

390 # TRANSLATOR: sex code description 

391 (SEX_OTHER_UNSPECIFIED, _("Other/unspecified (X)")), 

392 ] 

393 

394 

395# ============================================================================= 

396# Deform bug fix: SelectWidget "multiple" attribute 

397# ============================================================================= 

398 

399 

400class BugfixSelectWidget(SelectWidget): 

401 """ 

402 Fixes a bug where newer versions of Chameleon (e.g. 3.8.0) render Deform's 

403 ``multiple = False`` (in ``SelectWidget``) as this, which is wrong: 

404 

405 .. code-block:: none 

406 

407 <select name="which_idnum" id="deformField2" class=" form-control " multiple="False"> 

408 ^^^^^^^^^^^^^^^^ 

409 <option value="1">CPFT RiO number</option> 

410 <option value="2">NHS number</option> 

411 <option value="1000">MyHospital number</option> 

412 </select> 

413 

414 ... whereas previous versions of Chameleon (e.g. 3.4) omitted the tag. 

415 (I think it's a Chameleon change, anyway! And it's probably a bugfix in 

416 Chameleon that exposed a bug in Deform.) 

417 

418 See :func:`camcops_server.cc_modules.webview.debug_form_rendering`. 

419 """ # noqa 

420 

421 def __init__(self, multiple=False, **kwargs) -> None: 

422 multiple = True if multiple else None # None, not False 

423 super().__init__(multiple=multiple, **kwargs) 

424 

425 

426SelectWidget = BugfixSelectWidget 

427 

428 

429# ============================================================================= 

430# Form that handles Content-Security-Policy nonce tags 

431# ============================================================================= 

432 

433 

434class InformativeNonceForm(InformativeForm): 

435 """ 

436 A Form class to use our modifications to Deform, as per 

437 https://github.com/Pylons/deform/issues/512, to pass a nonce value through 

438 to the ``<script>`` and ``<style>`` tags in the Deform templates. 

439 

440 todo: if Deform is updated, work this into ``cardinal_pythonlib``. 

441 """ 

442 

443 if DEFORM_SUPPORTS_CSP_NONCE: 

444 

445 def __init__(self, schema: Schema, **kwargs) -> None: 

446 request = schema.request # type: CamcopsRequest 

447 kwargs["nonce"] = request.nonce 

448 super().__init__(schema, **kwargs) 

449 

450 

451class DynamicDescriptionsNonceForm(DynamicDescriptionsForm): 

452 """ 

453 Similarly; see :class:`InformativeNonceForm`. 

454 

455 todo: if Deform is updated, work this into ``cardinal_pythonlib``. 

456 """ 

457 

458 if DEFORM_SUPPORTS_CSP_NONCE: 

459 

460 def __init__(self, schema: Schema, **kwargs) -> None: 

461 request = schema.request # type: CamcopsRequest 

462 kwargs["nonce"] = request.nonce 

463 super().__init__(schema, **kwargs) 

464 

465 

466# ============================================================================= 

467# Mixin for Schema/SchemaNode objects for translation 

468# ============================================================================= 

469 

470GETTEXT_TYPE = Callable[[str], str] 

471 

472 

473class RequestAwareMixin(object): 

474 """ 

475 Mixin to add Pyramid request awareness to Schema/SchemaNode objects, 

476 together with some translations and other convenience functions. 

477 """ 

478 

479 def __init__(self, *args, **kwargs) -> None: 

480 # Stop multiple inheritance complaints 

481 super().__init__(*args, **kwargs) 

482 

483 # noinspection PyUnresolvedReferences 

484 @property 

485 def request(self) -> "CamcopsRequest": 

486 return self.bindings[Binding.REQUEST] 

487 

488 # noinspection PyUnresolvedReferences,PyPropertyDefinition 

489 @property 

490 def gettext(self) -> GETTEXT_TYPE: 

491 return self.request.gettext 

492 

493 @property 

494 def or_join_description(self) -> str: 

495 return or_join_description(self.request) 

496 

497 

498# ============================================================================= 

499# Translatable version of ValidateDangerousOperationNode 

500# ============================================================================= 

501 

502 

503class TranslatableValidateDangerousOperationNode( 

504 ValidateDangerousOperationNode, RequestAwareMixin 

505): 

506 """ 

507 Translatable version of ValidateDangerousOperationNode. 

508 """ 

509 

510 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None: 

511 super().after_bind(node, kw) # calls set_description() 

512 _ = self.gettext 

513 node.title = _("Danger") 

514 user_entry = get_child_node(self, "user_entry") 

515 user_entry.title = _("Validate this dangerous operation") 

516 

517 def set_description(self, target_value: str) -> None: 

518 # Overrides parent version (q.v.). 

519 _ = self.gettext 

520 user_entry = get_child_node(self, "user_entry") 

521 prefix = _("Please enter the following: ") 

522 user_entry.description = prefix + target_value 

523 

524 

525# ============================================================================= 

526# Translatable version of SequenceWidget 

527# ============================================================================= 

528 

529 

530class TranslatableSequenceWidget(SequenceWidget): 

531 """ 

532 SequenceWidget does support translation via _(), but not in a 

533 request-specific way. 

534 """ 

535 

536 def __init__(self, request: "CamcopsRequest", **kwargs) -> None: 

537 super().__init__(**kwargs) 

538 _ = request.gettext 

539 self.add_subitem_text_template = _("Add") + " ${subitem_title}" 

540 

541 

542# ============================================================================= 

543# Translatable version of OptionalPendulumNode 

544# ============================================================================= 

545 

546 

547class TranslatableOptionalPendulumNode( 

548 OptionalPendulumNode, RequestAwareMixin 

549): 

550 """ 

551 Translates the "Date" and "Time" labels for the widget, via 

552 the request. 

553 

554 .. todo:: TranslatableOptionalPendulumNode not fully implemented 

555 """ 

556 

557 def __init__(self, *args, **kwargs) -> None: 

558 super().__init__(*args, **kwargs) 

559 self.widget = None # type: Optional[Widget] 

560 

561 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None: 

562 _ = self.gettext 

563 self.widget = DateTimeInputWidget( 

564 date_options=DEFAULT_WIDGET_DATE_OPTIONS_FOR_PENDULUM, 

565 time_options=DEFAULT_WIDGET_TIME_OPTIONS_FOR_PENDULUM, 

566 ) 

567 # log.debug("TranslatableOptionalPendulumNode.widget: {!r}", 

568 # self.widget.__dict__) 

569 

570 

571class TranslatableDateTimeSelectorNode( 

572 DateTimeSelectorNode, RequestAwareMixin 

573): 

574 """ 

575 Translates the "Date" and "Time" labels for the widget, via 

576 the request. 

577 

578 .. todo:: TranslatableDateTimeSelectorNode not fully implemented 

579 """ 

580 

581 def __init__(self, *args, **kwargs) -> None: 

582 super().__init__(*args, **kwargs) 

583 self.widget = None # type: Optional[Widget] 

584 

585 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None: 

586 _ = self.gettext 

587 self.widget = DateTimeInputWidget() 

588 # log.debug("TranslatableDateTimeSelectorNode.widget: {!r}", 

589 # self.widget.__dict__) 

590 

591 

592''' 

593class TranslatableDateSelectorNode(DateSelectorNode, 

594 RequestAwareMixin): 

595 """ 

596 Translates the "Date" and "Time" labels for the widget, via 

597 the request. 

598 

599 .. todo:: TranslatableDateSelectorNode not fully implemented 

600 """ 

601 def __init__(self, *args, **kwargs) -> None: 

602 super().__init__(*args, **kwargs) 

603 self.widget = None # type: Optional[Widget] 

604 

605 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None: 

606 _ = self.gettext 

607 self.widget = DateInputWidget() 

608 # log.debug("TranslatableDateSelectorNode.widget: {!r}", 

609 # self.widget.__dict__) 

610''' 

611 

612 

613# ============================================================================= 

614# CSRF 

615# ============================================================================= 

616 

617 

618class CSRFToken(SchemaNode, RequestAwareMixin): 

619 """ 

620 Node to embed a cross-site request forgery (CSRF) prevention token in a 

621 form. 

622 

623 As per https://deformdemo.repoze.org/pyramid_csrf_demo/, modified for a 

624 more recent Colander API. 

625 

626 NOTE that this makes use of colander.SchemaNode.bind; this CLONES the 

627 Schema, and resolves any deferred values by means of the keywords passed to 

628 bind(). Since the Schema is created at module load time, but since we're 

629 asking the Schema to know about the request's CSRF values, this is the only 

630 mechanism 

631 (https://docs.pylonsproject.org/projects/colander/en/latest/api.html#colander.SchemaNode.bind). 

632 

633 From https://deform2000.readthedocs.io/en/latest/basics.html: 

634 

635 "The default of a schema node indicates the value to be serialized if a 

636 value for the schema node is not found in the input data during 

637 serialization. It should be the deserialized representation. If a schema 

638 node does not have a default, it is considered "serialization required"." 

639 

640 "The missing of a schema node indicates the value to be deserialized if a 

641 value for the schema node is not found in the input data during 

642 deserialization. It should be the deserialized representation. If a schema 

643 node does not have a missing value, a colander.Invalid exception will be 

644 raised if the data structure being deserialized does not contain a matching 

645 value." 

646 

647 RNC: Serialized values are always STRINGS. 

648 

649 """ # noqa 

650 

651 schema_type = String 

652 default = "" 

653 missing = "" 

654 title = " " 

655 # ... evaluates to True but won't be visible, if the "hidden" aspect ever 

656 # fails 

657 widget = HiddenWidget() 

658 

659 # noinspection PyUnusedLocal 

660 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None: 

661 request = self.request 

662 csrf_token = request.session.get_csrf_token() 

663 if DEBUG_CSRF_CHECK: 

664 log.debug("Got CSRF token from session: {!r}", csrf_token) 

665 self.default = csrf_token 

666 

667 def validator(self, node: SchemaNode, value: Any) -> None: 

668 # Deferred validator via method, as per 

669 # https://docs.pylonsproject.org/projects/colander/en/latest/basics.html # noqa 

670 request = self.request 

671 csrf_token = request.session.get_csrf_token() # type: str 

672 matches = value == csrf_token 

673 if DEBUG_CSRF_CHECK: 

674 log.debug( 

675 "Validating CSRF token: form says {!r}, session says " 

676 "{!r}, matches = {}", 

677 value, 

678 csrf_token, 

679 matches, 

680 ) 

681 if not matches: 

682 log.warning( 

683 "CSRF token mismatch; remote address {}", request.remote_addr 

684 ) 

685 _ = request.gettext 

686 raise Invalid(node, _("Bad CSRF token")) 

687 

688 

689class CSRFSchema(Schema, RequestAwareMixin): 

690 """ 

691 Base class for form schemas that use CSRF (XSRF; cross-site request 

692 forgery) tokens. 

693 

694 You can't put the call to ``bind()`` at the end of ``__init__()``, because 

695 ``bind()`` calls ``clone()`` with no arguments and ``clone()`` ends up 

696 calling ``__init__()```... 

697 

698 The item name should be one that the ZAP penetration testing tool expects, 

699 or you get: 

700 

701 .. code-block:: none 

702 

703 No known Anti-CSRF token [anticsrf, CSRFToken, 

704 __RequestVerificationToken, csrfmiddlewaretoken, authenticity_token, 

705 OWASP_CSRFTOKEN, anoncsrf, csrf_token, _csrf, _csrfSecret] was found in 

706 the following HTML form: [Form 1: "_charset_" "__formid__" 

707 "deformField1" "deformField2" "deformField3" "deformField4" ]. 

708 

709 """ 

710 

711 csrf_token = CSRFToken() # name must match ViewParam.CSRF_TOKEN 

712 # ... name should also be one that ZAP expects, as above 

713 

714 

715# ============================================================================= 

716# Horizontal forms 

717# ============================================================================= 

718 

719 

720class HorizontalFormWidget(FormWidget): 

721 """ 

722 Widget to render a form horizontally, with custom templates. 

723 

724 See :class:`deform.template.ZPTRendererFactory`, which explains how strings 

725 are resolved to Chameleon ZPT (Zope) templates. 

726 

727 See 

728 

729 - https://stackoverflow.com/questions/12201835/form-inline-inside-a-form-horizontal-in-twitter-bootstrap 

730 - https://stackoverflow.com/questions/18429121/inline-form-nested-within-horizontal-form-in-bootstrap-3 

731 - https://stackoverflow.com/questions/23954772/how-to-make-a-horizontal-form-with-deform-2 

732 """ # noqa 

733 

734 basedir = os.path.join(TEMPLATE_DIR, "deform") 

735 readonlydir = os.path.join(basedir, "readonly") 

736 form = "horizontal_form.pt" 

737 mapping_item = "horizontal_mapping_item.pt" 

738 

739 template = os.path.join( 

740 basedir, form 

741 ) # default "form" = deform/templates/form.pt 

742 readonly_template = os.path.join( 

743 readonlydir, form 

744 ) # default "readonly/form" 

745 item_template = os.path.join( 

746 basedir, mapping_item 

747 ) # default "mapping_item" 

748 readonly_item_template = os.path.join( 

749 readonlydir, mapping_item 

750 ) # default "readonly/mapping_item" 

751 

752 

753class HorizontalFormMixin(object): 

754 """ 

755 Modification to a Deform form that displays itself with horizontal layout, 

756 using custom templates via :class:`HorizontalFormWidget`. Not fantastic. 

757 """ 

758 

759 def __init__(self, schema: Schema, *args, **kwargs) -> None: 

760 kwargs = kwargs or {} 

761 

762 # METHOD 1: add "form-inline" to the CSS classes. 

763 # extra_classes = "form-inline" 

764 # if "css_class" in kwargs: 

765 # kwargs["css_class"] += " " + extra_classes 

766 # else: 

767 # kwargs["css_class"] = extra_classes 

768 

769 # Method 2: change the widget 

770 schema.widget = HorizontalFormWidget() 

771 

772 # OK, proceed. 

773 super().__init__(schema, *args, **kwargs) 

774 

775 

776def add_css_class( 

777 kwargs: Dict[str, Any], extra_classes: str, param_name: str = "css_class" 

778) -> None: 

779 """ 

780 Modifies a kwargs dictionary to add a CSS class to the ``css_class`` 

781 parameter. 

782 

783 Args: 

784 kwargs: a dictionary 

785 extra_classes: CSS classes to add (as a space-separated string) 

786 param_name: parameter name to modify; by default, "css_class" 

787 """ 

788 if param_name in kwargs: 

789 kwargs[param_name] += " " + extra_classes 

790 else: 

791 kwargs[param_name] = extra_classes 

792 

793 

794class FormInlineCssMixin(object): 

795 """ 

796 Modification to a Deform form that makes it display "inline" via CSS. This 

797 has the effect of wrapping everything horizontally. 

798 

799 Should PRECEDE the :class:`Form` (or something derived from it) in the 

800 inheritance order. 

801 """ 

802 

803 def __init__(self, *args, **kwargs) -> None: 

804 kwargs = kwargs or {} 

805 add_css_class(kwargs, BootstrapCssClasses.FORM_INLINE) 

806 super().__init__(*args, **kwargs) 

807 

808 

809def make_widget_horizontal(widget: Widget) -> None: 

810 """ 

811 Applies Bootstrap "form-inline" styling to the widget. 

812 """ 

813 widget.item_css_class = BootstrapCssClasses.FORM_INLINE 

814 

815 

816def make_node_widget_horizontal(node: SchemaNode) -> None: 

817 """ 

818 Applies Bootstrap "form-inline" styling to the schema node's widget. 

819 

820 **Note:** often better to use the ``inline=True`` option to the widget's 

821 constructor. 

822 """ 

823 make_widget_horizontal(node.widget) 

824 

825 

826# ============================================================================= 

827# Specialized Form classes 

828# ============================================================================= 

829 

830 

831class SimpleSubmitForm(InformativeNonceForm): 

832 """ 

833 Form with a simple "submit" button. 

834 """ 

835 

836 def __init__( 

837 self, 

838 schema_class: Type[Schema], 

839 submit_title: str, 

840 request: "CamcopsRequest", 

841 **kwargs, 

842 ) -> None: 

843 """ 

844 Args: 

845 schema_class: 

846 class of the Colander :class:`Schema` to use as this form's 

847 schema 

848 submit_title: 

849 title (text) to be used for the "submit" button 

850 request: 

851 :class:`camcops_server.cc_modules.cc_request.CamcopsRequest` 

852 """ 

853 schema = schema_class().bind(request=request) 

854 super().__init__( 

855 schema, 

856 buttons=[Button(name=FormAction.SUBMIT, title=submit_title)], 

857 **kwargs, 

858 ) 

859 

860 

861class OkForm(SimpleSubmitForm): 

862 """ 

863 Form with a button that says "OK". 

864 """ 

865 

866 def __init__(self, request: "CamcopsRequest", **kwargs) -> None: 

867 _ = request.gettext 

868 super().__init__( 

869 schema_class=CSRFSchema, 

870 submit_title=_("OK"), 

871 request=request, 

872 **kwargs, 

873 ) 

874 

875 

876class ApplyCancelForm(InformativeNonceForm): 

877 """ 

878 Form with "apply" and "cancel" buttons. 

879 """ 

880 

881 def __init__( 

882 self, schema_class: Type[Schema], request: "CamcopsRequest", **kwargs 

883 ) -> None: 

884 schema = schema_class().bind(request=request) 

885 _ = request.gettext 

886 super().__init__( 

887 schema, 

888 buttons=[ 

889 Button(name=FormAction.SUBMIT, title=_("Apply")), 

890 Button(name=FormAction.CANCEL, title=_("Cancel")), 

891 ], 

892 **kwargs, 

893 ) 

894 

895 

896class AddCancelForm(InformativeNonceForm): 

897 """ 

898 Form with "add" and "cancel" buttons. 

899 """ 

900 

901 def __init__( 

902 self, schema_class: Type[Schema], request: "CamcopsRequest", **kwargs 

903 ) -> None: 

904 schema = schema_class().bind(request=request) 

905 _ = request.gettext 

906 super().__init__( 

907 schema, 

908 buttons=[ 

909 Button(name=FormAction.SUBMIT, title=_("Add")), 

910 Button(name=FormAction.CANCEL, title=_("Cancel")), 

911 ], 

912 **kwargs, 

913 ) 

914 

915 

916class DangerousForm(DynamicDescriptionsNonceForm): 

917 """ 

918 Form with one "submit" button (with user-specifiable title text and action 

919 name), in a CSS class indicating that it's a dangerous operation, plus a 

920 "Cancel" button. 

921 """ 

922 

923 def __init__( 

924 self, 

925 schema_class: Type[Schema], 

926 submit_action: str, 

927 submit_title: str, 

928 request: "CamcopsRequest", 

929 **kwargs, 

930 ) -> None: 

931 schema = schema_class().bind(request=request) 

932 _ = request.gettext 

933 super().__init__( 

934 schema, 

935 buttons=[ 

936 Button( 

937 name=submit_action, 

938 title=submit_title, 

939 css_class="btn-danger", 

940 ), 

941 Button(name=FormAction.CANCEL, title=_("Cancel")), 

942 ], 

943 **kwargs, 

944 ) 

945 

946 

947class DeleteCancelForm(DangerousForm): 

948 """ 

949 Form with a "delete" button (visually marked as dangerous) and a "cancel" 

950 button. 

951 """ 

952 

953 def __init__( 

954 self, schema_class: Type[Schema], request: "CamcopsRequest", **kwargs 

955 ) -> None: 

956 _ = request.gettext 

957 super().__init__( 

958 schema_class=schema_class, 

959 submit_action=FormAction.DELETE, 

960 submit_title=_("Delete"), 

961 request=request, 

962 **kwargs, 

963 ) 

964 

965 

966# ============================================================================= 

967# Specialized SchemaNode classes used in several contexts 

968# ============================================================================= 

969 

970# ----------------------------------------------------------------------------- 

971# Task types 

972# ----------------------------------------------------------------------------- 

973 

974 

975class OptionalSingleTaskSelector(OptionalStringNode, RequestAwareMixin): 

976 """ 

977 Node to pick one task type. 

978 """ 

979 

980 def __init__( 

981 self, *args, tracker_tasks_only: bool = False, **kwargs 

982 ) -> None: 

983 """ 

984 Args: 

985 tracker_tasks_only: restrict the choices to tasks that offer 

986 trackers. 

987 """ 

988 self.title = "" # for type checker 

989 self.tracker_tasks_only = tracker_tasks_only 

990 self.widget = None # type: Optional[Widget] 

991 self.validator = None # type: Optional[ValidatorType] 

992 super().__init__(*args, **kwargs) 

993 

994 # noinspection PyUnusedLocal 

995 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None: 

996 _ = self.gettext 

997 self.title = _("Task type") 

998 if Binding.TRACKER_TASKS_ONLY in kw: 

999 self.tracker_tasks_only = kw[Binding.TRACKER_TASKS_ONLY] 

1000 values, pv = get_values_and_permissible( 

1001 self.get_task_choices(), True, _("[Any]") 

1002 ) 

1003 self.widget = SelectWidget(values=values) 

1004 self.validator = OneOf(pv) 

1005 

1006 def get_task_choices(self) -> List[Tuple[str, str]]: 

1007 from camcops_server.cc_modules.cc_task import Task # delayed import 

1008 

1009 choices = [] # type: List[Tuple[str, str]] 

1010 for tc in Task.all_subclasses_by_shortname(): 

1011 if self.tracker_tasks_only and not tc.provides_trackers: 

1012 continue 

1013 choices.append((tc.tablename, tc.shortname)) 

1014 return choices 

1015 

1016 

1017class MandatorySingleTaskSelector(MandatoryStringNode, RequestAwareMixin): 

1018 """ 

1019 Node to pick one task type. 

1020 """ 

1021 

1022 def __init__(self, *args: Any, **kwargs: Any) -> None: 

1023 self.title = "" # for type checker 

1024 self.widget = None # type: Optional[Widget] 

1025 self.validator = None # type: Optional[ValidatorType] 

1026 super().__init__(*args, **kwargs) 

1027 

1028 # noinspection PyUnusedLocal 

1029 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None: 

1030 _ = self.gettext 

1031 self.title = _("Task type") 

1032 values, pv = get_values_and_permissible(self.get_task_choices(), False) 

1033 self.widget = SelectWidget(values=values) 

1034 self.validator = OneOf(pv) 

1035 

1036 @staticmethod 

1037 def get_task_choices() -> List[Tuple[str, str]]: 

1038 from camcops_server.cc_modules.cc_task import Task # delayed import 

1039 

1040 choices = [] # type: List[Tuple[str, str]] 

1041 for tc in Task.all_subclasses_by_shortname(): 

1042 choices.append((tc.tablename, tc.shortname)) 

1043 return choices 

1044 

1045 

1046class MultiTaskSelector(SchemaNode, RequestAwareMixin): 

1047 """ 

1048 Node to select multiple task types. 

1049 """ 

1050 

1051 schema_type = Set 

1052 default = "" 

1053 missing = "" 

1054 

1055 def __init__( 

1056 self, 

1057 *args, 

1058 tracker_tasks_only: bool = False, 

1059 minimum_number: int = 0, 

1060 **kwargs, 

1061 ) -> None: 

1062 self.tracker_tasks_only = tracker_tasks_only 

1063 self.minimum_number = minimum_number 

1064 self.widget = None # type: Optional[Widget] 

1065 self.validator = None # type: Optional[ValidatorType] 

1066 self.title = "" # for type checker 

1067 self.description = "" # for type checker 

1068 super().__init__(*args, **kwargs) 

1069 

1070 # noinspection PyUnusedLocal 

1071 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None: 

1072 _ = self.gettext 

1073 request = self.request # noqa: F841 

1074 self.title = _("Task type(s)") 

1075 self.description = ( 

1076 _("If none are selected, all task types will be offered.") 

1077 + " " 

1078 + self.or_join_description 

1079 ) 

1080 if Binding.TRACKER_TASKS_ONLY in kw: 

1081 self.tracker_tasks_only = kw[Binding.TRACKER_TASKS_ONLY] 

1082 values, pv = get_values_and_permissible(self.get_task_choices()) 

1083 self.widget = CheckboxChoiceWidget(values=values, inline=True) 

1084 self.validator = Length(min=self.minimum_number) 

1085 

1086 def get_task_choices(self) -> List[Tuple[str, str]]: 

1087 from camcops_server.cc_modules.cc_task import Task # delayed import 

1088 

1089 choices = [] # type: List[Tuple[str, str]] 

1090 for tc in Task.all_subclasses_by_shortname(): 

1091 if self.tracker_tasks_only and not tc.provides_trackers: 

1092 continue 

1093 choices.append((tc.tablename, tc.shortname)) 

1094 return choices 

1095 

1096 

1097# ----------------------------------------------------------------------------- 

1098# Use the task index? 

1099# ----------------------------------------------------------------------------- 

1100 

1101 

1102class ViaIndexSelector(BooleanNode, RequestAwareMixin): 

1103 """ 

1104 Node to choose whether we use the server index or not. 

1105 Default is true. 

1106 """ 

1107 

1108 def __init__(self, *args, **kwargs) -> None: 

1109 super().__init__(*args, default=True, **kwargs) 

1110 

1111 # noinspection PyUnusedLocal 

1112 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None: 

1113 _ = self.gettext 

1114 self.title = _("Use server index?") 

1115 self.label = _("Use server index? (Default is true; much faster.)") 

1116 

1117 

1118# ----------------------------------------------------------------------------- 

1119# ID numbers 

1120# ----------------------------------------------------------------------------- 

1121 

1122 

1123class MandatoryWhichIdNumSelector(SchemaNode, RequestAwareMixin): 

1124 """ 

1125 Node to enforce the choice of a single ID number type (e.g. "NHS number" 

1126 or "study Blah ID number"). 

1127 """ 

1128 

1129 widget = SelectWidget() 

1130 

1131 def __init__(self, *args, **kwargs) -> None: 

1132 if not hasattr(self, "allow_none"): 

1133 # ... allows parameter-free (!) inheritance by 

1134 # OptionalWhichIdNumSelector 

1135 self.allow_none = False 

1136 self.title = "" # for type checker 

1137 self.description = "" # for type checker 

1138 self.validator = None # type: Optional[ValidatorType] 

1139 super().__init__(*args, **kwargs) 

1140 

1141 # noinspection PyUnusedLocal 

1142 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None: 

1143 request = self.request 

1144 _ = request.gettext 

1145 self.title = _("Identifier") 

1146 values = [] # type: List[Tuple[Optional[int], str]] 

1147 for iddef in request.idnum_definitions: 

1148 values.append((iddef.which_idnum, iddef.description)) 

1149 values, pv = get_values_and_permissible( 

1150 values, self.allow_none, _("[ignore]") 

1151 ) 

1152 # ... can't use None, because SelectWidget() will convert that to 

1153 # "None"; can't use colander.null, because that converts to 

1154 # "<colander.null>"; use "", which is the default null_value of 

1155 # SelectWidget. 

1156 self.widget.values = values 

1157 self.validator = OneOf(pv) 

1158 

1159 @staticmethod 

1160 def schema_type() -> SchemaType: 

1161 return Integer() 

1162 

1163 

1164class LinkingIdNumSelector(MandatoryWhichIdNumSelector): 

1165 """ 

1166 Convenience node: pick a single ID number, with title/description 

1167 indicating that it's the ID number to link on. 

1168 """ 

1169 

1170 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None: 

1171 super().after_bind(node, kw) 

1172 _ = self.gettext 

1173 self.title = _("Linking ID number") 

1174 self.description = _("Which ID number to link on?") 

1175 

1176 

1177class MandatoryIdNumValue(SchemaNode, RequestAwareMixin): 

1178 """ 

1179 Mandatory node to capture an ID number value. 

1180 """ 

1181 

1182 schema_type = Integer 

1183 validator = Range(min=0) 

1184 

1185 def __init__(self, *args, **kwargs) -> None: 

1186 self.title = "" # for type checker 

1187 super().__init__(*args, **kwargs) 

1188 

1189 # noinspection PyUnusedLocal 

1190 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None: 

1191 _ = self.gettext 

1192 self.title = _("ID# value") 

1193 

1194 

1195class MandatoryIdNumNode(MappingSchema, RequestAwareMixin): 

1196 """ 

1197 Mandatory node to capture an ID number type and the associated actual 

1198 ID number (value). 

1199 

1200 This is also where we apply ID number validation rules (e.g. NHS number). 

1201 """ 

1202 

1203 which_idnum = ( 

1204 MandatoryWhichIdNumSelector() 

1205 ) # must match ViewParam.WHICH_IDNUM 

1206 idnum_value = MandatoryIdNumValue() # must match ViewParam.IDNUM_VALUE 

1207 

1208 def __init__(self, *args, **kwargs) -> None: 

1209 self.title = "" # for type checker 

1210 super().__init__(*args, **kwargs) 

1211 

1212 # noinspection PyUnusedLocal 

1213 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None: 

1214 _ = self.gettext 

1215 self.title = _("ID number") 

1216 

1217 # noinspection PyMethodMayBeStatic 

1218 def validator(self, node: SchemaNode, value: Dict[str, int]) -> None: 

1219 assert isinstance(value, dict) 

1220 req = self.request 

1221 _ = req.gettext 

1222 which_idnum = value[ViewParam.WHICH_IDNUM] 

1223 idnum_value = value[ViewParam.IDNUM_VALUE] 

1224 idnum_def = req.get_idnum_definition(which_idnum) 

1225 if not idnum_def: 

1226 raise Invalid(node, _("Bad ID number type")) # shouldn't happen 

1227 method = idnum_def.validation_method 

1228 if method: 

1229 valid, why_invalid = validate_id_number(req, idnum_value, method) 

1230 if not valid: 

1231 raise Invalid(node, why_invalid) 

1232 

1233 

1234class IdNumSequenceAnyCombination(SequenceSchema, RequestAwareMixin): 

1235 """ 

1236 Sequence to capture multiple ID numbers (as type/value pairs). 

1237 """ 

1238 

1239 idnum_sequence = MandatoryIdNumNode() 

1240 

1241 def __init__(self, *args, **kwargs) -> None: 

1242 self.title = "" # for type checker 

1243 self.widget = None # type: Optional[Widget] 

1244 super().__init__(*args, **kwargs) 

1245 

1246 # noinspection PyUnusedLocal 

1247 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None: 

1248 _ = self.gettext 

1249 self.title = _("ID numbers") 

1250 self.widget = TranslatableSequenceWidget(request=self.request) 

1251 

1252 # noinspection PyMethodMayBeStatic 

1253 def validator(self, node: SchemaNode, value: List[Dict[str, int]]) -> None: 

1254 assert isinstance(value, list) 

1255 list_of_lists = [ 

1256 (x[ViewParam.WHICH_IDNUM], x[ViewParam.IDNUM_VALUE]) for x in value 

1257 ] 

1258 if len(list_of_lists) != len(set(list_of_lists)): 

1259 _ = self.gettext 

1260 raise Invalid( 

1261 node, _("You have specified duplicate ID definitions") 

1262 ) 

1263 

1264 

1265class IdNumSequenceUniquePerWhichIdnum(SequenceSchema, RequestAwareMixin): 

1266 """ 

1267 Sequence to capture multiple ID numbers (as type/value pairs) but with only 

1268 up to one per ID number type. 

1269 """ 

1270 

1271 idnum_sequence = MandatoryIdNumNode() 

1272 

1273 def __init__(self, *args, **kwargs) -> None: 

1274 self.title = "" # for type checker 

1275 self.widget = None # type: Optional[Widget] 

1276 super().__init__(*args, **kwargs) 

1277 

1278 # noinspection PyUnusedLocal 

1279 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None: 

1280 _ = self.gettext 

1281 self.title = _("ID numbers") 

1282 self.widget = TranslatableSequenceWidget(request=self.request) 

1283 

1284 # noinspection PyMethodMayBeStatic 

1285 def validator(self, node: SchemaNode, value: List[Dict[str, int]]) -> None: 

1286 assert isinstance(value, list) 

1287 which_idnums = [x[ViewParam.WHICH_IDNUM] for x in value] 

1288 if len(which_idnums) != len(set(which_idnums)): 

1289 _ = self.gettext 

1290 raise Invalid( 

1291 node, _("You have specified >1 value for one ID number type") 

1292 ) 

1293 

1294 

1295# ----------------------------------------------------------------------------- 

1296# Sex 

1297# ----------------------------------------------------------------------------- 

1298 

1299 

1300class OptionalSexSelector(OptionalStringNode, RequestAwareMixin): 

1301 """ 

1302 Optional node to choose sex. 

1303 """ 

1304 

1305 def __init__(self, *args, **kwargs) -> None: 

1306 self.title = "" # for type checker 

1307 self.validator = None # type: Optional[ValidatorType] 

1308 self.widget = None # type: Optional[Widget] 

1309 super().__init__(*args, **kwargs) 

1310 

1311 # noinspection PyUnusedLocal 

1312 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None: 

1313 _ = self.gettext 

1314 self.title = _("Sex") 

1315 choices = sex_choices(self.request) 

1316 values, pv = get_values_and_permissible(choices, True, _("Any")) 

1317 self.widget = RadioChoiceWidget(values=values, inline=True) 

1318 self.validator = OneOf(pv) 

1319 

1320 

1321class MandatorySexSelector(MandatoryStringNode, RequestAwareMixin): 

1322 """ 

1323 Mandatory node to choose sex. 

1324 """ 

1325 

1326 def __init__(self, *args, **kwargs) -> None: 

1327 self.title = "" # for type checker 

1328 self.validator = None # type: Optional[ValidatorType] 

1329 self.widget = None # type: Optional[Widget] 

1330 super().__init__(*args, **kwargs) 

1331 

1332 # noinspection PyUnusedLocal 

1333 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None: 

1334 _ = self.gettext 

1335 self.title = _("Sex") 

1336 choices = sex_choices(self.request) 

1337 values, pv = get_values_and_permissible(choices) 

1338 self.widget = RadioChoiceWidget(values=values, inline=True) 

1339 self.validator = OneOf(pv) 

1340 

1341 

1342# ----------------------------------------------------------------------------- 

1343# Users 

1344# ----------------------------------------------------------------------------- 

1345 

1346 

1347class MandatoryUserIdSelectorUsersAllowedToSee(SchemaNode, RequestAwareMixin): 

1348 """ 

1349 Mandatory node to choose a user, from the users that the requesting user 

1350 is allowed to see. 

1351 """ 

1352 

1353 schema_type = Integer 

1354 

1355 def __init__(self, *args, **kwargs) -> None: 

1356 self.title = "" # for type checker 

1357 self.validator = None # type: Optional[ValidatorType] 

1358 self.widget = None # type: Optional[Widget] 

1359 super().__init__(*args, **kwargs) 

1360 

1361 # noinspection PyUnusedLocal 

1362 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None: 

1363 from camcops_server.cc_modules.cc_user import User # delayed import 

1364 

1365 _ = self.gettext 

1366 self.title = _("User") 

1367 request = self.request 

1368 dbsession = request.dbsession 

1369 user = request.user 

1370 if user.superuser: 

1371 users = dbsession.query(User).order_by(User.username) 

1372 else: 

1373 # Users in my groups, or groups I'm allowed to see 

1374 my_allowed_group_ids = user.ids_of_groups_user_may_see 

1375 users = ( 

1376 dbsession.query(User) 

1377 .join(Group) 

1378 .filter(Group.id.in_(my_allowed_group_ids)) 

1379 .order_by(User.username) 

1380 ) 

1381 values = [] # type: List[Tuple[Optional[int], str]] 

1382 for user in users: 

1383 values.append((user.id, user.username)) 

1384 values, pv = get_values_and_permissible(values, False) 

1385 self.widget = SelectWidget(values=values) 

1386 self.validator = OneOf(pv) 

1387 

1388 

1389class OptionalUserNameSelector(OptionalStringNode, RequestAwareMixin): 

1390 """ 

1391 Optional node to select a username, from all possible users. 

1392 """ 

1393 

1394 title = "User" 

1395 

1396 def __init__(self, *args, **kwargs) -> None: 

1397 self.title = "" # for type checker 

1398 self.validator = None # type: Optional[ValidatorType] 

1399 self.widget = None # type: Optional[Widget] 

1400 super().__init__(*args, **kwargs) 

1401 

1402 # noinspection PyUnusedLocal 

1403 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None: 

1404 from camcops_server.cc_modules.cc_user import User # delayed import 

1405 

1406 _ = self.gettext 

1407 self.title = _("User") 

1408 request = self.request 

1409 dbsession = request.dbsession 

1410 values = [] # type: List[Tuple[str, str]] 

1411 users = dbsession.query(User).order_by(User.username) 

1412 for user in users: 

1413 values.append((user.username, user.username)) 

1414 values, pv = get_values_and_permissible(values, True, _("[ignore]")) 

1415 self.widget = SelectWidget(values=values) 

1416 self.validator = OneOf(pv) 

1417 

1418 

1419class UsernameNode(SchemaNode, RequestAwareMixin): 

1420 """ 

1421 Node to enter a username. 

1422 """ 

1423 

1424 schema_type = String 

1425 widget = TextInputWidget( 

1426 attributes={AUTOCOMPLETE_ATTR: AutocompleteAttrValues.OFF} 

1427 ) 

1428 

1429 def __init__( 

1430 self, *args, autocomplete: str = AutocompleteAttrValues.OFF, **kwargs 

1431 ) -> None: 

1432 self.title = "" # for type checker 

1433 self.autocomplete = autocomplete 

1434 super().__init__(*args, **kwargs) 

1435 

1436 # noinspection PyUnusedLocal 

1437 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None: 

1438 _ = self.gettext 

1439 self.title = _("Username") 

1440 # noinspection PyUnresolvedReferences 

1441 self.widget.attributes[AUTOCOMPLETE_ATTR] = self.autocomplete 

1442 

1443 def validator(self, node: SchemaNode, value: str) -> None: 

1444 if value == USER_NAME_FOR_SYSTEM: 

1445 _ = self.gettext 

1446 raise Invalid( 

1447 node, 

1448 _("Cannot use system username") 

1449 + " " 

1450 + repr(USER_NAME_FOR_SYSTEM), 

1451 ) 

1452 try: 

1453 validate_username(value, self.request) 

1454 except ValueError as e: 

1455 raise Invalid(node, str(e)) 

1456 

1457 

1458class UserFilterSchema(Schema, RequestAwareMixin): 

1459 """ 

1460 Schema to filter the list of users 

1461 """ 

1462 

1463 # must match ViewParam.INCLUDE_AUTO_GENERATED 

1464 include_auto_generated = BooleanNode() 

1465 

1466 # noinspection PyUnusedLocal 

1467 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None: 

1468 _ = self.gettext 

1469 include_auto_generated = get_child_node(self, "include_auto_generated") 

1470 include_auto_generated.title = _("Include auto-generated users") 

1471 include_auto_generated.label = None 

1472 

1473 

1474class UserFilterForm(InformativeNonceForm): 

1475 """ 

1476 Form to filter the list of users 

1477 """ 

1478 

1479 def __init__(self, request: "CamcopsRequest", **kwargs: Any) -> None: 

1480 _ = request.gettext 

1481 schema = UserFilterSchema().bind(request=request) 

1482 super().__init__( 

1483 schema, 

1484 buttons=[Button(name=FormAction.SET_FILTERS, title=_("Refresh"))], 

1485 css_class=BootstrapCssClasses.FORM_INLINE, 

1486 method=HttpMethod.GET, 

1487 **kwargs, 

1488 ) 

1489 

1490 

1491# ----------------------------------------------------------------------------- 

1492# Devices 

1493# ----------------------------------------------------------------------------- 

1494 

1495 

1496class MandatoryDeviceIdSelector(SchemaNode, RequestAwareMixin): 

1497 """ 

1498 Mandatory node to select a client device ID. 

1499 """ 

1500 

1501 schema_type = Integer 

1502 

1503 def __init__(self, *args, **kwargs) -> None: 

1504 self.title = "" # for type checker 

1505 self.validator = None # type: Optional[ValidatorType] 

1506 self.widget = None # type: Optional[Widget] 

1507 super().__init__(*args, **kwargs) 

1508 

1509 # noinspection PyUnusedLocal 

1510 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None: 

1511 from camcops_server.cc_modules.cc_device import ( 

1512 Device, 

1513 ) # delayed import 

1514 

1515 _ = self.gettext 

1516 self.title = _("Device") 

1517 request = self.request 

1518 dbsession = request.dbsession 

1519 devices = dbsession.query(Device).order_by(Device.friendly_name) 

1520 values = [] # type: List[Tuple[Optional[int], str]] 

1521 for device in devices: 

1522 values.append((device.id, device.friendly_name)) 

1523 values, pv = get_values_and_permissible(values, False) 

1524 self.widget = SelectWidget(values=values) 

1525 self.validator = OneOf(pv) 

1526 

1527 

1528# ----------------------------------------------------------------------------- 

1529# Server PK 

1530# ----------------------------------------------------------------------------- 

1531 

1532 

1533class ServerPkSelector(OptionalIntNode, RequestAwareMixin): 

1534 """ 

1535 Optional node to request an integer, marked as a server PK. 

1536 """ 

1537 

1538 def __init__(self, *args, **kwargs) -> None: 

1539 self.title = "" # for type checker 

1540 super().__init__(*args, **kwargs) 

1541 

1542 # noinspection PyUnusedLocal 

1543 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None: 

1544 _ = self.gettext 

1545 self.title = _("Server PK") 

1546 

1547 

1548# ----------------------------------------------------------------------------- 

1549# Dates/times 

1550# ----------------------------------------------------------------------------- 

1551 

1552 

1553class StartPendulumSelector( 

1554 TranslatableOptionalPendulumNode, RequestAwareMixin 

1555): 

1556 """ 

1557 Optional node to select a start date/time. 

1558 """ 

1559 

1560 def __init__(self, *args, **kwargs) -> None: 

1561 self.title = "" # for type checker 

1562 super().__init__(*args, **kwargs) 

1563 

1564 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None: 

1565 super().after_bind(node, kw) 

1566 _ = self.gettext 

1567 self.title = _("Start date/time (local timezone; inclusive)") 

1568 

1569 

1570class EndPendulumSelector(TranslatableOptionalPendulumNode, RequestAwareMixin): 

1571 """ 

1572 Optional node to select an end date/time. 

1573 """ 

1574 

1575 def __init__(self, *args, **kwargs) -> None: 

1576 self.title = "" # for type checker 

1577 super().__init__(*args, **kwargs) 

1578 

1579 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None: 

1580 super().after_bind(node, kw) 

1581 _ = self.gettext 

1582 self.title = _("End date/time (local timezone; exclusive)") 

1583 

1584 

1585class StartDateTimeSelector( 

1586 TranslatableDateTimeSelectorNode, RequestAwareMixin 

1587): 

1588 """ 

1589 Optional node to select a start date/time (in UTC). 

1590 """ 

1591 

1592 def __init__(self, *args, **kwargs) -> None: 

1593 self.title = "" # for type checker 

1594 super().__init__(*args, **kwargs) 

1595 

1596 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None: 

1597 super().after_bind(node, kw) 

1598 _ = self.gettext 

1599 self.title = _("Start date/time (UTC; inclusive)") 

1600 

1601 

1602class EndDateTimeSelector(TranslatableDateTimeSelectorNode, RequestAwareMixin): 

1603 """ 

1604 Optional node to select an end date/time (in UTC). 

1605 """ 

1606 

1607 def __init__(self, *args, **kwargs) -> None: 

1608 self.title = "" # for type checker 

1609 super().__init__(*args, **kwargs) 

1610 

1611 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None: 

1612 super().after_bind(node, kw) 

1613 _ = self.gettext 

1614 self.title = _("End date/time (UTC; exclusive)") 

1615 

1616 

1617''' 

1618class StartDateSelector(TranslatableDateSelectorNode, 

1619 RequestAwareMixin): 

1620 """ 

1621 Optional node to select a start date (in UTC). 

1622 """ 

1623 def __init__(self, *args, **kwargs) -> None: 

1624 self.title = "" # for type checker 

1625 super().__init__(*args, **kwargs) 

1626 

1627 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None: 

1628 super().after_bind(node, kw) 

1629 _ = self.gettext 

1630 self.title = _("Start date (UTC; inclusive)") 

1631 

1632 

1633class EndDateSelector(TranslatableDateSelectorNode, 

1634 RequestAwareMixin): 

1635 """ 

1636 Optional node to select an end date (in UTC). 

1637 """ 

1638 def __init__(self, *args, **kwargs) -> None: 

1639 self.title = "" # for type checker 

1640 super().__init__(*args, **kwargs) 

1641 

1642 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None: 

1643 super().after_bind(node, kw) 

1644 _ = self.gettext 

1645 self.title = _("End date (UTC; inclusive)") 

1646''' 

1647 

1648 

1649# ----------------------------------------------------------------------------- 

1650# Rows per page 

1651# ----------------------------------------------------------------------------- 

1652 

1653 

1654class RowsPerPageSelector(SchemaNode, RequestAwareMixin): 

1655 """ 

1656 Node to select how many rows per page are shown. 

1657 """ 

1658 

1659 _choices = ((10, "10"), (25, "25"), (50, "50"), (100, "100")) 

1660 

1661 schema_type = Integer 

1662 default = DEFAULT_ROWS_PER_PAGE 

1663 widget = RadioChoiceWidget(values=_choices) 

1664 validator = OneOf(list(x[0] for x in _choices)) 

1665 

1666 def __init__(self, *args, **kwargs) -> None: 

1667 self.title = "" # for type checker 

1668 super().__init__(*args, **kwargs) 

1669 

1670 # noinspection PyUnusedLocal 

1671 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None: 

1672 _ = self.gettext 

1673 self.title = _("Items to show per page") 

1674 

1675 

1676# ----------------------------------------------------------------------------- 

1677# Groups 

1678# ----------------------------------------------------------------------------- 

1679 

1680 

1681class MandatoryGroupIdSelectorAllGroups(SchemaNode, RequestAwareMixin): 

1682 """ 

1683 Offers a picklist of groups from ALL POSSIBLE GROUPS. 

1684 Used by superusers: "add user to any group". 

1685 """ 

1686 

1687 def __init__(self, *args, **kwargs) -> None: 

1688 self.title = "" # for type checker 

1689 self.validator = None # type: Optional[ValidatorType] 

1690 self.widget = None # type: Optional[Widget] 

1691 super().__init__(*args, **kwargs) 

1692 

1693 # noinspection PyUnusedLocal 

1694 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None: 

1695 _ = self.gettext 

1696 self.title = _("Group") 

1697 request = self.request 

1698 dbsession = request.dbsession 

1699 groups = dbsession.query(Group).order_by(Group.name) 

1700 values = [(g.id, g.name) for g in groups] 

1701 values, pv = get_values_and_permissible(values) 

1702 self.widget = SelectWidget(values=values) 

1703 self.validator = OneOf(pv) 

1704 

1705 @staticmethod 

1706 def schema_type() -> SchemaType: 

1707 return Integer() 

1708 

1709 

1710class MandatoryGroupIdSelectorAdministeredGroups( 

1711 SchemaNode, RequestAwareMixin 

1712): 

1713 """ 

1714 Offers a picklist of groups from GROUPS ADMINISTERED BY REQUESTOR. 

1715 Used by groupadmins: "add user to one of my groups". 

1716 """ 

1717 

1718 def __init__(self, *args, **kwargs) -> None: 

1719 self.title = "" # for type checker 

1720 self.validator = None # type: Optional[ValidatorType] 

1721 self.widget = None # type: Optional[Widget] 

1722 super().__init__(*args, **kwargs) 

1723 

1724 # noinspection PyUnusedLocal 

1725 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None: 

1726 _ = self.gettext 

1727 self.title = _("Group") 

1728 request = self.request 

1729 dbsession = request.dbsession 

1730 administered_group_ids = request.user.ids_of_groups_user_is_admin_for 

1731 groups = dbsession.query(Group).order_by(Group.name) 

1732 values = [ 

1733 (g.id, g.name) for g in groups if g.id in administered_group_ids 

1734 ] 

1735 values, pv = get_values_and_permissible(values) 

1736 self.widget = SelectWidget(values=values) 

1737 self.validator = OneOf(pv) 

1738 

1739 @staticmethod 

1740 def schema_type() -> SchemaType: 

1741 return Integer() 

1742 

1743 

1744class MandatoryGroupIdSelectorPatientGroups(SchemaNode, RequestAwareMixin): 

1745 """ 

1746 Offers a picklist of groups the user can manage patients in. 

1747 Used when managing patients: "add patient to one of my groups". 

1748 """ 

1749 

1750 def __init__(self, *args, **kwargs) -> None: 

1751 self.title = "" # for type checker 

1752 self.validator = None # type: Optional[ValidatorType] 

1753 self.widget = None # type: Optional[Widget] 

1754 super().__init__(*args, **kwargs) 

1755 

1756 # noinspection PyUnusedLocal 

1757 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None: 

1758 _ = self.gettext 

1759 self.title = _("Group") 

1760 request = self.request 

1761 dbsession = request.dbsession 

1762 group_ids = request.user.ids_of_groups_user_may_manage_patients_in 

1763 groups = dbsession.query(Group).order_by(Group.name) 

1764 values = [(g.id, g.name) for g in groups if g.id in group_ids] 

1765 values, pv = get_values_and_permissible(values) 

1766 self.widget = SelectWidget(values=values) 

1767 self.validator = OneOf(pv) 

1768 

1769 @staticmethod 

1770 def schema_type() -> SchemaType: 

1771 return Integer() 

1772 

1773 

1774class MandatoryGroupIdSelectorOtherGroups(SchemaNode, RequestAwareMixin): 

1775 """ 

1776 Offers a picklist of groups THAT ARE NOT THE SPECIFIED GROUP (as specified 

1777 in ``kw[Binding.GROUP]``). 

1778 Used by superusers: "which other groups can this group see?" 

1779 """ 

1780 

1781 def __init__(self, *args, **kwargs) -> None: 

1782 self.title = "" # for type checker 

1783 self.validator = None # type: Optional[ValidatorType] 

1784 self.widget = None # type: Optional[Widget] 

1785 super().__init__(*args, **kwargs) 

1786 

1787 # noinspection PyUnusedLocal 

1788 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None: 

1789 _ = self.gettext 

1790 self.title = _("Other group") 

1791 request = self.request 

1792 group = kw[Binding.GROUP] # type: Group # ATYPICAL BINDING 

1793 dbsession = request.dbsession 

1794 groups = dbsession.query(Group).order_by(Group.name) 

1795 values = [(g.id, g.name) for g in groups if g.id != group.id] 

1796 values, pv = get_values_and_permissible(values) 

1797 self.widget = SelectWidget(values=values) 

1798 self.validator = OneOf(pv) 

1799 

1800 @staticmethod 

1801 def schema_type() -> SchemaType: 

1802 return Integer() 

1803 

1804 

1805class MandatoryGroupIdSelectorUserGroups(SchemaNode, RequestAwareMixin): 

1806 """ 

1807 Offers a picklist of groups from THOSE THE USER IS A MEMBER OF. 

1808 Used for: "which of your groups do you want to upload into?" 

1809 """ 

1810 

1811 def __init__(self, *args, **kwargs) -> None: 

1812 if not hasattr(self, "allow_none"): 

1813 # ... allows parameter-free (!) inheritance by 

1814 # OptionalGroupIdSelectorUserGroups 

1815 self.allow_none = False 

1816 self.title = "" # for type checker 

1817 self.validator = None # type: Optional[ValidatorType] 

1818 self.widget = None # type: Optional[Widget] 

1819 super().__init__(*args, **kwargs) 

1820 

1821 # noinspection PyUnusedLocal 

1822 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None: 

1823 _ = self.gettext 

1824 self.title = _("Group") 

1825 user = kw[Binding.USER] # type: User # ATYPICAL BINDING 

1826 groups = sorted(list(user.groups), key=lambda g: g.name) 

1827 values = [(g.id, g.name) for g in groups] 

1828 values, pv = get_values_and_permissible( 

1829 values, self.allow_none, _("[None]") 

1830 ) 

1831 self.widget = SelectWidget(values=values) 

1832 self.validator = OneOf(pv) 

1833 

1834 @staticmethod 

1835 def schema_type() -> SchemaType: 

1836 return Integer() 

1837 

1838 

1839class OptionalGroupIdSelectorUserGroups(MandatoryGroupIdSelectorUserGroups): 

1840 """ 

1841 Offers a picklist of groups from THOSE THE USER IS A MEMBER OF. 

1842 Used for "which do you want to upload into?". Optional. 

1843 """ 

1844 

1845 default = None 

1846 missing = None 

1847 

1848 def __init__(self, *args, **kwargs) -> None: 

1849 self.allow_none = True 

1850 super().__init__(*args, **kwargs) 

1851 

1852 @staticmethod 

1853 def schema_type() -> SchemaType: 

1854 return AllowNoneType(Integer()) 

1855 

1856 

1857class MandatoryGroupIdSelectorAllowedGroups(SchemaNode, RequestAwareMixin): 

1858 """ 

1859 Offers a picklist of groups from THOSE THE USER IS ALLOWED TO SEE. 

1860 Used for task filters. 

1861 """ 

1862 

1863 def __init__(self, *args, **kwargs) -> None: 

1864 self.title = "" # for type checker 

1865 self.validator = None # type: Optional[ValidatorType] 

1866 self.widget = None # type: Optional[Widget] 

1867 super().__init__(*args, **kwargs) 

1868 

1869 # noinspection PyUnusedLocal 

1870 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None: 

1871 _ = self.gettext 

1872 self.title = _("Group") 

1873 request = self.request 

1874 dbsession = request.dbsession 

1875 user = request.user 

1876 if user.superuser: 

1877 groups = dbsession.query(Group).order_by(Group.name) 

1878 else: 

1879 groups = sorted(list(user.groups), key=lambda g: g.name) 

1880 values = [(g.id, g.name) for g in groups] 

1881 values, pv = get_values_and_permissible(values) 

1882 self.widget = SelectWidget(values=values) 

1883 self.validator = OneOf(pv) 

1884 

1885 @staticmethod 

1886 def schema_type() -> SchemaType: 

1887 return Integer() 

1888 

1889 

1890class GroupsSequenceBase(SequenceSchema, RequestAwareMixin): 

1891 """ 

1892 Sequence schema to capture zero or more non-duplicate groups. 

1893 """ 

1894 

1895 def __init__(self, *args, minimum_number: int = 0, **kwargs) -> None: 

1896 self.title = "" # for type checker 

1897 self.minimum_number = minimum_number 

1898 self.widget = None # type: Optional[Widget] 

1899 super().__init__(*args, **kwargs) 

1900 

1901 # noinspection PyUnusedLocal 

1902 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None: 

1903 _ = self.gettext 

1904 self.title = _("Groups") 

1905 self.widget = TranslatableSequenceWidget(request=self.request) 

1906 

1907 # noinspection PyMethodMayBeStatic 

1908 def validator(self, node: SchemaNode, value: List[int]) -> None: 

1909 assert isinstance(value, list) 

1910 _ = self.gettext 

1911 if len(value) != len(set(value)): 

1912 raise Invalid(node, _("You have specified duplicate groups")) 

1913 if len(value) < self.minimum_number: 

1914 raise Invalid( 

1915 node, 

1916 _("You must specify at least {} group(s)").format( 

1917 self.minimum_number 

1918 ), 

1919 ) 

1920 

1921 

1922class AllGroupsSequence(GroupsSequenceBase): 

1923 """ 

1924 Sequence to offer a choice of all possible groups. 

1925 

1926 Typical use: superuser assigns group memberships to a user. 

1927 """ 

1928 

1929 group_id_sequence = MandatoryGroupIdSelectorAllGroups() 

1930 

1931 

1932class AdministeredGroupsSequence(GroupsSequenceBase): 

1933 """ 

1934 Sequence to offer a choice of the groups administered by the requestor. 

1935 

1936 Typical use: (non-superuser) group administrator assigns group memberships 

1937 to a user. 

1938 """ 

1939 

1940 group_id_sequence = MandatoryGroupIdSelectorAdministeredGroups() 

1941 

1942 def __init__(self, *args, **kwargs) -> None: 

1943 super().__init__(*args, minimum_number=1, **kwargs) 

1944 

1945 

1946class AllOtherGroupsSequence(GroupsSequenceBase): 

1947 """ 

1948 Sequence to offer a choice of all possible OTHER groups (as determined 

1949 relative to the group specified in ``kw[Binding.GROUP]``). 

1950 

1951 Typical use: superuser assigns group permissions to another group. 

1952 """ 

1953 

1954 group_id_sequence = MandatoryGroupIdSelectorOtherGroups() 

1955 

1956 

1957class AllowedGroupsSequence(GroupsSequenceBase): 

1958 """ 

1959 Sequence to offer a choice of all the groups the user is allowed to see. 

1960 """ 

1961 

1962 group_id_sequence = MandatoryGroupIdSelectorAllowedGroups() 

1963 

1964 def __init__(self, *args, **kwargs) -> None: 

1965 self.description = "" # for type checker 

1966 super().__init__(*args, **kwargs) 

1967 

1968 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None: 

1969 super().after_bind(node, kw) 

1970 self.description = self.or_join_description 

1971 

1972 

1973# ----------------------------------------------------------------------------- 

1974# Languages (strictly, locales) 

1975# ----------------------------------------------------------------------------- 

1976 

1977 

1978class LanguageSelector(SchemaNode, RequestAwareMixin): 

1979 """ 

1980 Node to choose a language code, from those supported by the server. 

1981 """ 

1982 

1983 _choices = POSSIBLE_LOCALES_WITH_DESCRIPTIONS 

1984 schema_type = String 

1985 default = DEFAULT_LOCALE 

1986 missing = DEFAULT_LOCALE 

1987 widget = SelectWidget(values=_choices) # intrinsically translated! 

1988 validator = OneOf(POSSIBLE_LOCALES) 

1989 

1990 def __init__(self, *args, **kwargs) -> None: 

1991 self.title = "" # for type checker 

1992 super().__init__(*args, **kwargs) 

1993 

1994 # noinspection PyUnusedLocal 

1995 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None: 

1996 _ = self.gettext 

1997 self.title = _("Group") 

1998 request = self.request # noqa: F841 

1999 self.title = _("Language") 

2000 

2001 

2002# ----------------------------------------------------------------------------- 

2003# Validating dangerous operations 

2004# ----------------------------------------------------------------------------- 

2005 

2006 

2007class HardWorkConfirmationSchema(CSRFSchema): 

2008 """ 

2009 Schema to make it hard to do something. We require a pattern of yes/no 

2010 answers before we will proceed. 

2011 """ 

2012 

2013 confirm_1_t = BooleanNode(default=False) 

2014 confirm_2_t = BooleanNode(default=True) 

2015 confirm_3_f = BooleanNode(default=True) 

2016 confirm_4_t = BooleanNode(default=False) 

2017 

2018 # noinspection PyUnusedLocal 

2019 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None: 

2020 _ = self.gettext 

2021 confirm_1_t = get_child_node(self, "confirm_1_t") 

2022 confirm_1_t.title = _("Really?") 

2023 confirm_2_t = get_child_node(self, "confirm_2_t") 

2024 # TRANSLATOR: string context described here 

2025 confirm_2_t.title = _("Leave ticked to confirm") 

2026 confirm_3_f = get_child_node(self, "confirm_3_f") 

2027 confirm_3_f.title = _("Please untick to confirm") 

2028 confirm_4_t = get_child_node(self, "confirm_4_t") 

2029 confirm_4_t.title = _("Be really sure; tick here also to confirm") 

2030 

2031 # noinspection PyMethodMayBeStatic 

2032 def validator(self, node: SchemaNode, value: Any) -> None: 

2033 if ( 

2034 (not value["confirm_1_t"]) 

2035 or (not value["confirm_2_t"]) 

2036 or value["confirm_3_f"] 

2037 or (not value["confirm_4_t"]) 

2038 ): 

2039 _ = self.gettext 

2040 raise Invalid(node, _("Not fully confirmed")) 

2041 

2042 

2043# ----------------------------------------------------------------------------- 

2044# URLs 

2045# ----------------------------------------------------------------------------- 

2046 

2047 

2048class HiddenRedirectionUrlNode(HiddenStringNode, RequestAwareMixin): 

2049 """ 

2050 Note to encode a hidden URL, for redirection. 

2051 """ 

2052 

2053 # noinspection PyMethodMayBeStatic 

2054 def validator(self, node: SchemaNode, value: str) -> None: 

2055 if value: 

2056 try: 

2057 validate_redirect_url(value, self.request) 

2058 except ValueError: 

2059 _ = self.gettext 

2060 raise Invalid(node, _("Invalid redirection URL")) 

2061 

2062 

2063# ----------------------------------------------------------------------------- 

2064# Phone number 

2065# ----------------------------------------------------------------------------- 

2066 

2067 

2068class PhoneNumberType(String): 

2069 def __init__(self, request: "CamcopsRequest", *args, **kwargs) -> None: 

2070 super().__init__(*args, **kwargs) 

2071 

2072 self.request = request 

2073 

2074 # noinspection PyMethodMayBeStatic, PyUnusedLocal 

2075 def deserialize( 

2076 self, node: SchemaNode, cstruct: Union[str, ColanderNullType, None] 

2077 ) -> Optional[phonenumbers.PhoneNumber]: 

2078 request = self.request # type: CamcopsRequest 

2079 _ = request.gettext 

2080 err_message = _("Invalid phone number") 

2081 

2082 # is null when form is empty 

2083 if not cstruct: 

2084 if not self.allow_empty: 

2085 raise Invalid(node, err_message) 

2086 return null 

2087 

2088 cstruct: str 

2089 

2090 try: 

2091 phone_number = phonenumbers.parse( 

2092 cstruct, request.config.region_code 

2093 ) 

2094 except phonenumbers.NumberParseException: 

2095 raise Invalid(node, err_message) 

2096 

2097 if not phonenumbers.is_valid_number(phone_number): 

2098 # the number may parse but could still be invalid 

2099 # (e.g. too few digits) 

2100 raise Invalid(node, err_message) 

2101 

2102 return phone_number 

2103 

2104 # noinspection PyMethodMayBeStatic,PyUnusedLocal 

2105 def serialize( 

2106 self, 

2107 node: SchemaNode, 

2108 appstruct: Union[phonenumbers.PhoneNumber, None, ColanderNullType], 

2109 ) -> Union[str, ColanderNullType]: 

2110 # is None when populated from empty value in the database 

2111 if not appstruct: 

2112 return null 

2113 

2114 # appstruct should be well formed here (it would already have failed 

2115 # when reading from the database) 

2116 return phonenumbers.format_number( 

2117 appstruct, phonenumbers.PhoneNumberFormat.E164 

2118 ) 

2119 

2120 

2121class MandatoryPhoneNumberNode(MandatoryStringNode, RequestAwareMixin): 

2122 default = None 

2123 missing = None 

2124 

2125 # noinspection PyUnusedLocal 

2126 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None: 

2127 _ = self.gettext 

2128 self.title = _("Phone number") 

2129 self.typ = PhoneNumberType(self.request, allow_empty=False) 

2130 

2131 

2132# ============================================================================= 

2133# Login 

2134# ============================================================================= 

2135 

2136 

2137class LoginSchema(CSRFSchema): 

2138 """ 

2139 Schema to capture login details. 

2140 """ 

2141 

2142 username = UsernameNode( 

2143 autocomplete=AutocompleteAttrValues.USERNAME 

2144 ) # name must match ViewParam.USERNAME 

2145 password = SchemaNode( # name must match ViewParam.PASSWORD 

2146 String(), 

2147 widget=PasswordWidget( 

2148 attributes={ 

2149 AUTOCOMPLETE_ATTR: AutocompleteAttrValues.CURRENT_PASSWORD 

2150 } 

2151 ), 

2152 ) 

2153 redirect_url = ( 

2154 HiddenRedirectionUrlNode() 

2155 ) # name must match ViewParam.REDIRECT_URL 

2156 

2157 def __init__( 

2158 self, *args, autocomplete_password: bool = True, **kwargs 

2159 ) -> None: 

2160 self.autocomplete_password = autocomplete_password 

2161 super().__init__(*args, **kwargs) 

2162 

2163 # noinspection PyUnusedLocal 

2164 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None: 

2165 _ = self.gettext 

2166 password = get_child_node(self, "password") 

2167 password.title = _("Password") 

2168 password.widget.attributes[AUTOCOMPLETE_ATTR] = ( 

2169 AutocompleteAttrValues.CURRENT_PASSWORD 

2170 if self.autocomplete_password 

2171 else AutocompleteAttrValues.OFF 

2172 ) 

2173 

2174 

2175class LoginForm(InformativeNonceForm): 

2176 """ 

2177 Form to capture login details. 

2178 """ 

2179 

2180 def __init__( 

2181 self, 

2182 request: "CamcopsRequest", 

2183 autocomplete_password: bool = True, 

2184 **kwargs, 

2185 ) -> None: 

2186 """ 

2187 Args: 

2188 autocomplete_password: 

2189 suggest to the browser that it's OK to store the password for 

2190 autocompletion? Note that browsers may ignore this. 

2191 """ 

2192 _ = request.gettext 

2193 schema = LoginSchema(autocomplete_password=autocomplete_password).bind( 

2194 request=request 

2195 ) 

2196 super().__init__( 

2197 schema, 

2198 buttons=[Button(name=FormAction.SUBMIT, title=_("Log in"))], 

2199 # autocomplete=autocomplete_password, 

2200 **kwargs, 

2201 ) 

2202 # Suboptimal: autocomplete_password is not applied to the password 

2203 # widget, just to the form; see 

2204 # http://stackoverflow.com/questions/2530 

2205 # Note that e.g. Chrome may ignore this. 

2206 # ... fixed 2020-09-29 by applying autocomplete to LoginSchema.password 

2207 

2208 

2209class OtpSchema(CSRFSchema): 

2210 """ 

2211 Schema to capture a one-time password for Multi-factor Authentication. 

2212 """ 

2213 

2214 one_time_password = MandatoryStringNode() 

2215 redirect_url = ( 

2216 HiddenRedirectionUrlNode() 

2217 ) # name must match ViewParam.REDIRECT_URL 

2218 

2219 # noinspection PyUnusedLocal 

2220 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None: 

2221 _ = self.gettext 

2222 one_time_password = get_child_node(self, "one_time_password") 

2223 one_time_password.title = _("Enter the six-digit code") 

2224 

2225 

2226class OtpTokenForm(InformativeNonceForm): 

2227 """ 

2228 Form to capture a one-time password for Multi-factor authentication. 

2229 """ 

2230 

2231 def __init__(self, request: "CamcopsRequest", **kwargs) -> None: 

2232 _ = request.gettext 

2233 schema = OtpSchema().bind(request=request) 

2234 super().__init__( 

2235 schema, 

2236 buttons=[Button(name=FormAction.SUBMIT, title=_("Submit"))], 

2237 **kwargs, 

2238 ) 

2239 

2240 

2241# ============================================================================= 

2242# Change password 

2243# ============================================================================= 

2244 

2245 

2246class MustChangePasswordNode(SchemaNode, RequestAwareMixin): 

2247 """ 

2248 Boolean node: must the user change their password? 

2249 """ 

2250 

2251 schema_type = Boolean 

2252 default = True 

2253 missing = True 

2254 

2255 def __init__(self, *args, **kwargs) -> None: 

2256 self.label = "" # for type checker 

2257 self.title = "" # for type checker 

2258 super().__init__(*args, **kwargs) 

2259 

2260 # noinspection PyUnusedLocal 

2261 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None: 

2262 _ = self.gettext 

2263 self.label = _("User must change password at next login") 

2264 self.title = _("Must change password at next login?") 

2265 

2266 

2267class OldUserPasswordCheck(SchemaNode, RequestAwareMixin): 

2268 """ 

2269 Schema to capture an old password (for when a password is being changed). 

2270 """ 

2271 

2272 schema_type = String 

2273 widget = PasswordWidget( 

2274 attributes={AUTOCOMPLETE_ATTR: AutocompleteAttrValues.CURRENT_PASSWORD} 

2275 ) 

2276 

2277 def __init__(self, *args, **kwargs) -> None: 

2278 self.title = "" # for type checker 

2279 super().__init__(*args, **kwargs) 

2280 

2281 # noinspection PyUnusedLocal 

2282 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None: 

2283 _ = self.gettext 

2284 self.title = _("Old password") 

2285 

2286 def validator(self, node: SchemaNode, value: str) -> None: 

2287 request = self.request 

2288 user = request.user 

2289 assert user is not None 

2290 if not user.is_password_correct(value): 

2291 _ = request.gettext 

2292 raise Invalid(node, _("Old password incorrect")) 

2293 

2294 

2295class InformationalCheckedPasswordWidget(CheckedPasswordWidget): 

2296 """ 

2297 A more verbose version of Deform's CheckedPasswordWidget 

2298 which provides advice on good passwords. 

2299 """ 

2300 

2301 basedir = os.path.join(TEMPLATE_DIR, "deform") 

2302 readonlydir = os.path.join(basedir, "readonly") 

2303 form = "informational_checked_password.pt" 

2304 template = os.path.join(basedir, form) 

2305 readonly_template = os.path.join(readonlydir, form) 

2306 

2307 def __init__(self, request: "CamcopsRequest", **kwargs: Any) -> None: 

2308 super().__init__(**kwargs) 

2309 self.request = request 

2310 

2311 def get_template_values( 

2312 self, field: "Field", cstruct: str, kw: Dict[str, Any] 

2313 ) -> Dict[str, Any]: 

2314 values = super().get_template_values(field, cstruct, kw) 

2315 

2316 _ = self.request.gettext 

2317 

2318 href = "https://www.ncsc.gov.uk/blog-post/three-random-words-or-thinkrandom-0" # noqa: E501 

2319 link = f'<a href="{href}">{href}</a>' 

2320 password_advice = _("Choose strong passphrases. See {link}").format( 

2321 link=link 

2322 ) 

2323 min_password_length = _( 

2324 "Minimum password length is {limit} " "characters." 

2325 ).format(limit=MINIMUM_PASSWORD_LENGTH) 

2326 

2327 values.update( 

2328 password_advice=password_advice, 

2329 min_password_length=min_password_length, 

2330 ) 

2331 

2332 return values 

2333 

2334 

2335class NewPasswordNode(SchemaNode, RequestAwareMixin): 

2336 """ 

2337 Node to enter a new password. 

2338 """ 

2339 

2340 schema_type = String 

2341 

2342 def __init__(self, *args, **kwargs) -> None: 

2343 self.title = "" # for type checker 

2344 self.description = "" # for type checker 

2345 super().__init__(*args, **kwargs) 

2346 

2347 # noinspection PyUnusedLocal 

2348 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None: 

2349 _ = self.gettext 

2350 self.title = _("New password") 

2351 self.description = _("Type the new password and confirm it") 

2352 self.widget = InformationalCheckedPasswordWidget( 

2353 self.request, 

2354 attributes={ 

2355 AUTOCOMPLETE_ATTR: AutocompleteAttrValues.NEW_PASSWORD 

2356 }, 

2357 ) 

2358 

2359 def validator(self, node: SchemaNode, value: str) -> None: 

2360 try: 

2361 validate_new_password(value, self.request) 

2362 except ValueError as e: 

2363 raise Invalid(node, str(e)) 

2364 

2365 

2366class ChangeOwnPasswordSchema(CSRFSchema): 

2367 """ 

2368 Schema to change one's own password. 

2369 """ 

2370 

2371 old_password = OldUserPasswordCheck() 

2372 new_password = NewPasswordNode() # name must match ViewParam.NEW_PASSWORD 

2373 

2374 def __init__(self, *args, must_differ: bool = True, **kwargs) -> None: 

2375 """ 

2376 Args: 

2377 must_differ: 

2378 must the new password be different from the old one? 

2379 """ 

2380 self.must_differ = must_differ 

2381 super().__init__(*args, **kwargs) 

2382 

2383 def validator(self, node: SchemaNode, value: Dict[str, str]) -> None: 

2384 if self.must_differ and value["new_password"] == value["old_password"]: 

2385 _ = self.gettext 

2386 raise Invalid(node, _("New password must differ from old")) 

2387 

2388 

2389class ChangeOwnPasswordForm(InformativeNonceForm): 

2390 """ 

2391 Form to change one's own password. 

2392 """ 

2393 

2394 def __init__( 

2395 self, request: "CamcopsRequest", must_differ: bool = True, **kwargs 

2396 ) -> None: 

2397 """ 

2398 Args: 

2399 must_differ: 

2400 must the new password be different from the old one? 

2401 """ 

2402 schema = ChangeOwnPasswordSchema(must_differ=must_differ).bind( 

2403 request=request 

2404 ) 

2405 super().__init__( 

2406 schema, 

2407 buttons=[ 

2408 Button( 

2409 name=FormAction.SUBMIT, 

2410 title=change_password_title(request), 

2411 ) 

2412 ], 

2413 **kwargs, 

2414 ) 

2415 

2416 

2417class ChangeOtherPasswordSchema(CSRFSchema): 

2418 """ 

2419 Schema to change another user's password. 

2420 """ 

2421 

2422 user_id = HiddenIntegerNode() # name must match ViewParam.USER_ID 

2423 must_change_password = ( 

2424 MustChangePasswordNode() 

2425 ) # match ViewParam.MUST_CHANGE_PASSWORD 

2426 new_password = NewPasswordNode() # name must match ViewParam.NEW_PASSWORD 

2427 

2428 

2429class ChangeOtherPasswordForm(SimpleSubmitForm): 

2430 """ 

2431 Form to change another user's password. 

2432 """ 

2433 

2434 def __init__(self, request: "CamcopsRequest", **kwargs) -> None: 

2435 _ = request.gettext 

2436 super().__init__( 

2437 schema_class=ChangeOtherPasswordSchema, 

2438 submit_title=_("Submit"), 

2439 request=request, 

2440 **kwargs, 

2441 ) 

2442 

2443 

2444class DisableMfaNode(SchemaNode, RequestAwareMixin): 

2445 """ 

2446 Boolean node: disable multi-factor authentication 

2447 """ 

2448 

2449 schema_type = Boolean 

2450 default = False 

2451 missing = False 

2452 

2453 def __init__(self, *args, **kwargs) -> None: 

2454 self.label = "" # for type checker 

2455 self.title = "" # for type checker 

2456 super().__init__(*args, **kwargs) 

2457 

2458 # noinspection PyUnusedLocal 

2459 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None: 

2460 _ = self.gettext 

2461 self.label = _("Disable multi-factor authentication") 

2462 self.title = _("Disable multi-factor authentication?") 

2463 

2464 

2465class EditOtherUserMfaSchema(CSRFSchema): 

2466 """ 

2467 Schema to reset multi-factor authentication for another user. 

2468 """ 

2469 

2470 user_id = HiddenIntegerNode() # name must match ViewParam.USER_ID 

2471 disable_mfa = DisableMfaNode() # match ViewParam.DISABLE_MFA 

2472 

2473 

2474class EditOtherUserMfaForm(SimpleSubmitForm): 

2475 """ 

2476 Form to reset multi-factor authentication for another user. 

2477 """ 

2478 

2479 def __init__(self, request: "CamcopsRequest", **kwargs) -> None: 

2480 _ = request.gettext 

2481 super().__init__( 

2482 schema_class=EditOtherUserMfaSchema, 

2483 submit_title=_("Submit"), 

2484 request=request, 

2485 **kwargs, 

2486 ) 

2487 

2488 

2489# ============================================================================= 

2490# Multi-factor authentication 

2491# ============================================================================= 

2492 

2493 

2494class MfaSecretWidget(TextInputWidget): 

2495 """ 

2496 Display the TOTP (authorization app) secret as a QR code and alphanumeric 

2497 string. 

2498 """ 

2499 

2500 basedir = os.path.join(TEMPLATE_DIR, "deform") 

2501 readonlydir = os.path.join(basedir, "readonly") 

2502 form = "mfa_secret.pt" 

2503 template = os.path.join(basedir, form) 

2504 readonly_template = os.path.join(readonlydir, form) 

2505 

2506 def __init__(self, request: "CamcopsRequest", **kwargs: Any) -> None: 

2507 super().__init__(**kwargs) 

2508 self.request = request 

2509 

2510 def serialize(self, field: "Field", cstruct: str, **kw: Any) -> Any: 

2511 # cstruct contains the MFA secret key 

2512 readonly = kw.get("readonly", self.readonly) 

2513 template = readonly and self.readonly_template or self.template 

2514 values = self.get_template_values(field, cstruct, kw) 

2515 

2516 _ = self.request.gettext 

2517 

2518 factory = qrcode.image.svg.SvgImage 

2519 totp = pyotp.totp.TOTP(cstruct) 

2520 uri = totp.provisioning_uri( 

2521 name=self.request.user.username, issuer_name="CamCOPS" 

2522 ) 

2523 img = qrcode.make(uri, image_factory=factory, box_size=20) 

2524 stream = BytesIO() 

2525 img.save(stream) 

2526 values.update( 

2527 open_app=_("Open your authentication app."), 

2528 scan_qr_code=_("Add CamCOPS to the app by scanning this QR code:"), 

2529 qr_code=stream.getvalue().decode(), 

2530 enter_key=_( 

2531 "If you can't scan the QR code, enter this key " "instead:" 

2532 ), 

2533 enter_code=_( 

2534 "When prompted, enter the 6-digit code displayed on " 

2535 "the app." 

2536 ), 

2537 ) 

2538 

2539 return field.renderer(template, **values) 

2540 

2541 

2542class MfaSecretNode(OptionalStringNode, RequestAwareMixin): 

2543 """ 

2544 Node to display the TOTP (authorization app) secret as a QR code and 

2545 alphanumeric string. 

2546 """ 

2547 

2548 schema_type = String 

2549 

2550 # noinspection PyUnusedLocal,PyAttributeOutsideInit 

2551 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None: 

2552 self.widget = MfaSecretWidget(self.request) 

2553 

2554 

2555class MfaMethodSelector(SchemaNode, RequestAwareMixin): 

2556 """ 

2557 Node to select type of authentication 

2558 """ 

2559 

2560 schema_type = String 

2561 default = MfaMethod.TOTP 

2562 missing = MfaMethod.TOTP 

2563 

2564 def __init__(self, *args, **kwargs) -> None: 

2565 self.title = "" # for type checker 

2566 self.widget = None # type: Optional[Widget] 

2567 self.validator = None # type: Optional[ValidatorType] 

2568 super().__init__(*args, **kwargs) 

2569 

2570 # noinspection PyUnusedLocal 

2571 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None: 

2572 _ = self.gettext 

2573 self.title = _("Authentication type") 

2574 request = self.bindings[Binding.REQUEST] # type: CamcopsRequest 

2575 all_mfa_choices = [ 

2576 ( 

2577 MfaMethod.TOTP, 

2578 _("Use an app such as Google Authenticator or Twilio Authy"), 

2579 ), 

2580 (MfaMethod.HOTP_EMAIL, _("Send me a code by email")), 

2581 (MfaMethod.HOTP_SMS, _("Send me a code by text message")), 

2582 (MfaMethod.NO_MFA, _("Disable multi-factor authentication")), 

2583 ] 

2584 

2585 choices = [] 

2586 for (label, description) in all_mfa_choices: 

2587 if label in request.config.mfa_methods: 

2588 choices.append((label, description)) 

2589 values, pv = get_values_and_permissible(choices) 

2590 

2591 self.widget = RadioChoiceWidget(values=values) 

2592 self.validator = OneOf(pv) 

2593 

2594 

2595class MfaMethodSchema(CSRFSchema): 

2596 """ 

2597 Schema to edit Multi-factor Authentication method. 

2598 """ 

2599 

2600 mfa_method = MfaMethodSelector() # must match ViewParam.MFA_METHOD 

2601 

2602 # noinspection PyUnusedLocal 

2603 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None: 

2604 _ = self.gettext 

2605 mfa_method = get_child_node(self, "mfa_method") 

2606 mfa_method.title = _("How do you wish to authenticate?") 

2607 

2608 

2609class MfaTotpSchema(CSRFSchema): 

2610 """ 

2611 Schema to set up Multi-factor Authentication with authentication app. 

2612 """ 

2613 

2614 mfa_secret_key = MfaSecretNode() # must match ViewParam.MFA_SECRET_KEY 

2615 

2616 # noinspection PyUnusedLocal 

2617 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None: 

2618 _ = self.gettext 

2619 mfa_secret_key = get_child_node(self, "mfa_secret_key") 

2620 mfa_secret_key.title = _("Follow these steps:") 

2621 

2622 

2623class MfaHotpEmailSchema(CSRFSchema): 

2624 """ 

2625 Schema to change a user's email address for multi-factor authentication. 

2626 """ 

2627 

2628 mfa_secret_key = HiddenStringNode() # must match ViewParam.MFA_SECRET_KEY 

2629 email = MandatoryEmailNode() # must match ViewParam.EMAIL 

2630 

2631 # noinspection PyUnusedLocal 

2632 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None: 

2633 _ = self.gettext 

2634 

2635 

2636class MfaHotpSmsSchema(CSRFSchema): 

2637 """ 

2638 Schema to change a user's phone number for multi-factor authentication. 

2639 """ 

2640 

2641 mfa_secret_key = HiddenStringNode() # must match ViewParam.MFA_SECRET_KEY 

2642 phone_number = ( 

2643 MandatoryPhoneNumberNode() 

2644 ) # must match ViewParam.PHONE_NUMBER 

2645 

2646 # noinspection PyUnusedLocal 

2647 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None: 

2648 _ = self.gettext 

2649 phone_number = get_child_node(self, ViewParam.PHONE_NUMBER) 

2650 phone_number.description = _( 

2651 "Include the country code (e.g. +123) for numbers outside of the " 

2652 "'{region_code}' region" 

2653 ).format(region_code=self.request.config.region_code) 

2654 

2655 

2656class MfaMethodForm(InformativeNonceForm): 

2657 """ 

2658 Form to change one's own Multi-factor Authentication settings. 

2659 """ 

2660 

2661 def __init__(self, request: "CamcopsRequest", **kwargs) -> None: 

2662 schema = MfaMethodSchema().bind(request=request) 

2663 super().__init__( 

2664 schema, buttons=[Button(name=FormAction.SUBMIT)], **kwargs 

2665 ) 

2666 

2667 

2668class MfaTotpForm(InformativeNonceForm): 

2669 """ 

2670 Form to set up Multi-factor Authentication with authentication app. 

2671 """ 

2672 

2673 def __init__(self, request: "CamcopsRequest", **kwargs) -> None: 

2674 schema = MfaTotpSchema().bind(request=request) 

2675 super().__init__( 

2676 schema, buttons=[Button(name=FormAction.SUBMIT)], **kwargs 

2677 ) 

2678 

2679 

2680class MfaHotpEmailForm(InformativeNonceForm): 

2681 """ 

2682 Form to change a user's email address for multi-factor authentication. 

2683 """ 

2684 

2685 def __init__(self, request: "CamcopsRequest", **kwargs) -> None: 

2686 schema = MfaHotpEmailSchema().bind(request=request) 

2687 super().__init__( 

2688 schema, buttons=[Button(name=FormAction.SUBMIT)], **kwargs 

2689 ) 

2690 

2691 

2692class MfaHotpSmsForm(InformativeNonceForm): 

2693 """ 

2694 Form to change a user's phone number for multi-factor authentication. 

2695 """ 

2696 

2697 def __init__(self, request: "CamcopsRequest", **kwargs) -> None: 

2698 schema = MfaHotpSmsSchema().bind(request=request) 

2699 super().__init__( 

2700 schema, buttons=[Button(name=FormAction.SUBMIT)], **kwargs 

2701 ) 

2702 

2703 

2704# ============================================================================= 

2705# Offer/agree terms 

2706# ============================================================================= 

2707 

2708 

2709class OfferTermsSchema(CSRFSchema): 

2710 """ 

2711 Schema to offer terms and ask the user to accept them. 

2712 """ 

2713 

2714 pass 

2715 

2716 

2717class OfferTermsForm(SimpleSubmitForm): 

2718 """ 

2719 Form to offer terms and ask the user to accept them. 

2720 """ 

2721 

2722 def __init__( 

2723 self, request: "CamcopsRequest", agree_button_text: str, **kwargs 

2724 ) -> None: 

2725 """ 

2726 Args: 

2727 agree_button_text: 

2728 text for the "agree" button 

2729 """ 

2730 super().__init__( 

2731 schema_class=OfferTermsSchema, 

2732 submit_title=agree_button_text, 

2733 request=request, 

2734 **kwargs, 

2735 ) 

2736 

2737 

2738# ============================================================================= 

2739# View audit trail 

2740# ============================================================================= 

2741 

2742 

2743class OptionalIPAddressNode(OptionalStringNode, RequestAwareMixin): 

2744 """ 

2745 Optional IPv4 or IPv6 address. 

2746 """ 

2747 

2748 def validator(self, node: SchemaNode, value: str) -> None: 

2749 try: 

2750 validate_ip_address(value, self.request) 

2751 except ValueError as e: 

2752 raise Invalid(node, e) 

2753 

2754 

2755class OptionalAuditSourceNode(OptionalStringNode, RequestAwareMixin): 

2756 """ 

2757 Optional IPv4 or IPv6 address. 

2758 """ 

2759 

2760 def validator(self, node: SchemaNode, value: str) -> None: 

2761 try: 

2762 validate_by_char_and_length( 

2763 value, 

2764 permitted_char_expression=ALPHANUM_UNDERSCORE_CHAR, 

2765 min_length=0, 

2766 max_length=StringLengths.AUDIT_SOURCE_MAX_LEN, 

2767 req=self.request, 

2768 ) 

2769 except ValueError as e: 

2770 raise Invalid(node, e) 

2771 

2772 

2773class AuditTrailSchema(CSRFSchema): 

2774 """ 

2775 Schema to filter audit trail entries. 

2776 """ 

2777 

2778 rows_per_page = RowsPerPageSelector() # must match ViewParam.ROWS_PER_PAGE 

2779 start_datetime = ( 

2780 StartPendulumSelector() 

2781 ) # must match ViewParam.START_DATETIME # noqa 

2782 end_datetime = EndPendulumSelector() # must match ViewParam.END_DATETIME 

2783 source = OptionalAuditSourceNode() # must match ViewParam.SOURCE # noqa 

2784 remote_ip_addr = ( 

2785 OptionalIPAddressNode() 

2786 ) # must match ViewParam.REMOTE_IP_ADDR # noqa 

2787 username = ( 

2788 OptionalUserNameSelector() 

2789 ) # must match ViewParam.USERNAME # noqa 

2790 table_name = ( 

2791 OptionalSingleTaskSelector() 

2792 ) # must match ViewParam.TABLENAME # noqa 

2793 server_pk = ServerPkSelector() # must match ViewParam.SERVER_PK 

2794 truncate = BooleanNode(default=True) # must match ViewParam.TRUNCATE 

2795 

2796 # noinspection PyUnusedLocal 

2797 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None: 

2798 _ = self.gettext 

2799 source = get_child_node(self, "source") 

2800 source.title = _("Source (e.g. webviewer, tablet, console)") 

2801 remote_ip_addr = get_child_node(self, "remote_ip_addr") 

2802 remote_ip_addr.title = _("Remote IP address") 

2803 truncate = get_child_node(self, "truncate") 

2804 truncate.title = _("Truncate details for easy viewing") 

2805 

2806 

2807class AuditTrailForm(SimpleSubmitForm): 

2808 """ 

2809 Form to filter and then view audit trail entries. 

2810 """ 

2811 

2812 def __init__(self, request: "CamcopsRequest", **kwargs) -> None: 

2813 _ = request.gettext 

2814 super().__init__( 

2815 schema_class=AuditTrailSchema, 

2816 submit_title=_("View audit trail"), 

2817 request=request, 

2818 **kwargs, 

2819 ) 

2820 

2821 

2822# ============================================================================= 

2823# View export logs 

2824# ============================================================================= 

2825 

2826 

2827class OptionalExportRecipientNameSelector( 

2828 OptionalStringNode, RequestAwareMixin 

2829): 

2830 """ 

2831 Optional node to pick an export recipient name from those present in the 

2832 database. 

2833 """ 

2834 

2835 title = "Export recipient" 

2836 

2837 def __init__(self, *args, **kwargs) -> None: 

2838 self.validator = None # type: Optional[ValidatorType] 

2839 self.widget = None # type: Optional[Widget] 

2840 super().__init__(*args, **kwargs) 

2841 

2842 # noinspection PyUnusedLocal 

2843 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None: 

2844 from camcops_server.cc_modules.cc_exportrecipient import ( 

2845 ExportRecipient, 

2846 ) # delayed import 

2847 

2848 request = self.request 

2849 _ = request.gettext 

2850 dbsession = request.dbsession 

2851 q = ( 

2852 dbsession.query(ExportRecipient.recipient_name) 

2853 .distinct() 

2854 .order_by(ExportRecipient.recipient_name) 

2855 ) 

2856 values = [] # type: List[Tuple[str, str]] 

2857 for row in q: 

2858 recipient_name = row[0] 

2859 values.append((recipient_name, recipient_name)) 

2860 values, pv = get_values_and_permissible(values, True, _("[Any]")) 

2861 self.widget = SelectWidget(values=values) 

2862 self.validator = OneOf(pv) 

2863 

2864 

2865class ExportedTaskListSchema(CSRFSchema): 

2866 """ 

2867 Schema to filter HL7 message logs. 

2868 """ 

2869 

2870 rows_per_page = RowsPerPageSelector() # must match ViewParam.ROWS_PER_PAGE 

2871 recipient_name = ( 

2872 OptionalExportRecipientNameSelector() 

2873 ) # must match ViewParam.RECIPIENT_NAME # noqa 

2874 table_name = ( 

2875 OptionalSingleTaskSelector() 

2876 ) # must match ViewParam.TABLENAME # noqa 

2877 server_pk = ServerPkSelector() # must match ViewParam.SERVER_PK 

2878 id = OptionalIntNode() # must match ViewParam.ID # noqa 

2879 start_datetime = ( 

2880 StartDateTimeSelector() 

2881 ) # must match ViewParam.START_DATETIME # noqa 

2882 end_datetime = EndDateTimeSelector() # must match ViewParam.END_DATETIME 

2883 

2884 # noinspection PyUnusedLocal 

2885 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None: 

2886 _ = self.gettext 

2887 id_ = get_child_node(self, "id") 

2888 id_.title = _("ExportedTask ID") 

2889 

2890 

2891class ExportedTaskListForm(SimpleSubmitForm): 

2892 """ 

2893 Form to filter and then view exported task logs. 

2894 """ 

2895 

2896 def __init__(self, request: "CamcopsRequest", **kwargs) -> None: 

2897 _ = request.gettext 

2898 super().__init__( 

2899 schema_class=ExportedTaskListSchema, 

2900 submit_title=_("View exported task log"), 

2901 request=request, 

2902 **kwargs, 

2903 ) 

2904 

2905 

2906# ============================================================================= 

2907# Task filters 

2908# ============================================================================= 

2909 

2910 

2911class TextContentsSequence(SequenceSchema, RequestAwareMixin): 

2912 """ 

2913 Sequence to capture multiple pieces of text (representing text contents 

2914 for a task filter). 

2915 """ 

2916 

2917 text_sequence = SchemaNode( 

2918 String(), validator=Length(0, StringLengths.FILTER_TEXT_MAX_LEN) 

2919 ) # BEWARE: fairly unrestricted contents. 

2920 

2921 def __init__(self, *args, **kwargs) -> None: 

2922 self.title = "" # for type checker 

2923 self.description = "" # for type checker 

2924 self.widget = None # type: Optional[Widget] 

2925 super().__init__(*args, **kwargs) 

2926 

2927 # noinspection PyUnusedLocal 

2928 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None: 

2929 _ = self.gettext 

2930 self.title = _("Text contents") 

2931 self.description = self.or_join_description 

2932 self.widget = TranslatableSequenceWidget(request=self.request) 

2933 # Now it'll say "[Add]" Text Sequence because it'll make the string 

2934 # "Text Sequence" from the name of text_sequence. Unless we do this: 

2935 text_sequence = get_child_node(self, "text_sequence") 

2936 # TRANSLATOR: For the task filter form: the text in "Add text" 

2937 text_sequence.title = _("text") 

2938 

2939 # noinspection PyMethodMayBeStatic 

2940 def validator(self, node: SchemaNode, value: List[str]) -> None: 

2941 assert isinstance(value, list) 

2942 if len(value) != len(set(value)): 

2943 _ = self.gettext 

2944 raise Invalid(node, _("You have specified duplicate text filters")) 

2945 

2946 

2947class UploadingUserSequence(SequenceSchema, RequestAwareMixin): 

2948 """ 

2949 Sequence to capture multiple users (for task filters: "uploaded by one of 

2950 the following users..."). 

2951 """ 

2952 

2953 user_id_sequence = MandatoryUserIdSelectorUsersAllowedToSee() 

2954 

2955 def __init__(self, *args, **kwargs) -> None: 

2956 self.title = "" # for type checker 

2957 self.description = "" # for type checker 

2958 self.widget = None # type: Optional[Widget] 

2959 super().__init__(*args, **kwargs) 

2960 

2961 # noinspection PyUnusedLocal 

2962 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None: 

2963 _ = self.gettext 

2964 self.title = _("Uploading users") 

2965 self.description = self.or_join_description 

2966 self.widget = TranslatableSequenceWidget(request=self.request) 

2967 

2968 # noinspection PyMethodMayBeStatic 

2969 def validator(self, node: SchemaNode, value: List[int]) -> None: 

2970 assert isinstance(value, list) 

2971 if len(value) != len(set(value)): 

2972 _ = self.gettext 

2973 raise Invalid(node, _("You have specified duplicate users")) 

2974 

2975 

2976class DevicesSequence(SequenceSchema, RequestAwareMixin): 

2977 """ 

2978 Sequence to capture multiple client devices (for task filters: "uploaded by 

2979 one of the following devices..."). 

2980 """ 

2981 

2982 device_id_sequence = MandatoryDeviceIdSelector() 

2983 

2984 def __init__(self, *args, **kwargs) -> None: 

2985 self.title = "" # for type checker 

2986 self.description = "" # for type checker 

2987 self.widget = None # type: Optional[Widget] 

2988 super().__init__(*args, **kwargs) 

2989 

2990 # noinspection PyUnusedLocal 

2991 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None: 

2992 _ = self.gettext 

2993 self.title = _("Uploading devices") 

2994 self.description = self.or_join_description 

2995 self.widget = TranslatableSequenceWidget(request=self.request) 

2996 

2997 # noinspection PyMethodMayBeStatic 

2998 def validator(self, node: SchemaNode, value: List[int]) -> None: 

2999 assert isinstance(value, list) 

3000 if len(value) != len(set(value)): 

3001 raise Invalid(node, "You have specified duplicate devices") 

3002 

3003 

3004class OptionalPatientNameNode(OptionalStringNode, RequestAwareMixin): 

3005 def validator(self, node: SchemaNode, value: str) -> None: 

3006 try: 

3007 # TODO: Validating human names is hard. 

3008 # Decide if validation here is necessary and whether it should 

3009 # be configurable. 

3010 # validate_human_name(value, self.request) 

3011 

3012 # Does nothing but better to be explicit 

3013 validate_anything(value, self.request) 

3014 except ValueError as e: 

3015 # Should never happen with validate_anything 

3016 raise Invalid(node, str(e)) 

3017 

3018 

3019class EditTaskFilterWhoSchema(Schema, RequestAwareMixin): 

3020 """ 

3021 Schema to edit the "who" parts of a task filter. 

3022 """ 

3023 

3024 surname = OptionalPatientNameNode() # must match ViewParam.SURNAME # noqa 

3025 forename = ( 

3026 OptionalPatientNameNode() 

3027 ) # must match ViewParam.FORENAME # noqa 

3028 dob = SchemaNode(Date(), missing=None) # must match ViewParam.DOB 

3029 sex = OptionalSexSelector() # must match ViewParam.SEX 

3030 id_references = ( 

3031 IdNumSequenceAnyCombination() 

3032 ) # must match ViewParam.ID_REFERENCES # noqa 

3033 

3034 # noinspection PyUnusedLocal 

3035 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None: 

3036 _ = self.gettext 

3037 surname = get_child_node(self, "surname") 

3038 surname.title = _("Surname") 

3039 forename = get_child_node(self, "forename") 

3040 forename.title = _("Forename") 

3041 dob = get_child_node(self, "dob") 

3042 dob.title = _("Date of birth") 

3043 id_references = get_child_node(self, "id_references") 

3044 id_references.description = self.or_join_description 

3045 

3046 

3047class EditTaskFilterWhenSchema(Schema): 

3048 """ 

3049 Schema to edit the "when" parts of a task filter. 

3050 """ 

3051 

3052 start_datetime = ( 

3053 StartPendulumSelector() 

3054 ) # must match ViewParam.START_DATETIME # noqa 

3055 end_datetime = EndPendulumSelector() # must match ViewParam.END_DATETIME 

3056 

3057 

3058class EditTaskFilterWhatSchema(Schema, RequestAwareMixin): 

3059 """ 

3060 Schema to edit the "what" parts of a task filter. 

3061 """ 

3062 

3063 text_contents = ( 

3064 TextContentsSequence() 

3065 ) # must match ViewParam.TEXT_CONTENTS # noqa 

3066 complete_only = BooleanNode( 

3067 default=False 

3068 ) # must match ViewParam.COMPLETE_ONLY # noqa 

3069 tasks = MultiTaskSelector() # must match ViewParam.TASKS 

3070 

3071 # noinspection PyUnusedLocal 

3072 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None: 

3073 _ = self.gettext 

3074 complete_only = get_child_node(self, "complete_only") 

3075 only_completed_text = _("Only completed tasks?") 

3076 complete_only.title = only_completed_text 

3077 complete_only.label = only_completed_text 

3078 

3079 

3080class EditTaskFilterAdminSchema(Schema): 

3081 """ 

3082 Schema to edit the "admin" parts of a task filter. 

3083 """ 

3084 

3085 device_ids = DevicesSequence() # must match ViewParam.DEVICE_IDS 

3086 user_ids = UploadingUserSequence() # must match ViewParam.USER_IDS 

3087 group_ids = AllowedGroupsSequence() # must match ViewParam.GROUP_IDS 

3088 

3089 

3090class EditTaskFilterSchema(CSRFSchema): 

3091 """ 

3092 Schema to edit a task filter. 

3093 """ 

3094 

3095 who = EditTaskFilterWhoSchema( # must match ViewParam.WHO 

3096 widget=MappingWidget(template="mapping_accordion", open=False) 

3097 ) 

3098 what = EditTaskFilterWhatSchema( # must match ViewParam.WHAT 

3099 widget=MappingWidget(template="mapping_accordion", open=False) 

3100 ) 

3101 when = EditTaskFilterWhenSchema( # must match ViewParam.WHEN 

3102 widget=MappingWidget(template="mapping_accordion", open=False) 

3103 ) 

3104 admin = EditTaskFilterAdminSchema( # must match ViewParam.ADMIN 

3105 widget=MappingWidget(template="mapping_accordion", open=False) 

3106 ) 

3107 

3108 # noinspection PyUnusedLocal 

3109 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None: 

3110 # log.debug("EditTaskFilterSchema.after_bind") 

3111 # log.debug("{!r}", self.__dict__) 

3112 # This is pretty nasty. By the time we get here, the Form class has 

3113 # made Field objects, and, I think, called a clone() function on us. 

3114 # Objects like "who" are not in our __dict__ any more. Our __dict__ 

3115 # looks like: 

3116 # { 

3117 # 'typ': <colander.Mapping object at 0x7fd7989b18d0>, 

3118 # 'bindings': { 

3119 # 'open_who': True, 

3120 # 'open_when': True, 

3121 # 'request': ..., 

3122 # }, 

3123 # '_order': 118, 

3124 # 'children': [ 

3125 # <...CSRFToken object at ... (named csrf)>, 

3126 # <...EditTaskFilterWhoSchema object at ... (named who)>, 

3127 # ... 

3128 # ], 

3129 # 'title': '' 

3130 # } 

3131 _ = self.gettext 

3132 who = get_child_node(self, "who") 

3133 what = get_child_node(self, "what") 

3134 when = get_child_node(self, "when") 

3135 admin = get_child_node(self, "admin") 

3136 who.title = _("Who") 

3137 what.title = _("What") 

3138 when.title = _("When") 

3139 admin.title = _("Administrative criteria") 

3140 # log.debug("who = {!r}", who) 

3141 # log.debug("who.__dict__ = {!r}", who.__dict__) 

3142 who.widget.open = kw[Binding.OPEN_WHO] 

3143 what.widget.open = kw[Binding.OPEN_WHAT] 

3144 when.widget.open = kw[Binding.OPEN_WHEN] 

3145 admin.widget.open = kw[Binding.OPEN_ADMIN] 

3146 

3147 

3148class EditTaskFilterForm(InformativeNonceForm): 

3149 """ 

3150 Form to edit a task filter. 

3151 """ 

3152 

3153 def __init__( 

3154 self, 

3155 request: "CamcopsRequest", 

3156 open_who: bool = False, 

3157 open_what: bool = False, 

3158 open_when: bool = False, 

3159 open_admin: bool = False, 

3160 **kwargs, 

3161 ) -> None: 

3162 _ = request.gettext 

3163 schema = EditTaskFilterSchema().bind( 

3164 request=request, 

3165 open_admin=open_admin, 

3166 open_what=open_what, 

3167 open_when=open_when, 

3168 open_who=open_who, 

3169 ) 

3170 super().__init__( 

3171 schema, 

3172 buttons=[ 

3173 Button(name=FormAction.SET_FILTERS, title=_("Set filters")), 

3174 Button(name=FormAction.CLEAR_FILTERS, title=_("Clear")), 

3175 ], 

3176 **kwargs, 

3177 ) 

3178 

3179 

3180class TasksPerPageSchema(CSRFSchema): 

3181 """ 

3182 Schema to edit the number of rows per page, for the task view. 

3183 """ 

3184 

3185 rows_per_page = RowsPerPageSelector() # must match ViewParam.ROWS_PER_PAGE 

3186 

3187 

3188class TasksPerPageForm(InformativeNonceForm): 

3189 """ 

3190 Form to edit the number of tasks per page, for the task view. 

3191 """ 

3192 

3193 def __init__(self, request: "CamcopsRequest", **kwargs) -> None: 

3194 _ = request.gettext 

3195 schema = TasksPerPageSchema().bind(request=request) 

3196 super().__init__( 

3197 schema, 

3198 buttons=[ 

3199 Button( 

3200 name=FormAction.SUBMIT_TASKS_PER_PAGE, 

3201 title=_("Set n/page"), 

3202 ) 

3203 ], 

3204 css_class=BootstrapCssClasses.FORM_INLINE, 

3205 **kwargs, 

3206 ) 

3207 

3208 

3209class RefreshTasksSchema(CSRFSchema): 

3210 """ 

3211 Schema for a "refresh tasks" button. 

3212 """ 

3213 

3214 pass 

3215 

3216 

3217class RefreshTasksForm(InformativeNonceForm): 

3218 """ 

3219 Form for a "refresh tasks" button. 

3220 """ 

3221 

3222 def __init__(self, request: "CamcopsRequest", **kwargs) -> None: 

3223 _ = request.gettext 

3224 schema = RefreshTasksSchema().bind(request=request) 

3225 super().__init__( 

3226 schema, 

3227 buttons=[ 

3228 Button(name=FormAction.REFRESH_TASKS, title=_("Refresh")) 

3229 ], 

3230 **kwargs, 

3231 ) 

3232 

3233 

3234# ============================================================================= 

3235# Trackers 

3236# ============================================================================= 

3237 

3238 

3239class TaskTrackerOutputTypeSelector(SchemaNode, RequestAwareMixin): 

3240 """ 

3241 Node to select the output format for a tracker. 

3242 """ 

3243 

3244 # Choices don't require translation 

3245 _choices = ( 

3246 (ViewArg.HTML, "HTML"), 

3247 (ViewArg.PDF, "PDF"), 

3248 (ViewArg.XML, "XML"), 

3249 ) 

3250 

3251 schema_type = String 

3252 default = ViewArg.HTML 

3253 missing = ViewArg.HTML 

3254 widget = RadioChoiceWidget(values=_choices) 

3255 validator = OneOf(list(x[0] for x in _choices)) 

3256 

3257 def __init__(self, *args, **kwargs) -> None: 

3258 self.title = "" # for type checker 

3259 super().__init__(*args, **kwargs) 

3260 

3261 # noinspection PyUnusedLocal 

3262 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None: 

3263 _ = self.gettext 

3264 self.title = _("View as") 

3265 

3266 

3267class ChooseTrackerSchema(CSRFSchema): 

3268 """ 

3269 Schema to select a tracker or CTV. 

3270 """ 

3271 

3272 which_idnum = ( 

3273 MandatoryWhichIdNumSelector() 

3274 ) # must match ViewParam.WHICH_IDNUM # noqa 

3275 idnum_value = ( 

3276 MandatoryIdNumValue() 

3277 ) # must match ViewParam.IDNUM_VALUE # noqa 

3278 start_datetime = ( 

3279 StartPendulumSelector() 

3280 ) # must match ViewParam.START_DATETIME # noqa 

3281 end_datetime = EndPendulumSelector() # must match ViewParam.END_DATETIME 

3282 all_tasks = BooleanNode(default=True) # match ViewParam.ALL_TASKS 

3283 tasks = MultiTaskSelector() # must match ViewParam.TASKS 

3284 # tracker_tasks_only will be set via the binding 

3285 via_index = ViaIndexSelector() # must match ViewParam.VIA_INDEX 

3286 viewtype = ( 

3287 TaskTrackerOutputTypeSelector() 

3288 ) # must match ViewParam.VIEWTYPE # noqa 

3289 

3290 # noinspection PyUnusedLocal 

3291 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None: 

3292 _ = self.gettext 

3293 all_tasks = get_child_node(self, "all_tasks") 

3294 text = _("Use all eligible task types?") 

3295 all_tasks.title = text 

3296 all_tasks.label = text 

3297 

3298 

3299class ChooseTrackerForm(InformativeNonceForm): 

3300 """ 

3301 Form to select a tracker or CTV. 

3302 """ 

3303 

3304 def __init__( 

3305 self, request: "CamcopsRequest", as_ctv: bool, **kwargs 

3306 ) -> None: 

3307 """ 

3308 Args: 

3309 as_ctv: CTV, not tracker? 

3310 """ 

3311 _ = request.gettext 

3312 schema = ChooseTrackerSchema().bind( 

3313 request=request, tracker_tasks_only=not as_ctv 

3314 ) 

3315 super().__init__( 

3316 schema, 

3317 buttons=[ 

3318 Button( 

3319 name=FormAction.SUBMIT, 

3320 title=(_("View CTV") if as_ctv else _("View tracker")), 

3321 ) 

3322 ], 

3323 **kwargs, 

3324 ) 

3325 

3326 

3327# ============================================================================= 

3328# Reports, which use dynamically created forms 

3329# ============================================================================= 

3330 

3331 

3332class ReportOutputTypeSelector(SchemaNode, RequestAwareMixin): 

3333 """ 

3334 Node to select the output format for a report. 

3335 """ 

3336 

3337 schema_type = String 

3338 default = ViewArg.HTML 

3339 missing = ViewArg.HTML 

3340 

3341 def __init__(self, *args, **kwargs) -> None: 

3342 self.title = "" # for type checker 

3343 self.widget = None # type: Optional[Widget] 

3344 self.validator = None # type: Optional[ValidatorType] 

3345 super().__init__(*args, **kwargs) 

3346 

3347 # noinspection PyUnusedLocal 

3348 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None: 

3349 _ = self.gettext 

3350 self.title = _("View as") 

3351 choices = self.get_choices() 

3352 values, pv = get_values_and_permissible(choices) 

3353 self.widget = RadioChoiceWidget(values=choices) 

3354 self.validator = OneOf(pv) 

3355 

3356 def get_choices(self) -> Tuple[Tuple[str, str]]: 

3357 _ = self.gettext 

3358 # noinspection PyTypeChecker 

3359 return ( 

3360 (ViewArg.HTML, _("HTML")), 

3361 (ViewArg.ODS, _("OpenOffice spreadsheet (ODS) file")), 

3362 (ViewArg.TSV, _("TSV (tab-separated values)")), 

3363 (ViewArg.XLSX, _("XLSX (Microsoft Excel) file")), 

3364 ) 

3365 

3366 

3367class ReportParamSchema(CSRFSchema): 

3368 """ 

3369 Schema to embed a report type (ID) and output format (view type). 

3370 """ 

3371 

3372 viewtype = ReportOutputTypeSelector() # must match ViewParam.VIEWTYPE 

3373 report_id = HiddenStringNode() # must match ViewParam.REPORT_ID 

3374 # Specific forms may inherit from this. 

3375 

3376 

3377class DateTimeFilteredReportParamSchema(ReportParamSchema): 

3378 start_datetime = StartPendulumSelector() 

3379 end_datetime = EndPendulumSelector() 

3380 

3381 

3382class ReportParamForm(SimpleSubmitForm): 

3383 """ 

3384 Form to view a specific report. Often derived from, to configure the report 

3385 in more detail. 

3386 """ 

3387 

3388 def __init__( 

3389 self, 

3390 request: "CamcopsRequest", 

3391 schema_class: Type[ReportParamSchema], 

3392 **kwargs, 

3393 ) -> None: 

3394 _ = request.gettext 

3395 super().__init__( 

3396 schema_class=schema_class, 

3397 submit_title=_("View report"), 

3398 request=request, 

3399 **kwargs, 

3400 ) 

3401 

3402 

3403# ============================================================================= 

3404# View DDL 

3405# ============================================================================= 

3406 

3407 

3408def get_sql_dialect_choices( 

3409 request: "CamcopsRequest", 

3410) -> List[Tuple[str, str]]: 

3411 _ = request.gettext 

3412 return [ 

3413 # https://docs.sqlalchemy.org/en/latest/dialects/ 

3414 (SqlaDialectName.MYSQL, "MySQL"), 

3415 (SqlaDialectName.MSSQL, "Microsoft SQL Server"), 

3416 (SqlaDialectName.ORACLE, "Oracle" + _("[WILL NOT WORK]")), 

3417 # ... Oracle doesn't work; SQLAlchemy enforces the Oracle rule of a 30- 

3418 # character limit for identifiers, only relaxed to 128 characters in 

3419 # Oracle 12.2 (March 2017). 

3420 (SqlaDialectName.FIREBIRD, "Firebird"), 

3421 (SqlaDialectName.POSTGRES, "PostgreSQL"), 

3422 (SqlaDialectName.SQLITE, "SQLite"), 

3423 (SqlaDialectName.SYBASE, "Sybase"), 

3424 ] 

3425 

3426 

3427class DatabaseDialectSelector(SchemaNode, RequestAwareMixin): 

3428 """ 

3429 Node to choice an SQL dialect (for viewing DDL). 

3430 """ 

3431 

3432 schema_type = String 

3433 default = SqlaDialectName.MYSQL 

3434 missing = SqlaDialectName.MYSQL 

3435 

3436 def __init__(self, *args, **kwargs) -> None: 

3437 self.title = "" # for type checker 

3438 self.widget = None # type: Optional[Widget] 

3439 self.validator = None # type: Optional[ValidatorType] 

3440 super().__init__(*args, **kwargs) 

3441 

3442 # noinspection PyUnusedLocal 

3443 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None: 

3444 _ = self.gettext 

3445 self.title = _("SQL dialect to use (not all may be valid)") 

3446 choices = get_sql_dialect_choices(self.request) 

3447 values, pv = get_values_and_permissible(choices) 

3448 self.widget = RadioChoiceWidget(values=values) 

3449 self.validator = OneOf(pv) 

3450 

3451 

3452class ViewDdlSchema(CSRFSchema): 

3453 """ 

3454 Schema to choose how to view DDL. 

3455 """ 

3456 

3457 dialect = DatabaseDialectSelector() # must match ViewParam.DIALECT 

3458 

3459 

3460class ViewDdlForm(SimpleSubmitForm): 

3461 """ 

3462 Form to choose how to view DDL (and then view it). 

3463 """ 

3464 

3465 def __init__(self, request: "CamcopsRequest", **kwargs) -> None: 

3466 _ = request.gettext 

3467 super().__init__( 

3468 schema_class=ViewDdlSchema, 

3469 submit_title=_("View DDL"), 

3470 request=request, 

3471 **kwargs, 

3472 ) 

3473 

3474 

3475# ============================================================================= 

3476# Add/edit/delete users 

3477# ============================================================================= 

3478 

3479 

3480class UserGroupPermissionsGroupAdminSchema(CSRFSchema): 

3481 """ 

3482 Edit group-specific permissions for a user. For group administrators. 

3483 """ 

3484 

3485 # Currently the defaults here will be ignored because we don't use this 

3486 # schema to create new UserGroupMembership records. The record will already 

3487 # exist by the time we see the forms that use this schema. So the database 

3488 # defaults will be used instead. 

3489 may_upload = BooleanNode( 

3490 default=False 

3491 ) # match ViewParam.MAY_UPLOAD and User attribute # noqa 

3492 may_register_devices = BooleanNode( 

3493 default=False 

3494 ) # match ViewParam.MAY_REGISTER_DEVICES and User attribute # noqa 

3495 may_use_webviewer = BooleanNode( 

3496 default=False 

3497 ) # match ViewParam.MAY_USE_WEBVIEWER and User attribute # noqa 

3498 may_manage_patients = BooleanNode( 

3499 default=False 

3500 ) # match ViewParam.MAY_MANAGE_PATIENTS # noqa 

3501 may_email_patients = BooleanNode( 

3502 default=False 

3503 ) # match ViewParam.MAY_EMAIL_PATIENTS # noqa 

3504 view_all_patients_when_unfiltered = BooleanNode( 

3505 default=False 

3506 ) # match ViewParam.VIEW_ALL_PATIENTS_WHEN_UNFILTERED and User attribute # noqa 

3507 may_dump_data = BooleanNode( 

3508 default=False 

3509 ) # match ViewParam.MAY_DUMP_DATA and User attribute # noqa 

3510 may_run_reports = BooleanNode( 

3511 default=False 

3512 ) # match ViewParam.MAY_RUN_REPORTS and User attribute # noqa 

3513 may_add_notes = BooleanNode( 

3514 default=False 

3515 ) # match ViewParam.MAY_ADD_NOTES and User attribute # noqa 

3516 

3517 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None: 

3518 _ = self.gettext 

3519 may_upload = get_child_node(self, "may_upload") 

3520 mu_text = _("Permitted to upload from a tablet/device") 

3521 may_upload.title = mu_text 

3522 may_upload.label = mu_text 

3523 may_register_devices = get_child_node(self, "may_register_devices") 

3524 mrd_text = _("Permitted to register tablet/client devices") 

3525 may_register_devices.title = mrd_text 

3526 may_register_devices.label = mrd_text 

3527 may_use_webviewer = get_child_node(self, "may_use_webviewer") 

3528 ml_text = _("May log in to web front end") 

3529 may_use_webviewer.title = ml_text 

3530 may_use_webviewer.label = ml_text 

3531 may_manage_patients = get_child_node(self, "may_manage_patients") 

3532 mmp_text = _("May add, edit or delete patients created on the server") 

3533 may_manage_patients.title = mmp_text 

3534 may_manage_patients.label = mmp_text 

3535 may_email_patients = get_child_node(self, "may_email_patients") 

3536 mep_text = _("May send emails to patients created on the server") 

3537 may_email_patients.title = mep_text 

3538 may_email_patients.label = mep_text 

3539 view_all_patients_when_unfiltered = get_child_node( 

3540 self, "view_all_patients_when_unfiltered" 

3541 ) 

3542 vap_text = _( 

3543 "May view (browse) records from all patients when no patient " 

3544 "filter set" 

3545 ) 

3546 view_all_patients_when_unfiltered.title = vap_text 

3547 view_all_patients_when_unfiltered.label = vap_text 

3548 may_dump_data = get_child_node(self, "may_dump_data") 

3549 md_text = _("May perform bulk data dumps") 

3550 may_dump_data.title = md_text 

3551 may_dump_data.label = md_text 

3552 may_run_reports = get_child_node(self, "may_run_reports") 

3553 mrr_text = _("May run reports") 

3554 may_run_reports.title = mrr_text 

3555 may_run_reports.label = mrr_text 

3556 may_add_notes = get_child_node(self, "may_add_notes") 

3557 man_text = _("May add special notes to tasks") 

3558 may_add_notes.title = man_text 

3559 may_add_notes.label = man_text 

3560 

3561 

3562class UserGroupPermissionsFullSchema(UserGroupPermissionsGroupAdminSchema): 

3563 """ 

3564 Edit group-specific permissions for a user. For superusers; includes the 

3565 option to make the user a groupadmin. 

3566 """ 

3567 

3568 groupadmin = BooleanNode( 

3569 default=False 

3570 ) # match ViewParam.GROUPADMIN and User attribute 

3571 

3572 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None: 

3573 super().after_bind(node, kw) 

3574 _ = self.gettext 

3575 groupadmin = get_child_node(self, "groupadmin") 

3576 text = _("User is a privileged group administrator for this group") 

3577 groupadmin.title = text 

3578 groupadmin.label = text 

3579 

3580 

3581class EditUserGroupAdminSchema(CSRFSchema): 

3582 """ 

3583 Schema to edit a user. Version for group administrators. 

3584 """ 

3585 

3586 username = ( 

3587 UsernameNode() 

3588 ) # name must match ViewParam.USERNAME and User attribute # noqa 

3589 fullname = OptionalStringNode( # name must match ViewParam.FULLNAME and User attribute # noqa 

3590 validator=Length(0, StringLengths.FULLNAME_MAX_LEN) 

3591 ) 

3592 email = ( 

3593 OptionalEmailNode() 

3594 ) # name must match ViewParam.EMAIL and User attribute # noqa 

3595 must_change_password = ( 

3596 MustChangePasswordNode() 

3597 ) # match ViewParam.MUST_CHANGE_PASSWORD and User attribute # noqa 

3598 language = LanguageSelector() # must match ViewParam.LANGUAGE 

3599 group_ids = AdministeredGroupsSequence() # must match ViewParam.GROUP_IDS 

3600 

3601 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None: 

3602 _ = self.gettext 

3603 fullname = get_child_node(self, "fullname") 

3604 fullname.title = _("Full name") 

3605 email = get_child_node(self, "email") 

3606 email.title = _("E-mail address") 

3607 

3608 

3609class EditUserFullSchema(EditUserGroupAdminSchema): 

3610 """ 

3611 Schema to edit a user. Version for superusers; can also make the user a 

3612 superuser. 

3613 """ 

3614 

3615 superuser = BooleanNode( 

3616 default=False 

3617 ) # match ViewParam.SUPERUSER and User attribute # noqa 

3618 group_ids = AllGroupsSequence() # must match ViewParam.GROUP_IDS 

3619 

3620 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None: 

3621 _ = self.gettext 

3622 superuser = get_child_node(self, "superuser") 

3623 text = _("Superuser (CAUTION!)") 

3624 superuser.title = text 

3625 superuser.label = text 

3626 

3627 

3628class EditUserFullForm(ApplyCancelForm): 

3629 """ 

3630 Form to edit a user. Full version for superusers. 

3631 """ 

3632 

3633 def __init__(self, request: "CamcopsRequest", **kwargs) -> None: 

3634 super().__init__( 

3635 schema_class=EditUserFullSchema, request=request, **kwargs 

3636 ) 

3637 

3638 

3639class EditUserGroupAdminForm(ApplyCancelForm): 

3640 """ 

3641 Form to edit a user. Version for group administrators. 

3642 """ 

3643 

3644 def __init__(self, request: "CamcopsRequest", **kwargs) -> None: 

3645 super().__init__( 

3646 schema_class=EditUserGroupAdminSchema, request=request, **kwargs 

3647 ) 

3648 

3649 

3650class EditUserGroupPermissionsFullForm(ApplyCancelForm): 

3651 """ 

3652 Form to edit a user's permissions within a group. Version for superusers. 

3653 """ 

3654 

3655 def __init__(self, request: "CamcopsRequest", **kwargs) -> None: 

3656 super().__init__( 

3657 schema_class=UserGroupPermissionsFullSchema, 

3658 request=request, 

3659 **kwargs, 

3660 ) 

3661 

3662 

3663class EditUserGroupMembershipGroupAdminForm(ApplyCancelForm): 

3664 """ 

3665 Form to edit a user's permissions within a group. Version for group 

3666 administrators. 

3667 """ 

3668 

3669 def __init__(self, request: "CamcopsRequest", **kwargs) -> None: 

3670 super().__init__( 

3671 schema_class=UserGroupPermissionsGroupAdminSchema, 

3672 request=request, 

3673 **kwargs, 

3674 ) 

3675 

3676 

3677class AddUserSuperuserSchema(CSRFSchema): 

3678 """ 

3679 Schema to add a user. Version for superusers. 

3680 """ 

3681 

3682 username = ( 

3683 UsernameNode() 

3684 ) # name must match ViewParam.USERNAME and User attribute # noqa 

3685 new_password = NewPasswordNode() # name must match ViewParam.NEW_PASSWORD 

3686 must_change_password = ( 

3687 MustChangePasswordNode() 

3688 ) # match ViewParam.MUST_CHANGE_PASSWORD and User attribute # noqa 

3689 group_ids = AllGroupsSequence() # must match ViewParam.GROUP_IDS 

3690 

3691 

3692class AddUserGroupadminSchema(AddUserSuperuserSchema): 

3693 """ 

3694 Schema to add a user. Version for group administrators. 

3695 """ 

3696 

3697 group_ids = AdministeredGroupsSequence() # must match ViewParam.GROUP_IDS 

3698 

3699 

3700class AddUserSuperuserForm(AddCancelForm): 

3701 """ 

3702 Form to add a user. Version for superusers. 

3703 """ 

3704 

3705 def __init__(self, request: "CamcopsRequest", **kwargs) -> None: 

3706 super().__init__( 

3707 schema_class=AddUserSuperuserSchema, request=request, **kwargs 

3708 ) 

3709 

3710 

3711class AddUserGroupadminForm(AddCancelForm): 

3712 """ 

3713 Form to add a user. Version for group administrators. 

3714 """ 

3715 

3716 def __init__(self, request: "CamcopsRequest", **kwargs) -> None: 

3717 super().__init__( 

3718 schema_class=AddUserGroupadminSchema, request=request, **kwargs 

3719 ) 

3720 

3721 

3722class SetUserUploadGroupSchema(CSRFSchema): 

3723 """ 

3724 Schema to choose the group into which a user uploads. 

3725 """ 

3726 

3727 upload_group_id = ( 

3728 OptionalGroupIdSelectorUserGroups() 

3729 ) # must match ViewParam.UPLOAD_GROUP_ID # noqa 

3730 

3731 # noinspection PyUnusedLocal 

3732 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None: 

3733 _ = self.gettext 

3734 upload_group_id = get_child_node(self, "upload_group_id") 

3735 upload_group_id.title = _("Group into which to upload data") 

3736 upload_group_id.description = _( 

3737 "Pick a group from those to which the user belongs" 

3738 ) 

3739 

3740 

3741class SetUserUploadGroupForm(InformativeNonceForm): 

3742 """ 

3743 Form to choose the group into which a user uploads. 

3744 """ 

3745 

3746 def __init__( 

3747 self, request: "CamcopsRequest", user: "User", **kwargs 

3748 ) -> None: 

3749 _ = request.gettext 

3750 schema = SetUserUploadGroupSchema().bind( 

3751 request=request, user=user 

3752 ) # UNUSUAL 

3753 super().__init__( 

3754 schema, 

3755 buttons=[ 

3756 Button(name=FormAction.SUBMIT, title=_("Set")), 

3757 Button(name=FormAction.CANCEL, title=_("Cancel")), 

3758 ], 

3759 **kwargs, 

3760 ) 

3761 

3762 

3763class DeleteUserSchema(HardWorkConfirmationSchema): 

3764 """ 

3765 Schema to delete a user. 

3766 """ 

3767 

3768 user_id = HiddenIntegerNode() # name must match ViewParam.USER_ID 

3769 danger = TranslatableValidateDangerousOperationNode() 

3770 

3771 

3772class DeleteUserForm(DeleteCancelForm): 

3773 """ 

3774 Form to delete a user. 

3775 """ 

3776 

3777 def __init__(self, request: "CamcopsRequest", **kwargs) -> None: 

3778 super().__init__( 

3779 schema_class=DeleteUserSchema, request=request, **kwargs 

3780 ) 

3781 

3782 

3783# ============================================================================= 

3784# Add/edit/delete groups 

3785# ============================================================================= 

3786 

3787 

3788class PolicyNode(MandatoryStringNode, RequestAwareMixin): 

3789 """ 

3790 Node to capture a CamCOPS ID number policy, and make sure it is 

3791 syntactically valid. 

3792 """ 

3793 

3794 def validator(self, node: SchemaNode, value: Any) -> None: 

3795 _ = self.gettext 

3796 if not isinstance(value, str): 

3797 # unlikely! 

3798 raise Invalid(node, _("Not a string")) 

3799 policy = TokenizedPolicy(value) 

3800 if not policy.is_syntactically_valid(): 

3801 raise Invalid(node, _("Syntactically invalid policy")) 

3802 if not policy.is_valid_for_idnums(self.request.valid_which_idnums): 

3803 raise Invalid( 

3804 node, 

3805 _( 

3806 "Invalid policy. Have you referred to non-existent ID " 

3807 "numbers? Is the policy less restrictive than the " 

3808 "tablet’s minimum ID policy?" 

3809 ) 

3810 + f" [{TABLET_ID_POLICY_STR!r}]", 

3811 ) 

3812 

3813 

3814class GroupNameNode(MandatoryStringNode, RequestAwareMixin): 

3815 """ 

3816 Node to capture a CamCOPS group name, and check it's valid as a string. 

3817 """ 

3818 

3819 def validator(self, node: SchemaNode, value: str) -> None: 

3820 try: 

3821 validate_group_name(value, self.request) 

3822 except ValueError as e: 

3823 raise Invalid(node, str(e)) 

3824 

3825 

3826class GroupIpUseWidget(Widget): 

3827 basedir = os.path.join(TEMPLATE_DIR, "deform") 

3828 readonlydir = os.path.join(basedir, "readonly") 

3829 form = "group_ip_use.pt" 

3830 template = os.path.join(basedir, form) 

3831 readonly_template = os.path.join(readonlydir, form) 

3832 

3833 def __init__(self, request: "CamcopsRequest", **kwargs: Any) -> None: 

3834 super().__init__(**kwargs) 

3835 self.request = request 

3836 

3837 def serialize( 

3838 self, 

3839 field: "Field", 

3840 cstruct: Union[Dict[str, Any], None, ColanderNullType], 

3841 **kw: Any, 

3842 ) -> Any: 

3843 if cstruct in (None, null): 

3844 cstruct = {} 

3845 

3846 cstruct: Dict[str, Any] # For type checker 

3847 

3848 for context in IpUse.CONTEXTS: 

3849 value = cstruct.get(context, False) 

3850 kw.setdefault(context, value) 

3851 

3852 readonly = kw.get("readonly", self.readonly) 

3853 template = readonly and self.readonly_template or self.template 

3854 values = self.get_template_values(field, cstruct, kw) 

3855 

3856 _ = self.request.gettext 

3857 

3858 values.update( 

3859 introduction=_( 

3860 "These settings will be applied to the patient's device " 

3861 "when operating in single user mode." 

3862 ), 

3863 reason=_( 

3864 "The settings here influence whether CamCOPS will consider " 

3865 "some third-party tasks “permitted” on your behalf, according " 

3866 "to their published use criteria. They do <b>not</b> remove " 

3867 "your responsibility to ensure that you use them in " 

3868 "accordance with their own requirements." 

3869 ), 

3870 warning=_( 

3871 "WARNING. Providing incorrect information here may lead to " 

3872 "you VIOLATING copyright law, by using task for a purpose " 

3873 "that is not permitted, and being subject to damages and/or " 

3874 "prosecution." 

3875 ), 

3876 disclaimer=_( 

3877 "The authors of CamCOPS cannot be held responsible or liable " 

3878 "for any consequences of you misusing materials subject to " 

3879 "copyright." 

3880 ), 

3881 preamble=_("In which contexts does this group operate?"), 

3882 clinical_label=_("Clinical"), 

3883 medical_device_warning=_( 

3884 "WARNING: NOT FOR GENERAL CLINICAL USE; not a Medical Device; " 

3885 "see Terms and Conditions" 

3886 ), 

3887 commercial_label=_("Commercial"), 

3888 educational_label=_("Educational"), 

3889 research_label=_("Research"), 

3890 ) 

3891 

3892 return field.renderer(template, **values) 

3893 

3894 def deserialize( 

3895 self, field: "Field", pstruct: Union[Dict[str, Any], ColanderNullType] 

3896 ) -> Dict[str, bool]: 

3897 if pstruct is null: 

3898 pstruct = {} 

3899 

3900 pstruct: Dict[str, Any] # For type checker 

3901 

3902 # It doesn't really matter what the pstruct values are. Only the 

3903 # options that are ticked will be present as keys in pstruct 

3904 return {k: k in pstruct for k in IpUse.CONTEXTS} 

3905 

3906 

3907class IpUseType(SchemaType): 

3908 # noinspection PyMethodMayBeStatic,PyUnusedLocal 

3909 def deserialize( 

3910 self, 

3911 node: SchemaNode, 

3912 cstruct: Union[Dict[str, Any], None, ColanderNullType], 

3913 ) -> Optional[IpUse]: 

3914 if cstruct in (None, null): 

3915 return None 

3916 

3917 cstruct: Dict[str, Any] # For type checker 

3918 

3919 return IpUse(**cstruct) 

3920 

3921 # noinspection PyMethodMayBeStatic,PyUnusedLocal 

3922 def serialize( 

3923 self, node: SchemaNode, ip_use: Union[IpUse, None, ColanderNullType] 

3924 ) -> Union[Dict, ColanderNullType]: 

3925 if ip_use in (null, None): 

3926 return null 

3927 

3928 return { 

3929 context: getattr(ip_use, context) for context in IpUse.CONTEXTS 

3930 } 

3931 

3932 

3933class GroupIpUseNode(SchemaNode, RequestAwareMixin): 

3934 schema_type = IpUseType 

3935 

3936 # noinspection PyUnusedLocal,PyAttributeOutsideInit 

3937 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None: 

3938 self.widget = GroupIpUseWidget(self.request) 

3939 

3940 

3941class EditGroupSchema(CSRFSchema): 

3942 """ 

3943 Schema to edit a group. 

3944 """ 

3945 

3946 group_id = HiddenIntegerNode() # must match ViewParam.GROUP_ID 

3947 name = GroupNameNode() # must match ViewParam.NAME 

3948 description = MandatoryStringNode( # must match ViewParam.DESCRIPTION 

3949 validator=Length( 

3950 StringLengths.GROUP_DESCRIPTION_MIN_LEN, 

3951 StringLengths.GROUP_DESCRIPTION_MAX_LEN, 

3952 ) 

3953 ) 

3954 ip_use = GroupIpUseNode() 

3955 

3956 group_ids = AllOtherGroupsSequence() # must match ViewParam.GROUP_IDS 

3957 upload_policy = PolicyNode() # must match ViewParam.UPLOAD_POLICY 

3958 finalize_policy = PolicyNode() # must match ViewParam.FINALIZE_POLICY 

3959 

3960 # noinspection PyUnusedLocal 

3961 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None: 

3962 _ = self.gettext 

3963 name = get_child_node(self, "name") 

3964 name.title = _("Group name") 

3965 

3966 ip_use = get_child_node(self, "ip_use") 

3967 ip_use.title = _("Group intellectual property settings") 

3968 

3969 group_ids = get_child_node(self, "group_ids") 

3970 group_ids.title = _("Other groups this group may see") 

3971 upload_policy = get_child_node(self, "upload_policy") 

3972 upload_policy.title = _("Upload policy") 

3973 upload_policy.description = _( 

3974 "Minimum required patient information to copy data to server" 

3975 ) 

3976 finalize_policy = get_child_node(self, "finalize_policy") 

3977 finalize_policy.title = _("Finalize policy") 

3978 finalize_policy.description = _( 

3979 "Minimum required patient information to clear data off " 

3980 "source device" 

3981 ) 

3982 

3983 def validator(self, node: SchemaNode, value: Any) -> None: 

3984 request = self.bindings[Binding.REQUEST] # type: CamcopsRequest 

3985 q = ( 

3986 CountStarSpecializedQuery(Group, session=request.dbsession) 

3987 .filter(Group.id != value[ViewParam.GROUP_ID]) 

3988 .filter(Group.name == value[ViewParam.NAME]) 

3989 ) 

3990 if q.count_star() > 0: 

3991 _ = request.gettext 

3992 raise Invalid(node, _("Name is used by another group!")) 

3993 

3994 

3995class EditGroupForm(InformativeNonceForm): 

3996 """ 

3997 Form to edit a group. 

3998 """ 

3999 

4000 def __init__( 

4001 self, request: "CamcopsRequest", group: Group, **kwargs 

4002 ) -> None: 

4003 _ = request.gettext 

4004 schema = EditGroupSchema().bind( 

4005 request=request, group=group 

4006 ) # UNUSUAL BINDING 

4007 super().__init__( 

4008 schema, 

4009 buttons=[ 

4010 Button(name=FormAction.SUBMIT, title=_("Apply")), 

4011 Button(name=FormAction.CANCEL, title=_("Cancel")), 

4012 ], 

4013 **kwargs, 

4014 ) 

4015 

4016 

4017class AddGroupSchema(CSRFSchema): 

4018 """ 

4019 Schema to add a group. 

4020 """ 

4021 

4022 name = GroupNameNode() # name must match ViewParam.NAME 

4023 

4024 # noinspection PyUnusedLocal 

4025 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None: 

4026 _ = self.gettext 

4027 name = get_child_node(self, "name") 

4028 name.title = _("Group name") 

4029 

4030 def validator(self, node: SchemaNode, value: Any) -> None: 

4031 request = self.bindings[Binding.REQUEST] # type: CamcopsRequest 

4032 q = CountStarSpecializedQuery(Group, session=request.dbsession).filter( 

4033 Group.name == value[ViewParam.NAME] 

4034 ) 

4035 if q.count_star() > 0: 

4036 _ = request.gettext 

4037 raise Invalid(node, _("Name is used by another group!")) 

4038 

4039 

4040class AddGroupForm(AddCancelForm): 

4041 """ 

4042 Form to add a group. 

4043 """ 

4044 

4045 def __init__(self, request: "CamcopsRequest", **kwargs) -> None: 

4046 super().__init__( 

4047 schema_class=AddGroupSchema, request=request, **kwargs 

4048 ) 

4049 

4050 

4051class DeleteGroupSchema(HardWorkConfirmationSchema): 

4052 """ 

4053 Schema to delete a group. 

4054 """ 

4055 

4056 group_id = HiddenIntegerNode() # name must match ViewParam.GROUP_ID 

4057 danger = TranslatableValidateDangerousOperationNode() 

4058 

4059 

4060class DeleteGroupForm(DeleteCancelForm): 

4061 """ 

4062 Form to delete a group. 

4063 """ 

4064 

4065 def __init__(self, request: "CamcopsRequest", **kwargs) -> None: 

4066 super().__init__( 

4067 schema_class=DeleteGroupSchema, request=request, **kwargs 

4068 ) 

4069 

4070 

4071# ============================================================================= 

4072# Offer research dumps 

4073# ============================================================================= 

4074 

4075 

4076class DumpTypeSelector(SchemaNode, RequestAwareMixin): 

4077 """ 

4078 Node to select the filtering method for a data dump. 

4079 """ 

4080 

4081 schema_type = String 

4082 default = ViewArg.EVERYTHING 

4083 missing = ViewArg.EVERYTHING 

4084 

4085 def __init__(self, *args, **kwargs) -> None: 

4086 self.title = "" # for type checker 

4087 self.widget = None # type: Optional[Widget] 

4088 self.validator = None # type: Optional[ValidatorType] 

4089 super().__init__(*args, **kwargs) 

4090 

4091 # noinspection PyUnusedLocal 

4092 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None: 

4093 _ = self.gettext 

4094 self.title = _("Dump method") 

4095 choices = ( 

4096 (ViewArg.EVERYTHING, _("Everything")), 

4097 (ViewArg.USE_SESSION_FILTER, _("Use the session filter settings")), 

4098 ( 

4099 ViewArg.SPECIFIC_TASKS_GROUPS, 

4100 _("Specify tasks/groups manually (see below)"), 

4101 ), 

4102 ) 

4103 self.widget = RadioChoiceWidget(values=choices) 

4104 self.validator = OneOf(list(x[0] for x in choices)) 

4105 

4106 

4107class SpreadsheetFormatSelector(SchemaNode, RequestAwareMixin): 

4108 """ 

4109 Node to select a way of downloading an SQLite database. 

4110 """ 

4111 

4112 schema_type = String 

4113 default = ViewArg.XLSX 

4114 missing = ViewArg.XLSX 

4115 

4116 def __init__(self, *args, **kwargs) -> None: 

4117 self.title = "" # for type checker 

4118 self.widget = None # type: Optional[Widget] 

4119 self.validator = None # type: Optional[ValidatorType] 

4120 super().__init__(*args, **kwargs) 

4121 

4122 # noinspection PyUnusedLocal 

4123 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None: 

4124 _ = self.gettext 

4125 self.title = _("Spreadsheet format") 

4126 choices = ( 

4127 (ViewArg.R, _("R script")), 

4128 (ViewArg.ODS, _("OpenOffice spreadsheet (ODS) file")), 

4129 (ViewArg.XLSX, _("XLSX (Microsoft Excel) file")), 

4130 ( 

4131 ViewArg.TSV_ZIP, 

4132 _("ZIP file of tab-separated value (TSV) files"), 

4133 ), 

4134 ) 

4135 values, pv = get_values_and_permissible(choices) 

4136 self.widget = RadioChoiceWidget(values=values) 

4137 self.validator = OneOf(pv) 

4138 

4139 

4140class DeliveryModeNode(SchemaNode, RequestAwareMixin): 

4141 """ 

4142 Mode of delivery of data downloads. 

4143 """ 

4144 

4145 schema_type = String 

4146 default = ViewArg.EMAIL 

4147 missing = ViewArg.EMAIL 

4148 

4149 def __init__(self, *args, **kwargs) -> None: 

4150 self.title = "" # for type checker 

4151 self.widget = None # type: Optional[Widget] 

4152 super().__init__(*args, **kwargs) 

4153 

4154 # noinspection PyUnusedLocal 

4155 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None: 

4156 _ = self.gettext 

4157 self.title = _("Delivery") 

4158 choices = ( 

4159 (ViewArg.IMMEDIATELY, _("Serve immediately")), 

4160 (ViewArg.EMAIL, _("E-mail me")), 

4161 (ViewArg.DOWNLOAD, _("Create a file for me to download")), 

4162 ) 

4163 values, pv = get_values_and_permissible(choices) 

4164 self.widget = RadioChoiceWidget(values=values) 

4165 

4166 # noinspection PyUnusedLocal 

4167 def validator(self, node: SchemaNode, value: Any) -> None: 

4168 request = self.bindings[Binding.REQUEST] # type: CamcopsRequest 

4169 _ = request.gettext 

4170 if value == ViewArg.IMMEDIATELY: 

4171 if not request.config.permit_immediate_downloads: 

4172 raise Invalid( 

4173 self, 

4174 _("Disabled by the system administrator") 

4175 + f" [{ConfigParamSite.PERMIT_IMMEDIATE_DOWNLOADS}]", 

4176 ) 

4177 elif value == ViewArg.EMAIL: 

4178 if not request.user.email: 

4179 raise Invalid( 

4180 self, _("Your user does not have an email address") 

4181 ) 

4182 elif value == ViewArg.DOWNLOAD: 

4183 if not request.user_download_dir: 

4184 raise Invalid( 

4185 self, 

4186 _("User downloads not configured by administrator") 

4187 + f" [{ConfigParamSite.USER_DOWNLOAD_DIR}, " 

4188 f"{ConfigParamSite.USER_DOWNLOAD_MAX_SPACE_MB}]", 

4189 ) 

4190 else: 

4191 raise Invalid(self, _("Bad value")) 

4192 

4193 

4194class SqliteSelector(SchemaNode, RequestAwareMixin): 

4195 """ 

4196 Node to select a way of downloading an SQLite database. 

4197 """ 

4198 

4199 schema_type = String 

4200 default = ViewArg.SQLITE 

4201 missing = ViewArg.SQLITE 

4202 

4203 def __init__(self, *args, **kwargs) -> None: 

4204 self.title = "" # for type checker 

4205 self.widget = None # type: Optional[Widget] 

4206 self.validator = None # type: Optional[ValidatorType] 

4207 super().__init__(*args, **kwargs) 

4208 

4209 # noinspection PyUnusedLocal 

4210 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None: 

4211 _ = self.gettext 

4212 self.title = _("Database download method") 

4213 choices = ( 

4214 # https://docs.sqlalchemy.org/en/latest/dialects/ 

4215 (ViewArg.SQLITE, _("Binary SQLite database")), 

4216 (ViewArg.SQL, _("SQL text to create SQLite database")), 

4217 ) 

4218 values, pv = get_values_and_permissible(choices) 

4219 self.widget = RadioChoiceWidget(values=values) 

4220 self.validator = OneOf(pv) 

4221 

4222 

4223class SimplifiedSpreadsheetsNode(SchemaNode, RequestAwareMixin): 

4224 """ 

4225 Boolean node: simplify basic dump spreadsheets? 

4226 """ 

4227 

4228 schema_type = Boolean 

4229 default = True 

4230 missing = True 

4231 

4232 def __init__(self, *args, **kwargs) -> None: 

4233 self.title = "" # for type checker 

4234 self.label = "" # for type checker 

4235 super().__init__(*args, **kwargs) 

4236 

4237 # noinspection PyUnusedLocal 

4238 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None: 

4239 _ = self.gettext 

4240 self.title = _("Simplify spreadsheets?") 

4241 self.label = _("Remove non-essential details?") 

4242 

4243 

4244class SortTsvByHeadingsNode(SchemaNode, RequestAwareMixin): 

4245 """ 

4246 Boolean node: sort TSV files by column name? 

4247 """ 

4248 

4249 schema_type = Boolean 

4250 default = False 

4251 missing = False 

4252 

4253 def __init__(self, *args, **kwargs) -> None: 

4254 self.title = "" # for type checker 

4255 self.label = "" # for type checker 

4256 super().__init__(*args, **kwargs) 

4257 

4258 # noinspection PyUnusedLocal 

4259 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None: 

4260 _ = self.gettext 

4261 self.title = _("Sort columns?") 

4262 self.label = _("Sort by heading (column) names within spreadsheets?") 

4263 

4264 

4265class IncludeSchemaNode(SchemaNode, RequestAwareMixin): 

4266 """ 

4267 Boolean node: should INFORMATION_SCHEMA.COLUMNS be included (for 

4268 downloads)? 

4269 

4270 False by default -- adds about 350 kb to an ODS download, for example. 

4271 """ 

4272 

4273 schema_type = Boolean 

4274 default = False 

4275 missing = False 

4276 

4277 def __init__(self, *args, **kwargs) -> None: 

4278 self.title = "" # for type checker 

4279 self.label = "" # for type checker 

4280 super().__init__(*args, **kwargs) 

4281 

4282 # noinspection PyUnusedLocal 

4283 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None: 

4284 _ = self.gettext 

4285 self.title = _("Include column information?") 

4286 self.label = _( 

4287 "Include details of all columns in the source database?" 

4288 ) 

4289 

4290 

4291class IncludeBlobsNode(SchemaNode, RequestAwareMixin): 

4292 """ 

4293 Boolean node: should BLOBs be included (for downloads)? 

4294 """ 

4295 

4296 schema_type = Boolean 

4297 default = False 

4298 missing = False 

4299 

4300 def __init__(self, *args, **kwargs) -> None: 

4301 self.title = "" # for type checker 

4302 self.label = "" # for type checker 

4303 super().__init__(*args, **kwargs) 

4304 

4305 # noinspection PyUnusedLocal 

4306 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None: 

4307 _ = self.gettext 

4308 self.title = _("Include BLOBs?") 

4309 self.label = _( 

4310 "Include binary large objects (BLOBs)? WARNING: may be large" 

4311 ) 

4312 

4313 

4314class PatientIdPerRowNode(SchemaNode, RequestAwareMixin): 

4315 """ 

4316 Boolean node: should patient ID information, and other cross-referencing 

4317 denormalized info, be included per row? 

4318 

4319 See :ref:`DB_PATIENT_ID_PER_ROW <DB_PATIENT_ID_PER_ROW>`. 

4320 """ 

4321 

4322 schema_type = Boolean 

4323 default = True 

4324 missing = True 

4325 

4326 def __init__(self, *args, **kwargs) -> None: 

4327 self.title = "" # for type checker 

4328 self.label = "" # for type checker 

4329 super().__init__(*args, **kwargs) 

4330 

4331 # noinspection PyUnusedLocal 

4332 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None: 

4333 _ = self.gettext 

4334 self.title = _("Patient ID per row?") 

4335 self.label = _( 

4336 "Include patient ID numbers and task cross-referencing " 

4337 "(denormalized) information per row?" 

4338 ) 

4339 

4340 

4341class OfferDumpManualSchema(Schema, RequestAwareMixin): 

4342 """ 

4343 Schema to offer the "manual" settings for a data dump (groups, task types). 

4344 """ 

4345 

4346 group_ids = AllowedGroupsSequence() # must match ViewParam.GROUP_IDS 

4347 tasks = MultiTaskSelector() # must match ViewParam.TASKS 

4348 

4349 widget = MappingWidget(template="mapping_accordion", open=False) 

4350 

4351 def __init__(self, *args, **kwargs) -> None: 

4352 self.title = "" # for type checker 

4353 super().__init__(*args, **kwargs) 

4354 

4355 # noinspection PyUnusedLocal 

4356 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None: 

4357 _ = self.gettext 

4358 self.title = _("Manual settings") 

4359 

4360 

4361class OfferBasicDumpSchema(CSRFSchema): 

4362 """ 

4363 Schema to choose the settings for a basic (TSV/ZIP) data dump. 

4364 """ 

4365 

4366 dump_method = DumpTypeSelector() # must match ViewParam.DUMP_METHOD 

4367 simplified = ( 

4368 SimplifiedSpreadsheetsNode() 

4369 ) # must match ViewParam.SIMPLIFIED 

4370 sort = SortTsvByHeadingsNode() # must match ViewParam.SORT 

4371 include_schema = IncludeSchemaNode() # must match ViewParam.INCLUDE_SCHEMA 

4372 manual = OfferDumpManualSchema() # must match ViewParam.MANUAL 

4373 viewtype = ( 

4374 SpreadsheetFormatSelector() 

4375 ) # must match ViewParam.VIEWTYPE # noqa 

4376 delivery_mode = DeliveryModeNode() # must match ViewParam.DELIVERY_MODE 

4377 

4378 

4379class OfferBasicDumpForm(SimpleSubmitForm): 

4380 """ 

4381 Form to offer a basic (TSV/ZIP) data dump. 

4382 """ 

4383 

4384 def __init__(self, request: "CamcopsRequest", **kwargs) -> None: 

4385 _ = request.gettext 

4386 super().__init__( 

4387 schema_class=OfferBasicDumpSchema, 

4388 submit_title=_("Dump"), 

4389 request=request, 

4390 **kwargs, 

4391 ) 

4392 

4393 

4394class OfferSqlDumpSchema(CSRFSchema): 

4395 """ 

4396 Schema to choose the settings for an SQL data dump. 

4397 """ 

4398 

4399 dump_method = DumpTypeSelector() # must match ViewParam.DUMP_METHOD 

4400 sqlite_method = SqliteSelector() # must match ViewParam.SQLITE_METHOD 

4401 include_schema = IncludeSchemaNode() # must match ViewParam.INCLUDE_SCHEMA 

4402 include_blobs = IncludeBlobsNode() # must match ViewParam.INCLUDE_BLOBS 

4403 patient_id_per_row = ( 

4404 PatientIdPerRowNode() 

4405 ) # must match ViewParam.PATIENT_ID_PER_ROW # noqa 

4406 manual = OfferDumpManualSchema() # must match ViewParam.MANUAL 

4407 delivery_mode = DeliveryModeNode() # must match ViewParam.DELIVERY_MODE 

4408 

4409 

4410class OfferSqlDumpForm(SimpleSubmitForm): 

4411 """ 

4412 Form to choose the settings for an SQL data dump. 

4413 """ 

4414 

4415 def __init__(self, request: "CamcopsRequest", **kwargs) -> None: 

4416 _ = request.gettext 

4417 super().__init__( 

4418 schema_class=OfferSqlDumpSchema, 

4419 submit_title=_("Dump"), 

4420 request=request, 

4421 **kwargs, 

4422 ) 

4423 

4424 

4425# ============================================================================= 

4426# Edit server settings 

4427# ============================================================================= 

4428 

4429 

4430class EditServerSettingsSchema(CSRFSchema): 

4431 """ 

4432 Schema to edit the global settings for the server. 

4433 """ 

4434 

4435 database_title = SchemaNode( # must match ViewParam.DATABASE_TITLE 

4436 String(), 

4437 validator=Length( 

4438 StringLengths.DATABASE_TITLE_MIN_LEN, 

4439 StringLengths.DATABASE_TITLE_MAX_LEN, 

4440 ), 

4441 ) 

4442 

4443 # noinspection PyUnusedLocal 

4444 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None: 

4445 _ = self.gettext 

4446 database_title = get_child_node(self, "database_title") 

4447 database_title.title = _("Database friendly title") 

4448 

4449 

4450class EditServerSettingsForm(ApplyCancelForm): 

4451 """ 

4452 Form to edit the global settings for the server. 

4453 """ 

4454 

4455 def __init__(self, request: "CamcopsRequest", **kwargs) -> None: 

4456 super().__init__( 

4457 schema_class=EditServerSettingsSchema, request=request, **kwargs 

4458 ) 

4459 

4460 

4461# ============================================================================= 

4462# Edit ID number definitions 

4463# ============================================================================= 

4464 

4465 

4466class IdDefinitionDescriptionNode(SchemaNode, RequestAwareMixin): 

4467 """ 

4468 Node to capture the description of an ID number type. 

4469 """ 

4470 

4471 schema_type = String 

4472 validator = Length(1, StringLengths.ID_DESCRIPTOR_MAX_LEN) 

4473 

4474 def __init__(self, *args, **kwargs) -> None: 

4475 self.title = "" # for type checker 

4476 super().__init__(*args, **kwargs) 

4477 

4478 # noinspection PyUnusedLocal 

4479 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None: 

4480 _ = self.gettext 

4481 self.title = _("Full description (e.g. “NHS number”)") 

4482 

4483 

4484class IdDefinitionShortDescriptionNode(SchemaNode, RequestAwareMixin): 

4485 """ 

4486 Node to capture the short description of an ID number type. 

4487 """ 

4488 

4489 schema_type = String 

4490 validator = Length(1, StringLengths.ID_DESCRIPTOR_MAX_LEN) 

4491 

4492 def __init__(self, *args, **kwargs) -> None: 

4493 self.title = "" # for type checker 

4494 self.description = "" # for type checker 

4495 super().__init__(*args, **kwargs) 

4496 

4497 # noinspection PyUnusedLocal 

4498 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None: 

4499 _ = self.gettext 

4500 self.title = _("Short description (e.g. “NHS#”)") 

4501 self.description = _("Try to keep it very short!") 

4502 

4503 

4504class IdValidationMethodNode(OptionalStringNode, RequestAwareMixin): 

4505 """ 

4506 Node to choose a build-in ID number validation method. 

4507 """ 

4508 

4509 widget = SelectWidget(values=ID_NUM_VALIDATION_METHOD_CHOICES) 

4510 validator = OneOf(list(x[0] for x in ID_NUM_VALIDATION_METHOD_CHOICES)) 

4511 

4512 def __init__(self, *args, **kwargs) -> None: 

4513 self.title = "" # for type checker 

4514 self.description = "" # for type checker 

4515 super().__init__(*args, **kwargs) 

4516 

4517 # noinspection PyUnusedLocal 

4518 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None: 

4519 _ = self.gettext 

4520 self.title = _("Validation method") 

4521 self.description = _("Built-in CamCOPS ID number validation method") 

4522 

4523 

4524class Hl7AssigningAuthorityNode(OptionalStringNode, RequestAwareMixin): 

4525 """ 

4526 Optional node to capture the name of an HL7 Assigning Authority. 

4527 """ 

4528 

4529 def __init__(self, *args, **kwargs) -> None: 

4530 self.title = "" # for type checker 

4531 self.description = "" # for type checker 

4532 super().__init__(*args, **kwargs) 

4533 

4534 # noinspection PyUnusedLocal 

4535 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None: 

4536 _ = self.gettext 

4537 self.title = _("HL7 Assigning Authority") 

4538 self.description = _( 

4539 "For HL7 messaging: " 

4540 "HL7 Assigning Authority for ID number (unique name of the " 

4541 "system/organization/agency/department that creates the data)." 

4542 ) 

4543 

4544 # noinspection PyMethodMayBeStatic 

4545 def validator(self, node: SchemaNode, value: str) -> None: 

4546 try: 

4547 validate_hl7_aa(value, self.request) 

4548 except ValueError as e: 

4549 raise Invalid(node, str(e)) 

4550 

4551 

4552class Hl7IdTypeNode(OptionalStringNode, RequestAwareMixin): 

4553 """ 

4554 Optional node to capture the name of an HL7 Identifier Type code. 

4555 """ 

4556 

4557 def __init__(self, *args, **kwargs) -> None: 

4558 self.title = "" # for type checker 

4559 self.description = "" # for type checker 

4560 super().__init__(*args, **kwargs) 

4561 

4562 # noinspection PyUnusedLocal 

4563 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None: 

4564 _ = self.gettext 

4565 self.title = _("HL7 Identifier Type") 

4566 self.description = _( 

4567 "For HL7 messaging: " 

4568 "HL7 Identifier Type code: ‘a code corresponding to the type " 

4569 "of identifier. In some cases, this code may be used as a " 

4570 "qualifier to the “Assigning Authority” component.’" 

4571 ) 

4572 

4573 # noinspection PyMethodMayBeStatic 

4574 def validator(self, node: SchemaNode, value: str) -> None: 

4575 try: 

4576 validate_hl7_id_type(value, self.request) 

4577 except ValueError as e: 

4578 raise Invalid(node, str(e)) 

4579 

4580 

4581class FHIRIdSystemUrlNode(OptionalStringNode, RequestAwareMixin): 

4582 """ 

4583 Optional node to capture the URL for a FHIR ID system: 

4584 

4585 - https://www.hl7.org/fhir/datatypes.html#Identifier 

4586 - https://www.hl7.org/fhir/datatypes-definitions.html#Identifier.system 

4587 """ 

4588 

4589 validator = url 

4590 

4591 def __init__(self, *args, **kwargs) -> None: 

4592 self.title = "" # for type checker 

4593 self.description = "" # for type checker 

4594 super().__init__(*args, **kwargs) 

4595 

4596 # noinspection PyUnusedLocal 

4597 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None: 

4598 _ = self.gettext 

4599 self.title = _("FHIR ID system") 

4600 self.description = _("For FHIR exports: URL defining the ID system.") 

4601 

4602 

4603class EditIdDefinitionSchema(CSRFSchema): 

4604 """ 

4605 Schema to edit an ID number definition. 

4606 """ 

4607 

4608 which_idnum = HiddenIntegerNode() # must match ViewParam.WHICH_IDNUM 

4609 description = ( 

4610 IdDefinitionDescriptionNode() 

4611 ) # must match ViewParam.DESCRIPTION # noqa 

4612 short_description = ( 

4613 IdDefinitionShortDescriptionNode() 

4614 ) # must match ViewParam.SHORT_DESCRIPTION # noqa 

4615 validation_method = ( 

4616 IdValidationMethodNode() 

4617 ) # must match ViewParam.VALIDATION_METHOD # noqa 

4618 hl7_id_type = Hl7IdTypeNode() # must match ViewParam.HL7_ID_TYPE 

4619 hl7_assigning_authority = ( 

4620 Hl7AssigningAuthorityNode() 

4621 ) # must match ViewParam.HL7_ASSIGNING_AUTHORITY # noqa 

4622 fhir_id_system = ( 

4623 FHIRIdSystemUrlNode() 

4624 ) # must match ViewParam.FHIR_ID_SYSTEM # noqa 

4625 

4626 def validator(self, node: SchemaNode, value: Any) -> None: 

4627 request = self.bindings[Binding.REQUEST] # type: CamcopsRequest 

4628 _ = request.gettext 

4629 qd = ( 

4630 CountStarSpecializedQuery( 

4631 IdNumDefinition, session=request.dbsession 

4632 ) 

4633 .filter( 

4634 IdNumDefinition.which_idnum != value[ViewParam.WHICH_IDNUM] 

4635 ) 

4636 .filter( 

4637 IdNumDefinition.description == value[ViewParam.DESCRIPTION] 

4638 ) 

4639 ) 

4640 if qd.count_star() > 0: 

4641 raise Invalid(node, _("Description is used by another ID number!")) 

4642 qs = ( 

4643 CountStarSpecializedQuery( 

4644 IdNumDefinition, session=request.dbsession 

4645 ) 

4646 .filter( 

4647 IdNumDefinition.which_idnum != value[ViewParam.WHICH_IDNUM] 

4648 ) 

4649 .filter( 

4650 IdNumDefinition.short_description 

4651 == value[ViewParam.SHORT_DESCRIPTION] 

4652 ) 

4653 ) 

4654 if qs.count_star() > 0: 

4655 raise Invalid( 

4656 node, _("Short description is used by another ID number!") 

4657 ) 

4658 

4659 

4660class EditIdDefinitionForm(ApplyCancelForm): 

4661 """ 

4662 Form to edit an ID number definition. 

4663 """ 

4664 

4665 def __init__(self, request: "CamcopsRequest", **kwargs) -> None: 

4666 super().__init__( 

4667 schema_class=EditIdDefinitionSchema, request=request, **kwargs 

4668 ) 

4669 

4670 

4671class AddIdDefinitionSchema(CSRFSchema): 

4672 """ 

4673 Schema to add an ID number definition. 

4674 """ 

4675 

4676 which_idnum = SchemaNode( # must match ViewParam.WHICH_IDNUM 

4677 Integer(), validator=Range(min=1) 

4678 ) 

4679 description = ( 

4680 IdDefinitionDescriptionNode() 

4681 ) # must match ViewParam.DESCRIPTION # noqa 

4682 short_description = ( 

4683 IdDefinitionShortDescriptionNode() 

4684 ) # must match ViewParam.SHORT_DESCRIPTION # noqa 

4685 validation_method = ( 

4686 IdValidationMethodNode() 

4687 ) # must match ViewParam.VALIDATION_METHOD # noqa 

4688 

4689 # noinspection PyUnusedLocal 

4690 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None: 

4691 _ = self.gettext 

4692 which_idnum = get_child_node(self, "which_idnum") 

4693 which_idnum.title = _("Which ID number?") 

4694 which_idnum.description = ( 

4695 "Specify the integer to represent the type of this ID " 

4696 "number class (e.g. consecutive numbering from 1)" 

4697 ) 

4698 

4699 def validator(self, node: SchemaNode, value: Any) -> None: 

4700 request = self.bindings[Binding.REQUEST] # type: CamcopsRequest 

4701 _ = request.gettext 

4702 qw = CountStarSpecializedQuery( 

4703 IdNumDefinition, session=request.dbsession 

4704 ).filter(IdNumDefinition.which_idnum == value[ViewParam.WHICH_IDNUM]) 

4705 if qw.count_star() > 0: 

4706 raise Invalid(node, _("ID# clashes with another ID number!")) 

4707 qd = CountStarSpecializedQuery( 

4708 IdNumDefinition, session=request.dbsession 

4709 ).filter(IdNumDefinition.description == value[ViewParam.DESCRIPTION]) 

4710 if qd.count_star() > 0: 

4711 raise Invalid(node, _("Description is used by another ID number!")) 

4712 qs = CountStarSpecializedQuery( 

4713 IdNumDefinition, session=request.dbsession 

4714 ).filter( 

4715 IdNumDefinition.short_description 

4716 == value[ViewParam.SHORT_DESCRIPTION] 

4717 ) 

4718 if qs.count_star() > 0: 

4719 raise Invalid( 

4720 node, _("Short description is used by another ID number!") 

4721 ) 

4722 

4723 

4724class AddIdDefinitionForm(AddCancelForm): 

4725 """ 

4726 Form to add an ID number definition. 

4727 """ 

4728 

4729 def __init__(self, request: "CamcopsRequest", **kwargs) -> None: 

4730 super().__init__( 

4731 schema_class=AddIdDefinitionSchema, request=request, **kwargs 

4732 ) 

4733 

4734 

4735class DeleteIdDefinitionSchema(HardWorkConfirmationSchema): 

4736 """ 

4737 Schema to delete an ID number definition. 

4738 """ 

4739 

4740 which_idnum = HiddenIntegerNode() # name must match ViewParam.WHICH_IDNUM 

4741 danger = TranslatableValidateDangerousOperationNode() 

4742 

4743 

4744class DeleteIdDefinitionForm(DangerousForm): 

4745 """ 

4746 Form to add an ID number definition. 

4747 """ 

4748 

4749 def __init__(self, request: "CamcopsRequest", **kwargs) -> None: 

4750 _ = request.gettext 

4751 super().__init__( 

4752 schema_class=DeleteIdDefinitionSchema, 

4753 submit_action=FormAction.DELETE, 

4754 submit_title=_("Delete"), 

4755 request=request, 

4756 **kwargs, 

4757 ) 

4758 

4759 

4760# ============================================================================= 

4761# Special notes 

4762# ============================================================================= 

4763 

4764 

4765class AddSpecialNoteSchema(CSRFSchema): 

4766 """ 

4767 Schema to add a special note to a task. 

4768 """ 

4769 

4770 table_name = HiddenStringNode() # must match ViewParam.TABLENAME 

4771 server_pk = HiddenIntegerNode() # must match ViewParam.SERVER_PK 

4772 note = MandatoryStringNode( # must match ViewParam.NOTE 

4773 widget=TextAreaWidget(rows=20, cols=80) 

4774 ) 

4775 danger = TranslatableValidateDangerousOperationNode() 

4776 

4777 

4778class AddSpecialNoteForm(DangerousForm): 

4779 """ 

4780 Form to add a special note to a task. 

4781 """ 

4782 

4783 def __init__(self, request: "CamcopsRequest", **kwargs) -> None: 

4784 _ = request.gettext 

4785 super().__init__( 

4786 schema_class=AddSpecialNoteSchema, 

4787 submit_action=FormAction.SUBMIT, 

4788 submit_title=_("Add"), 

4789 request=request, 

4790 **kwargs, 

4791 ) 

4792 

4793 

4794class DeleteSpecialNoteSchema(CSRFSchema): 

4795 """ 

4796 Schema to add a special note to a task. 

4797 """ 

4798 

4799 note_id = HiddenIntegerNode() # must match ViewParam.NOTE_ID 

4800 danger = TranslatableValidateDangerousOperationNode() 

4801 

4802 

4803class DeleteSpecialNoteForm(DangerousForm): 

4804 """ 

4805 Form to delete (hide) a special note. 

4806 """ 

4807 

4808 def __init__(self, request: "CamcopsRequest", **kwargs) -> None: 

4809 _ = request.gettext 

4810 super().__init__( 

4811 schema_class=DeleteSpecialNoteSchema, 

4812 submit_action=FormAction.SUBMIT, 

4813 submit_title=_("Delete"), 

4814 request=request, 

4815 **kwargs, 

4816 ) 

4817 

4818 

4819# ============================================================================= 

4820# The unusual data manipulation operations 

4821# ============================================================================= 

4822 

4823 

4824class EraseTaskSchema(HardWorkConfirmationSchema): 

4825 """ 

4826 Schema to erase a task. 

4827 """ 

4828 

4829 table_name = HiddenStringNode() # must match ViewParam.TABLENAME 

4830 server_pk = HiddenIntegerNode() # must match ViewParam.SERVER_PK 

4831 danger = TranslatableValidateDangerousOperationNode() 

4832 

4833 

4834class EraseTaskForm(DangerousForm): 

4835 """ 

4836 Form to erase a task. 

4837 """ 

4838 

4839 def __init__(self, request: "CamcopsRequest", **kwargs) -> None: 

4840 _ = request.gettext 

4841 super().__init__( 

4842 schema_class=EraseTaskSchema, 

4843 submit_action=FormAction.DELETE, 

4844 submit_title=_("Erase"), 

4845 request=request, 

4846 **kwargs, 

4847 ) 

4848 

4849 

4850class DeletePatientChooseSchema(CSRFSchema): 

4851 """ 

4852 Schema to delete a patient. 

4853 """ 

4854 

4855 which_idnum = ( 

4856 MandatoryWhichIdNumSelector() 

4857 ) # must match ViewParam.WHICH_IDNUM # noqa 

4858 idnum_value = MandatoryIdNumValue() # must match ViewParam.IDNUM_VALUE 

4859 group_id = ( 

4860 MandatoryGroupIdSelectorAdministeredGroups() 

4861 ) # must match ViewParam.GROUP_ID # noqa 

4862 danger = TranslatableValidateDangerousOperationNode() 

4863 

4864 

4865class DeletePatientChooseForm(DangerousForm): 

4866 """ 

4867 Form to delete a patient. 

4868 """ 

4869 

4870 def __init__(self, request: "CamcopsRequest", **kwargs) -> None: 

4871 _ = request.gettext 

4872 super().__init__( 

4873 schema_class=DeletePatientChooseSchema, 

4874 submit_action=FormAction.SUBMIT, 

4875 submit_title=_("Show tasks that will be deleted"), 

4876 request=request, 

4877 **kwargs, 

4878 ) 

4879 

4880 

4881class DeletePatientConfirmSchema(HardWorkConfirmationSchema): 

4882 """ 

4883 Schema to confirm deletion of a patient. 

4884 """ 

4885 

4886 which_idnum = HiddenIntegerNode() # must match ViewParam.WHICH_IDNUM 

4887 idnum_value = HiddenIntegerNode() # must match ViewParam.IDNUM_VALUE 

4888 group_id = HiddenIntegerNode() # must match ViewParam.GROUP_ID 

4889 danger = TranslatableValidateDangerousOperationNode() 

4890 

4891 

4892class DeletePatientConfirmForm(DangerousForm): 

4893 """ 

4894 Form to confirm deletion of a patient. 

4895 """ 

4896 

4897 def __init__(self, request: "CamcopsRequest", **kwargs) -> None: 

4898 _ = request.gettext 

4899 super().__init__( 

4900 schema_class=DeletePatientConfirmSchema, 

4901 submit_action=FormAction.DELETE, 

4902 submit_title=_("Delete"), 

4903 request=request, 

4904 **kwargs, 

4905 ) 

4906 

4907 

4908class DeleteServerCreatedPatientSchema(HardWorkConfirmationSchema): 

4909 """ 

4910 Schema to delete a patient created on the server. 

4911 """ 

4912 

4913 # name must match ViewParam.SERVER_PK 

4914 server_pk = HiddenIntegerNode() 

4915 danger = TranslatableValidateDangerousOperationNode() 

4916 

4917 

4918class DeleteServerCreatedPatientForm(DeleteCancelForm): 

4919 """ 

4920 Form to delete a patient created on the server 

4921 """ 

4922 

4923 def __init__(self, request: "CamcopsRequest", **kwargs: Any) -> None: 

4924 super().__init__( 

4925 schema_class=DeleteServerCreatedPatientSchema, 

4926 request=request, 

4927 **kwargs, 

4928 ) 

4929 

4930 

4931EDIT_PATIENT_SIMPLE_PARAMS = [ 

4932 ViewParam.FORENAME, 

4933 ViewParam.SURNAME, 

4934 ViewParam.DOB, 

4935 ViewParam.SEX, 

4936 ViewParam.ADDRESS, 

4937 ViewParam.EMAIL, 

4938 ViewParam.GP, 

4939 ViewParam.OTHER, 

4940] 

4941 

4942 

4943class TaskScheduleSelector(SchemaNode, RequestAwareMixin): 

4944 """ 

4945 Drop-down with all available task schedules 

4946 """ 

4947 

4948 widget = SelectWidget() 

4949 

4950 def __init__(self, *args: Any, **kwargs: Any) -> None: 

4951 self.title = "" # for type checker 

4952 self.name = "" # for type checker 

4953 self.validator = None # type: Optional[ValidatorType] 

4954 super().__init__(*args, **kwargs) 

4955 

4956 # noinspection PyUnusedLocal 

4957 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None: 

4958 request = self.request 

4959 _ = request.gettext 

4960 self.title = _("Task schedule") 

4961 values = [] # type: List[Tuple[Optional[int], str]] 

4962 

4963 valid_group_ids = ( 

4964 request.user.ids_of_groups_user_may_manage_patients_in 

4965 ) 

4966 task_schedules = ( 

4967 request.dbsession.query(TaskSchedule) 

4968 .filter(TaskSchedule.group_id.in_(valid_group_ids)) 

4969 .order_by(TaskSchedule.name) 

4970 ) 

4971 

4972 for task_schedule in task_schedules: 

4973 values.append((task_schedule.id, task_schedule.name)) 

4974 values, pv = get_values_and_permissible(values, add_none=False) 

4975 

4976 self.widget.values = values 

4977 self.validator = OneOf(pv) 

4978 

4979 @staticmethod 

4980 def schema_type() -> SchemaType: 

4981 return Integer() 

4982 

4983 

4984class JsonType(SchemaType): 

4985 """ 

4986 Schema type for JsonNode 

4987 """ 

4988 

4989 # noinspection PyMethodMayBeStatic, PyUnusedLocal 

4990 def deserialize( 

4991 self, node: SchemaNode, cstruct: Union[str, ColanderNullType, None] 

4992 ) -> Any: 

4993 # is null when form is empty 

4994 if cstruct in (null, None): 

4995 return None 

4996 

4997 cstruct: str 

4998 

4999 try: 

5000 # Validation happens on the widget class 

5001 json_value = json.loads(cstruct) 

5002 except json.JSONDecodeError: 

5003 return None 

5004 

5005 return json_value 

5006 

5007 # noinspection PyMethodMayBeStatic,PyUnusedLocal 

5008 def serialize( 

5009 self, node: SchemaNode, appstruct: Union[Dict, None, ColanderNullType] 

5010 ) -> Union[str, ColanderNullType]: 

5011 # is null when form is empty (new record) 

5012 # is None when populated from empty value in the database 

5013 if appstruct in (null, None): 

5014 return null 

5015 

5016 # appstruct should be well formed here (it would already have failed 

5017 # when reading from the database) 

5018 return json.dumps(appstruct) 

5019 

5020 

5021class JsonWidget(Widget): 

5022 """ 

5023 Widget supporting jsoneditor https://github.com/josdejong/jsoneditor 

5024 """ 

5025 

5026 basedir = os.path.join(TEMPLATE_DIR, "deform") 

5027 readonlydir = os.path.join(basedir, "readonly") 

5028 form = "json.pt" 

5029 template = os.path.join(basedir, form) 

5030 readonly_template = os.path.join(readonlydir, form) 

5031 requirements = (("jsoneditor", None),) 

5032 

5033 def __init__(self, request: "CamcopsRequest", **kwargs: Any) -> None: 

5034 super().__init__(**kwargs) 

5035 self.request = request 

5036 

5037 def serialize( 

5038 self, field: "Field", cstruct: Union[str, ColanderNullType], **kw: Any 

5039 ) -> Any: 

5040 if cstruct is null: 

5041 cstruct = "" 

5042 

5043 readonly = kw.get("readonly", self.readonly) 

5044 template = readonly and self.readonly_template or self.template 

5045 

5046 values = self.get_template_values(field, cstruct, kw) 

5047 

5048 return field.renderer(template, **values) 

5049 

5050 def deserialize( 

5051 self, field: "Field", pstruct: Union[str, ColanderNullType] 

5052 ) -> Union[str, ColanderNullType]: 

5053 # is empty string when field is empty 

5054 if pstruct in (null, ""): 

5055 return null 

5056 

5057 _ = self.request.gettext 

5058 error_message = _("Please enter valid JSON or leave blank") 

5059 

5060 pstruct: str 

5061 

5062 try: 

5063 json.loads(pstruct) 

5064 except json.JSONDecodeError: 

5065 raise Invalid(field, error_message, pstruct) 

5066 

5067 return pstruct 

5068 

5069 

5070class JsonSettingsNode(SchemaNode, RequestAwareMixin): 

5071 """ 

5072 Note to edit raw JSON. 

5073 """ 

5074 

5075 schema_type = JsonType 

5076 missing = null 

5077 

5078 # noinspection PyUnusedLocal,PyAttributeOutsideInit 

5079 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None: 

5080 _ = self.gettext 

5081 self.widget = JsonWidget(self.request) 

5082 self.title = _("Task-specific settings for this patient") 

5083 self.description = _( 

5084 "ADVANCED. Only applicable to tasks that are configurable on a " 

5085 "per-patient basis. Format: JSON object, with settings keyed on " 

5086 "task table name." 

5087 ) 

5088 

5089 def validator(self, node: SchemaNode, value: Any) -> None: 

5090 if value is not None: 

5091 # will be None if JSON failed to validate 

5092 if not isinstance(value, dict): 

5093 _ = self.request.gettext 

5094 error_message = _( 

5095 "Please enter a valid JSON object (with settings keyed on " 

5096 "task table name) or leave blank" 

5097 ) 

5098 raise Invalid(node, error_message) 

5099 

5100 

5101class TaskScheduleJsonSchema(Schema): 

5102 """ 

5103 Schema for the advanced JSON parts of a patient-to-task-schedule mapping. 

5104 """ 

5105 

5106 settings = JsonSettingsNode() # must match ViewParam.SETTINGS 

5107 

5108 

5109class TaskScheduleNode(MappingSchema, RequestAwareMixin): 

5110 """ 

5111 Node to edit settings for a patient-to-task-schedule mapping. 

5112 """ 

5113 

5114 patient_task_schedule_id = ( 

5115 HiddenIntegerNode() 

5116 ) # name must match ViewParam.PATIENT_TASK_SCHEDULE_ID 

5117 schedule_id = TaskScheduleSelector() # must match ViewParam.SCHEDULE_ID 

5118 start_datetime = ( 

5119 StartPendulumSelector() 

5120 ) # must match ViewParam.START_DATETIME 

5121 if DEFORM_ACCORDION_BUG: 

5122 settings = JsonSettingsNode() # must match ViewParam.SETTINGS 

5123 else: 

5124 advanced = TaskScheduleJsonSchema( # must match ViewParam.ADVANCED 

5125 widget=MappingWidget(template="mapping_accordion", open=False) 

5126 ) 

5127 

5128 def __init__(self, *args: Any, **kwargs: Any) -> None: 

5129 self.title = "" # for type checker 

5130 super().__init__(*args, **kwargs) 

5131 

5132 # noinspection PyUnusedLocal 

5133 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None: 

5134 _ = self.gettext 

5135 self.title = _("Task schedule") 

5136 start_datetime = get_child_node(self, "start_datetime") 

5137 start_datetime.description = _( 

5138 "Leave blank for the date the patient first downloads the schedule" 

5139 ) 

5140 if not DEFORM_ACCORDION_BUG: 

5141 advanced = get_child_node(self, "advanced") 

5142 advanced.title = _("Advanced") 

5143 

5144 

5145class TaskScheduleSequence(SequenceSchema, RequestAwareMixin): 

5146 """ 

5147 Sequence for multiple patient-to-task-schedule mappings. 

5148 """ 

5149 

5150 task_schedule_sequence = TaskScheduleNode() 

5151 missing = drop 

5152 

5153 def __init__(self, *args: Any, **kwargs: Any) -> None: 

5154 self.title = "" # for type checker 

5155 self.widget = None # type: Optional[Widget] 

5156 super().__init__(*args, **kwargs) 

5157 

5158 # noinspection PyUnusedLocal 

5159 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None: 

5160 _ = self.gettext 

5161 self.title = _("Task Schedules") 

5162 self.widget = TranslatableSequenceWidget(request=self.request) 

5163 

5164 

5165class EditPatientSchema(CSRFSchema): 

5166 """ 

5167 Schema to edit a patient. 

5168 """ 

5169 

5170 server_pk = HiddenIntegerNode() # must match ViewParam.SERVER_PK 

5171 forename = OptionalPatientNameNode() # must match ViewParam.FORENAME 

5172 surname = OptionalPatientNameNode() # must match ViewParam.SURNAME 

5173 dob = DateSelectorNode() # must match ViewParam.DOB 

5174 sex = MandatorySexSelector() # must match ViewParam.SEX 

5175 address = OptionalStringNode() # must match ViewParam.ADDRESS 

5176 email = OptionalEmailNode() # must match ViewParam.EMAIL 

5177 gp = OptionalStringNode() # must match ViewParam.GP 

5178 other = OptionalStringNode() # must match ViewParam.OTHER 

5179 id_references = ( 

5180 IdNumSequenceUniquePerWhichIdnum() 

5181 ) # must match ViewParam.ID_REFERENCES # noqa 

5182 

5183 # noinspection PyUnusedLocal 

5184 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None: 

5185 _ = self.gettext 

5186 dob = get_child_node(self, "dob") 

5187 dob.title = _("Date of birth") 

5188 gp = get_child_node(self, "gp") 

5189 gp.title = _("GP") 

5190 

5191 def validator(self, node: SchemaNode, value: Any) -> None: 

5192 request = self.bindings[Binding.REQUEST] # type: CamcopsRequest 

5193 dbsession = request.dbsession 

5194 group_id = value[ViewParam.GROUP_ID] 

5195 group = Group.get_group_by_id(dbsession, group_id) 

5196 testpatient = Patient() 

5197 for k in EDIT_PATIENT_SIMPLE_PARAMS: 

5198 setattr(testpatient, k, value[k]) 

5199 testpatient.idnums = [] 

5200 for idrefdict in value[ViewParam.ID_REFERENCES]: 

5201 pidnum = PatientIdNum() 

5202 pidnum.which_idnum = idrefdict[ViewParam.WHICH_IDNUM] 

5203 pidnum.idnum_value = idrefdict[ViewParam.IDNUM_VALUE] 

5204 testpatient.idnums.append(pidnum) 

5205 tk_finalize_policy = TokenizedPolicy(group.finalize_policy) 

5206 if not testpatient.satisfies_id_policy(tk_finalize_policy): 

5207 _ = self.gettext 

5208 raise Invalid( 

5209 node, 

5210 _("Patient would not meet 'finalize' ID policy for group:") 

5211 + f" {group.name}! [" 

5212 + _("That policy is:") 

5213 + f" {group.finalize_policy!r}]", 

5214 ) 

5215 

5216 

5217class DangerousEditPatientSchema(EditPatientSchema): 

5218 group_id = HiddenIntegerNode() # must match ViewParam.GROUP_ID 

5219 danger = TranslatableValidateDangerousOperationNode() 

5220 

5221 

5222class EditServerCreatedPatientSchema(EditPatientSchema): 

5223 # Must match ViewParam.GROUP_ID 

5224 group_id = MandatoryGroupIdSelectorPatientGroups(insert_before="forename") 

5225 task_schedules = ( 

5226 TaskScheduleSequence() 

5227 ) # must match ViewParam.TASK_SCHEDULES 

5228 

5229 

5230class EditFinalizedPatientForm(DangerousForm): 

5231 """ 

5232 Form to edit a finalized patient. 

5233 """ 

5234 

5235 def __init__(self, request: "CamcopsRequest", **kwargs: Any) -> None: 

5236 _ = request.gettext 

5237 super().__init__( 

5238 schema_class=DangerousEditPatientSchema, 

5239 submit_action=FormAction.SUBMIT, 

5240 submit_title=_("Submit"), 

5241 request=request, 

5242 **kwargs, 

5243 ) 

5244 

5245 

5246class EditServerCreatedPatientForm(DynamicDescriptionsNonceForm): 

5247 """ 

5248 Form to add or edit a patient not yet on the device (for scheduled tasks) 

5249 """ 

5250 

5251 def __init__(self, request: "CamcopsRequest", **kwargs: Any) -> None: 

5252 schema = EditServerCreatedPatientSchema().bind(request=request) 

5253 _ = request.gettext 

5254 super().__init__( 

5255 schema, 

5256 request=request, 

5257 buttons=[ 

5258 Button( 

5259 name=FormAction.SUBMIT, 

5260 title=_("Submit"), 

5261 css_class="btn-danger", 

5262 ), 

5263 Button(name=FormAction.CANCEL, title=_("Cancel")), 

5264 ], 

5265 **kwargs, 

5266 ) 

5267 

5268 

5269class EmailTemplateNode(OptionalStringNode, RequestAwareMixin): 

5270 def __init__(self, *args, **kwargs) -> None: 

5271 self.title = "" # for type checker 

5272 self.description = "" # for type checker 

5273 self.formatter = TaskScheduleEmailTemplateFormatter() 

5274 super().__init__(*args, **kwargs) 

5275 

5276 # noinspection PyUnusedLocal 

5277 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None: 

5278 _ = self.gettext 

5279 self.title = _("Email template") 

5280 self.description = _( 

5281 "Template of email to be sent to patients when inviting them to " 

5282 "complete the tasks in the schedule. Valid placeholders: {}" 

5283 ).format(self.formatter.get_valid_parameters_string()) 

5284 

5285 # noinspection PyAttributeOutsideInit 

5286 self.widget = RichTextWidget(options=get_tinymce_options(self.request)) 

5287 

5288 def validator(self, node: SchemaNode, value: Any) -> None: 

5289 _ = self.gettext 

5290 

5291 try: 

5292 self.formatter.validate(value) 

5293 return 

5294 except KeyError as e: 

5295 error = _("{bad_key} is not a valid placeholder").format(bad_key=e) 

5296 except ValueError: 

5297 error = _( 

5298 "Invalid email template. Is there a missing '{' or '}' ?" 

5299 ) 

5300 

5301 raise Invalid(node, error) 

5302 

5303 

5304class EmailCcNode(OptionalEmailNode, RequestAwareMixin): 

5305 # noinspection PyUnusedLocal 

5306 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None: 

5307 _ = self.gettext 

5308 self.title = _("Email CC") 

5309 self.description = _( 

5310 "The patient will see these email addresses. Separate multiple " 

5311 "addresses with commas." 

5312 ) 

5313 

5314 

5315class EmailBccNode(OptionalEmailNode, RequestAwareMixin): 

5316 # noinspection PyUnusedLocal 

5317 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None: 

5318 _ = self.gettext 

5319 self.title = _("Email BCC") 

5320 self.description = _( 

5321 "The patient will not see these email addresses. Separate " 

5322 "multiple addresses with commas." 

5323 ) 

5324 

5325 

5326class EmailFromNode(OptionalEmailNode, RequestAwareMixin): 

5327 # noinspection PyUnusedLocal 

5328 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None: 

5329 _ = self.gettext 

5330 self.title = _('Email "From" address') 

5331 self.description = _( 

5332 "You must set this if you want to send emails to your patients" 

5333 ) 

5334 

5335 

5336class TaskScheduleSchema(CSRFSchema): 

5337 name = OptionalStringNode() 

5338 group_id = ( 

5339 MandatoryGroupIdSelectorAdministeredGroups() 

5340 ) # must match ViewParam.GROUP_ID # noqa 

5341 email_from = EmailFromNode() # must match ViewParam.EMAIL_FROM 

5342 email_cc = EmailCcNode() # must match ViewParam.EMAIL_CC 

5343 email_bcc = EmailBccNode() # must match ViewParam.EMAIL_BCC 

5344 email_subject = OptionalStringNode() 

5345 email_template = EmailTemplateNode() 

5346 

5347 

5348class EditTaskScheduleForm(DynamicDescriptionsNonceForm): 

5349 def __init__(self, request: "CamcopsRequest", **kwargs: Any) -> None: 

5350 schema = TaskScheduleSchema().bind(request=request) 

5351 _ = request.gettext 

5352 super().__init__( 

5353 schema, 

5354 request=request, 

5355 buttons=[ 

5356 Button( 

5357 name=FormAction.SUBMIT, 

5358 title=_("Submit"), 

5359 css_class="btn-danger", 

5360 ), 

5361 Button(name=FormAction.CANCEL, title=_("Cancel")), 

5362 ], 

5363 **kwargs, 

5364 ) 

5365 

5366 

5367class DeleteTaskScheduleSchema(HardWorkConfirmationSchema): 

5368 """ 

5369 Schema to delete a task schedule. 

5370 """ 

5371 

5372 # name must match ViewParam.SCHEDULE_ID 

5373 schedule_id = HiddenIntegerNode() 

5374 danger = TranslatableValidateDangerousOperationNode() 

5375 

5376 

5377class DeleteTaskScheduleForm(DeleteCancelForm): 

5378 """ 

5379 Form to delete a task schedule. 

5380 """ 

5381 

5382 def __init__(self, request: "CamcopsRequest", **kwargs: Any) -> None: 

5383 super().__init__( 

5384 schema_class=DeleteTaskScheduleSchema, request=request, **kwargs 

5385 ) 

5386 

5387 

5388class DurationWidget(Widget): 

5389 """ 

5390 Widget for entering a duration as a number of months, weeks and days. 

5391 The default template renders three text input fields. 

5392 Total days = (months * 30) + (weeks * 7) + days. 

5393 """ 

5394 

5395 basedir = os.path.join(TEMPLATE_DIR, "deform") 

5396 readonlydir = os.path.join(basedir, "readonly") 

5397 form = "duration.pt" 

5398 template = os.path.join(basedir, form) 

5399 readonly_template = os.path.join(readonlydir, form) 

5400 

5401 def __init__(self, request: "CamcopsRequest", **kwargs: Any) -> None: 

5402 super().__init__(**kwargs) 

5403 self.request = request 

5404 

5405 def serialize( 

5406 self, 

5407 field: "Field", 

5408 cstruct: Union[Dict[str, Any], None, ColanderNullType], 

5409 **kw: Any, 

5410 ) -> Any: 

5411 # called when rendering the form with values from 

5412 # DurationType.serialize 

5413 if cstruct in (None, null): 

5414 cstruct = {} 

5415 

5416 cstruct: Dict[str, Any] 

5417 

5418 months = cstruct.get("months", "") 

5419 weeks = cstruct.get("weeks", "") 

5420 days = cstruct.get("days", "") 

5421 

5422 kw.setdefault("months", months) 

5423 kw.setdefault("weeks", weeks) 

5424 kw.setdefault("days", days) 

5425 

5426 readonly = kw.get("readonly", self.readonly) 

5427 template = readonly and self.readonly_template or self.template 

5428 values = self.get_template_values(field, cstruct, kw) 

5429 

5430 _ = self.request.gettext 

5431 

5432 values.update( 

5433 weeks_placeholder=_("1 week = 7 days"), 

5434 months_placeholder=_("1 month = 30 days"), 

5435 months_label=_("Months"), 

5436 weeks_label=_("Weeks"), 

5437 days_label=_("Days"), 

5438 ) 

5439 

5440 return field.renderer(template, **values) 

5441 

5442 def deserialize( 

5443 self, field: "Field", pstruct: Union[Dict[str, Any], ColanderNullType] 

5444 ) -> Dict[str, int]: 

5445 # called when validating the form on submission 

5446 # value is passed to the schema deserialize() 

5447 

5448 if pstruct is null: 

5449 pstruct = {} 

5450 

5451 pstruct: Dict[str, Any] 

5452 

5453 errors = [] 

5454 

5455 try: 

5456 days = int(pstruct.get("days") or "0") 

5457 except ValueError: 

5458 errors.append("Please enter a valid number of days or leave blank") 

5459 

5460 try: 

5461 weeks = int(pstruct.get("weeks") or "0") 

5462 except ValueError: 

5463 errors.append( 

5464 "Please enter a valid number of weeks or leave blank" 

5465 ) 

5466 

5467 try: 

5468 months = int(pstruct.get("months") or "0") 

5469 except ValueError: 

5470 errors.append( 

5471 "Please enter a valid number of months or leave blank" 

5472 ) 

5473 

5474 if len(errors) > 0: 

5475 raise Invalid(field, errors, pstruct) 

5476 

5477 # noinspection PyUnboundLocalVariable 

5478 return {"days": days, "months": months, "weeks": weeks} 

5479 

5480 

5481class DurationType(SchemaType): 

5482 """ 

5483 Custom colander schema type to convert between Pendulum Duration objects 

5484 and months, weeks and days. 

5485 """ 

5486 

5487 # noinspection PyMethodMayBeStatic,PyUnusedLocal 

5488 def deserialize( 

5489 self, 

5490 node: SchemaNode, 

5491 cstruct: Union[Dict[str, Any], None, ColanderNullType], 

5492 ) -> Optional[Duration]: 

5493 # called when validating the submitted form with the total days 

5494 # from DurationWidget.deserialize() 

5495 if cstruct in (None, null): 

5496 return None 

5497 

5498 cstruct: Dict[str, Any] 

5499 

5500 # may be passed invalid values when re-rendering widget with error 

5501 # messages 

5502 try: 

5503 days = int(cstruct.get("days") or "0") 

5504 except ValueError: 

5505 days = 0 

5506 

5507 try: 

5508 weeks = int(cstruct.get("weeks") or "0") 

5509 except ValueError: 

5510 weeks = 0 

5511 

5512 try: 

5513 months = int(cstruct.get("months") or "0") 

5514 except ValueError: 

5515 months = 0 

5516 

5517 total_days = months * 30 + weeks * 7 + days 

5518 

5519 return Duration(days=total_days) 

5520 

5521 # noinspection PyMethodMayBeStatic,PyUnusedLocal 

5522 def serialize( 

5523 self, node: SchemaNode, duration: Union[Duration, ColanderNullType] 

5524 ) -> Union[Dict, ColanderNullType]: 

5525 if duration is null: 

5526 # For new schedule item 

5527 return null 

5528 

5529 duration: Duration 

5530 

5531 total_days = duration.in_days() 

5532 

5533 months = total_days // 30 

5534 weeks = (total_days % 30) // 7 

5535 days = (total_days % 30) % 7 

5536 

5537 # Existing schedule item 

5538 cstruct = {"days": days, "months": months, "weeks": weeks} 

5539 

5540 return cstruct 

5541 

5542 

5543class DurationNode(SchemaNode, RequestAwareMixin): 

5544 schema_type = DurationType 

5545 

5546 # noinspection PyUnusedLocal,PyAttributeOutsideInit 

5547 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None: 

5548 self.widget = DurationWidget(self.request) 

5549 

5550 

5551class TaskScheduleItemSchema(CSRFSchema): 

5552 schedule_id = HiddenIntegerNode() # name must match ViewParam.SCHEDULE_ID 

5553 # name must match ViewParam.TABLE_NAME 

5554 table_name = MandatorySingleTaskSelector() 

5555 # name must match ViewParam.CLINICIAN_CONFIRMATION 

5556 clinician_confirmation = BooleanNode(default=False) 

5557 due_from = DurationNode() # name must match ViewParam.DUE_FROM 

5558 due_within = DurationNode() # name must match ViewParam.DUE_WITHIN 

5559 

5560 # noinspection PyUnusedLocal 

5561 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None: 

5562 _ = self.gettext 

5563 due_from = get_child_node(self, "due_from") 

5564 due_from.title = _("Due from") 

5565 due_from.description = _( 

5566 "Time from the start of schedule when the patient may begin this " 

5567 "task" 

5568 ) 

5569 due_within = get_child_node(self, "due_within") 

5570 due_within.title = _("Due within") 

5571 due_within.description = _( 

5572 "Time the patient has to complete this task" 

5573 ) 

5574 clinician_confirmation = get_child_node(self, "clinician_confirmation") 

5575 clinician_confirmation.title = _("Allow clinician tasks") 

5576 clinician_confirmation.label = None 

5577 clinician_confirmation.description = _( 

5578 "Tick this box to schedule a task that would normally be " 

5579 "completed by (or with) a clinician" 

5580 ) 

5581 

5582 def validator(self, node: SchemaNode, value: Dict[str, Any]) -> None: 

5583 task_class = self._get_task_class(value) 

5584 

5585 self._validate_clinician_status(node, value, task_class) 

5586 self._validate_due_dates(node, value) 

5587 self._validate_task_ip_use(node, value, task_class) 

5588 

5589 # noinspection PyMethodMayBeStatic 

5590 def _get_task_class(self, value: Dict[str, Any]) -> Type["Task"]: 

5591 return tablename_to_task_class_dict()[value[ViewParam.TABLE_NAME]] 

5592 

5593 def _validate_clinician_status( 

5594 self, node: SchemaNode, value: Dict[str, Any], task_class: Type["Task"] 

5595 ) -> None: 

5596 

5597 _ = self.gettext 

5598 clinician_confirmation = value[ViewParam.CLINICIAN_CONFIRMATION] 

5599 if task_class.has_clinician and not clinician_confirmation: 

5600 raise Invalid( 

5601 node, 

5602 _( 

5603 "You have selected the task '{task_name}', which a " 

5604 "patient would not normally complete by themselves. " 

5605 "If you are sure you want to do this, you must tick " 

5606 "'Allow clinician tasks'." 

5607 ).format(task_name=task_class.shortname), 

5608 ) 

5609 

5610 def _validate_due_dates( 

5611 self, node: SchemaNode, value: Dict[str, Any] 

5612 ) -> None: 

5613 _ = self.gettext 

5614 due_from = value[ViewParam.DUE_FROM] 

5615 if due_from.total_days() < 0: 

5616 raise Invalid(node, _("'Due from' must be zero or more days")) 

5617 

5618 due_within = value[ViewParam.DUE_WITHIN] 

5619 if due_within.total_days() <= 0: 

5620 raise Invalid(node, _("'Due within' must be more than zero days")) 

5621 

5622 def _validate_task_ip_use( 

5623 self, node: SchemaNode, value: Dict[str, Any], task_class: Type["Task"] 

5624 ) -> None: 

5625 

5626 _ = self.gettext 

5627 

5628 if not task_class.prohibits_anything(): 

5629 return 

5630 

5631 schedule_id = value[ViewParam.SCHEDULE_ID] 

5632 schedule = ( 

5633 self.request.dbsession.query(TaskSchedule) 

5634 .filter(TaskSchedule.id == schedule_id) 

5635 .one() 

5636 ) 

5637 

5638 if schedule.group.ip_use is None: 

5639 raise Invalid( 

5640 node, 

5641 _( 

5642 "The task you have selected prohibits use in certain " 

5643 "contexts. The group '{group_name}' has no intellectual " 

5644 "property settings. " 

5645 "You need to edit the group '{group_name}' to say which " 

5646 "contexts it operates in.".format( 

5647 group_name=schedule.group.name 

5648 ) 

5649 ), 

5650 ) 

5651 

5652 # TODO: On the client we say 'to use this task, you must seek 

5653 # permission from the copyright holder'. We could do the same but at 

5654 # the moment there isn't a way of telling the system that we have done 

5655 # so. 

5656 if ( 

5657 task_class.prohibits_commercial 

5658 and schedule.group.ip_use.commercial 

5659 ): 

5660 raise Invalid( 

5661 node, 

5662 _( 

5663 "The group '{group_name}' associated with schedule " 

5664 "'{schedule_name}' operates in a " 

5665 "commercial context but the task you have selected " 

5666 "prohibits commercial use." 

5667 ).format( 

5668 group_name=schedule.group.name, schedule_name=schedule.name 

5669 ), 

5670 ) 

5671 

5672 if task_class.prohibits_clinical and schedule.group.ip_use.clinical: 

5673 raise Invalid( 

5674 node, 

5675 _( 

5676 "The group '{group_name}' associated with schedule " 

5677 "'{schedule_name}' operates in a " 

5678 "clinical context but the task you have selected " 

5679 "prohibits clinical use." 

5680 ).format( 

5681 group_name=schedule.group.name, schedule_name=schedule.name 

5682 ), 

5683 ) 

5684 

5685 if ( 

5686 task_class.prohibits_educational 

5687 and schedule.group.ip_use.educational 

5688 ): 

5689 raise Invalid( 

5690 node, 

5691 _( 

5692 "The group '{group_name}' associated with schedule " 

5693 "'{schedule_name}' operates in an " 

5694 "educational context but the task you have selected " 

5695 "prohibits educational use." 

5696 ).format( 

5697 group_name=schedule.group.name, schedule_name=schedule.name 

5698 ), 

5699 ) 

5700 

5701 if task_class.prohibits_research and schedule.group.ip_use.research: 

5702 raise Invalid( 

5703 node, 

5704 _( 

5705 "The group '{group_name}' associated with schedule " 

5706 "'{schedule_name}' operates in a " 

5707 "research context but the task you have selected " 

5708 "prohibits research use." 

5709 ).format( 

5710 group_name=schedule.group.name, schedule_name=schedule.name 

5711 ), 

5712 ) 

5713 

5714 

5715class EditTaskScheduleItemForm(DynamicDescriptionsNonceForm): 

5716 def __init__(self, request: "CamcopsRequest", **kwargs: Any) -> None: 

5717 schema = TaskScheduleItemSchema().bind(request=request) 

5718 _ = request.gettext 

5719 super().__init__( 

5720 schema, 

5721 request=request, 

5722 buttons=[ 

5723 Button( 

5724 name=FormAction.SUBMIT, 

5725 title=_("Submit"), 

5726 css_class="btn-danger", 

5727 ), 

5728 Button(name=FormAction.CANCEL, title=_("Cancel")), 

5729 ], 

5730 **kwargs, 

5731 ) 

5732 

5733 

5734class DeleteTaskScheduleItemSchema(HardWorkConfirmationSchema): 

5735 """ 

5736 Schema to delete a task schedule item. 

5737 """ 

5738 

5739 # name must match ViewParam.SCHEDULE_ITEM_ID 

5740 schedule_item_id = HiddenIntegerNode() 

5741 danger = TranslatableValidateDangerousOperationNode() 

5742 

5743 

5744class DeleteTaskScheduleItemForm(DeleteCancelForm): 

5745 """ 

5746 Form to delete a task schedule item. 

5747 """ 

5748 

5749 def __init__(self, request: "CamcopsRequest", **kwargs: Any) -> None: 

5750 super().__init__( 

5751 schema_class=DeleteTaskScheduleItemSchema, 

5752 request=request, 

5753 **kwargs, 

5754 ) 

5755 

5756 

5757class ForciblyFinalizeChooseDeviceSchema(CSRFSchema): 

5758 """ 

5759 Schema to force-finalize records from a device. 

5760 """ 

5761 

5762 device_id = MandatoryDeviceIdSelector() # must match ViewParam.DEVICE_ID 

5763 danger = TranslatableValidateDangerousOperationNode() 

5764 

5765 

5766class ForciblyFinalizeChooseDeviceForm(SimpleSubmitForm): 

5767 """ 

5768 Form to force-finalize records from a device. 

5769 """ 

5770 

5771 def __init__(self, request: "CamcopsRequest", **kwargs) -> None: 

5772 _ = request.gettext 

5773 super().__init__( 

5774 schema_class=ForciblyFinalizeChooseDeviceSchema, 

5775 submit_title=_("View affected tasks"), 

5776 request=request, 

5777 **kwargs, 

5778 ) 

5779 

5780 

5781class ForciblyFinalizeConfirmSchema(HardWorkConfirmationSchema): 

5782 """ 

5783 Schema to confirm force-finalizing of a device. 

5784 """ 

5785 

5786 device_id = HiddenIntegerNode() # must match ViewParam.DEVICE_ID 

5787 danger = TranslatableValidateDangerousOperationNode() 

5788 

5789 

5790class ForciblyFinalizeConfirmForm(DangerousForm): 

5791 """ 

5792 Form to confirm force-finalizing of a device. 

5793 """ 

5794 

5795 def __init__(self, request: "CamcopsRequest", **kwargs) -> None: 

5796 _ = request.gettext 

5797 super().__init__( 

5798 schema_class=ForciblyFinalizeConfirmSchema, 

5799 submit_action=FormAction.FINALIZE, 

5800 submit_title=_("Forcibly finalize"), 

5801 request=request, 

5802 **kwargs, 

5803 ) 

5804 

5805 

5806# ============================================================================= 

5807# User downloads 

5808# ============================================================================= 

5809 

5810 

5811class HiddenDownloadFilenameNode(HiddenStringNode, RequestAwareMixin): 

5812 """ 

5813 Note to encode a hidden filename. 

5814 """ 

5815 

5816 # noinspection PyMethodMayBeStatic 

5817 def validator(self, node: SchemaNode, value: str) -> None: 

5818 if value: 

5819 try: 

5820 validate_download_filename(value, self.request) 

5821 except ValueError as e: 

5822 raise Invalid(node, str(e)) 

5823 

5824 

5825class UserDownloadDeleteSchema(CSRFSchema): 

5826 """ 

5827 Schema to capture details of a file to be deleted. 

5828 """ 

5829 

5830 filename = ( 

5831 HiddenDownloadFilenameNode() 

5832 ) # name must match ViewParam.FILENAME # noqa 

5833 

5834 

5835class UserDownloadDeleteForm(SimpleSubmitForm): 

5836 """ 

5837 Form that provides a single button to delete a user download. 

5838 """ 

5839 

5840 def __init__(self, request: "CamcopsRequest", **kwargs) -> None: 

5841 _ = request.gettext 

5842 super().__init__( 

5843 schema_class=UserDownloadDeleteSchema, 

5844 submit_title=_("Delete"), 

5845 request=request, 

5846 **kwargs, 

5847 ) 

5848 

5849 

5850class EmailBodyNode(MandatoryStringNode, RequestAwareMixin): 

5851 def __init__(self, *args, **kwargs) -> None: 

5852 self.title = "" # for type checker 

5853 super().__init__(*args, **kwargs) 

5854 

5855 # noinspection PyUnusedLocal 

5856 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None: 

5857 _ = self.gettext 

5858 

5859 self.title = _("Message") 

5860 

5861 # noinspection PyAttributeOutsideInit 

5862 self.widget = RichTextWidget(options=get_tinymce_options(self.request)) 

5863 

5864 

5865class SendEmailSchema(CSRFSchema): 

5866 email = MandatoryEmailNode() # name must match ViewParam.EMAIL 

5867 email_cc = HiddenStringNode() 

5868 email_bcc = HiddenStringNode() 

5869 email_from = HiddenStringNode() 

5870 email_subject = MandatoryStringNode() 

5871 email_body = EmailBodyNode() 

5872 

5873 

5874class SendEmailForm(InformativeNonceForm): 

5875 """ 

5876 Form for sending email 

5877 """ 

5878 

5879 def __init__(self, request: "CamcopsRequest", **kwargs) -> None: 

5880 schema = SendEmailSchema().bind(request=request) 

5881 _ = request.gettext 

5882 super().__init__( 

5883 schema, 

5884 buttons=[ 

5885 Button(name=FormAction.SUBMIT, title=_("Send")), 

5886 Button(name=FormAction.CANCEL, title=_("Cancel")), 

5887 ], 

5888 **kwargs, 

5889 )