add episode: models, admin, diffusion gen, sounds_monitor, playlist import

This commit is contained in:
bkfox 2019-07-29 18:58:45 +02:00
parent fff4801ac7
commit 8581743d13
95 changed files with 1976 additions and 17373 deletions

View File

@ -1,5 +1,28 @@
from .base import *
from .diffusion import DiffusionAdmin
from django.contrib import admin
from .episode import DiffusionAdmin, EpisodeAdmin
# from .playlist import PlaylistAdmin
from .program import ProgramAdmin, ScheduleAdmin, StreamAdmin
from .sound import SoundAdmin
from aircox.models import Log, Port, Station
class PortInline(admin.StackedInline):
model = Port
extra = 0
@admin.register(Station)
class StationAdmin(admin.ModelAdmin):
prepopulated_fields = {'slug': ('name',)}
inlines = [PortInline]
@admin.register(Log)
class LogAdmin(admin.ModelAdmin):
list_display = ['id', 'date', 'station', 'source', 'type', 'diffusion', 'sound', 'track']
list_filter = ['date', 'source', 'station']

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -1,91 +0,0 @@
from django import forms
from django.contrib import admin
from django.urls import reverse
from django.utils.translation import ugettext as _, ugettext_lazy
from django.utils.safestring import mark_safe
from adminsortable2.admin import SortableInlineAdminMixin
from aircox.models import *
class ScheduleInline(admin.TabularInline):
model = Schedule
extra = 1
class StreamInline(admin.TabularInline):
fields = ['delay', 'begin', 'end']
model = Stream
extra = 1
@admin.register(Stream)
class StreamAdmin(admin.ModelAdmin):
list_display = ('id', 'program', 'delay', 'begin', 'end')
@admin.register(Program)
class ProgramAdmin(admin.ModelAdmin):
def schedule(self, obj):
return Schedule.objects.filter(program=obj).count() > 0
schedule.boolean = True
schedule.short_description = _("Schedule")
list_display = ('name', 'id', 'active', 'schedule', 'sync', 'station')
fields = ['name', 'slug', 'active', 'station', 'sync']
prepopulated_fields = {'slug': ('name',)}
search_fields = ['name']
inlines = [ScheduleInline, StreamInline]
@admin.register(Schedule)
class ScheduleAdmin(admin.ModelAdmin):
def program_name(self, obj):
return obj.program.name
program_name.short_description = _('Program')
def day(self, obj):
return '' # obj.date.strftime('%A')
day.short_description = _('Day')
def rerun(self, obj):
return obj.initial is not None
rerun.short_description = _('Rerun')
rerun.boolean = True
list_filter = ['frequency', 'program']
list_display = ['id', 'program_name', 'frequency', 'day', 'date',
'time', 'duration', 'timezone', 'rerun']
list_editable = ['time', 'timezone', 'duration']
def get_readonly_fields(self, request, obj=None):
if obj:
return ['program', 'date', 'frequency']
else:
return []
# TODO: sort & redo
class PortInline(admin.StackedInline):
model = Port
extra = 0
@admin.register(Station)
class StationAdmin(admin.ModelAdmin):
prepopulated_fields = {'slug': ('name',)}
inlines = [PortInline]
@admin.register(Log)
class LogAdmin(admin.ModelAdmin):
list_display = ['id', 'date', 'station', 'source', 'type', 'diffusion', 'sound', 'track']
list_filter = ['date', 'source', 'station']
admin.site.register(Port)

View File

@ -9,7 +9,7 @@ from .playlist import TracksInline
class SoundInline(admin.TabularInline):
model = Sound
fk_name = 'diffusion'
fields = ['type', 'path', 'duration','public']
fields = ['type', 'path', 'duration', 'is_public']
readonly_fields = ['type']
extra = 0

82
aircox/admin/episode.py Normal file
View File

@ -0,0 +1,82 @@
import copy
from django.contrib import admin
from django.utils.translation import ugettext as _, ugettext_lazy
from aircox.models import Episode, Diffusion, Sound, Track
from .page import PageAdmin
from .playlist import TracksInline
class DiffusionBaseAdmin:
fields = ['type', 'start', 'end']
def get_readonly_fields(self, request, obj=None):
fields = super().get_readonly_fields(request, obj)
if not request.user.has_perm('aircox_program.scheduling'):
fields += ['program', 'start', 'end']
return [field for field in fields if field in self.fields]
@admin.register(Diffusion)
class DiffusionAdmin(DiffusionBaseAdmin, admin.ModelAdmin):
def start_date(self, obj):
return obj.local_start.strftime('%Y/%m/%d %H:%M')
start_date.short_description = _('start')
def end_date(self, obj):
return obj.local_end.strftime('%H:%M')
end_date.short_description = _('end')
list_display = ('episode', 'start_date', 'end_date', 'type', 'initial')
list_filter = ('type', 'start', 'program')
list_editable = ('type',)
ordering = ('-start', 'id')
fields = ['type', 'start', 'end', 'initial', 'program']
def get_object(self, *args, **kwargs):
"""
We want rerun to redirect to the given object.
"""
obj = super().get_object(*args, **kwargs)
if obj and obj.initial:
obj = obj.initial
return obj
def get_queryset(self, request):
qs = super().get_queryset(request)
if request.GET and len(request.GET):
return qs
return qs.exclude(type=Diffusion.Type.unconfirmed)
class DiffusionInline(DiffusionBaseAdmin, admin.TabularInline):
model = Diffusion
fk_name = 'episode'
extra = 0
def has_add_permission(self, request):
return request.user.has_perm('aircox_program.scheduling')
class SoundInline(admin.TabularInline):
model = Sound
fk_name = 'episode'
fields = ['type', 'path', 'duration', 'is_public']
readonly_fields = ['type']
extra = 0
@admin.register(Episode)
class EpisodeAdmin(PageAdmin):
list_display = PageAdmin.list_display + ('program',)
list_filter = ('program',)
readonly_fields = ('program',)
fieldsets = copy.deepcopy(PageAdmin.fieldsets)
fieldsets[1][1]['fields'].insert(0, 'program')
inlines = [TracksInline, SoundInline, DiffusionInline]

28
aircox/admin/page.py Normal file
View File

@ -0,0 +1,28 @@
from django.contrib import admin
from django.utils.safestring import mark_safe
from django.utils.translation import ugettext_lazy as _
class PageAdmin(admin.ModelAdmin):
list_display = ('cover_thumb', 'title', 'status')
list_display_links = ('cover_thumb', 'title')
list_editable = ('status',)
prepopulated_fields = {"slug": ("title",)}
fieldsets = [
('', {
'fields': ['title', 'slug', 'cover', 'content'],
}),
(_('Publication Settings'), {
'fields': ['featured', 'allow_comments', 'status'],
'classes': ('collapse',),
}),
]
def cover_thumb(self, obj):
return mark_safe('<img src="{}"/>'.format(obj.cover.icons['64'])) \
if obj.cover else ''

View File

@ -21,19 +21,19 @@ class TrackAdmin(admin.ModelAdmin):
def tag_list(self, obj):
return u", ".join(o.name for o in obj.tags.all())
list_display = ['pk', 'artist', 'title', 'tag_list', 'diffusion', 'sound', 'timestamp']
list_display = ['pk', 'artist', 'title', 'tag_list', 'episode', 'sound', 'timestamp']
list_editable = ['artist', 'title']
list_filter = ['sound', 'diffusion', 'artist', 'title', 'tags']
list_filter = ['sound', 'episode', 'artist', 'title', 'tags']
fieldsets = [
(_('Playlist'), {'fields': ['diffusion', 'sound', 'position', 'timestamp']}),
(_('Playlist'), {'fields': ['episode', 'sound', 'position', 'timestamp']}),
(_('Info'), {'fields': ['artist', 'title', 'info', 'tags']}),
]
# TODO on edit: readonly_fields = ['diffusion', 'sound']
# TODO on edit: readonly_fields = ['episode', 'sound']
#@admin.register(Playlist)
#class PlaylistAdmin(admin.ModelAdmin):
# fields = ['diffusion', 'sound']
# fields = ['episode', 'sound']
# inlines = [TracksInline]
# # TODO: dynamic read only fields

75
aircox/admin/program.py Normal file
View File

@ -0,0 +1,75 @@
from copy import deepcopy
from django.contrib import admin
from django.utils.translation import ugettext_lazy as _
from aircox.models import Program, Schedule, Stream
from .page import PageAdmin
class ScheduleInline(admin.TabularInline):
model = Schedule
extra = 1
class StreamInline(admin.TabularInline):
fields = ['delay', 'begin', 'end']
model = Stream
extra = 1
@admin.register(Program)
class ProgramAdmin(PageAdmin):
def schedule(self, obj):
return Schedule.objects.filter(program=obj).count() > 0
schedule.boolean = True
schedule.short_description = _("Schedule")
list_display = PageAdmin.list_display + ('schedule', 'station')
fieldsets = deepcopy(PageAdmin.fieldsets) + [
(_('Program Settings'), {
'fields': ['active', 'station', 'sync'],
'classes': ('collapse',),
})
]
prepopulated_fields = {'slug': ('title',)}
search_fields = ['title']
inlines = [ScheduleInline, StreamInline]
@admin.register(Schedule)
class ScheduleAdmin(admin.ModelAdmin):
def program_title(self, obj):
return obj.program.title
program_title.short_description = _('Program')
def freq(self, obj):
return obj.get_frequency_verbose()
freq.short_description = _('Day')
def rerun(self, obj):
return obj.initial is not None
rerun.short_description = _('Rerun')
rerun.boolean = True
list_filter = ['frequency', 'program']
list_display = ['program_title', 'freq', 'time', 'timezone', 'duration',
'rerun']
list_editable = ['time', 'duration']
def get_readonly_fields(self, request, obj=None):
if obj:
return ['program', 'date', 'frequency']
else:
return []
@admin.register(Stream)
class StreamAdmin(admin.ModelAdmin):
list_display = ('id', 'program', 'delay', 'begin', 'end')

View File

@ -7,12 +7,16 @@ from .playlist import TracksInline
@admin.register(Sound)
class SoundAdmin(admin.ModelAdmin):
def filename(self, obj):
return '/'.join(obj.path.split('/')[-2:])
filename.short_description=_('file')
fields = None
list_display = ['id', 'name', 'program', 'type', 'duration', 'mtime',
'is_public', 'is_good_quality', 'path']
list_display = ['id', 'name', 'program', 'type', 'duration',
'is_public', 'is_good_quality', 'episode', 'filename']
list_filter = ('program', 'type', 'is_good_quality', 'is_public')
fieldsets = [
(None, {'fields': ['name', 'path', 'type', 'program', 'diffusion']}),
(None, {'fields': ['name', 'path', 'type', 'program', 'episode']}),
(None, {'fields': ['embed', 'duration', 'is_public', 'mtime']}),
(None, {'fields': ['is_good_quality']})
]

View File

@ -15,60 +15,57 @@ planified before the (given) month.
- "check" will remove all diffusions that are unconfirmed and have been planified
from the (given) month and later.
"""
import time
import datetime
import logging
from argparse import RawTextHelpFormatter
from django.core.management.base import BaseCommand, CommandError
from django.db import transaction
from django.utils import timezone as tz
from aircox.models import *
from aircox.models import Schedule, Diffusion
logger = logging.getLogger('aircox.tools')
class Actions:
@classmethod
def update(cl, date, mode):
manual = (mode == 'manual')
date = None
count = [0, 0]
for schedule in Schedule.objects.filter(program__active=True) \
.order_by('initial'):
# in order to allow rerun links between diffusions, we save items
# by schedule;
items = schedule.diffusions_of_month(date, exclude_saved=True)
count[0] += len(items)
def __init__(self, date):
self.date = date or datetime.date.today()
# we can't bulk create because we need signal processing
for item in items:
conflicts = item.get_conflicts()
item.type = Diffusion.Type.unconfirmed \
if manual or conflicts.count() else \
Diffusion.Type.normal
item.save(no_check=True)
if conflicts.count():
item.conflicts.set(conflicts.all())
def update(self):
episodes, diffusions = [], []
for schedule in Schedule.objects.filter(program__active=True,
initial__isnull=True):
eps, diffs = schedule.diffusions_of_month(self.date)
logger.info('[update] schedule %s: %d new diffusions',
str(schedule), len(items),
)
episodes += eps
diffusions += diffs
logger.info('[update] %d diffusions have been created, %s', count[0],
'do not forget manual approval' if manual else
'{} conflicts found'.format(count[1]))
logger.info('[update] %s: %d episodes, %d diffusions and reruns',
str(schedule), len(eps), len(diffs))
@staticmethod
def clean(date):
with transaction.atomic():
logger.info('[update] save %d episodes and %d diffusions',
len(episodes), len(diffusions))
for episode in episodes:
episode.save()
for diffusion in diffusions:
# force episode id's update
diffusion.episode = diffusion.episode
diffusion.save()
def clean(self):
qs = Diffusion.objects.filter(type=Diffusion.Type.unconfirmed,
start__lt=date)
start__lt=self.date)
logger.info('[clean] %d diffusions will be removed', qs.count())
qs.delete()
@staticmethod
def check(date):
def check(self):
# TODO: redo
qs = Diffusion.objects.filter(type=Diffusion.Type.unconfirmed,
start__gt=date)
start__gt=self.date)
items = []
for diffusion in qs:
schedules = Schedule.objects.filter(program=diffusion.program)
@ -88,21 +85,21 @@ class Command(BaseCommand):
def add_arguments(self, parser):
parser.formatter_class = RawTextHelpFormatter
now = tz.datetime.today()
today = datetime.date.today()
group = parser.add_argument_group('action')
group.add_argument(
'--update', action='store_true',
'-u', '--update', action='store_true',
help='generate (unconfirmed) diffusions for the given month. '
'These diffusions must be confirmed manually by changing '
'their type to "normal"'
)
group.add_argument(
'--clean', action='store_true',
'-l', '--clean', action='store_true',
help='remove unconfirmed diffusions older than the given month'
)
group.add_argument(
'--check', action='store_true',
'-c', '--check', action='store_true',
help='check unconfirmed later diffusions from the given '
'date agains\'t schedule. If no schedule is found, remove '
'it.'
@ -110,10 +107,10 @@ class Command(BaseCommand):
group = parser.add_argument_group('date')
group.add_argument(
'--year', type=int, default=now.year,
'--year', type=int, default=today.year,
help='used by update, default is today\'s year')
group.add_argument(
'--month', type=int, default=now.month,
'--month', type=int, default=today.month,
help='used by update, default is today\'s month')
group.add_argument(
'--next-month', action='store_true',
@ -121,31 +118,20 @@ class Command(BaseCommand):
' (if next month from today'
)
group = parser.add_argument_group('options')
group.add_argument(
'--mode', type=str, choices=['manual', 'auto'],
default='auto',
help='manual means that all generated diffusions are unconfirmed, '
'thus must be approved manually; auto confirmes all '
'diffusions except those that conflicts with others'
)
def handle(self, *args, **options):
date = tz.datetime(year=options.get('year'),
month=options.get('month'),
day=1)
date = tz.make_aware(date)
date = datetime.date(year=options['year'], month=options['month'],
day=1)
if options.get('next_month'):
month = options.get('month')
date += tz.timedelta(days=28)
if date.month == month:
date += tz.timedelta(days=28)
date = date.replace(day=1)
actions = Actions(date)
if options.get('update'):
Actions.update(date, mode=options.get('mode'))
actions.update()
if options.get('clean'):
Actions.clean(date)
actions.clean()
if options.get('check'):
Actions.check(date)
actions.check()

View File

@ -1,6 +1,6 @@
"""
Import one or more playlist for the given sound. Attach it to the sound
or to the related Diffusion if wanted.
Import one or more playlist for the given sound. Attach it to the provided
sound.
Playlists are in CSV format, where columns are separated with a
'{settings.AIRCOX_IMPORT_PLAYLIST_CSV_DELIMITER}'. Text quote is
@ -18,14 +18,15 @@ from argparse import RawTextHelpFormatter
from django.core.management.base import BaseCommand, CommandError
from django.contrib.contenttypes.models import ContentType
from aircox import settings
from aircox.models import *
import aircox.settings as settings
__doc__ = __doc__.format(settings=settings)
logger = logging.getLogger('aircox.tools')
class Importer:
class PlaylistImport:
path = None
data = None
tracks = None
@ -121,17 +122,12 @@ class Command (BaseCommand):
help='generate a playlist for the sound of the given path. '
'If not given, try to match a sound with the same path.'
)
parser.add_argument(
'--diffusion', '-d', action='store_true',
help='try to get the diffusion relative to the sound if it exists'
)
def handle(self, path, *args, **options):
# FIXME: absolute/relative path of sounds vs given path
if options.get('sound'):
sound = Sound.objects.filter(
path__icontains=options.get('sound')
).first()
sound = Sound.objects.filter(path__icontains=options.get('sound'))\
.first()
else:
path_, ext = os.path.splitext(path)
sound = Sound.objects.filter(path__icontains=path_).first()
@ -141,11 +137,10 @@ class Command (BaseCommand):
'{path}'.format(path=path))
return
if options.get('diffusion') and sound.diffusion:
sound = sound.diffusion
importer = Importer(path, sound=sound).run()
# FIXME: auto get sound.episode if any
importer = PlaylistImport(path, sound=sound).run()
for track in importer.tracks:
logger.info('track #{pos} imported: {title}, by {artist}'.format(
pos=track.position, title=track.title, artist=track.artist
))

View File

