"""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())