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"""Records in tables. 

2 

3* Rendering 

4* Modification 

5* Deletion 

6""" 

7 

8from config import Config as C, Names as N 

9from control.perm import permRecord 

10from control.utils import pick as G, cap1, E, ELLIPS, ONE, S 

11from control.html import HtmlElements as H 

12from control.field import Field 

13 

14from control.cust.factory_details import factory as detailsFactory 

15 

16 

17CT = C.tables 

18CW = C.web 

19 

20 

21MASTERS = CT.masters 

22MAIN_TABLE = CT.userTables[0] 

23ACTUAL_TABLES = set(CT.actualTables) 

24REFRESH_TABLES = set(CT.refreshTables) 

25USER_TABLES_LIST = CT.userTables 

26USER_TABLES = set(USER_TABLES_LIST) 

27WORKFLOW_TABLES = USER_TABLES | set(CT.userEntryTables) 

28CASCADE_SPECS = CT.cascade 

29 

30# an easy way to go from assessment to contrib and from contrib to assessment 

31# used in deleteButton 

32 

33TO_MASTER = { 

34 USER_TABLES_LIST[i + 1]: USER_TABLES_LIST[i] 

35 for i in range(len(USER_TABLES_LIST) - 1) 

36} 

37 

38 

39class Record: 

40 """Deals with records.""" 

41 

42 inheritProps = ( 

43 N.context, 

44 N.uid, 

45 N.eppn, 

46 N.mkTable, 

47 N.table, 

48 N.fields, 

49 N.prov, 

50 N.isUserTable, 

51 N.isUserEntryTable, 

52 N.itemLabels, 

53 ) 

54 

55 def __init__( 

56 self, 

57 tableObj, 

58 eid=None, 

59 record=None, 

60 withDetails=False, 

61 readonly=False, 

62 bodyMethod=None, 

63 ): 

64 """## Initialization 

65 

66 Store the incoming information. 

67 

68 A number of properties will be inherited from the table object 

69 that spawns a record object. 

70 

71 Parameters 

72 ---------- 

73 tableObj: object 

74 See below. 

75 eid, record, withDetails, readonly, bodyMethod 

76 See `control.table.Table.record` 

77 """ 

78 

79 for prop in Record.inheritProps: 

80 setattr(self, prop, getattr(tableObj, prop, None)) 

81 

82 self.tableObj = tableObj 

83 """*object* A `control.table.Table` object (or one of a derived class) 

84 """ 

85 

86 self.withDetails = withDetails 

87 """*boolean* Whether to present a list of detail records below the record. 

88 """ 

89 

90 self.readonly = readonly 

91 """*boolean* Whether to present the complete record in readonly mode. 

92 """ 

93 

94 self.bodyMethod = bodyMethod 

95 """*function* How to compose the HTML for the body of the record. 

96 """ 

97 

98 context = self.context 

99 table = self.table 

100 

101 self.DetailsClass = detailsFactory(table) 

102 """*class* The class used for presenting details of this record. 

103 

104 It might be the base class `control.details.Details` or one of its 

105 derived classes. 

106 """ 

107 

108 if record is None: 

109 record = context.getItem(table, eid) 

110 self.record = record 

111 """*dict* The data of the record, keyed by field names. 

112 """ 

113 

114 self.eid = G(record, N._id) 

115 """*ObjectId* The id of the record. 

116 """ 

117 

118 self.setPerm() 

119 

120 self.setWorkflow() 

121 self.mayDelete = self.getDelPerm() 

122 """*boolean* Whether the user may delete the record. 

123 """ 

124 

125 def getDelPerm(self): 

126 """Compute the delete permission for this record. 

127 

128 The unbreakable rule is: 

129 * Records with dependencies cannot be deleted if the dependencies 

130 are not configured as `cascade-delete` in tables.yaml. 

131 

132 The next rules are workflow rules: 

133 

134 * if a record is fixed due to workflow constraints, no one can delete it; 

135 * if a record is unfixed due to workflow, a user may delete it, 

136 irrespective of normal permissions; workflow will determine 

137 which records will appear unfixed to which users; 

138 

139 If these rules do not clinch it, the normal permission rules will 

140 be applied: 

141 

142 * authenticated users may delete their own records in the 

143 `contrib`, `assessment` and `review` tables 

144 * superusers may delete records if the configured edit 

145 permissions allow them 

146 """ 

