253 lines
9.3 KiB
Python
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())
|