work hard on this

This commit is contained in:
bkfox 2019-06-29 18:13:25 +02:00
parent a951d7a319
commit 74dbc620ed
31 changed files with 1191 additions and 833 deletions

View File

@ -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)

5
aircox/admin/__init__.py Normal file
View File

@ -0,0 +1,5 @@
from .base import *
from .diffusion import DiffusionAdmin
# from .playlist import PlaylistAdmin
from .sound import SoundAdmin

94
aircox/admin/base.py Normal file
View File

@ -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)

81
aircox/admin/diffusion.py Normal file
View File

@ -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)

42
aircox/admin/mixins.py Normal file
View File

@ -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

41
aircox/admin/playlist.py Normal file
View File

@ -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

24
aircox/admin/sound.py Normal file
View File

@ -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]

View File

@ -29,62 +29,65 @@ class Importer:
path = None path = None
data = None data = None
tracks = None tracks = None
track_kwargs = {}
def __init__(self, related = None, path = None, save = False): def __init__(self, path=None, **track_kwargs):
if path: self.path = path
self.read(path) self.track_kwargs = track_kwargs
if related:
self.make_playlist(related, save)
def reset(self): def reset(self):
self.data = None self.data = None
self.tracks = None self.tracks = None
def read(self, path): def run(self):
if not os.path.exists(path): 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 return True
with open(path, 'r') as file: with open(self.path, 'r') as file:
logger.info('start reading csv ' + path) logger.info('start reading csv ' + self.path)
self.path = path
self.data = list(csv.DictReader( self.data = list(csv.DictReader(
(row for row in file (row for row in file
if not (row.startswith('#') or row.startswith('\ufeff#')) if not (row.startswith('#') or row.startswith('\ufeff#'))
and row.strip() and row.strip()),
),
fieldnames=settings.AIRCOX_IMPORT_PLAYLIST_CSV_COLS, fieldnames=settings.AIRCOX_IMPORT_PLAYLIST_CSV_COLS,
delimiter=settings.AIRCOX_IMPORT_PLAYLIST_CSV_DELIMITER, delimiter=settings.AIRCOX_IMPORT_PLAYLIST_CSV_DELIMITER,
quotechar=settings.AIRCOX_IMPORT_PLAYLIST_CSV_TEXT_QUOTE, 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 Make a playlist from the read data, and return it. If save is
true, save it into the database 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 maps = settings.AIRCOX_IMPORT_PLAYLIST_CSV_COLS
tracks = [] tracks = []
logger.info('parse csv file ' + self.path) 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): for index, line in enumerate(self.data):
if ('title' or 'artist') not in line: if ('title' or 'artist') not in line:
return return
try: try:
position = \ timestamp = int(line.get('minutes') or 0) * 60 + \
int(line.get('minutes') or 0) * 60 + \
int(line.get('seconds') or 0) \ int(line.get('seconds') or 0) \
if in_seconds else index if has_timestamp else None
track, created = Track.objects.get_or_create( track, created = Track.objects.get_or_create(
related_type = ContentType.objects.get_for_model(related),
related_id = related.pk,
title=line.get('title'), title=line.get('title'),
artist=line.get('artist'), artist=line.get('artist'),
position = position, position=index,
**self.track_kwargs
) )
track.timestamp = timestamp
track.in_seconds = in_seconds
track.info = line.get('info') track.info = line.get('info')
tags = line.get('tags') tags = line.get('tags')
if tags: if tags:
@ -97,7 +100,6 @@ class Importer:
) )
continue continue
if save:
track.save() track.save()
tracks.append(track) tracks.append(track)
self.tracks = tracks self.tracks = tracks
@ -109,8 +111,6 @@ class Command (BaseCommand):
def add_arguments(self, parser): def add_arguments(self, parser):
parser.formatter_class=RawTextHelpFormatter parser.formatter_class=RawTextHelpFormatter
now = tz.datetime.today()
parser.add_argument( parser.add_argument(
'path', metavar='PATH', type=str, 'path', metavar='PATH', type=str,
help='path of the input playlist to read' help='path of the input playlist to read'
@ -128,27 +128,24 @@ class Command (BaseCommand):
def handle (self, path, *args, **options): def handle (self, path, *args, **options):
# FIXME: absolute/relative path of sounds vs given path # FIXME: absolute/relative path of sounds vs given path
if options.get('sound'): if options.get('sound'):
related = Sound.objects.filter( sound = Sound.objects.filter(
path__icontains=options.get('sound') path__icontains=options.get('sound')
).first() ).first()
else: else:
path_, ext = os.path.splitext(path) 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 ' \ logger.error('no sound found in the database for the path ' \
'{path}'.format(path=path)) '{path}'.format(path=path))
return return
if options.get('diffusion') and related.diffusion: if options.get('diffusion') and sound.diffusion:
related = related.diffusion sound = sound.diffusion
importer = Importer(related = related, path = path, save = True) importer = Importer(path, sound=sound).run()
for track in importer.tracks: for track in importer.tracks:
logger.info('imported track at {pos}: {title}, by ' logger.info('track #{pos} imported: {title}, by {artist}'.format(
'{artist}'.format( pos=track.position, title=track.title, artist=track.artist
pos = track.position, ))
title = track.title, artist = track.artist
)
)

View File

@ -43,6 +43,7 @@ import aircox.utils as utils
logger = logging.getLogger('aircox.tools') logger = logging.getLogger('aircox.tools')
class SoundInfo: class SoundInfo:
name = '' name = ''
sound = None sound = None
@ -135,7 +136,7 @@ class SoundInfo:
If use_default is True and there is no playlist find found, If use_default is True and there is no playlist find found,
use sound file's metadata. use sound file's metadata.
""" """
if sound.tracks.count(): if sound.track_set.count():
return return
import aircox.management.commands.import_playlist \ import aircox.management.commands.import_playlist \
@ -151,7 +152,7 @@ class SoundInfo:
return return
# else, import # 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):
""" """
@ -163,7 +164,7 @@ class SoundInfo:
rerun. rerun.
""" """
if self.year == None or not self.sound or self.sound.diffusion: if self.year == None or not self.sound or self.sound.diffusion:
return; return
if self.hour is None: if self.hour is None:
date = datetime.date(self.year, self.month, self.day) date = datetime.date(self.year, self.month, self.day)
@ -190,6 +191,7 @@ class MonitorHandler(PatternMatchingEventHandler):
""" """
Event handler for watchdog, in order to be used in monitoring. Event handler for watchdog, in order to be used in monitoring.
""" """
def __init__(self, subdir): def __init__(self, subdir):
""" """
subdir: AIRCOX_SOUND_ARCHIVES_SUBDIR or AIRCOX_SOUND_EXCERPTS_SUBDIR subdir: AIRCOX_SOUND_ARCHIVES_SUBDIR or AIRCOX_SOUND_EXCERPTS_SUBDIR
@ -253,7 +255,8 @@ class Command(BaseCommand):
def report(self, program=None, component=None, *content): def report(self, program=None, component=None, *content):
if not component: 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: else:
logger.info('%s, %s: %s', str(program), str(component), logger.info('%s, %s: %s', str(program), str(component),
' '.join([str(c) for c in content])) ' '.join([str(c) for c in content]))
@ -346,6 +349,7 @@ class Command(BaseCommand):
# update stats # update stats
logger.info('update stats in database') logger.info('update stats in database')
def update_stats(sound_info, sound): def update_stats(sound_info, sound):
stats = sound_info.get_file_stats() stats = sound_info.get_file_stats()
if stats: if stats:
@ -393,7 +397,7 @@ class Command(BaseCommand):
parser.formatter_class = RawTextHelpFormatter parser.formatter_class = RawTextHelpFormatter
parser.add_argument( parser.add_argument(
'-q', '--quality_check', action='store_true', '-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' 'sounds marqued as not good'
) )
parser.add_argument( parser.add_argument(
@ -414,4 +418,3 @@ class Command(BaseCommand):
self.check_quality(check=(not options.get('scan'))) self.check_quality(check=(not options.get('scan')))
if options.get('monitor'): if options.get('monitor'):
self.monitor() self.monitor()

View File

@ -82,17 +82,14 @@ class Monitor:
""" """
Last sound log of monitored station that occurred on_air Last sound log of monitored station that occurred on_air
""" """
return self.get_last_log(type = Log.Type.on_air, return self.get_last_log(type=Log.Type.on_air, sound__isnull=False)
sound__isnull = False)
@property @property
def last_diff_start(self): def last_diff_start(self):
""" """
Log of last triggered item (sound or diffusion) Log of last triggered item (sound or diffusion)
""" """
return self.get_last_log(type = Log.Type.start, return self.get_last_log(type=Log.Type.start, diffusion__isnull=False)
diffusion__isnull = False)
def __init__(self, station, **kwargs): def __init__(self, station, **kwargs):
self.station = station self.station = station
@ -124,8 +121,7 @@ class Monitor:
""" """
Create a log using **kwargs, and print info Create a log using **kwargs, and print info
""" """
log = Log(station = self.station, date = date or tz.now(), log = Log(station=self.station, date=date or tz.now(), **kwargs)
**kwargs)
log.save() log.save()
log.print() log.print()
return log return log
@ -161,16 +157,12 @@ class Monitor:
# log sound on air # log sound on air
return self.log( return self.log(
type = Log.Type.on_air, type=Log.Type.on_air, source=source.id, date=source.on_air,
source = source.id, sound=sound, diffusion=diff,
date = source.on_air,
sound = sound,
diffusion = diff,
# if sound is removed, we keep sound path info # if sound is removed, we keep sound path info
comment=sound_path, comment=sound_path,
) )
def trace_tracks(self, log): def trace_tracks(self, log):
""" """
Log tracks for the given sound log (for streamed programs only). Log tracks for the given sound log (for streamed programs only).
@ -178,13 +170,11 @@ class Monitor:
if log.diffusion: if log.diffusion:
return return
tracks = Track.objects.related(object = log.sound) \ tracks = Track.objects.filter(sound=log.sound, timestamp_isnull=False)
.filter(in_seconds = True)
if not tracks.exists(): if not tracks.exists():
return return
tracks = tracks.exclude(log__station = self.station, tracks = tracks.exclude(log__station=self.station, log__pk__gt=log.pk)
log__pk__gt = log.pk)
now = tz.now() now = tz.now()
for track in tracks: for track in tracks:
pos = log.date + tz.timedelta(seconds=track.position) pos = log.date + tz.timedelta(seconds=track.position)

View File

@ -11,6 +11,7 @@ from django.contrib.contenttypes.fields import (GenericForeignKey,
GenericRelation) GenericRelation)
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.db import models from django.db import models
from django.db.transaction import atomic
from django.template.defaultfilters import slugify from django.template.defaultfilters import slugify
from django.utils import timezone as tz from django.utils import timezone as tz
from django.utils.functional import cached_property from django.utils.functional import cached_property
@ -24,66 +25,6 @@ from taggit.managers import TaggableManager
logger = logging.getLogger('aircox.core') 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): class Nameable(models.Model):
name = models.CharField( name = models.CharField(
_('name'), _('name'),
@ -98,63 +39,14 @@ class Nameable(models.Model):
""" """
Slug based on the name. We replace '-' by '_' Slug based on the name. We replace '-' by '_'
""" """
return slugify(self.name).replace('-', '_') return slugify(self.name).replace('-', '_')
def __str__(self): def __str__(self):
# if self.pk: # if self.pk:
# return '#{} {}'.format(self.pk, self.name) # return '#{} {}'.format(self.pk, self.name)
return '{}'.format(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 # Station related classes
# #
@ -164,15 +56,13 @@ class StationQuerySet(models.QuerySet):
Return station model instance, using defaults or Return station model instance, using defaults or
given one. given one.
""" """
if station is None: if station is None:
return self.order_by('-default', 'pk').first() return self.order_by('-default', 'pk').first()
return self.filter(pk=station).first() return self.filter(pk=station).first()
def default_station(): def default_station():
""" Return default station (used by model fields) """ """ Return default station (used by model fields) """
return Station.objects.default() return Station.objects.default()
@ -208,7 +98,6 @@ class Station(Nameable):
def __prepare_controls(self): def __prepare_controls(self):
import aircox.controllers as controllers import aircox.controllers as controllers
if not self.__streamer: if not self.__streamer:
self.__streamer = controllers.Streamer(station=self) self.__streamer = controllers.Streamer(station=self)
self.__dealer = controllers.Source(station=self) self.__dealer = controllers.Source(station=self)
@ -223,7 +112,6 @@ class Station(Nameable):
""" """
Return all active input ports of the station Return all active input ports of the station
""" """
return self.port_set.filter( return self.port_set.filter(
direction=Port.Direction.input, direction=Port.Direction.input,
active=True active=True
@ -234,7 +122,6 @@ class Station(Nameable):
""" """
Return all active output ports of the station Return all active output ports of the station
""" """
return self.port_set.filter( return self.port_set.filter(
direction=Port.Direction.output, direction=Port.Direction.output,
active=True, active=True,
@ -246,13 +133,11 @@ class Station(Nameable):
Audio sources, dealer included Audio sources, dealer included
""" """
self.__prepare_controls() self.__prepare_controls()
return self.__sources return self.__sources
@property @property
def dealer(self): def dealer(self):
self.__prepare_controls() self.__prepare_controls()
return self.__dealer return self.__dealer
@property @property
@ -261,10 +146,9 @@ class Station(Nameable):
Audio controller for the station Audio controller for the station
""" """
self.__prepare_controls() self.__prepare_controls()
return self.__streamer 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 Return a queryset of what happened on air, based on logs and
diffusions informations. The queryset is sorted by -date. diffusions informations. The queryset is sorted by -date.
@ -279,20 +163,16 @@ class Station(Nameable):
that has been played when there was a live diffusion. that has been played when there was a live diffusion.
""" """
# TODO argument to get sound instead of tracks # TODO argument to get sound instead of tracks
if not date and not count: if not date and not count:
raise ValueError('at least one argument must be set') raise ValueError('at least one argument must be set')
# FIXME can be a potential source of bug # FIXME can be a potential source of bug
if date: 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(): if date and date > datetime.date.today():
return [] return []
now = tz.now() now = tz.now()
if date: if date:
logs = Log.objects.at(date) logs = Log.objects.at(date)
diffs = Diffusion.objects.station(self).at(date) \ diffs = Diffusion.objects.station(self).at(date) \
@ -310,9 +190,7 @@ class Station(Nameable):
logs = logs.station(self).on_air().filter(q).order_by('-date') logs = logs.station(self).on_air().filter(q).order_by('-date')
# filter out tracks played when there was a diffusion # filter out tracks played when there was a diffusion
n = 0 n, q = 0, models.Q()
q = models.Q()
for diff in diffs: for diff in diffs:
if count and n >= count: if count and n >= count:
break break
@ -321,10 +199,8 @@ class Station(Nameable):
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 n += 1
logs = logs.exclude(q, diffusion__isnull=True) logs = logs.exclude(q, diffusion__isnull=True)
if count: if count:
logs = logs[:count] logs = logs[:count]
return logs return logs
def save(self, make_sources=True, *args, **kwargs): def save(self, make_sources=True, *args, **kwargs):
@ -382,12 +258,12 @@ class Program(Nameable):
objects = ProgramManager() objects = ProgramManager()
# TODO: use unique slug
@property @property
def path(self): def path(self):
""" """
Return the path to the programs directory Return the path to the programs directory
""" """
return os.path.join(settings.AIRCOX_PROGRAMS_DIR, return os.path.join(settings.AIRCOX_PROGRAMS_DIR,
self.slug + '_' + str(self.id)) self.slug + '_' + str(self.id))
@ -441,7 +317,8 @@ class Program(Nameable):
self.id, self.name) self.id, self.name)
shutil.move(self.__original_path, self.path) 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: for sound in sounds:
sound.path.replace(self.__original_path, self.path) sound.path.replace(self.__original_path, self.path)
@ -455,9 +332,11 @@ class Program(Nameable):
""" """
path = path.replace(settings.AIRCOX_PROGRAMS_DIR, '') 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: if '/' in path:
path = path[:path.index('/')] path = path[:path.index('/')]
@ -529,9 +408,8 @@ class Schedule(models.Model):
one_on_two = 0b100000 one_on_two = 0b100000
program = models.ForeignKey( program = models.ForeignKey(
Program, Program, models.CASCADE,
verbose_name=_('related program'), verbose_name=_('related program'),
on_delete=models.CASCADE,
) )
time = models.TimeField( time = models.TimeField(
_('time'), _('time'),
@ -545,9 +423,8 @@ class Schedule(models.Model):
) )
timezone = models.CharField( timezone = models.CharField(
_('timezone'), _('timezone'),
default = tz.get_current_timezone, default=tz.get_current_timezone, max_length=100,
choices=[(x, x) for x in pytz.all_timezones], choices=[(x, x) for x in pytz.all_timezones],
max_length = 100,
help_text=_('timezone used for the date') help_text=_('timezone used for the date')
) )
duration = models.TimeField( duration = models.TimeField(
@ -556,8 +433,7 @@ class Schedule(models.Model):
) )
frequency = models.SmallIntegerField( frequency = models.SmallIntegerField(
_('frequency'), _('frequency'),
choices = [ choices=[(int(y), {
(int(y), {
'ponctual': _('ponctual'), 'ponctual': _('ponctual'),
'first': _('first week of the month'), 'first': _('first week of the month'),
'second': _('second week of the month'), 'second': _('second week of the month'),
@ -568,18 +444,15 @@ class Schedule(models.Model):
'second_and_fourth': _('second and fourth weeks of the month'), 'second_and_fourth': _('second and fourth weeks of the month'),
'every': _('every week'), 'every': _('every week'),
'one_on_two': _('one week on two'), 'one_on_two': _('one week on two'),
}[x]) for x,y in Frequency.__members__.items() }[x]) for x, y in Frequency.__members__.items()],
],
) )
initial = models.ForeignKey( initial = models.ForeignKey(
'self', 'self', models.SET_NULL,
verbose_name=_('initial schedule'), verbose_name=_('initial schedule'),
blank=True, null=True, blank=True, null=True,
on_delete=models.SET_NULL,
help_text=_('this schedule is a rerun of this one'), help_text=_('this schedule is a rerun of this one'),
) )
@cached_property @cached_property
def tz(self): def tz(self):
""" """
@ -622,9 +495,8 @@ class Schedule(models.Model):
# we check against a normalized version (norm_date will have # we check against a normalized version (norm_date will have
# schedule's date. # 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):
""" """
@ -644,7 +516,8 @@ class Schedule(models.Model):
if self.frequency == Schedule.Frequency.one_on_two: if self.frequency == Schedule.Frequency.one_on_two:
# cf notes in date_of_month # 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) return not (diff.days % 14)
@ -690,7 +563,8 @@ class Schedule(models.Model):
# last of the month # last of the month
if freq == Schedule.Frequency.last: 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 # end of month before the wanted weekday: move one week back
@ -706,7 +580,7 @@ class Schedule(models.Model):
# check on SO#3284452 for the formula # check on SO#3284452 for the formula
first_weekday = date.weekday() first_weekday = date.weekday()
sched_weekday = self.date.weekday() sched_weekday = self.date.weekday()
date += tz.timedelta(days = (7 if first_weekday > sched_weekday else 0) \ date += tz.timedelta(days=(7 if first_weekday > sched_weekday else 0)
- first_weekday + sched_weekday) - first_weekday + sched_weekday)
month = date.month month = date.month
@ -714,7 +588,8 @@ class Schedule(models.Model):
if freq == Schedule.Frequency.one_on_two: if freq == Schedule.Frequency.one_on_two:
# check date base on a diff of dates base on a 14 days delta # 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: if diff.days % 14:
date += tz.timedelta(days=7) date += tz.timedelta(days=7)
@ -729,7 +604,7 @@ class Schedule(models.Model):
if freq & (0b1 << week): if freq & (0b1 << week):
dates.append(date) dates.append(date)
date += tz.timedelta(days=7) date += tz.timedelta(days=7)
week += 1; week += 1
return [self.normalize(date) for date in dates] return [self.normalize(date) for date in dates]
@ -767,8 +642,7 @@ class Schedule(models.Model):
Diffusion( Diffusion(
program=self.program, program=self.program,
type=Diffusion.Type.unconfirmed, type=Diffusion.Type.unconfirmed,
initial = \ initial=Diffusion.objects.filter(start=date - delta).first()
Diffusion.objects.filter(start = date - delta).first() \
if self.initial else None, if self.initial else None,
start=date, start=date,
end=date + duration, end=date + duration,
@ -933,8 +807,6 @@ class Diffusion(models.Model):
start = models.DateTimeField(_('start of the diffusion')) start = models.DateTimeField(_('start of the diffusion'))
end = models.DateTimeField(_('end of the diffusion')) end = models.DateTimeField(_('end of the diffusion'))
tracks = Track.ReverseField()
@property @property
def duration(self): def duration(self):
return self.end - self.start return self.end - self.start
@ -981,16 +853,15 @@ class Diffusion(models.Model):
return self.type == self.Type.normal and \ 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): def get_playlist(self, **types):
""" """
Returns sounds as a playlist (list of *local* archive file path). Returns sounds as a playlist (list of *local* archive file path).
The given arguments are passed to ``get_sounds``. The given arguments are passed to ``get_sounds``.
""" """
return list(self.get_sounds(**types) \ return list(self.get_sounds(**types)
.filter(path__isnull=False, .filter(path__isnull=False,
type=Sound.Type.archive) \ type=Sound.Type.archive)
.values_list('path', flat=True)) .values_list('path', flat=True))
def get_sounds(self, **types): def get_sounds(self, **types):
@ -1025,7 +896,7 @@ class Diffusion(models.Model):
end__gt=self.start) | end__gt=self.start) |
models.Q(start__gt=self.start, models.Q(start__gt=self.start,
start__lt=self.end) start__lt=self.end)
) ).exclude(pk=self.pk).distinct()
def check_conflicts(self): def check_conflicts(self):
conflicts = self.get_conflicts() conflicts = self.get_conflicts()
@ -1056,7 +927,6 @@ class Diffusion(models.Model):
self.end != self.__initial['end']: self.end != self.__initial['end']:
self.check_conflicts() self.check_conflicts()
def __str__(self): def __str__(self):
return '{self.program.name} {date} #{self.pk}'.format( return '{self.program.name} {date} #{self.pk}'.format(
self=self, date=self.local_date.strftime('%Y/%m/%d %H:%M%z') self=self, date=self.local_date.strftime('%Y/%m/%d %H:%M%z')
@ -1065,7 +935,6 @@ class Diffusion(models.Model):
class Meta: class Meta:
verbose_name = _('Diffusion') verbose_name = _('Diffusion')
verbose_name_plural = _('Diffusions') verbose_name_plural = _('Diffusions')
permissions = ( permissions = (
('programming', _('edit the diffusion\'s planification')), ('programming', _('edit the diffusion\'s planification')),
) )
@ -1139,8 +1008,6 @@ class Sound(Nameable):
help_text=_('the sound is accessible to the public') help_text=_('the sound is accessible to the public')
) )
tracks = Track.ReverseField()
def get_mtime(self): def get_mtime(self):
""" """
Get the last modification date from file Get the last modification date from file
@ -1174,7 +1041,6 @@ class Sound(Nameable):
Get metadata from sound file and return a Track object if succeed, Get metadata from sound file and return a Track object if succeed,
else None. else None.
""" """
if not self.file_exists(): if not self.file_exists():
return None return None
@ -1189,7 +1055,6 @@ class Sound(Nameable):
def get_meta(key, cast=str): def get_meta(key, cast=str):
value = meta.get(key) value = meta.get(key)
return cast(value[0]) if value else None return cast(value[0]) if value else None
info = '{} ({})'.format(get_meta('album'), get_meta('year')) \ info = '{} ({})'.format(get_meta('album'), get_meta('year')) \
@ -1198,15 +1063,11 @@ class Sound(Nameable):
if 'album' else \ if 'album' else \
('year' in meta) and get_meta('year') or '' ('year' in meta) and get_meta('year') or ''
track = Track( return Track(sound=self,
related = self, position=get_meta('tracknumber', int) or 0,
title=get_meta('title') or self.name, title=get_meta('title') or self.name,
artist=get_meta('artist') or _('unknown'), artist=get_meta('artist') or _('unknown'),
info = info, info=info)
position = get_meta('tracknumber', int) or 0,
)
return track
def check_on_file(self): def check_on_file(self):
""" """
@ -1290,6 +1151,64 @@ class Sound(Nameable):
verbose_name_plural = _('Sounds') 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 # Controls and audio input/output
# #
@ -1485,7 +1404,7 @@ class LogQuerySet(models.QuerySet):
import gzip import gzip
os.makedirs(settings.AIRCOX_LOGS_ARCHIVES_DIR, exist_ok=True) os.makedirs(settings.AIRCOX_LOGS_ARCHIVES_DIR, exist_ok=True)
path = self._get_archive_path(station, date); path = self._get_archive_path(station, date)
if os.path.exists(path) and not force: if os.path.exists(path) and not force:
return -1 return -1
@ -1496,15 +1415,8 @@ class LogQuerySet(models.QuerySet):
return 0 return 0
fields = Log._meta.get_fields() fields = Log._meta.get_fields()
logs = [ logs = [{i.attname: getattr(log, i.attname)
{ for i in fields} for log in qs]
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 # Note: since we use Yaml, we can just append new logs when file
# exists yet <3 # exists yet <3
@ -1554,55 +1466,46 @@ class Log(models.Model):
""" """
type = models.SmallIntegerField( type = models.SmallIntegerField(
verbose_name = _('type'), choices=[(int(y), _(x.replace('_', ' ')))
choices = [ (int(y), _(x.replace('_',' '))) for x,y in Type.__members__.items() ], for x, y in Type.__members__.items()],
blank=True, null=True, blank=True, null=True,
verbose_name=_('type'),
) )
station = models.ForeignKey( station = models.ForeignKey(
Station, Station, on_delete=models.CASCADE,
verbose_name=_('station'), verbose_name=_('station'),
on_delete=models.CASCADE,
help_text=_('related station'), help_text=_('related station'),
) )
source = models.CharField( source = models.CharField(
# we use a CharField to avoid loosing logs information if the # we use a CharField to avoid loosing logs information if the
# source is removed # source is removed
_('source'), max_length=64, blank=True, null=True,
max_length=64, verbose_name=_('source'),
help_text=_('identifier of the source related to this log'), help_text=_('identifier of the source related to this log'),
blank = True, null = True,
) )
date = models.DateTimeField( date = models.DateTimeField(
_('date'), default=tz.now, db_index=True,
default=tz.now, verbose_name=_('date'),
db_index = True,
) )
comment = models.CharField( comment = models.CharField(
_('comment'), max_length=512, blank=True, null=True,
max_length = 512, verbose_name=_('comment'),
blank = True, null = True,
) )
diffusion = models.ForeignKey( diffusion = models.ForeignKey(
Diffusion, Diffusion, on_delete=models.SET_NULL,
blank=True, null=True, db_index=True,
verbose_name=_('Diffusion'), verbose_name=_('Diffusion'),
blank = True, null = True,
db_index = True,
on_delete=models.SET_NULL,
) )
sound = models.ForeignKey( sound = models.ForeignKey(
Sound, Sound, on_delete=models.SET_NULL,
blank=True, null=True, db_index=True,
verbose_name=_('Sound'), verbose_name=_('Sound'),
blank = True, null = True,
db_index = True,
on_delete=models.SET_NULL,
) )
track = models.ForeignKey( track = models.ForeignKey(
Track, Track, on_delete=models.SET_NULL,
blank=True, null=True, db_index=True,
verbose_name=_('Track'), verbose_name=_('Track'),
blank = True, null = True,
db_index = True,
on_delete=models.SET_NULL,
) )
objects = LogQuerySet.as_manager() 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 This is needed since datetime are stored as UTC date and we want
to get it as local time. to get it as local time.
""" """
return tz.localtime(self.date, tz.get_current_timezone()) return tz.localtime(self.date, tz.get_current_timezone())
def print(self): def print(self):
r = [] r = []
if self.diffusion: if self.diffusion:
r.append('diff: ' + str(self.diffusion_id)) r.append('diff: ' + str(self.diffusion_id))
if self.sound: if self.sound:
r.append('sound: ' + str(self.sound_id)) r.append('sound: ' + str(self.sound_id))
if self.track: if self.track:
r.append('track: ' + str(self.track_id)) r.append('track: ' + str(self.track_id))
logger.info('log %s: %s%s', str(self), self.comment or '',
logger.info('log %s: %s%s', ' (' + ', '.join(r) + ')' if r else '')
str(self),
self.comment or '',
' (' + ', '.join(r) + ')' if r else ''
)
def __str__(self): def __str__(self):
return '#{} ({}, {}, {})'.format( return '#{} ({}, {}, {})'.format(
self.pk, self.pk, self.get_type_display(),
self.get_type_display(),
self.source, self.source,
self.local_date.strftime('%Y/%m/%d %H:%M%z'), self.local_date.strftime('%Y/%m/%d %H:%M%z'),
) )

View File

@ -0,0 +1,6 @@
{% load static i18n %}
{% with inline_admin_formset.formset.instance as playlist %}
{% include "adminsortable2/tabular.html" %}
{% endwith %}

View File

@ -78,8 +78,7 @@ A stream is a source that:
- is interactive - is interactive
{% endcomment %} {% endcomment %}
def stream (id, file) = def stream (id, file) =
s = playlist(id = '#{id}_playlist', mode = "random", reload_mode='watch', s = playlist(id = '#{id}_playlist', mode = "random", reload_mode='watch', file)
file)
interactive_source(id, s) interactive_source(id, s)
end end
{% endblock %} {% endblock %}

View File

@ -39,7 +39,7 @@ def on_air(request):
except: except:
cms = None cms = None
station = request.GET.get('station'); station = request.GET.get('station')
if station: if station:
# FIXME: by name??? # FIXME: by name???
station = stations.stations.filter(name=station) station = stations.stations.filter(name=station)
@ -55,16 +55,13 @@ def on_air(request):
last = on_air.first() last = on_air.first()
if last.track: if last.track:
last = { last = {'date': last.date, 'type': 'track',
'type': 'track', 'artist': last.track.artist, 'title': last.track.title}
'artist': last.related.artist,
'title': last.related.title,
'date': last.date,
}
else: else:
try: try:
diff = last.diffusion diff = last.diffusion
publication = None publication = None
# FIXME CMS
if cms: if cms:
publication = \ publication = \
cms.DiffusionPage.objects.filter( cms.DiffusionPage.objects.filter(
@ -73,14 +70,9 @@ def on_air(request):
program=last.program).first() program=last.program).first()
except: except:
pass pass
last = {'date': diff.start, 'type': 'diffusion',
last = {
'type': 'diffusion',
'title': diff.program.name, 'title': diff.program.name,
'date': diff.start, 'url': publication.specific.url if publication else None}
'url': publication.specific.url if publication else None,
}
last['date'] = str(last['date']) last['date'] = str(last['date'])
return HttpResponse(json.dumps(last)) return HttpResponse(json.dumps(last))
@ -145,7 +137,7 @@ class StatisticsView(View,TemplateResponseMixin,LoginRequiredMixin):
""" """
View for statistics. 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' template_name = 'aircox/controllers/stats.html'
class Item: class Item:
@ -194,7 +186,6 @@ class StatisticsView(View,TemplateResponseMixin,LoginRequiredMixin):
# # all other cases: new row # # all other cases: new row
# self.rows.append((None, [log])) # self.rows.append((None, [log]))
def get_stats(self, station, date): def get_stats(self, station, date):
""" """
Return statistics for the given station and date. Return statistics for the given station and date.
@ -209,37 +200,26 @@ class StatisticsView(View,TemplateResponseMixin,LoginRequiredMixin):
sound_log = None sound_log = None
for log in qs: for log in qs:
rel = None rel, item = None, None
item = None
if log.diffusion: if log.diffusion:
rel = log.diffusion rel, item = log.diffusion, self.Item(
item = self.Item( name=rel.program.name, type=_('Diffusion'), col=0,
name = rel.program.name, tracks=models.Track.objects.filter(diffusion=log.diffusion)
type = _('Diffusion'),
col = 0,
tracks = models.Track.objects.related(object = rel)
.prefetch_related('tags'), .prefetch_related('tags'),
) )
sound_log = None sound_log = None
elif log.sound: elif log.sound:
rel = log.sound rel, item = log.sound, self.Item(
item = self.Item(
name=rel.program.name + ': ' + os.path.basename(rel.path), name=rel.program.name + ': ' + os.path.basename(rel.path),
type = _('Stream'), type=_('Stream'), col=1, tracks=[],
col = 1,
tracks = [],
) )
sound_log = item sound_log = item
elif log.track: elif log.track:
# append to last sound log # append to last sound log
if not sound_log: if not sound_log:
# TODO: create item ? should never happen
continue continue
sound_log.tracks.append(log.track) sound_log.tracks.append(log.track)
sound_log.end = log.end sound_log.end = log.end
sound_log
continue continue
item.date = log.date item.date = log.date
@ -247,7 +227,6 @@ class StatisticsView(View,TemplateResponseMixin,LoginRequiredMixin):
item.related = rel item.related = rel
# stats.append(item) # stats.append(item)
stats.items.append(item) stats.items.append(item)
return stats return stats
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
@ -279,4 +258,3 @@ class StatisticsView(View,TemplateResponseMixin,LoginRequiredMixin):
return render(request, self.template_name, context) return render(request, self.template_name, context)

0
aircox_web/__init__.py Normal file
View File

68
aircox_web/admin.py Normal file
View File

@ -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

5
aircox_web/apps.py Normal file
View File

@ -0,0 +1,5 @@
from django.apps import AppConfig
class AircoxWebConfig(AppConfig):
name = 'aircox_web'

View File

@ -0,0 +1 @@
import './js';

View File

@ -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',
})

117
aircox_web/models.py Normal file
View File

@ -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,
)

26
aircox_web/package.json Normal file
View File

@ -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"
}
}

20
aircox_web/renderer.py Normal file
View File

@ -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(
'<figure><img src="{}" alt=""/><figcaption>{}</figcaption></figure>',
plugin.image.url,
plugin.caption,
),
)

View File

@ -0,0 +1,34 @@
{% load static thumbnail %}
<html>
<head>
<meta charset="utf-8">
<meta name="application-name" content="aircox">
<meta name="description" content="{{ site_settings.description }}">
<meta name="keywords" content="{{ site_settings.tags }}">
<link rel="icon" href="{% thumbnail site_settings.favicon 32x32 crop %}" />
{% block assets %}
<link rel="stylesheet" type="text/css" href="{% static "aircox_web/assets/main.css" %}"/>
<link rel="stylesheet" type="text/css" href="{% static "aircox_web/assets/vendor.css" %}"/>
<script src="{% static "aircox_web/assets/main.js" %}"></script>
<script src="{% static "aircox_web/assets/vendor.js" %}"></script>
{% endblock %}
<title>{% block title %}{{ site_settings.title }}{% endblock %}</title>
{% block extra_head %}{% endblock %}
</head>
<body id="app">
<nav class="navbar" role="navigation" aria-label="main navigation">
</nav>
<main>
{% block main %}
{% endblock main %}
</main>
</body>
</html>

View File

@ -0,0 +1,8 @@
{% extends "aircox_web/base.html" %}
{% block title %}{{ page.title }} -- {{ block.super }}{% endblock %}
{% block main %}
<h1 class="title">{{ page.title }}</h1>
{% endblock %}

3
aircox_web/tests.py Normal file
View File

@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

9
aircox_web/urls.py Normal file
View File

@ -0,0 +1,9 @@
from django.conf.urls import url
from . import views
urlpatterns = [
url(r"^(?P<path>[-\w/]+)/$", views.page_detail, name="page"),
url(r"^$", views.page_detail, name="root"),
]

22
aircox_web/views.py Normal file
View File

@ -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
),
})

