add episode: models, admin, diffusion gen, sounds_monitor, playlist import
This commit is contained in:
		@ -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']
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										
											BIN
										
									
								
								aircox/admin/__pycache__/__init__.cpython-37.pyc
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								aircox/admin/__pycache__/__init__.cpython-37.pyc
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							
							
								
								
									
										
											BIN
										
									
								
								aircox/admin/__pycache__/base.cpython-37.pyc
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								aircox/admin/__pycache__/base.cpython-37.pyc
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							
							
								
								
									
										
											BIN
										
									
								
								aircox/admin/__pycache__/diffusion.cpython-37.pyc
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								aircox/admin/__pycache__/diffusion.cpython-37.pyc
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							
							
								
								
									
										
											BIN
										
									
								
								aircox/admin/__pycache__/episode.cpython-37.pyc
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								aircox/admin/__pycache__/episode.cpython-37.pyc
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							
							
								
								
									
										
											BIN
										
									
								
								aircox/admin/__pycache__/mixins.cpython-37.pyc
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								aircox/admin/__pycache__/mixins.cpython-37.pyc
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							
							
								
								
									
										
											BIN
										
									
								
								aircox/admin/__pycache__/page.cpython-37.pyc
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								aircox/admin/__pycache__/page.cpython-37.pyc
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							
							
								
								
									
										
											BIN
										
									
								
								aircox/admin/__pycache__/playlist.cpython-37.pyc
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								aircox/admin/__pycache__/playlist.cpython-37.pyc
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							
							
								
								
									
										
											BIN
										
									
								
								aircox/admin/__pycache__/program.cpython-37.pyc
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								aircox/admin/__pycache__/program.cpython-37.pyc
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							
							
								
								
									
										
											BIN
										
									
								
								aircox/admin/__pycache__/sound.cpython-37.pyc
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								aircox/admin/__pycache__/sound.cpython-37.pyc
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							@ -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)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -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
									
								
							
							
						
						
									
										82
									
								
								aircox/admin/episode.py
									
									
									
									
									
										Normal 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
									
								
							
							
						
						
									
										28
									
								
								aircox/admin/page.py
									
									
									
									
									
										Normal 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 ''
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -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
									
								
							
							
						
						
									
										75
									
								
								aircox/admin/program.py
									
									
									
									
									
										Normal 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')
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -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']})
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
@ -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()
 | 
			
		||||
 | 
			
		||||
@ -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
 | 
			
		||||
            ))
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -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)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -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()
 | 
			
		||||
 | 
			
		||||
@ -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):
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										8
									
								
								aircox/models/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										8
									
								
								aircox/models/__init__.py
									
									
									
									
									
										Normal 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
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										
											BIN
										
									
								
								aircox/models/__pycache__/__init__.cpython-37.pyc
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								aircox/models/__pycache__/__init__.cpython-37.pyc
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							
							
								
								
									
										
											BIN
										
									
								
								aircox/models/__pycache__/diffusion.cpython-37.pyc
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								aircox/models/__pycache__/diffusion.cpython-37.pyc
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							
							
								
								
									
										
											BIN
										
									
								
								aircox/models/__pycache__/episode.cpython-37.pyc
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								aircox/models/__pycache__/episode.cpython-37.pyc
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							
							
								
								
									
										
											BIN
										
									
								
								aircox/models/__pycache__/log.cpython-37.pyc
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								aircox/models/__pycache__/log.cpython-37.pyc
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							
							
								
								
									
										
											BIN
										
									
								
								aircox/models/__pycache__/mixins.cpython-37.pyc
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								aircox/models/__pycache__/mixins.cpython-37.pyc
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							
							
								
								
									
										
											BIN
										
									
								
								aircox/models/__pycache__/page.cpython-37.pyc
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								aircox/models/__pycache__/page.cpython-37.pyc
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							
							
								
								
									
										
											BIN
										
									
								
								aircox/models/__pycache__/program.cpython-37.pyc
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								aircox/models/__pycache__/program.cpython-37.pyc
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							
							
								
								
									
										
											BIN
										
									
								
								aircox/models/__pycache__/sound.cpython-37.pyc
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								aircox/models/__pycache__/sound.cpython-37.pyc
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							
							
								
								
									
										
											BIN
										
									
								
								aircox/models/__pycache__/station.cpython-37.pyc
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								aircox/models/__pycache__/station.cpython-37.pyc
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							
							
								
								
									
										296
									
								
								aircox/models/episode.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										296
									
								
								aircox/models/episode.py
									
									
									
									
									
										Normal 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
									
								
							
							
						
						
									
										264
									
								
								aircox/models/log.py
									
									
									
									
									
										Normal 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
									
								
							
							
						
						
									
										73
									
								
								aircox/models/page.py
									
									
									
									
									
										Normal 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
									
								
							
							
						
						
									
										508
									
								
								aircox/models/program.py
									
									
									
									
									
										Normal 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
									
								
							
							
						
						
									
										288
									
								
								aircox/models/sound.py
									
									
									
									
									
										Normal 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
									
								
							
							
						
						
									
										206
									
								
								aircox/models/station.py
									
									
									
									
									
										Normal 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 ''
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -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
 | 
			
		||||
