Coverage for cc_modules/cc_forms.py: 52%

2284 statements  

« prev     ^ index     » next       coverage.py v7.9.2, created at 2025-07-15 14:23 +0100

1""" 

2camcops_server/cc_modules/cc_forms.py 

3 

4=============================================================================== 

5 

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

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

8 

9 This file is part of CamCOPS. 

10 

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

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

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

14 (at your option) any later version. 

15 

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

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

18 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 

19 GNU General Public License for more details. 

20 

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

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

23 

24=============================================================================== 

25 

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

27 

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

29 

30*COLANDER NODES, NULLS, AND VALIDATION* 

31 

32- Surprisingly tricky. 

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

34 the Deform framework clones them. 

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

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

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

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

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

40 

41- Note that this error: 

42 

43 .. code-block:: none 

44 

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

46 

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

48 

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

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

51 

52*ACCESSING THE PYRAMID REQUEST IN FORMS AND SCHEMAS* 

53 

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

55sometimes more specialized reasons. 

56 

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

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

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

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

61 

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

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

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

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

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

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

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

69functions, as ``self.bindings``. 

70 

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

72sort of ``__init__`` function: 

73 

74.. code-block:: python 

75 

76 class SomeForm(...): 

77 def __init__(...): 

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

79 super().__init__( 

80 schema, 

81 ..., 

82 **kwargs 

83 ) 

84 

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

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

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

88of binding a request). 

89 

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

91request.gettext``. 

92 

93Form titles need to be dynamically written via 

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

95 

96.. glossary:: 

97 

98 cstruct 

99 See `cstruct 

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

101 in the Deform_ docs. 

102 

103 Colander 

104 See `Colander 

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

106 in the Deform_ docs. 

107 

108 field 

109 See `field 

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

111 in the Deform_ docs. 

112 

113 Peppercorn 

114 See `Peppercorn 

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

116 in the Deform_ docs. 

117 

118 pstruct 

119 See `pstruct 

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

121 in the Deform_ docs. 

122 

123""" 

124 

125from io import BytesIO 

126import json 

127import logging 

128import os 

129from typing import ( 

130 Any, 

131 Callable, 

132 Dict, 

133 List, 

134 Optional, 

135 Tuple, 

136 Type, 

137 TYPE_CHECKING, 

138 Union, 

139) 

140 

141from cardinal_pythonlib.colander_utils import ( 

142 AllowNoneType, 

143 BooleanNode, 

144 DateSelectorNode, 

145 DateTimeSelectorNode, 

146 DEFAULT_WIDGET_DATE_OPTIONS_FOR_PENDULUM, 

147 DEFAULT_WIDGET_TIME_OPTIONS_FOR_PENDULUM, 

148 get_child_node, 

149 get_values_and_permissible, 

150 HiddenIntegerNode, 

151 HiddenStringNode, 

152 MandatoryEmailNode, 

153 MandatoryStringNode, 

154 OptionalEmailNode, 

155 OptionalIntNode, 

156 OptionalPendulumNode, 

157 OptionalStringNode, 

158 ValidateDangerousOperationNode, 

159) 

160from cardinal_pythonlib.deform_utils import ( 

161 DynamicDescriptionsForm, 

162 InformativeForm, 

163) 

164from cardinal_pythonlib.httpconst import HttpMethod 

165from cardinal_pythonlib.logs import BraceStyleAdapter 

166from cardinal_pythonlib.sqlalchemy.dialect import SqlaDialectName 

167from cardinal_pythonlib.sqlalchemy.orm_query import CountStarSpecializedQuery 

168 

169# noinspection PyProtectedMember 

170from colander import ( 

171 Boolean, 

172 Date, 

173 drop, 

174 Integer, 

175 Invalid, 

176 Length, 

177 MappingSchema, 

178 null, 

179 OneOf, 

180 Range, 

181 Schema, 

182 SchemaNode, 

183 SchemaType, 

184 SequenceSchema, 

185 Set, 

186 String, 

187 _null, 

188 url, 

189) 

190from deform.form import Button 

191from deform.widget import ( 

192 CheckboxChoiceWidget, 

193 CheckedPasswordWidget, 

194 # DateInputWidget, 

195 DateTimeInputWidget, 

196 FormWidget, 

197 HiddenWidget, 

198 MappingWidget, 

199 PasswordWidget, 

200 RadioChoiceWidget, 

201 RichTextWidget, 

202 SelectWidget, 

203 SequenceWidget, 

204 TextAreaWidget, 

205 TextInputWidget, 

206 Widget, 

207) 

208 

209from pendulum import Duration 

210import phonenumbers 

211import pyotp 

212import qrcode 

213import qrcode.image.svg 

214 

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

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

217from camcops_server.cc_modules.cc_baseconstants import ( 

218 DEFORM_SUPPORTS_CSP_NONCE, 

219 TEMPLATE_DIR, 

220) 

221from camcops_server.cc_modules.cc_constants import ( 

222 ConfigParamSite, 

223 DEFAULT_ROWS_PER_PAGE, 

224 MfaMethod, 

225 MINIMUM_PASSWORD_LENGTH, 

226 SEX_OTHER_UNSPECIFIED, 

227 SEX_FEMALE, 

228 SEX_MALE, 

229 StringLengths, 

230 USER_NAME_FOR_SYSTEM, 

231) 

232from camcops_server.cc_modules.cc_group import Group 

233from camcops_server.cc_modules.cc_idnumdef import ( 

234 IdNumDefinition, 

235 ID_NUM_VALIDATION_METHOD_CHOICES, 

236 validate_id_number, 

237) 

238from camcops_server.cc_modules.cc_ipuse import IpUse 

239from camcops_server.cc_modules.cc_language import ( 

240 DEFAULT_LOCALE, 

241 POSSIBLE_LOCALES, 

242 POSSIBLE_LOCALES_WITH_DESCRIPTIONS, 

243) 

244from camcops_server.cc_modules.cc_patient import Patient 

245from camcops_server.cc_modules.cc_patientidnum import PatientIdNum 

246from camcops_server.cc_modules.cc_policy import ( 

247 TABLET_ID_POLICY_STR, 

248 TokenizedPolicy, 

249) 

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

251from camcops_server.cc_modules.cc_task import tablename_to_task_class_dict 

252from camcops_server.cc_modules.cc_taskschedule import ( 

253 TaskSchedule, 

254 TaskScheduleEmailTemplateFormatter, 

255) 

256from camcops_server.cc_modules.cc_validators import ( 

257 ALPHANUM_UNDERSCORE_CHAR, 

258 validate_anything, 

259 validate_by_char_and_length, 

260 validate_download_filename, 

261 validate_group_name, 

262 validate_hl7_aa, 

263 validate_hl7_id_type, 

264 validate_ip_address, 

265 validate_new_password, 

266 validate_redirect_url, 

267 validate_username, 

268) 

269 

270if TYPE_CHECKING: 

271 from deform.field import Field 

272 from camcops_server.cc_modules.cc_request import CamcopsRequest 

273 from camcops_server.cc_modules.cc_task import Task 

274 from camcops_server.cc_modules.cc_user import User 

275 

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

277 

278ColanderNullType = _null 

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

280 

281 

282# ============================================================================= 

283# Debugging options 

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

285 

286DEBUG_CSRF_CHECK = False 

287 

288if DEBUG_CSRF_CHECK: 

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

290 

291 

292# ============================================================================= 

293# Constants 

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

295 

296DEFORM_ACCORDION_BUG = True 

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

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

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

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

301 

302 

303class Binding(object): 

304 """ 

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

306 

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

308 """ 

309 

310 GROUP = "group" 

311 OPEN_ADMIN = "open_admin" 

312 OPEN_WHAT = "open_what" 

313 OPEN_WHEN = "open_when" 

314 OPEN_WHO = "open_who" 

315 REQUEST = "request" 

316 TRACKER_TASKS_ONLY = "tracker_tasks_only" 

317 USER = "user" 

318 

319 

320class BootstrapCssClasses(object): 

321 """ 

322 Constants from Bootstrap to control display. 

323 """ 

324 

325 FORM_INLINE = "form-inline" 

326 RADIO_INLINE = "radio-inline" 

327 LIST_INLINE = "list-inline" 

328 CHECKBOX_INLINE = "checkbox-inline" 

329 

330 

331AUTOCOMPLETE_ATTR = "autocomplete" 

332 

333 

334class AutocompleteAttrValues(object): 

335 """ 

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

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

338 Not all are used. 

339 """ 

340 

341 BDAY = "bday" 

342 CURRENT_PASSWORD = "current-password" 

343 EMAIL = "email" 

344 FAMILY_NAME = "family-name" 

345 GIVEN_NAME = "given-name" 

346 NEW_PASSWORD = "new-password" 

347 OFF = "off" 

348 ON = "on" # browser decides 

349 STREET_ADDRESS = "stree-address" 

350 USERNAME = "username" 

351 

352 

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

354 return { 

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

356 "menubar": "false", 

357 "plugins": "link", 

358 "toolbar": ( 

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

360 "bullist numlist | " 

361 "alignleft aligncenter alignright alignjustify | " 

362 "outdent indent" 

363 ), 

364 "language": request.language_iso_639_1, 

365 } 

366 

367 

368# ============================================================================= 

369# Common phrases for translation 

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

371 

372 

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

374 _ = request.gettext 

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

376 

377 

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

379 _ = request.gettext 

380 return _("Change password") 

381 

382 

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

384 _ = request.gettext 

385 return [ 

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

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

388 # TRANSLATOR: sex code description 

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

390 ] 

391 

392 

393# ============================================================================= 

394# Deform bug fix: SelectWidget "multiple" attribute 

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

396 

397 

398class BugfixSelectWidget(SelectWidget): 

399 """ 

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

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

402 

403 .. code-block:: none 

404 

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

406 ^^^^^^^^^^^^^^^^ 

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

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

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

410 </select> 

411 

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

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

414 Chameleon that exposed a bug in Deform.) 

415 

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

417 """ # noqa 

418 

419 def __init__(self, multiple: bool = False, **kwargs: Any) -> None: 

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

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

422 

423 

424SelectWidget = BugfixSelectWidget 

425 

426 

427# ============================================================================= 

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

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

430 

431 

432class InformativeNonceForm(InformativeForm): 

433 """ 

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

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

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

437 

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

439 """ 

440 

441 if DEFORM_SUPPORTS_CSP_NONCE: 

442 

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

444 request = schema.request # type: CamcopsRequest 

445 kwargs["nonce"] = request.nonce 

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

447 

448 

449class DynamicDescriptionsNonceForm(DynamicDescriptionsForm): 

450 """ 

451 Similarly; see :class:`InformativeNonceForm`. 

452 

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

454 """ 

455 

456 if DEFORM_SUPPORTS_CSP_NONCE: 

457 

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

459 request = schema.request # type: CamcopsRequest 

460 kwargs["nonce"] = request.nonce 

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

462 

463 

464# ============================================================================= 

465# Mixin for Schema/SchemaNode objects for translation 

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

467 

468GETTEXT_TYPE = Callable[[str], str] 

469 

470 

471class RequestAwareMixin(object): 

472 """ 

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

474 together with some translations and other convenience functions. 

475 """ 

476 

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

478 # Stop multiple inheritance complaints 

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

480 

481 # noinspection PyUnresolvedReferences 

482 @property 

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

484 return self.bindings[Binding.REQUEST] # type: ignore[attr-defined] 

485 

486 # noinspection PyUnresolvedReferences,PyPropertyDefinition 

487 @property 

488 def gettext(self) -> GETTEXT_TYPE: 

489 return self.request.gettext 

490 

491 @property 

492 def or_join_description(self) -> str: 

493 return or_join_description(self.request) 

494 

495 

496# ============================================================================= 

497# Translatable version of ValidateDangerousOperationNode 

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

499 

500 

501class TranslatableValidateDangerousOperationNode( 

502 ValidateDangerousOperationNode, RequestAwareMixin 

503): 

504 """ 

505 Translatable version of ValidateDangerousOperationNode. 

506 """ 

507 

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

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

510 _ = self.gettext 

511 node.title = _("Danger") 

512 user_entry = get_child_node(self, "user_entry") 

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

514 

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

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

517 _ = self.gettext 

518 user_entry = get_child_node(self, "user_entry") 

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

520 user_entry.description = prefix + target_value 

521 

522 

523# ============================================================================= 

524# Translatable version of SequenceWidget 

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

526 

527 

528class TranslatableSequenceWidget(SequenceWidget): 

529 """ 

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

531 request-specific way. 

532 """ 

533 

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

535 super().__init__(**kwargs) 

536 _ = request.gettext 

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

538 

539 

540# ============================================================================= 

541# Translatable version of OptionalPendulumNode 

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

543 

544 

545class TranslatableOptionalPendulumNode( 

546 OptionalPendulumNode, RequestAwareMixin 

547): 

548 """ 

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

550 the request. 

551 

552 .. todo:: TranslatableOptionalPendulumNode not fully implemented 

553 """ 

554 

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

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

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

558 

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

560 _ = self.gettext 

561 self.widget = DateTimeInputWidget( 

562 date_options=DEFAULT_WIDGET_DATE_OPTIONS_FOR_PENDULUM, 

563 time_options=DEFAULT_WIDGET_TIME_OPTIONS_FOR_PENDULUM, 

564 ) 

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

566 # self.widget.__dict__) 

567 

568 

569class TranslatableDateTimeSelectorNode( 

570 DateTimeSelectorNode, RequestAwareMixin 

571): 

572 """ 

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

574 the request. 

575 

576 .. todo:: TranslatableDateTimeSelectorNode not fully implemented 

577 """ 

578 

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

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

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

582 

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

584 _ = self.gettext 

585 self.widget = DateTimeInputWidget() 

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

587 # self.widget.__dict__) 

588 

589 

590''' 

591class TranslatableDateSelectorNode(DateSelectorNode, 

592 RequestAwareMixin): 

593 """ 

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

595 the request. 

596 

597 .. todo:: TranslatableDateSelectorNode not fully implemented 

598 """ 

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

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

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

602 

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

604 _ = self.gettext 

605 self.widget = DateInputWidget() 

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

607 # self.widget.__dict__) 

608''' 

609 

610 

611# ============================================================================= 

612# CSRF 

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

614 

615 

616class CSRFToken(SchemaNode, RequestAwareMixin): 

617 """ 

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

619 form. 

620 

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

622 more recent Colander API. 

623 

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

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

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

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

628 mechanism 

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

630 

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

632 

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

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

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

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

637 

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

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

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

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

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

643 value." 

644 

645 RNC: Serialized values are always STRINGS. 

646 

647 """ 

648 

649 schema_type = String 

650 default = "" 

651 missing = "" 

652 title = " " 

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

654 # fails 

655 widget = HiddenWidget() 

656 

657 # noinspection PyUnusedLocal 

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

659 request = self.request 

660 csrf_token = request.session.get_csrf_token() 

661 if DEBUG_CSRF_CHECK: 

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

663 self.default = csrf_token 

664 

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

666 # Deferred validator via method, as per 

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

668 request = self.request 

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

670 matches = value == csrf_token 

671 if DEBUG_CSRF_CHECK: 

672 log.debug( 

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

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

675 value, 

676 csrf_token, 

677 matches, 

678 ) 

679 if not matches: 

680 log.warning( 

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

682 ) 

683 _ = request.gettext 

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

685 

686 

687class CSRFSchema(Schema, RequestAwareMixin): 

688 """ 

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

690 forgery) tokens. 

691 

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

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

694 calling ``__init__()```... 

695 

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

697 or you get: 

698 

699 .. code-block:: none 

700 

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

702 __RequestVerificationToken, csrfmiddlewaretoken, authenticity_token, 

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

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

705 "deformField1" "deformField2" "deformField3" "deformField4" ]. 

706 

707 """ 

708 

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

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

711 

712 

713# ============================================================================= 

714# Horizontal forms 

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

716 

717 

718class HorizontalFormWidget(FormWidget): 

719 """ 

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

721 

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

723 are resolved to Chameleon ZPT (Zope) templates. 

724 

725 See 

726 

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

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

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

730 """ # noqa 

731 

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

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

734 form = "horizontal_form.pt" 

735 mapping_item = "horizontal_mapping_item.pt" 

736 