@ -23,6 +23,7 @@ parameters given by the setting AIRCOX_SOUND_QUALITY. This script requires
Sox (and soxi).
"""
from argparse import RawTextHelpFormatter
import datetime
import atexit
import logging
import os
@ -37,13 +38,21 @@ from django.conf import settings as main_settings
from django.core.management.base import BaseCommand, CommandError
from django.utils import timezone as tz
from aircox.models import *
import aircox.settings as settings
import aircox.utils as utils
from aircox import settings, utils
from aircox.models import Diffusion, Program, Sound
from .import_playlist import PlaylistImport
logger = logging.getLogger('aircox.tools')
sound_path_re = re.compile(
'^(?P<year>[0-9]{4})(?P<month>[0-9]{2})(?P<day>[0-9]{2})'
'(_(?P<hour>[0-9]{2})h(?P<minute>[0-9]{2}))?'
'(_(?P<n>[0-9]+))?'
'_?(?P<name>.*)$'
)
class SoundInfo:
name = ''
sound = None
@ -66,33 +75,19 @@ class SoundInfo:
Parse file name to get info on the assumption it has the correct
format (given in Command.help)
"""
file_name = os.path.basename(value)
file_name = os.path.splitext(file_name)[0]
r = re.search('^(?P<year>[0-9]{4})'
'(?P<month>[0-9]{2})'
'(?P<day>[0-9]{2})'
'(_(?P<hour>[0-9]{2})h(?P<minute>[0-9]{2}))?'
'(_(?P<n>[0-9]+))?'
'_?(?P<name>.*)$',
file_name)
if not (r and r.groupdict()):
r = {'name': file_name}
logger.info('file name can not be parsed -> %s', value)
else:
r = r.groupdict()
name = os.path.splitext(os.path.basename(value))[0]
match = sound_path_re.search(name)
match = match.groupdict() if match and match.groupdict() else \
{'name': name}
self._path = value
self.name = r['name'].replace('_', ' ').capitalize()
self.name = match['name'].replace('_', ' ').capitalize()
for key in ('year', 'month', 'day', 'hour', 'minute'):
value = r.get(key)
if value is not None:
value = int(value)
setattr(self, key, value)
value = match.get(key)
setattr(self, key, int(value) if value is not None else None)
self.n = r.get('n')
return r
self.n = match.get('n')
def __init__(self, path='', sound=None):
self.path = path
@ -116,9 +111,8 @@ class SoundInfo:
(if save is True, sync to DB), and check for a playlist file.
"""
sound, created = Sound.objects.get_or_create(
path=self.path,
defaults=kwargs
)
path=self.path, defaults=kwargs)
if created or sound.check_on_file():
logger.info('sound is new or have been modified -> %s', self.path)
sound.duration = self.get_duration()
@ -139,22 +133,17 @@ class SoundInfo:
if sound.track_set.count():
return
import aircox.management.commands.import_playlist \
as import_playlist
# no playlist, try to retrieve metadata
# import playlist
path = os.path.splitext(self.sound.path)[0] + '.csv'
if not os.path.exists(path):
if use_default:
track = sound.file_metadata()
if track:
track.save()
return
if os.path.exists(path):
PlaylistImport(path, sound=sound).run()
# try metadata
elif use_default:
track = sound.file_metadata()
if track:
track.save()
# else, import
import_playlist.Importer(path, sound=sound).run()
def find_diffusion(self, program, save=True):
def find_episode(self, program, save=True):
"""
For a given program, check if there is an initial diffusion
to associate to, using the date info we have. Update self.sound
@ -163,25 +152,22 @@ class SoundInfo:
We only allow initial diffusion since there should be no
rerun.
"""
if self.year == None or not self.sound or self.sound.diffusion:
if self.year is None or not self.sound or self.sound.episode:
return
if self.hour is None:
date = datetime.date(self.year, self.month, self.day)
else:
date = datetime.datetime(self.year, self.month, self.day,
self.hour or 0, self.minute or 0)
date = tz.datetime(self.year, self.month, self.day,
self.hour or 0, self.minute or 0)
date = tz.get_current_timezone().localize(date)
qs = Diffusion.objects.station(program.station).after(date) \
.filter(program=program, initial__isnull=True)
diffusion = qs.first()
diffusion = program.diffusion_set.initial().at(date).first()
if not diffusion:
return
logger.info('diffusion %s mathes to sound -> %s', str(diffusion),
self.sound.path)
self.sound.diffusion = diffusion
logger.info('%s <--> %s', self.sound.path, str(diffusion.episode))
self.sound.episode = diffusion.episode
if save:
self.sound.save()
return diffusion
@ -219,7 +205,7 @@ class MonitorHandler(PatternMatchingEventHandler):
self.sound_kwargs['program'] = program
si.get_sound(save=True, **self.sound_kwargs)
if si.year is not None:
si.find_diffusion(program)
si.find_episode(program)
si.sound.save(True)
def on_deleted(self, event):
@ -246,7 +232,7 @@ class MonitorHandler(PatternMatchingEventHandler):
if program:
si = SoundInfo(sound.path, sound=sound)
if si.year is not None:
si.find_diffusion(program)
si.find_episode(program)
sound.save()
@ -270,7 +256,7 @@ class Command(BaseCommand):
dirs = []
for program in programs:
logger.info('#%d %s', program.id, program.name)
logger.info('#%d %s', program.id, program.title)
self.scan_for_program(
program, settings.AIRCOX_SOUND_ARCHIVES_SUBDIR,
type=Sound.Type.archive,
@ -304,7 +290,7 @@ class Command(BaseCommand):
si = SoundInfo(path)
sound_kwargs['program'] = program
si.get_sound(save=True, **sound_kwargs)
si.find_diffusion(program, save=True)
si.find_episode(program, save=True)
si.find_playlist(si.sound)
sounds.append(si.sound.pk)

View File

@ -216,7 +216,7 @@ class Monitor:
return
qs = Diffusions.objects.station(self.station).at().filter(
type=Diffusion.Type.normal,
type=Diffusion.Type.on_air,
sound__type=Sound.Type.archive,
)
logs = Log.objects.station(station).on_air().with_diff()

View File

@ -153,12 +153,12 @@ class Station(models.Model):
if date:
logs = Log.objects.at(date)
diffs = Diffusion.objects.station(self).at(date) \
.filter(start__lte=now, type=Diffusion.Type.normal) \
.filter(start__lte=now, type=Diffusion.Type.on_air) \
.order_by('-start')
else:
logs = Log.objects
diffs = Diffusion.objects \
.filter(type=Diffusion.Type.normal,
.filter(type=Diffusion.Type.on_air,
start__lte=now) \
.order_by('-start')[:count]
@ -653,7 +653,7 @@ class DiffusionQuerySet(models.QuerySet):
return self.filter(program=program)
def on_air(self):
return self.filter(type=Diffusion.Type.normal)
return self.filter(type=Diffusion.Type.on_air)
def at(self, date=None):
"""
@ -811,7 +811,7 @@ class Diffusion(models.Model):
True if Diffusion is live (False if there are sounds files)
"""
return self.type == self.Type.normal and \
return self.type == self.Type.on_air and \
not self.get_sounds(archive=True).count()
def get_playlist(self, **types):

View File

@ -0,0 +1,8 @@
from .page import Page
from .program import Program, Stream, Schedule
from .episode import Episode, Diffusion
from .log import Log
from .sound import Sound, Track
from .station import Station, Port

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

296
aircox/models/episode.py Normal file
View File

@ -0,0 +1,296 @@
import datetime
from enum import IntEnum
from django.db import models
from django.db.models import F, Q
from django.db.models.functions import Concat, Substr
from django.utils import timezone as tz
from django.utils.translation import ugettext_lazy as _
from django.utils.functional import cached_property
from aircox import settings, utils
from .program import Program, BaseRerun, BaseRerunQuerySet
from .page import Page, PageQuerySet
__all__ = ['Episode', 'EpisodeQuerySet', 'Diffusion', 'DiffusionQuerySet']
class EpisodeQuerySet(PageQuerySet):
def station(self, station):
return self.filter(program__station=station)
# FIXME: useful??? might use program.episode_set
def program(self, program):
return self.filter(program=program)
class Episode(Page):
program = models.ForeignKey(
Program, models.CASCADE,
verbose_name=_('program'),
)
objects = EpisodeQuerySet.as_manager()
class Meta:
verbose_name = _('Episode')
verbose_name_plural = _('Episodes')
def save(self, *args, **kwargs):
if self.cover is None:
self.cover = self.program.cover
super().save(*args, **kwargs)
@classmethod
def from_date(cls, program, date):
title = settings.AIRCOX_EPISODE_TITLE.format(
program=program,
date=date.strftime(settings.AIRCOX_EPISODE_TITLE_DATE_FORMAT),
)
return cls(program=program, title=title)
class DiffusionQuerySet(BaseRerunQuerySet):
def station(self, station):
return self.filter(episode__program__station=station)
def program(self, program):
return self.filter(program=program)
def on_air(self):
return self.filter(type=Diffusion.Type.on_air)
def at(self, date=None):
"""
Return diffusions occuring at the given date, ordered by +start
If date is a datetime instance, get diffusions that occurs at
the given moment. If date is not a datetime object, it uses
it as a date, and get diffusions that occurs this day.
When date is None, uses tz.now().
"""
# note: we work with localtime
date = utils.date_or_default(date)
qs = self
filters = None
if isinstance(date, datetime.datetime):
# use datetime: we want diffusion that occurs around this
# range
filters = {'start__lte': date, 'end__gte': date}
qs = qs.filter(**filters)
else:
# use date: we want diffusions that occurs this day
qs = qs.filter(Q(start__date=date) | Q(end__date=date))
return qs.order_by('start').distinct()
def after(self, date=None):
"""
Return a queryset of diffusions that happen after the given
date (default: today).
"""
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')
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(BaseRerun):
"""
A Diffusion is an occurrence of a Program that is scheduled on the
station's timetable. It can be a rerun of a previous diffusion. In such
a case, use rerun's info instead of its own.
A Diffusion without any rerun is named Episode (previously, a
Diffusion was different from an Episode, but in the end, an
episode only has a name, a linked program, and a list of sounds, so we
finally merge theme).
A Diffusion can have different types:
- default: simple diffusion that is planified / did occurred
- unconfirmed: a generated diffusion that has not been confirmed and thus
is not yet planified
- cancel: the diffusion has been canceled
- stop: the diffusion has been manually stopped
"""
objects = DiffusionQuerySet.as_manager()
class Type(IntEnum):
on_air = 0x00
unconfirmed = 0x01
canceled = 0x02
episode = models.ForeignKey(
Episode, models.CASCADE,
verbose_name=_('episode'),
)
type = models.SmallIntegerField(
verbose_name=_('type'),
default=Type.on_air,
choices=[(int(y), _(x.replace('_', ' ')))
for x, y in Type.__members__.items()],
)
start = models.DateTimeField(_('start'))
end = models.DateTimeField(_('end'))
# port = models.ForeignKey(
# 'self',
# verbose_name = _('port'),
# blank = True, null = True,
# on_delete=models.SET_NULL,
# help_text = _('use this input port'),
# )
class Meta:
verbose_name = _('Diffusion')
verbose_name_plural = _('Diffusions')
permissions = (
('programming', _('edit the diffusion\'s planification')),
)
def __str__(self):
str_ = '{episode} - {date}'.format(
self=self, episode=self.episode and self.episode.title,
date=self.local_start.strftime('%Y/%m/%d %H:%M%z'),
)
if self.initial:
str_ += ' ({})'.format(_('rerun'))
return str_
#def save(self, no_check=False, *args, **kwargs):
#if self.start != self._initial['start'] or \
# self.end != self._initial['end']:
# self.check_conflicts()
def save_rerun(self):
self.episode = self.initial.episode
self.program = self.episode.program
def save_original(self):
self.program = self.episode.program
if self.episode != self._initial['episode']:
self.rerun_set.update(episode=self.episode, program=self.program)
@property
def duration(self):
return self.end - self.start
@property
def date(self):
""" Return diffusion start as a date. """
return utils.cast_date(self.start)
@cached_property
def local_start(self):
"""
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
to get it as local time.
"""
return tz.localtime(self.start, tz.get_current_timezone())
@property
def local_end(self):
"""
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
to get it as local time.
"""
return tz.localtime(self.end, tz.get_current_timezone())
@property
def original(self):
""" Return the original diffusion (self or initial) """
return self.initial.original if self.initial else self
# TODO: property?
def is_live(self):
"""
True if Diffusion is live (False if there are sounds files)
"""
return self.type == self.Type.on_air and \
not self.get_sounds(archive=True).count()
def get_playlist(self, **types):
"""
Returns sounds as a playlist (list of *local* archive file path).
The given arguments are passed to ``get_sounds``.
"""
from .sound import Sound
return list(self.get_sounds(**types)
.filter(path__isnull=False, type=Sound.Type.archive)
.values_list('path', flat=True))
def get_sounds(self, **types):
"""
Return a queryset of sounds related to this diffusion,
ordered by type then path.
**types: filter on the given sound types name, as `archive=True`
"""
from .sound import Sound
sounds = (self.initial or self).sound_set.order_by('type', 'path')
_in = [getattr(Sound.Type, name)
for name, value in types.items() if value]
return sounds.filter(type__in=_in)
def is_date_in_range(self, date=None):
"""
Return true if the given date is in the diffusion's start-end
range.
"""
date = date or tz.now()
return self.start < date < self.end
def get_conflicts(self):
""" Return conflicting diffusions queryset """
# conflicts=Diffusion.objects.filter(Q(start__lt=OuterRef('start'), end__gt=OuterRef('end')) | Q(start__gt=OuterRef('start'), start__lt=OuterRef('end')))
# diffs= Diffusion.objects.annotate(conflict_with=Exists(conflicts)).filter(conflict_with=True)
return Diffusion.objects.filter(
Q(start__lt=self.start, end__gt=self.start) |
Q(start__gt=self.start, start__lt=self.end)
).exclude(pk=self.pk).distinct()
def check_conflicts(self):
conflicts = self.get_conflicts()
self.conflicts.set(conflicts)
_initial = None
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._initial = {
'start': self.start,
'end': self.end,
'episode': getattr(self, 'episode', None),
}

264
aircox/models/log.py Normal file
View File

@ -0,0 +1,264 @@
import datetime
from enum import IntEnum
import logging
import os
from django.db import models
from django.utils import timezone as tz
from django.utils.translation import ugettext_lazy as _
from aircox import settings, utils
from .episode import Diffusion
from .sound import Sound, Track
from .station import Station
logger = logging.getLogger('aircox')
__all__ = ['Log', 'LogQuerySet']
class LogQuerySet(models.QuerySet):
def station(self, station):
return self.filter(station=station)
def at(self, date=None):
date = utils.date_or_default(date)
return self.filter(date__date=date)
def on_air(self):
return self.filter(type=Log.Type.on_air)
def start(self):
return self.filter(type=Log.Type.start)
def with_diff(self, with_it=True):
return self.filter(diffusion__isnull=not with_it)
def with_sound(self, with_it=True):
return self.filter(sound__isnull=not with_it)
def with_track(self, with_it=True):
return self.filter(track__isnull=not with_it)
@staticmethod
def _get_archive_path(station, date):
# note: station name is not included in order to avoid problems
# of retrieving archive when it changes
return os.path.join(
settings.AIRCOX_LOGS_ARCHIVES_DIR,
'{}_{}.log.gz'.format(date.strftime("%Y%m%d"), station.pk)
)
@staticmethod
def _get_rel_objects(logs, type, attr):
"""
From a list of dict representing logs, retrieve related objects
of the given type.
Example: _get_rel_objects([{..},..], Diffusion, 'diffusion')
"""
attr_id = attr + '_id'
return {
rel.pk: rel
for rel in type.objects.filter(
pk__in=(
log[attr_id]
for log in logs if attr_id in log
)
)
}
def load_archive(self, station, date):
"""
Return archived logs for a specific date as a list
"""
import yaml
import gzip
path = self._get_archive_path(station, date)
if not os.path.exists(path):
return []
with gzip.open(path, 'rb') as archive:
data = archive.read()
logs = yaml.load(data)
# we need to preload diffusions, sounds and tracks
rels = {
'diffusion': self._get_rel_objects(logs, Diffusion, 'diffusion'),
'sound': self._get_rel_objects(logs, Sound, 'sound'),
'track': self._get_rel_objects(logs, Track, 'track'),
}
def rel_obj(log, attr):
attr_id = attr + '_id'
rel_id = log.get(attr + '_id')
return rels[attr][rel_id] if rel_id else None
# make logs
return [
Log(diffusion=rel_obj(log, 'diffusion'),
sound=rel_obj(log, 'sound'),
track=rel_obj(log, 'track'),
**log)
for log in logs
]
def make_archive(self, station, date, force=False, keep=False):
"""
Archive logs of the given date. If the archive exists, it does
not overwrite it except if "force" is given. In this case, the
new elements will be appended to the existing archives.
Return the number of archived logs, -1 if archive could not be
created.
"""
import yaml
import gzip
os.makedirs(settings.AIRCOX_LOGS_ARCHIVES_DIR, exist_ok=True)
path = self._get_archive_path(station, date)
if os.path.exists(path) and not force:
return -1
qs = self.station(station).at(date)
if not qs.exists():
return 0
fields = Log._meta.get_fields()
logs = [{i.attname: getattr(log, i.attname)
for i in fields} for log in qs]
# Note: since we use Yaml, we can just append new logs when file
# exists yet <3
with gzip.open(path, 'ab') as archive:
data = yaml.dump(logs).encode('utf8')
archive.write(data)
if not keep:
qs.delete()
return len(logs)
class Log(models.Model):
"""
Log sounds and diffusions that are played on the station.
This only remember what has been played on the outputs, not on each
source; Source designate here which source is responsible of that.
"""
class Type(IntEnum):
stop = 0x00
"""
Source has been stopped, e.g. manually
"""
start = 0x01
"""
The diffusion or sound has been triggered by the streamer or
manually.
"""
load = 0x02
"""
A playlist has updated, and loading started. A related Diffusion
does not means that the playlist is only for it (e.g. after a
crash, it can reload previous remaining sound files + thoses of
the next diffusion)
"""
on_air = 0x03
"""
The sound or diffusion has been detected occurring on air. Can
also designate live diffusion, although Liquidsoap did not play
them since they don't have an attached sound archive.
"""
other = 0x04
"""
Other log
"""
type = models.SmallIntegerField(
choices=[(int(y), _(x.replace('_', ' ')))
for x, y in Type.__members__.items()],
blank=True, null=True,
verbose_name=_('type'),
)
station = models.ForeignKey(
Station, models.CASCADE,
verbose_name=_('station'),
help_text=_('related station'),
)
source = models.CharField(
# we use a CharField to avoid loosing logs information if the
# source is removed
max_length=64, blank=True, null=True,
verbose_name=_('source'),
help_text=_('identifier of the source related to this log'),
)
date = models.DateTimeField(
default=tz.now, db_index=True,
verbose_name=_('date'),
)
comment = models.CharField(
max_length=512, blank=True, null=True,
verbose_name=_('comment'),
)
diffusion = models.ForeignKey(
Diffusion, models.SET_NULL,
blank=True, null=True, db_index=True,
verbose_name=_('Diffusion'),
)
sound = models.ForeignKey(
Sound, models.SET_NULL,
blank=True, null=True, db_index=True,
verbose_name=_('Sound'),
)
track = models.ForeignKey(
Track, models.SET_NULL,
blank=True, null=True, db_index=True,
verbose_name=_('Track'),
)
objects = LogQuerySet.as_manager()
@property
def related(self):
return self.diffusion or self.sound or self.track
@property
def local_date(self):
"""
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
to get it as local time.
"""
return tz.localtime(self.date, tz.get_current_timezone())
def print(self):
r = []
if self.diffusion:
r.append('diff: ' + str(self.diffusion_id))
if self.sound:
r.append('sound: ' + str(self.sound_id))
if self.track:
r.append('track: ' + str(self.track_id))
logger.info('log %s: %s%s', str(self), self.comment or '',
' (' + ', '.join(r) + ')' if r else '')
def __str__(self):
return '#{} ({}, {}, {})'.format(
self.pk, self.get_type_display(),
self.source, self.local_date.strftime('%Y/%m/%d %H:%M%z'))

73
aircox/models/page.py Normal file
View File

@ -0,0 +1,73 @@
from enum import IntEnum
from django.db import models
from django.urls import reverse
from django.utils.text import slugify
from django.utils.translation import ugettext_lazy as _
from ckeditor.fields import RichTextField
from filer.fields.image import FilerImageField
from model_utils.managers import InheritanceQuerySet
__all__ = ['Page', 'PageQuerySet']
class PageQuerySet(InheritanceQuerySet):
def published(self):
return self.filter(status=Page.STATUS.published)
class Page(models.Model):
""" Base class for publishable content """
class STATUS(IntEnum):
draft = 0x00
published = 0x10
trash = 0x20
title = models.CharField(max_length=128)
slug = models.SlugField(_('slug'), blank=True, unique=True)
status = models.PositiveSmallIntegerField(
_('status'),
default=STATUS.draft,
choices=[(int(y), _(x)) for x, y in STATUS.__members__.items()],
)
cover = FilerImageField(
on_delete=models.SET_NULL, null=True, blank=True,
verbose_name=_('Cover'),
)
content = RichTextField(
_('content'), blank=True, null=True,
)
featured = models.BooleanField(
_('featured'), default=False,
)
allow_comments = models.BooleanField(
_('allow comments'), default=True,
)
objects = PageQuerySet.as_manager()
class Meta:
abstract=True
def __str__(self):
return '{}: {}'.format(self._meta.verbose_name,
self.title or self.pk)
def save(self, *args, **kwargs):
# TODO: ensure unique slug
if not self.slug:
self.slug = slugify(self.title)
print(self.title, '--', self.slug)
super().save(*args, **kwargs)
def get_absolute_url(self):
return reverse(self.detail_url_name, kwargs={'slug': self.slug}) \
if self.is_published else ''
@property
def is_published(self):
return self.status == self.STATUS.published

508
aircox/models/program.py Normal file
View File

@ -0,0 +1,508 @@
import calendar
from collections import OrderedDict
import datetime
from enum import IntEnum
import logging
import os
import shutil
import pytz
from django.core.exceptions import ValidationError
from django.db import models
from django.db.models import F, Q
from django.db.models.functions import Concat, Substr
from django.utils import timezone as tz
from django.utils.translation import ugettext_lazy as _
from django.utils.functional import cached_property
from aircox import settings, utils
from .page import Page, PageQuerySet
from .station import Station
logger = logging.getLogger('aircox')
__all__ = ['Program', 'ProgramQuerySet', 'Stream', 'Schedule']
class ProgramQuerySet(PageQuerySet):
def station(self, station):
# FIXME: reverse-lookup
return self.filter(station=station)
class Program(Page):
"""
A Program can either be a Streamed or a Scheduled program.
A Streamed program is used to generate non-stop random playlists when there
is not scheduled diffusion. In such a case, a Stream is used to describe
diffusion informations.
A Scheduled program has a schedule and is the one with a normal use case.
Renaming a Program rename the corresponding directory to matches the new
name if it does not exists.
"""
station = models.ForeignKey(
Station,
verbose_name=_('station'),
on_delete=models.CASCADE,
)
active = models.BooleanField(
_('active'),
default=True,
help_text=_('if not checked this program is no longer active')
)
sync = models.BooleanField(
_('syncronise'),
default=True,
help_text=_('update later diffusions according to schedule changes')
)
objects = ProgramQuerySet.as_manager()
@property
def path(self):
""" Return program's directory path """
return os.path.join(settings.AIRCOX_PROGRAMS_DIR, self.slug)
@property
def archives_path(self):
return os.path.join(self.path, settings.AIRCOX_SOUND_ARCHIVES_SUBDIR)
@property
def excerpts_path(self):
return os.path.join(
self.path, settings.AIRCOX_SOUND_ARCHIVES_SUBDIR
)
def __init__(self, *kargs, **kwargs):
super().__init__(*kargs, **kwargs)
if self.slug:
self.__initial_path = self.path
@classmethod
def get_from_path(cl, path):
"""
Return a Program from the given path. We assume the path has been
given in a previous time by this model (Program.path getter).
"""
path = path.replace(settings.AIRCOX_PROGRAMS_DIR, '')
while path[0] == '/':
path = path[1:]
while path[-1] == '/':
path = path[:-2]
if '/' in path:
path = path[:path.index('/')]
path = path.split('_')
path = path[-1]
qs = cl.objects.filter(id=int(path))
return qs[0] if qs else None
def ensure_dir(self, subdir=None):
"""
Make sur the program's dir exists (and optionally subdir). Return True
if the dir (or subdir) exists.
"""
path = os.path.join(self.path, subdir) if subdir else \
self.path
os.makedirs(path, exist_ok=True)
return os.path.exists(path)
def __str__(self):
return self.title
def save(self, *kargs, **kwargs):
from .sound import Sound
super().save(*kargs, **kwargs)
path_ = getattr(self, '__initial_path', None)
if path_ is not None and path_ != self.path and \
os.path.exists(path_) and not os.path.exists(self.path):
logger.info('program #%s\'s dir changed to %s - update it.',
self.id, self.title)
shutil.move(path_, self.path)
Sound.objects.filter(path__startswith=path_) \
.update(path=Concat('path', Substr(F('path'), len(path_))))
class BaseRerunQuerySet(models.QuerySet):
def rerun(self):
return self.filter(initial__isnull=False)
def initial(self):
return self.filter(initial__isnull=True)
class BaseRerun(models.Model):
"""
Abstract model offering rerun facilities.
`start` datetime field or property must be implemented by sub-classes
"""
program = models.ForeignKey(
Program, models.CASCADE,
verbose_name=_('related program'),
)
initial = models.ForeignKey(
'self', models.SET_NULL, related_name='rerun_set',
verbose_name=_('initial schedule'),
blank=True, null=True,
help_text=_('mark as rerun of this %(model_name)'),
)
class Meta:
abstract = True
def save(self, *args, **kwargs):
if self.initial is not None:
self.initial = self.initial.get_initial()
if self.initial == self:
self.initial = None
if self.is_rerun:
self.save_rerun()
else:
self.save_initial()
super().save(*args, **kwargs)
def save_rerun(self):
pass
def save_initial(self):
pass
@property
def is_initial(self):
return self.initial is None
@property
def is_rerun(self):
return self.initial is not None
def get_initial(self):
""" Return the initial schedule (self or initial) """
return self if self.initial is None else self.initial.get_initial()
def clean(self):
super().clean()
if self.initial is not None and self.initial.start >= self.start:
raise ValidationError({
'initial': _('rerun must happen after initial')
})
# BIG FIXME: self.date is still used as datetime
class Schedule(BaseRerun):
"""
A Schedule defines time slots of programs' diffusions. It can be an initial
run or a rerun (in such case it is linked to the related schedule).
"""
# Frequency for schedules. Basically, it is a mask of bits where each bit is
# a week. Bits > rank 5 are used for special schedules.
# Important: the first week is always the first week where the weekday of
# the schedule is present.
# For ponctual programs, there is no need for a schedule, only a diffusion
class Frequency(IntEnum):
ponctual = 0b000000
first = 0b000001
second = 0b000010
third = 0b000100
fourth = 0b001000
last = 0b010000
first_and_third = 0b000101
second_and_fourth = 0b001010
every = 0b011111
one_on_two = 0b100000
date = models.DateField(
_('date'), help_text=_('date of the first diffusion'),
)
time = models.TimeField(
_('time'), help_text=_('start time'),
)
timezone = models.CharField(
_('timezone'),
default=tz.get_current_timezone, max_length=100,
choices=[(x, x) for x in pytz.all_timezones],
help_text=_('timezone used for the date')
)
duration = models.TimeField(
_('duration'),
help_text=_('regular duration'),
)
frequency = models.SmallIntegerField(
_('frequency'),
choices=[(int(y), {
'ponctual': _('ponctual'),
'first': _('1st {day} of the month'),
'second': _('2nd {day} of the month'),
'third': _('3rd {day} of the month'),
'fourth': _('4th {day} of the month'),
'last': _('last {day} of the month'),
'first_and_third': _('1st and 3rd {day}s of the month'),
'second_and_fourth': _('2nd and 4th {day}s of the month'),
'every': _('every {day}'),
'one_on_two': _('one {day} on two'),
}[x]) for x, y in Frequency.__members__.items()],
)
class Meta:
verbose_name = _('Schedule')
verbose_name_plural = _('Schedules')
def __str__(self):
return '{} - {}, {}'.format(
self.program.title, self.get_frequency_verbose(),
self.time.strftime('%H:%M')
)
def save_rerun(self, *args, **kwargs):
self.program = self.initial.program
self.duration = self.initial.duration
self.frequency = self.initial.frequency
@cached_property
def tz(self):
""" Pytz timezone of the schedule. """
import pytz
return pytz.timezone(self.timezone)
@cached_property
def start(self):
""" Datetime of the start (timezone unaware) """
return tz.datetime.combine(self.date, self.time)
@cached_property
def end(self):
""" Datetime of the end """
return self.start + utils.to_timedelta(self.duration)
def get_frequency_verbose(self):
""" Return frequency formated for display """
from django.template.defaultfilters import date
return self.get_frequency_display().format(
day=date(self.date, 'l')
)
# initial cached data
__initial = None
def changed(self, fields=['date', 'duration', 'frequency', 'timezone']):
initial = self._Schedule__initial
if not initial:
return
this = self.__dict__
for field in fields:
if initial.get(field) != this.get(field):
return True
return False
def match(self, date=None, check_time=True):
"""
Return True if the given date(time) matches the schedule.
"""
date = utils.date_or_default(
date, tz.datetime if check_time else datetime.date)
if self.date.weekday() != date.weekday() or \
not self.match_week(date):
return False
# we check against a normalized version (norm_date will have
# schedule's date.
return date == self.normalize(date) if check_time else True
def match_week(self, date=None):
"""
Return True if the given week number matches the schedule, False
otherwise.
If the schedule is ponctual, return None.
"""
if self.frequency == Schedule.Frequency.ponctual:
return False
# since we care only about the week, go to the same day of the week
date = utils.date_or_default(date, datetime.date)
date += tz.timedelta(days=self.date.weekday() - date.weekday())
# FIXME this case
if self.frequency == Schedule.Frequency.one_on_two:
# cf notes in date_of_month
diff = date - utils.cast_date(self.date, datetime.date)
return not (diff.days % 14)
first_of_month = date.replace(day=1)
week = date.isocalendar()[1] - first_of_month.isocalendar()[1]
# weeks of month
if week == 4:
# fifth week: return if for every week
return self.frequency == self.Frequency.every
return (self.frequency & (0b0001 << week) > 0)
def normalize(self, date):
"""
Return a new datetime with schedule time. Timezone is handled
using `schedule.timezone`.
"""
date = tz.datetime.combine(date, self.time)
return self.tz.normalize(self.tz.localize(date))
def dates_of_month(self, date):
""" Return normalized diffusion dates of provided date's month. """
if self.frequency == Schedule.Frequency.ponctual:
return []
sched_wday, freq = self.date.weekday(), self.frequency
date = date.replace(day=1)
# last of the month
if freq == Schedule.Frequency.last:
date = date.replace(
day=calendar.monthrange(date.year, date.month)[1])
date_wday = date.weekday()
# end of month before the wanted weekday: move one week back
if date_wday < sched_wday:
date -= tz.timedelta(days=7)
date += tz.timedelta(days=sched_wday - date_wday)
return [self.normalize(date)]
# move to the first day of the month that matches the schedule's weekday
# check on SO#3284452 for the formula
date_wday, month = date.weekday(), date.month
date += tz.timedelta(days=(7 if date_wday > sched_wday else 0) -
date_wday + sched_wday)
if freq == Schedule.Frequency.one_on_two:
# - adjust date with modulo 14 (= 2 weeks in days)
# - there are max 3 "weeks on two" per month
if (date - self.date).days % 14:
date += tz.timedelta(days=7)
dates = (date + tz.timedelta(days=14*i) for i in range(0, 3))
else:
dates = (date + tz.timedelta(days=7*week) for week in range(0, 5)
if freq & (0b1 << week))
return [self.normalize(date) for date in dates if date.month == month]
def _exclude_existing_date(self, dates):
from .episode import Diffusion
saved = set(Diffusion.objects.filter(start__in=dates)
.values_list('start', flat=True))
return [date for date in dates if date not in saved]
def diffusions_of_month(self, date):
"""
Get episodes and diffusions for month of provided date, including
reruns.
:returns: tuple([Episode], [Diffusion])
"""
from .episode import Diffusion, Episode
if self.initial is not None or \
self.frequency == Schedule.Frequency.ponctual:
return []
# dates for self and reruns as (date, initial)
reruns = [(rerun, rerun.date - self.date)
for rerun in self.rerun_set.all()]
dates = OrderedDict((date, None) for date in self.dates_of_month(date))
dates.update([(rerun.normalize(date.date() + delta), date)
for date in dates.keys() for rerun, delta in reruns])
# remove dates corresponding to existing diffusions
saved = set(Diffusion.objects.filter(start__in=dates.keys(),
program=self.program)
.values_list('start', flat=True))
# make diffs
duration = utils.to_timedelta(self.duration)
diffusions = {}
episodes = {}
for date, initial in dates.items():
if date in saved:
continue
if initial is None:
episode = Episode.from_date(self.program, date)
episodes[date] = episode
else:
episode = episodes[initial]
initial = diffusions[initial]
diffusions[date] = Diffusion(
episode=episode, type=Diffusion.Type.on_air,
initial=initial, start=date, end=date+duration
)
return episodes.values(), diffusions.values()
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# TODO/FIXME: use validators?
if self.initial is not None and self.date > self.date:
raise ValueError('initial must be later')
# initial only if it has been yet saved
if self.pk:
self.__initial = self.__dict__.copy()
class Stream(models.Model):
"""
When there are no program scheduled, it is possible to play sounds
in order to avoid blanks. A Stream is a Program that plays this role,
and whose linked to a Stream.
All sounds that are marked as good and that are under the related
program's archive dir are elligible for the sound's selection.
"""
program = models.ForeignKey(
Program, models.CASCADE,
verbose_name=_('related program'),
)
delay = models.TimeField(
_('delay'), blank=True, null=True,
help_text=_('minimal delay between two sound plays')
)
begin = models.TimeField(
_('begin'), blank=True, null=True,
help_text=_('used to define a time range this stream is'
'played')
)
end = models.TimeField(
_('end'),
blank=True, null=True,
help_text=_('used to define a time range this stream is'
'played')
)

