Coverage for .tox/coverage/lib/python3.11/site-packages/wuttaweb/forms/widgets.py: 100%

187 statements  

« prev     ^ index     » next       coverage.py v7.6.10, created at 2025-01-15 08:54 -0600

1# -*- coding: utf-8; -*- 

2################################################################################ 

3# 

4# wuttaweb -- Web App for Wutta Framework 

5# Copyright © 2024 Lance Edgar 

6# 

7# This file is part of Wutta Framework. 

8# 

9# Wutta Framework is free software: you can redistribute it and/or modify it 

10# under the terms of the GNU General Public License as published by the Free 

11# Software Foundation, either version 3 of the License, or (at your option) any 

12# later version. 

13# 

14# Wutta Framework is distributed in the hope that it will be useful, but 

15# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or 

16# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for 

17# more details. 

18# 

19# You should have received a copy of the GNU General Public License along with 

20# Wutta Framework. If not, see <http://www.gnu.org/licenses/>. 

21# 

22################################################################################ 

23""" 

24Form widgets 

25 

26This module defines some custom widgets for use with WuttaWeb. 

27 

28However for convenience it also makes other Deform widgets available 

29in the namespace: 

30 

31* :class:`deform:deform.widget.Widget` (base class) 

32* :class:`deform:deform.widget.TextInputWidget` 

33* :class:`deform:deform.widget.TextAreaWidget` 

34* :class:`deform:deform.widget.PasswordWidget` 

35* :class:`deform:deform.widget.CheckedPasswordWidget` 

36* :class:`deform:deform.widget.CheckboxWidget` 

37* :class:`deform:deform.widget.SelectWidget` 

38* :class:`deform:deform.widget.CheckboxChoiceWidget` 

39* :class:`deform:deform.widget.DateInputWidget` 

40* :class:`deform:deform.widget.DateTimeInputWidget` 

41* :class:`deform:deform.widget.MoneyInputWidget` 

42""" 

43 

44import datetime 

45import decimal 

46import os 

47 

48import colander 

49import humanize 

50from deform.widget import (Widget, TextInputWidget, TextAreaWidget, 

51 PasswordWidget, CheckedPasswordWidget, 

52 CheckboxWidget, SelectWidget, CheckboxChoiceWidget, 

53 DateInputWidget, DateTimeInputWidget, MoneyInputWidget) 

54from webhelpers2.html import HTML 

55 

56from wuttjamaican.conf import parse_list 

57 

58from wuttaweb.db import Session 

59from wuttaweb.grids import Grid 

60 

61 

62class ObjectRefWidget(SelectWidget): 

63 """ 

64 Widget for use with model "object reference" fields, e.g. foreign 

65 key UUID => TargetModel instance. 

66 

67 While you may create instances of this widget directly, it 

68 normally happens automatically when schema nodes of the 

69 :class:`~wuttaweb.forms.schema.ObjectRef` (sub)type are part of 

70 the form schema; via 

71 :meth:`~wuttaweb.forms.schema.ObjectRef.widget_maker()`. 

72 

73 In readonly mode, this renders a ``<span>`` tag around the 

74 :attr:`model_instance` (converted to string). 

75 

76 Otherwise it renders a select (dropdown) element allowing user to 

77 choose from available records. 

78 

79 This is a subclass of :class:`deform:deform.widget.SelectWidget` 

80 and uses these Deform templates: 

81 

82 * ``select`` 

83 * ``readonly/objectref`` 

84 

85 .. attribute:: model_instance 

86 

87 Reference to the model record instance, i.e. the "far side" of 

88 the foreign key relationship. 

89 

90 .. note:: 

91 

92 You do not need to provide the ``model_instance`` when 

93 constructing the widget. Rather, it is set automatically 

94 when the :class:`~wuttaweb.forms.schema.ObjectRef` type 

95 instance (associated with the node) is serialized. 

96 """ 

97 readonly_template = 'readonly/objectref' 

98 

99 def __init__(self, request, url=None, *args, **kwargs): 

