merge aircox and aircox_instance

This commit is contained in:
bkfox 2016-10-10 15:04:15 +02:00
parent 72fd7bd490
commit 191d337c3f
100 changed files with 4686 additions and 360 deletions

142
README.md
View File

@ -2,18 +2,39 @@
Platform to manage a radio, schedules, website, and so on. We use the power of great tools like Django or Liquidsoap.
## Current features
* **streams**: multiple random music streams when no program is played. We also can specify a time range and frequency;
This project is distributed under GPL version 3. More information in the LICENSE file, except for some files whose license is indicated.
## Features
* **streams**: multiple random music streams when no program is played. We also can specify a time range and frequency for each;
* **diffusions**: generate diffusions time slot for programs that have schedule informations. Check for conflicts and rerun.
* **liquidsoap**: create a configuration to use liquidsoap as a stream generator. Also provides interface and control to it;
* **sounds**: each programs have a folder where sounds can be put, that will be detected by the system. Quality can be check and reported for later use. Later, we plan to have uploaders to external plateforms. Sounds can be defined as excerpts or as archives.
* **cms**: application that can be used as basis for website (we use Wagtail; if you don't want it this application is not required to make everything run);
* **log**: keep a trace of every played/loaded sounds on the stream generator.
## Applications
* **programs**: managing stations, programs, schedules and diffusions. This is the core application, that handle most of the work;
* **controllers**: interface with external stream generators. For the moment only support [Liquidsoap](http://liquidsoap.fm/). Generate configuration files, trigger scheduled diffusions and so on;
* **cms**: defines models and templates to generate a website connected to Aircox;
## Architecture
Aircox is a complete Django project, that includes multiple Django's applications (if you don't know what it is, it is just like modules). There are somes scripts that can be used for deployment.
**For the moment it is assumed that the application is installed in `/srv/apps/aircox`**, and that you have installed all the dependencies for aircox (external applications and python modules)
### Applications
* **aircox**: managing stations, programs, schedules and diffusions + interfaces with the stream generator (for the moment only support [Liquidsoap](http://liquidsoap.fm/)). This is the core application, that handle most of the work: diffusions generation, conflicts checks, creates configuration files for the controllers, monitors scheduled diffusions, etc, etc.
* **aircox_cms**: defines models and templates to generate a website connected to Aircox;
### Scripts
There are script/config file for various programs. You can copy and paste them,
or even link them in their correct directory. For the moment there are scripts
for:
* cron: daily cron configuration for the generation of the diffusions
* supervisorctl: audio stream generation, website, sounds monitoring
* nginx: sampe config file (must be adapted)
The scripts are written with a combination of `cron`, `supervisord`, `nginx`
and `gunicorn` in mind.
## Installation
### Dependencies
@ -26,84 +47,57 @@ Python modules:
* `bleach`: 'aircox.cms` (comments sanitization)
* `dateutils`: `aircox.programs` (used for tests)
* `Pillow`: `aircox.cms` (needed by `wagtail`)
* Django's required database modules
Applications:
* `liquidsoap`: `aircox.controllers` (generation of the audio streams)
External applications:
* `liquidsoap`: `aircox` (generation of the audio streams)
* `sox`: `aircox` (check sounds quality and metadatas)
* note there might be external dependencies for python's Pillow too
* sqlite, mysql or any database library that you need to run a database, that is supported by python
### settings.py
Base configuration:
```python
INSTALLED_APPS = (
# dependencies
'wagtail.wagtailforms',
'wagtail.wagtailredirects',
'wagtail.wagtailembeds',
'wagtail.wagtailsites',
'wagtail.wagtailusers',
'wagtail.wagtailsnippets',
'wagtail.wagtaildocs',
'wagtail.wagtailimages',
'wagtail.wagtailsearch',
'wagtail.wagtailadmin',
'wagtail.wagtailcore',
'wagtail.contrib.settings',
'taggit',
'honeypot',
### Configuration
You must write a settings.py file in the `instance` directory (you can just
copy and paste `instance/sample_settings.py`.
# ...
You also want to redefine the following variable (required by Wagtail for the CMS):
# aircox
'aircox.programs',
'aircox.controllers',
'aircox.cms',
)
MIDDLEWARE_CLASSES = (
# ...
'wagtail.wagtailcore.middleware.SiteMiddleware',
'wagtail.wagtailredirects.middleware.RedirectMiddleware',
)
TEMPLATES = [
{
# ...
'OPTIONS': {
'context_processors': (
# ...
'wagtail.contrib.settings.context_processors.settings',
),
},
},
]
# define your wagtail site name
WAGTAIL_SITE_NAME = 'My Radio'
```
WAGTAIL_SITE_NAME = 'Aircox'
```
To enable logging:
Each application have a `settings.py` that defines extra options that can be redefined in this file. Look in their respective directories for more informations.
```python
LOGGING = {
# ...
'loggers': {
'aircox.core': {
'handlers': ['console'],
'level': os.getenv('DJANGO_LOG_LEVEL', 'INFO'),
},
'aircox.test': {
'handlers': ['console'],
'level': os.getenv('DJANGO_LOG_LEVEL', 'INFO'),
},
'aircox.tools': {
'handlers': ['console'],
'level': os.getenv('DJANGO_LOG_LEVEL', 'INFO'),
},
},
}
### Installation and first run
Create the database if needed, and generate the tables:
```bash
./manage.py migrate --fake-initial
```
Each application have a `settings.py` that defines options that can be reused in application's `settings.py` file.
You must then configure the programs, schedules and audio streams. Start the
server from this directory:
```bash
./manage.py runserver
```
You can access to the django admin interface at `http://127.0.0.1:8000/admin`
and to the cms interface at `http://127.0.0.1:8000/cms/`.
Once the configuration is okay, you must start the *controllers monitor*,
that creates configuration file for the audio streams using the new information
and that run the appropriate application (note that you dont need to restart it
after adding a program that is based on schedules).
If you use supervisord and our script with it, you can use the services defined
in it instead of running commands manually.
Note: later we want to provide an installation script in order to make your life easy.
## More informations
There are extra informations in `aircox/README.md` and `aircox_cms/README.md` files.

33
aircox/README.md Normal file
View File

@ -0,0 +1,33 @@
# 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
## 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;
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
## manage.py's commands
* **diffusions_monitor**: update/create, check and clean diffusions; When a diffusion is created its type can be set on "unconfirmed" (this depends on the approval mode).
* **sound_monitor**: check for existing and missing sounds files in programs directories and synchronize the database. Can also check for the quality of file and synchronize the database according to them.
* **sound_quality_check**: check for the quality of the file (don't update database)
## Requirements
* Sox (and soxi): sound file monitor and quality check
* requirements.txt for python's dependecies

0
aircox/__init__.py Executable file
View File

193
aircox/admin.py Executable file
View File

@ -0,0 +1,193 @@
import copy
from django import forms
from django.contrib import admin
from django.contrib.contenttypes.admin import GenericTabularInline
from django.db import models
from django.utils.translation import ugettext as _, ugettext_lazy
from aircox.models import *
#
# Inlines
#
class SoundInline(admin.TabularInline):
model = Sound
class ScheduleInline(admin.TabularInline):
model = Schedule
extra = 1
class StreamInline(admin.TabularInline):
fields = ['delay', 'begin', 'end']
model = Stream
extra = 1
class SoundInline(admin.TabularInline):
fields = ['type', 'path', 'duration','public']
# readonly_fields = fields
model = Sound
extra = 0
class DiffusionInline(admin.StackedInline):
model = Diffusion
extra = 0
fields = ['type', 'start', 'end']
class NameableAdmin(admin.ModelAdmin):
fields = [ 'name' ]
list_display = ['id', 'name']
list_filter = []
search_fields = ['name',]
class TrackInline(GenericTabularInline):
ct_field = 'related_type'
ct_fk_field = 'related_id'
model = Track
extra = 0
fields = ('artist', 'title', 'info', 'position')
readonly_fields = ('position',)
@admin.register(Sound)
class SoundAdmin(NameableAdmin):
fields = None
list_display = ['id', 'name', 'duration', 'type', 'mtime',
'public', 'good_quality', 'path']
fieldsets = [
(None, { 'fields': NameableAdmin.fields +
['path', 'type', 'program', 'diffusion'] } ),
(None, { 'fields': ['embed', 'duration', 'public', 'mtime'] }),
(None, { 'fields': ['good_quality' ] } )
]
readonly_fields = ('path', 'duration',)
inlines = [TrackInline]
@admin.register(Stream)
class StreamAdmin(admin.ModelAdmin):
list_display = ('id', 'program', 'delay', 'begin', 'end')
@admin.register(Program)
class ProgramAdmin(NameableAdmin):
def schedule(self, obj):
return Schedule.objects.filter(program = obj).count() > 0
schedule.boolean = True
schedule.short_description = _("Schedule")
list_display = ('id', 'name', 'active', 'schedule')
fields = NameableAdmin.fields + [ 'active' ]
# TODO list_display
inlines = [ ScheduleInline, StreamInline ]
# SO#8074161
#def get_form(self, request, obj=None, **kwargs):
#if obj:
# if Schedule.objects.filter(program = obj).count():
# self.inlines.remove(StreamInline)
# elif Stream.objects.filter(program = obj).count():
# self.inlines.remove(ScheduleInline)
#return super().get_form(request, obj, **kwargs)
@admin.register(Diffusion)
class DiffusionAdmin(admin.ModelAdmin):
def archives(self, obj):
sounds = [ str(s) for s in obj.get_archives()]
return ', '.join(sounds) if sounds else ''
def conflicts(self, obj):
if obj.type == Diffusion.Type.unconfirmed:
return ', '.join([ str(d) for d in obj.get_conflicts()])
return ''
def end_time(self, obj):
return obj.end.strftime('%H:%M')
end_time.short_description = _('end')
def first(self, obj):
return obj.initial.start if obj.initial else ''
list_display = ('id', 'program', 'start', 'end_time', 'type', 'first', 'archives', 'conflicts')
list_filter = ('type', 'start', 'program')
list_editable = ('type',)
ordering = ('-start', 'id')
fields = ['type', 'start', 'end', 'initial', 'program']
inlines = [ DiffusionInline, SoundInline ]
def get_form(self, request, obj=None, **kwargs):
if request.user.has_perm('aircox_program.programming'):
self.readonly_fields = []
else:
self.readonly_fields = ['program', 'start', 'end']
return super().get_form(request, obj, **kwargs)
def get_object(self, *args, **kwargs):
"""
We want rerun to redirect to the given object.
"""
obj = super().get_object(*args, **kwargs)
if obj and obj.initial:
obj = obj.initial
return obj
def get_queryset(self, request):
qs = super().get_queryset(request)
if request.GET and len(request.GET):
return qs
return qs.exclude(type = Diffusion.Type.unconfirmed)
@admin.register(Schedule)
class ScheduleAdmin(admin.ModelAdmin):
def program_name(self, obj):
return obj.program.name
program_name.short_description = _('Program')
def day(self, obj):
return obj.date.strftime('%A')
day.short_description = _('Day')
def rerun(self, obj):
return obj.initial != None
rerun.short_description = _('Rerun')
rerun.boolean = True
list_filter = ['frequency', 'program']
list_display = ['id', 'program_name', 'frequency', 'date', 'day', 'rerun']
list_editable = ['frequency', 'date']
@admin.register(Track)
class TrackAdmin(admin.ModelAdmin):
list_display = ['id', 'title', 'artist', 'position', 'in_seconds', 'related']
# TODO: sort & redo
class OutputInline(admin.StackedInline):
model = Output
extra = 0
@admin.register(Station)
class StationAdmin(admin.ModelAdmin):
inlines = [ OutputInline ]
@admin.register(Log)
class LogAdmin(admin.ModelAdmin):
list_display = ['id', 'date', 'station', 'source', 'type', 'comment', 'related']
list_filter = ['date', 'source', 'related_type']
admin.site.register(Output)

90
aircox/connector.py Normal file
View File

@ -0,0 +1,90 @@
import os
import socket
import re
import json
class Connector:
"""
Simple connector class that retrieve/send data through a unix
domain socket file or a TCP/IP connection
It is able to parse list of `key=value`, and JSON data.
"""
__socket = None
__available = False
address = None
"""
a string to the unix domain socket file, or a tuple (host, port) for
TCP/IP connection
"""
@property
def available(self):
return self.__available
def __init__(self, address = None):
if address:
self.address = address
def open(self):
if self.__available:
return
try:
family = socket.AF_INET if type(self.address) in (tuple, list) else \
socket.AF_UNIX
self.__socket = socket.socket(family, socket.SOCK_STREAM)
self.__socket.connect(self.address)
self.__available = True
except:
self.__available = False
return -1
def send(self, *data, try_count = 1, parse = False, parse_json = False):
if self.open():
return ''
data = bytes(''.join([str(d) for d in data]) + '\n', encoding='utf-8')
try:
reg = re.compile(r'(.*)\s+END\s*$')
self.__socket.sendall(data)
data = ''
while not reg.search(data):
data += self.__socket.recv(1024).decode('utf-8')
if data:
data = reg.sub(r'\1', data)
data = data.strip()
if parse:
data = self.parse(data)
elif parse_json:
data = self.parse_json(data)
return data
except:
self.__available = False
if try_count > 0:
return self.send(data, try_count - 1)
def parse(self, string):
string = string.split('\n')
data = {}
for line in string:
line = re.search(r'(?P<key>[^=]+)="?(?P<value>([^"]|\\")+)"?', line)
if not line:
continue
line = line.groupdict()
data[line['key']] = line['value']
return data
def parse_json(self, string):
try:
if string[0] == '"' and string[-1] == '"':
string = string[1:-1]
return json.loads(string) if string else None
except:
return None

359
aircox/controllers.py Normal file
View File

@ -0,0 +1,359 @@
import os
import re
import subprocess
import atexit
from django.template.loader import render_to_string
import aircox.models as models
import aircox.settings as settings
from aircox.connector import Connector
class Streamer:
"""
Audio controller of a Station.
"""
station = None
"""
Related station
"""
template_name = 'aircox/controllers/liquidsoap.liq'
"""
If set, use this template in order to generated the configuration
file in self.path file
"""
path = None
"""
Path of the configuration file.
"""
current_sound = ''
"""
Current sound being played (retrieved by fetch)
"""
current_source = None
"""
Current source object that is responsible of self.current_sound
"""
process = None
"""
Application's process if ran from Streamer
"""
socket_path = ''
"""
Path to the connector's socket
"""
connector = None
"""
Connector to Liquidsoap server
"""
def __init__(self, station, **kwargs):
self.station = station
self.path = os.path.join(station.path, 'station.liq')
self.socket_path = os.path.join(station.path, 'station.sock')
self.connector = Connector(self.socket_path)
self.__dict__.update(kwargs)
@property
def id(self):
"""
Streamer identifier common in both external app and here
"""
return self.station.slug
#
# RPC
#
def _send(self, *args, **kwargs):
return self.connector.send(*args, **kwargs)
def fetch(self):
"""
Fetch data of the children and so on
The base function just execute the function of all children
sources. The plugin must implement the other extra part
"""
sources = self.station.sources
for source in sources:
source.fetch()
rid = self._send('request.on_air').split(' ')[0]
if ' ' in rid:
rid = rid[:rid.index(' ')]
if not rid:
return
data = self._send('request.metadata ', rid, parse = True)
if not data:
return
self.current_sound = data.get('initial_uri')
try:
self.current_source = next(
source for source in self.station.sources
if source.rid == rid
)
except:
self.current_source = None
def push(self, config = True):
"""
Update configuration and children's info.
The base function just execute the function of all children
sources. The plugin must implement the other extra part
"""
sources = self.station.sources
for source in sources:
source.push()
if config and self.path and self.template_name:
data = render_to_string(self.template_name, {
'station': self.station,
'streamer': self,
'settings': settings,
})
data = re.sub('[\t ]+\n', '\n', data)
data = re.sub('\n{3,}', '\n\n', data)
os.makedirs(os.path.dirname(self.path), exist_ok = True)
with open(self.path, 'w+') as file:
file.write(data)
def skip(self):
"""
Skip a given source. If no source, use master.
"""
if self.current_source:
self.current_source.skip()
else:
self._send(self.id, '.skip')
#
# Process management
#
def __get_process_args(self):
"""
Get arguments for the executed application. Called by exec, to be
used as subprocess.Popen(__get_process_args()).
If no value is returned, abort the execution.
"""
return ['liquidsoap', '-v', self.path]
def process_run(self):
"""
Execute the external application with corresponding informations.
This function must make sure that all needed files have been generated.
"""
if self.process:
return
self.push()
args = self.__get_process_args()
if not args:
return
self.process = subprocess.Popen(args, stderr=subprocess.STDOUT)
atexit.register(self.process.terminate)
def process_terminate(self):
if self.process:
self.process.terminate()
self.process = None
def process_wait(self):
"""
Wait for the process to terminate if there is a process
"""
if self.process:
self.process.wait()
self.process = None
def ready(self):
"""
If external program is ready to use, returns True
"""
return self._send('var.list') != ''
class Source:
"""
Controller of a Source. Value are usually updated directly on the
external side.
"""
program = None
"""
Related source
"""
name = 'dealer'
path = ''
"""
Path to the Source's playlist file. Optional.
"""
active = True
"""
Source is available. May be different from the containing Source,
e.g. dealer and liquidsoap.
"""
current_sound = ''
"""
Current sound being played (retrieved by fetch)
"""
current_source = None
"""
Current source being responsible of the current sound
"""
rid = None
"""
Current request id of the source in LiquidSoap
"""
connector = None
"""
Connector to Liquidsoap server
"""
@property
def id(self):
return self.program.slug if self.program else 'dealer'
def __init__(self, station, **kwargs):
self.station = station
self.connector = self.station.streamer.connector
self.__dict__.update(kwargs)
self.__init_playlist()
if self.program:
self.name = self.program.name
#
# Playlist
#
__playlist = None
def __init_playlist(self):
self.__playlist = []
if not self.path:
self.path = os.path.join(self.station.path,
self.id + '.m3u')
self.from_file()
if not self.__playlist:
self.from_db()
@property
def playlist(self):
"""
Current playlist on the Source, list of paths to play
"""
self.fetch()
return self.__playlist
@playlist.setter
def playlist(self, value):
value = sorted(value)
if value != self.__playlist:
self.__playlist = value
self.push()
def from_db(self, diffusion = None, program = None):
"""
Load a playlist to the controller from the database. If diffusion or
program is given use it, otherwise, try with self.program if exists, or
(if URI, self.url).
A playlist from a program uses all its available archives.
"""
if diffusion:
self.playlist = diffusion.playlist
return
program = program or self.program
if program:
self.playlist = [ sound.path for sound in
models.Sound.objects.filter(
type = models.Sound.Type.archive,
program = program,
)
]
return
def from_file(self, path = None):
"""
Load a playlist from the given file (if not, use the
controller's one
"""
path = path or self.path
if not os.path.exists(path):
return
with open(path, 'r') as file:
self.__playlist = file.read()
self.__playlist = self.__playlist.split('\n') \
if self.__playlist else []
#
# RPC
#
def _send(self, *args, **kwargs):
return self.connector.send(*args, **kwargs)
@property
def active(self):
return self._send('var.get ', self.id, '_active') == 'true'
@active.setter
def active(self, value):
self._send('var.set ', self.id, '_active', '=',
'true' if value else 'false')
def fetch(self):
"""
Get the source information
"""
data = self._send(self.id, '.get', parse = True)
if not data or type(data) != dict:
return
self.rid = data.get('rid')
self.current_sound = data.get('initial_uri')
def push(self):
"""
Update data relative to the source on the external program.
By default write the playlist.
"""
os.makedirs(os.path.dirname(self.path), exist_ok = True)
with open(self.path, 'w') as file:
file.write('\n'.join(self.__playlist or []))
def skip(self):
"""
Skip the current sound in the source
"""
self._send(self.id, '.skip')
def stream(self):
"""
Return a dict with stream info for a Stream program, or None if there
is not. Used in the template.
"""
# TODO: multiple streams
stream = self.program.stream_set.all().first()
if not stream or (not stream.begin and not stream.delay):
return
def to_seconds(time):
return 3600 * time.hour + 60 * time.minute + time.second
return {
'begin': stream.begin.strftime('%Hh%M') if stream.begin else None,
'end': stream.end.strftime('%Hh%M') if stream.end else None,
'delay': to_seconds(stream.delay) if stream.delay else 0
}

View File

@ -0,0 +1,317 @@
# SOME DESCRIPTIVE TITLE.
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
# This file is distributed under the same license as the PACKAGE package.
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
#
#, fuzzy
msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2016-09-10 17:54+0200\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
"Language: \n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n > 1);\n"
#: programs/admin.py:82 programs/models.py:648
msgid "Schedule"
msgstr ""
#: programs/admin.py:112 programs/models.py:451
msgid "end"
msgstr ""
#: programs/admin.py:153
msgid "Program"
msgstr ""
#: programs/admin.py:157
msgid "Day"
msgstr ""
#: programs/admin.py:161
msgid "Rerun"
msgstr ""
#: programs/models.py:98
msgid "name"
msgstr ""
#: programs/models.py:131
msgid "path"
msgstr ""
#: programs/models.py:132
msgid "path to the working directory"
msgstr ""
#: programs/models.py:295 programs/models.py:995 programs/models.py:1050
msgid "station"
msgstr ""
#: programs/models.py:298 programs/models.py:1003
msgid "active"
msgstr ""
#: programs/models.py:300
msgid "if not set this program is no longer active"
msgstr ""
#: programs/models.py:437 programs/models.py:481
msgid "related program"
msgstr ""
#: programs/models.py:440
msgid "delay"
msgstr ""
#: programs/models.py:442
msgid "delay between two sound plays"
msgstr ""
#: programs/models.py:445
msgid "begin"
msgstr ""
#: programs/models.py:447 programs/models.py:453
msgid "used to define a time range this stream isplayed"
msgstr ""
#: programs/models.py:483 programs/models.py:1062
msgid "date"
msgstr ""
#: programs/models.py:485 programs/models.py:812
msgid "duration"
msgstr ""
#: programs/models.py:486
msgid "regular duration"
msgstr ""
#: programs/models.py:489
msgid "frequency"
msgstr ""
#: programs/models.py:492
msgid "first week of the month"
msgstr ""
#: programs/models.py:493
msgid "second week of the month"
msgstr ""
#: programs/models.py:494
msgid "third week of the month"
msgstr ""
#: programs/models.py:495
msgid "fourth week of the month"
msgstr ""
#: programs/models.py:496
msgid "last week of the month"
msgstr ""
#: programs/models.py:497
msgid "first and third weeks of the month"
msgstr ""
#: programs/models.py:498
msgid "second and fourth weeks of the month"
msgstr ""
#: programs/models.py:499
msgid "every week"
msgstr ""
#: programs/models.py:500
msgid "one week on two"
msgstr ""
#: programs/models.py:506 programs/models.py:689
msgid "initial"
msgstr ""
#: programs/models.py:649
msgid "Schedules"
msgstr ""
#: programs/models.py:680 programs/models.py:782
msgid "program"
msgstr ""
#: programs/models.py:684 programs/models.py:793 programs/models.py:998
#: programs/models.py:1044
msgid "type"
msgstr ""
#: programs/models.py:691
msgid "the diffusion is a rerun of this one"
msgstr ""
#: programs/models.py:693
msgid "start of the diffusion"
msgstr ""
#: programs/models.py:694
msgid "end of the diffusion"
msgstr ""
#: programs/models.py:761
msgid "Diffusion"
msgstr ""
#: programs/models.py:762
msgid "Diffusions"
msgstr ""
#: programs/models.py:765
msgid "edit the diffusion's planification"
msgstr ""
#: programs/models.py:784
msgid "program related to it"
msgstr ""
#: programs/models.py:788
msgid "diffusion"
msgstr ""
#: programs/models.py:790
msgid "initial diffusion related it"
msgstr ""
#: programs/models.py:798
msgid "file"
msgstr ""
#: programs/models.py:807
msgid "embed HTML code"
msgstr ""
#: programs/models.py:809
msgid "HTML code used to embed a sound from external plateform"
msgstr ""
#: programs/models.py:814
msgid "duration of the sound"
msgstr ""
#: programs/models.py:817
msgid "modification time"
msgstr ""
#: programs/models.py:819
msgid "last modification date and time"
msgstr ""
#: programs/models.py:822
msgid "good quality"
msgstr ""
#: programs/models.py:824
msgid "sound's quality is okay"
msgstr ""
#: programs/models.py:827
msgid "public"
msgstr ""
#: programs/models.py:829
msgid "the sound is accessible to the public"
msgstr ""
#: programs/models.py:927
msgid "Sound"
msgstr ""
#: programs/models.py:928
msgid "Sounds"
msgstr ""
#: programs/models.py:940
msgid "title"
msgstr ""
#: programs/models.py:944
msgid "artist"
msgstr ""
#: programs/models.py:948
msgid "tags"
msgstr ""
#: programs/models.py:952
msgid "information"
msgstr ""
#: programs/models.py:955
msgid ""
"additional informations about this track, such as the version, if is it a "
"remix, features, etc."
msgstr ""
#: programs/models.py:960
msgid "position in the playlist"
msgstr ""
#: programs/models.py:963
msgid "in seconds"
msgstr ""
#: programs/models.py:965
msgid "position in the playlist is expressed in seconds"
msgstr ""
#: programs/models.py:972
msgid "Track"
msgstr ""
#: programs/models.py:973
msgid "Tracks"
msgstr ""
#: programs/models.py:1005
msgid "this output is active"
msgstr ""
#: programs/models.py:1008
msgid "output settings"
msgstr ""
#: programs/models.py:1009
msgid ""
"list of comma separated params available; this is put in the output config "
"as raw code; plugin related"
msgstr ""
#: programs/models.py:1051
msgid "station on which the event occured"
msgstr ""
#: programs/models.py:1056
msgid "source"
msgstr ""
#: programs/models.py:1058
msgid "source id that make it happen on the station"
msgstr ""
#: programs/models.py:1066
msgid "comment"
msgstr ""
#: programs/templates/aircox/controllers/monitor.html:107
#: programs/templates/aircox/controllers/monitor.html:117
msgid "skip"
msgstr ""
#: programs/templates/aircox/controllers/monitor.html:108
msgid "update"
msgstr ""

View File

View File

View File

@ -0,0 +1,184 @@
"""
Manage diffusions using schedules, to update, clean up or check diffusions.
A generated diffusion can be unconfirmed, that means that the user must confirm
it by changing its type to "normal". The behaviour is controlled using
--approval.
Different actions are available:
- "update" is the process that is used to generated them using programs
schedules for the (given) month.
- "clean" will remove all diffusions that are still unconfirmed and have been
planified before the (given) month.
- "check" will remove all diffusions that are unconfirmed and have been planified
from the (given) month and later.
"""
import logging
from argparse import RawTextHelpFormatter
from django.core.management.base import BaseCommand, CommandError
from django.utils import timezone as tz
from aircox.models import *
logger = logging.getLogger('aircox.tools')
class Actions:
@staticmethod
def __check_conflicts (item, saved_items):
"""
Check for conflicts, and update conflictual
items if they have been generated during this
update.
It set an attribute 'do_not_save' if the item should not
be saved. FIXME: find proper way
Return the number of conflicts
"""
conflicts = list(item.get_conflicts())
for i, conflict in enumerate(conflicts):
if conflict.program == item.program:
item.do_not_save = True
del conflicts[i]
continue
if conflict.pk in saved_items and \
conflict.type != Diffusion.Type.unconfirmed:
conflict.type = Diffusion.Type.unconfirmed
conflict.save()
if not conflicts:
item.type = Diffusion.Type.normal
return 0
item.type = Diffusion.Type.unconfirmed
return len(conflicts)
@classmethod
def update (cl, date, mode):
manual = (mode == 'manual')
if not manual:
saved_items = set()
count = [0, 0]
for schedule in Schedule.objects.filter(program__active = True) \
.order_by('initial'):
# in order to allow rerun links between diffusions, we save items
# by schedule;
items = schedule.diffusions_of_month(date, exclude_saved = True)
count[0] += len(items)
if manual:
Diffusion.objects.bulk_create(items)
else:
for item in items:
count[1] += cl.__check_conflicts(item, saved_items)
if hasattr(item, 'do_not_save'):
count[0] -= 1
continue
item.save()
saved_items.add(item)
logger.info('[update] schedule %s: %d new diffusions',
str(schedule), len(items),
)
logger.info('[update] %d diffusions have been created, %s', count[0],
'do not forget manual approval' if manual else
'{} conflicts found'.format(count[1]))
@staticmethod
def clean (date):
qs = Diffusion.objects.filter(type = Diffusion.Type.unconfirmed,
start__lt = date)
logger.info('[clean] %d diffusions will be removed', qs.count())
qs.delete()
@staticmethod
def check (date):
qs = Diffusion.objects.filter(type = Diffusion.Type.unconfirmed,
start__gt = date)
items = []
for diffusion in qs:
schedules = Schedule.objects.filter(program = diffusion.program)
for schedule in schedules:
if schedule.match(diffusion.start):
break
else:
items.append(diffusion.id)
logger.info('[check] %d diffusions will be removed', len(items))
if len(items):
Diffusion.objects.filter(id__in = items).delete()
class Command (BaseCommand):
help= __doc__
def add_arguments (self, parser):
parser.formatter_class=RawTextHelpFormatter
now = tz.datetime.today()
group = parser.add_argument_group('action')
group.add_argument(
'--update', action='store_true',
help='generate (unconfirmed) diffusions for the given month. '
'These diffusions must be confirmed manually by changing '
'their type to "normal"')
group.add_argument(
'--clean', action='store_true',
help='remove unconfirmed diffusions older than the given month')
group.add_argument(
'--check', action='store_true',
help='check future unconfirmed diffusions from the given date '
'agains\'t schedules and remove it if that do not match any '
'schedule')
group = parser.add_argument_group('date')
group.add_argument(
'--year', type=int, default=now.year,
help='used by update, default is today\'s year')
group.add_argument(
'--month', type=int, default=now.month,
help='used by update, default is today\'s month')
group.add_argument(
'--next-month', action='store_true',
help='set the date to the next month of given date'
' (if next month from today'
)
group = parser.add_argument_group('options')
group.add_argument(
'--mode', type=str, choices=['manual', 'auto'],
default='auto',
help='manual means that all generated diffusions are unconfirmed, '
'thus must be approved manually; auto confirmes all '
'diffusions except those that conflicts with others'
)
def handle (self, *args, **options):
date = tz.datetime(year = options.get('year'),
month = options.get('month'),
day = 1)
date = tz.make_aware(date)
if options.get('next_month'):
month = options.get('month')
date += tz.timedelta(days = 28)
if date.month == month:
date += tz.timedelta(days = 28)
date = date.replace(day = 1)
if options.get('update'):
Actions.update(date, mode = options.get('mode'))
if options.get('clean'):
Actions.clean(date)
if options.get('check'):
Actions.check(date)

View File

@ -0,0 +1,142 @@
"""
Import one or more playlist for the given sound. Attach it to the sound
or to the related Diffusion if wanted.
Playlists are in CSV format, where columns are separated with a
'{settings.AIRCOX_IMPORT_PLAYLIST_CSV_DELIMITER}'. Text quote is
{settings.AIRCOX_IMPORT_PLAYLIST_CSV_TEXT_QUOTE}.
The order of the elements is: {settings.AIRCOX_IMPORT_PLAYLIST_CSV_COLS}
If 'minutes' or 'seconds' are given, position will be expressed as timed
position, instead of position in playlist.
"""
import os
import csv
import logging
from argparse import RawTextHelpFormatter
from django.core.management.base import BaseCommand, CommandError
from django.contrib.contenttypes.models import ContentType
from aircox.models import *
import aircox.settings as settings
__doc__ = __doc__.format(settings = settings)
logger = logging.getLogger('aircox.tools')
class Importer:
data = None
tracks = None
def __init__(self, related = None, path = None, save = False):
if path:
self.read(path)
if related:
self.make_playlist(related, save)
def reset(self):
self.data = None
self.tracks = None
def read(self, path):
if not os.path.exists(path):
return True
with open(path, 'r') as file:
self.data = list(csv.reader(
file,
delimiter = settings.AIRCOX_IMPORT_PLAYLIST_CSV_DELIMITER,
quotechar = settings.AIRCOX_IMPORT_PLAYLIST_CSV_TEXT_QUOTE,
))
def __get(self, line, field, default = None):
maps = settings.AIRCOX_IMPORT_PLAYLIST_CSV_COLS
if field not in maps:
return default
index = maps.index(field)
return line[index] if index < len(line) else default
def make_playlist(self, related, save = False):
"""
Make a playlist from the read data, and return it. If save is
true, save it into the database
"""
maps = settings.AIRCOX_IMPORT_PLAYLIST_CSV_COLS
tracks = []
in_seconds = ('minutes' or 'seconds') in maps
for index, line in enumerate(self.data):
position = \
int(self.__get(line, 'minutes', 0)) * 60 + \
int(self.__get(line, 'seconds', 0)) \
if in_seconds else index
track, created = Track.objects.get_or_create(
related_type = ContentType.objects.get_for_model(related),
related_id = related.pk,
title = self.__get(line, 'title'),
artist = self.__get(line, 'artist'),
position = position,
)
track.in_seconds = in_seconds
track.info = self.__get(line, 'info')
tags = self.__get(line, 'tags')
if tags:
track.tags.add(*tags.split(','))
if save:
track.save()
tracks.append(track)
self.tracks = tracks
return tracks
class Command (BaseCommand):
help= __doc__
def add_arguments (self, parser):
parser.formatter_class=RawTextHelpFormatter
now = tz.datetime.today()
parser.add_argument(
'path', metavar='PATH', type=str,
help='path of the input playlist to read'
)
parser.add_argument(
'--sound', '-s', type=str,
help='generate a playlist for the sound of the given path. '
'If not given, try to match a sound with the same path.'
)
parser.add_argument(
'--diffusion', '-d', action='store_true',
help='try to get the diffusion relative to the sound if it exists'
)
def handle (self, path, *args, **options):
# FIXME: absolute/relative path of sounds vs given path
if options.get('sound'):
related = Sound.objects.filter(
path__icontains = options.get('sound')
).first()
else:
path, ext = os.path.splitext(options.get('path'))
related = Sound.objects.filter(path__icontains = path).first()
if not related:
logger.error('no sound found in the database for the path ' \
'{path}'.format(path=path))
return -1
if options.get('diffusion') and related.diffusion:
related = related.diffusion
importer = Importer(related = related, path = path, save = True)
for track in importer.tracks:
logger.info('imported track at {pos}: {title}, by '
'{artist}'.format(
pos = track.position,
title = track.title, artist = track.artist
)
)

View File

@ -0,0 +1,383 @@
"""
Monitor sound files; For each program, check for:
- new files;
- deleted files;
- differences between files and sound;
- quality of the files;
It tries to parse the file name to get the date of the diffusion of an
episode and associate the file with it; We use the following format:
yyyymmdd[_n][_][name]
Where:
'yyyy' the year of the episode's diffusion;
'mm' the month of the episode's diffusion;
'dd' the day of the episode's diffusion;
'n' the number of the episode (if multiple episodes);
'name' the title of the sound;
To check quality of files, call the command sound_quality_check using the
parameters given by the setting AIRCOX_SOUND_QUALITY. This script requires
Sox (and soxi).
"""
import os
import time
import re
import logging
import subprocess
from argparse import RawTextHelpFormatter
import atexit
from watchdog.observers import Observer
from watchdog.events import PatternMatchingEventHandler, FileModifiedEvent
from django.core.management.base import BaseCommand, CommandError
from aircox.models import *
import aircox.settings as settings
import aircox.utils as utils
logger = logging.getLogger('aircox.tools')
class SoundInfo:
name = ''
sound = None
year = None
month = None
day = None
n = None
duration = None
@property
def path(self):
return self._path
@path.setter
def path(self, value):
"""
Parse file name to get info on the assumption it has the correct
format (given in Command.help)
"""
file_name = os.path.basename(value)
file_name = os.path.splitext(file_name)[0]
r = re.search('^(?P<year>[0-9]{4})'
'(?P<month>[0-9]{2})'
'(?P<day>[0-9]{2})'
'(_(?P<n>[0-9]+))?'
'_?(?P<name>.*)$',
file_name)
if not (r and r.groupdict()):
r = { 'name': file_name }
logger.info('file name can not be parsed -> %s', value)
else:
r = r.groupdict()
self._path = value
self.name = r['name'].replace('_', ' ').capitalize()
self.year = int(r.get('year')) if 'year' in r else None
self.month = int(r.get('month')) if 'month' in r else None
self.day = int(r.get('day')) if 'day' in r else None
self.n = r.get('n')
return r
def __init__(self, path = ''):
self.path = path
def get_duration(self):
p = subprocess.Popen(['soxi', '-D', self.path],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE)
out, err = p.communicate()
if not err:
duration = utils.seconds_to_time(int(float(out)))
self.duration = duration
return duration
def get_sound(self, kwargs = None, save = True):
"""
Get or create a sound using self info.
If the sound is created/modified, get its duration and update it
(if save is True, sync to DB), and check for a playlist file.
"""
sound, created = Sound.objects.get_or_create(
path = self.path,
defaults = kwargs
)
if created or sound.check_on_file():
logger.info('sound is new or have been modified -> %s', self.path)
sound.duration = self.get_duration()
sound.name = self.name
if save:
sound.save()
self.sound = sound
return sound
def find_playlist(self, sound):
"""
Find a playlist file corresponding to the sound path
"""
import aircox.management.commands.import_playlist \
as import_playlist
path = os.path.splitext(self.sound.path)[0] + '.csv'
if not os.path.exists(path):
return
old = Track.objects.get_for(object = sound)
if old:
return
import_playlist.Importer(sound, path, save=True)
def find_diffusion(self, program, save = True):
"""
For a given program, check if there is an initial diffusion
to associate to, using the date info we have. Update self.sound
and save it consequently.
We only allow initial diffusion since there should be no
rerun.
"""
if self.year == None or not self.sound or self.sound.diffusion:
return;
diffusion = Diffusion.objects.filter(
program = program,
initial__isnull = True,
start__year = self.year,
start__month = self.month,
start__day = self.day,
)
if not diffusion:
return
diffusion = diffusion[0]
logger.info('diffusion %s mathes to sound -> %s', str(diffusion),
self.sound.path)
self.sound.diffusion = diffusion
if save:
self.sound.save()
return diffusion
class MonitorHandler(PatternMatchingEventHandler):
"""
Event handler for watchdog, in order to be used in monitoring.
"""
def __init__(self, subdir):
"""
subdir: AIRCOX_SOUND_ARCHIVES_SUBDIR or AIRCOX_SOUND_EXCERPTS_SUBDIR
"""
self.subdir = subdir
if self.subdir == settings.AIRCOX_SOUND_ARCHIVES_SUBDIR:
self.sound_kwargs = { 'type': Sound.Type.archive }
else:
self.sound_kwargs = { 'type': Sound.Type.excerpt }
patterns = ['*/{}/*{}'.format(self.subdir, ext)
for ext in settings.AIRCOX_SOUND_FILE_EXT ]
super().__init__(patterns=patterns, ignore_directories=True)
def on_created(self, event):
self.on_modified(event)
def on_modified(self, event):
logger.info('sound modified: %s', event.src_path)
program = Program.get_from_path(event.src_path)
if not program:
return
si = SoundInfo(event.src_path)
si.get_sound(self.sound_kwargs, True)
if si.year != None:
si.find_diffusion(program)
def on_deleted(self, event):
logger.info('sound deleted: %s', event.src_path)
sound = Sound.objects.filter(path = event.src_path)
if sound:
sound = sound[0]
sound.type = sound.Type.removed
sound.save()
def on_moved(self, event):
logger.info('sound moved: %s -> %s', event.src_path, event.dest_path)
sound = Sound.objects.filter(path = event.src_path)
if not sound:
self.on_modified(
FileModifiedEvent(event.dest_path)
)
return
sound = sound[0]
sound.path = event.dest_path
sound.save()
class Command(BaseCommand):
help= __doc__
def report(self, program = None, component = None, *content):
if not component:
logger.info('%s: %s', str(program), ' '.join([str(c) for c in content]))
else:
logger.info('%s, %s: %s', str(program), str(component),
' '.join([str(c) for c in content]))
def add_arguments(self, parser):
parser.formatter_class=RawTextHelpFormatter
parser.add_argument(
'-q', '--quality_check', action='store_true',
help='Enable quality check using sound_quality_check on all ' \
'sounds marqued as not good'
)
parser.add_argument(
'-s', '--scan', action='store_true',
help='Scan programs directories for changes, plus check for a '
' matching diffusion on sounds that have not been yet assigned'
)
parser.add_argument(
'-m', '--monitor', action='store_true',
help='Run in monitor mode, watch for modification in the filesystem '
'and react in consequence'
)
def handle(self, *args, **options):
if options.get('scan'):
self.scan()
if options.get('quality_check'):
self.check_quality(check = (not options.get('scan')) )
if options.get('monitor'):
self.monitor()
@staticmethod
def check_sounds(qs):
"""
Only check for the sound existence or update
"""
# check files
for sound in qs:
if sound.check_on_file():
sound.save(check = False)
def scan(self):
"""
For all programs, scan dirs
"""
logger.info('scan all programs...')
programs = Program.objects.filter()
for program in programs:
logger.info('#%d %s', program.id, program.name)
self.scan_for_program(
program, settings.AIRCOX_SOUND_ARCHIVES_SUBDIR,
type = Sound.Type.archive,
)
self.scan_for_program(
program, settings.AIRCOX_SOUND_EXCERPTS_SUBDIR,
type = Sound.Type.excerpt,
)
def scan_for_program(self, program, subdir, **sound_kwargs):
"""
Scan a given directory that is associated to the given program, and
update sounds information.
"""
logger.info('- %s/', subdir)
if not program.ensure_dir(subdir):
return
sound_kwargs['program'] = program
subdir = os.path.join(program.path, subdir)
sounds = []
# sounds in directory
for path in os.listdir(subdir):
path = os.path.join(subdir, path)
if not path.endswith(settings.AIRCOX_SOUND_FILE_EXT):
continue
si = SoundInfo(path)
si.get_sound(sound_kwargs, True)
si.find_diffusion(program)
si.find_playlist(si.sound)
sounds.append(si.sound.pk)
# sounds in db & unchecked
sounds = Sound.objects.filter(path__startswith = subdir). \
exclude(pk__in = sounds)
self.check_sounds(sounds)
def check_quality(self, check = False):
"""
Check all files where quality has been set to bad
"""
import aircox.management.commands.sounds_quality_check \
as quality_check
# get available sound files
sounds = Sound.objects.filter(good_quality = False) \
.exclude(type = Sound.Type.removed)
if check:
self.check_sounds(sounds)
files = [ sound.path for sound in sounds
if os.path.exists(sound.path) ]
# check quality
logger.info('quality check...',)
cmd = quality_check.Command()
cmd.handle( files = files,
**settings.AIRCOX_SOUND_QUALITY )
# update stats
logger.info('update stats in database')
def update_stats(sound_info, sound):
stats = sound_info.get_file_stats()
if stats:
duration = int(stats.get('length'))
sound.duration = utils.seconds_to_time(duration)
for sound_info in cmd.good:
sound = Sound.objects.get(path = sound_info.path)
sound.good_quality = True
update_stats(sound_info, sound)
sound.save(check = False)
for sound_info in cmd.bad:
sound = Sound.objects.get(path = sound_info.path)
update_stats(sound_info, sound)
sound.save(check = False)
def monitor(self):
"""
Run in monitor mode
"""
archives_handler = MonitorHandler(
subdir = settings.AIRCOX_SOUND_ARCHIVES_SUBDIR
)
excerpts_handler = MonitorHandler(
subdir = settings.AIRCOX_SOUND_EXCERPTS_SUBDIR
)
observer = Observer()
observer.schedule(archives_handler, settings.AIRCOX_PROGRAMS_DIR,
recursive=True)
observer.schedule(excerpts_handler, settings.AIRCOX_PROGRAMS_DIR,
recursive=True)
observer.start()
def leave():
observer.stop()
observer.join()
atexit.register(leave)
while True:
time.sleep(1)

View File

@ -0,0 +1,174 @@
"""
Analyse and check files using Sox, prints good and bad files.
"""
import sys
import logging
import re
import subprocess
from argparse import RawTextHelpFormatter
from django.core.management.base import BaseCommand, CommandError
logger = logging.getLogger('aircox.tools')
class Stats:
attributes = [
'DC offset', 'Min level', 'Max level',
'Pk lev dB', 'RMS lev dB', 'RMS Pk dB',
'RMS Tr dB', 'Flat factor', 'Length s',
]
def __init__ (self, path, **kwargs):
"""
If path is given, call analyse with path and kwargs
"""
self.values = {}
if path:
self.analyse(path, **kwargs)
def get (self, attr):
return self.values.get(attr)
def parse (self, output):
for attr in Stats.attributes:
value = re.search(attr + r'\s+(?P<value>\S+)', output)
value = value and value.groupdict()
if value:
try:
value = float(value.get('value'))
except ValueError:
value = None
self.values[attr] = value
self.values['length'] = self.values['Length s']
def analyse (self, path, at = None, length = None):
"""
If at and length are given use them as excerpt to analyse.
"""
args = ['sox', path, '-n']
if at is not None and length is not None:
args += ['trim', str(at), str(length) ]
args.append('stats')
p = subprocess.Popen(args, stdout=subprocess.PIPE,
stderr=subprocess.PIPE)
# sox outputs to stderr (my god WHYYYY)
out_, out = p.communicate()
self.parse(str(out, encoding='utf-8'))
class Sound:
path = None # file path
sample_length = 120 # default sample length in seconds
stats = None # list of samples statistics
bad = None # list of bad samples
good = None # list of good samples
def __init__ (self, path, sample_length = None):
self.path = path
self.sample_length = sample_length if sample_length is not None \
else self.sample_length
def get_file_stats (self):
return self.stats and self.stats[0]
def analyse (self):
logger.info('complete file analysis')
self.stats = [ Stats(self.path) ]
position = 0
length = self.stats[0].get('length')
if not self.sample_length:
return
logger.info('start samples analysis...')
while position < length:
stats = Stats(self.path, at = position, length = self.sample_length)
self.stats.append(stats)
position += self.sample_length
def check (self, name, min_val, max_val):
self.good = [ index for index, stats in enumerate(self.stats)
if min_val <= stats.get(name) <= max_val ]
self.bad = [ index for index, stats in enumerate(self.stats)
if index not in self.good ]
self.resume()
def resume (self):
view = lambda array: [
'file' if index is 0 else
'sample {} (at {} seconds)'.format(index, (index-1) * self.sample_length)
for index in array
]
if self.good:
logger.info(self.path + ' -> good: \033[92m%s\033[0m',
', '.join(view(self.good)))
if self.bad:
logger.info(self.path + ' -> bad: \033[91m%s\033[0m',
', '.join(view(self.bad)))
class Command (BaseCommand):
help = __doc__
sounds = None
def add_arguments (self, parser):
parser.formatter_class=RawTextHelpFormatter
parser.add_argument(
'files', metavar='FILE', type=str, nargs='+',
help='file(s) to analyse'
)
parser.add_argument(
'-s', '--sample_length', type=int, default=120,
help='size of sample to analyse in seconds. If not set (or 0), does'
' not analyse by sample',
)
parser.add_argument(
'-a', '--attribute', type=str,
help='attribute name to use to check, that can be:\n' + \
', '.join([ '"{}"'.format(attr) for attr in Stats.attributes ])
)
parser.add_argument(
'-r', '--range', type=float, nargs=2,
help='range of minimal and maximal accepted value such as: ' \
'--range min max'
)
parser.add_argument(
'-i', '--resume', action='store_true',
help='print a resume of good and bad files'
)
def handle (self, *args, **options):
# parameters
minmax = options.get('range')
if not minmax:
raise CommandError('no range specified')
attr = options.get('attribute')
if not attr:
raise CommandError('no attribute specified')
# sound analyse and checks
self.sounds = [ Sound(path, options.get('sample_length'))
for path in options.get('files') ]
self.bad = []
self.good = []
for sound in self.sounds:
logger.info('analyse ' + sound.path)
sound.analyse()
sound.check(attr, minmax[0], minmax[1])
if sound.bad:
self.bad.append(sound)
else:
self.good.append(sound)
# resume
if options.get('resume'):
for sound in self.good:
logger.info('\033[92m+ %s\033[0m', sound.path)
for sound in self.bad:
logger.info('\033[91m+ %s\033[0m', sound.path)

View File

@ -0,0 +1,346 @@
"""
Handle the audio streamer and controls it as we want it to be. It is
used to:
- generate config files and playlists;
- monitor Liquidsoap, logs and scheduled programs;
- cancels Diffusions that have an archive but could not have been played;
- run Liquidsoap
"""
import os
import time
import re
from argparse import RawTextHelpFormatter
from django.conf import settings as main_settings
from django.core.management.base import BaseCommand, CommandError
from django.utils import timezone as tz
from aircox.models import Station, Diffusion, Track, Sound, Log
class Monitor:
"""
Log and launch diffusions for the given station.
Monitor should be able to be used after a crash a go back
where it was playing, so we heavily use logs to be able to
do that.
We keep trace of played items on the generated stream:
- sounds played on this stream;
- scheduled diffusions
- tracks for sounds of streamed programs
"""
station = None
streamer = None
cancel_timeout = 60*10
"""
Time in seconds before a diffusion that have archives is cancelled
because it has not been played.
"""
sync_timeout = 60*10
"""
Time in minuts before all stream playlists are checked and updated
"""
sync_next = None
"""
Datetime of the next sync
"""
def __init__(self, station, **kwargs):
self.station = station
self.__dict__.update(kwargs)
def monitor(self):
"""
Run all monitoring functions.
"""
if not self.streamer:
self.streamer = self.station.streamer
if not self.streamer.ready():
return
self.trace()
self.sync_playlists()
self.handle()
def log(self, **kwargs):
"""
Create a log using **kwargs, and print info
"""
log = Log(station = self.station, **kwargs)
log.save()
log.print()
def trace(self):
"""
Check the current_sound of the station and update logs if
needed.
"""
self.streamer.fetch()
current_sound = self.streamer.current_sound
current_source = self.streamer.current_source
if not current_sound or not current_source:
return
log = Log.objects.get_for(model = Sound) \
.filter(station = self.station) \
.order_by('date').last()
# only streamed
if log and (log.related and not log.related.diffusion):
self.trace_sound_tracks(log)
# TODO: expiration
if log and (log.source == current_source.id and \
log.related and
log.related.path == current_sound):
return
sound = Sound.objects.filter(path = current_sound)
self.log(
type = Log.Type.play,
source = current_source.id,
date = tz.now(),
related = sound[0] if sound else None,
comment = None if sound else current_sound,
)
def trace_sound_tracks(self, log):
"""
Log tracks for the given sound (for streamed programs); Called by
self.trace
"""
logs = Log.objects.get_for(model = Track) \
.filter(pk__gt = log.pk)
logs = [ log.related_id for log in logs ]
tracks = Track.objects.get_for(object = log.related) \
.filter(in_seconds = True)
if tracks and len(tracks) == len(logs):
return
tracks = tracks.exclude(pk__in = logs).order_by('position')
now = tz.now()
for track in tracks:
pos = log.date + tz.timedelta(seconds = track.position)
if pos < now:
self.log(
type = Log.Type.play,
source = log.source,
date = pos,
related = track
)
def sync_playlists(self):
"""
Synchronize updated playlists
"""
now = tz.now()
if self.sync_next and self.sync_next < now:
return
self.sync_next = now + tz.timedelta(seconds = self.sync_timeout)
for source in self.station.sources:
if source == self.station.dealer:
continue
playlist = [ sound.path for sound in
source.program.sound_set.all() ]
source.playlist = playlist
def trace_canceled(self):
"""
Check diffusions that should have been played but did not start,
and cancel them
"""
if not self.cancel_timeout:
return
diffs = Diffusions.objects.get_at().filter(
type = Diffusion.Type.normal,
sound__type = Sound.Type.archive,
)
logs = station.get_played(models = Diffusion)
date = tz.now() - datetime.timedelta(seconds = self.cancel_timeout)
for diff in diffs:
if logs.filter(related = diff):
continue
if diff.start < now:
diff.type = Diffusion.Type.canceled
diff.save()
self.log(
type = Log.Type.other,
related = diff,
comment = 'Diffusion canceled after {} seconds' \
.format(self.cancel_timeout)
)
def __current_diff(self):
"""
Return a tuple with the currently running diffusion and the items
that still have to be played. If there is not, return None
"""
station = self.station
now = tz.make_aware(tz.datetime.now())
diff_log = station.get_played(models = Diffusion) \
.order_by('date').last()
if not diff_log or \
not diff_log.related.is_date_in_range(now):
return None, []
# sound has switched? assume it has been (forced to) stopped
sounds = station.get_played(models = Sound) \
.filter(date__gte = diff_log.date) \
.order_by('date')
if sounds.last() and sounds.last().source != diff_log.source:
return diff_log, []
# last diff is still playing: get the remaining playlist
sounds = sounds.filter(
source = diff_log.source, pk__gt = diff_log.pk
)
sounds = [
sound.related.path for sound in sounds
if sound.related.type != Sound.Type.removed
]
return (
diff_log.related,
[ path for path in diff_log.related.playlist
if path not in sounds ]
)
def __next_diff(self, diff):
"""
Return the tuple with the next diff that should be played and
the playlist
Note: diff is a log
"""
station = self.station
now = tz.now()
args = {'start__gt': diff.date } if diff else {}
diff = Diffusion.objects.get_at(now).filter(
type = Diffusion.Type.normal,
sound__type = Sound.Type.archive,
**args
).distinct().order_by('start').first()
return (diff, diff and diff.playlist or [])
def handle(self):
"""
Handle scheduled diffusion, trigger if needed, preload playlists
and so on.
"""
station = self.station
dealer = station.dealer
if not dealer:
return
now = tz.now()
# current and next diffs
diff, playlist = self.__current_diff()
dealer.active = bool(playlist)
next_diff, next_playlist = self.__next_diff(diff)
playlist += next_playlist
# playlist update
if dealer.playlist != playlist:
dealer.playlist = playlist
if next_diff:
self.log(
type = Log.Type.load,
source = dealer.id,
date = now,
related = next_diff
)
# dealer.on when next_diff start <= now
if next_diff and not dealer.active and \
next_diff.start <= now:
dealer.active = True
self.log(
type = Log.Type.play,
source = dealer.id,
date = now,
related = next_diff,
)
class Command (BaseCommand):
help= __doc__
def add_arguments (self, parser):
parser.formatter_class=RawTextHelpFormatter
group = parser.add_argument_group('actions')
group.add_argument(
'-c', '--config', action='store_true',
help='generate configuration files for the stations'
)
group.add_argument(
'-m', '--monitor', action='store_true',
help='monitor the scheduled diffusions and log what happens'
)
group.add_argument(
'-r', '--run', action='store_true',
help='run the required applications for the stations'
)
group = parser.add_argument_group('options')
group.add_argument(
'-d', '--delay', type=int,
default=1000,
help='time to sleep in MILLISECONDS between two updates when we '
'monitor'
)
group.add_argument(
'-s', '--station', type=str, action='append',
help='name of the station to monitor instead of monitoring '
'all stations'
)
group.add_argument(
'-t', '--timeout', type=int,
default=600,
help='time to wait in SECONDS before canceling a diffusion that '
'has not been ran but should have been. If 0, does not '
'check'
)
def handle (self, *args,
config = None, run = None, monitor = None,
station = [], delay = 1000, timeout = 600,
**options):
stations = Station.objects.filter(name__in = station)[:] \
if station else Station.objects.all()[:]
for station in stations:
# station.prepare()
if config and not run: # no need to write it twice
station.streamer.push()
if run:
station.streamer.process_run()
if monitor:
monitors = [
Monitor(station, cancel_timeout = timeout)
for station in stations
]
delay = delay / 1000
while True:
for monitor in monitors:
monitor.monitor()
time.sleep(delay)
if run:
for station in stations:
station.controller.process_wait()

1096
aircox/models.py Executable file

File diff suppressed because it is too large Load Diff

63
aircox/settings.py Executable file
View File

@ -0,0 +1,63 @@
import os
import stat
from django.conf import settings
def ensure (key, default):
globals()[key] = getattr(settings, key, default)
# Directory for the programs data
ensure('AIRCOX_PROGRAMS_DIR',
os.path.join(settings.MEDIA_ROOT, 'programs'))
# Default directory for the sounds that not linked to a program
ensure('AIRCOX_SOUND_DEFAULT_DIR',
os.path.join(AIRCOX_PROGRAMS_DIR, 'defaults')),
# Sub directory used for the complete episode sounds
ensure('AIRCOX_SOUND_ARCHIVES_SUBDIR', 'archives')
# Sub directory used for the excerpts of the episode
ensure('AIRCOX_SOUND_EXCERPTS_SUBDIR', 'excerpts')
# Change sound perms based on 'public' attribute if True
ensure('AIRCOX_SOUND_AUTO_CHMOD', True)
# Chmod bits flags as a tuple for (not public, public). Use os.chmod
# and stat.*
ensure(
'AIRCOX_SOUND_CHMOD_FLAGS',
(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,
}
)
# Extension of sound files
ensure(
'AIRCOX_SOUND_FILE_EXT',
('.ogg','.flac','.wav','.mp3','.opus')
)
# Stream for the scheduled diffusions
ensure('AIRCOX_SCHEDULED_STREAM', 0)
# Import playlist: columns for CSV file
ensure(
'AIRCOX_IMPORT_PLAYLIST_CSV_COLS',
('artist', 'title', 'minutes', 'seconds', 'tags', 'info')
)
# Import playlist: column delimiter of csv text files
ensure('AIRCOX_IMPORT_PLAYLIST_CSV_DELIMITER', ';')
# Import playlist: text delimiter of csv text files
ensure('AIRCOX_IMPORT_PLAYLIST_CSV_TEXT_QUOTE', '"')
# Controllers working directory
ensure('AIRCOX_CONTROLLERS_WORKING_DIR', '/tmp/aircox')

View File

@ -0,0 +1,150 @@
{% comment %}
TODO: update doc
Base configuration file to configure a station on liquidsoap.
# Interactive elements:
An interactive element is accessible to the people, in order to:
- get metadata
- skip the current sound
- enable/disable it
# Element of the context
We use theses elements from the template's context:
- controller: controller describing the station itself
- settings: global settings
# Overwrite the template
It is possible to overwrite the template, there are blocks at different
position in order to do it. Keep in mind that you might want to avoid to
put station specific configuration in the template itself.
{% endcomment %}
{% block functions %}
{% comment %}
An interactive source is a source that:
- is skippable through the given id on external interfaces
- can be disabled
- store metadata
{% endcomment %}
def interactive_source (id, s, ~active=true, ~disable_switch=false) =
s = store_metadata(id=id, size=1, s)
add_skip_command(s)
if disable_switch then
s
else
at(interactive.bool('#{id}_active', active), s)
end
end
{% comment %}
A stream is a source that:
- is a playlist on random mode (playlist object accessible at {id}_playlist
- is interactive
{% endcomment %}
def stream (id, file) =
s = playlist(id = '#{id}_playlist', mode = "random", reload_mode='watch',
file)
interactive_source(id, s)
end
{% endblock %}
{% block functions_extras %}
{% endblock %}
{% block config %}
set("server.socket", true)
set("server.socket.path", "{{ station.streamer.socket_path }}")
set("log.file.path", "{{ station.path }}/liquidsoap.log")
{% for key, value in settings.AIRCOX_LIQUIDSOAP_SET.items %}
set("{{ key|safe }}", {{ value|safe }})
{% endfor %}
{% endblock %}
{% block config_extras %}
{% endblock %}
{% block sources %}
live = fallback([
{% with source=station.dealer %}
interactive_source('{{ source.id }}',
playlist.once(reload_mode='watch', "{{ source.path }}"),
active=false
),
{% endwith %}
])
stream = fallback([
rotate([
{% for source in station.sources %}
{% if source != station.dealer %}
{% with stream=source.stream %}
{% if stream.delay %}
delay({{ stream.delay }}.,
stream("{{ source.id }}", "{{ source.path }}")),
{% elif stream.begin and stream.end %}
at({ {{stream.begin}}-{{stream.end}} },
stream("{{ source.id }}", "{{ source.path }}")),
{% elif not stream %}
stream("{{ source.id }}", "{{ source.path }}"),
{% endif %}
{% endwith %}
{% endif %}
{% endfor %}
]),
blank(id="blank", duration=0.1),
])
{% endblock %}
{% block sources_extras %}
{% endblock %}
def to_live(stream,live)
stream = fade.final(duration=2., type='log', stream)
live = fade.initial(duration=2., type='log', live)
add(normalize=false, [stream,live])
end
def to_stream(live,stream)
source.skip(stream)
add(normalize=false, [live,stream])
end
{% block station %}
{{ station.streamer.id }} = interactive_source (
"{{ station.streamer.id }}",
fallback(
track_sensitive=false,
transitions=[to_live,to_stream],
[ live, stream ]
),
disable_switch=true
)
{% endblock %}
{% block station_extras %}
{% endblock %}
{% block outputs %}
{% for output in station.output_set.all %}
output.{{ output.get_type_display }}(
{{ station.streamer.id }},
{% if controller.settings %},
{{ output.settings }}
{% endif %}
)
{% endfor %}
{% endblock %}
{% block output_extras %}
{% endblock %}

View File

@ -0,0 +1,125 @@
{% load i18n %}
<style>
section.station {
padding: 0.4em;
font-size: 0.9em;
}
section.station header {
margin: 0.4em 0em;
}
section.station header > * {
margin: 0em 0.2em;
}
section.station h1 {
display: inline;
margin: 0px;
font-size: 1.4em;
}
section.station button {
float: right;
}
section.station .sources {
border: 1px grey solid;
}
section.station .source {
margin: 0.2em 0em;
}
section.station .name {
display: inline-block;
width: 10em;
}
section.station .file {
color: #007EDF;
}
section.station .source.current:before {
content: '▶';
color: red;
margin: 0em 1em;
}
</style>
<script>
var Monitor = {
get_token: function () {
return document.cookie.replace(/.*csrftoken=([^;]+)(;.*|$)/, '$1');
},
post: function(station, source, action) {
var params = 'station=' + station + '&&action=' + action;
if(source)
params += '&&source=' + source;
var req = new XMLHttpRequest()
req.open('POST', '{% url 'aircox.monitor' %}', false);
req.setRequestHeader("Content-type", "application/x-www-form-urlencoded");
req.setRequestHeader("Content-length", params.length);
req.setRequestHeader("Connection", "close");
req.setRequestHeader("X-CSRFToken", this.get_token());
req.send(params);
this.update();
},
skip: function(station, source) {
this.post(station, source, 'skip');
},
update: function(timeout) {
var req = new XMLHttpRequest()
req.open('GET', '{% url 'aircox.monitor' %}', true);
req.onreadystatechange = function() {
if(req.readyState != 4 || (req.status != 200 && req.status != 0))
return;
var doc = document.implementation.createHTMLDocument('xhr')
.documentElement;
doc.innerHTML = req.responseText;
document.getElementById('stations').innerHTML =
doc.querySelector('#stations').innerHTML;
if(timeout)
window.setTimeout(
function() { Monitor.update(timeout);}, timeout
);
};
req.send();
},
}
Monitor.update(1000);
</script>
<div id='stations'>
{% for station in stations %}
<section class="station">
<header>
<h1>{{ station.name }}</h1>
<button onclick="Monitor.skip('{{ station.name }}');">{% trans "skip" %}</button>
<button onclick="Monitor.update();">{% trans "update" %}</button>
</header>
<div class="sources">
{% for source in station.all_sources %}
{% if source.controller.current_sound %}
<div class="source{% if source == station.controller.current_source %} current{% endif %}">
<span class="name">{{ source.name }}</span>
<span class="file">{{ source.controller.current_sound }}</span>
<button onclick="Monitor.skip('{{ station.name }}','{{ source.name }}');">
{% trans "skip" %}</button>
</div>
{% endif %}
{% endfor %}
</div>
</section>
{% endfor %}
</div>

66
aircox/tests.py Executable file
View File

@ -0,0 +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.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,
)
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())
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 check_one_on_two(self, schedule, date, dates):
for date in dates:
delta = date.date() - schedule.date.date()
self.assertEqual(delta.days % 14, 0)
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

9
aircox/urls.py Normal file
View File

@ -0,0 +1,9 @@
from django.conf.urls import include, url
import aircox.views as views
urls = [
url(r'^on_air', views.on_air, name='aircox.on_air'),
url(r'^monitor', views.Monitor.as_view(), name='aircox.monitor')
]

22
aircox/utils.py Normal file
View File

@ -0,0 +1,22 @@
import datetime
def to_timedelta (time):
"""
Transform a datetime or a time instance to a timedelta,
only using time info
"""
return datetime.timedelta(
hours = time.hour,
minutes = time.minute,
seconds = time.second
)
def seconds_to_time (seconds):
"""
Seconds to datetime.time
"""
minutes, seconds = divmod(seconds, 60)
hours, minutes = divmod(minutes, 60)
return datetime.time(hour = hours, minute = minutes, second = seconds)

126
aircox/views.py Executable file
View File

@ -0,0 +1,126 @@
import json
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 import timezone as tz
import aircox.models as models
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()
def on_air(request):
try:
import aircox_cms.models as cms
except:
cms = None
station = request.GET.get('station');
if station:
station = stations.stations.filter(name = station)
else:
station = stations.stations.first()
last = station.on_air(count = 1)
if not last:
return HttpResponse('')
last = last[0]
if type(last) == models.Log:
last = {
'type': 'track',
'artist': last.related.artist,
'title': last.related.title,
'date': last.date,
}
else:
try:
publication = None
if cms:
publication = \
cms.DiffusionPage.objects.filter(
diffusion = last.initial or last).first() or \
cms.ProgramPage.objects.filter(
program = last.program).first()
except:
pass
last = {
'type': 'diffusion',
'title': last.program.name,
'date': last.start,
'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
controller = POST.get('controller')
action = POST.get('action')
station = stations.stations.filter(name = POST.get('station')) \
.first()
if not station:
return HttpResponse('')
station.prepare(fetch=True)
source = None
if 'source' in POST:
source = next([ s for s in station.sources
if s.name == POST['source']], None)
if station and action == 'skip':
if source:
source.skip()
else:
station.streamer.skip()
return HttpResponse('')

0
aircox_cms/__init__.py Normal file
View File

5
aircox_cms/apps.py Normal file
View File

@ -0,0 +1,5 @@
from django.apps import AppConfig
class CmsConfig(AppConfig):
name = 'cms'

View File

@ -18,693 +18,693 @@ msgstr ""
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n > 1);\n"
#: cms/forms.py:17
#: aircox_cms/forms.py:17
msgid "your name"
msgstr ""
#: cms/forms.py:20
#: aircox_cms/forms.py:20
msgid "your email (optional)"
msgstr ""
#: cms/forms.py:23
#: aircox_cms/forms.py:23
msgid "your website (optional)"
msgstr ""
#: cms/forms.py:26
#: aircox_cms/forms.py:26
msgid "your comment"
msgstr ""
#: cms/forms.py:39
#: aircox_cms/forms.py:39
msgid "You are a bot, that is not cool"
msgstr ""
#: cms/forms.py:42
#: aircox_cms/forms.py:42
msgid "No publication found for this comment"
msgstr ""
#: cms/models.py:44
#: aircox_cms/models.py:44
msgid "favicon"
msgstr ""
#: cms/models.py:46
#: aircox_cms/models.py:46
msgid "small logo for the website displayed in the browser"
msgstr ""
#: cms/models.py:49 cms/models.py:249
#: aircox_cms/models.py:49 aircox_cms/models.py:249
msgid "tags"
msgstr ""
#: cms/models.py:52
#: aircox_cms/models.py:52
msgid "tags describing the website; used for referencing"
msgstr ""
#: cms/models.py:55
#: aircox_cms/models.py:55
msgid "public description"
msgstr ""
#: cms/models.py:58
#: aircox_cms/models.py:58
msgid "public description of the website; used for referencing"
msgstr ""
#: cms/models.py:62
#: aircox_cms/models.py:62
msgid "page for lists"
msgstr ""
#: cms/models.py:63
#: aircox_cms/models.py:63
msgid "page used to display the results of a search and other lists"
msgstr ""
#: cms/models.py:70 cms/models.py:74
#: aircox_cms/models.py:70 aircox_cms/models.py:74
msgid "publish comments automatically without verifying"
msgstr ""
#: cms/models.py:77
#: aircox_cms/models.py:77
msgid "success message"
msgstr ""
#: cms/models.py:78
#: aircox_cms/models.py:78
msgid "Your comment has been successfully posted!"
msgstr ""
#: cms/models.py:79
#: aircox_cms/models.py:79
msgid "message to display when a post has been posted"
msgstr ""
#: cms/models.py:82
#: aircox_cms/models.py:82
msgid "waiting message"
msgstr ""
#: cms/models.py:83
#: aircox_cms/models.py:83
msgid "Your comment is awaiting for approval."
msgstr ""
#: cms/models.py:84
#: aircox_cms/models.py:84
msgid "message to display when a post waits to be reviewed"
msgstr ""
#: cms/models.py:87
#: aircox_cms/models.py:87
msgid "error message"
msgstr ""
#: cms/models.py:88
#: aircox_cms/models.py:88
msgid "We could not save your message. Please correct the error(s) below."
msgstr ""
#: cms/models.py:89
#: aircox_cms/models.py:89
msgid "message to display there is an error an incomplete form."
msgstr ""
#: cms/models.py:93
#: aircox_cms/models.py:93
msgid "automatic publications"
msgstr ""
#: cms/models.py:96
#: aircox_cms/models.py:96
msgid ""
"Create automatically new publications for new programs and diffusions in the "
"timetable. If set, please complete other options of this panel"
msgstr ""
#: cms/models.py:103
#: aircox_cms/models.py:103
msgid "default program parent page"
msgstr ""
#: cms/models.py:106
#: aircox_cms/models.py:106
msgid ""
"Default parent page for program's pages. It is used to assign a page to the "
"publication of a newly created program and can be changed later"
msgstr ""
#: cms/models.py:122
#: aircox_cms/models.py:122
msgid "promotion"
msgstr ""
#: cms/models.py:129 cms/templates/cms/snippets/comments.html:6
#: aircox_cms/models.py:129 aircox_cms/templates/aircox_cms/snippets/comments.html:6
msgid "Comments"
msgstr ""
#: cms/models.py:133
#: aircox_cms/models.py:133
msgid "Programs and controls"
msgstr ""
#: cms/models.py:137
#: aircox_cms/models.py:137
msgid "website settings"
msgstr ""
#: cms/models.py:149
#: aircox_cms/models.py:149
msgid "public"
msgstr ""
#: cms/models.py:153
#: aircox_cms/models.py:153
msgid "author"
msgstr ""
#: cms/models.py:157 cms/models.py:365
#: aircox_cms/models.py:157 aircox_cms/models.py:365
msgid "email"
msgstr ""
#: cms/models.py:161
#: aircox_cms/models.py:161
msgid "website"
msgstr ""
#: cms/models.py:165 cms/models.py:212
#: aircox_cms/models.py:165 aircox_cms/models.py:212
msgid "date"
msgstr ""
#: cms/models.py:169
#: aircox_cms/models.py:169
msgid "comment"
msgstr ""
#. Translators: text shown in the comments list (in admin)
#: cms/models.py:175
#: aircox_cms/models.py:175
#, python-brace-format
msgid "{date}, {author}: {content}..."
msgstr ""
#: cms/models.py:218
#: aircox_cms/models.py:218
msgid "publish as program"
msgstr ""
#: cms/models.py:221
#: aircox_cms/models.py:221
msgid "use this program as the author of the publication"
msgstr ""
#: cms/models.py:224
#: aircox_cms/models.py:224
msgid "focus"
msgstr ""
#: cms/models.py:226
#: aircox_cms/models.py:226
msgid "the publication is highlighted;"
msgstr ""
#: cms/models.py:229 cms/models.py:231
#: aircox_cms/models.py:229 aircox_cms/models.py:231
msgid "allow comments"
msgstr ""
#: cms/models.py:237
#: aircox_cms/models.py:237
msgid "cover"
msgstr ""
#: cms/models.py:241
#: aircox_cms/models.py:241
msgid "image to use as cover of the publication"
msgstr ""
#: cms/models.py:244
#: aircox_cms/models.py:244
msgid "summary"
msgstr ""
#: cms/models.py:246
#: aircox_cms/models.py:246
msgid "summary of the publication"
msgstr ""
#: cms/models.py:255 cms/models.py:256
#: aircox_cms/models.py:255 aircox_cms/models.py:256
msgid "Publication"
msgstr ""
#: cms/models.py:263 cms/models.py:270 cms/models.py:590 cms/models.py:619
#: aircox_cms/models.py:263 aircox_cms/models.py:270 aircox_cms/models.py:590 aircox_cms/models.py:619
msgid "Content"
msgstr ""
#: cms/models.py:271
#: aircox_cms/models.py:271
msgid "Links"
msgstr ""
#: cms/models.py:358
#: aircox_cms/models.py:358
msgid "program"
msgstr ""
#: cms/models.py:368
#: aircox_cms/models.py:368
msgid "email is public"
msgstr ""
#: cms/models.py:370
#: aircox_cms/models.py:370
msgid "the email addess is accessible to the public"
msgstr ""
#: cms/models.py:374
#: aircox_cms/models.py:374
msgid "Program"
msgstr ""
#: cms/models.py:375 cms/wagtail_hooks.py:20 cms/wagtail_hooks.py:177
#: aircox_cms/models.py:375 aircox_cms/wagtail_hooks.py:20 aircox_cms/wagtail_hooks.py:177
msgid "Programs"
msgstr ""
#: cms/models.py:444
#: aircox_cms/models.py:444
msgid "diffusion"
msgstr ""
#: cms/models.py:453
#: aircox_cms/models.py:453
msgid "publish archive"
msgstr ""
#: cms/models.py:455
#: aircox_cms/models.py:455
msgid "publish the podcast of the complete diffusion"
msgstr ""
#: cms/models.py:459
#: aircox_cms/models.py:459
msgid "Diffusion"
msgstr ""
#: cms/models.py:460 cms/wagtail_hooks.py:28
#: aircox_cms/models.py:460 aircox_cms/wagtail_hooks.py:28
msgid "Diffusions"
msgstr ""
#: cms/models.py:463
#: aircox_cms/models.py:463
msgid "Tracks"
msgstr ""
#: cms/models.py:503
#: aircox_cms/models.py:503
#, python-format
msgid "Rerun of %(date)s"
msgstr ""
#: cms/models.py:507
#: aircox_cms/models.py:507
msgid "Cancelled"
msgstr ""
#: cms/models.py:570 cms/models.py:607
#: aircox_cms/models.py:570 aircox_cms/models.py:607
msgid "body"
msgstr ""
#: cms/models.py:572 cms/models.py:609
#: aircox_cms/models.py:572 aircox_cms/models.py:609
msgid "add an extra description for this list"
msgstr ""
#: cms/models.py:575
#: aircox_cms/models.py:575
msgid "list from the request"
msgstr ""
#: cms/models.py:578
#: aircox_cms/models.py:578
msgid ""
"if set, the page print a list based on the request made by the website "
"visitor, and its title will be adapted to this request. Can be usefull for "
"search pages, etc. and should only be set on one page."
msgstr ""
#: cms/models.py:594 cms/models.py:595
#: aircox_cms/models.py:594 aircox_cms/models.py:595
msgid "Generic Page"
msgstr ""
#: cms/models.py:652 cms/sections.py:859
#: aircox_cms/models.py:652 aircox_cms/sections.py:859
msgid "station"
msgstr ""
#: cms/models.py:655 cms/sections.py:862
#: aircox_cms/models.py:655 aircox_cms/sections.py:862
msgid "(required) the station on which the logs happened"
msgstr ""
#: cms/models.py:658
#: aircox_cms/models.py:658
msgid "maximum age"
msgstr ""
#: cms/models.py:660
#: aircox_cms/models.py:660
msgid "maximum days in the past allowed to be shown. 0 means no limit"
msgstr ""
#: cms/models.py:665 cms/models.py:666
#: aircox_cms/models.py:665 aircox_cms/models.py:666
msgid "Logs"
msgstr ""
#: cms/models.py:672
#: aircox_cms/models.py:672
msgid "Configuration"
msgstr ""
#: cms/models.py:707 cms/models.py:708
#: aircox_cms/models.py:707 aircox_cms/models.py:708
msgid "Timetable"
msgstr ""
#: cms/sections.py:84
#: aircox_cms/sections.py:84
msgid "url"
msgstr ""
#: cms/sections.py:86
#: aircox_cms/sections.py:86
msgid "URL of the link"
msgstr ""
#: cms/sections.py:94
#: aircox_cms/sections.py:94
msgid "Use a page instead of a URL"
msgstr ""
#: cms/sections.py:98
#: aircox_cms/sections.py:98
msgid "icon"
msgstr ""
#: cms/sections.py:102
#: aircox_cms/sections.py:102
msgid "icon to display before the url"
msgstr ""
#: cms/sections.py:110
#: aircox_cms/sections.py:110
msgid "text"
msgstr ""
#: cms/sections.py:113
#: aircox_cms/sections.py:113
msgid "text to display of the link"
msgstr ""
#: cms/sections.py:125
#: aircox_cms/sections.py:125
msgid "link"
msgstr ""
#: cms/sections.py:156
#: aircox_cms/sections.py:156
msgid "filter by date"
msgstr ""
#: cms/sections.py:163
#: aircox_cms/sections.py:163
msgid "filter by type"
msgstr ""
#: cms/sections.py:166
#: aircox_cms/sections.py:166
msgid "if set, select only elements that are of this type"
msgstr ""
#: cms/sections.py:171
#: aircox_cms/sections.py:171
msgid "filter by a related page"
msgstr ""
#: cms/sections.py:174
#: aircox_cms/sections.py:174
msgid "if set, select children or siblings related to this page"
msgstr ""
#: cms/sections.py:177
#: aircox_cms/sections.py:177
msgid "select siblings of related"
msgstr ""
#: cms/sections.py:179
#: aircox_cms/sections.py:179
msgid "if selected select related publications that are siblings of this one"
msgstr ""
#: cms/sections.py:183
#: aircox_cms/sections.py:183
msgid "ascending order"
msgstr ""
#: cms/sections.py:185
#: aircox_cms/sections.py:185
msgid "if selected sort list in the ascending order by date"
msgstr ""
#: cms/sections.py:196
#: aircox_cms/sections.py:196
msgid "filters"
msgstr ""
#: cms/sections.py:200
#: aircox_cms/sections.py:200
msgid "sorting"
msgstr ""
#: cms/sections.py:360
#: aircox_cms/sections.py:360
msgid "navigation days count"
msgstr ""
#: cms/sections.py:362
#: aircox_cms/sections.py:362
msgid "number of days to display in the navigation header when we use dates"
msgstr ""
#: cms/sections.py:366
#: aircox_cms/sections.py:366
msgid "navigation per week"
msgstr ""
#: cms/sections.py:368
#: aircox_cms/sections.py:368
msgid ""
"if selected, show dates navigation per weeks instead of show days equally "
"around the current date"
msgstr ""
#: cms/sections.py:379
#: aircox_cms/sections.py:379
msgid "Navigation"
msgstr ""
#: cms/sections.py:450
#: aircox_cms/sections.py:450
msgid "name"
msgstr ""
#: cms/sections.py:453
#: aircox_cms/sections.py:453
msgid "name of this section (not displayed)"
msgstr ""
#: cms/sections.py:456
#: aircox_cms/sections.py:456
msgid "position"
msgstr ""
#: cms/sections.py:459
#: aircox_cms/sections.py:459
msgid "name of the template block in which the section must be set"
msgstr ""
#: cms/sections.py:464
#: aircox_cms/sections.py:464
msgid "model"
msgstr ""
#: cms/sections.py:466
#: aircox_cms/sections.py:466
msgid ""
"this section is displayed only when the current page or publication is of "
"this type"
msgstr ""
#: cms/sections.py:472
#: aircox_cms/sections.py:472
msgid "page"
msgstr ""
#: cms/sections.py:474
#: aircox_cms/sections.py:474
msgid "this section is displayed only on this page"
msgstr ""
#: cms/sections.py:482 cms/sections.py:581
#: aircox_cms/sections.py:482 aircox_cms/sections.py:581
msgid "General"
msgstr ""
#: cms/sections.py:483
#: aircox_cms/sections.py:483
msgid "Section Items"
msgstr ""
#: cms/sections.py:517
#: aircox_cms/sections.py:517
msgid "item"
msgstr ""
#: cms/sections.py:561
#: aircox_cms/sections.py:561
msgid "title"
msgstr ""
#: cms/sections.py:566
#: aircox_cms/sections.py:566
msgid "show title"
msgstr ""
#: cms/sections.py:568
#: aircox_cms/sections.py:568
msgid "if set show a title at the head of the section"
msgstr ""
#: cms/sections.py:571
#: aircox_cms/sections.py:571
msgid "CSS class"
msgstr ""
#: cms/sections.py:574
#: aircox_cms/sections.py:574
msgid "section container's \"class\" attribute"
msgstr ""
#: cms/sections.py:643
#: aircox_cms/sections.py:643
msgid "is related"
msgstr ""
#: cms/sections.py:646
#: aircox_cms/sections.py:646
msgid ""
"if set, section is related to the page being processed e.g rendering a list "
"of links will use thoses of the publication instead of an assigned one."
msgstr ""
#: cms/sections.py:691
#: aircox_cms/sections.py:691
msgid "image"
msgstr ""
#: cms/sections.py:695
#: aircox_cms/sections.py:695
msgid ""
"If this item is related to the current page, this image will be used only "
"when the page has not a cover"
msgstr ""
#: cms/sections.py:700
#: aircox_cms/sections.py:700
msgid "width"
msgstr ""
#: cms/sections.py:702
#: aircox_cms/sections.py:702
msgid "if set and > 0, set a maximum width for the image"
msgstr ""
#: cms/sections.py:705
#: aircox_cms/sections.py:705
msgid "height"
msgstr ""
#: cms/sections.py:707
#: aircox_cms/sections.py:707
msgid "if set 0 and > 0, set a maximum height for the image"
msgstr ""
#: cms/sections.py:710
#: aircox_cms/sections.py:710
msgid "resize mode"
msgstr ""
#: cms/sections.py:713
#: aircox_cms/sections.py:713
msgid "if the image is resized, set the resizing mode"
msgstr ""
#: cms/sections.py:722
#: aircox_cms/sections.py:722
msgid "Resizing"
msgstr ""
#: cms/sections.py:776
#: aircox_cms/sections.py:776
msgid "links"
msgstr ""
#: cms/sections.py:777
#: aircox_cms/sections.py:777
msgid ""
"If the list is related to the current page, theses links will be used when "
"there is no links found for this publication"
msgstr ""
#: cms/sections.py:799
#: aircox_cms/sections.py:799
msgid "focus available"
msgstr ""
#: cms/sections.py:801
#: aircox_cms/sections.py:801
msgid "if true, highlight the first focused article found"
msgstr ""
#: cms/sections.py:804 cms/sections.py:865
#: aircox_cms/sections.py:804 aircox_cms/sections.py:865
msgid "count"
msgstr ""
#: cms/sections.py:806
#: aircox_cms/sections.py:806
msgid "number of items to display in the list"
msgstr ""
#: cms/sections.py:809
#: aircox_cms/sections.py:809
msgid "text of the url"
msgstr ""
#: cms/sections.py:812
#: aircox_cms/sections.py:812
msgid ""
"use this text to display an URL to the complete list. If empty, does not "
"print an address"
msgstr ""
#: cms/sections.py:821
#: aircox_cms/sections.py:821
msgid "Rendering"
msgstr ""
#: cms/sections.py:867
#: aircox_cms/sections.py:867
msgid "number of items to display in the list (max 100)"
msgstr ""
#: cms/sections.py:871
#: aircox_cms/sections.py:871
msgid "list of logs"
msgstr ""
#: cms/sections.py:872
#: aircox_cms/sections.py:872
msgid "lists of logs"
msgstr ""
#: cms/sections.py:912
#: aircox_cms/sections.py:912
msgid "Section: Timetable"
msgstr ""
#: cms/sections.py:913
#: aircox_cms/sections.py:913
msgid "Sections: Timetable"
msgstr ""
#: cms/sections.py:936
#: aircox_cms/sections.py:936
msgid "Section: publication's info"
msgstr ""
#: cms/sections.py:937
#: aircox_cms/sections.py:937
msgid "Sections: publication's info"
msgstr ""
#: cms/sections.py:942
#: aircox_cms/sections.py:942
msgid "default text"
msgstr ""
#: cms/sections.py:944
#: aircox_cms/sections.py:944
msgid "search"
msgstr ""
#: cms/sections.py:945
#: aircox_cms/sections.py:945
msgid "text to display when the search field is empty"
msgstr ""
#: cms/sections.py:949
#: aircox_cms/sections.py:949
msgid "Section: search field"
msgstr ""
#: cms/sections.py:950
#: aircox_cms/sections.py:950
msgid "Sections: search field"
msgstr ""
#: cms/sections.py:965
#: aircox_cms/sections.py:965
msgid "live title"
msgstr ""
#: cms/sections.py:967
#: aircox_cms/sections.py:967
msgid "text to display when it plays live"
msgstr ""
#: cms/sections.py:970
#: aircox_cms/sections.py:970
msgid "audio streams"
msgstr ""
#: cms/sections.py:971
#: aircox_cms/sections.py:971
msgid "one audio stream per line"
msgstr ""
#: cms/sections.py:975
#: aircox_cms/sections.py:975
msgid "Section: Player"
msgstr ""
#: cms/templates/cms/diffusion_page.html:8
#: aircox_cms/templates/aircox_cms/diffusion_page.html:8
msgid "Playlist"
msgstr ""
#: cms/templates/cms/diffusion_page.html:22
#: aircox_cms/templates/aircox_cms/diffusion_page.html:22
msgid "Dates of diffusion"
msgstr ""
#: cms/templates/cms/diffusion_page.html:36
#: aircox_cms/templates/aircox_cms/diffusion_page.html:36
msgid "Podcasts"
msgstr ""
#: cms/templates/cms/event_page.html:6
#: aircox_cms/templates/aircox_cms/event_page.html:6
msgid "Practical information"
msgstr ""
#: cms/templates/cms/event_page.html:10
#: aircox_cms/templates/aircox_cms/event_page.html:10
msgid "Date"
msgstr ""
#: cms/templates/cms/event_page.html:13
#: aircox_cms/templates/aircox_cms/event_page.html:13
msgid "Place"
msgstr ""
#: cms/templates/cms/event_page.html:15
#: aircox_cms/templates/aircox_cms/event_page.html:15
msgid "Price"
msgstr ""
#: cms/templates/cms/generic_page.html:16
#: aircox_cms/templates/aircox_cms/generic_page.html:16
#, python-format
msgid "Search in publications for <i>%(terms)s</i>"
msgstr ""
#: cms/templates/cms/generic_page.html:20
#: aircox_cms/templates/aircox_cms/generic_page.html:20
#, python-format
msgid ""
"\n"
" Related to <a href=\"%(url)s\">%(title)s</a>"
msgstr ""
#: cms/templates/cms/generic_page.html:24
#: aircox_cms/templates/aircox_cms/generic_page.html:24
msgid "All the publications"
msgstr ""
#: cms/templates/cms/generic_page.html:41
#: aircox_cms/templates/aircox_cms/generic_page.html:41
msgid "More about it"
msgstr ""
#: cms/templates/cms/program_page.html:12
#: aircox_cms/templates/aircox_cms/program_page.html:12
msgid "Schedule"
msgstr ""
#: cms/templates/cms/program_page.html:18
#: aircox_cms/templates/aircox_cms/program_page.html:18
#, python-format
msgid ""
"\n"
@ -712,110 +712,110 @@ msgid ""
" "
msgstr ""
#: cms/templates/cms/program_page.html:24
#: aircox_cms/templates/aircox_cms/program_page.html:24
msgid "Rerun"
msgstr ""
#: cms/templates/cms/program_page.html:30
#: aircox_cms/templates/aircox_cms/program_page.html:30
msgid "This program is no longer active"
msgstr ""
#: cms/templates/cms/publication.html:33
#: aircox_cms/templates/aircox_cms/publication.html:33
msgid "Go back to the publication"
msgstr ""
#: cms/templates/cms/sections/section_publication_info.html:15
#: aircox_cms/templates/aircox_cms/sections/section_publication_info.html:15
msgid "Published by"
msgstr ""
#: cms/templates/cms/sections/section_publication_info.html:22
#: aircox_cms/templates/aircox_cms/sections/section_publication_info.html:22
msgid "Published on "
msgstr ""
#: cms/templates/cms/sections/section_publication_info.html:30
#: aircox_cms/templates/aircox_cms/sections/section_publication_info.html:30
msgid "Tags"
msgstr ""
#: cms/templates/cms/sections/section_publication_info.html:39
#: aircox_cms/templates/aircox_cms/sections/section_publication_info.html:39
msgid "Share"
msgstr ""
#: cms/templates/cms/snippets/comments.html:21
#: aircox_cms/templates/aircox_cms/snippets/comments.html:21
msgid "show more options"
msgstr ""
#: cms/templates/cms/snippets/comments.html:34
#: aircox_cms/templates/aircox_cms/snippets/comments.html:34
msgid "Post!"
msgstr ""
#: cms/templates/cms/snippets/date_list.html:8
#: aircox_cms/templates/aircox_cms/snippets/date_list.html:8
msgid "previous days"
msgstr ""
#: cms/templates/cms/snippets/date_list.html:20
#: aircox_cms/templates/aircox_cms/snippets/date_list.html:20
msgid "next days"
msgstr ""
#: cms/templates/cms/snippets/list.html:24
#: aircox_cms/templates/aircox_cms/snippets/list.html:24
msgid "previous page"
msgstr ""
#: cms/templates/cms/snippets/list.html:54
#: aircox_cms/templates/aircox_cms/snippets/list.html:54
msgid "next page"
msgstr ""
#: cms/templates/cms/snippets/player.html:5
#: aircox_cms/templates/aircox_cms/snippets/player.html:5
msgid "Your browser does not support the <code>audio</code> element."
msgstr ""
#: cms/templates/cms/snippets/player.html:15
#: aircox_cms/templates/aircox_cms/snippets/player.html:15
msgid "play"
msgstr ""
#: cms/templates/cms/snippets/player.html:17
#: aircox_cms/templates/aircox_cms/snippets/player.html:17
msgid "pause"
msgstr ""
#: cms/templates/cms/snippets/player.html:19
#: aircox_cms/templates/aircox_cms/snippets/player.html:19
msgid "loading..."
msgstr ""
#: cms/templates/cms/snippets/player.html:27
#: aircox_cms/templates/aircox_cms/snippets/player.html:27
msgid "add to the player"
msgstr ""
#: cms/templates/cms/snippets/player.html:28
#: aircox_cms/templates/aircox_cms/snippets/player.html:28
msgid "more informations"
msgstr ""
#: cms/templates/cms/snippets/player.html:29
#: aircox_cms/templates/aircox_cms/snippets/player.html:29
msgid "remove this sound"
msgstr ""
#: cms/templates/cms/snippets/player.html:44
#: aircox_cms/templates/aircox_cms/snippets/player.html:44
msgid "enable and disable single mode"
msgstr ""
#: cms/templates/cms/snippets/sound_list_item.html:39
#: aircox_cms/templates/aircox_cms/snippets/sound_list_item.html:39
msgid "add this sound to the playlist"
msgstr ""
#: cms/wagtail_hooks.py:36
#: aircox_cms/wagtail_hooks.py:36
msgid "Schedules"
msgstr ""
#: cms/wagtail_hooks.py:44
#: aircox_cms/wagtail_hooks.py:44
msgid "Streams"
msgstr ""
#: cms/wagtail_hooks.py:51
#: aircox_cms/wagtail_hooks.py:51
msgid "Advanced"
msgstr ""
#: cms/wagtail_hooks.py:60
#: aircox_cms/wagtail_hooks.py:60
msgid "Sounds"
msgstr ""
#: cms/wagtail_hooks.py:145
#: aircox_cms/wagtail_hooks.py:145
msgid "Today's Diffusions"
msgstr ""

View File

@ -13,8 +13,8 @@ from django.core.management.base import BaseCommand, CommandError
from django.contrib.contenttypes.models import ContentType
from django.utils import timezone as tz
from aircox.programs.models import Program, Diffusion
from aircox.cms.models import WebsiteSettings, ProgramPage, DiffusionPage
from aircox.models import Program, Diffusion
from aircox_cms.models import WebsiteSettings, ProgramPage, DiffusionPage
logger = logging.getLogger('aircox.tools')

View File

@ -27,16 +27,16 @@ from taggit.models import TaggedItemBase
# comment clean-up
import bleach
import aircox.programs.models as programs
import aircox.cms.settings as settings
import aircox.models
import aircox_cms.settings as settings
from aircox.cms.utils import image_url
from aircox.cms.sections import *
from aircox_cms.utils import image_url
from aircox_cms.sections import *
@register_setting
class WebsiteSettings(BaseSetting):
# TODO: #Station assign a website to a programs.model.station when it will
# TODO: #Station assign a website to a aircox.models.model.station when it will
# exist. Update all dependent code such as signal handling
# general website information
@ -314,7 +314,7 @@ class Publication(Page):
super().save(*args, **kwargs)
def get_context(self, request, *args, **kwargs):
from aircox.cms.forms import CommentForm
from aircox_cms.forms import CommentForm
context = super().get_context(request, *args, **kwargs)
view = request.GET.get('view')
page = request.GET.get('page')
@ -330,7 +330,7 @@ class Publication(Page):
return context
def serve(self, request):
from aircox.cms.forms import CommentForm
from aircox_cms.forms import CommentForm
if request.POST and 'comment' in request.POST['type']:
settings = WebsiteSettings.for_site(request.site)
comment_form = CommentForm(request.POST)
@ -354,7 +354,7 @@ class Publication(Page):
class ProgramPage(Publication):
program = models.ForeignKey(
programs.Program,
aircox.models.Program,
verbose_name = _('program'),
related_name = 'page',
on_delete=models.SET_NULL,
@ -403,7 +403,7 @@ class ProgramPage(Publication):
@property
def next(self):
now = tz.now()
diffs = programs.Diffusion.objects \
diffs = aircox.models.Diffusion.objects \
.filter(end__gte = now, program = self.program) \
.order_by('start').prefetch_related('page')
return self.diffs_to_page(diffs)
@ -411,13 +411,13 @@ class ProgramPage(Publication):
@property
def prev(self):
now = tz.now()
diffs = programs.Diffusion.objects \
diffs = aircox.models.Diffusion.objects \
.filter(end__lte = now, program = self.program) \
.order_by('-start').prefetch_related('page')
return self.diffs_to_page(diffs)
class Track(programs.Track,Orderable):
class Track(aircox.models.Track,Orderable):
sort_order_field = 'position'
diffusion = ParentalKey('DiffusionPage',
@ -440,7 +440,7 @@ class DiffusionPage(Publication):
order_field = 'diffusion__start'
diffusion = models.ForeignKey(
programs.Diffusion,
aircox.models.Diffusion,
verbose_name = _('diffusion'),
related_name = 'page',
on_delete=models.SET_NULL,
@ -547,7 +547,7 @@ class DiffusionPage(Publication):
# update podcasts' attributes
for podcast in self.diffusion.sound_set \
.exclude(type = programs.Sound.Type.removed):
.exclude(type = aircox.models.Sound.Type.removed):
publish = self.live and self.publish_archive \
if podcast.type == podcast.Type.archive else self.live
@ -645,10 +645,10 @@ class DatedListPage(DatedListBase,Page):
class LogsPage(DatedListPage):
template = 'cms/dated_list_page.html'
template = 'aircox_cms/dated_list_page.html'
station = models.ForeignKey(
programs.Station,
aircox.models.Station,
verbose_name = _('station'),
null = True,
on_delete=models.SET_NULL,
@ -701,7 +701,7 @@ class LogsPage(DatedListPage):
class TimetablePage(DatedListPage):
template = 'cms/dated_list_page.html'
template = 'aircox_cms/dated_list_page.html'
class Meta:
verbose_name = _('Timetable')
@ -710,7 +710,7 @@ class TimetablePage(DatedListPage):
def get_queryset(self, request, context):
diffs = []
for date in context['nav_dates']['dates']:
items = programs.Diffusion.objects.get_at(date).order_by('start')
items = aircox.models.Diffusion.objects.get_at(date).order_by('start')
items = [ DiffusionPage.as_item(item) for item in items ]
diffs.append((date, items))
return diffs

View File

@ -33,13 +33,13 @@ from modelcluster.tags import ClusterTaggableManager
from taggit.models import TaggedItemBase
# aircox
import aircox.programs.models as programs
import aircox.models
def related_pages_filter(reset_cache=False):
"""
Return a dict that can be used to filter foreignkey to pages'
subtype declared in aircox.cms.models.
subtype declared in aircox_cms.models.
This value is stored in cache, but it is possible to reset the
cache using the `reset_cache` parameter.
@ -47,7 +47,7 @@ def related_pages_filter(reset_cache=False):
if not reset_cache and hasattr(related_pages_filter, 'cache'):
return related_pages_filter.cache
import aircox.cms.models as cms
import aircox_cms.models as cms
import inspect
related_pages_filter.cache = {
'model__in': list(name.lower() for name, member in
@ -206,7 +206,7 @@ class ListBase(models.Model):
Get queryset based on the arguments. This class is intended to be
reusable by other classes if needed.
"""
from aircox.cms.models import Publication
from aircox_cms.models import Publication
related = self.related and self.related.specific
# model
@ -249,7 +249,7 @@ class ListBase(models.Model):
If there is related field use it to get the page, otherwise use the
given list_page or the first GenericPage it finds.
"""
import aircox.cms.models as models
import aircox_cms.models as models
params = {
'date_filter': self.get_date_filter_display(),
@ -545,7 +545,7 @@ class SectionItemMeta(models.base.ModelBase):
try:
get_template(cl.template)
except TemplateDoesNotExist:
cl.template = 'cms/sections/section_item.html'
cl.template = 'aircox_cms/sections/section_item.html'
return cl
@register_snippet
@ -822,7 +822,7 @@ class SectionList(ListBase, SectionRelativeItem):
] + ListBase.panels
def get_context(self, request, page):
from aircox.cms.models import Publication
from aircox_cms.models import Publication
context = super().get_context(request, page)
if self.is_related:
@ -855,7 +855,7 @@ class SectionList(ListBase, SectionRelativeItem):
@register_snippet
class SectionLogsList(SectionItem):
station = models.ForeignKey(
programs.Station,
aircox.models.Station,
verbose_name = _('station'),
null = True,
on_delete=models.SET_NULL,
@ -882,9 +882,9 @@ class SectionLogsList(SectionItem):
Return a log object as a DiffusionPage or ListItem.
Supports: Log/Track, Diffusion
"""
from aircox.cms.models import DiffusionPage
from aircox_cms.models import DiffusionPage
print(log, type(log))
if type(log) == programs.Diffusion:
if type(log) == aircox.models.Diffusion:
return DiffusionPage.as_item(log)
return ListItem(
title = '{artist} -- {title}'.format(
@ -915,10 +915,10 @@ class SectionTimetable(SectionItem,DatedListBase):
panels = SectionItem.panels + DatedListBase.panels
def get_queryset(self, context):
from aircox.cms.models import DiffusionPage
from aircox_cms.models import DiffusionPage
diffs = []
for date in context['nav_dates']['dates']:
items = programs.Diffusion.objects.get_at(date).order_by('start')
items = aircox.models.Diffusion.objects.get_at(date).order_by('start')
items = [ DiffusionPage.as_item(item) for item in items ]
diffs.append((date, items))
return diffs
@ -954,7 +954,7 @@ class SectionSearchField(SectionItem):
]
def get_context(self, request, page):
from aircox.cms.models import GenericPage
from aircox_cms.models import GenericPage
context = super().get_context(request, page)
return context

13
aircox_cms/signals.py Normal file
View File

@ -0,0 +1,13 @@
from django.db.models.signals import post_save
from django.dispatch import receiver
import aircox.models
@receiver(post_save, sender=programs.Program)
def on_new_program(sender, instance, created, *args):
import aircox_cms.models as models
if not created or instance.page.count():
return

View File

Before

Width:  |  Height:  |  Size: 327 B

After

Width:  |  Height:  |  Size: 327 B

View File

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

Before

Width:  |  Height:  |  Size: 701 B

After

Width:  |  Height:  |  Size: 701 B

View File

Before

Width:  |  Height:  |  Size: 545 B

After

Width:  |  Height:  |  Size: 545 B

View File

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

Before

Width:  |  Height:  |  Size: 391 B

After

Width:  |  Height:  |  Size: 391 B

View File

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

View File

Before

Width:  |  Height:  |  Size: 952 B

After

Width:  |  Height:  |  Size: 952 B

View File

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

Before

Width:  |  Height:  |  Size: 311 B

After

Width:  |  Height:  |  Size: 311 B

View File

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

View File

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

Before

Width:  |  Height:  |  Size: 316 B

After

Width:  |  Height:  |  Size: 316 B

View File

Before

Width:  |  Height:  |  Size: 696 B

After

Width:  |  Height:  |  Size: 696 B

View File

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@ -19,14 +19,14 @@
<link rel="icon" href="{{ favicon.url }}" />
{% endwith %}
{% block css %}
<link rel="stylesheet" href="{% static 'cms/css/layout.css' %}" type="text/css" />
<link rel="stylesheet" href="{% static 'cms/css/theme.css' %}" type="text/css" />
<link rel="stylesheet" href="{% static 'aircox_cms/css/layout.css' %}" type="text/css" />
<link rel="stylesheet" href="{% static 'aircox_cms/css/theme.css' %}" type="text/css" />
{% block css_extras %}{% endblock %}
{% endblock %}
<script src="{% static 'cms/js/utils.js' %}"></script>
<script src="{% static 'cms/js/player.js' %}"></script>
<script src="{% static 'aircox_cms/js/utils.js' %}"></script>
<script src="{% static 'aircox_cms/js/player.js' %}"></script>
<title>{{ page.title }}</title>
</head>

View File

@ -1,4 +1,4 @@
{% extends "cms/base_site.html" %}
{% extends "aircox_cms/base_site.html" %}
{# display a timetable of planified diffusions by days #}
{% load wagtailcore_tags %}
@ -10,6 +10,6 @@
</div>
{% endif %}
{% include "cms/snippets/date_list.html" %}
{% include "aircox_cms/snippets/date_list.html" %}
{% endblock %}

View File

@ -1,4 +1,4 @@
{% extends "cms/publication.html" %}
{% extends "aircox_cms/publication.html" %}
{% load i18n %}
{% block content_extras %}
@ -35,7 +35,7 @@
<section class="podcasts list">
<h2>{% trans "Podcasts" %}</h2>
<div id="player_diff_{{ page.id }}" class="player">
{% include 'cms/snippets/player.html' %}
{% include 'aircox_cms/snippets/player.html' %}
<script>
var podcasts = new Player('player_diff_{{ page.id }}', undefined, true)

View File

@ -1,4 +1,4 @@
{% extends "cms/publication.html" %}
{% extends "aircox_cms/publication.html" %}
{% load i18n %}
{% block content %}

View File

@ -1,4 +1,4 @@
{% extends "cms/base_site.html" %}
{% extends "aircox_cms/base_site.html" %}
{# generic page to display list of articles #}
{% load i18n %}
@ -48,7 +48,7 @@
{% endwith %}
{% with list_paginator=paginator %}
{% include "cms/snippets/list.html" %}
{% include "aircox_cms/snippets/list.html" %}
{% endwith %}
{% else %}
<div class="body">

View File

@ -1,4 +1,4 @@
{% extends "cms/publication.html" %}
{% extends "aircox_cms/publication.html" %}
{# generic page to display programs #}
{% load i18n %}

View File

@ -1,4 +1,4 @@
{% extends "cms/base_site.html" %}
{% extends "aircox_cms/base_site.html" %}
{% load i18n %}
{% load wagtailcore_tags %}
@ -34,7 +34,7 @@
</section>
{% with list_paginator=paginator %}
{% include "cms/snippets/list.html" %}
{% include "aircox_cms/snippets/list.html" %}
{% endwith %}
{% else %}
{# detail view #}
@ -50,7 +50,7 @@
</div>
<section class="comments">
{% include "cms/snippets/comments.html" %}
{% include "aircox_cms/snippets/comments.html" %}
</section>
</div>
{% endif %}

View File

@ -1,4 +1,4 @@
{% extends "cms/sections/section_item.html" %}
{% extends "aircox_cms/sections/section_item.html" %}
{% load wagtailimages_tags %}
{% block content %}

View File

@ -1,4 +1,4 @@
{% extends "cms/sections/section_item.html" %}
{% extends "aircox_cms/sections/section_item.html" %}
{% load wagtailimages_tags %}
{% block content %}

View File

@ -1,14 +1,14 @@
{% extends "cms/sections/section_item.html" %}
{% extends "aircox_cms/sections/section_item.html" %}
{% block content %}
{% if focus %}
{% with item=focus item_big_cover=True %}
{% include "cms/snippets/list_item.html" %}
{% include "aircox_cms/snippets/list_item.html" %}
{% endwith %}
{% endif %}
{% with url=url url_text=self.url_text %}
{% include "cms/snippets/list.html" %}
{% include "aircox_cms/snippets/list.html" %}
{% endwith %}
{% endblock %}

View File

@ -1,9 +1,9 @@
{% extends "cms/sections/section_item.html" %}
{% extends "aircox_cms/sections/section_item.html" %}
{% block content %}
{% with item_date_format="H:i" list_css_class="date_list" list_no_cover=True %}
{% for item in object_list %}
{% include "cms/snippets/date_list_item.html" %}
{% include "aircox_cms/snippets/date_list_item.html" %}
{% endfor %}
{% endwith %}
{% endblock %}

View File

@ -1,9 +1,9 @@
{% extends 'cms/sections/section_item.html' %}
{% extends 'aircox_cms/sections/section_item.html' %}
{% block content %}
<div id="player" class="player">
{% include "cms/snippets/player.html" %}
{% include "aircox_cms/snippets/player.html" %}
</div>
<script>

View File

@ -1,4 +1,4 @@
{% extends "cms/sections/section_item.html" %}
{% extends "aircox_cms/sections/section_item.html" %}
{% load i18n %}
{% load static %}
@ -9,7 +9,7 @@
<div class="author">
{% if page.publish_as %}
{% with item=page.publish_as item_date_format='' %}
{% include "cms/snippets/list_item.html" %}
{% include "aircox_cms/snippets/list_item.html" %}
{% endwith %}
{% elif page.owner %}
{% trans "Published by" %}
@ -36,23 +36,23 @@
{% endwith %}
<div class="share">
<img src="{% static "cms/images/share.png" %}" alt="{% trans "Share" %}"
<img src="{% static "aircox_cms/images/share.png" %}" alt="{% trans "Share" %}"
class="small_icon">
<a href="mailto:?&body={{ page.full_url|urlencode }}"
target="new">
<img src="{% static "cms/images/mail.png" %}" alt="Mail" class="small_icon">
<img src="{% static "aircox_cms/images/mail.png" %}" alt="Mail" class="small_icon">
</a>
<a href="https://www.facebook.com/sharer/sharer.php?u={{ page.full_url|urlencode }}"
target="new">
<img src="{% static "cms/images/facebook.png" %}" alt="Facebook" class="small_icon">
<img src="{% static "aircox_cms/images/facebook.png" %}" alt="Facebook" class="small_icon">
</a>
<a href="https://twitter.com/intent/tweet?text={{ page.full_url|urlencode }}"
target="new">
<img src="{% static "cms/images/twitter.png" %}" alt="Twitter" class="small_icon">
<img src="{% static "aircox_cms/images/twitter.png" %}" alt="Twitter" class="small_icon">
</a>
<a href="https://plus.google.com/share?url={{ page.full_url|urlencode }}"
target="new">
<img src="{% static "cms/images/gplus.png" %}" alt="Google Plus" class="small_icon">
<img src="{% static "aircox_cms/images/gplus.png" %}" alt="Google Plus" class="small_icon">
</a>
</div>
</div>

View File

@ -1,4 +1,4 @@
{% extends "cms/sections/section_item.html" %}
{% extends "aircox_cms/sections/section_item.html" %}
{% load i18n %}
{% load static %}
@ -7,7 +7,7 @@
{% block content %}
{% with list_page=settings.cms.WebsiteSettings.list_page %}
<form action="{{ list_page.url }}" method="GET">
<img src="{% static "cms/images/search.png" %}" class="icon"/>
<img src="{% static "aircox_cms/images/search.png" %}" class="icon"/>
<input type="text" name="search" placeholder="{{ self.default_text }}">
<input type="submit" style="display: none;">
</form>

View File

@ -0,0 +1,6 @@
{% extends "aircox_cms/sections/section_item.html" %}
{% block content %}
{% include "aircox_cms/snippets/date_list.html" %}
{% endblock %}

View File

@ -3,7 +3,7 @@
{% load honeypot %}
{% if comment_form or page.comments %}
<h2><img src="{% static "cms/images/comments.png" %}" class="icon">{% trans "Comments" %}</h2>
<h2><img src="{% static "aircox_cms/images/comments.png" %}" class="icon">{% trans "Comments" %}</h2>
{% endif %}
{% if comment_form %}

View File

@ -30,7 +30,7 @@
<h2>{{ day|date:'l d F' }}</h2>
{% with item_date_format="H:i" %}
{% for item in list %}
{% include "cms/snippets/date_list_item.html" %}
{% include "aircox_cms/snippets/date_list_item.html" %}
{% endfor %}
{% endwith %}
</ul>

View File

@ -11,7 +11,7 @@ Options:
<ul class="list {{ list_css_class|default:'' }}">
{% for page in object_list %}
{% with item=page.specific %}
{% include "cms/snippets/list_item.html" %}
{% include "aircox_cms/snippets/list_item.html" %}
{% endwith %}
{% endfor %}

View File

@ -33,7 +33,7 @@ Options:
{% with date_format=item_date_format|default:'l d F, H:i' %}
<time datetime="{{ item.date }}">
{% if item.diffusion %}
<img src="{% static "cms/images/clock.png" %}" class="small_icon">
<img src="{% static "aircox_cms/images/clock.png" %}" class="small_icon">
{{ item.diffusion.start|date:date_format }}
{% else %}
{{ item.date|date:date_format }}

View File

@ -11,11 +11,11 @@
<div class="playlist">
<li class='item list_item flex_row' style="display: none;">
<div class="button">
<img src="{% static "cms/images/play.png" %}" class="play"
<img src="{% static "aircox_cms/images/play.png" %}" class="play"
title="{% trans "play" %}" />
<img src="{% static "cms/images/pause.png" %}" class="pause"
<img src="{% static "aircox_cms/images/pause.png" %}" class="pause"
title="{% trans "pause" %}" />
<img src="{% static "cms/images/loading.png" %}" class="loading"
<img src="{% static "aircox_cms/images/loading.png" %}" class="loading"
title="{% trans "loading..." %}" />
</div>

View File

@ -24,7 +24,7 @@ function add_sound_{{ item.id }}(event) {
</script>
<a class="list_item sound flex_row" onclick="add_sound_{{ item.id }}(event)">
<img src="{% static "cms/images/listen.png" %}" class="icon"/>
<img src="{% static "aircox_cms/images/listen.png" %}" class="icon"/>
<h3 class="flex_item">{{ item.name }}</h3>
<time class="info">
@ -35,7 +35,7 @@ function add_sound_{{ item.id }}(event) {
{% endif %}
</time>
<img src="{% static "cms/images/add.png" %}" class="icon"
<img src="{% static "aircox_cms/images/add.png" %}" class="icon"
data-action='add' alt="{% trans "add this sound to the playlist" %}"/>
</a>
{% endif %}

3
aircox_cms/tests.py Normal file
View File

@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

View File

@ -11,12 +11,12 @@ from wagtail.contrib.modeladmin.options import \
ModelAdmin, ModelAdminGroup, modeladmin_register
import aircox.programs.models as programs
import aircox.cms.models as models
import aircox.models
import aircox_cms.models as models
class ProgramAdmin(ModelAdmin):
model = programs.Program
model = aircox.models.Program
menu_label = _('Programs')
menu_icon = 'pick'
menu_order = 200
@ -24,7 +24,7 @@ class ProgramAdmin(ModelAdmin):
search_fields = ('name',)
class DiffusionAdmin(ModelAdmin):
model = programs.Diffusion
model = aircox.models.Diffusion
menu_label = _('Diffusions')
menu_icon = 'date'
menu_order = 200
@ -32,7 +32,7 @@ class DiffusionAdmin(ModelAdmin):
list_filter = ('frequency', 'start', 'program')
class ScheduleAdmin(ModelAdmin):
model = programs.Schedule
model = aircox.models.Schedule
menu_label = _('Schedules')
menu_icon = 'time'
menu_order = 200
@ -40,7 +40,7 @@ class ScheduleAdmin(ModelAdmin):
list_filter = ('frequency', 'date', 'duration', 'program')
class StreamAdmin(ModelAdmin):
model = programs.Stream
model = aircox.models.Stream
menu_label = _('Streams')
menu_icon = 'time'
menu_order = 200
@ -56,7 +56,7 @@ modeladmin_register(AdvancedAdminGroup)
class SoundAdmin(ModelAdmin):
model = programs.Sound
model = aircox.models.Sound
menu_label = _('Sounds')
menu_icon = 'media'
menu_order = 350
@ -73,7 +73,7 @@ modeladmin_register(SoundAdmin)
def editor_css():
return format_html(
'<link rel="stylesheet" href="{}">',
static('cms/css/cms.css')
static('aircox_cms/css/cms.css')
)
@ -126,8 +126,8 @@ class DiffusionsMenu(GenericMenu):
page_model = models.DiffusionPage
def get_queryset(self):
return programs.Diffusion.objects.filter(
type = programs.Diffusion.Type.normal,
return aircox.models.Diffusion.objects.filter(
type = aircox.models.Diffusion.Type.normal,
start__contains = tz.now().date(),
initial__isnull = True,
).order_by('start')
@ -149,12 +149,12 @@ def register_programs_menu_item():
class ProgramsMenu(GenericMenu):
"""
Menu to display all active programs.
Menu to display all active programs
"""
page_model = models.DiffusionPage
def get_queryset(self):
return programs.Program.objects \
return aircox.models.Program.objects \
.filter(active = True, page__isnull = False) \
.filter(stream__isnull = True) \
.order_by('name')
@ -164,7 +164,7 @@ class ProgramsMenu(GenericMenu):
def get_parent(self, item):
# TODO: #Station / get current site
from aircox.cms.models import WebsiteSettings
from aircox_cms.models import WebsiteSettings
settings = WebsiteSettings.objects.first()
if not settings:
return

View File

@ -1,6 +0,0 @@
{% extends "cms/sections/section_item.html" %}
{% block content %}
{% include "cms/snippets/date_list.html" %}
{% endblock %}

0
instance/__init__.py Normal file
View File

143
instance/base_settings.py Normal file
View File

@ -0,0 +1,143 @@
import os
import sys
sys.path.insert(1, os.path.dirname(os.path.realpath(__file__)))
BASE_DIR = os.path.dirname(os.path.dirname(__file__))
PROJECT_ROOT = os.path.normpath(os.path.dirname(__file__) + "/..")
STATIC_URL = '/static/'
MEDIA_URL = '/media/'
SITE_MEDIA_URL = '/media/'
STATIC_ROOT = os.path.join(PROJECT_ROOT, 'static')
MEDIA_ROOT = os.path.join(STATIC_ROOT, 'media')
# Internationalization
USE_I18N = True
USE_L10N = True
USE_TZ = True
LANGUAGE_CODE = os.environ.get('LANG') or 'en_US'
TIME_ZONE = os.environ.get('TZ') or 'Europe/Brussels'
try:
import locale
locale.setlocale(locale.LC_ALL, LANGUAGE_CODE)
except:
print(
'Can not set locale {LC}. Is it available on you system? Hint: '
'Check /etc/locale.gen and rerun locale-gen as sudo if needed.'
.format(LC = LANGUAGE_CODE)
)
pass
# Application definition
INSTALLED_APPS = (
'wagtail.wagtailforms',
'wagtail.wagtailredirects',
'wagtail.wagtailembeds',
'wagtail.wagtailsites',
'wagtail.wagtailusers',
'wagtail.wagtailsnippets',
'wagtail.wagtaildocs',
'wagtail.wagtailimages',
'wagtail.wagtailsearch',
'wagtail.wagtailadmin',
'wagtail.wagtailcore',
'wagtail.contrib.settings',
'wagtail.contrib.modeladmin',
'modelcluster',
'taggit',
'honeypot',
'django.contrib.contenttypes',
'django.contrib.auth',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'django.contrib.admin',
'aircox',
'aircox_cms',
)
MIDDLEWARE_CLASSES = (
'django.middleware.gzip.GZipMiddleware',
'htmlmin.middleware.HtmlMinifyMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.auth.middleware.SessionAuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
'django.middleware.security.SecurityMiddleware',
'wagtail.wagtailcore.middleware.SiteMiddleware',
'wagtail.wagtailredirects.middleware.RedirectMiddleware',
)
ROOT_URLCONF = 'instance.urls'
TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': (os.path.join(PROJECT_ROOT, 'templates'),),
# 'APP_DIRS': True,
'OPTIONS': {
'context_processors': (
"django.contrib.auth.context_processors.auth",
"django.template.context_processors.debug",
"django.template.context_processors.i18n",
"django.template.context_processors.media",
"django.template.context_processors.request",
"django.template.context_processors.static",
"django.template.context_processors.tz",
"django.contrib.messages.context_processors.messages",
'wagtail.contrib.settings.context_processors.settings',
),
'builtins': [
'overextends.templatetags.overextends_tags'
],
'loaders': (
'django.template.loaders.filesystem.Loader',
'django.template.loaders.app_directories.Loader',
),
},
},
]
WSGI_APPLICATION = 'instance.wsgi.application'
LOGGING = {
'version': 1,
'disable_existing_loggers': False,
'handlers': {
'console': {
'class': 'logging.StreamHandler',
},
},
'loggers': {
'aircox.core': {
'handlers': ['console'],
'level': os.getenv('DJANGO_LOG_LEVEL', 'INFO'),
},
'aircox.test': {
'handlers': ['console'],
'level': os.getenv('DJANGO_LOG_LEVEL', 'INFO'),
},
'aircox.tools': {
'handlers': ['console'],
'level': os.getenv('DJANGO_LOG_LEVEL', 'INFO'),
},
},
}
# Wagtail
WAGTAIL_SITE_NAME = 'Aircox'

29
instance/dev.py Normal file
View File

@ -0,0 +1,29 @@
import os
LOCALE_PATHS = ['aircox/locale', 'aircox_cms/locale']
LOGGING = {
'version': 1,
'disable_existing_loggers': False,
'handlers': {
'console': {
'class': 'logging.StreamHandler',
},
},
'loggers': {
'aircox.core': {
'handlers': ['console'],
'level': os.getenv('DJANGO_LOG_LEVEL', 'DEBUG'),
},
'aircox.test': {
'handlers': ['console'],
'level': os.getenv('DJANGO_LOG_LEVEL', 'DEBUG'),
},
'aircox.tools': {
'handlers': ['console'],
'level': os.getenv('DJANGO_LOG_LEVEL', 'DEBUG'),
},
},
}

28
instance/prod.py Normal file
View File

@ -0,0 +1,28 @@
import os
LOGGING = {
'version': 1,
'disable_existing_loggers': False,
'handlers': {
'console': {
'class': 'logging.StreamHandler',
},
},
'loggers': {
'aircox.core': {
'handlers': ['console'],
'level': os.getenv('DJANGO_LOG_LEVEL', 'INFO'),
},
'aircox.test': {
'handlers': ['console'],
'level': os.getenv('DJANGO_LOG_LEVEL', 'INFO'),
},
'aircox.tools': {
'handlers': ['console'],
'level': os.getenv('DJANGO_LOG_LEVEL', 'INFO'),
},
},
}

View File

@ -0,0 +1,58 @@
"""
Sample file for the settings.py
Environment variables:
* AIRCOX_DEBUG: enable/disable debugging
* TZ: [in base_settings] timezone (default: 'Europe/Brussels')
* LANG: [in base_settings] language code
Note that:
- SECRET_KEY
- ALLOWED_HOSTS
- DATABASES
are not defined in base_settings and must be defined here.
You can also take a look at `base_settings` for more information.
"""
import os
# If Aircox is not installed as a regular python module, you can use:
# import sys
# sys.path.append('/path/to/aircox_parent_folder/')
from .base_settings import *
DEBUG = False
if 'AIRCOX_DEBUG' in os.environ:
DEBUG = (os.environ['AIRCOX_DEBUG'].lower()) in ('true','1')
if DEBUG:
from .dev import *
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': os.path.join(BASE_DIR, 'db.sqlite3'),
'TIMEZONE': TIME_ZONE,
}
}
else:
from .prod import *
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.mysql',
'NAME': 'aircox',
'USER': 'aircox',
'PASSWORD': '',
'HOST': 'localhost',
'TIMEZONE': TIME_ZONE,
},
}
ALLOWED_HOSTS = ['127.0.0.1:8042']
SECRET_KEY = ''
WAGTAIL_SITE_NAME='Aircox'

47
instance/urls.py Normal file
View File

@ -0,0 +1,47 @@
"""aircox URL Configuration
The `urlpatterns` list routes URLs to views. For more information please see:
https://docs.djangoproject.com/en/1.8/topics/http/urls/
Examples:
Function views
1. Add an import: from my_app import views
2. Add a URL to urlpatterns: url(r'^$', views.home, name='home')
Class-based views
1. Add an import: from other_app.views import Home
2. Add a URL to urlpatterns: url(r'^$', Home.as_view(), name='home')
Including another URLconf
1. Add an import: from blog import urls as blog_urls
2. Add a URL to urlpatterns: url(r'^blog/', include(blog_urls))
"""
from django.conf.urls import include, url
from django.contrib import admin
from instance import settings
from wagtail.wagtailadmin import urls as wagtailadmin_urls
from wagtail.wagtaildocs import urls as wagtaildocs_urls
from wagtail.wagtailcore import urls as wagtail_urls
from wagtail.wagtailimages.views.serve import ServeView
import aircox.urls
urlpatterns = [
url(r'^admin/', include(admin.site.urls)),
url(r'^aircox/', include(aircox.urls.urls)),
# cms
url(r'^cms/', include(wagtailadmin_urls)),
url(r'^documents/', include(wagtaildocs_urls)),
url( r'^images/([^/]*)/(\d*)/([^/]*)/[^/]*$', ServeView.as_view(),
name='wagtailimages_serve'),
]
if settings.DEBUG:
from django.views.static import serve
urlpatterns.append(
url(r'^media/(?P<path>.*)$', serve,
{'document_root': settings.MEDIA_ROOT, 'show_indexes':True}
)
)
urlpatterns.append(url(r'', include(wagtail_urls)))

16
instance/wsgi.py Normal file
View File

@ -0,0 +1,16 @@
"""
WSGI config for aircox project.
It exposes the WSGI callable as a module-level variable named ``application``.
For more information on this file, see
https://docs.djangoproject.com/en/1.8/howto/deployment/wsgi/
"""
import os
from django.core.wsgi import get_wsgi_application
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "instance.settings")
application = get_wsgi_application()

10
manage.py Executable file
View File

@ -0,0 +1,10 @@
#!/usr/bin/env python3
import os
import sys
if __name__ == "__main__":
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "instance.settings")
from django.core.management import execute_from_command_line
execute_from_command_line(sys.argv)

View File

@ -6,10 +6,18 @@ This file is used as a reminder, can be used as crappy documentation too.
- metaclass: `class_name + 'Meta'`
- base classes: `class_name + 'Base'`
* import and naming:
- the imported "models" file in the same application is named "models"
- the imported "models" file from another application is named with the application's name
- to avoid conflict:
- django's settings can be named "main_settings"
## aircox.cms
* icons: cropped to 32x32
* cover in list items: cropped 64x64
# Long term TODO
- debug/prod configuration
@ -27,4 +35,19 @@ cms:
- player support diffusions with multiple archive files
- comments -> remove/edit by the author
# Instance's TODO
- menu_top .sections:
- display inline block
- search on the right
- lists > items style
- logo: url
- comments / more info (perhaps using the thing like the player)
- footer url to aircox's repo + admin
- styling cal (a.today colored)
- init of post related models
-> date is not formatted
-> missing image?

8
scripts/cron_diffusions Executable file
View File

@ -0,0 +1,8 @@
#! /bin/sh
# aircox daily tasks:
# - diffusions monitoring for the current month
/srv/apps/aircox/manage.py diffusions_monitor --update --clean --check
# - diffusions monitoring for the next month
/srv/apps/aircox/manage.py diffusions_monitor --update --next-month

27
scripts/nginx_aircox Normal file
View File

@ -0,0 +1,27 @@
# Sample configuration file for Nginx.
#
# The binding is done to a Gunicorn's instance (cf. supervisor scripts),
# on local port 8042 # and assumes that static files are in
# /srv/apps/aircox/static.
#
# You want to change the server_name and static location to suit your needs
#
server {
server_name aircox.somewhere.net;
listen 80;
location / {
proxy_pass http://127.0.0.1:8042/;
proxy_read_timeout 300;
proxy_redirect off;
proxy_buffering off;
proxy_store off;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
location /static/ {
alias /srv/apps/aircox/static/ ;
}
}

44
scripts/supervisord_aircox Executable file
View File

@ -0,0 +1,44 @@
; Supervisor sample config file for Aircox.
;
; It assumes that the instance is installed in the directory
; "/srv/apps/aircox". It requires Gunicorn in order to run the
; WSGI server.
;
; * aircox_server: WSGI server instance using Gunicorn for production;
; Note that it does not serve static files.
; * aircox_sounds_monitor: sounds scanning, monitoring, quality-check,
; and synchronisation with the database.
; * aircox_controllers: audio stream generation and monitoring; create
; config and playlists, and run the required programs.
; note: must be restarted after changes in controller's sources.
;
[program:aircox_server]
command = gunicorn --bind 127.0.0.1:8042 instance.wsgi:application
directory = /srv/apps/aircox
user = aircox
autostart = true
autorestart = true
stdout_logfile = /srv/apps/aircox/logs/server.log
redirect_stderr = true
environment=AIRCOX_DEBUG="False"
[program:aircox_sounds_monitor]
command = /srv/apps/aircox/manage.py sounds_monitor -qsm
directory = /srv/apps/aircox
user = aircox
autostart = true
autorestart = true
stdout_logfile = /srv/apps/aircox/logs/sounds_monitor.log
redirect_stderr = true
[program:aircox_controllers]
command = /srv/apps/aircox/manage.py controllers -crm
directory = /srv/apps/aircox
user = aircox
autostart = true
autorestart = true
stdout_logfile = /srv/apps/aircox/logs/controllers.log
redirect_stderr = true