147 

148 context = self.context 

149 auth = context.auth 

150 isUserTable = self.isUserTable 

151 isUserEntryTable = self.isUserEntryTable 

152 readonly = self.readonly 

153 perm = self.perm 

154 fixed = self.fixed 

155 

156 isAuthenticated = auth.authenticated() 

157 isSuperuser = auth.superuser() 

158 

159 normalDelPerm = ( 

160 not isUserEntryTable 

161 and not readonly 

162 and isAuthenticated 

163 and (isSuperuser or isUserTable and G(perm, N.isEdit)) 

164 ) 

165 return False if fixed else normalDelPerm 

166 

167 def reload( 

168 self, 

169 record, 

170 ): 

171 """Re-initializes a record object if its underlying data has changed. 

172 

173 This might be caused by an update in the record itself, 

174 or a change in workflow conditions. 

175 """ 

176 

177 self.record = record 

178 self.setPerm() 

179 self.setWorkflow() 

180 self.mayDelete = self.getDelPerm() 

181 

182 def getDependencies(self): 

183 """Compute dependent records. 

184 

185 See `control.db.Db.dependencies`. 

186 """ 

187 

188 context = self.context 

189 db = context.db 

190 table = self.table 

191 record = self.record 

192 

193 return db.dependencies(table, record) 

194 

195 def setPerm(self): 

196 """Compute permission info for this record. 

197 

198 See `control.perm.permRecord`. 

199 """ 

200 

201 context = self.context 

202 table = self.table 

203 record = self.record 

204 

205 self.perm = permRecord(context, table, record) 

206 

207 def setWorkflow(self): 

208 """Compute a workflow item for this record. 

209 

210 The workflow item corresponds to this record 

211 if it is in the contrib table, otherwise to the 

212 contrib that is the (grand)master of this record. 

213 

214 See `control.context.Context.getWorkflowItem` and 

215 `control.workflow.apply.WorkflowItem`. 

216 

217 Returns 

218 ------- 

219 void 

220 The attribute `wfitem` will point to the workflow item. 

221 If the record is not a valid part of any workflow, 

222 or if there is no workflow item found, 

223 `wfitem` will be set to `None`. 

224 """ 

225 

226 context = self.context 

227 perm = self.perm 

228 table = self.table 

229 eid = self.eid 

230 record = self.record 

231 

232 contribId = G(perm, N.contribId) 

233 

234 self.kind = None 

235 self.fixed = None 

236 valid = False 

237 

238 wfitem = context.getWorkflowItem(contribId) 

239 if wfitem: 

240 self.kind = wfitem.getKind(table, record) 

241 valid = wfitem.isValid(table, eid, record) 

242 self.mayRead = wfitem.checkReadable(self) 

243 else: 

244 valid = False if table in USER_TABLES - {MAIN_TABLE} else True 

245 self.mayRead = None 

246 

247 if valid and wfitem: 

248 self.fixed = wfitem.checkFixed(self) 

249 self.wfitem = wfitem 

250 else: 

251 self.wfitem = None 

252 

253 self.valid = valid 

254 

255 def adjustWorkflow(self, update=True, delete=False): 

256 """Recompute workflow information. 

257 

258 When this record or some other record has changed, it could have had 

259 an impact on the workflow. 

260 If there is reason to assume this has happened, this function can be called 

261 to recompute the workflow item. 

262 

263 !!! warning 

264 Do not confuse this method with the one with the same name in Tables: 

265 `control.table.Table.adjustWorkflow` which does its work after the 

266 insertion of a record. 

267 

268 Parameters 

269 ---------- 

270 update: boolean, optional `True` 

271 If `True`, reset the attribute `wfitem` to the recomputed workflow. 

272 Otherwise, recomputation is done, but the attribute is not reset. 

273 This is done if there is no use of the workflow info for the remaining 

274 steps in processing the request. 

275 delete: boolean, optional `False` 

276 If `True`, delete the workflow item and set the attribute `wfitem` 

277 to `None` 

278 

279 Returns 

280 ------- 

281 void 

282 The attribute `wfitem` will be set again. 

283 """ 

284 

