Merge pull request #288 from grafana/julia-web-schedule-backend-3
Create new oncall shift on update for started shifts
This commit is contained in:
commit
fdd369c16b
4 changed files with 1286 additions and 31 deletions
|
|
@ -30,13 +30,14 @@ class OnCallShiftSerializer(EagerLoadingMixin, serializers.ModelSerializer):
|
|||
queryset=User.objects, required=False, allow_null=True
|
||||
), # todo: filter by team?
|
||||
)
|
||||
updated_shift = serializers.CharField(read_only=True, allow_null=True, source="updated_shift.public_primary_key")
|
||||
|
||||
class Meta:
|
||||
model = CustomOnCallShift
|
||||
fields = [
|
||||
"id",
|
||||
"organization",
|
||||
"name",
|
||||
"title",
|
||||
"type",
|
||||
"schedule",
|
||||
"priority_level",
|
||||
|
|
@ -49,20 +50,20 @@ class OnCallShiftSerializer(EagerLoadingMixin, serializers.ModelSerializer):
|
|||
"by_day",
|
||||
"source",
|
||||
"rolling_users",
|
||||
"updated_shift",
|
||||
]
|
||||
extra_kwargs = {
|
||||
"interval": {"required": False, "allow_null": True},
|
||||
"source": {"required": False, "write_only": True},
|
||||
}
|
||||
|
||||
SELECT_RELATED = ["schedule"]
|
||||
SELECT_RELATED = ["schedule", "updated_shift"]
|
||||
|
||||
def get_shift_end(self, obj):
|
||||
return obj.start + obj.duration
|
||||
|
||||
def to_internal_value(self, data):
|
||||
data["source"] = CustomOnCallShift.SOURCE_WEB
|
||||
data["week_start"] = CustomOnCallShift.MONDAY
|
||||
if not data.get("shift_end"):
|
||||
raise serializers.ValidationError({"shift_end": ["This field is required."]})
|
||||
|
||||
|
|
@ -73,19 +74,6 @@ class OnCallShiftSerializer(EagerLoadingMixin, serializers.ModelSerializer):
|
|||
result = super().to_representation(instance)
|
||||
return result
|
||||
|
||||
def validate_name(self, name):
|
||||
organization = self.context["request"].auth.organization
|
||||
if name is None:
|
||||
return name
|
||||
try:
|
||||
obj = CustomOnCallShift.objects.get(organization=organization, name=name)
|
||||
except CustomOnCallShift.DoesNotExist:
|
||||
return name
|
||||
if self.instance and obj.id == self.instance.id:
|
||||
return name
|
||||
else:
|
||||
raise serializers.ValidationError(["On-call shift with this name already exists"])
|
||||
|
||||
def validate_by_day(self, by_day):
|
||||
if by_day:
|
||||
for day in by_day:
|
||||
|
|
@ -105,7 +93,7 @@ class OnCallShiftSerializer(EagerLoadingMixin, serializers.ModelSerializer):
|
|||
for users in rolling_users:
|
||||
users_dict = dict()
|
||||
for user in users:
|
||||
users_dict[user.pk] = user.public_primary_key
|
||||
users_dict[str(user.pk)] = user.public_primary_key
|
||||
result.append(users_dict)
|
||||
return result
|
||||
|
||||
|
|
@ -113,7 +101,7 @@ class OnCallShiftSerializer(EagerLoadingMixin, serializers.ModelSerializer):
|
|||
if end <= start:
|
||||
raise serializers.ValidationError({"shift_end": ["Incorrect shift end date"]})
|
||||
|
||||
def _validate_frequency(self, frequency, event_type, rolling_users, interval, by_day):
|
||||
def _validate_frequency(self, frequency, event_type, rolling_users, interval, by_day, until):
|
||||
if frequency is None:
|
||||
if rolling_users and len(rolling_users) > 1:
|
||||
raise serializers.ValidationError(
|
||||
|
|
@ -123,6 +111,8 @@ class OnCallShiftSerializer(EagerLoadingMixin, serializers.ModelSerializer):
|
|||
raise serializers.ValidationError({"interval": ["Cannot set interval for non-recurrent shifts"]})
|
||||
if by_day:
|
||||
raise serializers.ValidationError({"by_day": ["Cannot set days value for non-recurrent shifts"]})
|
||||
if until:
|
||||
raise serializers.ValidationError({"until": ["Cannot set 'until' for non-recurrent shifts"]})
|
||||
else:
|
||||
if event_type == CustomOnCallShift.TYPE_OVERRIDE:
|
||||
raise serializers.ValidationError(
|
||||
|
|
@ -142,10 +132,6 @@ class OnCallShiftSerializer(EagerLoadingMixin, serializers.ModelSerializer):
|
|||
def _correct_validated_data(self, event_type, validated_data):
|
||||
fields_to_update_for_overrides = [
|
||||
"priority_level",
|
||||
"frequency",
|
||||
"interval",
|
||||
"by_day",
|
||||
"until",
|
||||
"rotation_start",
|
||||
]
|
||||
if event_type == CustomOnCallShift.TYPE_OVERRIDE:
|
||||
|
|
@ -163,6 +149,7 @@ class OnCallShiftSerializer(EagerLoadingMixin, serializers.ModelSerializer):
|
|||
validated_data.get("rolling_users"),
|
||||
validated_data.get("interval"),
|
||||
validated_data.get("by_day"),
|
||||
validated_data.get("until"),
|
||||
)
|
||||
self._validate_rotation_start(validated_data["start"], validated_data["rotation_start"])
|
||||
self._validate_until(validated_data["rotation_start"], validated_data.get("until"))
|
||||
|
|
@ -176,11 +163,15 @@ class OnCallShiftSerializer(EagerLoadingMixin, serializers.ModelSerializer):
|
|||
if validated_data.get("schedule"):
|
||||
validated_data["team"] = validated_data["schedule"].team
|
||||
|
||||
validated_data["week_start"] = CustomOnCallShift.MONDAY
|
||||
|
||||
return validated_data
|
||||
|
||||
def create(self, validated_data):
|
||||
validated_data = self._correct_validated_data(validated_data["type"], validated_data)
|
||||
|
||||
validated_data["name"] = CustomOnCallShift.generate_name(
|
||||
validated_data["schedule"], validated_data["priority_level"], validated_data["type"]
|
||||
)
|
||||
instance = super().create(validated_data)
|
||||
|
||||
instance.start_drop_ical_and_check_schedule_tasks(instance.schedule)
|
||||
|
|
@ -196,8 +187,26 @@ class OnCallShiftUpdateSerializer(OnCallShiftSerializer):
|
|||
|
||||
def update(self, instance, validated_data):
|
||||
validated_data = self._correct_validated_data(instance.type, validated_data)
|
||||
change_only_title = True
|
||||
create_or_update_last_shift = False
|
||||
|
||||
result = super().update(instance, validated_data)
|
||||
for field in validated_data:
|
||||
if field != "title" and validated_data[field] != getattr(instance, field):
|
||||
change_only_title = False
|
||||
break
|
||||
|
||||
if not change_only_title:
|
||||
if instance.type != CustomOnCallShift.TYPE_OVERRIDE:
|
||||
if instance.event_is_started:
|
||||
create_or_update_last_shift = True
|
||||
|
||||
elif instance.event_is_finished:
|
||||
raise serializers.ValidationError(["This event cannot be updated"])
|
||||
|
||||
if create_or_update_last_shift:
|
||||
result = instance.create_or_update_last_shift(validated_data)
|
||||
else:
|
||||
result = super().update(instance, validated_data)
|
||||
|
||||
instance.start_drop_ical_and_check_schedule_tasks(instance.schedule)
|
||||
return result
|
||||
|
|
|
|||
1142
engine/apps/api/tests/test_oncall_shift.py
Normal file
1142
engine/apps/api/tests/test_oncall_shift.py
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,24 @@
|
|||
# Generated by Django 3.2.13 on 2022-07-22 11:54
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('schedules', '0006_customoncallshift_rotation_start'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='customoncallshift',
|
||||
name='updated_shift',
|
||||
field=models.OneToOneField(default=None, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='parent_shift', to='schedules.customoncallshift'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='customoncallshift',
|
||||
name='title',
|
||||
field=models.CharField(default=None, max_length=200, null=True),
|
||||
),
|
||||
]
|
||||
|
|
@ -1,4 +1,6 @@
|
|||
import logging
|
||||
import random
|
||||
import string
|
||||
from calendar import monthrange
|
||||
from uuid import uuid4
|
||||
|
||||
|
|
@ -6,8 +8,9 @@ import pytz
|
|||
from django.apps import apps
|
||||
from django.conf import settings
|
||||
from django.core.validators import MinLengthValidator
|
||||
from django.db import models
|
||||
from django.db import models, transaction
|
||||
from django.db.models import JSONField
|
||||
from django.forms.models import model_to_dict
|
||||
from django.utils import timezone
|
||||
from django.utils.functional import cached_property
|
||||
from icalendar.cal import Event
|
||||
|
|
@ -165,6 +168,7 @@ class CustomOnCallShift(models.Model):
|
|||
default=None,
|
||||
)
|
||||
name = models.CharField(max_length=200)
|
||||
title = models.CharField(max_length=200, null=True, default=None)
|
||||
time_zone = models.CharField(max_length=100, null=True, default=None)
|
||||
source = models.IntegerField(choices=SOURCE_CHOICES, default=SOURCE_API)
|
||||
users = models.ManyToManyField("user_management.User") # users in single and recurrent events
|
||||
|
|
@ -196,16 +200,44 @@ class CustomOnCallShift(models.Model):
|
|||
by_month = JSONField(default=None, null=True) # [] BYMONTH - what months (1, 2, 3, ...) - ical format
|
||||
by_monthday = JSONField(default=None, null=True) # [] BYMONTHDAY - what days of month (1, 2, -3) - ical format
|
||||
|
||||
updated_shift = models.OneToOneField(
|
||||
"schedules.CustomOnCallShift",
|
||||
on_delete=models.SET_NULL,
|
||||
default=None,
|
||||
null=True,
|
||||
related_name="parent_shift",
|
||||
)
|
||||
|
||||
class Meta:
|
||||
unique_together = ("name", "organization")
|
||||
|
||||
def delete(self, *args, **kwargs):
|
||||
for schedule in self.schedules.all():
|
||||
self.start_drop_ical_and_check_schedule_tasks(schedule)
|
||||
schedules_to_update = list(self.schedules.all())
|
||||
if self.schedule:
|
||||
self.start_drop_ical_and_check_schedule_tasks(self.schedule)
|
||||
# todo: add soft delete
|
||||
super().delete(*args, **kwargs)
|
||||
schedules_to_update.append(self.schedule)
|
||||
|
||||
# do soft delete for started shifts that were created for web schedule
|
||||
if self.schedule and self.event_is_started:
|
||||
self.until = timezone.now().replace(microsecond=0)
|
||||
self.save(update_fields=["until"])
|
||||
else:
|
||||
super().delete(*args, **kwargs)
|
||||
|
||||
for schedule in schedules_to_update:
|
||||
self.start_drop_ical_and_check_schedule_tasks(schedule)
|
||||
|
||||
@property
|
||||
def event_is_started(self):
|
||||
return bool(self.rotation_start <= timezone.now())
|
||||
|
||||
@property
|
||||
def event_is_finished(self):
|
||||
if self.frequency is not None:
|
||||
is_finished = bool(self.until <= timezone.now()) if self.until else False
|
||||
else:
|
||||
is_finished = bool(self.start + self.duration <= timezone.now())
|
||||
|
||||
return is_finished
|
||||
|
||||
@property
|
||||
def repr_settings_for_client_side_logging(self) -> str:
|
||||
|
|
@ -328,7 +360,7 @@ class CustomOnCallShift(models.Model):
|
|||
if event.start.date() >= next_event_start.date():
|
||||
next_event = event
|
||||
break
|
||||
next_event_dt = next_event.start
|
||||
next_event_dt = next_event.start if next_event is not None else None
|
||||
return next_event_dt
|
||||
|
||||
@cached_property
|
||||
|
|
@ -399,3 +431,51 @@ 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,))
|
||||
|
||||
@cached_property
|
||||
def last_updated_shift(self):
|
||||
last_shift = self.updated_shift
|
||||
if last_shift is not None:
|
||||
while last_shift.updated_shift is not None:
|
||||
last_shift = last_shift.updated_shift
|
||||
return last_shift
|
||||
|
||||
def create_or_update_last_shift(self, data):
|
||||
# rotation start date cannot be earlier than now
|
||||
data["rotation_start"] = max(data["rotation_start"], timezone.now().replace(microsecond=0))
|
||||
# prepare dict with params of existing instance with last updates and remove unique and m2m fields from it
|
||||
shift_to_update = self.last_updated_shift or self
|
||||
instance_data = model_to_dict(shift_to_update)
|
||||
fields_to_remove = ["id", "public_primary_key", "uuid", "users", "updated_shift", "name"]
|
||||
for field in fields_to_remove:
|
||||
instance_data.pop(field)
|
||||
|
||||
instance_data.update(data)
|
||||
instance_data["schedule"] = self.schedule
|
||||
instance_data["team"] = self.team
|
||||
|
||||
if self.last_updated_shift is None or self.last_updated_shift.event_is_started:
|
||||
# create new shift
|
||||
instance_data["name"] = CustomOnCallShift.generate_name(
|
||||
self.schedule, instance_data["priority_level"], instance_data["type"]
|
||||
)
|
||||
with transaction.atomic():
|
||||
shift = CustomOnCallShift(**instance_data)
|
||||
shift.save()
|
||||
shift_to_update.until = data["rotation_start"]
|
||||
shift_to_update.updated_shift = shift
|
||||
shift_to_update.save(update_fields=["until", "updated_shift"])
|
||||
else:
|
||||
shift = self.last_updated_shift
|
||||
for key in instance_data:
|
||||
setattr(shift, key, instance_data[key])
|
||||
shift.save(update_fields=list(instance_data))
|
||||
|
||||
return shift
|
||||
|
||||
@staticmethod
|
||||
def generate_name(schedule, priority_level, shift_type):
|
||||
shift_type_name = "override" if shift_type == CustomOnCallShift.TYPE_OVERRIDE else "rotation"
|
||||
name = f"{schedule.name}-{shift_type_name}-{priority_level}-"
|
||||
name += "".join(random.choice(string.ascii_lowercase) for _ in range(5))
|
||||
return name
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue