3
0
Fork 0

feat: add basic views for Alembic Migrations, Dashboard

can see all "known" migration revisions (per alembic config), and
migrate the DB arbitrarily via alembic upgrade/downgrade
This commit is contained in:
Lance Edgar 2025-12-22 17:35:07 -06:00
parent 6791abe96f
commit 3e7aa1fa0b
15 changed files with 1151 additions and 8 deletions

View file

@ -0,0 +1,6 @@
``wuttaweb.views.alembic``
==========================
.. automodule:: wuttaweb.views.alembic
:members:

View file

@ -28,6 +28,7 @@ templates_path = ["_templates"]
exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"]
intersphinx_mapping = {
"alembic": ("https://alembic.sqlalchemy.org/en/latest/", None),
"colander": ("https://docs.pylonsproject.org/projects/colander/en/latest/", None),
"deform": ("https://docs.pylonsproject.org/projects/deform/en/latest/", None),
"fanstatic": ("https://www.fanstatic.org/en/latest/", None),

View file

@ -58,6 +58,7 @@ the narrative docs are pretty scant. That will eventually change.
api/wuttaweb.subscribers
api/wuttaweb.util
api/wuttaweb.views
api/wuttaweb.views.alembic
api/wuttaweb.views.auth
api/wuttaweb.views.base
api/wuttaweb.views.batch

View file

@ -60,7 +60,7 @@ from deform.widget import ( # pylint: disable=unused-import
DateTimeInputWidget,
MoneyInputWidget,
)
from webhelpers2.html import HTML
from webhelpers2.html import HTML, tags
from wuttjamaican.conf import parse_list
@ -537,3 +537,58 @@ class BatchIdWidget(Widget): # pylint: disable=abstract-method
batch_id = int(cstruct)
return f"{batch_id:08d}"
class AlembicRevisionWidget(Widget): # pylint: disable=missing-class-docstring
"""
Widget to show an Alembic revision identifier, with link to view
the revision.
"""
def __init__(self, request, *args, **kwargs):
super().__init__(*args, **kwargs)
self.request = request
def serialize(self, field, cstruct, **kw): # pylint: disable=empty-docstring
""" """
if not cstruct:
return colander.null
return tags.link_to(
cstruct, self.request.route_url("alembic.migrations.view", revision=cstruct)
)
def deserialize(self, field, pstruct): # pylint: disable=empty-docstring
""" """
raise NotImplementedError
class AlembicRevisionsWidget(Widget):
"""
Widget to show list of Alembic revision identifiers, with links to
view each revision.
"""
def __init__(self, request, *args, **kwargs):
super().__init__(*args, **kwargs)
self.request = request
self.config = self.request.wutta_config
def serialize(self, field, cstruct, **kw): # pylint: disable=empty-docstring
""" """
if not cstruct:
return colander.null
revisions = []
for rev in self.config.parse_list(cstruct):
revisions.append(
tags.link_to(
rev, self.request.route_url("alembic.migrations.view", revision=rev)
)
)
return ", ".join(revisions)
def deserialize(self, field, pstruct): # pylint: disable=empty-docstring
""" """
raise NotImplementedError

View file

@ -0,0 +1,220 @@
## -*- coding: utf-8; -*-
<%inherit file="/page.mako" />
<%def name="page_content()">
<div style="display: flex; gap: 5rem; align-items: start;">
<div style="width: 40%;">
<br />
<b-field label="Script Dir" horizontal>
<span>{{ script.dir }}</span>
</b-field>
<b-field label="Env Location" horizontal>
<span>{{ script.env_py_location }}</span>
</b-field>
<b-field label="Version Locations" horizontal>
<ul>
<li v-for="path in script.version_locations">
{{ path }}
</li>
</ul>
</b-field>
</div>
<div>
<div class="buttons">
% if request.has_perm("tables.list"):
<wutta-button type="is-primary"
tag="a" href="${url('tables')}"
icon-left="table"
label="Database Tables"
once />
% endif
</div>
<div class="buttons">
% if request.has_perm("alembic.migrations.list"):
<wutta-button type="is-primary"
tag="a" href="${url('alembic.migrations')}"
icon-left="forward"
label="Alembic Migrations"
once />
% endif
</div>
% if request.has_perm("alembic.migrate"):
<div class="buttons">
<b-button type="is-warning"
icon-pack="fas"
icon-left="forward"
label="Migrate Database"
@click="migrateInit()">
Migrate Database
</b-button>
</div>
% endif
</div>
</div>
<br />
<h4 class="block is-size-4">Script Heads</h4>
<b-table :data="scriptHeads"
:row-class="getScriptHeadRowClass">
<b-table-column field="branch_labels" label="Branch" v-slot="props">
<span>{{ props.row.branch_labels }}</span>
</b-table-column>
<b-table-column field="doc" label="Description" v-slot="props">
<span>{{ props.row.doc }}</span>
</b-table-column>
<b-table-column field="is_current" label="Current in DB" v-slot="props">
<span :class="{'has-text-weight-bold': true, 'has-text-success': props.row.is_current}">{{ props.row.is_current ? "Yes" : "No" }}</span>
</b-table-column>
<b-table-column field="revision" label="Revision" v-slot="props">
<span v-html="props.row.revision"></span>
</b-table-column>
<b-table-column field="down_revision" label="Down Revision" v-slot="props">
<span v-html="props.row.down_revision"></span>
</b-table-column>
<b-table-column field="path" label="Path" v-slot="props">
<span>{{ props.row.path }}</span>
</b-table-column>
</b-table>
<br />
<h4 class="block is-size-4">Database Heads</h4>
<b-table :data="dbHeads">
<b-table-column field="branch_labels" label="Branch" v-slot="props">
<span>{{ props.row.branch_labels }}</span>
</b-table-column>
<b-table-column field="doc" label="Description" v-slot="props">
<span>{{ props.row.doc }}</span>
</b-table-column>
<b-table-column field="revision" label="Revision" v-slot="props">
<span v-html="props.row.revision"></span>
</b-table-column>
<b-table-column field="down_revision" label="Down Revision" v-slot="props">
<span v-html="props.row.down_revision"></span>
</b-table-column>
<b-table-column field="path" label="Path" v-slot="props">
<span>{{ props.row.path }}</span>
</b-table-column>
</b-table>
% if request.has_perm("alembic.migrate"):
<${b}-modal has-modal-card
:active.sync="migrateShowDialog">
<div class="modal-card">
<header class="modal-card-head">
<p class="modal-card-title">Migrate Database</p>
</header>
<section class="modal-card-body">
${h.form(url("alembic.migrate"), method="POST", ref="migrateForm")}
${h.csrf_token(request)}
<p class="block">
You can provide any revspec target. Default will
upgrade to all branch heads.
</p>
<div class="block content is-family-monospace">
<ul>
<li>alembic upgrade heads</li>
<li>alembic upgrade poser@head</li>
<li>alembic downgrade poser@-1</li>
<li>alembic downgrade poser@base</li>
<li>alembic downgrade fc3a3bcaa069</li>
</ul>
</div>
<p class="block has-text-weight-bold">
don&apos;t try that last one ;)
</p>
<b-field grouped>
<b-field label="Direction">
<b-select name="direction" v-model="migrateDirection">
<option value="upgrade">upgrade</option>
<option value="downgrade">downgrade</option>
</b-select>
</b-field>
<b-field label="Target Spec"
:type="migrateTarget ? null : 'is-danger'">
<b-input name="revspec" v-model="migrateTarget" />
</b-field>
</b-field>
${h.end_form()}
</section>
<footer class="modal-card-foot">
<div style="display: flex; gap: 0.5rem;">
<b-button @click="migrateShowDialog = false">
Cancel
</b-button>
<b-button type="is-warning"
icon-pack="fas"
icon-left="forward"
@click="migrateSubmit()"
:disabled="migrateSubmitDisabled">
{{ migrating ? "Working, please wait..." : "Migrate" }}
</b-button>
</div>
</footer>
</div>
</${b}-modal>
% endif
</%def>
<%def name="modify_vue_vars()">
${parent.modify_vue_vars()}
<script>
ThisPageData.script = ${json.dumps(script)|n}
ThisPageData.scriptHeads = ${json.dumps(script_heads)|n}
ThisPageData.dbHeads = ${json.dumps(db_heads)|n}
ThisPage.methods.getScriptHeadRowClass = function(rev) {
if (!rev.is_current) {
return 'has-background-warning'
}
}
% if request.has_perm("alembic.migrate"):
ThisPageData.migrating = false
ThisPageData.migrateShowDialog = false
ThisPageData.migrateTarget = "heads"
ThisPageData.migrateDirection = "upgrade"
ThisPage.methods.migrateInit = function() {
this.migrateShowDialog = true
}
ThisPage.computed.migrateSubmitDisabled = function() {
if (this.migrating) {
return true
}
if (!this.migrateTarget) {
return true
}
}
ThisPage.methods.migrateSubmit = function() {
this.migrating = true
this.$refs.migrateForm.submit()
}
% endif
</script>
</%def>

