Hide keyboard shortcuts

Hot-keys on this page

r m x p   toggle line displays

j k   next/prev highlighted chunk

0   (zero) top of page

1   (one) first highlighted chunk

1#!/usr/bin/env python 

2 

3""" 

4camcops_server/cc_modules/cc_forms.py 

5 

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

7 

8 Copyright (C) 2012-2020 Rudolf Cardinal (rudolf@pobox.com). 

9 

10 This file is part of CamCOPS. 

11 

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

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

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

15 (at your option) any later version. 

16 

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

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

19 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 

20 GNU General Public License for more details. 

21 

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

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

24 

25=============================================================================== 

26 

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

28 

29*COLANDER NODES, NULLS, AND VALIDATION* 

30 

31- Surprisingly tricky. 

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

33 the Deform framework clones them. 

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

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

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

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

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

39 

40- Note that this error: 

41 

42 .. code-block:: none 

43 

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

45 

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

47 

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

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

50 

51*ACCESSING THE PYRAMID REQUEST IN FORMS AND SCHEMAS* 

52 

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

54sometimes more specialized reasons. 

55 

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

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

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

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

60 

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

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

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

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

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

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

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

68functions, as ``self.bindings``. 

69 

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

71sort of ``__init__`` function: 

72 

73.. code-block:: python 

74 

75 class SomeForm(...): 

76 def __init__(...): 

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

78 super().__init__( 

79 schema, 

80 ..., 

81 **kwargs 

82 ) 

83 

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

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

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

87of binding a request). 

88 

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

90request.gettext``. 

91 

92Form titles need to be dynamically written via 

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

94 

95""" 

96 

97import json 

98import logging 

99import os 

100from typing import (Any, Callable, Dict, List, Optional, 

101 Tuple, Type, TYPE_CHECKING, Union) 

102 

103from cardinal_pythonlib.colander_utils import ( 

104 AllowNoneType, 

105 BooleanNode, 

106 DateSelectorNode, 

107 DateTimeSelectorNode, 

108 DEFAULT_WIDGET_DATE_OPTIONS_FOR_PENDULUM, 

109 DEFAULT_WIDGET_TIME_OPTIONS_FOR_PENDULUM, 

110 get_child_node, 

111 get_values_and_permissible, 

112 HiddenIntegerNode, 

113 HiddenStringNode, 

114 MandatoryStringNode, 

115 OptionalEmailNode, 

116 OptionalIntNode, 

117 OptionalPendulumNode, 

118 OptionalStringNode, 

119 ValidateDangerousOperationNode, 

120) 

121from cardinal_pythonlib.deform_utils import ( 

122 DynamicDescriptionsForm, 

123 InformativeForm, 

124) 

125from cardinal_pythonlib.logs import ( 

126 BraceStyleAdapter, 

127) 

128from cardinal_pythonlib.sqlalchemy.dialect import SqlaDialectName 

129from cardinal_pythonlib.sqlalchemy.orm_query import CountStarSpecializedQuery 

130# noinspection PyProtectedMember 

131from colander import ( 

132 Boolean, 

133 Date, 

134 drop, 

135 Integer, 

136 Invalid, 

137 Length, 

138 MappingSchema, 

139 null, 

140 OneOf, 

141 Range, 

142 Schema, 

143 SchemaNode, 

144 SchemaType, 

145 SequenceSchema, 

146 Set, 

147 String, 

148 _null, 

149) 

150from deform.form import Button 

151from deform.widget import ( 

152 CheckboxChoiceWidget, 

153 CheckedPasswordWidget, 

154 # DateInputWidget, 

155 DateTimeInputWidget, 

156 FormWidget, 

157 HiddenWidget, 

158 MappingWidget, 

159 PasswordWidget, 

160 RadioChoiceWidget, 

161 SelectWidget, 

162 SequenceWidget, 

163 TextAreaWidget, 

164 TextInputWidget, 

165 Widget, 

166) 

167 

168from pendulum import Duration 

169 

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

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

172from camcops_server.cc_modules.cc_baseconstants import ( 

173 DEFORM_SUPPORTS_CSP_NONCE, 

174 TEMPLATE_DIR, 

175) 

176from camcops_server.cc_modules.cc_constants import ( 

177 ConfigParamSite, 

178 DEFAULT_ROWS_PER_PAGE, 

179 SEX_OTHER_UNSPECIFIED, 

180 SEX_FEMALE, 

181 SEX_MALE, 

182 StringLengths, 

183 USER_NAME_FOR_SYSTEM, 

184) 

185from camcops_server.cc_modules.cc_group import Group 

186from camcops_server.cc_modules.cc_idnumdef import ( 

187 IdNumDefinition, 

188 ID_NUM_VALIDATION_METHOD_CHOICES, 

189 validate_id_number, 

190) 

191from camcops_server.cc_modules.cc_ipuse import IpUse 

192from camcops_server.cc_modules.cc_language import ( 

193 DEFAULT_LOCALE, 

194 POSSIBLE_LOCALES, 

195 POSSIBLE_LOCALES_WITH_DESCRIPTIONS, 

196) 

197from camcops_server.cc_modules.cc_patient import Patient 

198from camcops_server.cc_modules.cc_patientidnum import PatientIdNum 

199from camcops_server.cc_modules.cc_policy import ( 

200 TABLET_ID_POLICY_STR, 

201 TokenizedPolicy, 

202) 

203from camcops_server.cc_modules.cc_pyramid import ( 

204 FormAction, 

205 RequestMethod, 

206 ViewArg, 

207 ViewParam, 

208) 

209from camcops_server.cc_modules.cc_task import tablename_to_task_class_dict 

210from camcops_server.cc_modules.cc_taskschedule import ( 

211 TaskSchedule, 

212 TaskScheduleEmailTemplateFormatter, 

213) 

214from camcops_server.cc_modules.cc_validators import ( 

215 ALPHANUM_UNDERSCORE_CHAR, 

216 validate_anything, 

217 validate_by_char_and_length, 

218 validate_group_name, 

219 validate_hl7_aa, 

220 validate_hl7_id_type, 

221 validate_ip_address, 

222 validate_new_password, 

223 validate_redirect_url, 

224 validate_username, 

225) 

226 

227if TYPE_CHECKING: 

228 from deform.field import Field 

229 from camcops_server.cc_modules.cc_request import CamcopsRequest 

230 from camcops_server.cc_modules.cc_task import Task 

231 from camcops_server.cc_modules.cc_user import User 

232 

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

234 

235ColanderNullType = _null 

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

237 

238 

239# ============================================================================= 

240# Debugging options 

241# ============================================================================= 

242 

243DEBUG_CSRF_CHECK = False 

244 

245if DEBUG_CSRF_CHECK: 

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

247 

248 

249# ============================================================================= 

250# Constants 

251# ============================================================================= 

252 

253class Binding(object): 

254 """ 

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

256 

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

258 """ 

259 GROUP = "group" 

260 OPEN_ADMIN = "open_admin" 

261 OPEN_WHAT = "open_what" 

262 OPEN_WHEN = "open_when" 

263 OPEN_WHO = "open_who" 

264 REQUEST = "request" 

265 TRACKER_TASKS_ONLY = "tracker_tasks_only" 

266 USER = "user" 

267 

268 

269class BootstrapCssClasses(object): 

270 """ 

271 Constants from Bootstrap to control display. 

272 """ 

273 FORM_INLINE = "form-inline" 

274 RADIO_INLINE = "radio-inline" 

275 LIST_INLINE = "list-inline" 

276 CHECKBOX_INLINE = "checkbox-inline" 

277 

278 

279AUTOCOMPLETE_ATTR = "autocomplete" 

280 

281 

282class AutocompleteAttrValues(object): 

283 """ 

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

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

286 Not all are used. 

287 """ 

288 BDAY = "bday" 

289 CURRENT_PASSWORD = "current-password" 

290 EMAIL = "email" 

291 FAMILY_NAME = "family-name" 

292 GIVEN_NAME = "given-name" 

293 NEW_PASSWORD = "new-password" 

294 OFF = "off" 

295 ON = "on" # browser decides 

296 STREET_ADDRESS = "stree-address" 

297 USERNAME = "username" 

298 

299 

300# ============================================================================= 

301# Common phrases for translation 

302# ============================================================================= 

303 

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

305 _ = request.gettext 

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

307 

308 

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

310 _ = request.gettext 

311 return _("Change password") 

312 

313 

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

315 _ = request.gettext 

316 return [ 

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

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

319 # TRANSLATOR: sex code description 

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

321 ] 

322 

323 

324# ============================================================================= 

325# Deform bug fix: SelectWidget "multiple" attribute 

326# ============================================================================= 

327 

328class BugfixSelectWidget(SelectWidget): 

329 """ 

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

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

332 

333 .. code-block:: none 

334 

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

336 ^^^^^^^^^^^^^^^^ 

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

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

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

340 </select> 

341 

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

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

344 Chameleon that exposed a bug in Deform.) 

345 

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

347 """ # noqa 

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

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

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

351 

352 

353SelectWidget = BugfixSelectWidget 

354 

355 

356# ============================================================================= 

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

358# ============================================================================= 

359 

360class InformativeNonceForm(InformativeForm): 

361 """ 

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

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

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

365 

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

367 """ 

368 if DEFORM_SUPPORTS_CSP_NONCE: 

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

370 request = schema.request # type: CamcopsRequest 

371 kwargs["nonce"] = request.nonce 

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

373 

374 

375class DynamicDescriptionsNonceForm(DynamicDescriptionsForm): 

376 """ 

377 Similarly; see :class:`InformativeNonceForm`. 

378 

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

380 """ 

381 if DEFORM_SUPPORTS_CSP_NONCE: 

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

383 request = schema.request # type: CamcopsRequest 

384 kwargs["nonce"] = request.nonce 

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

386 

387 

388# ============================================================================= 

389# Mixin for Schema/SchemaNode objects for translation 

390# ============================================================================= 

391 

392GETTEXT_TYPE = Callable[[str], str] 

393 

394 

395class RequestAwareMixin(object): 

396 """ 

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

398 together with some translations and other convenience functions. 

399 """ 

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

401 # Stop multiple inheritance complaints 

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

403 

404 # noinspection PyUnresolvedReferences 

405 @property 

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

407 return self.bindings[Binding.REQUEST] 

408 

409 # noinspection PyUnresolvedReferences,PyPropertyDefinition 

410 @property 

411 def gettext(self) -> GETTEXT_TYPE: 

412 return self.request.gettext 

413 

414 @property 

415 def or_join_description(self) -> str: 

416 return or_join_description(self.request) 

417 

418 

419# ============================================================================= 

420# Translatable version of ValidateDangerousOperationNode 

421# ============================================================================= 

422 

423class TranslatableValidateDangerousOperationNode( 

424 ValidateDangerousOperationNode, RequestAwareMixin): 

425 """ 

426 Translatable version of ValidateDangerousOperationNode. 

427 """ 

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

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

430 _ = self.gettext 

431 node.title = _("Danger") 

432 user_entry = get_child_node(self, "user_entry") 

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

434 

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

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

437 _ = self.gettext 

438 user_entry = get_child_node(self, "user_entry") 

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

440 user_entry.description = prefix + target_value 

441 

442 

443# ============================================================================= 

444# Translatable version of SequenceWidget 

445# ============================================================================= 

446 

447class TranslatableSequenceWidget(SequenceWidget): 

448 """ 

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

450 request-specific way. 

451 """ 

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

453 super().__init__(**kwargs) 

454 _ = request.gettext 

455 self.add_subitem_text_template = _('Add') + ' ${subitem_title}' 

456 

457 

458# ============================================================================= 

459# Translatable version of OptionalPendulumNode 

460# ============================================================================= 

461 

462class TranslatableOptionalPendulumNode(OptionalPendulumNode, 

463 RequestAwareMixin): 

464 """ 

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

466 the request. 

467 

468 .. todo:: TranslatableOptionalPendulumNode not fully implemented 

469 """ 

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

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

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

473 

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

475 _ = self.gettext 

476 self.widget = DateTimeInputWidget( 

477 date_options=DEFAULT_WIDGET_DATE_OPTIONS_FOR_PENDULUM, 

478 time_options=DEFAULT_WIDGET_TIME_OPTIONS_FOR_PENDULUM 

479 ) 

480 # log.critical("TranslatableOptionalPendulumNode.widget: {!r}", 

481 # self.widget.__dict__) 

482 

483 

484class TranslatableDateTimeSelectorNode(DateTimeSelectorNode, 

485 RequestAwareMixin): 

486 """ 

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

488 the request. 

489 

490 .. todo:: TranslatableDateTimeSelectorNode not fully implemented 

491 """ 

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

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

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

495 

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

497 _ = self.gettext 

498 self.widget = DateTimeInputWidget() 

499 # log.critical("TranslatableDateTimeSelectorNode.widget: {!r}", 

500 # self.widget.__dict__) 

501 

502 

503''' 

504class TranslatableDateSelectorNode(DateSelectorNode, 

505 RequestAwareMixin): 

506 """ 

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

508 the request. 

509 

510 .. todo:: TranslatableDateSelectorNode not fully implemented 

511 """ 

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

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

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

515 

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

517 _ = self.gettext 

518 self.widget = DateInputWidget() 

519 # log.critical("TranslatableDateSelectorNode.widget: {!r}", 

520 # self.widget.__dict__) 

521''' 

522 

523 

524# ============================================================================= 

525# CSRF 

526# ============================================================================= 

527 

528class CSRFToken(SchemaNode, RequestAwareMixin): 

529 """ 

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

531 form. 

532 

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

534 more recent Colander API. 

535 

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

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

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

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

540 mechanism 

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

542 

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

544 

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

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

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

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

549 

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

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

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

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

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

555 value." 

556 

557 RNC: Serialized values are always STRINGS. 

558 

559 """ # noqa 

560 schema_type = String 

561 default = "" 

562 missing = "" 

563 title = " " 

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

565 # fails 

566 widget = HiddenWidget() 

567 

568 # noinspection PyUnusedLocal 

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

570 request = self.request 

571 csrf_token = request.session.get_csrf_token() 

572 if DEBUG_CSRF_CHECK: 

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

574 self.default = csrf_token 

575 

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

577 # Deferred validator via method, as per 

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

579 request = self.request 

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

581 matches = value == csrf_token 

582 if DEBUG_CSRF_CHECK: 

583 log.debug("Validating CSRF token: form says {!r}, session says " 

584 "{!r}, matches = {}", value, csrf_token, matches) 

585 if not matches: 

586 log.warning("CSRF token mismatch; remote address {}", 

587 request.remote_addr) 

588 _ = request.gettext 

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

590 

591 

592class CSRFSchema(Schema, RequestAwareMixin): 

593 """ 

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

595 forgery) tokens. 

596 

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

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

599 calling ``__init__()```... 

600 

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

602 or you get: 

603 

604 .. code-block:: none 

605 

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

607 __RequestVerificationToken, csrfmiddlewaretoken, authenticity_token, 

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

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

610 "deformField1" "deformField2" "deformField3" "deformField4" ]. 

611 

612 """ 

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

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

615 

616 

617# ============================================================================= 

618# Horizontal forms 

619# ============================================================================= 

620 

621class HorizontalFormWidget(FormWidget): 

622 """ 

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

624 

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

626 are resolved to Chameleon ZPT (Zope) templates. 

627 

628 See 

629 

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

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

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

633 """ # noqa 

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

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

636 form = "horizontal_form.pt" 

637 mapping_item = "horizontal_mapping_item.pt" 

638 

639 template = os.path.join(basedir, form) # default "form" = deform/templates/form.pt # noqa 

640 readonly_template = os.path.join(readonlydir, form) # default "readonly/form" # noqa 

641 item_template = os.path.join(basedir, mapping_item) # default "mapping_item" # noqa 

642 readonly_item_template = os.path.join(readonlydir, mapping_item) # default "readonly/mapping_item" # noqa 

643 

644 

645class HorizontalFormMixin(object): 

646 """ 

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

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

649 """ 

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

651 kwargs = kwargs or {} 

652 

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

654 # extra_classes = "form-inline" 

655 # if "css_class" in kwargs: 

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

657 # else: 

658 # kwargs["css_class"] = extra_classes 

659 

660 # Method 2: change the widget 

661 schema.widget = HorizontalFormWidget() 

662 

663 # OK, proceed. 

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

665 

666 

667def add_css_class(kwargs: Dict[str, Any], 

668 extra_classes: str, 

669 param_name: str = "css_class") -> None: 

670 """ 

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

672 parameter. 

673 

674 Args: 

675 kwargs: a dictionary 

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

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

678 """ 

679 if param_name in kwargs: 

680 kwargs[param_name] += " " + extra_classes 

681 else: 

682 kwargs[param_name] = extra_classes 

683 

684 

685class FormInlineCssMixin(object): 

686 """ 

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

688 has the effect of wrapping everything horizontally. 

689 

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

691 inheritance order. 

692 """ 

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

694 kwargs = kwargs or {} 

695 add_css_class(kwargs, BootstrapCssClasses.FORM_INLINE) 

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

697 

698 

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

700 """ 

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

702 """ 

703 widget.item_css_class = BootstrapCssClasses.FORM_INLINE 

704 

705 

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

707 """ 

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

709 

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

711 constructor. 

712 """ 

713 make_widget_horizontal(node.widget) 

714 

715 

716# ============================================================================= 

717# Specialized Form classes 

718# ============================================================================= 

719 

720class SimpleSubmitForm(InformativeNonceForm): 

721 """ 

722 Form with a simple "submit" button. 

723 """ 

724 def __init__(self, 

725 schema_class: Type[Schema], 

726 submit_title: str, 

727 request: "CamcopsRequest", 

728 **kwargs) -> None: 

729 """ 

730 Args: 

731 schema_class: 

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

733 schema 

734 submit_title: 

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

736 request: 

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

738 """ 

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

740 super().__init__( 

741 schema, 

742 buttons=[Button(name=FormAction.SUBMIT, 

743 title=submit_title)], 

744 **kwargs 

745 ) 

746 

747 

748class ApplyCancelForm(InformativeNonceForm): 

749 """ 

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

751 """ 

