work on sound list

This commit is contained in:
bkfox 2024-03-25 18:05:55 +01:00
parent f41cc3ce0c
commit 70a55607a5
17 changed files with 433 additions and 226 deletions

View File

@ -53,6 +53,12 @@ class SoundFilterSet(filters.FilterSet):
episode = filters.NumberFilter(field_name="episode_id")
search = filters.CharFilter(field_name="search", method="search_filter")
class Meta:
model = models.Sound
fields = {
"episode": ["in", "exact", "isnull"],
}
def search_filter(self, queryset, name, value):
return queryset.search(value)

View File

@ -5,7 +5,7 @@ from django.forms.models import modelformset_factory
from aircox import models
__all__ = ("CommentForm", "PageForm", "ProgramForm", "EpisodeForm", "SoundForm", "TrackFormSet")
__all__ = ("CommentForm", "PageForm", "ProgramForm", "EpisodeForm", "SoundForm", "SoundFormSet", "TrackFormSet")
class CommentForm(forms.ModelForm):
@ -60,7 +60,15 @@ class SoundForm(forms.ModelForm):
class Meta:
model = models.Sound
fields = ["name", "program", "episode", "type", "position", "duration", "is_public", "is_downloadable"]
fields = ["name", "program", "episode", "file", "type", "position", "duration", "is_public", "is_downloadable"]
class SoundCreateForm(forms.ModelForm):
"""SoundForm used in EpisodeUpdateView."""
class Meta:
model = models.Sound
fields = ["name", "episode", "program", "file", "type", "is_public", "is_downloadable"]
TrackFormSet = modelformset_factory(
@ -84,6 +92,7 @@ SoundFormSet = modelformset_factory(
"type",
"is_public",
"is_downloadable",
"duration",
],
extra=0,
)

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,25 @@
{% comment %}
Render a form field instance as field (to be used when no model instance is provided). Value is binded as vue, class to Bulma
Context:
- name: field name
- field: form field
- value: input ":value" attribute
- vbind: if True, use ":value" instead of "value"
- hidden: if True, hidden field
{% endcomment %}
{% load aircox %}
{% if field.is_hidden or hidden %}
<input type="hidden" name="{{ name }}" {% if vbind %}:value{% else %}value{% endif %}="{{ value|default:"" }}">
{% elif field|is_checkbox %}
<input type="checkbox" class="checkbox" name="{{ name }}" {% if vbind %}:checked="{{ value }}"{% elif value %}checked{% endif %}>
{% elif field|is_select %}
<select name="{{ name }}" class="select" {% if vbind %}:value{% else %}value{% endif %}="{{ value|default:"" }}">
{% for value, label in field.widget.choices %}
<option value="{{ value }}">{{ label }}</option>
{% endfor %}
</select>
{% else %}
<input type="text" class="input" name="{{ name }}" {% if vbind %}:value{% else %}value{% endif %}="{{ value|default:"" }}">
{% endif %}

View File