288
aircox/models/sound.py Normal file
View File

@ -0,0 +1,288 @@
from enum import IntEnum
import logging
import os
from django.conf import settings as main_settings
from django.db import models
from django.db.models import Q
from django.utils import timezone as tz
from django.utils.translation import ugettext_lazy as _
from taggit.managers import TaggableManager
from aircox import settings
from .program import Program
from .episode import Episode
logger = logging.getLogger('aircox')
__all__ = ['Sound', 'SoundQuerySet', 'Track']
class SoundQuerySet(models.QuerySet):
def podcasts(self):
""" Return sound available as podcasts """
return self.filter(Q(embed__isnull=False) | Q(is_public=True))
def episode(self, episode):
return self.filter(episode=episode)
def diffusion(self, diffusion):
return self.filter(episode__diffusion=diffusion)
class Sound(models.Model):
"""
A Sound is the representation of a sound file that can be either an excerpt
or a complete archive of the related diffusion.
"""
class Type(IntEnum):
other = 0x00,
archive = 0x01,
excerpt = 0x02,
removed = 0x03,
name = models.CharField(_('name'), max_length=64)
program = models.ForeignKey(
Program, models.SET_NULL, blank=True, null=True,
verbose_name=_('program'),
help_text=_('program related to it'),
)
episode = models.ForeignKey(
Episode, models.SET_NULL, blank=True, null=True,
verbose_name=_('episode'),
)
type = models.SmallIntegerField(
verbose_name=_('type'),
choices=[(int(y), _(x)) for x, y in Type.__members__.items()],
blank=True, null=True
)
# FIXME: url() does not use the same directory than here
# should we use FileField for more reliability?
path = models.FilePathField(
_('file'),
path=settings.AIRCOX_PROGRAMS_DIR,
match=r'(' + '|'.join(settings.AIRCOX_SOUND_FILE_EXT)
.replace('.', r'\.') + ')$',
recursive=True, max_length=255,
blank=True, null=True, unique=True,
)
embed = models.TextField(
_('embed'),
blank=True, null=True,
help_text=_('HTML code to embed a sound from an external plateform'),
)
duration = models.TimeField(
_('duration'),
blank=True, null=True,
help_text=_('duration of the sound'),
)
mtime = models.DateTimeField(
_('modification time'),
blank=True, null=True,
help_text=_('last modification date and time'),
)
is_good_quality = models.BooleanField(
_('good quality'), help_text=_('sound meets quality requirements'),
blank=True, null=True
)
is_public = models.BooleanField(
_('public'), help_text=_('if it can be podcasted from the server'),
default=False,
)
objects = SoundQuerySet.as_manager()
def get_mtime(self):
"""
Get the last modification date from file
"""
mtime = os.stat(self.path).st_mtime
mtime = tz.datetime.fromtimestamp(mtime)
# db does not store microseconds
mtime = mtime.replace(microsecond=0)
return tz.make_aware(mtime, tz.get_current_timezone())
def url(self):
"""
Return an url to the stream
"""
# path = self._meta.get_field('path').path
path = self.path.replace(main_settings.MEDIA_ROOT, '', 1)
#path = self.path.replace(path, '', 1)
return main_settings.MEDIA_URL + '/' + path
def file_exists(self):
"""
Return true if the file still exists
"""
return os.path.exists(self.path)
def file_metadata(self):
"""
Get metadata from sound file and return a Track object if succeed,
else None.
"""
if not self.file_exists():
return None
import mutagen
try:
meta = mutagen.File(self.path)
except:
meta = {}
if meta is None:
meta = {}
def get_meta(key, cast=str):
value = meta.get(key)
return cast(value[0]) if value else None
info = '{} ({})'.format(get_meta('album'), get_meta('year')) \
if meta and ('album' and 'year' in meta) else \
get_meta('album') \
if 'album' else \
('year' in meta) and get_meta('year') or ''
return Track(sound=self,
position=get_meta('tracknumber', int) or 0,
title=get_meta('title') or self.name,
artist=get_meta('artist') or _('unknown'),
info=info)
def check_on_file(self):
"""
Check sound file info again'st self, and update informations if
needed (do not save). Return True if there was changes.
"""
if not self.file_exists():
if self.type == self.Type.removed:
return
logger.info('sound %s: has been removed', self.path)
self.type = self.Type.removed
return True
# not anymore removed
changed = False
if self.type == self.Type.removed and self.program:
changed = True
self.type = self.Type.archive \
if self.path.startswith(self.program.archives_path) else \
self.Type.excerpt
# check mtime -> reset quality if changed (assume file changed)
mtime = self.get_mtime()
if self.mtime != mtime:
self.mtime = mtime
self.is_good_quality = None
logger.info('sound %s: m_time has changed. Reset quality info',
self.path)
return True
return changed
def check_perms(self):
"""
Check file permissions and update it if the sound is public
"""
if not settings.AIRCOX_SOUND_AUTO_CHMOD or \
self.removed or not os.path.exists(self.path):
return
flags = settings.AIRCOX_SOUND_CHMOD_FLAGS[self.is_public]
try:
os.chmod(self.path, flags)
except PermissionError as err:
logger.error('cannot set permissions {} to file {}: {}'.format(
self.flags[self.is_public], self.path, err))
def __check_name(self):
if not self.name and self.path:
# FIXME: later, remove date?
self.name = os.path.basename(self.path)
self.name = os.path.splitext(self.name)[0]
self.name = self.name.replace('_', ' ')
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.__check_name()
def save(self, check=True, *args, **kwargs):
if self.episode is not None and self.program is None:
self.program = self.episode.program
if check:
self.check_on_file()
self.__check_name()
super().save(*args, **kwargs)
def __str__(self):
return '/'.join(self.path.split('/')[-3:])
class Meta:
verbose_name = _('Sound')
verbose_name_plural = _('Sounds')
class Track(models.Model):
"""
Track of a playlist of an object. The position can either be expressed
as the position in the playlist or as the moment in seconds it started.
"""
episode = models.ForeignKey(
Episode, models.CASCADE, blank=True, null=True,
verbose_name=_('episode'),
)
sound = models.ForeignKey(
Sound, models.CASCADE, blank=True, null=True,
verbose_name=_('sound'),
)
position = models.PositiveSmallIntegerField(
_('order'),
default=0,
help_text=_('position in the playlist'),
)
timestamp = models.PositiveSmallIntegerField(
_('timestamp'),
blank=True, null=True,
help_text=_('position in seconds')
)
title = models.CharField(_('title'), max_length=128)
artist = models.CharField(_('artist'), max_length=128)
tags = TaggableManager(verbose_name=_('tags'), blank=True,)
info = models.CharField(
_('information'),
max_length=128,
blank=True, null=True,
help_text=_('additional informations about this track, such as '
'the version, if is it a remix, features, etc.'),
)
class Meta:
verbose_name = _('Track')
verbose_name_plural = _('Tracks')
ordering = ('position',)
def __str__(self):
return '{self.artist} -- {self.title} -- {self.position}'.format(
self=self)
def save(self, *args, **kwargs):
if (self.sound is None and self.episode is None) or \
(self.sound is not None and self.episode is not None):
raise ValueError('sound XOR episode is required')
super().save(*args, **kwargs)

206
aircox/models/station.py Normal file
View File

@ -0,0 +1,206 @@
from enum import IntEnum
import os
from django.db import models
from django.db.models import Q
from django.utils.translation import ugettext_lazy as _
import aircox.settings as settings
__all__ = ['Station', 'StationQuerySet', 'Port']
class StationQuerySet(models.QuerySet):
def default(self, station=None):
"""
Return station model instance, using defaults or
given one.
"""
if station is None:
return self.order_by('-default', 'pk').first()
return self.filter(pk=station).first()
class Station(models.Model):
"""
Represents a radio station, to which multiple programs are attached
and that is used as the top object for everything.
A Station holds controllers for the audio stream generation too.
Theses are set up when needed (at the first access to these elements)
then cached.
"""
name = models.CharField(_('name'), max_length=64)
slug = models.SlugField(_('slug'), max_length=64, unique=True)
path = models.CharField(
_('path'),
help_text=_('path to the working directory'),
max_length=256,
blank=True,
)
default = models.BooleanField(
_('default station'),
default=True,
help_text=_('if checked, this station is used as the main one')
)
objects = StationQuerySet.as_manager()
#
# Controllers
#
__sources = None
__dealer = None
__streamer = None
def __prepare_controls(self):
import aircox.controllers as controllers
from .program import Program
if not self.__streamer:
self.__streamer = controllers.Streamer(station=self)
self.__dealer = controllers.Source(station=self)
self.__sources = [self.__dealer] + [
controllers.Source(station=self, program=program)
for program in Program.objects.filter(stream__isnull=False)
]
@property
def inputs(self):
"""
Return all active input ports of the station
"""
return self.port_set.filter(
direction=Port.Direction.input,
active=True
)
@property
def outputs(self):
""" Return all active output ports of the station """
return self.port_set.filter(
direction=Port.Direction.output,
active=True,
)
@property
def sources(self):
""" Audio sources, dealer included """
self.__prepare_controls()
return self.__sources
@property
def dealer(self):
""" Get dealer control """
self.__prepare_controls()
return self.__dealer
@property
def streamer(self):
""" Audio controller for the station """
self.__prepare_controls()
return self.__streamer
def __str__(self):
return self.name
def save(self, make_sources=True, *args, **kwargs):
if not self.path:
self.path = os.path.join(
settings.AIRCOX_CONTROLLERS_WORKING_DIR,
self.slug
)
if self.default:
qs = Station.objects.filter(default=True)
if self.pk:
qs = qs.exclude(pk=self.pk)
qs.update(default=False)
super().save(*args, **kwargs)
class Port (models.Model):
"""
Represent an audio input/output for the audio stream
generation.
You might want to take a look to LiquidSoap's documentation
for the options available for each kind of input/output.
Some port types may be not available depending on the
direction of the port.
"""
class Direction(IntEnum):
input = 0x00
output = 0x01
class Type(IntEnum):
jack = 0x00
alsa = 0x01
pulseaudio = 0x02
icecast = 0x03
http = 0x04
https = 0x05
file = 0x06
station = models.ForeignKey(
Station,
verbose_name=_('station'),
on_delete=models.CASCADE,
)
direction = models.SmallIntegerField(
_('direction'),
choices=[(int(y), _(x)) for x, y in Direction.__members__.items()],
)
type = models.SmallIntegerField(
_('type'),
# we don't translate the names since it is project names.
choices=[(int(y), x) for x, y in Type.__members__.items()],
)
active = models.BooleanField(
_('active'),
default=True,
help_text=_('this port is active')
)
settings = models.TextField(
_('port settings'),
help_text=_('list of comma separated params available; '
'this is put in the output config file as raw code; '
'plugin related'),
blank=True, null=True
)
def is_valid_type(self):
"""
Return True if the type is available for the given direction.
"""
if self.direction == self.Direction.input:
return self.type not in (
self.Type.icecast, self.Type.file
)
return self.type not in (
self.Type.http, self.Type.https
)
def save(self, *args, **kwargs):
if not self.is_valid_type():
raise ValueError(
"port type is not allowed with the given port direction"
)
return super().save(*args, **kwargs)
def __str__(self):
return "{direction}: {type} #{id}".format(
direction=self.get_direction_display(),
type=self.get_type_display(),
id=self.pk or ''
)

View File

