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"""Applying workflow 

2 

3* Compute workflow permissions 

4* Show workflow state 

5* Perform workflow tasks 

6* Enforce workflow constraints 

7 

8## Workflow tasks 

9 

10The heart of the tool consists of a set of workflow tasks 

11that can be executed safely by a workflow engine. 

12 

13A task is is triggered by a url: 

14 

15`/api/task/`*taskName*`/`*eid* 

16 

17Here the *eid* is the id of the central record of the task, e.g. a particular 

18contribution, assessment, or review. 

19 

20Workflow tasks are listed in workflow.yaml, under `tasks`. 

21Every task name is associated with properties, 

22which are used in determining the permissions of a task. 

23They also steer the execution of the task. 

24 

25### Properties of workflow tasks 

26 

27Here is a list that explains the task properties. 

28 

29operator 

30: There are two kinds of operator: `add` and `set`. 

31 

32 The effect of `add` is the insertion of a new record in a 

33 table given in the `detail` property. 

34 

35 The effect of `set` is the setting of specific fields in a record in 

36 the table inndicated by the `table` property. 

37 The fields are indicated in the `field` and `date` properties. 

38 

39table 

40: The table in which the record resides that is central to the task. 

41 

42detail 

43: The detail table in case the operator is `add`: it will add a detail 

44 record of the central record into this table. 

45 

46kind 

47: In case the task operates on reviews: whether the task is relevant for 

48 an `expert` review or a `final` review. 

49 

50field 

51: In case the operator is `set`: the field in the central record that will be changed. 

52 

53value 

54: In case the operator is `set`: the new value for the field in the central 

55 record that will be changed. 

56 

57date 

58: In case the operator is `set`: the name of the field that will receive the 

59 timestamp. 

60 

61delay 

62: All `set` tasks are not meant to be revoked. But there is some leeway: 

63 Within the amount of hours specified here, the user can revoke the task. 

64 

65msg 

66: How the task is called on the interface. 

67 

68acro 

69: An acronym of the task to be used in flash messages. 

70 

71cls 

72: A CSS class that determines the color of the workflow button, usually 

73 `info`, `good`, `warning`, `error`. `info` is the neutral color. 

74 

75## Workflow stages 

76 

77Workflow stages are listed in workflow.yaml, under `stageAtts`. 

78 

79The stage of a record is stored in the workflow attribute `stage`, 

80so the only thing needed is to ask for that attribute with 

81`control.workflow.apply.WorkflowItem.info`. 

82""" 

83 

84from datetime import timedelta 

85from flask import flash 

86 

87from config import Config as C, Names as N 

88from control.utils import pick as G, E, now 

89from control.html import HtmlElements as H 

90from control.typ.datetime import Datetime 

91from control.cust.score import presentScore 

92from control.cust.factory_table import make as mkTable 

93 

94 

95CT = C.tables 

96CF = C.workflow 

97 

98ALL_TABLES = CT.all 

99 

100USER_TABLES_LIST = CT.userTables 

101MAIN_TABLE = USER_TABLES_LIST[0] 

102USER_ENTRY_TABLES = set(CT.userEntryTables) 

103USER_TABLES = set(USER_TABLES_LIST) 

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

105 

106STAGE_ATTS = CF.stageAtts 

107TASKS = CF.tasks 

108TASK_FIELDS = CF.taskFields 

109STATUS_REP = CF.statusRep 

110DECISION_DELAY = CF.decisionDelay 

111 

112datetime = Datetime() 

113 

114 

115def execute(context, task, eid): 

116 """Executes a workflow task. 

117 

118 First a table object is constructed, based on the `table` property 

119 of the task, using `context`. 

120 

121 Then a record object is constructed in that table, based on the `eid` 

122 parameter. 

123 

124 If that all succeeds, all information is at hand to verify permissions 

125 and perform the task. 

126 

127 Parameters 

128 ---------- 

129 context: object 

130 A `control.context.Context` singleton 

131 task: string 

132 The name of the task 

133 eid: string(objectId) 

134 The id of the relevant record 

135 """ 

136 

137 taskInfo = G(TASKS, task) 

138 acro = G(taskInfo, N.acro) 

139 table = G(taskInfo, N.table) 

140 if table not in ALL_TABLES: 140 ↛ 141line 140 didn't jump to line 141, because the condition on line 140 was never true

141 flash(f"""Workflow {acro} operates on wrong table: "{table or E}""", "error") 