285 context = self.context 

286 wf = context.wf 

287 perm = self.perm 

288 

289 contribId = G(perm, N.contribId) 

290 if delete: 

291 wf.delete(contribId) 

292 self.wfitem = None 

293 else: 

294 wf.recompute(contribId) 

295 if update: 

296 self.wfitem = context.getWorkflowItem(contribId, requireFresh=True) 

297 

298 def task(self, task): 

299 """Perform a workflow task. 

300 

301 See `control.workflow.apply.WorkflowItem.doTask`. 

302 """ 

303 

304 wfitem = self.wfitem 

305 

306 url = None 

307 good = False 

308 

309 if wfitem: 

310 url = wfitem.doTask(task, self) 

311 

312 if url is None: 

313 table = self.table 

314 eid = self.eid 

315 url = f"""/{table}/{N.item}/{eid}""" 

316 else: 

317 good = True 

318 return (good, url) 

319 

320 def field(self, fieldName, **kwargs): 

321 """Factory function to wrap a field object around the data of a field. 

322 

323 !!! note 

324 Workflow information will be checked whether this record is fixated. 

325 If so, the new field object will be initialized with parameters 

326 to make it uneditable. 

327 

328 !!! note 

329 It will also be checked whether the field is a workflow field. 

330 Such fields are not shown and edited in the normal way, 

331 hence they will be set to unreadable and uneditable. 

332 The manipulation of such fields is under control of workflow. 

333 See `control.workflow.apply.WorkflowItem.isTask`. 

334 

335 !!! caution 

336 The name of the field must be one for which field specs are defined 

337 in the yaml file for the table. 

338 

339 Parameters 

340 ---------- 

341 fieldName: string 

342 

343 Returns 

344 ------- 

345 object 

346 A `control.field.Field` object. 

347 """ 

348 

349 fields = self.fields 

350 if fieldName not in fields: 

351 return None 

352 

353 table = self.table 

354 wfitem = self.wfitem 

355 

356 forceEdit = G(kwargs, N.mayEdit) 

357 

358 if wfitem: 

359 fixed = wfitem.checkFixed(self, field=fieldName) 

360 if fixed: 

361 kwargs[N.mayEdit] = False 

362 if wfitem.isTask(table, fieldName): 

363 kwargs[N.mayRead] = False 

364 kwargs[N.mayEdit] = forceEdit or not fixed 

365 return Field(self, fieldName, **kwargs) 

366 

367 def delete(self): 

368 """Delete a record. 

369 

370 Permissions and dependencies will be checked, as a result, 

371 the deletion may be prevented. 

372 See `Record.getDependencies` and `Record.getDelPerm`. 

373 

374 If deletion happens, workflow information will be adapted afterwards. 

375 See `Record.adjustWorkflow`. 

376 """ 

377 

378 mayDelete = self.mayDelete 

379 if not mayDelete: 

380 return False 

381 

382 dependencies = self.getDependencies() 

383 nRef = G(dependencies, N.reference, default=0) 

384 

385 if nRef: 385 ↛ 386line 385 didn't jump to line 386, because the condition on line 385 was never true

386 return False 

387 

388 nCas = G(dependencies, N.cascade, default=0) 

389 if nCas: 

390 if not self.deleteDetails(): 390 ↛ 391line 390 didn't jump to line 391, because the condition on line 390 was never true

391 return False 

392 

393 context = self.context 

394 table = self.table 

395 eid = self.eid 

396 

397 good = context.deleteItem(table, eid) 

398 

399 if table == MAIN_TABLE: 

400 self.adjustWorkflow(delete=True) 

401 elif table in WORKFLOW_TABLES: 

402 self.adjustWorkflow(update=False) 

403 

404 return good 

405 

406 def deleteDetails(self): 

407 """Delete the details of a record. 

408 

409 Permissions and dependencies will be checked, as a result, 

410 the deletion may be prevented. 

411 See `Record.getDependencies` and `Record.getDelPerm`. 

412 

413 Returns 

414 ------- 

415 bool 

416 Whether there are still dependencies after deleting the details. 

417 """ 

418 

419 context = self.context 

420 db = context.db 

421 table = self.table 

422 eid = self.eid 

423 

424 for dtable in G(CASCADE_SPECS, table, default=[]): 

