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"""Fields of records of tables. 

2 

3* Display readonly/editable 

4* Refresh buttons 

5* Saving values 

6""" 

7 

8from flask import request, abort 

9 

10from config import Config as C, Names as N 

11from control.html import HtmlElements as H 

12from control.utils import pick as G, bencode, cap1, E, BLANK, ONE, COMMA 

13from control.perm import getPermField 

14from control.typ.value import ConversionError 

15 

16CT = C.tables 

17CW = C.web 

18CF = C.workflow 

19 

20 

21DEFAULT_TYPE = CT.defaultType 

22CONSTRAINED = CT.constrained 

23WITH_NOW = CT.withNow 

24WORKFLOW_TABLES = set(CT.userTables) | set(CT.userEntryTables) 

25CASCADE_SPECS = CT.cascade 

26 

27WORKFLOW_FIELDS = CF.fields 

28 

29REFRESH = CW.messages[N.refresh] 

30LIMITS = CW.limits 

31LIMIT_JSON = G(LIMITS, N.json, default=1000000) 

32 

33 

34class Field: 

35 """Deals with fields.""" 

36 

37 inheritProps = ( 

38 N.context, 

39 N.uid, 

40 N.eppn, 

41 N.table, 

42 N.record, 

43 N.eid, 

44 N.perm, 

45 N.readonly, 

46 N.mayRead, 

47 ) 

48 

49 def __init__( 

50 self, 

51 recordObj, 

52 field, 

53 asMaster=False, 

54 readonly=None, 

55 mayRead=None, 

56 mayEdit=None, 

57 ): 

58 """## Initialization 

59 

60 Store the incoming information. 

61 

62 A number of properties will be inherited from the record object 

63 that spawns a field object. 

64 

65 The value of the field will be looked up from the record and saved into the attribute 

66 `value`. 

67 

68 Set the attribute `fieldTypeObj` to a suitable derived class of 

69 `control.typ.base.TypeBase`. 

70 

71 !!! caution 

72 Fields that point to master records are never editable in this app. 

73 

74 !!! hint 

75 The parameters `readonly`, `mayRead`, `mayEdit` are optional. 

76 If they are not passed or `None`, values for these will be taken 

77 from the `recordObj` that has spawned this field object. 

78 

79 !!! caution 

80 Whether a field is readable or editable, depends first on how it 

81 is configured in the .yaml file in `tables` that correspnds to the table. 

82 This can be overriden by setting the `mayRead` and `mayEdit` attributes 

83 in the `recordObj`, and it can be overridden again by explicitly 

84 passing values for them here. 

85 

86 Parameters 

87 ---------- 

88 recordObj: object 

89 See below. 

90 field: string 

91 See below. 

92 asMaster: boolean, optional `False` 

93 See below. 

94 readonly: boolean | `None`, optional `None` 

95 Whether the field must be presented readonly. 

96 mayRead: boolean | `None`, optional `None` 

97 If passed, overrides the configured read permission for this field. 

98 mayEdit: boolean | `None`, optional `None` 

99 If passed, overrides the configured write permission for this field. 

100 """ 

101 

102 for prop in Field.inheritProps: 

103 setattr(self, prop, getattr(recordObj, prop, None)) 

104 

105 self.recordObj = recordObj 

106 """*object* A `control.record.Record` object (or one of a derived class) 

107 """ 

108 

109 self.field = field 

110 """*string* The name of the field 

111 """ 

112 

113 self.asMaster = asMaster 

114 """*boolean* Whether this field points to a master record. 

115 """ 

116 

117 table = self.table 

118 

119 withNow = G(WITH_NOW, table) 

120 if withNow: 

121 nowFields = [] 

122 for info in withNow.values(): 

123 if type(info) is str: 

124 nowFields.append(info) 

125 else: 

126 nowFields.extend(info) 

127 nowFields = set(nowFields) 

128 else: 

129 nowFields = set() 

130 

131 self.withRefresh = field == N.modified or field in nowFields 

132 """*boolean* Whether the field needs a refresh button. 

133 """ 

134 

135 self.withNow = G(withNow, field) 

