Coverage for cc_modules/cc_view_classes.py: 47%

1"""

2 camcops_server/cc_modules/cc_view_classes.py

3

4===============================================================================

5

6 Copyright (C) 2012, University of Cambridge, Department of Psychiatry.

7 Created by Rudolf Cardinal (rnc1001@cam.ac.uk).

8

9 This file is part of CamCOPS.

10

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.

15

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.

20

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/>.

23

24===============================================================================

25

26 Django-style class-based views for Pyramid.

27 Adapted from Django's ``views/generic/base.py`` and ``views/generic/edit.py``.

28

29 Django has the following licence:

30

31.. code-block:: none

32

33 Copyright (c) Django Software Foundation and individual contributors.

34 All rights reserved.

35

36 Redistribution and use in source and binary forms, with or without

37 modification, are permitted provided that the following conditions are met:

38

39 1. Redistributions of source code must retain the above copyright

40 notice, this list of conditions and the following disclaimer.

41

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.

45

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.

49

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.

61

62 Custom views typically inherit from :class:`CreateView`, :class:`DeleteView` or

63:class:`UpdateView`.

64

65 A Pyramid view function with a named route should create a view of the custom

66 class, passing in the request, and return the results of its ``dispatch()``

67 method. For example:

68

69.. code-block:: python

70

71 @view_config(route_name="edit_server_created_patient")

72 def edit_server_created_patient(req: Request) -> Response:

73 return EditServerCreatedPatientView(req).dispatch()

74

75 To provide a custom view class to create a new object in the database:

76

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).

93

94 To provide a custom view class to delete an object from the database:

95

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

113

114 To provide a custom view class to update an object in the database:

115

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).

135

136 You can use mixins for settings common to multiple views.

137

138.. note::

139

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.

142

143 Some examples are in ``webview.py``.

144

145"""

146

147 from pyramid.httpexceptions import (

148 HTTPBadRequest,

149 HTTPFound,

150 HTTPMethodNotAllowed,

151)

152 from pyramid.renderers import render_to_response

153 from pyramid.response import Response

154

155 import logging

156 from typing import Any, Dict, List, NoReturn, Optional, Type, TYPE_CHECKING

157

158 from cardinal_pythonlib.deform_utils import get_head_form_html

159 from cardinal_pythonlib.httpconst import HttpMethod, HttpStatus

160 from cardinal_pythonlib.logs import BraceStyleAdapter

161 from cardinal_pythonlib.typing_helpers import with_typehint, with_typehints

162 from deform.exception import ValidationFailure

163

164 from camcops_server.cc_modules.cc_exception import raise_runtime_error

165 from camcops_server.cc_modules.cc_pyramid import FlashQueue, FormAction

166 from camcops_server.cc_modules.cc_resource_registry import (

167 CamcopsResourceRegistry,

168)

169

170 if TYPE_CHECKING:

171 from deform.form import Form

172 from camcops_server.cc_modules.cc_request import CamcopsRequest

173

174 log = BraceStyleAdapter(logging.getLogger(__name__))

175

176

177# =============================================================================

178# View

179# =============================================================================

180

181

182 class View(object):

183 """

184 Simple parent class for all views. Owns the request object and provides a

185 dispatcher for HTTP requests.

186

187 Derived classes typically implement ``get()`` and ``post()``.

188 """

189

190 http_method_names = [HttpMethod.GET.lower(), HttpMethod.POST.lower()]

191

192 # -------------------------------------------------------------------------

193 # Creation

194 # -------------------------------------------------------------------------

195

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

203

204 # -------------------------------------------------------------------------

205 # Dispatching GET and POST requests

206 # -------------------------------------------------------------------------

207

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.

213

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()

222

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 )

240

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)]

246

247

248# =============================================================================

249# Basic mixins

250# =============================================================================

251

252

253 class ContextMixin(object):

254 """

255 A default context mixin that passes the keyword arguments received by

256 get_context_data() as the template context.

257 """

258

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 {}

265

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())

273

274 return kwargs

275

276

277 class TemplateResponseMixin(object):

278 """

279 A mixin that can be used to render a Mako template.

280 """

281

282 request: "CamcopsRequest"

283 template_name: str = None

284

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 )

294

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 )

304

305 return self.template_name

306

307

308# =============================================================================

309# Form views

310# =============================================================================

311

312

313 class 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.

318

