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,12 +1,48 @@
from .article import *
from .page import *
from .program import *
from .episode import *
from .log import *
from .sound import *
from .station import *
from .user_settings import *
from . import signals
from .article import Article
from .episode import Diffusion, DiffusionQuerySet, Episode
from .log import Log, LogArchiver, LogQuerySet
from .page import Category, Comment, NavItem, Page, PageQuerySet, StaticPage
from .program import (
BaseRerun,
BaseRerunQuerySet,
Program,
ProgramChildQuerySet,
ProgramQuerySet,
Schedule,
Stream,
)
from .sound import Sound, SoundQuerySet, Track
from .station import Port, Station, StationQuerySet
from .user_settings import UserSettings
__all__ = (
"signals",
"Article",
"Episode",
"Diffusion",
"DiffusionQuerySet",
"Log",
"LogQuerySet",
"LogArchiver",
"Category",
"PageQuerySet",
"Page",
"StaticPage",
"Comment",
"NavItem",
"Program",
"ProgramQuerySet",
"Stream",
"Schedule",
"ProgramChildQuerySet",
"BaseRerun",
"BaseRerunQuerySet",
"Sound",
"SoundQuerySet",
"Track",
"Station",
"StationQuerySet",
"Port",
"UserSettings",
)

View File

@ -3,16 +3,14 @@ from django.utils.translation import gettext_lazy as _
from .page import Page
from .program import ProgramChildQuerySet
__all__ = ('Article',)
__all__ = ("Article",)
class Article(Page):
detail_url_name = 'article-detail'
detail_url_name = "article-detail"
objects = ProgramChildQuerySet.as_manager()
class Meta:
verbose_name = _('Article')
verbose_name_plural = _('Articles')
verbose_name = _("Article")
verbose_name_plural = _("Articles")

View File

