Coverage for cc_modules/cc_forms.py: 52%

1"""

2 camcops_server/cc_modules/cc_forms.py

3

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

5

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

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

8

9 This file is part of CamCOPS.

10

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

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

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

14 (at your option) any later version.

15

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

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

18 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the

19 GNU General Public License for more details.

20

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

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

23

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

25

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

27

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

29

30*COLANDER NODES, NULLS, AND VALIDATION*

31

32- Surprisingly tricky.

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

34 the Deform framework clones them.

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

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

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

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

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

40

41- Note that this error:

42

43 .. code-block:: none

44

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

46

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

48

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

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

51

52*ACCESSING THE PYRAMID REQUEST IN FORMS AND SCHEMAS*

53

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

55 sometimes more specialized reasons.

56

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

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

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

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

61

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

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

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

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

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

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

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

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

70

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

72 sort of ``__init__`` function:

73

74.. code-block:: python

75

76 class SomeForm(...):

77 def __init__(...):

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

79 super().__init__(

80 schema,

81 ...,

82 **kwargs

83 )

84

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

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

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

88 of binding a request).

89

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

91 request.gettext``.

92

93 Form titles need to be dynamically written via

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

95

96.. glossary::

97

98 cstruct

99 See `cstruct

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

101 in the Deform_ docs.

102

103 Colander

104 See `Colander

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

106 in the Deform_ docs.

107

108 field

109 See `field

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

111 in the Deform_ docs.

112

113 Peppercorn

114 See `Peppercorn

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

116 in the Deform_ docs.

117

118 pstruct

119 See `pstruct

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

121 in the Deform_ docs.

122

123"""

124

125 from io import BytesIO

126 import json

127 import logging

128 import os

129 from typing import (

130 Any,

131 Callable,

132 Dict,

133 List,

134 Optional,

135 Tuple,

136 Type,

137 TYPE_CHECKING,

138 Union,

139)

140

141 from cardinal_pythonlib.colander_utils import (

142 AllowNoneType,

143 BooleanNode,

144 DateSelectorNode,

145 DateTimeSelectorNode,

146 DEFAULT_WIDGET_DATE_OPTIONS_FOR_PENDULUM,

147 DEFAULT_WIDGET_TIME_OPTIONS_FOR_PENDULUM,

148 get_child_node,

149 get_values_and_permissible,

150 HiddenIntegerNode,

151 HiddenStringNode,

152 MandatoryEmailNode,

153 MandatoryStringNode,

154 OptionalEmailNode,

155 OptionalIntNode,

156 OptionalPendulumNode,

157 OptionalStringNode,

158 ValidateDangerousOperationNode,

159)

160 from cardinal_pythonlib.deform_utils import (

161 DynamicDescriptionsForm,

162 InformativeForm,

163)

164 from cardinal_pythonlib.httpconst import HttpMethod

165 from cardinal_pythonlib.logs import BraceStyleAdapter

166 from cardinal_pythonlib.sqlalchemy.dialect import SqlaDialectName

167 from cardinal_pythonlib.sqlalchemy.orm_query import CountStarSpecializedQuery

168

169# noinspection PyProtectedMember

170 from colander import (

171 Boolean,

172 Date,

173 drop,

174 Integer,

175 Invalid,

176 Length,

177 MappingSchema,

178 null,

179 OneOf,

180 Range,

181 Schema,

182 SchemaNode,

183 SchemaType,

184 SequenceSchema,

185 Set,

186 String,

187 _null,

188 url,

189)

190 from deform.form import Button

191 from deform.widget import (

192 CheckboxChoiceWidget,

193 CheckedPasswordWidget,

194 # DateInputWidget,

195 DateTimeInputWidget,

196 FormWidget,

197 HiddenWidget,

198 MappingWidget,

199 PasswordWidget,

200 RadioChoiceWidget,

201 RichTextWidget,

202 SelectWidget,

203 SequenceWidget,

204 TextAreaWidget,

205 TextInputWidget,

206 Widget,

207)

208

209 from pendulum import Duration

210 import phonenumbers

211 import pyotp

212 import qrcode

213 import qrcode.image.svg

214

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

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

217 from camcops_server.cc_modules.cc_baseconstants import (

218 DEFORM_SUPPORTS_CSP_NONCE,

219 TEMPLATE_DIR,

220)

221 from camcops_server.cc_modules.cc_constants import (

222 ConfigParamSite,

223 DEFAULT_ROWS_PER_PAGE,

224 MfaMethod,

225 MINIMUM_PASSWORD_LENGTH,

226 SEX_OTHER_UNSPECIFIED,

227 SEX_FEMALE,

228 SEX_MALE,

229 StringLengths,

230 USER_NAME_FOR_SYSTEM,

231)

232 from camcops_server.cc_modules.cc_group import Group

233 from camcops_server.cc_modules.cc_idnumdef import (

234 IdNumDefinition,

235 ID_NUM_VALIDATION_METHOD_CHOICES,

236 validate_id_number,

237)

238 from camcops_server.cc_modules.cc_ipuse import IpUse

239 from camcops_server.cc_modules.cc_language import (

240 DEFAULT_LOCALE,

241 POSSIBLE_LOCALES,

242 POSSIBLE_LOCALES_WITH_DESCRIPTIONS,

243)

244 from camcops_server.cc_modules.cc_patient import Patient

245 from camcops_server.cc_modules.cc_patientidnum import PatientIdNum

246 from camcops_server.cc_modules.cc_policy import (

247 TABLET_ID_POLICY_STR,

248 TokenizedPolicy,

249)

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

251 from camcops_server.cc_modules.cc_task import tablename_to_task_class_dict

252 from camcops_server.cc_modules.cc_taskschedule import (

253 TaskSchedule,

254 TaskScheduleEmailTemplateFormatter,

255)

256 from camcops_server.cc_modules.cc_validators import (

257 ALPHANUM_UNDERSCORE_CHAR,

258 validate_anything,

259 validate_by_char_and_length,

260 validate_download_filename,

261 validate_group_name,

262 validate_hl7_aa,

263 validate_hl7_id_type,

264 validate_ip_address,

265 validate_new_password,

266 validate_redirect_url,

267 validate_username,

268)

269

270 if TYPE_CHECKING:

271 from deform.field import Field

272 from camcops_server.cc_modules.cc_request import CamcopsRequest

273 from camcops_server.cc_modules.cc_task import Task

274 from camcops_server.cc_modules.cc_user import User

275

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

277

278 ColanderNullType = _null

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

280

281

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

283# Debugging options

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

285

286 DEBUG_CSRF_CHECK = False

287

288 if DEBUG_CSRF_CHECK:

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

290

291

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

293# Constants

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

295

296 DEFORM_ACCORDION_BUG = True

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

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

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

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

301

302

303 class Binding(object):

304 """

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

306

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

308 """

309

310 GROUP = "group"

311 OPEN_ADMIN = "open_admin"

312 OPEN_WHAT = "open_what"

313 OPEN_WHEN = "open_when"

314 OPEN_WHO = "open_who"

315 REQUEST = "request"

316 TRACKER_TASKS_ONLY = "tracker_tasks_only"

317 USER = "user"

318

319

320 class BootstrapCssClasses(object):

321 """

322 Constants from Bootstrap to control display.

323 """

324

325 FORM_INLINE = "form-inline"

326 RADIO_INLINE = "radio-inline"

327 LIST_INLINE = "list-inline"

328 CHECKBOX_INLINE = "checkbox-inline"

329

330

331 AUTOCOMPLETE_ATTR = "autocomplete"

332

333

334 class AutocompleteAttrValues(object):

335 """

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

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

338 Not all are used.

339 """

340

341 BDAY = "bday"

342 CURRENT_PASSWORD = "current-password"

343 EMAIL = "email"

344 FAMILY_NAME = "family-name"

345 GIVEN_NAME = "given-name"

346 NEW_PASSWORD = "new-password"

347 OFF = "off"

348 ON = "on" # browser decides

349 STREET_ADDRESS = "stree-address"

350 USERNAME = "username"

351

352

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

354 return {

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

356 "menubar": "false",

357 "plugins": "link",

358 "toolbar": (

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

360 "bullist numlist | "

361 "alignleft aligncenter alignright alignjustify | "

362 "outdent indent"

363 ),

364 "language": request.language_iso_639_1,

365 }

366

367

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

369# Common phrases for translation

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

371

372

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

374 _ = request.gettext

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

376

377

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

379 _ = request.gettext

380 return _("Change password")

381

382

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

384 _ = request.gettext

385 return [

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

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

388 # TRANSLATOR: sex code description

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

390 ]

391

392

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

394# Deform bug fix: SelectWidget "multiple" attribute

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

396

397

398 class BugfixSelectWidget(SelectWidget):

399 """

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

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

402

403 .. code-block:: none

404

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

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

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

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

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

410 </select>

411

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

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

414 Chameleon that exposed a bug in Deform.)

415

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

417 """ # noqa

418

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

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

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

422

423

424 SelectWidget = BugfixSelectWidget

425

426

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

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

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

430

431

432 class InformativeNonceForm(InformativeForm):

433 """

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

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

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

437

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

439 """

440

441 if DEFORM_SUPPORTS_CSP_NONCE:

442

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

444 request = schema.request # type: CamcopsRequest

445 kwargs["nonce"] = request.nonce

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

447

448

449 class DynamicDescriptionsNonceForm(DynamicDescriptionsForm):

450 """

451 Similarly; see :class:`InformativeNonceForm`.

452

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

454 """

455

456 if DEFORM_SUPPORTS_CSP_NONCE:

457

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

459 request = schema.request # type: CamcopsRequest

460 kwargs["nonce"] = request.nonce

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

462

463

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

465# Mixin for Schema/SchemaNode objects for translation

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

467

468 GETTEXT_TYPE = Callable[[str], str]

469

470

471 class RequestAwareMixin(object):

472 """

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

474 together with some translations and other convenience functions.

475 """

476

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

478 # Stop multiple inheritance complaints

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

480

481 # noinspection PyUnresolvedReferences

482 @property

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

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

485

486 # noinspection PyUnresolvedReferences,PyPropertyDefinition

487 @property

488 def gettext(self) -> GETTEXT_TYPE:

489 return self.request.gettext

490

491 @property

492 def or_join_description(self) -> str:

493 return or_join_description(self.request)

494

495

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

497# Translatable version of ValidateDangerousOperationNode

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

499

500

501 class TranslatableValidateDangerousOperationNode(

502 ValidateDangerousOperationNode, RequestAwareMixin

503):

504 """

505 Translatable version of ValidateDangerousOperationNode.

506 """

507

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

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

510 _ = self.gettext

511 node.title = _("Danger")

512 user_entry = get_child_node(self, "user_entry")

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

514

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

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

517 _ = self.gettext

518 user_entry = get_child_node(self, "user_entry")

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

520 user_entry.description = prefix + target_value

521

522

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

524# Translatable version of SequenceWidget

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

526

527

528 class TranslatableSequenceWidget(SequenceWidget):

529 """

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

531 request-specific way.

532 """

533

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

535 super().__init__(**kwargs)

536 _ = request.gettext

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

538

539

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

541# Translatable version of OptionalPendulumNode

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

543

544

545 class TranslatableOptionalPendulumNode(

546 OptionalPendulumNode, RequestAwareMixin

547):

548 """

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

550 the request.

551

552 .. todo:: TranslatableOptionalPendulumNode not fully implemented

553 """

554

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

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

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

558

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

560 _ = self.gettext

561 self.widget = DateTimeInputWidget(

562 date_options=DEFAULT_WIDGET_DATE_OPTIONS_FOR_PENDULUM,

563 time_options=DEFAULT_WIDGET_TIME_OPTIONS_FOR_PENDULUM,

564 )

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

566 # self.widget.__dict__)

567

568

569 class TranslatableDateTimeSelectorNode(

570 DateTimeSelectorNode, RequestAwareMixin

571):

572 """

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

574 the request.

575

576 .. todo:: TranslatableDateTimeSelectorNode not fully implemented

577 """

578

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

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

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

582

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

584 _ = self.gettext

585 self.widget = DateTimeInputWidget()

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

587 # self.widget.__dict__)

588

589

590'''

591 class TranslatableDateSelectorNode(DateSelectorNode,

592 RequestAwareMixin):

593 """

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

595 the request.

596

597 .. todo:: TranslatableDateSelectorNode not fully implemented

598 """

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

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

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

602

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

604 _ = self.gettext

605 self.widget = DateInputWidget()

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

607 # self.widget.__dict__)

608'''

609

610

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

612# CSRF

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

614

615

616 class CSRFToken(SchemaNode, RequestAwareMixin):

617 """

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

619 form.

620

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

622 more recent Colander API.

623

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

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

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

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

628 mechanism

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

630

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

632

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

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

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

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

637

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

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

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

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

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

643 value."

644

645 RNC: Serialized values are always STRINGS.

646

647 """

648

649 schema_type = String

650 default = ""

651 missing = ""

652 title = " "

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

654 # fails

655 widget = HiddenWidget()

656

657 # noinspection PyUnusedLocal

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

659 request = self.request

660 csrf_token = request.session.get_csrf_token()

661 if DEBUG_CSRF_CHECK:

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

663 self.default = csrf_token

664

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

666 # Deferred validator via method, as per

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

668 request = self.request

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

670 matches = value == csrf_token

671 if DEBUG_CSRF_CHECK:

672 log.debug(

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

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

675 value,

676 csrf_token,

677 matches,

678 )

679 if not matches:

680 log.warning(

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

682 )

683 _ = request.gettext

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

685

686

687 class CSRFSchema(Schema, RequestAwareMixin):

688 """

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

690 forgery) tokens.

691

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

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

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

695

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

697 or you get:

698

699 .. code-block:: none

700

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

702 __RequestVerificationToken, csrfmiddlewaretoken, authenticity_token,

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

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

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

706

707 """

708

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

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

711

712

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

714# Horizontal forms

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

716

717

718 class HorizontalFormWidget(FormWidget):

719 """

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

721

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

723 are resolved to Chameleon ZPT (Zope) templates.

724

725 See

726

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

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

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

730 """ # noqa

731

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

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

734 form = "horizontal_form.pt"

735 mapping_item = "horizontal_mapping_item.pt"

736

