diff --git a/backend/app/api/v1/endpoints/accounts.py b/backend/app/api/v1/endpoints/accounts.py index fd6a805..d23ebd0 100644 --- a/backend/app/api/v1/endpoints/accounts.py +++ b/backend/app/api/v1/endpoints/accounts.py @@ -5,6 +5,7 @@ from sqlmodel import Session, select from app.auth import get_current_user from app.db import get_session +from app.timeutil import utcnow from app.models.models import Account, AccountCreate, AccountRead, AccountUpdate router = APIRouter(prefix="/accounts", tags=["accounts"]) @@ -44,7 +45,7 @@ def update_account( update_data = data.model_dump(exclude_unset=True) for key, value in update_data.items(): setattr(account, key, value) - account.updated_at = datetime.utcnow() + account.updated_at = utcnow() session.add(account) session.commit() session.refresh(account) diff --git a/backend/app/api/v1/endpoints/budget.py b/backend/app/api/v1/endpoints/budget.py index f20f63c..76a86c8 100644 --- a/backend/app/api/v1/endpoints/budget.py +++ b/backend/app/api/v1/endpoints/budget.py @@ -7,6 +7,7 @@ from sqlmodel import Session, select from app.auth import get_current_user from app.db import get_session +from app.timeutil import utcnow from app.models.models import ( BalanceOverride, BalanceOverrideCreate, @@ -259,7 +260,7 @@ def upsert_balance_override( if existing: existing.override_balance = data.override_balance - existing.updated_at = datetime.utcnow() + existing.updated_at = utcnow() session.add(existing) session.commit() session.refresh(existing) diff --git a/backend/app/api/v1/endpoints/savings_accrual.py b/backend/app/api/v1/endpoints/savings_accrual.py index 7b24705..9721db1 100644 --- a/backend/app/api/v1/endpoints/savings_accrual.py +++ b/backend/app/api/v1/endpoints/savings_accrual.py @@ -5,6 +5,7 @@ from sqlmodel import Session, col, select from app.auth import get_current_user from app.db import get_session +from app.timeutil import utcnow from app.models.models import ( SavingsAccrual, SavingsAccrualCreate, @@ -44,7 +45,7 @@ def create_accrual( detail=f"Accrual for {data.year}-{data.month:02d} already exists (id={existing.id})", ) accrual = SavingsAccrual.model_validate(data) - accrual.applied_at = datetime.utcnow() + accrual.applied_at = utcnow() session.add(accrual) session.commit() session.refresh(accrual) diff --git a/backend/app/api/v1/endpoints/settings.py b/backend/app/api/v1/endpoints/settings.py index 6757900..0345adc 100644 --- a/backend/app/api/v1/endpoints/settings.py +++ b/backend/app/api/v1/endpoints/settings.py @@ -5,6 +5,7 @@ from sqlmodel import Session, select from app.auth import get_current_user from app.db import get_session +from app.timeutil import utcnow from app.models.models import UserSettings, UserSettingsRead, UserSettingsUpdate router = APIRouter(prefix="/settings", tags=["settings"]) @@ -52,7 +53,7 @@ def update_settings( settings = UserSettings(key="default", data=body.data) else: settings.data = body.data - settings.updated_at = datetime.utcnow() + settings.updated_at = utcnow() session.add(settings) session.commit() session.refresh(settings) diff --git a/backend/app/api/v1/endpoints/tokens.py b/backend/app/api/v1/endpoints/tokens.py index d53c145..97925a9 100644 --- a/backend/app/api/v1/endpoints/tokens.py +++ b/backend/app/api/v1/endpoints/tokens.py @@ -8,6 +8,7 @@ from sqlmodel import Session, select from app.auth import get_current_user, hash_token from app.db import get_session +from app.timeutil import utcnow from app.models.models import APIToken, APITokenCreate, APITokenRead router = APIRouter(prefix="/tokens", tags=["tokens"]) @@ -28,7 +29,7 @@ def create_token( plaintext = secrets.token_urlsafe(32) expires_at = None if data.expires_days: - expires_at = datetime.utcnow() + timedelta(days=data.expires_days) + expires_at = utcnow() + timedelta(days=data.expires_days) api_token = APIToken( name=data.name, diff --git a/backend/app/auth.py b/backend/app/auth.py index 26951e7..ad00612 100644 --- a/backend/app/auth.py +++ b/backend/app/auth.py @@ -12,6 +12,7 @@ from jose import JWTError, jwt from sqlmodel import Session, select from app.config import settings +from app.timeutil import utcnow 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: - 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) @@ -102,7 +103,7 @@ def _validate_token(token: str) -> str: ) ).first() 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") return f"api:{api_token.name}" diff --git a/backend/app/models/models.py b/backend/app/models/models.py index 0e457bd..5b92c1b 100644 --- a/backend/app/models/models.py +++ b/backend/app/models/models.py @@ -1,5 +1,7 @@ import enum from datetime import date, datetime + +from app.timeutil import utcnow from typing import Optional from sqlalchemy import JSON, Column, UniqueConstraint @@ -106,7 +108,7 @@ class AccountBase(SQLModel): class Account(AccountBase, table=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): @@ -147,7 +149,7 @@ class TransactionBase(SQLModel): class Transaction(TransactionBase, table=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") @@ -182,7 +184,7 @@ class ExchangeRate(SQLModel, table=True): date: datetime buy_rate: float sell_rate: float - fetched_at: datetime = Field(default_factory=datetime.utcnow) + fetched_at: datetime = Field(default_factory=utcnow) class ExchangeRateRead(SQLModel): @@ -199,7 +201,7 @@ class APIToken(SQLModel, table=True): id: Optional[int] = Field(default=None, primary_key=True) name: str 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 is_active: bool = True @@ -227,7 +229,7 @@ class UserSettings(SQLModel, table=True): default_factory=dict, 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): @@ -262,7 +264,7 @@ class RecurringItemBase(SQLModel): class RecurringItem(RecurringItemBase, table=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() @@ -298,7 +300,7 @@ class PushSubscription(SQLModel, table=True): endpoint: str = Field(unique=True) p256dh: str auth: str - created_at: datetime = Field(default_factory=datetime.utcnow) + created_at: datetime = Field(default_factory=utcnow) class PushSubscriptionCreate(SQLModel): @@ -331,7 +333,7 @@ class PensionSnapshot(PensionSnapshotBase, table=True): UniqueConstraint("fund", "period_start", "period_end"), ) 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): @@ -348,8 +350,8 @@ class BalanceOverride(SQLModel, table=True): year: int month: int override_balance: float - created_at: datetime = Field(default_factory=datetime.utcnow) - updated_at: datetime = Field(default_factory=datetime.utcnow) + created_at: datetime = Field(default_factory=utcnow) + updated_at: datetime = Field(default_factory=utcnow) class BalanceOverrideCreate(SQLModel): @@ -379,7 +381,7 @@ class SavingsAccrualBase(SQLModel): class SavingsAccrual(SavingsAccrualBase, table=True): __table_args__ = (UniqueConstraint("year", "month"),) 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): @@ -423,7 +425,7 @@ class MunicipalReceiptBase(SQLModel): class MunicipalReceipt(MunicipalReceiptBase, table=True): __table_args__ = (UniqueConstraint("account", "period"),) 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( back_populates="receipt", ) @@ -458,7 +460,7 @@ class WaterMeterReadingBase(SQLModel): class WaterMeterReading(WaterMeterReadingBase, table=True): __table_args__ = (UniqueConstraint("meter_id", "period", "is_historical"),) 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( back_populates="water_readings", ) diff --git a/backend/app/services/exchange_rate.py b/backend/app/services/exchange_rate.py index 25b6f0a..8b1a54f 100644 --- a/backend/app/services/exchange_rate.py +++ b/backend/app/services/exchange_rate.py @@ -8,6 +8,7 @@ from sqlalchemy import case from sqlmodel import Session, col, select from app.config import settings +from app.timeutil import utcnow from app.db import engine from app.models.models import ExchangeRate @@ -136,7 +137,7 @@ def _fetch_rate_from_apis() -> tuple[float, float] | None: def _remember(rate: ExchangeRate) -> ExchangeRate: """Store rate in both TTL cache and permanent last-known holder.""" global _last_known - _cache["current"] = (rate, datetime.utcnow()) + _cache["current"] = (rate, utcnow()) _last_known = rate return rate @@ -147,11 +148,11 @@ def get_current_rate(session: Session) -> ExchangeRate | None: # 1. Fresh memory cache (< 1 hour) cached = _cache.get("current") - if cached and datetime.utcnow() - cached[1] < CACHE_TTL: + if cached and utcnow() - cached[1] < CACHE_TTL: return cached[0] # 2. Fresh DB rate (< 1 hour) - one_hour_ago = datetime.utcnow() - CACHE_TTL + one_hour_ago = utcnow() - CACHE_TTL db_rate = session.exec( select(ExchangeRate) .where(ExchangeRate.fetched_at > one_hour_ago) @@ -164,7 +165,7 @@ def get_current_rate(session: Session) -> ExchangeRate | None: result = _fetch_rate_from_apis() if result is not None: 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.commit() session.refresh(rate) @@ -230,7 +231,7 @@ def get_crc_rate(code: str) -> float | None: return 1.0 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] if code in _COINGECKO_IDS: @@ -239,7 +240,7 @@ def get_crc_rate(code: str) -> float | None: rate = _fetch_fiat_crc_mid(code) if rate is not None: - _xcrc_cache[code] = (rate, datetime.utcnow()) + _xcrc_cache[code] = (rate, utcnow()) _last_known_xcrc[code] = rate return rate @@ -293,7 +294,7 @@ def _refresh_usd_rate() -> bool: return False buy, sell = fetched 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.commit() session.refresh(rate) @@ -309,7 +310,7 @@ def _refresh_other_rate(code: str) -> bool: rate = _fetch_fiat_crc_mid(code) if rate is None: return False - _xcrc_cache[code] = (rate, datetime.utcnow()) + _xcrc_cache[code] = (rate, utcnow()) _last_known_xcrc[code] = rate return True @@ -368,7 +369,7 @@ async def refresh_rates_periodically( def get_rate_history(session: Session, days: int = 30) -> list[ExchangeRate]: """Get historical exchange rates.""" - cutoff = datetime.utcnow() - timedelta(days=days) + cutoff = utcnow() - timedelta(days=days) return list( session.exec( select(ExchangeRate) diff --git a/backend/app/timeutil.py b/backend/app/timeutil.py new file mode 100644 index 0000000..d3cdfa5 --- /dev/null +++ b/backend/app/timeutil.py @@ -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)