diff --git a/aircox/admin/base.py b/aircox/admin/base.py index 56c7577..2a05917 100644 --- a/aircox/admin/base.py +++ b/aircox/admin/base.py @@ -84,8 +84,8 @@ class StationAdmin(admin.ModelAdmin): @admin.register(Log) class LogAdmin(admin.ModelAdmin): - list_display = ['id', 'date', 'station', 'source', 'type', 'comment', 'diffusion', 'sound', 'track'] - list_filter = ['date', 'source', 'diffusion', 'sound', 'track'] + 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 1ed5d59..977905f 100644 --- a/aircox/admin/diffusion.py +++ b/aircox/admin/diffusion.py @@ -34,7 +34,7 @@ class DiffusionAdmin(admin.ModelAdmin): conflicts_count.short_description = _('Conflicts') def start_date(self, obj): - return obj.local_date.strftime('%Y/%m/%d %H:%M') + return obj.local_start.strftime('%Y/%m/%d %H:%M') start_date.short_description = _('start') def end_date(self, obj): diff --git a/aircox/admin/playlist.py b/aircox/admin/playlist.py index d3c6ca1..0d760b6 100644 --- a/aircox/admin/playlist.py +++ b/aircox/admin/playlist.py @@ -18,11 +18,10 @@ class TracksInline(SortableInlineAdminMixin, admin.TabularInline): @admin.register(Track) class TrackAdmin(admin.ModelAdmin): - # TODO: url to filter by tag 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'] + list_display = ['pk', 'artist', 'title', 'tag_list', 'diffusion', 'sound', 'timestamp'] list_editable = ['artist', 'title'] list_filter = ['sound', 'diffusion', 'artist', 'title', 'tags'] fieldsets = [ diff --git a/aircox/management/commands/archiver.py b/aircox/management/commands/archiver.py index 9ab545e..06893cf 100644 --- a/aircox/management/commands/archiver.py +++ b/aircox/management/commands/archiver.py @@ -16,14 +16,14 @@ logger = logging.getLogger('aircox.tools') class Command (BaseCommand): - help= __doc__ + help = __doc__ - def add_arguments (self, parser): - parser.formatter_class=RawTextHelpFormatter + def add_arguments(self, parser): + parser.formatter_class = RawTextHelpFormatter group = parser.add_argument_group('actions') group.add_argument( '-a', '--age', type=int, - default = settings.AIRCOX_LOGS_ARCHIVES_MIN_AGE, + default=settings.AIRCOX_LOGS_ARCHIVES_MIN_AGE, help='minimal age in days of logs to archive. Default is ' 'settings.AIRCOX_LOGS_ARCHIVES_MIN_AGE' ) @@ -36,22 +36,21 @@ class Command (BaseCommand): help='keep logs in database instead of deleting them' ) - def handle (self, *args, age, force, keep, **options): - date = tz.now() - tz.timedelta(days = age) + def handle(self, *args, age, force, keep, **options): + date = tz.now() - tz.timedelta(days=age) while True: date = date.replace( - hour = 0, minute = 0, second = 0, microsecond = 0 + hour=0, minute=0, second=0, microsecond=0 ) logger.info('archive log at date %s', date) for station in Station.objects.all(): Log.objects.make_archive( - station, date, force = force, keep = keep + station, date, force=force, keep=keep ) - qs = Log.objects.filter(date__lt = date) + qs = Log.objects.filter(date__lt=date) if not qs.exists(): break date = qs.order_by('-date').first().date - diff --git a/aircox/management/commands/diffusions.py b/aircox/management/commands/diffusions.py index d52f3fc..53baa62 100755 --- a/aircox/management/commands/diffusions.py +++ b/aircox/management/commands/diffusions.py @@ -15,6 +15,7 @@ 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 logging from argparse import RawTextHelpFormatter @@ -25,53 +26,52 @@ from aircox.models import * logger = logging.getLogger('aircox.tools') -import time class Actions: @classmethod - def update (cl, date, mode): + def update(cl, date, mode): manual = (mode == 'manual') count = [0, 0] - for schedule in Schedule.objects.filter(program__active = True) \ + 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) + items = schedule.diffusions_of_month(date, exclude_saved=True) count[0] += len(items) # 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 manual or conflicts.count() else \ + Diffusion.Type.normal + item.save(no_check=True) if conflicts.count(): item.conflicts.set(conflicts.all()) logger.info('[update] schedule %s: %d new diffusions', - str(schedule), len(items), - ) + str(schedule), len(items), + ) logger.info('[update] %d diffusions have been created, %s', count[0], - 'do not forget manual approval' if manual else - '{} conflicts found'.format(count[1])) + 'do not forget manual approval' if manual else + '{} conflicts found'.format(count[1])) @staticmethod - def clean (date): - qs = Diffusion.objects.filter(type = Diffusion.Type.unconfirmed, - start__lt = date) + def clean(date): + qs = Diffusion.objects.filter(type=Diffusion.Type.unconfirmed, + start__lt=date) logger.info('[clean] %d diffusions will be removed', qs.count()) qs.delete() @staticmethod def check(date): - qs = Diffusion.objects.filter(type = Diffusion.Type.unconfirmed, - start__gt = date) + qs = Diffusion.objects.filter(type=Diffusion.Type.unconfirmed, + start__gt=date) items = [] for diffusion in qs: - schedules = Schedule.objects.filter(program = diffusion.program) + schedules = Schedule.objects.filter(program=diffusion.program) for schedule in schedules: if schedule.match(diffusion.start): break @@ -80,14 +80,14 @@ class Actions: logger.info('[check] %d diffusions will be removed', len(items)) if len(items): - Diffusion.objects.filter(id__in = items).delete() + Diffusion.objects.filter(id__in=items).delete() class Command(BaseCommand): - help= __doc__ + help = __doc__ - def add_arguments (self, parser): - parser.formatter_class=RawTextHelpFormatter + def add_arguments(self, parser): + parser.formatter_class = RawTextHelpFormatter now = tz.datetime.today() group = parser.add_argument_group('action') @@ -130,23 +130,22 @@ class Command(BaseCommand): '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) + def handle(self, *args, **options): + date = tz.datetime(year=options.get('year'), + month=options.get('month'), + day=1) date = tz.make_aware(date) if options.get('next_month'): month = options.get('month') - date += tz.timedelta(days = 28) + date += tz.timedelta(days=28) if date.month == month: - date += tz.timedelta(days = 28) + date += tz.timedelta(days=28) - date = date.replace(day = 1) + date = date.replace(day=1) if options.get('update'): - Actions.update(date, mode = options.get('mode')) + Actions.update(date, mode=options.get('mode')) if options.get('clean'): Actions.clean(date) if options.get('check'): Actions.check(date) - diff --git a/aircox/management/commands/import_playlist.py b/aircox/management/commands/import_playlist.py index af0cd65..91bf382 100755 --- a/aircox/management/commands/import_playlist.py +++ b/aircox/management/commands/import_playlist.py @@ -20,7 +20,7 @@ from django.contrib.contenttypes.models import ContentType from aircox.models import * import aircox.settings as settings -__doc__ = __doc__.format(settings = settings) +__doc__ = __doc__.format(settings=settings) logger = logging.getLogger('aircox.tools') @@ -78,8 +78,8 @@ class Importer: return try: timestamp = int(line.get('minutes') or 0) * 60 + \ - int(line.get('seconds') or 0) \ - if has_timestamp else None + int(line.get('seconds') or 0) \ + if has_timestamp else None track, created = Track.objects.get_or_create( title=line.get('title'), @@ -88,6 +88,7 @@ class Importer: **self.track_kwargs ) track.timestamp = timestamp + print('track', track, timestamp) track.info = line.get('info') tags = line.get('tags') if tags: @@ -96,7 +97,7 @@ class Importer: logger.warning( 'an error occured for track {index}, it may not ' 'have been saved: {err}' - .format(index = index, err=err) + .format(index=index, err=err) ) continue @@ -107,10 +108,10 @@ class Importer: class Command (BaseCommand): - help= __doc__ + help = __doc__ def add_arguments(self, parser): - parser.formatter_class=RawTextHelpFormatter + parser.formatter_class = RawTextHelpFormatter parser.add_argument( 'path', metavar='PATH', type=str, help='path of the input playlist to read' @@ -125,7 +126,7 @@ class Command (BaseCommand): help='try to get the diffusion relative to the sound if it exists' ) - def handle (self, path, *args, **options): + def handle(self, path, *args, **options): # FIXME: absolute/relative path of sounds vs given path if options.get('sound'): sound = Sound.objects.filter( @@ -136,7 +137,7 @@ class Command (BaseCommand): sound = Sound.objects.filter(path__icontains=path_).first() if not sound: - logger.error('no sound found in the database for the path ' \ + logger.error('no sound found in the database for the path ' '{path}'.format(path=path)) return @@ -148,4 +149,3 @@ class Command (BaseCommand): 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_quality_check.py b/aircox/management/commands/sounds_quality_check.py index d512a36..b25b8d4 100755 --- a/aircox/management/commands/sounds_quality_check.py +++ b/aircox/management/commands/sounds_quality_check.py @@ -11,6 +11,7 @@ from django.core.management.base import BaseCommand, CommandError logger = logging.getLogger('aircox.tools') + class Stats: attributes = [ 'DC offset', 'Min level', 'Max level', @@ -18,7 +19,7 @@ class Stats: 'RMS Tr dB', 'Flat factor', 'Length s', ] - def __init__ (self, path, **kwargs): + def __init__(self, path, **kwargs): """ If path is given, call analyse with path and kwargs """ @@ -26,10 +27,10 @@ class Stats: if path: self.analyse(path, **kwargs) - def get (self, attr): + def get(self, attr): return self.values.get(attr) - def parse (self, output): + def parse(self, output): for attr in Stats.attributes: value = re.search(attr + r'\s+(?P\S+)', output) value = value and value.groupdict() @@ -41,14 +42,14 @@ class Stats: self.values[attr] = value self.values['length'] = self.values['Length s'] - def analyse (self, path, at = None, length = None): + def analyse(self, path, at=None, length=None): """ If at and length are given use them as excerpt to analyse. """ args = ['sox', path, '-n'] if at is not None and length is not None: - args += ['trim', str(at), str(length) ] + args += ['trim', str(at), str(length)] args.append('stats') @@ -66,17 +67,17 @@ class Sound: bad = None # list of bad samples good = None # list of good samples - def __init__ (self, path, sample_length = None): + def __init__(self, path, sample_length=None): self.path = path self.sample_length = sample_length if sample_length is not None \ - else self.sample_length + else self.sample_length - def get_file_stats (self): + def get_file_stats(self): return self.stats and self.stats[0] - def analyse (self): + def analyse(self): logger.info('complete file analysis') - self.stats = [ Stats(self.path) ] + self.stats = [Stats(self.path)] position = 0 length = self.stats[0].get('length') @@ -85,21 +86,22 @@ class Sound: logger.info('start samples analysis...') while position < length: - stats = Stats(self.path, at = position, length = self.sample_length) + stats = Stats(self.path, at=position, length=self.sample_length) self.stats.append(stats) position += self.sample_length - def check (self, name, min_val, max_val): - self.good = [ index for index, stats in enumerate(self.stats) - if min_val <= stats.get(name) <= max_val ] - self.bad = [ index for index, stats in enumerate(self.stats) - if index not in self.good ] + def check(self, name, min_val, max_val): + self.good = [index for index, stats in enumerate(self.stats) + if min_val <= stats.get(name) <= max_val] + self.bad = [index for index, stats in enumerate(self.stats) + if index not in self.good] self.resume() - def resume (self): - view = lambda array: [ + def resume(self): + def view(array): return [ 'file' if index is 0 else - 'sample {} (at {} seconds)'.format(index, (index-1) * self.sample_length) + 'sample {} (at {} seconds)'.format( + index, (index-1) * self.sample_length) for index in array ] @@ -110,12 +112,13 @@ class Sound: logger.info(self.path + ' -> bad: \033[91m%s\033[0m', ', '.join(view(self.bad))) + class Command (BaseCommand): help = __doc__ sounds = None - def add_arguments (self, parser): - parser.formatter_class=RawTextHelpFormatter + def add_arguments(self, parser): + parser.formatter_class = RawTextHelpFormatter parser.add_argument( 'files', metavar='FILE', type=str, nargs='+', @@ -128,12 +131,12 @@ class Command (BaseCommand): ) parser.add_argument( '-a', '--attribute', type=str, - help='attribute name to use to check, that can be:\n' + \ - ', '.join([ '"{}"'.format(attr) for attr in Stats.attributes ]) + help='attribute name to use to check, that can be:\n' + + ', '.join(['"{}"'.format(attr) for attr in Stats.attributes]) ) parser.add_argument( '-r', '--range', type=float, nargs=2, - help='range of minimal and maximal accepted value such as: ' \ + help='range of minimal and maximal accepted value such as: ' '--range min max' ) parser.add_argument( @@ -141,7 +144,7 @@ class Command (BaseCommand): help='print a resume of good and bad files' ) - def handle (self, *args, **options): + def handle(self, *args, **options): # parameters minmax = options.get('range') if not minmax: @@ -152,8 +155,8 @@ class Command (BaseCommand): raise CommandError('no attribute specified') # sound analyse and checks - self.sounds = [ Sound(path, options.get('sample_length')) - for path in options.get('files') ] + self.sounds = [Sound(path, options.get('sample_length')) + for path in options.get('files')] self.bad = [] self.good = [] for sound in self.sounds: @@ -171,4 +174,3 @@ class Command (BaseCommand): logger.info('\033[92m+ %s\033[0m', sound.path) for sound in self.bad: logger.info('\033[91m+ %s\033[0m', sound.path) - diff --git a/aircox/management/commands/streamer.py b/aircox/management/commands/streamer.py index 132fff7..4017132 100755 --- a/aircox/management/commands/streamer.py +++ b/aircox/management/commands/streamer.py @@ -6,6 +6,7 @@ used to: - cancels Diffusions that have an archive but could not have been played; - run Liquidsoap """ +import tzlocal import time import re @@ -28,7 +29,6 @@ tz.activate(pytz.UTC) # FIXME liquidsoap does not manage timezones -- we have to convert # 'on_air' metadata we get from it into utc one in order to work # correctly. -import tzlocal local_tz = tzlocal.get_localzone() @@ -118,10 +118,12 @@ class Monitor: self.handle() def log(self, date=None, **kwargs): - """ - Create a log using **kwargs, and print info - """ + """ Create a log using **kwargs, and print info """ log = Log(station=self.station, date=date or tz.now(), **kwargs) + if log.type == Log.Type.on_air and log.diffusion is None: + log.collision = Diffusion.objects.station(log.station) \ + .on_air().at(log.date).first() + log.save() log.print() return log @@ -153,7 +155,7 @@ class Monitor: # check for reruns if not diff.is_date_in_range(air_time) and not diff.initial: diff = Diffusion.objects.at(air_time) \ - .filter(initial=diff).first() + .on_air().filter(initial=diff).first() # log sound on air return self.log( @@ -170,14 +172,14 @@ class Monitor: if log.diffusion: return - tracks = Track.objects.filter(sound=log.sound, timestamp_isnull=False) + tracks = Track.objects.filter(sound=log.sound, timestamp__isnull=False) if not tracks.exists(): return tracks = tracks.exclude(log__station=self.station, log__pk__gt=log.pk) now = tz.now() for track in tracks: - pos = log.date + tz.timedelta(seconds=track.position) + pos = log.date + tz.timedelta(seconds=track.timestamp) if pos > now: break # log track on air @@ -195,14 +197,14 @@ class Monitor: if self.sync_next and self.sync_next < now: return - self.sync_next = now + tz.timedelta(seconds = self.sync_timeout) + self.sync_next = now + tz.timedelta(seconds=self.sync_timeout) for source in self.station.sources: if source == self.station.dealer: continue playlist = source.program.sound_set.all() \ .filter(type=Sound.Type.archive) \ - .values_list('path', flat = True) + .values_list('path', flat=True) source.playlist = list(playlist) def trace_canceled(self): @@ -214,24 +216,24 @@ class Monitor: return qs = Diffusions.objects.station(self.station).at().filter( - type = Diffusion.Type.normal, - sound__type = Sound.Type.archive, + type=Diffusion.Type.normal, + sound__type=Sound.Type.archive, ) logs = Log.objects.station(station).on_air().with_diff() - date = tz.now() - datetime.timedelta(seconds = self.cancel_timeout) + date = tz.now() - datetime.timedelta(seconds=self.cancel_timeout) for diff in qs: - if logs.filter(diffusion = diff): + if logs.filter(diffusion=diff): continue if diff.start < now: diff.type = Diffusion.Type.canceled diff.save() # log canceled diffusion self.log( - type = Log.Type.other, - diffusion = diff, - comment = 'Diffusion canceled after {} seconds' \ - .format(self.cancel_timeout) + type=Log.Type.other, + diffusion=diff, + comment='Diffusion canceled after {} seconds' + .format(self.cancel_timeout) ) def __current_diff(self): @@ -251,7 +253,7 @@ class Monitor: # last sound source change: end of file reached or forced to stop sounds = Log.objects.station(station).on_air().with_sound() \ - .filter(date__gte = log.date) \ + .filter(date__gte=log.date) \ .order_by('date') if sounds.count() and sounds.last().source != log.source: @@ -259,12 +261,12 @@ class Monitor: # last diff is still playing: get remaining playlist sounds = sounds \ - .filter(source = log.source, pk__gt = log.pk) \ - .exclude(sound__type = Sound.Type.removed) + .filter(source=log.source, pk__gt=log.pk) \ + .exclude(sound__type=Sound.Type.removed) - remaining = log.diffusion.get_sounds(archive = True) \ - .exclude(pk__in = sounds) \ - .values_list('path', flat = True) + remaining = log.diffusion.get_sounds(archive=True) \ + .exclude(pk__in=sounds) \ + .values_list('path', flat=True) return log.diffusion, list(remaining) def __next_diff(self, diff): @@ -273,16 +275,14 @@ class Monitor: If diff is given, it is the one to be played right after it. """ station = self.station - - kwargs = {'start__gte': diff.end } if diff else {} - kwargs['type'] = Diffusion.Type.normal - - qs = Diffusion.objects.station(station).at().filter(**kwargs) \ + kwargs = {'start__gte': diff.end} if diff else {} + qs = Diffusion.objects.station(station) \ + .on_air().at().filter(**kwargs) \ .distinct().order_by('start') diff = qs.first() - return (diff, diff and diff.get_playlist(archive = True) or []) + return (diff, diff and diff.get_playlist(archive=True) or []) - def handle_pl_sync(self, source, playlist, diff = None, date = None): + def handle_pl_sync(self, source, playlist, diff=None, date=None): """ Update playlist of a source if required, and handle logging when it is needed. @@ -297,11 +297,11 @@ class Monitor: source.playlist = playlist if diff and not diff.is_live(): # log diffusion archive load - self.log(type = Log.Type.load, - source = source.id, - diffusion = diff, - date = date, - comment = '\n'.join(playlist)) + self.log(type=Log.Type.load, + source=source.id, + diffusion=diff, + date=date, + comment='\n'.join(playlist)) def handle_diff_start(self, source, diff, date): """ @@ -318,11 +318,11 @@ class Monitor: # live: just log it if diff.is_live(): diff_ = Log.objects.station(self.station) \ - .filter(diffusion = diff, type = Log.Type.on_air) + .filter(diffusion=diff, type=Log.Type.on_air) if not diff_.count(): # log live diffusion - self.log(type = Log.Type.on_air, source = source.id, - diffusion = diff, date = date) + self.log(type=Log.Type.on_air, source=source.id, + diffusion=diff, date=date) return # enable dealer @@ -331,8 +331,8 @@ class Monitor: last_start = self.last_diff_start if not last_start or last_start.diffusion_id != diff.pk: # log triggered diffusion - self.log(type = Log.Type.start, source = source.id, - diffusion = diff, date = date) + self.log(type=Log.Type.start, source=source.id, + diffusion=diff, date=date) def handle(self): """ @@ -358,10 +358,10 @@ class Monitor: class Command (BaseCommand): - help= __doc__ + help = __doc__ - def add_arguments (self, parser): - parser.formatter_class=RawTextHelpFormatter + def add_arguments(self, parser): + parser.formatter_class = RawTextHelpFormatter group = parser.add_argument_group('actions') group.add_argument( '-c', '--config', action='store_true', @@ -396,25 +396,25 @@ class Command (BaseCommand): 'check' ) - def handle (self, *args, - config = None, run = None, monitor = None, - station = [], delay = 1000, timeout = 600, - **options): + def handle(self, *args, + config=None, run=None, monitor=None, + station=[], delay=1000, timeout=600, + **options): - stations = Station.objects.filter(name__in = station)[:] \ - if station else Station.objects.all()[:] + stations = Station.objects.filter(name__in=station)[:] \ + if station else Station.objects.all()[:] for station in stations: # station.prepare() - if config and not run: # no need to write it twice + if config and not run: # no need to write it twice station.streamer.push() if run: station.streamer.process_run() if monitor: monitors = [ - Monitor(station, cancel_timeout = timeout) - for station in stations + Monitor(station, cancel_timeout=timeout) + for station in stations ] delay = delay / 1000 while True: @@ -425,4 +425,3 @@ class Command (BaseCommand): if run: for station in stations: station.controller.process_wait() - diff --git a/aircox/models.py b/aircox/models.py index dc62ab8..cb7114e 100755 --- a/aircox/models.py +++ b/aircox/models.py @@ -11,6 +11,7 @@ from django.contrib.contenttypes.fields import (GenericForeignKey, GenericRelation) from django.contrib.contenttypes.models import ContentType from django.db import models +from django.db.models import Q from django.db.transaction import atomic from django.template.defaultfilters import slugify from django.utils import timezone as tz @@ -119,9 +120,7 @@ class Station(Nameable): @property def outputs(self): - """ - Return all active output ports of the station - """ + """ Return all active output ports of the station """ return self.port_set.filter( direction=Port.Direction.output, active=True, @@ -129,9 +128,7 @@ class Station(Nameable): @property def sources(self): - """ - Audio sources, dealer included - """ + """ Audio sources, dealer included """ self.__prepare_controls() return self.__sources @@ -168,7 +165,7 @@ class Station(Nameable): # FIXME can be a potential source of bug if date: - date = utils.cast_date(date, to_datetime=False) + date = utils.cast_date(date, datetime.date) if date and date > datetime.date.today(): return [] @@ -185,18 +182,17 @@ class Station(Nameable): start__lte=now) \ .order_by('-start')[:count] - q = models.Q(diffusion__isnull=False) | \ - models.Q(track__isnull=False) + q = Q(diffusion__isnull=False) | Q(track__isnull=False) logs = logs.station(self).on_air().filter(q).order_by('-date') # filter out tracks played when there was a diffusion - n, q = 0, models.Q() + n, q = 0, Q() for diff in diffs: if count and n >= count: break # FIXME: does not catch tracks started before diff end but # that continued afterwards - q = q | models.Q(date__gte=diff.start, date__lte=diff.end) + q = q | Q(date__gte=diff.start, date__lte=diff.end) n += 1 logs = logs.exclude(q, diffusion__isnull=True) if count: @@ -411,15 +407,11 @@ class Schedule(models.Model): Program, models.CASCADE, verbose_name=_('related program'), ) - time = models.TimeField( - _('time'), - blank=True, null=True, - help_text=_('start time'), - ) date = models.DateField( - _('date'), - blank=True, null=True, - help_text=_('date of the first diffusion'), + _('date'), help_text=_('date of the first diffusion'), + ) + time = models.TimeField( + _('time'), help_text=_('start time'), ) timezone = models.CharField( _('timezone'), @@ -462,6 +454,12 @@ class Schedule(models.Model): return pytz.timezone(self.timezone) + @property + def datetime(self): + """ Datetime for this schedule (timezone unaware) """ + import datetime + return datetime.datetime.combine(self.date, self.time) + # initial cached data __initial = None @@ -481,22 +479,18 @@ class Schedule(models.Model): def match(self, date=None, check_time=True): """ - Return True if the given datetime matches the schedule + Return True if the given date(time) matches the schedule. """ - date = utils.date_or_default(date) + date = utils.date_or_default( + date, tz.datetime if check_time else datetime.date) if self.date.weekday() != date.weekday() or \ not self.match_week(date): - return False - if not check_time: - return True - # we check against a normalized version (norm_date will have # schedule's date. - - return date == self.normalize(date) + return date == self.normalize(date) if check_time else True def match_week(self, date=None): """ @@ -509,15 +503,14 @@ class Schedule(models.Model): return False # since we care only about the week, go to the same day of the week - date = utils.date_or_default(date) + date = utils.date_or_default(date, datetime.date) date += tz.timedelta(days=self.date.weekday() - date.weekday()) # FIXME this case if self.frequency == Schedule.Frequency.one_on_two: # cf notes in date_of_month - diff = utils.cast_date(date, False) - \ - utils.cast_date(self.date, False) + diff = date - utils.cast_date(self.date, datetime.date) return not (diff.days % 14) @@ -556,8 +549,7 @@ class Schedule(models.Model): return [] # first day of month - date = utils.date_or_default(date, to_datetime=False) \ - .replace(day=1) + date = utils.date_or_default(date, datetime.date).replace(day=1) freq = self.frequency # last of the month @@ -588,8 +580,8 @@ class Schedule(models.Model): if freq == Schedule.Frequency.one_on_two: # check date base on a diff of dates base on a 14 days delta - diff = utils.cast_date(date, False) - \ - utils.cast_date(self.date, False) + diff = utils.cast_date(date, datetime.date) - \ + utils.cast_date(self.date, datetime.date) if diff.days % 14: date += tz.timedelta(days=7) @@ -636,13 +628,17 @@ class Schedule(models.Model): # new diffusions duration = utils.to_timedelta(self.duration) + delta = None if self.initial: - delta = self.date - self.initial.date + delta = self.datetime - self.initial.datetime + + # FIXME: daylight saving bug: delta misses an hour when diffusion and + # rerun are not on the same daylight-saving timezone diffusions += [ Diffusion( program=self.program, type=Diffusion.Type.unconfirmed, - initial=Diffusion.objects.filter(start=date - delta).first() + initial=Diffusion.objects.program(self.program).filter(start=date-delta).first() if self.initial else None, start=date, end=date + duration, @@ -685,7 +681,10 @@ class DiffusionQuerySet(models.QuerySet): def program(self, program): return self.filter(program=program) - def at(self, date=None, next=False, **kwargs): + def on_air(self): + return self.filter(type=Diffusion.Type.normal) + + def at(self, date=None): """ Return diffusions occuring at the given date, ordered by +start @@ -694,12 +693,9 @@ class DiffusionQuerySet(models.QuerySet): it as a date, and get diffusions that occurs this day. When date is None, uses tz.now(). - - When next is true, include diffusions that also occur after - the given moment. """ # note: we work with localtime - date = utils.date_or_default(date, keep_type=True) + date = utils.date_or_default(date) qs = self filters = None @@ -708,43 +704,39 @@ class DiffusionQuerySet(models.QuerySet): # use datetime: we want diffusion that occurs around this # range filters = {'start__lte': date, 'end__gte': date} - - if next: - qs = qs.filter( - models.Q(start__gte=date) | models.Q(**filters) - ) - else: - qs = qs.filter(**filters) + qs = qs.filter(**filters) else: # use date: we want diffusions that occurs this day - start, end = utils.date_range(date) - filters = models.Q(start__gte=start, start__lte=end) | \ - models.Q(end__gt=start, end__lt=end) - - if next: - # include also diffusions of the next day - filters |= models.Q(start__gte=start) - qs = qs.filter(filters, **kwargs) - + qs = qs.filter(Q(start__date=date) | Q(end__date=date)) return qs.order_by('start').distinct() - def after(self, date=None, **kwargs): + def after(self, date=None): """ Return a queryset of diffusions that happen after the given - date. - """ - date = utils.date_or_default(date, keep_type=True) - - return self.filter(start__gte=date, **kwargs).order_by('start') - - def before(self, date=None, **kwargs): - """ - Return a queryset of diffusions that finish before the given - date. + date (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') - return self.filter(end__lte=date, **kwargs).order_by('start') + def before(self, date=None): + """ + Return a queryset of diffusions that finish before the given + date (default: today). + """ + date = utils.date_or_default(date) + if isinstance(date, tz.datetime): + qs = self.filter(start__lt=date) + else: + qs = self.filter(start__date__lt=date) + return qs.order_by('start') + + def range(self, start, end): + # FIXME can return dates that are out of range... + return self.after(start).before(end) class Diffusion(models.Model): @@ -813,21 +805,19 @@ class Diffusion(models.Model): @property def date(self): - """ - Alias to self.start - """ + """ Return diffusion start as a date. """ - return self.start + return utils.cast_date(self.start) @cached_property - def local_date(self): + 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.date, tz.get_current_timezone()) + return tz.localtime(self.start, tz.get_current_timezone()) @property def local_end(self): @@ -892,10 +882,8 @@ class Diffusion(models.Model): """ return Diffusion.objects.filter( - models.Q(start__lt=self.start, - end__gt=self.start) | - models.Q(start__gt=self.start, - start__lt=self.end) + 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): @@ -929,7 +917,7 @@ class Diffusion(models.Model): def __str__(self): return '{self.program.name} {date} #{self.pk}'.format( - self=self, date=self.local_date.strftime('%Y/%m/%d %H:%M%z') + self=self, date=self.local_start.strftime('%Y/%m/%d %H:%M%z') ) class Meta: @@ -1298,11 +1286,8 @@ class LogQuerySet(models.QuerySet): return self.filter(station=station) def at(self, date=None): - start, end = utils.date_range(date) - # return qs.filter(models.Q(end__gte = start) | - # models.Q(date__lte = end)) - - return self.filter(date__gte=start, date__lte=end) + date = utils.date_or_default(date) + return self.filter(date__date=date) def on_air(self): return self.filter(type=Log.Type.on_air) @@ -1508,6 +1493,13 @@ class Log(models.Model): verbose_name=_('Track'), ) + collision = models.ForeignKey( + Diffusion, on_delete=models.SET_NULL, + blank=True, null=True, + verbose_name=_('Collision'), + related_name='+', + ) + objects = LogQuerySet.as_manager() @property diff --git a/aircox/utils.py b/aircox/utils.py index 38c212a..dd5835b 100755 --- a/aircox/utils.py +++ b/aircox/utils.py @@ -4,68 +4,59 @@ import django.utils.timezone as tz def date_range(date): """ - Return a range of datetime for a given day, such as: - [date, 0:0:0:0; date, 23:59:59:999] - - Ensure timezone awareness. + Return a datetime range for a given day, as: + ```(date, 0:0:0:0; date, 23:59:59:999)```. """ - date = date_or_default(date) - range = ( - date.replace(hour = 0, minute = 0, second = 0), \ - date.replace(hour = 23, minute = 59, second = 59, microsecond = 999) + date = date_or_default(date, tz.datetime) + return ( + date.replace(hour=0, minute=0, second=0), + date.replace(hour=23, minute=59, second=59, microsecond=999) ) - return range -def cast_date(date, to_datetime = True): + +def cast_date(date, into=datetime.date): """ - Given a date reset its time information and - return it as a date or datetime object. - - Ensure timezone awareness. + Cast a given date into the provided class' instance. Make datetime + aware of timezone. """ - if to_datetime: - return tz.make_aware( - tz.datetime(date.year, date.month, date.day, 0, 0, 0, 0) - ) - return datetime.date(date.year, date.month, date.day) + date = into(date.year, date.month, date.day) + return tz.make_aware(date) if issubclass(into, tz.datetime) else date -def date_or_default(date, reset_time = False, keep_type = False, to_datetime = True): + +def date_or_default(date, into=None): """ - Return datetime or default value (now) if not defined, and remove time info - if reset_time is True. - - \param reset_time reset time info to 0 - \param keep_type keep the same type of the given date if not None - \param to_datetime force conversion to datetime if not keep_type - - Ensure timezone awareness. + Return date if not None, otherwise return now. Cast result into provided + type if any. """ - date = date or tz.now() - to_datetime = isinstance(date, tz.datetime) if keep_type else to_datetime + date = date if date is not None else datetime.date.today() \ + if into is not None and issubclass(into, datetime.date) else \ + tz.datetime.now() - if reset_time or not isinstance(date, tz.datetime): - return cast_date(date, to_datetime) + if into is not None: + date = cast_date(date, into) - if not tz.is_aware(date): + if isinstance(date, tz.datetime) and not tz.is_aware(date): date = tz.make_aware(date) return date -def to_timedelta (time): + +def to_timedelta(time): """ Transform a datetime or a time instance to a timedelta, only using time info """ return datetime.timedelta( - hours = time.hour, - minutes = time.minute, - seconds = time.second + hours=time.hour, + minutes=time.minute, + seconds=time.second ) -def seconds_to_time (seconds): + +def seconds_to_time(seconds): """ Seconds to datetime.time """ minutes, seconds = divmod(seconds, 60) hours, minutes = divmod(minutes, 60) - return datetime.time(hour = hours, minute = minutes, second = seconds) + return datetime.time(hour=hours, minute=minutes, second=seconds) diff --git a/aircox_cms/models/__init__.py b/aircox_cms/models/__init__.py index 2159219..2a7f53b 100755 --- a/aircox_cms/models/__init__.py +++ b/aircox_cms/models/__init__.py @@ -793,10 +793,10 @@ 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') + verbose_name=_('station'), + on_delete=models.SET_NULL, + null=True, blank=True, + help_text=_('(required) related station') ) content_panels = DatedListPage.content_panels + [ diff --git a/aircox_web/admin.py b/aircox_web/admin.py index a9f3405..5beceb7 100644 --- a/aircox_web/admin.py +++ b/aircox_web/admin.py @@ -15,6 +15,8 @@ from aircox.admin.mixins import UnrelatedInlineMixin @admin.register(models.Site) class SiteAdmin(ContentEditor): + list_display = ['title', 'station'] + inlines = [ ContentEditorInline.create(models.SiteRichText), ContentEditorInline.create(models.SiteImage), @@ -37,14 +39,26 @@ class PageDiffusionPlaylist(UnrelatedInlineMixin, TracksInline): @admin.register(models.Page) -class PageAdmin(ContentEditor): +class PageAdmin(admin.ModelAdmin): list_display = ["title", "parent", "status"] + list_editable = ['status'] prepopulated_fields = {"slug": ("title",)} - # readonly_fields = ('diffusion',) fieldsets = ( (_('Main'), { - 'fields': ['title', 'slug', 'as_program', 'headline'], + 'fields': ['title', 'slug'] + }), + (_('Settings'), { + 'fields': ['status', 'static_path', 'path'], + }), + ) + + +@admin.register(models.Article) +class ArticleAdmin(ContentEditor, PageAdmin): + fieldsets = ( + (_('Main'), { + 'fields': ['title', 'slug', 'as_program', 'cover', 'headline'], 'classes': ('tabbed', 'uncollapse') }), (_('Settings'), { @@ -59,36 +73,27 @@ class PageAdmin(ContentEditor): ) inlines = [ - ContentEditorInline.create(models.PageRichText), - ContentEditorInline.create(models.PageImage), + ContentEditorInline.create(models.ArticleRichText), + ContentEditorInline.create(models.ArticleImage), ] @admin.register(models.DiffusionPage) -class DiffusionPageAdmin(PageAdmin): - fieldsets = copy.deepcopy(PageAdmin.fieldsets) +class DiffusionPageAdmin(ArticleAdmin): + fieldsets = copy.deepcopy(ArticleAdmin.fieldsets) fieldsets[1][1]['fields'].insert(0, 'diffusion') - inlines = PageAdmin.inlines + [ - PageDiffusionPlaylist - ] - # TODO: permissions - #def get_inline_instances(self, request, obj=None): - # inlines = super().get_inline_instances(request, obj) - # if obj and obj.diffusion: - # inlines.insert(0, PageDiffusionPlaylist(self.model, self.admin_site)) - # return inlines + def get_inline_instances(self, request, obj=None): + inlines = super().get_inline_instances(request, obj) + if obj and obj.diffusion: + inlines.insert(0, PageDiffusionPlaylist(self.model, self.admin_site)) + return inlines @admin.register(models.ProgramPage) -class DiffusionPageAdmin(PageAdmin): - fieldsets = copy.deepcopy(PageAdmin.fieldsets) +class ProgramPageAdmin(ArticleAdmin): + fieldsets = copy.deepcopy(ArticleAdmin.fieldsets) fieldsets[1][1]['fields'].insert(0, 'program') - inlines = PageAdmin.inlines + [ - PageDiffusionPlaylist - ] - - diff --git a/aircox_web/assets/index.js b/aircox_web/assets/index.js index 17c2705..a52ee43 100644 --- a/aircox_web/assets/index.js +++ b/aircox_web/assets/index.js @@ -1,3 +1,5 @@ import './js'; import './styles.scss'; +import './noscript.scss'; +import './vue'; diff --git a/aircox_web/assets/js/index.js b/aircox_web/assets/js/index.js index 206ef10..215a588 100644 --- a/aircox_web/assets/js/index.js +++ b/aircox_web/assets/js/index.js @@ -3,9 +3,12 @@ import Buefy from 'buefy'; Vue.use(Buefy); -var app = new Vue({ - el: '#app', -}) +window.addEventListener('load', () => { + var app = new Vue({ + el: '#app', + delimiters: [ '[[', ']]' ], + }) +}); diff --git a/aircox_web/assets/styles.scss b/aircox_web/assets/styles.scss index b3a8e42..db63020 100644 --- a/aircox_web/assets/styles.scss +++ b/aircox_web/assets/styles.scss @@ -10,9 +10,12 @@ $body-background-color: $light; } .navbar.has-shadow { - box-shadow: 0em 0.1em 0.5em rgba(0,0,0,0.1); + box-shadow: 0em 0.05em 0.5em rgba(0,0,0,0.1); } +/* + + .navbar-brand img { min-height: 6em; } @@ -20,5 +23,22 @@ $body-background-color: $light; .navbar-menu .navbar-item:not(:last-child) { border-right: 1px $grey solid; } +*/ +/** page **/ +img.cover { + border: 0.2em black solid; +} + + +.headline { + font-size: 1.2em; + padding: 0.2em 0em; +} + +img.cover { + float: right; + max-width: 40%; +} + diff --git a/aircox_web/assets/vue/index.js b/aircox_web/assets/vue/index.js new file mode 100644 index 0000000..a9ea751 --- /dev/null +++ b/aircox_web/assets/vue/index.js @@ -0,0 +1,11 @@ +import Vue from 'vue'; + +import Tab from './tab.vue'; +import Tabs from './tabs.vue'; + +Vue.component('a-tab', Tab); +Vue.component('a-tabs', Tabs); + +export {Tab, Tabs}; + + diff --git a/aircox_web/assets/vue/tab.vue b/aircox_web/assets/vue/tab.vue new file mode 100644 index 0000000..86b6f21 --- /dev/null +++ b/aircox_web/assets/vue/tab.vue @@ -0,0 +1,31 @@ + + + + diff --git a/aircox_web/assets/vue/tabs.vue b/aircox_web/assets/vue/tabs.vue new file mode 100644 index 0000000..42d9657 --- /dev/null +++ b/aircox_web/assets/vue/tabs.vue @@ -0,0 +1,45 @@ + + + + + diff --git a/aircox_web/converters.py b/aircox_web/converters.py new file mode 100644 index 0000000..89198f1 --- /dev/null +++ b/aircox_web/converters.py @@ -0,0 +1,49 @@ +import datetime + +from django.utils.safestring import mark_safe +from django.urls.converters import StringConverter + + +class PagePathConverter(StringConverter): + """ Match path for pages, including surrounding slashes. """ + regex = r'/?|([-_a-zA-Z0-9]+/)*?' + + def to_python(self, value): + if not value or value[0] != '/': + value = '/' + value + if len(value) > 1 and value[-1] != '/': + value = value + '/' + return value + + def to_url(self, value): + if value[0] == '/': + value = value[1:] + if value[-1] != '/': + value = value + '/' + return mark_safe(value) + + +#class WeekConverter: +# """ Converter for date as YYYYY/WW """ +# regex = r'[0-9]{4}/[0-9]{2}/?' +# +# def to_python(self, value): +# value = value.split('/') +# return datetime.date(int(value[0]), int(value[1]), int(value[2])) +# +# def to_url(self, value): +# return '{:04d}/{:02d}/'.format(*value.isocalendar()) + + +class DateConverter: + """ Converter for date as YYYY/MM/DD """ + regex = r'[0-9]{4}/[0-9]{2}/[0-9]{2}/?' + + def to_python(self, value): + value = value.split('/') + return datetime.date(int(value[0]), int(value[1]), int(value[2])) + + def to_url(self, value): + return '{:04d}/{:02d}/{:02d}/'.format(value.year, value.month, + value.day) + diff --git a/aircox_web/models.py b/aircox_web/models.py index 0b8abe3..0abaec1 100644 --- a/aircox_web/models.py +++ b/aircox_web/models.py @@ -1,18 +1,19 @@ from django.core.validators import RegexValidator from django.db import models -from django.db.models import F +from django.db.models import F, Q from django.db.models.functions import Concat, Substr from django.utils.translation import ugettext_lazy as _ from content_editor.models import Region, create_plugin_base from model_utils.models import TimeStampedModel, StatusModel -from model_utils.managers import InheritanceManager +from model_utils.managers import InheritanceQuerySet from model_utils import Choices from filer.fields.image import FilerImageField from aircox import models as aircox from . import plugins +from .converters import PagePathConverter class Site(models.Model): @@ -70,12 +71,31 @@ class SiteLink(plugins.Link, SitePlugin): #----------------------------------------------------------------------- -class BasePage(StatusModel): - """ - Base abstract class for views whose url path is defined by users. - Page parenting is based on foreignkey to parent and page path. +class PageQueryset(InheritanceQuerySet): + def active(self): + return self.filter(Q(status=Page.STATUS.announced) | + Q(status=Page.STATUS.published)) - Inspired by Feincms3. + def descendants(self, page, direct=True, inclusive=True): + qs = self.filter(parent=page) if direct else \ + self.filter(path__startswith=page.path) + if not inclusive: + qs = qs.exclude(pk=page.pk) + return qs + + def ancestors(self, page, inclusive=True): + path, paths = page.path, [] + index = path.find('/') + while index != -1 and index+1 < len(path): + paths.append(path[0:index+1]) + index = path.find('/', index+1) + return self.filter(path__in=paths) + + +class Page(StatusModel): + """ + Base class for views whose url path can be defined by users. + Page parenting is based on foreignkey to parent and page path. """ STATUS = Choices('draft', 'announced', 'published') @@ -89,22 +109,22 @@ class BasePage(StatusModel): path = models.CharField( _("path"), max_length=1000, blank=True, db_index=True, unique=True, - validators=[ - RegexValidator( - regex=r"^/(|.+/)$", - message=_("Path must start and end with a slash (/)."), - ) - ], + validators=[RegexValidator( + regex=PagePathConverter.regex, + message=_('Path accepts alphanumeric and "_-" characters ' + 'and must be surrounded by "/"') + )], ) static_path = models.BooleanField( _('static path'), default=False, + # FIXME: help help_text=_('Update path using parent\'s page path and page title') ) + headline = models.TextField( + _('headline'), max_length=128, blank=True, null=True, + ) - objects = InheritanceManager() - - class Meta: - abstract = True + objects = PageQueryset.as_manager() def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -112,10 +132,14 @@ class BasePage(StatusModel): self._initial_parent = self.parent self._initial_slug = self.slug - def view(self, request, *args, **kwargs): + def get_view_class(self): + """ Page view class""" + raise NotImplementedError('not implemented') + + def view(self, request, *args, site=None, **kwargs): """ Page view function """ - from django.http import HttpResponse - return HttpResponse('Not implemented') + view = self.get_view_class().as_view(site=site, page=self) + return view(request, *args, **kwargs) def update_descendants(self): """ Update descendants pages' path if required. """ @@ -123,8 +147,10 @@ class BasePage(StatusModel): return # FIXME: draft -> draft children? - expr = Concat(self.path, Substr(F('path'), len(self._initial_path))) - BasePage.objects.filter(path__startswith=self._initial_path) \ + # FIXME: Page.objects (can't use Page since its an abstract model) + if len(self._initial_path): + expr = Concat('path', Substr(F('path'), len(self._initial_path))) + Page.objects.filter(path__startswith=self._initial_path) \ .update(path=expr) def sync_generations(self, update_descendants=True): @@ -141,13 +167,13 @@ class BasePage(StatusModel): if not self.title or not self.path or self.static_path and \ self.slug != self._initial_slug: - self.path = self.parent.path + '/' + self.slug \ + self.path = self.parent.path + self.slug \ if self.parent is not None else '/' + self.slug - if self.path[-1] != '/': - self.path += '/' if self.path[0] != '/': self.path = '/' + self.path + if self.path[-1] != '/': + self.path += '/' if update_descendants: self.update_descendants() @@ -155,18 +181,23 @@ class BasePage(StatusModel): self.sync_generations(update_descendants) super().save(*args, **kwargs) + def __str__(self): + return '{}: {}'.format(self._meta.verbose_name, + self.title or self.pk) -class Page(BasePage, TimeStampedModel): + +class Article(Page, TimeStampedModel): """ User's pages """ regions = [ - Region(key="main", title=_("Content")), + Region(key="content", title=_("Content")), ] # metadata as_program = models.ForeignKey( aircox.Program, models.SET_NULL, blank=True, null=True, related_name='published_pages', - limit_choices_to={'schedule__isnull': False}, + # SO#51948640 + # limit_choices_to={'schedule__isnull': False}, verbose_name=_('Show program as author'), help_text=_("Show program as author"), ) @@ -180,45 +211,41 @@ class Page(BasePage, TimeStampedModel): ) # content - headline = models.TextField( - _('headline'), max_length=128, blank=True, null=True, - ) cover = FilerImageField( on_delete=models.SET_NULL, null=True, blank=True, verbose_name=_('Cover'), ) def get_view_class(self): - from .views import PageView - return PageView - - def view(self, request, *args, **kwargs): - """ Page view function """ - view = self.get_view_class().as_view() - return view(request, *args, **kwargs) + from .views import ArticleView + return ArticleView -class DiffusionPage(Page): +class DiffusionPage(Article): diffusion = models.OneToOneField( aircox.Diffusion, models.CASCADE, - blank=True, null=True, + related_name='page', ) -class ProgramPage(Page): +class ProgramPage(Article): program = models.OneToOneField( aircox.Program, models.CASCADE, - blank=True, null=True, + related_name='page', ) + def get_view_class(self): + from .views import ProgramView + return ProgramView + #----------------------------------------------------------------------- -PagePlugin = create_plugin_base(Page) +ArticlePlugin = create_plugin_base(Article) -class PageRichText(plugins.RichText, PagePlugin): +class ArticleRichText(plugins.RichText, ArticlePlugin): pass -class PageImage(plugins.Image, PagePlugin): +class ArticleImage(plugins.Image, ArticlePlugin): pass diff --git a/aircox_web/package.json b/aircox_web/package.json index ae265b3..a62bfb3 100644 --- a/aircox_web/package.json +++ b/aircox_web/package.json @@ -18,6 +18,7 @@ "ttf-loader": "^1.0.2", "vue-loader": "^15.7.0", "vue-style-loader": "^4.1.2", + "vue-template-compiler": "^2.6.10", "webpack": "^4.32.2", "webpack-cli": "^3.3.2" }, diff --git a/aircox_web/renderer.py b/aircox_web/renderer.py index 0deae15..a8404be 100644 --- a/aircox_web/renderer.py +++ b/aircox_web/renderer.py @@ -13,6 +13,6 @@ site_renderer.register(SiteLink, lambda plugin: plugin.render()) page_renderer = PluginRenderer() page_renderer._renderers.clear() -page_renderer.register(PageRichText, lambda plugin: mark_safe(plugin.text)) -page_renderer.register(PageImage, lambda plugin: plugin.render()) +page_renderer.register(ArticleRichText, lambda plugin: mark_safe(plugin.text)) +page_renderer.register(ArticleImage, lambda plugin: plugin.render()) diff --git a/aircox_web/templates/aircox_web/article.html b/aircox_web/templates/aircox_web/article.html new file mode 100644 index 0000000..eee0760 --- /dev/null +++ b/aircox_web/templates/aircox_web/article.html @@ -0,0 +1,17 @@ +{% extends "aircox_web/page.html" %} + + +{% block main %} +
+ + {% block headline %} + {{ page.headline }} + {% endblock %} + + {% block content %} + {{ regions.main }} + {% endblock %} +
+ +{% endblock %} + diff --git a/aircox_web/templates/aircox_web/base.html b/aircox_web/templates/aircox_web/base.html deleted file mode 100644 index ec373e3..0000000 --- a/aircox_web/templates/aircox_web/base.html +++ /dev/null @@ -1,50 +0,0 @@ -{% load static i18n thumbnail %} - - - - - - - - - {% block assets %} - - - - - - {% endblock %} - - {% block title %}{{ site.title }}{% endblock %} - - {% block extra_head %}{% endblock %} - - - - -
- -
- {% block main %} -

