work on website + page becomes concrete

This commit is contained in:
bkfox
2019-09-05 14:12:12 +02:00
parent 595af5a69d
commit c46f006379
88 changed files with 476 additions and 9823 deletions

View File

@ -1,24 +1,10 @@
# Aircox Programs
This application defines all base models and basic control of them. We have:
* **Nameable**: generic class used in any class needing to be named. Includes some utility functions;
* **Station**: a station
* **Program**: the program itself;
* **Diffusion**: occurrence of a program planified in the timetable. For rerun, informations are bound to the initial diffusion;
* **Schedule**: describes diffusions frequencies for each program;
* **Track**: track informations in a playlist of a diffusion;
* **Sound**: information about a sound that can be used for podcast or rerun;
* **Log**: logs
# Aircox
Aircox application aims to provide basis of a radio management system.
## Architecture
A Station is basically an object that represent a radio station. On each station, we use the Program object, that is declined in two different types:
* **Scheduled**: the diffusion is based on a timetable and planified through one Schedule or more; Diffusion object represent the occurrence of these programs;
* **Streamed**: the diffusion is based on random playlist, used to fill gaps between the programs;
A Station contains programs that can be scheduled or streamed. A *Scheduled Program* is a regular show that has planified diffusions of its occurences (episodes). A *Streamed Program* is a program used to play randoms musics between the shows.
Each program has a directory in **AIRCOX_PROGRAMS_DIR**; For each, subdir:
* **archives**: complete episode record, can be used for diffusions or as a podcast
* **excerpts**: excerpt of an episode, or other elements, can be used as a podcast
Each program has a directory on the server where user puts its podcasts (in **AIRCOX_PROGRAM_DIR**). It contains the directories **archives** (complete show's podcasts) and **excerpts** (partial or whatever podcasts).
## manage.py's commands

View File

@ -25,6 +25,8 @@ class PageAdmin(admin.ModelAdmin):
list_editable = ('status', 'category')
prepopulated_fields = {"slug": ("title",)}
change_form_template = 'admin/aircox/page_change_form.html'
fieldsets = [
('', {
'fields': ['title', 'slug', 'category', 'cover', 'content'],

View File

@ -78,18 +78,14 @@ class Streamer:
@property
def inputs(self):
""" Return input ports of the station """
return self.station.port_set.filter(
direction=Port.Direction.input,
active=True
)
return self.station.port_set.filter(direction=Port.DIRECTION_INPUT,
active=True)
@property
def outputs(self):
""" Return output ports of the station """
return self.station.port_set.filter(
direction=Port.Direction.output,
active=True,
)
return self.station.port_set.filter(direction=Port.DIRECTION_OUTPUT,
active=True)
# Sources and config ###############################################
def send(self, *args, **kwargs):

View File

@ -57,14 +57,14 @@ class Actions:
diffusion.save()
def clean(self):
qs = Diffusion.objects.filter(type=Diffusion.Type.unconfirmed,
qs = Diffusion.objects.filter(type=Diffusion.TYPE_UNCONFIRMED,
start__lt=self.date)
logger.info('[clean] %d diffusions will be removed', qs.count())
qs.delete()
def check(self):
# TODO: redo
qs = Diffusion.objects.filter(type=Diffusion.Type.unconfirmed,
qs = Diffusion.objects.filter(type=Diffusion.TYPE_UNCONFIRMED,
start__gt=self.date)
items = []
for diffusion in qs:

View File

@ -184,9 +184,9 @@ class MonitorHandler(PatternMatchingEventHandler):
"""
self.subdir = subdir
if self.subdir == settings.AIRCOX_SOUND_ARCHIVES_SUBDIR:
self.sound_kwargs = {'type': Sound.Type.archive}
self.sound_kwargs = {'type': Sound.TYPE_ARCHIVE}
else:
self.sound_kwargs = {'type': Sound.Type.excerpt}
self.sound_kwargs = {'type': Sound.TYPE_EXCERPT}
patterns = ['*/{}/*{}'.format(self.subdir, ext)
for ext in settings.AIRCOX_SOUND_FILE_EXT]
@ -213,7 +213,7 @@ class MonitorHandler(PatternMatchingEventHandler):
sound = Sound.objects.filter(path=event.src_path)
if sound:
sound = sound[0]
sound.type = sound.Type.removed
sound.type = sound.TYPE_REMOVED
sound.save()
def on_moved(self, event):
@ -259,11 +259,11 @@ class Command(BaseCommand):
logger.info('#%d %s', program.id, program.title)
self.scan_for_program(
program, settings.AIRCOX_SOUND_ARCHIVES_SUBDIR,
type=Sound.Type.archive,
type=Sound.TYPE_ARCHIVE,
)
self.scan_for_program(
program, settings.AIRCOX_SOUND_EXCERPTS_SUBDIR,
type=Sound.Type.excerpt,
type=Sound.TYPE_EXCERPT,
)
dirs.append(os.path.join(program.path))
@ -317,7 +317,7 @@ class Command(BaseCommand):
# get available sound files
sounds = Sound.objects.filter(is_good_quality=False) \
.exclude(type=Sound.Type.removed)
.exclude(type=Sound.TYPE_REMOVED)
if check:
self.check_sounds(sounds)

View File

@ -152,7 +152,7 @@ class Monitor:
.now(air_time).first()
# log sound on air
return self.log(type=Log.Type.on_air, date=source.air_time,
return self.log(type=Log.TYPE_ON_AIR, date=source.air_time,
source=source.id, sound=sound, diffusion=diff,
comment=air_uri)
@ -177,7 +177,7 @@ class Monitor:
if pos > now:
break
# log track on air
self.log(type=Log.Type.on_air, date=pos, source=log.source,
self.log(type=Log.TYPE_ON_AIR, date=pos, source=log.source,
track=track, comment=track)
def handle_diffusions(self):
@ -208,7 +208,7 @@ class Monitor:
#
now = tz.now()
diff = Diffusion.objects.station(self.station).on_air().now(now) \
.filter(episode__sound__type=Sound.Type.archive) \
.filter(episode__sound__type=Sound.TYPE_ARCHIVE) \
.first()
# Can't use delay: diffusion may start later than its assigned start.
log = None if not diff else self.logs.start().filter(diffusion=diff)
@ -228,13 +228,13 @@ class Monitor:
def start_diff(self, source, diff):
playlist = Sound.objects.episode(id=diff.episode_id).paths()
source.append(*playlist)
self.log(type=Log.Type.start, source=source.id, diffusion=diff,
self.log(type=Log.TYPE_START, source=source.id, diffusion=diff,
comment=str(diff))
def cancel_diff(self, source, diff):
diff.type = Diffusion.Type.cancel
diff.type = Diffusion.TYPE_CANCEL
diff.save()
self.log(type=Log.Type.cancel, source=source.id, diffusion=diff,
self.log(type=Log.TYPE_CANCEL, source=source.id, diffusion=diff,
comment=str(diff))
def sync(self):

View File

@ -1,11 +1,17 @@
from django.db import models
from django.utils.translation import ugettext_lazy as _
from .page import Page
from .page import Page, PageQuerySet
from .program import Program, InProgramQuerySet
class ArticleQuerySet(InProgramQuerySet, PageQuerySet):
pass
class Article(Page):
detail_url_name = 'article-detail'
program = models.ForeignKey(
Program, models.SET_NULL,
verbose_name=_('program'), blank=True, null=True,
@ -17,7 +23,7 @@ class Article(Page):
'instead of a blog article'),
)
objects = InProgramQuerySet.as_manager()
objects = ArticleQuerySet.as_manager()
class Meta:
verbose_name = _('Article')

View File

@ -1,9 +1,7 @@
import datetime
from enum import IntEnum
from django.db import models
from django.db.models import F, Q
from django.db.models.functions import Concat, Substr
from django.db.models import Q
from django.utils import timezone as tz
from django.utils.translation import ugettext_lazy as _
from django.utils.functional import cached_property
@ -64,7 +62,7 @@ class DiffusionQuerySet(BaseRerunQuerySet):
def on_air(self):
""" On air diffusions """
return self.filter(type=Diffusion.Type.on_air)
return self.filter(type=Diffusion.TYPE_ON_AIR)
def now(self, now=None, order=True):
""" Diffusions occuring now """
@ -132,20 +130,20 @@ class Diffusion(BaseRerun):
"""
objects = DiffusionQuerySet.as_manager()
class Type(IntEnum):
on_air = 0x00
unconfirmed = 0x01
cancel = 0x02
TYPE_ON_AIR = 0x00
TYPE_UNCONFIRMED = 0x01
TYPE_CANCEL = 0x02
TYPE_CHOICES = (
(TYPE_ON_AIR, _('on air')),
(TYPE_UNCONFIRMED, _('not confirmed')),
(TYPE_CANCEL, _('cancelled')),
)
episode = models.ForeignKey(
Episode, models.CASCADE,
verbose_name=_('episode'),
Episode, models.CASCADE, verbose_name=_('episode'),
)
type = models.SmallIntegerField(
verbose_name=_('type'),
default=Type.on_air,
choices=[(int(y), _(x.replace('_', ' ')))
for x, y in Type.__members__.items()],
verbose_name=_('type'), default=TYPE_ON_AIR, choices=TYPE_CHOICES,
)
start = models.DateTimeField(_('start'))
end = models.DateTimeField(_('end'))
@ -222,7 +220,7 @@ class Diffusion(BaseRerun):
# TODO: property?
def is_live(self):
""" True if Diffusion is live (False if there are sounds files). """
return self.type == self.Type.on_air and \
return self.type == self.TYPE_ON_AIR and \
not self.episode.sound_set.archive().count()
def get_playlist(self, **types):
@ -232,7 +230,7 @@ class Diffusion(BaseRerun):
"""
from .sound import Sound
return list(self.get_sounds(**types)
.filter(path__isnull=False, type=Sound.Type.archive)
.filter(path__isnull=False, type=Sound.TYPE_ARCHIVE)
.values_list('path', flat=True))
def get_sounds(self, **types):

View File

@ -1,6 +1,4 @@
from collections import deque
import datetime
from enum import IntEnum
import logging
import os
@ -9,7 +7,7 @@ from django.utils import timezone as tz
from django.utils.translation import ugettext_lazy as _
from aircox import settings, utils
from aircox import settings
from .episode import Diffusion
from .sound import Sound, Track
from .station import Station
@ -35,10 +33,10 @@ class LogQuerySet(models.QuerySet):
self.filter(date__date__gte=date)
def on_air(self):
return self.filter(type=Log.Type.on_air)
return self.filter(type=Log.TYPE_ON_AIR)
def start(self):
return self.filter(type=Log.Type.start)
return self.filter(type=Log.TYPE_START)
def with_diff(self, with_it=True):
return self.filter(diffusion__isnull=not with_it)
@ -163,43 +161,33 @@ class Log(models.Model):
This only remember what has been played on the outputs, not on each
source; Source designate here which source is responsible of that.
"""
class Type(IntEnum):
stop = 0x00
"""
Source has been stopped, e.g. manually
"""
# Rule: \/ diffusion != null \/ sound != null
start = 0x01
""" Diffusion or sound has been request to be played. """
cancel = 0x02
""" Diffusion has been canceled. """
# Rule: \/ sound != null /\ track == null
# \/ sound == null /\ track != null
# \/ sound == null /\ track == null /\ comment = sound_path
on_air = 0x03
"""
The sound or diffusion has been detected occurring on air. Can
also designate live diffusion, although Liquidsoap did not play
them since they don't have an attached sound archive.
"""
other = 0x04
""" Other log """
TYPE_STOP = 0x00
""" Source has been stopped, e.g. manually """
# Rule: \/ diffusion != null \/ sound != null
TYPE_START = 0x01
""" Diffusion or sound has been request to be played. """
TYPE_CANCEL = 0x02
""" Diffusion has been canceled. """
# Rule: \/ sound != null /\ track == null
# \/ sound == null /\ track != null
# \/ sound == null /\ track == null /\ comment = sound_path
TYPE_ON_AIR = 0x03
""" Sound or diffusion occured on air """
TYPE_OTHER = 0x04
""" Other log """
TYPE_CHOICES = (
(TYPE_STOP, _('stop')), (TYPE_START, _('start')),
(TYPE_CANCEL, _('cancelled')), (TYPE_ON_AIR, _('on air')),
(TYPE_OTHER, _('other'))
)
station = models.ForeignKey(
Station, models.CASCADE,
verbose_name=_('station'),
help_text=_('related station'),
)
type = models.SmallIntegerField(
choices=[(int(y), _(x.replace('_', ' ')))
for x, y in Type.__members__.items()],
blank=True, null=True,
verbose_name=_('type'),
)
date = models.DateTimeField(
default=tz.now, db_index=True,
verbose_name=_('date'),
verbose_name=_('station'), help_text=_('related station'),
)
type = models.SmallIntegerField(_('type'), choices=TYPE_CHOICES)
date = models.DateTimeField(_('date'), default=tz.now, db_index=True)
source = models.CharField(
# we use a CharField to avoid loosing logs information if the
# source is removed

View File

@ -38,28 +38,30 @@ class Category(models.Model):
class PageQuerySet(InheritanceQuerySet):
def draft(self):
return self.filter(status=Page.STATUS.draft)
return self.filter(status=Page.STATUS_DRAFT)
def published(self):
return self.filter(status=Page.STATUS.published)
return self.filter(status=Page.STATUS_PUBLISHED)
def trash(self):
return self.filter(status=Page.STATUS.trash)
return self.filter(status=Page.STATUS_TRASH)
class Page(models.Model):
""" Base class for publishable content """
class STATUS(IntEnum):
draft = 0x00
published = 0x10
trash = 0x20
STATUS_DRAFT = 0x00
STATUS_PUBLISHED = 0x10
STATUS_TRASH = 0x20
STATUS_CHOICES = (
(STATUS_DRAFT, _('draft')),
(STATUS_PUBLISHED, _('published')),
(STATUS_TRASH, _('trash')),
)
title = models.CharField(max_length=128)
slug = models.SlugField(_('slug'), blank=True, unique=True)
status = models.PositiveSmallIntegerField(
_('status'),
default=STATUS.draft,
choices=[(int(y), _(x)) for x, y in STATUS.__members__.items()],
_('status'), default=STATUS_DRAFT, choices=STATUS_CHOICES,
)
category = models.ForeignKey(
Category, models.SET_NULL,
@ -84,8 +86,6 @@ class Page(models.Model):
detail_url_name = None
class Meta:
abstract = True
def __str__(self):
return '{}: {}'.format(self._meta.verbose_name,
@ -104,15 +104,15 @@ class Page(models.Model):
@property
def is_draft(self):
return self.status == self.STATUS.draft
return self.status == self.STATUS_DRAFT
@property
def is_published(self):
return self.status == self.STATUS.published
return self.status == self.STATUS_PUBLISHED
@property
def is_trash(self):
return self.status == self.STATUS.trash
return self.status == self.STATUS_TRASH
@cached_property
def headline(self):
@ -132,6 +132,16 @@ class Page(models.Model):
return cls(**cls.get_init_kwargs_from(page, **kwargs))
class Comment(models.Model):
page = models.ForeignKey(
Page, models.CASCADE, verbose_name=_('related page'),
)
nickname = models.CharField(_('nickname'), max_length=32)
email = models.EmailField(_('email'), max_length=32)
date = models.DateTimeField(auto_now_add=True)
content = models.TextField(_('content'), max_length=1024)
class NavItem(models.Model):
""" Navigation menu items """
station = models.ForeignKey(

View File

@ -45,6 +45,11 @@ class Program(Page):
Renaming a Program rename the corresponding directory to matches the new
name if it does not exists.
"""
# explicit foreign key in order to avoid related name clashes
page = models.OneToOneField(
Page, models.CASCADE,
parent_link=True, related_name='program_page'
)
station = models.ForeignKey(
Station,
verbose_name=_('station'),
@ -478,7 +483,7 @@ class Schedule(BaseRerun):
initial = diffusions[initial]
diffusions[date] = Diffusion(
episode=episode, type=Diffusion.Type.on_air,
episode=episode, type=Diffusion.TYPE_ON_AIR,
initial=initial, start=date, end=date+duration
)
return episodes.values(), diffusions.values()

View File

@ -36,7 +36,7 @@ class SoundQuerySet(models.QuerySet):
def archive(self):
""" Return sounds that are archives """
return self.filter(type=Sound.Type.archive)
return self.filter(type=Sound.TYPE_ARCHIVE)
def paths(self, archive=True, order_by=True):
"""
@ -55,11 +55,14 @@ class Sound(models.Model):
A Sound is the representation of a sound file that can be either an excerpt
or a complete archive of the related diffusion.
"""
class Type(IntEnum):
other = 0x00,
archive = 0x01,
excerpt = 0x02,
removed = 0x03,
TYPE_OTHER = 0x00
TYPE_ARCHIVE = 0x01
TYPE_EXCERPT = 0x02
TYPE_REMOVED = 0x03
TYPE_CHOICES = (
(TYPE_OTHER, _('other')), (TYPE_ARCHIVE, _('archive')),
(TYPE_EXCERPT, _('excerpt')), (TYPE_REMOVED, _('removed'))
)
name = models.CharField(_('name'), max_length=64)
program = models.ForeignKey(
@ -72,11 +75,7 @@ class Sound(models.Model):
Episode, models.SET_NULL, blank=True, null=True,
verbose_name=_('episode'),
)
type = models.SmallIntegerField(
verbose_name=_('type'),
choices=[(int(y), _(x)) for x, y in Type.__members__.items()],
blank=True, null=True
)
type = models.SmallIntegerField(_('type'), choices=TYPE_CHOICES)
# FIXME: url() does not use the same directory than here
# should we use FileField for more reliability?
path = models.FilePathField(
@ -196,21 +195,21 @@ class Sound(models.Model):
"""
if not self.file_exists():
if self.type == self.Type.removed:
if self.type == self.TYPE_REMOVED:
return
logger.info('sound %s: has been removed', self.path)
self.type = self.Type.removed
self.type = self.TYPE_REMOVED
return True
# not anymore removed
changed = False
if self.type == self.Type.removed and self.program:
if self.type == self.TYPE_REMOVED and self.program:
changed = True
self.type = self.Type.archive \
self.type = self.TYPE_ARCHIVE \
if self.path.startswith(self.program.archives_path) else \
self.Type.excerpt
self.TYPE_EXCERPT
# check mtime -> reset quality if changed (assume file changed)
mtime = self.get_mtime()

View File

@ -1,4 +1,3 @@
from enum import IntEnum
import os
from django.db import models
@ -91,36 +90,32 @@ class Port(models.Model):
Some port types may be not available depending on the
direction of the port.
"""
class Direction(IntEnum):
input = 0x00
output = 0x01
DIRECTION_INPUT = 0x00
DIRECTION_OUTPUT = 0x01
DIRECTION_CHOICES = ((DIRECTION_INPUT, _('input')),
(DIRECTION_OUTPUT, _('output')))
class Type(IntEnum):
jack = 0x00
alsa = 0x01
pulseaudio = 0x02
icecast = 0x03
http = 0x04
https = 0x05
file = 0x06
TYPE_JACK = 0x00
TYPE_ALSA = 0x01
TYPE_PULSEAUDIO = 0x02
TYPE_ICECAST = 0x03
TYPE_HTTP = 0x04
TYPE_HTTPS = 0x05
TYPE_FILE = 0x06
TYPE_CHOICES = (
(TYPE_JACK, 'jack'), (TYPE_ALSA, 'alsa'),
(TYPE_PULSEAUDIO, 'pulseaudio'), (TYPE_ICECAST, 'icecast'),
(TYPE_HTTP, 'http'), (TYPE_HTTPS, 'https'),
(TYPE_FILE, _('file'))
)
station = models.ForeignKey(
Station,
verbose_name=_('station'),
on_delete=models.CASCADE,
)
Station, models.CASCADE, verbose_name=_('station'))
direction = models.SmallIntegerField(
_('direction'),
choices=[(int(y), _(x)) for x, y in Direction.__members__.items()],
)
type = models.SmallIntegerField(
_('type'),
# we don't translate the names since it is project names.
choices=[(int(y), x) for x, y in Type.__members__.items()],
)
_('direction'), choices=DIRECTION_CHOICES)
type = models.SmallIntegerField(_('type'), choices=TYPE_CHOICES)
active = models.BooleanField(
_('active'),
default=True,
_('active'), default=True,
help_text=_('this port is active')
)
settings = models.TextField(
@ -136,13 +131,13 @@ class Port(models.Model):
Return True if the type is available for the given direction.
"""
if self.direction == self.Direction.input:
if self.direction == self.DIRECTION_INPUT:
return self.type not in (
self.Type.icecast, self.Type.file
self.TYPE_ICECAST, self.TYPE_FILE
)
return self.type not in (
self.Type.http, self.Type.https
self.TYPE_HTTP, self.TYPE_HTTPS
)
def save(self, *args, **kwargs):

View File

@ -7159,12 +7159,18 @@ label.panel-block {
.is-borderless {
border: none; }
.has-background-transparent {
background-color: transparent; }
.navbar + .container {
margin-top: 1em; }
.navbar.has-shadow, .navbar.is-fixed-bottom.has-shadow {
box-shadow: 0em 0em 1em rgba(0, 0, 0, 0.1); }
a.navbar-item.is-active {
border-bottom: 1px grey solid; }
/*
.navbar-brand img {
min-height: 6em;

View File

@ -419,7 +419,7 @@ eval("/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__,
/***/ (function(module, __webpack_exports__, __webpack_require__) {
"use strict";
eval("/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, \"a\", function() { return render; });\n/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, \"b\", function() { return staticRenderFns; });\nvar render = function() {\n var _vm = this\n var _h = _vm.$createElement\n var _c = _vm._self._c || _h\n return _c(\n \"div\",\n [\n _c(\"div\", { staticClass: \"tabs is-centered\" }, [\n _c(\"ul\", [_vm._t(\"tabs\", null, { value: _vm.value })], 2)\n ]),\n _vm._v(\" \"),\n _vm._t(\"default\", null, { value: _vm.value })\n ],\n 2\n )\n}\nvar staticRenderFns = []\nrender._withStripped = true\n\n\n\n//# sourceURL=webpack:///./assets/vue/tabs.vue?./node_modules/vue-loader/lib/loaders/templateLoader.js??vue-loader-options!./node_modules/vue-loader/lib??vue-loader-options");
eval("/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, \"a\", function() { return render; });\n/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, \"b\", function() { return staticRenderFns; });\nvar render = function() {\n var _vm = this\n var _h = _vm.$createElement\n var _c = _vm._self._c || _h\n return _c(\n \"div\",\n [\n _c(\"div\", { staticClass: \"tabs is-centered is-medium\" }, [\n _c(\"ul\", [_vm._t(\"tabs\", null, { value: _vm.value })], 2)\n ]),\n _vm._v(\" \"),\n _vm._t(\"default\", null, { value: _vm.value })\n ],\n 2\n )\n}\nvar staticRenderFns = []\nrender._withStripped = true\n\n\n\n//# sourceURL=webpack:///./assets/vue/tabs.vue?./node_modules/vue-loader/lib/loaders/templateLoader.js??vue-loader-options!./node_modules/vue-loader/lib??vue-loader-options");
/***/ })

View File

@ -0,0 +1,32 @@
{% extends "admin/change_form.html" %}
{% load i18n static %}
{% block extrahead %}{{ block.super }}
<link rel="stylesheet" type="text/css" href="{% static "aircox/vendor.css" %}"/>
<link rel="stylesheet" type="text/css" href="{% static "aircox/main.css" %}"/>
{% endblock %}
{% block submit_buttons_bottom %}
{% if has_change_permission %}
<div class="columns is-size-5">
<div class="column has-text-left">
{% if original and not original.is_trash %}
<button type="submit" name="status" value="32" class="button is-danger is-size-6">{% trans "Move to trash" %}</button>
{% endif %}
{% if original and not original.is_draft %}
<button type="submit" name="status" value="0" class="button is-warning is-size-6">{% trans "Mark as draft" %}</button>
{% endif %}
</div>
<div class="column has-text-right">
<button type="submit" class="button is-secondary is-size-6">{% trans "Save" %}</button>
<button type="submit" name="_continue" class="button is-secondary is-size-6">{% trans "Save and continue" %}</button>
{% if not original.is_published %}
<button type="submit" name="status" value="16" class="button is-primary is-size-6">{% trans "Publish" %}</button>
{% endif %}
</div>
{% endif %}
</div>
{% endblock %}

View File

@ -0,0 +1,29 @@
{% extends "aircox/page.html" %}
{% load i18n %}
{% block side_nav %}
{{ block.super }}
{% if side_items %}
<section>
<h4 class="title is-4">{% trans "Latest news" %}</h4>
{% for object in side_items %}
{% include "aircox/page_item.html" %}
{% endfor %}
<br>
<nav class="pagination is-centered">
<ul class="pagination-list">
<li>
<a href="{% url "article-list" %}" class="pagination-link"
aria-label="{% trans "Show all news" %}">
{% trans "More news" %}
</a>
</li>
</ul>
</nav>
</section>
{% endif %}
{% endblock %}

View File

@ -0,0 +1,12 @@
{% extends "aircox/page_list.html" %}
{% load i18n aircox %}
{% block title %}
{% if parent %}
{% with parent.title as parent %}
{% blocktrans %}Articles of {{ parent }}{% endblocktrans %}
{% endwith %}
{% else %}{{ block.super }}{% endif %}
{% endblock %}

View File

@ -67,6 +67,7 @@ Context:
{% block main %}{% endblock main %}
</main>
{% if show_side_nav %}
<aside class="column is-one-third-desktop">
{% block cover %}

View File

@ -1,20 +1,28 @@
{% extends "aircox/page.html" %}
{% load i18n aircox %}
{% block title %}{% trans "Timetable" %}{% endblock %}
{% block title %}
{% with station.name as station %}
{% blocktrans %}This week's shows... {% endblocktrans %}
{% endwith %}
{% endblock %}
{% block subtitle %}
<div class="column">
{% blocktrans %}From <b>{{ start }}</b> to <b>{{ end }}</b>{% endblocktrans %}
</div>
{% endblock %}
{% block main %}
{{ block.super }}
<section class="section">
<h3 class="subtitle size-3">
{% blocktrans %}From <b>{{ start }}</b> to <b>{{ end }}</b>{% endblocktrans %}
</h3>
{% with True as hide_schedule %}
<section>
{% unique_id "timetable" as timetable_id %}
<a-tabs default="{{ date }}">
<template v-slot:tabs="scope" noscript="hidden">
<li><a href="{% url "timetable" date=prev_date %}">&lt;</a></li>
<li><a href="{% url "timetable" date=prev_date %}">&#10092; {% trans "Before" %}</a></li>
{% for day in by_date.keys %}
<a-tab value="{{ day }}">
@ -25,11 +33,10 @@
{% endfor %}
<li>
<a href="{% url "timetable" date=next_date %}">&gt;</a>
<a href="{% url "timetable" date=next_date %}">{% trans "After" %} &#10093;</a>
</li>
</template>
{% with True as hide_schedule %}
<template v-slot:default="{value}">
{% for day, diffusions in by_date.items %}
<noscript><h4 class="subtitle is-4">{{ day|date:"l d F Y" }}</h4></noscript>
@ -51,8 +58,8 @@
</div>
{% endfor %}
</template>
{% endwith %}
</a-tabs>
</section>
{% endwith %}
{% endblock %}

View File

@ -32,9 +32,9 @@
{{ block.super }}
{% if podcasts or tracks %}
<section class="columns is-desktop">
<div class="columns is-desktop">
{% if tracks %}
<div class="column">
<section class="column">
<h4 class="title is-4">{% trans "Playlist" %}</h4>
<ol>
{% for track in tracks %}
@ -46,17 +46,17 @@
</li>
{% endfor %}
</ol>
</div>
</section>
{% endif %}
{% if podcasts %}
<div class="column">
<section class="column">
<h4 class="title is-4">{% trans "Podcasts" %}</h4>
{% for object in podcasts %}
{% include "aircox/podcast_item.html" %}
{% endfor %}
{% endif %}
</div>
</section>
{% endif %}
</section>
</div>
{% endif %}
{% endblock %}

View File

@ -2,13 +2,11 @@
{% load i18n aircox %}
{% block title %}
{% if program %}
{% with program.title as program %}
{% blocktrans %}Episodes of {{ program }}{% endblocktrans %}
{% if parent %}
{% with parent.title as parent %}
{% blocktrans %}Episodes of {{ parent }}{% endblocktrans %}
{% endwith %}
{% else %}
{% trans "Episodes" %}
{% endif %}
{% else %}{{ block.super }}{% endif %}
{% endblock %}

View File

@ -11,7 +11,7 @@
<section class="section">
{% if dates %}
<nav class="tabs is-centered" aria-label="{% trans "Other days' logs" %}">
<nav class="tabs is-medium is-centered" aria-label="{% trans "Other days' logs" %}">
<ul>
{% for day in dates %}
<li {% if day == date %}class="is-active"{% endif %}>
@ -30,7 +30,7 @@
{# <h4 class="subtitle size-4">{{ date }}</h4> #}
{% with True as hide_schedule %}
<table class="table is-striped is-hoverable is-fullwidth">
<table class="table is-striped is-hoverable is-fullwidth has-background-transparent">
{% for object in object_list %}
<tr>
<td>

View File

@ -16,7 +16,7 @@ Context:
{% block head_title %}
{% block title %}{{ title }}{% endblock %}
{% if title %} &dash; {% endif %}
&mdash;
{{ station.name }}
{% endblock %}

View File

@ -1,15 +1,14 @@
{% extends "aircox/page.html" %}
{% load i18n aircox %}
{% with view.model|verbose_name:True as model_name_plural %}
{% block title %}
{{ model_name_plural }}
{{ view.model|verbose_name:True|title }}
{% endblock %}
{% block side_nav %}
{{ block.super }}
{% if filter_categories|length != 1 %}
<section class="toolbar">
<h4 class="subtitle is-5">{% trans "Filters" %}</h4>
<form method="GET" action="">
@ -51,6 +50,7 @@
</div>
</form>
</section>
{% endif %}
{% endblock %}
@ -97,5 +97,3 @@
{% endblock %}
{% endwith %}

View File

@ -17,7 +17,7 @@
</noscript>
<a-player ref="player" src="{{ audio_streams.0 }}"
live-info-url="{% url "api-live" %}" live-info-timeout="15"
live-info-url="{% url "api-live" %}" :live-info-timeout="15"
button-title="{% trans "Play/pause audio" %}">
<template v-slot:sources>
{% for stream in audio_streams %}

View File

@ -4,11 +4,11 @@
{% block side_nav %}
{{ block.super }}
{% if episodes %}
{% if side_items %}
<section>
<h4 class="title is-4">{% trans "Last shows" %}</h4>
{% for object in episodes %}
{% for object in side_items %}
{% include "aircox/episode_item.html" %}
{% endfor %}
@ -16,13 +16,14 @@
<nav class="pagination is-centered">
<ul class="pagination-list">
<li>
<a href="{% url "diffusion-list" program_slug=program.slug %}"
<a href="{% url "diffusion-list" parent_slug=program.slug %}"
class="pagination-link"
aria-label="{% trans "Show all diffusions" %}">
{% trans "All shows" %}
aria-label="{% trans "Show all program's diffusions" %}">
{% trans "More shows" %}
</a>
</li>
</ul>
</nav>
</section>
{% endif %}
{% endblock %}

View File

@ -6,3 +6,39 @@
{% include "aircox/program_header.html" %}
{% endblock %}
{% block main %}
{{ block.super }}
<br>
{% with show_headline=False %}
<div class="columns is-desktop">
{% if articles %}
<section class="column">
<h4 class="title is-4">{% trans "Articles" %}</h4>
{% for object in articles %}
{% include "aircox/page_item.html" %}
{% endfor %}
<br>
<nav class="pagination is-centered">
<ul class="pagination-list">
<li>
<a href="{% url "article-list" parent_slug=program.slug %}"
class="pagination-link"
aria-label="{% trans "Show all program's articles" %}">
{% trans "More articles" %}
</a>
</li>
</ul>
</nav>
</section>
{% endif %}
</div>
{% endwith %}
{% endblock %}

View File

@ -30,26 +30,26 @@ urls = [
views.ArticleListView.as_view(model=models.Article, is_static=False),
name='article-list'),
path(_('articles/<slug:slug>/'),
views.PageDetailView.as_view(model=models.Article),
views.ArticleDetailView.as_view(),
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/'),
path(_('programs/<slug:parent_slug>/episodes/'),
views.EpisodeListView.as_view(), name='diffusion-list'),
path(_('programs/<slug:program_slug>/articles/'),
path(_('programs/<slug:parent_slug>/articles/'),
views.ArticleListView.as_view(), name='article-list'),
path(_('episodes/'),
views.EpisodeListView.as_view(), name='diffusion-list'),
path(_('episodes/week/'),
views.TimetableView.as_view(), name='timetable'),
path(_('episodes/week/<week:date>/'),
views.TimetableView.as_view(), name='timetable'),
path(_('episodes/<slug:slug>/'),
views.EpisodeDetailView.as_view(), name='episode-detail'),
path(_('week/'),
views.TimetableView.as_view(), name='timetable'),
path(_('week/<week:date>/'),
views.TimetableView.as_view(), name='timetable'),
path(_('logs/'), views.LogListView.as_view(), name='logs'),
path(_('logs/<date:date>/'), views.LogListView.as_view(), name='logs'),

View File

@ -1,257 +0,0 @@
import os
import json
import datetime
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 _
from django.utils import timezone as tz
from django.views.decorators.cache import cache_page
import aircox.models as models
# FIXME usefull?
class Stations:
stations = models.Station.objects.all()
update_timeout = None
fetch_timeout = None
def fetch(self):
if self.fetch_timeout and self.fetch_timeout > tz.now():
return
self.fetch_timeout = tz.now() + tz.timedelta(seconds=5)
for station in self.stations:
station.streamer.fetch()
stations = Stations()
@cache_page(10)
def on_air(request):
try:
import aircox_cms.models as cms
except:
cms = None
station = request.GET.get('station')
if station:
# FIXME: by name???
station = stations.stations.filter(name=station)
if not station.count():
return HttpResponse('{}')
else:
station = stations.stations
station = station.first()
on_air = station.on_air(count=10).select_related('track', 'diffusion')
if not on_air.count():
return HttpResponse('')
last = on_air.first()
if last.track:
last = {'date': last.date, 'type': 'track',
'artist': last.track.artist, 'title': last.track.title}
else:
try:
diff = last.diffusion
publication = None
# FIXME CMS
if cms:
publication = \
cms.DiffusionPage.objects.filter(
diffusion=diff.initial or diff).first() or \
cms.ProgramPage.objects.filter(
program=last.program).first()
except:
pass
last = {'date': diff.start, 'type': 'diffusion',
'title': diff.program.name,
'url': publication.specific.url if publication else None}
last['date'] = str(last['date'])
return HttpResponse(json.dumps(last))
# TODO:
# - login url
class Monitor(View, TemplateResponseMixin, LoginRequiredMixin):
template_name = 'aircox/controllers/monitor.html'
def get_context_data(self, **kwargs):
stations.fetch()
return {'stations': stations.stations}
def get(self, request=None, **kwargs):
if not request.user.is_active:
return Http404()
self.request = request
context = self.get_context_data(**kwargs)
return render(request, self.template_name, context)
def post(self, request=None, **kwargs):
if not request.user.is_active:
return Http404()
if not ('action' or 'station') in request.POST:
return HttpResponse('')
POST = request.POST
POST.get('controller')
action = POST.get('action')
station = stations.stations.filter(name=POST.get('station')) \
.first()
if not station:
return Http404()
source = None
if 'source' in POST:
source = [s for s in station.sources
if s.name == POST['source']]
source = source[0]
if not source:
return Http404
station.streamer.fetch()
source = source or station.streamer.source
if action == 'skip':
self.actionSkip(request, station, source)
if action == 'restart':
self.actionRestart(request, station, source)
return HttpResponse('')
def actionSkip(self, request, station, source):
source.skip()
def actionRestart(self, request, station, source):
source.restart()
class StatisticsView(View, TemplateResponseMixin, LoginRequiredMixin):
"""
View for statistics.
"""
# we cannot manipulate queryset: we have to be able to read from archives
template_name = 'aircox/controllers/stats.html'
class Item:
date = None
end = None
name = None
related = None
tracks = None
tags = None
col = None
def __init__(self, **kwargs):
self.__dict__.update(kwargs)
class Stats:
station = None
date = None
items = None
"""
Log or Diffusion object that has been diffused by date. These
objects have extra fields:
- tags: [ (tag_name, tag_count), ...]
- tracks_count: total count of tracks
"""
count = 0
#rows = None
def __init__(self, **kwargs):
self.items = []
# self.rows = []
self.__dict__.update(kwargs)
# Note: one row contains a column for diffusions and one for streams
# def append(self, log):
# if log.col == 0:
# self.rows.append((log, []))
# return
#
# if self.rows:
# row = self.rows[len(self.rows)-1]
# last = row[0] or row[1][len(row[1])-1]
# if last.date < log.date < last.end:
# row[1].append(log)
# return
#
# # all other cases: new row
# self.rows.append((None, [log]))
def get_stats(self, station, date):
"""
Return statistics for the given station and date.
"""
stats = self.Stats(station=station, date=date,
items=[], tags={})
qs = Log.objects.station(station).on_air() \
.prefetch_related('diffusion', 'sound', 'track', 'track__tags')
if not qs.exists():
qs = models.Log.objects.load_archive(station, date)
sound_log = None
for log in qs:
rel, item = None, None
if log.diffusion:
rel, item = log.diffusion, self.Item(
name=rel.program.name, type=_('Diffusion'), col=0,
tracks=models.Track.objects.filter(diffusion=log.diffusion)
.prefetch_related('tags'),
)
sound_log = None
elif log.sound:
rel, item = log.sound, self.Item(
name=rel.program.name + ': ' + os.path.basename(rel.path),
type=_('Stream'), col=1, tracks=[],
)
sound_log = item
elif log.track:
# append to last sound log
if not sound_log:
continue
sound_log.tracks.append(log.track)
sound_log.end = log.end
continue
item.date = log.date
item.end = log.end
item.related = rel
# stats.append(item)
stats.items.append(item)
return stats
def get_context_data(self, **kwargs):
context = {}
date = datetime.date.today()
try:
GET = self.request.GET
year = int(GET["year"]) if 'year' in GET else date.year
month = int(GET["month"]) if 'month' in GET else date.month
day = int(GET["day"]) if 'day' in GET else date.day
date = datetime.date(year, month, day)
except:
pass
context["statistics"] = [
self.get_stats(station, date)
for station in models.Station.objects.all()
]
return context
def get(self, request=None, **kwargs):
if not request.user.is_active:
return Http404()
self.request = request
context = self.get_context_data(**kwargs)
return render(request, self.template_name, context)

View File

@ -1,6 +1,6 @@
from . import api
from .article import ArticleListView
from .article import ArticleDetailView, ArticleListView
from .base import BaseView
from .episode import EpisodeDetailView, EpisodeListView, TimetableView
from .log import LogListView

View File

@ -1,16 +1,36 @@
from ..models import Article
from .program import ProgramPageListView
from ..models import Article, Program
from .page import ParentMixin, PageDetailView, PageListView
__all__ = ['ArticleListView']
__all__ = ['ArticleDetailView', 'ArticleListView']
class ArticleListView(ProgramPageListView):
class ArticleDetailView(PageDetailView):
show_side_nav = True
model = Article
def get_side_queryset(self):
qs = Article.objects.select_related('cover') \
.filter(is_static=False) \
.order_by('-date')
return qs
def get_context_data(self, **kwargs):
if self.object.program is not None:
kwargs.setdefault('parent', self.object.program)
return super().get_context_data(**kwargs)
class ArticleListView(ParentMixin, PageListView):
model = Article
template_name = 'aircox/article_list.html'
show_headline = True
is_static = False
def get_queryset(self):
return super().get_queryset(is_static=self.is_static)
parent_model = Program
fk_parent = 'program'
def get_queryset(self):
return super().get_queryset().filter(is_static=self.is_static)

View File

@ -6,17 +6,20 @@ from django.views.generic.base import TemplateResponseMixin, ContextMixin
from ..utils import Redirect
__all__ = ['BaseView', 'PageView']
__all__ = ['BaseView']
class BaseView(TemplateResponseMixin, ContextMixin):
show_side_nav = False
""" Show side navigation """
title = None
""" Page title """
cover = None
""" Page cover """
show_side_nav = False
""" Show side navigation """
list_count = 5
""" Item count for small lists displayed on page. """
@property
def station(self):
return self.request.station
@ -24,14 +27,24 @@ class BaseView(TemplateResponseMixin, ContextMixin):
def get_queryset(self):
return super().get_queryset().station(self.station)
def get_context_data(self, **kwargs):
def get_side_queryset(self):
""" Return a queryset of items to render on the side nav. """
return None
def get_context_data(self, side_items=None, **kwargs):
kwargs.setdefault('station', self.station)
kwargs.setdefault('cover', self.cover)
kwargs.setdefault('show_side_nav', self.show_side_nav)
show_side_nav = kwargs.setdefault('show_side_nav', self.show_side_nav)
if show_side_nav and side_items is None:
side_items = self.get_side_queryset()
side_items = None if side_items is None else \
side_items[:self.list_count]
if not 'audio_streams' in kwargs:
streams = self.station.audio_streams
streams = streams and streams.split('\n')
kwargs['audio_streams'] = streams
return super().get_context_data(**kwargs)
return super().get_context_data(side_items=side_items, **kwargs)

View File

@ -1,16 +1,16 @@
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 ..converters import WeekConverter
from ..models import Diffusion, Episode, Program, Sound
from .base import BaseView
from .program import ProgramPageDetailView, ProgramPageListView
from .program import ProgramPageDetailView
from .page import ParentMixin, PageListView
__all__ = ['EpisodeDetailView', 'DiffusionListView', 'TimetableView']
__all__ = ['EpisodeDetailView', 'EpisodeListView', 'TimetableView']
class EpisodeDetailView(ProgramPageDetailView):
@ -20,8 +20,9 @@ class EpisodeDetailView(ProgramPageDetailView):
return Sound.objects.diffusion(diffusion).podcasts()
def get_context_data(self, **kwargs):
kwargs.setdefault('program', self.object.program)
kwargs.setdefault('parent', kwargs['program'])
self.program = kwargs.setdefault('program', self.object.program)
kwargs.setdefault('parent', self.program)
if not 'tracks' in kwargs:
kwargs['tracks'] = self.object.track_set.order_by('position')
if not 'podcasts' in kwargs:
@ -29,12 +30,15 @@ class EpisodeDetailView(ProgramPageDetailView):
return super().get_context_data(**kwargs)
class EpisodeListView(ProgramPageListView):
class EpisodeListView(ParentMixin, PageListView):
model = Episode
template_name = 'aircox/diffusion_list.html'
item_template_name = 'aircox/episode_item.html'
show_headline = True
parent_model = Program
fk_parent = 'program'
class TimetableView(BaseView, ListView):
""" View for timetables """

View File

@ -1,6 +1,7 @@
from django.core.exceptions import FieldDoesNotExist
from django.http import Http404
from django.shortcuts import get_object_or_404
from django.views.generic import DetailView, ListView
from ..models import Category
@ -8,41 +9,48 @@ from ..utils import Redirect
from .base import BaseView
__all__ = ['PageDetailView', 'PageListView']
__all__ = ['ParentMixin', 'PageDetailView', 'PageListView']
class PageDetailView(BaseView, DetailView):
""" Base view class for pages. """
context_object_name = 'page'
class ParentMixin:
"""
Optional parent page for a list view. Parent is fetched and passed to the
template context when `parent_model` is provided (queryset is filtered by
parent page in such case).
"""
parent_model = None
""" Parent model """
parent_url_kwarg = 'parent_slug'
""" Url lookup argument """
parent_field = 'slug'
""" Parent field for url lookup """
fk_parent = 'page'
""" Page foreign key to the parent """
parent = None
""" Parent page object """
def get_parent(self, request, *args, **kwargs):
if self.parent_model is None or self.parent_url_kwarg not in kwargs:
return
lookup = {self.parent_field: kwargs[self.parent_url_kwarg]}
return get_object_or_404(
self.parent_model.objects.select_related('cover'), **lookup)
def get(self, request, *args, **kwargs):
self.parent = self.get_parent(request, *args, **kwargs)
return super().get(request, *args, **kwargs)
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
if self.parent is not None:
lookup = {self.fk_parent: self.parent}
return super().get_queryset().filter(**lookup)
return super().get_queryset()
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)
parent = kwargs.setdefault('parent', self.parent)
if parent is not None:
kwargs.setdefault('cover', parent.cover)
return super().get_context_data(**kwargs)
@ -84,4 +92,35 @@ class PageListView(BaseView, ListView):
return super().get_context_data(**kwargs)
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):
page = kwargs.setdefault('page', self.object)
kwargs.setdefault('title', page.title)
kwargs.setdefault('cover', page.cover)
return super().get_context_data(**kwargs)

View File

@ -12,53 +12,25 @@ class ProgramPageDetailView(PageDetailView):
"""
Base view class for a page that is displayed as a program's child page.
"""
program = None
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 ProgramPageListView(PageListView):
"""
Base list view class rendering pages as a program's child page.
Retrieved program from it slug provided by `kwargs['program_slug']`.
This view class can be used with or without providing a program.
"""
program = None
def get(self, request, *args, **kwargs):
slug = kwargs.get('program_slug', None)
if slug is not None:
self.program = get_object_or_404(
Program.objects.select_related('cover'), slug=slug)
return super().get(request, *args, **kwargs)
def get_queryset(self):
return super().get_queryset().filter(program=self.program) \
if self.program else super().get_queryset()
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)
def get_side_queryset(self):
return self.program.episode_set.published().order_by('-date')
class ProgramDetailView(ProgramPageDetailView):
model = Program
def get_articles_queryset(self, program):
return program.article_set.published().order_by('-date')
def get_articles_queryset(self):
return self.program.article_set.published().order_by('-date')
def get_context_data(self, **kwargs):
kwargs.setdefault('program', self.object)
self.program = kwargs.setdefault('program', self.object)
if 'articles' not in kwargs:
kwargs['articles'] = \
self.get_articles_queryset()[:self.list_count]
return super().get_context_data(**kwargs)