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 

16CT = C.tables 

17CW = C.workflow 

18 

19 

20MAIN_TABLE = CT.userTables[0] 

21INTER_TABLE = CT.userTables[1] 

22USER_TABLES = set(CT.userTables) 

23USER_ENTRY_TABLES = set(CT.userEntryTables) 

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

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

26VALUE_TABLES = set(CT.valueTables) 

27SYSTEM_TABLES = set(CT.systemTables) 

28ITEMS = CT.items 

29PROV_SPECS = CT.prov 

30 

31ASSESSMENT_STAGES = set(CW.assessmentStages) 

32 

33 

34class Table: 

35 """Deals with tables.""" 

36 

37 def __init__(self, context, table): 

38 """## Initialization 

39 

40 Store the incoming information. 

41 

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

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

44 

45 Parameters 

46 ---------- 

47 context: object 

48 See below. 

49 table: string 

50 See below. 

51 """ 

52 

53 self.context = context 

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

55 """ 

56 

57 auth = context.auth 

58 user = auth.user 

59 

60 self.table = table 

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

62 """ 

63 

64 self.isMainTable = table == MAIN_TABLE 

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

66 """ 

67 

68 self.isInterTable = table == INTER_TABLE 

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

70 """ 

71 

72 self.isUserTable = table in USER_TABLES 

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

74 

75 !!! hint 

76 As opposed to value tables. 

77 """ 

78 

79 self.isUserEntryTable = table in USER_ENTRY_TABLES 

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

81 

82 !!! hint 

83 `criteriaEntry` and `reviewEntry`. 

84 """ 

85 

86 self.isValueTable = table in VALUE_TABLES 

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

88 

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

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

91 """ 

92 

93 self.isSystemTable = table in SYSTEM_TABLES 

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

95 

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

97 """ 

98 

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

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

101 """ 

102 

103 self.prov = PROV_SPECS 

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

105 

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

107 """ 

108 

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

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

111 

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

113 the table. 

114 """ 

115 

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

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

118 """ 

119 

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

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

122 

123 !!! hint 

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

125 """ 

126 

127 self.group = auth.groupRep() 

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

129 """ 

130 

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

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

133 """ 

134 

135 isUserTable = self.isUserTable 

136 isValueTable = self.isValueTable 

137 isSystemTable = self.isSystemTable 

138 isSuperuser = auth.superuser() 

139 isSysadmin = auth.sysadmin() 

140 

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

142 isUserTable or isValueTable and isSuperuser or isSystemTable and isSysadmin 

143 ) 

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

145 """ 

146 

147 def titleSortkey(r): 

148 return self.title(r).lower() 

149 

150 self.titleSortkey = titleSortkey 

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

152 """ 

153 

154 self.RecordClass = recordFactory(table) 

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

156 

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

158 derived classes. 

159 """ 

160 

161 def record( 

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

163 ): 

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

165 

166 !!! note 

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

168 

169 Parameters 

170 ---------- 

171 eid: ObjectId, optional `None` 

172 Entity id to identify the record 

173 record: dict, optional `None` 

174 The full record 

175 withDetails: boolean, optional `False` 

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

177 readonly: boolean, optional `False` 

178 Whether to present the complete record in readonly mode 

179 bodyMethod: function, optional `None` 

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

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

182 `control.record.Record.body`. 

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

184 and they may supply alternative body methods as well. 

185 

186 Returns 

187 ------- 

188 object 

189 A `control.record.Record` object. 

190 """ 

191 

192 return self.RecordClass( 

193 self, 

194 eid=eid, 

195 record=record, 

196 withDetails=withDetails, 

197 readonly=readonly, 

198 bodyMethod=bodyMethod, 

199 ) 

200 

201 def readable(self, record): 

202 """Is the record readable? 

203 

204 !!! note 

205 Readibility is a workflow condition. 

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

207 to find out. 

208 

209 Parameters 

