Add admin database backup export and import
This commit is contained in:
+173
@@ -0,0 +1,173 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from database import get_db, init_db
|
||||
|
||||
BACKUP_VERSION = 1
|
||||
TABLES = ("guest_links", "guest_claims", "traffic_grants")
|
||||
|
||||
|
||||
def _now_iso() -> str:
|
||||
return datetime.now(timezone.utc).replace(microsecond=0).isoformat()
|
||||
|
||||
|
||||
async def export_backup() -> dict[str, Any]:
|
||||
db = await get_db()
|
||||
try:
|
||||
payload: dict[str, Any] = {
|
||||
"format": "remna-site-backup",
|
||||
"version": BACKUP_VERSION,
|
||||
"exported_at": _now_iso(),
|
||||
}
|
||||
for table in TABLES:
|
||||
cursor = await db.execute(f"SELECT * FROM {table} ORDER BY id ASC")
|
||||
rows = await cursor.fetchall()
|
||||
payload[table] = [dict(row) for row in rows]
|
||||
return payload
|
||||
finally:
|
||||
await db.close()
|
||||
|
||||
|
||||
def validate_backup(data: Any) -> dict[str, Any]:
|
||||
if not isinstance(data, dict):
|
||||
raise ValueError("Файл должен быть JSON-объектом")
|
||||
if data.get("format") != "remna-site-backup":
|
||||
raise ValueError("Неверный формат бэкапа (ожидается remna-site-backup)")
|
||||
version = data.get("version")
|
||||
if version != BACKUP_VERSION:
|
||||
raise ValueError(f"Неподдерживаемая версия бэкапа: {version}")
|
||||
for table in TABLES:
|
||||
if table not in data or not isinstance(data[table], list):
|
||||
raise ValueError(f"В бэкапе нет таблицы {table}")
|
||||
return data
|
||||
|
||||
|
||||
async def import_backup(data: dict[str, Any]) -> dict[str, int]:
|
||||
validate_backup(data)
|
||||
await init_db()
|
||||
|
||||
links = data["guest_links"]
|
||||
claims = data["guest_claims"]
|
||||
grants = data["traffic_grants"]
|
||||
|
||||
db = await get_db()
|
||||
try:
|
||||
await db.execute("PRAGMA foreign_keys = OFF")
|
||||
await db.execute("BEGIN IMMEDIATE")
|
||||
await db.execute("DELETE FROM traffic_grants")
|
||||
await db.execute("DELETE FROM guest_claims")
|
||||
await db.execute("DELETE FROM guest_links")
|
||||
|
||||
for row in links:
|
||||
cols = [
|
||||
"id",
|
||||
"token",
|
||||
"link_type",
|
||||
"title",
|
||||
"value",
|
||||
"max_uses",
|
||||
"used_count",
|
||||
"is_active",
|
||||
"expires_at",
|
||||
"created_at",
|
||||
"note",
|
||||
"duration_days",
|
||||
"plan_traffic_gb",
|
||||
]
|
||||
values = [row.get(c) for c in cols]
|
||||
# defaults for older backups
|
||||
if values[cols.index("duration_days")] is None:
|
||||
values[cols.index("duration_days")] = 0
|
||||
if values[cols.index("plan_traffic_gb")] is None:
|
||||
values[cols.index("plan_traffic_gb")] = 0
|
||||
placeholders = ", ".join("?" for _ in cols)
|
||||
await db.execute(
|
||||
f"INSERT INTO guest_links ({', '.join(cols)}) VALUES ({placeholders})",
|
||||
values,
|
||||
)
|
||||
|
||||
for row in claims:
|
||||
cols = ["id", "link_id", "username", "user_uuid", "detail", "claimed_at"]
|
||||
values = [row.get(c) for c in cols]
|
||||
placeholders = ", ".join("?" for _ in cols)
|
||||
await db.execute(
|
||||
f"INSERT INTO guest_claims ({', '.join(cols)}) VALUES ({placeholders})",
|
||||
values,
|
||||
)
|
||||
|
||||
for row in grants:
|
||||
cols = [
|
||||
"id",
|
||||
"claim_id",
|
||||
"link_id",
|
||||
"user_uuid",
|
||||
"username",
|
||||
"bytes_added",
|
||||
"expires_at",
|
||||
"reverted_at",
|
||||
"status",
|
||||
"last_error",
|
||||
"created_at",
|
||||
]
|
||||
values = [row.get(c) for c in cols]
|
||||
if values[cols.index("status")] is None:
|
||||
values[cols.index("status")] = "active"
|
||||
if values[cols.index("last_error")] is None:
|
||||
values[cols.index("last_error")] = ""
|
||||
if values[cols.index("username")] is None:
|
||||
values[cols.index("username")] = ""
|
||||
placeholders = ", ".join("?" for _ in cols)
|
||||
await db.execute(
|
||||
f"INSERT INTO traffic_grants ({', '.join(cols)}) VALUES ({placeholders})",
|
||||
values,
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
|
||||
# восстановить autoincrement после вставки с явными id
|
||||
for table in TABLES:
|
||||
cursor = await db.execute(f"SELECT MAX(id) AS m FROM {table}")
|
||||
max_id = (await cursor.fetchone())["m"] or 0
|
||||
try:
|
||||
await db.execute("DELETE FROM sqlite_sequence WHERE name = ?", (table,))
|
||||
if max_id:
|
||||
await db.execute(
|
||||
"INSERT INTO sqlite_sequence(name, seq) VALUES (?, ?)",
|
||||
(table, max_id),
|
||||
)
|
||||
except Exception:
|
||||
# sqlite_sequence ещё нет — не критично
|
||||
pass
|
||||
await db.commit()
|
||||
await db.execute("PRAGMA foreign_keys = ON")
|
||||
except Exception:
|
||||
await db.execute("ROLLBACK")
|
||||
await db.execute("PRAGMA foreign_keys = ON")
|
||||
raise
|
||||
finally:
|
||||
await db.close()
|
||||
|
||||
return {
|
||||
"guest_links": len(links),
|
||||
"guest_claims": len(claims),
|
||||
"traffic_grants": len(grants),
|
||||
}
|
||||
|
||||
|
||||
def dumps_backup(payload: dict[str, Any]) -> bytes:
|
||||
return json.dumps(payload, ensure_ascii=False, indent=2).encode("utf-8")
|
||||
|
||||
|
||||
def loads_backup(raw: bytes) -> dict[str, Any]:
|
||||
try:
|
||||
text = raw.decode("utf-8-sig")
|
||||
except UnicodeDecodeError as exc:
|
||||
raise ValueError("Файл должен быть в UTF-8") from exc
|
||||
try:
|
||||
data = json.loads(text)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise ValueError(f"Некорректный JSON: {exc}") from exc
|
||||
return validate_backup(data)
|
||||
Reference in New Issue
Block a user