rewrite tests + fix error in schedule generator

This commit is contained in:
bkfox 2016-07-28 14:45:26 +02:00
parent d1debc5b9f
commit 502af1dba0
9 changed files with 532 additions and 128 deletions

View File

@ -414,6 +414,9 @@ class DiffusionPage(Publication):
related_name = 'page', related_name = 'page',
on_delete=models.SET_NULL, on_delete=models.SET_NULL,
null=True, null=True,
limit_choices_to = {
'initial__isnull': True,
},
) )
class Meta: class Meta:
@ -423,7 +426,7 @@ class DiffusionPage(Publication):
content_panels = [ content_panels = [
FieldPanel('diffusion'), FieldPanel('diffusion'),
] + Publication.content_panels + [ ] + Publication.content_panels + [
InlinePanel('tracks', label=_('Tracks')) InlinePanel('tracks', label=_('Tracks')),
] ]

View File

@ -890,8 +890,8 @@ class SectionLogsList(SectionItem):
@register_snippet @register_snippet
class SectionTimetable(SectionItem,DatedListBase): class SectionTimetable(SectionItem,DatedListBase):
class Meta: class Meta:
verbose_name = _('timetable') verbose_name = _('Section: Timetable')
verbose_name_plural = _('timetable') verbose_name_plural = _('Sections: Timetable')
panels = SectionItem.panels + DatedListBase.panels panels = SectionItem.panels + DatedListBase.panels
@ -914,8 +914,8 @@ class SectionTimetable(SectionItem,DatedListBase):
@register_snippet @register_snippet
class SectionPublicationInfo(SectionItem): class SectionPublicationInfo(SectionItem):
class Meta: class Meta:
verbose_name = _('section with publication\'s info') verbose_name = _('Section: publication\'s info')
verbose_name = _('sections with publication\'s info') verbose_name_plural = _('Sections: publication\'s info')
@register_snippet @register_snippet
class SectionSearchField(SectionItem): class SectionSearchField(SectionItem):
@ -933,8 +933,8 @@ class SectionSearchField(SectionItem):
) )
class Meta: class Meta:
verbose_name = _('search field') verbose_name = _('Section: search field')
verbose_name_plural = _('search fields') verbose_name_plural = _('Sections: search field')
panels = SectionItem.panels + [ panels = SectionItem.panels + [
PageChooserPanel('page'), PageChooserPanel('page'),
@ -946,6 +946,32 @@ class SectionSearchField(SectionItem):
context = super().get_context(request, page) context = super().get_context(request, page)
list_page = self.page or ListPage.objects.live().first() list_page = self.page or ListPage.objects.live().first()
context['list_page'] = list_page context['list_page'] = list_page
print(context, self.template)
return context return context
@register_snippet
class SectionPlayer(SectionItem):
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'),
)
class Meta:
verbose_name = _('Section: Player')
panels = SectionItem.panels + [
FieldPanel('live_title'),
FieldPanel('streams'),
]
def get_context(self, request, page):
context = super().get_context(request, page)
context['streams'] = self.streams.split('\r\n')
return context

280
cms/static/cms/js/player.js Normal file
View File

@ -0,0 +1,280 @@
// TODO
// - multiple sources for an item
// - live streams as item;
// - add to playlist button
//
/// Return a human-readable string from seconds
function duration_str(seconds) {
seconds = Math.floor(seconds);
var hours = Math.floor(seconds / 3600);
seconds -= hours;
var minutes = Math.floor(seconds / 60);
seconds -= minutes;
var str = hours ? (hours < 10 ? '0' + hours : hours) + ':' : '';
str += (minutes < 10 ? '0' + minutes : minutes) + ':';
str += (seconds < 10 ? '0' + seconds : seconds);
return str;
}
function Sound(title, detail, stream, duration) {
this.title = title;
this.detail = detail;
this.stream = stream;
this.duration = duration;
}
Sound.prototype = {
title: '',
detail: '',
stream: '',
duration: undefined,
item: undefined,
get seekable() {
return this.duration != undefined;
},
}
function PlayerPlaylist(player) {
this.player = player;
this.playlist = player.player.querySelector('.playlist');
this.item_ = player.player.querySelector('.playlist .item');
this.items = []
}
PlayerPlaylist.prototype = {
items: undefined,
find: function(stream) {
return this.items.find(function(v) {
return v.stream == item;
});
},
add: function(sound, container) {
if(this.find(sound.stream))
return;
var item = this.item_.cloneNode(true);
item.removeAttribute('style');
console.log(sound)
item.querySelector('.title').innerHTML = sound.title;
if(sound.seekable)
item.querySelector('.duration').innerHTML =
duration_str(sound.duration);
if(sound.detail)
item.querySelector('.detail').href = sound.detail;
item.sound = sound;
sound.item = item;
var self = this;
item.querySelector('.action.remove').addEventListener(
'click', function(event) { self.remove(sound); }, false
);
(container || this.playlist).appendChild(item);
this.items.push(sound);
this.save();
},
remove: function(sound) {
var index = this.items.indexOf(sound);
if(index != -1)
this.items.splice(index,1);
this.playlist.removeChild(sound.item);
this.save();
},
save: function() {
var list = [];
for(var i in this.items) {
var sound = Object.assign({}, this.items[i])
delete sound.item;
list.push(sound);
}
this.player.store.set('playlist', list);
},
load: function() {
var list = [];
var container = document.createDocumentFragment();
for(var i in list)
this.add(list[i], container)
this.playlist.appendChild(container);
},
}
function Player(id) {
this.store = new Store('player');
// html items
this.player = document.getElementById(id);
this.box = this.player.querySelector('.box');
this.audio = this.player.querySelector('audio');
this.controls = {
duration: this.box.querySelector('.duration'),
progress: this.player.querySelector('progress'),
single: this.player.querySelector('input.single'),
}
this.playlist = new PlayerPlaylist(this);
this.playlist.load();
this.init_events();
this.load();
}
Player.prototype = {
/// current item being played
sound: undefined,
init_events: function() {
var self = this;
function time_from_progress(event) {
bounding = self.controls.progress.getBoundingClientRect()
offset = (event.clientX - bounding.left);
return offset * self.audio.duration / bounding.width;
}
function update_info() {
var controls = self.controls;
// progress
if( !self.sound.seekable ||
self.audio.duration == Infinity) {
controls.duration.innerHTML = '';
controls.progress.value = 0;
return;
}
var pos = self.audio.currentTime;
controls.progress.value = pos;
controls.progress.max = self.audio.duration;
controls.duration.innerHTML = duration_str(sound.duration);
}
// audio
this.audio.addEventListener('playing', function() {
self.player.setAttribute('state', 'playing');
}, false);
this.audio.addEventListener('pause', function() {
self.player.setAttribute('state', 'paused');
}, false);
this.audio.addEventListener('loadstart', function() {
self.player.setAttribute('state', 'stalled');
}, false);
this.audio.addEventListener('loadeddata', function() {
self.player.removeAttribute('state');
}, false);
this.audio.addEventListener('timeupdate', update_info, false);
this.audio.addEventListener('ended', function() {
if(!self.controls.single.checked)
self.next(true);
}, false);
// buttons
this.box.querySelector('button.play').onclick = function() {
self.play();
};
// progress
progress = this.controls.progress;
progress.addEventListener('click', function(event) {
player.audio.currentTime = time_from_progress(event);
}, false);
progress.addEventListener('mouseout', update_info, false);
progress.addEventListener('mousemove', function(event) {
if(self.audio.duration == Infinity)
return;
var pos = time_from_progress(event);
self.controls.duration.innerHTML = duration_str(pos);
}, false);
},
play: function() {
if(this.audio.paused)
this.audio.play();
else
this.audio.pause();
},
unselect: function(sound) {
sound.item.removeAttribute('selected');
},
select: function(sound, play = true) {
if(this.sound)
this.unselect(this.sound);
this.audio.pause();
// if stream is a list, use <source>
if(sound.stream.splice) {
this.audio.src="";
var sources = this.audio.querySelectorAll('source');
for(var i in sources)
this.audio.removeChild(sources[i]);
for(var i in sound.stream) {
var source = document.createElement('source');
source.src = sound.stream[i];
}
}
else
this.audio.src = sound.stream;
this.audio.load();
this.sound = sound;
sound.item.setAttribute('selected', 'true');
this.box.querySelector('.title').innerHTML = sound.title;
if(play)
this.play();
},
next: function() {
var index = this.playlist.items.indexOf(this.sound);
if(index < 0)
return;
index++;
if(index < this.playlist.items.length)
this.select(this.playlist.items[index], true);
},
save: function() {
this.store.set('player', {
single: this.controls.single.checked,
sound: this.sound && this.sound.stream,
});
},
load: function() {
var data = this.store.get('player');
this.controls.single.checked = data.single;
this.sound = this.playlist.find(data.stream);
},
update_on_air: function() {
},
}

View File

@ -0,0 +1,68 @@
/// 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(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);
},
}