136 """*dict* Which field updates need a timestamp? 

137 

138 The info comes from tables.yaml, under key `withNow`. 

139 It is keyed by table name, then by field name, and the value 

140 is a single field or lists of two fields with the names of 

141 corresponding timestamp fields. 

142 

143 When a (boolean) field has two timestamp fields, the first one is used if 

144 he value is a list, the first one will be used if the modification 

145 sets the field to `True` and the second one when the field becomes `False`. 

146 

147 !!! hint 

148 In assessments, when `submitted` becomes `True`, `dateSubmitted` receives 

149 a timestamp. When `submitted` becomes `False`, it is `dateWithdrawn` that 

150 receives the timestamp. 

151 """ 

152 

153 fieldSpecs = recordObj.fields 

154 fieldSpec = G(fieldSpecs, field) 

155 

156 record = self.record 

157 self.value = G(record, field) 

158 """*mixed* The value of the field. 

159 """ 

160 

161 require = G(fieldSpec, N.perm, default={}) 

162 self.require = require 

163 """*dict* The required permissions for this field. 

164 

165 Keys are `read` and `edit`, the values are `True` or `False`. 

166 """ 

167 

168 self.label = G(fieldSpec, N.label, default=cap1(field)) 

169 """*string* A label to display in front of the field. 

170 """ 

171 

172 self.tp = G(fieldSpec, N.type, default=DEFAULT_TYPE) 

173 """*string* The data type of the field.""" 

174 

175 self.multiple = G(fieldSpec, N.multiple, default=False) 

176 """*boolean* Whether the field value consists of multiple values or a single one. 

177 """ 

178 

179 self.extensible = G(fieldSpec, N.extensible, default=False) 

180 """*boolean* Whether the user may add new values to the value table of this field. 

181 """ 

182 

183 context = self.context 

184 

185 perm = self.perm 

186 table = self.table 

187 eid = self.eid 

188 tp = self.tp 

189 types = context.types 

190 

191 fieldTypeObj = getattr(types, tp, None) 

192 self.fieldTypeObj = fieldTypeObj 

193 """*object* The type object by which the value of this field can be interpreted. 

194 """ 

195 

196 self.widgetType = fieldTypeObj.widgetType 

197 """*string* The type of widget for presenting an edit view on this field. 

198 """ 

199 

200 readonly = self.readonly if readonly is None else readonly 

201 

202 (self.mayRead, self.mayEdit) = getPermField(table, perm, require) 

203 if mayRead is not None: 

204 self.mayRead = mayRead 

205 if mayEdit is not None: 

206 self.mayEdit = mayEdit 

207 

208 if readonly or asMaster: 

209 self.mayEdit = False 

210 

211 self.atts = dict(table=table, eid=eid, field=field) 

212 """*dict* Identification of this field value: table, id of record, name of field. 

213 

214 !!! hint 

215 To be used to pass to buttons in widgets for this field. 

216 """ 

217 

218 def save(self, data): 

219 """Save a new value for this field to MongoDb. 

220 

221 Before saving, permissions and workflow conditions will be checked. 

222 

223 After saving, workflow information will be adjusted. 

224 

225 !!! caution 

226 If the `editors` field of an assessment or review is modified, 

227 the same value should be put in the detail records, the 

228 `criteriaEntry` and `reviewEntry` records respectively. 

229 

230 Parameters 

231 ---------- 

232 data: mixed 

233 The new value(s) for this field. 

234 It comes straight from the client, and will be converted to proper 

235 Python/MongoDb values 

236 """ 

237 

238 mayEdit = self.mayEdit 

239 

240 if not mayEdit: 240 ↛ 241line 240 didn't jump to line 241, because the condition on line 240 was never true

241 return False 

242 

243 context = self.context 

244 db = context.db 

245 uid = self.uid 

246 eppn = self.eppn 

247 table = self.table 

248 eid = self.eid 

249 field = self.field 

250 extensible = self.extensible 

251 record = self.record 

252 recordObj = self.recordObj 

253 require = self.require 

254 constrain = None 

255 constrainField = G(CONSTRAINED, field) 

256 if constrainField: 

257 constrainValue = G(record, constrainField) 