752 def __init__(self, 

753 schema_class: Type[Schema], 

754 request: "CamcopsRequest", 

755 **kwargs) -> None: 

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

757 _ = request.gettext 

758 super().__init__( 

759 schema, 

760 buttons=[ 

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

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

763 ], 

764 **kwargs 

765 ) 

766 

767 

768class AddCancelForm(InformativeNonceForm): 

769 """ 

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

771 """ 

772 def __init__(self, 

773 schema_class: Type[Schema], 

774 request: "CamcopsRequest", 

775 **kwargs) -> None: 

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

777 _ = request.gettext 

778 super().__init__( 

779 schema, 

780 buttons=[ 

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

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

783 ], 

784 **kwargs 

785 ) 

786 

787 

788class DangerousForm(DynamicDescriptionsNonceForm): 

789 """ 

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

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

792 "Cancel" button. 

793 """ 

794 def __init__(self, 

795 schema_class: Type[Schema], 

796 submit_action: str, 

797 submit_title: str, 

798 request: "CamcopsRequest", 

799 **kwargs) -> None: 

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

801 _ = request.gettext 

802 super().__init__( 

803 schema, 

804 buttons=[ 

805 Button(name=submit_action, title=submit_title, 

806 css_class="btn-danger"), 

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

808 ], 

809 **kwargs 

810 ) 

811 

812 

813class DeleteCancelForm(DangerousForm): 

814 """ 

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

816 button. 

817 """ 

818 def __init__(self, 

819 schema_class: Type[Schema], 

820 request: "CamcopsRequest", 

821 **kwargs) -> None: 

822 _ = request.gettext 

823 super().__init__( 

824 schema_class=schema_class, 

825 submit_action=FormAction.DELETE, 

826 submit_title=_("Delete"), 

827 request=request, 

828 **kwargs 

829 ) 

830 

831 

832# ============================================================================= 

833# Specialized SchemaNode classes used in several contexts 

834# ============================================================================= 

835 

836# ----------------------------------------------------------------------------- 

837# Task types 

838# ----------------------------------------------------------------------------- 

839 

840class OptionalSingleTaskSelector(OptionalStringNode, RequestAwareMixin): 

841 """ 

842 Node to pick one task type. 

843 """ 

844 def __init__(self, *args, tracker_tasks_only: bool = False, 

845 **kwargs) -> None: 

846 """ 

847 Args: 

848 tracker_tasks_only: restrict the choices to tasks that offer 

849 trackers. 

850 """ 

851 self.title = "" # for type checker 

852 self.tracker_tasks_only = tracker_tasks_only 

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

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

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

856 

857 # noinspection PyUnusedLocal 

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

859 _ = self.gettext 

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

861 if Binding.TRACKER_TASKS_ONLY in kw: 

862 self.tracker_tasks_only = kw[Binding.TRACKER_TASKS_ONLY] 

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

864 True, _("[Any]")) 

865 self.widget = SelectWidget(values=values) 

866 self.validator = OneOf(pv) 

867 

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

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

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

871 for tc in Task.all_subclasses_by_shortname(): 

872 if self.tracker_tasks_only and not tc.provides_trackers: 

873 continue 

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

875 return choices 

876 

877 

878class MandatorySingleTaskSelector(MandatoryStringNode, RequestAwareMixin): 

879 """ 

880 Node to pick one task type. 

881 """ 

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

883 self.title = "" # for type checker 

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

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

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

887 

888 # noinspection PyUnusedLocal 

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

890 _ = self.gettext 

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

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

893 self.widget = SelectWidget(values=values) 

894 self.validator = OneOf(pv) 

895 

896 @staticmethod 

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

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

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

900 for tc in Task.all_subclasses_by_shortname(): 

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

902 return choices 

903 

904 

905class MultiTaskSelector(SchemaNode, RequestAwareMixin): 

906 """ 

907 Node to select multiple task types. 

908 """ 

909 schema_type = Set 

910 default = "" 

911 missing = "" 

912 

913 def __init__(self, *args, tracker_tasks_only: bool = False, 

914 minimum_number: int = 0, **kwargs) -> None: 

915 self.tracker_tasks_only = tracker_tasks_only 

916 self.minimum_number = minimum_number 

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

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

919 self.title = "" # for type checker 

920 self.description = "" # for type checker 

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

922 

923 # noinspection PyUnusedLocal 

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

925 _ = self.gettext 

926 request = self.request # noqa: F841 

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

928 self.description = ( 

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

930 " " + self.or_join_description 

931 ) 

932 if Binding.TRACKER_TASKS_ONLY in kw: 

933 self.tracker_tasks_only = kw[Binding.TRACKER_TASKS_ONLY] 

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

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

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

937 

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

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

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

941 for tc in Task.all_subclasses_by_shortname(): 

942 if self.tracker_tasks_only and not tc.provides_trackers: 

943 continue 

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

945 return choices 

946 

947 

948# ----------------------------------------------------------------------------- 

949# Use the task index? 

950# ----------------------------------------------------------------------------- 

951 

952class ViaIndexSelector(BooleanNode, RequestAwareMixin): 

953 """ 

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

955 Default is true. 

956 """ 

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

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

959 

960 # noinspection PyUnusedLocal 

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

962 _ = self.gettext 

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

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

965 

966 

967# ----------------------------------------------------------------------------- 

968# ID numbers 

969# ----------------------------------------------------------------------------- 

970 

971class MandatoryWhichIdNumSelector(SchemaNode, RequestAwareMixin): 

972 """ 

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

974 or "study Blah ID number"). 

975 """ 

976 widget = SelectWidget() 

977 

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

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

980 # ... allows parameter-free (!) inheritance by OptionalWhichIdNumSelector # noqa 

981 self.allow_none = False 

982 self.title = "" # for type checker 

983 self.description = "" # for type checker 

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

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

986 

987 # noinspection PyUnusedLocal 

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

989 request = self.request 

990 _ = request.gettext 

991 self.title = _("Identifier") 

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

993 for iddef in request.idnum_definitions: 

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

995 values, pv = get_values_and_permissible(values, self.allow_none, 

996 _("[ignore]")) 

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

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

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

1000 # SelectWidget. 

1001 self.widget.values = values 

1002 self.validator = OneOf(pv) 

1003 

1004 @staticmethod 

1005 def schema_type() -> SchemaType: 

1006 return Integer() 

1007 

1008 

1009class LinkingIdNumSelector(MandatoryWhichIdNumSelector): 

1010 """ 

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

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

1013 """ 

1014 

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

1016 super().after_bind(node, kw) 

1017 _ = self.gettext 

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

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

1020 

1021 

1022class MandatoryIdNumValue(SchemaNode, RequestAwareMixin): 

1023 """ 

1024 Mandatory node to capture an ID number value. 

1025 """ 

1026 schema_type = Integer 

1027 validator = Range(min=0) 

1028 

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

1030 self.title = "" # for type checker 

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

1032 

1033 # noinspection PyUnusedLocal 

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

1035 _ = self.gettext 

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

1037 

1038 

1039class MandatoryIdNumNode(MappingSchema, RequestAwareMixin): 

1040 """ 

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

1042 ID number (value). 

1043 

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

1045 """ 

1046 which_idnum = MandatoryWhichIdNumSelector() # must match ViewParam.WHICH_IDNUM # noqa 

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

1048 

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

1050 self.title = "" # for type checker 

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

1052 

1053 # noinspection PyUnusedLocal 

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

1055 _ = self.gettext 

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

1057 

1058 # noinspection PyMethodMayBeStatic 

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

1060 assert isinstance(value, dict) 

1061 req = self.request 

1062 _ = req.gettext 

1063 which_idnum = value[ViewParam.WHICH_IDNUM] 

1064 idnum_value = value[ViewParam.IDNUM_VALUE] 

1065 idnum_def = req.get_idnum_definition(which_idnum) 

1066 if not idnum_def: 

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

1068 method = idnum_def.validation_method 

1069 if method: 

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

1071 if not valid: 

1072 raise Invalid(node, why_invalid) 

1073 

1074 

1075class IdNumSequenceAnyCombination(SequenceSchema, RequestAwareMixin): 

1076 """ 

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

1078 """ 

1079 idnum_sequence = MandatoryIdNumNode() 

1080 

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

1082 self.title = "" # for type checker 

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

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

1085 

1086 # noinspection PyUnusedLocal 

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

1088 _ = self.gettext 

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

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

1091 

1092 # noinspection PyMethodMayBeStatic 

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

1094 assert isinstance(value, list) 

1095 list_of_lists = [(x[ViewParam.WHICH_IDNUM], x[ViewParam.IDNUM_VALUE]) 

1096 for x in value] 

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

1098 _ = self.gettext 

1099 raise Invalid( 

1100 node, 

1101 _("You have specified duplicate ID definitions")) 

1102 

1103 

1104class IdNumSequenceUniquePerWhichIdnum(SequenceSchema, RequestAwareMixin): 

1105 """ 

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

1107 up to one per ID number type. 

1108 """ 

1109 idnum_sequence = MandatoryIdNumNode() 

1110 

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

1112 self.title = "" # for type checker 

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

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

1115 

1116 # noinspection PyUnusedLocal 

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

1118 _ = self.gettext 

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

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

1121 

1122 # noinspection PyMethodMayBeStatic 

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

1124 assert isinstance(value, list) 

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

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

1127 _ = self.gettext 

1128 raise Invalid( 

1129 node, 

1130 _("You have specified >1 value for one ID number type") 

1131 ) 

1132 

1133 

1134# ----------------------------------------------------------------------------- 

1135# Sex 

1136# ----------------------------------------------------------------------------- 

1137 

1138class OptionalSexSelector(OptionalStringNode, RequestAwareMixin): 

1139 """ 

1140 Optional node to choose sex. 

1141 """ 

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

1143 self.title = "" # for type checker 

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

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

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

1147 

1148 # noinspection PyUnusedLocal 

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

1150 _ = self.gettext 

1151 self.title = _("Sex") 

1152 choices = sex_choices(self.request) 

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

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

1155 self.validator = OneOf(pv) 

1156 

1157 

1158class MandatorySexSelector(MandatoryStringNode, RequestAwareMixin): 

1159 """ 

1160 Mandatory node to choose sex. 

1161 """ 

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

1163 self.title = "" # for type checker 

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

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

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

1167 

1168 # noinspection PyUnusedLocal 

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

1170 _ = self.gettext 

1171 self.title = _("Sex") 

1172 choices = sex_choices(self.request) 

1173 values, pv = get_values_and_permissible(choices) 

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

1175 self.validator = OneOf(pv) 

1176 

1177 

1178# ----------------------------------------------------------------------------- 

1179# Users 

1180# ----------------------------------------------------------------------------- 

1181 

1182class MandatoryUserIdSelectorUsersAllowedToSee(SchemaNode, RequestAwareMixin): 

1183 """ 

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

1185 is allowed to see. 

1186 """ 

1187 schema_type = Integer 

1188 

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

1190 self.title = "" # for type checker 

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

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

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

1194 

1195 # noinspection PyUnusedLocal 

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

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

1198 _ = self.gettext 

1199 self.title = _("User") 

1200 request = self.request 

1201 dbsession = request.dbsession 

1202 user = request.user 

1203 if user.superuser: 

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

1205 else: 

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

1207 my_allowed_group_ids = user.ids_of_groups_user_may_see 

1208 users = dbsession.query(User)\ 

1209 .join(Group)\ 

1210 .filter(Group.id.in_(my_allowed_group_ids))\ 

1211 .order_by(User.username) 

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

1213 for user in users: 

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

1215 values, pv = get_values_and_permissible(values, False) 

1216 self.widget = SelectWidget(values=values) 

1217 self.validator = OneOf(pv) 

1218 

1219 

1220class OptionalUserNameSelector(OptionalStringNode, RequestAwareMixin): 

1221 """ 

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

1223 """ 

1224 title = "User" 

1225 

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

1227 self.title = "" # for type checker 

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

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

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

1231 

1232 # noinspection PyUnusedLocal 

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

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

1235 _ = self.gettext 

1236 self.title = _("User") 

1237 request = self.request 

1238 dbsession = request.dbsession 

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

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

1241 for user in users: 

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

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

1244 self.widget = SelectWidget(values=values) 

1245 self.validator = OneOf(pv) 

1246 

1247 

1248class UsernameNode(SchemaNode, RequestAwareMixin): 

1249 """ 

1250 Node to enter a username. 

1251 """ 

1252 schema_type = String 

1253 widget = TextInputWidget(attributes={ 

1254 AUTOCOMPLETE_ATTR: AutocompleteAttrValues.OFF 

1255 }) 

1256 

1257 def __init__(self, 

1258 *args, 

1259 autocomplete: str = AutocompleteAttrValues.OFF, 

1260 **kwargs) -> None: 

1261 self.title = "" # for type checker 

1262 self.autocomplete = autocomplete 

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

1264 

1265 # noinspection PyUnusedLocal 

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

1267 _ = self.gettext 

1268 self.title = _("Username") 

1269 # noinspection PyUnresolvedReferences 

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

1271 

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

1273 if value == USER_NAME_FOR_SYSTEM: 

1274 _ = self.gettext 

1275 raise Invalid( 

1276 node, 

1277 _("Cannot use system username") + " " + 

1278 repr(USER_NAME_FOR_SYSTEM) 

1279 ) 

1280 try: 

1281 validate_username(value, self.request) 

1282 except ValueError as e: 

1283 raise Invalid(node, str(e)) 

1284 

1285 

1286class UserFilterSchema(Schema, RequestAwareMixin): 

1287 """ 

1288 Schema to filter the list of users 

1289 """ 

1290 # must match ViewParam.INCLUDE_AUTO_GENERATED 

1291 include_auto_generated = BooleanNode() 

1292 

1293 # noinspection PyUnusedLocal 

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

1295 _ = self.gettext 

1296 include_auto_generated = get_child_node(self, "include_auto_generated") 

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

1298 include_auto_generated.label = None 

1299 

1300 

1301class UserFilterForm(InformativeNonceForm): 

1302 """ 

1303 Form to filter the list of users 

1304 """ 

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

1306 _ = request.gettext 

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

1308 super().__init__( 

1309 schema, 

1310 buttons=[Button(name=FormAction.SET_FILTERS, 

1311 title=_("Refresh"))], 

1312 css_class=BootstrapCssClasses.FORM_INLINE, 

1313 method=RequestMethod.GET, 

1314 **kwargs 

1315 ) 

1316 

1317 

1318# ----------------------------------------------------------------------------- 

1319# Devices 

1320# ----------------------------------------------------------------------------- 

1321 

1322class MandatoryDeviceIdSelector(SchemaNode, RequestAwareMixin): 

1323 """ 

1324 Mandatory node to select a client device ID. 

1325 """ 

1326 schema_type = Integer 

1327 

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

1329 self.title = "" # for type checker 

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

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

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

1333 

1334 # noinspection PyUnusedLocal 

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

1336 from camcops_server.cc_modules.cc_device import Device # delayed import # noqa 

1337 _ = self.gettext 

1338 self.title = _("Device") 

1339 request = self.request 

1340 dbsession = request.dbsession 

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

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

1343 for device in devices: 

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

1345 values, pv = get_values_and_permissible(values, False) 

1346 self.widget = SelectWidget(values=values) 

1347 self.validator = OneOf(pv) 

1348 

1349 

1350# ----------------------------------------------------------------------------- 

1351# Server PK 

1352# ----------------------------------------------------------------------------- 

1353 

1354class ServerPkSelector(OptionalIntNode, RequestAwareMixin): 

1355 """ 

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

1357 """ 

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

1359 self.title = "" # for type checker 

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

1361 

1362 # noinspection PyUnusedLocal 

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

1364 _ = self.gettext 

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

1366 

1367 

1368# ----------------------------------------------------------------------------- 

1369# Dates/times 

1370# ----------------------------------------------------------------------------- 

1371 

1372class StartPendulumSelector(TranslatableOptionalPendulumNode, 

1373 RequestAwareMixin): 

1374 """ 

1375 Optional node to select a start date/time. 

1376 """ 

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

1378 self.title = "" # for type checker 

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

1380 

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

1382 super().after_bind(node, kw) 

1383 _ = self.gettext 

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

1385 

1386 

1387class EndPendulumSelector(TranslatableOptionalPendulumNode, 

1388 RequestAwareMixin): 

1389 """ 

1390 Optional node to select an end date/time. 

1391 """ 

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

1393 self.title = "" # for type checker 

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

1395 

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

1397 super().after_bind(node, kw) 

1398 _ = self.gettext 

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

1400 

1401 

1402class StartDateTimeSelector(TranslatableDateTimeSelectorNode, 

1403 RequestAwareMixin): 

1404 """ 

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

1406 """ 

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

1408 self.title = "" # for type checker 

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

1410 

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

1412 super().after_bind(node, kw) 

1413 _ = self.gettext 

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

1415 

1416 

1417class EndDateTimeSelector(TranslatableDateTimeSelectorNode, 

1418 RequestAwareMixin): 

1419 """ 

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

1421 """ 

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

1423 self.title = "" # for type checker 

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

1425 

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

1427 super().after_bind(node, kw) 

1428 _ = self.gettext 

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

1430 

1431 

1432''' 

1433class StartDateSelector(TranslatableDateSelectorNode, 

1434 RequestAwareMixin): 

1435 """ 

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

1437 """ 

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

1439 self.title = "" # for type checker 

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

1441 

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

1443 super().after_bind(node, kw) 

1444 _ = self.gettext 

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

1446 

1447 

1448class EndDateSelector(TranslatableDateSelectorNode, 

1449 RequestAwareMixin): 

1450 """ 

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

1452 """ 

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

1454 self.title = "" # for type checker 

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

1456 

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

1458 super().after_bind(node, kw) 

1459 _ = self.gettext 

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

1461''' 

1462 

1463 

1464# ----------------------------------------------------------------------------- 

1465# Rows per page 

1466# ----------------------------------------------------------------------------- 

1467 

1468class RowsPerPageSelector(SchemaNode, RequestAwareMixin): 

1469 """ 

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

1471 """ 

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

1473 

1474 schema_type = Integer 

1475 default = DEFAULT_ROWS_PER_PAGE 