425 db.deleteMany(dtable, {table: eid}) 

426 dependencies = self.getDependencies() 

427 nRef = G(dependencies, N.reference, default=0) 

428 return nRef == 0 

429 

430 def body(self, myMasters=None, hideMasters=False): 

431 """Wrap the body of the record in HTML. 

432 

433 This is the part without the provenance information and without 

434 the detail records. 

435 

436 This method can be overridden by `body` methods in derived classes. 

437 

438 Parameters 

439 ---------- 

440 myMasters: iterable of string, optional `None` 

441 A declaration of which fields must be treated as master fields. 

442 hideMaster: boolean, optional `False` 

443 If `True`, all master fields as declared in `myMasters` will be left out. 

444 

445 Returns 

446 ------- 

447 string(html) 

448 """ 

449 

450 fieldSpecs = self.fields 

451 provSpecs = self.prov 

452 

453 return H.join( 

454 self.field(field, asMaster=field in myMasters).wrap() 

455 for field in fieldSpecs 

456 if (field not in provSpecs and not (hideMasters and field in myMasters)) 

457 ) 

458 

459 def wrap( 

460 self, 

461 inner=True, 

462 wrapMethod=None, 

463 expanded=1, 

464 withProv=True, 

465 hideMasters=False, 

466 showTable=None, 

467 showEid=None, 

468 extraCls=E, 

469 ): 

470 """Wrap the record into HTML. 

471 

472 A record can be displayed in several states: 

473 

474 expanded | effect 

475 --- | --- 

476 `-1` | only a title with a control to get the full details 

477 `0` | full details, no control to collapse/expand 

478 `1` | full details, with a control to collapse to the title 

479 

480 !!! note 

481 When a record in state `1` or `-1` is sent to the client, 

482 only the material that is displayed is sent. When the user clicks on the 

483 expand/collapse control, the other part is fetched from the server 

484 *at that very moment*. 

485 So collapsing/expanding can be used to refresh the view on a record 

486 if things have happened. 

487 

488 !!! caution 

489 The triggering of the fetch actions for expanded/collapsed material 

490 is done by the Javascript in `index.js`. 

491 A single function does it all, and it is sensitive to the exact attributes 

492 of the summary and the detail. 

493 If you tamper with this code, you might end up with an infinite loop of 

494 expanding and collapsing. 

495 

496 !!! hint 

497 Pay extra attention to the attribute `fat`! 

498 When it is present, it is an indication that the expanded material 

499 is already on the client, and that it does not have to be fetched. 

500 

501 !!! note 

502 There are several ways to customise the effect of `wrap` for specific 

503 tables. Start with writing a derived class for that table with 

504 `Record` as base class. 

505 

506 * write an alternative for `Record.body`, 

507 e.g. `control.cust.review_record.ReviewR.bodyCompact`, and 

508 initialize the `Record` with `(bodyMethod='compact')`. 

509 Just specify the part of the name after `body` as string starting 

510 with a lower case. 

511 * override `Record.body`. This app does not do this currently. 

512 * use a custom `wrap` function, by defining it in your derived class, 

513 e.g. `control.cust.score_record.ScoreR.wrapHelp`. 

514 Use it by calling `Record.wrap(wrapMethod=scoreObj.wrapHelp)`. 

515 

516 Parameters 

517 ---------- 

518 inner: boolean, optional `True` 

519 Whether to add the CSS class `inner` to the outer `<div>` of the result. 

520 wrapMethod: function, optional `None` 

521 The method to compose the result out of all its components. 

522 Typically defined in a derived class. 

523 If passed, this function will be called to deliver the result. 

524 Otherwise, `wrap` does the composition itself. 

525 expanded: {-1, 0, 1} 

526 Whether to expand the record. 

527 See the table above. 

528 withProv: boolean, optional `True` 

529 Include a display of the provenance fields. 

530 hideMasters: boolean, optional `False` 

531 Whether to hide the master fields. 

532 If they are not hidden, they will be presented as hyperlinks to the 

533 master record. 

534 extraCls: string, optional `''` 

535 An extra class to add to the outer `<div>`. 

536 

537 Returns 

538 ------- 

539 string(html) 

540 """ 

541 

542 table = self.table 

