diff --git a/aircox/admin/__init__.py b/aircox/admin/__init__.py index 4a0752a..1954e33 100644 --- a/aircox/admin/__init__.py +++ b/aircox/admin/__init__.py @@ -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'] + + diff --git a/aircox/admin/__pycache__/__init__.cpython-37.pyc b/aircox/admin/__pycache__/__init__.cpython-37.pyc new file mode 100644 index 0000000..70c1708 Binary files /dev/null and b/aircox/admin/__pycache__/__init__.cpython-37.pyc differ diff --git a/aircox/admin/__pycache__/base.cpython-37.pyc b/aircox/admin/__pycache__/base.cpython-37.pyc new file mode 100644 index 0000000..de68b6d Binary files /dev/null and b/aircox/admin/__pycache__/base.cpython-37.pyc differ diff --git a/aircox/admin/__pycache__/diffusion.cpython-37.pyc b/aircox/admin/__pycache__/diffusion.cpython-37.pyc new file mode 100644 index 0000000..912c02b Binary files /dev/null and b/aircox/admin/__pycache__/diffusion.cpython-37.pyc differ diff --git a/aircox/admin/__pycache__/episode.cpython-37.pyc b/aircox/admin/__pycache__/episode.cpython-37.pyc new file mode 100644 index 0000000..5748b4a Binary files /dev/null and b/aircox/admin/__pycache__/episode.cpython-37.pyc differ diff --git a/aircox/admin/__pycache__/mixins.cpython-37.pyc b/aircox/admin/__pycache__/mixins.cpython-37.pyc new file mode 100644 index 0000000..fc084d9 Binary files /dev/null and b/aircox/admin/__pycache__/mixins.cpython-37.pyc differ diff --git a/aircox/admin/__pycache__/page.cpython-37.pyc b/aircox/admin/__pycache__/page.cpython-37.pyc new file mode 100644 index 0000000..a85256e Binary files /dev/null and b/aircox/admin/__pycache__/page.cpython-37.pyc differ diff --git a/aircox/admin/__pycache__/playlist.cpython-37.pyc b/aircox/admin/__pycache__/playlist.cpython-37.pyc new file mode 100644 index 0000000..03e431b Binary files /dev/null and b/aircox/admin/__pycache__/playlist.cpython-37.pyc differ diff --git a/aircox/admin/__pycache__/program.cpython-37.pyc b/aircox/admin/__pycache__/program.cpython-37.pyc new file mode 100644 index 0000000..4d67640 Binary files /dev/null and b/aircox/admin/__pycache__/program.cpython-37.pyc differ diff --git a/aircox/admin/__pycache__/sound.cpython-37.pyc b/aircox/admin/__pycache__/sound.cpython-37.pyc new file mode 100644 index 0000000..bbc5043 Binary files /dev/null and b/aircox/admin/__pycache__/sound.cpython-37.pyc differ diff --git a/aircox/admin/base.py b/aircox/admin/base.py deleted file mode 100644 index f5680eb..0000000 --- a/aircox/admin/base.py +++ /dev/null @@ -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) - - - - diff --git a/aircox/admin/diffusion.py b/aircox/admin/diffusion.py index 977905f..168bbfb 100644 --- a/aircox/admin/diffusion.py +++ b/aircox/admin/diffusion.py @@ -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 diff --git a/aircox/admin/episode.py b/aircox/admin/episode.py new file mode 100644 index 0000000..794221e --- /dev/null +++ b/aircox/admin/episode.py @@ -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] + + diff --git a/aircox/admin/page.py b/aircox/admin/page.py new file mode 100644 index 0000000..ae2fb3d --- /dev/null +++ b/aircox/admin/page.py @@ -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(''.format(obj.cover.icons['64'])) \ + if obj.cover else '' + + + + diff --git a/aircox/admin/playlist.py b/aircox/admin/playlist.py index 0d760b6..1879c1e 100644 --- a/aircox/admin/playlist.py +++ b/aircox/admin/playlist.py @@ -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 diff --git a/aircox/admin/program.py b/aircox/admin/program.py new file mode 100644 index 0000000..92345bc --- /dev/null +++ b/aircox/admin/program.py @@ -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') + + + diff --git a/aircox/admin/sound.py b/aircox/admin/sound.py index 3203e16..3f2655c 100644 --- a/aircox/admin/sound.py +++ b/aircox/admin/sound.py @@ -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']}) ] diff --git a/aircox/management/commands/diffusions.py b/aircox/management/commands/diffusions.py index 53baa62..f75fbd3 100755 --- a/aircox/management/commands/diffusions.py +++ b/aircox/management/commands/diffusions.py @@ -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() diff --git a/aircox/management/commands/import_playlist.py b/aircox/management/commands/import_playlist.py index 91bf382..011e221 100755 --- a/aircox/management/commands/import_playlist.py +++ b/aircox/management/commands/import_playlist.py @@ -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 )) + diff --git a/aircox/management/commands/sounds_monitor.py b/aircox/management/commands/sounds_monitor.py index c034470..422d08d 100755 --- a/aircox/management/commands/sounds_monitor.py +++ b/aircox/management/commands/sounds_monitor.py @@ -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[0-9]{4})(?P[0-9]{2})(?P[0-9]{2})' + '(_(?P[0-9]{2})h(?P[0-9]{2}))?' + '(_(?P[0-9]+))?' + '_?(?P.*)$' +) + + 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[0-9]{4})' - '(?P[0-9]{2})' - '(?P[0-9]{2})' - '(_(?P[0-9]{2})h(?P[0-9]{2}))?' - '(_(?P[0-9]+))?' - '_?(?P.*)$', - 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) diff --git a/aircox/management/commands/streamer.py b/aircox/management/commands/streamer.py index 4017132..dc1f986 100755 --- a/aircox/management/commands/streamer.py +++ b/aircox/management/commands/streamer.py @@ -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() diff --git a/aircox/models.py b/aircox/models.py index da689fb..94ccf59 100755 --- a/aircox/models.py +++ b/aircox/models.py @@ -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): diff --git a/aircox/models/__init__.py b/aircox/models/__init__.py new file mode 100644 index 0000000..1fabee8 --- /dev/null +++ b/aircox/models/__init__.py @@ -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 + + diff --git a/aircox/models/__pycache__/__init__.cpython-37.pyc b/aircox/models/__pycache__/__init__.cpython-37.pyc new file mode 100644 index 0000000..3e43f00 Binary files /dev/null and b/aircox/models/__pycache__/__init__.cpython-37.pyc differ diff --git a/aircox/models/__pycache__/diffusion.cpython-37.pyc b/aircox/models/__pycache__/diffusion.cpython-37.pyc new file mode 100644 index 0000000..d64fe3c Binary files /dev/null and b/aircox/models/__pycache__/diffusion.cpython-37.pyc differ diff --git a/aircox/models/__pycache__/episode.cpython-37.pyc b/aircox/models/__pycache__/episode.cpython-37.pyc new file mode 100644 index 0000000..d7e1cf0 Binary files /dev/null and b/aircox/models/__pycache__/episode.cpython-37.pyc differ diff --git a/aircox/models/__pycache__/log.cpython-37.pyc b/aircox/models/__pycache__/log.cpython-37.pyc new file mode 100644 index 0000000..2edd22e Binary files /dev/null and b/aircox/models/__pycache__/log.cpython-37.pyc differ diff --git a/aircox/models/__pycache__/mixins.cpython-37.pyc b/aircox/models/__pycache__/mixins.cpython-37.pyc new file mode 100644 index 0000000..a182ce6 Binary files /dev/null and b/aircox/models/__pycache__/mixins.cpython-37.pyc differ diff --git a/aircox/models/__pycache__/page.cpython-37.pyc b/aircox/models/__pycache__/page.cpython-37.pyc new file mode 100644 index 0000000..1c1631f Binary files /dev/null and b/aircox/models/__pycache__/page.cpython-37.pyc differ diff --git a/aircox/models/__pycache__/program.cpython-37.pyc b/aircox/models/__pycache__/program.cpython-37.pyc new file mode 100644 index 0000000..442c998 Binary files /dev/null and b/aircox/models/__pycache__/program.cpython-37.pyc differ diff --git a/aircox/models/__pycache__/sound.cpython-37.pyc b/aircox/models/__pycache__/sound.cpython-37.pyc new file mode 100644 index 0000000..29f67d3 Binary files /dev/null and b/aircox/models/__pycache__/sound.cpython-37.pyc differ diff --git a/aircox/models/__pycache__/station.cpython-37.pyc b/aircox/models/__pycache__/station.cpython-37.pyc new file mode 100644 index 0000000..52dd664 Binary files /dev/null and b/aircox/models/__pycache__/station.cpython-37.pyc differ diff --git a/aircox/models/episode.py b/aircox/models/episode.py new file mode 100644 index 0000000..be2174d --- /dev/null +++ b/aircox/models/episode.py @@ -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), + } + + diff --git a/aircox/models/log.py b/aircox/models/log.py new file mode 100644 index 0000000..5e4d262 --- /dev/null +++ b/aircox/models/log.py @@ -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')) diff --git a/aircox/models/page.py b/aircox/models/page.py new file mode 100644 index 0000000..d5009a1 --- /dev/null +++ b/aircox/models/page.py @@ -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 + + diff --git a/aircox/models/program.py b/aircox/models/program.py new file mode 100644 index 0000000..5484eb2 --- /dev/null +++ b/aircox/models/program.py @@ -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') + ) + + diff --git a/aircox/models/sound.py b/aircox/models/sound.py new file mode 100644 index 0000000..0238d26 --- /dev/null +++ b/aircox/models/sound.py @@ -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) + + diff --git a/aircox/models/station.py b/aircox/models/station.py new file mode 100644 index 0000000..808ea62 --- /dev/null +++ b/aircox/models/station.py @@ -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 '' + ) + + + diff --git a/aircox/settings.py b/aircox/settings.py index 1382f60..682d776 100755 --- a/aircox/settings.py +++ b/aircox/settings.py @@ -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 ######################################################################## diff --git a/aircox_cms/__init__.py b/aircox_cms/__init__.py deleted file mode 100755 index 3ab7718..0000000 --- a/aircox_cms/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ - -default_app_config = 'aircox_cms.apps.AircoxCMSConfig' - diff --git a/aircox_cms/admin.py b/aircox_cms/admin.py deleted file mode 100755 index 3c21844..0000000 --- a/aircox_cms/admin.py +++ /dev/null @@ -1,6 +0,0 @@ -from django.contrib import admin - -# Register your models here. - - - diff --git a/aircox_cms/apps.py b/aircox_cms/apps.py deleted file mode 100755 index 03bc796..0000000 --- a/aircox_cms/apps.py +++ /dev/null @@ -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 - diff --git a/aircox_cms/forms.py b/aircox_cms/forms.py deleted file mode 100755 index e5cbdbf..0000000 --- a/aircox_cms/forms.py +++ /dev/null @@ -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')) - - diff --git a/aircox_cms/locale/fr/LC_MESSAGES/django.mo b/aircox_cms/locale/fr/LC_MESSAGES/django.mo deleted file mode 100644 index 35f8867..0000000 Binary files a/aircox_cms/locale/fr/LC_MESSAGES/django.mo and /dev/null differ diff --git a/aircox_cms/locale/fr/LC_MESSAGES/django.po b/aircox_cms/locale/fr/LC_MESSAGES/django.po deleted file mode 100755 index dddc174..0000000 --- a/aircox_cms/locale/fr/LC_MESSAGES/django.po +++ /dev/null @@ -1,1069 +0,0 @@ -# French translation of Aircox -# Copyright (C) Aarys -# Copyright (C) Bkfox -# This file is distributed under the same license as the Aircox package. -# Aarys, 2016. -# -msgid "" -msgstr "" -"Project-Id-Version: Aircox 0.1\n" -"Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2018-02-12 00:34+0100\n" -"PO-Revision-Date: 2016-10-10 16:00+02\n" -"Last-Translator: Aarys\n" -"Language-Team: Aircox's translators team\n" -"Language: \n" -"MIME-Version: 1.0\n" -"Content-Type: text/plain; charset=UTF-8\n" -"Content-Transfer-Encoding: 8bit\n" -"Plural-Forms: nplurals=2; plural=(n > 1);\n" - -#: aircox_cms/forms.py:17 -msgid "your name" -msgstr "nom" - -#: aircox_cms/forms.py:20 -msgid "your email (optional)" -msgstr "email (optionnel)" - -#: aircox_cms/forms.py:23 -msgid "your website (optional)" -msgstr "site internet (optionnel)" - -#: aircox_cms/forms.py:26 -msgid "your comment" -msgstr "commentaire" - -#: aircox_cms/forms.py:39 -msgid "You are a bot, that is not cool" -msgstr "Vous êtes un robot. Pas cool !" - -#: aircox_cms/forms.py:42 -msgid "No publication found for this comment" -msgstr "Aucune publication n'a été trouvée pour ce commentaire" - -#: aircox_cms/models/__init__.py:43 -msgid "aircox station" -msgstr "station aircox" - -#: aircox_cms/models/__init__.py:48 -msgid "" -"refers to an Aircox's station; it is used to make the link between the " -"website and Aircox" -msgstr "" -"fait référence à une station Aircox; utilisé pour faire le lien entre le " -"site internet et Aircox" - -#: aircox_cms/models/__init__.py:55 -msgid "favicon" -msgstr "favicon" - -#: aircox_cms/models/__init__.py:57 -msgid "small logo for the website displayed in the browser" -msgstr "petit logo pour le site affiché dans le navigateur" - -#: aircox_cms/models/__init__.py:60 aircox_cms/models/__init__.py:378 -msgid "tags" -msgstr "tags" - -#: aircox_cms/models/__init__.py:63 -msgid "tags describing the website; used for referencing" -msgstr "tags pour décrire le site internet; utilisés pour le référencement" - -#: aircox_cms/models/__init__.py:66 -msgid "public description" -msgstr "description publique" - -#: aircox_cms/models/__init__.py:69 -msgid "public description of the website; used for referencing" -msgstr "description publique du site internet; utilisée pour le référencement" - -#: aircox_cms/models/__init__.py:73 -msgid "page for lists" -msgstr "page pour les listes" - -#: aircox_cms/models/__init__.py:74 -msgid "page used to display the results of a search and other lists" -msgstr "" -"page utilisée pour afficher les résultats d'une recherche et d'autres listes" - -#: aircox_cms/models/__init__.py:82 aircox_cms/models/__init__.py:86 -msgid "publish comments automatically without verifying" -msgstr "publier les commentaires automatiquement sans vérification" - -#: aircox_cms/models/__init__.py:89 -msgid "success message" -msgstr "message de réussite" - -#: aircox_cms/models/__init__.py:90 -msgid "Your comment has been successfully posted!" -msgstr "Votre commentaire a bien été posté !" - -#: aircox_cms/models/__init__.py:92 -msgid "message displayed when a comment has been successfully posted" -msgstr "message à afficher quand un commentaire a bien été posté" - -#: aircox_cms/models/__init__.py:96 -msgid "waiting message" -msgstr "message d'attente" - -#: aircox_cms/models/__init__.py:97 -msgid "Your comment is awaiting for approval." -msgstr "Votre message est en attente d'approbation" - -#: aircox_cms/models/__init__.py:99 -msgid "" -"message displayed when a comment has been sent, but waits for website " -"administrators' approval." -msgstr "" -"message affiché quand un commentaire a été envoyé, mais est en attente de " -"l'approbation des administrateurs du site" - -#: aircox_cms/models/__init__.py:104 -msgid "error message" -msgstr "message d'erreur" - -#: aircox_cms/models/__init__.py:105 -msgid "We could not save your message. Please correct the error(s) below." -msgstr "" -"Votre message n'a pas pu être sauvegardé. Veuillez corriger l'erreur suivante" - -#: aircox_cms/models/__init__.py:107 -msgid "" -"message displayed when the form of the comment has been submitted but there " -"is an error, such as an incomplete field" -msgstr "" -"message affiché quand le formulaire a été envoyé mais qu'il y a une erreur " -"telle qu'un champ incomplet" - -#: aircox_cms/models/__init__.py:113 -msgid "synchronize with Aircox" -msgstr "synchroniser avec Aircox" - -#: aircox_cms/models/__init__.py:116 -msgid "" -"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." -msgstr "" -"créer une publication pour chaque objet ajouté à une station Aircox; par " -"exemple quand il y a un nouveau programme, ou quand une diffusion a été " -"ajoutée au calendrier. Note: cela ne concerne pas les Stations elles-mêmes" - -#: aircox_cms/models/__init__.py:126 -#, fuzzy -#| msgid "default program parent page" -msgid "default programs page" -msgstr "Page des programmes par défault" - -#: aircox_cms/models/__init__.py:129 -msgid "" -"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)." -msgstr "" -"Quand un nouveau programme est sauvegardé et qu'une publication est créée, " -"placez cette publication en tant qu'enfant de cette page. Si aucune page n'a " -"été spécifiée, placez cette publication en tant qu'enfant de la page racine " -"du site (sinon, ne créez pas la page)." - -#: aircox_cms/models/__init__.py:148 -msgid "Promotion" -msgstr "Promotion" - -#: aircox_cms/models/__init__.py:155 -#: aircox_cms/templates/aircox_cms/snippets/comments.html:6 -#: aircox_cms/wagtail_hooks.py:163 -msgid "Comments" -msgstr "Commentaires" - -#: aircox_cms/models/__init__.py:159 -msgid "Programs and controls" -msgstr "Programmes et contrôles" - -#: aircox_cms/models/__init__.py:163 -msgid "website settings" -msgstr "paramètres du site internet" - -#: aircox_cms/models/__init__.py:170 aircox_cms/models/sections.py:63 -msgid "page" -msgstr "page" - -#: aircox_cms/models/__init__.py:173 -msgid "published" -msgstr "publié" - -#: aircox_cms/models/__init__.py:177 -msgid "author" -msgstr "auteur" - -#: aircox_cms/models/__init__.py:181 aircox_cms/models/__init__.py:443 -msgid "email" -msgstr "email" - -#: aircox_cms/models/__init__.py:185 -msgid "website" -msgstr "site internet" - -#: aircox_cms/models/__init__.py:189 aircox_cms/models/__init__.py:350 -msgid "date" -msgstr "date" - -#: aircox_cms/models/__init__.py:193 aircox_cms/models/__init__.py:197 -msgid "comment" -msgstr "commentaire" - -#: aircox_cms/models/__init__.py:198 -#, fuzzy -#| msgid "comment" -msgid "comments" -msgstr "commentaire" - -#. Translators: text shown in the comments list (in admin) -#: aircox_cms/models/__init__.py:202 -#, python-brace-format -msgid "{date}, {author}: {content}..." -msgstr "{date}, {author}: {content}..." - -#: aircox_cms/models/__init__.py:230 -msgid "body" -msgstr "corps de texte" - -#: aircox_cms/models/__init__.py:232 -msgid "the publication itself" -msgstr "contenu de la publication elle-même" - -#: aircox_cms/models/__init__.py:236 -msgid "cover" -msgstr "couverture" - -#: aircox_cms/models/__init__.py:240 -msgid "image to use as cover of the publication" -msgstr "image à utiliser comme couverture de la publication" - -#: aircox_cms/models/__init__.py:243 aircox_cms/models/__init__.py:245 -#: aircox_cms/models/__init__.py:367 aircox_cms/models/__init__.py:369 -msgid "allow comments" -msgstr "autoriser les commentaires" - -#: aircox_cms/models/__init__.py:254 aircox_cms/models/__init__.py:393 -#: aircox_cms/models/__init__.py:399 aircox_cms/models/__init__.py:556 -msgid "Content" -msgstr "Contenu" - -#: aircox_cms/models/__init__.py:356 -msgid "publish as program" -msgstr "publier en tant que programme" - -#: aircox_cms/models/__init__.py:359 -msgid "use this program as the author of the publication" -msgstr "utiliser ce programme en tant qu'auteur de la publication" - -#: aircox_cms/models/__init__.py:362 -msgid "focus" -msgstr "focus" - -#: aircox_cms/models/__init__.py:364 -msgid "the publication is highlighted;" -msgstr "la publication est mise en avant;" - -#: aircox_cms/models/__init__.py:373 -msgid "headline" -msgstr "entête" - -#: aircox_cms/models/__init__.py:375 -msgid "headline of the publication, use it as an introduction" -msgstr "entête de la publication, utiliée comme introduction" - -#: aircox_cms/models/__init__.py:384 aircox_cms/models/__init__.py:385 -msgid "Publication" -msgstr "Publication" - -#: aircox_cms/models/__init__.py:436 -msgid "program" -msgstr "programme" - -#: aircox_cms/models/__init__.py:446 -msgid "email is public" -msgstr "l'email est public" - -#: aircox_cms/models/__init__.py:448 -msgid "the email addess is accessible to the public" -msgstr "l'adresse email est accessible au public" - -#: aircox_cms/models/__init__.py:452 aircox_cms/wagtail_hooks.py:49 -msgid "Program" -msgstr "Programme" - -#: aircox_cms/models/__init__.py:453 aircox_cms/signals.py:87 -#: aircox_cms/signals.py:99 aircox_cms/wagtail_hooks.py:38 -#: aircox_cms/wagtail_hooks.py:377 -msgid "Programs" -msgstr "Programmes" - -#: aircox_cms/models/__init__.py:528 -msgid "diffusion" -msgstr "diffusion" - -#: aircox_cms/models/__init__.py:539 -msgid "publish archive" -msgstr "publier l'archive" - -#: aircox_cms/models/__init__.py:541 -msgid "publish the podcast of the complete diffusion" -msgstr "publier le podcast de la diffusion complète" - -#: aircox_cms/models/__init__.py:545 -#: aircox_cms/templates/aircox_cms/snippets/list_item.html:38 -#: aircox_cms/wagtail_hooks.py:80 -msgid "Diffusion" -msgstr "Diffusion" - -#: aircox_cms/models/__init__.py:546 aircox_cms/wagtail_hooks.py:55 -msgid "Diffusions" -msgstr "Diffusions" - -#: aircox_cms/models/__init__.py:549 -msgid "Tracks" -msgstr "Pistes" - -#: aircox_cms/models/__init__.py:594 -#, python-format -msgid "Rerun of %(date)s" -msgstr "Rediffusion du %(date)s" - -#: aircox_cms/models/__init__.py:598 -msgid "Cancelled" -msgstr "Annulé" - -#: aircox_cms/models/__init__.py:614 -msgid "Podcasts" -msgstr "Podcasts" - -#: aircox_cms/models/__init__.py:682 -msgid "Dynamic List Page" -msgstr "Page avec liste dynamique" - -#: aircox_cms/models/__init__.py:683 -msgid "Dynamic List Pages" -msgstr "Pages avec liste dynamique" - -#: aircox_cms/models/__init__.py:727 aircox_cms/models/__init__.py:792 -#: aircox_cms/models/sections.py:404 aircox_cms/models/sections.py:464 -msgid "station" -msgstr "station" - -#: aircox_cms/models/__init__.py:730 aircox_cms/models/__init__.py:795 -#: aircox_cms/models/sections.py:465 -msgid "(required) related station" -msgstr "(requis) station" - -#: aircox_cms/models/__init__.py:733 -msgid "maximum age" -msgstr "âge maximum" - -#: aircox_cms/models/__init__.py:735 -msgid "maximum days in the past allowed to be shown. 0 means no limit" -msgstr "" -"nombre de jours maximum dans le passé autorisés à être affichés. 0 signifie " -"qu'il n'y a pas de limite" - -#: aircox_cms/models/__init__.py:739 -msgid "reverse list" -msgstr "inverser la liste" - -#: aircox_cms/models/__init__.py:741 -msgid "print logs in ascending order by date" -msgstr "afficher les logs de manière ascendante par date" - -#: aircox_cms/models/__init__.py:745 aircox_cms/models/__init__.py:746 -#: aircox_cms/wagtail_hooks.py:127 -msgid "Logs" -msgstr "Logs" - -#: aircox_cms/models/__init__.py:753 aircox_cms/models/__init__.py:801 -msgid "Configuration" -msgstr "Configuration" - -#: aircox_cms/models/__init__.py:805 aircox_cms/models/__init__.py:806 -#: aircox_cms/models/sections.py:485 aircox_cms/signals.py:73 -msgid "Timetable" -msgstr "Grille horaire" - -#: aircox_cms/models/lists.py:49 -msgid "url" -msgstr "url" - -#: aircox_cms/models/lists.py:51 -msgid "URL of the link" -msgstr "URL du lien" - -#: aircox_cms/models/lists.py:59 -msgid "Use a page instead of a URL" -msgstr "Utiliser une page au lieu d'une URL" - -#: aircox_cms/models/lists.py:63 aircox_cms/models/sections.py:631 -msgid "icon" -msgstr "icône" - -#: aircox_cms/models/lists.py:68 -msgid "icon from the gallery" -msgstr "icône de la gallerie" - -#: aircox_cms/models/lists.py:72 -msgid "icon path" -msgstr "chemin de l'icône" - -#: aircox_cms/models/lists.py:76 -msgid "icon from a given URL or path in the directory of static files" -msgstr "icône d'une URL donnée ou chemin dans le des fichiers statiques" - -#: aircox_cms/models/lists.py:80 -msgid "text" -msgstr "texte" - -#: aircox_cms/models/lists.py:83 -msgid "text of the link" -msgstr "texte du lien" - -#: aircox_cms/models/lists.py:86 -msgid "info" -msgstr "info" - -#: aircox_cms/models/lists.py:90 -msgid "description displayed in a popup when the mouse hovers the link" -msgstr "" -"description affichée dans une fenêtre popup quand la souris passe au dessus " -"du lien" - -#: aircox_cms/models/lists.py:106 -msgid "link" -msgstr "lien" - -#: aircox_cms/models/lists.py:156 -msgid "focus available" -msgstr "focus disponible" - -#: aircox_cms/models/lists.py:158 -msgid "if true, highlight the first focused article found" -msgstr "si vrai, surligner le premier article en focus trouvé" - -#: aircox_cms/models/lists.py:161 aircox_cms/models/sections.py:410 -msgid "count" -msgstr "compte" - -#: aircox_cms/models/lists.py:163 -msgid "number of items to display in the list" -msgstr "nombre d'objets à afficher dans la liste" - -#: aircox_cms/models/lists.py:166 -msgid "ascending order" -msgstr "ordre ascendant" - -#: aircox_cms/models/lists.py:168 -msgid "if selected sort list in the ascending order by date" -msgstr "si selectionné, affiche la liste dans l'ordre ascendant par date" - -#: aircox_cms/models/lists.py:173 -msgid "filter on date" -msgstr "filtrer par date" - -#: aircox_cms/models/lists.py:178 -msgid "filter pages on their date" -msgstr "filtrer les pages par leur date" - -#: aircox_cms/models/lists.py:183 -msgid "keep only elements of this type" -msgstr "garder seulement les éléments de ce type" - -#: aircox_cms/models/lists.py:186 -msgid "if set, select only elements that are of this type" -msgstr "si actif, séléctionne seulement des éléments de ce type" - -#: aircox_cms/models/lists.py:191 -msgid "related page" -msgstr "page apparentée" - -#: aircox_cms/models/lists.py:195 -msgid "if set, select children or siblings of this page" -msgstr "" -"si actif, selectionne les enfants ou les sœurs apparentées à cette page" - -#: aircox_cms/models/lists.py:200 -msgid "relation" -msgstr "relation" - -#: aircox_cms/models/lists.py:205 -msgid "" -"when the list is related to a page, only select pages that correspond to " -"this relationship" -msgstr "" -"quand une liste est relative à une page, sélectionner uniquement les pages " -"correspondant à cette relation" - -#: aircox_cms/models/lists.py:210 -msgid "filter on search" -msgstr "filtre sur cette recherche" - -#: aircox_cms/models/lists.py:214 -msgid "keep only pages that matches the given search" -msgstr "garder seulement les pages qui correspondent à cette recherche" - -#: aircox_cms/models/lists.py:218 -msgid "filter on tag" -msgstr "filtrer par date" - -#: aircox_cms/models/lists.py:222 -msgid "keep only pages with the given tags (separated by a colon)" -msgstr "" -"garder seulement les pages avec les tags suivants (séparés par une virgule" - -#: aircox_cms/models/lists.py:231 -msgid "rendering" -msgstr "rendu" - -#: aircox_cms/models/lists.py:239 -msgid "filters" -msgstr "filtres" - -#: aircox_cms/models/lists.py:452 -msgid "navigation days count" -msgstr "compte des jours de navigation" - -#: aircox_cms/models/lists.py:454 -msgid "number of days to display in the navigation header when we use dates" -msgstr "" -"nombre de jours à afficher dans l'entête de la navigation quand des dates " -"sont utilisées" - -#: aircox_cms/models/lists.py:458 -msgid "navigation per week" -msgstr "navigation par semaine" - -#: aircox_cms/models/lists.py:460 -msgid "" -"if selected, show dates navigation per weeks instead of show days equally " -"around the current date" -msgstr "" -"si sélectionné, montre les dates de navigation par semaine au lieu de " -"montrer les date ségalement autour de la date actuelle" - -#: aircox_cms/models/lists.py:464 -msgid "hide icons" -msgstr "cacher les icones" - -#: aircox_cms/models/lists.py:466 -msgid "if selected, images of publications will not be displayed in the list" -msgstr "si sélectionné, la liste n'affiche pas les images des publications" - -#: aircox_cms/models/lists.py:478 -msgid "Navigation" -msgstr "Navigation" - -#: aircox_cms/models/sections.py:36 -msgid "name" -msgstr "nom" - -#: aircox_cms/models/sections.py:39 -msgid "name of this section (not displayed)" -msgstr "nom de cette section (pas affiché)" - -#: aircox_cms/models/sections.py:42 -msgid "position" -msgstr "position" - -#: aircox_cms/models/sections.py:45 -msgid "name of the template block in which the section must be set" -msgstr "nom du bloc modèle dans lequel la section doit être activée" - -#: aircox_cms/models/sections.py:49 aircox_cms/models/sections.py:125 -msgid "order" -msgstr "ordre" - -#: aircox_cms/models/sections.py:51 aircox_cms/models/sections.py:127 -msgid "order of rendering, the higher the latest" -msgstr "ordre d'affichage, le plus élevé étant affiché en dernier" - -#: aircox_cms/models/sections.py:55 -msgid "model" -msgstr "modèle" - -#: aircox_cms/models/sections.py:57 -msgid "" -"this section is displayed only when the current page or publication is of " -"this type" -msgstr "" -"cette section n'est affichée que quand la page ou publication courante est " -"de ce type" - -#: aircox_cms/models/sections.py:65 -msgid "this section is displayed only on this page" -msgstr "cette section n'est affichée que sur cette page" - -#: aircox_cms/models/sections.py:74 aircox_cms/models/sections.py:159 -msgid "General" -msgstr "Général" - -#: aircox_cms/models/sections.py:134 -msgid "title" -msgstr "titre" - -#: aircox_cms/models/sections.py:139 -msgid "show title" -msgstr "montrer le titre" - -#: aircox_cms/models/sections.py:141 -msgid "if set show a title at the head of the section" -msgstr "si actif, montre un titre dans l'entête de cette section" - -#: aircox_cms/models/sections.py:144 -msgid "CSS class" -msgstr "classe CSS" - -#: aircox_cms/models/sections.py:147 -msgid "section container's \"class\" attribute" -msgstr "attribut \"class\" de la balise HTML de la section" - -#: aircox_cms/models/sections.py:186 -msgid "is related" -msgstr "est apparenté" - -#: aircox_cms/models/sections.py:189 -msgid "" -"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." -msgstr "" -"si actif, la section est apparentée à la page traitée e.g. fournir une liste " -"de liens utilisera ceux de la publication au lieu de celle assignée" - -#: aircox_cms/models/sections.py:235 -msgid "image" -msgstr "image" - -#: aircox_cms/models/sections.py:239 -msgid "" -"If this item is related to the current page, this image will be used only " -"when the page has not a cover" -msgstr "" -"Si cet objet est apparenté à la page actuelle, cette page sera utilisée " -"seulement quand la page n'a pas de couverture" - -#: aircox_cms/models/sections.py:244 -msgid "width" -msgstr "largeur" - -#: aircox_cms/models/sections.py:246 -msgid "if set and > 0, sets a maximum width for the image" -msgstr "si actif et > 0, fixe une largeur maximum pour l'image" - -#: aircox_cms/models/sections.py:249 -msgid "height" -msgstr "hauteur" - -#: aircox_cms/models/sections.py:251 -msgid "if set 0 and > 0, sets a maximum height for the image" -msgstr "si actif et > 0, fixe une hauteur maximum pour l'image" - -#: aircox_cms/models/sections.py:254 -msgid "resize mode" -msgstr "mode de redimensionnement" - -#: aircox_cms/models/sections.py:257 -msgid "if the image is resized, set the resizing mode" -msgstr "si l'image est redimensionnée, fixer le mode de redimensionnement" - -#: aircox_cms/models/sections.py:266 -msgid "Resizing" -msgstr "Redimensionner" - -#: aircox_cms/models/sections.py:325 -msgid "Links" -msgstr "Liens" - -#: aircox_cms/models/sections.py:361 -msgid "text of the url" -msgstr "texte de l'url" - -#: aircox_cms/models/sections.py:364 -msgid "" -"use this text to display an URL to the complete list. If empty, no link is " -"displayed" -msgstr "" -"utiliser ce texte pour afficher un URL dans la liste complète. Si vide " -"aucune adresse est affichée." - -#: aircox_cms/models/sections.py:407 -msgid "(required) the station on which the logs happened" -msgstr "(requis) la station sur laquelle les logs se sont produits" - -#: aircox_cms/models/sections.py:412 -msgid "number of items to display in the list (max 100)" -msgstr "nombre d'objets à afficher dans la liste (max 100)" - -#: aircox_cms/models/sections.py:416 -msgid "list of logs" -msgstr "liste des logs" - -#: aircox_cms/models/sections.py:417 -msgid "lists of logs" -msgstr "listes des logs" - -#: aircox_cms/models/sections.py:459 -msgid "Section: Timetable" -msgstr "Section: Grille horaire" - -#: aircox_cms/models/sections.py:460 -msgid "Sections: Timetable" -msgstr "Sections: Grilles horaire" - -#: aircox_cms/models/sections.py:469 -msgid "timetable page" -msgstr "Page de Grille horaire" - -#: aircox_cms/models/sections.py:471 -msgid "select a timetable page used to show complete timetable" -msgstr "" -"sélectionner une page de grille horaire pour afficher l'horaire complet" - -#: aircox_cms/models/sections.py:474 -msgid "show date navigation" -msgstr "afficher les dates de navigation" - -#: aircox_cms/models/sections.py:476 -msgid "if checked, navigation dates will be shown" -msgstr "si coché, les dates de navigation sont affichées" - -#: aircox_cms/models/sections.py:511 -msgid "Section: publication's info" -msgstr "Section: info de la publication" - -#: aircox_cms/models/sections.py:512 -msgid "Sections: publication's info" -msgstr "Sections: info de la publication" - -#: aircox_cms/models/sections.py:518 -msgid "default text" -msgstr "texte par défaut" - -#: aircox_cms/models/sections.py:520 -msgid "search" -msgstr "recherche" - -#: aircox_cms/models/sections.py:521 -msgid "text to display when the search field is empty" -msgstr "texte à afficher quand le champ de recherche est vide" - -#: aircox_cms/models/sections.py:525 -msgid "Section: search field" -msgstr "Section: champ de recherche" - -#: aircox_cms/models/sections.py:526 -msgid "Sections: search field" -msgstr "Sections: champ de recherche" - -#: aircox_cms/models/sections.py:567 -msgid "user playlist" -msgstr "playlist utilisateur/ice" - -#: aircox_cms/models/sections.py:570 -msgid "" -"this is a user playlist, it can be edited and saved by the " -"users (the modifications will NOT be registered on the server)" -msgstr "" -"il s'agit d'une playlist utilisateur/ices qu'ils/elles peuvent éditer " -" et enregistrer (les modifications ne seront PAS enregistrées sur le serveur)" - -#: aircox_cms/models/sections.py:574 -msgid "read all" -msgstr "tout lire" - -#: aircox_cms/models/sections.py:577 -msgid "by default at the end of the sound play the next one" -msgstr "par défault à la fin d'un son, jouer le suivant" - -#: aircox_cms/models/sections.py:622 -msgid "live title" -msgstr "titre du direct" - -#: aircox_cms/models/sections.py:624 -msgid "text to display when it plays live" -msgstr "texte à afficher quand le direct est activé" - -#: aircox_cms/models/sections.py:627 -msgid "audio streams" -msgstr "streams audio" - -#: aircox_cms/models/sections.py:628 -msgid "one audio stream per line" -msgstr "un stream audio par ligne" - -#: aircox_cms/models/sections.py:633 -#, fuzzy -#| msgid "text to display when it plays live" -msgid "icon to display in the player" -msgstr "texte à afficher quand le direct est activé" - -#: aircox_cms/models/sections.py:637 -msgid "Section: Player" -msgstr "Section: Player" - -#: aircox_cms/signals.py:33 -#, python-brace-format -msgid "" -"If you see this page, then Aircox is running for the station {station.name}. " -"You might want to change it to a better one. " -msgstr "" -"Si vous voyez cette page, Aircox fonctionne pour la station {station.name}. " -"Vous devriez peut-être la change pour une meilleure station. " - -#: aircox_cms/signals.py:64 -#, python-brace-format -msgid "The website of the {name} radio" -msgstr "Site internet de la radio {name}" - -#. Translators: tags set by default in description of the website -#: aircox_cms/signals.py:68 -#, python-brace-format -msgid "radio,{station.name}" -msgstr "radio,{station.name}" - -#: aircox_cms/signals.py:80 -msgid "Search" -msgstr "Recherche" - -#: aircox_cms/signals.py:92 -msgid "programs" -msgstr "programmes" - -#: aircox_cms/signals.py:100 -msgid "All programs" -msgstr "Tous les programmes" - -#: aircox_cms/signals.py:110 -msgid "Previously on air" -msgstr "Précédemment on air" - -#. Translators: default content of a page for program -#: aircox_cms/signals.py:139 -#, python-brace-format -msgid "{program.name} is a program on {station.name}." -msgstr "{program.name} est un programme sur {station.name}" - -#: aircox_cms/templates/aircox_cms/diffusion_page.html:9 -msgid "Playlist" -msgstr "Playlist" - -#: aircox_cms/templates/aircox_cms/diffusion_page.html:24 -msgid "Dates of diffusion" -msgstr "Dates de diffusion" - -#: aircox_cms/templates/aircox_cms/dynamic_list_page.html:14 -#, python-format -msgid "Search in publications for %(terms)s" -msgstr "Chercher %(terms)s dans les publications" - -#: aircox_cms/templates/aircox_cms/dynamic_list_page.html:19 -#, python-format -msgid "Related to %(title)s" -msgstr "Relatif à %(title)s" - -#: aircox_cms/templates/aircox_cms/dynamic_list_page.html:23 -msgid "All the publications" -msgstr "Toutes les publications" - -#: aircox_cms/templates/aircox_cms/dynamic_list_page.html:36 -msgid "More about it" -msgstr "Plus d'informations" - -#: aircox_cms/templates/aircox_cms/event_page.html:6 -msgid "Practical information" -msgstr "Informations pratiques" - -#: aircox_cms/templates/aircox_cms/event_page.html:10 -msgid "Date" -msgstr "Date" - -#: aircox_cms/templates/aircox_cms/event_page.html:13 -msgid "Place" -msgstr "Lieu" - -#: aircox_cms/templates/aircox_cms/event_page.html:15 -msgid "Price" -msgstr "Prix" - -#: aircox_cms/templates/aircox_cms/program_page.html:12 -#: aircox_cms/wagtail_hooks.py:101 -msgid "Schedule" -msgstr "Horaire" - -#: aircox_cms/templates/aircox_cms/program_page.html:19 -#, python-format -msgid "" -"Diffusion on %(day)s at %(start_hour)s hours %(start_minute)s, " -"%(frequency)s, and last for %(duration_hour)s hours and %(duration_minute)s " -"minutes" -msgstr "" -"Diffusion le %(day)s à %(start_hour)s heures %(start_minute)s, " -"%(frequency)s, et dure %(duration_hour)s heures et %(duration_minute)s " - -#: aircox_cms/templates/aircox_cms/program_page.html:24 -msgid "" -"%(day)s at %(start)s (%(duration)s), %(frequency)s" -msgstr "" -"%(day)s à %(start)s (%(duration)s), %(frequency)s" - -#: aircox_cms/templates/aircox_cms/program_page.html:28 -msgid "Rerun" -msgstr "Rediffusion" - -#: aircox_cms/templates/aircox_cms/program_page.html:37 -msgid "This program is no longer active" -msgstr "Ce programme n'est plus actif" - -#: aircox_cms/templates/aircox_cms/sections/publication_info.html:14 -#: aircox_cms/templates/aircox_cms/sections/publication_info.html:15 -msgid "Parent pages" -msgstr "Pages parentes" - -#: aircox_cms/templates/aircox_cms/sections/publication_info.html:31 -#: aircox_cms/templates/aircox_cms/sections/publication_info.html:32 -msgid "Tags" -msgstr "Tags" - -#: aircox_cms/templates/aircox_cms/sections/publication_info.html:49 -#: aircox_cms/templates/aircox_cms/sections/publication_info.html:50 -msgid "Author" -msgstr "Auteur" - -#: aircox_cms/templates/aircox_cms/sections/publication_info.html:52 -#, python-format -msgid "Published by %(author)s" -msgstr "Publié par %(author)s" - -#: aircox_cms/templates/aircox_cms/sections/publication_info.html:62 -#: aircox_cms/templates/aircox_cms/sections/publication_info.html:63 -msgid "Date of publication" -msgstr "Date de publication" - -#: aircox_cms/templates/aircox_cms/sections/publication_info.html:72 -#: aircox_cms/templates/aircox_cms/sections/publication_info.html:73 -msgid "Share" -msgstr "Partager" - -#: aircox_cms/templates/aircox_cms/snippets/comments.html:21 -msgid "show more options" -msgstr "montrer plus d'options" - -#: aircox_cms/templates/aircox_cms/snippets/comments.html:34 -msgid "Post!" -msgstr "Poster!" - -#: aircox_cms/templates/aircox_cms/snippets/date_list.html:8 -msgid "go to today" -msgstr "aujourd'hui" - -#: aircox_cms/templates/aircox_cms/snippets/date_list.html:11 -msgid "previous days" -msgstr "jours précédents" - -#: aircox_cms/templates/aircox_cms/snippets/date_list.html:27 -msgid "next days" -msgstr "jours suivants" - -#: aircox_cms/templates/aircox_cms/snippets/date_list_item.html:21 -#: aircox_cms/templates/aircox_cms/snippets/date_list_item.html:22 -msgid "on air" -msgstr "en onde" - -#: aircox_cms/templates/aircox_cms/snippets/list.html:29 -msgid "previous page" -msgstr "page précédente" - -#: aircox_cms/templates/aircox_cms/snippets/list.html:59 -msgid "next page" -msgstr "page suivante" - -#: aircox_cms/templates/aircox_cms/snippets/sound_list_item.html:39 -msgid "add this sound to the playlist" -msgstr "ajouter ce son à la playlist" - -#: aircox_cms/templates/aircox_cms/vues/player.html:21 -msgid "Click to pause" -msgstr "Cliquer pour mettre sur pause" - -#: aircox_cms/templates/aircox_cms/vues/player.html:25 -msgid "Loading... Click to pause" -msgstr "Chargement... Cliquer pour mettre sur pause" - -#: aircox_cms/templates/aircox_cms/vues/player.html:29 -msgid "Click to play" -msgstr "Cliquer pour jouer" - -#: aircox_cms/templates/aircox_cms/vues/player.html:54 -msgid "Remove from playlist" -msgstr "Retirer de la playlist" - -#: aircox_cms/templates/aircox_cms/vues/player.html:59 -msgid "Add to my playlist" -msgstr "Ajouter à ma playlist" - -#: aircox_cms/templates/aircox_cms/vues/player.html:82 -#| msgid "add to the player" -msgid "Read all the playlist" -msgstr "Lire toute la playlist" - -#: aircox_cms/wagtail_hooks.py:86 -msgid "Schedules" -msgstr "Horaires" - -#: aircox_cms/wagtail_hooks.py:107 -msgid "Streams" -msgstr "Streams" - -#: aircox_cms/wagtail_hooks.py:121 -msgid "Stream" -msgstr "Stream" - -#: aircox_cms/wagtail_hooks.py:142 -msgid "Log" -msgstr "Logs" - -#: aircox_cms/wagtail_hooks.py:147 -msgid "Related objects" -msgstr "Objects relatifs" - -#: aircox_cms/wagtail_hooks.py:154 -msgid "Advanced" -msgstr "Avancé" - -#: aircox_cms/wagtail_hooks.py:174 -msgid "Sounds" -msgstr "Sons" - -#: aircox_cms/wagtail_hooks.py:338 -msgid "Today's Diffusions" -msgstr "Diffusions du jour" - -#: aircox_cms/wagtail_hooks.py:410 -msgid "Current Station" -msgstr "Station courante" - -#~ msgid "play" -#~ msgstr "jouer" - -#~ msgid "pause" -#~ msgstr "pause" - -#~ msgid "loading..." -#~ msgstr "chargement..." - -#~ msgid "more informations" -#~ msgstr "plus d'informations" - -#~ msgid "remove this sound" -#~ msgstr "enlever ce son" - -#~ msgid "enable and disable single mode" -#~ msgstr "activer et désactiver le mode solo" diff --git a/aircox_cms/management/commands/programs_to_cms.py b/aircox_cms/management/commands/programs_to_cms.py deleted file mode 100755 index 171fbfe..0000000 --- a/aircox_cms/management/commands/programs_to_cms.py +++ /dev/null @@ -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') - - - diff --git a/aircox_cms/models/__init__.py b/aircox_cms/models/__init__.py deleted file mode 100755 index 2a7f53b..0000000 --- a/aircox_cms/models/__init__.py +++ /dev/null @@ -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 - # 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 - - diff --git a/aircox_cms/models/lists.py b/aircox_cms/models/lists.py deleted file mode 100644 index 4bea799..0000000 --- a/aircox_cms/models/lists.py +++ /dev/null @@ -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[0-9]{4})(-|\/)?(?P[0-9]{1,2})(-|\/)?' \ - r'(?P[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, - } - } - - - diff --git a/aircox_cms/models/sections.py b/aircox_cms/models/sections.py deleted file mode 100644 index c5d8b4c..0000000 --- a/aircox_cms/models/sections.py +++ /dev/null @@ -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() - ) + - '' - ) - context = Context({ - "source": image - }) - self.cache = template.render(context) - else: - self.cache = ''.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 - - diff --git a/aircox_cms/settings.py b/aircox_cms/settings.py deleted file mode 100755 index 88908ce..0000000 --- a/aircox_cms/settings.py +++ /dev/null @@ -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 - - diff --git a/aircox_cms/signals.py b/aircox_cms/signals.py deleted file mode 100755 index cdd01cd..0000000 --- a/aircox_cms/signals.py +++ /dev/null @@ -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 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) - diff --git a/aircox_cms/static/aircox_cms/css/layout.css b/aircox_cms/static/aircox_cms/css/layout.css deleted file mode 100755 index ff6f184..0000000 --- a/aircox_cms/static/aircox_cms/css/layout.css +++ /dev/null @@ -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; -} - - diff --git a/aircox_cms/static/aircox_cms/css/theme.css b/aircox_cms/static/aircox_cms/css/theme.css deleted file mode 100755 index 4cad9a8..0000000 --- a/aircox_cms/static/aircox_cms/css/theme.css +++ /dev/null @@ -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: ':' -} - diff --git a/aircox_cms/static/aircox_cms/js/bootstrap.js b/aircox_cms/static/aircox_cms/js/bootstrap.js deleted file mode 100644 index e36eb3e..0000000 --- a/aircox_cms/static/aircox_cms/js/bootstrap.js +++ /dev/null @@ -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'); -} - - - - - diff --git a/aircox_cms/static/aircox_cms/js/player.js b/aircox_cms/static/aircox_cms/js/player.js deleted file mode 100644 index 8c731da..0000000 --- a/aircox_cms/static/aircox_cms/js/player.js +++ /dev/null @@ -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); - diff --git a/aircox_cms/static/aircox_cms/js/utils.js b/aircox_cms/static/aircox_cms/js/utils.js deleted file mode 100755 index b5f527e..0000000 --- a/aircox_cms/static/aircox_cms/js/utils.js +++ /dev/null @@ -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); - }, -} - - diff --git a/aircox_cms/static/lib/vue.js b/aircox_cms/static/lib/vue.js deleted file mode 100644 index 9d84c53..0000000 --- a/aircox_cms/static/lib/vue.js +++ /dev/null @@ -1,10798 +0,0 @@ -/*! - * Vue.js v2.5.13 - * (c) 2014-2017 Evan You - * Released under the MIT License. - */ -(function (global, factory) { - typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory() : - typeof define === 'function' && define.amd ? define(factory) : - (global.Vue = factory()); -}(this, (function () { 'use strict'; - -/* */ - -var emptyObject = Object.freeze({}); - -// these helpers produces better vm code in JS engines due to their -// explicitness and function inlining -function isUndef (v) { - return v === undefined || v === null -} - -function isDef (v) { - return v !== undefined && v !== null -} - -function isTrue (v) { - return v === true -} - -function isFalse (v) { - return v === false -} - -/** - * Check if value is primitive - */ -function isPrimitive (value) { - return ( - typeof value === 'string' || - typeof value === 'number' || - // $flow-disable-line - typeof value === 'symbol' || - typeof value === 'boolean' - ) -} - -/** - * Quick object check - this is primarily used to tell - * Objects from primitive values when we know the value - * is a JSON-compliant type. - */ -function isObject (obj) { - return obj !== null && typeof obj === 'object' -} - -/** - * Get the raw type string of a value e.g. [object Object] - */ -var _toString = Object.prototype.toString; - -function toRawType (value) { - return _toString.call(value).slice(8, -1) -} - -/** - * Strict object type check. Only returns true - * for plain JavaScript objects. - */ -function isPlainObject (obj) { - return _toString.call(obj) === '[object Object]' -} - -function isRegExp (v) { - return _toString.call(v) === '[object RegExp]' -} - -/** - * Check if val is a valid array index. - */ -function isValidArrayIndex (val) { - var n = parseFloat(String(val)); - return n >= 0 && Math.floor(n) === n && isFinite(val) -} - -/** - * Convert a value to a string that is actually rendered. - */ -function toString (val) { - return val == null - ? '' - : typeof val === 'object' - ? JSON.stringify(val, null, 2) - : String(val) -} - -/** - * Convert a input value to a number for persistence. - * If the conversion fails, return original string. - */ -function toNumber (val) { - var n = parseFloat(val); - return isNaN(n) ? val : n -} - -/** - * Make a map and return a function for checking if a key - * is in that map. - */ -function makeMap ( - str, - expectsLowerCase -) { - var map = Object.create(null); - var list = str.split(','); - for (var i = 0; i < list.length; i++) { - map[list[i]] = true; - } - return expectsLowerCase - ? function (val) { return map[val.toLowerCase()]; } - : function (val) { return map[val]; } -} - -/** - * Check if a tag is a built-in tag. - */ -var isBuiltInTag = makeMap('slot,component', true); - -/** - * Check if a attribute is a reserved attribute. - */ -var isReservedAttribute = makeMap('key,ref,slot,slot-scope,is'); - -/** - * Remove an item from an array - */ -function remove (arr, item) { - if (arr.length) { - var index = arr.indexOf(item); - if (index > -1) { - return arr.splice(index, 1) - } - } -} - -/** - * Check whether the object has the property. - */ -var hasOwnProperty = Object.prototype.hasOwnProperty; -function hasOwn (obj, key) { - return hasOwnProperty.call(obj, key) -} - -/** - * Create a cached version of a pure function. - */ -function cached (fn) { - var cache = Object.create(null); - return (function cachedFn (str) { - var hit = cache[str]; - return hit || (cache[str] = fn(str)) - }) -} - -/** - * Camelize a hyphen-delimited string. - */ -var camelizeRE = /-(\w)/g; -var camelize = cached(function (str) { - return str.replace(camelizeRE, function (_, c) { return c ? c.toUpperCase() : ''; }) -}); - -/** - * Capitalize a string. - */ -var capitalize = cached(function (str) { - return str.charAt(0).toUpperCase() + str.slice(1) -}); - -/** - * Hyphenate a camelCase string. - */ -var hyphenateRE = /\B([A-Z])/g; -var hyphenate = cached(function (str) { - return str.replace(hyphenateRE, '-$1').toLowerCase() -}); - -/** - * Simple bind, faster than native - */ -function bind (fn, ctx) { - function boundFn (a) { - var l = arguments.length; - return l - ? l > 1 - ? fn.apply(ctx, arguments) - : fn.call(ctx, a) - : fn.call(ctx) - } - // record original fn length - boundFn._length = fn.length; - return boundFn -} - -/** - * Convert an Array-like object to a real Array. - */ -function toArray (list, start) { - start = start || 0; - var i = list.length - start; - var ret = new Array(i); - while (i--) { - ret[i] = list[i + start]; - } - return ret -} - -/** - * Mix properties into target object. - */ -function extend (to, _from) { - for (var key in _from) { - to[key] = _from[key]; - } - return to -} - -/** - * Merge an Array of Objects into a single Object. - */ -function toObject (arr) { - var res = {}; - for (var i = 0; i < arr.length; i++) { - if (arr[i]) { - extend(res, arr[i]); - } - } - return res -} - -/** - * Perform no operation. - * Stubbing args to make Flow happy without leaving useless transpiled code - * with ...rest (https://flow.org/blog/2017/05/07/Strict-Function-Call-Arity/) - */ -function noop (a, b, c) {} - -/** - * Always return false. - */ -var no = function (a, b, c) { return false; }; - -/** - * Return same value - */ -var identity = function (_) { return _; }; - -/** - * Generate a static keys string from compiler modules. - */ -function genStaticKeys (modules) { - return modules.reduce(function (keys, m) { - return keys.concat(m.staticKeys || []) - }, []).join(',') -} - -/** - * Check if two values are loosely equal - that is, - * if they are plain objects, do they have the same shape? - */ -function looseEqual (a, b) { - if (a === b) { return true } - var isObjectA = isObject(a); - var isObjectB = isObject(b); - if (isObjectA && isObjectB) { - try { - var isArrayA = Array.isArray(a); - var isArrayB = Array.isArray(b); - if (isArrayA && isArrayB) { - return a.length === b.length && a.every(function (e, i) { - return looseEqual(e, b[i]) - }) - } else if (!isArrayA && !isArrayB) { - var keysA = Object.keys(a); - var keysB = Object.keys(b); - return keysA.length === keysB.length && keysA.every(function (key) { - return looseEqual(a[key], b[key]) - }) - } else { - /* istanbul ignore next */ - return false - } - } catch (e) { - /* istanbul ignore next */ - return false - } - } else if (!isObjectA && !isObjectB) { - return String(a) === String(b) - } else { - return false - } -} - -function looseIndexOf (arr, val) { - for (var i = 0; i < arr.length; i++) { - if (looseEqual(arr[i], val)) { return i } - } - return -1 -} - -/** - * Ensure a function is called only once. - */ -function once (fn) { - var called = false; - return function () { - if (!called) { - called = true; - fn.apply(this, arguments); - } - } -} - -var SSR_ATTR = 'data-server-rendered'; - -var ASSET_TYPES = [ - 'component', - 'directive', - 'filter' -]; - -var LIFECYCLE_HOOKS = [ - 'beforeCreate', - 'created', - 'beforeMount', - 'mounted', - 'beforeUpdate', - 'updated', - 'beforeDestroy', - 'destroyed', - 'activated', - 'deactivated', - 'errorCaptured' -]; - -/* */ - -var config = ({ - /** - * Option merge strategies (used in core/util/options) - */ - // $flow-disable-line - optionMergeStrategies: Object.create(null), - - /** - * Whether to suppress warnings. - */ - silent: false, - - /** - * Show production mode tip message on boot? - */ - productionTip: "development" !== 'production', - - /** - * Whether to enable devtools - */ - devtools: "development" !== 'production', - - /** - * Whether to record perf - */ - performance: false, - - /** - * Error handler for watcher errors - */ - errorHandler: null, - - /** - * Warn handler for watcher warns - */ - warnHandler: null, - - /** - * Ignore certain custom elements - */ - ignoredElements: [], - - /** - * Custom user key aliases for v-on - */ - // $flow-disable-line - keyCodes: Object.create(null), - - /** - * Check if a tag is reserved so that it cannot be registered as a - * component. This is platform-dependent and may be overwritten. - */ - isReservedTag: no, - - /** - * Check if an attribute is reserved so that it cannot be used as a component - * prop. This is platform-dependent and may be overwritten. - */ - isReservedAttr: no, - - /** - * Check if a tag is an unknown element. - * Platform-dependent. - */ - isUnknownElement: no, - - /** - * Get the namespace of an element - */ - getTagNamespace: noop, - - /** - * Parse the real tag name for the specific platform. - */ - parsePlatformTagName: identity, - - /** - * Check if an attribute must be bound using property, e.g. value - * Platform-dependent. - */ - mustUseProp: no, - - /** - * Exposed for legacy reasons - */ - _lifecycleHooks: LIFECYCLE_HOOKS -}); - -/* */ - -/** - * Check if a string starts with $ or _ - */ -function isReserved (str) { - var c = (str + '').charCodeAt(0); - return c === 0x24 || c === 0x5F -} - -/** - * Define a property. - */ -function def (obj, key, val, enumerable) { - Object.defineProperty(obj, key, { - value: val, - enumerable: !!enumerable, - writable: true, - configurable: true - }); -} - -/** - * Parse simple path. - */ -var bailRE = /[^\w.$]/; -function parsePath (path) { - if (bailRE.test(path)) { - return - } - var segments = path.split('.'); - return function (obj) { - for (var i = 0; i < segments.length; i++) { - if (!obj) { return } - obj = obj[segments[i]]; - } - return obj - } -} - -/* */ - - -// can we use __proto__? -var hasProto = '__proto__' in {}; - -// Browser environment sniffing -var inBrowser = typeof window !== 'undefined'; -var inWeex = typeof WXEnvironment !== 'undefined' && !!WXEnvironment.platform; -var weexPlatform = inWeex && WXEnvironment.platform.toLowerCase(); -var UA = inBrowser && window.navigator.userAgent.toLowerCase(); -var isIE = UA && /msie|trident/.test(UA); -var isIE9 = UA && UA.indexOf('msie 9.0') > 0; -var isEdge = UA && UA.indexOf('edge/') > 0; -var isAndroid = (UA && UA.indexOf('android') > 0) || (weexPlatform === 'android'); -var isIOS = (UA && /iphone|ipad|ipod|ios/.test(UA)) || (weexPlatform === 'ios'); -var isChrome = UA && /chrome\/\d+/.test(UA) && !isEdge; - -// Firefox has a "watch" function on Object.prototype... -var nativeWatch = ({}).watch; - -var supportsPassive = false; -if (inBrowser) { - try { - var opts = {}; - Object.defineProperty(opts, 'passive', ({ - get: function get () { - /* istanbul ignore next */ - supportsPassive = true; - } - })); // https://github.com/facebook/flow/issues/285 - window.addEventListener('test-passive', null, opts); - } catch (e) {} -} - -// this needs to be lazy-evaled because vue may be required before -// vue-server-renderer can set VUE_ENV -var _isServer; -var isServerRendering = function () { - if (_isServer === undefined) { - /* istanbul ignore if */ - if (!inBrowser && typeof global !== 'undefined') { - // detect presence of vue-server-renderer and avoid - // Webpack shimming the process - _isServer = global['process'].env.VUE_ENV === 'server'; - } else { - _isServer = false; - } - } - return _isServer -}; - -// detect devtools -var devtools = inBrowser && window.__VUE_DEVTOOLS_GLOBAL_HOOK__; - -/* istanbul ignore next */ -function isNative (Ctor) { - return typeof Ctor === 'function' && /native code/.test(Ctor.toString()) -} - -var hasSymbol = - typeof Symbol !== 'undefined' && isNative(Symbol) && - typeof Reflect !== 'undefined' && isNative(Reflect.ownKeys); - -var _Set; -/* istanbul ignore if */ // $flow-disable-line -if (typeof Set !== 'undefined' && isNative(Set)) { - // use native Set when available. - _Set = Set; -} else { - // a non-standard Set polyfill that only works with primitive keys. - _Set = (function () { - function Set () { - this.set = Object.create(null); - } - Set.prototype.has = function has (key) { - return this.set[key] === true - }; - Set.prototype.add = function add (key) { - this.set[key] = true; - }; - Set.prototype.clear = function clear () { - this.set = Object.create(null); - }; - - return Set; - }()); -} - -/* */ - -var warn = noop; -var tip = noop; -var generateComponentTrace = (noop); // work around flow check -var formatComponentName = (noop); - -{ - var hasConsole = typeof console !== 'undefined'; - var classifyRE = /(?:^|[-_])(\w)/g; - var classify = function (str) { return str - .replace(classifyRE, function (c) { return c.toUpperCase(); }) - .replace(/[-_]/g, ''); }; - - warn = function (msg, vm) { - var trace = vm ? generateComponentTrace(vm) : ''; - - if (config.warnHandler) { - config.warnHandler.call(null, msg, vm, trace); - } else if (hasConsole && (!config.silent)) { - console.error(("[Vue warn]: " + msg + trace)); - } - }; - - tip = function (msg, vm) { - if (hasConsole && (!config.silent)) { - console.warn("[Vue tip]: " + msg + ( - vm ? generateComponentTrace(vm) : '' - )); - } - }; - - formatComponentName = function (vm, includeFile) { - if (vm.$root === vm) { - return '' - } - var options = typeof vm === 'function' && vm.cid != null - ? vm.options - : vm._isVue - ? vm.$options || vm.constructor.options - : vm || {}; - var name = options.name || options._componentTag; - var file = options.__file; - if (!name && file) { - var match = file.match(/([^/\\]+)\.vue$/); - name = match && match[1]; - } - - return ( - (name ? ("<" + (classify(name)) + ">") : "") + - (file && includeFile !== false ? (" at " + file) : '') - ) - }; - - var repeat = function (str, n) { - var res = ''; - while (n) { - if (n % 2 === 1) { res += str; } - if (n > 1) { str += str; } - n >>= 1; - } - return res - }; - - generateComponentTrace = function (vm) { - if (vm._isVue && vm.$parent) { - var tree = []; - var currentRecursiveSequence = 0; - while (vm) { - if (tree.length > 0) { - var last = tree[tree.length - 1]; - if (last.constructor === vm.constructor) { - currentRecursiveSequence++; - vm = vm.$parent; - continue - } else if (currentRecursiveSequence > 0) { - tree[tree.length - 1] = [last, currentRecursiveSequence]; - currentRecursiveSequence = 0; - } - } - tree.push(vm); - vm = vm.$parent; - } - return '\n\nfound in\n\n' + tree - .map(function (vm, i) { return ("" + (i === 0 ? '---> ' : repeat(' ', 5 + i * 2)) + (Array.isArray(vm) - ? ((formatComponentName(vm[0])) + "... (" + (vm[1]) + " recursive calls)") - : formatComponentName(vm))); }) - .join('\n') - } else { - return ("\n\n(found in " + (formatComponentName(vm)) + ")") - } - }; -} - -/* */ - - -var uid = 0; - -/** - * A dep is an observable that can have multiple - * directives subscribing to it. - */ -var Dep = function Dep () { - this.id = uid++; - this.subs = []; -}; - -Dep.prototype.addSub = function addSub (sub) { - this.subs.push(sub); -}; - -Dep.prototype.removeSub = function removeSub (sub) { - remove(this.subs, sub); -}; - -Dep.prototype.depend = function depend () { - if (Dep.target) { - Dep.target.addDep(this); - } -}; - -Dep.prototype.notify = function notify () { - // stabilize the subscriber list first - var subs = this.subs.slice(); - for (var i = 0, l = subs.length; i < l; i++) { - subs[i].update(); - } -}; - -// the current target watcher being evaluated. -// this is globally unique because there could be only one -// watcher being evaluated at any time. -Dep.target = null; -var targetStack = []; - -function pushTarget (_target) { - if (Dep.target) { targetStack.push(Dep.target); } - Dep.target = _target; -} - -function popTarget () { - Dep.target = targetStack.pop(); -} - -/* */ - -var VNode = function VNode ( - tag, - data, - children, - text, - elm, - context, - componentOptions, - asyncFactory -) { - this.tag = tag; - this.data = data; - this.children = children; - this.text = text; - this.elm = elm; - this.ns = undefined; - this.context = context; - this.fnContext = undefined; - this.fnOptions = undefined; - this.fnScopeId = undefined; - this.key = data && data.key; - this.componentOptions = componentOptions; - this.componentInstance = undefined; - this.parent = undefined; - this.raw = false; - this.isStatic = false; - this.isRootInsert = true; - this.isComment = false; - this.isCloned = false; - this.isOnce = false; - this.asyncFactory = asyncFactory; - this.asyncMeta = undefined; - this.isAsyncPlaceholder = false; -}; - -var prototypeAccessors = { child: { configurable: true } }; - -// DEPRECATED: alias for componentInstance for backwards compat. -/* istanbul ignore next */ -prototypeAccessors.child.get = function () { - return this.componentInstance -}; - -Object.defineProperties( VNode.prototype, prototypeAccessors ); - -var createEmptyVNode = function (text) { - if ( text === void 0 ) text = ''; - - var node = new VNode(); - node.text = text; - node.isComment = true; - return node -}; - -function createTextVNode (val) { - return new VNode(undefined, undefined, undefined, String(val)) -} - -// optimized shallow clone -// used for static nodes and slot nodes because they may be reused across -// multiple renders, cloning them avoids errors when DOM manipulations rely -// on their elm reference. -function cloneVNode (vnode, deep) { - var componentOptions = vnode.componentOptions; - var cloned = new VNode( - vnode.tag, - vnode.data, - vnode.children, - vnode.text, - vnode.elm, - vnode.context, - componentOptions, - vnode.asyncFactory - ); - cloned.ns = vnode.ns; - cloned.isStatic = vnode.isStatic; - cloned.key = vnode.key; - cloned.isComment = vnode.isComment; - cloned.fnContext = vnode.fnContext; - cloned.fnOptions = vnode.fnOptions; - cloned.fnScopeId = vnode.fnScopeId; - cloned.isCloned = true; - if (deep) { - if (vnode.children) { - cloned.children = cloneVNodes(vnode.children, true); - } - if (componentOptions && componentOptions.children) { - componentOptions.children = cloneVNodes(componentOptions.children, true); - } - } - return cloned -} - -function cloneVNodes (vnodes, deep) { - var len = vnodes.length; - var res = new Array(len); - for (var i = 0; i < len; i++) { - res[i] = cloneVNode(vnodes[i], deep); - } - return res -} - -/* - * not type checking this file because flow doesn't play well with - * dynamically accessing methods on Array prototype - */ - -var arrayProto = Array.prototype; -var arrayMethods = Object.create(arrayProto);[ - 'push', - 'pop', - 'shift', - 'unshift', - 'splice', - 'sort', - 'reverse' -].forEach(function (method) { - // cache original method - var original = arrayProto[method]; - def(arrayMethods, method, function mutator () { - var args = [], len = arguments.length; - while ( len-- ) args[ len ] = arguments[ len ]; - - var result = original.apply(this, args); - var ob = this.__ob__; - var inserted; - switch (method) { - case 'push': - case 'unshift': - inserted = args; - break - case 'splice': - inserted = args.slice(2); - break - } - if (inserted) { ob.observeArray(inserted); } - // notify change - ob.dep.notify(); - return result - }); -}); - -/* */ - -var arrayKeys = Object.getOwnPropertyNames(arrayMethods); - -/** - * By default, when a reactive property is set, the new value is - * also converted to become reactive. However when passing down props, - * we don't want to force conversion because the value may be a nested value - * under a frozen data structure. Converting it would defeat the optimization. - */ -var observerState = { - shouldConvert: true -}; - -/** - * Observer class that are attached to each observed - * object. Once attached, the observer converts target - * object's property keys into getter/setters that - * collect dependencies and dispatches updates. - */ -var Observer = function Observer (value) { - this.value = value; - this.dep = new Dep(); - this.vmCount = 0; - def(value, '__ob__', this); - if (Array.isArray(value)) { - var augment = hasProto - ? protoAugment - : copyAugment; - augment(value, arrayMethods, arrayKeys); - this.observeArray(value); - } else { - this.walk(value); - } -}; - -/** - * Walk through each property and convert them into - * getter/setters. This method should only be called when - * value type is Object. - */ -Observer.prototype.walk = function walk (obj) { - var keys = Object.keys(obj); - for (var i = 0; i < keys.length; i++) { - defineReactive(obj, keys[i], obj[keys[i]]); - } -}; - -/** - * Observe a list of Array items. - */ -Observer.prototype.observeArray = function observeArray (items) { - for (var i = 0, l = items.length; i < l; i++) { - observe(items[i]); - } -}; - -// helpers - -/** - * Augment an target Object or Array by intercepting - * the prototype chain using __proto__ - */ -function protoAugment (target, src, keys) { - /* eslint-disable no-proto */ - target.__proto__ = src; - /* eslint-enable no-proto */ -} - -/** - * Augment an target Object or Array by defining - * hidden properties. - */ -/* istanbul ignore next */ -function copyAugment (target, src, keys) { - for (var i = 0, l = keys.length; i < l; i++) { - var key = keys[i]; - def(target, key, src[key]); - } -} - -/** - * Attempt to create an observer instance for a value, - * returns the new observer if successfully observed, - * or the existing observer if the value already has one. - */ -function observe (value, asRootData) { - if (!isObject(value) || value instanceof VNode) { - return - } - var ob; - if (hasOwn(value, '__ob__') && value.__ob__ instanceof Observer) { - ob = value.__ob__; - } else if ( - observerState.shouldConvert && - !isServerRendering() && - (Array.isArray(value) || isPlainObject(value)) && - Object.isExtensible(value) && - !value._isVue - ) { - ob = new Observer(value); - } - if (asRootData && ob) { - ob.vmCount++; - } - return ob -} - -/** - * Define a reactive property on an Object. - */ -function defineReactive ( - obj, - key, - val, - customSetter, - shallow -) { - var dep = new Dep(); - - var property = Object.getOwnPropertyDescriptor(obj, key); - if (property && property.configurable === false) { - return - } - - // cater for pre-defined getter/setters - var getter = property && property.get; - var setter = property && property.set; - - var childOb = !shallow && observe(val); - Object.defineProperty(obj, key, { - enumerable: true, - configurable: true, - get: function reactiveGetter () { - var value = getter ? getter.call(obj) : val; - if (Dep.target) { - dep.depend(); - if (childOb) { - childOb.dep.depend(); - if (Array.isArray(value)) { - dependArray(value); - } - } - } - return value - }, - set: function reactiveSetter (newVal) { - var value = getter ? getter.call(obj) : val; - /* eslint-disable no-self-compare */ - if (newVal === value || (newVal !== newVal && value !== value)) { - return - } - /* eslint-enable no-self-compare */ - if ("development" !== 'production' && customSetter) { - customSetter(); - } - if (setter) { - setter.call(obj, newVal); - } else { - val = newVal; - } - childOb = !shallow && observe(newVal); - dep.notify(); - } - }); -} - -/** - * Set a property on an object. Adds the new property and - * triggers change notification if the property doesn't - * already exist. - */ -function set (target, key, val) { - if (Array.isArray(target) && isValidArrayIndex(key)) { - target.length = Math.max(target.length, key); - target.splice(key, 1, val); - return val - } - if (key in target && !(key in Object.prototype)) { - target[key] = val; - return val - } - var ob = (target).__ob__; - if (target._isVue || (ob && ob.vmCount)) { - "development" !== 'production' && warn( - 'Avoid adding reactive properties to a Vue instance or its root $data ' + - 'at runtime - declare it upfront in the data option.' - ); - return val - } - if (!ob) { - target[key] = val; - return val - } - defineReactive(ob.value, key, val); - ob.dep.notify(); - return val -} - -/** - * Delete a property and trigger change if necessary. - */ -function del (target, key) { - if (Array.isArray(target) && isValidArrayIndex(key)) { - target.splice(key, 1); - return - } - var ob = (target).__ob__; - if (target._isVue || (ob && ob.vmCount)) { - "development" !== 'production' && warn( - 'Avoid deleting properties on a Vue instance or its root $data ' + - '- just set it to null.' - ); - return - } - if (!hasOwn(target, key)) { - return - } - delete target[key]; - if (!ob) { - return - } - ob.dep.notify(); -} - -/** - * Collect dependencies on array elements when the array is touched, since - * we cannot intercept array element access like property getters. - */ -function dependArray (value) { - for (var e = (void 0), i = 0, l = value.length; i < l; i++) { - e = value[i]; - e && e.__ob__ && e.__ob__.dep.depend(); - if (Array.isArray(e)) { - dependArray(e); - } - } -} - -/* */ - -/** - * Option overwriting strategies are functions that handle - * how to merge a parent option value and a child option - * value into the final value. - */ -var strats = config.optionMergeStrategies; - -/** - * Options with restrictions - */ -{ - strats.el = strats.propsData = function (parent, child, vm, key) { - if (!vm) { - warn( - "option \"" + key + "\" can only be used during instance " + - 'creation with the `new` keyword.' - ); - } - return defaultStrat(parent, child) - }; -} - -/** - * Helper that recursively merges two data objects together. - */ -function mergeData (to, from) { - if (!from) { return to } - var key, toVal, fromVal; - var keys = Object.keys(from); - for (var i = 0; i < keys.length; i++) { - key = keys[i]; - toVal = to[key]; - fromVal = from[key]; - if (!hasOwn(to, key)) { - set(to, key, fromVal); - } else if (isPlainObject(toVal) && isPlainObject(fromVal)) { - mergeData(toVal, fromVal); - } - } - return to -} - -/** - * Data - */ -function mergeDataOrFn ( - parentVal, - childVal, - vm -) { - if (!vm) { - // in a Vue.extend merge, both should be functions - if (!childVal) { - return parentVal - } - if (!parentVal) { - return childVal - } - // when parentVal & childVal are both present, - // we need to return a function that returns the - // merged result of both functions... no need to - // check if parentVal is a function here because - // it has to be a function to pass previous merges. - return function mergedDataFn () { - return mergeData( - typeof childVal === 'function' ? childVal.call(this, this) : childVal, - typeof parentVal === 'function' ? parentVal.call(this, this) : parentVal - ) - } - } else { - return function mergedInstanceDataFn () { - // instance merge - var instanceData = typeof childVal === 'function' - ? childVal.call(vm, vm) - : childVal; - var defaultData = typeof parentVal === 'function' - ? parentVal.call(vm, vm) - : parentVal; - if (instanceData) { - return mergeData(instanceData, defaultData) - } else { - return defaultData - } - } - } -} - -strats.data = function ( - parentVal, - childVal, - vm -) { - if (!vm) { - if (childVal && typeof childVal !== 'function') { - "development" !== 'production' && warn( - 'The "data" option should be a function ' + - 'that returns a per-instance value in component ' + - 'definitions.', - vm - ); - - return parentVal - } - return mergeDataOrFn(parentVal, childVal) - } - - return mergeDataOrFn(parentVal, childVal, vm) -}; - -/** - * Hooks and props are merged as arrays. - */ -function mergeHook ( - parentVal, - childVal -) { - return childVal - ? parentVal - ? parentVal.concat(childVal) - : Array.isArray(childVal) - ? childVal - : [childVal] - : parentVal -} - -LIFECYCLE_HOOKS.forEach(function (hook) { - strats[hook] = mergeHook; -}); - -/** - * Assets - * - * When a vm is present (instance creation), we need to do - * a three-way merge between constructor options, instance - * options and parent options. - */ -function mergeAssets ( - parentVal, - childVal, - vm, - key -) { - var res = Object.create(parentVal || null); - if (childVal) { - "development" !== 'production' && assertObjectType(key, childVal, vm); - return extend(res, childVal) - } else { - return res - } -} - -ASSET_TYPES.forEach(function (type) { - strats[type + 's'] = mergeAssets; -}); - -/** - * Watchers. - * - * Watchers hashes should not overwrite one - * another, so we merge them as arrays. - */ -strats.watch = function ( - parentVal, - childVal, - vm, - key -) { - // work around Firefox's Object.prototype.watch... - if (parentVal === nativeWatch) { parentVal = undefined; } - if (childVal === nativeWatch) { childVal = undefined; } - /* istanbul ignore if */ - if (!childVal) { return Object.create(parentVal || null) } - { - assertObjectType(key, childVal, vm); - } - if (!parentVal) { return childVal } - var ret = {}; - extend(ret, parentVal); - for (var key$1 in childVal) { - var parent = ret[key$1]; - var child = childVal[key$1]; - if (parent && !Array.isArray(parent)) { - parent = [parent]; - } - ret[key$1] = parent - ? parent.concat(child) - : Array.isArray(child) ? child : [child]; - } - return ret -}; - -/** - * Other object hashes. - */ -strats.props = -strats.methods = -strats.inject = -strats.computed = function ( - parentVal, - childVal, - vm, - key -) { - if (childVal && "development" !== 'production') { - assertObjectType(key, childVal, vm); - } - if (!parentVal) { return childVal } - var ret = Object.create(null); - extend(ret, parentVal); - if (childVal) { extend(ret, childVal); } - return ret -}; -strats.provide = mergeDataOrFn; - -/** - * Default strategy. - */ -var defaultStrat = function (parentVal, childVal) { - return childVal === undefined - ? parentVal - : childVal -}; - -/** - * Validate component names - */ -function checkComponents (options) { - for (var key in options.components) { - validateComponentName(key); - } -} - -function validateComponentName (name) { - if (!/^[a-zA-Z][\w-]*$/.test(name)) { - warn( - 'Invalid component name: "' + name + '". Component names ' + - 'can only contain alphanumeric characters and the hyphen, ' + - 'and must start with a letter.' - ); - } - if (isBuiltInTag(name) || config.isReservedTag(name)) { - warn( - 'Do not use built-in or reserved HTML elements as component ' + - 'id: ' + name - ); - } -} - -/** - * Ensure all props option syntax are normalized into the - * Object-based format. - */ -function normalizeProps (options, vm) { - var props = options.props; - if (!props) { return } - var res = {}; - var i, val, name; - if (Array.isArray(props)) { - i = props.length; - while (i--) { - val = props[i]; - if (typeof val === 'string') { - name = camelize(val); - res[name] = { type: null }; - } else { - warn('props must be strings when using array syntax.'); - } - } - } else if (isPlainObject(props)) { - for (var key in props) { - val = props[key]; - name = camelize(key); - res[name] = isPlainObject(val) - ? val - : { type: val }; - } - } else { - warn( - "Invalid value for option \"props\": expected an Array or an Object, " + - "but got " + (toRawType(props)) + ".", - vm - ); - } - options.props = res; -} - -/** - * Normalize all injections into Object-based format - */ -function normalizeInject (options, vm) { - var inject = options.inject; - if (!inject) { return } - var normalized = options.inject = {}; - if (Array.isArray(inject)) { - for (var i = 0; i < inject.length; i++) { - normalized[inject[i]] = { from: inject[i] }; - } - } else if (isPlainObject(inject)) { - for (var key in inject) { - var val = inject[key]; - normalized[key] = isPlainObject(val) - ? extend({ from: key }, val) - : { from: val }; - } - } else { - warn( - "Invalid value for option \"inject\": expected an Array or an Object, " + - "but got " + (toRawType(inject)) + ".", - vm - ); - } -} - -/** - * Normalize raw function directives into object format. - */ -function normalizeDirectives (options) { - var dirs = options.directives; - if (dirs) { - for (var key in dirs) { - var def = dirs[key]; - if (typeof def === 'function') { - dirs[key] = { bind: def, update: def }; - } - } - } -} - -function assertObjectType (name, value, vm) { - if (!isPlainObject(value)) { - warn( - "Invalid value for option \"" + name + "\": expected an Object, " + - "but got " + (toRawType(value)) + ".", - vm - ); - } -} - -/** - * Merge two option objects into a new one. - * Core utility used in both instantiation and inheritance. - */ -function mergeOptions ( - parent, - child, - vm -) { - { - checkComponents(child); - } - - if (typeof child === 'function') { - child = child.options; - } - - normalizeProps(child, vm); - normalizeInject(child, vm); - normalizeDirectives(child); - var extendsFrom = child.extends; - if (extendsFrom) { - parent = mergeOptions(parent, extendsFrom, vm); - } - if (child.mixins) { - for (var i = 0, l = child.mixins.length; i < l; i++) { - parent = mergeOptions(parent, child.mixins[i], vm); - } - } - var options = {}; - var key; - for (key in parent) { - mergeField(key); - } - for (key in child) { - if (!hasOwn(parent, key)) { - mergeField(key); - } - } - function mergeField (key) { - var strat = strats[key] || defaultStrat; - options[key] = strat(parent[key], child[key], vm, key); - } - return options -} - -/** - * Resolve an asset. - * This function is used because child instances need access - * to assets defined in its ancestor chain. - */ -function resolveAsset ( - options, - type, - id, - warnMissing -) { - /* istanbul ignore if */ - if (typeof id !== 'string') { - return - } - var assets = options[type]; - // check local registration variations first - if (hasOwn(assets, id)) { return assets[id] } - var camelizedId = camelize(id); - if (hasOwn(assets, camelizedId)) { return assets[camelizedId] } - var PascalCaseId = capitalize(camelizedId); - if (hasOwn(assets, PascalCaseId)) { return assets[PascalCaseId] } - // fallback to prototype chain - var res = assets[id] || assets[camelizedId] || assets[PascalCaseId]; - if ("development" !== 'production' && warnMissing && !res) { - warn( - 'Failed to resolve ' + type.slice(0, -1) + ': ' + id, - options - ); - } - return res -} - -/* */ - -function validateProp ( - key, - propOptions, - propsData, - vm -) { - var prop = propOptions[key]; - var absent = !hasOwn(propsData, key); - var value = propsData[key]; - // handle boolean props - if (isType(Boolean, prop.type)) { - if (absent && !hasOwn(prop, 'default')) { - value = false; - } else if (!isType(String, prop.type) && (value === '' || value === hyphenate(key))) { - value = true; - } - } - // check default value - if (value === undefined) { - value = getPropDefaultValue(vm, prop, key); - // since the default value is a fresh copy, - // make sure to observe it. - var prevShouldConvert = observerState.shouldConvert; - observerState.shouldConvert = true; - observe(value); - observerState.shouldConvert = prevShouldConvert; - } - { - assertProp(prop, key, value, vm, absent); - } - return value -} - -/** - * Get the default value of a prop. - */ -function getPropDefaultValue (vm, prop, key) { - // no default, return undefined - if (!hasOwn(prop, 'default')) { - return undefined - } - var def = prop.default; - // warn against non-factory defaults for Object & Array - if ("development" !== 'production' && isObject(def)) { - warn( - 'Invalid default value for prop "' + key + '": ' + - 'Props with type Object/Array must use a factory function ' + - 'to return the default value.', - vm - ); - } - // the raw prop value was also undefined from previous render, - // return previous default value to avoid unnecessary watcher trigger - if (vm && vm.$options.propsData && - vm.$options.propsData[key] === undefined && - vm._props[key] !== undefined - ) { - return vm._props[key] - } - // call factory function for non-Function types - // a value is Function if its prototype is function even across different execution context - return typeof def === 'function' && getType(prop.type) !== 'Function' - ? def.call(vm) - : def -} - -/** - * Assert whether a prop is valid. - */ -function assertProp ( - prop, - name, - value, - vm, - absent -) { - if (prop.required && absent) { - warn( - 'Missing required prop: "' + name + '"', - vm - ); - return - } - if (value == null && !prop.required) { - return - } - var type = prop.type; - var valid = !type || type === true; - var expectedTypes = []; - if (type) { - if (!Array.isArray(type)) { - type = [type]; - } - for (var i = 0; i < type.length && !valid; i++) { - var assertedType = assertType(value, type[i]); - expectedTypes.push(assertedType.expectedType || ''); - valid = assertedType.valid; - } - } - if (!valid) { - warn( - "Invalid prop: type check failed for prop \"" + name + "\"." + - " Expected " + (expectedTypes.map(capitalize).join(', ')) + - ", got " + (toRawType(value)) + ".", - vm - ); - return - } - var validator = prop.validator; - if (validator) { - if (!validator(value)) { - warn( - 'Invalid prop: custom validator check failed for prop "' + name + '".', - vm - ); - } - } -} - -var simpleCheckRE = /^(String|Number|Boolean|Function|Symbol)$/; - -function assertType (value, type) { - var valid; - var expectedType = getType(type); - if (simpleCheckRE.test(expectedType)) { - var t = typeof value; - valid = t === expectedType.toLowerCase(); - // for primitive wrapper objects - if (!valid && t === 'object') { - valid = value instanceof type; - } - } else if (expectedType === 'Object') { - valid = isPlainObject(value); - } else if (expectedType === 'Array') { - valid = Array.isArray(value); - } else { - valid = value instanceof type; - } - return { - valid: valid, - expectedType: expectedType - } -} - -/** - * Use function string name to check built-in types, - * because a simple equality check will fail when running - * across different vms / iframes. - */ -function getType (fn) { - var match = fn && fn.toString().match(/^\s*function (\w+)/); - return match ? match[1] : '' -} - -function isType (type, fn) { - if (!Array.isArray(fn)) { - return getType(fn) === getType(type) - } - for (var i = 0, len = fn.length; i < len; i++) { - if (getType(fn[i]) === getType(type)) { - return true - } - } - /* istanbul ignore next */ - return false -} - -/* */ - -function handleError (err, vm, info) { - if (vm) { - var cur = vm; - while ((cur = cur.$parent)) { - var hooks = cur.$options.errorCaptured; - if (hooks) { - for (var i = 0; i < hooks.length; i++) { - try { - var capture = hooks[i].call(cur, err, vm, info) === false; - if (capture) { return } - } catch (e) { - globalHandleError(e, cur, 'errorCaptured hook'); - } - } - } - } - } - globalHandleError(err, vm, info); -} - -function globalHandleError (err, vm, info) { - if (config.errorHandler) { - try { - return config.errorHandler.call(null, err, vm, info) - } catch (e) { - logError(e, null, 'config.errorHandler'); - } - } - logError(err, vm, info); -} - -function logError (err, vm, info) { - { - warn(("Error in " + info + ": \"" + (err.toString()) + "\""), vm); - } - /* istanbul ignore else */ - if ((inBrowser || inWeex) && typeof console !== 'undefined') { - console.error(err); - } else { - throw err - } -} - -/* */ -/* globals MessageChannel */ - -var callbacks = []; -var pending = false; - -function flushCallbacks () { - pending = false; - var copies = callbacks.slice(0); - callbacks.length = 0; - for (var i = 0; i < copies.length; i++) { - copies[i](); - } -} - -// Here we have async deferring wrappers using both micro and macro tasks. -// In < 2.4 we used micro tasks everywhere, but there are some scenarios where -// micro tasks have too high a priority and fires in between supposedly -// sequential events (e.g. #4521, #6690) or even between bubbling of the same -// event (#6566). However, using macro tasks everywhere also has subtle problems -// when state is changed right before repaint (e.g. #6813, out-in transitions). -// Here we use micro task by default, but expose a way to force macro task when -// needed (e.g. in event handlers attached by v-on). -var microTimerFunc; -var macroTimerFunc; -var useMacroTask = false; - -// Determine (macro) Task defer implementation. -// Technically setImmediate should be the ideal choice, but it's only available -// in IE. The only polyfill that consistently queues the callback after all DOM -// events triggered in the same loop is by using MessageChannel. -/* istanbul ignore if */ -if (typeof setImmediate !== 'undefined' && isNative(setImmediate)) { - macroTimerFunc = function () { - setImmediate(flushCallbacks); - }; -} else if (typeof MessageChannel !== 'undefined' && ( - isNative(MessageChannel) || - // PhantomJS - MessageChannel.toString() === '[object MessageChannelConstructor]' -)) { - var channel = new MessageChannel(); - var port = channel.port2; - channel.port1.onmessage = flushCallbacks; - macroTimerFunc = function () { - port.postMessage(1); - }; -} else { - /* istanbul ignore next */ - macroTimerFunc = function () { - setTimeout(flushCallbacks, 0); - }; -} - -// Determine MicroTask defer implementation. -/* istanbul ignore next, $flow-disable-line */ -if (typeof Promise !== 'undefined' && isNative(Promise)) { - var p = Promise.resolve(); - microTimerFunc = function () { - p.then(flushCallbacks); - // in problematic UIWebViews, Promise.then doesn't completely break, but - // it can get stuck in a weird state where callbacks are pushed into the - // microtask queue but the queue isn't being flushed, until the browser - // needs to do some other work, e.g. handle a timer. Therefore we can - // "force" the microtask queue to be flushed by adding an empty timer. - if (isIOS) { setTimeout(noop); } - }; -} else { - // fallback to macro - microTimerFunc = macroTimerFunc; -} - -/** - * Wrap a function so that if any code inside triggers state change, - * the changes are queued using a Task instead of a MicroTask. - */ -function withMacroTask (fn) { - return fn._withTask || (fn._withTask = function () { - useMacroTask = true; - var res = fn.apply(null, arguments); - useMacroTask = false; - return res - }) -} - -function nextTick (cb, ctx) { - var _resolve; - callbacks.push(function () { - if (cb) { - try { - cb.call(ctx); - } catch (e) { - handleError(e, ctx, 'nextTick'); - } - } else if (_resolve) { - _resolve(ctx); - } - }); - if (!pending) { - pending = true; - if (useMacroTask) { - macroTimerFunc(); - } else { - microTimerFunc(); - } - } - // $flow-disable-line - if (!cb && typeof Promise !== 'undefined') { - return new Promise(function (resolve) { - _resolve = resolve; - }) - } -} - -/* */ - -var mark; -var measure; - -{ - var perf = inBrowser && window.performance; - /* istanbul ignore if */ - if ( - perf && - perf.mark && - perf.measure && - perf.clearMarks && - perf.clearMeasures - ) { - mark = function (tag) { return perf.mark(tag); }; - measure = function (name, startTag, endTag) { - perf.measure(name, startTag, endTag); - perf.clearMarks(startTag); - perf.clearMarks(endTag); - perf.clearMeasures(name); - }; - } -} - -/* not type checking this file because flow doesn't play well with Proxy */ - -var initProxy; - -{ - var allowedGlobals = makeMap( - 'Infinity,undefined,NaN,isFinite,isNaN,' + - 'parseFloat,parseInt,decodeURI,decodeURIComponent,encodeURI,encodeURIComponent,' + - 'Math,Number,Date,Array,Object,Boolean,String,RegExp,Map,Set,JSON,Intl,' + - 'require' // for Webpack/Browserify - ); - - var warnNonPresent = function (target, key) { - warn( - "Property or method \"" + key + "\" is not defined on the instance but " + - 'referenced during render. Make sure that this property is reactive, ' + - 'either in the data option, or for class-based components, by ' + - 'initializing the property. ' + - 'See: https://vuejs.org/v2/guide/reactivity.html#Declaring-Reactive-Properties.', - target - ); - }; - - var hasProxy = - typeof Proxy !== 'undefined' && - Proxy.toString().match(/native code/); - - if (hasProxy) { - var isBuiltInModifier = makeMap('stop,prevent,self,ctrl,shift,alt,meta,exact'); - config.keyCodes = new Proxy(config.keyCodes, { - set: function set (target, key, value) { - if (isBuiltInModifier(key)) { - warn(("Avoid overwriting built-in modifier in config.keyCodes: ." + key)); - return false - } else { - target[key] = value; - return true - } - } - }); - } - - var hasHandler = { - has: function has (target, key) { - var has = key in target; - var isAllowed = allowedGlobals(key) || key.charAt(0) === '_'; - if (!has && !isAllowed) { - warnNonPresent(target, key); - } - return has || !isAllowed - } - }; - - var getHandler = { - get: function get (target, key) { - if (typeof key === 'string' && !(key in target)) { - warnNonPresent(target, key); - } - return target[key] - } - }; - - initProxy = function initProxy (vm) { - if (hasProxy) { - // determine which proxy handler to use - var options = vm.$options; - var handlers = options.render && options.render._withStripped - ? getHandler - : hasHandler; - vm._renderProxy = new Proxy(vm, handlers); - } else { - vm._renderProxy = vm; - } - }; -} - -/* */ - -var seenObjects = new _Set(); - -/** - * Recursively traverse an object to evoke all converted - * getters, so that every nested property inside the object - * is collected as a "deep" dependency. - */ -function traverse (val) { - _traverse(val, seenObjects); - seenObjects.clear(); -} - -function _traverse (val, seen) { - var i, keys; - var isA = Array.isArray(val); - if ((!isA && !isObject(val)) || Object.isFrozen(val)) { - return - } - if (val.__ob__) { - var depId = val.__ob__.dep.id; - if (seen.has(depId)) { - return - } - seen.add(depId); - } - if (isA) { - i = val.length; - while (i--) { _traverse(val[i], seen); } - } else { - keys = Object.keys(val); - i = keys.length; - while (i--) { _traverse(val[keys[i]], seen); } - } -} - -/* */ - -var normalizeEvent = cached(function (name) { - var passive = name.charAt(0) === '&'; - name = passive ? name.slice(1) : name; - var once$$1 = name.charAt(0) === '~'; // Prefixed last, checked first - name = once$$1 ? name.slice(1) : name; - var capture = name.charAt(0) === '!'; - name = capture ? name.slice(1) : name; - return { - name: name, - once: once$$1, - capture: capture, - passive: passive - } -}); - -function createFnInvoker (fns) { - function invoker () { - var arguments$1 = arguments; - - var fns = invoker.fns; - if (Array.isArray(fns)) { - var cloned = fns.slice(); - for (var i = 0; i < cloned.length; i++) { - cloned[i].apply(null, arguments$1); - } - } else { - // return handler return value for single handlers - return fns.apply(null, arguments) - } - } - invoker.fns = fns; - return invoker -} - -function updateListeners ( - on, - oldOn, - add, - remove$$1, - vm -) { - var name, def, cur, old, event; - for (name in on) { - def = cur = on[name]; - old = oldOn[name]; - event = normalizeEvent(name); - /* istanbul ignore if */ - if (isUndef(cur)) { - "development" !== 'production' && warn( - "Invalid handler for event \"" + (event.name) + "\": got " + String(cur), - vm - ); - } else if (isUndef(old)) { - if (isUndef(cur.fns)) { - cur = on[name] = createFnInvoker(cur); - } - add(event.name, cur, event.once, event.capture, event.passive, event.params); - } else if (cur !== old) { - old.fns = cur; - on[name] = old; - } - } - for (name in oldOn) { - if (isUndef(on[name])) { - event = normalizeEvent(name); - remove$$1(event.name, oldOn[name], event.capture); - } - } -} - -/* */ - -function mergeVNodeHook (def, hookKey, hook) { - if (def instanceof VNode) { - def = def.data.hook || (def.data.hook = {}); - } - var invoker; - var oldHook = def[hookKey]; - - function wrappedHook () { - hook.apply(this, arguments); - // important: remove merged hook to ensure it's called only once - // and prevent memory leak - remove(invoker.fns, wrappedHook); - } - - if (isUndef(oldHook)) { - // no existing hook - invoker = createFnInvoker([wrappedHook]); - } else { - /* istanbul ignore if */ - if (isDef(oldHook.fns) && isTrue(oldHook.merged)) { - // already a merged invoker - invoker = oldHook; - invoker.fns.push(wrappedHook); - } else { - // existing plain hook - invoker = createFnInvoker([oldHook, wrappedHook]); - } - } - - invoker.merged = true; - def[hookKey] = invoker; -} - -/* */ - -function extractPropsFromVNodeData ( - data, - Ctor, - tag -) { - // we are only extracting raw values here. - // validation and default values are handled in the child - // component itself. - var propOptions = Ctor.options.props; - if (isUndef(propOptions)) { - return - } - var res = {}; - var attrs = data.attrs; - var props = data.props; - if (isDef(attrs) || isDef(props)) { - for (var key in propOptions) { - var altKey = hyphenate(key); - { - var keyInLowerCase = key.toLowerCase(); - if ( - key !== keyInLowerCase && - attrs && hasOwn(attrs, keyInLowerCase) - ) { - tip( - "Prop \"" + keyInLowerCase + "\" is passed to component " + - (formatComponentName(tag || Ctor)) + ", but the declared prop name is" + - " \"" + key + "\". " + - "Note that HTML attributes are case-insensitive and camelCased " + - "props need to use their kebab-case equivalents when using in-DOM " + - "templates. You should probably use \"" + altKey + "\" instead of \"" + key + "\"." - ); - } - } - checkProp(res, props, key, altKey, true) || - checkProp(res, attrs, key, altKey, false); - } - } - return res -} - -function checkProp ( - res, - hash, - key, - altKey, - preserve -) { - if (isDef(hash)) { - if (hasOwn(hash, key)) { - res[key] = hash[key]; - if (!preserve) { - delete hash[key]; - } - return true - } else if (hasOwn(hash, altKey)) { - res[key] = hash[altKey]; - if (!preserve) { - delete hash[altKey]; - } - return true - } - } - return false -} - -/* */ - -// The template compiler attempts to minimize the need for normalization by -// statically analyzing the template at compile time. -// -// For plain HTML markup, normalization can be completely skipped because the -// generated render function is guaranteed to return Array. There are -// two cases where extra normalization is needed: - -// 1. When the children contains components - because a functional component -// may return an Array instead of a single root. In this case, just a simple -// normalization is needed - if any child is an Array, we flatten the whole -// thing with Array.prototype.concat. It is guaranteed to be only 1-level deep -// because functional components already normalize their own children. -function simpleNormalizeChildren (children) { - for (var i = 0; i < children.length; i++) { - if (Array.isArray(children[i])) { - return Array.prototype.concat.apply([], children) - } - } - return children -} - -// 2. When the children contains constructs that always generated nested Arrays, -// e.g.