1476 widget = RadioChoiceWidget(values=_choices) 

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

1478 

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

1480 self.title = "" # for type checker 

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

1482 

1483 # noinspection PyUnusedLocal 

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

1485 _ = self.gettext 

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

1487 

1488 

1489# ----------------------------------------------------------------------------- 

1490# Groups 

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

1492 

1493class MandatoryGroupIdSelectorAllGroups(SchemaNode, RequestAwareMixin): 

1494 """ 

1495 Offers a picklist of groups from ALL POSSIBLE GROUPS. 

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

1497 """ 

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

1499 self.title = "" # for type checker 

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

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

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

1503 

1504 # noinspection PyUnusedLocal 

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

1506 _ = self.gettext 

1507 self.title = _("Group") 

1508 request = self.request 

1509 dbsession = request.dbsession 

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

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

1512 values, pv = get_values_and_permissible(values) 

1513 self.widget = SelectWidget(values=values) 

1514 self.validator = OneOf(pv) 

1515 

1516 @staticmethod 

1517 def schema_type() -> SchemaType: 

1518 return Integer() 

1519 

1520 

1521class MandatoryGroupIdSelectorAdministeredGroups(SchemaNode, RequestAwareMixin): 

1522 """ 

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

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

1525 """ 

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

1527 self.title = "" # for type checker 

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

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

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

1531 

1532 # noinspection PyUnusedLocal 

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

1534 _ = self.gettext 

1535 self.title = _("Group") 

1536 request = self.request 

1537 dbsession = request.dbsession 

1538 administered_group_ids = request.user.ids_of_groups_user_is_admin_for 

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

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

1541 if g.id in administered_group_ids] 

1542 values, pv = get_values_and_permissible(values) 

1543 self.widget = SelectWidget(values=values) 

1544 self.validator = OneOf(pv) 

1545 

1546 @staticmethod 

1547 def schema_type() -> SchemaType: 

1548 return Integer() 

1549 

1550 

1551class MandatoryGroupIdSelectorOtherGroups(SchemaNode, RequestAwareMixin): 

1552 """ 

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

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

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

1556 """ 

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

1558 self.title = "" # for type checker 

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

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

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

1562 

1563 # noinspection PyUnusedLocal 

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

1565 _ = self.gettext 

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

1567 request = self.request 

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

1569 dbsession = request.dbsession 

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

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

1572 values, pv = get_values_and_permissible(values) 

1573 self.widget = SelectWidget(values=values) 

1574 self.validator = OneOf(pv) 

1575 

1576 @staticmethod 

1577 def schema_type() -> SchemaType: 

1578 return Integer() 

1579 

1580 

1581class MandatoryGroupIdSelectorUserGroups(SchemaNode, RequestAwareMixin): 

1582 """ 

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

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

1585 """ 

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

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

1588 # ... allows parameter-free (!) inheritance by OptionalGroupIdSelectorUserGroups # noqa 

1589 self.allow_none = False 

1590 self.title = "" # for type checker 

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

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

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

1594 

1595 # noinspection PyUnusedLocal 

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

1597 _ = self.gettext 

1598 self.title = _("Group") 

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

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

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

1602 values, pv = get_values_and_permissible(values, self.allow_none, 

1603 _("[None]")) 

1604 self.widget = SelectWidget(values=values) 

1605 self.validator = OneOf(pv) 

1606 

1607 @staticmethod 

1608 def schema_type() -> SchemaType: 

1609 return Integer() 

1610 

1611 

1612class OptionalGroupIdSelectorUserGroups(MandatoryGroupIdSelectorUserGroups): 

1613 """ 

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

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

1616 """ 

1617 default = None 

1618 missing = None 

1619 

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

1621 self.allow_none = True 

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

1623 

1624 @staticmethod 

1625 def schema_type() -> SchemaType: 

1626 return AllowNoneType(Integer()) 

1627 

1628 

1629class MandatoryGroupIdSelectorAllowedGroups(SchemaNode, RequestAwareMixin): 

1630 """ 

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

1632 Used for task filters. 

1633 """ 

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

1635 self.title = "" # for type checker 

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

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

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

1639 

1640 # noinspection PyUnusedLocal 

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

1642 _ = self.gettext 

1643 self.title = _("Group") 

1644 request = self.request 

1645 dbsession = request.dbsession 

1646 user = request.user 

1647 if user.superuser: 

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

1649 else: 

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

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

1652 values, pv = get_values_and_permissible(values) 

1653 self.widget = SelectWidget(values=values) 

1654 self.validator = OneOf(pv) 

1655 

1656 @staticmethod 

1657 def schema_type() -> SchemaType: 

1658 return Integer() 

1659 

1660 

1661class GroupsSequenceBase(SequenceSchema, RequestAwareMixin): 

1662 """ 

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

1664 """ 

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

1666 self.title = "" # for type checker 

1667 self.minimum_number = minimum_number 

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

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

1670 

1671 # noinspection PyUnusedLocal 

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

1673 _ = self.gettext 

1674 self.title = _("Groups") 

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

1676 

1677 # noinspection PyMethodMayBeStatic 

1678 def validator(self, 

1679 node: SchemaNode, 

1680 value: List[int]) -> None: 

1681 assert isinstance(value, list) 

1682 _ = self.gettext 

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

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

1685 if len(value) < self.minimum_number: 

1686 raise Invalid( 

1687 node, 

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

1689 self.minimum_number 

1690 ) 

1691 ) 

1692 

1693 

1694class AllGroupsSequence(GroupsSequenceBase): 

1695 """ 

1696 Sequence to offer a choice of all possible groups. 

1697 

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

1699 """ 

1700 group_id_sequence = MandatoryGroupIdSelectorAllGroups() 

1701 

1702 

1703class AdministeredGroupsSequence(GroupsSequenceBase): 

1704 """ 

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

1706 

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

1708 to a user. 

1709 """ 

1710 group_id_sequence = MandatoryGroupIdSelectorAdministeredGroups() 

1711 

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

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

1714 

1715 

1716class AllOtherGroupsSequence(GroupsSequenceBase): 

1717 """ 

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

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

1720 

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

1722 """ 

1723 group_id_sequence = MandatoryGroupIdSelectorOtherGroups() 

1724 

1725 

1726class AllowedGroupsSequence(GroupsSequenceBase): 

1727 """ 

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

1729 """ 

1730 group_id_sequence = MandatoryGroupIdSelectorAllowedGroups() 

1731 

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

1733 self.description = "" # for type checker 

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

1735 

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

1737 super().after_bind(node, kw) 

1738 self.description = self.or_join_description 

1739 

1740 

1741# ----------------------------------------------------------------------------- 

1742# Languages (strictly, locales) 

1743# ----------------------------------------------------------------------------- 

1744 

1745class LanguageSelector(SchemaNode, RequestAwareMixin): 

1746 """ 

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

1748 """ 

1749 _choices = POSSIBLE_LOCALES_WITH_DESCRIPTIONS 

1750 schema_type = String 

1751 default = DEFAULT_LOCALE 

1752 missing = DEFAULT_LOCALE 

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

1754 validator = OneOf(POSSIBLE_LOCALES) 

1755 

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

1757 self.title = "" # for type checker 

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

1759 

1760 # noinspection PyUnusedLocal 

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

1762 _ = self.gettext 

1763 self.title = _("Group") 

1764 request = self.request # noqa: F841 

1765 self.title = _("Language") 

1766 

1767 

1768# ----------------------------------------------------------------------------- 

1769# Validating dangerous operations 

1770# ----------------------------------------------------------------------------- 

1771 

1772class HardWorkConfirmationSchema(CSRFSchema): 

1773 """ 

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

1775 answers before we will proceed. 

1776 """ 

1777 confirm_1_t = BooleanNode(default=False) 

1778 confirm_2_t = BooleanNode(default=True) 

1779 confirm_3_f = BooleanNode(default=True) 

1780 confirm_4_t = BooleanNode(default=False) 

1781 

1782 # noinspection PyUnusedLocal 

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

1784 _ = self.gettext 

1785 confirm_1_t = get_child_node(self, "confirm_1_t") 

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

1787 confirm_2_t = get_child_node(self, "confirm_2_t") 

1788 # TRANSLATOR: string context described here 

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

1790 confirm_3_f = get_child_node(self, "confirm_3_f") 

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

1792 confirm_4_t = get_child_node(self, "confirm_4_t") 

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

1794 

1795 # noinspection PyMethodMayBeStatic 

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

1797 if ((not value['confirm_1_t']) or 

1798 (not value['confirm_2_t']) or 

1799 value['confirm_3_f'] or 

1800 (not value['confirm_4_t'])): 

1801 _ = self.gettext 

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

1803 

1804 

1805# ----------------------------------------------------------------------------- 

1806# URLs 

1807# ----------------------------------------------------------------------------- 

1808 

1809class HiddenRedirectionUrlNode(HiddenStringNode, RequestAwareMixin): 

1810 """ 

1811 Note to encode a hidden URL, for redirection. 

1812 """ 

1813 # noinspection PyMethodMayBeStatic 

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

1815 if value: 

1816 try: 

1817 validate_redirect_url(value, self.request) 

1818 except ValueError: 

1819 _ = self.gettext 

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

1821 

1822 

1823# ============================================================================= 

1824# Login 

1825# ============================================================================= 

1826 

1827class LoginSchema(CSRFSchema): 

1828 """ 

1829 Schema to capture login details. 

1830 """ 

1831 username = UsernameNode( 

1832 autocomplete=AutocompleteAttrValues.USERNAME 

1833 ) # name must match ViewParam.USERNAME 

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

1835 String(), 

1836 widget=PasswordWidget(attributes={ 

1837 AUTOCOMPLETE_ATTR: AutocompleteAttrValues.CURRENT_PASSWORD 

1838 }), 

1839 ) 

1840 redirect_url = HiddenRedirectionUrlNode() # name must match ViewParam.REDIRECT_URL # noqa 

1841 

1842 def __init__(self, *args, autocomplete_password: bool = True, 

1843 **kwargs) -> None: 

1844 self.autocomplete_password = autocomplete_password 

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

1846 

1847 # noinspection PyUnusedLocal 

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

1849 _ = self.gettext 

1850 password = get_child_node(self, "password") 

1851 password.title = _("Password") 

1852 password.widget.attributes[AUTOCOMPLETE_ATTR] = ( 

1853 AutocompleteAttrValues.CURRENT_PASSWORD 

1854 if self.autocomplete_password else AutocompleteAttrValues.OFF 

1855 ) 

1856 

1857 

1858class LoginForm(InformativeNonceForm): 

1859 """ 

1860 Form to capture login details. 

1861 """ 

1862 def __init__(self, 

1863 request: "CamcopsRequest", 

1864 autocomplete_password: bool = True, 

1865 **kwargs) -> None: 

1866 """ 

1867 Args: 

1868 autocomplete_password: 

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

1870 autocompletion? Note that browsers may ignore this. 

1871 """ 

1872 _ = request.gettext 

1873 schema = LoginSchema( 

1874 autocomplete_password=autocomplete_password 

1875 ).bind(request=request) 

1876 super().__init__( 

1877 schema, 

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

1879 # autocomplete=autocomplete_password, 

1880 **kwargs 

1881 ) 

1882 # Suboptimal: autocomplete_password is not applied to the password 

1883 # widget, just to the form; see 

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

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

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

1887 

1888 

1889# ============================================================================= 

1890# Change password 

1891# ============================================================================= 

1892 

1893class MustChangePasswordNode(SchemaNode, RequestAwareMixin): 

1894 """ 

1895 Boolean node: must the user change their password? 

1896 """ 

1897 schema_type = Boolean 

1898 default = True 

1899 missing = True 

1900 

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

1902 self.label = "" # for type checker 

1903 self.title = "" # for type checker 

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

1905 

1906 # noinspection PyUnusedLocal 

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

1908 _ = self.gettext 

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

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

1911 

1912 

1913class OldUserPasswordCheck(SchemaNode, RequestAwareMixin): 

1914 """ 

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

1916 """ 

1917 schema_type = String 

1918 widget = PasswordWidget(attributes={ 

1919 AUTOCOMPLETE_ATTR: AutocompleteAttrValues.CURRENT_PASSWORD 

1920 }) 

1921 

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

1923 self.title = "" # for type checker 

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

1925 

1926 # noinspection PyUnusedLocal 

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

1928 _ = self.gettext 

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

1930 

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

1932 request = self.request 

1933 user = request.user 

1934 assert user is not None 

1935 if not user.is_password_correct(value): 

1936 _ = request.gettext 

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

1938 

1939 

1940class NewPasswordNode(SchemaNode, RequestAwareMixin): 

1941 """ 

1942 Node to enter a new password. 

1943 """ 

1944 schema_type = String 

1945 widget = CheckedPasswordWidget(attributes={ 

1946 AUTOCOMPLETE_ATTR: AutocompleteAttrValues.NEW_PASSWORD 

1947 }) 

1948 

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

1950 self.title = "" # for type checker 

1951 self.description = "" # for type checker 

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

1953 

1954 # noinspection PyUnusedLocal 

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

1956 _ = self.gettext 

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

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

1959 

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

1961 try: 

1962 validate_new_password(value, self.request) 

1963 except ValueError as e: 

1964 raise Invalid(node, str(e)) 

1965 

1966 

1967class ChangeOwnPasswordSchema(CSRFSchema): 

1968 """ 

1969 Schema to change one's own password. 

1970 """ 

1971 old_password = OldUserPasswordCheck() 

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

1973 

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

1975 """ 

1976 Args: 

1977 must_differ: 

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

1979 """ 

1980 self.must_differ = must_differ 

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

1982 

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

1984 if self.must_differ and value['new_password'] == value['old_password']: 

1985 _ = self.gettext 

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

1987 

1988 

1989class ChangeOwnPasswordForm(InformativeNonceForm): 

1990 """ 

1991 Form to change one's own password. 

1992 """ 

1993 def __init__(self, request: "CamcopsRequest", 

1994 must_differ: bool = True, 

1995 **kwargs) -> None: 

1996 """ 

1997 Args: 

1998 must_differ: 

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

2000 """ 

2001 schema = ChangeOwnPasswordSchema(must_differ=must_differ).\ 

2002 bind(request=request) 

2003 super().__init__( 

2004 schema, 

2005 buttons=[Button(name=FormAction.SUBMIT, 

2006 title=change_password_title(request))], 

2007 **kwargs 

2008 ) 

2009 

2010 

2011class ChangeOtherPasswordSchema(CSRFSchema): 

2012 """ 

2013 Schema to change another user's password. 

2014 """ 

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

2016 must_change_password = MustChangePasswordNode() # match ViewParam.MUST_CHANGE_PASSWORD # noqa 

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

2018 

2019 

2020class ChangeOtherPasswordForm(SimpleSubmitForm): 

2021 """ 

2022 Form to change another user's password. 

2023 """ 

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

2025 super().__init__(schema_class=ChangeOtherPasswordSchema, 

2026 submit_title=change_password_title(request), 

2027 request=request, **kwargs) 

2028 

2029 

2030# ============================================================================= 

2031# Offer/agree terms 

2032# ============================================================================= 

2033 

2034class OfferTermsSchema(CSRFSchema): 

2035 """ 

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

2037 """ 

2038 pass 

2039 

2040 

2041class OfferTermsForm(SimpleSubmitForm): 

2042 """ 

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

2044 """ 

2045 def __init__(self, 

2046 request: "CamcopsRequest", 

2047 agree_button_text: str, 

2048 **kwargs) -> None: 

2049 """ 

2050 Args: 

2051 agree_button_text: 

2052 text for the "agree" button 

2053 """ 

2054 super().__init__(schema_class=OfferTermsSchema, 

2055 submit_title=agree_button_text, 

2056 request=request, **kwargs) 

2057 

2058 

2059# ============================================================================= 

2060# View audit trail 

2061# ============================================================================= 

2062 

2063class OptionalIPAddressNode(OptionalStringNode, RequestAwareMixin): 

2064 """ 

2065 Optional IPv4 or IPv6 address. 

2066 """ 

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

2068 try: 

2069 validate_ip_address(value, self.request) 

2070 except ValueError as e: 

2071 raise Invalid(node, e) 

2072 

2073 

2074class OptionalAuditSourceNode(OptionalStringNode, RequestAwareMixin): 

2075 """ 

2076 Optional IPv4 or IPv6 address. 

2077 """ 

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

2079 try: 

2080 validate_by_char_and_length( 

2081 value, 

2082 permitted_char_expression=ALPHANUM_UNDERSCORE_CHAR, 

2083 min_length=0, 

2084 max_length=StringLengths.AUDIT_SOURCE_MAX_LEN, 

2085 req=self.request 

2086 ) 

2087 except ValueError as e: 

2088 raise Invalid(node, e) 

2089 

2090 

2091class AuditTrailSchema(CSRFSchema): 

2092 """ 

2093 Schema to filter audit trail entries. 

2094 """ 

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

2096 start_datetime = StartPendulumSelector() # must match ViewParam.START_DATETIME # noqa 

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

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

2099 remote_ip_addr = OptionalIPAddressNode() # must match ViewParam.REMOTE_IP_ADDR # noqa 

2100 username = OptionalUserNameSelector() # must match ViewParam.USERNAME # noqa 

2101 table_name = OptionalSingleTaskSelector() # must match ViewParam.TABLENAME # noqa 

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

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

2104 

2105 # noinspection PyUnusedLocal 

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

2107 _ = self.gettext 

2108 source = get_child_node(self, "source") 

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

2110 remote_ip_addr = get_child_node(self, "remote_ip_addr") 

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

2112 truncate = get_child_node(self, "truncate") 

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

2114 

2115 

2116class AuditTrailForm(SimpleSubmitForm): 

2117 """ 

2118 Form to filter and then view audit trail entries. 

2119 """ 

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

2121 _ = request.gettext 

2122 super().__init__(schema_class=AuditTrailSchema, 

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

2124 request=request, **kwargs) 

2125 

2126 

2127# ============================================================================= 

2128# View export logs 

2129# ============================================================================= 

2130 

2131class OptionalExportRecipientNameSelector(OptionalStringNode, 

2132 RequestAwareMixin): 

