3
0
Fork 0

Compare commits

..

5 commits

Author SHA1 Message Date
49f43e53f1 bump: version 0.29.2 → 0.29.3 2026-03-15 10:07:17 -05:00
6c634303ae fix: fix datetime handling for alembic migrations view
this should ensure the column sorting works, and adds normal rendering
for the form fields
2026-03-14 16:29:06 -05:00
16131cd256 fix: keep original value along with rendered, in grid vue context
this change was made for sake of sorting, when the backend is not
responsible for that.  in particular datetime values must be
"rendered" somehow when passing to frontend, but depending on various
factors the rendering may not preserve the "sensible" sort order
behavior, e.g. if "weekday name" begins the rendered string.

so in all cases now, rendered values will be given a distinct key in
the record dict, while the original value stays in its original
place.  this should let grid sorting work off the original value, and
hopefully all is well..fingers crossed
2026-03-14 16:24:58 -05:00
d08ba5fe51 fix: render human-friendly datetime for such fields in version diff
still render the "raw" value but include human-friendly for convenience
2026-03-14 16:19:17 -05:00
6f26120640 fix: remove version pin for setuptools dependency
since pyramid 2.1 now specifies the pin

cf. https://docs.pylonsproject.org/projects/pyramid/en/2.1-branch/changes.html#a1-2026-02-25
2026-03-14 15:36:42 -05:00
8 changed files with 167 additions and 26 deletions

View file

@ -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/) 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). 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) ## v0.29.2 (2026-03-10)
### Fix ### Fix

View file

@ -6,7 +6,7 @@ build-backend = "hatchling.build"
[project] [project]
name = "WuttaWeb" name = "WuttaWeb"
version = "0.29.2" version = "0.29.3"
description = "Web App for Wutta Framework" description = "Web App for Wutta Framework"
readme = "README.md" readme = "README.md"
authors = [{name = "Lance Edgar", email = "lance@wuttaproject.org"}] 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 # can address a new bug that showed up in SA 2.1.0b1
# cf. https://github.com/kvesteri/sqlalchemy-utils/issues/800 # cf. https://github.com/kvesteri/sqlalchemy-utils/issues/800
"SQLAlchemy<2.1", "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",
] ]

View file

@ -25,6 +25,7 @@ Tools for displaying simple data diffs
""" """
import sqlalchemy as sa import sqlalchemy as sa
from sqlalchemy import orm
from pyramid.renderers import render from pyramid.renderers import render
from webhelpers2.html import HTML from webhelpers2.html import HTML
@ -150,6 +151,25 @@ class VersionDiff(WebDiff):
# be embedded within a more complex result. # be embedded within a more complex result.
text = HTML.tag("span", c=[repr(value)], style="font-family: monospace;") 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 # loop thru all mapped relationship props
for prop in self.mapper.relationships: for prop in self.mapper.relationships:
@ -176,12 +196,9 @@ class VersionDiff(WebDiff):
if ref := getattr(ref, "version_parent", None): if ref := getattr(ref, "version_parent", None):
# render text w/ related object as bold string # render text w/ related object as bold string
style = (
"margin-left: 2rem; font-style: italic; font-weight: bold;"
)
return HTML.tag( return HTML.tag(
"span", "span",
c=[text, HTML.tag("span", c=[str(ref)], style=style)], c=[text, HTML.tag("span", c=[str(ref)], style=bold)],
) )
return text return text

View file

@ -2501,11 +2501,10 @@ class Grid: # pylint: disable=too-many-instance-attributes,too-many-public-meth
# loop thru data # loop thru data
data = [] data = []
row_classes = {} row_classes = {}
for i, record in enumerate(original_data, 1): for i, original_record in enumerate(original_data, 1):
original_record = record
# convert record to new dict # convert record to new dict
record = self.object_to_dict(record) record = self.object_to_dict(original_record)
# discard non-declared fields # discard non-declared fields
record = {field: record[field] for field in record if field in self.columns} 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 # nb. no need to render if column not included
if key in self.columns: if key in self.columns:
value = record.get(key, None) 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 # add action urls to each record
for action in self.actions: for action in self.actions:
key = f"_action_url_{action.key}" key = f"_action_url_{action.key}"
if key not in record: if key not in record:
url = action.get_url(original_record, i) if url := action.get_url(original_record, i):
if url:
record[key] = url record[key] = url
# set row css class if applicable # set row css class if applicable
css_class = self.get_row_class(original_record, record, i) if css_class := self.get_row_class(original_record, record, i):
if css_class:
# nb. use *string* zero-based index, for js compat # nb. use *string* zero-based index, for js compat
row_classes[str(i - 1)] = css_class row_classes[str(i - 1)] = css_class

View file