100 super().__init__(*args, **kwargs) 

101 self.request = request 

102 self.url = url 

103 

104 def get_template_values(self, field, cstruct, kw): 

105 """ """ 

106 values = super().get_template_values(field, cstruct, kw) 

107 

108 # add url, only if rendering readonly 

109 readonly = kw.get('readonly', self.readonly) 

110 if readonly: 

111 if 'url' not in values and self.url and getattr(field.schema, 'model_instance', None): 

112 values['url'] = self.url(field.schema.model_instance) 

113 

114 return values 

115 

116 

117class NotesWidget(TextAreaWidget): 

118 """ 

119 Widget for use with "notes" fields. 

120 

121 In readonly mode, this shows the notes with a background to make 

122 them stand out a bit more. 

123 

124 Otherwise it effectively shows a ``<textarea>`` input element. 

125 

126 This is a subclass of :class:`deform:deform.widget.TextAreaWidget` 

127 and uses these Deform templates: 

128 

129 * ``textarea`` 

130 * ``readonly/notes`` 

131 """ 

132 readonly_template = 'readonly/notes' 

133 

134 

135class WuttaCheckboxChoiceWidget(CheckboxChoiceWidget): 

136 """ 

137 Custom widget for :class:`python:set` fields. 

138 

139 This is a subclass of 

140 :class:`deform:deform.widget.CheckboxChoiceWidget`. 

141 

142 :param request: Current :term:`request` object. 

143 

144 It uses these Deform templates: 

145 

146 * ``checkbox_choice`` 

147 * ``readonly/checkbox_choice`` 

148 """ 

149 

150 def __init__(self, request, *args, **kwargs): 

151 super().__init__(*args, **kwargs) 

152 self.request = request 

153 self.config = self.request.wutta_config 

154 self.app = self.config.get_app() 

155 

156 

157class WuttaDateWidget(DateInputWidget): 

158 """ 

159 Custom widget for :class:`python:datetime.date` fields. 

160 

161 The main purpose of this widget is to leverage 

162 :meth:`~wuttjamaican:wuttjamaican.app.AppHandler.render_date()` 

163 for the readonly display. 

164 

165 It is automatically used for SQLAlchemy mapped classes where the 

166 field maps to a :class:`sqlalchemy:sqlalchemy.types.Date` column. 

167 For other (non-mapped) date fields, or mapped datetime fields for 

168 which a date widget is preferred, use 

169 :meth:`~wuttaweb.forms.base.Form.set_widget()`. 

170 

171 This is a subclass of 

172 :class:`deform:deform.widget.DateInputWidget` and uses these 

173 Deform templates: 

174 

175 * ``dateinput`` 

176 """ 

177 

178 def __init__(self, request, *args, **kwargs): 

179 super().__init__(*args, **kwargs) 

180 self.request = request 

181 self.config = self.request.wutta_config 

182 self.app = self.config.get_app() 

183 

184 def serialize(self, field, cstruct, **kw): 

185 """ """ 

186 readonly = kw.get('readonly', self.readonly) 

187 if readonly and cstruct: 

188 dt = datetime.datetime.fromisoformat(cstruct) 

189 return self.app.render_date(dt) 

190 

191 return super().serialize(field, cstruct, **kw) 

192 

193 

194class WuttaDateTimeWidget(DateTimeInputWidget): 

195 """ 

196 Custom widget for :class:`python:datetime.datetime` fields. 

197 

198 The main purpose of this widget is to leverage 

199 :meth:`~wuttjamaican:wuttjamaican.app.AppHandler.render_datetime()` 

200 for the readonly display. 

201 

202 It is automatically used for SQLAlchemy mapped classes where the 

203 field maps to a :class:`sqlalchemy:sqlalchemy.types.DateTime` 

204 column. For other (non-mapped) datetime fields, you may have to 

205 use it explicitly via 

206 :meth:`~wuttaweb.forms.base.Form.set_widget()`. 

207 

208 This is a subclass of 

209 :class:`deform:deform.widget.DateTimeInputWidget` and uses these 

210 Deform templates: 

211 

212 * ``datetimeinput`` 

213 """ 

