This commit is contained in:
bkfox 2019-08-07 01:45:27 +02:00
parent 324cf2ef0f
commit 248b77fca4
52 changed files with 794 additions and 384 deletions

View File

@ -1,3 +1,2 @@
default_app_config = 'aircox.apps.AircoxConfig'

View File

@ -1,7 +1,7 @@
from .article import ArticleAdmin
from .episode import DiffusionAdmin, EpisodeAdmin
from .log import LogAdmin
# from .playlist import PlaylistAdmin
from .program import ProgramAdmin, ScheduleAdmin, StreamAdmin
from .sound import SoundAdmin
from .sound import SoundAdmin, TrackAdmin
from .station import StationAdmin

20
aircox/admin/article.py Normal file
View File

@ -0,0 +1,20 @@
import copy
from django.contrib import admin
from ..models import Article
from .page import PageAdmin
__all__ = ['ArticleAdmin']
@admin.register(Article)
class ArticleAdmin(PageAdmin):
list_display = PageAdmin.list_display + ('program',)
list_filter = ('program',)
# TODO: readonly field
fieldsets = copy.deepcopy(PageAdmin.fieldsets)
fieldsets[1][1]['fields'].insert(0, 'program')

View File

@ -6,8 +6,7 @@ from django.utils.translation import ugettext as _, ugettext_lazy
from aircox.models import Episode, Diffusion, Sound, Track
from .page import PageAdmin
from .playlist import TracksInline
from .sound import SoundInline
from .sound import SoundInline, TracksInline
class DiffusionBaseAdmin:

View File

