oncall-engine/engine/apps/alerts/tasks/notify_ical_schedule_shift.py

184 lines
7.2 KiB
Python
Raw Permalink Normal View History

import datetime
import json
import typing
from typing import TYPE_CHECKING
from apps.schedules.ical_utils import calculate_shift_diff, parse_event_uid
from apps.slack.client import SlackClient
from apps.slack.errors import (
SlackAPIChannelArchivedError,
SlackAPIChannelNotFoundError,
SlackAPIInvalidAuthError,
SlackAPITokenError,
)
from apps.slack.scenarios import scenario_step
from common.custom_celery_tasks import shared_dedicated_queue_retry_task
from .task_logger import task_logger
if TYPE_CHECKING:
from apps.schedules.models import OnCallSchedule
MIN_DAYS_TO_LOOKUP_FOR_THE_END_OF_EVENT = 3
def convert_prev_shifts_to_new_format(prev_shifts: dict, schedule: "OnCallSchedule") -> list:
new_prev_shifts = []
user_ids = []
users_info: typing.Dict[int, typing.Dict[str, str]] = {}
for shift in prev_shifts.values():
user_ids.extend(shift.get("users", []))
prev_users = schedule.organization.users.filter(id__in=user_ids)
for user in prev_users:
users_info.setdefault(
user.id,
{
"display_name": user.username,
"email": user.email,
"pk": user.public_primary_key,
"avatar_full": user.avatar_full_url(schedule.organization),
},
)
for uid, shift in prev_shifts.items():
shift_pk, _ = parse_event_uid(uid)
new_prev_shifts.append(
{
"users": [users_info[user_pk] for user_pk in shift["users"]],
"start": shift["start"],
"end": shift["end"],
"all_day": shift["all_day"],
"priority_level": shift["priority"],
"shift": {"pk": shift_pk},
}
)
return new_prev_shifts
@shared_dedicated_queue_retry_task()
def notify_ical_schedule_shift(schedule_pk):
task_logger.info(f"Start notify ical schedule shift {schedule_pk}")
`apps.get_model` -> `import` (#2619) # What this PR does Remove [`apps.get_model`](https://docs.djangoproject.com/en/3.2/ref/applications/#django.apps.apps.get_model) invocations and use inline `import` statements in places where models are imported within functions/methods to avoid circular imports. I believe `import` statements are more appropriate for most use cases as they allow for better static code analysis & formatting, and solve the issue of circular imports without being unnecessarily dynamic as `apps.get_model`. With `import` statements, it's possible to: - Jump to model definitions in most IDEs - Automatically sort inline imports with `isort` - Find import errors faster/easier (most IDEs highlight broken imports) - Have more consistency across regular & inline imports when importing models This PR also adds a flake8 rule to ban imports of `django.apps.apps`, so it's harder to use `apps.get_model` by mistake (it's possible to ignore this rule by using `# noqa: I251`). The rule is not enforced on directories with migration files, because `apps.get_model` is often used to get a historical state of a model, which is useful when writing migrations ([see this SO answer for more details](https://stackoverflow.com/a/37769213)). So `apps.get_model` is considered OK in migrations (even necessary in some cases). ## 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-07-25 10:43:23 +01:00
from apps.schedules.models import OnCallSchedule
try:
schedule = OnCallSchedule.objects.get(
feat: convert `schedule.channel` (char field) to `schedule.slack_channel` (foreign key) (#5199) # What this PR does `OnCallSchedule` equivalent of https://github.com/grafana/oncall/pull/5191. **NOTE**: merge after https://github.com/grafana/oncall/pull/5224 (so that I can use some of the new serializer fields defined in there) ### Migration ```bash Running migrations: │ │ source=engine:app google_trace_id=none logger=apps.schedules.migrations.0019_auto_20241021_1735 Starting migration to populate slack_channel field. │ │ source=engine:app google_trace_id=none logger=apps.schedules.migrations.0019_auto_20241021_1735 Total schedules to process: 1 │ │ source=engine:app google_trace_id=none logger=apps.schedules.migrations.0019_auto_20241021_1735 Schedule 26 updated with SlackChannel 2 (slack_id: C043LL6RTS7). │ │ source=engine:app google_trace_id=none logger=apps.schedules.migrations.0019_auto_20241021_1735 Bulk updated 1 OnCallSchedules with their Slack channel. │ │ source=engine:app google_trace_id=none logger=apps.schedules.migrations.0019_auto_20241021_1735 Finished migration. Total schedules processed: 1. Schedules updated: 1. Missing SlackChannels: 0. │ │ Applying schedules.0019_auto_20241021_1735... OK ``` ### Tested Public API ```txt POST {{oncall_host}}/api/v1/schedules/ Authorization: {{oncall_api_key}} Content-Type: application/json { "name": "Demo testy testy2", "type": "web", "time_zone": "America/Los_Angeles", "slack": { "channel_id": "C05PPLYN1U1" } } HTTP/1.1 201 Created Content-Type: application/json Vary: Accept, Origin Allow: GET, POST, HEAD, OPTIONS X-Frame-Options: DENY Content-Length: 198 X-Content-Type-Options: nosniff Referrer-Policy: same-origin Cross-Origin-Opener-Policy: same-origin { "id": "SBBN73UTUTVCE", "team_id": null, "name": "Demo testy testy2", "time_zone": "America/Los_Angeles", "on_call_now": [], "shifts": [], "slack": { "channel_id": "C05PPLYN1U1", "user_group_id": null }, "type": "web" } ``` ### Tested via UI (eg; internal API) https://www.loom.com/share/e66bf3468b144dd782da5eb6e0bfd0af ## 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] Added the relevant release notes label (see labels prefixed w/ `release:`). These labels dictate how your PR will show up in the autogenerated release notes.
2024-11-04 14:27:21 -05:00
pk=schedule_pk, cached_ical_file_primary__isnull=False, slack_channel__isnull=False
)
except OnCallSchedule.DoesNotExist:
task_logger.info(f"Trying to notify ical schedule shift for non-existing schedule {schedule_pk}")
return
if schedule.organization.slack_team_identity is None:
task_logger.info(
f"Trying to notify ical schedule shift with no slack team identity {schedule_pk}, "
f"organization {schedule.organization_id}"
)
return
elif schedule.organization.deleted_at:
task_logger.info(
f"Trying to notify ical schedule shift from deleted organization {schedule_pk}, "
f"organization {schedule.organization_id}"
)
return
task_logger.info(f"Notify ical schedule shift {schedule_pk}, organization {schedule.organization_id}")
prev_shifts = json.loads(schedule.current_shifts) if not schedule.empty_oncall else []
prev_shifts_updated = False
# convert prev_shifts to new events format for compatibility with the previous version of this task
if prev_shifts and isinstance(prev_shifts, dict):
prev_shifts = convert_prev_shifts_to_new_format(prev_shifts, schedule)
prev_shifts_updated = True
def _parse_timestamp(timestamp: str) -> datetime.datetime:
# convert datetimes which was dumped to str back to datetime to calculate shift diff correct
try:
dt = datetime.datetime.strptime(timestamp, "%Y-%m-%d %X%z")
except ValueError:
dt = datetime.datetime.strptime(timestamp, "%Y-%m-%d %X.%f%z")
return dt
for prev_shift in prev_shifts:
prev_shift["start"] = _parse_timestamp(prev_shift["start"])
prev_shift["end"] = _parse_timestamp(prev_shift["end"])
# get shifts in progress now
now = datetime.datetime.now(datetime.timezone.utc)
current_shifts = schedule.final_events(now, now, with_empty=False, with_gap=False, ignore_untaken_swaps=True)
# get days_to_lookup for next shifts (which may affect current shifts)
if len(current_shifts) != 0:
max_end_date = max([shift["end"].date() for shift in current_shifts])
days_to_lookup = (max_end_date - now.date()).days + 1
days_to_lookup = max([days_to_lookup, MIN_DAYS_TO_LOOKUP_FOR_THE_END_OF_EVENT])
else:
days_to_lookup = MIN_DAYS_TO_LOOKUP_FOR_THE_END_OF_EVENT
# get updated current and upcoming shifts
datetime_end = now + datetime.timedelta(days=days_to_lookup)
next_shifts_unfiltered = schedule.final_events(
now, datetime_end, with_empty=False, with_gap=False, ignore_untaken_swaps=True
)
# split current and next shifts
current_shifts = []
next_shifts = []
for shift in next_shifts_unfiltered:
if now < shift["start"]:
next_shifts.append(shift)
else:
current_shifts.append(shift)
shift_changed, diff_shifts = calculate_shift_diff(current_shifts, prev_shifts)
# Do not notify if there is no difference between current and previous shifts
if not shift_changed:
task_logger.info(f"No shift diff found for schedule {schedule_pk}, organization {schedule.organization_id}")
# If prev shifts were converted to a new format, update related field in db
if prev_shifts_updated:
schedule.current_shifts = json.dumps(current_shifts, default=str)
schedule.save(update_fields=["current_shifts"])
return
new_shifts = sorted(diff_shifts, key=lambda shift: shift["start"])
upcoming_shifts = []
# Add the earliest next_shift
if len(next_shifts) > 0:
earliest_shift = next_shifts[0]
upcoming_shifts.append(earliest_shift)
# Check if there are next shifts with the same start as the earliest
for shift in next_shifts[1:]:
if shift["start"] == earliest_shift["start"]:
upcoming_shifts.append(shift)
schedule.empty_oncall = len(current_shifts) == 0
if not schedule.empty_oncall:
schedule.current_shifts = json.dumps(current_shifts, default=str)
schedule.save(update_fields=["current_shifts", "empty_oncall"])
if len(new_shifts) > 0 or schedule.empty_oncall:
task_logger.info(f"new_shifts: {new_shifts}")
if schedule.notify_oncall_shift_freq != OnCallSchedule.NotifyOnCallShiftFreq.NEVER:
slack_client = SlackClient(
schedule.organization.slack_team_identity,
enable_ratelimit_retry=True,
)
step = scenario_step.ScenarioStep.get_step("schedules", "EditScheduleShiftNotifyStep")
report_blocks = step.get_report_blocks_ical(new_shifts, upcoming_shifts, schedule, schedule.empty_oncall)
try:
update slack_sdk dependency to latest version (#2947) # What this PR does - update `slackclient` dependency to latest version. The version we were using was 5 years old 😲 - first followed the v2 migration guide [here](https://github.com/slackapi/python-slack-sdk/wiki/Migrating-to-2.x) followed by the v3 migration guide [here](https://slack.dev/python-slack-sdk/v3-migration/). The main changes were: - The PyPI project was renamed from `slackclient` to `slack_sdk` - it is discouraged/harder to call `api_call` and encouraged to call the helper methods (ex. `chat_postMessage`; [note](https://github.com/slackapi/python-slack-sdk/wiki/Migrating-to-2.x#web-client-api-changes) in migration guide docs) - In 1.x, a failed api call would return the error payload to you and have you handle the error. In 2.x, a failed api call will throw an exception. To handle this in your code, you will have to wrap api calls with a try except block. Since we overload `WebClient.api_call` this was an easy change and only required a one line change - remove `apps.slack.slack_client.slack_server.SlackClientServer` class. The new version of `slack_sdk` handles the case that we needed to overload for in the first place. - merged `apps/slack/slack_client/slack_client.py` and `apps/slack/slack_client/exceptions.py` into `apps/slack/client.py` ## 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-09-05 11:31:59 +02:00
slack_client.chat_postMessage(
feat: convert `schedule.channel` (char field) to `schedule.slack_channel` (foreign key) (#5199) # What this PR does `OnCallSchedule` equivalent of https://github.com/grafana/oncall/pull/5191. **NOTE**: merge after https://github.com/grafana/oncall/pull/5224 (so that I can use some of the new serializer fields defined in there) ### Migration ```bash Running migrations: │ │ source=engine:app google_trace_id=none logger=apps.schedules.migrations.0019_auto_20241021_1735 Starting migration to populate slack_channel field. │ │ source=engine:app google_trace_id=none logger=apps.schedules.migrations.0019_auto_20241021_1735 Total schedules to process: 1 │ │ source=engine:app google_trace_id=none logger=apps.schedules.migrations.0019_auto_20241021_1735 Schedule 26 updated with SlackChannel 2 (slack_id: C043LL6RTS7). │ │ source=engine:app google_trace_id=none logger=apps.schedules.migrations.0019_auto_20241021_1735 Bulk updated 1 OnCallSchedules with their Slack channel. │ │ source=engine:app google_trace_id=none logger=apps.schedules.migrations.0019_auto_20241021_1735 Finished migration. Total schedules processed: 1. Schedules updated: 1. Missing SlackChannels: 0. │ │ Applying schedules.0019_auto_20241021_1735... OK ``` ### Tested Public API ```txt POST {{oncall_host}}/api/v1/schedules/ Authorization: {{oncall_api_key}} Content-Type: application/json { "name": "Demo testy testy2", "type": "web", "time_zone": "America/Los_Angeles", "slack": { "channel_id": "C05PPLYN1U1" } } HTTP/1.1 201 Created Content-Type: application/json Vary: Accept, Origin Allow: GET, POST, HEAD, OPTIONS X-Frame-Options: DENY Content-Length: 198 X-Content-Type-Options: nosniff Referrer-Policy: same-origin Cross-Origin-Opener-Policy: same-origin { "id": "SBBN73UTUTVCE", "team_id": null, "name": "Demo testy testy2", "time_zone": "America/Los_Angeles", "on_call_now": [], "shifts": [], "slack": { "channel_id": "C05PPLYN1U1", "user_group_id": null }, "type": "web" } ``` ### Tested via UI (eg; internal API) https://www.loom.com/share/e66bf3468b144dd782da5eb6e0bfd0af ## 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] Added the relevant release notes label (see labels prefixed w/ `release:`). These labels dictate how your PR will show up in the autogenerated release notes.
2024-11-04 14:27:21 -05:00
channel=schedule.slack_channel_slack_id,
blocks=report_blocks,
text=f"On-call shift for schedule {schedule.name} has changed",
)
except (
SlackAPITokenError,
SlackAPIChannelNotFoundError,
SlackAPIChannelArchivedError,
SlackAPIInvalidAuthError,
):
pass