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 More precisely, the workflow configuration table (yaml/workflow.yaml) 

548 my specify a set of delays for a set of user roles. 

549 

550 * `all` specifies the default for users 

551 whose role has not got a corresponding delay 

552 * `coord` is national coordinator of the relevant country 

553 * `office` is any office user 

554 * `super` is any super user, i.e. `system` or `root` 

555 

556 The value specified for each of these roles is either an integer, 

557 which is the amount of hours of the delay. 

558 Or it is `false` (no delay) or `true` (infinite delay). 

559 

560 Parameters 

561 ---------- 

562 table: string 

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

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

565 task: string 

566 An string consisting of the name of a task. 

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

568 Only if we want review attributes 

569 

570 Returns 

571 ------- 

572 boolean | timedelta | string 

573 """ 

574 

575 db = self.db 

576 auth = self.auth 

577 uid = self.uid 

578 

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

580 return False 

581 

582 taskInfo = TASKS[task] 

583 table = G(taskInfo, N.table) 

584 

585 if uid is None or table not in USER_TABLES: 

586 return False 

587 

588 taskField = ( 

589 N.selected 

590 if table == N.contrib 

591 else N.submitted 

592 if table == N.assessment 

593 else N.decision 

594 if table == N.review 

595 else None 

596 ) 

597 myKind = self.myKind 

598 

599 ( 

600 locked, 

601 done, 

602 frozen, 

603 mayAdd, 

604 stage, 

605 stageDate, 

606 creators, 

607 countryId, 

608 taskValue, 

609 ) = self.info( 

610 table, 

611 N.locked, 

612 N.done, 

613 N.frozen, 

614 N.mayAdd, 

615 N.stage, 

616 N.stageDate, 

617 N.creators, 

618 N.country, 

619 taskField, 

620 kind=kind, 

621 ) 

622 

623 operator = G(taskInfo, N.operator) 

624 value = G(taskInfo, N.value) 

625 if operator == N.set: 

626 if taskField == N.decision: 

627 value = G(db.decisionInv, value) 

628 

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

630 

631 isOwn = creators and uid in creators 

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

633 isSuper = auth.superuser() 

634 isOffice = auth.officeuser() 

635 isSysadmin = auth.sysadmin() 

636 

637 decisionDelay = G(taskInfo, N.delay, False) 

638 if decisionDelay: 

639 if type(decisionDelay) is int: 

640 decisionDelay = timedelta(hours=decisionDelay) 

641 elif type(decisionDelay) is dict: 641 ↛ 654line 641 didn't jump to line 654, because the condition on line 641 was never false

642 defaultDecisionDelay = G(decisionDelay, N.all, False) 

643 decisionDelay = ( 

644 G(decisionDelay, N.coord, defaultDecisionDelay) 

645 if isCoord 

646 else G(decisionDelay, N.sysadmin, defaultDecisionDelay) 

647 if isSysadmin 

648 else G(decisionDelay, N.office, defaultDecisionDelay) 

649 if isOffice 

650 else defaultDecisionDelay 

651 ) 

652 if type(decisionDelay) is int: 

653 decisionDelay = timedelta(hours=decisionDelay) 

654 elif type(decisionDelay) is not bool: 

655 decisionDelay = False 

656 

657 justNow = now() 

658 remaining = False 

659 if decisionDelay and stageDate: 

660 if type(decisionDelay) is bool: 

661 remaining = True 

662 else: 

663 remaining = stageDate + decisionDelay - justNow 

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

665 remaining = False 

666 

667 forbidden = frozen or done 

668 

669 if forbidden: 

670 if ( 

671 task == N.unselectContrib 

672 and table == N.contrib 

673 ): 

674 if remaining is True: 

675 return "as intervention" 

676 if remaining: 

677 return remaining 

678 if not remaining: 

679 return False 

680 

681 if table == N.contrib: 

682 if not isOwn and not isCoord and not isSuper: 

683 return False 

684 

685 if task == N.startAssessment: 

686 return not forbidden and isOwn and mayAdd 

687 

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

689 return False 

690 

691 if not isCoord: 

692 return False 

693 

694 answer = not frozen or remaining 

695 

696 if task == N.selectContrib: 

697 return stage != N.selectYes and answer 

698 

699 if task == N.deselectContrib: 

700 return stage != N.selectNo and answer 

701 

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

703 return stage != N.selectNone and answer 

704 

705 return False 

706 

707 if table == N.assessment: 

708 forbidden = frozen or done 

709 if forbidden: 

710 return False 

711 

712 if task == N.startReview: 

713 return not forbidden and G(mayAdd, myKind) 

714 

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

716 return False 

717 

718 if uid not in creators: 

719 return False 

720 

721 answer = not locked or remaining 

722 if not answer: 

723 return False 

724 

725 if task == N.submitAssessment: 

726 return stage == N.complete and answer 

727 

728 if task == N.resubmitAssessment: 

729 return stage == N.completeWithdrawn and answer 

730 

731 if task == N.submitRevised: 

732 return stage == N.completeRevised and answer 

733 

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

735 return ( 

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

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

738 and answer 

739 ) 

740 

741 return False 

742 

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

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

745 return False 

746 

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

748 return False 

749 

750 taskKind = G(taskInfo, N.kind) 

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

752 return False 

753 

754 answer = remaining or not done or remaining 

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

756 return False 

757 

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

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

760 (expertStage, expertStageDate) = self.info( 

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

762 ) 

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

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

765 revision = finalStage == N.reviewRevise 

766 zFinalStage = finalStage and not revision 

767 submitted = aStage == N.submitted 

768 submittedRevised = aStage == N.submittedRevised 

769 mayDecideExpert = ( 

770 submitted and not finalStage or submittedRevised and revision 

771 ) 

772 

773 if value == taskValue: 

774 if not revision: 

775 return False 

776 

777 if ( 

778 task 

779 in { 

780 N.expertReviewRevise, 

781 N.expertReviewAccept, 

782 N.expertReviewReject, 

783 N.expertReviewRevoke, 

784 } 

785 - {xExpertStage} 

786 ): 

787 return ( 

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

789 ) 

790 

791 if ( 791 ↛ 815line 791 didn't jump to line 815

792 task 

793 in { 

794 N.finalReviewRevise, 

795 N.finalReviewAccept, 

796 N.finalReviewReject, 

797 N.finalReviewRevoke, 

798 } 

799 - {xFinalStage} 

800 ): 

801 return ( 

802 kind == N.final 

803 and not not expertStage 

804 and (not aStageDate or expertStageDate > aStageDate) 

805 and ( 

806 ( 

807 (not finalStage and submitted) 

808 or (revision and submittedRevised) 

809 ) 

810 or remaining 

811 ) 

812 and answer 

813 ) 

814 

815 return False 

816 

817 return False 

818 

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

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

821 

822 !!! hint 

823 See above for a description of the stages. 

824 

825 Parameters 

826 ---------- 

827 table: string 

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

829 contrib, assessment, or review. 

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

831 Only if we want review attributes 

832 

833 Returns 

834 ------- 

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

836 See above for the complete list. 

837 """ 