543 eid = self.eid 

544 record = self.record 

545 provSpecs = self.prov 

546 valid = self.valid 

547 withDetails = self.withDetails 

548 

549 withRefresh = table in REFRESH_TABLES 

550 

551 func = getattr(self, wrapMethod, None) if wrapMethod else None 

552 if func: 552 ↛ 553line 552 didn't jump to line 553, because the condition on line 552 was never true

553 return func() 

554 

555 bodyMethod = self.bodyMethod 

556 urlExtra = f"""?method={bodyMethod}""" if bodyMethod else E 

557 fetchUrl = f"""/api/{table}/{N.item}/{eid}""" 

558 

559 itemKey = f"""{table}/{G(record, N._id)}""" 

560 theTitle = self.title() 

561 

562 if expanded == -1: 

563 return H.details( 

564 theTitle, 

565 H.div(ELLIPS), 

566 itemKey, 

567 fetchurl=fetchUrl, 

568 urlextra=urlExtra, 

569 urltitle=E, 

570 ) 

571 

572 bodyFunc = ( 

573 getattr(self, f"""{N.body}{cap1(bodyMethod)}""", self.body) 

574 if bodyMethod 

575 else self.body 

576 ) 

577 myMasters = G(MASTERS, table, default=[]) 

578 

579 deleteButton = self.deleteButton() 

580 

581 innerCls = " inner" if inner else E 

582 warningCls = E if valid else " warning " 

583 

584 provenance = ( 

585 H.div( 

586 H.detailx( 

587 (N.prov, N.dismiss), 

588 H.div( 

589 [self.field(field).wrap() for field in provSpecs], cls="prov" 

590 ), 

591 f"""{table}/{G(record, N._id)}/{N.prov}""", 

592 openAtts=dict( 

593 cls="button small", 

594 title="Provenance and editors of this record", 

595 ), 

596 closeAtts=dict(cls="button small", title="Hide provenance"), 

597 cls="prov", 

598 ), 

599 cls="provx", 

600 ) 

601 if withProv 

602 else E 

603 ) 

604 

605 main = H.div( 

606 [ 

607 deleteButton, 

608 H.div( 

609 H.join(bodyFunc(myMasters=myMasters, hideMasters=hideMasters)), 

610 cls=f"{table.lower()}", 

611 ), 

612 *provenance, 

613 ], 

614 cls=f"record{innerCls} {extraCls} {warningCls}", 

615 ) 

616 

617 rButton = H.iconr(itemKey, "#main", msg=table) if withRefresh else E 

618 details = ( 

619 self.DetailsClass(self).wrap(showTable=showTable, showEid=showEid) 

620 if withDetails 

621 else E 

622 ) 

623 

624 return ( 

625 H.details( 

626 rButton + theTitle, 

627 H.div(main + details), 

628 itemKey, 

629 fetchurl=fetchUrl, 

630 urlextra=urlExtra, 

631 urltitle="""/title""", 

632 fat=ONE, 

633 forceopen=ONE, 

634 open=True, 

635 ) 

636 if expanded == 1 

637 else H.div(main + details) 

638 ) 

639 

640 def wrapLogical(self): 

641 """Wrap the record into a dict. 

642 

643 A record can be displayed in several states: 

644 

645 full details 

646 

647 Returns 

648 ------- 

649 dict 

650 """ 

651 

652 table = self.table 

653 fieldSpecs = self.fields 

654 provSpecs = self.prov 

655 myMasters = G(MASTERS, table, default=[]) 

656 

657 record = { 

658 field: self.field(field, asMaster=field in myMasters).wrapBare(markup=None) 

659 for field in fieldSpecs 

660 if (field not in provSpecs and not (field in myMasters)) 

661 } 

662 record["id"] = self.eid 

663 return record 

664 

665 def deleteButton(self): 

666 """Show the delete button and/or the number of dependencies. 

667 

668 Check the permissions in order to not show a delete button if the user 

669 cannot delete the record. 

670 

671 Returns 

672 ------- 

673 string(html) 

674 """ 

675 

676 mayDelete = self.mayDelete 

677 

678 if not mayDelete: 

679 return E 

680 

681 record = self.record 

682 table = self.table 

683 itemSingle = self.itemLabels[0] 

684 

