work on website; fix stuffs on aircox too

This commit is contained in:
bkfox 2019-07-22 01:37:25 +02:00
parent 08d1c7bfac
commit 3e432c42b0
35 changed files with 1089 additions and 416 deletions

View File

@ -84,8 +84,8 @@ class StationAdmin(admin.ModelAdmin):
@admin.register(Log) @admin.register(Log)
class LogAdmin(admin.ModelAdmin): class LogAdmin(admin.ModelAdmin):
list_display = ['id', 'date', 'station', 'source', 'type', 'comment', 'diffusion', 'sound', 'track'] list_display = ['id', 'date', 'station', 'source', 'type', 'diffusion', 'sound', 'track']
list_filter = ['date', 'source', 'diffusion', 'sound', 'track'] list_filter = ['date', 'source', 'station']
admin.site.register(Port) admin.site.register(Port)

View File

@ -34,7 +34,7 @@ class DiffusionAdmin(admin.ModelAdmin):
conflicts_count.short_description = _('Conflicts') conflicts_count.short_description = _('Conflicts')
def start_date(self, obj): def start_date(self, obj):
return obj.local_date.strftime('%Y/%m/%d %H:%M') return obj.local_start.strftime('%Y/%m/%d %H:%M')
start_date.short_description = _('start') start_date.short_description = _('start')
def end_date(self, obj): def end_date(self, obj):

View File

@ -18,11 +18,10 @@ class TracksInline(SortableInlineAdminMixin, admin.TabularInline):
@admin.register(Track) @admin.register(Track)
class TrackAdmin(admin.ModelAdmin): class TrackAdmin(admin.ModelAdmin):
# TODO: url to filter by tag
def tag_list(self, obj): def tag_list(self, obj):
return u", ".join(o.name for o in obj.tags.all()) return u", ".join(o.name for o in obj.tags.all())
list_display = ['pk', 'artist', 'title', 'tag_list', 'diffusion', 'sound'] list_display = ['pk', 'artist', 'title', 'tag_list', 'diffusion', 'sound', 'timestamp']
list_editable = ['artist', 'title'] list_editable = ['artist', 'title']
list_filter = ['sound', 'diffusion', 'artist', 'title', 'tags'] list_filter = ['sound', 'diffusion', 'artist', 'title', 'tags']
fieldsets = [ fieldsets = [

View File

@ -16,14 +16,14 @@ logger = logging.getLogger('aircox.tools')
class Command (BaseCommand): class Command (BaseCommand):
help= __doc__ help = __doc__
def add_arguments (self, parser): def add_arguments(self, parser):
parser.formatter_class=RawTextHelpFormatter parser.formatter_class = RawTextHelpFormatter
group = parser.add_argument_group('actions') group = parser.add_argument_group('actions')
group.add_argument( group.add_argument(
'-a', '--age', type=int, '-a', '--age', type=int,
default = settings.AIRCOX_LOGS_ARCHIVES_MIN_AGE, default=settings.AIRCOX_LOGS_ARCHIVES_MIN_AGE,
help='minimal age in days of logs to archive. Default is ' help='minimal age in days of logs to archive. Default is '
'settings.AIRCOX_LOGS_ARCHIVES_MIN_AGE' 'settings.AIRCOX_LOGS_ARCHIVES_MIN_AGE'
) )
@ -36,22 +36,21 @@ class Command (BaseCommand):
help='keep logs in database instead of deleting them' help='keep logs in database instead of deleting them'
) )
def handle (self, *args, age, force, keep, **options): def handle(self, *args, age, force, keep, **options):
date = tz.now() - tz.timedelta(days = age) date = tz.now() - tz.timedelta(days=age)
while True: while True:
date = date.replace( date = date.replace(
hour = 0, minute = 0, second = 0, microsecond = 0 hour=0, minute=0, second=0, microsecond=0
) )
logger.info('archive log at date %s', date) logger.info('archive log at date %s', date)
for station in Station.objects.all(): for station in Station.objects.all():
Log.objects.make_archive( Log.objects.make_archive(
station, date, force = force, keep = keep station, date, force=force, keep=keep
) )
qs = Log.objects.filter(date__lt = date) qs = Log.objects.filter(date__lt=date)
if not qs.exists(): if not qs.exists():
break break
date = qs.order_by('-date').first().date date = qs.order_by('-date').first().date

View File

@ -15,6 +15,7 @@ planified before the (given) month.
- "check" will remove all diffusions that are unconfirmed and have been planified - "check" will remove all diffusions that are unconfirmed and have been planified
from the (given) month and later. from the (given) month and later.
""" """
import time
import logging import logging
from argparse import RawTextHelpFormatter from argparse import RawTextHelpFormatter
@ -25,53 +26,52 @@ from aircox.models import *
logger = logging.getLogger('aircox.tools') logger = logging.getLogger('aircox.tools')
import time
class Actions: class Actions:
@classmethod @classmethod
def update (cl, date, mode): def update(cl, date, mode):
manual = (mode == 'manual') manual = (mode == 'manual')
count = [0, 0] count = [0, 0]
for schedule in Schedule.objects.filter(program__active = True) \ for schedule in Schedule.objects.filter(program__active=True) \
.order_by('initial'): .order_by('initial'):
# in order to allow rerun links between diffusions, we save items # in order to allow rerun links between diffusions, we save items
# by schedule; # by schedule;
items = schedule.diffusions_of_month(date, exclude_saved = True) items = schedule.diffusions_of_month(date, exclude_saved=True)
count[0] += len(items) count[0] += len(items)
# we can't bulk create because we need signal processing # we can't bulk create because we need signal processing
for item in items: for item in items:
conflicts = item.get_conflicts() conflicts = item.get_conflicts()
item.type = Diffusion.Type.unconfirmed \ item.type = Diffusion.Type.unconfirmed \
if manual or conflicts.count() else \ if manual or conflicts.count() else \
Diffusion.Type.normal Diffusion.Type.normal
item.save(no_check = True) item.save(no_check=True)
if conflicts.count(): if conflicts.count():
item.conflicts.set(conflicts.all()) item.conflicts.set(conflicts.all())
logger.info('[update] schedule %s: %d new diffusions', logger.info('[update] schedule %s: %d new diffusions',
str(schedule), len(items), str(schedule), len(items),
) )
logger.info('[update] %d diffusions have been created, %s', count[0], logger.info('[update] %d diffusions have been created, %s', count[0],
'do not forget manual approval' if manual else 'do not forget manual approval' if manual else
'{} conflicts found'.format(count[1])) '{} conflicts found'.format(count[1]))
@staticmethod @staticmethod
def clean (date): def clean(date):
qs = Diffusion.objects.filter(type = Diffusion.Type.unconfirmed, qs = Diffusion.objects.filter(type=Diffusion.Type.unconfirmed,
start__lt = date) start__lt=date)
logger.info('[clean] %d diffusions will be removed', qs.count()) logger.info('[clean] %d diffusions will be removed', qs.count())
qs.delete() qs.delete()
@staticmethod @staticmethod
def check(date): def check(date):
qs = Diffusion.objects.filter(type = Diffusion.Type.unconfirmed, qs = Diffusion.objects.filter(type=Diffusion.Type.unconfirmed,
start__gt = date) start__gt=date)
items = [] items = []
for diffusion in qs: for diffusion in qs:
schedules = Schedule.objects.filter(program = diffusion.program) schedules = Schedule.objects.filter(program=diffusion.program)
for schedule in schedules: for schedule in schedules:
if schedule.match(diffusion.start): if schedule.match(diffusion.start):
break break
@ -80,14 +80,14 @@ class Actions:
logger.info('[check] %d diffusions will be removed', len(items)) logger.info('[check] %d diffusions will be removed', len(items))
if len(items): if len(items):
Diffusion.objects.filter(id__in = items).delete() Diffusion.objects.filter(id__in=items).delete()
class Command(BaseCommand): class Command(BaseCommand):
help= __doc__ help = __doc__
def add_arguments (self, parser): def add_arguments(self, parser):
parser.formatter_class=RawTextHelpFormatter parser.formatter_class = RawTextHelpFormatter
now = tz.datetime.today() now = tz.datetime.today()
group = parser.add_argument_group('action') group = parser.add_argument_group('action')
@ -130,23 +130,22 @@ class Command(BaseCommand):
'diffusions except those that conflicts with others' 'diffusions except those that conflicts with others'
) )
def handle (self, *args, **options): def handle(self, *args, **options):
date = tz.datetime(year = options.get('year'), date = tz.datetime(year=options.get('year'),
month = options.get('month'), month=options.get('month'),
day = 1) day=1)
date = tz.make_aware(date) date = tz.make_aware(date)
if options.get('next_month'): if options.get('next_month'):
month = options.get('month') month = options.get('month')
date += tz.timedelta(days = 28) date += tz.timedelta(days=28)
if date.month == month: if date.month == month:
date += tz.timedelta(days = 28) date += tz.timedelta(days=28)
date = date.replace(day = 1) date = date.replace(day=1)
if options.get('update'): if options.get('update'):
Actions.update(date, mode = options.get('mode')) Actions.update(date, mode=options.get('mode'))
if options.get('clean'): if options.get('clean'):
Actions.clean(date) Actions.clean(date)
if options.get('check'): if options.get('check'):
Actions.check(date) Actions.check(date)

View File

