Coverage for src/lexigram/admin/relations/morph_to.py: 94%
35 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-21 14:56 +0800
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-21 14:56 +0800
1"""MorphTo (polymorphic BelongsTo) relation manager."""
3from __future__ import annotations
5from typing import TYPE_CHECKING, Any
7from lexigram.admin.relations.manager_ext import RelationManager
9if TYPE_CHECKING:
10 from starlette.requests import Request
13class MorphToRelationManager(RelationManager):
14 """Relation manager for polymorphic BelongsTo relationships.
16 Renders a two-tier selector: choose the related type, then
17 choose the record within that type.
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 """
29 morph_name: str = ""
30 morph_types: dict[str, type] = {}
32 @classmethod
33 def table(cls, table_config: Any = None) -> list[Any]:
34 return []
36 async def get_query(self) -> list[Any]:
37 return []
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
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
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 []
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()
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 )
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 )
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>"""
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}