Newer
Older
#
# steering, Programme/schedule management for AURA
#
# Copyright (C) 2011-2017, 2020, Ernesto Rico Schmidt
# Copyright (C) 2017-2019, Ingo Leindecker
#
# This program is free software: you can redistribute it and/or modify it under
# the terms of the GNU Affero General Public License as published by the Free
# Software Foundation, either version 3 of the License, or (at your option) any
# later version.
#
# This program is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
from datetime import datetime, time, timedelta
from textwrap import dedent
from dateutil.relativedelta import relativedelta
from dateutil.rrule import rrule
from rest_framework.exceptions import ValidationError
from versatileimagefield.fields import PPOIField, VersatileImageField
from django.core.exceptions import ObjectDoesNotExist
from django.forms.models import model_to_dict
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from program.utils import parse_date, parse_datetime, parse_time
from steering.settings import (
AUTO_SET_UNTIL_DATE_TO_DAYS_IN_FUTURE,
AUTO_SET_UNTIL_DATE_TO_END_OF_YEAR,
THUMBNAIL_SIZES,
class Type(models.Model):
name = models.CharField(max_length=32)
slug = models.SlugField(max_length=32, unique=True)
is_active = models.BooleanField(default=True)
def __str__(self):
return self.name
class Category(models.Model):
name = models.CharField(max_length=32)
abbrev = models.CharField(max_length=4, unique=True)
slug = models.SlugField(max_length=32, unique=True)
is_active = models.BooleanField(default=True)
description = models.TextField(blank=True)
def __str__(self):
return self.name
class Topic(models.Model):
name = models.CharField(max_length=32)
abbrev = models.CharField(max_length=4, unique=True)
slug = models.SlugField(max_length=32, unique=True)
is_active = models.BooleanField(default=True)
def __str__(self):
return self.name
name = models.CharField(max_length=32)
abbrev = models.CharField(max_length=4, unique=True)
slug = models.SlugField(max_length=32, unique=True)
is_active = models.BooleanField(default=True)
def __str__(self):
return self.name
class FundingCategory(models.Model):
name = models.CharField(max_length=32)
abbrev = models.CharField(max_length=4, unique=True)
slug = models.SlugField(max_length=32, unique=True)
is_active = models.BooleanField(default=True)
class Meta:
def __str__(self):
return self.name
name = models.CharField(max_length=32)
is_active = models.BooleanField(default=True)
def __str__(self):
return self.name
name = models.CharField(max_length=128)
is_active = models.BooleanField(default=True)
email = models.EmailField(blank=True)
website = models.URLField(blank=True)
biography = models.TextField(blank=True, null=True)
ppoi = PPOIField()
height = models.PositiveIntegerField(blank=True, null=True, editable=False)
width = models.PositiveIntegerField(blank=True, null=True, editable=False)
image = VersatileImageField(
blank=True,
null=True,
upload_to="host_images",
width_field="width",
height_field="height",
ppoi_field="ppoi",
)
def __str__(self):
return self.name
def save(self, *args, **kwargs):
super(Host, self).save(*args, **kwargs)
# Generate thumbnails
if self.image.name and THUMBNAIL_SIZES:
for size in THUMBNAIL_SIZES:
self.image.thumbnail = self.image.crop[size].name
description = models.CharField(max_length=16)
class Meta:
abstract = True
def __str__(self):
return self.url
class HostLink(Link):
host = models.ForeignKey(Host, on_delete=models.CASCADE, related_name="links")
predecessor = models.ForeignKey(
"self",
blank=True,
null=True,
on_delete=models.CASCADE,
related_name="successors",
)
hosts = models.ManyToManyField(Host, blank=True, related_name="shows")
owners = models.ManyToManyField(User, blank=True, related_name="shows")
language = models.ManyToManyField(Language, blank=True, related_name="language")
type = models.ForeignKey(Type, on_delete=models.CASCADE, related_name="shows")
category = models.ManyToManyField(Category, blank=True, related_name="shows")
funding_category = models.ForeignKey(
FundingCategory,
null=True,
on_delete=models.CASCADE,
blank=True,
related_name="shows",
)
topic = models.ManyToManyField(Topic, blank=True, related_name="shows")
music_focus = models.ManyToManyField(MusicFocus, blank=True, related_name="shows")
name = models.CharField(max_length=255)
slug = models.CharField(max_length=255, unique=True)
ppoi = PPOIField()
height = models.PositiveIntegerField(blank=True, null=True, editable=False)
width = models.PositiveIntegerField(blank=True, null=True, editable=False)
image = VersatileImageField(
blank=True,
null=True,
upload_to="show_images",
width_field="width",
height_field="height",
ppoi_field="ppoi",
)
logo = models.ImageField(blank=True, null=True, upload_to="show_images")
short_description = models.TextField()
description = models.TextField(blank=True, null=True)
email = models.EmailField(blank=True, null=True)
website = models.URLField(blank=True, null=True)
cba_series_id = models.IntegerField(blank=True, null=True)
default_playlist_id = models.IntegerField(blank=True, null=True)
is_active = models.BooleanField(default=True)
is_public = models.BooleanField(default=False)
def __str__(self):
return self.name
name = models.CharField(max_length=32, unique=True)
freq = models.IntegerField()
interval = models.IntegerField(default=1)
by_set_pos = models.IntegerField(blank=True, null=True)
count = models.IntegerField(blank=True, null=True)
def __str__(self):
return self.name
class Schedule(models.Model):
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
rrule = models.ForeignKey(
RRule,
on_delete=models.CASCADE,
related_name="schedules",
help_text=dedent(
"""
A recurrence rule.
* 1 = once,
* 2 = daily,
* 3 = business days,
* 4 = weekly,
* 5 = biweekly,
* 6 = every four weeks,
* 7 = every even calendar week (ISO 8601),
* 8 = every odd calendar week (ISO 8601),
* 9 = every 1st week of month,
* 10 = every 2nd week of month,
* 11 = every 3rd week of month,
* 12 = every 4th week of month,
* 13 = every 5th week of month
"""
),
)
show = models.ForeignKey(
Show,
on_delete=models.CASCADE,
related_name="schedules",
help_text="Show the schedule belongs to.",
)
by_weekday = models.IntegerField(
help_text="Number of the Weekday.",
choices=[
(0, "Monday"),
(1, "Tuesday"),
(2, "Wednesday"),
(3, "Thursday"),
(4, "Friday"),
(5, "Saturday"),
(6, "Sunday"),
],
)
first_date = models.DateField(help_text="Start date of schedule.")
start_time = models.TimeField(help_text="Start time of schedule.")
end_time = models.TimeField(help_text="End time of schedule.")
last_date = models.DateField(help_text="End date of schedule.")
is_repetition = models.BooleanField(
default=False,
help_text="Whether the schedule is a repetition.",
)
add_days_no = models.IntegerField(
blank=True,
null=True,
help_text=(
"Add a number of days to the generated dates. "
"This can be useful for repetitions, like 'On the following day'."
),
)
add_business_days_only = models.BooleanField(
default=False,
help_text=(
"Whether to add add_days_no but skipping the weekends. "
"E.g. if weekday is Friday, the date returned will be the next Monday."
),
)
default_playlist_id = models.IntegerField(
blank=True,
null=True,
help_text="A tank ID in case the timeslot's playlist_id is empty.",
)
# FIXME: this does not belong here
@staticmethod
def instantiate_upcoming(sdl, show_pk, pk=None):
"""Returns an upcoming schedule instance for conflict resolution"""
pk = int(pk) if pk is not None else None
rrule = RRule.objects.get(pk=int(sdl["rrule"]))
show = Show.objects.get(pk=int(show_pk))
is_repetition = True if sdl.get("is_repetition") is True else False
default_playlist_id = (
int(sdl["default_playlist_id"]) if sdl.get("default_playlist_id") else None
)
add_days_no = int(sdl["add_days_no"]) if sdl.get("add_days_no") else None
add_business_days_only = (
True if sdl.get("add_business_days_only") is True else False
)
first_date = parse_date(str(sdl["first_date"]))
sdl["start_time"] + ":00"
if len(str(sdl["start_time"])) == 5
else sdl["start_time"]
)
end_time = (
sdl["end_time"] + ":00"
if len(str(sdl["end_time"])) == 5
else sdl["end_time"]
start_time = parse_time(str(start_time))
end_time = parse_time(str(end_time))
if sdl["last_date"]:
last_date = parse_date(str(sdl["last_date"]))
# If last_date was not set, set it to the end of the year or add x days
last_date = first_date + timedelta(
days=+AUTO_SET_UNTIL_DATE_TO_DAYS_IN_FUTURE
)
by_weekday=sdl["by_weekday"],
rrule=rrule,
first_date=first_date,
start_time=start_time,
end_time=end_time,
last_date=last_date,
is_repetition=is_repetition,
default_playlist_id=default_playlist_id,
show=show,
add_days_no=add_days_no,
add_business_days_only=add_business_days_only,
)
# FIXME: this does not belong here
@staticmethod
def generate_timeslots(schedule):
"""
Returns a list of timeslot objects based on a schedule and its rrule
Returns past timeslots as well, starting from first_date (not today)
"""
by_week_no = None
by_week_no_end = None
by_weekday_end = int(schedule.by_weekday)
starts = []
ends = []
timeslots = []
# Handle ending weekday for timeslots over midnight
if schedule.end_time < schedule.start_time:
if schedule.by_weekday < 6:
by_weekday_end = int(schedule.by_weekday + 1)
else:
by_weekday_end = 0
# Handle ending dates for timeslots over midnight
if schedule.end_time < schedule.start_time:
last_date = schedule.first_date + timedelta(days=+1)
else:
last_date = schedule.first_date
if schedule.rrule.freq == 0: # Ignore weekdays for one-time timeslots
by_weekday_start = None
by_weekday_end = None
elif schedule.rrule.freq == 3 and schedule.rrule.pk == 2: # Daily timeslots
by_weekday_start = (0, 1, 2, 3, 4, 5, 6)
by_weekday_end = (0, 1, 2, 3, 4, 5, 6)
elif (
schedule.rrule.freq == 3 and schedule.rrule.pk == 3
): # Business days MO - FR/SA
by_weekday_start = (0, 1, 2, 3, 4)
if schedule.end_time < schedule.start_time:
# End days for over midnight
by_weekday_end = (1, 2, 3, 4, 5)
else:
by_weekday_end = (0, 1, 2, 3, 4)
elif schedule.rrule.freq == 2 and schedule.rrule.pk == 7: # Even calendar weeks
by_weekday_start = int(schedule.by_weekday)
by_week_no = list(range(2, 54, 2))
# Reverse ending weeks if from Sun - Mon
if by_weekday_start == 6 and by_weekday_end == 0:
by_week_no_end = list(range(1, 54, 2))
else:
by_week_no_end = by_week_no
elif schedule.rrule.freq == 2 and schedule.rrule.pk == 8: # Odd calendar weeks
by_weekday_start = int(schedule.by_weekday)
by_week_no = list(range(1, 54, 2))
# Reverse ending weeks if from Sun - Mon
if by_weekday_start == 6 and by_weekday_end == 0:
by_week_no_end = list(range(2, 54, 2))

