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] 

30LIMIT_JSON = CW.limitJson 

31 

32 

33class Field: 

34 """Deals with fields.""" 

35 

36 inheritProps = ( 

37 N.context, 

38 N.uid, 

39 N.eppn, 

40 N.table, 

41 N.record, 

42 N.eid, 

43 N.perm, 

44 N.readonly, 

45 N.mayRead, 

46 ) 

47 

48 def __init__( 

49 self, 

50 recordObj, 

51 field, 

52 asMaster=False, 

53 readonly=None, 

54 mayRead=None, 

55 mayEdit=None, 

56 ): 

57 """## Initialization 

58 

59 Store the incoming information. 

60 

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

62 that spawns a field object. 

63 

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

65 `value`. 

66 

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

68 `control.typ.base.TypeBase`. 

69 

70 !!! caution 

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

72 

73 !!! hint 

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

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

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

77 

78 !!! caution 

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

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

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

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

83 passing values for them here. 

84 

85 Parameters 

86 ---------- 

87 recordObj: object 

88 See below. 

89 field: string 

90 See below. 

91 asMaster: boolean, optional `False` 

92 See below. 

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

94 Whether the field must be presented readonly. 

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

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

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

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

99 """ 

100 

101 for prop in Field.inheritProps: 

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

103 

104 self.recordObj = recordObj 

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

106 """ 

107 

108 self.field = field 

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

110 """ 

111 

112 self.asMaster = asMaster 

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

114 """ 

115 

116 table = self.table 

117 

118 withNow = G(WITH_NOW, table) 

119 if withNow: 

120 nowFields = [] 

121 for info in withNow.values(): 

122 if type(info) is str: 

123 nowFields.append(info) 

124 else: 

125 nowFields.extend(info) 

126 nowFields = set(nowFields) 

127 else: 

128 nowFields = set() 

129 

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

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

132 """ 

133 

134 self.withNow = G(withNow, field) 

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

136 

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

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

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

140 corresponding timestamp fields. 

141 

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

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

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

145 

146 !!! hint 

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

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

149 receives the timestamp. 

150 """ 

151 

152 fieldSpecs = recordObj.fields 

153 fieldSpec = G(fieldSpecs, field) 

154 

155 record = self.record 

156 self.value = G(record, field) 

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

158 """ 

159 

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

161 self.require = require 

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

163 

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

165 """ 

166 

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

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

169 """ 

170 

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

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

173 

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

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

176 """ 

177 

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

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

180 """ 

181 

182 context = self.context 

183 

184 perm = self.perm 

185 table = self.table 

186 eid = self.eid 

187 tp = self.tp 

188 types = context.types 

189 

190 fieldTypeObj = getattr(types, tp, None) 

191 self.fieldTypeObj = fieldTypeObj 

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

193 """ 

194 

195 self.widgetType = fieldTypeObj.widgetType 

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

197 """ 

198 

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

200 

201 (self.mayRead, self.mayEdit) = getPermField( 

202 table, perm, require, **self.getActualMinimum() 

203 ) 

204 if mayRead is not None: 

205 self.mayRead = mayRead 

206 if mayEdit is not None: 

207 self.mayEdit = mayEdit 

208 

209 if readonly or asMaster: 

210 self.mayEdit = False 

211 

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

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

214 

215 !!! hint 

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

217 """ 

218 

219 def getActualMinimum(self): 

220 tp = self.tp 

221 if tp == N.permissionGroup: 

222 context = self.context 

223 db = context.db 

224 auth = context.auth 

225 user = auth.user 

226 userGroup = G(user, N.group) 

227 minimum = G(G(db.permissionGroup, userGroup), N.rep) 

228 value = self.value 

229 actual = G(G(db.permissionGroup, value), N.rep) 

230 return dict(actual=actual, minimum=minimum) 

231 return {} 

232 

233 def save(self, data): 

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

235 

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

237 

238 After saving, workflow information will be adjusted. 

239 

240 !!! caution 

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

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

243 `criteriaEntry` and `reviewEntry` records respectively. 

244 

245 Parameters 

246 ---------- 

247 data: mixed 

248 The new value(s) for this field. 

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

250 Python/MongoDb values 

251 """ 

252 