2133 """ 

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

2135 database. 

2136 """ 

2137 title = "Export recipient" 

2138 

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

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

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

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

2143 

2144 # noinspection PyUnusedLocal 

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

2146 from camcops_server.cc_modules.cc_exportrecipient import ExportRecipient # delayed import # noqa 

2147 request = self.request 

2148 _ = request.gettext 

2149 dbsession = request.dbsession 

2150 q = ( 

2151 dbsession.query(ExportRecipient.recipient_name) 

2152 .distinct() 

2153 .order_by(ExportRecipient.recipient_name) 

2154 ) 

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

2156 for row in q: 

2157 recipient_name = row[0] 

2158 values.append((recipient_name, recipient_name)) 

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

2160 self.widget = SelectWidget(values=values) 

2161 self.validator = OneOf(pv) 

2162 

2163 

2164class ExportedTaskListSchema(CSRFSchema): 

2165 """ 

2166 Schema to filter HL7 message logs. 

2167 """ 

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

2169 recipient_name = OptionalExportRecipientNameSelector() # must match ViewParam.RECIPIENT_NAME # noqa 

2170 table_name = OptionalSingleTaskSelector() # must match ViewParam.TABLENAME # noqa 

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

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

2173 start_datetime = StartDateTimeSelector() # must match ViewParam.START_DATETIME # noqa 

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

2175 

2176 # noinspection PyUnusedLocal 

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

2178 _ = self.gettext 

2179 id_ = get_child_node(self, "id") 

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

2181 

2182 

2183class ExportedTaskListForm(SimpleSubmitForm): 

2184 """ 

2185 Form to filter and then view exported task logs. 

2186 """ 

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

2188 _ = request.gettext 

2189 super().__init__(schema_class=ExportedTaskListSchema, 

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

2191 request=request, **kwargs) 

2192 

2193 

2194# ============================================================================= 

2195# Task filters 

2196# ============================================================================= 

2197 

2198class TextContentsSequence(SequenceSchema, RequestAwareMixin): 

2199 """ 

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

2201 for a task filter). 

2202 """ 

2203 text_sequence = SchemaNode( 

2204 String(), 

2205 validator=Length(0, StringLengths.FILTER_TEXT_MAX_LEN) 

2206 ) # BEWARE: fairly unrestricted contents. 

2207 

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

2209 self.title = "" # for type checker 

2210 self.description = "" # for type checker 

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

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

2213 

2214 # noinspection PyUnusedLocal 

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

2216 _ = self.gettext 

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

2218 self.description = self.or_join_description 

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

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

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

2222 text_sequence = get_child_node(self, "text_sequence") 

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

2224 text_sequence.title = _("text") 

2225 

2226 # noinspection PyMethodMayBeStatic 

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

2228 assert isinstance(value, list) 

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

2230 _ = self.gettext 

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

2232 

2233 

2234class UploadingUserSequence(SequenceSchema, RequestAwareMixin): 

2235 """ 

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

2237 the following users..."). 

2238 """ 

2239 user_id_sequence = MandatoryUserIdSelectorUsersAllowedToSee() 

2240 

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

2242 self.title = "" # for type checker 

2243 self.description = "" # for type checker 

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

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

2246 

2247 # noinspection PyUnusedLocal 

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

2249 _ = self.gettext 

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

2251 self.description = self.or_join_description 

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

2253 

2254 # noinspection PyMethodMayBeStatic 

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

2256 assert isinstance(value, list) 

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

2258 _ = self.gettext 

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

2260 

2261 

2262class DevicesSequence(SequenceSchema, RequestAwareMixin): 

2263 """ 

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

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

2266 """ 

2267 device_id_sequence = MandatoryDeviceIdSelector() 

2268 

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

2270 self.title = "" # for type checker 

2271 self.description = "" # for type checker 

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

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

2274 

2275 # noinspection PyUnusedLocal 

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

2277 _ = self.gettext 

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

2279 self.description = self.or_join_description 

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

2281 

2282 # noinspection PyMethodMayBeStatic 

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

2284 assert isinstance(value, list) 

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

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

2287 

2288 

2289class OptionalPatientNameNode(OptionalStringNode, RequestAwareMixin): 

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

2291 try: 

2292 # TODO: Validating human names is hard. 

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

2294 # be configurable. 

2295 # validate_human_name(value, self.request) 

2296 

2297 # Does nothing but better to be explicit 

2298 validate_anything(value, self.request) 

2299 except ValueError as e: 

2300 # Should never happen with validate_anything 

2301 raise Invalid(node, str(e)) 

2302 

2303 

2304class EditTaskFilterWhoSchema(Schema, RequestAwareMixin): 

2305 """ 

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

2307 """ 

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

2309 forename = OptionalPatientNameNode() # must match ViewParam.FORENAME # noqa 

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

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

2312 id_references = IdNumSequenceAnyCombination() # must match ViewParam.ID_REFERENCES # noqa 

2313 

2314 # noinspection PyUnusedLocal 

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

2316 _ = self.gettext 

2317 surname = get_child_node(self, "surname") 

2318 surname.title = _("Surname") 

2319 forename = get_child_node(self, "forename") 

2320 forename.title = _("Forename") 

2321 dob = get_child_node(self, "dob") 

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

2323 id_references = get_child_node(self, "id_references") 

2324 id_references.description = self.or_join_description 

2325 

2326 

2327class EditTaskFilterWhenSchema(Schema): 

2328 """ 

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

2330 """ 

2331 start_datetime = StartPendulumSelector() # must match ViewParam.START_DATETIME # noqa 

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

2333 

2334 

2335class EditTaskFilterWhatSchema(Schema, RequestAwareMixin): 

2336 """ 

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

2338 """ 

2339 text_contents = TextContentsSequence() # must match ViewParam.TEXT_CONTENTS # noqa 

2340 complete_only = BooleanNode(default=False) # must match ViewParam.COMPLETE_ONLY # noqa 

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

2342 

2343 # noinspection PyUnusedLocal 

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

2345 _ = self.gettext 

2346 complete_only = get_child_node(self, "complete_only") 

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

2348 complete_only.title = only_completed_text 

2349 complete_only.label = only_completed_text 

2350 

2351 

2352class EditTaskFilterAdminSchema(Schema): 

2353 """ 

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

2355 """ 

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

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

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

2359 

2360 

2361class EditTaskFilterSchema(CSRFSchema): 

2362 """ 

2363 Schema to edit a task filter. 

2364 """ 

2365 who = EditTaskFilterWhoSchema( # must match ViewParam.WHO 

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

2367 ) 

2368 what = EditTaskFilterWhatSchema( # must match ViewParam.WHAT 

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

2370 ) 

2371 when = EditTaskFilterWhenSchema( # must match ViewParam.WHEN 

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

2373 ) 

2374 admin = EditTaskFilterAdminSchema( # must match ViewParam.ADMIN 

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

2376 ) 

2377 

2378 # noinspection PyUnusedLocal 

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

2380 # log.debug("EditTaskFilterSchema.after_bind") 

2381 # log.debug("{!r}", self.__dict__) 

2382 # This is pretty nasty. By the time we get here, the Form class has 

2383 # made Field objects, and, I think, called a clone() function on us. 

2384 # Objects like "who" are not in our __dict__ any more. Our __dict__ 

2385 # looks like: 

2386 # { 

2387 # 'typ': <colander.Mapping object at 0x7fd7989b18d0>, 

2388 # 'bindings': { 

2389 # 'open_who': True, 

2390 # 'open_when': True, 

2391 # 'request': ..., 

2392 # }, 

2393 # '_order': 118, 

2394 # 'children': [ 

2395 # <...CSRFToken object at ... (named csrf)>, 

2396 # <...EditTaskFilterWhoSchema object at ... (named who)>, 

2397 # ... 

2398 # ], 

2399 # 'title': '' 

2400 # } 

2401 _ = self.gettext 

2402 who = get_child_node(self, "who") 

2403 what = get_child_node(self, "what") 

2404 when = get_child_node(self, "when") 

2405 admin = get_child_node(self, "admin") 

2406 who.title = _("Who") 

2407 what.title = _("What") 

2408 when.title = _("When") 

2409 admin.title = _("Administrative criteria") 

2410 # log.debug("who = {!r}", who) 

2411 # log.debug("who.__dict__ = {!r}", who.__dict__) 

2412 who.widget.open = kw[Binding.OPEN_WHO] 

2413 what.widget.open = kw[Binding.OPEN_WHAT] 

2414 when.widget.open = kw[Binding.OPEN_WHEN] 

2415 admin.widget.open = kw[Binding.OPEN_ADMIN] 

2416 

2417 

2418class EditTaskFilterForm(InformativeNonceForm): 

2419 """ 

2420 Form to edit a task filter. 

2421 """ 

2422 def __init__(self, 

2423 request: "CamcopsRequest", 

2424 open_who: bool = False, 

2425 open_what: bool = False, 

2426 open_when: bool = False, 

2427 open_admin: bool = False, 

2428 **kwargs) -> None: 

2429 _ = request.gettext 

2430 schema = EditTaskFilterSchema().bind(request=request, 

2431 open_admin=open_admin, 

2432 open_what=open_what, 

2433 open_when=open_when, 

2434 open_who=open_who) 

2435 super().__init__( 

2436 schema, 

2437 buttons=[Button(name=FormAction.SET_FILTERS, 

2438 title=_("Set filters")), 

2439 Button(name=FormAction.CLEAR_FILTERS, 

2440 title=_("Clear"))], 

2441 **kwargs 

2442 ) 

2443 

2444 

2445class TasksPerPageSchema(CSRFSchema): 

2446 """ 

2447 Schema to edit the number of rows per page, for the task view. 

2448 """ 

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

2450 

2451 

2452class TasksPerPageForm(InformativeNonceForm): 

2453 """ 

2454 Form to edit the number of tasks per page, for the task view. 

2455 """ 

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

2457 _ = request.gettext 

2458 schema = TasksPerPageSchema().bind(request=request) 

2459 super().__init__( 

2460 schema, 

2461 buttons=[Button(name=FormAction.SUBMIT_TASKS_PER_PAGE, 

2462 title=_("Set n/page"))], 

2463 css_class=BootstrapCssClasses.FORM_INLINE, 

2464 **kwargs 

2465 ) 

2466 

2467 

2468class RefreshTasksSchema(CSRFSchema): 

2469 """ 

2470 Schema for a "refresh tasks" button. 

2471 """ 

2472 pass 

2473 

2474 

2475class RefreshTasksForm(InformativeNonceForm): 

2476 """ 

2477 Form for a "refresh tasks" button. 

2478 """ 

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

2480 _ = request.gettext 

2481 schema = RefreshTasksSchema().bind(request=request) 

2482 super().__init__( 

2483 schema, 

2484 buttons=[Button(name=FormAction.REFRESH_TASKS, 

2485 title=_("Refresh"))], 

2486 **kwargs 

2487 ) 

2488 

2489 

2490# ============================================================================= 

2491# Trackers 

2492# ============================================================================= 

2493 

2494class TaskTrackerOutputTypeSelector(SchemaNode, RequestAwareMixin): 

2495 """ 

2496 Node to select the output format for a tracker. 

2497 """ 

2498 # Choices don't require translation 

2499 _choices = ((ViewArg.HTML, "HTML"), 

2500 (ViewArg.PDF, "PDF"), 

2501 (ViewArg.XML, "XML")) 

2502 

2503 schema_type = String 

2504 default = ViewArg.HTML 

2505 missing = ViewArg.HTML 

2506 widget = RadioChoiceWidget(values=_choices) 

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

2508 

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

2510 self.title = "" # for type checker 

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

2512 

2513 # noinspection PyUnusedLocal 

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

2515 _ = self.gettext 

2516 self.title = _("View as") 

2517 

2518 

2519class ChooseTrackerSchema(CSRFSchema): 

2520 """ 

2521 Schema to select a tracker or CTV. 

2522 """ 

2523 which_idnum = MandatoryWhichIdNumSelector() # must match ViewParam.WHICH_IDNUM # noqa 

2524 idnum_value = MandatoryIdNumValue() # must match ViewParam.IDNUM_VALUE # noqa 

2525 start_datetime = StartPendulumSelector() # must match ViewParam.START_DATETIME # noqa 

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

2527 all_tasks = BooleanNode(default=True) # match ViewParam.ALL_TASKS 

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

2529 # tracker_tasks_only will be set via the binding 

2530 via_index = ViaIndexSelector() # must match ViewParam.VIA_INDEX 

2531 viewtype = TaskTrackerOutputTypeSelector() # must match ViewParams.VIEWTYPE # noqa 

2532 

2533 # noinspection PyUnusedLocal 

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

2535 _ = self.gettext 

2536 all_tasks = get_child_node(self, "all_tasks") 

2537 text = _("Use all eligible task types?") 

2538 all_tasks.title = text 

2539 all_tasks.label = text 

2540 

2541 

2542class ChooseTrackerForm(InformativeNonceForm): 

2543 """ 

2544 Form to select a tracker or CTV. 

2545 """ 

2546 def __init__(self, request: "CamcopsRequest", 

2547 as_ctv: bool, **kwargs) -> None: 

2548 """ 

2549 Args: 

2550 as_ctv: CTV, not tracker? 

2551 """ 

2552 _ = request.gettext 

2553 schema = ChooseTrackerSchema().bind(request=request, 

2554 tracker_tasks_only=not as_ctv) 

2555 super().__init__( 

2556 schema, 

2557 buttons=[ 

2558 Button( 

2559 name=FormAction.SUBMIT, 

2560 title=(_("View CTV") if as_ctv else _("View tracker")) 

2561 ) 

2562 ], 

2563 **kwargs 

2564 ) 

2565 

2566 

2567# ============================================================================= 

2568# Reports, which use dynamically created forms 

2569# ============================================================================= 

2570 

2571class ReportOutputTypeSelector(SchemaNode, RequestAwareMixin): 

2572 """ 

2573 Node to select the output format for a report. 

2574 """ 

2575 schema_type = String 

2576 default = ViewArg.HTML 

2577 missing = ViewArg.HTML 

2578 

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

2580 self.title = "" # for type checker 

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

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

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

2584 

2585 # noinspection PyUnusedLocal 

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

2587 _ = self.gettext 

2588 self.title = _("View as") 

2589 choices = self.get_choices() 

2590 values, pv = get_values_and_permissible(choices) 

2591 self.widget = RadioChoiceWidget(values=choices) 

2592 self.validator = OneOf(pv) 

2593 

2594 def get_choices(self) -> Tuple[Tuple[str, str]]: 

2595 _ = self.gettext 

2596 # noinspection PyTypeChecker 

2597 return ( 

2598 (ViewArg.HTML, _("HTML")), 

2599 (ViewArg.ODS, _("OpenOffice spreadsheet (ODS) file")), 

2600 (ViewArg.TSV, _("TSV (tab-separated values)")), 

2601 (ViewArg.XLSX, _("XLSX (Microsoft Excel) file")) 

2602 ) 

2603 

2604 

2605class ReportParamSchema(CSRFSchema): 

2606 """ 

2607 Schema to embed a report type (ID) and output format (view type). 

2608 """ 

2609 viewtype = ReportOutputTypeSelector() # must match ViewParam.VIEWTYPE 

2610 report_id = HiddenStringNode() # must match ViewParam.REPORT_ID 

2611 # Specific forms may inherit from this. 

2612 

2613 

2614class DateTimeFilteredReportParamSchema(ReportParamSchema): 

2615 start_datetime = StartPendulumSelector() 

2616 end_datetime = EndPendulumSelector() 

2617 

2618 

2619class ReportParamForm(SimpleSubmitForm): 

2620 """ 

2621 Form to view a specific report. Often derived from, to configure the report 

2622 in more detail. 

2623 """ 

2624 def __init__(self, request: "CamcopsRequest", 

2625 schema_class: Type[ReportParamSchema], **kwargs) -> None: 

2626 _ = request.gettext 

2627 super().__init__(schema_class=schema_class, 

2628 submit_title=_("View report"), 

2629 request=request, **kwargs) 

2630 

2631 

2632# ============================================================================= 

2633# View DDL 

2634# ============================================================================= 

2635 

2636def get_sql_dialect_choices( 

2637 request: "CamcopsRequest") -> List[Tuple[str, str]]: 

2638 _ = request.gettext 

2639 return [ 

2640 # http://docs.sqlalchemy.org/en/latest/dialects/ 

2641 (SqlaDialectName.MYSQL, "MySQL"), 

2642 (SqlaDialectName.MSSQL, "Microsoft SQL Server"), 

2643 (SqlaDialectName.ORACLE, "Oracle" + _("[WILL NOT WORK]")), 

2644 # ... Oracle doesn't work; SQLAlchemy enforces the Oracle rule of a 30- 

2645 # character limit for identifiers, only relaxed to 128 characters in 

2646 # Oracle 12.2 (March 2017). 

2647 (SqlaDialectName.FIREBIRD, "Firebird"), 

2648 (SqlaDialectName.POSTGRES, "PostgreSQL"), 

2649 (SqlaDialectName.SQLITE, "SQLite"), 

2650 (SqlaDialectName.SYBASE, "Sybase"), 

2651 ] 

2652 

2653 

2654class DatabaseDialectSelector(SchemaNode, RequestAwareMixin): 

2655 """ 

2656 Node to choice an SQL dialect (for viewing DDL). 

2657 """ 

2658 schema_type = String 

2659 default = SqlaDialectName.MYSQL 

2660 missing = SqlaDialectName.MYSQL 

2661 

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

2663 self.title = "" # for type checker 

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

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

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

2667 

2668 # noinspection PyUnusedLocal 

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

2670 _ = self.gettext 

2671 self.title = _("SQL dialect to use (not all may be valid)") 

2672 choices = get_sql_dialect_choices(self.request) 

2673 values, pv = get_values_and_permissible(choices) 

2674 self.widget = RadioChoiceWidget(values=values) 

2675 self.validator = OneOf(pv) 

2676 

2677 

2678class ViewDdlSchema(CSRFSchema): 

2679 """ 

2680 Schema to choose how to view DDL. 

2681 """ 

2682 dialect = DatabaseDialectSelector() # must match ViewParam.DIALECT 