@ -3,45 +3,51 @@ import datetime
from django.db import models
from django.db.models import Q
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 easy_thumbnails.files import get_thumbnailer
from aircox import settings, utils
from .program import ProgramChildQuerySet, \
BaseRerun, BaseRerunQuerySet, Schedule
from .page import Page
from .program import (
BaseRerun,
BaseRerunQuerySet,
ProgramChildQuerySet,
Schedule,
)
__all__ = ('Episode', 'Diffusion', 'DiffusionQuerySet')
__all__ = ("Episode", "Diffusion", "DiffusionQuerySet")
class Episode(Page):
objects = ProgramChildQuerySet.as_manager()
detail_url_name = 'episode-detail'
item_template_name = 'aircox/widgets/episode_item.html'
detail_url_name = "episode-detail"
item_template_name = "aircox/widgets/episode_item.html"
@property
def program(self):
return getattr(self.parent, 'program', None)
return getattr(self.parent, "program", None)
@cached_property
def podcasts(self):
""" Return serialized data about podcasts. """
"""Return serialized data about podcasts."""
from ..serializers import PodcastSerializer
podcasts = [PodcastSerializer(s).data
for s in self.sound_set.public().order_by('type')]
podcasts = [
PodcastSerializer(s).data
for s in self.sound_set.public().order_by("type")
]
if self.cover:
options = {'size': (128, 128), 'crop': 'scale'}
options = {"size": (128, 128), "crop": "scale"}
cover = get_thumbnailer(self.cover).get_thumbnail(options).url
else:
cover = None
for index, podcast in enumerate(podcasts):
podcasts[index]['cover'] = cover
podcasts[index]['page_url'] = self.get_absolute_url()
podcasts[index]['page_title'] = self.title
podcasts[index]["cover"] = cover
podcasts[index]["page_url"] = self.get_absolute_url()
podcasts[index]["page_title"] = self.title
return podcasts
@program.setter
@ -49,8 +55,8 @@ class Episode(Page):
self.parent = value
class Meta:
verbose_name = _('Episode')
verbose_name_plural = _('Episodes')
verbose_name = _("Episode")
verbose_name_plural = _("Episodes")
def get_absolute_url(self):
if not self.is_published:
@ -59,82 +65,89 @@ class Episode(Page):
def save(self, *args, **kwargs):
if self.parent is None:
raise ValueError('missing parent program')
raise ValueError("missing parent program")
super().save(*args, **kwargs)
@classmethod
def get_default_title(cls, page, date):
return settings.AIRCOX_EPISODE_TITLE.format(
program=page,
date=date.strftime(settings.AIRCOX_EPISODE_TITLE_DATE_FORMAT)
date=date.strftime(settings.AIRCOX_EPISODE_TITLE_DATE_FORMAT),
)
@classmethod
def get_init_kwargs_from(cls, page, date, title=None, **kwargs):
""" Get default Episode's title """
title = settings.AIRCOX_EPISODE_TITLE.format(
program=page,
date=date.strftime(settings.AIRCOX_EPISODE_TITLE_DATE_FORMAT),
) if title is None else title
return super().get_init_kwargs_from(page, title=title, program=page,
**kwargs)
"""Get default Episode's title."""
title = (
settings.AIRCOX_EPISODE_TITLE.format(
program=page,
date=date.strftime(settings.AIRCOX_EPISODE_TITLE_DATE_FORMAT),
)
if title is None
else title
)
return super().get_init_kwargs_from(
page, title=title, program=page, **kwargs
)
class DiffusionQuerySet(BaseRerunQuerySet):
def episode(self, episode=None, id=None):
""" Diffusions for this episode """
return self.filter(episode=episode) if id is None else \
self.filter(episode__id=id)
"""Diffusions for this episode."""
return (
self.filter(episode=episode)
if id is None
else self.filter(episode__id=id)
)
def on_air(self):
""" On air diffusions """
"""On air diffusions."""
return self.filter(type=Diffusion.TYPE_ON_AIR)
# TODO: rename to `datetime`
def now(self, now=None, order=True):
""" Diffusions occuring now """
"""Diffusions occuring now."""
now = now or tz.now()
qs = self.filter(start__lte=now, end__gte=now).distinct()
return qs.order_by('start') if order else qs
return qs.order_by("start") if order else qs
def date(self, date=None, order=True):
""" Diffusions occuring date. """
"""Diffusions occuring date."""
date = date or datetime.date.today()
start = tz.datetime.combine(date, datetime.time())
end = tz.datetime.combine(date, datetime.time(23, 59, 59, 999))
# start = tz.get_current_timezone().localize(start)
# end = tz.get_current_timezone().localize(end)
qs = self.filter(start__range=(start, end))
return qs.order_by('start') if order else qs
return qs.order_by("start") if order else qs
def at(self, date, order=True):
""" Return diffusions at specified date or datetime """
return self.now(date, order) if isinstance(date, tz.datetime) else \
self.date(date, order)
"""Return diffusions at specified date or datetime."""
return (
self.now(date, order)
if isinstance(date, tz.datetime)
else self.date(date, order)
)
def after(self, date=None):
"""
Return a queryset of diffusions that happen after the given
date (default: today).
"""
"""Return a queryset of diffusions that happen after the given date
(default: today)."""
date = utils.date_or_default(date)
if isinstance(date, tz.datetime):
qs = self.filter(Q(start__gte=date) | Q(end__gte=date))
else:
qs = self.filter(Q(start__date__gte=date) | Q(end__date__gte=date))
return qs.order_by('start')
return qs.order_by("start")
def before(self, date=None):
"""
Return a queryset of diffusions that finish before the given
date (default: today).
"""
"""Return a queryset of diffusions that finish before the given date
(default: today)."""
date = utils.date_or_default(date)
if isinstance(date, tz.datetime):
qs = self.filter(start__lt=date)
else:
qs = self.filter(start__date__lt=date)
return qs.order_by('start')
return qs.order_by("start")
def range(self, start, end):
# FIXME can return dates that are out of range...
@ -142,10 +155,9 @@ class DiffusionQuerySet(BaseRerunQuerySet):
class Diffusion(BaseRerun):
"""
A Diffusion is an occurrence of a Program that is scheduled on the
station's timetable. It can be a rerun of a previous diffusion. In such
a case, use rerun's info instead of its own.
"""A Diffusion is an occurrence of a Program that is scheduled on the
station's timetable. It can be a rerun of a previous diffusion. In such a
case, use rerun's info instead of its own.
A Diffusion without any rerun is named Episode (previously, a
Diffusion was different from an Episode, but in the end, an
@ -159,29 +171,37 @@ class Diffusion(BaseRerun):
- cancel: the diffusion has been canceled
- stop: the diffusion has been manually stopped
"""
objects = DiffusionQuerySet.as_manager()
TYPE_ON_AIR = 0x00
TYPE_UNCONFIRMED = 0x01
TYPE_CANCEL = 0x02
TYPE_CHOICES = (
(TYPE_ON_AIR, _('on air')),
(TYPE_UNCONFIRMED, _('not confirmed')),
(TYPE_CANCEL, _('cancelled')),
(TYPE_ON_AIR, _("on air")),
(TYPE_UNCONFIRMED, _("not confirmed")),
(TYPE_CANCEL, _("cancelled")),
)
episode = models.ForeignKey(
Episode, models.CASCADE, verbose_name=_('episode'),
Episode,
models.CASCADE,
verbose_name=_("episode"),
)
schedule = models.ForeignKey(
Schedule, models.CASCADE, verbose_name=_('schedule'),
blank=True, null=True,
Schedule,
models.CASCADE,
verbose_name=_("schedule"),
blank=True,
null=True,
)
type = models.SmallIntegerField(
verbose_name=_('type'), default=TYPE_ON_AIR, choices=TYPE_CHOICES,
verbose_name=_("type"),
default=TYPE_ON_AIR,
choices=TYPE_CHOICES,
)
start = models.DateTimeField(_('start'), db_index=True)
end = models.DateTimeField(_('end'), db_index=True)
start = models.DateTimeField(_("start"), db_index=True)
end = models.DateTimeField(_("end"), db_index=True)
# port = models.ForeignKey(
# 'self',
# verbose_name = _('port'),
@ -190,33 +210,33 @@ class Diffusion(BaseRerun):
# help_text = _('use this input port'),
# )
item_template_name = 'aircox/widgets/diffusion_item.html'
item_template_name = "aircox/widgets/diffusion_item.html"
class Meta:
verbose_name = _('Diffusion')
verbose_name_plural = _('Diffusions')
verbose_name = _("Diffusion")
verbose_name_plural = _("Diffusions")
permissions = (
('programming', _('edit the diffusions\' planification')),
("programming", _("edit the diffusions' planification")),
)
def __str__(self):
str_ = '{episode} - {date}'.format(
str_ = "{episode} - {date}".format(
episode=self.episode and self.episode.title,
date=self.local_start.strftime('%Y/%m/%d %H:%M%z'),
date=self.local_start.strftime("%Y/%m/%d %H:%M%z"),
)
if self.initial:
str_ += ' ({})'.format(_('rerun'))
str_ += " ({})".format(_("rerun"))
return str_
def save(self, *args, **kwargs):
super().save(*args, **kwargs)
if self.is_initial and self.episode != self._initial['episode']:
if self.is_initial and self.episode != self._initial["episode"]:
self.rerun_set.update(episode=self.episode, program=self.program)
#def save(self, no_check=False, *args, **kwargs):
#if self.start != self._initial['start'] or \
# self.end != self._initial['end']:
# self.check_conflicts()
# def save(self, no_check=False, *args, **kwargs):
# if self.start != self._initial['start'] or \
# self.end != self._initial['end']:
# self.check_conflicts()
def save_rerun(self):
self.episode = self.initial.episode
@ -231,85 +251,96 @@ class Diffusion(BaseRerun):
@property
def date(self):
""" Return diffusion start as a date. """
"""Return diffusion start as a date."""
return utils.cast_date(self.start)
@cached_property
def local_start(self):
"""
Return a version of self.date that is localized to self.timezone;
This is needed since datetime are stored as UTC date and we want
to get it as local time.
"""
"""Return a version of self.date that is localized to self.timezone;
This is needed since datetime are stored as UTC date and we want to get
it as local time."""
return tz.localtime(self.start, tz.get_current_timezone())
@property
def local_end(self):
"""
Return a version of self.date that is localized to self.timezone;
This is needed since datetime are stored as UTC date and we want
to get it as local time.
"""
"""Return a version of self.date that is localized to self.timezone;
This is needed since datetime are stored as UTC date and we want to get
it as local time."""
return tz.localtime(self.end, tz.get_current_timezone())
@property
def is_now(self):
""" True if diffusion is currently running """
"""True if diffusion is currently running."""
now = tz.now()
return self.type == self.TYPE_ON_AIR and \
self.start <= now and self.end >= now
return (
self.type == self.TYPE_ON_AIR
and self.start <= now
and self.end >= now
)
@property
def is_live(self):
""" True if Diffusion is live (False if there are sounds files). """
return self.type == self.TYPE_ON_AIR and \
not self.episode.sound_set.archive().count()
"""True if Diffusion is live (False if there are sounds files)."""
return (
self.type == self.TYPE_ON_AIR
and not self.episode.sound_set.archive().count()
)
def get_playlist(self, **types):
"""
Returns sounds as a playlist (list of *local* archive file path).
"""Returns sounds as a playlist (list of *local* archive file path).
The given arguments are passed to ``get_sounds``.
"""
from .sound import Sound
return list(self.get_sounds(**types)
.filter(path__isnull=False, type=Sound.TYPE_ARCHIVE)
.values_list('path', flat=True))
return list(
self.get_sounds(**types)
.filter(path__isnull=False, type=Sound.TYPE_ARCHIVE)
.values_list("path", flat=True)
)
def get_sounds(self, **types):
"""
Return a queryset of sounds related to this diffusion,
ordered by type then path.
"""Return a queryset of sounds related to this diffusion, ordered by
type then path.
**types: filter on the given sound types name, as `archive=True`
"""
from .sound import Sound
sounds = (self.initial or self).sound_set.order_by('type', 'path')
_in = [getattr(Sound.Type, name)
for name, value in types.items() if value]
sounds = (self.initial or self).sound_set.order_by("type", "path")
_in = [
getattr(Sound.Type, name) for name, value in types.items() if value
]
return sounds.filter(type__in=_in)
def is_date_in_range(self, date=None):
"""
Return true if the given date is in the diffusion's start-end
range.
"""
"""Return true if the given date is in the diffusion's start-end
range."""
date = date or tz.now()
return self.start < date < self.end
def get_conflicts(self):
""" Return conflicting diffusions queryset """
"""Return conflicting diffusions queryset."""
# conflicts=Diffusion.objects.filter(Q(start__lt=OuterRef('start'), end__gt=OuterRef('end')) | Q(start__gt=OuterRef('start'), start__lt=OuterRef('end')))
# diffs= Diffusion.objects.annotate(conflict_with=Exists(conflicts)).filter(conflict_with=True)
return Diffusion.objects.filter(
Q(start__lt=self.start, end__gt=self.start) |
Q(start__gt=self.start, start__lt=self.end)
).exclude(pk=self.pk).distinct()
# conflicts=Diffusion.objects.filter(
# Q(start__lt=OuterRef('start'), end__gt=OuterRef('end')) |
# Q(start__gt=OuterRef('start'), start__lt=OuterRef('end'))
# )
# diffs= Diffusion.objects.annotate(conflict_with=Exists(conflicts))
# .filter(conflict_with=True)
return (
Diffusion.objects.filter(
Q(start__lt=self.start, end__gt=self.start)
| Q(start__gt=self.start, start__lt=self.end)
)
.exclude(pk=self.pk)
.distinct()
)
def check_conflicts(self):
conflicts = self.get_conflicts()
@ -320,7 +351,7 @@ class Diffusion(BaseRerun):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._initial = {
'start': self.start,
'end': self.end,
'episode': getattr(self, 'episode', None),
"start": self.start,
"end": self.end,
"episode": getattr(self, "episode", None),
}

View File

@ -1,32 +1,34 @@
from collections import deque
import datetime
import gzip
import logging
import os
from collections import deque
import yaml
from django.db import models
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 settings
from .episode import Diffusion
from .sound import Sound, Track
from .station import Station
logger = logging.getLogger('aircox')
logger = logging.getLogger("aircox")
__all__ = ('Log', 'LogQuerySet', 'LogArchiver')
__all__ = ("Log", "LogQuerySet", "LogArchiver")
class LogQuerySet(models.QuerySet):
def station(self, station=None, id=None):
return self.filter(station=station) if id is None else \
self.filter(station_id=id)
return (
self.filter(station=station)
if id is None
else self.filter(station_id=id)
)
def date(self, date):
start = tz.datetime.combine(date, datetime.time())
@ -36,9 +38,11 @@ class LogQuerySet(models.QuerySet):
# return self.filter(date__date=date)
def after(self, date):
return self.filter(date__gte=date) \
if isinstance(date, tz.datetime) else \
self.filter(date__date__gte=date)
return (
self.filter(date__gte=date)
if isinstance(date, tz.datetime)
else self.filter(date__date__gte=date)
)
def on_air(self):
return self.filter(type=Log.TYPE_ON_AIR)
@ -57,64 +61,80 @@ class LogQuerySet(models.QuerySet):
class Log(models.Model):
"""
Log sounds and diffusions that are played on the station.
"""Log sounds and diffusions that are played on the station.
This only remember what has been played on the outputs, not on each
source; Source designate here which source is responsible of that.
"""
TYPE_STOP = 0x00
""" Source has been stopped, e.g. manually """
"""Source has been stopped, e.g. manually."""
# Rule: \/ diffusion != null \/ sound != null
TYPE_START = 0x01
""" Diffusion or sound has been request to be played. """
"""Diffusion or sound has been request to be played."""
TYPE_CANCEL = 0x02
""" Diffusion has been canceled. """
"""Diffusion has been canceled."""
# Rule: \/ sound != null /\ track == null
# \/ sound == null /\ track != null
# \/ sound == null /\ track == null /\ comment = sound_path
TYPE_ON_AIR = 0x03
""" Sound or diffusion occured on air """
"""Sound or diffusion occured on air."""
TYPE_OTHER = 0x04
""" Other log """
"""Other log."""
TYPE_CHOICES = (
(TYPE_STOP, _('stop')), (TYPE_START, _('start')),
(TYPE_CANCEL, _('cancelled')), (TYPE_ON_AIR, _('on air')),
(TYPE_OTHER, _('other'))
(TYPE_STOP, _("stop")),
(TYPE_START, _("start")),
(TYPE_CANCEL, _("cancelled")),
(TYPE_ON_AIR, _("on air")),
(TYPE_OTHER, _("other")),
)
station = models.ForeignKey(
Station, models.CASCADE,
verbose_name=_('station'), help_text=_('related station'),
Station,
models.CASCADE,
verbose_name=_("station"),
help_text=_("related station"),
)
type = models.SmallIntegerField(_('type'), choices=TYPE_CHOICES)
date = models.DateTimeField(_('date'), default=tz.now, db_index=True)
type = models.SmallIntegerField(_("type"), choices=TYPE_CHOICES)
date = models.DateTimeField(_("date"), default=tz.now, db_index=True)
source = models.CharField(
# we use a CharField to avoid loosing logs information if the
# source is removed
max_length=64, blank=True, null=True,
verbose_name=_('source'),
help_text=_('identifier of the source related to this log'),
max_length=64,
blank=True,
null=True,
verbose_name=_("source"),
help_text=_("identifier of the source related to this log"),
)
comment = models.CharField(
max_length=512, blank=True, null=True,
verbose_name=_('comment'),
max_length=512,
blank=True,
null=True,
verbose_name=_("comment"),
)
sound = models.ForeignKey(
Sound, models.SET_NULL,
blank=True, null=True, db_index=True,
verbose_name=_('Sound'),
Sound,
models.SET_NULL,
blank=True,
null=True,
db_index=True,
verbose_name=_("Sound"),
)
track = models.ForeignKey(
Track, models.SET_NULL,
blank=True, null=True, db_index=True,
verbose_name=_('Track'),
Track,
models.SET_NULL,
blank=True,
null=True,
db_index=True,
verbose_name=_("Track"),
)
diffusion = models.ForeignKey(
Diffusion, models.SET_NULL,
blank=True, null=True, db_index=True,
verbose_name=_('Diffusion'),
Diffusion,
models.SET_NULL,
blank=True,
null=True,
db_index=True,
verbose_name=_("Diffusion"),
)
objects = LogQuerySet.as_manager()
@ -126,11 +146,9 @@ class Log(models.Model):
# FIXME: required????
@property
def local_date(self):
"""
Return a version of self.date that is localized to self.timezone;
This is needed since datetime are stored as UTC date and we want
to get it as local time.
"""
"""Return a version of self.date that is localized to self.timezone;
This is needed since datetime are stored as UTC date and we want to get
it as local time."""
return tz.localtime(self.date, tz.get_current_timezone())
# prepare for the future on crash + ease the use in merged lists with
@ -140,13 +158,16 @@ class Log(models.Model):
return self.date
class Meta:
verbose_name = _('Log')
verbose_name_plural = _('Logs')
verbose_name = _("Log")
verbose_name_plural = _("Logs")
def __str__(self):
return '#{} ({}, {}, {})'.format(
self.pk, self.get_type_display(),
self.source, self.local_date.strftime('%Y/%m/%d %H:%M%z'))
return "#{} ({}, {}, {})".format(
self.pk,
self.get_type_display(),
self.source,
self.local_date.strftime("%Y/%m/%d %H:%M%z"),
)
@classmethod
def __list_append(cls, object_list, items):
@ -154,15 +175,15 @@ class Log(models.Model):
@classmethod
def merge_diffusions(cls, logs, diffs, count=None):
"""
Merge logs and diffusions together. `logs` can either be a queryset
or a list ordered by `Log.date`.
"""Merge logs and diffusions together.
`logs` can either be a queryset or a list ordered by `Log.date`.
"""
# TODO: limit count
# FIXME: log may be iterable (in stats view)
if isinstance(logs, models.QuerySet):
logs = list(logs.order_by('-date'))
diffs = deque(diffs.on_air().before().order_by('-start'))
logs = list(logs.order_by("-date"))
diffs = deque(diffs.on_air().before().order_by("-start"))
object_list = []
while True:
@ -177,8 +198,10 @@ class Log(models.Model):
diff = diffs.popleft()
# - takes all logs after diff start
index = next((i for i, v in enumerate(logs)
if v.date <= diff.end), len(logs))
index = next(
(i for i, v in enumerate(logs) if v.date <= diff.end),
len(logs),
)
if index is not None and index > 0:
object_list += logs[:index]
logs = logs[index:]
@ -186,12 +209,14 @@ class Log(models.Model):
if len(logs):
# FIXME
# - last log while diff is running
#if logs[0].date > diff.start:
# if logs[0].date > diff.start:
# object_list.append(logs[0])
# - skips logs while diff is running
index = next((i for i, v in enumerate(logs)
if v.date < diff.start), len(logs))
index = next(
(i for i, v in enumerate(logs) if v.date < diff.start),
len(logs),
)
if index is not None and index > 0:
logs = logs[index:]
@ -203,18 +228,22 @@ class Log(models.Model):
def print(self):
r = []
if self.diffusion:
r.append('diff: ' + str(self.diffusion_id))
r.append("diff: " + str(self.diffusion_id))
if self.sound:
r.append('sound: ' + str(self.sound_id))
r.append("sound: " + str(self.sound_id))
if self.track:
r.append('track: ' + str(self.track_id))
logger.info('log %s: %s%s', str(self), self.comment or '',
' (' + ', '.join(r) + ')' if r else '')
r.append("track: " + str(self.track_id))
logger.info(
"log %s: %s%s",
str(self),
self.comment or "",
" (" + ", ".join(r) + ")" if r else "",
)
class LogArchiver:
""" Commodity class used to manage archives of logs. """
"""Commodity class used to manage archives of logs."""
@cached_property
def fields(self):
return Log._meta.get_fields()
@ -223,13 +252,14 @@ class LogArchiver:
def get_path(station, date):
return os.path.join(
settings.AIRCOX_LOGS_ARCHIVES_DIR,
'{}_{}.log.gz'.format(date.strftime("%Y%m%d"), station.pk)
"{}_{}.log.gz".format(date.strftime("%Y%m%d"), station.pk),
)
def archive(self, qs, keep=False):
"""
Archive logs of the given queryset. Delete archived logs if not
`keep`. Return the count of archived logs
"""Archive logs of the given queryset.
Delete archived logs if not `keep`. Return the count of archived
logs
"""
if not qs.exists():
return 0
@ -242,8 +272,10 @@ class LogArchiver:
# exists yet <3
for (station, date), logs in logs.items():
path = self.get_path(station, date)
with gzip.open(path, 'ab') as archive:
data = yaml.dump([self.serialize(l) for l in logs]).encode('utf8')
with gzip.open(path, "ab") as archive:
data = yaml.dump(
[self.serialize(line) for line in logs]
).encode("utf8")
archive.write(data)
if not keep:
@ -253,11 +285,9 @@ class LogArchiver:
@staticmethod
def sort_logs(qs):
"""
Sort logs by station and date and return a dict of
`{ (station,date): [logs] }`.
"""
qs = qs.order_by('date')
"""Sort logs by station and date and return a dict of `{
(station,date): [logs] }`."""
qs = qs.order_by("date")
logs = {}
for log in qs:
key = (log.station, log.date)
@ -268,44 +298,45 @@ class LogArchiver:
return logs
def serialize(self, log):
""" Serialize log """
return {i.attname: getattr(log, i.attname)
for i in self.fields}
"""Serialize log."""
return {i.attname: getattr(log, i.attname) for i in self.fields}
def load(self, station, date):
""" Load an archive returning logs in a list. """
"""Load an archive returning logs in a list."""
path = self.get_path(station, date)
if not os.path.exists(path):
return []
with gzip.open(path, 'rb') as archive:
with gzip.open(path, "rb") as archive:
data = archive.read()
logs = yaml.load(data)
# we need to preload diffusions, sounds and tracks
rels = {
'diffusion': self.get_relations(logs, Diffusion, 'diffusion'),
'sound': self.get_relations(logs, Sound, 'sound'),
'track': self.get_relations(logs, Track, 'track'),
"diffusion": self.get_relations(logs, Diffusion, "diffusion"),
"sound": self.get_relations(logs, Sound, "sound"),
"track": self.get_relations(logs, Track, "track"),
}
def rel_obj(log, attr):
rel_id = log.get(attr + '_id')
rel_id = log.get(attr + "_id")
return rels[attr][rel_id] if rel_id else None
return [Log(diffusion=rel_obj(log, 'diffusion'),
sound=rel_obj(log, 'sound'),
track=rel_obj(log, 'track'),
**log) for log in logs]
return [
Log(
diffusion=rel_obj(log, "diffusion"),
sound=rel_obj(log, "sound"),
track=rel_obj(log, "track"),
**log
)
for log in logs
]
@staticmethod
def get_relations(logs, model, attr):
"""
From a list of dict representing logs, retrieve related objects
of the given type.
"""
attr_id = attr + '_id'
"""From a list of dict representing logs, retrieve related objects of
the given type."""
attr_id = attr + "_id"
pks = (log[attr_id] for log in logs if attr_id in log)
return {rel.pk: rel for rel in model.objects.filter(pk__in=pks)}

View File

@ -1,38 +1,42 @@
import re
from django.db import models
from django.urls import reverse
from django.utils import timezone as tz
from django.utils.text import slugify
from django.utils.html import format_html
from django.utils.safestring import mark_safe
from django.utils.translation import gettext_lazy as _
from django.utils.functional import cached_property
import bleach
from ckeditor_uploader.fields import RichTextUploadingField
from django.db import models
from django.urls import reverse
from django.utils import timezone as tz
from django.utils.functional import cached_property
from django.utils.html import format_html
from django.utils.safestring import mark_safe
from django.utils.text import slugify
from django.utils.translation import gettext_lazy as _
from filer.fields.image import FilerImageField
from model_utils.managers import InheritanceQuerySet
from .station import Station
__all__ = ('Category', 'PageQuerySet',
'Page', 'StaticPage', 'Comment', 'NavItem')
__all__ = (
"Category",
"PageQuerySet",
"Page",
"StaticPage",
"Comment",
"NavItem",
)
headline_re = re.compile(r'(<p>)?'
r'(?P<headline>[^\n]{1,140}(\n|[^\.]*?\.))'
r'(</p>)?')
headline_re = re.compile(
r"(<p>)?" r"(?P<headline>[^\n]{1,140}(\n|[^\.]*?\.))" r"(</p>)?"
)
class Category(models.Model):
title = models.CharField(_('title'), max_length=64)
slug = models.SlugField(_('slug'), max_length=64, db_index=True)
title = models.CharField(_("title"), max_length=64)
slug = models.SlugField(_("slug"), max_length=64, db_index=True)
class Meta:
verbose_name = _('Category')
verbose_name_plural = _('Categories')
verbose_name = _("Category")
verbose_name_plural = _("Categories")
def __str__(self):
return self.title
@ -49,68 +53,90 @@ class BasePageQuerySet(InheritanceQuerySet):
return self.filter(status=Page.STATUS_TRASH)
def parent(self, parent=None, id=None):
""" Return pages having this parent. """
return self.filter(parent=parent) if id is None else \
self.filter(parent__id=id)
"""Return pages having this parent."""
return (
self.filter(parent=parent)
if id is None
else self.filter(parent__id=id)
)
def search(self, q, search_content=True):
if search_content:
return self.filter(models.Q(title__icontains=q) | models.Q(content__icontains=q))
return self.filter(
models.Q(title__icontains=q) | models.Q(content__icontains=q)
)
return self.filter(title__icontains=q)
class BasePage(models.Model):
""" Base class for publishable content """
"""Base class for publishable content."""
STATUS_DRAFT = 0x00
STATUS_PUBLISHED = 0x10
STATUS_TRASH = 0x20
STATUS_CHOICES = (
(STATUS_DRAFT, _('draft')),
(STATUS_PUBLISHED, _('published')),
(STATUS_TRASH, _('trash')),
(STATUS_DRAFT, _("draft")),
(STATUS_PUBLISHED, _("published")),
(STATUS_TRASH, _("trash")),
)
parent = models.ForeignKey('self', models.CASCADE, blank=True, null=True,
db_index=True, related_name='child_set')
parent = models.ForeignKey(
"self",
models.CASCADE,
blank=True,
null=True,
db_index=True,
related_name="child_set",
)
title = models.CharField(max_length=100)
slug = models.SlugField(_('slug'), max_length=120, blank=True, unique=True,
db_index=True)
slug = models.SlugField(
_("slug"), max_length=120, blank=True, unique=True, db_index=True
)
status = models.PositiveSmallIntegerField(
_('status'), default=STATUS_DRAFT, choices=STATUS_CHOICES,
_("status"),
default=STATUS_DRAFT,
choices=STATUS_CHOICES,
)
cover = FilerImageField(
on_delete=models.SET_NULL,
verbose_name=_('cover'), null=True, blank=True,
verbose_name=_("cover"),
null=True,
blank=True,
)
content = RichTextUploadingField(
_('content'), blank=True, null=True,
_("content"),
blank=True,
null=True,
)
objects = BasePageQuerySet.as_manager()
detail_url_name = None
item_template_name = 'aircox/widgets/page_item.html'
item_template_name = "aircox/widgets/page_item.html"
class Meta:
abstract = True
def __str__(self):
return '{}'.format(self.title or self.pk)
return "{}".format(self.title or self.pk)
def save(self, *args, **kwargs):
if not self.slug:
self.slug = slugify(self.title)[:100]
count = Page.objects.filter(slug__startswith=self.slug).count()
if count:
self.slug += '-' + str(count)
self.slug += "-" + str(count)
if self.parent and not self.cover:
self.cover = self.parent.cover
super().save(*args, **kwargs)
def get_absolute_url(self):
return reverse(self.detail_url_name, kwargs={'slug': self.slug}) \
if self.is_published else '#'
return (
reverse(self.detail_url_name, kwargs={"slug": self.slug})
if self.is_published
else "#"
)
@property
def is_draft(self):
@ -133,15 +159,15 @@ class BasePage(models.Model):
@cached_property
def headline(self):
if not self.content:
return ''
return ""
content = bleach.clean(self.content, tags=[], strip=True)
headline = headline_re.search(content)
return mark_safe(headline.groupdict()['headline']) if headline else ''
return mark_safe(headline.groupdict()["headline"]) if headline else ""
@classmethod
def get_init_kwargs_from(cls, page, **kwargs):
kwargs.setdefault('cover', page.cover)
kwargs.setdefault('category', page.category)
kwargs.setdefault("cover", page.cover)
kwargs.setdefault("category", page.category)
return kwargs
@classmethod
@ -151,30 +177,39 @@ class BasePage(models.Model):
class PageQuerySet(BasePageQuerySet):
def published(self):
return self.filter(status=Page.STATUS_PUBLISHED,
pub_date__lte=tz.now())
return self.filter(
status=Page.STATUS_PUBLISHED, pub_date__lte=tz.now()
)
class Page(BasePage):
""" Base Page model used for articles and other dated content. """
"""Base Page model used for articles and other dated content."""
category = models.ForeignKey(
Category, models.SET_NULL,
verbose_name=_('category'), blank=True, null=True, db_index=True
Category,
models.SET_NULL,
verbose_name=_("category"),
blank=True,
null=True,
db_index=True,
)
pub_date = models.DateTimeField(
_('publication date'), blank=True, null=True, db_index=True)
_("publication date"), blank=True, null=True, db_index=True
)
featured = models.BooleanField(
_('featured'), default=False,
_("featured"),
default=False,
)
allow_comments = models.BooleanField(
_('allow comments'), default=True,
_("allow comments"),
default=True,
)
objects = PageQuerySet.as_manager()
class Meta:
verbose_name = _('Publication')
verbose_name_plural = _('Publications')
verbose_name = _("Publication")
verbose_name_plural = _("Publications")
def save(self, *args, **kwargs):
if self.is_published and self.pub_date is None:
@ -188,8 +223,9 @@ class Page(BasePage):
class StaticPage(BasePage):
""" Static page that eventually can be attached to a specific view. """
detail_url_name = 'static-page-detail'
"""Static page that eventually can be attached to a specific view."""
detail_url_name = "static-page-detail"
ATTACH_TO_HOME = 0x00
ATTACH_TO_DIFFUSIONS = 0x01
@ -199,25 +235,28 @@ class StaticPage(BasePage):
ATTACH_TO_ARTICLES = 0x05
ATTACH_TO_CHOICES = (
(ATTACH_TO_HOME, _('Home page')),
(ATTACH_TO_DIFFUSIONS, _('Diffusions page')),
(ATTACH_TO_LOGS, _('Logs page')),
(ATTACH_TO_PROGRAMS, _('Programs list')),
(ATTACH_TO_EPISODES, _('Episodes list')),
(ATTACH_TO_ARTICLES, _('Articles list')),
(ATTACH_TO_HOME, _("Home page")),
(ATTACH_TO_DIFFUSIONS, _("Diffusions page")),
(ATTACH_TO_LOGS, _("Logs page")),
(ATTACH_TO_PROGRAMS, _("Programs list")),
(ATTACH_TO_EPISODES, _("Episodes list")),
(ATTACH_TO_ARTICLES, _("Articles list")),
)
VIEWS = {
ATTACH_TO_HOME: 'home',
ATTACH_TO_DIFFUSIONS: 'diffusion-list',
ATTACH_TO_LOGS: 'log-list',
ATTACH_TO_PROGRAMS: 'program-list',
ATTACH_TO_EPISODES: 'episode-list',
ATTACH_TO_ARTICLES: 'article-list',
ATTACH_TO_HOME: "home",
ATTACH_TO_DIFFUSIONS: "diffusion-list",
ATTACH_TO_LOGS: "log-list",
ATTACH_TO_PROGRAMS: "program-list",
ATTACH_TO_EPISODES: "episode-list",
ATTACH_TO_ARTICLES: "article-list",
}
attach_to = models.SmallIntegerField(
_('attach to'), choices=ATTACH_TO_CHOICES, blank=True, null=True,
help_text=_('display this page content to related element'),
_("attach to"),
choices=ATTACH_TO_CHOICES,
blank=True,
null=True,
help_text=_("display this page content to related element"),
)
def get_absolute_url(self):
@ -228,49 +267,65 @@ class StaticPage(BasePage):
class Comment(models.Model):
page = models.ForeignKey(
Page, models.CASCADE, verbose_name=_('related page'),
Page,
models.CASCADE,
verbose_name=_("related page"),
db_index=True,
# TODO: allow_comment filter
)
nickname = models.CharField(_('nickname'), max_length=32)
email = models.EmailField(_('email'), max_length=32)
nickname = models.CharField(_("nickname"), max_length=32)
email = models.EmailField(_("email"), max_length=32)
date = models.DateTimeField(auto_now_add=True)
content = models.TextField(_('content'), max_length=1024)
content = models.TextField(_("content"), max_length=1024)
class Meta:
verbose_name = _('Comment')
verbose_name_plural = _('Comments')
verbose_name = _("Comment")
verbose_name_plural = _("Comments")
class NavItem(models.Model):
""" Navigation menu items """
"""Navigation menu items."""
station = models.ForeignKey(
Station, models.CASCADE, verbose_name=_('station'))
menu = models.SlugField(_('menu'), max_length=24)
order = models.PositiveSmallIntegerField(_('order'))
text = models.CharField(_('title'), max_length=64)
url = models.CharField(_('url'), max_length=256, blank=True, null=True)
page = models.ForeignKey(StaticPage, models.CASCADE, db_index=True,
verbose_name=_('page'), blank=True, null=True)
Station, models.CASCADE, verbose_name=_("station")
)
menu = models.SlugField(_("menu"), max_length=24)
order = models.PositiveSmallIntegerField(_("order"))
text = models.CharField(_("title"), max_length=64)
url = models.CharField(_("url"), max_length=256, blank=True, null=True)
page = models.ForeignKey(
StaticPage,
models.CASCADE,
db_index=True,
verbose_name=_("page"),
blank=True,
null=True,
)
class Meta:
verbose_name = _('Menu item')
verbose_name_plural = _('Menu items')
ordering = ('order', 'pk')
verbose_name = _("Menu item")
verbose_name_plural = _("Menu items")
ordering = ("order", "pk")
def get_url(self):
return self.url if self.url else \
self.page.get_absolute_url() if self.page else None
return (
self.url
if self.url
else self.page.get_absolute_url()
if self.page
else None
)
def render(self, request, css_class='', active_class=''):
def render(self, request, css_class="", active_class=""):
url = self.get_url()
if active_class and request.path.startswith(url):
css_class += ' ' + active_class
css_class += " " + active_class
if not url:
return self.text
elif not css_class:
return format_html('<a href="{}">{}</a>', url, self.text)
else:
return format_html('<a href="{}" class="{}">{}</a>', url,
css_class, self.text)
return format_html(
'<a href="{}" class="{}">{}</a>', url, css_class, self.text
)

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"),
)

