code quality

This commit is contained in:
bkfox
2023-03-13 17:47:00 +01:00
parent 934817da8a
commit 112770eddf
162 changed files with 4798 additions and 4069 deletions

View File

@ -1,9 +1,9 @@
import calendar
from collections import OrderedDict
from enum import IntEnum
import logging
import os
import shutil
from collections import OrderedDict
from enum import IntEnum
import pytz
from django.conf import settings as conf
@ -12,19 +12,26 @@ 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.translation import gettext_lazy as _
from django.utils.functional import cached_property
from django.utils.translation import gettext_lazy as _
from aircox import settings, utils
from .page import Page, PageQuerySet
from .station import Station
logger = logging.getLogger('aircox')
logger = logging.getLogger("aircox")
__all__ = ('Program', 'ProgramQuerySet', 'Stream', 'Schedule',
'ProgramChildQuerySet', 'BaseRerun', 'BaseRerunQuerySet')
__all__ = (
"Program",
"ProgramQuerySet",
"Stream",
"Schedule",
"ProgramChildQuerySet",
"BaseRerun",
"BaseRerunQuerySet",
)
class ProgramQuerySet(PageQuerySet):
@ -37,8 +44,7 @@ class ProgramQuerySet(PageQuerySet):
class Program(Page):
"""
A Program can either be a Streamed or a Scheduled program.
"""A Program can either be a Streamed or a Scheduled program.
A Streamed program is used to generate non-stop random playlists when there
is not scheduled diffusion. In such a case, a Stream is used to describe
@ -49,32 +55,35 @@ class Program(Page):
Renaming a Program rename the corresponding directory to matches the new
name if it does not exists.
"""
# explicit foreign key in order to avoid related name clashes
station = models.ForeignKey(Station, models.CASCADE,
verbose_name=_('station'))
station = models.ForeignKey(
Station, models.CASCADE, verbose_name=_("station")
)
active = models.BooleanField(
_('active'),
_("active"),
default=True,
help_text=_('if not checked this program is no longer active')
help_text=_("if not checked this program is no longer active"),
)
sync = models.BooleanField(
_('syncronise'),
_("syncronise"),
default=True,
help_text=_('update later diffusions according to schedule changes')
help_text=_("update later diffusions according to schedule changes"),
)
objects = ProgramQuerySet.as_manager()
detail_url_name = 'program-detail'
detail_url_name = "program-detail"
@property
def path(self):
""" Return program's directory path """
return os.path.join(settings.AIRCOX_PROGRAMS_DIR,
self.slug.replace('-', '_'))
"""Return program's directory path."""
return os.path.join(
settings.AIRCOX_PROGRAMS_DIR, self.slug.replace("-", "_")
)
@property
def abspath(self):
""" Return absolute path to program's dir """
"""Return absolute path to program's dir."""
return os.path.join(conf.MEDIA_ROOT, self.path)
@property
@ -93,69 +102,88 @@ class Program(Page):
@classmethod
def get_from_path(cl, path):
"""
Return a Program from the given path. We assume the path has been
given in a previous time by this model (Program.path getter).
"""Return a Program from the given path.
We assume the path has been given in a previous time by this
model (Program.path getter).
"""
if path.startswith(settings.AIRCOX_PROGRAMS_DIR_ABS):
path = path.replace(settings.AIRCOX_PROGRAMS_DIR_ABS, '')
while path[0] == '/':
path = path.replace(settings.AIRCOX_PROGRAMS_DIR_ABS, "")
while path[0] == "/":
path = path[1:]
path = path[:path.index('/')]
return cl.objects.filter(slug=path.replace('_','-')).first()
path = path[: path.index("/")]
return cl.objects.filter(slug=path.replace("_", "-")).first()
def ensure_dir(self, subdir=None):
"""Make sur the program's dir exists (and optionally subdir).
Return True if the dir (or subdir) exists.
"""
Make sur the program's dir exists (and optionally subdir). Return True
if the dir (or subdir) exists.
"""
path = os.path.join(self.abspath, subdir) if subdir else \
self.abspath
path = os.path.join(self.abspath, subdir) if subdir else self.abspath
os.makedirs(path, exist_ok=True)
return os.path.exists(path)
class Meta:
verbose_name = _('Program')
verbose_name_plural = _('Programs')
verbose_name = _("Program")
verbose_name_plural = _("Programs")
def __str__(self):
return self.title
def save(self, *kargs, **kwargs):
from .sound import Sound
super().save(*kargs, **kwargs)
# TODO: move in signals
path_ = getattr(self, '__initial_path', None)
path_ = getattr(self, "__initial_path", None)
abspath = path_ and os.path.join(conf.MEDIA_ROOT, path_)
if path_ is not None and path_ != self.path and \
os.path.exists(abspath) and not os.path.exists(self.abspath):
logger.info('program #%s\'s dir changed to %s - update it.',
self.id, self.title)
if (
path_ is not None
and path_ != self.path
and os.path.exists(abspath)
and not os.path.exists(self.abspath)
):
logger.info(
"program #%s's dir changed to %s - update it.",
self.id,
self.title,
)
shutil.move(abspath, self.abspath)
Sound.objects.filter(path__startswith=path_) \
.update(file=Concat('file', Substr(F('file'), len(path_))))
Sound.objects.filter(path__startswith=path_).update(
file=Concat("file", Substr(F("file"), len(path_)))
)
class ProgramChildQuerySet(PageQuerySet):
def station(self, station=None, id=None):
return self.filter(parent__program__station=station) if id is None else \
self.filter(parent__program__station__id=id)
return (
self.filter(parent__program__station=station)
if id is None
else self.filter(parent__program__station__id=id)
)
def program(self, program=None, id=None):
return self.parent(program, id)
class BaseRerunQuerySet(models.QuerySet):
""" Queryset for BaseRerun (sub)classes. """
"""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)
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)
return (
self.filter(program=program)
if id is None
else self.filter(program__id=id)
)
def rerun(self):
return self.filter(initial__isnull=False)
@ -165,19 +193,27 @@ class BaseRerunQuerySet(models.QuerySet):
class BaseRerun(models.Model):
"""Abstract model offering rerun facilities.
Assume `start` is a datetime field or attribute implemented by
subclass.
"""
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'),
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,
"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()
@ -212,25 +248,27 @@ class BaseRerun(models.Model):
return self.initial is not None
def get_initial(self):
""" Return the initial schedule (self or initial) """
"""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')
})
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).
"""
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.
# 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
@ -247,45 +285,55 @@ class Schedule(BaseRerun):
one_on_two = 0b100000
date = models.DateField(
_('date'), help_text=_('date of the first diffusion'),
_("date"),
help_text=_("date of the first diffusion"),
)
time = models.TimeField(
_('time'), help_text=_('start time'),
_("time"),
help_text=_("start time"),
)
timezone = models.CharField(
_('timezone'),
default=tz.get_current_timezone, max_length=100,
_("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')
help_text=_("timezone used for the date"),
)
duration = models.TimeField(
_('duration'),
help_text=_('regular duration'),
_("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()],
_("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')
verbose_name = _("Schedule")
verbose_name_plural = _("Schedules")
def __str__(self):
return '{} - {}, {}'.format(
self.program.title, self.get_frequency_verbose(),
self.time.strftime('%H:%M')
return "{} - {}, {}".format(
self.program.title,
self.get_frequency_verbose(),
self.time.strftime("%H:%M"),
)
def save_rerun(self, *args, **kwargs):
@ -295,31 +343,35 @@ class Schedule(BaseRerun):
@cached_property
def tz(self):
""" Pytz timezone of the schedule. """
"""Pytz timezone of the schedule."""
import pytz
return pytz.timezone(self.timezone)
@cached_property
def start(self):
""" Datetime of the start (timezone unaware) """
"""Datetime of the start (timezone unaware)"""
return tz.datetime.combine(self.date, self.time)
@cached_property
def end(self):
""" Datetime of the end """
"""Datetime of the end."""
return self.start + utils.to_timedelta(self.duration)
def get_frequency_verbose(self):
""" Return frequency formated for display """
"""Return frequency formated for display."""
from django.template.defaultfilters import date
return self.get_frequency_display().format(
day=date(self.date, 'l')
).capitalize()
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']):
def changed(self, fields=["date", "duration", "frequency", "timezone"]):
initial = self._Schedule__initial
if not initial:
@ -334,15 +386,13 @@ class Schedule(BaseRerun):
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).
"""
"""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. """
"""Return normalized diffusion dates of provided date's month."""
if self.frequency == Schedule.Frequency.ponctual:
return []
@ -352,7 +402,8 @@ class Schedule(BaseRerun):
# last of the month
if freq == Schedule.Frequency.last:
date = date.replace(
day=calendar.monthrange(date.year, date.month)[1])
day=calendar.monthrange(date.year, date.month)[1]
)
date_wday = date.weekday()
# end of month before the wanted weekday: move one week back
@ -361,56 +412,72 @@ class Schedule(BaseRerun):
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
# 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)
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))
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))
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))
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
"""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:
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()]
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])
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))
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)
@ -430,8 +497,12 @@ class Schedule(BaseRerun):
initial = diffusions[initial]
diffusions[date] = Diffusion(
episode=episode, schedule=self, type=Diffusion.TYPE_ON_AIR,
initial=initial, start=date, end=date+duration
episode=episode,
schedule=self,
type=Diffusion.TYPE_ON_AIR,
initial=initial,
start=date,
end=date + duration,
)
return episodes.values(), diffusions.values()
@ -440,36 +511,38 @@ class Schedule(BaseRerun):
# TODO/FIXME: use validators?
if self.initial is not None and self.date > self.date:
raise ValueError('initial must be later')
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 whose linked to a Stream.
"""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
whose linked to a Stream.
All sounds that are marked as good and that are under the related
program's archive dir are elligible for the sound's selection.
"""
program = models.ForeignKey(
Program, models.CASCADE,
verbose_name=_('related program'),
Program,
models.CASCADE,
verbose_name=_("related program"),
)
delay = models.TimeField(
_('delay'), blank=True, null=True,
help_text=_('minimal delay between two sound plays')
_("delay"),
blank=True,
null=True,
help_text=_("minimal delay between two sound plays"),
)
begin = models.TimeField(
_('begin'), blank=True, null=True,
help_text=_('used to define a time range this stream is '
'played')
_("begin"),
blank=True,
null=True,
help_text=_("used to define a time range this stream is " "played"),
)
end = models.TimeField(
_('end'),
blank=True, null=True,
help_text=_('used to define a time range this stream is '
'played')
_("end"),
blank=True,
null=True,
help_text=_("used to define a time range this stream is " "played"),
)