214 

215 def __init__(self, request, *args, **kwargs): 

216 super().__init__(*args, **kwargs) 

217 self.request = request 

218 self.config = self.request.wutta_config 

219 self.app = self.config.get_app() 

220 

221 def serialize(self, field, cstruct, **kw): 

222 """ """ 

223 readonly = kw.get('readonly', self.readonly) 

224 if readonly and cstruct: 

225 dt = datetime.datetime.fromisoformat(cstruct) 

226 return self.app.render_datetime(dt) 

227 

228 return super().serialize(field, cstruct, **kw) 

229 

230 

231class WuttaMoneyInputWidget(MoneyInputWidget): 

232 """ 

233 Custom widget for "money" fields. This is used by default for 

234 :class:`~wuttaweb.forms.schema.WuttaMoney` type nodes. 

235 

236 The main purpose of this widget is to leverage 

237 :meth:`~wuttjamaican:wuttjamaican.app.AppHandler.render_currency()` 

238 for the readonly display. 

239 

240 This is a subclass of 

241 :class:`deform:deform.widget.MoneyInputWidget` and uses these 

242 Deform templates: 

243 

244 * ``moneyinput`` 

245 

246 :param request: Current :term:`request` object. 

247 

248 :param scale: If this kwarg is specified, it will be passed along 

249 to ``render_currency()`` call. 

250 """ 

251 

252 def __init__(self, request, *args, **kwargs): 

253 self.scale = kwargs.pop('scale', 2) 

254 super().__init__(*args, **kwargs) 

255 self.request = request 

256 self.config = self.request.wutta_config 

257 self.app = self.config.get_app() 

258 

259 def serialize(self, field, cstruct, **kw): 

260 """ """ 

261 readonly = kw.get('readonly', self.readonly) 

262 if readonly: 

263 if cstruct in (colander.null, None): 

264 return HTML.tag('span') 

265 cstruct = decimal.Decimal(cstruct) 

266 text = self.app.render_currency(cstruct, scale=self.scale) 

267 return HTML.tag('span', c=[text]) 

268 

269 return super().serialize(field, cstruct, **kw) 

270 

271 

272class FileDownloadWidget(Widget): 

273 """ 

274 Widget for use with :class:`~wuttaweb.forms.schema.FileDownload` 

275 fields. 

276 

277 This only supports readonly, and shows a hyperlink to download the 

278 file. Link text is the filename plus file size. 

279 

280 This is a subclass of :class:`deform:deform.widget.Widget` and 

281 uses these Deform templates: 

282 

283 * ``readonly/filedownload`` 

284 

285 :param request: Current :term:`request` object. 

286 

287 :param url: Optional URL for hyperlink. If not specified, file 

288 name/size is shown with no hyperlink. 

289 """ 

290 readonly_template = 'readonly/filedownload' 

291 

292 def __init__(self, request, *args, **kwargs): 

293 self.url = kwargs.pop('url', None) 

294 super().__init__(*args, **kwargs) 

295 self.request = request 

296 self.config = self.request.wutta_config 

297 self.app = self.config.get_app() 

298 

299 def serialize(self, field, cstruct, **kw): 

300 """ """ 

301 # nb. readonly is the only way this rolls 

302 kw['readonly'] = True 

303 template = self.readonly_template 

304 

305 path = cstruct or None 

306 if path: 

307 kw.setdefault('filename', os.path.basename(path)) 

308 kw.setdefault('filesize', self.readable_size(path)) 

309 if self.url: 

310 kw.setdefault('url', self.url) 

311 

312 else: 

313 kw.setdefault('filename', None) 

314 kw.setdefault('filesize', None) 

315 

316 kw.setdefault('url', None) 

317 values = self.get_template_values(field, cstruct, kw) 

318 return field.renderer(template, **values) 

319 

320 def readable_size(self, path): 

321 """ """ 

322 try: 