253 mayEdit = self.mayEdit 

254 

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

256 return False 

257 

258 context = self.context 

259 db = context.db 

260 uid = self.uid 

261 eppn = self.eppn 

262 table = self.table 

263 eid = self.eid 

264 field = self.field 

265 extensible = self.extensible 

266 record = self.record 

267 recordObj = self.recordObj 

268 require = self.require 

269 constrain = None 

270 constrainField = G(CONSTRAINED, field) 

271 if constrainField: 

272 constrainValue = G(record, constrainField) 

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

274 constrain = (constrainField, constrainValue) 

275 

276 multiple = self.multiple 

277 fieldTypeObj = self.fieldTypeObj 

278 conversion = fieldTypeObj.fromStr if fieldTypeObj else None 

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

280 if constrain: 

281 kwargs[N.constrain] = constrain 

282 

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

284 try: 

285 if multiple: 

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

287 else: 

288 data = conversion(data, **kwargs) 

289 except ConversionError: 

290 return False 

291 

292 modified = G(record, N.modified) 

293 nowFields = [] 

294 if data is not None: 

295 withNow = self.withNow 

296 if withNow: 

297 withNowField = ( 

298 withNow 

299 if type(withNow) is str 

300 else withNow[0] 

301 if data 

302 else withNow[1] 

303 ) 

304 nowFields.append(withNowField) 

305 

306 result = db.updateField( 

307 table, 

308 eid, 

309 field, 

310 data, 

311 eppn, 

312 modified, 

313 nowFields=nowFields, 

314 ) 

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

316 return False 

317 

318 (updates, deletions) = result 

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

320 

321 recordObj.reload(record) 

322 self.value = G(record, field) 

323 self.perm = recordObj.perm 

324 perm = self.perm 

325 (self.mayRead, self.mayEdit) = getPermField( 

326 table, perm, require, **self.getActualMinimum() 

327 ) 

328 

329 good = True 

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

331 for dtable in CASCADE_SPECS[table]: 

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

333 for drecord in drecords: 

334 deid = G(drecord, N._id) 

335 result = db.updateField( 

336 dtable, 

337 deid, 

338 N.editors, 

339 data, 

340 eppn, 

341 modified, 

342 nowFields=nowFields, 

343 ) 

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

345 good = False 

346 

347 if table in WORKFLOW_TABLES and field in WORKFLOW_FIELDS: 

348 recordObj.adjustWorkflow() 

349 

350 return good 

351 

352 def isEmpty(self): 

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

354 

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

356 

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

358 

359 Returns 

360 ------- 

361 boolean 

362 """ 

363 

364 value = self.value 

365 multiple = self.multiple 

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

367 

368 def isBlank(self): 

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

370 

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

372 

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

374 component values are blank. 

375 

376 Returns 

377 ------- 

378 boolean 

379 """ 

380 

381 value = self.value 

382 multiple = self.multiple 

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

384 value is None 

385 or value == E 

386 or multiple 

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

388 ) 

389 

390 def wrapBare(self, markup=True): 

391 """Produce the bare field value. 

392 

393 This is the result of calling the 

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

395 type class that matches the type of the field. 

396 

397 Returns 

398 ------- 

399 string(html) 

400 """ 

401 

402 context = self.context 

403 types = context.types 

404 tp = self.tp 

405 value = self.value 

406 multiple = self.multiple 

407 

408 fieldTypeObj = getattr(types, tp, None) 

409 method = fieldTypeObj.toDisplay 

410 

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

412 ( 

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

414 if multiple 

415 else method(value, markup=markup) 

416 ) 

417 if markup is None 

418 else ( 

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

420 if multiple 

421 else method(value, markup=markup) 

422 ) 

423 ) 

424 

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

426 """Wrap the field into HTML. 

427 

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

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

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

431 

432 action | effect 

433 --- | --- 

434 `save` | no rendering 

435 `view`| a read only rendering 

436 `edit` | an edit widget 

437 

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

439 

440 factor | story 

441 --- | --- 

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

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

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

445 

446 Parameters 

447 ---------- 

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

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

450 asEdit: boolean, optional `False` 

451 No data will be saved. 

452 The field will rendered editable, if permitted. 

453 empty: boolean, optional `False` 

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

455 empty string and nothing else. 

456 withLabel: boolean, optional `True` 

457 Whether to precede the value with a field label. 

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

459 file in `control/tables`. 

460 cls: string, optional `''` 

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

462 

463 Returns 

464 ------- 

465 string(html) 

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

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

468 """ 

