From 74dbc620eddc94be343d6022acba8cea01c1012d Mon Sep 17 00:00:00 2001 From: bkfox Date: Sat, 29 Jun 2019 18:13:25 +0200 Subject: [PATCH] work hard on this --- aircox/admin.py | 208 ----- aircox/admin/__init__.py | 5 + aircox/admin/base.py | 94 +++ aircox/admin/diffusion.py | 81 ++ aircox/admin/mixins.py | 42 + aircox/admin/playlist.py | 41 + aircox/admin/sound.py | 24 + aircox/management/commands/import_playlist.py | 91 ++- aircox/management/commands/sounds_monitor.py | 93 +-- aircox/management/commands/streamer.py | 44 +- aircox/models.py | 748 ++++++++---------- .../admin/aircox/playlist_inline.html | 6 + aircox/templates/aircox/config/liquidsoap.liq | 3 +- aircox/views.py | 88 +-- aircox_web/__init__.py | 0 aircox_web/admin.py | 68 ++ aircox_web/apps.py | 5 + aircox_web/assets/index.js | 1 + aircox_web/assets/js/index.js | 12 + aircox_web/models.py | 117 +++ aircox_web/package.json | 26 + aircox_web/renderer.py | 20 + aircox_web/templates/aircox_web/base.html | 34 + aircox_web/templates/aircox_web/page.html | 8 + aircox_web/tests.py | 3 + aircox_web/urls.py | 9 + aircox_web/views.py | 22 + aircox_web/webpack.config.js | 87 ++ instance/sample_settings.py | 1 - instance/urls.py | 19 +- requirements.txt | 24 +- 31 files changed, 1191 insertions(+), 833 deletions(-) delete mode 100755 aircox/admin.py create mode 100644 aircox/admin/__init__.py create mode 100644 aircox/admin/base.py create mode 100644 aircox/admin/diffusion.py create mode 100644 aircox/admin/mixins.py create mode 100644 aircox/admin/playlist.py create mode 100644 aircox/admin/sound.py create mode 100644 aircox/templates/admin/aircox/playlist_inline.html create mode 100644 aircox_web/__init__.py create mode 100644 aircox_web/admin.py create mode 100644 aircox_web/apps.py create mode 100644 aircox_web/assets/index.js create mode 100644 aircox_web/assets/js/index.js create mode 100644 aircox_web/models.py create mode 100644 aircox_web/package.json create mode 100644 aircox_web/renderer.py create mode 100644 aircox_web/templates/aircox_web/base.html create mode 100644 aircox_web/templates/aircox_web/page.html create mode 100644 aircox_web/tests.py create mode 100644 aircox_web/urls.py create mode 100644 aircox_web/views.py create mode 100644 aircox_web/webpack.config.js diff --git a/aircox/admin.py b/aircox/admin.py deleted file mode 100755 index 9aefdd8..0000000 --- a/aircox/admin.py +++ /dev/null @@ -1,208 +0,0 @@ -import copy - -from django import forms -from django.contrib import admin -from django.contrib.contenttypes.admin import GenericTabularInline -from django.db import models -from django.utils.translation import ugettext as _, ugettext_lazy - -from aircox.models import * - -# -# Inlines -# -class SoundInline(admin.TabularInline): - model = Sound - - -class ScheduleInline(admin.TabularInline): - model = Schedule - extra = 1 - -class StreamInline(admin.TabularInline): - fields = ['delay', 'begin', 'end'] - model = Stream - extra = 1 - -class SoundInline(admin.TabularInline): - fields = ['type', 'path', 'duration','public'] - # readonly_fields = fields - model = Sound - extra = 0 - - -class DiffusionInline(admin.StackedInline): - model = Diffusion - extra = 0 - fields = ['type', 'start', 'end'] - -class NameableAdmin(admin.ModelAdmin): - fields = [ 'name' ] - - list_display = ['id', 'name'] - list_filter = [] - search_fields = ['name',] - - -class TrackInline(GenericTabularInline): - ct_field = 'related_type' - ct_fk_field = 'related_id' - model = Track - extra = 0 - fields = ('artist', 'title', 'info', 'position', 'in_seconds', 'tags') - - list_display = ['artist','title','tags','related'] - list_filter = ['artist','title','tags'] - - -@admin.register(Sound) -class SoundAdmin(NameableAdmin): - fields = None - list_display = ['id', 'name', 'program', 'type', 'duration', 'mtime', - 'public', 'good_quality', 'path'] - list_filter = ('program', 'type', 'good_quality', 'public') - fieldsets = [ - (None, { 'fields': NameableAdmin.fields + - ['path', 'type', 'program', 'diffusion'] } ), - (None, { 'fields': ['embed', 'duration', 'public', 'mtime'] }), - (None, { 'fields': ['good_quality' ] } ) - ] - readonly_fields = ('path', 'duration',) - inlines = [TrackInline] - - -@admin.register(Stream) -class StreamAdmin(admin.ModelAdmin): - list_display = ('id', 'program', 'delay', 'begin', 'end') - - -@admin.register(Program) -class ProgramAdmin(NameableAdmin): - def schedule(self, obj): - return Schedule.objects.filter(program = obj).count() > 0 - schedule.boolean = True - schedule.short_description = _("Schedule") - - list_display = ('id', 'name', 'active', 'schedule', 'sync', 'station') - fields = NameableAdmin.fields + [ 'active', 'station','sync' ] - inlines = [ ScheduleInline, StreamInline ] - - # SO#8074161 - #def get_form(self, request, obj=None, **kwargs): - #if obj: - # if Schedule.objects.filter(program = obj).count(): - # self.inlines.remove(StreamInline) - # elif Stream.objects.filter(program = obj).count(): - # self.inlines.remove(ScheduleInline) - #return super().get_form(request, obj, **kwargs) - - -@admin.register(Diffusion) -class DiffusionAdmin(admin.ModelAdmin): - def archives(self, obj): - sounds = [ str(s) for s in obj.get_sounds(archive=True)] - return ', '.join(sounds) if sounds else '' - - def conflicts_(self, obj): - if obj.conflicts.count(): - return obj.conflicts.count() - return '' - - def start_date(self, obj): - return obj.local_date.strftime('%Y/%m/%d %H:%M') - start_date.short_description = _('start') - - def end_date(self, obj): - return obj.local_end.strftime('%H:%M') - end_date.short_description = _('end') - - def first(self, obj): - return obj.initial.start if obj.initial else '' - - list_display = ('id', 'program', 'start_date', 'end_date', 'type', 'first', 'archives', 'conflicts_') - list_filter = ('type', 'start', 'program') - list_editable = ('type',) - ordering = ('-start', 'id') - - fields = ['type', 'start', 'end', 'initial', 'program', 'conflicts'] - readonly_fields = ('conflicts',) - inlines = [ DiffusionInline, SoundInline ] - - - def get_form(self, request, obj=None, **kwargs): - if request.user.has_perm('aircox_program.programming'): - self.readonly_fields = [] - else: - self.readonly_fields = ['program', 'start', 'end'] - return super().get_form(request, obj, **kwargs) - - def get_object(self, *args, **kwargs): - """ - We want rerun to redirect to the given object. - """ - obj = super().get_object(*args, **kwargs) - if obj and obj.initial: - obj = obj.initial - return obj - - def get_queryset(self, request): - qs = super().get_queryset(request) - if request.GET and len(request.GET): - return qs - return qs.exclude(type = Diffusion.Type.unconfirmed) - - -@admin.register(Schedule) -class ScheduleAdmin(admin.ModelAdmin): - def program_name(self, obj): - return obj.program.name - program_name.short_description = _('Program') - - def day(self, obj): - return '' # obj.date.strftime('%A') - day.short_description = _('Day') - - def rerun(self, obj): - return obj.initial != None - rerun.short_description = _('Rerun') - rerun.boolean = True - - - list_filter = ['frequency', 'program'] - list_display = ['id', 'program_name', 'frequency', 'day', 'date', - 'time', 'duration', 'timezone', 'rerun'] - list_editable = ['time', 'timezone', 'duration'] - - - def get_readonly_fields(self, request, obj=None): - if obj: - return ['program', 'date', 'frequency'] - else: - return [] - - -@admin.register(Track) -class TrackAdmin(admin.ModelAdmin): - list_display = ['id', 'title', 'artist', 'position', 'in_seconds', 'related'] - - - -# TODO: sort & redo -class PortInline(admin.StackedInline): - model = Port - extra = 0 - -@admin.register(Station) -class StationAdmin(admin.ModelAdmin): - inlines = [ PortInline ] - -@admin.register(Log) -class LogAdmin(admin.ModelAdmin): - list_display = ['id', 'date', 'station', 'source', 'type', 'comment', 'diffusion', 'sound', 'track'] - list_filter = ['date', 'source', 'diffusion', 'sound', 'track'] - -admin.site.register(Port) - - - - diff --git a/aircox/admin/__init__.py b/aircox/admin/__init__.py new file mode 100644 index 0000000..4a0752a --- /dev/null +++ b/aircox/admin/__init__.py @@ -0,0 +1,5 @@ +from .base import * +from .diffusion import DiffusionAdmin +# from .playlist import PlaylistAdmin +from .sound import SoundAdmin + diff --git a/aircox/admin/base.py b/aircox/admin/base.py new file mode 100644 index 0000000..56c7577 --- /dev/null +++ b/aircox/admin/base.py @@ -0,0 +1,94 @@ +from django import forms +from django.contrib import admin +from django.urls import reverse +from django.utils.translation import ugettext as _, ugettext_lazy +from django.utils.safestring import mark_safe + +from adminsortable2.admin import SortableInlineAdminMixin + +from aircox.models import * + + +class ScheduleInline(admin.TabularInline): + model = Schedule + extra = 1 + +class StreamInline(admin.TabularInline): + fields = ['delay', 'begin', 'end'] + model = Stream + extra = 1 + +class NameableAdmin(admin.ModelAdmin): + fields = [ 'name' ] + + list_display = ['id', 'name'] + list_filter = [] + search_fields = ['name',] + + +@admin.register(Stream) +class StreamAdmin(admin.ModelAdmin): + list_display = ('id', 'program', 'delay', 'begin', 'end') + + +@admin.register(Program) +class ProgramAdmin(NameableAdmin): + def schedule(self, obj): + return Schedule.objects.filter(program = obj).count() > 0 + schedule.boolean = True + schedule.short_description = _("Schedule") + + list_display = ('id', 'name', 'active', 'schedule', 'sync', 'station') + fields = NameableAdmin.fields + [ 'active', 'station','sync' ] + inlines = [ ScheduleInline, StreamInline ] + + +@admin.register(Schedule) +class ScheduleAdmin(admin.ModelAdmin): + def program_name(self, obj): + return obj.program.name + program_name.short_description = _('Program') + + def day(self, obj): + return '' # obj.date.strftime('%A') + day.short_description = _('Day') + + def rerun(self, obj): + return obj.initial is not None + rerun.short_description = _('Rerun') + rerun.boolean = True + + + list_filter = ['frequency', 'program'] + list_display = ['id', 'program_name', 'frequency', 'day', 'date', + 'time', 'duration', 'timezone', 'rerun'] + list_editable = ['time', 'timezone', 'duration'] + + + def get_readonly_fields(self, request, obj=None): + if obj: + return ['program', 'date', 'frequency'] + else: + return [] + +# TODO: sort & redo +class PortInline(admin.StackedInline): + model = Port + extra = 0 + + +@admin.register(Station) +class StationAdmin(admin.ModelAdmin): + inlines = [PortInline] + + +@admin.register(Log) +class LogAdmin(admin.ModelAdmin): + list_display = ['id', 'date', 'station', 'source', 'type', 'comment', 'diffusion', 'sound', 'track'] + list_filter = ['date', 'source', 'diffusion', 'sound', 'track'] + +admin.site.register(Port) + + + + diff --git a/aircox/admin/diffusion.py b/aircox/admin/diffusion.py new file mode 100644 index 0000000..1ed5d59 --- /dev/null +++ b/aircox/admin/diffusion.py @@ -0,0 +1,81 @@ +from django.contrib import admin +from django.utils.translation import ugettext as _, ugettext_lazy + +from aircox.models import Diffusion, Sound, Track + +from .playlist import TracksInline + + +class SoundInline(admin.TabularInline): + model = Sound + fk_name = 'diffusion' + fields = ['type', 'path', 'duration','public'] + readonly_fields = ['type'] + extra = 0 + + +class RediffusionInline(admin.StackedInline): + model = Diffusion + fk_name = 'initial' + extra = 0 + fields = ['type', 'start', 'end'] + + +@admin.register(Diffusion) +class DiffusionAdmin(admin.ModelAdmin): + def archives(self, obj): + sounds = [str(s) for s in obj.get_sounds(archive=True)] + return ', '.join(sounds) if sounds else '' + + def conflicts_count(self, obj): + if obj.conflicts.count(): + return obj.conflicts.count() + return '' + conflicts_count.short_description = _('Conflicts') + + def start_date(self, obj): + return obj.local_date.strftime('%Y/%m/%d %H:%M') + start_date.short_description = _('start') + + def end_date(self, obj): + return obj.local_end.strftime('%H:%M') + end_date.short_description = _('end') + + def first(self, obj): + return obj.initial.start if obj.initial else '' + + list_display = ('id', 'program', 'start_date', 'end_date', 'type', 'first', 'archives', 'conflicts_count') + list_filter = ('type', 'start', 'program') + list_editable = ('type',) + ordering = ('-start', 'id') + + fields = ['type', 'start', 'end', 'initial', 'program', 'conflicts'] + readonly_fields = ('conflicts',) + inlines = [TracksInline, RediffusionInline, SoundInline] + + def get_playlist(self, request, obj=None): + return obj and getattr(obj, 'playlist', None) + + def get_form(self, request, obj=None, **kwargs): + if request.user.has_perm('aircox_program.programming'): + self.readonly_fields = [] + else: + self.readonly_fields = ['program', 'start', 'end'] + return super().get_form(request, obj, **kwargs) + + def get_object(self, *args, **kwargs): + """ + We want rerun to redirect to the given object. + """ + obj = super().get_object(*args, **kwargs) + if obj and obj.initial: + obj = obj.initial + return obj + + def get_queryset(self, request): + qs = super().get_queryset(request) + if request.GET and len(request.GET): + return qs + return qs.exclude(type=Diffusion.Type.unconfirmed) + + diff --git a/aircox/admin/mixins.py b/aircox/admin/mixins.py new file mode 100644 index 0000000..3d2ed6a --- /dev/null +++ b/aircox/admin/mixins.py @@ -0,0 +1,42 @@ +class UnrelatedInlineMixin: + """ + Inline class that can be included in an admin change view whose model + is not directly related to inline's model. + """ + view_model = None + parent_model = None + parent_fk = '' + + def __init__(self, parent_model, admin_site): + self.view_model = parent_model + super().__init__(self.parent_model, admin_site) + + def get_parent(self, view_obj): + """ Get formset's instance from `obj` of AdminSite's change form. """ + field = self.parent_model._meta.get_field(self.parent_fk).remote_field + return getattr(view_obj, field.name, None) + + def save_parent(self, parent, view_obj): + """ Save formset's instance. """ + setattr(parent, self.parent_fk, view_obj) + parent.save() + return parent + + def get_formset(self, request, obj): + ParentFormSet = super().get_formset(request, obj) + inline = self + class FormSet(ParentFormSet): + view_obj = None + + def __init__(self, *args, instance=None, **kwargs): + self.view_obj = instance + instance = inline.get_parent(instance) + self.instance = instance + super().__init__(*args, instance=instance, **kwargs) + + def save(self): + inline.save_parent(self.instance, self.view_obj) + return super().save() + return FormSet + + diff --git a/aircox/admin/playlist.py b/aircox/admin/playlist.py new file mode 100644 index 0000000..d3c6ca1 --- /dev/null +++ b/aircox/admin/playlist.py @@ -0,0 +1,41 @@ +from django.contrib import admin +from django.utils.translation import ugettext as _, ugettext_lazy + +from adminsortable2.admin import SortableInlineAdminMixin + +from aircox.models import Track + + +class TracksInline(SortableInlineAdminMixin, admin.TabularInline): + template = 'admin/aircox/playlist_inline.html' + model = Track + extra = 0 + fields = ('position', 'artist', 'title', 'info', 'timestamp', 'tags') + + list_display = ['artist', 'title', 'tags', 'related'] + list_filter = ['artist', 'title', 'tags'] + + +@admin.register(Track) +class TrackAdmin(admin.ModelAdmin): + # TODO: url to filter by tag + def tag_list(self, obj): + return u", ".join(o.name for o in obj.tags.all()) + + list_display = ['pk', 'artist', 'title', 'tag_list', 'diffusion', 'sound'] + list_editable = ['artist', 'title'] + list_filter = ['sound', 'diffusion', 'artist', 'title', 'tags'] + fieldsets = [ + (_('Playlist'), {'fields': ['diffusion', 'sound', 'position', 'timestamp']}), + (_('Info'), {'fields': ['artist', 'title', 'info', 'tags']}), + ] + + # TODO on edit: readonly_fields = ['diffusion', 'sound'] + +#@admin.register(Playlist) +#class PlaylistAdmin(admin.ModelAdmin): +# fields = ['diffusion', 'sound'] +# inlines = [TracksInline] +# # TODO: dynamic read only fields + + diff --git a/aircox/admin/sound.py b/aircox/admin/sound.py new file mode 100644 index 0000000..06e1a9f --- /dev/null +++ b/aircox/admin/sound.py @@ -0,0 +1,24 @@ +from django.contrib import admin +from django.utils.translation import ugettext as _, ugettext_lazy + +from aircox.models import Sound +from .base import NameableAdmin +from .playlist import TracksInline + + +@admin.register(Sound) +class SoundAdmin(NameableAdmin): + fields = None + list_display = ['id', 'name', 'program', 'type', 'duration', 'mtime', + 'public', 'good_quality', 'path'] + list_filter = ('program', 'type', 'good_quality', 'public') + fieldsets = [ + (None, {'fields': NameableAdmin.fields + + ['path', 'type', 'program', 'diffusion']}), + (None, {'fields': ['embed', 'duration', 'public', 'mtime']}), + (None, {'fields': ['good_quality']}) + ] + readonly_fields = ('path', 'duration',) + inlines = [TracksInline] + + diff --git a/aircox/management/commands/import_playlist.py b/aircox/management/commands/import_playlist.py index 5870455..af0cd65 100755 --- a/aircox/management/commands/import_playlist.py +++ b/aircox/management/commands/import_playlist.py @@ -29,62 +29,65 @@ class Importer: path = None data = None tracks = None + track_kwargs = {} - def __init__(self, related = None, path = None, save = False): - if path: - self.read(path) - if related: - self.make_playlist(related, save) + def __init__(self, path=None, **track_kwargs): + self.path = path + self.track_kwargs = track_kwargs def reset(self): self.data = None self.tracks = None - def read(self, path): - if not os.path.exists(path): + def run(self): + self.read() + if self.track_kwargs.get('sound') is not None: + self.make_playlist() + + def read(self): + if not os.path.exists(self.path): return True - with open(path, 'r') as file: - logger.info('start reading csv ' + path) - self.path = path + with open(self.path, 'r') as file: + logger.info('start reading csv ' + self.path) self.data = list(csv.DictReader( (row for row in file if not (row.startswith('#') or row.startswith('\ufeff#')) - and row.strip() - ), - fieldnames = settings.AIRCOX_IMPORT_PLAYLIST_CSV_COLS, - delimiter = settings.AIRCOX_IMPORT_PLAYLIST_CSV_DELIMITER, - quotechar = settings.AIRCOX_IMPORT_PLAYLIST_CSV_TEXT_QUOTE, + and row.strip()), + fieldnames=settings.AIRCOX_IMPORT_PLAYLIST_CSV_COLS, + delimiter=settings.AIRCOX_IMPORT_PLAYLIST_CSV_DELIMITER, + quotechar=settings.AIRCOX_IMPORT_PLAYLIST_CSV_TEXT_QUOTE, )) - def make_playlist(self, related, save = False): + def make_playlist(self): """ Make a playlist from the read data, and return it. If save is true, save it into the database """ + if self.track_kwargs.get('sound') is None: + logger.error('related track\'s sound is missing. Skip import of ' + + self.path + '.') + return + maps = settings.AIRCOX_IMPORT_PLAYLIST_CSV_COLS tracks = [] logger.info('parse csv file ' + self.path) - in_seconds = ('minutes' or 'seconds') in maps + has_timestamp = ('minutes' or 'seconds') in maps for index, line in enumerate(self.data): if ('title' or 'artist') not in line: return - try: - position = \ - int(line.get('minutes') or 0) * 60 + \ - int(line.get('seconds') or 0) \ - if in_seconds else index + timestamp = int(line.get('minutes') or 0) * 60 + \ + int(line.get('seconds') or 0) \ + if has_timestamp else None track, created = Track.objects.get_or_create( - related_type = ContentType.objects.get_for_model(related), - related_id = related.pk, - title = line.get('title'), - artist = line.get('artist'), - position = position, + title=line.get('title'), + artist=line.get('artist'), + position=index, + **self.track_kwargs ) - - track.in_seconds = in_seconds + track.timestamp = timestamp track.info = line.get('info') tags = line.get('tags') if tags: @@ -97,8 +100,7 @@ class Importer: ) continue - if save: - track.save() + track.save() tracks.append(track) self.tracks = tracks return tracks @@ -107,10 +109,8 @@ class Importer: class Command (BaseCommand): help= __doc__ - def add_arguments (self, parser): + def add_arguments(self, parser): parser.formatter_class=RawTextHelpFormatter - now = tz.datetime.today() - parser.add_argument( 'path', metavar='PATH', type=str, help='path of the input playlist to read' @@ -128,27 +128,24 @@ class Command (BaseCommand): def handle (self, path, *args, **options): # FIXME: absolute/relative path of sounds vs given path if options.get('sound'): - related = Sound.objects.filter( - path__icontains = options.get('sound') + sound = Sound.objects.filter( + path__icontains=options.get('sound') ).first() else: path_, ext = os.path.splitext(path) - related = Sound.objects.filter(path__icontains = path_).first() + sound = Sound.objects.filter(path__icontains=path_).first() - if not related: + if not sound: logger.error('no sound found in the database for the path ' \ '{path}'.format(path=path)) return - if options.get('diffusion') and related.diffusion: - related = related.diffusion + if options.get('diffusion') and sound.diffusion: + sound = sound.diffusion - importer = Importer(related = related, path = path, save = True) + importer = Importer(path, sound=sound).run() for track in importer.tracks: - logger.info('imported track at {pos}: {title}, by ' - '{artist}'.format( - pos = track.position, - title = track.title, artist = track.artist - ) - ) + logger.info('track #{pos} imported: {title}, by {artist}'.format( + pos=track.position, title=track.title, artist=track.artist + )) diff --git a/aircox/management/commands/sounds_monitor.py b/aircox/management/commands/sounds_monitor.py index 8bbe58c..6f4704e 100755 --- a/aircox/management/commands/sounds_monitor.py +++ b/aircox/management/commands/sounds_monitor.py @@ -43,6 +43,7 @@ import aircox.utils as utils logger = logging.getLogger('aircox.tools') + class SoundInfo: name = '' sound = None @@ -76,7 +77,7 @@ class SoundInfo: file_name) if not (r and r.groupdict()): - r = { 'name': file_name } + r = {'name': file_name} logger.info('file name can not be parsed -> %s', value) else: r = r.groupdict() @@ -93,7 +94,7 @@ class SoundInfo: self.n = r.get('n') return r - def __init__(self, path = '', sound = None): + def __init__(self, path='', sound=None): self.path = path self.sound = sound @@ -107,7 +108,7 @@ class SoundInfo: self.duration = duration return duration - def get_sound(self, save = True, **kwargs): + def get_sound(self, save=True, **kwargs): """ Get or create a sound using self info. @@ -115,8 +116,8 @@ class SoundInfo: (if save is True, sync to DB), and check for a playlist file. """ sound, created = Sound.objects.get_or_create( - path = self.path, - defaults = kwargs + path=self.path, + defaults=kwargs ) if created or sound.check_on_file(): logger.info('sound is new or have been modified -> %s', self.path) @@ -127,7 +128,7 @@ class SoundInfo: self.sound = sound return sound - def find_playlist(self, sound, use_default = True): + def find_playlist(self, sound, use_default=True): """ Find a playlist file corresponding to the sound path, such as: my_sound.ogg => my_sound.csv @@ -135,11 +136,11 @@ class SoundInfo: If use_default is True and there is no playlist find found, use sound file's metadata. """ - if sound.tracks.count(): + if sound.track_set.count(): return import aircox.management.commands.import_playlist \ - as import_playlist + as import_playlist # no playlist, try to retrieve metadata path = os.path.splitext(self.sound.path)[0] + '.csv' @@ -151,9 +152,9 @@ class SoundInfo: return # else, import - import_playlist.Importer(sound, path, save=True) + import_playlist.Importer(path, sound=sound).run() - def find_diffusion(self, program, save = True): + def find_diffusion(self, program, save=True): """ For a given program, check if there is an initial diffusion to associate to, using the date info we have. Update self.sound @@ -163,7 +164,7 @@ class SoundInfo: rerun. """ if self.year == None or not self.sound or self.sound.diffusion: - return; + return if self.hour is None: date = datetime.date(self.year, self.month, self.day) @@ -173,7 +174,7 @@ class SoundInfo: date = tz.get_current_timezone().localize(date) qs = Diffusion.objects.station(program.station).after(date) \ - .filter(program = program, initial__isnull = True) + .filter(program=program, initial__isnull=True) diffusion = qs.first() if not diffusion: return @@ -190,18 +191,19 @@ class MonitorHandler(PatternMatchingEventHandler): """ Event handler for watchdog, in order to be used in monitoring. """ + def __init__(self, subdir): """ subdir: AIRCOX_SOUND_ARCHIVES_SUBDIR or AIRCOX_SOUND_EXCERPTS_SUBDIR """ self.subdir = subdir if self.subdir == settings.AIRCOX_SOUND_ARCHIVES_SUBDIR: - self.sound_kwargs = { 'type': Sound.Type.archive } + self.sound_kwargs = {'type': Sound.Type.archive} else: - self.sound_kwargs = { 'type': Sound.Type.excerpt } + self.sound_kwargs = {'type': Sound.Type.excerpt} patterns = ['*/{}/*{}'.format(self.subdir, ext) - for ext in settings.AIRCOX_SOUND_FILE_EXT ] + for ext in settings.AIRCOX_SOUND_FILE_EXT] super().__init__(patterns=patterns, ignore_directories=True) def on_created(self, event): @@ -215,14 +217,14 @@ class MonitorHandler(PatternMatchingEventHandler): si = SoundInfo(event.src_path) self.sound_kwargs['program'] = program - si.get_sound(save = True, **self.sound_kwargs) + si.get_sound(save=True, **self.sound_kwargs) if si.year is not None: si.find_diffusion(program) si.sound.save(True) def on_deleted(self, event): logger.info('sound deleted: %s', event.src_path) - sound = Sound.objects.filter(path = event.src_path) + sound = Sound.objects.filter(path=event.src_path) if sound: sound = sound[0] sound.type = sound.Type.removed @@ -230,7 +232,7 @@ class MonitorHandler(PatternMatchingEventHandler): def on_moved(self, event): logger.info('sound moved: %s -> %s', event.src_path, event.dest_path) - sound = Sound.objects.filter(path = event.src_path) + sound = Sound.objects.filter(path=event.src_path) if not sound: self.on_modified( FileModifiedEvent(event.dest_path) @@ -242,18 +244,19 @@ class MonitorHandler(PatternMatchingEventHandler): if not sound.diffusion: program = Program.get_from_path(event.src_path) if program: - si = SoundInfo(sound.path, sound = sound) + si = SoundInfo(sound.path, sound=sound) if si.year is not None: si.find_diffusion(program) sound.save() class Command(BaseCommand): - help= __doc__ + help = __doc__ - def report(self, program = None, component = None, *content): + def report(self, program=None, component=None, *content): if not component: - logger.info('%s: %s', str(program), ' '.join([str(c) for c in content])) + logger.info('%s: %s', str(program), + ' '.join([str(c) for c in content])) else: logger.info('%s, %s: %s', str(program), str(component), ' '.join([str(c) for c in content])) @@ -270,11 +273,11 @@ class Command(BaseCommand): logger.info('#%d %s', program.id, program.name) self.scan_for_program( program, settings.AIRCOX_SOUND_ARCHIVES_SUBDIR, - type = Sound.Type.archive, + type=Sound.Type.archive, ) self.scan_for_program( program, settings.AIRCOX_SOUND_EXCERPTS_SUBDIR, - type = Sound.Type.excerpt, + type=Sound.Type.excerpt, ) dirs.append(os.path.join(program.path)) @@ -300,14 +303,14 @@ class Command(BaseCommand): si = SoundInfo(path) sound_kwargs['program'] = program - si.get_sound(save = True, **sound_kwargs) - si.find_diffusion(program, save = True) + si.get_sound(save=True, **sound_kwargs) + si.find_diffusion(program, save=True) si.find_playlist(si.sound) sounds.append(si.sound.pk) # sounds in db & unchecked - sounds = Sound.objects.filter(path__startswith = subdir). \ - exclude(pk__in = sounds) + sounds = Sound.objects.filter(path__startswith=subdir). \ + exclude(pk__in=sounds) self.check_sounds(sounds) @staticmethod @@ -318,18 +321,18 @@ class Command(BaseCommand): # check files for sound in qs: if sound.check_on_file(): - sound.save(check = False) + sound.save(check=False) - def check_quality(self, check = False): + def check_quality(self, check=False): """ Check all files where quality has been set to bad """ import aircox.management.commands.sounds_quality_check \ - as quality_check + as quality_check # get available sound files - sounds = Sound.objects.filter(good_quality = False) \ - .exclude(type = Sound.Type.removed) + sounds = Sound.objects.filter(good_quality=False) \ + .exclude(type=Sound.Type.removed) if check: self.check_sounds(sounds) @@ -341,11 +344,12 @@ class Command(BaseCommand): # check quality logger.info('quality check...',) cmd = quality_check.Command() - cmd.handle( files = files, - **settings.AIRCOX_SOUND_QUALITY ) + cmd.handle(files=files, + **settings.AIRCOX_SOUND_QUALITY) # update stats logger.info('update stats in database') + def update_stats(sound_info, sound): stats = sound_info.get_file_stats() if stats: @@ -353,25 +357,25 @@ class Command(BaseCommand): sound.duration = utils.seconds_to_time(duration) for sound_info in cmd.good: - sound = Sound.objects.get(path = sound_info.path) + sound = Sound.objects.get(path=sound_info.path) sound.good_quality = True update_stats(sound_info, sound) - sound.save(check = False) + sound.save(check=False) for sound_info in cmd.bad: - sound = Sound.objects.get(path = sound_info.path) + sound = Sound.objects.get(path=sound_info.path) update_stats(sound_info, sound) - sound.save(check = False) + sound.save(check=False) def monitor(self): """ Run in monitor mode """ archives_handler = MonitorHandler( - subdir = settings.AIRCOX_SOUND_ARCHIVES_SUBDIR + subdir=settings.AIRCOX_SOUND_ARCHIVES_SUBDIR ) excerpts_handler = MonitorHandler( - subdir = settings.AIRCOX_SOUND_EXCERPTS_SUBDIR + subdir=settings.AIRCOX_SOUND_EXCERPTS_SUBDIR ) observer = Observer() @@ -390,10 +394,10 @@ class Command(BaseCommand): time.sleep(1) def add_arguments(self, parser): - parser.formatter_class=RawTextHelpFormatter + parser.formatter_class = RawTextHelpFormatter parser.add_argument( '-q', '--quality_check', action='store_true', - help='Enable quality check using sound_quality_check on all ' \ + help='Enable quality check using sound_quality_check on all ' 'sounds marqued as not good' ) parser.add_argument( @@ -411,7 +415,6 @@ class Command(BaseCommand): if options.get('scan'): self.scan() if options.get('quality_check'): - self.check_quality(check = (not options.get('scan')) ) + self.check_quality(check=(not options.get('scan'))) if options.get('monitor'): self.monitor() - diff --git a/aircox/management/commands/streamer.py b/aircox/management/commands/streamer.py index 9b0d37d..132fff7 100755 --- a/aircox/management/commands/streamer.py +++ b/aircox/management/commands/streamer.py @@ -82,17 +82,14 @@ class Monitor: """ Last sound log of monitored station that occurred on_air """ - return self.get_last_log(type = Log.Type.on_air, - sound__isnull = False) + return self.get_last_log(type=Log.Type.on_air, sound__isnull=False) @property def last_diff_start(self): """ Log of last triggered item (sound or diffusion) """ - return self.get_last_log(type = Log.Type.start, - diffusion__isnull = False) - + return self.get_last_log(type=Log.Type.start, diffusion__isnull=False) def __init__(self, station, **kwargs): self.station = station @@ -120,12 +117,11 @@ class Monitor: self.sync_playlists() self.handle() - def log(self, date = None, **kwargs): + def log(self, date=None, **kwargs): """ Create a log using **kwargs, and print info """ - log = Log(station = self.station, date = date or tz.now(), - **kwargs) + log = Log(station=self.station, date=date or tz.now(), **kwargs) log.save() log.print() return log @@ -142,14 +138,14 @@ class Monitor: air_times = (air_time - delta, air_time + delta) log = self.log_qs.on_air().filter( - source = source.id, sound__path = sound_path, - date__range = air_times, + source=source.id, sound__path=sound_path, + date__range=air_times, ).last() if log: return log # get sound - sound = Sound.objects.filter(path = sound_path) \ + sound = Sound.objects.filter(path=sound_path) \ .select_related('diffusion').first() diff = None if sound and sound.diffusion: @@ -157,20 +153,16 @@ class Monitor: # check for reruns if not diff.is_date_in_range(air_time) and not diff.initial: diff = Diffusion.objects.at(air_time) \ - .filter(initial = diff).first() + .filter(initial=diff).first() # log sound on air return self.log( - type = Log.Type.on_air, - source = source.id, - date = source.on_air, - sound = sound, - diffusion = diff, + type=Log.Type.on_air, source=source.id, date=source.on_air, + sound=sound, diffusion=diff, # if sound is removed, we keep sound path info - comment = sound_path, + comment=sound_path, ) - def trace_tracks(self, log): """ Log tracks for the given sound log (for streamed programs only). @@ -178,23 +170,21 @@ class Monitor: if log.diffusion: return - tracks = Track.objects.related(object = log.sound) \ - .filter(in_seconds = True) + tracks = Track.objects.filter(sound=log.sound, timestamp_isnull=False) if not tracks.exists(): return - tracks = tracks.exclude(log__station = self.station, - log__pk__gt = log.pk) + tracks = tracks.exclude(log__station=self.station, log__pk__gt=log.pk) now = tz.now() for track in tracks: - pos = log.date + tz.timedelta(seconds = track.position) + pos = log.date + tz.timedelta(seconds=track.position) if pos > now: break # log track on air self.log( - type = Log.Type.on_air, source = log.source, - date = pos, track = track, - comment = track, + type=Log.Type.on_air, source=log.source, + date=pos, track=track, + comment=track, ) def sync_playlists(self): diff --git a/aircox/models.py b/aircox/models.py index 4fdb4d6..dc62ab8 100755 --- a/aircox/models.py +++ b/aircox/models.py @@ -11,6 +11,7 @@ from django.contrib.contenttypes.fields import (GenericForeignKey, GenericRelation) from django.contrib.contenttypes.models import ContentType from django.db import models +from django.db.transaction import atomic from django.template.defaultfilters import slugify from django.utils import timezone as tz from django.utils.functional import cached_property @@ -24,70 +25,10 @@ from taggit.managers import TaggableManager logger = logging.getLogger('aircox.core') -# -# Abstracts -# -class RelatedQuerySet(models.QuerySet): - def related(self, object = None, model = None): - """ - Return a queryset that filter on the given object or model(s) - - * object: if given, use its type and pk; match on models only. - * model: one model or an iterable of models - """ - - if not model and object: - model = type(object) - - qs = self - - if hasattr(model, '__iter__'): - model = [ ContentType.objects.get_for_model(m).id - for m in model ] - self = self.filter(related_type__pk__in = model) - else: - model = ContentType.objects.get_for_model(model) - self = self.filter(related_type__pk = model.id) - - if object: - self = self.filter(related_id = object.pk) - - return self - -class Related(models.Model): - """ - Add a field "related" of type GenericForeignKey, plus utilities. - """ - related_type = models.ForeignKey( - ContentType, - blank = True, null = True, - on_delete=models.SET_NULL, - ) - related_id = models.PositiveIntegerField( - blank = True, null = True, - ) - related = GenericForeignKey( - 'related_type', 'related_id', - ) - - class Meta: - abstract = True - - objects = RelatedQuerySet.as_manager() - - @classmethod - def ReverseField(cl): - """ - Return a GenericRelation object that points to this class - """ - - return GenericRelation(cl, 'related_id', 'related_type') - - class Nameable(models.Model): - name = models.CharField ( + name = models.CharField( _('name'), - max_length = 128, + max_length=128, ) class Meta: @@ -98,81 +39,30 @@ class Nameable(models.Model): """ Slug based on the name. We replace '-' by '_' """ - return slugify(self.name).replace('-', '_') def __str__(self): - #if self.pk: + # if self.pk: # return '#{} {}'.format(self.pk, self.name) - return '{}'.format(self.name) -# -# Small common models -# -class Track(Related): - """ - 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. - """ - # 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 - title = models.CharField ( - _('title'), - max_length = 128, - ) - artist = models.CharField( - _('artist'), - max_length = 128, - ) - tags = TaggableManager( - verbose_name=_('tags'), - blank=True, - ) - info = models.CharField( - _('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.'), - ) - position = models.SmallIntegerField( - default = 0, - help_text=_('position in the playlist'), - ) - in_seconds = models.BooleanField( - _('in seconds'), - default = False, - help_text=_('position in the playlist is expressed in seconds') - ) - - def __str__(self): - return '{self.artist} -- {self.title} -- {self.position}'.format(self=self) - - class Meta: - verbose_name = _('Track') - verbose_name_plural = _('Tracks') - # # Station related classes # class StationQuerySet(models.QuerySet): - def default(self, station = None): + def default(self, station=None): """ Return station model instance, using defaults or given one. """ - if station is None: return self.order_by('-default', 'pk').first() + return self.filter(pk=station).first() - return self.filter(pk = station).first() def default_station(): """ Return default station (used by model fields) """ - return Station.objects.default() @@ -187,14 +77,14 @@ class Station(Nameable): """ path = models.CharField( _('path'), - help_text = _('path to the working directory'), - max_length = 256, - blank = True, + help_text=_('path to the working directory'), + max_length=256, + blank=True, ) default = models.BooleanField( _('default station'), - default = True, - help_text = _('if checked, this station is used as the main one') + default=True, + help_text=_('if checked, this station is used as the main one') ) objects = StationQuerySet.as_manager() @@ -208,14 +98,13 @@ class Station(Nameable): def __prepare_controls(self): import aircox.controllers as controllers - if not self.__streamer: - self.__streamer = controllers.Streamer(station = self) - self.__dealer = controllers.Source(station = self) - self.__sources = [ self.__dealer ] + [ - controllers.Source(station = self, program = program) + self.__streamer = controllers.Streamer(station=self) + self.__dealer = controllers.Source(station=self) + self.__sources = [self.__dealer] + [ + controllers.Source(station=self, program=program) - for program in Program.objects.filter(stream__isnull = False) + for program in Program.objects.filter(stream__isnull=False) ] @property @@ -223,10 +112,9 @@ class Station(Nameable): """ Return all active input ports of the station """ - return self.port_set.filter( - direction = Port.Direction.input, - active = True + direction=Port.Direction.input, + active=True ) @property @@ -234,10 +122,9 @@ class Station(Nameable): """ Return all active output ports of the station """ - return self.port_set.filter( - direction = Port.Direction.output, - active = True, + direction=Port.Direction.output, + active=True, ) @property @@ -246,13 +133,11 @@ class Station(Nameable): Audio sources, dealer included """ self.__prepare_controls() - return self.__sources @property def dealer(self): self.__prepare_controls() - return self.__dealer @property @@ -261,10 +146,9 @@ class Station(Nameable): Audio controller for the station """ self.__prepare_controls() - return self.__streamer - def on_air(self, date = None, count = 0, no_cache = False): + def on_air(self, date=None, count=0): """ Return a queryset of what happened on air, based on logs and diffusions informations. The queryset is sorted by -date. @@ -279,55 +163,47 @@ class Station(Nameable): that has been played when there was a live diffusion. """ # TODO argument to get sound instead of tracks - if not date and not count: raise ValueError('at least one argument must be set') # FIXME can be a potential source of bug - if date: - date = utils.cast_date(date, to_datetime = False) - + date = utils.cast_date(date, to_datetime=False) if date and date > datetime.date.today(): return [] now = tz.now() - if date: logs = Log.objects.at(date) diffs = Diffusion.objects.station(self).at(date) \ - .filter(start__lte = now, type = Diffusion.Type.normal) \ - .order_by('-start') + .filter(start__lte=now, type=Diffusion.Type.normal) \ + .order_by('-start') else: logs = Log.objects diffs = Diffusion.objects \ - .filter(type = Diffusion.Type.normal, - start__lte = now) \ + .filter(type=Diffusion.Type.normal, + start__lte=now) \ .order_by('-start')[:count] - q = models.Q(diffusion__isnull = False) | \ - models.Q(track__isnull = False) + q = models.Q(diffusion__isnull=False) | \ + models.Q(track__isnull=False) logs = logs.station(self).on_air().filter(q).order_by('-date') # filter out tracks played when there was a diffusion - n = 0 - q = models.Q() - + n, q = 0, models.Q() for diff in diffs: if count and n >= count: break # FIXME: does not catch tracks started before diff end but # that continued afterwards - q = q | models.Q(date__gte = diff.start, date__lte = diff.end) + q = q | models.Q(date__gte=diff.start, date__lte=diff.end) n += 1 - logs = logs.exclude(q, diffusion__isnull = True) - + logs = logs.exclude(q, diffusion__isnull=True) if count: logs = logs[:count] - return logs - def save(self, make_sources = True, *args, **kwargs): + def save(self, make_sources=True, *args, **kwargs): if not self.path: self.path = os.path.join( settings.AIRCOX_CONTROLLERS_WORKING_DIR, @@ -335,20 +211,20 @@ class Station(Nameable): ) if self.default: - qs = Station.objects.filter(default = True) + qs = Station.objects.filter(default=True) if self.pk: - qs = qs.exclude(pk = self.pk) - qs.update(default = False) + qs = qs.exclude(pk=self.pk) + qs.update(default=False) super().save(*args, **kwargs) class ProgramManager(models.Manager): - def station(self, station, qs = None, **kwargs): + def station(self, station, qs=None, **kwargs): qs = self if qs is None else qs - return qs.filter(station = station, **kwargs) + return qs.filter(station=station, **kwargs) class Program(Nameable): @@ -366,39 +242,39 @@ class Program(Nameable): """ station = models.ForeignKey( Station, - verbose_name = _('station'), + verbose_name=_('station'), on_delete=models.CASCADE, ) active = models.BooleanField( _('active'), - default = True, - help_text = _('if not checked this program is no longer active') + default=True, + help_text=_('if not checked this program is no longer active') ) sync = models.BooleanField( _('syncronise'), - default = True, - help_text = _('update later diffusions according to schedule changes') + default=True, + help_text=_('update later diffusions according to schedule changes') ) objects = ProgramManager() + # TODO: use unique slug @property def path(self): """ Return the path to the programs directory """ - return os.path.join(settings.AIRCOX_PROGRAMS_DIR, - self.slug + '_' + str(self.id) ) + self.slug + '_' + str(self.id)) - def ensure_dir(self, subdir = None): + def ensure_dir(self, subdir=None): """ Make sur the program's dir exists (and optionally subdir). Return True if the dir (or subdir) exists. """ path = os.path.join(self.path, subdir) if subdir else \ - self.path - os.makedirs(path, exist_ok = True) + self.path + os.makedirs(path, exist_ok=True) return os.path.exists(path) @@ -418,10 +294,10 @@ class Program(Nameable): """ Return the first schedule that matches a given date. """ - schedules = Schedule.objects.filter(program = self) + schedules = Schedule.objects.filter(program=self) for schedule in schedules: - if schedule.match(date, check_time = False): + if schedule.match(date, check_time=False): return schedule def __init__(self, *kargs, **kwargs): @@ -441,7 +317,8 @@ class Program(Nameable): self.id, self.name) shutil.move(self.__original_path, self.path) - sounds = Sounds.objects.filter(path__startswith = self.__original_path) + sounds = Sound.objects.filter( + path__startswith=self.__original_path) for sound in sounds: sound.path.replace(self.__original_path, self.path) @@ -455,16 +332,18 @@ class Program(Nameable): """ path = path.replace(settings.AIRCOX_PROGRAMS_DIR, '') - while path[0] == '/': path = path[1:] + while path[0] == '/': + path = path[1:] - while path[-1] == '/': path = path[:-2] + while path[-1] == '/': + path = path[:-2] if '/' in path: path = path[:path.index('/')] path = path.split('_') path = path[-1] - qs = cl.objects.filter(id = int(path)) + qs = cl.objects.filter(id=int(path)) return qs[0] if qs else None @@ -483,25 +362,25 @@ class Stream(models.Model): """ program = models.ForeignKey( Program, - verbose_name = _('related program'), + verbose_name=_('related program'), on_delete=models.CASCADE, ) delay = models.TimeField( _('delay'), - blank = True, null = True, - help_text = _('minimal delay between two sound plays') + 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') + 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') + blank=True, null=True, + help_text=_('used to define a time range this stream is' + 'played') ) @@ -529,57 +408,51 @@ class Schedule(models.Model): one_on_two = 0b100000 program = models.ForeignKey( - Program, - verbose_name = _('related program'), - on_delete=models.CASCADE, + Program, models.CASCADE, + verbose_name=_('related program'), ) time = models.TimeField( _('time'), - blank = True, null = True, - help_text = _('start time'), + blank=True, null=True, + help_text=_('start time'), ) date = models.DateField( _('date'), - blank = True, null = True, - help_text = _('date of the first diffusion'), + blank=True, null=True, + help_text=_('date of the first diffusion'), ) timezone = models.CharField( _('timezone'), - default = tz.get_current_timezone, - choices = [(x, x) for x in pytz.all_timezones], - max_length = 100, - help_text = _('timezone used for the date') + default=tz.get_current_timezone, max_length=100, + choices=[(x, x) for x in pytz.all_timezones], + help_text=_('timezone used for the date') ) duration = models.TimeField( _('duration'), - help_text = _('regular duration'), + help_text=_('regular duration'), ) frequency = models.SmallIntegerField( _('frequency'), - choices = [ - (int(y), { - 'ponctual': _('ponctual'), - 'first': _('first week of the month'), - 'second': _('second week of the month'), - 'third': _('third week of the month'), - 'fourth': _('fourth week of the month'), - 'last': _('last week of the month'), - 'first_and_third': _('first and third weeks of the month'), - 'second_and_fourth': _('second and fourth weeks of the month'), - 'every': _('every week'), - 'one_on_two': _('one week on two'), - }[x]) for x,y in Frequency.__members__.items() - ], + choices=[(int(y), { + 'ponctual': _('ponctual'), + 'first': _('first week of the month'), + 'second': _('second week of the month'), + 'third': _('third week of the month'), + 'fourth': _('fourth week of the month'), + 'last': _('last week of the month'), + 'first_and_third': _('first and third weeks of the month'), + 'second_and_fourth': _('second and fourth weeks of the month'), + 'every': _('every week'), + 'one_on_two': _('one week on two'), + }[x]) for x, y in Frequency.__members__.items()], ) initial = models.ForeignKey( - 'self', - verbose_name = _('initial schedule'), - blank = True, null = True, - on_delete=models.SET_NULL, - help_text = _('this schedule is a rerun of this one'), + 'self', models.SET_NULL, + verbose_name=_('initial schedule'), + blank=True, null=True, + help_text=_('this schedule is a rerun of this one'), ) - @cached_property def tz(self): """ @@ -592,7 +465,7 @@ class Schedule(models.Model): # 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: @@ -606,7 +479,7 @@ class Schedule(models.Model): return False - def match(self, date = None, check_time = True): + def match(self, date=None, check_time=True): """ Return True if the given datetime matches the schedule """ @@ -622,11 +495,10 @@ class Schedule(models.Model): # we check against a normalized version (norm_date will have # schedule's date. - norm_date = self.normalize(date) - return date == norm_date + return date == self.normalize(date) - def match_week(self, date = None): + def match_week(self, date=None): """ Return True if the given week number matches the schedule, False otherwise. @@ -638,17 +510,18 @@ class Schedule(models.Model): # since we care only about the week, go to the same day of the week date = utils.date_or_default(date) - date += tz.timedelta(days = self.date.weekday() - date.weekday() ) + date += tz.timedelta(days=self.date.weekday() - date.weekday()) # FIXME this case if self.frequency == Schedule.Frequency.one_on_two: # cf notes in date_of_month - diff = utils.cast_date(date, False) - utils.cast_date(self.date, False) + diff = utils.cast_date(date, False) - \ + utils.cast_date(self.date, False) return not (diff.days % 14) - first_of_month = date.replace(day = 1) + first_of_month = date.replace(day=1) week = date.isocalendar()[1] - first_of_month.isocalendar()[1] # weeks of month @@ -673,7 +546,7 @@ class Schedule(models.Model): return date - def dates_of_month(self, date = None): + def dates_of_month(self, date=None): """ Return a list with all matching dates of date.month (=today) Ensure timezone awareness. @@ -683,22 +556,23 @@ class Schedule(models.Model): return [] # first day of month - date = utils.date_or_default(date, to_datetime = False) \ + date = utils.date_or_default(date, to_datetime=False) \ .replace(day=1) freq = self.frequency # last of the month if freq == Schedule.Frequency.last: - date = date.replace(day=calendar.monthrange(date.year, date.month)[1]) + date = date.replace( + day=calendar.monthrange(date.year, date.month)[1]) # end of month before the wanted weekday: move one week back if date.weekday() < self.date.weekday(): - date -= tz.timedelta(days = 7) + date -= tz.timedelta(days=7) delta = self.date.weekday() - date.weekday() - date += tz.timedelta(days = delta) + date += tz.timedelta(days=delta) return [self.normalize(date)] @@ -706,34 +580,35 @@ class Schedule(models.Model): # check on SO#3284452 for the formula first_weekday = date.weekday() sched_weekday = self.date.weekday() - date += tz.timedelta(days = (7 if first_weekday > sched_weekday else 0) \ - - first_weekday + sched_weekday) + date += tz.timedelta(days=(7 if first_weekday > sched_weekday else 0) + - first_weekday + sched_weekday) month = date.month dates = [] if freq == Schedule.Frequency.one_on_two: # check date base on a diff of dates base on a 14 days delta - diff = utils.cast_date(date, False) - utils.cast_date(self.date, False) + diff = utils.cast_date(date, False) - \ + utils.cast_date(self.date, False) if diff.days % 14: - date += tz.timedelta(days = 7) + date += tz.timedelta(days=7) while date.month == month: dates.append(date) - date += tz.timedelta(days = 14) + date += tz.timedelta(days=14) else: week = 0 while week < 5 and date.month == month: if freq & (0b1 << week): dates.append(date) - date += tz.timedelta(days = 7) - week += 1; + date += tz.timedelta(days=7) + week += 1 return [self.normalize(date) for date in dates] - def diffusions_of_month(self, date = None, exclude_saved = False): + def diffusions_of_month(self, date=None, exclude_saved=False): """ Return a list of Diffusion instances, from month of the given date, that can be not in the database. @@ -750,7 +625,7 @@ class Schedule(models.Model): # existing diffusions for item in Diffusion.objects.filter( - program = self.program, start__in = dates): + program=self.program, start__in=dates): if item.start in dates: dates.remove(item.start) @@ -765,13 +640,12 @@ class Schedule(models.Model): delta = self.date - self.initial.date diffusions += [ Diffusion( - program = self.program, - type = Diffusion.Type.unconfirmed, - initial = \ - Diffusion.objects.filter(start = date - delta).first() \ - if self.initial else None, - start = date, - end = date + duration, + program=self.program, + type=Diffusion.Type.unconfirmed, + initial=Diffusion.objects.filter(start=date - delta).first() + if self.initial else None, + start=date, + end=date + duration, ) for date in dates ] @@ -786,9 +660,9 @@ class Schedule(models.Model): self.__initial = self.__dict__.copy() def __str__(self): - return ' | '.join([ '#' + str(self.id), self.program.name, - self.get_frequency_display(), - self.time.strftime('%a %H:%M') ]) + return ' | '.join(['#' + str(self.id), self.program.name, + self.get_frequency_display(), + self.time.strftime('%a %H:%M')]) def save(self, *args, **kwargs): if self.initial: @@ -806,12 +680,12 @@ class Schedule(models.Model): class DiffusionQuerySet(models.QuerySet): def station(self, station, **kwargs): - return self.filter(program__station = station, **kwargs) + return self.filter(program__station=station, **kwargs) def program(self, program): - return self.filter(program = program) + return self.filter(program=program) - def at(self, date = None, next = False, **kwargs): + def at(self, date=None, next=False, **kwargs): """ Return diffusions occuring at the given date, ordered by +start @@ -825,7 +699,7 @@ class DiffusionQuerySet(models.QuerySet): the given moment. """ # note: we work with localtime - date = utils.date_or_default(date, keep_type = True) + date = utils.date_or_default(date, keep_type=True) qs = self filters = None @@ -833,44 +707,44 @@ class DiffusionQuerySet(models.QuerySet): if isinstance(date, datetime.datetime): # use datetime: we want diffusion that occurs around this # range - filters = { 'start__lte': date, 'end__gte': date } + filters = {'start__lte': date, 'end__gte': date} if next: qs = qs.filter( - models.Q(start__gte = date) | models.Q(**filters) + models.Q(start__gte=date) | models.Q(**filters) ) else: qs = qs.filter(**filters) else: # use date: we want diffusions that occurs this day start, end = utils.date_range(date) - filters = models.Q(start__gte = start, start__lte = end) | \ - models.Q(end__gt = start, end__lt = end) + filters = models.Q(start__gte=start, start__lte=end) | \ + models.Q(end__gt=start, end__lt=end) if next: # include also diffusions of the next day - filters |= models.Q(start__gte = start) + filters |= models.Q(start__gte=start) qs = qs.filter(filters, **kwargs) return qs.order_by('start').distinct() - def after(self, date = None, **kwargs): + def after(self, date=None, **kwargs): """ Return a queryset of diffusions that happen after the given date. """ - date = utils.date_or_default(date, keep_type = True) + date = utils.date_or_default(date, keep_type=True) - return self.filter(start__gte = date, **kwargs).order_by('start') + return self.filter(start__gte=date, **kwargs).order_by('start') - def before(self, date = None, **kwargs): + def before(self, date=None, **kwargs): """ Return a queryset of diffusions that finish before the given date. """ date = utils.date_or_default(date) - return self.filter(end__lte = date, **kwargs).order_by('start') + return self.filter(end__lte=date, **kwargs).order_by('start') class Diffusion(models.Model): @@ -899,22 +773,22 @@ class Diffusion(models.Model): canceled = 0x02 # common - program = models.ForeignKey ( + program = models.ForeignKey( Program, - verbose_name = _('program'), + verbose_name=_('program'), on_delete=models.CASCADE, ) # specific type = models.SmallIntegerField( - verbose_name = _('type'), - choices = [ (int(y), _(x)) for x,y in Type.__members__.items() ], + verbose_name=_('type'), + choices=[(int(y), _(x)) for x, y in Type.__members__.items()], ) - initial = models.ForeignKey ( + initial = models.ForeignKey( 'self', on_delete=models.SET_NULL, - blank = True, null = True, - related_name = 'reruns', - verbose_name = _('initial diffusion'), - help_text = _('the diffusion is a rerun of this one') + blank=True, null=True, + related_name='reruns', + verbose_name=_('initial diffusion'), + help_text=_('the diffusion is a rerun of this one') ) # port = models.ForeignKey( # 'self', @@ -925,15 +799,13 @@ class Diffusion(models.Model): # ) conflicts = models.ManyToManyField( 'self', - verbose_name = _('conflicts'), - blank = True, - help_text = _('conflicts'), + verbose_name=_('conflicts'), + blank=True, + help_text=_('conflicts'), ) - start = models.DateTimeField( _('start of the diffusion') ) - end = models.DateTimeField( _('end of the diffusion') ) - - tracks = Track.ReverseField() + start = models.DateTimeField(_('start of the diffusion')) + end = models.DateTimeField(_('end of the diffusion')) @property def duration(self): @@ -979,8 +851,7 @@ class Diffusion(models.Model): """ return self.type == self.Type.normal and \ - not self.get_sounds(archive = True).count() - + not self.get_sounds(archive=True).count() def get_playlist(self, **types): """ @@ -988,10 +859,10 @@ class Diffusion(models.Model): The given arguments are passed to ``get_sounds``. """ - 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): """ @@ -1001,12 +872,12 @@ class Diffusion(models.Model): **types: filter on the given sound types name, as `archive=True` """ sounds = (self.initial or self).sound_set.order_by('type', 'path') - _in = [ getattr(Sound.Type, name) - for name, value in types.items() if value ] + _in = [getattr(Sound.Type, name) + for name, value in types.items() if value] - return sounds.filter(type__in = _in) + return sounds.filter(type__in=_in) - def is_date_in_range(self, date = None): + def is_date_in_range(self, date=None): """ Return true if the given date is in the diffusion's start-end range. @@ -1021,11 +892,11 @@ class Diffusion(models.Model): """ return Diffusion.objects.filter( - models.Q(start__lt = self.start, - end__gt = self.start) | - models.Q(start__gt = self.start, - start__lt = self.end) - ) + models.Q(start__lt=self.start, + end__gt=self.start) | + models.Q(start__gt=self.start, + start__lt=self.end) + ).exclude(pk=self.pk).distinct() def check_conflicts(self): conflicts = self.get_conflicts() @@ -1040,7 +911,7 @@ class Diffusion(models.Model): 'end': self.end, } - def save(self, no_check = False, *args, **kwargs): + def save(self, no_check=False, *args, **kwargs): if no_check: return super().save(*args, **kwargs) @@ -1056,7 +927,6 @@ class Diffusion(models.Model): self.end != self.__initial['end']: self.check_conflicts() - def __str__(self): return '{self.program.name} {date} #{self.pk}'.format( self=self, date=self.local_date.strftime('%Y/%m/%d %H:%M%z') @@ -1065,7 +935,6 @@ class Diffusion(models.Model): class Meta: verbose_name = _('Diffusion') verbose_name_plural = _('Diffusions') - permissions = ( ('programming', _('edit the diffusion\'s planification')), ) @@ -1084,63 +953,61 @@ class Sound(Nameable): program = models.ForeignKey( Program, - verbose_name = _('program'), - blank = True, null = True, + verbose_name=_('program'), + blank=True, null=True, on_delete=models.SET_NULL, - help_text = _('program related to it'), + help_text=_('program related to it'), ) diffusion = models.ForeignKey( 'Diffusion', - verbose_name = _('diffusion'), - blank = True, null = True, + verbose_name=_('diffusion'), + blank=True, null=True, on_delete=models.SET_NULL, - help_text = _('initial diffusion related it') + help_text=_('initial diffusion related it') ) type = models.SmallIntegerField( - verbose_name = _('type'), - choices = [ (int(y), _(x)) for x,y in Type.__members__.items() ], - blank = True, null = True + verbose_name=_('type'), + choices=[(int(y), _(x)) for x, y in Type.__members__.items()], + blank=True, null=True ) # FIXME: url() does not use the same directory than here # should we use FileField for more reliability? path = models.FilePathField( _('file'), - path = settings.AIRCOX_PROGRAMS_DIR, - match = r'(' + '|'.join(settings.AIRCOX_SOUND_FILE_EXT) - .replace('.', r'\.') + ')$', - recursive = True, - blank = True, null = True, - unique = True, - max_length = 255 + path=settings.AIRCOX_PROGRAMS_DIR, + match=r'(' + '|'.join(settings.AIRCOX_SOUND_FILE_EXT) + .replace('.', r'\.') + ')$', + recursive=True, + blank=True, null=True, + unique=True, + max_length=255 ) embed = models.TextField( _('embed HTML code'), - blank = True, null = True, - help_text = _('HTML code used to embed a sound from external plateform'), + blank=True, null=True, + help_text=_('HTML code used to embed a sound from external plateform'), ) duration = models.TimeField( _('duration'), - blank = True, null = True, - help_text = _('duration of the sound'), + 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'), + blank=True, null=True, + help_text=_('last modification date and time'), ) good_quality = models.NullBooleanField( _('good quality'), - help_text = _('sound\'s quality is okay'), - blank = True, null = True + help_text=_('sound\'s quality is okay'), + blank=True, null=True ) public = models.BooleanField( _('public'), - default = False, - help_text = _('the sound is accessible to the public') + default=False, + help_text=_('the sound is accessible to the public') ) - tracks = Track.ReverseField() - def get_mtime(self): """ Get the last modification date from file @@ -1148,7 +1015,7 @@ class Sound(Nameable): mtime = os.stat(self.path).st_mtime mtime = tz.datetime.fromtimestamp(mtime) # db does not store microseconds - mtime = mtime.replace(microsecond = 0) + mtime = mtime.replace(microsecond=0) return tz.make_aware(mtime, tz.get_current_timezone()) @@ -1174,7 +1041,6 @@ class Sound(Nameable): Get metadata from sound file and return a Track object if succeed, else None. """ - if not self.file_exists(): return None @@ -1189,24 +1055,19 @@ class Sound(Nameable): def get_meta(key, cast=str): value = meta.get(key) - return cast(value[0]) if value else None info = '{} ({})'.format(get_meta('album'), get_meta('year')) \ - if meta and ('album' and 'year' in meta) else \ + if meta and ('album' and 'year' in meta) else \ get_meta('album') \ - if 'album' else \ + if 'album' else \ ('year' in meta) and get_meta('year') or '' - track = Track( - related = self, - title = get_meta('title') or self.name, - artist = get_meta('artist') or _('unknown'), - info = info, - position = get_meta('tracknumber', int) or 0, - ) - - return track + return Track(sound=self, + position=get_meta('tracknumber', int) or 0, + title=get_meta('title') or self.name, + artist=get_meta('artist') or _('unknown'), + info=info) def check_on_file(self): """ @@ -1229,7 +1090,7 @@ class Sound(Nameable): changed = True self.type = self.Type.archive \ if self.path.startswith(self.program.archives_path) else \ - self.Type.excerpt + self.Type.excerpt # check mtime -> reset quality if changed (assume file changed) mtime = self.get_mtime() @@ -1276,7 +1137,7 @@ class Sound(Nameable): super().__init__(*args, **kwargs) self.__check_name() - def save(self, check = True, *args, **kwargs): + def save(self, check=True, *args, **kwargs): if check: self.check_on_file() self.__check_name() @@ -1290,6 +1151,64 @@ class Sound(Nameable): verbose_name_plural = _('Sounds') +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. + """ + diffusion = models.ForeignKey( + Diffusion, models.CASCADE, blank=True, null=True, + verbose_name=_('diffusion'), + ) + sound = models.ForeignKey( + Sound, models.CASCADE, blank=True, null=True, + verbose_name=_('sound'), + ) + position = models.PositiveSmallIntegerField( + _('order'), + default=0, + help_text=_('position in the playlist'), + ) + timestamp = models.PositiveSmallIntegerField( + _('timestamp'), + blank=True, null=True, + help_text=_('position in seconds') + ) + title = models.CharField( + _('title'), + max_length=128, + ) + artist = models.CharField( + _('artist'), + max_length=128, + ) + tags = TaggableManager( + verbose_name=_('tags'), + blank=True, + ) + info = models.CharField( + _('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.'), + ) + + class Meta: + verbose_name = _('Track') + verbose_name_plural = _('Tracks') + ordering = ('position',) + + def __str__(self): + return '{self.artist} -- {self.title} -- {self.position}'.format( + self=self) + + def save(self, *args, **kwargs): + if (self.sound is None and self.diffusion is None) or \ + (self.sound is not None and self.diffusion is not None): + raise ValueError('sound XOR diffusion is required') + super().save(*args, **kwargs) + # # Controls and audio input/output # @@ -1319,29 +1238,29 @@ class Port (models.Model): station = models.ForeignKey( Station, - verbose_name = _('station'), + verbose_name=_('station'), on_delete=models.CASCADE, ) direction = models.SmallIntegerField( _('direction'), - choices = [ (int(y), _(x)) for x,y in Direction.__members__.items() ], + choices=[(int(y), _(x)) for x, y in Direction.__members__.items()], ) type = models.SmallIntegerField( _('type'), # we don't translate the names since it is project names. - choices = [ (int(y), x) for x,y in Type.__members__.items() ], + choices=[(int(y), x) for x, y in Type.__members__.items()], ) active = models.BooleanField( _('active'), - default = True, - help_text = _('this port is 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 + 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 ) def is_valid_type(self): @@ -1368,37 +1287,37 @@ 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 '' + direction=self.get_direction_display(), + type=self.get_type_display(), + id=self.pk or '' ) class LogQuerySet(models.QuerySet): def station(self, station): - return self.filter(station = station) + return self.filter(station=station) - def at(self, date = None): + def at(self, date=None): start, end = utils.date_range(date) # return qs.filter(models.Q(end__gte = start) | # models.Q(date__lte = end)) - return self.filter(date__gte = start, date__lte = end) + return self.filter(date__gte=start, date__lte=end) def on_air(self): - return self.filter(type = Log.Type.on_air) + return self.filter(type=Log.Type.on_air) def start(self): - return self.filter(type = Log.Type.start) + return self.filter(type=Log.Type.start) - def with_diff(self, with_it = True): - return self.filter(diffusion__isnull = not with_it) + def with_diff(self, with_it=True): + return self.filter(diffusion__isnull=not with_it) - def with_sound(self, with_it = True): - return self.filter(sound__isnull = not with_it) + def with_sound(self, with_it=True): + return self.filter(sound__isnull=not with_it) - def with_track(self, with_it = True): - return self.filter(track__isnull = not with_it) + def with_track(self, with_it=True): + return self.filter(track__isnull=not with_it) @staticmethod def _get_archive_path(station, date): @@ -1424,7 +1343,7 @@ class LogQuerySet(models.QuerySet): rel.pk: rel for rel in type.objects.filter( - pk__in = ( + pk__in=( log[attr_id] for log in logs if attr_id in log @@ -1464,15 +1383,15 @@ class LogQuerySet(models.QuerySet): # make logs return [ - Log(diffusion = rel_obj(log, 'diffusion'), - sound = rel_obj(log, 'sound'), - track = rel_obj(log, 'track'), + Log(diffusion=rel_obj(log, 'diffusion'), + sound=rel_obj(log, 'sound'), + track=rel_obj(log, 'track'), **log) for log in logs ] - def make_archive(self, station, date, force = False, keep = False): + def make_archive(self, station, date, force=False, keep=False): """ Archive logs of the given date. If the archive exists, it does not overwrite it except if "force" is given. In this case, the @@ -1484,8 +1403,8 @@ class LogQuerySet(models.QuerySet): import yaml import gzip - os.makedirs(settings.AIRCOX_LOGS_ARCHIVES_DIR, exist_ok = True) - path = self._get_archive_path(station, date); + os.makedirs(settings.AIRCOX_LOGS_ARCHIVES_DIR, exist_ok=True) + path = self._get_archive_path(station, date) if os.path.exists(path) and not force: return -1 @@ -1496,15 +1415,8 @@ class LogQuerySet(models.QuerySet): return 0 fields = Log._meta.get_fields() - logs = [ - { - i.attname: getattr(log, i.attname) - - for i in fields - } - - for log in qs - ] + logs = [{i.attname: getattr(log, i.attname) + for i in fields} for log in qs] # Note: since we use Yaml, we can just append new logs when file # exists yet <3 @@ -1554,55 +1466,46 @@ class Log(models.Model): """ type = models.SmallIntegerField( - verbose_name = _('type'), - choices = [ (int(y), _(x.replace('_',' '))) for x,y in Type.__members__.items() ], - blank = True, null = True, + choices=[(int(y), _(x.replace('_', ' '))) + for x, y in Type.__members__.items()], + blank=True, null=True, + verbose_name=_('type'), ) station = models.ForeignKey( - Station, - verbose_name = _('station'), - on_delete=models.CASCADE, - help_text = _('related station'), + Station, on_delete=models.CASCADE, + verbose_name=_('station'), + help_text=_('related station'), ) source = models.CharField( # we use a CharField to avoid loosing logs information if the # source is removed - _('source'), - max_length=64, - help_text = _('identifier of the source related to this log'), - blank = True, null = True, + max_length=64, blank=True, null=True, + verbose_name=_('source'), + help_text=_('identifier of the source related to this log'), ) date = models.DateTimeField( - _('date'), - default=tz.now, - db_index = True, + default=tz.now, db_index=True, + verbose_name=_('date'), ) comment = models.CharField( - _('comment'), - max_length = 512, - blank = True, null = True, + max_length=512, blank=True, null=True, + verbose_name=_('comment'), ) diffusion = models.ForeignKey( - Diffusion, - verbose_name = _('Diffusion'), - blank = True, null = True, - db_index = True, - on_delete=models.SET_NULL, + Diffusion, on_delete=models.SET_NULL, + blank=True, null=True, db_index=True, + verbose_name=_('Diffusion'), ) sound = models.ForeignKey( - Sound, - verbose_name = _('Sound'), - blank = True, null = True, - db_index = True, - on_delete=models.SET_NULL, + Sound, on_delete=models.SET_NULL, + blank=True, null=True, db_index=True, + verbose_name=_('Sound'), ) track = models.ForeignKey( - Track, - verbose_name = _('Track'), - blank = True, null = True, - db_index = True, - on_delete=models.SET_NULL, + Track, on_delete=models.SET_NULL, + blank=True, null=True, db_index=True, + verbose_name=_('Track'), ) objects = LogQuerySet.as_manager() @@ -1618,31 +1521,22 @@ class Log(models.Model): 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()) def print(self): r = [] - if self.diffusion: r.append('diff: ' + str(self.diffusion_id)) - if self.sound: 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 '' - ) + logger.info('log %s: %s%s', str(self), self.comment or '', + ' (' + ', '.join(r) + ')' if r else '') def __str__(self): return '#{} ({}, {}, {})'.format( - self.pk, - self.get_type_display(), - self.source, - self.local_date.strftime('%Y/%m/%d %H:%M%z'), + self.pk, self.get_type_display(), + self.source, + self.local_date.strftime('%Y/%m/%d %H:%M%z'), ) diff --git a/aircox/templates/admin/aircox/playlist_inline.html b/aircox/templates/admin/aircox/playlist_inline.html new file mode 100644 index 0000000..7e16f63 --- /dev/null +++ b/aircox/templates/admin/aircox/playlist_inline.html @@ -0,0 +1,6 @@ +{% load static i18n %} + +{% with inline_admin_formset.formset.instance as playlist %} +{% include "adminsortable2/tabular.html" %} +{% endwith %} + diff --git a/aircox/templates/aircox/config/liquidsoap.liq b/aircox/templates/aircox/config/liquidsoap.liq index 9fc5d78..d2765db 100755 --- a/aircox/templates/aircox/config/liquidsoap.liq +++ b/aircox/templates/aircox/config/liquidsoap.liq @@ -78,8 +78,7 @@ A stream is a source that: - is interactive {% endcomment %} def stream (id, file) = - s = playlist(id = '#{id}_playlist', mode = "random", reload_mode='watch', - file) + s = playlist(id = '#{id}_playlist', mode = "random", reload_mode='watch', file) interactive_source(id, s) end {% endblock %} diff --git a/aircox/views.py b/aircox/views.py index af2781a..6efa6da 100755 --- a/aircox/views.py +++ b/aircox/views.py @@ -25,7 +25,7 @@ class Stations: if self.fetch_timeout and self.fetch_timeout > tz.now(): return - self.fetch_timeout = tz.now() + tz.timedelta(seconds = 5) + self.fetch_timeout = tz.now() + tz.timedelta(seconds=5) for station in self.stations: station.streamer.fetch() @@ -39,62 +39,54 @@ def on_air(request): except: cms = None - station = request.GET.get('station'); + station = request.GET.get('station') if station: # FIXME: by name??? - station = stations.stations.filter(name = station) + station = stations.stations.filter(name=station) if not station.count(): return HttpResponse('{}') else: station = stations.stations station = station.first() - on_air = station.on_air(count = 10).select_related('track','diffusion') + on_air = station.on_air(count=10).select_related('track', 'diffusion') if not on_air.count(): return HttpResponse('') last = on_air.first() if last.track: - last = { - 'type': 'track', - 'artist': last.related.artist, - 'title': last.related.title, - 'date': last.date, - } + last = {'date': last.date, 'type': 'track', + 'artist': last.track.artist, 'title': last.track.title} else: try: diff = last.diffusion publication = None + # FIXME CMS if cms: publication = \ cms.DiffusionPage.objects.filter( - diffusion = diff.initial or diff).first() or \ + diffusion=diff.initial or diff).first() or \ cms.ProgramPage.objects.filter( - program = last.program).first() + program=last.program).first() except: pass - - last = { - 'type': 'diffusion', - 'title': diff.program.name, - 'date': diff.start, - 'url': publication.specific.url if publication else None, - } - + last = {'date': diff.start, 'type': 'diffusion', + 'title': diff.program.name, + 'url': publication.specific.url if publication else None} last['date'] = str(last['date']) return HttpResponse(json.dumps(last)) # TODO: # - login url -class Monitor(View,TemplateResponseMixin,LoginRequiredMixin): +class Monitor(View, TemplateResponseMixin, LoginRequiredMixin): template_name = 'aircox/controllers/monitor.html' def get_context_data(self, **kwargs): stations.fetch() - return { 'stations': stations.stations } + return {'stations': stations.stations} - def get(self, request = None, **kwargs): + def get(self, request=None, **kwargs): if not request.user.is_active: return Http404() @@ -102,7 +94,7 @@ class Monitor(View,TemplateResponseMixin,LoginRequiredMixin): context = self.get_context_data(**kwargs) return render(request, self.template_name, context) - def post(self, request = None, **kwargs): + def post(self, request=None, **kwargs): if not request.user.is_active: return Http404() @@ -113,15 +105,15 @@ class Monitor(View,TemplateResponseMixin,LoginRequiredMixin): controller = POST.get('controller') action = POST.get('action') - station = stations.stations.filter(name = POST.get('station')) \ + station = stations.stations.filter(name=POST.get('station')) \ .first() if not station: return Http404() source = None if 'source' in POST: - source = [ s for s in station.sources - if s.name == POST['source'] ] + source = [s for s in station.sources + if s.name == POST['source']] source = source[0] if not source: return Http404 @@ -141,11 +133,11 @@ class Monitor(View,TemplateResponseMixin,LoginRequiredMixin): source.restart() -class StatisticsView(View,TemplateResponseMixin,LoginRequiredMixin): +class StatisticsView(View, TemplateResponseMixin, LoginRequiredMixin): """ View for statistics. """ - # we cannot manipulate queryset, since we have to be able to read from archives + # we cannot manipulate queryset: we have to be able to read from archives template_name = 'aircox/controllers/stats.html' class Item: @@ -179,7 +171,7 @@ class StatisticsView(View,TemplateResponseMixin,LoginRequiredMixin): self.__dict__.update(kwargs) # Note: one row contains a column for diffusions and one for streams - #def append(self, log): + # def append(self, log): # if log.col == 0: # self.rows.append((log, [])) # return @@ -194,13 +186,12 @@ class StatisticsView(View,TemplateResponseMixin,LoginRequiredMixin): # # all other cases: new row # self.rows.append((None, [log])) - def get_stats(self, station, date): """ Return statistics for the given station and date. """ - stats = self.Stats(station = station, date = date, - items = [], tags = {}) + stats = self.Stats(station=station, date=date, + items=[], tags={}) qs = Log.objects.station(station).on_air() \ .prefetch_related('diffusion', 'sound', 'track', 'track__tags') @@ -209,37 +200,26 @@ class StatisticsView(View,TemplateResponseMixin,LoginRequiredMixin): sound_log = None for log in qs: - rel = None - item = None - + rel, item = None, None if log.diffusion: - rel = log.diffusion - item = self.Item( - name = rel.program.name, - type = _('Diffusion'), - col = 0, - tracks = models.Track.objects.related(object = rel) - .prefetch_related('tags'), + rel, item = log.diffusion, self.Item( + name=rel.program.name, type=_('Diffusion'), col=0, + tracks=models.Track.objects.filter(diffusion=log.diffusion) + .prefetch_related('tags'), ) sound_log = None elif log.sound: - rel = log.sound - item = self.Item( - name = rel.program.name + ': ' + os.path.basename(rel.path), - type = _('Stream'), - col = 1, - tracks = [], + rel, item = log.sound, self.Item( + name=rel.program.name + ': ' + os.path.basename(rel.path), + type=_('Stream'), col=1, tracks=[], ) sound_log = item elif log.track: # append to last sound log if not sound_log: - # TODO: create item ? should never happen continue - sound_log.tracks.append(log.track) sound_log.end = log.end - sound_log continue item.date = log.date @@ -247,7 +227,6 @@ class StatisticsView(View,TemplateResponseMixin,LoginRequiredMixin): item.related = rel # stats.append(item) stats.items.append(item) - return stats def get_context_data(self, **kwargs): @@ -270,7 +249,7 @@ class StatisticsView(View,TemplateResponseMixin,LoginRequiredMixin): return context - def get(self, request = None, **kwargs): + def get(self, request=None, **kwargs): if not request.user.is_active: return Http404() @@ -279,4 +258,3 @@ class StatisticsView(View,TemplateResponseMixin,LoginRequiredMixin): return render(request, self.template_name, context) - diff --git a/aircox_web/__init__.py b/aircox_web/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/aircox_web/admin.py b/aircox_web/admin.py new file mode 100644 index 0000000..2d1c68b --- /dev/null +++ b/aircox_web/admin.py @@ -0,0 +1,68 @@ +import copy + +from django.contrib import admin +from django.utils.translation import ugettext_lazy as _ + +from content_editor.admin import ContentEditor +from feincms3 import plugins +from feincms3.admin import TreeAdmin + +from aircox import models as aircox +from . import models +from aircox.admin.playlist import TracksInline +from aircox.admin.mixins import UnrelatedInlineMixin + + +@admin.register(models.SiteSettings) +class SettingsAdmin(admin.ModelAdmin): + pass + + +class PageDiffusionPlaylist(UnrelatedInlineMixin, TracksInline): + parent_model = aircox.Diffusion + fields = list(TracksInline.fields) + fields.remove('timestamp') + + def get_parent(self, view_obj): + return view_obj and view_obj.diffusion + + def save_parent(self, parent, view_obj): + parent.save() + view_obj.diffusion = parent + view_obj.save() + + +@admin.register(models.Page) +class PageAdmin(ContentEditor, TreeAdmin): + list_display = ["indented_title", "move_column", "is_active"] + prepopulated_fields = {"slug": ("title",)} + # readonly_fields = ('diffusion',) + + fieldsets = ( + (_('Main'), { + 'fields': ['title', 'slug', 'by_program', 'summary'], + 'classes': ('tabbed', 'uncollapse') + }), + (_('Settings'), { + 'fields': ['show_author', 'featured', 'allow_comments', + 'status', 'static_path', 'path'], + 'classes': ('tabbed',) + }), + (_('Infos'), { + 'fields': ['diffusion'], + 'classes': ('tabbed',) + }), + ) + + inlines = [ + plugins.richtext.RichTextInline.create(models.RichText), + plugins.image.ImageInline.create(models.Image), + ] + + def get_inline_instances(self, request, obj=None): + inlines = super().get_inline_instances(request, obj) + if obj and obj.diffusion: + inlines.insert(0, PageDiffusionPlaylist(self.model, self.admin_site)) + return inlines + + diff --git a/aircox_web/apps.py b/aircox_web/apps.py new file mode 100644 index 0000000..87affde --- /dev/null +++ b/aircox_web/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class AircoxWebConfig(AppConfig): + name = 'aircox_web' diff --git a/aircox_web/assets/index.js b/aircox_web/assets/index.js new file mode 100644 index 0000000..54a2de9 --- /dev/null +++ b/aircox_web/assets/index.js @@ -0,0 +1 @@ +import './js'; diff --git a/aircox_web/assets/js/index.js b/aircox_web/assets/js/index.js new file mode 100644 index 0000000..a4233d5 --- /dev/null +++ b/aircox_web/assets/js/index.js @@ -0,0 +1,12 @@ +import Vue from 'vue'; +import Buefy from 'buefy'; +import 'buefy/dist/buefy.css'; + +Vue.use(Buefy); + +var app = new Vue({ + el: '#app', +}) + + + diff --git a/aircox_web/models.py b/aircox_web/models.py new file mode 100644 index 0000000..9f5f947 --- /dev/null +++ b/aircox_web/models.py @@ -0,0 +1,117 @@ +from django.db import models +from django.utils.translation import ugettext_lazy as _ +from django.contrib.auth import models as auth + +from content_editor.models import Region, create_plugin_base +from feincms3 import plugins +from feincms3.pages import AbstractPage + +from model_utils.models import TimeStampedModel, StatusModel +from model_utils import Choices +from filer.fields.image import FilerImageField + + +from aircox import models as aircox + + +class SiteSettings(models.Model): + station = models.ForeignKey( + aircox.Station, on_delete=models.SET_NULL, null=True, + ) + + # main settings + title = models.CharField( + _('Title'), max_length=32, + help_text=_('Website title used at various places'), + ) + logo = FilerImageField( + on_delete=models.SET_NULL, null=True, blank=True, + verbose_name=_('Logo'), + related_name='+', + ) + favicon = FilerImageField( + on_delete=models.SET_NULL, null=True, blank=True, + verbose_name=_('Favicon'), + related_name='+', + ) + + # meta descriptors + description = models.CharField( + _('Description'), max_length=128, + blank=True, null=True, + ) + tags = models.CharField( + _('Tags'), max_length=128, + blank=True, null=True, + ) + + + +class Page(AbstractPage, TimeStampedModel, StatusModel): + STATUS = Choices('draft', 'published') + regions = [ + Region(key="main", title=_("Content")), + Region(key="sidebar", title=_("Sidebar")), + ] + + + # metadata + by = models.ForeignKey( + auth.User, models.SET_NULL, blank=True, null=True, + verbose_name=_('Author'), + ) + by_program = models.ForeignKey( + aircox.Program, models.SET_NULL, blank=True, null=True, + related_name='authored_pages', + limit_choices_to={'schedule__isnull': False}, + verbose_name=_('Show program as author'), + help_text=_("If nothing is selected, display user's name"), + ) + + # options + show_author = models.BooleanField( + _('Show author'), default=True, + ) + featured = models.BooleanField( + _('featured'), default=False, + ) + allow_comments = models.BooleanField( + _('allow comments'), default=True, + ) + + # content + title = models.CharField( + _('title'), max_length=64, + ) + summary = models.TextField( + _('Summary'), + max_length=128, blank=True, null=True, + ) + cover = FilerImageField( + on_delete=models.SET_NULL, null=True, blank=True, + verbose_name=_('Cover'), + ) + + diffusion = models.OneToOneField( + aircox.Diffusion, models.CASCADE, + blank=True, null=True, + ) + +PagePlugin = create_plugin_base(Page) + + +class RichText(plugins.richtext.RichText, PagePlugin): + pass + + +class Image(plugins.image.Image, PagePlugin): + caption = models.CharField(_("caption"), max_length=200, blank=True) + + + +class ProgramPage(Page): + program = models.OneToOneField( + aircox.Program, models.CASCADE, + ) + + diff --git a/aircox_web/package.json b/aircox_web/package.json new file mode 100644 index 0000000..f987767 --- /dev/null +++ b/aircox_web/package.json @@ -0,0 +1,26 @@ +{ + "name": "aircox-web-assets", + "version": "0.0.0", + "description": "Assets for Aircox Web", + "main": "index.js", + "author": "bkfox", + "license": "AGPL", + "devDependencies": { + "@fortawesome/fontawesome-free": "^5.8.2", + "mini-css-extract-plugin": "^0.5.0", + "css-loader": "^2.1.1", + "file-loader": "^3.0.1", + "ttf-loader": "^1.0.2", + "vue-loader": "^15.7.0", + "vue-style-loader": "^4.1.2", + "webpack": "^4.32.2", + "webpack-bundle-analyzer": "^3.3.2", + "webpack-bundle-tracker": "^0.4.2-beta", + "webpack-cli": "^3.3.2" + }, + "dependencies": { + "bootstrap": "^4.3.1", + "buefy": "^0.7.8", + "vue": "^2.6.10" + } +} diff --git a/aircox_web/renderer.py b/aircox_web/renderer.py new file mode 100644 index 0000000..19fcaba --- /dev/null +++ b/aircox_web/renderer.py @@ -0,0 +1,20 @@ +from django.utils.html import format_html, mark_safe +from feincms3.renderer import TemplatePluginRenderer + +from .models import Page, RichText, Image + + +renderer = TemplatePluginRenderer() +renderer.register_string_renderer( + RichText, + lambda plugin: mark_safe(plugin.text), +) +renderer.register_string_renderer( + Image, + lambda plugin: format_html( + '
{}
', + plugin.image.url, + plugin.caption, + ), +) + diff --git a/aircox_web/templates/aircox_web/base.html b/aircox_web/templates/aircox_web/base.html new file mode 100644 index 0000000..7ff078c --- /dev/null +++ b/aircox_web/templates/aircox_web/base.html @@ -0,0 +1,34 @@ +{% load static thumbnail %} + + + + + + + + + {% block assets %} + + + + + + {% endblock %} + + {% block title %}{{ site_settings.title }}{% endblock %} + + {% block extra_head %}{% endblock %} + + + + +
+ {% block main %} + + {% endblock main %} +
+ + + + diff --git a/aircox_web/templates/aircox_web/page.html b/aircox_web/templates/aircox_web/page.html new file mode 100644 index 0000000..85d4e79 --- /dev/null +++ b/aircox_web/templates/aircox_web/page.html @@ -0,0 +1,8 @@ +{% extends "aircox_web/base.html" %} + +{% block title %}{{ page.title }} -- {{ block.super }}{% endblock %} + +{% block main %} +

