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

149 statements  

« prev     ^ index     » next       coverage.py v7.6.1, created at 2024-08-27 21:18 -0500

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 schema types 

25""" 

26 

27import colander 

28 

29from wuttaweb.db import Session 

30from wuttaweb.forms import widgets 

31from wuttjamaican.db.model import Person 

32 

33 

34class ObjectNode(colander.SchemaNode): 

35 """ 

36 Custom schema node class which adds methods for compatibility with 

37 ColanderAlchemy. This is a direct subclass of 

38 :class:`colander:colander.SchemaNode`. 

39 

40 ColanderAlchemy will call certain methods on any node found in the 

41 schema. However these methods are not "standard" and only exist 

42 for ColanderAlchemy nodes. 

43 

44 So we must add nodes using this class, to ensure the node has all 

45 methods needed by ColanderAlchemy. 

46 """ 

47 

48 def dictify(self, obj): 

49 """ 

50 This method is called by ColanderAlchemy when translating the 

51 in-app Python object to a value suitable for use in the form 

52 data dict. 

53 

54 The logic here will look for a ``dictify()`` method on the 

55 node's "type" instance (``self.typ``; see also 

56 :class:`colander:colander.SchemaNode`) and invoke it if found. 

57 

58 For an example type which is supported in this way, see 

59 :class:`ObjectRef`. 

60 

61 If the node's type does not have a ``dictify()`` method, this 

62 will just convert the object to a string and return that. 

63 """ 

64 if hasattr(self.typ, 'dictify'): 

65 return self.typ.dictify(obj) 

66 

67 # TODO: this is better than raising an error, as it previously 

68 # did, but seems like troubleshooting problems may often lead 

69 # one here.. i suspect this needs to do something smarter but 

70 # not sure what that is yet 

71 return str(obj) 

72 

73 def objectify(self, value): 

74 """ 

75 This method is called by ColanderAlchemy when translating form 

76 data to the final Python representation. 

77 

78 The logic here will look for an ``objectify()`` method on the 

79 node's "type" instance (``self.typ``; see also 

80 :class:`colander:colander.SchemaNode`) and invoke it if found. 

81 

82 For an example type which is supported in this way, see 

83 :class:`ObjectRef`. 

84 

85 If the node's type does not have an ``objectify()`` method, 

86 this will raise ``NotImplementeError``. 

87 """ 

88 if hasattr(self.typ, 'objectify'): 

89 return self.typ.objectify(value) 

90 

91 class_name = self.typ.__class__.__name__ 

92 raise NotImplementedError(f"you must define {class_name}.objectify()") 

93 

94 

95class WuttaEnum(colander.Enum): 

96 """ 

97 Custom schema type for enum fields. 

98 

99 This is a subclass of :class:`colander.Enum`, but adds a 

100 default widget (``SelectWidget``) with enum choices. 

101 

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

103 """ 

104 

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

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

107 self.request = request 

108 self.config = self.request.wutta_config 

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

110 

111 def widget_maker(self, **kwargs): 

112 """ """ 

113 

114 if 'values' not in kwargs: 

115 kwargs['values'] = [(getattr(e, self.attr), getattr(e, self.attr)) 

116 for e in self.enum_cls] 

117 

118 return widgets.SelectWidget(**kwargs) 

119 

120 

121class WuttaSet(colander.Set): 

122 """ 

123 Custom schema type for :class:`python:set` fields. 

124 

125 This is a subclass of :class:`colander.Set`, but adds 

126 Wutta-related params to the constructor. 

127 

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

129 

130 :param session: Optional :term:`db session` to use instead of 

131 :class:`wuttaweb.db.sess.Session`. 

132 """ 

133 

134 def __init__(self, request, session=None): 

135 super().__init__() 

136 self.request = request 

137 self.config = self.request.wutta_config 

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

139 self.session = session or Session() 

140 

141 

142class ObjectRef(colander.SchemaType): 

143 """ 

144 Custom schema type for a model class reference field. 

145 

146 This expects the incoming ``appstruct`` to be either a model 

147 record instance, or ``None``. 

148 

149 Serializes to the instance UUID as string, or ``colander.null``; 

150 form data should be of the same nature. 

151 

152 This schema type is not useful directly, but various other types 

153 will subclass it. Each should define (at least) the 

154 :attr:`model_class` attribute or property. 

155 

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

157 

158 :param empty_option: If a select widget is used, this determines 

159 whether an empty option is included for the dropdown. Set 

160 this to one of the following to add an empty option: 

161 

162 * ``True`` to add the default empty option 

163 * label text for the empty option 

164 * tuple of ``(value, label)`` for the empty option 

165 

166 Note that in the latter, ``value`` must be a string. 