@ -20,7 +20,7 @@ from django.contrib.contenttypes.models import ContentType
from aircox.models import * from aircox.models import *
import aircox.settings as settings import aircox.settings as settings
__doc__ = __doc__.format(settings = settings) __doc__ = __doc__.format(settings=settings)
logger = logging.getLogger('aircox.tools') logger = logging.getLogger('aircox.tools')
@ -78,8 +78,8 @@ class Importer:
return return
try: try:
timestamp = int(line.get('minutes') or 0) * 60 + \ timestamp = int(line.get('minutes') or 0) * 60 + \
int(line.get('seconds') or 0) \ int(line.get('seconds') or 0) \
if has_timestamp else None if has_timestamp else None
track, created = Track.objects.get_or_create( track, created = Track.objects.get_or_create(
title=line.get('title'), title=line.get('title'),
@ -88,6 +88,7 @@ class Importer:
**self.track_kwargs **self.track_kwargs
) )
track.timestamp = timestamp track.timestamp = timestamp
print('track', track, timestamp)
track.info = line.get('info') track.info = line.get('info')
tags = line.get('tags') tags = line.get('tags')
if tags: if tags:
@ -96,7 +97,7 @@ class Importer:
logger.warning( logger.warning(
'an error occured for track {index}, it may not ' 'an error occured for track {index}, it may not '
'have been saved: {err}' 'have been saved: {err}'
.format(index = index, err=err) .format(index=index, err=err)
) )
continue continue
@ -107,10 +108,10 @@ class Importer:
class Command (BaseCommand): class Command (BaseCommand):
help= __doc__ help = __doc__
def add_arguments(self, parser): def add_arguments(self, parser):
parser.formatter_class=RawTextHelpFormatter parser.formatter_class = RawTextHelpFormatter
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'
@ -125,7 +126,7 @@ class Command (BaseCommand):
help='try to get the diffusion relative to the sound if it exists' help='try to get the diffusion relative to the sound if it exists'
) )
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'):
sound = Sound.objects.filter( sound = Sound.objects.filter(
@ -136,7 +137,7 @@ class Command (BaseCommand):
sound = Sound.objects.filter(path__icontains=path_).first() sound = Sound.objects.filter(path__icontains=path_).first()
if not sound: 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
@ -148,4 +149,3 @@ class Command (BaseCommand):
logger.info('track #{pos} imported: {title}, by {artist}'.format( logger.info('track #{pos} imported: {title}, by {artist}'.format(
pos=track.position, title=track.title, artist=track.artist pos=track.position, title=track.title, artist=track.artist
)) ))

View File

@ -11,6 +11,7 @@ from django.core.management.base import BaseCommand, CommandError
logger = logging.getLogger('aircox.tools') logger = logging.getLogger('aircox.tools')
class Stats: class Stats:
attributes = [ attributes = [
'DC offset', 'Min level', 'Max level', 'DC offset', 'Min level', 'Max level',
@ -18,7 +19,7 @@ class Stats:
'RMS Tr dB', 'Flat factor', 'Length s', 'RMS Tr dB', 'Flat factor', 'Length s',
] ]
def __init__ (self, path, **kwargs): def __init__(self, path, **kwargs):
""" """
If path is given, call analyse with path and kwargs If path is given, call analyse with path and kwargs
""" """
@ -26,10 +27,10 @@ class Stats:
if path: if path:
self.analyse(path, **kwargs) self.analyse(path, **kwargs)
def get (self, attr): def get(self, attr):
return self.values.get(attr) return self.values.get(attr)
def parse (self, output): def parse(self, output):
for attr in Stats.attributes: for attr in Stats.attributes:
value = re.search(attr + r'\s+(?P<value>\S+)', output) value = re.search(attr + r'\s+(?P<value>\S+)', output)
value = value and value.groupdict() value = value and value.groupdict()
@ -41,14 +42,14 @@ class Stats:
self.values[attr] = value self.values[attr] = value
self.values['length'] = self.values['Length s'] self.values['length'] = self.values['Length s']
def analyse (self, path, at = None, length = None): def analyse(self, path, at=None, length=None):
""" """
If at and length are given use them as excerpt to analyse. If at and length are given use them as excerpt to analyse.
""" """
args = ['sox', path, '-n'] args = ['sox', path, '-n']
if at is not None and length is not None: if at is not None and length is not None:
args += ['trim', str(at), str(length) ] args += ['trim', str(at), str(length)]
args.append('stats') args.append('stats')
@ -66,17 +67,17 @@ class Sound:
bad = None # list of bad samples bad = None # list of bad samples
good = None # list of good samples good = None # list of good samples
def __init__ (self, path, sample_length = None): def __init__(self, path, sample_length=None):
self.path = path self.path = path
self.sample_length = sample_length if sample_length is not None \ self.sample_length = sample_length if sample_length is not None \
else self.sample_length else self.sample_length
def get_file_stats (self): def get_file_stats(self):
return self.stats and self.stats[0] return self.stats and self.stats[0]
def analyse (self): def analyse(self):
logger.info('complete file analysis') logger.info('complete file analysis')
self.stats = [ Stats(self.path) ] self.stats = [Stats(self.path)]
position = 0 position = 0
length = self.stats[0].get('length') length = self.stats[0].get('length')
@ -85,21 +86,22 @@ class Sound:
logger.info('start samples analysis...') logger.info('start samples analysis...')
while position < length: while position < length:
stats = Stats(self.path, at = position, length = self.sample_length) stats = Stats(self.path, at=position, length=self.sample_length)
self.stats.append(stats) self.stats.append(stats)
position += self.sample_length position += self.sample_length
def check (self, name, min_val, max_val): def check(self, name, min_val, max_val):
self.good = [ index for index, stats in enumerate(self.stats) self.good = [index for index, stats in enumerate(self.stats)
if min_val <= stats.get(name) <= max_val ] if min_val <= stats.get(name) <= max_val]
self.bad = [ index for index, stats in enumerate(self.stats) self.bad = [index for index, stats in enumerate(self.stats)
if index not in self.good ] if index not in self.good]
self.resume() self.resume()
def resume (self): def resume(self):
view = lambda array: [ def view(array): return [
'file' if index is 0 else 'file' if index is 0 else
'sample {} (at {} seconds)'.format(index, (index-1) * self.sample_length) 'sample {} (at {} seconds)'.format(
index, (index-1) * self.sample_length)
for index in array for index in array
] ]
@ -110,12 +112,13 @@ class Sound:
logger.info(self.path + ' -> bad: \033[91m%s\033[0m', logger.info(self.path + ' -> bad: \033[91m%s\033[0m',
', '.join(view(self.bad))) ', '.join(view(self.bad)))
class Command (BaseCommand): class Command (BaseCommand):
help = __doc__ help = __doc__
sounds = None sounds = None
def add_arguments (self, parser): def add_arguments(self, parser):
parser.formatter_class=RawTextHelpFormatter parser.formatter_class = RawTextHelpFormatter
parser.add_argument( parser.add_argument(
'files', metavar='FILE', type=str, nargs='+', 'files', metavar='FILE', type=str, nargs='+',
@ -128,12 +131,12 @@ class Command (BaseCommand):
) )
parser.add_argument( parser.add_argument(
'-a', '--attribute', type=str, '-a', '--attribute', type=str,
help='attribute name to use to check, that can be:\n' + \ help='attribute name to use to check, that can be:\n' +
', '.join([ '"{}"'.format(attr) for attr in Stats.attributes ]) ', '.join(['"{}"'.format(attr) for attr in Stats.attributes])
) )
parser.add_argument( parser.add_argument(
'-r', '--range', type=float, nargs=2, '-r', '--range', type=float, nargs=2,
help='range of minimal and maximal accepted value such as: ' \ help='range of minimal and maximal accepted value such as: '
'--range min max' '--range min max'
) )
parser.add_argument( parser.add_argument(
@ -141,7 +144,7 @@ class Command (BaseCommand):
help='print a resume of good and bad files' help='print a resume of good and bad files'
) )
def handle (self, *args, **options): def handle(self, *args, **options):
# parameters # parameters
minmax = options.get('range') minmax = options.get('range')
if not minmax: if not minmax:
@ -152,8 +155,8 @@ class Command (BaseCommand):
raise CommandError('no attribute specified') raise CommandError('no attribute specified')
# sound analyse and checks # sound analyse and checks
self.sounds = [ Sound(path, options.get('sample_length')) self.sounds = [Sound(path, options.get('sample_length'))
for path in options.get('files') ] for path in options.get('files')]
self.bad = [] self.bad = []
self.good = [] self.good = []
for sound in self.sounds: for sound in self.sounds:
@ -171,4 +174,3 @@ class Command (BaseCommand):
logger.info('\033[92m+ %s\033[0m', sound.path) logger.info('\033[92m+ %s\033[0m', sound.path)
for sound in self.bad: for sound in self.bad:
logger.info('\033[91m+ %s\033[0m', sound.path) logger.info('\033[91m+ %s\033[0m', sound.path)

View File

@ -6,6 +6,7 @@ used to:
- cancels Diffusions that have an archive but could not have been played; - cancels Diffusions that have an archive but could not have been played;
- run Liquidsoap - run Liquidsoap
""" """
import tzlocal
import time import time
import re import re
@ -28,7 +29,6 @@ tz.activate(pytz.UTC)
# FIXME liquidsoap does not manage timezones -- we have to convert # FIXME liquidsoap does not manage timezones -- we have to convert
# 'on_air' metadata we get from it into utc one in order to work # 'on_air' metadata we get from it into utc one in order to work
# correctly. # correctly.
import tzlocal
local_tz = tzlocal.get_localzone() local_tz = tzlocal.get_localzone()
@ -118,10 +118,12 @@ class Monitor:
self.handle() self.handle()
def log(self, date=None, **kwargs): def log(self, date=None, **kwargs):
""" """ 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(), **kwargs) log = Log(station=self.station, date=date or tz.now(), **kwargs)
if log.type == Log.Type.on_air and log.diffusion is None:
log.collision = Diffusion.objects.station(log.station) \
.on_air().at(log.date).first()
log.save() log.save()
log.print() log.print()
return log return log
@ -153,7 +155,7 @@ class Monitor:
# check for reruns # check for reruns
if not diff.is_date_in_range(air_time) and not diff.initial: if not diff.is_date_in_range(air_time) and not diff.initial:
diff = Diffusion.objects.at(air_time) \ diff = Diffusion.objects.at(air_time) \
.filter(initial=diff).first() .on_air().filter(initial=diff).first()
# log sound on air # log sound on air
return self.log( return self.log(
@ -170,14 +172,14 @@ class Monitor:
if log.diffusion: if log.diffusion:
return return
tracks = Track.objects.filter(sound=log.sound, timestamp_isnull=False) tracks = Track.objects.filter(sound=log.sound, timestamp__isnull=False)
if not tracks.exists(): if not tracks.exists():
return 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() 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.timestamp)
if pos > now: if pos > now:
break break
# log track on air # log track on air
@ -195,14 +197,14 @@ class Monitor:
if self.sync_next and self.sync_next < now: if self.sync_next and self.sync_next < now:
return return
self.sync_next = now + tz.timedelta(seconds = self.sync_timeout) self.sync_next = now + tz.timedelta(seconds=self.sync_timeout)
for source in self.station.sources: for source in self.station.sources:
if source == self.station.dealer: if source == self.station.dealer:
continue continue
playlist = source.program.sound_set.all() \ playlist = source.program.sound_set.all() \
.filter(type=Sound.Type.archive) \ .filter(type=Sound.Type.archive) \
.values_list('path', flat = True) .values_list('path', flat=True)
source.playlist = list(playlist) source.playlist = list(playlist)
def trace_canceled(self): def trace_canceled(self):
@ -214,24 +216,24 @@ class Monitor:
return return
qs = Diffusions.objects.station(self.station).at().filter( qs = Diffusions.objects.station(self.station).at().filter(
type = Diffusion.Type.normal, type=Diffusion.Type.normal,
sound__type = Sound.Type.archive, sound__type=Sound.Type.archive,
) )
logs = Log.objects.station(station).on_air().with_diff() logs = Log.objects.station(station).on_air().with_diff()
date = tz.now() - datetime.timedelta(seconds = self.cancel_timeout) date = tz.now() - datetime.timedelta(seconds=self.cancel_timeout)
for diff in qs: for diff in qs:
if logs.filter(diffusion = diff): if logs.filter(diffusion=diff):
continue continue
if diff.start < now: if diff.start < now:
diff.type = Diffusion.Type.canceled diff.type = Diffusion.Type.canceled
diff.save() diff.save()
# log canceled diffusion # log canceled diffusion
self.log( self.log(
type = Log.Type.other, type=Log.Type.other,
diffusion = diff, diffusion=diff,
comment = 'Diffusion canceled after {} seconds' \ comment='Diffusion canceled after {} seconds'
.format(self.cancel_timeout) .format(self.cancel_timeout)
) )
def __current_diff(self): def __current_diff(self):
@ -251,7 +253,7 @@ class Monitor:
# last sound source change: end of file reached or forced to stop # last sound source change: end of file reached or forced to stop
sounds = Log.objects.station(station).on_air().with_sound() \ sounds = Log.objects.station(station).on_air().with_sound() \
.filter(date__gte = log.date) \ .filter(date__gte=log.date) \
.order_by('date') .order_by('date')
if sounds.count() and sounds.last().source != log.source: if sounds.count() and sounds.last().source != log.source:
@ -259,12 +261,12 @@ class Monitor:
# last diff is still playing: get remaining playlist # last diff is still playing: get remaining playlist
sounds = sounds \ sounds = sounds \
.filter(source = log.source, pk__gt = log.pk) \ .filter(source=log.source, pk__gt=log.pk) \
.exclude(sound__type = Sound.Type.removed) .exclude(sound__type=Sound.Type.removed)
remaining = log.diffusion.get_sounds(archive = True) \ remaining = log.diffusion.get_sounds(archive=True) \
.exclude(pk__in = sounds) \ .exclude(pk__in=sounds) \
.values_list('path', flat = True) .values_list('path', flat=True)
return log.diffusion, list(remaining) return log.diffusion, list(remaining)
def __next_diff(self, diff): def __next_diff(self, diff):
@ -273,16 +275,14 @@ class Monitor:
If diff is given, it is the one to be played right after it. If diff is given, it is the one to be played right after it.
""" """
station = self.station station = self.station
kwargs = {'start__gte': diff.end} if diff else {}
kwargs = {'start__gte': diff.end } if diff else {} qs = Diffusion.objects.station(station) \
kwargs['type'] = Diffusion.Type.normal .on_air().at().filter(**kwargs) \
qs = Diffusion.objects.station(station).at().filter(**kwargs) \
.distinct().order_by('start') .distinct().order_by('start')
diff = qs.first() diff = qs.first()
return (diff, diff and diff.get_playlist(archive = True) or []) return (diff, diff and diff.get_playlist(archive=True) or [])
def handle_pl_sync(self, source, playlist, diff = None, date = None): def handle_pl_sync(self, source, playlist, diff=None, date=None):
""" """
Update playlist of a source if required, and handle logging when Update playlist of a source if required, and handle logging when
it is needed. it is needed.
@ -297,11 +297,11 @@ class Monitor:
source.playlist = playlist source.playlist = playlist
if diff and not diff.is_live(): if diff and not diff.is_live():
# log diffusion archive load # log diffusion archive load
self.log(type = Log.Type.load, self.log(type=Log.Type.load,
source = source.id, source=source.id,
diffusion = diff, diffusion=diff,
date = date, date=date,
comment = '\n'.join(playlist)) comment='\n'.join(playlist))
def handle_diff_start(self, source, diff, date): def handle_diff_start(self, source, diff, date):
""" """
@ -318,11 +318,11 @@ class Monitor:
# live: just log it # live: just log it
if diff.is_live(): if diff.is_live():
diff_ = Log.objects.station(self.station) \ diff_ = Log.objects.station(self.station) \
.filter(diffusion = diff, type = Log.Type.on_air) .filter(diffusion=diff, type=Log.Type.on_air)
if not diff_.count(): if not diff_.count():
# log live diffusion # log live diffusion
self.log(type = Log.Type.on_air, source = source.id, self.log(type=Log.Type.on_air, source=source.id,
diffusion = diff, date = date) diffusion=diff, date=date)
return return
# enable dealer # enable dealer
@ -331,8 +331,8 @@ class Monitor:
last_start = self.last_diff_start last_start = self.last_diff_start
if not last_start or last_start.diffusion_id != diff.pk: if not last_start or last_start.diffusion_id != diff.pk:
# log triggered diffusion # log triggered diffusion
self.log(type = Log.Type.start, source = source.id, self.log(type=Log.Type.start, source=source.id,
diffusion = diff, date = date) diffusion=diff, date=date)
def handle(self): def handle(self):
""" """
@ -358,10 +358,10 @@ class Monitor:
class Command (BaseCommand): class Command (BaseCommand):
help= __doc__ help = __doc__
def add_arguments (self, parser): def add_arguments(self, parser):
parser.formatter_class=RawTextHelpFormatter parser.formatter_class = RawTextHelpFormatter
group = parser.add_argument_group('actions') group = parser.add_argument_group('actions')
group.add_argument( group.add_argument(
'-c', '--config', action='store_true', '-c', '--config', action='store_true',
@ -396,25 +396,25 @@ class Command (BaseCommand):
'check' 'check'
) )
def handle (self, *args, def handle(self, *args,
config = None, run = None, monitor = None, config=None, run=None, monitor=None,
station = [], delay = 1000, timeout = 600, station=[], delay=1000, timeout=600,
**options): **options):
stations = Station.objects.filter(name__in = station)[:] \ stations = Station.objects.filter(name__in=station)[:] \
if station else Station.objects.all()[:] if station else Station.objects.all()[:]
for station in stations: for station in stations:
# station.prepare() # station.prepare()
if config and not run: # no need to write it twice if config and not run: # no need to write it twice
station.streamer.push() station.streamer.push()
if run: if run:
station.streamer.process_run() station.streamer.process_run()
if monitor: if monitor:
monitors = [ monitors = [
Monitor(station, cancel_timeout = timeout) Monitor(station, cancel_timeout=timeout)
for station in stations for station in stations
] ]
delay = delay / 1000 delay = delay / 1000
while True: while True:
@ -425,4 +425,3 @@ class Command (BaseCommand):
if run: if run:
for station in stations: for station in stations:
station.controller.process_wait() station.controller.process_wait()

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.models import Q
from django.db.transaction import atomic 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
@ -119,9 +120,7 @@ class Station(Nameable):
@property @property
def outputs(self): def outputs(self):
""" """ 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,
@ -129,9 +128,7 @@ class Station(Nameable):
@property @property
def sources(self): def sources(self):
""" """ Audio sources, dealer included """
Audio sources, dealer included
"""
self.__prepare_controls() self.__prepare_controls()
return self.__sources return self.__sources
@ -168,7 +165,7 @@ class Station(Nameable):
# 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, datetime.date)
if date and date > datetime.date.today(): if date and date > datetime.date.today():
return [] return []
@ -185,18 +182,17 @@ class Station(Nameable):
start__lte=now) \ start__lte=now) \
.order_by('-start')[:count] .order_by('-start')[:count]
q = models.Q(diffusion__isnull=False) | \ q = Q(diffusion__isnull=False) | Q(track__isnull=False)
models.Q(track__isnull=False)
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, q = 0, models.Q() n, q = 0, Q()
for diff in diffs: for diff in diffs:
if count and n >= count: if count and n >= count:
break break
# FIXME: does not catch tracks started before diff end but # FIXME: does not catch tracks started before diff end but
# that continued afterwards # that continued afterwards
q = q | models.Q(date__gte=diff.start, date__lte=diff.end) q = q | 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:
@ -411,15 +407,11 @@ class Schedule(models.Model):
Program, models.CASCADE, Program, models.CASCADE,
verbose_name=_('related program'), verbose_name=_('related program'),
) )
time = models.TimeField(
_('time'),
blank=True, null=True,
help_text=_('start time'),
)
date = models.DateField( date = models.DateField(
_('date'), _('date'), help_text=_('date of the first diffusion'),
blank=True, null=True, )
help_text=_('date of the first diffusion'), time = models.TimeField(
_('time'), help_text=_('start time'),
) )
timezone = models.CharField( timezone = models.CharField(
_('timezone'), _('timezone'),
@ -462,6 +454,12 @@ class Schedule(models.Model):
return pytz.timezone(self.timezone) return pytz.timezone(self.timezone)
@property
def datetime(self):
""" Datetime for this schedule (timezone unaware) """
import datetime
return datetime.datetime.combine(self.date, self.time)
# initial cached data # initial cached data
__initial = None __initial = None
@ -481,22 +479,18 @@ class Schedule(models.Model):
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 Return True if the given date(time) matches the schedule.
""" """
date = utils.date_or_default(date) date = utils.date_or_default(
date, tz.datetime if check_time else datetime.date)
if self.date.weekday() != date.weekday() or \ if self.date.weekday() != date.weekday() or \
not self.match_week(date): not self.match_week(date):
return False return False
if not check_time:
return True
# 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.
return date == self.normalize(date) if check_time else True
return date == self.normalize(date)
def match_week(self, date=None): def match_week(self, date=None):
""" """
@ -509,15 +503,14 @@ class Schedule(models.Model):
return False return False
# since we care only about the week, go to the same day of the week # since we care only about the week, go to the same day of the week
date = utils.date_or_default(date) date = utils.date_or_default(date, datetime.date)
date += tz.timedelta(days=self.date.weekday() - date.weekday()) date += tz.timedelta(days=self.date.weekday() - date.weekday())
# FIXME this case # FIXME this case
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) - \ diff = date - utils.cast_date(self.date, datetime.date)
utils.cast_date(self.date, False)
return not (diff.days % 14) return not (diff.days % 14)
@ -556,8 +549,7 @@ class Schedule(models.Model):
return [] return []
# first day of month # first day of month
date = utils.date_or_default(date, to_datetime=False) \ date = utils.date_or_default(date, datetime.date).replace(day=1)
.replace(day=1)
freq = self.frequency freq = self.frequency
# last of the month # last of the month
@ -588,8 +580,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) - \ diff = utils.cast_date(date, datetime.date) - \
utils.cast_date(self.date, False) utils.cast_date(self.date, datetime.date)
if diff.days % 14: if diff.days % 14:
date += tz.timedelta(days=7) date += tz.timedelta(days=7)
@ -636,13 +628,17 @@ class Schedule(models.Model):
# new diffusions # new diffusions
duration = utils.to_timedelta(self.duration) duration = utils.to_timedelta(self.duration)
delta = None
if self.initial: if self.initial:
delta = self.date - self.initial.date delta = self.datetime - self.initial.datetime
# FIXME: daylight saving bug: delta misses an hour when diffusion and
# rerun are not on the same daylight-saving timezone
diffusions += [ diffusions += [
Diffusion( Diffusion(
program=self.program, program=self.program,
type=Diffusion.Type.unconfirmed, type=Diffusion.Type.unconfirmed,
initial=Diffusion.objects.filter(start=date - delta).first() initial=Diffusion.objects.program(self.program).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,
@ -685,7 +681,10 @@ class DiffusionQuerySet(models.QuerySet):
def program(self, program): def program(self, program):
return self.filter(program=program) return self.filter(program=program)
def at(self, date=None, next=False, **kwargs): def on_air(self):
return self.filter(type=Diffusion.Type.normal)
def at(self, date=None):
""" """
Return diffusions occuring at the given date, ordered by +start Return diffusions occuring at the given date, ordered by +start
@ -694,12 +693,9 @@ class DiffusionQuerySet(models.QuerySet):
it as a date, and get diffusions that occurs this day. it as a date, and get diffusions that occurs this day.
When date is None, uses tz.now(). When date is None, uses tz.now().
When next is true, include diffusions that also occur after
the given moment.
""" """
# note: we work with localtime # note: we work with localtime
date = utils.date_or_default(date, keep_type=True) date = utils.date_or_default(date)
qs = self qs = self
filters = None filters = None
@ -708,43 +704,39 @@ class DiffusionQuerySet(models.QuerySet):
# use datetime: we want diffusion that occurs around this # use datetime: we want diffusion that occurs around this
# range # range
filters = {'start__lte': date, 'end__gte': date} filters = {'start__lte': date, 'end__gte': date}
qs = qs.filter(**filters)
if next:
qs = qs.filter(
models.Q(start__gte=date) | models.Q(**filters)
)
else:
qs = qs.filter(**filters)
else: else:
# use date: we want diffusions that occurs this day # use date: we want diffusions that occurs this day
start, end = utils.date_range(date) qs = qs.filter(Q(start__date=date) | Q(end__date=date))
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)
qs = qs.filter(filters, **kwargs)
return qs.order_by('start').distinct() return qs.order_by('start').distinct()
def after(self, date=None, **kwargs): def after(self, date=None):
""" """
Return a queryset of diffusions that happen after the given Return a queryset of diffusions that happen after the given
date. date (default: today).
"""
date = utils.date_or_default(date, keep_type=True)
return self.filter(start__gte=date, **kwargs).order_by('start')
def before(self, date=None, **kwargs):
"""
Return a queryset of diffusions that finish before the given
date.
""" """
date = utils.date_or_default(date) date = utils.date_or_default(date)
if isinstance(date, tz.datetime):
qs = self.filter(start__gte=date)
else:
qs = self.filter(start__date__gte=date)
return qs.order_by('start')
return self.filter(end__lte=date, **kwargs).order_by('start') def before(self, date=None):
"""
Return a queryset of diffusions that finish before the given
date (default: today).
"""
date = utils.date_or_default(date)
if isinstance(date, tz.datetime):
qs = self.filter(start__lt=date)
else:
qs = self.filter(start__date__lt=date)
return qs.order_by('start')
def range(self, start, end):
# FIXME can return dates that are out of range...
return self.after(start).before(end)
class Diffusion(models.Model): class Diffusion(models.Model):
@ -813,21 +805,19 @@ class Diffusion(models.Model):
@property @property
def date(self): def date(self):
""" """ Return diffusion start as a date. """
Alias to self.start
"""
return self.start return utils.cast_date(self.start)
@cached_property @cached_property
def local_date(self): def local_start(self):
""" """
Return a version of self.date that is localized to self.timezone; Return a version of self.date that is localized to self.timezone;
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.start, tz.get_current_timezone())
@property @property
def local_end(self): def local_end(self):
@ -892,10 +882,8 @@ class Diffusion(models.Model):
""" """
return Diffusion.objects.filter( return Diffusion.objects.filter(
models.Q(start__lt=self.start, Q(start__lt=self.start, end__gt=self.start) |
end__gt=self.start) | Q(start__gt=self.start, start__lt=self.end)
models.Q(start__gt=self.start,
start__lt=self.end)
).exclude(pk=self.pk).distinct() ).exclude(pk=self.pk).distinct()
def check_conflicts(self): def check_conflicts(self):
@ -929,7 +917,7 @@ class Diffusion(models.Model):
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_start.strftime('%Y/%m/%d %H:%M%z')
) )
class Meta: class Meta:
@ -1298,11 +1286,8 @@ class LogQuerySet(models.QuerySet):
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) date = utils.date_or_default(date)
# return qs.filter(models.Q(end__gte = start) | return self.filter(date__date=date)
# models.Q(date__lte = end))
return self.filter(date__gte=start, date__lte=end)
def on_air(self): def on_air(self):
return self.filter(type=Log.Type.on_air) return self.filter(type=Log.Type.on_air)
@ -1508,6 +1493,13 @@ class Log(models.Model):
verbose_name=_('Track'), verbose_name=_('Track'),
) )
collision = models.ForeignKey(
Diffusion, on_delete=models.SET_NULL,
blank=True, null=True,
verbose_name=_('Collision'),
related_name='+',
)
objects = LogQuerySet.as_manager() objects = LogQuerySet.as_manager()
@property @property

