Coverage for cc_modules/webview.py: 25%
2278 statements
« prev ^ index » next coverage.py v6.5.0, created at 2022-11-08 23:14 +0000
« prev ^ index » next coverage.py v6.5.0, created at 2022-11-08 23:14 +0000
1#!/usr/bin/env python
3"""
4camcops_server/cc_modules/webview.py
6===============================================================================
8 Copyright (C) 2012, University of Cambridge, Department of Psychiatry.
9 Created by Rudolf Cardinal (rnc1001@cam.ac.uk).
11 This file is part of CamCOPS.
13 CamCOPS is free software: you can redistribute it and/or modify
14 it under the terms of the GNU General Public License as published by
15 the Free Software Foundation, either version 3 of the License, or
16 (at your option) any later version.
18 CamCOPS is distributed in the hope that it will be useful,
19 but WITHOUT ANY WARRANTY; without even the implied warranty of
20 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
21 GNU General Public License for more details.
23 You should have received a copy of the GNU General Public License
24 along with CamCOPS. If not, see <https://www.gnu.org/licenses/>.
26===============================================================================
28**Implements the CamCOPS web front end.**
30Quick tutorial on Pyramid views:
32- The configurator registers routes, and routes have URLs associated with
33 them. Those URLs can be templatized, e.g. to accept numerical parameters.
34 The configurator associates view callables ("views" for short) with routes,
35 and one method for doing that is an automatic scan via Venusian for views
36 decorated with @view_config().
38- All views take a Request object and return a Response or raise an exception
39 that Pyramid will translate into a Response.
41- Having matched a route, Pyramid uses its "view lookup" process to choose
42 one from potentially several views. For example, a single route might be
43 associated with:
45 .. code-block:: python
47 @view_config(route_name="myroute")
48 def myroute_default(req: Request) -> Response:
49 pass
51 @view_config(route_name="myroute", request_method="POST")
52 def myroute_post(req: Request) -> Response:
53 pass
55 In this example, POST requests will go to the second; everything else will
56 go to the first. Pyramid's view lookup rule is essentially: if multiple
57 views match, choose the one with the most specifiers.
59- Specifiers include:
61 .. code-block:: none
63 route_name=ROUTENAME
65 the route
67 request_method="POST"
69 requires HTTP GET, POST, etc.
71 request_param="XXX"
73 ... requires the presence of a GET/POST variable with this name in
74 the request.params dictionary
76 request_param="XXX=YYY"
78 ... requires the presence of a GET/POST variable called XXX whose
79 value is YYY, in the request.params dictionary
81 match_param="XXX=YYY"
83 .. requires the presence of this key/value pair in
84 request.matchdict, which contains parameters from the URL
86 https://docs.pylonsproject.org/projects/pyramid/en/latest/api/config.html#pyramid.config.Configurator.add_view # noqa
88- Getting parameters
90 .. code-block:: none
92 request.params
94 ... parameters from HTTP GET or POST, including both the query
95 string (as in https://somewhere/path?key=value) and the body (e.g.
96 POST).
98 request.matchdict
100 ... parameters from the URL, via URL dispatch; see
101 https://docs.pylonsproject.org/projects/pyramid/en/latest/narr/urldispatch.html#urldispatch-chapter # noqa
103- Regarding rendering:
105 There might be some simplicity benefits from converting to a template
106 system like Mako. On the downside, that would entail a bit more work;
107 likely a minor performance hit (relative to plain Python string rendering);
108 and a loss of type checking. The type checking is also why we prefer:
110 .. code-block:: python
112 html = " ... {param_blah} ...".format(param_blah=PARAM.BLAH)
114 to
116 .. code-block:: python
118 html = " ... {PARAM.BLAH} ...".format(PARAM=PARAM)
120 as in the first situation, PyCharm will check that "BLAH" is present in
121 "PARAM", and in the second it won't. Automatic checking is worth a lot.
123"""
125from collections import OrderedDict
126import json
127import logging
128import os
130# from pprint import pformat
131import time
132from typing import (
133 Any,
134 cast,
135 Dict,
136 List,
137 NoReturn,
138 Optional,
139 Tuple,
140 Type,
141 TYPE_CHECKING,
142)
144from cardinal_pythonlib.datetimefunc import format_datetime
145from cardinal_pythonlib.deform_utils import get_head_form_html
146from cardinal_pythonlib.httpconst import HttpMethod, MimeType
147from cardinal_pythonlib.logs import BraceStyleAdapter
148from cardinal_pythonlib.pyramid.responses import (
149 BinaryResponse,
150 JsonResponse,
151 PdfResponse,
152 XmlResponse,
153)
154from cardinal_pythonlib.sqlalchemy.dialect import (
155 get_dialect_name,
156 SqlaDialectName,
157)
158from cardinal_pythonlib.sizeformatter import bytes2human
159from cardinal_pythonlib.sqlalchemy.orm_inspect import gen_orm_classes_from_base
160from cardinal_pythonlib.sqlalchemy.orm_query import CountStarSpecializedQuery
161from cardinal_pythonlib.sqlalchemy.session import get_engine_from_session
162from deform.exception import ValidationFailure
163from pendulum import DateTime as Pendulum
164import pyotp
165from pyramid.httpexceptions import HTTPBadRequest, HTTPFound, HTTPNotFound
166from pyramid.view import (
167 forbidden_view_config,
168 notfound_view_config,
169 view_config,
170)
171from pyramid.renderers import render_to_response
172from pyramid.response import Response
173from pyramid.security import Authenticated, NO_PERMISSION_REQUIRED
174import pygments
175import pygments.lexers
176import pygments.lexers.sql
177import pygments.lexers.web
178import pygments.formatters
179from sqlalchemy.orm import joinedload, Query
180from sqlalchemy.sql.functions import func
181from sqlalchemy.sql.expression import desc, or_, select, update
183from camcops_server.cc_modules.cc_audit import audit, AuditEntry
184from camcops_server.cc_modules.cc_all_models import CLIENT_TABLE_MAP
185from camcops_server.cc_modules.cc_client_api_core import (
186 BatchDetails,
187 get_server_live_records,
188 UploadTableChanges,
189 values_preserve_now,
190)
191from camcops_server.cc_modules.cc_client_api_helpers import (
192 upload_commit_order_sorter,
193)
194from camcops_server.cc_modules.cc_constants import (
195 CAMCOPS_URL,
196 DateFormat,
197 ERA_NOW,
198 GITHUB_RELEASES_URL,
199 JSON_INDENT,
200 MfaMethod,
201)
202from camcops_server.cc_modules.cc_db import (
203 GenericTabletRecordMixin,
204 FN_DEVICE_ID,
205 FN_ERA,
206 FN_GROUP_ID,
207 FN_PK,
208)
209from camcops_server.cc_modules.cc_device import Device
210from camcops_server.cc_modules.cc_email import Email
211from camcops_server.cc_modules.cc_export import (
212 DownloadOptions,
213 make_exporter,
214 UserDownloadFile,
215)
216from camcops_server.cc_modules.cc_exportmodels import (
217 ExportedTask,
218 ExportedTaskEmail,
219 ExportedTaskFhir,
220 ExportedTaskFhirEntry,
221 ExportedTaskFileGroup,
222 ExportedTaskHL7Message,
223 ExportedTaskRedcap,
224)
225from camcops_server.cc_modules.cc_exportrecipient import ExportRecipient
226from camcops_server.cc_modules.cc_forms import (
227 AddGroupForm,
228 AddIdDefinitionForm,
229 AddSpecialNoteForm,
230 AddUserGroupadminForm,
231 AddUserSuperuserForm,
232 AuditTrailForm,
233 ChangeOtherPasswordForm,
234 ChangeOwnPasswordForm,
235 ChooseTrackerForm,
236 DEFORM_ACCORDION_BUG,
237 DEFAULT_ROWS_PER_PAGE,
238 DeleteGroupForm,
239 DeleteIdDefinitionForm,
240 DeletePatientChooseForm,
241 DeletePatientConfirmForm,
242 DeleteServerCreatedPatientForm,
243 DeleteSpecialNoteForm,
244 DeleteTaskScheduleForm,
245 DeleteTaskScheduleItemForm,
246 DeleteUserForm,
247 EDIT_PATIENT_SIMPLE_PARAMS,
248 EditFinalizedPatientForm,
249 EditGroupForm,
250 EditIdDefinitionForm,
251 EditOtherUserMfaForm,
252 EditServerCreatedPatientForm,
253 EditServerSettingsForm,
254 EditTaskFilterForm,
255 EditTaskScheduleForm,
256 EditTaskScheduleItemForm,
257 EditUserFullForm,
258 EditUserGroupAdminForm,
259 EditUserGroupMembershipGroupAdminForm,
260 EditUserGroupPermissionsFullForm,
261 EraseTaskForm,
262 ExportedTaskListForm,
263 ForciblyFinalizeChooseDeviceForm,
264 ForciblyFinalizeConfirmForm,
265 get_sql_dialect_choices,
266 LoginForm,
267 MfaHotpEmailForm,
268 MfaHotpSmsForm,
269 MfaMethodForm,
270 MfaTotpForm,
271 OfferBasicDumpForm,
272 OfferSqlDumpForm,
273 OfferTermsForm,
274 OtpTokenForm,
275 RefreshTasksForm,
276 SendEmailForm,
277 SetUserUploadGroupForm,
278 TasksPerPageForm,
279 UserDownloadDeleteForm,
280 UserFilterForm,
281 ViewDdlForm,
282)
283from camcops_server.cc_modules.cc_group import Group
284from camcops_server.cc_modules.cc_idnumdef import IdNumDefinition
285from camcops_server.cc_modules.cc_membership import UserGroupMembership
286from camcops_server.cc_modules.cc_patient import Patient
287from camcops_server.cc_modules.cc_patientidnum import PatientIdNum
289# noinspection PyUnresolvedReferences
290import camcops_server.cc_modules.cc_plot # import side effects (configure matplotlib) # noqa
291from camcops_server.cc_modules.cc_pyramid import (
292 CamcopsPage,
293 FlashQueue,
294 FormAction,
295 HTTPFoundDebugVersion,
296 Icons,
297 PageUrl,
298 Permission,
299 Routes,
300 SqlalchemyOrmPage,
301 ViewArg,
302 ViewParam,
303)
304from camcops_server.cc_modules.cc_report import get_report_instance
305from camcops_server.cc_modules.cc_request import CamcopsRequest
306from camcops_server.cc_modules.cc_simpleobjects import (
307 IdNumReference,
308 TaskExportOptions,
309)
310from camcops_server.cc_modules.cc_specialnote import SpecialNote
311from camcops_server.cc_modules.cc_session import CamcopsSession
312from camcops_server.cc_modules.cc_sqlalchemy import get_all_ddl
313from camcops_server.cc_modules.cc_task import (
314 tablename_to_task_class_dict,
315 Task,
316)
317from camcops_server.cc_modules.cc_taskcollection import (
318 TaskFilter,
319 TaskCollection,
320 TaskSortMethod,
321)
322from camcops_server.cc_modules.cc_taskfactory import task_factory
323from camcops_server.cc_modules.cc_taskfilter import (
324 task_classes_from_table_names,
325 TaskClassSortMethod,
326)
327from camcops_server.cc_modules.cc_taskindex import (
328 PatientIdNumIndexEntry,
329 TaskIndexEntry,
330 update_indexes_and_push_exports,
331)
332from camcops_server.cc_modules.cc_taskschedule import (
333 PatientTaskSchedule,
334 PatientTaskScheduleEmail,
335 TaskSchedule,
336 TaskScheduleItem,
337 task_schedule_item_sort_order,
338)
339from camcops_server.cc_modules.cc_text import SS
340from camcops_server.cc_modules.cc_tracker import ClinicalTextView, Tracker
341from camcops_server.cc_modules.cc_user import (
342 SecurityAccountLockout,
343 SecurityLoginFailure,
344 User,
345)
346from camcops_server.cc_modules.cc_validators import (
347 validate_download_filename,
348 validate_export_recipient_name,
349 validate_ip_address,
350 validate_task_tablename,
351 validate_username,
352)
353from camcops_server.cc_modules.cc_version import CAMCOPS_SERVER_VERSION
354from camcops_server.cc_modules.cc_view_classes import (
355 CreateView,
356 DeleteView,
357 FormView,
358 FormWizardMixin,
359 UpdateView,
360)
362if TYPE_CHECKING:
363 # noinspection PyUnresolvedReferences
364 from deform.form import Form
366 # noinspection PyUnresolvedReferences
367 from camcops_server.cc_modules.cc_sqlalchemy import Base
369log = BraceStyleAdapter(logging.getLogger(__name__))
372# =============================================================================
373# Debugging options
374# =============================================================================
376DEBUG_REDIRECT = False
378if DEBUG_REDIRECT:
379 log.warning("Debugging options enabled!")
381if DEBUG_REDIRECT:
382 HTTPFound = HTTPFoundDebugVersion # noqa: F811
385# =============================================================================
386# Cache control, for the http_cache parameter of view_config etc.
387# =============================================================================
389NEVER_CACHE = 0
392# =============================================================================
393# Constants -- for Mako templates
394# =============================================================================
395# Keys that will be added to a context dictionary that is passed to a Mako
396# template. For example, a key of "title" can be rendered within the template
397# as ${title}. Some are used frequently, so we have them here as constants.
399MAKO_VAR_TITLE = "title"
400TEMPLATE_GENERIC_FORM = "generic_form.mako"
403# =============================================================================
404# Constants -- mutated into translated phrases
405# =============================================================================
408def errormsg_cannot_dump(req: "CamcopsRequest") -> str:
409 _ = req.gettext
410 return _("User not authorized to dump data (for any group).")
413def errormsg_cannot_report(req: "CamcopsRequest") -> str:
414 _ = req.gettext
415 return _("User not authorized to run reports (for any group).")
418def errormsg_task_live(req: "CamcopsRequest") -> str:
419 _ = req.gettext
420 return _("Task is live on tablet; finalize (or force-finalize) first.")
423# =============================================================================
424# Unused
425# =============================================================================
427# def query_result_html_core(req: "CamcopsRequest",
428# descriptions: Sequence[str],
429# rows: Sequence[Sequence[Any]],
430# null_html: str = "<i>NULL</i>") -> str:
431# return render("query_result_core.mako",
432# dict(descriptions=descriptions,
433# rows=rows,
434# null_html=null_html),
435# request=req)
438# def query_result_html_orm(req: "CamcopsRequest",
439# attrnames: List[str],
440# descriptions: List[str],
441# orm_objects: Sequence[Sequence[Any]],
442# null_html: str = "<i>NULL</i>") -> str:
443# return render("query_result_orm.mako",
444# dict(attrnames=attrnames,
445# descriptions=descriptions,
446# orm_objects=orm_objects,
447# null_html=null_html),
448# request=req)
451# =============================================================================
452# Error views
453# =============================================================================
455# noinspection PyUnusedLocal
456@notfound_view_config(renderer="not_found.mako", http_cache=NEVER_CACHE)
457def not_found(req: "CamcopsRequest") -> Dict[str, Any]:
458 """
459 "Page not found" view.
460 """
461 return {"msg": "", "extra_html": ""}
464# noinspection PyUnusedLocal
465@view_config(
466 context=HTTPBadRequest, renderer="bad_request.mako", http_cache=NEVER_CACHE
467)
468def bad_request(req: "CamcopsRequest") -> Dict[str, Any]:
469 """
470 "Bad request" view.
472 NOTE that this view only gets used from
474 .. code-block:: python
476 raise HTTPBadRequest("message")
478 and not
480 .. code-block:: python
482 return HTTPBadRequest("message")
484 ... so always raise it.
485 """
486 return {"msg": "", "extra_html": ""}
489# =============================================================================
490# Test pages
491# =============================================================================
493# noinspection PyUnusedLocal
494@view_config(
495 route_name=Routes.TESTPAGE_PUBLIC_1,
496 permission=NO_PERMISSION_REQUIRED,
497 http_cache=NEVER_CACHE,
498)
499def test_page_1(req: "CamcopsRequest") -> Response:
500 """
501 A public test page with no content.
502 """
503 _ = req.gettext
504 return Response(_("Hello! This is a public CamCOPS test page."))
507# noinspection PyUnusedLocal
508@view_config(
509 route_name=Routes.TEST_NHS_NUMBERS,
510 permission=NO_PERMISSION_REQUIRED,
511 renderer="test_nhs_numbers.mako",
512 http_cache=NEVER_CACHE,
513)
514def test_nhs_numbers(req: "CamcopsRequest") -> Response:
515 """
516 Random Test NHS numbers for testing
517 """
518 from cardinal_pythonlib.nhs import generate_random_nhs_number
520 nhs_numbers = [generate_random_nhs_number() for _ in range(10)]
521 return dict(test_nhs_numbers=nhs_numbers)
524# noinspection PyUnusedLocal
525@view_config(route_name=Routes.TESTPAGE_PRIVATE_1, http_cache=NEVER_CACHE)
526def test_page_private_1(req: "CamcopsRequest") -> Response:
527 """
528 A private test page with no informative content, but which should only
529 be accessible to authenticated users.
530 """
531 _ = req.gettext
532 return Response(_("Private test page."))
535# noinspection PyUnusedLocal
536@view_config(
537 route_name=Routes.TESTPAGE_PRIVATE_2,
538 permission=Permission.SUPERUSER,
539 renderer="testpage.mako",
540 http_cache=NEVER_CACHE,
541)
542def test_page_2(req: "CamcopsRequest") -> Dict[str, Any]:
543 """
544 A private test page containing POTENTIALLY SENSITIVE test information,
545 including environment variables, that should only be accessible to
546 superusers.
547 """
548 return dict(param1="world")
551# noinspection PyUnusedLocal
552@view_config(
553 route_name=Routes.TESTPAGE_PRIVATE_3,
554 permission=Permission.SUPERUSER,
555 renderer="inherit_cache_test_child.mako",
556 http_cache=NEVER_CACHE,
557)
558def test_page_3(req: "CamcopsRequest") -> Dict[str, Any]:
559 """
560 A private test page that tests template inheritance.
561 """
562 return {}
565# noinspection PyUnusedLocal
566@view_config(
567 route_name=Routes.TESTPAGE_PRIVATE_4,
568 permission=Permission.SUPERUSER,
569 renderer="test_template_filters.mako",
570 http_cache=NEVER_CACHE,
571)
572def test_page_4(req: "CamcopsRequest") -> Dict[str, Any]:
573 """
574 A private test page that tests Mako filtering.
575 """
576 return dict(test_strings=["plain", "normal <b>bold</b> normal"])
579# noinspection PyUnusedLocal,PyTypeChecker
580@view_config(
581 route_name=Routes.CRASH,
582 permission=Permission.SUPERUSER,
583 http_cache=NEVER_CACHE,
584)
585def crash(req: "CamcopsRequest") -> Response:
586 """
587 A view that deliberately raises an exception.
588 """
589 _ = req.gettext
590 raise RuntimeError(
591 _("Deliberately crashed. Should not affect other processes.")
592 )
595# noinspection PyUnusedLocal
596@view_config(
597 route_name=Routes.DEVELOPER,
598 permission=Permission.SUPERUSER,
599 renderer="developer.mako",
600 http_cache=NEVER_CACHE,
601)
602def developer_page(req: "CamcopsRequest") -> Dict[str, Any]:
603 """
604 Shows the developer menu.
605 """
606 return {}
609# noinspection PyUnusedLocal
610@view_config(
611 route_name=Routes.AUDIT_MENU,
612 permission=Permission.SUPERUSER,
613 renderer="audit_menu.mako",
614 http_cache=NEVER_CACHE,
615)
616def audit_menu(req: "CamcopsRequest") -> Dict[str, Any]:
617 """
618 Shows the auditing menu.
619 """
620 return {}
623# =============================================================================
624# Authorization: login, logout, login failures, terms/conditions
625# =============================================================================
627# Do NOT use extra parameters for functions decorated with @view_config;
628# @view_config can take functions like "def view(request)" but also
629# "def view(context, request)", so if you add additional parameters, it thinks
630# you're doing the latter and sends parameters accordingly.
633class MfaMixin(FormWizardMixin):
634 """
635 Enhances FormWizardMixin to include a multi-factor authentication step.
636 This must be named "mfa" in the subclass, via the ``SELF_MFA`` variable.
638 This handles:
640 - Timing out
641 - Generating, sending and checking the six-digit code used for
642 authentication
644 The subclass should:
646 - Set ``mfa_user`` on the class to be an instance of the User to be
647 authenticated.
648 - Call ``handle_authentication_type()`` in the appropriate step.
649 - Call ``otp_is_valid()`` and ``fail_bad_mfa_code()`` in the appropriate
650 step.
652 See ``LoginView`` for an example that works with the yet-to-be-logged-in
653 user.
654 See ``ChangeOwnPasswordView`` for an example with the logged-in user.
655 """
657 STEP_PASSWORD = "password"
658 STEP_MFA = "mfa"
660 KEY_TITLE_HTML = "title_html"
661 KEY_INSTRUCTIONS = "instructions"
662 KEY_MFA_TIME = "mfa_time"
664 def __init__(self, *args, **kwargs) -> None:
665 self._mfa_user: Optional[User] = None
666 super().__init__(*args, **kwargs)
668 # -------------------------------------------------------------------------
669 # mfa_user
670 # -------------------------------------------------------------------------
671 # Set during __init__ by LoggedInUserMfaMixin, or via a more complex
672 # process by LoginView.
674 @property
675 def mfa_user(self) -> Optional[User]:
676 """
677 The user undergoing authentication.
678 """
679 return self._mfa_user
681 @mfa_user.setter
682 def mfa_user(self, user: Optional[User]) -> None:
683 """
684 Sets the current user being authenticated.
685 """
686 self._mfa_user = user
688 # -------------------------------------------------------------------------
689 # Dispatch and timeouts
690 # -------------------------------------------------------------------------
692 def dispatch(self) -> Response:
693 # Docstring in superclass.
694 if self.timed_out():
695 self.fail_timed_out() # will raise
697 return super().dispatch()
699 def timed_out(self) -> bool:
700 """
701 Has authentication timed out?
702 """
703 if self.step != self.STEP_MFA:
704 return False
706 timeout = self.request.config.mfa_timeout_s
707 if timeout == 0:
708 return False
710 login_time = self.state.get(self.KEY_MFA_TIME)
711 if login_time is None:
712 return False
714 return int(time.time()) > login_time + timeout
716 # -------------------------------------------------------------------------
717 # Extra context for templates
718 # -------------------------------------------------------------------------
720 def get_extra_context(self) -> Dict[str, Any]:
721 # Docstring in superclass.
722 if self.step == self.STEP_MFA:
723 context = {
724 self.KEY_TITLE_HTML: self.request.icon_text(
725 icon=self.get_mfa_icon(), text=self.get_mfa_title()
726 ),
727 self.KEY_INSTRUCTIONS: self.get_mfa_instructions(),
728 }
729 return context
730 else:
731 return {}
733 def get_mfa_icon(self) -> str:
734 """
735 Returns an icon to let the user know which MFA method is being used.
736 """
737 method = self.mfa_user.mfa_method
739 if method == MfaMethod.TOTP:
740 return "shield-shaded"
742 elif method == MfaMethod.HOTP_EMAIL:
743 return "envelope"
745 elif method == MfaMethod.HOTP_SMS:
746 return "chat-left-dots"
748 else:
749 return "Error: get_mfa_icon() called for invalid MFA method"
751 def get_mfa_title(self) -> str:
752 """
753 Returns a title for the page that requests the code itself.
754 """
755 _ = self.request.gettext
756 method = self.mfa_user.mfa_method
758 if method == MfaMethod.TOTP:
759 return _("Authenticate via your authentication app")
761 elif method == MfaMethod.HOTP_EMAIL:
762 return _("Authenticate via e-mail")
764 elif method == MfaMethod.HOTP_SMS:
765 return _("Authenticate via SMS")
767 else:
768 return "Error: get_mfa_title() called for invalid MFA method"
770 def get_mfa_instructions(self) -> str:
771 """
772 Return user instructions for the relevant MFA method.
773 """
774 _ = self.request.gettext
775 method = self.mfa_user.mfa_method
777 if method == MfaMethod.TOTP:
778 return _(
779 "Enter the code for CamCOPS displayed on your "
780 "authentication app."
781 )
783 elif method == MfaMethod.HOTP_EMAIL:
784 return _("We've sent a code by email to {}.").format(
785 self.mfa_user.partial_email
786 )
788 elif method == MfaMethod.HOTP_SMS:
789 return _("We've sent a code by text message to {}").format(
790 self.mfa_user.partial_phone_number
791 )
793 else:
794 return "Error: get_mfa_instruction() called for invalid MFA method"
796 # -------------------------------------------------------------------------
797 # MFA handling
798 # -------------------------------------------------------------------------
800 def handle_authentication_type(self) -> None:
801 """
802 Function to be called when we want an MFA code to be created.
803 """
804 mfa_user = self.mfa_user
805 mfa_user.ensure_mfa_info()
806 mfa_method = mfa_user.mfa_method
808 if mfa_method == MfaMethod.TOTP:
809 # Nothing to do. The app generates the code.
810 return
812 # Record the time of code creation:
813 self.state[self.KEY_MFA_TIME] = int(time.time())
815 if mfa_method == MfaMethod.HOTP_EMAIL:
816 self.send_authentication_email()
817 elif mfa_method == MfaMethod.HOTP_SMS:
818 self.send_authentication_sms()
819 else:
820 raise ValueError(
821 f"MfaMixin.handle_authentication_type: "
822 f"unexpected mfa_method {mfa_method!r}"
823 )
825 def send_authentication_email(self) -> None:
826 """
827 E-mail the code to the user.
828 """
829 _ = self.request.gettext
830 config = self.request.config
831 kwargs = dict(
832 from_addr=config.email_from,
833 to=self.mfa_user.email,
834 subject=_("CamCOPS authentication"),
835 body=self.get_hotp_message(),
836 content_type=MimeType.TEXT,
837 )
839 email = Email(**kwargs)
840 success = email.send(
841 host=config.email_host,
842 username=config.email_host_username,
843 password=config.email_host_password,
844 port=config.email_port,
845 use_tls=config.email_use_tls,
846 )
847 if success:
848 msg = _("E-mail sent")
849 queue = FlashQueue.SUCCESS
850 else:
851 msg = _(
852 "Failed to send e-mail! "
853 "Please try again or contact your administrator."
854 )
855 queue = FlashQueue.DANGER
856 self.request.session.flash(msg, queue=queue)
858 def send_authentication_sms(self) -> None:
859 """
860 Send a code to the user via SMS (text message).
861 """
862 backend = self.request.config.sms_backend
863 backend.send_sms(
864 self.mfa_user.raw_phone_number, self.get_hotp_message()
865 )
867 def get_hotp_message(self) -> str:
868 """
869 Return a human-readable message containing an HOTP (HMAC-Based One-Time
870 Password).
871 """
872 self.mfa_user.hotp_counter += 1
873 self.request.dbsession.add(self.mfa_user)
874 _ = self.request.gettext
875 key = self.mfa_user.mfa_secret_key
876 assert key, f"Bug: self.mfa_user.mfa_secret_key = {key!r}"
877 handler = pyotp.HOTP(key)
878 code = handler.at(self.mfa_user.hotp_counter)
879 return _("Your CamCOPS verification code is {}").format(code)
881 def otp_is_valid(self, appstruct: Dict[str, Any]) -> bool:
882 """
883 Is the code being offered by the user the right one?
884 """
885 otp = appstruct.get(ViewParam.ONE_TIME_PASSWORD)
886 return self.mfa_user.verify_one_time_password(otp)
888 # -------------------------------------------------------------------------
889 # Ways to fail
890 # -------------------------------------------------------------------------
892 def fail_bad_mfa_code(self) -> NoReturn:
893 """
894 Fail because the code was wrong.
895 """
896 _ = self.request.gettext
897 self.fail(_("You entered an invalid code. Please try again."))
899 def fail_timed_out(self) -> NoReturn:
900 """
901 Fail because the process timed out.
902 """
903 _ = self.request.gettext
904 self.fail(_("Your code expired. Please try again."))
907class LoggedInUserMfaMixin(MfaMixin):
908 """
909 Handles multi-factor authentication for the currently logged in user
910 (everything except :class:`LoginView`).
911 """
913 def __init__(self, *args, **kwargs) -> None:
914 super().__init__(*args, **kwargs)
915 self.mfa_user = self.request.user
918class LoginView(MfaMixin, FormView):
919 """
920 Multi-factor authentication for the login process.
921 Sequences is: (1) password; (2) MFA, if enabled.
923 Inheritance (as of 2021-10-06):
925 - webview.LoginView
927 - webview.MfaMixin
929 - cc_view_classes.FormWizardMixin
931 - cc_view_classes.FormView
933 - cc_view_classes.TemplateResponseMixin
935 - cc_view_classes.BaseFormView
937 - cc_view_classes.FormMixin
939 - cc_view_classes.ContextMixin
941 - cc_view_classes.ProcessFormView -- provides ``get()``, ``post()``
943 - cc_view_classes.View -- owns ``request``, provides ``dispatch()``
944 """
946 KEY_MFA_USER_ID = "mfa_user_id"
948 _mfa_user: Optional[User]
949 wizard_first_step = MfaMixin.STEP_PASSWORD
950 wizard_forms = {
951 MfaMixin.STEP_PASSWORD: LoginForm, # 1. enter username/password
952 MfaMixin.STEP_MFA: OtpTokenForm, # 2. enter one-time code
953 }
954 wizard_templates = {
955 MfaMixin.STEP_PASSWORD: "login.mako",
956 MfaMixin.STEP_MFA: "login_token.mako",
957 }
959 def __init__(self, *args, **kwargs) -> None:
960 super().__init__(*args, **kwargs)
962 # -------------------------------------------------------------------------
963 # mfa_user
964 # -------------------------------------------------------------------------
965 # Slightly more complex here, since our user isn't logged in properly yet.
967 @property
968 def mfa_user(self) -> Optional[User]:
969 # Docstring in superclass.
970 if self._mfa_user is None:
971 try:
972 user_id = self.state[self.KEY_MFA_USER_ID]
973 self.mfa_user = (
974 self.request.dbsession.query(User)
975 .filter(User.id == user_id)
976 .one_or_none()
977 )
978 except KeyError:
979 pass
981 return self._mfa_user
983 @mfa_user.setter
984 def mfa_user(self, user: Optional[User]) -> None:
985 # Docstring in superclass.
986 self._mfa_user = user
987 if user is None:
988 self.state[self.KEY_MFA_USER_ID] = None
989 return
991 self.state[self.KEY_MFA_USER_ID] = user.id
993 # -------------------------------------------------------------------------
994 # Content for forms
995 # -------------------------------------------------------------------------
997 def get_form_values(self) -> Dict:
998 # Docstring in superclass.
999 return {ViewParam.REDIRECT_URL: self.get_redirect_url()}
1001 def get_form_kwargs(self) -> Dict[str, Any]:
1002 # Docstring in superclass.
1003 kwargs = super().get_form_kwargs()
1005 cfg = self.request.config
1006 autocomplete_password = not cfg.disable_password_autocomplete
1007 kwargs["autocomplete_password"] = autocomplete_password
1009 return kwargs
1011 # -------------------------------------------------------------------------
1012 # Form validation, and sequence handling
1013 # -------------------------------------------------------------------------
1015 def form_valid_process_data(
1016 self, form: "Form", appstruct: Dict[str, Any]
1017 ) -> None:
1018 # Docstring in superclass.
1019 if self.step == self.STEP_PASSWORD:
1020 self._form_valid_password(appstruct)
1021 else:
1022 self._form_valid_mfa(appstruct)
1024 super().form_valid_process_data(form, appstruct)
1026 def _form_valid_password(self, appstruct: Dict[str, Any]) -> None:
1027 """
1028 Called when the user has entered a username/password (via a validated
1029 form).
1030 """
1031 username = appstruct.get(ViewParam.USERNAME)
1033 # Is the user locked?
1034 locked_out_until = SecurityAccountLockout.user_locked_out_until(
1035 self.request, username
1036 )
1037 if locked_out_until is not None:
1038 self.fail_locked_out(locked_out_until) # will raise
1040 password = appstruct.get(ViewParam.PASSWORD)
1042 # Is the username/password combination correct?
1043 user = User.get_user_from_username_password(
1044 self.request, username, password
1045 ) # checks password
1047 # Some trade-off between usability and security here.
1048 # For failed attempts, the user has some idea as to what the problem
1049 # is.
1050 if user is None:
1051 # Unsuccessful. Note that the username may/may not be genuine.
1052 SecurityLoginFailure.act_on_login_failure(self.request, username)
1053 # ... may lock the account
1054 # Now, call audit() before session.logout(), as the latter
1055 # will wipe the session IP address:
1056 self.request.camcops_session.logout()
1057 self.fail_not_authorized() # will raise
1059 if not user.may_use_webviewer:
1060 # This means a user who can upload from tablet but who cannot
1061 # log in via the web front end.
1062 self.fail_not_authorized() # will raise
1064 self.mfa_user = user
1065 self._password_next_step()
1066 self._form_valid_success()
1068 def _password_next_step(self) -> None:
1069 """
1070 The user has entered a password correctly; what's the next step?
1071 """
1072 method = self.mfa_user.mfa_method
1073 if MfaMethod.requires_second_step(method):
1074 self.step = self.STEP_MFA
1075 self.handle_authentication_type()
1076 else:
1077 self.finish()
1078 # Guaranteed to be valid; see constructor.
1080 def _form_valid_mfa(self, appstruct: Dict[str, Any]) -> None:
1081 """
1082 Called when the user has entered an MFA code (via a validated form).
1083 """
1084 if not self.otp_is_valid(appstruct):
1085 self.fail_bad_mfa_code() # will raise
1087 self.finish()
1088 self._form_valid_success()
1090 def _form_valid_success(self) -> None:
1091 """
1092 Called when the next step has been determined. One possible outcome is
1093 a successful login.
1094 """
1095 if self.finished():
1096 # Successful login.
1097 self.mfa_user.login(
1098 self.request
1099 ) # will clear login failure record
1100 self.request.camcops_session.login(self.mfa_user)
1101 audit(self.request, "Login", user_id=self.mfa_user.id)
1103 # OK, logged in.
1104 # Redirect to the main menu, or wherever the user was heading.
1105 # HOWEVER, that may lead us to a "change password" or "agree terms"
1106 # page, via the permissions system (Permission.HAPPY or not).
1108 # -------------------------------------------------------------------------
1109 # Next destinations
1110 # -------------------------------------------------------------------------
1112 def get_success_url(self) -> str:
1113 # Docstring in superclass.
1114 if self.finished():
1115 return self.get_redirect_url()
1117 return self.request.route_url(
1118 Routes.LOGIN,
1119 _query={ViewParam.REDIRECT_URL: self.get_redirect_url()},
1120 )
1122 def get_failure_url(self) -> None:
1123 # Docstring in superclass.
1124 return self.request.route_url(
1125 Routes.LOGIN,
1126 _query={ViewParam.REDIRECT_URL: self.get_redirect_url()},
1127 )
1129 def get_redirect_url(self) -> str:
1130 """
1131 We may be logging in after a timeout, in which case we can redirect the
1132 user back to where they were before. Otherwise, they go to the main
1133 page.
1134 """
1135 return self.request.get_redirect_url_param(
1136 ViewParam.REDIRECT_URL, default=self.request.route_url(Routes.HOME)
1137 )
1139 # -------------------------------------------------------------------------
1140 # Ways to fail
1141 # -------------------------------------------------------------------------
1143 def fail_not_authorized(self) -> NoReturn:
1144 """
1145 Fail because the user has not logged in correctly or is not authorized
1146 to log in.
1148 Pretends to the type checker that it returns a response, so callers can
1149 use ``return`` for code safety.
1150 """
1151 _ = self.request.gettext
1152 self.fail(
1153 _("Invalid username/password (or user not authorized).")
1154 ) # will raise
1155 # assert False, "Bug: LoginView.fail_not_authorized() falling through"
1157 def fail_locked_out(self, locked_until: Pendulum) -> NoReturn:
1158 """
1159 Raises a failure because the user is locked out.
1161 Pretends to the type checker that it returns a response, so callers can
1162 use ``return`` for code safety.
1163 """
1164 _ = self.request.gettext
1165 locked_until = format_datetime(
1166 locked_until, DateFormat.LONG_DATETIME_WITH_DAY, _("(never)")
1167 )
1168 message = _(
1169 "Account locked until {} due to multiple login failures. "
1170 "Try again later or contact your administrator."
1171 ).format(locked_until)
1172 self.fail(message) # will raise
1173 # assert False, "Bug: LoginView.fail_locked_out() falling through"
1176@view_config(
1177 route_name=Routes.LOGIN,
1178 permission=NO_PERMISSION_REQUIRED,
1179 http_cache=NEVER_CACHE,
1180)
1181def login_view(req: "CamcopsRequest") -> Response:
1182 """
1183 Login view.
1185 - GET: presents the login screen
1186 - POST/submit: attempts to log in (with optional multi-factor
1187 authentication);
1189 - failure: returns a login failure view or an account lockout view
1190 - success:
1192 - redirects to the redirection view if one was specified;
1193 - redirects to the home view if not.
1194 """
1195 return LoginView(req).dispatch()
1198@view_config(
1199 route_name=Routes.LOGOUT,
1200 permission=Authenticated,
1201 renderer="logged_out.mako",
1202 http_cache=NEVER_CACHE,
1203)
1204def logout(req: "CamcopsRequest") -> Dict[str, Any]:
1205 """
1206 Logs a session out, and returns the "logged out" view.
1207 """
1208 audit(req, "Logout")
1209 ccsession = req.camcops_session
1210 ccsession.logout()
1211 return dict()
1214@view_config(
1215 route_name=Routes.OFFER_TERMS,
1216 permission=Authenticated,
1217 renderer="offer_terms.mako",
1218 http_cache=NEVER_CACHE,
1219)
1220def offer_terms(req: "CamcopsRequest") -> Response:
1221 """
1222 - GET: show terms/conditions and request acknowledgement
1223 - POST/submit: note the user's agreement; redirect to the home view.
1224 """
1225 form = OfferTermsForm(
1226 request=req, agree_button_text=req.wsstring(SS.DISCLAIMER_AGREE)
1227 )
1229 if FormAction.SUBMIT in req.POST:
1230 req.user.agree_terms(req)
1231 return HTTPFound(req.route_url(Routes.HOME)) # redirect
1233 return render_to_response(
1234 "offer_terms.mako",
1235 dict(
1236 title=req.wsstring(SS.DISCLAIMER_TITLE),
1237 subtitle=req.wsstring(SS.DISCLAIMER_SUBTITLE),
1238 content=req.wsstring(SS.DISCLAIMER_CONTENT),
1239 form=form.render(),
1240 head_form_html=get_head_form_html(req, [form]),
1241 ),
1242 request=req,
1243 )
1246@forbidden_view_config(http_cache=NEVER_CACHE)
1247def forbidden(req: "CamcopsRequest") -> Response:
1248 """
1249 Generic place that Pyramid comes when permission is denied for a view.
1251 We will offer one of these:
1253 - Must change password? Redirect to "change own password" view.
1254 - Must agree terms? Redirect to "offer terms" view.
1255 - Otherwise: a generic "forbidden" view.
1256 """
1257 # I was doing this:
1258 if req.has_permission(Authenticated):
1259 user = req.user
1260 assert user, "Bug! Authenticated but no user...!?"
1261 if user.must_change_password:
1262 return HTTPFound(req.route_url(Routes.CHANGE_OWN_PASSWORD))
1263 if user.must_agree_terms:
1264 return HTTPFound(req.route_url(Routes.OFFER_TERMS))
1265 if user.must_set_mfa_method(req):
1266 return HTTPFound(req.route_url(Routes.EDIT_OWN_USER_MFA))
1267 # ... but with "raise HTTPFound" instead.
1268 # BUT there is only one level of exception handling in Pyramid, i.e. you
1269 # can't raise exceptions from exceptions:
1270 # https://github.com/Pylons/pyramid/issues/436
1271 # The simplest way round is to use "return", not "raise".
1273 redirect_url = req.url
1274 # Redirects to login page, with onwards redirection to requested
1275 # destination once logged in:
1276 querydict = {ViewParam.REDIRECT_URL: redirect_url}
1277 return render_to_response(
1278 "forbidden.mako", dict(querydict=querydict), request=req
1279 )
1282# =============================================================================
1283# Changing passwords
1284# =============================================================================
1287class ChangeOwnPasswordView(LoggedInUserMfaMixin, UpdateView):
1288 """
1289 View to change one's own password.
1291 If MFA is enabled, you need to (re-)authenticate via MFA to do so.
1292 Then, you need to supply your own password to change it (regardless).
1293 Sequence is therefore (1) MFA, optionally; (2) change password.
1295 Most documentation in superclass.
1296 """
1298 model_form_dict: Dict[str, "Form"] = {}
1299 STEP_CHANGE_PASSWORD = "change_password"
1301 wizard_forms = {
1302 MfaMixin.STEP_MFA: OtpTokenForm,
1303 STEP_CHANGE_PASSWORD: ChangeOwnPasswordForm,
1304 }
1306 wizard_templates = {
1307 MfaMixin.STEP_MFA: "login_token.mako",
1308 STEP_CHANGE_PASSWORD: "change_own_password.mako",
1309 }
1311 wizard_extra_contexts: Dict[str, Dict[str, Any]] = {
1312 MfaMixin.STEP_MFA: {},
1313 STEP_CHANGE_PASSWORD: {},
1314 }
1316 def get_first_step(self) -> str:
1317 if self.request.user.mfa_method == MfaMethod.NO_MFA:
1318 return self.STEP_CHANGE_PASSWORD
1320 return self.STEP_MFA
1322 def get(self) -> Response:
1323 if self.step == self.STEP_MFA:
1324 self.handle_authentication_type()
1326 _ = self.request.gettext
1328 if self.request.user.must_change_password:
1329 self.request.session.flash(
1330 _("Your password has expired and must be changed."),
1331 queue=FlashQueue.DANGER,
1332 )
1333 return super().get()
1335 def get_object(self) -> User:
1336 return self.request.user
1338 def get_form_kwargs(self) -> Dict[str, Any]:
1339 kwargs = super().get_form_kwargs()
1340 kwargs.update(must_differ=True)
1341 return kwargs
1343 def get_success_url(self) -> str:
1344 if self.finished():
1345 return self.request.route_url(Routes.HOME)
1347 return self.request.route_url(Routes.CHANGE_OWN_PASSWORD)
1349 def get_failure_url(self) -> str:
1350 return self.request.route_url(Routes.HOME)
1352 def form_valid_process_data(
1353 self, form: "Form", appstruct: Dict[str, Any]
1354 ) -> None:
1355 if self.step == self.STEP_MFA:
1356 if not self.otp_is_valid(appstruct):
1357 self.fail_bad_mfa_code() # will raise
1359 super().form_valid_process_data(form, appstruct)
1361 def set_object_properties(self, appstruct: Dict[str, Any]) -> None:
1362 # Superclass method overridden, not called.
1363 if self.step == self.STEP_MFA:
1364 self.step = self.STEP_CHANGE_PASSWORD
1365 elif self.step == self.STEP_CHANGE_PASSWORD:
1366 self.set_password(appstruct)
1367 self.finish()
1368 else:
1369 assert f"ChangeOwnPasswordView: bad step {self.step!r}"
1371 def set_password(self, appstruct: Dict[str, Any]) -> None:
1372 """
1373 Success; change the user's password.
1374 """
1375 user = cast(User, self.object)
1376 # ... form has validated old password, etc.
1377 new_password = appstruct[ViewParam.NEW_PASSWORD]
1378 user.set_password(self.request, new_password)
1380 _ = self.request.gettext
1381 self.request.session.flash(
1382 _(
1383 "You have changed your password. "
1384 "If you store your password in your CamCOPS tablet "
1385 "application, remember to change it there as well."
1386 ),
1387 queue=FlashQueue.SUCCESS,
1388 )
1391@view_config(
1392 route_name=Routes.CHANGE_OWN_PASSWORD,
1393 permission=Authenticated,
1394 http_cache=NEVER_CACHE,
1395)
1396def change_own_password(req: "CamcopsRequest") -> Response:
1397 """
1398 For any user: to change their own password.
1400 - GET: offer "change own password" view
1401 - POST/submit: change the password and display success message.
1402 """
1403 view = ChangeOwnPasswordView(req)
1405 return view.dispatch()
1408class EditUserAuthenticationView(LoggedInUserMfaMixin, UpdateView):
1409 """
1410 View to edit aspects of another user.
1411 """
1413 model_form_dict: Dict[str, "Form"] = {}
1414 object_class = User
1415 pk_param = ViewParam.USER_ID
1416 server_pk_name = "id"
1418 def get(self) -> Response:
1419 if self.step == self.STEP_MFA:
1420 self.handle_authentication_type()
1422 return super().get()
1424 def get_object(self) -> User:
1425 user = cast(User, super().get_object())
1426 assert_may_edit_user(self.request, user)
1428 return user
1430 def get_extra_context(self) -> Dict[str, Any]:
1431 if self.step == self.STEP_MFA:
1432 return super().get_extra_context()
1434 user = cast(User, self.object)
1436 return {"username": user.username}
1438 def form_valid_process_data(
1439 self, form: "Form", appstruct: Dict[str, Any]
1440 ) -> None:
1441 if self.step == self.STEP_MFA:
1442 if not self.otp_is_valid(appstruct):
1443 self.fail_bad_mfa_code() # will raise
1445 super().form_valid_process_data(form, appstruct)
1447 def get_failure_url(self) -> str:
1448 return self.request.route_url(Routes.VIEW_ALL_USERS)
1451class ChangeOtherPasswordView(EditUserAuthenticationView):
1452 """
1453 View to change the password for another user.
1454 """
1456 STEP_CHANGE_PASSWORD = "change_password"
1458 wizard_forms = {
1459 MfaMixin.STEP_MFA: OtpTokenForm,
1460 STEP_CHANGE_PASSWORD: ChangeOtherPasswordForm,
1461 }
1463 wizard_templates = {
1464 MfaMixin.STEP_MFA: "login_token.mako",
1465 STEP_CHANGE_PASSWORD: "change_other_password.mako",
1466 }
1468 def get(self) -> Response:
1469 if self.get_pk_value() == self.request.user_id:
1470 raise HTTPFound(self.request.route_url(Routes.CHANGE_OWN_PASSWORD))
1472 return super().get()
1474 def get_first_step(self) -> str:
1475 if self.request.user.mfa_method != MfaMethod.NO_MFA:
1476 return self.STEP_MFA
1478 return self.STEP_CHANGE_PASSWORD
1480 def set_object_properties(self, appstruct: Dict[str, Any]) -> None:
1481 # Superclass method overridden, not called.
1482 if self.step == self.STEP_CHANGE_PASSWORD:
1483 self.set_password(appstruct)
1484 self.finish()
1485 return
1487 if self.step == self.STEP_MFA:
1488 self.step = self.STEP_CHANGE_PASSWORD
1490 def set_password(self, appstruct: Dict[str, Any]) -> None:
1491 """
1492 Success; change the password for the other user.
1493 """
1494 user = cast(User, self.object)
1495 _ = self.request.gettext
1496 new_password = appstruct[ViewParam.NEW_PASSWORD]
1497 user.set_password(self.request, new_password)
1498 must_change_pw = appstruct.get(ViewParam.MUST_CHANGE_PASSWORD)
1499 if must_change_pw:
1500 user.force_password_change()
1501 self.request.session.flash(
1502 _("Password changed for user '{username}'").format(
1503 username=user.username
1504 ),
1505 queue=FlashQueue.SUCCESS,
1506 )
1508 def get_success_url(self) -> str:
1509 if self.finished():
1510 return self.request.route_url(Routes.VIEW_ALL_USERS)
1512 user = cast(User, self.object)
1514 return self.request.route_url(
1515 Routes.CHANGE_OTHER_PASSWORD, _query={ViewParam.USER_ID: user.id}
1516 )
1519@view_config(
1520 route_name=Routes.CHANGE_OTHER_PASSWORD,
1521 permission=Permission.GROUPADMIN,
1522 http_cache=NEVER_CACHE,
1523)
1524def change_other_password(req: "CamcopsRequest") -> Response:
1525 """
1526 For administrators, to change another's password.
1528 - GET: offer "change another's password" view (except that if you're
1529 changing your own password, return :func:`change_own_password`.
1530 - POST/submit: change the password and display success message.
1531 """
1532 view = ChangeOtherPasswordView(req)
1533 return view.dispatch()
1536class EditOtherUserMfaView(EditUserAuthenticationView):
1537 """
1538 View to edit the MFA method for another user. Only permits disabling of
1539 MFA. (If MFA is mandatory, that will require the other user to set their
1540 MFA method at next logon.)
1541 """
1543 STEP_OTHER_USER_MFA = "other_user_mfa"
1545 wizard_forms = {
1546 MfaMixin.STEP_MFA: OtpTokenForm,
1547 STEP_OTHER_USER_MFA: EditOtherUserMfaForm,
1548 }
1550 wizard_templates = {
1551 MfaMixin.STEP_MFA: "login_token.mako",
1552 STEP_OTHER_USER_MFA: "edit_other_user_mfa.mako",
1553 }
1555 def get(self) -> Response:
1556 if self.get_pk_value() == self.request.user_id:
1557 raise HTTPFound(self.request.route_url(Routes.EDIT_OWN_USER_MFA))
1559 return super().get()
1561 def get_first_step(self) -> str:
1562 if self.request.user.mfa_method != MfaMethod.NO_MFA:
1563 return self.STEP_MFA
1565 return self.STEP_OTHER_USER_MFA
1567 def set_object_properties(self, appstruct: Dict[str, Any]) -> None:
1568 # Superclass method overridden, not called.
1569 if self.step == self.STEP_OTHER_USER_MFA:
1570 self.maybe_disable_mfa(appstruct)
1571 self.finish()
1572 return
1574 if self.step == self.STEP_MFA:
1575 self.step = self.STEP_OTHER_USER_MFA
1577 def maybe_disable_mfa(self, appstruct: Dict[str, Any]) -> None:
1578 """
1579 If our user asked for it, disable MFA for the user being edited.
1580 """
1581 if appstruct.get(ViewParam.DISABLE_MFA):
1582 user = cast(User, self.object)
1583 _ = self.request.gettext
1585 user.mfa_method = MfaMethod.NO_MFA
1586 self.request.session.flash(
1587 _(
1588 "Multi-factor authentication disabled for user "
1589 "'{username}'"
1590 ).format(username=user.username),
1591 queue=FlashQueue.SUCCESS,
1592 )
1594 def get_success_url(self) -> str:
1595 if self.finished():
1596 return self.request.route_url(Routes.VIEW_ALL_USERS)
1598 user = cast(User, self.object)
1600 return self.request.route_url(
1601 Routes.EDIT_OTHER_USER_MFA, _query={ViewParam.USER_ID: user.id}
1602 )
1605@view_config(
1606 route_name=Routes.EDIT_OTHER_USER_MFA,
1607 permission=Permission.GROUPADMIN,
1608 http_cache=NEVER_CACHE,
1609)
1610def edit_other_user_mfa(req: "CamcopsRequest") -> Response:
1611 """
1612 For administrators, to change another users's Multi-factor Authentication.
1613 Currently it is only possible to disable Multi-factor authentication for
1614 a user.
1616 - GET: offer "edit another's MFA" view (except that if you're
1617 changing your own MFA, return :func:`edit_own_user_mfa`.
1618 - POST/submit: edit MFA and display success message.
1619 """
1620 view = EditOtherUserMfaView(req)
1621 return view.dispatch()
1624class EditOwnUserMfaView(LoggedInUserMfaMixin, UpdateView):
1625 """
1626 View to edit your own MFA method.
1628 The inheritance (as of 2021-10-06) illustrates a typical situation:
1630 SPECIMEN VIEW CLASS:
1632 - webview.EditOwnUserMfaView
1634 - webview.LoggedInUserMfaMixin
1636 - webview.MfaMixin
1638 - cc_view_classes.FormWizardMixin -- with typehint for FormMixin --
1639 implements ``state``.
1641 - cc_view_classes.UpdateView
1643 - cc_view_classes.TemplateResponseMixin
1645 - cc_view_classes.BaseUpdateView
1647 - cc_view_classes.ModelFormMixin -- implements ``form_valid()`` -->
1648 ``save_object()`` > ``set_object_properties()``
1650 - cc_view_classes.FormMixin -- implements ``form_valid()``,
1651 ``get_context_data()``, etc.
1653 - cc_view_classes.ContextMixin
1655 - cc_view_classes.SingleObjectMixin -- implements ``get_object()``
1656 etc.
1658 - cc_view_classes.ContextMixin
1660 - cc_view_classes.ProcessFormView -- implements ``get()``, ``post()``
1662 - cc_view_classes.View -- owns ``request``, implements
1663 ``dispatch()`` (which calls ``get()``, ``post()``).
1665 SPECIMEN FORM WITHIN THAT VIEW:
1667 - cc_forms.MfaMethodForm
1669 - cc_forms.InformativeNonceForm
1671 - cc_forms.InformativeForm
1673 - deform.Form
1675 If you subclass A(B, C), then B's superclass methods are called before C's:
1676 https://www.python.org/download/releases/2.3/mro/;
1677 https://makina-corpus.com/blog/metier/2014/python-tutorial-understanding-python-mro-class-search-path;
1678 """ # noqa
1680 STEP_MFA_METHOD = "mfa_method"
1681 STEP_TOTP = MfaMethod.TOTP
1682 STEP_HOTP_EMAIL = MfaMethod.HOTP_EMAIL
1683 STEP_HOTP_SMS = MfaMethod.HOTP_SMS
1684 wizard_first_step = STEP_MFA_METHOD
1686 wizard_forms = {
1687 STEP_MFA_METHOD: MfaMethodForm, # 1. choose your MFA method
1688 STEP_TOTP: MfaTotpForm, # 2a. show TOTP (auth app) QR/alphanumeric code # noqa: E501
1689 STEP_HOTP_EMAIL: MfaHotpEmailForm, # 2b. choose e-mail address
1690 STEP_HOTP_SMS: MfaHotpSmsForm, # 2c. choose phone number for SMS
1691 MfaMixin.STEP_MFA: OtpTokenForm, # 4. request code from user
1692 }
1694 FORM_WITH_TITLE_TEMPLATE = "form_with_title.mako"
1696 wizard_templates = {
1697 STEP_MFA_METHOD: FORM_WITH_TITLE_TEMPLATE,
1698 STEP_TOTP: FORM_WITH_TITLE_TEMPLATE,
1699 STEP_HOTP_EMAIL: FORM_WITH_TITLE_TEMPLATE,
1700 STEP_HOTP_SMS: FORM_WITH_TITLE_TEMPLATE,
1701 MfaMixin.STEP_MFA: "login_token.mako",
1702 }
1704 hotp_steps = (STEP_HOTP_EMAIL, STEP_HOTP_SMS)
1705 secret_key_steps = (STEP_TOTP, STEP_HOTP_EMAIL, STEP_HOTP_SMS)
1707 def get(self) -> Response:
1708 if self.step == self.STEP_MFA:
1709 self.handle_authentication_type()
1711 return super().get()
1713 def get_model_form_dict(self) -> Dict[str, Any]:
1714 model_form_dict = {}
1716 # Dictionary keys here are attribute names of the User object.
1717 # Values are form attributes.
1719 if self.step == self.STEP_MFA_METHOD:
1720 model_form_dict["mfa_method"] = ViewParam.MFA_METHOD
1722 elif self.step == self.STEP_HOTP_EMAIL:
1723 model_form_dict["email"] = ViewParam.EMAIL
1725 elif self.step == self.STEP_HOTP_SMS:
1726 model_form_dict["phone_number"] = ViewParam.PHONE_NUMBER
1728 if self.step in self.secret_key_steps:
1729 model_form_dict["mfa_secret_key"] = ViewParam.MFA_SECRET_KEY
1731 return model_form_dict
1733 def get_object(self) -> User:
1734 return self.request.user
1736 def get_form_values(self) -> Dict[str, Any]:
1737 # Will call get_model_form_dict()
1738 form_values = super().get_form_values()
1740 if self.step in self.secret_key_steps:
1741 # Always create a new secret key. This will be written to the
1742 # user object at the next step, via set_object_properties.
1743 form_values[ViewParam.MFA_SECRET_KEY] = pyotp.random_base32()
1745 return form_values
1747 def get_extra_context(self) -> Dict[str, Any]:
1748 req = self.request
1749 _ = req.gettext
1750 if self.step == self.STEP_MFA:
1751 test_msg = _("Let's test it!") + " "
1752 context = super().get_extra_context()
1753 context[self.KEY_INSTRUCTIONS] = (
1754 test_msg + self.get_mfa_instructions()
1755 )
1756 return context
1758 titles = {
1759 self.STEP_MFA_METHOD: req.icon_text(
1760 icon=Icons.MFA,
1761 text=_("Configure multi-factor authentication settings"),
1762 ),
1763 self.STEP_TOTP: req.icon_text(
1764 icon=Icons.APP_AUTHENTICATOR,
1765 text=_("Configure authentication with app"),
1766 ),
1767 self.STEP_HOTP_EMAIL: req.icon_text(
1768 icon=Icons.EMAIL_SEND,
1769 text=_("Configure authentication by email"),
1770 ),
1771 self.STEP_HOTP_SMS: req.icon_text(
1772 icon=Icons.SMS,
1773 text=_("Configure authentication by text message"),
1774 ),
1775 }
1776 return {MAKO_VAR_TITLE: titles[self.step]}
1778 def get_success_url(self) -> str:
1779 if self.finished():
1780 return self.request.route_url(Routes.HOME)
1782 return self.request.route_url(Routes.EDIT_OWN_USER_MFA)
1784 def get_failure_url(self) -> str:
1785 # We get here because the user, who has already logged in successfully,
1786 # has changed their MFA method. Failure doesn't mean they should be
1787 # logged out instantly -- they may have (for example) misconfigured
1788 # their phone number, and if they are forcibly logged out now, they are
1789 # stuffed and require administrator assistance. Instead, we return them
1790 # to the home screen.
1791 return self.request.route_url(Routes.HOME)
1793 def set_object_properties(self, appstruct: Dict[str, Any]) -> None:
1794 # Called by ModelFormMixin.form_valid_process_data() ->
1795 # ModelFormMixin.save_object().
1797 super().set_object_properties(appstruct)
1799 if self.step == self.STEP_MFA_METHOD:
1800 # We are setting the MFA method, including secret key etc.
1801 user = cast(User, self.object)
1802 user.set_mfa_method(appstruct.get(ViewParam.MFA_METHOD))
1804 elif self.step == self.STEP_MFA:
1805 # Code entered.
1806 if self.otp_is_valid(appstruct):
1807 _ = self.request.gettext
1808 self.request.session.flash(
1809 _("Multi-factor authentication: success!"),
1810 queue=FlashQueue.SUCCESS,
1811 )
1812 # ... and continue as below
1813 else:
1814 return self.fail_bad_mfa_code()
1816 self._next_step(appstruct)
1818 def _next_step(self, appstruct: Dict[str, Any]) -> None:
1819 if self.step == self.STEP_MFA_METHOD:
1820 # The user has just chosen their method.
1821 # 2. Offer them method-specific options
1822 mfa_method = appstruct.get(ViewParam.MFA_METHOD)
1823 if mfa_method == MfaMethod.NO_MFA:
1824 self.finish()
1825 else:
1826 self.step = mfa_method
1828 elif self.step in (
1829 self.STEP_TOTP,
1830 self.STEP_HOTP_EMAIL,
1831 self.STEP_HOTP_SMS,
1832 ):
1833 # Coming from one of the method-specific steps.
1834 # 3. Ask for the authentication code.
1835 self.step = self.STEP_MFA
1837 elif self.step == self.STEP_MFA:
1838 # Authentication code provided. End.
1839 self.finish()
1841 else:
1842 raise AssertionError(
1843 f"EditOwnUserMfaView.next_step(): " f"Bad step {self.step!r}"
1844 )
1847@view_config(
1848 route_name=Routes.EDIT_OWN_USER_MFA,
1849 permission=Authenticated,
1850 http_cache=NEVER_CACHE,
1851)
1852def edit_own_user_mfa(request: "CamcopsRequest") -> Response:
1853 """
1854 Edit your own MFA method.
1855 """
1856 view = EditOwnUserMfaView(request)
1857 return view.dispatch()
1860# =============================================================================
1861# Main menu; simple information things
1862# =============================================================================
1865@view_config(
1866 route_name=Routes.HOME, renderer="main_menu.mako", http_cache=NEVER_CACHE
1867)
1868def main_menu(req: "CamcopsRequest") -> Dict[str, Any]:
1869 """
1870 Main CamCOPS menu view.
1871 """
1872 user = req.user
1873 result = dict(
1874 authorized_as_groupadmin=user.authorized_as_groupadmin,
1875 authorized_as_superuser=user.superuser,
1876 authorized_for_reports=user.authorized_for_reports,
1877 authorized_to_dump=user.authorized_to_dump,
1878 authorized_to_manage_patients=user.authorized_to_manage_patients,
1879 camcops_url=CAMCOPS_URL,
1880 now=format_datetime(req.now, DateFormat.SHORT_DATETIME_SECONDS),
1881 server_version=CAMCOPS_SERVER_VERSION,
1882 )
1883 return result
1886# =============================================================================
1887# Tasks
1888# =============================================================================
1891def edit_filter(
1892 req: "CamcopsRequest", task_filter: TaskFilter, redirect_url: str
1893) -> Response:
1894 """
1895 Edit the task filter for the current user.
1897 Args:
1898 req: the :class:`camcops_server.cc_modules.cc_request.CamcopsRequest`
1899 task_filter: the user's
1900 :class:`camcops_server.cc_modules.cc_taskfilter.TaskFilter`
1901 redirect_url: URL to redirect (back) to upon success
1902 """
1903 if FormAction.SET_FILTERS in req.POST:
1904 form = EditTaskFilterForm(request=req)
1905 try:
1906 controls = list(req.POST.items())
1907 fa = form.validate(controls)
1908 # -----------------------------------------------------------------
1909 # Apply the changes
1910 # -----------------------------------------------------------------
1911 who = fa.get(ViewParam.WHO)
1912 what = fa.get(ViewParam.WHAT)
1913 when = fa.get(ViewParam.WHEN)
1914 admin = fa.get(ViewParam.ADMIN)
1915 task_filter.surname = who.get(ViewParam.SURNAME)
1916 task_filter.forename = who.get(ViewParam.FORENAME)
1917 task_filter.dob = who.get(ViewParam.DOB)
1918 task_filter.sex = who.get(ViewParam.SEX)
1919 task_filter.idnum_criteria = [
1920 IdNumReference(
1921 which_idnum=x[ViewParam.WHICH_IDNUM],
1922 idnum_value=x[ViewParam.IDNUM_VALUE],
1923 )
1924 for x in who.get(ViewParam.ID_REFERENCES)
1925 ]
1926 task_filter.task_types = what.get(ViewParam.TASKS)
1927 task_filter.text_contents = what.get(ViewParam.TEXT_CONTENTS)
1928 task_filter.complete_only = what.get(ViewParam.COMPLETE_ONLY)
1929 task_filter.start_datetime = when.get(ViewParam.START_DATETIME)
1930 task_filter.end_datetime = when.get(ViewParam.END_DATETIME)
1931 task_filter.device_ids = admin.get(ViewParam.DEVICE_IDS)
1932 task_filter.adding_user_ids = admin.get(ViewParam.USER_IDS)
1933 task_filter.group_ids = admin.get(ViewParam.GROUP_IDS)
1935 return HTTPFound(redirect_url)
1936 except ValidationFailure as e:
1937 rendered_form = e.render()
1938 else:
1939 if FormAction.CLEAR_FILTERS in req.POST:
1940 # skip validation
1941 task_filter.clear()
1942 who = {
1943 ViewParam.SURNAME: task_filter.surname,
1944 ViewParam.FORENAME: task_filter.forename,
1945 ViewParam.DOB: task_filter.dob,
1946 ViewParam.SEX: task_filter.sex or "",
1947 ViewParam.ID_REFERENCES: [
1948 {
1949 ViewParam.WHICH_IDNUM: x.which_idnum,
1950 ViewParam.IDNUM_VALUE: x.idnum_value,
1951 }
1952 for x in task_filter.idnum_criteria
1953 ],
1954 }
1955 what = {
1956 ViewParam.TASKS: task_filter.task_types,
1957 ViewParam.TEXT_CONTENTS: task_filter.text_contents,
1958 ViewParam.COMPLETE_ONLY: task_filter.complete_only,
1959 }
1960 when = {
1961 ViewParam.START_DATETIME: task_filter.start_datetime,
1962 ViewParam.END_DATETIME: task_filter.end_datetime,
1963 }
1964 admin = {
1965 ViewParam.DEVICE_IDS: task_filter.device_ids,
1966 ViewParam.USER_IDS: task_filter.adding_user_ids,
1967 ViewParam.GROUP_IDS: task_filter.group_ids,
1968 }
1969 open_who = any(i for i in who.values())
1970 open_what = any(i for i in what.values())
1971 open_when = any(i for i in when.values())
1972 open_admin = any(i for i in admin.values())
1973 fa = {
1974 ViewParam.WHO: who,
1975 ViewParam.WHAT: what,
1976 ViewParam.WHEN: when,
1977 ViewParam.ADMIN: admin,
1978 }
1979 form = EditTaskFilterForm(
1980 request=req,
1981 open_admin=open_admin,
1982 open_what=open_what,
1983 open_when=open_when,
1984 open_who=open_who,
1985 )
1986 rendered_form = form.render(fa)
1988 return render_to_response(
1989 "filter_edit.mako",
1990 dict(
1991 form=rendered_form, head_form_html=get_head_form_html(req, [form])
1992 ),
1993 request=req,
1994 )
1997@view_config(route_name=Routes.SET_FILTERS, http_cache=NEVER_CACHE)
1998def set_filters(req: "CamcopsRequest") -> Response:
1999 """
2000 View to set the task filters for the current user.
2001 """
2002 redirect_url = req.get_redirect_url_param(
2003 ViewParam.REDIRECT_URL, req.route_url(Routes.VIEW_TASKS)
2004 )
2005 task_filter = req.camcops_session.get_task_filter()
2006 return edit_filter(req, task_filter=task_filter, redirect_url=redirect_url)
2009@view_config(
2010 route_name=Routes.VIEW_TASKS,
2011 renderer="view_tasks.mako",
2012 http_cache=NEVER_CACHE,
2013)
2014def view_tasks(req: "CamcopsRequest") -> Dict[str, Any]:
2015 """
2016 Main view displaying tasks and applicable filters.
2017 """
2018 ccsession = req.camcops_session
2019 user = req.user
2020 taskfilter = ccsession.get_task_filter()
2022 # Read from the GET parameters (or in some cases potentially POST but those
2023 # will be re-read).
2024 rows_per_page = req.get_int_param(
2025 ViewParam.ROWS_PER_PAGE,
2026 ccsession.number_to_view or DEFAULT_ROWS_PER_PAGE,
2027 )
2028 page_num = req.get_int_param(ViewParam.PAGE, 1)
2029 via_index = req.get_bool_param(ViewParam.VIA_INDEX, True)
2031 errors = False
2033 # "Number of tasks per page" form
2034 tpp_form = TasksPerPageForm(request=req)
2035 if FormAction.SUBMIT_TASKS_PER_PAGE in req.POST:
2036 try:
2037 controls = list(req.POST.items())
2038 tpp_appstruct = tpp_form.validate(controls)
2039 rows_per_page = tpp_appstruct.get(ViewParam.ROWS_PER_PAGE)
2040 ccsession.number_to_view = rows_per_page
2041 except ValidationFailure:
2042 errors = True
2043 rendered_tpp_form = tpp_form.render()
2044 else:
2045 tpp_appstruct = {ViewParam.ROWS_PER_PAGE: rows_per_page}
2046 rendered_tpp_form = tpp_form.render(tpp_appstruct)
2048 # Refresh tasks. Slightly pointless. Doesn't need validating. The user
2049 # could just press the browser's refresh button, but this improves the UI
2050 # slightly.
2051 refresh_form = RefreshTasksForm(request=req)
2052 rendered_refresh_form = refresh_form.render()
2054 # Get tasks, unless there have been form errors.
2055 # In principle, for some filter settings (single task, no "complete"
2056 # preference...) we could produce an ORM query and use SqlalchemyOrmPage,
2057 # which would apply LIMIT/OFFSET (or equivalent) to the query, and be
2058 # very nippy. In practice, this is probably an unusual setting, so we'll
2059 # simplify things here with a Python list regardless of the settings.
2060 if errors:
2061 collection = []
2062 else:
2063 collection = (
2064 TaskCollection( # SECURITY APPLIED HERE
2065 req=req,
2066 taskfilter=taskfilter,
2067 sort_method_global=TaskSortMethod.CREATION_DATE_DESC,
2068 via_index=via_index,
2069 ).all_tasks_or_indexes_or_query
2070 or []
2071 )
2072 paginator = (
2073 SqlalchemyOrmPage if isinstance(collection, Query) else CamcopsPage
2074 )
2075 page = paginator(
2076 collection,
2077 page=page_num,
2078 items_per_page=rows_per_page,
2079 url_maker=PageUrl(req),
2080 request=req,
2081 )
2082 return dict(
2083 page=page,
2084 head_form_html=get_head_form_html(req, [tpp_form, refresh_form]),
2085 tpp_form=rendered_tpp_form,
2086 refresh_form=rendered_refresh_form,
2087 no_patient_selected_and_user_restricted=(
2088 not user.may_view_all_patients_when_unfiltered
2089 and not taskfilter.any_specific_patient_filtering()
2090 ),
2091 user=user,
2092 )
2095@view_config(route_name=Routes.TASK, http_cache=NEVER_CACHE)
2096def serve_task(req: "CamcopsRequest") -> Response:
2097 """
2098 View that serves an individual task, in a variety of possible formats
2099 (e.g. HTML, PDF, XML).
2100 """
2101 _ = req.gettext
2102 viewtype = req.get_str_param(ViewParam.VIEWTYPE, ViewArg.HTML, lower=True)
2103 tablename = req.get_str_param(
2104 ViewParam.TABLE_NAME, validator=validate_task_tablename
2105 )
2106 server_pk = req.get_int_param(ViewParam.SERVER_PK)
2107 anonymise = req.get_bool_param(ViewParam.ANONYMISE, False)
2109 task = task_factory(req, tablename, server_pk) # SECURITY APPLIED HERE
2111 if task is None:
2112 raise HTTPNotFound( # raise, don't return
2113 f"{_('Task not found or not permitted:')} "
2114 f"tablename={tablename!r}, server_pk={server_pk!r}"
2115 )
2117 task.audit(req, "Viewed " + viewtype.upper())
2119 if viewtype == ViewArg.HTML:
2120 return Response(task.get_html(req=req, anonymise=anonymise))
2121 elif viewtype == ViewArg.PDF:
2122 return PdfResponse(
2123 body=task.get_pdf(req, anonymise=anonymise),
2124 filename=task.suggested_pdf_filename(req, anonymise=anonymise),
2125 )
2126 elif viewtype == ViewArg.PDFHTML: # debugging option; no direct hyperlink
2127 return Response(task.get_pdf_html(req, anonymise=anonymise))
2128 elif viewtype == ViewArg.XML:
2129 options = TaskExportOptions(
2130 xml_include_ancillary=True,
2131 include_blobs=req.get_bool_param(ViewParam.INCLUDE_BLOBS, True),
2132 xml_include_comments=req.get_bool_param(
2133 ViewParam.INCLUDE_COMMENTS, True
2134 ),
2135 xml_include_calculated=req.get_bool_param(
2136 ViewParam.INCLUDE_CALCULATED, True
2137 ),
2138 xml_include_patient=req.get_bool_param(
2139 ViewParam.INCLUDE_PATIENT, True
2140 ),
2141 xml_include_plain_columns=True,
2142 xml_include_snomed=req.get_bool_param(
2143 ViewParam.INCLUDE_SNOMED, True
2144 ),
2145 xml_with_header_comments=True,
2146 )
2147 return XmlResponse(task.get_xml(req=req, options=options))
2148 elif viewtype == ViewArg.FHIRJSON: # debugging option
2149 dummy_recipient = ExportRecipient()
2150 bundle = task.get_fhir_bundle(
2151 req, dummy_recipient, skip_docs_if_other_content=True
2152 )
2153 return JsonResponse(json.dumps(bundle.as_json(), indent=JSON_INDENT))
2154 else:
2155 permissible = (
2156 ViewArg.FHIRJSON,
2157 ViewArg.HTML,
2158 ViewArg.PDF,
2159 ViewArg.PDFHTML,
2160 ViewArg.XML,
2161 )
2162 raise HTTPBadRequest(
2163 f"{_('Bad output type:')} {viewtype!r} "
2164 f"({_('permissible:')} {permissible!r})"
2165 )
2168def view_patient(req: "CamcopsRequest", patient_server_pk: int) -> Response:
2169 """
2170 Primarily for FHIR views: show just a patient's details.
2171 Must check security carefully for this one.
2172 """
2173 user = req.user
2174 patient = Patient.get_patient_by_pk(req.dbsession, patient_server_pk)
2175 if not patient or not patient.user_may_view(user):
2176 _ = req.gettext
2177 raise HTTPBadRequest(_("No such patient or not authorized"))
2178 return render_to_response(
2179 "patient.mako",
2180 dict(patient=patient, viewtype=ViewArg.HTML),
2181 request=req,
2182 )
2185# =============================================================================
2186# Trackers, CTVs
2187# =============================================================================
2190def choose_tracker_or_ctv(
2191 req: "CamcopsRequest", as_ctv: bool
2192) -> Dict[str, Any]:
2193 """
2194 Returns a dictionary for a Mako template to configure a
2195 :class:`camcops_server.cc_modules.cc_tracker.Tracker` or
2196 :class:`camcops_server.cc_modules.cc_tracker.ClinicalTextView`.
2198 Upon success, it redirects to the tracker or CTV view itself, with the
2199 tracker's parameters embedded as URL parameters.
2201 Args:
2202 req: the :class:`camcops_server.cc_modules.cc_request.CamcopsRequest`
2203 as_ctv: CTV, rather than tracker?
2204 """
2206 form = ChooseTrackerForm(req, as_ctv=as_ctv) # , css_class="form-inline")
2208 if FormAction.SUBMIT in req.POST:
2209 try:
2210 controls = list(req.POST.items())
2211 appstruct = form.validate(controls)
2212 keys = [
2213 ViewParam.WHICH_IDNUM,
2214 ViewParam.IDNUM_VALUE,
2215 ViewParam.START_DATETIME,
2216 ViewParam.END_DATETIME,
2217 ViewParam.TASKS,
2218 ViewParam.ALL_TASKS,
2219 ViewParam.VIA_INDEX,
2220 ViewParam.VIEWTYPE,
2221 ]
2222 querydict = {k: appstruct.get(k) for k in keys}
2223 # Not so obvious this can be redirected cleanly via POST.
2224 # It is possible by returning a form that then autosubmits: see
2225 # https://stackoverflow.com/questions/46582/response-redirect-with-post-instead-of-get # noqa
2226 # However, since everything's on this server, we could just return
2227 # an appropriate Response directly. But the request information is
2228 # not sensitive, so we lose nothing by using a GET redirect:
2229 raise HTTPFound(
2230 req.route_url(
2231 Routes.CTV if as_ctv else Routes.TRACKER, _query=querydict
2232 )
2233 )
2234 except ValidationFailure as e:
2235 rendered_form = e.render()
2236 else:
2237 rendered_form = form.render()
2238 return dict(
2239 form=rendered_form, head_form_html=get_head_form_html(req, [form])
2240 )
2243@view_config(
2244 route_name=Routes.CHOOSE_TRACKER,
2245 renderer="choose_tracker.mako",
2246 http_cache=NEVER_CACHE,
2247)
2248def choose_tracker(req: "CamcopsRequest") -> Dict[str, Any]:
2249 """
2250 View to choose/configure a
2251 :class:`camcops_server.cc_modules.cc_tracker.Tracker`.
2252 """
2253 return choose_tracker_or_ctv(req, as_ctv=False)
2256@view_config(
2257 route_name=Routes.CHOOSE_CTV,
2258 renderer="choose_ctv.mako",
2259 http_cache=NEVER_CACHE,
2260)
2261def choose_ctv(req: "CamcopsRequest") -> Dict[str, Any]:
2262 """
2263 View to choose/configure a
2264 :class:`camcops_server.cc_modules.cc_tracker.ClinicalTextView`.
2265 """
2266 return choose_tracker_or_ctv(req, as_ctv=True)
2269def serve_tracker_or_ctv(req: "CamcopsRequest", as_ctv: bool) -> Response:
2270 """
2271 Returns a response to show a
2272 :class:`camcops_server.cc_modules.cc_tracker.Tracker` or
2273 :class:`camcops_server.cc_modules.cc_tracker.ClinicalTextView`, in a
2274 variety of formats (e.g. HTML, PDF, XML).
2276 Args:
2277 req: the :class:`camcops_server.cc_modules.cc_request.CamcopsRequest`
2278 as_ctv: CTV, rather than tracker?
2279 """
2280 as_tracker = not as_ctv
2281 _ = req.gettext
2282 which_idnum = req.get_int_param(ViewParam.WHICH_IDNUM)
2283 idnum_value = req.get_int_param(ViewParam.IDNUM_VALUE)
2284 start_datetime = req.get_datetime_param(ViewParam.START_DATETIME)
2285 end_datetime = req.get_datetime_param(ViewParam.END_DATETIME)
2286 tasks = req.get_str_list_param(
2287 ViewParam.TASKS, validator=validate_task_tablename
2288 )
2289 all_tasks = req.get_bool_param(ViewParam.ALL_TASKS, True)
2290 viewtype = req.get_str_param(ViewParam.VIEWTYPE, ViewArg.HTML)
2291 via_index = req.get_bool_param(ViewParam.VIA_INDEX, True)
2293 if all_tasks:
2294 task_classes = [] # type: List[Type[Task]]
2295 else:
2296 try:
2297 task_classes = task_classes_from_table_names(
2298 tasks, sortmethod=TaskClassSortMethod.SHORTNAME
2299 )
2300 except KeyError:
2301 raise HTTPBadRequest(_("Invalid tasks specified"))
2302 if as_tracker and not all(c.provides_trackers for c in task_classes):
2303 raise HTTPBadRequest(_("Not all tasks specified provide trackers"))
2305 iddefs = [IdNumReference(which_idnum, idnum_value)]
2307 taskfilter = TaskFilter()
2308 taskfilter.task_types = [
2309 tc.__tablename__ for tc in task_classes
2310 ] # a bit silly... # noqa
2311 taskfilter.idnum_criteria = iddefs
2312 taskfilter.start_datetime = start_datetime
2313 taskfilter.end_datetime = end_datetime
2314 taskfilter.complete_only = True # trackers require complete tasks
2315 taskfilter.set_sort_method(TaskClassSortMethod.SHORTNAME)
2316 taskfilter.tasks_offering_trackers_only = as_tracker
2317 taskfilter.tasks_with_patient_only = True
2319 tracker_ctv_class = ClinicalTextView if as_ctv else Tracker
2320 tracker = tracker_ctv_class(
2321 req=req, taskfilter=taskfilter, via_index=via_index
2322 )
2324 if viewtype == ViewArg.HTML:
2325 return Response(tracker.get_html())
2326 elif viewtype == ViewArg.PDF:
2327 return PdfResponse(
2328 body=tracker.get_pdf(), filename=tracker.suggested_pdf_filename()
2329 )
2330 elif viewtype == ViewArg.PDFHTML: # debugging option
2331 return Response(tracker.get_pdf_html())
2332 elif viewtype == ViewArg.XML:
2333 include_comments = req.get_bool_param(ViewParam.INCLUDE_COMMENTS, True)
2334 return XmlResponse(tracker.get_xml(include_comments=include_comments))
2335 else:
2336 permissible = [ViewArg.HTML, ViewArg.PDF, ViewArg.PDFHTML, ViewArg.XML]
2337 raise HTTPBadRequest(
2338 f"{_('Invalid view type:')} {viewtype!r} "
2339 f"({_('permissible:')} {permissible!r})"
2340 )
2343@view_config(route_name=Routes.TRACKER, http_cache=NEVER_CACHE)
2344def serve_tracker(req: "CamcopsRequest") -> Response:
2345 """
2346 View to serve a :class:`camcops_server.cc_modules.cc_tracker.Tracker`; see
2347 :func:`serve_tracker_or_ctv`.
2348 """
2349 return serve_tracker_or_ctv(req, as_ctv=False)
2352@view_config(route_name=Routes.CTV, http_cache=NEVER_CACHE)
2353def serve_ctv(req: "CamcopsRequest") -> Response:
2354 """
2355 View to serve a
2356 :class:`camcops_server.cc_modules.cc_tracker.ClinicalTextView`; see
2357 :func:`serve_tracker_or_ctv`.
2358 """
2359 return serve_tracker_or_ctv(req, as_ctv=True)
2362# =============================================================================
2363# Reports
2364# =============================================================================
2367@view_config(
2368 route_name=Routes.REPORTS_MENU,
2369 renderer="reports_menu.mako",
2370 http_cache=NEVER_CACHE,
2371)
2372def reports_menu(req: "CamcopsRequest") -> Dict[str, Any]:
2373 """
2374 Offer a menu of reports.
2376 Note: Reports are not group-specific.
2377 If you're authorized to see any, you'll see the whole menu.
2378 (The *data* you get will be restricted to the group's you're authorized
2379 to run reports for.)
2380 """
2381 if not req.user.authorized_for_reports:
2382 raise HTTPBadRequest(errormsg_cannot_report(req))
2383 return {}
2386@view_config(route_name=Routes.OFFER_REPORT, http_cache=NEVER_CACHE)
2387def offer_report(req: "CamcopsRequest") -> Response:
2388 """
2389 Offer configuration options for a single report, or (following submission)
2390 redirect to serve that report (with configuration parameters in the URL).
2391 """
2392 if not req.user.authorized_for_reports:
2393 raise HTTPBadRequest(errormsg_cannot_report(req))
2394 report_id = req.get_str_param(ViewParam.REPORT_ID)
2395 report = get_report_instance(report_id)
2396 _ = req.gettext
2397 if not report:
2398 raise HTTPBadRequest(f"{_('No such report ID:')} {report_id!r}")
2399 if report.superuser_only and not req.user.superuser:
2400 raise HTTPBadRequest(
2401 f"{_('Report is restricted to the superuser:')} {report_id!r}"
2402 )
2403 form = report.get_form(req)
2404 if FormAction.SUBMIT in req.POST:
2405 try:
2406 controls = list(req.POST.items())
2407 appstruct = form.validate(controls) # may raise
2408 keys = report.get_http_query_keys()
2409 querydict = {k: appstruct.get(k) for k in keys}
2410 querydict[ViewParam.REPORT_ID] = report_id
2411 querydict[ViewParam.PAGE] = 1
2412 # Send the user to the actual data using GET: this allows page
2413 # navigation whilst maintaining any report-specific parameters.
2414 raise HTTPFound(req.route_url(Routes.REPORT, _query=querydict))
2415 except ValidationFailure as e:
2416 rendered_form = e.render()
2417 else:
2418 rendered_form = form.render({ViewParam.REPORT_ID: report_id})
2419 return render_to_response(
2420 "report_offer.mako",
2421 dict(
2422 report=report,
2423 form=rendered_form,
2424 head_form_html=get_head_form_html(req, [form]),
2425 ),
2426 request=req,
2427 )
2430@view_config(route_name=Routes.REPORT, http_cache=NEVER_CACHE)
2431def serve_report(req: "CamcopsRequest") -> Response:
2432 """
2433 Serve a configured report.
2434 """
2435 if not req.user.authorized_for_reports:
2436 raise HTTPBadRequest(errormsg_cannot_report(req))
2437 report_id = req.get_str_param(ViewParam.REPORT_ID)
2438 report = get_report_instance(report_id)
2439 _ = req.gettext
2440 if not report:
2441 raise HTTPBadRequest(f"{_('No such report ID:')} {report_id!r}")
2442 if report.superuser_only and not req.user.superuser:
2443 raise HTTPBadRequest(
2444 f"{_('Report is restricted to the superuser:')} {report_id!r}"
2445 )
2447 return report.get_response(req)
2450# =============================================================================
2451# Research downloads
2452# =============================================================================
2455@view_config(route_name=Routes.OFFER_BASIC_DUMP, http_cache=NEVER_CACHE)
2456def offer_basic_dump(req: "CamcopsRequest") -> Response:
2457 """
2458 View to configure a basic research dump.
2459 Following submission success, it redirects to a view serving a TSV/ZIP
2460 dump.
2461 """
2462 if not req.user.authorized_to_dump:
2463 raise HTTPBadRequest(errormsg_cannot_dump(req))
2464 form = OfferBasicDumpForm(request=req)
2465 if FormAction.SUBMIT in req.POST:
2466 try:
2467 controls = list(req.POST.items())
2468 appstruct = form.validate(controls)
2469 manual = appstruct.get(ViewParam.MANUAL)
2470 querydict = {
2471 ViewParam.DUMP_METHOD: appstruct.get(ViewParam.DUMP_METHOD),
2472 ViewParam.SORT: appstruct.get(ViewParam.SORT),
2473 ViewParam.GROUP_IDS: manual.get(ViewParam.GROUP_IDS),
2474 ViewParam.TASKS: manual.get(ViewParam.TASKS),
2475 ViewParam.VIEWTYPE: appstruct.get(ViewParam.VIEWTYPE),
2476 ViewParam.DELIVERY_MODE: appstruct.get(
2477 ViewParam.DELIVERY_MODE
2478 ),
2479 ViewParam.INCLUDE_SCHEMA: appstruct.get(
2480 ViewParam.INCLUDE_SCHEMA
2481 ),
2482 ViewParam.SIMPLIFIED: appstruct.get(ViewParam.SIMPLIFIED),
2483 }
2484 # We could return a response, or redirect via GET.
2485 # The request is not sensitive, so let's redirect.
2486 return HTTPFound(
2487 req.route_url(Routes.BASIC_DUMP, _query=querydict)
2488 )
2489 except ValidationFailure as e:
2490 rendered_form = e.render()
2491 else:
2492 rendered_form = form.render()
2493 return render_to_response(
2494 "dump_basic_offer.mako",
2495 dict(
2496 form=rendered_form, head_form_html=get_head_form_html(req, [form])
2497 ),
2498 request=req,
2499 )
2502def get_dump_collection(req: "CamcopsRequest") -> TaskCollection:
2503 """
2504 Returns the collection of tasks being requested for a dump operation.
2505 Raises an error if the request is bad.
2506 """
2507 if not req.user.authorized_to_dump:
2508 raise HTTPBadRequest(errormsg_cannot_dump(req))
2509 # -------------------------------------------------------------------------
2510 # Get parameters
2511 # -------------------------------------------------------------------------
2512 dump_method = req.get_str_param(ViewParam.DUMP_METHOD)
2513 group_ids = req.get_int_list_param(ViewParam.GROUP_IDS)
2514 task_names = req.get_str_list_param(
2515 ViewParam.TASKS, validator=validate_task_tablename
2516 )
2518 # -------------------------------------------------------------------------
2519 # Select tasks
2520 # -------------------------------------------------------------------------
2521 if dump_method == ViewArg.EVERYTHING:
2522 taskfilter = TaskFilter()
2523 elif dump_method == ViewArg.USE_SESSION_FILTER:
2524 taskfilter = req.camcops_session.get_task_filter()
2525 elif dump_method == ViewArg.SPECIFIC_TASKS_GROUPS:
2526 taskfilter = TaskFilter()
2527 taskfilter.task_types = task_names
2528 taskfilter.group_ids = group_ids
2529 else:
2530 _ = req.gettext
2531 raise HTTPBadRequest(
2532 f"{_('Bad parameter:')} "
2533 f"{ViewParam.DUMP_METHOD}={dump_method!r}"
2534 )
2535 return TaskCollection(
2536 req=req,
2537 taskfilter=taskfilter,
2538 as_dump=True,
2539 sort_method_by_class=TaskSortMethod.CREATION_DATE_ASC,
2540 )
2543@view_config(route_name=Routes.BASIC_DUMP, http_cache=NEVER_CACHE)
2544def serve_basic_dump(req: "CamcopsRequest") -> Response:
2545 """
2546 View serving a spreadsheet-style basic research dump.
2547 """
2548 # Get view-specific parameters
2549 simplified = req.get_bool_param(ViewParam.SIMPLIFIED, False)
2550 sort_by_heading = req.get_bool_param(ViewParam.SORT, False)
2551 viewtype = req.get_str_param(ViewParam.VIEWTYPE, ViewArg.XLSX, lower=True)
2552 delivery_mode = req.get_str_param(
2553 ViewParam.DELIVERY_MODE, ViewArg.EMAIL, lower=True
2554 )
2555 include_schema = req.get_bool_param(ViewParam.INCLUDE_SCHEMA, False)
2557 # Get tasks (and perform checks)
2558 collection = get_dump_collection(req)
2559 # Create object that knows how to export
2560 exporter = make_exporter(
2561 req=req,
2562 collection=collection,
2563 options=DownloadOptions(
2564 # Exporting to spreadsheets
2565 user_id=req.user_id,
2566 viewtype=viewtype,
2567 delivery_mode=delivery_mode,
2568 spreadsheet_simplified=simplified,
2569 spreadsheet_sort_by_heading=sort_by_heading,
2570 include_information_schema_columns=include_schema,
2571 include_summary_schema=True,
2572 ),
2573 ) # may raise
2574 # Export, or schedule an email/download
2575 return exporter.immediate_response(req)
2578@view_config(route_name=Routes.OFFER_SQL_DUMP, http_cache=NEVER_CACHE)
2579def offer_sql_dump(req: "CamcopsRequest") -> Response:
2580 """
2581 View to configure a SQL research dump.
2582 Following submission success, it redirects to a view serving the SQL dump.
2583 """
2584 if not req.user.authorized_to_dump:
2585 raise HTTPBadRequest(errormsg_cannot_dump(req))
2586 form = OfferSqlDumpForm(request=req)
2587 if FormAction.SUBMIT in req.POST:
2588 try:
2589 controls = list(req.POST.items())
2590 appstruct = form.validate(controls)
2591 manual = appstruct.get(ViewParam.MANUAL)
2592 querydict = {
2593 ViewParam.DUMP_METHOD: appstruct.get(ViewParam.DUMP_METHOD),
2594 ViewParam.SQLITE_METHOD: appstruct.get(
2595 ViewParam.SQLITE_METHOD
2596 ),
2597 ViewParam.INCLUDE_BLOBS: appstruct.get(
2598 ViewParam.INCLUDE_BLOBS
2599 ),
2600 ViewParam.PATIENT_ID_PER_ROW: appstruct.get(
2601 ViewParam.PATIENT_ID_PER_ROW
2602 ),
2603 ViewParam.GROUP_IDS: manual.get(ViewParam.GROUP_IDS),
2604 ViewParam.TASKS: manual.get(ViewParam.TASKS),
2605 ViewParam.DELIVERY_MODE: appstruct.get(
2606 ViewParam.DELIVERY_MODE
2607 ),
2608 ViewParam.INCLUDE_SCHEMA: appstruct.get(
2609 ViewParam.INCLUDE_SCHEMA
2610 ),
2611 }
2612 # We could return a response, or redirect via GET.
2613 # The request is not sensitive, so let's redirect.
2614 return HTTPFound(req.route_url(Routes.SQL_DUMP, _query=querydict))
2615 except ValidationFailure as e:
2616 rendered_form = e.render()
2617 else:
2618 rendered_form = form.render()
2619 return render_to_response(
2620 "dump_sql_offer.mako",
2621 dict(
2622 form=rendered_form, head_form_html=get_head_form_html(req, [form])
2623 ),
2624 request=req,
2625 )
2628@view_config(route_name=Routes.SQL_DUMP, http_cache=NEVER_CACHE)
2629def sql_dump(req: "CamcopsRequest") -> Response:
2630 """
2631 View serving an SQL dump in the chosen format (e.g. SQLite binary, SQL).
2632 """
2633 # Get view-specific parameters
2634 sqlite_method = req.get_str_param(ViewParam.SQLITE_METHOD)
2635 include_blobs = req.get_bool_param(ViewParam.INCLUDE_BLOBS, False)
2636 patient_id_per_row = req.get_bool_param(ViewParam.PATIENT_ID_PER_ROW, True)
2637 delivery_mode = req.get_str_param(
2638 ViewParam.DELIVERY_MODE, ViewArg.EMAIL, lower=True
2639 )
2640 include_schema = req.get_bool_param(ViewParam.INCLUDE_SCHEMA, False)
2642 # Get tasks (and perform checks)
2643 collection = get_dump_collection(req)
2644 # Create object that knows how to export
2645 exporter = make_exporter(
2646 req=req,
2647 collection=collection,
2648 options=DownloadOptions(
2649 # Exporting to SQL
2650 user_id=req.user_id,
2651 viewtype=sqlite_method,
2652 delivery_mode=delivery_mode,
2653 db_include_blobs=include_blobs,
2654 db_patient_id_per_row=patient_id_per_row,
2655 include_information_schema_columns=include_schema,
2656 include_summary_schema=include_schema, # doesn't do much for SQL export at present # noqa
2657 ),
2658 ) # may raise
2659 # Export, or schedule an email/download
2660 return exporter.immediate_response(req)
2663# noinspection PyUnusedLocal
2664@view_config(
2665 route_name=Routes.DOWNLOAD_AREA,
2666 renderer="download_area.mako",
2667 http_cache=NEVER_CACHE,
2668)
2669def download_area(req: "CamcopsRequest") -> Dict[str, Any]:
2670 """
2671 Shows the user download area.
2672 """
2673 userdir = req.user_download_dir
2674 if userdir:
2675 files = UserDownloadFile.from_directory_scan(
2676 directory=userdir,
2677 permitted_lifespan_min=req.config.user_download_file_lifetime_min,
2678 req=req,
2679 )
2680 else:
2681 files = [] # type: List[UserDownloadFile]
2682 return dict(
2683 files=files,
2684 available=bytes2human(req.user_download_bytes_available),
2685 permitted=bytes2human(req.user_download_bytes_permitted),
2686 used=bytes2human(req.user_download_bytes_used),
2687 lifetime_min=req.config.user_download_file_lifetime_min,
2688 )
2691@view_config(route_name=Routes.DOWNLOAD_FILE, http_cache=NEVER_CACHE)
2692def download_file(req: "CamcopsRequest") -> Response:
2693 """
2694 Downloads a file.
2695 """
2696 _ = req.gettext
2697 filename = req.get_str_param(
2698 ViewParam.FILENAME, "", validator=validate_download_filename
2699 )
2700 # Security comes here: we do NOT permit any path information in the
2701 # filename. It MUST be relative to and within the user download directory.
2702 # We cannot trust the input.
2703 filename = os.path.basename(filename)
2704 udf = UserDownloadFile(directory=req.user_download_dir, filename=filename)
2705 if not udf.exists:
2706 raise HTTPBadRequest(f'{_("No such file:")} {filename}')
2707 try:
2708 return BinaryResponse(
2709 body=udf.contents,
2710 filename=udf.filename,
2711 content_type=MimeType.BINARY,
2712 as_inline=False,
2713 )
2714 except OSError:
2715 raise HTTPBadRequest(f'{_("Error reading file:")} {filename}')
2718@view_config(
2719 route_name=Routes.DELETE_FILE,
2720 request_method=HttpMethod.POST,
2721 http_cache=NEVER_CACHE,
2722)
2723def delete_file(req: "CamcopsRequest") -> Response:
2724 """
2725 Deletes a file.
2726 """
2727 form = UserDownloadDeleteForm(request=req)
2728 controls = list(req.POST.items())
2729 appstruct = form.validate(controls) # CSRF; may raise ValidationError
2730 filename = appstruct.get(ViewParam.FILENAME, "")
2731 # Security comes here: we do NOT permit any path information in the
2732 # filename. It MUST be relative to and within the user download directory.
2733 # We cannot trust the input.
2734 filename = os.path.basename(filename)
2735 udf = UserDownloadFile(directory=req.user_download_dir, filename=filename)
2736 if not udf.exists:
2737 _ = req.gettext
2738 raise HTTPBadRequest(f'{_("No such file:")} {filename}')
2739 udf.delete()
2740 return HTTPFound(req.route_url(Routes.DOWNLOAD_AREA)) # redirect
2743# =============================================================================
2744# View DDL (table definitions)
2745# =============================================================================
2747LEXERMAP = {
2748 SqlaDialectName.MYSQL: pygments.lexers.sql.MySqlLexer,
2749 SqlaDialectName.MSSQL: pygments.lexers.sql.SqlLexer, # generic
2750 SqlaDialectName.ORACLE: pygments.lexers.sql.SqlLexer, # generic
2751 SqlaDialectName.FIREBIRD: pygments.lexers.sql.SqlLexer, # generic
2752 SqlaDialectName.POSTGRES: pygments.lexers.sql.PostgresLexer,
2753 SqlaDialectName.SQLITE: pygments.lexers.sql.SqlLexer, # generic; SqliteConsoleLexer is wrong # noqa
2754 SqlaDialectName.SYBASE: pygments.lexers.sql.SqlLexer, # generic
2755}
2758def format_sql_as_html(
2759 sql: str, dialect: str = SqlaDialectName.MYSQL
2760) -> Tuple[str, str]:
2761 """
2762 Formats SQL as HTML with CSS.
2763 """
2764 lexer = LEXERMAP[dialect]()
2765 # noinspection PyUnresolvedReferences
2766 formatter = pygments.formatters.HtmlFormatter()
2767 html = pygments.highlight(sql, lexer, formatter)
2768 css = formatter.get_style_defs(".highlight")
2769 return html, css
2772@view_config(route_name=Routes.VIEW_DDL, http_cache=NEVER_CACHE)
2773def view_ddl(req: "CamcopsRequest") -> Response:
2774 """
2775 Inspect table definitions (data definition language, DDL) with field
2776 comments.
2778 2021-04-30: restricted to users with "dump" authority -- not because this
2779 is a vulnerability, as the penetration testers suggested, but just to make
2780 it consistent with the menu item for this.
2781 """
2782 if not req.user.authorized_to_dump:
2783 raise HTTPBadRequest(errormsg_cannot_dump(req))
2784 form = ViewDdlForm(request=req)
2785 if FormAction.SUBMIT in req.POST:
2786 try:
2787 controls = list(req.POST.items())
2788 appstruct = form.validate(controls)
2789 dialect = appstruct.get(ViewParam.DIALECT)
2790 ddl = get_all_ddl(dialect_name=dialect)
2791 html, css = format_sql_as_html(ddl, dialect)
2792 return render_to_response(
2793 "introspect_file.mako",
2794 dict(css=css, code_html=html),
2795 request=req,
2796 )
2797 except ValidationFailure as e:
2798 rendered_form = e.render()
2799 else:
2800 rendered_form = form.render()
2801 current_dialect = get_dialect_name(get_engine_from_session(req.dbsession))
2802 sql_dialect_choices = get_sql_dialect_choices(req)
2803 current_dialect_description = {k: v for k, v in sql_dialect_choices}.get(
2804 current_dialect, "?"
2805 )
2806 return render_to_response(
2807 "view_ddl_choose_dialect.mako",
2808 dict(
2809 current_dialect=current_dialect,
2810 current_dialect_description=current_dialect_description,
2811 form=rendered_form,
2812 head_form_html=get_head_form_html(req, [form]),
2813 ),
2814 request=req,
2815 )
2818# =============================================================================
2819# View audit trail
2820# =============================================================================
2823@view_config(
2824 route_name=Routes.OFFER_AUDIT_TRAIL,
2825 permission=Permission.SUPERUSER,
2826 http_cache=NEVER_CACHE,
2827)
2828def offer_audit_trail(req: "CamcopsRequest") -> Response:
2829 """
2830 View to configure how we'll view the audit trail. Once configured, it
2831 redirects to a view that shows the audit trail (with query parameters in
2832 the URL).
2833 """
2834 form = AuditTrailForm(request=req)
2835 if FormAction.SUBMIT in req.POST:
2836 try:
2837 controls = list(req.POST.items())
2838 appstruct = form.validate(controls)
2839 keys = [
2840 ViewParam.ROWS_PER_PAGE,
2841 ViewParam.START_DATETIME,
2842 ViewParam.END_DATETIME,
2843 ViewParam.SOURCE,
2844 ViewParam.REMOTE_IP_ADDR,
2845 ViewParam.USERNAME,
2846 ViewParam.TABLE_NAME,
2847 ViewParam.SERVER_PK,
2848 ViewParam.TRUNCATE,
2849 ]
2850 querydict = {k: appstruct.get(k) for k in keys}
2851 querydict[ViewParam.PAGE] = 1
2852 # Send the user to the actual data using GET:
2853 # (the parameters are NOT sensitive)
2854 raise HTTPFound(
2855 req.route_url(Routes.VIEW_AUDIT_TRAIL, _query=querydict)
2856 )
2857 except ValidationFailure as e:
2858 rendered_form = e.render()
2859 else:
2860 rendered_form = form.render()
2861 return render_to_response(
2862 "audit_trail_choices.mako",
2863 dict(
2864 form=rendered_form, head_form_html=get_head_form_html(req, [form])
2865 ),
2866 request=req,
2867 )
2870AUDIT_TRUNCATE_AT = 100
2873@view_config(
2874 route_name=Routes.VIEW_AUDIT_TRAIL,
2875 permission=Permission.SUPERUSER,
2876 http_cache=NEVER_CACHE,
2877)
2878def view_audit_trail(req: "CamcopsRequest") -> Response:
2879 """
2880 View to serve the audit trail.
2881 """
2882 rows_per_page = req.get_int_param(
2883 ViewParam.ROWS_PER_PAGE, DEFAULT_ROWS_PER_PAGE
2884 )
2885 start_datetime = req.get_datetime_param(ViewParam.START_DATETIME)
2886 end_datetime = req.get_datetime_param(ViewParam.END_DATETIME)
2887 source = req.get_str_param(ViewParam.SOURCE, None)
2888 remote_addr = req.get_str_param(
2889 ViewParam.REMOTE_IP_ADDR, None, validator=validate_ip_address
2890 )
2891 username = req.get_str_param(
2892 ViewParam.USERNAME, None, validator=validate_username
2893 )
2894 table_name = req.get_str_param(
2895 ViewParam.TABLE_NAME, None, validator=validate_task_tablename
2896 )
2897 server_pk = req.get_int_param(ViewParam.SERVER_PK, None)
2898 truncate = req.get_bool_param(ViewParam.TRUNCATE, True)
2899 page_num = req.get_int_param(ViewParam.PAGE, 1)
2901 conditions = [] # type: List[str]
2903 def add_condition(key: str, value: Any) -> None:
2904 conditions.append(f"{key} = {value}")
2906 dbsession = req.dbsession
2907 q = dbsession.query(AuditEntry)
2908 if start_datetime:
2909 q = q.filter(AuditEntry.when_access_utc >= start_datetime)
2910 add_condition(ViewParam.START_DATETIME, start_datetime)
2911 if end_datetime:
2912 q = q.filter(AuditEntry.when_access_utc < end_datetime)
2913 add_condition(ViewParam.END_DATETIME, end_datetime)
2914 if source:
2915 q = q.filter(AuditEntry.source == source)
2916 add_condition(ViewParam.SOURCE, source)
2917 if remote_addr:
2918 q = q.filter(AuditEntry.remote_addr == remote_addr)
2919 add_condition(ViewParam.REMOTE_IP_ADDR, remote_addr)
2920 if username:
2921 # https://stackoverflow.com/questions/8561470/sqlalchemy-filtering-by-relationship-attribute # noqa
2922 q = q.join(User).filter(User.username == username)
2923 add_condition(ViewParam.USERNAME, username)
2924 if table_name:
2925 q = q.filter(AuditEntry.table_name == table_name)
2926 add_condition(ViewParam.TABLE_NAME, table_name)
2927 if server_pk is not None:
2928 q = q.filter(AuditEntry.server_pk == server_pk)
2929 add_condition(ViewParam.SERVER_PK, server_pk)
2931 q = q.order_by(desc(AuditEntry.id))
2933 # audit_entries = dbsession.execute(q).fetchall()
2934 # ... no! That executes to give you row-type results.
2935 # audit_entries = q.all()
2936 # ... yes! But let's paginate, too:
2937 page = SqlalchemyOrmPage(
2938 query=q,
2939 page=page_num,
2940 items_per_page=rows_per_page,
2941 url_maker=PageUrl(req),
2942 request=req,
2943 )
2944 return render_to_response(
2945 "audit_trail_view.mako",
2946 dict(
2947 conditions="; ".join(conditions),
2948 page=page,
2949 truncate=truncate,
2950 truncate_at=AUDIT_TRUNCATE_AT,
2951 ),
2952 request=req,
2953 )
2956# =============================================================================
2957# View export logs
2958# =============================================================================
2959# Overview:
2960# - View exported tasks (ExportedTask) collectively
2961# ... option to filter by recipient_name
2962# ... option to filter by date/etc.
2963# - View exported tasks (ExportedTask) individually
2964# ... hyperlinks to individual views of:
2965# Email (not necessary: ExportedTaskEmail)
2966# ExportRecipient
2967# ExportedTaskFileGroup
2968# ExportedTaskHL7Message
2971@view_config(
2972 route_name=Routes.OFFER_EXPORTED_TASK_LIST,
2973 permission=Permission.SUPERUSER,
2974 http_cache=NEVER_CACHE,
2975)
2976def offer_exported_task_list(req: "CamcopsRequest") -> Response:
2977 """
2978 View to choose how we'll view the exported task log.
2979 """
2980 form = ExportedTaskListForm(request=req)
2981 if FormAction.SUBMIT in req.POST:
2982 try:
2983 controls = list(req.POST.items())
2984 appstruct = form.validate(controls)
2985 keys = [
2986 ViewParam.ROWS_PER_PAGE,
2987 ViewParam.RECIPIENT_NAME,
2988 ViewParam.TABLE_NAME,
2989 ViewParam.SERVER_PK,
2990 ViewParam.ID,
2991 ViewParam.START_DATETIME,
2992 ViewParam.END_DATETIME,
2993 ]
2994 querydict = {k: appstruct.get(k) for k in keys}
2995 querydict[ViewParam.PAGE] = 1
2996 # Send the user to the actual data using GET
2997 # (the parameters are NOT sensitive)
2998 return HTTPFound(
2999 req.route_url(Routes.VIEW_EXPORTED_TASK_LIST, _query=querydict)
3000 )
3001 except ValidationFailure as e:
3002 rendered_form = e.render()
3003 else:
3004 rendered_form = form.render()
3005 return render_to_response(
3006 "exported_task_choose.mako",
3007 dict(
3008 form=rendered_form, head_form_html=get_head_form_html(req, [form])
3009 ),
3010 request=req,
3011 )
3014@view_config(
3015 route_name=Routes.VIEW_EXPORTED_TASK_LIST,
3016 permission=Permission.SUPERUSER,
3017 http_cache=NEVER_CACHE,
3018)
3019def view_exported_task_list(req: "CamcopsRequest") -> Response:
3020 """
3021 View to serve the exported task log.
3022 """
3023 rows_per_page = req.get_int_param(
3024 ViewParam.ROWS_PER_PAGE, DEFAULT_ROWS_PER_PAGE
3025 )
3026 recipient_name = req.get_str_param(
3027 ViewParam.RECIPIENT_NAME,
3028 None,
3029 validator=validate_export_recipient_name,
3030 )
3031 table_name = req.get_str_param(
3032 ViewParam.TABLE_NAME, None, validator=validate_task_tablename
3033 )
3034 server_pk = req.get_int_param(ViewParam.SERVER_PK, None)
3035 et_id = req.get_int_param(ViewParam.ID, None)
3036 start_datetime = req.get_datetime_param(ViewParam.START_DATETIME)
3037 end_datetime = req.get_datetime_param(ViewParam.END_DATETIME)
3038 page_num = req.get_int_param(ViewParam.PAGE, 1)
3040 conditions = [] # type: List[str]
3042 def add_condition(key: str, value: Any) -> None:
3043 conditions.append(f"{key} = {value}")
3045 dbsession = req.dbsession
3046 q = dbsession.query(ExportedTask)
3048 if recipient_name:
3049 q = q.join(ExportRecipient).filter(
3050 ExportRecipient.recipient_name == recipient_name
3051 )
3052 add_condition(ViewParam.RECIPIENT_NAME, recipient_name)
3053 if table_name:
3054 q = q.filter(ExportedTask.basetable == table_name)
3055 add_condition(ViewParam.TABLE_NAME, table_name)
3056 if server_pk is not None:
3057 q = q.filter(ExportedTask.task_server_pk == server_pk)
3058 add_condition(ViewParam.SERVER_PK, server_pk)
3059 if et_id is not None:
3060 q = q.filter(ExportedTask.id == et_id)
3061 add_condition(ViewParam.ID, et_id)
3062 if start_datetime:
3063 q = q.filter(ExportedTask.start_at_utc >= start_datetime)
3064 add_condition(ViewParam.START_DATETIME, start_datetime)
3065 if end_datetime:
3066 q = q.filter(ExportedTask.start_at_utc < end_datetime)
3067 add_condition(ViewParam.END_DATETIME, end_datetime)
3069 q = q.order_by(desc(ExportedTask.id))
3071 page = SqlalchemyOrmPage(
3072 query=q,
3073 page=page_num,
3074 items_per_page=rows_per_page,
3075 url_maker=PageUrl(req),
3076 request=req,
3077 )
3078 return render_to_response(
3079 "exported_task_list.mako",
3080 dict(conditions="; ".join(conditions), page=page),
3081 request=req,
3082 )
3085# =============================================================================
3086# View helpers for ORM objects
3087# =============================================================================
3090def _view_generic_object_by_id(
3091 req: "CamcopsRequest",
3092 cls: Type,
3093 instance_name_for_mako: str,
3094 mako_template: str,
3095) -> Response:
3096 """
3097 Boilerplate code to view an individual SQLAlchemy ORM object. The object
3098 must have an integer ``id`` field as its primary key, and the ID value must
3099 be present in the ``ViewParam.ID`` field of the request.
3101 Args:
3102 req: a :class:`camcops_server.cc_modules.cc_request.CamcopsRequest`
3103 cls: the SQLAlchemy ORM class
3104 instance_name_for_mako: what will the object be called when it's
3105 mako_template: Mako template filename
3107 Returns:
3108 :class:`pyramid.response.Response`
3109 """
3110 item_id = req.get_int_param(ViewParam.ID, None)
3111 dbsession = req.dbsession
3112 # noinspection PyUnresolvedReferences
3113 obj = dbsession.query(cls).filter(cls.id == item_id).first()
3114 if obj is None:
3115 _ = req.gettext
3116 raise HTTPBadRequest(
3117 f"{_('Bad ID for object type')} " f"{cls.__name__}: {item_id}"
3118 )
3119 d = {instance_name_for_mako: obj}
3120 return render_to_response(mako_template, d, request=req)
3123# =============================================================================
3124# Specialized views for ORM objects
3125# =============================================================================
3128@view_config(
3129 route_name=Routes.VIEW_EMAIL,
3130 permission=Permission.SUPERUSER,
3131 http_cache=NEVER_CACHE,
3132)
3133def view_email(req: "CamcopsRequest") -> Response:
3134 """
3135 View on an individual :class:`camcops_server.cc_modules.cc_email.Email`.
3136 """
3137 return _view_generic_object_by_id(
3138 req=req,
3139 cls=Email,
3140 instance_name_for_mako="email",
3141 mako_template="view_email.mako",
3142 )
3145@view_config(
3146 route_name=Routes.VIEW_EXPORT_RECIPIENT,
3147 permission=Permission.SUPERUSER,
3148 http_cache=NEVER_CACHE,
3149)
3150def view_export_recipient(req: "CamcopsRequest") -> Response:
3151 """
3152 View on an individual
3153 :class:`camcops_server.cc_modules.cc_exportmodels.ExportedTask`.
3154 """
3155 return _view_generic_object_by_id(
3156 req=req,
3157 cls=ExportRecipient,
3158 instance_name_for_mako="recipient",
3159 mako_template="export_recipient.mako",
3160 )
3163@view_config(
3164 route_name=Routes.VIEW_EXPORTED_TASK,
3165 permission=Permission.SUPERUSER,
3166 http_cache=NEVER_CACHE,
3167)
3168def view_exported_task(req: "CamcopsRequest") -> Response:
3169 """
3170 View on an individual
3171 :class:`camcops_server.cc_modules.cc_exportmodels.ExportedTask`.
3172 """
3173 return _view_generic_object_by_id(
3174 req=req,
3175 cls=ExportedTask,
3176 instance_name_for_mako="et",
3177 mako_template="exported_task.mako",
3178 )
3181@view_config(
3182 route_name=Routes.VIEW_EXPORTED_TASK_EMAIL,
3183 permission=Permission.SUPERUSER,
3184 http_cache=NEVER_CACHE,
3185)
3186def view_exported_task_email(req: "CamcopsRequest") -> Response:
3187 """
3188 View on an individual
3189 :class:`camcops_server.cc_modules.cc_exportmodels.ExportedTaskEmail`.
3190 """
3191 return _view_generic_object_by_id(
3192 req=req,
3193 cls=ExportedTaskEmail,
3194 instance_name_for_mako="ete",
3195 mako_template="exported_task_email.mako",
3196 )
3199@view_config(
3200 route_name=Routes.VIEW_EXPORTED_TASK_FILE_GROUP,
3201 permission=Permission.SUPERUSER,
3202 http_cache=NEVER_CACHE,
3203)
3204def view_exported_task_file_group(req: "CamcopsRequest") -> Response:
3205 """
3206 View on an individual
3207 :class:`camcops_server.cc_modules.cc_exportmodels.ExportedTaskFileGroup`.
3208 """
3209 return _view_generic_object_by_id(
3210 req=req,
3211 cls=ExportedTaskFileGroup,
3212 instance_name_for_mako="fg",
3213 mako_template="exported_task_file_group.mako",
3214 )
3217@view_config(
3218 route_name=Routes.VIEW_EXPORTED_TASK_HL7_MESSAGE,
3219 permission=Permission.SUPERUSER,
3220 http_cache=NEVER_CACHE,
3221)
3222def view_exported_task_hl7_message(req: "CamcopsRequest") -> Response:
3223 """
3224 View on an individual
3225 :class:`camcops_server.cc_modules.cc_exportmodels.ExportedTaskHL7Message`.
3226 """
3227 return _view_generic_object_by_id(
3228 req=req,
3229 cls=ExportedTaskHL7Message,
3230 instance_name_for_mako="msg",
3231 mako_template="exported_task_hl7_message.mako",
3232 )
3235@view_config(
3236 route_name=Routes.VIEW_EXPORTED_TASK_REDCAP,
3237 permission=Permission.SUPERUSER,
3238 http_cache=NEVER_CACHE,
3239)
3240def view_exported_task_redcap(req: "CamcopsRequest") -> Response:
3241 """
3242 View on an individual
3243 :class:`camcops_server.cc_modules.cc_exportmodels.ExportedTaskRedcap`.
3244 """
3245 return _view_generic_object_by_id(
3246 req=req,
3247 cls=ExportedTaskRedcap,
3248 instance_name_for_mako="etr",
3249 mako_template="exported_task_redcap.mako",
3250 )
3253@view_config(
3254 route_name=Routes.VIEW_EXPORTED_TASK_FHIR,
3255 permission=Permission.SUPERUSER,
3256 http_cache=NEVER_CACHE,
3257)
3258def view_exported_task_fhir(req: "CamcopsRequest") -> Response:
3259 """
3260 View on an individual
3261 :class:`camcops_server.cc_modules.cc_exportmodels.ExportedTaskRedcap`.
3262 """
3263 return _view_generic_object_by_id(
3264 req=req,
3265 cls=ExportedTaskFhir,
3266 instance_name_for_mako="etf",
3267 mako_template="exported_task_fhir.mako",
3268 )
3271@view_config(
3272 route_name=Routes.VIEW_EXPORTED_TASK_FHIR_ENTRY,
3273 permission=Permission.SUPERUSER,
3274 http_cache=NEVER_CACHE,
3275)
3276def view_exported_task_fhir_entry(req: "CamcopsRequest") -> Response:
3277 """
3278 View on an individual
3279 :class:`camcops_server.cc_modules.cc_exportmodels.ExportedTaskRedcap`.
3280 """
3281 return _view_generic_object_by_id(
3282 req=req,
3283 cls=ExportedTaskFhirEntry,
3284 instance_name_for_mako="etfe",
3285 mako_template="exported_task_fhir_entry.mako",
3286 )
3289# =============================================================================
3290# User/server info views
3291# =============================================================================
3294@view_config(
3295 route_name=Routes.VIEW_OWN_USER_INFO,
3296 renderer="view_own_user_info.mako",
3297 http_cache=NEVER_CACHE,
3298)
3299def view_own_user_info(req: "CamcopsRequest") -> Dict[str, Any]:
3300 """
3301 View to provide information about your own user.
3302 """
3303 groups_page = CamcopsPage(
3304 req.user.groups, url_maker=PageUrl(req), request=req
3305 )
3306 return dict(
3307 user=req.user,
3308 groups_page=groups_page,
3309 valid_which_idnums=req.valid_which_idnums,
3310 )
3313@view_config(
3314 route_name=Routes.VIEW_SERVER_INFO,
3315 renderer="view_server_info.mako",
3316 http_cache=NEVER_CACHE,
3317)
3318def view_server_info(req: "CamcopsRequest") -> Dict[str, Any]:
3319 """
3320 View to show the server's ID policies, etc.
3321 """
3322 _ = req.gettext
3323 now = req.now
3324 recent_activity = OrderedDict(
3325 [
3326 (
3327 _("Last 1 minute"),
3328 CamcopsSession.n_sessions_active_since(
3329 req, now.subtract(minutes=1)
3330 ),
3331 ),
3332 (
3333 _("Last 5 minutes"),
3334 CamcopsSession.n_sessions_active_since(
3335 req, now.subtract(minutes=5)
3336 ),
3337 ),
3338 (
3339 _("Last 10 minutes"),
3340 CamcopsSession.n_sessions_active_since(
3341 req, now.subtract(minutes=10)
3342 ),
3343 ),
3344 (
3345 _("Last 1 hour"),
3346 CamcopsSession.n_sessions_active_since(
3347 req, now.subtract(hours=1)
3348 ),
3349 ),
3350 ]
3351 )
3352 return dict(
3353 idnum_definitions=req.idnum_definitions,
3354 string_families=req.extrastring_families(),
3355 recent_activity=recent_activity,
3356 session_timeout_minutes=req.config.session_timeout_minutes,
3357 restricted_tasks=req.config.restricted_tasks,
3358 )
3361# =============================================================================
3362# User management
3363# =============================================================================
3366def get_user_from_request_user_id_or_raise(req: "CamcopsRequest") -> User:
3367 """
3368 Returns the :class:`camcops_server.cc_modules.cc_user.User` represented by
3369 the request's ``ViewParam.USER_ID`` parameter, or raise
3370 :exc:`HTTPBadRequest`.
3371 """
3372 user_id = req.get_int_param(ViewParam.USER_ID)
3373 user = User.get_user_by_id(req.dbsession, user_id)
3374 if not user:
3375 _ = req.gettext
3376 raise HTTPBadRequest(f"{_('No such user ID:')} {user_id!r}")
3377 return user
3380def query_users_that_i_manage(req: "CamcopsRequest") -> Query:
3381 me = req.user
3382 return me.managed_users()
3385@view_config(
3386 route_name=Routes.VIEW_ALL_USERS,
3387 permission=Permission.GROUPADMIN,
3388 renderer="users_view.mako",
3389 http_cache=NEVER_CACHE,
3390)
3391def view_all_users(req: "CamcopsRequest") -> Dict[str, Any]:
3392 """
3393 View all users that the current user administers. The view has hyperlinks
3394 to edit those users too.
3395 """
3396 include_auto_generated = req.get_bool_param(
3397 ViewParam.INCLUDE_AUTO_GENERATED, False
3398 )
3399 rows_per_page = req.get_int_param(
3400 ViewParam.ROWS_PER_PAGE, DEFAULT_ROWS_PER_PAGE
3401 )
3402 page_num = req.get_int_param(ViewParam.PAGE, 1)
3403 q = query_users_that_i_manage(req)
3404 if not include_auto_generated:
3405 q = q.filter(User.auto_generated == False) # noqa: E712
3406 page = SqlalchemyOrmPage(
3407 query=q,
3408 page=page_num,
3409 items_per_page=rows_per_page,
3410 url_maker=PageUrl(req),
3411 request=req,
3412 )
3414 form = UserFilterForm(request=req)
3415 appstruct = {ViewParam.INCLUDE_AUTO_GENERATED: include_auto_generated}
3416 rendered_form = form.render(appstruct)
3418 return dict(
3419 page=page,
3420 head_form_html=get_head_form_html(req, [form]),
3421 form=rendered_form,
3422 )
3425@view_config(
3426 route_name=Routes.VIEW_USER_EMAIL_ADDRESSES,
3427 permission=Permission.GROUPADMIN,
3428 renderer="view_user_email_addresses.mako",
3429 http_cache=NEVER_CACHE,
3430)
3431def view_user_email_addresses(req: "CamcopsRequest") -> Dict[str, Any]:
3432 """
3433 View e-mail addresses of all users that the requesting user is authorized
3434 to manage.
3435 """
3436 q = query_users_that_i_manage(req).filter(
3437 User.auto_generated == False # noqa: E712
3438 )
3439 return dict(query=q)
3442def assert_may_edit_user(req: "CamcopsRequest", user: User) -> None:
3443 """
3444 Checks that the requesting user (``req.user``) is allowed to edit the other
3445 user (``user``). Raises :exc:`HTTPBadRequest` otherwise.
3446 """
3447 may_edit, why_not = req.user.may_edit_user(req, user)
3448 if not may_edit:
3449 raise HTTPBadRequest(why_not)
3452def assert_may_administer_group(req: "CamcopsRequest", group_id: int) -> None:
3453 """
3454 Checks that the requesting user (``req.user``) is allowed to adminster the
3455 specified group (specified by ``group_id``). Raises :exc:`HTTPBadRequest`
3456 otherwise.
3457 """
3458 if not req.user.may_administer_group(group_id):
3459 _ = req.gettext
3460 raise HTTPBadRequest(_("You may not administer this group"))
3463@view_config(
3464 route_name=Routes.VIEW_USER,
3465 permission=Permission.GROUPADMIN,
3466 renderer="view_other_user_info.mako",
3467 http_cache=NEVER_CACHE,
3468)
3469def view_user(req: "CamcopsRequest") -> Dict[str, Any]:
3470 """
3471 View to show details of another user, for administrators.
3472 """
3473 user = get_user_from_request_user_id_or_raise(req)
3474 assert_may_edit_user(req, user)
3475 return dict(user=user)
3476 # Groupadmins may see some information regarding groups that aren't theirs
3477 # here, but can't alter it.
3480class EditUserBaseView(UpdateView):
3481 """
3482 Django-style view to edit a user and their groups
3483 """
3485 model_form_dict = {
3486 "username": ViewParam.USERNAME,
3487 "fullname": ViewParam.FULLNAME,
3488 "email": ViewParam.EMAIL,
3489 "must_change_password": ViewParam.MUST_CHANGE_PASSWORD,
3490 "language": ViewParam.LANGUAGE,
3491 }
3492 object_class = User
3493 pk_param = ViewParam.USER_ID
3494 server_pk_name = "id"
3495 template_name = "user_edit.mako"
3497 def get_success_url(self) -> str:
3498 return self.request.route_url(Routes.VIEW_ALL_USERS)
3500 def get_object(self) -> Any:
3501 user = cast(User, super().get_object())
3503 assert_may_edit_user(self.request, user)
3505 return user
3507 def set_object_properties(self, appstruct: Dict[str, Any]) -> None:
3508 user = cast(User, self.object)
3509 _ = self.request.gettext
3511 new_user_name = appstruct.get(ViewParam.USERNAME)
3512 existing_user = User.get_user_by_name(
3513 self.request.dbsession, new_user_name
3514 )
3515 if existing_user and existing_user.id != user.id:
3516 # noinspection PyUnresolvedReferences
3517 cant_rename_user = _("Can't rename user")
3518 conflicts = _("that conflicts with an existing user with ID")
3519 raise HTTPBadRequest(
3520 f"{cant_rename_user} {user.username!r} (#{user.id!r}) → "
3521 f"{new_user_name!r}; {conflicts} {existing_user.id!r}"
3522 )
3524 email = appstruct.get(ViewParam.EMAIL)
3525 if not email and user.mfa_method == MfaMethod.HOTP_EMAIL:
3526 message = _(
3527 "This user's email address is used for multi-factor "
3528 "authentication. If you want to remove their email "
3529 "address, you must first disable multi-factor "
3530 "authentication"
3531 )
3533 raise HTTPBadRequest(message)
3535 super().set_object_properties(appstruct)
3537 # Groups that we might change memberships for:
3538 all_fluid_groups = self.request.user.ids_of_groups_user_is_admin_for
3539 # All groups that the user is currently in:
3540 user_group_ids = user.group_ids
3541 # Group membership we won't touch:
3542 user_frozen_group_ids = list(
3543 set(user_group_ids) - set(all_fluid_groups)
3544 )
3545 group_ids = appstruct.get(ViewParam.GROUP_IDS)
3546 # Add back in the groups we're not going to alter:
3547 final_group_ids = list(set(group_ids) | set(user_frozen_group_ids))
3548 user.set_group_ids(final_group_ids)
3549 # Also, if the user was uploading to a group that they are now no
3550 # longer a member of, we need to fix that
3551 if user.upload_group_id not in final_group_ids:
3552 user.upload_group_id = None
3554 def get_form_values(self) -> Dict[str, Any]:
3555 # will populate with model_form_dict
3556 form_values = super().get_form_values()
3558 user = cast(User, self.object)
3560 # Superusers can do everything, of course.
3561 # Groupadmins can change group memberships only for groups they control
3562 # (here: "fluid"). That means that there may be a subset of group
3563 # memberships for this user that they will neither see nor be able to
3564 # alter (here: "frozen"). They can also edit only a restricted set of
3565 # permissions.
3567 # Groups that we might change memberships for:
3568 all_fluid_groups = self.request.user.ids_of_groups_user_is_admin_for
3569 # All groups that the user is currently in:
3570 user_group_ids = user.group_ids
3571 # Group memberships we might alter:
3572 user_fluid_group_ids = list(
3573 set(user_group_ids) & set(all_fluid_groups)
3574 )
3575 form_values.update(
3576 {
3577 ViewParam.USER_ID: user.id,
3578 ViewParam.GROUP_IDS: user_fluid_group_ids,
3579 }
3580 )
3582 return form_values
3585class EditUserGroupAdminView(EditUserBaseView):
3586 """
3587 For group administrators to edit a user.
3588 """
3590 form_class = EditUserGroupAdminForm
3593class EditUserSuperUserView(EditUserBaseView):
3594 """
3595 For superusers to edit a user.
3596 """
3598 form_class = EditUserFullForm
3600 def get_model_form_dict(self) -> Dict[str, Any]:
3601 model_form_dict = super().get_model_form_dict()
3602 model_form_dict["superuser"] = ViewParam.SUPERUSER
3604 return model_form_dict
3607@view_config(
3608 route_name=Routes.EDIT_USER,
3609 permission=Permission.GROUPADMIN,
3610 http_cache=NEVER_CACHE,
3611)
3612def edit_user(req: "CamcopsRequest") -> Response:
3613 """
3614 View to edit a user (for administrators).
3615 """
3616 view: EditUserBaseView
3618 if req.user.superuser:
3619 view = EditUserSuperUserView(req)
3620 else:
3621 view = EditUserGroupAdminView(req)
3623 return view.dispatch()
3626class EditUserGroupMembershipBaseView(UpdateView):
3627 """
3628 Django-style view to edit a user's group membership permissions.
3629 """
3631 model_form_dict = {
3632 "may_upload": ViewParam.MAY_UPLOAD,
3633 "may_register_devices": ViewParam.MAY_REGISTER_DEVICES,
3634 "may_use_webviewer": ViewParam.MAY_USE_WEBVIEWER,
3635 "view_all_patients_when_unfiltered": ViewParam.VIEW_ALL_PATIENTS_WHEN_UNFILTERED, # noqa: E501
3636 "may_dump_data": ViewParam.MAY_DUMP_DATA,
3637 "may_run_reports": ViewParam.MAY_RUN_REPORTS,
3638 "may_add_notes": ViewParam.MAY_ADD_NOTES,
3639 "may_manage_patients": ViewParam.MAY_MANAGE_PATIENTS,
3640 "may_email_patients": ViewParam.MAY_EMAIL_PATIENTS,
3641 }
3643 object_class = UserGroupMembership
3644 pk_param = ViewParam.USER_GROUP_MEMBERSHIP_ID
3645 server_pk_name = "id"
3646 template_name = "user_edit_group_membership.mako"
3648 def get_success_url(self) -> str:
3649 return self.request.route_url(Routes.VIEW_ALL_USERS)
3651 def get_object(self) -> Any:
3652 # noinspection PyUnresolvedReferences
3653 ugm = cast(UserGroupMembership, super().get_object())
3654 user = ugm.user
3655 assert_may_edit_user(self.request, user)
3656 assert_may_administer_group(self.request, ugm.group_id)
3658 return ugm
3661class EditUserGroupMembershipSuperUserView(EditUserGroupMembershipBaseView):
3662 """
3663 For superusers to edit a user's group memberships.
3664 """
3666 form_class = EditUserGroupPermissionsFullForm
3668 def get_model_form_dict(self) -> Dict[str, str]:
3669 model_form_dict = super().get_model_form_dict()
3670 model_form_dict["groupadmin"] = ViewParam.GROUPADMIN
3672 return model_form_dict
3675class EditUserGroupMembershipGroupAdminView(EditUserGroupMembershipBaseView):
3676 """
3677 For group administrators to edit a user's group memberships.
3678 """
3680 form_class = EditUserGroupMembershipGroupAdminForm
3683@view_config(
3684 route_name=Routes.EDIT_USER_GROUP_MEMBERSHIP,
3685 permission=Permission.GROUPADMIN,
3686 http_cache=NEVER_CACHE,
3687)
3688def edit_user_group_membership(req: "CamcopsRequest") -> Response:
3689 """
3690 View to edit the group memberships of a user (for administrators).
3691 """
3692 if req.user.superuser:
3693 view = EditUserGroupMembershipSuperUserView(req)
3694 else:
3695 view = EditUserGroupMembershipGroupAdminView(req)
3697 return view.dispatch()
3700def set_user_upload_group(
3701 req: "CamcopsRequest", user: User, by_another: bool
3702) -> Response:
3703 """
3704 Provides a view to choose which group a user uploads into.
3706 TRUSTS ITS CALLER that this is permitted.
3708 Args:
3709 req: the :class:`camcops_server.cc_modules.cc_request.CamcopsRequest`
3710 user: the :class:`camcops_server.cc_modules.cc_user.User` to edit
3711 by_another: is the current user a superuser/group administrator, i.e.
3712 another user? Determines the screen we return to afterwards.
3713 """
3714 route_back = Routes.VIEW_ALL_USERS if by_another else Routes.HOME
3715 if FormAction.CANCEL in req.POST:
3716 return HTTPFound(req.route_url(route_back))
3717 form = SetUserUploadGroupForm(request=req, user=user)
3718 # ... need to show the groups permitted to THAT user, not OUR user
3719 if FormAction.SUBMIT in req.POST:
3720 try:
3721 controls = list(req.POST.items())
3722 appstruct = form.validate(controls)
3723 # -----------------------------------------------------------------
3724 # Apply the changes
3725 # -----------------------------------------------------------------
3726 user.upload_group_id = appstruct.get(ViewParam.UPLOAD_GROUP_ID)
3727 return HTTPFound(req.route_url(route_back))
3728 except ValidationFailure as e:
3729 rendered_form = e.render()
3730 else:
3731 appstruct = {
3732 ViewParam.USER_ID: user.id,
3733 ViewParam.UPLOAD_GROUP_ID: user.upload_group_id,
3734 }
3735 rendered_form = form.render(appstruct)
3736 return render_to_response(
3737 "set_user_upload_group.mako",
3738 dict(
3739 user=user,
3740 form=rendered_form,
3741 head_form_html=get_head_form_html(req, [form]),
3742 ),
3743 request=req,
3744 )
3747@view_config(
3748 route_name=Routes.SET_OWN_USER_UPLOAD_GROUP, http_cache=NEVER_CACHE
3749)
3750def set_own_user_upload_group(req: "CamcopsRequest") -> Response:
3751 """
3752 View to set the upload group for your own user.
3753 """
3754 return set_user_upload_group(req, req.user, False)
3757@view_config(
3758 route_name=Routes.SET_OTHER_USER_UPLOAD_GROUP,
3759 permission=Permission.GROUPADMIN,
3760 http_cache=NEVER_CACHE,
3761)
3762def set_other_user_upload_group(req: "CamcopsRequest") -> Response:
3763 """
3764 View to set the upload group for another user.
3765 """
3766 user = get_user_from_request_user_id_or_raise(req)
3767 if user.id != req.user.id:
3768 assert_may_edit_user(req, user)
3769 # ... but always OK to edit this for your own user; no such check required
3770 return set_user_upload_group(req, user, True)
3773# noinspection PyTypeChecker
3774@view_config(
3775 route_name=Routes.UNLOCK_USER,
3776 permission=Permission.GROUPADMIN,
3777 http_cache=NEVER_CACHE,
3778)
3779def unlock_user(req: "CamcopsRequest") -> Response:
3780 """
3781 View to unlock a locked user account.
3782 """
3783 user = get_user_from_request_user_id_or_raise(req)
3784 assert_may_edit_user(req, user)
3785 user.enable(req)
3786 _ = req.gettext
3788 req.session.flash(
3789 _("User {username} enabled").format(username=user.username),
3790 queue=FlashQueue.SUCCESS,
3791 )
3792 raise HTTPFound(req.route_url(Routes.VIEW_ALL_USERS))
3795@view_config(
3796 route_name=Routes.ADD_USER,
3797 permission=Permission.GROUPADMIN,
3798 renderer="user_add.mako",
3799 http_cache=NEVER_CACHE,
3800)
3801def add_user(req: "CamcopsRequest") -> Dict[str, Any]:
3802 """
3803 View to add a user.
3804 """
3805 route_back = Routes.VIEW_ALL_USERS
3806 if FormAction.CANCEL in req.POST:
3807 raise HTTPFound(req.route_url(route_back))
3808 if req.user.superuser:
3809 form = AddUserSuperuserForm(request=req)
3810 else:
3811 form = AddUserGroupadminForm(request=req)
3812 dbsession = req.dbsession
3813 if FormAction.SUBMIT in req.POST:
3814 try:
3815 controls = list(req.POST.items())
3816 appstruct = form.validate(controls)
3817 # -----------------------------------------------------------------
3818 # Add the user
3819 # -----------------------------------------------------------------
3820 user = User()
3821 user.username = appstruct.get(ViewParam.USERNAME)
3822 user.set_password(req, appstruct.get(ViewParam.NEW_PASSWORD))
3823 user.must_change_password = appstruct.get(
3824 ViewParam.MUST_CHANGE_PASSWORD
3825 )
3826 # We don't ask for language initially; that can be configured
3827 # later. But is is a reasonable guess that it should be the same
3828 # language as used by the person creating the new user.
3829 user.language = req.language
3830 if User.get_user_by_name(dbsession, user.username):
3831 raise HTTPBadRequest(
3832 f"User with username {user.username!r} already exists!"
3833 )
3834 dbsession.add(user)
3835 group_ids = appstruct.get(ViewParam.GROUP_IDS)
3836 for gid in group_ids:
3837 # noinspection PyUnresolvedReferences
3838 user.user_group_memberships.append(
3839 UserGroupMembership(user_id=user.id, group_id=gid)
3840 )
3841 raise HTTPFound(req.route_url(route_back))
3842 except ValidationFailure as e:
3843 rendered_form = e.render()
3844 else:
3845 rendered_form = form.render()
3846 return dict(
3847 form=rendered_form, head_form_html=get_head_form_html(req, [form])
3848 )
3851def any_records_use_user(req: "CamcopsRequest", user: User) -> bool:
3852 """
3853 Do any records in the database refer to the specified user?
3855 (Used when we're thinking about deleting a user; would it leave broken
3856 references? If so, we will prevent deletion; see :func:`delete_user`.)
3857 """
3858 dbsession = req.dbsession
3859 user_id = user.id
3860 # Device?
3861 q = CountStarSpecializedQuery(Device, session=dbsession).filter(
3862 or_(
3863 Device.registered_by_user_id == user_id,
3864 Device.uploading_user_id == user_id,
3865 )
3866 )
3867 if q.count_star() > 0:
3868 return True
3869 # SpecialNote?
3870 q = CountStarSpecializedQuery(SpecialNote, session=dbsession).filter(
3871 SpecialNote.user_id == user_id
3872 )
3873 if q.count_star() > 0:
3874 return True
3875 # Audit trail?
3876 q = CountStarSpecializedQuery(AuditEntry, session=dbsession).filter(
3877 AuditEntry.user_id == user_id
3878 )
3879 if q.count_star() > 0:
3880 return True
3881 # Uploaded records?
3882 for cls in gen_orm_classes_from_base(
3883 GenericTabletRecordMixin
3884 ): # type: Type[GenericTabletRecordMixin] # noqa
3885 # noinspection PyProtectedMember
3886 q = CountStarSpecializedQuery(cls, session=dbsession).filter(
3887 or_(
3888 cls._adding_user_id == user_id,
3889 cls._removing_user_id == user_id,
3890 cls._preserving_user_id == user_id,
3891 cls._manually_erasing_user_id == user_id,
3892 )
3893 )
3894 if q.count_star() > 0:
3895 return True
3896 # No; all clean.
3897 return False
3900@view_config(
3901 route_name=Routes.DELETE_USER,
3902 permission=Permission.GROUPADMIN,
3903 renderer="user_delete.mako",
3904 http_cache=NEVER_CACHE,
3905)
3906def delete_user(req: "CamcopsRequest") -> Dict[str, Any]:
3907 """
3908 View to delete a user (and make it hard work).
3909 """
3910 if FormAction.CANCEL in req.POST:
3911 raise HTTPFound(req.route_url(Routes.VIEW_ALL_USERS))
3912 user = get_user_from_request_user_id_or_raise(req)
3913 assert_may_edit_user(req, user)
3914 form = DeleteUserForm(request=req)
3915 rendered_form = ""
3916 error = ""
3917 _ = req.gettext
3918 if user.id == req.user.id:
3919 error = _("Can't delete your own user!")
3920 elif user.may_use_webviewer or user.may_upload:
3921 error = _(
3922 "Unable to delete user: user still has webviewer login "
3923 "and/or tablet upload permission"
3924 )
3925 elif user.superuser and (not req.user.superuser):
3926 error = _(
3927 "Unable to delete user: " "they are a superuser and you are not"
3928 )
3929 elif (not req.user.superuser) and bool(
3930 set(user.group_ids) - set(req.user.ids_of_groups_user_is_admin_for)
3931 ):
3932 error = _(
3933 "Unable to delete user: "
3934 "user belongs to groups that you do not administer"
3935 )
3936 else:
3937 if any_records_use_user(req, user):
3938 error = _(
3939 "Unable to delete user; records (or audit trails) refer to "
3940 "that user. Disable login and upload permissions instead."
3941 )
3942 else:
3943 if FormAction.DELETE in req.POST:
3944 try:
3945 controls = list(req.POST.items())
3946 appstruct = form.validate(controls)
3947 assert appstruct.get(ViewParam.USER_ID) == user.id
3948 # ---------------------------------------------------------
3949 # Delete the user and associated objects
3950 # ---------------------------------------------------------
3951 # (*) Sessions belonging to this user
3952 # ... done by modifying its ForeignKey to use "ondelete"
3953 # (*) user_group_table mapping
3954 # https://docs.sqlalchemy.org/en/latest/orm/basic_relationships.html#relationships-many-to-many-deletion # noqa
3955 # Simplest way:
3956 user.groups = [] # will delete the mapping entries
3957 # (*) User itself
3958 req.dbsession.delete(user)
3959 # Done
3960 raise HTTPFound(req.route_url(Routes.VIEW_ALL_USERS))
3961 except ValidationFailure as e:
3962 rendered_form = e.render()
3963 else:
3964 appstruct = {ViewParam.USER_ID: user.id}
3965 rendered_form = form.render(appstruct)
3967 return dict(
3968 user=user,
3969 error=error,
3970 form=rendered_form,
3971 head_form_html=get_head_form_html(req, [form]),
3972 )
3975# =============================================================================
3976# Group management
3977# =============================================================================
3980@view_config(
3981 route_name=Routes.VIEW_GROUPS,
3982 permission=Permission.SUPERUSER,
3983 renderer="groups_view.mako",
3984 http_cache=NEVER_CACHE,
3985)
3986def view_groups(req: "CamcopsRequest") -> Dict[str, Any]:
3987 """
3988 View to show all groups (with hyperlinks to edit them).
3989 Superusers only.
3990 """
3991 rows_per_page = req.get_int_param(
3992 ViewParam.ROWS_PER_PAGE, DEFAULT_ROWS_PER_PAGE
3993 )
3994 page_num = req.get_int_param(ViewParam.PAGE, 1)
3995 dbsession = req.dbsession
3996 groups = (
3997 dbsession.query(Group).order_by(Group.name).all()
3998 ) # type: List[Group] # noqa
3999 page = CamcopsPage(
4000 collection=groups,
4001 page=page_num,
4002 items_per_page=rows_per_page,
4003 url_maker=PageUrl(req),
4004 request=req,
4005 )
4007 valid_which_idnums = req.valid_which_idnums
4009 return dict(groups_page=page, valid_which_idnums=valid_which_idnums)
4012def get_group_from_request_group_id_or_raise(req: "CamcopsRequest") -> Group:
4013 """
4014 Returns the :class:`camcops_server.cc_modules.cc_group.Group` represented
4015 by the request's ``ViewParam.GROUP_ID`` parameter, or raise
4016 :exc:`HTTPBadRequest`.
4017 """
4018 group_id = req.get_int_param(ViewParam.GROUP_ID)
4019 group = None
4020 if group_id is not None:
4021 dbsession = req.dbsession
4022 group = dbsession.query(Group).filter(Group.id == group_id).first()
4023 if not group:
4024 _ = req.gettext
4025 raise HTTPBadRequest(f"{_('No such group ID:')} {group_id!r}")
4026 return group
4029class EditGroupView(UpdateView):
4030 """
4031 Django-style view to edit a CamCOPS group.
4032 """
4034 form_class = EditGroupForm
4035 model_form_dict = {
4036 "name": ViewParam.NAME,
4037 "description": ViewParam.DESCRIPTION,
4038 "upload_policy": ViewParam.UPLOAD_POLICY,
4039 "finalize_policy": ViewParam.FINALIZE_POLICY,
4040 }
4041 object_class = Group
4042 pk_param = ViewParam.GROUP_ID
4043 server_pk_name = "id"
4044 template_name = "group_edit.mako"
4046 def get_form_kwargs(self) -> Dict[str, Any]:
4047 kwargs = super().get_form_kwargs()
4049 group = cast(Group, self.object)
4050 kwargs.update(group=group)
4052 return kwargs
4054 def get_form_values(self) -> Dict:
4055 # will populate with model_form_dict
4056 form_values = super().get_form_values()
4058 group = cast(Group, self.object)
4060 other_group_ids = list(group.ids_of_other_groups_group_may_see())
4061 other_groups = Group.get_groups_from_id_list(
4062 self.request.dbsession, other_group_ids
4063 )
4064 other_groups.sort(key=lambda g: g.name)
4066 form_values.update(
4067 {
4068 ViewParam.IP_USE: group.ip_use,
4069 ViewParam.GROUP_ID: group.id,
4070 ViewParam.GROUP_IDS: [g.id for g in other_groups],
4071 }
4072 )
4074 return form_values
4076 def get_success_url(self) -> str:
4077 return self.request.route_url(Routes.VIEW_GROUPS)
4079 def save_object(self, appstruct: Dict[str, Any]) -> None:
4080 super().save_object(appstruct)
4082 group = cast(Group, self.object)
4084 # Group cross-references
4085 group_ids = appstruct.get(ViewParam.GROUP_IDS)
4086 # The form validation will prevent our own group from being in here
4087 other_groups = Group.get_groups_from_id_list(
4088 self.request.dbsession, group_ids
4089 )
4090 group.can_see_other_groups = other_groups
4092 ip_use = appstruct.get(ViewParam.IP_USE)
4093 if group.ip_use is not None:
4094 ip_use.id = group.ip_use.id
4096 group.ip_use = ip_use
4099@view_config(
4100 route_name=Routes.EDIT_GROUP,
4101 permission=Permission.SUPERUSER,
4102 http_cache=NEVER_CACHE,
4103)
4104def edit_group(req: "CamcopsRequest") -> Response:
4105 """
4106 View to edit a group. Superusers only.
4107 """
4108 return EditGroupView(req).dispatch()
4111@view_config(
4112 route_name=Routes.ADD_GROUP,
4113 permission=Permission.SUPERUSER,
4114 renderer="group_add.mako",
4115 http_cache=NEVER_CACHE,
4116)
4117def add_group(req: "CamcopsRequest") -> Dict[str, Any]:
4118 """
4119 View to add a group. Superusers only.
4120 """
4121 route_back = Routes.VIEW_GROUPS
4122 if FormAction.CANCEL in req.POST:
4123 raise HTTPFound(req.route_url(route_back))
4124 form = AddGroupForm(request=req)
4125 dbsession = req.dbsession
4126 if FormAction.SUBMIT in req.POST:
4127 try:
4128 controls = list(req.POST.items())
4129 appstruct = form.validate(controls)
4130 # -----------------------------------------------------------------
4131 # Add the group
4132 # -----------------------------------------------------------------
4133 group = Group()
4134 group.name = appstruct.get(ViewParam.NAME)
4135 dbsession.add(group)
4136 raise HTTPFound(req.route_url(route_back))
4137 except ValidationFailure as e:
4138 rendered_form = e.render()
4139 else:
4140 rendered_form = form.render()
4141 return dict(
4142 form=rendered_form, head_form_html=get_head_form_html(req, [form])
4143 )
4146def any_records_use_group(req: "CamcopsRequest", group: Group) -> bool:
4147 """
4148 Do any records in the database refer to the specified group?
4150 (Used when we're thinking about deleting a group; would it leave broken
4151 references? If so, we will prevent deletion; see :func:`delete_group`.)
4152 """
4153 dbsession = req.dbsession
4154 group_id = group.id
4155 # Our own or users filtering on us?
4156 # ... doesn't matter; see TaskFilter; stored as a CSV list so not part of
4157 # database integrity checks.
4158 # Uploaded records?
4159 for cls in gen_orm_classes_from_base(
4160 GenericTabletRecordMixin
4161 ): # type: Type[GenericTabletRecordMixin] # noqa
4162 # noinspection PyProtectedMember
4163 q = CountStarSpecializedQuery(cls, session=dbsession).filter(
4164 cls._group_id == group_id
4165 )
4166 if q.count_star() > 0:
4167 return True
4168 # No; all clean.
4169 return False
4172@view_config(
4173 route_name=Routes.DELETE_GROUP,
4174 permission=Permission.SUPERUSER,
4175 renderer="group_delete.mako",
4176 http_cache=NEVER_CACHE,
4177)
4178def delete_group(req: "CamcopsRequest") -> Dict[str, Any]:
4179 """
4180 View to delete a group. Superusers only.
4181 """
4182 route_back = Routes.VIEW_GROUPS
4183 if FormAction.CANCEL in req.POST:
4184 raise HTTPFound(req.route_url(route_back))
4185 group = get_group_from_request_group_id_or_raise(req)
4186 form = DeleteGroupForm(request=req)
4187 rendered_form = ""
4188 error = ""
4189 _ = req.gettext
4190 if group.users:
4191 error = _("Unable to delete group; there are users who are members!")
4192 else:
4193 if any_records_use_group(req, group):
4194 error = _("Unable to delete group; records refer to it.")
4195 else:
4196 if FormAction.DELETE in req.POST:
4197 try:
4198 controls = list(req.POST.items())
4199 appstruct = form.validate(controls)
4200 assert appstruct.get(ViewParam.GROUP_ID) == group.id
4201 # ---------------------------------------------------------
4202 # Delete the group
4203 # ---------------------------------------------------------
4204 req.dbsession.delete(group)
4205 raise HTTPFound(req.route_url(route_back))
4206 except ValidationFailure as e:
4207 rendered_form = e.render()
4208 else:
4209 appstruct = {ViewParam.GROUP_ID: group.id}
4210 rendered_form = form.render(appstruct)
4211 return dict(
4212 group=group,
4213 error=error,
4214 form=rendered_form,
4215 head_form_html=get_head_form_html(req, [form]),
4216 )
4219# =============================================================================
4220# Edit server settings
4221# =============================================================================
4224@view_config(
4225 route_name=Routes.EDIT_SERVER_SETTINGS,
4226 permission=Permission.SUPERUSER,
4227 renderer="server_settings_edit.mako",
4228 http_cache=NEVER_CACHE,
4229)
4230def edit_server_settings(req: "CamcopsRequest") -> Dict[str, Any]:
4231 """
4232 View to edit server settings (like the database title).
4233 """
4234 if FormAction.CANCEL in req.POST:
4235 raise HTTPFound(req.route_url(Routes.HOME))
4236 form = EditServerSettingsForm(request=req)
4237 if FormAction.SUBMIT in req.POST:
4238 try:
4239 controls = list(req.POST.items())
4240 appstruct = form.validate(controls)
4241 title = appstruct.get(ViewParam.DATABASE_TITLE)
4242 # -----------------------------------------------------------------
4243 # Apply changes
4244 # -----------------------------------------------------------------
4245 req.set_database_title(title)
4246 raise HTTPFound(req.route_url(Routes.HOME))
4247 except ValidationFailure as e:
4248 rendered_form = e.render()
4249 else:
4250 title = req.database_title
4251 appstruct = {ViewParam.DATABASE_TITLE: title}
4252 rendered_form = form.render(appstruct)
4253 return dict(
4254 form=rendered_form, head_form_html=get_head_form_html(req, [form])
4255 )
4258@view_config(
4259 route_name=Routes.VIEW_ID_DEFINITIONS,
4260 permission=Permission.SUPERUSER,
4261 renderer="id_definitions_view.mako",
4262 http_cache=NEVER_CACHE,
4263)
4264def view_id_definitions(req: "CamcopsRequest") -> Dict[str, Any]:
4265 """
4266 View to show all ID number definitions (with hyperlinks to edit them).
4267 Superusers only.
4268 """
4269 return dict(idnum_definitions=req.idnum_definitions)
4272def get_iddef_from_request_which_idnum_or_raise(
4273 req: "CamcopsRequest",
4274) -> IdNumDefinition:
4275 """
4276 Returns the :class:`camcops_server.cc_modules.cc_idnumdef.IdNumDefinition`
4277 represented by the request's ``ViewParam.WHICH_IDNUM`` parameter, or raise
4278 :exc:`HTTPBadRequest`.
4279 """
4280 which_idnum = req.get_int_param(ViewParam.WHICH_IDNUM)
4281 iddef = (
4282 req.dbsession.query(IdNumDefinition)
4283 .filter(IdNumDefinition.which_idnum == which_idnum)
4284 .first()
4285 )
4286 if not iddef:
4287 _ = req.gettext
4288 raise HTTPBadRequest(f"{_('No such ID definition:')} {which_idnum!r}")
4289 return iddef
4292@view_config(
4293 route_name=Routes.EDIT_ID_DEFINITION,
4294 permission=Permission.SUPERUSER,
4295 renderer="id_definition_edit.mako",
4296 http_cache=NEVER_CACHE,
4297)
4298def edit_id_definition(req: "CamcopsRequest") -> Dict[str, Any]:
4299 """
4300 View to edit an ID number definition. Superusers only.
4301 """
4302 route_back = Routes.VIEW_ID_DEFINITIONS
4303 if FormAction.CANCEL in req.POST:
4304 raise HTTPFound(req.route_url(route_back))
4305 iddef = get_iddef_from_request_which_idnum_or_raise(req)
4306 form = EditIdDefinitionForm(request=req)
4307 if FormAction.SUBMIT in req.POST:
4308 try:
4309 controls = list(req.POST.items())
4310 appstruct = form.validate(controls)
4311 # -----------------------------------------------------------------
4312 # Alter the ID definition
4313 # -----------------------------------------------------------------
4314 iddef.description = appstruct.get(ViewParam.DESCRIPTION)
4315 iddef.short_description = appstruct.get(
4316 ViewParam.SHORT_DESCRIPTION
4317 )
4318 iddef.validation_method = appstruct.get(
4319 ViewParam.VALIDATION_METHOD
4320 )
4321 iddef.hl7_id_type = appstruct.get(ViewParam.HL7_ID_TYPE)
4322 iddef.hl7_assigning_authority = appstruct.get(
4323 ViewParam.HL7_ASSIGNING_AUTHORITY
4324 )
4325 iddef.fhir_id_system = appstruct.get(ViewParam.FHIR_ID_SYSTEM)
4326 # REMOVED # clear_idnum_definition_cache() # SPECIAL
4327 raise HTTPFound(req.route_url(route_back))
4328 except ValidationFailure as e:
4329 rendered_form = e.render()
4330 else:
4331 appstruct = {
4332 ViewParam.WHICH_IDNUM: iddef.which_idnum,
4333 ViewParam.DESCRIPTION: iddef.description or "",
4334 ViewParam.SHORT_DESCRIPTION: iddef.short_description or "",
4335 ViewParam.VALIDATION_METHOD: iddef.validation_method or "",
4336 ViewParam.HL7_ID_TYPE: iddef.hl7_id_type or "",
4337 ViewParam.HL7_ASSIGNING_AUTHORITY: iddef.hl7_assigning_authority
4338 or "", # noqa
4339 ViewParam.FHIR_ID_SYSTEM: iddef.fhir_id_system or "",
4340 }
4341 rendered_form = form.render(appstruct)
4342 return dict(
4343 iddef=iddef,
4344 form=rendered_form,
4345 head_form_html=get_head_form_html(req, [form]),
4346 )
4349@view_config(
4350 route_name=Routes.ADD_ID_DEFINITION,
4351 permission=Permission.SUPERUSER,
4352 renderer="id_definition_add.mako",
4353 http_cache=NEVER_CACHE,
4354)
4355def add_id_definition(req: "CamcopsRequest") -> Dict[str, Any]:
4356 """
4357 View to add an ID number definition. Superusers only.
4358 """
4359 route_back = Routes.VIEW_ID_DEFINITIONS
4360 if FormAction.CANCEL in req.POST:
4361 raise HTTPFound(req.route_url(route_back))
4362 form = AddIdDefinitionForm(request=req)
4363 dbsession = req.dbsession
4364 if FormAction.SUBMIT in req.POST:
4365 try:
4366 controls = list(req.POST.items())
4367 appstruct = form.validate(controls)
4368 iddef = IdNumDefinition(
4369 which_idnum=appstruct.get(ViewParam.WHICH_IDNUM),
4370 description=appstruct.get(ViewParam.DESCRIPTION),
4371 short_description=appstruct.get(ViewParam.SHORT_DESCRIPTION),
4372 # we skip hl7_id_type at this stage
4373 # we skip hl7_assigning_authority at this stage
4374 validation_method=appstruct.get(ViewParam.VALIDATION_METHOD),
4375 )
4376 # -----------------------------------------------------------------
4377 # Add ID definition
4378 # -----------------------------------------------------------------
4379 dbsession.add(iddef)
4380 # REMOVED # clear_idnum_definition_cache() # SPECIAL
4381 raise HTTPFound(req.route_url(route_back))
4382 except ValidationFailure as e:
4383 rendered_form = e.render()
4384 else:
4385 rendered_form = form.render()
4386 return dict(
4387 form=rendered_form, head_form_html=get_head_form_html(req, [form])
4388 )
4391def any_records_use_iddef(
4392 req: "CamcopsRequest", iddef: IdNumDefinition
4393) -> bool:
4394 """
4395 Do any records in the database refer to the specified ID number definition?
4397 (Used when we're thinking about deleting one; would it leave broken
4398 references? If so, we will prevent deletion; see
4399 :func:`delete_id_definition`.)
4400 """
4401 # Helpfully, these are only referred to permanently from one place:
4402 q = CountStarSpecializedQuery(PatientIdNum, session=req.dbsession).filter(
4403 PatientIdNum.which_idnum == iddef.which_idnum
4404 )
4405 if q.count_star() > 0:
4406 return True
4407 # No; all clean.
4408 return False
4411@view_config(
4412 route_name=Routes.DELETE_ID_DEFINITION,
4413 permission=Permission.SUPERUSER,
4414 renderer="id_definition_delete.mako",
4415 http_cache=NEVER_CACHE,
4416)
4417def delete_id_definition(req: "CamcopsRequest") -> Dict[str, Any]:
4418 """
4419 View to delete an ID number definition. Superusers only.
4420 """
4421 route_back = Routes.VIEW_ID_DEFINITIONS
4422 if FormAction.CANCEL in req.POST:
4423 raise HTTPFound(req.route_url(route_back))
4424 iddef = get_iddef_from_request_which_idnum_or_raise(req)
4425 form = DeleteIdDefinitionForm(request=req)
4426 rendered_form = ""
4427 error = ""
4428 if any_records_use_iddef(req, iddef):
4429 _ = req.gettext
4430 error = _("Unable to delete ID definition; records refer to it.")
4431 else:
4432 if FormAction.DELETE in req.POST:
4433 try:
4434 controls = list(req.POST.items())
4435 appstruct = form.validate(controls)
4436 assert (
4437 appstruct.get(ViewParam.WHICH_IDNUM) == iddef.which_idnum
4438 )
4439 # -------------------------------------------------------------
4440 # Delete ID definition
4441 # -------------------------------------------------------------
4442 req.dbsession.delete(iddef)
4443 # REMOVED # clear_idnum_definition_cache() # SPECIAL
4444 raise HTTPFound(req.route_url(route_back))
4445 except ValidationFailure as e:
4446 rendered_form = e.render()
4447 else:
4448 appstruct = {ViewParam.WHICH_IDNUM: iddef.which_idnum}
4449 rendered_form = form.render(appstruct)
4450 return dict(
4451 iddef=iddef,
4452 error=error,
4453 form=rendered_form,
4454 head_form_html=get_head_form_html(req, [form]),
4455 )
4458# =============================================================================
4459# Altering data. Some of the more complex logic is here.
4460# =============================================================================
4463@view_config(
4464 route_name=Routes.ADD_SPECIAL_NOTE,
4465 renderer="special_note_add.mako",
4466 http_cache=NEVER_CACHE,
4467)
4468def add_special_note(req: "CamcopsRequest") -> Dict[str, Any]:
4469 """
4470 View to add a special note to a task (after confirmation).
4472 (Note that users can't add special notes to patients -- those get added
4473 automatically when a patient is edited. So the context here is always of a
4474 task.)
4475 """
4476 table_name = req.get_str_param(
4477 ViewParam.TABLE_NAME, validator=validate_task_tablename
4478 )
4479 server_pk = req.get_int_param(ViewParam.SERVER_PK, None)
4480 url_back = req.route_url(
4481 Routes.TASK,
4482 _query={
4483 ViewParam.TABLE_NAME: table_name,
4484 ViewParam.SERVER_PK: server_pk,
4485 ViewParam.VIEWTYPE: ViewArg.HTML,
4486 },
4487 )
4488 if FormAction.CANCEL in req.POST:
4489 raise HTTPFound(url_back)
4490 task = task_factory(req, table_name, server_pk)
4491 _ = req.gettext
4492 if task is None:
4493 raise HTTPBadRequest(
4494 f"{_('No such task:')} {table_name}, PK={server_pk}"
4495 )
4496 user = req.user
4497 if not user.authorized_to_add_special_note(task.group_id):
4498 raise HTTPBadRequest(
4499 _("Not authorized to add special notes for this task's group")
4500 )
4501 form = AddSpecialNoteForm(request=req)
4502 if FormAction.SUBMIT in req.POST:
4503 try:
4504 controls = list(req.POST.items())
4505 appstruct = form.validate(controls)
4506 note = appstruct.get(ViewParam.NOTE)
4507 # -----------------------------------------------------------------
4508 # Apply special note
4509 # -----------------------------------------------------------------
4510 task.apply_special_note(req, note)
4511 raise HTTPFound(url_back)
4512 except ValidationFailure as e:
4513 rendered_form = e.render()
4514 else:
4515 appstruct = {
4516 ViewParam.TABLE_NAME: table_name,
4517 ViewParam.SERVER_PK: server_pk,
4518 }
4519 rendered_form = form.render(appstruct)
4520 return dict(
4521 task=task,
4522 form=rendered_form,
4523 head_form_html=get_head_form_html(req, [form]),
4524 viewtype=ViewArg.HTML,
4525 )
4528@view_config(
4529 route_name=Routes.DELETE_SPECIAL_NOTE,
4530 renderer="special_note_delete.mako",
4531 http_cache=NEVER_CACHE,
4532)
4533def delete_special_note(req: "CamcopsRequest") -> Dict[str, Any]:
4534 """
4535 View to delete a special note (after confirmation).
4536 """
4537 note_id = req.get_int_param(ViewParam.NOTE_ID, None)
4538 sn = SpecialNote.get_specialnote_by_id(req.dbsession, note_id)
4539 _ = req.gettext
4540 if sn is None:
4541 raise HTTPBadRequest(f"{_('No such SpecialNote:')} note_id={note_id}")
4542 if sn.hidden:
4543 raise HTTPBadRequest(
4544 f"{_('SpecialNote already deleted/hidden:')} " f"note_id={note_id}"
4545 )
4546 if not sn.user_may_delete_specialnote(req.user):
4547 raise HTTPBadRequest(_("Not authorized to delete this special note"))
4548 url_back = req.route_url(Routes.VIEW_TASKS) # default
4549 if sn.refers_to_patient():
4550 # Special note on a patient.
4551 # We might have come here from any number of tasks relating to this
4552 # patient. In principle this information is retrievable; in practice it
4553 # is a considerable faff for a rare operation, since special notes are
4554 # displayed via special_notes.mako, which only looks at information
4555 # stored with the note itself.
4556 pass
4557 else:
4558 # Special note on a task.
4559 task = sn.target_task()
4560 if task:
4561 url_back = req.route_url(
4562 Routes.TASK,
4563 _query={
4564 ViewParam.TABLE_NAME: task.tablename,
4565 ViewParam.SERVER_PK: task.pk,
4566 ViewParam.VIEWTYPE: ViewArg.HTML,
4567 },
4568 )
4569 if FormAction.CANCEL in req.POST:
4570 raise HTTPFound(url_back)
4571 form = DeleteSpecialNoteForm(request=req)
4572 if FormAction.SUBMIT in req.POST:
4573 try:
4574 controls = list(req.POST.items())
4575 form.validate(controls)
4576 # -----------------------------------------------------------------
4577 # Delete special note
4578 # -----------------------------------------------------------------
4579 sn.hidden = True
4580 raise HTTPFound(url_back)
4581 except ValidationFailure as e:
4582 rendered_form = e.render()
4583 else:
4584 appstruct = {ViewParam.NOTE_ID: note_id}
4585 rendered_form = form.render(appstruct)
4586 return dict(
4587 sn=sn,
4588 form=rendered_form,
4589 head_form_html=get_head_form_html(req, [form]),
4590 )
4593class EraseTaskBaseView(DeleteView):
4594 """
4595 Django-style view to erase a task.
4596 """
4598 form_class = EraseTaskForm
4600 def get_object(self) -> Any:
4601 # noinspection PyAttributeOutsideInit
4602 self.table_name = self.request.get_str_param(
4603 ViewParam.TABLE_NAME, validator=validate_task_tablename
4604 )
4605 # noinspection PyAttributeOutsideInit
4606 self.server_pk = self.request.get_int_param(ViewParam.SERVER_PK, None)
4608 task = task_factory(self.request, self.table_name, self.server_pk)
4609 _ = self.request.gettext
4610 if task is None:
4611 raise HTTPBadRequest(
4612 f"{_('No such task:')} {self.table_name}, PK={self.server_pk}"
4613 )
4614 if task.is_live_on_tablet():
4615 raise HTTPBadRequest(errormsg_task_live(self.request))
4616 self.check_user_is_authorized(task)
4618 return task
4620 def check_user_is_authorized(self, task: Task) -> None:
4621 if not self.request.user.authorized_to_erase_tasks(task.group_id):
4622 _ = self.request.gettext
4623 raise HTTPBadRequest(
4624 _("Not authorized to erase tasks for this task's group")
4625 )
4627 def get_cancel_url(self) -> str:
4628 return self.request.route_url(
4629 Routes.TASK,
4630 _query={
4631 ViewParam.TABLE_NAME: self.table_name,
4632 ViewParam.SERVER_PK: self.server_pk,
4633 ViewParam.VIEWTYPE: ViewArg.HTML,
4634 },
4635 )
4638class EraseTaskLeavingPlaceholderView(EraseTaskBaseView):
4639 """
4640 Django-style view to erase data from a task, leaving an empty
4641 "placeholder".
4642 """
4644 template_name = "task_erase.mako"
4646 def get_object(self) -> Any:
4647 task = cast(Task, super().get_object())
4648 if task.is_erased():
4649 _ = self.request.gettext
4650 raise HTTPBadRequest(_("Task already erased"))
4652 return task
4654 def delete(self) -> None:
4655 task = cast(Task, self.object)
4657 task.manually_erase(self.request)
4659 def get_success_url(self) -> str:
4660 return self.request.route_url(
4661 Routes.TASK,
4662 _query={
4663 ViewParam.TABLE_NAME: self.table_name,
4664 ViewParam.SERVER_PK: self.server_pk,
4665 ViewParam.VIEWTYPE: ViewArg.HTML,
4666 },
4667 )
4670class EraseTaskEntirelyView(EraseTaskBaseView):
4671 """
4672 Django-style view to erase (delete) a task entirely.
4673 """
4675 template_name = "task_erase_entirely.mako"
4677 def delete(self) -> None:
4678 task = cast(Task, self.object)
4680 TaskIndexEntry.unindex_task(task, self.request.dbsession)
4681 task.delete_entirely(self.request)
4683 _ = self.request.gettext
4685 msg_erased = _("Task erased:")
4687 self.request.session.flash(
4688 f"{msg_erased} ({self.table_name}, server PK {self.server_pk}).",
4689 queue=FlashQueue.SUCCESS,
4690 )
4692 def get_success_url(self) -> str:
4693 return self.request.route_url(Routes.VIEW_TASKS)
4696@view_config(
4697 route_name=Routes.ERASE_TASK_LEAVING_PLACEHOLDER,
4698 permission=Permission.GROUPADMIN,
4699 http_cache=NEVER_CACHE,
4700)
4701def erase_task_leaving_placeholder(req: "CamcopsRequest") -> Response:
4702 """
4703 View to wipe all data from a task (after confirmation).
4705 Leaves the task record as a placeholder.
4706 """
4707 return EraseTaskLeavingPlaceholderView(req).dispatch()
4710@view_config(
4711 route_name=Routes.ERASE_TASK_ENTIRELY,
4712 permission=Permission.GROUPADMIN,
4713 http_cache=NEVER_CACHE,
4714)
4715def erase_task_entirely(req: "CamcopsRequest") -> Response:
4716 """
4717 View to erase a task from the database entirely (after confirmation).
4718 """
4719 return EraseTaskEntirelyView(req).dispatch()
4722@view_config(
4723 route_name=Routes.DELETE_PATIENT,
4724 permission=Permission.GROUPADMIN,
4725 http_cache=NEVER_CACHE,
4726)
4727def delete_patient(req: "CamcopsRequest") -> Response:
4728 """
4729 View to delete completely all data for a patient (after confirmation),
4730 within a specific group.
4731 """
4732 if FormAction.CANCEL in req.POST:
4733 raise HTTPFound(req.route_url(Routes.HOME))
4735 first_form = DeletePatientChooseForm(request=req)
4736 second_form = DeletePatientConfirmForm(request=req)
4737 form = None
4738 final_phase = False
4739 if FormAction.SUBMIT in req.POST:
4740 # FIRST form has been submitted
4741 form = first_form
4742 elif FormAction.DELETE in req.POST:
4743 # SECOND AND FINAL form has been submitted
4744 form = second_form
4745 final_phase = True
4746 _ = req.gettext
4747 if form is not None:
4748 try:
4749 controls = list(req.POST.items())
4750 appstruct = form.validate(controls)
4751 which_idnum = appstruct.get(ViewParam.WHICH_IDNUM)
4752 idnum_value = appstruct.get(ViewParam.IDNUM_VALUE)
4753 group_id = appstruct.get(ViewParam.GROUP_ID)
4754 if group_id not in req.user.ids_of_groups_user_is_admin_for:
4755 # rare occurrence; form should prevent it;
4756 # unless superuser has changed status since form was read
4757 raise HTTPBadRequest(_("You're not an admin for this group"))
4758 # -----------------------------------------------------------------
4759 # Fetch tasks to be deleted.
4760 # -----------------------------------------------------------------
4761 dbsession = req.dbsession
4762 # Tasks first:
4763 idnum_ref = IdNumReference(
4764 which_idnum=which_idnum, idnum_value=idnum_value
4765 )
4766 taskfilter = TaskFilter()
4767 taskfilter.idnum_criteria = [idnum_ref]
4768 taskfilter.group_ids = [group_id]
4769 collection = TaskCollection(
4770 req=req,
4771 taskfilter=taskfilter,
4772 sort_method_global=TaskSortMethod.CREATION_DATE_DESC,
4773 current_only=False, # unusual option!
4774 )
4775 tasks = collection.all_tasks
4776 n_tasks = len(tasks)
4777 patient_lineage_instances = Patient.get_patients_by_idnum(
4778 dbsession=dbsession,
4779 which_idnum=which_idnum,
4780 idnum_value=idnum_value,
4781 group_id=group_id,
4782 current_only=False,
4783 )
4784 n_patient_instances = len(patient_lineage_instances)
4786 # -----------------------------------------------------------------
4787 # Bin out at this stage and offer confirmation page?
4788 # -----------------------------------------------------------------
4789 if not final_phase:
4790 # New appstruct; we don't want the validation code persisting
4791 appstruct = {
4792 ViewParam.WHICH_IDNUM: which_idnum,
4793 ViewParam.IDNUM_VALUE: idnum_value,
4794 ViewParam.GROUP_ID: group_id,
4795 }
4796 rendered_form = second_form.render(appstruct)
4797 return render_to_response(
4798 "patient_delete_confirm.mako",
4799 dict(
4800 form=rendered_form,
4801 tasks=tasks,
4802 n_patient_instances=n_patient_instances,
4803 head_form_html=get_head_form_html(req, [form]),
4804 ),
4805 request=req,
4806 )
4808 # -----------------------------------------------------------------
4809 # Delete patient and associated tasks
4810 # -----------------------------------------------------------------
4811 for task in tasks:
4812 TaskIndexEntry.unindex_task(task, req.dbsession)
4813 task.delete_entirely(req)
4814 # Then patients:
4815 for p in patient_lineage_instances:
4816 PatientIdNumIndexEntry.unindex_patient(p, req.dbsession)
4817 p.delete_with_dependants(req)
4818 msg = (
4819 f"{_('Patient and associated tasks DELETED from group')} "
4820 f"{group_id}: idnum{which_idnum} = {idnum_value}. "
4821 f"{_('Task records deleted:')} {n_tasks}."
4822 f"{_('Patient records (current and/or old) deleted')} "
4823 f"{n_patient_instances}."
4824 )
4825 audit(req, msg)
4827 req.session.flash(msg, FlashQueue.SUCCESS)
4828 raise HTTPFound(req.route_url(Routes.HOME))
4830 except ValidationFailure as e:
4831 rendered_form = e.render()
4832 else:
4833 form = first_form
4834 rendered_form = first_form.render()
4835 return render_to_response(
4836 "patient_delete_choose.mako",
4837 dict(
4838 form=rendered_form, head_form_html=get_head_form_html(req, [form])
4839 ),
4840 request=req,
4841 )
4844@view_config(
4845 route_name=Routes.FORCIBLY_FINALIZE,
4846 permission=Permission.GROUPADMIN,
4847 http_cache=NEVER_CACHE,
4848)
4849def forcibly_finalize(req: "CamcopsRequest") -> Response:
4850 """
4851 View to force-finalize all live (``_era == ERA_NOW``) records from a
4852 device. Available to group administrators if all those records are within
4853 their groups (otherwise, it's a superuser operation).
4854 """
4855 if FormAction.CANCEL in req.POST:
4856 return HTTPFound(req.route_url(Routes.HOME))
4858 dbsession = req.dbsession
4859 first_form = ForciblyFinalizeChooseDeviceForm(request=req)
4860 second_form = ForciblyFinalizeConfirmForm(request=req)
4861 form = None
4862 final_phase = False
4863 if FormAction.SUBMIT in req.POST:
4864 # FIRST form has been submitted
4865 form = first_form
4866 elif FormAction.FINALIZE in req.POST:
4867 # SECOND form has been submitted:
4868 form = second_form
4869 final_phase = True
4870 _ = req.gettext
4871 if form is not None:
4872 try:
4873 controls = list(req.POST.items())
4874 appstruct = form.validate(controls)
4875 # log.debug("{}", pformat(appstruct))
4876 device_id = appstruct.get(ViewParam.DEVICE_ID)
4877 device = Device.get_device_by_id(dbsession, device_id)
4878 if device is None:
4879 raise HTTPBadRequest(f"{_('No such device:')} {device_id!r}")
4880 # -----------------------------------------------------------------
4881 # If at the first stage, bin out and offer confirmation page
4882 # -----------------------------------------------------------------
4883 if not final_phase:
4884 appstruct = {ViewParam.DEVICE_ID: device_id}
4885 rendered_form = second_form.render(appstruct)
4886 taskfilter = TaskFilter()
4887 taskfilter.device_ids = [device_id]
4888 taskfilter.era = ERA_NOW
4889 collection = TaskCollection(
4890 req=req,
4891 taskfilter=taskfilter,
4892 sort_method_global=TaskSortMethod.CREATION_DATE_DESC,
4893 current_only=False, # unusual option!
4894 via_index=False, # required for current_only=False
4895 )
4896 tasks = collection.all_tasks
4897 return render_to_response(
4898 "device_forcibly_finalize_confirm.mako",
4899 dict(
4900 form=rendered_form,
4901 tasks=tasks,
4902 head_form_html=get_head_form_html(req, [form]),
4903 ),
4904 request=req,
4905 )
4906 # -----------------------------------------------------------------
4907 # Check it's permitted
4908 # -----------------------------------------------------------------
4909 if not req.user.superuser:
4910 admin_group_ids = req.user.ids_of_groups_user_is_admin_for
4911 for clienttable in CLIENT_TABLE_MAP.values():
4912 # noinspection PyPropertyAccess
4913 count_query = (
4914 select([func.count()])
4915 .select_from(clienttable)
4916 .where(clienttable.c[FN_DEVICE_ID] == device_id)
4917 .where(clienttable.c[FN_ERA] == ERA_NOW)
4918 .where(
4919 clienttable.c[FN_GROUP_ID].notin_(admin_group_ids)
4920 )
4921 )
4922 n = dbsession.execute(count_query).scalar()
4923 if n > 0:
4924 raise HTTPBadRequest(
4925 _(
4926 "Some records for this device are in groups "
4927 "for which you are not an administrator"
4928 )
4929 )
4930 # -----------------------------------------------------------------
4931 # Forcibly finalize
4932 # -----------------------------------------------------------------
4933 msgs = [] # type: List[str]
4934 batchdetails = BatchDetails(batchtime=req.now_utc)
4935 alltables = sorted(
4936 CLIENT_TABLE_MAP.values(), key=upload_commit_order_sorter
4937 )
4938 for clienttable in alltables:
4939 liverecs = get_server_live_records(
4940 req, device_id, clienttable, current_only=False
4941 )
4942 preservation_pks = [r.server_pk for r in liverecs]
4943 if not preservation_pks:
4944 continue
4945 current_pks = [r.server_pk for r in liverecs if r.current]
4946 tablechanges = UploadTableChanges(clienttable)
4947 tablechanges.note_preservation_pks(preservation_pks)
4948 tablechanges.note_current_pks(current_pks)
4949 dbsession.execute(
4950 update(clienttable)
4951 .where(clienttable.c[FN_PK].in_(preservation_pks))
4952 .values(
4953 values_preserve_now(
4954 req, batchdetails, forcibly_preserved=True
4955 )
4956 )
4957 )
4958 update_indexes_and_push_exports(
4959 req, batchdetails, tablechanges
4960 )
4961 msgs.append(f"{clienttable.name} {preservation_pks}")
4962 # Field names are different in server-side tables, so they need
4963 # special handling:
4964 SpecialNote.forcibly_preserve_special_notes_for_device(
4965 req, device_id
4966 )
4967 # -----------------------------------------------------------------
4968 # Done
4969 # -----------------------------------------------------------------
4970 msg = (
4971 f"{_('Live records for device')} {device_id} "
4972 f"({device.friendly_name}) {_('forcibly finalized')} "
4973 f"(PKs: {'; '.join(msgs)})"
4974 )
4975 audit(req, msg)
4976 log.info(msg)
4978 req.session.flash(msg, queue=FlashQueue.SUCCESS)
4979 raise HTTPFound(req.route_url(Routes.HOME))
4981 except ValidationFailure as e:
4982 rendered_form = e.render()
4983 else:
4984 form = first_form
4985 rendered_form = form.render() # no appstruct
4986 return render_to_response(
4987 "device_forcibly_finalize_choose.mako",
4988 dict(
4989 form=rendered_form, head_form_html=get_head_form_html(req, [form])
4990 ),
4991 request=req,
4992 )
4995# =============================================================================
4996# Patient creation/editing (primarily for task scheduling)
4997# =============================================================================
5000class PatientMixin(object):
5001 """
5002 Mixin for views involving a patient.
5003 """
5005 object: Any
5006 object_class = Patient
5007 server_pk_name = "_pk"
5009 model_form_dict = {
5010 "forename": ViewParam.FORENAME,
5011 "surname": ViewParam.SURNAME,
5012 "dob": ViewParam.DOB,
5013 "sex": ViewParam.SEX,
5014 "email": ViewParam.EMAIL,
5015 "address": ViewParam.ADDRESS,
5016 "gp": ViewParam.GP,
5017 "other": ViewParam.OTHER,
5018 }
5020 def get_form_values(self) -> Dict:
5021 # will populate with model_form_dict
5022 # noinspection PyUnresolvedReferences
5023 form_values = super().get_form_values()
5025 patient = cast(Patient, self.object)
5027 if patient is not None:
5028 form_values[ViewParam.SERVER_PK] = patient.pk
5029 form_values[ViewParam.GROUP_ID] = patient.group.id
5030 form_values[ViewParam.ID_REFERENCES] = [
5031 {
5032 ViewParam.WHICH_IDNUM: pidnum.which_idnum,
5033 ViewParam.IDNUM_VALUE: pidnum.idnum_value,
5034 }
5035 for pidnum in patient.idnums
5036 ]
5037 ts_list = [] # type: List[Dict]
5038 for pts in patient.task_schedules:
5039 ts_dict = {
5040 ViewParam.PATIENT_TASK_SCHEDULE_ID: pts.id,
5041 ViewParam.SCHEDULE_ID: pts.schedule_id,
5042 ViewParam.START_DATETIME: pts.start_datetime,
5043 }
5044 if DEFORM_ACCORDION_BUG:
5045 ts_dict[ViewParam.SETTINGS] = pts.settings
5046 else:
5047 ts_dict[ViewParam.ADVANCED] = {
5048 ViewParam.SETTINGS: pts.settings
5049 }
5050 ts_list.append(ts_dict)
5051 form_values[ViewParam.TASK_SCHEDULES] = ts_list
5053 return form_values
5056class EditPatientBaseView(PatientMixin, UpdateView):
5057 """
5058 View to edit details for a patient.
5059 """
5061 pk_param = ViewParam.SERVER_PK
5063 def get_object(self) -> Any:
5064 patient = cast(Patient, super().get_object())
5066 _ = self.request.gettext
5068 if not patient.group:
5069 raise HTTPBadRequest(_("Bad patient: not in a group"))
5071 if not patient.user_may_edit(self.request):
5072 raise HTTPBadRequest(_("Not authorized to edit this patient"))
5074 return patient
5076 def save_object(self, appstruct: Dict[str, Any]) -> None:
5077 # -----------------------------------------------------------------
5078 # Apply edits
5079 # -----------------------------------------------------------------
5080 # Calculate the changes, and apply them to the Patient object
5081 _ = self.request.gettext
5083 patient = cast(Patient, self.object)
5085 changes = OrderedDict() # type: OrderedDict
5087 self.save_changes(appstruct, changes)
5089 if not changes:
5090 self.request.session.flash(
5091 f"{_('No changes required for patient record with server PK')} " # noqa
5092 f"{patient.pk} {_('(all new values matched old values)')}",
5093 queue=FlashQueue.INFO,
5094 )
5095 return
5097 formatted_changes = []
5099 for k, details in changes.items():
5100 if len(details) == 1:
5101 change = f"{k}: {details[0]}" # usually a plain message
5102 else:
5103 change = f"{k}: {details[0]!r} → {details[1]!r}"
5105 formatted_changes.append(change)
5107 # Below here, changes have definitely been made.
5108 change_msg = (
5109 _("Patient details edited. Changes:")
5110 + " "
5111 + "; ".join(formatted_changes)
5112 )
5114 # Apply special note to patient
5115 patient.apply_special_note(self.request, change_msg, "Patient edited")
5117 # Patient details changed, so resend any tasks via HL7
5118 for task in self.get_affected_tasks():
5119 task.cancel_from_export_log(self.request)
5121 # Done
5122 self.request.session.flash(
5123 f"{_('Amended patient record with server PK')} "
5124 f"{patient.pk}. "
5125 f"{_('Changes were:')} {change_msg}",
5126 queue=FlashQueue.SUCCESS,
5127 )
5129 def save_changes(
5130 self, appstruct: Dict[str, Any], changes: OrderedDict
5131 ) -> None:
5132 self._save_simple_params(appstruct, changes)
5133 self._save_idrefs(appstruct, changes)
5135 def _save_simple_params(
5136 self, appstruct: Dict[str, Any], changes: OrderedDict
5137 ) -> None:
5138 patient = cast(Patient, self.object)
5139 for k in EDIT_PATIENT_SIMPLE_PARAMS:
5140 new_value = appstruct.get(k)
5141 old_value = getattr(patient, k)
5142 if new_value == old_value:
5143 continue
5144 if new_value in (None, "") and old_value in (None, ""):
5145 # Nothing really changing!
5146 continue
5147 changes[k] = (old_value, new_value)
5148 setattr(patient, k, new_value)
5150 def _save_idrefs(
5151 self, appstruct: Dict[str, Any], changes: OrderedDict
5152 ) -> None:
5154 # The ID numbers are more complex.
5155 # log.debug("{}", pformat(appstruct))
5156 patient = cast(Patient, self.object)
5157 new_idrefs = [
5158 IdNumReference(
5159 which_idnum=idrefdict[ViewParam.WHICH_IDNUM],
5160 idnum_value=idrefdict[ViewParam.IDNUM_VALUE],
5161 )
5162 for idrefdict in appstruct.get(ViewParam.ID_REFERENCES, {})
5163 ]
5164 for idnum in patient.idnums:
5165 matching_idref = next(
5166 (
5167 idref
5168 for idref in new_idrefs
5169 if idref.which_idnum == idnum.which_idnum
5170 ),
5171 None,
5172 )
5173 if not matching_idref:
5174 # Delete ID numbers not present in the new set
5175 changes[
5176 "idnum{} ({})".format(
5177 idnum.which_idnum,
5178 self.request.get_id_desc(idnum.which_idnum),
5179 )
5180 ] = (idnum.idnum_value, None)
5181 idnum.mark_as_deleted(self.request)
5182 elif matching_idref.idnum_value != idnum.idnum_value:
5183 # Modify altered ID numbers present in the old + new sets
5184 changes[
5185 "idnum{} ({})".format(
5186 idnum.which_idnum,
5187 self.request.get_id_desc(idnum.which_idnum),
5188 )
5189 ] = (idnum.idnum_value, matching_idref.idnum_value)
5190 new_idnum = PatientIdNum()
5191 new_idnum.id = idnum.id
5192 new_idnum.patient_id = idnum.patient_id
5193 new_idnum.which_idnum = idnum.which_idnum
5194 new_idnum.idnum_value = matching_idref.idnum_value
5195 new_idnum.set_predecessor(self.request, idnum)
5197 for idref in new_idrefs:
5198 matching_idnum = next(
5199 (
5200 idnum
5201 for idnum in patient.idnums
5202 if idnum.which_idnum == idref.which_idnum
5203 ),
5204 None,
5205 )
5206 if not matching_idnum:
5207 # Create ID numbers where they were absent
5208 changes[
5209 "idnum{} ({})".format(
5210 idref.which_idnum,
5211 self.request.get_id_desc(idref.which_idnum),
5212 )
5213 ] = (None, idref.idnum_value)
5214 # We need to establish an "id" field, which is the PK as
5215 # seen by the tablet. The tablet has lost interest in these
5216 # records, since _era != ERA_NOW, so all we have to do is
5217 # pick a number that's not in use.
5218 new_idnum = PatientIdNum()
5219 new_idnum.patient_id = patient.id
5220 new_idnum.which_idnum = idref.which_idnum
5221 new_idnum.idnum_value = idref.idnum_value
5222 new_idnum.create_fresh(
5223 self.request,
5224 device_id=patient.device_id,
5225 era=patient.era,
5226 group_id=patient.group_id,
5227 )
5228 new_idnum.save_with_next_available_id(
5229 self.request, patient.device_id, era=patient.era
5230 )
5232 def get_context_data(self, **kwargs: Any) -> Any:
5233 # This parameter is (I think) used by Mako templates such as
5234 # finalized_patient_edit.mako
5235 # Todo:
5236 # Potential inefficiency: we fetch tasks regardless of the stage
5237 # of this form.
5238 kwargs["tasks"] = self.get_affected_tasks()
5240 return super().get_context_data(**kwargs)
5242 def get_affected_tasks(self) -> Optional[List[Task]]:
5243 patient = cast(Patient, self.object)
5245 taskfilter = TaskFilter()
5246 taskfilter.device_ids = [patient.device_id]
5247 taskfilter.group_ids = [patient.group.id]
5248 taskfilter.era = patient.era
5249 collection = TaskCollection(
5250 req=self.request,
5251 taskfilter=taskfilter,
5252 sort_method_global=TaskSortMethod.CREATION_DATE_DESC,
5253 current_only=False, # unusual option!
5254 via_index=False, # for current_only=False, or we'll get a warning
5255 )
5256 return collection.all_tasks
5259class EditServerCreatedPatientView(EditPatientBaseView):
5260 """
5261 View to edit a patient created on the server (as part of task scheduling).
5262 """
5264 template_name = "server_created_patient_edit.mako"
5265 form_class = EditServerCreatedPatientForm
5267 def get_success_url(self) -> str:
5268 return self.request.route_url(Routes.VIEW_PATIENT_TASK_SCHEDULES)
5270 def get_object(self) -> Any:
5271 patient = cast(Patient, super().get_object())
5273 if not patient.created_on_server(self.request):
5274 _ = self.request.gettext
5276 raise HTTPBadRequest(
5277 _("Patient is not editable - was not created on the server")
5278 )
5280 return patient
5282 def save_changes(
5283 self, appstruct: Dict[str, Any], changes: OrderedDict
5284 ) -> None:
5285 self._save_group(appstruct, changes)
5286 super().save_changes(appstruct, changes)
5287 self._save_task_schedules(appstruct, changes)
5289 def _save_group(
5290 self, appstruct: Dict[str, Any], changes: OrderedDict
5291 ) -> None:
5292 patient = cast(Patient, self.object)
5294 old_group_id = patient.group.id
5295 old_group_name = patient.group.name
5296 new_group_id = appstruct.get(ViewParam.GROUP_ID, None)
5297 new_group = (
5298 self.request.dbsession.query(Group)
5299 .filter(Group.id == new_group_id)
5300 .first()
5301 )
5303 if old_group_id != new_group_id:
5304 patient._group_id = new_group_id
5305 changes["group"] = (old_group_name, new_group.name)
5307 def _save_task_schedules(
5308 self, appstruct: Dict[str, Any], changes: OrderedDict
5309 ) -> None:
5311 _ = self.request.gettext
5312 patient = cast(Patient, self.object)
5313 ids_to_delete = [pts.id for pts in patient.task_schedules]
5315 anything_changed = False
5317 for schedule_dict in appstruct.get(ViewParam.TASK_SCHEDULES, {}):
5318 pts_id = schedule_dict[ViewParam.PATIENT_TASK_SCHEDULE_ID]
5319 schedule_id = schedule_dict[ViewParam.SCHEDULE_ID]
5320 start_datetime = schedule_dict[ViewParam.START_DATETIME]
5321 if DEFORM_ACCORDION_BUG:
5322 settings = schedule_dict[ViewParam.SETTINGS]
5323 else:
5324 settings = schedule_dict[ViewParam.ADVANCED][
5325 ViewParam.SETTINGS
5326 ] # noqa
5328 if pts_id is None:
5329 pts = PatientTaskSchedule()
5330 pts.patient_pk = patient.pk
5331 pts.schedule_id = schedule_id
5332 pts.start_datetime = start_datetime
5333 pts.settings = settings
5335 self.request.dbsession.add(pts)
5336 anything_changed = True
5337 else:
5338 old_pts = (
5339 self.request.dbsession.query(PatientTaskSchedule)
5340 .filter(PatientTaskSchedule.id == pts_id)
5341 .first()
5342 )
5344 updates = {}
5345 if old_pts.start_datetime != start_datetime:
5346 updates[
5347 PatientTaskSchedule.start_datetime
5348 ] = start_datetime
5350 if old_pts.schedule_id != schedule_id:
5351 updates[PatientTaskSchedule.schedule_id] = schedule_id
5353 if old_pts.settings != settings:
5354 updates[PatientTaskSchedule.settings] = settings
5356 if updates:
5357 anything_changed = True
5358 self.request.dbsession.query(PatientTaskSchedule).filter(
5359 PatientTaskSchedule.id == pts_id
5360 ).update(updates, synchronize_session="fetch")
5362 ids_to_delete.remove(pts_id)
5364 pts_to_delete = self.request.dbsession.query(
5365 PatientTaskSchedule
5366 ).filter(PatientTaskSchedule.id.in_(ids_to_delete))
5368 # Previously we had:
5369 # pts_to_delete.delete(synchronize_session="fetch")
5370 #
5371 # This won't cascade the deletion because we are calling delete() on
5372 # the query object. We could set up cascade at the database level
5373 # instead but there is little performance gain here.
5374 # https://stackoverflow.com/questions/19243964/sqlalchemy-delete-doesnt-cascade
5376 for pts in pts_to_delete:
5377 self.request.dbsession.delete(pts)
5378 anything_changed = True
5380 if anything_changed:
5381 changes[_("Task schedules")] = (_("Updated"),)
5384class EditFinalizedPatientView(EditPatientBaseView):
5385 """
5386 View to edit a finalized patient.
5387 """
5389 template_name = "finalized_patient_edit.mako"
5390 form_class = EditFinalizedPatientForm
5392 def __init__(
5393 self,
5394 req: CamcopsRequest,
5395 task_tablename: str = None,
5396 task_server_pk: int = None,
5397 ) -> None:
5398 """
5399 The two additional parameters are for returning the user to the task
5400 from which editing was initiated.
5401 """
5402 super().__init__(req)
5403 self.task_tablename = task_tablename
5404 self.task_server_pk = task_server_pk
5406 def get_success_url(self) -> str:
5407 """
5408 We got here by editing a patient from an uploaded task, so that's our
5409 return point.
5410 """
5411 if self.task_tablename and self.task_server_pk:
5412 return self.request.route_url(
5413 Routes.TASK,
5414 _query={
5415 ViewParam.TABLE_NAME: self.task_tablename,
5416 ViewParam.SERVER_PK: self.task_server_pk,
5417 ViewParam.VIEWTYPE: ViewArg.HTML,
5418 },
5419 )
5420 else:
5421 # Likely in a testing environment!
5422 return self.request.route_url(Routes.HOME)
5424 def get_object(self) -> Any:
5425 patient = cast(Patient, super().get_object())
5427 if not patient.is_finalized():
5428 _ = self.request.gettext
5430 raise HTTPBadRequest(
5431 _(
5432 "Patient is not editable (likely: not finalized, so a "
5433 "copy still on a client device)"
5434 )
5435 )
5437 return patient
5440@view_config(
5441 route_name=Routes.EDIT_FINALIZED_PATIENT,
5442 permission=Permission.GROUPADMIN,
5443 http_cache=NEVER_CACHE,
5444)
5445def edit_finalized_patient(req: "CamcopsRequest") -> Response:
5446 """
5447 View to edit details for a patient.
5448 """
5449 task_table_name = req.get_str_param(
5450 ViewParam.BACK_TASK_TABLENAME, validator=validate_task_tablename
5451 )
5452 task_server_pk = req.get_int_param(ViewParam.BACK_TASK_SERVER_PK, None)
5454 return EditFinalizedPatientView(
5455 req, task_tablename=task_table_name, task_server_pk=task_server_pk
5456 ).dispatch()
5459@view_config(
5460 route_name=Routes.EDIT_SERVER_CREATED_PATIENT, http_cache=NEVER_CACHE
5461)
5462def edit_server_created_patient(req: "CamcopsRequest") -> Response:
5463 """
5464 View to edit details for a patient created on the server (for scheduling
5465 tasks).
5466 """
5467 return EditServerCreatedPatientView(req).dispatch()
5470class AddPatientView(PatientMixin, CreateView):
5471 """
5472 View to add a patient (for task scheduling).
5473 """
5475 form_class = EditServerCreatedPatientForm
5476 template_name = "patient_add.mako"
5478 def dispatch(self) -> Response:
5479 if not self.request.user.authorized_to_manage_patients:
5480 _ = self.request.gettext
5481 raise HTTPBadRequest(_("Not authorized to manage patients"))
5483 return super().dispatch()
5485 def get_success_url(self) -> str:
5486 return self.request.route_url(Routes.VIEW_PATIENT_TASK_SCHEDULES)
5488 def save_object(self, appstruct: Dict[str, Any]) -> None:
5489 server_device = Device.get_server_device(self.request.dbsession)
5491 patient = Patient()
5492 patient.create_fresh(
5493 self.request,
5494 device_id=server_device.id,
5495 era=ERA_NOW,
5496 group_id=appstruct.get(ViewParam.GROUP_ID),
5497 )
5499 for k in EDIT_PATIENT_SIMPLE_PARAMS:
5500 new_value = appstruct.get(k)
5501 setattr(patient, k, new_value)
5503 patient.save_with_next_available_id(self.request, server_device.id)
5505 new_idrefs = [
5506 IdNumReference(
5507 which_idnum=idrefdict[ViewParam.WHICH_IDNUM],
5508 idnum_value=idrefdict[ViewParam.IDNUM_VALUE],
5509 )
5510 for idrefdict in appstruct.get(ViewParam.ID_REFERENCES)
5511 ]
5513 for idref in new_idrefs:
5514 new_idnum = PatientIdNum()
5515 new_idnum.patient_id = patient.id
5516 new_idnum.which_idnum = idref.which_idnum
5517 new_idnum.idnum_value = idref.idnum_value
5518 new_idnum.create_fresh(
5519 self.request,
5520 device_id=server_device.id,
5521 era=ERA_NOW,
5522 group_id=appstruct.get(ViewParam.GROUP_ID),
5523 )
5525 new_idnum.save_with_next_available_id(
5526 self.request, server_device.id
5527 )
5529 task_schedules = appstruct.get(ViewParam.TASK_SCHEDULES)
5531 self.request.dbsession.commit()
5533 for task_schedule in task_schedules:
5534 schedule_id = task_schedule[ViewParam.SCHEDULE_ID]
5535 start_datetime = task_schedule[ViewParam.START_DATETIME]
5536 if DEFORM_ACCORDION_BUG:
5537 settings = task_schedule[ViewParam.SETTINGS]
5538 else:
5539 settings = task_schedule[ViewParam.ADVANCED][
5540 ViewParam.SETTINGS
5541 ] # noqa
5542 patient_task_schedule = PatientTaskSchedule()
5543 patient_task_schedule.patient_pk = patient.pk
5544 patient_task_schedule.schedule_id = schedule_id
5545 patient_task_schedule.start_datetime = start_datetime
5546 patient_task_schedule.settings = settings
5548 self.request.dbsession.add(patient_task_schedule)
5550 self.object = patient
5553@view_config(route_name=Routes.ADD_PATIENT, http_cache=NEVER_CACHE)
5554def add_patient(req: "CamcopsRequest") -> Response:
5555 """
5556 View to add a patient.
5557 """
5558 return AddPatientView(req).dispatch()
5561class DeleteServerCreatedPatientView(DeleteView):
5562 """
5563 View to delete a patient that had been created on the server.
5564 """
5566 form_class = DeleteServerCreatedPatientForm
5567 object_class = Patient
5568 pk_param = ViewParam.SERVER_PK
5569 server_pk_name = "_pk"
5570 template_name = TEMPLATE_GENERIC_FORM
5572 def get_object(self) -> Any:
5573 patient = cast(Patient, super().get_object())
5574 if not patient.user_may_edit(self.request):
5575 _ = self.request.gettext
5576 raise HTTPBadRequest(_("Not authorized to delete this patient"))
5577 return patient
5579 def get_extra_context(self) -> Dict[str, Any]:
5580 _ = self.request.gettext
5581 return {
5582 MAKO_VAR_TITLE: self.request.icon_text(
5583 icon=Icons.DELETE, text=_("Delete patient")
5584 )
5585 }
5587 def get_success_url(self) -> str:
5588 return self.request.route_url(Routes.VIEW_PATIENT_TASK_SCHEDULES)
5590 def delete(self) -> None:
5591 patient = cast(Patient, self.object)
5593 PatientIdNumIndexEntry.unindex_patient(patient, self.request.dbsession)
5595 patient.delete_with_dependants(self.request)
5598@view_config(
5599 route_name=Routes.DELETE_SERVER_CREATED_PATIENT, http_cache=NEVER_CACHE
5600)
5601def delete_server_created_patient(req: "CamcopsRequest") -> Response:
5602 """
5603 Page to delete a patient created on the server (as part of task
5604 scheduling).
5605 """
5606 return DeleteServerCreatedPatientView(req).dispatch()
5609# =============================================================================
5610# Task scheduling
5611# =============================================================================
5614@view_config(
5615 route_name=Routes.VIEW_TASK_SCHEDULES,
5616 permission=Permission.GROUPADMIN,
5617 renderer="view_task_schedules.mako",
5618 http_cache=NEVER_CACHE,
5619)
5620def view_task_schedules(req: "CamcopsRequest") -> Dict[str, Any]:
5621 """
5622 View whole task schedules.
5623 """
5624 rows_per_page = req.get_int_param(
5625 ViewParam.ROWS_PER_PAGE, DEFAULT_ROWS_PER_PAGE
5626 )
5627 page_num = req.get_int_param(ViewParam.PAGE, 1)
5628 group_ids = req.user.ids_of_groups_user_is_admin_for
5629 q = (
5630 req.dbsession.query(TaskSchedule)
5631 .join(TaskSchedule.group)
5632 .filter(TaskSchedule.group_id.in_(group_ids))
5633 .order_by(Group.name, TaskSchedule.name)
5634 )
5635 page = SqlalchemyOrmPage(
5636 query=q,
5637 page=page_num,
5638 items_per_page=rows_per_page,
5639 url_maker=PageUrl(req),
5640 request=req,
5641 )
5642 return dict(page=page)
5645@view_config(
5646 route_name=Routes.VIEW_TASK_SCHEDULE_ITEMS,
5647 permission=Permission.GROUPADMIN,
5648 renderer="view_task_schedule_items.mako",
5649 http_cache=NEVER_CACHE,
5650)
5651def view_task_schedule_items(req: "CamcopsRequest") -> Dict[str, Any]:
5652 """
5653 View items within a task schedule.
5654 """
5655 rows_per_page = req.get_int_param(
5656 ViewParam.ROWS_PER_PAGE, DEFAULT_ROWS_PER_PAGE
5657 )
5658 page_num = req.get_int_param(ViewParam.PAGE, 1)
5659 schedule_id = req.get_int_param(ViewParam.SCHEDULE_ID)
5661 schedule = (
5662 req.dbsession.query(TaskSchedule)
5663 .filter(TaskSchedule.id == schedule_id)
5664 .one_or_none()
5665 )
5667 if schedule is None:
5668 _ = req.gettext
5669 raise HTTPBadRequest(_("Schedule does not exist"))
5671 q = (
5672 req.dbsession.query(TaskScheduleItem)
5673 .filter(TaskScheduleItem.schedule_id == schedule_id)
5674 .order_by(*task_schedule_item_sort_order())
5675 )
5676 page = SqlalchemyOrmPage(
5677 query=q,
5678 page=page_num,
5679 items_per_page=rows_per_page,
5680 url_maker=PageUrl(req),
5681 request=req,
5682 )
5683 return dict(page=page, schedule_name=schedule.name)
5686@view_config(
5687 route_name=Routes.VIEW_PATIENT_TASK_SCHEDULES,
5688 renderer="view_patient_task_schedules.mako",
5689 http_cache=NEVER_CACHE,
5690)
5691def view_patient_task_schedules(req: "CamcopsRequest") -> Dict[str, Any]:
5692 """
5693 View all patients and their assigned schedules (as well as their access
5694 keys, etc.).
5695 """
5696 server_device = Device.get_server_device(req.dbsession)
5698 rows_per_page = req.get_int_param(
5699 ViewParam.ROWS_PER_PAGE, DEFAULT_ROWS_PER_PAGE
5700 )
5701 page_num = req.get_int_param(ViewParam.PAGE, 1)
5702 allowed_group_ids = req.user.ids_of_groups_user_may_manage_patients_in
5703 # noinspection PyProtectedMember
5704 q = (
5705 req.dbsession.query(Patient)
5706 .filter(Patient._era == ERA_NOW)
5707 .filter(Patient._group_id.in_(allowed_group_ids))
5708 .filter(Patient._device_id == server_device.id)
5709 .order_by(Patient.surname, Patient.forename)
5710 .options(joinedload("task_schedules"))
5711 .options(joinedload("idnums"))
5712 )
5714 page = SqlalchemyOrmPage(
5715 query=q,
5716 page=page_num,
5717 items_per_page=rows_per_page,
5718 url_maker=PageUrl(req),
5719 request=req,
5720 )
5721 return dict(page=page)
5724@view_config(
5725 route_name=Routes.VIEW_PATIENT_TASK_SCHEDULE,
5726 renderer="view_patient_task_schedule.mako",
5727 http_cache=NEVER_CACHE,
5728)
5729def view_patient_task_schedule(req: "CamcopsRequest") -> Dict[str, Any]:
5730 """
5731 View scheduled tasks for one patient's specific task schedule.
5732 """
5733 pts_id = req.get_int_param(ViewParam.PATIENT_TASK_SCHEDULE_ID)
5735 pts = (
5736 req.dbsession.query(PatientTaskSchedule)
5737 .filter(PatientTaskSchedule.id == pts_id)
5738 .options(
5739 joinedload("patient.idnums"), joinedload("task_schedule.items")
5740 )
5741 .one_or_none()
5742 )
5744 _ = req.gettext
5745 if pts is None:
5746 raise HTTPBadRequest(_("Patient's task schedule does not exist"))
5748 if not pts.patient.user_may_edit(req):
5749 raise HTTPBadRequest(_("Not authorized to manage this patient"))
5751 patient_descriptor = pts.patient.prettystr(req)
5753 return dict(
5754 pts=pts,
5755 patient_descriptor=patient_descriptor,
5756 schedule_name=pts.task_schedule.name,
5757 task_list=pts.get_list_of_scheduled_tasks(req),
5758 )
5761class TaskScheduleMixin(object):
5762 """
5763 Mixin for viewing/editing a task schedule.
5764 """
5766 form_class = EditTaskScheduleForm
5767 model_form_dict = {
5768 "name": ViewParam.NAME,
5769 "group_id": ViewParam.GROUP_ID,
5770 "email_bcc": ViewParam.EMAIL_BCC,
5771 "email_cc": ViewParam.EMAIL_CC,
5772 "email_from": ViewParam.EMAIL_FROM,
5773 "email_subject": ViewParam.EMAIL_SUBJECT,
5774 "email_template": ViewParam.EMAIL_TEMPLATE,
5775 }
5776 object_class = TaskSchedule
5777 request: "CamcopsRequest"
5778 server_pk_name = "id"
5779 template_name = TEMPLATE_GENERIC_FORM
5781 def get_success_url(self) -> str:
5782 return self.request.route_url(Routes.VIEW_TASK_SCHEDULES)
5784 def get_object(self) -> Any:
5785 # noinspection PyUnresolvedReferences
5786 schedule = cast(TaskSchedule, super().get_object())
5788 if not schedule.user_may_edit(self.request):
5789 _ = self.request.gettext
5790 raise HTTPBadRequest(
5791 _(
5792 "You a not a group administrator for this "
5793 "task schedule's group"
5794 )
5795 )
5797 return schedule
5800class AddTaskScheduleView(TaskScheduleMixin, CreateView):
5801 """
5802 Django-style view class to add a task schedule.
5803 """
5805 def get_extra_context(self) -> Dict[str, Any]:
5806 _ = self.request.gettext
5807 return {
5808 MAKO_VAR_TITLE: self.request.icon_text(
5809 icon=Icons.TASK_SCHEDULE_ADD, text=_("Add a task schedule")
5810 )
5811 }
5814class EditTaskScheduleView(TaskScheduleMixin, UpdateView):
5815 """
5816 Django-style view class to edit a task schedule.
5817 """
5819 pk_param = ViewParam.SCHEDULE_ID
5821 def get_extra_context(self) -> Dict[str, Any]:
5822 _ = self.request.gettext
5823 return {
5824 MAKO_VAR_TITLE: self.request.icon_text(
5825 icon=Icons.TASK_SCHEDULE,
5826 text=_("Edit details for a task schedule"),
5827 )
5828 }
5831class DeleteTaskScheduleView(TaskScheduleMixin, DeleteView):
5832 """
5833 Django-style view class to delete a task schedule.
5834 """
5836 form_class = DeleteTaskScheduleForm
5837 pk_param = ViewParam.SCHEDULE_ID
5839 def get_extra_context(self) -> Dict[str, Any]:
5840 _ = self.request.gettext
5841 return {
5842 MAKO_VAR_TITLE: self.request.icon_text(
5843 icon=Icons.DELETE, text=_("Delete a task schedule")
5844 )
5845 }
5848@view_config(
5849 route_name=Routes.ADD_TASK_SCHEDULE,
5850 permission=Permission.GROUPADMIN,
5851 http_cache=NEVER_CACHE,
5852)
5853def add_task_schedule(req: "CamcopsRequest") -> Response:
5854 """
5855 View to add a task schedule.
5856 """
5857 return AddTaskScheduleView(req).dispatch()
5860@view_config(
5861 route_name=Routes.EDIT_TASK_SCHEDULE, permission=Permission.GROUPADMIN
5862)
5863def edit_task_schedule(req: "CamcopsRequest") -> Response:
5864 """
5865 View to edit a task schedule.
5866 """
5867 return EditTaskScheduleView(req).dispatch()
5870@view_config(
5871 route_name=Routes.DELETE_TASK_SCHEDULE, permission=Permission.GROUPADMIN
5872)
5873def delete_task_schedule(req: "CamcopsRequest") -> Response:
5874 """
5875 View to delete a task schedule.
5876 """
5877 return DeleteTaskScheduleView(req).dispatch()
5880class TaskScheduleItemMixin(object):
5881 """
5882 Mixin for viewing/editing a task schedule items.
5883 """
5885 form_class = EditTaskScheduleItemForm
5886 template_name = TEMPLATE_GENERIC_FORM
5887 model_form_dict = {
5888 "schedule_id": ViewParam.SCHEDULE_ID,
5889 "task_table_name": ViewParam.TABLE_NAME,
5890 "due_from": ViewParam.DUE_FROM,
5891 # we need to convert due_within to due_by
5892 }
5893 object: Any
5894 # noinspection PyTypeChecker
5895 object_class = cast(Type["Base"], TaskScheduleItem)
5896 pk_param = ViewParam.SCHEDULE_ITEM_ID
5897 request: "CamcopsRequest"
5898 server_pk_name = "id"
5900 def get_success_url(self) -> str:
5901 # noinspection PyUnresolvedReferences
5902 return self.request.route_url(
5903 Routes.VIEW_TASK_SCHEDULE_ITEMS,
5904 _query={ViewParam.SCHEDULE_ID: self.get_schedule_id()},
5905 )
5908class EditTaskScheduleItemMixin(TaskScheduleItemMixin):
5909 """
5910 Django-style view class to edit a task schedule item.
5911 """
5913 def set_object_properties(self, appstruct: Dict[str, Any]) -> None:
5914 # noinspection PyUnresolvedReferences
5915 super().set_object_properties(appstruct)
5917 due_from = appstruct.get(ViewParam.DUE_FROM)
5918 due_within = appstruct.get(ViewParam.DUE_WITHIN)
5920 setattr(self.object, "due_by", due_from + due_within)
5922 def get_schedule(self) -> TaskSchedule:
5923 # noinspection PyUnresolvedReferences
5924 schedule_id = self.get_schedule_id()
5926 schedule = (
5927 self.request.dbsession.query(TaskSchedule)
5928 .filter(TaskSchedule.id == schedule_id)
5929 .one_or_none()
5930 )
5932 if schedule is None:
5933 _ = self.request.gettext
5934 raise HTTPBadRequest(
5935 f"{_('Missing Task Schedule for id')} {schedule_id}"
5936 )
5938 if not schedule.user_may_edit(self.request):
5939 _ = self.request.gettext
5940 raise HTTPBadRequest(
5941 _(
5942 "You a not a group administrator for this "
5943 "task schedule's group"
5944 )
5945 )
5947 return schedule
5950class AddTaskScheduleItemView(EditTaskScheduleItemMixin, CreateView):
5951 """
5952 Django-style view class to add a task schedule item.
5953 """
5955 def get_extra_context(self) -> Dict[str, Any]:
5956 _ = self.request.gettext
5958 schedule = self.get_schedule()
5960 return {
5961 MAKO_VAR_TITLE: self.request.icon_text(
5962 icon=Icons.TASK_SCHEDULE_ITEM_ADD,
5963 text=_("Add an item to the {schedule_name} schedule").format(
5964 schedule_name=schedule.name
5965 ),
5966 )
5967 }
5969 def get_schedule_id(self) -> int:
5970 return self.request.get_int_param(ViewParam.SCHEDULE_ID)
5972 def get_form_values(self) -> Dict:
5973 schedule = self.get_schedule()
5975 form_values = super().get_form_values()
5976 form_values[ViewParam.SCHEDULE_ID] = schedule.id
5978 return form_values
5981class EditTaskScheduleItemView(EditTaskScheduleItemMixin, UpdateView):
5982 """
5983 Django-style view class to edit a task schedule item.
5984 """
5986 def get_extra_context(self) -> Dict[str, Any]:
5987 _ = self.request.gettext
5988 return {
5989 MAKO_VAR_TITLE: self.request.icon_text(
5990 icon=Icons.EDIT,
5991 text=_("Edit details for a task schedule item"),
5992 )
5993 }
5995 def get_schedule_id(self) -> int:
5996 item = cast(TaskScheduleItem, self.object)
5998 return item.schedule_id
6000 def get_form_values(self) -> Dict:
6001 schedule = self.get_schedule()
6003 form_values = super().get_form_values()
6004 form_values[ViewParam.SCHEDULE_ID] = schedule.id
6006 item = cast(TaskScheduleItem, self.object)
6007 due_within = item.due_by - form_values[ViewParam.DUE_FROM]
6008 form_values[ViewParam.DUE_WITHIN] = due_within
6010 return form_values
6013class DeleteTaskScheduleItemView(TaskScheduleItemMixin, DeleteView):
6014 """
6015 Django-style view class to delete a task schedule item.
6016 """
6018 form_class = DeleteTaskScheduleItemForm
6020 def get_extra_context(self) -> Dict[str, Any]:
6021 _ = self.request.gettext
6022 return {
6023 MAKO_VAR_TITLE: self.request.icon_text(
6024 icon=Icons.DELETE, text=_("Delete a task schedule item")
6025 )
6026 }
6028 def get_schedule_id(self) -> int:
6029 item = cast(TaskScheduleItem, self.object)
6031 return item.schedule_id
6034@view_config(
6035 route_name=Routes.ADD_TASK_SCHEDULE_ITEM, permission=Permission.GROUPADMIN
6036)
6037def add_task_schedule_item(req: "CamcopsRequest") -> Response:
6038 """
6039 View to add a task schedule item.
6040 """
6041 return AddTaskScheduleItemView(req).dispatch()
6044@view_config(
6045 route_name=Routes.EDIT_TASK_SCHEDULE_ITEM, permission=Permission.GROUPADMIN
6046)
6047def edit_task_schedule_item(req: "CamcopsRequest") -> Response:
6048 """
6049 View to edit a task schedule item.
6050 """
6051 return EditTaskScheduleItemView(req).dispatch()
6054@view_config(
6055 route_name=Routes.DELETE_TASK_SCHEDULE_ITEM,
6056 permission=Permission.GROUPADMIN,
6057)
6058def delete_task_schedule_item(req: "CamcopsRequest") -> Response:
6059 """
6060 View to delete a task schedule item.
6061 """
6062 return DeleteTaskScheduleItemView(req).dispatch()
6065@view_config(
6066 route_name=Routes.CLIENT_API,
6067 request_method=HttpMethod.GET,
6068 permission=NO_PERMISSION_REQUIRED,
6069 renderer="client_api_signposting.mako",
6070)
6071@view_config(
6072 route_name=Routes.CLIENT_API_ALIAS,
6073 request_method=HttpMethod.GET,
6074 permission=NO_PERMISSION_REQUIRED,
6075 renderer="client_api_signposting.mako",
6076)
6077def client_api_signposting(req: "CamcopsRequest") -> Dict[str, Any]:
6078 """
6079 Patients are likely to enter the ``/api`` address into a web browser,
6080 especially if it appears as a hyperlink in an email. If so, that will
6081 arrive as a ``GET`` request. This page will direct them to download the
6082 app.
6083 """
6084 return {
6085 "github_link": req.icon_text(
6086 icon=Icons.GITHUB, url=GITHUB_RELEASES_URL, text="GitHub"
6087 ),
6088 "server_url": req.route_url(Routes.CLIENT_API),
6089 }
6092class SendPatientEmailBaseView(FormView):
6093 """
6094 Send an e-mail to a patient (such as: "please download the app and register
6095 with this URL/code").
6096 """
6098 form_class = SendEmailForm
6099 template_name = "send_patient_email.mako"
6101 def __init__(self, *args, **kwargs) -> None:
6102 self._pts = None
6104 super().__init__(*args, **kwargs)
6106 def dispatch(self) -> Response:
6107 if not self.request.user.authorized_to_email_patients:
6108 _ = self.request.gettext
6109 raise HTTPBadRequest(_("Not authorized to email patients"))
6111 return super().dispatch()
6113 def get_context_data(self, **kwargs: Any) -> Dict[str, Any]:
6114 kwargs["pts"] = self._get_patient_task_schedule()
6116 return super().get_context_data(**kwargs)
6118 def form_valid(self, form: "Form", appstruct: Dict[str, Any]) -> Response:
6119 config = self.request.config
6121 patient_email = appstruct.get(ViewParam.EMAIL)
6123 kwargs = dict(
6124 from_addr=appstruct.get(ViewParam.EMAIL_FROM),
6125 to=patient_email,
6126 subject=appstruct.get(ViewParam.EMAIL_SUBJECT),
6127 body=appstruct.get(ViewParam.EMAIL_BODY),
6128 content_type=MimeType.HTML,
6129 )
6131 cc = appstruct.get(ViewParam.EMAIL_CC)
6132 if cc:
6133 kwargs["cc"] = cc
6135 bcc = appstruct.get(ViewParam.EMAIL_BCC)
6136 if bcc:
6137 kwargs["bcc"] = bcc
6139 email = Email(**kwargs)
6140 ok = email.send(
6141 host=config.email_host,
6142 username=config.email_host_username,
6143 password=config.email_host_password,
6144 port=config.email_port,
6145 use_tls=config.email_use_tls,
6146 )
6147 if ok:
6148 self._display_success_message(patient_email)
6149 else:
6150 self._display_failure_message(patient_email)
6152 self.request.dbsession.add(email)
6153 self.request.dbsession.flush()
6154 pts_id = self.request.get_int_param(ViewParam.PATIENT_TASK_SCHEDULE_ID)
6155 if pts_id is None:
6156 _ = self.request.gettext
6157 raise HTTPBadRequest(_("Patient task schedule does not exist"))
6159 pts_email = PatientTaskScheduleEmail()
6160 pts_email.patient_task_schedule_id = pts_id
6161 pts_email.email_id = email.id
6162 self.request.dbsession.add(pts_email)
6163 self.request.dbsession.commit()
6165 return super().form_valid(form, appstruct)
6167 def _display_success_message(self, patient_email: str) -> None:
6168 _ = self.request.gettext
6169 message = _("Email sent to {patient_email}").format(
6170 patient_email=patient_email
6171 )
6173 self.request.session.flash(message, queue=FlashQueue.SUCCESS)
6175 def _display_failure_message(self, patient_email: str) -> None:
6176 _ = self.request.gettext
6177 message = _("Failed to send email to {patient_email}").format(
6178 patient_email=patient_email
6179 )
6181 self.request.session.flash(message, queue=FlashQueue.DANGER)
6183 def get_form_values(self) -> Dict:
6184 pts = self._get_patient_task_schedule()
6186 if pts is None:
6187 _ = self.request.gettext
6188 raise HTTPBadRequest(_("Patient task schedule does not exist"))
6190 return {
6191 ViewParam.EMAIL: pts.patient.email,
6192 ViewParam.EMAIL_CC: pts.task_schedule.email_cc,
6193 ViewParam.EMAIL_BCC: pts.task_schedule.email_bcc,
6194 ViewParam.EMAIL_FROM: pts.task_schedule.email_from,
6195 ViewParam.EMAIL_SUBJECT: pts.task_schedule.email_subject,
6196 ViewParam.EMAIL_BODY: pts.email_body(self.request),
6197 }
6199 def _get_patient_task_schedule(self) -> Optional[PatientTaskSchedule]:
6200 if self._pts is not None:
6201 return self._pts
6203 pts_id = self.request.get_int_param(ViewParam.PATIENT_TASK_SCHEDULE_ID)
6205 self._pts = (
6206 self.request.dbsession.query(PatientTaskSchedule)
6207 .filter(PatientTaskSchedule.id == pts_id)
6208 .one_or_none()
6209 )
6211 return self._pts
6214class SendEmailFromPatientListView(SendPatientEmailBaseView):
6215 """
6216 Send an e-mail to a patient and return to the patient task schedule list
6217 view.
6218 """
6220 def get_success_url(self) -> str:
6221 return self.request.route_url(Routes.VIEW_PATIENT_TASK_SCHEDULES)
6224class SendEmailFromPatientTaskScheduleView(SendPatientEmailBaseView):
6225 """
6226 Send an e-mail to a patient and return to the task schedule view for that
6227 specific patient.
6228 """
6230 def get_success_url(self) -> str:
6231 pts_id = self.request.get_int_param(ViewParam.PATIENT_TASK_SCHEDULE_ID)
6233 return self.request.route_url(
6234 Routes.VIEW_PATIENT_TASK_SCHEDULE,
6235 _query={ViewParam.PATIENT_TASK_SCHEDULE_ID: pts_id},
6236 )
6239@view_config(
6240 route_name=Routes.SEND_EMAIL_FROM_PATIENT_TASK_SCHEDULE,
6241 http_cache=NEVER_CACHE,
6242)
6243def send_email_from_patient_task_schedule(req: "CamcopsRequest") -> Response:
6244 """
6245 View to send an email to a patient from their task schedule page.
6246 """
6247 return SendEmailFromPatientTaskScheduleView(req).dispatch()
6250@view_config(
6251 route_name=Routes.SEND_EMAIL_FROM_PATIENT_LIST, http_cache=NEVER_CACHE
6252)
6253def send_email_from_patient_list(req: "CamcopsRequest") -> Response:
6254 """
6255 View to send an email to a patient from the list of patients.
6256 """
6257 return SendEmailFromPatientListView(req).dispatch()
6260# =============================================================================
6261# FHIR identifier "system" information
6262# =============================================================================
6265@view_config(
6266 route_name=Routes.FHIR_PATIENT_ID_SYSTEM,
6267 request_method=HttpMethod.GET,
6268 renderer="fhir_patient_id_system.mako",
6269 http_cache=NEVER_CACHE,
6270)
6271def view_fhir_patient_id_system(req: "CamcopsRequest") -> Dict[str, Any]:
6272 """
6273 Placeholder view for FHIR patient identifier "system" types (from the ID
6274 that we may have provided to a FHIR server).
6276 Within each system, the "value" is the actual patient's ID number (not
6277 part of what we show here).
6278 """
6279 which_idnum = int(req.matchdict[ViewParam.WHICH_IDNUM])
6280 if which_idnum not in req.valid_which_idnums:
6281 _ = req.gettext
6282 raise HTTPBadRequest(
6283 f"{_('Unknown patient ID type:')} " f"{which_idnum!r}"
6284 )
6285 return dict(which_idnum=which_idnum)
6288# noinspection PyUnusedLocal
6289@view_config(
6290 route_name=Routes.FHIR_QUESTIONNAIRE_SYSTEM,
6291 request_method=HttpMethod.GET,
6292 renderer="all_tasks.mako",
6293 http_cache=NEVER_CACHE,
6294)
6295@view_config(
6296 route_name=Routes.TASK_LIST,
6297 request_method=HttpMethod.GET,
6298 renderer="all_tasks.mako",
6299 http_cache=NEVER_CACHE,
6300)
6301def view_task_list(req: "CamcopsRequest") -> Dict[str, Any]:
6302 """
6303 Lists all tasks.
6305 Also the placeholder view for FHIR Questionnaire "system".
6306 There's only one system -- the "value" is the task type.
6307 """
6308 return dict(all_task_classes=Task.all_subclasses_by_tablename())
6311@view_config(
6312 route_name=Routes.TASK_DETAILS,
6313 request_method=HttpMethod.GET,
6314 renderer="task_details.mako",
6315 http_cache=NEVER_CACHE,
6316)
6317def view_task_details(req: "CamcopsRequest") -> Dict[str, Any]:
6318 """
6319 View details of a specific task type.
6321 Used also for for FHIR DocumentReference, Observation,and
6322 QuestionnaireResponse "system" types. (There's one system per task. Within
6323 each task, the "value" relates to the specific task PK.)
6324 """
6325 table_name = req.matchdict[ViewParam.TABLE_NAME]
6326 task_class_dict = tablename_to_task_class_dict()
6327 if table_name not in task_class_dict:
6328 _ = req.gettext
6329 raise HTTPBadRequest(f"{_('Unknown task:')} {table_name!r}")
6330 task_class = task_class_dict[table_name]
6331 task_instance = task_class()
6333 fhir_aq_items = task_instance.get_fhir_questionnaire(req)
6334 # ddl = task_instance.get_ddl()
6335 # ddl_html, ddl_css = format_sql_as_html(ddl)
6337 return dict(
6338 task_class=task_class,
6339 task_instance=task_instance,
6340 fhir_aq_items=fhir_aq_items,
6341 # ddl_html=ddl_html,
6342 # css=ddl_css,
6343 )
6346@view_config(
6347 route_name=Routes.FHIR_CONDITION,
6348 request_method=HttpMethod.GET,
6349 http_cache=NEVER_CACHE,
6350)
6351@view_config(
6352 route_name=Routes.FHIR_DOCUMENT_REFERENCE,
6353 request_method=HttpMethod.GET,
6354 http_cache=NEVER_CACHE,
6355)
6356@view_config(
6357 route_name=Routes.FHIR_OBSERVATION,
6358 request_method=HttpMethod.GET,
6359 http_cache=NEVER_CACHE,
6360)
6361@view_config(
6362 route_name=Routes.FHIR_PRACTITIONER,
6363 request_method=HttpMethod.GET,
6364 http_cache=NEVER_CACHE,
6365)
6366@view_config(
6367 route_name=Routes.FHIR_QUESTIONNAIRE_RESPONSE,
6368 request_method=HttpMethod.GET,
6369 http_cache=NEVER_CACHE,
6370)
6371def fhir_view_task(req: "CamcopsRequest") -> Response:
6372 """
6373 Retrieve parameters from a FHIR URL referring back to this server, and
6374 serve the relevant task (as HTML).
6376 The "canonical URL" or "business identifier" of a FHIR resource is the
6377 reference to the master copy -- in this case, our copy. See
6378 https://www.hl7.org/fhir/datatypes.html#Identifier;
6379 https://www.hl7.org/fhir/resource.html#identifiers.
6381 FHIR identifiers have a "system" (which is a URL) and a "value". I don't
6382 think that FHIR has a rule for combining the system and value to create a
6383 full URL. For some (but by no means all) identifiers that we provide to
6384 FHIR servers, the "system" refers to a CamCOPS task (and the value to some
6385 attribute of that task, like the answer to a question (value of a field),
6386 or a fixed string like "patient", and so on.
6387 """
6388 table_name = req.matchdict[ViewParam.TABLE_NAME]
6389 server_pk = req.matchdict[ViewParam.SERVER_PK]
6390 return HTTPFound(
6391 req.route_url(
6392 Routes.TASK,
6393 _query={
6394 ViewParam.TABLE_NAME: table_name,
6395 ViewParam.SERVER_PK: server_pk,
6396 ViewParam.VIEWTYPE: ViewArg.HTML,
6397 },
6398 )
6399 )
6402@view_config(
6403 route_name=Routes.FHIR_TABLENAME_PK_ID,
6404 request_method=HttpMethod.GET,
6405 http_cache=NEVER_CACHE,
6406)
6407def fhir_view_tablename_pk(req: "CamcopsRequest") -> Response:
6408 """
6409 Deal with the slightly silly system that just takes a tablename and PK
6410 directly. Security is key here!
6411 """
6412 table_name = req.matchdict[ViewParam.TABLE_NAME]
6413 server_pk = req.matchdict[ViewParam.SERVER_PK]
6414 if table_name == Patient.__tablename__:
6415 return view_patient(req, server_pk)
6416 return HTTPFound(
6417 req.route_url(
6418 Routes.TASK,
6419 _query={
6420 ViewParam.TABLE_NAME: table_name,
6421 ViewParam.SERVER_PK: server_pk,
6422 ViewParam.VIEWTYPE: ViewArg.HTML,
6423 },
6424 )
6425 )
6428# =============================================================================
6429# Static assets
6430# =============================================================================
6431# https://docs.pylonsproject.org/projects/pyramid/en/latest/narr/assets.html#advanced-static # noqa
6434def debug_form_rendering() -> None:
6435 r"""
6436 Test code for form rendering.
6438 From the command line:
6440 .. code-block:: bash
6442 # Start in the CamCOPS source root directory.
6443 # - Needs the "-f" option to follow forks.
6444 # - "open" doesn't show all files opened. To see what you need, try
6445 # strace cat /proc/version
6446 # - ... which shows that "openat" is most useful.
6448 strace -f --trace=openat \
6449 python -c 'from camcops_server.cc_modules.webview import debug_form_rendering; debug_form_rendering()' \
6450 | grep site-packages \
6451 | grep -v "\.pyc"
6453 This tells us that the templates are files like:
6455 .. code-block:: none
6457 site-packages/deform/templates/form.pt
6458 site-packages/deform/templates/select.pt
6459 site-packages/deform/templates/textinput.pt
6461 On 2020-06-29 we are interested in why a newer (Docker) installation
6462 renders buggy HTML like:
6464 .. code-block:: none
6466 <select name="which_idnum" id="deformField2" class=" form-control " multiple="False">
6467 <option value="1">CPFT RiO number</option>
6468 <option value="2">NHS number</option>
6469 <option value="1000">MyHospital number</option>
6470 </select>
6472 ... the bug being that ``multiple="False"`` is wrong; an HTML boolean
6473 attribute is false when *absent*, not when set to a certain value (see
6474 https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes#Boolean_Attributes).
6475 The ``multiple`` attribute of ``<select>`` is a boolean attribute
6476 (https://developer.mozilla.org/en-US/docs/Web/HTML/Element/select).
6478 The ``select.pt`` file indicates that this is controlled by
6479 ``tal:attributes`` syntax. TAL is Template Attribution Language
6480 (https://sharptal.readthedocs.io/en/latest/tal.html).
6482 TAL is either provided by Zope (given ZPT files) or Chameleon or both. The
6483 tracing suggests Chameleon. So the TAL language reference is
6484 https://chameleon.readthedocs.io/en/latest/reference.html.
6486 Chameleon changelog is
6487 https://github.com/malthe/chameleon/blob/master/CHANGES.rst.
6489 Multiple sources for ``tal:attributes`` syntax say that a null value
6490 (presumably: ``None``) is required to omit the attribute, not a false
6491 value.
6493 """ # noqa
6495 import sys
6497 from camcops_server.cc_modules.cc_debug import makefunc_trace_unique_calls
6498 from camcops_server.cc_modules.cc_forms import ChooseTrackerForm
6499 from camcops_server.cc_modules.cc_request import get_core_debugging_request
6501 req = get_core_debugging_request()
6502 form = ChooseTrackerForm(req, as_ctv=False)
6504 sys.settrace(makefunc_trace_unique_calls(file_only=True))
6505 _ = form.render()
6506 sys.settrace(None)