Coverage for src/lexigram/admin/schema/numeric.py: 94%

100 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-08-21 14:56 +0800

1from __future__ import annotations 

2 

3from dataclasses import dataclass 

4from typing import Any 

5 

6from lexigram.admin.schema.base import SchemaField 

7from lexigram.admin.schema.exceptions import FieldError 

8from lexigram.result import Err, Ok, Result 

9from lexigram.ui import Element, NumberInput 

10 

11CURRENCY_SYMBOLS: dict[str, str] = { 

12 "USD": "$", 

13 "EUR": "\u20ac", 

14 "GBP": "\u00a3", 

15 "JPY": "\u00a5", 

16 "CAD": "$", 

17 "AUD": "$", 

18 "CHF": "CHF", 

19 "CNY": "\u00a5", 

20 "SEK": "kr", 

21 "NOK": "kr", 

22} 

23 

24 

25@dataclass(frozen=True, kw_only=True) 

26class NumberField(SchemaField[int | float]): 

27 """A numeric input field supporting integers and floats.""" 

28 

29 def render_form( 

30 self, value: float | None, *, errors: list[str] | None = None 

31 ) -> Element: 

32 kwargs: dict[str, Any] = { 

33 "name": self.name, 

34 "value": value if value is not None else "", 

35 } 

36 if self.placeholder is not None: 

37 kwargs["placeholder"] = self.placeholder 

38 if self.label is not None: 

39 kwargs["label"] = self.label 

40 if errors: 

41 kwargs["error"] = errors[0] 

42 if self.readonly: 

43 kwargs["disabled"] = True 

44 return NumberInput(**kwargs).render() 

45 

46 def render_column(self, record: Any, value: float | None) -> Element: 

47 if value is None: 

48 return Element("span", "\u2014", class_="text-muted") 

49 return Element("span", str(value)) 

50 

51 def from_form(self, raw: str | None) -> Result[int | float | None, FieldError]: 

52 if raw is None: 

53 return Ok(None) 

54 stripped = raw.strip() 

55 if not stripped: 

56 if self.nullable: 

57 return Ok(None) 

58 return Err(FieldError("Must be a number")) 

59 try: 

60 if "." in stripped: 

61 return Ok(float(stripped)) 

62 return Ok(int(stripped)) 

63 except (ValueError, TypeError): 

64 return Err(FieldError("Must be a number")) 

65 

66 def to_form(self, value: float | None) -> str: 

67 if value is None: 

68 return "" 

69 return str(value) 

70 

71 

72@dataclass(frozen=True, kw_only=True) 

73class IntegerField(NumberField): 

74 """An integer-only input field.""" 

75 

76 def from_form(self, raw: str | None) -> Result[int | None, FieldError]: # type: ignore[override] 

77 if raw is None: 

78 return Ok(None) 

79 stripped = raw.strip() 

80 if not stripped: 

81 if self.nullable: 

82 return Ok(None) 

83 return Err(FieldError("Must be an integer")) 

84 try: 

85 if "." in stripped: 

86 return Err(FieldError("Must be an integer")) 

87 return Ok(int(stripped)) 

88 except (ValueError, TypeError): 

89 return Err(FieldError("Must be an integer")) 

90 

91 def render_column(self, record: Any, value: float | None) -> Element: 

92 if value is None: 

93 return Element("span", "\u2014", class_="text-muted") 

94 return Element("span", str(int(value))) 

95 

96 

97@dataclass(frozen=True, kw_only=True) 

98class FloatField(NumberField): 

99 """A float-only input field.""" 

100 

101 def from_form(self, raw: str | None) -> Result[float | None, FieldError]: 

102 if raw is None: 

103 return Ok(None) 

104 stripped = raw.strip() 

105 if not stripped: 

106 if self.nullable: 

107 return Ok(None) 

108 return Err(FieldError("Must be a number")) 

109 try: 

110 return Ok(float(stripped)) 

111 except (ValueError, TypeError): 

112 return Err(FieldError("Must be a number")) 

113 

114 

115@dataclass(frozen=True, kw_only=True) 

116class CurrencyField(NumberField): 

117 """A currency input field with configurable currency symbol.""" 

118 

119 currency: str = "USD" 

120 

121 def render_infolist_entry(self, value: Any) -> Any: 

122 from lexigram.ui import InfolistEntry, InfolistEntryType 

123 

124 return InfolistEntry( 

125 name=self.name, 

126 label=self.label or self.name.replace("_", " ").title(), 

127 value=value, 

128 type=InfolistEntryType.MONEY, 

129 currency=self.currency, 

130 ) 

131 

132 def render_column(self, record: Any, value: float | None) -> Element: 

133 if value is None: 

134 return Element("span", "\u2014", class_="text-muted") 

135 symbol = CURRENCY_SYMBOLS.get(self.currency, self.currency) 

136 return Element("span", f"{symbol}{float(value):.2f}") 

137 

138 def from_form(self, raw: str | None) -> Result[float | None, FieldError]: 

139 if raw is None: 

140 return Ok(None) 

141 stripped = raw.strip() 

142 if not stripped: 

143 if self.nullable: 

144 return Ok(None) 

145 return Err(FieldError("Must be a number")) 

146 try: 

147 return Ok(float(stripped)) 

148 except (ValueError, TypeError): 

149 return Err(FieldError("Must be a number"))