Coverage for .tox / coverage / lib / python3.11 / site-packages / wuttaweb / views / tables.py: 100%

208 statements  

« prev     ^ index     » next       coverage.py v7.13.1, created at 2025-12-31 19:25 -0600

1# -*- coding: utf-8; -*- 

2################################################################################ 

3# 

4# wuttaweb -- Web App for Wutta Framework 

5# Copyright © 2024-2025 Lance Edgar 

6# 

7# This file is part of Wutta Framework. 

8# 

9# Wutta Framework is free software: you can redistribute it and/or modify it 

10# under the terms of the GNU General Public License as published by the Free 

11# Software Foundation, either version 3 of the License, or (at your option) any 

12# later version. 

13# 

14# Wutta Framework is distributed in the hope that it will be useful, but 

15# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or 

16# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for 

17# more details. 

18# 

19# You should have received a copy of the GNU General Public License along with 

20# Wutta Framework. If not, see <http://www.gnu.org/licenses/>. 

21# 

22################################################################################ 

23""" 

24Table Views 

25""" 

26 

27import os 

28import sys 

29 

30from alembic import command as alembic_command 

31from sqlalchemy_utils import get_mapper 

32from mako.lookup import TemplateLookup 

33from webhelpers2.html import HTML 

34 

35from wuttjamaican.db.conf import ( 

36 check_alembic_current, 

37 make_alembic_config, 

38 get_alembic_scriptdir, 

39) 

40 

41from wuttaweb.views import MasterView 

42 

43 

44class AppTableView(MasterView): # pylint: disable=abstract-method 

45 """ 

46 Master view showing all tables in the :term:`app database`. 

47 

48 Default route prefix is ``app_tables``. 

49 

50 Notable URLs provided by this class: 

51 

52 * ``/tables/app/`` 

53 * ``/tables/app/XXX`` 

54 """ 

55 

56 # pylint: disable=duplicate-code 

57 model_name = "app_table" 

58 model_title = "App Table" 

59 model_key = "name" 

60 url_prefix = "/tables/app" 

61 filterable = False 

62 sortable = True 

63 sort_on_backend = False 

64 paginated = True 

65 paginate_on_backend = False 

66 creatable = True 

67 editable = False 

68 deletable = False 

69 # pylint: enable=duplicate-code 

70 

71 labels = { 

72 "name": "Table Name", 

73 "module_name": "Module", 

74 "module_file": "File", 

75 } 

76 

77 grid_columns = [ 

78 "name", 

79 "schema", 

80 # "row_count", 

81 ] 

82 

83 sort_defaults = "name" 

84 

85 form_fields = [ 

86 "name", 

87 "schema", 

88 "model_name", 

89 "description", 

90 # "row_count", 

91 "module_name", 

92 "module_file", 

93 ] 

94 

95 has_rows = True 

96 rows_title = "Columns" 

97 rows_filterable = False 

98 rows_sort_defaults = "sequence" 

99 rows_sort_on_backend = False 

100 rows_paginated = True 

101 rows_paginate_on_backend = False 

102 rows_viewable = False 

103 

104 row_grid_columns = [ 

105 "sequence", 

106 "column_name", 

107 "data_type", 

108 "nullable", 

109 "description", 

110 ] 

111 

112 def normalize_table(self, table): # pylint: disable=missing-function-docstring 

113 record = { 

114 "name": table.name, 

115 "schema": table.schema or "", 

116 # "row_count": 42, 

117 } 

118 

119 try: 

120 cls = get_mapper(table).class_ 

121 except ValueError: 

122 pass 

123 else: 

124 record.update( 

125 { 

126 "model_class": cls, 

127 "model_name": cls.__name__, 

128 "model_name_dotted": f"{cls.__module__}.{cls.__name__}", 

129 "description": (cls.__doc__ or "").strip(), 

130 "module_name": cls.__module__, 

131 "module_file": sys.modules[cls.__module__].__file__, 

132 } 

133 ) 

