3
0
Fork 0

Compare commits

...

3 commits

Author SHA1 Message Date
fc9844f1ca bump: version 0.24.1 → 0.25.0 2025-12-15 16:25:50 -06:00
a5d641d5bc feat: drop timezone, assume UTC for all datetime values in DB
i guess it was worth a try, but preserving system timezone was doomed
to failure since only postgres actually supports it.  from now on all
DateTime columns will be naive, but understood / assumed to be
UTC-local
2025-12-15 16:25:09 -06:00
900937826c feat: add make_utc() function, app method
as prep for dropping timezone from DB columns
2025-12-15 14:04:26 -06:00
11 changed files with 316 additions and 22 deletions

View file

@ -5,6 +5,13 @@ All notable changes to WuttJamaican 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.25.0 (2025-12-15)
### Feat
- drop timezone, assume UTC for all datetime values in DB
- add `make_utc()` function, app method
## v0.24.1 (2025-10-29) ## v0.24.1 (2025-10-29)
### Fix ### Fix

View file

@ -6,7 +6,7 @@ build-backend = "hatchling.build"
[project] [project]
name = "WuttJamaican" name = "WuttJamaican"
version = "0.24.1" version = "0.25.0"
description = "Base package for Wutta Framework" description = "Base package 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"}]

View file

@ -39,6 +39,7 @@ from wuttjamaican.util import (
load_object, load_object,
make_title, make_title,
make_full_name, make_full_name,
make_utc,
make_uuid, make_uuid,
make_true_uuid, make_true_uuid,
progress_loop, progress_loop,
@ -526,6 +527,14 @@ class AppHandler: # pylint: disable=too-many-public-methods
""" """
return make_full_name(*parts) return make_full_name(*parts)
def make_utc(self, dt=None, tzinfo=False):
"""
This returns a datetime local to the UTC timezone. It is a
convenience wrapper around
:func:`~wuttjamaican.util.make_utc()`.
"""
return make_utc(dt=dt, tzinfo=tzinfo)
def make_true_uuid(self): def make_true_uuid(self):
""" """
Generate a new UUID value. Generate a new UUID value.
@ -853,7 +862,8 @@ class AppHandler: # pylint: disable=too-many-public-methods
:returns: Text to display. :returns: Text to display.
""" """
return humanize.naturaltime(value) # TODO: this now assumes naive UTC value incoming...
return humanize.naturaltime(value, when=self.make_utc(tzinfo=False))
############################## ##############################
# getters for other handlers # getters for other handlers

View file

@ -24,7 +24,6 @@
Batch Handlers Batch Handlers
""" """
import datetime
import os import os
import shutil import shutil
@ -481,7 +480,7 @@ class BatchHandler(GenericHandler): # pylint: disable=too-many-public-methods
result = self.execute( # pylint: disable=assignment-from-none result = self.execute( # pylint: disable=assignment-from-none
batch, user=user, progress=progress, **kwargs batch, user=user, progress=progress, **kwargs
) )
batch.executed = datetime.datetime.now() batch.executed = self.app.make_utc()
batch.executed_by = user batch.executed_by = user
return result return result

View file

