Files

480 lines
17 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Хранилище (SQLite): сертификаты и шаблоны."""
from __future__ import annotations
import json
import sqlite3
import threading
from datetime import datetime, timedelta, timezone
from pathlib import Path
from . import builtin, config
from .config import DB_PATH, TEMPLATES_DIR, ensure_dirs
_LOCK = threading.Lock()
SCHEMA = """
CREATE TABLE IF NOT EXISTS certificates (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
number TEXT NOT NULL,
template TEXT NOT NULL,
fio TEXT,
title TEXT,
body TEXT,
date_text TEXT,
fields TEXT,
png_path TEXT,
pdf_path TEXT,
recipient_email TEXT,
created_by INTEGER,
public_token TEXT,
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_cert_number ON certificates(number);
CREATE TABLE IF NOT EXISTS templates (
slug TEXT PRIMARY KEY,
title TEXT NOT NULL,
kind TEXT NOT NULL,
background TEXT,
layout TEXT,
html TEXT,
page TEXT,
defaults TEXT,
builtin INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT NOT NULL UNIQUE COLLATE NOCASE,
password_hash TEXT NOT NULL,
name TEXT,
role TEXT NOT NULL DEFAULT 'user',
is_active INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS sessions (
token TEXT PRIMARY KEY,
user_id INTEGER NOT NULL,
created_at TEXT NOT NULL,
expires_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions(user_id);
CREATE TABLE IF NOT EXISTS meta (
key TEXT PRIMARY KEY,
value TEXT
);
"""
def _migrate(conn: sqlite3.Connection) -> None:
"""Добавить недостающие колонки в старые базы."""
cols = {r["name"] for r in conn.execute("PRAGMA table_info(certificates)").fetchall()}
for name, ddl in (("recipient_email", "TEXT"), ("created_by", "INTEGER"),
("public_token", "TEXT")):
if name not in cols:
conn.execute(f"ALTER TABLE certificates ADD COLUMN {name} {ddl}")
conn.execute("CREATE INDEX IF NOT EXISTS idx_cert_recipient ON certificates(recipient_email)")
conn.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_cert_token ON certificates(public_token)")
def _connect() -> sqlite3.Connection:
conn = sqlite3.connect(DB_PATH, timeout=30)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA journal_mode=WAL")
return conn
def _now() -> str:
return datetime.now(timezone.utc).isoformat(timespec="seconds")
def init_db() -> None:
ensure_dirs()
with _LOCK, _connect() as conn:
conn.executescript(SCHEMA)
_migrate(conn)
builtin.ensure_assets()
ensure_builtin_templates()
# ---------------------------------------------------------------- templates
def _row_to_template(row: sqlite3.Row) -> dict:
return {
"slug": row["slug"],
"title": row["title"],
"kind": row["kind"],
"background": row["background"],
"layout": json.loads(row["layout"]) if row["layout"] else [],
"html": row["html"],
"page": json.loads(row["page"]) if row["page"] else None,
"defaults": json.loads(row["defaults"]) if row["defaults"] else {},
"builtin": bool(row["builtin"]),
"created_at": row["created_at"],
}
def ensure_builtin_templates() -> None:
with _LOCK, _connect() as conn:
row = conn.execute("SELECT value FROM meta WHERE key='builtin_version'").fetchone()
current = row["value"] if row else None
if current == builtin.BUILTIN_VERSION:
return
for tpl in builtin.builtin_templates():
upsert_template(
slug=tpl["slug"],
title=tpl["title"],
kind=tpl["kind"],
background=tpl.get("background"),
layout=tpl.get("layout"),
html=tpl.get("html"),
page=tpl.get("page"),
defaults=tpl.get("defaults"),
is_builtin=True,
)
with _LOCK, _connect() as conn:
conn.execute(
"INSERT INTO meta(key,value) VALUES('builtin_version',?) "
"ON CONFLICT(key) DO UPDATE SET value=excluded.value",
(builtin.BUILTIN_VERSION,),
)
def upsert_template(*, slug: str, title: str, kind: str, background=None,
layout=None, html=None, page=None, defaults=None,
is_builtin: bool = False) -> dict:
with _LOCK, _connect() as conn:
conn.execute(
"""INSERT INTO templates
(slug, title, kind, background, layout, html, page, defaults, builtin, created_at)
VALUES (?,?,?,?,?,?,?,?,?,?)
ON CONFLICT(slug) DO UPDATE SET
title=excluded.title, kind=excluded.kind, background=excluded.background,
layout=excluded.layout, html=excluded.html, page=excluded.page,
defaults=excluded.defaults, builtin=excluded.builtin""",
(
slug, title, kind, background,
json.dumps(layout, ensure_ascii=False) if layout is not None else None,
html,
json.dumps(page, ensure_ascii=False) if page is not None else None,
json.dumps(defaults or {}, ensure_ascii=False),
1 if is_builtin else 0, _now(),
),
)
return get_template(slug)
def get_template(slug: str) -> dict | None:
with _LOCK, _connect() as conn:
row = conn.execute("SELECT * FROM templates WHERE slug=?", (slug,)).fetchone()
return _row_to_template(row) if row else None
def list_templates() -> list[dict]:
with _LOCK, _connect() as conn:
rows = conn.execute("SELECT * FROM templates ORDER BY builtin DESC, slug").fetchall()
out = []
for row in rows:
t = _row_to_template(row)
t.pop("html", None) # не тянем тяжёлое поле в список
t.pop("layout", None)
out.append(t)
return out
def delete_template(slug: str) -> bool:
with _LOCK, _connect() as conn:
cur = conn.execute("DELETE FROM templates WHERE slug=? AND builtin=0", (slug,))
return cur.rowcount > 0
# ------------------------------------------------------------- certificates
def _format_number(seq: int, prefix: str | None) -> str:
base = f"{seq:0{config.NUMBER_PAD}d}"
prefix = (prefix or "").strip()
return f"{prefix}-{base}" if prefix else base
def reserve_certificate(*, template: str, values: dict, prefix: str | None = None,
number: int | str | None = None,
recipient_email: str | None = None,
created_by: int | None = None) -> dict:
"""Зарезервировать номер: вставить строку и вернуть её (файлы пока пустые).
По умолчанию номер берётся из общей нумерации (начиная с config.START_SEQ).
Если передан `number`, используется явный номер из зарезервированного
диапазона 1 .. START_SEQ-1 (для особых случаев).
"""
with _LOCK, _connect() as conn:
conn.execute("BEGIN IMMEDIATE")
if number is not None and str(number).strip():
try:
seq = int(str(number).strip())
except (TypeError, ValueError):
raise ValueError("Номер должен быть целым числом") from None
if seq < 1:
raise ValueError("Номер должен быть положительным")
if seq >= config.START_SEQ:
raise ValueError(
f"Явный номер допускается только из резерва 1..{config.START_SEQ - 1}"
)
if conn.execute("SELECT 1 FROM certificates WHERE seq=?", (seq,)).fetchone():
raise ValueError(f"Номер {seq} уже занят")
else:
row = conn.execute("SELECT MAX(seq) AS m FROM certificates").fetchone()
# обычная нумерация никогда не заходит в зарезервированный диапазон
seq = max(int(row["m"] or 0), config.START_SEQ - 1) + 1
number = _format_number(seq, prefix)
conn.execute(
"""INSERT INTO certificates
(seq, number, template, fio, title, body, date_text, fields,
png_path, pdf_path, recipient_email, created_by, created_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)""",
(
seq, number, template,
values.get("fio"), values.get("title"), values.get("body"),
values.get("date"),
json.dumps(values.get("fields") or {}, ensure_ascii=False),
None, None,
(recipient_email or "").strip().lower() or None,
created_by, _now(),
),
)
return get_certificate(seq)
def update_certificate_files(seq: int, png_path: str, pdf_path: str) -> None:
with _LOCK, _connect() as conn:
conn.execute(
"UPDATE certificates SET png_path=?, pdf_path=? WHERE seq=?",
(png_path, pdf_path, seq),
)
def _row_to_cert(row: sqlite3.Row) -> dict:
return {
"seq": row["seq"],
"number": row["number"],
"template": row["template"],
"fio": row["fio"],
"title": row["title"],
"body": row["body"],
"date": row["date_text"],
"fields": json.loads(row["fields"]) if row["fields"] else {},
"png_path": row["png_path"],
"pdf_path": row["pdf_path"],
"recipient_email": row["recipient_email"],
"created_by": row["created_by"],
"public_token": row["public_token"],
"created_at": row["created_at"],
}
def get_certificate(ref: int | str) -> dict | None:
with _LOCK, _connect() as conn:
if isinstance(ref, int) or str(ref).isdigit():
row = conn.execute("SELECT * FROM certificates WHERE seq=?", (int(ref),)).fetchone()
else:
row = conn.execute("SELECT * FROM certificates WHERE number=?", (str(ref),)).fetchone()
return _row_to_cert(row) if row else None
def list_certificates(limit: int = 50, offset: int = 0,
recipient_email: str | None = None) -> list[dict]:
"""Если recipient_email задан — вернуть только документы этого получателя."""
with _LOCK, _connect() as conn:
if recipient_email:
rows = conn.execute(
"SELECT * FROM certificates WHERE lower(recipient_email)=lower(?) "
"ORDER BY seq DESC LIMIT ? OFFSET ?",
(recipient_email.strip(), limit, offset),
).fetchall()
else:
rows = conn.execute(
"SELECT * FROM certificates ORDER BY seq DESC LIMIT ? OFFSET ?",
(limit, offset),
).fetchall()
return [_row_to_cert(r) for r in rows]
def count_certificates(recipient_email: str | None = None) -> int:
with _LOCK, _connect() as conn:
if recipient_email:
row = conn.execute(
"SELECT COUNT(*) AS n FROM certificates WHERE lower(recipient_email)=lower(?)",
(recipient_email.strip(),),
).fetchone()
else:
row = conn.execute("SELECT COUNT(*) AS n FROM certificates").fetchone()
return int(row["n"])
def set_public_token(ref: int | str, token: str | None) -> dict | None:
cert = get_certificate(ref)
if not cert:
return None
with _LOCK, _connect() as conn:
conn.execute("UPDATE certificates SET public_token=? WHERE seq=?", (token, cert["seq"]))
return get_certificate(cert["seq"])
def get_certificate_by_token(token: str) -> dict | None:
if not token:
return None
with _LOCK, _connect() as conn:
row = conn.execute("SELECT * FROM certificates WHERE public_token=?", (token,)).fetchone()
return _row_to_cert(row) if row else None
def delete_certificate(ref: int | str) -> bool:
cert = get_certificate(ref)
if not cert:
return False
with _LOCK, _connect() as conn:
conn.execute("DELETE FROM certificates WHERE seq=?", (cert["seq"],))
for key in ("png_path", "pdf_path"):
p = cert.get(key)
if p:
Path(p).unlink(missing_ok=True)
return True
# -------------------------------------------------------------------- users
def _row_to_user(row: sqlite3.Row) -> dict:
return {
"id": row["id"],
"email": row["email"],
"name": row["name"],
"role": row["role"],
"is_active": bool(row["is_active"]),
"created_at": row["created_at"],
}
def create_user(email: str, password_hash: str, name: str | None = None,
role: str = "user") -> dict:
email = (email or "").strip().lower()
with _LOCK, _connect() as conn:
try:
cur = conn.execute(
"INSERT INTO users (email, password_hash, name, role, is_active, created_at) "
"VALUES (?,?,?,?,1,?)",
(email, password_hash, name, role, _now()),
)
except sqlite3.IntegrityError:
raise ValueError("Пользователь с таким email уже существует") from None
uid = cur.lastrowid
return get_user(uid)
def get_user(user_id: int) -> dict | None:
with _LOCK, _connect() as conn:
row = conn.execute("SELECT * FROM users WHERE id=?", (user_id,)).fetchone()
return _row_to_user(row) if row else None
def get_user_by_email(email: str) -> dict | None:
with _LOCK, _connect() as conn:
row = conn.execute("SELECT * FROM users WHERE lower(email)=lower(?)",
((email or "").strip(),)).fetchone()
return _row_to_user(row) if row else None
def get_password_hash(user_id: int) -> str | None:
with _LOCK, _connect() as conn:
row = conn.execute("SELECT password_hash FROM users WHERE id=?", (user_id,)).fetchone()
return row["password_hash"] if row else None
def list_users() -> list[dict]:
with _LOCK, _connect() as conn:
rows = conn.execute(
"SELECT * FROM users ORDER BY (role='admin') DESC, email"
).fetchall()
return [_row_to_user(r) for r in rows]
def count_users(role: str | None = None) -> int:
with _LOCK, _connect() as conn:
if role:
row = conn.execute("SELECT COUNT(*) AS n FROM users WHERE role=?", (role,)).fetchone()
else:
row = conn.execute("SELECT COUNT(*) AS n FROM users").fetchone()
return int(row["n"])
def update_user(user_id: int, *, role: str | None = None, is_active: bool | None = None,
name: str | None = None, password_hash: str | None = None) -> bool:
sets, params = [], []
if role is not None:
sets.append("role=?"); params.append(role)
if is_active is not None:
sets.append("is_active=?"); params.append(1 if is_active else 0)
if name is not None:
sets.append("name=?"); params.append(name)
if password_hash is not None:
sets.append("password_hash=?"); params.append(password_hash)
if not sets:
return False
params.append(user_id)
with _LOCK, _connect() as conn:
cur = conn.execute(f"UPDATE users SET {', '.join(sets)} WHERE id=?", params)
return cur.rowcount > 0
def delete_user(user_id: int) -> bool:
with _LOCK, _connect() as conn:
conn.execute("DELETE FROM sessions WHERE user_id=?", (user_id,))
cur = conn.execute("DELETE FROM users WHERE id=?", (user_id,))
return cur.rowcount > 0
# ----------------------------------------------------------------- sessions
def create_session(token: str, user_id: int, ttl_days: int = 30) -> None:
now = datetime.now(timezone.utc)
expires = now + timedelta(days=ttl_days)
with _LOCK, _connect() as conn:
conn.execute(
"INSERT INTO sessions (token, user_id, created_at, expires_at) VALUES (?,?,?,?)",
(token, user_id, now.isoformat(timespec="seconds"),
expires.isoformat(timespec="seconds")),
)
def get_session_user(token: str) -> dict | None:
if not token:
return None
with _LOCK, _connect() as conn:
row = conn.execute("SELECT * FROM sessions WHERE token=?", (token,)).fetchone()
if not row:
return None
if row["expires_at"] <= datetime.now(timezone.utc).isoformat(timespec="seconds"):
conn.execute("DELETE FROM sessions WHERE token=?", (token,))
return None
urow = conn.execute("SELECT * FROM users WHERE id=?", (row["user_id"],)).fetchone()
if not urow or not urow["is_active"]:
return None
return _row_to_user(urow)
def delete_session(token: str) -> None:
with _LOCK, _connect() as conn:
conn.execute("DELETE FROM sessions WHERE token=?", (token,))
def delete_user_sessions(user_id: int) -> None:
with _LOCK, _connect() as conn:
conn.execute("DELETE FROM sessions WHERE user_id=?", (user_id,))
def purge_expired_sessions() -> None:
with _LOCK, _connect() as conn:
conn.execute("DELETE FROM sessions WHERE expires_at <= ?",
(datetime.now(timezone.utc).isoformat(timespec="seconds"),))