feat(m2): add dagens text view with golden output
This commit is contained in:
parent
06b45e9168
commit
bdb6291042
4 changed files with 578 additions and 0 deletions
253
scripts/dagens.py
Normal file
253
scripts/dagens.py
Normal file
|
|
@ -0,0 +1,253 @@
|
|||
"""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())
|
||||
Loading…
Add table
Add a link
Reference in a new issue