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
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-21 14:56 +0800
1from __future__ import annotations
3from dataclasses import dataclass
4from typing import Any
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
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}
25@dataclass(frozen=True, kw_only=True)
26class NumberField(SchemaField[int | float]):
27 """A numeric input field supporting integers and floats."""
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()
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))
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"))
66 def to_form(self, value: float | None) -> str:
67 if value is None:
68 return ""
69 return str(value)
72@dataclass(frozen=True, kw_only=True)
73class IntegerField(NumberField):
74 """An integer-only input field."""
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"))
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)))
97@dataclass(frozen=True, kw_only=True)
98class FloatField(NumberField):
99 """A float-only input field."""
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"))
115@dataclass(frozen=True, kw_only=True)
116class CurrencyField(NumberField):
117 """A currency input field with configurable currency symbol."""
119 currency: str = "USD"
121 def render_infolist_entry(self, value: Any) -> Any:
122 from lexigram.ui import InfolistEntry, InfolistEntryType
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 )
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}")
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"))