Ernesto Rico Schmidt
committed
else:
by_week_no_end = by_week_no
by_weekday_start = int(schedule.by_weekday)
if schedule.rrule.freq == 0:
starts.append(datetime.combine(schedule.first_date, schedule.start_time))
ends.append(datetime.combine(last_date, schedule.end_time))
else:
starts = list(
rrule(
freq=schedule.rrule.freq,
dtstart=datetime.combine(schedule.first_date, schedule.start_time),
interval=schedule.rrule.interval,
until=schedule.last_date + relativedelta(days=+1),
bysetpos=schedule.rrule.by_set_pos,
byweekday=by_weekday_start,
byweekno=by_week_no,
)
)
ends = list(
rrule(
freq=schedule.rrule.freq,
dtstart=datetime.combine(last_date, schedule.end_time),
interval=schedule.rrule.interval,
until=schedule.last_date + relativedelta(days=+1),
bysetpos=schedule.rrule.by_set_pos,
byweekday=by_weekday_end,
byweekno=by_week_no_end,
)
)
for k in range(min(len(starts), len(ends))):
# Correct dates for the (relatively seldom) case if:
# E.g.: 1st Monday from 23:00:00 to 1st Tuesday 00:00:00
# produces wrong end dates if the 1st Tuesday is before the 1st Monday
# In this case we take the next day instead of rrule's calculated end
if starts[k] > ends[k]:
ends[k] = datetime.combine(
starts[k] + relativedelta(days=+1), schedule.end_time
)
Add a number of days to the generated dates?
This can be helpful for repetitions:
Examples:
1. If RRule is "Every 1st Monday" and we want its repetition alyways to be on the
following day, the repetition's RRule is the same but add_days_no is 1
If we would set the repetition to "Every 1st Tuesday" instead
we will get unmeant results if the 1st Tuesday is before the 1st Monday
(e.g. 1st Tue = May 1 2018, 1st Mon = May 7 2018)
2. If RRule is "Every 1st Friday" and we want its repetition always to be on the
following business day, the repetition's RRule is the same but add_days_no is 1
and add_business_days_only is True (e.g. original date = Fri, March 2 2018;
generated date = Mon, March 5 2018)
In the UI these can be presets:
"On the following day" (add_days_no=1,add_business_days_only=False) or
"On the following business day" (add_days_no=1,add_business_days_only=True)
if schedule.add_days_no is not None and schedule.add_days_no > 0:
# If only business days and weekday is Fri, Sat or Sun: add add_days_no beginning
# from Sunday
weekday = datetime.date(starts[k]).weekday()
if schedule.add_business_days_only and weekday > 3:
days_until_sunday = 6 - weekday
starts[k] = starts[k] + relativedelta(
days=+days_until_sunday + schedule.add_days_no
)
ends[k] = ends[k] + relativedelta(
days=+days_until_sunday + schedule.add_days_no
)
else:
starts[k] = starts[k] + relativedelta(days=+schedule.add_days_no)
ends[k] = ends[k] + relativedelta(days=+schedule.add_days_no)
if ends[k].date() > schedule.last_date:
schedule.last_date = ends[k].date()
timeslots.append(
TimeSlot(
schedule=schedule,
start=timezone.make_aware(starts[k]),
end=timezone.make_aware(ends[k]),
)
)
return timeslots
# FIXME: this does not belong here
@staticmethod
def get_collisions(timeslots):
"""
Tests a list of timeslot objects for colliding timeslots in the database
Returns a list of collisions, containing colliding timeslot IDs or None
Keeps indices from input list for later comparison
"""
collisions = []
for ts in timeslots:

