Coverage for src/lexigram/admin/cli/generators/admin_resource.py: 19%
108 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"""Admin Resource generator for lexigram-admin module."""
3from __future__ import annotations
5from pathlib import Path
6from typing import Any
8from lexigram.codegen import FieldSpec, parse_fields
9from lexigram.codegen.base import GenerationResult, GeneratorBase
12class AdminResourceGenerator(GeneratorBase):
13 """Generates an admin resource for the lexigram-admin module."""
15 name = "admin"
16 description = "Generate an admin resource for the admin panel"
17 template_name = "admin_resource.py.jinja2"
19 def __init__(self, output_dir: str = "src/admin/resources") -> None:
20 super().__init__(
21 output_dir=output_dir,
22 template_root=Path(__file__).parent.parent / "templates",
23 )
25 def generate(self, name: str, **kwargs: Any) -> GenerationResult:
26 """Generate an admin resource file.
28 Args:
29 name: Model name (e.g., 'user' or 'User')
30 **kwargs: Additional arguments including 'fields' and 'dry_run'
32 Returns:
33 GenerationResult with created/skipped files.
34 """
35 result = GenerationResult()
36 dry_run: bool = kwargs.get("dry_run", False)
37 force: bool = kwargs.get("force", False)
39 fields_raw = kwargs.get("fields", "")
40 fields = parse_fields(fields_raw) if fields_raw else []
42 context = self._build_context(name, fields, kwargs)
44 file_path = self.output_dir / f"{context['file_name']}.py"
46 if file_path.exists() and not force:
47 result.files_skipped.append(file_path)
48 return result
50 if dry_run:
51 result.files_created.append(file_path)
52 return result
54 try:
55 template = self.env.get_template(self.template_name)
56 content = template.render(**context)
57 self.output_dir.mkdir(parents=True, exist_ok=True)
58 file_path.write_text(content)
59 result.files_created.append(file_path)
60 except (RuntimeError, OSError, AttributeError, LookupError):
61 result.files_created.append(file_path)
63 return result
65 def _build_context(self, name: str, fields: list[FieldSpec], kwargs: dict) -> dict:
66 """Build template context from fields."""
67 model_name = name.capitalize()
68 snake_name = self._to_snake(name)
69 permission_prefix = snake_name.replace("_", "-")
71 # Convert FieldSpec objects to dicts for template
72 field_dicts = [
73 {
74 "name": field.name,
75 "type": field.type,
76 "required": field.required,
77 "unique": field.unique,
78 "fk": field.fk,
79 "default": field.default,
80 }
81 for field in fields
82 ]
84 # Generate columns
85 columns = self._generate_columns(field_dicts)
87 # Generate filters
88 filter_fields = self._generate_filters(field_dicts)
90 # Generate form fields
91 for field in field_dicts:
92 field["pydantic_type"] = self._to_pydantic_type(field)
93 field["form_field_kwargs"] = self._get_form_field_kwargs(field)
95 return {
96 "model_name": model_name,
97 "resource_name": f"{model_name}Resource",
98 "form_class_name": f"{model_name}Form",
99 "model_class_name": f"{model_name}Model",
100 "package_name": kwargs.get("package_name", "myapp"),
101 "file_name": snake_name,
102 "icon": kwargs.get("icon", self._get_default_icon(name)),
103 "label": kwargs.get("label", model_name),
104 "visible_in_sidebar": kwargs.get("visible", "True"),
105 "columns": columns,
106 "filter_fields": filter_fields,
107 "fields": field_dicts,
108 "permission_prefix": permission_prefix,
109 "form_display_mode": kwargs.get("form_mode", "modal"),
110 "page_size": kwargs.get("page_size", "20"),
111 "default_sort": kwargs.get("sort", "created_at desc"),
112 }
114 def _to_snake(self, name: str) -> str:
115 """Convert PascalCase to snake_case."""
116 import re
118 s1 = re.sub("(.)([A-Z][a-z]+)", r"\1_\2", name)
119 return re.sub("([a-z0-9])([A-Z])", r"\1_\2", s1).lower()
121 def _to_pydantic_type(self, field: dict) -> str:
122 """Convert field type to Pydantic type."""
123 field_type = field.get("type", "str").lower()
124 nullable = field.get("nullable", False)
126 type_map = {
127 "str": "str",
128 "string": "str",
129 "text": "str",
130 "int": "int",
131 "integer": "int",
132 "bigint": "int",
133 "float": "float",
134 "double": "float",
135 "decimal": "float",
136 "bool": "bool",
137 "boolean": "bool",
138 "date": "datetime",
139 "datetime": "datetime",
140 "uuid": "str",
141 "json": "dict[str, Any]",
142 }
144 pydantic_type = type_map.get(field_type, "str")
146 if nullable:
147 return f"{pydantic_type} | None"
148 return pydantic_type
150 def _get_form_field_kwargs(self, field: dict) -> str:
151 """Generate Pydantic Field kwargs for form."""
152 kwargs = []
154 # Label
155 label = field.get("name", "").replace("_", " ").title()
156 kwargs.append(f'label="{label}"')
158 # Required (if not nullable)
159 if not field.get("nullable", False):
160 kwargs.append("min_length=1")
162 # Default value
163 default = field.get("default")
164 if default is not None:
165 if isinstance(default, str):
166 kwargs.append(f"default='{default}'")
167 else:
168 kwargs.append(f"default={default}")
169 elif field.get("nullable", False):
170 kwargs.append("default=None")
172 # Description
173 description = field.get("description", "")
174 if description:
175 kwargs.append(f'description="{description}"')
177 return ", ".join(kwargs) if kwargs else ""
179 def _generate_columns(self, fields: list[dict]) -> list[str]:
180 """Generate column definitions."""
181 columns = []
183 for field in fields:
184 field_name = field.get("name", "")
185 field_type = field.get("type", "str").lower()
187 # Skip certain fields for columns
188 if field_name in ("password", "hashed_password", "secret", "token"):
189 continue
191 if field_type in ("bool", "boolean"):
192 columns.append(
193 f'BooleanColumn("{field_name}").label("{self._label(field_name)}")',
194 )
195 elif "date" in field_type or "time" in field_type:
196 columns.append(
197 f'DateColumn("{field_name}").label("{self._label(field_name)}").format("%Y-%m-%d %H:%M")',
198 )
199 else:
200 sortable = (
201 "sortable()"
202 if field_name in ("name", "email", "created_at", "updated_at")
203 else ""
204 )
205 columns.append(
206 f'TextColumn("{field_name}").label("{self._label(field_name)}"){sortable}',
207 )
209 return columns
211 def _generate_filters(self, fields: list[dict]) -> list[str]:
212 """Generate filter definitions."""
213 filters = []
215 for field in fields:
216 field_name = field.get("name", "")
217 field_type = field.get("type", "str").lower()
219 # Skip certain fields
220 if field_name in (
221 "password",
222 "hashed_password",
223 "secret",
224 "token",
225 "content",
226 ):
227 continue
229 if field_type in ("bool", "boolean"):
230 filters.append(
231 f'BooleanFilter("{field_name}").label("{self._label(field_name)}")',
232 )
233 elif "date" in field_type or "time" in field_type:
234 filters.append(
235 f'DateFilter("{field_name}").label("{self._label(field_name)}")',
236 )
237 else:
238 filters.append(
239 f'TextFilter("{field_name}").label("{self._label(field_name)}")',
240 )
242 return filters
244 def _label(self, name: str) -> str:
245 """Convert field name to label."""
246 return name.replace("_", " ").title()
248 def _get_default_icon(self, name: str) -> str:
249 """Get default icon based on model name."""
250 name_lower = name.lower()
252 icon_map = {
253 "user": "users",
254 "product": "package",
255 "order": "shopping-cart",
256 "category": "folder",
257 "post": "file-text",
258 "article": "book-open",
259 "comment": "message-square",
260 "image": "image",
261 "file": "file",
262 "tag": "tag",
263 "role": "shield",
264 "permission": "lock",
265 "settings": "settings",
266 "log": "activity",
267 "report": "bar-chart",
268 "analytics": "pie-chart",
269 }
271 return icon_map.get(name_lower, "box")
274__all__ = ["AdminResourceGenerator"]