2683 

2684 

2685class ViewDdlForm(SimpleSubmitForm): 

2686 """ 

2687 Form to choose how to view DDL (and then view it). 

2688 """ 

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

2690 _ = request.gettext 

2691 super().__init__(schema_class=ViewDdlSchema, 

2692 submit_title=_("View DDL"), 

2693 request=request, **kwargs) 

2694 

2695 

2696# ============================================================================= 

2697# Add/edit/delete users 

2698# ============================================================================= 

2699 

2700class UserGroupPermissionsGroupAdminSchema(CSRFSchema): 

2701 """ 

2702 Edit group-specific permissions for a user. For group administrators. 

2703 """ 

2704 may_upload = BooleanNode(default=True) # match ViewParam.MAY_UPLOAD and User attribute # noqa 

2705 may_register_devices = BooleanNode(default=True) # match ViewParam.MAY_REGISTER_DEVICES and User attribute # noqa 

2706 may_use_webviewer = BooleanNode(default=True) # match ViewParam.MAY_USE_WEBVIEWER and User attribute # noqa 

2707 view_all_patients_when_unfiltered = BooleanNode(default=False) # match ViewParam.VIEW_ALL_PATIENTS_WHEN_UNFILTERED and User attribute # noqa 

2708 may_dump_data = BooleanNode(default=False) # match ViewParam.MAY_DUMP_DATA and User attribute # noqa 

2709 may_run_reports = BooleanNode(default=False) # match ViewParam.MAY_RUN_REPORTS and User attribute # noqa 

2710 may_add_notes = BooleanNode(default=False) # match ViewParam.MAY_ADD_NOTES and User attribute # noqa 

2711 

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

2713 _ = self.gettext 

2714 may_upload = get_child_node(self, "may_upload") 

2715 mu_text = _("Permitted to upload from a tablet/device") 

2716 may_upload.title = mu_text 

2717 may_upload.label = mu_text 

2718 may_register_devices = get_child_node(self, "may_register_devices") 

2719 mrd_text = _("Permitted to register tablet/client devices") 

2720 may_register_devices.title = mrd_text 

2721 may_register_devices.label = mrd_text 

2722 may_use_webviewer = get_child_node(self, "may_use_webviewer") 

2723 ml_text = _("May log in to web front end") 

2724 may_use_webviewer.title = ml_text 

2725 may_use_webviewer.label = ml_text 

2726 view_all_patients_when_unfiltered = get_child_node(self, "view_all_patients_when_unfiltered") # noqa 

2727 vap_text = _( 

2728 "May view (browse) records from all patients when no patient " 

2729 "filter set" 

2730 ) 

2731 view_all_patients_when_unfiltered.title = vap_text 

2732 view_all_patients_when_unfiltered.label = vap_text 

2733 may_dump_data = get_child_node(self, "may_dump_data") 

2734 md_text = _("May perform bulk data dumps") 

2735 may_dump_data.title = md_text 

2736 may_dump_data.label = md_text 

2737 may_run_reports = get_child_node(self, "may_run_reports") 

2738 mrr_text = _("May run reports") 

2739 may_run_reports.title = mrr_text 

2740 may_run_reports.label = mrr_text 

2741 may_add_notes = get_child_node(self, "may_add_notes") 

2742 man_text = _("May add special notes to tasks") 

2743 may_add_notes.title = man_text 

2744 may_add_notes.label = man_text 

2745 

2746 

2747class UserGroupPermissionsFullSchema(UserGroupPermissionsGroupAdminSchema): 

2748 """ 

2749 Edit group-specific permissions for a user. For superusers; includes the 

2750 option to make the user a groupadmin. 

2751 """ 

2752 groupadmin = BooleanNode(default=True) # match ViewParam.GROUPADMIN and User attribute # noqa 

2753 

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

2755 super().after_bind(node, kw) 

2756 _ = self.gettext 

2757 groupadmin = get_child_node(self, "groupadmin") 

2758 text = _("User is a privileged group administrator for this group") 

2759 groupadmin.title = text 

2760 groupadmin.label = text 

2761 

2762 

2763class EditUserGroupAdminSchema(CSRFSchema): 

2764 """ 

2765 Schema to edit a user. Version for group administrators. 

2766 """ 

2767 username = UsernameNode() # name must match ViewParam.USERNAME and User attribute # noqa 

2768 fullname = OptionalStringNode( # name must match ViewParam.FULLNAME and User attribute # noqa 

2769 validator=Length(0, StringLengths.FULLNAME_MAX_LEN) 

2770 ) 

2771 email = OptionalEmailNode() # name must match ViewParam.EMAIL and User attribute # noqa 

2772 must_change_password = MustChangePasswordNode() # match ViewParam.MUST_CHANGE_PASSWORD and User attribute # noqa 

2773 language = LanguageSelector() # must match ViewParam.LANGUAGE 

2774 group_ids = AdministeredGroupsSequence() # must match ViewParam.GROUP_IDS 

2775 

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

2777 _ = self.gettext 

2778 fullname = get_child_node(self, "fullname") 

2779 fullname.title = _("Full name") 

2780 email = get_child_node(self, "email") 

2781 email.title = _("E-mail address") 

2782 

2783 

2784class EditUserFullSchema(EditUserGroupAdminSchema): 

2785 """ 

2786 Schema to edit a user. Version for superusers; can also make the user a 

2787 superuser. 

2788 """ 

2789 superuser = BooleanNode(default=False) # match ViewParam.SUPERUSER and User attribute # noqa 

2790 group_ids = AllGroupsSequence() # must match ViewParam.GROUP_IDS 

2791 

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

2793 _ = self.gettext 

2794 superuser = get_child_node(self, "superuser") 

2795 text = _("Superuser (CAUTION!)") 

2796 superuser.title = text 

2797 superuser.label = text 

2798 

2799 

2800class EditUserFullForm(ApplyCancelForm): 

2801 """ 

2802 Form to edit a user. Full version for superusers. 

2803 """ 

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

2805 super().__init__(schema_class=EditUserFullSchema, 

2806 request=request, **kwargs) 

2807 

2808 

2809class EditUserGroupAdminForm(ApplyCancelForm): 

2810 """ 

2811 Form to edit a user. Version for group administrators. 

2812 """ 

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

2814 super().__init__(schema_class=EditUserGroupAdminSchema, 

2815 request=request, **kwargs) 

2816 

2817 

2818class EditUserGroupPermissionsFullForm(ApplyCancelForm): 

2819 """ 

2820 Form to edit a user's permissions within a group. Version for superusers. 

2821 """ 

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

2823 super().__init__(schema_class=UserGroupPermissionsFullSchema, 

2824 request=request, **kwargs) 

2825 

2826 

2827class EditUserGroupMembershipGroupAdminForm(ApplyCancelForm): 

2828 """ 

2829 Form to edit a user's permissions within a group. Version for group 

2830 administrators. 

2831 """ 

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

2833 super().__init__(schema_class=UserGroupPermissionsGroupAdminSchema, 

2834 request=request, **kwargs) 

2835 

2836 

2837class AddUserSuperuserSchema(CSRFSchema): 

2838 """ 

2839 Schema to add a user. Version for superusers. 

2840 """ 

2841 username = UsernameNode() # name must match ViewParam.USERNAME and User attribute # noqa 

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

2843 must_change_password = MustChangePasswordNode() # match ViewParam.MUST_CHANGE_PASSWORD and User attribute # noqa 

2844 group_ids = AllGroupsSequence() # must match ViewParam.GROUP_IDS 

2845 

2846 

2847class AddUserGroupadminSchema(AddUserSuperuserSchema): 

2848 """ 

2849 Schema to add a user. Version for group administrators. 

2850 """ 

2851 group_ids = AdministeredGroupsSequence() # must match ViewParam.GROUP_IDS 

2852 

2853 

2854class AddUserSuperuserForm(AddCancelForm): 

2855 """ 

2856 Form to add a user. Version for superusers. 

2857 """ 

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

2859 super().__init__(schema_class=AddUserSuperuserSchema, 

2860 request=request, **kwargs) 

2861 

2862 

2863class AddUserGroupadminForm(AddCancelForm): 

2864 """ 

2865 Form to add a user. Version for group administrators. 

2866 """ 

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

2868 super().__init__(schema_class=AddUserGroupadminSchema, 

2869 request=request, **kwargs) 

2870 

2871 

2872class SetUserUploadGroupSchema(CSRFSchema): 

2873 """ 

2874 Schema to choose the group into which a user uploads. 

2875 """ 

2876 upload_group_id = OptionalGroupIdSelectorUserGroups() # must match ViewParam.UPLOAD_GROUP_ID # noqa 

2877 

2878 # noinspection PyUnusedLocal 

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

2880 _ = self.gettext 

2881 upload_group_id = get_child_node(self, "upload_group_id") 

2882 upload_group_id.title = _("Group into which to upload data") 

2883 upload_group_id.description = _( 

2884 "Pick a group from those to which the user belongs") 

2885 

2886 

2887class SetUserUploadGroupForm(InformativeNonceForm): 

2888 """ 

2889 Form to choose the group into which a user uploads. 

2890 """ 

2891 def __init__(self, request: "CamcopsRequest", user: "User", 

2892 **kwargs) -> None: 

2893 _ = request.gettext 

2894 schema = SetUserUploadGroupSchema().bind(request=request, 

2895 user=user) # UNUSUAL 

2896 super().__init__( 

2897 schema, 

2898 buttons=[ 

2899 Button(name=FormAction.SUBMIT, title=_("Set")), 

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

2901 ], 

2902 **kwargs 

2903 ) 

2904 

2905 

2906class DeleteUserSchema(HardWorkConfirmationSchema): 

2907 """ 

2908 Schema to delete a user. 

2909 """ 

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

2911 danger = TranslatableValidateDangerousOperationNode() 

2912 

2913 

2914class DeleteUserForm(DeleteCancelForm): 

2915 """ 

2916 Form to delete a user. 

2917 """ 

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

2919 super().__init__(schema_class=DeleteUserSchema, 

2920 request=request, **kwargs) 

2921 

2922 

2923# ============================================================================= 

2924# Add/edit/delete groups 

2925# ============================================================================= 

2926 

2927class PolicyNode(MandatoryStringNode, RequestAwareMixin): 

2928 """ 

2929 Node to capture a CamCOPS ID number policy, and make sure it is 

2930 syntactically valid. 

2931 """ 

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

2933 _ = self.gettext 

2934 if not isinstance(value, str): 

2935 # unlikely! 

2936 raise Invalid(node, _("Not a string")) 

2937 policy = TokenizedPolicy(value) 

2938 if not policy.is_syntactically_valid(): 

2939 raise Invalid(node, _("Syntactically invalid policy")) 

2940 if not policy.is_valid_for_idnums(self.request.valid_which_idnums): 

2941 raise Invalid( 

2942 node, 

2943 _("Invalid policy. Have you referred to non-existent ID " 

2944 "numbers? Is the policy less restrictive than the tablet’s " 

2945 "minimum ID policy?") + 

2946 f" [{TABLET_ID_POLICY_STR!r}]" 

2947 ) 

2948 

2949 

2950class GroupNameNode(MandatoryStringNode, RequestAwareMixin): 

2951 """ 

2952 Node to capture a CamCOPS group name, and check it's valid as a string. 

2953 """ 

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

2955 try: 

2956 validate_group_name(value, self.request) 

2957 except ValueError as e: 

2958 raise Invalid(node, str(e)) 

2959 

2960 

2961class GroupIpUseWidget(Widget): 

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

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

2964 form = "group_ip_use.pt" 

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

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

2967 

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

2969 super().__init__(**kwargs) 

2970 self.request = request 

2971 

2972 def serialize(self, 

2973 field: "Field", 

2974 cstruct: Union[Dict[str, Any], None, ColanderNullType], 

2975 **kw: Any) -> Any: 

2976 if cstruct in (None, null): 

2977 cstruct = {} 

2978 

2979 cstruct: Dict[str, Any] # For type checker 

2980 

2981 for context in IpUse.CONTEXTS: 

2982 value = cstruct.get(context, False) 

2983 kw.setdefault(context, value) 

2984 

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

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

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

2988 

2989 _ = self.request.gettext 

2990 

2991 values.update( 

2992 introduction=_( 

2993 "These settings will be applied to the patient's device " 

2994 "when operating in single user mode." 

2995 ), 

2996 reason=_( 

2997 "The settings here influence whether CamCOPS will consider " 

2998 "some third-party tasks “permitted” on your behalf, according " 

2999 "to their published use criteria. They do <b>not</b> remove " 

3000 "your responsibility to ensure that you use them in accordance " 

3001 "with their own requirements." 

3002 ), 

3003 warning=_( 

3004 "WARNING. Providing incorrect information here may lead to you " 

3005 "VIOLATING copyright law, by using a task for a purpose that " 

3006 "is not permitted, and being subject to damages and/or " 

3007 "prosecution." 

3008 ), 

3009 disclaimer=_( 

3010 "The authors of CamCOPS cannot be held responsible or liable " 

3011 "for any consequences of you misusing materials subject to " 

3012 "copyright." 

3013 ), 

3014 preamble=_("In which contexts does this group operate?"), 

3015 clinical_label=_("Clinical"), 

3016 medical_device_warning=_( 

3017 "WARNING: NOT FOR GENERAL CLINICAL USE; not a Medical Device; " 

3018 "see Terms and Conditions" 

3019 ), 

3020 commercial_label=_("Commercial"), 

3021 educational_label=_("Educational"), 

3022 research_label=_("Research"), 

3023 ) 

3024 

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

3026 

3027 def deserialize( 

3028 self, 

3029 field: "Field", 

3030 pstruct: Union[Dict[str, Any], ColanderNullType] 

3031 ) -> Dict[str, bool]: 

3032 if pstruct is null: 

3033 pstruct = {} 

3034 

3035 pstruct: Dict[str, Any] # For type checker 

3036 

3037 # It doesn't really matter what the pstruct values are. Only the 

3038 # options that are ticked will be present as keys in pstruct 

3039 return {k: k in pstruct for k in IpUse.CONTEXTS} 

3040 

3041 

3042class IpUseType(object): 

3043 # noinspection PyMethodMayBeStatic,PyUnusedLocal 

3044 def deserialize( 

3045 self, 

3046 node: SchemaNode, 

3047 cstruct: Union[Dict[str, Any], None, ColanderNullType]) \ 

3048 -> Optional[IpUse]: 

3049 if cstruct in (None, null): 

3050 return None 

3051 

3052 cstruct: Dict[str, Any] # For type checker 

3053 

3054 return IpUse(**cstruct) 

3055 

3056 # noinspection PyMethodMayBeStatic,PyUnusedLocal 

3057 def serialize( 

3058 self, 

3059 node: SchemaNode, 

3060 ip_use: Union[IpUse, None, ColanderNullType]) \ 

3061 -> Union[Dict, ColanderNullType]: 

3062 if ip_use in [null, None]: 

3063 return null 

3064 

3065 return { 

3066 context: getattr(ip_use, context) for context in IpUse.CONTEXTS 

3067 } 

3068 

3069 

3070class GroupIpUseNode(SchemaNode, RequestAwareMixin): 

3071 schema_type = IpUseType 

3072 

3073 # noinspection PyUnusedLocal,PyAttributeOutsideInit 

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

3075 self.widget = GroupIpUseWidget(self.request) 

3076 

3077 

3078class EditGroupSchema(CSRFSchema): 

3079 """ 

3080 Schema to edit a group. 

3081 """ 

3082 group_id = HiddenIntegerNode() # must match ViewParam.GROUP_ID 

3083 name = GroupNameNode() # must match ViewParam.NAME 

3084 description = MandatoryStringNode( # must match ViewParam.DESCRIPTION 

3085 validator=Length(StringLengths.GROUP_DESCRIPTION_MIN_LEN, 

3086 StringLengths.GROUP_DESCRIPTION_MAX_LEN), 

3087 ) 

3088 ip_use = GroupIpUseNode() 

3089 

3090 group_ids = AllOtherGroupsSequence() # must match ViewParam.GROUP_IDS 

3091 upload_policy = PolicyNode() # must match ViewParam.UPLOAD_POLICY 

3092 finalize_policy = PolicyNode() # must match ViewParam.FINALIZE_POLICY 

3093 

3094 # noinspection PyUnusedLocal 

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

3096 _ = self.gettext 

3097 name = get_child_node(self, "name") 

3098 name.title = _("Group name") 

3099 

3100 ip_use = get_child_node(self, "ip_use") 

3101 ip_use.title = _("Group intellectual property settings") 

3102 

3103 group_ids = get_child_node(self, "group_ids") 

3104 group_ids.title = _("Other groups this group may see") 

3105 upload_policy = get_child_node(self, "upload_policy") 

3106 upload_policy.title = _("Upload policy") 

3107 upload_policy.description = _( 

3108 "Minimum required patient information to copy data to server") 

3109 finalize_policy = get_child_node(self, "finalize_policy") 

3110 finalize_policy.title = _("Finalize policy") 

3111 finalize_policy.description = _( 

3112 "Minimum required patient information to clear data off " 

3113 "source device") 

3114 

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

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

3117 q = CountStarSpecializedQuery(Group, session=request.dbsession)\ 

3118 .filter(Group.id != value[ViewParam.GROUP_ID])\ 

3119 .filter(Group.name == value[ViewParam.NAME]) 

3120 if q.count_star() > 0: 

3121 _ = request.gettext 

3122 raise Invalid(node, _("Name is used by another group!")) 

3123 

3124 

3125class EditGroupForm(InformativeNonceForm): 

3126 """ 

3127 Form to edit a group. 

3128 """ 

3129 def __init__(self, request: "CamcopsRequest", group: Group, 

3130 **kwargs) -> None: 

3131 _ = request.gettext 

3132 schema = EditGroupSchema().bind(request=request, 

3133 group=group) # UNUSUAL BINDING 

3134 super().__init__( 

3135 schema, 

3136 buttons=[ 

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

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

3139 ], 

3140 **kwargs 

3141 ) 

3142 

3143 

3144class AddGroupSchema(CSRFSchema): 

3145 """ 

3146 Schema to add a group. 

3147 """ 

