jobbsok/scripts/dagens.py

253 lines
9.3 KiB
Python

"""The daily operating view, as deterministic text (plan Step 23).
Build-brief 6 says the silence flags "surface in `dagens`". This is where they
surface, and at this milestone that is plain text -- the HTML dashboard belongs
to M6, and building it now would put a rendering layer between the operator and
the first thing the status machine ever produced for them.
Four sections, in the order the brief names them: what needs action, what has
gone silent, upcoming deadlines, and the pipeline by state.
**It writes nothing.** Not the cache, not a log, not a temporary file. The
operator reads this several times a day against a workspace they are working
in, and a view that refreshed something as a side effect would make reading a
write. `sak_status.py --oppdater` stays the only writer of the four cached
keys; when this view disagrees with a `sak.md`, it says so and names the
command rather than fixing it in passing.
**Two kinds of deadline, told apart rather than merged.** The status machine
derives `neste_frist` as the date a case goes silent, and derives nothing at
all in `vurderer` and `soker` -- where the date in the frontmatter is the
operator's own application deadline. Both are deadlines and neither is the
other, so each entry carries which kind it is. Printing a single unlabelled
date would have quietly told the operator that an application deadline is
something the machine computed.
**An absent inbound event is a fact about the log.** With the mail server
deferred to M4 nothing reads the inbox, so "nothing inbound recorded" cannot
mean "nobody answered" (risk H9). Those cases carry the status machine's own
note, verbatim, and never turn up in the silence section on that basis alone.
Determinism is a property of the interface. The date is injected, never read
off the wall clock, and nothing that reaches stdout is iterated out of a dict
or a set: two runs are byte-identical, including under different hash seeds.
"""
import datetime
import sak_status
from jobbsok_lib import frontmatter as frontmatter_lib
from jobbsok_lib import jsonl, paths
#: Section headings, in build-brief 6's order. Norwegian, because everything
#: the operator reads is Norwegian.
KREVER_HANDLING = "Krever handling"
GATT_STILLE = "Gått stille"
KOMMENDE_FRISTER = "Kommende frister"
PER_TILSTAND = "Saker per tilstand"
#: What a deadline is, when there is one. The derived one is the date silence
#: trips; the cached one is the operator's own, and no machine here can
#: recompute it.
STILLHETSFRIST = "stillhetsfrist"
SOKNADSFRIST = "søknadsfrist"
TOM = " (ingen)"
def samle(root, today):
"""Everything the view shows, as one ordered structure. Pure reader."""
data = sak_status.rapport(root, today)
saker = []
for sak in data["saker"]:
meta, _body = frontmatter_lib.read(root, "saker", sak["sak_id"], "sak.md")
frist, frist_kilde = _frist(sak, meta)
beriket = dict(sak)
beriket["frist"] = frist
beriket["frist_kilde"] = frist_kilde
saker.append(beriket)
return {"dato": data["dato"], "saker": saker}
def _frist(sak, meta):
"""The date this case is next measured against, and which kind it is."""
utledet = sak.get("neste_frist")
if utledet:
return utledet, STILLHETSFRIST
bufret = meta.get("neste_frist")
if bufret in (None, "", sak_status.NULL):
return None, None
return str(bufret), SOKNADSFRIST
def krever_handling(saker):
"""Non-terminal cases the operator is the one holding up."""
return [s for s in saker
if s["status"] not in sak_status.TERMINALE and s["ventende_part"] == "meg"]
def gatt_stille(saker):
"""Cases a silence rule has actually fired on -- and only those.
Membership is the flag list the status machine derived, never the absence
of an inbound event: those two look alike and mean opposite things.
"""
return [s for s in saker if s["flagg"]]
def kommende_frister(saker, dagen):
"""Deadlines still ahead, earliest first. A passed one is in the section
above, and repeating it here would double-count the same fact."""
ut = [s for s in saker
if s["status"] not in sak_status.TERMINALE and s["frist"] and s["frist"] >= dagen]
# ISO-8601 dates sort correctly as text, and sorting on the text keeps the
# ordering independent of any locale.
return sorted(ut, key=lambda s: (s["frist"], s["sak_id"]))
def per_tilstand(saker):
"""(state, count) for all nine states, in the brief's order.
All nine, including the empty ones: a pipeline with nothing in `tilbud` is
something the operator should be able to see rather than infer from a
missing line. Iterating the fixed tuple is also what keeps this out of a
dict's ordering.
"""
return [(tilstand, len([s for s in saker if s["status"] == tilstand]))
for tilstand in sak_status.TILSTANDER]
def render(data):
"""The whole view as text. Every ordering here is fixed, none is a dict's."""
saker = data["saker"]
dagen = data["dato"]
linjer = ["dagens %s (%d saker)" % (dagen, len(saker)), ""]
avvikende = [s["sak_id"] for s in saker if s["divergens"]]
if avvikende:
# Reported, never repaired: this view does not write, and a cache
# quietly fixed by a reader is a divergence nobody ever learns about.
linjer.append(
"NB: %d %s en hurtigbuffer som er uenig med loggen: %s."
% (len(avvikende), "sak har" if len(avvikende) == 1 else "saker har",
", ".join(avvikende))
)
linjer.append(
" Tallene nedenfor er utledet fra loggen. Kjør sak_status.py "
"--oppdater for å rette sak.md."
)
linjer.append("")
linjer.extend(_saksseksjon(KREVER_HANDLING, krever_handling(saker)))
linjer.extend(_saksseksjon(GATT_STILLE, gatt_stille(saker)))
linjer.extend(_fristseksjon(kommende_frister(saker, dagen)))
linjer.extend(_tilstandsseksjon(saker))
return "\n".join(linjer)
def _saksseksjon(tittel, utvalg):
linjer = ["## %s (%d)" % (tittel, len(utvalg)), ""]
if not utvalg:
linjer.extend([TOM, ""])
return linjer
for sak in utvalg:
linjer.extend(_sakslinjer(sak))
return linjer
def _sakslinjer(sak):
linjer = [
"- %s -- %s, %s" % (sak["sak_id"], sak["arbeidsgiver"], sak["rolle"]),
" %s, ventende: %s, sist aktivitet: %s"
% (sak["status"], sak["ventende_part"], sak["sist_aktivitet"] or "-"),
]
if sak["flagg"]:
linjer.append(
" flagg: %s (terskel passert %s)"
% (", ".join(sak["flagg"]), sak["neste_frist"] or "-")
)
# The flag line above already states a passed threshold by its date, so a
# `frist:` line repeating the same number is noise rather than a second
# fact. A deadline the flag line does not carry is still printed.
if sak["frist"] and not (sak["flagg"] and sak["frist"] == sak["neste_frist"]):
linjer.append(" frist: %s (%s)" % (sak["frist"], sak["frist_kilde"]))
if not sak["innkommende"]["registrert"]:
linjer.append(" merknad: %s" % sak["innkommende"]["merknad"])
linjer.append("")
return linjer
def _fristseksjon(utvalg):
linjer = ["## %s (%d)" % (KOMMENDE_FRISTER, len(utvalg)), ""]
if not utvalg:
linjer.extend([TOM, ""])
return linjer
for sak in utvalg:
linjer.append("- %s %s -- %s" % (sak["frist"], sak["sak_id"], sak["frist_kilde"]))
linjer.append("")
return linjer
def _tilstandsseksjon(saker):
linjer = ["## %s" % PER_TILSTAND, ""]
for tilstand, antall in per_tilstand(saker):
linjer.append(" %-11s %d" % (tilstand, antall))
linjer.append(" %-11s %d" % ("i alt", len(saker)))
linjer.append("")
return linjer
# ---------------------------------------------------------------------------
# Command line
# ---------------------------------------------------------------------------
EXIT_OK = 0
EXIT_FEIL = 2
def build_parser():
import argparse
parser = argparse.ArgumentParser(
prog="dagens.py",
description=(
"Dagens arbeidsbilde som ren tekst: hva som krever handling, hva "
"som har gaatt stille, kommende frister og saker per tilstand. "
"Skriver ingenting."
),
)
parser.add_argument("--workspace", required=True,
help="arbeidsomraadets rot (ingen underforstaatt standard)")
parser.add_argument("--today", default=None, metavar="YYYY-MM-DD",
help="klokka som skal brukes; injisert, aldri veggklokka")
return parser
def main(argv=None, stdout=None, stderr=None):
import sys as _sys
argv = _sys.argv[1:] if argv is None else argv
stdout = _sys.stdout if stdout is None else stdout
stderr = _sys.stderr if stderr is None else stderr
args = build_parser().parse_args(argv)
today = args.today or datetime.date.today().isoformat()
try:
root = paths.workspace_root(args.workspace)
tekst = render(samle(root, today))
except (sak_status.StatusError, paths.WorkspaceError, jsonl.JsonlError,
frontmatter_lib.FrontmatterError, OSError) as feil:
stderr.write("dagens: %s\n" % feil)
return EXIT_FEIL
stdout.write(tekst)
stdout.flush()
return EXIT_OK
if __name__ == "__main__":
import sys as _sys
_sys.exit(main())