838 

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

840 

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

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

843 

844 Parameters 

845 ---------- 

846 table: string 

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

848 contrib, assessment, or review. 

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

850 Only if we want review attributes 

851 

852 Returns 

853 ------- 

854 (list of ObjectId) 

855 """ 

856 

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

858 

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

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

861 

862 Parameters 

863 ---------- 

864 table: string 

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

866 contrib, assessment, or review. 

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

868 Only if we want review attributes 

869 

870 Returns 

871 ------- 

872 string(html) 

873 """ 

874 

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

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

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

878 

879 return H.div( 

880 [ 

881 rButton, 

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

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

884 ], 

885 cls="workflow", 

886 ) 

887 

888 @staticmethod 

889 def isTask(table, field): 

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

891 

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

893 directly: 

894 

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

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

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

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

899 

900 !!! hint 

901 Workflow tasks are described above. 

902 

903 !!! caution 

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

905 are represented and actionable in the normal way. 

906 

907 Parameters 

908 ---------- 

909 table: string 

910 The table in question. 

911 field: string 

912 The field in question. 

913 

914 Returns 

915 ------- 

916 boolean 

917 """ 

918 

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

920 return field in taskFields 

921 

922 def doTask(self, task, recordObj): 

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

924 

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

926 

927 !!! hint 

928 Workflow tasks are described above. 

929 

930 Parameters 

931 ---------- 

932 recordObj: object 

933 The record must be passed as a record object. 

934 

935 Returns 

936 ------- 

937 url | `None` 

938 To navigate to after the action has been performed. 

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

940 """ 

941 

942 context = recordObj.context 

943 table = recordObj.table 

944 eid = recordObj.eid 

945 kind = recordObj.kind 

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

947 

948 taskInfo = G(TASKS, task) 

949 acro = G(taskInfo, N.acro) 

950 

951 urlExtra = E 

952 

953 executed = False 

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