View File

@ -1,6 +1,4 @@
import pytz
from django.contrib.auth.models import User, Group, Permission
from django.contrib.auth.models import Group, Permission, User
from django.db import transaction
from django.db.models import signals
from django.dispatch import receiver
@ -18,9 +16,7 @@ from . import Diffusion, Episode, Page, Program, Schedule
#
@receiver(signals.post_save, sender=User)
def user_default_groups(sender, instance, created, *args, **kwargs):
"""
Set users to different default groups
"""
"""Set users to different default groups."""
if not created or instance.is_superuser:
return
@ -32,7 +28,8 @@ def user_default_groups(sender, instance, created, *args, **kwargs):
if created and permissions:
for codename in permissions:
permission = Permission.objects.filter(
codename=codename).first()
codename=codename
).first()
if permission:
group.permissions.add(permission)
group.save()
@ -42,43 +39,40 @@ def user_default_groups(sender, instance, created, *args, **kwargs):
@receiver(signals.post_save, sender=Page)
def page_post_save(sender, instance, created, *args, **kwargs):
if not created and instance.cover:
Page.objects.filter(parent=instance, cover__isnull=True) \
.update(cover=instance.cover)
Page.objects.filter(parent=instance, cover__isnull=True).update(
cover=instance.cover
)
@receiver(signals.post_save, sender=Program)
def program_post_save(sender, instance, created, *args, **kwargs):
"""
Clean-up later diffusions when a program becomes inactive
"""
"""Clean-up later diffusions when a program becomes inactive."""
if not instance.active:
Diffusion.object.program(instance).after(tz.now()).delete()
Episode.object.parent(instance).filter(diffusion__isnull=True) \
.delete()
Episode.object.parent(instance).filter(diffusion__isnull=True).delete()
cover = getattr(instance, '__initial_cover', None)
cover = getattr(instance, "__initial_cover", None)
if cover is None and instance.cover is not None:
Episode.objects.parent(instance) \
.filter(cover__isnull=True) \
.update(cover=instance.cover)
Episode.objects.parent(instance).filter(cover__isnull=True).update(
cover=instance.cover
)
@receiver(signals.pre_save, sender=Schedule)
def schedule_pre_save(sender, instance, *args, **kwargs):
if getattr(instance, 'pk') is not None:
if getattr(instance, "pk") is not None:
instance._initial = Schedule.objects.get(pk=instance.pk)
@receiver(signals.post_save, sender=Schedule)
def schedule_post_save(sender, instance, created, *args, **kwargs):
"""
Handles Schedule's time, duration and timezone changes and update
corresponding diffusions accordingly.
"""
initial = getattr(instance, '_initial', None)
if not initial or ((instance.time, instance.duration, instance.timezone) ==
(initial.time, initial.duration, initial.timezone)):
"""Handles Schedule's time, duration and timezone changes and update
corresponding diffusions accordingly."""
initial = getattr(instance, "_initial", None)
if not initial or (
(instance.time, instance.duration, instance.timezone)
== (initial.time, initial.duration, initial.timezone)
):
return
today = tz.datetime.today()
@ -94,14 +88,15 @@ def schedule_post_save(sender, instance, created, *args, **kwargs):
@receiver(signals.pre_delete, sender=Schedule)
def schedule_pre_delete(sender, instance, *args, **kwargs):
""" Delete later corresponding diffusion to a changed schedule. """
"""Delete later corresponding diffusion to a changed schedule."""
Diffusion.objects.filter(schedule=instance).after(tz.now()).delete()
Episode.objects.filter(diffusion__isnull=True, content__isnull=True,
sound__isnull=True).delete()
Episode.objects.filter(
diffusion__isnull=True, content__isnull=True, sound__isnull=True
).delete()
@receiver(signals.post_delete, sender=Diffusion)
def diffusion_post_delete(sender, instance, *args, **kwargs):
Episode.objects.filter(diffusion__isnull=True, content__isnull=True,
sound__isnull=True).delete()
Episode.objects.filter(
diffusion__isnull=True, content__isnull=True, sound__isnull=True
).delete()

