Coverage for src/lexigram/admin/cli/generators/admin_resource.py: 0%

108 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-08-24 23:18 +0800

1"""Admin Resource generator for lexigram-admin module.""" 

2 

3from __future__ import annotations 

4 

5from pathlib import Path 

6from typing import Any 

7 

8from lexigram.codegen import FieldSpec, parse_fields 

9from lexigram.codegen.base import GenerationResult, GeneratorBase 

10 

11 

12class AdminResourceGenerator(GeneratorBase): 

13 """Generates an admin resource for the lexigram-admin module.""" 

14 

15 name = "admin" 

16 description = "Generate an admin resource for the admin panel" 

17 template_name = "admin_resource.py.jinja2" 

18 

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 ) 

24 

25 def generate(self, name: str, **kwargs: Any) -> GenerationResult: 

26 """Generate an admin resource file. 

27 

28 Args: 

29 name: Model name (e.g., 'user' or 'User') 

30 **kwargs: Additional arguments including 'fields' and 'dry_run' 

31 

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) 

38 

39 fields_raw = kwargs.get("fields", "") 

40 fields = parse_fields(fields_raw) if fields_raw else [] 

41 

42 context = self._build_context(name, fields, kwargs) 

43 

44 file_path = self.output_dir / f"{context['file_name']}.py" 

45 

46 if file_path.exists() and not force: 

47 result.files_skipped.append(file_path) 

48 return result 

49 

50 if dry_run: 

51 result.files_created.append(file_path) 

52 return result 

53 

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) 

62 

63 return result 

64 

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("_", "-") 

70 

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 ] 

83 

84 # Generate columns 

85 columns = self._generate_columns(field_dicts) 

86 

87 # Generate filters 

88 filter_fields = self._generate_filters(field_dicts) 

89 

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) 

94 

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 } 

113 

114 def _to_snake(self, name: str) -> str: 

115 """Convert PascalCase to snake_case.""" 

116 import re 

117 

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() 

120 

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) 

125 

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 } 

143 

144 pydantic_type = type_map.get(field_type, "str") 

145 

146 if nullable: 

147 return f"{pydantic_type} | None" 

148 return pydantic_type 

149 

150 def _get_form_field_kwargs(self, field: dict) -> str: 

151 """Generate Pydantic Field kwargs for form.""" 

152 kwargs = [] 

153 

154 # Label 

155 label = field.get("name", "").replace("_", " ").title() 

156 kwargs.append(f'label="{label}"') 

157 

158 # Required (if not nullable) 

159 if not field.get("nullable", False): 

160 kwargs.append("min_length=1") 

161 

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

171 

172 # Description 

173 description = field.get("description", "") 

174 if description: 

175 kwargs.append(f'description="{description}"') 

176 

177 return ", ".join(kwargs) if kwargs else "" 

178 

179 def _generate_columns(self, fields: list[dict]) -> list[str]: 

180 """Generate column definitions.""" 

181 columns = [] 

182 

183 for field in fields: 

184 field_name = field.get("name", "") 

185 field_type = field.get("type", "str").lower() 

186 

187 # Skip certain fields for columns 

188 if field_name in ("password", "hashed_password", "secret", "token"): 

189 continue 

190 

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 ) 

208 

209 return columns 

210 

211 def _generate_filters(self, fields: list[dict]) -> list[str]: 

212 """Generate filter definitions.""" 

213 filters = [] 

214 

215 for field in fields: 

216 field_name = field.get("name", "") 

217 field_type = field.get("type", "str").lower() 

218 

219 # Skip certain fields 

220 if field_name in ( 

221 "password", 

222 "hashed_password", 

223 "secret", 

224 "token", 

225 "content", 

226 ): 

227 continue 

228 

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 ) 

241 

242 return filters 

243 

244 def _label(self, name: str) -> str: 

245 """Convert field name to label.""" 

246 return name.replace("_", " ").title() 

247 

248 def _get_default_icon(self, name: str) -> str: 

249 """Get default icon based on model name.""" 

250 name_lower = name.lower() 

251 

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 } 

270 

271 return icon_map.get(name_lower, "box") 

272 

273 

274__all__ = ["AdminResourceGenerator"]