Hide keyboard shortcuts

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 

3 

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

5 

6 Copyright (C) 2012-2020 Rudolf Cardinal (rudolf@pobox.com). 

7 

8 This file is part of CamCOPS. 

9 

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. 

14 

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. 

19 

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

22 

23=============================================================================== 

24 

25Django-style class-based views for Pyramid. 

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

27 

28Django has the following licence: 

29 

30.. code-block:: none 

31 

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

33 All rights reserved. 

34 

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

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

37 

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

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

40 

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. 

44 

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. 

48 

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. 

60 

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

62:class:`UpdateView`. 

63 

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: 

67 

68.. code-block:: python 

69 

70 @view_config(route_name="edit_server_created_patient") 

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

72 return EditServerCreatedPatientView(req).dispatch() 

73 

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

75 

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

92 

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

94 

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 

112 

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

114 

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

134 

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

136 

137.. note:: 

138 

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. 

141 

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

143 

144""" 

145 

146from pyramid.httpexceptions import ( 

147 HTTPBadRequest, 

148 HTTPFound, 

149 HTTPMethodNotAllowed, 

150) 

151from pyramid.renderers import render_to_response 

152from pyramid.response import Response 

153 

154import logging 

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

156 

157from cardinal_pythonlib.deform_utils import get_head_form_html 

158from cardinal_pythonlib.logs import BraceStyleAdapter 

159from deform.exception import ValidationFailure 

160 

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) 

166 

167if TYPE_CHECKING: 

168 from deform.form import Form 

169 from camcops_server.cc_modules.cc_request import CamcopsRequest 

170 

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

172 

173 

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

185 

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

193 

194 return kwargs 

195 

196 

197class View(object): 

198 """ 

199 Simple parent class for all views 

200 """ 

201 http_method_names = ["get", "post"] 

202 

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 

210 

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 

218 

219 if self.request.method.lower() in self.http_method_names: 

220 handler = getattr(self, self.request.method.lower(), 

221 handler) 

222 return handler() 

223 

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 ) 

237 

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

243 

244 

245class TemplateResponseMixin(object): 

246 """ 

247 A mixin that can be used to render a template. 

248 """ 

249 request: "CamcopsRequest" 

250 template_name: str = None 

251 

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__}.") 

260 

261 return render_to_response( 

262 self.template_name, 

263 context, 

264 request=self.request 

265 ) 

266 

267 

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 

277 

278 request: "CamcopsRequest" 

279 

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

281 """ 

282 Return the form class to use. 

283 """ 

284 return self.form_class 

285 

286 def get_form(self) -> "Form": 

287 """ 

288 Return an instance of the form to be used in this view. 

289 """ 

290 

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

295 

296 assert form_class is not None # type checker 

297 

298 self._form = form_class(**self.get_form_kwargs()) 

299 

300 return self._form 

301 

302 def get_form_kwargs(self) -> Dict[str, Any]: 

303 """ 

304 Return the keyword arguments for instantiating the form. 

305 """ 

306 

307 registry = CamcopsResourceRegistry() 

308 

309 kwargs = { 

310 "request": self.request, 

311 "resource_registry": registry, 

312 } 

313 

314 return kwargs 

315 

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 

323 

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 

331 

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

338 

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 

345 

346 # noinspection PyUnresolvedReferences 

347 return self.render_to_response( 

348 self.get_context_data() 

349 ) 

350 

351 def get_context_data(self, **kwargs: Any) -> Dict[str, Any]: 

352 """ 

353 Insert the rendered form (as HTML) into the context dict. 

354 """ 

355 

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 ) 

361 

362 return super().get_context_data(**kwargs) 

363 

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

370 

371 form = self.get_form() 

372 # noinspection PyUnresolvedReferences 

373 appstruct = self.get_form_values() 

374 

375 return form.render(appstruct) 

376 

377 

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 

387 

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 

395 

396 context.update(kwargs) 

397 

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

399 

400 def get_object(self) -> Any: 

401 pk_value = self.request.get_int_param(self.pk_param) 

402 

403 if self.object_class is None: 

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

405 

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

407 

408 obj = self.request.dbsession.query(self.object_class).filter( 

409 pk_property == pk_value 

410 ).one_or_none() 

411 

412 if obj is None: 

413 _ = self.request.gettext 

414 

415 assert self.object_class is not None # type checker 

416 

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 ) 

424 

425 return obj 

426 

427 

428class ModelFormMixin(FormMixin, SingleObjectMixin): 

429 """ 

430 Represents an ORM object and an associated form. 

431 """ 

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

433 

434 model_form_dict: Dict 

435 object: Any 

436 request: "CamcopsRequest" 

437 

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) 

445 

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

453 

454 assert self.object_class is not None # type checker 

455 

456 self.object = self.object_class() 

457 

458 self.set_object_properties(appstruct) 

459 

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

461 

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) 

469 

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

476 

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) 

480 

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

489 

490 form_values[form_param] = value 

491 

492 return form_values 

493 

494 

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

505 

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

514 

515 # noinspection PyUnresolvedReferences 

516 form = self.get_form() 

517 

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

519 

520 try: 

521 appstruct = form.validate(controls) 

522 

523 # noinspection PyUnresolvedReferences 

524 return self.form_valid(form, appstruct) 

525 except ValidationFailure as e: 

526 # noinspection PyUnresolvedReferences 

527 return self.form_invalid(e) 

528 

529 

530class BaseFormView(FormMixin, ProcessFormView): 

531 """ 

532 A base view for displaying a form. 

533 """ 

534 pass 

535 

536 

537class FormView(TemplateResponseMixin, BaseFormView): 

538 """ 

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

540 """ 

541 pass 

542 

543 

544class BaseCreateView(ModelFormMixin, ProcessFormView): 

545 """ 

546 Base view for creating a new object instance. 

547 

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

553 

554 def post(self) -> Any: 

555 self.object = None 

556 return super().post() 

557 

558 

559class CreateView(TemplateResponseMixin, BaseCreateView): 

560 """ 

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

562 """ 

563 pass 

564 

565 

566class BaseUpdateView(ModelFormMixin, ProcessFormView): 

567 """ 

568 Base view for updating an existing object. 

569 

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

571 """ 

572 pk = None 

573 

574 def get(self) -> Any: 

575 self.object = self.get_object() 

576 return super().get() 

577 

578 def post(self) -> Any: 

579 self.object = self.get_object() 

580 return super().post() 

581 

582 

583class UpdateView(TemplateResponseMixin, BaseUpdateView): 

584 """ 

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

586 """ 

587 pass 

588 

589 

590class BaseDeleteView(FormMixin, SingleObjectMixin, View): 

591 """ 

592 Base view for deleting an object. 

593 

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

595 """ 

596 success_url = None 

597 

598 def delete(self) -> None: 

599 """ 

600 Delete the fetched object 

601 """ 

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

603 

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) 

613 

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

620 

621 if FormAction.CANCEL in self.request.POST: 

622 raise HTTPFound(self.get_cancel_url()) 

623 

624 form = self.get_form() 

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

626 

627 try: 

628 appstruct = form.validate(controls) 

629 

630 return self.form_valid(form, appstruct) 

631 except ValidationFailure as e: 

632 return self.form_invalid(e) 

633 

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

639 

640 self.delete() 

641 

642 return super().form_valid(form, appstruct) 

643 

644 # noinspection PyMethodMayBeStatic 

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

646 return {} 

647 

648 

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