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

1"""Human-readable rendering specific to the `map` service. 

2 

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: 

7 

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

17 

18from __future__ import annotations 

19 

20from typing import Any 

21 

22from bayernwerk_client.formatting import format_dict 

23 

24_ORDER_HEADLINE_FIELDS = ("portalProcessStatuses", "entryDate", "orderId", "customer", "productI18nKeys", "address") 

25 

26 

27def render_orders(orders: list[dict[str, Any]], *, short: bool = False) -> str: 

28 """Human-readable rendering of `list_orders()`. 

29 

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. 

34 

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) 

42 

43 

44def is_order_finished(order: dict[str, Any]) -> bool: 

45 return "FINISHED" in (order.get("portalProcessStatuses") or []) 

46 

47 

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 {}) 

56 

57 headline = f"{status} | {date} | {order_id} | {name} | {products} | {address}" 

58 if short: 

59 return headline 

60 

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 

64 

65 

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}" 

75 

76 

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

83 

84 

85def render_instance_tree(tree: Any) -> str: 

86 """Human-readable indented rendering of a `get_order_instances` tree. 

87 

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

94 

95 

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 

103 

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 

114 

115 for value in node.values(): 

116 if isinstance(value, dict | list): 

117 _render_node(value, child_depth, lines, seen=seen) 

118 

119 

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}" 

127 

128 

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)