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"""Tables. 

2 

3* Selection 

4* Rendering 

5* Record insertion 

6""" 

7 

8from flask import request 

9 

10from config import Config as C, Names as N 

11from control.html import HtmlElements as H 

12from control.utils import pick as G, E, ELLIPS, NBSP, ONE 

13from control.perm import checkTable 

14from control.cust.factory_record import factory as recordFactory 

15 

16CP = C.perm 

17CT = C.tables 

18CW = C.workflow 

19 

20 

21GROUP_RANK = CP.groupRank 

22MAIN_TABLE = CT.userTables[0] 

23INTER_TABLE = CT.userTables[1] 

24USER_TABLES = set(CT.userTables) 

25USER_ENTRY_TABLES = set(CT.userEntryTables) 

26SENSITIVE_TABLES = (USER_TABLES - {MAIN_TABLE}) | USER_ENTRY_TABLES 

27SENSITIVE_FIELDS = {"costTotal", "costDescription"} 

28VALUE_TABLES = set(CT.valueTables) 

29SYSTEM_TABLES = set(CT.systemTables) 

30ITEMS = CT.items 

31PROV_SPECS = CT.prov 

32 

33ASSESSMENT_STAGES = set(CW.assessmentStages) 

34 

35 

36class Table: 

37 """Deals with tables.""" 

38 

39 def __init__(self, context, table): 

40 """## Initialization 

41 

42 Store the incoming information. 

43 

44 Set the RecordClass to a suitable derived class of Record, 

45 otherwise to the base class `control.record.Record` itself. 

46 

47 Parameters 

48 ---------- 

49 context: object 

50 See below. 

51 table: string 

52 See below. 

53 """ 

54 

55 self.context = context 

56 """*object* A `control.context.Context` singleton. 

57 """ 

58 

59 db = context.db 

60 auth = context.auth 

61 user = auth.user 

62 

63 self.table = table 

64 """*string* Name of the table. 

65 """ 

66 

67 self.isMainTable = table == MAIN_TABLE 

68 """*boolean* Whether the table is the main table, i.e. `contrib`. 

69 """ 

70 

71 self.isInterTable = table == INTER_TABLE 

72 """*boolean* Whether the table is the inter table, i.e. `assessment`. 

73 """ 

74 

75 self.isUserTable = table in USER_TABLES 

76 """*boolean* Whether the table is one that collects user content. 

77 

78 !!! hint 

79 As opposed to value tables. 

80 """ 

81 

82 self.isUserEntryTable = table in USER_ENTRY_TABLES 

83 """*boolean* Whether the table is one that collects user entries. 

84 

85 !!! hint 

86 `criteriaEntry` and `reviewEntry`. 

87 """ 

88 

89 self.isValueTable = table in VALUE_TABLES 

90 """*boolean* Whether the table is a value table. 

91 

92 Value tables have records that contain representations of fixed values, 

93 e.g. disciplines, decisions, scores, and also users and criteria. 

94 """ 

95 

96 self.isSystemTable = table in SYSTEM_TABLES 

97 """*boolean* Whether the table is a system table. 

98 

99 Some value tables are deemed system tables, e.g. `decision`, `permissionGroup`. 

100 """ 

101 

102 self.itemLabels = G(ITEMS, table, default=[table, f"""{table}s"""]) 

103 """*(string, string)* How to call an item in the table, singular and plural. 

104 """ 

105 

106 self.prov = PROV_SPECS 

107 """*dict* Field specifications for the provenance fields. 

108 

109 As in tables.yaml under key `prov`. 

110 """ 

111 

112 self.fields = getattr(CT, table, {}) 

113 """*dict* Field specifications for the fields in this table. 

114 

115 As in the xxx.yaml file in the `server/tables`, where `xxx` is the name of 

116 the table. 

117 """ 

118 

119 self.uid = G(user, N._id) 

120 """*ObjectId* The id of the current user. 

121 """ 

122 

123 self.eppn = G(user, N.eppn) 

124 """*ObjectId* The eppn of the current user. 

125 

126 !!! hint 

127 The eppn is the user identifying attribute from the identity provider. 

128 """ 