View File

@ -25,6 +25,9 @@
{% block css_extras %}{% endblock %} {% block css_extras %}{% endblock %}
{% endblock %} {% endblock %}
<script src="{% static 'cms/js/utils.js' %}"></script>
<script src="{% static 'cms/js/player.js' %}"></script>
<title>{{ page.title }}</title> <title>{{ page.title }}</title>
</head> </head>
<body> <body>

View File

@ -0,0 +1,61 @@
{% extends 'cms/sections/section_item.html' %}
{% load staticfiles %}
{% load i18n %}
{% block content %}
<style>
</style>
<div id="player">
<div class="box">
<audio preload="metadata">
{% trans "Your browser does not support the <code>audio</code> element." %}
{% for stream in streams %}
<source src="{{ stream }}" />
{% endfor %}
</audio>
<button class="play" onclick="Player.play()"
title="{% trans "play/pause" %}"></button>
<h3 class="title">{{ self.live_title }}</h3>
<div>
<div class="info duration"></div>
<progress value="0" max="1"></progress>
<input type="checkbox" class="single" id="player_single_mode">
<label for="player_single_mode"></label>
</div>
</div>
<div class="playlist">
<li class='item' style="display: none;">
<h2 class="title">{{ self.live_title }}</h2>
<div class="info duration"></div>
<div class="actions">
<a class="action detail" title="{% trans "more informations" %}"></a>
<a class="action remove" title="{% trans "remove this sound" %}"></a>
</div>
</li>
</div>
<div class='item on_air'>
<h2 class="title"></h2>
<a class="url"></a>
</div>
</div>
<script>
var player = new Player('player');
player.playlist.add(new Sound('{{ self.live_title }}', '', [
{% for stream in streams %}'{{ stream }}',{% endfor %}
]));
</script>
{% endblock %}