@ -1,24 +1,31 @@
{% comment %}
Base template for list editor based on formsets (tracklist_editor, playlist_editor).
Context:
- formset: formset
- tag_id: id of parent component
- tag: vue component tag (a-playlist-editor, etc.)
- formset: formset used to render the list editor
{% endcomment %}
{% load aircox aircox_admin static i18n %}
{% with formset.form.base_fields as fields %}
<div id="inline-sounds">
{% block outer %}
<div id="{{ tag_id }}">
{{ formset.non_form_errors }}
<!-- formset.management_form -->
<a-playlist-editor
<{{ tag }}
{% block tag-attrs %}
:labels="{% inline_labels %}"
:init-data="{% formset_inline_data formset=formset %}"
:default-columns="[{% for f in fields.keys %}{% if f != "position" %}'{{ f }}',{% endif %}{% endfor %}]"
settings-url="{% url "api:user-settings" %}"
data-prefix="{{ formset.prefix }}-">
<template #title>
<h3 class="title is-2">{% trans "Playlist" %}</h3>
</template>
data-prefix="{{ formset.prefix }}-"
{% endblock %}>
{% 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"
@ -27,16 +34,20 @@ Context:
value="{{ formset.min_num }}"/>
<input type="hidden" name="{{ formset.prefix }}-MAX_NUM_FORMS"
value="{{ formset.max_num }}"/>
{% endblock %}
</template>
<template #rows-header-head>
<th style="max-width:2em" title="{% trans "Sound Position" %}"
aria-description="{% trans "Sound Position" %}">
{% block rows-header-head %}
<th style="max-width:2em" title="{{ fields.position.help_text }}"
aria-description="{{ fields.position.help_text }}">
<span class="icon">
<i class="fa fa-arrow-down-1-9"></i>
</span>
</th>
{% endblock %}
</template>
<template v-slot:row-head="{item,row}">
{% block row-head %}
<td>
[[ row+1 ]]
<input type="hidden"
@ -54,14 +65,17 @@ Context:
{% endif %}
{% endfor %}
</td>
{% endblock %}
</template>
{% for name, field in fields.items %}
{% if not field.widget.is_hidden and not field.is_readonly %}
<template v-slot:row-{{ name }}="{item,cell,value,attr,emit}">
<div class="field">
{% block row-field %}
<div class="control">
{% include "./form_field.html" with field=field name=name value="item.data."|add:name %}
{% include "./form_field.html" with value="item.data."|add:name vbind=1 %}
</div>
{% endblock %}
<p v-for="error in item.error(attr)" class="help is-danger">
[[ error ]] !
</p>
@ -69,6 +83,8 @@ Context:
</template>
{% endif %}
{% endfor %}
</a-playlist-editor>
{% endblock %}
</{{ tag }}>
</div>
{% endblock %}
{% endwith %}

View File

@ -0,0 +1,40 @@
{% extends "./list_editor.html" %}
{% block outer %}
{% with tag_id="inline-sounds" %}
{% with tag="a-sound-list-editor" %}
{{ block.super }}
{% endwith %}
{% endwith %}
{% endblock %}
{% block tag-attrs %}
{{ block.super }}
sound-list-url="{% url "api:sound-list" %}?program={{ object.pk }}&episode__isnull"
sound-upload-url="{% url "api:sound-list" %}"
{% endblock %}
{% block inner %}
{{ block.super }}
<template #upload-form>
{% for field in sound_form %}
{% with field.name as name %}
{% with field.initial as value %}
{% with field.field as field %}
{% if name in "episode,program" %}
{% include "./form_field.html" with value=value hidden=True %}
{% elif name != "file" %}
<div class="field is-horizontal">
<label class="label mr-3">{{ field.label }}</label>
{% include "./form_field.html" with value=value %}
</div>
{% endif %}
{% endwith %}
{% endwith %}
{% endwith %}
{% endfor %}
</template>
{% endblock %}

View File

@ -0,0 +1,19 @@
{% extends "./list_editor.html" %}
{% block outer %}
{% with tag_id="inline-tracks" %}
{% with tag="a-track-list-editor" %}
{{ block.super }}
{% endwith %}
{% endwith %}
{% endblock %}
{% block row-field %}
<a-autocomplete
:input-class="['input', item.error(attr) ? 'is-danger' : 'half-field']"
url="{% url 'api:track-autocomplete' %}?{{ name }}=${query}&field={{ name }}"
:name="'{{ formset.prefix }}-' + cell.row + '-{{ name }}'"
v-model="item.data[attr]"
title="{{ name }}"
@change="emit('change', col)"/>
{% endblock %}

View File

@ -2,41 +2,15 @@
{% load static i18n humanize honeypot aircox %}
{% block page_form %}
<a-modal ref="sound-edit-modal" v-if="item" title="{% translate "Edit sound" %}">
<template #default="{item}">
{% for field in sound_form %}
{% if field.name in "episode,program" %}
<input type="hidden" name="{{ field.name }}" value="{{ field.value }}" />
{% else %}
<div class="field">
{% if field|is_checkbox %}
<label class="label">
<input type="text" name="{{ field.name }}" :value="item.{{ field.name }}">
{{ field.label }}
</label>
{% else %}
<label class="label">{{ field.label }}</label>
<div class="control">
<input type="text" name="{{ field.name }}" :value="item.{{ field.name }}">
</div>
{% endif %}
<p class="help">{{ field.help_text }}</p>
</div>
{% endif %}
{% endfor %}
</template>
</a-modal>
<a-episode :page="{title: &quot;{{ object.title }}&quot;, podcasts: {{ object.sounds|json }}}">
<template v-slot="{podcasts,page}">
{{ block.super }}
<hr/>
{% include "./widgets/tracklist_editor.html" with formset=tracklist_formset %}
{% include "./dashboard/tracklist_editor.html" with formset=tracklist_formset %}
<hr/>
<section class="container">
<h3 class="title">{% translate "Sound files" %}</h3>
{% include "./widgets/playlist_editor.html" with formset=playlist_formset %}
{% include "./dashboard/soundlist_editor.html" with formset=soundlist_formset %}
</section>
</template>
</a-episode>

View File

@ -1,21 +0,0 @@
{% comment %}
Render a form field instance as field (to be used when no model instance is provided). Value is binded as vue, class to Bulma
Context:
- name: field name
- field: form field
- value: input ":value" attribute
{% endcomment %}
{% load aircox %}
{% if field|is_checkbox %}
<input type="checkbox" class="checkbox" name="{{ name }}" :checked="{{ value }}">
{% elif field|is_select %}
<select name="{{ name }}" class="select" :value="{{ value }}">
{% for value, label in field.widget.choices %}
<option value="{{ value }}">{{ label }}</option>
{% endfor %}
</select>
{% else %}
<input type="text" class="input" name="{{ name }}" :value="{{ value }}">
{% endif %}

View File

@ -1,80 +0,0 @@
{% comment %}
Context:
- formset: playlist's track formset
{% endcomment %}
{% load aircox aircox_admin static i18n %}
{% with formset.form.base_fields as fields %}
<div id="inline-tracks">
{{ formset.non_form_errors }}
<!-- formset.management_form -->
<a-tracklist-editor
:labels="{% inline_labels %}"
:init-data="{% formset_inline_data formset=formset %}"
:default-columns="[{% for f in fields.keys %}{% if f != "position" %}'{{ f }}',{% endif %}{% endfor %}]"
settings-url="{% url "api:user-settings" %}"
data-prefix="{{ formset.prefix }}-">
<template #title>
<h3 class="title is-2">{% trans "Playlist" %}</h3>
</template>
<template #top="{items}">
<input type="hidden" name="{{ formset.prefix }}-TOTAL_FORMS"
:value="items.length || 0"/>
<input type="hidden" name="{{ formset.prefix }}-INITIAL_FORMS"
value="{{ formset.initial_form_count }}"/>
<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 }}"/>
</template>
<template #rows-header-head>
<th style="max-width:2em" title="{% trans "Track Position" %}"
aria-description="{% trans "Track Position" %}">
<span class="icon">
<i class="fa fa-arrow-down-1-9"></i>
</span>
</th>
</template>
<template v-slot:row-head="{item,row}">
<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>
</template>
{% for name, field in fields.items %}
{% if not field.widget.is_hidden and not field.is_readonly %}
---
<template v-slot:row-{{ name }}="{item,cell,value,attr,emit}">
<div class="field">
<a-autocomplete
:input-class="['input', item.error(attr) ? 'is-danger' : 'half-field']"
url="{% url 'api:track-autocomplete' %}?{{ name }}=${query}&field={{ name }}"
:name="'{{ formset.prefix }}-' + cell.row + '-{{ name }}'"
v-model="item.data[attr]"
title="{{ name }}"
@change="emit('change', col)"/>
<p v-for="error in item.error(attr)" class="help is-danger">
[[ error ]] !
</p>
</div>
</template>
{% endif %}
{% endfor %}
</a-tracklist-editor>
</div>
{% endwith %}

