From c3b5104f69642fb3ad2dfd3d1e666d2406139417 Mon Sep 17 00:00:00 2001 From: bkfox Date: Thu, 29 Oct 2015 11:01:15 +0100 Subject: [PATCH] changes --- aircox_cms/models.py | 19 ++- aircox_cms/routes.py | 3 +- aircox_cms/views.py | 15 ++- .../management/commands/diffusions_monitor.py | 10 +- .../management/commands/sounds_monitor.py | 70 ++++++++--- .../commands/sounds_quality_check.py | 47 ++++---- aircox_programs/models.py | 112 ++++++++++++------ aircox_programs/settings.py | 21 +++- aircox_programs/tests.py | 82 ++++++++++++- aircox_programs/views.py | 74 ------------ website/models.py | 4 +- website/static/website/styles.css | 2 +- website/views.py | 3 +- 13 files changed, 289 insertions(+), 173 deletions(-) diff --git a/aircox_cms/models.py b/aircox_cms/models.py index 6950089..7f7ed26 100644 --- a/aircox_cms/models.py +++ b/aircox_cms/models.py @@ -116,7 +116,7 @@ class RelatedPostBase (models.base.ModelBase): rel = attrs.get('Relation') rel = (rel and rel.__dict__) or {} - related_model = rel.get('related_model') + related_model = rel.get('model') if related_model: attrs['related'] = models.ForeignKey(related_model) @@ -140,6 +140,14 @@ class RelatedPostBase (models.base.ModelBase): class RelatedPost (Post, metaclass = RelatedPostBase): + """ + Use this post to generate Posts that are related to an external model. An + extra field "related" will be generated, and some bindings are possible to + update te related object on save if desired; + + This is done through a class name Relation inside the declaration of the new + model. + """ related = None class Meta: @@ -155,11 +163,15 @@ class RelatedPost (Post, metaclass = RelatedPostBase): It is a dict of { post_attr: rel_attr } If there is a post_attr "thread", the corresponding rel_attr is used - to update the post thread to the correct Post model. + to update the post thread to the correct Post model (in order to + establish a parent-child relation between two models) + * thread_model: generated by the metaclass that point to the + RelatedModel class related to the model that is the parent of + the current related one. """ model = None mapping = None # values to map { post_attr: rel_attr } - bind = False # update fields of related data on save + thread = None thread_model = None def get_attribute (self, attr): @@ -175,7 +187,6 @@ class RelatedPost (Post, metaclass = RelatedPostBase): related object. """ relation = self._relation - print(relation.__dict__) thread_model = relation.thread_model if not thread_model: return diff --git a/aircox_cms/routes.py b/aircox_cms/routes.py index 8825bde..521035d 100644 --- a/aircox_cms/routes.py +++ b/aircox_cms/routes.py @@ -160,8 +160,7 @@ class SearchRoute (Route): name = 'search' @classmethod - def get_queryset (cl, website, model, request, **kwargs): - q = request.GET.get('q') or '' + def get_queryset (cl, website, model, request, q, **kwargs): qs = model.objects for search_field in model.search_fields or []: r = model.objects.filter(**{ search_field + '__icontains': q }) diff --git a/aircox_cms/views.py b/aircox_cms/views.py index 8bb357d..183282c 100644 --- a/aircox_cms/views.py +++ b/aircox_cms/views.py @@ -52,12 +52,12 @@ class PostListView (PostBaseView, ListView): """ Request availables parameters """ - embed = False - exclude = None - order = 'desc' - reverse = False - fields = None - page = 1 + embed = False # view is embedded (only the list is shown) + exclude = None # exclude item of this id + order = 'desc' # order of the list when query + fields = None # fields to show + page = 1 # page number + q = None # query search def __init__ (self, query): if query: @@ -118,7 +118,7 @@ class PostListView (PostBaseView, ListView): def get_context_data (self, **kwargs): context = super().get_context_data(**kwargs) - context.update(self.get_base_context()) + context.update(self.get_base_context(**kwargs)) context.update({ 'title': self.get_title(), }) @@ -273,7 +273,6 @@ class Section (BaseSection): self.object = object or self.object if self.object_required and not self.object: raise ValueError('object is required by this Section but not given') - return super().get(request, **kwargs) diff --git a/aircox_programs/management/commands/diffusions_monitor.py b/aircox_programs/management/commands/diffusions_monitor.py index f5bbfd3..b26db03 100644 --- a/aircox_programs/management/commands/diffusions_monitor.py +++ b/aircox_programs/management/commands/diffusions_monitor.py @@ -14,10 +14,10 @@ planified before the (given) month. - "check" will remove all diffusions that are unconfirmed and have been planified from the (given) month and later. """ -from argparse import RawTextHelpFormatter -from django.core.management.base import BaseCommand, CommandError -from django.utils import timezone as tz -from aircox_programs.models import * +from argparse import RawTextHelpFormatter +from django.core.management.base import BaseCommand, CommandError +from django.utils import timezone as tz +from aircox_programs.models import * class Actions: @@ -52,7 +52,7 @@ class Actions: if schedule.match(diffusion.date): break else: - print('> #{}: {}'.format(diffusion.date, str(diffusion))) + print('> #{}: {}'.format(diffusion.pk, str(diffusion))) items.append(diffusion.id) print('{} diffusions will be removed'.format(len(items))) diff --git a/aircox_programs/management/commands/sounds_monitor.py b/aircox_programs/management/commands/sounds_monitor.py index 64b7bfd..5392f9e 100644 --- a/aircox_programs/management/commands/sounds_monitor.py +++ b/aircox_programs/management/commands/sounds_monitor.py @@ -42,22 +42,22 @@ class Command (BaseCommand): def add_arguments (self, parser): parser.formatter_class=RawTextHelpFormatter + parser.add_argument( + '-q', '--quality_check', action='store_true', + help='Enable quality check using sound_quality_check on all ' \ + 'sounds marqued as not good' + ) + parser.add_argument( + '-s', '--scan', action='store_true', + help='Scan programs directories for changes' + ) + def handle (self, *args, **options): - programs = Program.objects.filter() - - for program in programs: - path = lambda x: os.path.join(program.path, x) - self.check_files( - program, path(settings.AIRCOX_SOUND_ARCHIVES_SUBDIR), - archive = True, - ) - self.check_files( - program, path(settings.AIRCOX_SOUND_EXCERPTS_SUBDIR), - excerpt = True, - ) - - self.check_quality() + if options.get('scan'): + self.scan() + if options.get('quality_check'): + self.check_quality() def get_sound_info (self, path): """ @@ -111,12 +111,28 @@ class Command (BaseCommand): diffusion = diffusion[0] return diffusion.episode or None + def scan (self): + print('scan files for all programs...') + programs = Program.objects.filter() - def check_files (self, program, dir_path, **sound_kwargs): + for program in programs: + print('- program ', program.name) + path = lambda x: os.path.join(program.path, x) + self.scan_for_program( + program, path(settings.AIRCOX_SOUND_ARCHIVES_SUBDIR), + type = Sound.Type['archive'], + ) + self.scan_for_program( + program, path(settings.AIRCOX_SOUND_EXCERPTS_SUBDIR), + type = Sound.Type['excerpt'], + ) + + def scan_for_program (self, program, dir_path, **sound_kwargs): """ Scan a given directory that is associated to the given program, and update sounds information. """ + print(' - scan files in', dir_path) if not os.path.exists(dir_path): return @@ -154,5 +170,29 @@ class Command (BaseCommand): """ Check all files where quality has been set to bad """ + import sound_quality_check as quality_check + print('start quality check...') + files = [ sound.path + for sound in Sound.objects.filter(good_quality = False) ] + cmd = quality_check.Command() + cmd.handle( files = files, + **settings.AIRCOX_SOUND_QUALITY ) + + print('- update sounds in database') + def update_stats(sound_info, sound): + stats = sound_info.get_file_stats() + if stats: + sound.duration = int(stats.get('length')) + + for sound_info in cmd.good: + sound = Sound.objects.get(path = sound_info.path) + sound.good_quality = True + update_stats(sound_info, sound) + sound.save(check = False) + + for sound_info in cmd.bad: + sound = Sound.objects.get(path = sound_info.path) + update_stats(sound_info, sound) + sound.save(check = False) diff --git a/aircox_programs/management/commands/sounds_quality_check.py b/aircox_programs/management/commands/sounds_quality_check.py index 3bd74c4..3273c14 100644 --- a/aircox_programs/management/commands/sounds_quality_check.py +++ b/aircox_programs/management/commands/sounds_quality_check.py @@ -66,10 +66,14 @@ class Sound: def __init__ (self, path, sample_length = None): self.path = path - self.sample_length = sample_length or self.sample_length + self.sample_length = sample_length if sample_length is not None \ + else self.sample_length + + def get_file_stats (self): + return self.stats and self.stats[0] def analyse (self): - print('- Complete file analysis') + print('- complete file analysis') self.stats = [ Stats(self.path) ] position = 0 length = self.stats[0].get('length') @@ -77,7 +81,7 @@ class Sound: if not self.sample_length: return - print('- Samples analysis: ', end=' ') + print('- samples analysis: ', end=' ') while position < length: print(len(self.stats), end=' ') stats = Stats(self.path, at = position, length = self.sample_length) @@ -85,18 +89,6 @@ class Sound: position += self.sample_length print() - def resume (self): - view = lambda array: [ - 'file' if index is 0 else - 'sample {} (at {} seconds)'.format(index, (index-1) * self.sample_length) - for index in self.good - ] - - if self.good: - print('- Good:\033[92m', ', '.join( view(self.good) ), '\033[0m') - if self.bad: - print('- Bad:\033[91m', ', '.join( view(self.bad) ), '\033[0m') - def check (self, name, min_val, max_val): self.good = [ index for index, stats in enumerate(self.stats) if min_val <= stats.get(name) <= max_val ] @@ -104,6 +96,17 @@ class Sound: if index not in self.good ] self.resume() + def resume (self): + view = lambda array: [ + 'file' if index is 0 else + 'sample {} (at {} seconds)'.format(index, (index-1) * self.sample_length) + for index in array + ] + + if self.good: + print('- good:\033[92m', ', '.join( view(self.good) ), '\033[0m') + if self.bad: + print('- bad:\033[91m', ', '.join( view(self.bad) ), '\033[0m') class Command (BaseCommand): help = __doc__ @@ -117,7 +120,7 @@ class Command (BaseCommand): help='file(s) to analyse' ) parser.add_argument( - '-s', '--sample_length', type=int, + '-s', '--sample_length', type=int, default=120, help='size of sample to analyse in seconds. If not set (or 0), does' ' not analyse by sample', ) @@ -163,11 +166,11 @@ class Command (BaseCommand): # resume if options.get('resume'): - if good: - print('Files that did not failed the test:\033[92m\n ', - '\n '.join(good), '\033[0m') - if bad: + if self.good: + print('files that did not failed the test:\033[92m\n ', + '\n '.join([sound.path for sound in self.good]), '\033[0m') + if self.bad: # bad at the end for ergonomy - print('Files that failed the test:\033[91m\n ', - '\n '.join(bad),'\033[0m') + print('files that failed the test:\033[91m\n ', + '\n '.join([sound.path for sound in self.bad]),'\033[0m') diff --git a/aircox_programs/models.py b/aircox_programs/models.py index a85c81a..6fef55f 100755 --- a/aircox_programs/models.py +++ b/aircox_programs/models.py @@ -74,47 +74,60 @@ class Track (Nameable): class Sound (Nameable): """ - 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 + A Sound is the representation of a sound file that can be either an excerpt + or a complete archive of the related episode. - 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 can be associated to a filesystem's file or an embedded - code (for external podcasts). + The podcasting and public access permissions of a Sound are managed through + the related program info. """ + Type = { + 'other': 0x00, + 'archive': 0x01, + 'excerpt': 0x02, + } + for key, value in Type.items(): + ugettext_lazy(key) + + type = models.SmallIntegerField( + verbose_name = _('type'), + choices = [ (y, x) for x,y in Type.items() ], + blank = True, null = True + ) path = models.FilePathField( _('file'), path = settings.AIRCOX_PROGRAMS_DIR, - match = '*(' + '|'.join(settings.AIRCOX_SOUNDFILE_EXT) + ')$', + match = '*(' + '|'.join(settings.AIRCOX_SOUND_FILE_EXT) + ')$', recursive = True, blank = True, null = True, ) embed = models.TextField( - _('embed HTML code from external website'), + _('embed HTML code'), blank = True, null = True, - help_text = _('if set, consider the sound podcastable'), + help_text = _('HTML code used to embed a sound from external plateform'), ) duration = models.TimeField( _('duration'), blank = True, null = True, ) + date = models.DateTimeField( + _('date'), + blank = True, null = True, + help_text = _('last modification date and time'), + ) + removed = models.BooleanField( + _('removed'), + default = False, + help_text = _('this sound has been removed from filesystem'), + ) + good_quality = models.BooleanField( + _('good quality'), + default = False, + help_text = _('sound\'s quality is okay') + ) public = models.BooleanField( _('public'), default = False, - help_text = _("the element is public"), - ) - fragment = models.BooleanField( - _('incomplete sound'), - default = False, - help_text = _("the file is a cut"), - ) - removed = models.BooleanField( - default = False, - help_text = _('this sound has been removed from filesystem'), + help_text = _('sound\'s is accessible through the website') ) def get_mtime (self): @@ -125,10 +138,34 @@ class Sound (Nameable): 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() + def file_exists (self): + return os.path.exists(self.path) + def check_on_file (self): + """ + Check sound file info again'st self, and update informations if + needed. Return True if there was changes. + """ + if not self.file_exists(): + if self.removed: + return + self.removed = True + return True + + mtime = self.get_mtime() + if self.date != mtime: + self.date = mtime + self.good_quality = False + return True + + def save (self, check = True, *args, **kwargs): + if check: + self.check_on_file() + + if not self.name and self.path: + self.name = os.path.basename(self.path) \ + .splitext() \ + .replace('_', ' ') super().save(*args, **kwargs) def __str__ (self): @@ -194,12 +231,13 @@ class Schedule (models.Model): otherwise. If the schedule is ponctual, return None. """ + # FIXME: does not work if first_day > date_day 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) + first_of_month = date.replace(day = 1) week = date.isocalendar()[1] - first_of_month.isocalendar()[1] # weeks of month @@ -359,8 +397,7 @@ class Stream (models.Model): name = models.CharField( _('name'), max_length = 32, - blank = True, - null = True, + blank = True, null = True, ) public = models.BooleanField( _('public'), @@ -376,13 +413,22 @@ class Stream (models.Model): default = 0, help_text = _('priority of the stream') ) + time_start = models.TimeField( + _('start'), + blank = True, null = True, + help_text = _('if random, used to define a time range this stream is' + 'played') + ) + time_end = models.TimeField( + _('end'), + blank = True, null = True, + help_text = _('if random, used to define a time range this stream is' + 'played') + ) # 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.name) @@ -391,7 +437,7 @@ class Stream (models.Model): class Program (Nameable): stream = models.ForeignKey( Stream, - verbose_name = _('stream'), + verbose_name = _('streams'), ) active = models.BooleanField( _('inactive'), diff --git a/aircox_programs/settings.py b/aircox_programs/settings.py index 1c18882..f3fe104 100755 --- a/aircox_programs/settings.py +++ b/aircox_programs/settings.py @@ -10,15 +10,28 @@ def ensure (key, default): ensure('AIRCOX_PROGRAMS_DIR', os.path.join(settings.MEDIA_ROOT, 'programs')) -# Default directory for the sounds -ensure('AIRCOX_SOUNDFILE_DEFAULT_DIR', - os.path.join(AIRCOX_PROGRAMS_DIR + 'default')) +# Default directory for the sounds that not linked to a program +ensure('AIRCOX_SOUND_DEFAULT_DIR', + os.path.join(AIRCOX_PROGRAMS_DIR, 'defaults')) +# Sub directory used for the complete episode sounds +ensure('AIRCOX_SOUND_ARCHIVES_SUBDIR', 'archives') +# Sub directory used for the excerpts of the episode +ensure('AIRCOX_SOUND_EXCERPTS_SUBDIR', 'excerpts') + +# Quality attributes passed to sound_quality_check from sounds_monitor +ensure('AIRCOX_SOUND_QUALITY', { + 'attribute': 'RMS lev dB', + 'range': ('-18.0', '-8.0'), + 'sample_length': '120', + } +) # Extension of sound files -ensure('AIRCOX_SOUNDFILE_EXT', +ensure('AIRCOX_SOUND_FILE_EXT', ('.ogg','.flac','.wav','.mp3','.opus')) # Stream for the scheduled diffusions ensure('AIRCOX_SCHEDULED_STREAM', 0) + diff --git a/aircox_programs/tests.py b/aircox_programs/tests.py index 7ce503c..6b491ad 100755 --- a/aircox_programs/tests.py +++ b/aircox_programs/tests.py @@ -1,3 +1,81 @@ -from django.test import TestCase +import datetime + +from django.test import TestCase +from django.utils import timezone as tz + +from aircox_programs.models import * + +class Programs (TestCase): + def setUp (self): + stream = Stream.objects.get_or_create( + name = 'diffusions', + defaults = { 'type': Stream.Type['schedule'] } + )[0] + Program.objects.create(name = 'source', stream = stream) + Program.objects.create(name = 'microouvert', stream = stream) + #Stream.objects.create(name = 'bns', type = Stream.Type['random'], priority = 1) + #Stream.objects.create(name = 'jingle', type = Stream.Type['random'] priority = 2) + #Stream.objects.create(name = 'loves', type = Stream.Type['random'], priority = 3) + pass + + def test_programs_schedules (self): + program = Program.objects.get(name = 'source') + + sched_0 = self.create_schedule(program, 'one on two', [ + tz.datetime(2015, 10, 2, 18), + tz.datetime(2015, 10, 16, 18), + tz.datetime(2015, 10, 30, 18), + ] + ) + sched_1 = self.create_schedule(program, 'one on two', [ + tz.datetime(2015, 10, 5, 18), + tz.datetime(2015, 10, 19, 18), + ], + rerun = sched_0 + ) + + program = Program.objects.get(name = 'microouvert') + # special case with november first week starting on sunday + sched_2 = self.create_schedule(program, 'first and third', [ + tz.datetime(2015, 11, 6, 18), + tz.datetime(2015, 11, 20, 18), + ], + date = tz.datetime(2015, 10, 23, 18), + ) + + def create_schedule (self, program, frequency, dates, date = None, rerun = None): + frequency = Schedule.Frequency[frequency] + schedule = Schedule( + program = program, + frequency = frequency, + date = date or dates[0], + rerun = rerun, + duration = datetime.time(1, 30) + ) + print(schedule.__dict__) + schedule.save() + + self.check_schedule(schedule, dates) + return schedule + + def check_schedule (self, schedule, dates): + 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() + self.assertEqual(dates_, dates) + + # diffusions + dates_ = schedule.diffusions_of_month(dates[0]) + dates_ = [date_.date for date_ in dates_] + dates_.sort() + self.assertEqual(dates_, dates) + -# Create your tests here. diff --git a/aircox_programs/views.py b/aircox_programs/views.py index ff3f16d..7c4c5b7 100755 --- a/aircox_programs/views.py +++ b/aircox_programs/views.py @@ -12,79 +12,5 @@ import aircox_programs.settings import aircox_programs.utils -class ListQueries: - @staticmethod - def search (qs, q): - qs = qs.filter(tags__slug__in = re.compile(r'(\s|\+)+').split(q)) | \ - qs.filter(title__icontains = q) | \ - qs.filter(subtitle__icontains = q) | \ - qs.filter(content__icontains = q) - qs.distinct() - return qs - - @staticmethod - def thread (qs, q): - return qs.filter(parent = q) - - @staticmethod - def next (qs, q): - qs = qs.filter(date__gte = timezone.now()) - if q: - qs = qs.filter(parent = q) - return qs - - @staticmethod - def prev (qs, q): - qs = qs.filter(date__lte = timezone.now()) - if q: - qs = qs.filter(parent = q) - return qs - - @staticmethod - def date (qs, q): - if not q: - q = timezone.datetime.today() - if type(q) is str: - q = timezone.datetime.strptime(q, '%Y/%m/%d').date() - - return qs.filter(date__startswith = q) - - class Diffusion: - @staticmethod - def episode (qs, q): - return qs.filter(episode = q) - - @staticmethod - def program (qs, q): - return qs.filter(program = q) - -class ListQuery: - model = None - qs = None - - def __init__ (self, model, *kwargs): - self.model = model - self.__dict__.update(kwargs) - - def get_queryset (self, by, q): - qs = model.objects.all() - if model._meta.get_field_by_name('public'): - qs = qs.filter(public = True) - - # run query set - queries = Queries.__dict__.get(self.model) or Queries - filter = queries.__dict__.get(by) - if filter: - qs = filter(qs, q) - - # order - if self.sort == 'asc': - qs = qs.order_by('date', 'id') - else: - qs = qs.order_by('-date', '-id') - - # exclude - qs = qs.exclude(id = exclude) - return qs diff --git a/website/models.py b/website/models.py index 5107b70..15d033e 100644 --- a/website/models.py +++ b/website/models.py @@ -5,7 +5,7 @@ import aircox_programs.models as programs class Program (RelatedPost): class Relation: - related_model = programs.Program + model = programs.Program bind_mapping = True mapping = { 'title': 'name', @@ -14,7 +14,7 @@ class Program (RelatedPost): class Episode (RelatedPost): class Relation: - related_model = programs.Episode + model = programs.Episode bind_mapping = True mapping = { 'thread': 'program', diff --git a/website/static/website/styles.css b/website/static/website/styles.css index 015545c..594c8b5 100644 --- a/website/static/website/styles.css +++ b/website/static/website/styles.css @@ -7,7 +7,7 @@ body { h1, h2, h3 { - font-family: "Myriad Pro",Calibri,Helvetica,Arial,sans-serif + font-family: "Myriad Pro",Calibri,Helvetica,Arial,sans-serif; } time { diff --git a/website/views.py b/website/views.py index 96dfa40..edd399e 100644 --- a/website/views.py +++ b/website/views.py @@ -68,6 +68,7 @@ class PreviousDiffusions (Sections.Posts): episodes.append(post) if len(episodes) == self.paginate_by: break - return episodes + +