From 3d50afbc4a8a112f7fc3a14fb177d1d2d5f4ce18 Mon Sep 17 00:00:00 2001 From: bkfox Date: Tue, 1 Dec 2015 10:52:30 +0100 Subject: [PATCH] check for conflict in diffusion; diffusion monitor, approval modes --- .../management/commands/liquidsoap.py | 10 +- aircox_programs/README.md | 8 +- aircox_programs/admin.py | 7 +- .../management/commands/diffusions_monitor.py | 70 ++++- .../management/commands/programs.py | 271 ------------------ aircox_programs/models.py | 42 ++- aircox_programs/tests.py | 8 - 7 files changed, 113 insertions(+), 303 deletions(-) delete mode 100644 aircox_programs/management/commands/programs.py diff --git a/aircox_liquidsoap/management/commands/liquidsoap.py b/aircox_liquidsoap/management/commands/liquidsoap.py index 19dba2a..09e1891 100644 --- a/aircox_liquidsoap/management/commands/liquidsoap.py +++ b/aircox_liquidsoap/management/commands/liquidsoap.py @@ -1,5 +1,5 @@ """ -Control Liquidsoap +Monitor Liquidsoap's sources, logs, and even print what's on air. """ import time from argparse import RawTextHelpFormatter @@ -31,7 +31,6 @@ class Command (BaseCommand): default=1000, help='Time to sleep in milliseconds before update on monitor' ) - # start and run liquidsoap def handle (self, *args, **options): @@ -41,13 +40,16 @@ class Command (BaseCommand): if options.get('on_air'): for id, controller in self.monitor.controller.items(): - print(id, controller.master.current_sound()) + print(id, controller.on_air) if options.get('monitor'): delay = options.get('delay') / 1000 while True: for controller in self.monitor.controllers.values(): - controller.monitor() + try: + controller.monitor() + except Exception, e: + print(e) time.sleep(delay) diff --git a/aircox_programs/README.md b/aircox_programs/README.md index c4f3043..4d36a9d 100644 --- a/aircox_programs/README.md +++ b/aircox_programs/README.md @@ -1,3 +1,5 @@ +# Aircox Programs + 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; * **Program**: the program itself; @@ -8,7 +10,7 @@ This application defines all base models and basic control of them. We have: * **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: * **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; @@ -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 -# 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. * **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) -# External Requirements +## Requirements * Sox (and soxi): sound file monitor and quality check * Requirements.txt for python's dependecies diff --git a/aircox_programs/admin.py b/aircox_programs/admin.py index 44fab03..914c78d 100755 --- a/aircox_programs/admin.py +++ b/aircox_programs/admin.py @@ -85,7 +85,12 @@ class DiffusionAdmin (admin.ModelAdmin): sounds = [ str(s) for s in obj.get_archives()] 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_editable = ('type', 'date') diff --git a/aircox_programs/management/commands/diffusions_monitor.py b/aircox_programs/management/commands/diffusions_monitor.py index 446531a..4c2ad1a 100644 --- a/aircox_programs/management/commands/diffusions_monitor.py +++ b/aircox_programs/management/commands/diffusions_monitor.py @@ -1,8 +1,10 @@ """ 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 -diffusion case by changing it's type to "default". + +A generated diffusion can be unconfirmed, that means that the user must confirm +it by changing its type to "normal". The behaviour is controlled using +--approval. + Different actions are available: - "update" is the process that is used to generated them using programs @@ -22,21 +24,56 @@ from aircox_programs.models import * class Actions: @staticmethod - def update (date): - count = 0 + def __check_conflicts (item, saved_items): + """ + 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) \ .order_by('initial'): # in order to allow rerun links between diffusions, we save items # by schedule; items = schedule.diffusions_of_month(date, exclude_saved = True) - count += len(items) - Diffusion.objects.bulk_create(items) + count[0] += len(items) + + if manual: + 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( len(items), schedule.id, str(schedule) )) - print('total of {} diffusions have been created. They need a ' - 'manual approval.'.format(count)) + print('total of {} diffusions have been created,'.format(count[0]), + 'do not forget manual approval' if manual else + '{} conflicts found'.format(count[1])) @staticmethod def clean (date): @@ -87,8 +124,7 @@ class Command (BaseCommand): 'agains\'t schedules and remove it if that do not match any ' 'schedule') - group = parser.add_argument_group( - 'date') + group = parser.add_argument_group('date') group.add_argument( '--year', type=int, default=now.year, help='used by update, default is today\'s year') @@ -96,6 +132,16 @@ class Command (BaseCommand): '--month', type=int, default=now.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): date = tz.datetime(year = options.get('year'), month = options.get('month'), @@ -103,7 +149,7 @@ class Command (BaseCommand): date = tz.make_aware(date) if options.get('update'): - Actions.update(date) + Actions.update(date, mode = options.get('mode')) elif options.get('clean'): Actions.clean(date) elif options.get('check'): diff --git a/aircox_programs/management/commands/programs.py b/aircox_programs/management/commands/programs.py deleted file mode 100644 index 21b4d65..0000000 --- a/aircox_programs/management/commands/programs.py +++ /dev/null @@ -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) - - diff --git a/aircox_programs/models.py b/aircox_programs/models.py index 137c23c..cdec463 100755 --- a/aircox_programs/models.py +++ b/aircox_programs/models.py @@ -473,7 +473,6 @@ class Program (Nameable): os.mkdir(path) return os.path.exists(path) - def find_schedule (self, 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 """ Type = { - 'default': 0x00, # diffusion is planified + 'normal': 0x00, # diffusion is planified 'unconfirmed': 0x01, # scheduled by the generator but not confirmed for diffusion 'cancel': 0x02, # diffusion canceled } @@ -534,7 +533,6 @@ class Diffusion (models.Model): date = models.DateTimeField( _('start of the diffusion') ) duration = models.TimeField( _('duration'), - blank = True, null = True, help_text = _('regular duration'), ) @@ -580,6 +578,43 @@ class Diffusion (models.Model): filter_args['program__station'] = station 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): if self.initial: if self.initial.initial: @@ -600,7 +635,6 @@ class Diffusion (models.Model): ('programming', _('edit the diffusion\'s planification')), ) - class Log (models.Model): """ Log a played sound start and stop, or a single message diff --git a/aircox_programs/tests.py b/aircox_programs/tests.py index 18b8f92..6cbf74f 100755 --- a/aircox_programs/tests.py +++ b/aircox_programs/tests.py @@ -65,11 +65,6 @@ class Programs (TestCase): dates = [ tz.make_aware(date) for date in dates ] 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_ = schedule.dates_of_month(dates[0]) dates_.sort() @@ -82,6 +77,3 @@ class Programs (TestCase): self.assertEqual(dates_, dates) -class - -