########################################################################
 | 
			
		||||
 | 
			
		||||
@ -1,3 +0,0 @@
 | 
			
		||||
 | 
			
		||||
default_app_config = 'aircox_cms.apps.AircoxCMSConfig'
 | 
			
		||||
 | 
			
		||||
@ -1,6 +0,0 @@
 | 
			
		||||
from django.contrib import admin
 | 
			
		||||
 | 
			
		||||
# Register your models here.
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -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
 | 
			
		||||
 | 
			
		||||
@ -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'))
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
										
											Binary file not shown.
										
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							@ -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')
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -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
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -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,
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -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
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -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
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -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)
 | 
			
		||||
 | 
			
		||||
@ -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;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -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: ':'
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										41
									
								
								aircox_cms/static/aircox_cms/js/bootstrap.js
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										41
									
								
								aircox_cms/static/aircox_cms/js/bootstrap.js
									
									
									
									
										vendored
									
									
								
							@ -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');
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -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);
 | 
			
		||||
 | 
			
		||||
@ -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
											
										
									
								
							
							
								
								
									
										6
									
								
								aircox_cms/static/lib/vue.min.js
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										6
									
								
								aircox_cms/static/lib/vue.min.js
									
									
									
									
										vendored
									
									
								
							
										
											
												File diff suppressed because one or more lines are too long
											
										
									
								
							@ -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_)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -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>
 | 
			
		||||
@ -1,3 +0,0 @@
 | 
			
		||||
{% extends "aircox_cms/publication.html" %}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -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 %}
 | 
			
		||||
 | 
			
		||||
@ -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 %}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -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 %}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -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 %}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -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 %}
 | 
			
		||||
 | 
			
		||||
@ -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 %}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -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>
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -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 %}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -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 %}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -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 %}
 | 
			
		||||
 | 
			
		||||
@ -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 %}
 | 
			
		||||
 | 
			
		||||
@ -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 %} > {% 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 %}
 | 
			
		||||
 | 
			
		||||
@ -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 %}
 | 
			
		||||
 | 
			
		||||
@ -1,6 +0,0 @@
 | 
			
		||||
{% extends "aircox_cms/sections/item.html" %}
 | 
			
		||||
 | 
			
		||||
{% block content %}
 | 
			
		||||
{% include "aircox_cms/snippets/date_list.html" %}
 | 
			
		||||
{% endblock %}
 | 
			
		||||
 | 
			
		||||
@ -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>
 | 
			
		||||
 | 
			
		||||
@ -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" %}">●</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>
 | 
			
		||||
 | 
			
		||||
@ -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>
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -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 %}
 | 
			
		||||
 | 
			
		||||
@ -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 %}
 | 
			
		||||
        …
 | 
			
		||||
        {% 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 %}
 | 
			
		||||
        …
 | 
			
		||||
        {% 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>
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -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>
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -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 %}
 | 
			
		||||
 | 
			
		||||
@ -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	//
 | 
			
		||||
@ -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>
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -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 %}
 | 
			
		||||
 | 
			
		||||
@ -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 %}
 | 
			
		||||
@ -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')
 | 
			
		||||
    }))
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -1,3 +0,0 @@
 | 
			
		||||
from django.test import TestCase
 | 
			
		||||
 | 
			
		||||
# Create your tests here.
 | 
			
		||||
@ -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
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -1 +0,0 @@
 | 
			
		||||
 | 
			
		||||
@ -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))
 | 
			
		||||
 | 
			
		||||
@ -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
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -120,6 +120,7 @@ class Page(StatusModel):
 | 
			
		||||
    cover = FilerImageField(
 | 
			
		||||
        on_delete=models.SET_NULL, null=True, blank=True,
 | 
			
		||||
        verbose_name=_('Cover'),
 | 
			
		||||
        related_name='+',
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    # options
 | 
			
		||||
 | 
			
		||||
@ -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
 | 
			
		||||
 | 
			
		||||
		Reference in New Issue
	
	Block a user