955 operator = G(taskInfo, N.operator) 

956 if operator == N.add: 

957 dtable = G(taskInfo, N.detail) 

958 tableObj = mkTable(context, dtable) 

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

960 if deid: 

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

962 executed = True 

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

964 field = G(taskInfo, N.field) 

965 value = G(taskInfo, N.value) 

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

967 executed = True 

968 if executed: 

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

970 else: 

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

972 else: 

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

974 

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

976 

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

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

979 

980 Parameters 

981 ---------- 

982 table: string 

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

984 contrib, assessment, or review. 

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

986 Only if we want review attributes 

987 

988 Returns 

989 ------- 

990 string(html) 

991 """ 

992 

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

994 table, 

995 N.stage, 

996 N.stageDate, 

997 N.locked, 

998 N.done, 

999 N.frozen, 

1000 N.score, 

1001 N._id, 

1002 kind=kind, 

1003 ) 

1004 stageInfo = G(STAGE_ATTS, stage) 

1005 statusCls = G(stageInfo, N.cls) 

1006 stageOn = ( 

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

1008 if stageDate 

1009 else E 

1010 ) 

1011 statusMsg = H.span( 

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

1013 ) 

1014 lockedCls = N.locked if locked else E 

1015 lockedMsg = ( 

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

1017 if locked 

1018 else E 

1019 ) 

1020 doneCls = N.done if done else E 

1021 doneMsg = ( 

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

1023 ) 

1024 frozenCls = N.frozen if frozen else E 

1025 frozenMsg = ( 

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

1027 ) 

1028 

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

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

1031 ) 

1032 

1033 scorePart = E 

1034 if table == N.assessment: 

1035 scoreParts = presentScore(score, eid) 

1036 scorePart = ( 

1037 H.span(scoreParts) 

1038 if table == N.assessment 

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

1040 if table == N.contrib 

1041 else E 

1042 ) 

1043 

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

1045 

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

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

1048 

1049 !!! hint "easy comments" 

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

1051 for the ease of testing. 

1052 

1053 Parameters 

1054 ---------- 

1055 table: string 

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

1057 tasks: contrib, assessment, or review. 

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

1059 Only if we want review attributes 

1060 

1061 Returns 

1062 ------- 

1063 string(html) 

1064 """ 

1065 

1066 uid = self.uid 

1067 

1068 if not uid or table not in USER_TABLES: 

1069 return E 

1070 

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

1072 taskParts = [] 

1073 

1074 allowedTasks = sorted( 

1075 (task, taskInfo) 

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

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

1078 ) 

1079 justNow = now() 

1080 

1081 for (task, taskInfo) in allowedTasks: 

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

1083 if not permitted: 

1084 continue 

1085 

1086 remaining = type(permitted) is timedelta and permitted 

1087 remark = type(permitted) is str and permitted 

1088 taskExtra = E 

1089 if remaining: 

1090 remainingRep = datetime.toDisplay(justNow + remaining) 

1091 taskExtra = H.span(f""" before {remainingRep}""", cls="datex") 

1092 elif remark: 

1093 taskExtra = H.span(f""" {remark}""", cls="datex") 

1094 taskMsg = G(taskInfo, N.msg) 

1095 taskCls = G(taskInfo, N.cls) 

1096 

1097 taskPart = ( 

1098 H.a( 

1099 [taskMsg, taskExtra], 

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

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

1102 ) 

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

1104 ) 

1105 taskParts.append(taskPart) 

1106 

1107 return H.join(taskParts) 

1108 

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

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

1111 

1112 Parameters 

1113 ---------- 

1114 table: string 

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

1116 contrib, assessment, or review. 

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

1118 Only if we want review attributes 

1119 

1120 Returns 

1121 ------- 

1122 dict 

1123 """ 

1124 

1125 data = self.data 

1126 if table == N.contrib: 

1127 return data 

1128 

1129 data = G(data, N.assessment) 

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

1131 return data 

1132 

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

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

1135 return data 

1136 

1137 return None 

1138 

1139 def myReviewerKind(self, reviewer=None): 

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

1141 

1142 Parameters 

1143 ---------- 

1144 reviewer: dict, optional `None` 

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

1146 to get a dict of its reviewers by kind. 

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

1148 `final`. 

1149 

1150 Returns 

1151 ------- 

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

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

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

1155 at all. 

1156 """ 

1157 uid = self.uid 

1158 

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

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

1161 

1162 return ( 

1163 N.expert 

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

1165 else N.final 

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

1167 else None 

1168 )