Fix migration: parse ISO timestamp strings to datetime objects for asyncpg

asyncpg requires datetime instances for TIMESTAMPTZ columns, not strings.
Added _parse_dt() helper that converts ISO strings (with or without tz) to
timezone-aware datetime, falling back to NOW() if the value is missing.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Vadym Samoilenko 2026-03-23 19:58:09 +00:00
parent 1a1bc97bfc
commit d8d1dfeff5

View file

@ -13,11 +13,25 @@ import json
import logging
import os
import sys
from datetime import datetime, timezone
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
def _parse_dt(value) -> datetime | None:
"""Parse an ISO timestamp string (or None) into a timezone-aware datetime."""
if not value:
return None
try:
dt = datetime.fromisoformat(value)
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return dt
except (ValueError, TypeError):
return None
async def migrate():
# Import here to avoid circular issues at module level
from ..config_runtime import server_config
@ -38,16 +52,16 @@ async def migrate():
with open(users_file) as f:
users = json.load(f)
count = 0
now = datetime.now(timezone.utc)
for uid, u in users.items():
await conn.execute('''
INSERT INTO users (id, email, name, role, active, created_at, last_seen_at)
VALUES ($1, $2, $3, $4, $5,
COALESCE($6::timestamptz, NOW()),
COALESCE($7::timestamptz, NOW()))
VALUES ($1, $2, $3, $4, $5, $6, $7)
ON CONFLICT (id) DO NOTHING
''', uid, u.get('email', ''), u.get('name', ''),
u.get('role', 'user'), u.get('active', True),
u.get('created'), u.get('last_seen'))
_parse_dt(u.get('created')) or now,
_parse_dt(u.get('last_seen')) or now)
count += 1
logger.info(f"Migrated {count} users")
@ -56,12 +70,14 @@ async def migrate():
if os.path.exists(clients_file):
with open(clients_file) as f:
clients = json.load(f)
now = datetime.now(timezone.utc)
for c in clients:
await conn.execute('''
INSERT INTO clients (id, name, has_custom_dropdowns, created_at)
VALUES ($1, $2, $3, COALESCE($4::timestamptz, NOW()))
VALUES ($1, $2, $3, $4)
ON CONFLICT (id) DO NOTHING
''', c['id'], c['name'], c.get('hasCustomDropdowns', False), c.get('created'))
''', c['id'], c['name'], c.get('hasCustomDropdowns', False),
_parse_dt(c.get('created')) or now)
logger.info(f"Migrated {len(clients)} clients")
# ── Global dropdowns ───────────────────────────────────────────────────
@ -157,16 +173,16 @@ async def migrate():
data = json.load(f)
client_id = sheet_meta.get('client_id') or None
now = datetime.now(timezone.utc)
await conn.execute('''
INSERT INTO sheets
(id, user_id, name, client_id, data, item_count, created_at, modified_at)
VALUES ($1, $2, $3, $4, $5, $6,
COALESCE($7::timestamptz, NOW()),
COALESCE($8::timestamptz, NOW()))
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
ON CONFLICT (id) DO NOTHING
''', sid, user_id, sheet_meta.get('name', 'Untitled'),
client_id, data, len(data),
sheet_meta.get('created'), sheet_meta.get('modified'))
_parse_dt(sheet_meta.get('created')) or now,
_parse_dt(sheet_meta.get('modified')) or now)
total += 1
logger.info(f"Migrated {total} sheets")