258 if constrainValue: 258 ↛ 261line 258 didn't jump to line 261, because the condition on line 258 was never false

259 constrain = (constrainField, constrainValue) 

260 

261 multiple = self.multiple 

262 fieldTypeObj = self.fieldTypeObj 

263 conversion = fieldTypeObj.fromStr if fieldTypeObj else None 

264 kwargs = dict(uid=uid, eppn=eppn, extensible=extensible) if extensible else {} 

265 if constrain: 

266 kwargs[N.constrain] = constrain 

267 

268 if conversion is not None: 268 ↛ 277line 268 didn't jump to line 277, because the condition on line 268 was never false

269 try: 

270 if multiple: 

271 data = [conversion(d, **kwargs) for d in data or []] 

272 else: 

273 data = conversion(data, **kwargs) 

274 except ConversionError: 

275 return False 

276 

277 modified = G(record, N.modified) 

278 nowFields = [] 

279 if data is not None: 

280 withNow = self.withNow 

281 if withNow: 

282 withNowField = ( 

283 withNow 

284 if type(withNow) is str 

285 else withNow[0] 

286 if data 

287 else withNow[1] 

288 ) 

289 nowFields.append(withNowField) 

290 

291 result = db.updateField( 

292 table, 

293 eid, 

294 field, 

295 data, 

296 eppn, 

297 modified, 

298 nowFields=nowFields, 

299 ) 

300 if not result: 300 ↛ 301line 300 didn't jump to line 301, because the condition on line 300 was never true

301 return False 

302 

303 (updates, deletions) = result 

304 record = context.getItem(table, eid, requireFresh=True) 

305 

306 recordObj.reload(record) 

307 self.value = G(record, field) 

308 self.perm = recordObj.perm 

309 perm = self.perm 

310 (self.mayRead, self.mayEdit) = getPermField(table, perm, require) 

311 

312 good = True 

313 if field == N.editors and table in CASCADE_SPECS: 

314 for dtable in CASCADE_SPECS[table]: 

315 drecords = db.getDetails(dtable, table, eid) 

316 for drecord in drecords: 

317 deid = G(drecord, N._id) 

318 result = db.updateField( 

319 dtable, 

320 deid, 

321 N.editors, 

322 data, 

323 eppn, 

324 modified, 

325 nowFields=nowFields, 

326 ) 

327 if not result: 327 ↛ 328line 327 didn't jump to line 328, because the condition on line 327 was never true

328 good = False 

329 

330 if table in WORKFLOW_TABLES and field in WORKFLOW_FIELDS: 

331 recordObj.adjustWorkflow() 

332 

333 return good 

334 

335 def isEmpty(self): 

336 """Whether the value(s) is/are empty. 

337 

338 A single value is empty if it is `None`. 

339 

340 A mutliple value is empty if it is `None` or `[]`. 

341 

342 Returns 

343 ------- 

344 boolean 

345 """ 

346 

347 value = self.value 

348 multiple = self.multiple 

349 return value is None or multiple and value == [] 

350 

351 def isBlank(self): 

352 """Whether the value(s) is/are blank. 

353 

354 A single value is empty if it is `None` or `''`. 

355 

356 A mutliple value is empty if it is `None` or `[]`, or all its 

357 component values are blank. 

358 

359 Returns 

360 ------- 

361 boolean 

362 """ 

363 

364 value = self.value 

365 multiple = self.multiple 

366 return ( 366 ↛ exitline 366 didn't jump to the function exit

367 value is None 

368 or value == E 

369 or multiple 

370 and (value == [] or all(v is None or v == E for v in value)) 

371 ) 

372 

373 def wrapBare(self, markup=True): 

374 """Produce the bare field value. 

375 

376 This is the result of calling the 

377 `control.typ.base.TypeBase.toDisplay` method on the derived 

378 type class that matches the type of the field. 

379 

380 Returns 

381 ------- 

382 string(html) 

383 """ 

384 

385 context = self.context 

386 types = context.types 

387 tp = self.tp 

388 value = self.value 

389 multiple = self.multiple 

390 

391 fieldTypeObj = getattr(types, tp, None) 

392 method = fieldTypeObj.toDisplay 

393 

