big changes in profiles and users
This commit is contained in:
@@ -5,15 +5,39 @@ import threading
|
||||
import time
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from ..db import connect, utcnow, default_user_id
|
||||
from . import auth
|
||||
|
||||
# Note: Settings backups include persistent configuration tables only; volatile queues, caches, histories and tokens are intentionally skipped.
|
||||
BACKUP_TABLES = [
|
||||
"users", "user_profile_permissions", "user_preferences", "rtorrent_profiles",
|
||||
# Note: Application backups are admin-only because they include users, permissions and all profiles.
|
||||
APP_BACKUP_TABLES = [
|
||||
"users", "user_profile_permissions", "user_preferences", "profile_preferences", "rtorrent_profiles",
|
||||
"disk_monitor_preferences", "labels", "ratio_groups", "rss_feeds", "rss_rules",
|
||||
"smart_queue_settings", "smart_queue_exclusions", "automation_rules",
|
||||
"rtorrent_config_overrides", "app_settings", "download_plan_settings",
|
||||
"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.
|
||||
PROFILE_BACKUP_TABLES = [
|
||||
"rtorrent_profiles", "profile_preferences", "disk_monitor_preferences", "labels", "ratio_groups",
|
||||
"rss_feeds", "rss_rules", "smart_queue_settings", "smart_queue_exclusions",
|
||||
"automation_rules", "rtorrent_config_overrides", "poller_settings", "download_plan_settings",
|
||||
]
|
||||
|
||||
PROFILE_TABLE_FILTERS = {
|
||||
"rtorrent_profiles": "id=?",
|
||||
"profile_preferences": "user_id=? AND profile_id=?",
|
||||
"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=?",
|
||||
"automation_rules": "user_id=? AND profile_id=?",
|
||||
"rtorrent_config_overrides": "user_id=? AND profile_id=?",
|
||||
"poller_settings": "profile_id=?",
|
||||
"download_plan_settings": "user_id=? AND profile_id=?",
|
||||
}
|
||||
|
||||
DEFAULT_AUTO_BACKUP_SETTINGS = {
|
||||
"enabled": False,
|
||||
"interval_hours": 24,
|
||||
@@ -22,44 +46,107 @@ DEFAULT_AUTO_BACKUP_SETTINGS = {
|
||||
}
|
||||
BACKUP_PREVIEW_VALUE_LIMIT = 80
|
||||
BACKUP_PREVIEW_ROW_LIMIT = 3
|
||||
BACKUP_PREVIEW_SENSITIVE_KEYS = {
|
||||
"password",
|
||||
"password_hash",
|
||||
"token",
|
||||
"token_hash",
|
||||
"api_key",
|
||||
"secret",
|
||||
}
|
||||
BACKUP_PREVIEW_SENSITIVE_KEYS = {"password", "password_hash", "token", "token_hash", "api_key", "secret"}
|
||||
AUTO_BACKUP_SETTINGS_KEY = "backup:auto"
|
||||
_scheduler_started = False
|
||||
_scheduler_lock = threading.Lock()
|
||||
|
||||
|
||||
def create_backup(name: str, user_id: int | None = None, automatic: bool = False) -> dict:
|
||||
"""Create a settings backup and return a table-count summary.
|
||||
|
||||
Note: The automatic flag is metadata only; restore/download behavior remains unchanged.
|
||||
"""
|
||||
user_id = user_id or default_user_id()
|
||||
payload = {"version": 1, "created_at": utcnow(), "automatic": bool(automatic), "tables": {}}
|
||||
def _is_admin_user(user_id: int | None = None) -> bool:
|
||||
if not auth.enabled():
|
||||
return True
|
||||
uid = user_id or auth.current_user_id()
|
||||
if not uid:
|
||||
return False
|
||||
with connect() as conn:
|
||||
row = conn.execute("SELECT role,is_active FROM users WHERE id=?", (uid,)).fetchone()
|
||||
return bool(row and row.get("role") == "admin" and int(row.get("is_active") or 0))
|
||||
|
||||
|
||||
def _require_admin(user_id: int | None = None) -> None:
|
||||
if not _is_admin_user(user_id):
|
||||
raise PermissionError("Application backups are available only to admins")
|
||||
|
||||
|
||||
def _loads(value: str) -> dict:
|
||||
try:
|
||||
data = json.loads(value or "{}")
|
||||
return data if isinstance(data, dict) else {}
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
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 "")
|
||||
return [dict(row) for row in conn.execute(sql, params).fetchall()]
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
def _store_backup(user_id: int, name: str, backup_type: str, profile_id: int | None, payload: dict) -> dict:
|
||||
with connect() as conn:
|
||||
for table in BACKUP_TABLES:
|
||||
try:
|
||||
payload["tables"][table] = conn.execute(f"SELECT * FROM {table}").fetchall()
|
||||
except Exception:
|
||||
payload["tables"][table] = []
|
||||
cur = conn.execute(
|
||||
"INSERT INTO app_backups(user_id,name,payload_json,created_at) VALUES(?,?,?,?)",
|
||||
(user_id, name or f"Backup {payload['created_at']}", json.dumps(payload), payload["created_at"]),
|
||||
"INSERT INTO app_backups(user_id,name,backup_type,profile_id,payload_json,created_at) VALUES(?,?,?,?,?,?)",
|
||||
(user_id, name or f"Backup {payload['created_at']}", backup_type, profile_id, json.dumps(payload), payload["created_at"]),
|
||||
)
|
||||
backup_id = cur.lastrowid
|
||||
return {"id": backup_id, "name": name, "created_at": payload["created_at"], "automatic": bool(automatic), "tables": {k: len(v) for k, v in payload["tables"].items()}}
|
||||
return {
|
||||
"id": backup_id,
|
||||
"name": name,
|
||||
"backup_type": backup_type,
|
||||
"profile_id": profile_id,
|
||||
"created_at": payload["created_at"],
|
||||
"automatic": bool(payload.get("automatic")),
|
||||
"tables": {k: len(v) for k, v in (payload.get("tables") or {}).items()},
|
||||
}
|
||||
|
||||
|
||||
def list_backups(user_id: int | None = None) -> list[dict]:
|
||||
user_id = user_id or default_user_id()
|
||||
def create_app_backup(name: str, user_id: int | None = None, automatic: bool = False) -> dict:
|
||||
user_id = user_id or auth.current_user_id() or default_user_id()
|
||||
_require_admin(user_id)
|
||||
payload = {"version": 2, "backup_type": "app", "created_at": utcnow(), "automatic": bool(automatic), "tables": {}}
|
||||
with connect() as conn:
|
||||
rows = conn.execute("SELECT id,name,created_at,payload_json FROM app_backups WHERE user_id=? ORDER BY id DESC", (user_id,)).fetchall()
|
||||
for table in APP_BACKUP_TABLES:
|
||||
payload["tables"][table] = _table_rows(conn, table)
|
||||
return _store_backup(user_id, name, "app", None, payload)
|
||||
|
||||
|
||||
def create_profile_backup(name: str, profile_id: int, user_id: int | None = None, automatic: bool = False) -> dict:
|
||||
user_id = user_id or auth.current_user_id() or default_user_id()
|
||||
if not auth.can_access_profile(profile_id, user_id):
|
||||
raise PermissionError("No access to profile")
|
||||
payload = {"version": 2, "backup_type": "profile", "source_profile_id": int(profile_id), "created_at": utcnow(), "automatic": bool(automatic), "tables": {}}
|
||||
with connect() as conn:
|
||||
for table in PROFILE_BACKUP_TABLES:
|
||||
where = PROFILE_TABLE_FILTERS.get(table)
|
||||
if where == "id=?" or where == "profile_id=?":
|
||||
params = (int(profile_id),)
|
||||
else:
|
||||
params = (user_id, int(profile_id))
|
||||
payload["tables"][table] = _table_rows(conn, table, where, params)
|
||||
return _store_backup(user_id, name, "profile", int(profile_id), payload)
|
||||
|
||||
|
||||
def create_backup(name: str, user_id: int | None = None, automatic: bool = False) -> dict:
|
||||
return create_app_backup(name, user_id, automatic)
|
||||
|
||||
|
||||
def list_backups(user_id: int | None = None, backup_type: str | None = None, profile_id: int | None = None) -> list[dict]:
|
||||
user_id = user_id or auth.current_user_id() or default_user_id()
|
||||
clauses = ["user_id=?"]
|
||||
params: list[object] = [user_id]
|
||||
if backup_type:
|
||||
clauses.append("COALESCE(backup_type,'app')=?")
|
||||
params.append(backup_type)
|
||||
if profile_id is not None:
|
||||
clauses.append("profile_id=?")
|
||||
params.append(int(profile_id))
|
||||
with connect() as conn:
|
||||
rows = conn.execute(
|
||||
f"SELECT id,name,created_at,payload_json,COALESCE(backup_type,'app') AS backup_type,profile_id FROM app_backups WHERE {' AND '.join(clauses)} ORDER BY id DESC",
|
||||
tuple(params),
|
||||
).fetchall()
|
||||
result = []
|
||||
for row in rows:
|
||||
payload = _loads(row.get("payload_json") or "{}")
|
||||
@@ -68,6 +155,8 @@ def list_backups(user_id: int | None = None) -> list[dict]:
|
||||
"id": row.get("id"),
|
||||
"name": row.get("name"),
|
||||
"created_at": row.get("created_at"),
|
||||
"backup_type": row.get("backup_type") or payload.get("backup_type") or "app",
|
||||
"profile_id": row.get("profile_id") or payload.get("source_profile_id"),
|
||||
"automatic": bool(payload.get("automatic")),
|
||||
"tables": {key: len(value or []) for key, value in tables.items()},
|
||||
})
|
||||
@@ -75,7 +164,7 @@ def list_backups(user_id: int | None = None) -> list[dict]:
|
||||
|
||||
|
||||
def payload_for_backup(backup_id: int, user_id: int | None = None) -> dict:
|
||||
user_id = user_id or default_user_id()
|
||||
user_id = user_id or auth.current_user_id() or default_user_id()
|
||||
with connect() as conn:
|
||||
row = conn.execute("SELECT payload_json FROM app_backups WHERE id=? AND user_id=?", (backup_id, user_id)).fetchone()
|
||||
if not row:
|
||||
@@ -83,15 +172,22 @@ def payload_for_backup(backup_id: int, user_id: int | None = None) -> dict:
|
||||
return json.loads(row["payload_json"] or "{}")
|
||||
|
||||
|
||||
def restore_backup(backup_id: int, user_id: int | None = None) -> dict:
|
||||
user_id = user_id or default_user_id()
|
||||
def _backup_type(payload: dict) -> str:
|
||||
return str(payload.get("backup_type") or ("profile" if payload.get("source_profile_id") else "app"))
|
||||
|
||||
|
||||
def restore_app_backup(backup_id: int, user_id: int | None = None) -> dict:
|
||||
user_id = user_id or auth.current_user_id() or default_user_id()
|
||||
_require_admin(user_id)
|
||||
payload = payload_for_backup(backup_id, user_id)
|
||||
if _backup_type(payload) != "app":
|
||||
raise ValueError("This is not an application backup")
|
||||
tables = payload.get("tables") or {}
|
||||
restored = {}
|
||||
with connect() as conn:
|
||||
conn.execute("PRAGMA foreign_keys = OFF")
|
||||
try:
|
||||
for table in BACKUP_TABLES:
|
||||
for table in APP_BACKUP_TABLES:
|
||||
rows = tables.get(table) or []
|
||||
if not rows:
|
||||
continue
|
||||
@@ -103,50 +199,95 @@ def restore_backup(backup_id: int, user_id: int | None = None) -> dict:
|
||||
restored[table] = len(rows)
|
||||
finally:
|
||||
conn.execute("PRAGMA foreign_keys = ON")
|
||||
return {"restored": restored}
|
||||
return {"restored": restored, "backup_type": "app"}
|
||||
|
||||
|
||||
def _rewrite_profile_row(table: str, row: dict, user_id: int, target_profile_id: int) -> dict:
|
||||
clean = dict(row)
|
||||
if table == "rtorrent_profiles":
|
||||
clean["id"] = target_profile_id
|
||||
clean["user_id"] = user_id
|
||||
clean["is_default"] = int(clean.get("is_default") or 0)
|
||||
return clean
|
||||
if "profile_id" in clean:
|
||||
clean["profile_id"] = target_profile_id
|
||||
if "user_id" in clean:
|
||||
clean["user_id"] = user_id
|
||||
if table == "poller_settings":
|
||||
clean["profile_id"] = target_profile_id
|
||||
if "id" in clean and table != "rtorrent_profiles":
|
||||
clean.pop("id", None)
|
||||
return clean
|
||||
|
||||
|
||||
def restore_profile_backup(backup_id: int, target_profile_id: int, user_id: int | None = None) -> dict:
|
||||
user_id = user_id or auth.current_user_id() or default_user_id()
|
||||
if not auth.can_write_profile(target_profile_id, user_id):
|
||||
raise PermissionError("No write access to profile")
|
||||
payload = payload_for_backup(backup_id, user_id)
|
||||
if _backup_type(payload) != "profile":
|
||||
raise ValueError("This is not a profile backup")
|
||||
tables = payload.get("tables") or {}
|
||||
restored = {}
|
||||
with connect() as conn:
|
||||
conn.execute("PRAGMA foreign_keys = OFF")
|
||||
try:
|
||||
for table in PROFILE_BACKUP_TABLES:
|
||||
rows = tables.get(table) or []
|
||||
where = PROFILE_TABLE_FILTERS.get(table)
|
||||
if where == "id=?" or where == "profile_id=?":
|
||||
params = (int(target_profile_id),)
|
||||
else:
|
||||
params = (user_id, int(target_profile_id))
|
||||
conn.execute(f"DELETE FROM {table} WHERE {where}", params)
|
||||
if not rows:
|
||||
continue
|
||||
count = 0
|
||||
for row in rows:
|
||||
clean = _rewrite_profile_row(table, dict(row), user_id, int(target_profile_id))
|
||||
columns = list(clean.keys())
|
||||
placeholders = ",".join("?" for _ in columns)
|
||||
conn.execute(f"INSERT INTO {table}({','.join(columns)}) VALUES({placeholders})", [clean.get(col) for col in columns])
|
||||
count += 1
|
||||
restored[table] = count
|
||||
finally:
|
||||
conn.execute("PRAGMA foreign_keys = ON")
|
||||
return {"restored": restored, "backup_type": "profile", "profile_id": int(target_profile_id)}
|
||||
|
||||
|
||||
def restore_backup(backup_id: int, user_id: int | None = None, profile_id: int | None = None) -> dict:
|
||||
payload = payload_for_backup(backup_id, user_id)
|
||||
if _backup_type(payload) == "profile":
|
||||
target = profile_id or payload.get("source_profile_id")
|
||||
if not target:
|
||||
raise ValueError("Missing target profile")
|
||||
return restore_profile_backup(backup_id, int(target), user_id)
|
||||
return restore_app_backup(backup_id, user_id)
|
||||
|
||||
|
||||
def delete_backup(backup_id: int, user_id: int | None = None) -> dict:
|
||||
user_id = user_id or default_user_id()
|
||||
user_id = user_id or auth.current_user_id() or default_user_id()
|
||||
with connect() as conn:
|
||||
cur = conn.execute(
|
||||
"DELETE FROM app_backups WHERE id=? AND user_id=?",
|
||||
(backup_id, user_id),
|
||||
)
|
||||
cur = conn.execute("DELETE FROM app_backups WHERE id=? AND user_id=?", (backup_id, user_id))
|
||||
if not cur.rowcount:
|
||||
raise ValueError("Backup not found")
|
||||
return {"deleted": backup_id}
|
||||
|
||||
|
||||
|
||||
|
||||
def _loads(value: str) -> dict:
|
||||
try:
|
||||
data = json.loads(value or "{}")
|
||||
return data if isinstance(data, dict) else {}
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def _settings_row_key(user_id: int | None = None) -> str:
|
||||
return f"{AUTO_BACKUP_SETTINGS_KEY}:{user_id or default_user_id()}"
|
||||
return f"{AUTO_BACKUP_SETTINGS_KEY}:{user_id or auth.current_user_id() or default_user_id()}"
|
||||
|
||||
|
||||
def _latest_backup_created_at(user_id: int) -> str | None:
|
||||
"""Return the newest persisted backup timestamp for scheduler recovery after restarts.
|
||||
|
||||
Note: Automatic scheduling is based on the latest database backup record, so process
|
||||
restarts cannot create repeated backups before the configured interval elapses.
|
||||
"""
|
||||
with connect() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT created_at FROM app_backups WHERE user_id=? ORDER BY created_at DESC, id DESC LIMIT 1",
|
||||
"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,),
|
||||
).fetchone()
|
||||
return str(row["created_at"] or "") if row and row.get("created_at") else None
|
||||
|
||||
|
||||
def _preview_value(value: object) -> object:
|
||||
"""Return a safe, compact value for backup previews without exposing secrets."""
|
||||
if value is None or isinstance(value, (int, float, bool)):
|
||||
return value
|
||||
text = str(value)
|
||||
@@ -157,18 +298,11 @@ def _preview_row(row: dict) -> dict:
|
||||
output = {}
|
||||
for key, value in row.items():
|
||||
lowered = str(key).lower()
|
||||
if any(secret in lowered for secret in BACKUP_PREVIEW_SENSITIVE_KEYS):
|
||||
output[key] = "[hidden]"
|
||||
else:
|
||||
output[key] = _preview_value(value)
|
||||
output[key] = "[hidden]" if any(secret in lowered for secret in BACKUP_PREVIEW_SENSITIVE_KEYS) else _preview_value(value)
|
||||
return output
|
||||
|
||||
|
||||
def get_auto_backup_settings(user_id: int | None = None) -> dict:
|
||||
"""Return automatic backup schedule settings for the current user.
|
||||
|
||||
Note: The UI uses this as the single source for interval and retention controls.
|
||||
"""
|
||||
key = _settings_row_key(user_id)
|
||||
with connect() as conn:
|
||||
row = conn.execute("SELECT value FROM app_settings WHERE key=?", (key,)).fetchone()
|
||||
@@ -180,10 +314,7 @@ def get_auto_backup_settings(user_id: int | None = None) -> dict:
|
||||
|
||||
|
||||
def save_auto_backup_settings(data: dict, user_id: int | None = None) -> dict:
|
||||
"""Persist automatic backup schedule settings after validating UI input.
|
||||
|
||||
Note: Minimum interval is one hour to avoid creating excessive database rows.
|
||||
"""
|
||||
_require_admin(user_id)
|
||||
current = get_auto_backup_settings(user_id)
|
||||
settings = {
|
||||
**current,
|
||||
@@ -199,15 +330,13 @@ def save_auto_backup_settings(data: dict, user_id: int | None = None) -> dict:
|
||||
|
||||
|
||||
def preview_backup(backup_id: int, user_id: int | None = None) -> dict:
|
||||
"""Return a compact backup preview without exposing the full JSON payload in the list view.
|
||||
|
||||
Note: The preview shows included tables and example keys so users can verify settings coverage.
|
||||
"""
|
||||
payload = payload_for_backup(backup_id, user_id)
|
||||
tables = payload.get("tables") or {}
|
||||
return {
|
||||
"version": payload.get("version"),
|
||||
"created_at": payload.get("created_at"),
|
||||
"backup_type": _backup_type(payload),
|
||||
"source_profile_id": payload.get("source_profile_id"),
|
||||
"automatic": bool(payload.get("automatic")),
|
||||
"tables": [
|
||||
{
|
||||
@@ -222,23 +351,17 @@ 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:
|
||||
"""Delete backups older than the configured retention window for the selected user.
|
||||
|
||||
Note: Retention is applied only to backup records, not to restored application settings.
|
||||
"""
|
||||
user_id = user_id or default_user_id()
|
||||
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")
|
||||
with connect() as conn:
|
||||
cur = conn.execute("DELETE FROM app_backups WHERE user_id=? AND created_at<?", (user_id, cutoff))
|
||||
cur = conn.execute("DELETE FROM app_backups WHERE user_id=? AND COALESCE(backup_type,'app')='app' AND created_at<?", (user_id, cutoff))
|
||||
return int(cur.rowcount or 0)
|
||||
|
||||
|
||||
def maybe_create_automatic_backup(user_id: int | None = None) -> dict | None:
|
||||
"""Create an automatic backup when the saved interval has elapsed.
|
||||
|
||||
Note: The scheduler calls this periodically, while the UI controls the interval and retention values.
|
||||
"""
|
||||
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
|
||||
@@ -253,7 +376,7 @@ def maybe_create_automatic_backup(user_id: int | None = None) -> dict | None:
|
||||
settings["last_run_at"] = last_value
|
||||
save_auto_backup_settings(settings, user_id)
|
||||
return None
|
||||
backup = create_backup(f"Automatic backup {now.isoformat(timespec='seconds')}", user_id, automatic=True)
|
||||
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"])
|
||||
@@ -261,10 +384,6 @@ def maybe_create_automatic_backup(user_id: int | None = None) -> dict | None:
|
||||
|
||||
|
||||
def start_scheduler() -> None:
|
||||
"""Start a lightweight automatic-backup scheduler.
|
||||
|
||||
Note: It scans configured users and never blocks normal request handling.
|
||||
"""
|
||||
global _scheduler_started
|
||||
with _scheduler_lock:
|
||||
if _scheduler_started:
|
||||
@@ -275,7 +394,7 @@ def start_scheduler() -> None:
|
||||
while True:
|
||||
try:
|
||||
with connect() as conn:
|
||||
rows = conn.execute("SELECT id FROM users WHERE is_active=1").fetchall()
|
||||
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)
|
||||
|
||||
@@ -73,9 +73,19 @@ def normalize_settings(data: dict | None) -> dict:
|
||||
|
||||
def get_settings(profile_id: int) -> dict:
|
||||
with connect() as conn:
|
||||
row = conn.execute("SELECT value FROM app_settings WHERE key=?", (_key(profile_id),)).fetchone()
|
||||
row = conn.execute("SELECT settings_json FROM poller_settings WHERE profile_id=?", (int(profile_id),)).fetchone()
|
||||
if not row:
|
||||
# Note: Existing installs stored profile poller settings in app_settings; migrate lazily on first read.
|
||||
legacy = conn.execute("SELECT value FROM app_settings WHERE key=?", (_key(profile_id),)).fetchone()
|
||||
if legacy:
|
||||
try:
|
||||
settings = normalize_settings(json.loads(legacy.get("value") or "{}"))
|
||||
except Exception:
|
||||
settings = normalize_settings({})
|
||||
conn.execute("INSERT OR REPLACE INTO poller_settings(profile_id,settings_json,updated_at) VALUES(?,?,?)", (int(profile_id), json.dumps(settings), utcnow()))
|
||||
return settings
|
||||
try:
|
||||
data = json.loads(row.get("value") or "{}") if row else {}
|
||||
data = json.loads(row.get("settings_json") or "{}") if row else {}
|
||||
except Exception:
|
||||
data = {}
|
||||
return normalize_settings(data)
|
||||
@@ -84,7 +94,7 @@ def get_settings(profile_id: int) -> dict:
|
||||
def save_settings(profile_id: int, data: dict) -> dict:
|
||||
settings = normalize_settings(data)
|
||||
with connect() as conn:
|
||||
conn.execute("INSERT OR REPLACE INTO app_settings(key,value) VALUES(?,?)", (_key(profile_id), json.dumps(settings)))
|
||||
conn.execute("INSERT OR REPLACE INTO poller_settings(profile_id,settings_json,updated_at) VALUES(?,?,?)", (int(profile_id), json.dumps(settings), utcnow()))
|
||||
return settings
|
||||
|
||||
|
||||
|
||||
@@ -58,17 +58,21 @@ def recommended_table_columns_json() -> str:
|
||||
return json.dumps(RECOMMENDED_TABLE_COLUMNS, separators=(",", ":"))
|
||||
|
||||
|
||||
def apply_recommended_table_columns(user_id: int | None = None):
|
||||
def apply_recommended_table_columns(user_id: int | None = None, profile_id: int | None = None):
|
||||
user_id = user_id or auth.current_user_id() or default_user_id()
|
||||
get_preferences(user_id)
|
||||
profile_id = profile_id or _active_profile_id_for_user(user_id)
|
||||
if not profile_id:
|
||||
return get_preferences(user_id)
|
||||
get_preferences(user_id, profile_id)
|
||||
now = utcnow()
|
||||
value = recommended_table_columns_json()
|
||||
with connect() as conn:
|
||||
conn.execute(
|
||||
"UPDATE user_preferences SET table_columns_json=?, updated_at=? WHERE user_id=?",
|
||||
(value, now, user_id),
|
||||
"INSERT INTO profile_preferences(user_id,profile_id,table_columns_json,created_at,updated_at) VALUES(?,?,?,?,?) "
|
||||
"ON CONFLICT(user_id,profile_id) DO UPDATE SET table_columns_json=excluded.table_columns_json, updated_at=excluded.updated_at",
|
||||
(user_id, profile_id, value, now, now),
|
||||
)
|
||||
return get_preferences(user_id)
|
||||
return get_preferences(user_id, profile_id)
|
||||
|
||||
def bootstrap_css_url(theme: str | None) -> str:
|
||||
from .frontend_assets import bootstrap_css_path
|
||||
@@ -317,31 +321,137 @@ def save_disk_monitor_preferences(profile_id: int | None, data: dict, user_id: i
|
||||
return clean
|
||||
|
||||
|
||||
PROFILE_PREFERENCE_COLUMNS = {
|
||||
"table_columns_json",
|
||||
"torrent_sort_json",
|
||||
"active_filter",
|
||||
"peers_refresh_seconds",
|
||||
"port_check_enabled",
|
||||
"tracker_favicons_enabled",
|
||||
"reverse_dns_enabled",
|
||||
}
|
||||
|
||||
|
||||
def _seed_profile_preferences(conn, user_id: int, profile_id: int) -> dict:
|
||||
now = utcnow()
|
||||
legacy = conn.execute("SELECT * FROM user_preferences WHERE user_id=?", (user_id,)).fetchone() or {}
|
||||
row = conn.execute("SELECT * FROM profile_preferences WHERE user_id=? AND profile_id=?", (user_id, profile_id)).fetchone()
|
||||
if row:
|
||||
return dict(row)
|
||||
# Note: First profile preference row is seeded from legacy user-level values so upgrades keep the current layout/filter behavior.
|
||||
conn.execute(
|
||||
"INSERT INTO profile_preferences(user_id,profile_id,table_columns_json,torrent_sort_json,active_filter,peers_refresh_seconds,port_check_enabled,tracker_favicons_enabled,reverse_dns_enabled,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?)",
|
||||
(
|
||||
user_id,
|
||||
profile_id,
|
||||
legacy.get("table_columns_json"),
|
||||
legacy.get("torrent_sort_json"),
|
||||
legacy.get("active_filter") or "all",
|
||||
int(legacy.get("peers_refresh_seconds") or 0),
|
||||
int(legacy.get("port_check_enabled") or 0),
|
||||
int(legacy.get("tracker_favicons_enabled") or 0),
|
||||
int(legacy.get("reverse_dns_enabled") or 0),
|
||||
now,
|
||||
now,
|
||||
),
|
||||
)
|
||||
return dict(conn.execute("SELECT * FROM profile_preferences WHERE user_id=? AND profile_id=?", (user_id, profile_id)).fetchone() or {})
|
||||
|
||||
|
||||
def get_profile_preferences(user_id: int, profile_id: int | None) -> dict:
|
||||
if not profile_id:
|
||||
return {}
|
||||
with connect() as conn:
|
||||
return _seed_profile_preferences(conn, user_id, int(profile_id))
|
||||
|
||||
|
||||
def save_profile_preferences(user_id: int, profile_id: int | None, data: dict) -> None:
|
||||
if not profile_id:
|
||||
return
|
||||
profile_id = int(profile_id)
|
||||
now = utcnow()
|
||||
with connect() as conn:
|
||||
current = _seed_profile_preferences(conn, user_id, profile_id)
|
||||
updates: dict[str, object] = {}
|
||||
if data.get("table_columns_json") is not None:
|
||||
updates["table_columns_json"] = str(data.get("table_columns_json"))
|
||||
if data.get("peers_refresh_seconds") is not None:
|
||||
sec = int(data.get("peers_refresh_seconds") or 0)
|
||||
updates["peers_refresh_seconds"] = sec if sec in {0, 10, 15, 30, 60} else 0
|
||||
if data.get("port_check_enabled") is not None:
|
||||
updates["port_check_enabled"] = 1 if data.get("port_check_enabled") else 0
|
||||
if data.get("tracker_favicons_enabled") is not None:
|
||||
updates["tracker_favicons_enabled"] = 1 if data.get("tracker_favicons_enabled") else 0
|
||||
if data.get("reverse_dns_enabled") is not None:
|
||||
# Note: Reverse DNS is stored per profile because PTR lookups depend on swarm size and profile network latency.
|
||||
updates["reverse_dns_enabled"] = 1 if data.get("reverse_dns_enabled") else 0
|
||||
if data.get("torrent_sort_json") is not None:
|
||||
value = data.get("torrent_sort_json") if isinstance(data.get("torrent_sort_json"), str) else json.dumps(data.get("torrent_sort_json"))
|
||||
parsed = json.loads(value or "{}")
|
||||
if not isinstance(parsed, dict):
|
||||
parsed = {}
|
||||
try:
|
||||
direction = int(parsed.get("dir") or 1)
|
||||
except (TypeError, ValueError):
|
||||
direction = 1
|
||||
allowed_sort_keys = {"name", "status", "size", "progress", "down_rate", "up_rate", "eta", "seeds", "peers", "ratio", "path", "label", "ratio_group", "down_total", "to_download", "up_total", "created", "priority", "state", "active", "complete", "hashing", "message", "hash"}
|
||||
sort_key = str(parsed.get("key") or "name")
|
||||
if sort_key not in allowed_sort_keys:
|
||||
sort_key = "name"
|
||||
updates["torrent_sort_json"] = json.dumps({"key": sort_key, "dir": 1 if direction >= 0 else -1})
|
||||
if data.get("active_filter") is not None:
|
||||
value = str(data.get("active_filter") or "all").strip()
|
||||
if not value or len(value) > 180:
|
||||
value = "all"
|
||||
allowed_static_filters = {"all", "downloading", "seeding", "paused", "checking", "error", "stopped", "moving"}
|
||||
if value not in allowed_static_filters and not value.startswith("label:") and not value.startswith("tracker:"):
|
||||
value = "all"
|
||||
updates["active_filter"] = value
|
||||
if not updates:
|
||||
return
|
||||
merged = {**current, **updates}
|
||||
conn.execute(
|
||||
"INSERT INTO profile_preferences(user_id,profile_id,table_columns_json,torrent_sort_json,active_filter,peers_refresh_seconds,port_check_enabled,tracker_favicons_enabled,reverse_dns_enabled,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?) "
|
||||
"ON CONFLICT(user_id,profile_id) DO UPDATE SET table_columns_json=excluded.table_columns_json, torrent_sort_json=excluded.torrent_sort_json, active_filter=excluded.active_filter, peers_refresh_seconds=excluded.peers_refresh_seconds, port_check_enabled=excluded.port_check_enabled, tracker_favicons_enabled=excluded.tracker_favicons_enabled, reverse_dns_enabled=excluded.reverse_dns_enabled, updated_at=excluded.updated_at",
|
||||
(
|
||||
user_id,
|
||||
profile_id,
|
||||
merged.get("table_columns_json"),
|
||||
merged.get("torrent_sort_json"),
|
||||
merged.get("active_filter") or "all",
|
||||
int(merged.get("peers_refresh_seconds") or 0),
|
||||
int(merged.get("port_check_enabled") or 0),
|
||||
int(merged.get("tracker_favicons_enabled") or 0),
|
||||
int(merged.get("reverse_dns_enabled") or 0),
|
||||
merged.get("created_at") or now,
|
||||
now,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def get_preferences(user_id: int | None = None, profile_id: int | None = None):
|
||||
user_id = user_id or auth.current_user_id() or default_user_id()
|
||||
profile_id = profile_id or _active_profile_id_for_user(user_id)
|
||||
with connect() as conn:
|
||||
pref = conn.execute("SELECT * FROM user_preferences WHERE user_id=?", (user_id,)).fetchone()
|
||||
if not pref:
|
||||
now = utcnow()
|
||||
conn.execute("INSERT INTO user_preferences(user_id, theme, created_at, updated_at) VALUES(?, 'dark', ?, ?)", (user_id, now, now))
|
||||
pref = conn.execute("SELECT * FROM user_preferences WHERE user_id=?", (user_id,)).fetchone()
|
||||
merged = dict(pref or {})
|
||||
merged = dict(pref or {})
|
||||
if profile_id:
|
||||
merged.update(_seed_profile_preferences(conn, user_id, int(profile_id)))
|
||||
merged.update(get_disk_monitor_preferences(profile_id, user_id))
|
||||
return merged
|
||||
|
||||
|
||||
def save_preferences(data: dict, user_id: int | None = None):
|
||||
user_id = user_id or auth.current_user_id() or default_user_id()
|
||||
profile_id = _active_profile_id_for_user(user_id)
|
||||
allowed_theme = data.get("theme") if data.get("theme") in {"light", "dark"} else None
|
||||
bootstrap_theme = data.get("bootstrap_theme") if data.get("bootstrap_theme") in BOOTSTRAP_THEMES else None
|
||||
font_family = data.get("font_family") if data.get("font_family") in FONT_FAMILIES else None
|
||||
table_columns_json = data.get("table_columns_json")
|
||||
peers_refresh_seconds = data.get("peers_refresh_seconds")
|
||||
port_check_enabled = data.get("port_check_enabled")
|
||||
footer_items_json = data.get("footer_items_json")
|
||||
title_speed_enabled = data.get("title_speed_enabled")
|
||||
tracker_favicons_enabled = data.get("tracker_favicons_enabled")
|
||||
reverse_dns_enabled = data.get("reverse_dns_enabled")
|
||||
automation_toasts_enabled = data.get("automation_toasts_enabled")
|
||||
smart_queue_toasts_enabled = data.get("smart_queue_toasts_enabled")
|
||||
disk_monitor_paths_json = data.get("disk_monitor_paths_json")
|
||||
@@ -352,8 +462,6 @@ def save_preferences(data: dict, user_id: int | None = None):
|
||||
interface_scale = data.get("interface_scale")
|
||||
compact_torrent_list_enabled = data.get("compact_torrent_list_enabled")
|
||||
detail_panel_height = data.get("detail_panel_height")
|
||||
torrent_sort_json = data.get("torrent_sort_json")
|
||||
active_filter = data.get("active_filter")
|
||||
disk_payload = None
|
||||
if any(value is not None for value in (disk_monitor_paths_json, disk_monitor_mode, disk_monitor_selected_path, disk_monitor_stop_enabled, disk_monitor_stop_threshold)):
|
||||
disk_payload = {
|
||||
@@ -371,21 +479,8 @@ def save_preferences(data: dict, user_id: int | None = None):
|
||||
conn.execute("UPDATE user_preferences SET bootstrap_theme=?, updated_at=? WHERE user_id=?", (bootstrap_theme, now, user_id))
|
||||
if font_family:
|
||||
conn.execute("UPDATE user_preferences SET font_family=?, updated_at=? WHERE user_id=?", (font_family, now, user_id))
|
||||
if table_columns_json is not None:
|
||||
conn.execute("UPDATE user_preferences SET table_columns_json=?, updated_at=? WHERE user_id=?", (str(table_columns_json), now, user_id))
|
||||
if peers_refresh_seconds is not None:
|
||||
sec = int(peers_refresh_seconds or 0)
|
||||
if sec not in {0, 10, 15, 30, 60}: sec = 0
|
||||
conn.execute("UPDATE user_preferences SET peers_refresh_seconds=?, updated_at=? WHERE user_id=?", (sec, now, user_id))
|
||||
if port_check_enabled is not None:
|
||||
conn.execute("UPDATE user_preferences SET port_check_enabled=?, updated_at=? WHERE user_id=?", (1 if port_check_enabled else 0, now, user_id))
|
||||
if title_speed_enabled is not None:
|
||||
conn.execute("UPDATE user_preferences SET title_speed_enabled=?, updated_at=? WHERE user_id=?", (1 if title_speed_enabled else 0, now, user_id))
|
||||
if tracker_favicons_enabled is not None:
|
||||
conn.execute("UPDATE user_preferences SET tracker_favicons_enabled=?, updated_at=? WHERE user_id=?", (1 if tracker_favicons_enabled else 0, now, user_id))
|
||||
if reverse_dns_enabled is not None:
|
||||
# Note: Reverse DNS is optional because peer PTR lookups can add latency on busy swarms.
|
||||
conn.execute("UPDATE user_preferences SET reverse_dns_enabled=?, updated_at=? WHERE user_id=?", (1 if reverse_dns_enabled else 0, now, user_id))
|
||||
if automation_toasts_enabled is not None:
|
||||
# Note: Lets users silence automation-created toast noise without hiding job/history data.
|
||||
conn.execute("UPDATE user_preferences SET automation_toasts_enabled=?, updated_at=? WHERE user_id=?", (1 if automation_toasts_enabled else 0, now, user_id))
|
||||
@@ -415,30 +510,7 @@ def save_preferences(data: dict, user_id: int | None = None):
|
||||
if height < 160: height = 160
|
||||
if height > 720: height = 720
|
||||
conn.execute("UPDATE user_preferences SET detail_panel_height=?, updated_at=? WHERE user_id=?", (height, now, user_id))
|
||||
if torrent_sort_json is not None:
|
||||
# Note: Persist only a compact sort object; unknown keys are ignored on the client.
|
||||
value = torrent_sort_json if isinstance(torrent_sort_json, str) else json.dumps(torrent_sort_json)
|
||||
parsed = json.loads(value or "{}")
|
||||
if not isinstance(parsed, dict):
|
||||
parsed = {}
|
||||
try:
|
||||
direction = int(parsed.get("dir") or 1)
|
||||
except (TypeError, ValueError):
|
||||
direction = 1
|
||||
allowed_sort_keys = {"name", "status", "size", "progress", "down_rate", "up_rate", "eta", "seeds", "peers", "ratio", "path", "label", "ratio_group", "down_total", "to_download", "up_total", "created", "priority", "state", "active", "complete", "hashing", "message", "hash"}
|
||||
sort_key = str(parsed.get("key") or "name")
|
||||
if sort_key not in allowed_sort_keys:
|
||||
sort_key = "name"
|
||||
clean = {"key": sort_key, "dir": 1 if direction >= 0 else -1}
|
||||
conn.execute("UPDATE user_preferences SET torrent_sort_json=?, updated_at=? WHERE user_id=?", (json.dumps(clean), now, user_id))
|
||||
if active_filter is not None:
|
||||
value = str(active_filter or "all").strip()
|
||||
if not value or len(value) > 180:
|
||||
value = "all"
|
||||
allowed_static_filters = {"all", "downloading", "seeding", "paused", "checking", "error", "stopped", "moving"}
|
||||
if value not in allowed_static_filters and not value.startswith("label:") and not value.startswith("tracker:"):
|
||||
value = "all"
|
||||
conn.execute("UPDATE user_preferences SET active_filter=?, updated_at=? WHERE user_id=?", (value, now, user_id))
|
||||
save_profile_preferences(user_id, profile_id, data)
|
||||
if disk_payload is not None:
|
||||
save_disk_monitor_preferences(_active_profile_id_for_user(user_id), disk_payload, user_id)
|
||||
return get_preferences(user_id)
|
||||
save_disk_monitor_preferences(profile_id, disk_payload, user_id)
|
||||
return get_preferences(user_id, profile_id)
|
||||
|
||||
Reference in New Issue
Block a user