View File

@ -6,18 +6,17 @@ from django.db import models
from django.db.models import Q
from django.utils import timezone as tz
from django.utils.translation import gettext_lazy as _
from taggit.managers import TaggableManager
from aircox import settings
from .program import Program
from .episode import Episode
from .program import Program
logger = logging.getLogger("aircox")
logger = logging.getLogger('aircox')
__all__ = ('Sound', 'SoundQuerySet', 'Track')
__all__ = ("Sound", "SoundQuerySet", "Track")
class SoundQuerySet(models.QuerySet):
@ -37,122 +36,150 @@ class SoundQuerySet(models.QuerySet):
return self.exclude(type=Sound.TYPE_REMOVED)
def public(self):
""" Return sounds available as podcasts """
"""Return sounds available as podcasts."""
return self.filter(is_public=True)
def downloadable(self):
""" Return sounds available as podcasts """
"""Return sounds available as podcasts."""
return self.filter(is_downloadable=True)
def archive(self):
""" Return sounds that are archives """
"""Return sounds that are archives."""
return self.filter(type=Sound.TYPE_ARCHIVE)
def path(self, paths):
if isinstance(paths, str):
return self.filter(file=paths.replace(conf.MEDIA_ROOT + '/', ''))
return self.filter(file__in=(p.replace(conf.MEDIA_ROOT + '/', '')
for p in paths))
return self.filter(file=paths.replace(conf.MEDIA_ROOT + "/", ""))
return self.filter(
file__in=(p.replace(conf.MEDIA_ROOT + "/", "") for p in paths)
)
def playlist(self, archive=True, order_by=True):
"""
Return files absolute paths as a flat list (exclude sound without path).
"""Return files absolute paths as a flat list (exclude sound without
path).
If `order_by` is True, order by path.
"""
if archive:
self = self.archive()
if order_by:
self = self.order_by('file')
return [os.path.join(conf.MEDIA_ROOT, file) for file in self.filter(file__isnull=False) \
.values_list('file', flat=True)]
self = self.order_by("file")
return [
os.path.join(conf.MEDIA_ROOT, file)
for file in self.filter(file__isnull=False).values_list(
"file", flat=True
)
]
def search(self, query):
return self.filter(
Q(name__icontains=query) | Q(file__icontains=query) |
Q(program__title__icontains=query) |
Q(episode__title__icontains=query)
Q(name__icontains=query)
| Q(file__icontains=query)
| Q(program__title__icontains=query)
| Q(episode__title__icontains=query)
)
# TODO:
# - provide a default name based on program and episode
class Sound(models.Model):
"""
A Sound is the representation of a sound file that can be either an excerpt
or a complete archive of the related diffusion.
"""
"""A Sound is the representation of a sound file that can be either an
excerpt or a complete archive of the related diffusion."""
TYPE_OTHER = 0x00
TYPE_ARCHIVE = 0x01
TYPE_EXCERPT = 0x02
TYPE_REMOVED = 0x03
TYPE_CHOICES = (
(TYPE_OTHER, _('other')), (TYPE_ARCHIVE, _('archive')),
(TYPE_EXCERPT, _('excerpt')), (TYPE_REMOVED, _('removed'))
(TYPE_OTHER, _("other")),
(TYPE_ARCHIVE, _("archive")),
(TYPE_EXCERPT, _("excerpt")),
(TYPE_REMOVED, _("removed")),
)
name = models.CharField(_('name'), max_length=64)
name = models.CharField(_("name"), max_length=64)
program = models.ForeignKey(
Program, models.CASCADE, blank=True, # NOT NULL
verbose_name=_('program'),
help_text=_('program related to it'),
Program,
models.CASCADE,
blank=True, # NOT NULL
verbose_name=_("program"),
help_text=_("program related to it"),
db_index=True,
)
episode = models.ForeignKey(
Episode, models.SET_NULL, blank=True, null=True,
verbose_name=_('episode'),
Episode,
models.SET_NULL,
blank=True,
null=True,
verbose_name=_("episode"),
db_index=True,
)
type = models.SmallIntegerField(_('type'), choices=TYPE_CHOICES)
type = models.SmallIntegerField(_("type"), choices=TYPE_CHOICES)
position = models.PositiveSmallIntegerField(
_('order'), default=0, help_text=_('position in the playlist'),
_("order"),
default=0,
help_text=_("position in the playlist"),
)
def _upload_to(self, filename):
subdir = settings.AIRCOX_SOUND_ARCHIVES_SUBDIR \
if self.type == self.TYPE_ARCHIVE else \
settings.AIRCOX_SOUND_EXCERPTS_SUBDIR
subdir = (
settings.AIRCOX_SOUND_ARCHIVES_SUBDIR
if self.type == self.TYPE_ARCHIVE
else settings.AIRCOX_SOUND_EXCERPTS_SUBDIR
)
return os.path.join(self.program.path, subdir, filename)
file = models.FileField(
_('file'), upload_to=_upload_to, max_length=256,
db_index=True, unique=True,
_("file"),
upload_to=_upload_to,
max_length=256,
db_index=True,
unique=True,
)
duration = models.TimeField(
_('duration'),
blank=True, null=True,
help_text=_('duration of the sound'),
_("duration"),
blank=True,
null=True,
help_text=_("duration of the sound"),
)
mtime = models.DateTimeField(
_('modification time'),
blank=True, null=True,
help_text=_('last modification date and time'),
_("modification time"),
blank=True,
null=True,
help_text=_("last modification date and time"),
)
is_good_quality = models.BooleanField(
_('good quality'), help_text=_('sound meets quality requirements'),
blank=True, null=True
_("good quality"),
help_text=_("sound meets quality requirements"),
blank=True,
null=True,
)
is_public = models.BooleanField(
_('public'), help_text=_('whether it is publicly available as podcast'),
_("public"),
help_text=_("whether it is publicly available as podcast"),
default=False,
)
is_downloadable = models.BooleanField(
_('downloadable'),
help_text=_('whether it can be publicly downloaded by visitors (sound must be public)'),
_("downloadable"),
help_text=_(
"whether it can be publicly downloaded by visitors (sound must be "
"public)"
),
default=False,
)
objects = SoundQuerySet.as_manager()
class Meta:
verbose_name = _('Sound')
verbose_name_plural = _('Sounds')
verbose_name = _("Sound")
verbose_name_plural = _("Sounds")
@property
def url(self):
return self.file and self.file.url
def __str__(self):
return '/'.join(self.file.path.split('/')[-3:])
return "/".join(self.file.path.split("/")[-3:])
def save(self, check=True, *args, **kwargs):
if self.episode is not None and self.program is None:
@ -166,29 +193,28 @@ class Sound(models.Model):
# TODO: rename get_file_mtime(self)
def get_mtime(self):
"""
Get the last modification date from file
"""
"""Get the last modification date from file."""
mtime = os.stat(self.file.path).st_mtime
mtime = tz.datetime.fromtimestamp(mtime)
mtime = mtime.replace(microsecond=0)
return tz.make_aware(mtime, tz.get_current_timezone())
def file_exists(self):
""" Return true if the file still exists. """
"""Return true if the file still exists."""
return os.path.exists(self.file.path)
# TODO: rename to sync_fs()
def check_on_file(self):
"""
Check sound file info again'st self, and update informations if
needed (do not save). Return True if there was changes.
"""Check sound file info again'st self, and update informations if
needed (do not save).
Return True if there was changes.
"""
if not self.file_exists():
if self.type == self.TYPE_REMOVED:
return
logger.debug('sound %s: has been removed', self.file.name)
logger.debug("sound %s: has been removed", self.file.name)
self.type = self.TYPE_REMOVED
return True
@ -197,9 +223,11 @@ class Sound(models.Model):
if self.type == self.TYPE_REMOVED and self.program:
changed = True
self.type = self.TYPE_ARCHIVE \
if self.file.name.startswith(self.program.archives_path) else \
self.TYPE_EXCERPT
self.type = (
self.TYPE_ARCHIVE
if self.file.name.startswith(self.program.archives_path)
else self.TYPE_EXCERPT
)
# check mtime -> reset quality if changed (assume file changed)
mtime = self.get_mtime()
@ -207,8 +235,10 @@ class Sound(models.Model):
if self.mtime != mtime:
self.mtime = mtime
self.is_good_quality = None
logger.debug('sound %s: m_time has changed. Reset quality info',
self.file.name)
logger.debug(
"sound %s: m_time has changed. Reset quality info",
self.file.name,
)
return True
return changed
@ -218,7 +248,7 @@ class Sound(models.Model):
# FIXME: later, remove date?
name = os.path.basename(self.file.name)
name = os.path.splitext(name)[0]
self.name = name.replace('_', ' ').strip()
self.name = name.replace("_", " ").strip()
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
@ -226,53 +256,67 @@ class Sound(models.Model):
class Track(models.Model):
"""Track of a playlist of an object.
The position can either be expressed as the position in the playlist
or as the moment in seconds it started.
"""
Track of a playlist of an object. The position can either be expressed
as the position in the playlist or as the moment in seconds it started.
"""
episode = models.ForeignKey(
Episode, models.CASCADE, blank=True, null=True,
verbose_name=_('episode'),
Episode,
models.CASCADE,
blank=True,
null=True,
verbose_name=_("episode"),
)
sound = models.ForeignKey(
Sound, models.CASCADE, blank=True, null=True,
verbose_name=_('sound'),
Sound,
models.CASCADE,
blank=True,
null=True,
verbose_name=_("sound"),
)
position = models.PositiveSmallIntegerField(
_('order'), default=0, help_text=_('position in the playlist'),
_("order"),
default=0,
help_text=_("position in the playlist"),
)
timestamp = models.PositiveSmallIntegerField(
_('timestamp'),
blank=True, null=True,
help_text=_('position (in seconds)')
_("timestamp"),
blank=True,
null=True,
help_text=_("position (in seconds)"),
)
title = models.CharField(_('title'), max_length=128)
artist = models.CharField(_('artist'), max_length=128)
album = models.CharField(_('album'), max_length=128, null=True, blank=True)
tags = TaggableManager(verbose_name=_('tags'), blank=True)
year = models.IntegerField(_('year'), blank=True, null=True)
title = models.CharField(_("title"), max_length=128)
artist = models.CharField(_("artist"), max_length=128)
album = models.CharField(_("album"), max_length=128, null=True, blank=True)
tags = TaggableManager(verbose_name=_("tags"), blank=True)
year = models.IntegerField(_("year"), blank=True, null=True)
# FIXME: remove?
info = models.CharField(
_('information'),
_("information"),
max_length=128,
blank=True, null=True,
help_text=_('additional informations about this track, such as '
'the version, if is it a remix, features, etc.'),
blank=True,
null=True,
help_text=_(
"additional informations about this track, such as "
"the version, if is it a remix, features, etc."
),
)
class Meta:
verbose_name = _('Track')
verbose_name_plural = _('Tracks')
ordering = ('position',)
verbose_name = _("Track")
verbose_name_plural = _("Tracks")
ordering = ("position",)
def __str__(self):
return '{self.artist} -- {self.title} -- {self.position}'.format(
self=self)
return "{self.artist} -- {self.title} -- {self.position}".format(
self=self
)
def save(self, *args, **kwargs):
if (self.sound is None and self.episode is None) or \
(self.sound is not None and self.episode is not None):
raise ValueError('sound XOR episode is required')
if (self.sound is None and self.episode is None) or (
self.sound is not None and self.episode is not None
):
raise ValueError("sound XOR episode is required")
super().save(*args, **kwargs)

View File

@ -1,25 +1,20 @@
import os
from django.db import models
from django.utils.translation import gettext_lazy as _
from django.utils.functional import cached_property
from django.utils.translation import gettext_lazy as _
from filer.fields.image import FilerImageField
from .. import settings
__all__ = ('Station', 'StationQuerySet', 'Port')
__all__ = ("Station", "StationQuerySet", "Port")
class StationQuerySet(models.QuerySet):
def default(self, station=None):
"""
Return station model instance, using defaults or
given one.
"""
"""Return station model instance, using defaults or given one."""
if station is None:
return self.order_by('-default', 'pk').first()
return self.order_by("-default", "pk").first()
return self.filter(pk=station).first()
def active(self):
@ -27,66 +22,79 @@ class StationQuerySet(models.QuerySet):
class Station(models.Model):
"""
Represents a radio station, to which multiple programs are attached
and that is used as the top object for everything.
"""Represents a radio station, to which multiple programs are attached and
that is used as the top object for everything.
A Station holds controllers for the audio stream generation too.
Theses are set up when needed (at the first access to these elements)
then cached.
Theses are set up when needed (at the first access to these
elements) then cached.
"""
name = models.CharField(_('name'), max_length=64)
slug = models.SlugField(_('slug'), max_length=64, unique=True)
name = models.CharField(_("name"), max_length=64)
slug = models.SlugField(_("slug"), max_length=64, unique=True)
# FIXME: remove - should be decided only by Streamer controller + settings
path = models.CharField(
_('path'),
help_text=_('path to the working directory'),
_("path"),
help_text=_("path to the working directory"),
max_length=256,
blank=True,
)
default = models.BooleanField(
_('default station'),
_("default station"),
default=False,
help_text=_('use this station as the main one.')
help_text=_("use this station as the main one."),
)
active = models.BooleanField(
_('active'),
_("active"),
default=True,
help_text=_('whether this station is still active or not.')
help_text=_("whether this station is still active or not."),
)
logo = FilerImageField(
on_delete=models.SET_NULL, null=True, blank=True,
verbose_name=_('Logo'),
on_delete=models.SET_NULL,
null=True,
blank=True,
verbose_name=_("Logo"),
)
hosts = models.TextField(
_("website's urls"), max_length=512, null=True, blank=True,
help_text=_('specify one url per line')
_("website's urls"),
max_length=512,
null=True,
blank=True,
help_text=_("specify one url per line"),
)
audio_streams = models.TextField(
_("audio streams"), max_length=2048, null=True, blank=True,
help_text=_("Audio streams urls used by station's player. One url "
"a line.")
_("audio streams"),
max_length=2048,
null=True,
blank=True,
help_text=_(
"Audio streams urls used by station's player. One url " "a line."
),
)
default_cover = FilerImageField(
on_delete=models.SET_NULL,
verbose_name=_('Default pages\' cover'), null=True, blank=True,
related_name='+',
verbose_name=_("Default pages' cover"),
null=True,
blank=True,
related_name="+",
)
objects = StationQuerySet.as_manager()
@cached_property
def streams(self):
""" Audio streams as list of urls. """
return self.audio_streams.split('\n') if self.audio_streams else []
"""Audio streams as list of urls."""
return self.audio_streams.split("\n") if self.audio_streams else []
def __str__(self):
return self.name
def save(self, make_sources=True, *args, **kwargs):
if not self.path:
self.path = os.path.join(settings.AIRCOX_CONTROLLERS_WORKING_DIR,
self.slug.replace('-', '_'))
self.path = os.path.join(
settings.AIRCOX_CONTROLLERS_WORKING_DIR,
self.slug.replace("-", "_"),
)
if self.default:
qs = Station.objects.filter(default=True)
@ -99,22 +107,20 @@ class Station(models.Model):
class PortQuerySet(models.QuerySet):
def active(self, value=True):
""" Active ports """
"""Active ports."""
return self.filter(active=value)
def output(self):
""" Filter in output ports """
"""Filter in output ports."""
return self.filter(direction=Port.DIRECTION_OUTPUT)
def input(self):
""" Fitler in input ports """
"""Fitler in input ports."""
return self.filter(direction=Port.DIRECTION_INPUT)
class Port(models.Model):
"""
Represent an audio input/output for the audio stream
generation.
"""Represent an audio input/output for the audio stream generation.
You might want to take a look to LiquidSoap's documentation
for the options available for each kind of input/output.
@ -122,10 +128,13 @@ class Port(models.Model):
Some port types may be not available depending on the
direction of the port.
"""
DIRECTION_INPUT = 0x00
DIRECTION_OUTPUT = 0x01
DIRECTION_CHOICES = ((DIRECTION_INPUT, _('input')),
(DIRECTION_OUTPUT, _('output')))
DIRECTION_CHOICES = (
(DIRECTION_INPUT, _("input")),
(DIRECTION_OUTPUT, _("output")),
)
TYPE_JACK = 0x00
TYPE_ALSA = 0x01
@ -135,27 +144,34 @@ class Port(models.Model):
TYPE_HTTPS = 0x05
TYPE_FILE = 0x06
TYPE_CHOICES = (
(TYPE_JACK, 'jack'), (TYPE_ALSA, 'alsa'),
(TYPE_PULSEAUDIO, 'pulseaudio'), (TYPE_ICECAST, 'icecast'),
(TYPE_HTTP, 'http'), (TYPE_HTTPS, 'https'),
(TYPE_FILE, _('file'))
(TYPE_JACK, "jack"),
(TYPE_ALSA, "alsa"),
(TYPE_PULSEAUDIO, "pulseaudio"),
(TYPE_ICECAST, "icecast"),
(TYPE_HTTP, "http"),
(TYPE_HTTPS, "https"),
(TYPE_FILE, _("file")),
)
station = models.ForeignKey(
Station, models.CASCADE, verbose_name=_('station'))
Station, models.CASCADE, verbose_name=_("station")
)
direction = models.SmallIntegerField(
_('direction'), choices=DIRECTION_CHOICES)
type = models.SmallIntegerField(_('type'), choices=TYPE_CHOICES)
_("direction"), choices=DIRECTION_CHOICES
)
type = models.SmallIntegerField(_("type"), choices=TYPE_CHOICES)
active = models.BooleanField(
_('active'), default=True,
help_text=_('this port is active')
_("active"), default=True, help_text=_("this port is active")
)
settings = models.TextField(
_('port settings'),
help_text=_('list of comma separated params available; '
'this is put in the output config file as raw code; '
'plugin related'),
blank=True, null=True
_("port settings"),
help_text=_(
"list of comma separated params available; "
"this is put in the output config file as raw code; "
"plugin related"
),
blank=True,
null=True,
)
objects = PortQuerySet.as_manager()
@ -163,22 +179,17 @@ class Port(models.Model):
def __str__(self):
return "{direction}: {type} #{id}".format(
direction=self.get_direction_display(),
type=self.get_type_display(), id=self.pk or ''
type=self.get_type_display(),
id=self.pk or "",
)
def is_valid_type(self):
"""
Return True if the type is available for the given direction.
"""
"""Return True if the type is available for the given direction."""
if self.direction == self.DIRECTION_INPUT:
return self.type not in (
self.TYPE_ICECAST, self.TYPE_FILE
)
return self.type not in (self.TYPE_ICECAST, self.TYPE_FILE)
return self.type not in (
self.TYPE_HTTP, self.TYPE_HTTPS
)
return self.type not in (self.TYPE_HTTP, self.TYPE_HTTPS)
def save(self, *args, **kwargs):
if not self.is_valid_type():
@ -187,4 +198,3 @@ class Port(models.Model):
)
return super().save(*args, **kwargs)

View File

@ -1,16 +1,20 @@
from django.db import models
from django.contrib.auth.models import User
from django.db import models
from django.utils.translation import gettext_lazy as _
__all__ = ("UserSettings",)
class UserSettings(models.Model):
"""
Store user's settings.
"""
"""Store user's settings."""
user = models.OneToOneField(
User, models.CASCADE, verbose_name=_('User'),
related_name='aircox_settings')
playlist_editor_columns = models.JSONField(
_('Playlist Editor Columns'))
User,
models.CASCADE,
verbose_name=_("User"),
related_name="aircox_settings",
)
playlist_editor_columns = models.JSONField(_("Playlist Editor Columns"))
playlist_editor_sep = models.CharField(
_('Playlist Editor Separator'), max_length=16)
_("Playlist Editor Separator"), max_length=16
)