210 ---------- 

211 record: dict 

212 The full record 

213 

214 Returns 

215 ------- 

216 boolean 

217 """ 

218 

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

220 

221 def insert(self, force=False): 

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

223 

224 !!! note 

225 The permission is defined upon intialization of the record. 

226 See `control.table.Table` . 

227 

228 The rules are: 

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

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

231 `assessment`, `review`. 

232 * superusers may create new value records (under additional 

233 constraints) 

234 * system admins may create new records in system tables 

235 

236 !!! note 

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

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

239 

240 Parameters 

241 ---------- 

242 force: boolean, optional `False` 

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

244 

245 Returns 

246 ------- 

247 ObjectId 

248 id of the inserted item 

249 """ 

250 

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

252 if not mayInsert: 

253 return None 

254 

255 context = self.context 

256 db = context.db 

257 uid = self.uid 

258 eppn = self.eppn 

259 table = self.table 

260 

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

262 if table == MAIN_TABLE: 

263 self.adjustWorkflow(result) 

264 

265 return result 

266 

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

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

269 that is dependent on changed data. 

270 

271 Parameters 

272 ---------- 

273 contribId: ObjectId 

274 The id of the workflow item. 

275 new: boolean, optional `True` 

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

277 otherwise update the existing item. 

278 """ 

279 

280 context = self.context 

281 wf = context.wf 

282 

283 if new: 

284 wf.insert(contribId) 

285 else: 

286 wf.recompute(contribId) 

287 

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

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

290 

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

292 

293 Parameters 

294 ---------- 

295 record: dict 

296 The full record 

297 table: string {contrib, assessment, review} 

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

299 Only if we want review attributes 

300 

301 Returns 

302 ------- 

303 string | `None` 

304 """ 

305 

306 recordObj = self.record(record=record) 

307 

308 wfitem = recordObj.wfitem 

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

310 

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

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

313 

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

315 

316 Parameters 

317 ---------- 

318 record: dict 

319 The full record 

320 table: string {contrib, assessment, review} 

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

322 Only if we want review attributes 

323 

324 Returns 

325 ------- 

326 (list of ObjectId) | `None` 

327 """ 

328 

329 recordObj = self.record(record=record) 

330 

331 wfitem = recordObj.wfitem 

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

333 

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

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

336 

337 action | selection 

338 --- | --- 

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

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

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

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

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

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

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

346 

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

348 See `control.table.Table.mayList`. 

349 

350 !!! caution "Workflow restrictions" 

351 There might be additional restrictions on individual records 

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

353 They will be filtered out. 

354 

355 !!! note 

356 Whether records are presented in an opened or closed state 

357 depends onn how the user has last left them. 

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

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

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

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

362 

363 Parameters 

364 ---------- 

365 openEid: ObjectId 

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

367 action: string, optional, `None` 

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

369 otherwise all records go to the interface. 

370 logical: boolean, optional `False` 

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

372 

373 Returns 

374 ------- 

375 string(html) or any 

376 """ 

377 

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

379 return None 

380 

381 context = self.context 

382 db = context.db 

383 table = self.table 

384 uid = self.uid 

385 countryId = self.countryId 

386 titleSortkey = self.titleSortkey 

387 (itemSingular, itemPlural) = self.itemLabels 

388 

389 params = ( 

390 dict(my=uid) 

391 if action == N.my 

392 else dict(our=countryId) 

393 if action == N.our 

394 else dict(my=uid) 

395 if action == N.assess 

396 else dict(assign=True) 

397 if action == N.assign 

398 else dict(review=uid) 

399 if action == N.review 

400 else dict(review=uid) 

401 if action == N.reviewdone 

402 else dict(selectable=countryId) 

403 if action == N.select 

404 else {} 

405 ) 

406 if request.args: 

407 params.update(request.args) 

408 

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

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

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

412 sep = NBSP if insertButton else E 

413 

