Coverage for cc_modules/cc_view_classes.py: 47%
280 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"""
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)
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)):
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)):
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, **kwargs) -> 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 def form_valid_response(
789 self, form: "Form", appstruct: Dict[str, Any]
790 ) -> Response:
791 """
792 Called when the form is submitted via POST and is valid.
793 Redirects to the supplied "success" URL.
794 """
795 if self.finished():
796 raise HTTPFound(self.get_success_url())
797 else:
798 # Try to keep this in POST -- fewer requests, but it also means
799 # that we can use GET to indicate the first in a sequence, and thus
800 # be able to clear stale state correctly.
802 # The "step" should have been changed, and that means that we will
803 # get a new form:
804 return self.get()
806 # -------------------------------------------------------------------------
807 # Failure
808 # -------------------------------------------------------------------------
810 def fail(self, message: str) -> NoReturn:
811 """
812 Raises a failure.
813 """
814 self.finish()
815 super().fail(message) # will raise
816 assert False, "Bug: FormWizardMixin.fail() falling through"
819# =============================================================================
820# ORM mixins
821# =============================================================================
824class SingleObjectMixin(ContextMixin):
825 """
826 Represents a single ORM object, for use as a mixin.
827 """
829 object: Any
830 object_class: Optional[Type[Any]]
831 pk_param: str
832 request: "CamcopsRequest"
833 server_pk_name: str
835 def get_context_data(self, **kwargs: Any) -> Dict[str, Any]:
836 """
837 Insert the single object into the context dict.
838 """
839 context = {}
840 if self.object:
841 context["object"] = self.object
843 context.update(kwargs)
845 return super().get_context_data(**context)
847 def get_object(self) -> Any:
848 """
849 Returns the ORM object being manipulated.
850 """
851 pk_value = self.get_pk_value()
853 if self.object_class is None:
854 raise_runtime_error("Your view must provide an object_class.")
856 pk_property = getattr(self.object_class, self.server_pk_name)
858 obj = (
859 self.request.dbsession.query(self.object_class)
860 .filter(pk_property == pk_value)
861 .one_or_none()
862 )
864 if obj is None:
865 _ = self.request.gettext
867 assert self.object_class is not None # type checker
869 raise HTTPBadRequest(
870 _(
871 "Cannot find {object_class} with "
872 "{server_pk_name}:{pk_value}"
873 ).format(
874 object_class=self.object_class.__name__,
875 server_pk_name=self.server_pk_name,
876 pk_value=pk_value,
877 )
878 )
880 return obj
882 def get_pk_value(self) -> int:
883 """
884 Returns the integer primary key of the object.
885 """
886 return self.request.get_int_param(self.pk_param)
889class ModelFormMixin(FormMixin, SingleObjectMixin):
890 """
891 Represents an ORM object (the model) and an associated form.
892 """
894 object_class: Optional[Type[Any]] = None
896 model_form_dict: Dict # maps model attribute name to form param name
897 object: Any # the object being manipulated
898 request: "CamcopsRequest"
900 def form_valid_process_data(
901 self, form: "Form", appstruct: Dict[str, Any]
902 ) -> None:
903 """
904 Called when the form is valid.
905 Saves the associated model.
906 """
907 self.save_object(appstruct)
908 super().form_valid_process_data(form, appstruct)
910 def save_object(self, appstruct: Dict[str, Any]) -> None:
911 """
912 Saves the object in the database, from data provided via the form.
913 """
914 if self.object is None:
915 if self.object_class is None:
916 raise_runtime_error("Your view must provide an object_class.")
917 assert self.object_class is not None # type checker
918 self.object = self.object_class()
920 self.set_object_properties(appstruct)
921 self.request.dbsession.add(self.object)
923 def get_model_form_dict(self) -> Dict[str, str]:
924 """
925 Returns the dictionary mapping model attribute names to form parameter
926 names.
927 """
928 return self.model_form_dict
930 def set_object_properties(self, appstruct: Dict[str, Any]) -> None:
931 """
932 Sets properties of the object, from form data.
933 """
934 # No need to call superclass method; this is the top level.
935 for (model_attr, form_param) in self.get_model_form_dict().items():
936 try:
937 value = appstruct[form_param]
938 setattr(self.object, model_attr, value)
939 except KeyError:
940 # Value may have been removed from appstruct: don't change
941 pass
943 def get_form_values(self) -> Dict[str, Any]:
944 """
945 Reads form values from the object (or provides an empty dictionary if
946 there is no object yet). Returns a form dictionary.
947 """
948 form_values = {}
950 if self.object is not None:
951 for (model_attr, form_param) in self.get_model_form_dict().items():
952 value = getattr(self.object, model_attr)
954 # Not sure if this is a good idea. There may be legitimate
955 # reasons for keeping the value None here, but the view is
956 # likely to be overriding get_form_values() in that case.
957 # The alternative is we have to set all None string values
958 # to empty, in order to prevent the word None from appearing
959 # in text input fields.
960 if value is None:
961 value = ""
962 form_values[form_param] = value
964 return form_values
967# =============================================================================
968# Views involving forms and ORM objects
969# =============================================================================
972class BaseCreateView(ModelFormMixin, ProcessFormView):
973 """
974 Base view for creating a new object instance.
976 Using this base class requires subclassing to provide a response mixin.
977 """
979 def get(self) -> Any:
980 self.object = None
981 return super().get()
983 def post(self) -> Any:
984 self.object = None
985 return super().post()
988class CreateView(TemplateResponseMixin, BaseCreateView):
989 """
990 View for creating a new object, with a response rendered by a template.
991 """
993 pass
996class BaseUpdateView(ModelFormMixin, ProcessFormView):
997 """
998 Base view for updating an existing object.
1000 Using this base class requires subclassing to provide a response mixin.
1001 """
1003 pk = None
1005 def get(self) -> Any:
1006 self.object = self.get_object()
1007 return super().get()
1009 def post(self) -> Any:
1010 self.object = self.get_object()
1011 return super().post()
1014class UpdateView(TemplateResponseMixin, BaseUpdateView):
1015 """
1016 View for updating an object, with a response rendered by a template.
1017 """
1019 pass
1022class BaseDeleteView(FormMixin, SingleObjectMixin, ProcessFormView):
1023 """
1024 Base view for deleting an object.
1026 Using this base class requires subclassing to provide a response mixin.
1027 """
1029 success_url = None
1031 def delete(self) -> None:
1032 """
1033 Delete the fetched object
1034 """
1035 self.request.dbsession.delete(self.object)
1037 def get(self) -> Response:
1038 """
1039 Handle GET requests: fetch the object from the database, and renders
1040 a form with its data.
1041 """
1042 self.object = self.get_object()
1043 context = self.get_context_data(object=self.object)
1044 # noinspection PyUnresolvedReferences
1045 return self.render_to_response(context)
1047 def post(self) -> Response:
1048 """
1049 Handle POST requests: instantiate a form instance with the passed
1050 POST variables and then check if it's valid.
1051 """
1052 self.object = self.get_object()
1053 return super().post()
1055 def form_valid_process_data(
1056 self, form: "Form", appstruct: Dict[str, Any]
1057 ) -> None:
1058 """
1059 Called when the form is valid.
1060 Deletes the associated model.
1061 """
1062 self.delete()
1063 super().form_valid_process_data(form, appstruct)
1065 # noinspection PyMethodMayBeStatic
1066 def get_form_values(self) -> Dict[str, Any]:
1067 # Docstring in superclass
1068 return {}
1071class DeleteView(TemplateResponseMixin, BaseDeleteView):
1072 """
1073 View for deleting an object retrieved with self.get_object(), with a
1074 response rendered by a template.
1075 """
1077 pass