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

1""" 

2camcops_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 

26Django-style class-based views for Pyramid. 

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

28 

29Django 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 

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

63:class:`UpdateView`. 

64 

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: 

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 

75To 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 

94To 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 

114To 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 

136You 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 

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

144 

145""" 

146 

147from pyramid.httpexceptions import ( 

148 HTTPBadRequest, 

149 HTTPFound, 

150 HTTPMethodNotAllowed, 

151) 

152from pyramid.renderers import render_to_response 

153from pyramid.response import Response 

154 

155import logging 

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

157 

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 

163 

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) 

169 

170if TYPE_CHECKING: 

171 from deform.form import Form 

172 from camcops_server.cc_modules.cc_request import CamcopsRequest 

173 

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

175 

176 

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

178# View 

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

180 

181 

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

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

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 

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

313class ProcessFormView( 

314 View, with_typehints(ContextMixin, TemplateResponseMixin) 

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 

428class FormMixin(ContextMixin, with_typehint(ProcessFormView)): 

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 

557class BaseFormView(FormMixin, ProcessFormView): 

558 """ 

559 A base view for displaying a form. 

560 """ 

561 

562 pass 

563 

564 

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

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 

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, **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. 

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

801 

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

803 # get a new form: 

804 return self.get() 

805 

806 # ------------------------------------------------------------------------- 

807 # Failure 

808 # ------------------------------------------------------------------------- 

809 

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" 

817 

818 

819# ============================================================================= 

820# ORM mixins 

821# ============================================================================= 

822 

823 

824class SingleObjectMixin(ContextMixin): 

825 """ 

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

827 """ 

828 

829 object: Any 

830 object_class: Optional[Type[Any]] 

831 pk_param: str 

832 request: "CamcopsRequest" 

833 server_pk_name: str 

834 

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 

842 

843 context.update(kwargs) 

844 

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

846 

847 def get_object(self) -> Any: 

848 """ 

849 Returns the ORM object being manipulated. 

850 """ 

851 pk_value = self.get_pk_value() 

852 

853 if self.object_class is None: 

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

855 

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

857 

858 obj = ( 

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

860 .filter(pk_property == pk_value) 

861 .one_or_none() 

862 ) 

863 

864 if obj is None: 

865 _ = self.request.gettext 

866 

867 assert self.object_class is not None # type checker 

868 

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 ) 

879 

880 return obj 

881 

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) 

887 

888 

889class ModelFormMixin(FormMixin, SingleObjectMixin): 

890 """ 

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

892 """ 

893 

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

895 

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

897 object: Any # the object being manipulated 

898 request: "CamcopsRequest" 

899 

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) 

909 

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

919 

920 self.set_object_properties(appstruct) 

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

922 

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 

929 

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 

942 

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

949 

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) 

953 

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 

963 

964 return form_values 

965 

966 

967# ============================================================================= 

968# Views involving forms and ORM objects 

969# ============================================================================= 

970 

971 

972class BaseCreateView(ModelFormMixin, ProcessFormView): 

973 """ 

974 Base view for creating a new object instance. 

975 

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

977 """ 

978 

979 def get(self) -> Any: 

980 self.object = None 

981 return super().get() 

982 

983 def post(self) -> Any: 

984 self.object = None 

985 return super().post() 

986 

987 

988class CreateView(TemplateResponseMixin, BaseCreateView): 

989 """ 

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

991 """ 

992 

993 pass 

994 

995 

996class BaseUpdateView(ModelFormMixin, ProcessFormView): 

997 """ 

998 Base view for updating an existing object. 

999 

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

1001 """ 

1002 

1003 pk = None 

1004 

1005 def get(self) -> Any: 

1006 self.object = self.get_object() 

1007 return super().get() 

1008 

1009 def post(self) -> Any: 

1010 self.object = self.get_object() 

1011 return super().post() 

1012 

1013 

1014class UpdateView(TemplateResponseMixin, BaseUpdateView): 

1015 """ 

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

1017 """ 

1018 

1019 pass 

1020 

1021 

1022class BaseDeleteView(FormMixin, SingleObjectMixin, ProcessFormView): 

1023 """ 

1024 Base view for deleting an object. 

1025 

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

1027 """ 

1028 

1029 success_url = None 

1030 

1031 def delete(self) -> None: 

1032 """ 

1033 Delete the fetched object 

1034 """ 

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

1036 

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) 

1046 

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

1054 

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) 

1064 

1065 # noinspection PyMethodMayBeStatic 

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

1067 # Docstring in superclass 

1068 return {} 

1069 

1070 

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

1076 

1077 pass