722 lines
29 KiB
Python
722 lines
29 KiB
Python
from __future__ import annotations
|
|
|
|
import datetime
|
|
import logging
|
|
import re
|
|
from collections import namedtuple
|
|
from typing import TYPE_CHECKING
|
|
|
|
import pytz
|
|
import requests
|
|
from django.apps import apps
|
|
from django.db.models import Q
|
|
from django.utils import timezone
|
|
from icalendar import Calendar
|
|
|
|
from apps.schedules.ical_events import ical_events
|
|
from common.constants.role import Role
|
|
from common.utils import timed_lru_cache
|
|
|
|
"""
|
|
This is a hack to allow us to load models for type checking without circular dependencies.
|
|
This module likely needs to refactored to be part of the OnCallSchedule module.
|
|
"""
|
|
if TYPE_CHECKING:
|
|
from apps.schedules.models import OnCallSchedule
|
|
from apps.user_management.models import User
|
|
|
|
|
|
def users_in_ical(usernames_from_ical, organization, include_viewers=False):
|
|
"""
|
|
Parse ical file and return list of users found
|
|
"""
|
|
# Only grafana username will be used, consider adding grafana email and id
|
|
|
|
users_found_in_ical = organization.users
|
|
if not include_viewers:
|
|
users_found_in_ical = users_found_in_ical.filter(role__in=(Role.ADMIN, Role.EDITOR))
|
|
|
|
users_found_in_ical = users_found_in_ical.filter(
|
|
(Q(username__in=usernames_from_ical) | Q(email__in=usernames_from_ical))
|
|
).distinct()
|
|
|
|
# Here is the example how we extracted users previously, using slack fields too
|
|
# user_roles_found_in_ical = team.org_user_role.filter(role__in=[ROLE_ADMIN, ROLE_USER]).filter(
|
|
# Q(
|
|
# Q(amixr_user__slack_user_identities__slack_team_identity__amixr_team=team) &
|
|
# Q(
|
|
# Q(amixr_user__slack_user_identities__profile_display_name__in=usernames_from_ical) |
|
|
# Q(amixr_user__slack_user_identities__cached_name__in=usernames_from_ical) |
|
|
# Q(amixr_user__slack_user_identities__slack_id__in=[username.split(" ")[0] for username in
|
|
# usernames_from_ical]) |
|
|
# Q(amixr_user__slack_user_identities__cached_slack_login__in=usernames_from_ical) |
|
|
# Q(amixr_user__slack_user_identities__profile_real_name__in=usernames_from_ical)
|
|
# )
|
|
# )
|
|
# |
|
|
# Q(username__in=usernames_from_ical)
|
|
# ).annotate(is_deleted_sui=Subquery(slack_user_identity_subquery.values("deleted")[:1])).filter(
|
|
# ~Q(is_deleted_sui=True) | Q(is_deleted_sui__isnull=True)).distinct()
|
|
# return user_roles_found_in_ical
|
|
|
|
return users_found_in_ical
|
|
|
|
|
|
@timed_lru_cache(timeout=100)
|
|
def memoized_users_in_ical(usernames_from_ical, organization):
|
|
# using in-memory cache instead of redis to avoid pickling python objects
|
|
return users_in_ical(usernames_from_ical, organization)
|
|
|
|
|
|
ICAL_DATETIME_START = "DTSTART"
|
|
ICAL_DATETIME_END = "DTEND"
|
|
ICAL_SUMMARY = "SUMMARY"
|
|
ICAL_DESCRIPTION = "DESCRIPTION"
|
|
ICAL_ATTENDEE = "ATTENDEE"
|
|
ICAL_UID = "UID"
|
|
RE_PRIORITY = re.compile(r"^\[L(\d)\]")
|
|
RE_EVENT_UID_V1 = re.compile(r"amixr-([\w\d-]+)-U(\d+)-E(\d+)-S(\d+)")
|
|
RE_EVENT_UID_V2 = re.compile(r"oncall-([\w\d-]+)-PK([\w\d]+)-U(\d+)-E(\d+)-S(\d+)")
|
|
logger = logging.getLogger(__name__)
|
|
logger.setLevel(logging.DEBUG)
|
|
|
|
|
|
# used for display schedule events on web
|
|
def list_of_oncall_shifts_from_ical(
|
|
schedule, date, user_timezone="UTC", with_empty_shifts=False, with_gaps=False, days=1
|
|
):
|
|
"""
|
|
Parse the ical file and return list of events with users
|
|
This function is used in serializer for api schedules/events/ endpoint
|
|
Example output:
|
|
[
|
|
{
|
|
"start": datetime.datetime(2021, 7, 8, 5, 30, tzinfo=<UTC>,
|
|
"end": datetime.datetime(2021, 7, 8, 13, 15, tzinfo=<UTC>),
|
|
"users": <QuerySet [<User: User object (1)>]>,
|
|
"priority": 0,
|
|
"source": None,
|
|
"calendar_type": 0
|
|
}
|
|
]
|
|
"""
|
|
OnCallSchedule = apps.get_model("schedules", "OnCallSchedule")
|
|
|
|
# get list of iCalendars from current iCal files. If there is more than one calendar, primary calendar will always
|
|
# be the first
|
|
calendars = schedule.get_icalendars()
|
|
|
|
# TODO: Review offset usage
|
|
user_timezone_offset = timezone.datetime.now().astimezone(pytz.timezone(user_timezone)).utcoffset()
|
|
datetime_min = timezone.datetime.combine(date, datetime.time.min) + timezone.timedelta(milliseconds=1)
|
|
datetime_start = (datetime_min - user_timezone_offset).astimezone(pytz.UTC)
|
|
datetime_end = datetime_start + timezone.timedelta(days=days - 1, hours=23, minutes=59, seconds=59)
|
|
|
|
result_datetime = []
|
|
result_date = []
|
|
|
|
for idx, calendar in enumerate(calendars):
|
|
if calendar is not None:
|
|
if idx == 0:
|
|
calendar_type = OnCallSchedule.PRIMARY
|
|
else:
|
|
calendar_type = OnCallSchedule.OVERRIDES
|
|
|
|
tmp_result_datetime, tmp_result_date = get_shifts_dict(
|
|
calendar, calendar_type, schedule, datetime_start, datetime_end, date, with_empty_shifts
|
|
)
|
|
result_datetime.extend(tmp_result_datetime)
|
|
result_date.extend(tmp_result_date)
|
|
|
|
if with_gaps and len(result_date) == 0:
|
|
as_intervals = [DatetimeInterval(shift["start"], shift["end"]) for shift in result_datetime]
|
|
gaps = detect_gaps(as_intervals, datetime_start, datetime_end)
|
|
for g in gaps:
|
|
result_datetime.append(
|
|
{
|
|
"start": g.start if g.start else datetime_start,
|
|
"end": g.end if g.end else datetime_end,
|
|
"users": [],
|
|
"priority": None,
|
|
"source": None,
|
|
"calendar_type": None,
|
|
"is_gap": True,
|
|
"shift_pk": None,
|
|
}
|
|
)
|
|
result = sorted(result_datetime, key=lambda dt: dt["start"]) + result_date
|
|
# if there is no events, return None
|
|
return result or None
|
|
|
|
|
|
def get_shifts_dict(calendar, calendar_type, schedule, datetime_start, datetime_end, date, with_empty_shifts=False):
|
|
events = ical_events.get_events_from_ical_between(calendar, datetime_start, datetime_end)
|
|
result_datetime = []
|
|
result_date = []
|
|
for event in events:
|
|
priority = parse_priority_from_string(event.get(ICAL_SUMMARY, "[L0]"))
|
|
pk, source = parse_event_uid(event.get(ICAL_UID))
|
|
users = get_users_from_ical_event(event, schedule.organization)
|
|
# Define on-call shift out of ical event that has the actual user
|
|
if len(users) > 0 or with_empty_shifts:
|
|
if type(event[ICAL_DATETIME_START].dt) == datetime.date:
|
|
start = event[ICAL_DATETIME_START].dt
|
|
end = event[ICAL_DATETIME_END].dt
|
|
if start <= date < end:
|
|
result_date.append(
|
|
{
|
|
"start": start,
|
|
"end": end,
|
|
"users": users,
|
|
"priority": priority,
|
|
"source": source,
|
|
"calendar_type": calendar_type,
|
|
"shift_pk": pk,
|
|
}
|
|
)
|
|
else:
|
|
start = event[ICAL_DATETIME_START].dt.astimezone(pytz.UTC)
|
|
end = event[ICAL_DATETIME_END].dt.astimezone(pytz.UTC)
|
|
|
|
result_datetime.append(
|
|
{
|
|
"start": start,
|
|
"end": end,
|
|
"users": users,
|
|
"priority": priority,
|
|
"source": source,
|
|
"calendar_type": calendar_type,
|
|
"shift_pk": pk,
|
|
}
|
|
)
|
|
return result_datetime, result_date
|
|
|
|
|
|
EmptyShift = namedtuple(
|
|
"EmptyShift",
|
|
["start", "end", "summary", "description", "attendee", "all_day", "calendar_type", "calendar_tz", "shift_pk"],
|
|
)
|
|
|
|
|
|
def list_of_empty_shifts_in_schedule(schedule, start_date, end_date):
|
|
"""
|
|
Parse the ical file and return list of EmptyShift.
|
|
"""
|
|
# Calculate lookup window in schedule's tz
|
|
# If we can't get tz from ical use UTC
|
|
OnCallSchedule = apps.get_model("schedules", "OnCallSchedule")
|
|
|
|
calendars = schedule.get_icalendars()
|
|
empty_shifts = []
|
|
for idx, calendar in enumerate(calendars):
|
|
if calendar is not None:
|
|
if idx == 0:
|
|
calendar_type = OnCallSchedule.PRIMARY
|
|
else:
|
|
calendar_type = OnCallSchedule.OVERRIDES
|
|
|
|
calendar_tz = get_icalendar_tz_or_utc(calendar)
|
|
|
|
schedule_timezone_offset = timezone.datetime.now().astimezone(calendar_tz).utcoffset()
|
|
start_datetime = timezone.datetime.combine(start_date, datetime.time.min) + timezone.timedelta(
|
|
milliseconds=1
|
|
)
|
|
start_datetime_with_offset = (start_datetime - schedule_timezone_offset).astimezone(pytz.UTC)
|
|
end_datetime = timezone.datetime.combine(end_date, datetime.time.max)
|
|
end_datetime_with_offset = (end_datetime - schedule_timezone_offset).astimezone(pytz.UTC)
|
|
|
|
events = ical_events.get_events_from_ical_between(
|
|
calendar, start_datetime_with_offset, end_datetime_with_offset
|
|
)
|
|
|
|
# Keep hashes of checked events to include only first recurrent event into result
|
|
checked_events = set()
|
|
empty_shifts_per_calendar = []
|
|
for event in events:
|
|
users = get_users_from_ical_event(event, schedule.organization)
|
|
if len(users) == 0:
|
|
summary = event.get(ICAL_SUMMARY, "")
|
|
description = event.get(ICAL_DESCRIPTION, "")
|
|
attendee = event.get(ICAL_ATTENDEE, "")
|
|
pk, _ = parse_event_uid(event.get(ICAL_UID))
|
|
|
|
event_hash = hash(f"{event[ICAL_UID]}{summary}{description}{attendee}")
|
|
if event_hash in checked_events:
|
|
continue
|
|
|
|
checked_events.add(event_hash)
|
|
|
|
all_day = False
|
|
if type(event[ICAL_DATETIME_START].dt) == datetime.date:
|
|
# Convert all-day events start and end from date to datetime with calendar's tz
|
|
start, _ = ical_date_to_datetime(event["DTSTART"].dt, calendar_tz, start=True)
|
|
end, _ = ical_date_to_datetime(event["DTEND"].dt, calendar_tz, start=False)
|
|
all_day = True
|
|
else:
|
|
start = event[ICAL_DATETIME_START].dt.astimezone(pytz.UTC)
|
|
end = event[ICAL_DATETIME_END].dt.astimezone(pytz.UTC)
|
|
|
|
empty_shifts_per_calendar.append(
|
|
EmptyShift(
|
|
start=start,
|
|
end=end,
|
|
summary=summary,
|
|
description=description,
|
|
attendee=attendee,
|
|
all_day=all_day,
|
|
calendar_type=calendar_type,
|
|
calendar_tz=calendar_tz,
|
|
shift_pk=pk,
|
|
)
|
|
)
|
|
empty_shifts.extend(empty_shifts_per_calendar)
|
|
|
|
return sorted(empty_shifts, key=lambda dt: dt.start)
|
|
|
|
|
|
def list_users_to_notify_from_ical(schedule, events_datetime=None, include_viewers=False):
|
|
"""
|
|
Retrieve on-call users for the current time
|
|
"""
|
|
events_datetime = events_datetime if events_datetime else timezone.datetime.now(timezone.utc)
|
|
return list_users_to_notify_from_ical_for_period(
|
|
schedule, events_datetime, events_datetime, include_viewers=include_viewers
|
|
)
|
|
|
|
|
|
def list_users_to_notify_from_ical_for_period(schedule, start_datetime, end_datetime, include_viewers=False):
|
|
# get list of iCalendars from current iCal files. If there is more than one calendar, primary calendar will always
|
|
# be the first
|
|
calendars = schedule.get_icalendars()
|
|
# reverse calendars to make overrides calendar the first, if schedule is iCal
|
|
calendars = calendars[::-1]
|
|
users_found_in_ical = []
|
|
# at first check overrides calendar and return users from it if it exists and on-call users are found
|
|
for calendar in calendars:
|
|
if calendar is None:
|
|
continue
|
|
events = ical_events.get_events_from_ical_between(calendar, start_datetime, end_datetime)
|
|
parsed_ical_events = {} # event info where key is event priority and value list of found usernames {0:["alex"]}
|
|
for event in events:
|
|
current_usernames, current_priority = get_usernames_from_ical_event(event)
|
|
parsed_ical_events.setdefault(current_priority, []).extend(current_usernames)
|
|
# find users by usernames. if users are not found for shift, get users from lower priority
|
|
for _, usernames in sorted(parsed_ical_events.items(), reverse=True):
|
|
users_found_in_ical = users_in_ical(usernames, schedule.organization, include_viewers=include_viewers)
|
|
if users_found_in_ical:
|
|
break
|
|
if users_found_in_ical:
|
|
# if users are found in the overrides calendar, there is no need to check primary calendar
|
|
break
|
|
return users_found_in_ical
|
|
|
|
|
|
def parse_username_from_string(string):
|
|
"""
|
|
Parse on-call shift user from the given string
|
|
Example input:
|
|
[L1] bob@company.com
|
|
Example output:
|
|
bob@company.com
|
|
"""
|
|
return re.sub(RE_PRIORITY, "", string.strip(), 1).strip()
|
|
|
|
|
|
def parse_priority_from_string(string):
|
|
"""
|
|
Parse on-call shift priority from the given string
|
|
Example input:
|
|
[L1] @alex @bob
|
|
Example output:
|
|
1
|
|
"""
|
|
priority = re.findall(RE_PRIORITY, string.strip())
|
|
if len(priority) > 0:
|
|
priority = int(priority[0])
|
|
if priority < 1:
|
|
priority = 0
|
|
else:
|
|
priority = 0
|
|
return priority
|
|
|
|
|
|
def parse_event_uid(string):
|
|
pk = None
|
|
source = None
|
|
source_verbal = None
|
|
|
|
match = RE_EVENT_UID_V2.match(string)
|
|
if match:
|
|
_, pk, _, _, source = match.groups()
|
|
else:
|
|
# eventually this path would be automatically deprecated
|
|
# once all ical representations are refreshed
|
|
match = RE_EVENT_UID_V1.match(string)
|
|
if match:
|
|
_, _, _, source = match.groups()
|
|
|
|
if source is not None:
|
|
source = int(source)
|
|
CustomOnCallShift = apps.get_model("schedules", "CustomOnCallShift")
|
|
source_verbal = CustomOnCallShift.SOURCE_CHOICES[source][1]
|
|
|
|
return pk, source_verbal
|
|
|
|
|
|
def get_usernames_from_ical_event(event):
|
|
usernames_found = []
|
|
priority = parse_priority_from_string(event.get(ICAL_SUMMARY, "[L0]"))
|
|
if ICAL_SUMMARY in event:
|
|
usernames_found.append(parse_username_from_string(event[ICAL_SUMMARY]))
|
|
if ICAL_DESCRIPTION in event:
|
|
usernames_found.append(parse_username_from_string(event[ICAL_DESCRIPTION]))
|
|
if ICAL_ATTENDEE in event:
|
|
if isinstance(event[ICAL_ATTENDEE], str):
|
|
# PagerDuty adds only one attendee and in this case event[ICAL_ATTENDEE] is string.
|
|
# If several attendees were added to the event than event[ICAL_ATTENDEE] will be list
|
|
# (E.g. several invited in Google cal).
|
|
usernames_found.append(parse_username_from_string(event[ICAL_ATTENDEE]))
|
|
return usernames_found, priority
|
|
|
|
|
|
def get_users_from_ical_event(event, organization):
|
|
usernames_from_ical, _ = get_usernames_from_ical_event(event)
|
|
users = []
|
|
if len(usernames_from_ical) != 0:
|
|
users = memoized_users_in_ical(tuple(usernames_from_ical), organization)
|
|
return users
|
|
|
|
|
|
def is_icals_equal(first, second):
|
|
first = first.split("\n")
|
|
second = second.split("\n")
|
|
if len(first) != len(second):
|
|
return False
|
|
else:
|
|
for idx, first_item in enumerate(first):
|
|
if first_item.startswith("DTSTAMP"):
|
|
continue
|
|
else:
|
|
second_item = second[idx]
|
|
if first_item != second_item:
|
|
return False
|
|
|
|
return True
|
|
|
|
|
|
def ical_date_to_datetime(date, tz, start):
|
|
datetime_to_combine = datetime.time.min
|
|
all_day = False
|
|
if type(date) == datetime.date:
|
|
all_day = True
|
|
calendar_timezone_offset = timezone.datetime.now().astimezone(tz).utcoffset()
|
|
date = timezone.datetime.combine(date, datetime_to_combine).astimezone(tz) - calendar_timezone_offset
|
|
if not start:
|
|
date -= timezone.timedelta(seconds=1)
|
|
return date, all_day
|
|
|
|
|
|
def calculate_shift_diff(first_shift, second_shift):
|
|
fields_to_compare = ["users", "end", "start", "all_day", "priority"]
|
|
|
|
shift_changed = set(first_shift.keys()) != set(second_shift.keys())
|
|
if not shift_changed:
|
|
diff = set()
|
|
for k, v in first_shift.items():
|
|
for f in fields_to_compare:
|
|
if v.get(f) != second_shift[k].get(f):
|
|
shift_changed = True
|
|
diff.add(k)
|
|
break
|
|
else:
|
|
diff = set(first_shift.keys()) - set(second_shift.keys())
|
|
|
|
return shift_changed, diff
|
|
|
|
|
|
def get_icalendar_tz_or_utc(icalendar):
|
|
try:
|
|
calendar_timezone = icalendar.walk("VTIMEZONE")[0]["TZID"]
|
|
except (IndexError, KeyError):
|
|
calendar_timezone = "UTC"
|
|
|
|
try:
|
|
return pytz.timezone(calendar_timezone)
|
|
except pytz.UnknownTimeZoneError:
|
|
# try to convert the timezone from windows to iana
|
|
converted_timezone = convert_windows_timezone_to_iana(calendar_timezone)
|
|
if converted_timezone is None:
|
|
return "UTC"
|
|
return pytz.timezone(converted_timezone)
|
|
|
|
|
|
def fetch_ical_file_or_get_error(ical_url):
|
|
cached_ical_file = None
|
|
ical_file_error = None
|
|
try:
|
|
new_ical_file = requests.get(ical_url, timeout=10).text
|
|
Calendar.from_ical(new_ical_file)
|
|
cached_ical_file = new_ical_file
|
|
except requests.exceptions.RequestException:
|
|
ical_file_error = "iCal download failed"
|
|
except ValueError:
|
|
ical_file_error = "wrong iCal"
|
|
# TODO: catch icalendar exceptions
|
|
return cached_ical_file, ical_file_error
|
|
|
|
|
|
def create_base_icalendar(name: str) -> Calendar:
|
|
cal = Calendar()
|
|
cal.add("calscale", "GREGORIAN")
|
|
cal.add("x-wr-calname", name)
|
|
cal.add("x-wr-timezone", "UTC")
|
|
cal.add("prodid", "//Grafana Labs//Grafana On-Call//")
|
|
|
|
return cal
|
|
|
|
|
|
def get_events_from_calendars(ical_obj: Calendar, calendars: tuple) -> None:
|
|
for calendar in calendars:
|
|
if calendar:
|
|
for component in calendar.walk():
|
|
if component.name == "VEVENT":
|
|
ical_obj.add_component(component)
|
|
|
|
|
|
def get_user_events_from_calendars(ical_obj: Calendar, calendars: tuple, user: User) -> None:
|
|
for calendar in calendars:
|
|
if calendar:
|
|
for component in calendar.walk():
|
|
if component.name == "VEVENT":
|
|
event_user = get_usernames_from_ical_event(component)
|
|
if event_user[0][0] in [user.username, user.email]:
|
|
ical_obj.add_component(component)
|
|
|
|
|
|
def ical_export_from_schedule(schedule: OnCallSchedule) -> bytes:
|
|
calendars = schedule.get_icalendars()
|
|
ical_obj = create_base_icalendar(schedule.name)
|
|
get_events_from_calendars(ical_obj, calendars)
|
|
return ical_obj.to_ical()
|
|
|
|
|
|
def user_ical_export(user: User, schedules: list[OnCallSchedule]) -> bytes:
|
|
schedule_name = "On-Call Schedule for {0}".format(user.username)
|
|
ical_obj = create_base_icalendar(schedule_name)
|
|
|
|
for schedule in schedules:
|
|
calendars = schedule.get_icalendars()
|
|
get_user_events_from_calendars(ical_obj, calendars, user)
|
|
|
|
return ical_obj.to_ical()
|
|
|
|
|
|
DatetimeInterval = namedtuple("DatetimeInterval", ["start", "end"])
|
|
|
|
|
|
def list_of_gaps_in_schedule(schedule, start_date, end_date):
|
|
calendars = schedule.get_icalendars()
|
|
intervals = []
|
|
start_datetime = timezone.datetime.combine(start_date, datetime.time.min) + timezone.timedelta(milliseconds=1)
|
|
start_datetime = start_datetime.astimezone(pytz.UTC)
|
|
end_datetime = timezone.datetime.combine(end_date, datetime.time.max).astimezone(pytz.UTC)
|
|
|
|
for idx, calendar in enumerate(calendars):
|
|
if calendar is not None:
|
|
calendar_tz = get_icalendar_tz_or_utc(calendar)
|
|
events = ical_events.get_events_from_ical_between(
|
|
calendar,
|
|
start_datetime,
|
|
end_datetime,
|
|
)
|
|
for event in events:
|
|
start, end = start_end_with_respect_to_all_day(event, calendar_tz)
|
|
intervals.append(DatetimeInterval(start, end))
|
|
return detect_gaps(intervals, start_datetime, end_datetime)
|
|
|
|
|
|
def detect_gaps(intervals, start, end):
|
|
gaps = []
|
|
intervals = sorted(intervals, key=lambda dt: dt.start)
|
|
if len(intervals) > 0:
|
|
base_interval = intervals[0]
|
|
if base_interval.start > start:
|
|
gaps.append(DatetimeInterval(None, base_interval.start))
|
|
for interval in intervals[1:]:
|
|
overlaps, new_base_interval = merge_if_overlaps(base_interval, interval)
|
|
if not overlaps:
|
|
gaps.append(DatetimeInterval(base_interval.end, interval.start))
|
|
base_interval = new_base_interval
|
|
if base_interval.end < end:
|
|
gaps.append(DatetimeInterval(base_interval.end, None))
|
|
return gaps
|
|
|
|
|
|
def merge_if_overlaps(a: DatetimeInterval, b: DatetimeInterval):
|
|
if a.end >= b.end:
|
|
return True, DatetimeInterval(a.start, a.end)
|
|
if b.start - a.end < datetime.timedelta(minutes=1):
|
|
return True, DatetimeInterval(a.start, b.end)
|
|
else:
|
|
return False, DatetimeInterval(b.start, b.end)
|
|
|
|
|
|
def start_end_with_respect_to_all_day(event, calendar_tz):
|
|
start, _ = ical_date_to_datetime(event[ICAL_DATETIME_START].dt, calendar_tz, start=True)
|
|
end, _ = ical_date_to_datetime(event[ICAL_DATETIME_END].dt, calendar_tz, start=False)
|
|
return start, end
|
|
|
|
|
|
def convert_windows_timezone_to_iana(tz_name):
|
|
"""
|
|
Conversion info taken from https://raw.githubusercontent.com/unicode-org/cldr/main/common/supplemental/windowsZones.xml
|
|
Also see https://gist.github.com/mrled/8d29fde758cfc7dd0b52f3bbf2b8f06e
|
|
NOTE: This mapping could be updated, and it's technically a guess.
|
|
Also, there could probably be issues with DST for some timezones.
|
|
"""
|
|
windows_to_iana_map = {
|
|
"AUS Central Standard Time": "Australia/Darwin",
|
|
"AUS Eastern Standard Time": "Australia/Sydney",
|
|
"Afghanistan Standard Time": "Asia/Kabul",
|
|
"Alaskan Standard Time": "America/Anchorage",
|
|
"Aleutian Standard Time": "America/Adak",
|
|
"Altai Standard Time": "Asia/Barnaul",
|
|
"Arab Standard Time": "Asia/Riyadh",
|
|
"Arabian Standard Time": "Asia/Dubai",
|
|
"Arabic Standard Time": "Asia/Baghdad",
|
|
"Argentina Standard Time": "America/Buenos_Aires",
|
|
"Astrakhan Standard Time": "Europe/Astrakhan",
|
|
"Atlantic Standard Time": "America/Halifax",
|
|
"Aus Central W. Standard Time": "Australia/Eucla",
|
|
"Azerbaijan Standard Time": "Asia/Baku",
|
|
"Azores Standard Time": "Atlantic/Azores",
|
|
"Bahia Standard Time": "America/Bahia",
|
|
"Bangladesh Standard Time": "Asia/Dhaka",
|
|
"Belarus Standard Time": "Europe/Minsk",
|
|
"Bougainville Standard Time": "Pacific/Bougainville",
|
|
"Canada Central Standard Time": "America/Regina",
|
|
"Cape Verde Standard Time": "Atlantic/Cape_Verde",
|
|
"Caucasus Standard Time": "Asia/Yerevan",
|
|
"Cen. Australia Standard Time": "Australia/Adelaide",
|
|
"Central America Standard Time": "America/Guatemala",
|
|
"Central Asia Standard Time": "Asia/Almaty",
|
|
"Central Brazilian Standard Time": "America/Cuiaba",
|
|
"Central Europe Standard Time": "Europe/Budapest",
|
|
"Central European Standard Time": "Europe/Warsaw",
|
|
"Central Pacific Standard Time": "Pacific/Guadalcanal",
|
|
"Central Standard Time": "America/Chicago",
|
|
"Central Standard Time (Mexico)": "America/Mexico_City",
|
|
"Chatham Islands Standard Time": "Pacific/Chatham",
|
|
"China Standard Time": "Asia/Shanghai",
|
|
"Cuba Standard Time": "America/Havana",
|
|
"Dateline Standard Time": "Etc/GMT+12",
|
|
"E. Africa Standard Time": "Africa/Nairobi",
|
|
"E. Australia Standard Time": "Australia/Brisbane",
|
|
"E. Europe Standard Time": "Europe/Chisinau",
|
|
"E. South America Standard Time": "America/Sao_Paulo",
|
|
"Easter Island Standard Time": "Pacific/Easter",
|
|
"Eastern Standard Time": "America/New_York",
|
|
"Eastern Standard Time (Mexico)": "America/Cancun",
|
|
"Egypt Standard Time": "Africa/Cairo",
|
|
"Ekaterinburg Standard Time": "Asia/Yekaterinburg",
|
|
"FLE Standard Time": "Europe/Kiev",
|
|
"Fiji Standard Time": "Pacific/Fiji",
|
|
"GMT Standard Time": "Europe/London",
|
|
"GTB Standard Time": "Europe/Bucharest",
|
|
"Georgian Standard Time": "Asia/Tbilisi",
|
|
"Greenland Standard Time": "America/Godthab",
|
|
"Greenwich Standard Time": "Atlantic/Reykjavik",
|
|
"Haiti Standard Time": "America/Port-au-Prince",
|
|
"Hawaiian Standard Time": "Pacific/Honolulu",
|
|
"India Standard Time": "Asia/Calcutta",
|
|
"Iran Standard Time": "Asia/Tehran",
|
|
"Israel Standard Time": "Asia/Jerusalem",
|
|
"Jordan Standard Time": "Asia/Amman",
|
|
"Kaliningrad Standard Time": "Europe/Kaliningrad",
|
|
"Korea Standard Time": "Asia/Seoul",
|
|
"Libya Standard Time": "Africa/Tripoli",
|
|
"Line Islands Standard Time": "Pacific/Kiritimati",
|
|
"Lord Howe Standard Time": "Australia/Lord_Howe",
|
|
"Magadan Standard Time": "Asia/Magadan",
|
|
"Magallanes Standard Time": "America/Punta_Arenas",
|
|
"Marquesas Standard Time": "Pacific/Marquesas",
|
|
"Mauritius Standard Time": "Indian/Mauritius",
|
|
"Middle East Standard Time": "Asia/Beirut",
|
|
"Montevideo Standard Time": "America/Montevideo",
|
|
"Morocco Standard Time": "Africa/Casablanca",
|
|
"Mountain Standard Time": "America/Denver",
|
|
"Mountain Standard Time (Mexico)": "America/Chihuahua",
|
|
"Myanmar Standard Time": "Asia/Rangoon",
|
|
"N. Central Asia Standard Time": "Asia/Novosibirsk",
|
|
"Namibia Standard Time": "Africa/Windhoek",
|
|
"Nepal Standard Time": "Asia/Katmandu",
|
|
"New Zealand Standard Time": "Pacific/Auckland",
|
|
"Newfoundland Standard Time": "America/St_Johns",
|
|
"Norfolk Standard Time": "Pacific/Norfolk",
|
|
"North Asia East Standard Time": "Asia/Irkutsk",
|
|
"North Asia Standard Time": "Asia/Krasnoyarsk",
|
|
"North Korea Standard Time": "Asia/Pyongyang",
|
|
"Omsk Standard Time": "Asia/Omsk",
|
|
"Pacific SA Standard Time": "America/Santiago",
|
|
"Pacific Standard Time": "America/Los_Angeles",
|
|
"Pacific Standard Time (Mexico)": "America/Tijuana",
|
|
"Pakistan Standard Time": "Asia/Karachi",
|
|
"Paraguay Standard Time": "America/Asuncion",
|
|
"Qyzylorda Standard Time": "Asia/Qyzylorda",
|
|
"Romance Standard Time": "Europe/Paris",
|
|
"Russia Time Zone 10": "Asia/Srednekolymsk",
|
|
"Russia Time Zone 11": "Asia/Kamchatka",
|
|
"Russia Time Zone 3": "Europe/Samara",
|
|
"Russian Standard Time": "Europe/Moscow",
|
|
"SA Eastern Standard Time": "America/Cayenne",
|
|
"SA Pacific Standard Time": "America/Bogota",
|
|
"SA Western Standard Time": "America/La_Paz",
|
|
"SE Asia Standard Time": "Asia/Bangkok",
|
|
"Saint Pierre Standard Time": "America/Miquelon",
|
|
"Sakhalin Standard Time": "Asia/Sakhalin",
|
|
"Samoa Standard Time": "Pacific/Apia",
|
|
"Sao Tome Standard Time": "Africa/Sao_Tome",
|
|
"Saratov Standard Time": "Europe/Saratov",
|
|
"Singapore Standard Time": "Asia/Singapore",
|
|
"South Africa Standard Time": "Africa/Johannesburg",
|
|
"South Sudan Standard Time": "Africa/Juba",
|
|
"Sri Lanka Standard Time": "Asia/Colombo",
|
|
"Sudan Standard Time": "Africa/Khartoum",
|
|
"Syria Standard Time": "Asia/Damascus",
|
|
"Taipei Standard Time": "Asia/Taipei",
|
|
"Tasmania Standard Time": "Australia/Hobart",
|
|
"Tocantins Standard Time": "America/Araguaina",
|
|
"Tokyo Standard Time": "Asia/Tokyo",
|
|
"Tomsk Standard Time": "Asia/Tomsk",
|
|
"Tonga Standard Time": "Pacific/Tongatapu",
|
|
"Transbaikal Standard Time": "Asia/Chita",
|
|
"Turkey Standard Time": "Europe/Istanbul",
|
|
"Turks And Caicos Standard Time": "America/Grand_Turk",
|
|
"US Eastern Standard Time": "America/Indianapolis",
|
|
"US Mountain Standard Time": "America/Phoenix",
|
|
"UTC": "Etc/UTC",
|
|
"UTC+12": "Etc/GMT-12",
|
|
"UTC+13": "Etc/GMT-13",
|
|
"UTC-02": "Etc/GMT+2",
|
|
"UTC-08": "Etc/GMT+8",
|
|
"UTC-09": "Etc/GMT+9",
|
|
"UTC-11": "Etc/GMT+11",
|
|
"Ulaanbaatar Standard Time": "Asia/Ulaanbaatar",
|
|
"Venezuela Standard Time": "America/Caracas",
|
|
"Vladivostok Standard Time": "Asia/Vladivostok",
|
|
"Volgograd Standard Time": "Europe/Volgograd",
|
|
"W. Australia Standard Time": "Australia/Perth",
|
|
"W. Central Africa Standard Time": "Africa/Lagos",
|
|
"W. Europe Standard Time": "Europe/Berlin",
|
|
"W. Mongolia Standard Time": "Asia/Hovd",
|
|
"West Asia Standard Time": "Asia/Tashkent",
|
|
"West Bank Standard Time": "Asia/Hebron",
|
|
"West Pacific Standard Time": "Pacific/Port_Moresby",
|
|
"Yakutsk Standard Time": "Asia/Yakutsk",
|
|
"Yukon Standard Time": "America/Whitehorse",
|
|
}
|
|
|
|
result = windows_to_iana_map.get(tz_name)
|
|
logger.debug("Converting the timezone from Windows to IANA. '{}' -> '{}'".format(tz_name, result))
|
|
|
|
return result
|