737 template = os.path.join(

738 basedir, form

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

740 readonly_template = os.path.join(

741 readonlydir, form

742 ) # default "readonly/form"

743 item_template = os.path.join(

744 basedir, mapping_item

745 ) # default "mapping_item"

746 readonly_item_template = os.path.join(

747 readonlydir, mapping_item

748 ) # default "readonly/mapping_item"

749

750

751 class HorizontalFormMixin(object):

752 """

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

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

755 """

756

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

758 kwargs = kwargs or {}

759

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

761 # extra_classes = "form-inline"

762 # if "css_class" in kwargs:

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

764 # else:

765 # kwargs["css_class"] = extra_classes

766

767 # Method 2: change the widget

768 schema.widget = HorizontalFormWidget()

769

770 # OK, proceed.

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

772

773

774 def add_css_class(

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

776) -> None:

777 """

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

779 parameter.

780

781 Args:

782 kwargs: a dictionary

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

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

785 """

786 if param_name in kwargs:

787 kwargs[param_name] += " " + extra_classes

788 else:

789 kwargs[param_name] = extra_classes

790

791

792 class FormInlineCssMixin(object):

793 """

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

795 has the effect of wrapping everything horizontally.

796

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

798 inheritance order.

799 """

800

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

802 kwargs = kwargs or {}

803 add_css_class(kwargs, BootstrapCssClasses.FORM_INLINE)

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

805

806

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

808 """

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

810 """

811 widget.item_css_class = BootstrapCssClasses.FORM_INLINE

812

813

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

815 """

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

817

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

819 constructor.

820 """

821 make_widget_horizontal(node.widget)

822

823

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

825# Specialized Form classes

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

827

828

829 class SimpleSubmitForm(InformativeNonceForm):

830 """

831 Form with a simple "submit" button.

832 """

833

834 def __init__(

835 self,

836 schema_class: Type[Schema],

837 submit_title: str,

838 request: "CamcopsRequest",

839 **kwargs: Any,

840 ) -> None:

841 """

842 Args:

843 schema_class:

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

845 schema

846 submit_title:

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

848 request:

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

850 """

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

852 super().__init__(

853 schema,

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

855 **kwargs,

856 )

857

858

859 class OkForm(SimpleSubmitForm):

860 """

861 Form with a button that says "OK".

862 """

863

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

865 _ = request.gettext

866 super().__init__(

867 schema_class=CSRFSchema,

868 submit_title=_("OK"),

869 request=request,

870 **kwargs,

871 )

872

873

874 class ApplyCancelForm(InformativeNonceForm):

875 """

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

877 """

878

879 def __init__(

880 self,

881 schema_class: Type[Schema],

882 request: "CamcopsRequest",

883 **kwargs: Any,

884 ) -> None:

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

886 _ = request.gettext

887 super().__init__(

888 schema,

889 buttons=[

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

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

892 ],

893 **kwargs,

894 )

895

896

897 class AddCancelForm(InformativeNonceForm):

898 """

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

900 """

901

902 def __init__(

903 self,

904 schema_class: Type[Schema],

905 request: "CamcopsRequest",

906 **kwargs: Any,

907 ) -> None:

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

909 _ = request.gettext

910 super().__init__(

911 schema,

912 buttons=[

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

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

915 ],

916 **kwargs,

917 )

918

919

920 class DangerousForm(DynamicDescriptionsNonceForm):

921 """

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

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

924 "Cancel" button.

925 """

926

927 def __init__(

928 self,

929 schema_class: Type[Schema],

930 submit_action: str,

931 submit_title: str,

932 request: "CamcopsRequest",

933 **kwargs: Any,

934 ) -> None:

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

936 _ = request.gettext

937 super().__init__(

938 schema,

939 buttons=[

940 Button(

941 name=submit_action,

942 title=submit_title,

943 css_class="btn-danger",

944 ),

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

946 ],

947 **kwargs,

948 )

949

950

951 class DeleteCancelForm(DangerousForm):

952 """

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

954 button.

955 """

956

957 def __init__(

958 self,

959 schema_class: Type[Schema],

960 request: "CamcopsRequest",

961 **kwargs: Any,

962 ) -> None:

963 _ = request.gettext

964 super().__init__(

965 schema_class=schema_class,

966 submit_action=FormAction.DELETE,

967 submit_title=_("Delete"),

968 request=request,

969 **kwargs,

970 )

971

972

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

974# Specialized SchemaNode classes used in several contexts

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

976

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

978# Task types

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

980

981

982 class OptionalSingleTaskSelector(OptionalStringNode, RequestAwareMixin):

983 """

984 Node to pick one task type.

985 """

986

987 def __init__(

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

989 ) -> None:

990 """

991 Args:

992 tracker_tasks_only: restrict the choices to tasks that offer

993 trackers.

994 """

995 self.title = "" # for type checker

996 self.tracker_tasks_only = tracker_tasks_only

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

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

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

1000

1001 # noinspection PyUnusedLocal

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

1003 _ = self.gettext

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

1005 if Binding.TRACKER_TASKS_ONLY in kw:

1006 self.tracker_tasks_only = kw[Binding.TRACKER_TASKS_ONLY]

1007 values, pv = get_values_and_permissible(

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

1009 )

1010 self.widget = SelectWidget(values=values)

1011 self.validator = OneOf(pv)

1012

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

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

1015

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

1017 for tc in Task.all_subclasses_by_shortname():

1018 if self.tracker_tasks_only and not tc.provides_trackers:

1019 continue

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

1021 return choices

1022

1023

1024 class MandatorySingleTaskSelector(MandatoryStringNode, RequestAwareMixin):

1025 """

1026 Node to pick one task type.

1027 """

1028

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

1030 self.title = "" # for type checker

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

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

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

1034

1035 # noinspection PyUnusedLocal

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

1037 _ = self.gettext

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

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

1040 self.widget = SelectWidget(values=values)

1041 self.validator = OneOf(pv)

1042

1043 @staticmethod

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

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

1046

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

1048 for tc in Task.all_subclasses_by_shortname():

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

1050 return choices

1051

1052

1053 class MultiTaskSelector(SchemaNode, RequestAwareMixin):

1054 """

1055 Node to select multiple task types.

1056 """

1057

1058 schema_type = Set

1059 default = ""

1060 missing = ""

1061

1062 def __init__(

1063 self,

1064 *args: Any,

1065 tracker_tasks_only: bool = False,

1066 minimum_number: int = 0,

1067 **kwargs: Any,

1068 ) -> None:

1069 self.tracker_tasks_only = tracker_tasks_only

1070 self.minimum_number = minimum_number

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

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

1073 self.title = "" # for type checker

1074 self.description = "" # for type checker

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

1076

1077 # noinspection PyUnusedLocal

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

1079 _ = self.gettext

1080 request = self.request # noqa: F841

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

1082 self.description = (

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

1084 + " "

1085 + self.or_join_description

1086 )

1087 if Binding.TRACKER_TASKS_ONLY in kw:

1088 self.tracker_tasks_only = kw[Binding.TRACKER_TASKS_ONLY]

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

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

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

1092

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

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

1095

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

1097 for tc in Task.all_subclasses_by_shortname():

1098 if self.tracker_tasks_only and not tc.provides_trackers:

1099 continue

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

1101 return choices

1102

1103

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

1105# Use the task index?

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

1107

1108

1109 class ViaIndexSelector(BooleanNode, RequestAwareMixin):

1110 """

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

1112 Default is true.

1113 """

1114

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

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

1117

1118 # noinspection PyUnusedLocal

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

1120 _ = self.gettext

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

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

1123

1124

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

1126# ID numbers

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

1128

1129

1130 class MandatoryWhichIdNumSelector(SchemaNode, RequestAwareMixin):

1131 """

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

1133 or "study Blah ID number").

1134 """

1135

1136 widget = SelectWidget()

1137

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

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

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

1141 # OptionalWhichIdNumSelector

1142 self.allow_none = False

1143 self.title = "" # for type checker

1144 self.description = "" # for type checker

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

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

1147

1148 # noinspection PyUnusedLocal

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

1150 request = self.request

1151 _ = request.gettext

1152 self.title = _("Identifier")

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

1154 for iddef in request.idnum_definitions:

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

1156 values, pv = get_values_and_permissible(

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

1158 )

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

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

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

1162 # SelectWidget.

1163 self.widget.values = values

1164 self.validator = OneOf(pv)

1165

1166 @staticmethod

1167 def schema_type() -> SchemaType:

1168 return Integer()

1169

1170

1171 class LinkingIdNumSelector(MandatoryWhichIdNumSelector):

1172 """

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

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

1175 """

1176

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

1178 super().after_bind(node, kw)

1179 _ = self.gettext

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

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

1182

1183

1184 class MandatoryIdNumValue(SchemaNode, RequestAwareMixin):

1185 """

1186 Mandatory node to capture an ID number value.

1187 """

1188

1189 schema_type = Integer

1190 validator = Range(min=0)

1191

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

1193 self.title = "" # for type checker

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

1195

1196 # noinspection PyUnusedLocal

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

1198 _ = self.gettext

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

1200

1201

1202 class MandatoryIdNumNode(MappingSchema, RequestAwareMixin):

1203 """

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

1205 ID number (value).

1206

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

1208 """

1209

1210 which_idnum = (

1211 MandatoryWhichIdNumSelector()

1212 ) # must match ViewParam.WHICH_IDNUM

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

1214

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

1216 self.title = "" # for type checker

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

1218

1219 # noinspection PyUnusedLocal

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

1221 _ = self.gettext

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

1223

1224 # noinspection PyMethodMayBeStatic

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

1226 assert isinstance(value, dict)

1227 req = self.request

1228 _ = req.gettext

1229 which_idnum = value[ViewParam.WHICH_IDNUM]

1230 idnum_value = value[ViewParam.IDNUM_VALUE]

1231 idnum_def = req.get_idnum_definition(which_idnum)

1232 if not idnum_def:

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

1234 method = idnum_def.validation_method

1235 if method:

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

1237 if not valid:

1238 raise Invalid(node, why_invalid)

1239

1240

1241 class IdNumSequenceAnyCombination(SequenceSchema, RequestAwareMixin):

1242 """

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

1244 """

1245

1246 idnum_sequence = MandatoryIdNumNode()

1247

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

1249 self.title = "" # for type checker

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

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

1252

1253 # noinspection PyUnusedLocal

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

1255 _ = self.gettext

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

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

1258

1259 # noinspection PyMethodMayBeStatic

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

1261 assert isinstance(value, list)

1262 list_of_lists = [

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

1264 ]

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

1266 _ = self.gettext

1267 raise Invalid(

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

1269 )

1270

1271

1272 class IdNumSequenceUniquePerWhichIdnum(SequenceSchema, RequestAwareMixin):

1273 """

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

1275 up to one per ID number type.

1276 """

1277

1278 idnum_sequence = MandatoryIdNumNode()

1279

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

1281 self.title = "" # for type checker

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

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

1284

1285 # noinspection PyUnusedLocal

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

1287 _ = self.gettext

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

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

1290

1291 # noinspection PyMethodMayBeStatic

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

1293 assert isinstance(value, list)

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

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

1296 _ = self.gettext

1297 raise Invalid(

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

1299 )

1300

1301

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

1303# Sex

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

1305

1306

1307 class OptionalSexSelector(OptionalStringNode, RequestAwareMixin):

1308 """

1309 Optional node to choose sex.

1310 """

1311

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

1313 self.title = "" # for type checker

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

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

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

1317

1318 # noinspection PyUnusedLocal

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

1320 _ = self.gettext

1321 self.title = _("Sex")

1322 choices = sex_choices(self.request)

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

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

1325 self.validator = OneOf(pv)

1326

1327

1328 class MandatorySexSelector(MandatoryStringNode, RequestAwareMixin):

1329 """

1330 Mandatory node to choose sex.

1331 """

1332

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

1334 self.title = "" # for type checker

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

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

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

1338

1339 # noinspection PyUnusedLocal

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

1341 _ = self.gettext

1342 self.title = _("Sex")

1343 choices = sex_choices(self.request)

1344 values, pv = get_values_and_permissible(choices)

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

1346 self.validator = OneOf(pv)

1347

1348

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

1350# Users

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

1352

1353

1354 class MandatoryUserIdSelectorUsersAllowedToSee(SchemaNode, RequestAwareMixin):

1355 """

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

1357 is allowed to see.

1358 """

1359

1360 schema_type = Integer

1361

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

1363 self.title = "" # for type checker

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

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

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

1367

1368 # noinspection PyUnusedLocal

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

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

1371

1372 _ = self.gettext

1373 self.title = _("User")

1374 request = self.request

1375 dbsession = request.dbsession

1376 user = request.user

1377 if user.superuser:

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

1379 else:

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

1381 my_allowed_group_ids = user.ids_of_groups_user_may_see

1382 users = (

1383 dbsession.query(User)

1384 .join(Group)

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

1386 .order_by(User.username)

1387 )

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

1389 for user in users:

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

1391 values, pv = get_values_and_permissible(values, False)

1392 self.widget = SelectWidget(values=values)

1393 self.validator = OneOf(pv)

1394

1395

1396 class OptionalUserNameSelector(OptionalStringNode, RequestAwareMixin):

1397 """

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

1399 """

1400

1401 title = "User"

1402

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

1404 self.title = "" # for type checker

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

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

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

1408

1409 # noinspection PyUnusedLocal

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

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

1412

1413 _ = self.gettext

1414 self.title = _("User")

1415 request = self.request

1416 dbsession = request.dbsession

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

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

1419 for user in users:

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

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

1422 self.widget = SelectWidget(values=values)

1423 self.validator = OneOf(pv)

1424

1425

1426 class UsernameNode(SchemaNode, RequestAwareMixin):

1427 """

1428 Node to enter a username.

1429 """

1430

1431 schema_type = String

1432 widget = TextInputWidget(

1433 attributes={AUTOCOMPLETE_ATTR: AutocompleteAttrValues.OFF}

1434 )

1435

1436 def __init__(

1437 self,

1438 *args: Any,

1439 autocomplete: str = AutocompleteAttrValues.OFF,

1440 **kwargs: Any,

1441 ) -> None:

1442 self.title = "" # for type checker

1443 self.autocomplete = autocomplete

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

1445

1446 # noinspection PyUnusedLocal

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

1448 _ = self.gettext

1449 self.title = _("Username")

1450 # noinspection PyUnresolvedReferences

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

1452

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

1454 if value == USER_NAME_FOR_SYSTEM:

1455 _ = self.gettext

1456 raise Invalid(

1457 node,

1458 _("Cannot use system username")

1459 + " "

1460 + repr(USER_NAME_FOR_SYSTEM),

1461 )

1462 try:

1463 validate_username(value, self.request)

1464 except ValueError as e:

1465 raise Invalid(node, str(e))

1466

1467

1468 class UserFilterSchema(Schema, RequestAwareMixin):

1469 """

1470 Schema to filter the list of users

1471 """

1472

1473 # must match ViewParam.INCLUDE_AUTO_GENERATED

1474 include_auto_generated = BooleanNode()

1475

1476 # noinspection PyUnusedLocal

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

1478 _ = self.gettext

1479 include_auto_generated = get_child_node(self, "include_auto_generated")

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

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

1482

1483

1484 class UserFilterForm(InformativeNonceForm):

1485 """

1486 Form to filter the list of users

1487 """

1488

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

1490 _ = request.gettext

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

1492 super().__init__(

1493 schema,

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

1495 css_class=BootstrapCssClasses.FORM_INLINE,

1496 method=HttpMethod.GET,

1497 **kwargs,

1498 )

1499

1500

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

1502# Devices

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

1504

1505

1506 class MandatoryDeviceIdSelector(SchemaNode, RequestAwareMixin):

1507 """

1508 Mandatory node to select a client device ID.

1509 """

1510

1511 schema_type = Integer

1512

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

1514 self.title = "" # for type checker

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

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

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

1518

1519 # noinspection PyUnusedLocal

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

1521 from camcops_server.cc_modules.cc_device import (

1522 Device,

1523 ) # delayed import

1524

1525 _ = self.gettext

1526 self.title = _("Device")

1527 request = self.request

1528 dbsession = request.dbsession

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

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

1531 for device in devices:

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

1533 values, pv = get_values_and_permissible(values, False)

1534 self.widget = SelectWidget(values=values)

1535 self.validator = OneOf(pv)

1536

1537

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

1539# Server PK

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

1541

1542

1543 class ServerPkSelector(OptionalIntNode, RequestAwareMixin):

1544 """

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

1546 """

1547

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

1549 self.title = "" # for type checker

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

1551

1552 # noinspection PyUnusedLocal

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

1554 _ = self.gettext

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

1556

1557

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

1559# Dates/times

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

1561

1562

1563 class StartPendulumSelector(

1564 TranslatableOptionalPendulumNode, RequestAwareMixin

1565):

1566 """

1567 Optional node to select a start date/time.

1568 """

1569

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

1571 self.title = "" # for type checker

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

1573

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

1575 super().after_bind(node, kw)

1576 _ = self.gettext

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

1578

1579

1580 class EndPendulumSelector(TranslatableOptionalPendulumNode, RequestAwareMixin):

1581 """

1582 Optional node to select an end date/time.

1583 """

1584

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

1586 self.title = "" # for type checker

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

1588

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

1590 super().after_bind(node, kw)

1591 _ = self.gettext

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

1593

1594

1595 class StartDateTimeSelector(

1596 TranslatableDateTimeSelectorNode, RequestAwareMixin

1597):

1598 """

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

1600 """

1601

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

1603 self.title = "" # for type checker

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

1605

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

1607 super().after_bind(node, kw)

1608 _ = self.gettext

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

1610

1611

1612 class EndDateTimeSelector(TranslatableDateTimeSelectorNode, RequestAwareMixin):

1613 """

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

1615 """

1616

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

1618 self.title = "" # for type checker

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

1620

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

1622 super().after_bind(node, kw)

1623 _ = self.gettext

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

1625

1626

1627'''

1628 class StartDateSelector(TranslatableDateSelectorNode,

1629 RequestAwareMixin):

1630 """

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

1632 """

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

1634 self.title = "" # for type checker

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

1636

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

1638 super().after_bind(node, kw)

1639 _ = self.gettext

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

1641

1642

1643 class EndDateSelector(TranslatableDateSelectorNode,

1644 RequestAwareMixin):

1645 """

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

1647 """

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

1649 self.title = "" # for type checker

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

1651

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

1653 super().after_bind(node, kw)

1654 _ = self.gettext

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

1656'''

1657

1658

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

1660# Rows per page

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

1662

1663

1664 class RowsPerPageSelector(SchemaNode, RequestAwareMixin):

1665 """

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

1667 """

1668

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

1670

1671 schema_type = Integer

1672 default = DEFAULT_ROWS_PER_PAGE

1673 widget = RadioChoiceWidget(values=_choices)

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

1675

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

1677 self.title = "" # for type checker

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

1679

1680 # noinspection PyUnusedLocal

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

1682 _ = self.gettext

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

1684

1685

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

1687# Groups

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

1689

1690

1691 class MandatoryGroupIdSelectorAllGroups(SchemaNode, RequestAwareMixin):

1692 """

1693 Offers a picklist of groups from ALL POSSIBLE GROUPS.

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

1695 """

1696

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

1698 self.title = "" # for type checker

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

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

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

1702

1703 # noinspection PyUnusedLocal

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

1705 _ = self.gettext

1706 self.title = _("Group")

1707 request = self.request

1708 dbsession = request.dbsession

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

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

1711 values, pv = get_values_and_permissible(values)

1712 self.widget = SelectWidget(values=values)

1713 self.validator = OneOf(pv)

1714

1715 @staticmethod

1716 def schema_type() -> SchemaType:

1717 return Integer()

1718

1719

1720 class MandatoryGroupIdSelectorAdministeredGroups(

1721 SchemaNode, RequestAwareMixin

1722):

1723 """

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

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

1726 """

1727

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

1729 self.title = "" # for type checker

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

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

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

1733

1734 # noinspection PyUnusedLocal

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

1736 _ = self.gettext

1737 self.title = _("Group")

1738 request = self.request

1739 dbsession = request.dbsession

1740 administered_group_ids = request.user.ids_of_groups_user_is_admin_for

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

1742 values = [

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

1744 ]

1745 values, pv = get_values_and_permissible(values)

1746 self.widget = SelectWidget(values=values)

1747 self.validator = OneOf(pv)

1748

1749 @staticmethod

1750 def schema_type() -> SchemaType:

1751 return Integer()

1752

1753

1754 class MandatoryGroupIdSelectorPatientGroups(SchemaNode, RequestAwareMixin):

1755 """

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

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

1758 """

1759

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

1761 self.title = "" # for type checker

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

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

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

1765

1766 # noinspection PyUnusedLocal

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

1768 _ = self.gettext

1769 self.title = _("Group")

1770 request = self.request

1771 dbsession = request.dbsession

1772 group_ids = request.user.ids_of_groups_user_may_manage_patients_in

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

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

1775 values, pv = get_values_and_permissible(values)

1776 self.widget = SelectWidget(values=values)

1777 self.validator = OneOf(pv)

1778

1779 @staticmethod

1780 def schema_type() -> SchemaType:

1781 return Integer()

1782

1783

1784 class MandatoryGroupIdSelectorOtherGroups(SchemaNode, RequestAwareMixin):

1785 """

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

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

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

1789 """

1790

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

1792 self.title = "" # for type checker

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

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

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

1796

1797 # noinspection PyUnusedLocal

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

1799 _ = self.gettext

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

1801 request = self.request

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

1803 dbsession = request.dbsession

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

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

1806 values, pv = get_values_and_permissible(values)

1807 self.widget = SelectWidget(values=values)

1808 self.validator = OneOf(pv)

1809

1810 @staticmethod

1811 def schema_type() -> SchemaType:

1812 return Integer()

1813

1814

1815 class MandatoryGroupIdSelectorUserGroups(SchemaNode, RequestAwareMixin):

1816 """

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

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

1819 """

1820

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

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

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

1824 # OptionalGroupIdSelectorUserGroups

1825 self.allow_none = False

1826 self.title = "" # for type checker

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

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

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

1830

1831 # noinspection PyUnusedLocal

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

1833 _ = self.gettext

1834 self.title = _("Group")

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

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

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

1838 values, pv = get_values_and_permissible(

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

1840 )

1841 self.widget = SelectWidget(values=values)

1842 self.validator = OneOf(pv)

1843

1844 @staticmethod

1845 def schema_type() -> SchemaType:

1846 return Integer()

1847

1848

1849 class OptionalGroupIdSelectorUserGroups(MandatoryGroupIdSelectorUserGroups):

1850 """

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

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

1853 """

1854

1855 default = None

1856 missing = None

1857

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

1859 self.allow_none = True

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

1861

1862 @staticmethod

1863 def schema_type() -> SchemaType:

1864 return AllowNoneType(Integer())

1865

1866

1867 class MandatoryGroupIdSelectorAllowedGroups(SchemaNode, RequestAwareMixin):

1868 """

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

1870 Used for task filters.

1871 """

1872

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

1874 self.title = "" # for type checker

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

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

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

1878

1879 # noinspection PyUnusedLocal

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

1881 _ = self.gettext

1882 self.title = _("Group")

1883 request = self.request

1884 dbsession = request.dbsession

1885 user = request.user

1886 if user.superuser:

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

1888 else:

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

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

1891 values, pv = get_values_and_permissible(values)

1892 self.widget = SelectWidget(values=values)

1893 self.validator = OneOf(pv)

1894

1895 @staticmethod

1896 def schema_type() -> SchemaType:

1897 return Integer()

1898

1899

1900 class GroupsSequenceBase(SequenceSchema, RequestAwareMixin):

1901 """

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

1903 """

1904

1905 def __init__(

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

1907 ) -> None:

1908 self.title = "" # for type checker

1909 self.minimum_number = minimum_number

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

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

1912

1913 # noinspection PyUnusedLocal

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

1915 _ = self.gettext

1916 self.title = _("Groups")

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

1918

1919 # noinspection PyMethodMayBeStatic

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

1921 assert isinstance(value, list)

1922 _ = self.gettext

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

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

1925 if len(value) < self.minimum_number:

1926 raise Invalid(

1927 node,

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

1929 self.minimum_number

1930 ),

1931 )

1932

1933

1934 class AllGroupsSequence(GroupsSequenceBase):

1935 """

1936 Sequence to offer a choice of all possible groups.

1937

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

1939 """

1940

1941 group_id_sequence = MandatoryGroupIdSelectorAllGroups()

1942

1943

1944 class AdministeredGroupsSequence(GroupsSequenceBase):

1945 """

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

1947

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

1949 to a user.

1950 """

1951

1952 group_id_sequence = MandatoryGroupIdSelectorAdministeredGroups()

1953

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

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

1956

1957

1958 class AllOtherGroupsSequence(GroupsSequenceBase):

1959 """

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

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

1962

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

1964 """

1965

1966 group_id_sequence = MandatoryGroupIdSelectorOtherGroups()

1967

1968

1969 class AllowedGroupsSequence(GroupsSequenceBase):

1970 """

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

1972 """

1973

1974 group_id_sequence = MandatoryGroupIdSelectorAllowedGroups()

1975

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

1977 self.description = "" # for type checker

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

1979

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

1981 super().after_bind(node, kw)

1982 self.description = self.or_join_description

1983

1984

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

1986# Languages (strictly, locales)

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

1988

1989

1990 class LanguageSelector(SchemaNode, RequestAwareMixin):

1991 """

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

1993 """

1994

1995 _choices = POSSIBLE_LOCALES_WITH_DESCRIPTIONS

1996 schema_type = String

1997 default = DEFAULT_LOCALE

1998 missing = DEFAULT_LOCALE

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

2000 validator = OneOf(POSSIBLE_LOCALES)

2001

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

2003 self.title = "" # for type checker

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

2005

2006 # noinspection PyUnusedLocal

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

2008 _ = self.gettext

2009 self.title = _("Group")

2010 request = self.request # noqa: F841

2011 self.title = _("Language")

2012

2013

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

2015# Validating dangerous operations

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

2017

2018

2019 class HardWorkConfirmationSchema(CSRFSchema):

2020 """

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

2022 answers before we will proceed.

2023 """

2024

2025 confirm_1_t = BooleanNode(default=False)

2026 confirm_2_t = BooleanNode(default=True)

2027 confirm_3_f = BooleanNode(default=True)

2028 confirm_4_t = BooleanNode(default=False)

2029

2030 # noinspection PyUnusedLocal

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

2032 _ = self.gettext

2033 confirm_1_t = get_child_node(self, "confirm_1_t")

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