167 """ 

168 

169 default_empty_option = ('', "(none)") 

170 

171 def __init__( 

172 self, 

173 request, 

174 empty_option=None, 

175 session=None, 

176 *args, 

177 **kwargs, 

178 ): 

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

180 self.request = request 

181 self.config = self.request.wutta_config 

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

183 self.model_instance = None 

184 self.session = session or Session() 

185 

186 if empty_option: 

187 if empty_option is True: 

188 self.empty_option = self.default_empty_option 

189 elif isinstance(empty_option, tuple) and len(empty_option) == 2: 

190 self.empty_option = empty_option 

191 else: 

192 self.empty_option = ('', str(empty_option)) 

193 else: 

194 self.empty_option = None 

195 

196 @property 

197 def model_class(self): 

198 """ 

199 Should be a reference to the model class to which this schema 

200 type applies 

201 (e.g. :class:`~wuttjamaican:wuttjamaican.db.model.base.Person`). 

202 """ 

203 class_name = self.__class__.__name__ 

204 raise NotImplementedError(f"you must define {class_name}.model_class") 

205 

206 def serialize(self, node, appstruct): 

207 """ """ 

208 if appstruct is colander.null: 

209 return colander.null 

210 

211 # nb. keep a ref to this for later use 

212 node.model_instance = appstruct 

213 

214 # serialize to uuid 

215 return appstruct.uuid 

216 

217 def deserialize(self, node, cstruct): 

218 """ """ 

219 if not cstruct: 

220 return colander.null 

221 

222 # nb. use shortcut to fetch model instance from DB 

223 return self.objectify(cstruct) 

224 

225 def dictify(self, obj): 

226 """ """ 

227 

228 # TODO: would we ever need to do something else? 

229 return obj 

230 

231 def objectify(self, value): 

232 """ 

233 For the given UUID value, returns the object it represents 

234 (based on :attr:`model_class`). 

235 

236 If the value is empty, returns ``None``. 

237 

238 If the value is not empty but object cannot be found, raises 

239 ``colander.Invalid``. 

240 """ 

241 if not value: 

242 return 

243 

244 if isinstance(value, self.model_class): 

245 return value 

246 

247 # fetch object from DB 

248 model = self.app.model 

249 obj = self.session.get(self.model_class, value) 

250 

251 # raise error if not found 

252 if not obj: 

253 class_name = self.model_class.__name__ 

254 raise ValueError(f"{class_name} not found: {value}") 

255 

256 return obj 

257 

258 def get_query(self): 

259 """ 

260 Returns the main SQLAlchemy query responsible for locating the 

261 dropdown choices for the select widget. 

262 

263 This is called by :meth:`widget_maker()`. 

264 """ 

265 query = self.session.query(self.model_class) 

266 query = self.sort_query(query) 

267 return query 

268 

269 def sort_query(self, query): 

270 """ 

271 TODO 

272 """ 

273 return query 

274 

275 def widget_maker(self, **kwargs): 

276 """ 

277 This method is responsible for producing the default widget 

278 for the schema node. 

279 

280 Deform calls this method automatically when constructing the 

281 default widget for a field. 

282 

283 :returns: Instance of 

284 :class:`~wuttaweb.forms.widgets.ObjectRefWidget`. 

285 """ 

286 

287 if 'values' not in kwargs: 

288 query = self.get_query() 

289 objects = query.all() 

290 values = [(obj.uuid, str(obj)) 

291 for obj in objects] 

292 if self.empty_option: 

293 values.insert(0, self.empty_option) 

294 kwargs['values'] = values 

295 

296 if 'url' not in kwargs: 

297 kwargs['url'] = self.get_object_url 

298 

299 return widgets.ObjectRefWidget(self.request, **kwargs) 

300 

301 def get_object_url(self, obj): 

302 """ 

303 Returns the "view" URL for the given object, if applicable. 

304 

305 This is used when rendering the field readonly. If this 

306 method returns a URL then the field text will be wrapped with 

307 a hyperlink, otherwise it will be shown as-is. 

308 

309 Default logic always returns ``None``; subclass should 

310 override as needed. 

311 """ 

312 

313 

314class PersonRef(ObjectRef): 

315 """ 

316 Custom schema type for a 

317 :class:`~wuttjamaican:wuttjamaican.db.model.base.Person` reference 

318 field. 

319 

320 This is a subclass of :class:`ObjectRef`. 

321 """ 

322 

323 @property 

324 def model_class(self): 

325 """ """ 

326 model = self.app.model 

327 return model.Person 

328 

329 def sort_query(self, query): 

330 """ """ 

331 return query.order_by(self.model_class.full_name) 

332 

333 def get_object_url(self, person): 

334 """ """ 

335 return self.request.route_url('people.view', uuid=person.uuid) 

336 

337 

338class UserRef(ObjectRef): 

339 """ 

340 Custom schema type for a 

341 :class:`~wuttjamaican:wuttjamaican.db.model.auth.User` reference 

