diff --git a/CHANGELOG.md b/CHANGELOG.md index 9202116..63eca3a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,16 @@ All notable changes to wuttaweb will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). +## v0.29.3 (2026-03-15) + +### Fix + +- fix datetime handling for alembic migrations view +- keep original value along with rendered, in grid vue context +- render human-friendly datetime for such fields in version diff +- remove version pin for setuptools dependency +- make pylint happy + ## v0.29.2 (2026-03-10) ### Fix diff --git a/pyproject.toml b/pyproject.toml index 2613603..25183bd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" [project] name = "WuttaWeb" -version = "0.29.2" +version = "0.29.3" description = "Web App for Wutta Framework" readme = "README.md" authors = [{name = "Lance Edgar", email = "lance@wuttaproject.org"}] @@ -52,11 +52,6 @@ dependencies = [ # can address a new bug that showed up in SA 2.1.0b1 # cf. https://github.com/kvesteri/sqlalchemy-utils/issues/800 "SQLAlchemy<2.1", - - # nb. this must be pinned for now, until pyramid can remove - # its dependency on pkg_resources. - # cf. https://github.com/Pylons/pyramid/issues/3731 - "setuptools<81", ] diff --git a/src/wuttaweb/diffs.py b/src/wuttaweb/diffs.py index cb59ed5..23eaa7e 100644 --- a/src/wuttaweb/diffs.py +++ b/src/wuttaweb/diffs.py @@ -25,6 +25,7 @@ Tools for displaying simple data diffs """ import sqlalchemy as sa +from sqlalchemy import orm from pyramid.renderers import render from webhelpers2.html import HTML @@ -150,6 +151,25 @@ class VersionDiff(WebDiff): # be embedded within a more complex result. text = HTML.tag("span", c=[repr(value)], style="font-family: monospace;") + # style to apply for bold human-friendly text (if applicable) + bold = "margin-left: 2rem; font-style: italic; font-weight: bold;" + + # check for standard datetime field + if self.mapper.has_property(field): + prop = self.mapper.get_property(field) + if isinstance(prop, orm.ColumnProperty): + if len(prop.columns) == 1: + col = prop.columns[0] + if isinstance(col.type, sa.DateTime): + if value: + # render as local datetime w/ "time since" tooltip + display = HTML.tag( + "span", + c=self.app.render_datetime(value, html=True), + style=bold, + ) + return HTML.tag("span", c=[text, display]) + # loop thru all mapped relationship props for prop in self.mapper.relationships: @@ -176,12 +196,9 @@ class VersionDiff(WebDiff): if ref := getattr(ref, "version_parent", None): # render text w/ related object as bold string - style = ( - "margin-left: 2rem; font-style: italic; font-weight: bold;" - ) return HTML.tag( "span", - c=[text, HTML.tag("span", c=[str(ref)], style=style)], + c=[text, HTML.tag("span", c=[str(ref)], style=bold)], ) return text diff --git a/src/wuttaweb/grids/base.py b/src/wuttaweb/grids/base.py index 1906997..e2a9f83 100644 --- a/src/wuttaweb/grids/base.py +++ b/src/wuttaweb/grids/base.py @@ -2501,11 +2501,10 @@ class Grid: # pylint: disable=too-many-instance-attributes,too-many-public-meth # loop thru data data = [] row_classes = {} - for i, record in enumerate(original_data, 1): - original_record = record + for i, original_record in enumerate(original_data, 1): # convert record to new dict - record = self.object_to_dict(record) + record = self.object_to_dict(original_record) # discard non-declared fields record = {field: record[field] for field in record if field in self.columns} @@ -2518,19 +2517,17 @@ class Grid: # pylint: disable=too-many-instance-attributes,too-many-public-meth # nb. no need to render if column not included if key in self.columns: value = record.get(key, None) - record[key] = renderer(original_record, key, value) + record[f"_rendered_{key}"] = renderer(original_record, key, value) # add action urls to each record for action in self.actions: key = f"_action_url_{action.key}" if key not in record: - url = action.get_url(original_record, i) - if url: + if url := action.get_url(original_record, i): record[key] = url # set row css class if applicable - css_class = self.get_row_class(original_record, record, i) - if css_class: + if css_class := self.get_row_class(original_record, record, i): # nb. use *string* zero-based index, for js compat row_classes[str(i - 1)] = css_class diff --git a/src/wuttaweb/templates/grids/vue_template.mako b/src/wuttaweb/templates/grids/vue_template.mako index 47720fa..34424e9 100644 --- a/src/wuttaweb/templates/grids/vue_template.mako +++ b/src/wuttaweb/templates/grids/vue_template.mako @@ -177,9 +177,9 @@ cell-class="c_${column['field']}"> % if grid.is_linked(column['field']): + v-html="props.row?._rendered_${column['field']} === undefined ? props.row.${column['field']} : props.row._rendered_${column['field']}" /> % else: - + % endif % endif diff --git a/src/wuttaweb/views/alembic.py b/src/wuttaweb/views/alembic.py index ed9352e..5d73849 100644 --- a/src/wuttaweb/views/alembic.py +++ b/src/wuttaweb/views/alembic.py @@ -44,19 +44,23 @@ from webhelpers2.html import tags, HTML from wuttaweb.views import View, MasterView from wuttaweb.forms import widgets +from wuttaweb.forms.schema import WuttaDateTime log = logging.getLogger(__name__) -def normalize_revision(config, rev): # pylint: disable=missing-function-docstring +def normalize_revision( + config, rev, json_safe=False +): # pylint: disable=missing-function-docstring 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) + if json_safe: + created = app.render_datetime(created) return { "revision": rev.revision, @@ -92,7 +96,7 @@ class AlembicDashboardView(View): current_heads = context.get_current_heads() def normalize(rev): - normal = normalize_revision(self.config, rev) + normal = normalize_revision(self.config, rev, json_safe=True) normal["is_current"] = rev.revision in current_heads normal["revision"] = tags.link_to( @@ -318,6 +322,9 @@ class AlembicMigrationView(MasterView): # pylint: disable=abstract-method g.set_label("is_head", "Head") g.set_renderer("is_head", self.render_is_head) + # created + g.set_renderer("created", "datetime") + def render_is_head( # pylint: disable=missing-function-docstring,unused-argument self, rev, field, value ): @@ -369,6 +376,10 @@ class AlembicMigrationView(MasterView): # pylint: disable=abstract-method # path f.set_widget("path", widgets.CopyableTextWidget()) + # created + f.set_node("created", WuttaDateTime()) + f.set_widget("created", widgets.WuttaDateTimeWidget(self.request)) + def make_create_form(self): # pylint: disable=empty-docstring """ """ alembic = make_alembic_config(self.config) diff --git a/tests/grids/test_base.py b/tests/grids/test_base.py index 15a7a06..2978364 100644 --- a/tests/grids/test_base.py +++ b/tests/grids/test_base.py @@ -2025,7 +2025,12 @@ class TestGrid(WebTestCase): context, { "data": [ - {"foo": "blah blah", "baz": "zoo", "_action_url_view": "/blarg"} + { + "foo": "bar", + "_rendered_foo": "blah blah", + "baz": "zoo", + "_action_url_view": "/blarg", + } ], "row_classes": {}, }, @@ -2080,7 +2085,16 @@ class TestGrid(WebTestCase): # can override value rendering grid.set_renderer("foo", lambda record, key, value: "blah blah") data = grid.get_vue_data() - self.assertEqual(data, [{"foo": "blah blah", "_action_url_view": "/blarg"}]) + self.assertEqual( + data, + [ + { + "foo": "bar", + "_rendered_foo": "blah blah", + "_action_url_view": "/blarg", + } + ], + ) def test_get_row_class(self): model = self.app.model diff --git a/tests/test_diffs.py b/tests/test_diffs.py index 00c78a9..6f66893 100644 --- a/tests/test_diffs.py +++ b/tests/test_diffs.py @@ -1,5 +1,14 @@ # -*- coding: utf-8; -*- +import datetime +import re +from unittest.mock import patch + +import sqlalchemy as sa +from sqlalchemy import orm + +from wuttjamaican.util import get_timezone_by_name + from wuttaweb import diffs as mod from wuttaweb.testing import WebTestCase, VersionWebTestCase @@ -70,7 +79,7 @@ class TestVersionDiff(VersionWebTestCase): ["active", "person_uuid", "prevent_edit", "username", "uuid"], ) - def test_render_version_value(self): + def test_render_version_value_with_objref(self): import sqlalchemy_continuum as continuum model = self.app.model @@ -130,3 +139,91 @@ class TestVersionDiff(VersionWebTestCase): self.assertIn(str(person.uuid), html) self.assertIn("Fred Flintstone", html) self.assertEqual(diff.render_new_value("person_uuid"), "") + + def test_render_version_value_with_datetime(self): + import sqlalchemy_continuum as continuum + + tzlocal = get_timezone_by_name("America/Los_Angeles") + with patch.object(self.app, "get_timezone", return_value=tzlocal): + + # make one person + model = self.app.model + person = model.Person(full_name="Fred Flintstone") + self.session.add(person) + self.session.commit() + + # get its one version record + txncls = continuum.transaction_class(model.Person) + vercls = continuum.version_class(model.Person) + version = self.session.query(vercls).order_by(vercls.transaction_id).one() + + # make a diff, but we have to mock some things up for the test + # coverage, since we don't currently have a versioned datetime + # field in the base model. + diff = self.make_diff(version) + + mock_column = sa.Column("timestamp", sa.DateTime()) + mock_property = orm.ColumnProperty(column=mock_column) + + class MockMapper: + def __init__(self, mapper): + self.mapper = mapper + + def __getattr__(self, name): + if hasattr(self.mapper, name): + return getattr(self.mapper, name) + raise AttributeError(f"attr not found: {name}") + + def has_property(self, field): + if field == "timestamp": + return True + return self.mapper.has_property(field) + + def get_property(self, field): + if field == "timestamp": + return mock_property + return self.mapper.get_property(field) + + mock_mapper = MockMapper(diff.mapper) + with patch.object(diff, "mapper", new=mock_mapper): + + # zone-aware local time + dt = datetime.datetime(2026, 3, 14, 14, 0, tzinfo=tzlocal) + html = diff.render_version_value(version, "timestamp", dt) + self.assertTrue( + re.search( + r'\d{4}-\d{2}-\d{2} \d{2}:\d{2}-\d{4}', + html, + ) + ) + self.assertTrue( + re.search( + r'2026-03-14 14:00-0700', + html, + ) + ) + + # zone-naive (presumed UTC) + dt = datetime.datetime(2026, 3, 14, 21, 0) + html = diff.render_version_value(version, "timestamp", dt) + self.assertTrue( + re.search( + r'\d{4}-\d{2}-\d{2} \d{2}:\d{2}-\d{4}', + html, + ) + ) + self.assertTrue( + re.search( + r'2026-03-14 14:00-0700', + html, + ) + ) + + # null + html = diff.render_version_value(version, "timestamp", None) + self.assertFalse( + re.search( + r'\d{4}-\d{2}-\d{2} \d{2}:\d{2}-\d{4}', + html, + ) + )