Coverage for cc_modules/cc_view_classes.py: 47%
280 statements
« prev ^ index » next coverage.py v7.9.2, created at 2025-07-15 14:23 +0100
« prev ^ index » next coverage.py v7.9.2, created at 2025-07-15 14:23 +0100
1"""
2camcops_server/cc_modules/cc_view_classes.py
4===============================================================================
6 Copyright (C) 2012, University of Cambridge, Department of Psychiatry.
7 Created by Rudolf Cardinal (rnc1001@cam.ac.uk).
9 This file is part of CamCOPS.
11 CamCOPS is free software: you can redistribute it and/or modify
12 it under the terms of the GNU General Public License as published by
13 the Free Software Foundation, either version 3 of the License, or
14 (at your option) any later version.
16 CamCOPS is distributed in the hope that it will be useful,
17 but WITHOUT ANY WARRANTY; without even the implied warranty of
18 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
19 GNU General Public License for more details.
21 You should have received a copy of the GNU General Public License
22 along with CamCOPS. If not, see <https://www.gnu.org/licenses/>.
24===============================================================================
26Django-style class-based views for Pyramid.
27Adapted from Django's ``views/generic/base.py`` and ``views/generic/edit.py``.
29Django has the following licence:
31.. code-block:: none
33 Copyright (c) Django Software Foundation and individual contributors.
34 All rights reserved.
36 Redistribution and use in source and binary forms, with or without
37 modification, are permitted provided that the following conditions are met:
39 1. Redistributions of source code must retain the above copyright
40 notice, this list of conditions and the following disclaimer.
42 2. Redistributions in binary form must reproduce the above copyright
43 notice, this list of conditions and the following disclaimer in the
44 documentation and/or other materials provided with the distribution.
46 3. Neither the name of Django nor the names of its contributors may be
47 used to endorse or promote products derived from this software
48 without specific prior written permission.
50 THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
51 AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
52 IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
53 ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE
54 LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
55 CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
56 SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
57 INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
58 CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
59 ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
60 POSSIBILITY OF SUCH DAMAGE.
62Custom views typically inherit from :class:`CreateView`, :class:`DeleteView` or
63:class:`UpdateView`.
65A Pyramid view function with a named route should create a view of the custom
66class, passing in the request, and return the results of its ``dispatch()``
67method. For example:
69.. code-block:: python
71 @view_config(route_name="edit_server_created_patient")
72 def edit_server_created_patient(req: Request) -> Response:
73 return EditServerCreatedPatientView(req).dispatch()
75To provide a custom view class to create a new object in the database:
77- Inherit from :class:`CreateView`.
78- Set the ``object_class`` property.
79- Set the ``form_class`` property.
80- Set the ``template_name`` property or implement ``get_template_name()``.
81- Override ``get_extra_context()`` for any extra parameters to pass to the
82 template.
83- Set ``success_url`` or override ``get_success_url()`` to be the redirect on
84 successful creation.
85- Override ``get_form_kwargs()`` for any extra parameters to pass to the form
86 constructor.
87- For simple views, set the ``model_form_dict`` property to be a mapping of
88 object properties to form parameters.
89- Override ``get_form_values()`` with any values additional to
90 ``model_form_dict`` to populate the form.
91- Override ``save_object()`` to do anything more than a simple record save
92 (saving related objects, for example).
94To provide a custom view class to delete an object from the database:
96- Inherit from :class:`DeleteView`.
97- Set the ``object_class`` property.
98- Set the ``form_class`` property.
99- Set the ``template_name`` property or implement ``get_template_name()``.
100- Override ``get_extra_context()``. for any extra parameters to pass to the
101 template.
102- Set ``success_url`` or override ``get_success_url()`` to be the redirect on
103 successful creation.
104- Override ``get_form_kwargs()`` for any extra parameters to pass to the form
105 constructor.
106- Set the ``pk_param`` property to be the name of the parameter in the request
107 that holds the unique/primary key of the object to be deleted.
108- Set the ``server_pk_name`` property to be the name of the property on the
109 object that is the unique/primary key.
110- Override ``get_object()`` if the object cannot be retrieved with the above.
111- Override ``delete()`` to do anything more than a simple record delete; for
112 example, to delete dependant objects
114To provide a custom view class to update an object in the database:
116- Inherit from :class:`UpdateView`.
117- Set the ``object_class`` property.
118- Set the ``form_class`` property.
119- Set the ``template_name`` property or implement ``get_template_name()``.
120- Override ``get_extra_context()`` for any extra parameters to pass to the
121 template.
122- Set ``success_url`` or override ``get_success_url()`` to be the redirect on
123 successful creation.
124- Override ``get_form_kwargs()`` for any extra parameters to pass to the form
125 constructor.
126- Set the ``pk_param`` property to be the name of the parameter in the request
127 that holds the unique/primary key of the object to be updated.
128- Set the ``server_pk_name`` property to be the name of the property on the
129 object that is the unique/primary key.
130- Override ``get_object()`` if the object cannot be retrieved with the above.
131- For simple views, set the ``model_form_dict`` property to be a mapping of
132 object properties to form parameters.
133- Override ``save_object()`` to do anything more than a simple record save
134 (saving related objects, for example).
136You can use mixins for settings common to multiple views.
138.. note::
140 When we move to Python 3.8, there is ``typing.Protocol``, which allows
141 mixins to be type-checked properly. Currently we suppress warnings.
143Some examples are in ``webview.py``.
145"""
147from pyramid.httpexceptions import (
148 HTTPBadRequest,
149 HTTPFound,
150 HTTPMethodNotAllowed,
151)
152from pyramid.renderers import render_to_response
153from pyramid.response import Response
155import logging
156from typing import Any, Dict, List, NoReturn, Optional, Type, TYPE_CHECKING
158from cardinal_pythonlib.deform_utils import get_head_form_html
159from cardinal_pythonlib.httpconst import HttpMethod, HttpStatus
160from cardinal_pythonlib.logs import BraceStyleAdapter
161from cardinal_pythonlib.typing_helpers import with_typehint, with_typehints
162from deform.exception import ValidationFailure
164from camcops_server.cc_modules.cc_exception import raise_runtime_error
165from camcops_server.cc_modules.cc_pyramid import FlashQueue, FormAction
166from camcops_server.cc_modules.cc_resource_registry import (
167 CamcopsResourceRegistry,
168)
170if TYPE_CHECKING:
171 from deform.form import Form
172 from camcops_server.cc_modules.cc_request import CamcopsRequest
174log = BraceStyleAdapter(logging.getLogger(__name__))
177# =============================================================================
178# View
179# =============================================================================
182class View(object):
183 """
184 Simple parent class for all views. Owns the request object and provides a
185 dispatcher for HTTP requests.
187 Derived classes typically implement ``get()`` and ``post()``.
188 """
190 http_method_names = [HttpMethod.GET.lower(), HttpMethod.POST.lower()]
192 # -------------------------------------------------------------------------
193 # Creation
194 # -------------------------------------------------------------------------
196 def __init__(self, request: "CamcopsRequest") -> None:
197 """
198 Args:
199 request:
200 a :class:`camcops_server.cc_modules.cc_request.CamcopsRequest`
201 """
202 self.request = request
204 # -------------------------------------------------------------------------
205 # Dispatching GET and POST requests
206 # -------------------------------------------------------------------------
208 def dispatch(self) -> Response:
209 """
210 Try to dispatch to the right HTTP method (e.g. GET, POST). If a method
211 doesn't exist, defer to the error handler. Also defer to the error
212 handler if the request method isn't on the approved list.
214 Specifically, this ends up calling ``self.get()`` or ``self.post()`` or
215 ``self.http_method_not_allowed()``.
216 """
217 handler = self.http_method_not_allowed
218 method_lower = self.request.method.lower()
219 if method_lower in self.http_method_names:
220 handler = getattr(self, method_lower, handler)
221 return handler()
223 def http_method_not_allowed(self) -> NoReturn:
224 """
225 Raise a :exc:`pyramid.httpexceptions.HTTPMethodNotAllowed` (error 405)
226 indicating that the selected HTTP method is not allowed.
227 """
228 log.warning(
229 "Method Not Allowed (%s): %s",
230 self.request.method,
231 self.request.path,
232 extra={
233 "status_code": HttpStatus.METHOD_NOT_ALLOWED,
234 "request": self.request,
235 },
236 )
237 raise HTTPMethodNotAllowed(
238 detail=f"Allowed methods: {self._allowed_methods}"
239 )
241 def _allowed_methods(self) -> List[str]:
242 """
243 Which HTTP methods are allowed? Returns a list of upper-case strings.
244 """
245 return [m.upper() for m in self.http_method_names if hasattr(self, m)]
248# =============================================================================
249# Basic mixins
250# =============================================================================
253class ContextMixin(object):
254 """
255 A default context mixin that passes the keyword arguments received by
256 get_context_data() as the template context.
257 """
259 def get_extra_context(self) -> Dict[str, Any]:
260 """
261 Override to provide extra context, merged in by
262 :meth:`get_context_data`.
263 """
264 return {}
266 def get_context_data(self, **kwargs: Any) -> Dict[str, Any]:
267 """
268 Provides context for a template, including the ``view`` argument and
269 any additional context provided by :meth:`get_extra_context`.
270 """
271 kwargs.setdefault("view", self)
272 kwargs.update(self.get_extra_context())
274 return kwargs
277class TemplateResponseMixin(object):
278 """
279 A mixin that can be used to render a Mako template.
280 """
282 request: "CamcopsRequest"
283 template_name: str = None
285 def render_to_response(self, context: Dict) -> Response:
286 """
287 Takes the supplied context, renders it through our specified template
288 (set by ``template_name``), and returns a
289 :class:`pyramid.response.Response`.
290 """
291 return render_to_response(
292 self.get_template_name(), context, request=self.request
293 )
295 def get_template_name(self) -> str:
296 """
297 Returns the template filename.
298 """
299 if self.template_name is None:
300 raise_runtime_error(
301 "You must set template_name or override "
302 f"get_template_name() in {self.__class__}."
303 )
305 return self.template_name
308# =============================================================================
309# Form views
310# =============================================================================
313class ProcessFormView(
314 View, with_typehints(ContextMixin, TemplateResponseMixin) # type: ignore[misc] # noqa: E501
315):
316 """
317 Render a form on GET and processes it on POST.
319 Requires ContextMixin.
320 """
322 # -------------------------------------------------------------------------
323 # GET and POST handlers
324 # -------------------------------------------------------------------------
326 def get(self) -> Response:
327 """
328 Handle GET requests: instantiate a blank version of the form and render
329 it.
330 """
331 # noinspection PyUnresolvedReferences
332 return self.render_to_response(self.get_context_data())
334 def post(self) -> Response:
335 """
336 Handle POST requests:
338 - if the user has cancelled, redirect to the cancellation URL;
339 - instantiate a form instance with the passed POST variables and then
340 check if it's valid;
341 - if it's invalid, call ``form_invalid()``, which typically
342 renders the form to show the errors and allow resubmission;
343 - if it's valid, call ``form_valid()``, which in the default handler
345 (a) processes data via ``form_valid_process_data()``, and
346 (b) returns a response (either another form or redirection to another
347 URL) via ``form_valid_response()``.
348 """
349 if FormAction.CANCEL in self.request.POST:
350 # noinspection PyUnresolvedReferences
351 raise HTTPFound(self.get_cancel_url())
353 # noinspection PyUnresolvedReferences
354 form = self.get_form()
355 controls = list(self.request.POST.items())
357 try:
358 appstruct = form.validate(controls)
360 # noinspection PyUnresolvedReferences
361 return self.form_valid(form, appstruct)
362 except ValidationFailure as e:
363 # e.error.asdict() will reveal more
365 # noinspection PyUnresolvedReferences
366 return self.form_invalid(e)
368 # -------------------------------------------------------------------------
369 # Cancellation
370 # -------------------------------------------------------------------------
372 def get_cancel_url(self) -> str:
373 """
374 Return the URL to redirect to when cancelling a form.
375 """
376 raise NotImplementedError
378 # -------------------------------------------------------------------------
379 # Processing valid and invalid forms on POST
380 # -------------------------------------------------------------------------
382 def form_valid(self, form: "Form", appstruct: Dict[str, Any]) -> Response:
383 """
384 2021-10-05: separate data handling and the response to return. Why?
385 Because:
387 (a) returning a response can involve "return response" or "raise
388 HTTPFound", making flow harder to track;
389 (b) the Python method resolution order (MRO) makes it harder to be
390 clear on the flow through the combination function.
391 """
392 self.form_valid_process_data(form, appstruct)
393 return self.form_valid_response(form, appstruct)
395 def form_valid_process_data(
396 self, form: "Form", appstruct: Dict[str, Any]
397 ) -> None:
398 """
399 Perform any handling of data from the form.
401 Override in subclasses or mixins if necessary. Be sure to call the
402 superclass method to ensure all actions are performed.
403 """
404 pass
406 def form_valid_response(
407 self, form: "Form", appstruct: Dict[str, Any]
408 ) -> Response:
409 """
410 Return the response (or raise a redirection exception) following valid
411 form submission.
412 """
413 raise NotImplementedError
415 def form_invalid(self, validation_error: ValidationFailure) -> Response:
416 """
417 Called when the form is submitted via POST and is invalid.
418 Returns a response with a rendering of the invalid form.
419 """
420 raise NotImplementedError
423# =============================================================================
424# Form mixin
425# =============================================================================
428class FormMixin(ContextMixin, with_typehint(ProcessFormView)): # type: ignore[misc] # noqa: E501
429 """
430 Provide a way to show and handle a single form in a request.
431 """
433 cancel_url = None
434 form_class: Type["Form"] = None
435 success_url = None
436 failure_url = None
437 _form = None
438 _error = None
440 request: "CamcopsRequest"
442 # -------------------------------------------------------------------------
443 # Creating the form
444 # -------------------------------------------------------------------------
446 def get_form_class(self) -> Optional[Type["Form"]]:
447 """
448 Return the form class to use.
449 """
450 return self.form_class
452 def get_form(self) -> "Form":
453 """
454 Return an instance of the form to be used in this view.
455 """
456 form_class = self.get_form_class()
457 if not form_class:
458 raise_runtime_error("Your view must provide a form_class.")
459 assert form_class is not None # type checker
461 return form_class(**self.get_form_kwargs())
463 def get_form_kwargs(self) -> Dict[str, Any]:
464 """
465 Return the keyword arguments for instantiating the form.
466 """
467 return {
468 "request": self.request,
469 "resource_registry": CamcopsResourceRegistry(),
470 }
472 def get_rendered_form(self, form: "Form") -> str:
473 """
474 Returns the form, rendered as HTML.
475 """
476 if self._error is not None:
477 return self._error.render()
479 # noinspection PyUnresolvedReferences
480 appstruct = self.get_form_values()
481 return form.render(appstruct)
483 def get_context_data(self, **kwargs: Any) -> Dict[str, Any]:
484 """
485 Insert the rendered form (as HTML) into the context dict.
486 """
487 form = self.get_form()
488 kwargs["form"] = self.get_rendered_form(form)
489 kwargs["head_form_html"] = get_head_form_html(self.request, [form])
490 return super().get_context_data(**kwargs)
492 # -------------------------------------------------------------------------
493 # Destination URLs
494 # -------------------------------------------------------------------------
496 def get_cancel_url(self) -> str:
497 """
498 Return the URL to redirect to when cancelling a form.
499 """
500 if not self.cancel_url:
501 return self.get_success_url()
502 return str(self.cancel_url) # cancel_url may be lazy
504 def get_success_url(self) -> str:
505 """
506 Return the URL to redirect to after processing a valid form.
507 """
508 if not self.success_url:
509 raise_runtime_error("Your view must provide a success_url.")
510 return str(self.success_url) # success_url may be lazy
512 def get_failure_url(self) -> str:
513 """
514 Return the URL to redirect to on error after processing a valid form.
515 e.g. when a password is of the correct form but is invalid.
516 """
517 if not self.failure_url:
518 raise_runtime_error("Your view must provide a failure_url.")
519 return str(self.failure_url) # failure_url may be lazy
521 # -------------------------------------------------------------------------
522 # Handling valid/invalid forms
523 # -------------------------------------------------------------------------
525 # noinspection PyTypeChecker
526 def form_valid_response(
527 self, form: "Form", appstruct: Dict[str, Any]
528 ) -> Response:
529 """
530 Called when the form is submitted via POST and is valid.
531 Redirects to the supplied "success" URL.
532 """
533 raise HTTPFound(self.get_success_url())
535 def form_invalid(self, validation_error: ValidationFailure) -> Response:
536 """
537 Called when the form is submitted via POST and is invalid.
538 Returns a response with a rendering of the invalid form.
539 """
540 self._error = validation_error
542 # noinspection PyUnresolvedReferences
543 return self.render_to_response(self.get_context_data())
545 # -------------------------------------------------------------------------
546 # Helper methods
547 # -------------------------------------------------------------------------
549 def fail(self, message: str) -> NoReturn:
550 """
551 Raises a failure exception, redirecting to a failure URL.
552 """
553 self.request.session.flash(message, queue=FlashQueue.DANGER)
554 raise HTTPFound(self.get_failure_url())
557class BaseFormView(FormMixin, ProcessFormView):
558 """
559 A base view for displaying a form.
560 """
562 pass
565class FormView(TemplateResponseMixin, BaseFormView):
566 """
567 A view for displaying a form and rendering a template response.
568 """
570 pass
573# =============================================================================
574# Multi-step forms
575# =============================================================================
578class FormWizardMixin(with_typehints(FormMixin, ProcessFormView)): # type: ignore[misc] # noqa: E501
579 """
580 Basic support for multi-step form entry.
581 For more complexity we could do something like
582 https://github.com/jazzband/django-formtools/tree/master/formtools/wizard
584 We store temporary state in the ``form_state`` dictionary on the
585 :class:`CamcopsSession` object on the request. Arbitrary values can be
586 stored in ``form_state``. The following are used by this mixin:
588 - "step" stores the name of the current form entry step.
589 - "route_name" stores the name of the current route, so we can detect if
590 the form state is stale from a previous incomplete operation.
592 Views using this Mixin should implement:
594 ``wizard_first_step``: The name of the first form entry step
595 ``wizard_forms``: step name -> :class:``Form`` dict
596 ``wizard_templates``: step name -> template filename dict
597 ``wizard_extra_contexts``: step name -> context dict dict
599 Alternatively, subclasses can override ``get_first_step()`` etc.
601 The logic of changing steps is left to the subclass.
602 """
604 PARAM_FINISHED = "finished"
605 PARAM_STEP = "step"
606 PARAM_ROUTE_NAME = "route_name"
608 wizard_first_step: Optional[str] = None
609 wizard_forms: Dict[str, Type["Form"]] = {}
610 wizard_templates: Dict[str, str] = {}
611 wizard_extra_contexts: Dict[str, Dict[str, Any]] = {}
613 def __init__(self, *args: Any, **kwargs: Any) -> None:
614 """
615 We prevent stale state from messing things up by clearing state when a
616 form sequence starts. Form sequences start with HTTP GET and proceed
617 via HTTP POST. So, if this is a GET request, we clear the state. We do
618 so in the __init__ sequence, as others may wish to write state before
619 the view is dispatched.
621 An example of stale state: the user sets an MFA method but then that is
622 disallowed on the server whilst they are halfway through login. (That
623 leaves users totally stuffed as they are not properly "logged in" and
624 therefore can't easily log out.)
626 There are other examples seen in testing. This method gets round all
627 those. (For example, the worst-case situation is then advising the user
628 to log in again, or start whatever form-based process it was again).
630 We also reset the state if the stored route name doesn't match the
631 current route name.
632 """
633 super().__init__(*args, **kwargs) # initializes self.request
635 # Make sure we save any changes to the form state
636 self.request.dbsession.add(self.request.camcops_session)
638 if (
639 self.request.method == HttpMethod.GET
640 or self.route_name != self._request_route_name
641 ):
642 # If self.route_name was None when tested here, it will be
643 # initialised to self._request_route_name when first fetched
644 # (see getter/setter below) so this "!=" test will be False.
645 self._clear_state()
647 # -------------------------------------------------------------------------
648 # State
649 # -------------------------------------------------------------------------
651 @property
652 def state(self) -> Dict[str, Any]:
653 """
654 Returns the (arbitrary) state dictionary. See class help.
655 """
656 if self.request.camcops_session.form_state is None:
657 self.request.camcops_session.form_state = dict()
659 return self.request.camcops_session.form_state
661 @state.setter
662 def state(self, state: Optional[Dict[str, Any]]) -> None:
663 """
664 Sets the (arbitrary) state dictionary. See class help.
665 """
666 self.request.camcops_session.form_state = state
668 def _clear_state(self) -> None:
669 """
670 Creates a fresh starting state.
671 """
672 self.state = {
673 self.PARAM_FINISHED: False,
674 self.PARAM_ROUTE_NAME: self._request_route_name,
675 # ... we use str() largely because in the unit testing framework,
676 # we get objects like <Mock name='mock.name' id='140226165199816'>,
677 # which is not JSON-serializable.
678 }
680 # -------------------------------------------------------------------------
681 # Step (an aspect of state)
682 # -------------------------------------------------------------------------
684 @property
685 def step(self) -> str:
686 """
687 Returns the current step.
688 """
689 step = self.state.setdefault(self.PARAM_STEP, self.get_first_step())
690 return step
692 @step.setter
693 def step(self, step: str) -> None:
694 """
695 Sets the current step.
696 """
697 self.state[self.PARAM_STEP] = step
699 def get_first_step(self) -> str:
700 """
701 Returns the first step to be used when the form is first loaded.
702 """
703 return self.wizard_first_step
705 # -------------------------------------------------------------------------
706 # Finishing (an aspect of state)
707 # -------------------------------------------------------------------------
709 def finish(self) -> None:
710 """
711 Ends, by marking the state as finished, and clearing any other
712 state except the current route/step (the step in particular may be
713 useful for subsequent functions).
714 """
715 self.state = {
716 self.PARAM_FINISHED: True,
717 self.PARAM_ROUTE_NAME: self._request_route_name,
718 self.PARAM_STEP: self.step,
719 }
721 def finished(self) -> bool:
722 """
723 Have we finished?
724 """
725 return self.state.get(self.PARAM_FINISHED, False)
727 # -------------------------------------------------------------------------
728 # Routes (an aspect of state)
729 # -------------------------------------------------------------------------
731 @property
732 def _request_route_name(self) -> str:
733 """
734 Return the route name from the request. If for some reason it's
735 missing, we return an empty string.
737 We convert using ``str()`` largely because in the unit testing
738 framework, we get objects like ``<Mock name='mock.name'
739 id='140226165199816'>``, which is not JSON-serializable.
740 """
741 name = self.request.matched_route.name
742 return str(name) if name else ""
744 @property
745 def route_name(self) -> Optional[str]:
746 """
747 Get the name of the current route. See class help.
748 """
749 return self.state.setdefault(
750 self.PARAM_ROUTE_NAME, self._request_route_name
751 )
753 @route_name.setter
754 def route_name(self, route_name: str) -> None:
755 """
756 Set the name of the current route. See class help.
757 """
758 self.state[self.PARAM_ROUTE_NAME] = route_name
760 # -------------------------------------------------------------------------
761 # Step-specific information
762 # -------------------------------------------------------------------------
764 def get_form_class(self) -> Optional[Type["Form"]]:
765 """
766 Returns the class of Form to be used for the current step (not a form
767 instance).
768 """
769 return self.wizard_forms[self.step]
771 def get_template_name(self) -> str:
772 """
773 Returns the Mako template filename to be used for the current step.
774 """
775 return self.wizard_templates[self.step]
777 def get_extra_context(self) -> Dict[str, Any]:
778 """
779 Returns any extra context information (as a dictionary) for the current
780 step.
781 """
782 return self.wizard_extra_contexts[self.step]
784 # -------------------------------------------------------------------------
785 # Success
786 # -------------------------------------------------------------------------
788 # noinspection PyUnusedLocal
789 def form_valid_response(
790 self, form: "Form", appstruct: Dict[str, Any]
791 ) -> Response:
792 """
793 Called when the form is submitted via POST and is valid.
794 Redirects to the supplied "success" URL.
795 """
796 if self.finished():
797 raise HTTPFound(self.get_success_url())
798 else:
799 # Try to keep this in POST -- fewer requests, but it also means
800 # that we can use GET to indicate the first in a sequence, and thus
801 # be able to clear stale state correctly.
803 # The "step" should have been changed, and that means that we will
804 # get a new form:
805 return self.get()
807 # -------------------------------------------------------------------------
808 # Failure
809 # -------------------------------------------------------------------------
811 def fail(self, message: str) -> NoReturn:
812 """
813 Raises a failure.
814 """
815 self.finish()
816 super().fail(message) # will raise
817 assert False, "Bug: FormWizardMixin.fail() falling through"
820# =============================================================================
821# ORM mixins
822# =============================================================================
825class SingleObjectMixin(ContextMixin):
826 """
827 Represents a single ORM object, for use as a mixin.
828 """
830 object: Any
831 object_class: Optional[Type[Any]]
832 pk_param: str
833 request: "CamcopsRequest"
834 server_pk_name: str
836 def get_context_data(self, **kwargs: Any) -> Dict[str, Any]:
837 """
838 Insert the single object into the context dict.
839 """
840 context = {}
841 if self.object:
842 context["object"] = self.object
844 context.update(kwargs)
846 return super().get_context_data(**context)
848 def get_object(self) -> Any:
849 """
850 Returns the ORM object being manipulated.
851 """
852 pk_value = self.get_pk_value()
854 if self.object_class is None:
855 raise_runtime_error("Your view must provide an object_class.")
857 pk_property = getattr(self.object_class, self.server_pk_name)
859 obj = (
860 self.request.dbsession.query(self.object_class)
861 .filter(pk_property == pk_value)
862 .one_or_none()
863 )
865 if obj is None:
866 _ = self.request.gettext
868 assert self.object_class is not None # type checker
870 raise HTTPBadRequest(
871 _(
872 "Cannot find {object_class} with "
873 "{server_pk_name}:{pk_value}"
874 ).format(
875 object_class=self.object_class.__name__,
876 server_pk_name=self.server_pk_name,
877 pk_value=pk_value,
878 )
879 )
881 return obj
883 def get_pk_value(self) -> int:
884 """
885 Returns the integer primary key of the object.
886 """
887 return self.request.get_int_param(self.pk_param)
890class ModelFormMixin(FormMixin, SingleObjectMixin):
891 """
892 Represents an ORM object (the model) and an associated form.
893 """
895 object_class: Optional[Type[Any]] = None
897 model_form_dict: Dict # maps model attribute name to form param name
898 object: Any # the object being manipulated
899 request: "CamcopsRequest"
901 def form_valid_process_data(
902 self, form: "Form", appstruct: Dict[str, Any]
903 ) -> None:
904 """
905 Called when the form is valid.
906 Saves the associated model.
907 """
908 self.save_object(appstruct)
909 super().form_valid_process_data(form, appstruct)
911 def save_object(self, appstruct: Dict[str, Any]) -> None:
912 """
913 Saves the object in the database, from data provided via the form.
914 """
915 if self.object is None:
916 if self.object_class is None:
917 raise_runtime_error("Your view must provide an object_class.")
918 assert self.object_class is not None # type checker
919 self.object = self.object_class()
921 self.set_object_properties(appstruct)
922 self.request.dbsession.add(self.object)
924 def get_model_form_dict(self) -> Dict[str, str]:
925 """
926 Returns the dictionary mapping model attribute names to form parameter
927 names.
928 """
929 return self.model_form_dict
931 def set_object_properties(self, appstruct: Dict[str, Any]) -> None:
932 """
933 Sets properties of the object, from form data.
934 """
935 # No need to call superclass method; this is the top level.
936 for model_attr, form_param in self.get_model_form_dict().items():
937 try:
938 value = appstruct[form_param]
939 setattr(self.object, model_attr, value)
940 except KeyError:
941 # Value may have been removed from appstruct: don't change
942 pass
944 def get_form_values(self) -> Dict[str, Any]:
945 """
946 Reads form values from the object (or provides an empty dictionary if
947 there is no object yet). Returns a form dictionary.
948 """
949 form_values = {}
951 if self.object is not None:
952 for model_attr, form_param in self.get_model_form_dict().items():
953 value = getattr(self.object, model_attr)
955 # Not sure if this is a good idea. There may be legitimate
956 # reasons for keeping the value None here, but the view is
957 # likely to be overriding get_form_values() in that case.
958 # The alternative is we have to set all None string values
959 # to empty, in order to prevent the word None from appearing
960 # in text input fields.
961 if value is None:
962 value = ""
963 form_values[form_param] = value
965 return form_values
968# =============================================================================
969# Views involving forms and ORM objects
970# =============================================================================
973class BaseCreateView(ModelFormMixin, ProcessFormView):
974 """
975 Base view for creating a new object instance.
977 Using this base class requires subclassing to provide a response mixin.
978 """
980 def get(self) -> Any:
981 self.object = None
982 return super().get()
984 def post(self) -> Any:
985 self.object = None
986 return super().post()
989class CreateView(TemplateResponseMixin, BaseCreateView):
990 """
991 View for creating a new object, with a response rendered by a template.
992 """
994 pass
997class BaseUpdateView(ModelFormMixin, ProcessFormView):
998 """
999 Base view for updating an existing object.
1001 Using this base class requires subclassing to provide a response mixin.
1002 """
1004 pk = None
1006 def get(self) -> Any:
1007 self.object = self.get_object()
1008 return super().get()
1010 def post(self) -> Any:
1011 self.object = self.get_object()
1012 return super().post()
1015class UpdateView(TemplateResponseMixin, BaseUpdateView):
1016 """
1017 View for updating an object, with a response rendered by a template.
1018 """
1020 pass
1023class BaseDeleteView(FormMixin, SingleObjectMixin, ProcessFormView):
1024 """
1025 Base view for deleting an object.
1027 Using this base class requires subclassing to provide a response mixin.
1028 """
1030 success_url = None
1032 def delete(self) -> None:
1033 """
1034 Delete the fetched object
1035 """
1036 self.request.dbsession.delete(self.object)
1038 def get(self) -> Response:
1039 """
1040 Handle GET requests: fetch the object from the database, and renders
1041 a form with its data.
1042 """
1043 self.object = self.get_object()
1044 context = self.get_context_data(object=self.object)
1045 # noinspection PyUnresolvedReferences
1046 return self.render_to_response(context)
1048 def post(self) -> Response:
1049 """
1050 Handle POST requests: instantiate a form instance with the passed
1051 POST variables and then check if it's valid.
1052 """
1053 self.object = self.get_object()
1054 return super().post()
1056 def form_valid_process_data(
1057 self, form: "Form", appstruct: Dict[str, Any]
1058 ) -> None:
1059 """
1060 Called when the form is valid.
1061 Deletes the associated model.
1062 """
1063 self.delete()
1064 super().form_valid_process_data(form, appstruct)
1066 # noinspection PyMethodMayBeStatic
1067 def get_form_values(self) -> Dict[str, Any]:
1068 # Docstring in superclass
1069 return {}
1072class DeleteView(TemplateResponseMixin, BaseDeleteView):
1073 """
1074 View for deleting an object retrieved with self.get_object(), with a
1075 response rendered by a template.
1076 """
1078 pass