From d8d1dfeff5a02aa3823034b59a8378a70a9b9a76 Mon Sep 17 00:00:00 2001 From: Vadym Samoilenko Date: Mon, 23 Mar 2026 19:58:09 +0000 Subject: [PATCH] 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 --- backend/server/db/migrate_json.py | 36 ++++++++++++++++++++++--------- 1 file changed, 26 insertions(+), 10 deletions(-) diff --git a/backend/server/db/migrate_json.py b/backend/server/db/migrate_json.py index 0e807bd..65a9327 100644 --- a/backend/server/db/migrate_json.py +++ b/backend/server/db/migrate_json.py @@ -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")