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

81 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, 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 if self.readonly: 

36 kwargs["disabled"] = True 

37 return BelongsTo( 

38 name=self.name, 

39 resource=self.resource, 

40 searchable=self.searchable, 

41 **kwargs, 

42 ).render() 

43 

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

45 if raw is None: 

46 return Ok(None) 

47 stripped = raw.strip() 

48 if not stripped: 

49 if self.nullable: 

50 return Ok(None) 

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

52 return Ok(stripped) 

53 

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

55 if value is None: 

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

57 return Element("span", value) 

58 

59 

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

61class BelongsToField(RelationField): 

62 """A belongs-to relationship field. 

63 

64 Renders a searchable dropdown or record picker for selecting the 

65 related record this record belongs to. 

66 """ 

67 

68 

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

70class HasManyField(RelationField): 

71 """A has-many relationship field. 

72 

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

74 """ 

75 

76 def render_form( # type: ignore[override] 

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

78 ) -> Element: 

79 kwargs: dict[str, Any] = { 

80 "name": self.name, 

81 "choices": self.options, 

82 } 

83 if value is not None: 

84 kwargs["value"] = value 

85 if self.label is not None: 

86 kwargs["label"] = self.label 

87 if errors: 

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

89 if self.placeholder is not None: 

90 kwargs["placeholder"] = self.placeholder 

91 if self.readonly: 

92 kwargs["disabled"] = True 

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

94 

95 def render_column( # type: ignore[override] 

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

97 ) -> Element: 

98 if value is None: 

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

100 count = len(value) 

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

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

103 

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

105 if raw is None: 

106 return Ok(None) 

107 stripped = raw.strip() 

108 if not stripped: 

109 if self.nullable: 

110 return Ok(None) 

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

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

113 return Ok(values) 

114 

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

116 if value is None: 

117 return "" 

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

119 

120 

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

122class MorphField(RelationField): 

123 """A polymorphic relation selector field. 

124 

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

126 record search input. 

127 """ 

128 

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

130 

131 def render_form( 

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

133 ) -> Element: 

134 type_select = Select( 

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

136 choices=self.morph_types, 

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

138 disabled=self.readonly, 

139 ).render() 

140 

141 record_select = Select( 

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

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

144 disabled=self.readonly, 

145 ).render() 

146 

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