Coverage for src / lexigram / admin / schema / relation.py: 34%

77 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-08-13 22:14 +0800

1from __future__ import annotations 

2 

3from dataclasses import dataclass, field 

4from typing import Any 

5 

6from lexigram.admin.schema.exceptions import FieldError 

7from lexigram.admin.schema.select import SelectField 

8from lexigram.result import Err, Ok, Result 

9from lexigram.ui import BelongsTo, Element, MultiSelect, Select 

10 

11 

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

13class RelationField(SelectField): 

14 """Base field for related-record selection. 

15 

16 Renders a record picker widget (BelongsTo) for selecting a related 

17 database record. 

18 """ 

19 

20 resource: str 

21 searchable: bool = False 

22 

23 def render_form( 

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

25 ) -> Element: 

26 kwargs: dict[str, Any] = {} 

27 if value is not None: 

28 kwargs["value"] = value 

29 if self.label is not None: 

30 kwargs["label"] = self.label 

31 if errors: 

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

33 if self.options: 

34 kwargs["choices"] = self.options 

35 return BelongsTo( 

36 name=self.name, 

37 resource=self.resource, 

38 searchable=self.searchable, 

39 **kwargs, 

40 ).render() 

41 

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

43 if raw is None: 

44 return Ok(None) 

45 stripped = raw.strip() 

46 if not stripped: 

47 if self.nullable: 

48 return Ok(None) 

49 return Err(FieldError("Invalid option")) 

50 return Ok(stripped) 

51 

52 def render_column(self, record: Any, value: str | None) -> Element: 

53 if value is None: 

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

55 return Element("span", value) 

56 

57 

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

59class BelongsToField(RelationField): 

60 """A belongs-to relationship field. 

61 

62 Renders a searchable dropdown or record picker for selecting the 

63 related record this record belongs to. 

64 """ 

65 

66 

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

68class HasManyField(RelationField): 

69 """A has-many relationship field. 

70 

71 Renders a multi-select widget for picking multiple related records. 

72 """ 

73 

74 def render_form( # type: ignore[override] 

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

76 ) -> Element: 

77 kwargs: dict[str, Any] = { 

78 "name": self.name, 

79 "choices": self.options, 

80 } 

81 if value is not None: 

82 kwargs["value"] = value 

83 if self.label is not None: 

84 kwargs["label"] = self.label 

85 if errors: 

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

87 if self.placeholder is not None: 

88 kwargs["placeholder"] = self.placeholder 

89 return MultiSelect(**kwargs).render() 

90 

91 def render_column( # type: ignore[override] 

92 self, record: Any, value: list[str] | None 

93 ) -> Element: 

94 if value is None: 

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

96 count = len(value) 

97 label = "item" if count == 1 else "items" 

98 return Element("span", f"{count} {label}") 

99 

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

101 if raw is None: 

102 return Ok(None) 

103 stripped = raw.strip() 

104 if not stripped: 

105 if self.nullable: 

106 return Ok(None) 

107 return Err(FieldError("Invalid option")) 

108 values = [v.strip() for v in stripped.split(",") if v.strip()] 

109 return Ok(values) 

110 

111 def to_form(self, value: list[str] | None) -> str: # type: ignore[override] 

112 if value is None: 

113 return "" 

114 return ",".join(str(v) for v in value) 

115 

116 

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

118class MorphField(RelationField): 

119 """A polymorphic relation selector field. 

120 

121 Renders a type selector (which related model type) alongside a 

122 record search input. 

123 """ 

124 

125 morph_types: list[tuple[str, str]] = field(default_factory=list) 

126 

127 def render_form( 

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

129 ) -> Element: 

130 type_select = Select( 

131 name=f"{self.name}_type", 

132 choices=self.morph_types, 

133 value=value if value is not None else "", 

134 ).render() 

135 

136 record_select = Select( 

137 name=f"{self.name}_id", 

138 value=value if value is not None else "", 

139 ).render() 

140 

141 return Element("div", type_select, record_select, class_="morph-field")