319 Requires ContextMixin.

320 """

321

322 # -------------------------------------------------------------------------

323 # GET and POST handlers

324 # -------------------------------------------------------------------------

325

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())

333

334 def post(self) -> Response:

335 """

336 Handle POST requests:

337

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

344

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())

352

353 # noinspection PyUnresolvedReferences

354 form = self.get_form()

355 controls = list(self.request.POST.items())

356

357 try:

358 appstruct = form.validate(controls)

359

360 # noinspection PyUnresolvedReferences

361 return self.form_valid(form, appstruct)

362 except ValidationFailure as e:

363 # e.error.asdict() will reveal more

364

365 # noinspection PyUnresolvedReferences

366 return self.form_invalid(e)

367

368 # -------------------------------------------------------------------------

369 # Cancellation

370 # -------------------------------------------------------------------------

371

372 def get_cancel_url(self) -> str:

373 """

374 Return the URL to redirect to when cancelling a form.

375 """

376 raise NotImplementedError

377

378 # -------------------------------------------------------------------------

379 # Processing valid and invalid forms on POST

380 # -------------------------------------------------------------------------

381

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:

386

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)

394

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.

400

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

405

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

414

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

421

422

423# =============================================================================

424# Form mixin

425# =============================================================================

426

427

428 class 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 """

432

433 cancel_url = None

434 form_class: Type["Form"] = None

435 success_url = None

436 failure_url = None

437 _form = None

438 _error = None

439

440 request: "CamcopsRequest"

441

442 # -------------------------------------------------------------------------

443 # Creating the form

444 # -------------------------------------------------------------------------

445

446 def get_form_class(self) -> Optional[Type["Form"]]:

447 """

448 Return the form class to use.

449 """

450 return self.form_class

451

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

460

461 return form_class(**self.get_form_kwargs())

462

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 }

471

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()

478

479 # noinspection PyUnresolvedReferences

480 appstruct = self.get_form_values()

481 return form.render(appstruct)

482

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)

491

492 # -------------------------------------------------------------------------

493 # Destination URLs

494 # -------------------------------------------------------------------------

495

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

503

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

511

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

520

521 # -------------------------------------------------------------------------

522 # Handling valid/invalid forms

523 # -------------------------------------------------------------------------

524

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())

534

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

541

542 # noinspection PyUnresolvedReferences

543 return self.render_to_response(self.get_context_data())

544

545 # -------------------------------------------------------------------------

546 # Helper methods

547 # -------------------------------------------------------------------------

548

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())

555

556

557 class BaseFormView(FormMixin, ProcessFormView):

558 """

559 A base view for displaying a form.

560 """

561

562 pass

563

564

565 class FormView(TemplateResponseMixin, BaseFormView):

566 """

567 A view for displaying a form and rendering a template response.

568 """

569

570 pass

571

572

573# =============================================================================

574# Multi-step forms

575# =============================================================================

576

577

578 class 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

583

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:

587

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.

591

592 Views using this Mixin should implement:

593

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

598

599 Alternatively, subclasses can override ``get_first_step()`` etc.

600

601 The logic of changing steps is left to the subclass.

602 """

603

604 PARAM_FINISHED = "finished"

605 PARAM_STEP = "step"

606 PARAM_ROUTE_NAME = "route_name"

607

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]] = {}

612

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.

620

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.)

625

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).

629

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

634

635 # Make sure we save any changes to the form state

636 self.request.dbsession.add(self.request.camcops_session)

637

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()

646

647 # -------------------------------------------------------------------------

648 # State

649 # -------------------------------------------------------------------------

650

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()

658

659 return self.request.camcops_session.form_state

660

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

667

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 }

679

680 # -------------------------------------------------------------------------

681 # Step (an aspect of state)

682 # -------------------------------------------------------------------------

683

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

691

692 @step.setter

693 def step(self, step: str) -> None:

694 """

695 Sets the current step.

696 """

697 self.state[self.PARAM_STEP] = step

698

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

704

705 # -------------------------------------------------------------------------

706 # Finishing (an aspect of state)

707 # -------------------------------------------------------------------------

708

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 }

720

721 def finished(self) -> bool:

722 """

723 Have we finished?

724 """

725 return self.state.get(self.PARAM_FINISHED, False)

726

727 # -------------------------------------------------------------------------

728 # Routes (an aspect of state)