Ernesto Rico Schmidt
committed
collision = TimeSlot.objects.get_colliding_timeslots(ts)
if collision:
collisions.append(
collision[0]
) # TODO: Do we really always retrieve one?
else:
collisions.append(None)
return collisions
# FIXME: this does not belong here
@staticmethod
def generate_conflicts(timeslots):
"""
Tests a list of timeslot objects for colliding timeslots in the database
Returns a list of conflicts containing dicts of projected timeslots, collisions and
solutions
conflicts = {}
projected = []
solutions = {}
# Cycle each timeslot
for ts in timeslots:
# Contains collisions
collisions = []
# Contains possible solutions
solution_choices = set()
collision_list = list(
TimeSlot.objects.get_colliding_timeslots(ts).order_by("start")
)
projected_entry = {
"hash": ts.hash,
"start": str(ts.start),
"end": str(ts.end),
}
for c in collision_list:
# Add the collision
collision = {
"id": c.id,
"start": str(c.start),
"end": str(c.end),
"playlist_id": c.playlist_id,
"show": c.show.id,
"show_name": c.show.name,
"is_repetition": c.is_repetition,
"schedule": c.schedule_id,
"memo": c.memo,
}
note = Note.objects.get(timeslot=c.id)
collision["note_id"] = note.pk
# If there is more than one collision: Only these two are supported at the
# moment
solution_choices.add("theirs")
solution_choices.add("ours")
# These two are always possible: Either keep theirs and remove ours or vice
# versa
solution_choices.add("theirs")
solution_choices.add("ours")
# Partly overlapping: projected starts earlier than existing and ends earlier
#
# ex. pr.
# +--+
# | |
# +--+ | |
# | | +--+
# | |
# +--+
#
solution_choices.add("theirs-end")
solution_choices.add("ours-end")
# Partly overlapping: projected starts later than existing and ends later
#
# ex. pr.
# +--+
# | |
# | | +--+
# +--+ | |
# | |
# +--+
solution_choices.add("theirs-start")
solution_choices.add("ours-start")
# Fully overlapping: projected starts earlier and ends later than existing
#
# ex. pr.
# +--+
# +--+ | |
# | | | |
# +--+ | |
# +--+
#
solution_choices.add("theirs-end")
solution_choices.add("theirs-start")
solution_choices.add("theirs-both")
# Fully overlapping: projected starts later and ends earlier than existing
#
# ex. pr.
# +--+
# | | +--+
# | | | |
# | | +--+
# +--+
#
solution_choices.add("ours-end")
solution_choices.add("ours-start")
solution_choices.add("ours-both")
projected_entry["collisions"] = collisions
projected_entry["solution_choices"] = solution_choices
projected.append(projected_entry)
conflicts["projected"] = projected
conflicts["solutions"] = solutions
conflicts["notes"] = {}
conflicts["playlists"] = {}
# FIXME: this does not belong here
@staticmethod
def make_conflicts(sdl, schedule_pk, show_pk):
"""
Retrieves POST vars
Generates a schedule
Generates conflicts: Returns timeslots, collisions, solutions as JSON
Returns conflicts dict
"""
# Generate schedule to be saved
schedule = Schedule.instantiate_upcoming(sdl, show_pk, schedule_pk)
# Copy if first_date changes for generating timeslots
gen_schedule = schedule
# If extending: Get last timeslot and start generating from that date on
if schedule_pk is not None:
existing_schedule = Schedule.objects.get(pk=int(schedule_pk))
if schedule.last_date > existing_schedule.last_date:
last_timeslot = (
TimeSlot.objects.filter(schedule=existing_schedule)
.order_by("start")
.reverse()[0]
)
gen_schedule.first_date = last_timeslot.start.date() + timedelta(days=1)
timeslots = Schedule.generate_timeslots(gen_schedule)
# Generate conflicts and add schedule
conflicts = Schedule.generate_conflicts(timeslots)
conflicts["schedule"] = model_to_dict(schedule)
# FIXME: this does not belong here
@staticmethod
def resolve_conflicts(data, schedule_pk, show_pk):
Expects JSON POST/PUT data from /shows/1/schedules/
Returns a list of dicts if errors were found
Returns an empty list if resolution was successful
sdl = data["schedule"]
solutions = data["solutions"]
schedule = Schedule.instantiate_upcoming(sdl, show_pk, schedule_pk)
show = schedule.show
conflicts = Schedule.make_conflicts(sdl, schedule_pk, show_pk)
if schedule.rrule.freq > 0 and schedule.first_date == schedule.last_date:
_("Start and end dates must not be the same."),
code="no-same-day-start-and-end",
)
if schedule.last_date < schedule.first_date:
_("End date mustn't be before start."),
code="no-start-after-end",
)
num_conflicts = len(
[pr for pr in conflicts["projected"] if len(pr["collisions"]) > 0]
)
raise ValidationError(
_("Numbers of conflicts and solutions don't match."),
code="one-solution-per-conflict",
)
# Projected timeslots to create
create = []
# Existing timeslots to update
update = []
# Existing timeslots to delete
delete = []
# Error messages
# If no solution necessary
#
# - Create the projected timeslot and skip
#
if "solution_choices" not in ts or len(ts["collisions"]) < 1:
projected_ts = TimeSlot.objects.instantiate(
ts["start"], ts["end"], schedule, show
)
create.append(projected_ts)
continue
# Check hash (if start, end, rrule or byweekday changed)
if not ts["hash"] in solutions:
errors[ts["hash"]] = _("This change on the timeslot is not allowed.")
continue
# If no resolution given
#
# - Skip
#
if solutions[ts["hash"]] == "":
errors[ts["hash"]] = _("No solution given.")
continue
# If resolution is not accepted for this conflict
#
# - Skip
#
if not solutions[ts["hash"]] in ts["solution_choices"]:
errors[ts["hash"]] = _(
"Given solution is not accepted for this conflict."
)
existing = ts["collisions"][0]
solution = solutions[ts["hash"]]
# theirs
#
# - Discard the projected timeslot
# - Keep the existing collision(s)
#
continue
# ours
#
# - Create the projected timeslot
# - Delete the existing collision(s)
#
if solution == "ours":
projected_ts = TimeSlot.objects.instantiate(
ts["start"], ts["end"], schedule, show
)
create.append(projected_ts)
# Delete collision(s)
existing_ts = TimeSlot.objects.get(pk=ex["id"])
delete.append(existing_ts)
except ObjectDoesNotExist:
pass
# theirs-end
#
# - Keep the existing timeslot
# - Create projected with end of existing start
#
if solution == "theirs-end":
projected_ts = TimeSlot.objects.instantiate(
ts["start"], existing["start"], schedule, show
)
create.append(projected_ts)
# ours-end
#
# - Create the projected timeslot
# - Change the start of the existing collision to projected end
if solution == "ours-end":
projected_ts = TimeSlot.objects.instantiate(
ts["start"], ts["end"], schedule, show
)
existing_ts = TimeSlot.objects.get(pk=existing["id"])
existing_ts.start = parse_datetime(ts["end"])
update.append(existing_ts)
# theirs-start
#
# - Keep existing
# - Create projected with start time of existing end
#
if solution == "theirs-start":
projected_ts = TimeSlot.objects.instantiate(
existing["end"], ts["end"], schedule, show
)
create.append(projected_ts)
# ours-start
#
# - Create the projected timeslot
# - Change end of existing to projected start
#
if solution == "ours-start":
projected_ts = TimeSlot.objects.instantiate(
ts["start"], ts["end"], schedule, show
)
existing_ts = TimeSlot.objects.get(pk=existing["id"])
existing_ts.end = parse_datetime(ts["start"])
update.append(existing_ts)
# theirs-both
#
# - Keep existing
# - Create two projected timeslots with end of existing start and start of existing
# end
if solution == "theirs-both":
projected_ts = TimeSlot.objects.instantiate(
ts["start"], existing["start"], schedule, show
)
projected_ts = TimeSlot.objects.instantiate(
existing["end"], ts["end"], schedule, show
)
create.append(projected_ts)
# ours-both
#
# - Create projected
# - Split existing into two:
# - Set existing end time to projected start
# - Create another one with start = projected end and end = existing end
#
if solution == "ours-both":
projected_ts = TimeSlot.objects.instantiate(
ts["start"], ts["end"], schedule, show
)
existing_ts = TimeSlot.objects.get(pk=existing["id"])
existing_ts.end = parse_datetime(ts["start"])
projected_ts = TimeSlot.objects.instantiate(
ts["end"], existing["end"], schedule, show
)
create.append(projected_ts)
# If there were any errors, don't make any db changes yet
# but add error messages and return already chosen solutions
if len(errors) > 0:
conflicts = Schedule.make_conflicts(
model_to_dict(schedule), schedule.pk, show.pk
)
partly_resolved = conflicts["projected"]
# Add already chosen resolutions and error message to conflict
for index, c in enumerate(conflicts["projected"]):
# The element should only exist if there was a collision
if len(c["collisions"]) > 0:
saved_solutions[c["hash"]] = ""
if (
c["hash"] in solutions
and solutions[c["hash"]] in c["solution_choices"]
):
saved_solutions[c["hash"]] = solutions[c["hash"]]
if c["hash"] in errors:
partly_resolved[index]["error"] = errors[c["hash"]]
conflicts["projected"] = partly_resolved
conflicts["solutions"] = saved_solutions
conflicts["notes"] = data.get("notes")
conflicts["playlists"] = data.get("playlists")
# Collect upcoming timeslots to delete which might still remain
del_timeslots = TimeSlot.objects.filter(
schedule=schedule, start__gt=schedule.last_date
)
for del_ts in del_timeslots:
delete.append(del_ts)
# If 'dryrun' is true, just return the projected changes instead of executing them
"create": [model_to_dict(ts) for ts in create],
"update": [model_to_dict(ts) for ts in update],
"delete": [model_to_dict(ts) for ts in delete],
}
"""Database changes if no errors found"""
# Only save schedule if timeslots were created
if create:
# Create or update schedule
schedule.save()
# Update timeslots
for ts in update:
ts.save(update_fields=["start", "end"])
# Create timeslots
for ts in create:
ts.schedule = schedule
if "playlists" in data and ts.hash in data["playlists"]:
ts.playlist_id = int(data["playlists"][ts.hash])