from __future__ import annotations from datetime import datetime, timezone from sqlalchemy import DateTime, Float, Integer, String, UniqueConstraint from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column from sqlalchemy.types import TypeDecorator class UTCDateTime(TypeDecorator): """SQLite has no real timezone-aware datetime type: SQLAlchemy's `DateTime(timezone=True)` silently stores and returns *naive* datetimes on this backend, which then get misinterpreted as system-local time by `.astimezone()` anywhere they're read back (e.g. every time-of-use match in the costing engine) -- a correctness bug that only shows up when the host's local timezone isn't UTC. This type makes the round-trip explicit: always stores naive-UTC, always returns timezone-aware UTC. """ impl = DateTime cache_ok = True def process_bind_param(self, value: datetime | None, dialect): if value is None: return None if value.tzinfo is None: raise ValueError("UTCDateTime requires a timezone-aware datetime") return value.astimezone(timezone.utc).replace(tzinfo=None) def process_result_value(self, value: datetime | None, dialect): if value is None: return None return value.replace(tzinfo=timezone.utc) class Base(DeclarativeBase): pass class IntervalReadingRow(Base): __tablename__ = "interval_reading" __table_args__ = (UniqueConstraint("ts", "source", name="uq_interval_reading_ts_source"),) id: Mapped[int] = mapped_column(Integer, primary_key=True) ts: Mapped[datetime] = mapped_column(UTCDateTime, index=True) source: Mapped[str] = mapped_column(String) # 'isolarcloud' | 'mock' generation_kwh: Mapped[float] = mapped_column(Float) load_kwh: Mapped[float] = mapped_column(Float) import_kwh: Mapped[float] = mapped_column(Float) export_kwh: Mapped[float] = mapped_column(Float) resolution_minutes: Mapped[int] = mapped_column(Integer) class PlanCacheRow(Base): __tablename__ = "plan_cache" plan_id: Mapped[str] = mapped_column(String, primary_key=True) retailer: Mapped[str] = mapped_column(String) plan_name: Mapped[str] = mapped_column(String) fuel_type: Mapped[str] = mapped_column(String) distributors_json: Mapped[str] = mapped_column(String) # JSON list last_refreshed: Mapped[datetime] = mapped_column(UTCDateTime) raw_detail_json: Mapped[str] = mapped_column(String) parsed_tariff_json: Mapped[str] = mapped_column(String) class CurrentPlanConfigRow(Base): __tablename__ = "current_plan_config" id: Mapped[int] = mapped_column(Integer, primary_key=True, default=1) retailer_name: Mapped[str] = mapped_column(String) plan_name: Mapped[str] = mapped_column(String) tariff_json: Mapped[str] = mapped_column(String) class HouseholdConfigRow(Base): __tablename__ = "household_config" id: Mapped[int] = mapped_column(Integer, primary_key=True, default=1) distributor: Mapped[str] = mapped_column(String, default="Energex") postcode: Mapped[str] = mapped_column(String, default="") isolarcloud_ps_id: Mapped[str] = mapped_column(String, default="") class SyncStateRow(Base): __tablename__ = "sync_state" key: Mapped[str] = mapped_column(String, primary_key=True) value: Mapped[str] = mapped_column(String)