134 

135 return record 

136 

137 def get_grid_data( # pylint: disable=empty-docstring 

138 self, columns=None, session=None 

139 ): 

140 """ """ 

141 model = self.app.model 

142 data = [] 

143 

144 for table in model.Base.metadata.tables.values(): 

145 data.append(self.normalize_table(table)) 

146 

147 return data 

148 

149 def configure_grid(self, grid): # pylint: disable=empty-docstring 

150 """ """ 

151 g = grid 

152 super().configure_grid(g) 

153 

154 # nb. show more tables by default 

155 g.pagesize = 50 

156 

157 # schema 

158 g.set_searchable("schema") 

159 

160 # name 

161 g.set_searchable("name") 

162 g.set_link("name") 

163 

164 def get_instance( # pylint: disable=empty-docstring,arguments-differ,unused-argument 

165 self, **kwargs 

166 ): 

167 """ """ 

168 if "_cached_instance" not in self.__dict__: 

169 model = self.app.model 

170 

171 name = self.request.matchdict["name"] 

172 table = model.Base.metadata.tables[name] 

173 

174 # nb. sometimes need the real table reference later when 

175 # dealing with an instance view 

176 data = self.normalize_table(table) 

177 data["table"] = table 

178 

179 self.__dict__["_cached_instance"] = data 

180 

181 return self.__dict__["_cached_instance"] 

182 

183 def get_instance_title(self, instance): # pylint: disable=empty-docstring 

184 """ """ 

185 return instance["name"] 

186 

187 def configure_form(self, form): # pylint: disable=empty-docstring 

188 """ """ 

189 f = form 

190 super().configure_form(f) 

191 

192 # description 

193 f.set_widget("description", "notes") 

194 

195 def get_xref_buttons(self, obj): 

196 """ 

197 By default this returns a list of buttons for each 

198 :class:`~wuttaweb.views.master.MasterView` subclass registered 

199 in the app for the current table model. Also a button to make 

200 a new Master View class, if permissions allow. 

201 

202 See also parent method docs, 

203 :meth:`~wuttaweb.views.master.MasterView.get_xref_buttons()` 

204 """ 

205 table = obj 

206 buttons = [] 

207 

208 # nb. we do not omit any buttons due to lack of permission 

209 # here. all buttons are shown for anyone seeing this page. 

210 # this is for sake of clarity so admin users are aware of what 

211 # is *possible* within the app etc. 

212 master_views = self.request.registry.settings.get("wuttaweb_master_views", {}) 

213 model_views = master_views.get(table["model_class"], []) 

214 for view in model_views: 

215 buttons.append( 

216 self.make_button( 

217 view.get_model_title_plural(), 

218 primary=True, 

219 url=self.request.route_url(view.get_route_prefix()), 

220 icon_left="eye", 

221 ) 

222 ) 

223 

224 # only add "new master view" button if user has perm 

225 if self.request.has_perm("master_views.create"): 

226 # nb. separate slightly from others 

227 buttons.append(HTML.tag("br")) 

228 buttons.append( 

229 self.make_button( 

230 "New Master View", 

231 url=self.request.route_url("master_views.create"), 

232 icon_left="plus", 

233 ) 

234 ) 

235 

236 return buttons 

237 

238 def get_row_grid_data(self, obj): # pylint: disable=empty-docstring 

239 """ """ 

240 table = obj 

241 data = [] 

242 for i, column in enumerate(table["table"].columns, 1): 

243 data.append( 

244 { 

245 "column": column, 

246 "sequence": i, 

247 "column_name": column.name, 

248 "data_type": str(repr(column.type)), 

249 "nullable": column.nullable, 

250 "description": (column.doc or "").strip(), 

251 } 

252 ) 

253 return data 

254 

255 def configure_row_grid(self, grid): # pylint: disable=empty-docstring 

