3
0
Fork 0

feat: basic support for displaying version history

this is not terribly feature-rich yet, just the basics
This commit is contained in:
Lance Edgar 2025-10-29 18:32:35 -05:00
parent 6d2eccd0ea
commit f33448f64a
18 changed files with 1323 additions and 66 deletions

View file

@ -11,9 +11,10 @@ from pyramid import testing
from sqlalchemy import orm
from wuttjamaican.conf import WuttaConfig
from wuttjamaican.testing import DataTestCase
from wuttaweb.forms import schema as mod
from wuttaweb.forms import widgets
from wuttaweb.testing import DataTestCase, WebTestCase
from wuttaweb.testing import WebTestCase
class TestWuttaDateTime(TestCase):

View file

@ -576,10 +576,6 @@ class TestDateAlchemyFilter(WebTestCase):
def setUp(self):
self.setup_web()
model = self.app.model
# nb. create table for TheLocalThing
model.Base.metadata.create_all(bind=self.session.bind)
self.sample_data = [
{"id": 1, "date": datetime.date(2024, 1, 1)},

222
tests/test_diffs.py Normal file
View file

@ -0,0 +1,222 @@
# -*- coding: utf-8; -*-
from wuttaweb import diffs as mod
from wuttaweb.testing import WebTestCase, VersionWebTestCase
# nb. using WebTestCase here only for mako support in render_html()
class TestDiff(WebTestCase):
def make_diff(self, *args, **kwargs):
return mod.Diff(*args, **kwargs)
def test_constructor(self):
old_data = {"foo": "bar"}
new_data = {"foo": "baz"}
diff = self.make_diff(old_data, new_data, fields=["foo"])
self.assertEqual(diff.fields, ["foo"])
def test_make_fields(self):
old_data = {"foo": "bar"}
new_data = {"foo": "bar", "baz": "zer"}
# nb. this calls make_fields()
diff = self.make_diff(old_data, new_data)
# TODO: should the fields be cumulative? or just use new_data?
self.assertEqual(diff.fields, ["baz", "foo"])
def test_values(self):
old_data = {"foo": "bar"}
new_data = {"foo": "baz"}
diff = self.make_diff(old_data, new_data)
self.assertEqual(diff.old_value("foo"), "bar")
self.assertEqual(diff.new_value("foo"), "baz")
def test_values_differ(self):
old_data = {"foo": "bar"}
new_data = {"foo": "baz"}
diff = self.make_diff(old_data, new_data)
self.assertTrue(diff.values_differ("foo"))
old_data = {"foo": "bar"}
new_data = {"foo": "bar"}
diff = self.make_diff(old_data, new_data)
self.assertFalse(diff.values_differ("foo"))
def test_render_values(self):
old_data = {"foo": "bar"}
new_data = {"foo": "baz"}
diff = self.make_diff(old_data, new_data)
self.assertEqual(diff.render_old_value("foo"), "'bar'")
self.assertEqual(diff.render_new_value("foo"), "'baz'")
def test_get_old_value_attrs(self):
# no change
old_data = {"foo": "bar"}
new_data = {"foo": "bar"}
diff = self.make_diff(old_data, new_data, nature="update")
self.assertEqual(diff.get_old_value_attrs(False), {})
# update
old_data = {"foo": "bar"}
new_data = {"foo": "baz"}
diff = self.make_diff(old_data, new_data, nature="update")
self.assertEqual(
diff.get_old_value_attrs(True),
{"style": f"background-color: {diff.old_color};"},
)
# delete
old_data = {"foo": "bar"}
new_data = {}
diff = self.make_diff(old_data, new_data, nature="delete")
self.assertEqual(
diff.get_old_value_attrs(True),
{"style": f"background-color: {diff.old_color};"},
)
def test_get_new_value_attrs(self):
# no change
old_data = {"foo": "bar"}
new_data = {"foo": "bar"}
diff = self.make_diff(old_data, new_data, nature="update")
self.assertEqual(diff.get_new_value_attrs(False), {})
# update
old_data = {"foo": "bar"}
new_data = {"foo": "baz"}
diff = self.make_diff(old_data, new_data, nature="update")
self.assertEqual(
diff.get_new_value_attrs(True),
{"style": f"background-color: {diff.new_color};"},
)
# create
old_data = {}
new_data = {"foo": "bar"}
diff = self.make_diff(old_data, new_data, nature="create")
self.assertEqual(
diff.get_new_value_attrs(True),
{"style": f"background-color: {diff.new_color};"},
)
def test_render_field_row(self):
old_data = {"foo": "bar"}
new_data = {"foo": "baz"}
diff = self.make_diff(old_data, new_data)
row = diff.render_field_row("foo")
self.assertIn("<tr>", row)
self.assertIn("&#39;bar&#39;", row)
self.assertIn(f'style="background-color: {diff.old_color};"', row)
self.assertIn("&#39;baz&#39;", row)
self.assertIn(f'style="background-color: {diff.new_color};"', row)
self.assertIn("</tr>", row)
def test_render_html(self):
old_data = {"foo": "bar"}
new_data = {"foo": "baz"}
diff = self.make_diff(old_data, new_data)
html = diff.render_html()
self.assertIn("<table", html)
self.assertIn("<tr>", html)
self.assertIn("&#39;bar&#39;", html)
self.assertIn(f'style="background-color: {diff.old_color};"', html)
self.assertIn("&#39;baz&#39;", html)
self.assertIn(f'style="background-color: {diff.new_color};"', html)
self.assertIn("</tr>", html)
self.assertIn("</table>", html)
class TestVersionDiff(VersionWebTestCase):
def make_diff(self, *args, **kwargs):
return mod.VersionDiff(*args, **kwargs)
def test_constructor(self):
import sqlalchemy_continuum as continuum
model = self.app.model
user = model.User(username="fred")
self.session.add(user)
self.session.commit()
user.username = "freddie"
self.session.commit()
self.session.delete(user)
self.session.commit()
txncls = continuum.transaction_class(model.User)
vercls = continuum.version_class(model.User)
versions = self.session.query(vercls).order_by(vercls.transaction_id).all()
self.assertEqual(len(versions), 3)
version = versions[0]
diff = self.make_diff(version)
self.assertEqual(diff.nature, "create")
self.assertEqual(
diff.fields,
["active", "password", "person_uuid", "prevent_edit", "username", "uuid"],
)
version = versions[1]
diff = self.make_diff(version)
self.assertEqual(diff.nature, "update")
self.assertEqual(
diff.fields,
["active", "password", "person_uuid", "prevent_edit", "username", "uuid"],
)
version = versions[2]
diff = self.make_diff(version)
self.assertEqual(diff.nature, "delete")
self.assertEqual(
diff.fields,
["active", "password", "person_uuid", "prevent_edit", "username", "uuid"],
)
def test_render_values(self):
import sqlalchemy_continuum as continuum
model = self.app.model
user = model.User(username="fred")
self.session.add(user)
self.session.commit()
user.username = "freddie"
self.session.commit()
self.session.delete(user)
self.session.commit()
txncls = continuum.transaction_class(model.User)
vercls = continuum.version_class(model.User)
versions = self.session.query(vercls).order_by(vercls.transaction_id).all()
self.assertEqual(len(versions), 3)
version = versions[0]
diff = self.make_diff(version)
self.assertEqual(diff.nature, "create")
self.assertEqual(diff.render_old_value("username"), "")
self.assertEqual(
diff.render_new_value("username"),
'<span style="font-family: monospace;">&#39;fred&#39;</span>',
)
version = versions[1]
diff = self.make_diff(version)
self.assertEqual(diff.nature, "update")
self.assertEqual(
diff.render_old_value("username"),
'<span style="font-family: monospace;">&#39;fred&#39;</span>',
)
self.assertEqual(
diff.render_new_value("username"),
'<span style="font-family: monospace;">&#39;freddie&#39;</span>',
)
version = versions[2]
diff = self.make_diff(version)
self.assertEqual(diff.nature, "delete")
self.assertEqual(
diff.render_old_value("username"),
'<span style="font-family: monospace;">&#39;freddie&#39;</span>',
)
self.assertEqual(diff.render_new_value("username"), "")

View file

@ -1,43 +1,15 @@
# -*- coding: utf-8; -*-
from wuttjamaican.conf import WuttaConfig
from wuttjamaican.testing import FileConfigTestCase
from wuttaweb.menus import MenuHandler
class DataTestCase(FileConfigTestCase):
"""
Base class for test suites requiring a full (typical) database.
"""
def setUp(self):
self.setup_db()
def setup_db(self):
self.setup_files()
self.config = WuttaConfig(
defaults={
"wutta.db.default.url": "sqlite://",
}
)
self.app = self.config.get_app()
# init db
model = self.app.model
model.Base.metadata.create_all(bind=self.config.appdb_engine)
self.session = self.app.make_session()
def tearDown(self):
self.teardown_db()
def teardown_db(self):
self.teardown_files()
class NullMenuHandler(MenuHandler):
"""
Dummy menu handler for testing.
Dummy :term:`menu handler` for testing.
"""
def make_menus(self, request, **kwargs):
"""
This always returns an empty menu set.
"""
return []

View file

@ -31,12 +31,6 @@ class MockBatchHandler(BatchHandler):
class TestBatchMasterView(WebTestCase):
def setUp(self):
self.setup_web()
# nb. create MockBatch, MockBatchRow
model.Base.metadata.create_all(bind=self.session.bind)
def make_handler(self):
return MockBatchHandler(self.config)
@ -51,7 +45,7 @@ class TestBatchMasterView(WebTestCase):
self.assertEqual(view.batch_handler, 42)
def test_get_fallback_templates(self):
handler = MockBatchHandler(self.config)
handler = self.make_handler()
with patch.object(
mod.BatchMasterView, "get_batch_handler", return_value=handler
):
@ -67,7 +61,7 @@ class TestBatchMasterView(WebTestCase):
def test_render_to_response(self):
model = self.app.model
handler = MockBatchHandler(self.config)
handler = self.make_handler()
user = model.User(username="barney")
self.session.add(user)
@ -87,7 +81,7 @@ class TestBatchMasterView(WebTestCase):
self.assertIs(context["batch_handler"], handler)
def test_configure_grid(self):
handler = MockBatchHandler(self.config)
handler = self.make_handler()
with patch.multiple(mod.BatchMasterView, create=True, model_class=MockBatch):
with patch.object(
mod.BatchMasterView, "get_batch_handler", return_value=handler
@ -98,7 +92,7 @@ class TestBatchMasterView(WebTestCase):
view.configure_grid(grid)
def test_render_batch_id(self):
handler = MockBatchHandler(self.config)
handler = self.make_handler()
with patch.object(
mod.BatchMasterView, "get_batch_handler", return_value=handler
):
@ -112,7 +106,7 @@ class TestBatchMasterView(WebTestCase):
self.assertIsNone(result)
def test_get_instance_title(self):
handler = MockBatchHandler(self.config)
handler = self.make_handler()
with patch.object(
mod.BatchMasterView, "get_batch_handler", return_value=handler
):
@ -127,7 +121,7 @@ class TestBatchMasterView(WebTestCase):
self.assertEqual(result, "00000043 runnin some numbers")
def test_configure_form(self):
handler = MockBatchHandler(self.config)
handler = self.make_handler()
with patch.multiple(mod.BatchMasterView, create=True, model_class=MockBatch):
with patch.object(
mod.BatchMasterView, "get_batch_handler", return_value=handler
@ -163,7 +157,7 @@ class TestBatchMasterView(WebTestCase):
view.configure_form(form)
def test_objectify(self):
handler = MockBatchHandler(self.config)
handler = self.make_handler()
with patch.multiple(mod.BatchMasterView, create=True, model_class=MockBatch):
with patch.object(
mod.BatchMasterView, "get_batch_handler", return_value=handler
@ -194,7 +188,7 @@ class TestBatchMasterView(WebTestCase):
def test_redirect_after_create(self):
self.pyramid_config.add_route("mock_batches.view", "/batch/mock/{uuid}")
handler = MockBatchHandler(self.config)
handler = self.make_handler()
with patch.object(
mod.BatchMasterView, "get_batch_handler", return_value=handler
):
@ -247,7 +241,7 @@ class TestBatchMasterView(WebTestCase):
def test_populate_thread(self):
model = self.app.model
handler = MockBatchHandler(self.config)
handler = self.make_handler()
with patch.object(
mod.BatchMasterView, "get_batch_handler", return_value=handler
):
@ -315,7 +309,7 @@ class TestBatchMasterView(WebTestCase):
def test_execute(self):
self.pyramid_config.add_route("mock_batches.view", "/batch/mock/{uuid}")
model = self.app.model
handler = MockBatchHandler(self.config)
handler = self.make_handler()
user = model.User(username="barney")
self.session.add(user)
@ -345,7 +339,7 @@ class TestBatchMasterView(WebTestCase):
self.assertTrue(self.request.session.peek_flash("error"))
def test_get_row_model_class(self):
handler = MockBatchHandler(self.config)
handler = self.make_handler()
with patch.object(
mod.BatchMasterView, "get_batch_handler", return_value=handler
):
@ -370,7 +364,7 @@ class TestBatchMasterView(WebTestCase):
self.assertIs(cls, MockBatchRow)
def test_get_row_grid_data(self):
handler = MockBatchHandler(self.config)
handler = self.make_handler()
model = self.app.model
user = model.User(username="barney")
@ -401,7 +395,7 @@ class TestBatchMasterView(WebTestCase):
self.assertEqual(data.count(), 1)
def test_configure_row_grid(self):
handler = MockBatchHandler(self.config)
handler = self.make_handler()
model = self.app.model
user = model.User(username="barney")

View file

@ -16,7 +16,8 @@ from wuttaweb.views import master as mod
from wuttaweb.views import View
from wuttaweb.progress import SessionProgress
from wuttaweb.subscribers import new_request_set_user
from wuttaweb.testing import WebTestCase
from wuttaweb.testing import WebTestCase, VersionWebTestCase
from wuttaweb.grids import Grid
class TestMasterView(WebTestCase):
@ -1841,3 +1842,247 @@ class TestMasterView(WebTestCase):
# class may specify
with patch.object(view, "rows_title", create=True, new="Mock Rows"):
self.assertEqual(view.get_rows_title(), "Mock Rows")
class TestVersionedMasterView(VersionWebTestCase):
def make_view(self):
return mod.MasterView(self.request)
def test_defaults(self):
model = self.app.model
with patch.multiple(mod.MasterView, model_class=model.User, has_versions=True):
mod.MasterView.defaults(self.pyramid_config)
def test_get_model_version_class(self):
model = self.app.model
with patch.object(mod.MasterView, "model_class", new=model.User):
view = self.make_view()
vercls = view.get_model_version_class()
self.assertEqual(vercls.__name__, "UserVersion")
def test_should_expose_versions(self):
model = self.app.model
with patch.multiple(mod.MasterView, model_class=model.User, has_versions=True):
# fully enabled for root user
with patch.object(self.request, "is_root", new=True):
view = self.make_view()
self.assertTrue(view.should_expose_versions())
# but not if user has no access
view = self.make_view()
self.assertFalse(view.should_expose_versions())
# again, works for root user
with patch.object(self.request, "is_root", new=True):
view = self.make_view()
self.assertTrue(view.should_expose_versions())
# but not if config disables versioning
with patch.object(view.app, "continuum_is_enabled", return_value=False):
self.assertFalse(view.should_expose_versions())
def test_get_version_grid_key(self):
model = self.app.model
with patch.object(mod.MasterView, "model_class", new=model.User):
# default
view = self.make_view()
self.assertEqual(view.get_version_grid_key(), "users.history")
# custom
with patch.object(
mod.MasterView,
"version_grid_key",
new="users_custom_history",
create=True,
):
view = self.make_view()
self.assertEqual(view.get_version_grid_key(), "users_custom_history")
def test_get_version_grid_columns(self):
model = self.app.model
with patch.object(mod.MasterView, "model_class", new=model.User):
# default
view = self.make_view()
self.assertEqual(
view.get_version_grid_columns(),
["id", "issued_at", "user", "remote_addr"],
)
# custom
with patch.object(
mod.MasterView,
"version_grid_columns",
new=["issued_at", "user"],
create=True,
):
view = self.make_view()
self.assertEqual(view.get_version_grid_columns(), ["issued_at", "user"])
def test_get_version_grid_data(self):
model = self.app.model
user = model.User(username="fred")
self.session.add(user)
self.session.commit()
user.username = "freddie"
self.session.commit()
with patch.object(mod.MasterView, "model_class", new=model.User):
view = self.make_view()
query = view.get_version_grid_data(user)
self.assertIsInstance(query, orm.Query)
transactions = query.all()
self.assertEqual(len(transactions), 2)
def test_configure_version_grid(self):
import sqlalchemy_continuum as continuum
model = self.app.model
txncls = continuum.transaction_class(model.User)
with patch.object(mod.MasterView, "model_class", new=model.User):
view = self.make_view()
# this is mostly just for coverage, but we at least can
# confirm something does change
grid = view.make_grid(model_class=txncls)
self.assertNotIn("issued_at", grid.linked_columns)
view.configure_version_grid(grid)
self.assertIn("issued_at", grid.linked_columns)
def test_make_version_grid(self):
model = self.app.model
user = model.User(username="fred")
self.session.add(user)
self.session.commit()
user.username = "freddie"
self.session.commit()
with patch.object(mod.MasterView, "model_class", new=model.User):
with patch.object(mod.MasterView, "Session", return_value=self.session):
with patch.dict(self.request.matchdict, uuid=user.uuid):
view = self.make_view()
grid = view.make_version_grid()
self.assertIsInstance(grid, Grid)
self.assertIsInstance(grid.data, orm.Query)
self.assertEqual(len(grid.data.all()), 2)
def test_view_versions(self):
self.pyramid_config.add_route("home", "/")
self.pyramid_config.add_route("login", "/auth/login")
self.pyramid_config.add_route("users", "/users/")
self.pyramid_config.add_route("users.view", "/users/{uuid}")
self.pyramid_config.add_route("users.version", "/users/{uuid}/versions/{txnid}")
model = self.app.model
user = model.User(username="fred")
self.session.add(user)
self.session.commit()
user.username = "freddie"
self.session.commit()
with patch.object(mod.MasterView, "model_class", new=model.User):
with patch.object(mod.MasterView, "Session", return_value=self.session):
with patch.dict(self.request.matchdict, uuid=user.uuid):
view = self.make_view()
# normal, full page
response = view.view_versions()
self.assertEqual(response.content_type, "text/html")
self.assertIn("<b-table", response.text)
# partial page
with patch.dict(self.request.params, partial="1"):
response = view.view_versions()
self.assertEqual(response.content_type, "application/json")
self.assertIn("data", response.json)
self.assertEqual(len(response.json["data"]), 2)
def test_get_relevant_versions(self):
import sqlalchemy_continuum as continuum
model = self.app.model
txncls = continuum.transaction_class(model.User)
vercls = continuum.version_class(model.User)
user = model.User(username="fred")
self.session.add(user)
self.session.commit()
txn = (
self.session.query(txncls)
.join(vercls, vercls.transaction_id == txncls.id)
.order_by(txncls.id)
.first()
)
with patch.object(mod.MasterView, "model_class", new=model.User):
with patch.object(mod.MasterView, "Session", return_value=self.session):
view = self.make_view()
versions = view.get_relevant_versions(txn, user)
self.assertEqual(len(versions), 1)
version = versions[0]
self.assertIsInstance(version, vercls)
def test_view_version(self):
import sqlalchemy_continuum as continuum
self.pyramid_config.add_route("home", "/")
self.pyramid_config.add_route("login", "/auth/login")
self.pyramid_config.add_route("users", "/users/")
self.pyramid_config.add_route("users.view", "/users/{uuid}")
self.pyramid_config.add_route("users.versions", "/users/{uuid}/versions/")
self.pyramid_config.add_route("users.version", "/users/{uuid}/versions/{txnid}")
model = self.app.model
txncls = continuum.transaction_class(model.User)
vercls = continuum.version_class(model.User)
user = model.User(username="fred")
self.session.add(user)
self.session.commit()
user.username = "freddie"
self.session.commit()
transactions = (
self.session.query(txncls)
.join(vercls, vercls.transaction_id == txncls.id)
.order_by(txncls.id)
.all()
)
self.assertEqual(len(transactions), 2)
with patch.object(mod.MasterView, "model_class", new=model.User):
with patch.object(mod.MasterView, "Session", return_value=self.session):
# invalid txnid
with patch.dict(self.request.matchdict, uuid=user.uuid, txnid=999999):
view = self.make_view()
self.assertRaises(HTTPNotFound, view.view_version)
# first txn
first = transactions[0]
with patch.dict(self.request.matchdict, uuid=user.uuid, txnid=first.id):
view = self.make_view()
response = view.view_version()
self.assertIn(
'<table class="table is-fullwidth is-bordered is-narrow">',
response.text,
)
# second txn
second = transactions[1]
with patch.dict(
self.request.matchdict, uuid=user.uuid, txnid=second.id
):
view = self.make_view()
response = view.view_version()
self.assertIn(
'<table class="table is-fullwidth is-bordered is-narrow">',
response.text,
)