Coverage for cc_modules/cc_view_classes.py : 43%

Hot-keys on this page
r m x p toggle line displays
j k next/prev highlighted chunk
0 (zero) top of page
1 (one) first highlighted chunk
1"""
2camcops_server/cc_modules/cc_view_classes.py
4===============================================================================
6 Copyright (C) 2012-2020 Rudolf Cardinal (rudolf@pobox.com).
8 This file is part of CamCOPS.
10 CamCOPS is free software: you can redistribute it and/or modify
11 it under the terms of the GNU General Public License as published by
12 the Free Software Foundation, either version 3 of the License, or
13 (at your option) any later version.
15 CamCOPS is distributed in the hope that it will be useful,
16 but WITHOUT ANY WARRANTY; without even the implied warranty of
17 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
18 GNU General Public License for more details.
20 You should have received a copy of the GNU General Public License
21 along with CamCOPS. If not, see <https://www.gnu.org/licenses/>.
23===============================================================================
25Django-style class-based views for Pyramid.
26Adapted from Django's ``views/generic/base.py`` and ``views/generic/edit.py``.
28Django has the following licence:
30.. code-block:: none
32 Copyright (c) Django Software Foundation and individual contributors.
33 All rights reserved.
35 Redistribution and use in source and binary forms, with or without
36 modification, are permitted provided that the following conditions are met:
38 1. Redistributions of source code must retain the above copyright
39 notice, this list of conditions and the following disclaimer.
41 2. Redistributions in binary form must reproduce the above copyright
42 notice, this list of conditions and the following disclaimer in the
43 documentation and/or other materials provided with the distribution.
45 3. Neither the name of Django nor the names of its contributors may be
46 used to endorse or promote products derived from this software
47 without specific prior written permission.
49 THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
50 AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
51 IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
52 ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE
53 LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
54 CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
55 SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
56 INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
57 CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
58 ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
59 POSSIBILITY OF SUCH DAMAGE.
61Custom views typically inherit from :class:`CreateView`, :class:`DeleteView` or
62:class:`UpdateView`.
64A Pyramid view function with a named route should create a view of the custom
65class, passing in the request, and return the results of its ``dispatch()``
66method. For example:
68.. code-block:: python
70 @view_config(route_name="edit_server_created_patient")
71 def edit_server_created_patient(req: Request) -> Response:
72 return EditServerCreatedPatientView(req).dispatch()
74To provide a custom view class to create a new object in the database:
76* Inherit from :class:`CreateView`.
77* Set the ``object_class`` property.
78* Set the ``form_class`` property.
79* Set the ``template_name`` property.
80* Override ``get_extra_context()`` for any extra parameters to pass to the
81 template.
82* Set ``success_url`` or override ``get_success_url()`` to be the redirect on
83 successful creation.
84* Override ``get_form_kwargs()`` for any extra parameters to pass to the form
85 constructor.
86* For simple views, set the ``model_form_dict`` property to be a mapping of
87 object properties to form parameters.
88* Override ``get_form_values()`` with any values additional to
89 ``model_form_dict`` to populate the form.
90* Override ``save_object()` to do anything more than a simple record save
91 (saving related objects, for example).
93To provide a custom view class to delete an object from the database:
95* Inherit from :class:`DeleteView`.
96* Set the ``object_class`` property.
97* Set the ``form_class`` property.
98* Set the ``template_name`` property.
99* Override ``get_extra_context()``. for any extra parameters to pass to the
100 template.
101* Set ``success_url`` or override ``get_success_url()`` to be the redirect on
102 successful creation.
103* Override ``get_form_kwargs()`` for any extra parameters to pass to the form
104 constructor.
105* Set the ``pk_param`` property to be the name of the parameter in the request
106 that holds the unique/primary key of the object to be deleted.
107* Set the ``server_pk_name`` property to be the name of the property on the
108 object that is the unique/primary key.
109* Override ``get_object()`` if the object cannot be retrieved with the above.
110* Override ``delete()`` to do anything more than a simple record delete; for
111 example, to delete dependant objects
113To provide a custom view class to update an object in the database:
115* Inherit from :class:`UpdateView`.
116* Set the ``object_class`` property.
117* Set the ``form_class`` property.
118* Set the ``template_name`` property.
119* Override ``get_extra_context()`` for any extra parameters to pass to the
120 template.
121* Set ``success_url`` or override ``get_success_url()`` to be the redirect on
122 successful creation.
123* Override ``get_form_kwargs()`` for any extra parameters to pass to the form
124 constructor.
125* Set the ``pk_param`` property to be the name of the parameter in the request
126 that holds the unique/primary key of the object to be updated.
127* Set the ``server_pk_name`` property to be the name of the property on the
128 object that is the unique/primary key.
129* Override ``get_object()`` if the object cannot be retrieved with the above.
130* For simple views, set the ``model_form_dict`` property to be a mapping of
131 object properties to form parameters.
132* Override ``save_object()`` to do anything more than a simple record save
133 (saving related objects, for example).
135You can use mixins for settings common to multiple views.
137.. note::
139 When we move to Python 3.8, there is ``typing.Protocol``, which allows
140 mixins to be type-checked properly. Currently we suppress warnings.
142Some examples are in ``webview.py``.
144"""
146from pyramid.httpexceptions import (
147 HTTPBadRequest,
148 HTTPFound,
149 HTTPMethodNotAllowed,
150)
151from pyramid.renderers import render_to_response
152from pyramid.response import Response
154import logging
155from typing import Any, Dict, List, NoReturn, Optional, Type, TYPE_CHECKING
157from cardinal_pythonlib.deform_utils import get_head_form_html
158from cardinal_pythonlib.logs import BraceStyleAdapter
159from deform.exception import ValidationFailure
161from camcops_server.cc_modules.cc_exception import raise_runtime_error
162from camcops_server.cc_modules.cc_pyramid import FormAction
163from camcops_server.cc_modules.cc_resource_registry import (
164 CamcopsResourceRegistry
165)
167if TYPE_CHECKING:
168 from deform.form import Form
169 from camcops_server.cc_modules.cc_request import CamcopsRequest
171log = BraceStyleAdapter(logging.getLogger(__name__))
174class ContextMixin(object):
175 """
176 A default context mixin that passes the keyword arguments received by
177 get_context_data() as the template context.
178 """
179 def get_extra_context(self) -> Dict[str, Any]:
180 """
181 Override to provide extra context, merged in by
182 :meth:`get_context_data`.
183 """
184 return {}
186 def get_context_data(self, **kwargs: Any) -> Dict[str, Any]:
187 """
188 Provides context for a template, including the ``view`` argument and
189 any additional context provided by :meth:`get_extra_context`.
190 """
191 kwargs.setdefault("view", self)
192 kwargs.update(self.get_extra_context())
194 return kwargs
197class View(object):
198 """
199 Simple parent class for all views
200 """
201 http_method_names = ["get", "post"]
203 def __init__(self, request: "CamcopsRequest") -> None:
204 """
205 Args:
206 request:
207 a :class:`camcops_server.cc_modules.cc_request.CamcopsRequest`
208 """
209 self.request = request
211 def dispatch(self) -> Response:
212 """
213 Try to dispatch to the right HTTP method (e.g. GET, POST). If a method
214 doesn't exist, defer to the error handler. Also defer to the error
215 handler if the request method isn't on the approved list.
216 """
217 handler = self.http_method_not_allowed
219 if self.request.method.lower() in self.http_method_names:
220 handler = getattr(self, self.request.method.lower(),
221 handler)
222 return handler()
224 def http_method_not_allowed(self) -> NoReturn:
225 """
226 Raise a :exc:`pyramid.httpexceptions.HTTPMethodNotAllowed` indicating
227 that the selected HTTP method is not allowed.
228 """
229 log.warning(
230 "Method Not Allowed (%s): %s",
231 self.request.method, self.request.path,
232 extra={"status_code": 405, "request": self.request}
233 )
234 raise HTTPMethodNotAllowed(
235 detail=f"Allowed methods: {self._allowed_methods}"
236 )
238 def _allowed_methods(self) -> List[str]:
239 """
240 Which HTTP methods are allowed? Returns a list of upper-case strings.
241 """
242 return [m.upper() for m in self.http_method_names if hasattr(self, m)]
245class TemplateResponseMixin(object):
246 """
247 A mixin that can be used to render a template.
248 """
249 request: "CamcopsRequest"
250 template_name: str = None
252 def render_to_response(self, context: Dict) -> Response:
253 """
254 Takes the supplied context, renders it through our specified template
255 (set by ``template_name``), and returns a
256 :class:`pyramid.response.Response`.
257 """
258 if self.template_name is None:
259 raise_runtime_error(f"No template_name set for {self.__class__}.")
261 return render_to_response(
262 self.template_name,
263 context,
264 request=self.request
265 )
268class FormMixin(ContextMixin):
269 """
270 Provide a way to show and handle a form in a request.
271 """
272 cancel_url = None
273 form_class: Type["Form"] = None
274 success_url = None
275 _form = None
276 _error = None
278 request: "CamcopsRequest"
280 def get_form_class(self) -> Optional[Type["Form"]]:
281 """
282 Return the form class to use.
283 """
284 return self.form_class
286 def get_form(self) -> "Form":
287 """
288 Return an instance of the form to be used in this view.
289 """
291 if self._form is None:
292 form_class = self.get_form_class()
293 if not form_class:
294 raise_runtime_error("Your view must provide a form_class.")
296 assert form_class is not None # type checker
298 self._form = form_class(**self.get_form_kwargs())
300 return self._form
302 def get_form_kwargs(self) -> Dict[str, Any]:
303 """
304 Return the keyword arguments for instantiating the form.
305 """
307 registry = CamcopsResourceRegistry()
309 kwargs = {
310 "request": self.request,
311 "resource_registry": registry,
312 }
314 return kwargs
316 def get_cancel_url(self) -> str:
317 """
318 Return the URL to redirect to when cancelling a form.
319 """
320 if not self.cancel_url:
321 return self.get_success_url()
322 return str(self.cancel_url) # cancel_url may be lazy
324 def get_success_url(self) -> str:
325 """
326 Return the URL to redirect to after processing a valid form.
327 """
328 if not self.success_url:
329 raise_runtime_error("Your view must provide a success_url.")
330 return str(self.success_url) # success_url may be lazy
332 def form_valid(self, form: "Form", appstruct: Dict[str, Any]) -> Response:
333 """
334 Called when the form is valid.
335 Redirects to the supplied "success" URL.
336 """
337 raise HTTPFound(self.get_success_url())
339 def form_invalid(self, validation_error: ValidationFailure) -> Response:
340 """
341 Called when the form is invalid.
342 Returns a response with a rendering of the invalid form.
343 """
344 self._error = validation_error
346 # noinspection PyUnresolvedReferences
347 return self.render_to_response(
348 self.get_context_data()
349 )
351 def get_context_data(self, **kwargs: Any) -> Dict[str, Any]:
352 """
353 Insert the rendered form (as HTML) into the context dict.
354 """
356 form = self.get_form()
357 kwargs["form"] = self.get_rendered_form()
358 kwargs["head_form_html"] = get_head_form_html(
359 self.request, [form]
360 )
362 return super().get_context_data(**kwargs)
364 def get_rendered_form(self) -> str:
365 """
366 Returns the form, rendered as HTML.
367 """
368 if self._error is not None:
369 return self._error.render()
371 form = self.get_form()
372 # noinspection PyUnresolvedReferences
373 appstruct = self.get_form_values()
375 return form.render(appstruct)
378class SingleObjectMixin(ContextMixin):
379 """
380 Represents a single ORM object, for use as a mixin.
381 """
382 object: Any
383 object_class: Optional[Type[Any]]
384 pk_param: str
385 request: "CamcopsRequest"
386 server_pk_name: str
388 def get_context_data(self, **kwargs: Any) -> Dict[str, Any]:
389 """
390 Insert the single object into the context dict.
391 """
392 context = {}
393 if self.object:
394 context["object"] = self.object
396 context.update(kwargs)
398 return super().get_context_data(**context)
400 def get_object(self) -> Any:
401 pk_value = self.request.get_int_param(self.pk_param)
403 if self.object_class is None:
404 raise_runtime_error("Your view must provide an object_class.")
406 pk_property = getattr(self.object_class, self.server_pk_name)
408 obj = self.request.dbsession.query(self.object_class).filter(
409 pk_property == pk_value
410 ).one_or_none()
412 if obj is None:
413 _ = self.request.gettext
415 assert self.object_class is not None # type checker
417 raise HTTPBadRequest(
418 _("Cannot find {object_class} with {server_pk_name}:{pk_value}").format( # noqa: E501
419 object_class=self.object_class.__name__,
420 server_pk_name=self.server_pk_name,
421 pk_value=pk_value
422 )
423 )
425 return obj
428class ModelFormMixin(FormMixin, SingleObjectMixin):
429 """
430 Represents an ORM object and an associated form.
431 """
432 object_class: Optional[Type[Any]] = None
434 model_form_dict: Dict
435 object: Any
436 request: "CamcopsRequest"
438 def form_valid(self, form: "Form", appstruct: Dict[str, Any]) -> Response:
439 """
440 Called when the form is valid.
441 Saves the associated model.
442 """
443 self.save_object(appstruct)
444 return super().form_valid(form, appstruct)
446 def save_object(self, appstruct: Dict[str, Any]) -> None:
447 """
448 Saves the object in the database, from data provided via the form.
449 """
450 if self.object is None:
451 if self.object_class is None:
452 raise_runtime_error("Your view must provide an object_class.")
454 assert self.object_class is not None # type checker
456 self.object = self.object_class()
458 self.set_object_properties(appstruct)
460 self.request.dbsession.add(self.object)
462 def set_object_properties(self, appstruct: Dict[str, Any]) -> None:
463 """
464 Sets properties of the object, from form data.
465 """
466 for (model_attr, form_param) in self.model_form_dict.items():
467 value = appstruct.get(form_param)
468 setattr(self.object, model_attr, value)
470 def get_form_values(self) -> Dict[str, Any]:
471 """
472 Reads form values from the object (or provides an empty dictionary if
473 there is no object yet).
474 """
475 form_values = {}
477 if self.object is not None:
478 for (model_attr, form_param) in self.model_form_dict.items():
479 value = getattr(self.object, model_attr)
481 # Not sure if this is a good idea. There may be legitimate
482 # reasons for keeping the value None here, but the view is
483 # likely to be overriding get_form_values() in that case.
484 # The alternative is we have to set all None string values
485 # to empty, in order to prevent the word None from appearing
486 # in text input fields.
487 if value is None:
488 value = ""
490 form_values[form_param] = value
492 return form_values
495class ProcessFormView(View):
496 """
497 Render a form on GET and processes it on POST.
498 """
499 def get(self) -> Response:
500 """
501 Handle GET requests: instantiate a blank version of the form.
502 """
503 # noinspection PyUnresolvedReferences
504 return self.render_to_response(self.get_context_data())
506 def post(self) -> Response:
507 """
508 Handle POST requests: instantiate a form instance with the passed
509 POST variables and then check if it's valid.
510 """
511 if FormAction.CANCEL in self.request.POST:
512 # noinspection PyUnresolvedReferences
513 raise HTTPFound(self.get_cancel_url())
515 # noinspection PyUnresolvedReferences
516 form = self.get_form()
518 controls = list(self.request.POST.items())
520 try:
521 appstruct = form.validate(controls)
523 # noinspection PyUnresolvedReferences
524 return self.form_valid(form, appstruct)
525 except ValidationFailure as e:
526 # noinspection PyUnresolvedReferences
527 return self.form_invalid(e)
530class BaseFormView(FormMixin, ProcessFormView):
531 """
532 A base view for displaying a form.
533 """
534 pass
537class FormView(TemplateResponseMixin, BaseFormView):
538 """
539 A view for displaying a form and rendering a template response.
540 """
541 pass
544class BaseCreateView(ModelFormMixin, ProcessFormView):
545 """
546 Base view for creating a new object instance.
548 Using this base class requires subclassing to provide a response mixin.
549 """
550 def get(self) -> Any:
551 self.object = None
552 return super().get()
554 def post(self) -> Any:
555 self.object = None
556 return super().post()
559class CreateView(TemplateResponseMixin, BaseCreateView):
560 """
561 View for creating a new object, with a response rendered by a template.
562 """
563 pass
566class BaseUpdateView(ModelFormMixin, ProcessFormView):
567 """
568 Base view for updating an existing object.
570 Using this base class requires subclassing to provide a response mixin.
571 """
572 pk = None
574 def get(self) -> Any:
575 self.object = self.get_object()
576 return super().get()
578 def post(self) -> Any:
579 self.object = self.get_object()
580 return super().post()
583class UpdateView(TemplateResponseMixin, BaseUpdateView):
584 """
585 View for updating an object, with a response rendered by a template.
586 """
587 pass
590class BaseDeleteView(FormMixin, SingleObjectMixin, View):
591 """
592 Base view for deleting an object.
594 Using this base class requires subclassing to provide a response mixin.
595 """
596 success_url = None
598 def delete(self) -> None:
599 """
600 Delete the fetched object
601 """
602 self.request.dbsession.delete(self.object)
604 def get(self) -> Response:
605 """
606 Handle GET requests: fetch the object from the database, and renders
607 a form with its data.
608 """
609 self.object = self.get_object()
610 context = self.get_context_data(object=self.object)
611 # noinspection PyUnresolvedReferences
612 return self.render_to_response(context)
614 def post(self) -> Response:
615 """
616 Handle POST requests: instantiate a form instance with the passed
617 POST variables and then check if it's valid.
618 """
619 self.object = self.get_object()
621 if FormAction.CANCEL in self.request.POST:
622 raise HTTPFound(self.get_cancel_url())
624 form = self.get_form()
625 controls = list(self.request.POST.items())
627 try:
628 appstruct = form.validate(controls)
630 return self.form_valid(form, appstruct)
631 except ValidationFailure as e:
632 return self.form_invalid(e)
634 def form_valid(self, form: "Form", appstruct: Dict[str, Any]) -> Response:
635 """
636 Called when the form is valid.
637 Deletes the associated model.
638 """
640 self.delete()
642 return super().form_valid(form, appstruct)
644 # noinspection PyMethodMayBeStatic
645 def get_form_values(self) -> Dict[str, Any]:
646 return {}
649class DeleteView(TemplateResponseMixin, BaseDeleteView):
650 """
651 View for deleting an object retrieved with self.get_object(), with a
652 response rendered by a template.
653 """
654 pass