@ -33,6 +33,15 @@ ensure('AIRCOX_PROGRAMS_DIR',
ensure('AIRCOX_DATA_DIR',
os.path.join(settings.PROJECT_ROOT, 'data'))
########################################################################
# Programs & Episodes
########################################################################
# default title for episodes
ensure('AIRCOX_EPISODE_TITLE', '{program.title} - {date}')
# date format in episode title (python's strftime)
ensure('AIRCOX_EPISODE_TITLE_DATE_FORMAT', '%-d %B %Y')
########################################################################
# Logs & Archives
########################################################################

View File

@ -1,3 +0,0 @@
default_app_config = 'aircox_cms.apps.AircoxCMSConfig'

View File

@ -1,6 +0,0 @@
from django.contrib import admin
# Register your models here.

View File

@ -1,9 +0,0 @@
from django.apps import AppConfig
class AircoxCMSConfig(AppConfig):
name = 'aircox_cms'
verbose_name = 'Aircox CMS'
def ready(self):
import aircox_cms.signals

View File

@ -1,44 +0,0 @@
import django.forms as forms
from django.utils.translation import ugettext as _, ugettext_lazy
from django.core.exceptions import ValidationError
from honeypot.decorators import verify_honeypot_value
import aircox_cms.models as models
class CommentForm(forms.ModelForm):
class Meta:
model = models.Comment
fields = ['author', 'email', 'url', 'content']
localized_fields = '__all__'
widgets = {
'author': forms.TextInput(attrs={
'placeholder': _('your name'),
}),
'email': forms.TextInput(attrs={
'placeholder': _('your email (optional)'),
}),
'url': forms.URLInput(attrs={
'placeholder': _('your website (optional)'),
}),
'comment': forms.TextInput(attrs={
'placeholder': _('your comment'),
})
}
def __init__(self, *args, **kwargs):
self.request = kwargs.pop('request', None)
self.page = kwargs.pop('object', None)
super().__init__(*args, **kwargs)
def clean(self):
super().clean()
if self.request:
if verify_honeypot_value(self.request, 'hp_website'):
raise ValidationError(_('You are a bot, that is not cool'))
if not self.object:
raise ValidationError(_('No publication found for this comment'))

File diff suppressed because it is too large Load Diff

View File

@ -1,92 +0,0 @@
"""
Create missing publications for diffusions and programs already existing.
We limit the creation of diffusion to the elements to those that start at least
in the last 15 days, and to the future ones.
The new publications are not published automatically.
"""
import logging
from argparse import RawTextHelpFormatter
from django.core.management.base import BaseCommand, CommandError
from django.contrib.contenttypes.models import ContentType
from django.utils import timezone as tz
from aircox.models import Program, Diffusion
from aircox_cms.models import WebsiteSettings, ProgramPage, DiffusionPage
logger = logging.getLogger('aircox.tools')
class Command (BaseCommand):
help= __doc__
def add_arguments (self, parser):
parser.formatter_class=RawTextHelpFormatter
def handle (self, *args, **options):
for settings in WebsiteSettings.objects.all():
logger.info('start sync for website {}'.format(
str(settings.site)
))
if not settings.auto_create:
logger.warning('auto_create disabled: skip')
continue
if not settings.default_program_parent_page:
logger.warning('no default program page for this website: skip')
continue
# programs
logger.info('Programs...')
parent = settings.default_programs_page
qs = Program.objects.filter(
active = True,
stream__isnull = True,
page__isnull = True,
)
for program in qs:
logger.info('- ' + program.name)
page = ProgramPage(
program = program,
title = program.name,
live = False,
)
parent.add_child(instance = page)
# diffusions
logger.info('Diffusions...')
qs = Diffusion.objects.filter(
start__gt = tz.now().date() - tz.timedelta(days = 20),
page__isnull = True,
initial__isnull = True
).exclude(type = Diffusion.Type.unconfirmed)
for diffusion in qs:
if not diffusion.program.page:
if not hasattr(diffusion.program, '__logged_diff_error'):
logger.warning(
'the program {} has no page; skip the creation of '
'page for its diffusions'.format(
diffusion.program.name
)
)
diffusion.program.__logged_diff_error = True
continue
logger.info('- ' + str(diffusion))
try:
page = DiffusionPage.from_diffusion(
diffusion, live = False
)
diffusion.program.page.add_child(instance = page)
except:
import sys
e = sys.exc_info()[0]
logger.error('Error saving', str(diffusion) + ':', e)
logger.info('done')

View File

@ -1,823 +0,0 @@
import datetime
from django.db import models
from django.contrib.auth.models import User
from django.contrib import messages
from django.utils import timezone as tz
from django.utils.translation import ugettext as _, ugettext_lazy
# pages and panels
from wagtail.contrib.settings.models import BaseSetting, register_setting
from wagtail.core.models import Page, Orderable, \
PageManager, PageQuerySet
from wagtail.core.fields import RichTextField
from wagtail.images.edit_handlers import ImageChooserPanel
from wagtail.admin.edit_handlers import FieldPanel, FieldRowPanel, \
MultiFieldPanel, InlinePanel, PageChooserPanel, StreamFieldPanel
from wagtail.search import index
# snippets
from wagtail.snippets.models import register_snippet
# tags
from modelcluster.fields import ParentalKey
from modelcluster.tags import ClusterTaggableManager
from taggit.models import TaggedItemBase
# comment clean-up
import bleach
import aircox.models
import aircox_cms.settings as settings
from aircox_cms.models.lists import *
from aircox_cms.models.sections import *
from aircox_cms.template import TemplateMixin
from aircox_cms.utils import image_url
@register_setting
class WebsiteSettings(BaseSetting):
station = models.OneToOneField(
aircox.models.Station,
models.SET_NULL,
verbose_name = _('aircox station'),
related_name = 'website_settings',
unique = True,
blank = True, null = True,
help_text = _(
'refers to an Aircox\'s station; it is used to make the link '
'between the website and Aircox'
),
)
# general website information
favicon = models.ImageField(
verbose_name = _('favicon'),
null=True, blank=True,
help_text = _('small logo for the website displayed in the browser'),
)
tags = models.CharField(
_('tags'),
max_length=256,
null=True, blank=True,
help_text = _('tags describing the website; used for referencing'),
)
description = models.CharField(
_('public description'),
max_length=256,
null=True, blank=True,
help_text = _('public description of the website; used for referencing'),
)
list_page = models.ForeignKey(
'aircox_cms.DynamicListPage',
on_delete=models.CASCADE,
verbose_name = _('page for lists'),
help_text=_('page used to display the results of a search and other '
'lists'),
related_name= 'list_page',
blank = True, null = True,
)
# comments
accept_comments = models.BooleanField(
default = True,
help_text = _('publish comments automatically without verifying'),
)
allow_comments = models.BooleanField(
default = True,
help_text = _('publish comments automatically without verifying'),
)
comment_success_message = models.TextField(
_('success message'),
default = _('Your comment has been successfully posted!'),
help_text = _(
'message displayed when a comment has been successfully posted'
),
)
comment_wait_message = models.TextField(
_('waiting message'),
default = _('Your comment is awaiting for approval.'),
help_text = _(
'message displayed when a comment has been sent, but waits for '
' website administrators\' approval.'
),
)
comment_error_message = models.TextField(
_('error message'),
default = _('We could not save your message. Please correct the error(s) below.'),
help_text = _(
'message displayed when the form of the comment has been '
' submitted but there is an error, such as an incomplete field'
),
)
sync = models.BooleanField(
_('synchronize with Aircox'),
default = False,
help_text = _(
'create publication for each object added to an Aircox\'s '
'station; for example when there is a new program, or '
'when a diffusion has been added to the timetable. Note: '
'it does not concern the Station themselves.'
# /doc/ the page is saved but not pubished -- this must be
# done manually, when the user edit it.
)
)
default_programs_page = ParentalKey(
Page,
verbose_name = _('default programs page'),
blank = True, null = True,
help_text = _(
'when a new program is saved and a publication is created, '
'put this publication as a child of this page. If no page '
'has been specified, try to put it as the child of the '
'website\'s root page (otherwise, do not create the page).'
# /doc/ (technicians, admin): if the page has not been created,
# it still can be created using the `programs_to_cms` command.
),
limit_choices_to = {
'show_in_menus': True,
'publication__isnull': False,
},
)
panels = [
MultiFieldPanel([
FieldPanel('favicon'),
FieldPanel('tags'),
FieldPanel('description'),
FieldPanel('list_page'),
], heading=_('Promotion')),
MultiFieldPanel([
FieldPanel('allow_comments'),
FieldPanel('accept_comments'),
FieldPanel('comment_success_message'),
FieldPanel('comment_wait_message'),
FieldPanel('comment_error_message'),
], heading = _('Comments')),
MultiFieldPanel([
FieldPanel('sync'),
FieldPanel('default_programs_page'),
], heading = _('Programs and controls')),
]
class Meta:
verbose_name = _('website settings')
@register_snippet
class Comment(models.Model):
publication = models.ForeignKey(
Page,
on_delete=models.CASCADE,
verbose_name = _('page')
)
published = models.BooleanField(
verbose_name = _('published'),
default = False
)
author = models.CharField(
verbose_name = _('author'),
max_length = 32,
)
email = models.EmailField(
verbose_name = _('email'),
blank = True, null = True,
)
url = models.URLField(
verbose_name = _('website'),
blank = True, null = True,
)
date = models.DateTimeField(
_('date'),
auto_now_add = True,
)
content = models.TextField (
_('comment'),
)
class Meta:
verbose_name = _('comment')
verbose_name_plural = _('comments')
def __str__(self):
# Translators: text shown in the comments list (in admin)
return _('{date}, {author}: {content}...').format(
author = self.author,
date = self.date.strftime('%d %A %Y, %H:%M'),
content = self.content[:128]
)
def make_safe(self):
self.author = bleach.clean(self.author, tags=[])
if self.email:
self.email = bleach.clean(self.email, tags=[])
self.email = self.email.replace('"', '%22')
if self.url:
self.url = bleach.clean(self.url, tags=[])
self.url = self.url.replace('"', '%22')
self.content = bleach.clean(
self.content,
tags=settings.AIRCOX_CMS_BLEACH_COMMENT_TAGS,
attributes=settings.AIRCOX_CMS_BLEACH_COMMENT_ATTRS
)
def save(self, make_safe = True, *args, **kwargs):
if make_safe:
self.make_safe()
return super().save(*args, **kwargs)
class BasePage(Page):
body = RichTextField(
_('body'),
null = True, blank = True,
help_text = _('the publication itself')
)
cover = models.ForeignKey(
'wagtailimages.Image',
verbose_name = _('cover'),
null=True, blank=True,
on_delete=models.SET_NULL,
related_name='+',
help_text = _('image to use as cover of the publication'),
)
allow_comments = models.BooleanField(
_('allow comments'),
default = True,
help_text = _('allow comments')
)
# panels
content_panels = [
MultiFieldPanel([
FieldPanel('title'),
ImageChooserPanel('cover'),
FieldPanel('body', classname='full'),
], heading=_('Content'))
]
settings_panels = Page.settings_panels + [
FieldPanel('allow_comments'),
]
search_fields = [
index.SearchField('title', partial_match=True),
index.SearchField('body', partial_match=True),
index.FilterField('live'),
index.FilterField('show_in_menus'),
]
# properties
@property
def url(self):
if not self.live:
parent = self.get_parent().specific
return parent and parent.url
return super().url
@property
def icon(self):
return image_url(self.cover, 'fill-64x64')
@property
def small_icon(self):
return image_url(self.cover, 'fill-32x32')
@property
def comments(self):
return Comment.objects.filter(
publication = self,
published = True,
).order_by('-date')
# methods
def get_list_page(self):
"""
Return the page that should be used for lists related to this
page. If None is returned, use a default one.
"""
return None
def get_context(self, request, *args, **kwargs):
from aircox_cms.forms import CommentForm
context = super().get_context(request, *args, **kwargs)
if self.allow_comments and \
WebsiteSettings.for_site(request.site).allow_comments:
context['comment_form'] = CommentForm()
context['settings'] = {
'debug': settings.DEBUG
}
return context
def serve(self, request):
from aircox_cms.forms import CommentForm
if request.POST and 'comment' in request.POST['type']:
settings = WebsiteSettings.for_site(request.site)
comment_form = CommentForm(request.POST)
if comment_form.is_valid():
comment = comment_form.save(commit=False)
comment.publication = self
comment.published = settings.accept_comments
comment.save()
messages.success(request,
settings.comment_success_message
if comment.published else
settings.comment_wait_message,
fail_silently=True,
)
else:
messages.error(
request, settings.comment_error_message, fail_silently=True
)
return super().serve(request)
class Meta:
abstract = True
#
# Publications
#
class PublicationRelatedLink(RelatedLinkBase,Component):
template = 'aircox_cms/snippets/link.html'
parent = ParentalKey('Publication', related_name='links')
class PublicationTag(TaggedItemBase):
content_object = ParentalKey('Publication', related_name='tagged_items')
class Publication(BasePage):
order_field = 'date'
date = models.DateTimeField(
_('date'),
blank = True, null = True,
auto_now_add = True,
)
publish_as = models.ForeignKey(
'ProgramPage',
verbose_name = _('publish as program'),
on_delete=models.SET_NULL,
blank = True, null = True,
help_text = _('use this program as the author of the publication'),
)
focus = models.BooleanField(
_('focus'),
default = False,
help_text = _('the publication is highlighted;'),
)
allow_comments = models.BooleanField(
_('allow comments'),
default = True,
help_text = _('allow comments')
)
headline = models.TextField(
_('headline'),
blank = True, null = True,
help_text = _('headline of the publication, use it as an introduction'),
)
tags = ClusterTaggableManager(
verbose_name = _('tags'),
through=PublicationTag,
blank=True
)
class Meta:
verbose_name = _('Publication')
verbose_name_plural = _('Publication')
content_panels = [
MultiFieldPanel([
FieldPanel('title'),
ImageChooserPanel('cover'),
FieldPanel('headline'),
FieldPanel('body', classname='full'),
], heading=_('Content'))
]
promote_panels = [
MultiFieldPanel([
FieldPanel('tags'),
FieldPanel('focus'),
], heading=_('Content')),
] + Page.promote_panels
settings_panels = Page.settings_panels + [
FieldPanel('publish_as'),
FieldPanel('allow_comments'),
]
search_fields = BasePage.search_fields + [
index.SearchField('headline', partial_match=True),
]
@property
def recents(self):
return self.get_children().type(Publication).not_in_menu().live() \
.order_by('-publication__date')
def get_context(self, request, *args, **kwargs):
context = super().get_context(request, *args, **kwargs)
view = request.GET.get('view')
context.update({
'view': view,
'page': self,
})
if view == 'list':
context.update(BaseList.from_request(request, related = self))
context['list_url_args'] += '&view=list'
return context
def save(self, *args, **kwargs):
if not self.date and self.first_published_at:
self.date = self.first_published_at
return super().save(*args, **kwargs)
class ProgramPage(Publication):
program = models.OneToOneField(
aircox.models.Program,
verbose_name = _('program'),
related_name = 'page',
on_delete=models.SET_NULL,
blank=True, null=True,
)
# rss = models.URLField()
email = models.EmailField(
_('email'), blank=True, null=True,
)
email_is_public = models.BooleanField(
_('email is public'),
default = False,
help_text = _('the email addess is accessible to the public'),
)
class Meta:
verbose_name = _('Program')
verbose_name_plural = _('Programs')
content_panels = [
# FieldPanel('program'),
] + Publication.content_panels
settings_panels = Publication.settings_panels + [
FieldPanel('email'),
FieldPanel('email_is_public'),
]
def diffs_to_page(self, diffs):
for diff in diffs:
if not diff.page:
diff.page = ListItem(
title = '{}, {}'.format(
self.program.name, diff.date.strftime('%d %B %Y')
),
cover = self.cover,
live = True,
date = diff.start,
)
return [
diff.page for diff in diffs if diff.page.live
]
@property
def next(self):
now = tz.now()
diffs = aircox.models.Diffusion.objects \
.filter(end__gte = now, program = self.program) \
.order_by('start').prefetch_related('page')
return self.diffs_to_page(diffs)
@property
def prev(self):
now = tz.now()
diffs = aircox.models.Diffusion.objects \
.filter(end__lte = now, program = self.program) \
.order_by('-start').prefetch_related('page')
return self.diffs_to_page(diffs)
def save(self, *args, **kwargs):
# set publish_as
if self.program and not self.pk:
super().save()
self.publish_as = self
super().save(*args, **kwargs)
class Track(aircox.models.Track,Orderable):
diffusion = ParentalKey(
'DiffusionPage', related_name='tracks',
null = True, blank = True,
on_delete = models.SET_NULL
)
sort_order_field = 'position'
panels = [
FieldPanel('artist'),
FieldPanel('title'),
FieldPanel('tags'),
FieldPanel('info'),
]
def save(self, *args, **kwargs):
if self.diffusion.diffusion:
self.related = self.diffusion.diffusion
self.in_seconds = False
super().save(*args, **kwargs)
class DiffusionPage(Publication):
diffusion = models.OneToOneField(
aircox.models.Diffusion,
verbose_name = _('diffusion'),
related_name = 'page',
null=True, blank = True,
# not blank because we enforce the connection to a diffusion
# (still users always tend to break sth)
on_delete=models.SET_NULL,
limit_choices_to = {
'initial__isnull': True,
},
)
publish_archive = models.BooleanField(
_('publish archive'),
default = False,
help_text = _('publish the podcast of the complete diffusion'),
)
class Meta:
verbose_name = _('Diffusion')
verbose_name_plural = _('Diffusions')
content_panels = Publication.content_panels + [
InlinePanel('tracks', label=_('Tracks')),
]
promote_panels = [
MultiFieldPanel([
FieldPanel('publish_archive'),
FieldPanel('tags'),
FieldPanel('focus'),
], heading=_('Content')),
] + Page.promote_panels
settings_panels = Publication.settings_panels + [
FieldPanel('diffusion')
]
@classmethod
def from_diffusion(cl, diff, model = None, **kwargs):
model = model or cl
model_kwargs = {
'diffusion': diff,
'title': '{}, {}'.format(
diff.program.name, tz.localtime(diff.date).strftime('%d %B %Y')
),
'cover': (diff.program.page and \
diff.program.page.cover) or None,
'date': diff.start,
}
model_kwargs.update(kwargs)
r = model(**model_kwargs)
return r
@classmethod
def as_item(cl, diff):
"""
Return a DiffusionPage or ListItem from a Diffusion.
"""
initial = diff.initial or diff
if hasattr(initial, 'page'):
item = initial.page
else:
item = cl.from_diffusion(diff, ListItem)
item.live = True
item.info = []
# Translators: informations about a diffusion
if diff.initial:
item.info.append(_('Rerun of %(date)s') % {
'date': diff.initial.start.strftime('%A %d')
})
if diff.type == diff.Type.canceled:
item.info.append(_('Cancelled'))
item.info = '; '.join(item.info)
item.date = diff.start
item.css_class = 'diffusion'
now = tz.now()
if diff.start <= now <= diff.end:
item.css_class = ' now'
item.now = True
return item
def get_context(self, request, *args, **kwargs):
context = super().get_context(request, *args, **kwargs)
context['podcasts'] = self.diffusion and SectionPlaylist(
title=_('Podcasts'),
page = self,
sounds = self.diffusion.get_sounds(
archive = self.publish_archive, excerpt = True
)
)
return context
def save(self, *args, **kwargs):
if self.diffusion:
# force to sort by diffusion date in wagtail explorer
self.latest_revision_created_at = self.diffusion.start
# set publish_as
if not self.pk:
self.publish_as = self.diffusion.program.page
# sync date
self.date = self.diffusion.start
# update podcasts' attributes
for podcast in self.diffusion.sound_set \
.exclude(type = aircox.models.Sound.Type.removed):
publish = self.live and self.publish_archive \
if podcast.type == podcast.Type.archive else self.live
if podcast.public != publish:
podcast.public = publish
podcast.save()
super().save(*args, **kwargs)
#
# Others types of pages
#
class CategoryPage(BasePage, BaseList):
# TODO: hide related in panels?
content_panels = BasePage.content_panels + BaseList.panels
def get_list_page(self):
return self
def get_context(self, request, *args, **kwargs):
context = super().get_context(request, *args, **kwargs)
context.update(BaseList.get_context(self, request, paginate = True))
context['view'] = 'list'
return context
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# we force related attribute
if not self.related:
self.related = self
class DynamicListPage(BasePage):
"""
Displays a list of publications using query passed by the url.
This can be used for search/tags page, and generally only one
page is used per website.
If a title is given, use it instead of the generated one.
"""
# FIXME/TODO: title in template <title></title>
# TODO: personnalized titles depending on request
class Meta:
verbose_name = _('Dynamic List Page')
verbose_name_plural = _('Dynamic List Pages')
def get_context(self, request, *args, **kwargs):
context = super().get_context(request, *args, **kwargs)
context.update(BaseList.from_request(request))
return context
class DatedListPage(DatedBaseList,BasePage):
class Meta:
abstract = True
def get_queryset(self, request, context):
"""
Must be implemented by the child
"""
return []
def get_context(self, request, *args, **kwargs):
"""
note: context is updated using self.get_date_context
"""
context = super().get_context(request, *args, **kwargs)
# date navigation
if 'date' in request.GET:
date = request.GET.get('date')
date = self.str_to_date(date)
else:
date = tz.now().date()
context.update(self.get_date_context(date))
# queryset
context['object_list'] = self.get_queryset(request, context)
context['target'] = self
return context
class LogsPage(DatedListPage):
template = 'aircox_cms/dated_list_page.html'
# TODO: make it a property that automatically select the station
station = models.ForeignKey(
aircox.models.Station,
verbose_name = _('station'),
null = True, blank = True,
on_delete = models.SET_NULL,
help_text = _('(required) related station')
)
max_age = models.IntegerField(
_('maximum age'),
default=15,
help_text = _('maximum days in the past allowed to be shown. '
'0 means no limit')
)
reverse = models.BooleanField(
_('reverse list'),
default=False,
help_text = _('print logs in ascending order by date'),
)
class Meta:
verbose_name = _('Logs')
verbose_name_plural = _('Logs')
content_panels = DatedListPage.content_panels + [
MultiFieldPanel([
FieldPanel('station'),
FieldPanel('max_age'),
FieldPanel('reverse'),
], heading=_('Configuration')),
]
def get_nav_dates(self, date):
"""
Return a list of dates availables for the navigation
"""
# there might be a bug if max_age < nav_days
today = tz.now().date()
first = min(date, today)
first = first - tz.timedelta(days = self.nav_days-1)
if self.max_age:
first = max(first, today - tz.timedelta(days = self.max_age))
return [ first + tz.timedelta(days=i)
for i in range(0, self.nav_days) ]
def get_queryset(self, request, context):
today = tz.now().date()
if self.max_age and context['nav_dates']['next'] > today:
context['nav_dates']['next'] = None
if self.max_age and context['nav_dates']['prev'] < \
today - tz.timedelta(days = self.max_age):
context['nav_dates']['prev'] = None
logs = []
for date in context['nav_dates']['dates']:
items = self.station.on_air(date = date) \
.select_related('track','diffusion')
items = [ SectionLogsList.as_item(item) for item in items ]
logs.append(
(date, reversed(items) if self.reverse else items)
)
return logs
class TimetablePage(DatedListPage):
template = 'aircox_cms/dated_list_page.html'
station = models.ForeignKey(
aircox.models.Station,
verbose_name=_('station'),
on_delete=models.SET_NULL,
null=True, blank=True,
help_text=_('(required) related station')
)
content_panels = DatedListPage.content_panels + [
MultiFieldPanel([
FieldPanel('station'),
], heading=_('Configuration')),
]
class Meta:
verbose_name = _('Timetable')
verbose_name_plural = _('Timetable')
def get_queryset(self, request, context):
diffs = []
for date in context['nav_dates']['dates']:
items = [
DiffusionPage.as_item(item)
for item in aircox.models.Diffusion.objects \
.station(self.station).at(date)
]
diffs.append((date, items))
return diffs

View File

@ -1,534 +0,0 @@
"""
Generic list manipulation used to render list of items
Includes various usefull class and abstract models to make lists and
list items.
"""
import datetime
import re
from enum import IntEnum
from django.db import models
from django.contrib.contenttypes.models import ContentType
from django.contrib.staticfiles.templatetags.staticfiles import static
from django.core.paginator import Paginator, EmptyPage, PageNotAnInteger
from django.utils.translation import ugettext as _, ugettext_lazy
from django.utils import timezone as tz
from django.utils.functional import cached_property
from wagtail.admin.edit_handlers import *
from wagtail.core.models import Page, Orderable
from wagtail.images.models import Image
from wagtail.images.edit_handlers import ImageChooserPanel
from aircox_cms.utils import related_pages_filter
class ListItem:
"""
Generic normalized element to add item in lists that are not based
on Publication.
"""
title = ''
headline = ''
url = ''
cover = None
date = None
def __init__(self, **kwargs):
self.__dict__.update(kwargs)
self.specific = self
class RelatedLinkBase(Orderable):
"""
Base model to make a link item. It can link to an url, or a page and
includes some common fields.
"""
url = models.URLField(
_('url'),
null=True, blank=True,
help_text = _('URL of the link'),
)
page = models.ForeignKey(
Page,
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name='+',
help_text = _('Use a page instead of a URL')
)
icon = models.ForeignKey(
Image,
verbose_name = _('icon'),
null=True, blank=True,
on_delete=models.SET_NULL,
related_name='+',
help_text = _(
'icon from the gallery'
),
)
icon_path = models.CharField(
_('icon path'),
null=True, blank=True,
max_length=128,
help_text = _(
'icon from a given URL or path in the directory of static files'
)
)
text = models.CharField(
_('text'),
max_length = 64,
null = True, blank=True,
help_text = _('text of the link'),
)
info = models.CharField(
_('info'),
max_length = 128,
null=True, blank=True,
help_text = _(
'description displayed in a popup when the mouse hovers '
'the link'
)
)
class Meta:
abstract = True
panels = [
MultiFieldPanel([
FieldPanel('text'),
FieldPanel('info'),
ImageChooserPanel('icon'),
FieldPanel('icon_path'),
FieldPanel('url'),
PageChooserPanel('page'),
], heading=_('link'))
]
def icon_url(self):
"""
Return icon_path as a complete url, since it can either be an
url or a path to static file.
"""
if self.icon_path.startswith('http://') or \
self.icon_path.startswith('https://'):
return self.icon_path
return static(self.icon_path)
def as_dict(self):
"""
Return compiled values from parameters as dict with
'url', 'icon', 'text'
"""
if self.page:
url, text = self.page.url, self.text or self.page.title
else:
url, text = self.url, self.text or self.url
return {
'url': url,
'text': text,
'info': self.info,
'icon': self.icon,
'icon_path': self.icon_path and self.icon_url(),
}
class BaseList(models.Model):
"""
Generic list
"""
class DateFilter(IntEnum):
none = 0x00
previous = 0x01
next = 0x02
before_related = 0x03
after_related = 0x04
class RelationFilter(IntEnum):
none = 0x00
subpages = 0x01
siblings = 0x02
subpages_or_siblings = 0x03
# rendering
use_focus = models.BooleanField(
_('focus available'),
default = False,
help_text = _('if true, highlight the first focused article found')
)
count = models.SmallIntegerField(
_('count'),
default = 30,
help_text = _('number of items to display in the list'),
)
asc = models.BooleanField(
verbose_name = _('ascending order'),
default = True,
help_text = _('if selected sort list in the ascending order by date')
)
# selectors
date_filter = models.SmallIntegerField(
verbose_name = _('filter on date'),
choices = [ (int(y), _(x.replace('_', ' ')))
for x,y in DateFilter.__members__.items() ],
blank = True, null = True,
help_text = _('filter pages on their date')
)
model = models.ForeignKey(
ContentType,
verbose_name = _('filter on page type'),
blank = True, null = True,
on_delete=models.SET_NULL,
help_text = _('keep only elements of this type'),
limit_choices_to = related_pages_filter,
)
related = models.ForeignKey(
Page,
verbose_name = _('related page'),
blank = True, null = True,
on_delete=models.SET_NULL,
help_text = _(
'if set, select children or siblings of this page'
),
related_name = '+'
)
relation = models.SmallIntegerField(
verbose_name = _('relation'),
choices = [ (int(y), _(x.replace('_', ' ')))
for x,y in RelationFilter.__members__.items() ],
default = 1,
help_text = _(
'when the list is related to a page, only select pages that '
'correspond to this relationship'
),
)
search = models.CharField(
verbose_name = _('filter on search'),
blank = True, null = True,
max_length = 128,
help_text = _(
'keep only pages that matches the given search'
)
)
tags = models.CharField(
verbose_name = _('filter on tag'),
blank = True, null = True,
max_length = 128,
help_text = _(
'keep only pages with the given tags (separated by a colon)'
)
)
panels = [
MultiFieldPanel([
FieldPanel('count'),
FieldPanel('use_focus'),
FieldPanel('asc'),
], heading=_('rendering')),
MultiFieldPanel([
FieldPanel('date_filter'),
FieldPanel('model'),
PageChooserPanel('related'),
FieldPanel('relation'),
FieldPanel('search'),
FieldPanel('tags'),
], heading=_('filters'))
]
class Meta:
abstract = True
def __get_related(self, qs):
related = self.related and self.related.specific
filter = self.RelationFilter
if self.relation in (filter.subpages, filter.subpages_or_siblings):
qs_ = qs.descendant_of(related)
if self.relation == filter.subpages_or_siblings and \
not qs.count():
qs_ = qs.sibling_of(related)
qs = qs_
else:
qs = qs.sibling_of(related)
date = related.date if hasattr(related, 'date') else \
related.first_published_at
if self.date_filter == self.DateFilter.before_related:
qs = qs.filter(date__lt = date)
elif self.date_filter == self.DateFilter.after_related:
qs = qs.filter(date__gte = date)
return qs
def get_queryset(self):
"""
Get queryset based on the arguments. This class is intended to be
reusable by other classes if needed.
"""
# FIXME: check if related is published
from aircox_cms.models import Publication
# model
if self.model:
qs = self.model.model_class().objects.all()
else:
qs = Publication.objects.all()
qs = qs.live().not_in_menu()
# related
if self.related:
qs = self.__get_related(qs)
# date_filter
date = tz.now()
if self.date_filter == self.DateFilter.previous:
qs = qs.filter(date__lt = date)
elif self.date_filter == self.DateFilter.next:
qs = qs.filter(date__gte = date)
# sort
qs = qs.order_by('date', 'pk') \
if self.asc else qs.order_by('-date', '-pk')
# tags
if self.tags:
qs = qs.filter(tags__name__in = ','.split(self.tags))
# search
if self.search:
# this qs.search does not return a queryset
qs = qs.search(self.search)
return qs
def get_context(self, request, qs = None, paginate = True):
"""
Return a context object using the given request and arguments.
@param paginate: paginate and include paginator into context
Context arguments:
- object_list: queryset of the list's objects
- paginator: [if paginate] paginator object for this list
- list_url_args: GET arguments of the url as string
! Note: BaseList does not inherit from Wagtail.Page, and calling
this method won't call other super() get_context.
"""
qs = qs or self.get_queryset()
paginator = None
context = {}
if qs.count():
if paginate:
context.update(self.paginate(request, qs))
else:
context['object_list'] = qs[:self.count]
else:
# keep empty queryset
context['object_list'] = qs
context['list_url_args'] = self.to_url(full_url = False)
context['list_selector'] = self
return context
def paginate(self, request, qs):
# paginator
paginator = Paginator(qs, self.count)
try:
qs = paginator.page(request.GET.get('page') or 1)
except PageNotAnInteger:
qs = paginator.page(1)
except EmptyPage:
qs = paginator.page(paginator.num_pages)
return {
'paginator': paginator,
'object_list': qs
}
def to_url(self, page = None, **kwargs):
"""
Return a url to a given page with GET corresponding to this
list's parameters.
@param page: if given use it to prepend url with page's url instead of giving only
GET parameters
@param **kwargs: override list parameters
If there is related field use it to get the page, otherwise use
the given list_page or the first BaseListPage it finds.
"""
params = {
'asc': self.asc,
'date_filter': self.get_date_filter_display(),
'model': self.model and self.model.model,
'relation': self.relation,
'search': self.search,
'tags': self.tags
}
params.update(kwargs)
if self.related:
params['related'] = self.related.pk
params = '&'.join([
key if value == True else '{}={}'.format(key, value)
for key, value in params.items() if value
])
if not page:
return params
return page.url + '?' + params
@classmethod
def from_request(cl, request, related = None):
"""
Return a context from the request's GET parameters. Context
can be used to update relative informations, more information
on this object from BaseList.get_context()
@param request: get params from this request
@param related: reference page for a related list
@return context object from BaseList.get_context()
This function can be used by other views if needed
Parameters:
* asc: if present, sort ascending instead of descending
* date_filter: one of DateFilter attribute's key.
* model: ['program','diffusion','event'] type of the publication
* relation: one of RelationFilter attribute's key
* related: list is related to the method's argument `related`.
It can be a page id.
* tag: tag to search for
* search: query to search in the publications
* page: page number
"""
date_filter = request.GET.get('date_filter')
model = request.GET.get('model')
relation = request.GET.get('relation')
if relation is not None:
try:
relation = int(relation)
except:
relation = None
related_= request.GET.get('related')
if related_:
try:
related_ = int(related_)
related_ = Page.objects.filter(pk = related_).first()
related_ = related_ and related_.specific
except:
related_ = None
kwargs = {
'asc': 'asc' in request.GET,
'date_filter':
int(getattr(cl.DateFilter, date_filter))
if date_filter and hasattr(cl.DateFilter, date_filter)
else None,
'model':
ProgramPage if model == 'program' else
DiffusionPage if model == 'diffusion' else
EventPage if model == 'event' else None,
'related': related_,
'relation': relation,
'tags': request.GET.get('tags'),
'search': request.GET.get('search'),
}
base_list = cl(
count = 30, **{ k:v for k,v in kwargs.items() if v }
)
return base_list.get_context(request)
class DatedBaseList(models.Model):
"""
List that display items per days. Renders a navigation section on the
top.
"""
nav_days = models.SmallIntegerField(
_('navigation days count'),
default = 7,
help_text = _('number of days to display in the navigation header '
'when we use dates')
)
nav_per_week = models.BooleanField(
_('navigation per week'),
default = False,
help_text = _('if selected, show dates navigation per weeks instead '
'of show days equally around the current date')
)
hide_icons = models.BooleanField(
_('hide icons'),
default = False,
help_text = _('if selected, images of publications will not be '
'displayed in the list')
)
class Meta:
abstract = True
panels = [
MultiFieldPanel([
FieldPanel('nav_days'),
FieldPanel('nav_per_week'),
FieldPanel('hide_icons'),
], heading=_('Navigation')),
]
@staticmethod
def str_to_date(date):
"""
Parse a string and return a regular date or None.
Format is either "YYYY/MM/DD" or "YYYY-MM-DD" or "YYYYMMDD"
"""
try:
exp = r'(?P<year>[0-9]{4})(-|\/)?(?P<month>[0-9]{1,2})(-|\/)?' \
r'(?P<day>[0-9]{1,2})'
date = re.match(exp, date).groupdict()
return datetime.date(
year = int(date['year']), month = int(date['month']),
day = int(date['day'])
)
except:
return None
def get_nav_dates(self, date):
"""
Return a list of dates availables for the navigation
"""
if self.nav_per_week:
first = date.weekday()
else:
first = int((self.nav_days - 1) / 2)
first = date - tz.timedelta(days = first)
return [ first + tz.timedelta(days=i)
for i in range(0, self.nav_days) ]
def get_date_context(self, date = None):
"""
Return a dict that can be added to the context to be used by
a date_list.
"""
today = tz.now().date()
if not date:
date = today
# next/prev weeks/date bunch
dates = self.get_nav_dates(date)
next = date + tz.timedelta(days=self.nav_days)
prev = date - tz.timedelta(days=self.nav_days)
# context dict
return {
'nav_dates': {
'today': today,
'date': date,
'next': next,
'prev': prev,
'dates': dates,
}
}

View File

@ -1,666 +0,0 @@
from enum import IntEnum
from django.db import models
from django.contrib.contenttypes.models import ContentType
from django.template import Template, Context
from django.utils.translation import ugettext as _, ugettext_lazy
from django.utils.functional import cached_property
from django.urls import reverse
from modelcluster.models import ClusterableModel
from modelcluster.fields import ParentalKey
from wagtail.admin.edit_handlers import *
from wagtail.images.edit_handlers import ImageChooserPanel
from wagtail.core.models import Page
from wagtail.core.fields import RichTextField
from wagtail.snippets.models import register_snippet
import aircox.models
from aircox_cms.models.lists import *
from aircox_cms.views.components import Component, ExposedData
from aircox_cms.utils import related_pages_filter
@register_snippet
class Region(ClusterableModel):
"""
Region is a container of multiple items of different types
that are used to render extra content related or not the current
page.
A section has an assigned position in the page, and can be restrained
to a given type of page.
"""
name = models.CharField(
_('name'),
max_length=32,
blank = True, null = True,
help_text=_('name of this section (not displayed)'),
)
position = models.CharField(
_('position'),
max_length=16,
blank = True, null = True,
help_text = _('name of the template block in which the section must '
'be set'),
)
order = models.IntegerField(
_('order'),
default = 100,
help_text = _('order of rendering, the higher the latest')
)
model = models.ForeignKey(
ContentType,
on_delete=models.CASCADE,
verbose_name = _('model'),
blank = True, null = True,
help_text=_('this section is displayed only when the current '
'page or publication is of this type'),
limit_choices_to = related_pages_filter,
)
page = models.ForeignKey(
Page,
on_delete=models.CASCADE,
verbose_name = _('page'),
blank = True, null = True,
help_text=_('this section is displayed only on this page'),
)
panels = [
MultiFieldPanel([
FieldPanel('name'),
FieldPanel('position'),
FieldPanel('model'),
FieldPanel('page'),
], heading=_('General')),
# InlinePanel('items', label=_('Region Items')),
]
@classmethod
def get_sections_at (cl, position, page = None):
"""
Return a queryset of sections that are at the given position.
Filter out Region that are not for the given page.
"""
qs = Region.objects.filter(position = position)
if page:
qs = qs.filter(
models.Q(page__isnull = True) |
models.Q(page = page)
)
qs = qs.filter(
models.Q(model__isnull = True) |
models.Q(
model = ContentType.objects.get_for_model(page).pk
)
)
return qs.order_by('order','pk')
def add_item(self, item):
"""
Add an item to the section. Automatically save the item and
create the corresponding SectionPlace.
"""
item.section = self
item.save()
def render(self, request, page = None, context = None, *args, **kwargs):
return ''.join([
item.specific.render(request, page, context, *args, **kwargs)
for item in self.items.all().order_by('order','pk')
])
def __str__(self):
return '{}: {}'.format(self.__class__.__name__, self.name or self.pk)
@register_snippet
class Section(Component, models.Model):
"""
Section is a widget configurable by user that can be rendered inside
Regions.
"""
template_name = 'aircox_cms/sections/section.html'
section = ParentalKey(Region, related_name='items')
order = models.IntegerField(
_('order'),
default = 100,
help_text = _('order of rendering, the higher the latest')
)
real_type = models.CharField(
max_length=32,
blank = True, null = True,
)
title = models.CharField(
_('title'),
max_length=32,
blank = True, null = True,
)
show_title = models.BooleanField(
_('show title'),
default = False,
help_text=_('if set show a title at the head of the section'),
)
css_class = models.CharField(
_('CSS class'),
max_length=64,
blank = True, null = True,
help_text=_('section container\'s "class" attribute')
)
template_name = 'aircox_cms/sections/item.html'
panels = [
MultiFieldPanel([
FieldPanel('section'),
FieldPanel('title'),
FieldPanel('show_title'),
FieldPanel('order'),
FieldPanel('css_class'),
], heading=_('General')),
]
# TODO make it reusable
@cached_property
def specific(self):
"""
Return a downcasted version of the model if it is from another
model, or itself
"""
if not self.real_type or type(self) != Section:
return self
return getattr(self, self.real_type)
def save(self, *args, **kwargs):
if type(self) != Section and not self.real_type:
self.real_type = type(self).__name__.lower()
return super().save(*args, **kwargs)
def __str__(self):
return '{}: {}'.format(
(self.real_type or 'section item').replace('section','section '),
self.title or self.pk
)
class SectionRelativeItem(Section):
is_related = models.BooleanField(
_('is related'),
default = False,
help_text=_(
'if set, section is related to the page being processed '
'e.g rendering a list of links will use thoses of the '
'publication instead of an assigned one.'
)
)
class Meta:
abstract=True
panels = Section.panels.copy()
panels[-1] = MultiFieldPanel(
panels[-1].children + [ FieldPanel('is_related') ],
heading = panels[-1].heading
)
def related_attr(self, page, attr):
"""
Return an attribute from the given page if self.is_related,
otherwise retrieve the attribute from self.
"""
return self.is_related and hasattr(page, attr) \
and getattr(page, attr)
@register_snippet
class SectionText(Section):
template_name = 'aircox_cms/sections/text.html'
body = RichTextField()
panels = Section.panels + [
FieldPanel('body'),
]
def get_context(self, request, page):
from wagtail.core.rich_text import expand_db_html
context = super().get_context(request, page)
context['content'] = expand_db_html(self.body)
return context
@register_snippet
class SectionImage(SectionRelativeItem):
class ResizeMode(IntEnum):
max = 0x00
min = 0x01
crop = 0x02
image = models.ForeignKey(
'wagtailimages.Image',
on_delete=models.CASCADE,
verbose_name = _('image'),
related_name='+',
blank=True, null=True,
help_text=_(
'If this item is related to the current page, this image will '
'be used only when the page has not a cover'
)
)
width = models.SmallIntegerField(
_('width'),
blank=True, null=True,
help_text=_('if set and > 0, sets a maximum width for the image'),
)
height = models.SmallIntegerField(
_('height'),
blank=True, null=True,
help_text=_('if set 0 and > 0, sets a maximum height for the image'),
)
resize_mode = models.SmallIntegerField(
verbose_name = _('resize mode'),
choices = [ (int(y), _(x)) for x,y in ResizeMode.__members__.items() ],
default = int(ResizeMode.max),
help_text=_('if the image is resized, set the resizing mode'),
)
panels = Section.panels + [
ImageChooserPanel('image'),
MultiFieldPanel([
FieldPanel('width'),
FieldPanel('height'),
FieldPanel('resize_mode'),
], heading=_('Resizing'))
]
cache = ""
def get_filter(self):
return \
'original' if not (self.height or self.width) else \
'width-{}'.format(self.width) if not self.height else \
'height-{}'.format(self.height) if not self.width else \
'{}-{}x{}'.format(
self.get_resize_mode_display(),
self.width, self.height
)
def ensure_cache(self, image):
"""
Ensure that we have a generated image and that it is put in cache.
We use this method since generating dynamic signatures don't generate
static images (and we need it).
"""
# Note: in order to put the generated image in db, we first need a way
# to get save events from related page or image.
if self.cache:
return self.cache
if self.width or self.height:
template = Template(
'{% load wagtailimages_tags %}\n' +
'{{% image source {filter} as img %}}'.format(
filter = self.get_filter()
) +
'<img src="{{ img.url }}">'
)
context = Context({
"source": image
})
self.cache = template.render(context)
else:
self.cache = '<img src="{}"/>'.format(image.file.url)
return self.cache
def get_context(self, request, page):
from wagtail.images.views.serve import generate_signature
context = super().get_context(request, page)
image = self.related_attr(page, 'cover') or self.image
if not image:
return context
context['content'] = self.ensure_cache(image)
return context
@register_snippet
class SectionLinkList(ClusterableModel, Section):
template_name = 'aircox_cms/sections/link_list.html'
panels = Section.panels + [
InlinePanel('links', label=_('Links')),
]
@register_snippet
class SectionLink(RelatedLinkBase, Component):
"""
Render a link to a page or a given url.
Can either be used standalone or in a SectionLinkList
"""
template_name = 'aircox_cms/snippets/link.html'
parent = ParentalKey(
'SectionLinkList', related_name = 'links',
null = True
)
def __str__(self):
return 'link: {} #{}'.format(
self.text or (self.page and self.page.title) or self.title,
self.pk
)
@register_snippet
class SectionList(BaseList, SectionRelativeItem):
"""
This one is quite badass, but needed: render a list of pages
using given parameters (cf. BaseList).
If focus_available, the first article in the list will be the last
article with a focus, and will be rendered in a bigger size.
"""
template_name = 'aircox_cms/sections/list.html'
# TODO/FIXME: focus, quid?
# TODO: logs in menu show headline???
url_text = models.CharField(
_('text of the url'),
max_length=32,
blank = True, null = True,
help_text = _('use this text to display an URL to the complete '
'list. If empty, no link is displayed'),
)
panels = SectionRelativeItem.panels + [
FieldPanel('url_text'),
] + BaseList.panels
def get_context(self, request, page):
import aircox_cms.models as cms
if self.is_related and not self.related:
# set current page if there is not yet a related page only
self.related = page
context = BaseList.get_context(self, request, paginate = False)
if not context['object_list'].count():
self.hide = True
return {}
context.update(SectionRelativeItem.get_context(self, request, page))
if self.url_text:
self.related = self.related and self.related.specific
target = None
if self.related and hasattr(self.related, 'get_list_page'):
target = self.related.get_list_page()
if not target:
settings = cms.WebsiteSettings.for_site(request.site)
target = settings.list_page
context['url'] = self.to_url(page = target) + '&view=list'
return context
SectionList._meta.get_field('count').default = 5
@register_snippet
class SectionLogsList(Section):
template_name = 'aircox_cms/sections/logs_list.html'
station = models.ForeignKey(
aircox.models.Station,
verbose_name = _('station'),
null = True,
on_delete=models.SET_NULL,
help_text = _('(required) the station on which the logs happened')
)
count = models.SmallIntegerField(
_('count'),
default = 5,
help_text = _('number of items to display in the list (max 100)'),
)
class Meta:
verbose_name = _('list of logs')
verbose_name_plural = _('lists of logs')
panels = Section.panels + [
FieldPanel('station'),
FieldPanel('count'),
]
@staticmethod
def as_item(log):
"""
Return a log object as a DiffusionPage or ListItem.
Supports: Log/Track, Diffusion
"""
from aircox_cms.models import DiffusionPage
if log.diffusion:
return DiffusionPage.as_item(log.diffusion)
track = log.track
return ListItem(
title = '{artist} -- {title}'.format(
artist = track.artist,
title = track.title,
),
headline = track.info,
date = log.date,
info = '',
css_class = 'track'
)
def get_context(self, request, page):
context = super().get_context(request, page)
context['object_list'] = [
self.as_item(item)
for item in self.station.on_air(count = min(self.count, 100))
]
return context
@register_snippet
class SectionTimetable(Section,DatedBaseList):
template_name = 'aircox_cms/sections/timetable.html'
class Meta:
verbose_name = _('Section: Timetable')
verbose_name_plural = _('Sections: Timetable')
station = models.ForeignKey(
aircox.models.Station,
on_delete=models.CASCADE,
verbose_name = _('station'),
help_text = _('(required) related station')
)
target = models.ForeignKey(
'aircox_cms.TimetablePage',
on_delete=models.CASCADE,
verbose_name = _('timetable page'),
blank = True, null = True,
help_text = _('select a timetable page used to show complete timetable'),
)
nav_visible = models.BooleanField(
_('show date navigation'),
default = True,
help_text = _('if checked, navigation dates will be shown')
)
# TODO: put in multi-field panel of DatedBaseList
panels = Section.panels + DatedBaseList.panels + [
MultiFieldPanel([
FieldPanel('nav_visible'),
FieldPanel('station'),
FieldPanel('target'),
], heading=_('Timetable')),
]
def get_queryset(self, context):
from aircox_cms.models import DiffusionPage
diffs = []
for date in context['nav_dates']['dates']:
items = [
DiffusionPage.as_item(item)
for item in aircox.models.Diffusion.objects \
.station(self.station).at(date)
]
diffs.append((date, items))
return diffs
def get_context(self, request, page):
context = super().get_context(request, page)
context.update(self.get_date_context())
context['object_list'] = self.get_queryset(context)
context['target'] = self.target
if not self.nav_visible:
del context['nav_dates']['dates'];
return context
@register_snippet
class SectionPublicationInfo(Section):
template_name = 'aircox_cms/sections/publication_info.html'
class Meta:
verbose_name = _('Section: publication\'s info')
verbose_name_plural = _('Sections: publication\'s info')
@register_snippet
class SectionSearchField(Section):
template_name = 'aircox_cms/sections/search_field.html'
default_text = models.CharField(
_('default text'),
max_length=32,
default=_('search'),
help_text=_('text to display when the search field is empty'),
)
class Meta:
verbose_name = _('Section: search field')
verbose_name_plural = _('Sections: search field')
panels = Section.panels + [
FieldPanel('default_text'),
]
@register_snippet
class SectionPlaylist(Section):
"""
User playlist. Can be used to add sounds in it -- there should
only be one for the moment.
"""
class Track(ExposedData):
"""
Class exposed to Javascript playlist manager as Track.
"""
fields = {
'name': 'name',
'embed': 'embed',
'duration': lambda e, o:
o.duration.hour * 3600 + o.duration.minute * 60 +
o.duration.second
,
'duration_str': lambda e, o:
(str(o.duration.hour) + '"' if o.duration.hour else '') +
str(o.duration.minute) + "'" + str(o.duration.second)
,
'sources': lambda e, o: [ o.url() ],
'detail_url':
lambda e, o: o.diffusion and hasattr(o.diffusion, 'page') \
and o.diffusion.page.url
,
'cover':
lambda e, o: o.diffusion and hasattr(o.diffusion, 'page') \
and o.diffusion.page.icon
,
}
user_playlist = models.BooleanField(
_('user playlist'),
default = False,
help_text = _(
'this is a user playlist, it can be edited and saved by the '
'users (the modifications will NOT be registered on the server)'
)
)
read_all = models.BooleanField(
_('read all'),
default = True,
help_text = _(
'by default at the end of the sound play the next one'
)
)
tracks = None
template_name = 'aircox_cms/sections/playlist.html'
panels = Section.panels + [
FieldPanel('user_playlist'),
FieldPanel('read_all'),
]
def __init__(self, *args, sounds = None, tracks = None, page = None, **kwargs):
"""
Init playlist section. If ``sounds`` is given initialize playlist
tracks with it. If ``page`` is given use it for Track infos
related to a page (cover, detail_url, ...)
"""
self.tracks = (tracks or []) + [
self.Track(object = sound, detail_url = page and page.url,
cover = page and page.icon)
for sound in sounds or []
]
super().__init__(*args, **kwargs)
def get_context(self, request, page):
context = super().get_context(request, page)
context.update({
'is_default': self.user_playlist,
'modifiable': self.user_playlist,
'storage_key': self.user_playlist and str(self.pk),
'read_all': self.read_all,
'tracks': self.tracks
})
if not self.user_playlist and not self.tracks:
self.hide = True
return context
@register_snippet
class SectionPlayer(Section):
"""
Radio stream player.
"""
template_name = 'aircox_cms/sections/playlist.html'
live_title = models.CharField(
_('live title'),
max_length = 32,
help_text = _('text to display when it plays live'),
)
streams = models.TextField(
_('audio streams'),
help_text = _('one audio stream per line'),
)
icon = models.ImageField(
_('icon'),
blank = True, null = True,
help_text = _('icon to display in the player')
)
class Meta:
verbose_name = _('Section: Player')
panels = Section.panels + [
FieldPanel('live_title'),
FieldPanel('icon'),
FieldPanel('streams'),
]
def get_context(self, request, page):
context = super().get_context(request, page)
context['tracks'] = [SectionPlaylist.Track(
name = self.live_title,
sources = self.streams.split('\r\n'),
data_url = reverse('aircox.on_air'),
interval = 10,
run = True,
)]
return context

View File

@ -1,22 +0,0 @@
import os
from django.conf import settings
AIRCOX_CMS_BLEACH_COMMENT_TAGS = [
'i', 'emph', 'b', 'strong', 'strike', 's',
'p', 'span', 'quote','blockquote','code',
'sup', 'sub', 'a',
]
AIRCOX_CMS_BLEACH_COMMENT_ATTRS = {
'*': ['title'],
'a': ['href', 'rel'],
}
# import settings
for k, v in settings.__dict__.items():
if not k.startswith('__') and k not in globals():
globals()[k] = v

View File

@ -1,196 +0,0 @@
from django.contrib.contenttypes.models import ContentType
from django.db.models import Q
from django.db.models.signals import post_save, pre_delete
from django.dispatch import receiver
from django.utils import timezone as tz
from django.utils.translation import ugettext as _, ugettext_lazy
from wagtail.core.models import Page, Site, PageRevision
import aircox.models as aircox
import aircox_cms.models as models
import aircox_cms.models.sections as sections
import aircox_cms.utils as utils
# on a new diffusion
@receiver(post_save, sender=aircox.Station)
def station_post_saved(sender, instance, created, *args, **kwargs):
"""
Create the basis for the website: set up settings and pages
that are common.
"""
if not created:
return
# root pages
root_page = Page.objects.get(id=1)
homepage = models.Publication(
title = instance.name,
slug = instance.slug,
body = _(
'If you see this page, then Aircox is running for the station '
'{station.name}. You might want to change it to a better one. '
).format(station = instance),
)
root_page.add_child(instance=homepage)
# Site
default_site = Site.objects.filter(is_default_site = True).first()
is_default_site = False
if default_site and default_site.pk == 1:
# default website generated by wagtail: disable is_default_site so
# we can use it for us
default_site.is_default_site = False
default_site.save()
is_default_site = True
site = Site(
# /doc/ when a Station is created, a wagtail Site is generated with
# default options. User must set the correct localhost afterwards
hostname = instance.slug + ".local",
port = 80,
site_name = instance.name.capitalize(),
root_page = homepage,
is_default_site = is_default_site,
)
site.save()
# settings
website_settings = models.WebsiteSettings(
site = site,
station = instance,
description = _("The website of the {name} radio").format(
name = instance.name
),
# Translators: tags set by default in <meta> description of the website
tags = _('radio,{station.name}').format(station = instance)
)
# timetable
timetable = models.TimetablePage(
title = _('Timetable'),
)
homepage.add_child(instance = timetable)
# list page (search, terms)
list_page = models.DynamicListPage(
# title is dynamic: no need to specify
title = _('Search'),
)
homepage.add_child(instance = list_page)
website_settings.list_page = list_page
# programs' page: list of programs in a section
programs = models.Publication(
title = _('Programs'),
)
homepage.add_child(instance = programs)
section = sections.Region(
name = _('programs'),
position = 'post_content',
page = programs,
)
section.save();
section.add_item(sections.SectionList(
count = 15,
title = _('Programs'),
url_text = _('All programs'),
model = ContentType.objects.get_for_model(models.ProgramPage),
related = programs,
))
website_settings.default_programs_page = programs
website_settings.sync = True
# logs (because it is a cool feature)
logs = models.LogsPage(
title = _('Previously on air'),
station = instance,
)
homepage.add_child(instance = logs)
# save
site.save()
website_settings.save()
@receiver(post_save, sender=aircox.Program)
def program_post_saved(sender, instance, created, *args, **kwargs):
if not created or hasattr(instance, 'page'):
return
settings = utils.get_station_settings(instance.station)
if not settings or not settings.sync:
return
parent = settings.default_programs_page or \
settings.site.root_page
if not parent:
return
page = models.ProgramPage(
program = instance,
title = instance.name,
live = False,
# Translators: default content of a page for program
body = _('{program.name} is a program on {station.name}.').format(
program = instance,
station = instance.station
)
)
parent.add_child(instance = page)
def clean_page_of(instance):
"""
Delete empty pages for the given instance object; we assume instance
has a One-To-One relationship with a page.
Empty is defined on theses parameters:
- `numchild = 0` => no children
- no headline
- no body
"""
if not hasattr(instance, 'page'):
return
page = instance.page
if page.numchild > 0 or page.headline or page.body:
return
page.delete()
@receiver(pre_delete, sender=aircox.Program)
def program_post_deleted(sender, instance, *args, **kwargs):
clean_page_of(instance)
@receiver(post_save, sender=aircox.Diffusion)
def diffusion_post_saved(sender, instance, created, *args, **kwargs):
initial = instance.initial
if initial:
if not created and hasattr(instance, 'page'):
# fuck it
page = instance.page
page.diffusion = None
page.save()
return
if hasattr(instance, 'page'):
return
page = models.DiffusionPage.from_diffusion(
instance, live = False
)
page = instance.program.page.add_child(
instance = page
)
@receiver(pre_delete, sender=aircox.Diffusion)
def diffusion_pre_deleted(sender, instance, *args, **kwargs):
clean_page_of(instance)

View File

@ -1,627 +0,0 @@
/**
* Define rules for the default layouts, and some useful classes
*/
/** general **/
body {
background-color: #F2F2F2;
font-family: "Myriad Pro",Calibri,Helvetica,Arial,sans-serif;
}
h1, h2, h3, h4, h5 {
font-family: "Myriad Pro",Calibri,Helvetica,Arial,sans-serif;
margin: 0.4em 0em;
}
h1:first-letter, h2:first-letter, h3:first-letter, h4:first-letter {
text-transform: capitalize;
}
h1 { font-size: 1.4em; }
h2 { font-size: 1.2em; }
h3 { font-size: 0.9em; }
h4 { font-size: 0.8em; }
h1 > *, h2 > *, h3 > *, h4 > * { vertical-align: middle; }
a {
cursor: pointer;
text-decoration: none;
color: #616161;
}
a:hover { color: #007EDF; }
a:hover > .small_icon { box-shadow: 0em 0em 0.1em #007EDF; }
ul { margin: 0em; }
/**** position & box ****/
.float_right { float: right; }
.float_left { float: left; }
.flex_row {
display: -webkit-flex;
display: flex;
-webkit-flex-direction: row;
flex-direction: row;
}
.flex_column {
display: -webkit-flex;
display: flex;
-webkit-flex-direction: column;
flex-direction: column;
}
.flex_row > .flex_item,
.flex_column > .flex_item {
-webkit-flex: auto;
flex: auto;
}
.small {
font-size: 0.8em;
}
/**** indicators & info ****/
time, .tags {
font-size: 0.9em;
color: #616161;
}
.info {
font-size: 0.9em;
padding: 0.1em;
color: #007EDF;
}
.error { color: red; }
.warning { color: orange; }
.success { color: green; }
.icon {
max-width: 2em;
max-height: 2em;
vertical-align: middle;
}
.small_icon {
max-height: 1.5em;
vertical-align: middle;
}
/** main layout **/
body > * {
max-width: 92em;
margin: 0em auto;
padding: 0em;
}
.menu {
padding: 0.4em;
}
.menu:empty {
display: none;
}
.menu.row section {
display: inline-block;
}
.menu.col > section {
margin-bottom: 1em;
}
/**** top + header layout ****/
body > .top {
position: fixed;
z-index: 10000000;
top: 0;
left: 0;
width: 100%;
max-width: 100%;
margin: 0em auto;
background-color: white;
border-bottom: 0.1em #dfdfdf solid;
box-shadow: 0em 0.1em 0.1em rgba(255,255,255,0.7);
box-shadow: 0em 0.1em 0.5em rgba(0,0,0,0.1);
transition: opacity 1.5s;
}
body > .top > .menu {
max-width: 92em;
height: 2.5em;
margin: 0em auto;
}
body[scrollY] > .top {
opacity: 0.1;
transition: opacity 1.5s 1s;
}
body > .top:hover {
opacity: 1.0;
transition: opacity 1.5s;
}
body > .header {
overflow: hidden;
margin-top: 3.3em;
margin-bottom: 1em;
}
/** FIXME: remove this once image slides impled **/
body > .header > div {
width: 15000%;
}
body > .header > div > section {
margin: 0;
margin-right: -0.4em;
}
/**** page layout ****/
.page {
display: flex;
}
.page > main {
flex: auto;
overflow: hidden;
margin: 0em 0em;
border-radius: 0.4em;
border: 0.1em #dfdfdf solid;
background-color: rgba(255,255,255,0.9);
box-shadow: inset 0.1em 0.1em 0.2em rgba(255, 255, 255, 0.8);
}
.page > nav {
flex: 1;
width: 50em;
overflow: hidden;
max-width: 16em;
}
.page > .menu.col:first-child { margin-right: 2em; }
.page > main + .menu.col { margin-left: 2em; }
/**** page main ****/
main:not(.detail) h1 {
margin: 0em 0em 0.4em 0em;
}
main .post_content {
display: block;
}
main .post_content section {
display: inline-block;
width: calc(50% - 1em);
vertical-align: top;
}
main.detail {
padding: 0em;
margin: 0em;
}
main > .content {
padding: 1em;
}
main > header {
margin: 0em;
padding: 1em;
position: relative;
}
main > header .foreground {
position: absolute;
left: 0em;
top: 0em;
width: calc(100% - 2em);
padding: 1em;
}
main > header h1 {
width: calc(100% - 2em);
margin: 0em;
margin-bottom: 0.8em;
}
main header .headline {
display: inline-block;
width: calc(60% - 0.8em);
min-height: 1.2em;
font-size: 1.2em;
font-weight: bold;
}
main > header .background {
margin: -1em;
height: 17em;
overflow: hidden;
position: relative;
}
main > header .background img {
position: absolute;
/*! top: -40%; */
/*! left: -40%; */
width: 100%;
min-height: 100%;
filter: blur(20px);
opacity: 0.3;
}
main > header .cover {
right: 0em;
top: 1em;
width: auto;
max-height: calc(100% - 2em);
max-width: calc(40% - 2em);
margin: 1em;
position: absolute;
box-shadow: 0em 0em 4em rgba(0, 0, 0, 0.4);
}
/** sections **/
body section ul {
padding: 0em;
padding-left: 1em;
}
/**** link list ****/
.menu.row .section_link_list > a {
display: inline-block;
margin: 0.2em 1em;
}
.menu.col .section_link_list > a {
display: block;
}
/** content: menus **/
/** content: list & items **/
.list {
width: 100%;
}
ul.list, .list > ul {
padding: 0.4em;
}
.list_item {
margin: 0.4em 0;
}
.list_item > *:not(:last-child) {
margin-right: 0.4em;
}
.list_item img.cover.big {
display: block;
max-width: 100%;
min-height: 15em;
margin: auto;
}
.list_item img.cover.small {
margin-right: 0.4em;
border-radius: 0.4em;
float: left;
min-height: 64px;
}
.list_item > * {
margin: 0em 0.2em;
vertical-align: middle;
}
.list nav {
text-align: center;
font-size: 0.9em;
}
/** content: list items in full page **/
.content > .list:not(.date_list) .list_item {
min-width: 20em;
display: inline-block;
min-height: 2.5em;
margin: 0.4em;
}
/** content: date list **/
.date_list nav {
text-align:center;
}
.date_list nav a {
display: inline-block;
width: 2em;
}
.date_list nav a.date {
width: 4em;
}
.date_list nav a[selected] {
color: #007EDF;
border-bottom: 0.2em #007EDF dotted;
}
.date_list ul:not([selected]) {
display: none;
}
.date_list ul:target {
display: block;
}
.date_list h2 {
display: none;
}
.date_list_item .cover.small {
width: 64px;
margin: 0.4em;
}
.date_list_item h3 {
margin-top: 0em;
}
.date_list_item time {
color: #007EDF;
}
.date_list_item.now {
padding: 0.4em;
}
.date_list_item img.now {
width: 1.3em;
vertical-align: bottom;
}
/** content: date list in full page **/
.content > .date_list .date_list_item time {
color: #007EDF;
font-size: 1.1em;
display: block;
}
.content > .date_list .date_list_item:nth-child(2n+1),
.date_list_item.now {
box-shadow: inset 0em 0em 3em rgba(0, 124, 226, 0.1);
background-color: rgba(0, 124, 226, 0.05);
}
.content > .date_list {
padding: 0 10%;
margin: auto;
width: 80%;
}
/** content: comments **/
.comments form input:not([type=checkbox]),
.comments form textarea {
display: inline-block;
width: 100%;
max-height: 6em;
margin: 0.2em 0em;
padding: 0.2em;
}
.comments form input[type=checkbox],
.comments form button[type=submit] {
vertical-align:bottom;
margin: 0.2em 0em;
text-align: center;
}
.comments form button[type=submit] {
float: right;
}
.comments form #show_more:not(:checked) ~ .extra {
display: none;
}
.comments label[for="show_more"] {
font-size: 0.8em;
}
.comments ul {
margin-top: 2.5em;
}
.comment {
list-style: none;
border: 1px #818181 dotted;
margin: 0.4em 0em;
}
.comment .metadata {
font-size: 0.9em;
}
.comment time {
float: right;
}
/** component: sound **/
.component.sound {
display: flex;
flex-direction: row;
margin: 0.2em;
width: 100%;
}
.component.sound[state="play"] button {
animation-name: sound-blink;
animation-duration: 4s;
animation-iteration-count: infinite;
animation-direction: alternate;
}
@keyframes sound-blink {
from { background-color: rgba(255, 255, 255, 0); }
to { background-color: rgba(255, 255, 255, 0.6); }
}
.component.sound .button {
width: 4em;
height: 4em;
cursor: pointer;
position: relative;
margin-right: 0.4em;
}
.component.sound .button > img {
width: 100%;
height: 100%;
}
.component.sound button {
transition: background-color 0.5s;
background-color: rgba(255,255,255,0.1);
position: absolute;
cursor: pointer;
left: 0;
top: 0;
width: 100%;
height: 100%;
border: 0;
}
.component.sound button:hover {
background-color: rgba(255,255,255,0.5);
}
.component.sound button > img {
background-color: rgba(255,255,255,0.9);
border-radius: 50%;
}
.component.sound .content {
position: relative;
}
.component.sound .info {
text-align: right;
}
.component.sound progress {
width: 100%;
position: absolute;
bottom: 0;
height: 0.4em;
}
.component.sound progress:hover {
height: 1em;
}
/** component: playlist **/
.component.playlist footer {
text-align: right;
display: block;
}
.component.playlist .read_all {
display: none;
}
.component.playlist .read_all + label {
display: inline-block;
padding: 0.1em;
margin-left: 0.2em;
cursor: pointer;
font-size: 1em;
box-shadow: inset 0em 0em 0.1em #818181;
}
.component.playlist .read_all:not(:checked) + label {
border-left: 0.1em #818181 solid;
margin-right: 0em;
}
.component.playlist .read_all:checked + label {
border-right: 0.1em #007EDF solid;
box-shadow: inset 0em 0em 0.1em #007EDF;
margin-right: 0em;
}
/** content: page **/
main .body ~ section:not(.comments) {
width: calc(50% - 1em);
vertical-align: top;
display: inline-block;
}
.meta .author .headline {
display: none;
}
.meta .link_list > a {
font-size: 0.9em;
margin: 0em 0.1em;
padding: 0.2em;
line-height: 1.4em;
}
.meta .link_list > a:hover {
border-radius: 0.2em;
background-color: rgba(0, 126, 223, 0.1);
}
/** content: others **/
.list_item.track .title {
display: inline;
font-style: italic;
font-weight: normal;
font-size: 0.9em;
}

View File

@ -1,50 +0,0 @@
/*
* Define a default theme, that is the one for RadioCampus
*
* Colors:
* - light:
* - background: #F2F2F2
* - color: #000
*
* - dark:
* - background: #212121
* - color: #007EDF
*
* - info:
* - generic (time,url,...): #616161
* - additional: #007EDF
* - active: #007EDF
*/
/** detail view **/
@keyframes rotate {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
/** section: playlist **/
.playlist .title {
font-style: italic;
color: #616161;
}
section.playlist .artist {
display: inline-block;
margin-right: 0.4em;
}
section.playlist .artist:after {
padding-left: 0.2em;
content: ':'
}

View File

@ -1,41 +0,0 @@
scroll_margin = 0
window.addEventListener('scroll', function(e) {
if(window.scrollX > scroll_margin)
document.body.setAttribute('scrollX', 1)
else
document.body.removeAttribute('scrollX')
if(window.scrollY > scroll_margin)
document.body.setAttribute('scrollY', 1)
else
document.body.removeAttribute('scrollY')
});
/// TODO: later get rid of it in order to use Vue stuff
/// Helper to provide a tab+panel functionnality; the tab and the selected
/// element will have an attribute "selected".
/// We assume a common ancestor between tab and panel at a maximum level
/// of 2.
/// * tab: corresponding tab
/// * panel_selector is used to select the right panel object.
function select_tab(tab, panel_selector) {
var parent = tab.parentNode.parentNode;
var panel = parent.querySelector(panel_selector);
// unselect
var qs = parent.querySelectorAll('*[selected]');
for(var i = 0; i < qs.length; i++)
if(qs[i] != tab && qs[i] != panel)
qs[i].removeAttribute('selected');
panel.setAttribute('selected', 'true');
tab.setAttribute('selected', 'true');
}

View File

@ -1,336 +0,0 @@
/* Implementation status: -- TODO
* - proper design
* - mini-button integration in lists (list of diffusion articles)
*/
var State = Object.freeze({
Stop: 'stop',
Loading: 'loading',
Play: 'play',
});
class Track {
// Create a track with the given data.
// If url and interval are given, use them to retrieve regularely
// the track informations
constructor(data) {
Object.assign(this, {
'name': '',
'detail_url': '',
});
Object.assign(this, data);
if(this.data_url) {
if(!this.interval)
this.data_url = undefined;
if(this.run) {
this.run = false;
this.start();
}
}
}
start() {
if(this.run || !this.interval || !this.data_url)
return;
this.run = true;
this.fetch_data();
}
stop() {
this.run = false;
}
fetch_data() {
if(!this.run || !this.interval || !this.data_url)
return;
var self = this;
var req = new XMLHttpRequest();
req.open('GET', this.data_url, true);
req.onreadystatechange = function() {
if(req.readyState != 4 || (req.status && req.status != 200))
return;
if(!req.responseText.length)
return;
// TODO: more consistent API
var data = JSON.parse(req.responseText);
if(data.type == 'track')
data = {
name: '♫ ' + (data.artist ? data.artist + ' — ' : '') +
data.title,
detail_url: ''
}
else
data = {
name: data.title,
detail_url: data.url
}
Object.assign(self, data);
};
req.send();
if(this.run && this.interval)
this._trigger_fetch();
}
_trigger_fetch() {
if(!this.run || !this.data_url)
return;
var self = this;
if(this.interval)
window.setTimeout(function() {
self.fetch_data();
}, this.interval*1000);
else
this.fetch_data();
}
}
/// Current selected sound (being played)
var CurrentSound = null;
var Sound = Vue.extend({
template: '#template-sound',
delimiters: ['[[', ']]'],
data: function() {
return {
mounted: false,
// sound state,
state: State.Stop,
// current position in playing sound
position: 0,
// estimated position when user mouse over progress bar
user_seek: null,
};
},
computed: {
// sound can be seeked
seekable() {
// seekable: for the moment only when we have a podcast file
// note: need mounted because $refs is not reactive
return this.mounted && this.duration && this.$refs.audio.seekable;
},
// sound duration in seconds
duration() {
return this.track.duration;
},
seek_position() {
return (this.user_seek === null && this.position) ||
this.user_seek;
},
},
props: {
track: { type: Object, required: true },
},
mounted() {
this.mounted = true;
console.log(this.track, this.track.detail_url);
this.detail_url = this.track.detail_url;
this.storage_key = "sound." + this.track.sources[0];
var pos = localStorage.getItem(this.storage_key)
if(pos) try {
// go back of 5 seconds
pos = parseFloat(pos) - 5;
if(pos > 0)
this.$refs.audio.currentTime = pos;
} catch (e) {}
},
methods: {
//
// Common methods
//
stop() {
this.$refs.audio.pause();
CurrentSound = null;
},
play(reset = false) {
if(CurrentSound && CurrentSound != this)
CurrentSound.stop();
CurrentSound = this;
if(reset)
this.$refs.audio.currentTime = 0;
this.$refs.audio.play();
},
play_stop() {
if(this.state == State.Stop)
this.play();
else
this.stop();
},
add_to_playlist() {
if(!DefaultPlaylist)
return;
var tracks = DefaultPlaylist.tracks;
if(tracks.indexOf(this.track) == -1)
DefaultPlaylist.tracks.push(this.track);
},
remove() {
this.stop();
var tracks = this.$parent.tracks;
var i = tracks.indexOf(this.track);
if(i == -1)
return;
tracks.splice(i, 1);
},
//
// Utils functions
//
_as_progress_time(event) {
bounding = this.$refs.progress.getBoundingClientRect()
offset = (event.clientX - bounding.left);
return offset * this.$refs.audio.duration / bounding.width;
},
// format seconds into time string such as: [h"m]m'ss
format_time(seconds) {
seconds = Math.floor(seconds);
var hours = Math.floor(seconds / 3600);
seconds -= hours * 3600;
var minutes = Math.floor(seconds / 60);
seconds -= minutes * 60;
return (hours ? ((hours < 10 ? '0' + hours : hours) + '"') : '') +
minutes + "'" + seconds
;
},
//
// Events
//
timeUpdate() {
this.position = this.$refs.audio.currentTime;
if(this.state == State.Play)
localStorage.setItem(
this.storage_key, this.$refs.audio.currentTime
);
},
ended() {
this.state = State.Stop;
this.$refs.audio.currentTime = 0;
localStorage.removeItem(this.storage_key);
this.$emit('ended', this);
},
progress_mouse_out(event) {
this.user_seek = null;
},
progress_mouse_move(event) {
if(this.$refs.audio.duration == Infinity ||
isNaN(this.$refs.audio.duration))
return;
this.user_seek = this._as_progress_time(event);
},
progress_clicked(event) {
this.$refs.audio.currentTime = this._as_progress_time(event);
this.play();
event.stopImmediatePropagation();
},
}
});
/// User's default playlist
DefaultPlaylist = null;
var Playlist = Vue.extend({
template: '#template-playlist',
delimiters: ['[[', ']]'],
data() {
return {
// if true, use this playlist as user's default playlist
default: false,
// read all mode enabled
read_all: false,
// playlist can be modified by user
modifiable: false,
// if set, save items into localstorage using this root key
storage_key: null,
// sounds info
tracks: [],
};
},
computed: {
// id of the read all mode checkbox switch
read_all_id() {
return this.id + "_read_all";
}
},
mounted() {
// set default
if(this.default) {
if(DefaultPlaylist)
this.tracks = DefaultPlaylist.tracks;
else
DefaultPlaylist = this;
}
// storage_key
if(this.storage_key) {
tracks = localStorage.getItem('playlist.' + this.storage_key);
if(tracks)
this.tracks = JSON.parse(tracks);
}
console.log(this.tracks)
},
methods: {
sound_ended(sound) {
// ensure sound is stopped (beforeDestroy())
sound.stop();
// next only when read all mode
if(!this.read_all)
return;
var sounds = this.$refs.sounds;
var id = sounds.findIndex(s => s == sound);
if(id < 0 || id+1 >= sounds.length)
return
id++;
sounds[id].play(true);
},
},
watch: {
tracks: {
handler() {
if(!this.storage_key)
return;
localStorage.setItem('playlist.' + this.storage_key,
JSON.stringify(this.tracks));
},
deep: true,
}
}
});
Vue.component('a-sound', Sound);
Vue.component('a-playlist', Playlist);

View File

@ -1,68 +0,0 @@
/// Helper to provide a tab+panel functionnality; the tab and the selected
/// element will have an attribute "selected".
/// We assume a common ancestor between tab and panel at a maximum level
/// of 2.
/// * tab: corresponding tab
/// * panel_selector is used to select the right panel object.
function select_tab(tab, panel_selector) {
var parent = tab.parentNode.parentNode;
var panel = parent.querySelector(panel_selector);
// unselect
var qs = parent.querySelectorAll('*[selected]');
for(var i = 0; i < qs.length; i++)
if(qs[i] != tab && qs[i] != panel)
qs[i].removeAttribute('selected');
panel.setAttribute('selected', 'true');
tab.setAttribute('selected', 'true');
}
/// Utility to store objects in local storage. Data are stringified in JSON
/// format in order to keep type.
function Store(prefix) {
this.prefix = prefix;
}
Store.prototype = {
// save data to localstorage, or remove it if data is null
set: function(key, data) {
key = this.prefix + '.' + key;
if(data == undefined) {
localStorage.removeItem(this.prefix);
return;
}
localStorage.setItem(key, JSON.stringify(data))
},
// load data from localstorage
get: function(key) {
try {
key = this.prefix + '.' + key;
var data = localStorage.getItem(key);
if(data)
return JSON.parse(data);
}
catch(e) { console.log(e, data); }
},
// return true if the given item is stored
exists: function(key) {
key = this.prefix + '.' + key;
return (localStorage.getItem(key) != null);
},
// update a field in the stored data
update: function(key, field_key, value) {
data = this.get(key) || {};
if(value)
data[field_key] = value;
else
delete data[field_key];
this.set(key, data);
},
}

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@ -1,73 +0,0 @@
from django.db import models
from django.template.loader import render_to_string
from wagtail.core.utils import camelcase_to_underscore
class TemplateMixin(models.Model):
class Meta:
abstract = True
template_name = None
"""
Template to use for the mixin. If not given, use
"app_label/sections/section_class.html"
"""
snake_name = None
"""
Used in template as class
"""
@classmethod
def get_template_name(cl):
if not cl.template_name:
cl.snake_name = camelcase_to_underscore(cl.__name__)
cl.template_name = '{}/sections/{}.html'.format(
cl._meta.app_label, cl.snake_name
)
if cl.snake_name != 'section_item':
from django.template import TemplateDoesNotExist
try:
from django.template.loader import get_template
get_template(cl.template_name)
except TemplateDoesNotExist:
cl.template_name = 'aircox_cms/sections/section_item.html'
return cl.template_name
def get_context(self, request, page):
"""
Default context attributes:
* self: section being rendered
* page: current page being rendered
* request: request used to render the current page
Other context attributes usable in the default template:
* content: **safe string** set as content of the section
* hide: DO NOT render the section, render only an empty string
"""
return {
'self': self,
'page': page,
'request': request,
}
def render(self, request, page, context, *args, **kwargs):
"""
Render the section. Page is the current publication being rendered.
Rendering is similar to pages, using 'template' attribute set
by default to the app_label/sections/model_name_snake_case.html
If the default template is not found, use Section's one,
that can have a context attribute 'content' that is used to render
content.
"""
context_ = self.get_context(request, *args, page=page, **kwargs)
if context:
context_.update(context)
if context_.get('hide'):
return ''
return render_to_string(self.get_template_name(), context_)

View File

@ -1,146 +0,0 @@
{% load static %}
{% load i18n %}
{% load wagtailcore_tags %}
{% load wagtailimages_tags %}
{% load wagtailsettings_tags %}
{% load aircox_cms %}
{% get_settings %}
<html>
<head>
<meta charset="utf-8">
<meta name="application-name" content="aircox-cms">
<meta name="description" content="{{ settings.aircox_cms.WebsiteSettings.description }}">
<meta name="keywords" content="{{ page.tags.all|default:settings.aircox_cms.WebsiteSettings.tags }}">
{% with favicon=settings.cms.WebsiteSettings.favicon %}
<link rel="icon" href="{{ favicon.url }}" />
{% endwith %}
{% block css %}
<link rel="stylesheet" href="{% static 'aircox_cms/css/layout.css' %}" type="text/css" />
<link rel="stylesheet" href="{% static 'aircox_cms/css/theme.css' %}" type="text/css" />
{% block css_extras %}{% endblock %}
{% endblock %}
{% if settings.DEBUG %}
<script src="{% static 'lib/vue.js' %}">
{% else %}
<script src="{% static 'lib/vue.min.js' %}">
{% endif %}
<script src="{% static 'aircox_cms/js/bootstrap.js' %}"></script>
<script src="{% static 'aircox_cms/js/utils.js' %}"></script>
<script src="{% static 'aircox_cms/js/player.js' %}"></script>
<title>{{ page.title }}</title>
{# TODO: include vues somewhere else #}
{% include "aircox_cms/vues/player.html" %}
<script>
window.addEventListener('loaded', function() {
new Vue({
el: "#app",
delimiters: ['${', '}'],
});
}, false);
</script>
</head>
{% spaceless %}
<body id="app">
<nav class="top">
<div class="menu row">
{% render_sections position="top" %}
<div>
</nav>
<header class="header menu row">
<div>
{% render_sections position="header" %}
</div>
</header>
<div class="page flex_row">
<nav class="menu col page_left flex_item">
{% render_sections position="page_left" %}
</nav>
<main class="{% if not object_list %}detail{% endif %}">
{% if messages %}
<ul class="messages">
{% for message in messages %}
<li{% if message.tags %} class="{{ message.tags }}"{% endif %}>
{{ message }}
</li>
{% endfor %}
</ul>
{% endif %}
<header>
{% block title %}
{% if page.cover %}
<div class="background">
{% image page.cover max-600x480 class="background-cover" height="" width="" %}
</div>
{% image page.cover max-600x480 class="cover" height="" width="" %}
<div class="foreground">
{% endif %}
<h1 class="title"><a href="{{ page.url }}">{{ page.title }}</a></h1>
{% if page.headline %}
<section class="headline">
{{ page.headline }}
</section>
{% endif %}
{% if page.cover %}
</div>
{% endif %}
{% endblock %}
</header>
<div class="content">
{% block content %}
{% if page.body %}
<section class="body">
{{ page.body|richtext}}
</section>
{% endif %}
{% endblock %}
{% if view != 'list' %}
{% block content_extras %}
{% endblock %}
{% endif %}
<div class="post_content">
{% render_sections position="post_content" %}
</div>
<section class="comments">
{% include "aircox_cms/snippets/comments.html" %}
</section>
</div>
</main>
<nav class="menu col page_right flex_item">
{% render_sections position="page_right" %}
</nav>
</div>
{% block footer %}
<footer class="menu footer">
{% render_sections position="footer" %}
<div class="small float_right">Propulsed by
<a href="https://github.com/bkfox/aircox">Aircox</a>
</div>
</footer>
{% endblock %}
</body>
{% endspaceless %}
</html>

View File

@ -1,3 +0,0 @@
{% extends "aircox_cms/publication.html" %}

View File

@ -1,15 +0,0 @@
{% extends "aircox_cms/base_site.html" %}
{# display a timetable of planified diffusions by days #}
{% load wagtailcore_tags %}
{% block content %}
{% if page.body %}
<div class="body">
{{ page.body|richtext }}
</div>
{% endif %}
{% include "aircox_cms/snippets/date_list.html" %}
{% endblock %}

View File

@ -1,41 +0,0 @@
{% extends "aircox_cms/publication.html" %}
{% load i18n %}
{% load aircox_cms %}
{% block content_extras %}
{% with tracks=page.tracks.all %}
{% if tracks %}
<section class="playlist">
<h2>{% trans "Playlist" %}</h2>
<ul>
{% for track in tracks %}
<li><span class="artist">{{ track.artist }}</span>
<span class="title">{{ track.title }}</span>
{% if track.info %} <span class="info">{{ track.info }}</span>{% endif %}
</li>
{% endfor %}
</ul>
</section>
{% endif %}
{% endwith %}
{% if diffusion.reruns.count %}
<section class="dates">
<h2>{% trans "Dates of diffusion" %}</h2>
<ul>
{% with diffusion=page.diffusion %}
<li>{{ diffusion.date|date:"l d F Y, H:i" }}</li>
{% for diffusion in diffusion.reruns.all %}
<li>{{ diffusion.date|date:"l d F Y, H:i" }}</li>
{% endfor %}
{% endwith %}
</ul>
</section>
{% endif %}
{% if podcasts %}
{% render_section section=podcasts %}
{% endif %}
{% endblock %}

View File

@ -1,52 +0,0 @@
{% extends "aircox_cms/base_site.html" %}
{# generic page to display list of articles #}
{% load i18n %}
{% load wagtailcore_tags %}
{% load wagtailimages_tags %}
{% block title %}
<h1>
{# Translators: titles for the page that shows a list of elements. #}
{# Translators: terms are search terms, or tag tarms. url: url to the page #}
{% if list_selector.terms %}
{% blocktrans with terms=list_selector.terms trimmed %}
Search in publications for <i>{{ terms }}</i>
{% endblocktrans %}
{% elif list_selector.related %}
{# should never happen #}
{% blocktrans with title=list_selector.related.title url=list_selector.related.url trimmed %}
Related to <a href="{{ url }}">{{ title }}</a>
{% endblocktrans %}
{% else %}
{% blocktrans %}All the publications{% endblocktrans %}
{% endif %}
</h1>
{% endblock %}
{% block content %}
{# if there is a related, print related content, otherwise use dynpage #}
{% with related=list_selector.related %}
{% if related %}
<div class="body headline">
{% image related.cover fill-128x128 class="cover item_cover" %}
{{ related.headline }}
<a href="{{ related.url }}">{% trans "More about it" %}</a>
</div>
{% elif page.body %}
<div class="body">
{% image page.cover fill-128x128 class="cover item_cover" %}
{{ page.body|richtext }}
</div>
{% endif %}
{% endwith %}
{% with list_paginator=paginator %}
{% include "aircox_cms/snippets/list.html" %}
{% endwith %}
{% endblock %}

View File

@ -1,24 +0,0 @@
{% extends "aircox_cms/publication.html" %}
{% load i18n %}
{% block content %}
<div>
<h2>{% trans "Practical information" %}</h2>
<ul>
{% with start=page.start|date:'l d F H:i' %}
{% with end=page.end|date:'l d F H:i' %}
<li><b>{% trans "Date" %}</b>:
{% transblock %}{{ start }} until {{ end }}{% endtransblock %}
</li>
<li><b>{% trans "Place" %}</b>: {{ page.address }}</li>
{% if page.price %}
<li><b>{% trans "Price" %}</b>: {{ page.price }}</li>
{% endif %}
{% if page.info %}<li>{{ page.info }}</li>{% endif %}
{% endwith %}
{% endwith %}
</ul>
{% endblock %}

View File

@ -1,41 +0,0 @@
{% extends "aircox_cms/publication.html" %}
{# generic page to display programs #}
{% load i18n %}
{% load wagtailcore_tags %}
{# TODO message if program is no more active #}
{% block content_extras %}
<section class="schedule">
{% if page.program.active and page.program.schedule_set.count %}
<h2>{% trans "Schedule" %}</h2>
<ul>
{% for schedule in page.program.schedule_set.all %}
{% with frequency=schedule.get_frequency_display day=schedule.date|date:'l' %}
{% with start_hour=schedule.time.hour start_minute=schedule.time.minute %}
{% with duration_hour=schedule.duration.hour duration_minute=schedule.duration.minute %}
<li aria-label="{% blocktrans trimmed %}Diffusion on {{ day }} at {{ start_hour }} hours {{ start_minute }}, {{ frequency }}, and last for {{ duration_hour }} hours and {{ duration_minute }} minutes {% endblocktrans %}">
{% endwith %}
{% endwith %}
{% with start=schedule.time|date:"H:i" duration=schedule.duration|time:"H\"i" %}
{% blocktrans trimmed %}
{{ day }} at {{ start }} ({{ duration }}), <span class="info">{{ frequency }}</span>
{% endblocktrans %}
{% if schedule.initial %}
<span class="info">{% trans "Rerun" %}</span>
{% endif %}
{% endwith %}
</li>
{% endwith %}
{% endfor %}
</ul>
{% else %}
<div class="warning">{% trans "This program is no longer active" %}</div>
{% endif %}
</section>
{% endblock %}

View File

@ -1,27 +0,0 @@
{% extends "aircox_cms/base_site.html" %}
{% load i18n %}
{% load wagtailcore_tags %}
{% load wagtailimages_tags %}
{% load aircox_cms %}
{% block content %}
<div class="content">
{% if page.body %}
<section class="body">
{{ page.body|richtext}}
</section>
{% endif %}
{% if object_list %}
{# list view #}
{% with list_paginator=paginator %}
{% include "aircox_cms/snippets/list.html" %}
{% endwith %}
{% endif %}
</div>
{% endblock %}

View File

@ -1,10 +0,0 @@
<section class="section_item {{ self.css_class }} {{ self.snake_name }}">
{% block section_content %}
{% block title %}
{% if self.show_title %}<h2>{{ self.title }}</h2>{% endif %}
{% endblock %}
{% block content %}{{ content|safe }}{% endblock %}
{% endblock %}
</section>

View File

@ -1,11 +0,0 @@
{% extends "aircox_cms/sections/item.html" %}
{% load wagtailimages_tags %}
{% load aircox_cms %}
{% block content %}
{% for item in self.links.all %}
{% render_template_mixin item %}
{% endfor %}
{% endblock %}

View File

@ -1,9 +0,0 @@
{% extends "aircox_cms/sections/item.html" %}
{% block content %}
{% with url=url url_text=self.url_text %}
{% include "aircox_cms/snippets/list.html" %}
{% endwith %}
{% endblock %}

View File

@ -1,10 +0,0 @@
{% extends "aircox_cms/sections/item.html" %}
{% block content %}
{% with item_date_format="H:i" list_css_class="date_list" list_no_cover=True list_no_headline=True %}
{% for item in object_list %}
{% include "aircox_cms/snippets/date_list_item.html" %}
{% endfor %}
{% endwith %}
{% endblock %}

View File

@ -1,59 +0,0 @@
{% extends 'aircox_cms/sections/item.html' %}
{% load staticfiles %}
{% load i18n %}
{% load aircox_cms %}
{% block content %}
{% with playlist_id="playlist"|gen_id %}
<a-playlist class="playlist" id="{{ playlist_id }}">
<noscript>
{% for track in tracks %}
<div class="item">
<span class="name">
{{ track.data.name }}
{% if track.data.duration %}
({{ track.data.duration_str }})
{% endif %}
</span>
<span class="podcast">
{% if not track.data.embed %}
<audio src="{{ track.url|escape }}" controls>
{% else %}
{{ track.embed|safe }}
{% endif %}
</span>
</div>
{% endfor %}
</noscript>
<script>
window.addEventListener('load', function() {
var playlist = new Playlist({
data: {
id: "{{ playlist_id }}",
{% if is_default %}
default: true,
{% endif %}
{% if read_all %}
read_all: true,
{% endif %}
{% if modifiable %}
modifiable: true,
{% endif %}
{% if storage_key %}
storage_key: "{{ storage_key }}",
{% endif %}
tracks: [
{% for track in tracks %}
new Track({{ track.to_json }}),
{% endfor %}
],
},
});
playlist.$mount('#{{ playlist_id }}');
}, false);
</script>
</a-playlist>
{% endwith %}
{% endblock %}

View File

@ -1,94 +0,0 @@
{% extends "aircox_cms/sections/item.html" %}
{% load i18n %}
{% load static %}
{% load wagtailsettings_tags %}
{% block content %}
{% spaceless %}
<div class="meta">
{% with ancestors=page.get_ancestors %}
{% if ancestors|length != 1 %}
<div class="link_list">
<img src="{% static "aircox/images/home.png" %}"
alt="{% trans "Parent pages" %}"
title="{% trans "Parent pages" %}"
class="small_icon">
{% for page in page.get_ancestors %}
{% if not forloop.first %}
<a href="{{ page.url }}">{{ page.title }}</a>
{% if not forloop.last %} &gt; {% endif %}
{% endif %}
{% endfor %}
</div>
{% endif %}
{% endwith %}
{% with list_page=settings.cms.WebsiteSettings.list_page %}
{% if list_page and page.tags.count %}
<div class="link_list tags">
<img src="{% static "aircox/images/tags.png" %}"
alt="{% trans "Tags" %}"
title="{% trans "Tags" %}"
class="small_icon">
{% for tag in page.tags.all %}
<a href="{{ list_page }}?tag={{ tag }}">{{ tag }}</a>
{% if not forloop.last %}, {% endif %}
{% endfor %}
</div>
{% endif %}
{% endwith %}
<div class="author">
{% if page.publish_as %}
{% with item=page.publish_as item_date_format='' %}
{% include "aircox_cms/snippets/list_item.html" %}
{% endwith %}
{% elif page.owner %}
<img src="{% static "aircox/images/info.png" %}"
alt="{% trans "Author" %}"
title="{% trans "Author" %}"
class="small_icon">
{% blocktrans with author=page.owner trimmed %}
Published by {{ author }}
{% endblocktrans %}
{% endif %}
</div>
{% with page_date=page.specific.date %}
{% if page_date %}
<time datetime="{{ page_date }}">
<img src="{% static "aircox/images/calendar_day.png" %}"
alt="{% trans "Date of publication" %}"
title="{% trans "Date of publication" %}"
class="small_icon">
{{ page_date|date:'l d F, H:i' }}
</time>
{% endif %}
{% endwith %}
<div class="share link_list">
<img src="{% static "aircox/images/share.png" %}"
alt="{% trans "Share" %}"
title="{% trans "Share" %}" class="small_icon">
<a href="mailto:?&body={{ page.full_url|urlencode }}"
target="new">
<img src="{% static "aircox/images/mail.png" %}" alt="Mail" class="small_icon">
</a>
<a href="https://www.facebook.com/sharer/sharer.php?u={{ page.full_url|urlencode }}"
target="new">
<img src="{% static "aircox/images/facebook.png" %}" alt="Facebook" class="small_icon">
</a>
<a href="https://twitter.com/intent/tweet?text={{ page.full_url|urlencode }}"
target="new">
<img src="{% static "aircox/images/twitter.png" %}" alt="Twitter" class="small_icon">
</a>
<a href="https://plus.google.com/share?url={{ page.full_url|urlencode }}"
target="new">
<img src="{% static "aircox/images/gplus.png" %}" alt="Google Plus" class="small_icon">
</a>
</div>
</div>
{% endspaceless %}
{% endblock %}

View File

@ -1,16 +0,0 @@
{% extends "aircox_cms/sections/item.html" %}
{% load i18n %}
{% load static %}
{% load wagtailsettings_tags %}
{% block content %}
{% with list_page=settings.aircox_cms.WebsiteSettings.list_page %}
<form action="{{ list_page.url }}" method="GET">
<img src="{% static "aircox/images/search.png" %}" class="icon"/>
<input type="text" name="search" placeholder="{{ self.default_text }}">
<input type="submit" style="display: none;">
</form>
{% endwith %}
{% endblock %}

View File

@ -1,6 +0,0 @@
{% extends "aircox_cms/sections/item.html" %}
{% block content %}
{% include "aircox_cms/snippets/date_list.html" %}
{% endblock %}

View File

@ -1,55 +0,0 @@
{% load i18n %}
{% load static %}
{% load honeypot %}
{% if comment_form or page.comments %}
<h2><img src="{% static "aircox/images/comments.png" %}" class="icon">{% trans "Comments" %}</h2>
{% endif %}
{% if comment_form %}
{% with comment_form as form %}
{{ form.non_field_errors }}
<form action="" method="POST">
{% csrf_token %}
{% render_honeypot_field "hp_website" %}
<div>
<input type="hidden" name="type" value="comments">
{{ form.author.errors }}
{{ form.author }}
<div>
<input type="checkbox" value="1" id="show_more">
<label for="show_more">{% trans "show more options" %}</label>
<div class="extra">
{{ form.email.errors }}
{{ form.email }}
{{ form.url.errors }}
{{ form.url }}
</div>
</div>
</div>
<div>
{{ form.content.errors }}
{{ form.content }}
<button type="submit">{% trans "Post!" %}</button>
</div>
</form>
{% endwith %}
{% endif %}
<ul class="list">
{% for comment in page.comments %}
<li class="comment">
<div class="metadata">
<a {% if comment.url %}href="{{ comment.url }}" {% endif %}
class="author">{{ comment.author }}</a>
<time datetime="{{ comment.date }}">
{{ comment.date|date:'l d F, H:i' }}
</time>
</div>
<div class="body">{{ comment.content }}</div>
</li>
{% endfor %}
</ul>

View File

@ -1,48 +0,0 @@
{% load i18n %}
{# FIXME: get current complete URL #}
<div class="list date_list">
{% if nav_dates.dates %}
<nav class="nav_dates">
{% if target %}
<a href="{{ target.url }}?date={{ nav_dates.today|date:"Y-m-d" }}" title="{% trans "go to today" %}">&#9679;</a>
{% if nav_dates.prev %}
<a href="{{ target.url }}?date={{ nav_dates.prev|date:"Y-m-d" }}" title="{% trans "previous days" %}"></a>
{% endif %}
{% endif %}
{% for day in nav_dates.dates %}
<a onclick="select_tab(this, '.panel[data-date=\'{{day|date:"Y-m-d"}}\']');"
{% if day == nav_dates.date %}selected{% endif %}
class="tab date {% if day == nav_dates.date %}today{% endif %}"
title="{{ day|date:"l d F Y" }}"
>
{{ day|date:'D. d' }}
</a>
{% endfor %}
{% if target and nav_dates.next %}
<a href="{{ target.url }}?date={{ nav_dates.next|date:"Y-m-d" }}" title="{% trans "next days" %}"></a>
{% endif %}
</nav>
{% endif %}
{% for day, list in object_list %}
<ul class="panel {% if day == nav_dates.date %}today{% endif %}"
{% if day == nav_dates.date %}selected{% endif %}
data-date="{{day|date:"Y-m-d"}}">
{# you might like to hide it by default -- this more for sections #}
<h2>{{ day|date:'l d F' }}</h2>
{% with item_date_format="H:i" %}
{% with list_no_cover=self.hide_icons %}
{% for item in list %}
{% include "aircox_cms/snippets/date_list_item.html" %}
{% endfor %}
{% endwith %}
{% endwith %}
</ul>
{% endfor %}
</div>

View File

@ -1,57 +0,0 @@
{% comment %}
Configurable item to be put in a dated list. Work like list_item, the layout
is just a bit different.
{% endcomment %}
{% load static %}
{% load i18n %}
{% load wagtailimages_tags %}
<a {% if item.url %}href="{{ item.url }}" {% endif %}
class="list_item date_list_item {{ item.css_class|default_if_none:"" }}{% if not item_big_cover %} flex_row{% endif %}"
title="{{ item.date|date:"l d F Y" }}"
>
{% if not item.show_in_menus and item.date and item_date_format != '' %}
{% with date_format=item_date_format|default_if_none:'l d F, H:i' %}
<time datetime="{{ item.date }}">
{% if item.now %}
<img src="{% static "aircox/images/play.png" %}"
title="{% trans "on air" %}"
alt="{% trans "on air" %}" class="now">
{% endif %}
{{ item.date|date:date_format }}
</time>
{% endwith %}
{% endif %}
{% if not list_no_cover %}
{% if item_big_cover %}
{% image item.cover max-640x480 class="cover big" height="" width="" %}
{% elif item.cover %}
{% image item.cover fill-64x64 class="cover small" %}
{% else %}
<div class="cover small"></div>
{% endif %}
{% endif %}
<div class="flex_item">
<h3 class="title">{{ item.title }}</h3>
{% if not list_no_headline and item.headline %}
<div class="headline">{{ item.headline }}</div>
{% endif %}
{% if item.info %}
<span class="info">{{ item.info|safe }}</span>
{% endif %}
{% if item.extra %}
<div class="extra"></div>
{% endif %}
</div>
</a>

View File

@ -1,13 +0,0 @@
{% load wagtailimages_tags %}
{% with link=self.as_dict %}
<a href="{{ link.url }}" {% if self.info %}title="{{ self.info }}"{% endif %}>
{% if link.icon %}
{% image link.icon fill-32x32 class="icon link_icon" height='' width='' %}
{% elif link.icon_path %}
<img src="{{ link.icon_path }}" class="icon link_icon"/>
{% endif %}
{{ link.text }}
</a>
{% endwith %}

View File

@ -1,70 +0,0 @@
{% comment %}
Options:
- list_css_class: extra class for the main list container
- list_paginator: paginator object to display pagination at the bottom;
{% endcomment %}
{% load i18n %}
{% load aircox_cms %}
{% if focus %}
{% with item=focus item_big_cover=True %}
{% include "aircox_cms/snippets/list_item.html" %}
{% endwith %}
{% endif %}
<ul class="list {{ list_css_class|default:'' }}">
{% for page in object_list %}
{% with item=page.specific %}
{% include "aircox_cms/snippets/list_item.html" %}
{% endwith %}
{% endfor %}
{# we use list_paginator to avoid conflicts when there are multiple lists #}
{% if list_paginator and list_paginator.num_pages > 1 %}
<nav>
{% with list_paginator.num_pages as num_pages %}
{% if object_list.has_previous %}
<a href="?{{ list_url_args }}&page={{ object_list.previous_page_number }}">
{% trans "previous page" %}
</a>
{% endif %}
{% if object_list.number > 3 %}
<a href="?{{ list_url_args }}&page=1">1</a>
{% if object_list.number > 4 %}
&#8230;
{% endif %}
{% endif %}
{% for i in object_list.number|around:2 %}
{% if i == object_list.number %}
{{ object_list.number }}
{% elif i > 0 and i <= num_pages %}
<a href="?{{ list_url_args }}&page={{ i }}">{{ i }}</a>
{% endif %}
{% endfor %}
{% with object_list.number|add:"2" as max %}
{% if max < num_pages %}
{% if max|add:"1" < num_pages %}
&#8230;
{% endif %}
<a href="?{{ list_url_args }}&page={{ num_pages }}">{{ num_pages }}</a>
{% endif %}
{% endwith %}
{% if object_list.has_next %}
<a href="?page={{ object_list.next_page_number }}">
{% trans "next page" %}
</a>
{% endif %}
{% endwith %}
</nav>
{% elif url and url_text %}
<nav><a href="{{ url }}">{{ url_text }}</a></nav>
{% endif %}
</ul>

View File

@ -1,53 +0,0 @@
{% comment %}
Configurable item to be put in a list. Support standard Publication or
ListItem instance.
Options:
* item: item to render. Fields: title, headline, cover, url, date, info, css_class
* item_date_format: format passed to the date filter instead of default one. If
it is an empty string, do not print the date.
* item_big_cover: cover should is big instead of thumbnail (width: 600)
{% endcomment %}
{% load static %}
{% load i18n %}
{% load wagtailimages_tags %}
<a {% if item.url %}href="{{ item.url }}" {% endif %}
class="list_item {{ item.css_class|default_if_none:"" }}{% if not item_big_cover %} flex_row{% endif %}">
{% if item.cover %}
{% if item_big_cover %}
{% image item.cover max-640x480 class="cover big" height="" width="" %}
{% else %}
{% image item.cover fill-64x64 class="cover small" %}
{% endif %}
{% endif %}
<div class="flex_item">
<h3 class="title">{{ item.title }}</h3>
{% if item.info %}
<span class="info">{{ item.info|safe }}</span>
{% endif %}
{% if not item.show_in_menus and item.date and item_date_format != '' %}
{% with date_format=item_date_format|default:'l d F, H:i' %}
<time datetime="{{ item.date }}">
{% if item.diffusion %}
<img src="{% static "aircox/images/clock.png" %}" title="{% trans "Diffusion" %}" class="small_icon">
{{ item.diffusion.start|date:date_format }}
{% else %}
{{ item.date|date:date_format }}
{% endif %}
</time>
{% endwith %}
{% endif %}
</div>
{% if item.extra %}
<div class="extra"></div>
{% endif %}
</a>

View File

@ -1,42 +0,0 @@
{% load static %}
{% load i18n %}
{% if item.embed %}
{% else %}
{# TODO: complete archive podcast -> info #}
<script>
function add_sound_{{ item.id }}(event) {
var sound = new Sound(
title='{{ item.name|escape }}',
detail='{{ item.detail_url }}',
duration={{ item.duration|date:"H*3600+i*60+s" }},
streams='{{ item.url }}',
{% if page and page.cover %}cover='{{ page.icon }}'{% endif %}
);
sound = player.playlist.add(sound);
if(event.target.dataset.action != 'add')
player.select(sound, true);
}
</script>
<a class="list_item sound flex_row" onclick="add_sound_{{ item.id }}(event)">
<img src="{% static "aircox/images/listen.png" %}" class="icon"/>
<h3 class="flex_item">{{ item.name }}</h3>
<time class="info">
{% if item.duration.hour > 0 %}
{{ item.duration|date:'H:i:s' }}
{% else %}
{{ item.duration|date:'i:s' }}
{% endif %}
</time>
<img src="{% static "aircox/images/add.png" %}" class="icon"
data-action='add' alt="{% trans "add this sound to the playlist" %}"/>
</a>
{% endif %}

View File

@ -1,6 +0,0 @@
!_TAG_FILE_FORMAT 2 /extended format; --format=1 will not append ;" to lines/
!_TAG_FILE_SORTED 1 /0=unsorted, 1=sorted, 2=foldcase/
!_TAG_PROGRAM_AUTHOR Darren Hiebert /dhiebert@users.sourceforge.net/
!_TAG_PROGRAM_NAME Exuberant Ctags //
!_TAG_PROGRAM_URL http://ctags.sourceforge.net /official site/
!_TAG_PROGRAM_VERSION 5.8 //

View File

@ -1,94 +0,0 @@
{% load staticfiles %}
{% load i18n %}
<script type="text/x-template" id="template-sound">
<div class="component sound flex_row"
v-html="track.embed"
v-if="track.embed">
</div>
<div class="component sound flex_row"
:state="state"
v-else
>
<audio preload="metadata" ref="audio"
@pause="state = State.Stop"
@playing="state = State.Play"
@ended="ended"
@timeupdate="timeUpdate"
>
<source v-for="source in track.sources" :src="source">
</audio>
<div class="cover button">
<img :src="track.cover" v-if="track.cover">
<button @click="play_stop">
<img class="icon pause"
src="{% static "aircox/images/pause.png" %}"
title="{% trans "Click to pause" %}"
v-if="state === State.Play" >
<img class="icon loading"
src="{% static "aircox/images/loading.png" %}"
title="{% trans "Loading... Click to pause" %}"
v-else-if="state === State.Loading" >
<img class="icon play"
src="{% static "aircox/images/play.png" %}"
title="{% trans "Click to play" %}"
v-else >
</button>
</div>
<div class="content flex_item">
<h3 class="flex_item">
<a :href="track.detail_url">[[ track.name ]]</a>
</h3>
<div v-if="track.duration" class="info">
<span v-if="seek_position !== null">
[[ format_time(seek_position) ]] /
</span>
<span v-else-if="state == State.Play">[[ format_time(position) ]] /</span>
[[ format_time(track.duration) ]]
</div>
<progress ref="progress"
v-show="state == State.Play && track.duration"
v-on:click.prevent="progress_clicked"
v-on:mousemove = "progress_mouse_move"
v-on:mouseout = "progress_mouse_out"
:value="seek_position" :max="duration"
></progress>
</div>
<div class="actions" v-show="duration">
<a class="action remove"
title="{% trans "Remove from playlist" %}"
v-if="this.$parent.modifiable"
@click="remove"
>✖</a>
<a class="action add"
title="{% trans "Add to my playlist" %}"
@click="add_to_playlist"
v-else
>+</a>
</div>
</div>
</script>
<script type="text/x-template" id="template-playlist">
<div class="component playlist">
<a-sound v-for="track in tracks" ref="sounds"
:id="track.id" :track="track"
@ended="sound_ended"
@beforeDestroy="sound_ended"
/>
<footer v-show="tracks.length > 1" class="info">
<span v-show="read_all">{% trans "read all" %}</span>
<input type="checkbox" class="read_all"
:id="read_all_id"
value="true" v-model="read_all">
<label :for="read_all_id"
title="{% trans "Read all the playlist" %}">
<img src="{% static "aircox/images/list.png" %}" class="small icon">
</label>
</footer>
</div>
</script>

View File

@ -1,7 +0,0 @@
{% extends "wagtailadmin/admin_base.html" %}
{% load static %}
{% block extra_css %}
<link rel="stylesheet" href="{% static 'aircox_cms/css/cms.css' %}" type="text/css" />
{% endblock %}

View File

@ -1,6 +0,0 @@
{% extends "wagtailadmin/base.html" %}
{% load wagtailadmin_tags wagtailcore_tags staticfiles i18n %}
{% block branding_logo %}
<img class="wagtail-logo" src="{% static 'aircox/images/logo.png' %}" alt="Aircox" />
{% endblock %}

View File

@ -1,76 +0,0 @@
import random
from django import template
from django.utils.safestring import mark_safe
from aircox_cms.models.sections import Region
register = template.Library()
@register.filter
def gen_id(prefix, sep = "-"):
"""
Generate a random element id
"""
return sep.join([
prefix,
str(random.random())[2:],
str(random.random())[2:],
])
@register.filter
def concat(a,b):
"""
Concat two strings together
"""
return str(a) + str(b)
@register.filter
def around(page_num, n):
"""
Return a range of value around a given number.
"""
return range(page_num-n, page_num+n+1)
@register.simple_tag(takes_context=True)
def render_section(context, section, **kwargs):
"""
Render a section from the current page. By default retrieve required
information from the context
"""
return mark_safe(section.render(
context = context.flatten(),
request = context['request'],
page = context['page'],
**kwargs
))
@register.simple_tag(takes_context=True)
def render_sections(context, position = None):
"""
Render all sections at the given position (filter out base on page
models' too, cf. Region.model).
"""
request = context.get('request')
page = context.get('page')
return mark_safe(''.join(
section.render(request, page=page, context = {
'settings': context.get('settings')
})
for section in Region.get_sections_at(position, page)
))
@register.simple_tag(takes_context=True)
def render_template_mixin(context, mixin):
"""
Render correctly a template mixin, e.g SectionLink
"""
request = context.get('request')
page = context.get('page')
return mark_safe(mixin.render(request, page=page, context = {
'settings': context.get('settings')
}))

View File

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

View File

@ -1,55 +0,0 @@
import inspect
from django.urls import reverse
from wagtail.core.models import Page
def image_url(image, filter_spec):
"""
Return an url for the given image -- shortcut function for
wagtailimages' serve.
"""
from wagtail.images.views.serve import generate_signature
signature = generate_signature(image.id, filter_spec)
url = reverse('wagtailimages_serve', args=(signature, image.id, filter_spec))
url += image.file.name[len('original_images/'):]
return url
def get_station_settings(station):
"""
Get WebsiteSettings for the given station.
"""
import aircox_cms.models as models
return models.WebsiteSettings.objects \
.filter(station = station).first()
def get_station_site(station):
"""
Get the site of the given station.
"""
settings = get_station_settings(station)
return settings and settings.site
def related_pages_filter(reset_cache=False):
"""
Return a dict that can be used to filter foreignkey to pages'
subtype declared in aircox_cms.models.
This value is stored in cache, but it is possible to reset the
cache using the `reset_cache` parameter.
"""
import aircox_cms.models as cms
if not reset_cache and hasattr(related_pages_filter, 'cache'):
return related_pages_filter.cache
related_pages_filter.cache = {
'model__in': list(name.lower() for name, member in
inspect.getmembers(cms,
lambda x: inspect.isclass(x) and issubclass(x, Page)
)
if member != Page
),
}
return related_pages_filter.cache

View File

@ -1 +0,0 @@

View File

@ -1,111 +0,0 @@
import json
from django.db import models
from django.template.loader import render_to_string
from django.utils.safestring import mark_safe
from wagtail.core.utils import camelcase_to_underscore
class Component:
"""
A Component is a small part of a rendered web page. It can be used
to create elements configurable by users.
"""
template_name = ""
"""
[class] Template file path
"""
hide = False
"""
The component can be hidden because there is no reason to display it
(e.g. empty list)
"""
@classmethod
def snake_name(cl):
if not hasattr(cl, '_snake_name'):
cl._snake_name = camelcase_to_underscore(cl.__name__)
return cl._snake_name
def get_context(self, request, page):
"""
Context attributes:
* self: section being rendered
* page: current page being rendered
* request: request used to render the current page
Other context attributes usable in the default section template:
* content: **safe string** set as content of the section
* hide: DO NOT render the section, render only an empty string
"""
return {
'self': self,
'page': page,
'request': request,
}
def render(self, request, page, context, *args, **kwargs):
"""
Render the component. ``Page`` is the current page being
rendered.
"""
# use a different object
context_ = self.get_context(request, *args, page=page, **kwargs)
if self.hide:
return ''
if context:
context_.update({
k: v for k, v in context.items()
if k not in context_
})
context_['page'] = page
return render_to_string(self.template_name, context_)
class ExposedData:
"""
Data object that aims to be exposed to Javascript. This provides
various utilities.
"""
model = None
"""
[class attribute] Related model/class object that is to be exposed
"""
fields = {}
"""
[class attribute] Fields of the model to be exposed, as a dict of
``{ exposed_field: model_field }``
``model_field`` can either be a function(exposed, object) or a field
name.
"""
data = None
"""
Exposed data of the instance
"""
def __init__(self, object = None, **kwargs):
self.data = {}
if object:
self.from_object(object)
self.data.update(kwargs)
def from_object(self, object):
fields = type(self).fields
for k,v in fields.items():
if self.data.get(k) != None:
continue
v = v(self, object) if callable(v) else \
getattr(object, v) if hasattr(object, v) else \
None
self.data[k] = v
def to_json(self):
"""
Return a json string of encoded data.
"""
return mark_safe(json.dumps(self.data))

View File

@ -1,414 +0,0 @@
import json
from django.urls import reverse
from django.forms import SelectMultiple, TextInput
from django.contrib.staticfiles.templatetags.staticfiles import static
from django.utils.html import format_html
from django.utils import timezone as tz
from django.utils.safestring import mark_safe
from django.utils.translation import ugettext_lazy as _
from wagtail.core import hooks
from wagtail.admin.menu import MenuItem, Menu, SubmenuMenuItem
from wagtail.core.models import PageRevision
from wagtail.contrib.modeladmin.options import \
ModelAdmin, ModelAdminGroup, modeladmin_register
from wagtail.admin.edit_handlers import FieldPanel, FieldRowPanel, \
MultiFieldPanel, InlinePanel, PageChooserPanel, StreamFieldPanel
import aircox.models
import aircox_cms.models as models
class RelatedListWidget(SelectMultiple):
def __init__(self, *args, **kwargs):
self.readonly = True
super().__init__(*args, **kwargs)
def get_context(self, name, value, attrs):
self.choices.queryset = self.choices.queryset.filter(pk__in = value)
return super().get_context(name, value, attrs)
#
# ModelAdmin items
#
class ProgramAdmin(ModelAdmin):
model = aircox.models.Program
menu_label = _('Programs')
menu_icon = 'pick'
menu_order = 200
list_display = ('name', 'active', 'station')
search_fields = ('name',)
aircox.models.Program.panels = [
MultiFieldPanel([
FieldPanel('name'),
FieldPanel('active'),
FieldPanel('sync'),
], heading=_('Program')),
]
class DiffusionAdmin(ModelAdmin):
model = aircox.models.Diffusion
menu_label = _('Diffusions')
menu_icon = 'date'
menu_order = 200
list_display = ('program', 'start', 'end', 'type', 'initial')
list_filter = ('program', 'start', 'type')
readonly_fields = ('conflicts',)
search_fields = ('program__name', 'start')
aircox.models.Diffusion.panels = [
MultiFieldPanel([
FieldPanel('program'),
FieldPanel('type'),
FieldRowPanel([
FieldPanel('start'),
FieldPanel('end'),
]),
FieldPanel('initial'),
FieldPanel(
'conflicts',
widget = RelatedListWidget(
attrs = {
'disabled': True,
}
)
),
], heading=_('Diffusion')),
]
class ScheduleAdmin(ModelAdmin):
model = aircox.models.Schedule
menu_label = _('Schedules')
menu_icon = 'time'
menu_order = 200
list_display = ('program', 'frequency', 'duration', 'initial')
list_filter = ('frequency', 'date', 'duration', 'program')
aircox.models.Schedule.panels = [
MultiFieldPanel([
FieldPanel('program'),
FieldPanel('frequency'),
FieldRowPanel([
FieldPanel('date'),
FieldPanel('duration'),
]),
FieldPanel('initial'),
], heading=_('Schedule')),
]
class StreamAdmin(ModelAdmin):
model = aircox.models.Stream
menu_label = _('Streams')
menu_icon = 'time'
menu_order = 200
list_display = ('program', 'delay', 'begin', 'end')
list_filter = ('program', 'delay', 'begin', 'end')
aircox.models.Stream.panels = [
MultiFieldPanel([
FieldPanel('program'),
FieldPanel('delay'),
FieldRowPanel([
FieldPanel('begin'),
FieldPanel('end'),
]),
], heading=_('Stream')),
]
class LogAdmin(ModelAdmin):
model = aircox.models.Log
menu_label = _('Logs')
menu_icon = 'time'
menu_order = 300
list_display = ['id', 'date', 'station', 'source', 'type', 'comment', 'diffusion', 'sound', 'track']
list_filter = ['date', 'source', 'diffusion', 'sound', 'track']
aircox.models.Log.panels = [
MultiFieldPanel([
FieldPanel('date'),
FieldRowPanel([
FieldPanel('station'),
FieldPanel('source'),
]),
FieldPanel('type'),
FieldPanel('comment'),
], heading = _('Log')),
MultiFieldPanel([
FieldPanel('diffusion'),
FieldPanel('sound'),
FieldPanel('track'),
], heading = _('Related objects')),
]
# Missing: Port, Station, Track
class AdvancedAdminGroup(ModelAdminGroup):
menu_label = _("Advanced")
menu_icon = 'plus-inverse'
items = (ProgramAdmin, DiffusionAdmin, ScheduleAdmin, StreamAdmin, LogAdmin)
modeladmin_register(AdvancedAdminGroup)
class CommentAdmin(ModelAdmin):
model = models.Comment
menu_label = _('Comments')
menu_icon = 'pick'
menu_order = 300
list_display = ('published', 'publication', 'author', 'date', 'content')
list_filter = ('date', 'published')
search_fields = ('author', 'content', 'publication__title')
modeladmin_register(CommentAdmin)
class SoundAdmin(ModelAdmin):
model = aircox.models.Sound
menu_label = _('Sounds')
menu_icon = 'media'
menu_order = 350
list_display = ('name', 'program', 'type', 'duration', 'path', 'good_quality', 'public')
list_filter = ('program', 'type', 'good_quality', 'public')
search_fields = ('name', 'path')
modeladmin_register(SoundAdmin)
#
# Menus with sub-menus
#
class GenericMenu(Menu):
page_model = models.Publication
"""
Model of the page for the items
"""
explore = False
"""
If True, show page explorer instead of page editor.
"""
request = None
"""
Current request
"""
station = None
"""
Current station
"""
def __init__(self):
super().__init__('')
def get_queryset(self):
"""
Return a queryset of items used to display menu
"""
pass
def make_item(self, item):
"""
Return the instance of MenuItem for the given item in the queryset
"""
pass
def get_parent(self, item):
"""
Return id of the parent page for the given item of the queryset
"""
pass
@staticmethod
def page_of(item):
return hasattr(item, 'page') and item.page
def page_url(self, item):
page = self.page_of(item)
if page:
name = 'wagtailadmin_explore' \
if self.explore else 'wagtailadmin_pages:edit'
return reverse(name, args=[page.id])
parent_page = self.get_parent(item)
if not parent_page:
return ''
return reverse(
'wagtailadmin_pages:add', args= [
self.page_model._meta.app_label,
self.page_model._meta.model_name,
parent_page.id
]
)
@property
def registered_menu_items(self):
now = tz.now()
last_max = now - tz.timedelta(minutes = 10)
qs = self.get_queryset()
return [
self.make_item(item) for item in qs
]
def render_html(self, request):
self.request = request
self.station = self.request and self.request.aircox.station
return super().render_html(request)
class GroupMenuItem(MenuItem):
"""
Display a list of items based on given list of items
"""
def __init__(self, label, *args, **kwargs):
super().__init__(label, None, *args, **kwargs)
def get_queryset(self):
pass
def make_item(self, item):
pass
def render_html(self, request):
self.request = request
self.station = self.request and self.request.aircox.station
title = '</ul><h2>{}</h2><ul>'.format(self.label)
qs = [
self.make_item(item).render_html(request)
for item in self.get_queryset()
]
return title + '\n'.join(qs)
#
# Today's diffusions menu
#
class TodayMenu(GenericMenu):
"""
Menu to display today's diffusions
"""
page_model = models.DiffusionPage
def get_queryset(self):
qs = aircox.models.Diffusion.objects
if self.station:
qs = qs.filter(program__station = self.station)
return qs.filter(
type = aircox.models.Diffusion.Type.normal,
start__contains = tz.now().date(),
initial__isnull = True,
).order_by('start')
def make_item(self, item):
label = mark_safe(
'<i class="info">{}</i> {}'.format(
tz.localtime(item.start).strftime('%H:%M'),
item.program.name
)
)
attrs = {}
qs = hasattr(item, 'page') and \
PageRevision.objects.filter(page = item.page)
if qs and qs.count():
headline = qs.latest('created_at').content_json
headline = json.loads(headline).get('headline')
attrs['title'] = headline
else:
headline = ''
return MenuItem(label, self.page_url(item), attrs = attrs)
def get_parent(self, item):
return item.program.page
@hooks.register('register_admin_menu_item')
def register_programs_menu_item():
return SubmenuMenuItem(
_('Today\'s Diffusions'), TodayMenu(),
classnames='icon icon-folder-open-inverse', order=101
)
#
# Programs menu
#
class ProgramsMenu(GenericMenu):
"""
Display all active programs
"""
page_model = models.DiffusionPage
explore = True
def get_queryset(self):
qs = aircox.models.Program.objects
if self.station:
qs = qs.filter(station = self.station)
return qs.filter(active = True, page__isnull = False) \
.filter(stream__isnull = True) \
.order_by('name')
def make_item(self, item):
return MenuItem(item.name, self.page_url(item))
def get_parent(self, item):
# TODO: #Station / get current site
from aircox_cms.models import WebsiteSettings
settings = WebsiteSettings.objects.first()
if not settings:
return
return settings.default_program_parent_page
@hooks.register('register_admin_menu_item')
def register_programs_menu_item():
return SubmenuMenuItem(
_('Programs'), ProgramsMenu(),
classnames='icon icon-folder-open-inverse', order=102
)
#
# Select station
#
# Submenu hides themselves if there are no children
#
#
class SelectStationMenuItem(GroupMenuItem):
"""
Menu to display today's diffusions
"""
def get_queryset(self):
return aircox.models.Station.objects.all()
def make_item(self, station):
return MenuItem(
station.name,
reverse('wagtailadmin_home') + '?aircox.station=' + str(station.pk),
classnames = 'icon ' + ('icon-success menu-active'
if station == self.station else
'icon-cross'
if not station.active else
''
)
)
@hooks.register('register_settings_menu_item')
def register_select_station_menu_item():
return SelectStationMenuItem(
_('Current Station'), order=10000
)

View File

@ -120,6 +120,7 @@ class Page(StatusModel):
cover = FilerImageField(
on_delete=models.SET_NULL, null=True, blank=True,
verbose_name=_('Cover'),
related_name='+',
)
# options

View File

@ -18,7 +18,7 @@ from django.urls import include, path, re_path
from django.contrib import admin
import aircox.urls
import aircox_web.urls
# import aircox_web.urls
try:
urlpatterns = [
@ -35,7 +35,7 @@ try:
)
urlpatterns.append(path('filer/', include('filer.urls')))
urlpatterns += aircox_web.urls.urlpatterns
# urlpatterns += aircox_web.urls.urlpatterns
except Exception as e:
import traceback