2035 confirm_2_t = get_child_node(self, "confirm_2_t")

2036 # TRANSLATOR: string context described here

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

2038 confirm_3_f = get_child_node(self, "confirm_3_f")

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

2040 confirm_4_t = get_child_node(self, "confirm_4_t")

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

2042

2043 # noinspection PyMethodMayBeStatic

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

2045 if (

2046 (not value["confirm_1_t"])

2047 or (not value["confirm_2_t"])

2048 or value["confirm_3_f"]

2049 or (not value["confirm_4_t"])

2050 ):

2051 _ = self.gettext

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

2053

2054

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

2056# URLs

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

2058

2059

2060 class HiddenRedirectionUrlNode(HiddenStringNode, RequestAwareMixin):

2061 """

2062 Note to encode a hidden URL, for redirection.

2063 """

2064

2065 # noinspection PyMethodMayBeStatic

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

2067 if value:

2068 try:

2069 validate_redirect_url(value, self.request)

2070 except ValueError:

2071 _ = self.gettext

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

2073

2074

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

2076# Phone number

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

2078

2079

2080 class PhoneNumberType(String):

2081 def __init__(

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

2083 ) -> None:

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

2085

2086 self.request = request

2087

2088 # noinspection PyMethodMayBeStatic, PyUnusedLocal

2089 def deserialize(

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

2091 ) -> Optional[phonenumbers.PhoneNumber]:

2092 request = self.request # type: CamcopsRequest

2093 _ = request.gettext

2094 err_message = _("Invalid phone number")

2095

2096 # is null when form is empty

2097 if not cstruct:

2098 if not self.allow_empty:

2099 raise Invalid(node, err_message)

2100 return null

2101

2102 cstruct: str

2103

2104 try:

2105 phone_number = phonenumbers.parse(

2106 cstruct, request.config.region_code

2107 )

2108 except phonenumbers.NumberParseException:

2109 raise Invalid(node, err_message)

2110

2111 if not phonenumbers.is_valid_number(phone_number):

2112 # the number may parse but could still be invalid

2113 # (e.g. too few digits)

2114 raise Invalid(node, err_message)

2115

2116 return phone_number

2117

2118 # noinspection PyMethodMayBeStatic,PyUnusedLocal

2119 def serialize(

2120 self,

2121 node: SchemaNode,

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

2123 ) -> Union[str, ColanderNullType]:

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

2125 if not appstruct:

2126 return null

2127

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

2129 # when reading from the database)

2130 return phonenumbers.format_number(

2131 appstruct, phonenumbers.PhoneNumberFormat.E164

2132 )

2133

2134

2135 class MandatoryPhoneNumberNode(MandatoryStringNode, RequestAwareMixin):

2136 default = None

2137 missing = None

2138

2139 # noinspection PyUnusedLocal

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

2141 _ = self.gettext

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

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

2144

2145

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

2147# Login

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

2149

2150

2151 class LoginSchema(CSRFSchema):

2152 """

2153 Schema to capture login details.

2154 """

2155

2156 username = UsernameNode(

2157 autocomplete=AutocompleteAttrValues.USERNAME

2158 ) # name must match ViewParam.USERNAME

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

2160 String(),

2161 widget=PasswordWidget(

2162 attributes={

2163 AUTOCOMPLETE_ATTR: AutocompleteAttrValues.CURRENT_PASSWORD

2164 }

2165 ),

2166 )

2167 redirect_url = (

2168 HiddenRedirectionUrlNode()

2169 ) # name must match ViewParam.REDIRECT_URL

2170

2171 def __init__(

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

2173 ) -> None:

2174 self.autocomplete_password = autocomplete_password

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

2176

2177 # noinspection PyUnusedLocal

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

2179 _ = self.gettext

2180 password = get_child_node(self, "password")

2181 password.title = _("Password")

2182 password.widget.attributes[AUTOCOMPLETE_ATTR] = (

2183 AutocompleteAttrValues.CURRENT_PASSWORD

2184 if self.autocomplete_password

2185 else AutocompleteAttrValues.OFF

2186 )

2187

2188

2189 class LoginForm(InformativeNonceForm):

2190 """

2191 Form to capture login details.

2192 """

2193

2194 def __init__(

2195 self,

2196 request: "CamcopsRequest",

2197 autocomplete_password: bool = True,

2198 **kwargs: Any,

2199 ) -> None:

2200 """

2201 Args:

2202 autocomplete_password:

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

2204 autocompletion? Note that browsers may ignore this.

2205 """

2206 _ = request.gettext

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

2208 request=request

2209 )

2210 super().__init__(

2211 schema,

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

2213 # autocomplete=autocomplete_password,

2214 **kwargs,

2215 )

2216 # Suboptimal: autocomplete_password is not applied to the password

2217 # widget, just to the form; see

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

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

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

2221

2222

2223 class OtpSchema(CSRFSchema):

2224 """

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

2226 """

2227

2228 one_time_password = MandatoryStringNode()

2229 redirect_url = (

2230 HiddenRedirectionUrlNode()

2231 ) # name must match ViewParam.REDIRECT_URL

2232

2233 # noinspection PyUnusedLocal

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

2235 _ = self.gettext

2236 one_time_password = get_child_node(self, "one_time_password")

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

2238

2239

2240 class OtpTokenForm(InformativeNonceForm):

2241 """

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

2243 """

2244

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

2246 _ = request.gettext

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

2248 super().__init__(

2249 schema,

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

2251 **kwargs,

2252 )

2253

2254

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

2256# Change password

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

2258

2259

2260 class MustChangePasswordNode(SchemaNode, RequestAwareMixin):

2261 """

2262 Boolean node: must the user change their password?

2263 """

2264

2265 schema_type = Boolean

2266 default = True

2267 missing = True

2268

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

2270 self.label = "" # for type checker

2271 self.title = "" # for type checker

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

2273

2274 # noinspection PyUnusedLocal

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

2276 _ = self.gettext

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

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

2279

2280

2281 class OldUserPasswordCheck(SchemaNode, RequestAwareMixin):

2282 """

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

2284 """

2285

2286 schema_type = String

2287 widget = PasswordWidget(

2288 attributes={AUTOCOMPLETE_ATTR: AutocompleteAttrValues.CURRENT_PASSWORD}

2289 )

2290

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

2292 self.title = "" # for type checker

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

2294

2295 # noinspection PyUnusedLocal

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

2297 _ = self.gettext

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

2299

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

2301 request = self.request

2302 user = request.user

2303 assert user is not None

2304 if not user.is_password_correct(value):

2305 _ = request.gettext

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

2307

2308

2309 class InformationalCheckedPasswordWidget(CheckedPasswordWidget):

2310 """

2311 A more verbose version of Deform's CheckedPasswordWidget

2312 which provides advice on good passwords.

2313 """

2314

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

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

2317 form = "informational_checked_password.pt"

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

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

2320

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

2322 super().__init__(**kwargs)

2323 self.request = request

2324

2325 def get_template_values(

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

2327 ) -> Dict[str, Any]:

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

2329

2330 _ = self.request.gettext

2331

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

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

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

2335 link=link

2336 )

2337 min_password_length = _(

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

2339 ).format(limit=MINIMUM_PASSWORD_LENGTH)

2340

2341 values.update(

2342 password_advice=password_advice,

2343 min_password_length=min_password_length,

2344 )

2345

2346 return values

2347

2348

2349 class NewPasswordNode(SchemaNode, RequestAwareMixin):

2350 """

2351 Node to enter a new password.

2352 """

2353

2354 schema_type = String

2355

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

2357 self.title = "" # for type checker

2358 self.description = "" # for type checker

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

2360

2361 # noinspection PyUnusedLocal

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

2363 _ = self.gettext

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

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

2366 self.widget = InformationalCheckedPasswordWidget(

2367 self.request,

2368 attributes={

2369 AUTOCOMPLETE_ATTR: AutocompleteAttrValues.NEW_PASSWORD

2370 },

2371 )

2372

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

2374 try:

2375 validate_new_password(value, self.request)

2376 except ValueError as e:

2377 raise Invalid(node, str(e))

2378

2379

2380 class ChangeOwnPasswordSchema(CSRFSchema):

2381 """

2382 Schema to change one's own password.

2383 """

2384

2385 old_password = OldUserPasswordCheck()

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

2387

2388 def __init__(

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

2390 ) -> None:

2391 """

2392 Args:

2393 must_differ:

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

2395 """

2396 self.must_differ = must_differ

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

2398

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

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

2401 _ = self.gettext

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

2403

2404

2405 class ChangeOwnPasswordForm(InformativeNonceForm):

2406 """

2407 Form to change one's own password.

2408 """

2409

2410 def __init__(

2411 self,

2412 request: "CamcopsRequest",

2413 must_differ: bool = True,

2414 **kwargs: Any,

2415 ) -> None:

2416 """

2417 Args:

2418 must_differ:

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

2420 """

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

2422 request=request

2423 )

2424 super().__init__(

2425 schema,

2426 buttons=[

2427 Button(

2428 name=FormAction.SUBMIT,

2429 title=change_password_title(request),

2430 )

2431 ],

2432 **kwargs,

2433 )

2434

2435

2436 class ChangeOtherPasswordSchema(CSRFSchema):

2437 """

2438 Schema to change another user's password.

2439 """

2440

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

2442 must_change_password = (

2443 MustChangePasswordNode()

2444 ) # match ViewParam.MUST_CHANGE_PASSWORD

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

2446

2447

2448 class ChangeOtherPasswordForm(SimpleSubmitForm):

2449 """

2450 Form to change another user's password.

2451 """

2452

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

2454 _ = request.gettext

2455 super().__init__(

2456 schema_class=ChangeOtherPasswordSchema,

2457 submit_title=_("Submit"),

2458 request=request,

2459 **kwargs,

2460 )

2461

2462

2463 class DisableMfaNode(SchemaNode, RequestAwareMixin):

2464 """

2465 Boolean node: disable multi-factor authentication

2466 """

2467

2468 schema_type = Boolean

2469 default = False

2470 missing = False

2471

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

2473 self.label = "" # for type checker

2474 self.title = "" # for type checker

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

2476

2477 # noinspection PyUnusedLocal

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

2479 _ = self.gettext

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

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

2482

2483

2484 class EditOtherUserMfaSchema(CSRFSchema):

2485 """

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

2487 """

2488

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

2490 disable_mfa = DisableMfaNode() # match ViewParam.DISABLE_MFA

2491

2492

2493 class EditOtherUserMfaForm(SimpleSubmitForm):

2494 """

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

2496 """

2497

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

2499 _ = request.gettext

2500 super().__init__(

2501 schema_class=EditOtherUserMfaSchema,

2502 submit_title=_("Submit"),

2503 request=request,

2504 **kwargs,

2505 )

2506

2507

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

2509# Multi-factor authentication

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

2511

2512

2513 class MfaSecretWidget(TextInputWidget):

2514 """

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

2516 string.

2517 """

2518

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

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

2521 form = "mfa_secret.pt"

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

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

2524

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

2526 super().__init__(**kwargs)

2527 self.request = request

2528

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

2530 # cstruct contains the MFA secret key

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

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

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

2534

2535 _ = self.request.gettext

2536

2537 factory = qrcode.image.svg.SvgImage

2538 totp = pyotp.totp.TOTP(cstruct)

2539 uri = totp.provisioning_uri(

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

2541 )

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

2543 stream = BytesIO()

2544 img.save(stream)

2545 values.update(

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

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

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

2549 enter_key=_(

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

2551 ),

2552 enter_code=_(

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

2554 "the app."

2555 ),

2556 )

2557

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

2559

2560

2561 class MfaSecretNode(OptionalStringNode, RequestAwareMixin):

2562 """

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

2564 alphanumeric string.

2565 """

2566

2567 schema_type = String

2568

2569 # noinspection PyUnusedLocal,PyAttributeOutsideInit

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

2571 self.widget = MfaSecretWidget(self.request)

2572

2573

2574 class MfaMethodSelector(SchemaNode, RequestAwareMixin):

2575 """

2576 Node to select type of authentication

2577 """

2578

2579 schema_type = String

2580 default = MfaMethod.TOTP

2581 missing = MfaMethod.TOTP

2582

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

2584 self.title = "" # for type checker

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

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

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

2588

2589 # noinspection PyUnusedLocal

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

2591 _ = self.gettext

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

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

2594 all_mfa_choices = [

2595 (

2596 MfaMethod.TOTP,

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

2598 ),

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

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

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

2602 ]

2603

2604 choices = []

2605 for label, description in all_mfa_choices:

2606 if label in request.config.mfa_methods:

2607 choices.append((label, description))

2608 values, pv = get_values_and_permissible(choices)

2609

2610 self.widget = RadioChoiceWidget(values=values)

2611 self.validator = OneOf(pv)

2612

2613

2614 class MfaMethodSchema(CSRFSchema):

2615 """

2616 Schema to edit Multi-factor Authentication method.

2617 """

2618

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

2620

2621 # noinspection PyUnusedLocal

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

2623 _ = self.gettext

2624 mfa_method = get_child_node(self, "mfa_method")

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

2626

2627

2628 class MfaTotpSchema(CSRFSchema):

2629 """

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

2631 """

2632

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

2634

2635 # noinspection PyUnusedLocal

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

2637 _ = self.gettext

2638 mfa_secret_key = get_child_node(self, "mfa_secret_key")

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

2640

2641

2642 class MfaHotpEmailSchema(CSRFSchema):

2643 """

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

2645 """

2646

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

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

2649

2650 # noinspection PyUnusedLocal

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

2652 _ = self.gettext

2653

2654

2655 class MfaHotpSmsSchema(CSRFSchema):

2656 """

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

2658 """

2659

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

2661 phone_number = (

2662 MandatoryPhoneNumberNode()

2663 ) # must match ViewParam.PHONE_NUMBER

2664

2665 # noinspection PyUnusedLocal

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

2667 _ = self.gettext

2668 phone_number = get_child_node(self, ViewParam.PHONE_NUMBER)