View File

@ -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']
},
})

View File

@ -152,7 +152,6 @@ MIDDLEWARE = (
'django.middleware.common.CommonMiddleware', 'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware', 'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware', 'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.auth.middleware.SessionAuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware', 'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware', 'django.middleware.clickjacking.XFrameOptionsMiddleware',
'django.middleware.security.SecurityMiddleware', 'django.middleware.security.SecurityMiddleware',

View File

@ -17,24 +17,18 @@ from django.conf import settings
from django.urls import include, path, re_path from django.urls import include, path, re_path
from django.contrib import admin from django.contrib import admin
from wagtail.admin import urls as wagtailadmin_urls #from wagtail.admin import urls as wagtailadmin_urls
from wagtail.documents import urls as wagtaildocs_urls #from wagtail.documents import urls as wagtaildocs_urls
from wagtail.core import urls as wagtail_urls #from wagtail.core import urls as wagtail_urls
from wagtail.images.views.serve import ServeView #from wagtail.images.views.serve import ServeView
import aircox.urls import aircox.urls
import aircox_web.urls
try: try:
urlpatterns = [ urlpatterns = [
path('jet/', include('jet.urls', 'jet')),
path('admin/', admin.site.urls), path('admin/', admin.site.urls),
path('aircox/', include(aircox.urls.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: 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: except Exception as e:
import traceback import traceback

View File

@ -1,14 +1,20 @@
gunicorn>=19.6.0
Django>=2.2.0 Django>=2.2.0
wagtail>=2.4 djangorestframework>=3.9.4
dateutils>=0.6.6
watchdog>=0.8.3 watchdog>=0.8.3
psutil>=5.0.1 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 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