From f33448f64af857b83149b7094fb9b9576382f9cd Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 29 Oct 2025 18:32:35 -0500 Subject: [PATCH] feat: basic support for displaying version history this is not terribly feature-rich yet, just the basics --- docs/api/wuttaweb.diffs.rst | 6 + docs/index.rst | 1 + pyproject.toml | 8 +- src/wuttaweb/diffs.py | 224 ++++++++++ src/wuttaweb/templates/base.mako | 35 +- src/wuttaweb/templates/diff.mako | 15 + src/wuttaweb/templates/master/view.mako | 12 + .../templates/master/view_version.mako | 35 ++ .../templates/master/view_versions.mako | 20 + src/wuttaweb/testing.py | 68 ++- src/wuttaweb/views/master.py | 412 +++++++++++++++++- tests/forms/test_schema.py | 3 +- tests/grids/test_filters.py | 4 - tests/test_diffs.py | 222 ++++++++++ tests/util.py | 36 +- tests/views/test_batch.py | 32 +- tests/views/test_master.py | 247 ++++++++++- tox.ini | 9 +- 18 files changed, 1323 insertions(+), 66 deletions(-) create mode 100644 docs/api/wuttaweb.diffs.rst create mode 100644 src/wuttaweb/diffs.py create mode 100644 src/wuttaweb/templates/diff.mako create mode 100644 src/wuttaweb/templates/master/view_version.mako create mode 100644 src/wuttaweb/templates/master/view_versions.mako create mode 100644 tests/test_diffs.py diff --git a/docs/api/wuttaweb.diffs.rst b/docs/api/wuttaweb.diffs.rst new file mode 100644 index 0000000..1074cde --- /dev/null +++ b/docs/api/wuttaweb.diffs.rst @@ -0,0 +1,6 @@ + +``wuttaweb.diffs`` +================== + +.. automodule:: wuttaweb.diffs + :members: diff --git a/docs/index.rst b/docs/index.rst index ed834f0..bd5c25a 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -41,6 +41,7 @@ the narrative docs are pretty scant. That will eventually change. api/wuttaweb.db api/wuttaweb.db.continuum api/wuttaweb.db.sess + api/wuttaweb.diffs api/wuttaweb.emails api/wuttaweb.forms api/wuttaweb.forms.base diff --git a/pyproject.toml b/pyproject.toml index 5477833..4a76f81 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -50,7 +50,7 @@ dependencies = [ [project.optional-dependencies] -continuum = ["Wutta-Continuum"] +continuum = ["Wutta-Continuum>=0.2.1"] docs = ["Sphinx", "furo", "sphinxcontrib-programoutput"] tests = ["pylint", "pytest", "pytest-cov", "tox"] @@ -91,3 +91,9 @@ update_changelog_on_bump = true exclude = [ "htmlcov/", ] + + +[tool.pytest.ini_options] +markers = [ + "versioned: tests with SQLAlchemy-Continuum versioning feature enabled", +] diff --git a/src/wuttaweb/diffs.py b/src/wuttaweb/diffs.py new file mode 100644 index 0000000..9747fd0 --- /dev/null +++ b/src/wuttaweb/diffs.py @@ -0,0 +1,224 @@ +# -*- 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 . +# +################################################################################ +""" +Tools for displaying simple data diffs +""" + +import sqlalchemy as sa + +from pyramid.renderers import render +from webhelpers2.html import HTML + + +class Diff: + """ + Represent / display a basic "diff" between two data records. + + You must provide both the "old" and "new" data records, when + constructing an instance of this class. Then call + :meth:`render_html()` to display the diff table. + + :param old_data: Dict of "old" data record. + + :param new_data: Dict of "new" data record. + + :param fields: Optional list of field names. If not specified, + will be derived from the data records. + + :param nature: What sort of diff is being represented; must be one + of: ``("create", "update", "delete")`` + + :param old_color: Background color to display for "old/deleted" + field data, when applicable. + + :param new_color: Background color to display for "new/created" + field data, when applicable. + """ + + def __init__( # pylint: disable=too-many-arguments,too-many-positional-arguments + self, + old_data: dict, + new_data: dict, + fields: list = None, + nature="update", + old_color="#ffebe9", + new_color="#dafbe1", + ): + self.old_data = old_data + self.new_data = new_data + self.columns = ["field name", "old value", "new value"] + self.fields = fields or self.make_fields() + self.nature = nature + self.old_color = old_color + self.new_color = new_color + + def make_fields(self): # pylint: disable=missing-function-docstring + return sorted(set(self.old_data) | set(self.new_data), key=lambda x: x.lower()) + + def old_value(self, field): # pylint: disable=missing-function-docstring + return self.old_data.get(field) + + def new_value(self, field): # pylint: disable=missing-function-docstring + return self.new_data.get(field) + + def values_differ(self, field): # pylint: disable=missing-function-docstring + return self.new_value(field) != self.old_value(field) + + def render_html(self, template="/diff.mako", **kwargs): + """ + Render the diff as HTML table. + + :param template: Name of template to render, if you need to + override the default. + + :param \\**kwargs: Remaining kwargs are passed as context to + the template renderer. + + :returns: HTML literal string + """ + context = kwargs + context["diff"] = self + return HTML.literal(render(template, context)) + + def render_field_row(self, field): # pylint: disable=missing-function-docstring + is_diff = self.values_differ(field) + + td_field = HTML.tag("td", class_="field", c=field) + + td_old_value = HTML.tag( + "td", + c=self.render_old_value(field), + **self.get_old_value_attrs(is_diff), + ) + + td_new_value = HTML.tag( + "td", + c=self.render_new_value(field), + **self.get_new_value_attrs(is_diff), + ) + + return HTML.tag("tr", c=[td_field, td_old_value, td_new_value]) + + def render_old_value(self, field): # pylint: disable=missing-function-docstring + value = self.old_value(field) + return repr(value) + + def render_new_value(self, field): # pylint: disable=missing-function-docstring + value = self.new_value(field) + return repr(value) + + def get_old_value_attrs( # pylint: disable=missing-function-docstring + self, is_diff + ): + attrs = {} + if self.nature == "update" and is_diff: + attrs["style"] = f"background-color: {self.old_color};" + elif self.nature == "delete": + attrs["style"] = f"background-color: {self.old_color};" + return attrs + + def get_new_value_attrs( # pylint: disable=missing-function-docstring + self, is_diff + ): + attrs = {} + if self.nature == "create": + attrs["style"] = f"background-color: {self.new_color};" + elif self.nature == "update" and is_diff: + attrs["style"] = f"background-color: {self.new_color};" + return attrs + + +class VersionDiff(Diff): + """ + Special diff class, for use with version history views. Note that + while based on :class:`Diff`, this class uses a different + signature for the constructor. + + :param version: Reference to a Continuum version record (object). + + :param \\**kwargs: Remaining kwargs are passed as-is to the + :class:`Diff` constructor. + """ + + def __init__(self, version, **kwargs): + import sqlalchemy_continuum as continuum # pylint: disable=import-outside-toplevel + from wutta_continuum.util import ( # pylint: disable=import-outside-toplevel + render_operation_type, + ) + + self.version = version + self.model_class = continuum.parent_class(type(self.version)) + self.mapper = sa.inspect(self.model_class) + self.version_mapper = sa.inspect(type(self.version)) + self.title = kwargs.pop("title", self.model_class.__name__) + + self.operation_title = render_operation_type(self.version.operation_type) + + if "nature" not in kwargs: + if ( + version.previous + and version.operation_type == continuum.Operation.DELETE + ): + kwargs["nature"] = "delete" + elif version.previous: + kwargs["nature"] = "update" + else: + kwargs["nature"] = "create" + + if "fields" not in kwargs: + kwargs["fields"] = self.get_default_fields() + + old_data = {} + new_data = {} + for field in kwargs["fields"]: + if version.previous: + old_data[field] = getattr(version.previous, field) + new_data[field] = getattr(version, field) + + super().__init__(old_data, new_data, **kwargs) + + def get_default_fields(self): # pylint: disable=missing-function-docstring + fields = sorted(self.version_mapper.columns.keys()) + + unwanted = [ + "transaction_id", + "end_transaction_id", + "operation_type", + ] + + return [field for field in fields if field not in unwanted] + + def render_version_value(self, value): # pylint: disable=missing-function-docstring + return HTML.tag("span", c=[repr(value)], style="font-family: monospace;") + + def render_old_value(self, field): + if self.nature == "create": + return "" + value = self.old_value(field) + return self.render_version_value(value) + + def render_new_value(self, field): + if self.nature == "delete": + return "" + value = self.new_value(field) + return self.render_version_value(value) diff --git a/src/wuttaweb/templates/base.mako b/src/wuttaweb/templates/base.mako index 4f65595..0f740c5 100644 --- a/src/wuttaweb/templates/base.mako +++ b/src/wuttaweb/templates/base.mako @@ -152,6 +152,8 @@ font-size: 2rem; font-weight: bold; margin-bottom: 0 !important; + display: flex; + gap: 0.6rem; } #content-title h1 { @@ -242,7 +244,9 @@ ## nb. this is the index title proper
- % if index_title: + % if index_title_rendered is not Undefined and index_title_rendered: +

${index_title_rendered}

+ % elif index_title: % if index_url:

${h.link_to(index_title, index_url)}

% else: @@ -279,7 +283,7 @@ class="has-background-primary">
-
+

@@ -781,7 +785,32 @@ % endif -<%def name="render_prevnext_header_buttons()"> +<%def name="render_prevnext_header_buttons()"> + % if show_prev_next is not Undefined and show_prev_next: + + Older + + + Newer + + % endif + ############################## ## vue components + app diff --git a/src/wuttaweb/templates/diff.mako b/src/wuttaweb/templates/diff.mako new file mode 100644 index 0000000..c503c0a --- /dev/null +++ b/src/wuttaweb/templates/diff.mako @@ -0,0 +1,15 @@ +## -*- coding: utf-8; -*- + + + + % for column in diff.columns: + + % endfor + + + + % for field in diff.fields: + ${diff.render_field_row(field)} + % endfor + +
${column}
diff --git a/src/wuttaweb/templates/master/view.mako b/src/wuttaweb/templates/master/view.mako index 7d189ef..08615f3 100644 --- a/src/wuttaweb/templates/master/view.mako +++ b/src/wuttaweb/templates/master/view.mako @@ -5,6 +5,18 @@ <%def name="content_title()">${instance_title} +<%def name="render_instance_header_title_extras()"> + ${parent.render_instance_header_title_extras()} + % if master.should_expose_versions(): + + View History + + % endif + + <%def name="page_layout()"> % if master.has_rows: diff --git a/src/wuttaweb/templates/master/view_version.mako b/src/wuttaweb/templates/master/view_version.mako new file mode 100644 index 0000000..361055e --- /dev/null +++ b/src/wuttaweb/templates/master/view_version.mako @@ -0,0 +1,35 @@ +## -*- coding: utf-8; -*- +<%inherit file="/master/form.mako" /> + +<%def name="title()">${index_title} » ${instance_title} » changes @ TXN ${transaction.id} + +<%def name="content_title()">changes @ TXN ${transaction.id} + +<%def name="page_content()"> +
+ + + ${changed} + + + + ${transaction.user or ""} + + + + ${transaction.remote_addr or ""} + + + + ${transaction.id} + + +
+ +
+ % for diff in version_diffs: +

${diff.title} (${diff.operation_title})

+ ${diff.render_html()} + % endfor +
+ diff --git a/src/wuttaweb/templates/master/view_versions.mako b/src/wuttaweb/templates/master/view_versions.mako new file mode 100644 index 0000000..1218829 --- /dev/null +++ b/src/wuttaweb/templates/master/view_versions.mako @@ -0,0 +1,20 @@ +## -*- coding: utf-8; -*- +<%inherit file="/page.mako" /> + +<%def name="title()">${index_title} » ${instance_title} » history + +<%def name="content_title()">Version History + +<%def name="page_content()"> + ${grid.render_vue_tag()} + + +<%def name="render_vue_templates()"> + ${parent.render_vue_templates()} + ${grid.render_vue_template()} + + +<%def name="make_vue_components()"> + ${parent.make_vue_components()} + ${grid.render_vue_finalize()} + diff --git a/src/wuttaweb/testing.py b/src/wuttaweb/testing.py index ab13273..322d470 100644 --- a/src/wuttaweb/testing.py +++ b/src/wuttaweb/testing.py @@ -24,14 +24,18 @@ WuttaWeb - test utilities """ +import sys from unittest.mock import MagicMock import fanstatic +import pytest from pyramid import testing from wuttjamaican.testing import DataTestCase +from wuttjamaican.db.model.base import metadata from wuttaweb import subscribers +from wuttaweb.conf import WuttaWebConfigExtension class WebTestCase(DataTestCase): @@ -101,4 +105,66 @@ class WebTestCase(DataTestCase): """ Make and return a new dummy request object. """ - return testing.DummyRequest() + return testing.DummyRequest(client_addr="127.0.0.1") + + +@pytest.mark.versioned +class VersionWebTestCase(WebTestCase): + """ + Base class for test suites requiring a full (typical) web app, + with Continuum versioning support. + """ + + def setUp(self): + self.setup_versioning() + + def setup_versioning(self): + """ + Perform setup for the testing web app. + """ + self.setup_web() + + def tearDown(self): + self.teardown_versioning() + + def teardown_versioning(self): + """ + Perform teardown for the testing web app. + """ + import sqlalchemy_continuum as continuum # pylint: disable=import-outside-toplevel + + continuum.remove_versioning() + continuum.versioning_manager.transaction_cls = continuum.TransactionFactory() + self.teardown_web() + + def make_config(self, **kwargs): + """ + Make and customize the config object. + + We override this to explicitly enable the versioning feature. + """ + from wutta_continuum.conf import ( # pylint: disable=import-outside-toplevel + WuttaContinuumConfigExtension, + ) + + config = super().make_config(**kwargs) + config.setdefault("wutta_continuum.enable_versioning", "true") + + # nb. must purge model classes from sys.modules, so they will + # be reloaded and sqlalchemy-continuum can reconfigure + if "wuttjamaican.db.model" in sys.modules: + del sys.modules["wuttjamaican.db.model.batch"] + del sys.modules["wuttjamaican.db.model.upgrades"] + del sys.modules["wuttjamaican.db.model.auth"] + del sys.modules["wuttjamaican.db.model.base"] + del sys.modules["wuttjamaican.db.model"] + + self.assertNotIn("user_version", metadata.tables) + + ext = WuttaWebConfigExtension() + ext.configure(config) + + ext = WuttaContinuumConfigExtension() + ext.startup(config) + + return config diff --git a/src/wuttaweb/views/master.py b/src/wuttaweb/views/master.py index 500a276..607f6d8 100644 --- a/src/wuttaweb/views/master.py +++ b/src/wuttaweb/views/master.py @@ -25,6 +25,7 @@ Base Logic for Master Views """ # pylint: disable=too-many-lines +import datetime import logging import os import threading @@ -33,13 +34,14 @@ import sqlalchemy as sa from sqlalchemy import orm from pyramid.renderers import render_to_response -from webhelpers2.html import HTML +from webhelpers2.html import HTML, tags from wuttjamaican.util import get_class_hierarchy from wuttaweb.views.base import View from wuttaweb.util import get_form_data, render_csrf_token from wuttaweb.db import Session from wuttaweb.progress import SessionProgress +from wuttaweb.diffs import VersionDiff log = logging.getLogger(__name__) @@ -348,6 +350,20 @@ class MasterView(View): # pylint: disable=too-many-public-methods "configuring" - i.e. it should have a :meth:`configure()` view. Default value is ``False``. + .. attribute:: has_versions + + Boolean indicating whether the master view should expose + version history for its data records - i.e. it should have a + :meth:`view_versions()` view. Default value is ``False``. + + See also :meth:`should_expose_versions()`. + + .. attribute:: version_grid_columns + + List of columns for the :meth:`view_versions()` view grid. + + This is optional; see also :meth:`get_version_grid_columns()`. + **ROW FEATURES** .. attribute:: has_rows @@ -435,6 +451,9 @@ class MasterView(View): # pylint: disable=too-many-public-methods rows_paginate_on_backend = True rows_viewable = False + # versioning features + has_versions = False + # current action listing = False creating = False @@ -891,6 +910,363 @@ class MasterView(View): # pylint: disable=too-many-public-methods ) return form + ############################## + # version history methods + ############################## + + @classmethod + def get_model_version_class(cls): + """ + Returns the version class for the master model class. + + Should only be relevant if :attr:`has_versions` is true. + """ + import sqlalchemy_continuum as continuum # pylint: disable=import-outside-toplevel + + return continuum.version_class(cls.get_model_class()) + + def should_expose_versions(self): + """ + Returns boolean indicating whether versioning history should + be exposed for the current user. This will return ``True`` + unless any of the following are ``False``: + + * :attr:`has_versions` + * :meth:`wuttjamaican:wuttjamaican.app.AppHandler.continuum_is_enabled()` + * ``self.has_perm("versions")`` - cf. :meth:`has_perm()` + + :returns: ``True`` if versioning should be exposed for current + user; else ``False``. + """ + if not self.has_versions: + return False + + if not self.app.continuum_is_enabled(): + return False + + if not self.has_perm("versions"): + return False + + return True + + def view_versions(self): + """ + View to list version history for an object. See also + :meth:`view_version()`. + + This usually corresponds to a URL like + ``/widgets/XXX/versions/`` where ``XXX`` represents the key/ID + for the record. + + By default, this view is included only if :attr:`has_versions` + is true. + + The default view logic will show a "grid" (table) with the + record's version history. + + See also: + + * :meth:`make_version_grid()` + """ + instance = self.get_instance() + instance_title = self.get_instance_title(instance) + grid = self.make_version_grid(instance) + + # return grid data only, if partial page was requested + if self.request.GET.get("partial"): + context = grid.get_vue_context() + if grid.paginated and grid.paginate_on_backend: + context["pager_stats"] = grid.get_vue_pager_stats() + return self.json_response(context) + + index_link = tags.link_to(self.get_index_title(), self.get_index_url()) + + instance_link = tags.link_to( + instance_title, self.get_action_url("view", instance) + ) + + index_title_rendered = HTML.literal(" »").join( + [index_link, instance_link] + ) + + return self.render_to_response( + "view_versions", + { + "index_title_rendered": index_title_rendered, + "instance": instance, + "instance_title": instance_title, + "instance_url": self.get_action_url("view", instance), + "grid": grid, + }, + ) + + def make_version_grid(self, instance=None, **kwargs): + """ + Create and return a grid for use with the + :meth:`view_versions()` view. + + See also related methods, which are called by this one: + + * :meth:`get_version_grid_key()` + * :meth:`get_version_grid_columns()` + * :meth:`get_version_grid_data()` + * :meth:`configure_version_grid()` + + :returns: :class:`~wuttaweb.grids.base.Grid` instance + """ + import sqlalchemy_continuum as continuum # pylint: disable=import-outside-toplevel + + route_prefix = self.get_route_prefix() + # instance = kwargs.pop("instance", None) + if not instance: + instance = self.get_instance() + + if "key" not in kwargs: + kwargs["key"] = self.get_version_grid_key() + + if "model_class" not in kwargs: + kwargs["model_class"] = continuum.transaction_class(self.get_model_class()) + + if "columns" not in kwargs: + kwargs["columns"] = self.get_version_grid_columns() + + if "data" not in kwargs: + kwargs["data"] = self.get_version_grid_data(instance) + + if "actions" not in kwargs: + route = f"{route_prefix}.version" + + def url(txn, i): # pylint: disable=unused-argument + return self.request.route_url(route, uuid=instance.uuid, txnid=txn.id) + + kwargs["actions"] = [ + self.make_grid_action("view", icon="eye", url=url), + ] + + kwargs.setdefault("paginated", True) + + grid = self.make_grid(**kwargs) + self.configure_version_grid(grid) + grid.load_settings() + return grid + + @classmethod + def get_version_grid_key(cls): + """ + Returns the unique key to be used for the version grid, for caching + sort/filter options etc. + + This is normally called automatically from :meth:`make_version_grid()`. + + :returns: Grid key as string + """ + if hasattr(cls, "version_grid_key"): + return cls.version_grid_key + return f"{cls.get_route_prefix()}.history" + + def get_version_grid_columns(self): + """ + Returns the default list of version grid column names, for the + :meth:`view_versions()` view. + + This is normally called automatically by + :meth:`make_version_grid()`. + + Subclass may define :attr:`version_grid_columns` for simple + cases, or can override this method if needed. + + :returns: List of string column names + """ + if hasattr(self, "version_grid_columns"): + return self.version_grid_columns + + return [ + "id", + "issued_at", + "user", + "remote_addr", + ] + + def get_version_grid_data(self, instance): + """ + Returns the grid data query for the :meth:`view_versions()` + view. + + This is normally called automatically by + :meth:`make_version_grid()`. + + Default query will locate SQLAlchemy-Continuum ``transaction`` + records which are associated with versions of the given model + instance. See also + :func:`wutta-continuum:wutta_continuum.util.model_transaction_query()`. + + :returns: :class:`~sqlalchemy:sqlalchemy.orm.Query` instance + """ + import sqlalchemy_continuum as continuum # pylint: disable=import-outside-toplevel + from wutta_continuum.util import ( # pylint: disable=import-outside-toplevel + model_transaction_query, + ) + + model_class = self.get_model_class() + txncls = continuum.transaction_class(model_class) + query = model_transaction_query(instance) + return query.order_by(txncls.issued_at.desc()) + + def configure_version_grid(self, g): + """ + Configure the grid for the :meth:`view_versions()` view. + + This is called automatically by :meth:`make_version_grid()`. + + Default logic applies basic customization to the column labels etc. + """ + # id + g.set_label("id", "TXN ID") + # g.set_link("id") + + # issued_at + g.set_label("issued_at", "Changed") + g.set_renderer("issued_at", self.render_issued_at) + g.set_link("issued_at") + g.set_sort_defaults("issued_at", "desc") + + # user + g.set_label("user", "Changed by") + g.set_link("user") + + # remote_addr + g.set_label("remote_addr", "IP Address") + + def view_version(self): # pylint: disable=too-many-locals + """ + View to show diff details for a particular object version. + See also :meth:`view_versions()`. + + This usually corresponds to a URL like + ``/widgets/XXX/versions/YYY`` where ``XXX`` represents the + key/ID for the record and YYY represents a + SQLAlchemy-Continuum ``transaction.id``. + + By default, this view is included only if :attr:`has_versions` + is true. + + The default view logic will display a "diff" table showing how + the record's values were changed within a transaction. + + See also: + + * :func:`wutta-continuum:wutta_continuum.util.model_transaction_query()` + * :meth:`get_relevant_versions()` + * :class:`~wuttaweb.diffs.VersionDiff` + """ + import sqlalchemy_continuum as continuum # pylint: disable=import-outside-toplevel + from wutta_continuum.util import ( # pylint: disable=import-outside-toplevel + model_transaction_query, + ) + + instance = self.get_instance() + model_class = self.get_model_class() + route_prefix = self.get_route_prefix() + txncls = continuum.transaction_class(model_class) + transactions = model_transaction_query(instance) + + txnid = self.request.matchdict["txnid"] + txn = transactions.filter(txncls.id == txnid).first() + if not txn: + raise self.notfound() + + prev_url = None + older = ( + transactions.filter(txncls.issued_at <= txn.issued_at) + .filter(txncls.id != txnid) + .order_by(txncls.issued_at.desc()) + .first() + ) + if older: + prev_url = self.request.route_url( + f"{route_prefix}.version", uuid=instance.uuid, txnid=older.id + ) + + next_url = None + newer = ( + transactions.filter(txncls.issued_at >= txn.issued_at) + .filter(txncls.id != txnid) + .order_by(txncls.issued_at) + .first() + ) + if newer: + next_url = self.request.route_url( + f"{route_prefix}.version", uuid=instance.uuid, txnid=newer.id + ) + + version_diffs = [ + VersionDiff(version) + for version in self.get_relevant_versions(txn, instance) + ] + + index_link = tags.link_to(self.get_index_title(), self.get_index_url()) + + instance_title = self.get_instance_title(instance) + instance_link = tags.link_to( + instance_title, self.get_action_url("view", instance) + ) + + history_link = tags.link_to( + "history", + self.request.route_url(f"{route_prefix}.versions", uuid=instance.uuid), + ) + + index_title_rendered = HTML.literal(" »").join( + [index_link, instance_link, history_link] + ) + + return self.render_to_response( + "view_version", + { + "index_title_rendered": index_title_rendered, + "instance": instance, + "instance_title": instance_title, + "instance_url": self.get_action_url("versions", instance), + "transaction": txn, + "changed": self.render_issued_at(txn, None, None), + "version_diffs": version_diffs, + "show_prev_next": True, + "prev_url": prev_url, + "next_url": next_url, + }, + ) + + def get_relevant_versions(self, transaction, instance): + """ + Should return all version records pertaining to the given + model instance and transaction. + + This is normally called from :meth:`view_version()`. + + :param transaction: SQLAlchemy-Continuum ``transaction`` + record/instance. + + :param instance: Instance of the model class. + + :returns: List of version records. + """ + session = self.Session() + vercls = self.get_model_version_class() + return ( + session.query(vercls) + .filter(vercls.transaction == transaction) + .filter(vercls.uuid == instance.uuid) + .all() + ) + + def render_issued_at( # pylint: disable=missing-function-docstring,unused-argument + self, txn, key, value + ): + dt = txn.issued_at + dt = dt.replace(tzinfo=datetime.timezone.utc) + dt = dt.astimezone(None) + return self.app.render_datetime(dt) + ############################## # autocomplete methods ############################## @@ -3065,7 +3441,10 @@ class MasterView(View): # pylint: disable=too-many-public-methods cls._defaults(config) @classmethod - def _defaults(cls, config): + def _defaults(cls, config): # pylint: disable=too-many-statements + wutta_config = config.registry.settings.get("wutta_config") + app = wutta_config.get_app() + route_prefix = cls.get_route_prefix() permission_prefix = cls.get_permission_prefix() url_prefix = cls.get_url_prefix() @@ -3171,6 +3550,7 @@ class MasterView(View): # pylint: disable=too-many-public-methods # download if cls.downloadable: + instance_url_prefix = cls.get_instance_url_prefix() config.add_route( f"{route_prefix}.download", f"{instance_url_prefix}/download" ) @@ -3188,6 +3568,7 @@ class MasterView(View): # pylint: disable=too-many-public-methods # execute if cls.executable: + instance_url_prefix = cls.get_instance_url_prefix() config.add_route( f"{route_prefix}.execute", f"{instance_url_prefix}/execute", @@ -3235,3 +3616,30 @@ class MasterView(View): # pylint: disable=too-many-public-methods config.add_wutta_permission( permission_prefix, f"{permission_prefix}.view", f"View {model_title}" ) + + # version history + if cls.has_versions and app.continuum_is_enabled(): + instance_url_prefix = cls.get_instance_url_prefix() + config.add_wutta_permission( + permission_prefix, + f"{permission_prefix}.versions", + f"View version history for {model_title}", + ) + config.add_route( + f"{route_prefix}.versions", f"{instance_url_prefix}/versions/" + ) + config.add_view( + cls, + attr="view_versions", + route_name=f"{route_prefix}.versions", + permission=f"{permission_prefix}.versions", + ) + config.add_route( + f"{route_prefix}.version", f"{instance_url_prefix}/versions/{{txnid}}" + ) + config.add_view( + cls, + attr="view_version", + route_name=f"{route_prefix}.version", + permission=f"{permission_prefix}.versions", + ) diff --git a/tests/forms/test_schema.py b/tests/forms/test_schema.py index 7e1befb..a4189b5 100644 --- a/tests/forms/test_schema.py +++ b/tests/forms/test_schema.py @@ -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): diff --git a/tests/grids/test_filters.py b/tests/grids/test_filters.py index efb58eb..05ad891 100644 --- a/tests/grids/test_filters.py +++ b/tests/grids/test_filters.py @@ -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)}, diff --git a/tests/test_diffs.py b/tests/test_diffs.py new file mode 100644 index 0000000..b67cdf3 --- /dev/null +++ b/tests/test_diffs.py @@ -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("", row) + self.assertIn("'bar'", row) + self.assertIn(f'style="background-color: {diff.old_color};"', row) + self.assertIn("'baz'", row) + self.assertIn(f'style="background-color: {diff.new_color};"', row) + self.assertIn("", 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("", html) + self.assertIn("'bar'", html) + self.assertIn(f'style="background-color: {diff.old_color};"', html) + self.assertIn("'baz'", html) + self.assertIn(f'style="background-color: {diff.new_color};"', html) + self.assertIn("", html) + self.assertIn("", 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"), + ''fred'', + ) + + version = versions[1] + diff = self.make_diff(version) + self.assertEqual(diff.nature, "update") + self.assertEqual( + diff.render_old_value("username"), + ''fred'', + ) + self.assertEqual( + diff.render_new_value("username"), + ''freddie'', + ) + + version = versions[2] + diff = self.make_diff(version) + self.assertEqual(diff.nature, "delete") + self.assertEqual( + diff.render_old_value("username"), + ''freddie'', + ) + self.assertEqual(diff.render_new_value("username"), "") diff --git a/tests/util.py b/tests/util.py index df8d95a..ef0a348 100644 --- a/tests/util.py +++ b/tests/util.py @@ -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 [] diff --git a/tests/views/test_batch.py b/tests/views/test_batch.py index 7c1aafa..5bfda5a 100644 --- a/tests/views/test_batch.py +++ b/tests/views/test_batch.py @@ -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") diff --git a/tests/views/test_master.py b/tests/views/test_master.py index 9c28712..0a9bc1b 100644 --- a/tests/views/test_master.py +++ b/tests/views/test_master.py @@ -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("', + 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( + '', + response.text, + ) diff --git a/tox.ini b/tox.ini index ed4b4df..5c91961 100644 --- a/tox.ini +++ b/tox.ini @@ -4,10 +4,13 @@ envlist = py38, py39, py310, py311, nox [testenv] extras = continuum,tests -commands = pytest {posargs} +commands = + pytest -m 'not versioned' {posargs} + pytest -m 'versioned' {posargs} [testenv:nox] extras = tests +commands = pytest -m 'not versioned' {posargs} [testenv:pylint] basepython = python3.11 @@ -15,7 +18,9 @@ commands = pylint wuttaweb [testenv:coverage] basepython = python3.11 -commands = pytest --cov=wuttaweb --cov-report=html --cov-fail-under=100 +commands = + pytest -m 'not versioned' --cov=wuttaweb + pytest -m 'versioned' --cov-append --cov=wuttaweb --cov-report=html --cov-fail-under=100 [testenv:docs] basepython = python3.11