Coverage for cc_modules/cc_forms.py: 52%
2283 statements
« prev ^ index » next coverage.py v6.5.0, created at 2022-11-08 23:14 +0000
« prev ^ index » next coverage.py v6.5.0, created at 2022-11-08 23:14 +0000
1#!/usr/bin/env python
3"""
4camcops_server/cc_modules/cc_forms.py
6===============================================================================
8 Copyright (C) 2012, University of Cambridge, Department of Psychiatry.
9 Created by Rudolf Cardinal (rnc1001@cam.ac.uk).
11 This file is part of CamCOPS.
13 CamCOPS is free software: you can redistribute it and/or modify
14 it under the terms of the GNU General Public License as published by
15 the Free Software Foundation, either version 3 of the License, or
16 (at your option) any later version.
18 CamCOPS is distributed in the hope that it will be useful,
19 but WITHOUT ANY WARRANTY; without even the implied warranty of
20 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
21 GNU General Public License for more details.
23 You should have received a copy of the GNU General Public License
24 along with CamCOPS. If not, see <https://www.gnu.org/licenses/>.
26===============================================================================
28.. _Deform: https://docs.pylonsproject.org/projects/deform/en/latest/
30**Forms for use by the web front end.**
32*COLANDER NODES, NULLS, AND VALIDATION*
34- Surprisingly tricky.
35- Nodes must be validly intialized with NO USER-DEFINED PARAMETERS to __init__;
36 the Deform framework clones them.
37- A null appstruct is used to initialize nodes as Forms are created.
38 Therefore, the "default" value must be acceptable to the underlying type's
39 serialize() function. Note in particular that "default = None" is not
40 acceptable to Integer. Having no default is fine, though.
41- In general, flexible inheritance is very hard to implement.
43- Note that this error:
45 .. code-block:: none
47 AttributeError: 'EditTaskFilterSchema' object has no attribute 'typ'
49 means you have failed to call super().__init__() properly from __init__().
51- When creating a schema, its members seem to have to be created in the class
52 declaration as class properties, not in __init__().
54*ACCESSING THE PYRAMID REQUEST IN FORMS AND SCHEMAS*
56We often want to be able to access the request for translation purposes, or
57sometimes more specialized reasons.
59Forms are created dynamically as simple Python objects. So, for a
60:class:`deform.form.Form`, just add a ``request`` parameter to the constructor,
61and pass it when you create the form. An example is
62:class:`camcops_server.cc_modules.cc_forms.DeleteCancelForm`.
64For a :class:`colander.Schema` and :class:`colander.SchemaNode`, construction
65is separate from binding. The schema nodes are created as part of a schema
66class, not a schema instance. The schema is created by the form, and then bound
67to a request. Access to the request is therefore via the :func:`after_bind`
68callback function, offered by colander, via the ``kw`` parameter or
69``self.bindings``. We use ``Binding.REQUEST`` as a standard key for this
70dictionary. The bindings are also available in :func:`validator` and similar
71functions, as ``self.bindings``.
73All forms containing any schema that needs to see the request should have this
74sort of ``__init__`` function:
76.. code-block:: python
78 class SomeForm(...):
79 def __init__(...):
80 schema = schema_class().bind(request=request)
81 super().__init__(
82 schema,
83 ...,
84 **kwargs
85 )
87The simplest thing, therefore, is for all forms to do this. Some of our forms
88use a form superclass that does this via the ``schema_class`` argument (which
89is not part of colander, so if you see that, the superclass should do the work
90of binding a request).
92For translation, throughout there will be ``_ = self.gettext`` or ``_ =
93request.gettext``.
95Form titles need to be dynamically written via
96:class:`cardinal_pythonlib.deform_utils.DynamicDescriptionsForm` or similar.
98.. glossary::
100 cstruct
101 See `cstruct
102 <https://docs.pylonsproject.org/projects/deform/en/latest/glossary.html#term-cstruct>`_
103 in the Deform_ docs.
105 Colander
106 See `Colander
107 <https://docs.pylonsproject.org/projects/deform/en/latest/glossary.html#term-colander>`_
108 in the Deform_ docs.
110 field
111 See `field
112 <https://docs.pylonsproject.org/projects/deform/en/latest/glossary.html#term-field>`_
113 in the Deform_ docs.
115 Peppercorn
116 See `Peppercorn
117 <https://docs.pylonsproject.org/projects/deform/en/latest/glossary.html#term-peppercorn>`_
118 in the Deform_ docs.
120 pstruct
121 See `pstruct
122 <https://docs.pylonsproject.org/projects/deform/en/latest/glossary.html#term-pstruct>`_
123 in the Deform_ docs.
125""" # noqa
127from io import BytesIO
128import json
129import logging
130import os
131from typing import (
132 Any,
133 Callable,
134 Dict,
135 List,
136 Optional,
137 Tuple,
138 Type,
139 TYPE_CHECKING,
140 Union,
141)
143from cardinal_pythonlib.colander_utils import (
144 AllowNoneType,
145 BooleanNode,
146 DateSelectorNode,
147 DateTimeSelectorNode,
148 DEFAULT_WIDGET_DATE_OPTIONS_FOR_PENDULUM,
149 DEFAULT_WIDGET_TIME_OPTIONS_FOR_PENDULUM,
150 get_child_node,
151 get_values_and_permissible,
152 HiddenIntegerNode,
153 HiddenStringNode,
154 MandatoryEmailNode,
155 MandatoryStringNode,
156 OptionalEmailNode,
157 OptionalIntNode,
158 OptionalPendulumNode,
159 OptionalStringNode,
160 ValidateDangerousOperationNode,
161)
162from cardinal_pythonlib.deform_utils import (
163 DynamicDescriptionsForm,
164 InformativeForm,
165)
166from cardinal_pythonlib.httpconst import HttpMethod
167from cardinal_pythonlib.logs import BraceStyleAdapter
168from cardinal_pythonlib.sqlalchemy.dialect import SqlaDialectName
169from cardinal_pythonlib.sqlalchemy.orm_query import CountStarSpecializedQuery
171# noinspection PyProtectedMember
172from colander import (
173 Boolean,
174 Date,
175 drop,
176 Integer,
177 Invalid,
178 Length,
179 MappingSchema,
180 null,
181 OneOf,
182 Range,
183 Schema,
184 SchemaNode,
185 SchemaType,
186 SequenceSchema,
187 Set,
188 String,
189 _null,
190 url,
191)
192from deform.form import Button
193from deform.widget import (
194 CheckboxChoiceWidget,
195 CheckedPasswordWidget,
196 # DateInputWidget,
197 DateTimeInputWidget,
198 FormWidget,
199 HiddenWidget,
200 MappingWidget,
201 PasswordWidget,
202 RadioChoiceWidget,
203 RichTextWidget,
204 SelectWidget,
205 SequenceWidget,
206 TextAreaWidget,
207 TextInputWidget,
208 Widget,
209)
211from pendulum import Duration
212import phonenumbers
213import pyotp
214import qrcode
215import qrcode.image.svg
217# import as LITTLE AS POSSIBLE; this is used by lots of modules
218# We use some delayed imports here (search for "delayed import")
219from camcops_server.cc_modules.cc_baseconstants import (
220 DEFORM_SUPPORTS_CSP_NONCE,
221 TEMPLATE_DIR,
222)
223from camcops_server.cc_modules.cc_constants import (
224 ConfigParamSite,
225 DEFAULT_ROWS_PER_PAGE,
226 MfaMethod,
227 MINIMUM_PASSWORD_LENGTH,
228 SEX_OTHER_UNSPECIFIED,
229 SEX_FEMALE,
230 SEX_MALE,
231 StringLengths,
232 USER_NAME_FOR_SYSTEM,
233)
234from camcops_server.cc_modules.cc_group import Group
235from camcops_server.cc_modules.cc_idnumdef import (
236 IdNumDefinition,
237 ID_NUM_VALIDATION_METHOD_CHOICES,
238 validate_id_number,
239)
240from camcops_server.cc_modules.cc_ipuse import IpUse
241from camcops_server.cc_modules.cc_language import (
242 DEFAULT_LOCALE,
243 POSSIBLE_LOCALES,
244 POSSIBLE_LOCALES_WITH_DESCRIPTIONS,
245)
246from camcops_server.cc_modules.cc_patient import Patient
247from camcops_server.cc_modules.cc_patientidnum import PatientIdNum
248from camcops_server.cc_modules.cc_policy import (
249 TABLET_ID_POLICY_STR,
250 TokenizedPolicy,
251)
252from camcops_server.cc_modules.cc_pyramid import FormAction, ViewArg, ViewParam
253from camcops_server.cc_modules.cc_task import tablename_to_task_class_dict
254from camcops_server.cc_modules.cc_taskschedule import (
255 TaskSchedule,
256 TaskScheduleEmailTemplateFormatter,
257)
258from camcops_server.cc_modules.cc_validators import (
259 ALPHANUM_UNDERSCORE_CHAR,
260 validate_anything,
261 validate_by_char_and_length,
262 validate_download_filename,
263 validate_group_name,
264 validate_hl7_aa,
265 validate_hl7_id_type,
266 validate_ip_address,
267 validate_new_password,
268 validate_redirect_url,
269 validate_username,
270)
272if TYPE_CHECKING:
273 from deform.field import Field
274 from camcops_server.cc_modules.cc_request import CamcopsRequest
275 from camcops_server.cc_modules.cc_task import Task
276 from camcops_server.cc_modules.cc_user import User
278log = BraceStyleAdapter(logging.getLogger(__name__))
280ColanderNullType = _null
281ValidatorType = Callable[[SchemaNode, Any], None] # called as v(node, value)
284# =============================================================================
285# Debugging options
286# =============================================================================
288DEBUG_CSRF_CHECK = False
290if DEBUG_CSRF_CHECK:
291 log.warning("Debugging options enabled!")
294# =============================================================================
295# Constants
296# =============================================================================
298DEFORM_ACCORDION_BUG = True
299# If you have a sequence containing an accordion (e.g. advanced JSON settings),
300# then when you add a new node (e.g. "Add Task schedule") then the newly
301# created node's accordion won't open out.
302# https://github.com/Pylons/deform/issues/347
305class Binding(object):
306 """
307 Keys used for binding dictionaries with Colander schemas (schemata).
309 Must match ``kwargs`` of calls to ``bind()`` function of each ``Schema``.
310 """
312 GROUP = "group"
313 OPEN_ADMIN = "open_admin"
314 OPEN_WHAT = "open_what"
315 OPEN_WHEN = "open_when"
316 OPEN_WHO = "open_who"
317 REQUEST = "request"
318 TRACKER_TASKS_ONLY = "tracker_tasks_only"
319 USER = "user"
322class BootstrapCssClasses(object):
323 """
324 Constants from Bootstrap to control display.
325 """
327 FORM_INLINE = "form-inline"
328 RADIO_INLINE = "radio-inline"
329 LIST_INLINE = "list-inline"
330 CHECKBOX_INLINE = "checkbox-inline"
333AUTOCOMPLETE_ATTR = "autocomplete"
336class AutocompleteAttrValues(object):
337 """
338 Some values for the HTML "autocomplete" attribute, as per
339 https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/autocomplete.
340 Not all are used.
341 """
343 BDAY = "bday"
344 CURRENT_PASSWORD = "current-password"
345 EMAIL = "email"
346 FAMILY_NAME = "family-name"
347 GIVEN_NAME = "given-name"
348 NEW_PASSWORD = "new-password"
349 OFF = "off"
350 ON = "on" # browser decides
351 STREET_ADDRESS = "stree-address"
352 USERNAME = "username"
355def get_tinymce_options(request: "CamcopsRequest") -> Dict[str, Any]:
356 return {
357 "content_css": "static/tinymce/custom_content.css",
358 "menubar": "false",
359 "plugins": "link",
360 "toolbar": (
361 "undo redo | bold italic underline | link | "
362 "bullist numlist | "
363 "alignleft aligncenter alignright alignjustify | "
364 "outdent indent"
365 ),
366 "language": request.language_iso_639_1,
367 }
370# =============================================================================
371# Common phrases for translation
372# =============================================================================
375def or_join_description(request: "CamcopsRequest") -> str:
376 _ = request.gettext
377 return _("If you specify more than one, they will be joined with OR.")
380def change_password_title(request: "CamcopsRequest") -> str:
381 _ = request.gettext
382 return _("Change password")
385def sex_choices(request: "CamcopsRequest") -> List[Tuple[str, str]]:
386 _ = request.gettext
387 return [
388 (SEX_FEMALE, _("Female (F)")),
389 (SEX_MALE, _("Male (M)")),
390 # TRANSLATOR: sex code description
391 (SEX_OTHER_UNSPECIFIED, _("Other/unspecified (X)")),
392 ]
395# =============================================================================
396# Deform bug fix: SelectWidget "multiple" attribute
397# =============================================================================
400class BugfixSelectWidget(SelectWidget):
401 """
402 Fixes a bug where newer versions of Chameleon (e.g. 3.8.0) render Deform's
403 ``multiple = False`` (in ``SelectWidget``) as this, which is wrong:
405 .. code-block:: none
407 <select name="which_idnum" id="deformField2" class=" form-control " multiple="False">
408 ^^^^^^^^^^^^^^^^
409 <option value="1">CPFT RiO number</option>
410 <option value="2">NHS number</option>
411 <option value="1000">MyHospital number</option>
412 </select>
414 ... whereas previous versions of Chameleon (e.g. 3.4) omitted the tag.
415 (I think it's a Chameleon change, anyway! And it's probably a bugfix in
416 Chameleon that exposed a bug in Deform.)
418 See :func:`camcops_server.cc_modules.webview.debug_form_rendering`.
419 """ # noqa
421 def __init__(self, multiple=False, **kwargs) -> None:
422 multiple = True if multiple else None # None, not False
423 super().__init__(multiple=multiple, **kwargs)
426SelectWidget = BugfixSelectWidget
429# =============================================================================
430# Form that handles Content-Security-Policy nonce tags
431# =============================================================================
434class InformativeNonceForm(InformativeForm):
435 """
436 A Form class to use our modifications to Deform, as per
437 https://github.com/Pylons/deform/issues/512, to pass a nonce value through
438 to the ``<script>`` and ``<style>`` tags in the Deform templates.
440 todo: if Deform is updated, work this into ``cardinal_pythonlib``.
441 """
443 if DEFORM_SUPPORTS_CSP_NONCE:
445 def __init__(self, schema: Schema, **kwargs) -> None:
446 request = schema.request # type: CamcopsRequest
447 kwargs["nonce"] = request.nonce
448 super().__init__(schema, **kwargs)
451class DynamicDescriptionsNonceForm(DynamicDescriptionsForm):
452 """
453 Similarly; see :class:`InformativeNonceForm`.
455 todo: if Deform is updated, work this into ``cardinal_pythonlib``.
456 """
458 if DEFORM_SUPPORTS_CSP_NONCE:
460 def __init__(self, schema: Schema, **kwargs) -> None:
461 request = schema.request # type: CamcopsRequest
462 kwargs["nonce"] = request.nonce
463 super().__init__(schema, **kwargs)
466# =============================================================================
467# Mixin for Schema/SchemaNode objects for translation
468# =============================================================================
470GETTEXT_TYPE = Callable[[str], str]
473class RequestAwareMixin(object):
474 """
475 Mixin to add Pyramid request awareness to Schema/SchemaNode objects,
476 together with some translations and other convenience functions.
477 """
479 def __init__(self, *args, **kwargs) -> None:
480 # Stop multiple inheritance complaints
481 super().__init__(*args, **kwargs)
483 # noinspection PyUnresolvedReferences
484 @property
485 def request(self) -> "CamcopsRequest":
486 return self.bindings[Binding.REQUEST]
488 # noinspection PyUnresolvedReferences,PyPropertyDefinition
489 @property
490 def gettext(self) -> GETTEXT_TYPE:
491 return self.request.gettext
493 @property
494 def or_join_description(self) -> str:
495 return or_join_description(self.request)
498# =============================================================================
499# Translatable version of ValidateDangerousOperationNode
500# =============================================================================
503class TranslatableValidateDangerousOperationNode(
504 ValidateDangerousOperationNode, RequestAwareMixin
505):
506 """
507 Translatable version of ValidateDangerousOperationNode.
508 """
510 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
511 super().after_bind(node, kw) # calls set_description()
512 _ = self.gettext
513 node.title = _("Danger")
514 user_entry = get_child_node(self, "user_entry")
515 user_entry.title = _("Validate this dangerous operation")
517 def set_description(self, target_value: str) -> None:
518 # Overrides parent version (q.v.).
519 _ = self.gettext
520 user_entry = get_child_node(self, "user_entry")
521 prefix = _("Please enter the following: ")
522 user_entry.description = prefix + target_value
525# =============================================================================
526# Translatable version of SequenceWidget
527# =============================================================================
530class TranslatableSequenceWidget(SequenceWidget):
531 """
532 SequenceWidget does support translation via _(), but not in a
533 request-specific way.
534 """
536 def __init__(self, request: "CamcopsRequest", **kwargs) -> None:
537 super().__init__(**kwargs)
538 _ = request.gettext
539 self.add_subitem_text_template = _("Add") + " ${subitem_title}"
542# =============================================================================
543# Translatable version of OptionalPendulumNode
544# =============================================================================
547class TranslatableOptionalPendulumNode(
548 OptionalPendulumNode, RequestAwareMixin
549):
550 """
551 Translates the "Date" and "Time" labels for the widget, via
552 the request.
554 .. todo:: TranslatableOptionalPendulumNode not fully implemented
555 """
557 def __init__(self, *args, **kwargs) -> None:
558 super().__init__(*args, **kwargs)
559 self.widget = None # type: Optional[Widget]
561 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
562 _ = self.gettext
563 self.widget = DateTimeInputWidget(
564 date_options=DEFAULT_WIDGET_DATE_OPTIONS_FOR_PENDULUM,
565 time_options=DEFAULT_WIDGET_TIME_OPTIONS_FOR_PENDULUM,
566 )
567 # log.debug("TranslatableOptionalPendulumNode.widget: {!r}",
568 # self.widget.__dict__)
571class TranslatableDateTimeSelectorNode(
572 DateTimeSelectorNode, RequestAwareMixin
573):
574 """
575 Translates the "Date" and "Time" labels for the widget, via
576 the request.
578 .. todo:: TranslatableDateTimeSelectorNode not fully implemented
579 """
581 def __init__(self, *args, **kwargs) -> None:
582 super().__init__(*args, **kwargs)
583 self.widget = None # type: Optional[Widget]
585 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
586 _ = self.gettext
587 self.widget = DateTimeInputWidget()
588 # log.debug("TranslatableDateTimeSelectorNode.widget: {!r}",
589 # self.widget.__dict__)
592'''
593class TranslatableDateSelectorNode(DateSelectorNode,
594 RequestAwareMixin):
595 """
596 Translates the "Date" and "Time" labels for the widget, via
597 the request.
599 .. todo:: TranslatableDateSelectorNode not fully implemented
600 """
601 def __init__(self, *args, **kwargs) -> None:
602 super().__init__(*args, **kwargs)
603 self.widget = None # type: Optional[Widget]
605 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
606 _ = self.gettext
607 self.widget = DateInputWidget()
608 # log.debug("TranslatableDateSelectorNode.widget: {!r}",
609 # self.widget.__dict__)
610'''
613# =============================================================================
614# CSRF
615# =============================================================================
618class CSRFToken(SchemaNode, RequestAwareMixin):
619 """
620 Node to embed a cross-site request forgery (CSRF) prevention token in a
621 form.
623 As per https://deformdemo.repoze.org/pyramid_csrf_demo/, modified for a
624 more recent Colander API.
626 NOTE that this makes use of colander.SchemaNode.bind; this CLONES the
627 Schema, and resolves any deferred values by means of the keywords passed to
628 bind(). Since the Schema is created at module load time, but since we're
629 asking the Schema to know about the request's CSRF values, this is the only
630 mechanism
631 (https://docs.pylonsproject.org/projects/colander/en/latest/api.html#colander.SchemaNode.bind).
633 From https://deform2000.readthedocs.io/en/latest/basics.html:
635 "The default of a schema node indicates the value to be serialized if a
636 value for the schema node is not found in the input data during
637 serialization. It should be the deserialized representation. If a schema
638 node does not have a default, it is considered "serialization required"."
640 "The missing of a schema node indicates the value to be deserialized if a
641 value for the schema node is not found in the input data during
642 deserialization. It should be the deserialized representation. If a schema
643 node does not have a missing value, a colander.Invalid exception will be
644 raised if the data structure being deserialized does not contain a matching
645 value."
647 RNC: Serialized values are always STRINGS.
649 """ # noqa
651 schema_type = String
652 default = ""
653 missing = ""
654 title = " "
655 # ... evaluates to True but won't be visible, if the "hidden" aspect ever
656 # fails
657 widget = HiddenWidget()
659 # noinspection PyUnusedLocal
660 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
661 request = self.request
662 csrf_token = request.session.get_csrf_token()
663 if DEBUG_CSRF_CHECK:
664 log.debug("Got CSRF token from session: {!r}", csrf_token)
665 self.default = csrf_token
667 def validator(self, node: SchemaNode, value: Any) -> None:
668 # Deferred validator via method, as per
669 # https://docs.pylonsproject.org/projects/colander/en/latest/basics.html # noqa
670 request = self.request
671 csrf_token = request.session.get_csrf_token() # type: str
672 matches = value == csrf_token
673 if DEBUG_CSRF_CHECK:
674 log.debug(
675 "Validating CSRF token: form says {!r}, session says "
676 "{!r}, matches = {}",
677 value,
678 csrf_token,
679 matches,
680 )
681 if not matches:
682 log.warning(
683 "CSRF token mismatch; remote address {}", request.remote_addr
684 )
685 _ = request.gettext
686 raise Invalid(node, _("Bad CSRF token"))
689class CSRFSchema(Schema, RequestAwareMixin):
690 """
691 Base class for form schemas that use CSRF (XSRF; cross-site request
692 forgery) tokens.
694 You can't put the call to ``bind()`` at the end of ``__init__()``, because
695 ``bind()`` calls ``clone()`` with no arguments and ``clone()`` ends up
696 calling ``__init__()```...
698 The item name should be one that the ZAP penetration testing tool expects,
699 or you get:
701 .. code-block:: none
703 No known Anti-CSRF token [anticsrf, CSRFToken,
704 __RequestVerificationToken, csrfmiddlewaretoken, authenticity_token,
705 OWASP_CSRFTOKEN, anoncsrf, csrf_token, _csrf, _csrfSecret] was found in
706 the following HTML form: [Form 1: "_charset_" "__formid__"
707 "deformField1" "deformField2" "deformField3" "deformField4" ].
709 """
711 csrf_token = CSRFToken() # name must match ViewParam.CSRF_TOKEN
712 # ... name should also be one that ZAP expects, as above
715# =============================================================================
716# Horizontal forms
717# =============================================================================
720class HorizontalFormWidget(FormWidget):
721 """
722 Widget to render a form horizontally, with custom templates.
724 See :class:`deform.template.ZPTRendererFactory`, which explains how strings
725 are resolved to Chameleon ZPT (Zope) templates.
727 See
729 - https://stackoverflow.com/questions/12201835/form-inline-inside-a-form-horizontal-in-twitter-bootstrap
730 - https://stackoverflow.com/questions/18429121/inline-form-nested-within-horizontal-form-in-bootstrap-3
731 - https://stackoverflow.com/questions/23954772/how-to-make-a-horizontal-form-with-deform-2
732 """ # noqa
734 basedir = os.path.join(TEMPLATE_DIR, "deform")
735 readonlydir = os.path.join(basedir, "readonly")
736 form = "horizontal_form.pt"
737 mapping_item = "horizontal_mapping_item.pt"
739 template = os.path.join(
740 basedir, form
741 ) # default "form" = deform/templates/form.pt
742 readonly_template = os.path.join(
743 readonlydir, form
744 ) # default "readonly/form"
745 item_template = os.path.join(
746 basedir, mapping_item
747 ) # default "mapping_item"
748 readonly_item_template = os.path.join(
749 readonlydir, mapping_item
750 ) # default "readonly/mapping_item"
753class HorizontalFormMixin(object):
754 """
755 Modification to a Deform form that displays itself with horizontal layout,
756 using custom templates via :class:`HorizontalFormWidget`. Not fantastic.
757 """
759 def __init__(self, schema: Schema, *args, **kwargs) -> None:
760 kwargs = kwargs or {}
762 # METHOD 1: add "form-inline" to the CSS classes.
763 # extra_classes = "form-inline"
764 # if "css_class" in kwargs:
765 # kwargs["css_class"] += " " + extra_classes
766 # else:
767 # kwargs["css_class"] = extra_classes
769 # Method 2: change the widget
770 schema.widget = HorizontalFormWidget()
772 # OK, proceed.
773 super().__init__(schema, *args, **kwargs)
776def add_css_class(
777 kwargs: Dict[str, Any], extra_classes: str, param_name: str = "css_class"
778) -> None:
779 """
780 Modifies a kwargs dictionary to add a CSS class to the ``css_class``
781 parameter.
783 Args:
784 kwargs: a dictionary
785 extra_classes: CSS classes to add (as a space-separated string)
786 param_name: parameter name to modify; by default, "css_class"
787 """
788 if param_name in kwargs:
789 kwargs[param_name] += " " + extra_classes
790 else:
791 kwargs[param_name] = extra_classes
794class FormInlineCssMixin(object):
795 """
796 Modification to a Deform form that makes it display "inline" via CSS. This
797 has the effect of wrapping everything horizontally.
799 Should PRECEDE the :class:`Form` (or something derived from it) in the
800 inheritance order.
801 """
803 def __init__(self, *args, **kwargs) -> None:
804 kwargs = kwargs or {}
805 add_css_class(kwargs, BootstrapCssClasses.FORM_INLINE)
806 super().__init__(*args, **kwargs)
809def make_widget_horizontal(widget: Widget) -> None:
810 """
811 Applies Bootstrap "form-inline" styling to the widget.
812 """
813 widget.item_css_class = BootstrapCssClasses.FORM_INLINE
816def make_node_widget_horizontal(node: SchemaNode) -> None:
817 """
818 Applies Bootstrap "form-inline" styling to the schema node's widget.
820 **Note:** often better to use the ``inline=True`` option to the widget's
821 constructor.
822 """
823 make_widget_horizontal(node.widget)
826# =============================================================================
827# Specialized Form classes
828# =============================================================================
831class SimpleSubmitForm(InformativeNonceForm):
832 """
833 Form with a simple "submit" button.
834 """
836 def __init__(
837 self,
838 schema_class: Type[Schema],
839 submit_title: str,
840 request: "CamcopsRequest",
841 **kwargs,
842 ) -> None:
843 """
844 Args:
845 schema_class:
846 class of the Colander :class:`Schema` to use as this form's
847 schema
848 submit_title:
849 title (text) to be used for the "submit" button
850 request:
851 :class:`camcops_server.cc_modules.cc_request.CamcopsRequest`
852 """
853 schema = schema_class().bind(request=request)
854 super().__init__(
855 schema,
856 buttons=[Button(name=FormAction.SUBMIT, title=submit_title)],
857 **kwargs,
858 )
861class OkForm(SimpleSubmitForm):
862 """
863 Form with a button that says "OK".
864 """
866 def __init__(self, request: "CamcopsRequest", **kwargs) -> None:
867 _ = request.gettext
868 super().__init__(
869 schema_class=CSRFSchema,
870 submit_title=_("OK"),
871 request=request,
872 **kwargs,
873 )
876class ApplyCancelForm(InformativeNonceForm):
877 """
878 Form with "apply" and "cancel" buttons.
879 """
881 def __init__(
882 self, schema_class: Type[Schema], request: "CamcopsRequest", **kwargs
883 ) -> None:
884 schema = schema_class().bind(request=request)
885 _ = request.gettext
886 super().__init__(
887 schema,
888 buttons=[
889 Button(name=FormAction.SUBMIT, title=_("Apply")),
890 Button(name=FormAction.CANCEL, title=_("Cancel")),
891 ],
892 **kwargs,
893 )
896class AddCancelForm(InformativeNonceForm):
897 """
898 Form with "add" and "cancel" buttons.
899 """
901 def __init__(
902 self, schema_class: Type[Schema], request: "CamcopsRequest", **kwargs
903 ) -> None:
904 schema = schema_class().bind(request=request)
905 _ = request.gettext
906 super().__init__(
907 schema,
908 buttons=[
909 Button(name=FormAction.SUBMIT, title=_("Add")),
910 Button(name=FormAction.CANCEL, title=_("Cancel")),
911 ],
912 **kwargs,
913 )
916class DangerousForm(DynamicDescriptionsNonceForm):
917 """
918 Form with one "submit" button (with user-specifiable title text and action
919 name), in a CSS class indicating that it's a dangerous operation, plus a
920 "Cancel" button.
921 """
923 def __init__(
924 self,
925 schema_class: Type[Schema],
926 submit_action: str,
927 submit_title: str,
928 request: "CamcopsRequest",
929 **kwargs,
930 ) -> None:
931 schema = schema_class().bind(request=request)
932 _ = request.gettext
933 super().__init__(
934 schema,
935 buttons=[
936 Button(
937 name=submit_action,
938 title=submit_title,
939 css_class="btn-danger",
940 ),
941 Button(name=FormAction.CANCEL, title=_("Cancel")),
942 ],
943 **kwargs,
944 )
947class DeleteCancelForm(DangerousForm):
948 """
949 Form with a "delete" button (visually marked as dangerous) and a "cancel"
950 button.
951 """
953 def __init__(
954 self, schema_class: Type[Schema], request: "CamcopsRequest", **kwargs
955 ) -> None:
956 _ = request.gettext
957 super().__init__(
958 schema_class=schema_class,
959 submit_action=FormAction.DELETE,
960 submit_title=_("Delete"),
961 request=request,
962 **kwargs,
963 )
966# =============================================================================
967# Specialized SchemaNode classes used in several contexts
968# =============================================================================
970# -----------------------------------------------------------------------------
971# Task types
972# -----------------------------------------------------------------------------
975class OptionalSingleTaskSelector(OptionalStringNode, RequestAwareMixin):
976 """
977 Node to pick one task type.
978 """
980 def __init__(
981 self, *args, tracker_tasks_only: bool = False, **kwargs
982 ) -> None:
983 """
984 Args:
985 tracker_tasks_only: restrict the choices to tasks that offer
986 trackers.
987 """
988 self.title = "" # for type checker
989 self.tracker_tasks_only = tracker_tasks_only
990 self.widget = None # type: Optional[Widget]
991 self.validator = None # type: Optional[ValidatorType]
992 super().__init__(*args, **kwargs)
994 # noinspection PyUnusedLocal
995 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
996 _ = self.gettext
997 self.title = _("Task type")
998 if Binding.TRACKER_TASKS_ONLY in kw:
999 self.tracker_tasks_only = kw[Binding.TRACKER_TASKS_ONLY]
1000 values, pv = get_values_and_permissible(
1001 self.get_task_choices(), True, _("[Any]")
1002 )
1003 self.widget = SelectWidget(values=values)
1004 self.validator = OneOf(pv)
1006 def get_task_choices(self) -> List[Tuple[str, str]]:
1007 from camcops_server.cc_modules.cc_task import Task # delayed import
1009 choices = [] # type: List[Tuple[str, str]]
1010 for tc in Task.all_subclasses_by_shortname():
1011 if self.tracker_tasks_only and not tc.provides_trackers:
1012 continue
1013 choices.append((tc.tablename, tc.shortname))
1014 return choices
1017class MandatorySingleTaskSelector(MandatoryStringNode, RequestAwareMixin):
1018 """
1019 Node to pick one task type.
1020 """
1022 def __init__(self, *args: Any, **kwargs: Any) -> None:
1023 self.title = "" # for type checker
1024 self.widget = None # type: Optional[Widget]
1025 self.validator = None # type: Optional[ValidatorType]
1026 super().__init__(*args, **kwargs)
1028 # noinspection PyUnusedLocal
1029 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
1030 _ = self.gettext
1031 self.title = _("Task type")
1032 values, pv = get_values_and_permissible(self.get_task_choices(), False)
1033 self.widget = SelectWidget(values=values)
1034 self.validator = OneOf(pv)
1036 @staticmethod
1037 def get_task_choices() -> List[Tuple[str, str]]:
1038 from camcops_server.cc_modules.cc_task import Task # delayed import
1040 choices = [] # type: List[Tuple[str, str]]
1041 for tc in Task.all_subclasses_by_shortname():
1042 choices.append((tc.tablename, tc.shortname))
1043 return choices
1046class MultiTaskSelector(SchemaNode, RequestAwareMixin):
1047 """
1048 Node to select multiple task types.
1049 """
1051 schema_type = Set
1052 default = ""
1053 missing = ""
1055 def __init__(
1056 self,
1057 *args,
1058 tracker_tasks_only: bool = False,
1059 minimum_number: int = 0,
1060 **kwargs,
1061 ) -> None:
1062 self.tracker_tasks_only = tracker_tasks_only
1063 self.minimum_number = minimum_number
1064 self.widget = None # type: Optional[Widget]
1065 self.validator = None # type: Optional[ValidatorType]
1066 self.title = "" # for type checker
1067 self.description = "" # for type checker
1068 super().__init__(*args, **kwargs)
1070 # noinspection PyUnusedLocal
1071 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
1072 _ = self.gettext
1073 request = self.request # noqa: F841
1074 self.title = _("Task type(s)")
1075 self.description = (
1076 _("If none are selected, all task types will be offered.")
1077 + " "
1078 + self.or_join_description
1079 )
1080 if Binding.TRACKER_TASKS_ONLY in kw:
1081 self.tracker_tasks_only = kw[Binding.TRACKER_TASKS_ONLY]
1082 values, pv = get_values_and_permissible(self.get_task_choices())
1083 self.widget = CheckboxChoiceWidget(values=values, inline=True)
1084 self.validator = Length(min=self.minimum_number)
1086 def get_task_choices(self) -> List[Tuple[str, str]]:
1087 from camcops_server.cc_modules.cc_task import Task # delayed import
1089 choices = [] # type: List[Tuple[str, str]]
1090 for tc in Task.all_subclasses_by_shortname():
1091 if self.tracker_tasks_only and not tc.provides_trackers:
1092 continue
1093 choices.append((tc.tablename, tc.shortname))
1094 return choices
1097# -----------------------------------------------------------------------------
1098# Use the task index?
1099# -----------------------------------------------------------------------------
1102class ViaIndexSelector(BooleanNode, RequestAwareMixin):
1103 """
1104 Node to choose whether we use the server index or not.
1105 Default is true.
1106 """
1108 def __init__(self, *args, **kwargs) -> None:
1109 super().__init__(*args, default=True, **kwargs)
1111 # noinspection PyUnusedLocal
1112 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
1113 _ = self.gettext
1114 self.title = _("Use server index?")
1115 self.label = _("Use server index? (Default is true; much faster.)")
1118# -----------------------------------------------------------------------------
1119# ID numbers
1120# -----------------------------------------------------------------------------
1123class MandatoryWhichIdNumSelector(SchemaNode, RequestAwareMixin):
1124 """
1125 Node to enforce the choice of a single ID number type (e.g. "NHS number"
1126 or "study Blah ID number").
1127 """
1129 widget = SelectWidget()
1131 def __init__(self, *args, **kwargs) -> None:
1132 if not hasattr(self, "allow_none"):
1133 # ... allows parameter-free (!) inheritance by
1134 # OptionalWhichIdNumSelector
1135 self.allow_none = False
1136 self.title = "" # for type checker
1137 self.description = "" # for type checker
1138 self.validator = None # type: Optional[ValidatorType]
1139 super().__init__(*args, **kwargs)
1141 # noinspection PyUnusedLocal
1142 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
1143 request = self.request
1144 _ = request.gettext
1145 self.title = _("Identifier")
1146 values = [] # type: List[Tuple[Optional[int], str]]
1147 for iddef in request.idnum_definitions:
1148 values.append((iddef.which_idnum, iddef.description))
1149 values, pv = get_values_and_permissible(
1150 values, self.allow_none, _("[ignore]")
1151 )
1152 # ... can't use None, because SelectWidget() will convert that to
1153 # "None"; can't use colander.null, because that converts to
1154 # "<colander.null>"; use "", which is the default null_value of
1155 # SelectWidget.
1156 self.widget.values = values
1157 self.validator = OneOf(pv)
1159 @staticmethod
1160 def schema_type() -> SchemaType:
1161 return Integer()
1164class LinkingIdNumSelector(MandatoryWhichIdNumSelector):
1165 """
1166 Convenience node: pick a single ID number, with title/description
1167 indicating that it's the ID number to link on.
1168 """
1170 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
1171 super().after_bind(node, kw)
1172 _ = self.gettext
1173 self.title = _("Linking ID number")
1174 self.description = _("Which ID number to link on?")
1177class MandatoryIdNumValue(SchemaNode, RequestAwareMixin):
1178 """
1179 Mandatory node to capture an ID number value.
1180 """
1182 schema_type = Integer
1183 validator = Range(min=0)
1185 def __init__(self, *args, **kwargs) -> None:
1186 self.title = "" # for type checker
1187 super().__init__(*args, **kwargs)
1189 # noinspection PyUnusedLocal
1190 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
1191 _ = self.gettext
1192 self.title = _("ID# value")
1195class MandatoryIdNumNode(MappingSchema, RequestAwareMixin):
1196 """
1197 Mandatory node to capture an ID number type and the associated actual
1198 ID number (value).
1200 This is also where we apply ID number validation rules (e.g. NHS number).
1201 """
1203 which_idnum = (
1204 MandatoryWhichIdNumSelector()
1205 ) # must match ViewParam.WHICH_IDNUM
1206 idnum_value = MandatoryIdNumValue() # must match ViewParam.IDNUM_VALUE
1208 def __init__(self, *args, **kwargs) -> None:
1209 self.title = "" # for type checker
1210 super().__init__(*args, **kwargs)
1212 # noinspection PyUnusedLocal
1213 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
1214 _ = self.gettext
1215 self.title = _("ID number")
1217 # noinspection PyMethodMayBeStatic
1218 def validator(self, node: SchemaNode, value: Dict[str, int]) -> None:
1219 assert isinstance(value, dict)
1220 req = self.request
1221 _ = req.gettext
1222 which_idnum = value[ViewParam.WHICH_IDNUM]
1223 idnum_value = value[ViewParam.IDNUM_VALUE]
1224 idnum_def = req.get_idnum_definition(which_idnum)
1225 if not idnum_def:
1226 raise Invalid(node, _("Bad ID number type")) # shouldn't happen
1227 method = idnum_def.validation_method
1228 if method:
1229 valid, why_invalid = validate_id_number(req, idnum_value, method)
1230 if not valid:
1231 raise Invalid(node, why_invalid)
1234class IdNumSequenceAnyCombination(SequenceSchema, RequestAwareMixin):
1235 """
1236 Sequence to capture multiple ID numbers (as type/value pairs).
1237 """
1239 idnum_sequence = MandatoryIdNumNode()
1241 def __init__(self, *args, **kwargs) -> None:
1242 self.title = "" # for type checker
1243 self.widget = None # type: Optional[Widget]
1244 super().__init__(*args, **kwargs)
1246 # noinspection PyUnusedLocal
1247 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
1248 _ = self.gettext
1249 self.title = _("ID numbers")
1250 self.widget = TranslatableSequenceWidget(request=self.request)
1252 # noinspection PyMethodMayBeStatic
1253 def validator(self, node: SchemaNode, value: List[Dict[str, int]]) -> None:
1254 assert isinstance(value, list)
1255 list_of_lists = [
1256 (x[ViewParam.WHICH_IDNUM], x[ViewParam.IDNUM_VALUE]) for x in value
1257 ]
1258 if len(list_of_lists) != len(set(list_of_lists)):
1259 _ = self.gettext
1260 raise Invalid(
1261 node, _("You have specified duplicate ID definitions")
1262 )
1265class IdNumSequenceUniquePerWhichIdnum(SequenceSchema, RequestAwareMixin):
1266 """
1267 Sequence to capture multiple ID numbers (as type/value pairs) but with only
1268 up to one per ID number type.
1269 """
1271 idnum_sequence = MandatoryIdNumNode()
1273 def __init__(self, *args, **kwargs) -> None:
1274 self.title = "" # for type checker
1275 self.widget = None # type: Optional[Widget]
1276 super().__init__(*args, **kwargs)
1278 # noinspection PyUnusedLocal
1279 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
1280 _ = self.gettext
1281 self.title = _("ID numbers")
1282 self.widget = TranslatableSequenceWidget(request=self.request)
1284 # noinspection PyMethodMayBeStatic
1285 def validator(self, node: SchemaNode, value: List[Dict[str, int]]) -> None:
1286 assert isinstance(value, list)
1287 which_idnums = [x[ViewParam.WHICH_IDNUM] for x in value]
1288 if len(which_idnums) != len(set(which_idnums)):
1289 _ = self.gettext
1290 raise Invalid(
1291 node, _("You have specified >1 value for one ID number type")
1292 )
1295# -----------------------------------------------------------------------------
1296# Sex
1297# -----------------------------------------------------------------------------
1300class OptionalSexSelector(OptionalStringNode, RequestAwareMixin):
1301 """
1302 Optional node to choose sex.
1303 """
1305 def __init__(self, *args, **kwargs) -> None:
1306 self.title = "" # for type checker
1307 self.validator = None # type: Optional[ValidatorType]
1308 self.widget = None # type: Optional[Widget]
1309 super().__init__(*args, **kwargs)
1311 # noinspection PyUnusedLocal
1312 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
1313 _ = self.gettext
1314 self.title = _("Sex")
1315 choices = sex_choices(self.request)
1316 values, pv = get_values_and_permissible(choices, True, _("Any"))
1317 self.widget = RadioChoiceWidget(values=values, inline=True)
1318 self.validator = OneOf(pv)
1321class MandatorySexSelector(MandatoryStringNode, RequestAwareMixin):
1322 """
1323 Mandatory node to choose sex.
1324 """
1326 def __init__(self, *args, **kwargs) -> None:
1327 self.title = "" # for type checker
1328 self.validator = None # type: Optional[ValidatorType]
1329 self.widget = None # type: Optional[Widget]
1330 super().__init__(*args, **kwargs)
1332 # noinspection PyUnusedLocal
1333 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
1334 _ = self.gettext
1335 self.title = _("Sex")
1336 choices = sex_choices(self.request)
1337 values, pv = get_values_and_permissible(choices)
1338 self.widget = RadioChoiceWidget(values=values, inline=True)
1339 self.validator = OneOf(pv)
1342# -----------------------------------------------------------------------------
1343# Users
1344# -----------------------------------------------------------------------------
1347class MandatoryUserIdSelectorUsersAllowedToSee(SchemaNode, RequestAwareMixin):
1348 """
1349 Mandatory node to choose a user, from the users that the requesting user
1350 is allowed to see.
1351 """
1353 schema_type = Integer
1355 def __init__(self, *args, **kwargs) -> None:
1356 self.title = "" # for type checker
1357 self.validator = None # type: Optional[ValidatorType]
1358 self.widget = None # type: Optional[Widget]
1359 super().__init__(*args, **kwargs)
1361 # noinspection PyUnusedLocal
1362 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
1363 from camcops_server.cc_modules.cc_user import User # delayed import
1365 _ = self.gettext
1366 self.title = _("User")
1367 request = self.request
1368 dbsession = request.dbsession
1369 user = request.user
1370 if user.superuser:
1371 users = dbsession.query(User).order_by(User.username)
1372 else:
1373 # Users in my groups, or groups I'm allowed to see
1374 my_allowed_group_ids = user.ids_of_groups_user_may_see
1375 users = (
1376 dbsession.query(User)
1377 .join(Group)
1378 .filter(Group.id.in_(my_allowed_group_ids))
1379 .order_by(User.username)
1380 )
1381 values = [] # type: List[Tuple[Optional[int], str]]
1382 for user in users:
1383 values.append((user.id, user.username))
1384 values, pv = get_values_and_permissible(values, False)
1385 self.widget = SelectWidget(values=values)
1386 self.validator = OneOf(pv)
1389class OptionalUserNameSelector(OptionalStringNode, RequestAwareMixin):
1390 """
1391 Optional node to select a username, from all possible users.
1392 """
1394 title = "User"
1396 def __init__(self, *args, **kwargs) -> None:
1397 self.title = "" # for type checker
1398 self.validator = None # type: Optional[ValidatorType]
1399 self.widget = None # type: Optional[Widget]
1400 super().__init__(*args, **kwargs)
1402 # noinspection PyUnusedLocal
1403 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
1404 from camcops_server.cc_modules.cc_user import User # delayed import
1406 _ = self.gettext
1407 self.title = _("User")
1408 request = self.request
1409 dbsession = request.dbsession
1410 values = [] # type: List[Tuple[str, str]]
1411 users = dbsession.query(User).order_by(User.username)
1412 for user in users:
1413 values.append((user.username, user.username))
1414 values, pv = get_values_and_permissible(values, True, _("[ignore]"))
1415 self.widget = SelectWidget(values=values)
1416 self.validator = OneOf(pv)
1419class UsernameNode(SchemaNode, RequestAwareMixin):
1420 """
1421 Node to enter a username.
1422 """
1424 schema_type = String
1425 widget = TextInputWidget(
1426 attributes={AUTOCOMPLETE_ATTR: AutocompleteAttrValues.OFF}
1427 )
1429 def __init__(
1430 self, *args, autocomplete: str = AutocompleteAttrValues.OFF, **kwargs
1431 ) -> None:
1432 self.title = "" # for type checker
1433 self.autocomplete = autocomplete
1434 super().__init__(*args, **kwargs)
1436 # noinspection PyUnusedLocal
1437 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
1438 _ = self.gettext
1439 self.title = _("Username")
1440 # noinspection PyUnresolvedReferences
1441 self.widget.attributes[AUTOCOMPLETE_ATTR] = self.autocomplete
1443 def validator(self, node: SchemaNode, value: str) -> None:
1444 if value == USER_NAME_FOR_SYSTEM:
1445 _ = self.gettext
1446 raise Invalid(
1447 node,
1448 _("Cannot use system username")
1449 + " "
1450 + repr(USER_NAME_FOR_SYSTEM),
1451 )
1452 try:
1453 validate_username(value, self.request)
1454 except ValueError as e:
1455 raise Invalid(node, str(e))
1458class UserFilterSchema(Schema, RequestAwareMixin):
1459 """
1460 Schema to filter the list of users
1461 """
1463 # must match ViewParam.INCLUDE_AUTO_GENERATED
1464 include_auto_generated = BooleanNode()
1466 # noinspection PyUnusedLocal
1467 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
1468 _ = self.gettext
1469 include_auto_generated = get_child_node(self, "include_auto_generated")
1470 include_auto_generated.title = _("Include auto-generated users")
1471 include_auto_generated.label = None
1474class UserFilterForm(InformativeNonceForm):
1475 """
1476 Form to filter the list of users
1477 """
1479 def __init__(self, request: "CamcopsRequest", **kwargs: Any) -> None:
1480 _ = request.gettext
1481 schema = UserFilterSchema().bind(request=request)
1482 super().__init__(
1483 schema,
1484 buttons=[Button(name=FormAction.SET_FILTERS, title=_("Refresh"))],
1485 css_class=BootstrapCssClasses.FORM_INLINE,
1486 method=HttpMethod.GET,
1487 **kwargs,
1488 )
1491# -----------------------------------------------------------------------------
1492# Devices
1493# -----------------------------------------------------------------------------
1496class MandatoryDeviceIdSelector(SchemaNode, RequestAwareMixin):
1497 """
1498 Mandatory node to select a client device ID.
1499 """
1501 schema_type = Integer
1503 def __init__(self, *args, **kwargs) -> None:
1504 self.title = "" # for type checker
1505 self.validator = None # type: Optional[ValidatorType]
1506 self.widget = None # type: Optional[Widget]
1507 super().__init__(*args, **kwargs)
1509 # noinspection PyUnusedLocal
1510 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
1511 from camcops_server.cc_modules.cc_device import (
1512 Device,
1513 ) # delayed import
1515 _ = self.gettext
1516 self.title = _("Device")
1517 request = self.request
1518 dbsession = request.dbsession
1519 devices = dbsession.query(Device).order_by(Device.friendly_name)
1520 values = [] # type: List[Tuple[Optional[int], str]]
1521 for device in devices:
1522 values.append((device.id, device.friendly_name))
1523 values, pv = get_values_and_permissible(values, False)
1524 self.widget = SelectWidget(values=values)
1525 self.validator = OneOf(pv)
1528# -----------------------------------------------------------------------------
1529# Server PK
1530# -----------------------------------------------------------------------------
1533class ServerPkSelector(OptionalIntNode, RequestAwareMixin):
1534 """
1535 Optional node to request an integer, marked as a server PK.
1536 """
1538 def __init__(self, *args, **kwargs) -> None:
1539 self.title = "" # for type checker
1540 super().__init__(*args, **kwargs)
1542 # noinspection PyUnusedLocal
1543 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
1544 _ = self.gettext
1545 self.title = _("Server PK")
1548# -----------------------------------------------------------------------------
1549# Dates/times
1550# -----------------------------------------------------------------------------
1553class StartPendulumSelector(
1554 TranslatableOptionalPendulumNode, RequestAwareMixin
1555):
1556 """
1557 Optional node to select a start date/time.
1558 """
1560 def __init__(self, *args, **kwargs) -> None:
1561 self.title = "" # for type checker
1562 super().__init__(*args, **kwargs)
1564 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
1565 super().after_bind(node, kw)
1566 _ = self.gettext
1567 self.title = _("Start date/time (local timezone; inclusive)")
1570class EndPendulumSelector(TranslatableOptionalPendulumNode, RequestAwareMixin):
1571 """
1572 Optional node to select an end date/time.
1573 """
1575 def __init__(self, *args, **kwargs) -> None:
1576 self.title = "" # for type checker
1577 super().__init__(*args, **kwargs)
1579 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
1580 super().after_bind(node, kw)
1581 _ = self.gettext
1582 self.title = _("End date/time (local timezone; exclusive)")
1585class StartDateTimeSelector(
1586 TranslatableDateTimeSelectorNode, RequestAwareMixin
1587):
1588 """
1589 Optional node to select a start date/time (in UTC).
1590 """
1592 def __init__(self, *args, **kwargs) -> None:
1593 self.title = "" # for type checker
1594 super().__init__(*args, **kwargs)
1596 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
1597 super().after_bind(node, kw)
1598 _ = self.gettext
1599 self.title = _("Start date/time (UTC; inclusive)")
1602class EndDateTimeSelector(TranslatableDateTimeSelectorNode, RequestAwareMixin):
1603 """
1604 Optional node to select an end date/time (in UTC).
1605 """
1607 def __init__(self, *args, **kwargs) -> None:
1608 self.title = "" # for type checker
1609 super().__init__(*args, **kwargs)
1611 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
1612 super().after_bind(node, kw)
1613 _ = self.gettext
1614 self.title = _("End date/time (UTC; exclusive)")
1617'''
1618class StartDateSelector(TranslatableDateSelectorNode,
1619 RequestAwareMixin):
1620 """
1621 Optional node to select a start date (in UTC).
1622 """
1623 def __init__(self, *args, **kwargs) -> None:
1624 self.title = "" # for type checker
1625 super().__init__(*args, **kwargs)
1627 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
1628 super().after_bind(node, kw)
1629 _ = self.gettext
1630 self.title = _("Start date (UTC; inclusive)")
1633class EndDateSelector(TranslatableDateSelectorNode,
1634 RequestAwareMixin):
1635 """
1636 Optional node to select an end date (in UTC).
1637 """
1638 def __init__(self, *args, **kwargs) -> None:
1639 self.title = "" # for type checker
1640 super().__init__(*args, **kwargs)
1642 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
1643 super().after_bind(node, kw)
1644 _ = self.gettext
1645 self.title = _("End date (UTC; inclusive)")
1646'''
1649# -----------------------------------------------------------------------------
1650# Rows per page
1651# -----------------------------------------------------------------------------
1654class RowsPerPageSelector(SchemaNode, RequestAwareMixin):
1655 """
1656 Node to select how many rows per page are shown.
1657 """
1659 _choices = ((10, "10"), (25, "25"), (50, "50"), (100, "100"))
1661 schema_type = Integer
1662 default = DEFAULT_ROWS_PER_PAGE
1663 widget = RadioChoiceWidget(values=_choices)
1664 validator = OneOf(list(x[0] for x in _choices))
1666 def __init__(self, *args, **kwargs) -> None:
1667 self.title = "" # for type checker
1668 super().__init__(*args, **kwargs)
1670 # noinspection PyUnusedLocal
1671 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
1672 _ = self.gettext
1673 self.title = _("Items to show per page")
1676# -----------------------------------------------------------------------------
1677# Groups
1678# -----------------------------------------------------------------------------
1681class MandatoryGroupIdSelectorAllGroups(SchemaNode, RequestAwareMixin):
1682 """
1683 Offers a picklist of groups from ALL POSSIBLE GROUPS.
1684 Used by superusers: "add user to any group".
1685 """
1687 def __init__(self, *args, **kwargs) -> None:
1688 self.title = "" # for type checker
1689 self.validator = None # type: Optional[ValidatorType]
1690 self.widget = None # type: Optional[Widget]
1691 super().__init__(*args, **kwargs)
1693 # noinspection PyUnusedLocal
1694 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
1695 _ = self.gettext
1696 self.title = _("Group")
1697 request = self.request
1698 dbsession = request.dbsession
1699 groups = dbsession.query(Group).order_by(Group.name)
1700 values = [(g.id, g.name) for g in groups]
1701 values, pv = get_values_and_permissible(values)
1702 self.widget = SelectWidget(values=values)
1703 self.validator = OneOf(pv)
1705 @staticmethod
1706 def schema_type() -> SchemaType:
1707 return Integer()
1710class MandatoryGroupIdSelectorAdministeredGroups(
1711 SchemaNode, RequestAwareMixin
1712):
1713 """
1714 Offers a picklist of groups from GROUPS ADMINISTERED BY REQUESTOR.
1715 Used by groupadmins: "add user to one of my groups".
1716 """
1718 def __init__(self, *args, **kwargs) -> None:
1719 self.title = "" # for type checker
1720 self.validator = None # type: Optional[ValidatorType]
1721 self.widget = None # type: Optional[Widget]
1722 super().__init__(*args, **kwargs)
1724 # noinspection PyUnusedLocal
1725 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
1726 _ = self.gettext
1727 self.title = _("Group")
1728 request = self.request
1729 dbsession = request.dbsession
1730 administered_group_ids = request.user.ids_of_groups_user_is_admin_for
1731 groups = dbsession.query(Group).order_by(Group.name)
1732 values = [
1733 (g.id, g.name) for g in groups if g.id in administered_group_ids
1734 ]
1735 values, pv = get_values_and_permissible(values)
1736 self.widget = SelectWidget(values=values)
1737 self.validator = OneOf(pv)
1739 @staticmethod
1740 def schema_type() -> SchemaType:
1741 return Integer()
1744class MandatoryGroupIdSelectorPatientGroups(SchemaNode, RequestAwareMixin):
1745 """
1746 Offers a picklist of groups the user can manage patients in.
1747 Used when managing patients: "add patient to one of my groups".
1748 """
1750 def __init__(self, *args, **kwargs) -> None:
1751 self.title = "" # for type checker
1752 self.validator = None # type: Optional[ValidatorType]
1753 self.widget = None # type: Optional[Widget]
1754 super().__init__(*args, **kwargs)
1756 # noinspection PyUnusedLocal
1757 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
1758 _ = self.gettext
1759 self.title = _("Group")
1760 request = self.request
1761 dbsession = request.dbsession
1762 group_ids = request.user.ids_of_groups_user_may_manage_patients_in
1763 groups = dbsession.query(Group).order_by(Group.name)
1764 values = [(g.id, g.name) for g in groups if g.id in group_ids]
1765 values, pv = get_values_and_permissible(values)
1766 self.widget = SelectWidget(values=values)
1767 self.validator = OneOf(pv)
1769 @staticmethod
1770 def schema_type() -> SchemaType:
1771 return Integer()
1774class MandatoryGroupIdSelectorOtherGroups(SchemaNode, RequestAwareMixin):
1775 """
1776 Offers a picklist of groups THAT ARE NOT THE SPECIFIED GROUP (as specified
1777 in ``kw[Binding.GROUP]``).
1778 Used by superusers: "which other groups can this group see?"
1779 """
1781 def __init__(self, *args, **kwargs) -> None:
1782 self.title = "" # for type checker
1783 self.validator = None # type: Optional[ValidatorType]
1784 self.widget = None # type: Optional[Widget]
1785 super().__init__(*args, **kwargs)
1787 # noinspection PyUnusedLocal
1788 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
1789 _ = self.gettext
1790 self.title = _("Other group")
1791 request = self.request
1792 group = kw[Binding.GROUP] # type: Group # ATYPICAL BINDING
1793 dbsession = request.dbsession
1794 groups = dbsession.query(Group).order_by(Group.name)
1795 values = [(g.id, g.name) for g in groups if g.id != group.id]
1796 values, pv = get_values_and_permissible(values)
1797 self.widget = SelectWidget(values=values)
1798 self.validator = OneOf(pv)
1800 @staticmethod
1801 def schema_type() -> SchemaType:
1802 return Integer()
1805class MandatoryGroupIdSelectorUserGroups(SchemaNode, RequestAwareMixin):
1806 """
1807 Offers a picklist of groups from THOSE THE USER IS A MEMBER OF.
1808 Used for: "which of your groups do you want to upload into?"
1809 """
1811 def __init__(self, *args, **kwargs) -> None:
1812 if not hasattr(self, "allow_none"):
1813 # ... allows parameter-free (!) inheritance by
1814 # OptionalGroupIdSelectorUserGroups
1815 self.allow_none = False
1816 self.title = "" # for type checker
1817 self.validator = None # type: Optional[ValidatorType]
1818 self.widget = None # type: Optional[Widget]
1819 super().__init__(*args, **kwargs)
1821 # noinspection PyUnusedLocal
1822 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
1823 _ = self.gettext
1824 self.title = _("Group")
1825 user = kw[Binding.USER] # type: User # ATYPICAL BINDING
1826 groups = sorted(list(user.groups), key=lambda g: g.name)
1827 values = [(g.id, g.name) for g in groups]
1828 values, pv = get_values_and_permissible(
1829 values, self.allow_none, _("[None]")
1830 )
1831 self.widget = SelectWidget(values=values)
1832 self.validator = OneOf(pv)
1834 @staticmethod
1835 def schema_type() -> SchemaType:
1836 return Integer()
1839class OptionalGroupIdSelectorUserGroups(MandatoryGroupIdSelectorUserGroups):
1840 """
1841 Offers a picklist of groups from THOSE THE USER IS A MEMBER OF.
1842 Used for "which do you want to upload into?". Optional.
1843 """
1845 default = None
1846 missing = None
1848 def __init__(self, *args, **kwargs) -> None:
1849 self.allow_none = True
1850 super().__init__(*args, **kwargs)
1852 @staticmethod
1853 def schema_type() -> SchemaType:
1854 return AllowNoneType(Integer())
1857class MandatoryGroupIdSelectorAllowedGroups(SchemaNode, RequestAwareMixin):
1858 """
1859 Offers a picklist of groups from THOSE THE USER IS ALLOWED TO SEE.
1860 Used for task filters.
1861 """
1863 def __init__(self, *args, **kwargs) -> None:
1864 self.title = "" # for type checker
1865 self.validator = None # type: Optional[ValidatorType]
1866 self.widget = None # type: Optional[Widget]
1867 super().__init__(*args, **kwargs)
1869 # noinspection PyUnusedLocal
1870 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
1871 _ = self.gettext
1872 self.title = _("Group")
1873 request = self.request
1874 dbsession = request.dbsession
1875 user = request.user
1876 if user.superuser:
1877 groups = dbsession.query(Group).order_by(Group.name)
1878 else:
1879 groups = sorted(list(user.groups), key=lambda g: g.name)
1880 values = [(g.id, g.name) for g in groups]
1881 values, pv = get_values_and_permissible(values)
1882 self.widget = SelectWidget(values=values)
1883 self.validator = OneOf(pv)
1885 @staticmethod
1886 def schema_type() -> SchemaType:
1887 return Integer()
1890class GroupsSequenceBase(SequenceSchema, RequestAwareMixin):
1891 """
1892 Sequence schema to capture zero or more non-duplicate groups.
1893 """
1895 def __init__(self, *args, minimum_number: int = 0, **kwargs) -> None:
1896 self.title = "" # for type checker
1897 self.minimum_number = minimum_number
1898 self.widget = None # type: Optional[Widget]
1899 super().__init__(*args, **kwargs)
1901 # noinspection PyUnusedLocal
1902 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
1903 _ = self.gettext
1904 self.title = _("Groups")
1905 self.widget = TranslatableSequenceWidget(request=self.request)
1907 # noinspection PyMethodMayBeStatic
1908 def validator(self, node: SchemaNode, value: List[int]) -> None:
1909 assert isinstance(value, list)
1910 _ = self.gettext
1911 if len(value) != len(set(value)):
1912 raise Invalid(node, _("You have specified duplicate groups"))
1913 if len(value) < self.minimum_number:
1914 raise Invalid(
1915 node,
1916 _("You must specify at least {} group(s)").format(
1917 self.minimum_number
1918 ),
1919 )
1922class AllGroupsSequence(GroupsSequenceBase):
1923 """
1924 Sequence to offer a choice of all possible groups.
1926 Typical use: superuser assigns group memberships to a user.
1927 """
1929 group_id_sequence = MandatoryGroupIdSelectorAllGroups()
1932class AdministeredGroupsSequence(GroupsSequenceBase):
1933 """
1934 Sequence to offer a choice of the groups administered by the requestor.
1936 Typical use: (non-superuser) group administrator assigns group memberships
1937 to a user.
1938 """
1940 group_id_sequence = MandatoryGroupIdSelectorAdministeredGroups()
1942 def __init__(self, *args, **kwargs) -> None:
1943 super().__init__(*args, minimum_number=1, **kwargs)
1946class AllOtherGroupsSequence(GroupsSequenceBase):
1947 """
1948 Sequence to offer a choice of all possible OTHER groups (as determined
1949 relative to the group specified in ``kw[Binding.GROUP]``).
1951 Typical use: superuser assigns group permissions to another group.
1952 """
1954 group_id_sequence = MandatoryGroupIdSelectorOtherGroups()
1957class AllowedGroupsSequence(GroupsSequenceBase):
1958 """
1959 Sequence to offer a choice of all the groups the user is allowed to see.
1960 """
1962 group_id_sequence = MandatoryGroupIdSelectorAllowedGroups()
1964 def __init__(self, *args, **kwargs) -> None:
1965 self.description = "" # for type checker
1966 super().__init__(*args, **kwargs)
1968 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
1969 super().after_bind(node, kw)
1970 self.description = self.or_join_description
1973# -----------------------------------------------------------------------------
1974# Languages (strictly, locales)
1975# -----------------------------------------------------------------------------
1978class LanguageSelector(SchemaNode, RequestAwareMixin):
1979 """
1980 Node to choose a language code, from those supported by the server.
1981 """
1983 _choices = POSSIBLE_LOCALES_WITH_DESCRIPTIONS
1984 schema_type = String
1985 default = DEFAULT_LOCALE
1986 missing = DEFAULT_LOCALE
1987 widget = SelectWidget(values=_choices) # intrinsically translated!
1988 validator = OneOf(POSSIBLE_LOCALES)
1990 def __init__(self, *args, **kwargs) -> None:
1991 self.title = "" # for type checker
1992 super().__init__(*args, **kwargs)
1994 # noinspection PyUnusedLocal
1995 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
1996 _ = self.gettext
1997 self.title = _("Group")
1998 request = self.request # noqa: F841
1999 self.title = _("Language")
2002# -----------------------------------------------------------------------------
2003# Validating dangerous operations
2004# -----------------------------------------------------------------------------
2007class HardWorkConfirmationSchema(CSRFSchema):
2008 """
2009 Schema to make it hard to do something. We require a pattern of yes/no
2010 answers before we will proceed.
2011 """
2013 confirm_1_t = BooleanNode(default=False)
2014 confirm_2_t = BooleanNode(default=True)
2015 confirm_3_f = BooleanNode(default=True)
2016 confirm_4_t = BooleanNode(default=False)
2018 # noinspection PyUnusedLocal
2019 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
2020 _ = self.gettext
2021 confirm_1_t = get_child_node(self, "confirm_1_t")
2022 confirm_1_t.title = _("Really?")
2023 confirm_2_t = get_child_node(self, "confirm_2_t")
2024 # TRANSLATOR: string context described here
2025 confirm_2_t.title = _("Leave ticked to confirm")
2026 confirm_3_f = get_child_node(self, "confirm_3_f")
2027 confirm_3_f.title = _("Please untick to confirm")
2028 confirm_4_t = get_child_node(self, "confirm_4_t")
2029 confirm_4_t.title = _("Be really sure; tick here also to confirm")
2031 # noinspection PyMethodMayBeStatic
2032 def validator(self, node: SchemaNode, value: Any) -> None:
2033 if (
2034 (not value["confirm_1_t"])
2035 or (not value["confirm_2_t"])
2036 or value["confirm_3_f"]
2037 or (not value["confirm_4_t"])
2038 ):
2039 _ = self.gettext
2040 raise Invalid(node, _("Not fully confirmed"))
2043# -----------------------------------------------------------------------------
2044# URLs
2045# -----------------------------------------------------------------------------
2048class HiddenRedirectionUrlNode(HiddenStringNode, RequestAwareMixin):
2049 """
2050 Note to encode a hidden URL, for redirection.
2051 """
2053 # noinspection PyMethodMayBeStatic
2054 def validator(self, node: SchemaNode, value: str) -> None:
2055 if value:
2056 try:
2057 validate_redirect_url(value, self.request)
2058 except ValueError:
2059 _ = self.gettext
2060 raise Invalid(node, _("Invalid redirection URL"))
2063# -----------------------------------------------------------------------------
2064# Phone number
2065# -----------------------------------------------------------------------------
2068class PhoneNumberType(String):
2069 def __init__(self, request: "CamcopsRequest", *args, **kwargs) -> None:
2070 super().__init__(*args, **kwargs)
2072 self.request = request
2074 # noinspection PyMethodMayBeStatic, PyUnusedLocal
2075 def deserialize(
2076 self, node: SchemaNode, cstruct: Union[str, ColanderNullType, None]
2077 ) -> Optional[phonenumbers.PhoneNumber]:
2078 request = self.request # type: CamcopsRequest
2079 _ = request.gettext
2080 err_message = _("Invalid phone number")
2082 # is null when form is empty
2083 if not cstruct:
2084 if not self.allow_empty:
2085 raise Invalid(node, err_message)
2086 return null
2088 cstruct: str
2090 try:
2091 phone_number = phonenumbers.parse(
2092 cstruct, request.config.region_code
2093 )
2094 except phonenumbers.NumberParseException:
2095 raise Invalid(node, err_message)
2097 if not phonenumbers.is_valid_number(phone_number):
2098 # the number may parse but could still be invalid
2099 # (e.g. too few digits)
2100 raise Invalid(node, err_message)
2102 return phone_number
2104 # noinspection PyMethodMayBeStatic,PyUnusedLocal
2105 def serialize(
2106 self,
2107 node: SchemaNode,
2108 appstruct: Union[phonenumbers.PhoneNumber, None, ColanderNullType],
2109 ) -> Union[str, ColanderNullType]:
2110 # is None when populated from empty value in the database
2111 if not appstruct:
2112 return null
2114 # appstruct should be well formed here (it would already have failed
2115 # when reading from the database)
2116 return phonenumbers.format_number(
2117 appstruct, phonenumbers.PhoneNumberFormat.E164
2118 )
2121class MandatoryPhoneNumberNode(MandatoryStringNode, RequestAwareMixin):
2122 default = None
2123 missing = None
2125 # noinspection PyUnusedLocal
2126 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
2127 _ = self.gettext
2128 self.title = _("Phone number")
2129 self.typ = PhoneNumberType(self.request, allow_empty=False)
2132# =============================================================================
2133# Login
2134# =============================================================================
2137class LoginSchema(CSRFSchema):
2138 """
2139 Schema to capture login details.
2140 """
2142 username = UsernameNode(
2143 autocomplete=AutocompleteAttrValues.USERNAME
2144 ) # name must match ViewParam.USERNAME
2145 password = SchemaNode( # name must match ViewParam.PASSWORD
2146 String(),
2147 widget=PasswordWidget(
2148 attributes={
2149 AUTOCOMPLETE_ATTR: AutocompleteAttrValues.CURRENT_PASSWORD
2150 }
2151 ),
2152 )
2153 redirect_url = (
2154 HiddenRedirectionUrlNode()
2155 ) # name must match ViewParam.REDIRECT_URL
2157 def __init__(
2158 self, *args, autocomplete_password: bool = True, **kwargs
2159 ) -> None:
2160 self.autocomplete_password = autocomplete_password
2161 super().__init__(*args, **kwargs)
2163 # noinspection PyUnusedLocal
2164 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
2165 _ = self.gettext
2166 password = get_child_node(self, "password")
2167 password.title = _("Password")
2168 password.widget.attributes[AUTOCOMPLETE_ATTR] = (
2169 AutocompleteAttrValues.CURRENT_PASSWORD
2170 if self.autocomplete_password
2171 else AutocompleteAttrValues.OFF
2172 )
2175class LoginForm(InformativeNonceForm):
2176 """
2177 Form to capture login details.
2178 """
2180 def __init__(
2181 self,
2182 request: "CamcopsRequest",
2183 autocomplete_password: bool = True,
2184 **kwargs,
2185 ) -> None:
2186 """
2187 Args:
2188 autocomplete_password:
2189 suggest to the browser that it's OK to store the password for
2190 autocompletion? Note that browsers may ignore this.
2191 """
2192 _ = request.gettext
2193 schema = LoginSchema(autocomplete_password=autocomplete_password).bind(
2194 request=request
2195 )
2196 super().__init__(
2197 schema,
2198 buttons=[Button(name=FormAction.SUBMIT, title=_("Log in"))],
2199 # autocomplete=autocomplete_password,
2200 **kwargs,
2201 )
2202 # Suboptimal: autocomplete_password is not applied to the password
2203 # widget, just to the form; see
2204 # http://stackoverflow.com/questions/2530
2205 # Note that e.g. Chrome may ignore this.
2206 # ... fixed 2020-09-29 by applying autocomplete to LoginSchema.password
2209class OtpSchema(CSRFSchema):
2210 """
2211 Schema to capture a one-time password for Multi-factor Authentication.
2212 """
2214 one_time_password = MandatoryStringNode()
2215 redirect_url = (
2216 HiddenRedirectionUrlNode()
2217 ) # name must match ViewParam.REDIRECT_URL
2219 # noinspection PyUnusedLocal
2220 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
2221 _ = self.gettext
2222 one_time_password = get_child_node(self, "one_time_password")
2223 one_time_password.title = _("Enter the six-digit code")
2226class OtpTokenForm(InformativeNonceForm):
2227 """
2228 Form to capture a one-time password for Multi-factor authentication.
2229 """
2231 def __init__(self, request: "CamcopsRequest", **kwargs) -> None:
2232 _ = request.gettext
2233 schema = OtpSchema().bind(request=request)
2234 super().__init__(
2235 schema,
2236 buttons=[Button(name=FormAction.SUBMIT, title=_("Submit"))],
2237 **kwargs,
2238 )
2241# =============================================================================
2242# Change password
2243# =============================================================================
2246class MustChangePasswordNode(SchemaNode, RequestAwareMixin):
2247 """
2248 Boolean node: must the user change their password?
2249 """
2251 schema_type = Boolean
2252 default = True
2253 missing = True
2255 def __init__(self, *args, **kwargs) -> None:
2256 self.label = "" # for type checker
2257 self.title = "" # for type checker
2258 super().__init__(*args, **kwargs)
2260 # noinspection PyUnusedLocal
2261 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
2262 _ = self.gettext
2263 self.label = _("User must change password at next login")
2264 self.title = _("Must change password at next login?")
2267class OldUserPasswordCheck(SchemaNode, RequestAwareMixin):
2268 """
2269 Schema to capture an old password (for when a password is being changed).
2270 """
2272 schema_type = String
2273 widget = PasswordWidget(
2274 attributes={AUTOCOMPLETE_ATTR: AutocompleteAttrValues.CURRENT_PASSWORD}
2275 )
2277 def __init__(self, *args, **kwargs) -> None:
2278 self.title = "" # for type checker
2279 super().__init__(*args, **kwargs)
2281 # noinspection PyUnusedLocal
2282 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
2283 _ = self.gettext
2284 self.title = _("Old password")
2286 def validator(self, node: SchemaNode, value: str) -> None:
2287 request = self.request
2288 user = request.user
2289 assert user is not None
2290 if not user.is_password_correct(value):
2291 _ = request.gettext
2292 raise Invalid(node, _("Old password incorrect"))
2295class InformationalCheckedPasswordWidget(CheckedPasswordWidget):
2296 """
2297 A more verbose version of Deform's CheckedPasswordWidget
2298 which provides advice on good passwords.
2299 """
2301 basedir = os.path.join(TEMPLATE_DIR, "deform")
2302 readonlydir = os.path.join(basedir, "readonly")
2303 form = "informational_checked_password.pt"
2304 template = os.path.join(basedir, form)
2305 readonly_template = os.path.join(readonlydir, form)
2307 def __init__(self, request: "CamcopsRequest", **kwargs: Any) -> None:
2308 super().__init__(**kwargs)
2309 self.request = request
2311 def get_template_values(
2312 self, field: "Field", cstruct: str, kw: Dict[str, Any]
2313 ) -> Dict[str, Any]:
2314 values = super().get_template_values(field, cstruct, kw)
2316 _ = self.request.gettext
2318 href = "https://www.ncsc.gov.uk/blog-post/three-random-words-or-thinkrandom-0" # noqa: E501
2319 link = f'<a href="{href}">{href}</a>'
2320 password_advice = _("Choose strong passphrases. See {link}").format(
2321 link=link
2322 )
2323 min_password_length = _(
2324 "Minimum password length is {limit} " "characters."
2325 ).format(limit=MINIMUM_PASSWORD_LENGTH)
2327 values.update(
2328 password_advice=password_advice,
2329 min_password_length=min_password_length,
2330 )
2332 return values
2335class NewPasswordNode(SchemaNode, RequestAwareMixin):
2336 """
2337 Node to enter a new password.
2338 """
2340 schema_type = String
2342 def __init__(self, *args, **kwargs) -> None:
2343 self.title = "" # for type checker
2344 self.description = "" # for type checker
2345 super().__init__(*args, **kwargs)
2347 # noinspection PyUnusedLocal
2348 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
2349 _ = self.gettext
2350 self.title = _("New password")
2351 self.description = _("Type the new password and confirm it")
2352 self.widget = InformationalCheckedPasswordWidget(
2353 self.request,
2354 attributes={
2355 AUTOCOMPLETE_ATTR: AutocompleteAttrValues.NEW_PASSWORD
2356 },
2357 )
2359 def validator(self, node: SchemaNode, value: str) -> None:
2360 try:
2361 validate_new_password(value, self.request)
2362 except ValueError as e:
2363 raise Invalid(node, str(e))
2366class ChangeOwnPasswordSchema(CSRFSchema):
2367 """
2368 Schema to change one's own password.
2369 """
2371 old_password = OldUserPasswordCheck()
2372 new_password = NewPasswordNode() # name must match ViewParam.NEW_PASSWORD
2374 def __init__(self, *args, must_differ: bool = True, **kwargs) -> None:
2375 """
2376 Args:
2377 must_differ:
2378 must the new password be different from the old one?
2379 """
2380 self.must_differ = must_differ
2381 super().__init__(*args, **kwargs)
2383 def validator(self, node: SchemaNode, value: Dict[str, str]) -> None:
2384 if self.must_differ and value["new_password"] == value["old_password"]:
2385 _ = self.gettext
2386 raise Invalid(node, _("New password must differ from old"))
2389class ChangeOwnPasswordForm(InformativeNonceForm):
2390 """
2391 Form to change one's own password.
2392 """
2394 def __init__(
2395 self, request: "CamcopsRequest", must_differ: bool = True, **kwargs
2396 ) -> None:
2397 """
2398 Args:
2399 must_differ:
2400 must the new password be different from the old one?
2401 """
2402 schema = ChangeOwnPasswordSchema(must_differ=must_differ).bind(
2403 request=request
2404 )
2405 super().__init__(
2406 schema,
2407 buttons=[
2408 Button(
2409 name=FormAction.SUBMIT,
2410 title=change_password_title(request),
2411 )
2412 ],
2413 **kwargs,
2414 )
2417class ChangeOtherPasswordSchema(CSRFSchema):
2418 """
2419 Schema to change another user's password.
2420 """
2422 user_id = HiddenIntegerNode() # name must match ViewParam.USER_ID
2423 must_change_password = (
2424 MustChangePasswordNode()
2425 ) # match ViewParam.MUST_CHANGE_PASSWORD
2426 new_password = NewPasswordNode() # name must match ViewParam.NEW_PASSWORD
2429class ChangeOtherPasswordForm(SimpleSubmitForm):
2430 """
2431 Form to change another user's password.
2432 """
2434 def __init__(self, request: "CamcopsRequest", **kwargs) -> None:
2435 _ = request.gettext
2436 super().__init__(
2437 schema_class=ChangeOtherPasswordSchema,
2438 submit_title=_("Submit"),
2439 request=request,
2440 **kwargs,
2441 )
2444class DisableMfaNode(SchemaNode, RequestAwareMixin):
2445 """
2446 Boolean node: disable multi-factor authentication
2447 """
2449 schema_type = Boolean
2450 default = False
2451 missing = False
2453 def __init__(self, *args, **kwargs) -> None:
2454 self.label = "" # for type checker
2455 self.title = "" # for type checker
2456 super().__init__(*args, **kwargs)
2458 # noinspection PyUnusedLocal
2459 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
2460 _ = self.gettext
2461 self.label = _("Disable multi-factor authentication")
2462 self.title = _("Disable multi-factor authentication?")
2465class EditOtherUserMfaSchema(CSRFSchema):
2466 """
2467 Schema to reset multi-factor authentication for another user.
2468 """
2470 user_id = HiddenIntegerNode() # name must match ViewParam.USER_ID
2471 disable_mfa = DisableMfaNode() # match ViewParam.DISABLE_MFA
2474class EditOtherUserMfaForm(SimpleSubmitForm):
2475 """
2476 Form to reset multi-factor authentication for another user.
2477 """
2479 def __init__(self, request: "CamcopsRequest", **kwargs) -> None:
2480 _ = request.gettext
2481 super().__init__(
2482 schema_class=EditOtherUserMfaSchema,
2483 submit_title=_("Submit"),
2484 request=request,
2485 **kwargs,
2486 )
2489# =============================================================================
2490# Multi-factor authentication
2491# =============================================================================
2494class MfaSecretWidget(TextInputWidget):
2495 """
2496 Display the TOTP (authorization app) secret as a QR code and alphanumeric
2497 string.
2498 """
2500 basedir = os.path.join(TEMPLATE_DIR, "deform")
2501 readonlydir = os.path.join(basedir, "readonly")
2502 form = "mfa_secret.pt"
2503 template = os.path.join(basedir, form)
2504 readonly_template = os.path.join(readonlydir, form)
2506 def __init__(self, request: "CamcopsRequest", **kwargs: Any) -> None:
2507 super().__init__(**kwargs)
2508 self.request = request
2510 def serialize(self, field: "Field", cstruct: str, **kw: Any) -> Any:
2511 # cstruct contains the MFA secret key
2512 readonly = kw.get("readonly", self.readonly)
2513 template = readonly and self.readonly_template or self.template
2514 values = self.get_template_values(field, cstruct, kw)
2516 _ = self.request.gettext
2518 factory = qrcode.image.svg.SvgImage
2519 totp = pyotp.totp.TOTP(cstruct)
2520 uri = totp.provisioning_uri(
2521 name=self.request.user.username, issuer_name="CamCOPS"
2522 )
2523 img = qrcode.make(uri, image_factory=factory, box_size=20)
2524 stream = BytesIO()
2525 img.save(stream)
2526 values.update(
2527 open_app=_("Open your authentication app."),
2528 scan_qr_code=_("Add CamCOPS to the app by scanning this QR code:"),
2529 qr_code=stream.getvalue().decode(),
2530 enter_key=_(
2531 "If you can't scan the QR code, enter this key " "instead:"
2532 ),
2533 enter_code=_(
2534 "When prompted, enter the 6-digit code displayed on "
2535 "the app."
2536 ),
2537 )
2539 return field.renderer(template, **values)
2542class MfaSecretNode(OptionalStringNode, RequestAwareMixin):
2543 """
2544 Node to display the TOTP (authorization app) secret as a QR code and
2545 alphanumeric string.
2546 """
2548 schema_type = String
2550 # noinspection PyUnusedLocal,PyAttributeOutsideInit
2551 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
2552 self.widget = MfaSecretWidget(self.request)
2555class MfaMethodSelector(SchemaNode, RequestAwareMixin):
2556 """
2557 Node to select type of authentication
2558 """
2560 schema_type = String
2561 default = MfaMethod.TOTP
2562 missing = MfaMethod.TOTP
2564 def __init__(self, *args, **kwargs) -> None:
2565 self.title = "" # for type checker
2566 self.widget = None # type: Optional[Widget]
2567 self.validator = None # type: Optional[ValidatorType]
2568 super().__init__(*args, **kwargs)
2570 # noinspection PyUnusedLocal
2571 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
2572 _ = self.gettext
2573 self.title = _("Authentication type")
2574 request = self.bindings[Binding.REQUEST] # type: CamcopsRequest
2575 all_mfa_choices = [
2576 (
2577 MfaMethod.TOTP,
2578 _("Use an app such as Google Authenticator or Twilio Authy"),
2579 ),
2580 (MfaMethod.HOTP_EMAIL, _("Send me a code by email")),
2581 (MfaMethod.HOTP_SMS, _("Send me a code by text message")),
2582 (MfaMethod.NO_MFA, _("Disable multi-factor authentication")),
2583 ]
2585 choices = []
2586 for (label, description) in all_mfa_choices:
2587 if label in request.config.mfa_methods:
2588 choices.append((label, description))
2589 values, pv = get_values_and_permissible(choices)
2591 self.widget = RadioChoiceWidget(values=values)
2592 self.validator = OneOf(pv)
2595class MfaMethodSchema(CSRFSchema):
2596 """
2597 Schema to edit Multi-factor Authentication method.
2598 """
2600 mfa_method = MfaMethodSelector() # must match ViewParam.MFA_METHOD
2602 # noinspection PyUnusedLocal
2603 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
2604 _ = self.gettext
2605 mfa_method = get_child_node(self, "mfa_method")
2606 mfa_method.title = _("How do you wish to authenticate?")
2609class MfaTotpSchema(CSRFSchema):
2610 """
2611 Schema to set up Multi-factor Authentication with authentication app.
2612 """
2614 mfa_secret_key = MfaSecretNode() # must match ViewParam.MFA_SECRET_KEY
2616 # noinspection PyUnusedLocal
2617 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
2618 _ = self.gettext
2619 mfa_secret_key = get_child_node(self, "mfa_secret_key")
2620 mfa_secret_key.title = _("Follow these steps:")
2623class MfaHotpEmailSchema(CSRFSchema):
2624 """
2625 Schema to change a user's email address for multi-factor authentication.
2626 """
2628 mfa_secret_key = HiddenStringNode() # must match ViewParam.MFA_SECRET_KEY
2629 email = MandatoryEmailNode() # must match ViewParam.EMAIL
2631 # noinspection PyUnusedLocal
2632 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
2633 _ = self.gettext
2636class MfaHotpSmsSchema(CSRFSchema):
2637 """
2638 Schema to change a user's phone number for multi-factor authentication.
2639 """
2641 mfa_secret_key = HiddenStringNode() # must match ViewParam.MFA_SECRET_KEY
2642 phone_number = (
2643 MandatoryPhoneNumberNode()
2644 ) # must match ViewParam.PHONE_NUMBER
2646 # noinspection PyUnusedLocal
2647 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
2648 _ = self.gettext
2649 phone_number = get_child_node(self, ViewParam.PHONE_NUMBER)
2650 phone_number.description = _(
2651 "Include the country code (e.g. +123) for numbers outside of the "
2652 "'{region_code}' region"
2653 ).format(region_code=self.request.config.region_code)
2656class MfaMethodForm(InformativeNonceForm):
2657 """
2658 Form to change one's own Multi-factor Authentication settings.
2659 """
2661 def __init__(self, request: "CamcopsRequest", **kwargs) -> None:
2662 schema = MfaMethodSchema().bind(request=request)
2663 super().__init__(
2664 schema, buttons=[Button(name=FormAction.SUBMIT)], **kwargs
2665 )
2668class MfaTotpForm(InformativeNonceForm):
2669 """
2670 Form to set up Multi-factor Authentication with authentication app.
2671 """
2673 def __init__(self, request: "CamcopsRequest", **kwargs) -> None:
2674 schema = MfaTotpSchema().bind(request=request)
2675 super().__init__(
2676 schema, buttons=[Button(name=FormAction.SUBMIT)], **kwargs
2677 )
2680class MfaHotpEmailForm(InformativeNonceForm):
2681 """
2682 Form to change a user's email address for multi-factor authentication.
2683 """
2685 def __init__(self, request: "CamcopsRequest", **kwargs) -> None:
2686 schema = MfaHotpEmailSchema().bind(request=request)
2687 super().__init__(
2688 schema, buttons=[Button(name=FormAction.SUBMIT)], **kwargs
2689 )
2692class MfaHotpSmsForm(InformativeNonceForm):
2693 """
2694 Form to change a user's phone number for multi-factor authentication.
2695 """
2697 def __init__(self, request: "CamcopsRequest", **kwargs) -> None:
2698 schema = MfaHotpSmsSchema().bind(request=request)
2699 super().__init__(
2700 schema, buttons=[Button(name=FormAction.SUBMIT)], **kwargs
2701 )
2704# =============================================================================
2705# Offer/agree terms
2706# =============================================================================
2709class OfferTermsSchema(CSRFSchema):
2710 """
2711 Schema to offer terms and ask the user to accept them.
2712 """
2714 pass
2717class OfferTermsForm(SimpleSubmitForm):
2718 """
2719 Form to offer terms and ask the user to accept them.
2720 """
2722 def __init__(
2723 self, request: "CamcopsRequest", agree_button_text: str, **kwargs
2724 ) -> None:
2725 """
2726 Args:
2727 agree_button_text:
2728 text for the "agree" button
2729 """
2730 super().__init__(
2731 schema_class=OfferTermsSchema,
2732 submit_title=agree_button_text,
2733 request=request,
2734 **kwargs,
2735 )
2738# =============================================================================
2739# View audit trail
2740# =============================================================================
2743class OptionalIPAddressNode(OptionalStringNode, RequestAwareMixin):
2744 """
2745 Optional IPv4 or IPv6 address.
2746 """
2748 def validator(self, node: SchemaNode, value: str) -> None:
2749 try:
2750 validate_ip_address(value, self.request)
2751 except ValueError as e:
2752 raise Invalid(node, e)
2755class OptionalAuditSourceNode(OptionalStringNode, RequestAwareMixin):
2756 """
2757 Optional IPv4 or IPv6 address.
2758 """
2760 def validator(self, node: SchemaNode, value: str) -> None:
2761 try:
2762 validate_by_char_and_length(
2763 value,
2764 permitted_char_expression=ALPHANUM_UNDERSCORE_CHAR,
2765 min_length=0,
2766 max_length=StringLengths.AUDIT_SOURCE_MAX_LEN,
2767 req=self.request,
2768 )
2769 except ValueError as e:
2770 raise Invalid(node, e)
2773class AuditTrailSchema(CSRFSchema):
2774 """
2775 Schema to filter audit trail entries.
2776 """
2778 rows_per_page = RowsPerPageSelector() # must match ViewParam.ROWS_PER_PAGE
2779 start_datetime = (
2780 StartPendulumSelector()
2781 ) # must match ViewParam.START_DATETIME # noqa
2782 end_datetime = EndPendulumSelector() # must match ViewParam.END_DATETIME
2783 source = OptionalAuditSourceNode() # must match ViewParam.SOURCE # noqa
2784 remote_ip_addr = (
2785 OptionalIPAddressNode()
2786 ) # must match ViewParam.REMOTE_IP_ADDR # noqa
2787 username = (
2788 OptionalUserNameSelector()
2789 ) # must match ViewParam.USERNAME # noqa
2790 table_name = (
2791 OptionalSingleTaskSelector()
2792 ) # must match ViewParam.TABLENAME # noqa
2793 server_pk = ServerPkSelector() # must match ViewParam.SERVER_PK
2794 truncate = BooleanNode(default=True) # must match ViewParam.TRUNCATE
2796 # noinspection PyUnusedLocal
2797 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
2798 _ = self.gettext
2799 source = get_child_node(self, "source")
2800 source.title = _("Source (e.g. webviewer, tablet, console)")
2801 remote_ip_addr = get_child_node(self, "remote_ip_addr")
2802 remote_ip_addr.title = _("Remote IP address")
2803 truncate = get_child_node(self, "truncate")
2804 truncate.title = _("Truncate details for easy viewing")
2807class AuditTrailForm(SimpleSubmitForm):
2808 """
2809 Form to filter and then view audit trail entries.
2810 """
2812 def __init__(self, request: "CamcopsRequest", **kwargs) -> None:
2813 _ = request.gettext
2814 super().__init__(
2815 schema_class=AuditTrailSchema,
2816 submit_title=_("View audit trail"),
2817 request=request,
2818 **kwargs,
2819 )
2822# =============================================================================
2823# View export logs
2824# =============================================================================
2827class OptionalExportRecipientNameSelector(
2828 OptionalStringNode, RequestAwareMixin
2829):
2830 """
2831 Optional node to pick an export recipient name from those present in the
2832 database.
2833 """
2835 title = "Export recipient"
2837 def __init__(self, *args, **kwargs) -> None:
2838 self.validator = None # type: Optional[ValidatorType]
2839 self.widget = None # type: Optional[Widget]
2840 super().__init__(*args, **kwargs)
2842 # noinspection PyUnusedLocal
2843 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
2844 from camcops_server.cc_modules.cc_exportrecipient import (
2845 ExportRecipient,
2846 ) # delayed import
2848 request = self.request
2849 _ = request.gettext
2850 dbsession = request.dbsession
2851 q = (
2852 dbsession.query(ExportRecipient.recipient_name)
2853 .distinct()
2854 .order_by(ExportRecipient.recipient_name)
2855 )
2856 values = [] # type: List[Tuple[str, str]]
2857 for row in q:
2858 recipient_name = row[0]
2859 values.append((recipient_name, recipient_name))
2860 values, pv = get_values_and_permissible(values, True, _("[Any]"))
2861 self.widget = SelectWidget(values=values)
2862 self.validator = OneOf(pv)
2865class ExportedTaskListSchema(CSRFSchema):
2866 """
2867 Schema to filter HL7 message logs.
2868 """
2870 rows_per_page = RowsPerPageSelector() # must match ViewParam.ROWS_PER_PAGE
2871 recipient_name = (
2872 OptionalExportRecipientNameSelector()
2873 ) # must match ViewParam.RECIPIENT_NAME # noqa
2874 table_name = (
2875 OptionalSingleTaskSelector()
2876 ) # must match ViewParam.TABLENAME # noqa
2877 server_pk = ServerPkSelector() # must match ViewParam.SERVER_PK
2878 id = OptionalIntNode() # must match ViewParam.ID # noqa
2879 start_datetime = (
2880 StartDateTimeSelector()
2881 ) # must match ViewParam.START_DATETIME # noqa
2882 end_datetime = EndDateTimeSelector() # must match ViewParam.END_DATETIME
2884 # noinspection PyUnusedLocal
2885 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
2886 _ = self.gettext
2887 id_ = get_child_node(self, "id")
2888 id_.title = _("ExportedTask ID")
2891class ExportedTaskListForm(SimpleSubmitForm):
2892 """
2893 Form to filter and then view exported task logs.
2894 """
2896 def __init__(self, request: "CamcopsRequest", **kwargs) -> None:
2897 _ = request.gettext
2898 super().__init__(
2899 schema_class=ExportedTaskListSchema,
2900 submit_title=_("View exported task log"),
2901 request=request,
2902 **kwargs,
2903 )
2906# =============================================================================
2907# Task filters
2908# =============================================================================
2911class TextContentsSequence(SequenceSchema, RequestAwareMixin):
2912 """
2913 Sequence to capture multiple pieces of text (representing text contents
2914 for a task filter).
2915 """
2917 text_sequence = SchemaNode(
2918 String(), validator=Length(0, StringLengths.FILTER_TEXT_MAX_LEN)
2919 ) # BEWARE: fairly unrestricted contents.
2921 def __init__(self, *args, **kwargs) -> None:
2922 self.title = "" # for type checker
2923 self.description = "" # for type checker
2924 self.widget = None # type: Optional[Widget]
2925 super().__init__(*args, **kwargs)
2927 # noinspection PyUnusedLocal
2928 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
2929 _ = self.gettext
2930 self.title = _("Text contents")
2931 self.description = self.or_join_description
2932 self.widget = TranslatableSequenceWidget(request=self.request)
2933 # Now it'll say "[Add]" Text Sequence because it'll make the string
2934 # "Text Sequence" from the name of text_sequence. Unless we do this:
2935 text_sequence = get_child_node(self, "text_sequence")
2936 # TRANSLATOR: For the task filter form: the text in "Add text"
2937 text_sequence.title = _("text")
2939 # noinspection PyMethodMayBeStatic
2940 def validator(self, node: SchemaNode, value: List[str]) -> None:
2941 assert isinstance(value, list)
2942 if len(value) != len(set(value)):
2943 _ = self.gettext
2944 raise Invalid(node, _("You have specified duplicate text filters"))
2947class UploadingUserSequence(SequenceSchema, RequestAwareMixin):
2948 """
2949 Sequence to capture multiple users (for task filters: "uploaded by one of
2950 the following users...").
2951 """
2953 user_id_sequence = MandatoryUserIdSelectorUsersAllowedToSee()
2955 def __init__(self, *args, **kwargs) -> None:
2956 self.title = "" # for type checker
2957 self.description = "" # for type checker
2958 self.widget = None # type: Optional[Widget]
2959 super().__init__(*args, **kwargs)
2961 # noinspection PyUnusedLocal
2962 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
2963 _ = self.gettext
2964 self.title = _("Uploading users")
2965 self.description = self.or_join_description
2966 self.widget = TranslatableSequenceWidget(request=self.request)
2968 # noinspection PyMethodMayBeStatic
2969 def validator(self, node: SchemaNode, value: List[int]) -> None:
2970 assert isinstance(value, list)
2971 if len(value) != len(set(value)):
2972 _ = self.gettext
2973 raise Invalid(node, _("You have specified duplicate users"))
2976class DevicesSequence(SequenceSchema, RequestAwareMixin):
2977 """
2978 Sequence to capture multiple client devices (for task filters: "uploaded by
2979 one of the following devices...").
2980 """
2982 device_id_sequence = MandatoryDeviceIdSelector()
2984 def __init__(self, *args, **kwargs) -> None:
2985 self.title = "" # for type checker
2986 self.description = "" # for type checker
2987 self.widget = None # type: Optional[Widget]
2988 super().__init__(*args, **kwargs)
2990 # noinspection PyUnusedLocal
2991 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
2992 _ = self.gettext
2993 self.title = _("Uploading devices")
2994 self.description = self.or_join_description
2995 self.widget = TranslatableSequenceWidget(request=self.request)
2997 # noinspection PyMethodMayBeStatic
2998 def validator(self, node: SchemaNode, value: List[int]) -> None:
2999 assert isinstance(value, list)
3000 if len(value) != len(set(value)):
3001 raise Invalid(node, "You have specified duplicate devices")
3004class OptionalPatientNameNode(OptionalStringNode, RequestAwareMixin):
3005 def validator(self, node: SchemaNode, value: str) -> None:
3006 try:
3007 # TODO: Validating human names is hard.
3008 # Decide if validation here is necessary and whether it should
3009 # be configurable.
3010 # validate_human_name(value, self.request)
3012 # Does nothing but better to be explicit
3013 validate_anything(value, self.request)
3014 except ValueError as e:
3015 # Should never happen with validate_anything
3016 raise Invalid(node, str(e))
3019class EditTaskFilterWhoSchema(Schema, RequestAwareMixin):
3020 """
3021 Schema to edit the "who" parts of a task filter.
3022 """
3024 surname = OptionalPatientNameNode() # must match ViewParam.SURNAME # noqa
3025 forename = (
3026 OptionalPatientNameNode()
3027 ) # must match ViewParam.FORENAME # noqa
3028 dob = SchemaNode(Date(), missing=None) # must match ViewParam.DOB
3029 sex = OptionalSexSelector() # must match ViewParam.SEX
3030 id_references = (
3031 IdNumSequenceAnyCombination()
3032 ) # must match ViewParam.ID_REFERENCES # noqa
3034 # noinspection PyUnusedLocal
3035 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
3036 _ = self.gettext
3037 surname = get_child_node(self, "surname")
3038 surname.title = _("Surname")
3039 forename = get_child_node(self, "forename")
3040 forename.title = _("Forename")
3041 dob = get_child_node(self, "dob")
3042 dob.title = _("Date of birth")
3043 id_references = get_child_node(self, "id_references")
3044 id_references.description = self.or_join_description
3047class EditTaskFilterWhenSchema(Schema):
3048 """
3049 Schema to edit the "when" parts of a task filter.
3050 """
3052 start_datetime = (
3053 StartPendulumSelector()
3054 ) # must match ViewParam.START_DATETIME # noqa
3055 end_datetime = EndPendulumSelector() # must match ViewParam.END_DATETIME
3058class EditTaskFilterWhatSchema(Schema, RequestAwareMixin):
3059 """
3060 Schema to edit the "what" parts of a task filter.
3061 """
3063 text_contents = (
3064 TextContentsSequence()
3065 ) # must match ViewParam.TEXT_CONTENTS # noqa
3066 complete_only = BooleanNode(
3067 default=False
3068 ) # must match ViewParam.COMPLETE_ONLY # noqa
3069 tasks = MultiTaskSelector() # must match ViewParam.TASKS
3071 # noinspection PyUnusedLocal
3072 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
3073 _ = self.gettext
3074 complete_only = get_child_node(self, "complete_only")
3075 only_completed_text = _("Only completed tasks?")
3076 complete_only.title = only_completed_text
3077 complete_only.label = only_completed_text
3080class EditTaskFilterAdminSchema(Schema):
3081 """
3082 Schema to edit the "admin" parts of a task filter.
3083 """
3085 device_ids = DevicesSequence() # must match ViewParam.DEVICE_IDS
3086 user_ids = UploadingUserSequence() # must match ViewParam.USER_IDS
3087 group_ids = AllowedGroupsSequence() # must match ViewParam.GROUP_IDS
3090class EditTaskFilterSchema(CSRFSchema):
3091 """
3092 Schema to edit a task filter.
3093 """
3095 who = EditTaskFilterWhoSchema( # must match ViewParam.WHO
3096 widget=MappingWidget(template="mapping_accordion", open=False)
3097 )
3098 what = EditTaskFilterWhatSchema( # must match ViewParam.WHAT
3099 widget=MappingWidget(template="mapping_accordion", open=False)
3100 )
3101 when = EditTaskFilterWhenSchema( # must match ViewParam.WHEN
3102 widget=MappingWidget(template="mapping_accordion", open=False)
3103 )
3104 admin = EditTaskFilterAdminSchema( # must match ViewParam.ADMIN
3105 widget=MappingWidget(template="mapping_accordion", open=False)
3106 )
3108 # noinspection PyUnusedLocal
3109 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
3110 # log.debug("EditTaskFilterSchema.after_bind")
3111 # log.debug("{!r}", self.__dict__)
3112 # This is pretty nasty. By the time we get here, the Form class has
3113 # made Field objects, and, I think, called a clone() function on us.
3114 # Objects like "who" are not in our __dict__ any more. Our __dict__
3115 # looks like:
3116 # {
3117 # 'typ': <colander.Mapping object at 0x7fd7989b18d0>,
3118 # 'bindings': {
3119 # 'open_who': True,
3120 # 'open_when': True,
3121 # 'request': ...,
3122 # },
3123 # '_order': 118,
3124 # 'children': [
3125 # <...CSRFToken object at ... (named csrf)>,
3126 # <...EditTaskFilterWhoSchema object at ... (named who)>,
3127 # ...
3128 # ],
3129 # 'title': ''
3130 # }
3131 _ = self.gettext
3132 who = get_child_node(self, "who")
3133 what = get_child_node(self, "what")
3134 when = get_child_node(self, "when")
3135 admin = get_child_node(self, "admin")
3136 who.title = _("Who")
3137 what.title = _("What")
3138 when.title = _("When")
3139 admin.title = _("Administrative criteria")
3140 # log.debug("who = {!r}", who)
3141 # log.debug("who.__dict__ = {!r}", who.__dict__)
3142 who.widget.open = kw[Binding.OPEN_WHO]
3143 what.widget.open = kw[Binding.OPEN_WHAT]
3144 when.widget.open = kw[Binding.OPEN_WHEN]
3145 admin.widget.open = kw[Binding.OPEN_ADMIN]
3148class EditTaskFilterForm(InformativeNonceForm):
3149 """
3150 Form to edit a task filter.
3151 """
3153 def __init__(
3154 self,
3155 request: "CamcopsRequest",
3156 open_who: bool = False,
3157 open_what: bool = False,
3158 open_when: bool = False,
3159 open_admin: bool = False,
3160 **kwargs,
3161 ) -> None:
3162 _ = request.gettext
3163 schema = EditTaskFilterSchema().bind(
3164 request=request,
3165 open_admin=open_admin,
3166 open_what=open_what,
3167 open_when=open_when,
3168 open_who=open_who,
3169 )
3170 super().__init__(
3171 schema,
3172 buttons=[
3173 Button(name=FormAction.SET_FILTERS, title=_("Set filters")),
3174 Button(name=FormAction.CLEAR_FILTERS, title=_("Clear")),
3175 ],
3176 **kwargs,
3177 )
3180class TasksPerPageSchema(CSRFSchema):
3181 """
3182 Schema to edit the number of rows per page, for the task view.
3183 """
3185 rows_per_page = RowsPerPageSelector() # must match ViewParam.ROWS_PER_PAGE
3188class TasksPerPageForm(InformativeNonceForm):
3189 """
3190 Form to edit the number of tasks per page, for the task view.
3191 """
3193 def __init__(self, request: "CamcopsRequest", **kwargs) -> None:
3194 _ = request.gettext
3195 schema = TasksPerPageSchema().bind(request=request)
3196 super().__init__(
3197 schema,
3198 buttons=[
3199 Button(
3200 name=FormAction.SUBMIT_TASKS_PER_PAGE,
3201 title=_("Set n/page"),
3202 )
3203 ],
3204 css_class=BootstrapCssClasses.FORM_INLINE,
3205 **kwargs,
3206 )
3209class RefreshTasksSchema(CSRFSchema):
3210 """
3211 Schema for a "refresh tasks" button.
3212 """
3214 pass
3217class RefreshTasksForm(InformativeNonceForm):
3218 """
3219 Form for a "refresh tasks" button.
3220 """
3222 def __init__(self, request: "CamcopsRequest", **kwargs) -> None:
3223 _ = request.gettext
3224 schema = RefreshTasksSchema().bind(request=request)
3225 super().__init__(
3226 schema,
3227 buttons=[
3228 Button(name=FormAction.REFRESH_TASKS, title=_("Refresh"))
3229 ],
3230 **kwargs,
3231 )
3234# =============================================================================
3235# Trackers
3236# =============================================================================
3239class TaskTrackerOutputTypeSelector(SchemaNode, RequestAwareMixin):
3240 """
3241 Node to select the output format for a tracker.
3242 """
3244 # Choices don't require translation
3245 _choices = (
3246 (ViewArg.HTML, "HTML"),
3247 (ViewArg.PDF, "PDF"),
3248 (ViewArg.XML, "XML"),
3249 )
3251 schema_type = String
3252 default = ViewArg.HTML
3253 missing = ViewArg.HTML
3254 widget = RadioChoiceWidget(values=_choices)
3255 validator = OneOf(list(x[0] for x in _choices))
3257 def __init__(self, *args, **kwargs) -> None:
3258 self.title = "" # for type checker
3259 super().__init__(*args, **kwargs)
3261 # noinspection PyUnusedLocal
3262 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
3263 _ = self.gettext
3264 self.title = _("View as")
3267class ChooseTrackerSchema(CSRFSchema):
3268 """
3269 Schema to select a tracker or CTV.
3270 """
3272 which_idnum = (
3273 MandatoryWhichIdNumSelector()
3274 ) # must match ViewParam.WHICH_IDNUM # noqa
3275 idnum_value = (
3276 MandatoryIdNumValue()
3277 ) # must match ViewParam.IDNUM_VALUE # noqa
3278 start_datetime = (
3279 StartPendulumSelector()
3280 ) # must match ViewParam.START_DATETIME # noqa
3281 end_datetime = EndPendulumSelector() # must match ViewParam.END_DATETIME
3282 all_tasks = BooleanNode(default=True) # match ViewParam.ALL_TASKS
3283 tasks = MultiTaskSelector() # must match ViewParam.TASKS
3284 # tracker_tasks_only will be set via the binding
3285 via_index = ViaIndexSelector() # must match ViewParam.VIA_INDEX
3286 viewtype = (
3287 TaskTrackerOutputTypeSelector()
3288 ) # must match ViewParam.VIEWTYPE # noqa
3290 # noinspection PyUnusedLocal
3291 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
3292 _ = self.gettext
3293 all_tasks = get_child_node(self, "all_tasks")
3294 text = _("Use all eligible task types?")
3295 all_tasks.title = text
3296 all_tasks.label = text
3299class ChooseTrackerForm(InformativeNonceForm):
3300 """
3301 Form to select a tracker or CTV.
3302 """
3304 def __init__(
3305 self, request: "CamcopsRequest", as_ctv: bool, **kwargs
3306 ) -> None:
3307 """
3308 Args:
3309 as_ctv: CTV, not tracker?
3310 """
3311 _ = request.gettext
3312 schema = ChooseTrackerSchema().bind(
3313 request=request, tracker_tasks_only=not as_ctv
3314 )
3315 super().__init__(
3316 schema,
3317 buttons=[
3318 Button(
3319 name=FormAction.SUBMIT,
3320 title=(_("View CTV") if as_ctv else _("View tracker")),
3321 )
3322 ],
3323 **kwargs,
3324 )
3327# =============================================================================
3328# Reports, which use dynamically created forms
3329# =============================================================================
3332class ReportOutputTypeSelector(SchemaNode, RequestAwareMixin):
3333 """
3334 Node to select the output format for a report.
3335 """
3337 schema_type = String
3338 default = ViewArg.HTML
3339 missing = ViewArg.HTML
3341 def __init__(self, *args, **kwargs) -> None:
3342 self.title = "" # for type checker
3343 self.widget = None # type: Optional[Widget]
3344 self.validator = None # type: Optional[ValidatorType]
3345 super().__init__(*args, **kwargs)
3347 # noinspection PyUnusedLocal
3348 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
3349 _ = self.gettext
3350 self.title = _("View as")
3351 choices = self.get_choices()
3352 values, pv = get_values_and_permissible(choices)
3353 self.widget = RadioChoiceWidget(values=choices)
3354 self.validator = OneOf(pv)
3356 def get_choices(self) -> Tuple[Tuple[str, str]]:
3357 _ = self.gettext
3358 # noinspection PyTypeChecker
3359 return (
3360 (ViewArg.HTML, _("HTML")),
3361 (ViewArg.ODS, _("OpenOffice spreadsheet (ODS) file")),
3362 (ViewArg.TSV, _("TSV (tab-separated values)")),
3363 (ViewArg.XLSX, _("XLSX (Microsoft Excel) file")),
3364 )
3367class ReportParamSchema(CSRFSchema):
3368 """
3369 Schema to embed a report type (ID) and output format (view type).
3370 """
3372 viewtype = ReportOutputTypeSelector() # must match ViewParam.VIEWTYPE
3373 report_id = HiddenStringNode() # must match ViewParam.REPORT_ID
3374 # Specific forms may inherit from this.
3377class DateTimeFilteredReportParamSchema(ReportParamSchema):
3378 start_datetime = StartPendulumSelector()
3379 end_datetime = EndPendulumSelector()
3382class ReportParamForm(SimpleSubmitForm):
3383 """
3384 Form to view a specific report. Often derived from, to configure the report
3385 in more detail.
3386 """
3388 def __init__(
3389 self,
3390 request: "CamcopsRequest",
3391 schema_class: Type[ReportParamSchema],
3392 **kwargs,
3393 ) -> None:
3394 _ = request.gettext
3395 super().__init__(
3396 schema_class=schema_class,
3397 submit_title=_("View report"),
3398 request=request,
3399 **kwargs,
3400 )
3403# =============================================================================
3404# View DDL
3405# =============================================================================
3408def get_sql_dialect_choices(
3409 request: "CamcopsRequest",
3410) -> List[Tuple[str, str]]:
3411 _ = request.gettext
3412 return [
3413 # https://docs.sqlalchemy.org/en/latest/dialects/
3414 (SqlaDialectName.MYSQL, "MySQL"),
3415 (SqlaDialectName.MSSQL, "Microsoft SQL Server"),
3416 (SqlaDialectName.ORACLE, "Oracle" + _("[WILL NOT WORK]")),
3417 # ... Oracle doesn't work; SQLAlchemy enforces the Oracle rule of a 30-
3418 # character limit for identifiers, only relaxed to 128 characters in
3419 # Oracle 12.2 (March 2017).
3420 (SqlaDialectName.FIREBIRD, "Firebird"),
3421 (SqlaDialectName.POSTGRES, "PostgreSQL"),
3422 (SqlaDialectName.SQLITE, "SQLite"),
3423 (SqlaDialectName.SYBASE, "Sybase"),
3424 ]
3427class DatabaseDialectSelector(SchemaNode, RequestAwareMixin):
3428 """
3429 Node to choice an SQL dialect (for viewing DDL).
3430 """
3432 schema_type = String
3433 default = SqlaDialectName.MYSQL
3434 missing = SqlaDialectName.MYSQL
3436 def __init__(self, *args, **kwargs) -> None:
3437 self.title = "" # for type checker
3438 self.widget = None # type: Optional[Widget]
3439 self.validator = None # type: Optional[ValidatorType]
3440 super().__init__(*args, **kwargs)
3442 # noinspection PyUnusedLocal
3443 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
3444 _ = self.gettext
3445 self.title = _("SQL dialect to use (not all may be valid)")
3446 choices = get_sql_dialect_choices(self.request)
3447 values, pv = get_values_and_permissible(choices)
3448 self.widget = RadioChoiceWidget(values=values)
3449 self.validator = OneOf(pv)
3452class ViewDdlSchema(CSRFSchema):
3453 """
3454 Schema to choose how to view DDL.
3455 """
3457 dialect = DatabaseDialectSelector() # must match ViewParam.DIALECT
3460class ViewDdlForm(SimpleSubmitForm):
3461 """
3462 Form to choose how to view DDL (and then view it).
3463 """
3465 def __init__(self, request: "CamcopsRequest", **kwargs) -> None:
3466 _ = request.gettext
3467 super().__init__(
3468 schema_class=ViewDdlSchema,
3469 submit_title=_("View DDL"),
3470 request=request,
3471 **kwargs,
3472 )
3475# =============================================================================
3476# Add/edit/delete users
3477# =============================================================================
3480class UserGroupPermissionsGroupAdminSchema(CSRFSchema):
3481 """
3482 Edit group-specific permissions for a user. For group administrators.
3483 """
3485 # Currently the defaults here will be ignored because we don't use this
3486 # schema to create new UserGroupMembership records. The record will already
3487 # exist by the time we see the forms that use this schema. So the database
3488 # defaults will be used instead.
3489 may_upload = BooleanNode(
3490 default=False
3491 ) # match ViewParam.MAY_UPLOAD and User attribute # noqa
3492 may_register_devices = BooleanNode(
3493 default=False
3494 ) # match ViewParam.MAY_REGISTER_DEVICES and User attribute # noqa
3495 may_use_webviewer = BooleanNode(
3496 default=False
3497 ) # match ViewParam.MAY_USE_WEBVIEWER and User attribute # noqa
3498 may_manage_patients = BooleanNode(
3499 default=False
3500 ) # match ViewParam.MAY_MANAGE_PATIENTS # noqa
3501 may_email_patients = BooleanNode(
3502 default=False
3503 ) # match ViewParam.MAY_EMAIL_PATIENTS # noqa
3504 view_all_patients_when_unfiltered = BooleanNode(
3505 default=False
3506 ) # match ViewParam.VIEW_ALL_PATIENTS_WHEN_UNFILTERED and User attribute # noqa
3507 may_dump_data = BooleanNode(
3508 default=False
3509 ) # match ViewParam.MAY_DUMP_DATA and User attribute # noqa
3510 may_run_reports = BooleanNode(
3511 default=False
3512 ) # match ViewParam.MAY_RUN_REPORTS and User attribute # noqa
3513 may_add_notes = BooleanNode(
3514 default=False
3515 ) # match ViewParam.MAY_ADD_NOTES and User attribute # noqa
3517 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
3518 _ = self.gettext
3519 may_upload = get_child_node(self, "may_upload")
3520 mu_text = _("Permitted to upload from a tablet/device")
3521 may_upload.title = mu_text
3522 may_upload.label = mu_text
3523 may_register_devices = get_child_node(self, "may_register_devices")
3524 mrd_text = _("Permitted to register tablet/client devices")
3525 may_register_devices.title = mrd_text
3526 may_register_devices.label = mrd_text
3527 may_use_webviewer = get_child_node(self, "may_use_webviewer")
3528 ml_text = _("May log in to web front end")
3529 may_use_webviewer.title = ml_text
3530 may_use_webviewer.label = ml_text
3531 may_manage_patients = get_child_node(self, "may_manage_patients")
3532 mmp_text = _("May add, edit or delete patients created on the server")
3533 may_manage_patients.title = mmp_text
3534 may_manage_patients.label = mmp_text
3535 may_email_patients = get_child_node(self, "may_email_patients")
3536 mep_text = _("May send emails to patients created on the server")
3537 may_email_patients.title = mep_text
3538 may_email_patients.label = mep_text
3539 view_all_patients_when_unfiltered = get_child_node(
3540 self, "view_all_patients_when_unfiltered"
3541 )
3542 vap_text = _(
3543 "May view (browse) records from all patients when no patient "
3544 "filter set"
3545 )
3546 view_all_patients_when_unfiltered.title = vap_text
3547 view_all_patients_when_unfiltered.label = vap_text
3548 may_dump_data = get_child_node(self, "may_dump_data")
3549 md_text = _("May perform bulk data dumps")
3550 may_dump_data.title = md_text
3551 may_dump_data.label = md_text
3552 may_run_reports = get_child_node(self, "may_run_reports")
3553 mrr_text = _("May run reports")
3554 may_run_reports.title = mrr_text
3555 may_run_reports.label = mrr_text
3556 may_add_notes = get_child_node(self, "may_add_notes")
3557 man_text = _("May add special notes to tasks")
3558 may_add_notes.title = man_text
3559 may_add_notes.label = man_text
3562class UserGroupPermissionsFullSchema(UserGroupPermissionsGroupAdminSchema):
3563 """
3564 Edit group-specific permissions for a user. For superusers; includes the
3565 option to make the user a groupadmin.
3566 """
3568 groupadmin = BooleanNode(
3569 default=False
3570 ) # match ViewParam.GROUPADMIN and User attribute
3572 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
3573 super().after_bind(node, kw)
3574 _ = self.gettext
3575 groupadmin = get_child_node(self, "groupadmin")
3576 text = _("User is a privileged group administrator for this group")
3577 groupadmin.title = text
3578 groupadmin.label = text
3581class EditUserGroupAdminSchema(CSRFSchema):
3582 """
3583 Schema to edit a user. Version for group administrators.
3584 """
3586 username = (
3587 UsernameNode()
3588 ) # name must match ViewParam.USERNAME and User attribute # noqa
3589 fullname = OptionalStringNode( # name must match ViewParam.FULLNAME and User attribute # noqa
3590 validator=Length(0, StringLengths.FULLNAME_MAX_LEN)
3591 )
3592 email = (
3593 OptionalEmailNode()
3594 ) # name must match ViewParam.EMAIL and User attribute # noqa
3595 must_change_password = (
3596 MustChangePasswordNode()
3597 ) # match ViewParam.MUST_CHANGE_PASSWORD and User attribute # noqa
3598 language = LanguageSelector() # must match ViewParam.LANGUAGE
3599 group_ids = AdministeredGroupsSequence() # must match ViewParam.GROUP_IDS
3601 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
3602 _ = self.gettext
3603 fullname = get_child_node(self, "fullname")
3604 fullname.title = _("Full name")
3605 email = get_child_node(self, "email")
3606 email.title = _("E-mail address")
3609class EditUserFullSchema(EditUserGroupAdminSchema):
3610 """
3611 Schema to edit a user. Version for superusers; can also make the user a
3612 superuser.
3613 """
3615 superuser = BooleanNode(
3616 default=False
3617 ) # match ViewParam.SUPERUSER and User attribute # noqa
3618 group_ids = AllGroupsSequence() # must match ViewParam.GROUP_IDS
3620 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
3621 _ = self.gettext
3622 superuser = get_child_node(self, "superuser")
3623 text = _("Superuser (CAUTION!)")
3624 superuser.title = text
3625 superuser.label = text
3628class EditUserFullForm(ApplyCancelForm):
3629 """
3630 Form to edit a user. Full version for superusers.
3631 """
3633 def __init__(self, request: "CamcopsRequest", **kwargs) -> None:
3634 super().__init__(
3635 schema_class=EditUserFullSchema, request=request, **kwargs
3636 )
3639class EditUserGroupAdminForm(ApplyCancelForm):
3640 """
3641 Form to edit a user. Version for group administrators.
3642 """
3644 def __init__(self, request: "CamcopsRequest", **kwargs) -> None:
3645 super().__init__(
3646 schema_class=EditUserGroupAdminSchema, request=request, **kwargs
3647 )
3650class EditUserGroupPermissionsFullForm(ApplyCancelForm):
3651 """
3652 Form to edit a user's permissions within a group. Version for superusers.
3653 """
3655 def __init__(self, request: "CamcopsRequest", **kwargs) -> None:
3656 super().__init__(
3657 schema_class=UserGroupPermissionsFullSchema,
3658 request=request,
3659 **kwargs,
3660 )
3663class EditUserGroupMembershipGroupAdminForm(ApplyCancelForm):
3664 """
3665 Form to edit a user's permissions within a group. Version for group
3666 administrators.
3667 """
3669 def __init__(self, request: "CamcopsRequest", **kwargs) -> None:
3670 super().__init__(
3671 schema_class=UserGroupPermissionsGroupAdminSchema,
3672 request=request,
3673 **kwargs,
3674 )
3677class AddUserSuperuserSchema(CSRFSchema):
3678 """
3679 Schema to add a user. Version for superusers.
3680 """
3682 username = (
3683 UsernameNode()
3684 ) # name must match ViewParam.USERNAME and User attribute # noqa
3685 new_password = NewPasswordNode() # name must match ViewParam.NEW_PASSWORD
3686 must_change_password = (
3687 MustChangePasswordNode()
3688 ) # match ViewParam.MUST_CHANGE_PASSWORD and User attribute # noqa
3689 group_ids = AllGroupsSequence() # must match ViewParam.GROUP_IDS
3692class AddUserGroupadminSchema(AddUserSuperuserSchema):
3693 """
3694 Schema to add a user. Version for group administrators.
3695 """
3697 group_ids = AdministeredGroupsSequence() # must match ViewParam.GROUP_IDS
3700class AddUserSuperuserForm(AddCancelForm):
3701 """
3702 Form to add a user. Version for superusers.
3703 """
3705 def __init__(self, request: "CamcopsRequest", **kwargs) -> None:
3706 super().__init__(
3707 schema_class=AddUserSuperuserSchema, request=request, **kwargs
3708 )
3711class AddUserGroupadminForm(AddCancelForm):
3712 """
3713 Form to add a user. Version for group administrators.
3714 """
3716 def __init__(self, request: "CamcopsRequest", **kwargs) -> None:
3717 super().__init__(
3718 schema_class=AddUserGroupadminSchema, request=request, **kwargs
3719 )
3722class SetUserUploadGroupSchema(CSRFSchema):
3723 """
3724 Schema to choose the group into which a user uploads.
3725 """
3727 upload_group_id = (
3728 OptionalGroupIdSelectorUserGroups()
3729 ) # must match ViewParam.UPLOAD_GROUP_ID # noqa
3731 # noinspection PyUnusedLocal
3732 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
3733 _ = self.gettext
3734 upload_group_id = get_child_node(self, "upload_group_id")
3735 upload_group_id.title = _("Group into which to upload data")
3736 upload_group_id.description = _(
3737 "Pick a group from those to which the user belongs"
3738 )
3741class SetUserUploadGroupForm(InformativeNonceForm):
3742 """
3743 Form to choose the group into which a user uploads.
3744 """
3746 def __init__(
3747 self, request: "CamcopsRequest", user: "User", **kwargs
3748 ) -> None:
3749 _ = request.gettext
3750 schema = SetUserUploadGroupSchema().bind(
3751 request=request, user=user
3752 ) # UNUSUAL
3753 super().__init__(
3754 schema,
3755 buttons=[
3756 Button(name=FormAction.SUBMIT, title=_("Set")),
3757 Button(name=FormAction.CANCEL, title=_("Cancel")),
3758 ],
3759 **kwargs,
3760 )
3763class DeleteUserSchema(HardWorkConfirmationSchema):
3764 """
3765 Schema to delete a user.
3766 """
3768 user_id = HiddenIntegerNode() # name must match ViewParam.USER_ID
3769 danger = TranslatableValidateDangerousOperationNode()
3772class DeleteUserForm(DeleteCancelForm):
3773 """
3774 Form to delete a user.
3775 """
3777 def __init__(self, request: "CamcopsRequest", **kwargs) -> None:
3778 super().__init__(
3779 schema_class=DeleteUserSchema, request=request, **kwargs
3780 )
3783# =============================================================================
3784# Add/edit/delete groups
3785# =============================================================================
3788class PolicyNode(MandatoryStringNode, RequestAwareMixin):
3789 """
3790 Node to capture a CamCOPS ID number policy, and make sure it is
3791 syntactically valid.
3792 """
3794 def validator(self, node: SchemaNode, value: Any) -> None:
3795 _ = self.gettext
3796 if not isinstance(value, str):
3797 # unlikely!
3798 raise Invalid(node, _("Not a string"))
3799 policy = TokenizedPolicy(value)
3800 if not policy.is_syntactically_valid():
3801 raise Invalid(node, _("Syntactically invalid policy"))
3802 if not policy.is_valid_for_idnums(self.request.valid_which_idnums):
3803 raise Invalid(
3804 node,
3805 _(
3806 "Invalid policy. Have you referred to non-existent ID "
3807 "numbers? Is the policy less restrictive than the "
3808 "tablet’s minimum ID policy?"
3809 )
3810 + f" [{TABLET_ID_POLICY_STR!r}]",
3811 )
3814class GroupNameNode(MandatoryStringNode, RequestAwareMixin):
3815 """
3816 Node to capture a CamCOPS group name, and check it's valid as a string.
3817 """
3819 def validator(self, node: SchemaNode, value: str) -> None:
3820 try:
3821 validate_group_name(value, self.request)
3822 except ValueError as e:
3823 raise Invalid(node, str(e))
3826class GroupIpUseWidget(Widget):
3827 basedir = os.path.join(TEMPLATE_DIR, "deform")
3828 readonlydir = os.path.join(basedir, "readonly")
3829 form = "group_ip_use.pt"
3830 template = os.path.join(basedir, form)
3831 readonly_template = os.path.join(readonlydir, form)
3833 def __init__(self, request: "CamcopsRequest", **kwargs: Any) -> None:
3834 super().__init__(**kwargs)
3835 self.request = request
3837 def serialize(
3838 self,
3839 field: "Field",
3840 cstruct: Union[Dict[str, Any], None, ColanderNullType],
3841 **kw: Any,
3842 ) -> Any:
3843 if cstruct in (None, null):
3844 cstruct = {}
3846 cstruct: Dict[str, Any] # For type checker
3848 for context in IpUse.CONTEXTS:
3849 value = cstruct.get(context, False)
3850 kw.setdefault(context, value)
3852 readonly = kw.get("readonly", self.readonly)
3853 template = readonly and self.readonly_template or self.template
3854 values = self.get_template_values(field, cstruct, kw)
3856 _ = self.request.gettext
3858 values.update(
3859 introduction=_(
3860 "These settings will be applied to the patient's device "
3861 "when operating in single user mode."
3862 ),
3863 reason=_(
3864 "The settings here influence whether CamCOPS will consider "
3865 "some third-party tasks “permitted” on your behalf, according "
3866 "to their published use criteria. They do <b>not</b> remove "
3867 "your responsibility to ensure that you use them in "
3868 "accordance with their own requirements."
3869 ),
3870 warning=_(
3871 "WARNING. Providing incorrect information here may lead to "
3872 "you VIOLATING copyright law, by using task for a purpose "
3873 "that is not permitted, and being subject to damages and/or "
3874 "prosecution."
3875 ),
3876 disclaimer=_(
3877 "The authors of CamCOPS cannot be held responsible or liable "
3878 "for any consequences of you misusing materials subject to "
3879 "copyright."
3880 ),
3881 preamble=_("In which contexts does this group operate?"),
3882 clinical_label=_("Clinical"),
3883 medical_device_warning=_(
3884 "WARNING: NOT FOR GENERAL CLINICAL USE; not a Medical Device; "
3885 "see Terms and Conditions"
3886 ),
3887 commercial_label=_("Commercial"),
3888 educational_label=_("Educational"),
3889 research_label=_("Research"),
3890 )
3892 return field.renderer(template, **values)
3894 def deserialize(
3895 self, field: "Field", pstruct: Union[Dict[str, Any], ColanderNullType]
3896 ) -> Dict[str, bool]:
3897 if pstruct is null:
3898 pstruct = {}
3900 pstruct: Dict[str, Any] # For type checker
3902 # It doesn't really matter what the pstruct values are. Only the
3903 # options that are ticked will be present as keys in pstruct
3904 return {k: k in pstruct for k in IpUse.CONTEXTS}
3907class IpUseType(SchemaType):
3908 # noinspection PyMethodMayBeStatic,PyUnusedLocal
3909 def deserialize(
3910 self,
3911 node: SchemaNode,
3912 cstruct: Union[Dict[str, Any], None, ColanderNullType],
3913 ) -> Optional[IpUse]:
3914 if cstruct in (None, null):
3915 return None
3917 cstruct: Dict[str, Any] # For type checker
3919 return IpUse(**cstruct)
3921 # noinspection PyMethodMayBeStatic,PyUnusedLocal
3922 def serialize(
3923 self, node: SchemaNode, ip_use: Union[IpUse, None, ColanderNullType]
3924 ) -> Union[Dict, ColanderNullType]:
3925 if ip_use in (null, None):
3926 return null
3928 return {
3929 context: getattr(ip_use, context) for context in IpUse.CONTEXTS
3930 }
3933class GroupIpUseNode(SchemaNode, RequestAwareMixin):
3934 schema_type = IpUseType
3936 # noinspection PyUnusedLocal,PyAttributeOutsideInit
3937 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
3938 self.widget = GroupIpUseWidget(self.request)
3941class EditGroupSchema(CSRFSchema):
3942 """
3943 Schema to edit a group.
3944 """
3946 group_id = HiddenIntegerNode() # must match ViewParam.GROUP_ID
3947 name = GroupNameNode() # must match ViewParam.NAME
3948 description = MandatoryStringNode( # must match ViewParam.DESCRIPTION
3949 validator=Length(
3950 StringLengths.GROUP_DESCRIPTION_MIN_LEN,
3951 StringLengths.GROUP_DESCRIPTION_MAX_LEN,
3952 )
3953 )
3954 ip_use = GroupIpUseNode()
3956 group_ids = AllOtherGroupsSequence() # must match ViewParam.GROUP_IDS
3957 upload_policy = PolicyNode() # must match ViewParam.UPLOAD_POLICY
3958 finalize_policy = PolicyNode() # must match ViewParam.FINALIZE_POLICY
3960 # noinspection PyUnusedLocal
3961 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
3962 _ = self.gettext
3963 name = get_child_node(self, "name")
3964 name.title = _("Group name")
3966 ip_use = get_child_node(self, "ip_use")
3967 ip_use.title = _("Group intellectual property settings")
3969 group_ids = get_child_node(self, "group_ids")
3970 group_ids.title = _("Other groups this group may see")
3971 upload_policy = get_child_node(self, "upload_policy")
3972 upload_policy.title = _("Upload policy")
3973 upload_policy.description = _(
3974 "Minimum required patient information to copy data to server"
3975 )
3976 finalize_policy = get_child_node(self, "finalize_policy")
3977 finalize_policy.title = _("Finalize policy")
3978 finalize_policy.description = _(
3979 "Minimum required patient information to clear data off "
3980 "source device"
3981 )
3983 def validator(self, node: SchemaNode, value: Any) -> None:
3984 request = self.bindings[Binding.REQUEST] # type: CamcopsRequest
3985 q = (
3986 CountStarSpecializedQuery(Group, session=request.dbsession)
3987 .filter(Group.id != value[ViewParam.GROUP_ID])
3988 .filter(Group.name == value[ViewParam.NAME])
3989 )
3990 if q.count_star() > 0:
3991 _ = request.gettext
3992 raise Invalid(node, _("Name is used by another group!"))
3995class EditGroupForm(InformativeNonceForm):
3996 """
3997 Form to edit a group.
3998 """
4000 def __init__(
4001 self, request: "CamcopsRequest", group: Group, **kwargs
4002 ) -> None:
4003 _ = request.gettext
4004 schema = EditGroupSchema().bind(
4005 request=request, group=group
4006 ) # UNUSUAL BINDING
4007 super().__init__(
4008 schema,
4009 buttons=[
4010 Button(name=FormAction.SUBMIT, title=_("Apply")),
4011 Button(name=FormAction.CANCEL, title=_("Cancel")),
4012 ],
4013 **kwargs,
4014 )
4017class AddGroupSchema(CSRFSchema):
4018 """
4019 Schema to add a group.
4020 """
4022 name = GroupNameNode() # name must match ViewParam.NAME
4024 # noinspection PyUnusedLocal
4025 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
4026 _ = self.gettext
4027 name = get_child_node(self, "name")
4028 name.title = _("Group name")
4030 def validator(self, node: SchemaNode, value: Any) -> None:
4031 request = self.bindings[Binding.REQUEST] # type: CamcopsRequest
4032 q = CountStarSpecializedQuery(Group, session=request.dbsession).filter(
4033 Group.name == value[ViewParam.NAME]
4034 )
4035 if q.count_star() > 0:
4036 _ = request.gettext
4037 raise Invalid(node, _("Name is used by another group!"))
4040class AddGroupForm(AddCancelForm):
4041 """
4042 Form to add a group.
4043 """
4045 def __init__(self, request: "CamcopsRequest", **kwargs) -> None:
4046 super().__init__(
4047 schema_class=AddGroupSchema, request=request, **kwargs
4048 )
4051class DeleteGroupSchema(HardWorkConfirmationSchema):
4052 """
4053 Schema to delete a group.
4054 """
4056 group_id = HiddenIntegerNode() # name must match ViewParam.GROUP_ID
4057 danger = TranslatableValidateDangerousOperationNode()
4060class DeleteGroupForm(DeleteCancelForm):
4061 """
4062 Form to delete a group.
4063 """
4065 def __init__(self, request: "CamcopsRequest", **kwargs) -> None:
4066 super().__init__(
4067 schema_class=DeleteGroupSchema, request=request, **kwargs
4068 )
4071# =============================================================================
4072# Offer research dumps
4073# =============================================================================
4076class DumpTypeSelector(SchemaNode, RequestAwareMixin):
4077 """
4078 Node to select the filtering method for a data dump.
4079 """
4081 schema_type = String
4082 default = ViewArg.EVERYTHING
4083 missing = ViewArg.EVERYTHING
4085 def __init__(self, *args, **kwargs) -> None:
4086 self.title = "" # for type checker
4087 self.widget = None # type: Optional[Widget]
4088 self.validator = None # type: Optional[ValidatorType]
4089 super().__init__(*args, **kwargs)
4091 # noinspection PyUnusedLocal
4092 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
4093 _ = self.gettext
4094 self.title = _("Dump method")
4095 choices = (
4096 (ViewArg.EVERYTHING, _("Everything")),
4097 (ViewArg.USE_SESSION_FILTER, _("Use the session filter settings")),
4098 (
4099 ViewArg.SPECIFIC_TASKS_GROUPS,
4100 _("Specify tasks/groups manually (see below)"),
4101 ),
4102 )
4103 self.widget = RadioChoiceWidget(values=choices)
4104 self.validator = OneOf(list(x[0] for x in choices))
4107class SpreadsheetFormatSelector(SchemaNode, RequestAwareMixin):
4108 """
4109 Node to select a way of downloading an SQLite database.
4110 """
4112 schema_type = String
4113 default = ViewArg.XLSX
4114 missing = ViewArg.XLSX
4116 def __init__(self, *args, **kwargs) -> None:
4117 self.title = "" # for type checker
4118 self.widget = None # type: Optional[Widget]
4119 self.validator = None # type: Optional[ValidatorType]
4120 super().__init__(*args, **kwargs)
4122 # noinspection PyUnusedLocal
4123 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
4124 _ = self.gettext
4125 self.title = _("Spreadsheet format")
4126 choices = (
4127 (ViewArg.R, _("R script")),
4128 (ViewArg.ODS, _("OpenOffice spreadsheet (ODS) file")),
4129 (ViewArg.XLSX, _("XLSX (Microsoft Excel) file")),
4130 (
4131 ViewArg.TSV_ZIP,
4132 _("ZIP file of tab-separated value (TSV) files"),
4133 ),
4134 )
4135 values, pv = get_values_and_permissible(choices)
4136 self.widget = RadioChoiceWidget(values=values)
4137 self.validator = OneOf(pv)
4140class DeliveryModeNode(SchemaNode, RequestAwareMixin):
4141 """
4142 Mode of delivery of data downloads.
4143 """
4145 schema_type = String
4146 default = ViewArg.EMAIL
4147 missing = ViewArg.EMAIL
4149 def __init__(self, *args, **kwargs) -> None:
4150 self.title = "" # for type checker
4151 self.widget = None # type: Optional[Widget]
4152 super().__init__(*args, **kwargs)
4154 # noinspection PyUnusedLocal
4155 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
4156 _ = self.gettext
4157 self.title = _("Delivery")
4158 choices = (
4159 (ViewArg.IMMEDIATELY, _("Serve immediately")),
4160 (ViewArg.EMAIL, _("E-mail me")),
4161 (ViewArg.DOWNLOAD, _("Create a file for me to download")),
4162 )
4163 values, pv = get_values_and_permissible(choices)
4164 self.widget = RadioChoiceWidget(values=values)
4166 # noinspection PyUnusedLocal
4167 def validator(self, node: SchemaNode, value: Any) -> None:
4168 request = self.bindings[Binding.REQUEST] # type: CamcopsRequest
4169 _ = request.gettext
4170 if value == ViewArg.IMMEDIATELY:
4171 if not request.config.permit_immediate_downloads:
4172 raise Invalid(
4173 self,
4174 _("Disabled by the system administrator")
4175 + f" [{ConfigParamSite.PERMIT_IMMEDIATE_DOWNLOADS}]",
4176 )
4177 elif value == ViewArg.EMAIL:
4178 if not request.user.email:
4179 raise Invalid(
4180 self, _("Your user does not have an email address")
4181 )
4182 elif value == ViewArg.DOWNLOAD:
4183 if not request.user_download_dir:
4184 raise Invalid(
4185 self,
4186 _("User downloads not configured by administrator")
4187 + f" [{ConfigParamSite.USER_DOWNLOAD_DIR}, "
4188 f"{ConfigParamSite.USER_DOWNLOAD_MAX_SPACE_MB}]",
4189 )
4190 else:
4191 raise Invalid(self, _("Bad value"))
4194class SqliteSelector(SchemaNode, RequestAwareMixin):
4195 """
4196 Node to select a way of downloading an SQLite database.
4197 """
4199 schema_type = String
4200 default = ViewArg.SQLITE
4201 missing = ViewArg.SQLITE
4203 def __init__(self, *args, **kwargs) -> None:
4204 self.title = "" # for type checker
4205 self.widget = None # type: Optional[Widget]
4206 self.validator = None # type: Optional[ValidatorType]
4207 super().__init__(*args, **kwargs)
4209 # noinspection PyUnusedLocal
4210 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
4211 _ = self.gettext
4212 self.title = _("Database download method")
4213 choices = (
4214 # https://docs.sqlalchemy.org/en/latest/dialects/
4215 (ViewArg.SQLITE, _("Binary SQLite database")),
4216 (ViewArg.SQL, _("SQL text to create SQLite database")),
4217 )
4218 values, pv = get_values_and_permissible(choices)
4219 self.widget = RadioChoiceWidget(values=values)
4220 self.validator = OneOf(pv)
4223class SimplifiedSpreadsheetsNode(SchemaNode, RequestAwareMixin):
4224 """
4225 Boolean node: simplify basic dump spreadsheets?
4226 """
4228 schema_type = Boolean
4229 default = True
4230 missing = True
4232 def __init__(self, *args, **kwargs) -> None:
4233 self.title = "" # for type checker
4234 self.label = "" # for type checker
4235 super().__init__(*args, **kwargs)
4237 # noinspection PyUnusedLocal
4238 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
4239 _ = self.gettext
4240 self.title = _("Simplify spreadsheets?")
4241 self.label = _("Remove non-essential details?")
4244class SortTsvByHeadingsNode(SchemaNode, RequestAwareMixin):
4245 """
4246 Boolean node: sort TSV files by column name?
4247 """
4249 schema_type = Boolean
4250 default = False
4251 missing = False
4253 def __init__(self, *args, **kwargs) -> None:
4254 self.title = "" # for type checker
4255 self.label = "" # for type checker
4256 super().__init__(*args, **kwargs)
4258 # noinspection PyUnusedLocal
4259 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
4260 _ = self.gettext
4261 self.title = _("Sort columns?")
4262 self.label = _("Sort by heading (column) names within spreadsheets?")
4265class IncludeSchemaNode(SchemaNode, RequestAwareMixin):
4266 """
4267 Boolean node: should INFORMATION_SCHEMA.COLUMNS be included (for
4268 downloads)?
4270 False by default -- adds about 350 kb to an ODS download, for example.
4271 """
4273 schema_type = Boolean
4274 default = False
4275 missing = False
4277 def __init__(self, *args, **kwargs) -> None:
4278 self.title = "" # for type checker
4279 self.label = "" # for type checker
4280 super().__init__(*args, **kwargs)
4282 # noinspection PyUnusedLocal
4283 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
4284 _ = self.gettext
4285 self.title = _("Include column information?")
4286 self.label = _(
4287 "Include details of all columns in the source database?"
4288 )
4291class IncludeBlobsNode(SchemaNode, RequestAwareMixin):
4292 """
4293 Boolean node: should BLOBs be included (for downloads)?
4294 """
4296 schema_type = Boolean
4297 default = False
4298 missing = False
4300 def __init__(self, *args, **kwargs) -> None:
4301 self.title = "" # for type checker
4302 self.label = "" # for type checker
4303 super().__init__(*args, **kwargs)
4305 # noinspection PyUnusedLocal
4306 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
4307 _ = self.gettext
4308 self.title = _("Include BLOBs?")
4309 self.label = _(
4310 "Include binary large objects (BLOBs)? WARNING: may be large"
4311 )
4314class PatientIdPerRowNode(SchemaNode, RequestAwareMixin):
4315 """
4316 Boolean node: should patient ID information, and other cross-referencing
4317 denormalized info, be included per row?
4319 See :ref:`DB_PATIENT_ID_PER_ROW <DB_PATIENT_ID_PER_ROW>`.
4320 """
4322 schema_type = Boolean
4323 default = True
4324 missing = True
4326 def __init__(self, *args, **kwargs) -> None:
4327 self.title = "" # for type checker
4328 self.label = "" # for type checker
4329 super().__init__(*args, **kwargs)
4331 # noinspection PyUnusedLocal
4332 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
4333 _ = self.gettext
4334 self.title = _("Patient ID per row?")
4335 self.label = _(
4336 "Include patient ID numbers and task cross-referencing "
4337 "(denormalized) information per row?"
4338 )
4341class OfferDumpManualSchema(Schema, RequestAwareMixin):
4342 """
4343 Schema to offer the "manual" settings for a data dump (groups, task types).
4344 """
4346 group_ids = AllowedGroupsSequence() # must match ViewParam.GROUP_IDS
4347 tasks = MultiTaskSelector() # must match ViewParam.TASKS
4349 widget = MappingWidget(template="mapping_accordion", open=False)
4351 def __init__(self, *args, **kwargs) -> None:
4352 self.title = "" # for type checker
4353 super().__init__(*args, **kwargs)
4355 # noinspection PyUnusedLocal
4356 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
4357 _ = self.gettext
4358 self.title = _("Manual settings")
4361class OfferBasicDumpSchema(CSRFSchema):
4362 """
4363 Schema to choose the settings for a basic (TSV/ZIP) data dump.
4364 """
4366 dump_method = DumpTypeSelector() # must match ViewParam.DUMP_METHOD
4367 simplified = (
4368 SimplifiedSpreadsheetsNode()
4369 ) # must match ViewParam.SIMPLIFIED
4370 sort = SortTsvByHeadingsNode() # must match ViewParam.SORT
4371 include_schema = IncludeSchemaNode() # must match ViewParam.INCLUDE_SCHEMA
4372 manual = OfferDumpManualSchema() # must match ViewParam.MANUAL
4373 viewtype = (
4374 SpreadsheetFormatSelector()
4375 ) # must match ViewParam.VIEWTYPE # noqa
4376 delivery_mode = DeliveryModeNode() # must match ViewParam.DELIVERY_MODE
4379class OfferBasicDumpForm(SimpleSubmitForm):
4380 """
4381 Form to offer a basic (TSV/ZIP) data dump.
4382 """
4384 def __init__(self, request: "CamcopsRequest", **kwargs) -> None:
4385 _ = request.gettext
4386 super().__init__(
4387 schema_class=OfferBasicDumpSchema,
4388 submit_title=_("Dump"),
4389 request=request,
4390 **kwargs,
4391 )
4394class OfferSqlDumpSchema(CSRFSchema):
4395 """
4396 Schema to choose the settings for an SQL data dump.
4397 """
4399 dump_method = DumpTypeSelector() # must match ViewParam.DUMP_METHOD
4400 sqlite_method = SqliteSelector() # must match ViewParam.SQLITE_METHOD
4401 include_schema = IncludeSchemaNode() # must match ViewParam.INCLUDE_SCHEMA
4402 include_blobs = IncludeBlobsNode() # must match ViewParam.INCLUDE_BLOBS
4403 patient_id_per_row = (
4404 PatientIdPerRowNode()
4405 ) # must match ViewParam.PATIENT_ID_PER_ROW # noqa
4406 manual = OfferDumpManualSchema() # must match ViewParam.MANUAL
4407 delivery_mode = DeliveryModeNode() # must match ViewParam.DELIVERY_MODE
4410class OfferSqlDumpForm(SimpleSubmitForm):
4411 """
4412 Form to choose the settings for an SQL data dump.
4413 """
4415 def __init__(self, request: "CamcopsRequest", **kwargs) -> None:
4416 _ = request.gettext
4417 super().__init__(
4418 schema_class=OfferSqlDumpSchema,
4419 submit_title=_("Dump"),
4420 request=request,
4421 **kwargs,
4422 )
4425# =============================================================================
4426# Edit server settings
4427# =============================================================================
4430class EditServerSettingsSchema(CSRFSchema):
4431 """
4432 Schema to edit the global settings for the server.
4433 """
4435 database_title = SchemaNode( # must match ViewParam.DATABASE_TITLE
4436 String(),
4437 validator=Length(
4438 StringLengths.DATABASE_TITLE_MIN_LEN,
4439 StringLengths.DATABASE_TITLE_MAX_LEN,
4440 ),
4441 )
4443 # noinspection PyUnusedLocal
4444 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
4445 _ = self.gettext
4446 database_title = get_child_node(self, "database_title")
4447 database_title.title = _("Database friendly title")
4450class EditServerSettingsForm(ApplyCancelForm):
4451 """
4452 Form to edit the global settings for the server.
4453 """
4455 def __init__(self, request: "CamcopsRequest", **kwargs) -> None:
4456 super().__init__(
4457 schema_class=EditServerSettingsSchema, request=request, **kwargs
4458 )
4461# =============================================================================
4462# Edit ID number definitions
4463# =============================================================================
4466class IdDefinitionDescriptionNode(SchemaNode, RequestAwareMixin):
4467 """
4468 Node to capture the description of an ID number type.
4469 """
4471 schema_type = String
4472 validator = Length(1, StringLengths.ID_DESCRIPTOR_MAX_LEN)
4474 def __init__(self, *args, **kwargs) -> None:
4475 self.title = "" # for type checker
4476 super().__init__(*args, **kwargs)
4478 # noinspection PyUnusedLocal
4479 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
4480 _ = self.gettext
4481 self.title = _("Full description (e.g. “NHS number”)")
4484class IdDefinitionShortDescriptionNode(SchemaNode, RequestAwareMixin):
4485 """
4486 Node to capture the short description of an ID number type.
4487 """
4489 schema_type = String
4490 validator = Length(1, StringLengths.ID_DESCRIPTOR_MAX_LEN)
4492 def __init__(self, *args, **kwargs) -> None:
4493 self.title = "" # for type checker
4494 self.description = "" # for type checker
4495 super().__init__(*args, **kwargs)
4497 # noinspection PyUnusedLocal
4498 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
4499 _ = self.gettext
4500 self.title = _("Short description (e.g. “NHS#”)")
4501 self.description = _("Try to keep it very short!")
4504class IdValidationMethodNode(OptionalStringNode, RequestAwareMixin):
4505 """
4506 Node to choose a build-in ID number validation method.
4507 """
4509 widget = SelectWidget(values=ID_NUM_VALIDATION_METHOD_CHOICES)
4510 validator = OneOf(list(x[0] for x in ID_NUM_VALIDATION_METHOD_CHOICES))
4512 def __init__(self, *args, **kwargs) -> None:
4513 self.title = "" # for type checker
4514 self.description = "" # for type checker
4515 super().__init__(*args, **kwargs)
4517 # noinspection PyUnusedLocal
4518 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
4519 _ = self.gettext
4520 self.title = _("Validation method")
4521 self.description = _("Built-in CamCOPS ID number validation method")
4524class Hl7AssigningAuthorityNode(OptionalStringNode, RequestAwareMixin):
4525 """
4526 Optional node to capture the name of an HL7 Assigning Authority.
4527 """
4529 def __init__(self, *args, **kwargs) -> None:
4530 self.title = "" # for type checker
4531 self.description = "" # for type checker
4532 super().__init__(*args, **kwargs)
4534 # noinspection PyUnusedLocal
4535 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
4536 _ = self.gettext
4537 self.title = _("HL7 Assigning Authority")
4538 self.description = _(
4539 "For HL7 messaging: "
4540 "HL7 Assigning Authority for ID number (unique name of the "
4541 "system/organization/agency/department that creates the data)."
4542 )
4544 # noinspection PyMethodMayBeStatic
4545 def validator(self, node: SchemaNode, value: str) -> None:
4546 try:
4547 validate_hl7_aa(value, self.request)
4548 except ValueError as e:
4549 raise Invalid(node, str(e))
4552class Hl7IdTypeNode(OptionalStringNode, RequestAwareMixin):
4553 """
4554 Optional node to capture the name of an HL7 Identifier Type code.
4555 """
4557 def __init__(self, *args, **kwargs) -> None:
4558 self.title = "" # for type checker
4559 self.description = "" # for type checker
4560 super().__init__(*args, **kwargs)
4562 # noinspection PyUnusedLocal
4563 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
4564 _ = self.gettext
4565 self.title = _("HL7 Identifier Type")
4566 self.description = _(
4567 "For HL7 messaging: "
4568 "HL7 Identifier Type code: ‘a code corresponding to the type "
4569 "of identifier. In some cases, this code may be used as a "
4570 "qualifier to the “Assigning Authority” component.’"
4571 )
4573 # noinspection PyMethodMayBeStatic
4574 def validator(self, node: SchemaNode, value: str) -> None:
4575 try:
4576 validate_hl7_id_type(value, self.request)
4577 except ValueError as e:
4578 raise Invalid(node, str(e))
4581class FHIRIdSystemUrlNode(OptionalStringNode, RequestAwareMixin):
4582 """
4583 Optional node to capture the URL for a FHIR ID system:
4585 - https://www.hl7.org/fhir/datatypes.html#Identifier
4586 - https://www.hl7.org/fhir/datatypes-definitions.html#Identifier.system
4587 """
4589 validator = url
4591 def __init__(self, *args, **kwargs) -> None:
4592 self.title = "" # for type checker
4593 self.description = "" # for type checker
4594 super().__init__(*args, **kwargs)
4596 # noinspection PyUnusedLocal
4597 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
4598 _ = self.gettext
4599 self.title = _("FHIR ID system")
4600 self.description = _("For FHIR exports: URL defining the ID system.")
4603class EditIdDefinitionSchema(CSRFSchema):
4604 """
4605 Schema to edit an ID number definition.
4606 """
4608 which_idnum = HiddenIntegerNode() # must match ViewParam.WHICH_IDNUM
4609 description = (
4610 IdDefinitionDescriptionNode()
4611 ) # must match ViewParam.DESCRIPTION # noqa
4612 short_description = (
4613 IdDefinitionShortDescriptionNode()
4614 ) # must match ViewParam.SHORT_DESCRIPTION # noqa
4615 validation_method = (
4616 IdValidationMethodNode()
4617 ) # must match ViewParam.VALIDATION_METHOD # noqa
4618 hl7_id_type = Hl7IdTypeNode() # must match ViewParam.HL7_ID_TYPE
4619 hl7_assigning_authority = (
4620 Hl7AssigningAuthorityNode()
4621 ) # must match ViewParam.HL7_ASSIGNING_AUTHORITY # noqa
4622 fhir_id_system = (
4623 FHIRIdSystemUrlNode()
4624 ) # must match ViewParam.FHIR_ID_SYSTEM # noqa
4626 def validator(self, node: SchemaNode, value: Any) -> None:
4627 request = self.bindings[Binding.REQUEST] # type: CamcopsRequest
4628 _ = request.gettext
4629 qd = (
4630 CountStarSpecializedQuery(
4631 IdNumDefinition, session=request.dbsession
4632 )
4633 .filter(
4634 IdNumDefinition.which_idnum != value[ViewParam.WHICH_IDNUM]
4635 )
4636 .filter(
4637 IdNumDefinition.description == value[ViewParam.DESCRIPTION]
4638 )
4639 )
4640 if qd.count_star() > 0:
4641 raise Invalid(node, _("Description is used by another ID number!"))
4642 qs = (
4643 CountStarSpecializedQuery(
4644 IdNumDefinition, session=request.dbsession
4645 )
4646 .filter(
4647 IdNumDefinition.which_idnum != value[ViewParam.WHICH_IDNUM]
4648 )
4649 .filter(
4650 IdNumDefinition.short_description
4651 == value[ViewParam.SHORT_DESCRIPTION]
4652 )
4653 )
4654 if qs.count_star() > 0:
4655 raise Invalid(
4656 node, _("Short description is used by another ID number!")
4657 )
4660class EditIdDefinitionForm(ApplyCancelForm):
4661 """
4662 Form to edit an ID number definition.
4663 """
4665 def __init__(self, request: "CamcopsRequest", **kwargs) -> None:
4666 super().__init__(
4667 schema_class=EditIdDefinitionSchema, request=request, **kwargs
4668 )
4671class AddIdDefinitionSchema(CSRFSchema):
4672 """
4673 Schema to add an ID number definition.
4674 """
4676 which_idnum = SchemaNode( # must match ViewParam.WHICH_IDNUM
4677 Integer(), validator=Range(min=1)
4678 )
4679 description = (
4680 IdDefinitionDescriptionNode()
4681 ) # must match ViewParam.DESCRIPTION # noqa
4682 short_description = (
4683 IdDefinitionShortDescriptionNode()
4684 ) # must match ViewParam.SHORT_DESCRIPTION # noqa
4685 validation_method = (
4686 IdValidationMethodNode()
4687 ) # must match ViewParam.VALIDATION_METHOD # noqa
4689 # noinspection PyUnusedLocal
4690 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
4691 _ = self.gettext
4692 which_idnum = get_child_node(self, "which_idnum")
4693 which_idnum.title = _("Which ID number?")
4694 which_idnum.description = (
4695 "Specify the integer to represent the type of this ID "
4696 "number class (e.g. consecutive numbering from 1)"
4697 )
4699 def validator(self, node: SchemaNode, value: Any) -> None:
4700 request = self.bindings[Binding.REQUEST] # type: CamcopsRequest
4701 _ = request.gettext
4702 qw = CountStarSpecializedQuery(
4703 IdNumDefinition, session=request.dbsession
4704 ).filter(IdNumDefinition.which_idnum == value[ViewParam.WHICH_IDNUM])
4705 if qw.count_star() > 0:
4706 raise Invalid(node, _("ID# clashes with another ID number!"))
4707 qd = CountStarSpecializedQuery(
4708 IdNumDefinition, session=request.dbsession
4709 ).filter(IdNumDefinition.description == value[ViewParam.DESCRIPTION])
4710 if qd.count_star() > 0:
4711 raise Invalid(node, _("Description is used by another ID number!"))
4712 qs = CountStarSpecializedQuery(
4713 IdNumDefinition, session=request.dbsession
4714 ).filter(
4715 IdNumDefinition.short_description
4716 == value[ViewParam.SHORT_DESCRIPTION]
4717 )
4718 if qs.count_star() > 0:
4719 raise Invalid(
4720 node, _("Short description is used by another ID number!")
4721 )
4724class AddIdDefinitionForm(AddCancelForm):
4725 """
4726 Form to add an ID number definition.
4727 """
4729 def __init__(self, request: "CamcopsRequest", **kwargs) -> None:
4730 super().__init__(
4731 schema_class=AddIdDefinitionSchema, request=request, **kwargs
4732 )
4735class DeleteIdDefinitionSchema(HardWorkConfirmationSchema):
4736 """
4737 Schema to delete an ID number definition.
4738 """
4740 which_idnum = HiddenIntegerNode() # name must match ViewParam.WHICH_IDNUM
4741 danger = TranslatableValidateDangerousOperationNode()
4744class DeleteIdDefinitionForm(DangerousForm):
4745 """
4746 Form to add an ID number definition.
4747 """
4749 def __init__(self, request: "CamcopsRequest", **kwargs) -> None:
4750 _ = request.gettext
4751 super().__init__(
4752 schema_class=DeleteIdDefinitionSchema,
4753 submit_action=FormAction.DELETE,
4754 submit_title=_("Delete"),
4755 request=request,
4756 **kwargs,
4757 )
4760# =============================================================================
4761# Special notes
4762# =============================================================================
4765class AddSpecialNoteSchema(CSRFSchema):
4766 """
4767 Schema to add a special note to a task.
4768 """
4770 table_name = HiddenStringNode() # must match ViewParam.TABLENAME
4771 server_pk = HiddenIntegerNode() # must match ViewParam.SERVER_PK
4772 note = MandatoryStringNode( # must match ViewParam.NOTE
4773 widget=TextAreaWidget(rows=20, cols=80)
4774 )
4775 danger = TranslatableValidateDangerousOperationNode()
4778class AddSpecialNoteForm(DangerousForm):
4779 """
4780 Form to add a special note to a task.
4781 """
4783 def __init__(self, request: "CamcopsRequest", **kwargs) -> None:
4784 _ = request.gettext
4785 super().__init__(
4786 schema_class=AddSpecialNoteSchema,
4787 submit_action=FormAction.SUBMIT,
4788 submit_title=_("Add"),
4789 request=request,
4790 **kwargs,
4791 )
4794class DeleteSpecialNoteSchema(CSRFSchema):
4795 """
4796 Schema to add a special note to a task.
4797 """
4799 note_id = HiddenIntegerNode() # must match ViewParam.NOTE_ID
4800 danger = TranslatableValidateDangerousOperationNode()
4803class DeleteSpecialNoteForm(DangerousForm):
4804 """
4805 Form to delete (hide) a special note.
4806 """
4808 def __init__(self, request: "CamcopsRequest", **kwargs) -> None:
4809 _ = request.gettext
4810 super().__init__(
4811 schema_class=DeleteSpecialNoteSchema,
4812 submit_action=FormAction.SUBMIT,
4813 submit_title=_("Delete"),
4814 request=request,
4815 **kwargs,
4816 )
4819# =============================================================================
4820# The unusual data manipulation operations
4821# =============================================================================
4824class EraseTaskSchema(HardWorkConfirmationSchema):
4825 """
4826 Schema to erase a task.
4827 """
4829 table_name = HiddenStringNode() # must match ViewParam.TABLENAME
4830 server_pk = HiddenIntegerNode() # must match ViewParam.SERVER_PK
4831 danger = TranslatableValidateDangerousOperationNode()
4834class EraseTaskForm(DangerousForm):
4835 """
4836 Form to erase a task.
4837 """
4839 def __init__(self, request: "CamcopsRequest", **kwargs) -> None:
4840 _ = request.gettext
4841 super().__init__(
4842 schema_class=EraseTaskSchema,
4843 submit_action=FormAction.DELETE,
4844 submit_title=_("Erase"),
4845 request=request,
4846 **kwargs,
4847 )
4850class DeletePatientChooseSchema(CSRFSchema):
4851 """
4852 Schema to delete a patient.
4853 """
4855 which_idnum = (
4856 MandatoryWhichIdNumSelector()
4857 ) # must match ViewParam.WHICH_IDNUM # noqa
4858 idnum_value = MandatoryIdNumValue() # must match ViewParam.IDNUM_VALUE
4859 group_id = (
4860 MandatoryGroupIdSelectorAdministeredGroups()
4861 ) # must match ViewParam.GROUP_ID # noqa
4862 danger = TranslatableValidateDangerousOperationNode()
4865class DeletePatientChooseForm(DangerousForm):
4866 """
4867 Form to delete a patient.
4868 """
4870 def __init__(self, request: "CamcopsRequest", **kwargs) -> None:
4871 _ = request.gettext
4872 super().__init__(
4873 schema_class=DeletePatientChooseSchema,
4874 submit_action=FormAction.SUBMIT,
4875 submit_title=_("Show tasks that will be deleted"),
4876 request=request,
4877 **kwargs,
4878 )
4881class DeletePatientConfirmSchema(HardWorkConfirmationSchema):
4882 """
4883 Schema to confirm deletion of a patient.
4884 """
4886 which_idnum = HiddenIntegerNode() # must match ViewParam.WHICH_IDNUM
4887 idnum_value = HiddenIntegerNode() # must match ViewParam.IDNUM_VALUE
4888 group_id = HiddenIntegerNode() # must match ViewParam.GROUP_ID
4889 danger = TranslatableValidateDangerousOperationNode()
4892class DeletePatientConfirmForm(DangerousForm):
4893 """
4894 Form to confirm deletion of a patient.
4895 """
4897 def __init__(self, request: "CamcopsRequest", **kwargs) -> None:
4898 _ = request.gettext
4899 super().__init__(
4900 schema_class=DeletePatientConfirmSchema,
4901 submit_action=FormAction.DELETE,
4902 submit_title=_("Delete"),
4903 request=request,
4904 **kwargs,
4905 )
4908class DeleteServerCreatedPatientSchema(HardWorkConfirmationSchema):
4909 """
4910 Schema to delete a patient created on the server.
4911 """
4913 # name must match ViewParam.SERVER_PK
4914 server_pk = HiddenIntegerNode()
4915 danger = TranslatableValidateDangerousOperationNode()
4918class DeleteServerCreatedPatientForm(DeleteCancelForm):
4919 """
4920 Form to delete a patient created on the server
4921 """
4923 def __init__(self, request: "CamcopsRequest", **kwargs: Any) -> None:
4924 super().__init__(
4925 schema_class=DeleteServerCreatedPatientSchema,
4926 request=request,
4927 **kwargs,
4928 )
4931EDIT_PATIENT_SIMPLE_PARAMS = [
4932 ViewParam.FORENAME,
4933 ViewParam.SURNAME,
4934 ViewParam.DOB,
4935 ViewParam.SEX,
4936 ViewParam.ADDRESS,
4937 ViewParam.EMAIL,
4938 ViewParam.GP,
4939 ViewParam.OTHER,
4940]
4943class TaskScheduleSelector(SchemaNode, RequestAwareMixin):
4944 """
4945 Drop-down with all available task schedules
4946 """
4948 widget = SelectWidget()
4950 def __init__(self, *args: Any, **kwargs: Any) -> None:
4951 self.title = "" # for type checker
4952 self.name = "" # for type checker
4953 self.validator = None # type: Optional[ValidatorType]
4954 super().__init__(*args, **kwargs)
4956 # noinspection PyUnusedLocal
4957 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
4958 request = self.request
4959 _ = request.gettext
4960 self.title = _("Task schedule")
4961 values = [] # type: List[Tuple[Optional[int], str]]
4963 valid_group_ids = (
4964 request.user.ids_of_groups_user_may_manage_patients_in
4965 )
4966 task_schedules = (
4967 request.dbsession.query(TaskSchedule)
4968 .filter(TaskSchedule.group_id.in_(valid_group_ids))
4969 .order_by(TaskSchedule.name)
4970 )
4972 for task_schedule in task_schedules:
4973 values.append((task_schedule.id, task_schedule.name))
4974 values, pv = get_values_and_permissible(values, add_none=False)
4976 self.widget.values = values
4977 self.validator = OneOf(pv)
4979 @staticmethod
4980 def schema_type() -> SchemaType:
4981 return Integer()
4984class JsonType(SchemaType):
4985 """
4986 Schema type for JsonNode
4987 """
4989 # noinspection PyMethodMayBeStatic, PyUnusedLocal
4990 def deserialize(
4991 self, node: SchemaNode, cstruct: Union[str, ColanderNullType, None]
4992 ) -> Any:
4993 # is null when form is empty
4994 if cstruct in (null, None):
4995 return None
4997 cstruct: str
4999 try:
5000 # Validation happens on the widget class
5001 json_value = json.loads(cstruct)
5002 except json.JSONDecodeError:
5003 return None
5005 return json_value
5007 # noinspection PyMethodMayBeStatic,PyUnusedLocal
5008 def serialize(
5009 self, node: SchemaNode, appstruct: Union[Dict, None, ColanderNullType]
5010 ) -> Union[str, ColanderNullType]:
5011 # is null when form is empty (new record)
5012 # is None when populated from empty value in the database
5013 if appstruct in (null, None):
5014 return null
5016 # appstruct should be well formed here (it would already have failed
5017 # when reading from the database)
5018 return json.dumps(appstruct)
5021class JsonWidget(Widget):
5022 """
5023 Widget supporting jsoneditor https://github.com/josdejong/jsoneditor
5024 """
5026 basedir = os.path.join(TEMPLATE_DIR, "deform")
5027 readonlydir = os.path.join(basedir, "readonly")
5028 form = "json.pt"
5029 template = os.path.join(basedir, form)
5030 readonly_template = os.path.join(readonlydir, form)
5031 requirements = (("jsoneditor", None),)
5033 def __init__(self, request: "CamcopsRequest", **kwargs: Any) -> None:
5034 super().__init__(**kwargs)
5035 self.request = request
5037 def serialize(
5038 self, field: "Field", cstruct: Union[str, ColanderNullType], **kw: Any
5039 ) -> Any:
5040 if cstruct is null:
5041 cstruct = ""
5043 readonly = kw.get("readonly", self.readonly)
5044 template = readonly and self.readonly_template or self.template
5046 values = self.get_template_values(field, cstruct, kw)
5048 return field.renderer(template, **values)
5050 def deserialize(
5051 self, field: "Field", pstruct: Union[str, ColanderNullType]
5052 ) -> Union[str, ColanderNullType]:
5053 # is empty string when field is empty
5054 if pstruct in (null, ""):
5055 return null
5057 _ = self.request.gettext
5058 error_message = _("Please enter valid JSON or leave blank")
5060 pstruct: str
5062 try:
5063 json.loads(pstruct)
5064 except json.JSONDecodeError:
5065 raise Invalid(field, error_message, pstruct)
5067 return pstruct
5070class JsonSettingsNode(SchemaNode, RequestAwareMixin):
5071 """
5072 Note to edit raw JSON.
5073 """
5075 schema_type = JsonType
5076 missing = null
5078 # noinspection PyUnusedLocal,PyAttributeOutsideInit
5079 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
5080 _ = self.gettext
5081 self.widget = JsonWidget(self.request)
5082 self.title = _("Task-specific settings for this patient")
5083 self.description = _(
5084 "ADVANCED. Only applicable to tasks that are configurable on a "
5085 "per-patient basis. Format: JSON object, with settings keyed on "
5086 "task table name."
5087 )
5089 def validator(self, node: SchemaNode, value: Any) -> None:
5090 if value is not None:
5091 # will be None if JSON failed to validate
5092 if not isinstance(value, dict):
5093 _ = self.request.gettext
5094 error_message = _(
5095 "Please enter a valid JSON object (with settings keyed on "
5096 "task table name) or leave blank"
5097 )
5098 raise Invalid(node, error_message)
5101class TaskScheduleJsonSchema(Schema):
5102 """
5103 Schema for the advanced JSON parts of a patient-to-task-schedule mapping.
5104 """
5106 settings = JsonSettingsNode() # must match ViewParam.SETTINGS
5109class TaskScheduleNode(MappingSchema, RequestAwareMixin):
5110 """
5111 Node to edit settings for a patient-to-task-schedule mapping.
5112 """
5114 patient_task_schedule_id = (
5115 HiddenIntegerNode()
5116 ) # name must match ViewParam.PATIENT_TASK_SCHEDULE_ID
5117 schedule_id = TaskScheduleSelector() # must match ViewParam.SCHEDULE_ID
5118 start_datetime = (
5119 StartPendulumSelector()
5120 ) # must match ViewParam.START_DATETIME
5121 if DEFORM_ACCORDION_BUG:
5122 settings = JsonSettingsNode() # must match ViewParam.SETTINGS
5123 else:
5124 advanced = TaskScheduleJsonSchema( # must match ViewParam.ADVANCED
5125 widget=MappingWidget(template="mapping_accordion", open=False)
5126 )
5128 def __init__(self, *args: Any, **kwargs: Any) -> None:
5129 self.title = "" # for type checker
5130 super().__init__(*args, **kwargs)
5132 # noinspection PyUnusedLocal
5133 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
5134 _ = self.gettext
5135 self.title = _("Task schedule")
5136 start_datetime = get_child_node(self, "start_datetime")
5137 start_datetime.description = _(
5138 "Leave blank for the date the patient first downloads the schedule"
5139 )
5140 if not DEFORM_ACCORDION_BUG:
5141 advanced = get_child_node(self, "advanced")
5142 advanced.title = _("Advanced")
5145class TaskScheduleSequence(SequenceSchema, RequestAwareMixin):
5146 """
5147 Sequence for multiple patient-to-task-schedule mappings.
5148 """
5150 task_schedule_sequence = TaskScheduleNode()
5151 missing = drop
5153 def __init__(self, *args: Any, **kwargs: Any) -> None:
5154 self.title = "" # for type checker
5155 self.widget = None # type: Optional[Widget]
5156 super().__init__(*args, **kwargs)
5158 # noinspection PyUnusedLocal
5159 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
5160 _ = self.gettext
5161 self.title = _("Task Schedules")
5162 self.widget = TranslatableSequenceWidget(request=self.request)
5165class EditPatientSchema(CSRFSchema):
5166 """
5167 Schema to edit a patient.
5168 """
5170 server_pk = HiddenIntegerNode() # must match ViewParam.SERVER_PK
5171 forename = OptionalPatientNameNode() # must match ViewParam.FORENAME
5172 surname = OptionalPatientNameNode() # must match ViewParam.SURNAME
5173 dob = DateSelectorNode() # must match ViewParam.DOB
5174 sex = MandatorySexSelector() # must match ViewParam.SEX
5175 address = OptionalStringNode() # must match ViewParam.ADDRESS
5176 email = OptionalEmailNode() # must match ViewParam.EMAIL
5177 gp = OptionalStringNode() # must match ViewParam.GP
5178 other = OptionalStringNode() # must match ViewParam.OTHER
5179 id_references = (
5180 IdNumSequenceUniquePerWhichIdnum()
5181 ) # must match ViewParam.ID_REFERENCES # noqa
5183 # noinspection PyUnusedLocal
5184 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
5185 _ = self.gettext
5186 dob = get_child_node(self, "dob")
5187 dob.title = _("Date of birth")
5188 gp = get_child_node(self, "gp")
5189 gp.title = _("GP")
5191 def validator(self, node: SchemaNode, value: Any) -> None:
5192 request = self.bindings[Binding.REQUEST] # type: CamcopsRequest
5193 dbsession = request.dbsession
5194 group_id = value[ViewParam.GROUP_ID]
5195 group = Group.get_group_by_id(dbsession, group_id)
5196 testpatient = Patient()
5197 for k in EDIT_PATIENT_SIMPLE_PARAMS:
5198 setattr(testpatient, k, value[k])
5199 testpatient.idnums = []
5200 for idrefdict in value[ViewParam.ID_REFERENCES]:
5201 pidnum = PatientIdNum()
5202 pidnum.which_idnum = idrefdict[ViewParam.WHICH_IDNUM]
5203 pidnum.idnum_value = idrefdict[ViewParam.IDNUM_VALUE]
5204 testpatient.idnums.append(pidnum)
5205 tk_finalize_policy = TokenizedPolicy(group.finalize_policy)
5206 if not testpatient.satisfies_id_policy(tk_finalize_policy):
5207 _ = self.gettext
5208 raise Invalid(
5209 node,
5210 _("Patient would not meet 'finalize' ID policy for group:")
5211 + f" {group.name}! ["
5212 + _("That policy is:")
5213 + f" {group.finalize_policy!r}]",
5214 )
5217class DangerousEditPatientSchema(EditPatientSchema):
5218 group_id = HiddenIntegerNode() # must match ViewParam.GROUP_ID
5219 danger = TranslatableValidateDangerousOperationNode()
5222class EditServerCreatedPatientSchema(EditPatientSchema):
5223 # Must match ViewParam.GROUP_ID
5224 group_id = MandatoryGroupIdSelectorPatientGroups(insert_before="forename")
5225 task_schedules = (
5226 TaskScheduleSequence()
5227 ) # must match ViewParam.TASK_SCHEDULES
5230class EditFinalizedPatientForm(DangerousForm):
5231 """
5232 Form to edit a finalized patient.
5233 """
5235 def __init__(self, request: "CamcopsRequest", **kwargs: Any) -> None:
5236 _ = request.gettext
5237 super().__init__(
5238 schema_class=DangerousEditPatientSchema,
5239 submit_action=FormAction.SUBMIT,
5240 submit_title=_("Submit"),
5241 request=request,
5242 **kwargs,
5243 )
5246class EditServerCreatedPatientForm(DynamicDescriptionsNonceForm):
5247 """
5248 Form to add or edit a patient not yet on the device (for scheduled tasks)
5249 """
5251 def __init__(self, request: "CamcopsRequest", **kwargs: Any) -> None:
5252 schema = EditServerCreatedPatientSchema().bind(request=request)
5253 _ = request.gettext
5254 super().__init__(
5255 schema,
5256 request=request,
5257 buttons=[
5258 Button(
5259 name=FormAction.SUBMIT,
5260 title=_("Submit"),
5261 css_class="btn-danger",
5262 ),
5263 Button(name=FormAction.CANCEL, title=_("Cancel")),
5264 ],
5265 **kwargs,
5266 )
5269class EmailTemplateNode(OptionalStringNode, RequestAwareMixin):
5270 def __init__(self, *args, **kwargs) -> None:
5271 self.title = "" # for type checker
5272 self.description = "" # for type checker
5273 self.formatter = TaskScheduleEmailTemplateFormatter()
5274 super().__init__(*args, **kwargs)
5276 # noinspection PyUnusedLocal
5277 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
5278 _ = self.gettext
5279 self.title = _("Email template")
5280 self.description = _(
5281 "Template of email to be sent to patients when inviting them to "
5282 "complete the tasks in the schedule. Valid placeholders: {}"
5283 ).format(self.formatter.get_valid_parameters_string())
5285 # noinspection PyAttributeOutsideInit
5286 self.widget = RichTextWidget(options=get_tinymce_options(self.request))
5288 def validator(self, node: SchemaNode, value: Any) -> None:
5289 _ = self.gettext
5291 try:
5292 self.formatter.validate(value)
5293 return
5294 except KeyError as e:
5295 error = _("{bad_key} is not a valid placeholder").format(bad_key=e)
5296 except ValueError:
5297 error = _(
5298 "Invalid email template. Is there a missing '{' or '}' ?"
5299 )
5301 raise Invalid(node, error)
5304class EmailCcNode(OptionalEmailNode, RequestAwareMixin):
5305 # noinspection PyUnusedLocal
5306 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
5307 _ = self.gettext
5308 self.title = _("Email CC")
5309 self.description = _(
5310 "The patient will see these email addresses. Separate multiple "
5311 "addresses with commas."
5312 )
5315class EmailBccNode(OptionalEmailNode, RequestAwareMixin):
5316 # noinspection PyUnusedLocal
5317 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
5318 _ = self.gettext
5319 self.title = _("Email BCC")
5320 self.description = _(
5321 "The patient will not see these email addresses. Separate "
5322 "multiple addresses with commas."
5323 )
5326class EmailFromNode(OptionalEmailNode, RequestAwareMixin):
5327 # noinspection PyUnusedLocal
5328 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
5329 _ = self.gettext
5330 self.title = _('Email "From" address')
5331 self.description = _(
5332 "You must set this if you want to send emails to your patients"
5333 )
5336class TaskScheduleSchema(CSRFSchema):
5337 name = OptionalStringNode()
5338 group_id = (
5339 MandatoryGroupIdSelectorAdministeredGroups()
5340 ) # must match ViewParam.GROUP_ID # noqa
5341 email_from = EmailFromNode() # must match ViewParam.EMAIL_FROM
5342 email_cc = EmailCcNode() # must match ViewParam.EMAIL_CC
5343 email_bcc = EmailBccNode() # must match ViewParam.EMAIL_BCC
5344 email_subject = OptionalStringNode()
5345 email_template = EmailTemplateNode()
5348class EditTaskScheduleForm(DynamicDescriptionsNonceForm):
5349 def __init__(self, request: "CamcopsRequest", **kwargs: Any) -> None:
5350 schema = TaskScheduleSchema().bind(request=request)
5351 _ = request.gettext
5352 super().__init__(
5353 schema,
5354 request=request,
5355 buttons=[
5356 Button(
5357 name=FormAction.SUBMIT,
5358 title=_("Submit"),
5359 css_class="btn-danger",
5360 ),
5361 Button(name=FormAction.CANCEL, title=_("Cancel")),
5362 ],
5363 **kwargs,
5364 )
5367class DeleteTaskScheduleSchema(HardWorkConfirmationSchema):
5368 """
5369 Schema to delete a task schedule.
5370 """
5372 # name must match ViewParam.SCHEDULE_ID
5373 schedule_id = HiddenIntegerNode()
5374 danger = TranslatableValidateDangerousOperationNode()
5377class DeleteTaskScheduleForm(DeleteCancelForm):
5378 """
5379 Form to delete a task schedule.
5380 """
5382 def __init__(self, request: "CamcopsRequest", **kwargs: Any) -> None:
5383 super().__init__(
5384 schema_class=DeleteTaskScheduleSchema, request=request, **kwargs
5385 )
5388class DurationWidget(Widget):
5389 """
5390 Widget for entering a duration as a number of months, weeks and days.
5391 The default template renders three text input fields.
5392 Total days = (months * 30) + (weeks * 7) + days.
5393 """
5395 basedir = os.path.join(TEMPLATE_DIR, "deform")
5396 readonlydir = os.path.join(basedir, "readonly")
5397 form = "duration.pt"
5398 template = os.path.join(basedir, form)
5399 readonly_template = os.path.join(readonlydir, form)
5401 def __init__(self, request: "CamcopsRequest", **kwargs: Any) -> None:
5402 super().__init__(**kwargs)
5403 self.request = request
5405 def serialize(
5406 self,
5407 field: "Field",
5408 cstruct: Union[Dict[str, Any], None, ColanderNullType],
5409 **kw: Any,
5410 ) -> Any:
5411 # called when rendering the form with values from
5412 # DurationType.serialize
5413 if cstruct in (None, null):
5414 cstruct = {}
5416 cstruct: Dict[str, Any]
5418 months = cstruct.get("months", "")
5419 weeks = cstruct.get("weeks", "")
5420 days = cstruct.get("days", "")
5422 kw.setdefault("months", months)
5423 kw.setdefault("weeks", weeks)
5424 kw.setdefault("days", days)
5426 readonly = kw.get("readonly", self.readonly)
5427 template = readonly and self.readonly_template or self.template
5428 values = self.get_template_values(field, cstruct, kw)
5430 _ = self.request.gettext
5432 values.update(
5433 weeks_placeholder=_("1 week = 7 days"),
5434 months_placeholder=_("1 month = 30 days"),
5435 months_label=_("Months"),
5436 weeks_label=_("Weeks"),
5437 days_label=_("Days"),
5438 )
5440 return field.renderer(template, **values)
5442 def deserialize(
5443 self, field: "Field", pstruct: Union[Dict[str, Any], ColanderNullType]
5444 ) -> Dict[str, int]:
5445 # called when validating the form on submission
5446 # value is passed to the schema deserialize()
5448 if pstruct is null:
5449 pstruct = {}
5451 pstruct: Dict[str, Any]
5453 errors = []
5455 try:
5456 days = int(pstruct.get("days") or "0")
5457 except ValueError:
5458 errors.append("Please enter a valid number of days or leave blank")
5460 try:
5461 weeks = int(pstruct.get("weeks") or "0")
5462 except ValueError:
5463 errors.append(
5464 "Please enter a valid number of weeks or leave blank"
5465 )
5467 try:
5468 months = int(pstruct.get("months") or "0")
5469 except ValueError:
5470 errors.append(
5471 "Please enter a valid number of months or leave blank"
5472 )
5474 if len(errors) > 0:
5475 raise Invalid(field, errors, pstruct)
5477 # noinspection PyUnboundLocalVariable
5478 return {"days": days, "months": months, "weeks": weeks}
5481class DurationType(SchemaType):
5482 """
5483 Custom colander schema type to convert between Pendulum Duration objects
5484 and months, weeks and days.
5485 """
5487 # noinspection PyMethodMayBeStatic,PyUnusedLocal
5488 def deserialize(
5489 self,
5490 node: SchemaNode,
5491 cstruct: Union[Dict[str, Any], None, ColanderNullType],
5492 ) -> Optional[Duration]:
5493 # called when validating the submitted form with the total days
5494 # from DurationWidget.deserialize()
5495 if cstruct in (None, null):
5496 return None
5498 cstruct: Dict[str, Any]
5500 # may be passed invalid values when re-rendering widget with error
5501 # messages
5502 try:
5503 days = int(cstruct.get("days") or "0")
5504 except ValueError:
5505 days = 0
5507 try:
5508 weeks = int(cstruct.get("weeks") or "0")
5509 except ValueError:
5510 weeks = 0
5512 try:
5513 months = int(cstruct.get("months") or "0")
5514 except ValueError:
5515 months = 0
5517 total_days = months * 30 + weeks * 7 + days
5519 return Duration(days=total_days)
5521 # noinspection PyMethodMayBeStatic,PyUnusedLocal
5522 def serialize(
5523 self, node: SchemaNode, duration: Union[Duration, ColanderNullType]
5524 ) -> Union[Dict, ColanderNullType]:
5525 if duration is null:
5526 # For new schedule item
5527 return null
5529 duration: Duration
5531 total_days = duration.in_days()
5533 months = total_days // 30
5534 weeks = (total_days % 30) // 7
5535 days = (total_days % 30) % 7
5537 # Existing schedule item
5538 cstruct = {"days": days, "months": months, "weeks": weeks}
5540 return cstruct
5543class DurationNode(SchemaNode, RequestAwareMixin):
5544 schema_type = DurationType
5546 # noinspection PyUnusedLocal,PyAttributeOutsideInit
5547 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
5548 self.widget = DurationWidget(self.request)
5551class TaskScheduleItemSchema(CSRFSchema):
5552 schedule_id = HiddenIntegerNode() # name must match ViewParam.SCHEDULE_ID
5553 # name must match ViewParam.TABLE_NAME
5554 table_name = MandatorySingleTaskSelector()
5555 # name must match ViewParam.CLINICIAN_CONFIRMATION
5556 clinician_confirmation = BooleanNode(default=False)
5557 due_from = DurationNode() # name must match ViewParam.DUE_FROM
5558 due_within = DurationNode() # name must match ViewParam.DUE_WITHIN
5560 # noinspection PyUnusedLocal
5561 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
5562 _ = self.gettext
5563 due_from = get_child_node(self, "due_from")
5564 due_from.title = _("Due from")
5565 due_from.description = _(
5566 "Time from the start of schedule when the patient may begin this "
5567 "task"
5568 )
5569 due_within = get_child_node(self, "due_within")
5570 due_within.title = _("Due within")
5571 due_within.description = _(
5572 "Time the patient has to complete this task"
5573 )
5574 clinician_confirmation = get_child_node(self, "clinician_confirmation")
5575 clinician_confirmation.title = _("Allow clinician tasks")
5576 clinician_confirmation.label = None
5577 clinician_confirmation.description = _(
5578 "Tick this box to schedule a task that would normally be "
5579 "completed by (or with) a clinician"
5580 )
5582 def validator(self, node: SchemaNode, value: Dict[str, Any]) -> None:
5583 task_class = self._get_task_class(value)
5585 self._validate_clinician_status(node, value, task_class)
5586 self._validate_due_dates(node, value)
5587 self._validate_task_ip_use(node, value, task_class)
5589 # noinspection PyMethodMayBeStatic
5590 def _get_task_class(self, value: Dict[str, Any]) -> Type["Task"]:
5591 return tablename_to_task_class_dict()[value[ViewParam.TABLE_NAME]]
5593 def _validate_clinician_status(
5594 self, node: SchemaNode, value: Dict[str, Any], task_class: Type["Task"]
5595 ) -> None:
5597 _ = self.gettext
5598 clinician_confirmation = value[ViewParam.CLINICIAN_CONFIRMATION]
5599 if task_class.has_clinician and not clinician_confirmation:
5600 raise Invalid(
5601 node,
5602 _(
5603 "You have selected the task '{task_name}', which a "
5604 "patient would not normally complete by themselves. "
5605 "If you are sure you want to do this, you must tick "
5606 "'Allow clinician tasks'."
5607 ).format(task_name=task_class.shortname),
5608 )
5610 def _validate_due_dates(
5611 self, node: SchemaNode, value: Dict[str, Any]
5612 ) -> None:
5613 _ = self.gettext
5614 due_from = value[ViewParam.DUE_FROM]
5615 if due_from.total_days() < 0:
5616 raise Invalid(node, _("'Due from' must be zero or more days"))
5618 due_within = value[ViewParam.DUE_WITHIN]
5619 if due_within.total_days() <= 0:
5620 raise Invalid(node, _("'Due within' must be more than zero days"))
5622 def _validate_task_ip_use(
5623 self, node: SchemaNode, value: Dict[str, Any], task_class: Type["Task"]
5624 ) -> None:
5626 _ = self.gettext
5628 if not task_class.prohibits_anything():
5629 return
5631 schedule_id = value[ViewParam.SCHEDULE_ID]
5632 schedule = (
5633 self.request.dbsession.query(TaskSchedule)
5634 .filter(TaskSchedule.id == schedule_id)
5635 .one()
5636 )
5638 if schedule.group.ip_use is None:
5639 raise Invalid(
5640 node,
5641 _(
5642 "The task you have selected prohibits use in certain "
5643 "contexts. The group '{group_name}' has no intellectual "
5644 "property settings. "
5645 "You need to edit the group '{group_name}' to say which "
5646 "contexts it operates in.".format(
5647 group_name=schedule.group.name
5648 )
5649 ),
5650 )
5652 # TODO: On the client we say 'to use this task, you must seek
5653 # permission from the copyright holder'. We could do the same but at
5654 # the moment there isn't a way of telling the system that we have done
5655 # so.
5656 if (
5657 task_class.prohibits_commercial
5658 and schedule.group.ip_use.commercial
5659 ):
5660 raise Invalid(
5661 node,
5662 _(
5663 "The group '{group_name}' associated with schedule "
5664 "'{schedule_name}' operates in a "
5665 "commercial context but the task you have selected "
5666 "prohibits commercial use."
5667 ).format(
5668 group_name=schedule.group.name, schedule_name=schedule.name
5669 ),
5670 )
5672 if task_class.prohibits_clinical and schedule.group.ip_use.clinical:
5673 raise Invalid(
5674 node,
5675 _(
5676 "The group '{group_name}' associated with schedule "
5677 "'{schedule_name}' operates in a "
5678 "clinical context but the task you have selected "
5679 "prohibits clinical use."
5680 ).format(
5681 group_name=schedule.group.name, schedule_name=schedule.name
5682 ),
5683 )
5685 if (
5686 task_class.prohibits_educational
5687 and schedule.group.ip_use.educational
5688 ):
5689 raise Invalid(
5690 node,
5691 _(
5692 "The group '{group_name}' associated with schedule "
5693 "'{schedule_name}' operates in an "
5694 "educational context but the task you have selected "
5695 "prohibits educational use."
5696 ).format(
5697 group_name=schedule.group.name, schedule_name=schedule.name
5698 ),
5699 )
5701 if task_class.prohibits_research and schedule.group.ip_use.research:
5702 raise Invalid(
5703 node,
5704 _(
5705 "The group '{group_name}' associated with schedule "
5706 "'{schedule_name}' operates in a "
5707 "research context but the task you have selected "
5708 "prohibits research use."
5709 ).format(
5710 group_name=schedule.group.name, schedule_name=schedule.name
5711 ),
5712 )
5715class EditTaskScheduleItemForm(DynamicDescriptionsNonceForm):
5716 def __init__(self, request: "CamcopsRequest", **kwargs: Any) -> None:
5717 schema = TaskScheduleItemSchema().bind(request=request)
5718 _ = request.gettext
5719 super().__init__(
5720 schema,
5721 request=request,
5722 buttons=[
5723 Button(
5724 name=FormAction.SUBMIT,
5725 title=_("Submit"),
5726 css_class="btn-danger",
5727 ),
5728 Button(name=FormAction.CANCEL, title=_("Cancel")),
5729 ],
5730 **kwargs,
5731 )
5734class DeleteTaskScheduleItemSchema(HardWorkConfirmationSchema):
5735 """
5736 Schema to delete a task schedule item.
5737 """
5739 # name must match ViewParam.SCHEDULE_ITEM_ID
5740 schedule_item_id = HiddenIntegerNode()
5741 danger = TranslatableValidateDangerousOperationNode()
5744class DeleteTaskScheduleItemForm(DeleteCancelForm):
5745 """
5746 Form to delete a task schedule item.
5747 """
5749 def __init__(self, request: "CamcopsRequest", **kwargs: Any) -> None:
5750 super().__init__(
5751 schema_class=DeleteTaskScheduleItemSchema,
5752 request=request,
5753 **kwargs,
5754 )
5757class ForciblyFinalizeChooseDeviceSchema(CSRFSchema):
5758 """
5759 Schema to force-finalize records from a device.
5760 """
5762 device_id = MandatoryDeviceIdSelector() # must match ViewParam.DEVICE_ID
5763 danger = TranslatableValidateDangerousOperationNode()
5766class ForciblyFinalizeChooseDeviceForm(SimpleSubmitForm):
5767 """
5768 Form to force-finalize records from a device.
5769 """
5771 def __init__(self, request: "CamcopsRequest", **kwargs) -> None:
5772 _ = request.gettext
5773 super().__init__(
5774 schema_class=ForciblyFinalizeChooseDeviceSchema,
5775 submit_title=_("View affected tasks"),
5776 request=request,
5777 **kwargs,
5778 )
5781class ForciblyFinalizeConfirmSchema(HardWorkConfirmationSchema):
5782 """
5783 Schema to confirm force-finalizing of a device.
5784 """
5786 device_id = HiddenIntegerNode() # must match ViewParam.DEVICE_ID
5787 danger = TranslatableValidateDangerousOperationNode()
5790class ForciblyFinalizeConfirmForm(DangerousForm):
5791 """
5792 Form to confirm force-finalizing of a device.
5793 """
5795 def __init__(self, request: "CamcopsRequest", **kwargs) -> None:
5796 _ = request.gettext
5797 super().__init__(
5798 schema_class=ForciblyFinalizeConfirmSchema,
5799 submit_action=FormAction.FINALIZE,
5800 submit_title=_("Forcibly finalize"),
5801 request=request,
5802 **kwargs,
5803 )
5806# =============================================================================
5807# User downloads
5808# =============================================================================
5811class HiddenDownloadFilenameNode(HiddenStringNode, RequestAwareMixin):
5812 """
5813 Note to encode a hidden filename.
5814 """
5816 # noinspection PyMethodMayBeStatic
5817 def validator(self, node: SchemaNode, value: str) -> None:
5818 if value:
5819 try:
5820 validate_download_filename(value, self.request)
5821 except ValueError as e:
5822 raise Invalid(node, str(e))
5825class UserDownloadDeleteSchema(CSRFSchema):
5826 """
5827 Schema to capture details of a file to be deleted.
5828 """
5830 filename = (
5831 HiddenDownloadFilenameNode()
5832 ) # name must match ViewParam.FILENAME # noqa
5835class UserDownloadDeleteForm(SimpleSubmitForm):
5836 """
5837 Form that provides a single button to delete a user download.
5838 """
5840 def __init__(self, request: "CamcopsRequest", **kwargs) -> None:
5841 _ = request.gettext
5842 super().__init__(
5843 schema_class=UserDownloadDeleteSchema,
5844 submit_title=_("Delete"),
5845 request=request,
5846 **kwargs,
5847 )
5850class EmailBodyNode(MandatoryStringNode, RequestAwareMixin):
5851 def __init__(self, *args, **kwargs) -> None:
5852 self.title = "" # for type checker
5853 super().__init__(*args, **kwargs)
5855 # noinspection PyUnusedLocal
5856 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
5857 _ = self.gettext
5859 self.title = _("Message")
5861 # noinspection PyAttributeOutsideInit
5862 self.widget = RichTextWidget(options=get_tinymce_options(self.request))
5865class SendEmailSchema(CSRFSchema):
5866 email = MandatoryEmailNode() # name must match ViewParam.EMAIL
5867 email_cc = HiddenStringNode()
5868 email_bcc = HiddenStringNode()
5869 email_from = HiddenStringNode()
5870 email_subject = MandatoryStringNode()
5871 email_body = EmailBodyNode()
5874class SendEmailForm(InformativeNonceForm):
5875 """
5876 Form for sending email
5877 """
5879 def __init__(self, request: "CamcopsRequest", **kwargs) -> None:
5880 schema = SendEmailSchema().bind(request=request)
5881 _ = request.gettext
5882 super().__init__(
5883 schema,
5884 buttons=[
5885 Button(name=FormAction.SUBMIT, title=_("Send")),
5886 Button(name=FormAction.CANCEL, title=_("Cancel")),
5887 ],
5888 **kwargs,
5889 )