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/)
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)
### Fix

View file

@ -6,7 +6,7 @@ build-backend = "hatchling.build"
[project]
name = "WuttJamaican"
version = "0.24.1"
version = "0.25.0"
description = "Base package for Wutta Framework"
readme = "README.md"
authors = [{name = "Lance Edgar", email = "lance@wuttaproject.org"}]

View file

@ -39,6 +39,7 @@ from wuttjamaican.util import (
load_object,
make_title,
make_full_name,
make_utc,
make_uuid,
make_true_uuid,
progress_loop,
@ -526,6 +527,14 @@ class AppHandler: # pylint: disable=too-many-public-methods
"""
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):
"""
Generate a new UUID value.
@ -853,7 +862,8 @@ class AppHandler: # pylint: disable=too-many-public-methods
: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

View file

@ -24,7 +24,6 @@
Batch Handlers
"""
import datetime
import os
import shutil
@ -481,7 +480,7 @@ class BatchHandler(GenericHandler): # pylint: disable=too-many-public-methods
result = self.execute( # pylint: disable=assignment-from-none
batch, user=user, progress=progress, **kwargs
)
batch.executed = datetime.datetime.now()
batch.executed = self.app.make_utc()
batch.executed_by = user
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.
"""
import datetime
import sqlalchemy as sa
from sqlalchemy import orm
from sqlalchemy.ext.associationproxy import association_proxy
from wuttjamaican.db.util import uuid_column, uuid_fk_column
from wuttjamaican.db.model.base import Base
from wuttjamaican.util import make_utc
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(
sa.DateTime(timezone=True),
sa.DateTime(),
nullable=False,
default=datetime.datetime.now,
default=make_utc,
doc="""
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.auth import User
from wuttjamaican.db.util import UUID
from wuttjamaican.util import make_utc
class BatchMixin:
@ -223,9 +224,7 @@ class BatchMixin:
status_code = sa.Column(sa.Integer(), nullable=True)
status_text = sa.Column(sa.String(length=255), nullable=True)
created = sa.Column(
sa.DateTime(timezone=True), nullable=False, default=datetime.datetime.now
)
created = sa.Column(sa.DateTime(), nullable=False, default=make_utc)
created_by_uuid = sa.Column(UUID(), nullable=False)
@declared_attr
@ -238,7 +237,7 @@ class BatchMixin:
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)
@declared_attr
@ -428,8 +427,5 @@ class BatchRowMixin: # pylint: disable=too-few-public-methods
status_text = sa.Column(sa.String(length=255), nullable=True)
modified = sa.Column(
sa.DateTime(timezone=True),
nullable=True,
default=datetime.datetime.now,
onupdate=datetime.datetime.now,
sa.DateTime(), nullable=True, default=make_utc, onupdate=make_utc
)

View file

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

View file

@ -24,6 +24,7 @@
WuttJamaican - utilities
"""
import datetime
import importlib
import logging
import os
@ -189,6 +190,50 @@ def make_full_name(*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():
"""
Generate a new v7 UUID value.

View file

@ -426,6 +426,11 @@ app_title = WuttaTest
name = self.app.make_full_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):
uuid = self.app.make_uuid()
self.assertEqual(len(uuid), 32)
@ -566,7 +571,7 @@ app_title = WuttaTest
now = datetime.datetime.now()
result = self.app.render_time_ago(now)
self.assertEqual(result, "now")
humanize.naturaltime.assert_called_once_with(now)
humanize.naturaltime.assert_called_once()
def test_get_person(self):
people = self.app.get_people_handler()

View file

@ -1,5 +1,6 @@
# -*- coding: utf-8; -*-
import datetime
import sys
from unittest import TestCase
from unittest.mock import patch, MagicMock
@ -164,6 +165,53 @@ class TestLoadObject(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):
def test_basic(self):