#137 Deployment: **Upgrade to Liquidsoap 2.4**: code has been adapted to work with liquidsoap 2.4 Co-authored-by: bkfox <thomas bkfox net> Reviewed-on: #138
This commit is contained in:
parent
bda4efe336
commit
a24318bc84
|
@ -2,9 +2,9 @@ from adminsortable2.admin import SortableAdminBase
|
||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
from django.forms import ModelForm
|
from django.forms import ModelForm
|
||||||
|
|
||||||
from aircox.models import Episode
|
from aircox.models import Episode, EpisodeSound
|
||||||
from .page import PageAdmin
|
from .page import PageAdmin
|
||||||
from .sound import SoundInline, TrackInline
|
from .sound import TrackInline
|
||||||
from .diffusion import DiffusionInline
|
from .diffusion import DiffusionInline
|
||||||
|
|
||||||
|
|
||||||
|
@ -25,7 +25,7 @@ class EpisodeAdmin(SortableAdminBase, PageAdmin):
|
||||||
search_fields = PageAdmin.search_fields + ("parent__title",)
|
search_fields = PageAdmin.search_fields + ("parent__title",)
|
||||||
# readonly_fields = ('parent',)
|
# readonly_fields = ('parent',)
|
||||||
|
|
||||||
inlines = [TrackInline, SoundInline, DiffusionInline]
|
inlines = [TrackInline, DiffusionInline]
|
||||||
|
|
||||||
def add_view(self, request, object_id, form_url="", context=None):
|
def add_view(self, request, object_id, form_url="", context=None):
|
||||||
context = context or {}
|
context = context or {}
|
||||||
|
@ -38,3 +38,8 @@ class EpisodeAdmin(SortableAdminBase, PageAdmin):
|
||||||
context["init_app"] = True
|
context["init_app"] = True
|
||||||
context["init_el"] = "#inline-tracks"
|
context["init_el"] = "#inline-tracks"
|
||||||
return super().change_view(request, object_id, form_url, context)
|
return super().change_view(request, object_id, form_url, context)
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(EpisodeSound)
|
||||||
|
class EpisodeSoundAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ("episode", "sound", "broadcast")
|
||||||
|
|
|
@ -25,16 +25,16 @@ class SoundTrackInline(TrackInline):
|
||||||
class SoundInline(admin.TabularInline):
|
class SoundInline(admin.TabularInline):
|
||||||
model = Sound
|
model = Sound
|
||||||
fields = [
|
fields = [
|
||||||
"type",
|
|
||||||
"name",
|
"name",
|
||||||
"audio",
|
"audio",
|
||||||
"duration",
|
"duration",
|
||||||
|
"broadcast",
|
||||||
"is_good_quality",
|
"is_good_quality",
|
||||||
"is_public",
|
"is_public",
|
||||||
"is_downloadable",
|
"is_downloadable",
|
||||||
"is_removed",
|
"is_removed",
|
||||||
]
|
]
|
||||||
readonly_fields = ["type", "audio", "duration", "is_good_quality"]
|
readonly_fields = ["broadcast", "audio", "duration", "is_good_quality"]
|
||||||
extra = 0
|
extra = 0
|
||||||
max_num = 0
|
max_num = 0
|
||||||
|
|
||||||
|
@ -53,20 +53,20 @@ class SoundAdmin(SortableAdminBase, admin.ModelAdmin):
|
||||||
list_display = [
|
list_display = [
|
||||||
"id",
|
"id",
|
||||||
"name",
|
"name",
|
||||||
"related",
|
# "related",
|
||||||
"type",
|
"broadcast",
|
||||||
"duration",
|
"duration",
|
||||||
"is_public",
|
"is_public",
|
||||||
"is_good_quality",
|
"is_good_quality",
|
||||||
"is_downloadable",
|
"is_downloadable",
|
||||||
"audio",
|
"audio",
|
||||||
]
|
]
|
||||||
list_filter = ("type", "is_good_quality", "is_public")
|
list_filter = ("broadcast", "is_good_quality", "is_public")
|
||||||
list_editable = ["name", "is_public", "is_downloadable"]
|
list_editable = ["name", "is_public", "is_downloadable"]
|
||||||
|
|
||||||
search_fields = ["name", "program__title"]
|
search_fields = ["name", "program__title"]
|
||||||
fieldsets = [
|
fieldsets = [
|
||||||
(None, {"fields": ["name", "file", "type", "program", "episode"]}),
|
(None, {"fields": ["name", "file", "broadcast", "program", "episode"]}),
|
||||||
(
|
(
|
||||||
None,
|
None,
|
||||||
{
|
{
|
||||||
|
@ -80,14 +80,16 @@ class SoundAdmin(SortableAdminBase, admin.ModelAdmin):
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
readonly_fields = ("file", "duration", "type")
|
readonly_fields = ("file", "duration", "is_removed")
|
||||||
inlines = [SoundTrackInline]
|
inlines = [SoundTrackInline]
|
||||||
|
|
||||||
def related(self, obj):
|
def related(self, obj):
|
||||||
# TODO: link to episode or program edit
|
# # TODO: link to episode or program edit
|
||||||
return obj.episode.title if obj.episode else obj.program.title if obj.program else ""
|
return obj.program.title if obj.program else ""
|
||||||
|
|
||||||
related.short_description = _("Program / Episode")
|
# return obj.episode.title if obj.episode else obj.program.title if obj.program else ""
|
||||||
|
|
||||||
|
related.short_description = _("Program")
|
||||||
|
|
||||||
def audio(self, obj):
|
def audio(self, obj):
|
||||||
return mark_safe('<audio src="{}" controls></audio>'.format(obj.file.url)) if not obj.is_removed else ""
|
return mark_safe('<audio src="{}" controls></audio>'.format(obj.file.url)) if not obj.is_removed else ""
|
||||||
|
|
|
@ -140,7 +140,7 @@ class Settings(BaseSettings):
|
||||||
"""In days, minimal age of a log before it is archived."""
|
"""In days, minimal age of a log before it is archived."""
|
||||||
|
|
||||||
# --- Sounds
|
# --- Sounds
|
||||||
SOUND_ARCHIVES_SUBDIR = "archives"
|
SOUND_BROADCASTS_SUBDIR = "archives"
|
||||||
"""Sub directory used for the complete episode sounds."""
|
"""Sub directory used for the complete episode sounds."""
|
||||||
SOUND_EXCERPTS_SUBDIR = "excerpts"
|
SOUND_EXCERPTS_SUBDIR = "excerpts"
|
||||||
"""Sub directory used for the excerpts of the episode."""
|
"""Sub directory used for the excerpts of the episode."""
|
||||||
|
|
|
@ -21,23 +21,18 @@ parameters given by the setting SOUND_QUALITY. This script requires
|
||||||
Sox (and soxi).
|
Sox (and soxi).
|
||||||
"""
|
"""
|
||||||
import logging
|
import logging
|
||||||
import os
|
|
||||||
import re
|
|
||||||
from datetime import date
|
|
||||||
|
|
||||||
import mutagen
|
|
||||||
from django.conf import settings as conf
|
from django.conf import settings as conf
|
||||||
from django.utils import timezone as tz
|
|
||||||
from django.utils.translation import gettext as _
|
|
||||||
|
|
||||||
from aircox import utils
|
from aircox.models import Program, Sound, EpisodeSound
|
||||||
from aircox.models import Program, Sound, Track
|
|
||||||
|
|
||||||
from .playlist_import import PlaylistImport
|
|
||||||
|
|
||||||
logger = logging.getLogger("aircox.commands")
|
logger = logging.getLogger("aircox.commands")
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ("SoundFile",)
|
||||||
|
|
||||||
|
|
||||||
class SoundFile:
|
class SoundFile:
|
||||||
"""Handle synchronisation between sounds on files and database."""
|
"""Handle synchronisation between sounds on files and database."""
|
||||||
|
|
||||||
|
@ -61,153 +56,40 @@ class SoundFile:
|
||||||
def sync(self, sound=None, program=None, deleted=False, keep_deleted=False, **kwargs):
|
def sync(self, sound=None, program=None, deleted=False, keep_deleted=False, **kwargs):
|
||||||
"""Update related sound model and save it."""
|
"""Update related sound model and save it."""
|
||||||
if deleted:
|
if deleted:
|
||||||
return self._on_delete(self.path, keep_deleted)
|
self.sound = self._on_delete(self.path, keep_deleted)
|
||||||
|
return self.sound
|
||||||
|
|
||||||
# FIXME: sound.program as not null
|
program = sound and sound.program or Program.get_from_path(self.path)
|
||||||
if not program:
|
if program:
|
||||||
program = Program.get_from_path(self.path)
|
kwargs["program_id"] = program.pk
|
||||||
logger.debug('program from path "%s" -> %s', self.path, program)
|
|
||||||
kwargs["program_id"] = program.pk
|
|
||||||
|
|
||||||
if sound:
|
created = False
|
||||||
created = False
|
if not sound:
|
||||||
else:
|
|
||||||
sound, created = Sound.objects.get_or_create(file=self.sound_path, defaults=kwargs)
|
sound, created = Sound.objects.get_or_create(file=self.sound_path, defaults=kwargs)
|
||||||
|
|
||||||
self.sound = sound
|
self.sound = sound
|
||||||
self.path_info = self.read_path(self.path)
|
sound.sync_fs(on_update=True, find_playlist=True)
|
||||||
|
|
||||||
sound.program = program
|
|
||||||
if created or sound.check_on_file():
|
|
||||||
sound.name = self.path_info.get("name")
|
|
||||||
self.info = self.read_file_info()
|
|
||||||
if self.info is not None:
|
|
||||||
sound.duration = utils.seconds_to_time(self.info.info.length)
|
|
||||||
|
|
||||||
# check for episode
|
|
||||||
if sound.episode is None and "year" in self.path_info:
|
|
||||||
sound.episode = self.find_episode(sound, self.path_info)
|
|
||||||
sound.save()
|
sound.save()
|
||||||
|
|
||||||
# check for playlist
|
if not sound.episodesound_set.all().exists():
|
||||||
self.find_playlist(sound)
|
self.create_episode_sound(sound)
|
||||||
return sound
|
return sound
|
||||||
|
|
||||||
|
def create_episode_sound(self, sound):
|
||||||
|
episode = sound.find_episode()
|
||||||
|
if episode:
|
||||||
|
# FIXME: position from name
|
||||||
|
item = EpisodeSound(
|
||||||
|
episode=episode, sound=sound, position=episode.episodesound_set.all().count(), broadcast=sound.broadcast
|
||||||
|
)
|
||||||
|
item.save()
|
||||||
|
|
||||||
def _on_delete(self, path, keep_deleted):
|
def _on_delete(self, path, keep_deleted):
|
||||||
# TODO: remove from db on delete
|
sound = None
|
||||||
if keep_deleted:
|
if keep_deleted:
|
||||||
sound = Sound.objects.path(self.path).first()
|
if sound := Sound.objects.path(self.path).first():
|
||||||
if sound:
|
sound.is_removed = True
|
||||||
if keep_deleted:
|
sound.save(sync=False)
|
||||||
sound.is_removed = True
|
elif sound := Sound.objects.path(self.path):
|
||||||
sound.check_on_file()
|
sound.delete()
|
||||||
sound.save()
|
return sound
|
||||||
return sound
|
|
||||||
else:
|
|
||||||
Sound.objects.path(self.path).delete()
|
|
||||||
|
|
||||||
def read_path(self, path):
|
|
||||||
"""Parse path name returning dictionary of extracted info. It can
|
|
||||||
contain:
|
|
||||||
|
|
||||||
- `year`, `month`, `day`: diffusion date
|
|
||||||
- `hour`, `minute`: diffusion time
|
|
||||||
- `n`: sound arbitrary number (used for sound ordering)
|
|
||||||
- `name`: cleaned name extracted or file name (without extension)
|
|
||||||
"""
|
|
||||||
basename = os.path.basename(path)
|
|
||||||
basename = os.path.splitext(basename)[0]
|
|
||||||
reg_match = self._path_re.search(basename)
|
|
||||||
if reg_match:
|
|
||||||
info = reg_match.groupdict()
|
|
||||||
for k in ("year", "month", "day", "hour", "minute", "n"):
|
|
||||||
if info.get(k) is not None:
|
|
||||||
info[k] = int(info[k])
|
|
||||||
|
|
||||||
name = info.get("name")
|
|
||||||
info["name"] = name and self._into_name(name) or basename
|
|
||||||
else:
|
|
||||||
info = {"name": basename}
|
|
||||||
return info
|
|
||||||
|
|
||||||
_path_re = re.compile(
|
|
||||||
"^(?P<year>[0-9]{4})(?P<month>[0-9]{2})(?P<day>[0-9]{2})"
|
|
||||||
"(_(?P<hour>[0-9]{2})h(?P<minute>[0-9]{2}))?"
|
|
||||||
"(_(?P<n>[0-9]+))?"
|
|
||||||
"_?[ -]*(?P<name>.*)$"
|
|
||||||
)
|
|
||||||
|
|
||||||
def _into_name(self, name):
|
|
||||||
name = name.replace("_", " ")
|
|
||||||
return " ".join(r.capitalize() for r in name.split(" "))
|
|
||||||
|
|
||||||
def read_file_info(self):
|
|
||||||
"""Read file information and metadata."""
|
|
||||||
try:
|
|
||||||
if os.path.exists(self.path):
|
|
||||||
return mutagen.File(self.path)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
return None
|
|
||||||
|
|
||||||
def find_episode(self, sound, path_info):
|
|
||||||
"""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.
|
|
||||||
"""
|
|
||||||
program, pi = sound.program, path_info
|
|
||||||
if "year" not in pi or not sound or sound.episode:
|
|
||||||
return None
|
|
||||||
|
|
||||||
year, month, day = pi.get("year"), pi.get("month"), pi.get("day")
|
|
||||||
if pi.get("hour") is not None:
|
|
||||||
at = tz.datetime(year, month, day, pi.get("hour", 0), pi.get("minute", 0))
|
|
||||||
at = tz.make_aware(at)
|
|
||||||
else:
|
|
||||||
at = date(year, month, day)
|
|
||||||
|
|
||||||
diffusion = program.diffusion_set.at(at).first()
|
|
||||||
if not diffusion:
|
|
||||||
return None
|
|
||||||
|
|
||||||
logger.debug("%s <--> %s", sound.file.name, str(diffusion.episode))
|
|
||||||
return diffusion.episode
|
|
||||||
|
|
||||||
def find_playlist(self, sound=None, use_meta=True):
|
|
||||||
"""Find a playlist file corresponding to the sound path, such as:
|
|
||||||
my_sound.ogg => my_sound.csv.
|
|
||||||
|
|
||||||
Use sound's file metadata if no corresponding playlist has been
|
|
||||||
found and `use_meta` is True.
|
|
||||||
"""
|
|
||||||
if sound is None:
|
|
||||||
sound = self.sound
|
|
||||||
if sound.track_set.count() > 1:
|
|
||||||
return
|
|
||||||
|
|
||||||
# import playlist
|
|
||||||
path_noext, ext = os.path.splitext(self.sound.file.path)
|
|
||||||
path = path_noext + ".csv"
|
|
||||||
if os.path.exists(path):
|
|
||||||
PlaylistImport(path, sound=sound).run()
|
|
||||||
# use metadata
|
|
||||||
elif use_meta:
|
|
||||||
if self.info is None:
|
|
||||||
self.read_file_info()
|
|
||||||
if self.info and self.info.tags:
|
|
||||||
tags = self.info.tags
|
|
||||||
title, artist, album, year = tuple(
|
|
||||||
t and ", ".join(t) for t in (tags.get(k) for k in ("title", "artist", "album", "year"))
|
|
||||||
)
|
|
||||||
title = title or (self.path_info and self.path_info.get("name")) or os.path.basename(path_noext)
|
|
||||||
info = "{} ({})".format(album, year) if album and year else album or year or ""
|
|
||||||
track = Track(
|
|
||||||
sound=sound,
|
|
||||||
position=int(tags.get("tracknumber", 0)),
|
|
||||||
title=title,
|
|
||||||
artist=artist or _("unknown"),
|
|
||||||
info=info,
|
|
||||||
)
|
|
||||||
track.save()
|
|
||||||
|
|
|
@ -105,8 +105,7 @@ class MoveTask(Task):
|
||||||
def __call__(self, event, **kw):
|
def __call__(self, event, **kw):
|
||||||
sound = Sound.objects.filter(file=event.src_path).first()
|
sound = Sound.objects.filter(file=event.src_path).first()
|
||||||
if sound:
|
if sound:
|
||||||
kw["sound"] = sound
|
kw = {**kw, "sound": sound, "path": event.src_path}
|
||||||
kw["path"] = event.src_path
|
|
||||||
else:
|
else:
|
||||||
kw["path"] = event.dest_path
|
kw["path"] = event.dest_path
|
||||||
return super().__call__(event, **kw)
|
return super().__call__(event, **kw)
|
||||||
|
@ -214,15 +213,15 @@ class SoundMonitor:
|
||||||
logger.info(f"#{program.id} {program.title}")
|
logger.info(f"#{program.id} {program.title}")
|
||||||
self.scan_for_program(
|
self.scan_for_program(
|
||||||
program,
|
program,
|
||||||
settings.SOUND_ARCHIVES_SUBDIR,
|
settings.SOUND_BROADCASTS_SUBDIR,
|
||||||
logger=logger,
|
logger=logger,
|
||||||
type=Sound.TYPE_ARCHIVE,
|
broadcast=True,
|
||||||
)
|
)
|
||||||
self.scan_for_program(
|
self.scan_for_program(
|
||||||
program,
|
program,
|
||||||
settings.SOUND_EXCERPTS_SUBDIR,
|
settings.SOUND_EXCERPTS_SUBDIR,
|
||||||
logger=logger,
|
logger=logger,
|
||||||
type=Sound.TYPE_EXCERPT,
|
broadcast=False,
|
||||||
)
|
)
|
||||||
dirs.append(program.abspath)
|
dirs.append(program.abspath)
|
||||||
return dirs
|
return dirs
|
||||||
|
@ -255,7 +254,7 @@ class SoundMonitor:
|
||||||
"""Only check for the sound existence or update."""
|
"""Only check for the sound existence or update."""
|
||||||
# check files
|
# check files
|
||||||
for sound in qs:
|
for sound in qs:
|
||||||
if sound.check_on_file():
|
if sound.sync_fs(on_update=True):
|
||||||
SoundFile(sound.file.path).sync(sound=sound, **sync_kwargs)
|
SoundFile(sound.file.path).sync(sound=sound, **sync_kwargs)
|
||||||
|
|
||||||
_running = False
|
_running = False
|
||||||
|
@ -267,15 +266,15 @@ class SoundMonitor:
|
||||||
"""Run in monitor mode."""
|
"""Run in monitor mode."""
|
||||||
with futures.ThreadPoolExecutor() as pool:
|
with futures.ThreadPoolExecutor() as pool:
|
||||||
archives_handler = MonitorHandler(
|
archives_handler = MonitorHandler(
|
||||||
settings.SOUND_ARCHIVES_SUBDIR,
|
settings.SOUND_BROADCASTS_SUBDIR,
|
||||||
pool,
|
pool,
|
||||||
type=Sound.TYPE_ARCHIVE,
|
broadcast=True,
|
||||||
logger=logger,
|
logger=logger,
|
||||||
)
|
)
|
||||||
excerpts_handler = MonitorHandler(
|
excerpts_handler = MonitorHandler(
|
||||||
settings.SOUND_EXCERPTS_SUBDIR,
|
settings.SOUND_EXCERPTS_SUBDIR,
|
||||||
pool,
|
pool,
|
||||||
type=Sound.TYPE_EXCERPT,
|
broadcast=False,
|
||||||
logger=logger,
|
logger=logger,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
@ -50,13 +50,13 @@ class ImageFilterSet(filters.FilterSet):
|
||||||
class SoundFilterSet(filters.FilterSet):
|
class SoundFilterSet(filters.FilterSet):
|
||||||
station = filters.NumberFilter(field_name="program__station__id")
|
station = filters.NumberFilter(field_name="program__station__id")
|
||||||
program = filters.NumberFilter(field_name="program_id")
|
program = filters.NumberFilter(field_name="program_id")
|
||||||
episode = filters.NumberFilter(field_name="episode_id")
|
# episode = filters.NumberFilter(field_name="episode_id")
|
||||||
search = filters.CharFilter(field_name="search", method="search_filter")
|
search = filters.CharFilter(field_name="search", method="search_filter")
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = models.Sound
|
model = models.Sound
|
||||||
fields = {
|
fields = {
|
||||||
"episode": ["in", "exact", "isnull"],
|
# "episode": ["in", "exact", "isnull"],
|
||||||
}
|
}
|
||||||
|
|
||||||
def search_filter(self, queryset, name, value):
|
def search_filter(self, queryset, name, value):
|
||||||
|
|
|
@ -5,7 +5,7 @@ from django.forms.models import modelformset_factory
|
||||||
from aircox import models
|
from aircox import models
|
||||||
|
|
||||||
|
|
||||||
__all__ = ("CommentForm", "PageForm", "ProgramForm", "EpisodeForm", "SoundForm", "SoundFormSet", "TrackFormSet")
|
__all__ = ("CommentForm", "PageForm", "ProgramForm", "EpisodeForm", "SoundForm", "EpisodeSoundFormSet", "TrackFormSet")
|
||||||
|
|
||||||
|
|
||||||
class CommentForm(forms.ModelForm):
|
class CommentForm(forms.ModelForm):
|
||||||
|
@ -44,23 +44,12 @@ class EpisodeForm(PageForm):
|
||||||
fields = PageForm.Meta.fields
|
fields = PageForm.Meta.fields
|
||||||
|
|
||||||
|
|
||||||
# def save(self, commit=True):
|
|
||||||
# file_obj = self.cleaned_data["new_podcast"]
|
|
||||||
# if file_obj:
|
|
||||||
# obj, _ = File.objects.get_or_create(original_filename=file_obj.name, file=file_obj)
|
|
||||||
# sound_file = SoundFile(obj.path)
|
|
||||||
# sound_file.sync(
|
|
||||||
# program=self.instance.program, episode=self.instance, type=0, is_public=True, is_downloadable=True
|
|
||||||
# )
|
|
||||||
# super().save(commit=commit)
|
|
||||||
|
|
||||||
|
|
||||||
class SoundForm(forms.ModelForm):
|
class SoundForm(forms.ModelForm):
|
||||||
"""SoundForm used in EpisodeUpdateView."""
|
"""SoundForm used in EpisodeUpdateView."""
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = models.Sound
|
model = models.Sound
|
||||||
fields = ["name", "program", "episode", "file", "type", "position", "duration", "is_public", "is_downloadable"]
|
fields = ["name", "program", "file", "broadcast", "duration", "is_public", "is_downloadable"]
|
||||||
|
|
||||||
|
|
||||||
class SoundCreateForm(forms.ModelForm):
|
class SoundCreateForm(forms.ModelForm):
|
||||||
|
@ -68,33 +57,40 @@ class SoundCreateForm(forms.ModelForm):
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = models.Sound
|
model = models.Sound
|
||||||
fields = ["name", "episode", "program", "file", "type", "is_public", "is_downloadable"]
|
fields = ["name", "program", "file", "broadcast", "is_public", "is_downloadable"]
|
||||||
|
widgets = {"program": forms.HiddenInput()}
|
||||||
|
|
||||||
|
|
||||||
TrackFormSet = modelformset_factory(
|
TrackFormSet = modelformset_factory(
|
||||||
models.Track,
|
models.Track,
|
||||||
fields=[
|
fields=[
|
||||||
"position",
|
"position",
|
||||||
|
"episode",
|
||||||
"artist",
|
"artist",
|
||||||
"title",
|
"title",
|
||||||
"tags",
|
"tags",
|
||||||
],
|
],
|
||||||
|
widgets={"episode": forms.HiddenInput(), "position": forms.HiddenInput()},
|
||||||
can_delete=True,
|
can_delete=True,
|
||||||
extra=0,
|
extra=0,
|
||||||
)
|
)
|
||||||
"""Track formset used in EpisodeUpdateView."""
|
"""Track formset used in EpisodeUpdateView."""
|
||||||
|
|
||||||
SoundFormSet = modelformset_factory(
|
|
||||||
models.Sound,
|
EpisodeSoundFormSet = modelformset_factory(
|
||||||
fields=[
|
models.EpisodeSound,
|
||||||
|
fields=(
|
||||||
"position",
|
"position",
|
||||||
"name",
|
"episode",
|
||||||
"type",
|
"sound",
|
||||||
"is_public",
|
"broadcast",
|
||||||
"is_downloadable",
|
),
|
||||||
"duration",
|
widgets={
|
||||||
],
|
"broadcast": forms.CheckboxInput(),
|
||||||
|
"episode": forms.HiddenInput(),
|
||||||
|
# "sound": forms.HiddenInput(),
|
||||||
|
"position": forms.HiddenInput(),
|
||||||
|
},
|
||||||
can_delete=True,
|
can_delete=True,
|
||||||
extra=0,
|
extra=0,
|
||||||
)
|
)
|
||||||
"""Sound formset used in EpisodeUpdateView."""
|
|
||||||
|
|
|
@ -0,0 +1,633 @@
|
||||||
|
# Generated by Django 4.2.9 on 2024-03-15 19:56
|
||||||
|
|
||||||
|
import aircox.models.schedule
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("aircox", "0022_set_group_ownership"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="station",
|
||||||
|
name="legal_label",
|
||||||
|
field=models.CharField(
|
||||||
|
blank=True,
|
||||||
|
default="",
|
||||||
|
help_text="Displayed at the bottom of pages.",
|
||||||
|
max_length=64,
|
||||||
|
verbose_name="Legal label",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="schedule",
|
||||||
|
name="timezone",
|
||||||
|
field=models.CharField(
|
||||||
|
choices=[
|
||||||
|
("Africa/Abidjan", "Africa/Abidjan"),
|
||||||
|
("Africa/Accra", "Africa/Accra"),
|
||||||
|
("Africa/Addis_Ababa", "Africa/Addis_Ababa"),
|
||||||
|
("Africa/Algiers", "Africa/Algiers"),
|
||||||
|
("Africa/Asmara", "Africa/Asmara"),
|
||||||
|
("Africa/Asmera", "Africa/Asmera"),
|
||||||
|
("Africa/Bamako", "Africa/Bamako"),
|
||||||
|
("Africa/Bangui", "Africa/Bangui"),
|
||||||
|
("Africa/Banjul", "Africa/Banjul"),
|
||||||
|
("Africa/Bissau", "Africa/Bissau"),
|
||||||
|
("Africa/Blantyre", "Africa/Blantyre"),
|
||||||
|
("Africa/Brazzaville", "Africa/Brazzaville"),
|
||||||
|
("Africa/Bujumbura", "Africa/Bujumbura"),
|
||||||
|
("Africa/Cairo", "Africa/Cairo"),
|
||||||
|
("Africa/Casablanca", "Africa/Casablanca"),
|
||||||
|
("Africa/Ceuta", "Africa/Ceuta"),
|
||||||
|
("Africa/Conakry", "Africa/Conakry"),
|
||||||
|
("Africa/Dakar", "Africa/Dakar"),
|
||||||
|
("Africa/Dar_es_Salaam", "Africa/Dar_es_Salaam"),
|
||||||
|
("Africa/Djibouti", "Africa/Djibouti"),
|
||||||
|
("Africa/Douala", "Africa/Douala"),
|
||||||
|
("Africa/El_Aaiun", "Africa/El_Aaiun"),
|
||||||
|
("Africa/Freetown", "Africa/Freetown"),
|
||||||
|
("Africa/Gaborone", "Africa/Gaborone"),
|
||||||
|
("Africa/Harare", "Africa/Harare"),
|
||||||
|
("Africa/Johannesburg", "Africa/Johannesburg"),
|
||||||
|
("Africa/Juba", "Africa/Juba"),
|
||||||
|
("Africa/Kampala", "Africa/Kampala"),
|
||||||
|
("Africa/Khartoum", "Africa/Khartoum"),
|
||||||
|
("Africa/Kigali", "Africa/Kigali"),
|
||||||
|
("Africa/Kinshasa", "Africa/Kinshasa"),
|
||||||
|
("Africa/Lagos", "Africa/Lagos"),
|
||||||
|
("Africa/Libreville", "Africa/Libreville"),
|
||||||
|
("Africa/Lome", "Africa/Lome"),
|
||||||
|
("Africa/Luanda", "Africa/Luanda"),
|
||||||
|
("Africa/Lubumbashi", "Africa/Lubumbashi"),
|
||||||
|
("Africa/Lusaka", "Africa/Lusaka"),
|
||||||
|
("Africa/Malabo", "Africa/Malabo"),
|
||||||
|
("Africa/Maputo", "Africa/Maputo"),
|
||||||
|
("Africa/Maseru", "Africa/Maseru"),
|
||||||
|
("Africa/Mbabane", "Africa/Mbabane"),
|
||||||
|
("Africa/Mogadishu", "Africa/Mogadishu"),
|
||||||
|
("Africa/Monrovia", "Africa/Monrovia"),
|
||||||
|
("Africa/Nairobi", "Africa/Nairobi"),
|
||||||
|
("Africa/Ndjamena", "Africa/Ndjamena"),
|
||||||
|
("Africa/Niamey", "Africa/Niamey"),
|
||||||
|
("Africa/Nouakchott", "Africa/Nouakchott"),
|
||||||
|
("Africa/Ouagadougou", "Africa/Ouagadougou"),
|
||||||
|
("Africa/Porto-Novo", "Africa/Porto-Novo"),
|
||||||
|
("Africa/Sao_Tome", "Africa/Sao_Tome"),
|
||||||
|
("Africa/Timbuktu", "Africa/Timbuktu"),
|
||||||
|
("Africa/Tripoli", "Africa/Tripoli"),
|
||||||
|
("Africa/Tunis", "Africa/Tunis"),
|
||||||
|
("Africa/Windhoek", "Africa/Windhoek"),
|
||||||
|
("America/Adak", "America/Adak"),
|
||||||
|
("America/Anchorage", "America/Anchorage"),
|
||||||
|
("America/Anguilla", "America/Anguilla"),
|
||||||
|
("America/Antigua", "America/Antigua"),
|
||||||
|
("America/Araguaina", "America/Araguaina"),
|
||||||
|
("America/Argentina/Buenos_Aires", "America/Argentina/Buenos_Aires"),
|
||||||
|
("America/Argentina/Catamarca", "America/Argentina/Catamarca"),
|
||||||
|
("America/Argentina/ComodRivadavia", "America/Argentina/ComodRivadavia"),
|
||||||
|
("America/Argentina/Cordoba", "America/Argentina/Cordoba"),
|
||||||
|
("America/Argentina/Jujuy", "America/Argentina/Jujuy"),
|
||||||
|
("America/Argentina/La_Rioja", "America/Argentina/La_Rioja"),
|
||||||
|
("America/Argentina/Mendoza", "America/Argentina/Mendoza"),
|
||||||
|
("America/Argentina/Rio_Gallegos", "America/Argentina/Rio_Gallegos"),
|
||||||
|
("America/Argentina/Salta", "America/Argentina/Salta"),
|
||||||
|
("America/Argentina/San_Juan", "America/Argentina/San_Juan"),
|
||||||
|
("America/Argentina/San_Luis", "America/Argentina/San_Luis"),
|
||||||
|
("America/Argentina/Tucuman", "America/Argentina/Tucuman"),
|
||||||
|
("America/Argentina/Ushuaia", "America/Argentina/Ushuaia"),
|
||||||
|
("America/Aruba", "America/Aruba"),
|
||||||
|
("America/Asuncion", "America/Asuncion"),
|
||||||
|
("America/Atikokan", "America/Atikokan"),
|
||||||
|
("America/Atka", "America/Atka"),
|
||||||
|
("America/Bahia", "America/Bahia"),
|
||||||
|
("America/Bahia_Banderas", "America/Bahia_Banderas"),
|
||||||
|
("America/Barbados", "America/Barbados"),
|
||||||
|
("America/Belem", "America/Belem"),
|
||||||
|
("America/Belize", "America/Belize"),
|
||||||
|
("America/Blanc-Sablon", "America/Blanc-Sablon"),
|
||||||
|
("America/Boa_Vista", "America/Boa_Vista"),
|
||||||
|
("America/Bogota", "America/Bogota"),
|
||||||
|
("America/Boise", "America/Boise"),
|
||||||
|
("America/Buenos_Aires", "America/Buenos_Aires"),
|
||||||
|
("America/Cambridge_Bay", "America/Cambridge_Bay"),
|
||||||
|
("America/Campo_Grande", "America/Campo_Grande"),
|
||||||
|
("America/Cancun", "America/Cancun"),
|
||||||
|
("America/Caracas", "America/Caracas"),
|
||||||
|
("America/Catamarca", "America/Catamarca"),
|
||||||
|
("America/Cayenne", "America/Cayenne"),
|
||||||
|
("America/Cayman", "America/Cayman"),
|
||||||
|
("America/Chicago", "America/Chicago"),
|
||||||
|
("America/Chihuahua", "America/Chihuahua"),
|
||||||
|
("America/Ciudad_Juarez", "America/Ciudad_Juarez"),
|
||||||
|
("America/Coral_Harbour", "America/Coral_Harbour"),
|
||||||
|
("America/Cordoba", "America/Cordoba"),
|
||||||
|
("America/Costa_Rica", "America/Costa_Rica"),
|
||||||
|
("America/Creston", "America/Creston"),
|
||||||
|
("America/Cuiaba", "America/Cuiaba"),
|
||||||
|
("America/Curacao", "America/Curacao"),
|
||||||
|
("America/Danmarkshavn", "America/Danmarkshavn"),
|
||||||
|
("America/Dawson", "America/Dawson"),
|
||||||
|
("America/Dawson_Creek", "America/Dawson_Creek"),
|
||||||
|
("America/Denver", "America/Denver"),
|
||||||
|
("America/Detroit", "America/Detroit"),
|
||||||
|
("America/Dominica", "America/Dominica"),
|
||||||
|
("America/Edmonton", "America/Edmonton"),
|
||||||
|
("America/Eirunepe", "America/Eirunepe"),
|
||||||
|
("America/El_Salvador", "America/El_Salvador"),
|
||||||
|
("America/Ensenada", "America/Ensenada"),
|
||||||
|
("America/Fort_Nelson", "America/Fort_Nelson"),
|
||||||
|
("America/Fort_Wayne", "America/Fort_Wayne"),
|
||||||
|
("America/Fortaleza", "America/Fortaleza"),
|
||||||
|
("America/Glace_Bay", "America/Glace_Bay"),
|
||||||
|
("America/Godthab", "America/Godthab"),
|
||||||
|
("America/Goose_Bay", "America/Goose_Bay"),
|
||||||
|
("America/Grand_Turk", "America/Grand_Turk"),
|
||||||
|
("America/Grenada", "America/Grenada"),
|
||||||
|
("America/Guadeloupe", "America/Guadeloupe"),
|
||||||
|
("America/Guatemala", "America/Guatemala"),
|
||||||
|
("America/Guayaquil", "America/Guayaquil"),
|
||||||
|
("America/Guyana", "America/Guyana"),
|
||||||
|
("America/Halifax", "America/Halifax"),
|
||||||
|
("America/Havana", "America/Havana"),
|
||||||
|
("America/Hermosillo", "America/Hermosillo"),
|
||||||
|
("America/Indiana/Indianapolis", "America/Indiana/Indianapolis"),
|
||||||
|
("America/Indiana/Knox", "America/Indiana/Knox"),
|
||||||
|
("America/Indiana/Marengo", "America/Indiana/Marengo"),
|
||||||
|
("America/Indiana/Petersburg", "America/Indiana/Petersburg"),
|
||||||
|
("America/Indiana/Tell_City", "America/Indiana/Tell_City"),
|
||||||
|
("America/Indiana/Vevay", "America/Indiana/Vevay"),
|
||||||
|
("America/Indiana/Vincennes", "America/Indiana/Vincennes"),
|
||||||
|
("America/Indiana/Winamac", "America/Indiana/Winamac"),
|
||||||
|
("America/Indianapolis", "America/Indianapolis"),
|
||||||
|
("America/Inuvik", "America/Inuvik"),
|
||||||
|
("America/Iqaluit", "America/Iqaluit"),
|
||||||
|
("America/Jamaica", "America/Jamaica"),
|
||||||
|
("America/Jujuy", "America/Jujuy"),
|
||||||
|
("America/Juneau", "America/Juneau"),
|
||||||
|
("America/Kentucky/Louisville", "America/Kentucky/Louisville"),
|
||||||
|
("America/Kentucky/Monticello", "America/Kentucky/Monticello"),
|
||||||
|
("America/Knox_IN", "America/Knox_IN"),
|
||||||
|
("America/Kralendijk", "America/Kralendijk"),
|
||||||
|
("America/La_Paz", "America/La_Paz"),
|
||||||
|
("America/Lima", "America/Lima"),
|
||||||
|
("America/Los_Angeles", "America/Los_Angeles"),
|
||||||
|
("America/Louisville", "America/Louisville"),
|
||||||
|
("America/Lower_Princes", "America/Lower_Princes"),
|
||||||
|
("America/Maceio", "America/Maceio"),
|
||||||
|
("America/Managua", "America/Managua"),
|
||||||
|
("America/Manaus", "America/Manaus"),
|
||||||
|
("America/Marigot", "America/Marigot"),
|
||||||
|
("America/Martinique", "America/Martinique"),
|
||||||
|
("America/Matamoros", "America/Matamoros"),
|
||||||
|
("America/Mazatlan", "America/Mazatlan"),
|
||||||
|
("America/Mendoza", "America/Mendoza"),
|
||||||
|
("America/Menominee", "America/Menominee"),
|
||||||
|
("America/Merida", "America/Merida"),
|
||||||
|
("America/Metlakatla", "America/Metlakatla"),
|
||||||
|
("America/Mexico_City", "America/Mexico_City"),
|
||||||
|
("America/Miquelon", "America/Miquelon"),
|
||||||
|
("America/Moncton", "America/Moncton"),
|
||||||
|
("America/Monterrey", "America/Monterrey"),
|
||||||
|
("America/Montevideo", "America/Montevideo"),
|
||||||
|
("America/Montreal", "America/Montreal"),
|
||||||
|
("America/Montserrat", "America/Montserrat"),
|
||||||
|
("America/Nassau", "America/Nassau"),
|
||||||
|
("America/New_York", "America/New_York"),
|
||||||
|
("America/Nipigon", "America/Nipigon"),
|
||||||
|
("America/Nome", "America/Nome"),
|
||||||
|
("America/Noronha", "America/Noronha"),
|
||||||
|
("America/North_Dakota/Beulah", "America/North_Dakota/Beulah"),
|
||||||
|
("America/North_Dakota/Center", "America/North_Dakota/Center"),
|
||||||
|
("America/North_Dakota/New_Salem", "America/North_Dakota/New_Salem"),
|
||||||
|
("America/Nuuk", "America/Nuuk"),
|
||||||
|
("America/Ojinaga", "America/Ojinaga"),
|
||||||
|
("America/Panama", "America/Panama"),
|
||||||
|
("America/Pangnirtung", "America/Pangnirtung"),
|
||||||
|
("America/Paramaribo", "America/Paramaribo"),
|
||||||
|
("America/Phoenix", "America/Phoenix"),
|
||||||
|
("America/Port-au-Prince", "America/Port-au-Prince"),
|
||||||
|
("America/Port_of_Spain", "America/Port_of_Spain"),
|
||||||
|
("America/Porto_Acre", "America/Porto_Acre"),
|
||||||
|
("America/Porto_Velho", "America/Porto_Velho"),
|
||||||
|
("America/Puerto_Rico", "America/Puerto_Rico"),
|
||||||
|
("America/Punta_Arenas", "America/Punta_Arenas"),
|
||||||
|
("America/Rainy_River", "America/Rainy_River"),
|
||||||
|
("America/Rankin_Inlet", "America/Rankin_Inlet"),
|
||||||
|
("America/Recife", "America/Recife"),
|
||||||
|
("America/Regina", "America/Regina"),
|
||||||
|
("America/Resolute", "America/Resolute"),
|
||||||
|
("America/Rio_Branco", "America/Rio_Branco"),
|
||||||
|
("America/Rosario", "America/Rosario"),
|
||||||
|
("America/Santa_Isabel", "America/Santa_Isabel"),
|
||||||
|
("America/Santarem", "America/Santarem"),
|
||||||
|
("America/Santiago", "America/Santiago"),
|
||||||
|
("America/Santo_Domingo", "America/Santo_Domingo"),
|
||||||
|
("America/Sao_Paulo", "America/Sao_Paulo"),
|
||||||
|
("America/Scoresbysund", "America/Scoresbysund"),
|
||||||
|
("America/Shiprock", "America/Shiprock"),
|
||||||
|
("America/Sitka", "America/Sitka"),
|
||||||
|
("America/St_Barthelemy", "America/St_Barthelemy"),
|
||||||
|
("America/St_Johns", "America/St_Johns"),
|
||||||
|
("America/St_Kitts", "America/St_Kitts"),
|
||||||
|
("America/St_Lucia", "America/St_Lucia"),
|
||||||
|
("America/St_Thomas", "America/St_Thomas"),
|
||||||
|
("America/St_Vincent", "America/St_Vincent"),
|
||||||
|
("America/Swift_Current", "America/Swift_Current"),
|
||||||
|
("America/Tegucigalpa", "America/Tegucigalpa"),
|
||||||
|
("America/Thule", "America/Thule"),
|
||||||
|
("America/Thunder_Bay", "America/Thunder_Bay"),
|
||||||
|
("America/Tijuana", "America/Tijuana"),
|
||||||
|
("America/Toronto", "America/Toronto"),
|
||||||
|
("America/Tortola", "America/Tortola"),
|
||||||
|
("America/Vancouver", "America/Vancouver"),
|
||||||
|
("America/Virgin", "America/Virgin"),
|
||||||
|
("America/Whitehorse", "America/Whitehorse"),
|
||||||
|
("America/Winnipeg", "America/Winnipeg"),
|
||||||
|
("America/Yakutat", "America/Yakutat"),
|
||||||
|
("America/Yellowknife", "America/Yellowknife"),
|
||||||
|
("Antarctica/Casey", "Antarctica/Casey"),
|
||||||
|
("Antarctica/Davis", "Antarctica/Davis"),
|
||||||
|
("Antarctica/DumontDUrville", "Antarctica/DumontDUrville"),
|
||||||
|
("Antarctica/Macquarie", "Antarctica/Macquarie"),
|
||||||
|
("Antarctica/Mawson", "Antarctica/Mawson"),
|
||||||
|
("Antarctica/McMurdo", "Antarctica/McMurdo"),
|
||||||
|
("Antarctica/Palmer", "Antarctica/Palmer"),
|
||||||
|
("Antarctica/Rothera", "Antarctica/Rothera"),
|
||||||
|
("Antarctica/South_Pole", "Antarctica/South_Pole"),
|
||||||
|
("Antarctica/Syowa", "Antarctica/Syowa"),
|
||||||
|
("Antarctica/Troll", "Antarctica/Troll"),
|
||||||
|
("Antarctica/Vostok", "Antarctica/Vostok"),
|
||||||
|
("Arctic/Longyearbyen", "Arctic/Longyearbyen"),
|
||||||
|
("Asia/Aden", "Asia/Aden"),
|
||||||
|
("Asia/Almaty", "Asia/Almaty"),
|
||||||
|
("Asia/Amman", "Asia/Amman"),
|
||||||
|
("Asia/Anadyr", "Asia/Anadyr"),
|
||||||
|
("Asia/Aqtau", "Asia/Aqtau"),
|
||||||
|
("Asia/Aqtobe", "Asia/Aqtobe"),
|
||||||
|
("Asia/Ashgabat", "Asia/Ashgabat"),
|
||||||
|
("Asia/Ashkhabad", "Asia/Ashkhabad"),
|
||||||
|
("Asia/Atyrau", "Asia/Atyrau"),
|
||||||
|
("Asia/Baghdad", "Asia/Baghdad"),
|
||||||
|
("Asia/Bahrain", "Asia/Bahrain"),
|
||||||
|
("Asia/Baku", "Asia/Baku"),
|
||||||
|
("Asia/Bangkok", "Asia/Bangkok"),
|
||||||
|
("Asia/Barnaul", "Asia/Barnaul"),
|
||||||
|
("Asia/Beirut", "Asia/Beirut"),
|
||||||
|
("Asia/Bishkek", "Asia/Bishkek"),
|
||||||
|
("Asia/Brunei", "Asia/Brunei"),
|
||||||
|
("Asia/Calcutta", "Asia/Calcutta"),
|
||||||
|
("Asia/Chita", "Asia/Chita"),
|
||||||
|
("Asia/Choibalsan", "Asia/Choibalsan"),
|
||||||
|
("Asia/Chongqing", "Asia/Chongqing"),
|
||||||
|
("Asia/Chungking", "Asia/Chungking"),
|
||||||
|
("Asia/Colombo", "Asia/Colombo"),
|
||||||
|
("Asia/Dacca", "Asia/Dacca"),
|
||||||
|
("Asia/Damascus", "Asia/Damascus"),
|
||||||
|
("Asia/Dhaka", "Asia/Dhaka"),
|
||||||
|
("Asia/Dili", "Asia/Dili"),
|
||||||
|
("Asia/Dubai", "Asia/Dubai"),
|
||||||
|
("Asia/Dushanbe", "Asia/Dushanbe"),
|
||||||
|
("Asia/Famagusta", "Asia/Famagusta"),
|
||||||
|
("Asia/Gaza", "Asia/Gaza"),
|
||||||
|
("Asia/Harbin", "Asia/Harbin"),
|
||||||
|
("Asia/Hebron", "Asia/Hebron"),
|
||||||
|
("Asia/Ho_Chi_Minh", "Asia/Ho_Chi_Minh"),
|
||||||
|
("Asia/Hong_Kong", "Asia/Hong_Kong"),
|
||||||
|
("Asia/Hovd", "Asia/Hovd"),
|
||||||
|
("Asia/Irkutsk", "Asia/Irkutsk"),
|
||||||
|
("Asia/Istanbul", "Asia/Istanbul"),
|
||||||
|
("Asia/Jakarta", "Asia/Jakarta"),
|
||||||
|
("Asia/Jayapura", "Asia/Jayapura"),
|
||||||
|
("Asia/Jerusalem", "Asia/Jerusalem"),
|
||||||
|
("Asia/Kabul", "Asia/Kabul"),
|
||||||
|
("Asia/Kamchatka", "Asia/Kamchatka"),
|
||||||
|
("Asia/Karachi", "Asia/Karachi"),
|
||||||
|
("Asia/Kashgar", "Asia/Kashgar"),
|
||||||
|
("Asia/Kathmandu", "Asia/Kathmandu"),
|
||||||
|
("Asia/Katmandu", "Asia/Katmandu"),
|
||||||
|
("Asia/Khandyga", "Asia/Khandyga"),
|
||||||
|
("Asia/Kolkata", "Asia/Kolkata"),
|
||||||
|
("Asia/Krasnoyarsk", "Asia/Krasnoyarsk"),
|
||||||
|
("Asia/Kuala_Lumpur", "Asia/Kuala_Lumpur"),
|
||||||
|
("Asia/Kuching", "Asia/Kuching"),
|
||||||
|
("Asia/Kuwait", "Asia/Kuwait"),
|
||||||
|
("Asia/Macao", "Asia/Macao"),
|
||||||
|
("Asia/Macau", "Asia/Macau"),
|
||||||
|
("Asia/Magadan", "Asia/Magadan"),
|
||||||
|
("Asia/Makassar", "Asia/Makassar"),
|
||||||
|
("Asia/Manila", "Asia/Manila"),
|
||||||
|
("Asia/Muscat", "Asia/Muscat"),
|
||||||
|
("Asia/Nicosia", "Asia/Nicosia"),
|
||||||
|
("Asia/Novokuznetsk", "Asia/Novokuznetsk"),
|
||||||
|
("Asia/Novosibirsk", "Asia/Novosibirsk"),
|
||||||
|
("Asia/Omsk", "Asia/Omsk"),
|
||||||
|
("Asia/Oral", "Asia/Oral"),
|
||||||
|
("Asia/Phnom_Penh", "Asia/Phnom_Penh"),
|
||||||
|
("Asia/Pontianak", "Asia/Pontianak"),
|
||||||
|
("Asia/Pyongyang", "Asia/Pyongyang"),
|
||||||
|
("Asia/Qatar", "Asia/Qatar"),
|
||||||
|
("Asia/Qostanay", "Asia/Qostanay"),
|
||||||
|
("Asia/Qyzylorda", "Asia/Qyzylorda"),
|
||||||
|
("Asia/Rangoon", "Asia/Rangoon"),
|
||||||
|
("Asia/Riyadh", "Asia/Riyadh"),
|
||||||
|
("Asia/Saigon", "Asia/Saigon"),
|
||||||
|
("Asia/Sakhalin", "Asia/Sakhalin"),
|
||||||
|
("Asia/Samarkand", "Asia/Samarkand"),
|
||||||
|
("Asia/Seoul", "Asia/Seoul"),
|
||||||
|
("Asia/Shanghai", "Asia/Shanghai"),
|
||||||
|
("Asia/Singapore", "Asia/Singapore"),
|
||||||
|
("Asia/Srednekolymsk", "Asia/Srednekolymsk"),
|
||||||
|
("Asia/Taipei", "Asia/Taipei"),
|
||||||
|
("Asia/Tashkent", "Asia/Tashkent"),
|
||||||
|
("Asia/Tbilisi", "Asia/Tbilisi"),
|
||||||
|
("Asia/Tehran", "Asia/Tehran"),
|
||||||
|
("Asia/Tel_Aviv", "Asia/Tel_Aviv"),
|
||||||
|
("Asia/Thimbu", "Asia/Thimbu"),
|
||||||
|
("Asia/Thimphu", "Asia/Thimphu"),
|
||||||
|
("Asia/Tokyo", "Asia/Tokyo"),
|
||||||
|
("Asia/Tomsk", "Asia/Tomsk"),
|
||||||
|
("Asia/Ujung_Pandang", "Asia/Ujung_Pandang"),
|
||||||
|
("Asia/Ulaanbaatar", "Asia/Ulaanbaatar"),
|
||||||
|
("Asia/Ulan_Bator", "Asia/Ulan_Bator"),
|
||||||
|
("Asia/Urumqi", "Asia/Urumqi"),
|
||||||
|
("Asia/Ust-Nera", "Asia/Ust-Nera"),
|
||||||
|
("Asia/Vientiane", "Asia/Vientiane"),
|
||||||
|
("Asia/Vladivostok", "Asia/Vladivostok"),
|
||||||
|
("Asia/Yakutsk", "Asia/Yakutsk"),
|
||||||
|
("Asia/Yangon", "Asia/Yangon"),
|
||||||
|
("Asia/Yekaterinburg", "Asia/Yekaterinburg"),
|
||||||
|
("Asia/Yerevan", "Asia/Yerevan"),
|
||||||
|
("Atlantic/Azores", "Atlantic/Azores"),
|
||||||
|
("Atlantic/Bermuda", "Atlantic/Bermuda"),
|
||||||
|
("Atlantic/Canary", "Atlantic/Canary"),
|
||||||
|
("Atlantic/Cape_Verde", "Atlantic/Cape_Verde"),
|
||||||
|
("Atlantic/Faeroe", "Atlantic/Faeroe"),
|
||||||
|
("Atlantic/Faroe", "Atlantic/Faroe"),
|
||||||
|
("Atlantic/Jan_Mayen", "Atlantic/Jan_Mayen"),
|
||||||
|
("Atlantic/Madeira", "Atlantic/Madeira"),
|
||||||
|
("Atlantic/Reykjavik", "Atlantic/Reykjavik"),
|
||||||
|
("Atlantic/South_Georgia", "Atlantic/South_Georgia"),
|
||||||
|
("Atlantic/St_Helena", "Atlantic/St_Helena"),
|
||||||
|
("Atlantic/Stanley", "Atlantic/Stanley"),
|
||||||
|
("Australia/ACT", "Australia/ACT"),
|
||||||
|
("Australia/Adelaide", "Australia/Adelaide"),
|
||||||
|
("Australia/Brisbane", "Australia/Brisbane"),
|
||||||
|
("Australia/Broken_Hill", "Australia/Broken_Hill"),
|
||||||
|
("Australia/Canberra", "Australia/Canberra"),
|
||||||
|
("Australia/Currie", "Australia/Currie"),
|
||||||
|
("Australia/Darwin", "Australia/Darwin"),
|
||||||
|
("Australia/Eucla", "Australia/Eucla"),
|
||||||
|
("Australia/Hobart", "Australia/Hobart"),
|
||||||
|
("Australia/LHI", "Australia/LHI"),
|
||||||
|
("Australia/Lindeman", "Australia/Lindeman"),
|
||||||
|
("Australia/Lord_Howe", "Australia/Lord_Howe"),
|
||||||
|
("Australia/Melbourne", "Australia/Melbourne"),
|
||||||
|
("Australia/NSW", "Australia/NSW"),
|
||||||
|
("Australia/North", "Australia/North"),
|
||||||
|
("Australia/Perth", "Australia/Perth"),
|
||||||
|
("Australia/Queensland", "Australia/Queensland"),
|
||||||
|
("Australia/South", "Australia/South"),
|
||||||
|
("Australia/Sydney", "Australia/Sydney"),
|
||||||
|
("Australia/Tasmania", "Australia/Tasmania"),
|
||||||
|
("Australia/Victoria", "Australia/Victoria"),
|
||||||
|
("Australia/West", "Australia/West"),
|
||||||
|
("Australia/Yancowinna", "Australia/Yancowinna"),
|
||||||
|
("Brazil/Acre", "Brazil/Acre"),
|
||||||
|
("Brazil/DeNoronha", "Brazil/DeNoronha"),
|
||||||
|
("Brazil/East", "Brazil/East"),
|
||||||
|
("Brazil/West", "Brazil/West"),
|
||||||
|
("CET", "CET"),
|
||||||
|
("CST6CDT", "CST6CDT"),
|
||||||
|
("Canada/Atlantic", "Canada/Atlantic"),
|
||||||
|
("Canada/Central", "Canada/Central"),
|
||||||
|
("Canada/Eastern", "Canada/Eastern"),
|
||||||
|
("Canada/Mountain", "Canada/Mountain"),
|
||||||
|
("Canada/Newfoundland", "Canada/Newfoundland"),
|
||||||
|
("Canada/Pacific", "Canada/Pacific"),
|
||||||
|
("Canada/Saskatchewan", "Canada/Saskatchewan"),
|
||||||
|
("Canada/Yukon", "Canada/Yukon"),
|
||||||
|
("Chile/Continental", "Chile/Continental"),
|
||||||
|
("Chile/EasterIsland", "Chile/EasterIsland"),
|
||||||
|
("Cuba", "Cuba"),
|
||||||
|
("EET", "EET"),
|
||||||
|
("EST", "EST"),
|
||||||
|
("EST5EDT", "EST5EDT"),
|
||||||
|
("Egypt", "Egypt"),
|
||||||
|
("Eire", "Eire"),
|
||||||
|
("Etc/GMT", "Etc/GMT"),
|
||||||
|
("Etc/GMT+0", "Etc/GMT+0"),
|
||||||
|
("Etc/GMT+1", "Etc/GMT+1"),
|
||||||
|
("Etc/GMT+10", "Etc/GMT+10"),
|
||||||
|
("Etc/GMT+11", "Etc/GMT+11"),
|
||||||
|
("Etc/GMT+12", "Etc/GMT+12"),
|
||||||
|
("Etc/GMT+2", "Etc/GMT+2"),
|
||||||
|
("Etc/GMT+3", "Etc/GMT+3"),
|
||||||
|
("Etc/GMT+4", "Etc/GMT+4"),
|
||||||
|
("Etc/GMT+5", "Etc/GMT+5"),
|
||||||
|
("Etc/GMT+6", "Etc/GMT+6"),
|
||||||
|
("Etc/GMT+7", "Etc/GMT+7"),
|
||||||
|
("Etc/GMT+8", "Etc/GMT+8"),
|
||||||
|
("Etc/GMT+9", "Etc/GMT+9"),
|
||||||
|
("Etc/GMT-0", "Etc/GMT-0"),
|
||||||
|
("Etc/GMT-1", "Etc/GMT-1"),
|
||||||
|
("Etc/GMT-10", "Etc/GMT-10"),
|
||||||
|
("Etc/GMT-11", "Etc/GMT-11"),
|
||||||
|
("Etc/GMT-12", "Etc/GMT-12"),
|
||||||
|
("Etc/GMT-13", "Etc/GMT-13"),
|
||||||
|
("Etc/GMT-14", "Etc/GMT-14"),
|
||||||
|
("Etc/GMT-2", "Etc/GMT-2"),
|
||||||
|
("Etc/GMT-3", "Etc/GMT-3"),
|
||||||
|
("Etc/GMT-4", "Etc/GMT-4"),
|
||||||
|
("Etc/GMT-5", "Etc/GMT-5"),
|
||||||
|
("Etc/GMT-6", "Etc/GMT-6"),
|
||||||
|
("Etc/GMT-7", "Etc/GMT-7"),
|
||||||
|
("Etc/GMT-8", "Etc/GMT-8"),
|
||||||
|
("Etc/GMT-9", "Etc/GMT-9"),
|
||||||
|
("Etc/GMT0", "Etc/GMT0"),
|
||||||
|
("Etc/Greenwich", "Etc/Greenwich"),
|
||||||
|
("Etc/UCT", "Etc/UCT"),
|
||||||
|
("Etc/UTC", "Etc/UTC"),
|
||||||
|
("Etc/Universal", "Etc/Universal"),
|
||||||
|
("Etc/Zulu", "Etc/Zulu"),
|
||||||
|
("Europe/Amsterdam", "Europe/Amsterdam"),
|
||||||
|
("Europe/Andorra", "Europe/Andorra"),
|
||||||
|
("Europe/Astrakhan", "Europe/Astrakhan"),
|
||||||
|
("Europe/Athens", "Europe/Athens"),
|
||||||
|
("Europe/Belfast", "Europe/Belfast"),
|
||||||
|
("Europe/Belgrade", "Europe/Belgrade"),
|
||||||
|
("Europe/Berlin", "Europe/Berlin"),
|
||||||
|
("Europe/Bratislava", "Europe/Bratislava"),
|
||||||
|
("Europe/Brussels", "Europe/Brussels"),
|
||||||
|
("Europe/Bucharest", "Europe/Bucharest"),
|
||||||
|
("Europe/Budapest", "Europe/Budapest"),
|
||||||
|
("Europe/Busingen", "Europe/Busingen"),
|
||||||
|
("Europe/Chisinau", "Europe/Chisinau"),
|
||||||
|
("Europe/Copenhagen", "Europe/Copenhagen"),
|
||||||
|
("Europe/Dublin", "Europe/Dublin"),
|
||||||
|
("Europe/Gibraltar", "Europe/Gibraltar"),
|
||||||
|
("Europe/Guernsey", "Europe/Guernsey"),
|
||||||
|
("Europe/Helsinki", "Europe/Helsinki"),
|
||||||
|
("Europe/Isle_of_Man", "Europe/Isle_of_Man"),
|
||||||
|
("Europe/Istanbul", "Europe/Istanbul"),
|
||||||
|
("Europe/Jersey", "Europe/Jersey"),
|
||||||
|
("Europe/Kaliningrad", "Europe/Kaliningrad"),
|
||||||
|
("Europe/Kiev", "Europe/Kiev"),
|
||||||
|
("Europe/Kirov", "Europe/Kirov"),
|
||||||
|
("Europe/Kyiv", "Europe/Kyiv"),
|
||||||
|
("Europe/Lisbon", "Europe/Lisbon"),
|
||||||
|
("Europe/Ljubljana", "Europe/Ljubljana"),
|
||||||
|
("Europe/London", "Europe/London"),
|
||||||
|
("Europe/Luxembourg", "Europe/Luxembourg"),
|
||||||
|
("Europe/Madrid", "Europe/Madrid"),
|
||||||
|
("Europe/Malta", "Europe/Malta"),
|
||||||
|
("Europe/Mariehamn", "Europe/Mariehamn"),
|
||||||
|
("Europe/Minsk", "Europe/Minsk"),
|
||||||
|
("Europe/Monaco", "Europe/Monaco"),
|
||||||
|
("Europe/Moscow", "Europe/Moscow"),
|
||||||
|
("Europe/Nicosia", "Europe/Nicosia"),
|
||||||
|
("Europe/Oslo", "Europe/Oslo"),
|
||||||
|
("Europe/Paris", "Europe/Paris"),
|
||||||
|
("Europe/Podgorica", "Europe/Podgorica"),
|
||||||
|
("Europe/Prague", "Europe/Prague"),
|
||||||
|
("Europe/Riga", "Europe/Riga"),
|
||||||
|
("Europe/Rome", "Europe/Rome"),
|
||||||
|
("Europe/Samara", "Europe/Samara"),
|
||||||
|
("Europe/San_Marino", "Europe/San_Marino"),
|
||||||
|
("Europe/Sarajevo", "Europe/Sarajevo"),
|
||||||
|
("Europe/Saratov", "Europe/Saratov"),
|
||||||
|
("Europe/Simferopol", "Europe/Simferopol"),
|
||||||
|
("Europe/Skopje", "Europe/Skopje"),
|
||||||
|
("Europe/Sofia", "Europe/Sofia"),
|
||||||
|
("Europe/Stockholm", "Europe/Stockholm"),
|
||||||
|
("Europe/Tallinn", "Europe/Tallinn"),
|
||||||
|
("Europe/Tirane", "Europe/Tirane"),
|
||||||
|
("Europe/Tiraspol", "Europe/Tiraspol"),
|
||||||
|
("Europe/Ulyanovsk", "Europe/Ulyanovsk"),
|
||||||
|
("Europe/Uzhgorod", "Europe/Uzhgorod"),
|
||||||
|
("Europe/Vaduz", "Europe/Vaduz"),
|
||||||
|
("Europe/Vatican", "Europe/Vatican"),
|
||||||
|
("Europe/Vienna", "Europe/Vienna"),
|
||||||
|
("Europe/Vilnius", "Europe/Vilnius"),
|
||||||
|
("Europe/Volgograd", "Europe/Volgograd"),
|
||||||
|
("Europe/Warsaw", "Europe/Warsaw"),
|
||||||
|
("Europe/Zagreb", "Europe/Zagreb"),
|
||||||
|
("Europe/Zaporozhye", "Europe/Zaporozhye"),
|
||||||
|
("Europe/Zurich", "Europe/Zurich"),
|
||||||
|
("Factory", "Factory"),
|
||||||
|
("GB", "GB"),
|
||||||
|
("GB-Eire", "GB-Eire"),
|
||||||
|
("GMT", "GMT"),
|
||||||
|
("GMT+0", "GMT+0"),
|
||||||
|
("GMT-0", "GMT-0"),
|
||||||
|
("GMT0", "GMT0"),
|
||||||
|
("Greenwich", "Greenwich"),
|
||||||
|
("HST", "HST"),
|
||||||
|
("Hongkong", "Hongkong"),
|
||||||
|
("Iceland", "Iceland"),
|
||||||
|
("Indian/Antananarivo", "Indian/Antananarivo"),
|
||||||
|
("Indian/Chagos", "Indian/Chagos"),
|
||||||
|
("Indian/Christmas", "Indian/Christmas"),
|
||||||
|
("Indian/Cocos", "Indian/Cocos"),
|
||||||
|
("Indian/Comoro", "Indian/Comoro"),
|
||||||
|
("Indian/Kerguelen", "Indian/Kerguelen"),
|
||||||
|
("Indian/Mahe", "Indian/Mahe"),
|
||||||
|
("Indian/Maldives", "Indian/Maldives"),
|
||||||
|
("Indian/Mauritius", "Indian/Mauritius"),
|
||||||
|
("Indian/Mayotte", "Indian/Mayotte"),
|
||||||
|
("Indian/Reunion", "Indian/Reunion"),
|
||||||
|
("Iran", "Iran"),
|
||||||
|
("Israel", "Israel"),
|
||||||
|
("Jamaica", "Jamaica"),
|
||||||
|
("Japan", "Japan"),
|
||||||
|
("Kwajalein", "Kwajalein"),
|
||||||
|
("Libya", "Libya"),
|
||||||
|
("MET", "MET"),
|
||||||
|
("MST", "MST"),
|
||||||
|
("MST7MDT", "MST7MDT"),
|
||||||
|
("Mexico/BajaNorte", "Mexico/BajaNorte"),
|
||||||
|
("Mexico/BajaSur", "Mexico/BajaSur"),
|
||||||
|
("Mexico/General", "Mexico/General"),
|
||||||
|
("NZ", "NZ"),
|
||||||
|
("NZ-CHAT", "NZ-CHAT"),
|
||||||
|
("Navajo", "Navajo"),
|
||||||
|
("PRC", "PRC"),
|
||||||
|
("PST8PDT", "PST8PDT"),
|
||||||
|
("Pacific/Apia", "Pacific/Apia"),
|
||||||
|
("Pacific/Auckland", "Pacific/Auckland"),
|
||||||
|
("Pacific/Bougainville", "Pacific/Bougainville"),
|
||||||
|
("Pacific/Chatham", "Pacific/Chatham"),
|
||||||
|
("Pacific/Chuuk", "Pacific/Chuuk"),
|
||||||
|
("Pacific/Easter", "Pacific/Easter"),
|
||||||
|
("Pacific/Efate", "Pacific/Efate"),
|
||||||
|
("Pacific/Enderbury", "Pacific/Enderbury"),
|
||||||
|
("Pacific/Fakaofo", "Pacific/Fakaofo"),
|
||||||
|
("Pacific/Fiji", "Pacific/Fiji"),
|
||||||
|
("Pacific/Funafuti", "Pacific/Funafuti"),
|
||||||
|
("Pacific/Galapagos", "Pacific/Galapagos"),
|
||||||
|
("Pacific/Gambier", "Pacific/Gambier"),
|
||||||
|
("Pacific/Guadalcanal", "Pacific/Guadalcanal"),
|
||||||
|
("Pacific/Guam", "Pacific/Guam"),
|
||||||
|
("Pacific/Honolulu", "Pacific/Honolulu"),
|
||||||
|
("Pacific/Johnston", "Pacific/Johnston"),
|
||||||
|
("Pacific/Kanton", "Pacific/Kanton"),
|
||||||
|
("Pacific/Kiritimati", "Pacific/Kiritimati"),
|
||||||
|
("Pacific/Kosrae", "Pacific/Kosrae"),
|
||||||
|
("Pacific/Kwajalein", "Pacific/Kwajalein"),
|
||||||
|
("Pacific/Majuro", "Pacific/Majuro"),
|
||||||
|
("Pacific/Marquesas", "Pacific/Marquesas"),
|
||||||
|
("Pacific/Midway", "Pacific/Midway"),
|
||||||
|
("Pacific/Nauru", "Pacific/Nauru"),
|
||||||
|
("Pacific/Niue", "Pacific/Niue"),
|
||||||
|
("Pacific/Norfolk", "Pacific/Norfolk"),
|
||||||
|
("Pacific/Noumea", "Pacific/Noumea"),
|
||||||
|
("Pacific/Pago_Pago", "Pacific/Pago_Pago"),
|
||||||
|
("Pacific/Palau", "Pacific/Palau"),
|
||||||
|
("Pacific/Pitcairn", "Pacific/Pitcairn"),
|
||||||
|
("Pacific/Pohnpei", "Pacific/Pohnpei"),
|
||||||
|
("Pacific/Ponape", "Pacific/Ponape"),
|
||||||
|
("Pacific/Port_Moresby", "Pacific/Port_Moresby"),
|
||||||
|
("Pacific/Rarotonga", "Pacific/Rarotonga"),
|
||||||
|
("Pacific/Saipan", "Pacific/Saipan"),
|
||||||
|
("Pacific/Samoa", "Pacific/Samoa"),
|
||||||
|
("Pacific/Tahiti", "Pacific/Tahiti"),
|
||||||
|
("Pacific/Tarawa", "Pacific/Tarawa"),
|
||||||
|
("Pacific/Tongatapu", "Pacific/Tongatapu"),
|
||||||
|
("Pacific/Truk", "Pacific/Truk"),
|
||||||
|
("Pacific/Wake", "Pacific/Wake"),
|
||||||
|
("Pacific/Wallis", "Pacific/Wallis"),
|
||||||
|
("Pacific/Yap", "Pacific/Yap"),
|
||||||
|
("Poland", "Poland"),
|
||||||
|
("Portugal", "Portugal"),
|
||||||
|
("ROC", "ROC"),
|
||||||
|
("ROK", "ROK"),
|
||||||
|
("Singapore", "Singapore"),
|
||||||
|
("Turkey", "Turkey"),
|
||||||
|
("UCT", "UCT"),
|
||||||
|
("US/Alaska", "US/Alaska"),
|
||||||
|
("US/Aleutian", "US/Aleutian"),
|
||||||
|
("US/Arizona", "US/Arizona"),
|
||||||
|
("US/Central", "US/Central"),
|
||||||
|
("US/East-Indiana", "US/East-Indiana"),
|
||||||
|
("US/Eastern", "US/Eastern"),
|
||||||
|
("US/Hawaii", "US/Hawaii"),
|
||||||
|
("US/Indiana-Starke", "US/Indiana-Starke"),
|
||||||
|
("US/Michigan", "US/Michigan"),
|
||||||
|
("US/Mountain", "US/Mountain"),
|
||||||
|
("US/Pacific", "US/Pacific"),
|
||||||
|
("US/Samoa", "US/Samoa"),
|
||||||
|
("UTC", "UTC"),
|
||||||
|
("Universal", "Universal"),
|
||||||
|
("W-SU", "W-SU"),
|
||||||
|
("WET", "WET"),
|
||||||
|
("Zulu", "Zulu"),
|
||||||
|
],
|
||||||
|
default=aircox.models.schedule.current_timezone_key,
|
||||||
|
help_text="timezone used for the date",
|
||||||
|
max_length=100,
|
||||||
|
verbose_name="timezone",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
|
@ -0,0 +1,22 @@
|
||||||
|
# Generated by Django 4.2.9 on 2024-03-19 22:38
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("aircox", "0023_station_legal_label_alter_schedule_timezone"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RenameField(
|
||||||
|
model_name="usersettings",
|
||||||
|
old_name="playlist_editor_columns",
|
||||||
|
new_name="tracklist_editor_columns",
|
||||||
|
),
|
||||||
|
migrations.RenameField(
|
||||||
|
model_name="usersettings",
|
||||||
|
old_name="playlist_editor_sep",
|
||||||
|
new_name="tracklist_editor_sep",
|
||||||
|
),
|
||||||
|
]
|
|
@ -0,0 +1,36 @@
|
||||||
|
# Generated by Django 4.2.9 on 2024-03-25 20:23
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("aircox", "0024_rename_playlist_editor_columns_usersettings_tracklist_editor_columns_and_more"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="sound",
|
||||||
|
name="is_removed",
|
||||||
|
field=models.BooleanField(default=False, help_text="file has been removed", verbose_name="removed"),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="sound",
|
||||||
|
name="is_downloadable",
|
||||||
|
field=models.BooleanField(
|
||||||
|
default=False,
|
||||||
|
help_text="sound can be downloaded by visitors (sound must be public)",
|
||||||
|
verbose_name="downloadable",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="sound",
|
||||||
|
name="is_public",
|
||||||
|
field=models.BooleanField(default=False, help_text="sound is available as podcast", verbose_name="public"),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="sound",
|
||||||
|
name="type",
|
||||||
|
field=models.SmallIntegerField(choices=[(0, "other"), (1, "archive"), (2, "excerpt")], verbose_name="type"),
|
||||||
|
),
|
||||||
|
]
|
|
@ -0,0 +1,162 @@
|
||||||
|
# Generated by Django 4.2.9 on 2024-03-26 02:53
|
||||||
|
|
||||||
|
import aircox.models.file
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
|
||||||
|
|
||||||
|
sounds_info = {}
|
||||||
|
|
||||||
|
|
||||||
|
def get_sounds_info(apps, schema_editor):
|
||||||
|
Sound = apps.get_model("aircox", "Sound")
|
||||||
|
objs = Sound.objects.filter(episode__isnull=False).values(
|
||||||
|
"pk",
|
||||||
|
"episode_id",
|
||||||
|
"position",
|
||||||
|
"type",
|
||||||
|
)
|
||||||
|
sounds_info.update({obj["pk"]: obj for obj in objs})
|
||||||
|
|
||||||
|
|
||||||
|
def restore_sounds_info(apps, schema_editor):
|
||||||
|
try:
|
||||||
|
Sound = apps.get_model("aircox", "Sound")
|
||||||
|
EpisodeSound = apps.get_model("aircox", "EpisodeSound")
|
||||||
|
TYPE_ARCHIVE = 0x01
|
||||||
|
TYPE_REMOVED = 0x03
|
||||||
|
|
||||||
|
episode_sounds = []
|
||||||
|
sounds = []
|
||||||
|
for sound in Sound.objects.all():
|
||||||
|
info = sounds_info.get(sound.pk)
|
||||||
|
if not info:
|
||||||
|
continue
|
||||||
|
|
||||||
|
sound.broadcast = info["type"] == TYPE_ARCHIVE
|
||||||
|
sound.is_removed = info["type"] == TYPE_REMOVED
|
||||||
|
sounds.append(sound)
|
||||||
|
if not sound.is_removed:
|
||||||
|
obj = EpisodeSound(
|
||||||
|
sound=sound,
|
||||||
|
episode_id=info["episode_id"],
|
||||||
|
position=info["position"],
|
||||||
|
broadcast=sound.broadcast,
|
||||||
|
)
|
||||||
|
episode_sounds.append(obj)
|
||||||
|
|
||||||
|
Sound.objects.bulk_update(sounds, ("broadcast", "is_removed"))
|
||||||
|
EpisodeSound.objects.bulk_create(episode_sounds)
|
||||||
|
|
||||||
|
print(f"\n>>> {len(sounds)} Sound have been updated.")
|
||||||
|
print(f">>> {len(episode_sounds)} EpisodeSound have been created.")
|
||||||
|
except Exception as err:
|
||||||
|
print(err)
|
||||||
|
import traceback
|
||||||
|
|
||||||
|
traceback.print_exc()
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("aircox", "0025_sound_is_removed_alter_sound_is_downloadable_and_more"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RunPython(get_sounds_info),
|
||||||
|
migrations.AlterModelOptions(
|
||||||
|
name="sound",
|
||||||
|
options={"verbose_name": "Sound file", "verbose_name_plural": "Sound files"},
|
||||||
|
),
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name="sound",
|
||||||
|
name="episode",
|
||||||
|
),
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name="sound",
|
||||||
|
name="position",
|
||||||
|
),
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name="sound",
|
||||||
|
name="type",
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="sound",
|
||||||
|
name="broadcast",
|
||||||
|
field=models.BooleanField(
|
||||||
|
default=False, help_text="The sound is broadcasted on air", verbose_name="Broadcast"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="sound",
|
||||||
|
name="description",
|
||||||
|
field=models.TextField(blank=True, default="", max_length=256, verbose_name="description"),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="sound",
|
||||||
|
name="file",
|
||||||
|
field=models.FileField(
|
||||||
|
db_index=True, max_length=256, upload_to=aircox.models.file.File._upload_to, verbose_name="file"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="sound",
|
||||||
|
name="is_downloadable",
|
||||||
|
field=models.BooleanField(
|
||||||
|
default=False, help_text="sound can be downloaded by visitors", verbose_name="downloadable"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="sound",
|
||||||
|
name="is_public",
|
||||||
|
field=models.BooleanField(default=False, help_text="file is publicly accessible", verbose_name="public"),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="sound",
|
||||||
|
name="is_removed",
|
||||||
|
field=models.BooleanField(
|
||||||
|
db_index=True, default=False, help_text="file has been removed from server", verbose_name="removed"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="sound",
|
||||||
|
name="name",
|
||||||
|
field=models.CharField(db_index=True, max_length=64, verbose_name="name"),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="sound",
|
||||||
|
name="program",
|
||||||
|
field=models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.SET_NULL,
|
||||||
|
to="aircox.program",
|
||||||
|
verbose_name="Program",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="EpisodeSound",
|
||||||
|
fields=[
|
||||||
|
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
|
||||||
|
(
|
||||||
|
"position",
|
||||||
|
models.PositiveSmallIntegerField(
|
||||||
|
default=0, help_text="position in the playlist", verbose_name="order"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"broadcast",
|
||||||
|
models.BooleanField(
|
||||||
|
blank=None, help_text="The sound is broadcasted on air", verbose_name="Broadcast"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("episode", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="aircox.episode")),
|
||||||
|
("sound", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="aircox.sound")),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"verbose_name": "Episode Sound",
|
||||||
|
"verbose_name_plural": "Episode Sounds",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.RunPython(restore_sounds_info),
|
||||||
|
]
|
|
@ -1,7 +1,7 @@
|
||||||
from . import signals
|
from . import signals
|
||||||
from .article import Article
|
from .article import Article
|
||||||
from .diffusion import Diffusion, DiffusionQuerySet
|
from .diffusion import Diffusion, DiffusionQuerySet
|
||||||
from .episode import Episode
|
from .episode import Episode, EpisodeSound
|
||||||
from .log import Log, LogQuerySet
|
from .log import Log, LogQuerySet
|
||||||
from .page import Category, Comment, NavItem, Page, PageQuerySet, StaticPage
|
from .page import Category, Comment, NavItem, Page, PageQuerySet, StaticPage
|
||||||
from .program import Program, ProgramChildQuerySet, ProgramQuerySet, Stream
|
from .program import Program, ProgramChildQuerySet, ProgramQuerySet, Stream
|
||||||
|
@ -14,16 +14,17 @@ from .user_settings import UserSettings
|
||||||
__all__ = (
|
__all__ = (
|
||||||
"signals",
|
"signals",
|
||||||
"Article",
|
"Article",
|
||||||
"Episode",
|
"Category",
|
||||||
|
"Comment",
|
||||||
"Diffusion",
|
"Diffusion",
|
||||||
"DiffusionQuerySet",
|
"DiffusionQuerySet",
|
||||||
|
"Episode",
|
||||||
|
"EpisodeSound",
|
||||||
"Log",
|
"Log",
|
||||||
"LogQuerySet",
|
"LogQuerySet",
|
||||||
"Category",
|
|
||||||
"PageQuerySet",
|
"PageQuerySet",
|
||||||
"Page",
|
"Page",
|
||||||
"StaticPage",
|
"StaticPage",
|
||||||
"Comment",
|
|
||||||
"NavItem",
|
"NavItem",
|
||||||
"Program",
|
"Program",
|
||||||
"ProgramQuerySet",
|
"ProgramQuerySet",
|
||||||
|
|
|
@ -200,31 +200,7 @@ class Diffusion(Rerun):
|
||||||
@property
|
@property
|
||||||
def is_live(self):
|
def is_live(self):
|
||||||
"""True if Diffusion is live (False if there are sounds files)."""
|
"""True if Diffusion is live (False if there are sounds files)."""
|
||||||
return self.type == self.TYPE_ON_AIR and not self.episode.sound_set.archive().count()
|
return self.type == self.TYPE_ON_AIR and self.episode.episodesound_set.all().broadcast().empty()
|
||||||
|
|
||||||
def get_playlist(self, **types):
|
|
||||||
"""Returns sounds as a playlist (list of *local* archive file path).
|
|
||||||
|
|
||||||
The given arguments are passed to ``get_sounds``.
|
|
||||||
"""
|
|
||||||
from .sound import Sound
|
|
||||||
|
|
||||||
return list(
|
|
||||||
self.get_sounds(**types).filter(path__isnull=False, type=Sound.TYPE_ARCHIVE).values_list("path", flat=True)
|
|
||||||
)
|
|
||||||
|
|
||||||
def get_sounds(self, **types):
|
|
||||||
"""Return a queryset of sounds related to this diffusion, ordered by
|
|
||||||
type then path.
|
|
||||||
|
|
||||||
**types: filter on the given sound types name, as `archive=True`
|
|
||||||
"""
|
|
||||||
from .sound import Sound
|
|
||||||
|
|
||||||
sounds = (self.initial or self).sound_set.order_by("type", "path")
|
|
||||||
_in = [getattr(Sound.Type, name) for name, value in types.items() if value]
|
|
||||||
|
|
||||||
return sounds.filter(type__in=_in)
|
|
||||||
|
|
||||||
def is_date_in_range(self, date=None):
|
def is_date_in_range(self, date=None):
|
||||||
"""Return true if the given date is in the diffusion's start-end
|
"""Return true if the given date is in the diffusion's start-end
|
||||||
|
|
|
@ -1,18 +1,22 @@
|
||||||
|
import os
|
||||||
|
|
||||||
|
from django.conf import settings as d_settings
|
||||||
|
from django.db import models
|
||||||
from django.utils.functional import cached_property
|
from django.utils.functional import cached_property
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from easy_thumbnails.files import get_thumbnailer
|
|
||||||
|
|
||||||
from aircox.conf import settings
|
from aircox.conf import settings
|
||||||
|
|
||||||
from .page import Page
|
from .page import Page
|
||||||
from .program import ProgramChildQuerySet
|
from .program import ProgramChildQuerySet
|
||||||
|
from .sound import Sound
|
||||||
|
|
||||||
__all__ = ("Episode",)
|
__all__ = ("Episode",)
|
||||||
|
|
||||||
|
|
||||||
class EpisodeQuerySet(ProgramChildQuerySet):
|
class EpisodeQuerySet(ProgramChildQuerySet):
|
||||||
def with_podcasts(self):
|
def with_podcasts(self):
|
||||||
return self.filter(sound__is_public=True).distinct()
|
return self.filter(episodesound__sound__is_public=True).distinct()
|
||||||
|
|
||||||
|
|
||||||
class Episode(Page):
|
class Episode(Page):
|
||||||
|
@ -32,39 +36,21 @@ class Episode(Page):
|
||||||
@cached_property
|
@cached_property
|
||||||
def podcasts(self):
|
def podcasts(self):
|
||||||
"""Return serialized data about podcasts."""
|
"""Return serialized data about podcasts."""
|
||||||
from ..serializers import PodcastSerializer
|
query = self.episodesound_set.all().public().order_by("-broadcast", "position")
|
||||||
|
return self._to_podcasts(query)
|
||||||
query = self.sound_set.public().order_by("type")
|
|
||||||
return self._to_podcasts(query, PodcastSerializer)
|
|
||||||
|
|
||||||
@cached_property
|
@cached_property
|
||||||
def sounds(self):
|
def sounds(self):
|
||||||
"""Return serialized data about all related sounds."""
|
"""Return serialized data about all related sounds."""
|
||||||
from ..serializers import SoundSerializer
|
query = self.episodesound_set.all().order_by("-broadcast", "position")
|
||||||
|
return self._to_podcasts(query)
|
||||||
|
|
||||||
query = self.sound_set.order_by("type")
|
def _to_podcasts(self, query):
|
||||||
return self._to_podcasts(query, SoundSerializer)
|
from ..serializers import EpisodeSoundSerializer as serializer_class
|
||||||
|
|
||||||
def _to_podcasts(self, items, serializer_class):
|
query = query.select_related("sound")
|
||||||
from .sound import Sound
|
podcasts = [serializer_class(s).data for s in query]
|
||||||
|
|
||||||
podcasts = [serializer_class(s).data for s in items]
|
|
||||||
if self.cover:
|
|
||||||
options = {"size": (128, 128), "crop": "scale"}
|
|
||||||
cover = get_thumbnailer(self.cover).get_thumbnail(options).url
|
|
||||||
else:
|
|
||||||
cover = None
|
|
||||||
|
|
||||||
archive_index = 1
|
|
||||||
for index, podcast in enumerate(podcasts):
|
for index, podcast in enumerate(podcasts):
|
||||||
if podcast["type"] == Sound.TYPE_ARCHIVE:
|
|
||||||
if archive_index > 1:
|
|
||||||
podcast["name"] = f"{self.title} - {archive_index}"
|
|
||||||
else:
|
|
||||||
podcast["name"] = self.title
|
|
||||||
archive_index += 1
|
|
||||||
|
|
||||||
podcasts[index]["cover"] = cover
|
|
||||||
podcasts[index]["page_url"] = self.get_absolute_url()
|
podcasts[index]["page_url"] = self.get_absolute_url()
|
||||||
podcasts[index]["page_title"] = self.title
|
podcasts[index]["page_title"] = self.title
|
||||||
return podcasts
|
return podcasts
|
||||||
|
@ -102,3 +88,54 @@ class Episode(Page):
|
||||||
else title
|
else title
|
||||||
)
|
)
|
||||||
return super().get_init_kwargs_from(page, title=title, program=page, **kwargs)
|
return super().get_init_kwargs_from(page, title=title, program=page, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
class EpisodeSoundQuerySet(models.QuerySet):
|
||||||
|
def episode(self, episode):
|
||||||
|
if isinstance(episode, int):
|
||||||
|
return self.filter(episode_id=episode)
|
||||||
|
return self.filter(episode=episode)
|
||||||
|
|
||||||
|
def available(self):
|
||||||
|
return self.filter(sound__is_removed=False)
|
||||||
|
|
||||||
|
def public(self):
|
||||||
|
return self.filter(sound__is_public=True)
|
||||||
|
|
||||||
|
def broadcast(self):
|
||||||
|
return self.available().filter(broadcast=True)
|
||||||
|
|
||||||
|
def playlist(self, order="position"):
|
||||||
|
# TODO: subquery expression
|
||||||
|
if order:
|
||||||
|
self = self.order_by(order)
|
||||||
|
query = self.filter(sound__file__isnull=False, sound__is_removed=False).values_list("sound__file", flat=True)
|
||||||
|
return [os.path.join(d_settings.MEDIA_ROOT, file) for file in query]
|
||||||
|
|
||||||
|
|
||||||
|
class EpisodeSound(models.Model):
|
||||||
|
"""Element of an episode playlist."""
|
||||||
|
|
||||||
|
episode = models.ForeignKey(Episode, on_delete=models.CASCADE)
|
||||||
|
sound = models.ForeignKey(Sound, on_delete=models.CASCADE)
|
||||||
|
position = models.PositiveSmallIntegerField(
|
||||||
|
_("order"),
|
||||||
|
default=0,
|
||||||
|
help_text=_("position in the playlist"),
|
||||||
|
)
|
||||||
|
broadcast = models.BooleanField(
|
||||||
|
_("Broadcast"),
|
||||||
|
blank=None,
|
||||||
|
help_text=_("The sound is broadcasted on air"),
|
||||||
|
)
|
||||||
|
|
||||||
|
objects = EpisodeSoundQuerySet.as_manager()
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = _("Episode Sound")
|
||||||
|
verbose_name_plural = _("Episode Sounds")
|
||||||
|
|
||||||
|
def save(self, *args, **kwargs):
|
||||||
|
if self.broadcast is None:
|
||||||
|
self.broadcast = self.sound.broadcast
|
||||||
|
super().save(*args, **kwargs)
|
||||||
|
|
150
aircox/models/file.py
Normal file
150
aircox/models/file.py
Normal file
|
@ -0,0 +1,150 @@
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import models
|
||||||
|
from django.db.models import Q
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
from django.utils import timezone as tz
|
||||||
|
|
||||||
|
from .program import Program
|
||||||
|
|
||||||
|
|
||||||
|
class FileQuerySet(models.QuerySet):
|
||||||
|
def station(self, station=None, id=None):
|
||||||
|
id = station.pk if id is None else id
|
||||||
|
return self.filter(program__station__id=id)
|
||||||
|
|
||||||
|
def available(self):
|
||||||
|
return self.exclude(is_removed=False)
|
||||||
|
|
||||||
|
def public(self):
|
||||||
|
"""Return sounds available as podcasts."""
|
||||||
|
return self.filter(is_public=True)
|
||||||
|
|
||||||
|
def path(self, paths):
|
||||||
|
if isinstance(paths, str):
|
||||||
|
return self.filter(file=paths.replace(settings.MEDIA_ROOT + "/", ""))
|
||||||
|
return self.filter(file__in=(p.replace(settings.MEDIA_ROOT + "/", "") for p in paths))
|
||||||
|
|
||||||
|
def search(self, query):
|
||||||
|
return self.filter(Q(name__icontains=query) | Q(file__icontains=query) | Q(program__title__icontains=query))
|
||||||
|
|
||||||
|
|
||||||
|
class File(models.Model):
|
||||||
|
def _upload_to(self, filename):
|
||||||
|
dir = self.program and self.program.path or self.default_upload_path
|
||||||
|
subdir = self.get_upload_dir()
|
||||||
|
if subdir:
|
||||||
|
return os.path.join(dir, subdir, filename)
|
||||||
|
return os.path.join(dir, filename)
|
||||||
|
|
||||||
|
program = models.ForeignKey(
|
||||||
|
Program,
|
||||||
|
models.SET_NULL,
|
||||||
|
verbose_name=_("Program"),
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
)
|
||||||
|
file = models.FileField(
|
||||||
|
_("file"),
|
||||||
|
upload_to=_upload_to,
|
||||||
|
max_length=256,
|
||||||
|
db_index=True,
|
||||||
|
)
|
||||||
|
name = models.CharField(
|
||||||
|
_("name"),
|
||||||
|
max_length=64,
|
||||||
|
db_index=True,
|
||||||
|
)
|
||||||
|
description = models.TextField(
|
||||||
|
_("description"),
|
||||||
|
max_length=256,
|
||||||
|
blank=True,
|
||||||
|
default="",
|
||||||
|
)
|
||||||
|
mtime = models.DateTimeField(
|
||||||
|
_("modification time"),
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
help_text=_("last modification date and time"),
|
||||||
|
)
|
||||||
|
is_public = models.BooleanField(
|
||||||
|
_("public"),
|
||||||
|
help_text=_("file is publicly accessible"),
|
||||||
|
default=False,
|
||||||
|
)
|
||||||
|
is_removed = models.BooleanField(
|
||||||
|
_("removed"),
|
||||||
|
help_text=_("file has been removed from server"),
|
||||||
|
default=False,
|
||||||
|
db_index=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
abstract = True
|
||||||
|
|
||||||
|
objects = FileQuerySet.as_manager()
|
||||||
|
|
||||||
|
default_upload_path = Path(settings.MEDIA_ROOT)
|
||||||
|
"""Default upload directory when no program is provided."""
|
||||||
|
upload_dir = "uploads"
|
||||||
|
"""Upload sub-directory."""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def url(self):
|
||||||
|
return self.file and self.file.url
|
||||||
|
|
||||||
|
def get_upload_dir(self):
|
||||||
|
return self.upload_dir
|
||||||
|
|
||||||
|
def get_mtime(self):
|
||||||
|
"""Get the last modification date from file."""
|
||||||
|
mtime = os.stat(self.file.path).st_mtime
|
||||||
|
mtime = tz.datetime.fromtimestamp(mtime)
|
||||||
|
mtime = mtime.replace(microsecond=0)
|
||||||
|
return tz.make_aware(mtime, tz.get_current_timezone())
|
||||||
|
|
||||||
|
def file_updated(self):
|
||||||
|
"""Return True when file has been updated on filesystem."""
|
||||||
|
return self.mtime != self.get_mtime() or self.is_removed != (not self.file_exists())
|
||||||
|
|
||||||
|
def file_exists(self):
|
||||||
|
"""Return true if the file still exists."""
|
||||||
|
return os.path.exists(self.file.path)
|
||||||
|
|
||||||
|
def sync_fs(self, on_update=False):
|
||||||
|
"""Sync model to file on the filesystem.
|
||||||
|
|
||||||
|
:param bool on_update: only check if `file_updated`.
|
||||||
|
:return True wether a change happened.
|
||||||
|
"""
|
||||||
|
if on_update and not self.file_updated():
|
||||||
|
return
|
||||||
|
|
||||||
|
# check on name/remove/modification time
|
||||||
|
name = self.name
|
||||||
|
if not self.name and self.file and self.file.name:
|
||||||
|
name = os.path.basename(self.file.name)
|
||||||
|
name = os.path.splitext(name)[0]
|
||||||
|
name = name.replace("_", " ").strip()
|
||||||
|
|
||||||
|
is_removed = not self.file_exists()
|
||||||
|
mtime = self.get_mtime()
|
||||||
|
|
||||||
|
changed = is_removed != self.is_removed or mtime != self.mtime or name != self.name
|
||||||
|
self.name, self.is_removed, self.mtime = name, is_removed, mtime
|
||||||
|
|
||||||
|
# read metadata
|
||||||
|
if changed and not self.is_removed:
|
||||||
|
metadata = self.read_metadata()
|
||||||
|
metadata and self.__dict__.update(metadata)
|
||||||
|
return changed
|
||||||
|
|
||||||
|
def read_metadata(self):
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def save(self, sync=True, *args, **kwargs):
|
||||||
|
if sync and self.file_exists():
|
||||||
|
self.sync_fs(on_update=True)
|
||||||
|
super().save(*args, **kwargs)
|
|
@ -183,10 +183,14 @@ class BasePage(Renderable, models.Model):
|
||||||
headline[-1] += suffix
|
headline[-1] += suffix
|
||||||
return mark_safe(" ".join(headline))
|
return mark_safe(" ".join(headline))
|
||||||
|
|
||||||
_url_re = re.compile("(https?://[^\s\n]+)")
|
_url_re = re.compile(
|
||||||
|
"((http|https)\:\/\/)?[a-zA-Z0-9\.\/\?\:@\-_=#]+\.([a-zA-Z]){2,6}([a-zA-Z0-9\.\&\/\?\:@\-_=#])*"
|
||||||
|
)
|
||||||
|
|
||||||
@cached_property
|
@cached_property
|
||||||
def display_content(self):
|
def display_content(self):
|
||||||
|
if "<p>" in self.content:
|
||||||
|
return self.content
|
||||||
content = self._url_re.sub(r'<a href="\1" target="new">\1</a>', self.content)
|
content = self._url_re.sub(r'<a href="\1" target="new">\1</a>', self.content)
|
||||||
return content.replace("\n\n", "\n").replace("\n", "<br>")
|
return content.replace("\n\n", "\n").replace("\n", "<br>")
|
||||||
|
|
||||||
|
|
|
@ -91,12 +91,12 @@ def schedule_post_save(sender, instance, created, *args, **kwargs):
|
||||||
def schedule_pre_delete(sender, instance, *args, **kwargs):
|
def schedule_pre_delete(sender, instance, *args, **kwargs):
|
||||||
"""Delete later corresponding diffusion to a changed schedule."""
|
"""Delete later corresponding diffusion to a changed schedule."""
|
||||||
Diffusion.objects.filter(schedule=instance).after(tz.now()).delete()
|
Diffusion.objects.filter(schedule=instance).after(tz.now()).delete()
|
||||||
Episode.objects.filter(diffusion__isnull=True, content__isnull=True, sound__isnull=True).delete()
|
Episode.objects.filter(diffusion__isnull=True, content__isnull=True, episodesound__isnull=True).delete()
|
||||||
|
|
||||||
|
|
||||||
@receiver(signals.post_delete, sender=Diffusion)
|
@receiver(signals.post_delete, sender=Diffusion)
|
||||||
def diffusion_post_delete(sender, instance, *args, **kwargs):
|
def diffusion_post_delete(sender, instance, *args, **kwargs):
|
||||||
Episode.objects.filter(diffusion__isnull=True, content__isnull=True, sound__isnull=True).delete()
|
Episode.objects.filter(diffusion__isnull=True, content__isnull=True, episodesound__isnull=True).delete()
|
||||||
|
|
||||||
|
|
||||||
@receiver(signals.post_delete, sender=Sound)
|
@receiver(signals.post_delete, sender=Sound)
|
||||||
|
|
|
@ -1,240 +1,187 @@
|
||||||
import logging
|
from datetime import date
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
|
|
||||||
from django.conf import settings as conf
|
from django.conf import settings as conf
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.db.models import Q
|
|
||||||
from django.utils import timezone as tz
|
from django.utils import timezone as tz
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
|
from aircox import utils
|
||||||
from aircox.conf import settings
|
from aircox.conf import settings
|
||||||
|
|
||||||
from .episode import Episode
|
|
||||||
from .program import Program
|
from .program import Program
|
||||||
|
from .file import File, FileQuerySet
|
||||||
logger = logging.getLogger("aircox")
|
|
||||||
|
|
||||||
|
|
||||||
__all__ = ("Sound", "SoundQuerySet")
|
__all__ = ("Sound", "SoundQuerySet")
|
||||||
|
|
||||||
|
|
||||||
class SoundQuerySet(models.QuerySet):
|
class SoundQuerySet(FileQuerySet):
|
||||||
def station(self, station=None, id=None):
|
|
||||||
id = station.pk if id is None else id
|
|
||||||
return self.filter(program__station__id=id)
|
|
||||||
|
|
||||||
def episode(self, episode=None, id=None):
|
|
||||||
id = episode.pk if id is None else id
|
|
||||||
return self.filter(episode__id=id)
|
|
||||||
|
|
||||||
def diffusion(self, diffusion=None, id=None):
|
|
||||||
id = diffusion.pk if id is None else id
|
|
||||||
return self.filter(episode__diffusion__id=id)
|
|
||||||
|
|
||||||
def available(self):
|
|
||||||
return self.exclude(is_removed=False)
|
|
||||||
|
|
||||||
def public(self):
|
|
||||||
"""Return sounds available as podcasts."""
|
|
||||||
return self.filter(is_public=True)
|
|
||||||
|
|
||||||
def downloadable(self):
|
def downloadable(self):
|
||||||
"""Return sounds available as podcasts."""
|
"""Return sounds available as podcasts."""
|
||||||
return self.filter(is_downloadable=True)
|
return self.filter(is_downloadable=True)
|
||||||
|
|
||||||
def archive(self):
|
def broadcast(self):
|
||||||
"""Return sounds that are archives."""
|
"""Return sounds that are archives."""
|
||||||
return self.filter(type=Sound.TYPE_ARCHIVE)
|
return self.filter(broadcast=True)
|
||||||
|
|
||||||
def path(self, paths):
|
def playlist(self, order_by="file"):
|
||||||
if isinstance(paths, str):
|
|
||||||
return self.filter(file=paths.replace(conf.MEDIA_ROOT + "/", ""))
|
|
||||||
return self.filter(file__in=(p.replace(conf.MEDIA_ROOT + "/", "") for p in paths))
|
|
||||||
|
|
||||||
def playlist(self, archive=True, order_by=True):
|
|
||||||
"""Return files absolute paths as a flat list (exclude sound without
|
"""Return files absolute paths as a flat list (exclude sound without
|
||||||
path).
|
path)."""
|
||||||
|
|
||||||
If `order_by` is True, order by path.
|
|
||||||
"""
|
|
||||||
if archive:
|
|
||||||
self = self.archive()
|
|
||||||
if order_by:
|
if order_by:
|
||||||
self = self.order_by("file")
|
self = self.order_by(order_by)
|
||||||
return [
|
return [
|
||||||
os.path.join(conf.MEDIA_ROOT, file)
|
os.path.join(conf.MEDIA_ROOT, file)
|
||||||
for file in self.filter(file__isnull=False).values_list("file", flat=True)
|
for file in self.filter(file__isnull=False).values_list("file", flat=True)
|
||||||
]
|
]
|
||||||
|
|
||||||
def search(self, query):
|
|
||||||
return self.filter(
|
|
||||||
Q(name__icontains=query)
|
|
||||||
| Q(file__icontains=query)
|
|
||||||
| Q(program__title__icontains=query)
|
|
||||||
| Q(episode__title__icontains=query)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
class Sound(File):
|
||||||
# TODO:
|
|
||||||
# - provide a default name based on program and episode
|
|
||||||
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."""
|
|
||||||
|
|
||||||
TYPE_OTHER = 0x00
|
|
||||||
TYPE_ARCHIVE = 0x01
|
|
||||||
TYPE_EXCERPT = 0x02
|
|
||||||
TYPE_CHOICES = (
|
|
||||||
(TYPE_OTHER, _("other")),
|
|
||||||
(TYPE_ARCHIVE, _("archive")),
|
|
||||||
(TYPE_EXCERPT, _("excerpt")),
|
|
||||||
)
|
|
||||||
|
|
||||||
name = models.CharField(_("name"), max_length=64)
|
|
||||||
program = models.ForeignKey(
|
|
||||||
Program,
|
|
||||||
models.CASCADE,
|
|
||||||
blank=True, # NOT NULL
|
|
||||||
verbose_name=_("program"),
|
|
||||||
help_text=_("program related to it"),
|
|
||||||
db_index=True,
|
|
||||||
)
|
|
||||||
episode = models.ForeignKey(
|
|
||||||
Episode,
|
|
||||||
models.SET_NULL,
|
|
||||||
blank=True,
|
|
||||||
null=True,
|
|
||||||
verbose_name=_("episode"),
|
|
||||||
db_index=True,
|
|
||||||
)
|
|
||||||
type = models.SmallIntegerField(_("type"), choices=TYPE_CHOICES)
|
|
||||||
position = models.PositiveSmallIntegerField(
|
|
||||||
_("order"),
|
|
||||||
default=0,
|
|
||||||
help_text=_("position in the playlist"),
|
|
||||||
)
|
|
||||||
is_removed = models.BooleanField(_("removed"), default=False, help_text=_("file has been removed"))
|
|
||||||
|
|
||||||
def _upload_to(self, filename):
|
|
||||||
subdir = settings.SOUND_ARCHIVES_SUBDIR if self.type == self.TYPE_ARCHIVE else settings.SOUND_EXCERPTS_SUBDIR
|
|
||||||
return os.path.join(self.program.path, subdir, filename)
|
|
||||||
|
|
||||||
file = models.FileField(
|
|
||||||
_("file"),
|
|
||||||
upload_to=_upload_to,
|
|
||||||
max_length=256,
|
|
||||||
db_index=True,
|
|
||||||
unique=True,
|
|
||||||
)
|
|
||||||
duration = models.TimeField(
|
duration = models.TimeField(
|
||||||
_("duration"),
|
_("duration"),
|
||||||
blank=True,
|
blank=True,
|
||||||
null=True,
|
null=True,
|
||||||
help_text=_("duration of the sound"),
|
help_text=_("duration of the sound"),
|
||||||
)
|
)
|
||||||
mtime = models.DateTimeField(
|
|
||||||
_("modification time"),
|
|
||||||
blank=True,
|
|
||||||
null=True,
|
|
||||||
help_text=_("last modification date and time"),
|
|
||||||
)
|
|
||||||
is_good_quality = models.BooleanField(
|
is_good_quality = models.BooleanField(
|
||||||
_("good quality"),
|
_("good quality"),
|
||||||
help_text=_("sound meets quality requirements"),
|
help_text=_("sound meets quality requirements"),
|
||||||
blank=True,
|
blank=True,
|
||||||
null=True,
|
null=True,
|
||||||
)
|
)
|
||||||
is_public = models.BooleanField(
|
|
||||||
_("public"),
|
|
||||||
help_text=_("sound is available as podcast"),
|
|
||||||
default=False,
|
|
||||||
)
|
|
||||||
is_downloadable = models.BooleanField(
|
is_downloadable = models.BooleanField(
|
||||||
_("downloadable"),
|
_("downloadable"),
|
||||||
help_text=_("sound can be downloaded by visitors (sound must be public)"),
|
help_text=_("sound can be downloaded by visitors"),
|
||||||
default=False,
|
default=False,
|
||||||
)
|
)
|
||||||
|
broadcast = models.BooleanField(
|
||||||
|
_("Broadcast"),
|
||||||
|
default=False,
|
||||||
|
help_text=_("The sound is broadcasted on air"),
|
||||||
|
)
|
||||||
|
|
||||||
objects = SoundQuerySet.as_manager()
|
objects = SoundQuerySet.as_manager()
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
verbose_name = _("Sound")
|
verbose_name = _("Sound file")
|
||||||
verbose_name_plural = _("Sounds")
|
verbose_name_plural = _("Sound files")
|
||||||
|
|
||||||
@property
|
_path_re = re.compile(
|
||||||
def url(self):
|
"^(?P<year>[0-9]{4})(?P<month>[0-9]{2})(?P<day>[0-9]{2})"
|
||||||
return self.file and self.file.url
|
"(_(?P<hour>[0-9]{2})h(?P<minute>[0-9]{2}))?"
|
||||||
|
"(_(?P<n>[0-9]+))?"
|
||||||
|
"_?[ -]*(?P<name>.*)$"
|
||||||
|
)
|
||||||
|
|
||||||
def __str__(self):
|
@classmethod
|
||||||
return "/".join(self.file.path.split("/")[-3:])
|
def read_path(cls, path):
|
||||||
|
"""Parse path name returning dictionary of extracted info. It can
|
||||||
|
contain:
|
||||||
|
|
||||||
def save(self, check=True, *args, **kwargs):
|
- `year`, `month`, `day`: diffusion date
|
||||||
if self.episode is not None and self.program is None:
|
- `hour`, `minute`: diffusion time
|
||||||
self.program = self.episode.program
|
- `n`: sound arbitrary number (used for sound ordering)
|
||||||
if check:
|
- `name`: cleaned name extracted or file name (without extension)
|
||||||
self.check_on_file()
|
|
||||||
if not self.is_public:
|
|
||||||
self.is_downloadable = False
|
|
||||||
self.__check_name()
|
|
||||||
super().save(*args, **kwargs)
|
|
||||||
|
|
||||||
# TODO: rename get_file_mtime(self)
|
|
||||||
def get_mtime(self):
|
|
||||||
"""Get the last modification date from file."""
|
|
||||||
mtime = os.stat(self.file.path).st_mtime
|
|
||||||
mtime = tz.datetime.fromtimestamp(mtime)
|
|
||||||
mtime = mtime.replace(microsecond=0)
|
|
||||||
return tz.make_aware(mtime, tz.get_current_timezone())
|
|
||||||
|
|
||||||
def file_exists(self):
|
|
||||||
"""Return true if the file still exists."""
|
|
||||||
|
|
||||||
return os.path.exists(self.file.path)
|
|
||||||
|
|
||||||
# TODO: rename to sync_fs()
|
|
||||||
def check_on_file(self):
|
|
||||||
"""Check sound file info again'st self, and update informations if
|
|
||||||
needed (do not save).
|
|
||||||
|
|
||||||
Return True if there was changes.
|
|
||||||
"""
|
"""
|
||||||
if not self.file_exists():
|
basename = os.path.basename(path)
|
||||||
if self.is_removed:
|
basename = os.path.splitext(basename)[0]
|
||||||
return
|
reg_match = cls._path_re.search(basename)
|
||||||
logger.debug("sound %s: has been removed", self.file.name)
|
if reg_match:
|
||||||
self.is_removed = True
|
info = reg_match.groupdict()
|
||||||
return True
|
for k in ("year", "month", "day", "hour", "minute", "n"):
|
||||||
|
if info.get(k) is not None:
|
||||||
|
info[k] = int(info[k])
|
||||||
|
|
||||||
# not anymore removed
|
name = info.get("name")
|
||||||
changed = False
|
info["name"] = name and cls._as_name(name) or basename
|
||||||
|
else:
|
||||||
|
info = {"name": basename}
|
||||||
|
return info
|
||||||
|
|
||||||
if self.is_removed and self.program:
|
@classmethod
|
||||||
changed = True
|
def _as_name(cls, name):
|
||||||
self.type = (
|
name = name.replace("_", " ")
|
||||||
self.TYPE_ARCHIVE if self.file.name.startswith(self.program.archives_path) else self.TYPE_EXCERPT
|
return " ".join(r.capitalize() for r in name.split(" "))
|
||||||
|
|
||||||
|
def find_episode(self, path_info=None):
|
||||||
|
"""Base on self's file name, match date to an initial diffusion and
|
||||||
|
return corresponding episode or ``None``."""
|
||||||
|
pi = path_info or self.read_path(self.file.path)
|
||||||
|
if "year" not in pi:
|
||||||
|
return None
|
||||||
|
|
||||||
|
year, month, day = pi.get("year"), pi.get("month"), pi.get("day")
|
||||||
|
if pi.get("hour") is not None:
|
||||||
|
at = tz.datetime(year, month, day, pi.get("hour", 0), pi.get("minute", 0))
|
||||||
|
at = tz.make_aware(at)
|
||||||
|
else:
|
||||||
|
at = date(year, month, day)
|
||||||
|
|
||||||
|
diffusion = self.program.diffusion_set.at(at).first()
|
||||||
|
return diffusion and diffusion.episode or None
|
||||||
|
|
||||||
|
def find_playlist(self, meta=None):
|
||||||
|
"""Find a playlist file corresponding to the sound path, such as:
|
||||||
|
my_sound.ogg => my_sound.csv.
|
||||||
|
|
||||||
|
Use provided sound's metadata if any and no csv file has been
|
||||||
|
found.
|
||||||
|
"""
|
||||||
|
from aircox.controllers.playlist_import import PlaylistImport
|
||||||
|
from .track import Track
|
||||||
|
|
||||||
|
if self.track_set.count() > 1:
|
||||||
|
return
|
||||||
|
|
||||||
|
# import playlist
|
||||||
|
path_noext, ext = os.path.splitext(self.file.path)
|
||||||
|
path = path_noext + ".csv"
|
||||||
|
if os.path.exists(path):
|
||||||
|
PlaylistImport(path, sound=self).run()
|
||||||
|
# use metadata
|
||||||
|
elif meta and meta.tags:
|
||||||
|
title, artist, album, year = tuple(
|
||||||
|
t and ", ".join(t) for t in (meta.tags.get(k) for k in ("title", "artist", "album", "year"))
|
||||||
)
|
)
|
||||||
|
title = title or path_noext
|
||||||
# check mtime -> reset quality if changed (assume file changed)
|
info = "{} ({})".format(album, year) if album and year else album or year or ""
|
||||||
mtime = self.get_mtime()
|
track = Track(
|
||||||
|
sound=self,
|
||||||
if self.mtime != mtime:
|
position=int(meta.tags.get("tracknumber", 0)),
|
||||||
self.mtime = mtime
|
title=title,
|
||||||
self.is_good_quality = None
|
artist=artist or _("unknown"),
|
||||||
logger.debug(
|
info=info,
|
||||||
"sound %s: m_time has changed. Reset quality info",
|
|
||||||
self.file.name,
|
|
||||||
)
|
)
|
||||||
return True
|
track.save()
|
||||||
|
|
||||||
|
def get_upload_dir(self):
|
||||||
|
if self.broadcast:
|
||||||
|
return settings.SOUND_BROADCASTS_SUBDIR
|
||||||
|
return settings.SOUND_EXCERPTS_SUBDIR
|
||||||
|
|
||||||
|
meta = None
|
||||||
|
"""Provided by read_metadata: Mutagen's metadata."""
|
||||||
|
|
||||||
|
def sync_fs(self, *args, find_playlist=False, **kwargs):
|
||||||
|
changed = super().sync_fs(*args, **kwargs)
|
||||||
|
if changed and not self.is_removed:
|
||||||
|
if not self.program:
|
||||||
|
self.program = Program.get_from_path(self.file.path)
|
||||||
|
changed = True
|
||||||
|
if find_playlist and self.meta:
|
||||||
|
not self.pk and self.save(sync=False)
|
||||||
|
self.find_playlist(self.meta)
|
||||||
return changed
|
return changed
|
||||||
|
|
||||||
def __check_name(self):
|
def read_metadata(self):
|
||||||
if not self.name and self.file and self.file.name:
|
import mutagen
|
||||||
# FIXME: later, remove date?
|
|
||||||
name = os.path.basename(self.file.name)
|
|
||||||
name = os.path.splitext(name)[0]
|
|
||||||
self.name = name.replace("_", " ").strip()
|
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
meta = mutagen.File(self.file.path)
|
||||||
super().__init__(*args, **kwargs)
|
|
||||||
self.__check_name()
|
metadata = {"duration": utils.seconds_to_time(meta.info.length), "meta": meta}
|
||||||
|
|
||||||
|
path_info = self.read_path(self.file.path)
|
||||||
|
if name := path_info.get("name"):
|
||||||
|
metadata["name"] = name
|
||||||
|
return metadata
|
||||||
|
|
|
@ -1,11 +1,8 @@
|
||||||
import os
|
|
||||||
|
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.utils.functional import cached_property
|
from django.utils.functional import cached_property
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from filer.fields.image import FilerImageField
|
from filer.fields.image import FilerImageField
|
||||||
|
|
||||||
from aircox.conf import settings
|
|
||||||
|
|
||||||
__all__ = ("Station", "StationQuerySet", "Port")
|
__all__ = ("Station", "StationQuerySet", "Port")
|
||||||
|
|
||||||
|
@ -32,13 +29,6 @@ class Station(models.Model):
|
||||||
|
|
||||||
name = models.CharField(_("name"), max_length=64)
|
name = models.CharField(_("name"), max_length=64)
|
||||||
slug = models.SlugField(_("slug"), max_length=64, unique=True)
|
slug = models.SlugField(_("slug"), max_length=64, unique=True)
|
||||||
# FIXME: remove - should be decided only by Streamer controller + settings
|
|
||||||
path = models.CharField(
|
|
||||||
_("path"),
|
|
||||||
help_text=_("path to the working directory"),
|
|
||||||
max_length=256,
|
|
||||||
blank=True,
|
|
||||||
)
|
|
||||||
default = models.BooleanField(
|
default = models.BooleanField(
|
||||||
_("default station"),
|
_("default station"),
|
||||||
default=False,
|
default=False,
|
||||||
|
@ -96,12 +86,6 @@ class Station(models.Model):
|
||||||
return self.name
|
return self.name
|
||||||
|
|
||||||
def save(self, make_sources=True, *args, **kwargs):
|
def save(self, make_sources=True, *args, **kwargs):
|
||||||
if not self.path:
|
|
||||||
self.path = os.path.join(
|
|
||||||
settings.CONTROLLERS_WORKING_DIR,
|
|
||||||
self.slug.replace("-", "_"),
|
|
||||||
)
|
|
||||||
|
|
||||||
if self.default:
|
if self.default:
|
||||||
qs = Station.objects.filter(default=True)
|
qs = Station.objects.filter(default=True)
|
||||||
if self.pk is not None:
|
if self.pk is not None:
|
||||||
|
|
|
@ -1,12 +1,14 @@
|
||||||
from .admin import TrackSerializer, UserSettingsSerializer
|
from .admin import TrackSerializer, UserSettingsSerializer
|
||||||
from .log import LogInfo, LogInfoSerializer
|
from .log import LogInfo, LogInfoSerializer
|
||||||
from .sound import PodcastSerializer, SoundSerializer
|
from .sound import SoundSerializer
|
||||||
|
from .episode import EpisodeSoundSerializer, EpisodeSerializer
|
||||||
|
|
||||||
__all__ = (
|
__all__ = (
|
||||||
"TrackSerializer",
|
|
||||||
"UserSettingsSerializer",
|
|
||||||
"LogInfo",
|
"LogInfo",
|
||||||
"LogInfoSerializer",
|
"LogInfoSerializer",
|
||||||
|
"EpisodeSoundSerializer",
|
||||||
|
"EpisodeSerializer",
|
||||||
"SoundSerializer",
|
"SoundSerializer",
|
||||||
"PodcastSerializer",
|
"TrackSerializer",
|
||||||
|
"UserSettingsSerializer",
|
||||||
)
|
)
|
||||||
|
|
36
aircox/serializers/episode.py
Normal file
36
aircox/serializers/episode.py
Normal file
|
@ -0,0 +1,36 @@
|
||||||
|
from rest_framework import serializers
|
||||||
|
|
||||||
|
from .. import models
|
||||||
|
from .sound import SoundSerializer
|
||||||
|
from .admin import TrackSerializer
|
||||||
|
|
||||||
|
|
||||||
|
class EpisodeSoundSerializer(serializers.ModelSerializer):
|
||||||
|
sound = SoundSerializer(read_only=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = models.EpisodeSound
|
||||||
|
fields = [
|
||||||
|
"id",
|
||||||
|
"position",
|
||||||
|
"episode",
|
||||||
|
"broadcast",
|
||||||
|
"sound",
|
||||||
|
"sound_id",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class EpisodeSerializer(serializers.ModelSerializer):
|
||||||
|
playlist = EpisodeSoundSerializer(source="episodesound_set", many=True, read_only=True)
|
||||||
|
tracks = TrackSerializer(source="track_set", many=True, read_only=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = models.Episode
|
||||||
|
fields = [
|
||||||
|
"id",
|
||||||
|
"title",
|
||||||
|
"content",
|
||||||
|
"pub_date",
|
||||||
|
"playlist",
|
||||||
|
"tracks",
|
||||||
|
]
|
|
@ -1,23 +1,19 @@
|
||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
|
|
||||||
from ..models import Sound
|
from .. import models
|
||||||
|
|
||||||
__all__ = ("SoundSerializer", "PodcastSerializer")
|
__all__ = ("SoundSerializer",)
|
||||||
|
|
||||||
|
|
||||||
class SoundSerializer(serializers.ModelSerializer):
|
class SoundSerializer(serializers.ModelSerializer):
|
||||||
file = serializers.FileField(use_url=False)
|
file = serializers.FileField(use_url=False)
|
||||||
type_display = serializers.SerializerMethodField()
|
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Sound
|
model = models.Sound
|
||||||
fields = [
|
fields = [
|
||||||
"pk",
|
"id",
|
||||||
"name",
|
"name",
|
||||||
"program",
|
"program",
|
||||||
"episode",
|
|
||||||
"type",
|
|
||||||
"type_display",
|
|
||||||
"file",
|
"file",
|
||||||
"duration",
|
"duration",
|
||||||
"mtime",
|
"mtime",
|
||||||
|
@ -26,24 +22,3 @@ class SoundSerializer(serializers.ModelSerializer):
|
||||||
"is_downloadable",
|
"is_downloadable",
|
||||||
"url",
|
"url",
|
||||||
]
|
]
|
||||||
|
|
||||||
def get_type_display(self, obj):
|
|
||||||
return obj.get_type_display()
|
|
||||||
|
|
||||||
|
|
||||||
class PodcastSerializer(serializers.ModelSerializer):
|
|
||||||
# serializers.HyperlinkedIdentityField(view_name='sound', format='html')
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
model = Sound
|
|
||||||
fields = [
|
|
||||||
"pk",
|
|
||||||
"name",
|
|
||||||
"program",
|
|
||||||
"episode",
|
|
||||||
"type",
|
|
||||||
"duration",
|
|
||||||
"mtime",
|
|
||||||
"url",
|
|
||||||
"is_downloadable",
|
|
||||||
]
|
|
||||||
|
|
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
|
@ -62,18 +62,10 @@ Usefull context:
|
||||||
{% for item, render in items %}
|
{% for item, render in items %}
|
||||||
{{ render }}
|
{{ render }}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% if user.is_staff %}
|
|
||||||
<a class="nav-item" href="{% url "admin:index" %}" target="new">
|
|
||||||
{% translate "Admin" %}
|
|
||||||
</a>
|
|
||||||
{% endif %}
|
|
||||||
{% if user.is_authenticated %}
|
|
||||||
<a class="nav-item" href="{% url "logout" %}" title="{% translate "Disconnect" %}"
|
|
||||||
aria-label="{% translate "Disconnect" %}">
|
|
||||||
<i class="fa fa-power-off"></i>
|
|
||||||
</a>
|
|
||||||
{% endif %}
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
{% if user.is_authenticated %}
|
||||||
|
{% include "./dashboard/nav.html" %}
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
|
@ -10,7 +10,7 @@ Context:
|
||||||
{% endcomment %}
|
{% endcomment %}
|
||||||
{% load aircox %}
|
{% load aircox %}
|
||||||
|
|
||||||
{% if field.is_hidden or hidden %}
|
{% if field.widget.is_hidden or hidden %}
|
||||||
<input type="hidden" name="{{ name }}" value="{{ value|default:"" }}">
|
<input type="hidden" name="{{ name }}" value="{{ value|default:"" }}">
|
||||||
{% elif field|is_checkbox %}
|
{% elif field|is_checkbox %}
|
||||||
<input type="checkbox" class="checkbox" name="{{ name }}" {% if value %}checked{% endif %}>
|
<input type="checkbox" class="checkbox" name="{{ name }}" {% if value %}checked{% endif %}>
|
||||||
|
|
|
@ -4,7 +4,10 @@ Base template for list editor based on formsets (tracklist_editor, playlist_edit
|
||||||
Context:
|
Context:
|
||||||
- tag_id: id of parent component
|
- tag_id: id of parent component
|
||||||
- tag: vue component tag (a-playlist-editor, etc.)
|
- tag: vue component tag (a-playlist-editor, etc.)
|
||||||
|
- related_field: field name that target object
|
||||||
|
- object: related object
|
||||||
- formset: formset used to render the list editor
|
- formset: formset used to render the list editor
|
||||||
|
- formset_data: formset data
|
||||||
{% endcomment %}
|
{% endcomment %}
|
||||||
|
|
||||||
{% load aircox aircox_admin static i18n %}
|
{% load aircox aircox_admin static i18n %}
|
||||||
|
@ -17,30 +20,14 @@ Context:
|
||||||
|
|
||||||
<{{ tag }}
|
<{{ tag }}
|
||||||
{% block tag-attrs %}
|
{% block tag-attrs %}
|
||||||
:labels="{% inline_labels %}"
|
:form-data="{{ formset_data|json }}"
|
||||||
|
:labels="window.aircox.labels"
|
||||||
:init-data="{% formset_inline_data formset=formset %}"
|
:init-data="{% formset_inline_data formset=formset %}"
|
||||||
:default-columns="[{% for f in fields.keys %}{% if f != "position" %}'{{ f }}',{% endif %}{% endfor %}]"
|
:columns="[{% for n, f in fields.items %}{% if not f.widget.is_hidden %}'{{ n }}',{% endif %}{% endfor %} ]"
|
||||||
settings-url="{% url "api:user-settings" %}"
|
settings-url="{% url "api:user-settings" %}"
|
||||||
data-prefix="{{ formset.prefix }}-"
|
data-prefix="{{ formset.prefix }}-"
|
||||||
{% endblock %}>
|
{% endblock %}>
|
||||||
{% block inner %}
|
{% block inner %}
|
||||||
<template #top="{items}">
|
|
||||||
{% block top %}
|
|
||||||
<input type="hidden" name="{{ formset.prefix }}-TOTAL_FORMS"
|
|
||||||
:value="items.length || 0"/>
|
|
||||||
<input type="hidden" name="{{ formset.prefix }}-INITIAL_FORMS"
|
|
||||||
{% if no_initial_form_count %}
|
|
||||||
:value="items.length || 0"
|
|
||||||
{% else %}
|
|
||||||
value="{{ formset.initial_form_count }}"
|
|
||||||
{% endif %}
|
|
||||||
/>
|
|
||||||
<input type="hidden" name="{{ formset.prefix }}-MIN_NUM_FORMS"
|
|
||||||
value="{{ formset.min_num }}"/>
|
|
||||||
<input type="hidden" name="{{ formset.prefix }}-MAX_NUM_FORMS"
|
|
||||||
value="{{ formset.max_num }}"/>
|
|
||||||
{% endblock %}
|
|
||||||
</template>
|
|
||||||
<template #rows-header-head>
|
<template #rows-header-head>
|
||||||
{% block rows-header-head %}
|
{% block rows-header-head %}
|
||||||
<th style="max-width:2em" title="{{ fields.position.help_text }}"
|
<th style="max-width:2em" title="{{ fields.position.help_text }}"
|
||||||
|
@ -51,42 +38,12 @@ Context:
|
||||||
</th>
|
</th>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
</template>
|
</template>
|
||||||
<template v-slot:row-head="{item,row}">
|
|
||||||
{% block row-head %}
|
|
||||||
<td>
|
|
||||||
[[ row+1 ]]
|
|
||||||
<input type="hidden"
|
|
||||||
:name="'{{ formset.prefix }}-' + row + '-position'"
|
|
||||||
:value="row"/>
|
|
||||||
<input t-if="item.data.id" type="hidden"
|
|
||||||
:name="'{{ formset.prefix }}-' + row + '-id'"
|
|
||||||
:value="item.data.id || item.id"/>
|
|
||||||
|
|
||||||
{% for name, field in fields.items %}
|
|
||||||
{% if name != 'position' and field.widget.is_hidden %}
|
|
||||||
<input type="hidden"
|
|
||||||
:name="'{{ formset.prefix }}-' + row + '-{{ name }}'"
|
|
||||||
v-model="item.data[attr]"/>
|
|
||||||
{% endif %}
|
|
||||||
{% endfor %}
|
|
||||||
</td>
|
|
||||||
{% endblock %}
|
|
||||||
</template>
|
|
||||||
{% for name, field in fields.items %}
|
{% for name, field in fields.items %}
|
||||||
{% if not field.widget.is_hidden and not field.is_readonly %}
|
{% if not field.widget.is_hidden and not field.is_readonly %}
|
||||||
<template v-slot:row-{{ name }}="{item,cell,value,attr,emit}">
|
<template v-slot:control-{{ name }}="{item,cell,value,attr,emit,inputName}">
|
||||||
<div class="field">
|
{% block row-control %}
|
||||||
{% with full_name="'"|add:formset.prefix|add:"-' + cell.row + '-"|add:name|add:"'" %}
|
{% include "./v_form_field.html" with value="item.data."|add:name name="inputName" %}
|
||||||
{% block row-field %}
|
{% endblock %}
|
||||||
<div class="control">
|
|
||||||
{% include "./v_form_field.html" with value="item.data."|add:name name=full_name %}
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
|
||||||
{% endwith %}
|
|
||||||
<p v-for="error in item.error(attr)" class="help is-danger">
|
|
||||||
[[ error ]] !
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|
27
aircox/templates/aircox/dashboard/nav.html
Normal file
27
aircox/templates/aircox/dashboard/nav.html
Normal file
|
@ -0,0 +1,27 @@
|
||||||
|
{% load i18n %}
|
||||||
|
<div class="dropdown is-hoverable is-right">
|
||||||
|
<div class="dropdown-trigger">
|
||||||
|
<button class="button square" aria-haspopup="true" aria-controls="dropdown-menu" type="button">
|
||||||
|
<span class="icon">
|
||||||
|
<i class="fa fa-user" aria-hidden="true"></i>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="dropdown-menu" id="dropdown-menu" role="menu" style="z-index:200">
|
||||||
|
<div class="dropdown-content">
|
||||||
|
{% block user-menu %}
|
||||||
|
{% endblock %}
|
||||||
|
{% if user.is_admin %}
|
||||||
|
{% block admin-menu %}
|
||||||
|
<a class="nav-item" href="{% url "admin:index" %}" target="new">
|
||||||
|
{% translate "Admin" %}
|
||||||
|
</a>
|
||||||
|
{% endblock %}
|
||||||
|
<hr class="dropdown-divider" />
|
||||||
|
{% endif %}
|
||||||
|
<a class="dropdown-item" href="{% url "logout" %}">
|
||||||
|
{% translate "Disconnect" %}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
|
@ -1,9 +1,13 @@
|
||||||
{% extends "./list_editor.html" %}
|
{% extends "./list_editor.html" %}
|
||||||
|
{% comment %}
|
||||||
|
Context:
|
||||||
|
- object: episode
|
||||||
|
{% endcomment %}
|
||||||
|
|
||||||
{% block outer %}
|
{% block outer %}
|
||||||
{% with no_initial_form_count=True %}
|
|
||||||
{% with tag_id="inline-sounds" %}
|
{% with tag_id="inline-sounds" %}
|
||||||
{% with tag="a-sound-list-editor" %}
|
{% with tag="a-sound-list-editor" %}
|
||||||
|
{% with related_field="episode" %}
|
||||||
{{ block.super }}
|
{{ block.super }}
|
||||||
{% endwith %}
|
{% endwith %}
|
||||||
{% endwith %}
|
{% endwith %}
|
||||||
|
@ -13,8 +17,9 @@
|
||||||
|
|
||||||
{% block tag-attrs %}
|
{% block tag-attrs %}
|
||||||
{{ block.super }}
|
{{ block.super }}
|
||||||
sound-list-url="{% url "api:sound-list" %}?program={{ object.pk }}&episode__isnull"
|
sound-list-url="{% url "api:sound-list" %}?program={{ object.parent_id }}"
|
||||||
sound-upload-url="{% url "api:sound-list" %}"
|
sound-upload-url="{% url "api:sound-list" %}"
|
||||||
|
sound-delete-url="{% url "api:sound-detail" pk=123 %}"
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block inner %}
|
{% block inner %}
|
||||||
|
@ -23,19 +28,15 @@ sound-upload-url="{% url "api:sound-list" %}"
|
||||||
<template #upload-form>
|
<template #upload-form>
|
||||||
{% for field in sound_form %}
|
{% for field in sound_form %}
|
||||||
{% with field.name as name %}
|
{% with field.name as name %}
|
||||||
{% with field.initial as value %}
|
{% if name in "program" %}
|
||||||
{% with field.field as field %}
|
{% include "./form_field.html" with value=field.initial field=field.field hidden=True %}
|
||||||
{% if name in "episode,program" %}
|
|
||||||
{% include "./form_field.html" with value=value hidden=True %}
|
|
||||||
{% elif name != "file" %}
|
{% elif name != "file" %}
|
||||||
<div class="field is-horizontal">
|
<div class="field is-horizontal">
|
||||||
<label class="label mr-3">{{ field.label }}</label>
|
<label class="label mr-3">{{ field.label }}</label>
|
||||||
{% include "./form_field.html" with value=value %}
|
{% include "./form_field.html" with value=field.initial field=field.field %}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endwith %}
|
{% endwith %}
|
||||||
{% endwith %}
|
|
||||||
{% endwith %}
|
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</template>
|
</template>
|
||||||
<template #row-delete="{cell}">
|
<template #row-delete="{cell}">
|
||||||
|
|
|
@ -4,9 +4,11 @@
|
||||||
{% block outer %}
|
{% block outer %}
|
||||||
{% with tag_id="inline-tracks" %}
|
{% with tag_id="inline-tracks" %}
|
||||||
{% with tag="a-track-list-editor" %}
|
{% with tag="a-track-list-editor" %}
|
||||||
|
{% with related_field="episode" %}
|
||||||
{{ block.super }}
|
{{ block.super }}
|
||||||
{% endwith %}
|
{% endwith %}
|
||||||
{% endwith %}
|
{% endwith %}
|
||||||
|
{% endwith %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block inner %}
|
{% block inner %}
|
||||||
|
@ -14,12 +16,19 @@
|
||||||
{{ block.super }}
|
{{ block.super }}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block row-field %}
|
{% block row-control %}
|
||||||
|
{% if name == "tags" %}
|
||||||
|
<input type="text" class="input"
|
||||||
|
:name="inputName"
|
||||||
|
v-model="item.data[attr]"
|
||||||
|
@change="emit('change', cell.col)"
|
||||||
|
>
|
||||||
|
{% else %}
|
||||||
<a-autocomplete
|
<a-autocomplete
|
||||||
:input-class="['input', item.error(attr) ? 'is-danger' : 'half-field']"
|
:input-class="['input', item.error(attr) ? 'is-danger' : 'half-field']"
|
||||||
url="{% url 'api:track-autocomplete' %}?{{ name }}=${query}&field={{ name }}"
|
url="{% url 'api:track-autocomplete' %}?{{ name }}=${query}&field={{ name }}"
|
||||||
:name="'{{ formset.prefix }}-' + cell.row + '-{{ name }}'"
|
:name="inputName"
|
||||||
v-model="item.data[attr]"
|
v-model="item.data[attr]"
|
||||||
title="{{ name }}"
|
@change="emit('change', cell.col)"/>
|
||||||
@change="emit('change', col)"/>
|
{% endif %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
@ -9,7 +9,7 @@ Context:
|
||||||
{% endcomment %}
|
{% endcomment %}
|
||||||
{% load aircox %}
|
{% load aircox %}
|
||||||
|
|
||||||
{% if field.is_hidden or hidden %}
|
{% if field.widget.is_hidden or hidden %}
|
||||||
<input type="hidden" :name="{{ name }}" :value="{{ value|default:"" }}">
|
<input type="hidden" :name="{{ name }}" :value="{{ value|default:"" }}">
|
||||||
{% elif field|is_checkbox %}
|
{% elif field|is_checkbox %}
|
||||||
<input type="checkbox" class="checkbox" :name="{{ name }}" v-model="{{ value }}">
|
<input type="checkbox" class="checkbox" :name="{{ name }}" v-model="{{ value }}">
|
||||||
|
|
|
@ -16,7 +16,7 @@
|
||||||
<a-playlist v-if="page" :set="podcasts"
|
<a-playlist v-if="page" :set="podcasts"
|
||||||
name="{{ page.title }}"
|
name="{{ page.title }}"
|
||||||
list-class="menu-list" item-class="menu-item"
|
list-class="menu-list" item-class="menu-item"
|
||||||
:player="player" :actions="['play']"
|
:player="player" :actions="['play', 'pin']"
|
||||||
@select="player.playItems('queue', $event.item)">
|
@select="player.playItems('queue', $event.item)">
|
||||||
</a-playlist>
|
</a-playlist>
|
||||||
</section>
|
</section>
|
||||||
|
|
|
@ -6,12 +6,10 @@
|
||||||
<template v-slot="{podcasts,page}">
|
<template v-slot="{podcasts,page}">
|
||||||
{{ block.super }}
|
{{ block.super }}
|
||||||
<hr/>
|
<hr/>
|
||||||
{% include "./dashboard/tracklist_editor.html" with formset=tracklist_formset %}
|
{% include "./dashboard/tracklist_editor.html" with formset=tracklist_formset formset_data=tracklist_formset_data %}
|
||||||
<hr/>
|
<hr/>
|
||||||
<section class="container">
|
<h3 class="title">{% translate "Podcasts" %}</h3>
|
||||||
<h3 class="title">{% translate "Podcasts" %}</h3>
|
{% include "./dashboard/soundlist_editor.html" with formset=soundlist_formset formset_data=soundlist_formset_data %}
|
||||||
{% include "./dashboard/soundlist_editor.html" with formset=soundlist_formset %}
|
|
||||||
</section>
|
|
||||||
</template>
|
</template>
|
||||||
</a-episode>
|
</a-episode>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
@ -1,59 +1,56 @@
|
||||||
{% extends "./page_detail.html" %}
|
{% extends "./page_detail.html" %}
|
||||||
{% load static i18n %}
|
{% load static aircox_admin i18n %}
|
||||||
|
|
||||||
{% block assets %}
|
{% block assets %}
|
||||||
{{ block.super }}
|
{{ block.super }}
|
||||||
<script src="{% static "aircox/js/dashboard.js" %}"></script>
|
<script src="{% static "aircox/js/dashboard.js" %}"></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block init-scripts %}
|
||||||
|
aircox.labels = {% inline_labels %}
|
||||||
|
{{ block.super }}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
{% block header-cover %}
|
{% block header-cover %}
|
||||||
<div class="flex-column">
|
<div class="flex-column">
|
||||||
<img src="{{ cover }}" ref="cover" class="cover">
|
<img src="{{ cover }}" ref="cover" class="cover">
|
||||||
<button type="button" class="button" @click="$refs['cover-modal'].open()">
|
<button type="button" class="button" @click="$refs['cover-select'].open()">
|
||||||
{% translate "Change cover" %}
|
{% translate "Change cover" %}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block content-container %}
|
{% block content-container %}
|
||||||
<a-modal ref="cover-modal" title="{% translate "Select an image" %}">
|
<a-select-file ref="cover-select"
|
||||||
<template #default>
|
:labels="window.aircox.labels"
|
||||||
<a-select-file list-url="{% url "api:image-list" %}" upload-url="{% url "api:image-list" %}"
|
list-url="{% url "api:image-list" %}"
|
||||||
list-class="grid-4"
|
upload-url="{% url "api:image-list" %}"
|
||||||
prev-label="{% translate "Show previous" %}"
|
delete-url="{% url "api:image-detail" pk=123 %}"
|
||||||
next-label="{% translate "Show next" %}"
|
title="{% translate "Select an image" %}" list-class="grid-4"
|
||||||
ref="cover-select"
|
@select="(event) => fileSelected('cover-select', 'cover-input', $refs.cover)"
|
||||||
>
|
>
|
||||||
<template #upload-preview="{upload}">
|
<template #upload-preview="{upload}">
|
||||||
<img :src="upload.fileURL" class="upload-preview blink"/>
|
<img :src="upload.fileURL" class="upload-preview blink"/>
|
||||||
</template>
|
|
||||||
<template #default="{item, load, lastUrl}">
|
|
||||||
<div class="flex-column is-fullheight" v-if="item">
|
|
||||||
<figure class="flex-grow-1">
|
|
||||||
<img :src="item.file"/>
|
|
||||||
</figure>
|
|
||||||
<div>
|
|
||||||
<label class="label small">[[ item.name || item.original_filename ]]</label>
|
|
||||||
<a-action-button
|
|
||||||
class="has-text-danger small float-right"
|
|
||||||
icon="fa fa-trash"
|
|
||||||
confirm="{% translate "Are you sure you want to remove this item from server?" %}"
|
|
||||||
method="DELETE"
|
|
||||||
:url="'{% url "api:image-detail" pk="123" %}'.replace('123', item.id)"
|
|
||||||
@done="load(lastUrl)">
|
|
||||||
</a-action-button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</a-select-file>
|
|
||||||
</template>
|
</template>
|
||||||
<template #footer>
|
<template #default="{item, load, lastUrl}">
|
||||||
<button type="button" class="button align-right"
|
<div class="flex-column is-fullheight" v-if="item">
|
||||||
@click="(event) => fileSelected('cover-select', 'cover', 'cover-input', 'cover-modal')">
|
<figure class="flex-grow-1">
|
||||||
{% translate "Select" %}
|
<img :src="item.file"/>
|
||||||
</button>
|
</figure>
|
||||||
|
<div>
|
||||||
|
<label class="label small">[[ item.name || item.original_filename ]]</label>
|
||||||
|
<a-action-button
|
||||||
|
class="has-text-danger small float-right"
|
||||||
|
icon="fa fa-trash"
|
||||||
|
confirm="{% translate "Are you sure you want to remove this item from server?" %}"
|
||||||
|
method="DELETE"
|
||||||
|
:url="'{% url "api:image-detail" pk="123" %}'.replace('123', item.id)"
|
||||||
|
@done="load(lastUrl)">
|
||||||
|
</a-action-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</a-modal>
|
</a-select-file>
|
||||||
|
|
||||||
<section class="container">
|
<section class="container">
|
||||||
<form method="post" enctype="multipart/form-data">
|
<form method="post" enctype="multipart/form-data">
|
||||||
|
@ -67,12 +64,12 @@
|
||||||
<label class="label">{{ field.label }}</label>
|
<label class="label">{{ field.label }}</label>
|
||||||
<div class="control clear-unset">
|
<div class="control clear-unset">
|
||||||
{% if field.name == "pub_date" %}
|
{% if field.name == "pub_date" %}
|
||||||
<input type="datetime-local" name="{{ field.name }}"
|
<input type="datetime-local" class="input" name="{{ field.name }}"
|
||||||
value="{{ field.value|date:"Y-m-d" }}T{{ field.value|date:"H:i" }}"/>
|
value="{{ field.value|date:"Y-m-d" }}T{{ field.value|date:"H:i" }}"/>
|
||||||
{% elif field.name == "content" %}
|
{% elif field.name == "content" %}
|
||||||
<textarea name="{{ field.name }}" class="is-fullwidth height-25">{{ field.value|striptags|safe }}</textarea>
|
<textarea name="{{ field.name }}" class="is-fullwidth height-25">{{ field.value|striptags|safe }}</textarea>
|
||||||
{% else %}
|
{% else %}
|
||||||
{{ field }}
|
{% include "./dashboard/form_field.html" with field=field.field name=field.name value=field.initial %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<p class="help">{{ field.help_text }}</p>
|
<p class="help">{{ field.help_text }}</p>
|
||||||
|
|
|
@ -3,6 +3,7 @@ import json
|
||||||
from django import template
|
from django import template
|
||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
from django.utils.safestring import mark_safe
|
||||||
|
|
||||||
from aircox.serializers.admin import UserSettingsSerializer
|
from aircox.serializers.admin import UserSettingsSerializer
|
||||||
|
|
||||||
|
@ -25,6 +26,7 @@ def do_formset_inline_data(context, formset):
|
||||||
- ``items``: list of items. Extra keys:
|
- ``items``: list of items. Extra keys:
|
||||||
- ``__error__``: dict of form fields errors
|
- ``__error__``: dict of form fields errors
|
||||||
- ``settings``: user's settings
|
- ``settings``: user's settings
|
||||||
|
- ``fields``: dict of field name and label
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# --- get fields labels
|
# --- get fields labels
|
||||||
|
@ -43,6 +45,9 @@ def do_formset_inline_data(context, formset):
|
||||||
# hack for sound list
|
# hack for sound list
|
||||||
if duration := item.get("duration"):
|
if duration := item.get("duration"):
|
||||||
item["duration"] = duration.strftime("%H:%M")
|
item["duration"] = duration.strftime("%H:%M")
|
||||||
|
if sound := getattr(form.instance, "sound"):
|
||||||
|
item["name"] = sound.name
|
||||||
|
fields["name"] = str(_("Sound")).capitalize()
|
||||||
|
|
||||||
# hack for playlist editor
|
# hack for playlist editor
|
||||||
tags = item.get("tags")
|
tags = item.get("tags")
|
||||||
|
@ -62,12 +67,20 @@ inline_labels_ = {
|
||||||
# list editor
|
# list editor
|
||||||
"add_item": _("Add an item"),
|
"add_item": _("Add an item"),
|
||||||
"remove_item": _("Remove"),
|
"remove_item": _("Remove"),
|
||||||
|
"settings": _("Settings"),
|
||||||
"save_settings": _("Save Settings"),
|
"save_settings": _("Save Settings"),
|
||||||
"discard_changes": _("Discard changes"),
|
"discard_changes": _("Discard changes"),
|
||||||
"select_file": _("Select a file"),
|
|
||||||
"submit": _("Submit"),
|
"submit": _("Submit"),
|
||||||
"delete": _("Delete"),
|
"delete": _("Delete"),
|
||||||
|
# select file
|
||||||
|
"upload": _("Upload"),
|
||||||
|
"list": _("List"),
|
||||||
|
"confirm_delete": _("Are you sure to remove this element from the server?"),
|
||||||
|
"show_next": _("Show next"),
|
||||||
|
"show_previous": _("Show previous"),
|
||||||
|
"select_file": _("Select a file"),
|
||||||
# track list
|
# track list
|
||||||
|
"text": _("Text"),
|
||||||
"columns": _("Columns"),
|
"columns": _("Columns"),
|
||||||
"timestamp": _("Timestamp"),
|
"timestamp": _("Timestamp"),
|
||||||
# sound list
|
# sound list
|
||||||
|
@ -78,4 +91,4 @@ inline_labels_ = {
|
||||||
@register.simple_tag(name="inline_labels")
|
@register.simple_tag(name="inline_labels")
|
||||||
def do_inline_labels():
|
def do_inline_labels():
|
||||||
"""Return labels for columns in playlist editor as dict."""
|
"""Return labels for columns in playlist editor as dict."""
|
||||||
return json.dumps({k: str(v) for k, v in inline_labels_.items()})
|
return mark_safe(json.dumps({k: str(v) for k, v in inline_labels_.items()}))
|
||||||
|
|
|
@ -131,25 +131,32 @@ def episode(episodes):
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def podcasts(episodes):
|
def sound(program):
|
||||||
items = []
|
return baker.make(models.Sound, file="tmp/test.wav", program=program)
|
||||||
for episode in episodes:
|
|
||||||
sounds = baker.prepare(
|
|
||||||
models.Sound,
|
|
||||||
episode=episode,
|
|
||||||
program=episode.program,
|
|
||||||
is_public=True,
|
|
||||||
_quantity=2,
|
|
||||||
)
|
|
||||||
for i, sound in enumerate(sounds):
|
|
||||||
sound.file = f"test_sound_{episode.pk}_{i}.mp3"
|
|
||||||
items += sounds
|
|
||||||
return items
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def sound(program):
|
def sounds(program):
|
||||||
return baker.make(models.Sound, file="tmp/test.wav", program=program)
|
objs = [
|
||||||
|
models.Sound(program=program, file=f"tmp/test-{i}.wav", broadcast=(i == 0), is_downloadable=(i == 1))
|
||||||
|
for i in range(0, 3)
|
||||||
|
]
|
||||||
|
models.Sound.objects.bulk_create(objs)
|
||||||
|
return objs
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def podcasts(episode, sounds):
|
||||||
|
objs = [
|
||||||
|
models.EpisodeSound(
|
||||||
|
episode=episode,
|
||||||
|
sound=sound,
|
||||||
|
broadcast=True,
|
||||||
|
)
|
||||||
|
for sound in sounds
|
||||||
|
]
|
||||||
|
models.EpisodeSound.objects.bulk_create(objs)
|
||||||
|
return objs
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
|
|
|
@ -1,14 +1,12 @@
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from datetime import timedelta
|
|
||||||
|
|
||||||
from django.conf import settings as conf
|
from django.conf import settings as conf
|
||||||
from django.utils import timezone as tz
|
|
||||||
|
|
||||||
from aircox import models
|
|
||||||
from aircox.controllers.sound_file import SoundFile
|
from aircox.controllers.sound_file import SoundFile
|
||||||
|
|
||||||
|
|
||||||
|
# FIXME: use from tests.models.sound
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def path_infos():
|
def path_infos():
|
||||||
return {
|
return {
|
||||||
|
@ -27,6 +25,7 @@ def path_infos():
|
||||||
"day": 2,
|
"day": 2,
|
||||||
"hour": 10,
|
"hour": 10,
|
||||||
"minute": 13,
|
"minute": 13,
|
||||||
|
"n": None,
|
||||||
"name": "Sample 2",
|
"name": "Sample 2",
|
||||||
},
|
},
|
||||||
"test/20220103_1_sample_3.mp3": {
|
"test/20220103_1_sample_3.mp3": {
|
||||||
|
@ -56,42 +55,25 @@ def sound_files(path_infos):
|
||||||
return {k: r for k, r in ((path, SoundFile(conf.MEDIA_ROOT + "/" + path)) for path in path_infos.keys())}
|
return {k: r for k, r in ((path, SoundFile(conf.MEDIA_ROOT + "/" + path)) for path in path_infos.keys())}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def sound_file(sound_files):
|
||||||
|
return next(sound_files.items())
|
||||||
|
|
||||||
|
|
||||||
def test_sound_path(sound_files):
|
def test_sound_path(sound_files):
|
||||||
for path, sound_file in sound_files.items():
|
for path, sound_file in sound_files.items():
|
||||||
assert path == sound_file.sound_path
|
assert path == sound_file.sound_path
|
||||||
|
|
||||||
|
|
||||||
def test_read_path(path_infos, sound_files):
|
class TestSoundFile:
|
||||||
for path, sound_file in sound_files.items():
|
def sound_path(self, sound_file):
|
||||||
expected = path_infos[path]
|
assert sound_file[0] == sound_file[1].sound_path
|
||||||
result = sound_file.read_path(path)
|
|
||||||
# remove None values
|
|
||||||
result = {k: v for k, v in result.items() if v is not None}
|
|
||||||
assert expected == result, "path: {}".format(path)
|
|
||||||
|
|
||||||
|
def sync(self):
|
||||||
|
raise NotImplementedError("test is not implemented")
|
||||||
|
|
||||||
def _setup_diff(program, info):
|
def create_episode_sound(self):
|
||||||
episode = models.Episode(program=program, title="test-episode")
|
raise NotImplementedError("test is not implemented")
|
||||||
at = tz.datetime(**{k: info[k] for k in ("year", "month", "day", "hour", "minute") if info.get(k)})
|
|
||||||
at = tz.make_aware(at)
|
|
||||||
diff = models.Diffusion(episode=episode, start=at, end=at + timedelta(hours=1))
|
|
||||||
episode.save()
|
|
||||||
diff.save()
|
|
||||||
return diff
|
|
||||||
|
|
||||||
|
def _on_delete(self):
|
||||||
@pytest.mark.django_db(transaction=True)
|
raise NotImplementedError("test is not implemented")
|
||||||
def test_find_episode(sound_files):
|
|
||||||
station = models.Station(name="test-station")
|
|
||||||
program = models.Program(station=station, title="test")
|
|
||||||
station.save()
|
|
||||||
program.save()
|
|
||||||
|
|
||||||
for path, sound_file in sound_files.items():
|
|
||||||
infos = sound_file.read_path(path)
|
|
||||||
diff = _setup_diff(program, infos)
|
|
||||||
sound = models.Sound(program=diff.program, file=path)
|
|
||||||
result = sound_file.find_episode(sound, infos)
|
|
||||||
assert diff.episode == result
|
|
||||||
|
|
||||||
# TODO: find_playlist, sync
|
|
||||||
|
|
|
@ -223,22 +223,19 @@ class TestSoundMonitor:
|
||||||
[
|
[
|
||||||
(("scan all programs...",), {}),
|
(("scan all programs...",), {}),
|
||||||
]
|
]
|
||||||
+ [
|
+ [((f"#{program.id} {program.title}",), {}) for program in programs]
|
||||||
((f"#{program.id} {program.title}",), {})
|
|
||||||
for program in programs
|
|
||||||
]
|
|
||||||
)
|
)
|
||||||
assert dirs == [program.abspath for program in programs]
|
assert dirs == [program.abspath for program in programs]
|
||||||
traces = tuple(
|
traces = tuple(
|
||||||
[
|
[
|
||||||
[
|
[
|
||||||
(
|
(
|
||||||
(program, settings.SOUND_ARCHIVES_SUBDIR),
|
(program, settings.SOUND_BROADCASTS_SUBDIR),
|
||||||
{"logger": logger, "type": Sound.TYPE_ARCHIVE},
|
{"logger": logger, "broadcast": True},
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
(program, settings.SOUND_EXCERPTS_SUBDIR),
|
(program, settings.SOUND_EXCERPTS_SUBDIR),
|
||||||
{"logger": logger, "type": Sound.TYPE_EXCERPT},
|
{"logger": logger, "broadcast": False},
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
for program in programs
|
for program in programs
|
||||||
|
@ -247,6 +244,7 @@ class TestSoundMonitor:
|
||||||
traces_flat = tuple([item for sublist in traces for item in sublist])
|
traces_flat = tuple([item for sublist in traces for item in sublist])
|
||||||
assert interface._traces("scan_for_program") == traces_flat
|
assert interface._traces("scan_for_program") == traces_flat
|
||||||
|
|
||||||
|
# TODO / FIXME
|
||||||
def broken_test_monitor(self, monitor, monitor_interfaces, logger):
|
def broken_test_monitor(self, monitor, monitor_interfaces, logger):
|
||||||
def sleep(*args, **kwargs):
|
def sleep(*args, **kwargs):
|
||||||
monitor.stop()
|
monitor.stop()
|
||||||
|
@ -260,6 +258,7 @@ class TestSoundMonitor:
|
||||||
assert observer
|
assert observer
|
||||||
schedules = observer._traces("schedule")
|
schedules = observer._traces("schedule")
|
||||||
for (handler, *_), kwargs in schedules:
|
for (handler, *_), kwargs in schedules:
|
||||||
|
breakpoint()
|
||||||
assert isinstance(handler, sound_monitor.MonitorHandler)
|
assert isinstance(handler, sound_monitor.MonitorHandler)
|
||||||
assert isinstance(handler.pool, futures.ThreadPoolExecutor)
|
assert isinstance(handler.pool, futures.ThreadPoolExecutor)
|
||||||
assert (handler.subdir, handler.type) in (
|
assert (handler.subdir, handler.type) in (
|
||||||
|
|
122
aircox/tests/models/test_sound.py
Normal file
122
aircox/tests/models/test_sound.py
Normal file
|
@ -0,0 +1,122 @@
|
||||||
|
from datetime import timedelta
|
||||||
|
import os
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.utils import timezone as tz
|
||||||
|
|
||||||
|
from aircox import models
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def path_infos():
|
||||||
|
return {
|
||||||
|
"test/20220101_10h13_1_sample_1.mp3": {
|
||||||
|
"year": 2022,
|
||||||
|
"month": 1,
|
||||||
|
"day": 1,
|
||||||
|
"hour": 10,
|
||||||
|
"minute": 13,
|
||||||
|
"n": 1,
|
||||||
|
"name": "Sample 1",
|
||||||
|
},
|
||||||
|
"test/20220102_10h13_sample_2.mp3": {
|
||||||
|
"year": 2022,
|
||||||
|
"month": 1,
|
||||||
|
"day": 2,
|
||||||
|
"hour": 10,
|
||||||
|
"minute": 13,
|
||||||
|
"n": None,
|
||||||
|
"name": "Sample 2",
|
||||||
|
},
|
||||||
|
"test/20220103_1_sample_3.mp3": {
|
||||||
|
"year": 2022,
|
||||||
|
"month": 1,
|
||||||
|
"day": 3,
|
||||||
|
"hour": None,
|
||||||
|
"minute": None,
|
||||||
|
"n": 1,
|
||||||
|
"name": "Sample 3",
|
||||||
|
},
|
||||||
|
"test/20220104_sample_4.mp3": {
|
||||||
|
"year": 2022,
|
||||||
|
"month": 1,
|
||||||
|
"day": 4,
|
||||||
|
"hour": None,
|
||||||
|
"minute": None,
|
||||||
|
"n": None,
|
||||||
|
"name": "Sample 4",
|
||||||
|
},
|
||||||
|
"test/20220105.mp3": {
|
||||||
|
"year": 2022,
|
||||||
|
"month": 1,
|
||||||
|
"day": 5,
|
||||||
|
"hour": None,
|
||||||
|
"minute": None,
|
||||||
|
"n": None,
|
||||||
|
"name": "20220105",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class TestSoundQuerySet:
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_downloadable(self, sounds):
|
||||||
|
query = models.Sound.objects.downloadable().values_list("is_downloadable", flat=True)
|
||||||
|
assert set(query) == {True}
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_broadcast(self, sounds):
|
||||||
|
query = models.Sound.objects.broadcast().values_list("broadcast", flat=True)
|
||||||
|
assert set(query) == {True}
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_playlist(self, sounds):
|
||||||
|
expected = [os.path.join(settings.MEDIA_ROOT, s.file.path) for s in sounds]
|
||||||
|
assert models.Sound.objects.all().playlist() == expected
|
||||||
|
|
||||||
|
|
||||||
|
class TestSound:
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_read_path(self, path_infos):
|
||||||
|
for path, expected in path_infos.items():
|
||||||
|
result = models.Sound.read_path(path)
|
||||||
|
assert expected == result
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test__as_name(self):
|
||||||
|
name = "some_1_file"
|
||||||
|
assert models.Sound._as_name(name) == "Some 1 File"
|
||||||
|
|
||||||
|
def _setup_diff(self, program, info):
|
||||||
|
episode = models.Episode(program=program, title="test-episode")
|
||||||
|
at = tz.datetime(**{k: info[k] for k in ("year", "month", "day", "hour", "minute") if info.get(k)})
|
||||||
|
at = tz.make_aware(at)
|
||||||
|
diff = models.Diffusion(episode=episode, start=at, end=at + timedelta(hours=1))
|
||||||
|
episode.save()
|
||||||
|
diff.save()
|
||||||
|
return diff
|
||||||
|
|
||||||
|
@pytest.mark.django_db(transaction=True)
|
||||||
|
def test_find_episode(self, program, path_infos):
|
||||||
|
for path, infos in path_infos.items():
|
||||||
|
diff = self._setup_diff(program, infos)
|
||||||
|
sound = models.Sound(program=diff.program, file=path)
|
||||||
|
result = sound.find_episode(infos)
|
||||||
|
assert diff.episode == result
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_find_playlist(self):
|
||||||
|
raise NotImplementedError("test is not implemented")
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_get_upload_dir(self):
|
||||||
|
raise NotImplementedError("test is not implemented")
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_sync_fs(self):
|
||||||
|
raise NotImplementedError("test is not implemented")
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_read_metadata(self):
|
||||||
|
raise NotImplementedError("test is not implemented")
|
|
@ -1,10 +1,8 @@
|
||||||
|
# FIXME: this should be cleaner
|
||||||
from itertools import chain
|
from itertools import chain
|
||||||
import json
|
import json
|
||||||
import pytest
|
import pytest
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
|
||||||
|
|
||||||
from aircox.models import Program
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db()
|
@pytest.mark.django_db()
|
||||||
|
@ -22,20 +20,6 @@ def test_edit_program(user, client, program):
|
||||||
assert b"foobar" in response.content
|
assert b"foobar" in response.content
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db()
|
|
||||||
def test_add_cover(user, client, program, png_content):
|
|
||||||
assert program.cover is None
|
|
||||||
user.groups.add(program.editors)
|
|
||||||
client.force_login(user)
|
|
||||||
cover = SimpleUploadedFile("cover1.png", png_content, content_type="image/png")
|
|
||||||
r = client.post(
|
|
||||||
reverse("program-edit", kwargs={"pk": program.pk}), {"content": "foobar", "new_cover": cover}, follow=True
|
|
||||||
)
|
|
||||||
assert r.status_code == 200
|
|
||||||
p = Program.objects.get(pk=program.pk)
|
|
||||||
assert "cover1.png" in p.cover.url
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db()
|
@pytest.mark.django_db()
|
||||||
def test_edit_tracklist(user, client, program, episode, tracks):
|
def test_edit_tracklist(user, client, program, episode, tracks):
|
||||||
user.groups.add(program.editors)
|
user.groups.add(program.editors)
|
||||||
|
|
|
@ -11,6 +11,9 @@ class FakeView:
|
||||||
def ___init__(self):
|
def ___init__(self):
|
||||||
self.kwargs = {}
|
self.kwargs = {}
|
||||||
|
|
||||||
|
def dispatch(self, *args, **kwargs):
|
||||||
|
pass
|
||||||
|
|
||||||
def get(self, *args, **kwargs):
|
def get(self, *args, **kwargs):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
|
@ -43,7 +43,6 @@ class TestBaseView:
|
||||||
"view": base_view,
|
"view": base_view,
|
||||||
"station": station,
|
"station": station,
|
||||||
"page": None, # get_page() returns None
|
"page": None, # get_page() returns None
|
||||||
"audio_streams": station.streams,
|
|
||||||
"model": base_view.model,
|
"model": base_view.model,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -40,7 +40,7 @@ def parent_mixin():
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def attach_mixin():
|
def attach_mixin():
|
||||||
class Mixin(mixins.AttachedToMixin, FakeView):
|
class Mixin(mixins.AttachedToMixin, FakeView):
|
||||||
attach_to_value = models.StaticPage.ATTACH_TO_HOME
|
attach_to_value = models.StaticPage.Target.HOME
|
||||||
|
|
||||||
return Mixin()
|
return Mixin()
|
||||||
|
|
||||||
|
@ -105,10 +105,10 @@ class TestParentMixin:
|
||||||
def test_get_parent_not_parent_url_kwargs(self, parent_mixin):
|
def test_get_parent_not_parent_url_kwargs(self, parent_mixin):
|
||||||
assert parent_mixin.get_parent(self.req) is None
|
assert parent_mixin.get_parent(self.req) is None
|
||||||
|
|
||||||
def test_get_calls_parent(self, parent_mixin):
|
def test_dispatch_calls_parent(self, parent_mixin):
|
||||||
parent = "parent object"
|
parent = "parent object"
|
||||||
parent_mixin.get_parent = lambda *_, **kw: parent
|
parent_mixin.get_parent = lambda *_, **kw: parent
|
||||||
parent_mixin.get(self.req)
|
parent_mixin.dispatch(self.req)
|
||||||
assert parent_mixin.parent == parent
|
assert parent_mixin.parent == parent
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
|
@ -120,7 +120,7 @@ class TestParentMixin:
|
||||||
assert set(query) == episodes_id
|
assert set(query) == episodes_id
|
||||||
|
|
||||||
def test_get_context_data_with_parent(self, parent_mixin):
|
def test_get_context_data_with_parent(self, parent_mixin):
|
||||||
parent_mixin.parent = Interface(cover="parent-cover")
|
parent_mixin.parent = Interface(cover=Interface(url="parent-cover"))
|
||||||
context = parent_mixin.get_context_data()
|
context = parent_mixin.get_context_data()
|
||||||
assert context["cover"] == "parent-cover"
|
assert context["cover"] == "parent-cover"
|
||||||
|
|
||||||
|
|
|
@ -19,7 +19,7 @@ class AdminMixin(LoginRequiredMixin, UserPassesTestMixin):
|
||||||
return self.request.station
|
return self.request.station
|
||||||
|
|
||||||
def test_func(self):
|
def test_func(self):
|
||||||
return self.request.user.is_staff
|
return self.request.user.is_admin
|
||||||
|
|
||||||
def get_context_data(self, **kwargs):
|
def get_context_data(self, **kwargs):
|
||||||
kwargs.update(admin.site.each_context(self.request))
|
kwargs.update(admin.site.each_context(self.request))
|
||||||
|
|
|
@ -7,7 +7,6 @@ __all__ = ("BaseView", "BaseAPIView")
|
||||||
|
|
||||||
|
|
||||||
class BaseView(TemplateResponseMixin, ContextMixin):
|
class BaseView(TemplateResponseMixin, ContextMixin):
|
||||||
header_template_name = "aircox/widgets/header.html"
|
|
||||||
related_count = 4
|
related_count = 4
|
||||||
related_carousel_count = 8
|
related_carousel_count = 8
|
||||||
|
|
||||||
|
@ -50,8 +49,8 @@ class BaseView(TemplateResponseMixin, ContextMixin):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def get_context_data(self, **kwargs):
|
def get_context_data(self, **kwargs):
|
||||||
|
kwargs.setdefault("station", self.station)
|
||||||
kwargs.setdefault("page", self.get_page())
|
kwargs.setdefault("page", self.get_page())
|
||||||
kwargs.setdefault("header_template_name", self.header_template_name)
|
|
||||||
|
|
||||||
if "model" not in kwargs:
|
if "model" not in kwargs:
|
||||||
model = getattr(self, "model", None) or hasattr(self, "object") and type(self.object)
|
model = getattr(self, "model", None) or hasattr(self, "object") and type(self.object)
|
||||||
|
|
|
@ -1,9 +1,10 @@
|
||||||
from django.contrib.auth.mixins import UserPassesTestMixin
|
from django.contrib.auth.mixins import UserPassesTestMixin
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
|
||||||
from aircox.models import Episode, Program, StaticPage, Sound, Track
|
from aircox.models import Episode, Program, StaticPage, Track
|
||||||
from aircox import forms
|
from aircox import forms, filters
|
||||||
from ..filters import EpisodeFilters
|
|
||||||
|
from .mixins import VueFormDataMixin
|
||||||
from .page import PageListView
|
from .page import PageListView
|
||||||
from .program import ProgramPageDetailView, BaseProgramMixin
|
from .program import ProgramPageDetailView, BaseProgramMixin
|
||||||
from .page import PageUpdateView
|
from .page import PageUpdateView
|
||||||
|
@ -36,7 +37,7 @@ class EpisodeDetailView(ProgramPageDetailView):
|
||||||
|
|
||||||
class EpisodeListView(PageListView):
|
class EpisodeListView(PageListView):
|
||||||
model = Episode
|
model = Episode
|
||||||
filterset_class = EpisodeFilters
|
filterset_class = filters.EpisodeFilters
|
||||||
parent_model = Program
|
parent_model = Program
|
||||||
attach_to_value = StaticPage.Target.EPISODES
|
attach_to_value = StaticPage.Target.EPISODES
|
||||||
|
|
||||||
|
@ -46,7 +47,7 @@ class PodcastListView(EpisodeListView):
|
||||||
queryset = Episode.objects.published().with_podcasts().order_by("-pub_date")
|
queryset = Episode.objects.published().with_podcasts().order_by("-pub_date")
|
||||||
|
|
||||||
|
|
||||||
class EpisodeUpdateView(UserPassesTestMixin, BaseProgramMixin, PageUpdateView):
|
class EpisodeUpdateView(UserPassesTestMixin, VueFormDataMixin, BaseProgramMixin, PageUpdateView):
|
||||||
model = Episode
|
model = Episode
|
||||||
form_class = forms.EpisodeForm
|
form_class = forms.EpisodeForm
|
||||||
template_name = "aircox/episode_form.html"
|
template_name = "aircox/episode_form.html"
|
||||||
|
@ -63,38 +64,39 @@ class EpisodeUpdateView(UserPassesTestMixin, BaseProgramMixin, PageUpdateView):
|
||||||
{
|
{
|
||||||
"prefix": "tracks",
|
"prefix": "tracks",
|
||||||
"queryset": self.get_tracklist_queryset(episode),
|
"queryset": self.get_tracklist_queryset(episode),
|
||||||
"initial": {
|
"initial": [
|
||||||
"episode": episode.id,
|
{
|
||||||
},
|
"episode": episode.id,
|
||||||
|
}
|
||||||
|
],
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
return forms.TrackFormSet(**kwargs)
|
return forms.TrackFormSet(**kwargs)
|
||||||
|
|
||||||
def get_soundlist_queryset(self, episode):
|
def get_soundlist_queryset(self, episode):
|
||||||
return episode.sound_set.all().order_by("position")
|
return episode.episodesound_set.all().select_related("sound").order_by("position")
|
||||||
|
|
||||||
def get_soundlist_formset(self, episode, **kwargs):
|
def get_soundlist_formset(self, episode, **kwargs):
|
||||||
kwargs.update(
|
kwargs.update(
|
||||||
{
|
{
|
||||||
"prefix": "sounds",
|
"prefix": "sounds",
|
||||||
"queryset": self.get_soundlist_queryset(episode),
|
"queryset": self.get_soundlist_queryset(episode),
|
||||||
"initial": {
|
"initial": [
|
||||||
"program": episode.parent_id,
|
{
|
||||||
"episode": episode.id,
|
"episode": episode.id,
|
||||||
},
|
}
|
||||||
|
],
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
return forms.SoundFormSet(**kwargs)
|
return forms.EpisodeSoundFormSet(**kwargs)
|
||||||
|
|
||||||
def get_sound_form(self, episode, **kwargs):
|
def get_sound_form(self, episode, **kwargs):
|
||||||
kwargs.update(
|
kwargs.update(
|
||||||
{
|
{
|
||||||
"initial": {
|
"initial": {
|
||||||
"program": episode.parent_id,
|
"program": episode.parent_id,
|
||||||
"episode": episode.pk,
|
|
||||||
"name": episode.title,
|
"name": episode.title,
|
||||||
"is_public": True,
|
"is_public": True,
|
||||||
"type": Sound.TYPE_ARCHIVE,
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
@ -109,6 +111,10 @@ class EpisodeUpdateView(UserPassesTestMixin, BaseProgramMixin, PageUpdateView):
|
||||||
for key, func in forms:
|
for key, func in forms:
|
||||||
if key not in kwargs:
|
if key not in kwargs:
|
||||||
kwargs[key] = func(self.object)
|
kwargs[key] = func(self.object)
|
||||||
|
|
||||||
|
for key in ("soundlist_formset", "tracklist_formset"):
|
||||||
|
formset = kwargs[key]
|
||||||
|
kwargs[f"{key}_data"] = self.get_formset_data(formset, {"episode": self.object.id})
|
||||||
return super().get_context_data(**kwargs)
|
return super().get_context_data(**kwargs)
|
||||||
|
|
||||||
def post(self, request, *args, **kwargs):
|
def post(self, request, *args, **kwargs):
|
||||||
|
|
|
@ -108,3 +108,37 @@ class FiltersMixin:
|
||||||
params = self.request.GET.copy()
|
params = self.request.GET.copy()
|
||||||
kwargs["get_params"] = params.pop("page", True) and params
|
kwargs["get_params"] = params.pop("page", True) and params
|
||||||
return super().get_context_data(**kwargs)
|
return super().get_context_data(**kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
class VueFormDataMixin:
|
||||||
|
"""Provide form information as data to be used with vue components."""
|
||||||
|
|
||||||
|
# Note: values corresponds to AFormSet expected one
|
||||||
|
|
||||||
|
def get_form_field_data(self, form, values=None):
|
||||||
|
"""Return form fields as data."""
|
||||||
|
model = form.Meta.model
|
||||||
|
fields = ((name, field, model._meta.get_field(name)) for name, field in form.base_fields.items())
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"name": name,
|
||||||
|
"label": str(m_field.verbose_name).capitalize(),
|
||||||
|
"help": str(m_field.help_text).capitalize(),
|
||||||
|
"hidden": field.widget.is_hidden,
|
||||||
|
"value": values and values.get(name),
|
||||||
|
}
|
||||||
|
for name, field, m_field in fields
|
||||||
|
]
|
||||||
|
|
||||||
|
def get_formset_data(self, formset, field_values=None, **kwargs):
|
||||||
|
"""Return formset as data object."""
|
||||||
|
return {
|
||||||
|
"prefix": formset.prefix,
|
||||||
|
"management": {
|
||||||
|
"initial_forms": formset.initial_form_count(),
|
||||||
|
"min_num_forms": formset.min_num,
|
||||||
|
"max_num_forms": formset.max_num,
|
||||||
|
},
|
||||||
|
"fields": self.get_form_field_data(formset.form, field_values),
|
||||||
|
**kwargs,
|
||||||
|
}
|
||||||
|
|
|
@ -5,8 +5,7 @@ from rest_framework.response import Response
|
||||||
|
|
||||||
from filer.models.imagemodels import Image
|
from filer.models.imagemodels import Image
|
||||||
|
|
||||||
from . import models, forms, filters
|
from . import models, forms, filters, serializers
|
||||||
from .serializers import SoundSerializer, admin
|
|
||||||
from .views import BaseAPIView
|
from .views import BaseAPIView
|
||||||
|
|
||||||
__all__ = (
|
__all__ = (
|
||||||
|
@ -19,7 +18,8 @@ __all__ = (
|
||||||
|
|
||||||
class ImageViewSet(viewsets.ModelViewSet):
|
class ImageViewSet(viewsets.ModelViewSet):
|
||||||
parsers = (parsers.MultiPartParser,)
|
parsers = (parsers.MultiPartParser,)
|
||||||
serializer_class = admin.ImageSerializer
|
permissions = (permissions.IsAuthenticatedOrReadOnly,)
|
||||||
|
serializer_class = serializers.admin.ImageSerializer
|
||||||
queryset = Image.objects.all().order_by("-uploaded_at")
|
queryset = Image.objects.all().order_by("-uploaded_at")
|
||||||
filter_backends = (drf_filters.DjangoFilterBackend,)
|
filter_backends = (drf_filters.DjangoFilterBackend,)
|
||||||
filterset_class = filters.ImageFilterSet
|
filterset_class = filters.ImageFilterSet
|
||||||
|
@ -37,8 +37,8 @@ class ImageViewSet(viewsets.ModelViewSet):
|
||||||
class SoundViewSet(BaseAPIView, viewsets.ModelViewSet):
|
class SoundViewSet(BaseAPIView, viewsets.ModelViewSet):
|
||||||
parsers = (parsers.MultiPartParser,)
|
parsers = (parsers.MultiPartParser,)
|
||||||
permissions = (permissions.IsAuthenticatedOrReadOnly,)
|
permissions = (permissions.IsAuthenticatedOrReadOnly,)
|
||||||
serializer_class = SoundSerializer
|
serializer_class = serializers.SoundSerializer
|
||||||
queryset = models.Sound.objects.available().order_by("-pk")
|
queryset = models.Sound.objects.order_by("-pk")
|
||||||
filter_backends = (drf_filters.DjangoFilterBackend,)
|
filter_backends = (drf_filters.DjangoFilterBackend,)
|
||||||
filterset_class = filters.SoundFilterSet
|
filterset_class = filters.SoundFilterSet
|
||||||
|
|
||||||
|
@ -48,11 +48,17 @@ class SoundViewSet(BaseAPIView, viewsets.ModelViewSet):
|
||||||
# -> file is saved to fs after object is saved to db
|
# -> file is saved to fs after object is saved to db
|
||||||
obj.save()
|
obj.save()
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
query = super().get_queryset()
|
||||||
|
if not self.request.user.is_authenticated:
|
||||||
|
return query.available()
|
||||||
|
return query
|
||||||
|
|
||||||
|
|
||||||
class TrackROViewSet(viewsets.ReadOnlyModelViewSet):
|
class TrackROViewSet(viewsets.ReadOnlyModelViewSet):
|
||||||
"""Track viewset used for auto completion."""
|
"""Track viewset used for auto completion."""
|
||||||
|
|
||||||
serializer_class = admin.TrackSerializer
|
serializer_class = serializers.admin.TrackSerializer
|
||||||
permission_classes = (permissions.IsAuthenticated,)
|
permission_classes = (permissions.IsAuthenticated,)
|
||||||
filter_backends = (drf_filters.DjangoFilterBackend,)
|
filter_backends = (drf_filters.DjangoFilterBackend,)
|
||||||
filterset_class = filters.TrackFilterSet
|
filterset_class = filters.TrackFilterSet
|
||||||
|
@ -75,7 +81,7 @@ class UserSettingsViewSet(viewsets.ViewSet):
|
||||||
Allow only to create and edit user's own settings.
|
Allow only to create and edit user's own settings.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
serializer_class = admin.UserSettingsSerializer
|
serializer_class = serializers.admin.UserSettingsSerializer
|
||||||
permission_classes = (permissions.IsAuthenticated,)
|
permission_classes = (permissions.IsAuthenticated,)
|
||||||
|
|
||||||
def get_serializer(self, instance=None, **kwargs):
|
def get_serializer(self, instance=None, **kwargs):
|
||||||
|
|
|
@ -77,9 +77,12 @@ class Metadata:
|
||||||
air_time = tz.datetime.strptime(air_time, "%Y/%m/%d %H:%M:%S")
|
air_time = tz.datetime.strptime(air_time, "%Y/%m/%d %H:%M:%S")
|
||||||
return local_tz.localize(air_time)
|
return local_tz.localize(air_time)
|
||||||
|
|
||||||
def validate(self, data):
|
def validate(self, data, as_dict=False):
|
||||||
"""Validate provided data and set as attribute (must already be
|
"""Validate provided data and set as attribute (must already be
|
||||||
declared)"""
|
declared)"""
|
||||||
|
if as_dict and isinstance(data, list):
|
||||||
|
data = {v[0]: v[1] for v in data}
|
||||||
|
|
||||||
for key, value in data.items():
|
for key, value in data.items():
|
||||||
if hasattr(self, key) and not callable(getattr(self, key)):
|
if hasattr(self, key) and not callable(getattr(self, key)):
|
||||||
setattr(self, key, value)
|
setattr(self, key, value)
|
||||||
|
|
|
@ -133,8 +133,10 @@ class Monitor:
|
||||||
# get sound
|
# get sound
|
||||||
diff = None
|
diff = None
|
||||||
sound = Sound.objects.path(air_uri).first()
|
sound = Sound.objects.path(air_uri).first()
|
||||||
if sound and sound.episode_id is not None:
|
if sound:
|
||||||
diff = Diffusion.objects.episode(id=sound.episode_id).on_air().now(air_time).first()
|
ids = sound.episodesound_set.values_list("episode_id", flat=True)
|
||||||
|
if ids:
|
||||||
|
diff = Diffusion.objects.filter(episode_id__in=ids).on_air().now(air_time).first()
|
||||||
|
|
||||||
# log sound on air
|
# log sound on air
|
||||||
return self.log(
|
return self.log(
|
||||||
|
@ -198,7 +200,7 @@ class Monitor:
|
||||||
Diffusion.objects.station(self.station)
|
Diffusion.objects.station(self.station)
|
||||||
.on_air()
|
.on_air()
|
||||||
.now(now)
|
.now(now)
|
||||||
.filter(episode__sound__type=Sound.TYPE_ARCHIVE)
|
.filter(episode__episodesound__broadcast=True)
|
||||||
.first()
|
.first()
|
||||||
)
|
)
|
||||||
# Can't use delay: diffusion may start later than its assigned start.
|
# Can't use delay: diffusion may start later than its assigned start.
|
||||||
|
@ -227,7 +229,7 @@ class Monitor:
|
||||||
return log
|
return log
|
||||||
|
|
||||||
def start_diff(self, source, diff):
|
def start_diff(self, source, diff):
|
||||||
playlist = Sound.objects.episode(id=diff.episode_id).playlist()
|
playlist = diff.episode.episodesound_set.all().broadcast().playlist()
|
||||||
source.push(*playlist)
|
source.push(*playlist)
|
||||||
self.log(
|
self.log(
|
||||||
type=Log.TYPE_START,
|
type=Log.TYPE_START,
|
||||||
|
|
|
@ -43,9 +43,9 @@ class Source(Metadata):
|
||||||
except ValueError:
|
except ValueError:
|
||||||
self.remaining = None
|
self.remaining = None
|
||||||
|
|
||||||
data = self.controller.send(self.id, ".get", parse=True)
|
data = self.controller.send(f"var.get {self.id}_meta", parse_json=True)
|
||||||
if data:
|
if data:
|
||||||
self.validate(data if data and isinstance(data, dict) else {})
|
self.validate(data if data and isinstance(data, (dict, list)) else {}, as_dict=True)
|
||||||
|
|
||||||
def skip(self):
|
def skip(self):
|
||||||
"""Skip the current source sound."""
|
"""Skip the current source sound."""
|
||||||
|
@ -80,7 +80,7 @@ class PlaylistSource(Source):
|
||||||
|
|
||||||
def get_sound_queryset(self):
|
def get_sound_queryset(self):
|
||||||
"""Get playlist's sounds queryset."""
|
"""Get playlist's sounds queryset."""
|
||||||
return self.program.sound_set.archive()
|
return self.program.sound_set.broadcast()
|
||||||
|
|
||||||
def get_playlist(self):
|
def get_playlist(self):
|
||||||
"""Get playlist from db."""
|
"""Get playlist from db."""
|
||||||
|
|
|
@ -8,8 +8,7 @@ import subprocess
|
||||||
import psutil
|
import psutil
|
||||||
from django.template.loader import render_to_string
|
from django.template.loader import render_to_string
|
||||||
|
|
||||||
from aircox.conf import settings
|
from ..conf import settings
|
||||||
|
|
||||||
from ..connector import Connector
|
from ..connector import Connector
|
||||||
from .sources import PlaylistSource, QueueSource
|
from .sources import PlaylistSource, QueueSource
|
||||||
|
|
||||||
|
@ -46,8 +45,8 @@ class Streamer:
|
||||||
self.outputs = self.station.port_set.active().output()
|
self.outputs = self.station.port_set.active().output()
|
||||||
|
|
||||||
self.id = self.station.slug.replace("-", "_")
|
self.id = self.station.slug.replace("-", "_")
|
||||||
self.path = os.path.join(station.path, "station.liq")
|
self.path = settings.get_dir(station, "station.liq")
|
||||||
self.connector = connector or Connector(os.path.join(station.path, "station.sock"))
|
self.connector = connector or Connector(settings.get_dir(station, "station.sock"))
|
||||||
self.init_sources()
|
self.init_sources()
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
@ -98,7 +97,6 @@ class Streamer:
|
||||||
{
|
{
|
||||||
"station": self.station,
|
"station": self.station,
|
||||||
"streamer": self,
|
"streamer": self,
|
||||||
"settings": settings,
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
data = re.sub("[\t ]+\n", "\n", data)
|
data = re.sub("[\t ]+\n", "\n", data)
|
||||||
|
|
|
@ -19,7 +19,7 @@ from aircox_streamer.controllers import Monitor, Streamer
|
||||||
|
|
||||||
|
|
||||||
# force using UTC
|
# force using UTC
|
||||||
tz.activate(timezone.UTC)
|
tz.activate(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
class Command(BaseCommand):
|
class Command(BaseCommand):
|
||||||
|
|
|
@ -10,9 +10,9 @@ Base liquidsoap station configuration.
|
||||||
|
|
||||||
{% block functions %}
|
{% block functions %}
|
||||||
{# Seek function #}
|
{# Seek function #}
|
||||||
def seek(source, t) =
|
def seek(s, t) =
|
||||||
t = float_of_string(default=0.,t)
|
t = float_of_string(default=0.,t)
|
||||||
ret = source.seek(source,t)
|
ret = source.seek(s,t)
|
||||||
log("seek #{ret} seconds.")
|
log("seek #{ret} seconds.")
|
||||||
"#{ret}"
|
"#{ret}"
|
||||||
end
|
end
|
||||||
|
@ -30,6 +30,17 @@ def to_stream(live, stream)
|
||||||
add(normalize=false, [live,stream])
|
add(normalize=false, [live,stream])
|
||||||
end
|
end
|
||||||
|
|
||||||
|
{# Skip command #}
|
||||||
|
def add_skip_command(s) =
|
||||||
|
def skip(_) =
|
||||||
|
source.skip(s)
|
||||||
|
"Done!"
|
||||||
|
end
|
||||||
|
server.register(namespace="#{source.id(s)}",
|
||||||
|
usage="skip",
|
||||||
|
description="Skip the current song.",
|
||||||
|
"skip",skip)
|
||||||
|
end
|
||||||
|
|
||||||
{% comment %}
|
{% comment %}
|
||||||
An interactive source is a source that:
|
An interactive source is a source that:
|
||||||
|
@ -45,10 +56,14 @@ def interactive (id, s) =
|
||||||
server.register(namespace=id,
|
server.register(namespace=id,
|
||||||
description="Get source's track remaining time",
|
description="Get source's track remaining time",
|
||||||
usage="remaining",
|
usage="remaining",
|
||||||
"remaining", fun (_) -> begin json_of(source.remaining(s)) end)
|
"remaining", fun (_) -> begin json.stringify(source.remaining(s)) end)
|
||||||
|
|
||||||
s = store_metadata(id=id, size=1, s)
|
|
||||||
add_skip_command(s)
|
add_skip_command(s)
|
||||||
|
|
||||||
|
{# metadata: create an interactive variable as "{id}_meta" #}
|
||||||
|
s_meta = interactive.string("#{id}_meta", "")
|
||||||
|
s = source.on_metadata(s, fun(meta) -> s_meta.set(json.stringify(meta)))
|
||||||
|
|
||||||
s
|
s
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -66,9 +81,6 @@ end
|
||||||
set("server.socket", true)
|
set("server.socket", true)
|
||||||
set("server.socket.path", "{{ streamer.socket_path }}")
|
set("server.socket.path", "{{ streamer.socket_path }}")
|
||||||
set("log.file.path", "{{ station.path }}/liquidsoap.log")
|
set("log.file.path", "{{ station.path }}/liquidsoap.log")
|
||||||
{% for key, value in settings.AIRCOX_LIQUIDSOAP_SET.items %}
|
|
||||||
set("{{ key|safe }}", {{ value|safe }})
|
|
||||||
{% endfor %}
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block config_extras %}
|
{% block config_extras %}
|
||||||
|
|
|
@ -146,24 +146,28 @@ def episode(program):
|
||||||
def sound(program, episode):
|
def sound(program, episode):
|
||||||
sound = models.Sound(
|
sound = models.Sound(
|
||||||
program=program,
|
program=program,
|
||||||
episode=episode,
|
|
||||||
name="sound",
|
name="sound",
|
||||||
type=models.Sound.TYPE_ARCHIVE,
|
broadcast=True,
|
||||||
position=0,
|
|
||||||
file="sound.mp3",
|
file="sound.mp3",
|
||||||
)
|
)
|
||||||
sound.save(check=False)
|
sound.save(sync=False)
|
||||||
return sound
|
return sound
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def episode_sound(episode, sound):
|
||||||
|
obj = models.EpisodeSound(episode=episode, sound=sound, position=0, broadcast=sound.broadcast)
|
||||||
|
obj.save()
|
||||||
|
return obj
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def sounds(program):
|
def sounds(program):
|
||||||
items = [
|
items = [
|
||||||
models.Sound(
|
models.Sound(
|
||||||
name=f"sound {i}",
|
name=f"sound {i}",
|
||||||
program=program,
|
program=program,
|
||||||
type=models.Sound.TYPE_ARCHIVE,
|
broadcast=True,
|
||||||
position=i,
|
|
||||||
file=f"sound-{i}.mp3",
|
file=f"sound-{i}.mp3",
|
||||||
)
|
)
|
||||||
for i in range(0, 3)
|
for i in range(0, 3)
|
||||||
|
|
|
@ -20,7 +20,7 @@ def monitor(streamer):
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def diffusion(program, episode, sound):
|
def diffusion(program, episode, episode_sound):
|
||||||
return baker.make(
|
return baker.make(
|
||||||
models.Diffusion,
|
models.Diffusion,
|
||||||
program=program,
|
program=program,
|
||||||
|
@ -33,10 +33,10 @@ def diffusion(program, episode, sound):
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def source(monitor, streamer, sound, diffusion):
|
def source(monitor, streamer, episode_sound, diffusion):
|
||||||
source = next(monitor.streamer.playlists)
|
source = next(monitor.streamer.playlists)
|
||||||
source.uri = sound.file.path
|
source.uri = episode_sound.sound.file.path
|
||||||
source.episode_id = sound.episode_id
|
source.episode_id = episode_sound.episode_id
|
||||||
source.air_time = diffusion.start + tz.timedelta(seconds=10)
|
source.air_time = diffusion.start + tz.timedelta(seconds=10)
|
||||||
return source
|
return source
|
||||||
|
|
||||||
|
@ -185,7 +185,7 @@ class TestMonitor:
|
||||||
monitor.trace_tracks(log)
|
monitor.trace_tracks(log)
|
||||||
|
|
||||||
@pytest.mark.django_db(transaction=True)
|
@pytest.mark.django_db(transaction=True)
|
||||||
def test_handle_diffusions(self, monitor, streamer, diffusion, sound):
|
def test_handle_diffusions(self, monitor, streamer, diffusion, episode_sound):
|
||||||
interface(
|
interface(
|
||||||
monitor,
|
monitor,
|
||||||
{
|
{
|
||||||
|
|
|
@ -67,7 +67,7 @@ class TestPlaylistSource:
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
def test_get_sound_queryset(self, playlist_source, sounds):
|
def test_get_sound_queryset(self, playlist_source, sounds):
|
||||||
query = playlist_source.get_sound_queryset()
|
query = playlist_source.get_sound_queryset()
|
||||||
assert all(r.program_id == playlist_source.program.pk and r.type == r.TYPE_ARCHIVE for r in query)
|
assert all(r.program_id == playlist_source.program.pk and r.broadcast for r in query)
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
def test_get_playlist(self, playlist_source, sounds):
|
def test_get_playlist(self, playlist_source, sounds):
|
||||||
|
|
|
@ -137,7 +137,7 @@ class QueueSourceViewSet(SourceViewSet):
|
||||||
model = controllers.QueueSource
|
model = controllers.QueueSource
|
||||||
|
|
||||||
def get_sound_queryset(self, request):
|
def get_sound_queryset(self, request):
|
||||||
return Sound.objects.station(request.station).archive()
|
return Sound.objects.station(request.station).broadcast()
|
||||||
|
|
||||||
@action(detail=True, methods=["POST"])
|
@action(detail=True, methods=["POST"])
|
||||||
def push(self, request, pk):
|
def push(self, request, pk):
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
<template>
|
<template>
|
||||||
<component :is="tag" @click.capture.stop="call" type="button" :class="buttonClass">
|
<component :is="tag" @click.capture.stop="call" type="button" :class="[buttonClass, this.promise && 'blink' || '']">
|
||||||
<span v-if="promise && runIcon">
|
<span v-if="promise && runIcon">
|
||||||
<i :class="runIcon"></i>
|
<i :class="runIcon"></i>
|
||||||
</span>
|
</span>
|
||||||
|
|
|
@ -94,9 +94,13 @@ export default {
|
||||||
this.inputValue = value
|
this.inputValue = value
|
||||||
},
|
},
|
||||||
|
|
||||||
inputValue(value) {
|
inputValue(value, old) {
|
||||||
if(value != this.inputValue && value != this.modelValue)
|
if(value != old && value != this.modelValue) {
|
||||||
this.$emit('update:modelValue', value)
|
this.$emit('update:modelValue', value)
|
||||||
|
this.$emit('change', {target: this.$refs.input})
|
||||||
|
}
|
||||||
|
if(this.selectedLabel != value)
|
||||||
|
this.selectedIndex = -1
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
@ -176,8 +180,11 @@ export default {
|
||||||
},
|
},
|
||||||
|
|
||||||
onBlur(event) {
|
onBlur(event) {
|
||||||
var index = event.relatedTarget && event.relatedTarget.dataset.autocompleteIndex;
|
if(!this.items.length)
|
||||||
if(index !== undefined)
|
return
|
||||||
|
|
||||||
|
var index = event.relatedTarget && Math.parseInt(event.relatedTarget.dataset.autocompleteIndex);
|
||||||
|
if(index !== undefined && index !== null)
|
||||||
this.select(index, false, false)
|
this.select(index, false, false)
|
||||||
this.cursor = -1;
|
this.cursor = -1;
|
||||||
},
|
},
|
||||||
|
|
|
@ -29,7 +29,7 @@
|
||||||
import {getCsrf} from "../model"
|
import {getCsrf} from "../model"
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
emit: ["fileChange", "load"],
|
emit: ["fileChange", "load", "abort", "error"],
|
||||||
|
|
||||||
props: {
|
props: {
|
||||||
url: { type: String },
|
url: { type: String },
|
||||||
|
@ -71,9 +71,9 @@ export default {
|
||||||
const req = new XMLHttpRequest()
|
const req = new XMLHttpRequest()
|
||||||
req.open("POST", this.url)
|
req.open("POST", this.url)
|
||||||
req.upload.addEventListener("progress", (e) => this.onUploadProgress(e))
|
req.upload.addEventListener("progress", (e) => this.onUploadProgress(e))
|
||||||
req.addEventListener("load", (e) => this.onUploadDone(e))
|
req.addEventListener("load", (e) => this.onUploadDone(e, 'load'))
|
||||||
req.addEventListener("abort", (e) => this.onUploadDone(e))
|
req.addEventListener("abort", (e) => this.onUploadDone(e, 'abort'))
|
||||||
req.addEventListener("error", (e) => this.onUploadDone(e))
|
req.addEventListener("error", (e) => this.onUploadDone(e, 'error'))
|
||||||
|
|
||||||
const formData = new FormData(this.$refs.form);
|
const formData = new FormData(this.$refs.form);
|
||||||
formData.append('csrfmiddlewaretoken', getCsrf())
|
formData.append('csrfmiddlewaretoken', getCsrf())
|
||||||
|
@ -87,8 +87,8 @@ export default {
|
||||||
this.total = event.total
|
this.total = event.total
|
||||||
},
|
},
|
||||||
|
|
||||||
onUploadDone(event) {
|
onUploadDone(event, eventName) {
|
||||||
this.$emit("load", event)
|
this.$emit(eventName, event)
|
||||||
this._resetUpload(this.STATE.DEFAULT, true)
|
this._resetUpload(this.STATE.DEFAULT, true)
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
195
assets/src/components/AFormSet.vue
Normal file
195
assets/src/components/AFormSet.vue
Normal file
|
@ -0,0 +1,195 @@
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<input type="hidden" :name="_prefix + 'TOTAL_FORMS'" :value="items.length || 0"/>
|
||||||
|
<template v-for="(value,name) in formData.management" v-bind:key="name">
|
||||||
|
<input type="hidden" :name="_prefix + name.toUpperCase()"
|
||||||
|
:value="value"/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<a-rows ref="rows" :set="set"
|
||||||
|
:columns="visibleFields" :columnsOrderable="columnsOrderable"
|
||||||
|
:orderable="orderable" @move="moveItem" @colmove="onColumnMove"
|
||||||
|
@cell="e => $emit('cell', e)">
|
||||||
|
|
||||||
|
<template #header-head>
|
||||||
|
<template v-if="orderable">
|
||||||
|
<th style="max-width:2em" :title="orderField.label"
|
||||||
|
:aria-label="orderField.label"
|
||||||
|
:aria-description="orderField.help || ''">
|
||||||
|
<span class="icon">
|
||||||
|
<i class="fa fa-arrow-down-1-9"></i>
|
||||||
|
</span>
|
||||||
|
</th>
|
||||||
|
<slot name="rows-header-head"></slot>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #row-head="data">
|
||||||
|
<input v-if="orderable" type="hidden"
|
||||||
|
:name="_prefix + data.row + '-' + orderBy"
|
||||||
|
:value="data.row"/>
|
||||||
|
<input type="hidden" :name="_prefix + data.row + '-id'"
|
||||||
|
:value="data.item ? data.item.id : ''"/>
|
||||||
|
|
||||||
|
<template v-for="field of hiddenFields" v-bind:key="field.name">
|
||||||
|
<input type="hidden"
|
||||||
|
v-if="!(field.name in ['id', orderBy])"
|
||||||
|
:name="_prefix + data.row + '-' + field.name"
|
||||||
|
:value="field.value in [null, undefined] ? data.item.data[name] : field.value"/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<slot name="row-head" v-bind="data">
|
||||||
|
<td v-if="orderable">{{ data.row+1 }}</td>
|
||||||
|
</slot>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-for="(field,slot) of fieldSlots" v-bind:key="field.name"
|
||||||
|
v-slot:[slot]="data">
|
||||||
|
<slot :name="slot" v-bind="data" :field="field" :input-name="_prefix + data.cell.row + '-' + field.name">
|
||||||
|
<div class="field">
|
||||||
|
<div class="control">
|
||||||
|
<slot :name="'control-' + field.name" v-bind="data" :field="field" :input-name="_prefix + data.cell.row + '-' + field.name"/>
|
||||||
|
</div>
|
||||||
|
<p v-for="[error,index] in data.item.error(field.name)" class="help is-danger" v-bind:key="index">
|
||||||
|
{{ error }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</slot>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #row-tail="data">
|
||||||
|
<slot v-if="$slots['row-tail']" name="row-tail" v-bind="data"/>
|
||||||
|
<td class="align-right pr-0">
|
||||||
|
<button type="button" class="button square"
|
||||||
|
@click.stop="removeItem(data.row, data.item)"
|
||||||
|
:title="labels.remove_item"
|
||||||
|
:aria-label="labels.remove_item">
|
||||||
|
<span class="icon"><i class="fa fa-trash" /></span>
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</template>
|
||||||
|
</a-rows>
|
||||||
|
<div class="a-formset-footer flex-row">
|
||||||
|
<div class="flex-grow-1 flex-row">
|
||||||
|
<slot name="footer"/>
|
||||||
|
</div>
|
||||||
|
<div class="flex-grow-1 align-right">
|
||||||
|
<button type="button" class="button square is-warning p-2"
|
||||||
|
@click="reset()"
|
||||||
|
:title="labels.discard_changes"
|
||||||
|
:aria-label="labels.discard_changes"
|
||||||
|
>
|
||||||
|
<span class="icon"><i class="fa fa-rotate" /></span>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="button square is-primary p-2"
|
||||||
|
@click="onActionAdd"
|
||||||
|
:title="labels.add_item"
|
||||||
|
:aria-label="labels.add_item"
|
||||||
|
>
|
||||||
|
<span class="icon">
|
||||||
|
<i class="fa fa-plus"/></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script>
|
||||||
|
import {cloneDeep} from 'lodash'
|
||||||
|
import Model, {Set} from '../model'
|
||||||
|
|
||||||
|
import ARows from './ARows'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
emit: ['cell', 'move', 'colmove', 'load'],
|
||||||
|
components: {ARows},
|
||||||
|
|
||||||
|
props: {
|
||||||
|
labels: Object,
|
||||||
|
|
||||||
|
//! If provided call this function instead of adding an item to rows on "+" button click.
|
||||||
|
actionAdd: Function,
|
||||||
|
|
||||||
|
//! If True, columns can be reordered
|
||||||
|
columnsOrderable: Boolean,
|
||||||
|
//! Field name used for ordering
|
||||||
|
orderBy: String,
|
||||||
|
|
||||||
|
//! Formset data as returned by get_formset_data
|
||||||
|
formData: Object,
|
||||||
|
//! Model class used for item's set
|
||||||
|
model: {type: Function, default: Model},
|
||||||
|
//! initial data set load at mount
|
||||||
|
initials: Array,
|
||||||
|
},
|
||||||
|
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
set: new Set(Model),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
computed: {
|
||||||
|
// ---- fields
|
||||||
|
_prefix() { return this.formData.prefix ? this.formData.prefix + '-' : '' },
|
||||||
|
fields() { return this.formData.fields },
|
||||||
|
orderField() { return this.orderBy && this.fields.find(f => f.name == this.orderBy) },
|
||||||
|
orderable() { return !!this.orderField },
|
||||||
|
|
||||||
|
hiddenFields() { return this.fields.filter(f => f.hidden && !(this.orderable && f == this.orderField)) },
|
||||||
|
visibleFields() { return this.fields.filter(f => !f.hidden) },
|
||||||
|
|
||||||
|
fieldSlots() { return this.visibleFields.reduce(
|
||||||
|
(slots, f) => ({...slots, ['row-' + f.name]: f}),
|
||||||
|
{}
|
||||||
|
)},
|
||||||
|
|
||||||
|
items() { return this.set.items },
|
||||||
|
rows() { return this.$refs.rows },
|
||||||
|
},
|
||||||
|
|
||||||
|
methods: {
|
||||||
|
onCellEvent(event) { this.$emit('cell', event) },
|
||||||
|
onColumnMove(event) { this.$emit('colmove', event) },
|
||||||
|
onActionAdd() {
|
||||||
|
if(this.actionAdd)
|
||||||
|
return this.actionAdd(this)
|
||||||
|
this.set.push()
|
||||||
|
},
|
||||||
|
|
||||||
|
moveItem(event) {
|
||||||
|
const {from, to} = event
|
||||||
|
const set_ = event.set || this.set
|
||||||
|
set_.move(from, to);
|
||||||
|
this.$emit('move', {...event, seŧ: set_})
|
||||||
|
},
|
||||||
|
|
||||||
|
removeItem(row) {
|
||||||
|
const item = this.items[row]
|
||||||
|
if(item.id) {
|
||||||
|
// TODO
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
this.items.splice(row,1)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
//! Load items into set
|
||||||
|
load(items=[], reset=false) {
|
||||||
|
if(reset)
|
||||||
|
this.set.items = []
|
||||||
|
for(var item of items)
|
||||||
|
this.set.push(cloneDeep(item))
|
||||||
|
this.$emit('load', items)
|
||||||
|
},
|
||||||
|
|
||||||
|
//! Reset forms to initials
|
||||||
|
reset() {
|
||||||
|
this.load(this.initials || [], true)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
mounted() {
|
||||||
|
this.reset()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
|
@ -6,6 +6,7 @@
|
||||||
<div class="modal-card-title">
|
<div class="modal-card-title">
|
||||||
<slot name="title">{{ title }}</slot>
|
<slot name="title">{{ title }}</slot>
|
||||||
</div>
|
</div>
|
||||||
|
<slot name="bar"></slot>
|
||||||
<button type="button" class="delete square" aria-label="close" @click="close">
|
<button type="button" class="delete square" aria-label="close" @click="close">
|
||||||
<span class="icon">
|
<span class="icon">
|
||||||
<i class="fa fa-close"></i>
|
<i class="fa fa-close"></i>
|
||||||
|
|
|
@ -27,12 +27,15 @@ import {isReactive, toRefs} from 'vue'
|
||||||
import Model from '../model'
|
import Model from '../model'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
emit: ['move', 'cell'],
|
emits: ['move', 'cell'],
|
||||||
|
|
||||||
props: {
|
props: {
|
||||||
//! Item to display in row
|
//! Item to display in row
|
||||||
item: Object,
|
item: {type: Object, default: () => ({})},
|
||||||
//! Columns to display, as items' attributes
|
//! Columns to display, as items' attributes
|
||||||
|
//! - name: field name / item attribute value
|
||||||
|
//! - label: display label
|
||||||
|
//! - help: help text
|
||||||
columns: Array,
|
columns: Array,
|
||||||
//! Default cell's info
|
//! Default cell's info
|
||||||
cell: {type: Object, default() { return {row: 0}}},
|
cell: {type: Object, default() { return {row: 0}}},
|
||||||
|
|
|
@ -1,34 +1,38 @@
|
||||||
<template>
|
<template>
|
||||||
<table class="table is-stripped is-fullwidth">
|
<table class="table is-stripped is-fullwidth">
|
||||||
<thead>
|
<thead>
|
||||||
<a-row :item="labels" :columns="columns" :orderable="orderable"
|
<a-row :columns="columnNames"
|
||||||
@move="$emit('colmove', $event)">
|
:orderable="columnsOrderable" cellTag="th"
|
||||||
|
@move="moveColumn">
|
||||||
<template v-if="$slots['header-head']" v-slot:head="data">
|
<template v-if="$slots['header-head']" v-slot:head="data">
|
||||||
<slot name="header-head" v-bind="data"/>
|
<slot name="header-head" v-bind="data"/>
|
||||||
</template>
|
</template>
|
||||||
<template v-if="$slots['header-tail']" v-slot:tail="data">
|
<template v-if="$slots['header-tail']" v-slot:tail="data">
|
||||||
<slot name="header-tail" v-bind="data"/>
|
<slot name="header-tail" v-bind="data"/>
|
||||||
</template>
|
</template>
|
||||||
|
<template v-for="column of columns" v-bind:key="column.name"
|
||||||
|
v-slot:[column.name]="data">
|
||||||
|
<slot :name="'header-' + column.name" v-bind="data">
|
||||||
|
{{ column.label }}
|
||||||
|
<span v-if="column.help" class="icon small"
|
||||||
|
:title="column.help">
|
||||||
|
<i class="fa fa-circle-question"/>
|
||||||
|
</span>
|
||||||
|
</slot>
|
||||||
|
</template>
|
||||||
</a-row>
|
</a-row>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<slot name="head"/>
|
<slot name="head"/>
|
||||||
<template v-for="(item,row) in items" :key="row">
|
<template v-for="(item,row) in items" :key="row">
|
||||||
<!-- data-index comes from AList component drag & drop -->
|
<!-- data-index comes from AList component drag & drop -->
|
||||||
<a-row :item="item" :cell="{row}" :columns="columns" :data-index="row"
|
<a-row :item="item" :cell="{row}" :columns="columnNames" :data-index="row"
|
||||||
:data-row="row"
|
:data-row="row"
|
||||||
:draggable="orderable"
|
:draggable="orderable"
|
||||||
@dragstart="onDragStart" @dragover="onDragOver" @drop="onDrop"
|
@dragstart="onDragStart" @dragover="onDragOver" @drop="onDrop"
|
||||||
@cell="onCellEvent(row, $event)">
|
@cell="onCellEvent(row, $event)">
|
||||||
<template v-for="[name,slot] of rowSlots" :key="slot" v-slot:[slot]="data">
|
<template v-for="[name,slot] of rowSlots" :key="slot" v-slot:[slot]="data">
|
||||||
<template v-if="slot == 'head' || slot == 'tail'">
|
<slot :name="name" v-bind="data"/>
|
||||||
<slot :name="name" v-bind="data"/>
|
|
||||||
</template>
|
|
||||||
<template v-else>
|
|
||||||
<div>
|
|
||||||
<slot :name="name" v-bind="data"/>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</template>
|
</template>
|
||||||
</a-row>
|
</a-row>
|
||||||
</template>
|
</template>
|
||||||
|
@ -43,28 +47,38 @@ import ARow from './ARow.vue'
|
||||||
const Component = {
|
const Component = {
|
||||||
extends: AList,
|
extends: AList,
|
||||||
components: { ARow },
|
components: { ARow },
|
||||||
emit: ['cell', 'colmove'],
|
//! Event:
|
||||||
|
//! - cell(event): an event occured inside cell
|
||||||
|
//! - colmove({from,to}), colmove(): columns moved
|
||||||
|
emits: ['cell', 'colmove'],
|
||||||
|
|
||||||
props: {
|
props: {
|
||||||
...AList.props,
|
...AList.props,
|
||||||
|
//! Ordered list of columns, as objects with:
|
||||||
|
//! - name: item attribute value
|
||||||
|
//! - label: display label
|
||||||
|
//! - help: help text
|
||||||
|
//! - hidden: if true, field is hidden
|
||||||
columns: Array,
|
columns: Array,
|
||||||
labels: Object,
|
//! If True, columns are orderable
|
||||||
|
columnsOrderable: Boolean,
|
||||||
},
|
},
|
||||||
|
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
...super.data,
|
...super.data,
|
||||||
|
// TODO: add observer
|
||||||
|
columns_: [...this.columns],
|
||||||
extraItem: new this.set.model(),
|
extraItem: new this.set.model(),
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
computed: {
|
computed: {
|
||||||
rowCells() {
|
columnNames() { return this.columns_.map(c => c.name) },
|
||||||
const cells = []
|
columnLabels() { return this.columns_.reduce(
|
||||||
for(var row in this.items)
|
(labels, c) => ({...labels, [c.name]: c.label}),
|
||||||
cells.push({row})
|
{}
|
||||||
},
|
)},
|
||||||
|
|
||||||
rowSlots() {
|
rowSlots() {
|
||||||
return Object.keys(this.$slots).filter(x => x.startsWith('row-'))
|
return Object.keys(this.$slots).filter(x => x.startsWith('row-'))
|
||||||
.map(x => [x, x.slice(4)])
|
.map(x => [x, x.slice(4)])
|
||||||
|
@ -72,6 +86,25 @@ const Component = {
|
||||||
},
|
},
|
||||||
|
|
||||||
methods: {
|
methods: {
|
||||||
|
// TODO: use in tracklist
|
||||||
|
sortColumns(names) {
|
||||||
|
const ordered = names.map(n => this.columns_.find(c => c.name == n)).filter(c => !!c);
|
||||||
|
const remaining = this.columns_.filter(c => names.indexOf(c.name) == -1)
|
||||||
|
this.columns_ = [...ordered, ...remaining]
|
||||||
|
this.$emit('colmove')
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Move column using provided event object (as `{from, to}`)
|
||||||
|
*/
|
||||||
|
moveColumn(event) {
|
||||||
|
const {from, to} = event
|
||||||
|
const value = this.columns_[from]
|
||||||
|
this.columns_.splice(from, 1)
|
||||||
|
this.columns_.splice(to, 0, value)
|
||||||
|
this.$emit('colmove', event)
|
||||||
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* React on 'cell' event, re-emitting it with additional values:
|
* React on 'cell' event, re-emitting it with additional values:
|
||||||
* - `set`: data set
|
* - `set`: data set
|
||||||
|
|
|
@ -1,63 +1,99 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="a-select-file">
|
<a-modal ref="modal" :title="title">
|
||||||
<div ref="list" :class="['a-select-file-list', listClass]">
|
<template #bar>
|
||||||
<!-- upload -->
|
<button type="button" class="button small mr-3" v-if="panel == LIST"
|
||||||
<form ref="uploadForm" class="flex-column" v-if="state == STATE.DEFAULT">
|
@click="showPanel(UPLOAD)">
|
||||||
<div class="field flex-grow-1">
|
<span class="icon">
|
||||||
<label class="label">{{ uploadLabel }}</label>
|
<i class="fa fa-upload"></i>
|
||||||
<input type="file" ref="uploadFile" :name="uploadFieldName" @change="onSubmit"/>
|
</span>
|
||||||
</div>
|
<span>{{ labels.upload }}</span>
|
||||||
<div class="flex-grow-1">
|
</button>
|
||||||
<slot name="upload-form"></slot>
|
|
||||||
</div>
|
<button type="button" class="button small mr-3" v-else
|
||||||
</form>
|
@click="showPanel(LIST)">
|
||||||
<div class="flex-column" v-else>
|
<span class="icon">
|
||||||
<slot name="upload-preview" :upload="upload"></slot>
|
<i class="fa fa-list"></i>
|
||||||
<div class="flex-row">
|
</span>
|
||||||
<progress :max="upload.total" :value="upload.loaded"/>
|
<span>{{ labels.list }}</span>
|
||||||
<button type="button" class="button small square ml-2" @click="uploadAbort">
|
</button>
|
||||||
<span class="icon small">
|
</template>
|
||||||
<i class="fa fa-close"></i>
|
<template #default>
|
||||||
</span>
|
<a-file-upload ref="upload" v-if="panel == UPLOAD"
|
||||||
</button>
|
:url="uploadUrl"
|
||||||
|
:label="uploadLabel" :field-name="uploadFieldName"
|
||||||
|
@load="uploadDone">
|
||||||
|
<template #form="data">
|
||||||
|
<slot name="upload-form" v-bind="data"></slot>
|
||||||
|
</template>
|
||||||
|
<template #preview="data">
|
||||||
|
<slot name="upload-preview" v-bind="data"></slot>
|
||||||
|
</template>
|
||||||
|
</a-file-upload>
|
||||||
|
<div class="a-select-file" v-else>
|
||||||
|
<div ref="list"
|
||||||
|
:class="['a-select-file-list', listClass]">
|
||||||
|
<!-- tiles -->
|
||||||
|
<div v-if="prevUrl">
|
||||||
|
<a href="#" @click="load(prevUrl)">
|
||||||
|
{{ labels.show_previous }}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-for="item in items" v-bind:key="item.id">
|
||||||
|
<div :class="['file-preview', this.item && item.id == this.item.id && 'active']" @click="select(item)">
|
||||||
|
<slot :item="item" :load="load" :lastUrl="lastUrl"></slot>
|
||||||
|
<a-action-button v-if="deleteUrl"
|
||||||
|
class="has-text-danger small float-right"
|
||||||
|
icon="fa fa-trash"
|
||||||
|
:confirm="labels.confirm_delete"
|
||||||
|
method="DELETE"
|
||||||
|
:url="deleteUrl.replace('123', item.id)"
|
||||||
|
@done="load(lastUrl)">
|
||||||
|
</a-action-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div v-if="nextUrl">
|
||||||
|
<a href="#" @click="load(nextUrl)">
|
||||||
|
{{ labels.show_next }}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</template>
|
||||||
<!-- tiles -->
|
<template #footer>
|
||||||
<div v-if="prevUrl">
|
<slot name="footer" :item="item">
|
||||||
<a href="#" @click="load(prevUrl)">
|
<span class="mr-3" v-if="item">{{ item.name }}</span>
|
||||||
{{ prevLabel }}
|
</slot>
|
||||||
</a>
|
<button type="button" v-if="panel == LIST" class="button align-right"
|
||||||
</div>
|
@click="selected">
|
||||||
|
{{ labels.select_file }}
|
||||||
<template v-for="item in items" v-bind:key="item.id">
|
</button>
|
||||||
<div :class="['file-preview', this.item && item.id == this.item.id && 'active']" @click="select(item)">
|
</template>
|
||||||
<slot :item="item" :load="load" :lastUrl="lastUrl"></slot>
|
</a-modal>
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<div v-if="nextUrl">
|
|
||||||
<a href="#" @click="load(nextUrl)">
|
|
||||||
{{ nextLabel }}
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="a-select-footer">
|
|
||||||
<slot name="footer" :item="item" :items="items"></slot>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
<script>
|
<script>
|
||||||
import {getCsrf} from "../model"
|
import AModal from "./AModal"
|
||||||
|
import AActionButton from "./AActionButton"
|
||||||
|
import AFileUpload from "./AFileUpload"
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
|
emit: ["select"],
|
||||||
|
|
||||||
|
components: {AActionButton, AFileUpload, AModal},
|
||||||
|
|
||||||
props: {
|
props: {
|
||||||
name: { type: String },
|
title: { type: String },
|
||||||
|
labels: Object,
|
||||||
listClass: {type: String, default: ""},
|
listClass: {type: String, default: ""},
|
||||||
prevLabel: { type: String, default: "Prev" },
|
|
||||||
nextLabel: { type: String, default: "Next" },
|
// List url
|
||||||
listUrl: { type: String },
|
listUrl: { type: String },
|
||||||
|
|
||||||
|
// URL to delete an item, where "123" is replaced by
|
||||||
|
// the item id.
|
||||||
|
deleteUrl: {type: String },
|
||||||
|
|
||||||
uploadUrl: { type: String },
|
uploadUrl: { type: String },
|
||||||
uploadFieldName: { type: String, default: "file" },
|
uploadFieldName: { type: String, default: "file" },
|
||||||
uploadLabel: { type: String, default: "Upload a file" },
|
uploadLabel: { type: String, default: "Upload a file" },
|
||||||
|
@ -65,91 +101,63 @@ export default {
|
||||||
|
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
STATE: {
|
LIST: 0,
|
||||||
DEFAULT: 0,
|
UPLOAD: 1,
|
||||||
UPLOADING: 1,
|
|
||||||
},
|
|
||||||
|
|
||||||
state: 0,
|
|
||||||
|
|
||||||
|
panel: 0,
|
||||||
item: null,
|
item: null,
|
||||||
items: [],
|
items: [],
|
||||||
nextUrl: "",
|
nextUrl: "",
|
||||||
prevUrl: "",
|
prevUrl: "",
|
||||||
lastUrl: "",
|
lastUrl: "",
|
||||||
|
|
||||||
upload: {},
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
methods: {
|
methods: {
|
||||||
|
open() {
|
||||||
|
this.$refs.modal.open()
|
||||||
|
},
|
||||||
|
|
||||||
|
close() {
|
||||||
|
this.$refs.modal.close()
|
||||||
|
},
|
||||||
|
|
||||||
|
showPanel(panel) {
|
||||||
|
this.panel = panel
|
||||||
|
},
|
||||||
|
|
||||||
load(url) {
|
load(url) {
|
||||||
fetch(url || this.listUrl).then(
|
return fetch(url || this.listUrl).then(
|
||||||
response => response.ok ? response.json() : Promise.reject(response)
|
response => response.ok ? response.json() : Promise.reject(response)
|
||||||
).then(data => {
|
).then(data => {
|
||||||
this.lastUrl = url
|
this.lastUrl = url
|
||||||
this.nextUrl = data.next
|
this.nextUrl = data.next
|
||||||
this.prevUrl = data.previous
|
this.prevUrl = data.previous
|
||||||
this.items = data.results
|
this.items = data.results
|
||||||
|
this.showPanel(this.LIST)
|
||||||
|
|
||||||
this.$forceUpdate()
|
this.$forceUpdate()
|
||||||
this.$refs.list.scroll(0, 0)
|
this.$refs.list.scroll(0, 0)
|
||||||
|
return this.items
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|
||||||
|
//! Select an item
|
||||||
select(item) {
|
select(item) {
|
||||||
this.item = item;
|
this.item = item;
|
||||||
},
|
},
|
||||||
|
|
||||||
// ---- upload
|
//! User click on select button (confirm selection)
|
||||||
uploadAbort() {
|
selected() {
|
||||||
this.upload.request && this.upload.request.abort()
|
this.$emit("select", this.item)
|
||||||
|
this.close()
|
||||||
},
|
},
|
||||||
|
|
||||||
onSubmit() {
|
uploadDone(reload=false) {
|
||||||
const [file] = this.$refs.uploadFile.files
|
reload && this.load().then(items => {
|
||||||
if(!file)
|
this.item = items[0]
|
||||||
return
|
})
|
||||||
this._setUploadFile(file)
|
|
||||||
|
|
||||||
const req = new XMLHttpRequest()
|
|
||||||
req.open("POST", this.uploadUrl || this.listUrl)
|
|
||||||
req.upload.addEventListener("progress", (e) => this.onUploadProgress(e))
|
|
||||||
req.addEventListener("load", (e) => this.onUploadDone(e, true))
|
|
||||||
req.addEventListener("abort", (e) => this.onUploadDone(e))
|
|
||||||
req.addEventListener("error", (e) => this.onUploadDone(e))
|
|
||||||
|
|
||||||
const formData = new FormData(this.$refs.uploadForm);
|
|
||||||
formData.append('csrfmiddlewaretoken', getCsrf())
|
|
||||||
req.send(formData)
|
|
||||||
|
|
||||||
this._resetUpload(this.STATE.UPLOADING, false, req)
|
|
||||||
},
|
},
|
||||||
|
|
||||||
onUploadProgress(event) {
|
|
||||||
this.upload.loaded = event.loaded
|
|
||||||
this.upload.total = event.total
|
|
||||||
},
|
|
||||||
|
|
||||||
onUploadDone(reload=false) {
|
|
||||||
this._resetUpload(this.STATE.DEFAULT, true)
|
|
||||||
reload && this.load()
|
|
||||||
},
|
|
||||||
|
|
||||||
_setUploadFile(file) {
|
|
||||||
this.upload.file = file
|
|
||||||
this.upload.fileURL = file && URL.createObjectURL(file)
|
|
||||||
},
|
|
||||||
|
|
||||||
_resetUpload(state, resetFile=false, request=null) {
|
|
||||||
this.state = state
|
|
||||||
this.upload.loaded = 0
|
|
||||||
this.upload.total = 0
|
|
||||||
this.upload.request = request
|
|
||||||
if(resetFile)
|
|
||||||
this.upload.file = null
|
|
||||||
}
|
|
||||||
|
|
||||||
},
|
},
|
||||||
|
|
||||||
mounted() {
|
mounted() {
|
||||||
|
|
|
@ -1,155 +1,83 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="a-playlist-editor">
|
<div class="a-playlist-editor">
|
||||||
<a-modal ref="modal" :title="labels && labels.add_sound">
|
<a-select-file ref="select-file"
|
||||||
<template #default>
|
:title="labels && labels.add_sound"
|
||||||
<a-file-upload ref="file-upload" :url="soundUploadUrl" :label="labels.select_file" submitLabel="" @load="uploadDone"
|
:labels="labels"
|
||||||
>
|
:list-url="soundListUrl"
|
||||||
<template #preview="{upload}">
|
:deleteUrl="soundDeleteUrl"
|
||||||
<slot name="upload-preview" :upload="upload"></slot>
|
:uploadUrl="soundUploadUrl"
|
||||||
</template>
|
:uploadLabel="labels.select_file"
|
||||||
<template #form>
|
@select="selected"
|
||||||
<slot name="upload-form"></slot>
|
>
|
||||||
</template>
|
<template #upload-preview="{upload}">
|
||||||
</a-file-upload>
|
<slot name="upload-preview" :upload="upload"></slot>
|
||||||
</template>
|
</template>
|
||||||
<template #footer>
|
<template #upload-form>
|
||||||
<button type="button" class="button"
|
<slot name="upload-form"></slot>
|
||||||
@click.stop="$refs['file-upload'].submit()">
|
|
||||||
<span class="icon">
|
|
||||||
<i class="fa fa-upload"></i>
|
|
||||||
</span>
|
|
||||||
<span>{{ labels.submit }}</span>
|
|
||||||
</button>
|
|
||||||
</template>
|
</template>
|
||||||
</a-modal>
|
<template #default="{item}">
|
||||||
|
<audio controls :src="item.url"></audio>
|
||||||
|
<label class="label small flex-grow-1">{{ item.name }}</label>
|
||||||
|
</template>
|
||||||
|
</a-select-file>
|
||||||
|
|
||||||
<slot name="top" :set="set" :items="set.items"></slot>
|
<a-form-set ref="formset" :form-data="formData" :labels="labels"
|
||||||
<a-rows :set="set" :columns="allColumns"
|
:initials="initData.items"
|
||||||
:labels="allColumnsLabels" :allow-create="true" :orderable="true"
|
order-by="position"
|
||||||
@move="listItemMove">
|
:action-add="actionAdd">
|
||||||
<template v-for="[name,slot] of rowsSlots" :key="slot"
|
<template v-for="[name,slot] of rowsSlots" :key="slot"
|
||||||
v-slot:[slot]="data">
|
v-slot:[slot]="data">
|
||||||
<slot v-if="name != 'row-tail'" :name="name" v-bind="data"/>
|
<slot v-if="name != 'row-tail'" :name="name" v-bind="data"/>
|
||||||
</template>
|
</template>
|
||||||
</a-rows>
|
|
||||||
|
|
||||||
<div class="flex-row">
|
<template #row-sound="{item,inputName}">
|
||||||
<div class="flex-grow-1 flex-row">
|
<label>{{ item.data.name }}</label><br>
|
||||||
</div>
|
<audio controls :src="item.data.url"/>
|
||||||
<div class="flex-grow-1 align-right">
|
<input type="hidden" :name="inputName" :value="item.data.sound"/>
|
||||||
<button type="button" class="button square is-warning p-2"
|
</template>
|
||||||
@click="loadData({items: this.initData.items},true)"
|
</a-form-set>
|
||||||
:title="labels.discard_changes"
|
|
||||||
:aria-label="labels.discard_changes"
|
|
||||||
>
|
|
||||||
<span class="icon"><i class="fa fa-rotate" /></span>
|
|
||||||
</button>
|
|
||||||
<button type="button" class="button square is-primary p-2"
|
|
||||||
@click="$refs.modal.open()"
|
|
||||||
:title="labels.add_sound"
|
|
||||||
:aria-label="labels.add_sound"
|
|
||||||
>
|
|
||||||
<span class="icon">
|
|
||||||
<i class="fa fa-plus"/></span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<script>
|
<script>
|
||||||
// import {dropRightWhile, cloneDeep, isEqual} from 'lodash'
|
import AFormSet from './AFormSet'
|
||||||
import {cloneDeep} from 'lodash'
|
import ASelectFile from "./ASelectFile"
|
||||||
import Model, {Set} from '../model'
|
|
||||||
|
|
||||||
// import AActionButton from './AActionButton'
|
|
||||||
import ARows from './ARows'
|
|
||||||
import AModal from "./AModal"
|
|
||||||
import AFileUpload from "./AFileUpload"
|
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
components: {ARows, AModal, AFileUpload},
|
components: {AFormSet, ASelectFile},
|
||||||
|
|
||||||
props: {
|
props: {
|
||||||
initData: Object,
|
formData: Object,
|
||||||
dataPrefix: String,
|
|
||||||
labels: Object,
|
labels: Object,
|
||||||
settingsUrl: String,
|
// initial datas
|
||||||
|
initData: Object,
|
||||||
|
|
||||||
soundListUrl: String,
|
soundListUrl: String,
|
||||||
soundUploadUrl: String,
|
soundUploadUrl: String,
|
||||||
player: Object,
|
soundDeleteUrl: String,
|
||||||
columns: {
|
|
||||||
type: Array,
|
|
||||||
default: () => ['name', "type", 'is_public', 'is_downloadable']
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
data() {
|
|
||||||
return {
|
|
||||||
set: new Set(Model),
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
|
|
||||||
computed: {
|
computed: {
|
||||||
player_() {
|
|
||||||
return this.player || window.aircox.player
|
|
||||||
},
|
|
||||||
|
|
||||||
allColumns() {
|
|
||||||
return [...this.columns, "delete"]
|
|
||||||
},
|
|
||||||
|
|
||||||
allColumnsLabels() {
|
|
||||||
return {...this.labels, ...this.initData.fields}
|
|
||||||
},
|
|
||||||
|
|
||||||
items() {
|
|
||||||
return this.set.items
|
|
||||||
},
|
|
||||||
|
|
||||||
rowsSlots() {
|
rowsSlots() {
|
||||||
return Object.keys(this.$slots)
|
return Object.keys(this.$slots)
|
||||||
.filter(x => x.startsWith('row-') || x.startsWith('rows-'))
|
.filter(x => x.startsWith('row-') || x.startsWith('rows-') || x.startsWith('control-'))
|
||||||
.map(x => [x, x.startsWith('rows-') ? x.slice(5) : x])
|
.map(x => [x, x.startsWith('rows-') ? x.slice(5) : x])
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
methods: {
|
methods: {
|
||||||
listItemMove({from, to, set}) {
|
actionAdd() {
|
||||||
set.move(from, to);
|
this.$refs['select-file'].open()
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
selected(item) {
|
||||||
* Load initial data
|
const data = {
|
||||||
*/
|
"sound": item.id,
|
||||||
loadData({items=[] /*, settings=null*/}, reset=false) {
|
"name": item.name,
|
||||||
if(reset) {
|
"url": item.url,
|
||||||
this.set.items = []
|
"broadcast": item.broadcast,
|
||||||
}
|
}
|
||||||
for(var index in items)
|
this.$refs.formset.set.push(data)
|
||||||
this.set.push(cloneDeep(items[index]))
|
|
||||||
// if(settings)
|
|
||||||
// this.settingsSaved(settings)
|
|
||||||
},
|
},
|
||||||
|
|
||||||
uploadDone(event) {
|
|
||||||
const req = event.target
|
|
||||||
if(req.status == 201) {
|
|
||||||
const item = JSON.parse(req.response)
|
|
||||||
this.set.push(item)
|
|
||||||
this.$refs.modal.close()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
|
|
||||||
watch: {
|
|
||||||
initData(val) {
|
|
||||||
this.loadData(val)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
mounted() {
|
|
||||||
this.initData && this.loadData(this.initData)
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
|
@ -12,7 +12,7 @@
|
||||||
<span class="icon is-small">
|
<span class="icon is-small">
|
||||||
<i class="fa fa-pencil"></i>
|
<i class="fa fa-pencil"></i>
|
||||||
</span>
|
</span>
|
||||||
<span>Texte</span>
|
<span>{{ labels.text }}</span>
|
||||||
</button>
|
</button>
|
||||||
</p>
|
</p>
|
||||||
<p class="control">
|
<p class="control">
|
||||||
|
@ -21,13 +21,21 @@
|
||||||
<span class="icon is-small">
|
<span class="icon is-small">
|
||||||
<i class="fa fa-list"></i>
|
<i class="fa fa-list"></i>
|
||||||
</span>
|
</span>
|
||||||
<span>Liste</span>
|
<span>{{ labels.list }}</span>
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
|
<p class="control ml-3">
|
||||||
|
<button type="button" class="button is-info square"
|
||||||
|
:title="labels.settings"
|
||||||
|
@click="$refs.settings.open()">
|
||||||
|
<span class="icon is-small">
|
||||||
|
<i class="fa fa-cog"></i>
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<slot name="top" :set="set" :columns="columns" :items="items"/>
|
|
||||||
<section v-show="page == Page.Text" class="panel">
|
<section v-show="page == Page.Text" class="panel">
|
||||||
<textarea ref="textarea" class="is-fullwidth is-size-6" rows="20"
|
<textarea ref="textarea" class="is-fullwidth is-size-6" rows="20"
|
||||||
@change="updateList"
|
@change="updateList"
|
||||||
|
@ -35,61 +43,20 @@
|
||||||
|
|
||||||
</section>
|
</section>
|
||||||
<section v-show="page == Page.List" class="panel">
|
<section v-show="page == Page.List" class="panel">
|
||||||
<a-rows :set="set" :columns="columns" :labels="initData.fields"
|
<a-form-set ref="formset"
|
||||||
:orderable="true" @move="listItemMove" @colmove="columnMove"
|
:form-data="formData" :initials="initData.items"
|
||||||
@cell="onCellEvent">
|
:columnsOrderable="true" :labels="labels"
|
||||||
|
order-by="position"
|
||||||
|
@load="updateInput" @colmove="onColumnMove" @move="updateInput"
|
||||||
|
@cell="onCellEvent">
|
||||||
<template v-for="[name,slot] of rowsSlots" :key="slot"
|
<template v-for="[name,slot] of rowsSlots" :key="slot"
|
||||||
v-slot:[slot]="data">
|
v-slot:[slot]="data">
|
||||||
<slot v-if="name != 'row-tail'" :name="name" v-bind="data"/>
|
<slot v-if="name != 'row-tail'" :name="name" v-bind="data"/>
|
||||||
</template>
|
</template>
|
||||||
|
</a-form-set>
|
||||||
<template v-slot:row-tail="data">
|
|
||||||
<slot v-if="$slots['row-tail']" :name="row-tail" v-bind="data"/>
|
|
||||||
<td class="align-right pr-0">
|
|
||||||
<button type="button" class="button square"
|
|
||||||
@click.stop="items.splice(data.row,1)"
|
|
||||||
:title="labels.remove_item"
|
|
||||||
:aria-label="labels.remove_item">
|
|
||||||
<span class="icon"><i class="fa fa-trash" /></span>
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
</template>
|
|
||||||
</a-rows>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<div class="flex-row">
|
<a-modal ref="settings" :title="labels.settings">
|
||||||
<div class="flex-grow-1 flex-row">
|
|
||||||
<div class="field">
|
|
||||||
<p class="control">
|
|
||||||
<button type="button" class="button is-info"
|
|
||||||
@click="$refs.settings.open()">
|
|
||||||
<span class="icon is-small">
|
|
||||||
<i class="fa fa-cog"></i>
|
|
||||||
</span>
|
|
||||||
<span>Options</span>
|
|
||||||
</button>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex-grow-1 align-right">
|
|
||||||
<button type="button" class="button square is-warning p-2"
|
|
||||||
@click="loadData({items: this.initData.items},true)"
|
|
||||||
:title="labels.discard_changes"
|
|
||||||
:aria-label="labels.discard_changes"
|
|
||||||
>
|
|
||||||
<span class="icon"><i class="fa fa-rotate" /></span>
|
|
||||||
</button>
|
|
||||||
<button type="button" class="button square is-primary p-2" v-if="page == Page.List"
|
|
||||||
@click="this.set.push(new this.set.model())"
|
|
||||||
:title="labels.add_item"
|
|
||||||
:aria-label="labels.add_item"
|
|
||||||
>
|
|
||||||
<span class="icon"><i class="fa fa-plus"/></span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<a-modal ref="settings" title="Options">
|
|
||||||
<template #default>
|
<template #default>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="label" style="vertical-align: middle">
|
<label class="label" style="vertical-align: middle">
|
||||||
|
@ -97,12 +64,14 @@
|
||||||
</label>
|
</label>
|
||||||
<table class="table is-bordered"
|
<table class="table is-bordered"
|
||||||
style="vertical-align: middle">
|
style="vertical-align: middle">
|
||||||
<tr>
|
<tr v-if="$refs.formset">
|
||||||
<a-row :columns="columns" :item="initData.fields"
|
<a-row :columns="$refs.formset.rows.columnNames"
|
||||||
@move="formatMove" :orderable="true">
|
:item="$refs.formset.rows.columnLabels"
|
||||||
|
@move="$refs.formset.rows.moveColumn"
|
||||||
|
>
|
||||||
<template v-slot:cell-after="{cell}">
|
<template v-slot:cell-after="{cell}">
|
||||||
<td style="cursor:pointer;" v-if="cell.col < columns.length-1">
|
<td style="cursor:pointer;" v-if="cell.col < $refs.formset.rows.columns_.length-1">
|
||||||
<span class="icon" @click="formatMove({from: cell.col, to: cell.col+1})"
|
<span class="icon" @click="$refs.formset.rows.moveColumn({from: cell.col, to: cell.col+1})"
|
||||||
><i class="fa fa-left-right"/>
|
><i class="fa fa-left-right"/>
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
|
@ -143,16 +112,14 @@
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</a-modal>
|
</a-modal>
|
||||||
<slot name="bottom" :set="set" :columns="columns" :items="items"/>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<script>
|
<script>
|
||||||
import {dropRightWhile, cloneDeep, isEqual} from 'lodash'
|
import {dropRightWhile, cloneDeep, isEqual} from 'lodash'
|
||||||
import Model, {Set} from '../model'
|
|
||||||
|
|
||||||
import AActionButton from './AActionButton'
|
import AActionButton from './AActionButton'
|
||||||
|
import AFormSet from './AFormSet'
|
||||||
import ARow from './ARow'
|
import ARow from './ARow'
|
||||||
import ARows from './ARows'
|
|
||||||
import AModal from "./AModal"
|
import AModal from "./AModal"
|
||||||
|
|
||||||
/// Page display
|
/// Page display
|
||||||
|
@ -161,12 +128,14 @@ export const Page = {
|
||||||
}
|
}
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
components: { AActionButton, ARow, ARows, AModal },
|
components: { AActionButton, AFormSet, ARow, AModal },
|
||||||
props: {
|
props: {
|
||||||
|
formData: Object,
|
||||||
|
labels: Object,
|
||||||
|
|
||||||
///! initial data as: {items: [], fields: {column_name: label, settings: {}}
|
///! initial data as: {items: [], fields: {column_name: label, settings: {}}
|
||||||
initData: Object,
|
initData: Object,
|
||||||
dataPrefix: String,
|
dataPrefix: String,
|
||||||
labels: Object,
|
|
||||||
settingsUrl: String,
|
settingsUrl: String,
|
||||||
defaultColumns: {
|
defaultColumns: {
|
||||||
type: Array,
|
type: Array,
|
||||||
|
@ -175,13 +144,12 @@ export default {
|
||||||
|
|
||||||
data() {
|
data() {
|
||||||
const settings = {
|
const settings = {
|
||||||
tracklist_editor_columns: this.defaultColumns,
|
// tracklist_editor_columns: this.columns,
|
||||||
tracklist_editor_sep: ' -- ',
|
tracklist_editor_sep: ' -- ',
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
Page: Page,
|
Page: Page,
|
||||||
page: Page.Text,
|
page: Page.Text,
|
||||||
set: new Set(Model),
|
|
||||||
extraData: {},
|
extraData: {},
|
||||||
settings,
|
settings,
|
||||||
savedSettings: cloneDeep(settings),
|
savedSettings: cloneDeep(settings),
|
||||||
|
@ -189,6 +157,9 @@ export default {
|
||||||
},
|
},
|
||||||
|
|
||||||
computed: {
|
computed: {
|
||||||
|
rows() { return this.$refs.formset && this.$refs.formset.rows },
|
||||||
|
columns() { return this.rows && this.rows.columns_ || [] },
|
||||||
|
|
||||||
settingsChanged() {
|
settingsChanged() {
|
||||||
var k = Object.keys(this.savedSettings)
|
var k = Object.keys(this.savedSettings)
|
||||||
.findIndex(k => !isEqual(this.settings[k], this.savedSettings[k]))
|
.findIndex(k => !isEqual(this.settings[k], this.savedSettings[k]))
|
||||||
|
@ -204,25 +175,9 @@ export default {
|
||||||
get() { return this.settings.tracklist_editor_sep }
|
get() { return this.settings.tracklist_editor_sep }
|
||||||
},
|
},
|
||||||
|
|
||||||
columns: {
|
|
||||||
set(value) {
|
|
||||||
var cols = value.filter(x => x in this.defaultColumns)
|
|
||||||
var left = this.defaultColumns.filter(x => !(x in cols))
|
|
||||||
value = cols.concat(left)
|
|
||||||
this.settings.tracklist_editor_columns = value
|
|
||||||
},
|
|
||||||
get() {
|
|
||||||
return this.settings.tracklist_editor_columns
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
items() {
|
|
||||||
return this.set.items
|
|
||||||
},
|
|
||||||
|
|
||||||
rowsSlots() {
|
rowsSlots() {
|
||||||
return Object.keys(this.$slots)
|
return Object.keys(this.$slots)
|
||||||
.filter(x => x.startsWith('row-') || x.startsWith('rows-'))
|
.filter(x => x.startsWith('row-') || x.startsWith('rows-') || x.startsWith('control-'))
|
||||||
.map(x => [x, x.startsWith('rows-') ? x.slice(5) : x])
|
.map(x => [x, x.startsWith('rows-') ? x.slice(5) : x])
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
@ -235,35 +190,21 @@ export default {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
formatMove({from, to}) {
|
onColumnMove() {
|
||||||
const value = this.columns[from]
|
this.settings.tracklist_editor_columns = this.$refs.formset.rows.columnNames
|
||||||
this.settings.tracklist_editor_columns.splice(from, 1)
|
if(this.page == this.Page.List)
|
||||||
this.settings.tracklist_editor_columns.splice(to, 0, value)
|
|
||||||
if(this.page == Page.Text)
|
|
||||||
this.updateList()
|
|
||||||
else
|
|
||||||
this.updateInput()
|
this.updateInput()
|
||||||
},
|
else
|
||||||
|
this.updateList()
|
||||||
columnMove({from, to}) {
|
|
||||||
const value = this.columns[from]
|
|
||||||
this.columns.splice(from, 1)
|
|
||||||
this.columns.splice(to, 0, value)
|
|
||||||
this.updateInput()
|
|
||||||
},
|
|
||||||
|
|
||||||
listItemMove({from, to, set}) {
|
|
||||||
set.move(from, to);
|
|
||||||
this.updateInput()
|
|
||||||
},
|
},
|
||||||
|
|
||||||
updateList() {
|
updateList() {
|
||||||
const items = this.toList(this.$refs.textarea.value)
|
const items = this.toList(this.$refs.textarea.value)
|
||||||
this.set.reset(items)
|
this.$refs.formset.set.reset(items)
|
||||||
},
|
},
|
||||||
|
|
||||||
updateInput() {
|
updateInput() {
|
||||||
const input = this.toText(this.items)
|
const input = this.toText(this.$refs.formset.items)
|
||||||
this.$refs.textarea.value = input
|
this.$refs.textarea.value = input
|
||||||
},
|
},
|
||||||
|
|
||||||
|
@ -271,6 +212,7 @@ export default {
|
||||||
* From input and separator, return list of items.
|
* From input and separator, return list of items.
|
||||||
*/
|
*/
|
||||||
toList(input) {
|
toList(input) {
|
||||||
|
const columns = this.$refs.formset.rows.columns_
|
||||||
var lines = input.split('\n')
|
var lines = input.split('\n')
|
||||||
var items = []
|
var items = []
|
||||||
|
|
||||||
|
@ -281,11 +223,11 @@ export default {
|
||||||
|
|
||||||
var lineBits = line.split(this.separator)
|
var lineBits = line.split(this.separator)
|
||||||
var item = {}
|
var item = {}
|
||||||
for(var col in this.columns) {
|
for(var col in columns) {
|
||||||
if(col >= lineBits.length)
|
if(col >= lineBits.length)
|
||||||
break
|
break
|
||||||
const attr = this.columns[col]
|
const column = columns[col]
|
||||||
item[attr] = lineBits[col].trim()
|
item[column.name] = lineBits[col].trim()
|
||||||
}
|
}
|
||||||
item && items.push(item)
|
item && items.push(item)
|
||||||
}
|
}
|
||||||
|
@ -296,14 +238,15 @@ export default {
|
||||||
* From items and separator return a string
|
* From items and separator return a string
|
||||||
*/
|
*/
|
||||||
toText(items) {
|
toText(items) {
|
||||||
|
const columns = this.$refs.formset.rows.columns_
|
||||||
const sep = ` ${this.separator.trim()} `
|
const sep = ` ${this.separator.trim()} `
|
||||||
const lines = []
|
const lines = []
|
||||||
for(let item of items) {
|
for(let item of items) {
|
||||||
if(!item)
|
if(!item)
|
||||||
continue
|
continue
|
||||||
var line = []
|
var line = []
|
||||||
for(var col of this.columns)
|
for(var col of columns)
|
||||||
line.push(item.data[col] || '')
|
line.push(item.data[col.name] || '')
|
||||||
line = dropRightWhile(line, x => !x || !('' + x).trim())
|
line = dropRightWhile(line, x => !x || !('' + x).trim())
|
||||||
line = line.join(sep).trimRight()
|
line = line.join(sep).trimRight()
|
||||||
lines.push(line)
|
lines.push(line)
|
||||||
|
@ -331,31 +274,15 @@ export default {
|
||||||
this.$refs.settings.close()
|
this.$refs.settings.close()
|
||||||
this.savedSettings = cloneDeep(this.settings)
|
this.savedSettings = cloneDeep(this.settings)
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
|
||||||
* Load initial data
|
|
||||||
*/
|
|
||||||
loadData({items=[], settings=null}, reset=false) {
|
|
||||||
if(reset) {
|
|
||||||
this.set.items = []
|
|
||||||
}
|
|
||||||
for(var index in items)
|
|
||||||
this.set.push(cloneDeep(items[index]))
|
|
||||||
if(settings)
|
|
||||||
this.settingsSaved(settings)
|
|
||||||
this.updateInput()
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
watch: {
|
|
||||||
initData(val) {
|
|
||||||
this.loadData(val)
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
|
|
||||||
mounted() {
|
mounted() {
|
||||||
this.initData && this.loadData(this.initData)
|
const settings = this.initData && this.initData.settings
|
||||||
this.page = this.items.length ? Page.List : Page.Text
|
if(settings) {
|
||||||
|
this.settingsSaved(settings)
|
||||||
|
this.rows.sortColumns(settings.tracklist_editor_columns)
|
||||||
|
}
|
||||||
|
this.page = this.initData.items.length ? Page.List : Page.Text
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import AActionButton from './AActionButton'
|
import AActionButton from './AActionButton.vue'
|
||||||
import AAutocomplete from './AAutocomplete'
|
import AAutocomplete from './AAutocomplete'
|
||||||
import ACarousel from './ACarousel'
|
import ACarousel from './ACarousel'
|
||||||
import ADropdown from "./ADropdown"
|
import ADropdown from "./ADropdown"
|
||||||
|
@ -16,6 +16,8 @@ import AFileUpload from "./AFileUpload"
|
||||||
import ASelectFile from "./ASelectFile"
|
import ASelectFile from "./ASelectFile"
|
||||||
import AStatistics from './AStatistics'
|
import AStatistics from './AStatistics'
|
||||||
import AStreamer from './AStreamer'
|
import AStreamer from './AStreamer'
|
||||||
|
|
||||||
|
import AFormSet from './AFormSet'
|
||||||
import ATrackListEditor from './ATrackListEditor'
|
import ATrackListEditor from './ATrackListEditor'
|
||||||
import ASoundListEditor from './ASoundListEditor'
|
import ASoundListEditor from './ASoundListEditor'
|
||||||
|
|
||||||
|
@ -37,5 +39,6 @@ export const admin = {
|
||||||
|
|
||||||
export const dashboard = {
|
export const dashboard = {
|
||||||
...base,
|
...base,
|
||||||
AActionButton, AFileUpload, ASelectFile, AModal, ATrackListEditor, ASoundListEditor
|
AActionButton, AFileUpload, ASelectFile, AModal,
|
||||||
|
AFormSet, ATrackListEditor, ASoundListEditor
|
||||||
}
|
}
|
||||||
|
|
|
@ -17,13 +17,12 @@ const DashboardApp = {
|
||||||
methods: {
|
methods: {
|
||||||
...App.methods,
|
...App.methods,
|
||||||
|
|
||||||
fileSelected(select, cover, input, modal) {
|
fileSelected(select, input, preview) {
|
||||||
console.log("file!")
|
|
||||||
const item = this.$refs[select].item
|
const item = this.$refs[select].item
|
||||||
if(item) {
|
if(item) {
|
||||||
this.$refs[cover].src = item.file
|
|
||||||
this.$refs[input].value = item.id
|
this.$refs[input].value = item.id
|
||||||
modal && this.$refs[modal].close()
|
if(preview)
|
||||||
|
preview.src = item.file
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,9 +2,6 @@
|
||||||
* This module includes code available for both the public website and
|
* This module includes code available for both the public website and
|
||||||
* administration interface)
|
* administration interface)
|
||||||
*/
|
*/
|
||||||
//-- vendor
|
|
||||||
import '@fortawesome/fontawesome-free/css/all.min.css';
|
|
||||||
|
|
||||||
|
|
||||||
//-- aircox
|
//-- aircox
|
||||||
import App, {PlayerApp} from './app'
|
import App, {PlayerApp} from './app'
|
||||||
|
|
|
@ -2,8 +2,11 @@ import Model from './model';
|
||||||
|
|
||||||
|
|
||||||
export default class Sound extends Model {
|
export default class Sound extends Model {
|
||||||
|
constructor({sound={}, ...data}={}, options={}) {
|
||||||
|
// flatten EpisodeSound and sound data
|
||||||
|
super({...sound, ...data}, options)
|
||||||
|
}
|
||||||
|
|
||||||
get name() { return this.data.name }
|
get name() { return this.data.name }
|
||||||
get src() { return this.data.url }
|
get src() { return this.data.url }
|
||||||
|
|
||||||
static getId(data) { return data.pk }
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,9 +1,9 @@
|
||||||
@use "./vars";
|
@use "./vars";
|
||||||
@use "./components";
|
@use "./components";
|
||||||
|
|
||||||
@import "~bulma/sass/utilities/_all.sass";
|
@import "bulma/sass/utilities/_all.sass";
|
||||||
@import "~bulma/sass/elements/button";
|
@import "bulma/sass/elements/button";
|
||||||
@import "~bulma/sass/components/navbar";
|
@import "bulma/sass/components/navbar";
|
||||||
|
|
||||||
|
|
||||||
// enforce button usage inside custom application
|
// enforce button usage inside custom application
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
@import 'v-calendar/style.css';
|
@import 'v-calendar/style.css';
|
||||||
|
@import '@fortawesome/fontawesome-free/css/all.min.css';
|
||||||
|
|
||||||
// ---- bulma
|
// ---- bulma
|
||||||
$body-color: #000;
|
$body-color: #000;
|
||||||
|
@ -6,29 +7,29 @@ $title-color: #000;
|
||||||
$modal-content-width: 80%;
|
$modal-content-width: 80%;
|
||||||
|
|
||||||
|
|
||||||
@import "~bulma/sass/utilities/_all.sass";
|
@import "bulma/sass/utilities/_all.sass";
|
||||||
|
|
||||||
|
|
||||||
@import "~bulma/sass/base/_all";
|
@import "bulma/sass/base/_all";
|
||||||
@import "~bulma/sass/components/dropdown";
|
@import "bulma/sass/components/dropdown";
|
||||||
// @import "~bulma/sass/components/card";
|
// @import "bulma/sass/components/card";
|
||||||
@import "~bulma/sass/components/media";
|
@import "bulma/sass/components/media";
|
||||||
@import "~bulma/sass/components/message";
|
@import "bulma/sass/components/message";
|
||||||
@import "~bulma/sass/components/modal";
|
@import "bulma/sass/components/modal";
|
||||||
//@import "~bulma/sass/components/pagination";
|
//@import "bulma/sass/components/pagination";
|
||||||
|
|
||||||
@import "~bulma/sass/form/_all";
|
@import "bulma/sass/form/_all";
|
||||||
@import "~bulma/sass/grid/_all";
|
@import "bulma/sass/grid/_all";
|
||||||
@import "~bulma/sass/helpers/_all";
|
@import "bulma/sass/helpers/_all";
|
||||||
@import "~bulma/sass/layout/_all";
|
@import "bulma/sass/layout/_all";
|
||||||
@import "~bulma/sass/elements/box";
|
@import "bulma/sass/elements/box";
|
||||||
// @import "~bulma/sass/elements/button";
|
// @import "bulma/sass/elements/button";
|
||||||
@import "~bulma/sass/elements/container";
|
@import "bulma/sass/elements/container";
|
||||||
// @import "~bulma/sass/elements/content";
|
// @import "bulma/sass/elements/content";
|
||||||
@import "~bulma/sass/elements/icon";
|
@import "bulma/sass/elements/icon";
|
||||||
// @import "~bulma/sass/elements/image";
|
// @import "bulma/sass/elements/image";
|
||||||
// @import "~bulma/sass/elements/notification";
|
// @import "bulma/sass/elements/notification";
|
||||||
// @import "~bulma/sass/elements/progress";
|
// @import "bulma/sass/elements/progress";
|
||||||
@import "~bulma/sass/elements/table";
|
@import "bulma/sass/elements/table";
|
||||||
@import "~bulma/sass/elements/tag";
|
@import "bulma/sass/elements/tag";
|
||||||
//@import "~bulma/sass/elements/title";
|
//@import "bulma/sass/elements/title";
|
||||||
|
|
|
@ -20,13 +20,18 @@ from django.contrib import admin
|
||||||
from django.urls import include, path
|
from django.urls import include, path
|
||||||
|
|
||||||
import aircox.urls
|
import aircox.urls
|
||||||
|
import aircox_streamer.urls
|
||||||
|
|
||||||
urlpatterns = aircox.urls.urls + [
|
urlpatterns = (
|
||||||
path("admin/", admin.site.urls),
|
aircox.urls.urls
|
||||||
path("accounts/", include("django.contrib.auth.urls")),
|
+ aircox_streamer.urls.urls
|
||||||
path("ckeditor/", include("ckeditor_uploader.urls")),
|
+ [
|
||||||
path("filer/", include("filer.urls")),
|
path("admin/", admin.site.urls),
|
||||||
]
|
path("accounts/", include("django.contrib.auth.urls")),
|
||||||
|
path("ckeditor/", include("ckeditor_uploader.urls")),
|
||||||
|
path("filer/", include("filer.urls")),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
if settings.DEBUG:
|
if settings.DEBUG:
|
||||||
urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT) + static(
|
urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT) + static(
|
||||||
|
|
Loading…
Reference in New Issue
Block a user