394 return ( 394 ↛ exitline 394 didn't jump to the function exit

395 ( 

396 [method(val, markup=markup) for val in (value or [])] 

397 if multiple 

398 else method(value, markup=markup) 

399 ) 

400 if markup is None 

401 else ( 

402 BLANK.join(method(val, markup=markup) for val in (value or [])) 

403 if multiple 

404 else method(value, markup=markup) 

405 ) 

406 ) 

407 

408 def wrap(self, action=None, asEdit=False, empty=False, withLabel=True, cls=E): 

409 """Wrap the field into HTML. 

410 

411 If there is an `action`, data from the request is picked up and 

412 `Field.save` is called to save that data to the MongoDB. 

413 Depending on the `action`, the field is then rendered as follows: 

414 

415 action | effect 

416 --- | --- 

417 `save` | no rendering 

418 `view`| a read only rendering 

419 `edit` | an edit widget 

420 

421 Whether a field is presented as an editable field depends on a number of factors: 

422 

423 factor | story 

424 --- | --- 

425 is master | a field pointing to a master will not be edited 

426 attribute `mayEdit` | does the current user have permission to edit the field? 

427 action `edit` or parameter `asEdit` | do we want to present an editable widget? 

428 

429 Parameters 

430 ---------- 

431 action: {save, edit, view}, optional `None` 

432 If present, data will be saved to the database first. 

433 asEdit: boolean, optional `False` 

434 No data will be saved. 

435 The field will rendered editable, if permitted. 

436 empty: boolean, optional `False` 

437 Only relevant for readonly views: if the value is empty, just present the 

438 empty string and nothing else. 

439 withLabel: boolean, optional `True` 

440 Whether to precede the value with a field label. 

441 The label is specified in the field specs which are in the table's .yaml 

442 file in `control/tables`. 

443 cls: string, optional `''` 

444 A CSS class to append to the outer `<div>` of the result. 

445 

446 Returns 

447 ------- 

448 string(html) 

449 If there was an `action`, the bare representation of the field value is returned. 

450 Otherwise, and if `withLabel`, a label is added. 

451 """ 

452 

453 mayRead = self.mayRead 

454 

455 if mayRead is False: 

456 return E 

457 

458 asMaster = self.asMaster 

459 mayEdit = self.mayEdit 

460 

461 if action is not None and not asMaster: 

462 contentLength = request.content_length 

463 if contentLength is not None and contentLength > LIMIT_JSON: 463 ↛ 464line 463 didn't jump to line 464, because the condition on line 463 was never true

464 abort(400) 

465 data = request.get_json() 

466 if data is not None and N.save in data: 

467 if mayEdit: 

468 good = self.save(data[N.save]) 

469 else: 

470 good = False 

471 if not good: 

472 abort(400) 

473 

474 if action == N.save: 474 ↛ 475line 474 didn't jump to line 475, because the condition on line 474 was never true

475 return E 

476 

477 editable = mayEdit and (action == N.edit or asEdit) and not asMaster 

478 widget = self.wrapWidget(editable, cls=cls) 

479 

480 if action is not None: 

481 return H.join(widget) 

482 

483 if empty and self.isEmpty(): 483 ↛ 484line 483 didn't jump to line 484, because the condition on line 483 was never true

484 return E 

485 

486 label = self.label 

487 editClass = " edit" if editable else E 

488 

489 return ( 

490 H.div( 

491 [ 

492 H.div(f"""{label}:""", cls="record-label"), 

493 H.div(widget, cls=f"record-value{editClass}"), 

494 ], 

495 cls="record-row", 

496 ) 

497 if withLabel 

498 else H.div(widget, cls=f"record-value{editClass}") 

499 ) 

500 

501 def wrapWidget(self, editable, cls=E): 

