Module control.workflow.apply
Applying workflow
- Compute workflow permissions
- Show workflow state
- Perform workflow tasks
- Enforce workflow constraints
Workflow tasks
The heart of the tool consists of a set of workflow tasks that can be executed safely by a workflow engine.
A task is is triggered by a url:
/api/task/
taskName/
eid
Here the eid is the id of the central record of the task, e.g. a particular contribution, assessment, or review.
Workflow tasks are listed in workflow.yaml, under tasks
.
Every task name is associated with properties,
which are used in determining the permissions of a task.
They also steer the execution of the task.
Properties of workflow tasks
Here is a list that explains the task properties.
- operator
-
There are two kinds of operator:
add
andset
.The effect of
add
is the insertion of a new record in a table given in thedetail
property.The effect of
set
is the setting of specific fields in a record in the table inndicated by thetable
property. The fields are indicated in thefield
anddate
properties. - table
- The table in which the record resides that is central to the task.
- detail
- The detail table in case the operator is
add
: it will add a detail record of the central record into this table. - kind
- In case the task operates on reviews: whether the task is relevant for
an
expert
review or afinal
review. - field
- In case the operator is
set
: the field in the central record that will be changed. - value
- In case the operator is
set
: the new value for the field in the central record that will be changed. - date
- In case the operator is
set
: the name of the field that will receive the timestamp. - delay
- All
set
tasks are not meant to be revoked. But there is some leeway: Within the amount of hours specified here, the user can revoke the task. - msg
- How the task is called on the interface.
- acro
- An acronym of the task to be used in flash messages.
- cls
- A CSS class that determines the color of the workflow button, usually
info
,good
,warning
,error
.info
is the neutral color.
Workflow stages
Workflow stages are listed in workflow.yaml, under stageAtts
.
The stage of a record is stored in the workflow attribute stage
,
so the only thing needed is to ask for that attribute with
WorkflowItem.info()
.
Expand source code
"""Applying workflow
* Compute workflow permissions
* Show workflow state
* Perform workflow tasks
* Enforce workflow constraints
## Workflow tasks
The heart of the tool consists of a set of workflow tasks
that can be executed safely by a workflow engine.
A task is is triggered by a url:
`/api/task/`*taskName*`/`*eid*
Here the *eid* is the id of the central record of the task, e.g. a particular
contribution, assessment, or review.
Workflow tasks are listed in workflow.yaml, under `tasks`.
Every task name is associated with properties,
which are used in determining the permissions of a task.
They also steer the execution of the task.
### Properties of workflow tasks
Here is a list that explains the task properties.
operator
: There are two kinds of operator: `add` and `set`.
The effect of `add` is the insertion of a new record in a
table given in the `detail` property.
The effect of `set` is the setting of specific fields in a record in
the table inndicated by the `table` property.
The fields are indicated in the `field` and `date` properties.
table
: The table in which the record resides that is central to the task.
detail
: The detail table in case the operator is `add`: it will add a detail
record of the central record into this table.
kind
: In case the task operates on reviews: whether the task is relevant for
an `expert` review or a `final` review.
field
: In case the operator is `set`: the field in the central record that will be changed.
value
: In case the operator is `set`: the new value for the field in the central
record that will be changed.
date
: In case the operator is `set`: the name of the field that will receive the
timestamp.
delay
: All `set` tasks are not meant to be revoked. But there is some leeway:
Within the amount of hours specified here, the user can revoke the task.
msg
: How the task is called on the interface.
acro
: An acronym of the task to be used in flash messages.
cls
: A CSS class that determines the color of the workflow button, usually
`info`, `good`, `warning`, `error`. `info` is the neutral color.
## Workflow stages
Workflow stages are listed in workflow.yaml, under `stageAtts`.
The stage of a record is stored in the workflow attribute `stage`,
so the only thing needed is to ask for that attribute with
`control.workflow.apply.WorkflowItem.info`.
"""
from datetime import timedelta
from flask import flash
from config import Config as C, Names as N
from control.utils import pick as G, E, now
from control.html import HtmlElements as H
from control.typ.datetime import Datetime
from control.cust.score import presentScore
from control.cust.factory_table import make as mkTable
CT = C.tables
CF = C.workflow
ALL_TABLES = CT.all
USER_TABLES_LIST = CT.userTables
MAIN_TABLE = USER_TABLES_LIST[0]
USER_ENTRY_TABLES = set(CT.userEntryTables)
USER_TABLES = set(USER_TABLES_LIST)
SENSITIVE_TABLES = (USER_TABLES - {MAIN_TABLE}) | USER_ENTRY_TABLES
STAGE_ATTS = CF.stageAtts
TASKS = CF.tasks
TASK_FIELDS = CF.taskFields
STATUS_REP = CF.statusRep
DECISION_DELAY = CF.decisionDelay
datetime = Datetime()
def execute(context, task, eid):
"""Executes a workflow task.
First a table object is constructed, based on the `table` property
of the task, using `context`.
Then a record object is constructed in that table, based on the `eid`
parameter.
If that all succeeds, all information is at hand to verify permissions
and perform the task.
Parameters
----------
context: object
A `control.context.Context` singleton
task: string
The name of the task
eid: string(objectId)
The id of the relevant record
"""
taskInfo = G(TASKS, task)
acro = G(taskInfo, N.acro)
table = G(taskInfo, N.table)
if table not in ALL_TABLES:
flash(f"""Workflow {acro} operates on wrong table: "{table or E}""", "error")
return (False, None)
return mkTable(context, table).record(eid=eid).task(task)
class WorkflowItem:
"""Supports the application of workflow information.
A WorkflowItem singleton has a bunch of workflow attributes as dict in its
attribute `data` and offers methods to
* address selected pieces of that information;
* compute permissions for workflow actions and database actions;
* determine the workflow stage the contribution is in.
Attributes
----------
data: dict
All workflow attributes.
myKind: string
The kind of reviewer the current user is, if any.
"""
def __init__(self, context, data):
"""## Initialization
Wraps a workflow item record around a workflow data record.
Workflow item records are created per contribution,
but they will be referenced by contribution, assessment and review records
in their attribute `wfitem`.
Workflow items also store details of the current user, which will be needed
for the computation of permissions.
!!! note
The user attributes `uid` and `eppn` will be stored in this `WorkflowItem`
object.
At this point, it is also possible to what kind of reviewer the current
user is, if any, and store that in attribute `myKind`.
Parameters
----------
context: object
The `control.context.Context singleton`, from which the
`control.auth.Auth` singleton can be picked up, from which the
details of the current user can be read off.
data: dict
See below.
"""
db = context.db
auth = context.auth
user = auth.user
self.db = db
"""*object* The `control.db.Db` singleton
Provides methods to deal with values from the table `decision`.
"""
self.auth = auth
"""*object* The `control.auth.Auth` singleton
Provides methods to access the attributes of the current user.
"""
self.uid = G(user, N._id)
"""*ObjectId* The id of the current user.
"""
self.eppn = G(user, N.eppn)
"""*ObjectId* The eppn of the current user.
!!! hint
The eppn is the user identifying attribute from the identity provider.
"""
self.isSuperuser = auth.superuser()
"""*boolean* Whether the current user is a superuser.
See `control.auth.Auth.superuser`.
"""
self.data = data
"""*dict* The workflow attributes.
"""
self.myKind = self.myReviewerKind()
"""*dict* The kind of reviewer that the current user is.
A user is `expert` reviewer or `final` reviewer, or `None`.
"""
def getKind(self, table, record):
"""Determine whether a review(Entry) is `expert` or `final`.
!!! warning
The value `None` (not a string!) is returned for reviews that are
no (longer) part of the workflow.
They could be reviews with a type that does not match the type
of the contribution, or reviews that have been superseded by newer
reviews.
Parameters
----------
table: string
Either `review` or `reviewEntry`.
record: dict
Either a `review` record or a `reviewEntry` record.
Returns
-------
string {`expert`, `final`}
Or `None`.
"""
if table in {N.review, N.reviewEntry}:
eid = G(record, N._id) if table == N.review else G(record, N.review)
data = self.getWf(N.assessment)
reviews = G(data, N.reviews, default={})
kind = (
N.expert
if G(G(reviews, N.expert), N._id) == eid
else N.final
if G(G(reviews, N.final), N._id) == eid
else None
)
else:
kind = None
return kind
def isValid(self, table, eid, record):
"""Is a record a valid part of the workflow?
Valid parts are contributions, assessment and review detail records of
contributions satisfying:
* they have the same type as their master contribution
* they are not superseded by other assessments or reviews
with the correct type
Parameters
----------
table: string {`review`, `assessment`, `criteriaEntry`, `reviewEntry`}.
eid: ObjectId
(Entity) id of the record to be validated.
record: dict
The full record to be validated.
Only needed for `reviewEntry` and `criteriaEntry` in order to look
up the master `review` or `assessment` record.
Returns
-------
boolean
"""
if eid is None:
return False
refId = (
G(record, N.assessment)
if table == N.criteriaEntry
else G(record, N.review)
if table == N.reviewEntry
else eid
)
if refId is None:
return False
if table in {N.contrib, N.assessment, N.criteriaEntry}:
data = self.getWf(table)
return refId == G(data, N._id)
elif table in {N.review, N.reviewEntry}:
data = self.getWf(N.assessment)
reviews = G(data, N.reviews, default={})
return refId in {
G(reviewInfo, N._id) for (kind, reviewInfo) in reviews.items()
}
def info(self, table, *atts, kind=None):
"""Retrieve selected attributes of the workflow
A workflow record contains attributes at the outermost level,
but also within its enclosed assessment workflow record and
the enclosed review workflow records.
Parameters
----------
table: string
In order to read attributes, we must specify the source of those
attributes: `contrib` (outermost), `assessment` or `review`.
*atts: iterable
The workflow attribute names to fetch.
kind: string {`expert`, `final`}, optional `None`
Only if we want review attributes
Returns
-------
generator
Yields attribute values, corresponding to `*atts`.
"""
thisData = self.getWf(table, kind=kind)
return (G(thisData, att) for att in atts)
def checkReadable(self, recordObj):
"""Whether a record is readable because of workflow.
When a contribution, assessment, review is in a certain stage
in the workflow, its record may be closed to others than the owner, and
after finalization, some fields may be open to authenticated users or
the public.
This method determines the record is readable by the current user.
If the record is not part of the workflow, `None` is returned, and
the normal permission rules apply.
!!! note
It also depends on the current user.
Power users will not be prevented to read records because of
workflow conditions.
Here are the rules:
#### Assessment, Criteria Entry
Not submitted and not in revision:
: authors and editors only
Submitted, review not yet complete, or negative outcome
: authors, editors, reviewers, national coordinator only
Review with positive outcome
: public
In revision, or review with a negative outcome
: authors, editors, reviewers, national coordinator only
#### Review, Review Entry
Review has no decision and there is no final decision
: authors, editors, the other reviewer
Review in question has a decision, but still no final positive decision
: authors/editors, other reviewer, authors/editors of the assessment,
national coordinator
There is a positive final decision
: public
!!! caution "The influence of selection is nihil"
Whether a contribution is selected or not has no influence on the
readability of the assessment and review.
!!! caution "The influence on the contribution records is nihil"
Whether a contribution is readable does not depend on the
workflow, only on the normal rules.
Parameters
----------
recordObj: object
The record in question (from which the table and the kind
maybe inferred. It should be the record that contains this
WorkflowItem object as its `wfitem` attribute.
field: string, optional `None`
If None, we check for the readability of the record as a whole.
Otherwise, we check for the readability of this field in the record.
Returns
-------
boolean | `None`
"""
isSuperuser = self.isSuperuser
if isSuperuser:
return None
table = recordObj.table
if table not in SENSITIVE_TABLES:
return None
kind = recordObj.kind
perm = recordObj.perm
uid = self.uid
(stage,) = self.info(table, N.stage, kind=kind)
if table in {N.assessment, N.criteriaEntry}:
(r2Stage,) = self.info(N.review, N.stage, kind=N.final)
return (
True
if r2Stage == N.reviewAccept
else perm[N.isOur]
if stage
in {
N.submitted,
N.incompleteRevised,
N.completeRevised,
N.submittedRevised,
}
else perm[N.isEdit]
)
if table in {N.review, N.reviewEntry}:
(creators,) = self.info(N.assessment, N.creators)
(r2Stage,) = self.info(N.review, N.stage, kind=N.final)
result = (
True
if r2Stage == N.reviewAccept
else uid in creators or perm[N.isOur]
if stage
in {
N.reviewAdviseRevise,
N.reviewAdviseAccept,
N.reviewAdviseReject,
N.reviewRevise,
N.reviewReject,
}
or r2Stage in {N.reviewRevise, N.reviewReject}
else perm[N.isReviewer] or perm[N.isEdit]
)
return result
return None
def checkFixed(self, recordObj, field=None):
"""Whether a record or field is fixed because of workflow.
When a contribution, assessment, review is in a certain stage
in the workflow, its record or some fields in its record may be
fixated, either temporarily or permanently.
This method checks whether a record or field is currently fixed,
i.e. whether editing is possible.
!!! note
It might also depend on the current user.
!!! caution
Here is a case where the sysadmin and the root are less powerful
than the office users: only the office users can assign reviewers,
i.e. only they can update `reviewerE` and `reviewerF` inn assessment fields.
Parameters
----------
recordObj: object
The record in question (from which the table and the kind
maybe inferred. It should be the record that contains this
WorkflowItem object as its `wfitem` attribute.
field: string, optional `None`
If None, we check for the fixity of the record as a whole.
Otherwise, we check for the fixity of this field in the record.
Returns
-------
boolean
"""
auth = self.auth
table = recordObj.table
kind = recordObj.kind
(frozen, done, locked) = self.info(table, N.frozen, N.done, N.locked, kind=kind)
if field is None:
return frozen or done or locked
if frozen or done:
return True
if not locked:
return False
isOffice = auth.officeuser()
if isOffice and table == N.assessment:
return field not in {N.reviewerE, N.reviewerF}
return True
def permission(self, task, kind=None):
"""Checks whether a workflow task is permitted.
Note that the tasks are listed per kind of record they apply to:
contrib, assessment, review.
They are typically triggered by big workflow buttons on the interface.
When the request to execute such a task reaches the server, it will
check whether the current user is allowed to execute this task
on the records in question.
!!! hint
See above for explanation of the properties of the tasks.
!!! note
If you try to run a task on a kind of record that it is not
designed for, it will be detected and no permission will be given.
!!! note
Some tasks are designed to set a field to a value.
If that field already has that value, the task will not be permitted.
This already rules out a lot of things and relieves the burden of
prohibiting non-sensical tasks.
It may be that the task is only permitted for some limited time from now on.
Then a timedelta object with the amount of time left is returned.
More precisely, the workflow configuration table (yaml/workflow.yaml)
my specify a set of delays for a set of user roles.
* `all` specifies the default for users
whose role has not got a corresponding delay
* `coord` is national coordinator of the relevant country
* `office` is any office user
* `super` is any super user, i.e. `system` or `root`
The value specified for each of these roles is either an integer,
which is the amount of hours of the delay.
Or it is `false` (no delay) or `true` (infinite delay).
Parameters
----------
table: string
In order to check permissions, we must specify the kind of record that
the task acts on: contrib, assessment, or review.
task: string
An string consisting of the name of a task.
kind: string {`expert`, `final`}, optional `None`
Only if we want review attributes
Returns
-------
boolean | timedelta | string
"""
db = self.db
auth = self.auth
uid = self.uid
if task not in TASKS:
return False
taskInfo = TASKS[task]
table = G(taskInfo, N.table)
if uid is None or table not in USER_TABLES:
return False
taskField = (
N.selected
if table == N.contrib
else N.submitted
if table == N.assessment
else N.decision
if table == N.review
else None
)
myKind = self.myKind
(
locked,
done,
frozen,
mayAdd,
stage,
stageDate,
creators,
countryId,
taskValue,
) = self.info(
table,
N.locked,
N.done,
N.frozen,
N.mayAdd,
N.stage,
N.stageDate,
N.creators,
N.country,
taskField,
kind=kind,
)
operator = G(taskInfo, N.operator)
value = G(taskInfo, N.value)
if operator == N.set:
if taskField == N.decision:
value = G(db.decisionInv, value)
(contribId,) = self.info(N.contrib, N._id)
isOwn = creators and uid in creators
isCoord = countryId and auth.coordinator(countryId=countryId)
isSuper = auth.superuser()
isOffice = auth.officeuser()
isSysadmin = auth.sysadmin()
decisionDelay = G(taskInfo, N.delay, False)
if decisionDelay:
if type(decisionDelay) is int:
decisionDelay = timedelta(hours=decisionDelay)
elif type(decisionDelay) is dict:
defaultDecisionDelay = G(decisionDelay, N.all, False)
decisionDelay = (
G(decisionDelay, N.coord, defaultDecisionDelay)
if isCoord
else G(decisionDelay, N.sysadmin, defaultDecisionDelay)
if isSysadmin
else G(decisionDelay, N.office, defaultDecisionDelay)
if isOffice
else defaultDecisionDelay
)
if type(decisionDelay) is int:
decisionDelay = timedelta(hours=decisionDelay)
elif type(decisionDelay) is not bool:
decisionDelay = False
justNow = now()
remaining = False
if decisionDelay and stageDate:
if type(decisionDelay) is bool:
remaining = True
else:
remaining = stageDate + decisionDelay - justNow
if remaining <= timedelta(hours=0):
remaining = False
forbidden = frozen or done
if forbidden:
if (
task == N.unselectContrib
and table == N.contrib
):
if remaining is True:
return "as intervention"
if remaining:
return remaining
if not remaining:
return False
if table == N.contrib:
if not isOwn and not isCoord and not isSuper:
return False
if task == N.startAssessment:
return not forbidden and isOwn and mayAdd
if value == taskValue:
return False
if not isCoord:
return False
answer = not frozen or remaining
if task == N.selectContrib:
return stage != N.selectYes and answer
if task == N.deselectContrib:
return stage != N.selectNo and answer
if task == N.unselectContrib:
return stage != N.selectNone and answer
return False
if table == N.assessment:
forbidden = frozen or done
if forbidden:
return False
if task == N.startReview:
return not forbidden and G(mayAdd, myKind)
if value == taskValue:
return False
if uid not in creators:
return False
answer = not locked or remaining
if not answer:
return False
if task == N.submitAssessment:
return stage == N.complete and answer
if task == N.resubmitAssessment:
return stage == N.completeWithdrawn and answer
if task == N.submitRevised:
return stage == N.completeRevised and answer
if task == N.withdrawAssessment:
return (
stage in {N.submitted, N.submittedRevised}
and stage not in {N.incompleteWithdrawn, N.completeWithdrawn}
and answer
)
return False
if table == N.review:
if frozen:
return False
if done and not remaining:
return False
taskKind = G(taskInfo, N.kind)
if not kind or kind != taskKind or kind != myKind:
return False
answer = remaining or not done or remaining
if not answer:
return False
(aStage, aStageDate) = self.info(N.assessment, N.stage, N.stageDate)
(finalStage,) = self.info(table, N.stage, kind=N.final)
(expertStage, expertStageDate) = self.info(
table, N.stage, N.stageDate, kind=N.expert
)
xExpertStage = N.expertReviewRevoke if expertStage is None else expertStage
xFinalStage = N.finalReviewRevoke if finalStage is None else finalStage
revision = finalStage == N.reviewRevise
zFinalStage = finalStage and not revision
submitted = aStage == N.submitted
submittedRevised = aStage == N.submittedRevised
mayDecideExpert = (
submitted and not finalStage or submittedRevised and revision
)
if value == taskValue:
if not revision:
return False
if (
task
in {
N.expertReviewRevise,
N.expertReviewAccept,
N.expertReviewReject,
N.expertReviewRevoke,
}
- {xExpertStage}
):
return (
kind == N.expert and not zFinalStage and mayDecideExpert and answer
)
if (
task
in {
N.finalReviewRevise,
N.finalReviewAccept,
N.finalReviewReject,
N.finalReviewRevoke,
}
- {xFinalStage}
):
return (
kind == N.final
and not not expertStage
and (not aStageDate or expertStageDate > aStageDate)
and (
(
(not finalStage and submitted)
or (revision and submittedRevised)
)
or remaining
)
and answer
)
return False
return False
def stage(self, table, kind=None):
"""Find the workflow stage that a record is in.
!!! hint
See above for a description of the stages.
Parameters
----------
table: string
We must specify the kind of record for which we want to see the stage:
contrib, assessment, or review.
kind: string {`expert`, `final`}, optional `None`
Only if we want review attributes
Returns
-------
string {`selectYes`, `submittedRevised`, `reviewAccept`, ...}
See above for the complete list.
"""
return list(self.info(table, N.stage, kind=kind))[0]
def creators(self, table, kind=None):
"""Find the creators from a workflow related record.
Parameters
----------
table: string
We must specify the kind of record for which we want to see the creators:
contrib, assessment, or review.
kind: string {`expert`, `final`}, optional `None`
Only if we want review attributes
Returns
-------
(list of ObjectId)
"""
return list(self.info(table, N.creators, kind=kind))[0]
def status(self, table, kind=None):
"""Present all workflow info and controls relevant to the record.
Parameters
----------
table: string
We must specify the kind of record for which we want to see the status:
contrib, assessment, or review.
kind: string {`expert`, `final`}, optional `None`
Only if we want review attributes
Returns
-------
string(html)
"""
eid = list(self.info(table, N._id, kind=kind))[0]
itemKey = f"""{table}/{eid}"""
rButton = H.iconr(itemKey, "#workflow", msg=N.status)
return H.div(
[
rButton,
self.statusOverview(table, kind=kind),
self.tasks(table, kind=kind),
],
cls="workflow",
)
@staticmethod
def isTask(table, field):
"""Whether a field in a record is involved in a workflow task.
Fields that are involved in workflow tasks can not be read or edited
directly:
* they are represented as workflow status, not as a value
(see `control.workflow.apply.WorkflowItem.status`);
* they only change as a result of a workflow task
(see `control.workflow.apply.WorkflowItem.doTask`).
!!! hint
Workflow tasks are described above.
!!! caution
If a record is not a valid part of a workflow, then all its fields
are represented and actionable in the normal way.
Parameters
----------
table: string
The table in question.
field: string
The field in question.
Returns
-------
boolean
"""
taskFields = G(TASK_FIELDS, table, default=set())
return field in taskFields
def doTask(self, task, recordObj):
"""Execute a workflow task on a record.
The permission to execute the task will be checked first.
!!! hint
Workflow tasks are described above.
Parameters
----------
recordObj: object
The record must be passed as a record object.
Returns
-------
url | `None`
To navigate to after the action has been performed.
If the action has not been performed, `None` is returned.
"""
context = recordObj.context
table = recordObj.table
eid = recordObj.eid
kind = recordObj.kind
(contribId,) = self.info(N.contrib, N._id)
taskInfo = G(TASKS, task)
acro = G(taskInfo, N.acro)
urlExtra = E
executed = False
if self.permission(task, kind=kind):
operator = G(taskInfo, N.operator)
if operator == N.add:
dtable = G(taskInfo, N.detail)
tableObj = mkTable(context, dtable)
deid = tableObj.insert(masterTable=table, masterId=eid, force=True) or E
if deid:
urlExtra = f"""/{N.open}/{dtable}/{deid}"""
executed = True
elif operator == N.set:
field = G(taskInfo, N.field)
value = G(taskInfo, N.value)
if recordObj.field(field, mayEdit=True).save(value):
executed = True
if executed:
flash(f"""<{acro}> executed""", "message")
else:
flash(f"""<{acro}> failed""", "error")
else:
flash(f"""<{acro}> not permitted""", "error")
return f"""/{N.contrib}/{N.item}/{contribId}{urlExtra}""" if executed else None
def statusOverview(self, table, kind=None):
"""Present the current status of a record on the interface.
Parameters
----------
table: string
We must specify the kind of record for which we want to present the stage:
contrib, assessment, or review.
kind: string {`expert`, `final`}, optional `None`
Only if we want review attributes
Returns
-------
string(html)
"""
(stage, stageDate, locked, done, frozen, score, eid) = self.info(
table,
N.stage,
N.stageDate,
N.locked,
N.done,
N.frozen,
N.score,
N._id,
kind=kind,
)
stageInfo = G(STAGE_ATTS, stage)
statusCls = G(stageInfo, N.cls)
stageOn = (
H.span(f""" on {datetime.toDisplay(stageDate)}""", cls="date")
if stageDate
else E
)
statusMsg = H.span(
[G(stageInfo, N.msg) or E, stageOn], cls=f"large status {statusCls}"
)
lockedCls = N.locked if locked else E
lockedMsg = (
H.span(G(STATUS_REP, N.locked), cls=f"large status {lockedCls}")
if locked
else E
)
doneCls = N.done if done else E
doneMsg = (
H.span(G(STATUS_REP, N.done), cls=f"large status {doneCls}") if done else E
)
frozenCls = N.frozen if frozen else E
frozenMsg = (
H.span(G(STATUS_REP, N.frozen), cls="large status info") if frozen else E
)
statusRep = f"<!-- stage:{stage} -->" + H.div(
[statusMsg, lockedMsg, doneMsg, frozenMsg], cls=frozenCls
)
scorePart = E
if table == N.assessment:
scoreParts = presentScore(score, eid)
scorePart = (
H.span(scoreParts)
if table == N.assessment
else (scoreParts[0] if scoreParts else E)
if table == N.contrib
else E
)
return H.div([statusRep, scorePart], cls="workflow-line")
def tasks(self, table, kind=None):
"""Present the currently available tasks as buttons on the interface.
!!! hint "easy comments"
We also include a comment `<!-- task~!taskName:eid -->
for the ease of testing.
Parameters
----------
table: string
We must specify the table for which we want to present the
tasks: contrib, assessment, or review.
kind: string {`expert`, `final`}, optional `None`
Only if we want review attributes
Returns
-------
string(html)
"""
uid = self.uid
if not uid or table not in USER_TABLES:
return E
eid = list(self.info(table, N._id, kind=kind))[0]
taskParts = []
allowedTasks = sorted(
(task, taskInfo)
for (task, taskInfo) in TASKS.items()
if G(taskInfo, N.table) == table
)
justNow = now()
for (task, taskInfo) in allowedTasks:
permitted = self.permission(task, kind=kind)
if not permitted:
continue
remaining = type(permitted) is timedelta and permitted
remark = type(permitted) is str and permitted
taskExtra = E
if remaining:
remainingRep = datetime.toDisplay(justNow + remaining)
taskExtra = H.span(f""" before {remainingRep}""", cls="datex")
elif remark:
taskExtra = H.span(f""" {remark}""", cls="datex")
taskMsg = G(taskInfo, N.msg)
taskCls = G(taskInfo, N.cls)
taskPart = (
H.a(
[taskMsg, taskExtra],
f"""/api/task/{task}/{eid}""",
cls=f"large task {taskCls}",
)
+ f"""<!-- task!{task}:{eid} -->"""
)
taskParts.append(taskPart)
return H.join(taskParts)
def getWf(self, table, kind=None):
"""Select a source of attributes within a workflow item.
Parameters
----------
table: string
We must specify the kind of record for which we want the attributes:
contrib, assessment, or review.
kind: string {`expert`, `final`}, optional `None`
Only if we want review attributes
Returns
-------
dict
"""
data = self.data
if table == N.contrib:
return data
data = G(data, N.assessment)
if table in {N.assessment, N.criteriaEntry}:
return data
if table in {N.review, N.reviewEntry}:
data = G(G(data, N.reviews), kind)
return data
return None
def myReviewerKind(self, reviewer=None):
"""Determine whether the current user is `expert` or `final`.
Parameters
----------
reviewer: dict, optional `None`
If absent, the assessment in the workflow info will be inspected
to get a dict of its reviewers by kind.
Otherwise, it should be a dict of user ids keyed by `expert` and
`final`.
Returns
-------
string {`expert`, `final`} | `None`
Depending on whether the current user is such a reviewer of the
assessment of this contribution. Or `None` if (s)he is not a reviewer
at all.
"""
uid = self.uid
if reviewer is None:
reviewer = G(self.getWf(N.assessment), N.reviewer)
return (
N.expert
if G(reviewer, N.expert) == uid
else N.final
if G(reviewer, N.final) == uid
else None
)
Functions
def execute(context, task, eid)
-
Executes a workflow task.
First a table object is constructed, based on the
table
property of the task, usingcontext
.Then a record object is constructed in that table, based on the
eid
parameter.If that all succeeds, all information is at hand to verify permissions and perform the task.
Parameters
context
:object
- A
Context
singleton task
:string
- The name of the task
eid
:string(objectId)
- The id of the relevant record
Expand source code
def execute(context, task, eid): """Executes a workflow task. First a table object is constructed, based on the `table` property of the task, using `context`. Then a record object is constructed in that table, based on the `eid` parameter. If that all succeeds, all information is at hand to verify permissions and perform the task. Parameters ---------- context: object A `control.context.Context` singleton task: string The name of the task eid: string(objectId) The id of the relevant record """ taskInfo = G(TASKS, task) acro = G(taskInfo, N.acro) table = G(taskInfo, N.table) if table not in ALL_TABLES: flash(f"""Workflow {acro} operates on wrong table: "{table or E}""", "error") return (False, None) return mkTable(context, table).record(eid=eid).task(task)
Classes
class WorkflowItem (context, data)
-
Supports the application of workflow information.
A WorkflowItem singleton has a bunch of workflow attributes as dict in its attribute
data
and offers methods to- address selected pieces of that information;
- compute permissions for workflow actions and database actions;
- determine the workflow stage the contribution is in.
Attributes
data
:dict
- All workflow attributes.
myKind
:string
- The kind of reviewer the current user is, if any.
Initialization
Wraps a workflow item record around a workflow data record.
Workflow item records are created per contribution, but they will be referenced by contribution, assessment and review records in their attribute
wfitem
.Workflow items also store details of the current user, which will be needed for the computation of permissions.
Note
The user attributes
uid
andeppn
will be stored in thisWorkflowItem
object. At this point, it is also possible to what kind of reviewer the current user is, if any, and store that in attributemyKind
.Parameters
Expand source code
class WorkflowItem: """Supports the application of workflow information. A WorkflowItem singleton has a bunch of workflow attributes as dict in its attribute `data` and offers methods to * address selected pieces of that information; * compute permissions for workflow actions and database actions; * determine the workflow stage the contribution is in. Attributes ---------- data: dict All workflow attributes. myKind: string The kind of reviewer the current user is, if any. """ def __init__(self, context, data): """## Initialization Wraps a workflow item record around a workflow data record. Workflow item records are created per contribution, but they will be referenced by contribution, assessment and review records in their attribute `wfitem`. Workflow items also store details of the current user, which will be needed for the computation of permissions. !!! note The user attributes `uid` and `eppn` will be stored in this `WorkflowItem` object. At this point, it is also possible to what kind of reviewer the current user is, if any, and store that in attribute `myKind`. Parameters ---------- context: object The `control.context.Context singleton`, from which the `control.auth.Auth` singleton can be picked up, from which the details of the current user can be read off. data: dict See below. """ db = context.db auth = context.auth user = auth.user self.db = db """*object* The `control.db.Db` singleton Provides methods to deal with values from the table `decision`. """ self.auth = auth """*object* The `control.auth.Auth` singleton Provides methods to access the attributes of the current user. """ self.uid = G(user, N._id) """*ObjectId* The id of the current user. """ self.eppn = G(user, N.eppn) """*ObjectId* The eppn of the current user. !!! hint The eppn is the user identifying attribute from the identity provider. """ self.isSuperuser = auth.superuser() """*boolean* Whether the current user is a superuser. See `control.auth.Auth.superuser`. """ self.data = data """*dict* The workflow attributes. """ self.myKind = self.myReviewerKind() """*dict* The kind of reviewer that the current user is. A user is `expert` reviewer or `final` reviewer, or `None`. """ def getKind(self, table, record): """Determine whether a review(Entry) is `expert` or `final`. !!! warning The value `None` (not a string!) is returned for reviews that are no (longer) part of the workflow. They could be reviews with a type that does not match the type of the contribution, or reviews that have been superseded by newer reviews. Parameters ---------- table: string Either `review` or `reviewEntry`. record: dict Either a `review` record or a `reviewEntry` record. Returns ------- string {`expert`, `final`} Or `None`. """ if table in {N.review, N.reviewEntry}: eid = G(record, N._id) if table == N.review else G(record, N.review) data = self.getWf(N.assessment) reviews = G(data, N.reviews, default={}) kind = ( N.expert if G(G(reviews, N.expert), N._id) == eid else N.final if G(G(reviews, N.final), N._id) == eid else None ) else: kind = None return kind def isValid(self, table, eid, record): """Is a record a valid part of the workflow? Valid parts are contributions, assessment and review detail records of contributions satisfying: * they have the same type as their master contribution * they are not superseded by other assessments or reviews with the correct type Parameters ---------- table: string {`review`, `assessment`, `criteriaEntry`, `reviewEntry`}. eid: ObjectId (Entity) id of the record to be validated. record: dict The full record to be validated. Only needed for `reviewEntry` and `criteriaEntry` in order to look up the master `review` or `assessment` record. Returns ------- boolean """ if eid is None: return False refId = ( G(record, N.assessment) if table == N.criteriaEntry else G(record, N.review) if table == N.reviewEntry else eid ) if refId is None: return False if table in {N.contrib, N.assessment, N.criteriaEntry}: data = self.getWf(table) return refId == G(data, N._id) elif table in {N.review, N.reviewEntry}: data = self.getWf(N.assessment) reviews = G(data, N.reviews, default={}) return refId in { G(reviewInfo, N._id) for (kind, reviewInfo) in reviews.items() } def info(self, table, *atts, kind=None): """Retrieve selected attributes of the workflow A workflow record contains attributes at the outermost level, but also within its enclosed assessment workflow record and the enclosed review workflow records. Parameters ---------- table: string In order to read attributes, we must specify the source of those attributes: `contrib` (outermost), `assessment` or `review`. *atts: iterable The workflow attribute names to fetch. kind: string {`expert`, `final`}, optional `None` Only if we want review attributes Returns ------- generator Yields attribute values, corresponding to `*atts`. """ thisData = self.getWf(table, kind=kind) return (G(thisData, att) for att in atts) def checkReadable(self, recordObj): """Whether a record is readable because of workflow. When a contribution, assessment, review is in a certain stage in the workflow, its record may be closed to others than the owner, and after finalization, some fields may be open to authenticated users or the public. This method determines the record is readable by the current user. If the record is not part of the workflow, `None` is returned, and the normal permission rules apply. !!! note It also depends on the current user. Power users will not be prevented to read records because of workflow conditions. Here are the rules: #### Assessment, Criteria Entry Not submitted and not in revision: : authors and editors only Submitted, review not yet complete, or negative outcome : authors, editors, reviewers, national coordinator only Review with positive outcome : public In revision, or review with a negative outcome : authors, editors, reviewers, national coordinator only #### Review, Review Entry Review has no decision and there is no final decision : authors, editors, the other reviewer Review in question has a decision, but still no final positive decision : authors/editors, other reviewer, authors/editors of the assessment, national coordinator There is a positive final decision : public !!! caution "The influence of selection is nihil" Whether a contribution is selected or not has no influence on the readability of the assessment and review. !!! caution "The influence on the contribution records is nihil" Whether a contribution is readable does not depend on the workflow, only on the normal rules. Parameters ---------- recordObj: object The record in question (from which the table and the kind maybe inferred. It should be the record that contains this WorkflowItem object as its `wfitem` attribute. field: string, optional `None` If None, we check for the readability of the record as a whole. Otherwise, we check for the readability of this field in the record. Returns ------- boolean | `None` """ isSuperuser = self.isSuperuser if isSuperuser: return None table = recordObj.table if table not in SENSITIVE_TABLES: return None kind = recordObj.kind perm = recordObj.perm uid = self.uid (stage,) = self.info(table, N.stage, kind=kind) if table in {N.assessment, N.criteriaEntry}: (r2Stage,) = self.info(N.review, N.stage, kind=N.final) return ( True if r2Stage == N.reviewAccept else perm[N.isOur] if stage in { N.submitted, N.incompleteRevised, N.completeRevised, N.submittedRevised, } else perm[N.isEdit] ) if table in {N.review, N.reviewEntry}: (creators,) = self.info(N.assessment, N.creators) (r2Stage,) = self.info(N.review, N.stage, kind=N.final) result = ( True if r2Stage == N.reviewAccept else uid in creators or perm[N.isOur] if stage in { N.reviewAdviseRevise, N.reviewAdviseAccept, N.reviewAdviseReject, N.reviewRevise, N.reviewReject, } or r2Stage in {N.reviewRevise, N.reviewReject} else perm[N.isReviewer] or perm[N.isEdit] ) return result return None def checkFixed(self, recordObj, field=None): """Whether a record or field is fixed because of workflow. When a contribution, assessment, review is in a certain stage in the workflow, its record or some fields in its record may be fixated, either temporarily or permanently. This method checks whether a record or field is currently fixed, i.e. whether editing is possible. !!! note It might also depend on the current user. !!! caution Here is a case where the sysadmin and the root are less powerful than the office users: only the office users can assign reviewers, i.e. only they can update `reviewerE` and `reviewerF` inn assessment fields. Parameters ---------- recordObj: object The record in question (from which the table and the kind maybe inferred. It should be the record that contains this WorkflowItem object as its `wfitem` attribute. field: string, optional `None` If None, we check for the fixity of the record as a whole. Otherwise, we check for the fixity of this field in the record. Returns ------- boolean """ auth = self.auth table = recordObj.table kind = recordObj.kind (frozen, done, locked) = self.info(table, N.frozen, N.done, N.locked, kind=kind) if field is None: return frozen or done or locked if frozen or done: return True if not locked: return False isOffice = auth.officeuser() if isOffice and table == N.assessment: return field not in {N.reviewerE, N.reviewerF} return True def permission(self, task, kind=None): """Checks whether a workflow task is permitted. Note that the tasks are listed per kind of record they apply to: contrib, assessment, review. They are typically triggered by big workflow buttons on the interface. When the request to execute such a task reaches the server, it will check whether the current user is allowed to execute this task on the records in question. !!! hint See above for explanation of the properties of the tasks. !!! note If you try to run a task on a kind of record that it is not designed for, it will be detected and no permission will be given. !!! note Some tasks are designed to set a field to a value. If that field already has that value, the task will not be permitted. This already rules out a lot of things and relieves the burden of prohibiting non-sensical tasks. It may be that the task is only permitted for some limited time from now on. Then a timedelta object with the amount of time left is returned. More precisely, the workflow configuration table (yaml/workflow.yaml) my specify a set of delays for a set of user roles. * `all` specifies the default for users whose role has not got a corresponding delay * `coord` is national coordinator of the relevant country * `office` is any office user * `super` is any super user, i.e. `system` or `root` The value specified for each of these roles is either an integer, which is the amount of hours of the delay. Or it is `false` (no delay) or `true` (infinite delay). Parameters ---------- table: string In order to check permissions, we must specify the kind of record that the task acts on: contrib, assessment, or review. task: string An string consisting of the name of a task. kind: string {`expert`, `final`}, optional `None` Only if we want review attributes Returns ------- boolean | timedelta | string """ db = self.db auth = self.auth uid = self.uid if task not in TASKS: return False taskInfo = TASKS[task] table = G(taskInfo, N.table) if uid is None or table not in USER_TABLES: return False taskField = ( N.selected if table == N.contrib else N.submitted if table == N.assessment else N.decision if table == N.review else None ) myKind = self.myKind ( locked, done, frozen, mayAdd, stage, stageDate, creators, countryId, taskValue, ) = self.info( table, N.locked, N.done, N.frozen, N.mayAdd, N.stage, N.stageDate, N.creators, N.country, taskField, kind=kind, ) operator = G(taskInfo, N.operator) value = G(taskInfo, N.value) if operator == N.set: if taskField == N.decision: value = G(db.decisionInv, value) (contribId,) = self.info(N.contrib, N._id) isOwn = creators and uid in creators isCoord = countryId and auth.coordinator(countryId=countryId) isSuper = auth.superuser() isOffice = auth.officeuser() isSysadmin = auth.sysadmin() decisionDelay = G(taskInfo, N.delay, False) if decisionDelay: if type(decisionDelay) is int: decisionDelay = timedelta(hours=decisionDelay) elif type(decisionDelay) is dict: defaultDecisionDelay = G(decisionDelay, N.all, False) decisionDelay = ( G(decisionDelay, N.coord, defaultDecisionDelay) if isCoord else G(decisionDelay, N.sysadmin, defaultDecisionDelay) if isSysadmin else G(decisionDelay, N.office, defaultDecisionDelay) if isOffice else defaultDecisionDelay ) if type(decisionDelay) is int: decisionDelay = timedelta(hours=decisionDelay) elif type(decisionDelay) is not bool: decisionDelay = False justNow = now() remaining = False if decisionDelay and stageDate: if type(decisionDelay) is bool: remaining = True else: remaining = stageDate + decisionDelay - justNow if remaining <= timedelta(hours=0): remaining = False forbidden = frozen or done if forbidden: if ( task == N.unselectContrib and table == N.contrib ): if remaining is True: return "as intervention" if remaining: return remaining if not remaining: return False if table == N.contrib: if not isOwn and not isCoord and not isSuper: return False if task == N.startAssessment: return not forbidden and isOwn and mayAdd if value == taskValue: return False if not isCoord: return False answer = not frozen or remaining if task == N.selectContrib: return stage != N.selectYes and answer if task == N.deselectContrib: return stage != N.selectNo and answer if task == N.unselectContrib: return stage != N.selectNone and answer return False if table == N.assessment: forbidden = frozen or done if forbidden: return False if task == N.startReview: return not forbidden and G(mayAdd, myKind) if value == taskValue: return False if uid not in creators: return False answer = not locked or remaining if not answer: return False if task == N.submitAssessment: return stage == N.complete and answer if task == N.resubmitAssessment: return stage == N.completeWithdrawn and answer if task == N.submitRevised: return stage == N.completeRevised and answer if task == N.withdrawAssessment: return ( stage in {N.submitted, N.submittedRevised} and stage not in {N.incompleteWithdrawn, N.completeWithdrawn} and answer ) return False if table == N.review: if frozen: return False if done and not remaining: return False taskKind = G(taskInfo, N.kind) if not kind or kind != taskKind or kind != myKind: return False answer = remaining or not done or remaining if not answer: return False (aStage, aStageDate) = self.info(N.assessment, N.stage, N.stageDate) (finalStage,) = self.info(table, N.stage, kind=N.final) (expertStage, expertStageDate) = self.info( table, N.stage, N.stageDate, kind=N.expert ) xExpertStage = N.expertReviewRevoke if expertStage is None else expertStage xFinalStage = N.finalReviewRevoke if finalStage is None else finalStage revision = finalStage == N.reviewRevise zFinalStage = finalStage and not revision submitted = aStage == N.submitted submittedRevised = aStage == N.submittedRevised mayDecideExpert = ( submitted and not finalStage or submittedRevised and revision ) if value == taskValue: if not revision: return False if ( task in { N.expertReviewRevise, N.expertReviewAccept, N.expertReviewReject, N.expertReviewRevoke, } - {xExpertStage} ): return ( kind == N.expert and not zFinalStage and mayDecideExpert and answer ) if ( task in { N.finalReviewRevise, N.finalReviewAccept, N.finalReviewReject, N.finalReviewRevoke, } - {xFinalStage} ): return ( kind == N.final and not not expertStage and (not aStageDate or expertStageDate > aStageDate) and ( ( (not finalStage and submitted) or (revision and submittedRevised) ) or remaining ) and answer ) return False return False def stage(self, table, kind=None): """Find the workflow stage that a record is in. !!! hint See above for a description of the stages. Parameters ---------- table: string We must specify the kind of record for which we want to see the stage: contrib, assessment, or review. kind: string {`expert`, `final`}, optional `None` Only if we want review attributes Returns ------- string {`selectYes`, `submittedRevised`, `reviewAccept`, ...} See above for the complete list. """ return list(self.info(table, N.stage, kind=kind))[0] def creators(self, table, kind=None): """Find the creators from a workflow related record. Parameters ---------- table: string We must specify the kind of record for which we want to see the creators: contrib, assessment, or review. kind: string {`expert`, `final`}, optional `None` Only if we want review attributes Returns ------- (list of ObjectId) """ return list(self.info(table, N.creators, kind=kind))[0] def status(self, table, kind=None): """Present all workflow info and controls relevant to the record. Parameters ---------- table: string We must specify the kind of record for which we want to see the status: contrib, assessment, or review. kind: string {`expert`, `final`}, optional `None` Only if we want review attributes Returns ------- string(html) """ eid = list(self.info(table, N._id, kind=kind))[0] itemKey = f"""{table}/{eid}""" rButton = H.iconr(itemKey, "#workflow", msg=N.status) return H.div( [ rButton, self.statusOverview(table, kind=kind), self.tasks(table, kind=kind), ], cls="workflow", ) @staticmethod def isTask(table, field): """Whether a field in a record is involved in a workflow task. Fields that are involved in workflow tasks can not be read or edited directly: * they are represented as workflow status, not as a value (see `control.workflow.apply.WorkflowItem.status`); * they only change as a result of a workflow task (see `control.workflow.apply.WorkflowItem.doTask`). !!! hint Workflow tasks are described above. !!! caution If a record is not a valid part of a workflow, then all its fields are represented and actionable in the normal way. Parameters ---------- table: string The table in question. field: string The field in question. Returns ------- boolean """ taskFields = G(TASK_FIELDS, table, default=set()) return field in taskFields def doTask(self, task, recordObj): """Execute a workflow task on a record. The permission to execute the task will be checked first. !!! hint Workflow tasks are described above. Parameters ---------- recordObj: object The record must be passed as a record object. Returns ------- url | `None` To navigate to after the action has been performed. If the action has not been performed, `None` is returned. """ context = recordObj.context table = recordObj.table eid = recordObj.eid kind = recordObj.kind (contribId,) = self.info(N.contrib, N._id) taskInfo = G(TASKS, task) acro = G(taskInfo, N.acro) urlExtra = E executed = False if self.permission(task, kind=kind): operator = G(taskInfo, N.operator) if operator == N.add: dtable = G(taskInfo, N.detail) tableObj = mkTable(context, dtable) deid = tableObj.insert(masterTable=table, masterId=eid, force=True) or E if deid: urlExtra = f"""/{N.open}/{dtable}/{deid}""" executed = True elif operator == N.set: field = G(taskInfo, N.field) value = G(taskInfo, N.value) if recordObj.field(field, mayEdit=True).save(value): executed = True if executed: flash(f"""<{acro}> executed""", "message") else: flash(f"""<{acro}> failed""", "error") else: flash(f"""<{acro}> not permitted""", "error") return f"""/{N.contrib}/{N.item}/{contribId}{urlExtra}""" if executed else None def statusOverview(self, table, kind=None): """Present the current status of a record on the interface. Parameters ---------- table: string We must specify the kind of record for which we want to present the stage: contrib, assessment, or review. kind: string {`expert`, `final`}, optional `None` Only if we want review attributes Returns ------- string(html) """ (stage, stageDate, locked, done, frozen, score, eid) = self.info( table, N.stage, N.stageDate, N.locked, N.done, N.frozen, N.score, N._id, kind=kind, ) stageInfo = G(STAGE_ATTS, stage) statusCls = G(stageInfo, N.cls) stageOn = ( H.span(f""" on {datetime.toDisplay(stageDate)}""", cls="date") if stageDate else E ) statusMsg = H.span( [G(stageInfo, N.msg) or E, stageOn], cls=f"large status {statusCls}" ) lockedCls = N.locked if locked else E lockedMsg = ( H.span(G(STATUS_REP, N.locked), cls=f"large status {lockedCls}") if locked else E ) doneCls = N.done if done else E doneMsg = ( H.span(G(STATUS_REP, N.done), cls=f"large status {doneCls}") if done else E ) frozenCls = N.frozen if frozen else E frozenMsg = ( H.span(G(STATUS_REP, N.frozen), cls="large status info") if frozen else E ) statusRep = f"<!-- stage:{stage} -->" + H.div( [statusMsg, lockedMsg, doneMsg, frozenMsg], cls=frozenCls ) scorePart = E if table == N.assessment: scoreParts = presentScore(score, eid) scorePart = ( H.span(scoreParts) if table == N.assessment else (scoreParts[0] if scoreParts else E) if table == N.contrib else E ) return H.div([statusRep, scorePart], cls="workflow-line") def tasks(self, table, kind=None): """Present the currently available tasks as buttons on the interface. !!! hint "easy comments" We also include a comment `<!-- task~!taskName:eid --> for the ease of testing. Parameters ---------- table: string We must specify the table for which we want to present the tasks: contrib, assessment, or review. kind: string {`expert`, `final`}, optional `None` Only if we want review attributes Returns ------- string(html) """ uid = self.uid if not uid or table not in USER_TABLES: return E eid = list(self.info(table, N._id, kind=kind))[0] taskParts = [] allowedTasks = sorted( (task, taskInfo) for (task, taskInfo) in TASKS.items() if G(taskInfo, N.table) == table ) justNow = now() for (task, taskInfo) in allowedTasks: permitted = self.permission(task, kind=kind) if not permitted: continue remaining = type(permitted) is timedelta and permitted remark = type(permitted) is str and permitted taskExtra = E if remaining: remainingRep = datetime.toDisplay(justNow + remaining) taskExtra = H.span(f""" before {remainingRep}""", cls="datex") elif remark: taskExtra = H.span(f""" {remark}""", cls="datex") taskMsg = G(taskInfo, N.msg) taskCls = G(taskInfo, N.cls) taskPart = ( H.a( [taskMsg, taskExtra], f"""/api/task/{task}/{eid}""", cls=f"large task {taskCls}", ) + f"""<!-- task!{task}:{eid} -->""" ) taskParts.append(taskPart) return H.join(taskParts) def getWf(self, table, kind=None): """Select a source of attributes within a workflow item. Parameters ---------- table: string We must specify the kind of record for which we want the attributes: contrib, assessment, or review. kind: string {`expert`, `final`}, optional `None` Only if we want review attributes Returns ------- dict """ data = self.data if table == N.contrib: return data data = G(data, N.assessment) if table in {N.assessment, N.criteriaEntry}: return data if table in {N.review, N.reviewEntry}: data = G(G(data, N.reviews), kind) return data return None def myReviewerKind(self, reviewer=None): """Determine whether the current user is `expert` or `final`. Parameters ---------- reviewer: dict, optional `None` If absent, the assessment in the workflow info will be inspected to get a dict of its reviewers by kind. Otherwise, it should be a dict of user ids keyed by `expert` and `final`. Returns ------- string {`expert`, `final`} | `None` Depending on whether the current user is such a reviewer of the assessment of this contribution. Or `None` if (s)he is not a reviewer at all. """ uid = self.uid if reviewer is None: reviewer = G(self.getWf(N.assessment), N.reviewer) return ( N.expert if G(reviewer, N.expert) == uid else N.final if G(reviewer, N.final) == uid else None )
Static methods
def isTask(table, field)
-
Whether a field in a record is involved in a workflow task.
Fields that are involved in workflow tasks can not be read or edited directly:
- they are represented as workflow status, not as a value
(see
WorkflowItem.status()
); - they only change as a result of a
workflow task
(see
WorkflowItem.doTask()
).
Hint
Workflow tasks are described above.
Caution
If a record is not a valid part of a workflow, then all its fields are represented and actionable in the normal way.
Parameters
table
:string
- The table in question.
field
:string
- The field in question.
Returns
boolean
Expand source code
@staticmethod def isTask(table, field): """Whether a field in a record is involved in a workflow task. Fields that are involved in workflow tasks can not be read or edited directly: * they are represented as workflow status, not as a value (see `control.workflow.apply.WorkflowItem.status`); * they only change as a result of a workflow task (see `control.workflow.apply.WorkflowItem.doTask`). !!! hint Workflow tasks are described above. !!! caution If a record is not a valid part of a workflow, then all its fields are represented and actionable in the normal way. Parameters ---------- table: string The table in question. field: string The field in question. Returns ------- boolean """ taskFields = G(TASK_FIELDS, table, default=set()) return field in taskFields
- they are represented as workflow status, not as a value
(see
Instance variables
var auth
-
object The
Auth
singletonProvides methods to access the attributes of the current user.
var data
-
dict The workflow attributes.
var db
-
object The
Db
singletonProvides methods to deal with values from the table
decision
. var eppn
-
ObjectId The eppn of the current user.
Hint
The eppn is the user identifying attribute from the identity provider.
var isSuperuser
-
boolean Whether the current user is a superuser.
See
Auth.superuser()
. var myKind
-
dict The kind of reviewer that the current user is.
A user is
expert
reviewer orfinal
reviewer, orNone
. var uid
-
ObjectId The id of the current user.
Methods
def checkFixed(self, recordObj, field=None)
-
Whether a record or field is fixed because of workflow.
When a contribution, assessment, review is in a certain stage in the workflow, its record or some fields in its record may be fixated, either temporarily or permanently.
This method checks whether a record or field is currently fixed, i.e. whether editing is possible.
Note
It might also depend on the current user.
Caution
Here is a case where the sysadmin and the root are less powerful than the office users: only the office users can assign reviewers, i.e. only they can update
reviewerE
andreviewerF
inn assessment fields.Parameters
recordObj
:object
- The record in question (from which the table and the kind
maybe inferred. It should be the record that contains this
WorkflowItem object as its
wfitem
attribute. field
:string
, optionalNone
- If None, we check for the fixity of the record as a whole. Otherwise, we check for the fixity of this field in the record.
Returns
boolean
Expand source code
def checkFixed(self, recordObj, field=None): """Whether a record or field is fixed because of workflow. When a contribution, assessment, review is in a certain stage in the workflow, its record or some fields in its record may be fixated, either temporarily or permanently. This method checks whether a record or field is currently fixed, i.e. whether editing is possible. !!! note It might also depend on the current user. !!! caution Here is a case where the sysadmin and the root are less powerful than the office users: only the office users can assign reviewers, i.e. only they can update `reviewerE` and `reviewerF` inn assessment fields. Parameters ---------- recordObj: object The record in question (from which the table and the kind maybe inferred. It should be the record that contains this WorkflowItem object as its `wfitem` attribute. field: string, optional `None` If None, we check for the fixity of the record as a whole. Otherwise, we check for the fixity of this field in the record. Returns ------- boolean """ auth = self.auth table = recordObj.table kind = recordObj.kind (frozen, done, locked) = self.info(table, N.frozen, N.done, N.locked, kind=kind) if field is None: return frozen or done or locked if frozen or done: return True if not locked: return False isOffice = auth.officeuser() if isOffice and table == N.assessment: return field not in {N.reviewerE, N.reviewerF} return True
def checkReadable(self, recordObj)
-
Whether a record is readable because of workflow.
When a contribution, assessment, review is in a certain stage in the workflow, its record may be closed to others than the owner, and after finalization, some fields may be open to authenticated users or the public.
This method determines the record is readable by the current user.
If the record is not part of the workflow,
None
is returned, and the normal permission rules apply.Note
It also depends on the current user. Power users will not be prevented to read records because of workflow conditions.
Here are the rules:
Assessment, Criteria Entry
- Not submitted and not in revision:
- authors and editors only
- Submitted, review not yet complete, or negative outcome
- authors, editors, reviewers, national coordinator only
- Review with positive outcome
- public
- In revision, or review with a negative outcome
- authors, editors, reviewers, national coordinator only
Review, Review Entry
- Review has no decision and there is no final decision
- authors, editors, the other reviewer
- Review in question has a decision, but still no final positive decision
- authors/editors, other reviewer, authors/editors of the assessment, national coordinator
- There is a positive final decision
- public
The influence of selection is nihil
Whether a contribution is selected or not has no influence on the readability of the assessment and review.
The influence on the contribution records is nihil
Whether a contribution is readable does not depend on the workflow, only on the normal rules.
Parameters
recordObj
:object
- The record in question (from which the table and the kind
maybe inferred. It should be the record that contains this
WorkflowItem object as its
wfitem
attribute. field
:string
, optionalNone
- If None, we check for the readability of the record as a whole. Otherwise, we check for the readability of this field in the record.
Returns
boolean |
None
Expand source code
def checkReadable(self, recordObj): """Whether a record is readable because of workflow. When a contribution, assessment, review is in a certain stage in the workflow, its record may be closed to others than the owner, and after finalization, some fields may be open to authenticated users or the public. This method determines the record is readable by the current user. If the record is not part of the workflow, `None` is returned, and the normal permission rules apply. !!! note It also depends on the current user. Power users will not be prevented to read records because of workflow conditions. Here are the rules: #### Assessment, Criteria Entry Not submitted and not in revision: : authors and editors only Submitted, review not yet complete, or negative outcome : authors, editors, reviewers, national coordinator only Review with positive outcome : public In revision, or review with a negative outcome : authors, editors, reviewers, national coordinator only #### Review, Review Entry Review has no decision and there is no final decision : authors, editors, the other reviewer Review in question has a decision, but still no final positive decision : authors/editors, other reviewer, authors/editors of the assessment, national coordinator There is a positive final decision : public !!! caution "The influence of selection is nihil" Whether a contribution is selected or not has no influence on the readability of the assessment and review. !!! caution "The influence on the contribution records is nihil" Whether a contribution is readable does not depend on the workflow, only on the normal rules. Parameters ---------- recordObj: object The record in question (from which the table and the kind maybe inferred. It should be the record that contains this WorkflowItem object as its `wfitem` attribute. field: string, optional `None` If None, we check for the readability of the record as a whole. Otherwise, we check for the readability of this field in the record. Returns ------- boolean | `None` """ isSuperuser = self.isSuperuser if isSuperuser: return None table = recordObj.table if table not in SENSITIVE_TABLES: return None kind = recordObj.kind perm = recordObj.perm uid = self.uid (stage,) = self.info(table, N.stage, kind=kind) if table in {N.assessment, N.criteriaEntry}: (r2Stage,) = self.info(N.review, N.stage, kind=N.final) return ( True if r2Stage == N.reviewAccept else perm[N.isOur] if stage in { N.submitted, N.incompleteRevised, N.completeRevised, N.submittedRevised, } else perm[N.isEdit] ) if table in {N.review, N.reviewEntry}: (creators,) = self.info(N.assessment, N.creators) (r2Stage,) = self.info(N.review, N.stage, kind=N.final) result = ( True if r2Stage == N.reviewAccept else uid in creators or perm[N.isOur] if stage in { N.reviewAdviseRevise, N.reviewAdviseAccept, N.reviewAdviseReject, N.reviewRevise, N.reviewReject, } or r2Stage in {N.reviewRevise, N.reviewReject} else perm[N.isReviewer] or perm[N.isEdit] ) return result return None
def creators(self, table, kind=None)
-
Find the creators from a workflow related record.
Parameters
table
:string
- We must specify the kind of record for which we want to see the creators: contrib, assessment, or review.
kind
:string {
expert,
final}
, optionalNone
- Only if we want review attributes
Returns
(list of ObjectId)
Expand source code
def creators(self, table, kind=None): """Find the creators from a workflow related record. Parameters ---------- table: string We must specify the kind of record for which we want to see the creators: contrib, assessment, or review. kind: string {`expert`, `final`}, optional `None` Only if we want review attributes Returns ------- (list of ObjectId) """ return list(self.info(table, N.creators, kind=kind))[0]
def doTask(self, task, recordObj)
-
Execute a workflow task on a record.
The permission to execute the task will be checked first.
Hint
Workflow tasks are described above.
Parameters
recordObj
:object
- The record must be passed as a record object.
Returns
url |
None
To navigate to after the action has been performed. If the action has not been performed,None
is returned.Expand source code
def doTask(self, task, recordObj): """Execute a workflow task on a record. The permission to execute the task will be checked first. !!! hint Workflow tasks are described above. Parameters ---------- recordObj: object The record must be passed as a record object. Returns ------- url | `None` To navigate to after the action has been performed. If the action has not been performed, `None` is returned. """ context = recordObj.context table = recordObj.table eid = recordObj.eid kind = recordObj.kind (contribId,) = self.info(N.contrib, N._id) taskInfo = G(TASKS, task) acro = G(taskInfo, N.acro) urlExtra = E executed = False if self.permission(task, kind=kind): operator = G(taskInfo, N.operator) if operator == N.add: dtable = G(taskInfo, N.detail) tableObj = mkTable(context, dtable) deid = tableObj.insert(masterTable=table, masterId=eid, force=True) or E if deid: urlExtra = f"""/{N.open}/{dtable}/{deid}""" executed = True elif operator == N.set: field = G(taskInfo, N.field) value = G(taskInfo, N.value) if recordObj.field(field, mayEdit=True).save(value): executed = True if executed: flash(f"""<{acro}> executed""", "message") else: flash(f"""<{acro}> failed""", "error") else: flash(f"""<{acro}> not permitted""", "error") return f"""/{N.contrib}/{N.item}/{contribId}{urlExtra}""" if executed else None
def getKind(self, table, record)
-
Determine whether a review(Entry) is
expert
orfinal
.Warning
The value
None
(not a string!) is returned for reviews that are no (longer) part of the workflow. They could be reviews with a type that does not match the type of the contribution, or reviews that have been superseded by newer reviews.Parameters
table
:string
- Either
review
orreviewEntry
. record
:dict
- Either a
review
record or areviewEntry
record.
Returns
string {
expert
,final
} OrNone
.Expand source code
def getKind(self, table, record): """Determine whether a review(Entry) is `expert` or `final`. !!! warning The value `None` (not a string!) is returned for reviews that are no (longer) part of the workflow. They could be reviews with a type that does not match the type of the contribution, or reviews that have been superseded by newer reviews. Parameters ---------- table: string Either `review` or `reviewEntry`. record: dict Either a `review` record or a `reviewEntry` record. Returns ------- string {`expert`, `final`} Or `None`. """ if table in {N.review, N.reviewEntry}: eid = G(record, N._id) if table == N.review else G(record, N.review) data = self.getWf(N.assessment) reviews = G(data, N.reviews, default={}) kind = ( N.expert if G(G(reviews, N.expert), N._id) == eid else N.final if G(G(reviews, N.final), N._id) == eid else None ) else: kind = None return kind
def getWf(self, table, kind=None)
-
Select a source of attributes within a workflow item.
Parameters
table
:string
- We must specify the kind of record for which we want the attributes: contrib, assessment, or review.
kind
:string {
expert,
final}
, optionalNone
- Only if we want review attributes
Returns
dict
Expand source code
def getWf(self, table, kind=None): """Select a source of attributes within a workflow item. Parameters ---------- table: string We must specify the kind of record for which we want the attributes: contrib, assessment, or review. kind: string {`expert`, `final`}, optional `None` Only if we want review attributes Returns ------- dict """ data = self.data if table == N.contrib: return data data = G(data, N.assessment) if table in {N.assessment, N.criteriaEntry}: return data if table in {N.review, N.reviewEntry}: data = G(G(data, N.reviews), kind) return data return None
def info(self, table, *atts, kind=None)
-
Retrieve selected attributes of the workflow
A workflow record contains attributes at the outermost level, but also within its enclosed assessment workflow record and the enclosed review workflow records.
Parameters
table
:string
- In order to read attributes, we must specify the source of those
attributes:
contrib
(outermost),assessment
orreview
. *atts
:iterable
- The workflow attribute names to fetch.
kind
:string {
expert,
final}
, optionalNone
- Only if we want review attributes
Returns
generator
- Yields attribute values, corresponding to
*atts
.
Expand source code
def info(self, table, *atts, kind=None): """Retrieve selected attributes of the workflow A workflow record contains attributes at the outermost level, but also within its enclosed assessment workflow record and the enclosed review workflow records. Parameters ---------- table: string In order to read attributes, we must specify the source of those attributes: `contrib` (outermost), `assessment` or `review`. *atts: iterable The workflow attribute names to fetch. kind: string {`expert`, `final`}, optional `None` Only if we want review attributes Returns ------- generator Yields attribute values, corresponding to `*atts`. """ thisData = self.getWf(table, kind=kind) return (G(thisData, att) for att in atts)
def isValid(self, table, eid, record)
-
Is a record a valid part of the workflow?
Valid parts are contributions, assessment and review detail records of contributions satisfying:
- they have the same type as their master contribution
- they are not superseded by other assessments or reviews with the correct type
Parameters
- table: string {
review
,assessment
,criteriaEntry
,reviewEntry
}. eid
:ObjectId
- (Entity) id of the record to be validated.
record
:dict
- The full record to be validated.
Only needed for
reviewEntry
andcriteriaEntry
in order to look up the masterreview
orassessment
record.
Returns
boolean
Expand source code
def isValid(self, table, eid, record): """Is a record a valid part of the workflow? Valid parts are contributions, assessment and review detail records of contributions satisfying: * they have the same type as their master contribution * they are not superseded by other assessments or reviews with the correct type Parameters ---------- table: string {`review`, `assessment`, `criteriaEntry`, `reviewEntry`}. eid: ObjectId (Entity) id of the record to be validated. record: dict The full record to be validated. Only needed for `reviewEntry` and `criteriaEntry` in order to look up the master `review` or `assessment` record. Returns ------- boolean """ if eid is None: return False refId = ( G(record, N.assessment) if table == N.criteriaEntry else G(record, N.review) if table == N.reviewEntry else eid ) if refId is None: return False if table in {N.contrib, N.assessment, N.criteriaEntry}: data = self.getWf(table) return refId == G(data, N._id) elif table in {N.review, N.reviewEntry}: data = self.getWf(N.assessment) reviews = G(data, N.reviews, default={}) return refId in { G(reviewInfo, N._id) for (kind, reviewInfo) in reviews.items() }
def myReviewerKind(self, reviewer=None)
-
Determine whether the current user is
expert
orfinal
.Parameters
reviewer
:dict
, optionalNone
- If absent, the assessment in the workflow info will be inspected
to get a dict of its reviewers by kind.
Otherwise, it should be a dict of user ids keyed by
expert
andfinal
.
Returns
string {
expert
,final
} |None
Depending on whether the current user is such a reviewer of the assessment of this contribution. OrNone
if (s)he is not a reviewer at all.Expand source code
def myReviewerKind(self, reviewer=None): """Determine whether the current user is `expert` or `final`. Parameters ---------- reviewer: dict, optional `None` If absent, the assessment in the workflow info will be inspected to get a dict of its reviewers by kind. Otherwise, it should be a dict of user ids keyed by `expert` and `final`. Returns ------- string {`expert`, `final`} | `None` Depending on whether the current user is such a reviewer of the assessment of this contribution. Or `None` if (s)he is not a reviewer at all. """ uid = self.uid if reviewer is None: reviewer = G(self.getWf(N.assessment), N.reviewer) return ( N.expert if G(reviewer, N.expert) == uid else N.final if G(reviewer, N.final) == uid else None )
def permission(self, task, kind=None)
-
Checks whether a workflow task is permitted.
Note that the tasks are listed per kind of record they apply to: contrib, assessment, review. They are typically triggered by big workflow buttons on the interface.
When the request to execute such a task reaches the server, it will check whether the current user is allowed to execute this task on the records in question.
Hint
See above for explanation of the properties of the tasks.
Note
If you try to run a task on a kind of record that it is not designed for, it will be detected and no permission will be given.
Note
Some tasks are designed to set a field to a value. If that field already has that value, the task will not be permitted. This already rules out a lot of things and relieves the burden of prohibiting non-sensical tasks.
It may be that the task is only permitted for some limited time from now on. Then a timedelta object with the amount of time left is returned.
More precisely, the workflow configuration table (yaml/workflow.yaml) my specify a set of delays for a set of user roles.
all
specifies the default for users whose role has not got a corresponding delaycoord
is national coordinator of the relevant countryoffice
is any office usersuper
is any super user, i.e.system
orroot
The value specified for each of these roles is either an integer, which is the amount of hours of the delay. Or it is
false
(no delay) ortrue
(infinite delay).Parameters
table
:string
- In order to check permissions, we must specify the kind of record that the task acts on: contrib, assessment, or review.
task
:string
- An string consisting of the name of a task.
kind
:string {
expert,
final}
, optionalNone
- Only if we want review attributes
Returns
boolean | timedelta | string
Expand source code
def permission(self, task, kind=None): """Checks whether a workflow task is permitted. Note that the tasks are listed per kind of record they apply to: contrib, assessment, review. They are typically triggered by big workflow buttons on the interface. When the request to execute such a task reaches the server, it will check whether the current user is allowed to execute this task on the records in question. !!! hint See above for explanation of the properties of the tasks. !!! note If you try to run a task on a kind of record that it is not designed for, it will be detected and no permission will be given. !!! note Some tasks are designed to set a field to a value. If that field already has that value, the task will not be permitted. This already rules out a lot of things and relieves the burden of prohibiting non-sensical tasks. It may be that the task is only permitted for some limited time from now on. Then a timedelta object with the amount of time left is returned. More precisely, the workflow configuration table (yaml/workflow.yaml) my specify a set of delays for a set of user roles. * `all` specifies the default for users whose role has not got a corresponding delay * `coord` is national coordinator of the relevant country * `office` is any office user * `super` is any super user, i.e. `system` or `root` The value specified for each of these roles is either an integer, which is the amount of hours of the delay. Or it is `false` (no delay) or `true` (infinite delay). Parameters ---------- table: string In order to check permissions, we must specify the kind of record that the task acts on: contrib, assessment, or review. task: string An string consisting of the name of a task. kind: string {`expert`, `final`}, optional `None` Only if we want review attributes Returns ------- boolean | timedelta | string """ db = self.db auth = self.auth uid = self.uid if task not in TASKS: return False taskInfo = TASKS[task] table = G(taskInfo, N.table) if uid is None or table not in USER_TABLES: return False taskField = ( N.selected if table == N.contrib else N.submitted if table == N.assessment else N.decision if table == N.review else None ) myKind = self.myKind ( locked, done, frozen, mayAdd, stage, stageDate, creators, countryId, taskValue, ) = self.info( table, N.locked, N.done, N.frozen, N.mayAdd, N.stage, N.stageDate, N.creators, N.country, taskField, kind=kind, ) operator = G(taskInfo, N.operator) value = G(taskInfo, N.value) if operator == N.set: if taskField == N.decision: value = G(db.decisionInv, value) (contribId,) = self.info(N.contrib, N._id) isOwn = creators and uid in creators isCoord = countryId and auth.coordinator(countryId=countryId) isSuper = auth.superuser() isOffice = auth.officeuser() isSysadmin = auth.sysadmin() decisionDelay = G(taskInfo, N.delay, False) if decisionDelay: if type(decisionDelay) is int: decisionDelay = timedelta(hours=decisionDelay) elif type(decisionDelay) is dict: defaultDecisionDelay = G(decisionDelay, N.all, False) decisionDelay = ( G(decisionDelay, N.coord, defaultDecisionDelay) if isCoord else G(decisionDelay, N.sysadmin, defaultDecisionDelay) if isSysadmin else G(decisionDelay, N.office, defaultDecisionDelay) if isOffice else defaultDecisionDelay ) if type(decisionDelay) is int: decisionDelay = timedelta(hours=decisionDelay) elif type(decisionDelay) is not bool: decisionDelay = False justNow = now() remaining = False if decisionDelay and stageDate: if type(decisionDelay) is bool: remaining = True else: remaining = stageDate + decisionDelay - justNow if remaining <= timedelta(hours=0): remaining = False forbidden = frozen or done if forbidden: if ( task == N.unselectContrib and table == N.contrib ): if remaining is True: return "as intervention" if remaining: return remaining if not remaining: return False if table == N.contrib: if not isOwn and not isCoord and not isSuper: return False if task == N.startAssessment: return not forbidden and isOwn and mayAdd if value == taskValue: return False if not isCoord: return False answer = not frozen or remaining if task == N.selectContrib: return stage != N.selectYes and answer if task == N.deselectContrib: return stage != N.selectNo and answer if task == N.unselectContrib: return stage != N.selectNone and answer return False if table == N.assessment: forbidden = frozen or done if forbidden: return False if task == N.startReview: return not forbidden and G(mayAdd, myKind) if value == taskValue: return False if uid not in creators: return False answer = not locked or remaining if not answer: return False if task == N.submitAssessment: return stage == N.complete and answer if task == N.resubmitAssessment: return stage == N.completeWithdrawn and answer if task == N.submitRevised: return stage == N.completeRevised and answer if task == N.withdrawAssessment: return ( stage in {N.submitted, N.submittedRevised} and stage not in {N.incompleteWithdrawn, N.completeWithdrawn} and answer ) return False if table == N.review: if frozen: return False if done and not remaining: return False taskKind = G(taskInfo, N.kind) if not kind or kind != taskKind or kind != myKind: return False answer = remaining or not done or remaining if not answer: return False (aStage, aStageDate) = self.info(N.assessment, N.stage, N.stageDate) (finalStage,) = self.info(table, N.stage, kind=N.final) (expertStage, expertStageDate) = self.info( table, N.stage, N.stageDate, kind=N.expert ) xExpertStage = N.expertReviewRevoke if expertStage is None else expertStage xFinalStage = N.finalReviewRevoke if finalStage is None else finalStage revision = finalStage == N.reviewRevise zFinalStage = finalStage and not revision submitted = aStage == N.submitted submittedRevised = aStage == N.submittedRevised mayDecideExpert = ( submitted and not finalStage or submittedRevised and revision ) if value == taskValue: if not revision: return False if ( task in { N.expertReviewRevise, N.expertReviewAccept, N.expertReviewReject, N.expertReviewRevoke, } - {xExpertStage} ): return ( kind == N.expert and not zFinalStage and mayDecideExpert and answer ) if ( task in { N.finalReviewRevise, N.finalReviewAccept, N.finalReviewReject, N.finalReviewRevoke, } - {xFinalStage} ): return ( kind == N.final and not not expertStage and (not aStageDate or expertStageDate > aStageDate) and ( ( (not finalStage and submitted) or (revision and submittedRevised) ) or remaining ) and answer ) return False return False
def stage(self, table, kind=None)
-
Find the workflow stage that a record is in.
Hint
See above for a description of the stages.
Parameters
table
:string
- We must specify the kind of record for which we want to see the stage: contrib, assessment, or review.
kind
:string {
expert,
final}
, optionalNone
- Only if we want review attributes
Returns
string {
selectYes
,submittedRevised
,reviewAccept
, …} See above for the complete list.Expand source code
def stage(self, table, kind=None): """Find the workflow stage that a record is in. !!! hint See above for a description of the stages. Parameters ---------- table: string We must specify the kind of record for which we want to see the stage: contrib, assessment, or review. kind: string {`expert`, `final`}, optional `None` Only if we want review attributes Returns ------- string {`selectYes`, `submittedRevised`, `reviewAccept`, ...} See above for the complete list. """ return list(self.info(table, N.stage, kind=kind))[0]
def status(self, table, kind=None)
-
Present all workflow info and controls relevant to the record.
Parameters
table
:string
- We must specify the kind of record for which we want to see the status: contrib, assessment, or review.
kind
:string {
expert,
final}
, optionalNone
- Only if we want review attributes
Returns
string(html)
Expand source code
def status(self, table, kind=None): """Present all workflow info and controls relevant to the record. Parameters ---------- table: string We must specify the kind of record for which we want to see the status: contrib, assessment, or review. kind: string {`expert`, `final`}, optional `None` Only if we want review attributes Returns ------- string(html) """ eid = list(self.info(table, N._id, kind=kind))[0] itemKey = f"""{table}/{eid}""" rButton = H.iconr(itemKey, "#workflow", msg=N.status) return H.div( [ rButton, self.statusOverview(table, kind=kind), self.tasks(table, kind=kind), ], cls="workflow", )
def statusOverview(self, table, kind=None)
-
Present the current status of a record on the interface.
Parameters
table
:string
- We must specify the kind of record for which we want to present the stage: contrib, assessment, or review.
kind
:string {
expert,
final}
, optionalNone
- Only if we want review attributes
Returns
string(html)
Expand source code
def statusOverview(self, table, kind=None): """Present the current status of a record on the interface. Parameters ---------- table: string We must specify the kind of record for which we want to present the stage: contrib, assessment, or review. kind: string {`expert`, `final`}, optional `None` Only if we want review attributes Returns ------- string(html) """ (stage, stageDate, locked, done, frozen, score, eid) = self.info( table, N.stage, N.stageDate, N.locked, N.done, N.frozen, N.score, N._id, kind=kind, ) stageInfo = G(STAGE_ATTS, stage) statusCls = G(stageInfo, N.cls) stageOn = ( H.span(f""" on {datetime.toDisplay(stageDate)}""", cls="date") if stageDate else E ) statusMsg = H.span( [G(stageInfo, N.msg) or E, stageOn], cls=f"large status {statusCls}" ) lockedCls = N.locked if locked else E lockedMsg = ( H.span(G(STATUS_REP, N.locked), cls=f"large status {lockedCls}") if locked else E ) doneCls = N.done if done else E doneMsg = ( H.span(G(STATUS_REP, N.done), cls=f"large status {doneCls}") if done else E ) frozenCls = N.frozen if frozen else E frozenMsg = ( H.span(G(STATUS_REP, N.frozen), cls="large status info") if frozen else E ) statusRep = f"<!-- stage:{stage} -->" + H.div( [statusMsg, lockedMsg, doneMsg, frozenMsg], cls=frozenCls ) scorePart = E if table == N.assessment: scoreParts = presentScore(score, eid) scorePart = ( H.span(scoreParts) if table == N.assessment else (scoreParts[0] if scoreParts else E) if table == N.contrib else E ) return H.div([statusRep, scorePart], cls="workflow-line")
def tasks(self, table, kind=None)
-
Present the currently available tasks as buttons on the interface.
easy comments
We also include a comment ` for the ease of testing.
Parameters
table
:string
- We must specify the table for which we want to present the tasks: contrib, assessment, or review.
kind
:string {
expert,
final}
, optionalNone
- Only if we want review attributes
Returns
string(html)
Expand source code
def tasks(self, table, kind=None): """Present the currently available tasks as buttons on the interface. !!! hint "easy comments" We also include a comment `<!-- task~!taskName:eid --> for the ease of testing. Parameters ---------- table: string We must specify the table for which we want to present the tasks: contrib, assessment, or review. kind: string {`expert`, `final`}, optional `None` Only if we want review attributes Returns ------- string(html) """ uid = self.uid if not uid or table not in USER_TABLES: return E eid = list(self.info(table, N._id, kind=kind))[0] taskParts = [] allowedTasks = sorted( (task, taskInfo) for (task, taskInfo) in TASKS.items() if G(taskInfo, N.table) == table ) justNow = now() for (task, taskInfo) in allowedTasks: permitted = self.permission(task, kind=kind) if not permitted: continue remaining = type(permitted) is timedelta and permitted remark = type(permitted) is str and permitted taskExtra = E if remaining: remainingRep = datetime.toDisplay(justNow + remaining) taskExtra = H.span(f""" before {remainingRep}""", cls="datex") elif remark: taskExtra = H.span(f""" {remark}""", cls="datex") taskMsg = G(taskInfo, N.msg) taskCls = G(taskInfo, N.cls) taskPart = ( H.a( [taskMsg, taskExtra], f"""/api/task/{task}/{eid}""", cls=f"large task {taskCls}", ) + f"""<!-- task!{task}:{eid} -->""" ) taskParts.append(taskPart) return H.join(taskParts)