@ -177,9 +177,9 @@
cell-class="c_${column['field']}"> cell-class="c_${column['field']}">
% if grid.is_linked(column['field']): % if grid.is_linked(column['field']):
<a :href="props.row._action_url_view" <a :href="props.row._action_url_view"
v-html="props.row.${column['field']}" /> v-html="props.row?._rendered_${column['field']} === undefined ? props.row.${column['field']} : props.row._rendered_${column['field']}" />
% else: % else:
<span v-html="props.row.${column['field']}"></span> <span v-html="props.row?._rendered_${column['field']} === undefined ? props.row.${column['field']} : props.row._rendered_${column['field']}"></span>
% endif % endif
</${b}-table-column> </${b}-table-column>
% endif % endif

View file

@ -44,18 +44,22 @@ from webhelpers2.html import tags, HTML
from wuttaweb.views import View, MasterView from wuttaweb.views import View, MasterView
from wuttaweb.forms import widgets from wuttaweb.forms import widgets
from wuttaweb.forms.schema import WuttaDateTime
log = logging.getLogger(__name__) 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() app = config.get_app()
created = None created = None
if match := re.search(r"Create Date: (\d{4}-\d{2}-\d{2}[\d:\. ]+\d)", rev.longdoc): 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 = datetime.datetime.fromisoformat(match.group(1))
created = app.localtime(created, from_utc=False) created = app.localtime(created, from_utc=False)
if json_safe:
created = app.render_datetime(created) created = app.render_datetime(created)
return { return {
@ -92,7 +96,7 @@ class AlembicDashboardView(View):
current_heads = context.get_current_heads() current_heads = context.get_current_heads()
def normalize(rev): 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["is_current"] = rev.revision in current_heads
normal["revision"] = tags.link_to( normal["revision"] = tags.link_to(
@ -318,6 +322,9 @@ class AlembicMigrationView(MasterView): # pylint: disable=abstract-method
g.set_label("is_head", "Head") g.set_label("is_head", "Head")
g.set_renderer("is_head", self.render_is_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 def render_is_head( # pylint: disable=missing-function-docstring,unused-argument
self, rev, field, value self, rev, field, value
): ):
@ -369,6 +376,10 @@ class AlembicMigrationView(MasterView): # pylint: disable=abstract-method
# path # path
f.set_widget("path", widgets.CopyableTextWidget()) 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 def make_create_form(self): # pylint: disable=empty-docstring
""" """ """ """
alembic = make_alembic_config(self.config) alembic = make_alembic_config(self.config)

View file

@ -2025,7 +2025,12 @@ class TestGrid(WebTestCase):
context, context,
{ {
"data": [ "data": [
{"foo": "blah blah", "baz": "zoo", "_action_url_view": "/blarg"} {
"foo": "bar",
"_rendered_foo": "blah blah",
"baz": "zoo",
"_action_url_view": "/blarg",
}
], ],
"row_classes": {}, "row_classes": {},
}, },
@ -2080,7 +2085,16 @@ class TestGrid(WebTestCase):
# can override value rendering # can override value rendering
grid.set_renderer("foo", lambda record, key, value: "blah blah") grid.set_renderer("foo", lambda record, key, value: "blah blah")
data = grid.get_vue_data() 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): def test_get_row_class(self):
model = self.app.model model = self.app.model

View file

@ -1,5 +1,14 @@
# -*- coding: utf-8; -*- # -*- 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 import diffs as mod
from wuttaweb.testing import WebTestCase, VersionWebTestCase from wuttaweb.testing import WebTestCase, VersionWebTestCase
@ -70,7 +79,7 @@ class TestVersionDiff(VersionWebTestCase):
["active", "person_uuid", "prevent_edit", "username", "uuid"], ["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 import sqlalchemy_continuum as continuum
model = self.app.model model = self.app.model
@ -130,3 +139,91 @@ class TestVersionDiff(VersionWebTestCase):
self.assertIn(str(person.uuid), html) self.assertIn(str(person.uuid), html)
self.assertIn("Fred Flintstone", html) self.assertIn("Fred Flintstone", html)
self.assertEqual(diff.render_new_value("person_uuid"), "") 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'<span style="[^"]+"><span title="[^"]+">\d{4}-\d{2}-\d{2} \d{2}:\d{2}-\d{4}</span></span>',
html,
)
)
self.assertTrue(
re.search(
r'<span style="[^"]+"><span title="[^"]+">2026-03-14 14:00-0700</span></span>',
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'<span style="[^"]+"><span title="[^"]+">\d{4}-\d{2}-\d{2} \d{2}:\d{2}-\d{4}</span></span>',
html,
)
)
self.assertTrue(
re.search(
r'<span style="[^"]+"><span title="[^"]+">2026-03-14 14:00-0700</span></span>',
html,
)
)
# null
html = diff.render_version_value(version, "timestamp", None)
self.assertFalse(
re.search(
r'<span style="[^"]+"><span title="[^"]+">\d{4}-\d{2}-\d{2} \d{2}:\d{2}-\d{4}</span></span>',
html,
)
)