3148 name = GroupNameNode() # name must match ViewParam.NAME 

3149 

3150 # noinspection PyUnusedLocal 

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

3152 _ = self.gettext 

3153 name = get_child_node(self, "name") 

3154 name.title = _("Group name") 

3155 

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

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

3158 q = CountStarSpecializedQuery(Group, session=request.dbsession)\ 

3159 .filter(Group.name == value[ViewParam.NAME]) 

3160 if q.count_star() > 0: 

3161 _ = request.gettext 

3162 raise Invalid(node, _("Name is used by another group!")) 

3163 

3164 

3165class AddGroupForm(AddCancelForm): 

3166 """ 

3167 Form to add a group. 

3168 """ 

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

3170 super().__init__(schema_class=AddGroupSchema, 

3171 request=request, **kwargs) 

3172 

3173 

3174class DeleteGroupSchema(HardWorkConfirmationSchema): 

3175 """ 

3176 Schema to delete a group. 

3177 """ 

3178 group_id = HiddenIntegerNode() # name must match ViewParam.GROUP_ID 

3179 danger = TranslatableValidateDangerousOperationNode() 

3180 

3181 

3182class DeleteGroupForm(DeleteCancelForm): 

3183 """ 

3184 Form to delete a group. 

3185 """ 

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

3187 super().__init__(schema_class=DeleteGroupSchema, 

3188 request=request, **kwargs) 

3189 

3190 

3191# ============================================================================= 

3192# Offer research dumps 

3193# ============================================================================= 

3194 

3195class DumpTypeSelector(SchemaNode, RequestAwareMixin): 

3196 """ 

3197 Node to select the filtering method for a data dump. 

3198 """ 

3199 schema_type = String 

3200 default = ViewArg.EVERYTHING 

3201 missing = ViewArg.EVERYTHING 

3202 

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

3204 self.title = "" # for type checker 

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

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

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

3208 

3209 # noinspection PyUnusedLocal 

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

3211 _ = self.gettext 

3212 self.title = _("Dump method") 

3213 choices = ( 

3214 (ViewArg.EVERYTHING, _("Everything")), 

3215 (ViewArg.USE_SESSION_FILTER, 

3216 _("Use the session filter settings")), 

3217 (ViewArg.SPECIFIC_TASKS_GROUPS, 

3218 _("Specify tasks/groups manually (see below)")), 

3219 ) 

3220 self.widget = RadioChoiceWidget(values=choices) 

3221 self.validator = OneOf(list(x[0] for x in choices)) 

3222 

3223 

3224class SpreadsheetFormatSelector(SchemaNode, RequestAwareMixin): 

3225 """ 

3226 Node to select a way of downloading an SQLite database. 

3227 """ 

3228 schema_type = String 

3229 default = ViewArg.XLSX 

3230 missing = ViewArg.XLSX 

3231 

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

3233 self.title = "" # for type checker 

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

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

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

3237 

3238 # noinspection PyUnusedLocal 

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

3240 _ = self.gettext 

3241 self.title = _("Spreadsheet format") 

3242 choices = ( 

3243 (ViewArg.R, _("R script")), 

3244 (ViewArg.ODS, _("OpenOffice spreadsheet (ODS) file")), 

3245 (ViewArg.XLSX, _("XLSX (Microsoft Excel) file")), 

3246 (ViewArg.TSV_ZIP, _("ZIP file of tab-separated value (TSV) files")), 

3247 ) 

3248 values, pv = get_values_and_permissible(choices) 

3249 self.widget = RadioChoiceWidget(values=values) 

3250 self.validator = OneOf(pv) 

3251 

3252 

3253class DeliveryModeNode(SchemaNode, RequestAwareMixin): 

3254 """ 

3255 Mode of delivery of data downloads. 

3256 """ 

3257 schema_type = String 

3258 default = ViewArg.EMAIL 

3259 missing = ViewArg.EMAIL 

3260 

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

3262 self.title = "" # for type checker 

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

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

3265 

3266 # noinspection PyUnusedLocal 

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

3268 _ = self.gettext 

3269 self.title = _("Delivery") 

3270 choices = ( 

3271 (ViewArg.IMMEDIATELY, _("Serve immediately")), 

3272 (ViewArg.EMAIL, _("E-mail me")), 

3273 (ViewArg.DOWNLOAD, _("Create a file for me to download")), 

3274 ) 

3275 values, pv = get_values_and_permissible(choices) 

3276 self.widget = RadioChoiceWidget(values=values) 

3277 

3278 # noinspection PyUnusedLocal 

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

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

3281 _ = request.gettext 

3282 if value == ViewArg.IMMEDIATELY: 

3283 if not request.config.permit_immediate_downloads: 

3284 raise Invalid( 

3285 self, 

3286 _("Disabled by the system administrator") + 

3287 f" [{ConfigParamSite.PERMIT_IMMEDIATE_DOWNLOADS}]" 

3288 ) 

3289 elif value == ViewArg.EMAIL: 

3290 if not request.user.email: 

3291 raise Invalid( 

3292 self, _("Your user does not have an email address")) 

3293 elif value == ViewArg.DOWNLOAD: 

3294 if not request.user_download_dir: 

3295 raise Invalid( 

3296 self, 

3297 _("User downloads not configured by administrator") + 

3298 f" [{ConfigParamSite.USER_DOWNLOAD_DIR}, " 

3299 f"{ConfigParamSite.USER_DOWNLOAD_MAX_SPACE_MB}]" 

3300 ) 

3301 else: 

3302 raise Invalid(self, _("Bad value")) 

3303 

3304 

3305class SqliteSelector(SchemaNode, RequestAwareMixin): 

3306 """ 

3307 Node to select a way of downloading an SQLite database. 

3308 """ 

3309 schema_type = String 

3310 default = ViewArg.SQLITE 

3311 missing = ViewArg.SQLITE 

3312 

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

3314 self.title = "" # for type checker 

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

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

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

3318 

3319 # noinspection PyUnusedLocal 

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

3321 _ = self.gettext 

3322 self.title = _("Database download method") 

3323 choices = ( 

3324 # http://docs.sqlalchemy.org/en/latest/dialects/ 

3325 (ViewArg.SQLITE, _("Binary SQLite database")), 

3326 (ViewArg.SQL, _("SQL text to create SQLite database")), 

3327 ) 

3328 values, pv = get_values_and_permissible(choices) 

3329 self.widget = RadioChoiceWidget(values=values) 

3330 self.validator = OneOf(pv) 

3331 

3332 

3333class SortTsvByHeadingsNode(SchemaNode, RequestAwareMixin): 

3334 """ 

3335 Boolean node: sort TSV files by column name? 

3336 """ 

3337 schema_type = Boolean 

3338 default = False 

3339 missing = False 

3340 

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

3342 self.title = "" # for type checker 

3343 self.label = "" # for type checker 

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

3345 

3346 # noinspection PyUnusedLocal 

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

3348 _ = self.gettext 

3349 self.title = _("Sort columns?") 

3350 self.label = _("Sort by heading (column) names within spreadsheets?") 

3351 

3352 

3353class IncludeInformationSchemaColumnsNode(SchemaNode, RequestAwareMixin): 

3354 """ 

3355 Boolean node: should INFORMATION_SCHEMA.COLUMNS be included (for 

3356 downloads)? 

3357 

3358 False by default -- adds about 350 kb to an ODS download, for example. 

3359 """ 

3360 schema_type = Boolean 

3361 default = False 

3362 missing = False 

3363 

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

3365 self.title = "" # for type checker 

3366 self.label = "" # for type checker 

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

3368 

3369 # noinspection PyUnusedLocal 

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

3371 _ = self.gettext 

3372 self.title = _("Include column information?") 

3373 self.label = _("Include details of all columns in the source database?") # noqa 

3374 

3375 

3376class IncludeBlobsNode(SchemaNode, RequestAwareMixin): 

3377 """ 

3378 Boolean node: should BLOBs be included (for downloads)? 

3379 """ 

3380 schema_type = Boolean 

3381 default = False 

3382 missing = False 

3383 

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

3385 self.title = "" # for type checker 

3386 self.label = "" # for type checker 

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

3388 

3389 # noinspection PyUnusedLocal 

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

3391 _ = self.gettext 

3392 self.title = _("Include BLOBs?") 

3393 self.label = _( 

3394 "Include binary large objects (BLOBs)? WARNING: may be large") 

3395 

3396 

3397class PatientIdPerRowNode(SchemaNode, RequestAwareMixin): 

3398 """ 

3399 Boolean node: should patient ID information, and other cross-referencing 

3400 denormalized info, be included per row? 

3401 

3402 See :ref:`DB_PATIENT_ID_PER_ROW <DB_PATIENT_ID_PER_ROW>`. 

3403 """ 

3404 schema_type = Boolean 

3405 default = True 

3406 missing = True 

3407 

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

3409 self.title = "" # for type checker 

3410 self.label = "" # for type checker 

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

3412 

3413 # noinspection PyUnusedLocal 

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

3415 _ = self.gettext 

3416 self.title = _("Patient ID per row?") 

3417 self.label = _( 

3418 "Include patient ID numbers and task cross-referencing " 

3419 "(denormalized) information per row?") 

3420 

3421 

3422class OfferDumpManualSchema(Schema, RequestAwareMixin): 

3423 """ 

3424 Schema to offer the "manual" settings for a data dump (groups, task types). 

3425 """ 

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

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

3428 

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

3430 

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

3432 self.title = "" # for type checker 

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

3434 

3435 # noinspection PyUnusedLocal 

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

3437 _ = self.gettext 

3438 self.title = _("Manual settings") 

3439 

3440 

3441class OfferBasicDumpSchema(CSRFSchema): 

3442 """ 

3443 Schema to choose the settings for a basic (TSV/ZIP) data dump. 

3444 """ 

3445 dump_method = DumpTypeSelector() # must match ViewParam.DUMP_METHOD 

3446 sort = SortTsvByHeadingsNode() # must match ViewParam.SORT 

3447 include_information_schema_columns = IncludeInformationSchemaColumnsNode() # must match ViewParam.INCLUDE_INFORMATION_SCHEMA_COLUMNS # noqa 

3448 manual = OfferDumpManualSchema() # must match ViewParam.MANUAL 

3449 viewtype = SpreadsheetFormatSelector() # must match ViewParams.VIEWTYPE # noqa 

3450 delivery_mode = DeliveryModeNode() # must match ViewParam.DELIVERY_MODE 

3451 

3452 

3453class OfferBasicDumpForm(SimpleSubmitForm): 

3454 """ 

3455 Form to offer a basic (TSV/ZIP) data dump. 

3456 """ 

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

3458 _ = request.gettext 

3459 super().__init__(schema_class=OfferBasicDumpSchema, 

3460 submit_title=_("Dump"), 

3461 request=request, **kwargs) 

3462 

3463 

3464class OfferSqlDumpSchema(CSRFSchema): 

3465 """ 

3466 Schema to choose the settings for an SQL data dump. 

3467 """ 

3468 dump_method = DumpTypeSelector() # must match ViewParam.DUMP_METHOD 

3469 sqlite_method = SqliteSelector() # must match ViewParam.SQLITE_METHOD 

3470 include_information_schema_columns = IncludeInformationSchemaColumnsNode() # must match ViewParam.INCLUDE_INFORMATION_SCHEMA_COLUMNS # noqa 

3471 include_blobs = IncludeBlobsNode() # must match ViewParam.INCLUDE_BLOBS 

3472 patient_id_per_row = PatientIdPerRowNode() # must match ViewParam.PATIENT_ID_PER_ROW # noqa 

3473 manual = OfferDumpManualSchema() # must match ViewParam.MANUAL 

3474 delivery_mode = DeliveryModeNode() # must match ViewParam.DELIVERY_MODE 

3475 

3476 

3477class OfferSqlDumpForm(SimpleSubmitForm): 

3478 """ 

3479 Form to choose the settings for an SQL data dump. 

3480 """ 

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

3482 _ = request.gettext 

3483 super().__init__(schema_class=OfferSqlDumpSchema, 

3484 submit_title=_("Dump"), 

3485 request=request, **kwargs) 

3486 

3487 

3488# ============================================================================= 

3489# Edit server settings 

3490# ============================================================================= 

3491 

3492class EditServerSettingsSchema(CSRFSchema): 

3493 """ 

3494 Schema to edit the global settings for the server. 

3495 """ 

3496 database_title = SchemaNode( # must match ViewParam.DATABASE_TITLE 

3497 String(), 

3498 validator=Length(StringLengths.DATABASE_TITLE_MIN_LEN, 

3499 StringLengths.DATABASE_TITLE_MAX_LEN), 

3500 ) 

3501 

3502 # noinspection PyUnusedLocal 

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

3504 _ = self.gettext 

3505 database_title = get_child_node(self, "database_title") 

3506 database_title.title = _("Database friendly title") 

3507 

3508 

3509class EditServerSettingsForm(ApplyCancelForm): 

3510 """ 

3511 Form to edit the global settings for the server. 

3512 """ 

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

3514 super().__init__(schema_class=EditServerSettingsSchema, 

3515 request=request, **kwargs) 

3516 

3517 

3518# ============================================================================= 

3519# Edit ID number definitions 

3520# ============================================================================= 

3521 

3522class IdDefinitionDescriptionNode(SchemaNode, RequestAwareMixin): 

3523 """ 

3524 Node to capture the description of an ID number type. 

3525 """ 

3526 schema_type = String 

3527 validator = Length(1, StringLengths.ID_DESCRIPTOR_MAX_LEN) 

3528 

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

3530 self.title = "" # for type checker 

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

3532 

3533 # noinspection PyUnusedLocal 

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

3535 _ = self.gettext 

3536 self.title = _("Full description (e.g. “NHS number”)") 

3537 

3538 

3539class IdDefinitionShortDescriptionNode(SchemaNode, RequestAwareMixin): 

3540 """ 

3541 Node to capture the short description of an ID number type. 

3542 """ 

3543 schema_type = String 

3544 validator = Length(1, StringLengths.ID_DESCRIPTOR_MAX_LEN) 

3545 

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

3547 self.title = "" # for type checker 

3548 self.description = "" # for type checker 

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

3550 

3551 # noinspection PyUnusedLocal 

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

3553 _ = self.gettext 

3554 self.title = _("Short description (e.g. “NHS#”)") 

3555 self.description = _("Try to keep it very short!") 

3556 

3557 

3558class IdValidationMethodNode(OptionalStringNode, RequestAwareMixin): 

3559 """ 

3560 Node to choose a build-in ID number validation method. 

3561 """ 

3562 widget = SelectWidget(values=ID_NUM_VALIDATION_METHOD_CHOICES) 

3563 validator = OneOf(list(x[0] for x in ID_NUM_VALIDATION_METHOD_CHOICES)) 

3564 

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

3566 self.title = "" # for type checker 

3567 self.description = "" # for type checker 

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

3569 

3570 # noinspection PyUnusedLocal 

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

3572 _ = self.gettext 

3573 self.title = _("Validation method") 

3574 self.description = _("Built-in CamCOPS ID number validation method") 

3575 

3576 

3577class Hl7AssigningAuthorityNode(OptionalStringNode, RequestAwareMixin): 

3578 """ 

3579 Optional node to capture the name of an HL7 Assigning Authority. 

3580 """ 

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

3582 self.title = "" # for type checker 

3583 self.description = "" # for type checker 

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

3585 

3586 # noinspection PyUnusedLocal 

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

3588 _ = self.gettext 

3589 self.title = _("HL7 Assigning Authority") 

3590 self.description = _( 

3591 "For HL7 messaging: " 

3592 "HL7 Assigning Authority for ID number (unique name of the " 

3593 "system/organization/agency/department that creates the data)." 

3594 ) 

3595 

3596 # noinspection PyMethodMayBeStatic 

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

3598 try: 

3599 validate_hl7_aa(value, self.request) 

3600 except ValueError as e: 

3601 raise Invalid(node, str(e)) 

3602 

3603 

3604class Hl7IdTypeNode(OptionalStringNode, RequestAwareMixin): 

3605 """ 

3606 Optional node to capture the name of an HL7 Identifier Type code. 

3607 """ 

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

3609 self.title = "" # for type checker 

3610 self.description = "" # for type checker 

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

3612 

3613 # noinspection PyUnusedLocal 

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

3615 _ = self.gettext 

3616 self.title = _("HL7 Identifier Type") 

3617 self.description = _( 

3618 "For HL7 messaging: " 

3619 "HL7 Identifier Type code: ‘a code corresponding to the type " 

3620 "of identifier. In some cases, this code may be used as a " 

3621 "qualifier to the “Assigning Authority” component.’" 

3622 ) 

3623 

3624 # noinspection PyMethodMayBeStatic 

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

3626 try: 

3627 validate_hl7_id_type(value, self.request) 

3628 except ValueError as e: 

3629 raise Invalid(node, str(e)) 

3630 

3631 

3632class EditIdDefinitionSchema(CSRFSchema): 

3633 """ 

3634 Schema to edit an ID number definition. 

3635 """ 

3636 which_idnum = HiddenIntegerNode() # must match ViewParam.WHICH_IDNUM 

3637 description = IdDefinitionDescriptionNode() # must match ViewParam.DESCRIPTION # noqa 

3638 short_description = IdDefinitionShortDescriptionNode() # must match ViewParam.SHORT_DESCRIPTION # noqa 

3639 validation_method = IdValidationMethodNode() # must match ViewParam.VALIDATION_METHOD # noqa 

3640 hl7_id_type = Hl7IdTypeNode() # must match ViewParam.HL7_ID_TYPE 

3641 hl7_assigning_authority = Hl7AssigningAuthorityNode() # must match ViewParam.HL7_ASSIGNING_AUTHORITY # noqa 

3642 

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

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

3645 _ = request.gettext 

3646 qd = CountStarSpecializedQuery(IdNumDefinition, 

3647 session=request.dbsession)\ 

3648 .filter(IdNumDefinition.which_idnum != 

3649 value[ViewParam.WHICH_IDNUM])\ 

3650 .filter(IdNumDefinition.description == 

3651 value[ViewParam.DESCRIPTION]) 

3652 if qd.count_star() > 0: 

