work on website + page becomes concrete
This commit is contained in:
		@ -1,24 +1,10 @@
 | 
			
		||||
# Aircox Programs
 | 
			
		||||
 | 
			
		||||
This application defines all base models and basic control of them. We have:
 | 
			
		||||
* **Nameable**: generic class used in any class needing to be named. Includes some utility functions;
 | 
			
		||||
* **Station**: a station
 | 
			
		||||
* **Program**: the program itself;
 | 
			
		||||
* **Diffusion**: occurrence of a program planified in the timetable. For rerun, informations are bound to the initial diffusion;
 | 
			
		||||
* **Schedule**: describes diffusions frequencies for each program;
 | 
			
		||||
* **Track**: track informations in a playlist of a diffusion;
 | 
			
		||||
* **Sound**: information about a sound that can be used for podcast or rerun;
 | 
			
		||||
* **Log**: logs
 | 
			
		||||
 | 
			
		||||
# Aircox
 | 
			
		||||
Aircox application aims to provide basis of a radio management system.
 | 
			
		||||
 | 
			
		||||
## Architecture
 | 
			
		||||
A Station is basically an object that represent a radio station. On each station, we use the Program object, that is declined in two different types:
 | 
			
		||||
* **Scheduled**: the diffusion is based on a timetable and planified through one Schedule or more; Diffusion object represent the occurrence of these programs;
 | 
			
		||||
* **Streamed**: the diffusion is based on random playlist, used to fill gaps between the programs;
 | 
			
		||||
A Station contains programs that can be scheduled or streamed. A *Scheduled Program* is a regular show that has planified diffusions of its occurences (episodes). A *Streamed Program* is a program used to play randoms musics between the shows.
 | 
			
		||||
 | 
			
		||||
Each program has a directory in **AIRCOX_PROGRAMS_DIR**; For each, subdir:
 | 
			
		||||
* **archives**: complete episode record, can be used for diffusions or as a podcast
 | 
			
		||||
* **excerpts**: excerpt of an episode, or other elements, can be used as a podcast
 | 
			
		||||