View file

@ -0,0 +1,25 @@
## -*- coding: utf-8; -*-
<%inherit file="/master/index.mako" />
<%def name="page_content()">
<div class="buttons">
% if request.has_perm("alembic.dashboard"):
<wutta-button type="is-primary"
tag="a" href="${url('alembic.dashboard')}"
icon-left="forward"
label="Alembic Dashboard"
once />
% endif
% if request.has_perm("tables.list"):
<wutta-button type="is-primary"
tag="a" href="${url('tables')}"
icon-left="table"
label="Database Tables"
once />
% endif
</div>
${parent.page_content()}
</%def>

View file

@ -42,6 +42,14 @@
once />
% endif
% if request.has_perm("alembic.dashboard"):
<wutta-button type="is-primary"
tag="a" href="${url('alembic.dashboard')}"
icon-left="forward"
label="Alembic Dashboard"
once />
% endif
</div>
<nav class="panel">

View file

@ -0,0 +1,25 @@
## -*- coding: utf-8; -*-
<%inherit file="/master/index.mako" />
<%def name="page_content()">
<div class="buttons">
% if request.has_perm("alembic.dashboard"):
<wutta-button type="is-primary"
tag="a" href="${url('alembic.dashboard')}"
icon-left="forward"
label="Alembic Dashboard"
once />
% endif
% if request.has_perm("alembic.migrations.list"):
<wutta-button type="is-primary"
tag="a" href="${url('alembic.migrations')}"
icon-left="forward"
label="Alembic Migrations"
once />
% endif
</div>
${parent.page_content()}
</%def>