3653 raise Invalid(node, _("Description is used by another ID number!")) 

3654 qs = CountStarSpecializedQuery(IdNumDefinition, 

3655 session=request.dbsession)\ 

3656 .filter(IdNumDefinition.which_idnum != 

3657 value[ViewParam.WHICH_IDNUM])\ 

3658 .filter(IdNumDefinition.short_description == 

3659 value[ViewParam.SHORT_DESCRIPTION]) 

3660 if qs.count_star() > 0: 

3661 raise Invalid(node, 

3662 _("Short description is used by another ID number!")) 

3663 

3664 

3665class EditIdDefinitionForm(ApplyCancelForm): 

3666 """ 

3667 Form to edit an ID number definition. 

3668 """ 

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

3670 super().__init__(schema_class=EditIdDefinitionSchema, 

3671 request=request, **kwargs) 

3672 

3673 

3674class AddIdDefinitionSchema(CSRFSchema): 

3675 """ 

3676 Schema to add an ID number definition. 

3677 """ 

3678 which_idnum = SchemaNode( # must match ViewParam.WHICH_IDNUM 

3679 Integer(), 

3680 validator=Range(min=1) 

3681 ) 

3682 description = IdDefinitionDescriptionNode() # must match ViewParam.DESCRIPTION # noqa 

3683 short_description = IdDefinitionShortDescriptionNode() # must match ViewParam.SHORT_DESCRIPTION # noqa 

3684 validation_method = IdValidationMethodNode() # must match ViewParam.VALIDATION_METHOD # noqa 

3685 

3686 # noinspection PyUnusedLocal 

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

3688 _ = self.gettext 

3689 which_idnum = get_child_node(self, "which_idnum") 

3690 which_idnum.title = _("Which ID number?") 

3691 which_idnum.description = ( 

3692 "Specify the integer to represent the type of this ID " 

3693 "number class (e.g. consecutive numbering from 1)" 

3694 ) 

3695 

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

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

3698 _ = request.gettext 

3699 qw = ( 

3700 CountStarSpecializedQuery(IdNumDefinition, 

3701 session=request.dbsession) 

3702 .filter(IdNumDefinition.which_idnum == 

3703 value[ViewParam.WHICH_IDNUM]) 

3704 ) 

3705 if qw.count_star() > 0: 

3706 raise Invalid(node, _("ID# clashes with another ID number!")) 

3707 qd = ( 

3708 CountStarSpecializedQuery(IdNumDefinition, 

3709 session=request.dbsession) 

3710 .filter(IdNumDefinition.description == 

3711 value[ViewParam.DESCRIPTION]) 

3712 ) 

3713 if qd.count_star() > 0: 

3714 raise Invalid(node, _("Description is used by another ID number!")) 

3715 qs = ( 

3716 CountStarSpecializedQuery(IdNumDefinition, 

3717 session=request.dbsession) 

3718 .filter(IdNumDefinition.short_description == 

3719 value[ViewParam.SHORT_DESCRIPTION]) 

3720 ) 

3721 if qs.count_star() > 0: 

3722 raise Invalid(node, 

3723 _("Short description is used by another ID number!")) 

3724 

3725 

3726class AddIdDefinitionForm(AddCancelForm): 

3727 """ 

3728 Form to add an ID number definition. 

3729 """ 

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

3731 super().__init__(schema_class=AddIdDefinitionSchema, 

3732 request=request, **kwargs) 

3733 

3734 

3735class DeleteIdDefinitionSchema(HardWorkConfirmationSchema): 

3736 """ 

3737 Schema to delete an ID number definition. 

3738 """ 

3739 which_idnum = HiddenIntegerNode() # name must match ViewParam.WHICH_IDNUM 

3740 danger = TranslatableValidateDangerousOperationNode() 

3741 

3742 

3743class DeleteIdDefinitionForm(DangerousForm): 

3744 """ 

3745 Form to add an ID number definition. 

3746 """ 

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

3748 _ = request.gettext 

3749 super().__init__(schema_class=DeleteIdDefinitionSchema, 

3750 submit_action=FormAction.DELETE, 

3751 submit_title=_("Delete"), 

3752 request=request, **kwargs) 

3753 

3754 

3755# ============================================================================= 

3756# Special notes 

3757# ============================================================================= 

3758 

3759class AddSpecialNoteSchema(CSRFSchema): 

3760 """ 

3761 Schema to add a special note to a task. 

3762 """ 

3763 table_name = HiddenStringNode() # must match ViewParam.TABLENAME 

3764 server_pk = HiddenIntegerNode() # must match ViewParam.SERVER_PK 

3765 note = MandatoryStringNode( # must match ViewParam.NOTE 

3766 widget=TextAreaWidget(rows=20, cols=80) 

3767 ) 

3768 danger = TranslatableValidateDangerousOperationNode() 

3769 

3770 

3771class AddSpecialNoteForm(DangerousForm): 

3772 """ 

3773 Form to add a special note to a task. 

3774 """ 

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

3776 _ = request.gettext 

3777 super().__init__(schema_class=AddSpecialNoteSchema, 

3778 submit_action=FormAction.SUBMIT, 

3779 submit_title=_("Add"), 

3780 request=request, **kwargs) 

3781 

3782 

3783class DeleteSpecialNoteSchema(CSRFSchema): 

3784 """ 

3785 Schema to add a special note to a task. 

3786 """ 

3787 note_id = HiddenIntegerNode() # must match ViewParam.NOTE_ID 

3788 danger = TranslatableValidateDangerousOperationNode() 

3789 

3790 

3791class DeleteSpecialNoteForm(DangerousForm): 

3792 """ 

3793 Form to delete (hide) a special note. 

3794 """ 

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

3796 _ = request.gettext 

3797 super().__init__(schema_class=DeleteSpecialNoteSchema, 

3798 submit_action=FormAction.SUBMIT, 

3799 submit_title=_("Delete"), 

3800 request=request, **kwargs) 

3801 

3802 

3803# ============================================================================= 

3804# The unusual data manipulation operations 

3805# ============================================================================= 

3806 

3807class EraseTaskSchema(HardWorkConfirmationSchema): 

3808 """ 

3809 Schema to erase a task. 

3810 """ 

3811 table_name = HiddenStringNode() # must match ViewParam.TABLENAME 

3812 server_pk = HiddenIntegerNode() # must match ViewParam.SERVER_PK 

3813 danger = TranslatableValidateDangerousOperationNode() 

3814 

3815 

3816class EraseTaskForm(DangerousForm): 

3817 """ 

3818 Form to erase a task. 

3819 """ 

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

3821 _ = request.gettext 

3822 super().__init__(schema_class=EraseTaskSchema, 

3823 submit_action=FormAction.DELETE, 

3824 submit_title=_("Erase"), 

3825 request=request, **kwargs) 

3826 

3827 

3828class DeletePatientChooseSchema(CSRFSchema): 

3829 """ 

3830 Schema to delete a patient. 

3831 """ 

3832 which_idnum = MandatoryWhichIdNumSelector() # must match ViewParam.WHICH_IDNUM # noqa 

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

3834 group_id = MandatoryGroupIdSelectorAdministeredGroups() # must match ViewParam.GROUP_ID # noqa 

3835 danger = TranslatableValidateDangerousOperationNode() 

3836 

3837 

3838class DeletePatientChooseForm(DangerousForm): 

3839 """ 

3840 Form to delete a patient. 

3841 """ 

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

3843 _ = request.gettext 

3844 super().__init__(schema_class=DeletePatientChooseSchema, 

3845 submit_action=FormAction.SUBMIT, 

3846 submit_title=_("Show tasks that will be deleted"), 

3847 request=request, **kwargs) 

3848 

3849 

3850class DeletePatientConfirmSchema(HardWorkConfirmationSchema): 

3851 """ 

3852 Schema to confirm deletion of a patient. 

3853 """ 

3854 which_idnum = HiddenIntegerNode() # must match ViewParam.WHICH_IDNUM 

3855 idnum_value = HiddenIntegerNode() # must match ViewParam.IDNUM_VALUE 

3856 group_id = HiddenIntegerNode() # must match ViewParam.GROUP_ID 

3857 danger = TranslatableValidateDangerousOperationNode() 

3858 

3859 

3860class DeletePatientConfirmForm(DangerousForm): 

3861 """ 

3862 Form to confirm deletion of a patient. 

3863 """ 

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

3865 _ = request.gettext 

3866 super().__init__(schema_class=DeletePatientConfirmSchema, 

3867 submit_action=FormAction.DELETE, 

3868 submit_title=_("Delete"), 

3869 request=request, **kwargs) 

3870 

3871 

3872class DeleteServerCreatedPatientSchema(HardWorkConfirmationSchema): 

3873 """ 

3874 Schema to delete a patient created on the server. 

3875 """ 

3876 # name must match ViewParam.SERVER_PK 

3877 server_pk = HiddenIntegerNode() 

3878 danger = TranslatableValidateDangerousOperationNode() 

3879 

3880 

3881class DeleteServerCreatedPatientForm(DeleteCancelForm): 

3882 """ 

3883 Form to delete a patient created on the server 

3884 """ 

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

3886 super().__init__(schema_class=DeleteServerCreatedPatientSchema, 

3887 request=request, **kwargs) 

3888 

3889 

3890EDIT_PATIENT_SIMPLE_PARAMS = [ 

3891 ViewParam.FORENAME, 

3892 ViewParam.SURNAME, 

3893 ViewParam.DOB, 

3894 ViewParam.SEX, 

3895 ViewParam.ADDRESS, 

3896 ViewParam.EMAIL, 

3897 ViewParam.GP, 

3898 ViewParam.OTHER, 

3899] 

3900 

3901 

3902class TaskScheduleSelector(SchemaNode, RequestAwareMixin): 

3903 """ 

3904 Drop-down with all available task schedules 

3905 """ 

3906 widget = SelectWidget() 

3907 

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

3909 self.title = "" # for type checker 

3910 self.name = "" # for type checker 

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

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

3913 

3914 # noinspection PyUnusedLocal 

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

3916 request = self.request 

3917 _ = request.gettext 

3918 self.title = _("Task schedule") 

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

3920 

3921 task_schedules = ( 

3922 request.dbsession.query(TaskSchedule) 

3923 .order_by(TaskSchedule.name) 

3924 ) 

3925 

3926 for task_schedule in task_schedules: 

3927 values.append((task_schedule.id, task_schedule.name)) 

3928 values, pv = get_values_and_permissible(values, add_none=False) 

3929 

3930 self.widget.values = values 

3931 self.validator = OneOf(pv) 

3932 

3933 @staticmethod 

3934 def schema_type() -> SchemaType: 

3935 return Integer() 

3936 

3937 

3938class JsonType(object): 

3939 """ 

3940 Schema type for JsonNode 

3941 """ 

3942 # noinspection PyMethodMayBeStatic, PyUnusedLocal 

3943 def deserialize(self, node: SchemaNode, 

3944 cstruct: Union[str, ColanderNullType, None]) -> Any: 

3945 # is null when form is empty 

3946 if cstruct in (null, None): 

3947 return None 

3948 

3949 cstruct: str 

3950 

3951 try: 

3952 # Validation happens on the widget class 

3953 json_value = json.loads(cstruct) 

3954 except json.JSONDecodeError: 

3955 return None 

3956 

3957 return json_value 

3958 

3959 # noinspection PyMethodMayBeStatic,PyUnusedLocal 

3960 def serialize( 

3961 self, 

3962 node: SchemaNode, 

3963 appstruct: Union[Dict, None, ColanderNullType]) \ 

3964 -> Union[str, ColanderNullType]: 

3965 # is null when form is empty (new record) 

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

3967 if appstruct in (null, None): 

3968 return null 

3969 

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

3971 # when reading from the database) 

3972 return json.dumps(appstruct) 

3973 

3974 

3975class JsonWidget(Widget): 

3976 """ 

3977 Widget supporting jsoneditor https://github.com/josdejong/jsoneditor 

3978 """ 

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

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

3981 form = "json.pt" 

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

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

3984 requirements = (('jsoneditor', None),) 

3985 

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

3987 super().__init__(**kwargs) 

3988 self.request = request 

3989 

3990 def serialize( 

3991 self, field: "Field", cstruct: Union[str, ColanderNullType], **kw: Any 

3992 ) -> Any: 

3993 if cstruct is null: 

3994 cstruct = "" 

3995 

3996 readonly = kw.get('readonly', self.readonly) 

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

3998 

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

4000 

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

4002 

4003 def deserialize( 

4004 self, field: "Field", pstruct: Union[str, ColanderNullType] 

4005 ) -> Union[str, ColanderNullType]: 

4006 # is empty string when field is empty 

4007 if pstruct in (null, ""): 

4008 return null 

4009 

4010 _ = self.request.gettext 

4011 error_message = _("Please enter valid JSON or leave blank") 

4012 

4013 pstruct: str 

4014 

4015 try: 

4016 json.loads(pstruct) 

4017 except json.JSONDecodeError: 

4018 raise Invalid(field, error_message, pstruct) 

4019 

4020 return pstruct 

4021 

4022 

4023class JsonNode(SchemaNode, RequestAwareMixin): 

4024 schema_type = JsonType 

4025 missing = null 

4026 

4027 # noinspection PyUnusedLocal,PyAttributeOutsideInit 

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

4029 self.widget = JsonWidget(self.request) 

4030 

4031 

4032class TaskScheduleNode(MappingSchema, RequestAwareMixin): 

4033 schedule_id = TaskScheduleSelector() # must match ViewParam.SCHEDULE_ID # noqa: E501 

4034 # must match ViewParam.START_DATETIME 

4035 start_datetime = StartPendulumSelector() 

4036 settings = JsonNode() # must match ViewParam.SETTINGS 

4037 

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

4039 self.title = "" # for type checker 

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

4041 

4042 # noinspection PyUnusedLocal 

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

4044 _ = self.gettext 

4045 self.title = _("Task schedule") 

4046 start_datetime = get_child_node(self, "start_datetime") 

4047 start_datetime.description = _( 

4048 "Leave blank for the date the patient first downloads the schedule" 

4049 ) 

4050 settings = get_child_node(self, "settings") 

4051 settings.title = _("Task-specific settings for this patient") 

4052 settings.description = _( 

4053 "ADVANCED. Only applicable to tasks that are configurable on a " 

4054 "per-patient basis. Format: JSON object, with settings keyed on " 

4055 "task table name." 

4056 ) 

4057 

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

4059 settings_value = value["settings"] 

4060 

4061 if settings_value is not None: 

4062 # will be None if JSON failed to validate 

4063 if not isinstance(settings_value, dict): 

4064 _ = self.request.gettext 

4065 error_message = _( 

4066 "Please enter a valid JSON object (with settings keyed on " 

4067 "task table name) or leave blank" 

4068 ) 

4069 

4070 raise Invalid(node, error_message) 

4071 

4072 

4073class TaskScheduleSequence(SequenceSchema, RequestAwareMixin): 

4074 task_schedule_sequence = TaskScheduleNode() 

4075 missing = drop 

4076 

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

4078 self.title = "" # for type checker 

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

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

4081 

4082 # noinspection PyUnusedLocal 

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

4084 _ = self.gettext 

4085 self.title = _("Task Schedules") 

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

4087 

4088 

4089class EditPatientSchema(CSRFSchema): 

4090 """ 

4091 Schema to edit a patient. 

4092 """ 

4093 server_pk = HiddenIntegerNode() # must match ViewParam.SERVER_PK 

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

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

4096 dob = DateSelectorNode() # must match ViewParam.DOB 

4097 sex = MandatorySexSelector() # must match ViewParam.SEX 

4098 address = OptionalStringNode() # must match ViewParam.ADDRESS 

4099 email = OptionalEmailNode() # must match ViewParam.EMAIL 

4100 gp = OptionalStringNode() # must match ViewParam.GP 

4101 other = OptionalStringNode() # must match ViewParam.OTHER 

4102 id_references = IdNumSequenceUniquePerWhichIdnum() # must match ViewParam.ID_REFERENCES # noqa 

4103 

4104 # noinspection PyUnusedLocal 

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

4106 _ = self.gettext 

4107 dob = get_child_node(self, "dob") 

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

4109 gp = get_child_node(self, "gp") 

4110 gp.title = _("GP") 

4111 

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

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

4114 dbsession = request.dbsession 

4115 group_id = value[ViewParam.GROUP_ID] 

4116 group = Group.get_group_by_id(dbsession, group_id) 

4117 testpatient = Patient() 

4118 for k in EDIT_PATIENT_SIMPLE_PARAMS: 

4119 setattr(testpatient, k, value[k]) 

4120 testpatient.idnums = [] 

4121 for idrefdict in value[ViewParam.ID_REFERENCES]: 

4122 pidnum = PatientIdNum() 

4123 pidnum.which_idnum = idrefdict[ViewParam.WHICH_IDNUM] 

4124 pidnum.idnum_value = idrefdict[ViewParam.IDNUM_VALUE] 

4125 testpatient.idnums.append(pidnum) 

4126 tk_finalize_policy = TokenizedPolicy(group.finalize_policy) 

4127 if not testpatient.satisfies_id_policy(tk_finalize_policy): 

4128 _ = self.gettext 

4129 raise Invalid( 

4130 node, 

4131 _("Patient would not meet 'finalize' ID policy for group:") 

4132 + f" {group.name}! [" + 

4133 _("That policy is:") + 

4134 f" {group.finalize_policy!r}]" 

4135 ) 

4136 

4137 

4138class DangerousEditPatientSchema(EditPatientSchema): 

4139 group_id = HiddenIntegerNode() # must match ViewParam.GROUP_ID 

4140 danger = TranslatableValidateDangerousOperationNode() 

4141 

4142 

4143class EditServerCreatedPatientSchema(EditPatientSchema): 

4144 # Must match ViewParam.GROUP_ID 

4145 group_id = MandatoryGroupIdSelectorAdministeredGroups( 

4146 insert_before="forename" 

4147 ) 

4148 task_schedules = TaskScheduleSequence() # must match ViewParam.TASK_SCHEDULES # noqa: E501 

4149 

4150 

4151class EditFinalizedPatientForm(DangerousForm): 

4152 """ 

4153 Form to edit a finalized patient. 

4154 """ 

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