@ -0,0 +1,186 @@
"""drop time zones
Revision ID: b59a34266288
Revises: efdcb2c75034
Create Date: 2025-12-14 19:10:11.627188
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
import wuttjamaican.db.util
from sqlalchemy.dialects import postgresql
from wuttjamaican.util import make_utc
# revision identifiers, used by Alembic.
revision: str = "b59a34266288"
down_revision: Union[str, None] = "efdcb2c75034"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# upgrade.created
op.add_column("upgrade", sa.Column("created_new", sa.DateTime(), nullable=True))
upgrade = sa.sql.table(
"upgrade",
sa.sql.column("uuid"),
sa.sql.column("created"),
sa.sql.column("created_new"),
)
cursor = op.get_bind().execute(upgrade.select())
for row in cursor.fetchall():
op.get_bind().execute(
upgrade.update()
.where(upgrade.c.uuid == row.uuid)
.values({"created_new": make_utc(row.created)})
)
op.drop_column("upgrade", "created")
op.alter_column(
"upgrade",
"created_new",
new_column_name="created",
nullable=False,
existing_type=sa.DateTime(),
existing_nullable=True,
)
# upgrade.executed
op.add_column("upgrade", sa.Column("executed_new", sa.DateTime(), nullable=True))
upgrade = sa.sql.table(
"upgrade",
sa.sql.column("uuid"),
sa.sql.column("executed"),
sa.sql.column("executed_new"),
)
cursor = op.get_bind().execute(upgrade.select())
for row in cursor.fetchall():
if row.executed:
op.get_bind().execute(
upgrade.update()
.where(upgrade.c.uuid == row.uuid)
.values({"executed_new": make_utc(row.executed)})
)
op.drop_column("upgrade", "executed")
op.alter_column(
"upgrade",
"executed_new",
new_column_name="executed",
existing_type=sa.DateTime(),
existing_nullable=True,
)
# user_api_token.created
op.add_column(
"user_api_token", sa.Column("created_new", sa.DateTime(), nullable=True)
)
user_api_token = sa.sql.table(
"user_api_token",
sa.sql.column("uuid"),
sa.sql.column("created"),
sa.sql.column("created_new"),
)
cursor = op.get_bind().execute(user_api_token.select())
for row in cursor.fetchall():
op.get_bind().execute(
user_api_token.update()
.where(user_api_token.c.uuid == row.uuid)
.values({"created_new": make_utc(row.created)})
)
op.drop_column("user_api_token", "created")
op.alter_column(
"user_api_token",
"created_new",
new_column_name="created",
nullable=False,
existing_type=sa.DateTime(),
existing_nullable=True,
)
def downgrade() -> None:
# user_api_token.created
op.add_column(
"user_api_token",
sa.Column("created_old", sa.DateTime(timezone=True), nullable=True),
)
user_api_token = sa.sql.table(
"user_api_token",
sa.sql.column("uuid"),
sa.sql.column("created"),
sa.sql.column("created_old"),
)
cursor = op.get_bind().execute(user_api_token.select())
for row in cursor.fetchall():
op.get_bind().execute(
user_api_token.update()
.where(user_api_token.c.uuid == row.uuid)
.values({"created_old": row.created})
)
op.drop_column("user_api_token", "created")
op.alter_column(
"user_api_token",
"created_old",
new_column_name="created",
nullable=False,
existing_type=sa.DateTime(timezone=True),
existing_nullable=True,
)
# upgrade.executed
op.add_column(
"upgrade", sa.Column("executed_old", sa.DateTime(timezone=True), nullable=True)
)
upgrade = sa.sql.table(
"upgrade",
sa.sql.column("uuid"),
sa.sql.column("executed"),
sa.sql.column("executed_old"),
)
cursor = op.get_bind().execute(upgrade.select())
for row in cursor.fetchall():
if row.executed:
op.get_bind().execute(
upgrade.update()
.where(upgrade.c.uuid == row.uuid)
.values({"executed_old": row.executed})
)
op.drop_column("upgrade", "executed")
op.alter_column(
"upgrade",
"executed_old",
new_column_name="executed",
existing_type=sa.DateTime(timezone=True),
existing_nullable=True,
)
# upgrade.created
op.add_column(
"upgrade", sa.Column("created_old", sa.DateTime(timezone=True), nullable=True)
)
upgrade = sa.sql.table(
"upgrade",
sa.sql.column("uuid"),
sa.sql.column("created"),
sa.sql.column("created_old"),
)
cursor = op.get_bind().execute(upgrade.select())
for row in cursor.fetchall():
op.get_bind().execute(
upgrade.update()
.where(upgrade.c.uuid == row.uuid)
.values({"created_old": row.created})
)
op.drop_column("upgrade", "created")
op.alter_column(
"upgrade",
"created_old",
new_column_name="created",
nullable=False,
existing_type=sa.DateTime(timezone=True),
existing_nullable=True,
)

View file

@ -39,14 +39,13 @@ So a user's permissions are "inherited" from the role(s) to which they
belong. belong.
""" """
import datetime
import sqlalchemy as sa import sqlalchemy as sa
from sqlalchemy import orm from sqlalchemy import orm
from sqlalchemy.ext.associationproxy import association_proxy from sqlalchemy.ext.associationproxy import association_proxy
from wuttjamaican.db.util import uuid_column, uuid_fk_column from wuttjamaican.db.util import uuid_column, uuid_fk_column
from wuttjamaican.db.model.base import Base from wuttjamaican.db.model.base import Base
from wuttjamaican.util import make_utc
class Role(Base): # pylint: disable=too-few-public-methods class Role(Base): # pylint: disable=too-few-public-methods
@ -343,9 +342,9 @@ class UserAPIToken(Base): # pylint: disable=too-few-public-methods
) )
created = sa.Column( created = sa.Column(
sa.DateTime(timezone=True), sa.DateTime(),
nullable=False, nullable=False,
default=datetime.datetime.now, default=make_utc,
doc=""" doc="""
Date/time when the token was created. Date/time when the token was created.
""", """,

View file

@ -34,6 +34,7 @@ from sqlalchemy.ext.orderinglist import ordering_list
from wuttjamaican.db.model.base import uuid_column from wuttjamaican.db.model.base import uuid_column
from wuttjamaican.db.model.auth import User from wuttjamaican.db.model.auth import User
from wuttjamaican.db.util import UUID from wuttjamaican.db.util import UUID
from wuttjamaican.util import make_utc
class BatchMixin: class BatchMixin:
@ -223,9 +224,7 @@ class BatchMixin:
status_code = sa.Column(sa.Integer(), nullable=True) status_code = sa.Column(sa.Integer(), nullable=True)
status_text = sa.Column(sa.String(length=255), nullable=True) status_text = sa.Column(sa.String(length=255), nullable=True)
created = sa.Column( created = sa.Column(sa.DateTime(), nullable=False, default=make_utc)
sa.DateTime(timezone=True), nullable=False, default=datetime.datetime.now
)
created_by_uuid = sa.Column(UUID(), nullable=False) created_by_uuid = sa.Column(UUID(), nullable=False)
@declared_attr @declared_attr
@ -238,7 +237,7 @@ class BatchMixin:
cascade_backrefs=False, cascade_backrefs=False,
) )
executed = sa.Column(sa.DateTime(timezone=True), nullable=True) executed = sa.Column(sa.DateTime(), nullable=True)
executed_by_uuid = sa.Column(UUID(), nullable=True) executed_by_uuid = sa.Column(UUID(), nullable=True)
@declared_attr @declared_attr
@ -428,8 +427,5 @@ class BatchRowMixin: # pylint: disable=too-few-public-methods
status_text = sa.Column(sa.String(length=255), nullable=True) status_text = sa.Column(sa.String(length=255), nullable=True)
modified = sa.Column( modified = sa.Column(
sa.DateTime(timezone=True), sa.DateTime(), nullable=True, default=make_utc, onupdate=make_utc
nullable=True,
default=datetime.datetime.now,
onupdate=datetime.datetime.now,
) )

View file

@ -24,14 +24,13 @@
Upgrade Model Upgrade Model
""" """
import datetime
import sqlalchemy as sa import sqlalchemy as sa
from sqlalchemy import orm from sqlalchemy import orm
from wuttjamaican.enum import UpgradeStatus from wuttjamaican.enum import UpgradeStatus
from wuttjamaican.db.util import uuid_column, uuid_fk_column from wuttjamaican.db.util import uuid_column, uuid_fk_column
from wuttjamaican.db.model.base import Base from wuttjamaican.db.model.base import Base
from wuttjamaican.util import make_utc
class Upgrade(Base): # pylint: disable=too-few-public-methods class Upgrade(Base): # pylint: disable=too-few-public-methods
@ -44,9 +43,9 @@ class Upgrade(Base): # pylint: disable=too-few-public-methods
uuid = uuid_column() uuid = uuid_column()
created = sa.Column( created = sa.Column(
sa.DateTime(timezone=True), sa.DateTime(),
nullable=False, nullable=False,
default=datetime.datetime.now, default=make_utc,
doc=""" doc="""
When the upgrade record was created. When the upgrade record was created.
""", """,
@ -98,7 +97,7 @@ class Upgrade(Base): # pylint: disable=too-few-public-methods
) )
executed = sa.Column( executed = sa.Column(
sa.DateTime(timezone=True), sa.DateTime(),
nullable=True, nullable=True,
doc=""" doc="""
When the upgrade was executed. When the upgrade was executed.

View file

@ -24,6 +24,7 @@
WuttJamaican - utilities WuttJamaican - utilities
""" """
import datetime
import importlib import importlib
import logging import logging
import os import os
@ -189,6 +190,50 @@ def make_full_name(*parts):
return " ".join(parts) return " ".join(parts)
def make_utc(dt=None, tzinfo=False):
"""
This returns a datetime local to the UTC timezone. By default it
will be a *naive* datetime; the common use case is to convert as
needed for sake of writing to the database.
See also the shortcut
:meth:`~wuttjamaican.app.AppHandler.make_utc()` method on the app
handler.
:param dt: Optional :class:`python:datetime.datetime` instance.
If not specified, the current time will be used.
:param tzinfo: Boolean indicating whether the return value should
have its :attr:`~python:datetime.datetime.tzinfo` attribute
set. This is false by default in which case the return value
will be naive.
:returns: :class:`python:datetime.datetime` instance local to UTC.
"""
# use current time if none provided
if dt is None:
now = datetime.datetime.now(datetime.timezone.utc)
if tzinfo:
return now
return now.replace(tzinfo=None)
# otherwise may need to convert timezone
if dt.tzinfo:
if dt.tzinfo is not datetime.timezone.utc:
dt = dt.astimezone(datetime.timezone.utc)
if tzinfo:
return dt
return dt.replace(tzinfo=None)
# naive value returned as-is..
if not tzinfo:
return dt
# ..unless tzinfo is wanted, in which case this assumes naive
# value is in the UTC timezone
return dt.replace(tzinfo=datetime.timezone.utc)
def make_true_uuid(): def make_true_uuid():
""" """
Generate a new v7 UUID value. Generate a new v7 UUID value.

View file

@ -426,6 +426,11 @@ app_title = WuttaTest
name = self.app.make_full_name("Fred", "", "Flintstone", "") name = self.app.make_full_name("Fred", "", "Flintstone", "")
self.assertEqual(name, "Fred Flintstone") self.assertEqual(name, "Fred Flintstone")
def test_make_utc(self):
dt = self.app.make_utc()
self.assertIsInstance(dt, datetime.datetime)
self.assertIsNone(dt.tzinfo)
def test_make_uuid(self): def test_make_uuid(self):
uuid = self.app.make_uuid() uuid = self.app.make_uuid()
self.assertEqual(len(uuid), 32) self.assertEqual(len(uuid), 32)
@ -566,7 +571,7 @@ app_title = WuttaTest
now = datetime.datetime.now() now = datetime.datetime.now()
result = self.app.render_time_ago(now) result = self.app.render_time_ago(now)
self.assertEqual(result, "now") self.assertEqual(result, "now")
humanize.naturaltime.assert_called_once_with(now) humanize.naturaltime.assert_called_once()
def test_get_person(self): def test_get_person(self):
people = self.app.get_people_handler() people = self.app.get_people_handler()

View file

@ -1,5 +1,6 @@
# -*- coding: utf-8; -*- # -*- coding: utf-8; -*-
import datetime
import sys import sys
from unittest import TestCase from unittest import TestCase
from unittest.mock import patch, MagicMock from unittest.mock import patch, MagicMock
@ -164,6 +165,53 @@ class TestLoadObject(TestCase):
self.assertIs(result, TestCase) self.assertIs(result, TestCase)
class TestMakeUTC(TestCase):
def test_current_time(self):
# no tzinfo by default
dt = mod.make_utc()
self.assertIsInstance(dt, datetime.datetime)
self.assertIsNone(dt.tzinfo)
now = datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None)
self.assertAlmostEqual(int(dt.timestamp()), int(now.timestamp()))
# with tzinfo
dt = mod.make_utc(tzinfo=True)
self.assertIsInstance(dt, datetime.datetime)
self.assertIs(dt.tzinfo, datetime.timezone.utc)
now = datetime.datetime.now(datetime.timezone.utc)
self.assertAlmostEqual(int(dt.timestamp()), int(now.timestamp()))
def test_convert_with_tzinfo(self):
sample = datetime.datetime(
2024, 9, 15, 8, 30, tzinfo=datetime.timezone(-datetime.timedelta(hours=5))
)
# no tzinfo by default
dt = mod.make_utc(sample)
self.assertEqual(dt, datetime.datetime(2024, 9, 15, 13, 30, tzinfo=None))
# with tzinfo
dt = mod.make_utc(sample, tzinfo=True)
self.assertEqual(
dt, datetime.datetime(2024, 9, 15, 13, 30, tzinfo=datetime.timezone.utc)
)
def test_convert_without_tzinfo(self):
sample = datetime.datetime(2024, 9, 15, 8, 30)
# no tzinfo by default
dt = mod.make_utc(sample)
self.assertEqual(dt, datetime.datetime(2024, 9, 15, 8, 30, tzinfo=None))
# with tzinfo
dt = mod.make_utc(sample, tzinfo=True)
self.assertEqual(
dt, datetime.datetime(2024, 9, 15, 8, 30, tzinfo=datetime.timezone.utc)
)
class TestMakeUUID(TestCase): class TestMakeUUID(TestCase):
def test_basic(self): def test_basic(self):