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',
on_delete=models.SET_NULL,
null=True,
limit_choices_to = {
'initial__isnull': True,
},
)
class Meta:
@ -423,7 +426,7 @@ class DiffusionPage(Publication):
content_panels = [
FieldPanel('diffusion'),
] + Publication.content_panels + [
InlinePanel('tracks', label=_('Tracks'))
InlinePanel('tracks', label=_('Tracks')),
]

View File

@ -890,8 +890,8 @@ class SectionLogsList(SectionItem):
@register_snippet
class SectionTimetable(SectionItem,DatedListBase):
class Meta:
verbose_name = _('timetable')
verbose_name_plural = _('timetable')
verbose_name = _('Section: Timetable')
verbose_name_plural = _('Sections: Timetable')
panels = SectionItem.panels + DatedListBase.panels
@ -914,8 +914,8 @@ class SectionTimetable(SectionItem,DatedListBase):
@register_snippet
class SectionPublicationInfo(SectionItem):
class Meta:
verbose_name = _('section with publication\'s info')
verbose_name = _('sections with publication\'s info')
verbose_name = _('Section: publication\'s info')
verbose_name_plural = _('Sections: publication\'s info')
@register_snippet
class SectionSearchField(SectionItem):
@ -933,8 +933,8 @@ class SectionSearchField(SectionItem):
)
class Meta:
verbose_name = _('search field')
verbose_name_plural = _('search fields')
verbose_name = _('Section: search field')
verbose_name_plural = _('Sections: search field')
panels = SectionItem.panels + [
PageChooserPanel('page'),
@ -946,6 +946,32 @@ class SectionSearchField(SectionItem):
context = super().get_context(request, page)
list_page = self.page or ListPage.objects.live().first()
context['list_page'] = list_page
print(context, self.template)
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 %}
{% endblock %}
<script src="{% static 'cms/js/utils.js' %}"></script>
<script src="{% static 'cms/js/player.js' %}"></script>
<title>{{ page.title }}</title>
</head>
<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 %}
<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 #}
<div class="list date_list">
{% if nav_dates %}
@ -31,7 +9,7 @@
{% endif %}
{% 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 %}
class="tab {% if day == nav_dates.date %}today{% endif %}">
{{ day|date:'D. d' }}
@ -47,7 +25,7 @@
{% for day, list in object_list %}
<ul class="panel {% if day == nav_dates.date %}class="today"{% 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 #}
<h2>{{ day|date:'l d F' }}</h2>
{% with object_list=list item_date_format="H:i" %}

View File

@ -1,4 +1,5 @@
import datetime
import calendar
import os
import shutil
import logging
@ -400,6 +401,18 @@ class Schedule(models.Model):
date = date_or_default(date, True).replace(day=1)
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
# check on SO#3284452 for the formula
first_weekday = date.weekday()
@ -408,19 +421,9 @@ class Schedule(models.Model):
- first_weekday + sched_weekday)
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 = []
if freq == Schedule.Frequency.one_on_two:
# NOTE previous algorithm was based on the week number, but this
# 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.
# check date base on a diff of dates base on a 14 days delta
diff = as_date(date, False) - as_date(self.date, False)
if diff.days % 14:
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.
"""
dates = self.dates_of_month(date)
saved = Diffusion.objects.filter(start__in = dates,
program = self.program)
diffusions = []
duration = utils.to_timedelta(self.duration)
# existing diffusions
for item in saved:
for item in Diffusion.objects.filter(
program = self.program, start__in = dates):
if item.start in dates:
dates.remove(item.start)
if not exclude_saved:
diffusions.append(item)
# others
for date in dates:
first_date = date
# new diffusions
duration = utils.to_timedelta(self.duration)
if self.initial:
first_date -= self.date - self.initial.date
first_diffusion = Diffusion.objects.filter(start = first_date,
program = self.program)
first_diffusion = first_diffusion[0] if first_diffusion.count() \
else None
diffusions.append(Diffusion(
delta = self.date - self.initial.date
diffusions += [
Diffusion(
program = self.program,
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,
end = date + duration,
))
) for date in dates
]
return diffusions
def __str__(self):

View File

@ -1,79 +1,66 @@
import datetime
import calendar
import logging
from dateutil.relativedelta import relativedelta
from django.test import TestCase
from django.utils import timezone as tz
from aircox.programs.models import *
logger = logging.getLogger('aircox.test')
logger.setLevel('INFO')
class Programs (TestCase):
def setUp (self):
stream = Stream.objects.get_or_create(
name = 'diffusions',
defaults = { 'type': Stream.Type.schedule }
)[0]
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,
class ScheduleCheck (TestCase):
def setUp(self):
self.schedules = [
Schedule(
date = tz.now(),
duration = datetime.time(1,30),
frequency = frequency,
date = date or dates[0],
rerun = rerun,
duration = datetime.time(1, 30)
)
print(schedule.__dict__)
schedule.save()
for frequency in Schedule.Frequency.__members__.values()
]
self.schedules[schedule.pk] = (schedule, dates)
return schedule
def test_frequencies(self):
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):
for schedule, dates in self.schedules:
dates = [ tz.make_aware(date) for date in dates ]
dates.sort()
def check_one_on_two(self, schedule, date, dates):
for date in dates:
delta = date.date() - schedule.date.date()
self.assertEqual(delta.days % 14, 0)
# dates
dates_ = schedule.dates_of_month(dates[0])
dates_.sort()
self.assertEqual(dates_, dates)
def check_last(self, schedule, date, dates):
month_info = calendar.monthrange(date.year, date.month)
date = datetime.date(date.year, date.month, month_info[1])
# 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)