{{ page.title }}

- {{ regions.main }} - {% endblock main %} -
-
- - - - diff --git a/aircox_web/templates/aircox_web/diffusion_item.html b/aircox_web/templates/aircox_web/diffusion_item.html new file mode 100644 index 0000000..a554e0d --- /dev/null +++ b/aircox_web/templates/aircox_web/diffusion_item.html @@ -0,0 +1,43 @@ +{% load i18n easy_thumbnails_tags aircox_web %} +{% comment %} +Context variables: +- object: the actual diffusion +- page: current parent page in which item is rendered +{% endcomment %} + +{% with page as context_page %} +{% with object.program as program %} +{% diffusion_page object as page %} +
+
+
+ +
+
+
+
+

+ {% if page and context_page != page %} + {{ page.title }} + {% else %} + {{ page.title|default:program.name }} + {% endif %} + {% if object.page is page %} + — {{ program.name }} + {% endif %} + {% if object.initial %} + {% with object.initial.date as date %} + + {% trans "rerun" %} + + {% endwith %} + {% endif %} +
+ {{ page.headline|default:program.page.headline }} +

+
+
+
+{% endwith %} +{% endwith %} + diff --git a/aircox_web/templates/aircox_web/diffusions.html b/aircox_web/templates/aircox_web/diffusions.html new file mode 100644 index 0000000..d1a0fec --- /dev/null +++ b/aircox_web/templates/aircox_web/diffusions.html @@ -0,0 +1,46 @@ +{% extends "aircox_web/page.html" %} +{% load i18n aircox_web %} + +{% block main %} +{{ block.super }} + +
+ {% for object in object_list %} +
+
+ +
+
+ {% include "aircox_web/diffusion_item.html" %} +
+
+ {% endfor %} + + +{% if is_paginated %} + +{% endif %} +
+{% endblock %} + diff --git a/aircox_web/templates/aircox_web/log_item.html b/aircox_web/templates/aircox_web/log_item.html new file mode 100644 index 0000000..90af6c7 --- /dev/null +++ b/aircox_web/templates/aircox_web/log_item.html @@ -0,0 +1,19 @@ +{% load i18n %} + +{% with object.track as track %} + +{{ track.title }} +{% with track.artist as artist %} +{% with track.info as info %} + + {% blocktrans %} + by {{ artist }} + {% endblocktrans %} + {% if info %} + ({% blocktrans %}{{ info }}{% endblocktrans %}) + {% endif %} + +{% endwith %} +{% endwith %} +{% endwith %} + diff --git a/aircox_web/templates/aircox_web/logs.html b/aircox_web/templates/aircox_web/logs.html new file mode 100644 index 0000000..94819f8 --- /dev/null +++ b/aircox_web/templates/aircox_web/logs.html @@ -0,0 +1,51 @@ +{% extends "aircox_web/page.html" %} +{% load i18n aircox_web %} + + +{% block main %} +{{ block.super }} + +
+ {% if dates %} + + {% endif %} + + {#

{{ date }}

#} + + {% for object in object_list reversed %} + + {% if object|is_diffusion %} + + + {% else %} + + + {% endif %} + + {% endfor %} +
+ + {% include "aircox_web/diffusion_item.html" %} + + {% include "aircox_web/log_item.html" %}
+
+{% endblock %} + diff --git a/aircox_web/templates/aircox_web/page.html b/aircox_web/templates/aircox_web/page.html index 85d4e79..f8ecb0d 100644 --- a/aircox_web/templates/aircox_web/page.html +++ b/aircox_web/templates/aircox_web/page.html @@ -1,8 +1,59 @@ -{% extends "aircox_web/base.html" %} +{% load static i18n thumbnail %} + + + + + + + -{% block title %}{{ page.title }} -- {{ block.super }}{% endblock %} + {% block assets %} + + + + {% endblock %} + + {% block title %}{% if title %}{{ title }} -- {% endif %}{{ site.title }}{% endblock %} + + {% block extra_head %}{% endblock %} + + +
+ + +
+
+ +
+
+ {% block header %} +

{{ title }}

+ {% endblock %} +
+ + {% block main %}{% endblock main %} +
+
+
+
+ + -{% block main %} -

{{ page.title }}

-{% endblock %} diff --git a/aircox_web/templates/aircox_web/program.html b/aircox_web/templates/aircox_web/program.html new file mode 100644 index 0000000..14a0cd1 --- /dev/null +++ b/aircox_web/templates/aircox_web/program.html @@ -0,0 +1,27 @@ +{% extends "aircox_web/article.html" %} +{% load i18n %} + +{% block headline %} +
+ {% for schedule in program.schedule_set.all %} +

+ {{ schedule.datetime|date:"l H:i" }} + + {{ schedule.get_frequency_display }} + {% if schedule.initial %} + {% with schedule.initial.date as date %} + + / {% trans "rerun" %} + + {% endwith %} + {% endif %} + +

+ {% endfor %} +
+ +{{ block.super }} + +{% endblock %} + + diff --git a/aircox_web/templates/aircox_web/timetable.html b/aircox_web/templates/aircox_web/timetable.html new file mode 100644 index 0000000..21b0f41 --- /dev/null +++ b/aircox_web/templates/aircox_web/timetable.html @@ -0,0 +1,52 @@ +{% extends "aircox_web/page.html" %} +{% load i18n aircox_web %} + +{% block main %} +{{ block.super }} + +
+

+ {% blocktrans %}From {{ start }} to {{ end }}{% endblocktrans %} +

+ + {% unique_id "timetable" as timetable_id %} + + + + + +
+{% endblock %} + diff --git a/aircox_web/templatetags/aircox_web.py b/aircox_web/templatetags/aircox_web.py new file mode 100644 index 0000000..8d4051f --- /dev/null +++ b/aircox_web/templatetags/aircox_web.py @@ -0,0 +1,28 @@ +import random + +from django import template + +from aircox import models as aircox +from aircox_web.models import Page + +random.seed() +register = template.Library() + +@register.simple_tag(name='diffusion_page') +def do_diffusion_page(diffusion): + """ Return page for diffusion. """ + for obj in (diffusion, diffusion.program): + page = getattr(obj, 'page', None) + if page is not None and page.status is not Page.STATUS.draft: + return page + +@register.simple_tag(name='unique_id') +def do_unique_id(prefix=''): + value = str(random.random()).replace('.', '') + return prefix + '_' + value if prefix else value + +@register.filter(name='is_diffusion') +def do_is_diffusion(obj): + return isinstance(obj, aircox.Diffusion) + + diff --git a/aircox_web/urls.py b/aircox_web/urls.py index 4df06cb..c10b393 100644 --- a/aircox_web/urls.py +++ b/aircox_web/urls.py @@ -1,9 +1,23 @@ from django.conf.urls import url +from django.urls import path, register_converter -from . import views +from . import views, models +from .converters import PagePathConverter, DateConverter + +register_converter(PagePathConverter, 'page_path') +register_converter(DateConverter, 'date') urlpatterns = [ - url(r"^(?P[-\w/]+)/$", views.route_page, name="page"), - url(r"^$", views.route_page, name="root"), + path('diffusions/', + views.TimetableView.as_view(), name='timetable'), + path('diffusions/', + views.TimetableView.as_view(), name='timetable'), + path('diffusions/all', + views.DiffusionsView.as_view(), name='diffusion-list'), + path('diffusions/', + views.DiffusionsView.as_view(), name='diffusion-list'), + path('logs/', views.LogsView.as_view(), name='logs'), + path('logs/', views.LogsView.as_view(), name='logs'), + path('', views.route_page, name='page'), ] diff --git a/aircox_web/views.py b/aircox_web/views.py index a06db81..5313cf9 100644 --- a/aircox_web/views.py +++ b/aircox_web/views.py @@ -1,48 +1,241 @@ -from django.db.models import Q -from django.shortcuts import get_object_or_404, render -from django.views.generic.base import TemplateView +from collections import OrderedDict, deque +import datetime + +from django.core.paginator import Paginator +from django.shortcuts import get_object_or_404 +from django.utils.translation import ugettext_lazy as _ +from django.views.generic import TemplateView, ListView +from django.views.generic.base import TemplateResponseMixin, ContextMixin from content_editor.contents import contents_for_item +from aircox import models as aircox from .models import Site, Page from .renderer import site_renderer, page_renderer -def route_page(request, path=None, *args, site=None, **kwargs): +def route_page(request, path=None, *args, model=None, site=None, **kwargs): + """ + Route request to page of the provided path. If model is provided, uses + it. + """ # TODO/FIXME: django site framework | site from request host # TODO: extra page kwargs (as in pepr) site = Site.objects.all().order_by('-default').first() \ if site is None else site + + model = model if model is not None else Page page = get_object_or_404( - # TODO: published - Page.objects.select_subclasses() - .filter(Q(status=Page.STATUS.published) | - Q(status=Page.STATUS.announced)), - path="/{}/".format(path) if path else "/", + model.objects.select_subclasses().active(), + path=path ) kwargs['page'] = page return page.view(request, *args, site=site, **kwargs) -class PageView(TemplateView): - """ Base view class for pages. """ - template_name = 'aircox_web/page.html' - +class BaseView(TemplateResponseMixin, ContextMixin): + title = None site = None + + def dispatch(self, request, *args, site=None, **kwargs): + self.site = site if site is not None else \ + Site.objects.all().order_by('-default').first() + return super().dispatch(request, *args, **kwargs) + + def get_context_data(self, **kwargs): + if kwargs.get('site_regions') is None: + contents = contents_for_item(self.site, site_renderer._renderers.keys()) + kwargs['site_regions'] = contents.render_regions(site_renderer) + + kwargs.setdefault('site', self.site) + if self.title is not None: + kwargs.setdefault('title', self.title) + return super().get_context_data(**kwargs) + + +class ArticleView(BaseView, TemplateView): + """ Base view class for pages. """ + template_name = 'aircox_web/article.html' page = None def get_context_data(self, **kwargs): - page = kwargs.setdefault('page', self.page or self.kwargs.get('site')) - site = kwargs.setdefault('site', self.site or self.kwargs.get('site')) - + # article content + page = kwargs.setdefault('page', self.page or self.kwargs.get('page')) if kwargs.get('regions') is None: contents = contents_for_item(page, page_renderer._renderers.keys()) kwargs['regions'] = contents.render_regions(page_renderer) - if kwargs.get('site_regions') is None: - contents = contents_for_item(site, site_renderer._renderers.keys()) - kwargs['site_regions'] = contents.render_regions(site_renderer) + kwargs.setdefault('title', page.title) return super().get_context_data(**kwargs) +class ProgramView(ArticleView): + """ Base view class for pages. """ + template_name = 'aircox_web/program.html' + next_diffs_count = 5 + + def get_context_data(self, program=None, **kwargs): + # TODO: pagination + program = program or self.page.program + #next_diffs = program.diffusion_set.on_air().after().order_by('start') + return super().get_context_data( + program=program, + # next_diffs=next_diffs[:self.next_diffs_count], + **kwargs, + ) + + +class DiffusionView(ArticleView): + template_name = 'aircox_web/diffusion.html' + + +class DiffusionsView(BaseView, ListView): + template_name = 'aircox_web/diffusions.html' + model = aircox.Diffusion + paginate_by = 10 + title = _('Diffusions') + program = None + + # TODO: get program object + display program title when filtered by program + # TODO: pagination: in template, only a limited number of pages displayed + + def get_queryset(self): + qs = super().get_queryset().station(self.site.station).on_air() \ + .filter(initial__isnull=True) #TODO, page__isnull=False) + program = self.kwargs.get('program') + if program: + qs = qs.filter(program__page__slug=program) + return qs.order_by('-start') + + +class TimetableView(BaseView, ListView): + """ View for timetables """ + template_name = 'aircox_web/timetable.html' + model = aircox.Diffusion + + title = _('Timetable') + + date = None + start = None + end = None + + def get_queryset(self): + self.date = self.kwargs.get('date', datetime.date.today()) + self.start = self.date - datetime.timedelta(days=self.date.weekday()) + self.end = self.date + datetime.timedelta(days=7-self.date.weekday()) + return super().get_queryset().station(self.site.station) \ + .range(self.start, self.end) \ + .order_by('start') + + def get_context_data(self, **kwargs): + # regoup by dates + by_date = OrderedDict() + date = self.start + while date < self.end: + by_date[date] = [] + date += datetime.timedelta(days=1) + + for diffusion in self.object_list: + if not diffusion.date in by_date: + continue + by_date[diffusion.date].append(diffusion) + + return super().get_context_data( + by_date=by_date, + date=self.date, + start=self.start, + end=self.end - datetime.timedelta(days=1), + prev_date=self.start - datetime.timedelta(days=1), + next_date=self.end + datetime.timedelta(days=1), + **kwargs + ) + + +class LogViewBase(ListView): + station = None + date = None + delta = None + + def get_queryset(self): + # only get logs for tracks: log for diffusion will be retrieved + # by the diffusions' queryset. + return super().get_queryset().station(self.station).on_air() \ + .at(self.date).filter(track__isnull=False) + + def get_diffusions_queryset(self): + return aircox.Diffusion.objects.station(self.station).on_air() \ + .at(self.date) + + def get_object_list(self, queryset): + diffs = deque(self.get_diffusions_queryset().order_by('start')) + logs = list(queryset.order_by('date')) + if not len(diffs): + return logs + + object_list = [] + diff = diffs.popleft() + last_collision = None + + # diff.start < log on first diff + # diff.end > log on last diff + + for index, log in enumerate(logs): + # get next diff + if diff.end < log.date: + diff = diffs.popleft() if len(diffs) else None + + # no more diff that can collide: return list + if diff is None: + return object_list + logs[index:] + + # diff colliding with log + if diff.start <= log.date <= diff.end: + if object_list[-1] is not diff: + object_list.append(diff) + last_collision = log + else: + # add last colliding log: track + if last_collision is not None: + object_list.append(last_collision) + + object_list.append(log) + last_collision = None + return object_list + + +class LogsView(BaseView, LogViewBase): + """ View for timetables """ + template_name = 'aircox_web/logs.html' + model = aircox.Log + title = _('Logs') + + date = None + max_age = 10 + + min_date = None + + def get(self, request, *args, **kwargs): + self.station = self.site.station + + today = datetime.date.today() + self.min_date = today - datetime.timedelta(days=self.max_age) + self.date = min(max(self.min_date, self.kwargs['date']), today) \ + if 'date' in self.kwargs else today + return super().get(request, *args, **kwargs) + + def get_context_data(self, **kwargs): + today = datetime.date.today() + max_date = min(max(self.date + datetime.timedelta(days=3), + self.min_date + datetime.timedelta(days=6)), today) + + return super().get_context_data( + date=self.date, + min_date=self.min_date, + dates=(date for date in ( + max_date - datetime.timedelta(days=i) + for i in range(0, 7)) if date >= self.min_date + ), + object_list=self.get_object_list(self.object_list), + **kwargs + ) diff --git a/aircox_web/webpack.config.js b/aircox_web/webpack.config.js index abf694c..66cf4c1 100644 --- a/aircox_web/webpack.config.js +++ b/aircox_web/webpack.config.js @@ -3,7 +3,7 @@ const webpack = require('webpack'); const MiniCssExtractPlugin = require("mini-css-extract-plugin"); // const { createLodashAliases } = require('lodash-loader'); -const { VueLoaderPlugin } = require('vue-loader'); +const VueLoaderPlugin = require('vue-loader/lib/plugin'); module.exports = (env, argv) => Object({ @@ -29,6 +29,13 @@ module.exports = (env, argv) => Object({ test: /[\\/]node_modules[\\/]/, }, + + /*noscript: { + name: 'noscript', + chunks: 'initial', + enforce: true, + test: /noscript/, + }*/ } } }, @@ -43,6 +50,7 @@ module.exports = (env, argv) => Object({ module: { rules: [ + { test: /\.vue$/, loader: 'vue-loader' }, { test: /\/node_modules\//, sideEffects: false @@ -64,7 +72,6 @@ module.exports = (env, argv) => Object({ } }], }, - { test: /\.vue$/, use: 'vue-loader' }, ], },