1. Discard reloads /config/model: after a stale save the page kept the
new fingerprint with the old values, so Discard + Save overwrote the
other surface's change without a 409.
2. Removing cards back to one restores the stored inverter shape, so a
flat site stays flat after add + remove.
3. A choice the page can only keep ("Other time windows") stays offered
while it is the stored value.
4. A load locks every locked value again.
5. at_zero() needs an accepted 0 W write; before the first one Home says
"Going to 0 W" (the heartbeat writes every heartbeat_s, so it clears).
6. A field with a unit drops the translation's "(W)" from its label: the
peak limit read "Peak limit (W)" beside a kW value.
7. A mirrored choice lists the mirrored field's keys, so a search for
static_profile finds the contract on a fixed-price site.
8. test_settings: a save refused by ui_to_options writes nothing.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
281 lines
12 KiB
Python
281 lines
12 KiB
Python
"""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)
|
|
if fld.unit:
|
|
# The page shows the unit beside the control, in its own scale (kW),
|
|
# so the translation's "(W)" goes; "(optional)" stays.
|
|
label = re.sub(r"\s*\([^()\s]{1,6}\)", "", label)
|
|
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),
|
|
# A choice mirrored into a field also shows (and finds) its keys.
|
|
"writes": sorted(k for k, how in L.DERIVED.items()
|
|
if how in (fld.key, fld.mirror))}
|
|
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"(?<![A-Za-z0-9_])([a-z_0-9]+)(?:\[(\d+)\])?\.([a-z_0-9]+)")
|
|
|
|
|
|
def _mirror_of(key):
|
|
return next((f.key for _g, _p, f in L.fields() if f.mirror == key), None)
|
|
|
|
|
|
def targets(key: str, text: str, model: dict) -> 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))
|