Each program has a directory on the server where user puts its podcasts (in **AIRCOX_PROGRAM_DIR**). It contains the directories **archives** (complete show's podcasts) and **excerpts** (partial or whatever podcasts).
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
## manage.py's commands
 | 
			
		||||
 | 
			
		||||
										
											Binary file not shown.
										
									
								
							
										
											Binary file not shown.
										
									
								
							
										
											Binary file not shown.
										
									
								
							
										
											Binary file not shown.
										
									
								
							
										
											Binary file not shown.
										
									
								
							@ -25,6 +25,8 @@ class PageAdmin(admin.ModelAdmin):
 | 
			
		||||
    list_editable = ('status', 'category')
 | 
			
		||||
    prepopulated_fields = {"slug": ("title",)}
 | 
			
		||||
 | 
			
		||||
    change_form_template = 'admin/aircox/page_change_form.html'
 | 
			
		||||
 | 
			
		||||
    fieldsets = [
 | 
			
		||||
        ('', {
 | 
			
		||||
            'fields': ['title', 'slug', 'category', 'cover', 'content'],
 | 
			
		||||
 | 
			
		||||
@ -78,18 +78,14 @@ class Streamer:
 | 
			
		||||
    @property
 | 
			
		||||
    def inputs(self):
 | 
			
		||||
        """ Return input ports of the station """
 | 
			
		||||
        return self.station.port_set.filter(
 | 
			
		||||
            direction=Port.Direction.input,
 | 
			
		||||
            active=True
 | 
			
		||||
        )
 | 
			
		||||
        return self.station.port_set.filter(direction=Port.DIRECTION_INPUT,
 | 
			
		||||
                                            active=True)
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def outputs(self):
 | 
			
		||||
        """ Return output ports of the station """
 | 
			
		||||
        return self.station.port_set.filter(
 | 
			
		||||
            direction=Port.Direction.output,
 | 
			
		||||
            active=True,
 | 
			
		||||
        )
 | 
			
		||||
        return self.station.port_set.filter(direction=Port.DIRECTION_OUTPUT,
 | 
			
		||||
                                            active=True)
 | 
			
		||||
 | 
			
		||||
    # Sources and config ###############################################
 | 
			
		||||
    def send(self, *args, **kwargs):
 | 
			
		||||
 | 
			
		||||
@ -57,14 +57,14 @@ class Actions:
 | 
			
		||||
                diffusion.save()
 | 
			
		||||
 | 
			
		||||
    def clean(self):
 | 
			
		||||
        qs = Diffusion.objects.filter(type=Diffusion.Type.unconfirmed,
 | 
			
		||||
        qs = Diffusion.objects.filter(type=Diffusion.TYPE_UNCONFIRMED,
 | 
			
		||||
                                      start__lt=self.date)
 | 
			
		||||
        logger.info('[clean] %d diffusions will be removed', qs.count())
 | 
			
		||||
        qs.delete()
 | 
			
		||||
 | 
			
		||||
    def check(self):
 | 
			
		||||
        # TODO: redo
 | 
			
		||||
        qs = Diffusion.objects.filter(type=Diffusion.Type.unconfirmed,
 | 
			
		||||
        qs = Diffusion.objects.filter(type=Diffusion.TYPE_UNCONFIRMED,
 | 
			
		||||
                                      start__gt=self.date)
 | 
			
		||||
        items = []
 | 
			
		||||
        for diffusion in qs:
 | 
			
		||||
 | 
			
		||||
@ -184,9 +184,9 @@ class MonitorHandler(PatternMatchingEventHandler):
 | 
			
		||||
        """
 | 
			
		||||
        self.subdir = subdir
 | 
			
		||||
        if self.subdir == settings.AIRCOX_SOUND_ARCHIVES_SUBDIR:
 | 
			
		||||
            self.sound_kwargs = {'type': Sound.Type.archive}
 | 
			
		||||
            self.sound_kwargs = {'type': Sound.TYPE_ARCHIVE}
 | 
			
		||||
        else:
 | 
			
		||||
            self.sound_kwargs = {'type': Sound.Type.excerpt}
 | 
			
		||||
            self.sound_kwargs = {'type': Sound.TYPE_EXCERPT}
 | 
			
		||||
 | 
			
		||||
        patterns = ['*/{}/*{}'.format(self.subdir, ext)
 | 
			
		||||
                    for ext in settings.AIRCOX_SOUND_FILE_EXT]
 | 
			
		||||
@ -213,7 +213,7 @@ class MonitorHandler(PatternMatchingEventHandler):
 | 
			
		||||
        sound = Sound.objects.filter(path=event.src_path)
 | 
			
		||||
        if sound:
 | 
			
		||||
            sound = sound[0]
 | 
			
		||||
            sound.type = sound.Type.removed
 | 
			
		||||
            sound.type = sound.TYPE_REMOVED
 | 
			
		||||
            sound.save()
 | 
			
		||||
 | 
			
		||||
    def on_moved(self, event):
 | 
			
		||||
@ -259,11 +259,11 @@ class Command(BaseCommand):
 | 
			
		||||
            logger.info('#%d %s', program.id, program.title)
 | 
			
		||||
            self.scan_for_program(
 | 
			
		||||
                program, settings.AIRCOX_SOUND_ARCHIVES_SUBDIR,
 | 
			
		||||
                type=Sound.Type.archive,
 | 
			
		||||
                type=Sound.TYPE_ARCHIVE,
 | 
			
		||||
            )
 | 
			
		||||
            self.scan_for_program(
 | 
			
		||||
                program, settings.AIRCOX_SOUND_EXCERPTS_SUBDIR,
 | 
			
		||||
                type=Sound.Type.excerpt,
 | 
			
		||||
                type=Sound.TYPE_EXCERPT,
 | 
			
		||||
            )
 | 
			
		||||
            dirs.append(os.path.join(program.path))
 | 
			
		||||
 | 
			
		||||
@ -317,7 +317,7 @@ class Command(BaseCommand):
 | 
			
		||||
 | 
			
		||||
        # get available sound files
 | 
			
		||||
        sounds = Sound.objects.filter(is_good_quality=False) \
 | 
			
		||||
                      .exclude(type=Sound.Type.removed)
 | 
			
		||||
                      .exclude(type=Sound.TYPE_REMOVED)
 | 
			
		||||
        if check:
 | 
			
		||||
            self.check_sounds(sounds)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -152,7 +152,7 @@ class Monitor:
 | 
			
		||||
                                    .now(air_time).first()
 | 
			
		||||
 | 
			
		||||
        # log sound on air
 | 
			
		||||
        return self.log(type=Log.Type.on_air, date=source.air_time,
 | 
			
		||||
        return self.log(type=Log.TYPE_ON_AIR, date=source.air_time,
 | 
			
		||||
                        source=source.id, sound=sound, diffusion=diff,
 | 
			
		||||
                        comment=air_uri)
 | 
			
		||||
 | 
			
		||||
@ -177,7 +177,7 @@ class Monitor:
 | 
			
		||||
            if pos > now:
 | 
			
		||||
                break
 | 
			
		||||
            # log track on air
 | 
			
		||||
            self.log(type=Log.Type.on_air, date=pos, source=log.source,
 | 
			
		||||
            self.log(type=Log.TYPE_ON_AIR, date=pos, source=log.source,
 | 
			
		||||
                     track=track, comment=track)
 | 
			
		||||
 | 
			
		||||
    def handle_diffusions(self):
 | 
			
		||||
@ -208,7 +208,7 @@ class Monitor:
 | 
			
		||||
        #
 | 
			
		||||
        now = tz.now()
 | 
			
		||||
        diff = Diffusion.objects.station(self.station).on_air().now(now) \
 | 
			
		||||
                        .filter(episode__sound__type=Sound.Type.archive) \
 | 
			
		||||
                        .filter(episode__sound__type=Sound.TYPE_ARCHIVE) \
 | 
			
		||||
                        .first()
 | 
			
		||||
        # Can't use delay: diffusion may start later than its assigned start.
 | 
			
		||||
        log = None if not diff else self.logs.start().filter(diffusion=diff)
 | 
			
		||||
@ -228,13 +228,13 @@ class Monitor:
 | 
			
		||||
    def start_diff(self, source, diff):
 | 
			
		||||
        playlist = Sound.objects.episode(id=diff.episode_id).paths()
 | 
			
		||||
        source.append(*playlist)
 | 
			
		||||
        self.log(type=Log.Type.start, source=source.id, diffusion=diff,
 | 
			
		||||
        self.log(type=Log.TYPE_START, source=source.id, diffusion=diff,
 | 
			
		||||
                 comment=str(diff))
 | 
			
		||||
 | 
			
		||||
    def cancel_diff(self, source, diff):
 | 
			
		||||
        diff.type = Diffusion.Type.cancel
 | 
			
		||||
        diff.type = Diffusion.TYPE_CANCEL
 | 
			
		||||
        diff.save()
 | 
			
		||||
        self.log(type=Log.Type.cancel, source=source.id, diffusion=diff,
 | 
			
		||||
        self.log(type=Log.TYPE_CANCEL, source=source.id, diffusion=diff,
 | 
			
		||||
                 comment=str(diff))
 | 
			
		||||
 | 
			
		||||
    def sync(self):
 | 
			
		||||
 | 
			
		||||
										
											Binary file not shown.
										
									
								
							
										
											Binary file not shown.
										
									
								
							
										
											Binary file not shown.
										
									
								
							
										
											Binary file not shown.
										
									
								
							
										
											Binary file not shown.
										
									
								
							
										
											Binary file not shown.
										
									
								
							
										
											Binary file not shown.
										
									
								
							@ -1,11 +1,17 @@
 | 
			
		||||
from django.db import models
 | 
			
		||||
from django.utils.translation import ugettext_lazy as _
 | 
			
		||||
 | 
			
		||||
from .page import Page
 | 
			
		||||
from .page import Page, PageQuerySet
 | 
			
		||||
from .program import Program, InProgramQuerySet
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class ArticleQuerySet(InProgramQuerySet, PageQuerySet):
 | 
			
		||||
    pass
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Article(Page):
 | 
			
		||||
    detail_url_name = 'article-detail'
 | 
			
		||||
 | 
			
		||||
    program = models.ForeignKey(
 | 
			
		||||
        Program, models.SET_NULL,
 | 
			
		||||
        verbose_name=_('program'), blank=True, null=True,
 | 
			
		||||
@ -17,7 +23,7 @@ class Article(Page):
 | 
			
		||||
                    'instead of a blog article'),
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    objects = InProgramQuerySet.as_manager()
 | 
			
		||||
    objects = ArticleQuerySet.as_manager()
 | 
			
		||||
 | 
			
		||||
    class Meta:
 | 
			
		||||
        verbose_name = _('Article')
 | 
			
		||||
 | 
			
		||||
@ -1,9 +1,7 @@
 | 
			
		||||
import datetime
 | 
			
		||||
from enum import IntEnum
 | 
			
		||||
 | 
			
		||||
from django.db import models
 | 
			
		||||
from django.db.models import F, Q
 | 
			
		||||
from django.db.models.functions import Concat, Substr
 | 
			
		||||
from django.db.models import Q
 | 
			
		||||
from django.utils import timezone as tz
 | 
			
		||||
from django.utils.translation import ugettext_lazy as _
 | 
			
		||||
from django.utils.functional import cached_property
 | 
			
		||||
@ -64,7 +62,7 @@ class DiffusionQuerySet(BaseRerunQuerySet):
 | 
			
		||||
 | 
			
		||||
    def on_air(self):
 | 
			
		||||
        """ On air diffusions """
 | 
			
		||||
        return self.filter(type=Diffusion.Type.on_air)
 | 
			
		||||
        return self.filter(type=Diffusion.TYPE_ON_AIR)
 | 
			
		||||
 | 
			
		||||
    def now(self, now=None, order=True):
 | 
			
		||||
        """ Diffusions occuring now """
 | 
			
		||||
@ -132,20 +130,20 @@ class Diffusion(BaseRerun):
 | 
			
		||||
    """
 | 
			
		||||
    objects = DiffusionQuerySet.as_manager()
 | 
			
		||||
 | 
			
		||||
    class Type(IntEnum):
 | 
			
		||||
        on_air = 0x00
 | 
			
		||||
        unconfirmed = 0x01
 | 
			
		||||
        cancel = 0x02
 | 
			
		||||
    TYPE_ON_AIR = 0x00
 | 
			
		||||
    TYPE_UNCONFIRMED = 0x01
 | 
			
		||||
    TYPE_CANCEL = 0x02
 | 
			
		||||
    TYPE_CHOICES = (
 | 
			
		||||
        (TYPE_ON_AIR, _('on air')),
 | 
			
		||||
        (TYPE_UNCONFIRMED, _('not confirmed')),
 | 
			
		||||
        (TYPE_CANCEL, _('cancelled')),
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    episode = models.ForeignKey(
 | 
			
		||||
        Episode, models.CASCADE,
 | 
			
		||||
        verbose_name=_('episode'),
 | 
			
		||||
        Episode, models.CASCADE, verbose_name=_('episode'),
 | 
			
		||||
    )
 | 
			
		||||
    type = models.SmallIntegerField(
 | 
			
		||||
        verbose_name=_('type'),
 | 
			
		||||
        default=Type.on_air,
 | 
			
		||||
        choices=[(int(y), _(x.replace('_', ' ')))
 | 
			
		||||
                 for x, y in Type.__members__.items()],
 | 
			
		||||
        verbose_name=_('type'), default=TYPE_ON_AIR, choices=TYPE_CHOICES,
 | 
			
		||||
    )
 | 
			
		||||
    start = models.DateTimeField(_('start'))
 | 
			
		||||
    end = models.DateTimeField(_('end'))
 | 
			
		||||
@ -222,7 +220,7 @@ class Diffusion(BaseRerun):
 | 
			
		||||
    # TODO: property?
 | 
			
		||||
    def is_live(self):
 | 
			
		||||
        """ True if Diffusion is live (False if there are sounds files). """
 | 
			
		||||
        return self.type == self.Type.on_air and \
 | 
			
		||||
        return self.type == self.TYPE_ON_AIR and \
 | 
			
		||||
            not self.episode.sound_set.archive().count()
 | 
			
		||||
 | 
			
		||||
    def get_playlist(self, **types):
 | 
			
		||||
@ -232,7 +230,7 @@ class Diffusion(BaseRerun):
 | 
			
		||||
        """
 | 
			
		||||
        from .sound import Sound
 | 
			
		||||
        return list(self.get_sounds(**types)
 | 
			
		||||
                        .filter(path__isnull=False, type=Sound.Type.archive)
 | 
			
		||||
                        .filter(path__isnull=False, type=Sound.TYPE_ARCHIVE)
 | 
			
		||||
                        .values_list('path', flat=True))
 | 
			
		||||
 | 
			
		||||
    def get_sounds(self, **types):
 | 
			
		||||
 | 
			
		||||
@ -1,6 +1,4 @@
 | 
			
		||||
from collections import deque
 | 
			
		||||
import datetime
 | 
			
		||||
from enum import IntEnum
 | 
			
		||||
import logging
 | 
			
		||||
import os
 | 
			
		||||
 | 
			
		||||
@ -9,7 +7,7 @@ from django.utils import timezone as tz
 | 
			
		||||
from django.utils.translation import ugettext_lazy as _
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
from aircox import settings, utils
 | 
			
		||||
from aircox import settings
 | 
			
		||||
from .episode import Diffusion
 | 
			
		||||
from .sound import Sound, Track
 | 
			
		||||
from .station import Station
 | 
			
		||||
@ -35,10 +33,10 @@ class LogQuerySet(models.QuerySet):
 | 
			
		||||
            self.filter(date__date__gte=date)
 | 
			
		||||
 | 
			
		||||
    def on_air(self):
 | 
			
		||||
        return self.filter(type=Log.Type.on_air)
 | 
			
		||||
        return self.filter(type=Log.TYPE_ON_AIR)
 | 
			
		||||
 | 
			
		||||
    def start(self):
 | 
			
		||||
        return self.filter(type=Log.Type.start)
 | 
			
		||||
        return self.filter(type=Log.TYPE_START)
 | 
			
		||||
 | 
			
		||||
    def with_diff(self, with_it=True):
 | 
			
		||||
        return self.filter(diffusion__isnull=not with_it)
 | 
			
		||||
@ -163,43 +161,33 @@ class Log(models.Model):
 | 
			
		||||
    This only remember what has been played on the outputs, not on each
 | 
			
		||||
    source; Source designate here which source is responsible of that.
 | 
			
		||||
    """
 | 
			
		||||
    class Type(IntEnum):
 | 
			
		||||
        stop = 0x00
 | 
			
		||||
        """
 | 
			
		||||
        Source has been stopped, e.g. manually
 | 
			
		||||
        """
 | 
			
		||||
        # Rule: \/ diffusion != null \/ sound != null
 | 
			
		||||
        start = 0x01
 | 
			
		||||
        """ Diffusion or sound has been request to be played. """
 | 
			
		||||
        cancel = 0x02
 | 
			
		||||
        """ Diffusion has been canceled. """
 | 
			
		||||
        # Rule: \/ sound != null /\ track == null
 | 
			
		||||
        #       \/ sound == null /\ track != null
 | 
			
		||||
        #       \/ sound == null /\ track == null /\ comment = sound_path
 | 
			
		||||
        on_air = 0x03
 | 
			
		||||
        """
 | 
			
		||||
        The sound or diffusion has been detected occurring on air. Can
 | 
			
		||||
        also designate live diffusion, although Liquidsoap did not play
 | 
			
		||||
        them since they don't have an attached sound archive.
 | 
			
		||||
        """
 | 
			
		||||
        other = 0x04
 | 
			
		||||
        """ Other log """
 | 
			
		||||
 | 
			
		||||
    TYPE_STOP = 0x00
 | 
			
		||||
    """ Source has been stopped, e.g. manually """
 | 
			
		||||
    # Rule: \/ diffusion != null \/ sound != null
 | 
			
		||||
    TYPE_START = 0x01
 | 
			
		||||
    """ Diffusion or sound has been request to be played. """
 | 
			
		||||
    TYPE_CANCEL = 0x02
 | 
			
		||||
    """ Diffusion has been canceled. """
 | 
			
		||||
    # Rule: \/ sound != null /\ track == null
 | 
			
		||||
    #       \/ sound == null /\ track != null
 | 
			
		||||
    #       \/ sound == null /\ track == null /\ comment = sound_path
 | 
			
		||||
    TYPE_ON_AIR = 0x03
 | 
			
		||||
    """ Sound or diffusion occured on air """
 | 
			
		||||
    TYPE_OTHER = 0x04
 | 
			
		||||
    """ Other log """
 | 
			
		||||
    TYPE_CHOICES = (
 | 
			
		||||
        (TYPE_STOP, _('stop')), (TYPE_START, _('start')),
 | 
			
		||||
        (TYPE_CANCEL, _('cancelled')), (TYPE_ON_AIR, _('on air')),
 | 
			
		||||
        (TYPE_OTHER, _('other'))
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    station = models.ForeignKey(
 | 
			
		||||
        Station, models.CASCADE,
 | 
			
		||||
        verbose_name=_('station'),
 | 
			
		||||
        help_text=_('related station'),
 | 
			
		||||
    )
 | 
			
		||||
    type = models.SmallIntegerField(
 | 
			
		||||
        choices=[(int(y), _(x.replace('_', ' ')))
 | 
			
		||||
                 for x, y in Type.__members__.items()],
 | 
			
		||||
        blank=True, null=True,
 | 
			
		||||
        verbose_name=_('type'),
 | 
			
		||||
    )
 | 
			
		||||
    date = models.DateTimeField(
 | 
			
		||||
        default=tz.now, db_index=True,
 | 
			
		||||
        verbose_name=_('date'),
 | 
			
		||||
        verbose_name=_('station'), help_text=_('related station'),
 | 
			
		||||
    )
 | 
			
		||||
    type = models.SmallIntegerField(_('type'), choices=TYPE_CHOICES)
 | 
			
		||||
    date = models.DateTimeField(_('date'), default=tz.now, db_index=True)
 | 
			
		||||
    source = models.CharField(
 | 
			
		||||
        # we use a CharField to avoid loosing logs information if the
 | 
			
		||||
        # source is removed
 | 
			
		||||
 | 
			
		||||
@ -38,28 +38,30 @@ class Category(models.Model):
 | 
			
		||||
 | 
			
		||||
class PageQuerySet(InheritanceQuerySet):
 | 
			
		||||
    def draft(self):
 | 
			
		||||
        return self.filter(status=Page.STATUS.draft)
 | 
			
		||||
        return self.filter(status=Page.STATUS_DRAFT)
 | 
			
		||||
 | 
			
		||||
    def published(self):
 | 
			
		||||
        return self.filter(status=Page.STATUS.published)
 | 
			
		||||
        return self.filter(status=Page.STATUS_PUBLISHED)
 | 
			
		||||
 | 
			
		||||
    def trash(self):
 | 
			
		||||
        return self.filter(status=Page.STATUS.trash)
 | 
			
		||||
        return self.filter(status=Page.STATUS_TRASH)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Page(models.Model):
 | 
			
		||||
    """ Base class for publishable content """
 | 
			
		||||
    class STATUS(IntEnum):
 | 
			
		||||
        draft = 0x00
 | 
			
		||||
        published = 0x10
 | 
			
		||||
        trash = 0x20
 | 
			
		||||
    STATUS_DRAFT = 0x00
 | 
			
		||||
    STATUS_PUBLISHED = 0x10
 | 
			
		||||
    STATUS_TRASH = 0x20
 | 
			
		||||
    STATUS_CHOICES = (
 | 
			
		||||
        (STATUS_DRAFT, _('draft')),
 | 
			
		||||
        (STATUS_PUBLISHED, _('published')),
 | 
			
		||||
        (STATUS_TRASH, _('trash')),
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    title = models.CharField(max_length=128)
 | 
			
		||||
    slug = models.SlugField(_('slug'), blank=True, unique=True)
 | 
			
		||||
    status = models.PositiveSmallIntegerField(
 | 
			
		||||
        _('status'),
 | 
			
		||||
        default=STATUS.draft,
 | 
			
		||||
        choices=[(int(y), _(x)) for x, y in STATUS.__members__.items()],
 | 
			
		||||
        _('status'), default=STATUS_DRAFT, choices=STATUS_CHOICES,
 | 
			
		||||
    )
 | 
			
		||||
    category = models.ForeignKey(
 | 
			
		||||
        Category, models.SET_NULL,
 | 
			
		||||
@ -84,8 +86,6 @@ class Page(models.Model):
 | 
			
		||||
 | 
			
		||||
    detail_url_name = None
 | 
			
		||||
 | 
			
		||||
    class Meta:
 | 
			
		||||
        abstract = True
 | 
			
		||||
 | 
			
		||||
    def __str__(self):
 | 
			
		||||
        return '{}: {}'.format(self._meta.verbose_name,
 | 
			
		||||
@ -104,15 +104,15 @@ class Page(models.Model):
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def is_draft(self):
 | 
			
		||||
        return self.status == self.STATUS.draft
 | 
			
		||||
        return self.status == self.STATUS_DRAFT
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def is_published(self):
 | 
			
		||||
        return self.status == self.STATUS.published
 | 
			
		||||
        return self.status == self.STATUS_PUBLISHED
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def is_trash(self):
 | 
			
		||||
        return self.status == self.STATUS.trash
 | 
			
		||||
        return self.status == self.STATUS_TRASH
 | 
			
		||||
 | 
			
		||||
    @cached_property
 | 
			
		||||
    def headline(self):
 | 
			
		||||
@ -132,6 +132,16 @@ class Page(models.Model):
 | 
			
		||||
        return cls(**cls.get_init_kwargs_from(page, **kwargs))
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Comment(models.Model):
 | 
			
		||||
    page = models.ForeignKey(
 | 
			
		||||
        Page, models.CASCADE, verbose_name=_('related page'),
 | 
			
		||||
    )
 | 
			
		||||
    nickname = models.CharField(_('nickname'), max_length=32)
 | 
			
		||||
    email = models.EmailField(_('email'), max_length=32)
 | 
			
		||||
    date = models.DateTimeField(auto_now_add=True)
 | 
			
		||||
    content = models.TextField(_('content'), max_length=1024)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class NavItem(models.Model):
 | 
			
		||||
    """ Navigation menu items """
 | 
			
		||||
    station = models.ForeignKey(
 | 
			
		||||
 | 
			
		||||
@ -45,6 +45,11 @@ class Program(Page):
 | 
			
		||||
    Renaming a Program rename the corresponding directory to matches the new
 | 
			
		||||
    name if it does not exists.
 | 
			
		||||
    """
 | 
			
		||||
    # explicit foreign key in order to avoid related name clashes
 | 
			
		||||
    page = models.OneToOneField(
 | 
			
		||||
        Page, models.CASCADE,
 | 
			
		||||
        parent_link=True, related_name='program_page'
 | 
			
		||||
    )
 | 
			
		||||
    station = models.ForeignKey(
 | 
			
		||||
        Station,
 | 
			
		||||
        verbose_name=_('station'),
 | 
			
		||||
@ -478,7 +483,7 @@ class Schedule(BaseRerun):
 | 
			
		||||
                initial = diffusions[initial]
 | 
			
		||||
 | 
			
		||||
            diffusions[date] = Diffusion(
 | 
			
		||||
                episode=episode, type=Diffusion.Type.on_air,
 | 
			
		||||
                episode=episode, type=Diffusion.TYPE_ON_AIR,
 | 
			
		||||
                initial=initial, start=date, end=date+duration
 | 
			
		||||
            )
 | 
			
		||||
        return episodes.values(), diffusions.values()
 | 
			
		||||
 | 
			
		||||
@ -36,7 +36,7 @@ class SoundQuerySet(models.QuerySet):
 | 
			
		||||
 | 
			
		||||
    def archive(self):
 | 
			
		||||
        """ Return sounds that are archives """
 | 
			
		||||
        return self.filter(type=Sound.Type.archive)
 | 
			
		||||
        return self.filter(type=Sound.TYPE_ARCHIVE)
 | 
			
		||||
 | 
			
		||||
    def paths(self, archive=True, order_by=True):
 | 
			
		||||
        """
 | 
			
		||||
@ -55,11 +55,14 @@ class Sound(models.Model):
 | 
			
		||||
    A Sound is the representation of a sound file that can be either an excerpt
 | 
			
		||||
    or a complete archive of the related diffusion.
 | 
			
		||||
    """
 | 
			
		||||
    class Type(IntEnum):
 | 
			
		||||
        other = 0x00,
 | 
			
		||||
        archive = 0x01,
 | 
			
		||||
        excerpt = 0x02,
 | 
			
		||||
        removed = 0x03,
 | 
			
		||||
    TYPE_OTHER = 0x00
 | 
			
		||||
    TYPE_ARCHIVE = 0x01
 | 
			
		||||
    TYPE_EXCERPT = 0x02
 | 
			
		||||
    TYPE_REMOVED = 0x03
 | 
			
		||||
    TYPE_CHOICES = (
 | 
			
		||||
        (TYPE_OTHER, _('other')), (TYPE_ARCHIVE, _('archive')),
 | 
			
		||||
        (TYPE_EXCERPT, _('excerpt')), (TYPE_REMOVED, _('removed'))
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    name = models.CharField(_('name'), max_length=64)
 | 
			
		||||
    program = models.ForeignKey(
 | 
			
		||||
@ -72,11 +75,7 @@ class Sound(models.Model):
 | 
			
		||||
        Episode, models.SET_NULL, blank=True, null=True,
 | 
			
		||||
        verbose_name=_('episode'),
 | 
			
		||||
    )
 | 
			
		||||
    type = models.SmallIntegerField(
 | 
			
		||||
        verbose_name=_('type'),
 | 
			
		||||
        choices=[(int(y), _(x)) for x, y in Type.__members__.items()],
 | 
			
		||||
        blank=True, null=True
 | 
			
		||||
    )
 | 
			
		||||
    type = models.SmallIntegerField(_('type'), choices=TYPE_CHOICES)
 | 
			
		||||
    # FIXME: url() does not use the same directory than here
 | 
			
		||||
    #        should we use FileField for more reliability?
 | 
			
		||||
    path = models.FilePathField(
 | 
			
		||||
@ -196,21 +195,21 @@ class Sound(models.Model):
 | 
			
		||||
        """
 | 
			
		||||
 | 
			
		||||
        if not self.file_exists():
 | 
			
		||||
            if self.type == self.Type.removed:
 | 
			
		||||
            if self.type == self.TYPE_REMOVED:
 | 
			
		||||
                return
 | 
			
		||||
            logger.info('sound %s: has been removed', self.path)
 | 
			
		||||
            self.type = self.Type.removed
 | 
			
		||||
            self.type = self.TYPE_REMOVED
 | 
			
		||||
 | 
			
		||||
            return True
 | 
			
		||||
 | 
			
		||||
        # not anymore removed
 | 
			
		||||
        changed = False
 | 
			
		||||
 | 
			
		||||
        if self.type == self.Type.removed and self.program:
 | 
			
		||||
        if self.type == self.TYPE_REMOVED and self.program:
 | 
			
		||||
            changed = True
 | 
			
		||||
            self.type = self.Type.archive \
 | 
			
		||||
            self.type = self.TYPE_ARCHIVE \
 | 
			
		||||
                if self.path.startswith(self.program.archives_path) else \
 | 
			
		||||
                self.Type.excerpt
 | 
			
		||||
                self.TYPE_EXCERPT
 | 
			
		||||
 | 
			
		||||
        # check mtime -> reset quality if changed (assume file changed)
 | 
			
		||||
        mtime = self.get_mtime()
 | 
			
		||||
 | 
			
		||||
@ -1,4 +1,3 @@
 | 
			
		||||
from enum import IntEnum
 | 
			
		||||
import os
 | 
			
		||||
 | 
			
		||||
from django.db import models
 | 
			
		||||
@ -91,36 +90,32 @@ class Port(models.Model):
 | 
			
		||||
    Some port types may be not available depending on the
 | 
			
		||||
    direction of the port.
 | 
			
		||||
    """
 | 
			
		||||
    class Direction(IntEnum):
 | 
			
		||||
        input = 0x00
 | 
			
		||||
        output = 0x01
 | 
			
		||||
    DIRECTION_INPUT = 0x00
 | 
			
		||||
    DIRECTION_OUTPUT = 0x01
 | 
			
		||||
    DIRECTION_CHOICES = ((DIRECTION_INPUT, _('input')),
 | 
			
		||||
                         (DIRECTION_OUTPUT, _('output')))
 | 
			
		||||
 | 
			
		||||
    class Type(IntEnum):
 | 
			
		||||
        jack = 0x00
 | 
			
		||||
        alsa = 0x01
 | 
			
		||||
        pulseaudio = 0x02
 | 
			
		||||
        icecast = 0x03
 | 
			
		||||
        http = 0x04
 | 
			
		||||
        https = 0x05
 | 
			
		||||
        file = 0x06
 | 
			
		||||
    TYPE_JACK = 0x00
 | 
			
		||||
    TYPE_ALSA = 0x01
 | 
			
		||||
    TYPE_PULSEAUDIO = 0x02
 | 
			
		||||
    TYPE_ICECAST = 0x03
 | 
			
		||||
    TYPE_HTTP = 0x04
 | 
			
		||||
    TYPE_HTTPS = 0x05
 | 
			
		||||
    TYPE_FILE = 0x06
 | 
			
		||||
    TYPE_CHOICES = (
 | 
			
		||||
        (TYPE_JACK, 'jack'), (TYPE_ALSA, 'alsa'),
 | 
			
		||||
        (TYPE_PULSEAUDIO, 'pulseaudio'), (TYPE_ICECAST, 'icecast'),
 | 
			
		||||
        (TYPE_HTTP, 'http'), (TYPE_HTTPS, 'https'),
 | 
			
		||||
        (TYPE_FILE, _('file'))
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    station = models.ForeignKey(
 | 
			
		||||
        Station,
 | 
			
		||||
        verbose_name=_('station'),
 | 
			
		||||
        on_delete=models.CASCADE,
 | 
			
		||||
    )
 | 
			
		||||
        Station, models.CASCADE, verbose_name=_('station'))
 | 
			
		||||
    direction = models.SmallIntegerField(
 | 
			
		||||
        _('direction'),
 | 
			
		||||
        choices=[(int(y), _(x)) for x, y in Direction.__members__.items()],
 | 
			
		||||
    )
 | 
			
		||||
    type = models.SmallIntegerField(
 | 
			
		||||
        _('type'),
 | 
			
		||||
        # we don't translate the names since it is project names.
 | 
			
		||||
        choices=[(int(y), x) for x, y in Type.__members__.items()],
 | 
			
		||||
    )
 | 
			
		||||
        _('direction'), choices=DIRECTION_CHOICES)
 | 
			
		||||
    type = models.SmallIntegerField(_('type'), choices=TYPE_CHOICES)
 | 
			
		||||
    active = models.BooleanField(
 | 
			
		||||
        _('active'),
 | 
			
		||||
        default=True,
 | 
			
		||||
        _('active'), default=True,
 | 
			
		||||
        help_text=_('this port is active')
 | 
			
		||||
    )
 | 
			
		||||
    settings = models.TextField(
 | 
			
		||||
@ -136,13 +131,13 @@ class Port(models.Model):
 | 
			
		||||
        Return True if the type is available for the given direction.
 | 
			
		||||
        """
 | 
			
		||||
 | 
			
		||||
        if self.direction == self.Direction.input:
 | 
			
		||||
        if self.direction == self.DIRECTION_INPUT:
 | 
			
		||||
            return self.type not in (
 | 
			
		||||
                self.Type.icecast, self.Type.file
 | 
			
		||||
                self.TYPE_ICECAST, self.TYPE_FILE
 | 
			
		||||
            )
 | 
			
		||||
 | 
			
		||||
        return self.type not in (
 | 
			
		||||
            self.Type.http, self.Type.https
 | 
			
		||||
            self.TYPE_HTTP, self.TYPE_HTTPS
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
    def save(self, *args, **kwargs):
 | 
			
		||||
 | 
			
		||||
@ -7159,12 +7159,18 @@ label.panel-block {
 | 
			
		||||
.is-borderless {
 | 
			
		||||
  border: none; }
 | 
			
		||||
 | 
			
		||||
.has-background-transparent {
 | 
			
		||||
  background-color: transparent; }
 | 
			
		||||
 | 
			
		||||
.navbar + .container {
 | 
			
		||||
  margin-top: 1em; }
 | 
			
		||||
 | 
			
		||||
.navbar.has-shadow, .navbar.is-fixed-bottom.has-shadow {
 | 
			
		||||
  box-shadow: 0em 0em 1em rgba(0, 0, 0, 0.1); }
 | 
			
		||||
 | 
			
		||||
a.navbar-item.is-active {
 | 
			
		||||
  border-bottom: 1px grey solid; }
 | 
			
		||||
 | 
			
		||||
/*
 | 
			
		||||
.navbar-brand img {
 | 
			
		||||
    min-height: 6em;
 | 
			
		||||
 | 
			
		||||
@ -419,7 +419,7 @@ eval("/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__,
 | 
			
		||||
/***/ (function(module, __webpack_exports__, __webpack_require__) {
 | 
			
		||||
 | 
			
		||||
"use strict";
 | 
			
		||||
eval("/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, \"a\", function() { return render; });\n/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, \"b\", function() { return staticRenderFns; });\nvar render = function() {\n  var _vm = this\n  var _h = _vm.$createElement\n  var _c = _vm._self._c || _h\n  return _c(\n    \"div\",\n    [\n      _c(\"div\", { staticClass: \"tabs is-centered\" }, [\n        _c(\"ul\", [_vm._t(\"tabs\", null, { value: _vm.value })], 2)\n      ]),\n      _vm._v(\" \"),\n      _vm._t(\"default\", null, { value: _vm.value })\n    ],\n    2\n  )\n}\nvar staticRenderFns = []\nrender._withStripped = true\n\n\n\n//# sourceURL=webpack:///./assets/vue/tabs.vue?./node_modules/vue-loader/lib/loaders/templateLoader.js??vue-loader-options!./node_modules/vue-loader/lib??vue-loader-options");
 | 
			
		||||
eval("/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, \"a\", function() { return render; });\n/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, \"b\", function() { return staticRenderFns; });\nvar render = function() {\n  var _vm = this\n  var _h = _vm.$createElement\n  var _c = _vm._self._c || _h\n  return _c(\n    \"div\",\n    [\n      _c(\"div\", { staticClass: \"tabs is-centered is-medium\" }, [\n        _c(\"ul\", [_vm._t(\"tabs\", null, { value: _vm.value })], 2)\n      ]),\n      _vm._v(\" \"),\n      _vm._t(\"default\", null, { value: _vm.value })\n    ],\n    2\n  )\n}\nvar staticRenderFns = []\nrender._withStripped = true\n\n\n\n//# sourceURL=webpack:///./assets/vue/tabs.vue?./node_modules/vue-loader/lib/loaders/templateLoader.js??vue-loader-options!./node_modules/vue-loader/lib??vue-loader-options");
 | 
			
		||||
 | 
			
		||||
/***/ })
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										32
									
								
								aircox/templates/admin/aircox/page_change_form.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										32
									
								
								aircox/templates/admin/aircox/page_change_form.html
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,32 @@
 | 
			
		||||
{% extends  "admin/change_form.html" %}
 | 
			
		||||
{% load i18n static %}
 | 
			
		||||
 | 
			
		||||
{% block extrahead %}{{ block.super }}
 | 
			
		||||
<link rel="stylesheet" type="text/css" href="{% static "aircox/vendor.css" %}"/>
 | 
			
		||||
<link rel="stylesheet" type="text/css" href="{% static "aircox/main.css" %}"/>
 | 
			
		||||
{% endblock %}
 | 
			
		||||
 | 
			
		||||
{% block submit_buttons_bottom %}
 | 
			
		||||
{% if has_change_permission %}
 | 
			
		||||
<div class="columns is-size-5">
 | 
			
		||||
    <div class="column has-text-left">
 | 
			
		||||
        {% if original and not original.is_trash %}
 | 
			
		||||
        <button type="submit" name="status" value="32" class="button is-danger is-size-6">{% trans "Move to trash" %}</button>
 | 
			
		||||
        {% endif %}
 | 
			
		||||
        {% if original and not original.is_draft %}
 | 
			
		||||
        <button type="submit" name="status" value="0" class="button is-warning is-size-6">{% trans "Mark as draft" %}</button>
 | 
			
		||||
        {% endif %}
 | 
			
		||||
    </div>
 | 
			
		||||
 | 
			
		||||
    <div class="column has-text-right">
 | 
			
		||||
        <button type="submit" class="button is-secondary is-size-6">{% trans "Save" %}</button>
 | 
			
		||||
        <button type="submit" name="_continue" class="button is-secondary is-size-6">{% trans "Save and continue" %}</button>
 | 
			
		||||
        {% if not original.is_published %}
 | 
			
		||||
        <button type="submit" name="status" value="16" class="button is-primary is-size-6">{% trans "Publish" %}</button>
 | 
			
		||||
        {% endif %}
 | 
			
		||||
    </div>
 | 
			
		||||
 | 
			
		||||
{% endif %}
 | 
			
		||||
</div>
 | 
			
		||||
{% endblock %}
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										29
									
								
								aircox/templates/aircox/article_detail.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										29
									
								
								aircox/templates/aircox/article_detail.html
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,29 @@
 | 
			
		||||
{% extends "aircox/page.html" %}
 | 
			
		||||
{% load i18n %}
 | 
			
		||||
 | 
			
		||||
{% block side_nav %}
 | 
			
		||||
{{ block.super }}
 | 
			
		||||
 | 
			
		||||
{% if side_items %}
 | 
			
		||||
<section>
 | 
			
		||||
    <h4 class="title is-4">{% trans "Latest news" %}</h4>
 | 
			
		||||
 | 
			
		||||
    {% for object in side_items %}
 | 
			
		||||
    {% include "aircox/page_item.html" %}
 | 
			
		||||
    {% endfor %}
 | 
			
		||||
 | 
			
		||||
    <br>
 | 
			
		||||
    <nav class="pagination is-centered">
 | 
			
		||||
        <ul class="pagination-list">
 | 
			
		||||
            <li>
 | 
			
		||||
                <a href="{% url "article-list" %}" class="pagination-link"
 | 
			
		||||
                    aria-label="{% trans "Show all news" %}">
 | 
			
		||||
                    {% trans "More news" %}
 | 
			
		||||
                </a>
 | 
			
		||||
            </li>
 | 
			
		||||
        </ul>
 | 
			
		||||
    </nav>
 | 
			
		||||
</section>
 | 
			
		||||
{% endif %}
 | 
			
		||||
{% endblock %}
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										12
									
								
								aircox/templates/aircox/article_list.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										12
									
								
								aircox/templates/aircox/article_list.html
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,12 @@
 | 
			
		||||
{% extends "aircox/page_list.html" %}
 | 
			
		||||
{% load i18n aircox %}
 | 
			
		||||
 | 
			
		||||
{% block title %}
 | 
			
		||||
{% if parent %}
 | 
			
		||||
    {% with parent.title as parent %}
 | 
			
		||||
    {% blocktrans %}Articles of {{ parent }}{% endblocktrans %}
 | 
			
		||||
    {% endwith %}
 | 
			
		||||
{% else %}{{ block.super }}{% endif %}
 | 
			
		||||
{% endblock %}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -67,6 +67,7 @@ Context:
 | 
			
		||||
 | 
			
		||||
                        {% block main %}{% endblock main %}
 | 
			
		||||
                    </main>
 | 
			
		||||
 | 
			
		||||
                    {% if show_side_nav %}
 | 
			
		||||
                    <aside class="column is-one-third-desktop">
 | 
			
		||||
                        {% block cover %}
 | 
			
		||||
 | 
			
		||||
@ -1,20 +1,28 @@
 | 
			
		||||
{% extends "aircox/page.html" %}
 | 
			
		||||
{% load i18n aircox %}
 | 
			
		||||
 | 
			
		||||
{% block title %}{% trans "Timetable" %}{% endblock %}
 | 
			
		||||
{% block title %}
 | 
			
		||||
{% with station.name as station %}
 | 
			
		||||
{% blocktrans %}This week's shows... {% endblocktrans %}
 | 
			
		||||
{% endwith %}
 | 
			
		||||
{% endblock %}
 | 
			
		||||
 | 
			
		||||
{% block subtitle %}
 | 
			
		||||
<div class="column">
 | 
			
		||||
{% blocktrans %}From <b>{{ start }}</b> to <b>{{ end }}</b>{% endblocktrans %}
 | 
			
		||||
</div>
 | 
			
		||||
{% endblock %}
 | 
			
		||||
 | 
			
		||||
{% block main %}
 | 
			
		||||
{{ block.super }}
 | 
			
		||||
 | 
			
		||||
<section class="section">
 | 
			
		||||
    <h3 class="subtitle size-3">
 | 
			
		||||
        {% blocktrans %}From <b>{{ start }}</b> to <b>{{ end }}</b>{% endblocktrans %}
 | 
			
		||||
    </h3>
 | 
			
		||||
 | 
			
		||||
{% with True as hide_schedule %}
 | 
			
		||||
<section>
 | 
			
		||||
    {% unique_id "timetable" as timetable_id %}
 | 
			
		||||
    <a-tabs default="{{ date }}">
 | 
			
		||||
        <template v-slot:tabs="scope" noscript="hidden">
 | 
			
		||||
            <li><a href="{% url "timetable" date=prev_date %}"><</a></li>
 | 
			
		||||
            <li><a href="{% url "timetable" date=prev_date %}">❬ {% trans "Before" %}</a></li>
 | 
			
		||||
 | 
			
		||||
            {% for day in by_date.keys %}
 | 
			
		||||
            <a-tab value="{{ day }}">
 | 
			
		||||
@ -25,11 +33,10 @@
 | 
			
		||||
            {% endfor %}
 | 
			
		||||
 | 
			
		||||
            <li>
 | 
			
		||||
                <a href="{% url "timetable" date=next_date %}">></a>
 | 
			
		||||
                <a href="{% url "timetable" date=next_date %}">{% trans "After" %} ❭</a>
 | 
			
		||||
            </li>
 | 
			
		||||
        </template>
 | 
			
		||||
 | 
			
		||||
        {% with True as hide_schedule %}
 | 
			
		||||
        <template v-slot:default="{value}">
 | 
			
		||||
            {% for day, diffusions in by_date.items %}
 | 
			
		||||
            <noscript><h4 class="subtitle is-4">{{ day|date:"l d F Y" }}</h4></noscript>
 | 
			
		||||
@ -51,8 +58,8 @@
 | 
			
		||||
            </div>
 | 
			
		||||
            {% endfor %}
 | 
			
		||||
        </template>
 | 
			
		||||
        {% endwith %}
 | 
			
		||||
    </a-tabs>
 | 
			
		||||
</section>
 | 
			
		||||
{% endwith %}
 | 
			
		||||
{% endblock %}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -32,9 +32,9 @@
 | 
			
		||||
{{ block.super }}
 | 
			
		||||
 | 
			
		||||
{% if podcasts or tracks %}
 | 
			
		||||
<section class="columns is-desktop">
 | 
			
		||||
<div class="columns is-desktop">
 | 
			
		||||
    {% if tracks %}
 | 
			
		||||
    <div class="column">
 | 
			
		||||
    <section class="column">
 | 
			
		||||
        <h4 class="title is-4">{% trans "Playlist" %}</h4>
 | 
			
		||||
        <ol>
 | 
			
		||||
            {% for track in tracks %}
 | 
			
		||||
@ -46,17 +46,17 @@
 | 
			
		||||
            </li>
 | 
			
		||||
            {% endfor %}
 | 
			
		||||
        </ol>
 | 
			
		||||
    </div>
 | 
			
		||||
    </section>
 | 
			
		||||
    {% endif %}
 | 
			
		||||
    {% if podcasts %}
 | 
			
		||||
    <div class="column">
 | 
			
		||||
    <section class="column">
 | 
			
		||||
        <h4 class="title is-4">{% trans "Podcasts" %}</h4>
 | 
			
		||||
        {% for object in podcasts %}
 | 
			
		||||
        {% include "aircox/podcast_item.html" %}
 | 
			
		||||
        {% endfor %}
 | 
			
		||||
        {% endif %}
 | 
			
		||||
    </div>
 | 
			
		||||
    </section>
 | 
			
		||||
    {% endif %}
 | 
			
		||||
</section>
 | 
			
		||||
</div>
 | 
			
		||||
{% endif %}
 | 
			
		||||
{% endblock %}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -2,13 +2,11 @@
 | 
			
		||||
{% load i18n aircox %}
 | 
			
		||||
 | 
			
		||||
{% block title %}
 | 
			
		||||
{% if program %}
 | 
			
		||||
    {% with program.title as program %}
 | 
			
		||||
    {% blocktrans %}Episodes of {{ program }}{% endblocktrans %}
 | 
			
		||||
{% if parent %}
 | 
			
		||||
    {% with parent.title as parent %}
 | 
			
		||||
    {% blocktrans %}Episodes of {{ parent }}{% endblocktrans %}
 | 
			
		||||
    {% endwith %}
 | 
			
		||||
{% else %}
 | 
			
		||||
    {% trans "Episodes" %}
 | 
			
		||||
    {% endif %}
 | 
			
		||||
{% else %}{{ block.super }}{% endif %}
 | 
			
		||||
{% endblock %}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -11,7 +11,7 @@
 | 
			
		||||
 | 
			
		||||
<section class="section">
 | 
			
		||||
    {% if dates %}
 | 
			
		||||
    <nav class="tabs is-centered" aria-label="{% trans "Other days' logs" %}">
 | 
			
		||||
    <nav class="tabs is-medium is-centered" aria-label="{% trans "Other days' logs" %}">
 | 
			
		||||
        <ul>
 | 
			
		||||
        {% for day in dates %}
 | 
			
		||||
            <li {% if day == date %}class="is-active"{% endif %}>
 | 
			
		||||
@ -30,7 +30,7 @@
 | 
			
		||||
 | 
			
		||||
    {# <h4 class="subtitle size-4">{{ date }}</h4> #}
 | 
			
		||||
    {% with True as hide_schedule %}
 | 
			
		||||
    <table class="table is-striped is-hoverable is-fullwidth">
 | 
			
		||||
    <table class="table is-striped is-hoverable is-fullwidth has-background-transparent">
 | 
			
		||||
        {% for object in object_list %}
 | 
			
		||||
        <tr>
 | 
			
		||||
            <td>
 | 
			
		||||
 | 
			
		||||
@ -16,7 +16,7 @@ Context:
 | 
			
		||||
 | 
			
		||||
{% block head_title %}
 | 
			
		||||
    {% block title %}{{ title }}{% endblock %}
 | 
			
		||||
    {% if title %} ‐ {% endif %}
 | 
			
		||||
    —
 | 
			
		||||
    {{ station.name }}
 | 
			
		||||
{% endblock %}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -1,15 +1,14 @@
 | 
			
		||||
{% extends "aircox/page.html" %}
 | 
			
		||||
{% load i18n aircox %}
 | 
			
		||||
 | 
			
		||||
{% with view.model|verbose_name:True as model_name_plural %}
 | 
			
		||||
 | 
			
		||||
{% block title %}
 | 
			
		||||
{{ model_name_plural }}
 | 
			
		||||
{{ view.model|verbose_name:True|title }}
 | 
			
		||||
{% endblock %}
 | 
			
		||||
 | 
			
		||||
{% block side_nav %}
 | 
			
		||||
{{ block.super }}
 | 
			
		||||
 | 
			
		||||
{% if filter_categories|length != 1 %}
 | 
			
		||||
<section class="toolbar">
 | 
			
		||||
    <h4 class="subtitle is-5">{% trans "Filters" %}</h4>
 | 
			
		||||
    <form method="GET" action="">
 | 
			
		||||
@ -51,6 +50,7 @@
 | 
			
		||||
        </div>
 | 
			
		||||
    </form>
 | 
			
		||||
</section>
 | 
			
		||||
{% endif %}
 | 
			
		||||
{% endblock %}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -97,5 +97,3 @@
 | 
			
		||||
 | 
			
		||||
{% endblock %}
 | 
			
		||||
 | 
			
		||||
{% endwith %}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -17,7 +17,7 @@
 | 
			
		||||
    </noscript>
 | 
			
		||||
 | 
			
		||||
    <a-player ref="player" src="{{ audio_streams.0 }}"
 | 
			
		||||
            live-info-url="{% url "api-live" %}" live-info-timeout="15"
 | 
			
		||||
            live-info-url="{% url "api-live" %}" :live-info-timeout="15"
 | 
			
		||||
            button-title="{% trans "Play/pause audio" %}">
 | 
			
		||||
        <template v-slot:sources>
 | 
			
		||||
            {% for stream in audio_streams %}
 | 
			
		||||
 | 
			
		||||
@ -4,11 +4,11 @@
 | 
			
		||||
{% block side_nav %}
 | 
			
		||||
{{ block.super }}
 | 
			
		||||
 | 
			
		||||
{% if episodes %}
 | 
			
		||||
{% if side_items %}
 | 
			
		||||
<section>
 | 
			
		||||
    <h4 class="title is-4">{% trans "Last shows" %}</h4>
 | 
			
		||||
 | 
			
		||||
    {% for object in episodes %}
 | 
			
		||||
    {% for object in side_items %}
 | 
			
		||||
    {% include "aircox/episode_item.html" %}
 | 
			
		||||
    {% endfor %}
 | 
			
		||||
 | 
			
		||||
@ -16,13 +16,14 @@
 | 
			
		||||
    <nav class="pagination is-centered">
 | 
			
		||||
        <ul class="pagination-list">
 | 
			
		||||
            <li>
 | 
			
		||||
                <a href="{% url "diffusion-list" program_slug=program.slug %}"
 | 
			
		||||
                <a href="{% url "diffusion-list" parent_slug=program.slug %}"
 | 
			
		||||
                    class="pagination-link"
 | 
			
		||||
                    aria-label="{% trans "Show all diffusions" %}">
 | 
			
		||||
                    {% trans "All shows" %}
 | 
			
		||||
                    aria-label="{% trans "Show all program's diffusions" %}">
 | 
			
		||||
                    {% trans "More shows" %}
 | 
			
		||||
                </a>
 | 
			
		||||
            </li>
 | 
			
		||||
        </ul>
 | 
			
		||||
    </nav>
 | 
			
		||||
</section>
 | 
			
		||||
{% endif %}
 | 
			
		||||
{% endblock %}
 | 
			
		||||
 | 
			
		||||
@ -6,3 +6,39 @@
 | 
			
		||||
{% include "aircox/program_header.html" %}
 | 
			
		||||
{% endblock %}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
{% block main %}
 | 
			
		||||
{{ block.super }}
 | 
			
		||||
 | 
			
		||||
<br>
 | 
			
		||||
 | 
			
		||||
{% with show_headline=False %}
 | 
			
		||||
<div class="columns is-desktop">
 | 
			
		||||
    {% if articles %}
 | 
			
		||||
    <section class="column">
 | 
			
		||||
        <h4 class="title is-4">{% trans "Articles" %}</h4>
 | 
			
		||||
 | 
			
		||||
        {% for object in articles %}
 | 
			
		||||
        {% include "aircox/page_item.html" %}
 | 
			
		||||
        {% endfor %}
 | 
			
		||||
 | 
			
		||||
        <br>
 | 
			
		||||
        <nav class="pagination is-centered">
 | 
			
		||||
            <ul class="pagination-list">
 | 
			
		||||
                <li>
 | 
			
		||||
                    <a href="{% url "article-list" parent_slug=program.slug %}"
 | 
			
		||||
                        class="pagination-link"
 | 
			
		||||
                        aria-label="{% trans "Show all program's articles" %}">
 | 
			
		||||
                        {% trans "More articles" %}
 | 
			
		||||
                    </a>
 | 
			
		||||
                </li>
 | 
			
		||||
            </ul>
 | 
			
		||||
        </nav>
 | 
			
		||||
    </section>
 | 
			
		||||
    {% endif %}
 | 
			
		||||
</div>
 | 
			
		||||
{% endwith %}
 | 
			
		||||
 | 
			
		||||
{% endblock %}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -30,26 +30,26 @@ urls = [
 | 
			
		||||
         views.ArticleListView.as_view(model=models.Article, is_static=False),
 | 
			
		||||
         name='article-list'),
 | 
			
		||||
    path(_('articles/<slug:slug>/'),
 | 
			
		||||
         views.PageDetailView.as_view(model=models.Article),
 | 
			
		||||
         views.ArticleDetailView.as_view(),
 | 
			
		||||
         name='article-detail'),
 | 
			
		||||
 | 
			
		||||
    path(_('programs/'), views.PageListView.as_view(model=models.Program),
 | 
			
		||||
         name='program-list'),
 | 
			
		||||
    path(_('programs/<slug:slug>/'),
 | 
			
		||||
         views.ProgramDetailView.as_view(), name='program-detail'),
 | 
			
		||||
    path(_('programs/<slug:program_slug>/episodes/'),
 | 
			
		||||
    path(_('programs/<slug:parent_slug>/episodes/'),
 | 
			
		||||
         views.EpisodeListView.as_view(), name='diffusion-list'),
 | 
			
		||||
    path(_('programs/<slug:program_slug>/articles/'),
 | 
			
		||||
    path(_('programs/<slug:parent_slug>/articles/'),
 | 
			
		||||
         views.ArticleListView.as_view(), name='article-list'),
 | 
			
		||||
 | 
			
		||||
    path(_('episodes/'),
 | 
			
		||||
         views.EpisodeListView.as_view(), name='diffusion-list'),
 | 
			
		||||
    path(_('episodes/week/'),
 | 
			
		||||
         views.TimetableView.as_view(), name='timetable'),
 | 
			
		||||
    path(_('episodes/week/<week:date>/'),
 | 
			
		||||
         views.TimetableView.as_view(), name='timetable'),
 | 
			
		||||
    path(_('episodes/<slug:slug>/'),
 | 
			
		||||
         views.EpisodeDetailView.as_view(), name='episode-detail'),
 | 
			
		||||
    path(_('week/'),
 | 
			
		||||
         views.TimetableView.as_view(), name='timetable'),
 | 
			
		||||
    path(_('week/<week:date>/'),
 | 
			
		||||
         views.TimetableView.as_view(), name='timetable'),
 | 
			
		||||
 | 
			
		||||
    path(_('logs/'), views.LogListView.as_view(), name='logs'),
 | 
			
		||||
    path(_('logs/<date:date>/'), views.LogListView.as_view(), name='logs'),
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										257
									
								
								aircox/views.py
									
									
									
									
									
								
							
							
						
						
									
										257
									
								
								aircox/views.py
									
									
									
									
									
								
							@ -1,257 +0,0 @@
 | 
			
		||||
import os
 | 
			
		||||
import json
 | 
			
		||||
import datetime
 | 
			
		||||
 | 
			
		||||
from django.views.generic.base import View, TemplateResponseMixin
 | 
			
		||||
from django.contrib.auth.mixins import LoginRequiredMixin
 | 
			
		||||
from django.http import HttpResponse, Http404
 | 
			
		||||
from django.shortcuts import render
 | 
			
		||||
from django.utils.translation import ugettext as _
 | 
			
		||||
from django.utils import timezone as tz
 | 
			
		||||
from django.views.decorators.cache import cache_page
 | 
			
		||||
 | 
			
		||||
import aircox.models as models
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
# FIXME usefull?
 | 
			
		||||
class Stations:
 | 
			
		||||
    stations = models.Station.objects.all()
 | 
			
		||||
    update_timeout = None
 | 
			
		||||
    fetch_timeout = None
 | 
			
		||||
 | 
			
		||||
    def fetch(self):
 | 
			
		||||
        if self.fetch_timeout and self.fetch_timeout > tz.now():
 | 
			
		||||
            return
 | 
			
		||||
 | 
			
		||||
        self.fetch_timeout = tz.now() + tz.timedelta(seconds=5)
 | 
			
		||||
        for station in self.stations:
 | 
			
		||||
            station.streamer.fetch()
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
stations = Stations()
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@cache_page(10)
 | 
			
		||||
def on_air(request):
 | 
			
		||||
    try:
 | 
			
		||||
        import aircox_cms.models as cms
 | 
			
		||||
    except:
 | 
			
		||||
        cms = None
 | 
			
		||||
 | 
			
		||||
    station = request.GET.get('station')
 | 
			
		||||
    if station:
 | 
			
		||||
        # FIXME: by name???
 | 
			
		||||
        station = stations.stations.filter(name=station)
 | 
			
		||||
        if not station.count():
 | 
			
		||||
            return HttpResponse('{}')
 | 
			
		||||
    else:
 | 
			
		||||
        station = stations.stations
 | 
			
		||||
 | 
			
		||||
    station = station.first()
 | 
			
		||||
    on_air = station.on_air(count=10).select_related('track', 'diffusion')
 | 
			
		||||
    if not on_air.count():
 | 
			
		||||
        return HttpResponse('')
 | 
			
		||||
 | 
			
		||||
    last = on_air.first()
 | 
			
		||||
    if last.track:
 | 
			
		||||
        last = {'date': last.date, 'type': 'track',
 | 
			
		||||
                'artist': last.track.artist, 'title': last.track.title}
 | 
			
		||||
    else:
 | 
			
		||||
        try:
 | 
			
		||||
            diff = last.diffusion
 | 
			
		||||
            publication = None
 | 
			
		||||
            # FIXME CMS
 | 
			
		||||
            if cms:
 | 
			
		||||
                publication = \
 | 
			
		||||
                    cms.DiffusionPage.objects.filter(
 | 
			
		||||
                        diffusion=diff.initial or diff).first() or \
 | 
			
		||||
                    cms.ProgramPage.objects.filter(
 | 
			
		||||
                        program=last.program).first()
 | 
			
		||||
        except:
 | 
			
		||||
            pass
 | 
			
		||||
        last = {'date': diff.start, 'type': 'diffusion',
 | 
			
		||||
                'title': diff.program.name,
 | 
			
		||||
                'url': publication.specific.url if publication else None}
 | 
			
		||||
    last['date'] = str(last['date'])
 | 
			
		||||
    return HttpResponse(json.dumps(last))
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
# TODO:
 | 
			
		||||
#   - login url
 | 
			
		||||
class Monitor(View, TemplateResponseMixin, LoginRequiredMixin):
 | 
			
		||||
    template_name = 'aircox/controllers/monitor.html'
 | 
			
		||||
 | 
			
		||||
    def get_context_data(self, **kwargs):
 | 
			
		||||
        stations.fetch()
 | 
			
		||||
        return {'stations': stations.stations}
 | 
			
		||||
 | 
			
		||||
    def get(self, request=None, **kwargs):
 | 
			
		||||
        if not request.user.is_active:
 | 
			
		||||
            return Http404()
 | 
			
		||||
 | 
			
		||||
        self.request = request
 | 
			
		||||
        context = self.get_context_data(**kwargs)
 | 
			
		||||
        return render(request, self.template_name, context)
 | 
			
		||||
 | 
			
		||||
    def post(self, request=None, **kwargs):
 | 
			
		||||
        if not request.user.is_active:
 | 
			
		||||
            return Http404()
 | 
			
		||||
 | 
			
		||||
        if not ('action' or 'station') in request.POST:
 | 
			
		||||
            return HttpResponse('')
 | 
			
		||||
 | 
			
		||||
        POST = request.POST
 | 
			
		||||
        POST.get('controller')
 | 
			
		||||
        action = POST.get('action')
 | 
			
		||||
 | 
			
		||||
        station = stations.stations.filter(name=POST.get('station')) \
 | 
			
		||||
                                   .first()
 | 
			
		||||
        if not station:
 | 
			
		||||
            return Http404()
 | 
			
		||||
 | 
			
		||||
        source = None
 | 
			
		||||
        if 'source' in POST:
 | 
			
		||||
            source = [s for s in station.sources
 | 
			
		||||
                      if s.name == POST['source']]
 | 
			
		||||
            source = source[0]
 | 
			
		||||
            if not source:
 | 
			
		||||
                return Http404
 | 
			
		||||
 | 
			
		||||
        station.streamer.fetch()
 | 
			
		||||
        source = source or station.streamer.source
 | 
			
		||||
        if action == 'skip':
 | 
			
		||||
            self.actionSkip(request, station, source)
 | 
			
		||||
        if action == 'restart':
 | 
			
		||||
            self.actionRestart(request, station, source)
 | 
			
		||||
        return HttpResponse('')
 | 
			
		||||
 | 
			
		||||
    def actionSkip(self, request, station, source):
 | 
			
		||||
        source.skip()
 | 
			
		||||
 | 
			
		||||
    def actionRestart(self, request, station, source):
 | 
			
		||||
        source.restart()
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class StatisticsView(View, TemplateResponseMixin, LoginRequiredMixin):
 | 
			
		||||
    """
 | 
			
		||||
    View for statistics.
 | 
			
		||||
    """
 | 
			
		||||
    # we cannot manipulate queryset: we have to be able to read from archives
 | 
			
		||||
    template_name = 'aircox/controllers/stats.html'
 | 
			
		||||
 | 
			
		||||
    class Item:
 | 
			
		||||
        date = None
 | 
			
		||||
        end = None
 | 
			
		||||
        name = None
 | 
			
		||||
        related = None
 | 
			
		||||
        tracks = None
 | 
			
		||||
        tags = None
 | 
			
		||||
        col = None
 | 
			
		||||
 | 
			
		||||
        def __init__(self, **kwargs):
 | 
			
		||||
            self.__dict__.update(kwargs)
 | 
			
		||||
 | 
			
		||||
    class Stats:
 | 
			
		||||
        station = None
 | 
			
		||||
        date = None
 | 
			
		||||
        items = None
 | 
			
		||||
        """
 | 
			
		||||
        Log or Diffusion object that has been diffused by date. These
 | 
			
		||||
        objects have extra fields:
 | 
			
		||||
            - tags: [ (tag_name, tag_count), ...]
 | 
			
		||||
            - tracks_count: total count of tracks
 | 
			
		||||
        """
 | 
			
		||||
        count = 0
 | 
			
		||||
        #rows = None
 | 
			
		||||
 | 
			
		||||
        def __init__(self, **kwargs):
 | 
			
		||||
            self.items = []
 | 
			
		||||
        #    self.rows = []
 | 
			
		||||
            self.__dict__.update(kwargs)
 | 
			
		||||
 | 
			
		||||
        # Note: one row contains a column for diffusions and one for streams
 | 
			
		||||
        # def append(self, log):
 | 
			
		||||
        #    if log.col == 0:
 | 
			
		||||
        #        self.rows.append((log, []))
 | 
			
		||||
        #        return
 | 
			
		||||
        #
 | 
			
		||||
        #    if self.rows:
 | 
			
		||||
        #        row = self.rows[len(self.rows)-1]
 | 
			
		||||
        #        last = row[0] or row[1][len(row[1])-1]
 | 
			
		||||
        #        if last.date < log.date < last.end:
 | 
			
		||||
        #            row[1].append(log)
 | 
			
		||||
        #            return
 | 
			
		||||
        #
 | 
			
		||||
        #    # all other cases: new row
 | 
			
		||||
        #    self.rows.append((None, [log]))
 | 
			
		||||
 | 
			
		||||
    def get_stats(self, station, date):
 | 
			
		||||
        """
 | 
			
		||||
        Return statistics for the given station and date.
 | 
			
		||||
        """
 | 
			
		||||
        stats = self.Stats(station=station, date=date,
 | 
			
		||||
                           items=[], tags={})
 | 
			
		||||
 | 
			
		||||
        qs = Log.objects.station(station).on_air() \
 | 
			
		||||
                .prefetch_related('diffusion', 'sound', 'track', 'track__tags')
 | 
			
		||||
        if not qs.exists():
 | 
			
		||||
            qs = models.Log.objects.load_archive(station, date)
 | 
			
		||||
 | 
			
		||||
        sound_log = None
 | 
			
		||||
        for log in qs:
 | 
			
		||||
            rel, item = None, None
 | 
			
		||||
            if log.diffusion:
 | 
			
		||||
                rel, item = log.diffusion, self.Item(
 | 
			
		||||
                    name=rel.program.name, type=_('Diffusion'), col=0,
 | 
			
		||||
                    tracks=models.Track.objects.filter(diffusion=log.diffusion)
 | 
			
		||||
                                       .prefetch_related('tags'),
 | 
			
		||||
                )
 | 
			
		||||
                sound_log = None
 | 
			
		||||
            elif log.sound:
 | 
			
		||||
                rel, item = log.sound, self.Item(
 | 
			
		||||
                    name=rel.program.name + ': ' + os.path.basename(rel.path),
 | 
			
		||||
                    type=_('Stream'), col=1, tracks=[],
 | 
			
		||||
                )
 | 
			
		||||
                sound_log = item
 | 
			
		||||
            elif log.track:
 | 
			
		||||
                # append to last sound log
 | 
			
		||||
                if not sound_log:
 | 
			
		||||
                    continue
 | 
			
		||||
                sound_log.tracks.append(log.track)
 | 
			
		||||
                sound_log.end = log.end
 | 
			
		||||
                continue
 | 
			
		||||
 | 
			
		||||
            item.date = log.date
 | 
			
		||||
            item.end = log.end
 | 
			
		||||
            item.related = rel
 | 
			
		||||
            # stats.append(item)
 | 
			
		||||
            stats.items.append(item)
 | 
			
		||||
        return stats
 | 
			
		||||
 | 
			
		||||
    def get_context_data(self, **kwargs):
 | 
			
		||||
        context = {}
 | 
			
		||||
        date = datetime.date.today()
 | 
			
		||||
 | 
			
		||||
        try:
 | 
			
		||||
            GET = self.request.GET
 | 
			
		||||
            year = int(GET["year"]) if 'year' in GET else date.year
 | 
			
		||||
            month = int(GET["month"]) if 'month' in GET else date.month
 | 
			
		||||
            day = int(GET["day"]) if 'day' in GET else date.day
 | 
			
		||||
            date = datetime.date(year, month, day)
 | 
			
		||||
        except:
 | 
			
		||||
            pass
 | 
			
		||||
 | 
			
		||||
        context["statistics"] = [
 | 
			
		||||
            self.get_stats(station, date)
 | 
			
		||||
            for station in models.Station.objects.all()
 | 
			
		||||
        ]
 | 
			
		||||
 | 
			
		||||
        return context
 | 
			
		||||
 | 
			
		||||
    def get(self, request=None, **kwargs):
 | 
			
		||||
        if not request.user.is_active:
 | 
			
		||||
            return Http404()
 | 
			
		||||
 | 
			
		||||
        self.request = request
 | 
			
		||||
        context = self.get_context_data(**kwargs)
 | 
			
		||||
        return render(request, self.template_name, context)
 | 
			
		||||
@ -1,6 +1,6 @@
 | 
			
		||||
from . import api
 | 
			
		||||
 | 
			
		||||
from .article import ArticleListView
 | 
			
		||||
from .article import ArticleDetailView, ArticleListView
 | 
			
		||||
from .base import BaseView
 | 
			
		||||
from .episode import EpisodeDetailView, EpisodeListView, TimetableView
 | 
			
		||||
from .log import LogListView
 | 
			
		||||
 | 
			
		||||
@ -1,16 +1,36 @@
 | 
			
		||||
from ..models import Article
 | 
			
		||||
from .program import ProgramPageListView
 | 
			
		||||
from ..models import Article, Program
 | 
			
		||||
from .page import ParentMixin, PageDetailView, PageListView
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
__all__ = ['ArticleListView']
 | 
			
		||||
__all__ = ['ArticleDetailView', 'ArticleListView']
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class ArticleListView(ProgramPageListView):
 | 
			
		||||
class ArticleDetailView(PageDetailView):
 | 
			
		||||
    show_side_nav = True
 | 
			
		||||
    model = Article
 | 
			
		||||
 | 
			
		||||
    def get_side_queryset(self):
 | 
			
		||||
        qs = Article.objects.select_related('cover') \
 | 
			
		||||
                    .filter(is_static=False) \
 | 
			
		||||
                    .order_by('-date')
 | 
			
		||||
        return qs
 | 
			
		||||
 | 
			
		||||
    def get_context_data(self, **kwargs):
 | 
			
		||||
        if self.object.program is not None:
 | 
			
		||||
            kwargs.setdefault('parent', self.object.program)
 | 
			
		||||
        return super().get_context_data(**kwargs)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class ArticleListView(ParentMixin, PageListView):
 | 
			
		||||
    model = Article
 | 
			
		||||
    template_name = 'aircox/article_list.html'
 | 
			
		||||
    show_headline = True
 | 
			
		||||
    is_static = False
 | 
			
		||||
 | 
			
		||||
    def get_queryset(self):
 | 
			
		||||
        return super().get_queryset(is_static=self.is_static)
 | 
			
		||||
    parent_model = Program
 | 
			
		||||
    fk_parent = 'program'
 | 
			
		||||
 | 
			
		||||
    def get_queryset(self):
 | 
			
		||||
        return super().get_queryset().filter(is_static=self.is_static)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -6,17 +6,20 @@ from django.views.generic.base import TemplateResponseMixin, ContextMixin
 | 
			
		||||
from ..utils import Redirect
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
__all__ = ['BaseView', 'PageView']
 | 
			
		||||
__all__ = ['BaseView']
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class BaseView(TemplateResponseMixin, ContextMixin):
 | 
			
		||||
    show_side_nav = False
 | 
			
		||||
    """ Show side navigation """
 | 
			
		||||
    title = None
 | 
			
		||||
    """ Page title """
 | 
			
		||||
    cover = None
 | 
			
		||||
    """ Page cover """
 | 
			
		||||
 | 
			
		||||
    show_side_nav = False
 | 
			
		||||
    """ Show side navigation """
 | 
			
		||||
    list_count = 5
 | 
			
		||||
    """ Item count for small lists displayed on page. """
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def station(self):
 | 
			
		||||
        return self.request.station
 | 
			
		||||
@ -24,14 +27,24 @@ class BaseView(TemplateResponseMixin, ContextMixin):
 | 
			
		||||
    def get_queryset(self):
 | 
			
		||||
        return super().get_queryset().station(self.station)
 | 
			
		||||
 | 
			
		||||
    def get_context_data(self, **kwargs):
 | 
			
		||||
    def get_side_queryset(self):
 | 
			
		||||
        """ Return a queryset of items to render on the side nav. """
 | 
			
		||||
        return None
 | 
			
		||||
 | 
			
		||||
    def get_context_data(self, side_items=None, **kwargs):
 | 
			
		||||
        kwargs.setdefault('station', self.station)
 | 
			
		||||
        kwargs.setdefault('cover', self.cover)
 | 
			
		||||
        kwargs.setdefault('show_side_nav', self.show_side_nav)
 | 
			
		||||
 | 
			
		||||
        show_side_nav = kwargs.setdefault('show_side_nav', self.show_side_nav)
 | 
			
		||||
        if show_side_nav and side_items is None:
 | 
			
		||||
            side_items = self.get_side_queryset()
 | 
			
		||||
            side_items = None if side_items is None else \
 | 
			
		||||
                side_items[:self.list_count]
 | 
			
		||||
 | 
			
		||||
        if not 'audio_streams' in kwargs:
 | 
			
		||||
            streams = self.station.audio_streams
 | 
			
		||||
            streams = streams and streams.split('\n')
 | 
			
		||||
            kwargs['audio_streams'] = streams
 | 
			
		||||
        return super().get_context_data(**kwargs)
 | 
			
		||||
 | 
			
		||||
        return super().get_context_data(side_items=side_items, **kwargs)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -1,16 +1,16 @@
 | 
			
		||||
from collections import OrderedDict
 | 
			
		||||
import datetime
 | 
			
		||||
 | 
			
		||||
from django.db.models import OuterRef, Subquery
 | 
			
		||||
from django.shortcuts import get_object_or_404
 | 
			
		||||
from django.views.generic import ListView
 | 
			
		||||
 | 
			
		||||
from ..models import Diffusion, Episode, Page, Program, Sound
 | 
			
		||||
from ..converters import WeekConverter
 | 
			
		||||
from ..models import Diffusion, Episode, Program, Sound
 | 
			
		||||
from .base import BaseView
 | 
			
		||||
from .program import ProgramPageDetailView, ProgramPageListView
 | 
			
		||||
from .program import ProgramPageDetailView
 | 
			
		||||
from .page import ParentMixin, PageListView
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
__all__ = ['EpisodeDetailView', 'DiffusionListView', 'TimetableView']
 | 
			
		||||
__all__ = ['EpisodeDetailView', 'EpisodeListView', 'TimetableView']
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class EpisodeDetailView(ProgramPageDetailView):
 | 
			
		||||
@ -20,8 +20,9 @@ class EpisodeDetailView(ProgramPageDetailView):
 | 
			
		||||
        return Sound.objects.diffusion(diffusion).podcasts()
 | 
			
		||||
 | 
			
		||||
    def get_context_data(self, **kwargs):
 | 
			
		||||
        kwargs.setdefault('program', self.object.program)
 | 
			
		||||
        kwargs.setdefault('parent', kwargs['program'])
 | 
			
		||||
        self.program = kwargs.setdefault('program', self.object.program)
 | 
			
		||||
 | 
			
		||||
        kwargs.setdefault('parent', self.program)
 | 
			
		||||
        if not 'tracks' in kwargs:
 | 
			
		||||
            kwargs['tracks'] = self.object.track_set.order_by('position')
 | 
			
		||||
        if not 'podcasts' in kwargs:
 | 
			
		||||
@ -29,12 +30,15 @@ class EpisodeDetailView(ProgramPageDetailView):
 | 
			
		||||
        return super().get_context_data(**kwargs)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class EpisodeListView(ProgramPageListView):
 | 
			
		||||
class EpisodeListView(ParentMixin, PageListView):
 | 
			
		||||
    model = Episode
 | 
			
		||||
    template_name = 'aircox/diffusion_list.html'
 | 
			
		||||
    item_template_name = 'aircox/episode_item.html'
 | 
			
		||||
    show_headline = True
 | 
			
		||||
 | 
			
		||||
    parent_model = Program
 | 
			
		||||
    fk_parent = 'program'
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class TimetableView(BaseView, ListView):
 | 
			
		||||
    """ View for timetables """
 | 
			
		||||
 | 
			
		||||
@ -1,6 +1,7 @@
 | 
			
		||||
 | 
			
		||||
from django.core.exceptions import FieldDoesNotExist
 | 
			
		||||
from django.http import Http404
 | 
			
		||||
from django.shortcuts import get_object_or_404
 | 
			
		||||
from django.views.generic import DetailView, ListView
 | 
			
		||||
 | 
			
		||||
from ..models import Category
 | 
			
		||||
@ -8,41 +9,48 @@ from ..utils import Redirect
 | 
			
		||||
from .base import BaseView
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
__all__ = ['PageDetailView', 'PageListView']
 | 
			
		||||
__all__ = ['ParentMixin', 'PageDetailView', 'PageListView']
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class PageDetailView(BaseView, DetailView):
 | 
			
		||||
    """ Base view class for pages. """
 | 
			
		||||
    context_object_name = 'page'
 | 
			
		||||
class ParentMixin:
 | 
			
		||||
    """
 | 
			
		||||
    Optional parent page for a list view. Parent is fetched and passed to the
 | 
			
		||||
    template context when `parent_model` is provided (queryset is filtered by
 | 
			
		||||
    parent page in such case).
 | 
			
		||||
    """
 | 
			
		||||
    parent_model = None
 | 
			
		||||
    """ Parent model """
 | 
			
		||||
    parent_url_kwarg = 'parent_slug'
 | 
			
		||||
    """ Url lookup argument """
 | 
			
		||||
    parent_field = 'slug'
 | 
			
		||||
    """ Parent field for url lookup """
 | 
			
		||||
    fk_parent = 'page'
 | 
			
		||||
    """ Page foreign key to the parent """
 | 
			
		||||
    parent = None
 | 
			
		||||
    """ Parent page object """
 | 
			
		||||
 | 
			
		||||
    def get_parent(self, request, *args, **kwargs):
 | 
			
		||||
        if self.parent_model is None or self.parent_url_kwarg not in kwargs:
 | 
			
		||||
            return
 | 
			
		||||
 | 
			
		||||
        lookup = {self.parent_field: kwargs[self.parent_url_kwarg]}
 | 
			
		||||
        return get_object_or_404(
 | 
			
		||||
            self.parent_model.objects.select_related('cover'), **lookup)
 | 
			
		||||
 | 
			
		||||
    def get(self, request, *args, **kwargs):
 | 
			
		||||
        self.parent = self.get_parent(request, *args, **kwargs)
 | 
			
		||||
        return super().get(request, *args, **kwargs)
 | 
			
		||||
 | 
			
		||||
    def get_queryset(self):
 | 
			
		||||
        return super().get_queryset().select_related('cover', 'category')
 | 
			
		||||
 | 
			
		||||
    # This should not exists: it allows mapping not published pages
 | 
			
		||||
    # or it should be only used for trashed pages.
 | 
			
		||||
    def not_published_redirect(self, page):
 | 
			
		||||
        """
 | 
			
		||||
        When a page is not published, redirect to the returned url instead
 | 
			
		||||
        of an HTTP 404 code. """
 | 
			
		||||
        return None
 | 
			
		||||
 | 
			
		||||
    def get_object(self):
 | 
			
		||||
        obj = super().get_object()
 | 
			
		||||
        if not obj.is_published:
 | 
			
		||||
            redirect_url = self.not_published_redirect(obj)
 | 
			
		||||
            if redirect_url:
 | 
			
		||||
                raise Redirect(redirect_url)
 | 
			
		||||
            raise Http404('%s not found' % self.model._meta.verbose_name)
 | 
			
		||||
        return obj
 | 
			
		||||
        if self.parent is not None:
 | 
			
		||||
            lookup = {self.fk_parent: self.parent}
 | 
			
		||||
            return super().get_queryset().filter(**lookup)
 | 
			
		||||
        return super().get_queryset()
 | 
			
		||||
 | 
			
		||||
    def get_context_data(self, **kwargs):
 | 
			
		||||
        #if kwargs.get('regions') is None:
 | 
			
		||||
        #    contents = contents_for_item(
 | 
			
		||||
        #        page, page_renderer._renderers.keys())
 | 
			
		||||
        #    kwargs['regions'] = contents.render_regions(page_renderer)
 | 
			
		||||
        page = kwargs.setdefault('page', self.object)
 | 
			
		||||
        kwargs.setdefault('title', page.title)
 | 
			
		||||
        kwargs.setdefault('cover', page.cover)
 | 
			
		||||
        parent = kwargs.setdefault('parent', self.parent)
 | 
			
		||||
        if parent is not None:
 | 
			
		||||
            kwargs.setdefault('cover', parent.cover)
 | 
			
		||||
        return super().get_context_data(**kwargs)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -84,4 +92,35 @@ class PageListView(BaseView, ListView):
 | 
			
		||||
        return super().get_context_data(**kwargs)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class PageDetailView(BaseView, DetailView):
 | 
			
		||||
    """ Base view class for pages. """
 | 
			
		||||
    context_object_name = 'page'
 | 
			
		||||
 | 
			
		||||
    def get_queryset(self):
 | 
			
		||||
        return super().get_queryset().select_related('cover', 'category')
 | 
			
		||||
 | 
			
		||||
    # This should not exists: it allows mapping not published pages
 | 
			
		||||
    # or it should be only used for trashed pages.
 | 
			
		||||
    def not_published_redirect(self, page):
 | 
			
		||||
        """
 | 
			
		||||
        When a page is not published, redirect to the returned url instead
 | 
			
		||||
        of an HTTP 404 code. """
 | 
			
		||||
        return None
 | 
			
		||||
 | 
			
		||||
    def get_object(self):
 | 
			
		||||
        obj = super().get_object()
 | 
			
		||||
        if not obj.is_published:
 | 
			
		||||
            redirect_url = self.not_published_redirect(obj)
 | 
			
		||||
            if redirect_url:
 | 
			
		||||
                raise Redirect(redirect_url)
 | 
			
		||||
            raise Http404('%s not found' % self.model._meta.verbose_name)
 | 
			
		||||
        return obj
 | 
			
		||||
 | 
			
		||||
    def get_context_data(self, **kwargs):
 | 
			
		||||
        page = kwargs.setdefault('page', self.object)
 | 
			
		||||
        kwargs.setdefault('title', page.title)
 | 
			
		||||
        kwargs.setdefault('cover', page.cover)
 | 
			
		||||
        return super().get_context_data(**kwargs)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -12,53 +12,25 @@ class ProgramPageDetailView(PageDetailView):
 | 
			
		||||
    """
 | 
			
		||||
    Base view class for a page that is displayed as a program's child page.
 | 
			
		||||
    """
 | 
			
		||||
    program = None
 | 
			
		||||
    show_side_nav = True
 | 
			
		||||
    list_count = 5
 | 
			
		||||
 | 
			
		||||
    def get_episodes_queryset(self, program):
 | 
			
		||||
        return program.episode_set.published().order_by('-date')
 | 
			
		||||
 | 
			
		||||
    def get_context_data(self, program, episodes=None, **kwargs):
 | 
			
		||||
        if episodes is None:
 | 
			
		||||
            episodes = self.get_episodes_queryset(program)
 | 
			
		||||
        return super().get_context_data(
 | 
			
		||||
            program=program, episodes=episodes[:self.list_count], **kwargs)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class ProgramPageListView(PageListView):
 | 
			
		||||
    """
 | 
			
		||||
    Base list view class rendering pages as a program's child page.
 | 
			
		||||
    Retrieved program from it slug provided by `kwargs['program_slug']`.
 | 
			
		||||
 | 
			
		||||
    This view class can be used with or without providing a program.
 | 
			
		||||
    """
 | 
			
		||||
    program = None
 | 
			
		||||
 | 
			
		||||
    def get(self, request, *args, **kwargs):
 | 
			
		||||
        slug = kwargs.get('program_slug', None)
 | 
			
		||||
        if slug is not None:
 | 
			
		||||
            self.program = get_object_or_404(
 | 
			
		||||
                Program.objects.select_related('cover'), slug=slug)
 | 
			
		||||
        return super().get(request, *args, **kwargs)
 | 
			
		||||
 | 
			
		||||
    def get_queryset(self):
 | 
			
		||||
        return super().get_queryset().filter(program=self.program) \
 | 
			
		||||
               if self.program else super().get_queryset()
 | 
			
		||||
 | 
			
		||||
    def get_context_data(self, **kwargs):
 | 
			
		||||
        program = kwargs.setdefault('program', self.program)
 | 
			
		||||
        if program is not None:
 | 
			
		||||
            kwargs.setdefault('cover', program.cover)
 | 
			
		||||
            kwargs.setdefault('parent', program)
 | 
			
		||||
        return super().get_context_data(**kwargs)
 | 
			
		||||
    def get_side_queryset(self):
 | 
			
		||||
        return self.program.episode_set.published().order_by('-date')
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class ProgramDetailView(ProgramPageDetailView):
 | 
			
		||||
    model = Program
 | 
			
		||||
 | 
			
		||||
    def get_articles_queryset(self, program):
 | 
			
		||||
        return program.article_set.published().order_by('-date')
 | 
			
		||||
    def get_articles_queryset(self):
 | 
			
		||||
        return self.program.article_set.published().order_by('-date')
 | 
			
		||||
 | 
			
		||||
    def get_context_data(self, **kwargs):
 | 
			
		||||
        kwargs.setdefault('program', self.object)
 | 
			
		||||
        self.program = kwargs.setdefault('program', self.object)
 | 
			
		||||
        if 'articles' not in kwargs:
 | 
			
		||||
            kwargs['articles'] = \
 | 
			
		||||
                self.get_articles_queryset()[:self.list_count]
 | 
			
		||||
        return super().get_context_data(**kwargs)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
		Reference in New Issue
	
	Block a user