685 dependencies = self.getDependencies() 

686 

687 nCas = G(dependencies, N.cascade, default=0) 

688 cascadeMsg = ( 

689 H.span( 

690 f"""{nCas} detail record{E if nCas == 1 else S}""", 

691 title="""Detail records will be deleted with the master record""", 

692 cls="label small warning-o right", 

693 ) 

694 if nCas 

695 else E 

696 ) 

697 cascadeMsgShort = ( 

698 f""" and {nCas} dependent record{E if nCas == 1 else S}""" if nCas else E 

699 ) 

700 

701 nRef = G(dependencies, N.reference, default=0) 

702 

703 if nRef: 

704 plural = E if nRef == 1 else S 

705 return H.span( 

706 [ 

707 H.icon( 

708 N.chain, 

709 cls="medium right", 

710 title=f"""Cannot delete because of {nRef} dependent record{plural}""", 

711 ), 

712 H.span( 

713 f"""{nRef} dependent record{plural}""", 

714 cls="label small warning-o right", 

715 ), 

716 ] 

717 ) 

718 

719 if table in TO_MASTER: 

720 masterTable = G(TO_MASTER, table) 

721 masterId = G(record, masterTable) 

722 else: 

723 masterTable = None 

724 masterId = None 

725 

726 url = ( 

727 f"""/api/{table}/{N.delete}/{G(record, N._id)}""" 

728 if masterTable is None or masterId is None 

729 else f"""/api/{masterTable}/{masterId}/{table}/{N.delete}/{G(record, N._id)}""" 

730 ) 

731 return H.span( 

732 [ 

733 cascadeMsg, 

734 H.iconx( 

735 N.delete, 

736 cls="medium right warning", 

737 deleteurl=url, 

738 title=f"""delete this {itemSingle}{cascadeMsgShort}""", 

739 ), 

740 ] 

741 ) 

742 

743 def title(self, markup=True, **kwargs): 

744 """Generate a title for the record.""" 

745 record = self.record 

746 valid = self.valid 

747 

748 warningCls = E if valid else " warning " 

749 

750 return Record.titleRaw(self, record, cls=warningCls, markup=markup, **kwargs) 

751 

752 def inActualCls(self, record): 

753 """Get a CSS class name for a record based on whether it is *actual*. 

754 

755 Actual records belong to the current `package`, a record that specifies 

756 which contribution types, and criteria are currently part of the workflow. 

757 

758 Parameters 

759 ---------- 

760 record: dict | `None` 

761 If `self` does not have a `record` attribute, the record data must 

762 be passed here. 

763 Returns 

764 ------- 

765 string 

766 `inactual` if the record is not actual, else the empty string. 

767 """ 

768 

769 table = self.table 

770 if record is None: 

771 record = self.record 

772 

773 isActual = ( 

774 table not in ACTUAL_TABLES 

775 or not record 

776 or G(record, N.actual, default=False) 

777 ) 

778 return E if isActual else "inactual" 

779 

780 @staticmethod 

781 def titleRaw(obj, record, cls=E, markup=True, **kwargs): 

782 """Generate a title for a different record. 

783 

784 This is fast title generation. 

785 No record object will be created. 

786 

787 The title will be based on the fields in the record, 

788 and its formatting is assisted by the appropriate 

789 type class in `control.typ.types`. 

790 

791 !!! hint 

792 If the record is not "actual", its title will get a warning 

793 background color. 

794 See `control.db.Db.collect`. 

795 

796 Parameters 

797 ---------- 

798 obj: object 

799 Any object that has a table and context attribute, e.g. a table object 

800 or a record object 

801 record: dict 

802 cls: string, optional, `''` 

803 A CSS class to add to the outer element of the result 

804 """ 

805 

806 table = obj.table 

807 context = obj.context 

808 types = context.types 

809 typesObj = getattr(types, table, None) 

810 valueBare = typesObj.title(record=record, **kwargs) 

811 

812 if markup is None: 812 ↛ 813line 812 didn't jump to line 813, because the condition on line 812 was never true

813 return valueBare 

814 

815 inActualCls = Record.inActualCls(obj, record) 

816 atts = dict(cls=f"{cls} {inActualCls}") 

817 

818 return H.span(valueBare, **atts, **kwargs)