737 template = os.path.join( 

738 basedir, form 

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

740 readonly_template = os.path.join( 

741 readonlydir, form 

742 ) # default "readonly/form" 

743 item_template = os.path.join( 

744 basedir, mapping_item 

745 ) # default "mapping_item" 

746 readonly_item_template = os.path.join( 

747 readonlydir, mapping_item 

748 ) # default "readonly/mapping_item" 

749 

750 

751class HorizontalFormMixin(object): 

752 """ 

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

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

755 """ 

756 

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

758 kwargs = kwargs or {} 

759 

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

761 # extra_classes = "form-inline" 

762 # if "css_class" in kwargs: 

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

764 # else: 

765 # kwargs["css_class"] = extra_classes 

766 

767 # Method 2: change the widget 

768 schema.widget = HorizontalFormWidget() 

769 

770 # OK, proceed. 

771 super().__init__(schema, *args, **kwargs) # type: ignore[call-arg] 

772 

773 

774def add_css_class( 

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

776) -> None: 

777 """ 

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

779 parameter. 

780 

781 Args: 

782 kwargs: a dictionary 

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

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

785 """ 

786 if param_name in kwargs: 

787 kwargs[param_name] += " " + extra_classes 

788 else: 

789 kwargs[param_name] = extra_classes 

790 

791 

792class FormInlineCssMixin(object): 

793 """ 

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

795 has the effect of wrapping everything horizontally. 

796 

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

798 inheritance order. 

799 """ 

800 

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

802 kwargs = kwargs or {} 

803 add_css_class(kwargs, BootstrapCssClasses.FORM_INLINE) 

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

805 

806 

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

808 """ 

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

810 """ 

811 widget.item_css_class = BootstrapCssClasses.FORM_INLINE 

812 

813 

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

815 """ 

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

817 

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

819 constructor. 

820 """ 

821 make_widget_horizontal(node.widget) 

822 

823 

824# ============================================================================= 

825# Specialized Form classes 

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

827 

828 

829class SimpleSubmitForm(InformativeNonceForm): 

830 """ 

831 Form with a simple "submit" button. 

832 """ 

833 

834 def __init__( 

835 self, 

836 schema_class: Type[Schema], 

837 submit_title: str, 

838 request: "CamcopsRequest", 

839 **kwargs: Any, 

840 ) -> None: 

841 """ 

842 Args: 

843 schema_class: 

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

845 schema 

846 submit_title: 

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

848 request: 

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

850 """ 

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

852 super().__init__( 

853 schema, 

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

855 **kwargs, 

856 ) 

857 

858 

859class OkForm(SimpleSubmitForm): 

860 """ 

861 Form with a button that says "OK". 

862 """ 

863 

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

865 _ = request.gettext 

866 super().__init__( 

867 schema_class=CSRFSchema, 

868 submit_title=_("OK"), 

869 request=request, 

870 **kwargs, 

871 ) 

872 

873 

874class ApplyCancelForm(InformativeNonceForm): 

875 """ 

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

877 """ 

878 

879 def __init__( 

880 self, 

881 schema_class: Type[Schema], 

882 request: "CamcopsRequest", 

883 **kwargs: Any, 

884 ) -> None: 

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

886 _ = request.gettext 

887 super().__init__( 

888 schema, 

889 buttons=[ 

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

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

892 ], 

893 **kwargs, 

894 ) 

895 

896 

897class AddCancelForm(InformativeNonceForm): 

898 """ 

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

900 """ 

901 

902 def __init__( 

903 self, 

904 schema_class: Type[Schema], 

905 request: "CamcopsRequest", 

906 **kwargs: Any, 

907 ) -> None: 

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

909 _ = request.gettext 

910 super().__init__( 

911 schema, 

912 buttons=[ 

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

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

915 ], 

916 **kwargs, 

917 ) 

918 

919 

920class DangerousForm(DynamicDescriptionsNonceForm): 

921 """ 

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

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

924 "Cancel" button. 

925 """ 

926 

927 def __init__( 

928 self, 

929 schema_class: Type[Schema], 

930 submit_action: str, 

931 submit_title: str, 

932 request: "CamcopsRequest", 

933 **kwargs: Any, 

934 ) -> None: 

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

936 _ = request.gettext 

937 super().__init__( 

938 schema, 

939 buttons=[ 

940 Button( 

941 name=submit_action, 

942 title=submit_title, 

943 css_class="btn-danger", 

944 ), 

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

946 ], 

947 **kwargs, 

948 ) 

949 

950 

951class DeleteCancelForm(DangerousForm): 

952 """ 

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

954 button. 

955 """ 

956 

957 def __init__( 

958 self, 

959 schema_class: Type[Schema], 

960 request: "CamcopsRequest", 

961 **kwargs: Any, 

962 ) -> None: 

963 _ = request.gettext 

964 super().__init__( 

965 schema_class=schema_class, 

966 submit_action=FormAction.DELETE, 

967 submit_title=_("Delete"), 

968 request=request, 

969 **kwargs, 

970 ) 

971 

972 

973# ============================================================================= 

974# Specialized SchemaNode classes used in several contexts 

975# ============================================================================= 

976 

977# ----------------------------------------------------------------------------- 

978# Task types 

979# ----------------------------------------------------------------------------- 

980 

981 

982class OptionalSingleTaskSelector(OptionalStringNode, RequestAwareMixin): 

983 """ 

984 Node to pick one task type. 

985 """ 

986 

987 def __init__( 

988 self, *args: Any, tracker_tasks_only: bool = False, **kwargs: Any 

989 ) -> None: 

990 """ 

991 Args: 

992 tracker_tasks_only: restrict the choices to tasks that offer 

993 trackers. 

994 """ 

995 self.title = "" # for type checker 

996 self.tracker_tasks_only = tracker_tasks_only 

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

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

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

1000 

1001 # noinspection PyUnusedLocal 

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

1003 _ = self.gettext 

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

1005 if Binding.TRACKER_TASKS_ONLY in kw: 

1006 self.tracker_tasks_only = kw[Binding.TRACKER_TASKS_ONLY] 

1007 values, pv = get_values_and_permissible( 

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

1009 ) 

1010 self.widget = SelectWidget(values=values) 

1011 self.validator = OneOf(pv) 

1012 

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

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

1015 

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

1017 for tc in Task.all_subclasses_by_shortname(): 

1018 if self.tracker_tasks_only and not tc.provides_trackers: 

1019 continue 

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

1021 return choices 

1022 

1023 

1024class MandatorySingleTaskSelector(MandatoryStringNode, RequestAwareMixin): 

1025 """ 

1026 Node to pick one task type. 

1027 """ 

1028 

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

1030 self.title = "" # for type checker 

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

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

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

1034 

1035 # noinspection PyUnusedLocal 

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

1037 _ = self.gettext 

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

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

1040 self.widget = SelectWidget(values=values) 

1041 self.validator = OneOf(pv) 

1042 

1043 @staticmethod 

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

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

1046 

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

1048 for tc in Task.all_subclasses_by_shortname(): 

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

1050 return choices 

1051 

1052 

1053class MultiTaskSelector(SchemaNode, RequestAwareMixin): 

1054 """ 

1055 Node to select multiple task types. 

1056 """ 

1057 

1058 schema_type = Set 

1059 default = "" 

1060 missing = "" 

1061 

1062 def __init__( 

1063 self, 

1064 *args: Any, 

1065 tracker_tasks_only: bool = False, 

1066 minimum_number: int = 0, 

1067 **kwargs: Any, 

1068 ) -> None: 

1069 self.tracker_tasks_only = tracker_tasks_only 

1070 self.minimum_number = minimum_number 

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

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

1073 self.title = "" # for type checker 

1074 self.description = "" # for type checker 

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

1076 

1077 # noinspection PyUnusedLocal 

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

1079 _ = self.gettext 

1080 request = self.request # noqa: F841 

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

1082 self.description = ( 

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

1084 + " " 

1085 + self.or_join_description 

1086 ) 

1087 if Binding.TRACKER_TASKS_ONLY in kw: 

1088 self.tracker_tasks_only = kw[Binding.TRACKER_TASKS_ONLY] 

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

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

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

1092 

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

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

1095 

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

1097 for tc in Task.all_subclasses_by_shortname(): 

1098 if self.tracker_tasks_only and not tc.provides_trackers: 

1099 continue 

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

1101 return choices 

1102 

1103 

1104# ----------------------------------------------------------------------------- 

1105# Use the task index? 

1106# ----------------------------------------------------------------------------- 

1107 

1108 

1109class ViaIndexSelector(BooleanNode, RequestAwareMixin): 

1110 """ 

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

1112 Default is true. 

1113 """ 

1114 

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

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

1117 

1118 # noinspection PyUnusedLocal 

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

1120 _ = self.gettext 

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

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

1123 

1124 

1125# ----------------------------------------------------------------------------- 

1126# ID numbers 

1127# ----------------------------------------------------------------------------- 

1128 

1129 

1130class MandatoryWhichIdNumSelector(SchemaNode, RequestAwareMixin): 

1131 """ 

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

1133 or "study Blah ID number"). 

1134 """ 

1135 

1136 widget = SelectWidget() 

1137 

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

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

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

1141 # OptionalWhichIdNumSelector 

1142 self.allow_none = False 

1143 self.title = "" # for type checker 

1144 self.description = "" # for type checker 

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

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

1147 

1148 # noinspection PyUnusedLocal 

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

1150 request = self.request 

1151 _ = request.gettext 

1152 self.title = _("Identifier") 

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

1154 for iddef in request.idnum_definitions: 

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

1156 values, pv = get_values_and_permissible( 

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

1158 ) 

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

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

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

1162 # SelectWidget. 

1163 self.widget.values = values 

1164 self.validator = OneOf(pv) 

1165 

1166 @staticmethod 

1167 def schema_type() -> SchemaType: 

1168 return Integer() 

1169 

1170 

1171class LinkingIdNumSelector(MandatoryWhichIdNumSelector): 

1172 """ 

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

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

1175 """ 

1176 

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

1178 super().after_bind(node, kw) 

1179 _ = self.gettext 

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

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

1182 

1183 

1184class MandatoryIdNumValue(SchemaNode, RequestAwareMixin): 

1185 """ 

1186 Mandatory node to capture an ID number value. 

1187 """ 

1188 

1189 schema_type = Integer 

1190 validator = Range(min=0) 

1191 

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

1193 self.title = "" # for type checker 

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

1195 

1196 # noinspection PyUnusedLocal 

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

1198 _ = self.gettext 

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

1200 

1201 

1202class MandatoryIdNumNode(MappingSchema, RequestAwareMixin): 

1203 """ 

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

1205 ID number (value). 

1206 

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

1208 """ 

1209 

1210 which_idnum = ( 

1211 MandatoryWhichIdNumSelector() 

1212 ) # must match ViewParam.WHICH_IDNUM 

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

1214 

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

1216 self.title = "" # for type checker 

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

1218 

1219 # noinspection PyUnusedLocal 

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

1221 _ = self.gettext 

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

1223 

1224 # noinspection PyMethodMayBeStatic 

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

1226 assert isinstance(value, dict) 

1227 req = self.request 

1228 _ = req.gettext 

1229 which_idnum = value[ViewParam.WHICH_IDNUM] 

1230 idnum_value = value[ViewParam.IDNUM_VALUE] 

1231 idnum_def = req.get_idnum_definition(which_idnum) 

1232 if not idnum_def: 

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

1234 method = idnum_def.validation_method 

1235 if method: 

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

1237 if not valid: 

1238 raise Invalid(node, why_invalid) 

1239 

1240 

1241class IdNumSequenceAnyCombination(SequenceSchema, RequestAwareMixin): 

1242 """ 

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

1244 """ 

1245 

1246 idnum_sequence = MandatoryIdNumNode() 

1247 

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

1249 self.title = "" # for type checker 

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

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

1252 

1253 # noinspection PyUnusedLocal 

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

1255 _ = self.gettext 

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

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

1258 

1259 # noinspection PyMethodMayBeStatic 

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

1261 assert isinstance(value, list) 

1262 list_of_lists = [ 

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

1264 ] 

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

1266 _ = self.gettext 

1267 raise Invalid( 

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

1269 ) 

1270 

1271 

1272class IdNumSequenceUniquePerWhichIdnum(SequenceSchema, RequestAwareMixin): 

1273 """ 

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

1275 up to one per ID number type. 

1276 """ 

1277 

1278 idnum_sequence = MandatoryIdNumNode() 

1279 

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

1281 self.title = "" # for type checker 

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

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

1284 

1285 # noinspection PyUnusedLocal 

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

1287 _ = self.gettext 

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

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

1290 

1291 # noinspection PyMethodMayBeStatic 

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

1293 assert isinstance(value, list) 

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

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

1296 _ = self.gettext 

1297 raise Invalid( 

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

1299 ) 

1300 

1301 

1302# ----------------------------------------------------------------------------- 

1303# Sex 

1304# ----------------------------------------------------------------------------- 

1305 

1306 

1307class OptionalSexSelector(OptionalStringNode, RequestAwareMixin): 

1308 """ 

1309 Optional node to choose sex. 

1310 """ 

1311 

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

1313 self.title = "" # for type checker 

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

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

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

1317 

1318 # noinspection PyUnusedLocal 

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

1320 _ = self.gettext 

1321 self.title = _("Sex") 

1322 choices = sex_choices(self.request) 

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

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

1325 self.validator = OneOf(pv) 

1326 

1327 

1328class MandatorySexSelector(MandatoryStringNode, RequestAwareMixin): 

1329 """ 

1330 Mandatory node to choose sex. 

1331 """ 

1332 

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

1334 self.title = "" # for type checker 

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

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

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

1338 

1339 # noinspection PyUnusedLocal 

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

1341 _ = self.gettext 

1342 self.title = _("Sex") 

1343 choices = sex_choices(self.request) 

1344 values, pv = get_values_and_permissible(choices) 

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

1346 self.validator = OneOf(pv) 

1347 

1348 

1349# ----------------------------------------------------------------------------- 

1350# Users 

1351# ----------------------------------------------------------------------------- 

1352 

1353 

1354class MandatoryUserIdSelectorUsersAllowedToSee(SchemaNode, RequestAwareMixin): 

1355 """ 

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

1357 is allowed to see. 

1358 """ 

1359 

1360 schema_type = Integer 

1361 

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

1363 self.title = "" # for type checker 

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

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

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

1367 

1368 # noinspection PyUnusedLocal 

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

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

1371 

1372 _ = self.gettext 

1373 self.title = _("User") 

1374 request = self.request 

1375 dbsession = request.dbsession 

1376 user = request.user 

1377 if user.superuser: 

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

1379 else: 

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

1381 my_allowed_group_ids = user.ids_of_groups_user_may_see 

1382 users = ( 

1383 dbsession.query(User) 

1384 .join(Group) 

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

1386 .order_by(User.username) 

1387 ) 

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

1389 for user in users: 

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

1391 values, pv = get_values_and_permissible(values, False) 

1392 self.widget = SelectWidget(values=values) 

1393 self.validator = OneOf(pv) 

1394 

1395 

1396class OptionalUserNameSelector(OptionalStringNode, RequestAwareMixin): 

1397 """ 

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

1399 """ 

1400 

1401 title = "User" 

1402 

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

1404 self.title = "" # for type checker 

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

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

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

1408 

1409 # noinspection PyUnusedLocal 

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

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

1412 

1413 _ = self.gettext 

1414 self.title = _("User") 

1415 request = self.request 

1416 dbsession = request.dbsession 

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

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

1419 for user in users: 

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

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

1422 self.widget = SelectWidget(values=values) 

1423 self.validator = OneOf(pv) 

1424 

1425 

1426class UsernameNode(SchemaNode, RequestAwareMixin): 

1427 """ 

1428 Node to enter a username. 

1429 """ 

1430 

1431 schema_type = String 

1432 widget = TextInputWidget( 

1433 attributes={AUTOCOMPLETE_ATTR: AutocompleteAttrValues.OFF} 

1434 ) 

1435 

1436 def __init__( 

1437 self, 

1438 *args: Any, 

1439 autocomplete: str = AutocompleteAttrValues.OFF, 

1440 **kwargs: Any, 

1441 ) -> None: 

1442 self.title = "" # for type checker 

1443 self.autocomplete = autocomplete 

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

1445 

1446 # noinspection PyUnusedLocal 

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

1448 _ = self.gettext 

1449 self.title = _("Username") 

1450 # noinspection PyUnresolvedReferences 

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

1452 

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

1454 if value == USER_NAME_FOR_SYSTEM: 

1455 _ = self.gettext 

1456 raise Invalid( 

1457 node, 

1458 _("Cannot use system username") 

1459 + " " 

1460 + repr(USER_NAME_FOR_SYSTEM), 

1461 ) 

1462 try: 

1463 validate_username(value, self.request) 

1464 except ValueError as e: 

1465 raise Invalid(node, str(e)) 

1466 

1467 

1468class UserFilterSchema(Schema, RequestAwareMixin): 

1469 """ 

1470 Schema to filter the list of users 

1471 """ 

1472 

1473 # must match ViewParam.INCLUDE_AUTO_GENERATED 

1474 include_auto_generated = BooleanNode() 

1475 

1476 # noinspection PyUnusedLocal 

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

1478 _ = self.gettext 

1479 include_auto_generated = get_child_node(self, "include_auto_generated") 

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

1481 include_auto_generated.label = None # type: ignore[attr-defined] 

1482 

1483 

1484class UserFilterForm(InformativeNonceForm): 

1485 """ 

1486 Form to filter the list of users 

1487 """ 

1488 

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

1490 _ = request.gettext 

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

1492 super().__init__( 

1493 schema, 

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

1495 css_class=BootstrapCssClasses.FORM_INLINE, 

1496 method=HttpMethod.GET, 

1497 **kwargs, 

1498 ) 

1499 

1500 

1501# ----------------------------------------------------------------------------- 

1502# Devices 

1503# ----------------------------------------------------------------------------- 

1504 

1505 

1506class MandatoryDeviceIdSelector(SchemaNode, RequestAwareMixin): 

1507 """ 

1508 Mandatory node to select a client device ID. 

1509 """ 

1510 

1511 schema_type = Integer 

1512 

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

1514 self.title = "" # for type checker 

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

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

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

1518 

1519 # noinspection PyUnusedLocal 

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

1521 from camcops_server.cc_modules.cc_device import ( 

1522 Device, 

1523 ) # delayed import 

1524 

1525 _ = self.gettext 

1526 self.title = _("Device") 

1527 request = self.request 

1528 dbsession = request.dbsession 

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

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

1531 for device in devices: 

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

1533 values, pv = get_values_and_permissible(values, False) 

1534 self.widget = SelectWidget(values=values) 

1535 self.validator = OneOf(pv) 

1536 

1537 

1538# ----------------------------------------------------------------------------- 

1539# Server PK 

1540# ----------------------------------------------------------------------------- 

1541 

1542 

1543class ServerPkSelector(OptionalIntNode, RequestAwareMixin): 

1544 """ 

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

1546 """ 

1547 

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

1549 self.title = "" # for type checker 

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

1551 

1552 # noinspection PyUnusedLocal 

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

1554 _ = self.gettext 

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

1556 

1557 

1558# ----------------------------------------------------------------------------- 

1559# Dates/times 

1560# ----------------------------------------------------------------------------- 

1561 

1562 

1563class StartPendulumSelector( 

1564 TranslatableOptionalPendulumNode, RequestAwareMixin 

1565): 

1566 """ 

1567 Optional node to select a start date/time. 

1568 """ 

1569 

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

1571 self.title = "" # for type checker 

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

1573 

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

1575 super().after_bind(node, kw) 

1576 _ = self.gettext 

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

1578 

1579 

1580class EndPendulumSelector(TranslatableOptionalPendulumNode, RequestAwareMixin): 

1581 """ 

1582 Optional node to select an end date/time. 

1583 """ 

1584 

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

1586 self.title = "" # for type checker 

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

1588 

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

1590 super().after_bind(node, kw) 

1591 _ = self.gettext 

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

1593 

1594 

1595class StartDateTimeSelector( 

1596 TranslatableDateTimeSelectorNode, RequestAwareMixin 

1597): 

1598 """ 

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

1600 """ 

1601 

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

1603 self.title = "" # for type checker 

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

1605 

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

1607 super().after_bind(node, kw) 

1608 _ = self.gettext 

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

1610 

1611 

1612class EndDateTimeSelector(TranslatableDateTimeSelectorNode, RequestAwareMixin): 

1613 """ 

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

1615 """ 

1616 

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

1618 self.title = "" # for type checker 

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

1620 

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

1622 super().after_bind(node, kw) 

1623 _ = self.gettext 

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

1625 

1626 

1627''' 

1628class StartDateSelector(TranslatableDateSelectorNode, 

1629 RequestAwareMixin): 

1630 """ 

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

1632 """ 

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

1634 self.title = "" # for type checker 

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

1636 

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

1638 super().after_bind(node, kw) 

1639 _ = self.gettext 

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

1641 

1642 

1643class EndDateSelector(TranslatableDateSelectorNode, 

1644 RequestAwareMixin): 

1645 """ 

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

1647 """ 

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

1649 self.title = "" # for type checker 

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

1651 

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

1653 super().after_bind(node, kw) 

1654 _ = self.gettext 

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

1656''' 

1657 

1658 

1659# ----------------------------------------------------------------------------- 

1660# Rows per page 

1661# ----------------------------------------------------------------------------- 

1662 

1663 

1664class RowsPerPageSelector(SchemaNode, RequestAwareMixin): 

1665 """ 

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

1667 """ 

1668 

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

1670 

1671 schema_type = Integer 

1672 default = DEFAULT_ROWS_PER_PAGE 

1673 widget = RadioChoiceWidget(values=_choices) 

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

1675 

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

1677 self.title = "" # for type checker 

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

1679 

1680 # noinspection PyUnusedLocal 

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

1682 _ = self.gettext 

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

1684 

1685 

1686# ----------------------------------------------------------------------------- 

1687# Groups 

1688# ----------------------------------------------------------------------------- 

1689 

1690 

1691class MandatoryGroupIdSelectorAllGroups(SchemaNode, RequestAwareMixin): 

1692 """ 

1693 Offers a picklist of groups from ALL POSSIBLE GROUPS. 

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

1695 """ 

1696 

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

1698 self.title = "" # for type checker 

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

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

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

1702 

1703 # noinspection PyUnusedLocal 

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

1705 _ = self.gettext 

1706 self.title = _("Group") 

1707 request = self.request 

1708 dbsession = request.dbsession 

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

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

1711 values, pv = get_values_and_permissible(values) 

1712 self.widget = SelectWidget(values=values) 

1713 self.validator = OneOf(pv) 

1714 

1715 @staticmethod 

1716 def schema_type() -> SchemaType: 

1717 return Integer() 

1718 

1719 

1720class MandatoryGroupIdSelectorAdministeredGroups( 

1721 SchemaNode, RequestAwareMixin 

1722): 

1723 """ 

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

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

1726 """ 

1727 

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

1729 self.title = "" # for type checker 

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

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

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

1733 

1734 # noinspection PyUnusedLocal 

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

1736 _ = self.gettext 

1737 self.title = _("Group") 

1738 request = self.request 

1739 dbsession = request.dbsession 

1740 administered_group_ids = request.user.ids_of_groups_user_is_admin_for 

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

1742 values = [ 

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

1744 ] 

1745 values, pv = get_values_and_permissible(values) 

1746 self.widget = SelectWidget(values=values) 

1747 self.validator = OneOf(pv) 

1748 

1749 @staticmethod 

1750 def schema_type() -> SchemaType: 

1751 return Integer() 

1752 

1753 

1754class MandatoryGroupIdSelectorPatientGroups(SchemaNode, RequestAwareMixin): 

1755 """ 

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

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

1758 """ 

1759 

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

1761 self.title = "" # for type checker 

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

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

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

1765 

1766 # noinspection PyUnusedLocal 

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

1768 _ = self.gettext 

1769 self.title = _("Group") 

1770 request = self.request 

1771 dbsession = request.dbsession 

1772 group_ids = request.user.ids_of_groups_user_may_manage_patients_in 

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

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

1775 values, pv = get_values_and_permissible(values) 

1776 self.widget = SelectWidget(values=values) 

1777 self.validator = OneOf(pv) 

1778 

1779 @staticmethod 

1780 def schema_type() -> SchemaType: 

1781 return Integer() 

1782 

1783 

1784class MandatoryGroupIdSelectorOtherGroups(SchemaNode, RequestAwareMixin): 

1785 """ 

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

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

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

1789 """ 

1790 

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

1792 self.title = "" # for type checker 

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

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

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

1796 

1797 # noinspection PyUnusedLocal 

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

1799 _ = self.gettext 

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

1801 request = self.request 

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

1803 dbsession = request.dbsession 

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

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

1806 values, pv = get_values_and_permissible(values) 

1807 self.widget = SelectWidget(values=values) 

1808 self.validator = OneOf(pv) 

1809 

1810 @staticmethod 

1811 def schema_type() -> SchemaType: 

1812 return Integer() 

1813 

1814 

1815class MandatoryGroupIdSelectorUserGroups(SchemaNode, RequestAwareMixin): 

1816 """ 

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

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

1819 """ 

1820 

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

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

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

1824 # OptionalGroupIdSelectorUserGroups 

1825 self.allow_none = False 

1826 self.title = "" # for type checker 

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

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

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

1830 

1831 # noinspection PyUnusedLocal 

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

1833 _ = self.gettext 

1834 self.title = _("Group") 

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

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

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

1838 values, pv = get_values_and_permissible( 

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

1840 ) 

1841 self.widget = SelectWidget(values=values) 

1842 self.validator = OneOf(pv) 

1843 

1844 @staticmethod 

1845 def schema_type() -> SchemaType: 

1846 return Integer() 

1847 

1848 

1849class OptionalGroupIdSelectorUserGroups(MandatoryGroupIdSelectorUserGroups): 

1850 """ 

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

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

1853 """ 

1854 

1855 default = None 

1856 missing = None 

1857 

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

1859 self.allow_none = True 

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

1861 

1862 @staticmethod 

1863 def schema_type() -> SchemaType: 

1864 return AllowNoneType(Integer()) 

1865 

1866 

1867class MandatoryGroupIdSelectorAllowedGroups(SchemaNode, RequestAwareMixin): 

1868 """ 

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

1870 Used for task filters. 

1871 """ 

1872 

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

1874 self.title = "" # for type checker 

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

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

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

1878 

1879 # noinspection PyUnusedLocal 

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

1881 _ = self.gettext 

1882 self.title = _("Group") 

1883 request = self.request 

1884 dbsession = request.dbsession 

1885 user = request.user 

1886 if user.superuser: 

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

1888 else: 

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

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

1891 values, pv = get_values_and_permissible(values) 

1892 self.widget = SelectWidget(values=values) 

1893 self.validator = OneOf(pv) 

1894 

1895 @staticmethod 

1896 def schema_type() -> SchemaType: 

1897 return Integer() 

1898 

1899 

1900class GroupsSequenceBase(SequenceSchema, RequestAwareMixin): 

1901 """ 

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

1903 """ 

1904 

1905 def __init__( 

1906 self, *args: Any, minimum_number: int = 0, **kwargs: Any 

1907 ) -> None: 

1908 self.title = "" # for type checker 

1909 self.minimum_number = minimum_number 

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

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

1912 

1913 # noinspection PyUnusedLocal 

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

1915 _ = self.gettext 

1916 self.title = _("Groups") 

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

1918 

1919 # noinspection PyMethodMayBeStatic 

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

1921 assert isinstance(value, list) 

1922 _ = self.gettext 

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

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

1925 if len(value) < self.minimum_number: 

1926 raise Invalid( 

1927 node, 

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

1929 self.minimum_number 

1930 ), 

1931 ) 