142 return (False, None) 

143 return mkTable(context, table).record(eid=eid).task(task) 

144 

145 

146class WorkflowItem: 

147 """Supports the application of workflow information. 

148 

149 A WorkflowItem singleton has a bunch of workflow attributes as dict in its 

150 attribute `data` and offers methods to 

151 

152 * address selected pieces of that information; 

153 * compute permissions for workflow actions and database actions; 

154 * determine the workflow stage the contribution is in. 

155 

156 Attributes 

157 ---------- 

158 data: dict 

159 All workflow attributes. 

160 myKind: string 

161 The kind of reviewer the current user is, if any. 

162 """ 

163 

164 def __init__(self, context, data): 

165 """## Initialization 

166 

167 Wraps a workflow item record around a workflow data record. 

168 

169 Workflow item records are created per contribution, 

170 but they will be referenced by contribution, assessment and review records 

171 in their attribute `wfitem`. 

172 

173 Workflow items also store details of the current user, which will be needed 

174 for the computation of permissions. 

175 

176 !!! note 

177 The user attributes `uid` and `eppn` will be stored in this `WorkflowItem` 

178 object. 

179 At this point, it is also possible to what kind of reviewer the current 

180 user is, if any, and store that in attribute `myKind`. 

181 

182 Parameters 

183 ---------- 

184 context: object 

185 The `control.context.Context singleton`, from which the 

186 `control.auth.Auth` singleton can be picked up, from which the 

187 details of the current user can be read off. 

188 data: dict 

189 See below. 

190 """ 

191 

192 db = context.db 

193 auth = context.auth 

194 user = auth.user 

195 

196 self.db = db 

197 """*object* The `control.db.Db` singleton 

198 

199 Provides methods to deal with values from the table `decision`. 

200 """ 

201 

202 self.auth = auth 

203 """*object* The `control.auth.Auth` singleton 

204 

205 Provides methods to access the attributes of the current user. 

206 """ 

207 

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

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

210 """ 

211 

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

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

214 

215 !!! hint 

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

217 """ 

218 

219 self.isSuperuser = auth.superuser() 

220 """*boolean* Whether the current user is a superuser. 

221 

222 See `control.auth.Auth.superuser`. 

223 """ 

224 

225 self.data = data 

226 """*dict* The workflow attributes. 

227 """ 

228 

229 self.myKind = self.myReviewerKind() 

230 """*dict* The kind of reviewer that the current user is. 

231 

232 A user is `expert` reviewer or `final` reviewer, or `None`. 

233 """ 

234 

235 def getKind(self, table, record): 

236 """Determine whether a review(Entry) is `expert` or `final`. 

237 

238 !!! warning 

239 The value `None` (not a string!) is returned for reviews that are 

240 no (longer) part of the workflow. 

241 They could be reviews with a type that does not match the type 

242 of the contribution, or reviews that have been superseded by newer 

243 reviews. 

244 

245 Parameters 

246 ---------- 

247 table: string 

248 Either `review` or `reviewEntry`. 

249 record: dict 

250 Either a `review` record or a `reviewEntry` record. 

251 

252 Returns 

253 ------- 

254 string {`expert`, `final`} 

255 Or `None`. 

256 """ 

257 

258 if table in {N.review, N.reviewEntry}: 

259 eid = G(record, N._id) if table == N.review else G(record, N.review) 

260 data = self.getWf(N.assessment) 

261 reviews = G(data, N.reviews, default={}) 

262 kind = ( 

263 N.expert 

264 if G(G(reviews, N.expert), N._id) == eid 

265 else N.final 

266 if G(G(reviews, N.final), N._id) == eid 

267 else None 

268 ) 

269 else: 

270 kind = None 

271 return kind 

272 

273 def isValid(self, table, eid, record): 

274 """Is a record a valid part of the workflow? 

275 

276 Valid parts are contributions, assessment and review detail records of 

277 contributions satisfying: 

278 

279 * they have the same type as their master contribution 

280 * they are not superseded by other assessments or reviews 

281 with the correct type 

282 

283 Parameters 

284 ---------- 

285 table: string {`review`, `assessment`, `criteriaEntry`, `reviewEntry`}. 

286 eid: ObjectId 

287 (Entity) id of the record to be validated. 

288 record: dict 

289 The full record to be validated. 

290 Only needed for `reviewEntry` and `criteriaEntry` in order to look 

291 up the master `review` or `assessment` record. 

292 

293 Returns 

294 ------- 

295 boolean 

296 """ 