View file

@ -0,0 +1,14 @@
## -*- coding: utf-8; -*-
<%inherit file="/master/index.mako" />
<%def name="index_title_controls()">
${parent.index_title_controls()}
% if request.has_perm("alembic.dashboard"):
<wutta-button type="is-primary"
tag="a" href="${url('alembic.dashboard')}"
icon-left="forward"
label="Alembic Dashboard"
once />
% endif
</%def>

View file

@ -0,0 +1,379 @@
# -*- coding: utf-8; -*-
################################################################################
#
# wuttaweb -- Web App for Wutta Framework
# Copyright © 2024-2025 Lance Edgar
#
# This file is part of Wutta Framework.
#
# Wutta Framework is free software: you can redistribute it and/or modify it
# under the terms of the GNU General Public License as published by the Free
# Software Foundation, either version 3 of the License, or (at your option) any
# later version.
#
# Wutta Framework is distributed in the hope that it will be useful, but
# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
# more details.
#
# You should have received a copy of the GNU General Public License along with
# Wutta Framework. If not, see <http://www.gnu.org/licenses/>.
#
################################################################################
"""
Views for Alembic
"""
import datetime
import logging
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
import colander
from webhelpers2.html import tags
from wuttaweb.views import View, MasterView
from wuttaweb.forms import widgets
log = logging.getLogger(__name__)
def normalize_revision(config, rev):
app = config.get_app()
created = None
if match := re.search(r"Create Date: (\d{4}-\d{2}-\d{2}[\d:\. ]+\d)", rev.longdoc):
created = datetime.datetime.fromisoformat(match.group(1))
created = app.localtime(created, from_utc=False)
created = app.render_datetime(created)
return {
"revision": rev.revision,
"branch_labels": ", ".join(rev.branch_labels),
"doc": rev.doc,
"longdoc": rev.longdoc,
"path": rev.path,
"dependencies": rev.dependencies or "",
"down_revision": rev.down_revision or "",
"nextrev": ", ".join(rev.nextrev),
"is_base": app.render_boolean(rev.is_base),
"is_branch_point": app.render_boolean(rev.is_branch_point),
"is_head": rev.is_head,
"created": created,
}
class AlembicDashboardView(View):
"""
Custom views for the Alembic Dashboard.
"""
def dashboard(self):
"""
Main view for the Alembic Dashboard.
Route name is ``alembic.dashboard``; URL is
``/alembic/dashboard``
"""
script = get_alembic_scriptdir(self.config)
with self.config.appdb_engine.begin() as conn:
context = MigrationContext.configure(conn)
current_heads = context.get_current_heads()
def normalize(rev):
normal = normalize_revision(self.config, rev)
normal["is_current"] = rev.revision in current_heads
normal["revision"] = tags.link_to(
normal["revision"],
self.request.route_url(
"alembic.migrations.view", revision=normal["revision"]
),
)
if normal["down_revision"]:
normal["down_revision"] = tags.link_to(
normal["down_revision"],
self.request.route_url(
"alembic.migrations.view", revision=normal["down_revision"]
),
)
return normal
script_heads = []
for head in script.get_heads():
rev = script.get_revision(head)
script_heads.append(normalize(rev))
db_heads = []
for head in current_heads:
rev = script.get_revision(head)
db_heads.append(normalize(rev))
script_heads.sort(key=lambda rev: rev["branch_labels"])
db_heads.sort(key=lambda rev: rev["branch_labels"])
return {
"index_title": "Alembic Dashboard",
"script": {
"dir": script.dir,
"version_locations": script.version_locations,
"env_py_location": script.env_py_location,
"file_template": script.file_template,
},
"script_heads": script_heads,
"db_heads": db_heads,
}
def migrate(self):
"""
Action view to migrate the database. POST request must be used.
This directly invokes the :func:`alembic upgrade
<alembic:alembic.command.upgrade>` (or :func:`alembic
downgrade <alembic:alembic.command.downgrade>`) command.
It then sets a flash message per the command status, and
redirects user back to the Dashboard (or other referrer).
The request must specify a ``revspec`` param, which we pass
along as-is to the ``alembic`` command. We assume ``alembic
upgrade`` unless the request sets ``direction`` param to
``"downgrade"``.
"""
referrer = self.request.get_referrer(
default=self.request.route_url("alembic.dashboard")
)
if self.request.method != "POST":
return self.redirect(referrer)
revspec = self.request.POST.get("revspec")
if not revspec:
self.request.session.flash("You must provide a target revspec.", "error")
else:
direction = self.request.POST.get("direction")
if direction != "downgrade":
direction = "upgrade"
alembic = make_alembic_config(self.config)
command = (
alembic_command.downgrade
if direction == "downgrade"
else alembic_command.upgrade
)
try:
command(alembic, revspec)
except Exception as err:
log.exception(
"database failed to %s using revspec: %s", direction, revspec
)
self.request.session.flash(
f"Database failed to migrate: {err}", "error"
)
else:
self.request.session.flash("Database has been migrated.")
return self.redirect(referrer)
@classmethod
def defaults(cls, config):
cls._defaults(config)
@classmethod
def _defaults(cls, config):
# permission group
config.add_wutta_permission_group(
"alembic", "Alembic (General)", overwrite=False
)
# dashboard
config.add_wutta_permission(
"alembic",
"alembic.dashboard",
"Basic (view) access to the Alembic Dashboard",
)
config.add_route("alembic.dashboard", f"/alembic/dashboard")
config.add_view(
cls,
attr="dashboard",
route_name="alembic.dashboard",
renderer="/alembic/dashboard.mako",
permission="alembic.dashboard",
)
# migrate
config.add_wutta_permission(
"alembic",
"alembic.migrate",
"Run migration scripts on the database",
)
config.add_route(
"alembic.migrate",
"/alembic/migrate",
# request_method="POST"
)
config.add_view(
cls,
attr="migrate",
route_name="alembic.migrate",
permission="alembic.migrate",
)
class AlembicMigrationView(MasterView):
"""
Master view for Alembic Migrations.
Route prefix is ``alembic.migrations``; notable URLs include:
* ``/alembic/migrations/``
* ``/alembic/migrations/XXX``
"""
model_name = "alembic_migration"
model_key = "revision"
model_title = "Alembic Migration"
route_prefix = "alembic.migrations"
url_prefix = "/alembic/migrations"
filterable = False
sortable = True
sort_on_backend = False
paginated = True
paginate_on_backend = False
creatable = False
editable = False
deletable = False
labels = {
"doc": "Description",
"longdoc": "Long Description",
"nextrev": "Next Revision",
}
grid_columns = [
"is_head",
"revision",
"doc",
"branch_labels",
"down_revision",
"created",
]
sort_defaults = ("is_head", "desc")
form_fields = [
"revision",
"doc",
"longdoc",
"branch_labels",
"dependencies",
"down_revision",
"nextrev",
"is_base",
"is_branch_point",
"is_head",
"path",
"created",
]
def get_grid_data( # pylint: disable=empty-docstring
self, columns=None, session=None
):
""" """
data = []
script = get_alembic_scriptdir(self.config)
for rev in script.walk_revisions():
data.append(normalize_revision(self.config, rev))
return data
def configure_grid(self, grid): # pylint: disable=empty-docstring
""" """
g = grid
super().configure_grid(g)
# revision
g.set_link("revision")
g.set_searchable("revision")
# doc
g.set_link("doc")
g.set_searchable("doc")
# branch_labels
g.set_searchable("branch_labels")
# is_head
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 get_instance(self): # pylint: disable=empty-docstring
""" """
if "_cached_instance" not in self.__dict__:
revision = self.request.matchdict["revision"]
script = get_alembic_scriptdir(self.config)
try:
rev = script.get_revision(revision)
except CommandError:
rev = None
if not rev:
raise self.notfound()
self.__dict__["_cached_instance"] = normalize_revision(self.config, rev)
return self.__dict__["_cached_instance"]
def get_instance_title(self, instance): # pylint: disable=empty-docstring
""" """
text = f"({instance['branch_labels']}) {instance['doc']}"
if instance.get("is_head"):
text += " [head]"
return text
def configure_form(self, form): # pylint: disable=empty-docstring
""" """
f = form
super().configure_form(f)
# longdoc
f.set_widget("longdoc", "notes")
# down_revision
f.set_widget("down_revision", widgets.AlembicRevisionWidget(self.request))
# nextrev
f.set_widget("nextrev", widgets.AlembicRevisionsWidget(self.request))
# is_head
f.set_node("is_head", colander.Boolean())
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.defaults(config)
AlembicMigrationView = (
kwargs.get( # pylint: disable=invalid-name,redefined-outer-name
"AlembicMigrationView", base["AlembicMigrationView"]
)
)
AlembicMigrationView.defaults(config)
def includeme(config): # pylint: disable=missing-function-docstring
defaults(config)

