Compare commits
5 commits
787765f986
...
49f43e53f1
| Author | SHA1 | Date | |
|---|---|---|---|
| 49f43e53f1 | |||
| 6c634303ae | |||
| 16131cd256 | |||
| d08ba5fe51 | |||
| 6f26120640 |
8 changed files with 167 additions and 26 deletions
10
CHANGELOG.md
10
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/)
|
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
|
||||||
|
|
|
||||||
|
|
@ -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",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue