diff --git a/src/wuttaweb/templates/alembic/dashboard.mako b/src/wuttaweb/templates/alembic/dashboard.mako index 34ce1a7..74ad51d 100644 --- a/src/wuttaweb/templates/alembic/dashboard.mako +++ b/src/wuttaweb/templates/alembic/dashboard.mako @@ -43,6 +43,13 @@ label="Alembic Migrations" once /> % endif + % if request.has_perm("alembic.migrations.create"): + + % endif % if request.has_perm("alembic.migrate"):
diff --git a/src/wuttaweb/templates/alembic/migrations/configure.mako b/src/wuttaweb/templates/alembic/migrations/configure.mako new file mode 100644 index 0000000..903a85d --- /dev/null +++ b/src/wuttaweb/templates/alembic/migrations/configure.mako @@ -0,0 +1,31 @@ +## -*- coding: utf-8; -*- +<%inherit file="/configure.mako" /> + +<%def name="form_content()"> + +

Basics

+
+ + + + + + + + +
+ + +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + + diff --git a/src/wuttaweb/templates/alembic/migrations/create.mako b/src/wuttaweb/templates/alembic/migrations/create.mako new file mode 100644 index 0000000..af3803e --- /dev/null +++ b/src/wuttaweb/templates/alembic/migrations/create.mako @@ -0,0 +1,70 @@ +## -*- coding: utf-8; -*- +<%inherit file="/master/create.mako" /> + +<%def name="page_content()"> +
+
+ % if request.has_perm("alembic.dashboard"): + + % endif +
+ + ${parent.page_content()} + + +<%def name="form_vue_fields()"> + + ${form.render_vue_field("description", horizontal=False)} + + ${form.render_vue_field("autogenerate", horizontal=False, label=False, static_text="Auto-generate migration logic based on current app model")} + +
+ + +
+ +
+ + Revise existing branch + +
+ +
+ + ${form.render_vue_field("revise_branch", horizontal=True)} + +
+ +
+ + Start new branch + +
+ +
+ + ${form.render_vue_field("new_branch", horizontal=True)} + ${form.render_vue_field("version_location", horizontal=True)} + +

+ NOTE: New version locations must be added to the + [alembic] section of + your config file (and app restarted) before they will appear as + options here. +

