Coverage for src/bayernwerk_client/map/formatting.py: 97%
76 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-17 13:07 +0000
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-17 13:07 +0000
1"""Human-readable rendering specific to the `map` service.
3Generic paragraph/dict rendering lives in the shared
4`bayernwerk_client.formatting` module. This module adds two things that
5generic rendering can't do without domain knowledge of this specific
6service:
8- `render_orders`: a curated one-line headline per order (status, date,
9 order id, customer, product, address - mirroring the portal's own order
10 list) with the remaining, less important fields indented below. Orders
11 are a small, stable, well-known shape, unlike...
12- `render_instance_tree`: the `get_order_instances` tree, which needs
13 actual nesting depth preserved for display - see `instances.py` for why
14 *that* structure isn't curated/modelled with fixed fields the way orders
15 are here.
16"""
18from __future__ import annotations
20from typing import Any
22from bayernwerk_client.formatting import format_dict
24_ORDER_HEADLINE_FIELDS = ("portalProcessStatuses", "entryDate", "orderId", "customer", "productI18nKeys", "address")
27def render_orders(orders: list[dict[str, Any]], *, short: bool = False) -> str:
28 """Human-readable rendering of `list_orders()`.
30 Not a translation of `productI18nKeys` (e.g. `POWER_CHANGE`) into the
31 portal's German labels ("Anlagenveränderung Strom") - we don't have
32 Bayernwerk's localization table, and showing a raw key beats guessing
33 wrong German text.
35 `short=True` prints only the headline per order (no indented details),
36 one per line rather than separated by a blank line.
37 """
38 if not orders:
39 return "(leer)"
40 separator = "\n" if short else "\n\n"
41 return separator.join(_render_order(order, short=short) for order in orders)
44def is_order_finished(order: dict[str, Any]) -> bool:
45 return "FINISHED" in (order.get("portalProcessStatuses") or [])
48def _render_order(order: dict[str, Any], *, short: bool = False) -> str:
49 status = ", ".join(order.get("portalProcessStatuses") or []) or "-"
50 date = _short_date(order.get("entryDate"))
51 order_id = order.get("orderId", "-")
52 customer = order.get("customer") or {}
53 name = ", ".join(part for part in (customer.get("lastname"), customer.get("firstname")) if part) or "-"
54 products = ", ".join(order.get("productI18nKeys") or []) or "-"
55 address = _format_address(order.get("address") or {})
57 headline = f"{status} | {date} | {order_id} | {name} | {products} | {address}"
58 if short:
59 return headline
61 rest = {key: value for key, value in order.items() if key not in _ORDER_HEADLINE_FIELDS}
62 details = format_dict(rest, indent=1) if rest else ""
63 return f"{headline}\n{details}" if details else headline
66def _short_date(value: Any) -> str:
67 if not isinstance(value, str):
68 return "-"
69 date_part = value.split("T", 1)[0]
70 year, _, rest = date_part.partition("-")
71 month, _, day = rest.partition("-")
72 if not (year and month and day):
73 return date_part
74 return f"{day}.{month}.{year}"
77def _format_address(address: dict[str, Any]) -> str:
78 if not address:
79 return "-"
80 street = " ".join(part for part in (address.get("street"), address.get("houseNo")) if part)
81 city = " ".join(part for part in (address.get("zipCode"), address.get("city")) if part)
82 return ", ".join(part for part in (street, city) if part) or "-"
85def render_instance_tree(tree: Any) -> str:
86 """Human-readable indented rendering of a `get_order_instances` tree.
88 Unlike `iter_instance_items` (schema-less, flat, for programmatic use),
89 this preserves nesting depth for display.
90 """
91 lines: list[str] = []
92 _render_node(tree, 0, lines, seen=set())
93 return "\n".join(lines) if lines else "(leer)"
96def _render_node(node: Any, depth: int, lines: list[str], *, seen: set[tuple[Any, Any]]) -> None:
97 if isinstance(node, list):
98 for item in node:
99 _render_node(item, depth, lines, seen=seen)
100 return
101 if not isinstance(node, dict):
102 return
104 child_depth = depth
105 if "itemType" in node:
106 ivy_id = node.get("ivyId")
107 key = (node.get("itemType"), ivy_id)
108 if ivy_id is not None and key in seen:
109 return
110 if ivy_id is not None:
111 seen.add(key)
112 lines.append(_format_instance_line(node, depth))
113 child_depth = depth + 1
115 for value in node.values():
116 if isinstance(value, dict | list):
117 _render_node(value, child_depth, lines, seen=seen)
120def _format_instance_line(node: dict[str, Any], depth: int) -> str:
121 label = node.get("schemaType") or node.get("itemType")
122 display_id = node.get("displayId")
123 suffix = f" #{display_id}" if display_id else ""
124 details = _format_form_data(node.get("formData"))
125 detail_suffix = f" ({details})" if details else ""
126 return f"{' ' * depth}- {label}{suffix}{detail_suffix}"
129def _format_form_data(form_data: Any) -> str:
130 if not isinstance(form_data, dict) or not form_data:
131 return ""
132 parts = [f"{key}={value}" for key, value in form_data.items() if not isinstance(value, dict | list)]
133 return ", ".join(parts)