323 size = os.path.getsize(path) 

324 except os.error: 

325 size = 0 

326 return humanize.naturalsize(size) 

327 

328 

329class GridWidget(Widget): 

330 """ 

331 Widget for fields whose data is represented by a :term:`grid`. 

332 

333 This is a subclass of :class:`deform:deform.widget.Widget` but 

334 does not use any Deform templates. 

335 

336 This widget only supports "readonly" mode, is not editable. It is 

337 merely a convenience around the grid itself, which does the heavy 

338 lifting. 

339 

340 Instead of creating this widget directly you probably should call 

341 :meth:`~wuttaweb.forms.base.Form.set_grid()` on your form. 

342 

343 :param request: Current :term:`request` object. 

344 

345 :param grid: :class:`~wuttaweb.grids.base.Grid` instance, used to 

346 display the field data. 

347 """ 

348 

349 def __init__(self, request, grid, *args, **kwargs): 

350 super().__init__(*args, **kwargs) 

351 self.request = request 

352 self.grid = grid 

353 

354 def serialize(self, field, cstruct, **kw): 

355 """ 

356 This widget simply calls 

357 :meth:`~wuttaweb.grids.base.Grid.render_table_element()` on 

358 the ``grid`` to serialize. 

359 """ 

360 readonly = kw.get('readonly', self.readonly) 

361 if not readonly: 

362 raise NotImplementedError("edit not allowed for this widget") 

363 

364 return self.grid.render_table_element() 

365 

366 

367class RoleRefsWidget(WuttaCheckboxChoiceWidget): 

368 """ 

369 Widget for use with User 

370 :attr:`~wuttjamaican:wuttjamaican.db.model.auth.User.roles` field. 

371 This is the default widget for the 

372 :class:`~wuttaweb.forms.schema.RoleRefs` type. 

373 

374 This is a subclass of :class:`WuttaCheckboxChoiceWidget`. 

375 """ 

376 readonly_template = 'readonly/rolerefs' 

377 

378 def serialize(self, field, cstruct, **kw): 

379 """ """ 

380 model = self.app.model 

381 

382 # special logic when field is editable 

383 readonly = kw.get('readonly', self.readonly) 

384 if not readonly: 

385 

386 # but does not apply if current user is root 

387 if not self.request.is_root: 

388 auth = self.app.get_auth_handler() 

389 admin = auth.get_role_administrator(self.session) 

390 

391 # prune admin role from values list; it should not be 

392 # one of the options since current user is not admin 

393 values = kw.get('values', self.values) 

394 values = [val for val in values 

395 if val[0] != admin.uuid] 

396 kw['values'] = values 

397 

398 else: # readonly 

399 

400 # roles 

401 roles = [] 

402 if cstruct: 

403 for uuid in cstruct: 

404 role = self.session.get(model.Role, uuid) 

405 if role: 

406 roles.append(role) 

407 kw['roles'] = roles 

408 

409 # url 

410 url = lambda role: self.request.route_url('roles.view', uuid=role.uuid) 

411 kw['url'] = url 

412 

413 # default logic from here 

414 return super().serialize(field, cstruct, **kw) 

415 

416 

417class UserRefsWidget(WuttaCheckboxChoiceWidget): 

418 """ 

419 Widget for use with Role 

420 :attr:`~wuttjamaican:wuttjamaican.db.model.auth.Role.users` field. 

421 This is the default widget for the 

422 :class:`~wuttaweb.forms.schema.UserRefs` type. 

423 

424 This is a subclass of :class:`WuttaCheckboxChoiceWidget`; however 

425 it only supports readonly mode and does not use a template. 

426 Rather, it generates and renders a 

427 :class:`~wuttaweb.grids.base.Grid` showing the users list. 

428 """ 

429 

430 def serialize(self, field, cstruct, **kw): 

431 """ """ 

432 readonly = kw.get('readonly', self.readonly) 

433 if not readonly: 

434 raise NotImplementedError("edit not allowed for this widget") 

435 

436 model = self.app.model 

