Coverage for src / lexigram / admin / relations / morph_to.py: 40%

35 statements  

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

1"""MorphTo (polymorphic BelongsTo) relation manager.""" 

2 

3from __future__ import annotations 

4 

5from typing import TYPE_CHECKING, Any 

6 

7from lexigram.admin.relations.manager_ext import RelationManager 

8 

9if TYPE_CHECKING: 

10 from starlette.requests import Request 

11 

12 

13class MorphToRelationManager(RelationManager): 

14 """Relation manager for polymorphic BelongsTo relationships. 

15 

16 Renders a two-tier selector: choose the related type, then 

17 choose the record within that type. 

18 

19 Example: 

20 class CommentableRelationManager(MorphToRelationManager): 

21 relationship_name = "commentable" 

22 morph_name = "commentable" 

23 morph_types = { 

24 "post": PostResource, 

25 "video": VideoResource, 

26 } 

27 """ 

28 

29 morph_name: str = "" 

30 morph_types: dict[str, type] = {} 

31 

32 @classmethod 

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

34 return [] 

35 

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

37 return [] 

38 

39 def __init__( 

40 self, 

41 parent_id: Any = None, 

42 parent: Any = None, 

43 current_type: str | None = None, 

44 current_id: str | None = None, 

45 ): 

46 super().__init__(parent_id=parent_id, parent=parent) 

47 self.current_type = current_type 

48 self.current_id = current_id 

49 

50 async def get_available_types(self) -> list[dict[str, str]]: 

51 """Return available morph types as label/value pairs.""" 

52 result: list[dict[str, str]] = [] 

53 for key, resource_cls in self.morph_types.items(): 

54 label = getattr(resource_cls, "name", key.replace("_", " ").title()) 

55 result.append({"value": key, "label": label}) 

56 return result 

57 

58 async def search_records( 

59 self, type_key: str, query: str = "" 

60 ) -> list[dict[str, str]]: 

61 """Search records of a given morph type.""" 

62 return [] 

63 

64 async def render(self, request: Request, resource_name: str = "") -> str: 

65 types = await self.get_available_types() 

66 rel_name = self.get_relationship_name() 

67 

68 type_options = "".join( 

69 f'<option value="{t["value"]}" {"selected" if t["value"] == self.current_type else ""}>{t["label"]}</option>' 

70 for t in types 

71 ) 

72 

73 current_id_html = "" 

74 if self.current_id: 

75 current_id_html = ( 

76 f'<div class="mt-2 text-sm text-muted-foreground">' 

77 f" Currently: {self.current_type} #{self.current_id}" 

78 f"</div>" 

79 ) 

80 

81 return f"""<div class="relation-panel p-4" id="relation-panel-{rel_name}"> 

82 <div class="mb-4"> 

83 <label class="block text-sm font-medium text-foreground mb-1">Type</label> 

84 <select class="block w-full rounded-lg border-border dark:bg-card text-sm" 

85 name="{rel_name}_type" 

86 hx-get="/admin/{resource_name}/{self.parent_id}/relations/{rel_name}/records" 

87 hx-target="#{rel_name}-records" hx-trigger="change"> 

88 <option value="">Select type...</option> 

89 {type_options} 

90 </select> 

91 </div> 

92 <div class="mb-4"> 

93 <label class="block text-sm font-medium text-foreground mb-1">Record</label> 

94 <input type="text" class="block w-full rounded-lg border-border dark:bg-card text-sm mb-2" 

95 placeholder="Search records..." 

96 hx-trigger="keyup changed delay:300ms" 

97 hx-get="/admin/{resource_name}/{self.parent_id}/relations/{rel_name}/records" 

98 hx-target="#{rel_name}-records" hx-include="[name='{rel_name}_type']" /> 

99 <div id="{rel_name}-records" class="max-h-48 overflow-y-auto border border-border rounded-lg"> 

100 {current_id_html} 

101 </div> 

102 </div> 

103 </div>""" 

104 

105 async def get_selected_record(self) -> Any | None: 

106 """Return the currently selected record, if any.""" 

107 if not self.current_type or not self.current_id: 

108 return None 

109 return {"type": self.current_type, "id": self.current_id}