View File

@ -1,27 +1,5 @@
{% load i18n %} {% load i18n %}
<script>
/// Function used to select a panel on a tab selection.
/// The tab should be at max level -2 of the main container
/// The panel must have a class "panel"
function select_tab(target) {
parent = target.parentNode.parentNode;
var date = target.dataset.date;
panel = parent.querySelector('.panel[data-date="' + date + '"]');
// unselect
qs = parent.querySelectorAll('*[selected]');
for(var i = 0; i < qs.length; i++)
if(qs[i].dataset.date != date)
qs[i].removeAttribute('selected');
console.log(panel, target, date);
panel.setAttribute('selected', 'true');
target.setAttribute('selected', 'true');
}
</script>
{# FIXME: get current complete URL #} {# FIXME: get current complete URL #}
<div class="list date_list"> <div class="list date_list">
{% if nav_dates %} {% if nav_dates %}
@ -31,7 +9,7 @@
{% endif %} {% endif %}
{% for day in nav_dates.dates %} {% for day in nav_dates.dates %}
<a onclick="select_tab(this);" data-date="day_{{day|date:"Y-m-d"}}" <a onclick="select_tab(this, '.panel[data-date=\'{{day|date:"Y-m-d"}}\']');"
{% if day == nav_dates.date %}selected{% endif %} {% if day == nav_dates.date %}selected{% endif %}
class="tab {% if day == nav_dates.date %}today{% endif %}"> class="tab {% if day == nav_dates.date %}today{% endif %}">
{{ day|date:'D. d' }} {{ day|date:'D. d' }}
@ -47,7 +25,7 @@
{% for day, list in object_list %} {% for day, list in object_list %}
<ul class="panel {% if day == nav_dates.date %}class="today"{% endif %}" <ul class="panel {% if day == nav_dates.date %}class="today"{% endif %}"
{% if day == nav_dates.date %}selected{% endif %} {% if day == nav_dates.date %}selected{% endif %}
data-date="day_{{day|date:"Y-m-d"}}"> data-date="{{day|date:"Y-m-d"}}">
{# you might like to hide it by default -- this more for sections #} {# you might like to hide it by default -- this more for sections #}
<h2>{{ day|date:'l d F' }}</h2> <h2>{{ day|date:'l d F' }}</h2>
{% with object_list=list item_date_format="H:i" %} {% with object_list=list item_date_format="H:i" %}

View File

@ -1,4 +1,5 @@
import datetime import datetime
import calendar
import os import os
import shutil import shutil
import logging import logging
@ -400,6 +401,18 @@ class Schedule(models.Model):
date = date_or_default(date, True).replace(day=1) date = date_or_default(date, True).replace(day=1)
freq = self.frequency freq = self.frequency
# last of the month
if freq == Schedule.Frequency.last:
date = date.replace(day=calendar.monthrange(date.year, date.month)[1])
# end of month before the wanted weekday: move one week back
if date.weekday() < self.date.weekday():
date -= datetime.timedelta(days = 7)
delta = self.date.weekday() - date.weekday()
date += datetime.timedelta(days = delta)
return [self.normalize(date)]
# move to the first day of the month that matches the schedule's weekday # move to the first day of the month that matches the schedule's weekday
# check on SO#3284452 for the formula # check on SO#3284452 for the formula
first_weekday = date.weekday() first_weekday = date.weekday()
@ -408,19 +421,9 @@ class Schedule(models.Model):
- first_weekday + sched_weekday) - first_weekday + sched_weekday)
month = date.month month = date.month
# last of the month
if freq == Schedule.Frequency.last:
date += tz.timedelta(days = 4 * 7)
next_date = date + tz.timedelta(days = 7)
if next_date.month == month:
date = next_date
return [self.normalize(date)]
dates = [] dates = []
if freq == Schedule.Frequency.one_on_two: if freq == Schedule.Frequency.one_on_two:
# NOTE previous algorithm was based on the week number, but this # check date base on a diff of dates base on a 14 days delta
# approach is wrong because number of weeks in a year can be
# 52 or 53. This also clashes with the first week of the year.
diff = as_date(date, False) - as_date(self.date, False) diff = as_date(date, False) - as_date(self.date, False)
if diff.days % 14: if diff.days % 14:
date += tz.timedelta(days = 7) date += tz.timedelta(days = 7)
@ -445,36 +448,31 @@ class Schedule(models.Model):
If exclude_saved, exclude all diffusions that are yet in the database. If exclude_saved, exclude all diffusions that are yet in the database.
""" """
dates = self.dates_of_month(date) dates = self.dates_of_month(date)
saved = Diffusion.objects.filter(start__in = dates,
program = self.program)
diffusions = [] diffusions = []
duration = utils.to_timedelta(self.duration)
# existing diffusions # existing diffusions
for item in saved: for item in Diffusion.objects.filter(
program = self.program, start__in = dates):
if item.start in dates: if item.start in dates:
dates.remove(item.start) dates.remove(item.start)
if not exclude_saved: if not exclude_saved:
diffusions.append(item) diffusions.append(item)
# others # new diffusions
for date in dates: duration = utils.to_timedelta(self.duration)
first_date = date
if self.initial: if self.initial:
first_date -= self.date - self.initial.date delta = self.date - self.initial.date
diffusions += [
first_diffusion = Diffusion.objects.filter(start = first_date, Diffusion(
program = self.program)
first_diffusion = first_diffusion[0] if first_diffusion.count() \
else None
diffusions.append(Diffusion(
program = self.program, program = self.program,
type = Diffusion.Type.unconfirmed, type = Diffusion.Type.unconfirmed,
initial = first_diffusion if self.initial else None, initial = \
Diffusion.objects.filter(start = date - delta).first() \
if self.initial else None,
start = date, start = date,
end = date + duration, end = date + duration,
)) ) for date in dates
]
return diffusions return diffusions
def __str__(self): def __str__(self):