+ +
+
+ +
+ diff --git a/src/wuttaweb/templates/alembic/migrations/view.mako b/src/wuttaweb/templates/alembic/migrations/view.mako new file mode 100644 index 0000000..6bd47e8 --- /dev/null +++ b/src/wuttaweb/templates/alembic/migrations/view.mako @@ -0,0 +1,17 @@ +## -*- coding: utf-8; -*- +<%inherit file="/master/view.mako" /> + +<%def name="page_content()"> +
+
+ % if request.has_perm("alembic.dashboard"): + + % endif +
+ + ${parent.page_content()} + diff --git a/src/wuttaweb/views/alembic.py b/src/wuttaweb/views/alembic.py index a380b08..713d5dd 100644 --- a/src/wuttaweb/views/alembic.py +++ b/src/wuttaweb/views/alembic.py @@ -26,16 +26,21 @@ Views for Alembic import datetime import logging +import os import re from alembic import command as alembic_command from alembic.migration import MigrationContext from alembic.util import CommandError -from wuttjamaican.db.conf import make_alembic_config, get_alembic_scriptdir +from wuttjamaican.db.conf import ( + make_alembic_config, + get_alembic_scriptdir, + check_alembic_current, +) import colander -from webhelpers2.html import tags +from webhelpers2.html import tags, HTML from wuttaweb.views import View, MasterView from wuttaweb.forms import widgets @@ -44,7 +49,7 @@ from wuttaweb.forms import widgets log = logging.getLogger(__name__) -def normalize_revision(config, rev): +def normalize_revision(config, rev): # pylint: disable=missing-function-docstring app = config.get_app() created = None @@ -124,7 +129,7 @@ class AlembicDashboardView(View): "index_title": "Alembic Dashboard", "script": { "dir": script.dir, - "version_locations": script.version_locations, + "version_locations": sorted(script.version_locations), "env_py_location": script.env_py_location, "file_template": script.file_template, }, @@ -169,22 +174,22 @@ class AlembicDashboardView(View): else alembic_command.upgrade ) + # invoke alembic upgrade/downgrade try: command(alembic, revspec) - except Exception as err: + except Exception as err: # pylint: disable=broad-exception-caught log.exception( "database failed to %s using revspec: %s", direction, revspec ) - self.request.session.flash( - f"Database failed to migrate: {err}", "error" - ) + self.request.session.flash(f"Migrate failed: {err}", "error") else: self.request.session.flash("Database has been migrated.") return self.redirect(referrer) @classmethod - def defaults(cls, config): + def defaults(cls, config): # pylint: disable=empty-docstring + """ """ cls._defaults(config) @classmethod @@ -201,7 +206,7 @@ class AlembicDashboardView(View): "alembic.dashboard", "Basic (view) access to the Alembic Dashboard", ) - config.add_route("alembic.dashboard", f"/alembic/dashboard") + config.add_route("alembic.dashboard", "/alembic/dashboard") config.add_view( cls, attr="dashboard", @@ -216,11 +221,7 @@ class AlembicDashboardView(View): "alembic.migrate", "Run migration scripts on the database", ) - config.add_route( - "alembic.migrate", - "/alembic/migrate", - # request_method="POST" - ) + config.add_route("alembic.migrate", "/alembic/migrate") config.add_view( cls, attr="migrate", @@ -229,16 +230,18 @@ class AlembicDashboardView(View): ) -class AlembicMigrationView(MasterView): +class AlembicMigrationView(MasterView): # pylint: disable=abstract-method """ Master view for Alembic Migrations. Route prefix is ``alembic.migrations``; notable URLs include: * ``/alembic/migrations/`` + * ``/alembic/migrations/new`` * ``/alembic/migrations/XXX`` """ + # pylint: disable=duplicate-code model_name = "alembic_migration" model_key = "revision" model_title = "Alembic Migration" @@ -249,9 +252,9 @@ class AlembicMigrationView(MasterView): sort_on_backend = False paginated = True paginate_on_backend = False - creatable = False editable = False - deletable = False + configurable = True + # pylint: enable=duplicate-code labels = { "doc": "Description", @@ -315,10 +318,14 @@ class AlembicMigrationView(MasterView): g.set_label("is_head", "Head") g.set_renderer("is_head", self.render_is_head) - def render_is_head(self, rev, field, value): - return "Yes" if rev.get("is_head") else "" + def render_is_head( # pylint: disable=missing-function-docstring,unused-argument + self, rev, field, value + ): + return self.app.render_boolean(value) if value else "" - def get_instance(self): # pylint: disable=empty-docstring + def get_instance( + self, **kwargs + ): # pylint: disable=empty-docstring,arguments-differ,unused-argument """ """ if "_cached_instance" not in self.__dict__: revision = self.request.matchdict["revision"] @@ -344,6 +351,9 @@ class AlembicMigrationView(MasterView): f = form super().configure_form(f) + # revision + f.set_widget("revision", widgets.CopyableTextWidget()) + # longdoc f.set_widget("longdoc", "notes") @@ -356,21 +366,190 @@ class AlembicMigrationView(MasterView): # is_head f.set_node("is_head", colander.Boolean()) + # path + f.set_widget("path", widgets.CopyableTextWidget()) + + def make_create_form(self): # pylint: disable=empty-docstring + """ """ + alembic = make_alembic_config(self.config) + script = get_alembic_scriptdir(self.config, alembic) + + schema = colander.Schema() + schema.add(colander.SchemaNode(colander.String(), name="description")) + + schema.add( + colander.SchemaNode( + colander.Boolean(), + name="autogenerate", + default=check_alembic_current(self.config, alembic), + ) + ) + + schema.add( + colander.SchemaNode( + colander.String(), name="branching_option", default="revise" + ) + ) + + branch_options = self.get_revise_branch_options(script) + + revise_branch = colander.SchemaNode( + colander.String(), + name="revise_branch", + missing=colander.null, + validator=colander.OneOf(branch_options), + widget=widgets.SelectWidget(values=[(b, b) for b in 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] + if branch: + revise_branch.default = branch + + schema.add(revise_branch) + + schema.add( + colander.SchemaNode( + colander.String(), name="new_branch", missing=colander.null + ) + ) + + version_locations = sorted( + self.config.parse_list(alembic.get_main_option("version_locations")) + ) + + schema.add( + colander.SchemaNode( + colander.String(), + name="version_location", + missing=colander.null, + validator=colander.OneOf(version_locations), + widget=widgets.SelectWidget(values=[(v, v) for v in version_locations]), + ) + ) + + schema.validator = colander.All( + self.validate_revise_branch, self.validate_new_branch + ) + + form = self.make_form( + schema=schema, + cancel_url_fallback=self.get_index_url(), + button_label_submit="Write Script File", + ) + + form.set_label("revise_branch", "Branch") + + return form + + def validate_revise_branch(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): + if value["branching_option"] == "new": + + if not value["new_branch"]: + node["new_branch"].raise_invalid("New branch requires a name.") + + if not value["version_location"]: + node["version_location"].raise_invalid( + "New branch requires a version location." + ) + + 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 + kw = { + "message": data["description"], + "autogenerate": data["autogenerate"], + } + if data["branching_option"] == "new": + kw["head"] = "base" + kw["branch_label"] = data["new_branch"] + kw["version_path"] = self.app.resource_path(data["version_location"]) + else: + assert data["branching_option"] == "revise" + kw["head"] = f"{data['revise_branch']}@head" + + # run `alembic revision` + revision = alembic_command.revision(alembic, **kw) + + intro = HTML.tag( + "p", + class_="block", + c="New migration script has been created. Please review and modify the file contents as needed:", + ) + + path = HTML.tag( + "p", + class_="block has-background-white has-text-black is-family-monospace", + style="padding: 0.5rem;", + c=[HTML.tag("wutta-copyable-text", text=revision.path)], + ) + + outro = HTML.tag( + "p", + class_="block", + c=[ + "When satisfied, proceed to ", + tags.link_to( + "Migrate Database", self.request.route_url("alembic.dashboard") + ), + ".", + ], + ) + + self.request.session.flash(HTML.tag("div", c=[intro, path, outro])) + return revision + + def save_delete_form(self, form): # pylint: disable=empty-docstring + """ """ + rev = self.get_instance() + os.remove(rev["path"]) + + def get_revise_branch_options(self, script): + branches = set() + for rev in script.get_revisions(script.get_heads()): + branches.update(rev.branch_labels) + return sorted(branches) + + def configure_get_simple_settings(self): # pylint: disable=empty-docstring + """ """ + return [ + {"name": f"{self.config.appname}.alembic.default_revise_branch"}, + ] + + def configure_get_context( # pylint: disable=empty-docstring,arguments-differ + self, **kwargs + ): + """ """ + context = super().configure_get_context(**kwargs) + + script = get_alembic_scriptdir(self.config) + context["revise_branch_options"] = self.get_revise_branch_options(script) + + return context + def defaults(config, **kwargs): # pylint: disable=missing-function-docstring base = globals() - AlembicDashboardView = ( - kwargs.get( # pylint: disable=invalid-name,redefined-outer-name - "AlembicDashboardView", base["AlembicDashboardView"] - ) + AlembicDashboardView = ( # pylint: disable=invalid-name,redefined-outer-name + kwargs.get("AlembicDashboardView", base["AlembicDashboardView"]) ) AlembicDashboardView.defaults(config) - AlembicMigrationView = ( - kwargs.get( # pylint: disable=invalid-name,redefined-outer-name - "AlembicMigrationView", base["AlembicMigrationView"] - ) + AlembicMigrationView = ( # pylint: disable=invalid-name,redefined-outer-name + kwargs.get("AlembicMigrationView", base["AlembicMigrationView"]) ) AlembicMigrationView.defaults(config) diff --git a/tests/views/test_alembic.py b/tests/views/test_alembic.py index 88e227f..59251da 100644 --- a/tests/views/test_alembic.py +++ b/tests/views/test_alembic.py @@ -1,5 +1,6 @@ # -*- coding: utf-8; -*- +import os from unittest.mock import patch import sqlalchemy as sa @@ -12,9 +13,11 @@ from wuttjamaican.db.conf import ( make_alembic_config, ) +import colander from pyramid.httpexceptions import HTTPNotFound, HTTPFound from wuttaweb.views import alembic as mod +from wuttaweb.forms import Form from wuttaweb.testing import WebTestCase from wuttaweb.forms.widgets import AlembicRevisionWidget @@ -133,6 +136,166 @@ class TestAlembicMigrationView(WebTestCase): form = view.make_model_form(rev) self.assertIsInstance(form.widgets["down_revision"], AlembicRevisionWidget) + def test_make_create_form(self): + self.pyramid_config.add_route("alembic.migrations", "/alembic/migrations/") + view = self.make_view() + + # sanity / coverage + form = view.make_create_form() + self.assertIsInstance(form, Form) + self.assertIn("branching_option", form) + + def test_validate_revise_branch(self): + self.pyramid_config.add_route("alembic.migrations", "/alembic/migrations/") + view = self.make_view() + form = view.make_create_form() + schema = form.get_schema() + + # good example + self.assertIsNone( + view.validate_revise_branch( + schema, + { + "branching_option": "revise", + "revise_branch": "wutta", + }, + ) + ) + + # branch is required + self.assertRaises( + colander.Invalid, + view.validate_revise_branch, + schema, + { + "branching_option": "revise", + "revise_branch": None, + }, + ) + + def test_validate_new_branch(self): + self.pyramid_config.add_route("alembic.migrations", "/alembic/migrations/") + view = self.make_view() + form = view.make_create_form() + schema = form.get_schema() + + # good example + self.assertIsNone( + view.validate_revise_branch( + schema, + { + "branching_option": "new", + "new_branch": "poser", + "version_location": "wuttjamaican.db:alembic/versions", + }, + ) + ) + + # name is required + self.assertRaises( + colander.Invalid, + view.validate_new_branch, + schema, + { + "branching_option": "new", + "new_branch": None, + "version_location": "wuttjamaican.db:alembic/versions", + }, + ) + + # version_location is required + self.assertRaises( + colander.Invalid, + view.validate_new_branch, + schema, + { + "branching_option": "new", + "new_branch": "poser", + "version_location": None, + }, + ) + + def test_save_create_form(self): + self.pyramid_config.add_route("alembic.migrations", "/alembic/migrations/") + self.pyramid_config.add_route("alembic.dashboard", "/alembic/dashboard") + view = self.make_view() + form = view.make_create_form() + + # revise branch + form.validated = { + "description": "test revision", + "autogenerate": False, + "branching_option": "revise", + "revise_branch": "wutta", + } + revision = view.save_create_form(form) + + # file was saved in wutta dir + self.assertTrue( + revision.path.startswith( + self.app.resource_path("wuttjamaican.db:alembic/versions") + ) + ) + + # get rid of that file! + os.remove(revision.path) + + # new branch + form.validated = { + "description": "test revision", + "autogenerate": False, + "branching_option": "new", + "new_branch": "wuttatest", + "version_location": "wuttjamaican.db:alembic/versions", + } + revision = view.save_create_form(form) + + # file was saved in wutta dir + self.assertTrue( + revision.path.startswith( + self.app.resource_path("wuttjamaican.db:alembic/versions") + ) + ) + + # get rid of that file! + os.remove(revision.path) + + def test_save_delete_form(self): + self.pyramid_config.add_route( + "alembic.migrations.view", "/alembic/migrations/{revision}" + ) + view = self.make_view() + alembic = make_alembic_config(self.config) + + # write new empty migration script + revision = alembic_command.revision( + alembic, + head="base", + branch_label="wuttatest", + version_path=self.app.resource_path("wuttjamaican.db:alembic/versions"), + message="test revision", + ) + + # script exists + self.assertTrue(os.path.exists(revision.path)) + + with patch.object( + self.request, "matchdict", new={"revision": revision.revision} + ): + form = view.make_delete_form(revision) + view.save_delete_form(form) + # script gone + self.assertFalse(os.path.exists(revision.path)) + + def test_configure(self): + self.pyramid_config.add_route("home", "/") + self.pyramid_config.add_route("login", "/auth/login") + self.pyramid_config.add_route("alembic.migrations", "/alembic/migrations") + view = self.make_view() + + # sanity/coverage + view.configure() + class TestAlembicDashboardView(WebTestCase): @@ -294,4 +457,4 @@ version_locations = wuttjamaican.db:alembic/versions self.assertFalse(self.request.session.peek_flash()) self.assertTrue(self.request.session.peek_flash("error")) [msg] = self.request.session.pop_flash("error") - self.assertTrue(msg.startswith("Database failed to migrate: ")) + self.assertTrue(msg.startswith("Migrate failed: "))