437 columns = ['username', 'active'] 

438 

439 # generate data set for users 

440 users = [] 

441 if cstruct: 

442 for uuid in cstruct: 

443 user = self.session.get(model.User, uuid) 

444 if user: 

445 users.append(dict([(key, getattr(user, key)) 

446 for key in columns + ['uuid']])) 

447 

448 # do not render if no data 

449 if not users: 

450 return HTML.tag('span') 

451 

452 # grid 

453 grid = Grid(self.request, key='roles.view.users', 

454 columns=columns, data=users) 

455 

456 # view action 

457 if self.request.has_perm('users.view'): 

458 url = lambda user, i: self.request.route_url('users.view', uuid=user['uuid']) 

459 grid.add_action('view', icon='eye', url=url) 

460 grid.set_link('person') 

461 grid.set_link('username') 

462 

463 # edit action 

464 if self.request.has_perm('users.edit'): 

465 url = lambda user, i: self.request.route_url('users.edit', uuid=user['uuid']) 

466 grid.add_action('edit', url=url) 

467 

468 # render as simple <b-table> 

469 # nb. must indicate we are a part of this form 

470 form = getattr(field.parent, 'wutta_form', None) 

471 return grid.render_table_element(form) 

472 

473 

474class PermissionsWidget(WuttaCheckboxChoiceWidget): 

475 """ 

476 Widget for use with Role 

477 :attr:`~wuttjamaican:wuttjamaican.db.model.auth.Role.permissions` 

478 field. 

479 

480 This is a subclass of :class:`WuttaCheckboxChoiceWidget`. It uses 

481 these Deform templates: 

482 

483 * ``permissions`` 

484 * ``readonly/permissions`` 

485 """ 

486 template = 'permissions' 

487 readonly_template = 'readonly/permissions' 

488 

489 def serialize(self, field, cstruct, **kw): 

490 """ """ 

491 kw.setdefault('permissions', self.permissions) 

492 

493 if 'values' not in kw: 

494 values = [] 

495 for gkey, group in self.permissions.items(): 

496 for pkey, perm in group['perms'].items(): 

497 values.append((pkey, perm['label'])) 

498 kw['values'] = values 

499 

500 return super().serialize(field, cstruct, **kw) 

501 

502 

503class EmailRecipientsWidget(TextAreaWidget): 

504 """ 

505 Widget for :term:`email setting` recipient fields (``To``, ``Cc``, 

506 ``Bcc``). 

507 

508 This is a subclass of 

509 :class:`deform:deform.widget.TextAreaWidget`. It uses these 

510 Deform templates: 

511 

512 * ``textarea`` 

513 * ``readonly/email_recips`` 

514 

515 See also the :class:`~wuttaweb.forms.schema.EmailRecipients` 

516 schema type, which uses this widget. 

517 """ 

518 readonly_template = 'readonly/email_recips' 

519 

520 def serialize(self, field, cstruct, **kw): 

521 """ """ 

522 readonly = kw.get('readonly', self.readonly) 

523 if readonly: 

524 kw['recips'] = parse_list(cstruct or '') 

525 

526 return super().serialize(field, cstruct, **kw) 

527 

528 def deserialize(self, field, pstruct): 

529 """ """ 

530 if pstruct is colander.null: 

531 return colander.null 

532 

533 values = [value for value in parse_list(pstruct) 

534 if value] 

535 return ', '.join(values) 

536 

537 

538class BatchIdWidget(Widget): 

539 """ 

540 Widget for use with the 

541 :attr:`~wuttjamaican:wuttjamaican.db.model.batch.BatchMixin.id` 

542 field of a :term:`batch` model. 

543 

544 This widget is "always" read-only and renders the Batch ID as 

545 zero-padded 8-char string 

546 """ 

547 

548 def serialize(self, field, cstruct, **kw): 

549 """ """ 

550 if cstruct is colander.null: 

551 return colander.null 

552 

553 batch_id = int(cstruct) 

554 return f'{batch_id:08d}'