@ -4,18 +4,30 @@ from django.utils.translation import ugettext_lazy as _
from adminsortable2.admin import SortableInlineAdminMixin
from ..models import NavItem
from ..models import Category, Article, NavItem
__all__ = ['CategoryAdmin', 'PageAdmin', 'NavItemInline']
@admin.register(Category)
class CategoryAdmin(admin.ModelAdmin):
list_display = ['pk', 'title', 'slug']
list_editable = ['title', 'slug']
fields = ['title', 'slug']
prepopulated_fields = {"slug": ("title",)}
# limit category choice
class PageAdmin(admin.ModelAdmin):
list_display = ('cover_thumb', 'title', 'status')
list_display = ('cover_thumb', 'title', 'status', 'category')
list_display_links = ('cover_thumb', 'title')
list_editable = ('status',)
list_editable = ('status', 'category')
prepopulated_fields = {"slug": ("title",)}
fieldsets = [
('', {
'fields': ['title', 'slug', 'cover', 'content'],
'fields': ['title', 'slug', 'category', 'cover', 'content'],
}),
(_('Publication Settings'), {
'fields': ['featured', 'allow_comments', 'status'],
@ -31,3 +43,5 @@ class PageAdmin(admin.ModelAdmin):
class NavItemInline(SortableInlineAdminMixin, admin.TabularInline):
model = NavItem

View File

@ -1,40 +0,0 @@
from django.contrib import admin
from django.utils.translation import ugettext as _, ugettext_lazy
from adminsortable2.admin import SortableInlineAdminMixin
from aircox.models import Track
class TracksInline(SortableInlineAdminMixin, admin.TabularInline):
template = 'admin/aircox/playlist_inline.html'
model = Track
extra = 0
fields = ('position', 'artist', 'title', 'info', 'timestamp', 'tags')
list_display = ['artist', 'title', 'tags', 'related']
list_filter = ['artist', 'title', 'tags']
@admin.register(Track)
class TrackAdmin(admin.ModelAdmin):
def tag_list(self, obj):
return u", ".join(o.name for o in obj.tags.all())
list_display = ['pk', 'artist', 'title', 'tag_list', 'episode', 'sound', 'timestamp']
list_editable = ['artist', 'title']
list_filter = ['sound', 'episode', 'artist', 'title', 'tags']
fieldsets = [
(_('Playlist'), {'fields': ['episode', 'sound', 'position', 'timestamp']}),
(_('Info'), {'fields': ['artist', 'title', 'info', 'tags']}),
]
# TODO on edit: readonly_fields = ['episode', 'sound']
#@admin.register(Playlist)
#class PlaylistAdmin(admin.ModelAdmin):
# fields = ['episode', 'sound']
# inlines = [TracksInline]
# # TODO: dynamic read only fields

View File

@ -1,8 +1,20 @@
from django.contrib import admin
from django.utils.translation import ugettext as _, ugettext_lazy
from aircox.models import Sound
from .playlist import TracksInline
from adminsortable2.admin import SortableInlineAdminMixin
from aircox.models import Sound, Track
class TracksInline(SortableInlineAdminMixin, admin.TabularInline):
template = 'admin/aircox/playlist_inline.html'
model = Track
extra = 0
fields = ('position', 'artist', 'title', 'info', 'timestamp', 'tags')
list_display = ['artist', 'title', 'tags', 'related']
list_filter = ['artist', 'title', 'tags']
class SoundInline(admin.TabularInline):
@ -31,4 +43,19 @@ class SoundAdmin(admin.ModelAdmin):
inlines = [TracksInline]
@admin.register(Track)
class TrackAdmin(admin.ModelAdmin):
def tag_list(self, obj):
return u", ".join(o.name for o in obj.tags.all())
list_display = ['pk', 'artist', 'title', 'tag_list', 'episode', 'sound', 'timestamp']
list_editable = ['artist', 'title']
list_filter = ['sound', 'episode', 'artist', 'title', 'tags']
fieldsets = [
(_('Playlist'), {'fields': ['episode', 'sound', 'position', 'timestamp']}),
(_('Info'), {'fields': ['artist', 'title', 'info', 'tags']}),
]
# TODO on edit: readonly_fields = ['episode', 'sound']

View File

@ -6,5 +6,4 @@ class AircoxConfig(AppConfig):
verbose_name = 'Aircox'
def ready(self):
import aircox.signals
pass

View File

@ -33,7 +33,7 @@ class Connector:
return
family = socket.AF_UNIX if isinstance(self.address, str) else \
socket.AF_INET
socket.AF_INET
try:
self.socket = socket.socket(family, socket.SOCK_STREAM)
self.socket.connect(self.address)
@ -81,4 +81,3 @@ class Connector:
return json.loads(value) if value else None
except:
return None

View File

@ -1,4 +1,3 @@
from collections import OrderedDict
import atexit
import logging
import os
@ -121,9 +120,6 @@ class Streamer:
def sync(self):
""" Sync all sources. """
if self.process is None:
return
for source in self.sources:
source.sync()
@ -141,7 +137,7 @@ class Streamer:
return
self.source = next((source for source in self.sources
if source.is_playing), None)
if source.is_playing), None)
# Process ##########################################################
def get_process_args(self):
@ -214,8 +210,8 @@ class Source:
def is_playing(self):
return self.status == 'playing'
#@property
#def is_on_air(self):
# @property
# def is_on_air(self):
# return self.rid is not None and self.rid in self.controller.on_air
def __init__(self, controller, id=None):
@ -224,7 +220,6 @@ class Source:
def sync(self):
""" Synchronize what should be synchronized """
pass
def fetch(self):
data = self.controller.send(self.id, '.remaining')
@ -322,5 +317,3 @@ class QueueSource(Source):
super().fetch()
queue = self.controller.send(self.id, '_queue.queue').split(' ')
self.queue = queue

View File

@ -45,4 +45,3 @@ class DateConverter:
def to_url(self, value):
return '{:04d}/{:02d}/{:02d}'.format(value.year, value.month,
value.day)

View File

@ -1,6 +1,5 @@
import pytz
from django import shortcuts
from django.db.models import Q, Case, Value, When
from django.db.models import Q
from django.utils import timezone as tz
from .models import Station
@ -16,18 +15,18 @@ class AircoxMiddleware(object):
This middleware must be set after the middleware
'django.contrib.auth.middleware.AuthenticationMiddleware',
"""
def __init__(self, get_response):
self.get_response = get_response
def get_station(self, request):
""" Return station for the provided request """
expr = Q(default=True) | Q(hosts__contains=request.get_host())
#case = Case(When(hosts__contains=request.get_host(), then=Value(0)),
# case = Case(When(hosts__contains=request.get_host(), then=Value(0)),
# When(default=True, then=Value(32)))
return Station.objects.filter(expr).order_by('default').first()
# .annotate(resolve_priority=case) \
#.order_by('resolve_priority').first()
# .order_by('resolve_priority').first()
def init_timezone(self, request):
# note: later we can use http://freegeoip.net/ on user side if
@ -44,13 +43,10 @@ class AircoxMiddleware(object):
timezone = tz.get_current_timezone()
tz.activate(timezone)
def __call__(self, request):
self.init_timezone(request)
request.station = self.get_station(request)
try:
return self.get_response(request)
except Redirect as redirect:
return
except Redirect:
return

View File

@ -1,8 +1,11 @@
from .page import Page, NavItem
from .article import Article
from .page import Category, Page, NavItem
from .program import Program, Stream, Schedule
from .episode import Episode, Diffusion
from .log import Log
from .sound import Sound, Track
from .station import Station, Port
from . import signals

27
aircox/models/article.py Normal file
View File

@ -0,0 +1,27 @@
from django.db import models
from django.utils.translation import ugettext_lazy as _
from .page import Page
from .program import Program, InProgramQuerySet
class Article(Page):
program = models.ForeignKey(
Program, models.SET_NULL,
verbose_name=_('program'), blank=True, null=True,
help_text=_("publish as this program's article"),
)
is_static = models.BooleanField(
_('is static'), default=False,
help_text=_('Should this article be considered as a page '
'instead of a blog article'),
)
objects = InProgramQuerySet.as_manager()
class Meta:
verbose_name = _('Article')
verbose_name_plural = _('Articles')

View File

@ -18,13 +18,17 @@ from .page import Page, PageQuerySet
__all__ = ['Episode', 'Diffusion', 'DiffusionQuerySet']
class EpisodeQuerySet(PageQuerySet, InProgramQuerySet):
pass
class Episode(Page):
program = models.ForeignKey(
Program, models.CASCADE,
verbose_name=_('program'),
)
objects = InProgramQuerySet.as_manager()
objects = EpisodeQuerySet.as_manager()
detail_url_name = 'episode-detail'
class Meta:
@ -37,17 +41,14 @@ class Episode(Page):
super().save(*args, **kwargs)
@classmethod
def get_default_title(cls, program, date):
def get_init_kwargs_from(cls, page, date, title=None, **kwargs):
""" Get default Episode's title """
return settings.AIRCOX_EPISODE_TITLE.format(
program=program,
title = settings.AIRCOX_EPISODE_TITLE.format(
program=page,
date=date.strftime(settings.AIRCOX_EPISODE_TITLE_DATE_FORMAT),
)
@classmethod
def from_date(cls, program, date):
title = cls.get_default_title(program, date)
return cls(program=program, title=title, cover=program.cover)
) if title is None else title
return super().get_init_kwargs_from(page, title=title, program=page,
**kwargs)
class DiffusionQuerySet(BaseRerunQuerySet):

View File

@ -1,13 +1,13 @@
from enum import IntEnum
import re
from django.db import models
from django.urls import reverse
from django.utils import timezone as tz
from django.utils.text import slugify
from django.utils.html import format_html
from django.utils.translation import ugettext_lazy as _
from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentType
from django.utils.functional import cached_property
from ckeditor.fields import RichTextField
from filer.fields.image import FilerImageField
@ -16,13 +16,36 @@ from model_utils.managers import InheritanceQuerySet
from .station import Station
__all__ = ['PageQuerySet', 'Page', 'NavItem']
__all__ = ['Category', 'PageQuerySet', 'Page', 'NavItem']
headline_re = re.compile(r'(<p>)?'
r'(?P<headline>[^\n]{1,140}(\n|[^\.]*?\.))'
r'(</p>)?')
class Category(models.Model):
title = models.CharField(_('title'), max_length=64)
slug = models.SlugField(_('slug'), max_length=64, db_index=True)
class Meta:
verbose_name = _('Category')
verbose_name_plural = _('Categories')
def __str__(self):
return self.title
class PageQuerySet(InheritanceQuerySet):
def draft(self):
return self.filter(status=Page.STATUS.draft)
def published(self):
return self.filter(status=Page.STATUS.published)
def trash(self):
return self.filter(status=Page.STATUS.trash)
class Page(models.Model):
""" Base class for publishable content """
@ -38,13 +61,18 @@ class Page(models.Model):
default=STATUS.draft,
choices=[(int(y), _(x)) for x, y in STATUS.__members__.items()],
)
category = models.ForeignKey(
Category, models.SET_NULL,
verbose_name=_('category'), blank=True, null=True, db_index=True
)
cover = FilerImageField(
on_delete=models.SET_NULL, null=True, blank=True,
verbose_name=_('Cover'),
on_delete=models.SET_NULL,
verbose_name=_('Cover'), null=True, blank=True,
)
content = RichTextField(
_('content'), blank=True, null=True,
)
date = models.DateTimeField(default=tz.now)
featured = models.BooleanField(
_('featured'), default=False,
)
@ -86,6 +114,23 @@ class Page(models.Model):
def is_trash(self):
return self.status == self.STATUS.trash
@cached_property
def headline(self):
if not self.content:
return ''
headline = headline_re.search(self.content)
return headline.groupdict()['headline'] if headline else ''
@classmethod
def get_init_kwargs_from(cls, page, **kwargs):
kwargs.setdefault('cover', page.cover)
kwargs.setdefault('category', page.category)
return kwargs
@classmethod
def from_page(cls, page, **kwargs):
return cls(**cls.get_init_kwargs_from(page, **kwargs))
class NavItem(models.Model):
""" Navigation menu items """

