#93: reorganise Rerun, Diffusion, Schedule module (#95)

#93

Co-authored-by: bkfox <thomas bkfox net>
Reviewed-on: rc/aircox#95
This commit is contained in:
Thomas Kairos
2023-04-02 20:37:47 +02:00
parent 695e4d7c5d
commit cd19c26e82
37 changed files with 4791 additions and 842 deletions

View File

@ -1,21 +1,13 @@
import calendar
import logging
import os
import shutil
from collections import OrderedDict
from enum import IntEnum
import pytz
from django.conf import settings as conf
from django.core.exceptions import ValidationError
from django.db import models
from django.db.models import F
from django.db.models.functions import Concat, Substr
from django.utils import timezone as tz
from django.utils.functional import cached_property
from django.utils.translation import gettext_lazy as _
from aircox import utils
from aircox.conf import settings
from .page import Page, PageQuerySet
@ -26,12 +18,9 @@ logger = logging.getLogger("aircox")
__all__ = (
"Program",
"ProgramChildQuerySet",
"ProgramQuerySet",
"Stream",
"Schedule",
"ProgramChildQuerySet",
"BaseRerun",
"BaseRerunQuerySet",
)
@ -167,352 +156,6 @@ class ProgramChildQuerySet(PageQuerySet):
return self.parent(program, id)
class BaseRerunQuerySet(models.QuerySet):
"""Queryset for BaseRerun (sub)classes."""
def station(self, station=None, id=None):
return (
self.filter(program__station=station)
if id is None
else self.filter(program__station__id=id)
)
def program(self, program=None, id=None):
return (
self.filter(program=program)
if id is None
else self.filter(program__id=id)
)
def rerun(self):
return self.filter(initial__isnull=False)
def initial(self):
return self.filter(initial__isnull=True)
class BaseRerun(models.Model):
"""Abstract model offering rerun facilities.
Assume `start` is a datetime field or attribute implemented by
subclass.
"""
program = models.ForeignKey(
Program,
models.CASCADE,
db_index=True,
verbose_name=_("related program"),
)
initial = models.ForeignKey(
"self",
models.SET_NULL,
related_name="rerun_set",
verbose_name=_("rerun of"),
limit_choices_to={"initial__isnull": True},
blank=True,
null=True,
db_index=True,
)
objects = BaseRerunQuerySet.as_manager()
class Meta:
abstract = True
def save(self, *args, **kwargs):
if self.initial is not None:
self.initial = self.initial.get_initial()
if self.initial == self:
self.initial = None
if self.is_rerun:
self.save_rerun()
else:
self.save_initial()
super().save(*args, **kwargs)
def save_rerun(self):
pass
def save_initial(self):
pass
@property
def is_initial(self):
return self.initial is None
@property
def is_rerun(self):
return self.initial is not None
def get_initial(self):
"""Return the initial schedule (self or initial)"""
return self if self.initial is None else self.initial.get_initial()
def clean(self):
super().clean()
if self.initial is not None and self.initial.start >= self.start:
raise ValidationError(
{"initial": _("rerun must happen after original")}
)
# ? BIG FIXME: self.date is still used as datetime
class Schedule(BaseRerun):
"""A Schedule defines time slots of programs' diffusions.
It can be an initial run or a rerun (in such case it is linked to
the related schedule).
"""
# Frequency for schedules. Basically, it is a mask of bits where each bit
# is a week. Bits > rank 5 are used for special schedules.
# Important: the first week is always the first week where the weekday of
# the schedule is present.
# For ponctual programs, there is no need for a schedule, only a diffusion
class Frequency(IntEnum):
ponctual = 0b000000
first = 0b000001
second = 0b000010
third = 0b000100
fourth = 0b001000
last = 0b010000
first_and_third = 0b000101
second_and_fourth = 0b001010
every = 0b011111
one_on_two = 0b100000
date = models.DateField(
_("date"),
help_text=_("date of the first diffusion"),
)
time = models.TimeField(
_("time"),
help_text=_("start time"),
)
timezone = models.CharField(
_("timezone"),
default=tz.get_current_timezone,
max_length=100,
choices=[(x, x) for x in pytz.all_timezones],
help_text=_("timezone used for the date"),
)
duration = models.TimeField(
_("duration"),
help_text=_("regular duration"),
)
frequency = models.SmallIntegerField(
_("frequency"),
choices=[
(
int(y),
{
"ponctual": _("ponctual"),
"first": _("1st {day} of the month"),
"second": _("2nd {day} of the month"),
"third": _("3rd {day} of the month"),
"fourth": _("4th {day} of the month"),
"last": _("last {day} of the month"),
"first_and_third": _("1st and 3rd {day} of the month"),
"second_and_fourth": _("2nd and 4th {day} of the month"),
"every": _("{day}"),
"one_on_two": _("one {day} on two"),
}[x],
)
for x, y in Frequency.__members__.items()
],
)
class Meta:
verbose_name = _("Schedule")
verbose_name_plural = _("Schedules")
def __str__(self):
return "{} - {}, {}".format(
self.program.title,
self.get_frequency_verbose(),
self.time.strftime("%H:%M"),
)
def save_rerun(self, *args, **kwargs):
self.program = self.initial.program
self.duration = self.initial.duration
self.frequency = self.initial.frequency
@cached_property
def tz(self):
"""Pytz timezone of the schedule."""
import pytz
return pytz.timezone(self.timezone)
@cached_property
def start(self):
"""Datetime of the start (timezone unaware)"""
return tz.datetime.combine(self.date, self.time)
@cached_property
def end(self):
"""Datetime of the end."""
return self.start + utils.to_timedelta(self.duration)
def get_frequency_verbose(self):
"""Return frequency formated for display."""
from django.template.defaultfilters import date
return (
self.get_frequency_display()
.format(day=date(self.date, "l"))
.capitalize()
)
# initial cached data
__initial = None
def changed(self, fields=["date", "duration", "frequency", "timezone"]):
initial = self._Schedule__initial
if not initial:
return
this = self.__dict__
for field in fields:
if initial.get(field) != this.get(field):
return True
return False
def normalize(self, date):
"""Return a datetime set to schedule's time for the provided date,
handling timezone (based on schedule's timezone)."""
date = tz.datetime.combine(date, self.time)
return self.tz.normalize(self.tz.localize(date))
def dates_of_month(self, date):
"""Return normalized diffusion dates of provided date's month."""
if self.frequency == Schedule.Frequency.ponctual:
return []
sched_wday, freq = self.date.weekday(), self.frequency
date = date.replace(day=1)
# last of the month
if freq == Schedule.Frequency.last:
date = date.replace(
day=calendar.monthrange(date.year, date.month)[1]
)
date_wday = date.weekday()
# end of month before the wanted weekday: move one week back
if date_wday < sched_wday:
date -= tz.timedelta(days=7)
date += tz.timedelta(days=sched_wday - date_wday)
return [self.normalize(date)]
# move to the first day of the month that matches the schedule's
# weekday. Check on SO#3284452 for the formula
date_wday, month = date.weekday(), date.month
date += tz.timedelta(
days=(7 if date_wday > sched_wday else 0) - date_wday + sched_wday
)
if freq == Schedule.Frequency.one_on_two:
# - adjust date with modulo 14 (= 2 weeks in days)
# - there are max 3 "weeks on two" per month
if (date - self.date).days % 14:
date += tz.timedelta(days=7)
dates = (date + tz.timedelta(days=14 * i) for i in range(0, 3))
else:
dates = (
date + tz.timedelta(days=7 * week)
for week in range(0, 5)
if freq & (0b1 << week)
)
return [self.normalize(date) for date in dates if date.month == month]
def _exclude_existing_date(self, dates):
from .episode import Diffusion
saved = set(
Diffusion.objects.filter(start__in=dates).values_list(
"start", flat=True
)
)
return [date for date in dates if date not in saved]
def diffusions_of_month(self, date):
"""Get episodes and diffusions for month of provided date, including
reruns.
:returns: tuple([Episode], [Diffusion])
"""
from .episode import Diffusion, Episode
if (
self.initial is not None
or self.frequency == Schedule.Frequency.ponctual
):
return [], []
# dates for self and reruns as (date, initial)
reruns = [
(rerun, rerun.date - self.date) for rerun in self.rerun_set.all()
]
dates = OrderedDict((date, None) for date in self.dates_of_month(date))
dates.update(
[
(rerun.normalize(date.date() + delta), date)
for date in dates.keys()
for rerun, delta in reruns
]
)
# remove dates corresponding to existing diffusions
saved = set(
Diffusion.objects.filter(
start__in=dates.keys(), program=self.program, schedule=self
).values_list("start", flat=True)
)
# make diffs
duration = utils.to_timedelta(self.duration)
diffusions = {}
episodes = {}
for date, initial in dates.items():
if date in saved:
continue
if initial is None:
episode = Episode.from_page(self.program, date=date)
episode.date = date
episodes[date] = episode
else:
episode = episodes[initial]
initial = diffusions[initial]
diffusions[date] = Diffusion(
episode=episode,
schedule=self,
type=Diffusion.TYPE_ON_AIR,
initial=initial,
start=date,
end=date + duration,
)
return episodes.values(), diffusions.values()
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# TODO/FIXME: use validators?
if self.initial is not None and self.date > self.date:
raise ValueError("initial must be later")
class Stream(models.Model):
"""When there are no program scheduled, it is possible to play sounds in
order to avoid blanks. A Stream is a Program that plays this role, and