4156 _ = request.gettext 

4157 super().__init__(schema_class=DangerousEditPatientSchema, 

4158 submit_action=FormAction.SUBMIT, 

4159 submit_title=_("Submit"), 

4160 request=request, **kwargs) 

4161 

4162 

4163class EditServerCreatedPatientForm(DynamicDescriptionsNonceForm): 

4164 """ 

4165 Form to add or edit a patient not yet on the device (for scheduled tasks) 

4166 """ 

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

4168 schema = EditServerCreatedPatientSchema().bind(request=request) 

4169 _ = request.gettext 

4170 super().__init__( 

4171 schema, 

4172 request=request, 

4173 buttons=[ 

4174 Button(name=FormAction.SUBMIT, title=_("Submit"), 

4175 css_class="btn-danger"), 

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

4177 ], 

4178 **kwargs 

4179 ) 

4180 

4181 

4182class EmailTemplateNode(OptionalStringNode, RequestAwareMixin): 

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

4184 self.title = "" # for type checker 

4185 self.description = "" # for type checker 

4186 self.formatter = TaskScheduleEmailTemplateFormatter() 

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

4188 

4189 # noinspection PyUnusedLocal 

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

4191 _ = self.gettext 

4192 self.title = _("Email template") 

4193 self.description = _( 

4194 "Template of email to be sent to patients when inviting them to " 

4195 "complete the tasks in the schedule. Valid placeholders: {}" 

4196 ).format(self.formatter.get_valid_parameters_string()) 

4197 

4198 # noinspection PyAttributeOutsideInit 

4199 self.widget = TextAreaWidget(rows=20, cols=80) 

4200 

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

4202 _ = self.gettext 

4203 

4204 try: 

4205 self.formatter.validate(value) 

4206 return 

4207 except KeyError as e: 

4208 error = _("{bad_key} is not a valid placeholder").format( 

4209 bad_key=e, 

4210 ) 

4211 except ValueError: 

4212 error = _( 

4213 "Invalid email template. Is there a missing '{' or '}' ?" 

4214 ) 

4215 

4216 raise Invalid(node, error) 

4217 

4218 

4219class TaskScheduleSchema(CSRFSchema): 

4220 name = OptionalStringNode() 

4221 group_id = MandatoryGroupIdSelectorAdministeredGroups() # must match ViewParam.GROUP_ID # noqa 

4222 email_subject = OptionalStringNode() 

4223 email_template = EmailTemplateNode() 

4224 

4225 

4226class EditTaskScheduleForm(DynamicDescriptionsNonceForm): 

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

4228 schema = TaskScheduleSchema().bind(request=request) 

4229 _ = request.gettext 

4230 super().__init__( 

4231 schema, 

4232 request=request, 

4233 buttons=[ 

4234 Button(name=FormAction.SUBMIT, title=_("Submit"), 

4235 css_class="btn-danger"), 

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

4237 ], 

4238 **kwargs 

4239 ) 

4240 

4241 

4242class DeleteTaskScheduleSchema(HardWorkConfirmationSchema): 

4243 """ 

4244 Schema to delete a task schedule. 

4245 """ 

4246 # name must match ViewParam.SCHEDULE_ID 

4247 schedule_id = HiddenIntegerNode() 

4248 danger = TranslatableValidateDangerousOperationNode() 

4249 

4250 

4251class DeleteTaskScheduleForm(DeleteCancelForm): 

4252 """ 

4253 Form to delete a task schedule. 

4254 """ 

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

4256 super().__init__(schema_class=DeleteTaskScheduleSchema, 

4257 request=request, **kwargs) 

4258 

4259 

4260class DurationWidget(Widget): 

4261 """ 

4262 Widget for entering a duration as a number of months, weeks and days. 

4263 The default template renders three text input fields. 

4264 Total days = (months * 30) + (weeks * 7) + days. 

4265 """ 

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

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

4268 form = "duration.pt" 

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

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

4271 

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

4273 super().__init__(**kwargs) 

4274 self.request = request 

4275 

4276 def serialize(self, 

4277 field: "Field", 

4278 cstruct: Union[Dict[str, Any], None, ColanderNullType], 

4279 **kw: Any) -> Any: 

4280 # called when rendering the form with values from DurationType.serialize 

4281 if cstruct in (None, null): 

4282 cstruct = {} 

4283 

4284 cstruct: Dict[str, Any] 

4285 

4286 months = cstruct.get("months", "") 

4287 weeks = cstruct.get("weeks", "") 

4288 days = cstruct.get("days", "") 

4289 

4290 kw.setdefault("months", months) 

4291 kw.setdefault("weeks", weeks) 

4292 kw.setdefault("days", days) 

4293 

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

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

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

4297 

4298 _ = self.request.gettext 

4299 

4300 values.update( 

4301 weeks_placeholder=_("1 week = 7 days"), 

4302 months_placeholder=_("1 month = 30 days"), 

4303 months_label=_("Months"), 

4304 weeks_label=_("Weeks"), 

4305 days_label=_("Days"), 

4306 ) 

4307 

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

4309 

4310 def deserialize( 

4311 self, 

4312 field: "Field", 

4313 pstruct: Union[Dict[str, Any], ColanderNullType] 

4314 ) -> Dict[str, int]: 

4315 # called when validating the form on submission 

4316 # value is passed to the schema deserialize() 

4317 

4318 if pstruct is null: 

4319 pstruct = {} 

4320 

4321 pstruct: Dict[str, Any] 

4322 

4323 errors = [] 

4324 

4325 try: 

4326 days = int(pstruct.get("days") or "0") 

4327 except ValueError: 

4328 errors.append("Please enter a valid number of days or leave blank") 

4329 

4330 try: 

4331 weeks = int(pstruct.get("weeks") or "0") 

4332 except ValueError: 

4333 errors.append("Please enter a valid number of weeks or leave blank") 

4334 

4335 try: 

4336 months = int(pstruct.get("months") or "0") 

4337 except ValueError: 

4338 errors.append( 

4339 "Please enter a valid number of months or leave blank" 

4340 ) 

4341 

4342 if len(errors) > 0: 

4343 raise Invalid(field, errors, pstruct) 

4344 

4345 # noinspection PyUnboundLocalVariable 

4346 return { 

4347 "days": days, 

4348 "months": months, 

4349 "weeks": weeks, 

4350 } 

4351 

4352 

4353class DurationType(object): 

4354 """ 

4355 Custom colander schema type to convert between Pendulum Duration objects 

4356 and months, weeks and days. 

4357 """ 

4358 

4359 # noinspection PyMethodMayBeStatic,PyUnusedLocal 

4360 def deserialize( 

4361 self, 

4362 node: SchemaNode, 

4363 cstruct: Union[Dict[str, Any], None, ColanderNullType]) \ 

4364 -> Optional[Duration]: 

4365 # called when validating the submitted form with the total days 

4366 # from DurationWidget.deserialize() 

4367 if cstruct in (None, null): 

4368 return None 

4369 

4370 cstruct: Dict[str, Any] 

4371 

4372 # may be passed invalid values when re-rendering widget with error 

4373 # messages 

4374 try: 

4375 days = int(cstruct.get("days") or "0") 

4376 except ValueError: 

4377 days = 0 

4378 

4379 try: 

4380 weeks = int(cstruct.get("weeks") or "0") 

4381 except ValueError: 

4382 weeks = 0 

4383 

4384 try: 

4385 months = int(cstruct.get("months") or "0") 

4386 except ValueError: 

4387 months = 0 

4388 

4389 total_days = months * 30 + weeks * 7 + days 

4390 

4391 return Duration(days=total_days) 

4392 

4393 # noinspection PyMethodMayBeStatic,PyUnusedLocal 

4394 def serialize( 

4395 self, 

4396 node: SchemaNode, 

4397 duration: Union[Duration, ColanderNullType]) \ 

4398 -> Union[Dict, ColanderNullType]: 

4399 if duration is null: 

4400 # For new schedule item 

4401 return null 

4402 

4403 duration: Duration 

4404 

4405 total_days = duration.in_days() 

4406 

4407 months = total_days // 30 

4408 weeks = (total_days % 30) // 7 

4409 days = (total_days % 30) % 7 

4410 

4411 # Existing schedule item 

4412 cstruct = { 

4413 "days": days, 

4414 "months": months, 

4415 "weeks": weeks, 

4416 } 

4417 

4418 return cstruct 

4419 

4420 

4421class DurationNode(SchemaNode, RequestAwareMixin): 

4422 schema_type = DurationType 

4423 

4424 # noinspection PyUnusedLocal,PyAttributeOutsideInit 

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

4426 self.widget = DurationWidget(self.request) 

4427 

4428 

4429class TaskScheduleItemSchema(CSRFSchema): 

4430 schedule_id = HiddenIntegerNode() # name must match ViewParam.SCHEDULE_ID 

4431 # name must match ViewParam.TABLE_NAME 

4432 table_name = MandatorySingleTaskSelector() 

4433 # name must match ViewParam.CLINICIAN_CONFIRMATION 

4434 clinician_confirmation = BooleanNode(default=False) 

4435 due_from = DurationNode() # name must match ViewParam.DUE_FROM 

4436 due_within = DurationNode() # name must match ViewParam.DUE_WITHIN 

4437 

4438 # noinspection PyUnusedLocal 

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

4440 _ = self.gettext 

4441 due_from = get_child_node(self, "due_from") 

4442 due_from.title = _("Due from") 

4443 due_from.description = _( 

4444 "Time from the start of schedule when the patient may begin this " 

4445 "task" 

4446 ) 

4447 due_within = get_child_node(self, "due_within") 

4448 due_within.title = _("Due within") 

4449 due_within.description = _( 

4450 "Time the patient has to complete this task" 

4451 ) 

4452 clinician_confirmation = get_child_node(self, "clinician_confirmation") 

4453 clinician_confirmation.title = _("Allow clinician tasks") 

4454 clinician_confirmation.label = None 

4455 clinician_confirmation.description = _( 

4456 "Tick this box to schedule a task that would normally be completed " 

4457 "by (or with) a clinician" 

4458 ) 

4459 

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

4461 task_class = self._get_task_class(value) 

4462 

4463 self._validate_clinician_status(node, value, task_class) 

4464 self._validate_due_dates(node, value) 

4465 self._validate_task_ip_use(node, value, task_class) 

4466 

4467 # noinspection PyMethodMayBeStatic 

4468 def _get_task_class(self, value: Dict[str, Any]) -> Type["Task"]: 

4469 return tablename_to_task_class_dict()[value[ViewParam.TABLE_NAME]] 

4470 

4471 def _validate_clinician_status(self, 

4472 node: SchemaNode, 

4473 value: Dict[str, Any], 

4474 task_class: Type["Task"]) -> None: 

4475 

4476 _ = self.gettext 

4477 clinician_confirmation = value[ViewParam.CLINICIAN_CONFIRMATION] 

4478 if task_class.has_clinician and not clinician_confirmation: 

4479 raise Invalid( 

4480 node, 

4481 _( 

4482 "You have selected the task '{task_name}', which a " 

4483 "patient would not normally complete by themselves. " 

4484 "If you are sure you want to do this, you must tick " 

4485 "'Allow clinician tasks'." 

4486 ).format(task_name=task_class.shortname) 

4487 ) 

4488 

4489 def _validate_due_dates(self, 

4490 node: SchemaNode, 

4491 value: Dict[str, Any]) -> None: 

4492 _ = self.gettext 

4493 due_from = value[ViewParam.DUE_FROM] 

4494 if due_from.total_days() < 0: 

4495 raise Invalid( 

4496 node, 

4497 _("'Due from' must be zero or more days"), 

4498 ) 

4499 

4500 due_within = value[ViewParam.DUE_WITHIN] 

4501 if due_within.total_days() <= 0: 

4502 raise Invalid( 

4503 node, 

4504 _("'Due within' must be more than zero days"), 

4505 ) 

4506 

4507 def _validate_task_ip_use(self, 

4508 node: SchemaNode, 

4509 value: Dict[str, Any], 

4510 task_class: Type["Task"]) -> None: 

4511 

4512 _ = self.gettext 

4513 

4514 if not task_class.prohibits_anything(): 

4515 return 

4516 

4517 schedule_id = value[ViewParam.SCHEDULE_ID] 

4518 schedule = self.request.dbsession.query(TaskSchedule).filter( 

4519 TaskSchedule.id == schedule_id 

4520 ).one() 

4521 

4522 if schedule.group.ip_use is None: 

4523 raise Invalid( 

4524 node, _( 

4525 "The task you have selected prohibits use in certain " 

4526 "contexts. The group '{group_name}' has no intellectual " 

4527 "property settings. " 

4528 "You need to edit the group '{group_name}' to say which " 

4529 "contexts it operates in.".format( 

4530 group_name=schedule.group.name 

4531 ) 

4532 ) 

4533 ) 

4534 

4535 # TODO: One the client we say 'to use this task, you must seek 

4536 # permission from the copyright holder'. We could do the same but at the 

4537 # moment there isn't a way of telling the system that we have done so. 

4538 if task_class.prohibits_commercial and schedule.group.ip_use.commercial: 

4539 raise Invalid( 

4540 node, 

4541 _("The group '{group_name}' associated with schedule " 

4542 "'{schedule_name}' operates in a " 

4543 "commercial context but the task you have selected " 

4544 "prohibits commercial use.").format( 

4545 group_name=schedule.group.name, 

4546 schedule_name=schedule.name 

4547 ) 

4548 ) 

4549 

4550 if task_class.prohibits_clinical and schedule.group.ip_use.clinical: 

4551 raise Invalid( 

4552 node, 

4553 _("The group '{group_name}' associated with schedule " 

4554 "'{schedule_name}' operates in a " 

4555 "clinical context but the task you have selected " 

4556 "prohibits clinical use.").format( 

4557 group_name=schedule.group.name, 

4558 schedule_name=schedule.name 

4559 ) 

4560 ) 

4561 

4562 if task_class.prohibits_educational and schedule.group.ip_use.educational: # noqa 

4563 raise Invalid( 

4564 node, 

4565 _("The group '{group_name}' associated with schedule " 

4566 "'{schedule_name}' operates in an " 

4567 "educational context but the task you have selected " 

4568 "prohibits educational use.").format( 

4569 group_name=schedule.group.name, 

4570 schedule_name=schedule.name 

4571 ) 

4572 ) 

4573 

4574 if task_class.prohibits_research and schedule.group.ip_use.research: 

4575 raise Invalid( 

4576 node, 

4577 _("The group '{group_name}' associated with schedule " 

4578 "'{schedule_name}' operates in a " 

4579 "research context but the task you have selected " 

4580 "prohibits research use.").format( 

4581 group_name=schedule.group.name, 

4582 schedule_name=schedule.name 

4583 ) 

4584 ) 

4585 

4586 

4587class EditTaskScheduleItemForm(DynamicDescriptionsNonceForm): 

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

4589 schema = TaskScheduleItemSchema().bind(request=request) 

4590 _ = request.gettext 

4591 super().__init__( 

4592 schema, 

4593 request=request, 

4594 buttons=[ 

4595 Button(name=FormAction.SUBMIT, title=_("Submit"), 

4596 css_class="btn-danger"), 

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

4598 ], 

4599 **kwargs 

4600 ) 

4601 

4602 

4603class DeleteTaskScheduleItemSchema(HardWorkConfirmationSchema): 

4604 """ 

4605 Schema to delete a task schedule item. 

4606 """ 

4607 # name must match ViewParam.SCHEDULE_ITEM_ID 

4608 schedule_item_id = HiddenIntegerNode() 

4609 danger = TranslatableValidateDangerousOperationNode() 

4610 

4611 

4612class DeleteTaskScheduleItemForm(DeleteCancelForm): 

4613 """ 

4614 Form to delete a task schedule item. 

4615 """ 

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

4617 super().__init__(schema_class=DeleteTaskScheduleItemSchema, 

4618 request=request, **kwargs) 

4619 

4620 

4621class ForciblyFinalizeChooseDeviceSchema(CSRFSchema): 

4622 """ 

4623 Schema to force-finalize records from a device. 

4624 """ 

4625 device_id = MandatoryDeviceIdSelector() # must match ViewParam.DEVICE_ID 

4626 danger = TranslatableValidateDangerousOperationNode() 

4627 

4628 

4629class ForciblyFinalizeChooseDeviceForm(SimpleSubmitForm): 

4630 """ 

4631 Form to force-finalize records from a device. 

4632 """ 

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

4634 _ = request.gettext 

4635 super().__init__(schema_class=ForciblyFinalizeChooseDeviceSchema, 

4636 submit_title=_("View affected tasks"), 

4637 request=request, **kwargs) 

4638 

4639 

4640class ForciblyFinalizeConfirmSchema(HardWorkConfirmationSchema): 

4641 """ 

4642 Schema to confirm force-finalizing of a device. 

4643 """ 

4644 device_id = HiddenIntegerNode() # must match ViewParam.DEVICE_ID 

4645 danger = TranslatableValidateDangerousOperationNode() 

4646 

4647 

4648class ForciblyFinalizeConfirmForm(DangerousForm): 

4649 """ 

4650 Form to confirm force-finalizing of a device. 

4651 """ 

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

4653 _ = request.gettext 

4654 super().__init__(schema_class=ForciblyFinalizeConfirmSchema, 

4655 submit_action=FormAction.FINALIZE, 

4656 submit_title=_("Forcibly finalize"), 

4657 request=request, **kwargs) 

4658 

4659 

4660# ============================================================================= 

4661# User downloads 

4662# ============================================================================= 

4663 

4664class UserDownloadDeleteSchema(CSRFSchema): 

4665 """ 

4666 Schema to capture details of a file to be deleted. 

4667 """ 

4668 filename = HiddenStringNode() # name must match ViewParam.FILENAME 

4669 

4670 

4671class UserDownloadDeleteForm(SimpleSubmitForm): 

4672 """ 

4673 Form that provides a single button to delete a user download. 

4674 """ 

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

4676 _ = request.gettext 

4677 super().__init__(schema_class=UserDownloadDeleteSchema, 

4678 submit_title=_("Delete"), 

4679 request=request, **kwargs)