View file

@ -301,7 +301,7 @@ class CommonView(View):
@classmethod
def _defaults(cls, config):
config.add_wutta_permission_group("common", "(common)", overwrite=False)
config.add_wutta_permission_group("common", "(General)", overwrite=False)
# home page
config.add_route("home", "/")
@ -328,7 +328,7 @@ class CommonView(View):
renderer="json",
)
config.add_wutta_permission(
"common", "common.feedback", "Send user feedback about the app"
"common", "common.feedback", "Send a feedback message"
)
# setup
@ -339,7 +339,7 @@ class CommonView(View):
config.add_route("change_theme", "/change-theme", request_method="POST")
config.add_view(cls, attr="change_theme", route_name="change_theme")
config.add_wutta_permission(
"common", "common.change_theme", "Change global theme"
"common", "common.change_theme", "Change the global app theme"
)

View file

@ -25,7 +25,7 @@ Essential views for convenient includes
Most apps should include this module::
pyramid_config.include('wuttaweb.views.essential')
pyramid_config.include("wuttaweb.views.essential")
That will in turn include the following modules:
@ -39,6 +39,25 @@ That will in turn include the following modules:
* :mod:`wuttaweb.views.users`
* :mod:`wuttaweb.views.upgrades`
* :mod:`wuttaweb.views.tables`
* :mod:`wuttaweb.views.alembic`
You can also selectively override some modules while keeping most
defaults.
That uses a slightly different approach. First here is how to do it
while keeping all defaults::
from wuttaweb.views import essential
essential.defaults(pyramid_config)
To override, specify the default module with your preferred alias like
so::
essential.defaults(pyramid_config, **{
"wuttaweb.views.users": "poser.web.views.users",
"wuttaweb.views.upgrades": "poser.web.views.upgrades",
})
"""
@ -57,6 +76,7 @@ def defaults(config, **kwargs): # pylint: disable=missing-function-docstring
config.include(mod("wuttaweb.views.users"))
config.include(mod("wuttaweb.views.upgrades"))
config.include(mod("wuttaweb.views.tables"))
config.include(mod("wuttaweb.views.alembic"))
def includeme(config): # pylint: disable=missing-function-docstring

View file

@ -432,3 +432,98 @@ class TestBatchIdWidget(WebTestCase):
result = widget.serialize(field, 42)
self.assertEqual(result, "00000042")
class TestAlembicRevisionWidget(WebTestCase):
def setUp(self):
super().setUp()
self.pyramid_config.add_route(
"alembic.migrations.view", "/alembic/migrations/{revision}"
)
def make_field(self, node, **kwargs):
# TODO: not sure why default renderer is in use even though
# pyramid_deform was included in setup? but this works..
kwargs.setdefault("renderer", deform.Form.default_renderer)
return deform.Field(node, **kwargs)
def make_widget(self, **kwargs):
return mod.AlembicRevisionWidget(self.request, **kwargs)
def test_serialize(self):
node = colander.SchemaNode(colander.String())
field = self.make_field(node)
widget = self.make_widget()
html = widget.serialize(field, colander.null)
self.assertIs(html, colander.null)
html = widget.serialize(field, None)
self.assertIs(html, colander.null)
html = widget.serialize(field, "")
self.assertIs(html, colander.null)
html = widget.serialize(field, "fc3a3bcaa069")
self.assertEqual(
html,
'<a href="http://example.com/alembic/migrations/fc3a3bcaa069">fc3a3bcaa069</a>',
)
def test_deserialize(self):
node = colander.SchemaNode(colander.String())
field = self.make_field(node)
widget = self.make_widget()
self.assertRaises(
NotImplementedError, widget.deserialize, field, "fc3a3bcaa069"
)
class TestAlembicRevisionsWidget(WebTestCase):
def setUp(self):
super().setUp()
self.pyramid_config.add_route(
"alembic.migrations.view", "/alembic/migrations/{revision}"
)
def make_field(self, node, **kwargs):
# TODO: not sure why default renderer is in use even though
# pyramid_deform was included in setup? but this works..
kwargs.setdefault("renderer", deform.Form.default_renderer)
return deform.Field(node, **kwargs)
def make_widget(self, **kwargs):
return mod.AlembicRevisionsWidget(self.request, **kwargs)
def test_serialize(self):
node = colander.SchemaNode(colander.String())
field = self.make_field(node)
widget = self.make_widget()
html = widget.serialize(field, colander.null)
self.assertIs(html, colander.null)
html = widget.serialize(field, None)
self.assertIs(html, colander.null)
html = widget.serialize(field, "")
self.assertIs(html, colander.null)
html = widget.serialize(field, "fc3a3bcaa069")
self.assertEqual(
html,
'<a href="http://example.com/alembic/migrations/fc3a3bcaa069">fc3a3bcaa069</a>',
)
html = widget.serialize(field, "fc3a3bcaa069, d686f7abe3e0")
self.assertEqual(
html,
'<a href="http://example.com/alembic/migrations/fc3a3bcaa069">fc3a3bcaa069</a>, '
'<a href="http://example.com/alembic/migrations/d686f7abe3e0">d686f7abe3e0</a>',
)
def test_deserialize(self):
node = colander.SchemaNode(colander.String())
field = self.make_field(node)
widget = self.make_widget()
self.assertRaises(
NotImplementedError, widget.deserialize, field, "fc3a3bcaa069"
)

297
tests/views/test_alembic.py Normal file
View file

@ -0,0 +1,297 @@
# -*- coding: utf-8; -*-
from unittest.mock import patch
import sqlalchemy as sa
from alembic import command as alembic_command
from wuttjamaican.testing import ConfigTestCase
from wuttjamaican.db.conf import (
get_alembic_scriptdir,
check_alembic_current,
make_alembic_config,
)
from pyramid.httpexceptions import HTTPNotFound, HTTPFound
from wuttaweb.views import alembic as mod
from wuttaweb.testing import WebTestCase
from wuttaweb.forms.widgets import AlembicRevisionWidget
class TestNormalizeRevision(ConfigTestCase):
def test_basic(self):
self.config.setdefault("alembic.script_location", "wuttjamaican.db:alembic")
self.config.setdefault(
"alembic.version_locations", "wuttjamaican.db:alembic/versions"
)
script = get_alembic_scriptdir(self.config)
head = script.get_heads()[0]
rev = script.get_revision(head)
result = mod.normalize_revision(self.config, rev)
self.assertIsInstance(result, dict)
self.assertIn("revision", result)
self.assertEqual(result["revision"], rev.revision)
class TestAlembicMigrationView(WebTestCase):
def setUp(self):
super().setUp()
self.config.setdefault("alembic.script_location", "wuttjamaican.db:alembic")
self.config.setdefault(
"alembic.version_locations", "wuttjamaican.db:alembic/versions"
)
def make_view(self):
return mod.AlembicMigrationView(self.request)
def test_includeme(self):
self.pyramid_config.include("wuttaweb.views.alembic")
def test_get_grid_data(self):
view = self.make_view()
data = view.get_grid_data()
self.assertIsInstance(data, list)
self.assertTrue(data) # 1+ items
rev = data[0]
self.assertIn("revision", rev)
self.assertIn("down_revision", rev)
self.assertIn("doc", rev)
def test_configure_grid(self):
view = self.make_view()
grid = view.make_model_grid()
self.assertIn("revision", grid.searchable_columns)
self.assertIn("doc", grid.searchable_columns)
def test_render_is_head(self):
view = self.make_view()
# missing field / empty default
rev = {"revision": "foo"}
self.assertEqual(view.render_is_head(rev, "is_head", None), "")
# boolean true
rev = {"revision": "foo", "is_head": True}
self.assertEqual(view.render_is_head(rev, "is_head", True), "Yes")
# boolean false
rev = {"revision": "foo", "is_head": False}
self.assertEqual(view.render_is_head(rev, "is_head", False), "")
def test_get_instance(self):
view = self.make_view()
with patch.object(self.request, "matchdict", new={"revision": "fc3a3bcaa069"}):
rev1 = view.get_instance()
self.assertIsInstance(rev1, dict)
self.assertIn("revision", rev1)
self.assertEqual(rev1["revision"], "fc3a3bcaa069")
self.assertIn("doc", rev1)
rev2 = view.get_instance()
self.assertIs(rev2, rev1)
self.assertEqual(rev2["revision"], "fc3a3bcaa069")
view = self.make_view()
with patch.object(self.request, "matchdict", new={"revision": "invalid"}):
self.assertRaises(HTTPNotFound, view.get_instance)
def test_get_instance_title(self):
view = self.make_view()
rev = {
"revision": "fc3a3bcaa069",
"doc": "init with settings table",
"branch_labels": "wutta",
}
self.assertEqual(
view.get_instance_title(rev), "(wutta) init with settings table"
)
rev = {
"revision": "fc3a3bcaa069",
"doc": "init with settings table",
"branch_labels": "wutta",
"is_head": True,
}
self.assertEqual(
view.get_instance_title(rev), "(wutta) init with settings table [head]"
)
def test_configure_form(self):
view = self.make_view()
# sanity / coverage
with patch.object(self.request, "matchdict", new={"revision": "fc3a3bcaa069"}):
rev = view.get_instance()
form = view.make_model_form(rev)
self.assertIsInstance(form.widgets["down_revision"], AlembicRevisionWidget)
class TestAlembicDashboardView(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.AlembicDashboardView(self.request)
def test_dashboard(self):
self.pyramid_config.add_route(
"alembic.migrations.view", "/alembic/migrations/{revision}"
)
view = self.make_view()
alembic = make_alembic_config(self.config)
# tests use MetaData.create_all() instead of migrations for
# setup, so alembic will assume db is not current
self.assertFalse(check_alembic_current(self.config, alembic))
# and to further prove the point, alembic_version table is missing
self.assertEqual(
self.session.execute(sa.text("select count(*) from person")).scalar(),
0,
)
self.assertRaises(
sa.exc.OperationalError,
self.session.execute,
sa.text("select count(*) from alembic_version"),
)
# therefore dashboard shows db with no heads at first
context = view.dashboard()
self.assertIsInstance(context, dict)
self.assertIn("script_heads", context)
self.assertEqual(len(context["script_heads"]), 1)
self.assertIn("db_heads", context)
self.assertEqual(len(context["db_heads"]), 0)
# but we can 'stamp' the db as current
alembic_command.stamp(alembic, "heads")
# now the alembic_version table exists
self.assertEqual(
self.session.execute(
sa.text("select count(*) from alembic_version")
).scalar(),
1,
)
self.assertTrue(check_alembic_current(self.config, alembic))
# and the dashboard knows about db heads
context = view.dashboard()
self.assertEqual(len(context["script_heads"]), 1)
self.assertEqual(len(context["db_heads"]), 1)
def test_migrate(self):
self.pyramid_config.add_route("alembic.dashboard", "/alembic/dashboard")
view = self.make_view()
# tell alembic our db is already current
alembic = make_alembic_config(self.config)
alembic_command.stamp(alembic, "heads")
self.assertTrue(check_alembic_current(self.config, alembic))
# GET request redirects to dashboard w/ no flash
result = view.migrate()
self.assertIsInstance(result, HTTPFound)
self.assertEqual(result.location, "http://example.com/alembic/dashboard")
self.assertFalse(self.request.session.peek_flash())
self.assertFalse(self.request.session.peek_flash("error"))
# POST with no revspec also redirects but w/ flash
with patch.object(self.request, "method", new="POST"):
result = view.migrate()
self.assertIsInstance(result, HTTPFound)
self.assertEqual(result.location, "http://example.com/alembic/dashboard")
self.assertFalse(self.request.session.peek_flash())
self.assertTrue(self.request.session.peek_flash("error"))
self.assertEqual(
self.request.session.pop_flash("error"),
["You must provide a target revspec."],
)
# force downgrade to wutta@-1
with patch.object(self.request, "method", new="POST"):
with patch.object(
self.request,
"POST",
new={"direction": "downgrade", "revspec": "wutta@-1"},
):
# nb. this still redirects but w/ different flash
result = view.migrate()
self.assertIsInstance(result, HTTPFound)
self.assertEqual(
result.location, "http://example.com/alembic/dashboard"
)
self.assertTrue(self.request.session.peek_flash())
self.assertFalse(self.request.session.peek_flash("error"))
self.assertEqual(
self.request.session.pop_flash(),
["Database has been migrated."],
)
# alembic should know our db is no longer current
self.assertFalse(check_alembic_current(self.config, alembic))
# force upgrade to heads
with patch.object(self.request, "method", new="POST"):
with patch.object(
self.request,
"POST",
new={"revspec": "heads"},
):
# nb. this still redirects but w/ different flash
result = view.migrate()
self.assertIsInstance(result, HTTPFound)
self.assertEqual(
result.location, "http://example.com/alembic/dashboard"
)
self.assertTrue(self.request.session.peek_flash())
self.assertFalse(self.request.session.peek_flash("error"))
self.assertEqual(
self.request.session.pop_flash(),
["Database has been migrated."],
)
# alembic should know our db is current again
self.assertTrue(check_alembic_current(self.config, alembic))
# upgrade to invalid spec (force an error)
with patch.object(self.request, "method", new="POST"):
with patch.object(
self.request,
"POST",
new={"revspec": "bad-spec"},
):
# nb. this still redirects but w/ different flash
result = view.migrate()
self.assertIsInstance(result, HTTPFound)
self.assertEqual(
result.location, "http://example.com/alembic/dashboard"
)
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: "))

View file

@ -25,7 +25,6 @@ class TestUpgradeView(WebTestCase):
self.assertIn("schema", table)
def test_configure_grid(self):
model = self.app.model
view = self.make_view()
# sanity / coverage check
@ -33,7 +32,6 @@ class TestUpgradeView(WebTestCase):
view.configure_grid(grid)
def test_get_instance(self):
model = self.app.model
view = self.make_view()
with patch.object(self.request, "matchdict", new={"name": "person"}):
@ -49,7 +47,6 @@ class TestUpgradeView(WebTestCase):
self.assertEqual(table2["name"], "person")
def test_get_instance_title(self):
model = self.app.model
view = self.make_view()
table = {"name": "poser_foo"}