2022-06-03 08:09:47 -06:00
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
|
|
import datetime
|
|
|
|
|
|
import logging
|
|
|
|
|
|
import re
|
2023-01-25 11:08:09 +01:00
|
|
|
|
import typing
|
2022-06-03 08:09:47 -06:00
|
|
|
|
from collections import namedtuple
|
|
|
|
|
|
from typing import TYPE_CHECKING
|
|
|
|
|
|
|
|
|
|
|
|
import pytz
|
|
|
|
|
|
import requests
|
2023-11-06 15:30:32 -05:00
|
|
|
|
from django.core.cache import cache
|
2022-06-03 08:09:47 -06:00
|
|
|
|
from django.db.models import Q
|
|
|
|
|
|
from icalendar import Calendar
|
2023-06-27 12:23:08 +02:00
|
|
|
|
from icalendar import Event as IcalEvent
|
2022-06-03 08:09:47 -06:00
|
|
|
|
|
2022-11-29 09:41:56 +01:00
|
|
|
|
from apps.api.permissions import RBACPermission
|
2022-08-16 15:38:52 +03:00
|
|
|
|
from apps.schedules.constants import (
|
2023-05-08 16:01:24 -03:00
|
|
|
|
CALENDAR_TYPE_FINAL,
|
2022-08-16 15:38:52 +03:00
|
|
|
|
ICAL_ATTENDEE,
|
|
|
|
|
|
ICAL_DATETIME_END,
|
|
|
|
|
|
ICAL_DATETIME_START,
|
|
|
|
|
|
ICAL_DESCRIPTION,
|
2023-05-08 16:01:24 -03:00
|
|
|
|
ICAL_LOCATION,
|
2023-08-24 12:36:42 -03:00
|
|
|
|
ICAL_PRIORITY,
|
2023-08-07 10:11:46 -03:00
|
|
|
|
ICAL_RECURRENCE_ID,
|
|
|
|
|
|
ICAL_SEQUENCE,
|
2023-08-11 12:14:15 -03:00
|
|
|
|
ICAL_STATUS,
|
|
|
|
|
|
ICAL_STATUS_CANCELLED,
|
2022-08-16 15:38:52 +03:00
|
|
|
|
ICAL_SUMMARY,
|
|
|
|
|
|
ICAL_UID,
|
2023-09-18 14:49:21 -03:00
|
|
|
|
RE_EVENT_UID_EXPORT,
|
2022-08-16 15:38:52 +03:00
|
|
|
|
RE_EVENT_UID_V1,
|
|
|
|
|
|
RE_EVENT_UID_V2,
|
|
|
|
|
|
RE_PRIORITY,
|
2024-01-17 13:30:11 -03:00
|
|
|
|
SCHEDULE_ONCALL_CACHE_KEY_PREFIX,
|
|
|
|
|
|
SCHEDULE_ONCALL_CACHE_TTL,
|
2022-08-16 15:38:52 +03:00
|
|
|
|
)
|
2022-06-03 08:09:47 -06:00
|
|
|
|
from apps.schedules.ical_events import ical_events
|
2023-12-04 13:08:57 -05:00
|
|
|
|
from common.cache import ensure_cache_key_allocates_to_the_same_hash_slot
|
2022-12-01 14:13:39 +01:00
|
|
|
|
from common.timezones import is_valid_timezone
|
2022-06-03 08:09:47 -06:00
|
|
|
|
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:
|
2023-07-25 10:43:23 +01:00
|
|
|
|
from apps.schedules.models import OnCallSchedule
|
2023-06-27 12:23:08 +02:00
|
|
|
|
from apps.schedules.models.on_call_schedule import OnCallScheduleQuerySet
|
2023-01-25 11:08:09 +01:00
|
|
|
|
from apps.user_management.models import Organization, User
|
|
|
|
|
|
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
logger.setLevel(logging.DEBUG)
|
2022-06-03 08:09:47 -06:00
|
|
|
|
|
|
|
|
|
|
|
2025-01-02 10:50:09 -03:00
|
|
|
|
class MissingUser:
|
|
|
|
|
|
"""Represent a missing user in a rolling users shift."""
|
|
|
|
|
|
|
|
|
|
|
|
DISPLAY_NAME = "(missing)"
|
|
|
|
|
|
|
|
|
|
|
|
def __init__(self, pk):
|
|
|
|
|
|
self.pk = pk
|
|
|
|
|
|
|
|
|
|
|
|
@property
|
|
|
|
|
|
def username(self):
|
|
|
|
|
|
return self.DISPLAY_NAME
|
|
|
|
|
|
|
|
|
|
|
|
|
2023-06-27 12:23:08 +02:00
|
|
|
|
EmptyShift = namedtuple(
|
|
|
|
|
|
"EmptyShift",
|
|
|
|
|
|
["start", "end", "summary", "description", "attendee", "all_day", "calendar_type", "calendar_tz", "shift_pk"],
|
|
|
|
|
|
)
|
|
|
|
|
|
EmptyShifts = typing.List[EmptyShift]
|
|
|
|
|
|
|
|
|
|
|
|
DatetimeInterval = namedtuple("DatetimeInterval", ["start", "end"])
|
|
|
|
|
|
DatetimeIntervals = typing.List[DatetimeInterval]
|
|
|
|
|
|
|
|
|
|
|
|
IcalEvents = typing.List[IcalEvent]
|
Add responders improvements (#3128)
# What this PR does
https://www.loom.com/share/c5e10b5ec51343d0954c6f41cfd6a5fb
## Summary of backend changes
- Add `AlertReceiveChannel.get_orgs_direct_paging_integrations` method
and `AlertReceiveChannel.is_contactable` property. These are needed to
be able to (optionally) filter down teams, in the `GET /teams` internal
API endpoint
([here](https://github.com/grafana/oncall/pull/3128/files#diff-a4bd76e557f7e11dafb28a52c1034c075028c693b3c12d702d53c07fc6f24c05R55-R63)),
to just teams that have a "contactable" Direct Paging integration
- `engine/apps/alerts/paging.py`
- update these functions to support new UX. In short `direct_paging` no
longer takes a list of `ScheduleNotifications` or an `EscalationChain`
object
- add `user_is_oncall` helper function
- add `_construct_title` helper function. In short if no `title` is
provided, which is the case for Direct Pages originating from OnCall
(either UI or Slack), then the format is `f"{from_user.username} is
paging <team.name (if team is specified> <comma separated list of
user.usernames> to join escalation"`
- `engine/apps/api/serializers/team.py` - add
`number_of_users_currently_oncall` attribute to response schema
([code](https://github.com/grafana/oncall/pull/3128/files#diff-26af48f796c9e987a76447586dd0f92349783d6ea6a0b6039a2f0f28bd58c2ebR45-R52))
- `engine/apps/api/serializers/user.py` - add `is_currently_oncall`
attribute to response schema
([code](https://github.com/grafana/oncall/pull/3128/files#diff-6744b5544ebb120437af98a996da5ad7d48ee1139a6112c7e3904010ab98f232R157-R162))
- `engine/apps/api/views/team.py` - add support for two new optional
query params `only_include_notifiable_teams` and `include_no_team`
([code](https://github.com/grafana/oncall/pull/3128/files#diff-a4bd76e557f7e11dafb28a52c1034c075028c693b3c12d702d53c07fc6f24c05R55-R70))
- `engine/apps/api/views/user.py`
- in the `GET /users` internal API endpoint, when specifying the
`search` query param now also search on `teams__name`
([code](https://github.com/grafana/oncall/pull/3128/files#diff-30309629484ad28e6fe09816e1bd226226d652ea977b6f3b6775976c729bf4b5R223);
this is a new UX requirement)
- add support for a new optional query param, `is_currently_oncall`, to
allow filtering users based on.. whether they are currently on call or
not
([code](https://github.com/grafana/oncall/pull/3128/files#diff-30309629484ad28e6fe09816e1bd226226d652ea977b6f3b6775976c729bf4b5R272-R282))
- remove `check_availability` endpoint (no longer used with new UX; also
removed references in frontend code)
- `engine/apps/slack/scenarios/paging.py` and
`engine/apps/slack/scenarios/manage_responders.py` - update Slack
workflows to support new UX. Schedules are no longer a concept here.
When creating a new alert group via `/escalate` the user either
specifies a team and/or user(s) (they must specify at least one of the
two and validation is done here to check this). When adding responders
to an existing alert group it's simply a list of users that they can
add, no more schedules.
- add `Organization.slack_is_configured` and
`Organization.telegram_is_configured` properties. These are needed to
support [this new functionality
](https://github.com/grafana/oncall/pull/3128/files#diff-9d96504027309f2bd1e95352bac1433b09b60eb4fafb611b52a6c15ed16cbc48R271-R272)
in the `AlertReceiveChannel` model.
## Summary of frontend changes
- Refactor/rename `EscalationVariants` component to `AddResponders` +
remove `grafana-plugin/src/containers/UserWarningModal` (no longer
needed with new UX)
- Remove `grafana-plugin/src/models/user.ts` as it seemed to be a
duplicate of `grafana-plugin/src/models/user/user.types.ts`
Related to https://github.com/grafana/incident/issues/4278
- Closes #3115
- Closes #3116
- Closes #3117
- Closes #3118
- Closes #3177
## TODO
- [x] make frontend changes
- [x] update Slack backend functionality
- [x] update public documentation
- [x] add/update e2e tests
## Post-deploy To-dos
- [ ] update dev/ops/production Slack bots to update `/escalate` command
description (should now say "Direct page a team or user(s)")
## Checklist
- [x] Unit, integration, and e2e (if applicable) tests updated
- [x] Documentation added (or `pr:no public docs` PR label added if not
required)
- [x] `CHANGELOG.md` updated (or `pr:no changelog` PR label added if not
required)
2023-10-27 12:12:07 -04:00
|
|
|
|
SchedulesOnCallUsers = typing.Dict["OnCallSchedule", typing.List["User"]]
|
2023-06-27 12:23:08 +02:00
|
|
|
|
|
|
|
|
|
|
|
2023-01-25 11:08:09 +01:00
|
|
|
|
def users_in_ical(
|
|
|
|
|
|
usernames_from_ical: typing.List[str],
|
2023-06-27 12:23:08 +02:00
|
|
|
|
organization: "Organization",
|
2023-09-27 09:35:52 -03:00
|
|
|
|
) -> typing.List["User"]:
|
2022-06-03 08:09:47 -06:00
|
|
|
|
"""
|
2024-10-10 15:02:21 -04:00
|
|
|
|
This method returns a list of `User` objects, filtered by users whose username, or case-insensitive e-mail,
|
2023-09-27 09:35:52 -03:00
|
|
|
|
is present in `usernames_from_ical`.
|
2023-01-25 11:08:09 +01:00
|
|
|
|
|
2024-10-10 15:02:21 -04:00
|
|
|
|
Additionally, it filters the users by the organization they belong to and checks if they have the required
|
|
|
|
|
|
permission to receive notifications.
|
|
|
|
|
|
|
2023-01-25 11:08:09 +01:00
|
|
|
|
Parameters
|
|
|
|
|
|
----------
|
|
|
|
|
|
usernames_from_ical : typing.List[str]
|
|
|
|
|
|
A list of usernames present in the ical feed
|
|
|
|
|
|
organization : apps.user_management.models.organization.Organization
|
|
|
|
|
|
The organization in question
|
2022-06-03 08:09:47 -06:00
|
|
|
|
"""
|
2023-11-20 06:31:07 -05:00
|
|
|
|
required_permission = RBACPermission.Permissions.NOTIFICATIONS_READ
|
2022-11-29 09:41:56 +01:00
|
|
|
|
|
2023-01-25 11:08:09 +01:00
|
|
|
|
emails_from_ical = [username.lower() for username in usernames_from_ical]
|
|
|
|
|
|
|
2024-10-10 15:02:21 -04:00
|
|
|
|
# NOTE: doing a select_related for organization here, since we will be accessing u.organization for each user
|
|
|
|
|
|
# in the required_permission.user_has_permission calls below
|
|
|
|
|
|
users_found_in_ical = (
|
|
|
|
|
|
organization.users.filter((Q(username__in=usernames_from_ical) | Q(email__lower__in=emails_from_ical)))
|
|
|
|
|
|
.distinct()
|
|
|
|
|
|
.select_related("organization")
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
# it is more efficient to check permissions on the subset of users filtered above
|
|
|
|
|
|
# than performing a regex query for the required permission
|
|
|
|
|
|
return [u for u in users_found_in_ical if required_permission.user_has_permission(u)]
|
2022-06-03 08:09:47 -06:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@timed_lru_cache(timeout=100)
|
2024-04-11 16:46:51 +02:00
|
|
|
|
def memoized_users_in_ical(usernames_from_ical: typing.Tuple[str], organization: "Organization") -> typing.List["User"]:
|
2022-06-03 08:09:47 -06:00
|
|
|
|
# using in-memory cache instead of redis to avoid pickling python objects
|
|
|
|
|
|
return users_in_ical(usernames_from_ical, organization)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# used for display schedule events on web
|
2022-07-05 15:50:38 -03:00
|
|
|
|
def list_of_oncall_shifts_from_ical(
|
2023-06-27 12:23:08 +02:00
|
|
|
|
schedule: "OnCallSchedule",
|
2023-07-28 08:59:33 -03:00
|
|
|
|
datetime_start: datetime.datetime,
|
|
|
|
|
|
datetime_end: datetime.datetime,
|
2023-06-27 12:23:08 +02:00
|
|
|
|
with_empty_shifts: bool = False,
|
|
|
|
|
|
with_gaps: bool = False,
|
|
|
|
|
|
filter_by: str | None = None,
|
|
|
|
|
|
from_cached_final: bool = False,
|
2022-07-05 15:50:38 -03:00
|
|
|
|
):
|
2022-06-03 08:09:47 -06:00
|
|
|
|
"""
|
|
|
|
|
|
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
|
|
|
|
|
|
}
|
|
|
|
|
|
]
|
|
|
|
|
|
"""
|
2023-07-25 10:43:23 +01:00
|
|
|
|
from apps.schedules.models import OnCallSchedule
|
2022-06-03 08:09:47 -06:00
|
|
|
|
|
|
|
|
|
|
# get list of iCalendars from current iCal files. If there is more than one calendar, primary calendar will always
|
|
|
|
|
|
# be the first
|
2023-06-27 12:23:08 +02:00
|
|
|
|
calendars: typing.Tuple[typing.Optional[Calendar], ...]
|
|
|
|
|
|
|
2023-05-08 16:01:24 -03:00
|
|
|
|
if from_cached_final:
|
2023-06-27 12:23:08 +02:00
|
|
|
|
calendars = (Calendar.from_ical(schedule.cached_ical_final_schedule),)
|
2023-05-08 16:01:24 -03:00
|
|
|
|
else:
|
|
|
|
|
|
calendars = schedule.get_icalendars()
|
2022-06-03 08:09:47 -06:00
|
|
|
|
|
|
|
|
|
|
result_datetime = []
|
|
|
|
|
|
result_date = []
|
|
|
|
|
|
|
|
|
|
|
|
for idx, calendar in enumerate(calendars):
|
|
|
|
|
|
if calendar is not None:
|
2023-06-27 12:23:08 +02:00
|
|
|
|
calendar_type: str | int
|
|
|
|
|
|
|
2023-05-08 16:01:24 -03:00
|
|
|
|
if from_cached_final:
|
|
|
|
|
|
calendar_type = CALENDAR_TYPE_FINAL
|
|
|
|
|
|
elif idx == 0:
|
2022-06-03 08:09:47 -06:00
|
|
|
|
calendar_type = OnCallSchedule.PRIMARY
|
|
|
|
|
|
else:
|
|
|
|
|
|
calendar_type = OnCallSchedule.OVERRIDES
|
|
|
|
|
|
|
2022-08-04 17:00:09 -03:00
|
|
|
|
if filter_by is not None and filter_by != calendar_type:
|
|
|
|
|
|
continue
|
|
|
|
|
|
|
2022-06-03 08:09:47 -06:00
|
|
|
|
tmp_result_datetime, tmp_result_date = get_shifts_dict(
|
2022-09-13 10:30:34 -03:00
|
|
|
|
calendar, calendar_type, schedule, datetime_start, datetime_end, with_empty_shifts
|
2022-06-03 08:09:47 -06:00
|
|
|
|
)
|
|
|
|
|
|
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": [],
|
2022-07-12 11:00:02 -06:00
|
|
|
|
"missing_users": [],
|
2022-06-03 08:09:47 -06:00
|
|
|
|
"priority": None,
|
|
|
|
|
|
"source": None,
|
|
|
|
|
|
"calendar_type": None,
|
|
|
|
|
|
"is_gap": True,
|
2022-07-06 15:47:21 -03:00
|
|
|
|
"shift_pk": None,
|
2022-06-03 08:09:47 -06:00
|
|
|
|
}
|
|
|
|
|
|
)
|
2023-05-08 16:01:24 -03:00
|
|
|
|
|
|
|
|
|
|
def event_start_cmp_key(e):
|
2023-07-28 08:59:33 -03:00
|
|
|
|
pytz_tz = pytz.timezone("UTC")
|
2023-05-08 16:01:24 -03:00
|
|
|
|
return (
|
|
|
|
|
|
datetime.datetime.combine(e["start"], datetime.datetime.min.time(), tzinfo=pytz_tz)
|
2024-04-02 10:26:19 -04:00
|
|
|
|
if type(e["start"]) is datetime.date
|
2023-05-08 16:01:24 -03:00
|
|
|
|
else e["start"]
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
result = sorted(result_datetime + result_date, key=event_start_cmp_key)
|
2022-06-03 08:09:47 -06:00
|
|
|
|
# if there is no events, return None
|
|
|
|
|
|
return result or None
|
|
|
|
|
|
|
|
|
|
|
|
|
2023-06-27 12:23:08 +02:00
|
|
|
|
def get_shifts_dict(
|
|
|
|
|
|
calendar: Calendar,
|
|
|
|
|
|
calendar_type: str | int,
|
|
|
|
|
|
schedule: "OnCallSchedule",
|
|
|
|
|
|
datetime_start: datetime.datetime,
|
|
|
|
|
|
datetime_end: datetime.datetime,
|
|
|
|
|
|
with_empty_shifts: bool = False,
|
|
|
|
|
|
):
|
2022-06-03 08:09:47 -06:00
|
|
|
|
events = ical_events.get_events_from_ical_between(calendar, datetime_start, datetime_end)
|
|
|
|
|
|
result_datetime = []
|
|
|
|
|
|
result_date = []
|
|
|
|
|
|
for event in events:
|
2023-08-11 12:14:15 -03:00
|
|
|
|
status = event.get(ICAL_STATUS)
|
|
|
|
|
|
if status == ICAL_STATUS_CANCELLED:
|
|
|
|
|
|
# ignore cancelled events
|
|
|
|
|
|
continue
|
2023-08-07 10:11:46 -03:00
|
|
|
|
sequence = event.get(ICAL_SEQUENCE)
|
|
|
|
|
|
recurrence_id = event.get(ICAL_RECURRENCE_ID)
|
|
|
|
|
|
if recurrence_id:
|
|
|
|
|
|
recurrence_id = recurrence_id.dt.isoformat()
|
2022-06-03 08:09:47 -06:00
|
|
|
|
priority = parse_priority_from_string(event.get(ICAL_SUMMARY, "[L0]"))
|
2023-08-07 10:11:46 -03:00
|
|
|
|
pk, source = parse_event_uid(event.get(ICAL_UID), sequence=sequence, recurrence_id=recurrence_id)
|
2022-06-03 08:09:47 -06:00
|
|
|
|
users = get_users_from_ical_event(event, schedule.organization)
|
2022-07-12 11:00:02 -06:00
|
|
|
|
missing_users = get_missing_users_from_ical_event(event, schedule.organization)
|
2023-05-08 16:01:24 -03:00
|
|
|
|
event_calendar_type = calendar_type
|
|
|
|
|
|
if calendar_type == CALENDAR_TYPE_FINAL:
|
|
|
|
|
|
event_calendar_type = (
|
|
|
|
|
|
schedule.OVERRIDES
|
2023-08-24 12:36:42 -03:00
|
|
|
|
if (
|
|
|
|
|
|
event.get(ICAL_PRIORITY, "") == schedule.OVERRIDES
|
|
|
|
|
|
or
|
|
|
|
|
|
# keep for backwards compatibility (to be removed later once schedules are refreshed)
|
|
|
|
|
|
event.get(ICAL_LOCATION, "") == schedule.CALENDAR_TYPE_VERBAL[schedule.OVERRIDES]
|
|
|
|
|
|
)
|
2023-05-08 16:01:24 -03:00
|
|
|
|
else schedule.PRIMARY
|
|
|
|
|
|
)
|
2022-06-03 08:09:47 -06:00
|
|
|
|
# Define on-call shift out of ical event that has the actual user
|
|
|
|
|
|
if len(users) > 0 or with_empty_shifts:
|
2024-04-02 10:26:19 -04:00
|
|
|
|
if type(event[ICAL_DATETIME_START].dt) is datetime.date:
|
2022-08-18 14:28:48 +03:00
|
|
|
|
start = event[ICAL_DATETIME_START].dt
|
|
|
|
|
|
end = event[ICAL_DATETIME_END].dt
|
2022-09-12 17:25:46 -03:00
|
|
|
|
result_date.append(
|
|
|
|
|
|
{
|
|
|
|
|
|
"start": start,
|
|
|
|
|
|
"end": end,
|
|
|
|
|
|
"users": users,
|
|
|
|
|
|
"missing_users": missing_users,
|
|
|
|
|
|
"priority": priority,
|
|
|
|
|
|
"source": source,
|
2023-05-08 16:01:24 -03:00
|
|
|
|
"calendar_type": event_calendar_type,
|
2022-09-12 17:25:46 -03:00
|
|
|
|
"shift_pk": pk,
|
|
|
|
|
|
}
|
|
|
|
|
|
)
|
2022-06-03 08:09:47 -06:00
|
|
|
|
else:
|
2022-08-18 14:28:48 +03:00
|
|
|
|
start, end = ical_events.get_start_and_end_with_respect_to_event_type(event)
|
2022-09-21 13:19:59 +03:00
|
|
|
|
if start < end:
|
|
|
|
|
|
result_datetime.append(
|
|
|
|
|
|
{
|
2023-11-06 13:52:50 -03:00
|
|
|
|
"start": start.astimezone(datetime.timezone.utc),
|
|
|
|
|
|
"end": end.astimezone(datetime.timezone.utc),
|
2022-09-21 13:19:59 +03:00
|
|
|
|
"users": users,
|
|
|
|
|
|
"missing_users": missing_users,
|
|
|
|
|
|
"priority": priority,
|
|
|
|
|
|
"source": source,
|
2023-05-08 16:01:24 -03:00
|
|
|
|
"calendar_type": event_calendar_type,
|
2022-09-21 13:19:59 +03:00
|
|
|
|
"shift_pk": pk,
|
|
|
|
|
|
}
|
|
|
|
|
|
)
|
2022-06-03 08:09:47 -06:00
|
|
|
|
return result_datetime, result_date
|
|
|
|
|
|
|
|
|
|
|
|
|
2023-06-27 12:23:08 +02:00
|
|
|
|
def list_of_empty_shifts_in_schedule(
|
|
|
|
|
|
schedule: "OnCallSchedule", start_date: datetime.date, end_date: datetime.date
|
|
|
|
|
|
) -> EmptyShifts:
|
2022-06-03 08:09:47 -06:00
|
|
|
|
# Calculate lookup window in schedule's tz
|
|
|
|
|
|
# If we can't get tz from ical use UTC
|
2023-07-25 10:43:23 +01:00
|
|
|
|
from apps.schedules.models import OnCallSchedule
|
2022-06-03 08:09:47 -06:00
|
|
|
|
|
|
|
|
|
|
calendars = schedule.get_icalendars()
|
2023-06-27 12:23:08 +02:00
|
|
|
|
empty_shifts: EmptyShifts = []
|
2022-06-03 08:09:47 -06:00
|
|
|
|
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)
|
|
|
|
|
|
|
2023-06-27 12:23:08 +02:00
|
|
|
|
# utcoffset can technically return None, but we're confident it is a timedelta here
|
|
|
|
|
|
schedule_timezone_offset: datetime.timedelta = datetime.datetime.now().astimezone(calendar_tz).utcoffset() # type: ignore[assignment]
|
|
|
|
|
|
|
2023-06-12 18:50:33 +02:00
|
|
|
|
start_datetime = datetime.datetime.combine(start_date, datetime.time.min) + datetime.timedelta(
|
2022-06-03 08:09:47 -06:00
|
|
|
|
milliseconds=1
|
|
|
|
|
|
)
|
2023-11-06 13:52:50 -03:00
|
|
|
|
start_datetime_with_offset = (start_datetime - schedule_timezone_offset).astimezone(datetime.timezone.utc)
|
2023-06-12 18:50:33 +02:00
|
|
|
|
end_datetime = datetime.datetime.combine(end_date, datetime.time.max)
|
2023-11-06 13:52:50 -03:00
|
|
|
|
end_datetime_with_offset = (end_datetime - schedule_timezone_offset).astimezone(datetime.timezone.utc)
|
2022-06-03 08:09:47 -06:00
|
|
|
|
|
|
|
|
|
|
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, "")
|
2022-07-06 15:47:21 -03:00
|
|
|
|
pk, _ = parse_event_uid(event.get(ICAL_UID))
|
2022-06-03 08:09:47 -06:00
|
|
|
|
|
|
|
|
|
|
event_hash = hash(f"{event[ICAL_UID]}{summary}{description}{attendee}")
|
|
|
|
|
|
if event_hash in checked_events:
|
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
|
|
checked_events.add(event_hash)
|
|
|
|
|
|
|
2022-09-05 11:10:47 +03:00
|
|
|
|
start, end, all_day = event_start_end_all_day_with_respect_to_type(event, calendar_tz)
|
|
|
|
|
|
if not all_day:
|
2023-11-06 13:52:50 -03:00
|
|
|
|
start = start.astimezone(datetime.timezone.utc)
|
|
|
|
|
|
end = end.astimezone(datetime.timezone.utc)
|
2022-06-03 08:09:47 -06:00
|
|
|
|
|
|
|
|
|
|
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,
|
2022-07-06 15:47:21 -03:00
|
|
|
|
shift_pk=pk,
|
2022-06-03 08:09:47 -06:00
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
empty_shifts.extend(empty_shifts_per_calendar)
|
|
|
|
|
|
|
|
|
|
|
|
return sorted(empty_shifts, key=lambda dt: dt.start)
|
|
|
|
|
|
|
|
|
|
|
|
|
2023-01-25 11:08:09 +01:00
|
|
|
|
def list_users_to_notify_from_ical(
|
2023-06-27 12:23:08 +02:00
|
|
|
|
schedule: "OnCallSchedule",
|
|
|
|
|
|
events_datetime: typing.Optional[datetime.datetime] = None,
|
2024-10-11 16:39:39 -03:00
|
|
|
|
from_cached_final: bool = False,
|
2024-11-04 13:34:06 -05:00
|
|
|
|
) -> typing.List["User"]:
|
2022-06-03 08:09:47 -06:00
|
|
|
|
"""
|
|
|
|
|
|
Retrieve on-call users for the current time
|
|
|
|
|
|
"""
|
2023-11-06 13:52:50 -03:00
|
|
|
|
events_datetime = events_datetime if events_datetime else datetime.datetime.now(datetime.timezone.utc)
|
2022-06-13 16:39:58 -06:00
|
|
|
|
return list_users_to_notify_from_ical_for_period(
|
2023-01-25 11:08:09 +01:00
|
|
|
|
schedule,
|
|
|
|
|
|
events_datetime,
|
|
|
|
|
|
events_datetime,
|
2024-10-11 16:39:39 -03:00
|
|
|
|
from_cached_final=from_cached_final,
|
2022-06-13 16:39:58 -06:00
|
|
|
|
)
|
2022-06-03 08:09:47 -06:00
|
|
|
|
|
|
|
|
|
|
|
2023-01-25 11:08:09 +01:00
|
|
|
|
def list_users_to_notify_from_ical_for_period(
|
2023-06-27 12:23:08 +02:00
|
|
|
|
schedule: "OnCallSchedule",
|
|
|
|
|
|
start_datetime: datetime.datetime,
|
|
|
|
|
|
end_datetime: datetime.datetime,
|
2024-10-11 16:39:39 -03:00
|
|
|
|
from_cached_final: bool = False,
|
2024-11-04 13:34:06 -05:00
|
|
|
|
) -> typing.List["User"]:
|
2024-10-11 16:39:39 -03:00
|
|
|
|
if from_cached_final and schedule.cached_ical_final_schedule:
|
|
|
|
|
|
events = schedule.filter_events(start_datetime, end_datetime, from_cached_final=True)
|
|
|
|
|
|
else:
|
|
|
|
|
|
events = schedule.final_events(start_datetime, end_datetime)
|
2024-04-11 16:46:51 +02:00
|
|
|
|
usernames: typing.List[str] = []
|
2023-07-28 08:59:33 -03:00
|
|
|
|
for event in events:
|
|
|
|
|
|
usernames += [u["email"] for u in event.get("users", [])]
|
|
|
|
|
|
|
2024-04-11 16:46:51 +02:00
|
|
|
|
return memoized_users_in_ical(tuple(usernames), schedule.organization)
|
2022-06-03 08:09:47 -06:00
|
|
|
|
|
|
|
|
|
|
|
2023-01-25 11:08:09 +01:00
|
|
|
|
def get_oncall_users_for_multiple_schedules(
|
2023-09-20 08:49:58 -03:00
|
|
|
|
schedules: typing.List["OnCallSchedule"], events_datetime=None
|
Add responders improvements (#3128)
# What this PR does
https://www.loom.com/share/c5e10b5ec51343d0954c6f41cfd6a5fb
## Summary of backend changes
- Add `AlertReceiveChannel.get_orgs_direct_paging_integrations` method
and `AlertReceiveChannel.is_contactable` property. These are needed to
be able to (optionally) filter down teams, in the `GET /teams` internal
API endpoint
([here](https://github.com/grafana/oncall/pull/3128/files#diff-a4bd76e557f7e11dafb28a52c1034c075028c693b3c12d702d53c07fc6f24c05R55-R63)),
to just teams that have a "contactable" Direct Paging integration
- `engine/apps/alerts/paging.py`
- update these functions to support new UX. In short `direct_paging` no
longer takes a list of `ScheduleNotifications` or an `EscalationChain`
object
- add `user_is_oncall` helper function
- add `_construct_title` helper function. In short if no `title` is
provided, which is the case for Direct Pages originating from OnCall
(either UI or Slack), then the format is `f"{from_user.username} is
paging <team.name (if team is specified> <comma separated list of
user.usernames> to join escalation"`
- `engine/apps/api/serializers/team.py` - add
`number_of_users_currently_oncall` attribute to response schema
([code](https://github.com/grafana/oncall/pull/3128/files#diff-26af48f796c9e987a76447586dd0f92349783d6ea6a0b6039a2f0f28bd58c2ebR45-R52))
- `engine/apps/api/serializers/user.py` - add `is_currently_oncall`
attribute to response schema
([code](https://github.com/grafana/oncall/pull/3128/files#diff-6744b5544ebb120437af98a996da5ad7d48ee1139a6112c7e3904010ab98f232R157-R162))
- `engine/apps/api/views/team.py` - add support for two new optional
query params `only_include_notifiable_teams` and `include_no_team`
([code](https://github.com/grafana/oncall/pull/3128/files#diff-a4bd76e557f7e11dafb28a52c1034c075028c693b3c12d702d53c07fc6f24c05R55-R70))
- `engine/apps/api/views/user.py`
- in the `GET /users` internal API endpoint, when specifying the
`search` query param now also search on `teams__name`
([code](https://github.com/grafana/oncall/pull/3128/files#diff-30309629484ad28e6fe09816e1bd226226d652ea977b6f3b6775976c729bf4b5R223);
this is a new UX requirement)
- add support for a new optional query param, `is_currently_oncall`, to
allow filtering users based on.. whether they are currently on call or
not
([code](https://github.com/grafana/oncall/pull/3128/files#diff-30309629484ad28e6fe09816e1bd226226d652ea977b6f3b6775976c729bf4b5R272-R282))
- remove `check_availability` endpoint (no longer used with new UX; also
removed references in frontend code)
- `engine/apps/slack/scenarios/paging.py` and
`engine/apps/slack/scenarios/manage_responders.py` - update Slack
workflows to support new UX. Schedules are no longer a concept here.
When creating a new alert group via `/escalate` the user either
specifies a team and/or user(s) (they must specify at least one of the
two and validation is done here to check this). When adding responders
to an existing alert group it's simply a list of users that they can
add, no more schedules.
- add `Organization.slack_is_configured` and
`Organization.telegram_is_configured` properties. These are needed to
support [this new functionality
](https://github.com/grafana/oncall/pull/3128/files#diff-9d96504027309f2bd1e95352bac1433b09b60eb4fafb611b52a6c15ed16cbc48R271-R272)
in the `AlertReceiveChannel` model.
## Summary of frontend changes
- Refactor/rename `EscalationVariants` component to `AddResponders` +
remove `grafana-plugin/src/containers/UserWarningModal` (no longer
needed with new UX)
- Remove `grafana-plugin/src/models/user.ts` as it seemed to be a
duplicate of `grafana-plugin/src/models/user/user.types.ts`
Related to https://github.com/grafana/incident/issues/4278
- Closes #3115
- Closes #3116
- Closes #3117
- Closes #3118
- Closes #3177
## TODO
- [x] make frontend changes
- [x] update Slack backend functionality
- [x] update public documentation
- [x] add/update e2e tests
## Post-deploy To-dos
- [ ] update dev/ops/production Slack bots to update `/escalate` command
description (should now say "Direct page a team or user(s)")
## Checklist
- [x] Unit, integration, and e2e (if applicable) tests updated
- [x] Documentation added (or `pr:no public docs` PR label added if not
required)
- [x] `CHANGELOG.md` updated (or `pr:no changelog` PR label added if not
required)
2023-10-27 12:12:07 -04:00
|
|
|
|
) -> SchedulesOnCallUsers:
|
2023-01-25 11:08:09 +01:00
|
|
|
|
if events_datetime is None:
|
2023-11-06 13:52:50 -03:00
|
|
|
|
events_datetime = datetime.datetime.now(datetime.timezone.utc)
|
2023-01-25 11:08:09 +01:00
|
|
|
|
|
|
|
|
|
|
# Exit early if there are no schedules
|
2023-09-20 08:49:58 -03:00
|
|
|
|
if not schedules:
|
2023-01-25 11:08:09 +01:00
|
|
|
|
return {}
|
|
|
|
|
|
|
|
|
|
|
|
# Get on-call users
|
Add responders improvements (#3128)
# What this PR does
https://www.loom.com/share/c5e10b5ec51343d0954c6f41cfd6a5fb
## Summary of backend changes
- Add `AlertReceiveChannel.get_orgs_direct_paging_integrations` method
and `AlertReceiveChannel.is_contactable` property. These are needed to
be able to (optionally) filter down teams, in the `GET /teams` internal
API endpoint
([here](https://github.com/grafana/oncall/pull/3128/files#diff-a4bd76e557f7e11dafb28a52c1034c075028c693b3c12d702d53c07fc6f24c05R55-R63)),
to just teams that have a "contactable" Direct Paging integration
- `engine/apps/alerts/paging.py`
- update these functions to support new UX. In short `direct_paging` no
longer takes a list of `ScheduleNotifications` or an `EscalationChain`
object
- add `user_is_oncall` helper function
- add `_construct_title` helper function. In short if no `title` is
provided, which is the case for Direct Pages originating from OnCall
(either UI or Slack), then the format is `f"{from_user.username} is
paging <team.name (if team is specified> <comma separated list of
user.usernames> to join escalation"`
- `engine/apps/api/serializers/team.py` - add
`number_of_users_currently_oncall` attribute to response schema
([code](https://github.com/grafana/oncall/pull/3128/files#diff-26af48f796c9e987a76447586dd0f92349783d6ea6a0b6039a2f0f28bd58c2ebR45-R52))
- `engine/apps/api/serializers/user.py` - add `is_currently_oncall`
attribute to response schema
([code](https://github.com/grafana/oncall/pull/3128/files#diff-6744b5544ebb120437af98a996da5ad7d48ee1139a6112c7e3904010ab98f232R157-R162))
- `engine/apps/api/views/team.py` - add support for two new optional
query params `only_include_notifiable_teams` and `include_no_team`
([code](https://github.com/grafana/oncall/pull/3128/files#diff-a4bd76e557f7e11dafb28a52c1034c075028c693b3c12d702d53c07fc6f24c05R55-R70))
- `engine/apps/api/views/user.py`
- in the `GET /users` internal API endpoint, when specifying the
`search` query param now also search on `teams__name`
([code](https://github.com/grafana/oncall/pull/3128/files#diff-30309629484ad28e6fe09816e1bd226226d652ea977b6f3b6775976c729bf4b5R223);
this is a new UX requirement)
- add support for a new optional query param, `is_currently_oncall`, to
allow filtering users based on.. whether they are currently on call or
not
([code](https://github.com/grafana/oncall/pull/3128/files#diff-30309629484ad28e6fe09816e1bd226226d652ea977b6f3b6775976c729bf4b5R272-R282))
- remove `check_availability` endpoint (no longer used with new UX; also
removed references in frontend code)
- `engine/apps/slack/scenarios/paging.py` and
`engine/apps/slack/scenarios/manage_responders.py` - update Slack
workflows to support new UX. Schedules are no longer a concept here.
When creating a new alert group via `/escalate` the user either
specifies a team and/or user(s) (they must specify at least one of the
two and validation is done here to check this). When adding responders
to an existing alert group it's simply a list of users that they can
add, no more schedules.
- add `Organization.slack_is_configured` and
`Organization.telegram_is_configured` properties. These are needed to
support [this new functionality
](https://github.com/grafana/oncall/pull/3128/files#diff-9d96504027309f2bd1e95352bac1433b09b60eb4fafb611b52a6c15ed16cbc48R271-R272)
in the `AlertReceiveChannel` model.
## Summary of frontend changes
- Refactor/rename `EscalationVariants` component to `AddResponders` +
remove `grafana-plugin/src/containers/UserWarningModal` (no longer
needed with new UX)
- Remove `grafana-plugin/src/models/user.ts` as it seemed to be a
duplicate of `grafana-plugin/src/models/user/user.types.ts`
Related to https://github.com/grafana/incident/issues/4278
- Closes #3115
- Closes #3116
- Closes #3117
- Closes #3118
- Closes #3177
## TODO
- [x] make frontend changes
- [x] update Slack backend functionality
- [x] update public documentation
- [x] add/update e2e tests
## Post-deploy To-dos
- [ ] update dev/ops/production Slack bots to update `/escalate` command
description (should now say "Direct page a team or user(s)")
## Checklist
- [x] Unit, integration, and e2e (if applicable) tests updated
- [x] Documentation added (or `pr:no public docs` PR label added if not
required)
- [x] `CHANGELOG.md` updated (or `pr:no changelog` PR label added if not
required)
2023-10-27 12:12:07 -04:00
|
|
|
|
oncall_users: SchedulesOnCallUsers = {}
|
2023-09-20 08:49:58 -03:00
|
|
|
|
for schedule in schedules:
|
2023-01-25 11:08:09 +01:00
|
|
|
|
# pass user list to list_users_to_notify_from_ical
|
2023-07-28 15:53:27 -03:00
|
|
|
|
schedule_oncall_users = list_users_to_notify_from_ical(schedule, events_datetime=events_datetime)
|
Add responders improvements (#3128)
# What this PR does
https://www.loom.com/share/c5e10b5ec51343d0954c6f41cfd6a5fb
## Summary of backend changes
- Add `AlertReceiveChannel.get_orgs_direct_paging_integrations` method
and `AlertReceiveChannel.is_contactable` property. These are needed to
be able to (optionally) filter down teams, in the `GET /teams` internal
API endpoint
([here](https://github.com/grafana/oncall/pull/3128/files#diff-a4bd76e557f7e11dafb28a52c1034c075028c693b3c12d702d53c07fc6f24c05R55-R63)),
to just teams that have a "contactable" Direct Paging integration
- `engine/apps/alerts/paging.py`
- update these functions to support new UX. In short `direct_paging` no
longer takes a list of `ScheduleNotifications` or an `EscalationChain`
object
- add `user_is_oncall` helper function
- add `_construct_title` helper function. In short if no `title` is
provided, which is the case for Direct Pages originating from OnCall
(either UI or Slack), then the format is `f"{from_user.username} is
paging <team.name (if team is specified> <comma separated list of
user.usernames> to join escalation"`
- `engine/apps/api/serializers/team.py` - add
`number_of_users_currently_oncall` attribute to response schema
([code](https://github.com/grafana/oncall/pull/3128/files#diff-26af48f796c9e987a76447586dd0f92349783d6ea6a0b6039a2f0f28bd58c2ebR45-R52))
- `engine/apps/api/serializers/user.py` - add `is_currently_oncall`
attribute to response schema
([code](https://github.com/grafana/oncall/pull/3128/files#diff-6744b5544ebb120437af98a996da5ad7d48ee1139a6112c7e3904010ab98f232R157-R162))
- `engine/apps/api/views/team.py` - add support for two new optional
query params `only_include_notifiable_teams` and `include_no_team`
([code](https://github.com/grafana/oncall/pull/3128/files#diff-a4bd76e557f7e11dafb28a52c1034c075028c693b3c12d702d53c07fc6f24c05R55-R70))
- `engine/apps/api/views/user.py`
- in the `GET /users` internal API endpoint, when specifying the
`search` query param now also search on `teams__name`
([code](https://github.com/grafana/oncall/pull/3128/files#diff-30309629484ad28e6fe09816e1bd226226d652ea977b6f3b6775976c729bf4b5R223);
this is a new UX requirement)
- add support for a new optional query param, `is_currently_oncall`, to
allow filtering users based on.. whether they are currently on call or
not
([code](https://github.com/grafana/oncall/pull/3128/files#diff-30309629484ad28e6fe09816e1bd226226d652ea977b6f3b6775976c729bf4b5R272-R282))
- remove `check_availability` endpoint (no longer used with new UX; also
removed references in frontend code)
- `engine/apps/slack/scenarios/paging.py` and
`engine/apps/slack/scenarios/manage_responders.py` - update Slack
workflows to support new UX. Schedules are no longer a concept here.
When creating a new alert group via `/escalate` the user either
specifies a team and/or user(s) (they must specify at least one of the
two and validation is done here to check this). When adding responders
to an existing alert group it's simply a list of users that they can
add, no more schedules.
- add `Organization.slack_is_configured` and
`Organization.telegram_is_configured` properties. These are needed to
support [this new functionality
](https://github.com/grafana/oncall/pull/3128/files#diff-9d96504027309f2bd1e95352bac1433b09b60eb4fafb611b52a6c15ed16cbc48R271-R272)
in the `AlertReceiveChannel` model.
## Summary of frontend changes
- Refactor/rename `EscalationVariants` component to `AddResponders` +
remove `grafana-plugin/src/containers/UserWarningModal` (no longer
needed with new UX)
- Remove `grafana-plugin/src/models/user.ts` as it seemed to be a
duplicate of `grafana-plugin/src/models/user/user.types.ts`
Related to https://github.com/grafana/incident/issues/4278
- Closes #3115
- Closes #3116
- Closes #3117
- Closes #3118
- Closes #3177
## TODO
- [x] make frontend changes
- [x] update Slack backend functionality
- [x] update public documentation
- [x] add/update e2e tests
## Post-deploy To-dos
- [ ] update dev/ops/production Slack bots to update `/escalate` command
description (should now say "Direct page a team or user(s)")
## Checklist
- [x] Unit, integration, and e2e (if applicable) tests updated
- [x] Documentation added (or `pr:no public docs` PR label added if not
required)
- [x] `CHANGELOG.md` updated (or `pr:no changelog` PR label added if not
required)
2023-10-27 12:12:07 -04:00
|
|
|
|
oncall_users.update({schedule: schedule_oncall_users})
|
2023-01-25 11:08:09 +01:00
|
|
|
|
|
|
|
|
|
|
return oncall_users
|
|
|
|
|
|
|
|
|
|
|
|
|
2024-01-17 13:30:11 -03:00
|
|
|
|
def _generate_cache_key_for_schedule_oncall_users(schedule: "OnCallSchedule") -> str:
|
|
|
|
|
|
return ensure_cache_key_allocates_to_the_same_hash_slot(
|
|
|
|
|
|
f"{SCHEDULE_ONCALL_CACHE_KEY_PREFIX}{schedule.public_primary_key}", SCHEDULE_ONCALL_CACHE_KEY_PREFIX
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def update_cached_oncall_users_for_schedule(schedule: "OnCallSchedule"):
|
|
|
|
|
|
oncall_users = get_oncall_users_for_multiple_schedules([schedule])
|
|
|
|
|
|
users = oncall_users.get(schedule, [])
|
|
|
|
|
|
cache.set(
|
|
|
|
|
|
_generate_cache_key_for_schedule_oncall_users(schedule),
|
|
|
|
|
|
[user.public_primary_key for user in users],
|
|
|
|
|
|
timeout=SCHEDULE_ONCALL_CACHE_TTL,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
2023-11-06 15:30:32 -05:00
|
|
|
|
def get_cached_oncall_users_for_multiple_schedules(schedules: typing.List["OnCallSchedule"]) -> SchedulesOnCallUsers:
|
|
|
|
|
|
"""
|
|
|
|
|
|
More "performant" version of `apps.schedules.ical_utils.get_oncall_users_for_multiple_schedules`
|
|
|
|
|
|
which caches results, for 15 minutes.
|
|
|
|
|
|
|
|
|
|
|
|
The cache results are stored in the following format:
|
|
|
|
|
|
- `schedule_<schedule_public_primary_key>_oncall_users`: [list of oncall user public_primary_keys for the schedule]
|
|
|
|
|
|
|
|
|
|
|
|
This method will return the cached version of the results, if they exist, otherwise it will calculate
|
|
|
|
|
|
the results and cache them for 15 minutes.
|
|
|
|
|
|
|
|
|
|
|
|
Note: since we cannot cache Python objects we will cache the primary keys of schedules/users and
|
|
|
|
|
|
then fetch these from the db in two queries (one for users, one for schedules).
|
|
|
|
|
|
"""
|
|
|
|
|
|
from apps.schedules.models import OnCallSchedule
|
|
|
|
|
|
from apps.user_management.models import User
|
|
|
|
|
|
|
|
|
|
|
|
def _get_schedule_public_primary_key_from_schedule_oncall_users_cache_key(cache_key: str) -> str:
|
2023-12-04 13:08:57 -05:00
|
|
|
|
"""
|
|
|
|
|
|
remove any brackets that might be included in the cache key (when redis cluster is active).
|
|
|
|
|
|
See `_generate_cache_key_for_schedule_oncall_users` just above
|
|
|
|
|
|
"""
|
|
|
|
|
|
cache_key = cache_key.replace("{", "").replace("}", "")
|
2024-01-17 13:30:11 -03:00
|
|
|
|
return cache_key.replace(SCHEDULE_ONCALL_CACHE_KEY_PREFIX, "")
|
2023-11-06 15:30:32 -05:00
|
|
|
|
|
2023-12-04 13:08:57 -05:00
|
|
|
|
cache_keys = [_generate_cache_key_for_schedule_oncall_users(schedule) for schedule in schedules]
|
2023-11-06 15:30:32 -05:00
|
|
|
|
|
|
|
|
|
|
# get_many returns a dictionary with all the keys we asked for that actually exist
|
|
|
|
|
|
# in the cache (and haven’t expired)
|
|
|
|
|
|
cached_results = cache.get_many(cache_keys)
|
|
|
|
|
|
|
|
|
|
|
|
schedule_public_primary_keys_to_fetch_from_db: typing.Set[str] = set()
|
|
|
|
|
|
user_public_primary_keys_to_fetch_from_db: typing.Set[str] = set()
|
|
|
|
|
|
|
|
|
|
|
|
# for the results returned from the cache we need to determine which schedule and user objects
|
|
|
|
|
|
# that we will need to fetch from the database (since we can't cache python objects and are only caching
|
|
|
|
|
|
# the objects primary keys)
|
|
|
|
|
|
for cache_key, oncall_users in cached_results.items():
|
|
|
|
|
|
schedule_public_primary_keys_to_fetch_from_db.add(
|
|
|
|
|
|
_get_schedule_public_primary_key_from_schedule_oncall_users_cache_key(cache_key)
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
user_public_primary_keys_to_fetch_from_db.update(oncall_users)
|
|
|
|
|
|
|
|
|
|
|
|
# calculate results for schedules that weren't in the cache
|
|
|
|
|
|
cached_result_keys = list(cached_results.keys())
|
|
|
|
|
|
schedule_primary_keys_we_need_to_calculate_results_for = [
|
|
|
|
|
|
_get_schedule_public_primary_key_from_schedule_oncall_users_cache_key(cache_key)
|
|
|
|
|
|
for cache_key in cache_keys
|
|
|
|
|
|
if cache_key not in cached_result_keys
|
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
schedules_we_need_to_calculate_results_for = [
|
|
|
|
|
|
schedule
|
|
|
|
|
|
for schedule in schedules
|
|
|
|
|
|
if schedule.public_primary_key in schedule_primary_keys_we_need_to_calculate_results_for
|
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
results = get_oncall_users_for_multiple_schedules(schedules_we_need_to_calculate_results_for)
|
|
|
|
|
|
|
|
|
|
|
|
# update the cache with the new results we just got back
|
|
|
|
|
|
new_results_to_update_in_cache: typing.Dict[str, typing.List[str]] = {}
|
|
|
|
|
|
for schedule, oncall_users in results.items():
|
|
|
|
|
|
oncall_user_public_primary_keys = [user.public_primary_key for user in oncall_users]
|
|
|
|
|
|
new_results_to_update_in_cache[
|
|
|
|
|
|
_generate_cache_key_for_schedule_oncall_users(schedule)
|
|
|
|
|
|
] = oncall_user_public_primary_keys
|
|
|
|
|
|
|
2024-01-17 13:30:11 -03:00
|
|
|
|
cache.set_many(new_results_to_update_in_cache, timeout=SCHEDULE_ONCALL_CACHE_TTL)
|
2023-11-06 15:30:32 -05:00
|
|
|
|
|
|
|
|
|
|
# make two queries to the database, one to fetch the schedule objects we need and the other to fetch
|
|
|
|
|
|
# the user objects we need
|
|
|
|
|
|
schedules = {
|
|
|
|
|
|
schedule.public_primary_key: schedule
|
|
|
|
|
|
for schedule in OnCallSchedule.objects.filter(
|
|
|
|
|
|
public_primary_key__in=schedule_public_primary_keys_to_fetch_from_db
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
users = {
|
|
|
|
|
|
user.public_primary_key: user
|
|
|
|
|
|
for user in User.objects.filter(public_primary_key__in=user_public_primary_keys_to_fetch_from_db)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
# revisit our cached_results and this time populate the results with the actual objects
|
|
|
|
|
|
for cache_key, oncall_users in cached_results.items():
|
|
|
|
|
|
schedule_public_primary_key = _get_schedule_public_primary_key_from_schedule_oncall_users_cache_key(cache_key)
|
2024-07-31 17:17:08 +02:00
|
|
|
|
schedule = schedules.get(schedule_public_primary_key)
|
|
|
|
|
|
if schedule is None:
|
|
|
|
|
|
# schedule might have been deleted
|
|
|
|
|
|
continue
|
2024-04-23 08:40:02 -03:00
|
|
|
|
oncall_users = [
|
|
|
|
|
|
users[user_public_primary_key]
|
|
|
|
|
|
for user_public_primary_key in oncall_users
|
|
|
|
|
|
# filter out any users that we couldn't find in the database (e.g. deleted)
|
|
|
|
|
|
if user_public_primary_key in users
|
|
|
|
|
|
]
|
2023-11-06 15:30:32 -05:00
|
|
|
|
|
|
|
|
|
|
results[schedule] = oncall_users
|
|
|
|
|
|
|
|
|
|
|
|
return results
|
|
|
|
|
|
|
|
|
|
|
|
|
2023-06-27 12:23:08 +02:00
|
|
|
|
def parse_username_from_string(string: str) -> str:
|
2022-06-03 08:09:47 -06:00
|
|
|
|
"""
|
|
|
|
|
|
Parse on-call shift user from the given string
|
|
|
|
|
|
Example input:
|
|
|
|
|
|
[L1] bob@company.com
|
|
|
|
|
|
Example output:
|
|
|
|
|
|
bob@company.com
|
|
|
|
|
|
"""
|
2023-11-29 12:04:48 -03:00
|
|
|
|
return re.sub(RE_PRIORITY, "", string.strip(), count=1).strip()
|
2022-06-03 08:09:47 -06:00
|
|
|
|
|
|
|
|
|
|
|
2023-06-27 12:23:08 +02:00
|
|
|
|
def parse_priority_from_string(string: str) -> int:
|
2022-06-03 08:09:47 -06:00
|
|
|
|
"""
|
|
|
|
|
|
Parse on-call shift priority from the given string
|
|
|
|
|
|
Example input:
|
|
|
|
|
|
[L1] @alex @bob
|
|
|
|
|
|
Example output:
|
|
|
|
|
|
1
|
|
|
|
|
|
"""
|
2023-06-27 12:23:08 +02:00
|
|
|
|
priority = 0
|
|
|
|
|
|
priority_matches = re.findall(RE_PRIORITY, string.strip())
|
|
|
|
|
|
if len(priority_matches) > 0:
|
|
|
|
|
|
priority = int(priority_matches[0])
|
2022-06-03 08:09:47 -06:00
|
|
|
|
if priority < 1:
|
|
|
|
|
|
priority = 0
|
|
|
|
|
|
return priority
|
|
|
|
|
|
|
|
|
|
|
|
|
2023-08-07 10:11:46 -03:00
|
|
|
|
def parse_event_uid(string: str, sequence: str = None, recurrence_id: str = None):
|
2022-07-06 15:47:21 -03:00
|
|
|
|
pk = None
|
|
|
|
|
|
source = None
|
2022-06-03 08:09:47 -06:00
|
|
|
|
source_verbal = None
|
2022-07-06 15:47:21 -03:00
|
|
|
|
|
2023-10-02 09:56:07 -03:00
|
|
|
|
match = None
|
|
|
|
|
|
if string.startswith("oncall"):
|
|
|
|
|
|
match = RE_EVENT_UID_V2.match(string)
|
|
|
|
|
|
if match:
|
|
|
|
|
|
_, pk, _, _, source = match.groups()
|
|
|
|
|
|
elif string.startswith("amixr"):
|
|
|
|
|
|
# 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()
|
2022-07-06 15:47:21 -03:00
|
|
|
|
else:
|
2023-09-18 14:49:21 -03:00
|
|
|
|
match = RE_EVENT_UID_EXPORT.match(string)
|
2022-07-06 15:47:21 -03:00
|
|
|
|
if match:
|
2023-09-18 14:49:21 -03:00
|
|
|
|
pk, _, _ = match.groups()
|
2023-10-02 09:56:07 -03:00
|
|
|
|
|
|
|
|
|
|
if not match:
|
|
|
|
|
|
# fallback to use the UID string as the rotation ID
|
|
|
|
|
|
pk = string
|
|
|
|
|
|
# in ical imported calendars, sequence and/or recurrence_id
|
|
|
|
|
|
# distinguish main recurring event vs instance modification
|
|
|
|
|
|
# (see https://icalendar.org/iCalendar-RFC-5545/3-8-4-4-recurrence-id.html)
|
|
|
|
|
|
if sequence:
|
|
|
|
|
|
pk = f"{pk}_{sequence}"
|
|
|
|
|
|
if recurrence_id:
|
|
|
|
|
|
pk = f"{pk}_{recurrence_id}"
|
2022-07-06 15:47:21 -03:00
|
|
|
|
|
|
|
|
|
|
if source is not None:
|
2022-07-05 15:50:38 -03:00
|
|
|
|
source = int(source)
|
2023-07-25 10:43:23 +01:00
|
|
|
|
from apps.schedules.models import CustomOnCallShift
|
|
|
|
|
|
|
|
|
|
|
|
source_verbal = CustomOnCallShift.SOURCE_CHOICES[source][1]
|
2022-07-06 15:47:21 -03:00
|
|
|
|
|
|
|
|
|
|
return pk, source_verbal
|
2022-06-03 08:09:47 -06:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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:
|
2022-07-05 14:29:47 +01:00
|
|
|
|
if isinstance(event[ICAL_ATTENDEE], str):
|
2022-06-03 08:09:47 -06:00
|
|
|
|
# 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
|
|
|
|
|
|
|
|
|
|
|
|
|
2023-06-27 12:23:08 +02:00
|
|
|
|
def get_missing_users_from_ical_event(event, organization: "Organization"):
|
2022-07-12 11:00:02 -06:00
|
|
|
|
all_usernames, _ = get_usernames_from_ical_event(event)
|
|
|
|
|
|
users = list(get_users_from_ical_event(event, organization))
|
|
|
|
|
|
found_usernames = [u.username for u in users]
|
2022-10-20 15:58:46 -03:00
|
|
|
|
found_emails = [u.email.lower() for u in users]
|
|
|
|
|
|
return [u for u in all_usernames if u != "" and u not in found_usernames and u.lower() not in found_emails]
|
2022-07-12 11:00:02 -06:00
|
|
|
|
|
|
|
|
|
|
|
2024-11-04 13:34:06 -05:00
|
|
|
|
def get_users_from_ical_event(event, organization: "Organization") -> typing.List["User"]:
|
2022-06-03 08:09:47 -06:00
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
|
|
2022-09-06 19:12:10 +05:00
|
|
|
|
def is_icals_equal_line_by_line(first, second):
|
2022-06-03 08:09:47 -06:00
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
|
|
2022-09-08 16:11:03 +05:00
|
|
|
|
def is_icals_equal(first, second):
|
|
|
|
|
|
first_cal = Calendar.from_ical(first)
|
|
|
|
|
|
if first_cal.get("PRODID", None) in ("-//My calendar product//amixr//", "-//web schedule//oncall//"):
|
|
|
|
|
|
# Compare schedules generated by oncall line by line, since they not support SEQUENCE field yet.
|
|
|
|
|
|
# But we are sure that same calendars will have same lines, since we are generating it.
|
|
|
|
|
|
return is_icals_equal_line_by_line(first, second)
|
|
|
|
|
|
else:
|
|
|
|
|
|
# Compare external calendars by events, since sometimes they contain different lines even for equal calendars.
|
2022-09-06 19:12:10 +05:00
|
|
|
|
second_cal = Calendar.from_ical(second)
|
|
|
|
|
|
first_subcomponents = first_cal.subcomponents
|
|
|
|
|
|
second_subcomponents = second_cal.subcomponents
|
2023-05-03 17:24:10 -03:00
|
|
|
|
# only consider VEVENT components
|
|
|
|
|
|
first_cal_events = {
|
|
|
|
|
|
cmp.get("UID", None): cmp.get("SEQUENCE", None) for cmp in first_subcomponents if cmp.name == "VEVENT"
|
|
|
|
|
|
}
|
|
|
|
|
|
second_cal_events = {
|
|
|
|
|
|
cmp.get("UID", None): cmp.get("SEQUENCE", None) for cmp in second_subcomponents if cmp.name == "VEVENT"
|
|
|
|
|
|
}
|
|
|
|
|
|
# check events and their respective sequences are equal
|
|
|
|
|
|
return first_cal_events == second_cal_events
|
2022-09-06 19:12:10 +05:00
|
|
|
|
|
|
|
|
|
|
|
2022-06-03 08:09:47 -06:00
|
|
|
|
def ical_date_to_datetime(date, tz, start):
|
|
|
|
|
|
datetime_to_combine = datetime.time.min
|
|
|
|
|
|
all_day = False
|
2024-04-02 10:26:19 -04:00
|
|
|
|
if type(date) is datetime.date:
|
2022-06-03 08:09:47 -06:00
|
|
|
|
all_day = True
|
2023-06-12 18:50:33 +02:00
|
|
|
|
calendar_timezone_offset = datetime.datetime.now().astimezone(tz).utcoffset()
|
|
|
|
|
|
date = datetime.datetime.combine(date, datetime_to_combine).astimezone(tz) - calendar_timezone_offset
|
2022-06-03 08:09:47 -06:00
|
|
|
|
if not start:
|
2023-06-12 18:50:33 +02:00
|
|
|
|
date -= datetime.timedelta(seconds=1)
|
2022-06-03 08:09:47 -06:00
|
|
|
|
return date, all_day
|
|
|
|
|
|
|
|
|
|
|
|
|
2023-08-03 14:38:01 +02:00
|
|
|
|
def calculate_shift_diff(shifts: list, prev_shifts: list) -> typing.Tuple[bool, list]:
|
|
|
|
|
|
"""
|
|
|
|
|
|
Get shifts diff comparing with the previous shifts
|
|
|
|
|
|
"""
|
|
|
|
|
|
fields_to_compare = ["users", "end", "start", "all_day", "priority_level", "shift"]
|
|
|
|
|
|
|
|
|
|
|
|
shifts_fields = [{k: v for k, v in shift.items() if k in fields_to_compare} for shift in shifts]
|
|
|
|
|
|
prev_shifts_fields = [{k: v for k, v in shift.items() if k in fields_to_compare} for shift in prev_shifts]
|
|
|
|
|
|
|
|
|
|
|
|
shift_changed = len(shifts) != len(prev_shifts)
|
|
|
|
|
|
|
|
|
|
|
|
diff = []
|
|
|
|
|
|
|
|
|
|
|
|
for idx, shift in enumerate(shifts_fields):
|
|
|
|
|
|
if shift not in prev_shifts_fields:
|
|
|
|
|
|
shift_changed = True
|
|
|
|
|
|
diff.append(shifts[idx])
|
2022-06-03 08:09:47 -06:00
|
|
|
|
|
|
|
|
|
|
return shift_changed, diff
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def get_icalendar_tz_or_utc(icalendar):
|
2023-06-02 14:28:04 -03:00
|
|
|
|
calendar_timezone = icalendar.get("X-WR-TIMEZONE", "UTC")
|
2022-06-03 08:09:47 -06:00
|
|
|
|
|
2022-12-01 14:13:39 +01:00
|
|
|
|
if pytz_timezone := is_valid_timezone(calendar_timezone):
|
|
|
|
|
|
return pytz_timezone
|
|
|
|
|
|
|
|
|
|
|
|
# try to convert the timezone from windows to iana
|
|
|
|
|
|
if (converted_timezone := convert_windows_timezone_to_iana(calendar_timezone)) is None:
|
|
|
|
|
|
return "UTC"
|
|
|
|
|
|
|
|
|
|
|
|
return pytz.timezone(converted_timezone)
|
2022-06-03 08:09:47 -06:00
|
|
|
|
|
|
|
|
|
|
|
2023-06-27 12:23:08 +02:00
|
|
|
|
def fetch_ical_file_or_get_error(ical_url: str) -> typing.Tuple[str | None, str | None]:
|
|
|
|
|
|
cached_ical_file: str | None = None
|
|
|
|
|
|
ical_file_error: str | None = None
|
2022-06-03 08:09:47 -06:00
|
|
|
|
try:
|
2023-02-22 07:29:59 +01:00
|
|
|
|
new_ical_file = fetch_ical_file(ical_url)
|
2022-06-03 08:09:47 -06:00
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
|
|
2023-06-27 12:23:08 +02:00
|
|
|
|
def fetch_ical_file(ical_url: str) -> str:
|
2023-02-22 07:29:59 +01:00
|
|
|
|
# without user-agent header google calendar sometimes returns text/html instead of text/calendar
|
|
|
|
|
|
headers = {"User-Agent": "Grafana OnCall"}
|
|
|
|
|
|
r = requests.get(ical_url, headers=headers, timeout=10)
|
|
|
|
|
|
logger.info(f"fetch_ical_file: content-type={r.headers.get('Content-Type')}")
|
2023-06-27 12:23:08 +02:00
|
|
|
|
return r.text
|
2023-02-22 07:29:59 +01:00
|
|
|
|
|
|
|
|
|
|
|
2022-06-03 08:09:47 -06:00
|
|
|
|
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")
|
2023-04-18 14:07:11 -03:00
|
|
|
|
cal.add("version", "2.0")
|
2022-06-03 08:09:47 -06:00
|
|
|
|
cal.add("prodid", "//Grafana Labs//Grafana On-Call//")
|
2023-04-24 12:56:37 -03:00
|
|
|
|
# suggested minimum interval for polling for changes
|
2024-01-23 08:14:59 -06:00
|
|
|
|
cal.add("REFRESH-INTERVAL;VALUE=DURATION", "PT1H")
|
2022-06-03 08:09:47 -06:00
|
|
|
|
|
|
|
|
|
|
return cal
|
|
|
|
|
|
|
|
|
|
|
|
|
2023-06-27 12:23:08 +02:00
|
|
|
|
def get_user_events_from_calendars(
|
|
|
|
|
|
ical_obj: Calendar, calendar: Calendar, user: User, name: typing.Optional[str] = None
|
|
|
|
|
|
) -> None:
|
|
|
|
|
|
if calendar:
|
|
|
|
|
|
for component in calendar.walk():
|
|
|
|
|
|
if component.name == "VEVENT":
|
|
|
|
|
|
event_user = get_usernames_from_ical_event(component)
|
|
|
|
|
|
event_user_value = event_user[0][0]
|
|
|
|
|
|
if event_user_value == user.username or event_user_value.lower() == user.email.lower():
|
|
|
|
|
|
if name:
|
|
|
|
|
|
component["SUMMARY"] = "{}: {}".format(name, component["SUMMARY"])
|
2022-06-03 08:09:47 -06:00
|
|
|
|
ical_obj.add_component(component)
|
|
|
|
|
|
|
|
|
|
|
|
|
2023-06-27 12:23:08 +02:00
|
|
|
|
def _get_ical_data_final_schedule(schedule: "OnCallSchedule") -> str | None:
|
2023-04-18 14:07:11 -03:00
|
|
|
|
ical_data = schedule.cached_ical_final_schedule
|
|
|
|
|
|
if ical_data is None:
|
|
|
|
|
|
schedule.refresh_ical_final_schedule()
|
2023-08-03 11:43:03 +02:00
|
|
|
|
# casting is safe here. cached_ical_final_schedule is updated inside of refresh_ical_final_schedule
|
|
|
|
|
|
return typing.cast(str, schedule.cached_ical_final_schedule)
|
2023-04-18 14:07:11 -03:00
|
|
|
|
return ical_data
|
|
|
|
|
|
|
|
|
|
|
|
|
2023-06-27 12:23:08 +02:00
|
|
|
|
def ical_export_from_schedule(schedule: "OnCallSchedule") -> bytes:
|
2023-04-25 12:40:27 -03:00
|
|
|
|
ical_data = _get_ical_data_final_schedule(schedule)
|
|
|
|
|
|
return ical_data.encode()
|
2022-06-03 08:09:47 -06:00
|
|
|
|
|
|
|
|
|
|
|
2023-06-27 12:23:08 +02:00
|
|
|
|
def user_ical_export(user: "User", schedules: "OnCallScheduleQuerySet") -> bytes:
|
2022-06-03 08:09:47 -06:00
|
|
|
|
schedule_name = "On-Call Schedule for {0}".format(user.username)
|
|
|
|
|
|
ical_obj = create_base_icalendar(schedule_name)
|
|
|
|
|
|
|
|
|
|
|
|
for schedule in schedules:
|
2023-04-25 12:40:27 -03:00
|
|
|
|
name = schedule.name
|
|
|
|
|
|
ical_data = _get_ical_data_final_schedule(schedule)
|
2023-06-27 12:23:08 +02:00
|
|
|
|
get_user_events_from_calendars(ical_obj, Calendar.from_ical(ical_data), user, name=name)
|
2022-06-03 08:09:47 -06:00
|
|
|
|
|
|
|
|
|
|
return ical_obj.to_ical()
|
|
|
|
|
|
|
|
|
|
|
|
|
2023-06-27 12:23:08 +02:00
|
|
|
|
def detect_gaps(intervals: DatetimeIntervals, start: datetime.datetime, end: datetime.datetime) -> DatetimeIntervals:
|
|
|
|
|
|
gaps: DatetimeIntervals = []
|
2022-06-03 08:09:47 -06:00
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
|
|
2023-06-27 12:23:08 +02:00
|
|
|
|
def merge_if_overlaps(a: DatetimeInterval, b: DatetimeInterval) -> typing.Tuple[bool, DatetimeInterval]:
|
2022-06-03 08:09:47 -06:00
|
|
|
|
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)
|
|
|
|
|
|
|
|
|
|
|
|
|
2023-06-27 12:23:08 +02:00
|
|
|
|
def start_end_with_respect_to_all_day(event: IcalEvent, calendar_tz):
|
2022-06-03 08:09:47 -06:00
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
|
|
2023-06-27 12:23:08 +02:00
|
|
|
|
def event_start_end_all_day_with_respect_to_type(event: IcalEvent, calendar_tz):
|
2022-09-05 11:10:47 +03:00
|
|
|
|
all_day = False
|
2024-04-02 10:26:19 -04:00
|
|
|
|
if type(event[ICAL_DATETIME_START].dt) is datetime.date:
|
2022-09-05 11:10:47 +03:00
|
|
|
|
start, end = start_end_with_respect_to_all_day(event, calendar_tz)
|
|
|
|
|
|
all_day = True
|
|
|
|
|
|
else:
|
|
|
|
|
|
start, end = ical_events.get_start_and_end_with_respect_to_event_type(event)
|
|
|
|
|
|
return start, end, all_day
|
|
|
|
|
|
|
|
|
|
|
|
|
2023-06-27 12:23:08 +02:00
|
|
|
|
def convert_windows_timezone_to_iana(tz_name: str) -> str | None:
|
2022-06-03 08:09:47 -06:00
|
|
|
|
"""
|
|
|
|
|
|
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
|