feat(catchup): enhance catchup functionality with user and system settings

This update introduces a new `is_catchup_enabled` function to determine if catch-up is allowed for users based on their custom properties and system settings. The `UserViewSet` is modified to restrict admin-managed properties, including catch-up access. Additionally, various views and tests are updated to incorporate catch-up checks, ensuring that users without access receive appropriate error responses. The frontend is enhanced with a catch-up toggle in user and system settings forms, allowing for better management of catch-up capabilities.
This commit is contained in:
SergeantPanda 2026-07-18 18:55:51 +00:00
parent 2b62928b42
commit b6442e6421
23 changed files with 549 additions and 106 deletions

View file

@ -198,33 +198,13 @@ USER_LIMITS_SETTINGS_KEY = "user_limit_settings"
# Redis cache for CoreSettings JSON groups. Primary invalidation is post_save /
# post_delete; TTL is a safety net if a writer bypasses signals.
# A version key is bumped on invalidate so a concurrent miss cannot re-poison
# Redis with a stale DB snapshot after delete.
_GROUP_CACHE_PREFIX = "coresettings:group:"
_GROUP_CACHE_VER_PREFIX = "coresettings:groupver:"
_GROUP_CACHE_TTL_SECONDS = 300
def setting_flag_enabled(value, *, default=True):
"""Parse a settings/JSON flag that should default to *default* when unset.
Explicit ``False`` / ``0`` / common false strings disable. Explicit
``True`` / ``1`` / common true strings enable. Other types fall back to
*default* so garbage payloads do not accidentally enable a kill-switch.
"""
if value is None:
return default
if value is False or value == 0:
return False
if value is True or value == 1:
return True
if isinstance(value, str):
lowered = value.strip().lower()
if lowered in ("false", "0", "no", "off", ""):
return False
if lowered in ("true", "1", "yes", "on"):
return True
return default
return default
class CoreSettings(models.Model):
key = models.CharField(
max_length=255,
@ -245,12 +225,21 @@ class CoreSettings(models.Model):
def group_cache_key(cls, key):
return f"{_GROUP_CACHE_PREFIX}{key}"
@classmethod
def group_cache_ver_key(cls, key):
return f"{_GROUP_CACHE_VER_PREFIX}{key}"
@classmethod
def invalidate_group_cache(cls, key):
"""Drop the cached JSON for a settings group (all workers share Redis)."""
import time
from django.core.cache import cache
cache.delete(cls.group_cache_key(key))
# Monotonic bump so in-flight _get_group fills skip cache.set.
# timeout=None: never expire (version must outlive group entries).
cache.set(cls.group_cache_ver_key(key), time.time_ns(), timeout=None)
if key == PROXY_SETTINGS_KEY:
# Proxy workers also keep a short process-local copy.
try:
@ -276,10 +265,12 @@ class CoreSettings(models.Model):
defaults = defaults or {}
cache_key = cls.group_cache_key(key)
ver_key = cls.group_cache_ver_key(key)
cached = cache.get(cache_key)
if isinstance(cached, dict):
return copy.deepcopy(cached)
ver_before = cache.get(ver_key)
try:
value = cls.objects.get(key=key).value or defaults
if not isinstance(value, dict):
@ -288,7 +279,10 @@ class CoreSettings(models.Model):
value = defaults
value = copy.deepcopy(value)
cache.set(cache_key, value, timeout=_GROUP_CACHE_TTL_SECONDS)
# Skip fill if an invalidate landed during the DB read (avoids
# re-caching a stale snapshot for the full TTL).
if cache.get(ver_key) == ver_before:
cache.set(cache_key, value, timeout=_GROUP_CACHE_TTL_SECONDS)
return copy.deepcopy(value)
@classmethod
@ -489,10 +483,8 @@ class CoreSettings(models.Model):
@classmethod
def get_catchup_enabled(cls):
"""Whether catch-up / timeshift is enabled system-wide (default True)."""
return setting_flag_enabled(
cls.get_system_settings().get("catchup_enabled"),
default=True,
)
# Stored as a JSON boolean by System Settings; default on when unset.
return cls.get_system_settings().get("catchup_enabled", True) is not False
@classmethod
def get_system_time_zone(cls):

View file

@ -9,34 +9,9 @@ from core.models import (
DVR_SETTINGS_KEY,
EPG_SETTINGS_KEY,
SYSTEM_SETTINGS_KEY,
setting_flag_enabled,
)
class SettingFlagEnabledTests(SimpleTestCase):
def test_defaults_when_missing(self):
self.assertTrue(setting_flag_enabled(None, default=True))
self.assertFalse(setting_flag_enabled(None, default=False))
def test_bool_and_int(self):
self.assertFalse(setting_flag_enabled(False))
self.assertTrue(setting_flag_enabled(True))
self.assertFalse(setting_flag_enabled(0))
self.assertTrue(setting_flag_enabled(1))
def test_common_strings(self):
self.assertFalse(setting_flag_enabled("false"))
self.assertFalse(setting_flag_enabled("0"))
self.assertFalse(setting_flag_enabled("off"))
self.assertTrue(setting_flag_enabled("true"))
self.assertTrue(setting_flag_enabled("1"))
self.assertTrue(setting_flag_enabled("yes"))
def test_garbage_falls_back_to_default(self):
self.assertTrue(setting_flag_enabled("maybe", default=True))
self.assertFalse(setting_flag_enabled([], default=False))
class CoreSettingsGroupCacheTests(TestCase):
"""_get_group Redis cache: hit after first read, invalidate on save."""
@ -44,6 +19,10 @@ class CoreSettingsGroupCacheTests(TestCase):
cache.clear()
CoreSettings.objects.filter(key=SYSTEM_SETTINGS_KEY).delete()
def tearDown(self):
# DB rollback does not undo Redis entries written during the test.
cache.clear()
def test_second_read_does_not_query_database(self):
CoreSettings.objects.create(
key=SYSTEM_SETTINGS_KEY,
@ -79,6 +58,43 @@ class CoreSettingsGroupCacheTests(TestCase):
# Row gone: defaults apply (catchup enabled)
self.assertTrue(CoreSettings.get_catchup_enabled())
def test_stale_fill_does_not_repoison_after_invalidate(self):
"""A miss that read DB before invalidate must not rewrite Redis."""
CoreSettings.objects.create(
key=SYSTEM_SETTINGS_KEY,
name="System Settings",
value={"catchup_enabled": True},
)
cache_key = CoreSettings.group_cache_key(SYSTEM_SETTINGS_KEY)
cache.delete(cache_key)
real_get = CoreSettings.objects.get
cached_sets = []
def racing_get(*args, **kwargs):
row = real_get(*args, **kwargs)
# Concurrent writer: bump version after this miss read the row.
CoreSettings.invalidate_group_cache(SYSTEM_SETTINGS_KEY)
return row
real_set = cache.set
def tracking_set(key, value, timeout=None, **kwargs):
cached_sets.append(key)
return real_set(key, value, timeout=timeout, **kwargs)
with patch.object(CoreSettings.objects, "get", side_effect=racing_get), \
patch.object(cache, "set", side_effect=tracking_set):
CoreSettings.get_system_settings()
self.assertNotIn(cache_key, cached_sets)
# Writer left DB at True; a later read may refill, but not with a
# skipped stale set over a newer disable. Flip DB and confirm.
obj = CoreSettings.objects.get(key=SYSTEM_SETTINGS_KEY)
obj.value = {"catchup_enabled": False}
obj.save()
self.assertFalse(CoreSettings.get_catchup_enabled())
def test_nested_mutation_does_not_poison_cache(self):
from core.models import DVR_SETTINGS_KEY