297 if eid is None: 297 ↛ 298line 297 didn't jump to line 298, because the condition on line 297 was never true

298 return False 

299 

300 refId = ( 

301 G(record, N.assessment) 

302 if table == N.criteriaEntry 

303 else G(record, N.review) 

304 if table == N.reviewEntry 

305 else eid 

306 ) 

307 if refId is None: 307 ↛ 308line 307 didn't jump to line 308, because the condition on line 307 was never true

308 return False 

309 

310 if table in {N.contrib, N.assessment, N.criteriaEntry}: 

311 data = self.getWf(table) 

312 return refId == G(data, N._id) 

313 elif table in {N.review, N.reviewEntry}: 313 ↛ exitline 313 didn't return from function 'isValid', because the condition on line 313 was never false

314 data = self.getWf(N.assessment) 

315 reviews = G(data, N.reviews, default={}) 

316 return refId in { 

317 G(reviewInfo, N._id) for (kind, reviewInfo) in reviews.items() 

318 } 

319 

320 def info(self, table, *atts, kind=None): 

321 """Retrieve selected attributes of the workflow 

322 

323 A workflow record contains attributes at the outermost level, 

324 but also within its enclosed assessment workflow record and 

325 the enclosed review workflow records. 

326 

327 Parameters 

328 ---------- 

329 table: string 

330 In order to read attributes, we must specify the source of those 

331 attributes: `contrib` (outermost), `assessment` or `review`. 

332 *atts: iterable 

333 The workflow attribute names to fetch. 

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

335 Only if we want review attributes 

336 

337 Returns 

338 ------- 

339 generator 

340 Yields attribute values, corresponding to `*atts`. 

341 """ 

342 

343 thisData = self.getWf(table, kind=kind) 

344 return (G(thisData, att) for att in atts) 

345 

346 def checkReadable(self, recordObj): 

347 """Whether a record is readable because of workflow. 

348 

349 When a contribution, assessment, review is in a certain stage 

350 in the workflow, its record may be closed to others than the owner, and 

351 after finalization, some fields may be open to authenticated users or 

352 the public. 

353 

354 This method determines the record is readable by the current user. 

355 

356 If the record is not part of the workflow, `None` is returned, and 

357 the normal permission rules apply. 

358 

359 !!! note 

360 It also depends on the current user. 

361 Power users will not be prevented to read records because of 

362 workflow conditions. 

363 

364 Here are the rules: 

365 

366 #### Assessment, Criteria Entry 

367 

368 Not submitted and not in revision: 

369 : authors and editors only 

370 

371 Submitted, review not yet complete, or negative outcome 

372 : authors, editors, reviewers, national coordinator only 

373 

374 Review with positive outcome 

375 : public 

376 

377 In revision, or review with a negative outcome 

378 : authors, editors, reviewers, national coordinator only 

379 

380 #### Review, Review Entry 

381 

382 Review has no decision and there is no final decision 

383 : authors, editors, the other reviewer 

384 

385 Review in question has a decision, but still no final positive decision 

386 : authors/editors, other reviewer, authors/editors of the assessment, 

387 national coordinator 

388 

389 There is a positive final decision 

390 : public 

391 

392 !!! caution "The influence of selection is nihil" 

393 Whether a contribution is selected or not has no influence on the 

394 readability of the assessment and review. 

395 

396 !!! caution "The influence on the contribution records is nihil" 

397 Whether a contribution is readable does not depend on the 

398 workflow, only on the normal rules. 

399 

400 Parameters 

401 ---------- 

402 recordObj: object 

403 The record in question (from which the table and the kind 

404 maybe inferred. It should be the record that contains this 

405 WorkflowItem object as its `wfitem` attribute. 

406 field: string, optional `None` 

407 If None, we check for the readability of the record as a whole. 

408 Otherwise, we check for the readability of this field in the record. 

409 

410 Returns 

411 ------- 

412 boolean | `None` 

413 """ 

414 

415 isSuperuser = self.isSuperuser 

416 if isSuperuser: 

417 return None 

418 

419 table = recordObj.table 

420 if table not in SENSITIVE_TABLES: 

421 return None 

422 

423 kind = recordObj.kind 

424 perm = recordObj.perm 

425 uid = self.uid 

426 

427 (stage,) = self.info(table, N.stage, kind=kind) 

428 

