Coverage for cc_modules/cc_forms.py: 52%
2284 statements
« prev ^ index » next coverage.py v7.9.2, created at 2025-07-15 14:23 +0100
« prev ^ index » next coverage.py v7.9.2, created at 2025-07-15 14:23 +0100
1"""
2camcops_server/cc_modules/cc_forms.py
4===============================================================================
6 Copyright (C) 2012, University of Cambridge, Department of Psychiatry.
7 Created by Rudolf Cardinal (rnc1001@cam.ac.uk).
9 This file is part of CamCOPS.
11 CamCOPS is free software: you can redistribute it and/or modify
12 it under the terms of the GNU General Public License as published by
13 the Free Software Foundation, either version 3 of the License, or
14 (at your option) any later version.
16 CamCOPS is distributed in the hope that it will be useful,
17 but WITHOUT ANY WARRANTY; without even the implied warranty of
18 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
19 GNU General Public License for more details.
21 You should have received a copy of the GNU General Public License
22 along with CamCOPS. If not, see <https://www.gnu.org/licenses/>.
24===============================================================================
26.. _Deform: https://docs.pylonsproject.org/projects/deform/en/latest/
28**Forms for use by the web front end.**
30*COLANDER NODES, NULLS, AND VALIDATION*
32- Surprisingly tricky.
33- Nodes must be validly intialized with NO USER-DEFINED PARAMETERS to __init__;
34 the Deform framework clones them.
35- A null appstruct is used to initialize nodes as Forms are created.
36 Therefore, the "default" value must be acceptable to the underlying type's
37 serialize() function. Note in particular that "default = None" is not
38 acceptable to Integer. Having no default is fine, though.
39- In general, flexible inheritance is very hard to implement.
41- Note that this error:
43 .. code-block:: none
45 AttributeError: 'EditTaskFilterSchema' object has no attribute 'typ'
47 means you have failed to call super().__init__() properly from __init__().
49- When creating a schema, its members seem to have to be created in the class
50 declaration as class properties, not in __init__().
52*ACCESSING THE PYRAMID REQUEST IN FORMS AND SCHEMAS*
54We often want to be able to access the request for translation purposes, or
55sometimes more specialized reasons.
57Forms are created dynamically as simple Python objects. So, for a
58:class:`deform.form.Form`, just add a ``request`` parameter to the constructor,
59and pass it when you create the form. An example is
60:class:`camcops_server.cc_modules.cc_forms.DeleteCancelForm`.
62For a :class:`colander.Schema` and :class:`colander.SchemaNode`, construction
63is separate from binding. The schema nodes are created as part of a schema
64class, not a schema instance. The schema is created by the form, and then bound
65to a request. Access to the request is therefore via the :func:`after_bind`
66callback function, offered by colander, via the ``kw`` parameter or
67``self.bindings``. We use ``Binding.REQUEST`` as a standard key for this
68dictionary. The bindings are also available in :func:`validator` and similar
69functions, as ``self.bindings``.
71All forms containing any schema that needs to see the request should have this
72sort of ``__init__`` function:
74.. code-block:: python
76 class SomeForm(...):
77 def __init__(...):
78 schema = schema_class().bind(request=request)
79 super().__init__(
80 schema,
81 ...,
82 **kwargs
83 )
85The simplest thing, therefore, is for all forms to do this. Some of our forms
86use a form superclass that does this via the ``schema_class`` argument (which
87is not part of colander, so if you see that, the superclass should do the work
88of binding a request).
90For translation, throughout there will be ``_ = self.gettext`` or ``_ =
91request.gettext``.
93Form titles need to be dynamically written via
94:class:`cardinal_pythonlib.deform_utils.DynamicDescriptionsForm` or similar.
96.. glossary::
98 cstruct
99 See `cstruct
100 <https://docs.pylonsproject.org/projects/deform/en/latest/glossary.html#term-cstruct>`_
101 in the Deform_ docs.
103 Colander
104 See `Colander
105 <https://docs.pylonsproject.org/projects/deform/en/latest/glossary.html#term-colander>`_
106 in the Deform_ docs.
108 field
109 See `field
110 <https://docs.pylonsproject.org/projects/deform/en/latest/glossary.html#term-field>`_
111 in the Deform_ docs.
113 Peppercorn
114 See `Peppercorn
115 <https://docs.pylonsproject.org/projects/deform/en/latest/glossary.html#term-peppercorn>`_
116 in the Deform_ docs.
118 pstruct
119 See `pstruct
120 <https://docs.pylonsproject.org/projects/deform/en/latest/glossary.html#term-pstruct>`_
121 in the Deform_ docs.
123"""
125from io import BytesIO
126import json
127import logging
128import os
129from typing import (
130 Any,
131 Callable,
132 Dict,
133 List,
134 Optional,
135 Tuple,
136 Type,
137 TYPE_CHECKING,
138 Union,
139)
141from cardinal_pythonlib.colander_utils import (
142 AllowNoneType,
143 BooleanNode,
144 DateSelectorNode,
145 DateTimeSelectorNode,
146 DEFAULT_WIDGET_DATE_OPTIONS_FOR_PENDULUM,
147 DEFAULT_WIDGET_TIME_OPTIONS_FOR_PENDULUM,
148 get_child_node,
149 get_values_and_permissible,
150 HiddenIntegerNode,
151 HiddenStringNode,
152 MandatoryEmailNode,
153 MandatoryStringNode,
154 OptionalEmailNode,
155 OptionalIntNode,
156 OptionalPendulumNode,
157 OptionalStringNode,
158 ValidateDangerousOperationNode,
159)
160from cardinal_pythonlib.deform_utils import (
161 DynamicDescriptionsForm,
162 InformativeForm,
163)
164from cardinal_pythonlib.httpconst import HttpMethod
165from cardinal_pythonlib.logs import BraceStyleAdapter
166from cardinal_pythonlib.sqlalchemy.dialect import SqlaDialectName
167from cardinal_pythonlib.sqlalchemy.orm_query import CountStarSpecializedQuery
169# noinspection PyProtectedMember
170from colander import (
171 Boolean,
172 Date,
173 drop,
174 Integer,
175 Invalid,
176 Length,
177 MappingSchema,
178 null,
179 OneOf,
180 Range,
181 Schema,
182 SchemaNode,
183 SchemaType,
184 SequenceSchema,
185 Set,
186 String,
187 _null,
188 url,
189)
190from deform.form import Button
191from deform.widget import (
192 CheckboxChoiceWidget,
193 CheckedPasswordWidget,
194 # DateInputWidget,
195 DateTimeInputWidget,
196 FormWidget,
197 HiddenWidget,
198 MappingWidget,
199 PasswordWidget,
200 RadioChoiceWidget,
201 RichTextWidget,
202 SelectWidget,
203 SequenceWidget,
204 TextAreaWidget,
205 TextInputWidget,
206 Widget,
207)
209from pendulum import Duration
210import phonenumbers
211import pyotp
212import qrcode
213import qrcode.image.svg
215# import as LITTLE AS POSSIBLE; this is used by lots of modules
216# We use some delayed imports here (search for "delayed import")
217from camcops_server.cc_modules.cc_baseconstants import (
218 DEFORM_SUPPORTS_CSP_NONCE,
219 TEMPLATE_DIR,
220)
221from camcops_server.cc_modules.cc_constants import (
222 ConfigParamSite,
223 DEFAULT_ROWS_PER_PAGE,
224 MfaMethod,
225 MINIMUM_PASSWORD_LENGTH,
226 SEX_OTHER_UNSPECIFIED,
227 SEX_FEMALE,
228 SEX_MALE,
229 StringLengths,
230 USER_NAME_FOR_SYSTEM,
231)
232from camcops_server.cc_modules.cc_group import Group
233from camcops_server.cc_modules.cc_idnumdef import (
234 IdNumDefinition,
235 ID_NUM_VALIDATION_METHOD_CHOICES,
236 validate_id_number,
237)
238from camcops_server.cc_modules.cc_ipuse import IpUse
239from camcops_server.cc_modules.cc_language import (
240 DEFAULT_LOCALE,
241 POSSIBLE_LOCALES,
242 POSSIBLE_LOCALES_WITH_DESCRIPTIONS,
243)
244from camcops_server.cc_modules.cc_patient import Patient
245from camcops_server.cc_modules.cc_patientidnum import PatientIdNum
246from camcops_server.cc_modules.cc_policy import (
247 TABLET_ID_POLICY_STR,
248 TokenizedPolicy,
249)
250from camcops_server.cc_modules.cc_pyramid import FormAction, ViewArg, ViewParam
251from camcops_server.cc_modules.cc_task import tablename_to_task_class_dict
252from camcops_server.cc_modules.cc_taskschedule import (
253 TaskSchedule,
254 TaskScheduleEmailTemplateFormatter,
255)
256from camcops_server.cc_modules.cc_validators import (
257 ALPHANUM_UNDERSCORE_CHAR,
258 validate_anything,
259 validate_by_char_and_length,
260 validate_download_filename,
261 validate_group_name,
262 validate_hl7_aa,
263 validate_hl7_id_type,
264 validate_ip_address,
265 validate_new_password,
266 validate_redirect_url,
267 validate_username,
268)
270if TYPE_CHECKING:
271 from deform.field import Field
272 from camcops_server.cc_modules.cc_request import CamcopsRequest
273 from camcops_server.cc_modules.cc_task import Task
274 from camcops_server.cc_modules.cc_user import User
276log = BraceStyleAdapter(logging.getLogger(__name__))
278ColanderNullType = _null
279ValidatorType = Callable[[SchemaNode, Any], None] # called as v(node, value)
282# =============================================================================
283# Debugging options
284# =============================================================================
286DEBUG_CSRF_CHECK = False
288if DEBUG_CSRF_CHECK:
289 log.warning("Debugging options enabled!")
292# =============================================================================
293# Constants
294# =============================================================================
296DEFORM_ACCORDION_BUG = True
297# If you have a sequence containing an accordion (e.g. advanced JSON settings),
298# then when you add a new node (e.g. "Add Task schedule") then the newly
299# created node's accordion won't open out.
300# https://github.com/Pylons/deform/issues/347
303class Binding(object):
304 """
305 Keys used for binding dictionaries with Colander schemas (schemata).
307 Must match ``kwargs`` of calls to ``bind()`` function of each ``Schema``.
308 """
310 GROUP = "group"
311 OPEN_ADMIN = "open_admin"
312 OPEN_WHAT = "open_what"
313 OPEN_WHEN = "open_when"
314 OPEN_WHO = "open_who"
315 REQUEST = "request"
316 TRACKER_TASKS_ONLY = "tracker_tasks_only"
317 USER = "user"
320class BootstrapCssClasses(object):
321 """
322 Constants from Bootstrap to control display.
323 """
325 FORM_INLINE = "form-inline"
326 RADIO_INLINE = "radio-inline"
327 LIST_INLINE = "list-inline"
328 CHECKBOX_INLINE = "checkbox-inline"
331AUTOCOMPLETE_ATTR = "autocomplete"
334class AutocompleteAttrValues(object):
335 """
336 Some values for the HTML "autocomplete" attribute, as per
337 https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/autocomplete.
338 Not all are used.
339 """
341 BDAY = "bday"
342 CURRENT_PASSWORD = "current-password"
343 EMAIL = "email"
344 FAMILY_NAME = "family-name"
345 GIVEN_NAME = "given-name"
346 NEW_PASSWORD = "new-password"
347 OFF = "off"
348 ON = "on" # browser decides
349 STREET_ADDRESS = "stree-address"
350 USERNAME = "username"
353def get_tinymce_options(request: "CamcopsRequest") -> Dict[str, Any]:
354 return {
355 "content_css": "static/tinymce/custom_content.css",
356 "menubar": "false",
357 "plugins": "link",
358 "toolbar": (
359 "undo redo | bold italic underline | link | "
360 "bullist numlist | "
361 "alignleft aligncenter alignright alignjustify | "
362 "outdent indent"
363 ),
364 "language": request.language_iso_639_1,
365 }
368# =============================================================================
369# Common phrases for translation
370# =============================================================================
373def or_join_description(request: "CamcopsRequest") -> str:
374 _ = request.gettext
375 return _("If you specify more than one, they will be joined with OR.")
378def change_password_title(request: "CamcopsRequest") -> str:
379 _ = request.gettext
380 return _("Change password")
383def sex_choices(request: "CamcopsRequest") -> List[Tuple[str, str]]:
384 _ = request.gettext
385 return [
386 (SEX_FEMALE, _("Female (F)")),
387 (SEX_MALE, _("Male (M)")),
388 # TRANSLATOR: sex code description
389 (SEX_OTHER_UNSPECIFIED, _("Other/unspecified (X)")),
390 ]
393# =============================================================================
394# Deform bug fix: SelectWidget "multiple" attribute
395# =============================================================================
398class BugfixSelectWidget(SelectWidget):
399 """
400 Fixes a bug where newer versions of Chameleon (e.g. 3.8.0) render Deform's
401 ``multiple = False`` (in ``SelectWidget``) as this, which is wrong:
403 .. code-block:: none
405 <select name="which_idnum" id="deformField2" class=" form-control " multiple="False">
406 ^^^^^^^^^^^^^^^^
407 <option value="1">CPFT RiO number</option>
408 <option value="2">NHS number</option>
409 <option value="1000">MyHospital number</option>
410 </select>
412 ... whereas previous versions of Chameleon (e.g. 3.4) omitted the tag.
413 (I think it's a Chameleon change, anyway! And it's probably a bugfix in
414 Chameleon that exposed a bug in Deform.)
416 See :func:`camcops_server.cc_modules.webview.debug_form_rendering`.
417 """ # noqa
419 def __init__(self, multiple: bool = False, **kwargs: Any) -> None:
420 multiple = True if multiple else None # None, not False
421 super().__init__(multiple=multiple, **kwargs)
424SelectWidget = BugfixSelectWidget
427# =============================================================================
428# Form that handles Content-Security-Policy nonce tags
429# =============================================================================
432class InformativeNonceForm(InformativeForm):
433 """
434 A Form class to use our modifications to Deform, as per
435 https://github.com/Pylons/deform/issues/512, to pass a nonce value through
436 to the ``<script>`` and ``<style>`` tags in the Deform templates.
438 todo: if Deform is updated, work this into ``cardinal_pythonlib``.
439 """
441 if DEFORM_SUPPORTS_CSP_NONCE:
443 def __init__(self, schema: Schema, **kwargs: Any) -> None:
444 request = schema.request # type: CamcopsRequest
445 kwargs["nonce"] = request.nonce
446 super().__init__(schema, **kwargs)
449class DynamicDescriptionsNonceForm(DynamicDescriptionsForm):
450 """
451 Similarly; see :class:`InformativeNonceForm`.
453 todo: if Deform is updated, work this into ``cardinal_pythonlib``.
454 """
456 if DEFORM_SUPPORTS_CSP_NONCE:
458 def __init__(self, schema: Schema, **kwargs: Any) -> None:
459 request = schema.request # type: CamcopsRequest
460 kwargs["nonce"] = request.nonce
461 super().__init__(schema, **kwargs)
464# =============================================================================
465# Mixin for Schema/SchemaNode objects for translation
466# =============================================================================
468GETTEXT_TYPE = Callable[[str], str]
471class RequestAwareMixin(object):
472 """
473 Mixin to add Pyramid request awareness to Schema/SchemaNode objects,
474 together with some translations and other convenience functions.
475 """
477 def __init__(self, *args: Any, **kwargs: Any) -> None:
478 # Stop multiple inheritance complaints
479 super().__init__(*args, **kwargs)
481 # noinspection PyUnresolvedReferences
482 @property
483 def request(self) -> "CamcopsRequest":
484 return self.bindings[Binding.REQUEST] # type: ignore[attr-defined]
486 # noinspection PyUnresolvedReferences,PyPropertyDefinition
487 @property
488 def gettext(self) -> GETTEXT_TYPE:
489 return self.request.gettext
491 @property
492 def or_join_description(self) -> str:
493 return or_join_description(self.request)
496# =============================================================================
497# Translatable version of ValidateDangerousOperationNode
498# =============================================================================
501class TranslatableValidateDangerousOperationNode(
502 ValidateDangerousOperationNode, RequestAwareMixin
503):
504 """
505 Translatable version of ValidateDangerousOperationNode.
506 """
508 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
509 super().after_bind(node, kw) # calls set_description()
510 _ = self.gettext
511 node.title = _("Danger")
512 user_entry = get_child_node(self, "user_entry")
513 user_entry.title = _("Validate this dangerous operation")
515 def set_description(self, target_value: str) -> None:
516 # Overrides parent version (q.v.).
517 _ = self.gettext
518 user_entry = get_child_node(self, "user_entry")
519 prefix = _("Please enter the following: ")
520 user_entry.description = prefix + target_value
523# =============================================================================
524# Translatable version of SequenceWidget
525# =============================================================================
528class TranslatableSequenceWidget(SequenceWidget):
529 """
530 SequenceWidget does support translation via _(), but not in a
531 request-specific way.
532 """
534 def __init__(self, request: "CamcopsRequest", **kwargs: Any) -> None:
535 super().__init__(**kwargs)
536 _ = request.gettext
537 self.add_subitem_text_template = _("Add") + " ${subitem_title}"
540# =============================================================================
541# Translatable version of OptionalPendulumNode
542# =============================================================================
545class TranslatableOptionalPendulumNode(
546 OptionalPendulumNode, RequestAwareMixin
547):
548 """
549 Translates the "Date" and "Time" labels for the widget, via
550 the request.
552 .. todo:: TranslatableOptionalPendulumNode not fully implemented
553 """
555 def __init__(self, *args: Any, **kwargs: Any) -> None:
556 super().__init__(*args, **kwargs)
557 self.widget = None # type: Optional[Widget]
559 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
560 _ = self.gettext
561 self.widget = DateTimeInputWidget(
562 date_options=DEFAULT_WIDGET_DATE_OPTIONS_FOR_PENDULUM,
563 time_options=DEFAULT_WIDGET_TIME_OPTIONS_FOR_PENDULUM,
564 )
565 # log.debug("TranslatableOptionalPendulumNode.widget: {!r}",
566 # self.widget.__dict__)
569class TranslatableDateTimeSelectorNode(
570 DateTimeSelectorNode, RequestAwareMixin
571):
572 """
573 Translates the "Date" and "Time" labels for the widget, via
574 the request.
576 .. todo:: TranslatableDateTimeSelectorNode not fully implemented
577 """
579 def __init__(self, *args: Any, **kwargs: Any) -> None:
580 super().__init__(*args, **kwargs)
581 self.widget = None # type: Optional[Widget]
583 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
584 _ = self.gettext
585 self.widget = DateTimeInputWidget()
586 # log.debug("TranslatableDateTimeSelectorNode.widget: {!r}",
587 # self.widget.__dict__)
590'''
591class TranslatableDateSelectorNode(DateSelectorNode,
592 RequestAwareMixin):
593 """
594 Translates the "Date" and "Time" labels for the widget, via
595 the request.
597 .. todo:: TranslatableDateSelectorNode not fully implemented
598 """
599 def __init__(self, *args, **kwargs) -> None:
600 super().__init__(*args, **kwargs)
601 self.widget = None # type: Optional[Widget]
603 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
604 _ = self.gettext
605 self.widget = DateInputWidget()
606 # log.debug("TranslatableDateSelectorNode.widget: {!r}",
607 # self.widget.__dict__)
608'''
611# =============================================================================
612# CSRF
613# =============================================================================
616class CSRFToken(SchemaNode, RequestAwareMixin):
617 """
618 Node to embed a cross-site request forgery (CSRF) prevention token in a
619 form.
621 As per https://deformdemo.repoze.org/pyramid_csrf_demo/, modified for a
622 more recent Colander API.
624 NOTE that this makes use of colander.SchemaNode.bind; this CLONES the
625 Schema, and resolves any deferred values by means of the keywords passed to
626 bind(). Since the Schema is created at module load time, but since we're
627 asking the Schema to know about the request's CSRF values, this is the only
628 mechanism
629 (https://docs.pylonsproject.org/projects/colander/en/latest/api.html#colander.SchemaNode.bind).
631 From https://deform2000.readthedocs.io/en/latest/basics.html:
633 "The default of a schema node indicates the value to be serialized if a
634 value for the schema node is not found in the input data during
635 serialization. It should be the deserialized representation. If a schema
636 node does not have a default, it is considered "serialization required"."
638 "The missing of a schema node indicates the value to be deserialized if a
639 value for the schema node is not found in the input data during
640 deserialization. It should be the deserialized representation. If a schema
641 node does not have a missing value, a colander.Invalid exception will be
642 raised if the data structure being deserialized does not contain a matching
643 value."
645 RNC: Serialized values are always STRINGS.
647 """
649 schema_type = String
650 default = ""
651 missing = ""
652 title = " "
653 # ... evaluates to True but won't be visible, if the "hidden" aspect ever
654 # fails
655 widget = HiddenWidget()
657 # noinspection PyUnusedLocal
658 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
659 request = self.request
660 csrf_token = request.session.get_csrf_token()
661 if DEBUG_CSRF_CHECK:
662 log.debug("Got CSRF token from session: {!r}", csrf_token)
663 self.default = csrf_token
665 def validator(self, node: SchemaNode, value: Any) -> None:
666 # Deferred validator via method, as per
667 # https://docs.pylonsproject.org/projects/colander/en/latest/basics.html # noqa
668 request = self.request
669 csrf_token = request.session.get_csrf_token() # type: str
670 matches = value == csrf_token
671 if DEBUG_CSRF_CHECK:
672 log.debug(
673 "Validating CSRF token: form says {!r}, session says "
674 "{!r}, matches = {}",
675 value,
676 csrf_token,
677 matches,
678 )
679 if not matches:
680 log.warning(
681 "CSRF token mismatch; remote address {}", request.remote_addr
682 )
683 _ = request.gettext
684 raise Invalid(node, _("Bad CSRF token"))
687class CSRFSchema(Schema, RequestAwareMixin):
688 """
689 Base class for form schemas that use CSRF (XSRF; cross-site request
690 forgery) tokens.
692 You can't put the call to ``bind()`` at the end of ``__init__()``, because
693 ``bind()`` calls ``clone()`` with no arguments and ``clone()`` ends up
694 calling ``__init__()```...
696 The item name should be one that the ZAP penetration testing tool expects,
697 or you get:
699 .. code-block:: none
701 No known Anti-CSRF token [anticsrf, CSRFToken,
702 __RequestVerificationToken, csrfmiddlewaretoken, authenticity_token,
703 OWASP_CSRFTOKEN, anoncsrf, csrf_token, _csrf, _csrfSecret] was found in
704 the following HTML form: [Form 1: "_charset_" "__formid__"
705 "deformField1" "deformField2" "deformField3" "deformField4" ].
707 """
709 csrf_token = CSRFToken() # name must match ViewParam.CSRF_TOKEN
710 # ... name should also be one that ZAP expects, as above
713# =============================================================================
714# Horizontal forms
715# =============================================================================
718class HorizontalFormWidget(FormWidget):
719 """
720 Widget to render a form horizontally, with custom templates.
722 See :class:`deform.template.ZPTRendererFactory`, which explains how strings
723 are resolved to Chameleon ZPT (Zope) templates.
725 See
727 - https://stackoverflow.com/questions/12201835/form-inline-inside-a-form-horizontal-in-twitter-bootstrap
728 - https://stackoverflow.com/questions/18429121/inline-form-nested-within-horizontal-form-in-bootstrap-3
729 - https://stackoverflow.com/questions/23954772/how-to-make-a-horizontal-form-with-deform-2
730 """ # noqa
732 basedir = os.path.join(TEMPLATE_DIR, "deform")
733 readonlydir = os.path.join(basedir, "readonly")
734 form = "horizontal_form.pt"
735 mapping_item = "horizontal_mapping_item.pt"
737 template = os.path.join(
738 basedir, form
739 ) # default "form" = deform/templates/form.pt
740 readonly_template = os.path.join(
741 readonlydir, form
742 ) # default "readonly/form"
743 item_template = os.path.join(
744 basedir, mapping_item
745 ) # default "mapping_item"
746 readonly_item_template = os.path.join(
747 readonlydir, mapping_item
748 ) # default "readonly/mapping_item"
751class HorizontalFormMixin(object):
752 """
753 Modification to a Deform form that displays itself with horizontal layout,
754 using custom templates via :class:`HorizontalFormWidget`. Not fantastic.
755 """
757 def __init__(self, schema: Schema, *args: Any, **kwargs: Any) -> None:
758 kwargs = kwargs or {}
760 # METHOD 1: add "form-inline" to the CSS classes.
761 # extra_classes = "form-inline"
762 # if "css_class" in kwargs:
763 # kwargs["css_class"] += " " + extra_classes
764 # else:
765 # kwargs["css_class"] = extra_classes
767 # Method 2: change the widget
768 schema.widget = HorizontalFormWidget()
770 # OK, proceed.
771 super().__init__(schema, *args, **kwargs) # type: ignore[call-arg]
774def add_css_class(
775 kwargs: Dict[str, Any], extra_classes: str, param_name: str = "css_class"
776) -> None:
777 """
778 Modifies a kwargs dictionary to add a CSS class to the ``css_class``
779 parameter.
781 Args:
782 kwargs: a dictionary
783 extra_classes: CSS classes to add (as a space-separated string)
784 param_name: parameter name to modify; by default, "css_class"
785 """
786 if param_name in kwargs:
787 kwargs[param_name] += " " + extra_classes
788 else:
789 kwargs[param_name] = extra_classes
792class FormInlineCssMixin(object):
793 """
794 Modification to a Deform form that makes it display "inline" via CSS. This
795 has the effect of wrapping everything horizontally.
797 Should PRECEDE the :class:`Form` (or something derived from it) in the
798 inheritance order.
799 """
801 def __init__(self, *args: Any, **kwargs: Any) -> None:
802 kwargs = kwargs or {}
803 add_css_class(kwargs, BootstrapCssClasses.FORM_INLINE)
804 super().__init__(*args, **kwargs)
807def make_widget_horizontal(widget: Widget) -> None:
808 """
809 Applies Bootstrap "form-inline" styling to the widget.
810 """
811 widget.item_css_class = BootstrapCssClasses.FORM_INLINE
814def make_node_widget_horizontal(node: SchemaNode) -> None:
815 """
816 Applies Bootstrap "form-inline" styling to the schema node's widget.
818 **Note:** often better to use the ``inline=True`` option to the widget's
819 constructor.
820 """
821 make_widget_horizontal(node.widget)
824# =============================================================================
825# Specialized Form classes
826# =============================================================================
829class SimpleSubmitForm(InformativeNonceForm):
830 """
831 Form with a simple "submit" button.
832 """
834 def __init__(
835 self,
836 schema_class: Type[Schema],
837 submit_title: str,
838 request: "CamcopsRequest",
839 **kwargs: Any,
840 ) -> None:
841 """
842 Args:
843 schema_class:
844 class of the Colander :class:`Schema` to use as this form's
845 schema
846 submit_title:
847 title (text) to be used for the "submit" button
848 request:
849 :class:`camcops_server.cc_modules.cc_request.CamcopsRequest`
850 """
851 schema = schema_class().bind(request=request)
852 super().__init__(
853 schema,
854 buttons=[Button(name=FormAction.SUBMIT, title=submit_title)],
855 **kwargs,
856 )
859class OkForm(SimpleSubmitForm):
860 """
861 Form with a button that says "OK".
862 """
864 def __init__(self, request: "CamcopsRequest", **kwargs: Any) -> None:
865 _ = request.gettext
866 super().__init__(
867 schema_class=CSRFSchema,
868 submit_title=_("OK"),
869 request=request,
870 **kwargs,
871 )
874class ApplyCancelForm(InformativeNonceForm):
875 """
876 Form with "apply" and "cancel" buttons.
877 """
879 def __init__(
880 self,
881 schema_class: Type[Schema],
882 request: "CamcopsRequest",
883 **kwargs: Any,
884 ) -> None:
885 schema = schema_class().bind(request=request)
886 _ = request.gettext
887 super().__init__(
888 schema,
889 buttons=[
890 Button(name=FormAction.SUBMIT, title=_("Apply")),
891 Button(name=FormAction.CANCEL, title=_("Cancel")),
892 ],
893 **kwargs,
894 )
897class AddCancelForm(InformativeNonceForm):
898 """
899 Form with "add" and "cancel" buttons.
900 """
902 def __init__(
903 self,
904 schema_class: Type[Schema],
905 request: "CamcopsRequest",
906 **kwargs: Any,
907 ) -> None:
908 schema = schema_class().bind(request=request)
909 _ = request.gettext
910 super().__init__(
911 schema,
912 buttons=[
913 Button(name=FormAction.SUBMIT, title=_("Add")),
914 Button(name=FormAction.CANCEL, title=_("Cancel")),
915 ],
916 **kwargs,
917 )
920class DangerousForm(DynamicDescriptionsNonceForm):
921 """
922 Form with one "submit" button (with user-specifiable title text and action
923 name), in a CSS class indicating that it's a dangerous operation, plus a
924 "Cancel" button.
925 """
927 def __init__(
928 self,
929 schema_class: Type[Schema],
930 submit_action: str,
931 submit_title: str,
932 request: "CamcopsRequest",
933 **kwargs: Any,
934 ) -> None:
935 schema = schema_class().bind(request=request)
936 _ = request.gettext
937 super().__init__(
938 schema,
939 buttons=[
940 Button(
941 name=submit_action,
942 title=submit_title,
943 css_class="btn-danger",
944 ),
945 Button(name=FormAction.CANCEL, title=_("Cancel")),
946 ],
947 **kwargs,
948 )
951class DeleteCancelForm(DangerousForm):
952 """
953 Form with a "delete" button (visually marked as dangerous) and a "cancel"
954 button.
955 """
957 def __init__(
958 self,
959 schema_class: Type[Schema],
960 request: "CamcopsRequest",
961 **kwargs: Any,
962 ) -> None:
963 _ = request.gettext
964 super().__init__(
965 schema_class=schema_class,
966 submit_action=FormAction.DELETE,
967 submit_title=_("Delete"),
968 request=request,
969 **kwargs,
970 )
973# =============================================================================
974# Specialized SchemaNode classes used in several contexts
975# =============================================================================
977# -----------------------------------------------------------------------------
978# Task types
979# -----------------------------------------------------------------------------
982class OptionalSingleTaskSelector(OptionalStringNode, RequestAwareMixin):
983 """
984 Node to pick one task type.
985 """
987 def __init__(
988 self, *args: Any, tracker_tasks_only: bool = False, **kwargs: Any
989 ) -> None:
990 """
991 Args:
992 tracker_tasks_only: restrict the choices to tasks that offer
993 trackers.
994 """
995 self.title = "" # for type checker
996 self.tracker_tasks_only = tracker_tasks_only
997 self.widget = None # type: Optional[Widget]
998 self.validator = None # type: Optional[ValidatorType]
999 super().__init__(*args, **kwargs)
1001 # noinspection PyUnusedLocal
1002 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
1003 _ = self.gettext
1004 self.title = _("Task type")
1005 if Binding.TRACKER_TASKS_ONLY in kw:
1006 self.tracker_tasks_only = kw[Binding.TRACKER_TASKS_ONLY]
1007 values, pv = get_values_and_permissible(
1008 self.get_task_choices(), True, _("[Any]")
1009 )
1010 self.widget = SelectWidget(values=values)
1011 self.validator = OneOf(pv)
1013 def get_task_choices(self) -> List[Tuple[str, str]]:
1014 from camcops_server.cc_modules.cc_task import Task # delayed import
1016 choices = [] # type: List[Tuple[str, str]]
1017 for tc in Task.all_subclasses_by_shortname():
1018 if self.tracker_tasks_only and not tc.provides_trackers:
1019 continue
1020 choices.append((tc.tablename, tc.shortname))
1021 return choices
1024class MandatorySingleTaskSelector(MandatoryStringNode, RequestAwareMixin):
1025 """
1026 Node to pick one task type.
1027 """
1029 def __init__(self, *args: Any, **kwargs: Any) -> None:
1030 self.title = "" # for type checker
1031 self.widget = None # type: Optional[Widget]
1032 self.validator = None # type: Optional[ValidatorType]
1033 super().__init__(*args, **kwargs)
1035 # noinspection PyUnusedLocal
1036 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
1037 _ = self.gettext
1038 self.title = _("Task type")
1039 values, pv = get_values_and_permissible(self.get_task_choices(), False)
1040 self.widget = SelectWidget(values=values)
1041 self.validator = OneOf(pv)
1043 @staticmethod
1044 def get_task_choices() -> List[Tuple[str, str]]:
1045 from camcops_server.cc_modules.cc_task import Task # delayed import
1047 choices = [] # type: List[Tuple[str, str]]
1048 for tc in Task.all_subclasses_by_shortname():
1049 choices.append((tc.tablename, tc.shortname))
1050 return choices
1053class MultiTaskSelector(SchemaNode, RequestAwareMixin):
1054 """
1055 Node to select multiple task types.
1056 """
1058 schema_type = Set
1059 default = ""
1060 missing = ""
1062 def __init__(
1063 self,
1064 *args: Any,
1065 tracker_tasks_only: bool = False,
1066 minimum_number: int = 0,
1067 **kwargs: Any,
1068 ) -> None:
1069 self.tracker_tasks_only = tracker_tasks_only
1070 self.minimum_number = minimum_number
1071 self.widget = None # type: Optional[Widget]
1072 self.validator = None # type: Optional[ValidatorType]
1073 self.title = "" # for type checker
1074 self.description = "" # for type checker
1075 super().__init__(*args, **kwargs)
1077 # noinspection PyUnusedLocal
1078 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
1079 _ = self.gettext
1080 request = self.request # noqa: F841
1081 self.title = _("Task type(s)")
1082 self.description = (
1083 _("If none are selected, all task types will be offered.")
1084 + " "
1085 + self.or_join_description
1086 )
1087 if Binding.TRACKER_TASKS_ONLY in kw:
1088 self.tracker_tasks_only = kw[Binding.TRACKER_TASKS_ONLY]
1089 values, pv = get_values_and_permissible(self.get_task_choices())
1090 self.widget = CheckboxChoiceWidget(values=values, inline=True)
1091 self.validator = Length(min=self.minimum_number)
1093 def get_task_choices(self) -> List[Tuple[str, str]]:
1094 from camcops_server.cc_modules.cc_task import Task # delayed import
1096 choices = [] # type: List[Tuple[str, str]]
1097 for tc in Task.all_subclasses_by_shortname():
1098 if self.tracker_tasks_only and not tc.provides_trackers:
1099 continue
1100 choices.append((tc.tablename, tc.shortname))
1101 return choices
1104# -----------------------------------------------------------------------------
1105# Use the task index?
1106# -----------------------------------------------------------------------------
1109class ViaIndexSelector(BooleanNode, RequestAwareMixin):
1110 """
1111 Node to choose whether we use the server index or not.
1112 Default is true.
1113 """
1115 def __init__(self, *args: Any, **kwargs: Any) -> None:
1116 super().__init__(*args, default=True, **kwargs)
1118 # noinspection PyUnusedLocal
1119 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
1120 _ = self.gettext
1121 self.title = _("Use server index?")
1122 self.label = _("Use server index? (Default is true; much faster.)")
1125# -----------------------------------------------------------------------------
1126# ID numbers
1127# -----------------------------------------------------------------------------
1130class MandatoryWhichIdNumSelector(SchemaNode, RequestAwareMixin):
1131 """
1132 Node to enforce the choice of a single ID number type (e.g. "NHS number"
1133 or "study Blah ID number").
1134 """
1136 widget = SelectWidget()
1138 def __init__(self, *args: Any, **kwargs: Any) -> None:
1139 if not hasattr(self, "allow_none"):
1140 # ... allows parameter-free (!) inheritance by
1141 # OptionalWhichIdNumSelector
1142 self.allow_none = False
1143 self.title = "" # for type checker
1144 self.description = "" # for type checker
1145 self.validator = None # type: Optional[ValidatorType]
1146 super().__init__(*args, **kwargs)
1148 # noinspection PyUnusedLocal
1149 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
1150 request = self.request
1151 _ = request.gettext
1152 self.title = _("Identifier")
1153 values = [] # type: List[Tuple[Optional[int], str]]
1154 for iddef in request.idnum_definitions:
1155 values.append((iddef.which_idnum, iddef.description))
1156 values, pv = get_values_and_permissible(
1157 values, self.allow_none, _("[ignore]")
1158 )
1159 # ... can't use None, because SelectWidget() will convert that to
1160 # "None"; can't use colander.null, because that converts to
1161 # "<colander.null>"; use "", which is the default null_value of
1162 # SelectWidget.
1163 self.widget.values = values
1164 self.validator = OneOf(pv)
1166 @staticmethod
1167 def schema_type() -> SchemaType:
1168 return Integer()
1171class LinkingIdNumSelector(MandatoryWhichIdNumSelector):
1172 """
1173 Convenience node: pick a single ID number, with title/description
1174 indicating that it's the ID number to link on.
1175 """
1177 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
1178 super().after_bind(node, kw)
1179 _ = self.gettext
1180 self.title = _("Linking ID number")
1181 self.description = _("Which ID number to link on?")
1184class MandatoryIdNumValue(SchemaNode, RequestAwareMixin):
1185 """
1186 Mandatory node to capture an ID number value.
1187 """
1189 schema_type = Integer
1190 validator = Range(min=0)
1192 def __init__(self, *args: Any, **kwargs: Any) -> None:
1193 self.title = "" # for type checker
1194 super().__init__(*args, **kwargs)
1196 # noinspection PyUnusedLocal
1197 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
1198 _ = self.gettext
1199 self.title = _("ID# value")
1202class MandatoryIdNumNode(MappingSchema, RequestAwareMixin):
1203 """
1204 Mandatory node to capture an ID number type and the associated actual
1205 ID number (value).
1207 This is also where we apply ID number validation rules (e.g. NHS number).
1208 """
1210 which_idnum = (
1211 MandatoryWhichIdNumSelector()
1212 ) # must match ViewParam.WHICH_IDNUM
1213 idnum_value = MandatoryIdNumValue() # must match ViewParam.IDNUM_VALUE
1215 def __init__(self, *args: Any, **kwargs: Any) -> None:
1216 self.title = "" # for type checker
1217 super().__init__(*args, **kwargs)
1219 # noinspection PyUnusedLocal
1220 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
1221 _ = self.gettext
1222 self.title = _("ID number")
1224 # noinspection PyMethodMayBeStatic
1225 def validator(self, node: SchemaNode, value: Dict[str, int]) -> None:
1226 assert isinstance(value, dict)
1227 req = self.request
1228 _ = req.gettext
1229 which_idnum = value[ViewParam.WHICH_IDNUM]
1230 idnum_value = value[ViewParam.IDNUM_VALUE]
1231 idnum_def = req.get_idnum_definition(which_idnum)
1232 if not idnum_def:
1233 raise Invalid(node, _("Bad ID number type")) # shouldn't happen
1234 method = idnum_def.validation_method
1235 if method:
1236 valid, why_invalid = validate_id_number(req, idnum_value, method)
1237 if not valid:
1238 raise Invalid(node, why_invalid)
1241class IdNumSequenceAnyCombination(SequenceSchema, RequestAwareMixin):
1242 """
1243 Sequence to capture multiple ID numbers (as type/value pairs).
1244 """
1246 idnum_sequence = MandatoryIdNumNode()
1248 def __init__(self, *args: Any, **kwargs: Any) -> None:
1249 self.title = "" # for type checker
1250 self.widget = None # type: Optional[Widget]
1251 super().__init__(*args, **kwargs)
1253 # noinspection PyUnusedLocal
1254 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
1255 _ = self.gettext
1256 self.title = _("ID numbers")
1257 self.widget = TranslatableSequenceWidget(request=self.request)
1259 # noinspection PyMethodMayBeStatic
1260 def validator(self, node: SchemaNode, value: List[Dict[str, int]]) -> None:
1261 assert isinstance(value, list)
1262 list_of_lists = [
1263 (x[ViewParam.WHICH_IDNUM], x[ViewParam.IDNUM_VALUE]) for x in value
1264 ]
1265 if len(list_of_lists) != len(set(list_of_lists)):
1266 _ = self.gettext
1267 raise Invalid(
1268 node, _("You have specified duplicate ID definitions")
1269 )
1272class IdNumSequenceUniquePerWhichIdnum(SequenceSchema, RequestAwareMixin):
1273 """
1274 Sequence to capture multiple ID numbers (as type/value pairs) but with only
1275 up to one per ID number type.
1276 """
1278 idnum_sequence = MandatoryIdNumNode()
1280 def __init__(self, *args: Any, **kwargs: Any) -> None:
1281 self.title = "" # for type checker
1282 self.widget = None # type: Optional[Widget]
1283 super().__init__(*args, **kwargs)
1285 # noinspection PyUnusedLocal
1286 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
1287 _ = self.gettext
1288 self.title = _("ID numbers")
1289 self.widget = TranslatableSequenceWidget(request=self.request)
1291 # noinspection PyMethodMayBeStatic
1292 def validator(self, node: SchemaNode, value: List[Dict[str, int]]) -> None:
1293 assert isinstance(value, list)
1294 which_idnums = [x[ViewParam.WHICH_IDNUM] for x in value]
1295 if len(which_idnums) != len(set(which_idnums)):
1296 _ = self.gettext
1297 raise Invalid(
1298 node, _("You have specified >1 value for one ID number type")
1299 )
1302# -----------------------------------------------------------------------------
1303# Sex
1304# -----------------------------------------------------------------------------
1307class OptionalSexSelector(OptionalStringNode, RequestAwareMixin):
1308 """
1309 Optional node to choose sex.
1310 """
1312 def __init__(self, *args: Any, **kwargs: Any) -> None:
1313 self.title = "" # for type checker
1314 self.validator = None # type: Optional[ValidatorType]
1315 self.widget = None # type: Optional[Widget]
1316 super().__init__(*args, **kwargs)
1318 # noinspection PyUnusedLocal
1319 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
1320 _ = self.gettext
1321 self.title = _("Sex")
1322 choices = sex_choices(self.request)
1323 values, pv = get_values_and_permissible(choices, True, _("Any"))
1324 self.widget = RadioChoiceWidget(values=values, inline=True)
1325 self.validator = OneOf(pv)
1328class MandatorySexSelector(MandatoryStringNode, RequestAwareMixin):
1329 """
1330 Mandatory node to choose sex.
1331 """
1333 def __init__(self, *args: Any, **kwargs: Any) -> None:
1334 self.title = "" # for type checker
1335 self.validator = None # type: Optional[ValidatorType]
1336 self.widget = None # type: Optional[Widget]
1337 super().__init__(*args, **kwargs)
1339 # noinspection PyUnusedLocal
1340 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
1341 _ = self.gettext
1342 self.title = _("Sex")
1343 choices = sex_choices(self.request)
1344 values, pv = get_values_and_permissible(choices)
1345 self.widget = RadioChoiceWidget(values=values, inline=True)
1346 self.validator = OneOf(pv)
1349# -----------------------------------------------------------------------------
1350# Users
1351# -----------------------------------------------------------------------------
1354class MandatoryUserIdSelectorUsersAllowedToSee(SchemaNode, RequestAwareMixin):
1355 """
1356 Mandatory node to choose a user, from the users that the requesting user
1357 is allowed to see.
1358 """
1360 schema_type = Integer
1362 def __init__(self, *args: Any, **kwargs: Any) -> None:
1363 self.title = "" # for type checker
1364 self.validator = None # type: Optional[ValidatorType]
1365 self.widget = None # type: Optional[Widget]
1366 super().__init__(*args, **kwargs)
1368 # noinspection PyUnusedLocal
1369 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
1370 from camcops_server.cc_modules.cc_user import User # delayed import
1372 _ = self.gettext
1373 self.title = _("User")
1374 request = self.request
1375 dbsession = request.dbsession
1376 user = request.user
1377 if user.superuser:
1378 users = dbsession.query(User).order_by(User.username)
1379 else:
1380 # Users in my groups, or groups I'm allowed to see
1381 my_allowed_group_ids = user.ids_of_groups_user_may_see
1382 users = (
1383 dbsession.query(User)
1384 .join(Group)
1385 .filter(Group.id.in_(my_allowed_group_ids))
1386 .order_by(User.username)
1387 )
1388 values = [] # type: List[Tuple[Optional[int], str]]
1389 for user in users:
1390 values.append((user.id, user.username))
1391 values, pv = get_values_and_permissible(values, False)
1392 self.widget = SelectWidget(values=values)
1393 self.validator = OneOf(pv)
1396class OptionalUserNameSelector(OptionalStringNode, RequestAwareMixin):
1397 """
1398 Optional node to select a username, from all possible users.
1399 """
1401 title = "User"
1403 def __init__(self, *args: Any, **kwargs: Any) -> None:
1404 self.title = "" # for type checker
1405 self.validator = None # type: Optional[ValidatorType]
1406 self.widget = None # type: Optional[Widget]
1407 super().__init__(*args, **kwargs)
1409 # noinspection PyUnusedLocal
1410 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
1411 from camcops_server.cc_modules.cc_user import User # delayed import
1413 _ = self.gettext
1414 self.title = _("User")
1415 request = self.request
1416 dbsession = request.dbsession
1417 values = [] # type: List[Tuple[str, str]]
1418 users = dbsession.query(User).order_by(User.username)
1419 for user in users:
1420 values.append((user.username, user.username))
1421 values, pv = get_values_and_permissible(values, True, _("[ignore]"))
1422 self.widget = SelectWidget(values=values)
1423 self.validator = OneOf(pv)
1426class UsernameNode(SchemaNode, RequestAwareMixin):
1427 """
1428 Node to enter a username.
1429 """
1431 schema_type = String
1432 widget = TextInputWidget(
1433 attributes={AUTOCOMPLETE_ATTR: AutocompleteAttrValues.OFF}
1434 )
1436 def __init__(
1437 self,
1438 *args: Any,
1439 autocomplete: str = AutocompleteAttrValues.OFF,
1440 **kwargs: Any,
1441 ) -> None:
1442 self.title = "" # for type checker
1443 self.autocomplete = autocomplete
1444 super().__init__(*args, **kwargs)
1446 # noinspection PyUnusedLocal
1447 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
1448 _ = self.gettext
1449 self.title = _("Username")
1450 # noinspection PyUnresolvedReferences
1451 self.widget.attributes[AUTOCOMPLETE_ATTR] = self.autocomplete
1453 def validator(self, node: SchemaNode, value: str) -> None:
1454 if value == USER_NAME_FOR_SYSTEM:
1455 _ = self.gettext
1456 raise Invalid(
1457 node,
1458 _("Cannot use system username")
1459 + " "
1460 + repr(USER_NAME_FOR_SYSTEM),
1461 )
1462 try:
1463 validate_username(value, self.request)
1464 except ValueError as e:
1465 raise Invalid(node, str(e))
1468class UserFilterSchema(Schema, RequestAwareMixin):
1469 """
1470 Schema to filter the list of users
1471 """
1473 # must match ViewParam.INCLUDE_AUTO_GENERATED
1474 include_auto_generated = BooleanNode()
1476 # noinspection PyUnusedLocal
1477 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
1478 _ = self.gettext
1479 include_auto_generated = get_child_node(self, "include_auto_generated")
1480 include_auto_generated.title = _("Include auto-generated users")
1481 include_auto_generated.label = None # type: ignore[attr-defined]
1484class UserFilterForm(InformativeNonceForm):
1485 """
1486 Form to filter the list of users
1487 """
1489 def __init__(self, request: "CamcopsRequest", **kwargs: Any) -> None:
1490 _ = request.gettext
1491 schema = UserFilterSchema().bind(request=request)
1492 super().__init__(
1493 schema,
1494 buttons=[Button(name=FormAction.SET_FILTERS, title=_("Refresh"))],
1495 css_class=BootstrapCssClasses.FORM_INLINE,
1496 method=HttpMethod.GET,
1497 **kwargs,
1498 )
1501# -----------------------------------------------------------------------------
1502# Devices
1503# -----------------------------------------------------------------------------
1506class MandatoryDeviceIdSelector(SchemaNode, RequestAwareMixin):
1507 """
1508 Mandatory node to select a client device ID.
1509 """
1511 schema_type = Integer
1513 def __init__(self, *args: Any, **kwargs: Any) -> None:
1514 self.title = "" # for type checker
1515 self.validator = None # type: Optional[ValidatorType]
1516 self.widget = None # type: Optional[Widget]
1517 super().__init__(*args, **kwargs)
1519 # noinspection PyUnusedLocal
1520 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
1521 from camcops_server.cc_modules.cc_device import (
1522 Device,
1523 ) # delayed import
1525 _ = self.gettext
1526 self.title = _("Device")
1527 request = self.request
1528 dbsession = request.dbsession
1529 devices = dbsession.query(Device).order_by(Device.friendly_name)
1530 values = [] # type: List[Tuple[Optional[int], str]]
1531 for device in devices:
1532 values.append((device.id, device.friendly_name))
1533 values, pv = get_values_and_permissible(values, False)
1534 self.widget = SelectWidget(values=values)
1535 self.validator = OneOf(pv)
1538# -----------------------------------------------------------------------------
1539# Server PK
1540# -----------------------------------------------------------------------------
1543class ServerPkSelector(OptionalIntNode, RequestAwareMixin):
1544 """
1545 Optional node to request an integer, marked as a server PK.
1546 """
1548 def __init__(self, *args: Any, **kwargs: Any) -> None:
1549 self.title = "" # for type checker
1550 super().__init__(*args, **kwargs)
1552 # noinspection PyUnusedLocal
1553 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
1554 _ = self.gettext
1555 self.title = _("Server PK")
1558# -----------------------------------------------------------------------------
1559# Dates/times
1560# -----------------------------------------------------------------------------
1563class StartPendulumSelector(
1564 TranslatableOptionalPendulumNode, RequestAwareMixin
1565):
1566 """
1567 Optional node to select a start date/time.
1568 """
1570 def __init__(self, *args: Any, **kwargs: Any) -> None:
1571 self.title = "" # for type checker
1572 super().__init__(*args, **kwargs)
1574 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
1575 super().after_bind(node, kw)
1576 _ = self.gettext
1577 self.title = _("Start date/time (local timezone; inclusive)")
1580class EndPendulumSelector(TranslatableOptionalPendulumNode, RequestAwareMixin):
1581 """
1582 Optional node to select an end date/time.
1583 """
1585 def __init__(self, *args: Any, **kwargs: Any) -> None:
1586 self.title = "" # for type checker
1587 super().__init__(*args, **kwargs)
1589 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
1590 super().after_bind(node, kw)
1591 _ = self.gettext
1592 self.title = _("End date/time (local timezone; exclusive)")
1595class StartDateTimeSelector(
1596 TranslatableDateTimeSelectorNode, RequestAwareMixin
1597):
1598 """
1599 Optional node to select a start date/time (in UTC).
1600 """
1602 def __init__(self, *args: Any, **kwargs: Any) -> None:
1603 self.title = "" # for type checker
1604 super().__init__(*args, **kwargs)
1606 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
1607 super().after_bind(node, kw)
1608 _ = self.gettext
1609 self.title = _("Start date/time (UTC; inclusive)")
1612class EndDateTimeSelector(TranslatableDateTimeSelectorNode, RequestAwareMixin):
1613 """
1614 Optional node to select an end date/time (in UTC).
1615 """
1617 def __init__(self, *args: Any, **kwargs: Any) -> None:
1618 self.title = "" # for type checker
1619 super().__init__(*args, **kwargs)
1621 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
1622 super().after_bind(node, kw)
1623 _ = self.gettext
1624 self.title = _("End date/time (UTC; exclusive)")
1627'''
1628class StartDateSelector(TranslatableDateSelectorNode,
1629 RequestAwareMixin):
1630 """
1631 Optional node to select a start date (in UTC).
1632 """
1633 def __init__(self, *args: Any, **kwargs: Any) -> None:
1634 self.title = "" # for type checker
1635 super().__init__(*args, **kwargs)
1637 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
1638 super().after_bind(node, kw)
1639 _ = self.gettext
1640 self.title = _("Start date (UTC; inclusive)")
1643class EndDateSelector(TranslatableDateSelectorNode,
1644 RequestAwareMixin):
1645 """
1646 Optional node to select an end date (in UTC).
1647 """
1648 def __init__(self, *args: Any, **kwargs) -> None:
1649 self.title = "" # for type checker
1650 super().__init__(*args, **kwargs)
1652 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
1653 super().after_bind(node, kw)
1654 _ = self.gettext
1655 self.title = _("End date (UTC; inclusive)")
1656'''
1659# -----------------------------------------------------------------------------
1660# Rows per page
1661# -----------------------------------------------------------------------------
1664class RowsPerPageSelector(SchemaNode, RequestAwareMixin):
1665 """
1666 Node to select how many rows per page are shown.
1667 """
1669 _choices = ((10, "10"), (25, "25"), (50, "50"), (100, "100"))
1671 schema_type = Integer
1672 default = DEFAULT_ROWS_PER_PAGE
1673 widget = RadioChoiceWidget(values=_choices)
1674 validator = OneOf(list(x[0] for x in _choices))
1676 def __init__(self, *args: Any, **kwargs: Any) -> None:
1677 self.title = "" # for type checker
1678 super().__init__(*args, **kwargs)
1680 # noinspection PyUnusedLocal
1681 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
1682 _ = self.gettext
1683 self.title = _("Items to show per page")
1686# -----------------------------------------------------------------------------
1687# Groups
1688# -----------------------------------------------------------------------------
1691class MandatoryGroupIdSelectorAllGroups(SchemaNode, RequestAwareMixin):
1692 """
1693 Offers a picklist of groups from ALL POSSIBLE GROUPS.
1694 Used by superusers: "add user to any group".
1695 """
1697 def __init__(self, *args: Any, **kwargs: Any) -> None:
1698 self.title = "" # for type checker
1699 self.validator = None # type: Optional[ValidatorType]
1700 self.widget = None # type: Optional[Widget]
1701 super().__init__(*args, **kwargs)
1703 # noinspection PyUnusedLocal
1704 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
1705 _ = self.gettext
1706 self.title = _("Group")
1707 request = self.request
1708 dbsession = request.dbsession
1709 groups = dbsession.query(Group).order_by(Group.name)
1710 values = [(g.id, g.name) for g in groups]
1711 values, pv = get_values_and_permissible(values)
1712 self.widget = SelectWidget(values=values)
1713 self.validator = OneOf(pv)
1715 @staticmethod
1716 def schema_type() -> SchemaType:
1717 return Integer()
1720class MandatoryGroupIdSelectorAdministeredGroups(
1721 SchemaNode, RequestAwareMixin
1722):
1723 """
1724 Offers a picklist of groups from GROUPS ADMINISTERED BY REQUESTOR.
1725 Used by groupadmins: "add user to one of my groups".
1726 """
1728 def __init__(self, *args: Any, **kwargs: Any) -> None:
1729 self.title = "" # for type checker
1730 self.validator = None # type: Optional[ValidatorType]
1731 self.widget = None # type: Optional[Widget]
1732 super().__init__(*args, **kwargs)
1734 # noinspection PyUnusedLocal
1735 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
1736 _ = self.gettext
1737 self.title = _("Group")
1738 request = self.request
1739 dbsession = request.dbsession
1740 administered_group_ids = request.user.ids_of_groups_user_is_admin_for
1741 groups = dbsession.query(Group).order_by(Group.name)
1742 values = [
1743 (g.id, g.name) for g in groups if g.id in administered_group_ids
1744 ]
1745 values, pv = get_values_and_permissible(values)
1746 self.widget = SelectWidget(values=values)
1747 self.validator = OneOf(pv)
1749 @staticmethod
1750 def schema_type() -> SchemaType:
1751 return Integer()
1754class MandatoryGroupIdSelectorPatientGroups(SchemaNode, RequestAwareMixin):
1755 """
1756 Offers a picklist of groups the user can manage patients in.
1757 Used when managing patients: "add patient to one of my groups".
1758 """
1760 def __init__(self, *args: Any, **kwargs: Any) -> None:
1761 self.title = "" # for type checker
1762 self.validator = None # type: Optional[ValidatorType]
1763 self.widget = None # type: Optional[Widget]
1764 super().__init__(*args, **kwargs)
1766 # noinspection PyUnusedLocal
1767 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
1768 _ = self.gettext
1769 self.title = _("Group")
1770 request = self.request
1771 dbsession = request.dbsession
1772 group_ids = request.user.ids_of_groups_user_may_manage_patients_in
1773 groups = dbsession.query(Group).order_by(Group.name)
1774 values = [(g.id, g.name) for g in groups if g.id in group_ids]
1775 values, pv = get_values_and_permissible(values)
1776 self.widget = SelectWidget(values=values)
1777 self.validator = OneOf(pv)
1779 @staticmethod
1780 def schema_type() -> SchemaType:
1781 return Integer()
1784class MandatoryGroupIdSelectorOtherGroups(SchemaNode, RequestAwareMixin):
1785 """
1786 Offers a picklist of groups THAT ARE NOT THE SPECIFIED GROUP (as specified
1787 in ``kw[Binding.GROUP]``).
1788 Used by superusers: "which other groups can this group see?"
1789 """
1791 def __init__(self, *args: Any, **kwargs: Any) -> None:
1792 self.title = "" # for type checker
1793 self.validator = None # type: Optional[ValidatorType]
1794 self.widget = None # type: Optional[Widget]
1795 super().__init__(*args, **kwargs)
1797 # noinspection PyUnusedLocal
1798 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
1799 _ = self.gettext
1800 self.title = _("Other group")
1801 request = self.request
1802 group = kw[Binding.GROUP] # type: Group # ATYPICAL BINDING
1803 dbsession = request.dbsession
1804 groups = dbsession.query(Group).order_by(Group.name)
1805 values = [(g.id, g.name) for g in groups if g.id != group.id]
1806 values, pv = get_values_and_permissible(values)
1807 self.widget = SelectWidget(values=values)
1808 self.validator = OneOf(pv)
1810 @staticmethod
1811 def schema_type() -> SchemaType:
1812 return Integer()
1815class MandatoryGroupIdSelectorUserGroups(SchemaNode, RequestAwareMixin):
1816 """
1817 Offers a picklist of groups from THOSE THE USER IS A MEMBER OF.
1818 Used for: "which of your groups do you want to upload into?"
1819 """
1821 def __init__(self, *args: Any, **kwargs: Any) -> None:
1822 if not hasattr(self, "allow_none"):
1823 # ... allows parameter-free (!) inheritance by
1824 # OptionalGroupIdSelectorUserGroups
1825 self.allow_none = False
1826 self.title = "" # for type checker
1827 self.validator = None # type: Optional[ValidatorType]
1828 self.widget = None # type: Optional[Widget]
1829 super().__init__(*args, **kwargs)
1831 # noinspection PyUnusedLocal
1832 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
1833 _ = self.gettext
1834 self.title = _("Group")
1835 user = kw[Binding.USER] # type: User # ATYPICAL BINDING
1836 groups = sorted(list(user.groups), key=lambda g: g.name)
1837 values = [(g.id, g.name) for g in groups]
1838 values, pv = get_values_and_permissible(
1839 values, self.allow_none, _("[None]")
1840 )
1841 self.widget = SelectWidget(values=values)
1842 self.validator = OneOf(pv)
1844 @staticmethod
1845 def schema_type() -> SchemaType:
1846 return Integer()
1849class OptionalGroupIdSelectorUserGroups(MandatoryGroupIdSelectorUserGroups):
1850 """
1851 Offers a picklist of groups from THOSE THE USER IS A MEMBER OF.
1852 Used for "which do you want to upload into?". Optional.
1853 """
1855 default = None
1856 missing = None
1858 def __init__(self, *args: Any, **kwargs: Any) -> None:
1859 self.allow_none = True
1860 super().__init__(*args, **kwargs)
1862 @staticmethod
1863 def schema_type() -> SchemaType:
1864 return AllowNoneType(Integer())
1867class MandatoryGroupIdSelectorAllowedGroups(SchemaNode, RequestAwareMixin):
1868 """
1869 Offers a picklist of groups from THOSE THE USER IS ALLOWED TO SEE.
1870 Used for task filters.
1871 """
1873 def __init__(self, *args: Any, **kwargs: Any) -> None:
1874 self.title = "" # for type checker
1875 self.validator = None # type: Optional[ValidatorType]
1876 self.widget = None # type: Optional[Widget]
1877 super().__init__(*args, **kwargs)
1879 # noinspection PyUnusedLocal
1880 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
1881 _ = self.gettext
1882 self.title = _("Group")
1883 request = self.request
1884 dbsession = request.dbsession
1885 user = request.user
1886 if user.superuser:
1887 groups = dbsession.query(Group).order_by(Group.name)
1888 else:
1889 groups = sorted(list(user.groups), key=lambda g: g.name)
1890 values = [(g.id, g.name) for g in groups]
1891 values, pv = get_values_and_permissible(values)
1892 self.widget = SelectWidget(values=values)
1893 self.validator = OneOf(pv)
1895 @staticmethod
1896 def schema_type() -> SchemaType:
1897 return Integer()
1900class GroupsSequenceBase(SequenceSchema, RequestAwareMixin):
1901 """
1902 Sequence schema to capture zero or more non-duplicate groups.
1903 """
1905 def __init__(
1906 self, *args: Any, minimum_number: int = 0, **kwargs: Any
1907 ) -> None:
1908 self.title = "" # for type checker
1909 self.minimum_number = minimum_number
1910 self.widget = None # type: Optional[Widget]
1911 super().__init__(*args, **kwargs)
1913 # noinspection PyUnusedLocal
1914 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
1915 _ = self.gettext
1916 self.title = _("Groups")
1917 self.widget = TranslatableSequenceWidget(request=self.request)
1919 # noinspection PyMethodMayBeStatic
1920 def validator(self, node: SchemaNode, value: List[int]) -> None:
1921 assert isinstance(value, list)
1922 _ = self.gettext
1923 if len(value) != len(set(value)):
1924 raise Invalid(node, _("You have specified duplicate groups"))
1925 if len(value) < self.minimum_number:
1926 raise Invalid(
1927 node,
1928 _("You must specify at least {} group(s)").format(
1929 self.minimum_number
1930 ),
1931 )
1934class AllGroupsSequence(GroupsSequenceBase):
1935 """
1936 Sequence to offer a choice of all possible groups.
1938 Typical use: superuser assigns group memberships to a user.
1939 """
1941 group_id_sequence = MandatoryGroupIdSelectorAllGroups()
1944class AdministeredGroupsSequence(GroupsSequenceBase):
1945 """
1946 Sequence to offer a choice of the groups administered by the requestor.
1948 Typical use: (non-superuser) group administrator assigns group memberships
1949 to a user.
1950 """
1952 group_id_sequence = MandatoryGroupIdSelectorAdministeredGroups()
1954 def __init__(self, *args: Any, **kwargs: Any) -> None:
1955 super().__init__(*args, minimum_number=1, **kwargs)
1958class AllOtherGroupsSequence(GroupsSequenceBase):
1959 """
1960 Sequence to offer a choice of all possible OTHER groups (as determined
1961 relative to the group specified in ``kw[Binding.GROUP]``).
1963 Typical use: superuser assigns group permissions to another group.
1964 """
1966 group_id_sequence = MandatoryGroupIdSelectorOtherGroups()
1969class AllowedGroupsSequence(GroupsSequenceBase):
1970 """
1971 Sequence to offer a choice of all the groups the user is allowed to see.
1972 """
1974 group_id_sequence = MandatoryGroupIdSelectorAllowedGroups()
1976 def __init__(self, *args: Any, **kwargs: Any) -> None:
1977 self.description = "" # for type checker
1978 super().__init__(*args, **kwargs)
1980 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
1981 super().after_bind(node, kw)
1982 self.description = self.or_join_description
1985# -----------------------------------------------------------------------------
1986# Languages (strictly, locales)
1987# -----------------------------------------------------------------------------
1990class LanguageSelector(SchemaNode, RequestAwareMixin):
1991 """
1992 Node to choose a language code, from those supported by the server.
1993 """
1995 _choices = POSSIBLE_LOCALES_WITH_DESCRIPTIONS
1996 schema_type = String
1997 default = DEFAULT_LOCALE
1998 missing = DEFAULT_LOCALE
1999 widget = SelectWidget(values=_choices) # intrinsically translated!
2000 validator = OneOf(POSSIBLE_LOCALES)
2002 def __init__(self, *args: Any, **kwargs: Any) -> None:
2003 self.title = "" # for type checker
2004 super().__init__(*args, **kwargs)
2006 # noinspection PyUnusedLocal
2007 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
2008 _ = self.gettext
2009 self.title = _("Group")
2010 request = self.request # noqa: F841
2011 self.title = _("Language")
2014# -----------------------------------------------------------------------------
2015# Validating dangerous operations
2016# -----------------------------------------------------------------------------
2019class HardWorkConfirmationSchema(CSRFSchema):
2020 """
2021 Schema to make it hard to do something. We require a pattern of yes/no
2022 answers before we will proceed.
2023 """
2025 confirm_1_t = BooleanNode(default=False)
2026 confirm_2_t = BooleanNode(default=True)
2027 confirm_3_f = BooleanNode(default=True)
2028 confirm_4_t = BooleanNode(default=False)
2030 # noinspection PyUnusedLocal
2031 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
2032 _ = self.gettext
2033 confirm_1_t = get_child_node(self, "confirm_1_t")
2034 confirm_1_t.title = _("Really?")
2035 confirm_2_t = get_child_node(self, "confirm_2_t")
2036 # TRANSLATOR: string context described here
2037 confirm_2_t.title = _("Leave ticked to confirm")
2038 confirm_3_f = get_child_node(self, "confirm_3_f")
2039 confirm_3_f.title = _("Please untick to confirm")
2040 confirm_4_t = get_child_node(self, "confirm_4_t")
2041 confirm_4_t.title = _("Be really sure; tick here also to confirm")
2043 # noinspection PyMethodMayBeStatic
2044 def validator(self, node: SchemaNode, value: Any) -> None:
2045 if (
2046 (not value["confirm_1_t"])
2047 or (not value["confirm_2_t"])
2048 or value["confirm_3_f"]
2049 or (not value["confirm_4_t"])
2050 ):
2051 _ = self.gettext
2052 raise Invalid(node, _("Not fully confirmed"))
2055# -----------------------------------------------------------------------------
2056# URLs
2057# -----------------------------------------------------------------------------
2060class HiddenRedirectionUrlNode(HiddenStringNode, RequestAwareMixin):
2061 """
2062 Note to encode a hidden URL, for redirection.
2063 """
2065 # noinspection PyMethodMayBeStatic
2066 def validator(self, node: SchemaNode, value: str) -> None:
2067 if value:
2068 try:
2069 validate_redirect_url(value, self.request)
2070 except ValueError:
2071 _ = self.gettext
2072 raise Invalid(node, _("Invalid redirection URL"))
2075# -----------------------------------------------------------------------------
2076# Phone number
2077# -----------------------------------------------------------------------------
2080class PhoneNumberType(String):
2081 def __init__(
2082 self, request: "CamcopsRequest", *args: Any, **kwargs: Any
2083 ) -> None:
2084 super().__init__(*args, **kwargs)
2086 self.request = request
2088 # noinspection PyMethodMayBeStatic, PyUnusedLocal
2089 def deserialize(
2090 self, node: SchemaNode, cstruct: Union[str, ColanderNullType, None]
2091 ) -> Optional[phonenumbers.PhoneNumber]:
2092 request = self.request # type: CamcopsRequest
2093 _ = request.gettext
2094 err_message = _("Invalid phone number")
2096 # is null when form is empty
2097 if not cstruct:
2098 if not self.allow_empty:
2099 raise Invalid(node, err_message)
2100 return null
2102 cstruct: str
2104 try:
2105 phone_number = phonenumbers.parse(
2106 cstruct, request.config.region_code
2107 )
2108 except phonenumbers.NumberParseException:
2109 raise Invalid(node, err_message)
2111 if not phonenumbers.is_valid_number(phone_number):
2112 # the number may parse but could still be invalid
2113 # (e.g. too few digits)
2114 raise Invalid(node, err_message)
2116 return phone_number
2118 # noinspection PyMethodMayBeStatic,PyUnusedLocal
2119 def serialize(
2120 self,
2121 node: SchemaNode,
2122 appstruct: Union[phonenumbers.PhoneNumber, None, ColanderNullType],
2123 ) -> Union[str, ColanderNullType]:
2124 # is None when populated from empty value in the database
2125 if not appstruct:
2126 return null
2128 # appstruct should be well formed here (it would already have failed
2129 # when reading from the database)
2130 return phonenumbers.format_number(
2131 appstruct, phonenumbers.PhoneNumberFormat.E164
2132 )
2135class MandatoryPhoneNumberNode(MandatoryStringNode, RequestAwareMixin):
2136 default = None
2137 missing = None
2139 # noinspection PyUnusedLocal
2140 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
2141 _ = self.gettext
2142 self.title = _("Phone number")
2143 self.typ = PhoneNumberType(self.request, allow_empty=False)
2146# =============================================================================
2147# Login
2148# =============================================================================
2151class LoginSchema(CSRFSchema):
2152 """
2153 Schema to capture login details.
2154 """
2156 username = UsernameNode(
2157 autocomplete=AutocompleteAttrValues.USERNAME
2158 ) # name must match ViewParam.USERNAME
2159 password = SchemaNode( # name must match ViewParam.PASSWORD
2160 String(),
2161 widget=PasswordWidget(
2162 attributes={
2163 AUTOCOMPLETE_ATTR: AutocompleteAttrValues.CURRENT_PASSWORD
2164 }
2165 ),
2166 )
2167 redirect_url = (
2168 HiddenRedirectionUrlNode()
2169 ) # name must match ViewParam.REDIRECT_URL
2171 def __init__(
2172 self, *args: Any, autocomplete_password: bool = True, **kwargs: Any
2173 ) -> None:
2174 self.autocomplete_password = autocomplete_password
2175 super().__init__(*args, **kwargs)
2177 # noinspection PyUnusedLocal
2178 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
2179 _ = self.gettext
2180 password = get_child_node(self, "password")
2181 password.title = _("Password")
2182 password.widget.attributes[AUTOCOMPLETE_ATTR] = (
2183 AutocompleteAttrValues.CURRENT_PASSWORD
2184 if self.autocomplete_password
2185 else AutocompleteAttrValues.OFF
2186 )
2189class LoginForm(InformativeNonceForm):
2190 """
2191 Form to capture login details.
2192 """
2194 def __init__(
2195 self,
2196 request: "CamcopsRequest",
2197 autocomplete_password: bool = True,
2198 **kwargs: Any,
2199 ) -> None:
2200 """
2201 Args:
2202 autocomplete_password:
2203 suggest to the browser that it's OK to store the password for
2204 autocompletion? Note that browsers may ignore this.
2205 """
2206 _ = request.gettext
2207 schema = LoginSchema(autocomplete_password=autocomplete_password).bind(
2208 request=request
2209 )
2210 super().__init__(
2211 schema,
2212 buttons=[Button(name=FormAction.SUBMIT, title=_("Log in"))],
2213 # autocomplete=autocomplete_password,
2214 **kwargs,
2215 )
2216 # Suboptimal: autocomplete_password is not applied to the password
2217 # widget, just to the form; see
2218 # http://stackoverflow.com/questions/2530
2219 # Note that e.g. Chrome may ignore this.
2220 # ... fixed 2020-09-29 by applying autocomplete to LoginSchema.password
2223class OtpSchema(CSRFSchema):
2224 """
2225 Schema to capture a one-time password for Multi-factor Authentication.
2226 """
2228 one_time_password = MandatoryStringNode()
2229 redirect_url = (
2230 HiddenRedirectionUrlNode()
2231 ) # name must match ViewParam.REDIRECT_URL
2233 # noinspection PyUnusedLocal
2234 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
2235 _ = self.gettext
2236 one_time_password = get_child_node(self, "one_time_password")
2237 one_time_password.title = _("Enter the six-digit code")
2240class OtpTokenForm(InformativeNonceForm):
2241 """
2242 Form to capture a one-time password for Multi-factor authentication.
2243 """
2245 def __init__(self, request: "CamcopsRequest", **kwargs: Any) -> None:
2246 _ = request.gettext
2247 schema = OtpSchema().bind(request=request)
2248 super().__init__(
2249 schema,
2250 buttons=[Button(name=FormAction.SUBMIT, title=_("Submit"))],
2251 **kwargs,
2252 )
2255# =============================================================================
2256# Change password
2257# =============================================================================
2260class MustChangePasswordNode(SchemaNode, RequestAwareMixin):
2261 """
2262 Boolean node: must the user change their password?
2263 """
2265 schema_type = Boolean
2266 default = True
2267 missing = True
2269 def __init__(self, *args: Any, **kwargs: Any) -> None:
2270 self.label = "" # for type checker
2271 self.title = "" # for type checker
2272 super().__init__(*args, **kwargs)
2274 # noinspection PyUnusedLocal
2275 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
2276 _ = self.gettext
2277 self.label = _("User must change password at next login")
2278 self.title = _("Must change password at next login?")
2281class OldUserPasswordCheck(SchemaNode, RequestAwareMixin):
2282 """
2283 Schema to capture an old password (for when a password is being changed).
2284 """
2286 schema_type = String
2287 widget = PasswordWidget(
2288 attributes={AUTOCOMPLETE_ATTR: AutocompleteAttrValues.CURRENT_PASSWORD}
2289 )
2291 def __init__(self, *args: Any, **kwargs: Any) -> None:
2292 self.title = "" # for type checker
2293 super().__init__(*args, **kwargs)
2295 # noinspection PyUnusedLocal
2296 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
2297 _ = self.gettext
2298 self.title = _("Old password")
2300 def validator(self, node: SchemaNode, value: str) -> None:
2301 request = self.request
2302 user = request.user
2303 assert user is not None
2304 if not user.is_password_correct(value):
2305 _ = request.gettext
2306 raise Invalid(node, _("Old password incorrect"))
2309class InformationalCheckedPasswordWidget(CheckedPasswordWidget):
2310 """
2311 A more verbose version of Deform's CheckedPasswordWidget
2312 which provides advice on good passwords.
2313 """
2315 basedir = os.path.join(TEMPLATE_DIR, "deform")
2316 readonlydir = os.path.join(basedir, "readonly")
2317 form = "informational_checked_password.pt"
2318 template = os.path.join(basedir, form)
2319 readonly_template = os.path.join(readonlydir, form)
2321 def __init__(self, request: "CamcopsRequest", **kwargs: Any) -> None:
2322 super().__init__(**kwargs)
2323 self.request = request
2325 def get_template_values(
2326 self, field: "Field", cstruct: str, kw: Dict[str, Any]
2327 ) -> Dict[str, Any]:
2328 values = super().get_template_values(field, cstruct, kw)
2330 _ = self.request.gettext
2332 href = "https://www.ncsc.gov.uk/blog-post/three-random-words-or-thinkrandom-0" # noqa: E501
2333 link = f'<a href="{href}">{href}</a>'
2334 password_advice = _("Choose strong passphrases. See {link}").format(
2335 link=link
2336 )
2337 min_password_length = _(
2338 "Minimum password length is {limit} " "characters."
2339 ).format(limit=MINIMUM_PASSWORD_LENGTH)
2341 values.update(
2342 password_advice=password_advice,
2343 min_password_length=min_password_length,
2344 )
2346 return values
2349class NewPasswordNode(SchemaNode, RequestAwareMixin):
2350 """
2351 Node to enter a new password.
2352 """
2354 schema_type = String
2356 def __init__(self, *args: Any, **kwargs: Any) -> None:
2357 self.title = "" # for type checker
2358 self.description = "" # for type checker
2359 super().__init__(*args, **kwargs)
2361 # noinspection PyUnusedLocal
2362 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
2363 _ = self.gettext
2364 self.title = _("New password")
2365 self.description = _("Type the new password and confirm it")
2366 self.widget = InformationalCheckedPasswordWidget(
2367 self.request,
2368 attributes={
2369 AUTOCOMPLETE_ATTR: AutocompleteAttrValues.NEW_PASSWORD
2370 },
2371 )
2373 def validator(self, node: SchemaNode, value: str) -> None:
2374 try:
2375 validate_new_password(value, self.request)
2376 except ValueError as e:
2377 raise Invalid(node, str(e))
2380class ChangeOwnPasswordSchema(CSRFSchema):
2381 """
2382 Schema to change one's own password.
2383 """
2385 old_password = OldUserPasswordCheck()
2386 new_password = NewPasswordNode() # name must match ViewParam.NEW_PASSWORD
2388 def __init__(
2389 self, *args: Any, must_differ: bool = True, **kwargs: Any
2390 ) -> None:
2391 """
2392 Args:
2393 must_differ:
2394 must the new password be different from the old one?
2395 """
2396 self.must_differ = must_differ
2397 super().__init__(*args, **kwargs)
2399 def validator(self, node: SchemaNode, value: Dict[str, str]) -> None:
2400 if self.must_differ and value["new_password"] == value["old_password"]:
2401 _ = self.gettext
2402 raise Invalid(node, _("New password must differ from old"))
2405class ChangeOwnPasswordForm(InformativeNonceForm):
2406 """
2407 Form to change one's own password.
2408 """
2410 def __init__(
2411 self,
2412 request: "CamcopsRequest",
2413 must_differ: bool = True,
2414 **kwargs: Any,
2415 ) -> None:
2416 """
2417 Args:
2418 must_differ:
2419 must the new password be different from the old one?
2420 """
2421 schema = ChangeOwnPasswordSchema(must_differ=must_differ).bind(
2422 request=request
2423 )
2424 super().__init__(
2425 schema,
2426 buttons=[
2427 Button(
2428 name=FormAction.SUBMIT,
2429 title=change_password_title(request),
2430 )
2431 ],
2432 **kwargs,
2433 )
2436class ChangeOtherPasswordSchema(CSRFSchema):
2437 """
2438 Schema to change another user's password.
2439 """
2441 user_id = HiddenIntegerNode() # name must match ViewParam.USER_ID
2442 must_change_password = (
2443 MustChangePasswordNode()
2444 ) # match ViewParam.MUST_CHANGE_PASSWORD
2445 new_password = NewPasswordNode() # name must match ViewParam.NEW_PASSWORD
2448class ChangeOtherPasswordForm(SimpleSubmitForm):
2449 """
2450 Form to change another user's password.
2451 """
2453 def __init__(self, request: "CamcopsRequest", **kwargs: Any) -> None:
2454 _ = request.gettext
2455 super().__init__(
2456 schema_class=ChangeOtherPasswordSchema,
2457 submit_title=_("Submit"),
2458 request=request,
2459 **kwargs,
2460 )
2463class DisableMfaNode(SchemaNode, RequestAwareMixin):
2464 """
2465 Boolean node: disable multi-factor authentication
2466 """
2468 schema_type = Boolean
2469 default = False
2470 missing = False
2472 def __init__(self, *args: Any, **kwargs: Any) -> None:
2473 self.label = "" # for type checker
2474 self.title = "" # for type checker
2475 super().__init__(*args, **kwargs)
2477 # noinspection PyUnusedLocal
2478 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
2479 _ = self.gettext
2480 self.label = _("Disable multi-factor authentication")
2481 self.title = _("Disable multi-factor authentication?")
2484class EditOtherUserMfaSchema(CSRFSchema):
2485 """
2486 Schema to reset multi-factor authentication for another user.
2487 """
2489 user_id = HiddenIntegerNode() # name must match ViewParam.USER_ID
2490 disable_mfa = DisableMfaNode() # match ViewParam.DISABLE_MFA
2493class EditOtherUserMfaForm(SimpleSubmitForm):
2494 """
2495 Form to reset multi-factor authentication for another user.
2496 """
2498 def __init__(self, request: "CamcopsRequest", **kwargs: Any) -> None:
2499 _ = request.gettext
2500 super().__init__(
2501 schema_class=EditOtherUserMfaSchema,
2502 submit_title=_("Submit"),
2503 request=request,
2504 **kwargs,
2505 )
2508# =============================================================================
2509# Multi-factor authentication
2510# =============================================================================
2513class MfaSecretWidget(TextInputWidget):
2514 """
2515 Display the TOTP (authorization app) secret as a QR code and alphanumeric
2516 string.
2517 """
2519 basedir = os.path.join(TEMPLATE_DIR, "deform")
2520 readonlydir = os.path.join(basedir, "readonly")
2521 form = "mfa_secret.pt"
2522 template = os.path.join(basedir, form)
2523 readonly_template = os.path.join(readonlydir, form)
2525 def __init__(self, request: "CamcopsRequest", **kwargs: Any) -> None:
2526 super().__init__(**kwargs)
2527 self.request = request
2529 def serialize(self, field: "Field", cstruct: str, **kw: Any) -> Any:
2530 # cstruct contains the MFA secret key
2531 readonly = kw.get("readonly", self.readonly)
2532 template = readonly and self.readonly_template or self.template
2533 values = self.get_template_values(field, cstruct, kw)
2535 _ = self.request.gettext
2537 factory = qrcode.image.svg.SvgImage
2538 totp = pyotp.totp.TOTP(cstruct)
2539 uri = totp.provisioning_uri(
2540 name=self.request.user.username, issuer_name="CamCOPS"
2541 )
2542 img = qrcode.make(uri, image_factory=factory, box_size=20)
2543 stream = BytesIO()
2544 img.save(stream)
2545 values.update(
2546 open_app=_("Open your authentication app."),
2547 scan_qr_code=_("Add CamCOPS to the app by scanning this QR code:"),
2548 qr_code=stream.getvalue().decode(),
2549 enter_key=_(
2550 "If you can't scan the QR code, enter this key " "instead:"
2551 ),
2552 enter_code=_(
2553 "When prompted, enter the 6-digit code displayed on "
2554 "the app."
2555 ),
2556 )
2558 return field.renderer(template, **values)
2561class MfaSecretNode(OptionalStringNode, RequestAwareMixin):
2562 """
2563 Node to display the TOTP (authorization app) secret as a QR code and
2564 alphanumeric string.
2565 """
2567 schema_type = String
2569 # noinspection PyUnusedLocal,PyAttributeOutsideInit
2570 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
2571 self.widget = MfaSecretWidget(self.request)
2574class MfaMethodSelector(SchemaNode, RequestAwareMixin):
2575 """
2576 Node to select type of authentication
2577 """
2579 schema_type = String
2580 default = MfaMethod.TOTP
2581 missing = MfaMethod.TOTP
2583 def __init__(self, *args: Any, **kwargs: Any) -> None:
2584 self.title = "" # for type checker
2585 self.widget = None # type: Optional[Widget]
2586 self.validator = None # type: Optional[ValidatorType]
2587 super().__init__(*args, **kwargs)
2589 # noinspection PyUnusedLocal
2590 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
2591 _ = self.gettext
2592 self.title = _("Authentication type")
2593 request = self.bindings[Binding.REQUEST] # type: CamcopsRequest
2594 all_mfa_choices = [
2595 (
2596 MfaMethod.TOTP,
2597 _("Use an app such as Google Authenticator or Twilio Authy"),
2598 ),
2599 (MfaMethod.HOTP_EMAIL, _("Send me a code by email")),
2600 (MfaMethod.HOTP_SMS, _("Send me a code by text message")),
2601 (MfaMethod.NO_MFA, _("Disable multi-factor authentication")),
2602 ]
2604 choices = []
2605 for label, description in all_mfa_choices:
2606 if label in request.config.mfa_methods:
2607 choices.append((label, description))
2608 values, pv = get_values_and_permissible(choices)
2610 self.widget = RadioChoiceWidget(values=values)
2611 self.validator = OneOf(pv)
2614class MfaMethodSchema(CSRFSchema):
2615 """
2616 Schema to edit Multi-factor Authentication method.
2617 """
2619 mfa_method = MfaMethodSelector() # must match ViewParam.MFA_METHOD
2621 # noinspection PyUnusedLocal
2622 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
2623 _ = self.gettext
2624 mfa_method = get_child_node(self, "mfa_method")
2625 mfa_method.title = _("How do you wish to authenticate?")
2628class MfaTotpSchema(CSRFSchema):
2629 """
2630 Schema to set up Multi-factor Authentication with authentication app.
2631 """
2633 mfa_secret_key = MfaSecretNode() # must match ViewParam.MFA_SECRET_KEY
2635 # noinspection PyUnusedLocal
2636 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
2637 _ = self.gettext
2638 mfa_secret_key = get_child_node(self, "mfa_secret_key")
2639 mfa_secret_key.title = _("Follow these steps:")
2642class MfaHotpEmailSchema(CSRFSchema):
2643 """
2644 Schema to change a user's email address for multi-factor authentication.
2645 """
2647 mfa_secret_key = HiddenStringNode() # must match ViewParam.MFA_SECRET_KEY
2648 email = MandatoryEmailNode() # must match ViewParam.EMAIL
2650 # noinspection PyUnusedLocal
2651 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
2652 _ = self.gettext
2655class MfaHotpSmsSchema(CSRFSchema):
2656 """
2657 Schema to change a user's phone number for multi-factor authentication.
2658 """
2660 mfa_secret_key = HiddenStringNode() # must match ViewParam.MFA_SECRET_KEY
2661 phone_number = (
2662 MandatoryPhoneNumberNode()
2663 ) # must match ViewParam.PHONE_NUMBER
2665 # noinspection PyUnusedLocal
2666 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
2667 _ = self.gettext
2668 phone_number = get_child_node(self, ViewParam.PHONE_NUMBER)
2669 phone_number.description = _(
2670 "Include the country code (e.g. +123) for numbers outside of the "
2671 "'{region_code}' region"
2672 ).format(region_code=self.request.config.region_code)
2675class MfaMethodForm(InformativeNonceForm):
2676 """
2677 Form to change one's own Multi-factor Authentication settings.
2678 """
2680 def __init__(self, request: "CamcopsRequest", **kwargs: Any) -> None:
2681 schema = MfaMethodSchema().bind(request=request)
2682 super().__init__(
2683 schema, buttons=[Button(name=FormAction.SUBMIT)], **kwargs
2684 )
2687class MfaTotpForm(InformativeNonceForm):
2688 """
2689 Form to set up Multi-factor Authentication with authentication app.
2690 """
2692 def __init__(self, request: "CamcopsRequest", **kwargs: Any) -> None:
2693 schema = MfaTotpSchema().bind(request=request)
2694 super().__init__(
2695 schema, buttons=[Button(name=FormAction.SUBMIT)], **kwargs
2696 )
2699class MfaHotpEmailForm(InformativeNonceForm):
2700 """
2701 Form to change a user's email address for multi-factor authentication.
2702 """
2704 def __init__(self, request: "CamcopsRequest", **kwargs: Any) -> None:
2705 schema = MfaHotpEmailSchema().bind(request=request)
2706 super().__init__(
2707 schema, buttons=[Button(name=FormAction.SUBMIT)], **kwargs
2708 )
2711class MfaHotpSmsForm(InformativeNonceForm):
2712 """
2713 Form to change a user's phone number for multi-factor authentication.
2714 """
2716 def __init__(self, request: "CamcopsRequest", **kwargs: Any) -> None:
2717 schema = MfaHotpSmsSchema().bind(request=request)
2718 super().__init__(
2719 schema, buttons=[Button(name=FormAction.SUBMIT)], **kwargs
2720 )
2723# =============================================================================
2724# Offer/agree terms
2725# =============================================================================
2728class OfferTermsSchema(CSRFSchema):
2729 """
2730 Schema to offer terms and ask the user to accept them.
2731 """
2733 pass
2736class OfferTermsForm(SimpleSubmitForm):
2737 """
2738 Form to offer terms and ask the user to accept them.
2739 """
2741 def __init__(
2742 self, request: "CamcopsRequest", agree_button_text: str, **kwargs: Any
2743 ) -> None:
2744 """
2745 Args:
2746 agree_button_text:
2747 text for the "agree" button
2748 """
2749 super().__init__(
2750 schema_class=OfferTermsSchema,
2751 submit_title=agree_button_text,
2752 request=request,
2753 **kwargs,
2754 )
2757# =============================================================================
2758# View audit trail
2759# =============================================================================
2762class OptionalIPAddressNode(OptionalStringNode, RequestAwareMixin):
2763 """
2764 Optional IPv4 or IPv6 address.
2765 """
2767 def validator(self, node: SchemaNode, value: str) -> None:
2768 try:
2769 validate_ip_address(value, self.request)
2770 except ValueError as e:
2771 raise Invalid(node, e)
2774class OptionalAuditSourceNode(OptionalStringNode, RequestAwareMixin):
2775 """
2776 Optional IPv4 or IPv6 address.
2777 """
2779 def validator(self, node: SchemaNode, value: str) -> None:
2780 try:
2781 validate_by_char_and_length(
2782 value,
2783 permitted_char_expression=ALPHANUM_UNDERSCORE_CHAR,
2784 min_length=0,
2785 max_length=StringLengths.AUDIT_SOURCE_MAX_LEN,
2786 req=self.request,
2787 )
2788 except ValueError as e:
2789 raise Invalid(node, e)
2792class AuditTrailSchema(CSRFSchema):
2793 """
2794 Schema to filter audit trail entries.
2795 """
2797 rows_per_page = RowsPerPageSelector() # must match ViewParam.ROWS_PER_PAGE
2798 start_datetime = (
2799 StartPendulumSelector()
2800 ) # must match ViewParam.START_DATETIME
2801 end_datetime = EndPendulumSelector() # must match ViewParam.END_DATETIME
2802 source = OptionalAuditSourceNode() # must match ViewParam.SOURCE
2803 remote_ip_addr = (
2804 OptionalIPAddressNode()
2805 ) # must match ViewParam.REMOTE_IP_ADDR
2806 username = OptionalUserNameSelector() # must match ViewParam.USERNAME
2807 table_name = OptionalSingleTaskSelector() # must match ViewParam.TABLENAME
2808 server_pk = ServerPkSelector() # must match ViewParam.SERVER_PK
2809 truncate = BooleanNode(default=True) # must match ViewParam.TRUNCATE
2811 # noinspection PyUnusedLocal
2812 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
2813 _ = self.gettext
2814 source = get_child_node(self, "source")
2815 source.title = _("Source (e.g. webviewer, tablet, console)")
2816 remote_ip_addr = get_child_node(self, "remote_ip_addr")
2817 remote_ip_addr.title = _("Remote IP address")
2818 truncate = get_child_node(self, "truncate")
2819 truncate.title = _("Truncate details for easy viewing")
2822class AuditTrailForm(SimpleSubmitForm):
2823 """
2824 Form to filter and then view audit trail entries.
2825 """
2827 def __init__(self, request: "CamcopsRequest", **kwargs: Any) -> None:
2828 _ = request.gettext
2829 super().__init__(
2830 schema_class=AuditTrailSchema,
2831 submit_title=_("View audit trail"),
2832 request=request,
2833 **kwargs,
2834 )
2837# =============================================================================
2838# View export logs
2839# =============================================================================
2842class OptionalExportRecipientNameSelector(
2843 OptionalStringNode, RequestAwareMixin
2844):
2845 """
2846 Optional node to pick an export recipient name from those present in the
2847 database.
2848 """
2850 title = "Export recipient"
2852 def __init__(self, *args: Any, **kwargs: Any) -> None:
2853 self.validator = None # type: Optional[ValidatorType]
2854 self.widget = None # type: Optional[Widget]
2855 super().__init__(*args, **kwargs)
2857 # noinspection PyUnusedLocal
2858 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
2859 from camcops_server.cc_modules.cc_exportrecipient import (
2860 ExportRecipient,
2861 ) # delayed import
2863 request = self.request
2864 _ = request.gettext
2865 dbsession = request.dbsession
2866 q = (
2867 dbsession.query(ExportRecipient.recipient_name)
2868 .distinct()
2869 .order_by(ExportRecipient.recipient_name)
2870 )
2871 values = [] # type: List[Tuple[str, str]]
2872 for row in q:
2873 recipient_name = row[0]
2874 values.append((recipient_name, recipient_name))
2875 values, pv = get_values_and_permissible(values, True, _("[Any]"))
2876 self.widget = SelectWidget(values=values)
2877 self.validator = OneOf(pv)
2880class ExportedTaskListSchema(CSRFSchema):
2881 """
2882 Schema to filter HL7 message logs.
2883 """
2885 rows_per_page = RowsPerPageSelector() # must match ViewParam.ROWS_PER_PAGE
2886 recipient_name = (
2887 OptionalExportRecipientNameSelector()
2888 ) # must match ViewParam.RECIPIENT_NAME
2889 table_name = OptionalSingleTaskSelector() # must match ViewParam.TABLENAME
2890 server_pk = ServerPkSelector() # must match ViewParam.SERVER_PK
2891 id = OptionalIntNode() # must match ViewParam.ID
2892 start_datetime = (
2893 StartDateTimeSelector()
2894 ) # must match ViewParam.START_DATETIME
2895 end_datetime = EndDateTimeSelector() # must match ViewParam.END_DATETIME
2897 # noinspection PyUnusedLocal
2898 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
2899 _ = self.gettext
2900 id_ = get_child_node(self, "id")
2901 id_.title = _("ExportedTask ID")
2904class ExportedTaskListForm(SimpleSubmitForm):
2905 """
2906 Form to filter and then view exported task logs.
2907 """
2909 def __init__(self, request: "CamcopsRequest", **kwargs: Any) -> None:
2910 _ = request.gettext
2911 super().__init__(
2912 schema_class=ExportedTaskListSchema,
2913 submit_title=_("View exported task log"),
2914 request=request,
2915 **kwargs,
2916 )
2919# =============================================================================
2920# Task filters
2921# =============================================================================
2924class TextContentsSequence(SequenceSchema, RequestAwareMixin):
2925 """
2926 Sequence to capture multiple pieces of text (representing text contents
2927 for a task filter).
2928 """
2930 text_sequence = SchemaNode(
2931 String(), validator=Length(0, StringLengths.FILTER_TEXT_MAX_LEN)
2932 ) # BEWARE: fairly unrestricted contents.
2934 def __init__(self, *args: Any, **kwargs: Any) -> None:
2935 self.title = "" # for type checker
2936 self.description = "" # for type checker
2937 self.widget = None # type: Optional[Widget]
2938 super().__init__(*args, **kwargs)
2940 # noinspection PyUnusedLocal
2941 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
2942 _ = self.gettext
2943 self.title = _("Text contents")
2944 self.description = self.or_join_description
2945 self.widget = TranslatableSequenceWidget(request=self.request)
2946 # Now it'll say "[Add]" Text Sequence because it'll make the string
2947 # "Text Sequence" from the name of text_sequence. Unless we do this:
2948 text_sequence = get_child_node(self, "text_sequence")
2949 # TRANSLATOR: For the task filter form: the text in "Add text"
2950 text_sequence.title = _("text")
2952 # noinspection PyMethodMayBeStatic
2953 def validator(self, node: SchemaNode, value: List[str]) -> None:
2954 assert isinstance(value, list)
2955 if len(value) != len(set(value)):
2956 _ = self.gettext
2957 raise Invalid(node, _("You have specified duplicate text filters"))
2960class UploadingUserSequence(SequenceSchema, RequestAwareMixin):
2961 """
2962 Sequence to capture multiple users (for task filters: "uploaded by one of
2963 the following users...").
2964 """
2966 user_id_sequence = MandatoryUserIdSelectorUsersAllowedToSee()
2968 def __init__(self, *args: Any, **kwargs: Any) -> None:
2969 self.title = "" # for type checker
2970 self.description = "" # for type checker
2971 self.widget = None # type: Optional[Widget]
2972 super().__init__(*args, **kwargs)
2974 # noinspection PyUnusedLocal
2975 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
2976 _ = self.gettext
2977 self.title = _("Uploading users")
2978 self.description = self.or_join_description
2979 self.widget = TranslatableSequenceWidget(request=self.request)
2981 # noinspection PyMethodMayBeStatic
2982 def validator(self, node: SchemaNode, value: List[int]) -> None:
2983 assert isinstance(value, list)
2984 if len(value) != len(set(value)):
2985 _ = self.gettext
2986 raise Invalid(node, _("You have specified duplicate users"))
2989class DevicesSequence(SequenceSchema, RequestAwareMixin):
2990 """
2991 Sequence to capture multiple client devices (for task filters: "uploaded by
2992 one of the following devices...").
2993 """
2995 device_id_sequence = MandatoryDeviceIdSelector()
2997 def __init__(self, *args: Any, **kwargs: Any) -> None:
2998 self.title = "" # for type checker
2999 self.description = "" # for type checker
3000 self.widget = None # type: Optional[Widget]
3001 super().__init__(*args, **kwargs)
3003 # noinspection PyUnusedLocal
3004 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
3005 _ = self.gettext
3006 self.title = _("Uploading devices")
3007 self.description = self.or_join_description
3008 self.widget = TranslatableSequenceWidget(request=self.request)
3010 # noinspection PyMethodMayBeStatic
3011 def validator(self, node: SchemaNode, value: List[int]) -> None:
3012 assert isinstance(value, list)
3013 if len(value) != len(set(value)):
3014 raise Invalid(node, "You have specified duplicate devices")
3017class OptionalPatientNameNode(OptionalStringNode, RequestAwareMixin):
3018 def validator(self, node: SchemaNode, value: str) -> None:
3019 try:
3020 # TODO: Validating human names is hard.
3021 # Decide if validation here is necessary and whether it should
3022 # be configurable.
3023 # validate_human_name(value, self.request)
3025 # Does nothing but better to be explicit
3026 validate_anything(value, self.request)
3027 except ValueError as e:
3028 # Should never happen with validate_anything
3029 raise Invalid(node, str(e))
3032class EditTaskFilterWhoSchema(Schema, RequestAwareMixin):
3033 """
3034 Schema to edit the "who" parts of a task filter.
3035 """
3037 surname = OptionalPatientNameNode() # must match ViewParam.SURNAME
3038 forename = OptionalPatientNameNode() # must match ViewParam.FORENAME
3039 dob = SchemaNode(Date(), missing=None) # must match ViewParam.DOB
3040 sex = OptionalSexSelector() # must match ViewParam.SEX
3041 id_references = (
3042 IdNumSequenceAnyCombination()
3043 ) # must match ViewParam.ID_REFERENCES
3045 # noinspection PyUnusedLocal
3046 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
3047 _ = self.gettext
3048 surname = get_child_node(self, "surname")
3049 surname.title = _("Surname")
3050 forename = get_child_node(self, "forename")
3051 forename.title = _("Forename")
3052 dob = get_child_node(self, "dob")
3053 dob.title = _("Date of birth")
3054 id_references = get_child_node(self, "id_references")
3055 id_references.description = self.or_join_description
3058class EditTaskFilterWhenSchema(Schema):
3059 """
3060 Schema to edit the "when" parts of a task filter.
3061 """
3063 start_datetime = (
3064 StartPendulumSelector()
3065 ) # must match ViewParam.START_DATETIME
3066 end_datetime = EndPendulumSelector() # must match ViewParam.END_DATETIME
3069class EditTaskFilterWhatSchema(Schema, RequestAwareMixin):
3070 """
3071 Schema to edit the "what" parts of a task filter.
3072 """
3074 text_contents = (
3075 TextContentsSequence()
3076 ) # must match ViewParam.TEXT_CONTENTS
3077 complete_only = BooleanNode(
3078 default=False
3079 ) # must match ViewParam.COMPLETE_ONLY
3080 tasks = MultiTaskSelector() # must match ViewParam.TASKS
3082 # noinspection PyUnusedLocal
3083 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
3084 _ = self.gettext
3085 complete_only = get_child_node(self, "complete_only")
3086 only_completed_text = _("Only completed tasks?")
3087 complete_only.title = only_completed_text
3088 complete_only.label = only_completed_text # type: ignore[attr-defined]
3091class EditTaskFilterAdminSchema(Schema):
3092 """
3093 Schema to edit the "admin" parts of a task filter.
3094 """
3096 device_ids = DevicesSequence() # must match ViewParam.DEVICE_IDS
3097 user_ids = UploadingUserSequence() # must match ViewParam.USER_IDS
3098 group_ids = AllowedGroupsSequence() # must match ViewParam.GROUP_IDS
3101class EditTaskFilterSchema(CSRFSchema):
3102 """
3103 Schema to edit a task filter.
3104 """
3106 who = EditTaskFilterWhoSchema( # must match ViewParam.WHO
3107 widget=MappingWidget(template="mapping_accordion", open=False)
3108 )
3109 what = EditTaskFilterWhatSchema( # must match ViewParam.WHAT
3110 widget=MappingWidget(template="mapping_accordion", open=False)
3111 )
3112 when = EditTaskFilterWhenSchema( # must match ViewParam.WHEN
3113 widget=MappingWidget(template="mapping_accordion", open=False)
3114 )
3115 admin = EditTaskFilterAdminSchema( # must match ViewParam.ADMIN
3116 widget=MappingWidget(template="mapping_accordion", open=False)
3117 )
3119 # noinspection PyUnusedLocal
3120 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
3121 # log.debug("EditTaskFilterSchema.after_bind")
3122 # log.debug("{!r}", self.__dict__)
3123 # This is pretty nasty. By the time we get here, the Form class has
3124 # made Field objects, and, I think, called a clone() function on us.
3125 # Objects like "who" are not in our __dict__ any more. Our __dict__
3126 # looks like:
3127 # {
3128 # 'typ': <colander.Mapping object at 0x7fd7989b18d0>,
3129 # 'bindings': {
3130 # 'open_who': True,
3131 # 'open_when': True,
3132 # 'request': ...,
3133 # },
3134 # '_order': 118,
3135 # 'children': [
3136 # <...CSRFToken object at ... (named csrf)>,
3137 # <...EditTaskFilterWhoSchema object at ... (named who)>,
3138 # ...
3139 # ],
3140 # 'title': ''
3141 # }
3142 _ = self.gettext
3143 who = get_child_node(self, "who")
3144 what = get_child_node(self, "what")
3145 when = get_child_node(self, "when")
3146 admin = get_child_node(self, "admin")
3147 who.title = _("Who")
3148 what.title = _("What")
3149 when.title = _("When")
3150 admin.title = _("Administrative criteria")
3151 # log.debug("who = {!r}", who)
3152 # log.debug("who.__dict__ = {!r}", who.__dict__)
3153 who.widget.open = kw[Binding.OPEN_WHO]
3154 what.widget.open = kw[Binding.OPEN_WHAT]
3155 when.widget.open = kw[Binding.OPEN_WHEN]
3156 admin.widget.open = kw[Binding.OPEN_ADMIN]
3159class EditTaskFilterForm(InformativeNonceForm):
3160 """
3161 Form to edit a task filter.
3162 """
3164 def __init__(
3165 self,
3166 request: "CamcopsRequest",
3167 open_who: bool = False,
3168 open_what: bool = False,
3169 open_when: bool = False,
3170 open_admin: bool = False,
3171 **kwargs: Any,
3172 ) -> None:
3173 _ = request.gettext
3174 schema = EditTaskFilterSchema().bind(
3175 request=request,
3176 open_admin=open_admin,
3177 open_what=open_what,
3178 open_when=open_when,
3179 open_who=open_who,
3180 )
3181 super().__init__(
3182 schema,
3183 buttons=[
3184 Button(name=FormAction.SET_FILTERS, title=_("Set filters")),
3185 Button(name=FormAction.CLEAR_FILTERS, title=_("Clear")),
3186 ],
3187 **kwargs,
3188 )
3191class TasksPerPageSchema(CSRFSchema):
3192 """
3193 Schema to edit the number of rows per page, for the task view.
3194 """
3196 rows_per_page = RowsPerPageSelector() # must match ViewParam.ROWS_PER_PAGE
3199class TasksPerPageForm(InformativeNonceForm):
3200 """
3201 Form to edit the number of tasks per page, for the task view.
3202 """
3204 def __init__(self, request: "CamcopsRequest", **kwargs: Any) -> None:
3205 _ = request.gettext
3206 schema = TasksPerPageSchema().bind(request=request)
3207 super().__init__(
3208 schema,
3209 buttons=[
3210 Button(
3211 name=FormAction.SUBMIT_TASKS_PER_PAGE,
3212 title=_("Set n/page"),
3213 )
3214 ],
3215 css_class=BootstrapCssClasses.FORM_INLINE,
3216 **kwargs,
3217 )
3220class RefreshTasksSchema(CSRFSchema):
3221 """
3222 Schema for a "refresh tasks" button.
3223 """
3225 pass
3228class RefreshTasksForm(InformativeNonceForm):
3229 """
3230 Form for a "refresh tasks" button.
3231 """
3233 def __init__(self, request: "CamcopsRequest", **kwargs: Any) -> None:
3234 _ = request.gettext
3235 schema = RefreshTasksSchema().bind(request=request)
3236 super().__init__(
3237 schema,
3238 buttons=[
3239 Button(name=FormAction.REFRESH_TASKS, title=_("Refresh"))
3240 ],
3241 **kwargs,
3242 )
3245# =============================================================================
3246# Trackers
3247# =============================================================================
3250class TaskTrackerOutputTypeSelector(SchemaNode, RequestAwareMixin):
3251 """
3252 Node to select the output format for a tracker.
3253 """
3255 # Choices don't require translation
3256 _choices = (
3257 (ViewArg.HTML, "HTML"),
3258 (ViewArg.PDF, "PDF"),
3259 (ViewArg.XML, "XML"),
3260 )
3262 schema_type = String
3263 default = ViewArg.HTML
3264 missing = ViewArg.HTML
3265 widget = RadioChoiceWidget(values=_choices)
3266 validator = OneOf(list(x[0] for x in _choices))
3268 def __init__(self, *args: Any, **kwargs: Any) -> None:
3269 self.title = "" # for type checker
3270 super().__init__(*args, **kwargs)
3272 # noinspection PyUnusedLocal
3273 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
3274 _ = self.gettext
3275 self.title = _("View as")
3278class ChooseTrackerSchema(CSRFSchema):
3279 """
3280 Schema to select a tracker or CTV.
3281 """
3283 which_idnum = (
3284 MandatoryWhichIdNumSelector()
3285 ) # must match ViewParam.WHICH_IDNUM
3286 idnum_value = MandatoryIdNumValue() # must match ViewParam.IDNUM_VALUE
3287 start_datetime = (
3288 StartPendulumSelector()
3289 ) # must match ViewParam.START_DATETIME
3290 end_datetime = EndPendulumSelector() # must match ViewParam.END_DATETIME
3291 all_tasks = BooleanNode(default=True) # match ViewParam.ALL_TASKS
3292 tasks = MultiTaskSelector() # must match ViewParam.TASKS
3293 # tracker_tasks_only will be set via the binding
3294 via_index = ViaIndexSelector() # must match ViewParam.VIA_INDEX
3295 viewtype = TaskTrackerOutputTypeSelector() # must match ViewParam.VIEWTYPE
3297 # noinspection PyUnusedLocal
3298 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
3299 _ = self.gettext
3300 all_tasks = get_child_node(self, "all_tasks")
3301 text = _("Use all eligible task types?")
3302 all_tasks.title = text
3303 all_tasks.label = text # type: ignore[attr-defined]
3306class ChooseTrackerForm(InformativeNonceForm):
3307 """
3308 Form to select a tracker or CTV.
3309 """
3311 def __init__(
3312 self, request: "CamcopsRequest", as_ctv: bool, **kwargs: Any
3313 ) -> None:
3314 """
3315 Args:
3316 as_ctv: CTV, not tracker?
3317 """
3318 _ = request.gettext
3319 schema = ChooseTrackerSchema().bind(
3320 request=request, tracker_tasks_only=not as_ctv
3321 )
3322 super().__init__(
3323 schema,
3324 buttons=[
3325 Button(
3326 name=FormAction.SUBMIT,
3327 title=(_("View CTV") if as_ctv else _("View tracker")),
3328 )
3329 ],
3330 **kwargs,
3331 )
3334# =============================================================================
3335# Reports, which use dynamically created forms
3336# =============================================================================
3339class ReportOutputTypeSelector(SchemaNode, RequestAwareMixin):
3340 """
3341 Node to select the output format for a report.
3342 """
3344 schema_type = String
3345 default = ViewArg.HTML
3346 missing = ViewArg.HTML
3348 def __init__(self, *args: Any, **kwargs: Any) -> None:
3349 self.title = "" # for type checker
3350 self.widget = None # type: Optional[Widget]
3351 self.validator = None # type: Optional[ValidatorType]
3352 super().__init__(*args, **kwargs)
3354 # noinspection PyUnusedLocal
3355 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
3356 _ = self.gettext
3357 self.title = _("View as")
3358 choices = self.get_choices()
3359 values, pv = get_values_and_permissible(choices)
3360 self.widget = RadioChoiceWidget(values=choices)
3361 self.validator = OneOf(pv)
3363 def get_choices(self) -> Tuple[Tuple[str, str]]:
3364 _ = self.gettext
3365 # noinspection PyTypeChecker
3366 return ( # type: ignore[return-value]
3367 (ViewArg.HTML, _("HTML")),
3368 (ViewArg.ODS, _("OpenOffice spreadsheet (ODS) file")),
3369 (ViewArg.TSV, _("TSV (tab-separated values)")),
3370 (ViewArg.XLSX, _("XLSX (Microsoft Excel) file")),
3371 )
3374class ReportParamSchema(CSRFSchema):
3375 """
3376 Schema to embed a report type (ID) and output format (view type).
3377 """
3379 viewtype = ReportOutputTypeSelector() # must match ViewParam.VIEWTYPE
3380 report_id = HiddenStringNode() # must match ViewParam.REPORT_ID
3381 # Specific forms may inherit from this.
3384class DateTimeFilteredReportParamSchema(ReportParamSchema):
3385 start_datetime = StartPendulumSelector()
3386 end_datetime = EndPendulumSelector()
3389class ReportParamForm(SimpleSubmitForm):
3390 """
3391 Form to view a specific report. Often derived from, to configure the report
3392 in more detail.
3393 """
3395 def __init__(
3396 self,
3397 request: "CamcopsRequest",
3398 schema_class: Type[ReportParamSchema],
3399 **kwargs: Any,
3400 ) -> None:
3401 _ = request.gettext
3402 super().__init__(
3403 schema_class=schema_class,
3404 submit_title=_("View report"),
3405 request=request,
3406 **kwargs,
3407 )
3410# =============================================================================
3411# View DDL
3412# =============================================================================
3415def get_sql_dialect_choices(
3416 request: "CamcopsRequest",
3417) -> List[Tuple[str, str]]:
3418 _ = request.gettext
3419 return [
3420 # https://docs.sqlalchemy.org/en/latest/dialects/
3421 (SqlaDialectName.MYSQL, "MySQL"),
3422 (SqlaDialectName.MSSQL, "Microsoft SQL Server"),
3423 (SqlaDialectName.ORACLE, "Oracle" + _("[WILL NOT WORK]")),
3424 # ... Oracle doesn't work; SQLAlchemy enforces the Oracle rule of a 30-
3425 # character limit for identifiers, only relaxed to 128 characters in
3426 # Oracle 12.2 (March 2017).
3427 (SqlaDialectName.FIREBIRD, "Firebird"),
3428 (SqlaDialectName.POSTGRES, "PostgreSQL"),
3429 (SqlaDialectName.SQLITE, "SQLite"),
3430 (SqlaDialectName.SYBASE, "Sybase"),
3431 ]
3434class DatabaseDialectSelector(SchemaNode, RequestAwareMixin):
3435 """
3436 Node to choice an SQL dialect (for viewing DDL).
3437 """
3439 schema_type = String
3440 default = SqlaDialectName.MYSQL
3441 missing = SqlaDialectName.MYSQL
3443 def __init__(self, *args: Any, **kwargs: Any) -> None:
3444 self.title = "" # for type checker
3445 self.widget = None # type: Optional[Widget]
3446 self.validator = None # type: Optional[ValidatorType]
3447 super().__init__(*args, **kwargs)
3449 # noinspection PyUnusedLocal
3450 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
3451 _ = self.gettext
3452 self.title = _("SQL dialect to use (not all may be valid)")
3453 choices = get_sql_dialect_choices(self.request)
3454 values, pv = get_values_and_permissible(choices)
3455 self.widget = RadioChoiceWidget(values=values)
3456 self.validator = OneOf(pv)
3459class ViewDdlSchema(CSRFSchema):
3460 """
3461 Schema to choose how to view DDL.
3462 """
3464 dialect = DatabaseDialectSelector() # must match ViewParam.DIALECT
3467class ViewDdlForm(SimpleSubmitForm):
3468 """
3469 Form to choose how to view DDL (and then view it).
3470 """
3472 def __init__(self, request: "CamcopsRequest", **kwargs: Any) -> None:
3473 _ = request.gettext
3474 super().__init__(
3475 schema_class=ViewDdlSchema,
3476 submit_title=_("View DDL"),
3477 request=request,
3478 **kwargs,
3479 )
3482# =============================================================================
3483# Add/edit/delete users
3484# =============================================================================
3487class UserGroupPermissionsGroupAdminSchema(CSRFSchema):
3488 """
3489 Edit group-specific permissions for a user. For group administrators.
3490 """
3492 # Currently the defaults here will be ignored because we don't use this
3493 # schema to create new UserGroupMembership records. The record will already
3494 # exist by the time we see the forms that use this schema. So the database
3495 # defaults will be used instead.
3496 may_upload = BooleanNode(
3497 default=False
3498 ) # match ViewParam.MAY_UPLOAD and User attribute
3499 may_register_devices = BooleanNode(
3500 default=False
3501 ) # match ViewParam.MAY_REGISTER_DEVICES and User attribute
3502 may_use_webviewer = BooleanNode(
3503 default=False
3504 ) # match ViewParam.MAY_USE_WEBVIEWER and User attribute
3505 may_manage_patients = BooleanNode(
3506 default=False
3507 ) # match ViewParam.MAY_MANAGE_PATIENTS
3508 may_email_patients = BooleanNode(
3509 default=False
3510 ) # match ViewParam.MAY_EMAIL_PATIENTS
3511 view_all_patients_when_unfiltered = BooleanNode(
3512 default=False
3513 ) # match ViewParam.VIEW_ALL_PATIENTS_WHEN_UNFILTERED and User attribute # noqa
3514 may_dump_data = BooleanNode(
3515 default=False
3516 ) # match ViewParam.MAY_DUMP_DATA and User attribute
3517 may_run_reports = BooleanNode(
3518 default=False
3519 ) # match ViewParam.MAY_RUN_REPORTS and User attribute
3520 may_add_notes = BooleanNode(
3521 default=False
3522 ) # match ViewParam.MAY_ADD_NOTES and User attribute
3524 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
3525 _ = self.gettext
3526 may_upload = get_child_node(self, "may_upload")
3527 mu_text = _("Permitted to upload from a tablet/device")
3528 may_upload.title = mu_text
3529 may_upload.label = mu_text # type: ignore[attr-defined]
3530 may_register_devices = get_child_node(self, "may_register_devices")
3531 mrd_text = _("Permitted to register tablet/client devices")
3532 may_register_devices.title = mrd_text
3533 may_register_devices.label = mrd_text # type: ignore[attr-defined]
3534 may_use_webviewer = get_child_node(self, "may_use_webviewer")
3535 ml_text = _("May log in to web front end")
3536 may_use_webviewer.title = ml_text
3537 may_use_webviewer.label = ml_text # type: ignore[attr-defined]
3538 may_manage_patients = get_child_node(self, "may_manage_patients")
3539 mmp_text = _("May add, edit or delete patients created on the server")
3540 may_manage_patients.title = mmp_text
3541 may_manage_patients.label = mmp_text # type: ignore[attr-defined]
3542 may_email_patients = get_child_node(self, "may_email_patients")
3543 mep_text = _("May send emails to patients created on the server")
3544 may_email_patients.title = mep_text
3545 may_email_patients.label = mep_text # type: ignore[attr-defined]
3546 view_all_patients_when_unfiltered = get_child_node(
3547 self, "view_all_patients_when_unfiltered"
3548 )
3549 vap_text = _(
3550 "May view (browse) records from all patients when no patient "
3551 "filter set"
3552 )
3553 view_all_patients_when_unfiltered.title = vap_text
3554 view_all_patients_when_unfiltered.label = vap_text # type: ignore[attr-defined] # noqa: E501
3555 may_dump_data = get_child_node(self, "may_dump_data")
3556 md_text = _("May perform bulk data dumps")
3557 may_dump_data.title = md_text
3558 may_dump_data.label = md_text # type: ignore[attr-defined]
3559 may_run_reports = get_child_node(self, "may_run_reports")
3560 mrr_text = _("May run reports")
3561 may_run_reports.title = mrr_text
3562 may_run_reports.label = mrr_text # type: ignore[attr-defined]
3563 may_add_notes = get_child_node(self, "may_add_notes")
3564 man_text = _("May add special notes to tasks")
3565 may_add_notes.title = man_text
3566 may_add_notes.label = man_text # type: ignore[attr-defined]
3569class UserGroupPermissionsFullSchema(UserGroupPermissionsGroupAdminSchema):
3570 """
3571 Edit group-specific permissions for a user. For superusers; includes the
3572 option to make the user a groupadmin.
3573 """
3575 groupadmin = BooleanNode(
3576 default=False
3577 ) # match ViewParam.GROUPADMIN and User attribute
3579 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
3580 super().after_bind(node, kw)
3581 _ = self.gettext
3582 groupadmin = get_child_node(self, "groupadmin")
3583 text = _("User is a privileged group administrator for this group")
3584 groupadmin.title = text
3585 groupadmin.label = text # type: ignore[attr-defined]
3588class EditUserGroupAdminSchema(CSRFSchema):
3589 """
3590 Schema to edit a user. Version for group administrators.
3591 """
3593 username = (
3594 UsernameNode()
3595 ) # name must match ViewParam.USERNAME and User attribute
3596 fullname = OptionalStringNode( # name must match ViewParam.FULLNAME and User attribute # noqa
3597 validator=Length(0, StringLengths.FULLNAME_MAX_LEN)
3598 )
3599 email = (
3600 OptionalEmailNode()
3601 ) # name must match ViewParam.EMAIL and User attribute
3602 must_change_password = (
3603 MustChangePasswordNode()
3604 ) # match ViewParam.MUST_CHANGE_PASSWORD and User attribute
3605 language = LanguageSelector() # must match ViewParam.LANGUAGE
3606 group_ids = AdministeredGroupsSequence() # must match ViewParam.GROUP_IDS
3608 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
3609 _ = self.gettext
3610 fullname = get_child_node(self, "fullname")
3611 fullname.title = _("Full name")
3612 email = get_child_node(self, "email")
3613 email.title = _("E-mail address")
3616class EditUserFullSchema(EditUserGroupAdminSchema):
3617 """
3618 Schema to edit a user. Version for superusers; can also make the user a
3619 superuser.
3620 """
3622 superuser = BooleanNode(
3623 default=False
3624 ) # match ViewParam.SUPERUSER and User attribute
3625 group_ids = AllGroupsSequence() # must match ViewParam.GROUP_IDS
3627 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
3628 _ = self.gettext
3629 superuser = get_child_node(self, "superuser")
3630 text = _("Superuser (CAUTION!)")
3631 superuser.title = text
3632 superuser.label = text # type: ignore[attr-defined]
3635class EditUserFullForm(ApplyCancelForm):
3636 """
3637 Form to edit a user. Full version for superusers.
3638 """
3640 def __init__(self, request: "CamcopsRequest", **kwargs: Any) -> None:
3641 super().__init__(
3642 schema_class=EditUserFullSchema, request=request, **kwargs
3643 )
3646class EditUserGroupAdminForm(ApplyCancelForm):
3647 """
3648 Form to edit a user. Version for group administrators.
3649 """
3651 def __init__(self, request: "CamcopsRequest", **kwargs: Any) -> None:
3652 super().__init__(
3653 schema_class=EditUserGroupAdminSchema, request=request, **kwargs
3654 )
3657class EditUserGroupPermissionsFullForm(ApplyCancelForm):
3658 """
3659 Form to edit a user's permissions within a group. Version for superusers.
3660 """
3662 def __init__(self, request: "CamcopsRequest", **kwargs: Any) -> None:
3663 super().__init__(
3664 schema_class=UserGroupPermissionsFullSchema,
3665 request=request,
3666 **kwargs,
3667 )
3670class EditUserGroupMembershipGroupAdminForm(ApplyCancelForm):
3671 """
3672 Form to edit a user's permissions within a group. Version for group
3673 administrators.
3674 """
3676 def __init__(self, request: "CamcopsRequest", **kwargs: Any) -> None:
3677 super().__init__(
3678 schema_class=UserGroupPermissionsGroupAdminSchema,
3679 request=request,
3680 **kwargs,
3681 )
3684class AddUserSuperuserSchema(CSRFSchema):
3685 """
3686 Schema to add a user. Version for superusers.
3687 """
3689 username = (
3690 UsernameNode()
3691 ) # name must match ViewParam.USERNAME and User attribute
3692 new_password = NewPasswordNode() # name must match ViewParam.NEW_PASSWORD
3693 must_change_password = (
3694 MustChangePasswordNode()
3695 ) # match ViewParam.MUST_CHANGE_PASSWORD and User attribute
3696 group_ids = AllGroupsSequence() # must match ViewParam.GROUP_IDS
3699class AddUserGroupadminSchema(AddUserSuperuserSchema):
3700 """
3701 Schema to add a user. Version for group administrators.
3702 """
3704 group_ids = AdministeredGroupsSequence() # must match ViewParam.GROUP_IDS
3707class AddUserSuperuserForm(AddCancelForm):
3708 """
3709 Form to add a user. Version for superusers.
3710 """
3712 def __init__(self, request: "CamcopsRequest", **kwargs: Any) -> None:
3713 super().__init__(
3714 schema_class=AddUserSuperuserSchema, request=request, **kwargs
3715 )
3718class AddUserGroupadminForm(AddCancelForm):
3719 """
3720 Form to add a user. Version for group administrators.
3721 """
3723 def __init__(self, request: "CamcopsRequest", **kwargs: Any) -> None:
3724 super().__init__(
3725 schema_class=AddUserGroupadminSchema, request=request, **kwargs
3726 )
3729class SetUserUploadGroupSchema(CSRFSchema):
3730 """
3731 Schema to choose the group into which a user uploads.
3732 """
3734 upload_group_id = (
3735 OptionalGroupIdSelectorUserGroups()
3736 ) # must match ViewParam.UPLOAD_GROUP_ID
3738 # noinspection PyUnusedLocal
3739 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
3740 _ = self.gettext
3741 upload_group_id = get_child_node(self, "upload_group_id")
3742 upload_group_id.title = _("Group into which to upload data")
3743 upload_group_id.description = _(
3744 "Pick a group from those to which the user belongs"
3745 )
3748class SetUserUploadGroupForm(InformativeNonceForm):
3749 """
3750 Form to choose the group into which a user uploads.
3751 """
3753 def __init__(
3754 self, request: "CamcopsRequest", user: "User", **kwargs: Any
3755 ) -> None:
3756 _ = request.gettext
3757 schema = SetUserUploadGroupSchema().bind(
3758 request=request, user=user
3759 ) # UNUSUAL
3760 super().__init__(
3761 schema,
3762 buttons=[
3763 Button(name=FormAction.SUBMIT, title=_("Set")),
3764 Button(name=FormAction.CANCEL, title=_("Cancel")),
3765 ],
3766 **kwargs,
3767 )
3770class DeleteUserSchema(HardWorkConfirmationSchema):
3771 """
3772 Schema to delete a user.
3773 """
3775 user_id = HiddenIntegerNode() # name must match ViewParam.USER_ID
3776 danger = TranslatableValidateDangerousOperationNode()
3779class DeleteUserForm(DeleteCancelForm):
3780 """
3781 Form to delete a user.
3782 """
3784 def __init__(self, request: "CamcopsRequest", **kwargs: Any) -> None:
3785 super().__init__(
3786 schema_class=DeleteUserSchema, request=request, **kwargs
3787 )
3790# =============================================================================
3791# Add/edit/delete groups
3792# =============================================================================
3795class PolicyNode(MandatoryStringNode, RequestAwareMixin):
3796 """
3797 Node to capture a CamCOPS ID number policy, and make sure it is
3798 syntactically valid.
3799 """
3801 def validator(self, node: SchemaNode, value: Any) -> None:
3802 _ = self.gettext
3803 if not isinstance(value, str):
3804 # unlikely!
3805 raise Invalid(node, _("Not a string"))
3806 policy = TokenizedPolicy(value)
3807 if not policy.is_syntactically_valid():
3808 raise Invalid(node, _("Syntactically invalid policy"))
3809 if not policy.is_valid_for_idnums(self.request.valid_which_idnums):
3810 raise Invalid(
3811 node,
3812 _(
3813 "Invalid policy. Have you referred to non-existent ID "
3814 "numbers? Is the policy less restrictive than the "
3815 "tablet’s minimum ID policy?"
3816 )
3817 + f" [{TABLET_ID_POLICY_STR!r}]",
3818 )
3821class GroupNameNode(MandatoryStringNode, RequestAwareMixin):
3822 """
3823 Node to capture a CamCOPS group name, and check it's valid as a string.
3824 """
3826 def validator(self, node: SchemaNode, value: str) -> None:
3827 try:
3828 validate_group_name(value, self.request)
3829 except ValueError as e:
3830 raise Invalid(node, str(e))
3833class GroupIpUseWidget(Widget):
3834 basedir = os.path.join(TEMPLATE_DIR, "deform")
3835 readonlydir = os.path.join(basedir, "readonly")
3836 form = "group_ip_use.pt"
3837 template = os.path.join(basedir, form)
3838 readonly_template = os.path.join(readonlydir, form)
3840 def __init__(self, request: "CamcopsRequest", **kwargs: Any) -> None:
3841 super().__init__(**kwargs)
3842 self.request = request
3844 def serialize(
3845 self,
3846 field: "Field",
3847 cstruct: Union[Dict[str, Any], None, ColanderNullType],
3848 **kw: Any,
3849 ) -> Any:
3850 if cstruct in (None, null):
3851 cstruct = {}
3853 cstruct: Dict[str, Any] # For type checker
3855 for context in IpUse.CONTEXTS:
3856 value = cstruct.get(context, False)
3857 kw.setdefault(context, value)
3859 readonly = kw.get("readonly", self.readonly)
3860 template = readonly and self.readonly_template or self.template
3861 values = self.get_template_values(field, cstruct, kw)
3863 _ = self.request.gettext
3865 values.update(
3866 introduction=_(
3867 "These settings will be applied to the patient's device "
3868 "when operating in single user mode."
3869 ),
3870 reason=_(
3871 "The settings here influence whether CamCOPS will consider "
3872 "some third-party tasks “permitted” on your behalf, according "
3873 "to their published use criteria. They do <b>not</b> remove "
3874 "your responsibility to ensure that you use them in "
3875 "accordance with their own requirements."
3876 ),
3877 warning=_(
3878 "WARNING. Providing incorrect information here may lead to "
3879 "you VIOLATING copyright law, by using task for a purpose "
3880 "that is not permitted, and being subject to damages and/or "
3881 "prosecution."
3882 ),
3883 disclaimer=_(
3884 "The authors of CamCOPS cannot be held responsible or liable "
3885 "for any consequences of you misusing materials subject to "
3886 "copyright."
3887 ),
3888 preamble=_("In which contexts does this group operate?"),
3889 clinical_label=_("Clinical"),
3890 medical_device_warning=_(
3891 "WARNING: NOT FOR GENERAL CLINICAL USE; not a Medical Device; "
3892 "see Terms and Conditions"
3893 ),
3894 commercial_label=_("Commercial"),
3895 educational_label=_("Educational"),
3896 research_label=_("Research"),
3897 )
3899 return field.renderer(template, **values)
3901 def deserialize(
3902 self, field: "Field", pstruct: Union[Dict[str, Any], ColanderNullType]
3903 ) -> Dict[str, bool]:
3904 if pstruct is null:
3905 pstruct = {}
3907 pstruct: Dict[str, Any] # For type checker
3909 # It doesn't really matter what the pstruct values are. Only the
3910 # options that are ticked will be present as keys in pstruct
3911 return {k: k in pstruct for k in IpUse.CONTEXTS}
3914class IpUseType(SchemaType):
3915 # noinspection PyMethodMayBeStatic,PyUnusedLocal
3916 def deserialize(
3917 self,
3918 node: SchemaNode,
3919 cstruct: Union[Dict[str, Any], None, ColanderNullType],
3920 ) -> Optional[IpUse]:
3921 if cstruct in (None, null):
3922 return None
3924 cstruct: Dict[str, Any] # For type checker
3926 return IpUse(**cstruct)
3928 # noinspection PyMethodMayBeStatic,PyUnusedLocal
3929 def serialize(
3930 self, node: SchemaNode, ip_use: Union[IpUse, None, ColanderNullType]
3931 ) -> Union[Dict, ColanderNullType]:
3932 if ip_use in (null, None):
3933 return null
3935 return {
3936 context: getattr(ip_use, context) for context in IpUse.CONTEXTS
3937 }
3940class GroupIpUseNode(SchemaNode, RequestAwareMixin):
3941 schema_type = IpUseType
3943 # noinspection PyUnusedLocal,PyAttributeOutsideInit
3944 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
3945 self.widget = GroupIpUseWidget(self.request)
3948class EditGroupSchema(CSRFSchema):
3949 """
3950 Schema to edit a group.
3951 """
3953 group_id = HiddenIntegerNode() # must match ViewParam.GROUP_ID
3954 name = GroupNameNode() # must match ViewParam.NAME
3955 description = MandatoryStringNode( # must match ViewParam.DESCRIPTION
3956 validator=Length(
3957 StringLengths.GROUP_DESCRIPTION_MIN_LEN,
3958 StringLengths.GROUP_DESCRIPTION_MAX_LEN,
3959 )
3960 )
3961 ip_use = GroupIpUseNode()
3963 group_ids = AllOtherGroupsSequence() # must match ViewParam.GROUP_IDS
3964 upload_policy = PolicyNode() # must match ViewParam.UPLOAD_POLICY
3965 finalize_policy = PolicyNode() # must match ViewParam.FINALIZE_POLICY
3967 # noinspection PyUnusedLocal
3968 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
3969 _ = self.gettext
3970 name = get_child_node(self, "name")
3971 name.title = _("Group name")
3973 ip_use = get_child_node(self, "ip_use")
3974 ip_use.title = _("Group intellectual property settings")
3976 group_ids = get_child_node(self, "group_ids")
3977 group_ids.title = _("Other groups this group may see")
3978 upload_policy = get_child_node(self, "upload_policy")
3979 upload_policy.title = _("Upload policy")
3980 upload_policy.description = _(
3981 "Minimum required patient information to copy data to server"
3982 )
3983 finalize_policy = get_child_node(self, "finalize_policy")
3984 finalize_policy.title = _("Finalize policy")
3985 finalize_policy.description = _(
3986 "Minimum required patient information to clear data off "
3987 "source device"
3988 )
3990 def validator(self, node: SchemaNode, value: Any) -> None:
3991 request = self.bindings[Binding.REQUEST] # type: CamcopsRequest
3992 q = (
3993 CountStarSpecializedQuery(Group, session=request.dbsession) # type: ignore[arg-type] # noqa: E501
3994 .filter(Group.id != value[ViewParam.GROUP_ID])
3995 .filter(Group.name == value[ViewParam.NAME])
3996 )
3997 if q.count_star() > 0:
3998 _ = request.gettext
3999 raise Invalid(node, _("Name is used by another group!"))
4002class EditGroupForm(InformativeNonceForm):
4003 """
4004 Form to edit a group.
4005 """
4007 def __init__(
4008 self, request: "CamcopsRequest", group: Group, **kwargs: Any
4009 ) -> None:
4010 _ = request.gettext
4011 schema = EditGroupSchema().bind(
4012 request=request, group=group
4013 ) # UNUSUAL BINDING
4014 super().__init__(
4015 schema,
4016 buttons=[
4017 Button(name=FormAction.SUBMIT, title=_("Apply")),
4018 Button(name=FormAction.CANCEL, title=_("Cancel")),
4019 ],
4020 **kwargs,
4021 )
4024class AddGroupSchema(CSRFSchema):
4025 """
4026 Schema to add a group.
4027 """
4029 name = GroupNameNode() # name must match ViewParam.NAME
4031 # noinspection PyUnusedLocal
4032 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
4033 _ = self.gettext
4034 name = get_child_node(self, "name")
4035 name.title = _("Group name")
4037 def validator(self, node: SchemaNode, value: Any) -> None:
4038 request = self.bindings[Binding.REQUEST] # type: CamcopsRequest
4039 q = CountStarSpecializedQuery(Group, session=request.dbsession).filter( # type: ignore[arg-type] # noqa: E501
4040 Group.name == value[ViewParam.NAME]
4041 )
4042 if q.count_star() > 0:
4043 _ = request.gettext
4044 raise Invalid(node, _("Name is used by another group!"))
4047class AddGroupForm(AddCancelForm):
4048 """
4049 Form to add a group.
4050 """
4052 def __init__(self, request: "CamcopsRequest", **kwargs: Any) -> None:
4053 super().__init__(
4054 schema_class=AddGroupSchema, request=request, **kwargs
4055 )
4058class DeleteGroupSchema(HardWorkConfirmationSchema):
4059 """
4060 Schema to delete a group.
4061 """
4063 group_id = HiddenIntegerNode() # name must match ViewParam.GROUP_ID
4064 danger = TranslatableValidateDangerousOperationNode()
4067class DeleteGroupForm(DeleteCancelForm):
4068 """
4069 Form to delete a group.
4070 """
4072 def __init__(self, request: "CamcopsRequest", **kwargs: Any) -> None:
4073 super().__init__(
4074 schema_class=DeleteGroupSchema, request=request, **kwargs
4075 )
4078# =============================================================================
4079# Offer research dumps
4080# =============================================================================
4083class DumpTypeSelector(SchemaNode, RequestAwareMixin):
4084 """
4085 Node to select the filtering method for a data dump.
4086 """
4088 schema_type = String
4089 default = ViewArg.EVERYTHING
4090 missing = ViewArg.EVERYTHING
4092 def __init__(self, *args: Any, **kwargs: Any) -> None:
4093 self.title = "" # for type checker
4094 self.widget = None # type: Optional[Widget]
4095 self.validator = None # type: Optional[ValidatorType]
4096 super().__init__(*args, **kwargs)
4098 # noinspection PyUnusedLocal
4099 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
4100 _ = self.gettext
4101 self.title = _("Dump method")
4102 choices = (
4103 (ViewArg.EVERYTHING, _("Everything")),
4104 (ViewArg.USE_SESSION_FILTER, _("Use the session filter settings")),
4105 (
4106 ViewArg.SPECIFIC_TASKS_GROUPS,
4107 _("Specify tasks/groups manually (see below)"),
4108 ),
4109 )
4110 self.widget = RadioChoiceWidget(values=choices)
4111 self.validator = OneOf(list(x[0] for x in choices))
4114class SpreadsheetFormatSelector(SchemaNode, RequestAwareMixin):
4115 """
4116 Node to select a way of downloading an SQLite database.
4117 """
4119 schema_type = String
4120 default = ViewArg.XLSX
4121 missing = ViewArg.XLSX
4123 def __init__(self, *args: Any, **kwargs: Any) -> None:
4124 self.title = "" # for type checker
4125 self.widget = None # type: Optional[Widget]
4126 self.validator = None # type: Optional[ValidatorType]
4127 super().__init__(*args, **kwargs)
4129 # noinspection PyUnusedLocal
4130 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
4131 _ = self.gettext
4132 self.title = _("Spreadsheet format")
4133 choices = (
4134 (ViewArg.R, _("R script")),
4135 (ViewArg.ODS, _("OpenOffice spreadsheet (ODS) file")),
4136 (ViewArg.XLSX, _("XLSX (Microsoft Excel) file")),
4137 (
4138 ViewArg.TSV_ZIP,
4139 _("ZIP file of tab-separated value (TSV) files"),
4140 ),
4141 )
4142 values, pv = get_values_and_permissible(choices)
4143 self.widget = RadioChoiceWidget(values=values)
4144 self.validator = OneOf(pv)
4147class DeliveryModeNode(SchemaNode, RequestAwareMixin):
4148 """
4149 Mode of delivery of data downloads.
4150 """
4152 schema_type = String
4153 default = ViewArg.EMAIL
4154 missing = ViewArg.EMAIL
4156 def __init__(self, *args: Any, **kwargs: Any) -> None:
4157 self.title = "" # for type checker
4158 self.widget = None # type: Optional[Widget]
4159 super().__init__(*args, **kwargs)
4161 # noinspection PyUnusedLocal
4162 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
4163 _ = self.gettext
4164 self.title = _("Delivery")
4165 choices = (
4166 (ViewArg.IMMEDIATELY, _("Serve immediately")),
4167 (ViewArg.EMAIL, _("E-mail me")),
4168 (ViewArg.DOWNLOAD, _("Create a file for me to download")),
4169 )
4170 values, pv = get_values_and_permissible(choices)
4171 self.widget = RadioChoiceWidget(values=values)
4173 # noinspection PyUnusedLocal
4174 def validator(self, node: SchemaNode, value: Any) -> None:
4175 request = self.bindings[Binding.REQUEST] # type: CamcopsRequest
4176 _ = request.gettext
4177 if value == ViewArg.IMMEDIATELY:
4178 if not request.config.permit_immediate_downloads:
4179 raise Invalid(
4180 self,
4181 _("Disabled by the system administrator")
4182 + f" [{ConfigParamSite.PERMIT_IMMEDIATE_DOWNLOADS}]",
4183 )
4184 elif value == ViewArg.EMAIL:
4185 if not request.user.email:
4186 raise Invalid(
4187 self, _("Your user does not have an email address")
4188 )
4189 elif value == ViewArg.DOWNLOAD:
4190 if not request.user_download_dir:
4191 raise Invalid(
4192 self,
4193 _("User downloads not configured by administrator")
4194 + f" [{ConfigParamSite.USER_DOWNLOAD_DIR}, "
4195 f"{ConfigParamSite.USER_DOWNLOAD_MAX_SPACE_MB}]",
4196 )
4197 else:
4198 raise Invalid(self, _("Bad value"))
4201class SqliteSelector(SchemaNode, RequestAwareMixin):
4202 """
4203 Node to select a way of downloading an SQLite database.
4204 """
4206 schema_type = String
4207 default = ViewArg.SQLITE
4208 missing = ViewArg.SQLITE
4210 def __init__(self, *args: Any, **kwargs: Any) -> None:
4211 self.title = "" # for type checker
4212 self.widget = None # type: Optional[Widget]
4213 self.validator = None # type: Optional[ValidatorType]
4214 super().__init__(*args, **kwargs)
4216 # noinspection PyUnusedLocal
4217 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
4218 _ = self.gettext
4219 self.title = _("Database download method")
4220 choices = (
4221 # https://docs.sqlalchemy.org/en/latest/dialects/
4222 (ViewArg.SQLITE, _("Binary SQLite database")),
4223 (ViewArg.SQL, _("SQL text to create SQLite database")),
4224 )
4225 values, pv = get_values_and_permissible(choices)
4226 self.widget = RadioChoiceWidget(values=values)
4227 self.validator = OneOf(pv)
4230class SimplifiedSpreadsheetsNode(SchemaNode, RequestAwareMixin):
4231 """
4232 Boolean node: simplify basic dump spreadsheets?
4233 """
4235 schema_type = Boolean
4236 default = True
4237 missing = True
4239 def __init__(self, *args: Any, **kwargs: Any) -> None:
4240 self.title = "" # for type checker
4241 self.label = "" # for type checker
4242 super().__init__(*args, **kwargs)
4244 # noinspection PyUnusedLocal
4245 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
4246 _ = self.gettext
4247 self.title = _("Simplify spreadsheets?")
4248 self.label = _("Remove non-essential details?")
4251class SortTsvByHeadingsNode(SchemaNode, RequestAwareMixin):
4252 """
4253 Boolean node: sort TSV files by column name?
4254 """
4256 schema_type = Boolean
4257 default = False
4258 missing = False
4260 def __init__(self, *args: Any, **kwargs: Any) -> None:
4261 self.title = "" # for type checker
4262 self.label = "" # for type checker
4263 super().__init__(*args, **kwargs)
4265 # noinspection PyUnusedLocal
4266 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
4267 _ = self.gettext
4268 self.title = _("Sort columns?")
4269 self.label = _("Sort by heading (column) names within spreadsheets?")
4272class IncludeSchemaNode(SchemaNode, RequestAwareMixin):
4273 """
4274 Boolean node: should INFORMATION_SCHEMA.COLUMNS be included (for
4275 downloads)?
4277 False by default -- adds about 350 kb to an ODS download, for example.
4278 """
4280 schema_type = Boolean
4281 default = False
4282 missing = False
4284 def __init__(self, *args: Any, **kwargs: Any) -> None:
4285 self.title = "" # for type checker
4286 self.label = "" # for type checker
4287 super().__init__(*args, **kwargs)
4289 # noinspection PyUnusedLocal
4290 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
4291 _ = self.gettext
4292 self.title = _("Include column information?")
4293 self.label = _(
4294 "Include details of all columns in the source database?"
4295 )
4298class IncludeBlobsNode(SchemaNode, RequestAwareMixin):
4299 """
4300 Boolean node: should BLOBs be included (for downloads)?
4301 """
4303 schema_type = Boolean
4304 default = False
4305 missing = False
4307 def __init__(self, *args: Any, **kwargs: Any) -> None:
4308 self.title = "" # for type checker
4309 self.label = "" # for type checker
4310 super().__init__(*args, **kwargs)
4312 # noinspection PyUnusedLocal
4313 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
4314 _ = self.gettext
4315 self.title = _("Include BLOBs?")
4316 self.label = _(
4317 "Include binary large objects (BLOBs)? WARNING: may be large"
4318 )
4321class PatientIdPerRowNode(SchemaNode, RequestAwareMixin):
4322 """
4323 Boolean node: should patient ID information, and other cross-referencing
4324 denormalized info, be included per row?
4326 See :ref:`DB_PATIENT_ID_PER_ROW <DB_PATIENT_ID_PER_ROW>`.
4327 """
4329 schema_type = Boolean
4330 default = True
4331 missing = True
4333 def __init__(self, *args: Any, **kwargs: Any) -> None:
4334 self.title = "" # for type checker
4335 self.label = "" # for type checker
4336 super().__init__(*args, **kwargs)
4338 # noinspection PyUnusedLocal
4339 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
4340 _ = self.gettext
4341 self.title = _("Patient ID per row?")
4342 self.label = _(
4343 "Include patient ID numbers and task cross-referencing "
4344 "(denormalized) information per row?"
4345 )
4348class OfferDumpManualSchema(Schema, RequestAwareMixin):
4349 """
4350 Schema to offer the "manual" settings for a data dump (groups, task types).
4351 """
4353 group_ids = AllowedGroupsSequence() # must match ViewParam.GROUP_IDS
4354 tasks = MultiTaskSelector() # must match ViewParam.TASKS
4356 widget = MappingWidget(template="mapping_accordion", open=False)
4358 def __init__(self, *args: Any, **kwargs: Any) -> None:
4359 self.title = "" # for type checker
4360 super().__init__(*args, **kwargs)
4362 # noinspection PyUnusedLocal
4363 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
4364 _ = self.gettext
4365 self.title = _("Manual settings")
4368class OfferBasicDumpSchema(CSRFSchema):
4369 """
4370 Schema to choose the settings for a basic (TSV/ZIP) data dump.
4371 """
4373 dump_method = DumpTypeSelector() # must match ViewParam.DUMP_METHOD
4374 simplified = (
4375 SimplifiedSpreadsheetsNode()
4376 ) # must match ViewParam.SIMPLIFIED
4377 sort = SortTsvByHeadingsNode() # must match ViewParam.SORT
4378 include_schema = IncludeSchemaNode() # must match ViewParam.INCLUDE_SCHEMA
4379 manual = OfferDumpManualSchema() # must match ViewParam.MANUAL
4380 viewtype = SpreadsheetFormatSelector() # must match ViewParam.VIEWTYPE
4381 delivery_mode = DeliveryModeNode() # must match ViewParam.DELIVERY_MODE
4384class OfferBasicDumpForm(SimpleSubmitForm):
4385 """
4386 Form to offer a basic (TSV/ZIP) data dump.
4387 """
4389 def __init__(self, request: "CamcopsRequest", **kwargs: Any) -> None:
4390 _ = request.gettext
4391 super().__init__(
4392 schema_class=OfferBasicDumpSchema,
4393 submit_title=_("Dump"),
4394 request=request,
4395 **kwargs,
4396 )
4399class OfferSqlDumpSchema(CSRFSchema):
4400 """
4401 Schema to choose the settings for an SQL data dump.
4402 """
4404 dump_method = DumpTypeSelector() # must match ViewParam.DUMP_METHOD
4405 sqlite_method = SqliteSelector() # must match ViewParam.SQLITE_METHOD
4406 include_schema = IncludeSchemaNode() # must match ViewParam.INCLUDE_SCHEMA
4407 include_blobs = IncludeBlobsNode() # must match ViewParam.INCLUDE_BLOBS
4408 patient_id_per_row = (
4409 PatientIdPerRowNode()
4410 ) # must match ViewParam.PATIENT_ID_PER_ROW
4411 manual = OfferDumpManualSchema() # must match ViewParam.MANUAL
4412 delivery_mode = DeliveryModeNode() # must match ViewParam.DELIVERY_MODE
4415class OfferSqlDumpForm(SimpleSubmitForm):
4416 """
4417 Form to choose the settings for an SQL data dump.
4418 """
4420 def __init__(self, request: "CamcopsRequest", **kwargs: Any) -> None:
4421 _ = request.gettext
4422 super().__init__(
4423 schema_class=OfferSqlDumpSchema,
4424 submit_title=_("Dump"),
4425 request=request,
4426 **kwargs,
4427 )
4430# =============================================================================
4431# Edit server settings
4432# =============================================================================
4435class EditServerSettingsSchema(CSRFSchema):
4436 """
4437 Schema to edit the global settings for the server.
4438 """
4440 database_title = SchemaNode( # must match ViewParam.DATABASE_TITLE
4441 String(),
4442 validator=Length(
4443 StringLengths.DATABASE_TITLE_MIN_LEN,
4444 StringLengths.DATABASE_TITLE_MAX_LEN,
4445 ),
4446 )
4448 # noinspection PyUnusedLocal
4449 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
4450 _ = self.gettext
4451 database_title = get_child_node(self, "database_title")
4452 database_title.title = _("Database friendly title")
4455class EditServerSettingsForm(ApplyCancelForm):
4456 """
4457 Form to edit the global settings for the server.
4458 """
4460 def __init__(self, request: "CamcopsRequest", **kwargs: Any) -> None:
4461 super().__init__(
4462 schema_class=EditServerSettingsSchema, request=request, **kwargs
4463 )
4466# =============================================================================
4467# Edit ID number definitions
4468# =============================================================================
4471class IdDefinitionDescriptionNode(SchemaNode, RequestAwareMixin):
4472 """
4473 Node to capture the description of an ID number type.
4474 """
4476 schema_type = String
4477 validator = Length(1, StringLengths.ID_DESCRIPTOR_MAX_LEN)
4479 def __init__(self, *args: Any, **kwargs: Any) -> None:
4480 self.title = "" # for type checker
4481 super().__init__(*args, **kwargs)
4483 # noinspection PyUnusedLocal
4484 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
4485 _ = self.gettext
4486 self.title = _("Full description (e.g. “NHS number”)")
4489class IdDefinitionShortDescriptionNode(SchemaNode, RequestAwareMixin):
4490 """
4491 Node to capture the short description of an ID number type.
4492 """
4494 schema_type = String
4495 validator = Length(1, StringLengths.ID_DESCRIPTOR_MAX_LEN)
4497 def __init__(self, *args: Any, **kwargs: Any) -> None:
4498 self.title = "" # for type checker
4499 self.description = "" # for type checker
4500 super().__init__(*args, **kwargs)
4502 # noinspection PyUnusedLocal
4503 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
4504 _ = self.gettext
4505 self.title = _("Short description (e.g. “NHS#”)")
4506 self.description = _("Try to keep it very short!")
4509class IdValidationMethodNode(OptionalStringNode, RequestAwareMixin):
4510 """
4511 Node to choose a build-in ID number validation method.
4512 """
4514 widget = SelectWidget(values=ID_NUM_VALIDATION_METHOD_CHOICES)
4515 validator = OneOf(list(x[0] for x in ID_NUM_VALIDATION_METHOD_CHOICES))
4517 def __init__(self, *args: Any, **kwargs: Any) -> None:
4518 self.title = "" # for type checker
4519 self.description = "" # for type checker
4520 super().__init__(*args, **kwargs)
4522 # noinspection PyUnusedLocal
4523 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
4524 _ = self.gettext
4525 self.title = _("Validation method")
4526 self.description = _("Built-in CamCOPS ID number validation method")
4529class Hl7AssigningAuthorityNode(OptionalStringNode, RequestAwareMixin):
4530 """
4531 Optional node to capture the name of an HL7 Assigning Authority.
4532 """
4534 def __init__(self, *args: Any, **kwargs: Any) -> None:
4535 self.title = "" # for type checker
4536 self.description = "" # for type checker
4537 super().__init__(*args, **kwargs)
4539 # noinspection PyUnusedLocal
4540 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
4541 _ = self.gettext
4542 self.title = _("HL7 Assigning Authority")
4543 self.description = _(
4544 "For HL7 messaging: "
4545 "HL7 Assigning Authority for ID number (unique name of the "
4546 "system/organization/agency/department that creates the data)."
4547 )
4549 # noinspection PyMethodMayBeStatic
4550 def validator(self, node: SchemaNode, value: str) -> None:
4551 try:
4552 validate_hl7_aa(value, self.request)
4553 except ValueError as e:
4554 raise Invalid(node, str(e))
4557class Hl7IdTypeNode(OptionalStringNode, RequestAwareMixin):
4558 """
4559 Optional node to capture the name of an HL7 Identifier Type code.
4560 """
4562 def __init__(self, *args: Any, **kwargs: Any) -> None:
4563 self.title = "" # for type checker
4564 self.description = "" # for type checker
4565 super().__init__(*args, **kwargs)
4567 # noinspection PyUnusedLocal
4568 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
4569 _ = self.gettext
4570 self.title = _("HL7 Identifier Type")
4571 self.description = _(
4572 "For HL7 messaging: "
4573 "HL7 Identifier Type code: ‘a code corresponding to the type "
4574 "of identifier. In some cases, this code may be used as a "
4575 "qualifier to the “Assigning Authority” component.’"
4576 )
4578 # noinspection PyMethodMayBeStatic
4579 def validator(self, node: SchemaNode, value: str) -> None:
4580 try:
4581 validate_hl7_id_type(value, self.request)
4582 except ValueError as e:
4583 raise Invalid(node, str(e))
4586class FHIRIdSystemUrlNode(OptionalStringNode, RequestAwareMixin):
4587 """
4588 Optional node to capture the URL for a FHIR ID system:
4590 - https://www.hl7.org/fhir/datatypes.html#Identifier
4591 - https://www.hl7.org/fhir/datatypes-definitions.html#Identifier.system
4592 """
4594 validator = url
4596 def __init__(self, *args: Any, **kwargs: Any) -> None:
4597 self.title = "" # for type checker
4598 self.description = "" # for type checker
4599 super().__init__(*args, **kwargs)
4601 # noinspection PyUnusedLocal
4602 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
4603 _ = self.gettext
4604 self.title = _("FHIR ID system")
4605 self.description = _("For FHIR exports: URL defining the ID system.")
4608class EditIdDefinitionSchema(CSRFSchema):
4609 """
4610 Schema to edit an ID number definition.
4611 """
4613 which_idnum = HiddenIntegerNode() # must match ViewParam.WHICH_IDNUM
4614 description = (
4615 IdDefinitionDescriptionNode()
4616 ) # must match ViewParam.DESCRIPTION
4617 short_description = (
4618 IdDefinitionShortDescriptionNode()
4619 ) # must match ViewParam.SHORT_DESCRIPTION
4620 validation_method = (
4621 IdValidationMethodNode()
4622 ) # must match ViewParam.VALIDATION_METHOD
4623 hl7_id_type = Hl7IdTypeNode() # must match ViewParam.HL7_ID_TYPE
4624 hl7_assigning_authority = (
4625 Hl7AssigningAuthorityNode()
4626 ) # must match ViewParam.HL7_ASSIGNING_AUTHORITY
4627 fhir_id_system = (
4628 FHIRIdSystemUrlNode()
4629 ) # must match ViewParam.FHIR_ID_SYSTEM
4631 def validator(self, node: SchemaNode, value: Any) -> None:
4632 request = self.bindings[Binding.REQUEST] # type: CamcopsRequest
4633 _ = request.gettext
4634 qd = (
4635 CountStarSpecializedQuery(
4636 IdNumDefinition, session=request.dbsession # type: ignore[arg-type] # noqa: E501
4637 )
4638 .filter(
4639 IdNumDefinition.which_idnum != value[ViewParam.WHICH_IDNUM]
4640 )
4641 .filter(
4642 IdNumDefinition.description == value[ViewParam.DESCRIPTION]
4643 )
4644 )
4645 if qd.count_star() > 0:
4646 raise Invalid(node, _("Description is used by another ID number!"))
4647 qs = (
4648 CountStarSpecializedQuery(
4649 IdNumDefinition, session=request.dbsession # type: ignore[arg-type] # noqa: E501
4650 )
4651 .filter(
4652 IdNumDefinition.which_idnum != value[ViewParam.WHICH_IDNUM]
4653 )
4654 .filter(
4655 IdNumDefinition.short_description
4656 == value[ViewParam.SHORT_DESCRIPTION]
4657 )
4658 )
4659 if qs.count_star() > 0:
4660 raise Invalid(
4661 node, _("Short description is used by another ID number!")
4662 )
4665class EditIdDefinitionForm(ApplyCancelForm):
4666 """
4667 Form to edit an ID number definition.
4668 """
4670 def __init__(self, request: "CamcopsRequest", **kwargs: Any) -> None:
4671 super().__init__(
4672 schema_class=EditIdDefinitionSchema, request=request, **kwargs
4673 )
4676class AddIdDefinitionSchema(CSRFSchema):
4677 """
4678 Schema to add an ID number definition.
4679 """
4681 which_idnum = SchemaNode( # must match ViewParam.WHICH_IDNUM
4682 Integer(), validator=Range(min=1)
4683 )
4684 description = (
4685 IdDefinitionDescriptionNode()
4686 ) # must match ViewParam.DESCRIPTION
4687 short_description = (
4688 IdDefinitionShortDescriptionNode()
4689 ) # must match ViewParam.SHORT_DESCRIPTION
4690 validation_method = (
4691 IdValidationMethodNode()
4692 ) # must match ViewParam.VALIDATION_METHOD
4694 # noinspection PyUnusedLocal
4695 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
4696 _ = self.gettext
4697 which_idnum = get_child_node(self, "which_idnum")
4698 which_idnum.title = _("Which ID number?")
4699 which_idnum.description = (
4700 "Specify the integer to represent the type of this ID "
4701 "number class (e.g. consecutive numbering from 1)"
4702 )
4704 def validator(self, node: SchemaNode, value: Any) -> None:
4705 request = self.bindings[Binding.REQUEST] # type: CamcopsRequest
4706 _ = request.gettext
4707 qw = CountStarSpecializedQuery(
4708 IdNumDefinition, session=request.dbsession # type: ignore[arg-type] # noqa: E501
4709 ).filter(IdNumDefinition.which_idnum == value[ViewParam.WHICH_IDNUM])
4710 if qw.count_star() > 0:
4711 raise Invalid(node, _("ID# clashes with another ID number!"))
4712 qd = CountStarSpecializedQuery(
4713 IdNumDefinition, session=request.dbsession # type: ignore[arg-type] # noqa: E501
4714 ).filter(IdNumDefinition.description == value[ViewParam.DESCRIPTION])
4715 if qd.count_star() > 0:
4716 raise Invalid(node, _("Description is used by another ID number!"))
4717 qs = CountStarSpecializedQuery(
4718 IdNumDefinition, session=request.dbsession # type: ignore[arg-type] # noqa: E501
4719 ).filter(
4720 IdNumDefinition.short_description
4721 == value[ViewParam.SHORT_DESCRIPTION]
4722 )
4723 if qs.count_star() > 0:
4724 raise Invalid(
4725 node, _("Short description is used by another ID number!")
4726 )
4729class AddIdDefinitionForm(AddCancelForm):
4730 """
4731 Form to add an ID number definition.
4732 """
4734 def __init__(self, request: "CamcopsRequest", **kwargs: Any) -> None:
4735 super().__init__(
4736 schema_class=AddIdDefinitionSchema, request=request, **kwargs
4737 )
4740class DeleteIdDefinitionSchema(HardWorkConfirmationSchema):
4741 """
4742 Schema to delete an ID number definition.
4743 """
4745 which_idnum = HiddenIntegerNode() # name must match ViewParam.WHICH_IDNUM
4746 danger = TranslatableValidateDangerousOperationNode()
4749class DeleteIdDefinitionForm(DangerousForm):
4750 """
4751 Form to add an ID number definition.
4752 """
4754 def __init__(self, request: "CamcopsRequest", **kwargs: Any) -> None:
4755 _ = request.gettext
4756 super().__init__(
4757 schema_class=DeleteIdDefinitionSchema,
4758 submit_action=FormAction.DELETE,
4759 submit_title=_("Delete"),
4760 request=request,
4761 **kwargs,
4762 )
4765# =============================================================================
4766# Special notes
4767# =============================================================================
4770class AddSpecialNoteSchema(CSRFSchema):
4771 """
4772 Schema to add a special note to a task.
4773 """
4775 table_name = HiddenStringNode() # must match ViewParam.TABLENAME
4776 server_pk = HiddenIntegerNode() # must match ViewParam.SERVER_PK
4777 note = MandatoryStringNode( # must match ViewParam.NOTE
4778 widget=TextAreaWidget(rows=20, cols=80)
4779 )
4780 danger = TranslatableValidateDangerousOperationNode()
4783class AddSpecialNoteForm(DangerousForm):
4784 """
4785 Form to add a special note to a task.
4786 """
4788 def __init__(self, request: "CamcopsRequest", **kwargs: Any) -> None:
4789 _ = request.gettext
4790 super().__init__(
4791 schema_class=AddSpecialNoteSchema,
4792 submit_action=FormAction.SUBMIT,
4793 submit_title=_("Add"),
4794 request=request,
4795 **kwargs,
4796 )
4799class DeleteSpecialNoteSchema(CSRFSchema):
4800 """
4801 Schema to add a special note to a task.
4802 """
4804 note_id = HiddenIntegerNode() # must match ViewParam.NOTE_ID
4805 danger = TranslatableValidateDangerousOperationNode()
4808class DeleteSpecialNoteForm(DangerousForm):
4809 """
4810 Form to delete (hide) a special note.
4811 """
4813 def __init__(self, request: "CamcopsRequest", **kwargs: Any) -> None:
4814 _ = request.gettext
4815 super().__init__(
4816 schema_class=DeleteSpecialNoteSchema,
4817 submit_action=FormAction.SUBMIT,
4818 submit_title=_("Delete"),
4819 request=request,
4820 **kwargs,
4821 )
4824# =============================================================================
4825# The unusual data manipulation operations
4826# =============================================================================
4829class EraseTaskSchema(HardWorkConfirmationSchema):
4830 """
4831 Schema to erase a task.
4832 """
4834 table_name = HiddenStringNode() # must match ViewParam.TABLENAME
4835 server_pk = HiddenIntegerNode() # must match ViewParam.SERVER_PK
4836 danger = TranslatableValidateDangerousOperationNode()
4839class EraseTaskForm(DangerousForm):
4840 """
4841 Form to erase a task.
4842 """
4844 def __init__(self, request: "CamcopsRequest", **kwargs: Any) -> None:
4845 _ = request.gettext
4846 super().__init__(
4847 schema_class=EraseTaskSchema,
4848 submit_action=FormAction.DELETE,
4849 submit_title=_("Erase"),
4850 request=request,
4851 **kwargs,
4852 )
4855class DeletePatientChooseSchema(CSRFSchema):
4856 """
4857 Schema to delete a patient.
4858 """
4860 which_idnum = (
4861 MandatoryWhichIdNumSelector()
4862 ) # must match ViewParam.WHICH_IDNUM
4863 idnum_value = MandatoryIdNumValue() # must match ViewParam.IDNUM_VALUE
4864 group_id = (
4865 MandatoryGroupIdSelectorAdministeredGroups()
4866 ) # must match ViewParam.GROUP_ID
4867 danger = TranslatableValidateDangerousOperationNode()
4870class DeletePatientChooseForm(DangerousForm):
4871 """
4872 Form to delete a patient.
4873 """
4875 def __init__(self, request: "CamcopsRequest", **kwargs: Any) -> None:
4876 _ = request.gettext
4877 super().__init__(
4878 schema_class=DeletePatientChooseSchema,
4879 submit_action=FormAction.SUBMIT,
4880 submit_title=_("Show tasks that will be deleted"),
4881 request=request,
4882 **kwargs,
4883 )
4886class DeletePatientConfirmSchema(HardWorkConfirmationSchema):
4887 """
4888 Schema to confirm deletion of a patient.
4889 """
4891 which_idnum = HiddenIntegerNode() # must match ViewParam.WHICH_IDNUM
4892 idnum_value = HiddenIntegerNode() # must match ViewParam.IDNUM_VALUE
4893 group_id = HiddenIntegerNode() # must match ViewParam.GROUP_ID
4894 danger = TranslatableValidateDangerousOperationNode()
4897class DeletePatientConfirmForm(DangerousForm):
4898 """
4899 Form to confirm deletion of a patient.
4900 """
4902 def __init__(self, request: "CamcopsRequest", **kwargs: Any) -> None:
4903 _ = request.gettext
4904 super().__init__(
4905 schema_class=DeletePatientConfirmSchema,
4906 submit_action=FormAction.DELETE,
4907 submit_title=_("Delete"),
4908 request=request,
4909 **kwargs,
4910 )
4913class DeleteServerCreatedPatientSchema(HardWorkConfirmationSchema):
4914 """
4915 Schema to delete a patient created on the server.
4916 """
4918 # name must match ViewParam.SERVER_PK
4919 server_pk = HiddenIntegerNode()
4920 danger = TranslatableValidateDangerousOperationNode()
4923class DeleteServerCreatedPatientForm(DeleteCancelForm):
4924 """
4925 Form to delete a patient created on the server
4926 """
4928 def __init__(self, request: "CamcopsRequest", **kwargs: Any) -> None:
4929 super().__init__(
4930 schema_class=DeleteServerCreatedPatientSchema,
4931 request=request,
4932 **kwargs,
4933 )
4936EDIT_PATIENT_SIMPLE_PARAMS = [
4937 ViewParam.FORENAME,
4938 ViewParam.SURNAME,
4939 ViewParam.DOB,
4940 ViewParam.SEX,
4941 ViewParam.ADDRESS,
4942 ViewParam.EMAIL,
4943 ViewParam.GP,
4944 ViewParam.OTHER,
4945]
4948class TaskScheduleSelector(SchemaNode, RequestAwareMixin):
4949 """
4950 Drop-down with all available task schedules
4951 """
4953 widget = SelectWidget()
4955 def __init__(self, *args: Any, **kwargs: Any) -> None:
4956 self.title = "" # for type checker
4957 self.name = "" # for type checker
4958 self.validator = None # type: Optional[ValidatorType]
4959 super().__init__(*args, **kwargs)
4961 # noinspection PyUnusedLocal
4962 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
4963 request = self.request
4964 _ = request.gettext
4965 self.title = _("Task schedule")
4966 values = [] # type: List[Tuple[Optional[int], str]]
4968 valid_group_ids = (
4969 request.user.ids_of_groups_user_may_manage_patients_in
4970 )
4971 task_schedules = (
4972 request.dbsession.query(TaskSchedule)
4973 .filter(TaskSchedule.group_id.in_(valid_group_ids))
4974 .order_by(TaskSchedule.name)
4975 )
4977 for task_schedule in task_schedules:
4978 values.append((task_schedule.id, task_schedule.name))
4979 values, pv = get_values_and_permissible(values, add_none=False)
4981 self.widget.values = values
4982 self.validator = OneOf(pv)
4984 @staticmethod
4985 def schema_type() -> SchemaType:
4986 return Integer()
4989class JsonType(SchemaType):
4990 """
4991 Schema type for JsonNode
4992 """
4994 # noinspection PyMethodMayBeStatic, PyUnusedLocal
4995 def deserialize(
4996 self, node: SchemaNode, cstruct: Union[str, ColanderNullType, None]
4997 ) -> Any:
4998 # is null when form is empty
4999 if cstruct in (null, None):
5000 return None
5002 cstruct: str
5004 try:
5005 # Validation happens on the widget class
5006 json_value = json.loads(cstruct)
5007 except json.JSONDecodeError:
5008 return None
5010 return json_value
5012 # noinspection PyMethodMayBeStatic,PyUnusedLocal
5013 def serialize(
5014 self, node: SchemaNode, appstruct: Union[Dict, None, ColanderNullType]
5015 ) -> Union[str, ColanderNullType]:
5016 # is null when form is empty (new record)
5017 # is None when populated from empty value in the database
5018 if appstruct in (null, None):
5019 return null
5021 # appstruct should be well formed here (it would already have failed
5022 # when reading from the database)
5023 return json.dumps(appstruct)
5026class JsonWidget(Widget):
5027 """
5028 Widget supporting jsoneditor https://github.com/josdejong/jsoneditor
5029 """
5031 basedir = os.path.join(TEMPLATE_DIR, "deform")
5032 readonlydir = os.path.join(basedir, "readonly")
5033 form = "json.pt"
5034 template = os.path.join(basedir, form)
5035 readonly_template = os.path.join(readonlydir, form)
5036 requirements = (("jsoneditor", None),)
5038 def __init__(self, request: "CamcopsRequest", **kwargs: Any) -> None:
5039 super().__init__(**kwargs)
5040 self.request = request
5042 def serialize(
5043 self, field: "Field", cstruct: Union[str, ColanderNullType], **kw: Any
5044 ) -> Any:
5045 if cstruct is null:
5046 cstruct = ""
5048 readonly = kw.get("readonly", self.readonly)
5049 template = readonly and self.readonly_template or self.template
5051 values = self.get_template_values(field, cstruct, kw)
5053 return field.renderer(template, **values)
5055 def deserialize(
5056 self, field: "Field", pstruct: Union[str, ColanderNullType]
5057 ) -> Union[str, ColanderNullType]:
5058 # is empty string when field is empty
5059 if pstruct in (null, ""):
5060 return null
5062 _ = self.request.gettext
5063 error_message = _("Please enter valid JSON or leave blank")
5065 pstruct: str
5067 try:
5068 json.loads(pstruct)
5069 except json.JSONDecodeError:
5070 raise Invalid(field, error_message, pstruct)
5072 return pstruct
5075class JsonSettingsNode(SchemaNode, RequestAwareMixin):
5076 """
5077 Note to edit raw JSON.
5078 """
5080 schema_type = JsonType
5081 missing = null
5083 # noinspection PyUnusedLocal,PyAttributeOutsideInit
5084 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
5085 _ = self.gettext
5086 self.widget = JsonWidget(self.request)
5087 self.title = _("Task-specific settings for this patient")
5088 self.description = _(
5089 "ADVANCED. Only applicable to tasks that are configurable on a "
5090 "per-patient basis. Format: JSON object, with settings keyed on "
5091 "task table name."
5092 )
5094 def validator(self, node: SchemaNode, value: Any) -> None:
5095 if value is not None:
5096 # will be None if JSON failed to validate
5097 if not isinstance(value, dict):
5098 _ = self.request.gettext
5099 error_message = _(
5100 "Please enter a valid JSON object (with settings keyed on "
5101 "task table name) or leave blank"
5102 )
5103 raise Invalid(node, error_message)
5106class TaskScheduleJsonSchema(Schema):
5107 """
5108 Schema for the advanced JSON parts of a patient-to-task-schedule mapping.
5109 """
5111 settings = JsonSettingsNode() # must match ViewParam.SETTINGS
5114class TaskScheduleNode(MappingSchema, RequestAwareMixin):
5115 """
5116 Node to edit settings for a patient-to-task-schedule mapping.
5117 """
5119 patient_task_schedule_id = (
5120 HiddenIntegerNode()
5121 ) # name must match ViewParam.PATIENT_TASK_SCHEDULE_ID
5122 schedule_id = TaskScheduleSelector() # must match ViewParam.SCHEDULE_ID
5123 start_datetime = (
5124 StartPendulumSelector()
5125 ) # must match ViewParam.START_DATETIME
5126 if DEFORM_ACCORDION_BUG:
5127 settings = JsonSettingsNode() # must match ViewParam.SETTINGS
5128 else:
5129 advanced = TaskScheduleJsonSchema( # must match ViewParam.ADVANCED
5130 widget=MappingWidget(template="mapping_accordion", open=False)
5131 )
5133 def __init__(self, *args: Any, **kwargs: Any) -> None:
5134 self.title = "" # for type checker
5135 super().__init__(*args, **kwargs)
5137 # noinspection PyUnusedLocal
5138 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
5139 _ = self.gettext
5140 self.title = _("Task schedule")
5141 start_datetime = get_child_node(self, "start_datetime")
5142 start_datetime.description = _(
5143 "Leave blank for the date the patient first downloads the schedule"
5144 )
5145 if not DEFORM_ACCORDION_BUG:
5146 advanced = get_child_node(self, "advanced")
5147 advanced.title = _("Advanced")
5150class TaskScheduleSequence(SequenceSchema, RequestAwareMixin):
5151 """
5152 Sequence for multiple patient-to-task-schedule mappings.
5153 """
5155 task_schedule_sequence = TaskScheduleNode()
5156 missing = drop
5158 def __init__(self, *args: Any, **kwargs: Any) -> None:
5159 self.title = "" # for type checker
5160 self.widget = None # type: Optional[Widget]
5161 super().__init__(*args, **kwargs)
5163 # noinspection PyUnusedLocal
5164 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
5165 _ = self.gettext
5166 self.title = _("Task Schedules")
5167 self.widget = TranslatableSequenceWidget(request=self.request)
5170class EditPatientSchema(CSRFSchema):
5171 """
5172 Schema to edit a patient.
5173 """
5175 server_pk = HiddenIntegerNode() # must match ViewParam.SERVER_PK
5176 forename = OptionalPatientNameNode() # must match ViewParam.FORENAME
5177 surname = OptionalPatientNameNode() # must match ViewParam.SURNAME
5178 dob = DateSelectorNode() # must match ViewParam.DOB
5179 sex = MandatorySexSelector() # must match ViewParam.SEX
5180 address = OptionalStringNode() # must match ViewParam.ADDRESS
5181 email = OptionalEmailNode() # must match ViewParam.EMAIL
5182 gp = OptionalStringNode() # must match ViewParam.GP
5183 other = OptionalStringNode() # must match ViewParam.OTHER
5184 id_references = (
5185 IdNumSequenceUniquePerWhichIdnum()
5186 ) # must match ViewParam.ID_REFERENCES
5188 # noinspection PyUnusedLocal
5189 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
5190 _ = self.gettext
5191 dob = get_child_node(self, "dob")
5192 dob.title = _("Date of birth")
5193 gp = get_child_node(self, "gp")
5194 gp.title = _("GP")
5196 def validator(self, node: SchemaNode, value: Any) -> None:
5197 request = self.bindings[Binding.REQUEST] # type: CamcopsRequest
5198 dbsession = request.dbsession
5199 group_id = value[ViewParam.GROUP_ID]
5200 group = Group.get_group_by_id(dbsession, group_id)
5201 testpatient = Patient()
5202 for k in EDIT_PATIENT_SIMPLE_PARAMS:
5203 setattr(testpatient, k, value[k])
5204 testpatient.idnums = []
5205 for idrefdict in value[ViewParam.ID_REFERENCES]:
5206 pidnum = PatientIdNum()
5207 pidnum.which_idnum = idrefdict[ViewParam.WHICH_IDNUM]
5208 pidnum.idnum_value = idrefdict[ViewParam.IDNUM_VALUE]
5209 testpatient.idnums.append(pidnum)
5210 tk_finalize_policy = TokenizedPolicy(group.finalize_policy)
5211 if not testpatient.satisfies_id_policy(tk_finalize_policy):
5212 _ = self.gettext
5213 raise Invalid(
5214 node,
5215 _("Patient would not meet 'finalize' ID policy for group:")
5216 + f" {group.name}! ["
5217 + _("That policy is:")
5218 + f" {group.finalize_policy!r}]",
5219 )
5222class DangerousEditPatientSchema(EditPatientSchema):
5223 group_id = HiddenIntegerNode() # must match ViewParam.GROUP_ID
5224 danger = TranslatableValidateDangerousOperationNode()
5227class EditServerCreatedPatientSchema(EditPatientSchema):
5228 # Must match ViewParam.GROUP_ID
5229 group_id = MandatoryGroupIdSelectorPatientGroups(insert_before="forename")
5230 task_schedules = (
5231 TaskScheduleSequence()
5232 ) # must match ViewParam.TASK_SCHEDULES
5235class EditFinalizedPatientForm(DangerousForm):
5236 """
5237 Form to edit a finalized patient.
5238 """
5240 def __init__(self, request: "CamcopsRequest", **kwargs: Any) -> None:
5241 _ = request.gettext
5242 super().__init__(
5243 schema_class=DangerousEditPatientSchema,
5244 submit_action=FormAction.SUBMIT,
5245 submit_title=_("Submit"),
5246 request=request,
5247 **kwargs,
5248 )
5251class EditServerCreatedPatientForm(DynamicDescriptionsNonceForm):
5252 """
5253 Form to add or edit a patient not yet on the device (for scheduled tasks)
5254 """
5256 def __init__(self, request: "CamcopsRequest", **kwargs: Any) -> None:
5257 schema = EditServerCreatedPatientSchema().bind(request=request)
5258 _ = request.gettext
5259 super().__init__(
5260 schema,
5261 request=request,
5262 buttons=[
5263 Button(
5264 name=FormAction.SUBMIT,
5265 title=_("Submit"),
5266 css_class="btn-danger",
5267 ),
5268 Button(name=FormAction.CANCEL, title=_("Cancel")),
5269 ],
5270 **kwargs,
5271 )
5274class EmailTemplateNode(OptionalStringNode, RequestAwareMixin):
5275 def __init__(self, *args: Any, **kwargs: Any) -> None:
5276 self.title = "" # for type checker
5277 self.description = "" # for type checker
5278 self.formatter = TaskScheduleEmailTemplateFormatter()
5279 super().__init__(*args, **kwargs)
5281 # noinspection PyUnusedLocal
5282 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
5283 _ = self.gettext
5284 self.title = _("Email template")
5285 self.description = _(
5286 "Template of email to be sent to patients when inviting them to "
5287 "complete the tasks in the schedule. Valid placeholders: {}"
5288 ).format(self.formatter.get_valid_parameters_string())
5290 # noinspection PyAttributeOutsideInit
5291 self.widget = RichTextWidget(options=get_tinymce_options(self.request))
5293 def validator(self, node: SchemaNode, value: Any) -> None:
5294 _ = self.gettext
5296 try:
5297 self.formatter.validate(value)
5298 return
5299 except KeyError as e:
5300 error = _("{bad_key} is not a valid placeholder").format(bad_key=e)
5301 except ValueError:
5302 error = _(
5303 "Invalid email template. Is there a missing '{' or '}' ?"
5304 )
5306 raise Invalid(node, error)
5309class EmailCcNode(OptionalEmailNode, RequestAwareMixin):
5310 # noinspection PyUnusedLocal
5311 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
5312 _ = self.gettext
5313 self.title = _("Email CC")
5314 self.description = _(
5315 "The patient will see these email addresses. Separate multiple "
5316 "addresses with commas."
5317 )
5320class EmailBccNode(OptionalEmailNode, RequestAwareMixin):
5321 # noinspection PyUnusedLocal
5322 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
5323 _ = self.gettext
5324 self.title = _("Email BCC")
5325 self.description = _(
5326 "The patient will not see these email addresses. Separate "
5327 "multiple addresses with commas."
5328 )
5331class EmailFromNode(OptionalEmailNode, RequestAwareMixin):
5332 # noinspection PyUnusedLocal
5333 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
5334 _ = self.gettext
5335 self.title = _('Email "From" address')
5336 self.description = _(
5337 "You must set this if you want to send emails to your patients"
5338 )
5341class TaskScheduleSchema(CSRFSchema):
5342 name = OptionalStringNode()
5343 group_id = (
5344 MandatoryGroupIdSelectorAdministeredGroups()
5345 ) # must match ViewParam.GROUP_ID
5346 email_from = EmailFromNode() # must match ViewParam.EMAIL_FROM
5347 email_cc = EmailCcNode() # must match ViewParam.EMAIL_CC
5348 email_bcc = EmailBccNode() # must match ViewParam.EMAIL_BCC
5349 email_subject = OptionalStringNode()
5350 email_template = EmailTemplateNode()
5353class EditTaskScheduleForm(DynamicDescriptionsNonceForm):
5354 def __init__(self, request: "CamcopsRequest", **kwargs: Any) -> None:
5355 schema = TaskScheduleSchema().bind(request=request)
5356 _ = request.gettext
5357 super().__init__(
5358 schema,
5359 request=request,
5360 buttons=[
5361 Button(
5362 name=FormAction.SUBMIT,
5363 title=_("Submit"),
5364 css_class="btn-danger",
5365 ),
5366 Button(name=FormAction.CANCEL, title=_("Cancel")),
5367 ],
5368 **kwargs,
5369 )
5372class DeleteTaskScheduleSchema(HardWorkConfirmationSchema):
5373 """
5374 Schema to delete a task schedule.
5375 """
5377 # name must match ViewParam.SCHEDULE_ID
5378 schedule_id = HiddenIntegerNode()
5379 danger = TranslatableValidateDangerousOperationNode()
5382class DeleteTaskScheduleForm(DeleteCancelForm):
5383 """
5384 Form to delete a task schedule.
5385 """
5387 def __init__(self, request: "CamcopsRequest", **kwargs: Any) -> None:
5388 super().__init__(
5389 schema_class=DeleteTaskScheduleSchema, request=request, **kwargs
5390 )
5393class DurationWidget(Widget):
5394 """
5395 Widget for entering a duration as a number of months, weeks and days.
5396 The default template renders three text input fields.
5397 Total days = (months * 30) + (weeks * 7) + days.
5398 """
5400 basedir = os.path.join(TEMPLATE_DIR, "deform")
5401 readonlydir = os.path.join(basedir, "readonly")
5402 form = "duration.pt"
5403 template = os.path.join(basedir, form)
5404 readonly_template = os.path.join(readonlydir, form)
5406 def __init__(self, request: "CamcopsRequest", **kwargs: Any) -> None:
5407 super().__init__(**kwargs)
5408 self.request = request
5410 def serialize(
5411 self,
5412 field: "Field",
5413 cstruct: Union[Dict[str, Any], None, ColanderNullType],
5414 **kw: Any,
5415 ) -> Any:
5416 # called when rendering the form with values from
5417 # DurationType.serialize
5418 if cstruct in (None, null):
5419 cstruct = {}
5421 cstruct: Dict[str, Any]
5423 months = cstruct.get("months", "")
5424 weeks = cstruct.get("weeks", "")
5425 days = cstruct.get("days", "")
5427 kw.setdefault("months", months)
5428 kw.setdefault("weeks", weeks)
5429 kw.setdefault("days", days)
5431 readonly = kw.get("readonly", self.readonly)
5432 template = readonly and self.readonly_template or self.template
5433 values = self.get_template_values(field, cstruct, kw)
5435 _ = self.request.gettext
5437 values.update(
5438 weeks_placeholder=_("1 week = 7 days"),
5439 months_placeholder=_("1 month = 30 days"),
5440 months_label=_("Months"),
5441 weeks_label=_("Weeks"),
5442 days_label=_("Days"),
5443 )
5445 return field.renderer(template, **values)
5447 def deserialize(
5448 self, field: "Field", pstruct: Union[Dict[str, Any], ColanderNullType]
5449 ) -> Dict[str, int]:
5450 # called when validating the form on submission
5451 # value is passed to the schema deserialize()
5453 if pstruct is null:
5454 pstruct = {}
5456 pstruct: Dict[str, Any]
5458 errors = []
5460 try:
5461 days = int(pstruct.get("days") or "0")
5462 except ValueError:
5463 errors.append("Please enter a valid number of days or leave blank")
5465 try:
5466 weeks = int(pstruct.get("weeks") or "0")
5467 except ValueError:
5468 errors.append(
5469 "Please enter a valid number of weeks or leave blank"
5470 )
5472 try:
5473 months = int(pstruct.get("months") or "0")
5474 except ValueError:
5475 errors.append(
5476 "Please enter a valid number of months or leave blank"
5477 )
5479 if len(errors) > 0:
5480 raise Invalid(field, errors, pstruct)
5482 # noinspection PyUnboundLocalVariable
5483 return {"days": days, "months": months, "weeks": weeks}
5486class DurationType(SchemaType):
5487 """
5488 Custom colander schema type to convert between Pendulum Duration objects
5489 and months, weeks and days.
5490 """
5492 # noinspection PyMethodMayBeStatic,PyUnusedLocal
5493 def deserialize(
5494 self,
5495 node: SchemaNode,
5496 cstruct: Union[Dict[str, Any], None, ColanderNullType],
5497 ) -> Optional[Duration]:
5498 # called when validating the submitted form with the total days
5499 # from DurationWidget.deserialize()
5500 if cstruct in (None, null):
5501 return None
5503 cstruct: Dict[str, Any]
5505 # may be passed invalid values when re-rendering widget with error
5506 # messages
5507 try:
5508 days = int(cstruct.get("days") or "0")
5509 except ValueError:
5510 days = 0
5512 try:
5513 weeks = int(cstruct.get("weeks") or "0")
5514 except ValueError:
5515 weeks = 0
5517 try:
5518 months = int(cstruct.get("months") or "0")
5519 except ValueError:
5520 months = 0
5522 total_days = months * 30 + weeks * 7 + days
5524 return Duration(days=total_days)
5526 # noinspection PyMethodMayBeStatic,PyUnusedLocal
5527 def serialize(
5528 self, node: SchemaNode, duration: Union[Duration, ColanderNullType]
5529 ) -> Union[Dict, ColanderNullType]:
5530 if duration is null:
5531 # For new schedule item
5532 return null
5534 duration: Duration
5536 total_days = duration.in_days()
5538 months = total_days // 30
5539 weeks = (total_days % 30) // 7
5540 days = (total_days % 30) % 7
5542 # Existing schedule item
5543 cstruct = {"days": days, "months": months, "weeks": weeks}
5545 return cstruct
5548class DurationNode(SchemaNode, RequestAwareMixin):
5549 schema_type = DurationType
5551 # noinspection PyUnusedLocal,PyAttributeOutsideInit
5552 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
5553 self.widget = DurationWidget(self.request)
5556class TaskScheduleItemSchema(CSRFSchema):
5557 schedule_id = HiddenIntegerNode() # name must match ViewParam.SCHEDULE_ID
5558 # name must match ViewParam.TABLE_NAME
5559 table_name = MandatorySingleTaskSelector()
5560 # name must match ViewParam.CLINICIAN_CONFIRMATION
5561 clinician_confirmation = BooleanNode(default=False)
5562 due_from = DurationNode() # name must match ViewParam.DUE_FROM
5563 due_within = DurationNode() # name must match ViewParam.DUE_WITHIN
5565 # noinspection PyUnusedLocal
5566 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
5567 _ = self.gettext
5568 due_from = get_child_node(self, "due_from")
5569 due_from.title = _("Due from")
5570 due_from.description = _(
5571 "Time from the start of schedule when the patient may begin this "
5572 "task"
5573 )
5574 due_within = get_child_node(self, "due_within")
5575 due_within.title = _("Due within")
5576 due_within.description = _(
5577 "Time the patient has to complete this task"
5578 )
5579 clinician_confirmation = get_child_node(self, "clinician_confirmation")
5580 clinician_confirmation.title = _("Allow clinician tasks")
5581 clinician_confirmation.label = None # type: ignore[attr-defined]
5582 clinician_confirmation.description = _(
5583 "Tick this box to schedule a task that would normally be "
5584 "completed by (or with) a clinician"
5585 )
5587 def validator(self, node: SchemaNode, value: Dict[str, Any]) -> None:
5588 task_class = self._get_task_class(value)
5590 self._validate_clinician_status(node, value, task_class)
5591 self._validate_due_dates(node, value)
5592 self._validate_task_ip_use(node, value, task_class)
5594 # noinspection PyMethodMayBeStatic
5595 def _get_task_class(self, value: Dict[str, Any]) -> Type["Task"]:
5596 return tablename_to_task_class_dict()[value[ViewParam.TABLE_NAME]]
5598 def _validate_clinician_status(
5599 self, node: SchemaNode, value: Dict[str, Any], task_class: Type["Task"]
5600 ) -> None:
5602 _ = self.gettext
5603 clinician_confirmation = value[ViewParam.CLINICIAN_CONFIRMATION]
5604 if task_class.has_clinician and not clinician_confirmation:
5605 raise Invalid(
5606 node,
5607 _(
5608 "You have selected the task '{task_name}', which a "
5609 "patient would not normally complete by themselves. "
5610 "If you are sure you want to do this, you must tick "
5611 "'Allow clinician tasks'."
5612 ).format(task_name=task_class.shortname),
5613 )
5615 def _validate_due_dates(
5616 self, node: SchemaNode, value: Dict[str, Any]
5617 ) -> None:
5618 _ = self.gettext
5619 due_from = value[ViewParam.DUE_FROM]
5620 if due_from.total_days() < 0:
5621 raise Invalid(node, _("'Due from' must be zero or more days"))
5623 due_within = value[ViewParam.DUE_WITHIN]
5624 if due_within.total_days() <= 0:
5625 raise Invalid(node, _("'Due within' must be more than zero days"))
5627 def _validate_task_ip_use(
5628 self, node: SchemaNode, value: Dict[str, Any], task_class: Type["Task"]
5629 ) -> None:
5631 _ = self.gettext
5633 if not task_class.prohibits_anything():
5634 return
5636 schedule_id = value[ViewParam.SCHEDULE_ID]
5637 schedule = (
5638 self.request.dbsession.query(TaskSchedule)
5639 .filter(TaskSchedule.id == schedule_id)
5640 .one()
5641 )
5643 if schedule.group.ip_use is None:
5644 raise Invalid(
5645 node,
5646 _(
5647 "The task you have selected prohibits use in certain "
5648 "contexts. The group '{group_name}' has no intellectual "
5649 "property settings. "
5650 "You need to edit the group '{group_name}' to say which "
5651 "contexts it operates in.".format(
5652 group_name=schedule.group.name
5653 )
5654 ),
5655 )
5657 # TODO: On the client we say 'to use this task, you must seek
5658 # permission from the copyright holder'. We could do the same but at
5659 # the moment there isn't a way of telling the system that we have done
5660 # so.
5661 if (
5662 task_class.prohibits_commercial
5663 and schedule.group.ip_use.commercial
5664 ):
5665 raise Invalid(
5666 node,
5667 _(
5668 "The group '{group_name}' associated with schedule "
5669 "'{schedule_name}' operates in a "
5670 "commercial context but the task you have selected "
5671 "prohibits commercial use."
5672 ).format(
5673 group_name=schedule.group.name, schedule_name=schedule.name
5674 ),
5675 )
5677 if task_class.prohibits_clinical and schedule.group.ip_use.clinical:
5678 raise Invalid(
5679 node,
5680 _(
5681 "The group '{group_name}' associated with schedule "
5682 "'{schedule_name}' operates in a "
5683 "clinical context but the task you have selected "
5684 "prohibits clinical use."
5685 ).format(
5686 group_name=schedule.group.name, schedule_name=schedule.name
5687 ),
5688 )
5690 if (
5691 task_class.prohibits_educational
5692 and schedule.group.ip_use.educational
5693 ):
5694 raise Invalid(
5695 node,
5696 _(
5697 "The group '{group_name}' associated with schedule "
5698 "'{schedule_name}' operates in an "
5699 "educational context but the task you have selected "
5700 "prohibits educational use."
5701 ).format(
5702 group_name=schedule.group.name, schedule_name=schedule.name
5703 ),
5704 )
5706 if task_class.prohibits_research and schedule.group.ip_use.research:
5707 raise Invalid(
5708 node,
5709 _(
5710 "The group '{group_name}' associated with schedule "
5711 "'{schedule_name}' operates in a "
5712 "research context but the task you have selected "
5713 "prohibits research use."
5714 ).format(
5715 group_name=schedule.group.name, schedule_name=schedule.name
5716 ),
5717 )
5720class EditTaskScheduleItemForm(DynamicDescriptionsNonceForm):
5721 def __init__(self, request: "CamcopsRequest", **kwargs: Any) -> None:
5722 schema = TaskScheduleItemSchema().bind(request=request)
5723 _ = request.gettext
5724 super().__init__(
5725 schema,
5726 request=request,
5727 buttons=[
5728 Button(
5729 name=FormAction.SUBMIT,
5730 title=_("Submit"),
5731 css_class="btn-danger",
5732 ),
5733 Button(name=FormAction.CANCEL, title=_("Cancel")),
5734 ],
5735 **kwargs,
5736 )
5739class DeleteTaskScheduleItemSchema(HardWorkConfirmationSchema):
5740 """
5741 Schema to delete a task schedule item.
5742 """
5744 # name must match ViewParam.SCHEDULE_ITEM_ID
5745 schedule_item_id = HiddenIntegerNode()
5746 danger = TranslatableValidateDangerousOperationNode()
5749class DeleteTaskScheduleItemForm(DeleteCancelForm):
5750 """
5751 Form to delete a task schedule item.
5752 """
5754 def __init__(self, request: "CamcopsRequest", **kwargs: Any) -> None:
5755 super().__init__(
5756 schema_class=DeleteTaskScheduleItemSchema,
5757 request=request,
5758 **kwargs,
5759 )
5762class ForciblyFinalizeChooseDeviceSchema(CSRFSchema):
5763 """
5764 Schema to force-finalize records from a device.
5765 """
5767 device_id = MandatoryDeviceIdSelector() # must match ViewParam.DEVICE_ID
5768 danger = TranslatableValidateDangerousOperationNode()
5771class ForciblyFinalizeChooseDeviceForm(SimpleSubmitForm):
5772 """
5773 Form to force-finalize records from a device.
5774 """
5776 def __init__(self, request: "CamcopsRequest", **kwargs: Any) -> None:
5777 _ = request.gettext
5778 super().__init__(
5779 schema_class=ForciblyFinalizeChooseDeviceSchema,
5780 submit_title=_("View affected tasks"),
5781 request=request,
5782 **kwargs,
5783 )
5786class ForciblyFinalizeConfirmSchema(HardWorkConfirmationSchema):
5787 """
5788 Schema to confirm force-finalizing of a device.
5789 """
5791 device_id = HiddenIntegerNode() # must match ViewParam.DEVICE_ID
5792 danger = TranslatableValidateDangerousOperationNode()
5795class ForciblyFinalizeConfirmForm(DangerousForm):
5796 """
5797 Form to confirm force-finalizing of a device.
5798 """
5800 def __init__(self, request: "CamcopsRequest", **kwargs: Any) -> None:
5801 _ = request.gettext
5802 super().__init__(
5803 schema_class=ForciblyFinalizeConfirmSchema,
5804 submit_action=FormAction.FINALIZE,
5805 submit_title=_("Forcibly finalize"),
5806 request=request,
5807 **kwargs,
5808 )
5811# =============================================================================
5812# User downloads
5813# =============================================================================
5816class HiddenDownloadFilenameNode(HiddenStringNode, RequestAwareMixin):
5817 """
5818 Note to encode a hidden filename.
5819 """
5821 # noinspection PyMethodMayBeStatic
5822 def validator(self, node: SchemaNode, value: str) -> None:
5823 if value:
5824 try:
5825 validate_download_filename(value, self.request)
5826 except ValueError as e:
5827 raise Invalid(node, str(e))
5830class UserDownloadDeleteSchema(CSRFSchema):
5831 """
5832 Schema to capture details of a file to be deleted.
5833 """
5835 filename = (
5836 HiddenDownloadFilenameNode()
5837 ) # name must match ViewParam.FILENAME
5840class UserDownloadDeleteForm(SimpleSubmitForm):
5841 """
5842 Form that provides a single button to delete a user download.
5843 """
5845 def __init__(self, request: "CamcopsRequest", **kwargs: Any) -> None:
5846 _ = request.gettext
5847 super().__init__(
5848 schema_class=UserDownloadDeleteSchema,
5849 submit_title=_("Delete"),
5850 request=request,
5851 **kwargs,
5852 )
5855class EmailBodyNode(MandatoryStringNode, RequestAwareMixin):
5856 def __init__(self, *args: Any, **kwargs: Any) -> None:
5857 self.title = "" # for type checker
5858 super().__init__(*args, **kwargs)
5860 # noinspection PyUnusedLocal
5861 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
5862 _ = self.gettext
5864 self.title = _("Message")
5866 # noinspection PyAttributeOutsideInit
5867 self.widget = RichTextWidget(options=get_tinymce_options(self.request))
5870class SendEmailSchema(CSRFSchema):
5871 email = MandatoryEmailNode() # name must match ViewParam.EMAIL
5872 email_cc = HiddenStringNode()
5873 email_bcc = HiddenStringNode()
5874 email_from = HiddenStringNode()
5875 email_subject = MandatoryStringNode()
5876 email_body = EmailBodyNode()
5879class SendEmailForm(InformativeNonceForm):
5880 """
5881 Form for sending email
5882 """
5884 def __init__(self, request: "CamcopsRequest", **kwargs: Any) -> None:
5885 schema = SendEmailSchema().bind(request=request)
5886 _ = request.gettext
5887 super().__init__(
5888 schema,
5889 buttons=[
5890 Button(name=FormAction.SUBMIT, title=_("Send")),
5891 Button(name=FormAction.CANCEL, title=_("Cancel")),
5892 ],
5893 **kwargs,
5894 )