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
+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,