Files
colibre/src/saved_views/db.py
T
Colin Maudry 31427a8b3b feat(vues): jeton de partage en minuscules (base36, #112)
Alphabet du jeton passé de base62 (a-z A-Z 0-9) à base36 (a-z 0-9), longueur
inchangée (6). Lien plus lisible/dictable et cohérent avec le slug déjà en
minuscules. Les jetons déjà générés (à casse mixte) restent valides (lookup par
match exact).
2026-07-13 22:17:26 +02:00

128 lines
3.8 KiB
Python

import secrets
import sqlite3
import string
from datetime import datetime, timezone
from src.auth.db import get_conn
SCHEMA = """
CREATE TABLE IF NOT EXISTS saved_views (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
table_name TEXT NOT NULL DEFAULT 'tableau',
name TEXT NOT NULL,
query TEXT NOT NULL,
token TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
UNIQUE (user_id, table_name, name)
);
CREATE INDEX IF NOT EXISTS idx_saved_views_user
ON saved_views(user_id, table_name);
CREATE UNIQUE INDEX IF NOT EXISTS idx_saved_views_token
ON saved_views(token);
"""
_TOKEN_ALPHABET = string.ascii_lowercase + string.digits # base36 (a-z 0-9)
def generate_token(length: int = 6) -> str:
return "".join(secrets.choice(_TOKEN_ALPHABET) for _ in range(length))
def _unique_token(conn) -> str:
while True:
token = generate_token()
exists = conn.execute(
"SELECT 1 FROM saved_views WHERE token = ?", (token,)
).fetchone()
if exists is None:
return token
def _now() -> str:
return datetime.now(timezone.utc).isoformat()
def init_schema() -> None:
conn = get_conn()
conn.executescript(SCHEMA)
# Backfill des lignes pré-migration (token NULL). L'index unique tolère
# plusieurs NULL transitoires ; on attribue un jeton à chacune. Idempotent :
# sans effet une fois toutes les lignes pourvues.
null_rows = conn.execute(
"SELECT id FROM saved_views WHERE token IS NULL"
).fetchall()
for row in null_rows:
conn.execute(
"UPDATE saved_views SET token = ? WHERE id = ?",
(_unique_token(conn), row["id"]),
)
def list_views(user_id: int, table_name: str = "tableau") -> list[sqlite3.Row]:
return (
get_conn()
.execute(
"SELECT * FROM saved_views WHERE user_id = ? AND table_name = ? "
"ORDER BY name COLLATE NOCASE",
(user_id, table_name),
)
.fetchall()
)
def get(view_id: int, user_id: int) -> sqlite3.Row | None:
return (
get_conn()
.execute(
"SELECT * FROM saved_views WHERE id = ? AND user_id = ?",
(view_id, user_id),
)
.fetchone()
)
def get_by_token(token: str) -> sqlite3.Row | None:
return (
get_conn()
.execute("SELECT * FROM saved_views WHERE token = ?", (token,))
.fetchone()
)
def upsert(user_id: int, table_name: str, name: str, query: str) -> str:
now = _now()
conn = get_conn()
# Jeton candidat, utilisé uniquement en cas d'INSERT réel ; à l'écrasement
# (ON CONFLICT ... DO UPDATE), `token` n'est pas dans le SET → conservé.
conn.execute(
"INSERT INTO saved_views "
"(user_id, table_name, name, query, token, created_at, updated_at) "
"VALUES (?, ?, ?, ?, ?, ?, ?) "
"ON CONFLICT(user_id, table_name, name) DO UPDATE SET "
"query = excluded.query, updated_at = excluded.updated_at",
(user_id, table_name, name, query, _unique_token(conn), now, now),
)
row = conn.execute(
"SELECT token FROM saved_views "
"WHERE user_id = ? AND table_name = ? AND name = ?",
(user_id, table_name, name),
).fetchone()
return row["token"]
def rename(view_id: int, user_id: int, new_name: str) -> None:
get_conn().execute(
"UPDATE saved_views SET name = ?, updated_at = ? WHERE id = ? AND user_id = ?",
(new_name, _now(), view_id, user_id),
)
def delete(view_id: int, user_id: int) -> None:
get_conn().execute(
"DELETE FROM saved_views WHERE id = ? AND user_id = ?",
(view_id, user_id),
)