forked from rc/aircox
code quality
This commit is contained in:
@ -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",
|
||||
)
|
||||
|
@ -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")
|
||||
|
@ -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),
|
||||
}
|
||||
|
@ -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)}
|
||||
|
||||
|
@ -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
|
||||
)
|
||||
|
@ -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"),
|
||||
)
|
||||
|
||||
|
||||
|
@ -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()
|
||||
|
@ -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)
|
||||
|
||||
|
||||
|
@ -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)
|
||||
|
||||
|
@ -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
|
||||
)
|
||||
|
Reference in New Issue
Block a user