Dispatcharr/core/tests/test_display_timezone.py
nagelm fd8e787104 fix(logging): render log timestamps in the system display timezone
Application log timestamps are pinned to UTC no matter what the operator
configures, while nginx in the same container honors TZ - one container,
two timezones (issue 1439). Two mechanisms force this:

1. docker/entrypoint.sh launches the app tree via `su - dispatch`,
   which strips the environment; only whitelisted vars are re-exported
   through /etc/environment and /etc/profile.d, and TZ is not among
   them, so no app process ever sees the operator's declared zone.

2. Whitelisting TZ alone cannot fix Django-managed processes anyway:
   Django's settings loader unconditionally re-stamps os.environ["TZ"]
   = TIME_ZONE ("UTC") and calls time.tzset() in every uwsgi, celery
   and management-command process. Stdlib logging's %(asctime)s renders
   via time.localtime(), so it can never show local time in a Django
   process regardless of the environment.

The fix, keeping TIME_ZONE = "UTC" / USE_TZ untouched (DB truth and
every wire surface - XMLTV offsets, XC server_info/time_now/EPG
timestamps - stay UTC by design):

- entrypoint: normalize a DISPATCHARR_TIME_ZONE bootstrap var from the
  standard TZ env and whitelist it. TZ itself is deliberately NOT
  whitelisted: pam_env would hand it to every su- child including
  initdb/postgres, silently flipping the database server timezone on
  fresh installs - the precondition for the EPG-offset corruption
  class tracked in issue 651, and (with an invalid zone) a source of
  psycopg "unknown PostgreSQL timezone" warnings emitted while the
  connection lock is held. Verified empirically: with TZ whitelisted,
  a fresh install ran its sessions at the container zone; without it,
  the server default stays UTC.

- settings: capture DISPATCHARR_DISPLAY_TZ at module import, before
  Django's tzset re-stamp, as the pre-database display default.

- logging: the verbose formatter renders timestamps with
  datetime/ZoneInfo from an in-process cached zone - never
  time.localtime(), and never a database query at emit time. An
  emit-time query can self-deadlock: a log record that originates
  inside a psycopg call (e.g. its timezone warning) would re-enter the
  same non-reentrant connection lock the ORM query then needs. The
  cache is refreshed out-of-band from provably safe contexts instead:
  request_started, celery task_prerun, and a CoreSettings post_save
  receiver (immediate in the process that saves the UI setting). The
  zone resolves to the UI's System > Time Zone setting - already
  canonical for DVR rules, celery crontabs and backup naming - with
  the env capture as the value before the first refresh, and any
  database error or invalid stored zone keeps the previous value.

- first-boot seed: the fresh-install default that the settings
  consolidation migration writes for the system time zone now derives
  from the same env capture (validated against zoneinfo, falling back
  to UTC) instead of a hardcoded "UTC", so a new install honors the
  declared container timezone from first boot. Existing installs are
  untouched: the migration only manufactures a default when no stored
  value exists, and installs that already ran it never re-run it.

Known residuals: uWSGI's native request log renders its own C-level
ftime outside Python logging and keeps UTC, as do celery's internal
worker_log_format lines; idle workers adopt a changed UI zone on their
next request/task. All documented rather than patched.
2026-07-15 13:32:36 +12:00

103 lines
3.4 KiB
Python

"""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
)