429 if table in {N.assessment, N.criteriaEntry}: 

430 (r2Stage,) = self.info(N.review, N.stage, kind=N.final) 

431 return ( 

432 True 

433 if r2Stage == N.reviewAccept 

434 else perm[N.isOur] 

435 if stage 

436 in { 

437 N.submitted, 

438 N.incompleteRevised, 

439 N.completeRevised, 

440 N.submittedRevised, 

441 } 

442 else perm[N.isEdit] 

443 ) 

444 

445 if table in {N.review, N.reviewEntry}: 445 ↛ 464line 445 didn't jump to line 464, because the condition on line 445 was never false

446 (creators,) = self.info(N.assessment, N.creators) 

447 (r2Stage,) = self.info(N.review, N.stage, kind=N.final) 

448 result = ( 

449 True 

450 if r2Stage == N.reviewAccept 

451 else uid in creators or perm[N.isOur] 

452 if stage 

453 in { 

454 N.reviewAdviseRevise, 

455 N.reviewAdviseAccept, 

456 N.reviewAdviseReject, 

457 N.reviewRevise, 

458 N.reviewReject, 

459 } 

460 or r2Stage in {N.reviewRevise, N.reviewReject} 

461 else perm[N.isReviewer] or perm[N.isEdit] 

462 ) 

463 return result 

464 return None 

465 

466 def checkFixed(self, recordObj, field=None): 

467 """Whether a record or field is fixed because of workflow. 

468 

469 When a contribution, assessment, review is in a certain stage 

470 in the workflow, its record or some fields in its record may be 

471 fixated, either temporarily or permanently. 

472 

473 This method checks whether a record or field is currently fixed, 

474 i.e. whether editing is possible. 

475 

476 !!! note 

477 It might also depend on the current user. 

478 

479 !!! caution 

480 Here is a case where the sysadmin and the root are less powerful 

481 than the office users: only the office users can assign reviewers, 

482 i.e. only they can update `reviewerE` and `reviewerF` inn assessment fields. 

483 

484 Parameters 

485 ---------- 

486 recordObj: object 

487 The record in question (from which the table and the kind 

488 maybe inferred. It should be the record that contains this 

489 WorkflowItem object as its `wfitem` attribute. 

490 field: string, optional `None` 

491 If None, we check for the fixity of the record as a whole. 

492 Otherwise, we check for the fixity of this field in the record. 

493 

494 Returns 

495 ------- 

496 boolean 

497 """ 

498 

499 auth = self.auth 

500 table = recordObj.table 

501 kind = recordObj.kind 

502 

503 (frozen, done, locked) = self.info(table, N.frozen, N.done, N.locked, kind=kind) 

504 

505 if field is None: 

506 return frozen or done or locked 

507 

508 if frozen or done: 

509 return True 

510 

511 if not locked: 

512 return False 

513 

514 isOffice = auth.officeuser() 

515 if isOffice and table == N.assessment: 

516 return field not in {N.reviewerE, N.reviewerF} 

517 

518 return True 

519 

520 def permission(self, task, kind=None): 

521 """Checks whether a workflow task is permitted. 

522 

523 Note that the tasks are listed per kind of record they apply to: 

524 contrib, assessment, review. 

525 They are typically triggered by big workflow buttons on the interface. 

526 

527 When the request to execute such a task reaches the server, it will 

528 check whether the current user is allowed to execute this task 

529 on the records in question. 

530 

531 !!! hint 

532 See above for explanation of the properties of the tasks. 

533 

534 !!! note 

535 If you try to run a task on a kind of record that it is not 

536 designed for, it will be detected and no permission will be given. 

537 

538 !!! note 

539 Some tasks are designed to set a field to a value. 

540 If that field already has that value, the task will not be permitted. 

541 This already rules out a lot of things and relieves the burden of 

542 prohibiting non-sensical tasks. 

543 

544 It may be that the task is only permitted for some limited time from now on. 

545 Then a timedelta object with the amount of time left is returned. 

546 

547 Parameters 

548 ---------- 

549 table: string 

550 In order to check permissions, we must specify the kind of record that 

551 the task acts on: contrib, assessment, or review. 

552 task: string 

553 An string consisting of the name of a task. 

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

555 Only if we want review attributes 

556 

557 Returns 

558 ------- 

559 boolean | timedelta 

560 """ 

561 

562 db = self.db 

563 auth = self.auth 

564 uid = self.uid 

565 

566 if task not in TASKS: 566 ↛ 567line 566 didn't jump to line 567, because the condition on line 566 was never true

567 return False 

568 

569 taskInfo = TASKS[task] 

570 table = G(taskInfo, N.table) 

571 

572 if uid is None or table not in USER_TABLES: 

573 return False 

574 

575 taskField = ( 

576 N.selected 

577 if table == N.contrib 

578 else N.submitted 

579 if table == N.assessment 

580 else N.decision 

581 if table == N.review 

582 else None 

583 ) 

584 myKind = self.myKind 

585 

586 ( 

587 locked, 

588 done, 

589 frozen, 

590 mayAdd, 

591 stage, 

592 stageDate, 

593 creators, 

594 countryId, 

595 taskValue, 

596 ) = self.info( 

597 table, 

598 N.locked, 

599 N.done, 

600 N.frozen, 

601 N.mayAdd, 

602 N.stage, 

603 N.stageDate, 

604 N.creators, 

605 N.country, 

606 taskField, 

607 kind=kind, 

608 ) 

609 

610 operator = G(taskInfo, N.operator) 

611 value = G(taskInfo, N.value) 

612 if operator == N.set: 

613 if taskField == N.decision: 

614 value = G(db.decisionInv, value) 

615 

616 (contribId,) = self.info(N.contrib, N._id) 

617 

618 isOwn = uid in creators 

619 isCoord = countryId and auth.coordinator(countryId=countryId) 

620 isSuper = auth.superuser() 

621 

622 decisionDelay = G(taskInfo, N.delay) 

623 if decisionDelay: 

624 decisionDelay = timedelta(hours=decisionDelay) 

625 

626 justNow = now() 

627 remaining = False 

628 if decisionDelay and stageDate: 

629 remaining = stageDate + decisionDelay - justNow 

630 if remaining <= timedelta(hours=0): 

631 remaining = False 

632 

633 forbidden = frozen or done 

634 

635 if forbidden and not remaining: 

636 return False 

637 

638 if table == N.contrib: 

639 if not isOwn and not isCoord and not isSuper: 

640 return False 

641 

642 if task == N.startAssessment: 

643 return not forbidden and isOwn and mayAdd 

644 

645 if value == taskValue: 645 ↛ 646line 645 didn't jump to line 646, because the condition on line 645 was never true

646 return False 

647 

648 if not isCoord: 

649 return False 

650 

651 answer = not frozen or remaining 

652 

653 if task == N.selectContrib: 

654 return stage != N.selectYes and answer 

655 

656 if task == N.deselectContrib: 

657 return stage != N.selectNo and answer 

658 

659 if task == N.unselectContrib: 659 ↛ 662line 659 didn't jump to line 662, because the condition on line 659 was never false

660 return stage != N.selectNone and answer 

661 

662 return False 

663 

664 if table == N.assessment: 

665 forbidden = frozen or done 

666 if forbidden: 

667 return False 

668 

669 if task == N.startReview: 

670 return not forbidden and G(mayAdd, myKind) 

671 

672 if value == taskValue: 672 ↛ 673line 672 didn't jump to line 673, because the condition on line 672 was never true

673 return False 

674 

675 if uid not in creators: 

676 return False 

677 

678 answer = not locked or remaining 

679 if not answer: 

680 return False 

681 

682 if task == N.submitAssessment: 

683 return stage == N.complete and answer 

684 

685 if task == N.resubmitAssessment: 

686 return stage == N.completeWithdrawn and answer 

687 

688 if task == N.submitRevised: 

689 return stage == N.completeRevised and answer 

690 

691 if task == N.withdrawAssessment: 691 ↛ 698line 691 didn't jump to line 698, because the condition on line 691 was never false

692 return ( 

693 stage in {N.submitted, N.submittedRevised} 

694 and stage not in {N.incompleteWithdrawn, N.completeWithdrawn} 

695 and answer 

696 ) 

697 

698 return False 

699 

700 if table == N.review: 700 ↛ 766line 700 didn't jump to line 766, because the condition on line 700 was never false

701 if frozen: 701 ↛ 702line 701 didn't jump to line 702, because the condition on line 701 was never true

702 return False 

703 

704 if done and not remaining: 704 ↛ 705line 704 didn't jump to line 705, because the condition on line 704 was never true

705 return False 

706 

707 taskKind = G(taskInfo, N.kind) 

708 if not kind or kind != taskKind or kind != myKind: 

709 return False 

710 

711 answer = remaining or not done or remaining 

712 if not answer: 712 ↛ 713line 712 didn't jump to line 713, because the condition on line 712 was never true

713 return False 

714 

715 (aStage, aStageDate) = self.info(N.assessment, N.stage, N.stageDate) 

716 (finalStage,) = self.info(table, N.stage, kind=N.final) 

717 (expertStage, expertStageDate) = self.info( 

718 table, N.stage, N.stageDate, kind=N.expert 

719 ) 

720 xExpertStage = N.expertReviewRevoke if expertStage is None else expertStage 

721 xFinalStage = N.finalReviewRevoke if finalStage is None else finalStage 

722 revision = finalStage == N.reviewRevise 

723 zFinalStage = finalStage and not revision 

724 submitted = aStage == N.submitted 

725 submittedRevised = aStage == N.submittedRevised 

726 mayDecideExpert = ( 

727 submitted and not finalStage or submittedRevised and revision 

728 ) 

729 

730 if value == taskValue: 

731 if not revision: 

732 return False 

733 

734 if task in { 

735 N.expertReviewRevise, 

736 N.expertReviewAccept, 

737 N.expertReviewReject, 

738 N.expertReviewRevoke, 

739 } - {xExpertStage}: 

740 return ( 

741 kind == N.expert and not zFinalStage and mayDecideExpert and answer 

742 ) 

743 

744 if task in { 744 ↛ 764line 744 didn't jump to line 764, because the condition on line 744 was never false

745 N.finalReviewRevise, 

746 N.finalReviewAccept, 

747 N.finalReviewReject, 

748 N.finalReviewRevoke, 

749 } - {xFinalStage}: 

750 return ( 

751 kind == N.final 

752 and not not expertStage 

753 and (not aStageDate or expertStageDate > aStageDate) 

754 and ( 

755 ( 

756 (not finalStage and submitted) 

757 or (revision and submittedRevised) 

758 ) 

759 or remaining 

760 ) 

761 and answer 

762 ) 

763 

764 return False 

765 

766 return False 

767 

768 def stage(self, table, kind=None): 

769 """Find the workflow stage that a record is in. 

770 

771 !!! hint 

772 See above for a description of the stages. 

773 

774 Parameters 

775 ---------- 

776 table: string 

777 We must specify the kind of record for which we want to see the stage: 

778 contrib, assessment, or review. 

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

780 Only if we want review attributes 

781 

782 Returns 

783 ------- 

784 string {`selectYes`, `submittedRevised`, `reviewAccept`, ...} 

785 See above for the complete list. 

786 """ 

787 

788 return list(self.info(table, N.stage, kind=kind))[0] 

789 

790 def creators(self, table, kind=None): 

791 """Find the creators from a workflow related record. 

792 

793 Parameters 

794 ---------- 

795 table: string 

796 We must specify the kind of record for which we want to see the creators: 

797 contrib, assessment, or review. 

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

799 Only if we want review attributes 

800 

801 Returns 

802 ------- 

803 (list of ObjectId) 

804 """ 

805 

806 return list(self.info(table, N.creators, kind=kind))[0] 

807 

808 def status(self, table, kind=None): 

809 """Present all workflow info and controls relevant to the record. 

810 

811 Parameters 

812 ---------- 

813 table: string 

814 We must specify the kind of record for which we want to see the status: 

815 contrib, assessment, or review. 

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

817 Only if we want review attributes 

818 

819 Returns 

820 ------- 

821 string(html) 

822 """ 

823 

824 eid = list(self.info(table, N._id, kind=kind))[0] 

825 itemKey = f"""{table}/{eid}""" 

826 rButton = H.iconr(itemKey, "#workflow", msg=N.status) 

827 

828 return H.div( 

829 [ 

830 rButton, 

831 self.statusOverview(table, kind=kind), 

832 self.tasks(table, kind=kind), 

833 ], 

834 cls="workflow", 

835 ) 

836 

837 @staticmethod 

838 def isTask(table, field): 

839 """Whether a field in a record is involved in a workflow task. 

840 

841 Fields that are involved in workflow tasks can not be read or edited 

842 directly: 

843 

844 * they are represented as workflow status, not as a value 

845 (see `control.workflow.apply.WorkflowItem.status`); 

846 * they only change as a result of a workflow task 

847 (see `control.workflow.apply.WorkflowItem.doTask`). 

848 

849 !!! hint 

850 Workflow tasks are described above. 

851 

852 !!! caution 

853 If a record is not a valid part of a workflow, then all its fields 

854 are represented and actionable in the normal way. 

855 

856 Parameters 

857 ---------- 

858 table: string 

859 The table in question. 

860 field: string 

861 The field in question. 

862 

863 Returns 

864 ------- 

865 boolean 

866 """ 

