fix profile-scoped backups and shared profile rules

This commit is contained in:
Mateusz Gruszczyński
2026-06-07 23:12:00 +02:00
parent 51e00a4e37
commit 8990f2b404
10 changed files with 264 additions and 86 deletions
+140 -38
View File
@@ -15,27 +15,46 @@ APP_BACKUP_TABLES = [
"rtorrent_config_overrides", "poller_settings", "app_settings", "download_plan_settings",
]
# Note: Profile backups contain active profile data. User-specific preferences remain scoped to the current user.
# Note: Profile backups contain profile behavior plus user-specific view preferences for the user creating the backup.
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",
]
# Scope values:
# - profile: shared profile behavior, visible/restored by profile access.
# - user_profile: personal preferences for the backup creator/restorer.
PROFILE_TABLE_SCOPES = {
"rtorrent_profiles": "profile_id",
"profile_preferences": "user_profile",
"disk_monitor_preferences": "user_profile",
"labels": "profile",
"ratio_groups": "profile",
"rss_feeds": "profile",
"rss_rules": "profile",
"smart_queue_settings": "profile",
"smart_queue_exclusions": "profile",
"automation_rules": "profile",
"rtorrent_config_overrides": "profile",
"poller_settings": "profile",
"download_plan_settings": "profile_singleton",
}
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=?",
"labels": "profile_id=?",
"ratio_groups": "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=?",
"automation_rules": "profile_id=?",
"rtorrent_config_overrides": "profile_id=?",
"poller_settings": "profile_id=?",
"download_plan_settings": "user_id=? AND profile_id=?",
"download_plan_settings": "profile_id=?",
}
DEFAULT_AUTO_BACKUP_SETTINGS = {
@@ -91,6 +110,41 @@ def _table_rows(conn, table: str, where: str | None = None, params: tuple = ())
return []
def _profile_filter_params(table: str, user_id: int, profile_id: int) -> tuple[object, ...]:
scope = PROFILE_TABLE_SCOPES.get(table)
if scope in {"profile", "profile_id", "profile_singleton"}:
return (int(profile_id),)
return (int(user_id), int(profile_id))
def _user_label(conn, user_id: int | None) -> str:
if not user_id:
return "system"
try:
row = conn.execute("SELECT display_name, username, email FROM users WHERE id=?", (int(user_id),)).fetchone()
if row:
return str(row.get("display_name") or row.get("username") or row.get("email") or f"user {user_id}")
except Exception:
pass
return f"user {user_id}"
def _backup_row_visible(row: dict, user_id: int) -> bool:
backup_type = str(row.get("backup_type") or "app")
if backup_type == "app":
return _is_admin_user(user_id)
profile_id = int(row.get("profile_id") or 0)
return bool(profile_id and auth.can_access_profile(profile_id, user_id))
def _backup_row_writable(row: dict, user_id: int) -> bool:
backup_type = str(row.get("backup_type") or "app")
if backup_type == "app":
return _is_admin_user(user_id)
profile_id = int(row.get("profile_id") or 0)
return bool(profile_id and auth.can_write_profile(profile_id, user_id))
def _store_backup(user_id: int, name: str, backup_type: str, profile_id: int | None, payload: dict) -> dict:
with connect() as conn:
cur = conn.execute(
@@ -127,11 +181,7 @@ def create_profile_backup(name: str, profile_id: int, user_id: int | None = None
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)
payload["tables"][table] = _table_rows(conn, table, where, _profile_filter_params(table, user_id, int(profile_id)))
return _store_backup(user_id, name, "profile", int(profile_id), payload)
@@ -141,26 +191,39 @@ def create_backup(name: str, user_id: int | None = None, automatic: bool = False
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]
clauses: list[str] = []
params: list[object] = []
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))
where = f"WHERE {' AND '.join(clauses)}" if clauses else ""
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",
f"""
SELECT b.id,b.name,b.user_id,b.created_at,b.payload_json,COALESCE(b.backup_type,'app') AS backup_type,b.profile_id,
u.display_name AS owner_display_name,u.username AS owner_username,u.email AS owner_email
FROM app_backups b
LEFT JOIN users u ON u.id=b.user_id
{where}
ORDER BY b.id DESC
""",
tuple(params),
).fetchall()
result = []
for row in rows:
if not _backup_row_visible(row, user_id):
continue
payload = _loads(row.get("payload_json") or "{}")
tables = payload.get("tables") or {}
owner_name = str(row.get("owner_display_name") or row.get("owner_username") or row.get("owner_email") or f"user {row.get('user_id')}")
result.append({
"id": row.get("id"),
"name": row.get("name"),
"owner_user_id": row.get("user_id"),
"owner_name": owner_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"),
@@ -169,16 +232,14 @@ def list_backups(user_id: int | None = None, backup_type: str | None = None, pro
})
return result
def payload_for_backup(backup_id: int, user_id: int | None = None) -> dict:
def payload_for_backup(backup_id: int, user_id: int | None = None, require_write: bool = False) -> dict:
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:
row = conn.execute("SELECT id,user_id,COALESCE(backup_type,'app') AS backup_type,profile_id,payload_json FROM app_backups WHERE id=?", (backup_id,)).fetchone()
if not row or not (_backup_row_writable(row, user_id) if require_write else _backup_row_visible(row, user_id)):
raise ValueError("Backup not found")
return json.loads(row["payload_json"] or "{}")
def _backup_type(payload: dict) -> str:
return str(payload.get("backup_type") or ("profile" if payload.get("source_profile_id") else "app"))
@@ -186,7 +247,7 @@ def _backup_type(payload: dict) -> str:
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)
payload = payload_for_backup(backup_id, user_id, require_write=True)
if _backup_type(payload) != "app":
raise ValueError("This is not an application backup")
tables = payload.get("tables") or {}
@@ -234,7 +295,7 @@ def restore_profile_backup(backup_id: int, target_profile_id: int, user_id: int
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)
payload = payload_for_backup(backup_id, user_id, require_write=True)
if _backup_type(payload) != "profile":
raise ValueError("This is not a profile backup")
tables = payload.get("tables") or {}
@@ -245,10 +306,7 @@ def restore_profile_backup(backup_id: int, target_profile_id: int, user_id: int
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))
params = _profile_filter_params(table, user_id, int(target_profile_id))
conn.execute(f"DELETE FROM {table} WHERE {where}", params)
if not rows:
continue
@@ -269,7 +327,7 @@ def restore_profile_backup(backup_id: int, target_profile_id: int, user_id: int
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)
payload = payload_for_backup(backup_id, user_id, require_write=True)
if _backup_type(payload) == "profile":
target = profile_id or payload.get("source_profile_id")
if not target:
@@ -281,26 +339,30 @@ def restore_backup(backup_id: int, user_id: int | None = None, profile_id: int |
def delete_backup(backup_id: int, user_id: int | None = None) -> dict:
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))
row = conn.execute("SELECT id,user_id,COALESCE(backup_type,'app') AS backup_type,profile_id FROM app_backups WHERE id=?", (backup_id,)).fetchone()
if not row or not _backup_row_writable(row, user_id):
raise ValueError("Backup not found")
cur = conn.execute("DELETE FROM app_backups WHERE id=?", (backup_id,))
if not cur.rowcount:
raise ValueError("Backup not found")
return {"deleted": backup_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}:profile:{int(profile_id or 0)}"
return f"{AUTO_BACKUP_SETTINGS_KEY}:app:{uid}"
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]
clauses = ["COALESCE(backup_type,'app')=?"]
params: list[object] = [backup_type]
if backup_type == "profile":
clauses.append("profile_id=?")
params.append(int(profile_id or 0))
else:
clauses.append("user_id=?")
params.append(user_id)
with connect() as conn:
row = conn.execute(
f"SELECT created_at FROM app_backups WHERE {' AND '.join(clauses)} ORDER BY created_at DESC, id DESC LIMIT 1",
@@ -308,7 +370,6 @@ def _latest_backup_created_at(user_id: int, backup_type: str = "app", profile_id
).fetchone()
return str(row["created_at"] or "") if row and row.get("created_at") else None
def _preview_value(value: object) -> object:
if value is None or isinstance(value, (int, float, bool)):
return value
@@ -325,9 +386,13 @@ def _preview_row(row: dict) -> dict:
def get_auto_backup_settings(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()
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()
if not row and backup_type == "profile":
legacy_key = f"{AUTO_BACKUP_SETTINGS_KEY}:profile:{int(user_id)}:{int(profile_id or 0)}"
row = conn.execute("SELECT value FROM app_settings WHERE key=?", (legacy_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))
@@ -335,6 +400,9 @@ def get_auto_backup_settings(user_id: int | None = None, backup_type: str = "app
settings["backup_type"] = "profile" if backup_type == "profile" else "app"
if backup_type == "profile":
settings["profile_id"] = int(profile_id or 0)
settings["owner_user_id"] = user_id or auth.current_user_id() or default_user_id()
with connect() as conn:
settings["owner_name"] = _user_label(conn, settings["owner_user_id"])
return settings
@@ -361,11 +429,28 @@ def save_auto_backup_settings(data: dict, user_id: int | None = None, backup_typ
return settings
def _backup_owner_info(backup_id: int) -> dict:
with connect() as conn:
row = conn.execute(
"""
SELECT b.user_id,COALESCE(u.display_name,u.username,u.email,'user ' || b.user_id) AS owner_name
FROM app_backups b
LEFT JOIN users u ON u.id=b.user_id
WHERE b.id=?
""",
(int(backup_id),),
).fetchone()
return {"owner_user_id": row.get("user_id") if row else None, "owner_name": row.get("owner_name") if row else ""}
def preview_backup(backup_id: int, user_id: int | None = None) -> dict:
payload = payload_for_backup(backup_id, user_id)
tables = payload.get("tables") or {}
owner = _backup_owner_info(backup_id)
return {
"version": payload.get("version"),
"owner_user_id": owner.get("owner_user_id"),
"owner_name": owner.get("owner_name"),
"created_at": payload.get("created_at"),
"backup_type": _backup_type(payload),
"source_profile_id": payload.get("source_profile_id"),
@@ -385,16 +470,18 @@ 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, 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]
clauses = ["COALESCE(backup_type,'app')=?", "created_at<?"]
params: list[object] = [backup_type, cutoff]
if backup_type == "profile":
clauses.append("profile_id=?")
params.append(int(profile_id or 0))
else:
clauses.append("user_id=?")
params.append(user_id)
with connect() as conn:
cur = conn.execute(f"DELETE FROM app_backups WHERE {' AND '.join(clauses)}", tuple(params))
return int(cur.rowcount or 0)
def _should_run(settings: dict, last_value: str | None) -> bool:
now = datetime.now(timezone.utc)
try:
@@ -433,18 +520,33 @@ def maybe_create_automatic_backup(user_id: int | None = None, backup_type: str =
def _profile_schedule_keys() -> list[tuple[int, int]]:
prefix = f"{AUTO_BACKUP_SETTINGS_KEY}:profile:"
keys: list[tuple[int, int]] = []
keys: set[tuple[int, int]] = set()
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])))
if len(parts) >= 5:
# Legacy key: backup:auto:profile:{uid}:{profile_id}
keys.add((int(parts[-2]), int(parts[-1])))
elif len(parts) >= 4:
profile_id = int(parts[-1])
keys.add((_profile_owner_for_backup(profile_id), profile_id))
except Exception:
continue
return keys
return sorted(keys)
def _profile_owner_for_backup(profile_id: int) -> int:
with connect() as conn:
row = conn.execute("SELECT user_id FROM rtorrent_profiles WHERE id=?", (int(profile_id),)).fetchone()
if row and row.get("user_id"):
return int(row["user_id"])
row = conn.execute("SELECT user_id FROM user_profile_permissions WHERE profile_id=? AND access_level='full' ORDER BY user_id LIMIT 1", (int(profile_id),)).fetchone()
if row and row.get("user_id"):
return int(row["user_id"])
return default_user_id()
def start_scheduler() -> None:
global _scheduler_started
with _scheduler_lock:
+34 -14
View File
@@ -8,7 +8,7 @@ from typing import Any
import psutil
from ..db import connect, default_user_id, utcnow
from . import rtorrent
from . import auth, rtorrent
DEFAULTS = {
"enabled": False,
@@ -140,14 +140,31 @@ def normalize(data: dict | None) -> dict:
}
def _row(user_id: int, profile_id: int) -> dict | None:
def _row(user_id: int | None, profile_id: int) -> dict | None:
with connect() as conn:
return conn.execute(
"SELECT * FROM download_plan_settings WHERE user_id=? AND profile_id=?",
(user_id, profile_id),
row = conn.execute(
"SELECT * FROM download_plan_settings WHERE profile_id=? ORDER BY updated_at DESC, user_id ASC LIMIT 1",
(profile_id,),
).fetchone()
if row:
return row
if user_id:
return conn.execute(
"SELECT * FROM download_plan_settings WHERE user_id=? AND profile_id=?",
(user_id, profile_id),
).fetchone()
return None
def _user_label(user_id: int | None) -> str:
if not user_id:
return "system"
with connect() as conn:
row = conn.execute("SELECT display_name, username, email FROM users WHERE id=?", (int(user_id),)).fetchone()
if row:
return str(row.get("display_name") or row.get("username") or row.get("email") or f"user {user_id}")
return f"user {user_id}"
def _preference_row_for_disk_source(profile_id: int, user_id: int | None = None) -> dict | None:
@@ -269,12 +286,13 @@ def get_settings(profile_id: int, user_id: int | None = None) -> dict:
row = _row(user_id, profile_id)
if not row:
migrated = normalize({**DEFAULTS, **_legacy_disk_guard_defaults(int(profile_id), user_id)})
return {**migrated, "profile_id": int(profile_id), "user_id": int(user_id)}
return {**migrated, "profile_id": int(profile_id), "owner_user_id": int(user_id), "owner_name": _user_label(user_id)}
try:
data = json.loads(row.get("settings_json") or "{}")
except Exception:
data = {}
settings = {**normalize(data), "profile_id": int(profile_id), "user_id": int(user_id), "updated_at": row.get("updated_at")}
owner_user_id = int(row.get("user_id") or user_id)
settings = {**normalize(data), "profile_id": int(profile_id), "owner_user_id": owner_user_id, "owner_name": _user_label(owner_user_id), "updated_at": row.get("updated_at")}
runtime_override = _override_until(int(profile_id))
if runtime_override:
settings["manual_override_until"] = runtime_override
@@ -283,18 +301,20 @@ def get_settings(profile_id: int, user_id: int | None = None) -> dict:
def save_settings(profile_id: int, data: dict, user_id: int | None = None) -> dict:
user_id = user_id or default_user_id()
if not auth.can_write_profile(int(profile_id), user_id):
raise PermissionError("No write access to profile")
settings = normalize(data)
now = utcnow()
with connect() as conn:
conn.execute("DELETE FROM download_plan_settings WHERE profile_id=?", (int(profile_id),))
conn.execute(
"""
INSERT INTO download_plan_settings(user_id, profile_id, settings_json, updated_at)
VALUES(?,?,?,?)
ON CONFLICT(user_id, profile_id) DO UPDATE SET settings_json=excluded.settings_json, updated_at=excluded.updated_at
""",
(user_id, profile_id, json.dumps(settings), now),
)
return {**settings, "profile_id": int(profile_id), "user_id": int(user_id), "updated_at": now}
return {**settings, "profile_id": int(profile_id), "owner_user_id": int(user_id), "owner_name": _user_label(user_id), "updated_at": now}
def _active_downloading_hashes(profile: dict) -> list[str]:
@@ -445,9 +465,10 @@ def evaluate(profile: dict, settings: dict | None = None, now: datetime | None =
def enforce(profile: dict, force: bool = False, user_id: int | None = None) -> dict:
profile_id = int(profile.get("id") or 0)
user_id = user_id or int(profile.get("user_id") or default_user_id())
# Note: Background planner runs without Flask session state, so settings are resolved with the profile owner.
settings = get_settings(profile_id, user_id)
settings = get_settings(profile_id, user_id or int(profile.get("user_id") or default_user_id()))
user_id = int(settings.get("owner_user_id") or user_id or profile.get("user_id") or default_user_id())
if not auth.can_write_profile(profile_id, user_id):
return {"ok": True, "enabled": False, "profile_id": profile_id, "skipped": True, "reason": "planner owner has no write access", "history": history(profile_id, 20), "history_total": history_count(profile_id)}
if not settings.get("enabled"):
return {"ok": True, "enabled": False, "profile_id": profile_id, "history": history(profile_id, 20), "history_total": history_count(profile_id), "preview": preview(profile, user_id=user_id)}
now = time.monotonic()
@@ -505,8 +526,7 @@ def enforce(profile: dict, force: bool = False, user_id: int | None = None) -> d
def preview(profile: dict, user_id: int | None = None) -> dict:
profile_id = int(profile.get("id") or 0)
user_id = user_id or int(profile.get("user_id") or default_user_id())
settings = get_settings(profile_id, user_id)
settings = get_settings(profile_id, user_id or int(profile.get("user_id") or default_user_id()))
decision = evaluate(profile, settings)
return {
"profile_id": profile_id,
+7 -8
View File
@@ -104,12 +104,13 @@ def get_settings(profile_id: int = 0, user_id: int | None = None) -> dict:
profile_id = int(profile_id or 0)
with connect() as conn:
row = conn.execute(
"SELECT * FROM operation_log_settings WHERE user_id=? AND profile_id=?",
(user_id, profile_id),
"SELECT * FROM operation_log_settings WHERE profile_id=? ORDER BY updated_at DESC, user_id ASC LIMIT 1",
(profile_id,),
).fetchone()
if not row:
return {"user_id": user_id, "profile_id": profile_id, **DEFAULT_SETTINGS}
return {"owner_user_id": user_id, "profile_id": profile_id, **DEFAULT_SETTINGS}
data = {**DEFAULT_SETTINGS, **dict(row)}
data["owner_user_id"] = int(data.pop("user_id", user_id) or user_id)
data["retention_mode"] = data.get("retention_mode") if data.get("retention_mode") in VALID_RETENTION_MODES else "days"
data["retention_days"] = max(1, int(data.get("retention_days") or DEFAULT_SETTINGS["retention_days"]))
data["retention_lines"] = max(100, int(data.get("retention_lines") or DEFAULT_SETTINGS["retention_lines"]))
@@ -125,16 +126,14 @@ def save_settings(profile_id: int, data: dict, user_id: int | None = None) -> di
days = max(1, min(3650, int(data.get("retention_days") or DEFAULT_SETTINGS["retention_days"])))
lines = max(100, min(1_000_000, int(data.get("retention_lines") or DEFAULT_SETTINGS["retention_lines"])))
now = utcnow()
if not auth.can_write_profile(profile_id, user_id):
raise PermissionError("No write access to profile")
with connect() as conn:
conn.execute("DELETE FROM operation_log_settings WHERE profile_id=?", (profile_id,))
conn.execute(
"""
INSERT INTO operation_log_settings(user_id, profile_id, retention_mode, retention_days, retention_lines, created_at, updated_at)
VALUES(?,?,?,?,?,?,?)
ON CONFLICT(user_id, profile_id) DO UPDATE SET
retention_mode=excluded.retention_mode,
retention_days=excluded.retention_days,
retention_lines=excluded.retention_lines,
updated_at=excluded.updated_at
""",
(user_id, profile_id, mode, days, lines, now, now),
)
+19 -9
View File
@@ -5,7 +5,7 @@ import time
from datetime import datetime, timezone
from ..db import connect, utcnow, default_user_id
from . import rtorrent
from . import auth, rtorrent
from .workers import enqueue
@@ -67,12 +67,14 @@ def _should_apply(profile: dict, group: dict, torrent: dict) -> tuple[bool, str]
def check(profile: dict, user_id: int | None = None) -> dict:
user_id = user_id or default_user_id()
viewer_user_id = user_id or default_user_id()
profile_id = int(profile["id"])
with connect() as conn:
groups = conn.execute("SELECT * FROM ratio_groups WHERE user_id=? AND profile_id=? AND enabled=1", (user_id, profile_id)).fetchall()
groups = conn.execute("SELECT * FROM ratio_groups WHERE profile_id=? AND enabled=1 ORDER BY lower(name), id", (profile_id,)).fetchall()
already = {row["torrent_hash"] for row in conn.execute("SELECT torrent_hash FROM ratio_assignments WHERE profile_id=? AND last_status='applied'", (profile_id,)).fetchall()}
groups_by_name = {str(g.get("name") or ""): g for g in groups}
groups_by_name: dict[str, dict] = {}
for group in groups:
groups_by_name.setdefault(str(group.get("name") or ""), group)
applied = 0
skipped = 0
queued_jobs = []
@@ -93,6 +95,11 @@ def check(profile: dict, user_id: int | None = None) -> dict:
)
continue
action = str(group.get("action") or "stop")
owner_user_id = int(group.get("user_id") or viewer_user_id)
if not auth.can_write_profile(profile_id, owner_user_id):
skipped += 1
_record(owner_user_id, profile_id, group, torrent, action, "skipped", "owner has no write access to profile")
continue
payload = {"hashes": [torrent["hash"]], "source": "ratio", "job_context": {"source": "ratio", "rule_name": group.get("name"), "hash_count": 1}}
if action == "remove_data":
api_action = "remove"
@@ -105,10 +112,10 @@ def check(profile: dict, user_id: int | None = None) -> dict:
payload["label"] = group.get("set_label") or group.get("name") or ""
else:
api_action = action if action in {"stop", "remove", "pause"} else "stop"
job_id = enqueue(api_action, profile_id, payload, user_id=user_id)
job_id = enqueue(api_action, profile_id, payload, user_id=owner_user_id)
queued_jobs.append(job_id)
applied += 1
_record(user_id, profile_id, group, torrent, action, "applied", reason, {"job_id": job_id, "api_action": api_action})
_record(owner_user_id, profile_id, group, torrent, action, "applied", reason, {"job_id": job_id, "api_action": api_action})
return {"applied": applied, "skipped": skipped, "job_ids": queued_jobs}
@@ -127,12 +134,15 @@ def start_scheduler(socketio=None) -> None:
try:
from .preferences import get_profile
with connect() as conn:
profiles = conn.execute("SELECT DISTINCT user_id, profile_id FROM ratio_groups WHERE enabled=1 AND profile_id IS NOT NULL").fetchall()
profiles = conn.execute("SELECT DISTINCT profile_id FROM ratio_groups WHERE enabled=1 AND profile_id IS NOT NULL").fetchall()
for row in profiles:
profile = get_profile(int(row["profile_id"]), int(row["user_id"]))
profile_id = int(row["profile_id"])
with connect() as conn:
owner = conn.execute("SELECT user_id FROM rtorrent_profiles WHERE id=?", (profile_id,)).fetchone()
profile = get_profile(profile_id, int(owner["user_id"] if owner and owner.get("user_id") else default_user_id()))
if not profile:
continue
result = check(profile, int(row["user_id"]))
result = check(profile)
if socketio and result.get("applied"):
socketio.emit("ratio_rules_checked", {"profile_id": profile["id"], **result}, to=f"profile:{profile['id']}")
except Exception: