check for conflict in diffusion; diffusion monitor, approval modes

This commit is contained in:
bkfox 2015-12-01 10:52:30 +01:00
parent 49c4939708
commit 3d50afbc4a
7 changed files with 113 additions and 303 deletions

View File

@ -1,5 +1,5 @@
""" """
Control Liquidsoap Monitor Liquidsoap's sources, logs, and even print what's on air.
""" """
import time import time
from argparse import RawTextHelpFormatter from argparse import RawTextHelpFormatter
@ -31,7 +31,6 @@ class Command (BaseCommand):
default=1000, default=1000,
help='Time to sleep in milliseconds before update on monitor' help='Time to sleep in milliseconds before update on monitor'
) )
# start and run liquidsoap
def handle (self, *args, **options): def handle (self, *args, **options):
@ -41,13 +40,16 @@ class Command (BaseCommand):
if options.get('on_air'): if options.get('on_air'):
for id, controller in self.monitor.controller.items(): for id, controller in self.monitor.controller.items():
print(id, controller.master.current_sound()) print(id, controller.on_air)
if options.get('monitor'): if options.get('monitor'):
delay = options.get('delay') / 1000 delay = options.get('delay') / 1000
while True: while True:
for controller in self.monitor.controllers.values(): for controller in self.monitor.controllers.values():
try:
controller.monitor() controller.monitor()
except Exception, e:
print(e)
time.sleep(delay) time.sleep(delay)

View File

@ -1,3 +1,5 @@
# Aircox Programs
This application defines all base models and basic control of them. We have: This application defines all base models and basic control of them. We have:
* **Nameable**: generic class used in any class needing to be named. Includes some utility functions; * **Nameable**: generic class used in any class needing to be named. Includes some utility functions;
* **Program**: the program itself; * **Program**: the program itself;
@ -8,7 +10,7 @@ This application defines all base models and basic control of them. We have:
* **Log**: logs * **Log**: logs
# Architecture ## Architecture
A Station is basically an object that represent a radio station. On each station, we use the Program object, that is declined in two different type: A Station is basically an object that represent a radio station. On each station, we use the Program object, that is declined in two different type:
* **Scheduled**: the diffusion is based on a timetable and planified through one Schedule or more; Diffusion object represent the occurrence of these programs; * **Scheduled**: the diffusion is based on a timetable and planified through one Schedule or more; Diffusion object represent the occurrence of these programs;
* **Streamed**: the diffusion is based on random playlist, used to fill gaps between the programs; * **Streamed**: the diffusion is based on random playlist, used to fill gaps between the programs;
@ -18,13 +20,13 @@ Each program has a directory in **AIRCOX_PROGRAMS_DIR**; For each, subdir:
* **excerpts**: excerpt of an episode, or other elements, can be used as a podcast * **excerpts**: excerpt of an episode, or other elements, can be used as a podcast
# manage.py's commands ## manage.py's commands
* **diffusions_monitor**: update/create, check and clean diffusions; When a diffusion is created, its type is unconfirmed, and requires a manual approval to be on the timetable. * **diffusions_monitor**: update/create, check and clean diffusions; When a diffusion is created, its type is unconfirmed, and requires a manual approval to be on the timetable.
* **sound_monitor**: check for existing and missing sounds files in programs directories and synchronize the database. Can also check for the quality of file and synchronize the database according to them. * **sound_monitor**: check for existing and missing sounds files in programs directories and synchronize the database. Can also check for the quality of file and synchronize the database according to them.
* **sound_quality_check**: check for the quality of the file (don't update database) * **sound_quality_check**: check for the quality of the file (don't update database)
# External Requirements ## Requirements
* Sox (and soxi): sound file monitor and quality check * Sox (and soxi): sound file monitor and quality check
* Requirements.txt for python's dependecies * Requirements.txt for python's dependecies

View File

@ -85,7 +85,12 @@ class DiffusionAdmin (admin.ModelAdmin):
sounds = [ str(s) for s in obj.get_archives()] sounds = [ str(s) for s in obj.get_archives()]
return ', '.join(sounds) if sounds else '' return ', '.join(sounds) if sounds else ''
list_display = ('id', 'type', 'date', 'program', 'initial', 'archives') def conflicts (self, obj):
if obj.type == Diffusion.Type['unconfirmed']:
return ', '.join([ str(d) for d in obj.get_conflicts()])
return ''
list_display = ('id', 'type', 'date', 'program', 'initial', 'archives', 'conflicts')
list_filter = ('type', 'date', 'program') list_filter = ('type', 'date', 'program')
list_editable = ('type', 'date') list_editable = ('type', 'date')

View File

@ -1,8 +1,10 @@
""" """
Manage diffusions using schedules, to update, clean up or check diffusions. Manage diffusions using schedules, to update, clean up or check diffusions.
A diffusion generated using this utility is considered has type "unconfirmed",
and is not considered as ready for diffusion; To do so, users must confirm the A generated diffusion can be unconfirmed, that means that the user must confirm
diffusion case by changing it's type to "default". it by changing its type to "normal". The behaviour is controlled using
--approval.
Different actions are available: Different actions are available:
- "update" is the process that is used to generated them using programs - "update" is the process that is used to generated them using programs
@ -22,21 +24,56 @@ from aircox_programs.models import *
class Actions: class Actions:
@staticmethod @staticmethod
def update (date): def __check_conflicts (item, saved_items):
count = 0 """
Check for conflicts, and update conflictual
items if they have been generated during this
update.
Return the number of conflicts
"""
conflicts = item.get_conflicts()
if not conflicts:
item.type = Diffusion.Type['normal']
return 0
item.type = Diffusion.Type['unconfirmed']
for conflict in conflicts:
if conflict.pk in saved_items and \
conflict.type != Diffusion.Type['unconfirmed']:
conflict.type = Diffusion.Type['unconfirmed']
conflict.save()
return len(conflicts)
@classmethod
def update (cl, date, mode):
manual = (mode == 'manual')
if not manual:
saved_items = set()
count = [0, 0]
for schedule in Schedule.objects.filter(program__active = True) \ for schedule in Schedule.objects.filter(program__active = True) \
.order_by('initial'): .order_by('initial'):
# in order to allow rerun links between diffusions, we save items # in order to allow rerun links between diffusions, we save items
# by schedule; # by schedule;
items = schedule.diffusions_of_month(date, exclude_saved = True) items = schedule.diffusions_of_month(date, exclude_saved = True)
count += len(items) count[0] += len(items)
if manual:
Diffusion.objects.bulk_create(items) Diffusion.objects.bulk_create(items)
else:
for item in items:
count[1] += cl.__check_conflicts(item, saved_items)
item.save()
saved_items.add(item)
print('> {} new diffusions for schedule #{} ({})'.format( print('> {} new diffusions for schedule #{} ({})'.format(
len(items), schedule.id, str(schedule) len(items), schedule.id, str(schedule)
)) ))
print('total of {} diffusions have been created. They need a ' print('total of {} diffusions have been created,'.format(count[0]),
'manual approval.'.format(count)) 'do not forget manual approval' if manual else
'{} conflicts found'.format(count[1]))
@staticmethod @staticmethod
def clean (date): def clean (date):
@ -87,8 +124,7 @@ class Command (BaseCommand):
'agains\'t schedules and remove it if that do not match any ' 'agains\'t schedules and remove it if that do not match any '
'schedule') 'schedule')
group = parser.add_argument_group( group = parser.add_argument_group('date')
'date')
group.add_argument( group.add_argument(
'--year', type=int, default=now.year, '--year', type=int, default=now.year,
help='used by update, default is today\'s year') help='used by update, default is today\'s year')
@ -96,6 +132,16 @@ class Command (BaseCommand):
'--month', type=int, default=now.month, '--month', type=int, default=now.month,
help='used by update, default is today\'s month') help='used by update, default is today\'s month')
group = parser.add_argument_group('mode')
group.add_argument(
'--approval', type=str, choices=['manual', 'auto'],
default='auto',
help='manual means that all generated diffusions are unconfirmed, '
'thus must be approved manually; auto confirmes all '
'diffusions except those that conflicts with others'
)
def handle (self, *args, **options): def handle (self, *args, **options):
date = tz.datetime(year = options.get('year'), date = tz.datetime(year = options.get('year'),
month = options.get('month'), month = options.get('month'),
@ -103,7 +149,7 @@ class Command (BaseCommand):
date = tz.make_aware(date) date = tz.make_aware(date)
if options.get('update'): if options.get('update'):
Actions.update(date) Actions.update(date, mode = options.get('mode'))
elif options.get('clean'): elif options.get('clean'):
Actions.clean(date) Actions.clean(date)
elif options.get('check'): elif options.get('check'):

View File

@ -1,271 +0,0 @@
import argparse
import json
from django.core.management.base import BaseCommand, CommandError
from django.utils import timezone
import aircox_programs.models as models
class Model:
# dict: key is the argument name, value is the constructor
required = {}
optional = {}
model = None
def __init__ (self, model, required = {}, optional = {}, post = None):
self.model = model
self.required = required
self.optional = optional
self.post = post
def to_string (self):
return '\n'.join(
[ ' - required: {}'.format(', '.join(self.required))
, ' - optional: {}'.format(', '.join(self.optional))
, (self.post is AddTags and ' - tags available\n') or
'\n'
])
def check_or_raise (self, options):
for req in self.required:
if req not in options:
raise CommandError('required argument ' + req + ' is missing')
def get_kargs (self, options):
kargs = {}
for i in self.required:
if options.get(i):
fn = self.required[i]
kargs[i] = fn(options[i])
for i in self.optional:
if options.get(i):
fn = self.optional[i]
kargs[i] = fn(options[i])
return kargs
def get_by_id (self, options):
id_list = options.get('id')
items = self.model.objects.filter( id__in = id_list )
if len(items) is not len(id_list):
for key, id in enumerate(id_list):
if id in items:
del id_list[key]
raise CommandError(
'the following ids has not been found: {} (no change done)'
, ', '.join(id_list)
)
return items
def make (self, options):
self.check_or_raise(options)
kargs = self.get_kargs(options)
item = self.model(**kargs)
item.save()
if self.post:
self.post(item, options)
print('{} #{} created'.format(self.model.name()
, item.id))
def update (self, options):
items = self.get_by_id(options)
for key, item in enumerate(items):
kargs = self.get_kargs(options)
item.__dict__.update(options)
item.save()
print('{} #{} updated'.format(self.model.name()
, item.id))
del items[key]
def delete (self, options):
items = self.get_by_id(options)
items.delete()
print('{} #{} deleted'.format(self.model.name()
, ', '.join(options.get('id'))
))
def dump (self, options):
qs = self.model.objects.all()
fields = ['id'] + [ f.name for f in self.model._meta.fields
if f.name is not 'id']
items = []
for item in qs:
r = []
for f in fields:
v = getattr(item, f)
if hasattr(v, 'id'):
v = v.id
r.append(v)
items.append(r)
if options.get('head'):
items = items[0:options.get('head')]
elif options.get('tail'):
items = items[-options.get('tail'):]
if options.get('fields'):
print(json.dumps(fields))
print(json.dumps(items, default = lambda x: str(x)))
return
def DateTime (string):
dt = timezone.datetime.strptime(string, '%Y-%m-%d %H:%M:%S')
return timezone.make_aware(dt, timezone.get_current_timezone())
def Time (string):
dt = timezone.datetime.strptime(string, '%H:%M')
return timezone.datetime.time(dt)
def AddTags (instance, options):
if options.get('tags'):
instance.tags.add(*options['tags'])
models = {
'program': Model( models.Program
, { 'title': str }
, { 'subtitle': str, 'can_comment': bool, 'date': DateTime
, 'parent_id': int, 'public': bool
, 'url': str, 'email': str, 'non_stop': bool
}
, AddTags
)
, 'article': Model( models.Article
, { 'title': str }
, { 'subtitle': str, 'can_comment': bool, 'date': DateTime
, 'parent_id': int, 'public': bool
, 'static_page': bool, 'focus': bool
}
, AddTags
)
, 'episode': Model( models.Episode
, { 'title': str }
, { 'subtitle': str, 'can_comment': bool, 'date': DateTime
, 'parent_id': int, 'public': bool
}
, AddTags
)
, 'schedule': Model( models.Schedule
, { 'parent_id': int, 'date': DateTime, 'duration': Time
, 'frequency': int }
, { 'rerun': int } # FIXME: redo
)
, 'sound': Model( models.Sound
, { 'parent_id': int, 'date': DateTime, 'file': str
, 'duration': Time}
, { 'fragment': bool, 'embed': str, 'removed': bool }
, AddTags
)
}
class Command (BaseCommand):
help='Create, update, delete or dump an element of the given model.' \
' If no action is given, dump it'
def add_arguments (self, parser):
parser.add_argument( 'model', type=str
, metavar="MODEL"
, help='model to add. It must be in {}'\
.format(', '.join(models.keys()))
)
group = parser.add_argument_group('actions')
group.add_argument('--dump', action='store_true')
group.add_argument('--add', action='store_true'
, help='create or update (if id is given) object')
group.add_argument('--delete', action='store_true')
group.add_argument('--json', action='store_true'
, help='dump using json')
group = parser.add_argument_group('selector')
group.add_argument('--id', type=str, nargs='+'
, metavar="ID"
, help='select existing object by id'
)
group.add_argument('--head', type=int
, help='dump the HEAD first objects only'
)
group.add_argument('--tail', type=int
, help='dump the TAIL last objects only'
)
group.add_argument('--fields', action='store_true'
, help='print fields before dumping'
)
# publication/generic
group = parser.add_argument_group('fields'
, 'depends on the given model')
group.add_argument('--parent_id', type=str)
group.add_argument('--title', type=str)
group.add_argument('--subtitle', type=str)
group.add_argument('--can_comment',action='store_true')
group.add_argument('--public', action='store_true')
group.add_argument( '--date', type=str
, help='a valid date time (Y/m/d H:m:s')
group.add_argument('--tags', type=str, nargs='+')
# program
group.add_argument('--url', type=str)
group.add_argument('--email', type=str)
group.add_argument('--non_stop', type=int)
# article
group.add_argument('--static_page',action='store_true')
group.add_argument('--focus', action='store_true')
# schedule
group.add_argument('--duration', type=str)
group.add_argument('--frequency', type=int)
group.add_argument('--rerun', type=int)
# fields
parser.formatter_class=argparse.RawDescriptionHelpFormatter
parser.epilog = 'available fields per model:'
for name, model in models.items():
parser.epilog += '\n ' + model.model.type() + ': \n' \
+ model.to_string()
def handle (self, *args, **options):
model = options.get('model')
if not model:
raise CommandError('no model has been given')
model = model.lower()
if model not in models:
raise CommandError('model {} is not supported'.format(str(model)))
if options.get('add'):
if options.get('id'):
models[model].update(options)
else:
models[model].make(options)
elif options.get('delete'):
models[model].delete(options)
else: # --dump --json
models[model].dump(options)

View File

@ -473,7 +473,6 @@ class Program (Nameable):
os.mkdir(path) os.mkdir(path)
return os.path.exists(path) return os.path.exists(path)
def find_schedule (self, date): def find_schedule (self, date):
""" """
Return the first schedule that matches a given date. Return the first schedule that matches a given date.
@ -503,7 +502,7 @@ class Diffusion (models.Model):
- stop: the diffusion has been manually stopped - stop: the diffusion has been manually stopped
""" """
Type = { Type = {
'default': 0x00, # diffusion is planified 'normal': 0x00, # diffusion is planified
'unconfirmed': 0x01, # scheduled by the generator but not confirmed for diffusion 'unconfirmed': 0x01, # scheduled by the generator but not confirmed for diffusion
'cancel': 0x02, # diffusion canceled 'cancel': 0x02, # diffusion canceled
} }
@ -534,7 +533,6 @@ class Diffusion (models.Model):
date = models.DateTimeField( _('start of the diffusion') ) date = models.DateTimeField( _('start of the diffusion') )
duration = models.TimeField( duration = models.TimeField(
_('duration'), _('duration'),
blank = True, null = True,
help_text = _('regular duration'), help_text = _('regular duration'),
) )
@ -580,6 +578,43 @@ class Diffusion (models.Model):
filter_args['program__station'] = station filter_args['program__station'] = station
return cl.objects.filter(**filter_args).order_by('-date') return cl.objects.filter(**filter_args).order_by('-date')
def get_conflicts (self):
"""
Return a list of conflictual diffusions, based on the scheduled duration.
Note: for performance reason, check next and prev are limited to a
certain amount of diffusions.
"""
r = []
# prev
qs = self.get_prev(self.program.station, self.date)
count = 0
for diff in qs:
if diff.pk == self.pk:
continue
end = diff.date + utils.to_timedelta(diff.duration)
if end > self.date:
r.append(diff)
continue
count+=1
if count > 5: break
# next
end = self.date + utils.to_timedelta(self.duration)
qs = self.get_next(self.program.station, self.date)
count = 0
for diff in qs:
if diff.pk == self.pk:
continue
if diff.date < end:
r.append(diff)
continue
count+=1
if count > 5: break
return r
def save (self, *args, **kwargs): def save (self, *args, **kwargs):
if self.initial: if self.initial:
if self.initial.initial: if self.initial.initial:
@ -600,7 +635,6 @@ class Diffusion (models.Model):
('programming', _('edit the diffusion\'s planification')), ('programming', _('edit the diffusion\'s planification')),
) )
class Log (models.Model): class Log (models.Model):
""" """
Log a played sound start and stop, or a single message Log a played sound start and stop, or a single message

View File

@ -65,11 +65,6 @@ class Programs (TestCase):
dates = [ tz.make_aware(date) for date in dates ] dates = [ tz.make_aware(date) for date in dates ]
dates.sort() dates.sort()
# match date and weeks
#for date in dates:
#self.assertTrue(schedule.match(date, check_time = False))
#self.assertTrue(schedule.match_week(date))
# dates # dates
dates_ = schedule.dates_of_month(dates[0]) dates_ = schedule.dates_of_month(dates[0])
dates_.sort() dates_.sort()
@ -82,6 +77,3 @@ class Programs (TestCase):
self.assertEqual(dates_, dates) self.assertEqual(dates_, dates)
class