729 # -------------------------------------------------------------------------

730

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.

736

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 ""

743

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 )

752

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

759

760 # -------------------------------------------------------------------------

761 # Step-specific information

762 # -------------------------------------------------------------------------

763

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]

770

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]

776

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]

783

784 # -------------------------------------------------------------------------

785 # Success

786 # -------------------------------------------------------------------------

787

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.

802

803 # The "step" should have been changed, and that means that we will

804 # get a new form:

805 return self.get()

806

807 # -------------------------------------------------------------------------

808 # Failure

809 # -------------------------------------------------------------------------

810

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"

818

819

820# =============================================================================

821# ORM mixins

822# =============================================================================

823

824

825 class SingleObjectMixin(ContextMixin):

826 """

827 Represents a single ORM object, for use as a mixin.

828 """

829

830 object: Any

831 object_class: Optional[Type[Any]]

832 pk_param: str

833 request: "CamcopsRequest"

834 server_pk_name: str

835

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

843

844 context.update(kwargs)

845

846 return super().get_context_data(**context)

847

848 def get_object(self) -> Any:

849 """

850 Returns the ORM object being manipulated.

851 """

852 pk_value = self.get_pk_value()

853

854 if self.object_class is None:

855 raise_runtime_error("Your view must provide an object_class.")

856

857 pk_property = getattr(self.object_class, self.server_pk_name)

858

859 obj = (

860 self.request.dbsession.query(self.object_class)

861 .filter(pk_property == pk_value)

862 .one_or_none()

863 )

864

865 if obj is None:

866 _ = self.request.gettext

867

868 assert self.object_class is not None # type checker

869

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 )

880

881 return obj

882

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)

888

889

890 class ModelFormMixin(FormMixin, SingleObjectMixin):

891 """

892 Represents an ORM object (the model) and an associated form.

893 """

894

895 object_class: Optional[Type[Any]] = None

896

897 model_form_dict: Dict # maps model attribute name to form param name

898 object: Any # the object being manipulated

899 request: "CamcopsRequest"

900

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)

910

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()

920

921 self.set_object_properties(appstruct)

922 self.request.dbsession.add(self.object)

923

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

930

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

943

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 = {}

950

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)

954

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

964

965 return form_values

966

967

968# =============================================================================

969# Views involving forms and ORM objects

970# =============================================================================

971

972

973 class BaseCreateView(ModelFormMixin, ProcessFormView):

974 """

975 Base view for creating a new object instance.

976

977 Using this base class requires subclassing to provide a response mixin.

978 """

979

980 def get(self) -> Any:

981 self.object = None

982 return super().get()

983

984 def post(self) -> Any:

985 self.object = None

986 return super().post()

987

988

989 class CreateView(TemplateResponseMixin, BaseCreateView):

990 """

991 View for creating a new object, with a response rendered by a template.

992 """

993

994 pass

995

996

997 class BaseUpdateView(ModelFormMixin, ProcessFormView):

998 """

999 Base view for updating an existing object.

1000

1001 Using this base class requires subclassing to provide a response mixin.

1002 """

1003

1004 pk = None

1005

1006 def get(self) -> Any:

1007 self.object = self.get_object()

1008 return super().get()

1009

1010 def post(self) -> Any:

1011 self.object = self.get_object()

1012 return super().post()

1013

1014

1015 class UpdateView(TemplateResponseMixin, BaseUpdateView):

1016 """

1017 View for updating an object, with a response rendered by a template.

1018 """

1019

1020 pass

1021

1022

1023 class BaseDeleteView(FormMixin, SingleObjectMixin, ProcessFormView):

1024 """

1025 Base view for deleting an object.

1026

1027 Using this base class requires subclassing to provide a response mixin.

1028 """

1029

1030 success_url = None

1031

1032 def delete(self) -> None:

1033 """

1034 Delete the fetched object

1035 """

1036 self.request.dbsession.delete(self.object)

1037

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)

1047

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()

1055

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)

1065

1066 # noinspection PyMethodMayBeStatic

1067 def get_form_values(self) -> Dict[str, Any]:

1068 # Docstring in superclass

1069 return {}

1070

1071

1072 class DeleteView(TemplateResponseMixin, BaseDeleteView):

1073 """

1074 View for deleting an object retrieved with self.get_object(), with a

1075 response rendered by a template.

1076 """

1077

1078 pass