256 """ """ 

257 g = grid 

258 super().configure_row_grid(g) 

259 

260 # nb. try not to hide any columns by default 

261 g.pagesize = 100 

262 

263 # sequence 

264 g.set_label("sequence", "Seq.") 

265 

266 # column_name 

267 g.set_searchable("column_name") 

268 

269 # data_type 

270 g.set_searchable("data_type") 

271 

272 # nullable 

273 g.set_renderer("nullable", "boolean") 

274 

275 # description 

276 g.set_searchable("description") 

277 g.set_renderer("description", self.render_column_description) 

278 

279 def render_column_description( # pylint: disable=missing-function-docstring,unused-argument 

280 self, column, field, value 

281 ): 

282 if not value: 

283 return "" 

284 

285 max_length = 100 

286 if len(value) <= max_length: 

287 return value 

288 

289 return HTML.tag("span", title=value, c=f"{value[:max_length]} ...") 

290 

291 def get_template_context(self, context): # pylint: disable=empty-docstring 

292 """ """ 

293 if self.creating: 

294 model = self.app.model 

295 

296 # alembic current 

297 context["alembic_is_current"] = check_alembic_current(self.config) 

298 

299 # existing tables 

300 # TODO: any reason this should check grid data instead of metadata? 

301 unwanted = ["transaction", "transaction_meta"] 

302 context["existing_tables"] = [ 

303 {"name": table} 

304 for table in sorted(model.Base.metadata.tables) 

305 if table not in unwanted and not table.endswith("_version") 

306 ] 

307 

308 # model dir 

309 context["model_dir"] = os.path.dirname(model.__file__) 

310 

311 # migration branch 

312 script = get_alembic_scriptdir(self.config) 

313 branch_options = self.get_migration_branch_options(script) 

314 context["migration_branch_options"] = branch_options 

315 branch = self.config.get( 

316 f"{self.config.appname}.alembic.default_revise_branch" 

317 ) 

318 if not branch and len(branch_options) == 1: 

319 branch = branch_options[0] 

320 context["migration_branch"] = branch 

321 

322 return context 

323 

324 # TODO: this is effectivey duplicated in AlembicMigrationView.get_revise_branch_options() 

325 def get_migration_branch_options( # pylint: disable=missing-function-docstring 

326 self, script 

327 ): 

328 branches = set() 

329 for rev in script.get_revisions(script.get_heads()): 

330 branches.update(rev.branch_labels) 

331 return sorted(branches) 

332 

333 def wizard_action(self): # pylint: disable=too-many-return-statements 

334 """ 

335 AJAX view to handle various actions for the "new table" wizard. 

336 """ 

337 data = self.request.json_body 

338 action = data.get("action", "").strip() 

339 try: 

340 

341 # nb. cannot use match/case statement until python 3.10, but this 

342 # project technically still supports python 3.8 

343 if action == "write_model_file": 

344 return self.write_model_file(data) 

345 if action == "check_model": 

346 return self.check_model(data) 

347 if action == "write_revision_script": 

348 return self.write_revision_script(data) 

349 if action == "migrate_db": 

350 return self.migrate_db(data) 

351 if action == "check_table": 

352 return self.check_table(data) 

353 if action == "": 

354 return {"error": "Must specify the action to perform."} 

355 return {"error": f"Unknown action requested: {action}"} 

356 

357 except Exception as err: # pylint: disable=broad-exception-caught 

358 return {"error": f"Unexpected error occurred: {err}"} 

359 

360 def write_model_file(self, data): # pylint: disable=missing-function-docstring 

361 model = self.app.model 

362 path = data["module_file"] 

363 

364 if os.path.exists(path): 

365 if data["overwrite"]: 

366 os.remove(path) 

367 else: 

368 return {"error": "File already exists"} 

369 

370 for column in data["columns"]: 

