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

35 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-08-13 22:14 +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 

8 

9 

10class MorphManyRelationManager(RelationManager): 

11 """Relation manager for polymorphic HasMany relationships. 

12 

13 Similar to HasMany but filters related records by morph type 

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

15 

16 Example: 

17 class PostCommentsRelationManager(MorphManyRelationManager): 

18 relationship_name = "comments" 

19 morph_name = "commentable" 

20 morph_type_value = "post" 

21 """ 

22 

23 morph_name: str = "" 

24 morph_type_value: str = "" 

25 

26 @classmethod 

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

28 return [] 

29 

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

31 return [] 

32 

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

34 items = await self.get_query() 

35 rel_name = self.get_relationship_name() 

36 

37 rows_html = "" 

38 for item in items: 

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

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

41 

42 actions = "" 

43 if self.inline_edit: 

44 edit_url = f"/admin/{resource_name}/{self.parent_id}/relations/{rel_name}/{item_id}/edit" 

45 actions += f'<a href="{edit_url}" hx-get="{edit_url}" hx-target="closest tr" hx-swap="outerHTML" class="text-primary-600 hover:text-primary-800 text-sm mr-2">Edit</a>' 

46 if self.inline_delete: 

47 delete_url = f"/admin/{resource_name}/{self.parent_id}/relations/{rel_name}/{item_id}" 

48 actions += f'<a href="{delete_url}" hx-delete="{delete_url}" hx-confirm="Delete this record?" hx-target="closest tr" hx-swap="outerHTML" class="text-destructive hover:text-destructive/90 text-sm">Delete</a>' 

49 

50 rows_html += f"""<tr> 

51 <td class="px-4 py-2 text-sm text-foreground">{label}</td> 

52 <td class="px-4 py-2 text-sm text-muted-foreground">{item_id}</td> 

53 <td class="px-4 py-2 text-sm">{actions}</td> 

54 </tr>""" 

55 

56 header = "" 

57 if self.inline_create: 

58 create_url = ( 

59 f"/admin/{resource_name}/{self.parent_id}/relations/{rel_name}/new" 

60 ) 

61 header = f"""<div class="mb-3"> 

62 <a href="{create_url}" hx-get="{create_url}" hx-target="#relation-panel-{rel_name}" hx-swap="beforeend" 

63 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"> 

64 + Add {rel_name.replace("_", " ").title()} 

65 </a> 

66 </div>""" 

67 

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

69 <h3 class="text-lg font-medium text-foreground mb-4">{rel_name.replace("_", " ").title()}</h3> 

70 {header} 

71 <table class="min-w-full divide-y divide-border"> 

72 <thead class="bg-muted dark:bg-card"> 

73 <tr> 

74 <th class="px-4 py-2 text-left text-xs font-medium text-muted-foreground uppercase">Record</th> 

75 <th class="px-4 py-2 text-left text-xs font-medium text-muted-foreground uppercase">ID</th> 

76 <th class="px-4 py-2 text-left text-xs font-medium text-muted-foreground uppercase">Actions</th> 

77 </tr> 

78 </thead> 

79 <tbody class="divide-y divide-border">{rows_html}</tbody> 

80 </table> 

81 {self._render_empty_state(rows_html)} 

82 </div>""" 

83 

84 def _render_empty_state(self, rows_html: str) -> str: 

85 if rows_html: 

86 return "" 

87 return '<p class="text-sm text-muted-foreground italic mt-4">No related records found.</p>'