Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions openwisp_monitoring/device/tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -654,6 +654,15 @@ def test_get_device_metrics_400_bad_timezone(self):
self.assertEqual(r.status_code, 400)
self.assertIn("Unkown Time Zone", r.data)

def test_get_device_metrics_legacy_timezone_fallback(self):
dd = self.create_test_data(no_resources=True)
d = self.device_model.objects.get(pk=dd.pk)
url = "{0}&timezone={1}".format(self._url(d.pk, d.key), "Asia/Calcutta")
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
self.assertIn("charts", response.data)
self.assertIsInstance(response.data["charts"], list)

def test_device_metrics_received_signal(self):
d = self._create_device(organization=self._create_org())
dd = DeviceData(name="test-device", pk=d.pk)
Expand Down
66 changes: 66 additions & 0 deletions openwisp_monitoring/legacy_tz_utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import logging
from functools import lru_cache
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError

from django.conf import settings

logger = logging.getLogger(__name__)

_INVALID_TIMEZONE = object()

_LEGACY_TIMEZONE_MAP = {
"Asia/Calcutta": "Asia/Kolkata",
"Asia/Saigon": "Asia/Ho_Chi_Minh",
"Asia/Katmandu": "Asia/Kathmandu",
"US/Eastern": "America/New_York",
"US/Pacific": "America/Los_Angeles",
"Europe/Kiev": "Europe/Kyiv",
"Asia/Rangoon": "Asia/Yangon",
"America/Godthab": "America/Nuuk",
"Asia/Ulan_Bator": "Asia/Ulaanbaatar",
"US/Central": "America/Chicago",
"US/Mountain": "America/Denver",
}


def normalize_timezone(tz_name):
"""
Normalize known legacy timezone aliases.

Invalid or made-up timezones are returned unchanged so the existing
pytz-based validation in the API views can still raise a 400 response.
"""
fallback = getattr(settings, "TIME_ZONE", None) or "UTC"
if not tz_name:
return fallback

if tz_name not in _LEGACY_TIMEZONE_MAP:
return tz_name

normalized_tz = _normalize_timezone_cached(tz_name)
if normalized_tz is _INVALID_TIMEZONE:
logger.warning(
f"Normalized timezone '{_LEGACY_TIMEZONE_MAP[tz_name]}' "
"not found in OS zoneinfo. "
f"Falling back to system TIME_ZONE '{fallback}'."
)
return fallback
return normalized_tz


@lru_cache(maxsize=128)
def _normalize_timezone_cached(tz_name):
"""
Cache only explicit legacy timezone normalization results.
"""
normalized_tz = _LEGACY_TIMEZONE_MAP[tz_name]

try:
ZoneInfo(normalized_tz)
if normalized_tz != tz_name:
logger.info(
f"Normalized deprecated timezone '{tz_name}' to '{normalized_tz}'"
)
return normalized_tz
except ZoneInfoNotFoundError:
return _INVALID_TIMEZONE
36 changes: 35 additions & 1 deletion openwisp_monitoring/monitoring/tests/test_api.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import uuid
from unittest.mock import patch

from django.test import TestCase
from django.test import TestCase, override_settings
from django.urls import reverse
from swapper import load_model

from openwisp_controller.config.tests.utils import CreateConfigTemplateMixin
from openwisp_controller.geo.tests.utils import TestGeoMixin
from openwisp_monitoring.legacy_tz_utils import normalize_timezone

from ..configuration import DEFAULT_DASHBOARD_TRAFFIC_CHART
from . import TestMonitoringMixin
Expand Down Expand Up @@ -679,6 +680,39 @@ def test_group_by_time(self):
response = self.client.get(path, {"time": "3w"})
self.assertEqual(response.status_code, 400)

def test_legacy_timezone_fallback(self):
"""
Pass a legacy timezone, without crashing the API
with a 500 error when OS zoneinfo is missing. fix for monitoring#728
"""
admin = self._create_admin()
self.client.force_login(admin)
path = reverse("monitoring_general:api_dashboard_timeseries")

with self.subTest(
"Test legacy timezone that isn't in modern tzdata (Asia/Calcutta)"
):
response = self.client.get(
path, {"timezone": "Asia/Calcutta", "time": "7d"}
)
self.assertEqual(response.status_code, 200)
self.assertIsInstance(response.data.get("charts"), list)

with self.subTest("Test invalid timezone still raises validation error"):
response = self.client.get(
path, {"timezone": "Antarctica/Banana", "time": "7d"}
)
self.assertEqual(response.status_code, 400)
self.assertIn("Unkown Time Zone", response.data)

def test_timezone_fallback_cache_not_polluted(self):
"""Ensure the fallback TIME_ZONE is read dynamically for empty values."""
with override_settings(TIME_ZONE="UTC"):
self.assertEqual(normalize_timezone(None), "UTC")

with override_settings(TIME_ZONE="Europe/Rome"):
self.assertEqual(normalize_timezone(None), "Europe/Rome")

def test_organizations_list(self):
path = reverse("monitoring_general:api_dashboard_timeseries")
Organization.objects.all().delete()
Expand Down
4 changes: 3 additions & 1 deletion openwisp_monitoring/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
from rest_framework.response import Response
from swapper import load_model

from .legacy_tz_utils import normalize_timezone
from .monitoring.exceptions import InvalidChartConfigException

logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -54,7 +55,8 @@ def get(self, request, *args, **kwargs):
start_date = request.query_params.get("start", None)
end_date = request.query_params.get("end", None)
# try to read timezone
timezone = request.query_params.get("timezone", settings.TIME_ZONE)
raw_timezone = request.query_params.get("timezone", settings.TIME_ZONE)
timezone = normalize_timezone(raw_timezone)
try:
tz(timezone)
except UnknownTimeZoneError:
Expand Down
Loading