129 

130 self.group = auth.groupRep() 

131 """*ObjectId* The permission group of the current user. 

132 """ 

133 

134 self.countryId = G(user, N.country) 

135 """*ObjectId* The country of the current user. 

136 """ 

137 

138 isUserTable = self.isUserTable 

139 isValueTable = self.isValueTable 

140 isSystemTable = self.isSystemTable 

141 isSuperuser = auth.superuser() 

142 isSysadmin = auth.sysadmin() 

143 

144 self.mayInsert = auth.authenticated() and ( 

145 isUserTable or isValueTable and isSuperuser or isSystemTable and isSysadmin 

146 ) 

147 """*boolean* Whether the user may insert a new record into this table. 

148 """ 

149 

150 def titleSortkey(r): 

151 return self.title(r, withRole=True).lower() 

152 

153 self.titleSortkey = titleSortkey 

154 """*function* Given a record delivers a key for sorting the records. 

155 

156 The key is based on the title. 

157 """ 

158 

159 def groupSortkey(r): 

160 title = self.title(r, withRole=True).lower() 

161 group = G(r, N.group, "") 

162 groupRep = G(G(db.permissionGroup, group, {}), N.rep, E) or E 

163 rank = G(GROUP_RANK, groupRep, 0) 

164 return (-rank, title) 

165 

166 self.groupSortkey = groupSortkey 

167 """*function* Given a record delivers a key for sorting the records. 

168 

169 The key is based on the permission group and then on the title. 

170 """ 

171 

172 self.RecordClass = recordFactory(table) 

173 """*class* The class used for manipulating records of this table. 

174 

175 It might be the base class `control.record.Record` or one of its 

176 derived classes. 

177 """ 

178 

179 def record( 

180 self, eid=None, record=None, withDetails=False, readonly=False, bodyMethod=None, 

181 ): 

182 """Factory function to wrap a record object around the data of a record. 

183 

184 !!! note 

185 Only one of `eid` or `record` needs to be passed. 

186 

187 Parameters 

188 ---------- 

189 eid: ObjectId, optional `None` 

190 Entity id to identify the record 

191 record: dict, optional `None` 

192 The full record 

193 withDetails: boolean, optional `False` 

194 Whether to present a list of detail records below the record 

195 readonly: boolean, optional `False` 

196 Whether to present the complete record in readonly mode 

197 bodyMethod: function, optional `None` 

198 How to compose the HTML for the body of the record. 

199 If `None` is passed, the default will be chosen: 

200 `control.record.Record.body`. 

201 Some particular tables have their own implementation of `body()` 

202 and they may supply alternative body methods as well. 

203 

204 Returns 

205 ------- 

206 object 

207 A `control.record.Record` object. 

208 """ 

209 

210 return self.RecordClass( 

211 self, 

212 eid=eid, 

213 record=record, 

214 withDetails=withDetails, 

215 readonly=readonly, 

216 bodyMethod=bodyMethod, 

217 ) 

218 

219 def readable(self, record): 

220 """Is the record readable? 

221 

222 !!! note 

223 Readibility is a workflow condition. 

224 We have to construct a record object and retrieve workflow info 

225 to find out. 

226 

227 Parameters 

228 ---------- 

229 record: dict 

230 The full record 

231 

232 Returns 

233 ------- 

234 boolean 

235 """ 

236 

237 return self.RecordClass(self, record=record).mayRead is not False 

238 

239 def insert(self, force=False): 

240 """Insert a new, (blank) record into the table. 

241 

242 !!! note 

243 The permission is defined upon intialization of the record. 

244 See `control.table.Table` . 

245 

246 The rules are: 

247 * authenticated users may create new records in the main user tables: 

248 `contrib`, and, (under additional workflow constraints), 

249 `assessment`, `review`. 

250 * superusers may create new value records (under additional 

251 constraints) 

252 * system admins may create new records in system tables 

253 

254 !!! note 

255 `force=True` is used when the system needs to insert additional 

256 records in other tables. The code for specific tables will instruct so. 

257 

258 Parameters 

259 ---------- 

260 force: boolean, optional `False` 

261 Permissions are respected, unless `force=True`. 

262 

263 Returns 

264 ------- 

265 ObjectId 

266 id of the inserted item 

267 """ 

268 

269 mayInsert = force or self.mayInsert and self.withInsert(N.my) 

270 if not mayInsert: 

271 return None 

272 

273 context = self.context 

274 db = context.db 

275 uid = self.uid 

276 eppn = self.eppn 

277 table = self.table 

278 

279 result = db.insertItem(table, uid, eppn, False) 

280 if table == MAIN_TABLE: 

281 self.adjustWorkflow(result) 

282 

283 return result 

284 

285 def adjustWorkflow(self, contribId, new=True): 

286 """Adjust the `control.workflow.apply.WorkflowItem` 

287 that is dependent on changed data. 

288 

289 Parameters 

290 ---------- 

291 contribId: ObjectId 

292 The id of the workflow item. 

293 new: boolean, optional `True` 

294 If `True`, insert the computed workflow as a new item; 

295 otherwise update the existing item. 

296 """ 

297 

298 context = self.context 

299 wf = context.wf 

300 

301 if new: 

302 wf.insert(contribId) 

303 else: 

304 wf.recompute(contribId) 

305 

306 def stage(self, record, table, kind=None): 

307 """Retrieve the workflow attribute `stage` from a record, if existing. 

308 

309 This is a quick and direct way to retrieve workflow info for a record. 

310 

311 Parameters 

312 ---------- 

313 record: dict 

314 The full record 

315 table: string {contrib, assessment, review} 

316 kind: string {`expert`, `final`}, optional `None` 

317 Only if we want review attributes 

318 

319 Returns 

320 ------- 

321 string | `None` 

322 """ 

323 

324 recordObj = self.record(record=record) 

325 

326 wfitem = recordObj.wfitem 

327 return wfitem.stage(table, kind=kind) if wfitem else None 

328 

329 def creators(self, record, table, kind=None): 

330 """Retrieve the workflow attribute `creators` from a record, if existing. 

331 

332 This is a quick and direct way to retrieve workflow info for a record. 

333 

334 Parameters 

335 ---------- 

336 record: dict 

337 The full record 

338 table: string {contrib, assessment, review} 

339 kind: string {`expert`, `final`}, optional `None` 

340 Only if we want review attributes 

341 

342 Returns 

343 ------- 

344 (list of ObjectId) | `None` 

345 """ 

346 

347 recordObj = self.record(record=record) 

348 

349 wfitem = recordObj.wfitem 

350 return wfitem.creators(table, kind=kind) if wfitem else None 

351 

352 def wrap(self, openEid, action=None, logical=False): 

353 """Wrap the list of records into HTML or Json. 

354 

355 action | selection 

356 --- | --- 

357 `my` | records that the current user has created or is an editor of 

358 `our` | records that the current user can edit, assess, review, or select 

359 `assess` | records that the current user is assessing 

360 `assign` | records that the current office user must assign to reviewers 

361 `reviewer` | records that the current user is reviewing 

362 `reviewdone` | records that the current user has reviewed 

363 `select` | records that the current national coordinator user can select 

364 

365 Permissions will be checked before executing one of these list actions. 

366 See `control.table.Table.mayList`. 

367 

368 !!! caution "Workflow restrictions" 

369 There might be additional restrictions on individual records 

370 due to workflow. Some records may not be readable. 

371 They will be filtered out. 

372 

373 !!! note 

374 Whether records are presented in an opened or closed state 

375 depends onn how the user has last left them. 

376 This information is stored in `localStorage` inn the browser. 

377 However, if the last action was the creation of a nnew record, 

378 we want to open the list with the new record open and scrolled to, 

379 so that the usercan start filling in the blank record straightaway. 

380 

381 Parameters 

382 ---------- 

383 openEid: ObjectId 

384 The id of a record that must forcibly be opened. 

385 action: string, optional, `None` 

386 If present, a specific record selection will be presented, 

387 otherwise all records go to the interface. 

388 logical: boolean, optional `False` 

389 If True, return the data as a dict or list, otherwise wrap it in HTML 

390 

391 Returns 

392 ------- 

393 string(html) or any 

394 """ 

