Coverage for control/workflow/apply.py : 92%

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
3* Compute workflow permissions
4* Show workflow state
5* Perform workflow tasks
6* Enforce workflow constraints
8## Workflow tasks
10The heart of the tool consists of a set of workflow tasks
11that can be executed safely by a workflow engine.
13A task is is triggered by a url:
15`/api/task/`*taskName*`/`*eid*
17Here the *eid* is the id of the central record of the task, e.g. a particular
18contribution, assessment, or review.
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.
25### Properties of workflow tasks
27Here is a list that explains the task properties.
29operator
30: There are two kinds of operator: `add` and `set`.
32 The effect of `add` is the insertion of a new record in a
33 table given in the `detail` property.
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.
39table
40: The table in which the record resides that is central to the task.
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.
46kind
47: In case the task operates on reviews: whether the task is relevant for
48 an `expert` review or a `final` review.
50field
51: In case the operator is `set`: the field in the central record that will be changed.
53value
54: In case the operator is `set`: the new value for the field in the central
55 record that will be changed.
57date
58: In case the operator is `set`: the name of the field that will receive the
59 timestamp.
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.
65msg
66: How the task is called on the interface.
68acro
69: An acronym of the task to be used in flash messages.
71cls
72: A CSS class that determines the color of the workflow button, usually
73 `info`, `good`, `warning`, `error`. `info` is the neutral color.
75## Workflow stages
77Workflow stages are listed in workflow.yaml, under `stageAtts`.
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"""
84from datetime import timedelta
85from flask import flash
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
95CT = C.tables
96CF = C.workflow
98ALL_TABLES = CT.all
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
106STAGE_ATTS = CF.stageAtts
107TASKS = CF.tasks
108TASK_FIELDS = CF.taskFields
109STATUS_REP = CF.statusRep
110DECISION_DELAY = CF.decisionDelay
112datetime = Datetime()
115def execute(context, task, eid):
116 """Executes a workflow task.
118 First a table object is constructed, based on the `table` property
119 of the task, using `context`.
121 Then a record object is constructed in that table, based on the `eid`
122 parameter.
124 If that all succeeds, all information is at hand to verify permissions
125 and perform the task.
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 """
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)
146class WorkflowItem:
147 """Supports the application of workflow information.
149 A WorkflowItem singleton has a bunch of workflow attributes as dict in its
150 attribute `data` and offers methods to
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.
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 """
164 def __init__(self, context, data):
165 """## Initialization
167 Wraps a workflow item record around a workflow data record.
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`.
173 Workflow items also store details of the current user, which will be needed
174 for the computation of permissions.
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`.
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 """
192 db = context.db
193 auth = context.auth
194 user = auth.user
196 self.db = db
197 """*object* The `control.db.Db` singleton
199 Provides methods to deal with values from the table `decision`.
200 """
202 self.auth = auth
203 """*object* The `control.auth.Auth` singleton
205 Provides methods to access the attributes of the current user.
206 """
208 self.uid = G(user, N._id)
209 """*ObjectId* The id of the current user.
210 """
212 self.eppn = G(user, N.eppn)
213 """*ObjectId* The eppn of the current user.
215 !!! hint
216 The eppn is the user identifying attribute from the identity provider.
217 """
219 self.isSuperuser = auth.superuser()
220 """*boolean* Whether the current user is a superuser.
222 See `control.auth.Auth.superuser`.
223 """
225 self.data = data
226 """*dict* The workflow attributes.
227 """
229 self.myKind = self.myReviewerKind()
230 """*dict* The kind of reviewer that the current user is.
232 A user is `expert` reviewer or `final` reviewer, or `None`.
233 """
235 def getKind(self, table, record):
236 """Determine whether a review(Entry) is `expert` or `final`.
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.
245 Parameters
246 ----------
247 table: string
248 Either `review` or `reviewEntry`.
249 record: dict
250 Either a `review` record or a `reviewEntry` record.
252 Returns
253 -------
254 string {`expert`, `final`}
255 Or `None`.
256 """
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
273 def isValid(self, table, eid, record):
274 """Is a record a valid part of the workflow?
276 Valid parts are contributions, assessment and review detail records of
277 contributions satisfying:
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
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.
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
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
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 }
320 def info(self, table, *atts, kind=None):
321 """Retrieve selected attributes of the workflow
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.
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
337 Returns
338 -------
339 generator
340 Yields attribute values, corresponding to `*atts`.
341 """
343 thisData = self.getWf(table, kind=kind)
344 return (G(thisData, att) for att in atts)
346 def checkReadable(self, recordObj):
347 """Whether a record is readable because of workflow.
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.
354 This method determines the record is readable by the current user.
356 If the record is not part of the workflow, `None` is returned, and
357 the normal permission rules apply.
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.
364 Here are the rules:
366 #### Assessment, Criteria Entry
368 Not submitted and not in revision:
369 : authors and editors only
371 Submitted, review not yet complete, or negative outcome
372 : authors, editors, reviewers, national coordinator only
374 Review with positive outcome
375 : public
377 In revision, or review with a negative outcome
378 : authors, editors, reviewers, national coordinator only
380 #### Review, Review Entry
382 Review has no decision and there is no final decision
383 : authors, editors, the other reviewer
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
389 There is a positive final decision
390 : public
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.
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.
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.
410 Returns
411 -------
412 boolean | `None`
413 """
415 isSuperuser = self.isSuperuser
416 if isSuperuser:
417 return None
419 table = recordObj.table
420 if table not in SENSITIVE_TABLES:
421 return None
423 kind = recordObj.kind
424 perm = recordObj.perm
425 uid = self.uid
427 (stage,) = self.info(table, N.stage, kind=kind)
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 )
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
466 def checkFixed(self, recordObj, field=None):
467 """Whether a record or field is fixed because of workflow.
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.
473 This method checks whether a record or field is currently fixed,
474 i.e. whether editing is possible.
476 !!! note
477 It might also depend on the current user.
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.
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.
494 Returns
495 -------
496 boolean
497 """
499 auth = self.auth
500 table = recordObj.table
501 kind = recordObj.kind
503 (frozen, done, locked) = self.info(table, N.frozen, N.done, N.locked, kind=kind)
505 if field is None:
506 return frozen or done or locked
508 if frozen or done:
509 return True
511 if not locked:
512 return False
514 isOffice = auth.officeuser()
515 if isOffice and table == N.assessment:
516 return field not in {N.reviewerE, N.reviewerF}
518 return True
520 def permission(self, task, kind=None):
521 """Checks whether a workflow task is permitted.
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.
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.
531 !!! hint
532 See above for explanation of the properties of the tasks.
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.
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.
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.
547 More precisely, the workflow configuration table (yaml/workflow.yaml)
548 my specify a set of delays for a set of user roles.
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`
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).
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
570 Returns
571 -------
572 boolean | timedelta | string
573 """
575 db = self.db
576 auth = self.auth
577 uid = self.uid
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
582 taskInfo = TASKS[task]
583 table = G(taskInfo, N.table)
585 if uid is None or table not in USER_TABLES:
586 return False
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
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 )
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)
629 (contribId,) = self.info(N.contrib, N._id)
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()
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
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
667 forbidden = frozen or done
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
681 if table == N.contrib:
682 if not isOwn and not isCoord and not isSuper:
683 return False
685 if task == N.startAssessment:
686 return not forbidden and isOwn and mayAdd
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
691 if not isCoord:
692 return False
694 answer = not frozen or remaining
696 if task == N.selectContrib:
697 return stage != N.selectYes and answer
699 if task == N.deselectContrib:
700 return stage != N.selectNo and answer
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
705 return False
707 if table == N.assessment:
708 forbidden = frozen or done
709 if forbidden:
710 return False
712 if task == N.startReview:
713 return not forbidden and G(mayAdd, myKind)
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
718 if uid not in creators:
719 return False
721 answer = not locked or remaining
722 if not answer:
723 return False
725 if task == N.submitAssessment:
726 return stage == N.complete and answer
728 if task == N.resubmitAssessment:
729 return stage == N.completeWithdrawn and answer
731 if task == N.submitRevised:
732 return stage == N.completeRevised and answer
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 )
741 return False
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
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
750 taskKind = G(taskInfo, N.kind)
751 if not kind or kind != taskKind or kind != myKind:
752 return False
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
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 )
773 if value == taskValue:
774 if not revision:
775 return False
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 )
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 )
815 return False
817 return False
819 def stage(self, table, kind=None):
820 """Find the workflow stage that a record is in.
822 !!! hint
823 See above for a description of the stages.
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
833 Returns
834 -------
835 string {`selectYes`, `submittedRevised`, `reviewAccept`, ...}
836 See above for the complete list.
837 """
839 return list(self.info(table, N.stage, kind=kind))[0]
841 def creators(self, table, kind=None):
842 """Find the creators from a workflow related record.
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
852 Returns
853 -------
854 (list of ObjectId)
855 """
857 return list(self.info(table, N.creators, kind=kind))[0]
859 def status(self, table, kind=None):
860 """Present all workflow info and controls relevant to the record.
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
870 Returns
871 -------
872 string(html)
873 """
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)
879 return H.div(
880 [
881 rButton,
882 self.statusOverview(table, kind=kind),
883 self.tasks(table, kind=kind),
884 ],
885 cls="workflow",
886 )
888 @staticmethod
889 def isTask(table, field):
890 """Whether a field in a record is involved in a workflow task.
892 Fields that are involved in workflow tasks can not be read or edited
893 directly:
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`).
900 !!! hint
901 Workflow tasks are described above.
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.
907 Parameters
908 ----------
909 table: string
910 The table in question.
911 field: string
912 The field in question.
914 Returns
915 -------
916 boolean
917 """
919 taskFields = G(TASK_FIELDS, table, default=set())
920 return field in taskFields
922 def doTask(self, task, recordObj):
923 """Execute a workflow task on a record.
925 The permission to execute the task will be checked first.
927 !!! hint
928 Workflow tasks are described above.
930 Parameters
931 ----------
932 recordObj: object
933 The record must be passed as a record object.
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 """
942 context = recordObj.context
943 table = recordObj.table
944 eid = recordObj.eid
945 kind = recordObj.kind
946 (contribId,) = self.info(N.contrib, N._id)
948 taskInfo = G(TASKS, task)
949 acro = G(taskInfo, N.acro)
951 urlExtra = E
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")
975 return f"""/{N.contrib}/{N.item}/{contribId}{urlExtra}""" if executed else None
977 def statusOverview(self, table, kind=None):
978 """Present the current status of a record on the interface.
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
988 Returns
989 -------
990 string(html)
991 """
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 )
1029 statusRep = f"<!-- stage:{stage} -->" + H.div(
1030 [statusMsg, lockedMsg, doneMsg, frozenMsg], cls=frozenCls
1031 )
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 )
1044 return H.div([statusRep, scorePart], cls="workflow-line")
1046 def tasks(self, table, kind=None):
1047 """Present the currently available tasks as buttons on the interface.
1049 !!! hint "easy comments"
1050 We also include a comment `<!-- task~!taskName:eid -->
1051 for the ease of testing.
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
1061 Returns
1062 -------
1063 string(html)
1064 """
1066 uid = self.uid
1068 if not uid or table not in USER_TABLES:
1069 return E
1071 eid = list(self.info(table, N._id, kind=kind))[0]
1072 taskParts = []
1074 allowedTasks = sorted(
1075 (task, taskInfo)
1076 for (task, taskInfo) in TASKS.items()
1077 if G(taskInfo, N.table) == table
1078 )
1079 justNow = now()
1081 for (task, taskInfo) in allowedTasks:
1082 permitted = self.permission(task, kind=kind)
1083 if not permitted:
1084 continue
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)
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)
1107 return H.join(taskParts)
1109 def getWf(self, table, kind=None):
1110 """Select a source of attributes within a workflow item.
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
1120 Returns
1121 -------
1122 dict
1123 """
1125 data = self.data
1126 if table == N.contrib:
1127 return data
1129 data = G(data, N.assessment)
1130 if table in {N.assessment, N.criteriaEntry}:
1131 return data
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
1137 return None
1139 def myReviewerKind(self, reviewer=None):
1140 """Determine whether the current user is `expert` or `final`.
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`.
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
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)
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 )