Rework schedule ical export (#1783)

Related to #1501. Behind a feature flag, will migrate existing exports
to use the new ical export transparently.
This commit is contained in:
Matias Bordese 2023-04-18 14:07:11 -03:00 committed by GitHub
parent 6cff9729d8
commit 017d98efad
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 457 additions and 11 deletions

View file

@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## Unreleased
### Changed
- Rework ical schedule export to include final events; also improve changing shifts sync
## v1.2.12 (2023-04-18)
### Changed

View file

@ -169,6 +169,7 @@ class ScheduleView(
# avoid requesting large text fields which are not used when listing schedules
"prev_ical_file_primary",
"prev_ical_file_overrides",
"cached_ical_final_schedule",
)
if not ignore_filtering_by_available_teams:
queryset = queryset.filter(*self.available_teams_lookup_args).distinct()

View file

@ -5,6 +5,7 @@ from rest_framework import status
from rest_framework.test import APIClient
from apps.auth_token.models import ScheduleExportAuthToken, UserScheduleExportAuthToken
from apps.schedules.constants import ICAL_COMPONENT_VEVENT, ICAL_SUMMARY
from apps.schedules.models import OnCallScheduleICal
ICAL_DATA = """
@ -48,9 +49,13 @@ END:VCALENDAR
@pytest.mark.django_db
def test_export_calendar(make_organization_and_user_with_token, make_schedule):
def test_export_calendar(make_organization_and_user_with_token, make_user_for_organization, make_schedule):
organization, user, _ = make_organization_and_user_with_token()
usernames = {"amixr", "justin.hunthrop@grafana.com"}
# setup users for shifts
for u in usernames:
make_user_for_organization(organization, username=u)
schedule = make_schedule(
organization,
@ -75,7 +80,11 @@ def test_export_calendar(make_organization_and_user_with_token, make_schedule):
cal = Calendar.from_ical(response.data)
assert type(cal) == Calendar
assert len(cal.subcomponents) == 2
# check there are events
assert len(cal.subcomponents) > 0
for component in cal.walk():
if component.name == ICAL_COMPONENT_VEVENT:
assert component[ICAL_SUMMARY] in usernames
@pytest.mark.django_db

View file

@ -38,7 +38,12 @@ class OnCallScheduleChannelView(RateLimitHeadersMixin, UpdateSerializerMixin, Mo
def get_queryset(self):
name = self.request.query_params.get("name", None)
queryset = OnCallSchedule.objects.filter(organization=self.request.auth.organization)
queryset = OnCallSchedule.objects.filter(organization=self.request.auth.organization).defer(
# avoid requesting large text fields which are not used when listing schedules
"prev_ical_file_primary",
"prev_ical_file_overrides",
"cached_ical_final_schedule",
)
if name is not None:
queryset = queryset.filter(name=name)

View file

@ -9,6 +9,13 @@ ICAL_ATTENDEE = "ATTENDEE"
ICAL_UID = "UID"
ICAL_RRULE = "RRULE"
ICAL_UNTIL = "UNTIL"
ICAL_LAST_MODIFIED = "LAST-MODIFIED"
ICAL_STATUS = "STATUS"
ICAL_STATUS_CANCELLED = "CANCELLED"
ICAL_COMPONENT_VEVENT = "VEVENT"
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+)")
EXPORT_WINDOW_DAYS_AFTER = 180
EXPORT_WINDOW_DAYS_BEFORE = 15

View file

@ -601,6 +601,7 @@ def create_base_icalendar(name: str) -> Calendar:
cal.add("calscale", "GREGORIAN")
cal.add("x-wr-calname", name)
cal.add("x-wr-timezone", "UTC")
cal.add("version", "2.0")
cal.add("prodid", "//Grafana Labs//Grafana On-Call//")
return cal
@ -614,7 +615,7 @@ def get_events_from_calendars(ical_obj: Calendar, calendars: tuple) -> None:
ical_obj.add_component(component)
def get_user_events_from_calendars(ical_obj: Calendar, calendars: tuple, user: User) -> None:
def get_user_events_from_calendars(ical_obj: Calendar, calendars: tuple, user: User, name: str = None) -> None:
for calendar in calendars:
if calendar:
for component in calendar.walk():
@ -622,14 +623,41 @@ def get_user_events_from_calendars(ical_obj: Calendar, calendars: tuple, user: U
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"])
ical_obj.add_component(component)
def _is_final_export_enabled(schedule: OnCallSchedule) -> bool:
DynamicSetting = apps.get_model("base", "DynamicSetting")
enabled_final_export = DynamicSetting.objects.get_or_create(
name="enabled_final_schedule_export",
defaults={
"json_value": {
"schedule_ids": [],
}
},
)[0]
return schedule.public_primary_key in enabled_final_export.json_value["schedule_ids"]
def _get_ical_data_final_schedule(schedule: OnCallSchedule) -> str:
ical_data = schedule.cached_ical_final_schedule
if ical_data is None:
schedule.refresh_ical_final_schedule()
ical_data = schedule.cached_ical_final_schedule
return ical_data
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()
if _is_final_export_enabled(schedule):
ical_data = _get_ical_data_final_schedule(schedule)
return ical_data.encode()
else:
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:
@ -637,8 +665,14 @@ def user_ical_export(user: User, schedules: list[OnCallSchedule]) -> bytes:
ical_obj = create_base_icalendar(schedule_name)
for schedule in schedules:
calendars = schedule.get_icalendars()
get_user_events_from_calendars(ical_obj, calendars, user)
if _is_final_export_enabled(schedule):
name = schedule.name
ical_data = _get_ical_data_final_schedule(schedule)
calendars = [Calendar.from_ical(ical_data)]
else:
name = None
calendars = schedule.get_icalendars()
get_user_events_from_calendars(ical_obj, calendars, user, name=name)
return ical_obj.to_ical()

View file

@ -0,0 +1,18 @@
# Generated by Django 3.2.18 on 2023-04-11 19:03
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('schedules', '0010_fix_polymorphic_delete_related'),
]
operations = [
migrations.AddField(
model_name='oncallschedule',
name='cached_ical_final_schedule',
field=models.TextField(default=None, null=True),
),
]

View file

@ -21,6 +21,7 @@ from recurring_ical_events import UnfoldableCalendar
from apps.schedules.tasks import (
drop_cached_ical_task,
refresh_ical_final_schedule,
schedule_notify_about_empty_shifts_in_schedule,
schedule_notify_about_gaps_in_schedule,
)
@ -670,6 +671,7 @@ class CustomOnCallShift(models.Model):
drop_cached_ical_task.apply_async((schedule.pk,))
schedule_notify_about_empty_shifts_in_schedule.apply_async((schedule.pk,))
schedule_notify_about_gaps_in_schedule.apply_async((schedule.pk,))
refresh_ical_final_schedule.apply_async((schedule.pk,))
@cached_property
def last_updated_shift(self):

View file

@ -19,7 +19,21 @@ from polymorphic.managers import PolymorphicManager
from polymorphic.models import PolymorphicModel
from polymorphic.query import PolymorphicQuerySet
from apps.schedules.constants import (
EXPORT_WINDOW_DAYS_AFTER,
EXPORT_WINDOW_DAYS_BEFORE,
ICAL_COMPONENT_VEVENT,
ICAL_DATETIME_END,
ICAL_DATETIME_STAMP,
ICAL_DATETIME_START,
ICAL_LAST_MODIFIED,
ICAL_STATUS,
ICAL_STATUS_CANCELLED,
ICAL_SUMMARY,
ICAL_UID,
)
from apps.schedules.ical_utils import (
create_base_icalendar,
fetch_ical_file_or_get_error,
get_oncall_users_for_multiple_schedules,
list_of_empty_shifts_in_schedule,
@ -107,6 +121,8 @@ class OnCallSchedule(PolymorphicModel):
cached_ical_file_overrides = models.TextField(null=True, default=None)
prev_ical_file_overrides = models.TextField(null=True, default=None)
cached_ical_final_schedule = models.TextField(null=True, default=None)
organization = models.ForeignKey(
"user_management.Organization", on_delete=NON_POLYMORPHIC_CASCADE, related_name="oncall_schedules"
)
@ -295,6 +311,63 @@ class OnCallSchedule(PolymorphicModel):
events = self._resolve_schedule(events)
return events
def refresh_ical_final_schedule(self):
# TODO: check flag?
tz = "UTC"
now = timezone.now()
# window to consider: from now, -15 days + 6 months
delta = EXPORT_WINDOW_DAYS_BEFORE
starting_datetime = now - timezone.timedelta(days=delta)
starting_date = starting_datetime.date()
days = EXPORT_WINDOW_DAYS_AFTER + delta
# setup calendar with final schedule shift events
calendar = create_base_icalendar(self.name)
events = self.final_events(tz, starting_date, days)
updated_ids = set()
for e in events:
for u in e["users"]:
event = icalendar.Event()
event.add(ICAL_SUMMARY, u["display_name"])
event.add(ICAL_DATETIME_START, e["start"])
event.add(ICAL_DATETIME_END, e["end"])
event.add(ICAL_DATETIME_STAMP, now)
event.add(ICAL_LAST_MODIFIED, now)
event_uid = "{}-{}-{}".format(e["shift"]["pk"], e["start"].strftime("%Y%m%d%H%S"), u["pk"])
event[ICAL_UID] = event_uid
calendar.add_component(event)
updated_ids.add(event_uid)
# check previously cached final schedule for potentially cancelled events
if self.cached_ical_final_schedule:
previous = icalendar.Calendar.from_ical(self.cached_ical_final_schedule)
for component in previous.walk():
if component.name == ICAL_COMPONENT_VEVENT and component[ICAL_UID] not in updated_ids:
# check if event was ended or cancelled, update ical
dtend = component.get(ICAL_DATETIME_END)
if dtend and dtend.dt < starting_datetime:
# event ended before window start
continue
is_cancelled = component.get(ICAL_STATUS)
last_modified = component.get(ICAL_LAST_MODIFIED)
if is_cancelled and last_modified and last_modified.dt < starting_datetime:
# drop already ended events older than the window we consider
continue
elif is_cancelled and not last_modified:
# set last_modified if it was missing (e.g. from previous export ical implementation)
component[ICAL_LAST_MODIFIED] = icalendar.vDatetime(now).to_ical()
elif not is_cancelled:
# set the event as cancelled
component[ICAL_DATETIME_END] = component[ICAL_DATETIME_START]
component[ICAL_STATUS] = ICAL_STATUS_CANCELLED
component[ICAL_LAST_MODIFIED] = icalendar.vDatetime(now).to_ical()
# include just cancelled events as well as those that were cancelled during the time window
calendar.add_component(component)
ical_data = calendar.to_ical().decode()
self.cached_ical_final_schedule = ical_data
self.save(update_fields=["cached_ical_final_schedule"])
def upcoming_shift_for_user(self, user, days=7):
user_tz = user.timezone or "UTC"
now = timezone.now()

View file

@ -13,4 +13,9 @@ from .notify_about_gaps_in_schedule import ( # noqa: F401
start_check_gaps_in_schedule,
start_notify_about_gaps_in_schedule,
)
from .refresh_ical_files import refresh_ical_file, start_refresh_ical_files # noqa: F401
from .refresh_ical_files import ( # noqa: F401
refresh_ical_file,
refresh_ical_final_schedule,
start_refresh_ical_files,
start_refresh_ical_final_schedules,
)

View file

@ -24,6 +24,17 @@ def start_refresh_ical_files():
start_update_slack_user_group_for_schedules.apply_async(countdown=30)
@shared_dedicated_queue_retry_task()
def start_refresh_ical_final_schedules():
OnCallSchedule = apps.get_model("schedules", "OnCallSchedule")
task_logger.info("Start refresh ical final schedules")
schedules = OnCallSchedule.objects.all()
for schedule in schedules:
refresh_ical_final_schedule.apply_async((schedule.pk,))
@shared_dedicated_queue_retry_task()
def refresh_ical_file(schedule_pk):
OnCallSchedule = apps.get_model("schedules", "OnCallSchedule")
@ -74,3 +85,17 @@ def refresh_ical_file(schedule_pk):
if run_task:
notify_about_empty_shifts_in_schedule.apply_async((schedule_pk,))
notify_about_gaps_in_schedule.apply_async((schedule_pk,))
@shared_dedicated_queue_retry_task()
def refresh_ical_final_schedule(schedule_pk):
OnCallSchedule = apps.get_model("schedules", "OnCallSchedule")
task_logger.info(f"Refresh ical final schedule {schedule_pk}")
try:
schedule = OnCallSchedule.objects.get(pk=schedule_pk)
except OnCallSchedule.DoesNotExist:
task_logger.info(f"Tried to refresh final schedule for non-existing schedule {schedule_pk}")
return
schedule.refresh_ical_final_schedule()

View file

@ -1,11 +1,22 @@
import datetime
import textwrap
from unittest.mock import patch
import icalendar
import pytest
import pytz
from django.utils import timezone
from apps.api.permissions import LegacyAccessControlRole
from apps.schedules.constants import (
ICAL_COMPONENT_VEVENT,
ICAL_DATETIME_END,
ICAL_DATETIME_START,
ICAL_LAST_MODIFIED,
ICAL_STATUS,
ICAL_STATUS_CANCELLED,
ICAL_SUMMARY,
)
from apps.schedules.ical_utils import memoized_users_in_ical
from apps.schedules.models import (
CustomOnCallShift,
@ -1311,3 +1322,248 @@ def test_upcoming_shift_for_user(
current_shift, upcoming_shift = schedule.upcoming_shift_for_user(other_user)
assert current_shift is None
assert upcoming_shift is None
@pytest.mark.django_db
def test_refresh_ical_final_schedule_ok(
make_organization,
make_user_for_organization,
make_schedule,
make_on_call_shift,
):
organization = make_organization()
u1 = make_user_for_organization(organization)
u2 = make_user_for_organization(organization)
today = timezone.now().replace(hour=0, minute=0, second=0, microsecond=0)
schedule = make_schedule(organization, schedule_class=OnCallScheduleWeb)
shifts = (
# user, priority, start time (h), duration (seconds)
(u1, 1, 0, (12 * 60 * 60) - 1), # r1-1: 0-11:59:59
(u2, 1, 12, (12 * 60 * 60) - 1), # r1-1: 12-23:59:59
)
for user, priority, start_h, duration in shifts:
data = {
"start": today + timezone.timedelta(hours=start_h),
"rotation_start": today + timezone.timedelta(hours=start_h),
"duration": timezone.timedelta(seconds=duration),
"priority_level": priority,
"frequency": CustomOnCallShift.FREQUENCY_DAILY,
"schedule": schedule,
}
on_call_shift = make_on_call_shift(
organization=organization, shift_type=CustomOnCallShift.TYPE_ROLLING_USERS_EVENT, **data
)
on_call_shift.add_rolling_users([[user]])
override_data = {
"start": today + timezone.timedelta(hours=22),
"rotation_start": today + timezone.timedelta(hours=22),
"duration": timezone.timedelta(hours=1),
"schedule": schedule,
}
override = make_on_call_shift(
organization=organization, shift_type=CustomOnCallShift.TYPE_OVERRIDE, **override_data
)
override.add_rolling_users([[u1]])
schedule.refresh_ical_file()
expected_events = {
# user, start, end
(u1.username, today, today + timezone.timedelta(seconds=(12 * 60 * 60) - 1)),
(u2.username, today + timezone.timedelta(hours=12), today + timezone.timedelta(hours=22)),
(u1.username, today + timezone.timedelta(hours=22), today + timezone.timedelta(hours=23)),
(u2.username, today + timezone.timedelta(hours=23), today + timezone.timedelta(seconds=(24 * 60 * 60) - 1)),
}
for i in range(2):
# running multiple times keeps the same events in place
with patch("apps.schedules.models.on_call_schedule.EXPORT_WINDOW_DAYS_AFTER", 1):
with patch("apps.schedules.models.on_call_schedule.EXPORT_WINDOW_DAYS_BEFORE", 0):
schedule.refresh_ical_final_schedule()
assert schedule.cached_ical_final_schedule
calendar = icalendar.Calendar.from_ical(schedule.cached_ical_final_schedule)
for component in calendar.walk():
if component.name == ICAL_COMPONENT_VEVENT:
event = (component[ICAL_SUMMARY], component[ICAL_DATETIME_START].dt, component[ICAL_DATETIME_END].dt)
assert event in expected_events
@pytest.mark.django_db
def test_refresh_ical_final_schedule_cancel_deleted_events(
make_organization,
make_user_for_organization,
make_schedule,
make_on_call_shift,
):
organization = make_organization()
u1 = make_user_for_organization(organization)
u2 = make_user_for_organization(organization)
today = timezone.now().replace(hour=0, minute=0, second=0, microsecond=0)
schedule = make_schedule(organization, schedule_class=OnCallScheduleWeb)
shifts = (
# user, priority, start time (h), duration (seconds)
(u1, 1, 0, (24 * 60 * 60) - 1), # r1-1: 0-23:59:59
)
for user, priority, start_h, duration in shifts:
data = {
"start": today + timezone.timedelta(hours=start_h),
"rotation_start": today + timezone.timedelta(hours=start_h),
"duration": timezone.timedelta(seconds=duration),
"priority_level": priority,
"frequency": CustomOnCallShift.FREQUENCY_DAILY,
"schedule": schedule,
}
on_call_shift = make_on_call_shift(
organization=organization, shift_type=CustomOnCallShift.TYPE_ROLLING_USERS_EVENT, **data
)
on_call_shift.add_rolling_users([[user]])
override_data = {
"start": today + timezone.timedelta(hours=22),
"rotation_start": today + timezone.timedelta(hours=22),
"duration": timezone.timedelta(hours=1),
"schedule": schedule,
}
override = make_on_call_shift(
organization=organization, shift_type=CustomOnCallShift.TYPE_OVERRIDE, **override_data
)
override.add_rolling_users([[u2]])
# refresh ical files
schedule.refresh_ical_file()
with patch("apps.schedules.models.on_call_schedule.EXPORT_WINDOW_DAYS_AFTER", 1):
with patch("apps.schedules.models.on_call_schedule.EXPORT_WINDOW_DAYS_BEFORE", 0):
schedule.refresh_ical_final_schedule()
# delete override, re-check the final refresh
override.delete()
# reload instance to avoid cached properties issue
schedule = OnCallScheduleWeb.objects.get(id=schedule.id)
schedule.refresh_ical_file()
with patch("apps.schedules.models.on_call_schedule.EXPORT_WINDOW_DAYS_AFTER", 1):
with patch("apps.schedules.models.on_call_schedule.EXPORT_WINDOW_DAYS_BEFORE", 0):
schedule.refresh_ical_final_schedule()
# check for deleted override
calendar = icalendar.Calendar.from_ical(schedule.cached_ical_final_schedule)
for component in calendar.walk():
if component.name == ICAL_COMPONENT_VEVENT and component[ICAL_SUMMARY] == u2.username:
# check event is cancelled
assert component[ICAL_DATETIME_START].dt == component[ICAL_DATETIME_END].dt
assert component[ICAL_LAST_MODIFIED]
assert component[ICAL_STATUS] == ICAL_STATUS_CANCELLED
@pytest.mark.django_db
def test_refresh_ical_final_schedule_cancelled_not_updated(
make_organization,
make_user_for_organization,
make_schedule,
):
organization = make_organization()
u1 = make_user_for_organization(organization)
u2 = make_user_for_organization(organization)
last_week = timezone.now().replace(hour=0, minute=0, second=0, microsecond=0) - timezone.timedelta(days=7)
last_week_timestamp = last_week.strftime("%Y%m%dT%H%M%S")
cached_ical_final_schedule = textwrap.dedent(
"""
BEGIN:VCALENDAR
VERSION:2.0
PRODID://Grafana Labs//Grafana On-Call//
CALSCALE:GREGORIAN
X-WR-CALNAME:Cup cut.
X-WR-TIMEZONE:UTC
BEGIN:VEVENT
SUMMARY:{}
DTSTART;VALUE=DATE-TIME:20220414T000000Z
DTEND;VALUE=DATE-TIME:20220414T000000Z
DTSTAMP;VALUE=DATE-TIME:20220414T190951Z
UID:O231U3VXVIYRX-202304140000-U5FWIHEASEWS2
LAST-MODIFIED;VALUE=DATE-TIME:20220414T190951Z
STATUS:CANCELLED
END:VEVENT
BEGIN:VEVENT
SUMMARY:{}
DTSTART;VALUE=DATE-TIME:{}Z
DTEND;VALUE=DATE-TIME:{}Z
DTSTAMP;VALUE=DATE-TIME:20230414T190951Z
UID:OBPQ1TI99E4DG-202304141200-U2G6RZQM3S3I9
LAST-MODIFIED;VALUE=DATE-TIME:{}Z
STATUS:CANCELLED
END:VEVENT
END:VCALENDAR
""".format(
u1.username, u2.username, last_week_timestamp, last_week_timestamp, last_week_timestamp
)
)
schedule = make_schedule(
organization,
schedule_class=OnCallScheduleWeb,
cached_ical_final_schedule=cached_ical_final_schedule,
)
schedule.refresh_ical_final_schedule()
# check old event is dropped, recent one is kept unchanged
event_count = 0
calendar = icalendar.Calendar.from_ical(schedule.cached_ical_final_schedule)
for component in calendar.walk():
if component.name == ICAL_COMPONENT_VEVENT:
event_count += 1
if component[ICAL_SUMMARY] == u2.username:
# check event is unchanged
assert component[ICAL_DATETIME_START].dt == last_week
assert component[ICAL_DATETIME_END].dt == last_week
assert component[ICAL_LAST_MODIFIED].dt == last_week
assert component[ICAL_STATUS] == ICAL_STATUS_CANCELLED
assert event_count == 1
@pytest.mark.django_db
def test_refresh_ical_final_schedule_event_in_the_past(
make_organization,
make_user_for_organization,
make_schedule,
):
organization = make_organization()
u1 = make_user_for_organization(organization)
cached_ical_final_schedule = textwrap.dedent(
"""
BEGIN:VCALENDAR
VERSION:2.0
PRODID://Grafana Labs//Grafana On-Call//
CALSCALE:GREGORIAN
X-WR-CALNAME:Cup cut.
X-WR-TIMEZONE:UTC
BEGIN:VEVENT
SUMMARY:{}
DTSTART;VALUE=DATE-TIME:20220414T000000Z
DTEND;VALUE=DATE-TIME:20220414T000000Z
DTSTAMP;VALUE=DATE-TIME:20220414T190951Z
UID:O231U3VXVIYRX-202304140000-U5FWIHEASEWS2
LAST-MODIFIED;VALUE=DATE-TIME:20220414T190951Z
END:VEVENT
END:VCALENDAR
""".format(
u1.username
)
)
schedule = make_schedule(
organization,
schedule_class=OnCallScheduleWeb,
cached_ical_final_schedule=cached_ical_final_schedule,
)
schedule.refresh_ical_final_schedule()
# check old event is dropped, recent one is kept unchanged
calendar = icalendar.Calendar.from_ical(schedule.cached_ical_final_schedule)
events = [component for component in calendar.walk() if component.name == ICAL_COMPONENT_VEVENT]
assert len(events) == 0

View file

@ -414,6 +414,11 @@ CELERY_BEAT_SCHEDULE = {
"schedule": getenv_integer("ALERT_GROUP_ESCALATION_AUDITOR_CELERY_TASK_HEARTBEAT_INTERVAL", 13 * 60),
"args": (),
},
"start_refresh_ical_final_schedules": {
"task": "apps.schedules.tasks.refresh_ical_files.start_refresh_ical_final_schedules",
"schedule": crontab(minute=15, hour=0),
"args": (),
},
"start_refresh_ical_files": {
"task": "apps.schedules.tasks.refresh_ical_files.start_refresh_ical_files",
"schedule": 10 * 60,