backend: close P1 data model and create-flow issues
All checks were successful
docker / test (push) Successful in 13s
docker / build-and-push (push) Successful in 1m23s

This commit is contained in:
Space-Banane
2026-05-20 22:40:32 +02:00
parent 643785ad1e
commit 791126cdd0
2 changed files with 56 additions and 19 deletions

16
TODO.md
View File

@@ -26,14 +26,14 @@ Concrete follow-up work for Jellomator, prioritized by implementation risk and u
## P1 - Data Model and Backend Quality ## P1 - Data Model and Backend Quality
- Replace string timestamps with DB-native datetime. - [x] Replace string timestamps with DB-native datetime.
- Migrate `created_at`/`updated_at` columns from `varchar` to `datetime`. - [x] Migrate `created_at`/`updated_at` columns from `varchar` to `datetime`.
- Use UTC consistently for writes and reads. - [x] Use UTC consistently for writes and reads.
- Add display ordering support. - [x] Add display ordering support.
- Add `sort_order` column and stable ordering fallback by `name`. - [x] Add `sort_order` column and stable ordering fallback by `name`.
- Update read query to order by `enabled desc`, `sort_order`, `name`. - [x] Update read query to order by `enabled desc`, `sort_order`, `name`.
- Remove duplicate connection pattern in create flow. - [x] Remove duplicate connection pattern in create flow.
- Use one DB transaction/connection per request path where possible. - [x] Use one DB transaction/connection per request path where possible.
- Add backup and restore flow in admin API/UI. - Add backup and restore flow in admin API/UI.
- Download full export. - Download full export.
- Upload validated import with explicit confirmation. - Upload validated import with explicit confirmation.

View File

@@ -3,7 +3,7 @@ from __future__ import annotations
import secrets import secrets
import os import os
import time import time
from datetime import datetime from datetime import datetime, timezone
from contextlib import contextmanager from contextlib import contextmanager
from pathlib import Path from pathlib import Path
from typing import Optional from typing import Optional
@@ -122,14 +122,48 @@ def init_db():
url text not null, url text not null,
description text, description text,
category varchar(255), category varchar(255),
sort_order int not null default 0,
icon_blob longblob, icon_blob longblob,
icon_mime varchar(255), icon_mime varchar(255),
icon_url text, icon_url text,
enabled tinyint(1) not null default 1, enabled tinyint(1) not null default 1,
created_at varchar(64) not null, created_at datetime not null,
updated_at varchar(64) not null updated_at datetime not null
) engine=InnoDB default charset=utf8mb4 ) 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() init_db()
@@ -156,6 +190,10 @@ def utc_now_iso() -> str:
return datetime.utcnow().isoformat() return datetime.utcnow().isoformat()
def utc_now_db() -> datetime:
return datetime.now(timezone.utc).replace(tzinfo=None)
def expires_at_iso() -> str: def expires_at_iso() -> str:
now = datetime.utcnow().timestamp() now = datetime.utcnow().timestamp()
return datetime.utcfromtimestamp(now + SESSION_TTL_SECONDS).isoformat() return datetime.utcfromtimestamp(now + SESSION_TTL_SECONDS).isoformat()
@@ -398,7 +436,7 @@ def logout(request: Request):
def links(): def links():
with db() as c: with db() as c:
with c.cursor() as cur: 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() rows = cur.fetchall()
out = [] out = []
for r in rows: for r in rows:
@@ -437,17 +475,16 @@ def create_link(
require_admin(request) require_admin(request)
require_csrf(request) require_csrf(request)
validate_link_payload(name, url, description, category, icon_url) 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: with db() as c:
if link_name_exists(c, name): if link_name_exists(c, name):
raise HTTPException(409, "Link name already exists") 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: with c.cursor() as cur:
cur.execute( cur.execute(
"""insert into links(name,url,description,category,icon_blob,icon_mime,icon_url,enabled,created_at,updated_at) """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)""", values (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)""",
(name, url, description, category, icon_blob, icon_mime, icon_url, int(enabled), now, now), (name, url, description, category, 0, icon_blob, icon_mime, icon_url, int(enabled), now, now),
) )
return {"ok": True} return {"ok": True}
@@ -489,7 +526,7 @@ def update_link(
require_csrf(request) require_csrf(request)
validate_link_payload(name, url, description, category, icon_url) validate_link_payload(name, url, description, category, icon_url)
icon_blob, icon_mime = read_icon_blob(icon) icon_blob, icon_mime = read_icon_blob(icon)
now = datetime.utcnow().isoformat() now = utc_now_db()
with db() as c: with db() as c:
if link_name_exists(c, name, exclude_id=link_id): if link_name_exists(c, name, exclude_id=link_id):
raise HTTPException(409, "Link name already exists") raise HTTPException(409, "Link name already exists")