414 if action == N.assess: 

415 records = [ 

416 record 

417 for record in records 

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

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

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

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

422 ] 

423 if action == N.review: 

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

425 if action == N.reviewdone: 

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

427 

428 recordsJson = [] 

429 recordsHtml = [] 

430 nRecords = 0 

431 sensitive = table in SENSITIVE_TABLES 

432 for record in records: 

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

434 nRecords += 1 

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

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

437 else: 

438 recordsHtml.append( 

439 H.details( 

440 self.title(record), 

441 H.div(ELLIPS), 

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

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

444 urltitle=E, 

445 urlextra=E, 

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

447 ) 

448 ) 

449 

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

451 return recordsJson 

452 

453 itemLabel = itemSingular if nRecords == 1 else itemPlural 

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

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

456 

457 return H.div( 

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

459 ) 

460 

461 def withInsert(self, action): 

462 context = self.context 

463 auth = context.auth 

464 table = self.table 

465 return ( 

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

467 or table in VALUE_TABLES 

468 and auth.superuser() 

469 or table in SYSTEM_TABLES 

470 and auth.sysadmin() 

471 ) 

472 

473 @staticmethod 

474 def myKind(uid, record): 

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

476 

477 Parameters 

478 ---------- 

479 uid: ObjectId 

480 The user as reviewer. 

481 record: dict 

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

483 

484 Returns 

485 ------- 

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

487 """ 

488 

489 return ( 

490 N.expert 

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

492 else N.final 

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

494 else None 

495 ) 

496 

497 def myFinished(self, uid, record): 

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

499 

500 Parameters 

501 ---------- 

502 uid: ObjectId 

503 The user as reviewer. 

504 record: dict 

505 The review in question. 

506 

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

508 has the final reviewer already decided anyway? 

509 

510 Returns 

511 ------- 

512 bool 

513 """ 

514 

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

516 N.reviewAccept, 

517 N.reviewReject, 

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

519 N.reviewAdviseAccept, 

520 N.reviewAdviseReject, 

521 N.reviewAccept, 

522 N.reviewReject, 

523 } 

524 

525 def insertButton(self): 

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

527 

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

529 """ 

530 

531 mayInsert = self.mayInsert 

532 

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

534 return E 

535 

536 table = self.table 

537 itemSingle = self.itemLabels[0] 

538 

539 return H.a( 

540 f"""New {itemSingle}""", 

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

542 cls="small task info", 

543 ) 

544 

545 def mayList(self, action=None): 

546 """Checks permission for a list action. 

547 

548 Hera are the rules: 

549 

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

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

552 * authenticated users may see 

553 * contribs, assessments, reviews 

554 * value tables. 

555 

556 Parameters 

557 ---------- 

558 action: string, optional `None` 

559 The action to check permissions for. 

560 If not present, it will be checked whether 

561 the user may see the list of all records. 

562 

563 Returns 

564 ------- 

565 boolean 

566 """ 

567 

568 table = self.table 

569 context = self.context 

570 auth = context.auth 

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

572 

573 def title(self, record, markup=True): 

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

575 

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

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

578 fields from the record itself. 

579 

580 The proper way would be: 

581 

582 ``` python 

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

584 ``` 

585 

586 but that is painfully slow for the contribution table. 

587 

588 Parameters 

589 ---------- 

590 record: dict 

591 The full record 

592 

593 Returns 

594 ------- 

595 string 

596 """ 

597 

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

599 return self.RecordClass.titleRaw(self, record, markup=markup) 

600 

601 @staticmethod 

602 def forceOpen(theEid, openEid): 

603 """HTML attribute that trigger forced opening. 

604 

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

606 and be forced to open after loading. 

607 

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

609 

610 !!! hint 

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

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

613 

614 Parameters 

615 ---------- 

616 theId: string 

617 openId: string 

618 

619 Returns 

620 ------- 

621 dict 

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

623 """ 

624 

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