867 

868 taskFields = G(TASK_FIELDS, table, default=set()) 

869 return field in taskFields 

870 

871 def doTask(self, task, recordObj): 

872 """Execute a workflow task on a record. 

873 

874 The permission to execute the task will be checked first. 

875 

876 !!! hint 

877 Workflow tasks are described above. 

878 

879 Parameters 

880 ---------- 

881 recordObj: object 

882 The record must be passed as a record object. 

883 

884 Returns 

885 ------- 

886 url | `None` 

887 To navigate to after the action has been performed. 

888 If the action has not been performed, `None` is returned. 

889 """ 

890 

891 context = recordObj.context 

892 table = recordObj.table 

893 eid = recordObj.eid 

894 kind = recordObj.kind 

895 (contribId,) = self.info(N.contrib, N._id) 

896 

897 taskInfo = G(TASKS, task) 

898 acro = G(taskInfo, N.acro) 

899 

900 urlExtra = E 

901 

902 executed = False 

903 if self.permission(task, kind=kind): 

904 operator = G(taskInfo, N.operator) 

905 if operator == N.add: 

906 dtable = G(taskInfo, N.detail) 

907 tableObj = mkTable(context, dtable) 

908 deid = tableObj.insert(masterTable=table, masterId=eid, force=True) or E 

909 if deid: 

910 urlExtra = f"""/{N.open}/{dtable}/{deid}""" 

911 executed = True 

912 elif operator == N.set: 912 ↛ 917line 912 didn't jump to line 917, because the condition on line 912 was never false

913 field = G(taskInfo, N.field) 

914 value = G(taskInfo, N.value) 

915 if recordObj.field(field, mayEdit=True).save(value): 915 ↛ 917line 915 didn't jump to line 917, because the condition on line 915 was never false

916 executed = True 

917 if executed: 

918 flash(f"""<{acro}> executed""", "message") 

919 else: 

920 flash(f"""<{acro}> failed""", "error") 

921 else: 

922 flash(f"""<{acro}> not permitted""", "error") 

923 

924 return f"""/{N.contrib}/{N.item}/{contribId}{urlExtra}""" if executed else None 

925 

926 def statusOverview(self, table, kind=None): 

927 """Present the current status of a record on the interface. 

928 

929 Parameters 

930 ---------- 

931 table: string 

932 We must specify the kind of record for which we want to present the stage: 

933 contrib, assessment, or review. 

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

935 Only if we want review attributes 

936 

937 Returns 

938 ------- 

939 string(html) 

940 """ 

941 

942 (stage, stageDate, locked, done, frozen, score, eid) = self.info( 

943 table, 

944 N.stage, 

945 N.stageDate, 

946 N.locked, 

947 N.done, 

948 N.frozen, 

949 N.score, 

950 N._id, 

951 kind=kind, 

952 ) 

953 stageInfo = G(STAGE_ATTS, stage) 

954 statusCls = G(stageInfo, N.cls) 

955 stageOn = ( 

956 H.span(f""" on {datetime.toDisplay(stageDate)}""", cls="date") 

957 if stageDate 

958 else E 

959 ) 

960 statusMsg = H.span( 

961 [G(stageInfo, N.msg) or E, stageOn], cls=f"large status {statusCls}" 

962 ) 

963 lockedCls = N.locked if locked else E 

964 lockedMsg = ( 

965 H.span(G(STATUS_REP, N.locked), cls=f"large status {lockedCls}") 

966 if locked 

967 else E 

968 ) 

969 doneCls = N.done if done else E 

970 doneMsg = ( 

971 H.span(G(STATUS_REP, N.done), cls=f"large status {doneCls}") if done else E 

972 ) 

973 frozenCls = N.frozen if frozen else E 

974 frozenMsg = ( 

975 H.span(G(STATUS_REP, N.frozen), cls="large status info") if frozen else E 

976 ) 

977 

978 statusRep = f"<!-- stage:{stage} -->" + H.div( 

979 [statusMsg, lockedMsg, doneMsg, frozenMsg], cls=frozenCls 

980 ) 

981 