371 if column["data_type"]["type"] == "_fk_uuid_" and column["relationship"]: 

372 name = column["relationship"] 

373 

374 table = model.Base.metadata.tables[column["data_type"]["reference"]] 

375 mapper = get_mapper(table) 

376 reference_model = mapper.class_.__name__ 

377 

378 column["relationship"] = { 

379 "name": name, 

380 "reference_model": reference_model, 

381 } 

382 

383 # TODO: make templates dir configurable? 

384 templates = [self.app.resource_path("wuttaweb:code-templates")] 

385 table_templates = TemplateLookup(directories=templates) 

386 

387 template = table_templates.get_template("/new-table.mako") 

388 content = template.render(**data) 

389 with open(path, "wt", encoding="utf_8") as f: 

390 f.write(content) 

391 

392 return {} 

393 

394 def check_model(self, data): # pylint: disable=missing-function-docstring 

395 model = self.app.model 

396 model_name = data["model_name"] 

397 

398 if not hasattr(model, model_name): 

399 return { 

400 "problem": "class not found in app model", 

401 "model": model.__name__, 

402 } 

403 

404 return {} 

405 

406 def write_revision_script(self, data): # pylint: disable=missing-function-docstring 

407 alembic_config = make_alembic_config(self.config) 

408 

409 script = alembic_command.revision( 

410 alembic_config, 

411 autogenerate=True, 

412 head=f"{data['branch']}@head", 

413 message=data["message"], 

414 ) 

415 

416 return {"script": script.path} 

417 

418 def migrate_db( # pylint: disable=missing-function-docstring,unused-argument 

419 self, data 

420 ): 

421 alembic_config = make_alembic_config(self.config) 

422 alembic_command.upgrade(alembic_config, "heads") 

423 return {} 

424 

425 def check_table(self, data): # pylint: disable=missing-function-docstring 

426 model = self.app.model 

427 name = data["name"] 

428 

429 table = model.Base.metadata.tables.get(name) 

430 if table is None: 

431 return {"problem": "table does not exist in app model"} 

432 

433 session = self.Session() 

434 count = session.query(table).count() 

435 

436 route_prefix = self.get_route_prefix() 

437 url = self.request.route_url(f"{route_prefix}.view", name=name) 

438 return {"url": url, "count": count} 

439 

440 @classmethod 

441 def defaults(cls, config): # pylint: disable=empty-docstring 

442 """ """ 

443 cls._apptable_defaults(config) 

444 cls._defaults(config) 

445 

446 # pylint: disable=duplicate-code 

447 @classmethod 

448 def _apptable_defaults(cls, config): 

449 route_prefix = cls.get_route_prefix() 

450 permission_prefix = cls.get_permission_prefix() 

451 model_title_plural = cls.get_model_title_plural() 

452 url_prefix = cls.get_url_prefix() 

453 

454 # fix permission group 

455 config.add_wutta_permission_group( 

456 permission_prefix, model_title_plural, overwrite=False 

457 ) 

458 

459 # wizard actions 

460 config.add_route( 

461 f"{route_prefix}.wizard_action", 

462 f"{url_prefix}/new/wizard-action", 

463 request_method="POST", 

464 ) 

465 config.add_view( 

466 cls, 

467 attr="wizard_action", 

468 route_name=f"{route_prefix}.wizard_action", 

469 renderer="json", 

470 permission=f"{permission_prefix}.create", 

471 ) 

472 

473 # pylint: enable=duplicate-code 

474 

475 

476def defaults(config, **kwargs): # pylint: disable=missing-function-docstring 

477 base = globals() 

478 

479 AppTableView = kwargs.get( # pylint: disable=invalid-name,redefined-outer-name 

480 "AppTableView", base["AppTableView"] 

481 ) 

482 AppTableView.defaults(config) 

483 

484 

485def includeme(config): # pylint: disable=missing-function-docstring 

486 defaults(config)