Coverage for cc_modules/cc_forms.py : 52%

Hot-keys on this page
r m x p toggle line displays
j k next/prev highlighted chunk
0 (zero) top of page
1 (one) first highlighted chunk
1#!/usr/bin/env python
3"""
4camcops_server/cc_modules/cc_forms.py
6===============================================================================
8 Copyright (C) 2012-2020 Rudolf Cardinal (rudolf@pobox.com).
10 This file is part of CamCOPS.
12 CamCOPS is free software: you can redistribute it and/or modify
13 it under the terms of the GNU General Public License as published by
14 the Free Software Foundation, either version 3 of the License, or
15 (at your option) any later version.
17 CamCOPS is distributed in the hope that it will be useful,
18 but WITHOUT ANY WARRANTY; without even the implied warranty of
19 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
20 GNU General Public License for more details.
22 You should have received a copy of the GNU General Public License
23 along with CamCOPS. If not, see <https://www.gnu.org/licenses/>.
25===============================================================================
27**Forms for use by the web front end.**
29*COLANDER NODES, NULLS, AND VALIDATION*
31- Surprisingly tricky.
32- Nodes must be validly intialized with NO USER-DEFINED PARAMETERS to __init__;
33 the Deform framework clones them.
34- A null appstruct is used to initialize nodes as Forms are created.
35 Therefore, the "default" value must be acceptable to the underlying type's
36 serialize() function. Note in particular that "default = None" is not
37 acceptable to Integer. Having no default is fine, though.
38- In general, flexible inheritance is very hard to implement.
40- Note that this error:
42 .. code-block:: none
44 AttributeError: 'EditTaskFilterSchema' object has no attribute 'typ'
46 means you have failed to call super().__init__() properly from __init__().
48- When creating a schema, its members seem to have to be created in the class
49 declaration as class properties, not in __init__().
51*ACCESSING THE PYRAMID REQUEST IN FORMS AND SCHEMAS*
53We often want to be able to access the request for translation purposes, or
54sometimes more specialized reasons.
56Forms are created dynamically as simple Python objects. So, for a
57:class:`deform.form.Form`, just add a ``request`` parameter to the constructor,
58and pass it when you create the form. An example is
59:class:`camcops_server.cc_modules.cc_forms.DeleteCancelForm`.
61For a :class:`colander.Schema` and :class:`colander.SchemaNode`, construction
62is separate from binding. The schema nodes are created as part of a schema
63class, not a schema instance. The schema is created by the form, and then bound
64to a request. Access to the request is therefore via the :func:`after_bind`
65callback function, offered by colander, via the ``kw`` parameter or
66``self.bindings``. We use ``Binding.REQUEST`` as a standard key for this
67dictionary. The bindings are also available in :func:`validator` and similar
68functions, as ``self.bindings``.
70All forms containing any schema that needs to see the request should have this
71sort of ``__init__`` function:
73.. code-block:: python
75 class SomeForm(...):
76 def __init__(...):
77 schema = schema_class().bind(request=request)
78 super().__init__(
79 schema,
80 ...,
81 **kwargs
82 )
84The simplest thing, therefore, is for all forms to do this. Some of our forms
85use a form superclass that does this via the ``schema_class`` argument (which
86is not part of colander, so if you see that, the superclass should do the work
87of binding a request).
89For translation, throughout there will be ``_ = self.gettext`` or ``_ =
90request.gettext``.
92Form titles need to be dynamically written via
93:class:`cardinal_pythonlib.deform_utils.DynamicDescriptionsForm` or similar.
95"""
97import json
98import logging
99import os
100from typing import (Any, Callable, Dict, List, Optional,
101 Tuple, Type, TYPE_CHECKING, Union)
103from cardinal_pythonlib.colander_utils import (
104 AllowNoneType,
105 BooleanNode,
106 DateSelectorNode,
107 DateTimeSelectorNode,
108 DEFAULT_WIDGET_DATE_OPTIONS_FOR_PENDULUM,
109 DEFAULT_WIDGET_TIME_OPTIONS_FOR_PENDULUM,
110 get_child_node,
111 get_values_and_permissible,
112 HiddenIntegerNode,
113 HiddenStringNode,
114 MandatoryStringNode,
115 OptionalEmailNode,
116 OptionalIntNode,
117 OptionalPendulumNode,
118 OptionalStringNode,
119 ValidateDangerousOperationNode,
120)
121from cardinal_pythonlib.deform_utils import (
122 DynamicDescriptionsForm,
123 InformativeForm,
124)
125from cardinal_pythonlib.logs import (
126 BraceStyleAdapter,
127)
128from cardinal_pythonlib.sqlalchemy.dialect import SqlaDialectName
129from cardinal_pythonlib.sqlalchemy.orm_query import CountStarSpecializedQuery
130# noinspection PyProtectedMember
131from colander import (
132 Boolean,
133 Date,
134 drop,
135 Integer,
136 Invalid,
137 Length,
138 MappingSchema,
139 null,
140 OneOf,
141 Range,
142 Schema,
143 SchemaNode,
144 SchemaType,
145 SequenceSchema,
146 Set,
147 String,
148 _null,
149)
150from deform.form import Button
151from deform.widget import (
152 CheckboxChoiceWidget,
153 CheckedPasswordWidget,
154 # DateInputWidget,
155 DateTimeInputWidget,
156 FormWidget,
157 HiddenWidget,
158 MappingWidget,
159 PasswordWidget,
160 RadioChoiceWidget,
161 SelectWidget,
162 SequenceWidget,
163 TextAreaWidget,
164 TextInputWidget,
165 Widget,
166)
168from pendulum import Duration
170# import as LITTLE AS POSSIBLE; this is used by lots of modules
171# We use some delayed imports here (search for "delayed import")
172from camcops_server.cc_modules.cc_baseconstants import (
173 DEFORM_SUPPORTS_CSP_NONCE,
174 TEMPLATE_DIR,
175)
176from camcops_server.cc_modules.cc_constants import (
177 ConfigParamSite,
178 DEFAULT_ROWS_PER_PAGE,
179 SEX_OTHER_UNSPECIFIED,
180 SEX_FEMALE,
181 SEX_MALE,
182 StringLengths,
183 USER_NAME_FOR_SYSTEM,
184)
185from camcops_server.cc_modules.cc_group import Group
186from camcops_server.cc_modules.cc_idnumdef import (
187 IdNumDefinition,
188 ID_NUM_VALIDATION_METHOD_CHOICES,
189 validate_id_number,
190)
191from camcops_server.cc_modules.cc_ipuse import IpUse
192from camcops_server.cc_modules.cc_language import (
193 DEFAULT_LOCALE,
194 POSSIBLE_LOCALES,
195 POSSIBLE_LOCALES_WITH_DESCRIPTIONS,
196)
197from camcops_server.cc_modules.cc_patient import Patient
198from camcops_server.cc_modules.cc_patientidnum import PatientIdNum
199from camcops_server.cc_modules.cc_policy import (
200 TABLET_ID_POLICY_STR,
201 TokenizedPolicy,
202)
203from camcops_server.cc_modules.cc_pyramid import (
204 FormAction,
205 RequestMethod,
206 ViewArg,
207 ViewParam,
208)
209from camcops_server.cc_modules.cc_task import tablename_to_task_class_dict
210from camcops_server.cc_modules.cc_taskschedule import (
211 TaskSchedule,
212 TaskScheduleEmailTemplateFormatter,
213)
214from camcops_server.cc_modules.cc_validators import (
215 ALPHANUM_UNDERSCORE_CHAR,
216 validate_anything,
217 validate_by_char_and_length,
218 validate_group_name,
219 validate_hl7_aa,
220 validate_hl7_id_type,
221 validate_ip_address,
222 validate_new_password,
223 validate_redirect_url,
224 validate_username,
225)
227if TYPE_CHECKING:
228 from deform.field import Field
229 from camcops_server.cc_modules.cc_request import CamcopsRequest
230 from camcops_server.cc_modules.cc_task import Task
231 from camcops_server.cc_modules.cc_user import User
233log = BraceStyleAdapter(logging.getLogger(__name__))
235ColanderNullType = _null
236ValidatorType = Callable[[SchemaNode, Any], None] # called as v(node, value)
239# =============================================================================
240# Debugging options
241# =============================================================================
243DEBUG_CSRF_CHECK = False
245if DEBUG_CSRF_CHECK:
246 log.warning("Debugging options enabled!")
249# =============================================================================
250# Constants
251# =============================================================================
253class Binding(object):
254 """
255 Keys used for binding dictionaries with Colander schemas (schemata).
257 Must match ``kwargs`` of calls to ``bind()`` function of each ``Schema``.
258 """
259 GROUP = "group"
260 OPEN_ADMIN = "open_admin"
261 OPEN_WHAT = "open_what"
262 OPEN_WHEN = "open_when"
263 OPEN_WHO = "open_who"
264 REQUEST = "request"
265 TRACKER_TASKS_ONLY = "tracker_tasks_only"
266 USER = "user"
269class BootstrapCssClasses(object):
270 """
271 Constants from Bootstrap to control display.
272 """
273 FORM_INLINE = "form-inline"
274 RADIO_INLINE = "radio-inline"
275 LIST_INLINE = "list-inline"
276 CHECKBOX_INLINE = "checkbox-inline"
279AUTOCOMPLETE_ATTR = "autocomplete"
282class AutocompleteAttrValues(object):
283 """
284 Some values for the HTML "autocomplete" attribute, as per
285 https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/autocomplete.
286 Not all are used.
287 """
288 BDAY = "bday"
289 CURRENT_PASSWORD = "current-password"
290 EMAIL = "email"
291 FAMILY_NAME = "family-name"
292 GIVEN_NAME = "given-name"
293 NEW_PASSWORD = "new-password"
294 OFF = "off"
295 ON = "on" # browser decides
296 STREET_ADDRESS = "stree-address"
297 USERNAME = "username"
300# =============================================================================
301# Common phrases for translation
302# =============================================================================
304def or_join_description(request: "CamcopsRequest") -> str:
305 _ = request.gettext
306 return _("If you specify more than one, they will be joined with OR.")
309def change_password_title(request: "CamcopsRequest") -> str:
310 _ = request.gettext
311 return _("Change password")
314def sex_choices(request: "CamcopsRequest") -> List[Tuple[str, str]]:
315 _ = request.gettext
316 return [
317 (SEX_FEMALE, _("Female (F)")),
318 (SEX_MALE, _("Male (M)")),
319 # TRANSLATOR: sex code description
320 (SEX_OTHER_UNSPECIFIED, _("Other/unspecified (X)")),
321 ]
324# =============================================================================
325# Deform bug fix: SelectWidget "multiple" attribute
326# =============================================================================
328class BugfixSelectWidget(SelectWidget):
329 """
330 Fixes a bug where newer versions of Chameleon (e.g. 3.8.0) render Deform's
331 ``multiple = False`` (in ``SelectWidget``) as this, which is wrong:
333 .. code-block:: none
335 <select name="which_idnum" id="deformField2" class=" form-control " multiple="False">
336 ^^^^^^^^^^^^^^^^
337 <option value="1">CPFT RiO number</option>
338 <option value="2">NHS number</option>
339 <option value="1000">MyHospital number</option>
340 </select>
342 ... whereas previous versions of Chameleon (e.g. 3.4) omitted the tag.
343 (I think it's a Chameleon change, anyway! And it's probably a bugfix in
344 Chameleon that exposed a bug in Deform.)
346 See :func:`camcops_server.cc_modules.webview.debug_form_rendering`.
347 """ # noqa
348 def __init__(self, multiple=False, **kwargs) -> None:
349 multiple = True if multiple else None # None, not False
350 super().__init__(multiple=multiple, **kwargs)
353SelectWidget = BugfixSelectWidget
356# =============================================================================
357# Form that handles Content-Security-Policy nonce tags
358# =============================================================================
360class InformativeNonceForm(InformativeForm):
361 """
362 A Form class to use our modifications to Deform, as per
363 https://github.com/Pylons/deform/issues/512, to pass a nonce value through
364 to the ``<script>`` and ``<style>`` tags in the Deform templates.
366 todo: if Deform is updated, work this into ``cardinal_pythonlib``.
367 """
368 if DEFORM_SUPPORTS_CSP_NONCE:
369 def __init__(self, schema: Schema, **kwargs) -> None:
370 request = schema.request # type: CamcopsRequest
371 kwargs["nonce"] = request.nonce
372 super().__init__(schema, **kwargs)
375class DynamicDescriptionsNonceForm(DynamicDescriptionsForm):
376 """
377 Similarly; see :class:`InformativeNonceForm`.
379 todo: if Deform is updated, work this into ``cardinal_pythonlib``.
380 """
381 if DEFORM_SUPPORTS_CSP_NONCE:
382 def __init__(self, schema: Schema, **kwargs) -> None:
383 request = schema.request # type: CamcopsRequest
384 kwargs["nonce"] = request.nonce
385 super().__init__(schema, **kwargs)
388# =============================================================================
389# Mixin for Schema/SchemaNode objects for translation
390# =============================================================================
392GETTEXT_TYPE = Callable[[str], str]
395class RequestAwareMixin(object):
396 """
397 Mixin to add Pyramid request awareness to Schema/SchemaNode objects,
398 together with some translations and other convenience functions.
399 """
400 def __init__(self, *args, **kwargs) -> None:
401 # Stop multiple inheritance complaints
402 super().__init__(*args, **kwargs)
404 # noinspection PyUnresolvedReferences
405 @property
406 def request(self) -> "CamcopsRequest":
407 return self.bindings[Binding.REQUEST]
409 # noinspection PyUnresolvedReferences,PyPropertyDefinition
410 @property
411 def gettext(self) -> GETTEXT_TYPE:
412 return self.request.gettext
414 @property
415 def or_join_description(self) -> str:
416 return or_join_description(self.request)
419# =============================================================================
420# Translatable version of ValidateDangerousOperationNode
421# =============================================================================
423class TranslatableValidateDangerousOperationNode(
424 ValidateDangerousOperationNode, RequestAwareMixin):
425 """
426 Translatable version of ValidateDangerousOperationNode.
427 """
428 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
429 super().after_bind(node, kw) # calls set_description()
430 _ = self.gettext
431 node.title = _("Danger")
432 user_entry = get_child_node(self, "user_entry")
433 user_entry.title = _("Validate this dangerous operation")
435 def set_description(self, target_value: str) -> None:
436 # Overrides parent version (q.v.).
437 _ = self.gettext
438 user_entry = get_child_node(self, "user_entry")
439 prefix = _("Please enter the following: ")
440 user_entry.description = prefix + target_value
443# =============================================================================
444# Translatable version of SequenceWidget
445# =============================================================================
447class TranslatableSequenceWidget(SequenceWidget):
448 """
449 SequenceWidget does support translation via _(), but not in a
450 request-specific way.
451 """
452 def __init__(self, request: "CamcopsRequest", **kwargs) -> None:
453 super().__init__(**kwargs)
454 _ = request.gettext
455 self.add_subitem_text_template = _('Add') + ' ${subitem_title}'
458# =============================================================================
459# Translatable version of OptionalPendulumNode
460# =============================================================================
462class TranslatableOptionalPendulumNode(OptionalPendulumNode,
463 RequestAwareMixin):
464 """
465 Translates the "Date" and "Time" labels for the widget, via
466 the request.
468 .. todo:: TranslatableOptionalPendulumNode not fully implemented
469 """
470 def __init__(self, *args, **kwargs) -> None:
471 super().__init__(*args, **kwargs)
472 self.widget = None # type: Optional[Widget]
474 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
475 _ = self.gettext
476 self.widget = DateTimeInputWidget(
477 date_options=DEFAULT_WIDGET_DATE_OPTIONS_FOR_PENDULUM,
478 time_options=DEFAULT_WIDGET_TIME_OPTIONS_FOR_PENDULUM
479 )
480 # log.critical("TranslatableOptionalPendulumNode.widget: {!r}",
481 # self.widget.__dict__)
484class TranslatableDateTimeSelectorNode(DateTimeSelectorNode,
485 RequestAwareMixin):
486 """
487 Translates the "Date" and "Time" labels for the widget, via
488 the request.
490 .. todo:: TranslatableDateTimeSelectorNode not fully implemented
491 """
492 def __init__(self, *args, **kwargs) -> None:
493 super().__init__(*args, **kwargs)
494 self.widget = None # type: Optional[Widget]
496 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
497 _ = self.gettext
498 self.widget = DateTimeInputWidget()
499 # log.critical("TranslatableDateTimeSelectorNode.widget: {!r}",
500 # self.widget.__dict__)
503'''
504class TranslatableDateSelectorNode(DateSelectorNode,
505 RequestAwareMixin):
506 """
507 Translates the "Date" and "Time" labels for the widget, via
508 the request.
510 .. todo:: TranslatableDateSelectorNode not fully implemented
511 """
512 def __init__(self, *args, **kwargs) -> None:
513 super().__init__(*args, **kwargs)
514 self.widget = None # type: Optional[Widget]
516 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
517 _ = self.gettext
518 self.widget = DateInputWidget()
519 # log.critical("TranslatableDateSelectorNode.widget: {!r}",
520 # self.widget.__dict__)
521'''
524# =============================================================================
525# CSRF
526# =============================================================================
528class CSRFToken(SchemaNode, RequestAwareMixin):
529 """
530 Node to embed a cross-site request forgery (CSRF) prevention token in a
531 form.
533 As per https://deformdemo.repoze.org/pyramid_csrf_demo/, modified for a
534 more recent Colander API.
536 NOTE that this makes use of colander.SchemaNode.bind; this CLONES the
537 Schema, and resolves any deferred values by means of the keywords passed to
538 bind(). Since the Schema is created at module load time, but since we're
539 asking the Schema to know about the request's CSRF values, this is the only
540 mechanism
541 (https://docs.pylonsproject.org/projects/colander/en/latest/api.html#colander.SchemaNode.bind).
543 From https://deform2000.readthedocs.io/en/latest/basics.html:
545 "The default of a schema node indicates the value to be serialized if a
546 value for the schema node is not found in the input data during
547 serialization. It should be the deserialized representation. If a schema
548 node does not have a default, it is considered "serialization required"."
550 "The missing of a schema node indicates the value to be deserialized if a
551 value for the schema node is not found in the input data during
552 deserialization. It should be the deserialized representation. If a schema
553 node does not have a missing value, a colander.Invalid exception will be
554 raised if the data structure being deserialized does not contain a matching
555 value."
557 RNC: Serialized values are always STRINGS.
559 """ # noqa
560 schema_type = String
561 default = ""
562 missing = ""
563 title = " "
564 # ... evaluates to True but won't be visible, if the "hidden" aspect ever
565 # fails
566 widget = HiddenWidget()
568 # noinspection PyUnusedLocal
569 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
570 request = self.request
571 csrf_token = request.session.get_csrf_token()
572 if DEBUG_CSRF_CHECK:
573 log.debug("Got CSRF token from session: {!r}", csrf_token)
574 self.default = csrf_token
576 def validator(self, node: SchemaNode, value: Any) -> None:
577 # Deferred validator via method, as per
578 # https://docs.pylonsproject.org/projects/colander/en/latest/basics.html # noqa
579 request = self.request
580 csrf_token = request.session.get_csrf_token() # type: str
581 matches = value == csrf_token
582 if DEBUG_CSRF_CHECK:
583 log.debug("Validating CSRF token: form says {!r}, session says "
584 "{!r}, matches = {}", value, csrf_token, matches)
585 if not matches:
586 log.warning("CSRF token mismatch; remote address {}",
587 request.remote_addr)
588 _ = request.gettext
589 raise Invalid(node, _("Bad CSRF token"))
592class CSRFSchema(Schema, RequestAwareMixin):
593 """
594 Base class for form schemas that use CSRF (XSRF; cross-site request
595 forgery) tokens.
597 You can't put the call to ``bind()`` at the end of ``__init__()``, because
598 ``bind()`` calls ``clone()`` with no arguments and ``clone()`` ends up
599 calling ``__init__()```...
601 The item name should be one that the ZAP penetration testing tool expects,
602 or you get:
604 .. code-block:: none
606 No known Anti-CSRF token [anticsrf, CSRFToken,
607 __RequestVerificationToken, csrfmiddlewaretoken, authenticity_token,
608 OWASP_CSRFTOKEN, anoncsrf, csrf_token, _csrf, _csrfSecret] was found in
609 the following HTML form: [Form 1: "_charset_" "__formid__"
610 "deformField1" "deformField2" "deformField3" "deformField4" ].
612 """
613 csrf_token = CSRFToken() # name must match ViewParam.CSRF_TOKEN
614 # ... name should also be one that ZAP expects, as above
617# =============================================================================
618# Horizontal forms
619# =============================================================================
621class HorizontalFormWidget(FormWidget):
622 """
623 Widget to render a form horizontally, with custom templates.
625 See :class:`deform.template.ZPTRendererFactory`, which explains how strings
626 are resolved to Chameleon ZPT (Zope) templates.
628 See
630 - https://stackoverflow.com/questions/12201835/form-inline-inside-a-form-horizontal-in-twitter-bootstrap
631 - https://stackoverflow.com/questions/18429121/inline-form-nested-within-horizontal-form-in-bootstrap-3
632 - https://stackoverflow.com/questions/23954772/how-to-make-a-horizontal-form-with-deform-2
633 """ # noqa
634 basedir = os.path.join(TEMPLATE_DIR, "deform")
635 readonlydir = os.path.join(basedir, "readonly")
636 form = "horizontal_form.pt"
637 mapping_item = "horizontal_mapping_item.pt"
639 template = os.path.join(basedir, form) # default "form" = deform/templates/form.pt # noqa
640 readonly_template = os.path.join(readonlydir, form) # default "readonly/form" # noqa
641 item_template = os.path.join(basedir, mapping_item) # default "mapping_item" # noqa
642 readonly_item_template = os.path.join(readonlydir, mapping_item) # default "readonly/mapping_item" # noqa
645class HorizontalFormMixin(object):
646 """
647 Modification to a Deform form that displays itself with horizontal layout,
648 using custom templates via :class:`HorizontalFormWidget`. Not fantastic.
649 """
650 def __init__(self, schema: Schema, *args, **kwargs) -> None:
651 kwargs = kwargs or {}
653 # METHOD 1: add "form-inline" to the CSS classes.
654 # extra_classes = "form-inline"
655 # if "css_class" in kwargs:
656 # kwargs["css_class"] += " " + extra_classes
657 # else:
658 # kwargs["css_class"] = extra_classes
660 # Method 2: change the widget
661 schema.widget = HorizontalFormWidget()
663 # OK, proceed.
664 super().__init__(schema, *args, **kwargs)
667def add_css_class(kwargs: Dict[str, Any],
668 extra_classes: str,
669 param_name: str = "css_class") -> None:
670 """
671 Modifies a kwargs dictionary to add a CSS class to the ``css_class``
672 parameter.
674 Args:
675 kwargs: a dictionary
676 extra_classes: CSS classes to add (as a space-separated string)
677 param_name: parameter name to modify; by default, "css_class"
678 """
679 if param_name in kwargs:
680 kwargs[param_name] += " " + extra_classes
681 else:
682 kwargs[param_name] = extra_classes
685class FormInlineCssMixin(object):
686 """
687 Modification to a Deform form that makes it display "inline" via CSS. This
688 has the effect of wrapping everything horizontally.
690 Should PRECEDE the :class:`Form` (or something derived from it) in the
691 inheritance order.
692 """
693 def __init__(self, *args, **kwargs) -> None:
694 kwargs = kwargs or {}
695 add_css_class(kwargs, BootstrapCssClasses.FORM_INLINE)
696 super().__init__(*args, **kwargs)
699def make_widget_horizontal(widget: Widget) -> None:
700 """
701 Applies Bootstrap "form-inline" styling to the widget.
702 """
703 widget.item_css_class = BootstrapCssClasses.FORM_INLINE
706def make_node_widget_horizontal(node: SchemaNode) -> None:
707 """
708 Applies Bootstrap "form-inline" styling to the schema node's widget.
710 **Note:** often better to use the ``inline=True`` option to the widget's
711 constructor.
712 """
713 make_widget_horizontal(node.widget)
716# =============================================================================
717# Specialized Form classes
718# =============================================================================
720class SimpleSubmitForm(InformativeNonceForm):
721 """
722 Form with a simple "submit" button.
723 """
724 def __init__(self,
725 schema_class: Type[Schema],
726 submit_title: str,
727 request: "CamcopsRequest",
728 **kwargs) -> None:
729 """
730 Args:
731 schema_class:
732 class of the Colander :class:`Schema` to use as this form's
733 schema
734 submit_title:
735 title (text) to be used for the "submit" button
736 request:
737 :class:`camcops_server.cc_modules.cc_request.CamcopsRequest`
738 """
739 schema = schema_class().bind(request=request)
740 super().__init__(
741 schema,
742 buttons=[Button(name=FormAction.SUBMIT,
743 title=submit_title)],
744 **kwargs
745 )
748class ApplyCancelForm(InformativeNonceForm):
749 """
750 Form with "apply" and "cancel" buttons.
751 """
752 def __init__(self,
753 schema_class: Type[Schema],
754 request: "CamcopsRequest",
755 **kwargs) -> None:
756 schema = schema_class().bind(request=request)
757 _ = request.gettext
758 super().__init__(
759 schema,
760 buttons=[
761 Button(name=FormAction.SUBMIT, title=_("Apply")),
762 Button(name=FormAction.CANCEL, title=_("Cancel")),
763 ],
764 **kwargs
765 )
768class AddCancelForm(InformativeNonceForm):
769 """
770 Form with "add" and "cancel" buttons.
771 """
772 def __init__(self,
773 schema_class: Type[Schema],
774 request: "CamcopsRequest",
775 **kwargs) -> None:
776 schema = schema_class().bind(request=request)
777 _ = request.gettext
778 super().__init__(
779 schema,
780 buttons=[
781 Button(name=FormAction.SUBMIT, title=_("Add")),
782 Button(name=FormAction.CANCEL, title=_("Cancel")),
783 ],
784 **kwargs
785 )
788class DangerousForm(DynamicDescriptionsNonceForm):
789 """
790 Form with one "submit" button (with user-specifiable title text and action
791 name), in a CSS class indicating that it's a dangerous operation, plus a
792 "Cancel" button.
793 """
794 def __init__(self,
795 schema_class: Type[Schema],
796 submit_action: str,
797 submit_title: str,
798 request: "CamcopsRequest",
799 **kwargs) -> None:
800 schema = schema_class().bind(request=request)
801 _ = request.gettext
802 super().__init__(
803 schema,
804 buttons=[
805 Button(name=submit_action, title=submit_title,
806 css_class="btn-danger"),
807 Button(name=FormAction.CANCEL, title=_("Cancel")),
808 ],
809 **kwargs
810 )
813class DeleteCancelForm(DangerousForm):
814 """
815 Form with a "delete" button (visually marked as dangerous) and a "cancel"
816 button.
817 """
818 def __init__(self,
819 schema_class: Type[Schema],
820 request: "CamcopsRequest",
821 **kwargs) -> None:
822 _ = request.gettext
823 super().__init__(
824 schema_class=schema_class,
825 submit_action=FormAction.DELETE,
826 submit_title=_("Delete"),
827 request=request,
828 **kwargs
829 )
832# =============================================================================
833# Specialized SchemaNode classes used in several contexts
834# =============================================================================
836# -----------------------------------------------------------------------------
837# Task types
838# -----------------------------------------------------------------------------
840class OptionalSingleTaskSelector(OptionalStringNode, RequestAwareMixin):
841 """
842 Node to pick one task type.
843 """
844 def __init__(self, *args, tracker_tasks_only: bool = False,
845 **kwargs) -> None:
846 """
847 Args:
848 tracker_tasks_only: restrict the choices to tasks that offer
849 trackers.
850 """
851 self.title = "" # for type checker
852 self.tracker_tasks_only = tracker_tasks_only
853 self.widget = None # type: Optional[Widget]
854 self.validator = None # type: Optional[ValidatorType]
855 super().__init__(*args, **kwargs)
857 # noinspection PyUnusedLocal
858 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
859 _ = self.gettext
860 self.title = _("Task type")
861 if Binding.TRACKER_TASKS_ONLY in kw:
862 self.tracker_tasks_only = kw[Binding.TRACKER_TASKS_ONLY]
863 values, pv = get_values_and_permissible(self.get_task_choices(),
864 True, _("[Any]"))
865 self.widget = SelectWidget(values=values)
866 self.validator = OneOf(pv)
868 def get_task_choices(self) -> List[Tuple[str, str]]:
869 from camcops_server.cc_modules.cc_task import Task # delayed import
870 choices = [] # type: List[Tuple[str, str]]
871 for tc in Task.all_subclasses_by_shortname():
872 if self.tracker_tasks_only and not tc.provides_trackers:
873 continue
874 choices.append((tc.tablename, tc.shortname))
875 return choices
878class MandatorySingleTaskSelector(MandatoryStringNode, RequestAwareMixin):
879 """
880 Node to pick one task type.
881 """
882 def __init__(self, *args: Any, **kwargs: Any) -> None:
883 self.title = "" # for type checker
884 self.widget = None # type: Optional[Widget]
885 self.validator = None # type: Optional[ValidatorType]
886 super().__init__(*args, **kwargs)
888 # noinspection PyUnusedLocal
889 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
890 _ = self.gettext
891 self.title = _("Task type")
892 values, pv = get_values_and_permissible(self.get_task_choices(), False)
893 self.widget = SelectWidget(values=values)
894 self.validator = OneOf(pv)
896 @staticmethod
897 def get_task_choices() -> List[Tuple[str, str]]:
898 from camcops_server.cc_modules.cc_task import Task # delayed import
899 choices = [] # type: List[Tuple[str, str]]
900 for tc in Task.all_subclasses_by_shortname():
901 choices.append((tc.tablename, tc.shortname))
902 return choices
905class MultiTaskSelector(SchemaNode, RequestAwareMixin):
906 """
907 Node to select multiple task types.
908 """
909 schema_type = Set
910 default = ""
911 missing = ""
913 def __init__(self, *args, tracker_tasks_only: bool = False,
914 minimum_number: int = 0, **kwargs) -> None:
915 self.tracker_tasks_only = tracker_tasks_only
916 self.minimum_number = minimum_number
917 self.widget = None # type: Optional[Widget]
918 self.validator = None # type: Optional[ValidatorType]
919 self.title = "" # for type checker
920 self.description = "" # for type checker
921 super().__init__(*args, **kwargs)
923 # noinspection PyUnusedLocal
924 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
925 _ = self.gettext
926 request = self.request # noqa: F841
927 self.title = _("Task type(s)")
928 self.description = (
929 _("If none are selected, all task types will be offered.") +
930 " " + self.or_join_description
931 )
932 if Binding.TRACKER_TASKS_ONLY in kw:
933 self.tracker_tasks_only = kw[Binding.TRACKER_TASKS_ONLY]
934 values, pv = get_values_and_permissible(self.get_task_choices())
935 self.widget = CheckboxChoiceWidget(values=values, inline=True)
936 self.validator = Length(min=self.minimum_number)
938 def get_task_choices(self) -> List[Tuple[str, str]]:
939 from camcops_server.cc_modules.cc_task import Task # delayed import
940 choices = [] # type: List[Tuple[str, str]]
941 for tc in Task.all_subclasses_by_shortname():
942 if self.tracker_tasks_only and not tc.provides_trackers:
943 continue
944 choices.append((tc.tablename, tc.shortname))
945 return choices
948# -----------------------------------------------------------------------------
949# Use the task index?
950# -----------------------------------------------------------------------------
952class ViaIndexSelector(BooleanNode, RequestAwareMixin):
953 """
954 Node to choose whether we use the server index or not.
955 Default is true.
956 """
957 def __init__(self, *args, **kwargs) -> None:
958 super().__init__(*args, default=True, **kwargs)
960 # noinspection PyUnusedLocal
961 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
962 _ = self.gettext
963 self.title = _("Use server index?")
964 self.label = _("Use server index? (Default is true; much faster.)")
967# -----------------------------------------------------------------------------
968# ID numbers
969# -----------------------------------------------------------------------------
971class MandatoryWhichIdNumSelector(SchemaNode, RequestAwareMixin):
972 """
973 Node to enforce the choice of a single ID number type (e.g. "NHS number"
974 or "study Blah ID number").
975 """
976 widget = SelectWidget()
978 def __init__(self, *args, **kwargs) -> None:
979 if not hasattr(self, "allow_none"):
980 # ... allows parameter-free (!) inheritance by OptionalWhichIdNumSelector # noqa
981 self.allow_none = False
982 self.title = "" # for type checker
983 self.description = "" # for type checker
984 self.validator = None # type: Optional[ValidatorType]
985 super().__init__(*args, **kwargs)
987 # noinspection PyUnusedLocal
988 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
989 request = self.request
990 _ = request.gettext
991 self.title = _("Identifier")
992 values = [] # type: List[Tuple[Optional[int], str]]
993 for iddef in request.idnum_definitions:
994 values.append((iddef.which_idnum, iddef.description))
995 values, pv = get_values_and_permissible(values, self.allow_none,
996 _("[ignore]"))
997 # ... can't use None, because SelectWidget() will convert that to
998 # "None"; can't use colander.null, because that converts to
999 # "<colander.null>"; use "", which is the default null_value of
1000 # SelectWidget.
1001 self.widget.values = values
1002 self.validator = OneOf(pv)
1004 @staticmethod
1005 def schema_type() -> SchemaType:
1006 return Integer()
1009class LinkingIdNumSelector(MandatoryWhichIdNumSelector):
1010 """
1011 Convenience node: pick a single ID number, with title/description
1012 indicating that it's the ID number to link on.
1013 """
1015 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
1016 super().after_bind(node, kw)
1017 _ = self.gettext
1018 self.title = _("Linking ID number")
1019 self.description = _("Which ID number to link on?")
1022class MandatoryIdNumValue(SchemaNode, RequestAwareMixin):
1023 """
1024 Mandatory node to capture an ID number value.
1025 """
1026 schema_type = Integer
1027 validator = Range(min=0)
1029 def __init__(self, *args, **kwargs) -> None:
1030 self.title = "" # for type checker
1031 super().__init__(*args, **kwargs)
1033 # noinspection PyUnusedLocal
1034 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
1035 _ = self.gettext
1036 self.title = _("ID# value")
1039class MandatoryIdNumNode(MappingSchema, RequestAwareMixin):
1040 """
1041 Mandatory node to capture an ID number type and the associated actual
1042 ID number (value).
1044 This is also where we apply ID number validation rules (e.g. NHS number).
1045 """
1046 which_idnum = MandatoryWhichIdNumSelector() # must match ViewParam.WHICH_IDNUM # noqa
1047 idnum_value = MandatoryIdNumValue() # must match ViewParam.IDNUM_VALUE
1049 def __init__(self, *args, **kwargs) -> None:
1050 self.title = "" # for type checker
1051 super().__init__(*args, **kwargs)
1053 # noinspection PyUnusedLocal
1054 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
1055 _ = self.gettext
1056 self.title = _("ID number")
1058 # noinspection PyMethodMayBeStatic
1059 def validator(self, node: SchemaNode, value: Dict[str, int]) -> None:
1060 assert isinstance(value, dict)
1061 req = self.request
1062 _ = req.gettext
1063 which_idnum = value[ViewParam.WHICH_IDNUM]
1064 idnum_value = value[ViewParam.IDNUM_VALUE]
1065 idnum_def = req.get_idnum_definition(which_idnum)
1066 if not idnum_def:
1067 raise Invalid(node, _("Bad ID number type")) # shouldn't happen
1068 method = idnum_def.validation_method
1069 if method:
1070 valid, why_invalid = validate_id_number(req, idnum_value, method)
1071 if not valid:
1072 raise Invalid(node, why_invalid)
1075class IdNumSequenceAnyCombination(SequenceSchema, RequestAwareMixin):
1076 """
1077 Sequence to capture multiple ID numbers (as type/value pairs).
1078 """
1079 idnum_sequence = MandatoryIdNumNode()
1081 def __init__(self, *args, **kwargs) -> None:
1082 self.title = "" # for type checker
1083 self.widget = None # type: Optional[Widget]
1084 super().__init__(*args, **kwargs)
1086 # noinspection PyUnusedLocal
1087 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
1088 _ = self.gettext
1089 self.title = _("ID numbers")
1090 self.widget = TranslatableSequenceWidget(request=self.request)
1092 # noinspection PyMethodMayBeStatic
1093 def validator(self, node: SchemaNode, value: List[Dict[str, int]]) -> None:
1094 assert isinstance(value, list)
1095 list_of_lists = [(x[ViewParam.WHICH_IDNUM], x[ViewParam.IDNUM_VALUE])
1096 for x in value]
1097 if len(list_of_lists) != len(set(list_of_lists)):
1098 _ = self.gettext
1099 raise Invalid(
1100 node,
1101 _("You have specified duplicate ID definitions"))
1104class IdNumSequenceUniquePerWhichIdnum(SequenceSchema, RequestAwareMixin):
1105 """
1106 Sequence to capture multiple ID numbers (as type/value pairs) but with only
1107 up to one per ID number type.
1108 """
1109 idnum_sequence = MandatoryIdNumNode()
1111 def __init__(self, *args, **kwargs) -> None:
1112 self.title = "" # for type checker
1113 self.widget = None # type: Optional[Widget]
1114 super().__init__(*args, **kwargs)
1116 # noinspection PyUnusedLocal
1117 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
1118 _ = self.gettext
1119 self.title = _("ID numbers")
1120 self.widget = TranslatableSequenceWidget(request=self.request)
1122 # noinspection PyMethodMayBeStatic
1123 def validator(self, node: SchemaNode, value: List[Dict[str, int]]) -> None:
1124 assert isinstance(value, list)
1125 which_idnums = [x[ViewParam.WHICH_IDNUM] for x in value]
1126 if len(which_idnums) != len(set(which_idnums)):
1127 _ = self.gettext
1128 raise Invalid(
1129 node,
1130 _("You have specified >1 value for one ID number type")
1131 )
1134# -----------------------------------------------------------------------------
1135# Sex
1136# -----------------------------------------------------------------------------
1138class OptionalSexSelector(OptionalStringNode, RequestAwareMixin):
1139 """
1140 Optional node to choose sex.
1141 """
1142 def __init__(self, *args, **kwargs) -> None:
1143 self.title = "" # for type checker
1144 self.validator = None # type: Optional[ValidatorType]
1145 self.widget = None # type: Optional[Widget]
1146 super().__init__(*args, **kwargs)
1148 # noinspection PyUnusedLocal
1149 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
1150 _ = self.gettext
1151 self.title = _("Sex")
1152 choices = sex_choices(self.request)
1153 values, pv = get_values_and_permissible(choices, True, _("Any"))
1154 self.widget = RadioChoiceWidget(values=values, inline=True)
1155 self.validator = OneOf(pv)
1158class MandatorySexSelector(MandatoryStringNode, RequestAwareMixin):
1159 """
1160 Mandatory node to choose sex.
1161 """
1162 def __init__(self, *args, **kwargs) -> None:
1163 self.title = "" # for type checker
1164 self.validator = None # type: Optional[ValidatorType]
1165 self.widget = None # type: Optional[Widget]
1166 super().__init__(*args, **kwargs)
1168 # noinspection PyUnusedLocal
1169 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
1170 _ = self.gettext
1171 self.title = _("Sex")
1172 choices = sex_choices(self.request)
1173 values, pv = get_values_and_permissible(choices)
1174 self.widget = RadioChoiceWidget(values=values, inline=True)
1175 self.validator = OneOf(pv)
1178# -----------------------------------------------------------------------------
1179# Users
1180# -----------------------------------------------------------------------------
1182class MandatoryUserIdSelectorUsersAllowedToSee(SchemaNode, RequestAwareMixin):
1183 """
1184 Mandatory node to choose a user, from the users that the requesting user
1185 is allowed to see.
1186 """
1187 schema_type = Integer
1189 def __init__(self, *args, **kwargs) -> None:
1190 self.title = "" # for type checker
1191 self.validator = None # type: Optional[ValidatorType]
1192 self.widget = None # type: Optional[Widget]
1193 super().__init__(*args, **kwargs)
1195 # noinspection PyUnusedLocal
1196 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
1197 from camcops_server.cc_modules.cc_user import User # delayed import
1198 _ = self.gettext
1199 self.title = _("User")
1200 request = self.request
1201 dbsession = request.dbsession
1202 user = request.user
1203 if user.superuser:
1204 users = dbsession.query(User).order_by(User.username)
1205 else:
1206 # Users in my groups, or groups I'm allowed to see
1207 my_allowed_group_ids = user.ids_of_groups_user_may_see
1208 users = dbsession.query(User)\
1209 .join(Group)\
1210 .filter(Group.id.in_(my_allowed_group_ids))\
1211 .order_by(User.username)
1212 values = [] # type: List[Tuple[Optional[int], str]]
1213 for user in users:
1214 values.append((user.id, user.username))
1215 values, pv = get_values_and_permissible(values, False)
1216 self.widget = SelectWidget(values=values)
1217 self.validator = OneOf(pv)
1220class OptionalUserNameSelector(OptionalStringNode, RequestAwareMixin):
1221 """
1222 Optional node to select a username, from all possible users.
1223 """
1224 title = "User"
1226 def __init__(self, *args, **kwargs) -> None:
1227 self.title = "" # for type checker
1228 self.validator = None # type: Optional[ValidatorType]
1229 self.widget = None # type: Optional[Widget]
1230 super().__init__(*args, **kwargs)
1232 # noinspection PyUnusedLocal
1233 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
1234 from camcops_server.cc_modules.cc_user import User # delayed import
1235 _ = self.gettext
1236 self.title = _("User")
1237 request = self.request
1238 dbsession = request.dbsession
1239 values = [] # type: List[Tuple[str, str]]
1240 users = dbsession.query(User).order_by(User.username)
1241 for user in users:
1242 values.append((user.username, user.username))
1243 values, pv = get_values_and_permissible(values, True, _("[ignore]"))
1244 self.widget = SelectWidget(values=values)
1245 self.validator = OneOf(pv)
1248class UsernameNode(SchemaNode, RequestAwareMixin):
1249 """
1250 Node to enter a username.
1251 """
1252 schema_type = String
1253 widget = TextInputWidget(attributes={
1254 AUTOCOMPLETE_ATTR: AutocompleteAttrValues.OFF
1255 })
1257 def __init__(self,
1258 *args,
1259 autocomplete: str = AutocompleteAttrValues.OFF,
1260 **kwargs) -> None:
1261 self.title = "" # for type checker
1262 self.autocomplete = autocomplete
1263 super().__init__(*args, **kwargs)
1265 # noinspection PyUnusedLocal
1266 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
1267 _ = self.gettext
1268 self.title = _("Username")
1269 # noinspection PyUnresolvedReferences
1270 self.widget.attributes[AUTOCOMPLETE_ATTR] = self.autocomplete
1272 def validator(self, node: SchemaNode, value: str) -> None:
1273 if value == USER_NAME_FOR_SYSTEM:
1274 _ = self.gettext
1275 raise Invalid(
1276 node,
1277 _("Cannot use system username") + " " +
1278 repr(USER_NAME_FOR_SYSTEM)
1279 )
1280 try:
1281 validate_username(value, self.request)
1282 except ValueError as e:
1283 raise Invalid(node, str(e))
1286class UserFilterSchema(Schema, RequestAwareMixin):
1287 """
1288 Schema to filter the list of users
1289 """
1290 # must match ViewParam.INCLUDE_AUTO_GENERATED
1291 include_auto_generated = BooleanNode()
1293 # noinspection PyUnusedLocal
1294 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
1295 _ = self.gettext
1296 include_auto_generated = get_child_node(self, "include_auto_generated")
1297 include_auto_generated.title = _("Include auto-generated users")
1298 include_auto_generated.label = None
1301class UserFilterForm(InformativeNonceForm):
1302 """
1303 Form to filter the list of users
1304 """
1305 def __init__(self, request: "CamcopsRequest", **kwargs: Any) -> None:
1306 _ = request.gettext
1307 schema = UserFilterSchema().bind(request=request)
1308 super().__init__(
1309 schema,
1310 buttons=[Button(name=FormAction.SET_FILTERS,
1311 title=_("Refresh"))],
1312 css_class=BootstrapCssClasses.FORM_INLINE,
1313 method=RequestMethod.GET,
1314 **kwargs
1315 )
1318# -----------------------------------------------------------------------------
1319# Devices
1320# -----------------------------------------------------------------------------
1322class MandatoryDeviceIdSelector(SchemaNode, RequestAwareMixin):
1323 """
1324 Mandatory node to select a client device ID.
1325 """
1326 schema_type = Integer
1328 def __init__(self, *args, **kwargs) -> None:
1329 self.title = "" # for type checker
1330 self.validator = None # type: Optional[ValidatorType]
1331 self.widget = None # type: Optional[Widget]
1332 super().__init__(*args, **kwargs)
1334 # noinspection PyUnusedLocal
1335 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
1336 from camcops_server.cc_modules.cc_device import Device # delayed import # noqa
1337 _ = self.gettext
1338 self.title = _("Device")
1339 request = self.request
1340 dbsession = request.dbsession
1341 devices = dbsession.query(Device).order_by(Device.friendly_name)
1342 values = [] # type: List[Tuple[Optional[int], str]]
1343 for device in devices:
1344 values.append((device.id, device.friendly_name))
1345 values, pv = get_values_and_permissible(values, False)
1346 self.widget = SelectWidget(values=values)
1347 self.validator = OneOf(pv)
1350# -----------------------------------------------------------------------------
1351# Server PK
1352# -----------------------------------------------------------------------------
1354class ServerPkSelector(OptionalIntNode, RequestAwareMixin):
1355 """
1356 Optional node to request an integer, marked as a server PK.
1357 """
1358 def __init__(self, *args, **kwargs) -> None:
1359 self.title = "" # for type checker
1360 super().__init__(*args, **kwargs)
1362 # noinspection PyUnusedLocal
1363 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
1364 _ = self.gettext
1365 self.title = _("Server PK")
1368# -----------------------------------------------------------------------------
1369# Dates/times
1370# -----------------------------------------------------------------------------
1372class StartPendulumSelector(TranslatableOptionalPendulumNode,
1373 RequestAwareMixin):
1374 """
1375 Optional node to select a start date/time.
1376 """
1377 def __init__(self, *args, **kwargs) -> None:
1378 self.title = "" # for type checker
1379 super().__init__(*args, **kwargs)
1381 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
1382 super().after_bind(node, kw)
1383 _ = self.gettext
1384 self.title = _("Start date/time (local timezone; inclusive)")
1387class EndPendulumSelector(TranslatableOptionalPendulumNode,
1388 RequestAwareMixin):
1389 """
1390 Optional node to select an end date/time.
1391 """
1392 def __init__(self, *args, **kwargs) -> None:
1393 self.title = "" # for type checker
1394 super().__init__(*args, **kwargs)
1396 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
1397 super().after_bind(node, kw)
1398 _ = self.gettext
1399 self.title = _("End date/time (local timezone; exclusive)")
1402class StartDateTimeSelector(TranslatableDateTimeSelectorNode,
1403 RequestAwareMixin):
1404 """
1405 Optional node to select a start date/time (in UTC).
1406 """
1407 def __init__(self, *args, **kwargs) -> None:
1408 self.title = "" # for type checker
1409 super().__init__(*args, **kwargs)
1411 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
1412 super().after_bind(node, kw)
1413 _ = self.gettext
1414 self.title = _("Start date/time (UTC; inclusive)")
1417class EndDateTimeSelector(TranslatableDateTimeSelectorNode,
1418 RequestAwareMixin):
1419 """
1420 Optional node to select an end date/time (in UTC).
1421 """
1422 def __init__(self, *args, **kwargs) -> None:
1423 self.title = "" # for type checker
1424 super().__init__(*args, **kwargs)
1426 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
1427 super().after_bind(node, kw)
1428 _ = self.gettext
1429 self.title = _("End date/time (UTC; exclusive)")
1432'''
1433class StartDateSelector(TranslatableDateSelectorNode,
1434 RequestAwareMixin):
1435 """
1436 Optional node to select a start date (in UTC).
1437 """
1438 def __init__(self, *args, **kwargs) -> None:
1439 self.title = "" # for type checker
1440 super().__init__(*args, **kwargs)
1442 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
1443 super().after_bind(node, kw)
1444 _ = self.gettext
1445 self.title = _("Start date (UTC; inclusive)")
1448class EndDateSelector(TranslatableDateSelectorNode,
1449 RequestAwareMixin):
1450 """
1451 Optional node to select an end date (in UTC).
1452 """
1453 def __init__(self, *args, **kwargs) -> None:
1454 self.title = "" # for type checker
1455 super().__init__(*args, **kwargs)
1457 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
1458 super().after_bind(node, kw)
1459 _ = self.gettext
1460 self.title = _("End date (UTC; inclusive)")
1461'''
1464# -----------------------------------------------------------------------------
1465# Rows per page
1466# -----------------------------------------------------------------------------
1468class RowsPerPageSelector(SchemaNode, RequestAwareMixin):
1469 """
1470 Node to select how many rows per page are shown.
1471 """
1472 _choices = ((10, "10"), (25, "25"), (50, "50"), (100, "100"))
1474 schema_type = Integer
1475 default = DEFAULT_ROWS_PER_PAGE
1476 widget = RadioChoiceWidget(values=_choices)
1477 validator = OneOf(list(x[0] for x in _choices))
1479 def __init__(self, *args, **kwargs) -> None:
1480 self.title = "" # for type checker
1481 super().__init__(*args, **kwargs)
1483 # noinspection PyUnusedLocal
1484 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
1485 _ = self.gettext
1486 self.title = _("Items to show per page")
1489# -----------------------------------------------------------------------------
1490# Groups
1491# -----------------------------------------------------------------------------
1493class MandatoryGroupIdSelectorAllGroups(SchemaNode, RequestAwareMixin):
1494 """
1495 Offers a picklist of groups from ALL POSSIBLE GROUPS.
1496 Used by superusers: "add user to any group".
1497 """
1498 def __init__(self, *args, **kwargs) -> None:
1499 self.title = "" # for type checker
1500 self.validator = None # type: Optional[ValidatorType]
1501 self.widget = None # type: Optional[Widget]
1502 super().__init__(*args, **kwargs)
1504 # noinspection PyUnusedLocal
1505 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
1506 _ = self.gettext
1507 self.title = _("Group")
1508 request = self.request
1509 dbsession = request.dbsession
1510 groups = dbsession.query(Group).order_by(Group.name)
1511 values = [(g.id, g.name) for g in groups]
1512 values, pv = get_values_and_permissible(values)
1513 self.widget = SelectWidget(values=values)
1514 self.validator = OneOf(pv)
1516 @staticmethod
1517 def schema_type() -> SchemaType:
1518 return Integer()
1521class MandatoryGroupIdSelectorAdministeredGroups(SchemaNode, RequestAwareMixin):
1522 """
1523 Offers a picklist of groups from GROUPS ADMINISTERED BY REQUESTOR.
1524 Used by groupadmins: "add user to one of my groups".
1525 """
1526 def __init__(self, *args, **kwargs) -> None:
1527 self.title = "" # for type checker
1528 self.validator = None # type: Optional[ValidatorType]
1529 self.widget = None # type: Optional[Widget]
1530 super().__init__(*args, **kwargs)
1532 # noinspection PyUnusedLocal
1533 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
1534 _ = self.gettext
1535 self.title = _("Group")
1536 request = self.request
1537 dbsession = request.dbsession
1538 administered_group_ids = request.user.ids_of_groups_user_is_admin_for
1539 groups = dbsession.query(Group).order_by(Group.name)
1540 values = [(g.id, g.name) for g in groups
1541 if g.id in administered_group_ids]
1542 values, pv = get_values_and_permissible(values)
1543 self.widget = SelectWidget(values=values)
1544 self.validator = OneOf(pv)
1546 @staticmethod
1547 def schema_type() -> SchemaType:
1548 return Integer()
1551class MandatoryGroupIdSelectorOtherGroups(SchemaNode, RequestAwareMixin):
1552 """
1553 Offers a picklist of groups THAT ARE NOT THE SPECIFIED GROUP (as specified
1554 in ``kw[Binding.GROUP]``).
1555 Used by superusers: "which other groups can this group see?"
1556 """
1557 def __init__(self, *args, **kwargs) -> None:
1558 self.title = "" # for type checker
1559 self.validator = None # type: Optional[ValidatorType]
1560 self.widget = None # type: Optional[Widget]
1561 super().__init__(*args, **kwargs)
1563 # noinspection PyUnusedLocal
1564 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
1565 _ = self.gettext
1566 self.title = _("Other group")
1567 request = self.request
1568 group = kw[Binding.GROUP] # type: Group # ATYPICAL BINDING
1569 dbsession = request.dbsession
1570 groups = dbsession.query(Group).order_by(Group.name)
1571 values = [(g.id, g.name) for g in groups if g.id != group.id]
1572 values, pv = get_values_and_permissible(values)
1573 self.widget = SelectWidget(values=values)
1574 self.validator = OneOf(pv)
1576 @staticmethod
1577 def schema_type() -> SchemaType:
1578 return Integer()
1581class MandatoryGroupIdSelectorUserGroups(SchemaNode, RequestAwareMixin):
1582 """
1583 Offers a picklist of groups from THOSE THE USER IS A MEMBER OF.
1584 Used for: "which of your groups do you want to upload into?"
1585 """
1586 def __init__(self, *args, **kwargs) -> None:
1587 if not hasattr(self, "allow_none"):
1588 # ... allows parameter-free (!) inheritance by OptionalGroupIdSelectorUserGroups # noqa
1589 self.allow_none = False
1590 self.title = "" # for type checker
1591 self.validator = None # type: Optional[ValidatorType]
1592 self.widget = None # type: Optional[Widget]
1593 super().__init__(*args, **kwargs)
1595 # noinspection PyUnusedLocal
1596 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
1597 _ = self.gettext
1598 self.title = _("Group")
1599 user = kw[Binding.USER] # type: User # ATYPICAL BINDING
1600 groups = sorted(list(user.groups), key=lambda g: g.name)
1601 values = [(g.id, g.name) for g in groups]
1602 values, pv = get_values_and_permissible(values, self.allow_none,
1603 _("[None]"))
1604 self.widget = SelectWidget(values=values)
1605 self.validator = OneOf(pv)
1607 @staticmethod
1608 def schema_type() -> SchemaType:
1609 return Integer()
1612class OptionalGroupIdSelectorUserGroups(MandatoryGroupIdSelectorUserGroups):
1613 """
1614 Offers a picklist of groups from THOSE THE USER IS A MEMBER OF.
1615 Used for "which do you want to upload into?". Optional.
1616 """
1617 default = None
1618 missing = None
1620 def __init__(self, *args, **kwargs) -> None:
1621 self.allow_none = True
1622 super().__init__(*args, **kwargs)
1624 @staticmethod
1625 def schema_type() -> SchemaType:
1626 return AllowNoneType(Integer())
1629class MandatoryGroupIdSelectorAllowedGroups(SchemaNode, RequestAwareMixin):
1630 """
1631 Offers a picklist of groups from THOSE THE USER IS ALLOWED TO SEE.
1632 Used for task filters.
1633 """
1634 def __init__(self, *args, **kwargs) -> None:
1635 self.title = "" # for type checker
1636 self.validator = None # type: Optional[ValidatorType]
1637 self.widget = None # type: Optional[Widget]
1638 super().__init__(*args, **kwargs)
1640 # noinspection PyUnusedLocal
1641 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
1642 _ = self.gettext
1643 self.title = _("Group")
1644 request = self.request
1645 dbsession = request.dbsession
1646 user = request.user
1647 if user.superuser:
1648 groups = dbsession.query(Group).order_by(Group.name)
1649 else:
1650 groups = sorted(list(user.groups), key=lambda g: g.name)
1651 values = [(g.id, g.name) for g in groups]
1652 values, pv = get_values_and_permissible(values)
1653 self.widget = SelectWidget(values=values)
1654 self.validator = OneOf(pv)
1656 @staticmethod
1657 def schema_type() -> SchemaType:
1658 return Integer()
1661class GroupsSequenceBase(SequenceSchema, RequestAwareMixin):
1662 """
1663 Sequence schema to capture zero or more non-duplicate groups.
1664 """
1665 def __init__(self, *args, minimum_number: int = 0, **kwargs) -> None:
1666 self.title = "" # for type checker
1667 self.minimum_number = minimum_number
1668 self.widget = None # type: Optional[Widget]
1669 super().__init__(*args, **kwargs)
1671 # noinspection PyUnusedLocal
1672 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
1673 _ = self.gettext
1674 self.title = _("Groups")
1675 self.widget = TranslatableSequenceWidget(request=self.request)
1677 # noinspection PyMethodMayBeStatic
1678 def validator(self,
1679 node: SchemaNode,
1680 value: List[int]) -> None:
1681 assert isinstance(value, list)
1682 _ = self.gettext
1683 if len(value) != len(set(value)):
1684 raise Invalid(node, _("You have specified duplicate groups"))
1685 if len(value) < self.minimum_number:
1686 raise Invalid(
1687 node,
1688 _("You must specify at least {} group(s)").format(
1689 self.minimum_number
1690 )
1691 )
1694class AllGroupsSequence(GroupsSequenceBase):
1695 """
1696 Sequence to offer a choice of all possible groups.
1698 Typical use: superuser assigns group memberships to a user.
1699 """
1700 group_id_sequence = MandatoryGroupIdSelectorAllGroups()
1703class AdministeredGroupsSequence(GroupsSequenceBase):
1704 """
1705 Sequence to offer a choice of the groups administered by the requestor.
1707 Typical use: (non-superuser) group administrator assigns group memberships
1708 to a user.
1709 """
1710 group_id_sequence = MandatoryGroupIdSelectorAdministeredGroups()
1712 def __init__(self, *args, **kwargs) -> None:
1713 super().__init__(*args, minimum_number=1, **kwargs)
1716class AllOtherGroupsSequence(GroupsSequenceBase):
1717 """
1718 Sequence to offer a choice of all possible OTHER groups (as determined
1719 relative to the group specified in ``kw[Binding.GROUP]``).
1721 Typical use: superuser assigns group permissions to another group.
1722 """
1723 group_id_sequence = MandatoryGroupIdSelectorOtherGroups()
1726class AllowedGroupsSequence(GroupsSequenceBase):
1727 """
1728 Sequence to offer a choice of all the groups the user is allowed to see.
1729 """
1730 group_id_sequence = MandatoryGroupIdSelectorAllowedGroups()
1732 def __init__(self, *args, **kwargs) -> None:
1733 self.description = "" # for type checker
1734 super().__init__(*args, **kwargs)
1736 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
1737 super().after_bind(node, kw)
1738 self.description = self.or_join_description
1741# -----------------------------------------------------------------------------
1742# Languages (strictly, locales)
1743# -----------------------------------------------------------------------------
1745class LanguageSelector(SchemaNode, RequestAwareMixin):
1746 """
1747 Node to choose a language code, from those supported by the server.
1748 """
1749 _choices = POSSIBLE_LOCALES_WITH_DESCRIPTIONS
1750 schema_type = String
1751 default = DEFAULT_LOCALE
1752 missing = DEFAULT_LOCALE
1753 widget = SelectWidget(values=_choices) # intrinsically translated!
1754 validator = OneOf(POSSIBLE_LOCALES)
1756 def __init__(self, *args, **kwargs) -> None:
1757 self.title = "" # for type checker
1758 super().__init__(*args, **kwargs)
1760 # noinspection PyUnusedLocal
1761 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
1762 _ = self.gettext
1763 self.title = _("Group")
1764 request = self.request # noqa: F841
1765 self.title = _("Language")
1768# -----------------------------------------------------------------------------
1769# Validating dangerous operations
1770# -----------------------------------------------------------------------------
1772class HardWorkConfirmationSchema(CSRFSchema):
1773 """
1774 Schema to make it hard to do something. We require a pattern of yes/no
1775 answers before we will proceed.
1776 """
1777 confirm_1_t = BooleanNode(default=False)
1778 confirm_2_t = BooleanNode(default=True)
1779 confirm_3_f = BooleanNode(default=True)
1780 confirm_4_t = BooleanNode(default=False)
1782 # noinspection PyUnusedLocal
1783 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
1784 _ = self.gettext
1785 confirm_1_t = get_child_node(self, "confirm_1_t")
1786 confirm_1_t.title = _("Really?")
1787 confirm_2_t = get_child_node(self, "confirm_2_t")
1788 # TRANSLATOR: string context described here
1789 confirm_2_t.title = _("Leave ticked to confirm")
1790 confirm_3_f = get_child_node(self, "confirm_3_f")
1791 confirm_3_f.title = _("Please untick to confirm")
1792 confirm_4_t = get_child_node(self, "confirm_4_t")
1793 confirm_4_t.title = _("Be really sure; tick here also to confirm")
1795 # noinspection PyMethodMayBeStatic
1796 def validator(self, node: SchemaNode, value: Any) -> None:
1797 if ((not value['confirm_1_t']) or
1798 (not value['confirm_2_t']) or
1799 value['confirm_3_f'] or
1800 (not value['confirm_4_t'])):
1801 _ = self.gettext
1802 raise Invalid(node, _("Not fully confirmed"))
1805# -----------------------------------------------------------------------------
1806# URLs
1807# -----------------------------------------------------------------------------
1809class HiddenRedirectionUrlNode(HiddenStringNode, RequestAwareMixin):
1810 """
1811 Note to encode a hidden URL, for redirection.
1812 """
1813 # noinspection PyMethodMayBeStatic
1814 def validator(self, node: SchemaNode, value: str) -> None:
1815 if value:
1816 try:
1817 validate_redirect_url(value, self.request)
1818 except ValueError:
1819 _ = self.gettext
1820 raise Invalid(node, _("Invalid redirection URL"))
1823# =============================================================================
1824# Login
1825# =============================================================================
1827class LoginSchema(CSRFSchema):
1828 """
1829 Schema to capture login details.
1830 """
1831 username = UsernameNode(
1832 autocomplete=AutocompleteAttrValues.USERNAME
1833 ) # name must match ViewParam.USERNAME
1834 password = SchemaNode( # name must match ViewParam.PASSWORD
1835 String(),
1836 widget=PasswordWidget(attributes={
1837 AUTOCOMPLETE_ATTR: AutocompleteAttrValues.CURRENT_PASSWORD
1838 }),
1839 )
1840 redirect_url = HiddenRedirectionUrlNode() # name must match ViewParam.REDIRECT_URL # noqa
1842 def __init__(self, *args, autocomplete_password: bool = True,
1843 **kwargs) -> None:
1844 self.autocomplete_password = autocomplete_password
1845 super().__init__(*args, **kwargs)
1847 # noinspection PyUnusedLocal
1848 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
1849 _ = self.gettext
1850 password = get_child_node(self, "password")
1851 password.title = _("Password")
1852 password.widget.attributes[AUTOCOMPLETE_ATTR] = (
1853 AutocompleteAttrValues.CURRENT_PASSWORD
1854 if self.autocomplete_password else AutocompleteAttrValues.OFF
1855 )
1858class LoginForm(InformativeNonceForm):
1859 """
1860 Form to capture login details.
1861 """
1862 def __init__(self,
1863 request: "CamcopsRequest",
1864 autocomplete_password: bool = True,
1865 **kwargs) -> None:
1866 """
1867 Args:
1868 autocomplete_password:
1869 suggest to the browser that it's OK to store the password for
1870 autocompletion? Note that browsers may ignore this.
1871 """
1872 _ = request.gettext
1873 schema = LoginSchema(
1874 autocomplete_password=autocomplete_password
1875 ).bind(request=request)
1876 super().__init__(
1877 schema,
1878 buttons=[Button(name=FormAction.SUBMIT, title=_("Log in"))],
1879 # autocomplete=autocomplete_password,
1880 **kwargs
1881 )
1882 # Suboptimal: autocomplete_password is not applied to the password
1883 # widget, just to the form; see
1884 # http://stackoverflow.com/questions/2530
1885 # Note that e.g. Chrome may ignore this.
1886 # ... fixed 2020-09-29 by applying autocomplete to LoginSchema.password
1889# =============================================================================
1890# Change password
1891# =============================================================================
1893class MustChangePasswordNode(SchemaNode, RequestAwareMixin):
1894 """
1895 Boolean node: must the user change their password?
1896 """
1897 schema_type = Boolean
1898 default = True
1899 missing = True
1901 def __init__(self, *args, **kwargs) -> None:
1902 self.label = "" # for type checker
1903 self.title = "" # for type checker
1904 super().__init__(*args, **kwargs)
1906 # noinspection PyUnusedLocal
1907 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
1908 _ = self.gettext
1909 self.label = _("User must change password at next login")
1910 self.title = _("Must change password at next login?")
1913class OldUserPasswordCheck(SchemaNode, RequestAwareMixin):
1914 """
1915 Schema to capture an old password (for when a password is being changed).
1916 """
1917 schema_type = String
1918 widget = PasswordWidget(attributes={
1919 AUTOCOMPLETE_ATTR: AutocompleteAttrValues.CURRENT_PASSWORD
1920 })
1922 def __init__(self, *args, **kwargs) -> None:
1923 self.title = "" # for type checker
1924 super().__init__(*args, **kwargs)
1926 # noinspection PyUnusedLocal
1927 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
1928 _ = self.gettext
1929 self.title = _("Old password")
1931 def validator(self, node: SchemaNode, value: str) -> None:
1932 request = self.request
1933 user = request.user
1934 assert user is not None
1935 if not user.is_password_correct(value):
1936 _ = request.gettext
1937 raise Invalid(node, _("Old password incorrect"))
1940class NewPasswordNode(SchemaNode, RequestAwareMixin):
1941 """
1942 Node to enter a new password.
1943 """
1944 schema_type = String
1945 widget = CheckedPasswordWidget(attributes={
1946 AUTOCOMPLETE_ATTR: AutocompleteAttrValues.NEW_PASSWORD
1947 })
1949 def __init__(self, *args, **kwargs) -> None:
1950 self.title = "" # for type checker
1951 self.description = "" # for type checker
1952 super().__init__(*args, **kwargs)
1954 # noinspection PyUnusedLocal
1955 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
1956 _ = self.gettext
1957 self.title = _("New password")
1958 self.description = _("Type the new password and confirm it")
1960 def validator(self, node: SchemaNode, value: str) -> None:
1961 try:
1962 validate_new_password(value, self.request)
1963 except ValueError as e:
1964 raise Invalid(node, str(e))
1967class ChangeOwnPasswordSchema(CSRFSchema):
1968 """
1969 Schema to change one's own password.
1970 """
1971 old_password = OldUserPasswordCheck()
1972 new_password = NewPasswordNode() # name must match ViewParam.NEW_PASSWORD
1974 def __init__(self, *args, must_differ: bool = True, **kwargs) -> None:
1975 """
1976 Args:
1977 must_differ:
1978 must the new password be different from the old one?
1979 """
1980 self.must_differ = must_differ
1981 super().__init__(*args, **kwargs)
1983 def validator(self, node: SchemaNode, value: Dict[str, str]) -> None:
1984 if self.must_differ and value['new_password'] == value['old_password']:
1985 _ = self.gettext
1986 raise Invalid(node, _("New password must differ from old"))
1989class ChangeOwnPasswordForm(InformativeNonceForm):
1990 """
1991 Form to change one's own password.
1992 """
1993 def __init__(self, request: "CamcopsRequest",
1994 must_differ: bool = True,
1995 **kwargs) -> None:
1996 """
1997 Args:
1998 must_differ:
1999 must the new password be different from the old one?
2000 """
2001 schema = ChangeOwnPasswordSchema(must_differ=must_differ).\
2002 bind(request=request)
2003 super().__init__(
2004 schema,
2005 buttons=[Button(name=FormAction.SUBMIT,
2006 title=change_password_title(request))],
2007 **kwargs
2008 )
2011class ChangeOtherPasswordSchema(CSRFSchema):
2012 """
2013 Schema to change another user's password.
2014 """
2015 user_id = HiddenIntegerNode() # name must match ViewParam.USER_ID
2016 must_change_password = MustChangePasswordNode() # match ViewParam.MUST_CHANGE_PASSWORD # noqa
2017 new_password = NewPasswordNode() # name must match ViewParam.NEW_PASSWORD
2020class ChangeOtherPasswordForm(SimpleSubmitForm):
2021 """
2022 Form to change another user's password.
2023 """
2024 def __init__(self, request: "CamcopsRequest", **kwargs) -> None:
2025 super().__init__(schema_class=ChangeOtherPasswordSchema,
2026 submit_title=change_password_title(request),
2027 request=request, **kwargs)
2030# =============================================================================
2031# Offer/agree terms
2032# =============================================================================
2034class OfferTermsSchema(CSRFSchema):
2035 """
2036 Schema to offer terms and ask the user to accept them.
2037 """
2038 pass
2041class OfferTermsForm(SimpleSubmitForm):
2042 """
2043 Form to offer terms and ask the user to accept them.
2044 """
2045 def __init__(self,
2046 request: "CamcopsRequest",
2047 agree_button_text: str,
2048 **kwargs) -> None:
2049 """
2050 Args:
2051 agree_button_text:
2052 text for the "agree" button
2053 """
2054 super().__init__(schema_class=OfferTermsSchema,
2055 submit_title=agree_button_text,
2056 request=request, **kwargs)
2059# =============================================================================
2060# View audit trail
2061# =============================================================================
2063class OptionalIPAddressNode(OptionalStringNode, RequestAwareMixin):
2064 """
2065 Optional IPv4 or IPv6 address.
2066 """
2067 def validator(self, node: SchemaNode, value: str) -> None:
2068 try:
2069 validate_ip_address(value, self.request)
2070 except ValueError as e:
2071 raise Invalid(node, e)
2074class OptionalAuditSourceNode(OptionalStringNode, RequestAwareMixin):
2075 """
2076 Optional IPv4 or IPv6 address.
2077 """
2078 def validator(self, node: SchemaNode, value: str) -> None:
2079 try:
2080 validate_by_char_and_length(
2081 value,
2082 permitted_char_expression=ALPHANUM_UNDERSCORE_CHAR,
2083 min_length=0,
2084 max_length=StringLengths.AUDIT_SOURCE_MAX_LEN,
2085 req=self.request
2086 )
2087 except ValueError as e:
2088 raise Invalid(node, e)
2091class AuditTrailSchema(CSRFSchema):
2092 """
2093 Schema to filter audit trail entries.
2094 """
2095 rows_per_page = RowsPerPageSelector() # must match ViewParam.ROWS_PER_PAGE
2096 start_datetime = StartPendulumSelector() # must match ViewParam.START_DATETIME # noqa
2097 end_datetime = EndPendulumSelector() # must match ViewParam.END_DATETIME
2098 source = OptionalAuditSourceNode() # must match ViewParam.SOURCE # noqa
2099 remote_ip_addr = OptionalIPAddressNode() # must match ViewParam.REMOTE_IP_ADDR # noqa
2100 username = OptionalUserNameSelector() # must match ViewParam.USERNAME # noqa
2101 table_name = OptionalSingleTaskSelector() # must match ViewParam.TABLENAME # noqa
2102 server_pk = ServerPkSelector() # must match ViewParam.SERVER_PK
2103 truncate = BooleanNode(default=True) # must match ViewParam.TRUNCATE
2105 # noinspection PyUnusedLocal
2106 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
2107 _ = self.gettext
2108 source = get_child_node(self, "source")
2109 source.title = _("Source (e.g. webviewer, tablet, console)")
2110 remote_ip_addr = get_child_node(self, "remote_ip_addr")
2111 remote_ip_addr.title = _("Remote IP address")
2112 truncate = get_child_node(self, "truncate")
2113 truncate.title = _("Truncate details for easy viewing")
2116class AuditTrailForm(SimpleSubmitForm):
2117 """
2118 Form to filter and then view audit trail entries.
2119 """
2120 def __init__(self, request: "CamcopsRequest", **kwargs) -> None:
2121 _ = request.gettext
2122 super().__init__(schema_class=AuditTrailSchema,
2123 submit_title=_("View audit trail"),
2124 request=request, **kwargs)
2127# =============================================================================
2128# View export logs
2129# =============================================================================
2131class OptionalExportRecipientNameSelector(OptionalStringNode,
2132 RequestAwareMixin):
2133 """
2134 Optional node to pick an export recipient name from those present in the
2135 database.
2136 """
2137 title = "Export recipient"
2139 def __init__(self, *args, **kwargs) -> None:
2140 self.validator = None # type: Optional[ValidatorType]
2141 self.widget = None # type: Optional[Widget]
2142 super().__init__(*args, **kwargs)
2144 # noinspection PyUnusedLocal
2145 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
2146 from camcops_server.cc_modules.cc_exportrecipient import ExportRecipient # delayed import # noqa
2147 request = self.request
2148 _ = request.gettext
2149 dbsession = request.dbsession
2150 q = (
2151 dbsession.query(ExportRecipient.recipient_name)
2152 .distinct()
2153 .order_by(ExportRecipient.recipient_name)
2154 )
2155 values = [] # type: List[Tuple[str, str]]
2156 for row in q:
2157 recipient_name = row[0]
2158 values.append((recipient_name, recipient_name))
2159 values, pv = get_values_and_permissible(values, True, _("[Any]"))
2160 self.widget = SelectWidget(values=values)
2161 self.validator = OneOf(pv)
2164class ExportedTaskListSchema(CSRFSchema):
2165 """
2166 Schema to filter HL7 message logs.
2167 """
2168 rows_per_page = RowsPerPageSelector() # must match ViewParam.ROWS_PER_PAGE
2169 recipient_name = OptionalExportRecipientNameSelector() # must match ViewParam.RECIPIENT_NAME # noqa
2170 table_name = OptionalSingleTaskSelector() # must match ViewParam.TABLENAME # noqa
2171 server_pk = ServerPkSelector() # must match ViewParam.SERVER_PK
2172 id = OptionalIntNode() # must match ViewParam.ID # noqa
2173 start_datetime = StartDateTimeSelector() # must match ViewParam.START_DATETIME # noqa
2174 end_datetime = EndDateTimeSelector() # must match ViewParam.END_DATETIME
2176 # noinspection PyUnusedLocal
2177 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
2178 _ = self.gettext
2179 id_ = get_child_node(self, "id")
2180 id_.title = _("ExportedTask ID")
2183class ExportedTaskListForm(SimpleSubmitForm):
2184 """
2185 Form to filter and then view exported task logs.
2186 """
2187 def __init__(self, request: "CamcopsRequest", **kwargs) -> None:
2188 _ = request.gettext
2189 super().__init__(schema_class=ExportedTaskListSchema,
2190 submit_title=_("View exported task log"),
2191 request=request, **kwargs)
2194# =============================================================================
2195# Task filters
2196# =============================================================================
2198class TextContentsSequence(SequenceSchema, RequestAwareMixin):
2199 """
2200 Sequence to capture multiple pieces of text (representing text contents
2201 for a task filter).
2202 """
2203 text_sequence = SchemaNode(
2204 String(),
2205 validator=Length(0, StringLengths.FILTER_TEXT_MAX_LEN)
2206 ) # BEWARE: fairly unrestricted contents.
2208 def __init__(self, *args, **kwargs) -> None:
2209 self.title = "" # for type checker
2210 self.description = "" # for type checker
2211 self.widget = None # type: Optional[Widget]
2212 super().__init__(*args, **kwargs)
2214 # noinspection PyUnusedLocal
2215 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
2216 _ = self.gettext
2217 self.title = _("Text contents")
2218 self.description = self.or_join_description
2219 self.widget = TranslatableSequenceWidget(request=self.request)
2220 # Now it'll say "[Add]" Text Sequence because it'll make the string
2221 # "Text Sequence" from the name of text_sequence. Unless we do this:
2222 text_sequence = get_child_node(self, "text_sequence")
2223 # TRANSLATOR: For the task filter form: the text in "Add text"
2224 text_sequence.title = _("text")
2226 # noinspection PyMethodMayBeStatic
2227 def validator(self, node: SchemaNode, value: List[str]) -> None:
2228 assert isinstance(value, list)
2229 if len(value) != len(set(value)):
2230 _ = self.gettext
2231 raise Invalid(node, _("You have specified duplicate text filters"))
2234class UploadingUserSequence(SequenceSchema, RequestAwareMixin):
2235 """
2236 Sequence to capture multiple users (for task filters: "uploaded by one of
2237 the following users...").
2238 """
2239 user_id_sequence = MandatoryUserIdSelectorUsersAllowedToSee()
2241 def __init__(self, *args, **kwargs) -> None:
2242 self.title = "" # for type checker
2243 self.description = "" # for type checker
2244 self.widget = None # type: Optional[Widget]
2245 super().__init__(*args, **kwargs)
2247 # noinspection PyUnusedLocal
2248 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
2249 _ = self.gettext
2250 self.title = _("Uploading users")
2251 self.description = self.or_join_description
2252 self.widget = TranslatableSequenceWidget(request=self.request)
2254 # noinspection PyMethodMayBeStatic
2255 def validator(self, node: SchemaNode, value: List[int]) -> None:
2256 assert isinstance(value, list)
2257 if len(value) != len(set(value)):
2258 _ = self.gettext
2259 raise Invalid(node, _("You have specified duplicate users"))
2262class DevicesSequence(SequenceSchema, RequestAwareMixin):
2263 """
2264 Sequence to capture multiple client devices (for task filters: "uploaded by
2265 one of the following devices...").
2266 """
2267 device_id_sequence = MandatoryDeviceIdSelector()
2269 def __init__(self, *args, **kwargs) -> None:
2270 self.title = "" # for type checker
2271 self.description = "" # for type checker
2272 self.widget = None # type: Optional[Widget]
2273 super().__init__(*args, **kwargs)
2275 # noinspection PyUnusedLocal
2276 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
2277 _ = self.gettext
2278 self.title = _("Uploading devices")
2279 self.description = self.or_join_description
2280 self.widget = TranslatableSequenceWidget(request=self.request)
2282 # noinspection PyMethodMayBeStatic
2283 def validator(self, node: SchemaNode, value: List[int]) -> None:
2284 assert isinstance(value, list)
2285 if len(value) != len(set(value)):
2286 raise Invalid(node, "You have specified duplicate devices")
2289class OptionalPatientNameNode(OptionalStringNode, RequestAwareMixin):
2290 def validator(self, node: SchemaNode, value: str) -> None:
2291 try:
2292 # TODO: Validating human names is hard.
2293 # Decide if validation here is necessary and whether it should
2294 # be configurable.
2295 # validate_human_name(value, self.request)
2297 # Does nothing but better to be explicit
2298 validate_anything(value, self.request)
2299 except ValueError as e:
2300 # Should never happen with validate_anything
2301 raise Invalid(node, str(e))
2304class EditTaskFilterWhoSchema(Schema, RequestAwareMixin):
2305 """
2306 Schema to edit the "who" parts of a task filter.
2307 """
2308 surname = OptionalPatientNameNode() # must match ViewParam.SURNAME # noqa
2309 forename = OptionalPatientNameNode() # must match ViewParam.FORENAME # noqa
2310 dob = SchemaNode(Date(), missing=None) # must match ViewParam.DOB
2311 sex = OptionalSexSelector() # must match ViewParam.SEX
2312 id_references = IdNumSequenceAnyCombination() # must match ViewParam.ID_REFERENCES # noqa
2314 # noinspection PyUnusedLocal
2315 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
2316 _ = self.gettext
2317 surname = get_child_node(self, "surname")
2318 surname.title = _("Surname")
2319 forename = get_child_node(self, "forename")
2320 forename.title = _("Forename")
2321 dob = get_child_node(self, "dob")
2322 dob.title = _("Date of birth")
2323 id_references = get_child_node(self, "id_references")
2324 id_references.description = self.or_join_description
2327class EditTaskFilterWhenSchema(Schema):
2328 """
2329 Schema to edit the "when" parts of a task filter.
2330 """
2331 start_datetime = StartPendulumSelector() # must match ViewParam.START_DATETIME # noqa
2332 end_datetime = EndPendulumSelector() # must match ViewParam.END_DATETIME
2335class EditTaskFilterWhatSchema(Schema, RequestAwareMixin):
2336 """
2337 Schema to edit the "what" parts of a task filter.
2338 """
2339 text_contents = TextContentsSequence() # must match ViewParam.TEXT_CONTENTS # noqa
2340 complete_only = BooleanNode(default=False) # must match ViewParam.COMPLETE_ONLY # noqa
2341 tasks = MultiTaskSelector() # must match ViewParam.TASKS
2343 # noinspection PyUnusedLocal
2344 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
2345 _ = self.gettext
2346 complete_only = get_child_node(self, "complete_only")
2347 only_completed_text = _("Only completed tasks?")
2348 complete_only.title = only_completed_text
2349 complete_only.label = only_completed_text
2352class EditTaskFilterAdminSchema(Schema):
2353 """
2354 Schema to edit the "admin" parts of a task filter.
2355 """
2356 device_ids = DevicesSequence() # must match ViewParam.DEVICE_IDS
2357 user_ids = UploadingUserSequence() # must match ViewParam.USER_IDS
2358 group_ids = AllowedGroupsSequence() # must match ViewParam.GROUP_IDS
2361class EditTaskFilterSchema(CSRFSchema):
2362 """
2363 Schema to edit a task filter.
2364 """
2365 who = EditTaskFilterWhoSchema( # must match ViewParam.WHO
2366 widget=MappingWidget(template="mapping_accordion", open=False)
2367 )
2368 what = EditTaskFilterWhatSchema( # must match ViewParam.WHAT
2369 widget=MappingWidget(template="mapping_accordion", open=False)
2370 )
2371 when = EditTaskFilterWhenSchema( # must match ViewParam.WHEN
2372 widget=MappingWidget(template="mapping_accordion", open=False)
2373 )
2374 admin = EditTaskFilterAdminSchema( # must match ViewParam.ADMIN
2375 widget=MappingWidget(template="mapping_accordion", open=False)
2376 )
2378 # noinspection PyUnusedLocal
2379 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
2380 # log.debug("EditTaskFilterSchema.after_bind")
2381 # log.debug("{!r}", self.__dict__)
2382 # This is pretty nasty. By the time we get here, the Form class has
2383 # made Field objects, and, I think, called a clone() function on us.
2384 # Objects like "who" are not in our __dict__ any more. Our __dict__
2385 # looks like:
2386 # {
2387 # 'typ': <colander.Mapping object at 0x7fd7989b18d0>,
2388 # 'bindings': {
2389 # 'open_who': True,
2390 # 'open_when': True,
2391 # 'request': ...,
2392 # },
2393 # '_order': 118,
2394 # 'children': [
2395 # <...CSRFToken object at ... (named csrf)>,
2396 # <...EditTaskFilterWhoSchema object at ... (named who)>,
2397 # ...
2398 # ],
2399 # 'title': ''
2400 # }
2401 _ = self.gettext
2402 who = get_child_node(self, "who")
2403 what = get_child_node(self, "what")
2404 when = get_child_node(self, "when")
2405 admin = get_child_node(self, "admin")
2406 who.title = _("Who")
2407 what.title = _("What")
2408 when.title = _("When")
2409 admin.title = _("Administrative criteria")
2410 # log.debug("who = {!r}", who)
2411 # log.debug("who.__dict__ = {!r}", who.__dict__)
2412 who.widget.open = kw[Binding.OPEN_WHO]
2413 what.widget.open = kw[Binding.OPEN_WHAT]
2414 when.widget.open = kw[Binding.OPEN_WHEN]
2415 admin.widget.open = kw[Binding.OPEN_ADMIN]
2418class EditTaskFilterForm(InformativeNonceForm):
2419 """
2420 Form to edit a task filter.
2421 """
2422 def __init__(self,
2423 request: "CamcopsRequest",
2424 open_who: bool = False,
2425 open_what: bool = False,
2426 open_when: bool = False,
2427 open_admin: bool = False,
2428 **kwargs) -> None:
2429 _ = request.gettext
2430 schema = EditTaskFilterSchema().bind(request=request,
2431 open_admin=open_admin,
2432 open_what=open_what,
2433 open_when=open_when,
2434 open_who=open_who)
2435 super().__init__(
2436 schema,
2437 buttons=[Button(name=FormAction.SET_FILTERS,
2438 title=_("Set filters")),
2439 Button(name=FormAction.CLEAR_FILTERS,
2440 title=_("Clear"))],
2441 **kwargs
2442 )
2445class TasksPerPageSchema(CSRFSchema):
2446 """
2447 Schema to edit the number of rows per page, for the task view.
2448 """
2449 rows_per_page = RowsPerPageSelector() # must match ViewParam.ROWS_PER_PAGE
2452class TasksPerPageForm(InformativeNonceForm):
2453 """
2454 Form to edit the number of tasks per page, for the task view.
2455 """
2456 def __init__(self, request: "CamcopsRequest", **kwargs) -> None:
2457 _ = request.gettext
2458 schema = TasksPerPageSchema().bind(request=request)
2459 super().__init__(
2460 schema,
2461 buttons=[Button(name=FormAction.SUBMIT_TASKS_PER_PAGE,
2462 title=_("Set n/page"))],
2463 css_class=BootstrapCssClasses.FORM_INLINE,
2464 **kwargs
2465 )
2468class RefreshTasksSchema(CSRFSchema):
2469 """
2470 Schema for a "refresh tasks" button.
2471 """
2472 pass
2475class RefreshTasksForm(InformativeNonceForm):
2476 """
2477 Form for a "refresh tasks" button.
2478 """
2479 def __init__(self, request: "CamcopsRequest", **kwargs) -> None:
2480 _ = request.gettext
2481 schema = RefreshTasksSchema().bind(request=request)
2482 super().__init__(
2483 schema,
2484 buttons=[Button(name=FormAction.REFRESH_TASKS,
2485 title=_("Refresh"))],
2486 **kwargs
2487 )
2490# =============================================================================
2491# Trackers
2492# =============================================================================
2494class TaskTrackerOutputTypeSelector(SchemaNode, RequestAwareMixin):
2495 """
2496 Node to select the output format for a tracker.
2497 """
2498 # Choices don't require translation
2499 _choices = ((ViewArg.HTML, "HTML"),
2500 (ViewArg.PDF, "PDF"),
2501 (ViewArg.XML, "XML"))
2503 schema_type = String
2504 default = ViewArg.HTML
2505 missing = ViewArg.HTML
2506 widget = RadioChoiceWidget(values=_choices)
2507 validator = OneOf(list(x[0] for x in _choices))
2509 def __init__(self, *args, **kwargs) -> None:
2510 self.title = "" # for type checker
2511 super().__init__(*args, **kwargs)
2513 # noinspection PyUnusedLocal
2514 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
2515 _ = self.gettext
2516 self.title = _("View as")
2519class ChooseTrackerSchema(CSRFSchema):
2520 """
2521 Schema to select a tracker or CTV.
2522 """
2523 which_idnum = MandatoryWhichIdNumSelector() # must match ViewParam.WHICH_IDNUM # noqa
2524 idnum_value = MandatoryIdNumValue() # must match ViewParam.IDNUM_VALUE # noqa
2525 start_datetime = StartPendulumSelector() # must match ViewParam.START_DATETIME # noqa
2526 end_datetime = EndPendulumSelector() # must match ViewParam.END_DATETIME
2527 all_tasks = BooleanNode(default=True) # match ViewParam.ALL_TASKS
2528 tasks = MultiTaskSelector() # must match ViewParam.TASKS
2529 # tracker_tasks_only will be set via the binding
2530 via_index = ViaIndexSelector() # must match ViewParam.VIA_INDEX
2531 viewtype = TaskTrackerOutputTypeSelector() # must match ViewParams.VIEWTYPE # noqa
2533 # noinspection PyUnusedLocal
2534 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
2535 _ = self.gettext
2536 all_tasks = get_child_node(self, "all_tasks")
2537 text = _("Use all eligible task types?")
2538 all_tasks.title = text
2539 all_tasks.label = text
2542class ChooseTrackerForm(InformativeNonceForm):
2543 """
2544 Form to select a tracker or CTV.
2545 """
2546 def __init__(self, request: "CamcopsRequest",
2547 as_ctv: bool, **kwargs) -> None:
2548 """
2549 Args:
2550 as_ctv: CTV, not tracker?
2551 """
2552 _ = request.gettext
2553 schema = ChooseTrackerSchema().bind(request=request,
2554 tracker_tasks_only=not as_ctv)
2555 super().__init__(
2556 schema,
2557 buttons=[
2558 Button(
2559 name=FormAction.SUBMIT,
2560 title=(_("View CTV") if as_ctv else _("View tracker"))
2561 )
2562 ],
2563 **kwargs
2564 )
2567# =============================================================================
2568# Reports, which use dynamically created forms
2569# =============================================================================
2571class ReportOutputTypeSelector(SchemaNode, RequestAwareMixin):
2572 """
2573 Node to select the output format for a report.
2574 """
2575 schema_type = String
2576 default = ViewArg.HTML
2577 missing = ViewArg.HTML
2579 def __init__(self, *args, **kwargs) -> None:
2580 self.title = "" # for type checker
2581 self.widget = None # type: Optional[Widget]
2582 self.validator = None # type: Optional[ValidatorType]
2583 super().__init__(*args, **kwargs)
2585 # noinspection PyUnusedLocal
2586 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
2587 _ = self.gettext
2588 self.title = _("View as")
2589 choices = self.get_choices()
2590 values, pv = get_values_and_permissible(choices)
2591 self.widget = RadioChoiceWidget(values=choices)
2592 self.validator = OneOf(pv)
2594 def get_choices(self) -> Tuple[Tuple[str, str]]:
2595 _ = self.gettext
2596 # noinspection PyTypeChecker
2597 return (
2598 (ViewArg.HTML, _("HTML")),
2599 (ViewArg.ODS, _("OpenOffice spreadsheet (ODS) file")),
2600 (ViewArg.TSV, _("TSV (tab-separated values)")),
2601 (ViewArg.XLSX, _("XLSX (Microsoft Excel) file"))
2602 )
2605class ReportParamSchema(CSRFSchema):
2606 """
2607 Schema to embed a report type (ID) and output format (view type).
2608 """
2609 viewtype = ReportOutputTypeSelector() # must match ViewParam.VIEWTYPE
2610 report_id = HiddenStringNode() # must match ViewParam.REPORT_ID
2611 # Specific forms may inherit from this.
2614class DateTimeFilteredReportParamSchema(ReportParamSchema):
2615 start_datetime = StartPendulumSelector()
2616 end_datetime = EndPendulumSelector()
2619class ReportParamForm(SimpleSubmitForm):
2620 """
2621 Form to view a specific report. Often derived from, to configure the report
2622 in more detail.
2623 """
2624 def __init__(self, request: "CamcopsRequest",
2625 schema_class: Type[ReportParamSchema], **kwargs) -> None:
2626 _ = request.gettext
2627 super().__init__(schema_class=schema_class,
2628 submit_title=_("View report"),
2629 request=request, **kwargs)
2632# =============================================================================
2633# View DDL
2634# =============================================================================
2636def get_sql_dialect_choices(
2637 request: "CamcopsRequest") -> List[Tuple[str, str]]:
2638 _ = request.gettext
2639 return [
2640 # http://docs.sqlalchemy.org/en/latest/dialects/
2641 (SqlaDialectName.MYSQL, "MySQL"),
2642 (SqlaDialectName.MSSQL, "Microsoft SQL Server"),
2643 (SqlaDialectName.ORACLE, "Oracle" + _("[WILL NOT WORK]")),
2644 # ... Oracle doesn't work; SQLAlchemy enforces the Oracle rule of a 30-
2645 # character limit for identifiers, only relaxed to 128 characters in
2646 # Oracle 12.2 (March 2017).
2647 (SqlaDialectName.FIREBIRD, "Firebird"),
2648 (SqlaDialectName.POSTGRES, "PostgreSQL"),
2649 (SqlaDialectName.SQLITE, "SQLite"),
2650 (SqlaDialectName.SYBASE, "Sybase"),
2651 ]
2654class DatabaseDialectSelector(SchemaNode, RequestAwareMixin):
2655 """
2656 Node to choice an SQL dialect (for viewing DDL).
2657 """
2658 schema_type = String
2659 default = SqlaDialectName.MYSQL
2660 missing = SqlaDialectName.MYSQL
2662 def __init__(self, *args, **kwargs) -> None:
2663 self.title = "" # for type checker
2664 self.widget = None # type: Optional[Widget]
2665 self.validator = None # type: Optional[ValidatorType]
2666 super().__init__(*args, **kwargs)
2668 # noinspection PyUnusedLocal
2669 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
2670 _ = self.gettext
2671 self.title = _("SQL dialect to use (not all may be valid)")
2672 choices = get_sql_dialect_choices(self.request)
2673 values, pv = get_values_and_permissible(choices)
2674 self.widget = RadioChoiceWidget(values=values)
2675 self.validator = OneOf(pv)
2678class ViewDdlSchema(CSRFSchema):
2679 """
2680 Schema to choose how to view DDL.
2681 """
2682 dialect = DatabaseDialectSelector() # must match ViewParam.DIALECT
2685class ViewDdlForm(SimpleSubmitForm):
2686 """
2687 Form to choose how to view DDL (and then view it).
2688 """
2689 def __init__(self, request: "CamcopsRequest", **kwargs) -> None:
2690 _ = request.gettext
2691 super().__init__(schema_class=ViewDdlSchema,
2692 submit_title=_("View DDL"),
2693 request=request, **kwargs)
2696# =============================================================================
2697# Add/edit/delete users
2698# =============================================================================
2700class UserGroupPermissionsGroupAdminSchema(CSRFSchema):
2701 """
2702 Edit group-specific permissions for a user. For group administrators.
2703 """
2704 may_upload = BooleanNode(default=True) # match ViewParam.MAY_UPLOAD and User attribute # noqa
2705 may_register_devices = BooleanNode(default=True) # match ViewParam.MAY_REGISTER_DEVICES and User attribute # noqa
2706 may_use_webviewer = BooleanNode(default=True) # match ViewParam.MAY_USE_WEBVIEWER and User attribute # noqa
2707 view_all_patients_when_unfiltered = BooleanNode(default=False) # match ViewParam.VIEW_ALL_PATIENTS_WHEN_UNFILTERED and User attribute # noqa
2708 may_dump_data = BooleanNode(default=False) # match ViewParam.MAY_DUMP_DATA and User attribute # noqa
2709 may_run_reports = BooleanNode(default=False) # match ViewParam.MAY_RUN_REPORTS and User attribute # noqa
2710 may_add_notes = BooleanNode(default=False) # match ViewParam.MAY_ADD_NOTES and User attribute # noqa
2712 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
2713 _ = self.gettext
2714 may_upload = get_child_node(self, "may_upload")
2715 mu_text = _("Permitted to upload from a tablet/device")
2716 may_upload.title = mu_text
2717 may_upload.label = mu_text
2718 may_register_devices = get_child_node(self, "may_register_devices")
2719 mrd_text = _("Permitted to register tablet/client devices")
2720 may_register_devices.title = mrd_text
2721 may_register_devices.label = mrd_text
2722 may_use_webviewer = get_child_node(self, "may_use_webviewer")
2723 ml_text = _("May log in to web front end")
2724 may_use_webviewer.title = ml_text
2725 may_use_webviewer.label = ml_text
2726 view_all_patients_when_unfiltered = get_child_node(self, "view_all_patients_when_unfiltered") # noqa
2727 vap_text = _(
2728 "May view (browse) records from all patients when no patient "
2729 "filter set"
2730 )
2731 view_all_patients_when_unfiltered.title = vap_text
2732 view_all_patients_when_unfiltered.label = vap_text
2733 may_dump_data = get_child_node(self, "may_dump_data")
2734 md_text = _("May perform bulk data dumps")
2735 may_dump_data.title = md_text
2736 may_dump_data.label = md_text
2737 may_run_reports = get_child_node(self, "may_run_reports")
2738 mrr_text = _("May run reports")
2739 may_run_reports.title = mrr_text
2740 may_run_reports.label = mrr_text
2741 may_add_notes = get_child_node(self, "may_add_notes")
2742 man_text = _("May add special notes to tasks")
2743 may_add_notes.title = man_text
2744 may_add_notes.label = man_text
2747class UserGroupPermissionsFullSchema(UserGroupPermissionsGroupAdminSchema):
2748 """
2749 Edit group-specific permissions for a user. For superusers; includes the
2750 option to make the user a groupadmin.
2751 """
2752 groupadmin = BooleanNode(default=True) # match ViewParam.GROUPADMIN and User attribute # noqa
2754 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
2755 super().after_bind(node, kw)
2756 _ = self.gettext
2757 groupadmin = get_child_node(self, "groupadmin")
2758 text = _("User is a privileged group administrator for this group")
2759 groupadmin.title = text
2760 groupadmin.label = text
2763class EditUserGroupAdminSchema(CSRFSchema):
2764 """
2765 Schema to edit a user. Version for group administrators.
2766 """
2767 username = UsernameNode() # name must match ViewParam.USERNAME and User attribute # noqa
2768 fullname = OptionalStringNode( # name must match ViewParam.FULLNAME and User attribute # noqa
2769 validator=Length(0, StringLengths.FULLNAME_MAX_LEN)
2770 )
2771 email = OptionalEmailNode() # name must match ViewParam.EMAIL and User attribute # noqa
2772 must_change_password = MustChangePasswordNode() # match ViewParam.MUST_CHANGE_PASSWORD and User attribute # noqa
2773 language = LanguageSelector() # must match ViewParam.LANGUAGE
2774 group_ids = AdministeredGroupsSequence() # must match ViewParam.GROUP_IDS
2776 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
2777 _ = self.gettext
2778 fullname = get_child_node(self, "fullname")
2779 fullname.title = _("Full name")
2780 email = get_child_node(self, "email")
2781 email.title = _("E-mail address")
2784class EditUserFullSchema(EditUserGroupAdminSchema):
2785 """
2786 Schema to edit a user. Version for superusers; can also make the user a
2787 superuser.
2788 """
2789 superuser = BooleanNode(default=False) # match ViewParam.SUPERUSER and User attribute # noqa
2790 group_ids = AllGroupsSequence() # must match ViewParam.GROUP_IDS
2792 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
2793 _ = self.gettext
2794 superuser = get_child_node(self, "superuser")
2795 text = _("Superuser (CAUTION!)")
2796 superuser.title = text
2797 superuser.label = text
2800class EditUserFullForm(ApplyCancelForm):
2801 """
2802 Form to edit a user. Full version for superusers.
2803 """
2804 def __init__(self, request: "CamcopsRequest", **kwargs) -> None:
2805 super().__init__(schema_class=EditUserFullSchema,
2806 request=request, **kwargs)
2809class EditUserGroupAdminForm(ApplyCancelForm):
2810 """
2811 Form to edit a user. Version for group administrators.
2812 """
2813 def __init__(self, request: "CamcopsRequest", **kwargs) -> None:
2814 super().__init__(schema_class=EditUserGroupAdminSchema,
2815 request=request, **kwargs)
2818class EditUserGroupPermissionsFullForm(ApplyCancelForm):
2819 """
2820 Form to edit a user's permissions within a group. Version for superusers.
2821 """
2822 def __init__(self, request: "CamcopsRequest", **kwargs) -> None:
2823 super().__init__(schema_class=UserGroupPermissionsFullSchema,
2824 request=request, **kwargs)
2827class EditUserGroupMembershipGroupAdminForm(ApplyCancelForm):
2828 """
2829 Form to edit a user's permissions within a group. Version for group
2830 administrators.
2831 """
2832 def __init__(self, request: "CamcopsRequest", **kwargs) -> None:
2833 super().__init__(schema_class=UserGroupPermissionsGroupAdminSchema,
2834 request=request, **kwargs)
2837class AddUserSuperuserSchema(CSRFSchema):
2838 """
2839 Schema to add a user. Version for superusers.
2840 """
2841 username = UsernameNode() # name must match ViewParam.USERNAME and User attribute # noqa
2842 new_password = NewPasswordNode() # name must match ViewParam.NEW_PASSWORD
2843 must_change_password = MustChangePasswordNode() # match ViewParam.MUST_CHANGE_PASSWORD and User attribute # noqa
2844 group_ids = AllGroupsSequence() # must match ViewParam.GROUP_IDS
2847class AddUserGroupadminSchema(AddUserSuperuserSchema):
2848 """
2849 Schema to add a user. Version for group administrators.
2850 """
2851 group_ids = AdministeredGroupsSequence() # must match ViewParam.GROUP_IDS
2854class AddUserSuperuserForm(AddCancelForm):
2855 """
2856 Form to add a user. Version for superusers.
2857 """
2858 def __init__(self, request: "CamcopsRequest", **kwargs) -> None:
2859 super().__init__(schema_class=AddUserSuperuserSchema,
2860 request=request, **kwargs)
2863class AddUserGroupadminForm(AddCancelForm):
2864 """
2865 Form to add a user. Version for group administrators.
2866 """
2867 def __init__(self, request: "CamcopsRequest", **kwargs) -> None:
2868 super().__init__(schema_class=AddUserGroupadminSchema,
2869 request=request, **kwargs)
2872class SetUserUploadGroupSchema(CSRFSchema):
2873 """
2874 Schema to choose the group into which a user uploads.
2875 """
2876 upload_group_id = OptionalGroupIdSelectorUserGroups() # must match ViewParam.UPLOAD_GROUP_ID # noqa
2878 # noinspection PyUnusedLocal
2879 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
2880 _ = self.gettext
2881 upload_group_id = get_child_node(self, "upload_group_id")
2882 upload_group_id.title = _("Group into which to upload data")
2883 upload_group_id.description = _(
2884 "Pick a group from those to which the user belongs")
2887class SetUserUploadGroupForm(InformativeNonceForm):
2888 """
2889 Form to choose the group into which a user uploads.
2890 """
2891 def __init__(self, request: "CamcopsRequest", user: "User",
2892 **kwargs) -> None:
2893 _ = request.gettext
2894 schema = SetUserUploadGroupSchema().bind(request=request,
2895 user=user) # UNUSUAL
2896 super().__init__(
2897 schema,
2898 buttons=[
2899 Button(name=FormAction.SUBMIT, title=_("Set")),
2900 Button(name=FormAction.CANCEL, title=_("Cancel")),
2901 ],
2902 **kwargs
2903 )
2906class DeleteUserSchema(HardWorkConfirmationSchema):
2907 """
2908 Schema to delete a user.
2909 """
2910 user_id = HiddenIntegerNode() # name must match ViewParam.USER_ID
2911 danger = TranslatableValidateDangerousOperationNode()
2914class DeleteUserForm(DeleteCancelForm):
2915 """
2916 Form to delete a user.
2917 """
2918 def __init__(self, request: "CamcopsRequest", **kwargs) -> None:
2919 super().__init__(schema_class=DeleteUserSchema,
2920 request=request, **kwargs)
2923# =============================================================================
2924# Add/edit/delete groups
2925# =============================================================================
2927class PolicyNode(MandatoryStringNode, RequestAwareMixin):
2928 """
2929 Node to capture a CamCOPS ID number policy, and make sure it is
2930 syntactically valid.
2931 """
2932 def validator(self, node: SchemaNode, value: Any) -> None:
2933 _ = self.gettext
2934 if not isinstance(value, str):
2935 # unlikely!
2936 raise Invalid(node, _("Not a string"))
2937 policy = TokenizedPolicy(value)
2938 if not policy.is_syntactically_valid():
2939 raise Invalid(node, _("Syntactically invalid policy"))
2940 if not policy.is_valid_for_idnums(self.request.valid_which_idnums):
2941 raise Invalid(
2942 node,
2943 _("Invalid policy. Have you referred to non-existent ID "
2944 "numbers? Is the policy less restrictive than the tablet’s "
2945 "minimum ID policy?") +
2946 f" [{TABLET_ID_POLICY_STR!r}]"
2947 )
2950class GroupNameNode(MandatoryStringNode, RequestAwareMixin):
2951 """
2952 Node to capture a CamCOPS group name, and check it's valid as a string.
2953 """
2954 def validator(self, node: SchemaNode, value: str) -> None:
2955 try:
2956 validate_group_name(value, self.request)
2957 except ValueError as e:
2958 raise Invalid(node, str(e))
2961class GroupIpUseWidget(Widget):
2962 basedir = os.path.join(TEMPLATE_DIR, "deform")
2963 readonlydir = os.path.join(basedir, "readonly")
2964 form = "group_ip_use.pt"
2965 template = os.path.join(basedir, form)
2966 readonly_template = os.path.join(readonlydir, form)
2968 def __init__(self, request: "CamcopsRequest", **kwargs: Any) -> None:
2969 super().__init__(**kwargs)
2970 self.request = request
2972 def serialize(self,
2973 field: "Field",
2974 cstruct: Union[Dict[str, Any], None, ColanderNullType],
2975 **kw: Any) -> Any:
2976 if cstruct in (None, null):
2977 cstruct = {}
2979 cstruct: Dict[str, Any] # For type checker
2981 for context in IpUse.CONTEXTS:
2982 value = cstruct.get(context, False)
2983 kw.setdefault(context, value)
2985 readonly = kw.get("readonly", self.readonly)
2986 template = readonly and self.readonly_template or self.template
2987 values = self.get_template_values(field, cstruct, kw)
2989 _ = self.request.gettext
2991 values.update(
2992 introduction=_(
2993 "These settings will be applied to the patient's device "
2994 "when operating in single user mode."
2995 ),
2996 reason=_(
2997 "The settings here influence whether CamCOPS will consider "
2998 "some third-party tasks “permitted” on your behalf, according "
2999 "to their published use criteria. They do <b>not</b> remove "
3000 "your responsibility to ensure that you use them in accordance "
3001 "with their own requirements."
3002 ),
3003 warning=_(
3004 "WARNING. Providing incorrect information here may lead to you "
3005 "VIOLATING copyright law, by using a task for a purpose that "
3006 "is not permitted, and being subject to damages and/or "
3007 "prosecution."
3008 ),
3009 disclaimer=_(
3010 "The authors of CamCOPS cannot be held responsible or liable "
3011 "for any consequences of you misusing materials subject to "
3012 "copyright."
3013 ),
3014 preamble=_("In which contexts does this group operate?"),
3015 clinical_label=_("Clinical"),
3016 medical_device_warning=_(
3017 "WARNING: NOT FOR GENERAL CLINICAL USE; not a Medical Device; "
3018 "see Terms and Conditions"
3019 ),
3020 commercial_label=_("Commercial"),
3021 educational_label=_("Educational"),
3022 research_label=_("Research"),
3023 )
3025 return field.renderer(template, **values)
3027 def deserialize(
3028 self,
3029 field: "Field",
3030 pstruct: Union[Dict[str, Any], ColanderNullType]
3031 ) -> Dict[str, bool]:
3032 if pstruct is null:
3033 pstruct = {}
3035 pstruct: Dict[str, Any] # For type checker
3037 # It doesn't really matter what the pstruct values are. Only the
3038 # options that are ticked will be present as keys in pstruct
3039 return {k: k in pstruct for k in IpUse.CONTEXTS}
3042class IpUseType(object):
3043 # noinspection PyMethodMayBeStatic,PyUnusedLocal
3044 def deserialize(
3045 self,
3046 node: SchemaNode,
3047 cstruct: Union[Dict[str, Any], None, ColanderNullType]) \
3048 -> Optional[IpUse]:
3049 if cstruct in (None, null):
3050 return None
3052 cstruct: Dict[str, Any] # For type checker
3054 return IpUse(**cstruct)
3056 # noinspection PyMethodMayBeStatic,PyUnusedLocal
3057 def serialize(
3058 self,
3059 node: SchemaNode,
3060 ip_use: Union[IpUse, None, ColanderNullType]) \
3061 -> Union[Dict, ColanderNullType]:
3062 if ip_use in [null, None]:
3063 return null
3065 return {
3066 context: getattr(ip_use, context) for context in IpUse.CONTEXTS
3067 }
3070class GroupIpUseNode(SchemaNode, RequestAwareMixin):
3071 schema_type = IpUseType
3073 # noinspection PyUnusedLocal,PyAttributeOutsideInit
3074 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
3075 self.widget = GroupIpUseWidget(self.request)
3078class EditGroupSchema(CSRFSchema):
3079 """
3080 Schema to edit a group.
3081 """
3082 group_id = HiddenIntegerNode() # must match ViewParam.GROUP_ID
3083 name = GroupNameNode() # must match ViewParam.NAME
3084 description = MandatoryStringNode( # must match ViewParam.DESCRIPTION
3085 validator=Length(StringLengths.GROUP_DESCRIPTION_MIN_LEN,
3086 StringLengths.GROUP_DESCRIPTION_MAX_LEN),
3087 )
3088 ip_use = GroupIpUseNode()
3090 group_ids = AllOtherGroupsSequence() # must match ViewParam.GROUP_IDS
3091 upload_policy = PolicyNode() # must match ViewParam.UPLOAD_POLICY
3092 finalize_policy = PolicyNode() # must match ViewParam.FINALIZE_POLICY
3094 # noinspection PyUnusedLocal
3095 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
3096 _ = self.gettext
3097 name = get_child_node(self, "name")
3098 name.title = _("Group name")
3100 ip_use = get_child_node(self, "ip_use")
3101 ip_use.title = _("Group intellectual property settings")
3103 group_ids = get_child_node(self, "group_ids")
3104 group_ids.title = _("Other groups this group may see")
3105 upload_policy = get_child_node(self, "upload_policy")
3106 upload_policy.title = _("Upload policy")
3107 upload_policy.description = _(
3108 "Minimum required patient information to copy data to server")
3109 finalize_policy = get_child_node(self, "finalize_policy")
3110 finalize_policy.title = _("Finalize policy")
3111 finalize_policy.description = _(
3112 "Minimum required patient information to clear data off "
3113 "source device")
3115 def validator(self, node: SchemaNode, value: Any) -> None:
3116 request = self.bindings[Binding.REQUEST] # type: CamcopsRequest
3117 q = CountStarSpecializedQuery(Group, session=request.dbsession)\
3118 .filter(Group.id != value[ViewParam.GROUP_ID])\
3119 .filter(Group.name == value[ViewParam.NAME])
3120 if q.count_star() > 0:
3121 _ = request.gettext
3122 raise Invalid(node, _("Name is used by another group!"))
3125class EditGroupForm(InformativeNonceForm):
3126 """
3127 Form to edit a group.
3128 """
3129 def __init__(self, request: "CamcopsRequest", group: Group,
3130 **kwargs) -> None:
3131 _ = request.gettext
3132 schema = EditGroupSchema().bind(request=request,
3133 group=group) # UNUSUAL BINDING
3134 super().__init__(
3135 schema,
3136 buttons=[
3137 Button(name=FormAction.SUBMIT, title=_("Apply")),
3138 Button(name=FormAction.CANCEL, title=_("Cancel")),
3139 ],
3140 **kwargs
3141 )
3144class AddGroupSchema(CSRFSchema):
3145 """
3146 Schema to add a group.
3147 """
3148 name = GroupNameNode() # name must match ViewParam.NAME
3150 # noinspection PyUnusedLocal
3151 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
3152 _ = self.gettext
3153 name = get_child_node(self, "name")
3154 name.title = _("Group name")
3156 def validator(self, node: SchemaNode, value: Any) -> None:
3157 request = self.bindings[Binding.REQUEST] # type: CamcopsRequest
3158 q = CountStarSpecializedQuery(Group, session=request.dbsession)\
3159 .filter(Group.name == value[ViewParam.NAME])
3160 if q.count_star() > 0:
3161 _ = request.gettext
3162 raise Invalid(node, _("Name is used by another group!"))
3165class AddGroupForm(AddCancelForm):
3166 """
3167 Form to add a group.
3168 """
3169 def __init__(self, request: "CamcopsRequest", **kwargs) -> None:
3170 super().__init__(schema_class=AddGroupSchema,
3171 request=request, **kwargs)
3174class DeleteGroupSchema(HardWorkConfirmationSchema):
3175 """
3176 Schema to delete a group.
3177 """
3178 group_id = HiddenIntegerNode() # name must match ViewParam.GROUP_ID
3179 danger = TranslatableValidateDangerousOperationNode()
3182class DeleteGroupForm(DeleteCancelForm):
3183 """
3184 Form to delete a group.
3185 """
3186 def __init__(self, request: "CamcopsRequest", **kwargs) -> None:
3187 super().__init__(schema_class=DeleteGroupSchema,
3188 request=request, **kwargs)
3191# =============================================================================
3192# Offer research dumps
3193# =============================================================================
3195class DumpTypeSelector(SchemaNode, RequestAwareMixin):
3196 """
3197 Node to select the filtering method for a data dump.
3198 """
3199 schema_type = String
3200 default = ViewArg.EVERYTHING
3201 missing = ViewArg.EVERYTHING
3203 def __init__(self, *args, **kwargs) -> None:
3204 self.title = "" # for type checker
3205 self.widget = None # type: Optional[Widget]
3206 self.validator = None # type: Optional[ValidatorType]
3207 super().__init__(*args, **kwargs)
3209 # noinspection PyUnusedLocal
3210 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
3211 _ = self.gettext
3212 self.title = _("Dump method")
3213 choices = (
3214 (ViewArg.EVERYTHING, _("Everything")),
3215 (ViewArg.USE_SESSION_FILTER,
3216 _("Use the session filter settings")),
3217 (ViewArg.SPECIFIC_TASKS_GROUPS,
3218 _("Specify tasks/groups manually (see below)")),
3219 )
3220 self.widget = RadioChoiceWidget(values=choices)
3221 self.validator = OneOf(list(x[0] for x in choices))
3224class SpreadsheetFormatSelector(SchemaNode, RequestAwareMixin):
3225 """
3226 Node to select a way of downloading an SQLite database.
3227 """
3228 schema_type = String
3229 default = ViewArg.XLSX
3230 missing = ViewArg.XLSX
3232 def __init__(self, *args, **kwargs) -> None:
3233 self.title = "" # for type checker
3234 self.widget = None # type: Optional[Widget]
3235 self.validator = None # type: Optional[ValidatorType]
3236 super().__init__(*args, **kwargs)
3238 # noinspection PyUnusedLocal
3239 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
3240 _ = self.gettext
3241 self.title = _("Spreadsheet format")
3242 choices = (
3243 (ViewArg.R, _("R script")),
3244 (ViewArg.ODS, _("OpenOffice spreadsheet (ODS) file")),
3245 (ViewArg.XLSX, _("XLSX (Microsoft Excel) file")),
3246 (ViewArg.TSV_ZIP, _("ZIP file of tab-separated value (TSV) files")),
3247 )
3248 values, pv = get_values_and_permissible(choices)
3249 self.widget = RadioChoiceWidget(values=values)
3250 self.validator = OneOf(pv)
3253class DeliveryModeNode(SchemaNode, RequestAwareMixin):
3254 """
3255 Mode of delivery of data downloads.
3256 """
3257 schema_type = String
3258 default = ViewArg.EMAIL
3259 missing = ViewArg.EMAIL
3261 def __init__(self, *args, **kwargs) -> None:
3262 self.title = "" # for type checker
3263 self.widget = None # type: Optional[Widget]
3264 super().__init__(*args, **kwargs)
3266 # noinspection PyUnusedLocal
3267 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
3268 _ = self.gettext
3269 self.title = _("Delivery")
3270 choices = (
3271 (ViewArg.IMMEDIATELY, _("Serve immediately")),
3272 (ViewArg.EMAIL, _("E-mail me")),
3273 (ViewArg.DOWNLOAD, _("Create a file for me to download")),
3274 )
3275 values, pv = get_values_and_permissible(choices)
3276 self.widget = RadioChoiceWidget(values=values)
3278 # noinspection PyUnusedLocal
3279 def validator(self, node: SchemaNode, value: Any) -> None:
3280 request = self.bindings[Binding.REQUEST] # type: CamcopsRequest
3281 _ = request.gettext
3282 if value == ViewArg.IMMEDIATELY:
3283 if not request.config.permit_immediate_downloads:
3284 raise Invalid(
3285 self,
3286 _("Disabled by the system administrator") +
3287 f" [{ConfigParamSite.PERMIT_IMMEDIATE_DOWNLOADS}]"
3288 )
3289 elif value == ViewArg.EMAIL:
3290 if not request.user.email:
3291 raise Invalid(
3292 self, _("Your user does not have an email address"))
3293 elif value == ViewArg.DOWNLOAD:
3294 if not request.user_download_dir:
3295 raise Invalid(
3296 self,
3297 _("User downloads not configured by administrator") +
3298 f" [{ConfigParamSite.USER_DOWNLOAD_DIR}, "
3299 f"{ConfigParamSite.USER_DOWNLOAD_MAX_SPACE_MB}]"
3300 )
3301 else:
3302 raise Invalid(self, _("Bad value"))
3305class SqliteSelector(SchemaNode, RequestAwareMixin):
3306 """
3307 Node to select a way of downloading an SQLite database.
3308 """
3309 schema_type = String
3310 default = ViewArg.SQLITE
3311 missing = ViewArg.SQLITE
3313 def __init__(self, *args, **kwargs) -> None:
3314 self.title = "" # for type checker
3315 self.widget = None # type: Optional[Widget]
3316 self.validator = None # type: Optional[ValidatorType]
3317 super().__init__(*args, **kwargs)
3319 # noinspection PyUnusedLocal
3320 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
3321 _ = self.gettext
3322 self.title = _("Database download method")
3323 choices = (
3324 # http://docs.sqlalchemy.org/en/latest/dialects/
3325 (ViewArg.SQLITE, _("Binary SQLite database")),
3326 (ViewArg.SQL, _("SQL text to create SQLite database")),
3327 )
3328 values, pv = get_values_and_permissible(choices)
3329 self.widget = RadioChoiceWidget(values=values)
3330 self.validator = OneOf(pv)
3333class SortTsvByHeadingsNode(SchemaNode, RequestAwareMixin):
3334 """
3335 Boolean node: sort TSV files by column name?
3336 """
3337 schema_type = Boolean
3338 default = False
3339 missing = False
3341 def __init__(self, *args, **kwargs) -> None:
3342 self.title = "" # for type checker
3343 self.label = "" # for type checker
3344 super().__init__(*args, **kwargs)
3346 # noinspection PyUnusedLocal
3347 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
3348 _ = self.gettext
3349 self.title = _("Sort columns?")
3350 self.label = _("Sort by heading (column) names within spreadsheets?")
3353class IncludeInformationSchemaColumnsNode(SchemaNode, RequestAwareMixin):
3354 """
3355 Boolean node: should INFORMATION_SCHEMA.COLUMNS be included (for
3356 downloads)?
3358 False by default -- adds about 350 kb to an ODS download, for example.
3359 """
3360 schema_type = Boolean
3361 default = False
3362 missing = False
3364 def __init__(self, *args, **kwargs) -> None:
3365 self.title = "" # for type checker
3366 self.label = "" # for type checker
3367 super().__init__(*args, **kwargs)
3369 # noinspection PyUnusedLocal
3370 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
3371 _ = self.gettext
3372 self.title = _("Include column information?")
3373 self.label = _("Include details of all columns in the source database?") # noqa
3376class IncludeBlobsNode(SchemaNode, RequestAwareMixin):
3377 """
3378 Boolean node: should BLOBs be included (for downloads)?
3379 """
3380 schema_type = Boolean
3381 default = False
3382 missing = False
3384 def __init__(self, *args, **kwargs) -> None:
3385 self.title = "" # for type checker
3386 self.label = "" # for type checker
3387 super().__init__(*args, **kwargs)
3389 # noinspection PyUnusedLocal
3390 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
3391 _ = self.gettext
3392 self.title = _("Include BLOBs?")
3393 self.label = _(
3394 "Include binary large objects (BLOBs)? WARNING: may be large")
3397class PatientIdPerRowNode(SchemaNode, RequestAwareMixin):
3398 """
3399 Boolean node: should patient ID information, and other cross-referencing
3400 denormalized info, be included per row?
3402 See :ref:`DB_PATIENT_ID_PER_ROW <DB_PATIENT_ID_PER_ROW>`.
3403 """
3404 schema_type = Boolean
3405 default = True
3406 missing = True
3408 def __init__(self, *args, **kwargs) -> None:
3409 self.title = "" # for type checker
3410 self.label = "" # for type checker
3411 super().__init__(*args, **kwargs)
3413 # noinspection PyUnusedLocal
3414 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
3415 _ = self.gettext
3416 self.title = _("Patient ID per row?")
3417 self.label = _(
3418 "Include patient ID numbers and task cross-referencing "
3419 "(denormalized) information per row?")
3422class OfferDumpManualSchema(Schema, RequestAwareMixin):
3423 """
3424 Schema to offer the "manual" settings for a data dump (groups, task types).
3425 """
3426 group_ids = AllowedGroupsSequence() # must match ViewParam.GROUP_IDS
3427 tasks = MultiTaskSelector() # must match ViewParam.TASKS
3429 widget = MappingWidget(template="mapping_accordion", open=False)
3431 def __init__(self, *args, **kwargs) -> None:
3432 self.title = "" # for type checker
3433 super().__init__(*args, **kwargs)
3435 # noinspection PyUnusedLocal
3436 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
3437 _ = self.gettext
3438 self.title = _("Manual settings")
3441class OfferBasicDumpSchema(CSRFSchema):
3442 """
3443 Schema to choose the settings for a basic (TSV/ZIP) data dump.
3444 """
3445 dump_method = DumpTypeSelector() # must match ViewParam.DUMP_METHOD
3446 sort = SortTsvByHeadingsNode() # must match ViewParam.SORT
3447 include_information_schema_columns = IncludeInformationSchemaColumnsNode() # must match ViewParam.INCLUDE_INFORMATION_SCHEMA_COLUMNS # noqa
3448 manual = OfferDumpManualSchema() # must match ViewParam.MANUAL
3449 viewtype = SpreadsheetFormatSelector() # must match ViewParams.VIEWTYPE # noqa
3450 delivery_mode = DeliveryModeNode() # must match ViewParam.DELIVERY_MODE
3453class OfferBasicDumpForm(SimpleSubmitForm):
3454 """
3455 Form to offer a basic (TSV/ZIP) data dump.
3456 """
3457 def __init__(self, request: "CamcopsRequest", **kwargs) -> None:
3458 _ = request.gettext
3459 super().__init__(schema_class=OfferBasicDumpSchema,
3460 submit_title=_("Dump"),
3461 request=request, **kwargs)
3464class OfferSqlDumpSchema(CSRFSchema):
3465 """
3466 Schema to choose the settings for an SQL data dump.
3467 """
3468 dump_method = DumpTypeSelector() # must match ViewParam.DUMP_METHOD
3469 sqlite_method = SqliteSelector() # must match ViewParam.SQLITE_METHOD
3470 include_information_schema_columns = IncludeInformationSchemaColumnsNode() # must match ViewParam.INCLUDE_INFORMATION_SCHEMA_COLUMNS # noqa
3471 include_blobs = IncludeBlobsNode() # must match ViewParam.INCLUDE_BLOBS
3472 patient_id_per_row = PatientIdPerRowNode() # must match ViewParam.PATIENT_ID_PER_ROW # noqa
3473 manual = OfferDumpManualSchema() # must match ViewParam.MANUAL
3474 delivery_mode = DeliveryModeNode() # must match ViewParam.DELIVERY_MODE
3477class OfferSqlDumpForm(SimpleSubmitForm):
3478 """
3479 Form to choose the settings for an SQL data dump.
3480 """
3481 def __init__(self, request: "CamcopsRequest", **kwargs) -> None:
3482 _ = request.gettext
3483 super().__init__(schema_class=OfferSqlDumpSchema,
3484 submit_title=_("Dump"),
3485 request=request, **kwargs)
3488# =============================================================================
3489# Edit server settings
3490# =============================================================================
3492class EditServerSettingsSchema(CSRFSchema):
3493 """
3494 Schema to edit the global settings for the server.
3495 """
3496 database_title = SchemaNode( # must match ViewParam.DATABASE_TITLE
3497 String(),
3498 validator=Length(StringLengths.DATABASE_TITLE_MIN_LEN,
3499 StringLengths.DATABASE_TITLE_MAX_LEN),
3500 )
3502 # noinspection PyUnusedLocal
3503 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
3504 _ = self.gettext
3505 database_title = get_child_node(self, "database_title")
3506 database_title.title = _("Database friendly title")
3509class EditServerSettingsForm(ApplyCancelForm):
3510 """
3511 Form to edit the global settings for the server.
3512 """
3513 def __init__(self, request: "CamcopsRequest", **kwargs) -> None:
3514 super().__init__(schema_class=EditServerSettingsSchema,
3515 request=request, **kwargs)
3518# =============================================================================
3519# Edit ID number definitions
3520# =============================================================================
3522class IdDefinitionDescriptionNode(SchemaNode, RequestAwareMixin):
3523 """
3524 Node to capture the description of an ID number type.
3525 """
3526 schema_type = String
3527 validator = Length(1, StringLengths.ID_DESCRIPTOR_MAX_LEN)
3529 def __init__(self, *args, **kwargs) -> None:
3530 self.title = "" # for type checker
3531 super().__init__(*args, **kwargs)
3533 # noinspection PyUnusedLocal
3534 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
3535 _ = self.gettext
3536 self.title = _("Full description (e.g. “NHS number”)")
3539class IdDefinitionShortDescriptionNode(SchemaNode, RequestAwareMixin):
3540 """
3541 Node to capture the short description of an ID number type.
3542 """
3543 schema_type = String
3544 validator = Length(1, StringLengths.ID_DESCRIPTOR_MAX_LEN)
3546 def __init__(self, *args, **kwargs) -> None:
3547 self.title = "" # for type checker
3548 self.description = "" # for type checker
3549 super().__init__(*args, **kwargs)
3551 # noinspection PyUnusedLocal
3552 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
3553 _ = self.gettext
3554 self.title = _("Short description (e.g. “NHS#”)")
3555 self.description = _("Try to keep it very short!")
3558class IdValidationMethodNode(OptionalStringNode, RequestAwareMixin):
3559 """
3560 Node to choose a build-in ID number validation method.
3561 """
3562 widget = SelectWidget(values=ID_NUM_VALIDATION_METHOD_CHOICES)
3563 validator = OneOf(list(x[0] for x in ID_NUM_VALIDATION_METHOD_CHOICES))
3565 def __init__(self, *args, **kwargs) -> None:
3566 self.title = "" # for type checker
3567 self.description = "" # for type checker
3568 super().__init__(*args, **kwargs)
3570 # noinspection PyUnusedLocal
3571 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
3572 _ = self.gettext
3573 self.title = _("Validation method")
3574 self.description = _("Built-in CamCOPS ID number validation method")
3577class Hl7AssigningAuthorityNode(OptionalStringNode, RequestAwareMixin):
3578 """
3579 Optional node to capture the name of an HL7 Assigning Authority.
3580 """
3581 def __init__(self, *args, **kwargs) -> None:
3582 self.title = "" # for type checker
3583 self.description = "" # for type checker
3584 super().__init__(*args, **kwargs)
3586 # noinspection PyUnusedLocal
3587 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
3588 _ = self.gettext
3589 self.title = _("HL7 Assigning Authority")
3590 self.description = _(
3591 "For HL7 messaging: "
3592 "HL7 Assigning Authority for ID number (unique name of the "
3593 "system/organization/agency/department that creates the data)."
3594 )
3596 # noinspection PyMethodMayBeStatic
3597 def validator(self, node: SchemaNode, value: str) -> None:
3598 try:
3599 validate_hl7_aa(value, self.request)
3600 except ValueError as e:
3601 raise Invalid(node, str(e))
3604class Hl7IdTypeNode(OptionalStringNode, RequestAwareMixin):
3605 """
3606 Optional node to capture the name of an HL7 Identifier Type code.
3607 """
3608 def __init__(self, *args, **kwargs) -> None:
3609 self.title = "" # for type checker
3610 self.description = "" # for type checker
3611 super().__init__(*args, **kwargs)
3613 # noinspection PyUnusedLocal
3614 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
3615 _ = self.gettext
3616 self.title = _("HL7 Identifier Type")
3617 self.description = _(
3618 "For HL7 messaging: "
3619 "HL7 Identifier Type code: ‘a code corresponding to the type "
3620 "of identifier. In some cases, this code may be used as a "
3621 "qualifier to the “Assigning Authority” component.’"
3622 )
3624 # noinspection PyMethodMayBeStatic
3625 def validator(self, node: SchemaNode, value: str) -> None:
3626 try:
3627 validate_hl7_id_type(value, self.request)
3628 except ValueError as e:
3629 raise Invalid(node, str(e))
3632class EditIdDefinitionSchema(CSRFSchema):
3633 """
3634 Schema to edit an ID number definition.
3635 """
3636 which_idnum = HiddenIntegerNode() # must match ViewParam.WHICH_IDNUM
3637 description = IdDefinitionDescriptionNode() # must match ViewParam.DESCRIPTION # noqa
3638 short_description = IdDefinitionShortDescriptionNode() # must match ViewParam.SHORT_DESCRIPTION # noqa
3639 validation_method = IdValidationMethodNode() # must match ViewParam.VALIDATION_METHOD # noqa
3640 hl7_id_type = Hl7IdTypeNode() # must match ViewParam.HL7_ID_TYPE
3641 hl7_assigning_authority = Hl7AssigningAuthorityNode() # must match ViewParam.HL7_ASSIGNING_AUTHORITY # noqa
3643 def validator(self, node: SchemaNode, value: Any) -> None:
3644 request = self.bindings[Binding.REQUEST] # type: CamcopsRequest
3645 _ = request.gettext
3646 qd = CountStarSpecializedQuery(IdNumDefinition,
3647 session=request.dbsession)\
3648 .filter(IdNumDefinition.which_idnum !=
3649 value[ViewParam.WHICH_IDNUM])\
3650 .filter(IdNumDefinition.description ==
3651 value[ViewParam.DESCRIPTION])
3652 if qd.count_star() > 0:
3653 raise Invalid(node, _("Description is used by another ID number!"))
3654 qs = CountStarSpecializedQuery(IdNumDefinition,
3655 session=request.dbsession)\
3656 .filter(IdNumDefinition.which_idnum !=
3657 value[ViewParam.WHICH_IDNUM])\
3658 .filter(IdNumDefinition.short_description ==
3659 value[ViewParam.SHORT_DESCRIPTION])
3660 if qs.count_star() > 0:
3661 raise Invalid(node,
3662 _("Short description is used by another ID number!"))
3665class EditIdDefinitionForm(ApplyCancelForm):
3666 """
3667 Form to edit an ID number definition.
3668 """
3669 def __init__(self, request: "CamcopsRequest", **kwargs) -> None:
3670 super().__init__(schema_class=EditIdDefinitionSchema,
3671 request=request, **kwargs)
3674class AddIdDefinitionSchema(CSRFSchema):
3675 """
3676 Schema to add an ID number definition.
3677 """
3678 which_idnum = SchemaNode( # must match ViewParam.WHICH_IDNUM
3679 Integer(),
3680 validator=Range(min=1)
3681 )
3682 description = IdDefinitionDescriptionNode() # must match ViewParam.DESCRIPTION # noqa
3683 short_description = IdDefinitionShortDescriptionNode() # must match ViewParam.SHORT_DESCRIPTION # noqa
3684 validation_method = IdValidationMethodNode() # must match ViewParam.VALIDATION_METHOD # noqa
3686 # noinspection PyUnusedLocal
3687 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
3688 _ = self.gettext
3689 which_idnum = get_child_node(self, "which_idnum")
3690 which_idnum.title = _("Which ID number?")
3691 which_idnum.description = (
3692 "Specify the integer to represent the type of this ID "
3693 "number class (e.g. consecutive numbering from 1)"
3694 )
3696 def validator(self, node: SchemaNode, value: Any) -> None:
3697 request = self.bindings[Binding.REQUEST] # type: CamcopsRequest
3698 _ = request.gettext
3699 qw = (
3700 CountStarSpecializedQuery(IdNumDefinition,
3701 session=request.dbsession)
3702 .filter(IdNumDefinition.which_idnum ==
3703 value[ViewParam.WHICH_IDNUM])
3704 )
3705 if qw.count_star() > 0:
3706 raise Invalid(node, _("ID# clashes with another ID number!"))
3707 qd = (
3708 CountStarSpecializedQuery(IdNumDefinition,
3709 session=request.dbsession)
3710 .filter(IdNumDefinition.description ==
3711 value[ViewParam.DESCRIPTION])
3712 )
3713 if qd.count_star() > 0:
3714 raise Invalid(node, _("Description is used by another ID number!"))
3715 qs = (
3716 CountStarSpecializedQuery(IdNumDefinition,
3717 session=request.dbsession)
3718 .filter(IdNumDefinition.short_description ==
3719 value[ViewParam.SHORT_DESCRIPTION])
3720 )
3721 if qs.count_star() > 0:
3722 raise Invalid(node,
3723 _("Short description is used by another ID number!"))
3726class AddIdDefinitionForm(AddCancelForm):
3727 """
3728 Form to add an ID number definition.
3729 """
3730 def __init__(self, request: "CamcopsRequest", **kwargs) -> None:
3731 super().__init__(schema_class=AddIdDefinitionSchema,
3732 request=request, **kwargs)
3735class DeleteIdDefinitionSchema(HardWorkConfirmationSchema):
3736 """
3737 Schema to delete an ID number definition.
3738 """
3739 which_idnum = HiddenIntegerNode() # name must match ViewParam.WHICH_IDNUM
3740 danger = TranslatableValidateDangerousOperationNode()
3743class DeleteIdDefinitionForm(DangerousForm):
3744 """
3745 Form to add an ID number definition.
3746 """
3747 def __init__(self, request: "CamcopsRequest", **kwargs) -> None:
3748 _ = request.gettext
3749 super().__init__(schema_class=DeleteIdDefinitionSchema,
3750 submit_action=FormAction.DELETE,
3751 submit_title=_("Delete"),
3752 request=request, **kwargs)
3755# =============================================================================
3756# Special notes
3757# =============================================================================
3759class AddSpecialNoteSchema(CSRFSchema):
3760 """
3761 Schema to add a special note to a task.
3762 """
3763 table_name = HiddenStringNode() # must match ViewParam.TABLENAME
3764 server_pk = HiddenIntegerNode() # must match ViewParam.SERVER_PK
3765 note = MandatoryStringNode( # must match ViewParam.NOTE
3766 widget=TextAreaWidget(rows=20, cols=80)
3767 )
3768 danger = TranslatableValidateDangerousOperationNode()
3771class AddSpecialNoteForm(DangerousForm):
3772 """
3773 Form to add a special note to a task.
3774 """
3775 def __init__(self, request: "CamcopsRequest", **kwargs) -> None:
3776 _ = request.gettext
3777 super().__init__(schema_class=AddSpecialNoteSchema,
3778 submit_action=FormAction.SUBMIT,
3779 submit_title=_("Add"),
3780 request=request, **kwargs)
3783class DeleteSpecialNoteSchema(CSRFSchema):
3784 """
3785 Schema to add a special note to a task.
3786 """
3787 note_id = HiddenIntegerNode() # must match ViewParam.NOTE_ID
3788 danger = TranslatableValidateDangerousOperationNode()
3791class DeleteSpecialNoteForm(DangerousForm):
3792 """
3793 Form to delete (hide) a special note.
3794 """
3795 def __init__(self, request: "CamcopsRequest", **kwargs) -> None:
3796 _ = request.gettext
3797 super().__init__(schema_class=DeleteSpecialNoteSchema,
3798 submit_action=FormAction.SUBMIT,
3799 submit_title=_("Delete"),
3800 request=request, **kwargs)
3803# =============================================================================
3804# The unusual data manipulation operations
3805# =============================================================================
3807class EraseTaskSchema(HardWorkConfirmationSchema):
3808 """
3809 Schema to erase a task.
3810 """
3811 table_name = HiddenStringNode() # must match ViewParam.TABLENAME
3812 server_pk = HiddenIntegerNode() # must match ViewParam.SERVER_PK
3813 danger = TranslatableValidateDangerousOperationNode()
3816class EraseTaskForm(DangerousForm):
3817 """
3818 Form to erase a task.
3819 """
3820 def __init__(self, request: "CamcopsRequest", **kwargs) -> None:
3821 _ = request.gettext
3822 super().__init__(schema_class=EraseTaskSchema,
3823 submit_action=FormAction.DELETE,
3824 submit_title=_("Erase"),
3825 request=request, **kwargs)
3828class DeletePatientChooseSchema(CSRFSchema):
3829 """
3830 Schema to delete a patient.
3831 """
3832 which_idnum = MandatoryWhichIdNumSelector() # must match ViewParam.WHICH_IDNUM # noqa
3833 idnum_value = MandatoryIdNumValue() # must match ViewParam.IDNUM_VALUE
3834 group_id = MandatoryGroupIdSelectorAdministeredGroups() # must match ViewParam.GROUP_ID # noqa
3835 danger = TranslatableValidateDangerousOperationNode()
3838class DeletePatientChooseForm(DangerousForm):
3839 """
3840 Form to delete a patient.
3841 """
3842 def __init__(self, request: "CamcopsRequest", **kwargs) -> None:
3843 _ = request.gettext
3844 super().__init__(schema_class=DeletePatientChooseSchema,
3845 submit_action=FormAction.SUBMIT,
3846 submit_title=_("Show tasks that will be deleted"),
3847 request=request, **kwargs)
3850class DeletePatientConfirmSchema(HardWorkConfirmationSchema):
3851 """
3852 Schema to confirm deletion of a patient.
3853 """
3854 which_idnum = HiddenIntegerNode() # must match ViewParam.WHICH_IDNUM
3855 idnum_value = HiddenIntegerNode() # must match ViewParam.IDNUM_VALUE
3856 group_id = HiddenIntegerNode() # must match ViewParam.GROUP_ID
3857 danger = TranslatableValidateDangerousOperationNode()
3860class DeletePatientConfirmForm(DangerousForm):
3861 """
3862 Form to confirm deletion of a patient.
3863 """
3864 def __init__(self, request: "CamcopsRequest", **kwargs) -> None:
3865 _ = request.gettext
3866 super().__init__(schema_class=DeletePatientConfirmSchema,
3867 submit_action=FormAction.DELETE,
3868 submit_title=_("Delete"),
3869 request=request, **kwargs)
3872class DeleteServerCreatedPatientSchema(HardWorkConfirmationSchema):
3873 """
3874 Schema to delete a patient created on the server.
3875 """
3876 # name must match ViewParam.SERVER_PK
3877 server_pk = HiddenIntegerNode()
3878 danger = TranslatableValidateDangerousOperationNode()
3881class DeleteServerCreatedPatientForm(DeleteCancelForm):
3882 """
3883 Form to delete a patient created on the server
3884 """
3885 def __init__(self, request: "CamcopsRequest", **kwargs: Any) -> None:
3886 super().__init__(schema_class=DeleteServerCreatedPatientSchema,
3887 request=request, **kwargs)
3890EDIT_PATIENT_SIMPLE_PARAMS = [
3891 ViewParam.FORENAME,
3892 ViewParam.SURNAME,
3893 ViewParam.DOB,
3894 ViewParam.SEX,
3895 ViewParam.ADDRESS,
3896 ViewParam.EMAIL,
3897 ViewParam.GP,
3898 ViewParam.OTHER,
3899]
3902class TaskScheduleSelector(SchemaNode, RequestAwareMixin):
3903 """
3904 Drop-down with all available task schedules
3905 """
3906 widget = SelectWidget()
3908 def __init__(self, *args: Any, **kwargs: Any) -> None:
3909 self.title = "" # for type checker
3910 self.name = "" # for type checker
3911 self.validator = None # type: Optional[ValidatorType]
3912 super().__init__(*args, **kwargs)
3914 # noinspection PyUnusedLocal
3915 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
3916 request = self.request
3917 _ = request.gettext
3918 self.title = _("Task schedule")
3919 values = [] # type: List[Tuple[Optional[int], str]]
3921 task_schedules = (
3922 request.dbsession.query(TaskSchedule)
3923 .order_by(TaskSchedule.name)
3924 )
3926 for task_schedule in task_schedules:
3927 values.append((task_schedule.id, task_schedule.name))
3928 values, pv = get_values_and_permissible(values, add_none=False)
3930 self.widget.values = values
3931 self.validator = OneOf(pv)
3933 @staticmethod
3934 def schema_type() -> SchemaType:
3935 return Integer()
3938class JsonType(object):
3939 """
3940 Schema type for JsonNode
3941 """
3942 # noinspection PyMethodMayBeStatic, PyUnusedLocal
3943 def deserialize(self, node: SchemaNode,
3944 cstruct: Union[str, ColanderNullType, None]) -> Any:
3945 # is null when form is empty
3946 if cstruct in (null, None):
3947 return None
3949 cstruct: str
3951 try:
3952 # Validation happens on the widget class
3953 json_value = json.loads(cstruct)
3954 except json.JSONDecodeError:
3955 return None
3957 return json_value
3959 # noinspection PyMethodMayBeStatic,PyUnusedLocal
3960 def serialize(
3961 self,
3962 node: SchemaNode,
3963 appstruct: Union[Dict, None, ColanderNullType]) \
3964 -> Union[str, ColanderNullType]:
3965 # is null when form is empty (new record)
3966 # is None when populated from empty value in the database
3967 if appstruct in (null, None):
3968 return null
3970 # appstruct should be well formed here (it would already have failed
3971 # when reading from the database)
3972 return json.dumps(appstruct)
3975class JsonWidget(Widget):
3976 """
3977 Widget supporting jsoneditor https://github.com/josdejong/jsoneditor
3978 """
3979 basedir = os.path.join(TEMPLATE_DIR, "deform")
3980 readonlydir = os.path.join(basedir, "readonly")
3981 form = "json.pt"
3982 template = os.path.join(basedir, form)
3983 readonly_template = os.path.join(readonlydir, form)
3984 requirements = (('jsoneditor', None),)
3986 def __init__(self, request: "CamcopsRequest", **kwargs: Any) -> None:
3987 super().__init__(**kwargs)
3988 self.request = request
3990 def serialize(
3991 self, field: "Field", cstruct: Union[str, ColanderNullType], **kw: Any
3992 ) -> Any:
3993 if cstruct is null:
3994 cstruct = ""
3996 readonly = kw.get('readonly', self.readonly)
3997 template = readonly and self.readonly_template or self.template
3999 values = self.get_template_values(field, cstruct, kw)
4001 return field.renderer(template, **values)
4003 def deserialize(
4004 self, field: "Field", pstruct: Union[str, ColanderNullType]
4005 ) -> Union[str, ColanderNullType]:
4006 # is empty string when field is empty
4007 if pstruct in (null, ""):
4008 return null
4010 _ = self.request.gettext
4011 error_message = _("Please enter valid JSON or leave blank")
4013 pstruct: str
4015 try:
4016 json.loads(pstruct)
4017 except json.JSONDecodeError:
4018 raise Invalid(field, error_message, pstruct)
4020 return pstruct
4023class JsonNode(SchemaNode, RequestAwareMixin):
4024 schema_type = JsonType
4025 missing = null
4027 # noinspection PyUnusedLocal,PyAttributeOutsideInit
4028 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
4029 self.widget = JsonWidget(self.request)
4032class TaskScheduleNode(MappingSchema, RequestAwareMixin):
4033 schedule_id = TaskScheduleSelector() # must match ViewParam.SCHEDULE_ID # noqa: E501
4034 # must match ViewParam.START_DATETIME
4035 start_datetime = StartPendulumSelector()
4036 settings = JsonNode() # must match ViewParam.SETTINGS
4038 def __init__(self, *args: Any, **kwargs: Any) -> None:
4039 self.title = "" # for type checker
4040 super().__init__(*args, **kwargs)
4042 # noinspection PyUnusedLocal
4043 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
4044 _ = self.gettext
4045 self.title = _("Task schedule")
4046 start_datetime = get_child_node(self, "start_datetime")
4047 start_datetime.description = _(
4048 "Leave blank for the date the patient first downloads the schedule"
4049 )
4050 settings = get_child_node(self, "settings")
4051 settings.title = _("Task-specific settings for this patient")
4052 settings.description = _(
4053 "ADVANCED. Only applicable to tasks that are configurable on a "
4054 "per-patient basis. Format: JSON object, with settings keyed on "
4055 "task table name."
4056 )
4058 def validator(self, node: SchemaNode, value: Any) -> None:
4059 settings_value = value["settings"]
4061 if settings_value is not None:
4062 # will be None if JSON failed to validate
4063 if not isinstance(settings_value, dict):
4064 _ = self.request.gettext
4065 error_message = _(
4066 "Please enter a valid JSON object (with settings keyed on "
4067 "task table name) or leave blank"
4068 )
4070 raise Invalid(node, error_message)
4073class TaskScheduleSequence(SequenceSchema, RequestAwareMixin):
4074 task_schedule_sequence = TaskScheduleNode()
4075 missing = drop
4077 def __init__(self, *args: Any, **kwargs: Any) -> None:
4078 self.title = "" # for type checker
4079 self.widget = None # type: Optional[Widget]
4080 super().__init__(*args, **kwargs)
4082 # noinspection PyUnusedLocal
4083 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
4084 _ = self.gettext
4085 self.title = _("Task Schedules")
4086 self.widget = TranslatableSequenceWidget(request=self.request)
4089class EditPatientSchema(CSRFSchema):
4090 """
4091 Schema to edit a patient.
4092 """
4093 server_pk = HiddenIntegerNode() # must match ViewParam.SERVER_PK
4094 forename = OptionalPatientNameNode() # must match ViewParam.FORENAME
4095 surname = OptionalPatientNameNode() # must match ViewParam.SURNAME
4096 dob = DateSelectorNode() # must match ViewParam.DOB
4097 sex = MandatorySexSelector() # must match ViewParam.SEX
4098 address = OptionalStringNode() # must match ViewParam.ADDRESS
4099 email = OptionalEmailNode() # must match ViewParam.EMAIL
4100 gp = OptionalStringNode() # must match ViewParam.GP
4101 other = OptionalStringNode() # must match ViewParam.OTHER
4102 id_references = IdNumSequenceUniquePerWhichIdnum() # must match ViewParam.ID_REFERENCES # noqa
4104 # noinspection PyUnusedLocal
4105 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
4106 _ = self.gettext
4107 dob = get_child_node(self, "dob")
4108 dob.title = _("Date of birth")
4109 gp = get_child_node(self, "gp")
4110 gp.title = _("GP")
4112 def validator(self, node: SchemaNode, value: Any) -> None:
4113 request = self.bindings[Binding.REQUEST] # type: CamcopsRequest
4114 dbsession = request.dbsession
4115 group_id = value[ViewParam.GROUP_ID]
4116 group = Group.get_group_by_id(dbsession, group_id)
4117 testpatient = Patient()
4118 for k in EDIT_PATIENT_SIMPLE_PARAMS:
4119 setattr(testpatient, k, value[k])
4120 testpatient.idnums = []
4121 for idrefdict in value[ViewParam.ID_REFERENCES]:
4122 pidnum = PatientIdNum()
4123 pidnum.which_idnum = idrefdict[ViewParam.WHICH_IDNUM]
4124 pidnum.idnum_value = idrefdict[ViewParam.IDNUM_VALUE]
4125 testpatient.idnums.append(pidnum)
4126 tk_finalize_policy = TokenizedPolicy(group.finalize_policy)
4127 if not testpatient.satisfies_id_policy(tk_finalize_policy):
4128 _ = self.gettext
4129 raise Invalid(
4130 node,
4131 _("Patient would not meet 'finalize' ID policy for group:")
4132 + f" {group.name}! [" +
4133 _("That policy is:") +
4134 f" {group.finalize_policy!r}]"
4135 )
4138class DangerousEditPatientSchema(EditPatientSchema):
4139 group_id = HiddenIntegerNode() # must match ViewParam.GROUP_ID
4140 danger = TranslatableValidateDangerousOperationNode()
4143class EditServerCreatedPatientSchema(EditPatientSchema):
4144 # Must match ViewParam.GROUP_ID
4145 group_id = MandatoryGroupIdSelectorAdministeredGroups(
4146 insert_before="forename"
4147 )
4148 task_schedules = TaskScheduleSequence() # must match ViewParam.TASK_SCHEDULES # noqa: E501
4151class EditFinalizedPatientForm(DangerousForm):
4152 """
4153 Form to edit a finalized patient.
4154 """
4155 def __init__(self, request: "CamcopsRequest", **kwargs: Any) -> None:
4156 _ = request.gettext
4157 super().__init__(schema_class=DangerousEditPatientSchema,
4158 submit_action=FormAction.SUBMIT,
4159 submit_title=_("Submit"),
4160 request=request, **kwargs)
4163class EditServerCreatedPatientForm(DynamicDescriptionsNonceForm):
4164 """
4165 Form to add or edit a patient not yet on the device (for scheduled tasks)
4166 """
4167 def __init__(self, request: "CamcopsRequest", **kwargs: Any) -> None:
4168 schema = EditServerCreatedPatientSchema().bind(request=request)
4169 _ = request.gettext
4170 super().__init__(
4171 schema,
4172 request=request,
4173 buttons=[
4174 Button(name=FormAction.SUBMIT, title=_("Submit"),
4175 css_class="btn-danger"),
4176 Button(name=FormAction.CANCEL, title=_("Cancel")),
4177 ],
4178 **kwargs
4179 )
4182class EmailTemplateNode(OptionalStringNode, RequestAwareMixin):
4183 def __init__(self, *args, **kwargs) -> None:
4184 self.title = "" # for type checker
4185 self.description = "" # for type checker
4186 self.formatter = TaskScheduleEmailTemplateFormatter()
4187 super().__init__(*args, **kwargs)
4189 # noinspection PyUnusedLocal
4190 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
4191 _ = self.gettext
4192 self.title = _("Email template")
4193 self.description = _(
4194 "Template of email to be sent to patients when inviting them to "
4195 "complete the tasks in the schedule. Valid placeholders: {}"
4196 ).format(self.formatter.get_valid_parameters_string())
4198 # noinspection PyAttributeOutsideInit
4199 self.widget = TextAreaWidget(rows=20, cols=80)
4201 def validator(self, node: SchemaNode, value: Any) -> None:
4202 _ = self.gettext
4204 try:
4205 self.formatter.validate(value)
4206 return
4207 except KeyError as e:
4208 error = _("{bad_key} is not a valid placeholder").format(
4209 bad_key=e,
4210 )
4211 except ValueError:
4212 error = _(
4213 "Invalid email template. Is there a missing '{' or '}' ?"
4214 )
4216 raise Invalid(node, error)
4219class TaskScheduleSchema(CSRFSchema):
4220 name = OptionalStringNode()
4221 group_id = MandatoryGroupIdSelectorAdministeredGroups() # must match ViewParam.GROUP_ID # noqa
4222 email_subject = OptionalStringNode()
4223 email_template = EmailTemplateNode()
4226class EditTaskScheduleForm(DynamicDescriptionsNonceForm):
4227 def __init__(self, request: "CamcopsRequest", **kwargs: Any) -> None:
4228 schema = TaskScheduleSchema().bind(request=request)
4229 _ = request.gettext
4230 super().__init__(
4231 schema,
4232 request=request,
4233 buttons=[
4234 Button(name=FormAction.SUBMIT, title=_("Submit"),
4235 css_class="btn-danger"),
4236 Button(name=FormAction.CANCEL, title=_("Cancel")),
4237 ],
4238 **kwargs
4239 )
4242class DeleteTaskScheduleSchema(HardWorkConfirmationSchema):
4243 """
4244 Schema to delete a task schedule.
4245 """
4246 # name must match ViewParam.SCHEDULE_ID
4247 schedule_id = HiddenIntegerNode()
4248 danger = TranslatableValidateDangerousOperationNode()
4251class DeleteTaskScheduleForm(DeleteCancelForm):
4252 """
4253 Form to delete a task schedule.
4254 """
4255 def __init__(self, request: "CamcopsRequest", **kwargs: Any) -> None:
4256 super().__init__(schema_class=DeleteTaskScheduleSchema,
4257 request=request, **kwargs)
4260class DurationWidget(Widget):
4261 """
4262 Widget for entering a duration as a number of months, weeks and days.
4263 The default template renders three text input fields.
4264 Total days = (months * 30) + (weeks * 7) + days.
4265 """
4266 basedir = os.path.join(TEMPLATE_DIR, "deform")
4267 readonlydir = os.path.join(basedir, "readonly")
4268 form = "duration.pt"
4269 template = os.path.join(basedir, form)
4270 readonly_template = os.path.join(readonlydir, form)
4272 def __init__(self, request: "CamcopsRequest", **kwargs: Any) -> None:
4273 super().__init__(**kwargs)
4274 self.request = request
4276 def serialize(self,
4277 field: "Field",
4278 cstruct: Union[Dict[str, Any], None, ColanderNullType],
4279 **kw: Any) -> Any:
4280 # called when rendering the form with values from DurationType.serialize
4281 if cstruct in (None, null):
4282 cstruct = {}
4284 cstruct: Dict[str, Any]
4286 months = cstruct.get("months", "")
4287 weeks = cstruct.get("weeks", "")
4288 days = cstruct.get("days", "")
4290 kw.setdefault("months", months)
4291 kw.setdefault("weeks", weeks)
4292 kw.setdefault("days", days)
4294 readonly = kw.get("readonly", self.readonly)
4295 template = readonly and self.readonly_template or self.template
4296 values = self.get_template_values(field, cstruct, kw)
4298 _ = self.request.gettext
4300 values.update(
4301 weeks_placeholder=_("1 week = 7 days"),
4302 months_placeholder=_("1 month = 30 days"),
4303 months_label=_("Months"),
4304 weeks_label=_("Weeks"),
4305 days_label=_("Days"),
4306 )
4308 return field.renderer(template, **values)
4310 def deserialize(
4311 self,
4312 field: "Field",
4313 pstruct: Union[Dict[str, Any], ColanderNullType]
4314 ) -> Dict[str, int]:
4315 # called when validating the form on submission
4316 # value is passed to the schema deserialize()
4318 if pstruct is null:
4319 pstruct = {}
4321 pstruct: Dict[str, Any]
4323 errors = []
4325 try:
4326 days = int(pstruct.get("days") or "0")
4327 except ValueError:
4328 errors.append("Please enter a valid number of days or leave blank")
4330 try:
4331 weeks = int(pstruct.get("weeks") or "0")
4332 except ValueError:
4333 errors.append("Please enter a valid number of weeks or leave blank")
4335 try:
4336 months = int(pstruct.get("months") or "0")
4337 except ValueError:
4338 errors.append(
4339 "Please enter a valid number of months or leave blank"
4340 )
4342 if len(errors) > 0:
4343 raise Invalid(field, errors, pstruct)
4345 # noinspection PyUnboundLocalVariable
4346 return {
4347 "days": days,
4348 "months": months,
4349 "weeks": weeks,
4350 }
4353class DurationType(object):
4354 """
4355 Custom colander schema type to convert between Pendulum Duration objects
4356 and months, weeks and days.
4357 """
4359 # noinspection PyMethodMayBeStatic,PyUnusedLocal
4360 def deserialize(
4361 self,
4362 node: SchemaNode,
4363 cstruct: Union[Dict[str, Any], None, ColanderNullType]) \
4364 -> Optional[Duration]:
4365 # called when validating the submitted form with the total days
4366 # from DurationWidget.deserialize()
4367 if cstruct in (None, null):
4368 return None
4370 cstruct: Dict[str, Any]
4372 # may be passed invalid values when re-rendering widget with error
4373 # messages
4374 try:
4375 days = int(cstruct.get("days") or "0")
4376 except ValueError:
4377 days = 0
4379 try:
4380 weeks = int(cstruct.get("weeks") or "0")
4381 except ValueError:
4382 weeks = 0
4384 try:
4385 months = int(cstruct.get("months") or "0")
4386 except ValueError:
4387 months = 0
4389 total_days = months * 30 + weeks * 7 + days
4391 return Duration(days=total_days)
4393 # noinspection PyMethodMayBeStatic,PyUnusedLocal
4394 def serialize(
4395 self,
4396 node: SchemaNode,
4397 duration: Union[Duration, ColanderNullType]) \
4398 -> Union[Dict, ColanderNullType]:
4399 if duration is null:
4400 # For new schedule item
4401 return null
4403 duration: Duration
4405 total_days = duration.in_days()
4407 months = total_days // 30
4408 weeks = (total_days % 30) // 7
4409 days = (total_days % 30) % 7
4411 # Existing schedule item
4412 cstruct = {
4413 "days": days,
4414 "months": months,
4415 "weeks": weeks,
4416 }
4418 return cstruct
4421class DurationNode(SchemaNode, RequestAwareMixin):
4422 schema_type = DurationType
4424 # noinspection PyUnusedLocal,PyAttributeOutsideInit
4425 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
4426 self.widget = DurationWidget(self.request)
4429class TaskScheduleItemSchema(CSRFSchema):
4430 schedule_id = HiddenIntegerNode() # name must match ViewParam.SCHEDULE_ID
4431 # name must match ViewParam.TABLE_NAME
4432 table_name = MandatorySingleTaskSelector()
4433 # name must match ViewParam.CLINICIAN_CONFIRMATION
4434 clinician_confirmation = BooleanNode(default=False)
4435 due_from = DurationNode() # name must match ViewParam.DUE_FROM
4436 due_within = DurationNode() # name must match ViewParam.DUE_WITHIN
4438 # noinspection PyUnusedLocal
4439 def after_bind(self, node: SchemaNode, kw: Dict[str, Any]) -> None:
4440 _ = self.gettext
4441 due_from = get_child_node(self, "due_from")
4442 due_from.title = _("Due from")
4443 due_from.description = _(
4444 "Time from the start of schedule when the patient may begin this "
4445 "task"
4446 )
4447 due_within = get_child_node(self, "due_within")
4448 due_within.title = _("Due within")
4449 due_within.description = _(
4450 "Time the patient has to complete this task"
4451 )
4452 clinician_confirmation = get_child_node(self, "clinician_confirmation")
4453 clinician_confirmation.title = _("Allow clinician tasks")
4454 clinician_confirmation.label = None
4455 clinician_confirmation.description = _(
4456 "Tick this box to schedule a task that would normally be completed "
4457 "by (or with) a clinician"
4458 )
4460 def validator(self, node: SchemaNode, value: Dict[str, Any]) -> None:
4461 task_class = self._get_task_class(value)
4463 self._validate_clinician_status(node, value, task_class)
4464 self._validate_due_dates(node, value)
4465 self._validate_task_ip_use(node, value, task_class)
4467 # noinspection PyMethodMayBeStatic
4468 def _get_task_class(self, value: Dict[str, Any]) -> Type["Task"]:
4469 return tablename_to_task_class_dict()[value[ViewParam.TABLE_NAME]]
4471 def _validate_clinician_status(self,
4472 node: SchemaNode,
4473 value: Dict[str, Any],
4474 task_class: Type["Task"]) -> None:
4476 _ = self.gettext
4477 clinician_confirmation = value[ViewParam.CLINICIAN_CONFIRMATION]
4478 if task_class.has_clinician and not clinician_confirmation:
4479 raise Invalid(
4480 node,
4481 _(
4482 "You have selected the task '{task_name}', which a "
4483 "patient would not normally complete by themselves. "
4484 "If you are sure you want to do this, you must tick "
4485 "'Allow clinician tasks'."
4486 ).format(task_name=task_class.shortname)
4487 )
4489 def _validate_due_dates(self,
4490 node: SchemaNode,
4491 value: Dict[str, Any]) -> None:
4492 _ = self.gettext
4493 due_from = value[ViewParam.DUE_FROM]
4494 if due_from.total_days() < 0:
4495 raise Invalid(
4496 node,
4497 _("'Due from' must be zero or more days"),
4498 )
4500 due_within = value[ViewParam.DUE_WITHIN]
4501 if due_within.total_days() <= 0:
4502 raise Invalid(
4503 node,
4504 _("'Due within' must be more than zero days"),
4505 )
4507 def _validate_task_ip_use(self,
4508 node: SchemaNode,
4509 value: Dict[str, Any],
4510 task_class: Type["Task"]) -> None:
4512 _ = self.gettext
4514 if not task_class.prohibits_anything():
4515 return
4517 schedule_id = value[ViewParam.SCHEDULE_ID]
4518 schedule = self.request.dbsession.query(TaskSchedule).filter(
4519 TaskSchedule.id == schedule_id
4520 ).one()
4522 if schedule.group.ip_use is None:
4523 raise Invalid(
4524 node, _(
4525 "The task you have selected prohibits use in certain "
4526 "contexts. The group '{group_name}' has no intellectual "
4527 "property settings. "
4528 "You need to edit the group '{group_name}' to say which "
4529 "contexts it operates in.".format(
4530 group_name=schedule.group.name
4531 )
4532 )
4533 )
4535 # TODO: One the client we say 'to use this task, you must seek
4536 # permission from the copyright holder'. We could do the same but at the
4537 # moment there isn't a way of telling the system that we have done so.
4538 if task_class.prohibits_commercial and schedule.group.ip_use.commercial:
4539 raise Invalid(
4540 node,
4541 _("The group '{group_name}' associated with schedule "
4542 "'{schedule_name}' operates in a "
4543 "commercial context but the task you have selected "
4544 "prohibits commercial use.").format(
4545 group_name=schedule.group.name,
4546 schedule_name=schedule.name
4547 )
4548 )
4550 if task_class.prohibits_clinical and schedule.group.ip_use.clinical:
4551 raise Invalid(
4552 node,
4553 _("The group '{group_name}' associated with schedule "
4554 "'{schedule_name}' operates in a "
4555 "clinical context but the task you have selected "
4556 "prohibits clinical use.").format(
4557 group_name=schedule.group.name,
4558 schedule_name=schedule.name
4559 )
4560 )
4562 if task_class.prohibits_educational and schedule.group.ip_use.educational: # noqa
4563 raise Invalid(
4564 node,
4565 _("The group '{group_name}' associated with schedule "
4566 "'{schedule_name}' operates in an "
4567 "educational context but the task you have selected "
4568 "prohibits educational use.").format(
4569 group_name=schedule.group.name,
4570 schedule_name=schedule.name
4571 )
4572 )
4574 if task_class.prohibits_research and schedule.group.ip_use.research:
4575 raise Invalid(
4576 node,
4577 _("The group '{group_name}' associated with schedule "
4578 "'{schedule_name}' operates in a "
4579 "research context but the task you have selected "
4580 "prohibits research use.").format(
4581 group_name=schedule.group.name,
4582 schedule_name=schedule.name
4583 )
4584 )
4587class EditTaskScheduleItemForm(DynamicDescriptionsNonceForm):
4588 def __init__(self, request: "CamcopsRequest", **kwargs: Any) -> None:
4589 schema = TaskScheduleItemSchema().bind(request=request)
4590 _ = request.gettext
4591 super().__init__(
4592 schema,
4593 request=request,
4594 buttons=[
4595 Button(name=FormAction.SUBMIT, title=_("Submit"),
4596 css_class="btn-danger"),
4597 Button(name=FormAction.CANCEL, title=_("Cancel")),
4598 ],
4599 **kwargs
4600 )
4603class DeleteTaskScheduleItemSchema(HardWorkConfirmationSchema):
4604 """
4605 Schema to delete a task schedule item.
4606 """
4607 # name must match ViewParam.SCHEDULE_ITEM_ID
4608 schedule_item_id = HiddenIntegerNode()
4609 danger = TranslatableValidateDangerousOperationNode()
4612class DeleteTaskScheduleItemForm(DeleteCancelForm):
4613 """
4614 Form to delete a task schedule item.
4615 """
4616 def __init__(self, request: "CamcopsRequest", **kwargs: Any) -> None:
4617 super().__init__(schema_class=DeleteTaskScheduleItemSchema,
4618 request=request, **kwargs)
4621class ForciblyFinalizeChooseDeviceSchema(CSRFSchema):
4622 """
4623 Schema to force-finalize records from a device.
4624 """
4625 device_id = MandatoryDeviceIdSelector() # must match ViewParam.DEVICE_ID
4626 danger = TranslatableValidateDangerousOperationNode()
4629class ForciblyFinalizeChooseDeviceForm(SimpleSubmitForm):
4630 """
4631 Form to force-finalize records from a device.
4632 """
4633 def __init__(self, request: "CamcopsRequest", **kwargs) -> None:
4634 _ = request.gettext
4635 super().__init__(schema_class=ForciblyFinalizeChooseDeviceSchema,
4636 submit_title=_("View affected tasks"),
4637 request=request, **kwargs)
4640class ForciblyFinalizeConfirmSchema(HardWorkConfirmationSchema):
4641 """
4642 Schema to confirm force-finalizing of a device.
4643 """
4644 device_id = HiddenIntegerNode() # must match ViewParam.DEVICE_ID
4645 danger = TranslatableValidateDangerousOperationNode()
4648class ForciblyFinalizeConfirmForm(DangerousForm):
4649 """
4650 Form to confirm force-finalizing of a device.
4651 """
4652 def __init__(self, request: "CamcopsRequest", **kwargs) -> None:
4653 _ = request.gettext
4654 super().__init__(schema_class=ForciblyFinalizeConfirmSchema,
4655 submit_action=FormAction.FINALIZE,
4656 submit_title=_("Forcibly finalize"),
4657 request=request, **kwargs)
4660# =============================================================================
4661# User downloads
4662# =============================================================================
4664class UserDownloadDeleteSchema(CSRFSchema):
4665 """
4666 Schema to capture details of a file to be deleted.
4667 """
4668 filename = HiddenStringNode() # name must match ViewParam.FILENAME
4671class UserDownloadDeleteForm(SimpleSubmitForm):
4672 """
4673 Form that provides a single button to delete a user download.
4674 """
4675 def __init__(self, request: "CamcopsRequest", **kwargs) -> None:
4676 _ = request.gettext
4677 super().__init__(schema_class=UserDownloadDeleteSchema,
4678 submit_title=_("Delete"),
4679 request=request, **kwargs)