mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-07-23 18:18:18 +00:00
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:
parent
2b62928b42
commit
b6442e6421
23 changed files with 549 additions and 106 deletions
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue