Replace deprecated datetime.utcnow with shared naive-UTC helper

All 29 call sites now use app.timeutil.utcnow(), which returns naive
UTC via the non-deprecated API. Semantics are unchanged on purpose:
every datetime column is TIMESTAMP WITHOUT TIME ZONE, so going aware
here would poison naive/aware comparisons. The TIMESTAMPTZ migration
(Phase 2) now has a single place to change. (BE-01)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Carlos Escalante
2026-06-09 19:46:54 -06:00
parent 0a4c3e9436
commit 996bd437a1
9 changed files with 52 additions and 29 deletions

View File

@@ -5,6 +5,7 @@ from sqlmodel import Session, select
from app.auth import get_current_user from app.auth import get_current_user
from app.db import get_session from app.db import get_session
from app.timeutil import utcnow
from app.models.models import Account, AccountCreate, AccountRead, AccountUpdate from app.models.models import Account, AccountCreate, AccountRead, AccountUpdate
router = APIRouter(prefix="/accounts", tags=["accounts"]) router = APIRouter(prefix="/accounts", tags=["accounts"])
@@ -44,7 +45,7 @@ def update_account(
update_data = data.model_dump(exclude_unset=True) update_data = data.model_dump(exclude_unset=True)
for key, value in update_data.items(): for key, value in update_data.items():
setattr(account, key, value) setattr(account, key, value)
account.updated_at = datetime.utcnow() account.updated_at = utcnow()
session.add(account) session.add(account)
session.commit() session.commit()
session.refresh(account) session.refresh(account)

View File

@@ -7,6 +7,7 @@ from sqlmodel import Session, select
from app.auth import get_current_user from app.auth import get_current_user
from app.db import get_session from app.db import get_session
from app.timeutil import utcnow
from app.models.models import ( from app.models.models import (
BalanceOverride, BalanceOverride,
BalanceOverrideCreate, BalanceOverrideCreate,
@@ -259,7 +260,7 @@ def upsert_balance_override(
if existing: if existing:
existing.override_balance = data.override_balance existing.override_balance = data.override_balance
existing.updated_at = datetime.utcnow() existing.updated_at = utcnow()
session.add(existing) session.add(existing)
session.commit() session.commit()
session.refresh(existing) session.refresh(existing)

View File

@@ -5,6 +5,7 @@ from sqlmodel import Session, col, select
from app.auth import get_current_user from app.auth import get_current_user
from app.db import get_session from app.db import get_session
from app.timeutil import utcnow
from app.models.models import ( from app.models.models import (
SavingsAccrual, SavingsAccrual,
SavingsAccrualCreate, SavingsAccrualCreate,
@@ -44,7 +45,7 @@ def create_accrual(
detail=f"Accrual for {data.year}-{data.month:02d} already exists (id={existing.id})", detail=f"Accrual for {data.year}-{data.month:02d} already exists (id={existing.id})",
) )
accrual = SavingsAccrual.model_validate(data) accrual = SavingsAccrual.model_validate(data)
accrual.applied_at = datetime.utcnow() accrual.applied_at = utcnow()
session.add(accrual) session.add(accrual)
session.commit() session.commit()
session.refresh(accrual) session.refresh(accrual)

View File

@@ -5,6 +5,7 @@ from sqlmodel import Session, select
from app.auth import get_current_user from app.auth import get_current_user
from app.db import get_session from app.db import get_session
from app.timeutil import utcnow
from app.models.models import UserSettings, UserSettingsRead, UserSettingsUpdate from app.models.models import UserSettings, UserSettingsRead, UserSettingsUpdate
router = APIRouter(prefix="/settings", tags=["settings"]) router = APIRouter(prefix="/settings", tags=["settings"])
@@ -52,7 +53,7 @@ def update_settings(
settings = UserSettings(key="default", data=body.data) settings = UserSettings(key="default", data=body.data)
else: else:
settings.data = body.data settings.data = body.data
settings.updated_at = datetime.utcnow() settings.updated_at = utcnow()
session.add(settings) session.add(settings)
session.commit() session.commit()
session.refresh(settings) session.refresh(settings)

View File

@@ -8,6 +8,7 @@ from sqlmodel import Session, select
from app.auth import get_current_user, hash_token from app.auth import get_current_user, hash_token
from app.db import get_session from app.db import get_session
from app.timeutil import utcnow
from app.models.models import APIToken, APITokenCreate, APITokenRead from app.models.models import APIToken, APITokenCreate, APITokenRead
router = APIRouter(prefix="/tokens", tags=["tokens"]) router = APIRouter(prefix="/tokens", tags=["tokens"])
@@ -28,7 +29,7 @@ def create_token(
plaintext = secrets.token_urlsafe(32) plaintext = secrets.token_urlsafe(32)
expires_at = None expires_at = None
if data.expires_days: if data.expires_days:
expires_at = datetime.utcnow() + timedelta(days=data.expires_days) expires_at = utcnow() + timedelta(days=data.expires_days)
api_token = APIToken( api_token = APIToken(
name=data.name, name=data.name,

View File

@@ -12,6 +12,7 @@ from jose import JWTError, jwt
from sqlmodel import Session, select from sqlmodel import Session, select
from app.config import settings from app.config import settings
from app.timeutil import utcnow
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login") oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login")
@@ -70,7 +71,7 @@ def verify_admin_credentials(username: str, password: str) -> None:
def create_access_token(subject: str) -> str: def create_access_token(subject: str) -> str:
expire = datetime.utcnow() + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES) expire = utcnow() + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
return jwt.encode({"sub": subject, "exp": expire}, settings.SECRET_KEY, algorithm=ALGORITHM) return jwt.encode({"sub": subject, "exp": expire}, settings.SECRET_KEY, algorithm=ALGORITHM)
@@ -102,7 +103,7 @@ def _validate_token(token: str) -> str:
) )
).first() ).first()
if api_token: if api_token:
if api_token.expires_at and api_token.expires_at < datetime.utcnow(): if api_token.expires_at and api_token.expires_at < utcnow():
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Token expired") raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Token expired")
return f"api:{api_token.name}" return f"api:{api_token.name}"

View File

@@ -1,5 +1,7 @@
import enum import enum
from datetime import date, datetime from datetime import date, datetime
from app.timeutil import utcnow
from typing import Optional from typing import Optional
from sqlalchemy import JSON, Column, UniqueConstraint from sqlalchemy import JSON, Column, UniqueConstraint
@@ -106,7 +108,7 @@ class AccountBase(SQLModel):
class Account(AccountBase, table=True): class Account(AccountBase, table=True):
id: Optional[int] = Field(default=None, primary_key=True) id: Optional[int] = Field(default=None, primary_key=True)
updated_at: datetime = Field(default_factory=datetime.utcnow) updated_at: datetime = Field(default_factory=utcnow)
class AccountCreate(AccountBase): class AccountCreate(AccountBase):
@@ -147,7 +149,7 @@ class TransactionBase(SQLModel):
class Transaction(TransactionBase, table=True): class Transaction(TransactionBase, table=True):
id: Optional[int] = Field(default=None, primary_key=True) id: Optional[int] = Field(default=None, primary_key=True)
created_at: datetime = Field(default_factory=datetime.utcnow) created_at: datetime = Field(default_factory=utcnow)
category: Optional[Category] = Relationship(back_populates="transactions") category: Optional[Category] = Relationship(back_populates="transactions")
@@ -182,7 +184,7 @@ class ExchangeRate(SQLModel, table=True):
date: datetime date: datetime
buy_rate: float buy_rate: float
sell_rate: float sell_rate: float
fetched_at: datetime = Field(default_factory=datetime.utcnow) fetched_at: datetime = Field(default_factory=utcnow)
class ExchangeRateRead(SQLModel): class ExchangeRateRead(SQLModel):
@@ -199,7 +201,7 @@ class APIToken(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True) id: Optional[int] = Field(default=None, primary_key=True)
name: str name: str
token_hash: str = Field(index=True) token_hash: str = Field(index=True)
created_at: datetime = Field(default_factory=datetime.utcnow) created_at: datetime = Field(default_factory=utcnow)
expires_at: Optional[datetime] = None expires_at: Optional[datetime] = None
is_active: bool = True is_active: bool = True
@@ -227,7 +229,7 @@ class UserSettings(SQLModel, table=True):
default_factory=dict, default_factory=dict,
sa_column=Column(JSON, nullable=False, server_default="{}"), sa_column=Column(JSON, nullable=False, server_default="{}"),
) )
updated_at: datetime = Field(default_factory=datetime.utcnow) updated_at: datetime = Field(default_factory=utcnow)
class UserSettingsRead(SQLModel): class UserSettingsRead(SQLModel):
@@ -262,7 +264,7 @@ class RecurringItemBase(SQLModel):
class RecurringItem(RecurringItemBase, table=True): class RecurringItem(RecurringItemBase, table=True):
id: Optional[int] = Field(default=None, primary_key=True) id: Optional[int] = Field(default=None, primary_key=True)
created_at: datetime = Field(default_factory=datetime.utcnow) created_at: datetime = Field(default_factory=utcnow)
category: Optional[Category] = Relationship() category: Optional[Category] = Relationship()
@@ -298,7 +300,7 @@ class PushSubscription(SQLModel, table=True):
endpoint: str = Field(unique=True) endpoint: str = Field(unique=True)
p256dh: str p256dh: str
auth: str auth: str
created_at: datetime = Field(default_factory=datetime.utcnow) created_at: datetime = Field(default_factory=utcnow)
class PushSubscriptionCreate(SQLModel): class PushSubscriptionCreate(SQLModel):
@@ -331,7 +333,7 @@ class PensionSnapshot(PensionSnapshotBase, table=True):
UniqueConstraint("fund", "period_start", "period_end"), UniqueConstraint("fund", "period_start", "period_end"),
) )
id: Optional[int] = Field(default=None, primary_key=True) id: Optional[int] = Field(default=None, primary_key=True)
created_at: datetime = Field(default_factory=datetime.utcnow) created_at: datetime = Field(default_factory=utcnow)
class PensionSnapshotRead(PensionSnapshotBase): class PensionSnapshotRead(PensionSnapshotBase):
@@ -348,8 +350,8 @@ class BalanceOverride(SQLModel, table=True):
year: int year: int
month: int month: int
override_balance: float override_balance: float
created_at: datetime = Field(default_factory=datetime.utcnow) created_at: datetime = Field(default_factory=utcnow)
updated_at: datetime = Field(default_factory=datetime.utcnow) updated_at: datetime = Field(default_factory=utcnow)
class BalanceOverrideCreate(SQLModel): class BalanceOverrideCreate(SQLModel):
@@ -379,7 +381,7 @@ class SavingsAccrualBase(SQLModel):
class SavingsAccrual(SavingsAccrualBase, table=True): class SavingsAccrual(SavingsAccrualBase, table=True):
__table_args__ = (UniqueConstraint("year", "month"),) __table_args__ = (UniqueConstraint("year", "month"),)
id: Optional[int] = Field(default=None, primary_key=True) id: Optional[int] = Field(default=None, primary_key=True)
applied_at: datetime = Field(default_factory=datetime.utcnow) applied_at: datetime = Field(default_factory=utcnow)
class SavingsAccrualCreate(SavingsAccrualBase): class SavingsAccrualCreate(SavingsAccrualBase):
@@ -423,7 +425,7 @@ class MunicipalReceiptBase(SQLModel):
class MunicipalReceipt(MunicipalReceiptBase, table=True): class MunicipalReceipt(MunicipalReceiptBase, table=True):
__table_args__ = (UniqueConstraint("account", "period"),) __table_args__ = (UniqueConstraint("account", "period"),)
id: Optional[int] = Field(default=None, primary_key=True) id: Optional[int] = Field(default=None, primary_key=True)
created_at: datetime = Field(default_factory=datetime.utcnow) created_at: datetime = Field(default_factory=utcnow)
water_readings: list["WaterMeterReading"] = Relationship( water_readings: list["WaterMeterReading"] = Relationship(
back_populates="receipt", back_populates="receipt",
) )
@@ -458,7 +460,7 @@ class WaterMeterReadingBase(SQLModel):
class WaterMeterReading(WaterMeterReadingBase, table=True): class WaterMeterReading(WaterMeterReadingBase, table=True):
__table_args__ = (UniqueConstraint("meter_id", "period", "is_historical"),) __table_args__ = (UniqueConstraint("meter_id", "period", "is_historical"),)
id: Optional[int] = Field(default=None, primary_key=True) id: Optional[int] = Field(default=None, primary_key=True)
created_at: datetime = Field(default_factory=datetime.utcnow) created_at: datetime = Field(default_factory=utcnow)
receipt: Optional[MunicipalReceipt] = Relationship( receipt: Optional[MunicipalReceipt] = Relationship(
back_populates="water_readings", back_populates="water_readings",
) )