View File

@ -470,7 +470,8 @@ class Schedule(BaseRerun):
continue
if initial is None:
episode = Episode.from_date(self.program, date)
episode = Episode.from_page(self.program, date=date)
episode.date = date
episodes[date] = episode
else:
episode = episodes[initial]
@ -489,10 +490,6 @@ class Schedule(BaseRerun):
if self.initial is not None and self.date > self.date:
raise ValueError('initial must be later')
# initial only if it has been yet saved
if self.pk:
self.__initial = self.__dict__.copy()
class Stream(models.Model):
"""

100
aircox/models/signals.py Executable file
View File

@ -0,0 +1,100 @@
import pytz
from django.contrib.auth.models import User, Group, Permission
from django.db.models import F, signals
from django.dispatch import receiver
from django.utils import timezone as tz
from .. import settings, utils
from . import Diffusion, Episode, Program, Schedule
# Add a default group to a user when it is created. It also assigns a list
# of permissions to the group if it is created.
#
# - group name: settings.AIRCOX_DEFAULT_USER_GROUP
# - group permissions: settings.AIRCOX_DEFAULT_USER_GROUP_PERMS
#
@receiver(signals.post_save, sender=User)
def user_default_groups(sender, instance, created, *args, **kwargs):
"""
Set users to different default groups
"""
if not created or instance.is_superuser:
return
for groupName, permissions in settings.AIRCOX_DEFAULT_USER_GROUPS.items():
if instance.groups.filter(name=groupName).count():
continue
group, created = Group.objects.get_or_create(name=groupName)
if created and permissions:
for codename in permissions:
permission = Permission.objects.filter(
codename=codename).first()
if permission:
group.permissions.add(permission)
group.save()
instance.groups.add(group)
@receiver(signals.post_save, sender=Program)
def program_post_save(sender, instance, created, *args, **kwargs):
"""
Clean-up later diffusions when a program becomes inactive
"""
if not instance.active:
Diffusion.objects.program(instance).after().delete()
Episode.object.program(instance).filter(diffusion__isnull=True) \
.delete()
@receiver(signals.pre_save, sender=Schedule)
def schedule_pre_save(sender, instance, *args, **kwargs):
if getattr(instance, 'pk') is not None:
instance._initial = Schedule.objects.get(pk=instance.pk)
# TODO
@receiver(signals.post_save, sender=Schedule)
def schedule_post_save(sender, instance, created, *args, **kwargs):
"""
Handles Schedule's time, duration and timezone changes and update
corresponding diffusions accordingly.
"""
initial = getattr(instance, '_initial', None)
if not initial or ((instance.time, instance.duration, instance.timezone) ==
(initial.time, initial.duration, initial.timezone)):
return
today = tz.datetime.today()
delta = instance.normalize(today) - initial.normalize(today)
qs = Diffusion.objects.program(instance.program).after()
pks = [d.pk for d in qs if initial.match(d.date)]
qs.filter(pk__in=pks).update(
start=F('start') + delta,
end=F('start') + delta + utils.to_timedelta(instance.duration)
)
@receiver(signals.pre_delete, sender=Schedule)
def schedule_pre_delete(sender, instance, *args, **kwargs):
"""
Delete later corresponding diffusion to a changed schedule.
"""
if not instance.program.sync:
return
qs = Diffusion.objects.program(instance.program).after()
pks = [d.pk for d in qs if instance.match(d.date)]
qs.filter(pk__in=pks).delete()
@receiver(signals.post_delete, sender=Diffusion)
def diffusion_post_delete(sender, instance, *args, **kwargs):
Episode.objects.filter(diffusion__isnull=True, content_isnull=True,
sound__isnull=True) \
.delete()

View File

@ -3,9 +3,11 @@ import stat
from django.conf import settings
def ensure (key, default):
def ensure(key, default):
globals()[key] = getattr(settings, key, default)
########################################################################
# Global & misc
########################################################################
@ -48,7 +50,7 @@ ensure('AIRCOX_EPISODE_TITLE_DATE_FORMAT', '%-d %B %Y')
# Directory where to save logs' archives
ensure('AIRCOX_LOGS_ARCHIVES_DIR',
os.path.join(AIRCOX_DATA_DIR, 'archives')
)
)
# In days, minimal age of a log before it is archived
ensure('AIRCOX_LOGS_ARCHIVES_MIN_AGE', 60)
@ -70,21 +72,21 @@ ensure('AIRCOX_SOUND_AUTO_CHMOD', True)
# and stat.*
ensure(
'AIRCOX_SOUND_CHMOD_FLAGS',
(stat.S_IRWXU, stat.S_IRWXU | stat.S_IRWXG | stat.S_IROTH )
(stat.S_IRWXU, stat.S_IRWXU | stat.S_IRWXG | stat.S_IROTH)
)
# Quality attributes passed to sound_quality_check from sounds_monitor
ensure('AIRCOX_SOUND_QUALITY', {
'attribute': 'RMS lev dB',
'range': (-18.0, -8.0),
'sample_length': 120,
}
'attribute': 'RMS lev dB',
'range': (-18.0, -8.0),
'sample_length': 120,
}
)
# Extension of sound files
ensure(
'AIRCOX_SOUND_FILE_EXT',
('.ogg','.flac','.wav','.mp3','.opus')
('.ogg', '.flac', '.wav', '.mp3', '.opus')
)
@ -107,6 +109,3 @@ ensure(
ensure('AIRCOX_IMPORT_PLAYLIST_CSV_DELIMITER', ';')
# Text delimiter of csv text files
ensure('AIRCOX_IMPORT_PLAYLIST_CSV_TEXT_QUOTE', '"')

View File

@ -1,92 +0,0 @@
import pytz
from django.contrib.auth.models import User, Group, Permission
from django.contrib.contenttypes.models import ContentType
from django.db.models import F
from django.db.models.signals import post_save, pre_save, pre_delete, m2m_changed
from django.dispatch import receiver, Signal
from django.utils import timezone as tz
from django.utils.translation import ugettext as _, ugettext_lazy
import aircox.models as models
import aircox.utils as utils
import aircox.settings as settings
# Add a default group to a user when it is created. It also assigns a list
# of permissions to the group if it is created.
#
# - group name: settings.AIRCOX_DEFAULT_USER_GROUP
# - group permissions: settings.AIRCOX_DEFAULT_USER_GROUP_PERMS
#
@receiver(post_save, sender=User)
def user_default_groups(sender, instance, created, *args, **kwargs):
"""
Set users to different default groups
"""
if not created or instance.is_superuser:
return
for groupName, permissions in settings.AIRCOX_DEFAULT_USER_GROUPS.items():
if instance.groups.filter(name = groupName).count():
continue
group, created = Group.objects.get_or_create(name = groupName)
if created and permissions:
for codename in permissions:
permission = Permission.objects.filter(codename = codename).first()
if permission:
group.permissions.add(permission)
group.save()
instance.groups.add(group)
@receiver(post_save, sender=models.Program)
def program_post_save(sender, instance, created, *args, **kwargs):
"""
Clean-up later diffusions when a program becomes inactive
"""
if not instance.active:
instance.diffusion_set.after().delete()
@receiver(post_save, sender=models.Schedule)
def schedule_post_save(sender, instance, created, *args, **kwargs):
"""
Handles Schedule's time, duration and timezone changes and update
corresponding diffusions accordingly.
"""
if created or not instance.program.sync or \
not instance.changed(['time','duration','timezone']):
return
initial = instance._Schedule__initial
initial = models.Schedule(**{ k: v
for k, v in instance._Schedule__initial.items()
if not k.startswith('_')
})
today = tz.datetime.today()
delta = instance.normalize(today) - \
initial.normalize(today)
qs = models.Diffusion.objects.program(instance.program).after()
pks = [ d.pk for d in qs if initial.match(d.date) ]
qs.filter(pk__in = pks).update(
start = F('start') + delta,
end = F('start') + delta + utils.to_timedelta(instance.duration)
)
@receiver(pre_delete, sender=models.Schedule)
def schedule_pre_delete(sender, instance, *args, **kwargs):
"""
Delete later corresponding diffusion to a changed schedule.
"""
if not instance.program.sync:
return
qs = models.Diffusion.objects.program(instance.program).after()
pks = [ d.pk for d in qs if instance.match(d.date) ]
qs.filter(pk__in = pks).delete()

View File

@ -7169,7 +7169,7 @@ label.panel-block {
float: right;
max-width: 45%; }
.page > .header {
.page .header {
margin-bottom: 1.5em; }
.page .headline {
@ -7179,6 +7179,11 @@ label.panel-block {
.page p {
padding: 0.4em 0em; }
section > .toolbar {
background-color: rgba(0, 0, 0, 0.05);
padding: 1em;
margin-bottom: 1.5em; }
.cover {
margin: 1em 0em;
border: 0.2em black solid; }

View File

@ -5,10 +5,11 @@ Context:
{% endcomment %}
<html>
<head>
<meta charset="utf-8">
<meta name="application-name" content="aircox">
<meta name="description" content="{{ site.description }}">
<meta name="keywords" content="{{ site.tags }}">
<meta charset="utf-8" />
<meta name="application-name" content="aircox" />
<meta name="description" content="{{ site.description }}" />
<meta name="keywords" content="{{ site.tags }}" />
<meta name="generator" content="Aircox" />
<link rel="icon" href="{% thumbnail site.favicon 32x32 crop %}" />
{% block assets %}
@ -18,7 +19,7 @@ Context:
{% endblock %}
<title>
{% block head_title %}{{ site.title }}{% endblock %}
{% block head_title %}{{ station.name }}{% endblock %}
</title>
{% block head_extra %}{% endblock %}
@ -52,12 +53,14 @@ Context:
{% block header %}
<h1 class="title is-1">{% block title %}{% endblock %}</h1>
{% if parent %}
<h4 class="subtitle is-size-3">
<a href="{{ parent.get_absolute_url }}">
<h4 class="subtitle is-size-3 columns">
{% block subtitle %}
{% if parent %}
<a href="{{ parent.get_absolute_url }}" class="column">
&#10092; {{ parent.title }}</a></li>
{% endif %}
{% endblock %}
</h4>
{% endif %}
{% endblock %}
</header>

View File

@ -1,59 +0,0 @@
{% load i18n easy_thumbnails_tags aircox %}
{% comment %}
Context variables:
- object: the actual diffusion
- page: current parent page in which item is rendered
- hide_schedule: if True, do not display start time
- hide_headline: if True, do not display headline
{% endcomment %}
{% with object.episode as episode %}
{% with episode.program as program %}
<article class="media">
<div class="media-left">
<img src="{% thumbnail episode.cover 128x128 crop=scale %}"
class="small-cover">
</div>
<div class="media-content">
<h5 class="subtitle is-size-5">
{% if episode.is_published %}
<a href="{{ episode.get_absolute_url }}">{{ episode.title }}</a>
{% endif %}
</h5>
<div class="">
{% if not page or program != page %}
{% if program.is_published %}
<a href="{{ program.get_absolute_url }}" class="has-text-grey-dark">
{{ program.title }}</a>
{% else %}{{ program.title }}
{% endif %}
{% if not hide_schedule %} &mdash; {% endif %}
{% endif %}
{% if not hide_schedule %}
<time datetime="{{ object.start|date:"c" }}" title="{{ object.start }}"
class="has-text-weight-light is-size-6">
{{ object.start|date:"d M, H:i" }}
</time>
{% endif %}
{% if object.initial %}
{% with object.initial.date as date %}
<span class="tag is-info" title="{% blocktrans %}Rerun of {{ date }}{% endblocktrans %}">
{% trans "rerun" %}
</span>
{% endwith %}
{% endif %}
</span>
</div>
{% if not hide_headline %}
<div class="content">
{{ episode.headline }}
</div>
{% endif %}
</div>
</article>
{% endwith %}
{% endwith %}

View File

@ -1,51 +0,0 @@
{% extends "aircox/page.html" %}
{% load i18n aircox %}
{% block title %}
{% if program %}
{% with program.name as program %}
{% blocktrans %}Diffusions of {{ program }}{% endblocktrans %}
{% endwith %}
{% else %}
{% trans "All diffusions" %}
{% endif %}
{% endblock %}
{% block content %}
<section>
{% for object in object_list %}
{% include "aircox/diffusion_item.html" %}
{% endfor %}
</section>
{% if is_paginated %}
<nav class="pagination is-centered" role="pagination" aria-label="{% trans "pagination" %}">
{% if page_obj.has_previous %}
<a href="?page={{ page_obj.previous_page_number }}" class="pagination-previous">
{% else %}
<a class="pagination-previous" disabled>
{% endif %}
{% trans "Previous" %}</a>
{% if page_obj.has_next %}
<a href="?page={{ page_obj.next_page_number }}" class="pagination-next">
{% else %}
<a class="pagination-next" disabled>
{% endif %}
{% trans "Next" %}</a>
<ul class="pagination-list">
{% for i in paginator.page_range %}
<li>
<a class="pagination-link {% if page_obj.number == i %}is-current{% endif %}"
href="?page={{ i }}">{{ i }}</a>
</li>
{% endfor %}
</ul>
</nav>
{% endif %}
</section>
{% endblock %}

View File

@ -34,15 +34,17 @@
{% for day, diffusions in by_date.items %}
<noscript><h4 class="subtitle is-4">{{ day|date:"l d F Y" }}</h4></noscript>
<div id="{{timetable_id}}-{{ day|date:"Y-m-d" }}" v-if="value == '{{ day }}'">
{% for object in diffusions %}
{% for diffusion in diffusions %}
<div class="columns">
<div class="column is-one-fifth has-text-right">
<time datetime="{{ object.start|date:"c" }}">
{{ object.start|date:"H:i" }} - {{ object.end|date:"H:i" }}
<time datetime="{{ diffusion.start|date:"c" }}">
{{ diffusion.start|date:"H:i" }} - {{ diffusion.end|date:"H:i" }}
</time>
</div>
<div class="column">
{% include "aircox/diffusion_item.html" %}
{% with diffusion.episode as object %}
{% include "aircox/episode_item.html" %}
{% endwith %}
</div>
</div>
{% endfor %}

View File

@ -1,6 +1,33 @@
{% extends "aircox/program_base.html" %}
{% load i18n %}
{% block header %}
{{ block.super }}
<section class="is-size-5 has-text-weight-bold">
{% for diffusion in object.diffusion_set.all %}
{% with diffusion.start as start %}
{% with diffusion.end as end %}
<time datetime="{{ start }}">{{ start|date:"D. d F Y, H:i" }}</time>
&mdash;
<time datetime="{{ end }}">{{ end|date:"H:i" }}</time>
{% endwith %}
{% endwith %}
<small>
{% if diffusion.initial %}
{% with diffusion.initial.date as date %}
<span title="{% blocktrans %}Rerun of {{ date }}{% endblocktrans %}">
({% trans "rerun" %})
</span>
{% endwith %}
{% endif %}
</small>
<br>
{% endfor %}
</section>
{% endblock %}
{% block main %}
{{ block.super }}

View File

@ -39,7 +39,11 @@
{{ object.start|date:"H:i" }} - {{ object.end|date:"H:i" }}
</time>
</td>
<td>{% include "aircox/diffusion_item.html" %}</td>
{% with object as diffusion %}
{% with diffusion.episode as object %}
<td>{% include "aircox/episode_item.html" %}</td>
{% endwith %}
{% endwith %}
{% else %}
<td>
<time datetime="{{ object.date }}" title="{{ object.date }}">

View File

@ -7,10 +7,17 @@ Context:
- page: page
{% endcomment %}
{% block subtitle %}
{{ block.super }}
{% if page.category %}
<span class="column has-text-right">{{ page.category.title }}</span>
{% endif %}
{% endblock %}
{% block head_title %}
{% block title %}{{ title }}{% endblock %}
{% if title %} &mdash; {% endif %}
{{ site.title }}
{% if title %} &dash; {% endif %}
{{ station.name }}
{% endblock %}

View File

@ -1,6 +1,6 @@
<div class="podcast">
{% if object.embed %}
{{ object.embed }}
{{ object.embed|safe }}
{% else %}
<audio src="{{ object.url }}" controls>
{% endif %}

View File

@ -4,22 +4,22 @@
{% block side_nav %}
{{ block.super }}
{% if diffusions %}
{% if episodes %}
<section>
<h4 class="subtitle is-size-4">{% trans "Last shows" %}</h4>
{% for object in diffusions %}
{% include "aircox/diffusion_item.html" %}
{% for object in episodes %}
{% include "aircox/episode_item.html" %}
{% endfor %}
<br>
<nav class="pagination is-centered">
<ul class="pagination-list">
<li>
<a href="{% url "diffusion-list" program_slug=page.slug %}"
<a href="{% url "diffusion-list" program_slug=program.slug %}"
class="pagination-link"
aria-label="{% trans "Show all diffusions" %}">
{% trans "All diffusions" %}
{% trans "All shows" %}
</a>
</li>
</ul>

View File

@ -1,25 +1,24 @@
{% load i18n %}
<section class="is-size-5">
<section class="is-size-5 has-text-weight-bold">
{% for schedule in program.schedule_set.all %}
<p>
{{ schedule.get_frequency_verbose }}
{% with schedule.start|date:"H:i" as start %}
{% with schedule.end|date:"H:i" as end %}
<time datetime="{{ start }}">{{ start }}</time>
&mdash;
<time datetime="{{ end }}">{{ end }}</time>
{{ schedule.get_frequency_verbose }}
{% with schedule.start|date:"H:i" as start %}
{% with schedule.end|date:"H:i" as end %}
<time datetime="{{ start }}">{{ start }}</time>
&mdash;
<time datetime="{{ end }}">{{ end }}</time>
{% endwith %}
{% endwith %}
<small>
{% if schedule.initial %}
{% with schedule.initial.date as date %}
<span title="{% blocktrans %}Rerun of {{ date }}{% endblocktrans %}">
({% trans "rerun" %})
</span>
{% endwith %}
{% endwith %}
<small>
{% if schedule.initial %}
{% with schedule.initial.date as date %}
<span title="{% blocktrans %}Rerun of {{ date }}{% endblocktrans %}">
({% trans "rerun" %})
</span>
{% endwith %}
{% endif %}
</small>
</p>
{% endif %}
</small>
<br>
{% endfor %}
</section>

View File

@ -8,17 +8,33 @@ random.seed()
register = template.Library()
@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='verbose_name')
def do_verbose_name(obj, plural=False):
""" Return model's verbose name (singular or plural) """
return obj._meta.verbose_name_plural if plural else \
obj._meta.verbose_name
@register.simple_tag(name='update_query')
def do_update_query(obj, **kwargs):
""" Replace provided querydict's values with **kwargs. """
for k, v in kwargs.items():
if v is not None:
obj[k] = list(v) if hasattr(v, '__iter__') else [v]
elif k in obj:
obj.pop(k)
return obj
@register.filter(name='is_diffusion')
def do_is_diffusion(obj):
return isinstance(obj, Diffusion)
@register.simple_tag(name='unique_id')
def do_unique_id(prefix=''):
value = str(random.random()).replace('.', '')
return prefix + '_' + value if prefix else value
@register.simple_tag(name='nav_items', takes_context=True)
def do_nav_items(context, menu, **kwargs):
station, request = context['station'], context['request']

