"""What a screen needs to know without re-deriving a rule: for each NEXT transition of an item or a project, its gates
(met or not, the reason in plain words, how to fix it), and the project's stage. Pure functions of what they are
given, in the same words the rules themselves refuse with. Which transitions a CALLER may see is decided by the
use case (permissions); this module knows only the process."""
from datetime import date, datetime
from typing import Optional
from brick_projects.domain.item import NO_EVIDENCE, SUBMITTER, Item, _day, calendar_of
from brick_projects.domain.values import ItemSource, ItemState, ProjectState, Settings
# item transition -> (name, states it leaves). The keys are the process's own (process.yaml).
ITEM_TRANSITIONS = {
"start": ("Start the work", ("planned",)),
"block": ("Say it waits on the client", ("in_progress",)),
"unblock": ("Back to work", ("blocked",)),
"submit": ("Submit for review", ("in_progress",)),
"send_warning": ("Record the deemed-acceptance warning", ("in_review",)),
"request_changes": ("Record changes requested", ("in_review",)),
"accept": ("Record acceptance", ("in_review",)),
"accept_deemed": ("Record deemed acceptance", ("in_review",)),
"descope": ("Descope", ("planned", "in_progress", "blocked", "in_review")),
}
def gate(name: str, met: bool, reason_met: str, reason_unmet: str, fix: str) -> dict:
return {"gate": name, "met": met, "reason": reason_met if met else reason_unmet, "fix": None if met else fix}
def _project_moves(item: Item, project_state: Optional[str]) -> dict:
ok = project_state is None or project_state not in (ProjectState.CANCELLED, ProjectState.CLOSED) or (
project_state == ProjectState.CLOSED and item.source == ItemSource.MAKEGOOD.value)
if project_state == ProjectState.CANCELLED:
why = "The project is cancelled: its work no longer moves"
else:
why = "The project is closed and reconciled: work left short is made good with a makegood"
return gate("The project is open", ok, "The project is open for work", why,
"Nothing moves on a cancelled project; on a closed one, raise a makegood for the short work")
def item_gates(item: Item, *, now: datetime, settings: Settings, project_state: Optional[str], me: str,
may_accept_own: bool, identities: tuple = ()) -> dict:
"""transition key -> its gates, for every transition that leaves the item's current state."""
cal, today = calendar_of(settings), _day(now)
out = {}
for key, (_, states) in ITEM_TRANSITIONS.items():
if item.state not in states:
continue
gates = [_project_moves(item, project_state)]
has_evidence = bool(item.evidence)
evidence = gate("Evidence is attached", has_evidence, "Evidence is attached", NO_EVIDENCE,
"Attach the evidence: a link, a file or a sign-off")
recorder = gate("Someone else records it", not item.submitted_by_me(me, identities) or may_accept_own,
"You did not submit this work, or you may record your own work's acceptance", SUBMITTER,
"Ask the project's lead or a manager, or have pj:item:accept_own granted to your role")
if key in ("send_warning", "accept_deemed"):
ends = item.window_ends_on(cal, settings)
if key == "send_warning":
gates.append(gate("The review window has passed", ends is not None and today > ends,
"The client's review window has passed with no answer",
f"The client has until {ends.isoformat() if ends else 'the end of the window'} "
"to review",
"Wait until the window has passed, then warn the client"))
if key == "accept":
gates += [evidence, recorder]
if key == "accept_deemed":
possible = item.deemed_possible_on(cal, settings)
gates += [
gate("Deemed acceptance is allowed here", settings.deemed_acceptance,
"This business allows deemed acceptance", "Deemed acceptance is switched off here",
"Record the client's answer instead"),
gate("The client was warned", item.warned_at is not None, "The warning is recorded",
"Warn the client first: acceptance is deemed only after a warning",
"Send the warning, then record it with send_warning"),
gate("The warning's notice has ended", possible is not None and today >= possible,
"The notice has ended",
f"The warning's notice runs until {possible.isoformat()}" if possible
else "The notice starts when the warning is recorded",
"Wait until the notice has ended"),
evidence, recorder]
out[key] = gates
return out
def stage_of(project, items: list, today: date) -> str:
"""plan | work | review | reconcile (or cancelled), derived from the items: none started is plan; any in review is review; the
window closed (or the project reconciled) is reconcile; otherwise work."""
if project.state == ProjectState.CANCELLED:
return "cancelled"
closed = project.state == ProjectState.CLOSED or (project.ends_on is not None and project.ends_on < today)
started = (ItemState.IN_PROGRESS, ItemState.BLOCKED, ItemState.IN_REVIEW, ItemState.DONE)
if not any(i.state in started for i in items):
# all descoped with the window closed is a project to reconcile, not one still to plan
return "reconcile" if closed and items and all(i.state == ItemState.CANCELLED for i in items) else "plan"
if any(i.state == ItemState.IN_REVIEW for i in items):
return "review"
if closed:
return "reconcile"
return "work"
def project_gates(project, today: date) -> dict:
"""transition key -> gates, for the project's own next transitions."""
out = {}
if project.state == ProjectState.ACTIVE:
closed = project.ends_on is not None and project.ends_on < today
out["reconcile"] = [
gate("The project is active", True, "The project is active", "", ""),
gate("The window has closed", closed, "The project's window has closed",
f"The window runs until {project.ends_on.isoformat()}" if project.ends_on
else "The project has no end date, so its window never closes",
"Wait for the window to end (the scheduler's daily pass reconciles it)")]
if project.state in (ProjectState.PLANNED, ProjectState.ACTIVE):
out["cancel_project"] = [gate("The project is planned or active", True, "The project can still be cancelled",
"", "")]
return out