395 

396 if not self.mayList(action=action): 396 ↛ 397line 396 didn't jump to line 397, because the condition on line 396 was never true

397 return None 

398 

399 context = self.context 

400 db = context.db 

401 table = self.table 

402 uid = self.uid 

403 countryId = self.countryId 

404 titleSortkey = self.groupSortkey if table == N.user else self.titleSortkey 

405 (itemSingular, itemPlural) = self.itemLabels 

406 

407 params = ( 

408 dict(my=uid) 

409 if action == N.my 

410 else dict(our=countryId) 

411 if action == N.our 

412 else dict(my=uid) 

413 if action == N.assess 

414 else dict(assign=True) 

415 if action == N.assign 

416 else dict(review=uid) 

417 if action == N.review 

418 else dict(review=uid) 

419 if action == N.reviewdone 

420 else dict(selectable=countryId) 

421 if action == N.select 

422 else {} 

423 ) 

424 if request.args: 

425 params.update(request.args) 

426 

427 records = db.getList(table, titleSortkey, select=self.isMainTable, **params) 

428 if not logical: 428 ↛ 432line 428 didn't jump to line 432, because the condition on line 428 was never false

429 insertButton = self.insertButton() if self.withInsert(action) else E 

430 sep = NBSP if insertButton else E 

431 

432 if action == N.assess: 

433 records = [ 

434 record 

435 for record in records 

436 if self.stage(record, N.assessment) in ASSESSMENT_STAGES 

437 and self.stage(record, N.review, kind=N.final) 

438 not in {N.reviewAccept, N.reviewReject} 

439 and uid in self.creators(record, N.assessment) 

440 ] 

441 if action == N.review: 

442 records = [record for record in records if not self.myFinished(uid, record)] 

443 if action == N.reviewdone: 

444 records = [record for record in records if self.myFinished(uid, record)] 

445 

446 recordsJson = [] 

447 recordsHtml = [] 

448 nRecords = 0 

449 sensitive = table in SENSITIVE_TABLES 

450 for record in records: 

451 if not sensitive or self.readable(record) is not False: 451 ↛ 450line 451 didn't jump to line 450, because the condition on line 451 was never false

452 nRecords += 1 

453 if logical: 453 ↛ 454line 453 didn't jump to line 454, because the condition on line 453 was never true

454 recordsJson.append(self.record(record=record).wrapLogical()) 

455 else: 

456 recordsHtml.append( 

457 H.details( 

458 self.title(record, withRole=True), 

459 H.div(ELLIPS), 

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

461 fetchurl=f"""/api/{table}/{N.item}/{G(record, N._id)}""", 

462 urltitle=E, 

463 urlextra=E, 

464 **self.forceOpen(G(record, N._id), openEid), 

465 ) 

466 ) 

467 

468 if logical: 468 ↛ 469line 468 didn't jump to line 469, because the condition on line 468 was never true

469 return recordsJson 

470 

471 itemLabel = itemSingular if nRecords == 1 else itemPlural 

472 nRepCmt = f"""<!-- mainN~{nRecords}~{itemLabel} -->""" 

473 nRep = nRepCmt + H.span(f"""{nRecords} {itemLabel}""", cls="stats") 

474 

475 return H.div( 

476 [H.span([insertButton, sep, nRep])] + recordsHtml, cls=f"table {table}", 

477 ) 

478 

479 def withInsert(self, action): 

480 context = self.context 

481 auth = context.auth 

482 table = self.table 

483 return ( 

484 action == N.my and table == MAIN_TABLE 

485 or table in VALUE_TABLES 

486 and auth.superuser() 

487 or table in SYSTEM_TABLES 

488 and auth.sysadmin() 

489 ) 

490 

491 @staticmethod 

492 def myKind(uid, record): 