{{ page.title }}

+{% endblock %} + diff --git a/aircox_web/tests.py b/aircox_web/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/aircox_web/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/aircox_web/urls.py b/aircox_web/urls.py new file mode 100644 index 0000000..a3e614b --- /dev/null +++ b/aircox_web/urls.py @@ -0,0 +1,9 @@ +from django.conf.urls import url + +from . import views + +urlpatterns = [ + url(r"^(?P[-\w/]+)/$", views.page_detail, name="page"), + url(r"^$", views.page_detail, name="root"), +] + diff --git a/aircox_web/views.py b/aircox_web/views.py new file mode 100644 index 0000000..4fb2f4e --- /dev/null +++ b/aircox_web/views.py @@ -0,0 +1,22 @@ +from django.shortcuts import get_object_or_404, render + +from feincms3.regions import Regions + +from .models import SiteSettings, Page +from .renderer import renderer + + +def page_detail(request, path=None): + page = get_object_or_404( + # TODO: published + Page.objects.all(), + path="/{}/".format(path) if path else "/", + ) + return render(request, "aircox_web/page.html", { + 'site_settings': SiteSettings.objects.all().first(), + "page": page, + "regions": Regions.from_item( + page, renderer=renderer, timeout=60 + ), + }) + diff --git a/aircox_web/webpack.config.js b/aircox_web/webpack.config.js new file mode 100644 index 0000000..3865687 --- /dev/null +++ b/aircox_web/webpack.config.js @@ -0,0 +1,87 @@ +const path = require('path'); +const webpack = require('webpack'); + +const MiniCssExtractPlugin = require("mini-css-extract-plugin"); +// const { createLodashAliases } = require('lodash-loader'); +const { VueLoaderPlugin } = require('vue-loader'); + + +module.exports = (env, argv) => Object({ + context: __dirname, + entry: './assets/index', + + output: { + path: path.resolve('static/aircox_web/assets'), + filename: '[name].js', + chunkFilename: '[name].js', + }, + + optimization: { + usedExports: true, + concatenateModules: argv.mode == 'production' ? true : false, + + splitChunks: { + cacheGroups: { + vendor: { + name: 'vendor', + chunks: 'initial', + enforce: true, + + test: /[\\/]node_modules[\\/]/, + }, + } + } + }, + + plugins: [ + new MiniCssExtractPlugin({ + filename: "[name].css", + chunkFilename: "[id].css" + }), + new VueLoaderPlugin(), + ], + + module: { + rules: [ + { + test: /\/node_modules\//, + sideEffects: false + }, + { + test: /\.css$/, + use: [ { loader: MiniCssExtractPlugin.loader }, + 'css-loader' ] + }, + { + // TODO: remove ttf eot svg + test: /\.(ttf|eot|svg|woff2?)$/, + use: [{ + loader: 'file-loader', + options: { + name: '[name].[ext]', + outputPath: 'fonts/', + } + }], + }, + { test: /\.vue$/, use: 'vue-loader' }, + ], + }, + + resolve: { + alias: { + js: path.resolve(__dirname, 'assets/js'), + vue: path.resolve(__dirname, 'assets/vue'), + css: path.resolve(__dirname, 'assets/css'), + vue: 'vue/dist/vue.esm.browser.js', + // buefy: 'buefy/dist/buefy.js', + }, + modules: [ + 'assets/css', + 'assets/js', + 'assets/vue', + './node_modules', + ], + extensions: ['.js', '.vue', '.css', '.styl', '.ttf'] + }, +}) + diff --git a/instance/sample_settings.py b/instance/sample_settings.py index 703168c..084e757 100755 --- a/instance/sample_settings.py +++ b/instance/sample_settings.py @@ -152,7 +152,6 @@ MIDDLEWARE = ( 'django.middleware.common.CommonMiddleware', 'django.middleware.csrf.CsrfViewMiddleware', 'django.contrib.auth.middleware.AuthenticationMiddleware', - 'django.contrib.auth.middleware.SessionAuthenticationMiddleware', 'django.contrib.messages.middleware.MessageMiddleware', 'django.middleware.clickjacking.XFrameOptionsMiddleware', 'django.middleware.security.SecurityMiddleware', diff --git a/instance/urls.py b/instance/urls.py index 2a1b029..0fc7b7c 100755 --- a/instance/urls.py +++ b/instance/urls.py @@ -17,24 +17,18 @@ from django.conf import settings from django.urls import include, path, re_path from django.contrib import admin -from wagtail.admin import urls as wagtailadmin_urls -from wagtail.documents import urls as wagtaildocs_urls -from wagtail.core import urls as wagtail_urls -from wagtail.images.views.serve import ServeView +#from wagtail.admin import urls as wagtailadmin_urls +#from wagtail.documents import urls as wagtaildocs_urls +#from wagtail.core import urls as wagtail_urls +#from wagtail.images.views.serve import ServeView import aircox.urls +import aircox_web.urls try: urlpatterns = [ - path('jet/', include('jet.urls', 'jet')), path('admin/', admin.site.urls), path('aircox/', include(aircox.urls.urls)), - - # cms - path('cms/', include(wagtailadmin_urls)), - path('documents/', include(wagtaildocs_urls)), - re_path( r'^images/([^/]*)/(\d*)/([^/]*)/[^/]*$', ServeView.as_view(), - name='wagtailimages_serve'), ] if settings.DEBUG: @@ -45,7 +39,8 @@ try: ) ) - urlpatterns.append(re_path(r'', include(wagtail_urls))) + urlpatterns.append(path('filer/', include('filer.urls'))) + urlpatterns += aircox_web.urls.urlpatterns except Exception as e: import traceback diff --git a/requirements.txt b/requirements.txt index 4c28f54..433e55c 100755 --- a/requirements.txt +++ b/requirements.txt @@ -1,14 +1,20 @@ -gunicorn>=19.6.0 Django>=2.2.0 -wagtail>=2.4 +djangorestframework>=3.9.4 + +dateutils>=0.6.6 watchdog>=0.8.3 psutil>=5.0.1 -pyyaml>=3.12 -dateutils>=0.6.6 -bleach>=1.4.3 -django-auth-ldap>=1.7.0 -django-honeypot>=0.5.0 -django-jet>=1.0.3 -mutagen>=1.37 tzlocal>=1.4 +mutagen>=1.37 +pyyaml>=3.12 + +django-filer>=1.5.0 +django-admin-sortable2>=0.7.2 +django-content-editor>=1.4.2 +feincms3[all]>=0.31.0 + +bleach>=1.4.3 +django-honeypot>=0.5.0 + +gunicorn>=19.6.0