View File

@ -11,20 +11,22 @@ from aircox.models import *
logger = logging.getLogger('aircox.test')
logger.setLevel('INFO')
class ScheduleCheck (TestCase):
def setUp(self):
self.schedules = [
Schedule(
date = tz.now(),
duration = datetime.time(1,30),
frequency = frequency,
date=tz.now(),
duration=datetime.time(1, 30),
frequency=frequency,
)
for frequency in Schedule.Frequency.__members__.values()
]
def test_frequencies(self):
for schedule in self.schedules:
logger.info('- test frequency %s' % schedule.get_frequency_display())
logger.info('- test frequency %s' %
schedule.get_frequency_display())
date = schedule.date
count = 24
while count:
@ -40,7 +42,7 @@ class ScheduleCheck (TestCase):
self.check_last(schedule, date, dates)
else:
pass
date += relativedelta(months = 1)
date += relativedelta(months=1)
def check_one_on_two(self, schedule, date, dates):
for date in dates:
@ -53,14 +55,11 @@ class ScheduleCheck (TestCase):
# 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=7)
date -= datetime.timedelta(days = date.weekday())
date += datetime.timedelta(days = schedule.date.weekday())
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

View File

@ -1,7 +1,7 @@
from django.urls import path, register_converter
from django.utils.translation import ugettext_lazy as _
from . import views
from . import views, models
from .converters import PagePathConverter, DateConverter, WeekConverter
@ -10,21 +10,32 @@ register_converter(DateConverter, 'date')
register_converter(WeekConverter, 'week')
#urls = [
# urls = [
# path('on_air', views.on_air, name='aircox.on_air'),
# path('monitor', views.Monitor.as_view(), name='aircox.monitor'),
# path('stats', views.StatisticsView.as_view(), name='aircox.stats'),
#]
# ]
urls = [
# path('', views.PageDetailView.as_view(model=models.Article),
# name='home'),
path(_('articles/'),
views.ArticleListView.as_view(model=models.Article, is_static=False),
name='article-list'),
path(_('articles/<slug:slug>/'),
views.PageDetailView.as_view(model=models.Article),
name='article-detail'),
path(_('programs/'), views.PageListView.as_view(model=models.Program),
name='program-list'),
path(_('programs/<slug:slug>/'),
views.ProgramDetailView.as_view(), name='program-detail'),
path(_('programs/<slug:program_slug>/episodes/'),
views.DiffusionListView.as_view(), name='diffusion-list'),
views.EpisodeListView.as_view(), name='diffusion-list'),
path(_('episodes/'),
views.DiffusionListView.as_view(), name='diffusion-list'),
views.EpisodeListView.as_view(), name='diffusion-list'),
path(_('episodes/week/'),
views.TimetableView.as_view(), name='timetable'),
path(_('episodes/week/<week:date>/'),
@ -36,5 +47,3 @@ urls = [
path(_('logs/<date:date>/'), views.LogListView.as_view(), name='logs'),
# path('<page_path:path>', views.route_page, name='page'),
]

View File

@ -8,6 +8,7 @@ __all__ = ['Redirect', 'redirect', 'date_range', 'cast_date',
class Redirect(Exception):
""" Redirect exception -- see `redirect()`. """
def __init__(self, url):
self.url = url
@ -82,4 +83,3 @@ def seconds_to_time(seconds):
minutes, seconds = divmod(seconds, 60)
hours, minutes = divmod(minutes, 60)
return datetime.time(hour=hours, minute=minutes, second=seconds)

View File

@ -2,17 +2,15 @@ import os
import json
import datetime
from django.db.models import Count
from django.views.generic.base import View, TemplateResponseMixin
from django.contrib.auth.mixins import LoginRequiredMixin
from django.http import HttpResponse, Http404
from django.shortcuts import render
from django.utils.translation import ugettext as _, ugettext_lazy
from django.utils.translation import ugettext as _
from django.utils import timezone as tz
from django.views.decorators.cache import never_cache, cache_page
from django.views.decorators.cache import cache_page
import aircox.models as models
import aircox.settings as settings
# FIXME usefull?
@ -29,6 +27,7 @@ class Stations:
for station in self.stations:
station.streamer.fetch()
stations = Stations()
@ -102,7 +101,7 @@ class Monitor(View, TemplateResponseMixin, LoginRequiredMixin):
return HttpResponse('')
POST = request.POST
controller = POST.get('controller')
POST.get('controller')
action = POST.get('action')
station = stations.stations.filter(name=POST.get('station')) \
@ -256,5 +255,3 @@ class StatisticsView(View, TemplateResponseMixin, LoginRequiredMixin):
self.request = request
context = self.get_context_data(**kwargs)
return render(request, self.template_name, context)

7
aircox/views/__init__.py Normal file
View File

@ -0,0 +1,7 @@
from .article import ArticleListView
from .base import BaseView
from .episode import EpisodeDetailView, EpisodeListView, TimetableView
from .log import LogListView
from .page import PageDetailView, PageListView
from .program import ProgramDetailView

14
aircox/views/article.py Normal file
View File

@ -0,0 +1,14 @@
from ..models import Article
from .page import PageListView
__all__ = ['ArticleListView']
class ArticleListView(PageListView):
model = Article
is_static = False
def get_queryset(self):
return super().get_queryset(is_static=self.is_static)

32
aircox/views/base.py Normal file
View File

@ -0,0 +1,32 @@
from django.http import Http404
from django.views.generic import DetailView, ListView
from django.views.generic.base import TemplateResponseMixin, ContextMixin
from ..utils import Redirect
__all__ = ['BaseView', 'PageView']
class BaseView(TemplateResponseMixin, ContextMixin):
show_side_nav = False
""" Show side navigation """
title = None
""" Page title """
cover = None
""" Page cover """
@property
def station(self):
return self.request.station
def get_queryset(self):
return super().get_queryset().station(self.station)
def get_context_data(self, **kwargs):
kwargs.setdefault('station', self.station)
kwargs.setdefault('cover', self.cover)
kwargs.setdefault('show_side_nav', self.show_side_nav)
return super().get_context_data(**kwargs)

99
aircox/views/episode.py Normal file
View File

@ -0,0 +1,99 @@
from collections import OrderedDict
import datetime
from django.db.models import OuterRef, Subquery
from django.shortcuts import get_object_or_404
from django.views.generic import ListView
from ..models import Diffusion, Episode, Page, Program, Sound
from .base import BaseView
from .page import PageListView
from .program import ProgramPageDetailView
__all__ = ['EpisodeDetailView', 'DiffusionListView', 'TimetableView']
class EpisodeDetailView(ProgramPageDetailView):
model = Episode
def get_podcasts(self, diffusion):
return Sound.objects.diffusion(diffusion).podcasts()
def get_context_data(self, **kwargs):
kwargs.setdefault('program', self.object.program)
kwargs.setdefault('parent', kwargs['program'])
if not 'podcasts' in kwargs:
kwargs['podcasts'] = self.object.sound_set.podcasts()
return super().get_context_data(**kwargs)
# TODO: pagination: in template, only a limited number of pages displayed
class EpisodeListView(PageListView):
model = Episode
item_template_name = 'aircox/episode_item.html'
show_headline = True
template_name = 'aircox/diffusion_list.html'
program = None
def get(self, request, *args, **kwargs):
program_slug = kwargs.get('program_slug')
if program_slug:
self.program = get_object_or_404(Program, slug=program_slug)
return super().get(request, *args, **kwargs)
def get_queryset(self):
qs = super().get_queryset()
if self.program:
qs = qs.filter(program=self.program)
return qs
def get_context_data(self, **kwargs):
program = kwargs.setdefault('program', self.program)
if program is not None:
kwargs.setdefault('cover', program.cover)
kwargs.setdefault('parent', program)
return super().get_context_data(**kwargs)
class TimetableView(BaseView, ListView):
""" View for timetables """
template_name_suffix = '_timetable'
model = Diffusion
# ordering = ('start',)
date = None
start = None
end = None
def get_queryset(self):
self.date = self.kwargs.get('date') or datetime.date.today()
self.start = self.date - datetime.timedelta(days=self.date.weekday())
self.end = self.start + datetime.timedelta(days=7)
return super().get_queryset().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 diffusion.date not 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
)

96
aircox/views/log.py Normal file
View File

@ -0,0 +1,96 @@
from collections import deque
import datetime
from django.views.generic import ListView
from ..models import Diffusion, Log
from .base import BaseView
__all__ = ['BaseLogView', 'LogListView']
class BaseLogView(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 Diffusion.objects.station(self.station).on_air() \
.today(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 = None
last_collision = None
# TODO/FIXME: multiple diffs at once - recheck the whole algorithm in
# detail -- however I barely see cases except when there are diff
# collision or the streamer is not working
for index, log in enumerate(logs):
# get next diff
if diff is None or diff.end < log.date:
diff = diffs.popleft() if len(diffs) else None
# no more diff that can collide: return list
if diff is None:
if last_collision and not object_list or \
object_list[-1] is not last_collision:
object_list.append(last_collision)
return object_list + logs[index:]
# diff colliding with log
if diff.start <= log.date:
if not object_list or object_list[-1] is not diff:
object_list.append(diff)
if log.date <= diff.end:
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 LogListView(BaseView, BaseLogView):
model = Log
date = None
max_age = 10
min_date = None
def get(self, request, *args, **kwargs):
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
)

84
aircox/views/page.py Normal file
View File

@ -0,0 +1,84 @@
from django.http import Http404
from django.views.generic import DetailView, ListView
from ..models import Category
from ..utils import Redirect
from .base import BaseView
__all__ = ['PageDetailView', 'PageListView']
class PageDetailView(BaseView, DetailView):
""" Base view class for pages. """
context_object_name = 'page'
def get_queryset(self):
return super().get_queryset().select_related('cover', 'category')
# This should not exists: it allows mapping not published pages
# or it should be only used for trashed pages.
def not_published_redirect(self, page):
"""
When a page is not published, redirect to the returned url instead
of an HTTP 404 code. """
return None
def get_object(self):
obj = super().get_object()
if not obj.is_published:
redirect_url = self.not_published_redirect(obj)
if redirect_url:
raise Redirect(redirect_url)
raise Http404('%s not found' % self.model._meta.verbose_name)
return obj
def get_context_data(self, **kwargs):
#if kwargs.get('regions') is None:
# contents = contents_for_item(
# page, page_renderer._renderers.keys())
# kwargs['regions'] = contents.render_regions(page_renderer)
page = kwargs.setdefault('page', self.object)
kwargs.setdefault('title', page.title)
kwargs.setdefault('cover', page.cover)
return super().get_context_data(**kwargs)
class PageListView(BaseView, ListView):
template_name = 'aircox/page_list.html'
item_template_name = 'aircox/page_item.html'
paginate_by = 10
show_headline = True
show_side_nav = True
categories = None
def get(self, *args, **kwargs):
self.categories = set(self.request.GET.getlist('categories'))
return super().get(*args, **kwargs)
def get_queryset(self):
qs = super().get_queryset().published() \
.select_related('cover', 'category')
# category can be filtered based on request.GET['categories']
# (by id)
if self.categories:
qs = qs.filter(category__slug__in=self.categories)
return qs.order_by('-date')
def get_categories_queryset(self):
# TODO: use generic reverse field lookup
categories = self.model.objects.published() \
.filter(category__isnull=False) \
.values_list('category', flat=True)
return Category.objects.filter(id__in=categories)
def get_context_data(self, **kwargs):
kwargs.setdefault('item_template_name', self.item_template_name)
kwargs.setdefault('filter_categories', self.get_categories_queryset())
kwargs.setdefault('categories', self.categories)
kwargs.setdefault('show_headline', self.show_headline)
return super().get_context_data(**kwargs)

29
aircox/views/program.py Normal file
View File

@ -0,0 +1,29 @@
from aircox.models import Episode, Program
from .page import PageDetailView
__all__ = ['ProgramPageDetailView', 'ProgramDetailView']
class ProgramPageDetailView(PageDetailView):
""" Base view class for rendering content of a specific programs. """
show_side_nav = True
list_count=5
def get_episodes_queryset(self, program):
return program.episode_set.published().order_by('-date')
def get_context_data(self, program, episodes=None, **kwargs):
if episodes is None:
episodes = self.get_episodes_queryset(program)
return super().get_context_data(
program=program, episodes=episodes[:self.list_count], **kwargs)
class ProgramDetailView(ProgramPageDetailView):
model = Program
def get_context_data(self, **kwargs):
kwargs.setdefault('program', self.object)
return super().get_context_data(**kwargs)

View File

@ -33,7 +33,7 @@ $body-background-color: $light;
max-width: 45%;
}
& > .header {
.header {
margin-bottom: 1.5em;
}
@ -47,6 +47,12 @@ $body-background-color: $light;
}
}
section > .toolbar {
background-color: rgba(0,0,0,0.05);
padding: 1em;
margin-bottom: 1.5em;
}
.cover {
margin: 1em 0em;