diff --git a/TODO.md b/TODO.md index 9931cd7..6045b93 100644 --- a/TODO.md +++ b/TODO.md @@ -26,14 +26,14 @@ Concrete follow-up work for Jellomator, prioritized by implementation risk and u ## P1 - Data Model and Backend Quality -- Replace string timestamps with DB-native datetime. - - Migrate `created_at`/`updated_at` columns from `varchar` to `datetime`. - - Use UTC consistently for writes and reads. -- Add display ordering support. - - Add `sort_order` column and stable ordering fallback by `name`. - - Update read query to order by `enabled desc`, `sort_order`, `name`. -- Remove duplicate connection pattern in create flow. - - Use one DB transaction/connection per request path where possible. +- [x] Replace string timestamps with DB-native datetime. + - [x] Migrate `created_at`/`updated_at` columns from `varchar` to `datetime`. + - [x] Use UTC consistently for writes and reads. +- [x] Add display ordering support. + - [x] Add `sort_order` column and stable ordering fallback by `name`. + - [x] Update read query to order by `enabled desc`, `sort_order`, `name`. +- [x] Remove duplicate connection pattern in create flow. + - [x] Use one DB transaction/connection per request path where possible. - Add backup and restore flow in admin API/UI. - Download full export. - Upload validated import with explicit confirmation. diff --git a/backend/main.py b/backend/main.py index a52ea14..7a6acc6 100644 --- a/backend/main.py +++ b/backend/main.py @@ -3,7 +3,7 @@ from __future__ import annotations import secrets import os import time -from datetime import datetime +from datetime import datetime, timezone from contextlib import contextmanager from pathlib import Path from typing import Optional @@ -122,14 +122,48 @@ def init_db(): url text not null, description text, category varchar(255), + sort_order int not null default 0, icon_blob longblob, icon_mime varchar(255), icon_url text, enabled tinyint(1) not null default 1, - created_at varchar(64) not null, - updated_at varchar(64) not null + created_at datetime not null, + updated_at datetime not null ) engine=InnoDB default charset=utf8mb4 """) + cur.execute("show columns from links like 'sort_order'") + if cur.fetchone() is None: + cur.execute("alter table links add column sort_order int not null default 0 after category") + cur.execute("show columns from links like 'created_at'") + created_col = cur.fetchone() + cur.execute("show columns from links like 'updated_at'") + updated_col = cur.fetchone() + created_is_varchar = bool(created_col and str(created_col.get("Type", "")).startswith("varchar")) + updated_is_varchar = bool(updated_col and str(updated_col.get("Type", "")).startswith("varchar")) + if created_is_varchar or updated_is_varchar: + cur.execute("alter table links add column created_at_dt datetime null, add column updated_at_dt datetime null") + cur.execute( + """ + update links + set created_at_dt=coalesce( + str_to_date(created_at, '%Y-%m-%dT%H:%i:%s.%f'), + str_to_date(created_at, '%Y-%m-%dT%H:%i:%s'), + str_to_date(created_at, '%Y-%m-%d %H:%i:%s'), + utc_timestamp() + ), + updated_at_dt=coalesce( + str_to_date(updated_at, '%Y-%m-%dT%H:%i:%s.%f'), + str_to_date(updated_at, '%Y-%m-%dT%H:%i:%s'), + str_to_date(updated_at, '%Y-%m-%d %H:%i:%s'), + utc_timestamp() + ) + """ + ) + cur.execute("alter table links drop column created_at, drop column updated_at") + cur.execute( + "alter table links change column created_at_dt created_at datetime not null, " + "change column updated_at_dt updated_at datetime not null" + ) init_db() @@ -156,6 +190,10 @@ def utc_now_iso() -> str: return datetime.utcnow().isoformat() +def utc_now_db() -> datetime: + return datetime.now(timezone.utc).replace(tzinfo=None) + + def expires_at_iso() -> str: now = datetime.utcnow().timestamp() return datetime.utcfromtimestamp(now + SESSION_TTL_SECONDS).isoformat() @@ -398,7 +436,7 @@ def logout(request: Request): def links(): with db() as c: with c.cursor() as cur: - cur.execute("select * from links order by enabled desc, category, name") + cur.execute("select * from links order by enabled desc, sort_order asc, name asc") rows = cur.fetchall() out = [] for r in rows: @@ -437,17 +475,16 @@ def create_link( require_admin(request) require_csrf(request) validate_link_payload(name, url, description, category, icon_url) + icon_blob, icon_mime = read_icon_blob(icon) + now = utc_now_db() with db() as c: if link_name_exists(c, name): raise HTTPException(409, "Link name already exists") - icon_blob, icon_mime = read_icon_blob(icon) - now = datetime.utcnow().isoformat() - with db() as c: with c.cursor() as cur: cur.execute( - """insert into links(name,url,description,category,icon_blob,icon_mime,icon_url,enabled,created_at,updated_at) - values (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)""", - (name, url, description, category, icon_blob, icon_mime, icon_url, int(enabled), now, now), + """insert into links(name,url,description,category,sort_order,icon_blob,icon_mime,icon_url,enabled,created_at,updated_at) + values (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)""", + (name, url, description, category, 0, icon_blob, icon_mime, icon_url, int(enabled), now, now), ) return {"ok": True} @@ -489,7 +526,7 @@ def update_link( require_csrf(request) validate_link_payload(name, url, description, category, icon_url) icon_blob, icon_mime = read_icon_blob(icon) - now = datetime.utcnow().isoformat() + now = utc_now_db() with db() as c: if link_name_exists(c, name, exclude_id=link_id): raise HTTPException(409, "Link name already exists")