View File

@ -40,6 +40,10 @@ def do_formset_inline_data(context, formset):
item = {name: form[name].value() for name in form.fields.keys()}
item["__errors__"] = form.errors
# hack for sound list
if duration := item.get("duration"):
item["duration"] = duration.strftime("%H:%M")
# hack for playlist editor
tags = item.get("tags")
if tags and not isinstance(tags, str):
@ -60,9 +64,13 @@ inline_labels_ = {
"remove_item": _("Remove"),
"save_settings": _("Save Settings"),
"discard_changes": _("Discard changes"),
"select_file": _("Select a file"),
"submit": _("Submit"),
# track list
"columns": _("Columns"),
"timestamp": _("Timestamp"),
# sound list
"add_sound": _("Add a sound"),
}

View File

@ -1,7 +1,7 @@
from django.contrib.auth.mixins import UserPassesTestMixin
from django.urls import reverse
from aircox.models import Episode, Program, StaticPage, Track
from aircox.models import Episode, Program, StaticPage, Sound, Track
from aircox import forms
from ..filters import EpisodeFilters
from .page import PageListView
@ -72,13 +72,13 @@ class EpisodeUpdateView(UserPassesTestMixin, BaseProgramMixin, PageUpdateView):
)
return forms.TrackFormSet(**kwargs)
def get_playlist_queryset(self, episode):
def get_soundlist_queryset(self, episode):
return episode.sound_set.all()
def get_playlist_formset(self, episode, **kwargs):
def get_soundlist_formset(self, episode, **kwargs):
kwargs.update(
{
"queryset": self.get_playlist_queryset(episode),
"queryset": self.get_soundlist_queryset(episode),
"initial": {
"program": episode.parent_id,
"episode": episode.id,
@ -87,11 +87,26 @@ class EpisodeUpdateView(UserPassesTestMixin, BaseProgramMixin, PageUpdateView):
)
return forms.SoundFormSet(**kwargs)
def get_sound_form(self, episode, **kwargs):
kwargs.update(
{
"initial": {
"program": episode.parent_id,
"episode": episode.pk,
"name": episode.title,
"is_public": True,
"type": Sound.TYPE_ARCHIVE,
},
}
)
return forms.SoundCreateForm(**kwargs)
def get_context_data(self, **kwargs):
kwargs.update(
{
"playlist_formset": self.get_playlist_formset(self.object),
"soundlist_formset": self.get_soundlist_formset(self.object),
"tracklist_formset": self.get_tracklist_formset(self.object),
"sound_form": self.get_sound_form(self.object),
}
)
return super().get_context_data(**kwargs)

View File

@ -1,9 +1,7 @@
from django_filters import rest_framework as drf_filters
from rest_framework import status, viewsets
from rest_framework import status, viewsets, parsers, permissions
from rest_framework.decorators import action
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.parsers import MultiPartParser
from filer.models.imagemodels import Image
@ -20,7 +18,7 @@ __all__ = (
class ImageViewSet(viewsets.ModelViewSet):
parsers = (MultiPartParser,)
parsers = (parsers.MultiPartParser,)
serializer_class = admin.ImageSerializer
queryset = Image.objects.all().order_by("-uploaded_at")
filter_backends = (drf_filters.DjangoFilterBackend,)
@ -37,6 +35,8 @@ class ImageViewSet(viewsets.ModelViewSet):
class SoundViewSet(BaseAPIView, viewsets.ModelViewSet):
parsers = (parsers.MultiPartParser,)
permissions = (permissions.IsAuthenticatedOrReadOnly,)
serializer_class = SoundSerializer
queryset = models.Sound.objects.available().order_by("-pk")
filter_backends = (drf_filters.DjangoFilterBackend,)
@ -47,7 +47,7 @@ class TrackROViewSet(viewsets.ReadOnlyModelViewSet):
"""Track viewset used for auto completion."""
serializer_class = admin.TrackSerializer
permission_classes = [IsAuthenticated]
permission_classes = (permissions.IsAuthenticated,)
filter_backends = (drf_filters.DjangoFilterBackend,)
filterset_class = filters.TrackFilterSet
queryset = models.Track.objects.all()
@ -70,7 +70,7 @@ class UserSettingsViewSet(viewsets.ViewSet):
"""
serializer_class = admin.UserSettingsSerializer
permission_classes = [IsAuthenticated]
permission_classes = (permissions.IsAuthenticated,)
def get_serializer(self, instance=None, **kwargs):
return self.serializer_class(instance=instance, context={"user": self.request.user}, **kwargs)

View File

@ -0,0 +1,110 @@
<template>
<div ref="list" class="a-select-file-list">
<form ref="form" class="flex-column" v-if="state == STATE.DEFAULT">
<slot name="form"></slot>
<div class="field is-horizontal">
<label class="label">{{ label }}</label>
<input type="file" ref="uploadFile" :name="fieldName" @change="onFileChange"/>
</div>
<div class="flex-row align-right" v-if="submitLabel">
<button type="button" class="button small" @click="submit">
{{ submitLabel }}
</button>
</div>
</form>
<div class="flex-column" v-else>
<slot name="preview" :fileUrl="fileUrl" :file="file" :loaded="loaded" :total="total"></slot>
<div class="flex-row">
<progress :max="total" :value="loaded"/>
<button type="button" class="button small square ml-2" @click="abort">
<span class="icon small">
<i class="fa fa-close"></i>
</span>
</button>
</div>
</div>
</div>
</template>
<script>
import {getCsrf} from "../model"
export default {
emit: ["fileChange", "load"],
props: {
url: { type: String },
fieldName: { type: String, default: "file" },
label: { type: String, default: "Select a file" },
submitLabel: { type: String, default: "Upload" },
},
data() {
return {
STATE: {
DEFAULT: 0,
UPLOADING: 1,
},
state: 0,
upload: {},
file: null,
fileUrl: null,
total: 0,
loaded: 0,
request: null,
}
},
methods: {
abort() {
this.request && this.request.abort()
},
onFileChange() {
const [file] = this.$refs.uploadFile.files
if(!file)
return
this._setUploadFile(file)
this.$emit("fileChange", {upload: this, file: this.file, fileUrl: this.fileUrl})
},
submit() {
const req = new XMLHttpRequest()
req.open("POST", this.url)
req.upload.addEventListener("progress", (e) => this.onUploadProgress(e))
req.addEventListener("load", (e) => this.onUploadDone(e))
req.addEventListener("abort", (e) => this.onUploadDone(e))
req.addEventListener("error", (e) => this.onUploadDone(e))
const formData = new FormData(this.$refs.form);
formData.append('csrfmiddlewaretoken', getCsrf())
req.send(formData)
this._resetUpload(this.STATE.UPLOADING, false, req)
},
onUploadProgress(event) {
this.loaded = event.loaded
this.total = event.total
},
onUploadDone(event) {
this.$emit("load", event)
this._resetUpload(this.STATE.DEFAULT, true)
},
_setUploadFile(file) {
this.file = file
this.fileURL = file && URL.createObjectURL(file)
},
_resetUpload(state, resetFile=false, request=null) {
this.state = state
this.loaded = 0
this.total = 0
this.request = request
if(resetFile)
this.file = null
}
},}
</script>

View File

@ -1,5 +1,29 @@
<template>
<div class="a-playlist-editor">
<a-modal ref="modal" :title="labels && labels.add_sound">
<template #default>
<a-file-upload ref="file-upload" :url="soundUploadUrl" :label="labels.select_file" submitLabel="" @load="uploadDone"
>
<template #preview="{upload}">
<slot name="upload-preview" :upload="upload"></slot>
</template>
<template #form>
<slot name="upload-form"></slot>
</template>
</a-file-upload>
</template>
<template #footer>
<button type="button" class="button"
@click.stop="$refs['file-upload'].submit()">
<span class="icon">
<i class="fa fa-upload"></i>
</span>
<span>{{ labels.submit }}</span>
</button>
</template>
</a-modal>
<a-rows :set="set" :columns="columns"
:labels="initData.fields" :allow-create="true" :orderable="true"
@move="listItemMove">
@ -21,7 +45,7 @@
<span class="icon"><i class="fa fa-rotate" /></span>
</button>
<button type="button" class="button square is-primary p-2"
@click="this.set.push(new this.set.model())"
@click="$refs.modal.open()"
:title="labels.add_sound"
:aria-label="labels.add_sound"
>
@ -38,16 +62,19 @@ import Model, {Set} from '../model'
// import AActionButton from './AActionButton'
import ARows from './ARows'
// import AModal from "./AModal"
import AModal from "./AModal"
import AFileUpload from "./AFileUpload"
export default {
components: {ARows},
components: {ARows, AModal, AFileUpload},
props: {
initData: Object,
dataPrefix: String,
labels: Object,
settingsUrl: String,
soundListUrl: String,
soundUploadUrl: String,
columns: {
type: Array,
default: () => ['name', "type", 'is_public', 'is_downloadable']
@ -89,6 +116,14 @@ export default {
// 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)
}
},
},

View File

@ -12,11 +12,12 @@ import ASoundItem from './ASoundItem'
import ASwitch from './ASwitch'
import AModal from "./AModal"
import AFileUpload from "./AFileUpload"
import ASelectFile from "./ASelectFile"
import AStatistics from './AStatistics'
import AStreamer from './AStreamer'
import ATracklistEditor from './ATracklistEditor'
import APlaylistEditor from './APlaylistEditor'
import ATrackListEditor from './ATrackListEditor'
import ASoundListEditor from './ASoundListEditor'
/**
* Core components
@ -31,10 +32,10 @@ export default base
export const admin = {
...base,
AStatistics, AStreamer, ATracklistEditor
AStatistics, AStreamer, ATrackListEditor
}
export const dashboard = {
...base,
AActionButton, ASelectFile, AModal, ATracklistEditor, APlaylistEditor
AActionButton, AFileUpload, ASelectFile, AModal, ATrackListEditor, ASoundListEditor
}