469 

470 mayRead = self.mayRead 

471 

472 if mayRead is False: 

473 return E 

474 

475 asMaster = self.asMaster 

476 mayEdit = self.mayEdit 

477 

478 if action is not None and not asMaster: 

479 contentLength = request.content_length 

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

481 abort(400) 

482 data = request.get_json() 

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

484 if mayEdit: 

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

486 else: 

487 good = False 

488 if not good: 

489 abort(400) 

490 

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

492 return E 

493 

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

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

496 

497 if action is not None: 

498 return H.join(widget) 

499 

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

501 return E 

502 

503 label = self.label 

504 editClass = " edit" if editable else E 

505 

506 return ( 

507 H.div( 

508 [ 

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

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

511 ], 

512 cls="record-row", 

513 ) 

514 if withLabel 

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

516 ) 

517 

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

519 """Wrap the field value. 

520 

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

522 

523 * edit the value 

524 * refresh the value 

525 

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

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

528 These fields will get a refresh button automatically. 

529 

530 Fields may have three conditions relevant for rendering: 

531 

532 condition | rendering 

533 --- | --- 

534 not editable | readonly 

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

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

537 

538 Parameters 

539 ---------- 

540 editable: boolean 

541 Whether the field should be presented in editable form 

542 cls 

543 See `Field.wrap()` 

544 

545 Returns 

546 ------- 

547 button: string(html) 

548 representation: string(html) 

549 

550 They are packaged as a tuple. 

551 """ 

552 

553 atts = self.atts 

554 mayEdit = self.mayEdit 

555 withRefresh = self.withRefresh 

556 

557 button = ( 

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

559 if editable 

560 else ( 

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

562 if mayEdit 

563 else H.iconx( 

564 N.refresh, 

565 cls="small", 

566 action=N.view, 

567 title=REFRESH, 

568 **atts, 

569 ) 

570 if withRefresh 

571 else E 

572 ) 

573 ) 

574 

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

576 

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

578 """Wraps the value of a field. 

579 

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

581 In the result we include a string 

582 

583 ``` html 

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

585 ``` 

586 

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

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

589 This makes it easier for the test suite 

590 to spot the fields and their values. 

591 

592 Parameters 

593 ---------- 

594 editable: boolean 

595 Whether the field should be presented in editable form 

596 cls 

597 See `Field.wrap()` 

598 

599 Returns 

600 ------- 

601 string(html) 

602 """ 

603 context = self.context 

604 types = context.types 

605 fieldTypeObj = self.fieldTypeObj 

606 field = self.field 

607 value = self.value 

608 tp = self.tp 

609 multiple = self.multiple 

610 extensible = self.extensible 

611 widgetType = self.widgetType 

612 

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

614 isSelectWidget = widgetType == N.related 

615 

616 args = [] 

617 if isSelectWidget and editable: 

618 record = self.record 

619 constrain = None 

620 constrainField = G(CONSTRAINED, field) 

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

622 constrainValue = G(record, constrainField) 

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

624 constrain = (constrainField, constrainValue) 

625 args.append(multiple) 

626 args.append(extensible) 

627 args.append(constrain) 

628 atts = dict(wtype=widgetType) 

629 

630 if editable: 

631 typeObj = getattr(types, tp, None) 

632 method = typeObj.toOrig 

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

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

635 if multiple: 

636 atts[N.multiple] = ONE 

637 if extensible: 

638 atts[N.extensible] = ONE 

639 

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

641 extraCls = E if editable else cls 

642 valueBare = ( 

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

644 if tp == N.markdown 

645 else ( 

646 COMMA.join( 

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

648 ) 

649 if multiple 

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

651 ) 

652 ) 

653 

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

655 H.div( 

656 [ 

657 method(val, *args) 

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

659 ], 

660 **atts, 

661 cls=baseCls, 

662 ) 

663 if multiple and not (editable and isSelectWidget) 

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

665 )