- % if request.has_perm("tables.list"):
+ % if request.has_perm("app_tables.list"):
+ % endif
+ % if request.has_perm("app_tables.create"):
+
% endif
diff --git a/src/wuttaweb/templates/alembic/migrations/index.mako b/src/wuttaweb/templates/alembic/migrations/index.mako
index b3cbd79..bcda890 100644
--- a/src/wuttaweb/templates/alembic/migrations/index.mako
+++ b/src/wuttaweb/templates/alembic/migrations/index.mako
@@ -12,11 +12,11 @@
once />
% endif
- % if request.has_perm("tables.list"):
+ % if request.has_perm("app_tables.list"):
% endif
diff --git a/src/wuttaweb/templates/appinfo/index.mako b/src/wuttaweb/templates/appinfo/index.mako
index 554be2f..137d8b1 100644
--- a/src/wuttaweb/templates/appinfo/index.mako
+++ b/src/wuttaweb/templates/appinfo/index.mako
@@ -34,11 +34,11 @@
- % if request.has_perm("tables.list"):
+ % if request.has_perm("app_tables.list"):
% endif
diff --git a/src/wuttaweb/templates/tables/app/create.mako b/src/wuttaweb/templates/tables/app/create.mako
new file mode 100644
index 0000000..ca82af9
--- /dev/null
+++ b/src/wuttaweb/templates/tables/app/create.mako
@@ -0,0 +1,1035 @@
+## -*- coding: utf-8; -*-
+<%inherit file="/master/create.mako" />
+
+<%def name="extra_styles()">
+ ${parent.extra_styles()}
+
+%def>
+
+<%def name="page_content()">
+
+ % if not alembic_is_current:
+
+
+ Database is not current! There are
+ ${h.link_to("pending migrations", url("alembic.dashboard"))}.
+
+
+ (This will be a problem if you wish to auto-generate a migration for a new table.)
+
+
+ % endif
+
+
+
+
+
+ Enter Details
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Record version data for this table
+
+
+
+
+
+
+
+
+
Columns
+
+
+
+ New
+
+
+
+
+
+
+
+ {{ props.row.name }}
+
+
+
+ {{ formatDataType(props.row.data_type) }}
+
+
+
+ {{ props.row.nullable ? "Yes" : "No" }}
+
+
+
+ ## nb. versioned may be a string e.g. "n/a"
+ {{ typeof(props.row.versioned) == "boolean" ? (props.row.versioned ? "Yes" : "No") : props.row.versioned }}
+
+
+
+ {{ props.row.description }}
+
+
+
+
+
+ Edit
+
+
+
+
+
+ Delete
+
+
+
+
+
+
+
+
+
+
+
+
+ Details are complete
+
+
+ Skip
+
+
+
+
+
+
+
+ Write Model
+
+
+ This will create a new Python module with your table/model definition.
+
+
+
+
+
+
+
+ {{ tableName }}
+
+
+
+ {{ tableModelName }}
+
+
+
+
+
+
+
+
+
+
+
+ Overwrite file if it exists
+
+
+
+
+
+
+
+
+
+ Back
+
+
+ {{ writingModelFile ? "Working, please wait..." : "Write model class to file" }}
+
+
+ Skip
+
+
+
+
+
+
+ Confirm Model
+
+
+
+
+ Code was generated to file:
+
+
+
+
+ Review and modify code to your liking, then include the new
+ model/module in your root model/module.
+
+
+
+ Typical root model/module is at:
+
+
+
+
+ The root model/module should contain something like:
+
+
+
+ from .{{ tableModelFileModuleName }} import {{ tableModelName }}
+
+
+
+ Once you've done all that, the web app must be restarted.
+ This may happen automatically depending on your setup.
+ Test the model import status below.
+
+
+
+
+
+
+
+ At this point your new class should be present in the app
+ model. Test below.
+
+
+
+
+
+
+
+
+
+
+
+
+ check not yet attempted
+
+
+ checking model...
+
+
+ {{ modelImported }} found in app model
+
+
+ {{ modelImported }} not found in app model
+
+
+
+
+
+
+
+
+
+
+
+ Check for Model
+
+
+
+
+
+
+
+
+
+
+ Back
+
+
+ Model class looks good
+
+
+ Skip
+
+
+
+
+
+
+ Write Migration
+
+
+ This will create a new Alembic Migration script, with all
+ pending schema changes.
+
+
+
+ Be sure to choose the correct migration branch!
+
+
+
+
+
+
+
+ {{ branch }}
+
+
+
+
+
+
+
+
+
+
+
+
+ Back
+
+
+ {{ writingRevisionScript ? "Working, please wait..." : "Write migration script" }}
+
+
+ Skip
+
+
+
+
+
+
+ Confirm Migration
+
+
+ Script was generated to file:
+
+
+
+
+ Review and modify the new migration script(s) to your liking, then proceed.
+
+
+
+
+ Back
+
+
+ Migration scripts look good
+
+
+ Skip
+
+
+
+
+
+
+ Migrate Database
+
+
+ If all migration scripts are ready to go, it's time to run them.
+
+
+
+
+ Back
+
+
+ {{ migratingDatabase ? "Working, please wait..." : "Migrate database" }}
+
+
+ Skip
+
+
+
+
+
+
+
+ Confirm Table
+
+
+ At this point your new table should be present in the
+ database. Test below.
+
+
+
+
+
+
+
+
+
+
+ check not yet attempted
+
+
+ checking table...
+
+
+ {{ tableChecked }} found in database
+
+
+ {{ tableChecked }} not found in database
+
+
+
+
+
+
+
+
+
+
+
+ Check for Table
+
+
+
+
+
+
+
+
+
+
+ Back
+
+
+ Table looks good
+
+
+ Skip
+
+
+
+
+
+
+ Commit Code
+
+
+ Hope you're having a great day.
+
+
+
+ Don't forget to commit code changes to your source repo.
+
+
+
+
+ Back
+
+
+
+
+
+
+%def>
+
+<%def name="modify_vue_vars()">
+ ${parent.modify_vue_vars()}
+
+%def>
diff --git a/src/wuttaweb/templates/tables/index.mako b/src/wuttaweb/templates/tables/app/index.mako
similarity index 100%
rename from src/wuttaweb/templates/tables/index.mako
rename to src/wuttaweb/templates/tables/app/index.mako
diff --git a/src/wuttaweb/views/alembic.py b/src/wuttaweb/views/alembic.py
index 713d5dd..ed9352e 100644
--- a/src/wuttaweb/views/alembic.py
+++ b/src/wuttaweb/views/alembic.py
@@ -443,14 +443,18 @@ class AlembicMigrationView(MasterView): # pylint: disable=abstract-method
return form
- def validate_revise_branch(self, node, value):
+ def validate_revise_branch( # pylint: disable=missing-function-docstring
+ self, node, value
+ ):
if value["branching_option"] == "revise":
if not value["revise_branch"]:
node["revise_branch"].raise_invalid(
"Must specify which branch to revise."
)
- def validate_new_branch(self, node, value):
+ def validate_new_branch( # pylint: disable=missing-function-docstring
+ self, node, value
+ ):
if value["branching_option"] == "new":
if not value["new_branch"]:
@@ -464,7 +468,6 @@ class AlembicMigrationView(MasterView): # pylint: disable=abstract-method
def save_create_form(self, form): # pylint: disable=empty-docstring
""" """
alembic = make_alembic_config(self.config)
- script = get_alembic_scriptdir(self.config, alembic)
data = form.validated
# kwargs for `alembic revision` command
@@ -486,7 +489,8 @@ class AlembicMigrationView(MasterView): # pylint: disable=abstract-method
intro = HTML.tag(
"p",
class_="block",
- c="New migration script has been created. Please review and modify the file contents as needed:",
+ c="New migration script has been created. "
+ "Please review and modify the file contents as needed:",
)
path = HTML.tag(
@@ -516,7 +520,10 @@ class AlembicMigrationView(MasterView): # pylint: disable=abstract-method
rev = self.get_instance()
os.remove(rev["path"])
- def get_revise_branch_options(self, script):
+ # TODO: this is effectivey duplicated in AppTableView.get_migration_branch_options()
+ def get_revise_branch_options( # pylint: disable=missing-function-docstring
+ self, script
+ ):
branches = set()
for rev in script.get_revisions(script.get_heads()):
branches.update(rev.branch_labels)
diff --git a/src/wuttaweb/views/tables.py b/src/wuttaweb/views/tables.py
index a3c48bf..611a97a 100644
--- a/src/wuttaweb/views/tables.py
+++ b/src/wuttaweb/views/tables.py
@@ -24,12 +24,23 @@
Table Views
"""
-import sqlalchemy as sa
+import os
+
+from alembic import command as alembic_command
+from sqlalchemy_utils import get_mapper
+from mako.lookup import TemplateLookup
+from webhelpers2.html import HTML
+
+from wuttjamaican.db.conf import (
+ check_alembic_current,
+ make_alembic_config,
+ get_alembic_scriptdir,
+)
from wuttaweb.views import MasterView
-class TableView(MasterView):
+class AppTableView(MasterView): # pylint: disable=abstract-method
"""
Master view showing all tables in the :term:`app database`.
@@ -40,17 +51,20 @@ class TableView(MasterView):
* ``/tables/``
"""
- model_name = "table"
- model_title = "Database Table"
+ # pylint: disable=duplicate-code
+ model_name = "app_table"
+ model_title = "App Table"
model_key = "name"
+ url_prefix = "/tables/app"
filterable = False
sortable = True
sort_on_backend = False
paginated = True
paginate_on_backend = False
- creatable = False
+ creatable = True
editable = False
deletable = False
+ # pylint: enable=duplicate-code
labels = {
"name": "Table Name",
@@ -70,6 +84,23 @@ class TableView(MasterView):
# "row_count",
]
+ has_rows = True
+ rows_title = "Columns"
+ rows_filterable = False
+ rows_sort_defaults = "sequence"
+ rows_sort_on_backend = False
+ rows_paginated = True
+ rows_paginate_on_backend = False
+ rows_viewable = False
+
+ row_grid_columns = [
+ "sequence",
+ "column_name",
+ "data_type",
+ "nullable",
+ "description",
+ ]
+
def get_grid_data( # pylint: disable=empty-docstring
self, columns=None, session=None
):
@@ -93,6 +124,9 @@ class TableView(MasterView):
g = grid
super().configure_grid(g)
+ # nb. show more tables by default
+ g.pagesize = 50
+
# schema
g.set_searchable("schema")
@@ -100,7 +134,9 @@ class TableView(MasterView):
g.set_searchable("name")
g.set_link("name")
- def get_instance(self): # pylint: disable=empty-docstring
+ def get_instance( # pylint: disable=empty-docstring,arguments-differ,unused-argument
+ self, **kwargs
+ ):
""" """
if "_cached_instance" not in self.__dict__:
model = self.app.model
@@ -111,6 +147,7 @@ class TableView(MasterView):
"name": table.name,
"schema": table.schema or "",
# "row_count": 42,
+ "table": table,
}
self.__dict__["_cached_instance"] = data
@@ -121,14 +158,248 @@ class TableView(MasterView):
""" """
return instance["name"]
+ def get_row_grid_data(self, obj): # pylint: disable=empty-docstring
+ """ """
+ table = obj
+ data = []
+ for i, column in enumerate(table["table"].columns, 1):
+ data.append(
+ {
+ "column": column,
+ "sequence": i,
+ "column_name": column.name,
+ "data_type": str(repr(column.type)),
+ "nullable": column.nullable,
+ "description": (column.doc or "").strip(),
+ }
+ )
+ return data
+
+ def configure_row_grid(self, grid): # pylint: disable=empty-docstring
+ """ """
+ g = grid
+ super().configure_row_grid(g)
+
+ # nb. try not to hide any columns by default
+ g.pagesize = 100
+
+ # sequence
+ g.set_label("sequence", "Seq.")
+
+ # column_name
+ g.set_searchable("column_name")
+
+ # data_type
+ g.set_searchable("data_type")
+
+ # nullable
+ g.set_renderer("nullable", "boolean")
+
+ # description
+ g.set_searchable("description")
+ g.set_renderer("description", self.render_column_description)
+
+ def render_column_description( # pylint: disable=missing-function-docstring,unused-argument
+ self, column, field, value
+ ):
+ if not value:
+ return ""
+
+ max_length = 100
+ if len(value) <= max_length:
+ return value
+
+ return HTML.tag("span", title=value, c=f"{value[:max_length]} ...")
+
+ def get_template_context(self, context): # pylint: disable=empty-docstring
+ """ """
+ if self.creating:
+ model = self.app.model
+
+ # alembic current
+ context["alembic_is_current"] = check_alembic_current(self.config)
+
+ # existing tables
+ # TODO: any reason this should check grid data instead of metadata?
+ unwanted = ["transaction", "transaction_meta"]
+ context["existing_tables"] = [
+ {"name": table}
+ for table in sorted(model.Base.metadata.tables)
+ if table not in unwanted and not table.endswith("_version")
+ ]
+
+ # model dir
+ context["model_dir"] = os.path.dirname(model.__file__)
+
+ # migration branch
+ script = get_alembic_scriptdir(self.config)
+ branch_options = self.get_migration_branch_options(script)
+ context["migration_branch_options"] = branch_options
+ branch = self.config.get(
+ f"{self.config.appname}.alembic.default_revise_branch"
+ )
+ if not branch and len(branch_options) == 1:
+ branch = branch_options[0]
+ context["migration_branch"] = branch
+
+ return context
+
+ # TODO: this is effectivey duplicated in AlembicMigrationView.get_revise_branch_options()
+ def get_migration_branch_options( # pylint: disable=missing-function-docstring
+ self, script
+ ):
+ branches = set()
+ for rev in script.get_revisions(script.get_heads()):
+ branches.update(rev.branch_labels)
+ return sorted(branches)
+
+ def wizard_action(self): # pylint: disable=too-many-return-statements
+ """
+ AJAX view to handle various actions for the "new table" wizard.
+ """
+ data = self.request.json_body
+ action = data.get("action", "").strip()
+ try:
+
+ # nb. cannot use match/case statement until python 3.10, but this
+ # project technically still supports python 3.8
+ if action == "write_model_file":
+ return self.write_model_file(data)
+ if action == "check_model":
+ return self.check_model(data)
+ if action == "write_revision_script":
+ return self.write_revision_script(data)
+ if action == "migrate_db":
+ return self.migrate_db(data)
+ if action == "check_table":
+ return self.check_table(data)
+ if action == "":
+ return {"error": "Must specify the action to perform."}
+ return {"error": f"Unknown action requested: {action}"}
+
+ except Exception as err: # pylint: disable=broad-exception-caught
+ return {"error": f"Unexpected error occurred: {err}"}
+
+ def write_model_file(self, data): # pylint: disable=missing-function-docstring
+ model = self.app.model
+ path = data["module_file"]
+
+ if os.path.exists(path):
+ if data["overwrite"]:
+ os.remove(path)
+ else:
+ return {"error": "File already exists"}
+
+ for column in data["columns"]:
+ if column["data_type"]["type"] == "_fk_uuid_" and column["relationship"]:
+ name = column["relationship"]
+
+ table = model.Base.metadata.tables[column["data_type"]["reference"]]
+ mapper = get_mapper(table)
+ reference_model = mapper.class_.__name__
+
+ column["relationship"] = {
+ "name": name,
+ "reference_model": reference_model,
+ }
+
+ # TODO: make templates dir configurable?
+ templates = [self.app.resource_path("wuttaweb:code-templates")]
+ table_templates = TemplateLookup(directories=templates)
+
+ template = table_templates.get_template("/new-table.mako")
+ content = template.render(**data)
+ with open(path, "wt", encoding="utf_8") as f:
+ f.write(content)
+
+ return {}
+
+ def check_model(self, data): # pylint: disable=missing-function-docstring
+ model = self.app.model
+ model_name = data["model_name"]
+
+ if not hasattr(model, model_name):
+ return {
+ "problem": "class not found in app model",
+ "model": model.__name__,
+ }
+
+ return {}
+
+ def write_revision_script(self, data): # pylint: disable=missing-function-docstring
+ alembic_config = make_alembic_config(self.config)
+
+ script = alembic_command.revision(
+ alembic_config,
+ autogenerate=True,
+ head=f"{data['branch']}@head",
+ message=data["message"],
+ )
+
+ return {"script": script.path}
+
+ def migrate_db( # pylint: disable=missing-function-docstring,unused-argument
+ self, data
+ ):
+ alembic_config = make_alembic_config(self.config)
+ alembic_command.upgrade(alembic_config, "heads")
+ return {}
+
+ def check_table(self, data): # pylint: disable=missing-function-docstring
+ model = self.app.model
+ name = data["name"]
+
+ table = model.Base.metadata.tables.get(name)
+ if table is None:
+ return {"problem": "table does not exist in app model"}
+
+ session = self.Session()
+ count = session.query(table).count()
+
+ route_prefix = self.get_route_prefix()
+ url = self.request.route_url(f"{route_prefix}.view", name=name)
+ return {"url": url, "count": count}
+
+ @classmethod
+ def defaults(cls, config): # pylint: disable=empty-docstring
+ """ """
+ cls._apptable_defaults(config)
+ cls._defaults(config)
+
+ @classmethod
+ def _apptable_defaults(cls, config):
+ route_prefix = cls.get_route_prefix()
+ permission_prefix = cls.get_permission_prefix()
+ model_title_plural = cls.get_model_title_plural()
+ url_prefix = cls.get_url_prefix()
+
+ # fix permission group
+ config.add_wutta_permission_group(
+ permission_prefix, model_title_plural, overwrite=False
+ )
+
+ # wizard actions
+ config.add_route(
+ f"{route_prefix}.wizard_action",
+ f"{url_prefix}/new/wizard-action",
+ request_method="POST",
+ )
+ config.add_view(
+ cls,
+ attr="wizard_action",
+ route_name=f"{route_prefix}.wizard_action",
+ renderer="json",
+ permission=f"{permission_prefix}.create",
+ )
+
def defaults(config, **kwargs): # pylint: disable=missing-function-docstring
base = globals()
- TableView = kwargs.get( # pylint: disable=invalid-name,redefined-outer-name
- "TableView", base["TableView"]
+ AppTableView = kwargs.get( # pylint: disable=invalid-name,redefined-outer-name
+ "AppTableView", base["AppTableView"]
)
- TableView.defaults(config)
+ AppTableView.defaults(config)
def includeme(config): # pylint: disable=missing-function-docstring
diff --git a/tests/views/test_tables.py b/tests/views/test_tables.py
index 14cf7bb..d755cdd 100644
--- a/tests/views/test_tables.py
+++ b/tests/views/test_tables.py
@@ -1,15 +1,38 @@
# -*- coding: utf-8; -*-
+import os
from unittest.mock import patch
+from alembic import command as alembic_command
+
+from wuttjamaican.db.conf import check_alembic_current, make_alembic_config
+
from wuttaweb.testing import WebTestCase
from wuttaweb.views import tables as mod
-class TestUpgradeView(WebTestCase):
+class TestAppTableView(WebTestCase):
+
+ def make_config(self, **kwargs):
+ sqlite_path = self.write_file("test.sqlite", "")
+ self.sqlite_engine_url = f"sqlite:///{sqlite_path}"
+
+ config_path = self.write_file(
+ "test.ini",
+ f"""
+[wutta.db]
+default.url = {self.sqlite_engine_url}
+
+[alembic]
+script_location = wuttjamaican.db:alembic
+version_locations = wuttjamaican.db:alembic/versions
+""",
+ )
+
+ return super().make_config([config_path], **kwargs)
def make_view(self):
- return mod.TableView(self.request)
+ return mod.AppTableView(self.request)
def test_includeme(self):
self.pyramid_config.include("wuttaweb.views.tables")
@@ -51,3 +74,253 @@ class TestUpgradeView(WebTestCase):
table = {"name": "poser_foo"}
self.assertEqual(view.get_instance_title(table), "poser_foo")
+
+ def test_get_row_grid_data(self):
+ model = self.app.model
+ view = self.make_view()
+
+ table = model.Base.metadata.tables["person"]
+ table_dict = {"name": "person", "table": table}
+
+ data = view.get_row_grid_data(table_dict)
+ self.assertIsInstance(data, list)
+ self.assertGreater(len(data), 4)
+ columns = [c["column_name"] for c in data]
+ self.assertIn("full_name", columns)
+ self.assertIn("first_name", columns)
+ self.assertIn("last_name", columns)
+
+ def test_configure_row_grid(self):
+ view = self.make_view()
+
+ # sanity / coverage check
+ grid = view.make_grid(columns=["column_name", "data_type"])
+ view.configure_row_grid(grid)
+
+ def test_render_column_description(self):
+ view = self.make_view()
+
+ # nb. first 2 params are igored
+ text = view.render_column_description(None, None, "hello world")
+ self.assertEqual(text, "hello world")
+
+ text = view.render_column_description(None, None, "")
+ self.assertEqual(text, "")
+ text = view.render_column_description(None, None, None)
+ self.assertEqual(text, "")
+
+ msg = (
+ "This is a very long and rambling sentence. "
+ "There is no point to it except that it is long. "
+ "Far too long to be reasonable."
+ "I mean I am serious when I say this is simply too long."
+ )
+ text = view.render_column_description(None, None, msg)
+ self.assertNotEqual(text, msg)
+ self.assertIn("