This commit is contained in:
nagelm 2026-07-18 16:08:19 +12:00 committed by GitHub
commit b7f84fe059
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 193 additions and 3 deletions

View file

@ -1,6 +1,8 @@
# Generated migration to change CoreSettings value field to JSONField and consolidate settings
import json
import zoneinfo
from django.conf import settings as django_settings
from django.db import migrations, models
@ -113,8 +115,15 @@ def consolidate_settings(apps, schema_editor):
)
# SYSTEM SETTINGS
default_time_zone = (
getattr(django_settings, "DISPATCHARR_DISPLAY_TZ", None) or "UTC"
)
try:
zoneinfo.ZoneInfo(default_time_zone)
except Exception:
default_time_zone = "UTC"
system_settings = {
"time_zone": get_value("system-time-zone", "UTC"),
"time_zone": get_value("system-time-zone", default_time_zone),
"max_system_events": int(get_value("max-system-events", 100) or 100),
}
CoreSettings.objects.update_or_create(

View file

@ -1,13 +1,29 @@
from celery.signals import task_prerun
from django.core.signals import request_started
from django.db.models.signals import pre_delete, post_save
from django.dispatch import receiver
from django.core.exceptions import ValidationError
from .models import StreamProfile, CoreSettings, NETWORK_ACCESS_KEY
from dispatcharr.display_timezone import refresh_display_zone
from .models import StreamProfile, CoreSettings, NETWORK_ACCESS_KEY, SYSTEM_SETTINGS_KEY
@receiver(pre_delete, sender=StreamProfile)
def prevent_deletion_if_locked(sender, instance, **kwargs):
if instance.locked:
raise ValidationError("This profile is locked and cannot be deleted.")
@receiver(request_started, dispatch_uid="core_refresh_log_display_zone")
def refresh_log_display_zone_on_request(sender, **kwargs):
refresh_display_zone()
@task_prerun.connect(weak=False)
def refresh_log_display_zone_on_task(**kwargs):
refresh_display_zone()
@receiver(post_save, sender=CoreSettings)
def refresh_log_display_zone_on_settings_change(sender, instance, **kwargs):
if instance.key == SYSTEM_SETTINGS_KEY:
refresh_display_zone(force=True)
@receiver(post_save, sender=CoreSettings)
def handle_network_access_update(sender, instance, **kwargs):
"""Invalidate cache and sync notifications when network access settings change."""

View file

@ -0,0 +1,103 @@
"""Log timestamps must render in the configured display timezone."""
import logging
from datetime import datetime
from unittest import mock
from zoneinfo import ZoneInfo
from django.db import ProgrammingError
from django.test import TestCase, override_settings
from core.models import CoreSettings
from dispatcharr import display_timezone
from dispatcharr.display_timezone import DisplayTimezoneFormatter, refresh_display_zone
FIXED_EPOCH = 1784073600.0
def _record():
record = logging.LogRecord(
name="test",
level=logging.INFO,
pathname=__file__,
lineno=1,
msg="message",
args=(),
exc_info=None,
)
record.created = FIXED_EPOCH
record.msecs = 123.0
return record
def _expected(zone_name):
stamp = datetime.fromtimestamp(FIXED_EPOCH, ZoneInfo(zone_name)).strftime(
"%Y-%m-%d %H:%M:%S"
)
return f"{stamp},123"
class DisplayTimezoneFormatterTests(TestCase):
def setUp(self):
self._reset_cache()
self.addCleanup(self._reset_cache)
self.formatter = DisplayTimezoneFormatter(
format="{asctime} {levelname} {name} {message}", style="{"
)
@staticmethod
def _reset_cache():
display_timezone._cache.update({"zone": None, "checked": 0.0})
@override_settings(DISPATCHARR_DISPLAY_TZ="Europe/Zurich")
def test_env_capture_used_before_first_refresh(self):
self.assertEqual(
self.formatter.formatTime(_record()), _expected("Europe/Zurich")
)
def test_settings_change_refreshes_through_signal(self):
CoreSettings.set_system_time_zone("Pacific/Auckland")
self.assertEqual(
self.formatter.formatTime(_record()), _expected("Pacific/Auckland")
)
@override_settings(DISPATCHARR_DISPLAY_TZ="Europe/Zurich")
def test_database_errors_keep_previous_value(self):
with mock.patch.object(
CoreSettings,
"get_system_time_zone",
side_effect=ProgrammingError("relation does not exist"),
):
refresh_display_zone(force=True)
self.assertEqual(
self.formatter.formatTime(_record()), _expected("Europe/Zurich")
)
def test_invalid_stored_zone_keeps_previous_value(self):
CoreSettings.set_system_time_zone("Pacific/Auckland")
with mock.patch.object(
CoreSettings, "get_system_time_zone", return_value="Not/AZone"
):
refresh_display_zone(force=True)
self.assertEqual(
self.formatter.formatTime(_record()), _expected("Pacific/Auckland")
)
def test_refresh_respects_interval_unless_forced(self):
CoreSettings.set_system_time_zone("UTC")
with mock.patch.object(
CoreSettings, "get_system_time_zone", return_value="Pacific/Auckland"
):
refresh_display_zone()
self.assertEqual(self.formatter.formatTime(_record()), _expected("UTC"))
refresh_display_zone(force=True)
self.assertEqual(
self.formatter.formatTime(_record()), _expected("Pacific/Auckland")
)
def test_custom_datefmt_is_respected(self):
CoreSettings.set_system_time_zone("Pacific/Auckland")
expected = datetime.fromtimestamp(
FIXED_EPOCH, ZoneInfo("Pacific/Auckland")
).strftime("%H:%M")
self.assertEqual(
self.formatter.formatTime(_record(), datefmt="%H:%M"), expected
)

View file

@ -0,0 +1,49 @@
import logging
import time
from datetime import datetime
from zoneinfo import ZoneInfo
_REFRESH_INTERVAL_SECONDS = 5.0
_cache = {"zone": None, "checked": 0.0}
def _env_zone():
from django.conf import settings
try:
return ZoneInfo(getattr(settings, "DISPATCHARR_DISPLAY_TZ", "UTC"))
except Exception:
return ZoneInfo("UTC")
def refresh_display_zone(force=False):
"""Refresh the cached display zone from CoreSettings.
Only call from contexts that may safely run a database query
(request/task start, settings writes) never from the logging path:
an emit-time query deadlocks on the psycopg connection lock when the
log record itself originates inside a database call.
"""
now = time.monotonic()
if not force and now - _cache["checked"] < _REFRESH_INTERVAL_SECONDS:
return
_cache["checked"] = now
try:
from core.models import CoreSettings
_cache["zone"] = ZoneInfo(CoreSettings.get_system_time_zone())
except Exception:
# Database or app registry not ready, or an invalid stored zone —
# keep the previous value (the env capture until a refresh lands).
pass
class DisplayTimezoneFormatter(logging.Formatter):
def __init__(self, format=None, datefmt=None, style="%"):
super().__init__(fmt=format, datefmt=datefmt, style=style)
def formatTime(self, record, datefmt=None):
dt = datetime.fromtimestamp(record.created, tz=_cache["zone"] or _env_zone())
if datefmt:
return dt.strftime(datefmt)
return f"{dt.strftime('%Y-%m-%d %H:%M:%S')},{int(record.msecs):03d}"

View file

@ -522,12 +522,19 @@ else:
LOG_LEVEL = LOG_LEVEL_MAP.get(LOG_LEVEL_NAME, 20) # Default to INFO (20) if invalid
# Read at module import: Django re-stamps os.environ["TZ"] to TIME_ZONE
# ("UTC") via time.tzset() as soon as settings finish loading.
DISPATCHARR_DISPLAY_TZ = (
os.environ.get("DISPATCHARR_TIME_ZONE") or os.environ.get("TZ") or "UTC"
)
# Add this to your existing LOGGING configuration or create one if it doesn't exist
LOGGING = {
"version": 1,
"disable_existing_loggers": False,
"formatters": {
"verbose": {
"()": "dispatcharr.display_timezone.DisplayTimezoneFormatter",
"format": "{asctime} {levelname} {name} {message}",
"style": "{",
},

View file

@ -153,6 +153,12 @@ echo "Environment DISPATCHARR_LOG_LEVEL set to: '${DISPATCHARR_LOG_LEVEL}'"
# Also make the log level available in /etc/environment for all login shells
#grep -q "DISPATCHARR_LOG_LEVEL" /etc/environment || echo "DISPATCHARR_LOG_LEVEL=${DISPATCHARR_LOG_LEVEL}" >> /etc/environment
export DISPATCHARR_TIME_ZONE
# Normalize from the standard TZ env when not set explicitly
DISPATCHARR_TIME_ZONE=${DISPATCHARR_TIME_ZONE:-${TZ:-UTC}}
echo "Environment DISPATCHARR_TIME_ZONE set to: '${DISPATCHARR_TIME_ZONE}'"
# Translate Dispatcharr POSTGRES_SSL_* env vars into libpq-recognized PGSSL*
# env vars. Called once before any external PostgreSQL connection; all child
# processes (psql, pg_dump, pg_isready, createdb, dropdb) inherit these
@ -180,7 +186,7 @@ variables=(
DISPATCHARR_ENV DISPATCHARR_DEBUG DISPATCHARR_LOG_LEVEL DISPATCHARR_ENABLE_IP_LOOKUP
REDIS_HOST REDIS_PORT REDIS_DB REDIS_PASSWORD REDIS_USER POSTGRES_DIR DISPATCHARR_PORT
DISPATCHARR_VERSION DISPATCHARR_TIMESTAMP LIBVA_DRIVERS_PATH LIBVA_DRIVER_NAME LD_LIBRARY_PATH
CELERY_NICE_LEVEL UWSGI_NICE_LEVEL DJANGO_SECRET_KEY
CELERY_NICE_LEVEL UWSGI_NICE_LEVEL DJANGO_SECRET_KEY DISPATCHARR_TIME_ZONE
)
# TLS variables are optional — only propagate when set to avoid noisy warnings