502 """Wrap the field value. 

503 

504 A widget shows the value and may have additional controls to 

505 

506 * edit the value 

507 * refresh the value 

508 

509 Refresh fields are those fields that change if other fields are updated, 

510 typically fields that record the moment on which something happened. 

511 These fields will get a refresh button automatically. 

512 

513 Fields may have three conditions relevant for rendering: 

514 

515 condition | rendering 

516 --- | --- 

517 not editable | readonly 

518 editable in readonly view | readonly with button for editable view 

519 editable in edit view | editable with button for readonly view 

520 

521 Parameters 

522 ---------- 

523 editable: boolean 

524 Whether the field should be presented in editable form 

525 cls 

526 See `Field.wrap()` 

527 

528 Returns 

529 ------- 

530 button: string(html) 

531 representation: string(html) 

532 

533 They are packaged as a tuple. 

534 """ 

535 

536 atts = self.atts 

537 mayEdit = self.mayEdit 

538 withRefresh = self.withRefresh 

539 

540 button = ( 

541 H.iconx(N.ok, cls="small", action=N.view, **atts) 

542 if editable 

543 else ( 

544 H.iconx(N.edit, cls="small", action=N.edit, **atts) 

545 if mayEdit 

546 else H.iconx( 

547 N.refresh, 

548 cls="small", 

549 action=N.view, 

550 title=REFRESH, 

551 **atts, 

552 ) 

553 if withRefresh 

554 else E 

555 ) 

556 ) 

557 

558 return (button, self.wrapValue(editable, cls=cls)) 

559 

560 def wrapValue(self, editable, cls=E): 

561 """Wraps the value of a field. 

562 

563 !!! hint "field=value in comments" 

564 In the result we include a string 

565 

566 ``` html 

567 <!-- title=My contribution --> 

568 ``` 

569 

570 but then with the field name instead of `title` 

571 and the unmarked-up value instead of `My contribution`. 

572 This makes it easier for the test suite 

573 to spot the fields and their values. 

574 

575 Parameters 

576 ---------- 

577 editable: boolean 

578 Whether the field should be presented in editable form 

579 cls 

580 See `Field.wrap()` 

581 

582 Returns 

583 ------- 

584 string(html) 

585 """ 

586 context = self.context 

587 types = context.types 

588 fieldTypeObj = self.fieldTypeObj 

589 field = self.field 

590 value = self.value 

591 tp = self.tp 

592 multiple = self.multiple 

593 extensible = self.extensible 

594 widgetType = self.widgetType 

595 

596 baseCls = "tags" if widgetType == N.related else "values" 

597 isSelectWidget = widgetType == N.related 

598 

599 args = [] 

600 if isSelectWidget and editable: 

601 record = self.record 

602 constrain = None 

603 constrainField = G(CONSTRAINED, field) 

604 if constrainField: 604 ↛ 608line 604 didn't jump to line 608, because the condition on line 604 was never false

605 constrainValue = G(record, constrainField) 

606 if constrainValue: 606 ↛ 608line 606 didn't jump to line 608, because the condition on line 606 was never false

607 constrain = (constrainField, constrainValue) 

608 args.append(multiple) 

609 args.append(extensible) 

610 args.append(constrain) 

611 atts = dict(wtype=widgetType) 

612 

613 if editable: 

614 typeObj = getattr(types, tp, None) 

615 method = typeObj.toOrig 

616 origStr = [method(v) for v in value or []] if multiple else method(value) 

617 atts[N.orig] = bencode(origStr) 

618 if multiple: 

619 atts[N.multiple] = ONE 

620 if extensible: 

621 atts[N.extensible] = ONE 

622 

623 method = fieldTypeObj.widget if editable else fieldTypeObj.toDisplay 

624 extraCls = E if editable else cls 

625 valueBare = ( 

626 (COMMA.join(val for val in value or []) if multiple else value) 

627 if tp == N.markdown 

628 else ( 

629 COMMA.join( 

630 fieldTypeObj.toDisplay(val, markup=False) for val in value or [] 

631 ) 

632 if multiple 

633 else fieldTypeObj.toDisplay(value, markup=False) 

634 ) 

635 ) 

636 

637 return f"<!-- {field}={valueBare} -->" + ( 

638 H.div( 

639 [ 

640 method(val, *args) 

641 for val in (value or []) + ([E] if editable else []) 

642 ], 

643 **atts, 

644 cls=baseCls, 

645 ) 

646 if multiple and not (editable and isSelectWidget) 

647 else H.div(method(value, *args), **atts, cls=f"value {extraCls}") 

648 )