"""UX-13 P2: the settings view's server half. It names no option key. It turns app/uilayout.py's table into the JSON the page draws from (labels from the Supervisor's translations, ranges and choices from its schema, page words from app/ui_text), turns a posted page model into options through app/uimodel.py, and says beside which page field each validator sentence goes. The rules stay in validate.py and the sentences are passed on as they are (DECISIONS/UX-13-one-layout-table.md). """ import copy import io import json import os import re from . import uilayout as L from . import uimodel as U from .configui import help_of, label_of, problems_by_field, split_group UI_TEXT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "ui_text", "en.json") # A choice the page cannot make, only keep: odd tariff windows (P5 editor). ONLY_IF_HELD = ("custom",) _TIERS = ("bad", "warn", "info") def ui_text() -> dict: with io.open(UI_TEXT, encoding="utf-8") as f: return json.load(f) def _roster(): """The inverter list field's key, read off the table.""" return next(f.key for _g, _p, f in L.fields() if f.control == L.INVERTER_LIST) def _node(schema_ui, key): """The Supervisor's schema entry for `key` (`list.sub` too), or {}.""" nodes, path = schema_ui or [], key.split(".") for step in path[:-1]: parent = next((n for n in nodes if n.get("name") == step), None) nodes = (parent or {}).get("schema") or [] return next((n for n in nodes if n.get("name") == path[-1]), None) or {} def _words(translations, lang, key, T): """(label, help) for a field: ui_text for `ui.` fields, else the translation without its `Group · ` prefix, and the raw key last.""" if key.startswith("ui."): entry = T["fields"].get(key[3:], {}) return entry.get("name", key), entry.get("description", "") path = tuple(key.split(".")[:-1]) name = key.split(".")[-1] label, _lang = label_of(translations, lang, name, path) text, _lang = help_of(translations, lang, name, path) return split_group(label)[1], text def _choices(fld, node, T, value_type): """[[value, label, only_if_held], ...] for a choice control, or None.""" if fld.control not in (L.SEGMENTED, L.SELECT, L.CHOICE): return None name = fld.key[3:] if fld.key.startswith("ui.") else fld.key words = T["choices"].get(name) or {} if fld.key.startswith("ui."): values = list(words) elif node.get("options"): values = [v for v in node["options"] if not words or v in words] elif value_type == "integer" and node.get("lengthMax") is not None: values = list(range(int(node["lengthMin"]), int(node["lengthMax"]) + 1)) if words: values = [v for v in values if str(v) in words] else: return [] out = [] for v in values: label = words.get(str(v)) if label is None: label = (T["settings"]["hour"].replace("{hour}", f"{v:02d}") if isinstance(v, int) else str(v)) preset = U.REGION_PRESETS.get(v) if fld.fills else None if preset: label = f"{label}, " + T["settings"]["region_year"].replace( "{year}", str(preset["year"])) out.append([v, label, v in ONLY_IF_HELD]) return out def _field(fld, schema_ui, translations, lang, T): node = _node(schema_ui, fld.key) label, text = _words(translations, lang, fld.key, T) value_type = ("float" if fld.control == L.NUMBER and fld.key.startswith("ui.") else node.get("type") or "") out = {"key": fld.key, "control": fld.control, "label": label, "help": text, "unit": fld.unit, "scale": fld.scale, "step": fld.step, "kind": fld.kind, "show": [list(c) for c in fld.show], "applies": fld.applies, "type": value_type, "optional": not node.get("required", False), "writes": sorted(k for k, how in L.DERIVED.items() if how == fld.key)} if node.get("lengthMin") is not None: out["min"] = node["lengthMin"] if node.get("lengthMax") is not None: out["max"] = node["lengthMax"] choices = _choices(fld, node, T, value_type) if choices is not None: out["choices"] = choices name = fld.key[3:] if fld.key.startswith("ui.") else fld.key later = T.get("choices_later", {}).get(name) if later: out["later"] = [[v, words] for v, words in later.items()] hints = T.get("choice_help", {}).get(name) if hints: out["hints"] = {v: e.get("description", "") for v, e in hints.items()} if fld.mirror: out["mirror"] = fld.mirror if fld.fills: out["fills"] = fld.fills out["presets"] = {rid: {"year": p["year"], "value": p["taxes_eur_kwh"]} for rid, p in U.REGION_PRESETS.items()} roster = _roster() if fld.key.startswith(roster + "."): sub = fld.key[len(roster) + 1:] if L.DERIVED.get(sub) == roster: out["flat"] = sub # how the one flat inverter stores it if fld.control == L.INVERTER_LIST: out["fields"] = [_field(sub, schema_ui, translations, lang, T) for sub in L.ROSTER_FIELDS] out["count"] = U.COUNT out["grow"] = dict(U.GROWN) out["shape"] = next(iter(U.GROWN)) return out def layout(schema_ui, translations, lang="en") -> dict: """Every group, part and field of the Installer view, as the page draws it. Built from the table only: a field the table places is on the page.""" T = ui_text() groups = [] for g in L.GROUPS: parts = [] for p in g.parts: words = T["parts"].get(f"{g.id}.{p.id}", {}) if p.id else {} parts.append({"id": p.id, "name": words.get("name", ""), "description": words.get("description", ""), "collapsed": p.collapsed, "fields": [_field(f, schema_ui, translations, lang, T) for f in p.fields]}) words = T["groups"].get(g.id, {}) applies = sorted({f.applies for p in g.parts for f in p.fields}) groups.append({"id": g.id, "name": words.get("name", g.id), "description": words.get("description", ""), "support_only": g.support_only, "applies": [T["applies"][a]["name"] for a in applies], "parts": parts}) return {"groups": groups} # --------------------------------------------------------------------------- # # The page model -> options, for the validator and the save # --------------------------------------------------------------------------- # def _same(a, b) -> bool: """Equal as the Supervisor stores it: 3 and 3.0 alike, True and 1 not.""" if isinstance(a, bool) or isinstance(b, bool): return type(a) is type(b) and a == b if isinstance(a, (int, float)) and isinstance(b, (int, float)): return a == b if isinstance(a, list) and isinstance(b, list): return len(a) == len(b) and all(_same(x, y) for x, y in zip(a, b)) if isinstance(a, dict) and isinstance(b, dict): return a.keys() == b.keys() and all(_same(a[k], b[k]) for k in a) return type(a) is type(b) and a == b def to_options(model: dict, stored: dict | None = None) -> dict: """The options blob for a posted page model. A value equal to the stored one is written as stored, in the stored key order, so a save that changed nothing writes an identical blob (the browser reads 3.0 as 3).""" out = U.ui_to_options(model) stored = stored or {} for k, v in out.items(): if k in stored and _same(v, stored[k]): out[k] = copy.deepcopy(stored[k]) return ({k: out[k] for k in stored if k in out} | {k: v for k, v in out.items() if k not in stored}) def derive_message(err: U.DeriveError) -> dict: words = ui_text()["settings"]["derive"] text = words.get(err.code, words["unknown"])["description"] return {"field": err.field or None, "card": None, "tier": "bad", "text": text} # --------------------------------------------------------------------------- # # Where a sentence goes on the page # --------------------------------------------------------------------------- # # `inverters.max_w`, or `inverters[1].setpoint_entity` for one card. _SUB = re.compile(r"(? list: """[(page field, card)] for a sentence that names option `key`. `card` is the inverter card's index, or None.""" roster = _roster() cards = model.get(roster) if isinstance(model.get(roster), list) else [] names = [c.get("name") for c in cards if isinstance(c, dict)] # messages.setting(): “Inverter rating” for inverter-b (inverters.max_w), # or a roster rule's own `inverters[1] (inverter-b)`. card = next((i for i, n in enumerate(names) if n and f"for {n} ({roster}." in text), None) index = re.search(re.escape(roster) + r"\[(\d+)\]", text) if card is None and index and int(index.group(1)) < len(cards): card = int(index.group(1)) if key == roster: subs = [(f"{roster}.{s}", int(i) if i and int(i) < len(cards) else card) for r, i, s in _SUB.findall(text) if r == roster] subs = [x for x in dict.fromkeys(subs) if L.field(x[0])] return subs or [(roster, card)] if key in L.DERIVED: how = L.DERIVED[key] if how == roster: return [(f"{roster}.{key}", 0 if card is None else card)] fld = L.field(how) if fld is not None and not L.visible(fld, model) and _mirror_of(how): return [(_mirror_of(how), None)] return [(how, None)] if L.field(key) is not None: return [(key, None)] return [(None, None)] def anchors(problems, notes, model, keys) -> list: """[{field, card, tier, text}] for the refusals and notes. `field` is None for a sentence that names no setting on the page.""" out, seen = [], set() def add(field, card, tier, text): if (field, card, text) not in seen: seen.add((field, card, text)) out.append({"field": field, "card": card, "tier": tier, "text": text}) by_key, unanchored = problems_by_field(problems, keys) for key, texts in by_key.items(): for text in texts: for field, card in targets(key, text, model): add(field, card, "bad", text) for text in unanchored: add(None, None, "bad", text) for note in notes: tier = note.tier if note.tier in _TIERS else "info" for key in note.keys or (None,): for field, card in (targets(key, note.text, model) if key else [(None, None)]): add(field, card, tier, note.text) # A sentence placed on one inverter's field is not also put on the list. roster = _roster() placed = {m["text"] for m in out if (m["field"] or "").startswith(roster + ".")} return [m for m in out if not (m["field"] == roster and m["text"] in placed)] def fields_of(keys, model) -> list: """The page fields that show these option keys (a stale save's list).""" return list(dict.fromkeys(f for k in keys for f, _c in targets(k, "", model) if f))