View File

@ -4,68 +4,59 @@ import django.utils.timezone as tz
def date_range(date): def date_range(date):
""" """
Return a range of datetime for a given day, such as: Return a datetime range for a given day, as:
[date, 0:0:0:0; date, 23:59:59:999] ```(date, 0:0:0:0; date, 23:59:59:999)```.
Ensure timezone awareness.
""" """
date = date_or_default(date) date = date_or_default(date, tz.datetime)
range = ( return (
date.replace(hour = 0, minute = 0, second = 0), \ date.replace(hour=0, minute=0, second=0),
date.replace(hour = 23, minute = 59, second = 59, microsecond = 999) date.replace(hour=23, minute=59, second=59, microsecond=999)
) )
return range
def cast_date(date, to_datetime = True):
def cast_date(date, into=datetime.date):
""" """
Given a date reset its time information and Cast a given date into the provided class' instance. Make datetime
return it as a date or datetime object. aware of timezone.
Ensure timezone awareness.
""" """
if to_datetime: date = into(date.year, date.month, date.day)
return tz.make_aware( return tz.make_aware(date) if issubclass(into, tz.datetime) else date
tz.datetime(date.year, date.month, date.day, 0, 0, 0, 0)
)
return datetime.date(date.year, date.month, date.day)
def date_or_default(date, reset_time = False, keep_type = False, to_datetime = True):
def date_or_default(date, into=None):
""" """
Return datetime or default value (now) if not defined, and remove time info Return date if not None, otherwise return now. Cast result into provided
if reset_time is True. type if any.
\param reset_time reset time info to 0
\param keep_type keep the same type of the given date if not None
\param to_datetime force conversion to datetime if not keep_type
Ensure timezone awareness.
""" """
date = date or tz.now() date = date if date is not None else datetime.date.today() \
to_datetime = isinstance(date, tz.datetime) if keep_type else to_datetime if into is not None and issubclass(into, datetime.date) else \
tz.datetime.now()
if reset_time or not isinstance(date, tz.datetime): if into is not None:
return cast_date(date, to_datetime) date = cast_date(date, into)
if not tz.is_aware(date): if isinstance(date, tz.datetime) and not tz.is_aware(date):
date = tz.make_aware(date) date = tz.make_aware(date)
return date return date
def to_timedelta (time):
def to_timedelta(time):
""" """
Transform a datetime or a time instance to a timedelta, Transform a datetime or a time instance to a timedelta,
only using time info only using time info
""" """
return datetime.timedelta( return datetime.timedelta(
hours = time.hour, hours=time.hour,
minutes = time.minute, minutes=time.minute,
seconds = time.second seconds=time.second
) )
def seconds_to_time (seconds):
def seconds_to_time(seconds):
""" """
Seconds to datetime.time Seconds to datetime.time
""" """
minutes, seconds = divmod(seconds, 60) minutes, seconds = divmod(seconds, 60)
hours, minutes = divmod(minutes, 60) hours, minutes = divmod(minutes, 60)
return datetime.time(hour = hours, minute = minutes, second = seconds) return datetime.time(hour=hours, minute=minutes, second=seconds)