1932 

1933 

1934class AllGroupsSequence(GroupsSequenceBase): 

1935 """ 

1936 Sequence to offer a choice of all possible groups. 

1937 

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

1939 """ 

1940 

1941 group_id_sequence = MandatoryGroupIdSelectorAllGroups() 

1942 

1943 

1944class AdministeredGroupsSequence(GroupsSequenceBase): 

1945 """ 

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

1947 

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

1949 to a user. 

1950 """ 

1951 

1952 group_id_sequence = MandatoryGroupIdSelectorAdministeredGroups() 

1953 

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

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

1956 

1957 

1958class AllOtherGroupsSequence(GroupsSequenceBase): 

1959 """ 

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

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

1962 

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

1964 """ 

1965 

1966 group_id_sequence = MandatoryGroupIdSelectorOtherGroups() 

1967 

1968 

1969class AllowedGroupsSequence(GroupsSequenceBase): 

1970 """ 

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

1972 """ 

1973 

1974 group_id_sequence = MandatoryGroupIdSelectorAllowedGroups() 

1975 

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

1977 self.description = "" # for type checker 

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

1979 

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

1981 super().after_bind(node, kw) 

1982 self.description = self.or_join_description 

1983 

1984 

1985# ----------------------------------------------------------------------------- 

1986# Languages (strictly, locales) 

1987# ----------------------------------------------------------------------------- 

1988 

1989 

1990class LanguageSelector(SchemaNode, RequestAwareMixin): 

1991 """ 

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

1993 """ 

1994 

1995 _choices = POSSIBLE_LOCALES_WITH_DESCRIPTIONS 

1996 schema_type = String 

1997 default = DEFAULT_LOCALE 

1998 missing = DEFAULT_LOCALE 

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

2000 validator = OneOf(POSSIBLE_LOCALES) 

2001 

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

2003 self.title = "" # for type checker 

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

2005 

2006 # noinspection PyUnusedLocal 

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

2008 _ = self.gettext 

2009 self.title = _("Group") 

2010 request = self.request # noqa: F841 

2011 self.title = _("Language") 

2012 

2013 

2014# ----------------------------------------------------------------------------- 

2015# Validating dangerous operations 

2016# ----------------------------------------------------------------------------- 

2017 

2018 

2019class HardWorkConfirmationSchema(CSRFSchema): 

2020 """ 

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

2022 answers before we will proceed. 

2023 """ 

2024 

2025 confirm_1_t = BooleanNode(default=False) 

2026 confirm_2_t = BooleanNode(default=True) 

2027 confirm_3_f = BooleanNode(default=True) 

2028 confirm_4_t = BooleanNode(default=False) 

2029 

2030 # noinspection PyUnusedLocal 

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

2032 _ = self.gettext 

2033 confirm_1_t = get_child_node(self, "confirm_1_t") 

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

2035 confirm_2_t = get_child_node(self, "confirm_2_t") 

2036 # TRANSLATOR: string context described here 

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

2038 confirm_3_f = get_child_node(self, "confirm_3_f") 

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

2040 confirm_4_t = get_child_node(self, "confirm_4_t") 

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

2042 

2043 # noinspection PyMethodMayBeStatic 

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

2045 if ( 

2046 (not value["confirm_1_t"]) 

2047 or (not value["confirm_2_t"]) 

2048 or value["confirm_3_f"] 

2049 or (not value["confirm_4_t"]) 

2050 ): 

2051 _ = self.gettext 

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

2053 

2054 

2055# ----------------------------------------------------------------------------- 

2056# URLs 

2057# ----------------------------------------------------------------------------- 

2058 

2059 

2060class HiddenRedirectionUrlNode(HiddenStringNode, RequestAwareMixin): 

2061 """ 

2062 Note to encode a hidden URL, for redirection. 

2063 """ 

2064 

2065 # noinspection PyMethodMayBeStatic 

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

2067 if value: 

2068 try: 

2069 validate_redirect_url(value, self.request) 

2070 except ValueError: 

2071 _ = self.gettext 

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

2073 

2074 

2075# ----------------------------------------------------------------------------- 

2076# Phone number 

2077# ----------------------------------------------------------------------------- 

2078 

2079 

2080class PhoneNumberType(String): 

2081 def __init__( 

2082 self, request: "CamcopsRequest", *args: Any, **kwargs: Any 

2083 ) -> None: 

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

2085 

2086 self.request = request 

2087 

2088 # noinspection PyMethodMayBeStatic, PyUnusedLocal 

2089 def deserialize( 

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

2091 ) -> Optional[phonenumbers.PhoneNumber]: 

2092 request = self.request # type: CamcopsRequest 

2093 _ = request.gettext 

2094 err_message = _("Invalid phone number") 

2095 

2096 # is null when form is empty 

2097 if not cstruct: 

2098 if not self.allow_empty: 

2099 raise Invalid(node, err_message) 

2100 return null 

2101 

2102 cstruct: str 

2103 

2104 try: 

2105 phone_number = phonenumbers.parse( 

2106 cstruct, request.config.region_code 

2107 ) 

2108 except phonenumbers.NumberParseException: 

2109 raise Invalid(node, err_message) 

2110 

2111 if not phonenumbers.is_valid_number(phone_number): 

2112 # the number may parse but could still be invalid 

2113 # (e.g. too few digits) 

2114 raise Invalid(node, err_message) 

2115 

2116 return phone_number 

2117 

2118 # noinspection PyMethodMayBeStatic,PyUnusedLocal 

2119 def serialize( 

2120 self, 

2121 node: SchemaNode, 

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

2123 ) -> Union[str, ColanderNullType]: 

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

2125 if not appstruct: 

2126 return null 

2127 

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

2129 # when reading from the database) 

2130 return phonenumbers.format_number( 

2131 appstruct, phonenumbers.PhoneNumberFormat.E164 

2132 ) 

2133 

2134 

2135class MandatoryPhoneNumberNode(MandatoryStringNode, RequestAwareMixin): 

2136 default = None 

2137 missing = None 

2138 

2139 # noinspection PyUnusedLocal 

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

2141 _ = self.gettext 

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

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

2144 

2145 

2146# ============================================================================= 

2147# Login 

2148# ============================================================================= 

2149 

2150 

2151class LoginSchema(CSRFSchema): 

2152 """ 

2153 Schema to capture login details. 

2154 """ 

2155 

2156 username = UsernameNode( 

2157 autocomplete=AutocompleteAttrValues.USERNAME 

2158 ) # name must match ViewParam.USERNAME 

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

2160 String(), 

2161 widget=PasswordWidget( 

2162 attributes={ 

2163 AUTOCOMPLETE_ATTR: AutocompleteAttrValues.CURRENT_PASSWORD 

2164 } 

2165 ), 

2166 ) 

2167 redirect_url = ( 

2168 HiddenRedirectionUrlNode() 

2169 ) # name must match ViewParam.REDIRECT_URL 

2170 

2171 def __init__( 

2172 self, *args: Any, autocomplete_password: bool = True, **kwargs: Any 

2173 ) -> None: 

2174 self.autocomplete_password = autocomplete_password 

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

2176 

2177 # noinspection PyUnusedLocal 

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

2179 _ = self.gettext 

2180 password = get_child_node(self, "password") 

2181 password.title = _("Password") 

2182 password.widget.attributes[AUTOCOMPLETE_ATTR] = ( 

2183 AutocompleteAttrValues.CURRENT_PASSWORD 

2184 if self.autocomplete_password 

2185 else AutocompleteAttrValues.OFF 

2186 ) 

2187 

2188 

2189class LoginForm(InformativeNonceForm): 

2190 """ 

2191 Form to capture login details. 

2192 """ 

2193 

2194 def __init__( 

2195 self, 

2196 request: "CamcopsRequest", 

2197 autocomplete_password: bool = True, 

2198 **kwargs: Any, 

2199 ) -> None: 

2200 """ 

2201 Args: 

2202 autocomplete_password: 

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

2204 autocompletion? Note that browsers may ignore this. 

2205 """ 

2206 _ = request.gettext 

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

2208 request=request 

2209 ) 

2210 super().__init__( 

2211 schema, 

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

2213 # autocomplete=autocomplete_password, 

2214 **kwargs, 

2215 ) 

2216 # Suboptimal: autocomplete_password is not applied to the password 

2217 # widget, just to the form; see 

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

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

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

2221 

2222 

2223class OtpSchema(CSRFSchema): 

2224 """ 

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

2226 """ 

2227 

2228 one_time_password = MandatoryStringNode() 

2229 redirect_url = ( 

2230 HiddenRedirectionUrlNode() 

2231 ) # name must match ViewParam.REDIRECT_URL 

2232 

2233 # noinspection PyUnusedLocal 

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

2235 _ = self.gettext 

2236 one_time_password = get_child_node(self, "one_time_password") 

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

2238 

2239 

2240class OtpTokenForm(InformativeNonceForm): 

2241 """ 

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

2243 """ 

2244 

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

2246 _ = request.gettext 

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

2248 super().__init__( 

2249 schema, 

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

2251 **kwargs, 

2252 ) 

2253 

2254 

2255# ============================================================================= 

2256# Change password 

2257# ============================================================================= 

2258 

2259 

2260class MustChangePasswordNode(SchemaNode, RequestAwareMixin): 

2261 """ 

2262 Boolean node: must the user change their password? 

2263 """ 

2264 

2265 schema_type = Boolean 

2266 default = True 

2267 missing = True 

2268 

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

2270 self.label = "" # for type checker 

2271 self.title = "" # for type checker 

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

2273 

2274 # noinspection PyUnusedLocal 

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

2276 _ = self.gettext 

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

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

2279 

2280 

2281class OldUserPasswordCheck(SchemaNode, RequestAwareMixin): 

2282 """ 

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

2284 """ 

2285 

2286 schema_type = String 

2287 widget = PasswordWidget( 

2288 attributes={AUTOCOMPLETE_ATTR: AutocompleteAttrValues.CURRENT_PASSWORD} 

2289 ) 

2290 

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

2292 self.title = "" # for type checker 

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

2294 

2295 # noinspection PyUnusedLocal 

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

2297 _ = self.gettext 

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

2299 

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

2301 request = self.request 

2302 user = request.user 

2303 assert user is not None 

2304 if not user.is_password_correct(value): 

2305 _ = request.gettext 

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

2307 

2308 

2309class InformationalCheckedPasswordWidget(CheckedPasswordWidget): 

2310 """ 

2311 A more verbose version of Deform's CheckedPasswordWidget 

2312 which provides advice on good passwords. 

2313 """ 

2314 

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

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

2317 form = "informational_checked_password.pt" 

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

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

2320 

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

2322 super().__init__(**kwargs) 

2323 self.request = request 

2324 

2325 def get_template_values( 

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

2327 ) -> Dict[str, Any]: 

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

2329 

2330 _ = self.request.gettext 

2331 

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

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

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

2335 link=link 

2336 ) 

2337 min_password_length = _( 

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

2339 ).format(limit=MINIMUM_PASSWORD_LENGTH) 

2340 

2341 values.update( 

2342 password_advice=password_advice, 

2343 min_password_length=min_password_length, 

2344 ) 

2345 

2346 return values 

2347 

2348 

2349class NewPasswordNode(SchemaNode, RequestAwareMixin): 

2350 """ 

2351 Node to enter a new password. 

2352 """ 

2353 

2354 schema_type = String 

2355 

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

2357 self.title = "" # for type checker 

2358 self.description = "" # for type checker 

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

2360 

2361 # noinspection PyUnusedLocal 

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

2363 _ = self.gettext 

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

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

2366 self.widget = InformationalCheckedPasswordWidget( 

2367 self.request, 

2368 attributes={ 

2369 AUTOCOMPLETE_ATTR: AutocompleteAttrValues.NEW_PASSWORD 

2370 }, 

2371 ) 

2372 

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

2374 try: 

2375 validate_new_password(value, self.request) 

2376 except ValueError as e: 

2377 raise Invalid(node, str(e)) 

2378 

2379 

2380class ChangeOwnPasswordSchema(CSRFSchema): 

2381 """ 

2382 Schema to change one's own password. 

2383 """ 

2384 

2385 old_password = OldUserPasswordCheck() 

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

2387 

2388 def __init__( 

2389 self, *args: Any, must_differ: bool = True, **kwargs: Any 

2390 ) -> None: 

2391 """ 

2392 Args: 

2393 must_differ: 

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

2395 """ 

2396 self.must_differ = must_differ 

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

2398 

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

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

2401 _ = self.gettext 

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

2403 

2404 

2405class ChangeOwnPasswordForm(InformativeNonceForm): 

2406 """ 

2407 Form to change one's own password. 

2408 """ 

2409 

2410 def __init__( 

2411 self, 

2412 request: "CamcopsRequest", 

2413 must_differ: bool = True, 

2414 **kwargs: Any, 

2415 ) -> None: 

2416 """ 

2417 Args: 

2418 must_differ: 

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

2420 """ 

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

2422 request=request 

2423 ) 

2424 super().__init__( 

2425 schema, 

2426 buttons=[ 

2427 Button( 

2428 name=FormAction.SUBMIT, 

2429 title=change_password_title(request), 

2430 ) 

2431 ], 

2432 **kwargs, 

2433 ) 

2434 

2435 

2436class ChangeOtherPasswordSchema(CSRFSchema): 

2437 """ 

2438 Schema to change another user's password. 

2439 """ 

2440 

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

2442 must_change_password = ( 

2443 MustChangePasswordNode() 

2444 ) # match ViewParam.MUST_CHANGE_PASSWORD 

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

2446 

2447 

2448class ChangeOtherPasswordForm(SimpleSubmitForm): 

2449 """ 

2450 Form to change another user's password. 

2451 """ 

2452 

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

2454 _ = request.gettext 

2455 super().__init__( 

2456 schema_class=ChangeOtherPasswordSchema, 

2457 submit_title=_("Submit"), 

2458 request=request, 

2459 **kwargs, 

2460 ) 

2461 

2462 

2463class DisableMfaNode(SchemaNode, RequestAwareMixin): 

2464 """ 

2465 Boolean node: disable multi-factor authentication 

2466 """ 

2467 

2468 schema_type = Boolean 

2469 default = False 

2470 missing = False 

2471 

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

2473 self.label = "" # for type checker 

2474 self.title = "" # for type checker 

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

2476 

2477 # noinspection PyUnusedLocal 

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

2479 _ = self.gettext 

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

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

2482 

2483 

2484class EditOtherUserMfaSchema(CSRFSchema): 

2485 """ 

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

2487 """ 

2488 

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

2490 disable_mfa = DisableMfaNode() # match ViewParam.DISABLE_MFA 

2491 

2492 

2493class EditOtherUserMfaForm(SimpleSubmitForm): 

2494 """ 

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

2496 """ 

2497 

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

2499 _ = request.gettext 

2500 super().__init__( 

2501 schema_class=EditOtherUserMfaSchema, 

2502 submit_title=_("Submit"), 

2503 request=request, 

2504 **kwargs, 

2505 ) 

2506 

2507 

2508# ============================================================================= 

2509# Multi-factor authentication 

2510# ============================================================================= 

2511 

2512 

2513class MfaSecretWidget(TextInputWidget): 

2514 """ 

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

2516 string. 

2517 """ 

2518 

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

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

2521 form = "mfa_secret.pt" 

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

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

2524 

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

2526 super().__init__(**kwargs) 

2527 self.request = request 

2528 

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

2530 # cstruct contains the MFA secret key 

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

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

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

2534 

2535 _ = self.request.gettext 

2536 

2537 factory = qrcode.image.svg.SvgImage 

2538 totp = pyotp.totp.TOTP(cstruct) 

2539 uri = totp.provisioning_uri( 

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

2541 ) 

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

2543 stream = BytesIO() 

2544 img.save(stream) 

2545 values.update( 

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

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

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

2549 enter_key=_( 

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

2551 ), 

2552 enter_code=_( 

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

2554 "the app." 

2555 ), 

2556 ) 

2557 

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

2559 

2560 

2561class MfaSecretNode(OptionalStringNode, RequestAwareMixin): 

2562 """ 

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

2564 alphanumeric string. 

2565 """ 

2566 

2567 schema_type = String 

2568 

2569 # noinspection PyUnusedLocal,PyAttributeOutsideInit 

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

2571 self.widget = MfaSecretWidget(self.request) 

2572 

2573 

2574class MfaMethodSelector(SchemaNode, RequestAwareMixin): 

2575 """ 

2576 Node to select type of authentication 

2577 """ 

2578 

2579 schema_type = String 

2580 default = MfaMethod.TOTP 

2581 missing = MfaMethod.TOTP 

2582 

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

2584 self.title = "" # for type checker 

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

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

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

2588 

2589 # noinspection PyUnusedLocal 

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

2591 _ = self.gettext 

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

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

2594 all_mfa_choices = [ 

2595 ( 

2596 MfaMethod.TOTP, 

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

2598 ), 

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

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

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

2602 ] 

2603 

2604 choices = [] 

2605 for label, description in all_mfa_choices: 

2606 if label in request.config.mfa_methods: 

2607 choices.append((label, description)) 

2608 values, pv = get_values_and_permissible(choices) 

2609 

2610 self.widget = RadioChoiceWidget(values=values) 

2611 self.validator = OneOf(pv) 

2612 

2613 

2614class MfaMethodSchema(CSRFSchema): 

2615 """ 

2616 Schema to edit Multi-factor Authentication method. 

2617 """ 

2618 

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

2620 

2621 # noinspection PyUnusedLocal 

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

2623 _ = self.gettext 

2624 mfa_method = get_child_node(self, "mfa_method") 

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

2626 

2627 

2628class MfaTotpSchema(CSRFSchema): 

2629 """ 

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

2631 """ 

2632 

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

2634 

2635 # noinspection PyUnusedLocal 

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

2637 _ = self.gettext 

2638 mfa_secret_key = get_child_node(self, "mfa_secret_key") 

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

2640 

2641 

2642class MfaHotpEmailSchema(CSRFSchema): 

2643 """ 

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

2645 """ 

2646 

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

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

2649 

2650 # noinspection PyUnusedLocal 

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

2652 _ = self.gettext 

2653 

2654 

2655class MfaHotpSmsSchema(CSRFSchema): 

2656 """ 

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

2658 """ 

2659 

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

2661 phone_number = ( 

2662 MandatoryPhoneNumberNode() 

2663 ) # must match ViewParam.PHONE_NUMBER 

2664 

2665 # noinspection PyUnusedLocal 

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

2667 _ = self.gettext 

2668 phone_number = get_child_node(self, ViewParam.PHONE_NUMBER) 