493 """Quickly determine the kind of reviewer that somebody is. 

494 

495 Parameters 

496 ---------- 

497 uid: ObjectId 

498 The user as reviewer. 

499 record: dict 

500 The review of which the user is or is not a reviewer. 

501 

502 Returns 

503 ------- 

504 string {`expert`, `final`} | `None` 

505 """ 

506 

507 return ( 

508 N.expert 

509 if G(record, N.reviewerE) == uid 

510 else N.final 

511 if G(record, N.reviewerF) == uid 

512 else None 

513 ) 

514 

515 def myFinished(self, uid, record): 

516 """Quickly determine whethe somebody is done reviewing. 

517 

518 Parameters 

519 ---------- 

520 uid: ObjectId 

521 The user as reviewer. 

522 record: dict 

523 The review in question. 

524 

525 The question is: did `uid` take a review decision, or 

526 has the final reviewer already decided anyway? 

527 

528 Returns 

529 ------- 

530 bool 

531 """ 

532 

533 return self.stage(record, N.review, kind=N.final) in { 

534 N.reviewAccept, 

535 N.reviewReject, 

536 } or self.stage(record, N.review, kind=Table.myKind(uid, record)) in { 

537 N.reviewAdviseAccept, 

538 N.reviewAdviseReject, 

539 N.reviewAccept, 

540 N.reviewReject, 

541 } 

542 

543 def insertButton(self): 

544 """Present an insert button on the interface. 

545 

546 Only if the user has rights to insert new items in this table. 

547 """ 

548 

549 mayInsert = self.mayInsert 

550 

551 if not mayInsert: 551 ↛ 552line 551 didn't jump to line 552, because the condition on line 551 was never true

552 return E 

553 

554 table = self.table 

555 itemSingle = self.itemLabels[0] 

556 

557 return H.a( 

558 f"""New {itemSingle}""", 

559 f"""/api/{table}/{N.insert}""", 

560 cls="small task info", 

561 ) 

562 

563 def mayList(self, action=None): 

564 """Checks permission for a list action. 

565 

566 Hera are the rules: 

567 

568 * all users may see the whole contrib table (not all fields!); 

569 * superusers may see all tables with all list actions; 

570 * authenticated users may see 

571 * contribs, assessments, reviews 

572 * value tables. 

573 

574 Parameters 

575 ---------- 

576 action: string, optional `None` 

577 The action to check permissions for. 

578 If not present, it will be checked whether 

579 the user may see the list of all records. 

580 

581 Returns 

582 ------- 

583 boolean 

584 """ 

585 

586 table = self.table 

587 context = self.context 

588 auth = context.auth 

589 return checkTable(auth, table) and (action is None or auth.authenticated()) 

590 

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

592 """Fast way to get a title on the basis of the record only. 

593 

594 When record titles have to be generated for many records in a list, 

595 we forego the sophistications of the special tables, and we pick some 

596 fields from the record itself. 

597 

598 The proper way would be: 

599 

600 ``` python 

601 return obj.record(record=record).title(**atts) 

602 ``` 

603 

604 but that is painfully slow for the contribution table. 

605 

606 Parameters 

607 ---------- 

608 record: dict 

609 The full record 

610 

611 Returns 

612 ------- 

613 string 

614 """ 

615 

616 # return obj.record(record=record).title(**atts) 

617 return self.RecordClass.titleRaw(self, record, markup=markup, **kwargs) 

618 

619 @staticmethod 

620 def forceOpen(theEid, openEid): 

621 """HTML attribute that trigger forced opening. 

622 

623 Elements with the `forceopen` attribute will be found by Javascript 

624 and be forced to open after loading. 

625 

626 We only return this attribute if `theId` is equal to `openEid`. 

627 

628 !!! hint 

629 The use case comes from iterating through many records and only 

630 add the `forceopen` attribute for a specific record. 

631 

632 Parameters 

633 ---------- 

634 theId: string 

635 openId: string 

636 

637 Returns 

638 ------- 

639 dict 

640 `{forceopen='1'}` | `None` 

641 """ 

642 

643 return dict(forceopen=ONE) if openEid and str(theEid) == openEid else dict()