Coverage for src/lexigram/admin/relations/morph_many.py: 97%

38 statements  

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

1"""MorphMany (polymorphic HasMany) relation manager.""" 

2 

3from __future__ import annotations 

4 

5from typing import Any 

6 

7from lexigram.admin.relations.manager_ext import RelationManager 

8from lexigram.ui import el, render_to_string 

9 

10 

11class MorphManyRelationManager(RelationManager): 

12 """Relation manager for polymorphic HasMany relationships. 

13 

14 Similar to HasMany but filters related records by morph type 

15 and key. On create, automatically sets the morph type and key. 

16 

17 Example: 

18 class PostCommentsRelationManager(MorphManyRelationManager): 

19 relationship_name = "comments" 

20 morph_name = "commentable" 

21 morph_type_value = "post" 

22 """ 

23 

24 morph_name: str = "" 

25 morph_type_value: str = "" 

26 

27 @classmethod 

28 def table(cls, table_config: Any = None) -> list[Any]: 

29 return [] 

30 

31 async def get_query(self) -> list[Any]: 

32 return [] 

33 

34 async def render(self, request: Any, resource_name: str = "") -> str: 

35 """Render the morph-many relation panel as HTML.""" 

36 items = await self.get_query() 

37 rel_name = self.get_relationship_name() 

38 parent_id = self.parent_id 

39 

40 rows: list[Any] = [] 

41 for item in items: 

42 item_id = str(getattr(item, "id", str(id(item)))) 

43 label = str(getattr(item, "name", item_id)) 

44 

45 actions: list[Any] = [] 

46 if self.inline_edit: 

47 edit_url = f"/admin/{resource_name}/{parent_id}/relations/{rel_name}/{item_id}/edit" 

48 actions.append( 

49 el( 

50 "a", 

51 "Edit", 

52 href=edit_url, 

53 hx_get=edit_url, 

54 hx_target="closest tr", 

55 hx_swap="outerHTML", 

56 class_="text-primary-600 hover:text-primary-800 text-sm mr-2", 

57 ) 

58 ) 

59 if self.inline_delete: 

60 delete_url = ( 

61 f"/admin/{resource_name}/{parent_id}/relations/{rel_name}/{item_id}" 

62 ) 

63 actions.append( 

64 el( 

65 "a", 

66 "Delete", 

67 href=delete_url, 

68 hx_delete=delete_url, 

69 hx_confirm="Delete this record?", 

70 hx_target="closest tr", 

71 hx_swap="outerHTML", 

72 class_="text-destructive hover:text-destructive/90 text-sm", 

73 ) 

74 ) 

75 

76 rows.append( 

77 el( 

78 "tr", 

79 el("td", label, class_="px-4 py-2 text-sm text-foreground"), 

80 el( 

81 "td", 

82 item_id, 

83 class_="px-4 py-2 text-sm text-muted-foreground", 

84 ), 

85 el("td", *actions, class_="px-4 py-2 text-sm"), 

86 ) 

87 ) 

88 

89 header: list[Any] = [] 

90 if self.inline_create: 

91 create_url = f"/admin/{resource_name}/{parent_id}/relations/{rel_name}/new" 

92 header.append( 

93 el( 

94 "div", 

95 el( 

96 "a", 

97 "+ Add ", 

98 rel_name.replace("_", " ").title(), 

99 href=create_url, 

100 hx_get=create_url, 

101 hx_target=f"#relation-panel-{rel_name}", 

102 hx_swap="beforeend", 

103 class_="inline-flex items-center px-3 py-1.5 text-sm font-medium text-primary-600 bg-primary-50 dark:bg-primary-900/30 dark:text-primary-400 rounded-lg hover:bg-primary-100 transition-colors", 

104 ), 

105 class_="mb-3", 

106 ) 

107 ) 

108 

109 table = el( 

110 "table", 

111 el( 

112 "thead", 

113 el( 

114 "tr", 

115 el( 

116 "th", 

117 "Record", 

118 class_="px-4 py-2 text-left text-xs font-medium text-muted-foreground uppercase", 

119 ), 

120 el( 

121 "th", 

122 "ID", 

123 class_="px-4 py-2 text-left text-xs font-medium text-muted-foreground uppercase", 

124 ), 

125 el( 

126 "th", 

127 "Actions", 

128 class_="px-4 py-2 text-left text-xs font-medium text-muted-foreground uppercase", 

129 ), 

130 ), 

131 class_="bg-muted dark:bg-card", 

132 ), 

133 el("tbody", *rows, class_="divide-y divide-border"), 

134 class_="min-w-full divide-y divide-border", 

135 ) 

136 

137 return render_to_string( 

138 el( 

139 "div", 

140 el( 

141 "h3", 

142 rel_name.replace("_", " ").title(), 

143 class_="text-lg font-medium text-foreground mb-4", 

144 ), 

145 *header, 

146 table, 

147 self._render_empty_state(rows), 

148 class_="relation-panel p-4", 

149 id=f"relation-panel-{rel_name}", 

150 ) 

151 ) 

152 

153 def _render_empty_state(self, rows: list[Any]) -> Any: 

154 """Return an empty-state paragraph element, or None when rows exist.""" 

155 if rows: 

156 return None 

157 return el( 

158 "p", 

159 "No related records found.", 

160 class_="text-sm text-muted-foreground italic mt-4", 

161 )