Stamp UTC on naive API timestamps before serializing (B-35)
SQLite/aiosqlite returns DateTime columns as naive even though every value is written in UTC, so a bare .isoformat() dropped the offset and the frontend's new Date() parsed it as local time. Add a shared isoformat_utc() helper and use it at every call site that was missing the fix already applied ad hoc in rounds.py. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -6,6 +6,7 @@ from pydantic import BaseModel, Field, field_validator
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.timeutil import isoformat_utc
|
||||
from app.audit.log import write_audit_log
|
||||
from app.auth.security import hash_password
|
||||
from app.config import settings
|
||||
@@ -163,7 +164,7 @@ async def list_users(session: AsyncSession = Depends(get_session)) -> list[Admin
|
||||
username=u.username,
|
||||
address=u.address,
|
||||
balance_sats=u.cached_balance_sats,
|
||||
created_at=u.created_at.isoformat(),
|
||||
created_at=isoformat_utc(u.created_at),
|
||||
)
|
||||
for u in users
|
||||
]
|
||||
@@ -253,8 +254,8 @@ async def list_rounds(session: AsyncSession = Depends(get_session), limit: int =
|
||||
AdminRoundResponse(
|
||||
id=r.id,
|
||||
status=r.status,
|
||||
opened_at=r.opened_at.isoformat(),
|
||||
closed_at=r.closed_at.isoformat() if r.closed_at else None,
|
||||
opened_at=isoformat_utc(r.opened_at),
|
||||
closed_at=isoformat_utc(r.closed_at),
|
||||
draw_block_height=r.draw_block_height,
|
||||
draw_block_hash=r.draw_block_hash,
|
||||
winner_user_id=r.winner_user_id,
|
||||
@@ -291,7 +292,7 @@ async def list_audit_log(
|
||||
payload=json.loads(e.payload_json),
|
||||
user_id=e.user_id,
|
||||
round_id=e.round_id,
|
||||
created_at=e.created_at.isoformat(),
|
||||
created_at=isoformat_utc(e.created_at),
|
||||
)
|
||||
for e in entries
|
||||
]
|
||||
@@ -333,7 +334,7 @@ async def list_pending_transactions(
|
||||
current_txid=p.current_txid,
|
||||
fee_rate_sat_vb=p.fee_rate_sat_vb,
|
||||
attempt_count=p.attempt_count,
|
||||
broadcast_at=p.broadcast_at.isoformat(),
|
||||
broadcast_at=isoformat_utc(p.broadcast_at),
|
||||
replaced_by_txid=p.replaced_by_txid,
|
||||
)
|
||||
for p in entries
|
||||
|
||||
@@ -4,6 +4,7 @@ from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.errors import http_error
|
||||
from app.api.timeutil import isoformat_utc
|
||||
from app.auth.dependencies import get_current_user
|
||||
from app.auth.security import MIN_PASSWORD_LENGTH, create_access_token, hash_password, verify_password
|
||||
from app.db.models import Round, RoundParticipant, User
|
||||
@@ -36,7 +37,7 @@ async def me(
|
||||
balance_sats=user.cached_balance_sats,
|
||||
pending_balance_sats=pending_balance_sats,
|
||||
has_pending=has_pending,
|
||||
created_at=user.created_at.isoformat(),
|
||||
created_at=isoformat_utc(user.created_at),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
from datetime import datetime, timezone
|
||||
|
||||
|
||||
def isoformat_utc(dt: datetime | None) -> str | None:
|
||||
"""Serialize a datetime for API responses, stamping it UTC first.
|
||||
|
||||
Every DateTime column is written via app.db.models.utcnow() but SQLite/aiosqlite
|
||||
round-trips it as a naive datetime, so a bare .isoformat() drops the "Z"/offset
|
||||
and JavaScript's `new Date()` on the frontend parses the result as local time
|
||||
instead of UTC (B-35). All stored values are UTC in practice, so a naive value
|
||||
can be safely stamped rather than converted.
|
||||
"""
|
||||
if dt is None:
|
||||
return None
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=timezone.utc)
|
||||
return dt.isoformat()
|
||||
Reference in New Issue
Block a user