Coverage for src / lexigram / admin / schema / numeric.py: 27%
95 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-08-13 22:14 +0800
« prev ^ index » next coverage.py v7.13.5, created at 2026-08-13 22:14 +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 return NumberInput(**kwargs).render()
44 def render_column(self, record: Any, value: float | None) -> Element:
45 if value is None:
46 return Element("span", "\u2014", class_="text-muted")
47 return Element("span", str(value))
49 def from_form(self, raw: str | None) -> Result[int | float | None, FieldError]:
50 if raw is None:
51 return Ok(None)
52 stripped = raw.strip()
53 if not stripped:
54 if self.nullable:
55 return Ok(None)
56 return Err(FieldError("Must be a number"))
57 try:
58 if "." in stripped:
59 return Ok(float(stripped))
60 return Ok(int(stripped))
61 except (ValueError, TypeError):
62 return Err(FieldError("Must be a number"))
64 def to_form(self, value: float | None) -> str:
65 if value is None:
66 return ""
67 return str(value)
70@dataclass(frozen=True, kw_only=True)
71class IntegerField(NumberField):
72 """An integer-only input field."""
74 def from_form(self, raw: str | None) -> Result[int | None, FieldError]: # type: ignore[override]
75 if raw is None:
76 return Ok(None)
77 stripped = raw.strip()
78 if not stripped:
79 if self.nullable:
80 return Ok(None)
81 return Err(FieldError("Must be an integer"))
82 try:
83 if "." in stripped:
84 return Err(FieldError("Must be an integer"))
85 return Ok(int(stripped))
86 except (ValueError, TypeError):
87 return Err(FieldError("Must be an integer"))
89 def render_column(self, record: Any, value: float | None) -> Element:
90 if value is None:
91 return Element("span", "\u2014", class_="text-muted")
92 return Element("span", str(int(value)))
95@dataclass(frozen=True, kw_only=True)
96class FloatField(NumberField):
97 """A float-only input field."""
99 def from_form(self, raw: str | None) -> Result[float | None, FieldError]:
100 if raw is None:
101 return Ok(None)
102 stripped = raw.strip()
103 if not stripped:
104 if self.nullable:
105 return Ok(None)
106 return Err(FieldError("Must be a number"))
107 try:
108 return Ok(float(stripped))
109 except (ValueError, TypeError):
110 return Err(FieldError("Must be a number"))
113@dataclass(frozen=True, kw_only=True)
114class CurrencyField(NumberField):
115 """A currency input field with configurable currency symbol."""
117 currency: str = "USD"
119 def render_column(self, record: Any, value: float | None) -> Element:
120 if value is None:
121 return Element("span", "\u2014", class_="text-muted")
122 symbol = CURRENCY_SYMBOLS.get(self.currency, self.currency)
123 return Element("span", f"{symbol}{float(value):.2f}")
125 def from_form(self, raw: str | None) -> Result[float | None, FieldError]:
126 if raw is None:
127 return Ok(None)
128 stripped = raw.strip()
129 if not stripped:
130 if self.nullable:
131 return Ok(None)
132 return Err(FieldError("Must be a number"))
133 try:
134 return Ok(float(stripped))
135 except (ValueError, TypeError):
136 return Err(FieldError("Must be a number"))