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:
parent
6791abe96f
commit
3e7aa1fa0b
15 changed files with 1151 additions and 8 deletions
6
docs/api/wuttaweb.views.alembic.rst
Normal file
6
docs/api/wuttaweb.views.alembic.rst
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
|
||||||
|
``wuttaweb.views.alembic``
|
||||||
|
==========================
|
||||||
|
|
||||||
|
.. automodule:: wuttaweb.views.alembic
|
||||||
|
:members:
|
||||||
|
|
@ -28,6 +28,7 @@ templates_path = ["_templates"]
|
||||||
exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"]
|
exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"]
|
||||||
|
|
||||||
intersphinx_mapping = {
|
intersphinx_mapping = {
|
||||||
|
"alembic": ("https://alembic.sqlalchemy.org/en/latest/", None),
|
||||||
"colander": ("https://docs.pylonsproject.org/projects/colander/en/latest/", None),
|
"colander": ("https://docs.pylonsproject.org/projects/colander/en/latest/", None),
|
||||||
"deform": ("https://docs.pylonsproject.org/projects/deform/en/latest/", None),
|
"deform": ("https://docs.pylonsproject.org/projects/deform/en/latest/", None),
|
||||||
"fanstatic": ("https://www.fanstatic.org/en/latest/", None),
|
"fanstatic": ("https://www.fanstatic.org/en/latest/", None),
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,7 @@ the narrative docs are pretty scant. That will eventually change.
|
||||||
api/wuttaweb.subscribers
|
api/wuttaweb.subscribers
|
||||||
api/wuttaweb.util
|
api/wuttaweb.util
|
||||||
api/wuttaweb.views
|
api/wuttaweb.views
|
||||||
|
api/wuttaweb.views.alembic
|
||||||
api/wuttaweb.views.auth
|
api/wuttaweb.views.auth
|
||||||
api/wuttaweb.views.base
|
api/wuttaweb.views.base
|
||||||
api/wuttaweb.views.batch
|
api/wuttaweb.views.batch
|
||||||
|
|
|
||||||
|
|
@ -60,7 +60,7 @@ from deform.widget import ( # pylint: disable=unused-import
|
||||||
DateTimeInputWidget,
|
DateTimeInputWidget,
|
||||||
MoneyInputWidget,
|
MoneyInputWidget,
|
||||||
)
|
)
|
||||||
from webhelpers2.html import HTML
|
from webhelpers2.html import HTML, tags
|
||||||
|
|
||||||
from wuttjamaican.conf import parse_list
|
from wuttjamaican.conf import parse_list
|
||||||
|
|
||||||
|
|
@ -537,3 +537,58 @@ class BatchIdWidget(Widget): # pylint: disable=abstract-method
|
||||||
|
|
||||||
batch_id = int(cstruct)
|
batch_id = int(cstruct)
|
||||||
return f"{batch_id:08d}"
|
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
|
||||||
|
|
|
||||||
220
src/wuttaweb/templates/alembic/dashboard.mako
Normal file
220
src/wuttaweb/templates/alembic/dashboard.mako
Normal 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'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>
|
||||||
25
src/wuttaweb/templates/alembic/migrations/index.mako
Normal file
25
src/wuttaweb/templates/alembic/migrations/index.mako
Normal 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>
|
||||||
|
|
@ -42,6 +42,14 @@
|
||||||
once />
|
once />
|
||||||
% endif
|
% 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>
|
</div>
|
||||||
|
|
||||||
<nav class="panel">
|
<nav class="panel">
|
||||||
|
|
|
||||||
25
src/wuttaweb/templates/tables/index.mako
Normal file
25
src/wuttaweb/templates/tables/index.mako
Normal 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>
|
||||||
14
src/wuttaweb/templates/upgrades/index.mako
Normal file
14
src/wuttaweb/templates/upgrades/index.mako
Normal 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>
|
||||||
379
src/wuttaweb/views/alembic.py
Normal file
379
src/wuttaweb/views/alembic.py
Normal 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)
|
||||||
|
|
@ -301,7 +301,7 @@ class CommonView(View):
|
||||||
@classmethod
|
@classmethod
|
||||||
def _defaults(cls, config):
|
def _defaults(cls, config):
|
||||||
|
|
||||||
config.add_wutta_permission_group("common", "(common)", overwrite=False)
|
config.add_wutta_permission_group("common", "(General)", overwrite=False)
|
||||||
|
|
||||||
# home page
|
# home page
|
||||||
config.add_route("home", "/")
|
config.add_route("home", "/")
|
||||||
|
|
@ -328,7 +328,7 @@ class CommonView(View):
|
||||||
renderer="json",
|
renderer="json",
|
||||||
)
|
)
|
||||||
config.add_wutta_permission(
|
config.add_wutta_permission(
|
||||||
"common", "common.feedback", "Send user feedback about the app"
|
"common", "common.feedback", "Send a feedback message"
|
||||||
)
|
)
|
||||||
|
|
||||||
# setup
|
# setup
|
||||||
|
|
@ -339,7 +339,7 @@ class CommonView(View):
|
||||||
config.add_route("change_theme", "/change-theme", request_method="POST")
|
config.add_route("change_theme", "/change-theme", request_method="POST")
|
||||||
config.add_view(cls, attr="change_theme", route_name="change_theme")
|
config.add_view(cls, attr="change_theme", route_name="change_theme")
|
||||||
config.add_wutta_permission(
|
config.add_wutta_permission(
|
||||||
"common", "common.change_theme", "Change global theme"
|
"common", "common.change_theme", "Change the global app theme"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,7 @@ Essential views for convenient includes
|
||||||
|
|
||||||
Most apps should include this module::
|
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:
|
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.users`
|
||||||
* :mod:`wuttaweb.views.upgrades`
|
* :mod:`wuttaweb.views.upgrades`
|
||||||
* :mod:`wuttaweb.views.tables`
|
* :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.users"))
|
||||||
config.include(mod("wuttaweb.views.upgrades"))
|
config.include(mod("wuttaweb.views.upgrades"))
|
||||||
config.include(mod("wuttaweb.views.tables"))
|
config.include(mod("wuttaweb.views.tables"))
|
||||||
|
config.include(mod("wuttaweb.views.alembic"))
|
||||||
|
|
||||||
|
|
||||||
def includeme(config): # pylint: disable=missing-function-docstring
|
def includeme(config): # pylint: disable=missing-function-docstring
|
||||||
|
|
|
||||||
|
|
@ -432,3 +432,98 @@ class TestBatchIdWidget(WebTestCase):
|
||||||
|
|
||||||
result = widget.serialize(field, 42)
|
result = widget.serialize(field, 42)
|
||||||
self.assertEqual(result, "00000042")
|
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
297
tests/views/test_alembic.py
Normal 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: "))
|
||||||
|
|
@ -25,7 +25,6 @@ class TestUpgradeView(WebTestCase):
|
||||||
self.assertIn("schema", table)
|
self.assertIn("schema", table)
|
||||||
|
|
||||||
def test_configure_grid(self):
|
def test_configure_grid(self):
|
||||||
model = self.app.model
|
|
||||||
view = self.make_view()
|
view = self.make_view()
|
||||||
|
|
||||||
# sanity / coverage check
|
# sanity / coverage check
|
||||||
|
|
@ -33,7 +32,6 @@ class TestUpgradeView(WebTestCase):
|
||||||
view.configure_grid(grid)
|
view.configure_grid(grid)
|
||||||
|
|
||||||
def test_get_instance(self):
|
def test_get_instance(self):
|
||||||
model = self.app.model
|
|
||||||
view = self.make_view()
|
view = self.make_view()
|
||||||
|
|
||||||
with patch.object(self.request, "matchdict", new={"name": "person"}):
|
with patch.object(self.request, "matchdict", new={"name": "person"}):
|
||||||
|
|
@ -49,7 +47,6 @@ class TestUpgradeView(WebTestCase):
|
||||||
self.assertEqual(table2["name"], "person")
|
self.assertEqual(table2["name"], "person")
|
||||||
|
|
||||||
def test_get_instance_title(self):
|
def test_get_instance_title(self):
|
||||||
model = self.app.model
|
|
||||||
view = self.make_view()
|
view = self.make_view()
|
||||||
|
|
||||||
table = {"name": "poser_foo"}
|
table = {"name": "poser_foo"}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue