changes in db

This commit is contained in:
Mateusz Gruszczyński
2026-05-26 09:25:47 +02:00
parent 8268ad87cf
commit 70a9344cdd
10 changed files with 346 additions and 165 deletions

View File

@@ -15,7 +15,7 @@ APP_BACKUP_TABLES = [
"rtorrent_config_overrides", "poller_settings", "app_settings", "download_plan_settings",
]
# Note: Profile backups contain only the active profile context and current user's profile-scoped preferences.
# Note: Profile backups contain active profile data. User-specific preferences remain scoped to the current user.
PROFILE_BACKUP_TABLES = [
"rtorrent_profiles", "profile_preferences", "disk_monitor_preferences", "labels", "ratio_groups",
"rss_feeds", "rss_rules", "smart_queue_settings", "smart_queue_exclusions",
@@ -28,12 +28,12 @@ PROFILE_TABLE_FILTERS = {
"disk_monitor_preferences": "user_id=? AND profile_id=?",
"labels": "user_id=? AND profile_id=?",
"ratio_groups": "user_id=? AND profile_id=?",
"rss_feeds": "user_id=? AND profile_id=?",
"rss_rules": "user_id=? AND profile_id=?",
"smart_queue_settings": "user_id=? AND profile_id=?",
"smart_queue_exclusions": "user_id=? AND profile_id=?",
"rss_feeds": "profile_id=?",
"rss_rules": "profile_id=?",
"smart_queue_settings": "profile_id=?",
"smart_queue_exclusions": "profile_id=?",
"automation_rules": "user_id=? AND profile_id=?",
"rtorrent_config_overrides": "user_id=? AND profile_id=?",
"rtorrent_config_overrides": "profile_id=?",
"poller_settings": "profile_id=?",
"download_plan_settings": "user_id=? AND profile_id=?",
}
@@ -76,6 +76,13 @@ def _loads(value: str) -> dict:
return {}
def _table_columns(conn, table: str) -> set[str]:
try:
return {str(row["name"]) for row in conn.execute(f"PRAGMA table_info({table})").fetchall()}
except Exception:
return set()
def _table_rows(conn, table: str, where: str | None = None, params: tuple = ()) -> list[dict]:
try:
sql = f"SELECT * FROM {table}" + (f" WHERE {where}" if where else "")
@@ -191,7 +198,10 @@ def restore_app_backup(backup_id: int, user_id: int | None = None) -> dict:
rows = tables.get(table) or []
if not rows:
continue
columns = list(rows[0].keys())
available = _table_columns(conn, table)
columns = [col for col in rows[0].keys() if col in available]
if not columns:
continue
placeholders = ",".join("?" for _ in columns)
conn.execute(f"DELETE FROM {table}")
for row in rows:
@@ -245,7 +255,10 @@ def restore_profile_backup(backup_id: int, target_profile_id: int, user_id: int
count = 0
for row in rows:
clean = _rewrite_profile_row(table, dict(row), user_id, int(target_profile_id))
columns = list(clean.keys())
available = _table_columns(conn, table)
columns = [col for col in clean.keys() if col in available]
if not columns:
continue
placeholders = ",".join("?" for _ in columns)
conn.execute(f"INSERT INTO {table}({','.join(columns)}) VALUES({placeholders})", [clean.get(col) for col in columns])
count += 1
@@ -274,15 +287,24 @@ def delete_backup(backup_id: int, user_id: int | None = None) -> dict:
return {"deleted": backup_id}
def _settings_row_key(user_id: int | None = None) -> str:
return f"{AUTO_BACKUP_SETTINGS_KEY}:{user_id or auth.current_user_id() or default_user_id()}"
def _settings_row_key(user_id: int | None = None, backup_type: str = "app", profile_id: int | None = None) -> str:
uid = user_id or auth.current_user_id() or default_user_id()
scope = "profile" if backup_type == "profile" else "app"
if scope == "profile":
return f"{AUTO_BACKUP_SETTINGS_KEY}:profile:{uid}:{int(profile_id or 0)}"
return f"{AUTO_BACKUP_SETTINGS_KEY}:app:{uid}"
def _latest_backup_created_at(user_id: int) -> str | None:
def _latest_backup_created_at(user_id: int, backup_type: str = "app", profile_id: int | None = None) -> str | None:
clauses = ["user_id=?", "COALESCE(backup_type,'app')=?"]
params: list[object] = [user_id, backup_type]
if backup_type == "profile":
clauses.append("profile_id=?")
params.append(int(profile_id or 0))
with connect() as conn:
row = conn.execute(
"SELECT created_at FROM app_backups WHERE user_id=? AND COALESCE(backup_type,'app')='app' ORDER BY created_at DESC, id DESC LIMIT 1",
(user_id,),
f"SELECT created_at FROM app_backups WHERE {' AND '.join(clauses)} ORDER BY created_at DESC, id DESC LIMIT 1",
tuple(params),
).fetchone()
return str(row["created_at"] or "") if row and row.get("created_at") else None
@@ -302,20 +324,29 @@ def _preview_row(row: dict) -> dict:
return output
def get_auto_backup_settings(user_id: int | None = None) -> dict:
key = _settings_row_key(user_id)
def get_auto_backup_settings(user_id: int | None = None, backup_type: str = "app", profile_id: int | None = None) -> dict:
key = _settings_row_key(user_id, backup_type, profile_id)
with connect() as conn:
row = conn.execute("SELECT value FROM app_settings WHERE key=?", (key,)).fetchone()
settings = {**DEFAULT_AUTO_BACKUP_SETTINGS, **_loads(row.get("value") if row else "{}")}
settings["enabled"] = bool(settings.get("enabled"))
settings["interval_hours"] = max(1, int(settings.get("interval_hours") or 24))
settings["retention_days"] = max(1, int(settings.get("retention_days") or 30))
settings["backup_type"] = "profile" if backup_type == "profile" else "app"
if backup_type == "profile":
settings["profile_id"] = int(profile_id or 0)
return settings
def save_auto_backup_settings(data: dict, user_id: int | None = None) -> dict:
_require_admin(user_id)
current = get_auto_backup_settings(user_id)
def save_auto_backup_settings(data: dict, user_id: int | None = None, backup_type: str = "app", profile_id: int | None = None) -> dict:
user_id = user_id or auth.current_user_id() or default_user_id()
backup_type = "profile" if backup_type == "profile" else "app"
if backup_type == "app":
_require_admin(user_id)
else:
if not profile_id or not auth.can_access_profile(int(profile_id), user_id):
raise PermissionError("No access to profile")
current = get_auto_backup_settings(user_id, backup_type, profile_id)
settings = {
**current,
"enabled": bool(data.get("enabled")),
@@ -323,7 +354,7 @@ def save_auto_backup_settings(data: dict, user_id: int | None = None) -> dict:
"retention_days": max(1, int(data.get("retention_days") or current["retention_days"])),
"last_run_at": data.get("last_run_at", current.get("last_run_at")),
}
key = _settings_row_key(user_id)
key = _settings_row_key(user_id, backup_type, profile_id)
with connect() as conn:
conn.execute("INSERT OR REPLACE INTO app_settings(key,value) VALUES(?,?)", (key, json.dumps(settings)))
return settings
@@ -350,39 +381,69 @@ def preview_backup(backup_id: int, user_id: int | None = None) -> dict:
}
def prune_old_backups(user_id: int | None = None, retention_days: int = 30) -> int:
def prune_old_backups(user_id: int | None = None, retention_days: int = 30, backup_type: str = "app", profile_id: int | None = None) -> int:
user_id = user_id or auth.current_user_id() or default_user_id()
cutoff = (datetime.now(timezone.utc) - timedelta(days=max(1, int(retention_days)))).isoformat(timespec="seconds")
clauses = ["user_id=?", "COALESCE(backup_type,'app')=?", "created_at<?"]
params: list[object] = [user_id, backup_type, cutoff]
if backup_type == "profile":
clauses.append("profile_id=?")
params.append(int(profile_id or 0))
with connect() as conn:
cur = conn.execute("DELETE FROM app_backups WHERE user_id=? AND COALESCE(backup_type,'app')='app' AND created_at<?", (user_id, cutoff))
cur = conn.execute(f"DELETE FROM app_backups WHERE {' AND '.join(clauses)}", tuple(params))
return int(cur.rowcount or 0)
def maybe_create_automatic_backup(user_id: int | None = None) -> dict | None:
user_id = user_id or default_user_id()
if not _is_admin_user(user_id):
return None
settings = get_auto_backup_settings(user_id)
if not settings.get("enabled"):
return None
def _should_run(settings: dict, last_value: str | None) -> bool:
now = datetime.now(timezone.utc)
last_value = settings.get("last_run_at") or _latest_backup_created_at(user_id)
try:
last = datetime.fromisoformat(str(last_value).replace("Z", "+00:00")) if last_value else None
except Exception:
last = None
if last and now - last < timedelta(hours=settings["interval_hours"]):
return not last or now - last >= timedelta(hours=settings["interval_hours"])
def maybe_create_automatic_backup(user_id: int | None = None, backup_type: str = "app", profile_id: int | None = None) -> dict | None:
user_id = user_id or default_user_id()
backup_type = "profile" if backup_type == "profile" else "app"
if backup_type == "app" and not _is_admin_user(user_id):
return None
if backup_type == "profile" and (not profile_id or not auth.can_access_profile(int(profile_id), user_id)):
return None
settings = get_auto_backup_settings(user_id, backup_type, profile_id)
if not settings.get("enabled"):
return None
last_value = settings.get("last_run_at") or _latest_backup_created_at(user_id, backup_type, profile_id)
if not _should_run(settings, last_value):
if settings.get("last_run_at") != last_value:
settings["last_run_at"] = last_value
save_auto_backup_settings(settings, user_id)
save_auto_backup_settings(settings, user_id, backup_type, profile_id)
return None
backup = create_app_backup(f"Automatic application backup {now.isoformat(timespec='seconds')}", user_id, automatic=True)
now = datetime.now(timezone.utc)
if backup_type == "profile":
backup = create_profile_backup(f"Automatic profile backup {now.isoformat(timespec='seconds')}", int(profile_id or 0), user_id, automatic=True)
else:
backup = create_app_backup(f"Automatic application backup {now.isoformat(timespec='seconds')}", user_id, automatic=True)
settings["last_run_at"] = backup.get("created_at") or now.isoformat(timespec="seconds")
save_auto_backup_settings(settings, user_id)
prune_old_backups(user_id, settings["retention_days"])
save_auto_backup_settings(settings, user_id, backup_type, profile_id)
prune_old_backups(user_id, settings["retention_days"], backup_type, profile_id)
return backup
def _profile_schedule_keys() -> list[tuple[int, int]]:
prefix = f"{AUTO_BACKUP_SETTINGS_KEY}:profile:"
keys: list[tuple[int, int]] = []
with connect() as conn:
rows = conn.execute("SELECT key FROM app_settings WHERE key LIKE ?", (prefix + "%",)).fetchall()
for row in rows:
parts = str(row.get("key") or "").split(":")
try:
keys.append((int(parts[-2]), int(parts[-1])))
except Exception:
continue
return keys
def start_scheduler() -> None:
global _scheduler_started
with _scheduler_lock:
@@ -397,7 +458,9 @@ def start_scheduler() -> None:
rows = conn.execute("SELECT id FROM users WHERE is_active=1 AND role='admin'").fetchall()
user_ids = [int(row["id"]) for row in rows] or [default_user_id()]
for uid in user_ids:
maybe_create_automatic_backup(uid)
maybe_create_automatic_backup(uid, "app")
for uid, pid in _profile_schedule_keys():
maybe_create_automatic_backup(uid, "profile", pid)
except Exception:
pass
time.sleep(300)