982 scorePart = E 

983 if table == N.assessment: 

984 scoreParts = presentScore(score, eid) 

985 scorePart = ( 

986 H.span(scoreParts) 

987 if table == N.assessment 

988 else (scoreParts[0] if scoreParts else E) 

989 if table == N.contrib 

990 else E 

991 ) 

992 

993 return H.div([statusRep, scorePart], cls="workflow-line") 

994 

995 def tasks(self, table, kind=None): 

996 """Present the currently available tasks as buttons on the interface. 

997 

998 !!! hint "easy comments" 

999 We also include a comment `<!-- task~!taskName:eid --> 

1000 for the ease of testing. 

1001 

1002 Parameters 

1003 ---------- 

1004 table: string 

1005 We must specify the table for which we want to present the 

1006 tasks: contrib, assessment, or review. 

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

1008 Only if we want review attributes 

1009 

1010 Returns 

1011 ------- 

1012 string(html) 

1013 """ 

1014 

1015 uid = self.uid 

1016 

1017 if not uid or table not in USER_TABLES: 

1018 return E 

1019 

1020 eid = list(self.info(table, N._id, kind=kind))[0] 

1021 taskParts = [] 

1022 

1023 allowedTasks = sorted( 

1024 (task, taskInfo) 

1025 for (task, taskInfo) in TASKS.items() 

1026 if G(taskInfo, N.table) == table 

1027 ) 

1028 justNow = now() 

1029 

1030 for (task, taskInfo) in allowedTasks: 

1031 permitted = self.permission(task, kind=kind) 

1032 if not permitted: 

1033 continue 

1034 

1035 remaining = type(permitted) is timedelta and permitted 

1036 taskUntil = E 

1037 if remaining: 

1038 remainingRep = datetime.toDisplay(justNow + remaining) 

1039 taskUntil = H.span(f""" before {remainingRep}""", cls="datex") 

1040 taskMsg = G(taskInfo, N.msg) 

1041 taskCls = G(taskInfo, N.cls) 

1042 

1043 taskPart = ( 

1044 H.a( 

1045 [taskMsg, taskUntil], 

1046 f"""/api/task/{task}/{eid}""", 

1047 cls=f"large task {taskCls}", 

1048 ) 

1049 + f"""<!-- task!{task}:{eid} -->""" 

1050 ) 

1051 taskParts.append(taskPart) 

1052 

1053 return H.join(taskParts) 

1054 

1055 def getWf(self, table, kind=None): 

1056 """Select a source of attributes within a workflow item. 

1057 

1058 Parameters 

1059 ---------- 

1060 table: string 

1061 We must specify the kind of record for which we want the attributes: 

1062 contrib, assessment, or review. 

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

1064 Only if we want review attributes 

1065 

1066 Returns 

1067 ------- 

1068 dict 

1069 """ 

1070 

1071 data = self.data 

1072 if table == N.contrib: 

1073 return data 

1074 

1075 data = G(data, N.assessment) 

1076 if table in {N.assessment, N.criteriaEntry}: 

1077 return data 

1078 

1079 if table in {N.review, N.reviewEntry}: 1079 ↛ 1083line 1079 didn't jump to line 1083, because the condition on line 1079 was never false

1080 data = G(G(data, N.reviews), kind) 

1081 return data 

1082 

1083 return None 

1084 

1085 def myReviewerKind(self, reviewer=None): 

1086 """Determine whether the current user is `expert` or `final`. 

1087 

1088 Parameters 

1089 ---------- 

1090 reviewer: dict, optional `None` 

1091 If absent, the assessment in the workflow info will be inspected 

1092 to get a dict of its reviewers by kind. 

1093 Otherwise, it should be a dict of user ids keyed by `expert` and 

1094 `final`. 

1095 

1096 Returns 

1097 ------- 

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

1099 Depending on whether the current user is such a reviewer of the 

1100 assessment of this contribution. Or `None` if (s)he is not a reviewer 

1101 at all. 

1102 """ 

1103 uid = self.uid 

1104 

1105 if reviewer is None: 1105 ↛ 1108line 1105 didn't jump to line 1108, because the condition on line 1105 was never false

1106 reviewer = G(self.getWf(N.assessment), N.reviewer) 

1107 

1108 return ( 

1109 N.expert 

1110 if G(reviewer, N.expert) == uid 

1111 else N.final 

1112 if G(reviewer, N.final) == uid 

1113 else None 

1114 )