2669 phone_number.description = _(

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

2671 "'{region_code}' region"

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

2673

2674

2675 class MfaMethodForm(InformativeNonceForm):

2676 """

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

2678 """

2679

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

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

2682 super().__init__(

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

2684 )

2685

2686

2687 class MfaTotpForm(InformativeNonceForm):

2688 """

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

2690 """

2691

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

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

2694 super().__init__(

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

2696 )

2697

2698

2699 class MfaHotpEmailForm(InformativeNonceForm):

2700 """

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

2702 """

2703

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

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

2706 super().__init__(

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

2708 )

2709

2710

2711 class MfaHotpSmsForm(InformativeNonceForm):

2712 """

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

2714 """

2715

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

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

2718 super().__init__(

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

2720 )

2721

2722

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

2724# Offer/agree terms

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

2726

2727

2728 class OfferTermsSchema(CSRFSchema):

2729 """

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

2731 """

2732

2733 pass

2734

2735

2736 class OfferTermsForm(SimpleSubmitForm):

2737 """

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

2739 """

2740

2741 def __init__(

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

2743 ) -> None:

2744 """

2745 Args:

2746 agree_button_text:

2747 text for the "agree" button

2748 """

2749 super().__init__(

2750 schema_class=OfferTermsSchema,

2751 submit_title=agree_button_text,

2752 request=request,

2753 **kwargs,

2754 )

2755

2756

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

2758# View audit trail

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

2760

2761

2762 class OptionalIPAddressNode(OptionalStringNode, RequestAwareMixin):

2763 """

2764 Optional IPv4 or IPv6 address.

2765 """

2766

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

2768 try:

2769 validate_ip_address(value, self.request)

2770 except ValueError as e:

2771 raise Invalid(node, e)

2772

2773

2774 class OptionalAuditSourceNode(OptionalStringNode, RequestAwareMixin):

2775 """

2776 Optional IPv4 or IPv6 address.

2777 """

2778

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

2780 try:

2781 validate_by_char_and_length(

2782 value,

2783 permitted_char_expression=ALPHANUM_UNDERSCORE_CHAR,

2784 min_length=0,

2785 max_length=StringLengths.AUDIT_SOURCE_MAX_LEN,

2786 req=self.request,

2787 )

2788 except ValueError as e:

2789 raise Invalid(node, e)

2790

2791

2792 class AuditTrailSchema(CSRFSchema):

2793 """

2794 Schema to filter audit trail entries.

2795 """

2796

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

2798 start_datetime = (

2799 StartPendulumSelector()

2800 ) # must match ViewParam.START_DATETIME

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

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

2803 remote_ip_addr = (

2804 OptionalIPAddressNode()

2805 ) # must match ViewParam.REMOTE_IP_ADDR

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

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

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

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

2810

2811 # noinspection PyUnusedLocal

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

2813 _ = self.gettext

2814 source = get_child_node(self, "source")

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

2816 remote_ip_addr = get_child_node(self, "remote_ip_addr")

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

2818 truncate = get_child_node(self, "truncate")

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

2820

2821

2822 class AuditTrailForm(SimpleSubmitForm):

2823 """

2824 Form to filter and then view audit trail entries.

2825 """

2826

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

2828 _ = request.gettext

2829 super().__init__(

2830 schema_class=AuditTrailSchema,

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

2832 request=request,

2833 **kwargs,

2834 )

2835

2836

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

2838# View export logs

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

2840

2841

2842 class OptionalExportRecipientNameSelector(

2843 OptionalStringNode, RequestAwareMixin

2844):

2845 """

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

2847 database.

2848 """

2849

2850 title = "Export recipient"

2851

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

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

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

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

2856

2857 # noinspection PyUnusedLocal

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

2859 from camcops_server.cc_modules.cc_exportrecipient import (

2860 ExportRecipient,

2861 ) # delayed import

2862

2863 request = self.request

2864 _ = request.gettext

2865 dbsession = request.dbsession

2866 q = (

2867 dbsession.query(ExportRecipient.recipient_name)

2868 .distinct()

2869 .order_by(ExportRecipient.recipient_name)

2870 )

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

2872 for row in q:

2873 recipient_name = row[0]

2874 values.append((recipient_name, recipient_name))

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

2876 self.widget = SelectWidget(values=values)

2877 self.validator = OneOf(pv)

2878

2879

2880 class ExportedTaskListSchema(CSRFSchema):

2881 """

2882 Schema to filter HL7 message logs.

2883 """

2884

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

2886 recipient_name = (

2887 OptionalExportRecipientNameSelector()

2888 ) # must match ViewParam.RECIPIENT_NAME

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

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

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

2892 start_datetime = (

2893 StartDateTimeSelector()

2894 ) # must match ViewParam.START_DATETIME

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

2896

2897 # noinspection PyUnusedLocal

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

2899 _ = self.gettext

2900 id_ = get_child_node(self, "id")

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

2902

2903

2904 class ExportedTaskListForm(SimpleSubmitForm):

2905 """

2906 Form to filter and then view exported task logs.

2907 """

2908

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

2910 _ = request.gettext

2911 super().__init__(

2912 schema_class=ExportedTaskListSchema,

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

2914 request=request,

2915 **kwargs,

2916 )

2917

2918

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

2920# Task filters

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

2922

2923

2924 class TextContentsSequence(SequenceSchema, RequestAwareMixin):

2925 """

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

2927 for a task filter).

2928 """

2929

2930 text_sequence = SchemaNode(

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

2932 ) # BEWARE: fairly unrestricted contents.

2933

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

2935 self.title = "" # for type checker

2936 self.description = "" # for type checker

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

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

2939

2940 # noinspection PyUnusedLocal

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

2942 _ = self.gettext

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

2944 self.description = self.or_join_description

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

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

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

2948 text_sequence = get_child_node(self, "text_sequence")

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

2950 text_sequence.title = _("text")

2951

2952 # noinspection PyMethodMayBeStatic

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

2954 assert isinstance(value, list)

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

2956 _ = self.gettext

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

2958

2959

2960 class UploadingUserSequence(SequenceSchema, RequestAwareMixin):

2961 """

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

2963 the following users...").

2964 """

2965

2966 user_id_sequence = MandatoryUserIdSelectorUsersAllowedToSee()

2967

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

2969 self.title = "" # for type checker

2970 self.description = "" # for type checker

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

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

2973

2974 # noinspection PyUnusedLocal

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

2976 _ = self.gettext

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

2978 self.description = self.or_join_description

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

2980

2981 # noinspection PyMethodMayBeStatic

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

2983 assert isinstance(value, list)

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

2985 _ = self.gettext

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

2987

2988

2989 class DevicesSequence(SequenceSchema, RequestAwareMixin):

2990 """

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

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

2993 """

2994

2995 device_id_sequence = MandatoryDeviceIdSelector()

2996

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

2998 self.title = "" # for type checker

2999 self.description = "" # for type checker

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

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

3002

3003 # noinspection PyUnusedLocal

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

3005 _ = self.gettext

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

3007 self.description = self.or_join_description

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

3009

3010 # noinspection PyMethodMayBeStatic

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

3012 assert isinstance(value, list)

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

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

3015

3016

3017 class OptionalPatientNameNode(OptionalStringNode, RequestAwareMixin):

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

3019 try:

3020 # TODO: Validating human names is hard.

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

3022 # be configurable.

3023 # validate_human_name(value, self.request)

3024

3025 # Does nothing but better to be explicit

3026 validate_anything(value, self.request)

3027 except ValueError as e:

3028 # Should never happen with validate_anything

3029 raise Invalid(node, str(e))

3030

3031

3032 class EditTaskFilterWhoSchema(Schema, RequestAwareMixin):

3033 """

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

3035 """

3036

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

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

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

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

3041 id_references = (

3042 IdNumSequenceAnyCombination()

3043 ) # must match ViewParam.ID_REFERENCES

3044

3045 # noinspection PyUnusedLocal

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

3047 _ = self.gettext

3048 surname = get_child_node(self, "surname")

3049 surname.title = _("Surname")

3050 forename = get_child_node(self, "forename")

3051 forename.title = _("Forename")

3052 dob = get_child_node(self, "dob")

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

3054 id_references = get_child_node(self, "id_references")

3055 id_references.description = self.or_join_description

3056

3057

3058 class EditTaskFilterWhenSchema(Schema):

3059 """

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

3061 """

3062

3063 start_datetime = (

3064 StartPendulumSelector()

3065 ) # must match ViewParam.START_DATETIME

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

3067

3068

3069 class EditTaskFilterWhatSchema(Schema, RequestAwareMixin):

3070 """

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

3072 """

3073

3074 text_contents = (

3075 TextContentsSequence()

3076 ) # must match ViewParam.TEXT_CONTENTS

3077 complete_only = BooleanNode(

3078 default=False

3079 ) # must match ViewParam.COMPLETE_ONLY

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

3081

3082 # noinspection PyUnusedLocal

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

3084 _ = self.gettext

3085 complete_only = get_child_node(self, "complete_only")

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

3087 complete_only.title = only_completed_text

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

3089

3090

3091 class EditTaskFilterAdminSchema(Schema):

3092 """

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

3094 """

3095

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

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

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

3099

3100

3101 class EditTaskFilterSchema(CSRFSchema):

3102 """

3103 Schema to edit a task filter.

3104 """

3105

3106 who = EditTaskFilterWhoSchema( # must match ViewParam.WHO

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

3108 )

3109 what = EditTaskFilterWhatSchema( # must match ViewParam.WHAT

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

3111 )

3112 when = EditTaskFilterWhenSchema( # must match ViewParam.WHEN

3113 widget=MappingWidget(template="mapping_accordion", open=False)

3114 )

3115 admin = EditTaskFilterAdminSchema( # must match ViewParam.ADMIN

3116 widget=MappingWidget(template="mapping_accordion", open=False)

3117 )

3118

3119 # noinspection PyUnusedLocal

3120 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:

3121 # log.debug("EditTaskFilterSchema.after_bind")

3122 # log.debug("{!r}", self.__dict__)

3123 # This is pretty nasty. By the time we get here, the Form class has

3124 # made Field objects, and, I think, called a clone() function on us.

3125 # Objects like "who" are not in our __dict__ any more. Our __dict__

3126 # looks like:

3127 # {

3128 # 'typ': <colander.Mapping object at 0x7fd7989b18d0>,

3129 # 'bindings': {

3130 # 'open_who': True,

3131 # 'open_when': True,

3132 # 'request': ...,

3133 # },

3134 # '_order': 118,

3135 # 'children': [

3136 # <...CSRFToken object at ... (named csrf)>,

3137 # <...EditTaskFilterWhoSchema object at ... (named who)>,

3138 # ...

3139 # ],

3140 # 'title': ''

3141 # }

3142 _ = self.gettext

3143 who = get_child_node(self, "who")

3144 what = get_child_node(self, "what")

3145 when = get_child_node(self, "when")

3146 admin = get_child_node(self, "admin")

3147 who.title = _("Who")

3148 what.title = _("What")

3149 when.title = _("When")

3150 admin.title = _("Administrative criteria")

3151 # log.debug("who = {!r}", who)

3152 # log.debug("who.__dict__ = {!r}", who.__dict__)

3153 who.widget.open = kw[Binding.OPEN_WHO]

3154 what.widget.open = kw[Binding.OPEN_WHAT]

3155 when.widget.open = kw[Binding.OPEN_WHEN]

3156 admin.widget.open = kw[Binding.OPEN_ADMIN]

3157

3158

3159 class EditTaskFilterForm(InformativeNonceForm):

3160 """

3161 Form to edit a task filter.

3162 """

3163

3164 def __init__(

3165 self,

3166 request: "CamcopsRequest",

3167 open_who: bool = False,

3168 open_what: bool = False,

3169 open_when: bool = False,

3170 open_admin: bool = False,

3171 **kwargs: Any,

3172 ) -> None:

3173 _ = request.gettext

3174 schema = EditTaskFilterSchema().bind(

3175 request=request,

3176 open_admin=open_admin,

3177 open_what=open_what,

3178 open_when=open_when,

3179 open_who=open_who,

3180 )

3181 super().__init__(

3182 schema,

3183 buttons=[

3184 Button(name=FormAction.SET_FILTERS, title=_("Set filters")),

3185 Button(name=FormAction.CLEAR_FILTERS, title=_("Clear")),

3186 ],

3187 **kwargs,

3188 )

3189

3190

3191 class TasksPerPageSchema(CSRFSchema):

3192 """

3193 Schema to edit the number of rows per page, for the task view.

3194 """

3195

3196 rows_per_page = RowsPerPageSelector() # must match ViewParam.ROWS_PER_PAGE

3197

3198

3199 class TasksPerPageForm(InformativeNonceForm):

3200 """

3201 Form to edit the number of tasks per page, for the task view.

3202 """

3203

3204 def __init__(self, request: "CamcopsRequest", **kwargs: Any) -> None:

3205 _ = request.gettext

3206 schema = TasksPerPageSchema().bind(request=request)

3207 super().__init__(

3208 schema,

3209 buttons=[

3210 Button(

3211 name=FormAction.SUBMIT_TASKS_PER_PAGE,

3212 title=_("Set n/page"),

3213 )

3214 ],

3215 css_class=BootstrapCssClasses.FORM_INLINE,

3216 **kwargs,

3217 )

3218

3219

3220 class RefreshTasksSchema(CSRFSchema):

3221 """

3222 Schema for a "refresh tasks" button.

3223 """

3224

3225 pass

3226

3227

3228 class RefreshTasksForm(InformativeNonceForm):

3229 """

3230 Form for a "refresh tasks" button.

3231 """

3232

3233 def __init__(self, request: "CamcopsRequest", **kwargs: Any) -> None:

3234 _ = request.gettext

3235 schema = RefreshTasksSchema().bind(request=request)

3236 super().__init__(

3237 schema,

3238 buttons=[

3239 Button(name=FormAction.REFRESH_TASKS, title=_("Refresh"))

3240 ],

3241 **kwargs,

3242 )

3243

3244

3245# =============================================================================

3246# Trackers

3247# =============================================================================

3248

3249

3250 class TaskTrackerOutputTypeSelector(SchemaNode, RequestAwareMixin):

3251 """

3252 Node to select the output format for a tracker.

3253 """

3254

3255 # Choices don't require translation

3256 _choices = (

3257 (ViewArg.HTML, "HTML"),

3258 (ViewArg.PDF, "PDF"),

3259 (ViewArg.XML, "XML"),

3260 )

3261

3262 schema_type = String

3263 default = ViewArg.HTML

3264 missing = ViewArg.HTML

3265 widget = RadioChoiceWidget(values=_choices)

3266 validator = OneOf(list(x[0] for x in _choices))

3267

3268 def __init__(self, *args: Any, **kwargs: Any) -> None:

3269 self.title = "" # for type checker

3270 super().__init__(*args, **kwargs)

3271

3272 # noinspection PyUnusedLocal

3273 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:

3274 _ = self.gettext

3275 self.title = _("View as")

3276

3277

3278 class ChooseTrackerSchema(CSRFSchema):

3279 """

3280 Schema to select a tracker or CTV.

3281 """

3282

3283 which_idnum = (

3284 MandatoryWhichIdNumSelector()

3285 ) # must match ViewParam.WHICH_IDNUM

3286 idnum_value = MandatoryIdNumValue() # must match ViewParam.IDNUM_VALUE

3287 start_datetime = (

3288 StartPendulumSelector()

3289 ) # must match ViewParam.START_DATETIME

3290 end_datetime = EndPendulumSelector() # must match ViewParam.END_DATETIME

3291 all_tasks = BooleanNode(default=True) # match ViewParam.ALL_TASKS

3292 tasks = MultiTaskSelector() # must match ViewParam.TASKS

3293 # tracker_tasks_only will be set via the binding

3294 via_index = ViaIndexSelector() # must match ViewParam.VIA_INDEX

3295 viewtype = TaskTrackerOutputTypeSelector() # must match ViewParam.VIEWTYPE

3296

3297 # noinspection PyUnusedLocal

3298 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:

3299 _ = self.gettext

3300 all_tasks = get_child_node(self, "all_tasks")

3301 text = _("Use all eligible task types?")

3302 all_tasks.title = text

3303 all_tasks.label = text # type: ignore[attr-defined]

3304

3305

3306 class ChooseTrackerForm(InformativeNonceForm):

3307 """

3308 Form to select a tracker or CTV.

3309 """

3310

3311 def __init__(

3312 self, request: "CamcopsRequest", as_ctv: bool, **kwargs: Any

3313 ) -> None:

3314 """

3315 Args:

3316 as_ctv: CTV, not tracker?

3317 """

3318 _ = request.gettext

3319 schema = ChooseTrackerSchema().bind(

3320 request=request, tracker_tasks_only=not as_ctv

3321 )

3322 super().__init__(

3323 schema,

3324 buttons=[

3325 Button(

3326 name=FormAction.SUBMIT,

3327 title=(_("View CTV") if as_ctv else _("View tracker")),

3328 )

3329 ],

3330 **kwargs,

3331 )

3332

3333

3334# =============================================================================

3335# Reports, which use dynamically created forms

3336# =============================================================================

3337

3338

3339 class ReportOutputTypeSelector(SchemaNode, RequestAwareMixin):

3340 """

3341 Node to select the output format for a report.

3342 """

3343

3344 schema_type = String

3345 default = ViewArg.HTML

3346 missing = ViewArg.HTML

3347

3348 def __init__(self, *args: Any, **kwargs: Any) -> None:

3349 self.title = "" # for type checker

3350 self.widget = None # type: Optional[Widget]

3351 self.validator = None # type: Optional[ValidatorType]

3352 super().__init__(*args, **kwargs)

3353

3354 # noinspection PyUnusedLocal

3355 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:

3356 _ = self.gettext

3357 self.title = _("View as")

3358 choices = self.get_choices()

3359 values, pv = get_values_and_permissible(choices)

3360 self.widget = RadioChoiceWidget(values=choices)

3361 self.validator = OneOf(pv)

3362

3363 def get_choices(self) -> Tuple[Tuple[str, str]]:

3364 _ = self.gettext

3365 # noinspection PyTypeChecker

3366 return ( # type: ignore[return-value]

3367 (ViewArg.HTML, _("HTML")),

3368 (ViewArg.ODS, _("OpenOffice spreadsheet (ODS) file")),

3369 (ViewArg.TSV, _("TSV (tab-separated values)")),

3370 (ViewArg.XLSX, _("XLSX (Microsoft Excel) file")),

3371 )

3372

3373

3374 class ReportParamSchema(CSRFSchema):

3375 """

3376 Schema to embed a report type (ID) and output format (view type).

3377 """

3378

3379 viewtype = ReportOutputTypeSelector() # must match ViewParam.VIEWTYPE

3380 report_id = HiddenStringNode() # must match ViewParam.REPORT_ID

3381 # Specific forms may inherit from this.

3382

3383

3384 class DateTimeFilteredReportParamSchema(ReportParamSchema):

3385 start_datetime = StartPendulumSelector()

3386 end_datetime = EndPendulumSelector()

3387

3388

3389 class ReportParamForm(SimpleSubmitForm):

3390 """

3391 Form to view a specific report. Often derived from, to configure the report

3392 in more detail.

3393 """

3394

3395 def __init__(

3396 self,

3397 request: "CamcopsRequest",

3398 schema_class: Type[ReportParamSchema],

3399 **kwargs: Any,

3400 ) -> None:

3401 _ = request.gettext

3402 super().__init__(

3403 schema_class=schema_class,

3404 submit_title=_("View report"),

3405 request=request,

3406 **kwargs,

3407 )

3408

3409

3410# =============================================================================

3411# View DDL

3412# =============================================================================

3413

3414

3415 def get_sql_dialect_choices(

3416 request: "CamcopsRequest",

3417) -> List[Tuple[str, str]]:

3418 _ = request.gettext

3419 return [

3420 # https://docs.sqlalchemy.org/en/latest/dialects/

3421 (SqlaDialectName.MYSQL, "MySQL"),

3422 (SqlaDialectName.MSSQL, "Microsoft SQL Server"),

3423 (SqlaDialectName.ORACLE, "Oracle" + _("[WILL NOT WORK]")),

3424 # ... Oracle doesn't work; SQLAlchemy enforces the Oracle rule of a 30-

3425 # character limit for identifiers, only relaxed to 128 characters in

3426 # Oracle 12.2 (March 2017).

3427 (SqlaDialectName.FIREBIRD, "Firebird"),

3428 (SqlaDialectName.POSTGRES, "PostgreSQL"),

3429 (SqlaDialectName.SQLITE, "SQLite"),

3430 (SqlaDialectName.SYBASE, "Sybase"),

3431 ]

3432

3433

3434 class DatabaseDialectSelector(SchemaNode, RequestAwareMixin):

3435 """

3436 Node to choice an SQL dialect (for viewing DDL).

3437 """

3438

3439 schema_type = String

3440 default = SqlaDialectName.MYSQL

3441 missing = SqlaDialectName.MYSQL

3442

3443 def __init__(self, *args: Any, **kwargs: Any) -> None:

3444 self.title = "" # for type checker

3445 self.widget = None # type: Optional[Widget]

3446 self.validator = None # type: Optional[ValidatorType]

3447 super().__init__(*args, **kwargs)

3448

3449 # noinspection PyUnusedLocal

3450 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:

3451 _ = self.gettext

3452 self.title = _("SQL dialect to use (not all may be valid)")

3453 choices = get_sql_dialect_choices(self.request)

3454 values, pv = get_values_and_permissible(choices)

3455 self.widget = RadioChoiceWidget(values=values)

3456 self.validator = OneOf(pv)

3457

3458

3459 class ViewDdlSchema(CSRFSchema):

3460 """

3461 Schema to choose how to view DDL.

3462 """

3463

3464 dialect = DatabaseDialectSelector() # must match ViewParam.DIALECT

3465

3466

3467 class ViewDdlForm(SimpleSubmitForm):

3468 """

3469 Form to choose how to view DDL (and then view it).

3470 """

3471

3472 def __init__(self, request: "CamcopsRequest", **kwargs: Any) -> None:

3473 _ = request.gettext

3474 super().__init__(

3475 schema_class=ViewDdlSchema,

3476 submit_title=_("View DDL"),

3477 request=request,

3478 **kwargs,

3479 )

3480

3481

3482# =============================================================================

3483# Add/edit/delete users

3484# =============================================================================

3485

3486

3487 class UserGroupPermissionsGroupAdminSchema(CSRFSchema):

3488 """

3489 Edit group-specific permissions for a user. For group administrators.

3490 """

3491

3492 # Currently the defaults here will be ignored because we don't use this

3493 # schema to create new UserGroupMembership records. The record will already

3494 # exist by the time we see the forms that use this schema. So the database

3495 # defaults will be used instead.

3496 may_upload = BooleanNode(

3497 default=False

3498 ) # match ViewParam.MAY_UPLOAD and User attribute

3499 may_register_devices = BooleanNode(

3500 default=False

3501 ) # match ViewParam.MAY_REGISTER_DEVICES and User attribute

3502 may_use_webviewer = BooleanNode(

3503 default=False

3504 ) # match ViewParam.MAY_USE_WEBVIEWER and User attribute

3505 may_manage_patients = BooleanNode(

3506 default=False

3507 ) # match ViewParam.MAY_MANAGE_PATIENTS

3508 may_email_patients = BooleanNode(

3509 default=False

3510 ) # match ViewParam.MAY_EMAIL_PATIENTS

3511 view_all_patients_when_unfiltered = BooleanNode(

3512 default=False

3513 ) # match ViewParam.VIEW_ALL_PATIENTS_WHEN_UNFILTERED and User attribute # noqa

3514 may_dump_data = BooleanNode(

3515 default=False

3516 ) # match ViewParam.MAY_DUMP_DATA and User attribute

3517 may_run_reports = BooleanNode(

3518 default=False

3519 ) # match ViewParam.MAY_RUN_REPORTS and User attribute

3520 may_add_notes = BooleanNode(

3521 default=False

3522 ) # match ViewParam.MAY_ADD_NOTES and User attribute

3523

3524 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:

3525 _ = self.gettext

3526 may_upload = get_child_node(self, "may_upload")

3527 mu_text = _("Permitted to upload from a tablet/device")

3528 may_upload.title = mu_text

3529 may_upload.label = mu_text # type: ignore[attr-defined]

3530 may_register_devices = get_child_node(self, "may_register_devices")

3531 mrd_text = _("Permitted to register tablet/client devices")

3532 may_register_devices.title = mrd_text

3533 may_register_devices.label = mrd_text # type: ignore[attr-defined]

3534 may_use_webviewer = get_child_node(self, "may_use_webviewer")

3535 ml_text = _("May log in to web front end")

3536 may_use_webviewer.title = ml_text

3537 may_use_webviewer.label = ml_text # type: ignore[attr-defined]

3538 may_manage_patients = get_child_node(self, "may_manage_patients")

3539 mmp_text = _("May add, edit or delete patients created on the server")

3540 may_manage_patients.title = mmp_text

3541 may_manage_patients.label = mmp_text # type: ignore[attr-defined]

3542 may_email_patients = get_child_node(self, "may_email_patients")

3543 mep_text = _("May send emails to patients created on the server")

3544 may_email_patients.title = mep_text

3545 may_email_patients.label = mep_text # type: ignore[attr-defined]

3546 view_all_patients_when_unfiltered = get_child_node(

3547 self, "view_all_patients_when_unfiltered"

3548 )

3549 vap_text = _(

3550 "May view (browse) records from all patients when no patient "

3551 "filter set"

3552 )

3553 view_all_patients_when_unfiltered.title = vap_text

3554 view_all_patients_when_unfiltered.label = vap_text # type: ignore[attr-defined] # noqa: E501

3555 may_dump_data = get_child_node(self, "may_dump_data")

3556 md_text = _("May perform bulk data dumps")

3557 may_dump_data.title = md_text

3558 may_dump_data.label = md_text # type: ignore[attr-defined]

3559 may_run_reports = get_child_node(self, "may_run_reports")

3560 mrr_text = _("May run reports")

3561 may_run_reports.title = mrr_text

3562 may_run_reports.label = mrr_text # type: ignore[attr-defined]

3563 may_add_notes = get_child_node(self, "may_add_notes")

3564 man_text = _("May add special notes to tasks")

3565 may_add_notes.title = man_text

3566 may_add_notes.label = man_text # type: ignore[attr-defined]

3567

3568

3569 class UserGroupPermissionsFullSchema(UserGroupPermissionsGroupAdminSchema):

3570 """

3571 Edit group-specific permissions for a user. For superusers; includes the

3572 option to make the user a groupadmin.

3573 """

3574

3575 groupadmin = BooleanNode(

3576 default=False

3577 ) # match ViewParam.GROUPADMIN and User attribute

3578

3579 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:

3580 super().after_bind(node, kw)

3581 _ = self.gettext

3582 groupadmin = get_child_node(self, "groupadmin")

3583 text = _("User is a privileged group administrator for this group")

3584 groupadmin.title = text

3585 groupadmin.label = text # type: ignore[attr-defined]

3586

3587

3588 class EditUserGroupAdminSchema(CSRFSchema):

3589 """

3590 Schema to edit a user. Version for group administrators.

3591 """

3592

3593 username = (

3594 UsernameNode()

3595 ) # name must match ViewParam.USERNAME and User attribute

3596 fullname = OptionalStringNode( # name must match ViewParam.FULLNAME and User attribute # noqa

3597 validator=Length(0, StringLengths.FULLNAME_MAX_LEN)

3598 )

3599 email = (

3600 OptionalEmailNode()

3601 ) # name must match ViewParam.EMAIL and User attribute

3602 must_change_password = (

3603 MustChangePasswordNode()

3604 ) # match ViewParam.MUST_CHANGE_PASSWORD and User attribute

3605 language = LanguageSelector() # must match ViewParam.LANGUAGE

3606 group_ids = AdministeredGroupsSequence() # must match ViewParam.GROUP_IDS

3607

3608 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:

3609 _ = self.gettext

3610 fullname = get_child_node(self, "fullname")

3611 fullname.title = _("Full name")

3612 email = get_child_node(self, "email")

3613 email.title = _("E-mail address")

3614

3615

3616 class EditUserFullSchema(EditUserGroupAdminSchema):

3617 """

3618 Schema to edit a user. Version for superusers; can also make the user a

3619 superuser.

3620 """

3621

3622 superuser = BooleanNode(

3623 default=False

3624 ) # match ViewParam.SUPERUSER and User attribute

3625 group_ids = AllGroupsSequence() # must match ViewParam.GROUP_IDS

3626

3627 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:

3628 _ = self.gettext

3629 superuser = get_child_node(self, "superuser")

3630 text = _("Superuser (CAUTION!)")

3631 superuser.title = text

3632 superuser.label = text # type: ignore[attr-defined]

3633

3634

3635 class EditUserFullForm(ApplyCancelForm):

3636 """

3637 Form to edit a user. Full version for superusers.

3638 """

3639

3640 def __init__(self, request: "CamcopsRequest", **kwargs: Any) -> None:

3641 super().__init__(

3642 schema_class=EditUserFullSchema, request=request, **kwargs

3643 )

3644

3645

3646 class EditUserGroupAdminForm(ApplyCancelForm):

3647 """

3648 Form to edit a user. Version for group administrators.

3649 """

3650

3651 def __init__(self, request: "CamcopsRequest", **kwargs: Any) -> None:

3652 super().__init__(

3653 schema_class=EditUserGroupAdminSchema, request=request, **kwargs

3654 )

3655

3656

3657 class EditUserGroupPermissionsFullForm(ApplyCancelForm):

3658 """

3659 Form to edit a user's permissions within a group. Version for superusers.

3660 """

3661

3662 def __init__(self, request: "CamcopsRequest", **kwargs: Any) -> None:

3663 super().__init__(

3664 schema_class=UserGroupPermissionsFullSchema,

3665 request=request,

3666 **kwargs,

3667 )

3668

3669

3670 class EditUserGroupMembershipGroupAdminForm(ApplyCancelForm):

3671 """

3672 Form to edit a user's permissions within a group. Version for group

3673 administrators.

3674 """

3675

3676 def __init__(self, request: "CamcopsRequest", **kwargs: Any) -> None:

3677 super().__init__(

3678 schema_class=UserGroupPermissionsGroupAdminSchema,

3679 request=request,

3680 **kwargs,

3681 )

3682

3683

3684 class AddUserSuperuserSchema(CSRFSchema):

3685 """

3686 Schema to add a user. Version for superusers.

3687 """

3688

3689 username = (

3690 UsernameNode()

3691 ) # name must match ViewParam.USERNAME and User attribute

3692 new_password = NewPasswordNode() # name must match ViewParam.NEW_PASSWORD

3693 must_change_password = (

3694 MustChangePasswordNode()

3695 ) # match ViewParam.MUST_CHANGE_PASSWORD and User attribute

3696 group_ids = AllGroupsSequence() # must match ViewParam.GROUP_IDS

3697

3698

3699 class AddUserGroupadminSchema(AddUserSuperuserSchema):

3700 """

3701 Schema to add a user. Version for group administrators.

3702 """

3703

3704 group_ids = AdministeredGroupsSequence() # must match ViewParam.GROUP_IDS

3705

3706

3707 class AddUserSuperuserForm(AddCancelForm):

3708 """

3709 Form to add a user. Version for superusers.

3710 """

3711

3712 def __init__(self, request: "CamcopsRequest", **kwargs: Any) -> None:

3713 super().__init__(

3714 schema_class=AddUserSuperuserSchema, request=request, **kwargs

3715 )

3716

3717

3718 class AddUserGroupadminForm(AddCancelForm):

3719 """

3720 Form to add a user. Version for group administrators.

3721 """

3722

3723 def __init__(self, request: "CamcopsRequest", **kwargs: Any) -> None:

3724 super().__init__(

3725 schema_class=AddUserGroupadminSchema, request=request, **kwargs

3726 )

3727

3728

3729 class SetUserUploadGroupSchema(CSRFSchema):

3730 """

3731 Schema to choose the group into which a user uploads.

3732 """

3733

3734 upload_group_id = (

3735 OptionalGroupIdSelectorUserGroups()

3736 ) # must match ViewParam.UPLOAD_GROUP_ID

3737

3738 # noinspection PyUnusedLocal

3739 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:

3740 _ = self.gettext

3741 upload_group_id = get_child_node(self, "upload_group_id")

3742 upload_group_id.title = _("Group into which to upload data")

3743 upload_group_id.description = _(

3744 "Pick a group from those to which the user belongs"

3745 )

3746

3747

3748 class SetUserUploadGroupForm(InformativeNonceForm):

3749 """

3750 Form to choose the group into which a user uploads.

3751 """

3752

3753 def __init__(

3754 self, request: "CamcopsRequest", user: "User", **kwargs: Any

3755 ) -> None:

3756 _ = request.gettext

3757 schema = SetUserUploadGroupSchema().bind(

3758 request=request, user=user

3759 ) # UNUSUAL

3760 super().__init__(

3761 schema,

3762 buttons=[

3763 Button(name=FormAction.SUBMIT, title=_("Set")),

3764 Button(name=FormAction.CANCEL, title=_("Cancel")),

3765 ],

3766 **kwargs,

3767 )

3768

3769

3770 class DeleteUserSchema(HardWorkConfirmationSchema):

3771 """

3772 Schema to delete a user.

3773 """

3774

3775 user_id = HiddenIntegerNode() # name must match ViewParam.USER_ID

3776 danger = TranslatableValidateDangerousOperationNode()

3777

3778

3779 class DeleteUserForm(DeleteCancelForm):

3780 """

3781 Form to delete a user.

3782 """

3783

3784 def __init__(self, request: "CamcopsRequest", **kwargs: Any) -> None:

3785 super().__init__(

3786 schema_class=DeleteUserSchema, request=request, **kwargs

3787 )

3788

3789

3790# =============================================================================

3791# Add/edit/delete groups

3792# =============================================================================

3793

3794

3795 class PolicyNode(MandatoryStringNode, RequestAwareMixin):

3796 """

3797 Node to capture a CamCOPS ID number policy, and make sure it is

3798 syntactically valid.

3799 """

3800

3801 def validator(self, node: SchemaNode, value: Any) -> None:

3802 _ = self.gettext

3803 if not isinstance(value, str):

3804 # unlikely!

3805 raise Invalid(node, _("Not a string"))

3806 policy = TokenizedPolicy(value)

3807 if not policy.is_syntactically_valid():

3808 raise Invalid(node, _("Syntactically invalid policy"))

3809 if not policy.is_valid_for_idnums(self.request.valid_which_idnums):

3810 raise Invalid(

3811 node,

3812 _(

3813 "Invalid policy. Have you referred to non-existent ID "

3814 "numbers? Is the policy less restrictive than the "

3815 "tablet’s minimum ID policy?"

3816 )

3817 + f" [{TABLET_ID_POLICY_STR!r}]",

3818 )

3819

3820

3821 class GroupNameNode(MandatoryStringNode, RequestAwareMixin):

3822 """

3823 Node to capture a CamCOPS group name, and check it's valid as a string.

3824 """

3825

3826 def validator(self, node: SchemaNode, value: str) -> None:

3827 try:

3828 validate_group_name(value, self.request)

3829 except ValueError as e:

3830 raise Invalid(node, str(e))

3831

3832

3833 class GroupIpUseWidget(Widget):

3834 basedir = os.path.join(TEMPLATE_DIR, "deform")

3835 readonlydir = os.path.join(basedir, "readonly")

3836 form = "group_ip_use.pt"

3837 template = os.path.join(basedir, form)

3838 readonly_template = os.path.join(readonlydir, form)

3839

3840 def __init__(self, request: "CamcopsRequest", **kwargs: Any) -> None:

3841 super().__init__(**kwargs)

3842 self.request = request

3843

3844 def serialize(

3845 self,

3846 field: "Field",

3847 cstruct: Union[Dict[str, Any], None, ColanderNullType],

3848 **kw: Any,

3849 ) -> Any:

3850 if cstruct in (None, null):

3851 cstruct = {}

3852

3853 cstruct: Dict[str, Any] # For type checker

3854

3855 for context in IpUse.CONTEXTS:

3856 value = cstruct.get(context, False)

3857 kw.setdefault(context, value)

3858

3859 readonly = kw.get("readonly", self.readonly)

3860 template = readonly and self.readonly_template or self.template

3861 values = self.get_template_values(field, cstruct, kw)

3862

3863 _ = self.request.gettext

3864

3865 values.update(

3866 introduction=_(

3867 "These settings will be applied to the patient's device "

3868 "when operating in single user mode."

3869 ),

3870 reason=_(

3871 "The settings here influence whether CamCOPS will consider "

3872 "some third-party tasks “permitted” on your behalf, according "

3873 "to their published use criteria. They do <b>not</b> remove "

3874 "your responsibility to ensure that you use them in "

3875 "accordance with their own requirements."

3876 ),

3877 warning=_(

3878 "WARNING. Providing incorrect information here may lead to "

3879 "you VIOLATING copyright law, by using task for a purpose "

3880 "that is not permitted, and being subject to damages and/or "

3881 "prosecution."

3882 ),

3883 disclaimer=_(

3884 "The authors of CamCOPS cannot be held responsible or liable "

3885 "for any consequences of you misusing materials subject to "

3886 "copyright."

3887 ),

3888 preamble=_("In which contexts does this group operate?"),

3889 clinical_label=_("Clinical"),

3890 medical_device_warning=_(

3891 "WARNING: NOT FOR GENERAL CLINICAL USE; not a Medical Device; "

3892 "see Terms and Conditions"

3893 ),

3894 commercial_label=_("Commercial"),

3895 educational_label=_("Educational"),

3896 research_label=_("Research"),

3897 )

3898

3899 return field.renderer(template, **values)

3900

3901 def deserialize(

3902 self, field: "Field", pstruct: Union[Dict[str, Any], ColanderNullType]

3903 ) -> Dict[str, bool]:

3904 if pstruct is null:

3905 pstruct = {}

3906

3907 pstruct: Dict[str, Any] # For type checker

3908

3909 # It doesn't really matter what the pstruct values are. Only the

3910 # options that are ticked will be present as keys in pstruct

3911 return {k: k in pstruct for k in IpUse.CONTEXTS}

3912

3913

3914 class IpUseType(SchemaType):

3915 # noinspection PyMethodMayBeStatic,PyUnusedLocal

3916 def deserialize(

3917 self,

3918 node: SchemaNode,

3919 cstruct: Union[Dict[str, Any], None, ColanderNullType],

3920 ) -> Optional[IpUse]:

3921 if cstruct in (None, null):

3922 return None

3923

3924 cstruct: Dict[str, Any] # For type checker

3925

3926 return IpUse(**cstruct)

3927

3928 # noinspection PyMethodMayBeStatic,PyUnusedLocal

3929 def serialize(

3930 self, node: SchemaNode, ip_use: Union[IpUse, None, ColanderNullType]

3931 ) -> Union[Dict, ColanderNullType]:

3932 if ip_use in (null, None):

3933 return null

3934

3935 return {

3936 context: getattr(ip_use, context) for context in IpUse.CONTEXTS

3937 }

3938

3939

3940 class GroupIpUseNode(SchemaNode, RequestAwareMixin):

3941 schema_type = IpUseType

3942

3943 # noinspection PyUnusedLocal,PyAttributeOutsideInit

3944 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:

3945 self.widget = GroupIpUseWidget(self.request)

3946

3947

3948 class EditGroupSchema(CSRFSchema):

3949 """

3950 Schema to edit a group.

3951 """

3952

3953 group_id = HiddenIntegerNode() # must match ViewParam.GROUP_ID

3954 name = GroupNameNode() # must match ViewParam.NAME

3955 description = MandatoryStringNode( # must match ViewParam.DESCRIPTION

3956 validator=Length(

3957 StringLengths.GROUP_DESCRIPTION_MIN_LEN,

3958 StringLengths.GROUP_DESCRIPTION_MAX_LEN,

3959 )

3960 )

3961 ip_use = GroupIpUseNode()

3962

3963 group_ids = AllOtherGroupsSequence() # must match ViewParam.GROUP_IDS

3964 upload_policy = PolicyNode() # must match ViewParam.UPLOAD_POLICY

3965 finalize_policy = PolicyNode() # must match ViewParam.FINALIZE_POLICY

3966

3967 # noinspection PyUnusedLocal

3968 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:

3969 _ = self.gettext

3970 name = get_child_node(self, "name")

3971 name.title = _("Group name")

3972

3973 ip_use = get_child_node(self, "ip_use")

3974 ip_use.title = _("Group intellectual property settings")

3975

3976 group_ids = get_child_node(self, "group_ids")

3977 group_ids.title = _("Other groups this group may see")

3978 upload_policy = get_child_node(self, "upload_policy")

3979 upload_policy.title = _("Upload policy")

3980 upload_policy.description = _(

3981 "Minimum required patient information to copy data to server"

3982 )

3983 finalize_policy = get_child_node(self, "finalize_policy")

3984 finalize_policy.title = _("Finalize policy")

3985 finalize_policy.description = _(

3986 "Minimum required patient information to clear data off "

3987 "source device"

3988 )

3989

3990 def validator(self, node: SchemaNode, value: Any) -> None:

3991 request = self.bindings[Binding.REQUEST] # type: CamcopsRequest

3992 q = (

3993 CountStarSpecializedQuery(Group, session=request.dbsession) # type: ignore[arg-type] # noqa: E501

3994 .filter(Group.id != value[ViewParam.GROUP_ID])

3995 .filter(Group.name == value[ViewParam.NAME])

3996 )

3997 if q.count_star() > 0:

3998 _ = request.gettext

3999 raise Invalid(node, _("Name is used by another group!"))

4000

4001

4002 class EditGroupForm(InformativeNonceForm):

4003 """

4004 Form to edit a group.

4005 """

4006

4007 def __init__(

4008 self, request: "CamcopsRequest", group: Group, **kwargs: Any

4009 ) -> None:

4010 _ = request.gettext

4011 schema = EditGroupSchema().bind(

4012 request=request, group=group

4013 ) # UNUSUAL BINDING

4014 super().__init__(

4015 schema,

4016 buttons=[

4017 Button(name=FormAction.SUBMIT, title=_("Apply")),

4018 Button(name=FormAction.CANCEL, title=_("Cancel")),

4019 ],

4020 **kwargs,

4021 )

4022

4023

4024 class AddGroupSchema(CSRFSchema):

4025 """

4026 Schema to add a group.

4027 """

4028

4029 name = GroupNameNode() # name must match ViewParam.NAME

4030

4031 # noinspection PyUnusedLocal

4032 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:

4033 _ = self.gettext

4034 name = get_child_node(self, "name")

4035 name.title = _("Group name")

4036

4037 def validator(self, node: SchemaNode, value: Any) -> None:

4038 request = self.bindings[Binding.REQUEST] # type: CamcopsRequest

4039 q = CountStarSpecializedQuery(Group, session=request.dbsession).filter( # type: ignore[arg-type] # noqa: E501

4040 Group.name == value[ViewParam.NAME]

4041 )

4042 if q.count_star() > 0:

4043 _ = request.gettext

4044 raise Invalid(node, _("Name is used by another group!"))

4045

4046

4047 class AddGroupForm(AddCancelForm):

4048 """

4049 Form to add a group.

4050 """

4051

4052 def __init__(self, request: "CamcopsRequest", **kwargs: Any) -> None:

4053 super().__init__(

4054 schema_class=AddGroupSchema, request=request, **kwargs

4055 )

4056

4057

4058 class DeleteGroupSchema(HardWorkConfirmationSchema):

4059 """

4060 Schema to delete a group.

4061 """

4062

4063 group_id = HiddenIntegerNode() # name must match ViewParam.GROUP_ID

4064 danger = TranslatableValidateDangerousOperationNode()

4065

4066

4067 class DeleteGroupForm(DeleteCancelForm):

4068 """

4069 Form to delete a group.

4070 """

4071

4072 def __init__(self, request: "CamcopsRequest", **kwargs: Any) -> None:

4073 super().__init__(

4074 schema_class=DeleteGroupSchema, request=request, **kwargs

4075 )

4076

4077

4078# =============================================================================

4079# Offer research dumps

4080# =============================================================================

4081

4082

4083 class DumpTypeSelector(SchemaNode, RequestAwareMixin):

4084 """

4085 Node to select the filtering method for a data dump.

4086 """

4087

4088 schema_type = String

4089 default = ViewArg.EVERYTHING

4090 missing = ViewArg.EVERYTHING

4091

4092 def __init__(self, *args: Any, **kwargs: Any) -> None:

4093 self.title = "" # for type checker

4094 self.widget = None # type: Optional[Widget]

4095 self.validator = None # type: Optional[ValidatorType]

4096 super().__init__(*args, **kwargs)

4097

4098 # noinspection PyUnusedLocal

4099 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:

4100 _ = self.gettext

4101 self.title = _("Dump method")

4102 choices = (

4103 (ViewArg.EVERYTHING, _("Everything")),

4104 (ViewArg.USE_SESSION_FILTER, _("Use the session filter settings")),

4105 (

4106 ViewArg.SPECIFIC_TASKS_GROUPS,

4107 _("Specify tasks/groups manually (see below)"),

4108 ),

4109 )

4110 self.widget = RadioChoiceWidget(values=choices)

4111 self.validator = OneOf(list(x[0] for x in choices))

4112

4113

4114 class SpreadsheetFormatSelector(SchemaNode, RequestAwareMixin):

4115 """

4116 Node to select a way of downloading an SQLite database.

4117 """

4118

4119 schema_type = String

4120 default = ViewArg.XLSX

4121 missing = ViewArg.XLSX

4122

4123 def __init__(self, *args: Any, **kwargs: Any) -> None:

4124 self.title = "" # for type checker

4125 self.widget = None # type: Optional[Widget]

4126 self.validator = None # type: Optional[ValidatorType]

4127 super().__init__(*args, **kwargs)

4128

4129 # noinspection PyUnusedLocal

4130 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:

4131 _ = self.gettext

4132 self.title = _("Spreadsheet format")

4133 choices = (

4134 (ViewArg.R, _("R script")),

4135 (ViewArg.ODS, _("OpenOffice spreadsheet (ODS) file")),

4136 (ViewArg.XLSX, _("XLSX (Microsoft Excel) file")),

4137 (

4138 ViewArg.TSV_ZIP,

4139 _("ZIP file of tab-separated value (TSV) files"),

4140 ),

4141 )

4142 values, pv = get_values_and_permissible(choices)

4143 self.widget = RadioChoiceWidget(values=values)

4144 self.validator = OneOf(pv)

4145

4146

4147 class DeliveryModeNode(SchemaNode, RequestAwareMixin):

4148 """

4149 Mode of delivery of data downloads.

4150 """

4151

4152 schema_type = String

4153 default = ViewArg.EMAIL

4154 missing = ViewArg.EMAIL

4155

4156 def __init__(self, *args: Any, **kwargs: Any) -> None:

4157 self.title = "" # for type checker

4158 self.widget = None # type: Optional[Widget]

4159 super().__init__(*args, **kwargs)

4160

4161 # noinspection PyUnusedLocal

4162 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:

4163 _ = self.gettext

4164 self.title = _("Delivery")

4165 choices = (

4166 (ViewArg.IMMEDIATELY, _("Serve immediately")),

4167 (ViewArg.EMAIL, _("E-mail me")),

4168 (ViewArg.DOWNLOAD, _("Create a file for me to download")),

4169 )

4170 values, pv = get_values_and_permissible(choices)

4171 self.widget = RadioChoiceWidget(values=values)

4172

4173 # noinspection PyUnusedLocal

4174 def validator(self, node: SchemaNode, value: Any) -> None:

4175 request = self.bindings[Binding.REQUEST] # type: CamcopsRequest

4176 _ = request.gettext

4177 if value == ViewArg.IMMEDIATELY:

4178 if not request.config.permit_immediate_downloads:

4179 raise Invalid(

4180 self,

4181 _("Disabled by the system administrator")

4182 + f" [{ConfigParamSite.PERMIT_IMMEDIATE_DOWNLOADS}]",

4183 )

4184 elif value == ViewArg.EMAIL:

4185 if not request.user.email:

4186 raise Invalid(

4187 self, _("Your user does not have an email address")

4188 )

4189 elif value == ViewArg.DOWNLOAD:

4190 if not request.user_download_dir:

4191 raise Invalid(

4192 self,

4193 _("User downloads not configured by administrator")

4194 + f" [{ConfigParamSite.USER_DOWNLOAD_DIR}, "

4195 f"{ConfigParamSite.USER_DOWNLOAD_MAX_SPACE_MB}]",

4196 )

4197 else:

4198 raise Invalid(self, _("Bad value"))

4199

4200

4201 class SqliteSelector(SchemaNode, RequestAwareMixin):

4202 """

4203 Node to select a way of downloading an SQLite database.

4204 """

4205

4206 schema_type = String

4207 default = ViewArg.SQLITE

4208 missing = ViewArg.SQLITE

4209

4210 def __init__(self, *args: Any, **kwargs: Any) -> None:

4211 self.title = "" # for type checker

4212 self.widget = None # type: Optional[Widget]

4213 self.validator = None # type: Optional[ValidatorType]

4214 super().__init__(*args, **kwargs)

4215

4216 # noinspection PyUnusedLocal

4217 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:

4218 _ = self.gettext

4219 self.title = _("Database download method")

4220 choices = (

4221 # https://docs.sqlalchemy.org/en/latest/dialects/

4222 (ViewArg.SQLITE, _("Binary SQLite database")),

4223 (ViewArg.SQL, _("SQL text to create SQLite database")),

4224 )

4225 values, pv = get_values_and_permissible(choices)

4226 self.widget = RadioChoiceWidget(values=values)

4227 self.validator = OneOf(pv)

4228

4229

4230 class SimplifiedSpreadsheetsNode(SchemaNode, RequestAwareMixin):

4231 """

4232 Boolean node: simplify basic dump spreadsheets?

4233 """

4234

4235 schema_type = Boolean

4236 default = True

4237 missing = True

4238

4239 def __init__(self, *args: Any, **kwargs: Any) -> None:

4240 self.title = "" # for type checker

4241 self.label = "" # for type checker

4242 super().__init__(*args, **kwargs)

4243

4244 # noinspection PyUnusedLocal

4245 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:

4246 _ = self.gettext

4247 self.title = _("Simplify spreadsheets?")

4248 self.label = _("Remove non-essential details?")

4249

4250

4251 class SortTsvByHeadingsNode(SchemaNode, RequestAwareMixin):

4252 """

4253 Boolean node: sort TSV files by column name?

4254 """

4255

4256 schema_type = Boolean

4257 default = False

4258 missing = False

4259

4260 def __init__(self, *args: Any, **kwargs: Any) -> None:

4261 self.title = "" # for type checker

4262 self.label = "" # for type checker

4263 super().__init__(*args, **kwargs)

4264

4265 # noinspection PyUnusedLocal

4266 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:

4267 _ = self.gettext

4268 self.title = _("Sort columns?")

4269 self.label = _("Sort by heading (column) names within spreadsheets?")

4270

4271

4272 class IncludeSchemaNode(SchemaNode, RequestAwareMixin):

4273 """

4274 Boolean node: should INFORMATION_SCHEMA.COLUMNS be included (for

4275 downloads)?

4276

4277 False by default -- adds about 350 kb to an ODS download, for example.

4278 """

4279

4280 schema_type = Boolean

4281 default = False

4282 missing = False

4283

4284 def __init__(self, *args: Any, **kwargs: Any) -> None:

4285 self.title = "" # for type checker

4286 self.label = "" # for type checker

4287 super().__init__(*args, **kwargs)

4288

4289 # noinspection PyUnusedLocal

4290 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:

4291 _ = self.gettext

4292 self.title = _("Include column information?")

4293 self.label = _(

4294 "Include details of all columns in the source database?"

4295 )

4296

4297

4298 class IncludeBlobsNode(SchemaNode, RequestAwareMixin):

4299 """

4300 Boolean node: should BLOBs be included (for downloads)?

4301 """

4302

4303 schema_type = Boolean

4304 default = False

4305 missing = False

4306

4307 def __init__(self, *args: Any, **kwargs: Any) -> None:

4308 self.title = "" # for type checker

4309 self.label = "" # for type checker

4310 super().__init__(*args, **kwargs)

4311

4312 # noinspection PyUnusedLocal

4313 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:

4314 _ = self.gettext

4315 self.title = _("Include BLOBs?")

4316 self.label = _(

4317 "Include binary large objects (BLOBs)? WARNING: may be large"

4318 )

4319

4320

4321 class PatientIdPerRowNode(SchemaNode, RequestAwareMixin):

4322 """

4323 Boolean node: should patient ID information, and other cross-referencing

4324 denormalized info, be included per row?

4325

4326 See :ref:`DB_PATIENT_ID_PER_ROW <DB_PATIENT_ID_PER_ROW>`.

4327 """

4328

4329 schema_type = Boolean

4330 default = True

4331 missing = True

4332

4333 def __init__(self, *args: Any, **kwargs: Any) -> None:

4334 self.title = "" # for type checker

4335 self.label = "" # for type checker

4336 super().__init__(*args, **kwargs)

4337

4338 # noinspection PyUnusedLocal

4339 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:

4340 _ = self.gettext

4341 self.title = _("Patient ID per row?")

4342 self.label = _(

4343 "Include patient ID numbers and task cross-referencing "

4344 "(denormalized) information per row?"

4345 )

4346

4347

4348 class OfferDumpManualSchema(Schema, RequestAwareMixin):

4349 """

4350 Schema to offer the "manual" settings for a data dump (groups, task types).

4351 """

4352

4353 group_ids = AllowedGroupsSequence() # must match ViewParam.GROUP_IDS

4354 tasks = MultiTaskSelector() # must match ViewParam.TASKS

4355

4356 widget = MappingWidget(template="mapping_accordion", open=False)

4357

4358 def __init__(self, *args: Any, **kwargs: Any) -> None:

4359 self.title = "" # for type checker

4360 super().__init__(*args, **kwargs)

4361

4362 # noinspection PyUnusedLocal

4363 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:

4364 _ = self.gettext

4365 self.title = _("Manual settings")

4366

4367

4368 class OfferBasicDumpSchema(CSRFSchema):

4369 """

4370 Schema to choose the settings for a basic (TSV/ZIP) data dump.

4371 """

4372

4373 dump_method = DumpTypeSelector() # must match ViewParam.DUMP_METHOD

4374 simplified = (

4375 SimplifiedSpreadsheetsNode()

4376 ) # must match ViewParam.SIMPLIFIED

4377 sort = SortTsvByHeadingsNode() # must match ViewParam.SORT

4378 include_schema = IncludeSchemaNode() # must match ViewParam.INCLUDE_SCHEMA

4379 manual = OfferDumpManualSchema() # must match ViewParam.MANUAL

4380 viewtype = SpreadsheetFormatSelector() # must match ViewParam.VIEWTYPE

4381 delivery_mode = DeliveryModeNode() # must match ViewParam.DELIVERY_MODE

4382

4383

4384 class OfferBasicDumpForm(SimpleSubmitForm):

4385 """

4386 Form to offer a basic (TSV/ZIP) data dump.

4387 """

4388

4389 def __init__(self, request: "CamcopsRequest", **kwargs: Any) -> None:

4390 _ = request.gettext

4391 super().__init__(

4392 schema_class=OfferBasicDumpSchema,

4393 submit_title=_("Dump"),

4394 request=request,

4395 **kwargs,

4396 )

4397

4398

4399 class OfferSqlDumpSchema(CSRFSchema):

4400 """

4401 Schema to choose the settings for an SQL data dump.

4402 """

4403

4404 dump_method = DumpTypeSelector() # must match ViewParam.DUMP_METHOD

4405 sqlite_method = SqliteSelector() # must match ViewParam.SQLITE_METHOD

4406 include_schema = IncludeSchemaNode() # must match ViewParam.INCLUDE_SCHEMA

4407 include_blobs = IncludeBlobsNode() # must match ViewParam.INCLUDE_BLOBS

4408 patient_id_per_row = (

4409 PatientIdPerRowNode()

4410 ) # must match ViewParam.PATIENT_ID_PER_ROW

4411 manual = OfferDumpManualSchema() # must match ViewParam.MANUAL

4412 delivery_mode = DeliveryModeNode() # must match ViewParam.DELIVERY_MODE

4413

4414

4415 class OfferSqlDumpForm(SimpleSubmitForm):

4416 """

4417 Form to choose the settings for an SQL data dump.

4418 """

4419

4420 def __init__(self, request: "CamcopsRequest", **kwargs: Any) -> None:

4421 _ = request.gettext

4422 super().__init__(

4423 schema_class=OfferSqlDumpSchema,

4424 submit_title=_("Dump"),

4425 request=request,

4426 **kwargs,

4427 )

4428

4429

4430# =============================================================================

4431# Edit server settings

4432# =============================================================================

4433

4434

4435 class EditServerSettingsSchema(CSRFSchema):

4436 """

4437 Schema to edit the global settings for the server.

4438 """

4439

4440 database_title = SchemaNode( # must match ViewParam.DATABASE_TITLE

4441 String(),

4442 validator=Length(

4443 StringLengths.DATABASE_TITLE_MIN_LEN,

4444 StringLengths.DATABASE_TITLE_MAX_LEN,

4445 ),

4446 )

4447

4448 # noinspection PyUnusedLocal

4449 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:

4450 _ = self.gettext

4451 database_title = get_child_node(self, "database_title")

4452 database_title.title = _("Database friendly title")

4453

4454

4455 class EditServerSettingsForm(ApplyCancelForm):

4456 """

4457 Form to edit the global settings for the server.

4458 """

4459

4460 def __init__(self, request: "CamcopsRequest", **kwargs: Any) -> None:

4461 super().__init__(

4462 schema_class=EditServerSettingsSchema, request=request, **kwargs

4463 )

4464

4465

4466# =============================================================================

4467# Edit ID number definitions

4468# =============================================================================

4469

4470

4471 class IdDefinitionDescriptionNode(SchemaNode, RequestAwareMixin):

4472 """

4473 Node to capture the description of an ID number type.

4474 """

4475

4476 schema_type = String

4477 validator = Length(1, StringLengths.ID_DESCRIPTOR_MAX_LEN)

4478

4479 def __init__(self, *args: Any, **kwargs: Any) -> None:

4480 self.title = "" # for type checker

4481 super().__init__(*args, **kwargs)

4482

4483 # noinspection PyUnusedLocal

4484 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:

4485 _ = self.gettext

4486 self.title = _("Full description (e.g. “NHS number”)")

4487

4488

4489 class IdDefinitionShortDescriptionNode(SchemaNode, RequestAwareMixin):

4490 """

4491 Node to capture the short description of an ID number type.

4492 """

4493

4494 schema_type = String

4495 validator = Length(1, StringLengths.ID_DESCRIPTOR_MAX_LEN)

4496

4497 def __init__(self, *args: Any, **kwargs: Any) -> None:

4498 self.title = "" # for type checker

4499 self.description = "" # for type checker

4500 super().__init__(*args, **kwargs)

4501

4502 # noinspection PyUnusedLocal

4503 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:

4504 _ = self.gettext

4505 self.title = _("Short description (e.g. “NHS#”)")

4506 self.description = _("Try to keep it very short!")

4507

4508

4509 class IdValidationMethodNode(OptionalStringNode, RequestAwareMixin):

4510 """

4511 Node to choose a build-in ID number validation method.

4512 """

4513

4514 widget = SelectWidget(values=ID_NUM_VALIDATION_METHOD_CHOICES)

4515 validator = OneOf(list(x[0] for x in ID_NUM_VALIDATION_METHOD_CHOICES))

4516

4517 def __init__(self, *args: Any, **kwargs: Any) -> None:

4518 self.title = "" # for type checker

4519 self.description = "" # for type checker

4520 super().__init__(*args, **kwargs)

4521

4522 # noinspection PyUnusedLocal

4523 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:

4524 _ = self.gettext

4525 self.title = _("Validation method")

4526 self.description = _("Built-in CamCOPS ID number validation method")

4527

4528

4529 class Hl7AssigningAuthorityNode(OptionalStringNode, RequestAwareMixin):

4530 """

4531 Optional node to capture the name of an HL7 Assigning Authority.

4532 """

4533

4534 def __init__(self, *args: Any, **kwargs: Any) -> None:

4535 self.title = "" # for type checker

4536 self.description = "" # for type checker

4537 super().__init__(*args, **kwargs)

4538

4539 # noinspection PyUnusedLocal

4540 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:

4541 _ = self.gettext

4542 self.title = _("HL7 Assigning Authority")

4543 self.description = _(

4544 "For HL7 messaging: "

4545 "HL7 Assigning Authority for ID number (unique name of the "

4546 "system/organization/agency/department that creates the data)."

4547 )

4548

4549 # noinspection PyMethodMayBeStatic

4550 def validator(self, node: SchemaNode, value: str) -> None:

4551 try:

4552 validate_hl7_aa(value, self.request)

4553 except ValueError as e:

4554 raise Invalid(node, str(e))

4555

4556

4557 class Hl7IdTypeNode(OptionalStringNode, RequestAwareMixin):

4558 """

4559 Optional node to capture the name of an HL7 Identifier Type code.

4560 """

4561

4562 def __init__(self, *args: Any, **kwargs: Any) -> None:

4563 self.title = "" # for type checker

4564 self.description = "" # for type checker

4565 super().__init__(*args, **kwargs)

4566

4567 # noinspection PyUnusedLocal

4568 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:

4569 _ = self.gettext

4570 self.title = _("HL7 Identifier Type")

4571 self.description = _(

4572 "For HL7 messaging: "

4573 "HL7 Identifier Type code: ‘a code corresponding to the type "

4574 "of identifier. In some cases, this code may be used as a "

4575 "qualifier to the “Assigning Authority” component.’"

4576 )

4577

4578 # noinspection PyMethodMayBeStatic

4579 def validator(self, node: SchemaNode, value: str) -> None:

4580 try:

4581 validate_hl7_id_type(value, self.request)

4582 except ValueError as e:

4583 raise Invalid(node, str(e))

4584

4585

4586 class FHIRIdSystemUrlNode(OptionalStringNode, RequestAwareMixin):

4587 """

4588 Optional node to capture the URL for a FHIR ID system:

4589

4590 - https://www.hl7.org/fhir/datatypes.html#Identifier

4591 - https://www.hl7.org/fhir/datatypes-definitions.html#Identifier.system

4592 """

4593

4594 validator = url

4595

4596 def __init__(self, *args: Any, **kwargs: Any) -> None:

4597 self.title = "" # for type checker

4598 self.description = "" # for type checker

4599 super().__init__(*args, **kwargs)

4600

4601 # noinspection PyUnusedLocal

4602 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:

4603 _ = self.gettext

4604 self.title = _("FHIR ID system")

4605 self.description = _("For FHIR exports: URL defining the ID system.")

4606

4607

4608 class EditIdDefinitionSchema(CSRFSchema):

4609 """

4610 Schema to edit an ID number definition.

4611 """

4612

4613 which_idnum = HiddenIntegerNode() # must match ViewParam.WHICH_IDNUM

4614 description = (

4615 IdDefinitionDescriptionNode()

4616 ) # must match ViewParam.DESCRIPTION

4617 short_description = (

4618 IdDefinitionShortDescriptionNode()

4619 ) # must match ViewParam.SHORT_DESCRIPTION

4620 validation_method = (

4621 IdValidationMethodNode()

4622 ) # must match ViewParam.VALIDATION_METHOD

4623 hl7_id_type = Hl7IdTypeNode() # must match ViewParam.HL7_ID_TYPE

4624 hl7_assigning_authority = (

4625 Hl7AssigningAuthorityNode()

4626 ) # must match ViewParam.HL7_ASSIGNING_AUTHORITY

4627 fhir_id_system = (

4628 FHIRIdSystemUrlNode()

4629 ) # must match ViewParam.FHIR_ID_SYSTEM

4630

4631 def validator(self, node: SchemaNode, value: Any) -> None:

4632 request = self.bindings[Binding.REQUEST] # type: CamcopsRequest

4633 _ = request.gettext

4634 qd = (

4635 CountStarSpecializedQuery(

4636 IdNumDefinition, session=request.dbsession # type: ignore[arg-type] # noqa: E501

4637 )

4638 .filter(

4639 IdNumDefinition.which_idnum != value[ViewParam.WHICH_IDNUM]

4640 )

4641 .filter(

4642 IdNumDefinition.description == value[ViewParam.DESCRIPTION]

4643 )

4644 )

4645 if qd.count_star() > 0:

4646 raise Invalid(node, _("Description is used by another ID number!"))

4647 qs = (

4648 CountStarSpecializedQuery(

4649 IdNumDefinition, session=request.dbsession # type: ignore[arg-type] # noqa: E501

4650 )

4651 .filter(

4652 IdNumDefinition.which_idnum != value[ViewParam.WHICH_IDNUM]

4653 )

4654 .filter(

4655 IdNumDefinition.short_description

4656 == value[ViewParam.SHORT_DESCRIPTION]

4657 )

4658 )

4659 if qs.count_star() > 0:

4660 raise Invalid(

4661 node, _("Short description is used by another ID number!")

4662 )

4663

4664

4665 class EditIdDefinitionForm(ApplyCancelForm):

4666 """

4667 Form to edit an ID number definition.

4668 """

4669

4670 def __init__(self, request: "CamcopsRequest", **kwargs: Any) -> None:

4671 super().__init__(

4672 schema_class=EditIdDefinitionSchema, request=request, **kwargs

4673 )

4674

4675

4676 class AddIdDefinitionSchema(CSRFSchema):

4677 """

4678 Schema to add an ID number definition.

4679 """

4680

4681 which_idnum = SchemaNode( # must match ViewParam.WHICH_IDNUM

4682 Integer(), validator=Range(min=1)

4683 )

4684 description = (

4685 IdDefinitionDescriptionNode()

4686 ) # must match ViewParam.DESCRIPTION

4687 short_description = (

4688 IdDefinitionShortDescriptionNode()

4689 ) # must match ViewParam.SHORT_DESCRIPTION

4690 validation_method = (

4691 IdValidationMethodNode()

4692 ) # must match ViewParam.VALIDATION_METHOD

4693

4694 # noinspection PyUnusedLocal

4695 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:

4696 _ = self.gettext

4697 which_idnum = get_child_node(self, "which_idnum")

4698 which_idnum.title = _("Which ID number?")

4699 which_idnum.description = (

4700 "Specify the integer to represent the type of this ID "

4701 "number class (e.g. consecutive numbering from 1)"

4702 )

4703

4704 def validator(self, node: SchemaNode, value: Any) -> None:

4705 request = self.bindings[Binding.REQUEST] # type: CamcopsRequest

4706 _ = request.gettext

4707 qw = CountStarSpecializedQuery(

4708 IdNumDefinition, session=request.dbsession # type: ignore[arg-type] # noqa: E501

4709 ).filter(IdNumDefinition.which_idnum == value[ViewParam.WHICH_IDNUM])

4710 if qw.count_star() > 0:

4711 raise Invalid(node, _("ID# clashes with another ID number!"))

4712 qd = CountStarSpecializedQuery(

4713 IdNumDefinition, session=request.dbsession # type: ignore[arg-type] # noqa: E501

4714 ).filter(IdNumDefinition.description == value[ViewParam.DESCRIPTION])

4715 if qd.count_star() > 0:

4716 raise Invalid(node, _("Description is used by another ID number!"))

4717 qs = CountStarSpecializedQuery(

4718 IdNumDefinition, session=request.dbsession # type: ignore[arg-type] # noqa: E501

4719 ).filter(

4720 IdNumDefinition.short_description

4721 == value[ViewParam.SHORT_DESCRIPTION]

4722 )

4723 if qs.count_star() > 0:

4724 raise Invalid(

4725 node, _("Short description is used by another ID number!")

4726 )

4727

4728

4729 class AddIdDefinitionForm(AddCancelForm):

4730 """

4731 Form to add an ID number definition.

4732 """

4733

4734 def __init__(self, request: "CamcopsRequest", **kwargs: Any) -> None:

4735 super().__init__(

4736 schema_class=AddIdDefinitionSchema, request=request, **kwargs

4737 )

4738

4739

4740 class DeleteIdDefinitionSchema(HardWorkConfirmationSchema):

4741 """

4742 Schema to delete an ID number definition.

4743 """

4744

4745 which_idnum = HiddenIntegerNode() # name must match ViewParam.WHICH_IDNUM

4746 danger = TranslatableValidateDangerousOperationNode()

4747

4748

4749 class DeleteIdDefinitionForm(DangerousForm):

4750 """

4751 Form to add an ID number definition.

4752 """

4753

4754 def __init__(self, request: "CamcopsRequest", **kwargs: Any) -> None:

4755 _ = request.gettext

4756 super().__init__(

4757 schema_class=DeleteIdDefinitionSchema,

4758 submit_action=FormAction.DELETE,

4759 submit_title=_("Delete"),

4760 request=request,

4761 **kwargs,

4762 )

4763

4764

4765# =============================================================================

4766# Special notes

4767# =============================================================================

4768

4769

4770 class AddSpecialNoteSchema(CSRFSchema):

4771 """

4772 Schema to add a special note to a task.

4773 """

4774

4775 table_name = HiddenStringNode() # must match ViewParam.TABLENAME

4776 server_pk = HiddenIntegerNode() # must match ViewParam.SERVER_PK

4777 note = MandatoryStringNode( # must match ViewParam.NOTE

4778 widget=TextAreaWidget(rows=20, cols=80)

4779 )

4780 danger = TranslatableValidateDangerousOperationNode()

4781

4782

4783 class AddSpecialNoteForm(DangerousForm):

4784 """

4785 Form to add a special note to a task.

4786 """

4787

4788 def __init__(self, request: "CamcopsRequest", **kwargs: Any) -> None:

4789 _ = request.gettext

4790 super().__init__(

4791 schema_class=AddSpecialNoteSchema,

4792 submit_action=FormAction.SUBMIT,

4793 submit_title=_("Add"),

4794 request=request,

4795 **kwargs,

4796 )

4797

4798

4799 class DeleteSpecialNoteSchema(CSRFSchema):

4800 """

4801 Schema to add a special note to a task.

4802 """

4803

4804 note_id = HiddenIntegerNode() # must match ViewParam.NOTE_ID

4805 danger = TranslatableValidateDangerousOperationNode()

4806

4807

4808 class DeleteSpecialNoteForm(DangerousForm):

4809 """

4810 Form to delete (hide) a special note.

4811 """

4812

4813 def __init__(self, request: "CamcopsRequest", **kwargs: Any) -> None:

4814 _ = request.gettext

4815 super().__init__(

4816 schema_class=DeleteSpecialNoteSchema,

4817 submit_action=FormAction.SUBMIT,

4818 submit_title=_("Delete"),

4819 request=request,

4820 **kwargs,

4821 )

4822

4823

4824# =============================================================================

4825# The unusual data manipulation operations

4826# =============================================================================

4827

4828

4829 class EraseTaskSchema(HardWorkConfirmationSchema):

4830 """

4831 Schema to erase a task.

4832 """

4833

4834 table_name = HiddenStringNode() # must match ViewParam.TABLENAME

4835 server_pk = HiddenIntegerNode() # must match ViewParam.SERVER_PK

4836 danger = TranslatableValidateDangerousOperationNode()

4837

4838

4839 class EraseTaskForm(DangerousForm):

4840 """

4841 Form to erase a task.

4842 """

4843

4844 def __init__(self, request: "CamcopsRequest", **kwargs: Any) -> None:

4845 _ = request.gettext

4846 super().__init__(

4847 schema_class=EraseTaskSchema,

4848 submit_action=FormAction.DELETE,

4849 submit_title=_("Erase"),

4850 request=request,

4851 **kwargs,

4852 )

4853

4854

4855 class DeletePatientChooseSchema(CSRFSchema):

4856 """

4857 Schema to delete a patient.

4858 """

4859

4860 which_idnum = (

4861 MandatoryWhichIdNumSelector()

4862 ) # must match ViewParam.WHICH_IDNUM

4863 idnum_value = MandatoryIdNumValue() # must match ViewParam.IDNUM_VALUE

4864 group_id = (

4865 MandatoryGroupIdSelectorAdministeredGroups()

4866 ) # must match ViewParam.GROUP_ID

4867 danger = TranslatableValidateDangerousOperationNode()

4868

4869

4870 class DeletePatientChooseForm(DangerousForm):

4871 """

4872 Form to delete a patient.

4873 """

4874

4875 def __init__(self, request: "CamcopsRequest", **kwargs: Any) -> None:

4876 _ = request.gettext

4877 super().__init__(

4878 schema_class=DeletePatientChooseSchema,

4879 submit_action=FormAction.SUBMIT,

4880 submit_title=_("Show tasks that will be deleted"),

4881 request=request,

4882 **kwargs,

4883 )

4884

4885

4886 class DeletePatientConfirmSchema(HardWorkConfirmationSchema):

4887 """

4888 Schema to confirm deletion of a patient.

4889 """

4890

4891 which_idnum = HiddenIntegerNode() # must match ViewParam.WHICH_IDNUM

4892 idnum_value = HiddenIntegerNode() # must match ViewParam.IDNUM_VALUE

4893 group_id = HiddenIntegerNode() # must match ViewParam.GROUP_ID

4894 danger = TranslatableValidateDangerousOperationNode()

4895

4896

4897 class DeletePatientConfirmForm(DangerousForm):

4898 """

4899 Form to confirm deletion of a patient.

4900 """

4901

4902 def __init__(self, request: "CamcopsRequest", **kwargs: Any) -> None:

4903 _ = request.gettext

4904 super().__init__(

4905 schema_class=DeletePatientConfirmSchema,

4906 submit_action=FormAction.DELETE,

4907 submit_title=_("Delete"),

4908 request=request,

4909 **kwargs,

4910 )

4911

4912

4913 class DeleteServerCreatedPatientSchema(HardWorkConfirmationSchema):

4914 """

4915 Schema to delete a patient created on the server.

4916 """

4917

4918 # name must match ViewParam.SERVER_PK

4919 server_pk = HiddenIntegerNode()

4920 danger = TranslatableValidateDangerousOperationNode()

4921

4922

4923 class DeleteServerCreatedPatientForm(DeleteCancelForm):

4924 """

4925 Form to delete a patient created on the server

4926 """

4927

4928 def __init__(self, request: "CamcopsRequest", **kwargs: Any) -> None:

4929 super().__init__(

4930 schema_class=DeleteServerCreatedPatientSchema,

4931 request=request,

4932 **kwargs,

4933 )

4934

4935

4936 EDIT_PATIENT_SIMPLE_PARAMS = [

4937 ViewParam.FORENAME,

4938 ViewParam.SURNAME,

4939 ViewParam.DOB,

4940 ViewParam.SEX,

4941 ViewParam.ADDRESS,

4942 ViewParam.EMAIL,

4943 ViewParam.GP,

4944 ViewParam.OTHER,

4945]

4946

4947

4948 class TaskScheduleSelector(SchemaNode, RequestAwareMixin):

4949 """

4950 Drop-down with all available task schedules

4951 """

4952

4953 widget = SelectWidget()

4954

4955 def __init__(self, *args: Any, **kwargs: Any) -> None:

4956 self.title = "" # for type checker

4957 self.name = "" # for type checker

4958 self.validator = None # type: Optional[ValidatorType]

4959 super().__init__(*args, **kwargs)

4960

4961 # noinspection PyUnusedLocal

4962 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:

4963 request = self.request

4964 _ = request.gettext

4965 self.title = _("Task schedule")

4966 values = [] # type: List[Tuple[Optional[int], str]]

4967

4968 valid_group_ids = (

4969 request.user.ids_of_groups_user_may_manage_patients_in

4970 )

4971 task_schedules = (

4972 request.dbsession.query(TaskSchedule)

4973 .filter(TaskSchedule.group_id.in_(valid_group_ids))

4974 .order_by(TaskSchedule.name)

4975 )

4976

4977 for task_schedule in task_schedules:

4978 values.append((task_schedule.id, task_schedule.name))

4979 values, pv = get_values_and_permissible(values, add_none=False)

4980

4981 self.widget.values = values

4982 self.validator = OneOf(pv)

4983

4984 @staticmethod

4985 def schema_type() -> SchemaType:

4986 return Integer()

4987

4988

4989 class JsonType(SchemaType):

4990 """

4991 Schema type for JsonNode

4992 """

4993

4994 # noinspection PyMethodMayBeStatic, PyUnusedLocal

4995 def deserialize(

4996 self, node: SchemaNode, cstruct: Union[str, ColanderNullType, None]

4997 ) -> Any:

4998 # is null when form is empty

4999 if cstruct in (null, None):

5000 return None

5001

5002 cstruct: str

5003

5004 try:

5005 # Validation happens on the widget class

5006 json_value = json.loads(cstruct)

5007 except json.JSONDecodeError:

5008 return None

5009

5010 return json_value

5011

5012 # noinspection PyMethodMayBeStatic,PyUnusedLocal

5013 def serialize(

5014 self, node: SchemaNode, appstruct: Union[Dict, None, ColanderNullType]

5015 ) -> Union[str, ColanderNullType]:

5016 # is null when form is empty (new record)

5017 # is None when populated from empty value in the database

5018 if appstruct in (null, None):

5019 return null

5020

5021 # appstruct should be well formed here (it would already have failed

5022 # when reading from the database)

5023 return json.dumps(appstruct)

5024

5025

5026 class JsonWidget(Widget):

5027 """

5028 Widget supporting jsoneditor https://github.com/josdejong/jsoneditor

5029 """

5030

5031 basedir = os.path.join(TEMPLATE_DIR, "deform")

5032 readonlydir = os.path.join(basedir, "readonly")

5033 form = "json.pt"

5034 template = os.path.join(basedir, form)

5035 readonly_template = os.path.join(readonlydir, form)

5036 requirements = (("jsoneditor", None),)

5037

5038 def __init__(self, request: "CamcopsRequest", **kwargs: Any) -> None:

5039 super().__init__(**kwargs)

5040 self.request = request

5041

5042 def serialize(

5043 self, field: "Field", cstruct: Union[str, ColanderNullType], **kw: Any

5044 ) -> Any:

5045 if cstruct is null:

5046 cstruct = ""

5047

5048 readonly = kw.get("readonly", self.readonly)

5049 template = readonly and self.readonly_template or self.template

5050

5051 values = self.get_template_values(field, cstruct, kw)

5052

5053 return field.renderer(template, **values)

5054

5055 def deserialize(

5056 self, field: "Field", pstruct: Union[str, ColanderNullType]

5057 ) -> Union[str, ColanderNullType]:

5058 # is empty string when field is empty

5059 if pstruct in (null, ""):

5060 return null

5061

5062 _ = self.request.gettext

5063 error_message = _("Please enter valid JSON or leave blank")

5064

5065 pstruct: str

5066

5067 try:

5068 json.loads(pstruct)

5069 except json.JSONDecodeError:

5070 raise Invalid(field, error_message, pstruct)

5071

5072 return pstruct

5073

5074

5075 class JsonSettingsNode(SchemaNode, RequestAwareMixin):

5076 """

5077 Note to edit raw JSON.

5078 """

5079

5080 schema_type = JsonType

5081 missing = null

5082

5083 # noinspection PyUnusedLocal,PyAttributeOutsideInit

5084 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:

5085 _ = self.gettext

5086 self.widget = JsonWidget(self.request)

5087 self.title = _("Task-specific settings for this patient")

5088 self.description = _(

5089 "ADVANCED. Only applicable to tasks that are configurable on a "

5090 "per-patient basis. Format: JSON object, with settings keyed on "

5091 "task table name."

5092 )

5093

5094 def validator(self, node: SchemaNode, value: Any) -> None:

5095 if value is not None:

5096 # will be None if JSON failed to validate

5097 if not isinstance(value, dict):

5098 _ = self.request.gettext

5099 error_message = _(

5100 "Please enter a valid JSON object (with settings keyed on "

5101 "task table name) or leave blank"

5102 )

5103 raise Invalid(node, error_message)

5104

5105

5106 class TaskScheduleJsonSchema(Schema):

5107 """

5108 Schema for the advanced JSON parts of a patient-to-task-schedule mapping.

5109 """

5110

5111 settings = JsonSettingsNode() # must match ViewParam.SETTINGS

5112

5113

5114 class TaskScheduleNode(MappingSchema, RequestAwareMixin):

5115 """

5116 Node to edit settings for a patient-to-task-schedule mapping.

5117 """

5118

5119 patient_task_schedule_id = (

5120 HiddenIntegerNode()

5121 ) # name must match ViewParam.PATIENT_TASK_SCHEDULE_ID

5122 schedule_id = TaskScheduleSelector() # must match ViewParam.SCHEDULE_ID

5123 start_datetime = (

5124 StartPendulumSelector()

5125 ) # must match ViewParam.START_DATETIME

5126 if DEFORM_ACCORDION_BUG:

5127 settings = JsonSettingsNode() # must match ViewParam.SETTINGS

5128 else:

5129 advanced = TaskScheduleJsonSchema( # must match ViewParam.ADVANCED

5130 widget=MappingWidget(template="mapping_accordion", open=False)

5131 )

5132

5133 def __init__(self, *args: Any, **kwargs: Any) -> None:

5134 self.title = "" # for type checker

5135 super().__init__(*args, **kwargs)

5136

5137 # noinspection PyUnusedLocal

5138 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:

5139 _ = self.gettext

5140 self.title = _("Task schedule")

5141 start_datetime = get_child_node(self, "start_datetime")

5142 start_datetime.description = _(

5143 "Leave blank for the date the patient first downloads the schedule"

5144 )

5145 if not DEFORM_ACCORDION_BUG:

5146 advanced = get_child_node(self, "advanced")

5147 advanced.title = _("Advanced")

5148

5149

5150 class TaskScheduleSequence(SequenceSchema, RequestAwareMixin):

5151 """

5152 Sequence for multiple patient-to-task-schedule mappings.

5153 """

5154

5155 task_schedule_sequence = TaskScheduleNode()

5156 missing = drop

5157

5158 def __init__(self, *args: Any, **kwargs: Any) -> None:

5159 self.title = "" # for type checker

5160 self.widget = None # type: Optional[Widget]

5161 super().__init__(*args, **kwargs)

5162

5163 # noinspection PyUnusedLocal

5164 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:

5165 _ = self.gettext

5166 self.title = _("Task Schedules")

5167 self.widget = TranslatableSequenceWidget(request=self.request)

5168

5169

5170 class EditPatientSchema(CSRFSchema):

5171 """

5172 Schema to edit a patient.

5173 """

5174

5175 server_pk = HiddenIntegerNode() # must match ViewParam.SERVER_PK

5176 forename = OptionalPatientNameNode() # must match ViewParam.FORENAME

5177 surname = OptionalPatientNameNode() # must match ViewParam.SURNAME

5178 dob = DateSelectorNode() # must match ViewParam.DOB

5179 sex = MandatorySexSelector() # must match ViewParam.SEX

5180 address = OptionalStringNode() # must match ViewParam.ADDRESS

5181 email = OptionalEmailNode() # must match ViewParam.EMAIL

5182 gp = OptionalStringNode() # must match ViewParam.GP

5183 other = OptionalStringNode() # must match ViewParam.OTHER

5184 id_references = (

5185 IdNumSequenceUniquePerWhichIdnum()

5186 ) # must match ViewParam.ID_REFERENCES

5187

5188 # noinspection PyUnusedLocal

5189 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:

5190 _ = self.gettext

5191 dob = get_child_node(self, "dob")

5192 dob.title = _("Date of birth")

5193 gp = get_child_node(self, "gp")

5194 gp.title = _("GP")

5195

5196 def validator(self, node: SchemaNode, value: Any) -> None:

5197 request = self.bindings[Binding.REQUEST] # type: CamcopsRequest

5198 dbsession = request.dbsession

5199 group_id = value[ViewParam.GROUP_ID]

5200 group = Group.get_group_by_id(dbsession, group_id)

5201 testpatient = Patient()

5202 for k in EDIT_PATIENT_SIMPLE_PARAMS:

5203 setattr(testpatient, k, value[k])

5204 testpatient.idnums = []

5205 for idrefdict in value[ViewParam.ID_REFERENCES]:

5206 pidnum = PatientIdNum()

5207 pidnum.which_idnum = idrefdict[ViewParam.WHICH_IDNUM]

5208 pidnum.idnum_value = idrefdict[ViewParam.IDNUM_VALUE]

5209 testpatient.idnums.append(pidnum)

5210 tk_finalize_policy = TokenizedPolicy(group.finalize_policy)

5211 if not testpatient.satisfies_id_policy(tk_finalize_policy):

5212 _ = self.gettext

5213 raise Invalid(

5214 node,

5215 _("Patient would not meet 'finalize' ID policy for group:")

5216 + f" {group.name}! ["

5217 + _("That policy is:")

5218 + f" {group.finalize_policy!r}]",

5219 )

5220

5221

5222 class DangerousEditPatientSchema(EditPatientSchema):

5223 group_id = HiddenIntegerNode() # must match ViewParam.GROUP_ID

5224 danger = TranslatableValidateDangerousOperationNode()

5225

5226

5227 class EditServerCreatedPatientSchema(EditPatientSchema):

5228 # Must match ViewParam.GROUP_ID

5229 group_id = MandatoryGroupIdSelectorPatientGroups(insert_before="forename")

5230 task_schedules = (

5231 TaskScheduleSequence()

5232 ) # must match ViewParam.TASK_SCHEDULES

5233

5234

5235 class EditFinalizedPatientForm(DangerousForm):

5236 """

5237 Form to edit a finalized patient.

5238 """

5239

5240 def __init__(self, request: "CamcopsRequest", **kwargs: Any) -> None:

5241 _ = request.gettext

5242 super().__init__(

5243 schema_class=DangerousEditPatientSchema,

5244 submit_action=FormAction.SUBMIT,

5245 submit_title=_("Submit"),

5246 request=request,

5247 **kwargs,

5248 )

5249

5250

5251 class EditServerCreatedPatientForm(DynamicDescriptionsNonceForm):

5252 """

5253 Form to add or edit a patient not yet on the device (for scheduled tasks)

5254 """

5255

5256 def __init__(self, request: "CamcopsRequest", **kwargs: Any) -> None:

5257 schema = EditServerCreatedPatientSchema().bind(request=request)

5258 _ = request.gettext

5259 super().__init__(

5260 schema,

5261 request=request,

5262 buttons=[

5263 Button(

5264 name=FormAction.SUBMIT,

5265 title=_("Submit"),

5266 css_class="btn-danger",

5267 ),

5268 Button(name=FormAction.CANCEL, title=_("Cancel")),

5269 ],

5270 **kwargs,

5271 )

5272

5273

5274 class EmailTemplateNode(OptionalStringNode, RequestAwareMixin):

5275 def __init__(self, *args: Any, **kwargs: Any) -> None:

5276 self.title = "" # for type checker

5277 self.description = "" # for type checker

5278 self.formatter = TaskScheduleEmailTemplateFormatter()

5279 super().__init__(*args, **kwargs)

5280

5281 # noinspection PyUnusedLocal

5282 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:

5283 _ = self.gettext

5284 self.title = _("Email template")

5285 self.description = _(

5286 "Template of email to be sent to patients when inviting them to "

5287 "complete the tasks in the schedule. Valid placeholders: {}"

5288 ).format(self.formatter.get_valid_parameters_string())

5289

5290 # noinspection PyAttributeOutsideInit

5291 self.widget = RichTextWidget(options=get_tinymce_options(self.request))

5292

5293 def validator(self, node: SchemaNode, value: Any) -> None:

5294 _ = self.gettext

5295

5296 try:

5297 self.formatter.validate(value)

5298 return

5299 except KeyError as e:

5300 error = _("{bad_key} is not a valid placeholder").format(bad_key=e)

5301 except ValueError:

5302 error = _(

5303 "Invalid email template. Is there a missing '{' or '}' ?"

5304 )

5305

5306 raise Invalid(node, error)

5307

5308

5309 class EmailCcNode(OptionalEmailNode, RequestAwareMixin):

5310 # noinspection PyUnusedLocal

5311 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:

5312 _ = self.gettext

5313 self.title = _("Email CC")

5314 self.description = _(

5315 "The patient will see these email addresses. Separate multiple "

5316 "addresses with commas."

5317 )

5318

5319

5320 class EmailBccNode(OptionalEmailNode, RequestAwareMixin):

5321 # noinspection PyUnusedLocal

5322 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:

5323 _ = self.gettext

5324 self.title = _("Email BCC")

5325 self.description = _(

5326 "The patient will not see these email addresses. Separate "

5327 "multiple addresses with commas."

5328 )

5329

5330

5331 class EmailFromNode(OptionalEmailNode, RequestAwareMixin):

5332 # noinspection PyUnusedLocal

5333 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:

5334 _ = self.gettext

5335 self.title = _('Email "From" address')

5336 self.description = _(

5337 "You must set this if you want to send emails to your patients"

5338 )

5339

5340

5341 class TaskScheduleSchema(CSRFSchema):

5342 name = OptionalStringNode()

5343 group_id = (

5344 MandatoryGroupIdSelectorAdministeredGroups()

5345 ) # must match ViewParam.GROUP_ID

5346 email_from = EmailFromNode() # must match ViewParam.EMAIL_FROM

5347 email_cc = EmailCcNode() # must match ViewParam.EMAIL_CC

5348 email_bcc = EmailBccNode() # must match ViewParam.EMAIL_BCC

5349 email_subject = OptionalStringNode()

5350 email_template = EmailTemplateNode()

5351

5352

5353 class EditTaskScheduleForm(DynamicDescriptionsNonceForm):

5354 def __init__(self, request: "CamcopsRequest", **kwargs: Any) -> None:

5355 schema = TaskScheduleSchema().bind(request=request)

5356 _ = request.gettext

5357 super().__init__(

5358 schema,

5359 request=request,

5360 buttons=[

5361 Button(

5362 name=FormAction.SUBMIT,

5363 title=_("Submit"),

5364 css_class="btn-danger",

5365 ),

5366 Button(name=FormAction.CANCEL, title=_("Cancel")),

5367 ],

5368 **kwargs,

5369 )

5370

5371

5372 class DeleteTaskScheduleSchema(HardWorkConfirmationSchema):

5373 """

5374 Schema to delete a task schedule.

5375 """

5376

5377 # name must match ViewParam.SCHEDULE_ID

5378 schedule_id = HiddenIntegerNode()

5379 danger = TranslatableValidateDangerousOperationNode()

5380

5381

5382 class DeleteTaskScheduleForm(DeleteCancelForm):

5383 """

5384 Form to delete a task schedule.

5385 """

5386

5387 def __init__(self, request: "CamcopsRequest", **kwargs: Any) -> None:

5388 super().__init__(

5389 schema_class=DeleteTaskScheduleSchema, request=request, **kwargs

5390 )

5391

5392

5393 class DurationWidget(Widget):

5394 """

5395 Widget for entering a duration as a number of months, weeks and days.

5396 The default template renders three text input fields.

5397 Total days = (months * 30) + (weeks * 7) + days.

5398 """

5399

5400 basedir = os.path.join(TEMPLATE_DIR, "deform")

5401 readonlydir = os.path.join(basedir, "readonly")

5402 form = "duration.pt"

5403 template = os.path.join(basedir, form)

5404 readonly_template = os.path.join(readonlydir, form)

5405

5406 def __init__(self, request: "CamcopsRequest", **kwargs: Any) -> None:

5407 super().__init__(**kwargs)

5408 self.request = request

5409

5410 def serialize(

5411 self,

5412 field: "Field",

5413 cstruct: Union[Dict[str, Any], None, ColanderNullType],

5414 **kw: Any,

5415 ) -> Any:

5416 # called when rendering the form with values from

5417 # DurationType.serialize

5418 if cstruct in (None, null):

5419 cstruct = {}

5420

5421 cstruct: Dict[str, Any]

5422

5423 months = cstruct.get("months", "")

5424 weeks = cstruct.get("weeks", "")

5425 days = cstruct.get("days", "")

5426

5427 kw.setdefault("months", months)

5428 kw.setdefault("weeks", weeks)

5429 kw.setdefault("days", days)

5430

5431 readonly = kw.get("readonly", self.readonly)

5432 template = readonly and self.readonly_template or self.template

5433 values = self.get_template_values(field, cstruct, kw)

5434

5435 _ = self.request.gettext

5436

5437 values.update(

5438 weeks_placeholder=_("1 week = 7 days"),

5439 months_placeholder=_("1 month = 30 days"),

5440 months_label=_("Months"),

5441 weeks_label=_("Weeks"),

5442 days_label=_("Days"),

5443 )

5444

5445 return field.renderer(template, **values)

5446

5447 def deserialize(

5448 self, field: "Field", pstruct: Union[Dict[str, Any], ColanderNullType]

5449 ) -> Dict[str, int]:

5450 # called when validating the form on submission

5451 # value is passed to the schema deserialize()

5452

5453 if pstruct is null:

5454 pstruct = {}

5455

5456 pstruct: Dict[str, Any]

5457

5458 errors = []

5459

5460 try:

5461 days = int(pstruct.get("days") or "0")

5462 except ValueError:

5463 errors.append("Please enter a valid number of days or leave blank")

5464

5465 try:

5466 weeks = int(pstruct.get("weeks") or "0")

5467 except ValueError:

5468 errors.append(

5469 "Please enter a valid number of weeks or leave blank"

5470 )

5471

5472 try:

5473 months = int(pstruct.get("months") or "0")

5474 except ValueError:

5475 errors.append(

5476 "Please enter a valid number of months or leave blank"

5477 )

5478

5479 if len(errors) > 0:

5480 raise Invalid(field, errors, pstruct)

5481

5482 # noinspection PyUnboundLocalVariable

5483 return {"days": days, "months": months, "weeks": weeks}

5484

5485

5486 class DurationType(SchemaType):

5487 """

5488 Custom colander schema type to convert between Pendulum Duration objects

5489 and months, weeks and days.

5490 """

5491

5492 # noinspection PyMethodMayBeStatic,PyUnusedLocal

5493 def deserialize(

5494 self,

5495 node: SchemaNode,

5496 cstruct: Union[Dict[str, Any], None, ColanderNullType],

5497 ) -> Optional[Duration]:

5498 # called when validating the submitted form with the total days

5499 # from DurationWidget.deserialize()

5500 if cstruct in (None, null):

5501 return None

5502

5503 cstruct: Dict[str, Any]

5504

5505 # may be passed invalid values when re-rendering widget with error

5506 # messages

5507 try:

5508 days = int(cstruct.get("days") or "0")

5509 except ValueError:

5510 days = 0

5511

5512 try:

5513 weeks = int(cstruct.get("weeks") or "0")

5514 except ValueError:

5515 weeks = 0

5516

5517 try:

5518 months = int(cstruct.get("months") or "0")

5519 except ValueError:

5520 months = 0

5521

5522 total_days = months * 30 + weeks * 7 + days

5523

5524 return Duration(days=total_days)

5525

5526 # noinspection PyMethodMayBeStatic,PyUnusedLocal

5527 def serialize(

5528 self, node: SchemaNode, duration: Union[Duration, ColanderNullType]

5529 ) -> Union[Dict, ColanderNullType]:

5530 if duration is null:

5531 # For new schedule item

5532 return null

5533

5534 duration: Duration

5535

5536 total_days = duration.in_days()

5537

5538 months = total_days // 30

5539 weeks = (total_days % 30) // 7

5540 days = (total_days % 30) % 7

5541

5542 # Existing schedule item

5543 cstruct = {"days": days, "months": months, "weeks": weeks}

5544

5545 return cstruct

5546

5547

5548 class DurationNode(SchemaNode, RequestAwareMixin):

5549 schema_type = DurationType

5550

5551 # noinspection PyUnusedLocal,PyAttributeOutsideInit

5552 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:

5553 self.widget = DurationWidget(self.request)

5554

5555

5556 class TaskScheduleItemSchema(CSRFSchema):

5557 schedule_id = HiddenIntegerNode() # name must match ViewParam.SCHEDULE_ID

5558 # name must match ViewParam.TABLE_NAME

5559 table_name = MandatorySingleTaskSelector()

5560 # name must match ViewParam.CLINICIAN_CONFIRMATION

5561 clinician_confirmation = BooleanNode(default=False)

5562 due_from = DurationNode() # name must match ViewParam.DUE_FROM

5563 due_within = DurationNode() # name must match ViewParam.DUE_WITHIN

5564

5565 # noinspection PyUnusedLocal

5566 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:

5567 _ = self.gettext

5568 due_from = get_child_node(self, "due_from")

5569 due_from.title = _("Due from")

5570 due_from.description = _(

5571 "Time from the start of schedule when the patient may begin this "

5572 "task"

5573 )

5574 due_within = get_child_node(self, "due_within")

5575 due_within.title = _("Due within")

5576 due_within.description = _(

5577 "Time the patient has to complete this task"

5578 )

5579 clinician_confirmation = get_child_node(self, "clinician_confirmation")

5580 clinician_confirmation.title = _("Allow clinician tasks")

5581 clinician_confirmation.label = None # type: ignore[attr-defined]

5582 clinician_confirmation.description = _(

5583 "Tick this box to schedule a task that would normally be "

5584 "completed by (or with) a clinician"

5585 )

5586

5587 def validator(self, node: SchemaNode, value: Dict[str, Any]) -> None:

5588 task_class = self._get_task_class(value)

5589

5590 self._validate_clinician_status(node, value, task_class)

5591 self._validate_due_dates(node, value)

5592 self._validate_task_ip_use(node, value, task_class)

5593

5594 # noinspection PyMethodMayBeStatic

5595 def _get_task_class(self, value: Dict[str, Any]) -> Type["Task"]:

5596 return tablename_to_task_class_dict()[value[ViewParam.TABLE_NAME]]

5597

5598 def _validate_clinician_status(

5599 self, node: SchemaNode, value: Dict[str, Any], task_class: Type["Task"]

5600 ) -> None:

5601

5602 _ = self.gettext

5603 clinician_confirmation = value[ViewParam.CLINICIAN_CONFIRMATION]

5604 if task_class.has_clinician and not clinician_confirmation:

5605 raise Invalid(

5606 node,

5607 _(

5608 "You have selected the task '{task_name}', which a "

5609 "patient would not normally complete by themselves. "

5610 "If you are sure you want to do this, you must tick "

5611 "'Allow clinician tasks'."

5612 ).format(task_name=task_class.shortname),

5613 )

5614

5615 def _validate_due_dates(

5616 self, node: SchemaNode, value: Dict[str, Any]

5617 ) -> None:

5618 _ = self.gettext

5619 due_from = value[ViewParam.DUE_FROM]

5620 if due_from.total_days() < 0:

5621 raise Invalid(node, _("'Due from' must be zero or more days"))

5622

5623 due_within = value[ViewParam.DUE_WITHIN]

5624 if due_within.total_days() <= 0:

5625 raise Invalid(node, _("'Due within' must be more than zero days"))

5626

5627 def _validate_task_ip_use(

5628 self, node: SchemaNode, value: Dict[str, Any], task_class: Type["Task"]

5629 ) -> None:

5630

5631 _ = self.gettext

5632

5633 if not task_class.prohibits_anything():

5634 return

5635

5636 schedule_id = value[ViewParam.SCHEDULE_ID]

5637 schedule = (

5638 self.request.dbsession.query(TaskSchedule)

5639 .filter(TaskSchedule.id == schedule_id)

5640 .one()

5641 )

5642

5643 if schedule.group.ip_use is None:

5644 raise Invalid(

5645 node,

5646 _(

5647 "The task you have selected prohibits use in certain "

5648 "contexts. The group '{group_name}' has no intellectual "

5649 "property settings. "

5650 "You need to edit the group '{group_name}' to say which "

5651 "contexts it operates in.".format(

5652 group_name=schedule.group.name

5653 )

5654 ),

5655 )

5656

5657 # TODO: On the client we say 'to use this task, you must seek

5658 # permission from the copyright holder'. We could do the same but at

5659 # the moment there isn't a way of telling the system that we have done

5660 # so.

5661 if (

5662 task_class.prohibits_commercial

5663 and schedule.group.ip_use.commercial

5664 ):

5665 raise Invalid(

5666 node,

5667 _(

5668 "The group '{group_name}' associated with schedule "

5669 "'{schedule_name}' operates in a "

5670 "commercial context but the task you have selected "

5671 "prohibits commercial use."

5672 ).format(

5673 group_name=schedule.group.name, schedule_name=schedule.name

5674 ),

5675 )

5676

5677 if task_class.prohibits_clinical and schedule.group.ip_use.clinical:

5678 raise Invalid(

5679 node,

5680 _(

5681 "The group '{group_name}' associated with schedule "

5682 "'{schedule_name}' operates in a "

5683 "clinical context but the task you have selected "

5684 "prohibits clinical use."

5685 ).format(

5686 group_name=schedule.group.name, schedule_name=schedule.name

5687 ),

5688 )

5689

5690 if (

5691 task_class.prohibits_educational

5692 and schedule.group.ip_use.educational

5693 ):

5694 raise Invalid(

5695 node,

5696 _(

5697 "The group '{group_name}' associated with schedule "

5698 "'{schedule_name}' operates in an "

5699 "educational context but the task you have selected "

5700 "prohibits educational use."

5701 ).format(

5702 group_name=schedule.group.name, schedule_name=schedule.name

5703 ),

5704 )

5705

5706 if task_class.prohibits_research and schedule.group.ip_use.research:

5707 raise Invalid(

5708 node,

5709 _(

5710 "The group '{group_name}' associated with schedule "

5711 "'{schedule_name}' operates in a "

5712 "research context but the task you have selected "

5713 "prohibits research use."

5714 ).format(

5715 group_name=schedule.group.name, schedule_name=schedule.name

5716 ),

5717 )

5718

5719

5720 class EditTaskScheduleItemForm(DynamicDescriptionsNonceForm):

5721 def __init__(self, request: "CamcopsRequest", **kwargs: Any) -> None:

5722 schema = TaskScheduleItemSchema().bind(request=request)

5723 _ = request.gettext

5724 super().__init__(

5725 schema,

5726 request=request,

5727 buttons=[

5728 Button(

5729 name=FormAction.SUBMIT,

5730 title=_("Submit"),

5731 css_class="btn-danger",

5732 ),

5733 Button(name=FormAction.CANCEL, title=_("Cancel")),

5734 ],

5735 **kwargs,

5736 )

5737

5738

5739 class DeleteTaskScheduleItemSchema(HardWorkConfirmationSchema):

5740 """

5741 Schema to delete a task schedule item.

5742 """

5743

5744 # name must match ViewParam.SCHEDULE_ITEM_ID

5745 schedule_item_id = HiddenIntegerNode()

5746 danger = TranslatableValidateDangerousOperationNode()

5747

5748

5749 class DeleteTaskScheduleItemForm(DeleteCancelForm):

5750 """

5751 Form to delete a task schedule item.

5752 """

5753

5754 def __init__(self, request: "CamcopsRequest", **kwargs: Any) -> None:

5755 super().__init__(

5756 schema_class=DeleteTaskScheduleItemSchema,

5757 request=request,

5758 **kwargs,

5759 )

5760

5761

5762 class ForciblyFinalizeChooseDeviceSchema(CSRFSchema):

5763 """

5764 Schema to force-finalize records from a device.

5765 """

5766

5767 device_id = MandatoryDeviceIdSelector() # must match ViewParam.DEVICE_ID

5768 danger = TranslatableValidateDangerousOperationNode()

5769

5770

5771 class ForciblyFinalizeChooseDeviceForm(SimpleSubmitForm):

5772 """

5773 Form to force-finalize records from a device.

5774 """

5775

5776 def __init__(self, request: "CamcopsRequest", **kwargs: Any) -> None:

5777 _ = request.gettext

5778 super().__init__(

5779 schema_class=ForciblyFinalizeChooseDeviceSchema,

5780 submit_title=_("View affected tasks"),

5781 request=request,

5782 **kwargs,

5783 )

5784

5785

5786 class ForciblyFinalizeConfirmSchema(HardWorkConfirmationSchema):

5787 """

5788 Schema to confirm force-finalizing of a device.

5789 """

5790

5791 device_id = HiddenIntegerNode() # must match ViewParam.DEVICE_ID

5792 danger = TranslatableValidateDangerousOperationNode()

5793

5794

5795 class ForciblyFinalizeConfirmForm(DangerousForm):

5796 """

5797 Form to confirm force-finalizing of a device.

5798 """

5799

5800 def __init__(self, request: "CamcopsRequest", **kwargs: Any) -> None:

5801 _ = request.gettext

5802 super().__init__(

5803 schema_class=ForciblyFinalizeConfirmSchema,

5804 submit_action=FormAction.FINALIZE,

5805 submit_title=_("Forcibly finalize"),

5806 request=request,

5807 **kwargs,

5808 )

5809

5810

5811# =============================================================================

5812# User downloads

5813# =============================================================================

5814

5815

5816 class HiddenDownloadFilenameNode(HiddenStringNode, RequestAwareMixin):

5817 """

5818 Note to encode a hidden filename.

5819 """

5820

5821 # noinspection PyMethodMayBeStatic

5822 def validator(self, node: SchemaNode, value: str) -> None:

5823 if value:

5824 try:

5825 validate_download_filename(value, self.request)

5826 except ValueError as e:

5827 raise Invalid(node, str(e))

5828

5829

5830 class UserDownloadDeleteSchema(CSRFSchema):

5831 """

5832 Schema to capture details of a file to be deleted.

5833 """

5834

5835 filename = (

5836 HiddenDownloadFilenameNode()

5837 ) # name must match ViewParam.FILENAME

5838

5839

5840 class UserDownloadDeleteForm(SimpleSubmitForm):

5841 """

5842 Form that provides a single button to delete a user download.

5843 """

5844

5845 def __init__(self, request: "CamcopsRequest", **kwargs: Any) -> None:

5846 _ = request.gettext

5847 super().__init__(

5848 schema_class=UserDownloadDeleteSchema,

5849 submit_title=_("Delete"),

5850 request=request,

5851 **kwargs,

5852 )

5853

5854

5855 class EmailBodyNode(MandatoryStringNode, RequestAwareMixin):

5856 def __init__(self, *args: Any, **kwargs: Any) -> None:

5857 self.title = "" # for type checker

5858 super().__init__(*args, **kwargs)

5859

5860 # noinspection PyUnusedLocal

5861 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:

5862 _ = self.gettext

5863

5864 self.title = _("Message")

5865

5866 # noinspection PyAttributeOutsideInit

5867 self.widget = RichTextWidget(options=get_tinymce_options(self.request))

5868

5869

5870 class SendEmailSchema(CSRFSchema):

5871 email = MandatoryEmailNode() # name must match ViewParam.EMAIL

5872 email_cc = HiddenStringNode()

5873 email_bcc = HiddenStringNode()

5874 email_from = HiddenStringNode()

5875 email_subject = MandatoryStringNode()

5876 email_body = EmailBodyNode()

5877

5878

5879 class SendEmailForm(InformativeNonceForm):

5880 """

5881 Form for sending email

5882 """

5883

5884 def __init__(self, request: "CamcopsRequest", **kwargs: Any) -> None:

5885 schema = SendEmailSchema().bind(request=request)

5886 _ = request.gettext

5887 super().__init__(

5888 schema,

5889 buttons=[

5890 Button(name=FormAction.SUBMIT, title=_("Send")),

5891 Button(name=FormAction.CANCEL, title=_("Cancel")),

5892 ],

5893 **kwargs,

5894 )