Coverage for .tox/coverage/lib/python3.13/site-packages/wuttaweb/views/tables.py: 100%
211 statements
« prev ^ index » next coverage.py v7.16.1, created at 2026-09-20 16:54 -0500
« prev ^ index » next coverage.py v7.16.1, created at 2026-09-20 16:54 -0500
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"""
27import os
28import sys
30from alembic import command as alembic_command
31from sqlalchemy_utils import get_mapper
32from mako.lookup import TemplateLookup
33from webhelpers2.html import HTML
35from wuttjamaican.db.conf import (
36 check_alembic_current,
37 make_alembic_config,
38 get_alembic_scriptdir,
39)
41from wuttaweb.views import MasterView
44class AppTableView(MasterView): # pylint: disable=abstract-method
45 """
46 Master view showing all tables in the :term:`app database`.
48 Default route prefix is ``app_tables``.
50 Notable URLs provided by this class:
52 * ``/tables/app/``
53 * ``/tables/app/XXX``
54 """
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
71 labels = {
72 "name": "Table Name",
73 "module_name": "Module",
74 "module_file": "File",
75 }
77 grid_columns = [
78 "name",
79 "schema",
80 # "row_count",
81 ]
83 sort_defaults = "name"
85 form_fields = [
86 "name",
87 "schema",
88 "model_name",
89 "description",
90 # "row_count",
91 "module_name",
92 "module_file",
93 ]
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
104 row_grid_columns = [
105 "sequence",
106 "column_name",
107 "data_type",
108 "nullable",
109 "description",
110 ]
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 }
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 )
135 return record
137 def get_grid_data( # pylint: disable=empty-docstring
138 self, columns=None, session=None
139 ):
140 """ """
141 model = self.app.model
142 data = []
144 for table in model.Base.metadata.tables.values():
145 data.append(self.normalize_table(table))
147 return data
149 def configure_grid(self, grid): # pylint: disable=empty-docstring
150 """ """
151 g = grid
152 super().configure_grid(g)
154 # nb. show more tables by default
155 g.pagesize = 50
157 # schema
158 g.set_searchable("schema")
160 # name
161 g.set_searchable("name")
162 g.set_link("name")
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
171 name = self.request.matchdict["name"]
172 table = model.Base.metadata.tables[name]
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
179 self.__dict__["_cached_instance"] = data
181 return self.__dict__["_cached_instance"]
183 def get_instance_title(self, instance): # pylint: disable=empty-docstring
184 """ """
185 return instance["name"]
187 def configure_form(self, form): # pylint: disable=empty-docstring
188 """ """
189 f = form
190 super().configure_form(f)
192 # description
193 f.set_widget("description", "notes")
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.
202 See also parent method docs,
203 :meth:`~wuttaweb.views.master.MasterView.get_xref_buttons()`
204 """
205 table = obj
206 buttons = []
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 )
224 # only add "new master view" button if user has perm
225 if self.request.has_perm("master_views.create"):
226 query = None
227 if table.get("model_name"):
228 query = {"modelClass": table["model_name"]}
230 # nb. separate slightly from others
231 buttons.append(HTML.tag("br"))
232 buttons.append(
233 self.make_button(
234 "New Master View",
235 url=self.request.route_url("master_views.create", _query=query),
236 icon_left="plus",
237 )
238 )
240 return buttons
242 def get_row_grid_data(self, obj): # pylint: disable=empty-docstring
243 """ """
244 table = obj
245 data = []
246 for i, column in enumerate(table["table"].columns, 1):
247 data.append(
248 {
249 "column": column,
250 "sequence": i,
251 "column_name": column.name,
252 "data_type": str(repr(column.type)),
253 "nullable": column.nullable,
254 "description": (column.doc or "").strip(),
255 }
256 )
257 return data
259 def configure_row_grid(self, grid): # pylint: disable=empty-docstring
260 """ """
261 g = grid
262 super().configure_row_grid(g)
264 # nb. try not to hide any columns by default
265 g.pagesize = 100
267 # sequence
268 g.set_label("sequence", "Seq.")
270 # column_name
271 g.set_searchable("column_name")
273 # data_type
274 g.set_searchable("data_type")
276 # nullable
277 g.set_renderer("nullable", "boolean")
279 # description
280 g.set_searchable("description")
281 g.set_renderer("description", self.render_column_description)
283 def render_column_description( # pylint: disable=missing-function-docstring,unused-argument
284 self, column, field, value
285 ):
286 if not value:
287 return ""
289 max_length = 100
290 if len(value) <= max_length:
291 return value
293 return HTML.tag("span", title=value, c=f"{value[:max_length]} ...")
295 def get_template_context(self, context): # pylint: disable=empty-docstring
296 """ """
297 if self.creating:
298 model = self.app.model
300 # alembic current
301 context["alembic_is_current"] = check_alembic_current(self.config)
303 # existing tables
304 # TODO: any reason this should check grid data instead of metadata?
305 unwanted = ["transaction", "transaction_meta"]
306 context["existing_tables"] = [
307 {"name": table}
308 for table in sorted(model.Base.metadata.tables)
309 if table not in unwanted and not table.endswith("_version")
310 ]
312 # model dir
313 context["model_dir"] = os.path.dirname(model.__file__)
315 # migration branch
316 script = get_alembic_scriptdir(self.config)
317 branch_options = self.get_migration_branch_options(script)
318 context["migration_branch_options"] = branch_options
319 branch = self.config.get(
320 f"{self.config.appname}.alembic.default_revise_branch"
321 )
322 if not branch and len(branch_options) == 1:
323 branch = branch_options[0]
324 context["migration_branch"] = branch
326 return context
328 # TODO: this is effectivey duplicated in AlembicMigrationView.get_revise_branch_options()
329 def get_migration_branch_options( # pylint: disable=missing-function-docstring
330 self, script
331 ):
332 branches = set()
333 for rev in script.get_revisions(script.get_heads()):
334 branches.update(rev.branch_labels)
335 return sorted(branches)
337 def wizard_action(self): # pylint: disable=too-many-return-statements
338 """
339 AJAX view to handle various actions for the "new table" wizard.
340 """
341 data = self.request.json_body
342 action = data.get("action", "").strip()
343 try:
345 # nb. cannot use match/case statement until python 3.10, but this
346 # project technically still supports python 3.8
347 if action == "write_model_file":
348 return self.write_model_file(data)
349 if action == "check_model":
350 return self.check_model(data)
351 if action == "write_revision_script":
352 return self.write_revision_script(data)
353 if action == "migrate_db":
354 return self.migrate_db(data)
355 if action == "check_table":
356 return self.check_table(data)
357 if action == "":
358 return {"error": "Must specify the action to perform."}
359 return {"error": f"Unknown action requested: {action}"}
361 except Exception as err: # pylint: disable=broad-exception-caught
362 return {"error": f"Unexpected error occurred: {err}"}
364 def write_model_file(self, data): # pylint: disable=missing-function-docstring
365 model = self.app.model
366 path = data["module_file"]
368 if os.path.exists(path):
369 if data["overwrite"]:
370 os.remove(path)
371 else:
372 return {"error": "File already exists"}
374 for column in data["columns"]:
375 if column["data_type"]["type"] == "_fk_uuid_" and column["relationship"]:
376 name = column["relationship"]
378 table = model.Base.metadata.tables[column["data_type"]["reference"]]
379 mapper = get_mapper(table)
380 reference_model = mapper.class_.__name__
382 column["relationship"] = {
383 "name": name,
384 "reference_model": reference_model,
385 }
387 # TODO: make templates dir configurable?
388 templates = [self.app.resource_path("wuttaweb:code-templates")]
389 table_templates = TemplateLookup(directories=templates)
391 template = table_templates.get_template("/new-table.mako")
392 content = template.render(**data)
393 with open(path, "wt", encoding="utf_8") as f:
394 f.write(content)
396 return {}
398 def check_model(self, data): # pylint: disable=missing-function-docstring
399 model = self.app.model
400 model_name = data["model_name"]
402 if not hasattr(model, model_name):
403 return {
404 "problem": "class not found in app model",
405 "model": model.__name__,
406 }
408 return {}
410 def write_revision_script(self, data): # pylint: disable=missing-function-docstring
411 alembic_config = make_alembic_config(self.config)
413 script = alembic_command.revision(
414 alembic_config,
415 autogenerate=True,
416 head=f"{data['branch']}@head",
417 message=data["message"],
418 )
420 return {"script": script.path}
422 def migrate_db( # pylint: disable=missing-function-docstring,unused-argument
423 self, data
424 ):
425 alembic_config = make_alembic_config(self.config)
426 alembic_command.upgrade(alembic_config, "heads")
427 return {}
429 def check_table(self, data): # pylint: disable=missing-function-docstring
430 model = self.app.model
431 name = data["name"]
433 table = model.Base.metadata.tables.get(name)
434 if table is None:
435 return {"problem": "table does not exist in app model"}
437 session = self.Session()
438 count = session.query(table).count()
440 route_prefix = self.get_route_prefix()
441 url = self.request.route_url(f"{route_prefix}.view", name=name)
442 return {"url": url, "count": count}
444 @classmethod
445 def defaults(cls, config): # pylint: disable=empty-docstring
446 """ """
447 cls._apptable_defaults(config)
448 cls._defaults(config)
450 # pylint: disable=duplicate-code
451 @classmethod
452 def _apptable_defaults(cls, config):
453 route_prefix = cls.get_route_prefix()
454 permission_prefix = cls.get_permission_prefix()
455 model_title_plural = cls.get_model_title_plural()
456 url_prefix = cls.get_url_prefix()
458 # fix permission group
459 config.add_wutta_permission_group(
460 permission_prefix, model_title_plural, overwrite=False
461 )
463 # wizard actions
464 config.add_route(
465 f"{route_prefix}.wizard_action",
466 f"{url_prefix}/new/wizard-action",
467 request_method="POST",
468 )
469 config.add_view(
470 cls,
471 attr="wizard_action",
472 route_name=f"{route_prefix}.wizard_action",
473 renderer="json",
474 permission=f"{permission_prefix}.create",
475 )
477 # pylint: enable=duplicate-code
480def defaults(config, **kwargs): # pylint: disable=missing-function-docstring
481 base = globals()
483 AppTableView = kwargs.get( # pylint: disable=invalid-name,redefined-outer-name
484 "AppTableView", base["AppTableView"]
485 )
486 AppTableView.defaults(config)
489def includeme(config): # pylint: disable=missing-function-docstring
490 defaults(config)