511 lines
15 KiB
Python
Executable File
511 lines
15 KiB
Python
Executable File
import os
|
|
|
|
from django.db import models
|
|
from django.contrib.auth.models import User
|
|
from django.template.defaultfilters import slugify
|
|
from django.contrib.contenttypes.fields import GenericForeignKey
|
|
from django.contrib.contenttypes.models import ContentType
|
|
from django.utils.translation import ugettext as _, ugettext_lazy
|
|
from django.utils import timezone as tz
|
|
from django.utils.html import strip_tags
|
|
|
|
from taggit.managers import TaggableManager
|
|
|
|
import programs.settings as settings
|
|
|
|
|
|
def date_or_default (date, date_only = False):
|
|
"""
|
|
Return date or default value (now) if not defined, and remove time info
|
|
if date_only is True
|
|
"""
|
|
date = date or tz.datetime.today()
|
|
if not tz.is_aware(date):
|
|
date = tz.make_aware(date)
|
|
if date_only:
|
|
return date.replace(hour = 0, minute = 0, second = 0, microsecond = 0)
|
|
return date
|
|
|
|
|
|
class Metadata (models.Model):
|
|
"""
|
|
meta is used to extend a model for future needs
|
|
"""
|
|
author = models.ForeignKey (
|
|
User,
|
|
verbose_name = _('author'),
|
|
blank = True, null = True,
|
|
)
|
|
title = models.CharField(
|
|
_('title'),
|
|
max_length = 128,
|
|
)
|
|
date = models.DateTimeField(
|
|
_('date'),
|
|
default = tz.datetime.now,
|
|
)
|
|
public = models.BooleanField(
|
|
_('public'),
|
|
default = True,
|
|
help_text = _('publication is public'),
|
|
)
|
|
tags = TaggableManager(
|
|
_('tags'),
|
|
blank = True,
|
|
)
|
|
|
|
def get_slug_name (self):
|
|
return slugify(self.title)
|
|
|
|
class Meta:
|
|
abstract = True
|
|
|
|
|
|
class Publication (Metadata):
|
|
subtitle = models.CharField(
|
|
_('subtitle'),
|
|
max_length = 128,
|
|
blank = True,
|
|
)
|
|
img = models.ImageField(
|
|
_('image'),
|
|
upload_to = "images",
|
|
blank = True,
|
|
)
|
|
content = models.TextField(
|
|
_('content'),
|
|
blank = True,
|
|
)
|
|
commentable = models.BooleanField(
|
|
_('enable comments'),
|
|
default = True,
|
|
help_text = _('comments are enabled on this publication'),
|
|
)
|
|
|
|
@staticmethod
|
|
def _exclude_args (allow_unpublished = False, prefix = ''):
|
|
if allow_unpublished:
|
|
return {}
|
|
|
|
res = {}
|
|
res[prefix + 'public'] = False
|
|
res[prefix + 'date__gt'] = tz.now()
|
|
return res
|
|
|
|
@classmethod
|
|
def get_available (cl, first = False, **kwargs):
|
|
"""
|
|
Return the result of filter(kargs) if the resulting publications
|
|
is published and public
|
|
|
|
Otherwise, return None
|
|
"""
|
|
kwargs['public'] = True
|
|
kwargs['date__lte'] = tz.now()
|
|
|
|
e = cl.objects.filter(**kwargs)
|
|
|
|
if first:
|
|
return (e and e[0]) or None
|
|
return e or None
|
|
|
|
def __str__ (self):
|
|
return self.title + ' (' + str(self.id) + ')'
|
|
|
|
class Meta:
|
|
abstract = True
|
|
|
|
|
|
class Track (models.Model):
|
|
# There are no nice solution for M2M relations ship (even without
|
|
# through) in django-admin. So we unfortunately need to make one-
|
|
# to-one relations and add a position argument
|
|
episode = models.ForeignKey(
|
|
'Episode',
|
|
)
|
|
artist = models.CharField(
|
|
_('artist'),
|
|
max_length = 128,
|
|
)
|
|
title = models.CharField(
|
|
_('title'),
|
|
max_length = 128,
|
|
)
|
|
tags = TaggableManager( blank = True )
|
|
# position can be used to specify a position in seconds for non-stop
|
|
# programs or a position in the playlist
|
|
position = models.SmallIntegerField(
|
|
default = 0,
|
|
help_text=_('position in the playlist'),
|
|
)
|
|
|
|
def __str__(self):
|
|
return ' '.join([self.artist, ':', self.title])
|
|
|
|
class Meta:
|
|
verbose_name = _('Track')
|
|
verbose_name_plural = _('Tracks')
|
|
|
|
|
|
class Sound (Metadata):
|
|
"""
|
|
A Sound is the representation of a sound, that can be:
|
|
- An episode podcast/complete record
|
|
- An episode partial podcast
|
|
- An episode is a part of the episode but not usable for direct podcast
|
|
|
|
We can manage this using the "public" and "fragment" fields. If a Sound is
|
|
public, then we can podcast it. If a Sound is a fragment, then it is not
|
|
usable for diffusion.
|
|
|
|
Each sound file can be associated to a filesystem's file or an embedded
|
|
code (for external podcasts).
|
|
"""
|
|
path = models.FilePathField(
|
|
_('file'),
|
|
path = settings.AIRCOX_PROGRAMS_DIR,
|
|
match = '*(' + '|'.join(settings.AIRCOX_SOUNDFILE_EXT) + ')$',
|
|
recursive = True,
|
|
blank = True, null = True,
|
|
)
|
|
embed = models.TextField(
|
|
_('embed HTML code from external website'),
|
|
blank = True, null = True,
|
|
help_text = _('if set, consider the sound podcastable'),
|
|
)
|
|
duration = models.TimeField(
|
|
_('duration'),
|
|
blank = True, null = True,
|
|
)
|
|
fragment = models.BooleanField(
|
|
_('incomplete sound'),
|
|
default = False,
|
|
help_text = _("the file has been cut"),
|
|
)
|
|
removed = models.BooleanField(
|
|
default = False,
|
|
help_text = _('this sound has been removed from filesystem'),
|
|
)
|
|
|
|
def get_mtime (self):
|
|
"""
|
|
Get the last modification date from file
|
|
"""
|
|
mtime = os.stat(self.path).st_mtime
|
|
mtime = tz.datetime.fromtimestamp(mtime)
|
|
return tz.make_aware(mtime, timezone.get_current_timezone())
|
|
|
|
def save (self, *args, **kwargs):
|
|
if not self.pk:
|
|
self.date = self.get_mtime()
|
|
super(Sound, self).save(*args, **kwargs)
|
|
|
|
def __str__ (self):
|
|
return '/'.join(self.path.split('/')[-3:])
|
|
|
|
class Meta:
|
|
verbose_name = _('Sound')
|
|
verbose_name_plural = _('Sounds')
|
|
|
|
|
|
class Schedule (models.Model):
|
|
# 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
|
|
Frequency = {
|
|
'first': 0b000001,
|
|
'second': 0b000010,
|
|
'third': 0b000100,
|
|
'fourth': 0b001000,
|
|
'last': 0b010000,
|
|
'first and third': 0b000101,
|
|
'second and fourth': 0b001010,
|
|
'every': 0b011111,
|
|
'one on two': 0b100000,
|
|
}
|
|
for key, value in Frequency.items():
|
|
ugettext_lazy(key)
|
|
|
|
parent = models.ForeignKey(
|
|
'Program',
|
|
blank = True, null = True,
|
|
)
|
|
date = models.DateTimeField(_('date'))
|
|
duration = models.TimeField(
|
|
_('duration'),
|
|
)
|
|
frequency = models.SmallIntegerField(
|
|
_('frequency'),
|
|
choices = [ (y, x) for x,y in Frequency.items() ],
|
|
)
|
|
rerun = models.ForeignKey(
|
|
'self',
|
|
blank = True, null = True,
|
|
help_text = "Schedule of a rerun",
|
|
)
|
|
|
|
def match (self, date = None, check_time = True):
|
|
"""
|
|
Return True if the given datetime matches the schedule
|
|
"""
|
|
date = date_or_default(date)
|
|
|
|
if self.date.weekday() == date.weekday() and self.match_week(date):
|
|
return self.date.time() == date.time() if check_time else True
|
|
return False
|
|
|
|
def match_week (self, date = None):
|
|
"""
|
|
Return True if the given week number matches the schedule, False
|
|
otherwise.
|
|
If the schedule is ponctual, return None.
|
|
"""
|
|
date = date_or_default(date)
|
|
if self.frequency == Schedule.Frequency['one on two']:
|
|
week = date.isocalendar()[1]
|
|
return (week % 2) == (self.date.isocalendar()[1] % 2)
|
|
|
|
first_of_month = tz.datetime.date(date.year, date.month, 1)
|
|
week = date.isocalendar()[1] - first_of_month.isocalendar()[1]
|
|
|
|
# weeks of month
|
|
if week == 4:
|
|
# fifth week: return if for every week
|
|
return self.frequency == 0b1111
|
|
return (self.frequency & (0b0001 << week) > 0)
|
|
|
|
def normalize (self, date):
|
|
"""
|
|
Set the time of a datetime to the schedule's one
|
|
"""
|
|
return date.replace(hour = self.date.hour, minute = self.date.minute)
|
|
|
|
def dates_of_month (self, date = None):
|
|
"""
|
|
Return a list with all matching dates of date.month (=today)
|
|
"""
|
|
date = date_or_default(date, True).replace(day=1)
|
|
wday = self.date.weekday()
|
|
fwday = date.weekday()
|
|
|
|
# move date to the date weekday of the schedule
|
|
# check on SO#3284452 for the formula
|
|
date += tz.timedelta(days = (7 if fwday > wday else 0) - fwday + wday)
|
|
fwday = date.weekday()
|
|
|
|
# special frequency case
|
|
weeks = self.frequency
|
|
if self.frequency == Schedule.Frequency['last']:
|
|
date += tz.timedelta(month = 1, days = -7)
|
|
return self.normalize([date])
|
|
if weeks == Schedule.Frequency['one on two']:
|
|
# if both week are the same, then the date week of the month
|
|
# matches. Note: wday % 2 + fwday % 2 => (wday + fwday) % 2
|
|
fweek = date.isocalendar()[1]
|
|
week = self.date.isocalendar()[1]
|
|
weeks = 0b010101 if not (fweek + week) % 2 else 0b001010
|
|
|
|
dates = []
|
|
for week in range(0,5):
|
|
# there can be five weeks in a month
|
|
if not weeks & (0b1 << week):
|
|
continue
|
|
wdate = date + tz.timedelta(days = week * 7)
|
|
if wdate.month == date.month:
|
|
dates.append(self.normalize(wdate))
|
|
return dates
|
|
|
|
def diffusions_of_month (self, date, exclude_saved = False):
|
|
"""
|
|
Return a list of Diffusion instances, from month of the given date, that
|
|
can be not in the database.
|
|
|
|
If exclude_saved, exclude all diffusions that are yet in the database.
|
|
|
|
When a Diffusion is created, it tries to attach the corresponding
|
|
episode using a match of episode.date (and takes care of rerun case);
|
|
"""
|
|
dates = self.dates_of_month(date)
|
|
saved = Diffusion.objects.filter(date__in = dates,
|
|
program = self.parent)
|
|
diffusions = []
|
|
|
|
# existing diffusions
|
|
for item in saved:
|
|
if item.date in dates:
|
|
dates.remove(item.date)
|
|
if not exclude_saved:
|
|
diffusions.append(item)
|
|
|
|
# others
|
|
for date in dates:
|
|
first_date = date
|
|
if self.rerun:
|
|
first_date -= self.date - self.rerun.date
|
|
|
|
episode = Episode.objects.filter(date = first_date,
|
|
parent = self.parent)
|
|
episode = episode[0] if episode.count() else None
|
|
|
|
diffusions.append(Diffusion(
|
|
episode = episode,
|
|
program = self.parent,
|
|
stream = self.parent.stream,
|
|
type = Diffusion.Type['unconfirmed'],
|
|
date = date,
|
|
))
|
|
return diffusions
|
|
|
|
def __str__ (self):
|
|
frequency = [ x for x,y in Schedule.Frequency.items()
|
|
if y == self.frequency ]
|
|
return self.parent.title + ': ' + frequency[0]
|
|
|
|
class Meta:
|
|
verbose_name = _('Schedule')
|
|
verbose_name_plural = _('Schedules')
|
|
|
|
|
|
class Diffusion (models.Model):
|
|
Type = {
|
|
'default': 0x00, # simple diffusion (done/planed)
|
|
'unconfirmed': 0x01, # scheduled by the generator but not confirmed for diffusion
|
|
'cancel': 0x02, # cancellation happened; used to inform users
|
|
'restart': 0x03, # manual restart; used to remix/give up antenna
|
|
'stop': 0x04, # diffusion has been forced to stop
|
|
}
|
|
for key, value in Type.items():
|
|
ugettext_lazy(key)
|
|
|
|
episode = models.ForeignKey (
|
|
'Episode',
|
|
blank = True, null = True,
|
|
verbose_name = _('episode'),
|
|
)
|
|
program = models.ForeignKey (
|
|
'Program',
|
|
verbose_name = _('program'),
|
|
)
|
|
# program.stream can change, but not the stream;
|
|
stream = models.ForeignKey(
|
|
'Stream',
|
|
verbose_name = _('stream'),
|
|
default = 0,
|
|
help_text = 'stream id on which the diffusion happens',
|
|
)
|
|
type = models.SmallIntegerField(
|
|
verbose_name = _('type'),
|
|
choices = [ (y, x) for x,y in Type.items() ],
|
|
)
|
|
date = models.DateTimeField( _('start of the diffusion') )
|
|
|
|
def save (self, *args, **kwargs):
|
|
if self.episode: # FIXME self.episode or kwargs['episode']
|
|
self.program = self.episode.parent
|
|
# check type against stream's type
|
|
super(Diffusion, self).save(*args, **kwargs)
|
|
|
|
def __str__ (self):
|
|
return self.program.title + ' on ' + str(self.date) \
|
|
+ str(self.type)
|
|
|
|
class Meta:
|
|
verbose_name = _('Diffusion')
|
|
verbose_name_plural = _('Diffusions')
|
|
|
|
|
|
class Stream (models.Model):
|
|
Type = {
|
|
'random': 0x00, # selection using random function
|
|
'schedule': 0x01, # selection using schedule
|
|
}
|
|
for key, value in Type.items():
|
|
ugettext_lazy(key)
|
|
|
|
title = models.CharField(
|
|
_('title'),
|
|
max_length = 32,
|
|
blank = True,
|
|
null = True,
|
|
)
|
|
type = models.SmallIntegerField(
|
|
verbose_name = _('type'),
|
|
choices = [ (y, x) for x,y in Type.items() ],
|
|
)
|
|
priority = models.SmallIntegerField(
|
|
_('priority'),
|
|
default = 0,
|
|
help_text = _('priority of the stream')
|
|
)
|
|
public = models.BooleanField(
|
|
_('public'),
|
|
default = True,
|
|
help_text = _('program list is public'),
|
|
)
|
|
|
|
# get info for:
|
|
# - random lists
|
|
# - scheduled lists
|
|
# link between Streams and Programs:
|
|
# - hours range (non-stop)
|
|
# - stream/pgm
|
|
|
|
def __str__ (self):
|
|
return '#{} {}'.format(self.priority, self.title)
|
|
|
|
|
|
class Program (Publication):
|
|
parent = models.ForeignKey(
|
|
Stream,
|
|
verbose_name = _('stream'),
|
|
)
|
|
email = models.EmailField(
|
|
_('email'),
|
|
max_length = 128,
|
|
null = True, blank = True,
|
|
)
|
|
url = models.URLField(
|
|
_('website'),
|
|
blank = True, null = True,
|
|
)
|
|
active = models.BooleanField(
|
|
_('inactive'),
|
|
default = True,
|
|
help_text = _('if not set this program is no longer active')
|
|
)
|
|
|
|
@property
|
|
def path (self):
|
|
return os.path.join(settings.AIRCOX_PROGRAMS_DIR,
|
|
slugify(self.title + '_' + str(self.id)) )
|
|
|
|
def find_schedule (self, date):
|
|
"""
|
|
Return the first schedule that matches a given date
|
|
"""
|
|
schedules = Schedule.objects.filter(parent = self)
|
|
for schedule in schedules:
|
|
if schedule.match(date, check_time = False):
|
|
return schedule
|
|
|
|
|
|
class Episode (Publication):
|
|
parent = models.ForeignKey(
|
|
Program,
|
|
verbose_name = _('parent'),
|
|
help_text = _('parent program'),
|
|
)
|
|
sounds = models.ManyToManyField(
|
|
Sound,
|
|
blank = True,
|
|
verbose_name = _('sounds'),
|
|
)
|
|
|
|
class Meta:
|
|
verbose_name = _('Episode')
|
|
verbose_name_plural = _('Episodes')
|
|
|
|
|