2669 phone_number.description = _( 

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

2671 "'{region_code}' region" 

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

2673 

2674 

2675class MfaMethodForm(InformativeNonceForm): 

2676 """ 

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

2678 """ 

2679 

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

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

2682 super().__init__( 

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

2684 ) 

2685 

2686 

2687class MfaTotpForm(InformativeNonceForm): 

2688 """ 

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

2690 """ 

2691 

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

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

2694 super().__init__( 

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

2696 ) 

2697 

2698 

2699class MfaHotpEmailForm(InformativeNonceForm): 

2700 """ 

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

2702 """ 

2703 

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

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

2706 super().__init__( 

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

2708 ) 

2709 

2710 

2711class MfaHotpSmsForm(InformativeNonceForm): 

2712 """ 

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

2714 """ 

2715 

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

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

2718 super().__init__( 

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

2720 ) 

2721 

2722 

2723# ============================================================================= 

2724# Offer/agree terms 

2725# ============================================================================= 

2726 

2727 

2728class OfferTermsSchema(CSRFSchema): 

2729 """ 

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

2731 """ 

2732 

2733 pass 

2734 

2735 

2736class OfferTermsForm(SimpleSubmitForm): 

2737 """ 

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

2739 """ 

2740 

2741 def __init__( 

2742 self, request: "CamcopsRequest", agree_button_text: str, **kwargs: Any 

2743 ) -> None: 

2744 """ 

2745 Args: 

2746 agree_button_text: 

2747 text for the "agree" button 

2748 """ 

2749 super().__init__( 

2750 schema_class=OfferTermsSchema, 

2751 submit_title=agree_button_text, 

2752 request=request, 

2753 **kwargs, 

2754 ) 

2755 

2756 

2757# ============================================================================= 

2758# View audit trail 

2759# ============================================================================= 

2760 

2761 

2762class OptionalIPAddressNode(OptionalStringNode, RequestAwareMixin): 

2763 """ 

2764 Optional IPv4 or IPv6 address. 

2765 """ 

2766 

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

2768 try: 

2769 validate_ip_address(value, self.request) 

2770 except ValueError as e: 

2771 raise Invalid(node, e) 

2772 

2773 

2774class OptionalAuditSourceNode(OptionalStringNode, RequestAwareMixin): 

2775 """ 

2776 Optional IPv4 or IPv6 address. 

2777 """ 

2778 

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

2780 try: 

2781 validate_by_char_and_length( 

2782 value, 

2783 permitted_char_expression=ALPHANUM_UNDERSCORE_CHAR, 

2784 min_length=0, 

2785 max_length=StringLengths.AUDIT_SOURCE_MAX_LEN, 

2786 req=self.request, 

2787 ) 

2788 except ValueError as e: 

2789 raise Invalid(node, e) 

2790 

2791 

2792class AuditTrailSchema(CSRFSchema): 

2793 """ 

2794 Schema to filter audit trail entries. 

2795 """ 

2796 

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

2798 start_datetime = ( 

2799 StartPendulumSelector() 

2800 ) # must match ViewParam.START_DATETIME 

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

2802 source = OptionalAuditSourceNode() # must match ViewParam.SOURCE 

2803 remote_ip_addr = ( 

2804 OptionalIPAddressNode() 

2805 ) # must match ViewParam.REMOTE_IP_ADDR 

2806 username = OptionalUserNameSelector() # must match ViewParam.USERNAME 

2807 table_name = OptionalSingleTaskSelector() # must match ViewParam.TABLENAME 

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

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

2810 

2811 # noinspection PyUnusedLocal 

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

2813 _ = self.gettext 

2814 source = get_child_node(self, "source") 

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

2816 remote_ip_addr = get_child_node(self, "remote_ip_addr") 

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

2818 truncate = get_child_node(self, "truncate") 

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

2820 

2821 

2822class AuditTrailForm(SimpleSubmitForm): 

2823 """ 

2824 Form to filter and then view audit trail entries. 

2825 """ 

2826 

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

2828 _ = request.gettext 

2829 super().__init__( 

2830 schema_class=AuditTrailSchema, 

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

2832 request=request, 

2833 **kwargs, 

2834 ) 

2835 

2836 

2837# ============================================================================= 

2838# View export logs 

2839# ============================================================================= 

2840 

2841 

2842class OptionalExportRecipientNameSelector( 

2843 OptionalStringNode, RequestAwareMixin 

2844): 

2845 """ 

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

2847 database. 

2848 """ 

2849 

2850 title = "Export recipient" 

2851 

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

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

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

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

2856 

2857 # noinspection PyUnusedLocal 

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

2859 from camcops_server.cc_modules.cc_exportrecipient import ( 

2860 ExportRecipient, 

2861 ) # delayed import 

2862 

2863 request = self.request 

2864 _ = request.gettext 

2865 dbsession = request.dbsession 

2866 q = ( 

2867 dbsession.query(ExportRecipient.recipient_name) 

2868 .distinct() 

2869 .order_by(ExportRecipient.recipient_name) 

2870 ) 

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

2872 for row in q: 

2873 recipient_name = row[0] 

2874 values.append((recipient_name, recipient_name)) 

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

2876 self.widget = SelectWidget(values=values) 

2877 self.validator = OneOf(pv) 

2878 

2879 

2880class ExportedTaskListSchema(CSRFSchema): 

2881 """ 

2882 Schema to filter HL7 message logs. 

2883 """ 

2884 

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

2886 recipient_name = ( 

2887 OptionalExportRecipientNameSelector() 

2888 ) # must match ViewParam.RECIPIENT_NAME 

2889 table_name = OptionalSingleTaskSelector() # must match ViewParam.TABLENAME 

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

2891 id = OptionalIntNode() # must match ViewParam.ID 

2892 start_datetime = ( 

2893 StartDateTimeSelector() 

2894 ) # must match ViewParam.START_DATETIME 

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

2896 

2897 # noinspection PyUnusedLocal 

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

2899 _ = self.gettext 

2900 id_ = get_child_node(self, "id") 

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

2902 

2903 

2904class ExportedTaskListForm(SimpleSubmitForm): 

2905 """ 

2906 Form to filter and then view exported task logs. 

2907 """ 

2908 

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

2910 _ = request.gettext 

2911 super().__init__( 

2912 schema_class=ExportedTaskListSchema, 

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

2914 request=request, 

2915 **kwargs, 

2916 ) 

2917 

2918 

2919# ============================================================================= 

2920# Task filters 

2921# ============================================================================= 

2922 

2923 

2924class TextContentsSequence(SequenceSchema, RequestAwareMixin): 

2925 """ 

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

2927 for a task filter). 

2928 """ 

2929 

2930 text_sequence = SchemaNode( 

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

2932 ) # BEWARE: fairly unrestricted contents. 

2933 

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

2935 self.title = "" # for type checker 

2936 self.description = "" # for type checker 

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

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

2939 

2940 # noinspection PyUnusedLocal 

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

2942 _ = self.gettext 

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

2944 self.description = self.or_join_description 

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

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

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

2948 text_sequence = get_child_node(self, "text_sequence") 

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

2950 text_sequence.title = _("text") 

2951 

2952 # noinspection PyMethodMayBeStatic 

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

2954 assert isinstance(value, list) 

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

2956 _ = self.gettext 

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

2958 

2959 

2960class UploadingUserSequence(SequenceSchema, RequestAwareMixin): 

2961 """ 

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

2963 the following users..."). 

2964 """ 

2965 

2966 user_id_sequence = MandatoryUserIdSelectorUsersAllowedToSee() 

2967 

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

2969 self.title = "" # for type checker 

2970 self.description = "" # for type checker 

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

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

2973 

2974 # noinspection PyUnusedLocal 

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

2976 _ = self.gettext 

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

2978 self.description = self.or_join_description 

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

2980 

2981 # noinspection PyMethodMayBeStatic 

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

2983 assert isinstance(value, list) 

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

2985 _ = self.gettext 

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

2987 

2988 

2989class DevicesSequence(SequenceSchema, RequestAwareMixin): 

2990 """ 

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

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

2993 """ 

2994 

2995 device_id_sequence = MandatoryDeviceIdSelector() 

2996 

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

2998 self.title = "" # for type checker 

2999 self.description = "" # for type checker 

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

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

3002 

3003 # noinspection PyUnusedLocal 

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

3005 _ = self.gettext 

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

3007 self.description = self.or_join_description 

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

3009 

3010 # noinspection PyMethodMayBeStatic 

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

3012 assert isinstance(value, list) 

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

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

3015 

3016 

3017class OptionalPatientNameNode(OptionalStringNode, RequestAwareMixin): 

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

3019 try: 

3020 # TODO: Validating human names is hard. 

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

3022 # be configurable. 

3023 # validate_human_name(value, self.request) 

3024 

3025 # Does nothing but better to be explicit 

3026 validate_anything(value, self.request) 

3027 except ValueError as e: 

3028 # Should never happen with validate_anything 

3029 raise Invalid(node, str(e)) 

3030 

3031 

3032class EditTaskFilterWhoSchema(Schema, RequestAwareMixin): 

3033 """ 

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

3035 """ 

3036 

3037 surname = OptionalPatientNameNode() # must match ViewParam.SURNAME 

3038 forename = OptionalPatientNameNode() # must match ViewParam.FORENAME 

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

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

3041 id_references = ( 

3042 IdNumSequenceAnyCombination() 

3043 ) # must match ViewParam.ID_REFERENCES 

3044 

3045 # noinspection PyUnusedLocal 

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

3047 _ = self.gettext 

3048 surname = get_child_node(self, "surname") 

3049 surname.title = _("Surname") 

3050 forename = get_child_node(self, "forename") 

3051 forename.title = _("Forename") 

3052 dob = get_child_node(self, "dob") 

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

3054 id_references = get_child_node(self, "id_references") 

3055 id_references.description = self.or_join_description 

3056 

3057 

3058class EditTaskFilterWhenSchema(Schema): 

3059 """ 

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

3061 """ 

3062 

3063 start_datetime = ( 

3064 StartPendulumSelector() 

3065 ) # must match ViewParam.START_DATETIME 

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

3067 

3068 

3069class EditTaskFilterWhatSchema(Schema, RequestAwareMixin): 

3070 """ 

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

3072 """ 

3073 

3074 text_contents = ( 

3075 TextContentsSequence() 

3076 ) # must match ViewParam.TEXT_CONTENTS 

3077 complete_only = BooleanNode( 

3078 default=False 

3079 ) # must match ViewParam.COMPLETE_ONLY 

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

3081 

3082 # noinspection PyUnusedLocal 

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

3084 _ = self.gettext 

3085 complete_only = get_child_node(self, "complete_only") 

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

3087 complete_only.title = only_completed_text 

3088 complete_only.label = only_completed_text # type: ignore[attr-defined] 

3089 

3090 

3091class EditTaskFilterAdminSchema(Schema): 

3092 """ 

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

3094 """ 

3095 

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

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

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

3099 

3100 

3101class EditTaskFilterSchema(CSRFSchema): 

3102 """ 

3103 Schema to edit a task filter. 

3104 """ 

3105 

3106 who = EditTaskFilterWhoSchema( # must match ViewParam.WHO 

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

3108 ) 

3109 what = EditTaskFilterWhatSchema( # must match ViewParam.WHAT 

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

3111 ) 

3112 when = EditTaskFilterWhenSchema( # must match ViewParam.WHEN 

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

3114 ) 

3115 admin = EditTaskFilterAdminSchema( # must match ViewParam.ADMIN 

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

3117 ) 

3118 

3119 # noinspection PyUnusedLocal 

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

3121 # log.debug("EditTaskFilterSchema.after_bind") 

3122 # log.debug("{!r}", self.__dict__) 

3123 # This is pretty nasty. By the time we get here, the Form class has 

3124 # made Field objects, and, I think, called a clone() function on us. 

3125 # Objects like "who" are not in our __dict__ any more. Our __dict__ 

3126 # looks like: 

3127 # { 

3128 # 'typ': <colander.Mapping object at 0x7fd7989b18d0>, 

3129 # 'bindings': { 

3130 # 'open_who': True, 

3131 # 'open_when': True, 

3132 # 'request': ..., 

3133 # }, 

3134 # '_order': 118, 

3135 # 'children': [ 

3136 # <...CSRFToken object at ... (named csrf)>, 

3137 # <...EditTaskFilterWhoSchema object at ... (named who)>, 

3138 # ... 

3139 # ], 

3140 # 'title': '' 

3141 # } 

3142 _ = self.gettext 

3143 who = get_child_node(self, "who") 

3144 what = get_child_node(self, "what") 

3145 when = get_child_node(self, "when") 

3146 admin = get_child_node(self, "admin") 

3147 who.title = _("Who") 

3148 what.title = _("What") 

3149 when.title = _("When") 

3150 admin.title = _("Administrative criteria") 

3151 # log.debug("who = {!r}", who) 

3152 # log.debug("who.__dict__ = {!r}", who.__dict__) 

3153 who.widget.open = kw[Binding.OPEN_WHO] 

3154 what.widget.open = kw[Binding.OPEN_WHAT] 

3155 when.widget.open = kw[Binding.OPEN_WHEN] 

3156 admin.widget.open = kw[Binding.OPEN_ADMIN] 

3157 

3158 

3159class EditTaskFilterForm(InformativeNonceForm): 

3160 """ 

3161 Form to edit a task filter. 

3162 """ 

3163 

3164 def __init__( 

3165 self, 

3166 request: "CamcopsRequest", 

3167 open_who: bool = False, 

3168 open_what: bool = False, 

3169 open_when: bool = False, 

3170 open_admin: bool = False, 

3171 **kwargs: Any, 

3172 ) -> None: 

3173 _ = request.gettext 

3174 schema = EditTaskFilterSchema().bind( 

3175 request=request, 

3176 open_admin=open_admin, 

3177 open_what=open_what, 

3178 open_when=open_when, 

3179 open_who=open_who, 

3180 ) 

3181 super().__init__( 

3182 schema, 

3183 buttons=[ 

3184 Button(name=FormAction.SET_FILTERS, title=_("Set filters")), 

3185 Button(name=FormAction.CLEAR_FILTERS, title=_("Clear")), 

3186 ], 

3187 **kwargs, 

3188 ) 

3189 

3190 

3191class TasksPerPageSchema(CSRFSchema): 

3192 """ 

3193 Schema to edit the number of rows per page, for the task view. 

3194 """ 

3195 

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

3197 

3198 

3199class TasksPerPageForm(InformativeNonceForm): 

3200 """ 

3201 Form to edit the number of tasks per page, for the task view. 

3202 """ 

3203 

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

3205 _ = request.gettext 

3206 schema = TasksPerPageSchema().bind(request=request) 

3207 super().__init__( 

3208 schema, 

3209 buttons=[ 

3210 Button( 

3211 name=FormAction.SUBMIT_TASKS_PER_PAGE, 

3212 title=_("Set n/page"), 

3213 ) 

3214 ], 

3215 css_class=BootstrapCssClasses.FORM_INLINE, 

3216 **kwargs, 

3217 ) 

3218 

3219 

3220class RefreshTasksSchema(CSRFSchema): 

3221 """ 

3222 Schema for a "refresh tasks" button. 

3223 """ 

3224 

3225 pass 

3226 

3227 

3228class RefreshTasksForm(InformativeNonceForm): 

3229 """ 

3230 Form for a "refresh tasks" button. 

3231 """ 

3232 

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

3234 _ = request.gettext 

3235 schema = RefreshTasksSchema().bind(request=request) 

3236 super().__init__( 

3237 schema, 

3238 buttons=[ 

3239 Button(name=FormAction.REFRESH_TASKS, title=_("Refresh")) 

3240 ], 

3241 **kwargs, 

3242 ) 

3243 

3244 

3245# ============================================================================= 

3246# Trackers 

3247# ============================================================================= 

3248 

3249 

3250class TaskTrackerOutputTypeSelector(SchemaNode, RequestAwareMixin): 

3251 """ 

3252 Node to select the output format for a tracker. 

3253 """ 

3254 

3255 # Choices don't require translation 

3256 _choices = ( 

3257 (ViewArg.HTML, "HTML"), 

3258 (ViewArg.PDF, "PDF"), 

3259 (ViewArg.XML, "XML"), 

3260 ) 

3261 

3262 schema_type = String 

3263 default = ViewArg.HTML 

3264 missing = ViewArg.HTML 

3265 widget = RadioChoiceWidget(values=_choices) 

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

3267 

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

3269 self.title = "" # for type checker 

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

3271 

3272 # noinspection PyUnusedLocal 

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

3274 _ = self.gettext 

3275 self.title = _("View as") 

3276 

3277 

3278class ChooseTrackerSchema(CSRFSchema): 

3279 """ 

3280 Schema to select a tracker or CTV. 

3281 """ 

3282 

3283 which_idnum = ( 

3284 MandatoryWhichIdNumSelector() 

3285 ) # must match ViewParam.WHICH_IDNUM 

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

3287 start_datetime = ( 

3288 StartPendulumSelector() 

3289 ) # must match ViewParam.START_DATETIME 

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

3291 all_tasks = BooleanNode(default=True) # match ViewParam.ALL_TASKS 

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

3293 # tracker_tasks_only will be set via the binding 

3294 via_index = ViaIndexSelector() # must match ViewParam.VIA_INDEX 

3295 viewtype = TaskTrackerOutputTypeSelector() # must match ViewParam.VIEWTYPE 

3296 

3297 # noinspection PyUnusedLocal 

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

3299 _ = self.gettext 

3300 all_tasks = get_child_node(self, "all_tasks") 

3301 text = _("Use all eligible task types?") 

3302 all_tasks.title = text 

3303 all_tasks.label = text # type: ignore[attr-defined] 

3304 

3305 

3306class ChooseTrackerForm(InformativeNonceForm): 

3307 """ 

3308 Form to select a tracker or CTV. 

3309 """ 

3310 

3311 def __init__( 

3312 self, request: "CamcopsRequest", as_ctv: bool, **kwargs: Any 

3313 ) -> None: 

3314 """ 

3315 Args: 

3316 as_ctv: CTV, not tracker? 

3317 """ 

3318 _ = request.gettext 

3319 schema = ChooseTrackerSchema().bind( 

3320 request=request, tracker_tasks_only=not as_ctv 

3321 ) 

3322 super().__init__( 

3323 schema, 

3324 buttons=[ 

3325 Button( 

3326 name=FormAction.SUBMIT, 

3327 title=(_("View CTV") if as_ctv else _("View tracker")), 

3328 ) 

3329 ], 

3330 **kwargs, 

3331 ) 

3332 

3333 

3334# ============================================================================= 

3335# Reports, which use dynamically created forms 

3336# ============================================================================= 

3337 

3338 

3339class ReportOutputTypeSelector(SchemaNode, RequestAwareMixin): 

3340 """ 

3341 Node to select the output format for a report. 

3342 """ 

3343 

3344 schema_type = String 

3345 default = ViewArg.HTML 

3346 missing = ViewArg.HTML 

3347 

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

3349 self.title = "" # for type checker 

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

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

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

3353 

3354 # noinspection PyUnusedLocal 

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

3356 _ = self.gettext 

3357 self.title = _("View as") 

3358 choices = self.get_choices() 

3359 values, pv = get_values_and_permissible(choices) 

3360 self.widget = RadioChoiceWidget(values=choices) 

3361 self.validator = OneOf(pv) 

3362 

3363 def get_choices(self) -> Tuple[Tuple[str, str]]: 

3364 _ = self.gettext 

3365 # noinspection PyTypeChecker 

3366 return ( # type: ignore[return-value] 

3367 (ViewArg.HTML, _("HTML")), 

3368 (ViewArg.ODS, _("OpenOffice spreadsheet (ODS) file")), 

3369 (ViewArg.TSV, _("TSV (tab-separated values)")), 

3370 (ViewArg.XLSX, _("XLSX (Microsoft Excel) file")), 

3371 ) 

3372 

3373 

3374class ReportParamSchema(CSRFSchema): 

3375 """ 

3376 Schema to embed a report type (ID) and output format (view type). 

3377 """ 

3378 

3379 viewtype = ReportOutputTypeSelector() # must match ViewParam.VIEWTYPE 

3380 report_id = HiddenStringNode() # must match ViewParam.REPORT_ID 

3381 # Specific forms may inherit from this. 

3382 

3383 

3384class DateTimeFilteredReportParamSchema(ReportParamSchema): 

3385 start_datetime = StartPendulumSelector() 

3386 end_datetime = EndPendulumSelector() 

3387 

3388 

3389class ReportParamForm(SimpleSubmitForm): 

3390 """ 

3391 Form to view a specific report. Often derived from, to configure the report 

3392 in more detail. 

3393 """ 

3394 

3395 def __init__( 

3396 self, 

3397 request: "CamcopsRequest", 

3398 schema_class: Type[ReportParamSchema], 

3399 **kwargs: Any, 

3400 ) -> None: 

3401 _ = request.gettext 

3402 super().__init__( 

3403 schema_class=schema_class, 

3404 submit_title=_("View report"), 

3405 request=request, 

3406 **kwargs, 

3407 ) 

3408 

3409 

3410# ============================================================================= 

3411# View DDL 

3412# ============================================================================= 

3413 

3414 

3415def get_sql_dialect_choices( 

3416 request: "CamcopsRequest", 

3417) -> List[Tuple[str, str]]: 

3418 _ = request.gettext 

3419 return [ 

3420 # https://docs.sqlalchemy.org/en/latest/dialects/ 

3421 (SqlaDialectName.MYSQL, "MySQL"), 

3422 (SqlaDialectName.MSSQL, "Microsoft SQL Server"), 

3423 (SqlaDialectName.ORACLE, "Oracle" + _("[WILL NOT WORK]")), 

3424 # ... Oracle doesn't work; SQLAlchemy enforces the Oracle rule of a 30- 

3425 # character limit for identifiers, only relaxed to 128 characters in 

3426 # Oracle 12.2 (March 2017). 

3427 (SqlaDialectName.FIREBIRD, "Firebird"), 

3428 (SqlaDialectName.POSTGRES, "PostgreSQL"), 

3429 (SqlaDialectName.SQLITE, "SQLite"), 

3430 (SqlaDialectName.SYBASE, "Sybase"), 

3431 ] 

3432 

3433 

3434class DatabaseDialectSelector(SchemaNode, RequestAwareMixin): 

3435 """ 

3436 Node to choice an SQL dialect (for viewing DDL). 

3437 """ 

3438 

3439 schema_type = String 

3440 default = SqlaDialectName.MYSQL 

3441 missing = SqlaDialectName.MYSQL 

3442 

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

3444 self.title = "" # for type checker 

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

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

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

3448 

3449 # noinspection PyUnusedLocal 

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

3451 _ = self.gettext 

3452 self.title = _("SQL dialect to use (not all may be valid)") 

3453 choices = get_sql_dialect_choices(self.request) 

3454 values, pv = get_values_and_permissible(choices) 

3455 self.widget = RadioChoiceWidget(values=values) 

3456 self.validator = OneOf(pv) 

3457 

3458 

3459class ViewDdlSchema(CSRFSchema): 

3460 """ 

3461 Schema to choose how to view DDL. 

3462 """ 

3463 

3464 dialect = DatabaseDialectSelector() # must match ViewParam.DIALECT 

3465 

3466 

3467class ViewDdlForm(SimpleSubmitForm): 

3468 """ 

3469 Form to choose how to view DDL (and then view it). 

3470 """ 

3471 

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

3473 _ = request.gettext 

3474 super().__init__( 

3475 schema_class=ViewDdlSchema, 

3476 submit_title=_("View DDL"), 

3477 request=request, 

3478 **kwargs, 

3479 ) 

3480 

3481 

3482# ============================================================================= 

3483# Add/edit/delete users 

3484# ============================================================================= 

3485 

3486 

3487class UserGroupPermissionsGroupAdminSchema(CSRFSchema): 

3488 """ 

3489 Edit group-specific permissions for a user. For group administrators. 

3490 """ 

3491 

3492 # Currently the defaults here will be ignored because we don't use this 

3493 # schema to create new UserGroupMembership records. The record will already 

3494 # exist by the time we see the forms that use this schema. So the database 

3495 # defaults will be used instead. 

3496 may_upload = BooleanNode( 

3497 default=False 

3498 ) # match ViewParam.MAY_UPLOAD and User attribute 

3499 may_register_devices = BooleanNode( 

3500 default=False 

3501 ) # match ViewParam.MAY_REGISTER_DEVICES and User attribute 

3502 may_use_webviewer = BooleanNode( 

3503 default=False 

3504 ) # match ViewParam.MAY_USE_WEBVIEWER and User attribute 

3505 may_manage_patients = BooleanNode( 

3506 default=False 

3507 ) # match ViewParam.MAY_MANAGE_PATIENTS 

3508 may_email_patients = BooleanNode( 

3509 default=False 

3510 ) # match ViewParam.MAY_EMAIL_PATIENTS 

3511 view_all_patients_when_unfiltered = BooleanNode( 

3512 default=False 

3513 ) # match ViewParam.VIEW_ALL_PATIENTS_WHEN_UNFILTERED and User attribute # noqa 

3514 may_dump_data = BooleanNode( 

3515 default=False 

3516 ) # match ViewParam.MAY_DUMP_DATA and User attribute 

3517 may_run_reports = BooleanNode( 

3518 default=False 

3519 ) # match ViewParam.MAY_RUN_REPORTS and User attribute 

3520 may_add_notes = BooleanNode( 

3521 default=False 

3522 ) # match ViewParam.MAY_ADD_NOTES and User attribute 

3523 

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

3525 _ = self.gettext 

3526 may_upload = get_child_node(self, "may_upload") 

3527 mu_text = _("Permitted to upload from a tablet/device") 

3528 may_upload.title = mu_text 

3529 may_upload.label = mu_text # type: ignore[attr-defined] 

3530 may_register_devices = get_child_node(self, "may_register_devices") 

3531 mrd_text = _("Permitted to register tablet/client devices") 

3532 may_register_devices.title = mrd_text 

3533 may_register_devices.label = mrd_text # type: ignore[attr-defined] 

3534 may_use_webviewer = get_child_node(self, "may_use_webviewer") 

3535 ml_text = _("May log in to web front end") 

3536 may_use_webviewer.title = ml_text 

3537 may_use_webviewer.label = ml_text # type: ignore[attr-defined] 

3538 may_manage_patients = get_child_node(self, "may_manage_patients") 

3539 mmp_text = _("May add, edit or delete patients created on the server") 

3540 may_manage_patients.title = mmp_text 

3541 may_manage_patients.label = mmp_text # type: ignore[attr-defined] 

3542 may_email_patients = get_child_node(self, "may_email_patients") 

3543 mep_text = _("May send emails to patients created on the server") 

3544 may_email_patients.title = mep_text 

3545 may_email_patients.label = mep_text # type: ignore[attr-defined] 

3546 view_all_patients_when_unfiltered = get_child_node( 

3547 self, "view_all_patients_when_unfiltered" 

3548 ) 

3549 vap_text = _( 

3550 "May view (browse) records from all patients when no patient " 

3551 "filter set" 

3552 ) 

3553 view_all_patients_when_unfiltered.title = vap_text 

3554 view_all_patients_when_unfiltered.label = vap_text # type: ignore[attr-defined] # noqa: E501 

3555 may_dump_data = get_child_node(self, "may_dump_data") 

3556 md_text = _("May perform bulk data dumps") 

3557 may_dump_data.title = md_text 

3558 may_dump_data.label = md_text # type: ignore[attr-defined] 

3559 may_run_reports = get_child_node(self, "may_run_reports") 

3560 mrr_text = _("May run reports") 

3561 may_run_reports.title = mrr_text 

3562 may_run_reports.label = mrr_text # type: ignore[attr-defined] 

3563 may_add_notes = get_child_node(self, "may_add_notes") 

3564 man_text = _("May add special notes to tasks") 

3565 may_add_notes.title = man_text 

3566 may_add_notes.label = man_text # type: ignore[attr-defined] 

3567 

3568 

3569class UserGroupPermissionsFullSchema(UserGroupPermissionsGroupAdminSchema): 

3570 """ 

3571 Edit group-specific permissions for a user. For superusers; includes the 

3572 option to make the user a groupadmin. 

3573 """ 

3574 

3575 groupadmin = BooleanNode( 

3576 default=False 

3577 ) # match ViewParam.GROUPADMIN and User attribute 

3578 

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

3580 super().after_bind(node, kw) 

3581 _ = self.gettext 

3582 groupadmin = get_child_node(self, "groupadmin") 

3583 text = _("User is a privileged group administrator for this group") 

3584 groupadmin.title = text 

3585 groupadmin.label = text # type: ignore[attr-defined] 

3586 

3587 

3588class EditUserGroupAdminSchema(CSRFSchema): 

3589 """ 

3590 Schema to edit a user. Version for group administrators. 

3591 """ 

3592 

3593 username = ( 

3594 UsernameNode() 

3595 ) # name must match ViewParam.USERNAME and User attribute 

3596 fullname = OptionalStringNode( # name must match ViewParam.FULLNAME and User attribute # noqa 

3597 validator=Length(0, StringLengths.FULLNAME_MAX_LEN) 

3598 ) 

3599 email = ( 

3600 OptionalEmailNode() 

3601 ) # name must match ViewParam.EMAIL and User attribute 

3602 must_change_password = ( 

3603 MustChangePasswordNode() 

3604 ) # match ViewParam.MUST_CHANGE_PASSWORD and User attribute 

3605 language = LanguageSelector() # must match ViewParam.LANGUAGE 

3606 group_ids = AdministeredGroupsSequence() # must match ViewParam.GROUP_IDS 

3607 

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

3609 _ = self.gettext 

3610 fullname = get_child_node(self, "fullname") 

3611 fullname.title = _("Full name") 

3612 email = get_child_node(self, "email") 

3613 email.title = _("E-mail address") 

3614 

3615 

3616class EditUserFullSchema(EditUserGroupAdminSchema): 

3617 """ 

3618 Schema to edit a user. Version for superusers; can also make the user a 

3619 superuser. 

3620 """ 

3621 

3622 superuser = BooleanNode( 

3623 default=False 

3624 ) # match ViewParam.SUPERUSER and User attribute 

3625 group_ids = AllGroupsSequence() # must match ViewParam.GROUP_IDS 

3626 

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

3628 _ = self.gettext 

3629 superuser = get_child_node(self, "superuser") 

3630 text = _("Superuser (CAUTION!)") 

3631 superuser.title = text 

3632 superuser.label = text # type: ignore[attr-defined] 

3633 

3634 

3635class EditUserFullForm(ApplyCancelForm): 

3636 """ 

3637 Form to edit a user. Full version for superusers. 

3638 """ 

3639 

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

3641 super().__init__( 

3642 schema_class=EditUserFullSchema, request=request, **kwargs 

3643 ) 

3644 

3645 

3646class EditUserGroupAdminForm(ApplyCancelForm): 

3647 """ 

3648 Form to edit a user. Version for group administrators. 

3649 """ 

3650 

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

3652 super().__init__( 

3653 schema_class=EditUserGroupAdminSchema, request=request, **kwargs 

3654 ) 

3655 

3656 

3657class EditUserGroupPermissionsFullForm(ApplyCancelForm): 

3658 """ 

3659 Form to edit a user's permissions within a group. Version for superusers. 

3660 """ 

3661 

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

3663 super().__init__( 

3664 schema_class=UserGroupPermissionsFullSchema, 

3665 request=request, 

3666 **kwargs, 

3667 ) 

3668 

3669 

3670class EditUserGroupMembershipGroupAdminForm(ApplyCancelForm): 

3671 """ 

3672 Form to edit a user's permissions within a group. Version for group 

3673 administrators. 

3674 """ 

3675 

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

3677 super().__init__( 

3678 schema_class=UserGroupPermissionsGroupAdminSchema, 

3679 request=request, 

3680 **kwargs, 

3681 ) 

3682 

3683 

3684class AddUserSuperuserSchema(CSRFSchema): 

3685 """ 

3686 Schema to add a user. Version for superusers. 

3687 """ 

3688 

3689 username = ( 

3690 UsernameNode() 

3691 ) # name must match ViewParam.USERNAME and User attribute 

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

3693 must_change_password = ( 

3694 MustChangePasswordNode() 

3695 ) # match ViewParam.MUST_CHANGE_PASSWORD and User attribute 

3696 group_ids = AllGroupsSequence() # must match ViewParam.GROUP_IDS 

3697 

3698 

3699class AddUserGroupadminSchema(AddUserSuperuserSchema): 

3700 """ 

3701 Schema to add a user. Version for group administrators. 

3702 """ 

3703 

3704 group_ids = AdministeredGroupsSequence() # must match ViewParam.GROUP_IDS 

3705 

3706 

3707class AddUserSuperuserForm(AddCancelForm): 

3708 """ 

3709 Form to add a user. Version for superusers. 

3710 """ 

3711 

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

3713 super().__init__( 

3714 schema_class=AddUserSuperuserSchema, request=request, **kwargs 

3715 ) 

3716 

3717 

3718class AddUserGroupadminForm(AddCancelForm): 

3719 """ 

3720 Form to add a user. Version for group administrators. 

3721 """ 

3722 

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

3724 super().__init__( 

3725 schema_class=AddUserGroupadminSchema, request=request, **kwargs 

3726 ) 

3727 

3728 

3729class SetUserUploadGroupSchema(CSRFSchema): 

3730 """ 

3731 Schema to choose the group into which a user uploads. 

3732 """ 

3733 

3734 upload_group_id = ( 

3735 OptionalGroupIdSelectorUserGroups() 

3736 ) # must match ViewParam.UPLOAD_GROUP_ID 

3737 

3738 # noinspection PyUnusedLocal 

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

3740 _ = self.gettext 

3741 upload_group_id = get_child_node(self, "upload_group_id") 

3742 upload_group_id.title = _("Group into which to upload data") 

3743 upload_group_id.description = _( 

3744 "Pick a group from those to which the user belongs" 

3745 ) 

3746 

3747 

3748class SetUserUploadGroupForm(InformativeNonceForm): 

3749 """ 

3750 Form to choose the group into which a user uploads. 

3751 """ 

3752 

3753 def __init__( 

3754 self, request: "CamcopsRequest", user: "User", **kwargs: Any 

3755 ) -> None: 

3756 _ = request.gettext 

3757 schema = SetUserUploadGroupSchema().bind( 

3758 request=request, user=user 

3759 ) # UNUSUAL 

3760 super().__init__( 

3761 schema, 

3762 buttons=[ 

3763 Button(name=FormAction.SUBMIT, title=_("Set")), 

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

3765 ], 

3766 **kwargs, 

3767 ) 

3768 

3769 

3770class DeleteUserSchema(HardWorkConfirmationSchema): 

3771 """ 

3772 Schema to delete a user. 

3773 """ 

3774 

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

3776 danger = TranslatableValidateDangerousOperationNode() 

3777 

3778 

3779class DeleteUserForm(DeleteCancelForm): 

3780 """ 

3781 Form to delete a user. 

3782 """ 

3783 

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

3785 super().__init__( 

3786 schema_class=DeleteUserSchema, request=request, **kwargs 

3787 ) 

3788 

3789 

3790# ============================================================================= 

3791# Add/edit/delete groups 

3792# ============================================================================= 

3793 

3794 

3795class PolicyNode(MandatoryStringNode, RequestAwareMixin): 

3796 """ 

3797 Node to capture a CamCOPS ID number policy, and make sure it is 

3798 syntactically valid. 

3799 """ 

3800 

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

3802 _ = self.gettext 

3803 if not isinstance(value, str): 

3804 # unlikely! 

3805 raise Invalid(node, _("Not a string")) 

3806 policy = TokenizedPolicy(value) 

3807 if not policy.is_syntactically_valid(): 

3808 raise Invalid(node, _("Syntactically invalid policy")) 

3809 if not policy.is_valid_for_idnums(self.request.valid_which_idnums): 

3810 raise Invalid( 

3811 node, 

3812 _( 

3813 "Invalid policy. Have you referred to non-existent ID " 

3814 "numbers? Is the policy less restrictive than the " 

3815 "tablet’s minimum ID policy?" 

3816 ) 

3817 + f" [{TABLET_ID_POLICY_STR!r}]", 

3818 ) 

3819 

3820 

3821class GroupNameNode(MandatoryStringNode, RequestAwareMixin): 

3822 """ 

3823 Node to capture a CamCOPS group name, and check it's valid as a string. 

3824 """ 

3825 

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

3827 try: 

3828 validate_group_name(value, self.request) 

3829 except ValueError as e: 

3830 raise Invalid(node, str(e)) 

3831 

3832 

3833class GroupIpUseWidget(Widget): 

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

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

3836 form = "group_ip_use.pt" 

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

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

3839 

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

3841 super().__init__(**kwargs) 

3842 self.request = request 

3843 

3844 def serialize( 

3845 self, 

3846 field: "Field", 

3847 cstruct: Union[Dict[str, Any], None, ColanderNullType], 

3848 **kw: Any, 

3849 ) -> Any: 

3850 if cstruct in (None, null): 

3851 cstruct = {} 

3852 

3853 cstruct: Dict[str, Any] # For type checker 

3854 

3855 for context in IpUse.CONTEXTS: 

3856 value = cstruct.get(context, False) 

3857 kw.setdefault(context, value) 

3858 

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

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

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

3862 

3863 _ = self.request.gettext 

3864 

3865 values.update( 

3866 introduction=_( 

3867 "These settings will be applied to the patient's device " 

3868 "when operating in single user mode." 

3869 ), 

3870 reason=_( 

3871 "The settings here influence whether CamCOPS will consider " 

3872 "some third-party tasks “permitted” on your behalf, according " 

3873 "to their published use criteria. They do <b>not</b> remove " 

3874 "your responsibility to ensure that you use them in " 

3875 "accordance with their own requirements." 

3876 ), 

3877 warning=_( 

3878 "WARNING. Providing incorrect information here may lead to " 

3879 "you VIOLATING copyright law, by using task for a purpose " 

3880 "that is not permitted, and being subject to damages and/or " 

3881 "prosecution." 

3882 ), 

3883 disclaimer=_( 

3884 "The authors of CamCOPS cannot be held responsible or liable " 

3885 "for any consequences of you misusing materials subject to " 

3886 "copyright." 

3887 ), 

3888 preamble=_("In which contexts does this group operate?"), 

3889 clinical_label=_("Clinical"), 

3890 medical_device_warning=_( 

3891 "WARNING: NOT FOR GENERAL CLINICAL USE; not a Medical Device; " 

3892 "see Terms and Conditions" 

3893 ), 

3894 commercial_label=_("Commercial"), 

3895 educational_label=_("Educational"), 

3896 research_label=_("Research"), 

3897 ) 

3898 

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

3900 

3901 def deserialize( 

3902 self, field: "Field", pstruct: Union[Dict[str, Any], ColanderNullType] 

3903 ) -> Dict[str, bool]: 

3904 if pstruct is null: 

3905 pstruct = {} 

3906 

3907 pstruct: Dict[str, Any] # For type checker 

3908 

3909 # It doesn't really matter what the pstruct values are. Only the 

3910 # options that are ticked will be present as keys in pstruct 

3911 return {k: k in pstruct for k in IpUse.CONTEXTS} 

3912 

3913 

3914class IpUseType(SchemaType): 

3915 # noinspection PyMethodMayBeStatic,PyUnusedLocal 

3916 def deserialize( 

3917 self, 

3918 node: SchemaNode, 

3919 cstruct: Union[Dict[str, Any], None, ColanderNullType], 

3920 ) -> Optional[IpUse]: 

3921 if cstruct in (None, null): 

3922 return None 

3923 

3924 cstruct: Dict[str, Any] # For type checker 

3925 

3926 return IpUse(**cstruct) 

3927 

3928 # noinspection PyMethodMayBeStatic,PyUnusedLocal 

3929 def serialize( 

3930 self, node: SchemaNode, ip_use: Union[IpUse, None, ColanderNullType] 

3931 ) -> Union[Dict, ColanderNullType]: 

3932 if ip_use in (null, None): 

3933 return null 

3934 

3935 return { 

3936 context: getattr(ip_use, context) for context in IpUse.CONTEXTS 

3937 } 

3938 

3939 

3940class GroupIpUseNode(SchemaNode, RequestAwareMixin): 

3941 schema_type = IpUseType 

3942 

3943 # noinspection PyUnusedLocal,PyAttributeOutsideInit 

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

3945 self.widget = GroupIpUseWidget(self.request) 

3946 

3947 

3948class EditGroupSchema(CSRFSchema): 

3949 """ 

3950 Schema to edit a group. 

3951 """ 

3952 

3953 group_id = HiddenIntegerNode() # must match ViewParam.GROUP_ID 

3954 name = GroupNameNode() # must match ViewParam.NAME 

3955 description = MandatoryStringNode( # must match ViewParam.DESCRIPTION 

3956 validator=Length( 

3957 StringLengths.GROUP_DESCRIPTION_MIN_LEN, 

3958 StringLengths.GROUP_DESCRIPTION_MAX_LEN, 

3959 ) 

3960 ) 

3961 ip_use = GroupIpUseNode() 

3962 

3963 group_ids = AllOtherGroupsSequence() # must match ViewParam.GROUP_IDS 

3964 upload_policy = PolicyNode() # must match ViewParam.UPLOAD_POLICY 

3965 finalize_policy = PolicyNode() # must match ViewParam.FINALIZE_POLICY 

3966 

3967 # noinspection PyUnusedLocal 

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

3969 _ = self.gettext 

3970 name = get_child_node(self, "name") 

3971 name.title = _("Group name") 

3972 

3973 ip_use = get_child_node(self, "ip_use") 

3974 ip_use.title = _("Group intellectual property settings") 

3975 

3976 group_ids = get_child_node(self, "group_ids") 

3977 group_ids.title = _("Other groups this group may see") 

3978 upload_policy = get_child_node(self, "upload_policy") 

3979 upload_policy.title = _("Upload policy") 

3980 upload_policy.description = _( 

3981 "Minimum required patient information to copy data to server" 

3982 ) 

3983 finalize_policy = get_child_node(self, "finalize_policy") 

3984 finalize_policy.title = _("Finalize policy") 

3985 finalize_policy.description = _( 

3986 "Minimum required patient information to clear data off " 

3987 "source device" 

3988 ) 

3989 

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

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

3992 q = ( 

3993 CountStarSpecializedQuery(Group, session=request.dbsession) # type: ignore[arg-type] # noqa: E501 

3994 .filter(Group.id != value[ViewParam.GROUP_ID]) 

3995 .filter(Group.name == value[ViewParam.NAME]) 

3996 ) 

3997 if q.count_star() > 0: 

3998 _ = request.gettext 

3999 raise Invalid(node, _("Name is used by another group!")) 

4000 

4001 

4002class EditGroupForm(InformativeNonceForm): 

4003 """ 

4004 Form to edit a group. 

4005 """ 

4006 

4007 def __init__( 

4008 self, request: "CamcopsRequest", group: Group, **kwargs: Any 

4009 ) -> None: 

4010 _ = request.gettext 

4011 schema = EditGroupSchema().bind( 

4012 request=request, group=group 

4013 ) # UNUSUAL BINDING 

4014 super().__init__( 

4015 schema, 

4016 buttons=[ 

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

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

4019 ], 

4020 **kwargs, 

4021 ) 

4022 

4023 

4024class AddGroupSchema(CSRFSchema): 

4025 """ 

4026 Schema to add a group. 

4027 """ 

4028 

4029 name = GroupNameNode() # name must match ViewParam.NAME 

4030 

4031 # noinspection PyUnusedLocal 

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

4033 _ = self.gettext 

4034 name = get_child_node(self, "name") 

4035 name.title = _("Group name") 

4036 

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

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

4039 q = CountStarSpecializedQuery(Group, session=request.dbsession).filter( # type: ignore[arg-type] # noqa: E501 

4040 Group.name == value[ViewParam.NAME] 

4041 ) 

4042 if q.count_star() > 0: 

4043 _ = request.gettext 

4044 raise Invalid(node, _("Name is used by another group!")) 

4045 

4046 

4047class AddGroupForm(AddCancelForm): 

4048 """ 

4049 Form to add a group. 

4050 """ 

4051 

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

4053 super().__init__( 

4054 schema_class=AddGroupSchema, request=request, **kwargs 

4055 ) 

4056 

4057 

4058class DeleteGroupSchema(HardWorkConfirmationSchema): 

4059 """ 

4060 Schema to delete a group. 

4061 """ 

4062 

4063 group_id = HiddenIntegerNode() # name must match ViewParam.GROUP_ID 

4064 danger = TranslatableValidateDangerousOperationNode() 

4065 

4066 

4067class DeleteGroupForm(DeleteCancelForm): 

4068 """ 

4069 Form to delete a group. 

4070 """ 

4071 

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

4073 super().__init__( 

4074 schema_class=DeleteGroupSchema, request=request, **kwargs 

4075 ) 

4076 

4077 

4078# ============================================================================= 

4079# Offer research dumps 

4080# ============================================================================= 

4081 

4082 

4083class DumpTypeSelector(SchemaNode, RequestAwareMixin): 

4084 """ 

4085 Node to select the filtering method for a data dump. 

4086 """ 

4087 

4088 schema_type = String 

4089 default = ViewArg.EVERYTHING 

4090 missing = ViewArg.EVERYTHING 

4091 

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

4093 self.title = "" # for type checker 

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

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

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

4097 

4098 # noinspection PyUnusedLocal 

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

4100 _ = self.gettext 

4101 self.title = _("Dump method") 

4102 choices = ( 

4103 (ViewArg.EVERYTHING, _("Everything")), 

4104 (ViewArg.USE_SESSION_FILTER, _("Use the session filter settings")), 

4105 ( 

4106 ViewArg.SPECIFIC_TASKS_GROUPS, 

4107 _("Specify tasks/groups manually (see below)"), 

4108 ), 

4109 ) 

4110 self.widget = RadioChoiceWidget(values=choices) 

4111 self.validator = OneOf(list(x[0] for x in choices)) 

4112 

4113 

4114class SpreadsheetFormatSelector(SchemaNode, RequestAwareMixin): 

4115 """ 

4116 Node to select a way of downloading an SQLite database. 

4117 """ 

4118 

4119 schema_type = String 

4120 default = ViewArg.XLSX 

4121 missing = ViewArg.XLSX 

4122 

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

4124 self.title = "" # for type checker 

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

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

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

4128 

4129 # noinspection PyUnusedLocal 

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

4131 _ = self.gettext 

4132 self.title = _("Spreadsheet format") 

4133 choices = ( 

4134 (ViewArg.R, _("R script")), 

4135 (ViewArg.ODS, _("OpenOffice spreadsheet (ODS) file")), 

4136 (ViewArg.XLSX, _("XLSX (Microsoft Excel) file")), 

4137 ( 

4138 ViewArg.TSV_ZIP, 

4139 _("ZIP file of tab-separated value (TSV) files"), 

4140 ), 

4141 ) 

4142 values, pv = get_values_and_permissible(choices) 

4143 self.widget = RadioChoiceWidget(values=values) 

4144 self.validator = OneOf(pv) 

4145 

4146 

4147class DeliveryModeNode(SchemaNode, RequestAwareMixin): 

4148 """ 

4149 Mode of delivery of data downloads. 

4150 """ 

4151 

4152 schema_type = String 

4153 default = ViewArg.EMAIL 

4154 missing = ViewArg.EMAIL 

4155 

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

4157 self.title = "" # for type checker 

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

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

4160 

4161 # noinspection PyUnusedLocal 

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

4163 _ = self.gettext 

4164 self.title = _("Delivery") 

4165 choices = ( 

4166 (ViewArg.IMMEDIATELY, _("Serve immediately")), 

4167 (ViewArg.EMAIL, _("E-mail me")), 

4168 (ViewArg.DOWNLOAD, _("Create a file for me to download")), 

4169 ) 

4170 values, pv = get_values_and_permissible(choices) 

4171 self.widget = RadioChoiceWidget(values=values) 

4172 

4173 # noinspection PyUnusedLocal 

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

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

4176 _ = request.gettext 

4177 if value == ViewArg.IMMEDIATELY: 

4178 if not request.config.permit_immediate_downloads: 

4179 raise Invalid( 

4180 self, 

4181 _("Disabled by the system administrator") 

4182 + f" [{ConfigParamSite.PERMIT_IMMEDIATE_DOWNLOADS}]", 

4183 ) 

4184 elif value == ViewArg.EMAIL: 

4185 if not request.user.email: 

4186 raise Invalid( 

4187 self, _("Your user does not have an email address") 

4188 ) 

4189 elif value == ViewArg.DOWNLOAD: 

4190 if not request.user_download_dir: 

4191 raise Invalid( 

4192 self, 

4193 _("User downloads not configured by administrator") 

4194 + f" [{ConfigParamSite.USER_DOWNLOAD_DIR}, " 

4195 f"{ConfigParamSite.USER_DOWNLOAD_MAX_SPACE_MB}]", 

4196 ) 

4197 else: 

4198 raise Invalid(self, _("Bad value")) 

4199 

4200 

4201class SqliteSelector(SchemaNode, RequestAwareMixin): 

4202 """ 

4203 Node to select a way of downloading an SQLite database. 

4204 """ 

4205 

4206 schema_type = String 

4207 default = ViewArg.SQLITE 

4208 missing = ViewArg.SQLITE 

4209 

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

4211 self.title = "" # for type checker 

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

4213 self.validator = None # type: Optional[ValidatorType] 

4214 super().__init__(*args, **kwargs) 

4215 

4216 # noinspection PyUnusedLocal 

4217 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None: 

4218 _ = self.gettext 

4219 self.title = _("Database download method") 

4220 choices = ( 

4221 # https://docs.sqlalchemy.org/en/latest/dialects/ 

4222 (ViewArg.SQLITE, _("Binary SQLite database")), 

4223 (ViewArg.SQL, _("SQL text to create SQLite database")), 

4224 ) 

4225 values, pv = get_values_and_permissible(choices) 

4226 self.widget = RadioChoiceWidget(values=values) 

4227 self.validator = OneOf(pv) 

4228 

4229 

4230class SimplifiedSpreadsheetsNode(SchemaNode, RequestAwareMixin): 

4231 """ 

4232 Boolean node: simplify basic dump spreadsheets? 

4233 """ 

4234 

4235 schema_type = Boolean 

4236 default = True 

4237 missing = True 

4238 

4239 def __init__(self, *args: Any, **kwargs: Any) -> None: 

4240 self.title = "" # for type checker 

4241 self.label = "" # for type checker 

4242 super().__init__(*args, **kwargs) 

4243 

4244 # noinspection PyUnusedLocal 

4245 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None: 

4246 _ = self.gettext 

4247 self.title = _("Simplify spreadsheets?") 

4248 self.label = _("Remove non-essential details?") 

4249 

4250 

4251class SortTsvByHeadingsNode(SchemaNode, RequestAwareMixin): 

4252 """ 

4253 Boolean node: sort TSV files by column name? 

4254 """ 

4255 

4256 schema_type = Boolean 

4257 default = False 

4258 missing = False 

4259 

4260 def __init__(self, *args: Any, **kwargs: Any) -> None: 

4261 self.title = "" # for type checker 

4262 self.label = "" # for type checker 

4263 super().__init__(*args, **kwargs) 

4264 

4265 # noinspection PyUnusedLocal 

4266 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None: 

4267 _ = self.gettext 

4268 self.title = _("Sort columns?") 

4269 self.label = _("Sort by heading (column) names within spreadsheets?") 

4270 

4271 

4272class IncludeSchemaNode(SchemaNode, RequestAwareMixin): 

4273 """ 

4274 Boolean node: should INFORMATION_SCHEMA.COLUMNS be included (for 

4275 downloads)? 

4276 

4277 False by default -- adds about 350 kb to an ODS download, for example. 

4278 """ 

4279 

4280 schema_type = Boolean 

4281 default = False 

4282 missing = False 

4283 

4284 def __init__(self, *args: Any, **kwargs: Any) -> None: 

4285 self.title = "" # for type checker 

4286 self.label = "" # for type checker 

4287 super().__init__(*args, **kwargs) 

4288 

4289 # noinspection PyUnusedLocal 

4290 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None: 

4291 _ = self.gettext 

4292 self.title = _("Include column information?") 

4293 self.label = _( 

4294 "Include details of all columns in the source database?" 

4295 ) 

4296 

4297 

4298class IncludeBlobsNode(SchemaNode, RequestAwareMixin): 

4299 """ 

4300 Boolean node: should BLOBs be included (for downloads)? 

4301 """ 

4302 

4303 schema_type = Boolean 

4304 default = False 

4305 missing = False 

4306 

4307 def __init__(self, *args: Any, **kwargs: Any) -> None: 

4308 self.title = "" # for type checker 

4309 self.label = "" # for type checker 

4310 super().__init__(*args, **kwargs) 

4311 

4312 # noinspection PyUnusedLocal 

4313 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None: 

4314 _ = self.gettext 

4315 self.title = _("Include BLOBs?") 

4316 self.label = _( 

4317 "Include binary large objects (BLOBs)? WARNING: may be large" 

4318 ) 

4319 

4320 

4321class PatientIdPerRowNode(SchemaNode, RequestAwareMixin): 

4322 """ 

4323 Boolean node: should patient ID information, and other cross-referencing 

4324 denormalized info, be included per row? 

4325 

4326 See :ref:`DB_PATIENT_ID_PER_ROW <DB_PATIENT_ID_PER_ROW>`. 

4327 """ 

4328 

4329 schema_type = Boolean 

4330 default = True 

4331 missing = True 

4332 

4333 def __init__(self, *args: Any, **kwargs: Any) -> None: 

4334 self.title = "" # for type checker 

4335 self.label = "" # for type checker 

4336 super().__init__(*args, **kwargs) 

4337 

4338 # noinspection PyUnusedLocal 

4339 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None: 

4340 _ = self.gettext 

4341 self.title = _("Patient ID per row?") 

4342 self.label = _( 

4343 "Include patient ID numbers and task cross-referencing " 

4344 "(denormalized) information per row?" 

4345 ) 

4346 

4347 

4348class OfferDumpManualSchema(Schema, RequestAwareMixin): 

4349 """ 

4350 Schema to offer the "manual" settings for a data dump (groups, task types). 

4351 """ 

4352 

4353 group_ids = AllowedGroupsSequence() # must match ViewParam.GROUP_IDS 

4354 tasks = MultiTaskSelector() # must match ViewParam.TASKS 

4355 

4356 widget = MappingWidget(template="mapping_accordion", open=False) 

4357 

4358 def __init__(self, *args: Any, **kwargs: Any) -> None: 

4359 self.title = "" # for type checker 

4360 super().__init__(*args, **kwargs) 

4361 

4362 # noinspection PyUnusedLocal 

4363 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None: 

4364 _ = self.gettext 

4365 self.title = _("Manual settings") 

4366 

4367 

4368class OfferBasicDumpSchema(CSRFSchema): 

4369 """ 

4370 Schema to choose the settings for a basic (TSV/ZIP) data dump. 

4371 """ 

4372 

4373 dump_method = DumpTypeSelector() # must match ViewParam.DUMP_METHOD 

4374 simplified = ( 

4375 SimplifiedSpreadsheetsNode() 

4376 ) # must match ViewParam.SIMPLIFIED 

4377 sort = SortTsvByHeadingsNode() # must match ViewParam.SORT 

4378 include_schema = IncludeSchemaNode() # must match ViewParam.INCLUDE_SCHEMA 

4379 manual = OfferDumpManualSchema() # must match ViewParam.MANUAL 

4380 viewtype = SpreadsheetFormatSelector() # must match ViewParam.VIEWTYPE 

4381 delivery_mode = DeliveryModeNode() # must match ViewParam.DELIVERY_MODE 

4382 

4383 

4384class OfferBasicDumpForm(SimpleSubmitForm): 

4385 """ 

4386 Form to offer a basic (TSV/ZIP) data dump. 

4387 """ 

4388 

4389 def __init__(self, request: "CamcopsRequest", **kwargs: Any) -> None: 

4390 _ = request.gettext 

4391 super().__init__( 

4392 schema_class=OfferBasicDumpSchema, 

4393 submit_title=_("Dump"), 

4394 request=request, 

4395 **kwargs, 

4396 ) 

4397 

4398 

4399class OfferSqlDumpSchema(CSRFSchema): 

4400 """ 

4401 Schema to choose the settings for an SQL data dump. 

4402 """ 

4403 

4404 dump_method = DumpTypeSelector() # must match ViewParam.DUMP_METHOD 

4405 sqlite_method = SqliteSelector() # must match ViewParam.SQLITE_METHOD 

4406 include_schema = IncludeSchemaNode() # must match ViewParam.INCLUDE_SCHEMA 

4407 include_blobs = IncludeBlobsNode() # must match ViewParam.INCLUDE_BLOBS 

4408 patient_id_per_row = ( 

4409 PatientIdPerRowNode() 

4410 ) # must match ViewParam.PATIENT_ID_PER_ROW 

4411 manual = OfferDumpManualSchema() # must match ViewParam.MANUAL 

4412 delivery_mode = DeliveryModeNode() # must match ViewParam.DELIVERY_MODE 

4413 

4414 

4415class OfferSqlDumpForm(SimpleSubmitForm): 

4416 """ 

4417 Form to choose the settings for an SQL data dump. 

4418 """ 

4419 

4420 def __init__(self, request: "CamcopsRequest", **kwargs: Any) -> None: 

4421 _ = request.gettext 

4422 super().__init__( 

4423 schema_class=OfferSqlDumpSchema, 

4424 submit_title=_("Dump"), 

4425 request=request, 

4426 **kwargs, 

4427 ) 

4428 

4429 

4430# ============================================================================= 

4431# Edit server settings 

4432# ============================================================================= 

4433 

4434 

4435class EditServerSettingsSchema(CSRFSchema): 

4436 """ 

4437 Schema to edit the global settings for the server. 

4438 """ 

4439 

4440 database_title = SchemaNode( # must match ViewParam.DATABASE_TITLE 

4441 String(), 

4442 validator=Length( 

4443 StringLengths.DATABASE_TITLE_MIN_LEN, 

4444 StringLengths.DATABASE_TITLE_MAX_LEN, 

4445 ), 

4446 ) 

4447 

4448 # noinspection PyUnusedLocal 

4449 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None: 

4450 _ = self.gettext 

4451 database_title = get_child_node(self, "database_title") 

4452 database_title.title = _("Database friendly title") 

4453 

4454 

4455class EditServerSettingsForm(ApplyCancelForm): 

4456 """ 

4457 Form to edit the global settings for the server. 

4458 """ 

4459 

4460 def __init__(self, request: "CamcopsRequest", **kwargs: Any) -> None: 

4461 super().__init__( 

4462 schema_class=EditServerSettingsSchema, request=request, **kwargs 

4463 ) 

4464 

4465 

4466# ============================================================================= 

4467# Edit ID number definitions 

4468# ============================================================================= 

4469 

4470 

4471class IdDefinitionDescriptionNode(SchemaNode, RequestAwareMixin): 

4472 """ 

4473 Node to capture the description of an ID number type. 

4474 """ 

4475 

4476 schema_type = String 

4477 validator = Length(1, StringLengths.ID_DESCRIPTOR_MAX_LEN) 

4478 

4479 def __init__(self, *args: Any, **kwargs: Any) -> None: 

4480 self.title = "" # for type checker 

4481 super().__init__(*args, **kwargs) 

4482 

4483 # noinspection PyUnusedLocal 

4484 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None: 

4485 _ = self.gettext 

4486 self.title = _("Full description (e.g. “NHS number”)") 

4487 

4488 

4489class IdDefinitionShortDescriptionNode(SchemaNode, RequestAwareMixin): 

4490 """ 

4491 Node to capture the short description of an ID number type. 

4492 """ 

4493 

4494 schema_type = String 

4495 validator = Length(1, StringLengths.ID_DESCRIPTOR_MAX_LEN) 

4496 

4497 def __init__(self, *args: Any, **kwargs: Any) -> None: 

4498 self.title = "" # for type checker 

4499 self.description = "" # for type checker 

4500 super().__init__(*args, **kwargs) 

4501 

4502 # noinspection PyUnusedLocal 

4503 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None: 

4504 _ = self.gettext 

4505 self.title = _("Short description (e.g. “NHS#”)") 

4506 self.description = _("Try to keep it very short!") 

4507 

4508 

4509class IdValidationMethodNode(OptionalStringNode, RequestAwareMixin): 

4510 """ 

4511 Node to choose a build-in ID number validation method. 

4512 """ 

4513 

4514 widget = SelectWidget(values=ID_NUM_VALIDATION_METHOD_CHOICES) 

4515 validator = OneOf(list(x[0] for x in ID_NUM_VALIDATION_METHOD_CHOICES)) 

4516 

4517 def __init__(self, *args: Any, **kwargs: Any) -> None: 

4518 self.title = "" # for type checker 

4519 self.description = "" # for type checker 

4520 super().__init__(*args, **kwargs) 

4521 

4522 # noinspection PyUnusedLocal 

4523 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None: 

4524 _ = self.gettext 

4525 self.title = _("Validation method") 

4526 self.description = _("Built-in CamCOPS ID number validation method") 

4527 

4528 

4529class Hl7AssigningAuthorityNode(OptionalStringNode, RequestAwareMixin): 

4530 """ 

4531 Optional node to capture the name of an HL7 Assigning Authority. 

4532 """ 

4533 

4534 def __init__(self, *args: Any, **kwargs: Any) -> None: 

4535 self.title = "" # for type checker 

4536 self.description = "" # for type checker 

4537 super().__init__(*args, **kwargs) 

4538 

4539 # noinspection PyUnusedLocal 

4540 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None: 

4541 _ = self.gettext 

4542 self.title = _("HL7 Assigning Authority") 

4543 self.description = _( 

4544 "For HL7 messaging: " 

4545 "HL7 Assigning Authority for ID number (unique name of the " 

4546 "system/organization/agency/department that creates the data)." 

4547 ) 

4548 

4549 # noinspection PyMethodMayBeStatic 

4550 def validator(self, node: SchemaNode, value: str) -> None: 

4551 try: 

4552 validate_hl7_aa(value, self.request) 

4553 except ValueError as e: 

4554 raise Invalid(node, str(e)) 

4555 

4556 

4557class Hl7IdTypeNode(OptionalStringNode, RequestAwareMixin): 

4558 """ 

4559 Optional node to capture the name of an HL7 Identifier Type code. 

4560 """ 

4561 

4562 def __init__(self, *args: Any, **kwargs: Any) -> None: 

4563 self.title = "" # for type checker 

4564 self.description = "" # for type checker 

4565 super().__init__(*args, **kwargs) 

4566 

4567 # noinspection PyUnusedLocal 

4568 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None: 

4569 _ = self.gettext 

4570 self.title = _("HL7 Identifier Type") 

4571 self.description = _( 

4572 "For HL7 messaging: " 

4573 "HL7 Identifier Type code: ‘a code corresponding to the type " 

4574 "of identifier. In some cases, this code may be used as a " 

4575 "qualifier to the “Assigning Authority” component.’" 

4576 ) 

4577 

4578 # noinspection PyMethodMayBeStatic 

4579 def validator(self, node: SchemaNode, value: str) -> None: 

4580 try: 

4581 validate_hl7_id_type(value, self.request) 

4582 except ValueError as e: 

4583 raise Invalid(node, str(e)) 

4584 

4585 

4586class FHIRIdSystemUrlNode(OptionalStringNode, RequestAwareMixin): 

4587 """ 

4588 Optional node to capture the URL for a FHIR ID system: 

4589 

4590 - https://www.hl7.org/fhir/datatypes.html#Identifier 

4591 - https://www.hl7.org/fhir/datatypes-definitions.html#Identifier.system 

4592 """ 

4593 

4594 validator = url 

4595 

4596 def __init__(self, *args: Any, **kwargs: Any) -> None: 

4597 self.title = "" # for type checker 

4598 self.description = "" # for type checker 

4599 super().__init__(*args, **kwargs) 

4600 

4601 # noinspection PyUnusedLocal 

4602 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None: 

4603 _ = self.gettext 

4604 self.title = _("FHIR ID system") 

4605 self.description = _("For FHIR exports: URL defining the ID system.") 

4606 

4607 

4608class EditIdDefinitionSchema(CSRFSchema): 

4609 """ 

4610 Schema to edit an ID number definition. 

4611 """ 

4612 

4613 which_idnum = HiddenIntegerNode() # must match ViewParam.WHICH_IDNUM 

4614 description = ( 

4615 IdDefinitionDescriptionNode() 

4616 ) # must match ViewParam.DESCRIPTION 

4617 short_description = ( 

4618 IdDefinitionShortDescriptionNode() 

4619 ) # must match ViewParam.SHORT_DESCRIPTION 

4620 validation_method = ( 

4621 IdValidationMethodNode() 

4622 ) # must match ViewParam.VALIDATION_METHOD 

4623 hl7_id_type = Hl7IdTypeNode() # must match ViewParam.HL7_ID_TYPE 

4624 hl7_assigning_authority = ( 

4625 Hl7AssigningAuthorityNode() 

4626 ) # must match ViewParam.HL7_ASSIGNING_AUTHORITY 

4627 fhir_id_system = ( 

4628 FHIRIdSystemUrlNode() 

4629 ) # must match ViewParam.FHIR_ID_SYSTEM 

4630 

4631 def validator(self, node: SchemaNode, value: Any) -> None: 

4632 request = self.bindings[Binding.REQUEST] # type: CamcopsRequest 

4633 _ = request.gettext 

4634 qd = ( 

4635 CountStarSpecializedQuery( 

4636 IdNumDefinition, session=request.dbsession # type: ignore[arg-type] # noqa: E501 

4637 ) 

4638 .filter( 

4639 IdNumDefinition.which_idnum != value[ViewParam.WHICH_IDNUM] 

4640 ) 

4641 .filter( 

4642 IdNumDefinition.description == value[ViewParam.DESCRIPTION] 

4643 ) 

4644 ) 

4645 if qd.count_star() > 0: 

4646 raise Invalid(node, _("Description is used by another ID number!")) 

4647 qs = ( 

4648 CountStarSpecializedQuery( 

4649 IdNumDefinition, session=request.dbsession # type: ignore[arg-type] # noqa: E501 

4650 ) 

4651 .filter( 

4652 IdNumDefinition.which_idnum != value[ViewParam.WHICH_IDNUM] 

4653 ) 

4654 .filter( 

4655 IdNumDefinition.short_description 

4656 == value[ViewParam.SHORT_DESCRIPTION] 

4657 ) 

4658 ) 

4659 if qs.count_star() > 0: 

4660 raise Invalid( 

4661 node, _("Short description is used by another ID number!") 

4662 ) 

4663 

4664 

4665class EditIdDefinitionForm(ApplyCancelForm): 

4666 """ 

4667 Form to edit an ID number definition. 

4668 """ 

4669 

4670 def __init__(self, request: "CamcopsRequest", **kwargs: Any) -> None: 

4671 super().__init__( 

4672 schema_class=EditIdDefinitionSchema, request=request, **kwargs 

4673 ) 

4674 

4675 

4676class AddIdDefinitionSchema(CSRFSchema): 

4677 """ 

4678 Schema to add an ID number definition. 

4679 """ 

4680 

4681 which_idnum = SchemaNode( # must match ViewParam.WHICH_IDNUM 

4682 Integer(), validator=Range(min=1) 

4683 ) 

4684 description = ( 

4685 IdDefinitionDescriptionNode() 

4686 ) # must match ViewParam.DESCRIPTION 

4687 short_description = ( 

4688 IdDefinitionShortDescriptionNode() 

4689 ) # must match ViewParam.SHORT_DESCRIPTION 

4690 validation_method = ( 

4691 IdValidationMethodNode() 

4692 ) # must match ViewParam.VALIDATION_METHOD 

4693 

4694 # noinspection PyUnusedLocal 

4695 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None: 

4696 _ = self.gettext 

4697 which_idnum = get_child_node(self, "which_idnum") 

4698 which_idnum.title = _("Which ID number?") 

4699 which_idnum.description = ( 

4700 "Specify the integer to represent the type of this ID " 

4701 "number class (e.g. consecutive numbering from 1)" 

4702 ) 

4703 

4704 def validator(self, node: SchemaNode, value: Any) -> None: 

4705 request = self.bindings[Binding.REQUEST] # type: CamcopsRequest 

4706 _ = request.gettext 

4707 qw = CountStarSpecializedQuery( 

4708 IdNumDefinition, session=request.dbsession # type: ignore[arg-type] # noqa: E501 

4709 ).filter(IdNumDefinition.which_idnum == value[ViewParam.WHICH_IDNUM]) 

4710 if qw.count_star() > 0: 

4711 raise Invalid(node, _("ID# clashes with another ID number!")) 

4712 qd = CountStarSpecializedQuery( 

4713 IdNumDefinition, session=request.dbsession # type: ignore[arg-type] # noqa: E501 

4714 ).filter(IdNumDefinition.description == value[ViewParam.DESCRIPTION]) 

4715 if qd.count_star() > 0: 

4716 raise Invalid(node, _("Description is used by another ID number!")) 

4717 qs = CountStarSpecializedQuery( 

4718 IdNumDefinition, session=request.dbsession # type: ignore[arg-type] # noqa: E501 

4719 ).filter( 

4720 IdNumDefinition.short_description 

4721 == value[ViewParam.SHORT_DESCRIPTION] 

4722 ) 

4723 if qs.count_star() > 0: 

4724 raise Invalid( 

4725 node, _("Short description is used by another ID number!") 

4726 ) 

4727 

4728 

4729class AddIdDefinitionForm(AddCancelForm): 

4730 """ 

4731 Form to add an ID number definition. 

4732 """ 

4733 

4734 def __init__(self, request: "CamcopsRequest", **kwargs: Any) -> None: 

4735 super().__init__( 

4736 schema_class=AddIdDefinitionSchema, request=request, **kwargs 

4737 ) 

4738 

4739 

4740class DeleteIdDefinitionSchema(HardWorkConfirmationSchema): 

4741 """ 

4742 Schema to delete an ID number definition. 

4743 """ 

4744 

4745 which_idnum = HiddenIntegerNode() # name must match ViewParam.WHICH_IDNUM 

4746 danger = TranslatableValidateDangerousOperationNode() 

4747 

4748 

4749class DeleteIdDefinitionForm(DangerousForm): 

4750 """ 

4751 Form to add an ID number definition. 

4752 """ 

4753 

4754 def __init__(self, request: "CamcopsRequest", **kwargs: Any) -> None: 

4755 _ = request.gettext 

4756 super().__init__( 

4757 schema_class=DeleteIdDefinitionSchema, 

4758 submit_action=FormAction.DELETE, 

4759 submit_title=_("Delete"), 

4760 request=request, 

4761 **kwargs, 

4762 ) 

4763 

4764 

4765# ============================================================================= 

4766# Special notes 

4767# ============================================================================= 

4768 

4769 

4770class AddSpecialNoteSchema(CSRFSchema): 

4771 """ 

4772 Schema to add a special note to a task. 

4773 """ 

4774 

4775 table_name = HiddenStringNode() # must match ViewParam.TABLENAME 

4776 server_pk = HiddenIntegerNode() # must match ViewParam.SERVER_PK 

4777 note = MandatoryStringNode( # must match ViewParam.NOTE 

4778 widget=TextAreaWidget(rows=20, cols=80) 

4779 ) 

4780 danger = TranslatableValidateDangerousOperationNode() 

4781 

4782 

4783class AddSpecialNoteForm(DangerousForm): 

4784 """ 

4785 Form to add a special note to a task. 

4786 """ 

4787 

4788 def __init__(self, request: "CamcopsRequest", **kwargs: Any) -> None: 

4789 _ = request.gettext 

4790 super().__init__( 

4791 schema_class=AddSpecialNoteSchema, 

4792 submit_action=FormAction.SUBMIT, 

4793 submit_title=_("Add"), 

4794 request=request, 

4795 **kwargs, 

4796 ) 

4797 

4798 

4799class DeleteSpecialNoteSchema(CSRFSchema): 

4800 """ 

4801 Schema to add a special note to a task. 

4802 """ 

4803 

4804 note_id = HiddenIntegerNode() # must match ViewParam.NOTE_ID 

4805 danger = TranslatableValidateDangerousOperationNode() 

4806 

4807 

4808class DeleteSpecialNoteForm(DangerousForm): 

4809 """ 

4810 Form to delete (hide) a special note. 

4811 """ 

4812 

4813 def __init__(self, request: "CamcopsRequest", **kwargs: Any) -> None: 

4814 _ = request.gettext 

4815 super().__init__( 

4816 schema_class=DeleteSpecialNoteSchema, 

4817 submit_action=FormAction.SUBMIT, 

4818 submit_title=_("Delete"), 

4819 request=request, 

4820 **kwargs, 

4821 ) 

4822 

4823 

4824# ============================================================================= 

4825# The unusual data manipulation operations 

4826# ============================================================================= 

4827 

4828 

4829class EraseTaskSchema(HardWorkConfirmationSchema): 

4830 """ 

4831 Schema to erase a task. 

4832 """ 

4833 

4834 table_name = HiddenStringNode() # must match ViewParam.TABLENAME 

4835 server_pk = HiddenIntegerNode() # must match ViewParam.SERVER_PK 

4836 danger = TranslatableValidateDangerousOperationNode() 

4837 

4838 

4839class EraseTaskForm(DangerousForm): 

4840 """ 

4841 Form to erase a task. 

4842 """ 

4843 

4844 def __init__(self, request: "CamcopsRequest", **kwargs: Any) -> None: 

4845 _ = request.gettext 

4846 super().__init__( 

4847 schema_class=EraseTaskSchema, 

4848 submit_action=FormAction.DELETE, 

4849 submit_title=_("Erase"), 

4850 request=request, 

4851 **kwargs, 

4852 ) 

4853 

4854 

4855class DeletePatientChooseSchema(CSRFSchema): 

4856 """ 

4857 Schema to delete a patient. 

4858 """ 

4859 

4860 which_idnum = ( 

4861 MandatoryWhichIdNumSelector() 

4862 ) # must match ViewParam.WHICH_IDNUM 

4863 idnum_value = MandatoryIdNumValue() # must match ViewParam.IDNUM_VALUE 

4864 group_id = ( 

4865 MandatoryGroupIdSelectorAdministeredGroups() 

4866 ) # must match ViewParam.GROUP_ID 

4867 danger = TranslatableValidateDangerousOperationNode() 

4868 

4869 

4870class DeletePatientChooseForm(DangerousForm): 

4871 """ 

4872 Form to delete a patient. 

4873 """ 

4874 

4875 def __init__(self, request: "CamcopsRequest", **kwargs: Any) -> None: 

4876 _ = request.gettext 

4877 super().__init__( 

4878 schema_class=DeletePatientChooseSchema, 

4879 submit_action=FormAction.SUBMIT, 

4880 submit_title=_("Show tasks that will be deleted"), 

4881 request=request, 

4882 **kwargs, 

4883 ) 

4884 

4885 

4886class DeletePatientConfirmSchema(HardWorkConfirmationSchema): 

4887 """ 

4888 Schema to confirm deletion of a patient. 

4889 """ 

4890 

4891 which_idnum = HiddenIntegerNode() # must match ViewParam.WHICH_IDNUM 

4892 idnum_value = HiddenIntegerNode() # must match ViewParam.IDNUM_VALUE 

4893 group_id = HiddenIntegerNode() # must match ViewParam.GROUP_ID 

4894 danger = TranslatableValidateDangerousOperationNode() 

4895 

4896 

4897class DeletePatientConfirmForm(DangerousForm): 

4898 """ 

4899 Form to confirm deletion of a patient. 

4900 """ 

4901 

4902 def __init__(self, request: "CamcopsRequest", **kwargs: Any) -> None: 

4903 _ = request.gettext 

4904 super().__init__( 

4905 schema_class=DeletePatientConfirmSchema, 

4906 submit_action=FormAction.DELETE, 

4907 submit_title=_("Delete"), 

4908 request=request, 

4909 **kwargs, 

4910 ) 

4911 

4912 

4913class DeleteServerCreatedPatientSchema(HardWorkConfirmationSchema): 

4914 """ 

4915 Schema to delete a patient created on the server. 

4916 """ 

4917 

4918 # name must match ViewParam.SERVER_PK 

4919 server_pk = HiddenIntegerNode() 

4920 danger = TranslatableValidateDangerousOperationNode() 

4921 

4922 

4923class DeleteServerCreatedPatientForm(DeleteCancelForm): 

4924 """ 

4925 Form to delete a patient created on the server 

4926 """ 

4927 

4928 def __init__(self, request: "CamcopsRequest", **kwargs: Any) -> None: 

4929 super().__init__( 

4930 schema_class=DeleteServerCreatedPatientSchema, 

4931 request=request, 

4932 **kwargs, 

4933 ) 

4934 

4935 

4936EDIT_PATIENT_SIMPLE_PARAMS = [ 

4937 ViewParam.FORENAME, 

4938 ViewParam.SURNAME, 

4939 ViewParam.DOB, 

4940 ViewParam.SEX, 

4941 ViewParam.ADDRESS, 

4942 ViewParam.EMAIL, 

4943 ViewParam.GP, 

4944 ViewParam.OTHER, 

4945] 

4946 

4947 

4948class TaskScheduleSelector(SchemaNode, RequestAwareMixin): 

4949 """ 

4950 Drop-down with all available task schedules 

4951 """ 

4952 

4953 widget = SelectWidget() 

4954 

4955 def __init__(self, *args: Any, **kwargs: Any) -> None: 

4956 self.title = "" # for type checker 

4957 self.name = "" # for type checker 

4958 self.validator = None # type: Optional[ValidatorType] 

4959 super().__init__(*args, **kwargs) 

4960 

4961 # noinspection PyUnusedLocal 

4962 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None: 

4963 request = self.request 

4964 _ = request.gettext 

4965 self.title = _("Task schedule") 

4966 values = [] # type: List[Tuple[Optional[int], str]] 

4967 

4968 valid_group_ids = ( 

4969 request.user.ids_of_groups_user_may_manage_patients_in 

4970 ) 

4971 task_schedules = ( 

4972 request.dbsession.query(TaskSchedule) 

4973 .filter(TaskSchedule.group_id.in_(valid_group_ids)) 

4974 .order_by(TaskSchedule.name) 

4975 ) 

4976 

4977 for task_schedule in task_schedules: 

4978 values.append((task_schedule.id, task_schedule.name)) 

4979 values, pv = get_values_and_permissible(values, add_none=False) 

4980 

4981 self.widget.values = values 

4982 self.validator = OneOf(pv) 

4983 

4984 @staticmethod 

4985 def schema_type() -> SchemaType: 

4986 return Integer() 

4987 

4988 

4989class JsonType(SchemaType): 

4990 """ 

4991 Schema type for JsonNode 

4992 """ 

4993 

4994 # noinspection PyMethodMayBeStatic, PyUnusedLocal 

4995 def deserialize( 

4996 self, node: SchemaNode, cstruct: Union[str, ColanderNullType, None] 

4997 ) -> Any: 

4998 # is null when form is empty 

4999 if cstruct in (null, None): 

5000 return None 

5001 

5002 cstruct: str 

5003 

5004 try: 

5005 # Validation happens on the widget class 

5006 json_value = json.loads(cstruct) 

5007 except json.JSONDecodeError: 

5008 return None 

5009 

5010 return json_value 

5011 

5012 # noinspection PyMethodMayBeStatic,PyUnusedLocal 

5013 def serialize( 

5014 self, node: SchemaNode, appstruct: Union[Dict, None, ColanderNullType] 

5015 ) -> Union[str, ColanderNullType]: 

5016 # is null when form is empty (new record) 

5017 # is None when populated from empty value in the database 

5018 if appstruct in (null, None): 

5019 return null 

5020 

5021 # appstruct should be well formed here (it would already have failed 

5022 # when reading from the database) 

5023 return json.dumps(appstruct) 

5024 

5025 

5026class JsonWidget(Widget): 

5027 """ 

5028 Widget supporting jsoneditor https://github.com/josdejong/jsoneditor 

5029 """ 

5030 

5031 basedir = os.path.join(TEMPLATE_DIR, "deform") 

5032 readonlydir = os.path.join(basedir, "readonly") 

5033 form = "json.pt" 

5034 template = os.path.join(basedir, form) 

5035 readonly_template = os.path.join(readonlydir, form) 

5036 requirements = (("jsoneditor", None),) 

5037 

5038 def __init__(self, request: "CamcopsRequest", **kwargs: Any) -> None: 

5039 super().__init__(**kwargs) 

5040 self.request = request 

5041 

5042 def serialize( 

5043 self, field: "Field", cstruct: Union[str, ColanderNullType], **kw: Any 

5044 ) -> Any: 

5045 if cstruct is null: 

5046 cstruct = "" 

5047 

5048 readonly = kw.get("readonly", self.readonly) 

5049 template = readonly and self.readonly_template or self.template 

5050 

5051 values = self.get_template_values(field, cstruct, kw) 

5052 

5053 return field.renderer(template, **values) 

5054 

5055 def deserialize( 

5056 self, field: "Field", pstruct: Union[str, ColanderNullType] 

5057 ) -> Union[str, ColanderNullType]: 

5058 # is empty string when field is empty 

5059 if pstruct in (null, ""): 

5060 return null 

5061 

5062 _ = self.request.gettext 

5063 error_message = _("Please enter valid JSON or leave blank") 

5064 

5065 pstruct: str 

5066 

5067 try: 

5068 json.loads(pstruct) 

5069 except json.JSONDecodeError: 

5070 raise Invalid(field, error_message, pstruct) 

5071 

5072 return pstruct 

5073 

5074 

5075class JsonSettingsNode(SchemaNode, RequestAwareMixin): 

5076 """ 

5077 Note to edit raw JSON. 

5078 """ 

5079 

5080 schema_type = JsonType 

5081 missing = null 

5082 

5083 # noinspection PyUnusedLocal,PyAttributeOutsideInit 

5084 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None: 

5085 _ = self.gettext 

5086 self.widget = JsonWidget(self.request) 

5087 self.title = _("Task-specific settings for this patient") 

5088 self.description = _( 

5089 "ADVANCED. Only applicable to tasks that are configurable on a " 

5090 "per-patient basis. Format: JSON object, with settings keyed on " 

5091 "task table name." 

5092 ) 

5093 

5094 def validator(self, node: SchemaNode, value: Any) -> None: 

5095 if value is not None: 

5096 # will be None if JSON failed to validate 

5097 if not isinstance(value, dict): 

5098 _ = self.request.gettext 

5099 error_message = _( 

5100 "Please enter a valid JSON object (with settings keyed on " 

5101 "task table name) or leave blank" 

5102 ) 

5103 raise Invalid(node, error_message) 

5104 

5105 

5106class TaskScheduleJsonSchema(Schema): 

5107 """ 

5108 Schema for the advanced JSON parts of a patient-to-task-schedule mapping. 

5109 """ 

5110 

5111 settings = JsonSettingsNode() # must match ViewParam.SETTINGS 

5112 

5113 

5114class TaskScheduleNode(MappingSchema, RequestAwareMixin): 

5115 """ 

5116 Node to edit settings for a patient-to-task-schedule mapping. 

5117 """ 

5118 

5119 patient_task_schedule_id = ( 

5120 HiddenIntegerNode() 

5121 ) # name must match ViewParam.PATIENT_TASK_SCHEDULE_ID 

5122 schedule_id = TaskScheduleSelector() # must match ViewParam.SCHEDULE_ID 

5123 start_datetime = ( 

5124 StartPendulumSelector() 

5125 ) # must match ViewParam.START_DATETIME 

5126 if DEFORM_ACCORDION_BUG: 

5127 settings = JsonSettingsNode() # must match ViewParam.SETTINGS 

5128 else: 

5129 advanced = TaskScheduleJsonSchema( # must match ViewParam.ADVANCED 

5130 widget=MappingWidget(template="mapping_accordion", open=False) 

5131 ) 

5132 

5133 def __init__(self, *args: Any, **kwargs: Any) -> None: 

5134 self.title = "" # for type checker 

5135 super().__init__(*args, **kwargs) 

5136 

5137 # noinspection PyUnusedLocal 

5138 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None: 

5139 _ = self.gettext 

5140 self.title = _("Task schedule") 

5141 start_datetime = get_child_node(self, "start_datetime") 

5142 start_datetime.description = _( 

5143 "Leave blank for the date the patient first downloads the schedule" 

5144 ) 

5145 if not DEFORM_ACCORDION_BUG: 

5146 advanced = get_child_node(self, "advanced") 

5147 advanced.title = _("Advanced") 

5148 

5149 

5150class TaskScheduleSequence(SequenceSchema, RequestAwareMixin): 

5151 """ 

5152 Sequence for multiple patient-to-task-schedule mappings. 

5153 """ 

5154 

5155 task_schedule_sequence = TaskScheduleNode() 

5156 missing = drop 

5157 

5158 def __init__(self, *args: Any, **kwargs: Any) -> None: 

5159 self.title = "" # for type checker 

5160 self.widget = None # type: Optional[Widget] 

5161 super().__init__(*args, **kwargs) 

5162 

5163 # noinspection PyUnusedLocal 

5164 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None: 

5165 _ = self.gettext 

5166 self.title = _("Task Schedules") 

5167 self.widget = TranslatableSequenceWidget(request=self.request) 

5168 

5169 

5170class EditPatientSchema(CSRFSchema): 

5171 """ 

5172 Schema to edit a patient. 

5173 """ 

5174 

5175 server_pk = HiddenIntegerNode() # must match ViewParam.SERVER_PK 

5176 forename = OptionalPatientNameNode() # must match ViewParam.FORENAME 

5177 surname = OptionalPatientNameNode() # must match ViewParam.SURNAME 

5178 dob = DateSelectorNode() # must match ViewParam.DOB 

5179 sex = MandatorySexSelector() # must match ViewParam.SEX 

5180 address = OptionalStringNode() # must match ViewParam.ADDRESS 

5181 email = OptionalEmailNode() # must match ViewParam.EMAIL 

5182 gp = OptionalStringNode() # must match ViewParam.GP 

5183 other = OptionalStringNode() # must match ViewParam.OTHER 

5184 id_references = ( 

5185 IdNumSequenceUniquePerWhichIdnum() 

5186 ) # must match ViewParam.ID_REFERENCES 

5187 

5188 # noinspection PyUnusedLocal 

5189 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None: 

5190 _ = self.gettext 

5191 dob = get_child_node(self, "dob") 

5192 dob.title = _("Date of birth") 

5193 gp = get_child_node(self, "gp") 

5194 gp.title = _("GP") 

5195 

5196 def validator(self, node: SchemaNode, value: Any) -> None: 

5197 request = self.bindings[Binding.REQUEST] # type: CamcopsRequest 

5198 dbsession = request.dbsession 

5199 group_id = value[ViewParam.GROUP_ID] 

5200 group = Group.get_group_by_id(dbsession, group_id) 

5201 testpatient = Patient() 

5202 for k in EDIT_PATIENT_SIMPLE_PARAMS: 

5203 setattr(testpatient, k, value[k]) 

5204 testpatient.idnums = [] 

5205 for idrefdict in value[ViewParam.ID_REFERENCES]: 

5206 pidnum = PatientIdNum() 

5207 pidnum.which_idnum = idrefdict[ViewParam.WHICH_IDNUM] 

5208 pidnum.idnum_value = idrefdict[ViewParam.IDNUM_VALUE] 

5209 testpatient.idnums.append(pidnum) 

5210 tk_finalize_policy = TokenizedPolicy(group.finalize_policy) 

5211 if not testpatient.satisfies_id_policy(tk_finalize_policy): 

5212 _ = self.gettext 

5213 raise Invalid( 

5214 node, 

5215 _("Patient would not meet 'finalize' ID policy for group:") 

5216 + f" {group.name}! [" 

5217 + _("That policy is:") 

5218 + f" {group.finalize_policy!r}]", 

5219 ) 

5220 

5221 

5222class DangerousEditPatientSchema(EditPatientSchema): 

5223 group_id = HiddenIntegerNode() # must match ViewParam.GROUP_ID 

5224 danger = TranslatableValidateDangerousOperationNode() 

5225 

5226 

5227class EditServerCreatedPatientSchema(EditPatientSchema): 

5228 # Must match ViewParam.GROUP_ID 

5229 group_id = MandatoryGroupIdSelectorPatientGroups(insert_before="forename") 

5230 task_schedules = ( 

5231 TaskScheduleSequence() 

5232 ) # must match ViewParam.TASK_SCHEDULES 

5233 

5234 

5235class EditFinalizedPatientForm(DangerousForm): 

5236 """ 

5237 Form to edit a finalized patient. 

5238 """ 

5239 

5240 def __init__(self, request: "CamcopsRequest", **kwargs: Any) -> None: 

5241 _ = request.gettext 

5242 super().__init__( 

5243 schema_class=DangerousEditPatientSchema, 

5244 submit_action=FormAction.SUBMIT, 

5245 submit_title=_("Submit"), 

5246 request=request, 

5247 **kwargs, 

5248 ) 

5249 

5250 

5251class EditServerCreatedPatientForm(DynamicDescriptionsNonceForm): 

5252 """ 

5253 Form to add or edit a patient not yet on the device (for scheduled tasks) 

5254 """ 

5255 

5256 def __init__(self, request: "CamcopsRequest", **kwargs: Any) -> None: 

5257 schema = EditServerCreatedPatientSchema().bind(request=request) 

5258 _ = request.gettext 

5259 super().__init__( 

5260 schema, 

5261 request=request, 

5262 buttons=[ 

5263 Button( 

5264 name=FormAction.SUBMIT, 

5265 title=_("Submit"), 

5266 css_class="btn-danger", 

5267 ), 

5268 Button(name=FormAction.CANCEL, title=_("Cancel")), 

5269 ], 

5270 **kwargs, 

5271 ) 

5272 

5273 

5274class EmailTemplateNode(OptionalStringNode, RequestAwareMixin): 

5275 def __init__(self, *args: Any, **kwargs: Any) -> None: 

5276 self.title = "" # for type checker 

5277 self.description = "" # for type checker 

5278 self.formatter = TaskScheduleEmailTemplateFormatter() 

5279 super().__init__(*args, **kwargs) 

5280 

5281 # noinspection PyUnusedLocal 

5282 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None: 

5283 _ = self.gettext 

5284 self.title = _("Email template") 

5285 self.description = _( 

5286 "Template of email to be sent to patients when inviting them to " 

5287 "complete the tasks in the schedule. Valid placeholders: {}" 

5288 ).format(self.formatter.get_valid_parameters_string()) 

5289 

5290 # noinspection PyAttributeOutsideInit 

5291 self.widget = RichTextWidget(options=get_tinymce_options(self.request)) 

5292 

5293 def validator(self, node: SchemaNode, value: Any) -> None: 

5294 _ = self.gettext 

5295 

5296 try: 

5297 self.formatter.validate(value) 

5298 return 

5299 except KeyError as e: 

5300 error = _("{bad_key} is not a valid placeholder").format(bad_key=e) 

5301 except ValueError: 

5302 error = _( 

5303 "Invalid email template. Is there a missing '{' or '}' ?" 

5304 ) 

5305 

5306 raise Invalid(node, error) 

5307 

5308 

5309class EmailCcNode(OptionalEmailNode, RequestAwareMixin): 

5310 # noinspection PyUnusedLocal 

5311 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None: 

5312 _ = self.gettext 

5313 self.title = _("Email CC") 

5314 self.description = _( 

5315 "The patient will see these email addresses. Separate multiple " 

5316 "addresses with commas." 

5317 ) 

5318 

5319 

5320class EmailBccNode(OptionalEmailNode, RequestAwareMixin): 

5321 # noinspection PyUnusedLocal 

5322 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None: 

5323 _ = self.gettext 

5324 self.title = _("Email BCC") 

5325 self.description = _( 

5326 "The patient will not see these email addresses. Separate " 

5327 "multiple addresses with commas." 

5328 ) 

5329 

5330 

5331class EmailFromNode(OptionalEmailNode, RequestAwareMixin): 

5332 # noinspection PyUnusedLocal 

5333 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None: 

5334 _ = self.gettext 

5335 self.title = _('Email "From" address') 

5336 self.description = _( 

5337 "You must set this if you want to send emails to your patients" 

5338 ) 

5339 

5340 

5341class TaskScheduleSchema(CSRFSchema): 

5342 name = OptionalStringNode() 

5343 group_id = ( 

5344 MandatoryGroupIdSelectorAdministeredGroups() 

5345 ) # must match ViewParam.GROUP_ID 

5346 email_from = EmailFromNode() # must match ViewParam.EMAIL_FROM 

5347 email_cc = EmailCcNode() # must match ViewParam.EMAIL_CC 

5348 email_bcc = EmailBccNode() # must match ViewParam.EMAIL_BCC 

5349 email_subject = OptionalStringNode() 

5350 email_template = EmailTemplateNode() 

5351 

5352 

5353class EditTaskScheduleForm(DynamicDescriptionsNonceForm): 

5354 def __init__(self, request: "CamcopsRequest", **kwargs: Any) -> None: 

5355 schema = TaskScheduleSchema().bind(request=request) 

5356 _ = request.gettext 

5357 super().__init__( 

5358 schema, 

5359 request=request, 

5360 buttons=[ 

5361 Button( 

5362 name=FormAction.SUBMIT, 

5363 title=_("Submit"), 

5364 css_class="btn-danger", 

5365 ), 

5366 Button(name=FormAction.CANCEL, title=_("Cancel")), 

5367 ], 

5368 **kwargs, 

5369 ) 

5370 

5371 

5372class DeleteTaskScheduleSchema(HardWorkConfirmationSchema): 

5373 """ 

5374 Schema to delete a task schedule. 

5375 """ 

5376 

5377 # name must match ViewParam.SCHEDULE_ID 

5378 schedule_id = HiddenIntegerNode() 

5379 danger = TranslatableValidateDangerousOperationNode() 

5380 

5381 

5382class DeleteTaskScheduleForm(DeleteCancelForm): 

5383 """ 

5384 Form to delete a task schedule. 

5385 """ 

5386 

5387 def __init__(self, request: "CamcopsRequest", **kwargs: Any) -> None: 

5388 super().__init__( 

5389 schema_class=DeleteTaskScheduleSchema, request=request, **kwargs 

5390 ) 

5391 

5392 

5393class DurationWidget(Widget): 

5394 """ 

5395 Widget for entering a duration as a number of months, weeks and days. 

5396 The default template renders three text input fields. 

5397 Total days = (months * 30) + (weeks * 7) + days. 

5398 """ 

5399 

5400 basedir = os.path.join(TEMPLATE_DIR, "deform") 

5401 readonlydir = os.path.join(basedir, "readonly") 

5402 form = "duration.pt" 

5403 template = os.path.join(basedir, form) 

5404 readonly_template = os.path.join(readonlydir, form) 

5405 

5406 def __init__(self, request: "CamcopsRequest", **kwargs: Any) -> None: 

5407 super().__init__(**kwargs) 

5408 self.request = request 

5409 

5410 def serialize( 

5411 self, 

5412 field: "Field", 

5413 cstruct: Union[Dict[str, Any], None, ColanderNullType], 

5414 **kw: Any, 

5415 ) -> Any: 

5416 # called when rendering the form with values from 

5417 # DurationType.serialize 

5418 if cstruct in (None, null): 

5419 cstruct = {} 

5420 

5421 cstruct: Dict[str, Any] 

5422 

5423 months = cstruct.get("months", "") 

5424 weeks = cstruct.get("weeks", "") 

5425 days = cstruct.get("days", "") 

5426 

5427 kw.setdefault("months", months) 

5428 kw.setdefault("weeks", weeks) 

5429 kw.setdefault("days", days) 

5430 

5431 readonly = kw.get("readonly", self.readonly) 

5432 template = readonly and self.readonly_template or self.template 

5433 values = self.get_template_values(field, cstruct, kw) 

5434 

5435 _ = self.request.gettext 

5436 

5437 values.update( 

5438 weeks_placeholder=_("1 week = 7 days"), 

5439 months_placeholder=_("1 month = 30 days"), 

5440 months_label=_("Months"), 

5441 weeks_label=_("Weeks"), 

5442 days_label=_("Days"), 

5443 ) 

5444 

5445 return field.renderer(template, **values) 

5446 

5447 def deserialize( 

5448 self, field: "Field", pstruct: Union[Dict[str, Any], ColanderNullType] 

5449 ) -> Dict[str, int]: 

5450 # called when validating the form on submission 

5451 # value is passed to the schema deserialize() 

5452 

5453 if pstruct is null: 

5454 pstruct = {} 

5455 

5456 pstruct: Dict[str, Any] 

5457 

5458 errors = [] 

5459 

5460 try: 

5461 days = int(pstruct.get("days") or "0") 

5462 except ValueError: 

5463 errors.append("Please enter a valid number of days or leave blank") 

5464 

5465 try: 

5466 weeks = int(pstruct.get("weeks") or "0") 

5467 except ValueError: 

5468 errors.append( 

5469 "Please enter a valid number of weeks or leave blank" 

5470 ) 

5471 

5472 try: 

5473 months = int(pstruct.get("months") or "0") 

5474 except ValueError: 

5475 errors.append( 

5476 "Please enter a valid number of months or leave blank" 

5477 ) 

5478 

5479 if len(errors) > 0: 

5480 raise Invalid(field, errors, pstruct) 

5481 

5482 # noinspection PyUnboundLocalVariable 

5483 return {"days": days, "months": months, "weeks": weeks} 

5484 

5485 

5486class DurationType(SchemaType): 

5487 """ 

5488 Custom colander schema type to convert between Pendulum Duration objects 

5489 and months, weeks and days. 

5490 """ 

5491 

5492 # noinspection PyMethodMayBeStatic,PyUnusedLocal 

5493 def deserialize( 

5494 self, 

5495 node: SchemaNode, 

5496 cstruct: Union[Dict[str, Any], None, ColanderNullType], 

5497 ) -> Optional[Duration]: 

5498 # called when validating the submitted form with the total days 

5499 # from DurationWidget.deserialize() 

5500 if cstruct in (None, null): 

5501 return None 

5502 

5503 cstruct: Dict[str, Any] 

5504 

5505 # may be passed invalid values when re-rendering widget with error 

5506 # messages 

5507 try: 

5508 days = int(cstruct.get("days") or "0") 

5509 except ValueError: 

5510 days = 0 

5511 

5512 try: 

5513 weeks = int(cstruct.get("weeks") or "0") 

5514 except ValueError: 

5515 weeks = 0 

5516 

5517 try: 

5518 months = int(cstruct.get("months") or "0") 

5519 except ValueError: 

5520 months = 0 

5521 

5522 total_days = months * 30 + weeks * 7 + days 

5523 

5524 return Duration(days=total_days) 

5525 

5526 # noinspection PyMethodMayBeStatic,PyUnusedLocal 

5527 def serialize( 

5528 self, node: SchemaNode, duration: Union[Duration, ColanderNullType] 

5529 ) -> Union[Dict, ColanderNullType]: 

5530 if duration is null: 

5531 # For new schedule item 

5532 return null 

5533 

5534 duration: Duration 

5535 

5536 total_days = duration.in_days() 

5537 

5538 months = total_days // 30 

5539 weeks = (total_days % 30) // 7 

5540 days = (total_days % 30) % 7 

5541 

5542 # Existing schedule item 

5543 cstruct = {"days": days, "months": months, "weeks": weeks} 

5544 

5545 return cstruct 

5546 

5547 

5548class DurationNode(SchemaNode, RequestAwareMixin): 

5549 schema_type = DurationType 

5550 

5551 # noinspection PyUnusedLocal,PyAttributeOutsideInit 

5552 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None: 

5553 self.widget = DurationWidget(self.request) 

5554 

5555 

5556class TaskScheduleItemSchema(CSRFSchema): 

5557 schedule_id = HiddenIntegerNode() # name must match ViewParam.SCHEDULE_ID 

5558 # name must match ViewParam.TABLE_NAME 

5559 table_name = MandatorySingleTaskSelector() 

5560 # name must match ViewParam.CLINICIAN_CONFIRMATION 

5561 clinician_confirmation = BooleanNode(default=False) 

5562 due_from = DurationNode() # name must match ViewParam.DUE_FROM 

5563 due_within = DurationNode() # name must match ViewParam.DUE_WITHIN 

5564 

5565 # noinspection PyUnusedLocal 

5566 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None: 

5567 _ = self.gettext 

5568 due_from = get_child_node(self, "due_from") 

5569 due_from.title = _("Due from") 

5570 due_from.description = _( 

5571 "Time from the start of schedule when the patient may begin this " 

5572 "task" 

5573 ) 

5574 due_within = get_child_node(self, "due_within") 

5575 due_within.title = _("Due within") 

5576 due_within.description = _( 

5577 "Time the patient has to complete this task" 

5578 ) 

5579 clinician_confirmation = get_child_node(self, "clinician_confirmation") 

5580 clinician_confirmation.title = _("Allow clinician tasks") 

5581 clinician_confirmation.label = None # type: ignore[attr-defined] 

5582 clinician_confirmation.description = _( 

5583 "Tick this box to schedule a task that would normally be " 

5584 "completed by (or with) a clinician" 

5585 ) 

5586 

5587 def validator(self, node: SchemaNode, value: Dict[str, Any]) -> None: 

5588 task_class = self._get_task_class(value) 

5589 

5590 self._validate_clinician_status(node, value, task_class) 

5591 self._validate_due_dates(node, value) 

5592 self._validate_task_ip_use(node, value, task_class) 

5593 

5594 # noinspection PyMethodMayBeStatic 

5595 def _get_task_class(self, value: Dict[str, Any]) -> Type["Task"]: 

5596 return tablename_to_task_class_dict()[value[ViewParam.TABLE_NAME]] 

5597 

5598 def _validate_clinician_status( 

5599 self, node: SchemaNode, value: Dict[str, Any], task_class: Type["Task"] 

5600 ) -> None: 

5601 

5602 _ = self.gettext 

5603 clinician_confirmation = value[ViewParam.CLINICIAN_CONFIRMATION] 

5604 if task_class.has_clinician and not clinician_confirmation: 

5605 raise Invalid( 

5606 node, 

5607 _( 

5608 "You have selected the task '{task_name}', which a " 

5609 "patient would not normally complete by themselves. " 

5610 "If you are sure you want to do this, you must tick " 

5611 "'Allow clinician tasks'." 

5612 ).format(task_name=task_class.shortname), 

5613 ) 

5614 

5615 def _validate_due_dates( 

5616 self, node: SchemaNode, value: Dict[str, Any] 

5617 ) -> None: 

5618 _ = self.gettext 

5619 due_from = value[ViewParam.DUE_FROM] 

5620 if due_from.total_days() < 0: 

5621 raise Invalid(node, _("'Due from' must be zero or more days")) 

5622 

5623 due_within = value[ViewParam.DUE_WITHIN] 

5624 if due_within.total_days() <= 0: 

5625 raise Invalid(node, _("'Due within' must be more than zero days")) 

5626 

5627 def _validate_task_ip_use( 

5628 self, node: SchemaNode, value: Dict[str, Any], task_class: Type["Task"] 

5629 ) -> None: 

5630 

5631 _ = self.gettext 

5632 

5633 if not task_class.prohibits_anything(): 

5634 return 

5635 

5636 schedule_id = value[ViewParam.SCHEDULE_ID] 

5637 schedule = ( 

5638 self.request.dbsession.query(TaskSchedule) 

5639 .filter(TaskSchedule.id == schedule_id) 

5640 .one() 

5641 ) 

5642 

5643 if schedule.group.ip_use is None: 

5644 raise Invalid( 

5645 node, 

5646 _( 

5647 "The task you have selected prohibits use in certain " 

5648 "contexts. The group '{group_name}' has no intellectual " 

5649 "property settings. " 

5650 "You need to edit the group '{group_name}' to say which " 

5651 "contexts it operates in.".format( 

5652 group_name=schedule.group.name 

5653 ) 

5654 ), 

5655 ) 

5656 

5657 # TODO: On the client we say 'to use this task, you must seek 

5658 # permission from the copyright holder'. We could do the same but at 

5659 # the moment there isn't a way of telling the system that we have done 

5660 # so. 

5661 if ( 

5662 task_class.prohibits_commercial 

5663 and schedule.group.ip_use.commercial 

5664 ): 

5665 raise Invalid( 

5666 node, 

5667 _( 

5668 "The group '{group_name}' associated with schedule " 

5669 "'{schedule_name}' operates in a " 

5670 "commercial context but the task you have selected " 

5671 "prohibits commercial use." 

5672 ).format( 

5673 group_name=schedule.group.name, schedule_name=schedule.name 

5674 ), 

5675 ) 

5676 

5677 if task_class.prohibits_clinical and schedule.group.ip_use.clinical: 

5678 raise Invalid( 

5679 node, 

5680 _( 

5681 "The group '{group_name}' associated with schedule " 

5682 "'{schedule_name}' operates in a " 

5683 "clinical context but the task you have selected " 

5684 "prohibits clinical use." 

5685 ).format( 

5686 group_name=schedule.group.name, schedule_name=schedule.name 

5687 ), 

5688 ) 

5689 

5690 if ( 

5691 task_class.prohibits_educational 

5692 and schedule.group.ip_use.educational 

5693 ): 

5694 raise Invalid( 

5695 node, 

5696 _( 

5697 "The group '{group_name}' associated with schedule " 

5698 "'{schedule_name}' operates in an " 

5699 "educational context but the task you have selected " 

5700 "prohibits educational use." 

5701 ).format( 

5702 group_name=schedule.group.name, schedule_name=schedule.name 

5703 ), 

5704 ) 

5705 

5706 if task_class.prohibits_research and schedule.group.ip_use.research: 

5707 raise Invalid( 

5708 node, 

5709 _( 

5710 "The group '{group_name}' associated with schedule " 

5711 "'{schedule_name}' operates in a " 

5712 "research context but the task you have selected " 

5713 "prohibits research use." 

5714 ).format( 

5715 group_name=schedule.group.name, schedule_name=schedule.name 

5716 ), 

5717 ) 

5718 

5719 

5720class EditTaskScheduleItemForm(DynamicDescriptionsNonceForm): 

5721 def __init__(self, request: "CamcopsRequest", **kwargs: Any) -> None: 

5722 schema = TaskScheduleItemSchema().bind(request=request) 

5723 _ = request.gettext 

5724 super().__init__( 

5725 schema, 

5726 request=request, 

5727 buttons=[ 

5728 Button( 

5729 name=FormAction.SUBMIT, 

5730 title=_("Submit"), 

5731 css_class="btn-danger", 

5732 ), 

5733 Button(name=FormAction.CANCEL, title=_("Cancel")), 

5734 ], 

5735 **kwargs, 

5736 ) 

5737 

5738 

5739class DeleteTaskScheduleItemSchema(HardWorkConfirmationSchema): 

5740 """ 

5741 Schema to delete a task schedule item. 

5742 """ 

5743 

5744 # name must match ViewParam.SCHEDULE_ITEM_ID 

5745 schedule_item_id = HiddenIntegerNode() 

5746 danger = TranslatableValidateDangerousOperationNode() 

5747 

5748 

5749class DeleteTaskScheduleItemForm(DeleteCancelForm): 

5750 """ 

5751 Form to delete a task schedule item. 

5752 """ 

5753 

5754 def __init__(self, request: "CamcopsRequest", **kwargs: Any) -> None: 

5755 super().__init__( 

5756 schema_class=DeleteTaskScheduleItemSchema, 

5757 request=request, 

5758 **kwargs, 

5759 ) 

5760 

5761 

5762class ForciblyFinalizeChooseDeviceSchema(CSRFSchema): 

5763 """ 

5764 Schema to force-finalize records from a device. 

5765 """ 

5766 

5767 device_id = MandatoryDeviceIdSelector() # must match ViewParam.DEVICE_ID 

5768 danger = TranslatableValidateDangerousOperationNode() 

5769 

5770 

5771class ForciblyFinalizeChooseDeviceForm(SimpleSubmitForm): 

5772 """ 

5773 Form to force-finalize records from a device. 

5774 """ 

5775 

5776 def __init__(self, request: "CamcopsRequest", **kwargs: Any) -> None: 

5777 _ = request.gettext 

5778 super().__init__( 

5779 schema_class=ForciblyFinalizeChooseDeviceSchema, 

5780 submit_title=_("View affected tasks"), 

5781 request=request, 

5782 **kwargs, 

5783 ) 

5784 

5785 

5786class ForciblyFinalizeConfirmSchema(HardWorkConfirmationSchema): 

5787 """ 

5788 Schema to confirm force-finalizing of a device. 

5789 """ 

5790 

5791 device_id = HiddenIntegerNode() # must match ViewParam.DEVICE_ID 

5792 danger = TranslatableValidateDangerousOperationNode() 

5793 

5794 

5795class ForciblyFinalizeConfirmForm(DangerousForm): 

5796 """ 

5797 Form to confirm force-finalizing of a device. 

5798 """ 

5799 

5800 def __init__(self, request: "CamcopsRequest", **kwargs: Any) -> None: 

5801 _ = request.gettext 

5802 super().__init__( 

5803 schema_class=ForciblyFinalizeConfirmSchema, 

5804 submit_action=FormAction.FINALIZE, 

5805 submit_title=_("Forcibly finalize"), 

5806 request=request, 

5807 **kwargs, 

5808 ) 

5809 

5810 

5811# ============================================================================= 

5812# User downloads 

5813# ============================================================================= 

5814 

5815 

5816class HiddenDownloadFilenameNode(HiddenStringNode, RequestAwareMixin): 

5817 """ 

5818 Note to encode a hidden filename. 

5819 """ 

5820 

5821 # noinspection PyMethodMayBeStatic 

5822 def validator(self, node: SchemaNode, value: str) -> None: 

5823 if value: 

5824 try: 

5825 validate_download_filename(value, self.request) 

5826 except ValueError as e: 

5827 raise Invalid(node, str(e)) 

5828 

5829 

5830class UserDownloadDeleteSchema(CSRFSchema): 

5831 """ 

5832 Schema to capture details of a file to be deleted. 

5833 """ 

5834 

5835 filename = ( 

5836 HiddenDownloadFilenameNode() 

5837 ) # name must match ViewParam.FILENAME 

5838 

5839 

5840class UserDownloadDeleteForm(SimpleSubmitForm): 

5841 """ 

5842 Form that provides a single button to delete a user download. 

5843 """ 

5844 

5845 def __init__(self, request: "CamcopsRequest", **kwargs: Any) -> None: 

5846 _ = request.gettext 

5847 super().__init__( 

5848 schema_class=UserDownloadDeleteSchema, 

5849 submit_title=_("Delete"), 

5850 request=request, 

5851 **kwargs, 

5852 ) 

5853 

5854 

5855class EmailBodyNode(MandatoryStringNode, RequestAwareMixin): 

5856 def __init__(self, *args: Any, **kwargs: Any) -> None: 

5857 self.title = "" # for type checker 

5858 super().__init__(*args, **kwargs) 

5859 

5860 # noinspection PyUnusedLocal 

5861 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None: 

5862 _ = self.gettext 

5863 

5864 self.title = _("Message") 

5865 

5866 # noinspection PyAttributeOutsideInit 

5867 self.widget = RichTextWidget(options=get_tinymce_options(self.request)) 

5868 

5869 

5870class SendEmailSchema(CSRFSchema): 

5871 email = MandatoryEmailNode() # name must match ViewParam.EMAIL 

5872 email_cc = HiddenStringNode() 

5873 email_bcc = HiddenStringNode() 

5874 email_from = HiddenStringNode() 

5875 email_subject = MandatoryStringNode() 

5876 email_body = EmailBodyNode() 

5877 

5878 

5879class SendEmailForm(InformativeNonceForm): 

5880 """ 

5881 Form for sending email 

5882 """ 

5883 

5884 def __init__(self, request: "CamcopsRequest", **kwargs: Any) -> None: 

5885 schema = SendEmailSchema().bind(request=request) 

5886 _ = request.gettext 

5887 super().__init__( 

5888 schema, 

5889 buttons=[ 

5890 Button(name=FormAction.SUBMIT, title=_("Send")), 

5891 Button(name=FormAction.CANCEL, title=_("Cancel")), 

5892 ], 

5893 **kwargs, 

5894 )