View File

@ -793,10 +793,10 @@ class TimetablePage(DatedListPage):
template = 'aircox_cms/dated_list_page.html' template = 'aircox_cms/dated_list_page.html'
station = models.ForeignKey( station = models.ForeignKey(
aircox.models.Station, aircox.models.Station,
verbose_name = _('station'), verbose_name=_('station'),
on_delete = models.SET_NULL, on_delete=models.SET_NULL,
null = True, blank = True, null=True, blank=True,
help_text = _('(required) related station') help_text=_('(required) related station')
) )
content_panels = DatedListPage.content_panels + [ content_panels = DatedListPage.content_panels + [

View File

@ -15,6 +15,8 @@ from aircox.admin.mixins import UnrelatedInlineMixin
@admin.register(models.Site) @admin.register(models.Site)
class SiteAdmin(ContentEditor): class SiteAdmin(ContentEditor):
list_display = ['title', 'station']
inlines = [ inlines = [
ContentEditorInline.create(models.SiteRichText), ContentEditorInline.create(models.SiteRichText),
ContentEditorInline.create(models.SiteImage), ContentEditorInline.create(models.SiteImage),
@ -37,14 +39,26 @@ class PageDiffusionPlaylist(UnrelatedInlineMixin, TracksInline):
@admin.register(models.Page) @admin.register(models.Page)
class PageAdmin(ContentEditor): class PageAdmin(admin.ModelAdmin):
list_display = ["title", "parent", "status"] list_display = ["title", "parent", "status"]
list_editable = ['status']
prepopulated_fields = {"slug": ("title",)} prepopulated_fields = {"slug": ("title",)}
# readonly_fields = ('diffusion',)
fieldsets = ( fieldsets = (
(_('Main'), { (_('Main'), {
'fields': ['title', 'slug', 'as_program', 'headline'], 'fields': ['title', 'slug']
}),
(_('Settings'), {
'fields': ['status', 'static_path', 'path'],
}),
)
@admin.register(models.Article)
class ArticleAdmin(ContentEditor, PageAdmin):
fieldsets = (
(_('Main'), {
'fields': ['title', 'slug', 'as_program', 'cover', 'headline'],
'classes': ('tabbed', 'uncollapse') 'classes': ('tabbed', 'uncollapse')
}), }),
(_('Settings'), { (_('Settings'), {
@ -59,36 +73,27 @@ class PageAdmin(ContentEditor):
) )
inlines = [ inlines = [
ContentEditorInline.create(models.PageRichText), ContentEditorInline.create(models.ArticleRichText),
ContentEditorInline.create(models.PageImage), ContentEditorInline.create(models.ArticleImage),
] ]
@admin.register(models.DiffusionPage) @admin.register(models.DiffusionPage)
class DiffusionPageAdmin(PageAdmin): class DiffusionPageAdmin(ArticleAdmin):
fieldsets = copy.deepcopy(PageAdmin.fieldsets) fieldsets = copy.deepcopy(ArticleAdmin.fieldsets)
fieldsets[1][1]['fields'].insert(0, 'diffusion') fieldsets[1][1]['fields'].insert(0, 'diffusion')
inlines = PageAdmin.inlines + [
PageDiffusionPlaylist
]
# TODO: permissions # TODO: permissions
#def get_inline_instances(self, request, obj=None): def get_inline_instances(self, request, obj=None):
# inlines = super().get_inline_instances(request, obj) inlines = super().get_inline_instances(request, obj)
# if obj and obj.diffusion: if obj and obj.diffusion:
# inlines.insert(0, PageDiffusionPlaylist(self.model, self.admin_site)) inlines.insert(0, PageDiffusionPlaylist(self.model, self.admin_site))
# return inlines return inlines
@admin.register(models.ProgramPage) @admin.register(models.ProgramPage)
class DiffusionPageAdmin(PageAdmin): class ProgramPageAdmin(ArticleAdmin):
fieldsets = copy.deepcopy(PageAdmin.fieldsets) fieldsets = copy.deepcopy(ArticleAdmin.fieldsets)
fieldsets[1][1]['fields'].insert(0, 'program') fieldsets[1][1]['fields'].insert(0, 'program')
inlines = PageAdmin.inlines + [
PageDiffusionPlaylist
]

View File

@ -1,3 +1,5 @@
import './js'; import './js';
import './styles.scss'; import './styles.scss';
import './noscript.scss';
import './vue';

View File

@ -3,9 +3,12 @@ import Buefy from 'buefy';
Vue.use(Buefy); Vue.use(Buefy);
var app = new Vue({ window.addEventListener('load', () => {
el: '#app', var app = new Vue({
}) el: '#app',
delimiters: [ '[[', ']]' ],
})
});

View File

@ -10,9 +10,12 @@ $body-background-color: $light;
} }
.navbar.has-shadow { .navbar.has-shadow {
box-shadow: 0em 0.1em 0.5em rgba(0,0,0,0.1); box-shadow: 0em 0.05em 0.5em rgba(0,0,0,0.1);
} }
/*
.navbar-brand img { .navbar-brand img {
min-height: 6em; min-height: 6em;
} }
@ -20,5 +23,22 @@ $body-background-color: $light;
.navbar-menu .navbar-item:not(:last-child) { .navbar-menu .navbar-item:not(:last-child) {
border-right: 1px $grey solid; border-right: 1px $grey solid;
} }
*/
/** page **/
img.cover {
border: 0.2em black solid;
}
.headline {
font-size: 1.2em;
padding: 0.2em 0em;
}
img.cover {
float: right;
max-width: 40%;
}

View File

@ -0,0 +1,11 @@
import Vue from 'vue';
import Tab from './tab.vue';
import Tabs from './tabs.vue';
Vue.component('a-tab', Tab);
Vue.component('a-tabs', Tabs);
export {Tab, Tabs};

View File

@ -0,0 +1,31 @@
<template>
<li @click.prevent="onclick"
:class="{'is-active': $parent.value == value}">
<slot></slot>
</li>
</template>
<script>
export default {
props: {
value: { default: undefined },
},
methods: {
select() {
this.$parent.selectTab(this);
},
onclick(event) {
this.select();
/*if(event.target.href != document.location)
window.history.pushState(
{ url: event.target.href },
event.target.innerText + ' - ' + document.title,
event.target.href
) */
}
}
}
</script>

View File

@ -0,0 +1,45 @@
<template>
<div>
<div class="tabs is-centered">
<ul><slot name="tabs" :value="value" /></ul>
</div>
<slot :value="value"/>
</div>
</template>
<script>
export default {
props: {
default: { default: null },
},
data() {
return {
value: this.default,
}
},
computed: {
tab() {
const vnode = this.$slots.default && this.$slots.default.find(
elm => elm.child && elm.child.value == this.value
);
return vnode && vnode.child;
}
},
methods: {
selectTab(tab) {
const value = tab.value;
if(this.value === value)
return;
this.value = value;
this.$emit('select', {target: this, value: value, tab: tab});
},
},
}
</script>

49
aircox_web/converters.py Normal file
View File

@ -0,0 +1,49 @@
import datetime
from django.utils.safestring import mark_safe
from django.urls.converters import StringConverter
class PagePathConverter(StringConverter):
""" Match path for pages, including surrounding slashes. """
regex = r'/?|([-_a-zA-Z0-9]+/)*?'
def to_python(self, value):
if not value or value[0] != '/':
value = '/' + value
if len(value) > 1 and value[-1] != '/':
value = value + '/'
return value
def to_url(self, value):
if value[0] == '/':
value = value[1:]
if value[-1] != '/':
value = value + '/'
return mark_safe(value)
#class WeekConverter:
# """ Converter for date as YYYYY/WW """
# regex = r'[0-9]{4}/[0-9]{2}/?'
#
# def to_python(self, value):
# value = value.split('/')
# return datetime.date(int(value[0]), int(value[1]), int(value[2]))
#
# def to_url(self, value):
# return '{:04d}/{:02d}/'.format(*value.isocalendar())
class DateConverter:
""" Converter for date as YYYY/MM/DD """
regex = r'[0-9]{4}/[0-9]{2}/[0-9]{2}/?'
def to_python(self, value):
value = value.split('/')
return datetime.date(int(value[0]), int(value[1]), int(value[2]))
def to_url(self, value):
return '{:04d}/{:02d}/{:02d}/'.format(value.year, value.month,
value.day)

View File

@ -1,18 +1,19 @@
from django.core.validators import RegexValidator from django.core.validators import RegexValidator
from django.db import models from django.db import models
from django.db.models import F from django.db.models import F, Q
from django.db.models.functions import Concat, Substr from django.db.models.functions import Concat, Substr
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from content_editor.models import Region, create_plugin_base from content_editor.models import Region, create_plugin_base
from model_utils.models import TimeStampedModel, StatusModel from model_utils.models import TimeStampedModel, StatusModel
from model_utils.managers import InheritanceManager from model_utils.managers import InheritanceQuerySet
from model_utils import Choices from model_utils import Choices
from filer.fields.image import FilerImageField from filer.fields.image import FilerImageField
from aircox import models as aircox from aircox import models as aircox
from . import plugins from . import plugins
from .converters import PagePathConverter
class Site(models.Model): class Site(models.Model):
@ -70,12 +71,31 @@ class SiteLink(plugins.Link, SitePlugin):
#----------------------------------------------------------------------- #-----------------------------------------------------------------------
class BasePage(StatusModel): class PageQueryset(InheritanceQuerySet):
""" def active(self):
Base abstract class for views whose url path is defined by users. return self.filter(Q(status=Page.STATUS.announced) |
Page parenting is based on foreignkey to parent and page path. Q(status=Page.STATUS.published))
Inspired by Feincms3. def descendants(self, page, direct=True, inclusive=True):
qs = self.filter(parent=page) if direct else \
self.filter(path__startswith=page.path)
if not inclusive:
qs = qs.exclude(pk=page.pk)
return qs
def ancestors(self, page, inclusive=True):
path, paths = page.path, []
index = path.find('/')
while index != -1 and index+1 < len(path):
paths.append(path[0:index+1])
index = path.find('/', index+1)
return self.filter(path__in=paths)
class Page(StatusModel):
"""
Base class for views whose url path can be defined by users.
Page parenting is based on foreignkey to parent and page path.
""" """
STATUS = Choices('draft', 'announced', 'published') STATUS = Choices('draft', 'announced', 'published')
@ -89,22 +109,22 @@ class BasePage(StatusModel):
path = models.CharField( path = models.CharField(
_("path"), max_length=1000, _("path"), max_length=1000,
blank=True, db_index=True, unique=True, blank=True, db_index=True, unique=True,
validators=[ validators=[RegexValidator(
RegexValidator( regex=PagePathConverter.regex,
regex=r"^/(|.+/)$", message=_('Path accepts alphanumeric and "_-" characters '
message=_("Path must start and end with a slash (/)."), 'and must be surrounded by "/"')
) )],
],
) )
static_path = models.BooleanField( static_path = models.BooleanField(
_('static path'), default=False, _('static path'), default=False,
# FIXME: help
help_text=_('Update path using parent\'s page path and page title') help_text=_('Update path using parent\'s page path and page title')
) )
headline = models.TextField(
_('headline'), max_length=128, blank=True, null=True,
)
objects = InheritanceManager() objects = PageQueryset.as_manager()
class Meta:
abstract = True
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
@ -112,10 +132,14 @@ class BasePage(StatusModel):
self._initial_parent = self.parent self._initial_parent = self.parent
self._initial_slug = self.slug self._initial_slug = self.slug
def view(self, request, *args, **kwargs): def get_view_class(self):
""" Page view class"""
raise NotImplementedError('not implemented')
def view(self, request, *args, site=None, **kwargs):
""" Page view function """ """ Page view function """
from django.http import HttpResponse view = self.get_view_class().as_view(site=site, page=self)
return HttpResponse('Not implemented') return view(request, *args, **kwargs)
def update_descendants(self): def update_descendants(self):
""" Update descendants pages' path if required. """ """ Update descendants pages' path if required. """
@ -123,8 +147,10 @@ class BasePage(StatusModel):
return return
# FIXME: draft -> draft children? # FIXME: draft -> draft children?
expr = Concat(self.path, Substr(F('path'), len(self._initial_path))) # FIXME: Page.objects (can't use Page since its an abstract model)
BasePage.objects.filter(path__startswith=self._initial_path) \ if len(self._initial_path):
expr = Concat('path', Substr(F('path'), len(self._initial_path)))
Page.objects.filter(path__startswith=self._initial_path) \
.update(path=expr) .update(path=expr)
def sync_generations(self, update_descendants=True): def sync_generations(self, update_descendants=True):
@ -141,13 +167,13 @@ class BasePage(StatusModel):
if not self.title or not self.path or self.static_path and \ if not self.title or not self.path or self.static_path and \
self.slug != self._initial_slug: self.slug != self._initial_slug:
self.path = self.parent.path + '/' + self.slug \ self.path = self.parent.path + self.slug \
if self.parent is not None else '/' + self.slug if self.parent is not None else '/' + self.slug
if self.path[-1] != '/':
self.path += '/'
if self.path[0] != '/': if self.path[0] != '/':
self.path = '/' + self.path self.path = '/' + self.path
if self.path[-1] != '/':
self.path += '/'
if update_descendants: if update_descendants:
self.update_descendants() self.update_descendants()
@ -155,18 +181,23 @@ class BasePage(StatusModel):
self.sync_generations(update_descendants) self.sync_generations(update_descendants)
super().save(*args, **kwargs) super().save(*args, **kwargs)
def __str__(self):
return '{}: {}'.format(self._meta.verbose_name,
self.title or self.pk)
class Page(BasePage, TimeStampedModel):
class Article(Page, TimeStampedModel):
""" User's pages """ """ User's pages """
regions = [ regions = [
Region(key="main", title=_("Content")), Region(key="content", title=_("Content")),
] ]
# metadata # metadata
as_program = models.ForeignKey( as_program = models.ForeignKey(
aircox.Program, models.SET_NULL, blank=True, null=True, aircox.Program, models.SET_NULL, blank=True, null=True,
related_name='published_pages', related_name='published_pages',
limit_choices_to={'schedule__isnull': False}, # SO#51948640
# limit_choices_to={'schedule__isnull': False},
verbose_name=_('Show program as author'), verbose_name=_('Show program as author'),
help_text=_("Show program as author"), help_text=_("Show program as author"),
) )
@ -180,45 +211,41 @@ class Page(BasePage, TimeStampedModel):
) )
# content # content
headline = models.TextField(
_('headline'), max_length=128, blank=True, null=True,
)
cover = FilerImageField( cover = FilerImageField(
on_delete=models.SET_NULL, null=True, blank=True, on_delete=models.SET_NULL, null=True, blank=True,
verbose_name=_('Cover'), verbose_name=_('Cover'),
) )
def get_view_class(self): def get_view_class(self):
from .views import PageView from .views import ArticleView
return PageView return ArticleView
def view(self, request, *args, **kwargs):
""" Page view function """
view = self.get_view_class().as_view()
return view(request, *args, **kwargs)
class DiffusionPage(Page): class DiffusionPage(Article):
diffusion = models.OneToOneField( diffusion = models.OneToOneField(
aircox.Diffusion, models.CASCADE, aircox.Diffusion, models.CASCADE,
blank=True, null=True, related_name='page',
) )
class ProgramPage(Page): class ProgramPage(Article):
program = models.OneToOneField( program = models.OneToOneField(
aircox.Program, models.CASCADE, aircox.Program, models.CASCADE,
blank=True, null=True, related_name='page',
) )
def get_view_class(self):
from .views import ProgramView
return ProgramView
#----------------------------------------------------------------------- #-----------------------------------------------------------------------
PagePlugin = create_plugin_base(Page) ArticlePlugin = create_plugin_base(Article)
class PageRichText(plugins.RichText, PagePlugin): class ArticleRichText(plugins.RichText, ArticlePlugin):
pass pass
class PageImage(plugins.Image, PagePlugin): class ArticleImage(plugins.Image, ArticlePlugin):
pass pass

View File

@ -18,6 +18,7 @@
"ttf-loader": "^1.0.2", "ttf-loader": "^1.0.2",
"vue-loader": "^15.7.0", "vue-loader": "^15.7.0",
"vue-style-loader": "^4.1.2", "vue-style-loader": "^4.1.2",
"vue-template-compiler": "^2.6.10",
"webpack": "^4.32.2", "webpack": "^4.32.2",
"webpack-cli": "^3.3.2" "webpack-cli": "^3.3.2"
}, },

View File

@ -13,6 +13,6 @@ site_renderer.register(SiteLink, lambda plugin: plugin.render())
page_renderer = PluginRenderer() page_renderer = PluginRenderer()
page_renderer._renderers.clear() page_renderer._renderers.clear()
page_renderer.register(PageRichText, lambda plugin: mark_safe(plugin.text)) page_renderer.register(ArticleRichText, lambda plugin: mark_safe(plugin.text))
page_renderer.register(PageImage, lambda plugin: plugin.render()) page_renderer.register(ArticleImage, lambda plugin: plugin.render())

View File

@ -0,0 +1,17 @@
{% extends "aircox_web/page.html" %}
{% block main %}
<section class="is-inline-block">
<img class="cover" src="{{ page.cover.url }}"/>
{% block headline %}
{{ page.headline }}
{% endblock %}
{% block content %}
{{ regions.main }}
{% endblock %}
</section>
{% endblock %}

View File

@ -1,50 +0,0 @@
{% load static i18n thumbnail %}
<html>
<head>
<meta charset="utf-8">
<meta name="application-name" content="aircox">
<meta name="description" content="{{ site.description }}">
<meta name="keywords" content="{{ site.tags }}">
<link rel="icon" href="{% thumbnail site.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.title }}{% endblock %}</title>
{% block extra_head %}{% endblock %}
</head>
<body id="app">
<nav class="navbar has-shadow" role="navigation" aria-label="main navigation">
<div class="navbar-brand">
<a href="/" title="{% trans "Home" %}" class="navbar-item">
<img src="{{ site.logo.url }}" class="logo"/>
</a>
</div>
<div class="navbar-menu">
<div class="navbar-start">
{{ site_regions.topnav }}
</div>
</div>
</nav>
<div class="columns">
<aside class="column">
{{ site_regions.sidenav }}
</aside>
<main class="column is-three-quarters">
{% block main %}
<h1>{{ page.title }}</h1>
{{ regions.main }}
{% endblock main %}
</main>
</div>
</body>
</html>

View File

@ -0,0 +1,43 @@
{% load i18n easy_thumbnails_tags aircox_web %}
{% comment %}
Context variables:
- object: the actual diffusion
- page: current parent page in which item is rendered
{% endcomment %}
{% with page as context_page %}
{% with object.program as program %}
{% diffusion_page object as page %}
<article class="media">
<div class="media-left">
<figure class="image is-64x64">
<img src="{% thumbnail page.cover|default:site.logo 128x128 crop=scale %}">
</figure>
</div>
<div class="media-content">
<div class="content">
<p>
{% if page and context_page != page %}
<strong><a href="{{ page.path }}">{{ page.title }}</a></strong>
{% else %}
<strong>{{ page.title|default:program.name }}</strong>
{% endif %}
{% if object.page is page %}
&mdash; <a href="{{ program.page.path }}">{{ program.name }}</a></small>
{% endif %}
{% if object.initial %}
{% with object.initial.date as date %}
<span class="tag is-info" title="{% blocktrans %}Rerun of {{ date }}{% endblocktrans %}">
{% trans "rerun" %}
</span>
{% endwith %}
{% endif %}
<br>
{{ page.headline|default:program.page.headline }}
</p>
</div>
</div>
</article>
{% endwith %}
{% endwith %}

View File

@ -0,0 +1,46 @@
{% extends "aircox_web/page.html" %}
{% load i18n aircox_web %}
{% block main %}
{{ block.super }}
<section class="section">
{% for object in object_list %}
<div class="columns">
<div class="column is-one-fifth has-text-right">
<time datetime="{{ object.start|date:"c" }}" title="{{ object.start }}">
{{ object.start|date:"d M, H:i" }}
</time>
</div>
<div class="column">
{% include "aircox_web/diffusion_item.html" %}
</div>
</div>
{% endfor %}
{% if is_paginated %}
<nav class="pagination is-centered" role="pagination" aria-label="{% trans "pagination" %}">
{% if page_obj.has_previous %}
<a href="?page={{ page_obj.previous_page_number }}" class="pagination-previous">
{% trans "Previous" %}</a>
{% endif %}
{% if page_obj.has_next %}
<a href="?page={{ page_obj.next_page_number }}" class="pagination-next">
{% trans "Next" %}</a>
{% endif %}
<ul class="pagination-list">
{% for i in paginator.page_range %}
<li>
<a class="pagination-link {% if page_obj.number == i %}is-current{% endif %}"
href="?page={{ i }}">{{ i }}</a>
</li>
{% endfor %}
</ul>
</nav>
{% endif %}
</section>
{% endblock %}

View File

@ -0,0 +1,19 @@
{% load i18n %}
{% with object.track as track %}
<span class="has-text-info is-size-5">&#9836;</span>
<span>{{ track.title }}</span>
{% with track.artist as artist %}
{% with track.info as info %}
<span class="has-text-grey-dark has-text-weight-light">
{% blocktrans %}
by {{ artist }}
{% endblocktrans %}
{% if info %}
({% blocktrans %}<i>{{ info }}</i>{% endblocktrans %})
{% endif %}
</span>
{% endwith %}
{% endwith %}
{% endwith %}

View File

@ -0,0 +1,51 @@
{% extends "aircox_web/page.html" %}
{% load i18n aircox_web %}
{% block main %}
{{ block.super }}
<section class="section">
{% if dates %}
<nav class="tabs is-centered" aria-label="{% trans "Other days' logs" %}">
<ul>
{% for day in dates %}
<li {% if day == date %}class="is-active"{% endif %}>
<a href="{% url "logs" date=day %}">
{{ day|date:"d b" }}
</a>
</li>
{% if forloop.last and day > min_date %}
<li>...</li>
{% endif %}
{% endfor %}
</ul>
</nav>
{% endif %}
{# <h4 class="subtitle size-4">{{ date }}</h4> #}
<table class="table is-striped is-hoverable is-fullwidth">
{% for object in object_list reversed %}
<tr>
{% if object|is_diffusion %}
<td>
<time datetime="{{ object.start }}" title="{{ object.start }}">
{{ object.start|date:"H:i" }} - {{ object.end|date:"H:i" }}
</time>
</td>
<td>{% include "aircox_web/diffusion_item.html" %}</td>
{% else %}
<td>
<time datetime="{{ object.date }}" title="{{ object.date }}">
{{ object.date|date:"H:i" }}
</time>
</td>
<td>{% include "aircox_web/log_item.html" %}</td>
{% endif %}
</tr>
{% endfor %}
</table>
</section>
{% endblock %}

View File

@ -1,8 +1,59 @@
{% extends "aircox_web/base.html" %} {% load static i18n thumbnail %}
<html>
<head>
<meta charset="utf-8">
<meta name="application-name" content="aircox">
<meta name="description" content="{{ site.description }}">
<meta name="keywords" content="{{ site.tags }}">
<link rel="icon" href="{% thumbnail site.favicon 32x32 crop %}" />
{% block title %}{{ page.title }} -- {{ block.super }}{% endblock %} {% block assets %}
<link rel="stylesheet" type="text/css" href="{% static "aircox_web/assets/main.css" %}"/>
<script src="{% static "aircox_web/assets/main.js" %}"></script>
<script src="{% static "aircox_web/assets/vendor.js" %}"></script>
{% endblock %}
<title>{% block title %}{% if title %}{{ title }} -- {% endif %}{{ site.title }}{% endblock %}</title>
{% block extra_head %}{% endblock %}
</head>
<body>
<div id="app">
<nav class="navbar has-shadow" role="navigation" aria-label="main navigation">
<div class="container">
<div class="navbar-brand">
<a href="/" title="{% trans "Home" %}" class="navbar-item">
<img src="{{ site.logo.url }}" class="logo"/>
</a>
</div>
<div class="navbar-menu">
<div class="navbar-start">
{{ site_regions.topnav }}
</div>
</div>
</div>
</nav>
<div class="container">
<div class="columns">
<aside class="column is-one-quarter">
{% block left-sidebar %}
{{ site_regions.sidenav }}
{% endblock %}
</aside>
<main class="column page">
<header class="header">
{% block header %}
<h1 class="title is-1">{{ title }}</h1>
{% endblock %}
</header>
{% block main %}{% endblock main %}
</main>
</div>
</div>
</div>
</body>
</html>
{% block main %}
<h1 class="title">{{ page.title }}</h1>
{% endblock %}

View File

@ -0,0 +1,27 @@
{% extends "aircox_web/article.html" %}
{% load i18n %}
{% block headline %}
<section class="is-size-5">
{% for schedule in program.schedule_set.all %}
<p>
<strong>{{ schedule.datetime|date:"l H:i" }}</strong>
<small>
{{ schedule.get_frequency_display }}
{% if schedule.initial %}
{% with schedule.initial.date as date %}
<span title="{% blocktrans %}Rerun of {{ date }}{% endblocktrans %}">
/ {% trans "rerun" %}
</span>
{% endwith %}
{% endif %}
</small>
</p>
{% endfor %}
</section>
{{ block.super }}
{% endblock %}

View File

@ -0,0 +1,52 @@
{% extends "aircox_web/page.html" %}
{% load i18n aircox_web %}
{% block main %}
{{ block.super }}
<section class="section">
<h3 class="subtitle size-3">
{% blocktrans %}From <b>{{ start }}</b> to <b>{{ end }}</b>{% endblocktrans %}
</h3>
{% unique_id "timetable" as timetable_id %}
<a-tabs default="{{ date }}">
<template v-slot:tabs="scope" noscript="hidden">
<li><a href="{% url "timetable" date=prev_date %}">&lt;</a></li>
{% for day in by_date.keys %}
<a-tab value="{{ day }}">
<a href="{% url "timetable" date=day %}">
{{ day|date:"D. d" }}
</a>
</a-tab>
{% endfor %}
<li>
<a href="{% url "timetable" date=next_date %}">&gt;</a>
</li>
</template>
<template v-slot:default="{value}">
{% for day, diffusions in by_date.items %}
<noscript><h4 class="subtitle is-4">{{ day|date:"l d F Y" }}</h4></noscript>
<div id="{{timetable_id}}-{{ day|date:"Y-m-d" }}" v-if="value == '{{ day }}'">
{% for object in diffusions %}
<div class="columns">
<div class="column is-one-fifth has-text-right">
<time datetime="{{ object.start|date:"c" }}">
{{ object.start|date:"H:i" }} - {{ object.end|date:"H:i" }}
</time>
</div>
<div class="column">
{% include "aircox_web/diffusion_item.html" %}
</div>
</div>
{% endfor %}
</div>
{% endfor %}
</template>
</a-tabs>
</section>
{% endblock %}

View File

@ -0,0 +1,28 @@
import random
from django import template
from aircox import models as aircox
from aircox_web.models import Page
random.seed()
register = template.Library()
@register.simple_tag(name='diffusion_page')
def do_diffusion_page(diffusion):
""" Return page for diffusion. """
for obj in (diffusion, diffusion.program):
page = getattr(obj, 'page', None)
if page is not None and page.status is not Page.STATUS.draft:
return page
@register.simple_tag(name='unique_id')
def do_unique_id(prefix=''):
value = str(random.random()).replace('.', '')
return prefix + '_' + value if prefix else value
@register.filter(name='is_diffusion')
def do_is_diffusion(obj):
return isinstance(obj, aircox.Diffusion)

View File

@ -1,9 +1,23 @@
from django.conf.urls import url from django.conf.urls import url
from django.urls import path, register_converter
from . import views from . import views, models
from .converters import PagePathConverter, DateConverter
register_converter(PagePathConverter, 'page_path')
register_converter(DateConverter, 'date')
urlpatterns = [ urlpatterns = [
url(r"^(?P<path>[-\w/]+)/$", views.route_page, name="page"), path('diffusions/',
url(r"^$", views.route_page, name="root"), views.TimetableView.as_view(), name='timetable'),
path('diffusions/<date:date>',
views.TimetableView.as_view(), name='timetable'),
path('diffusions/all',
views.DiffusionsView.as_view(), name='diffusion-list'),
path('diffusions/<slug:program>',
views.DiffusionsView.as_view(), name='diffusion-list'),
path('logs/', views.LogsView.as_view(), name='logs'),
path('logs/<date:date>', views.LogsView.as_view(), name='logs'),
path('<page_path:path>', views.route_page, name='page'),
] ]

View File

@ -1,48 +1,241 @@
from django.db.models import Q from collections import OrderedDict, deque
from django.shortcuts import get_object_or_404, render import datetime
from django.views.generic.base import TemplateView
from django.core.paginator import Paginator
from django.shortcuts import get_object_or_404
from django.utils.translation import ugettext_lazy as _
from django.views.generic import TemplateView, ListView
from django.views.generic.base import TemplateResponseMixin, ContextMixin
from content_editor.contents import contents_for_item from content_editor.contents import contents_for_item
from aircox import models as aircox
from .models import Site, Page from .models import Site, Page
from .renderer import site_renderer, page_renderer from .renderer import site_renderer, page_renderer
def route_page(request, path=None, *args, site=None, **kwargs): def route_page(request, path=None, *args, model=None, site=None, **kwargs):
"""
Route request to page of the provided path. If model is provided, uses
it.
"""
# TODO/FIXME: django site framework | site from request host # TODO/FIXME: django site framework | site from request host
# TODO: extra page kwargs (as in pepr) # TODO: extra page kwargs (as in pepr)
site = Site.objects.all().order_by('-default').first() \ site = Site.objects.all().order_by('-default').first() \
if site is None else site if site is None else site
model = model if model is not None else Page
page = get_object_or_404( page = get_object_or_404(
# TODO: published model.objects.select_subclasses().active(),
Page.objects.select_subclasses() path=path
.filter(Q(status=Page.STATUS.published) |
Q(status=Page.STATUS.announced)),
path="/{}/".format(path) if path else "/",
) )
kwargs['page'] = page kwargs['page'] = page
return page.view(request, *args, site=site, **kwargs) return page.view(request, *args, site=site, **kwargs)
class PageView(TemplateView): class BaseView(TemplateResponseMixin, ContextMixin):
""" Base view class for pages. """ title = None
template_name = 'aircox_web/page.html'
site = None site = None
def dispatch(self, request, *args, site=None, **kwargs):
self.site = site if site is not None else \
Site.objects.all().order_by('-default').first()
return super().dispatch(request, *args, **kwargs)
def get_context_data(self, **kwargs):
if kwargs.get('site_regions') is None:
contents = contents_for_item(self.site, site_renderer._renderers.keys())
kwargs['site_regions'] = contents.render_regions(site_renderer)
kwargs.setdefault('site', self.site)
if self.title is not None:
kwargs.setdefault('title', self.title)
return super().get_context_data(**kwargs)
class ArticleView(BaseView, TemplateView):
""" Base view class for pages. """
template_name = 'aircox_web/article.html'
page = None page = None
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
page = kwargs.setdefault('page', self.page or self.kwargs.get('site')) # article content
site = kwargs.setdefault('site', self.site or self.kwargs.get('site')) page = kwargs.setdefault('page', self.page or self.kwargs.get('page'))
if kwargs.get('regions') is None: if kwargs.get('regions') is None:
contents = contents_for_item(page, page_renderer._renderers.keys()) contents = contents_for_item(page, page_renderer._renderers.keys())
kwargs['regions'] = contents.render_regions(page_renderer) kwargs['regions'] = contents.render_regions(page_renderer)
if kwargs.get('site_regions') is None: kwargs.setdefault('title', page.title)
contents = contents_for_item(site, site_renderer._renderers.keys())
kwargs['site_regions'] = contents.render_regions(site_renderer)
return super().get_context_data(**kwargs) return super().get_context_data(**kwargs)
class ProgramView(ArticleView):
""" Base view class for pages. """
template_name = 'aircox_web/program.html'
next_diffs_count = 5
def get_context_data(self, program=None, **kwargs):
# TODO: pagination
program = program or self.page.program
#next_diffs = program.diffusion_set.on_air().after().order_by('start')
return super().get_context_data(
program=program,
# next_diffs=next_diffs[:self.next_diffs_count],
**kwargs,
)
class DiffusionView(ArticleView):
template_name = 'aircox_web/diffusion.html'
class DiffusionsView(BaseView, ListView):
template_name = 'aircox_web/diffusions.html'
model = aircox.Diffusion
paginate_by = 10
title = _('Diffusions')
program = None
# TODO: get program object + display program title when filtered by program
# TODO: pagination: in template, only a limited number of pages displayed
def get_queryset(self):
qs = super().get_queryset().station(self.site.station).on_air() \
.filter(initial__isnull=True) #TODO, page__isnull=False)
program = self.kwargs.get('program')
if program:
qs = qs.filter(program__page__slug=program)
return qs.order_by('-start')
class TimetableView(BaseView, ListView):
""" View for timetables """
template_name = 'aircox_web/timetable.html'
model = aircox.Diffusion
title = _('Timetable')
date = None
start = None
end = None
def get_queryset(self):
self.date = self.kwargs.get('date', datetime.date.today())
self.start = self.date - datetime.timedelta(days=self.date.weekday())
self.end = self.date + datetime.timedelta(days=7-self.date.weekday())
return super().get_queryset().station(self.site.station) \
.range(self.start, self.end) \
.order_by('start')
def get_context_data(self, **kwargs):
# regoup by dates
by_date = OrderedDict()
date = self.start
while date < self.end:
by_date[date] = []
date += datetime.timedelta(days=1)
for diffusion in self.object_list:
if not diffusion.date in by_date:
continue
by_date[diffusion.date].append(diffusion)
return super().get_context_data(
by_date=by_date,
date=self.date,
start=self.start,
end=self.end - datetime.timedelta(days=1),
prev_date=self.start - datetime.timedelta(days=1),
next_date=self.end + datetime.timedelta(days=1),
**kwargs
)
class LogViewBase(ListView):
station = None
date = None
delta = None
def get_queryset(self):
# only get logs for tracks: log for diffusion will be retrieved
# by the diffusions' queryset.
return super().get_queryset().station(self.station).on_air() \
.at(self.date).filter(track__isnull=False)
def get_diffusions_queryset(self):
return aircox.Diffusion.objects.station(self.station).on_air() \
.at(self.date)
def get_object_list(self, queryset):
diffs = deque(self.get_diffusions_queryset().order_by('start'))
logs = list(queryset.order_by('date'))
if not len(diffs):
return logs
object_list = []
diff = diffs.popleft()
last_collision = None
# diff.start < log on first diff
# diff.end > log on last diff
for index, log in enumerate(logs):
# get next diff
if diff.end < log.date:
diff = diffs.popleft() if len(diffs) else None
# no more diff that can collide: return list
if diff is None:
return object_list + logs[index:]
# diff colliding with log
if diff.start <= log.date <= diff.end:
if object_list[-1] is not diff:
object_list.append(diff)
last_collision = log
else:
# add last colliding log: track
if last_collision is not None:
object_list.append(last_collision)
object_list.append(log)
last_collision = None
return object_list
class LogsView(BaseView, LogViewBase):
""" View for timetables """
template_name = 'aircox_web/logs.html'
model = aircox.Log
title = _('Logs')
date = None
max_age = 10
min_date = None
def get(self, request, *args, **kwargs):
self.station = self.site.station
today = datetime.date.today()
self.min_date = today - datetime.timedelta(days=self.max_age)
self.date = min(max(self.min_date, self.kwargs['date']), today) \
if 'date' in self.kwargs else today
return super().get(request, *args, **kwargs)
def get_context_data(self, **kwargs):
today = datetime.date.today()
max_date = min(max(self.date + datetime.timedelta(days=3),
self.min_date + datetime.timedelta(days=6)), today)
return super().get_context_data(
date=self.date,
min_date=self.min_date,
dates=(date for date in (
max_date - datetime.timedelta(days=i)
for i in range(0, 7)) if date >= self.min_date
),
object_list=self.get_object_list(self.object_list),
**kwargs
)

View File

@ -3,7 +3,7 @@ const webpack = require('webpack');
const MiniCssExtractPlugin = require("mini-css-extract-plugin"); const MiniCssExtractPlugin = require("mini-css-extract-plugin");
// const { createLodashAliases } = require('lodash-loader'); // const { createLodashAliases } = require('lodash-loader');
const { VueLoaderPlugin } = require('vue-loader'); const VueLoaderPlugin = require('vue-loader/lib/plugin');
module.exports = (env, argv) => Object({ module.exports = (env, argv) => Object({
@ -29,6 +29,13 @@ module.exports = (env, argv) => Object({
test: /[\\/]node_modules[\\/]/, test: /[\\/]node_modules[\\/]/,
}, },
/*noscript: {
name: 'noscript',
chunks: 'initial',
enforce: true,
test: /noscript/,
}*/
} }
} }
}, },
@ -43,6 +50,7 @@ module.exports = (env, argv) => Object({
module: { module: {
rules: [ rules: [
{ test: /\.vue$/, loader: 'vue-loader' },
{ {
test: /\/node_modules\//, test: /\/node_modules\//,
sideEffects: false sideEffects: false
@ -64,7 +72,6 @@ module.exports = (env, argv) => Object({
} }
}], }],
}, },
{ test: /\.vue$/, use: 'vue-loader' },
], ],
}, },