View File

@@ -8,6 +8,7 @@ from sqlalchemy import case
from sqlmodel import Session, col, select from sqlmodel import Session, col, select
from app.config import settings from app.config import settings
from app.timeutil import utcnow
from app.db import engine from app.db import engine
from app.models.models import ExchangeRate from app.models.models import ExchangeRate
@@ -136,7 +137,7 @@ def _fetch_rate_from_apis() -> tuple[float, float] | None:
def _remember(rate: ExchangeRate) -> ExchangeRate: def _remember(rate: ExchangeRate) -> ExchangeRate:
"""Store rate in both TTL cache and permanent last-known holder.""" """Store rate in both TTL cache and permanent last-known holder."""
global _last_known global _last_known
_cache["current"] = (rate, datetime.utcnow()) _cache["current"] = (rate, utcnow())
_last_known = rate _last_known = rate
return rate return rate
@@ -147,11 +148,11 @@ def get_current_rate(session: Session) -> ExchangeRate | None:
# 1. Fresh memory cache (< 1 hour) # 1. Fresh memory cache (< 1 hour)
cached = _cache.get("current") cached = _cache.get("current")
if cached and datetime.utcnow() - cached[1] < CACHE_TTL: if cached and utcnow() - cached[1] < CACHE_TTL:
return cached[0] return cached[0]
# 2. Fresh DB rate (< 1 hour) # 2. Fresh DB rate (< 1 hour)
one_hour_ago = datetime.utcnow() - CACHE_TTL one_hour_ago = utcnow() - CACHE_TTL
db_rate = session.exec( db_rate = session.exec(
select(ExchangeRate) select(ExchangeRate)
.where(ExchangeRate.fetched_at > one_hour_ago) .where(ExchangeRate.fetched_at > one_hour_ago)
@@ -164,7 +165,7 @@ def get_current_rate(session: Session) -> ExchangeRate | None:
result = _fetch_rate_from_apis() result = _fetch_rate_from_apis()
if result is not None: if result is not None:
buy, sell = result buy, sell = result
rate = ExchangeRate(date=datetime.utcnow(), buy_rate=buy, sell_rate=sell) rate = ExchangeRate(date=utcnow(), buy_rate=buy, sell_rate=sell)
session.add(rate) session.add(rate)
session.commit() session.commit()
session.refresh(rate) session.refresh(rate)
@@ -230,7 +231,7 @@ def get_crc_rate(code: str) -> float | None:
return 1.0 return 1.0
cached = _xcrc_cache.get(code) cached = _xcrc_cache.get(code)
if cached and datetime.utcnow() - cached[1] < CACHE_TTL: if cached and utcnow() - cached[1] < CACHE_TTL:
return cached[0] return cached[0]
if code in _COINGECKO_IDS: if code in _COINGECKO_IDS:
@@ -239,7 +240,7 @@ def get_crc_rate(code: str) -> float | None:
rate = _fetch_fiat_crc_mid(code) rate = _fetch_fiat_crc_mid(code)
if rate is not None: if rate is not None:
_xcrc_cache[code] = (rate, datetime.utcnow()) _xcrc_cache[code] = (rate, utcnow())
_last_known_xcrc[code] = rate _last_known_xcrc[code] = rate
return rate return rate
@@ -293,7 +294,7 @@ def _refresh_usd_rate() -> bool:
return False return False
buy, sell = fetched buy, sell = fetched
with Session(engine) as session: with Session(engine) as session:
rate = ExchangeRate(date=datetime.utcnow(), buy_rate=buy, sell_rate=sell) rate = ExchangeRate(date=utcnow(), buy_rate=buy, sell_rate=sell)
session.add(rate) session.add(rate)
session.commit() session.commit()
session.refresh(rate) session.refresh(rate)
@@ -309,7 +310,7 @@ def _refresh_other_rate(code: str) -> bool:
rate = _fetch_fiat_crc_mid(code) rate = _fetch_fiat_crc_mid(code)
if rate is None: if rate is None:
return False return False
_xcrc_cache[code] = (rate, datetime.utcnow()) _xcrc_cache[code] = (rate, utcnow())
_last_known_xcrc[code] = rate _last_known_xcrc[code] = rate
return True return True
@@ -368,7 +369,7 @@ async def refresh_rates_periodically(
def get_rate_history(session: Session, days: int = 30) -> list[ExchangeRate]: def get_rate_history(session: Session, days: int = 30) -> list[ExchangeRate]:
"""Get historical exchange rates.""" """Get historical exchange rates."""
cutoff = datetime.utcnow() - timedelta(days=days) cutoff = utcnow() - timedelta(days=days)
return list( return list(
session.exec( session.exec(
select(ExchangeRate) select(ExchangeRate)

14
backend/app/timeutil.py Normal file
View File

@@ -0,0 +1,14 @@
from datetime import datetime, timezone
def utcnow() -> datetime:
"""Naive UTC now, via the non-deprecated API.
Every datetime column in the schema is TIMESTAMP WITHOUT TIME ZONE, so the
whole app speaks naive-UTC. Replacing datetime.utcnow() call sites with
this helper removes the Python 3.12 deprecation without changing any
stored value or comparison. When the columns move to TIMESTAMPTZ
(Phase 2), this becomes `datetime.now(timezone.utc)` and the type change
happens in exactly one place.
"""
return datetime.now(timezone.utc).replace(tzinfo=None)