342 field. 

343 

344 This is a subclass of :class:`ObjectRef`. 

345 """ 

346 

347 @property 

348 def model_class(self): 

349 """ """ 

350 model = self.app.model 

351 return model.User 

352 

353 def sort_query(self, query): 

354 """ """ 

355 return query.order_by(self.model_class.username) 

356 

357 def get_object_url(self, user): 

358 """ """ 

359 return self.request.route_url('users.view', uuid=user.uuid) 

360 

361 

362class RoleRefs(WuttaSet): 

363 """ 

364 Form schema type for the User 

365 :attr:`~wuttjamaican:wuttjamaican.db.model.auth.User.roles` 

366 association proxy field. 

367 

368 This is a subclass of :class:`WuttaSet`. It uses a ``set`` of 

369 :class:`~wuttjamaican:wuttjamaican.db.model.auth.Role` ``uuid`` 

370 values for underlying data format. 

371 """ 

372 

373 def widget_maker(self, **kwargs): 

374 """ 

375 Constructs a default widget for the field. 

376 

377 :returns: Instance of 

378 :class:`~wuttaweb.forms.widgets.RoleRefsWidget`. 

379 """ 

380 kwargs.setdefault('session', self.session) 

381 

382 if 'values' not in kwargs: 

383 model = self.app.model 

384 auth = self.app.get_auth_handler() 

385 avoid = { 

386 auth.get_role_authenticated(self.session), 

387 auth.get_role_anonymous(self.session), 

388 } 

389 avoid = set([role.uuid for role in avoid]) 

390 roles = self.session.query(model.Role)\ 

391 .filter(~model.Role.uuid.in_(avoid))\ 

392 .order_by(model.Role.name)\ 

393 .all() 

394 values = [(role.uuid, role.name) for role in roles] 

395 kwargs['values'] = values 

396 

397 return widgets.RoleRefsWidget(self.request, **kwargs) 

398 

399 

400class UserRefs(WuttaSet): 

401 """ 

402 Form schema type for the Role 

403 :attr:`~wuttjamaican:wuttjamaican.db.model.auth.Role.users` 

404 association proxy field. 

405 

406 This is a subclass of :class:`WuttaSet`. It uses a ``set`` of 

407 :class:`~wuttjamaican:wuttjamaican.db.model.auth.User` ``uuid`` 

408 values for underlying data format. 

409 """ 

410 

411 def widget_maker(self, **kwargs): 

412 """ 

413 Constructs a default widget for the field. 

414 

415 :returns: Instance of 

416 :class:`~wuttaweb.forms.widgets.UserRefsWidget`. 

417 """ 

418 kwargs.setdefault('session', self.session) 

419 return widgets.UserRefsWidget(self.request, **kwargs) 

420 

421 

422class Permissions(WuttaSet): 

423 """ 

424 Form schema type for the Role 

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

426 association proxy field. 

427 

428 This is a subclass of :class:`WuttaSet`. It uses a ``set`` of 

429 :attr:`~wuttjamaican:wuttjamaican.db.model.auth.Permission.permission` 

430 values for underlying data format. 

431 

432 :param permissions: Dict with all possible permissions. Should be 

433 in the same format as returned by 

434 :meth:`~wuttaweb.views.roles.RoleView.get_available_permissions()`. 

435 """ 

436 

437 def __init__(self, request, permissions, *args, **kwargs): 

438 super().__init__(request, *args, **kwargs) 

439 self.permissions = permissions 

440 

441 def widget_maker(self, **kwargs): 

442 """ 

443 Constructs a default widget for the field. 

444 

445 :returns: Instance of 

446 :class:`~wuttaweb.forms.widgets.PermissionsWidget`. 

447 """ 

448 kwargs.setdefault('session', self.session) 

449 kwargs.setdefault('permissions', self.permissions) 

450 

451 if 'values' not in kwargs: 

452 values = [] 

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

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

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

456 kwargs['values'] = values 

457 

458 return widgets.PermissionsWidget(self.request, **kwargs) 

459 

460 

461class FileDownload(colander.String): 

462 """ 

463 Custom schema type for a file download field. 

464 

465 This field is only meant for readonly use, it does not handle file 

466 uploads. 

467 

468 It expects the incoming ``appstruct`` to be the path to a file on 

469 disk (or null). 

470 

471 Uses the :class:`~wuttaweb.forms.widgets.FileDownloadWidget` by 

472 default. 

473 

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

475 

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

477 name/size is shown with no hyperlink. 

478 """ 

479 

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

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

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

483 self.request = request 

484 self.config = self.request.wutta_config 

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

486 

487 def widget_maker(self, **kwargs): 

488 """ """ 

489 kwargs.setdefault('url', self.url) 

490 return widgets.FileDownloadWidget(self.request, **kwargs)