View File

@ -1,79 +1,66 @@
import datetime import datetime
import calendar
import logging
from dateutil.relativedelta import relativedelta
from django.test import TestCase from django.test import TestCase
from django.utils import timezone as tz from django.utils import timezone as tz
from aircox.programs.models import * from aircox.programs.models import *
logger = logging.getLogger('aircox.test')
logger.setLevel('INFO')
class Programs (TestCase): class ScheduleCheck (TestCase):
def setUp (self): def setUp(self):
stream = Stream.objects.get_or_create( self.schedules = [
name = 'diffusions', Schedule(
defaults = { 'type': Stream.Type.schedule } date = tz.now(),
)[0] duration = datetime.time(1,30),
Program.objects.create(name = 'source', stream = stream)
Program.objects.create(name = 'microouvert', stream = stream)
self.schedules = {}
self.programs = {}
def test_create_programs_schedules (self):
program = Program.objects.get(name = 'source')
sched_0 = self.create_schedule(program, 'one on two', [
tz.datetime(2015, 10, 2, 18),
tz.datetime(2015, 10, 16, 18),
tz.datetime(2015, 10, 30, 18),
]
)
sched_1 = self.create_schedule(program, 'one on two', [
tz.datetime(2015, 10, 5, 18),
tz.datetime(2015, 10, 19, 18),
],
rerun = sched_0
)
self.programs[program.pk] = program
program = Program.objects.get(name = 'microouvert')
# special case with november first week starting on sunday
sched_2 = self.create_schedule(program, 'first and third', [
tz.datetime(2015, 11, 6, 18),
tz.datetime(2015, 11, 20, 18),
],
date = tz.datetime(2015, 10, 23, 18),
)
def create_schedule (self, program, frequency, dates, date = None, rerun = None):
frequency = Schedule.Frequency[frequency]
schedule = Schedule(
program = program,
frequency = frequency, frequency = frequency,
date = date or dates[0],
rerun = rerun,
duration = datetime.time(1, 30)
) )
print(schedule.__dict__) for frequency in Schedule.Frequency.__members__.values()
schedule.save() ]
self.schedules[schedule.pk] = (schedule, dates) def test_frequencies(self):
return schedule for schedule in self.schedules:
logger.info('- test frequency %s' % schedule.get_frequency_display())
date = schedule.date
count = 24
while count:
logger.info('- month %(month)s/%(year)s' % {
'month': date.month,
'year': date.year
})
count -= 1
dates = schedule.dates_of_month(date)
if schedule.frequency == schedule.Frequency.one_on_two:
self.check_one_on_two(schedule, date, dates)
elif schedule.frequency == schedule.Frequency.last:
self.check_last(schedule, date, dates)
else:
pass
date += relativedelta(months = 1)
def test_check_schedule (self): def check_one_on_two(self, schedule, date, dates):
for schedule, dates in self.schedules: for date in dates:
dates = [ tz.make_aware(date) for date in dates ] delta = date.date() - schedule.date.date()
dates.sort() self.assertEqual(delta.days % 14, 0)
# dates def check_last(self, schedule, date, dates):
dates_ = schedule.dates_of_month(dates[0]) month_info = calendar.monthrange(date.year, date.month)
dates_.sort() date = datetime.date(date.year, date.month, month_info[1])
self.assertEqual(dates_, dates)
# end of month before the wanted weekday: move one week back
if date.weekday() < schedule.date.weekday():
date -= datetime.timedelta(days = 7)
date -= datetime.timedelta(days = date.weekday())
date += datetime.timedelta(days = schedule.date.weekday())
self.assertEqual(date, dates[0].date())
def check_n_of_week(self, schedule, date, dates):
pass
# diffusions
dates_ = schedule.diffusions_of_month(dates[0])
dates_ = [date_.date for date_ in dates_]
dates_.sort()
self.assertEqual(dates_, dates)