forked from rc/aircox
work on website; fix stuffs on aircox too
This commit is contained in:
parent
08d1c7bfac
commit
3e432c42b0
|
@ -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)
|
||||||
|
|
||||||
|
|
|
@ -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):
|
||||||
|
|
|
@ -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 = [
|
||||||
|
|
|
@ -54,4 +54,3 @@ class Command (BaseCommand):
|
||||||
if not qs.exists():
|
if not qs.exists():
|
||||||
break
|
break
|
||||||
date = qs.order_by('-date').first().date
|
date = qs.order_by('-date').first().date
|
||||||
|
|
||||||
|
|
|
@ -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,7 +26,6 @@ from aircox.models import *
|
||||||
|
|
||||||
logger = logging.getLogger('aircox.tools')
|
logger = logging.getLogger('aircox.tools')
|
||||||
|
|
||||||
import time
|
|
||||||
|
|
||||||
class Actions:
|
class Actions:
|
||||||
@classmethod
|
@classmethod
|
||||||
|
@ -149,4 +149,3 @@ class Command(BaseCommand):
|
||||||
Actions.clean(date)
|
Actions.clean(date)
|
||||||
if options.get('check'):
|
if options.get('check'):
|
||||||
Actions.check(date)
|
Actions.check(date)
|
||||||
|
|
||||||
|
|
|
@ -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:
|
||||||
|
@ -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
|
||||||
))
|
))
|
||||||
|
|
||||||
|
|
|
@ -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',
|
||||||
|
@ -97,9 +98,10 @@ class Sound:
|
||||||
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,6 +112,7 @@ 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
|
||||||
|
@ -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(
|
||||||
|
@ -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)
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
@ -230,7 +232,7 @@ class Monitor:
|
||||||
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)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -273,11 +275,9 @@ 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 {}
|
||||||
kwargs['type'] = Diffusion.Type.normal
|
qs = Diffusion.objects.station(station) \
|
||||||
|
.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 [])
|
||||||
|
@ -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()
|
||||||
|
|
||||||
|
|
160
aircox/models.py
160
aircox/models.py
|
@ -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}
|
||||||
|
|
||||||
if next:
|
|
||||||
qs = qs.filter(
|
|
||||||
models.Q(start__gte=date) | models.Q(**filters)
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
qs = qs.filter(**filters)
|
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
|
||||||
|
|
|
@ -4,52 +4,42 @@ 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,
|
||||||
|
@ -61,6 +51,7 @@ def to_timedelta (time):
|
||||||
seconds=time.second
|
seconds=time.second
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def seconds_to_time(seconds):
|
def seconds_to_time(seconds):
|
||||||
"""
|
"""
|
||||||
Seconds to datetime.time
|
Seconds to datetime.time
|
||||||
|
|
|
@ -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
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -1,3 +1,5 @@
|
||||||
import './js';
|
import './js';
|
||||||
import './styles.scss';
|
import './styles.scss';
|
||||||
|
import './noscript.scss';
|
||||||
|
import './vue';
|
||||||
|
|
||||||
|
|
|
@ -3,9 +3,12 @@ import Buefy from 'buefy';
|
||||||
|
|
||||||
Vue.use(Buefy);
|
Vue.use(Buefy);
|
||||||
|
|
||||||
|
window.addEventListener('load', () => {
|
||||||
var app = new Vue({
|
var app = new Vue({
|
||||||
el: '#app',
|
el: '#app',
|
||||||
|
delimiters: [ '[[', ']]' ],
|
||||||
})
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -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%;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
11
aircox_web/assets/vue/index.js
Normal file
11
aircox_web/assets/vue/index.js
Normal 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};
|
||||||
|
|
||||||
|
|
31
aircox_web/assets/vue/tab.vue
Normal file
31
aircox_web/assets/vue/tab.vue
Normal 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>
|
||||||
|
|
45
aircox_web/assets/vue/tabs.vue
Normal file
45
aircox_web/assets/vue/tabs.vue
Normal 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
49
aircox_web/converters.py
Normal 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)
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -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"
|
||||||
},
|
},
|
||||||
|
|
|
@ -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())
|
||||||
|
|
||||||
|
|
17
aircox_web/templates/aircox_web/article.html
Normal file
17
aircox_web/templates/aircox_web/article.html
Normal 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 %}
|
||||||
|
|
|
@ -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>
|
|
||||||
|
|
||||||
|
|
43
aircox_web/templates/aircox_web/diffusion_item.html
Normal file
43
aircox_web/templates/aircox_web/diffusion_item.html
Normal 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 %}
|
||||||
|
— <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 %}
|
||||||
|
|
46
aircox_web/templates/aircox_web/diffusions.html
Normal file
46
aircox_web/templates/aircox_web/diffusions.html
Normal 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 %}
|
||||||
|
|
19
aircox_web/templates/aircox_web/log_item.html
Normal file
19
aircox_web/templates/aircox_web/log_item.html
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
{% load i18n %}
|
||||||
|
|
||||||
|
{% with object.track as track %}
|
||||||
|
<span class="has-text-info is-size-5">♬</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 %}
|
||||||
|
|
51
aircox_web/templates/aircox_web/logs.html
Normal file
51
aircox_web/templates/aircox_web/logs.html
Normal 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 %}
|
||||||
|
|
|
@ -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" %}"/>
|
||||||
{% block main %}
|
<script src="{% static "aircox_web/assets/main.js" %}"></script>
|
||||||
<h1 class="title">{{ page.title }}</h1>
|
<script src="{% static "aircox_web/assets/vendor.js" %}"></script>
|
||||||
{% endblock %}
|
{% 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>
|
||||||
|
|
||||||
|
|
||||||
|
|
27
aircox_web/templates/aircox_web/program.html
Normal file
27
aircox_web/templates/aircox_web/program.html
Normal 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 %}
|
||||||
|
|
||||||
|
|
52
aircox_web/templates/aircox_web/timetable.html
Normal file
52
aircox_web/templates/aircox_web/timetable.html
Normal 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 %}"><</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 %}">></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 %}
|
||||||
|
|
28
aircox_web/templatetags/aircox_web.py
Normal file
28
aircox_web/templatetags/aircox_web.py
Normal 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)
|
||||||
|
|
||||||
|
|
|
@ -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'),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
)
|
||||||
|
|
||||||
|
|
|
@ -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' },
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
Loading…
Reference in New Issue
Block a user