Initial commit: certgen — сервис генерации сертификатов

This commit is contained in:
apuc committed 2026-10-16 17:17:47 +03:00
commit 49bac43e16
23 files changed
+3046

No files matched your search

+479
View File
@@ -0,0 +1,479 @@
"""Хранилище (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"),))