This commit is contained in:
bkfox 2024-04-19 15:06:23 +02:00
parent 1d321a0de6
commit 07d72d799d
60 changed files with 503 additions and 306 deletions

View File

@ -233,12 +233,12 @@ class SoundMonitor:
if not program.ensure_dir(subdir):
return
subdir = os.path.join(program.abspath, subdir)
abs_subdir = os.path.join(program.abspath, subdir)
sounds = []
# sounds in directory
for path in os.listdir(subdir):
path = os.path.join(subdir, path)
for path in os.listdir(abs_subdir):
path = os.path.join(abs_subdir, path)
if not path.endswith(settings.SOUND_FILE_EXT):
continue
@ -247,7 +247,7 @@ class SoundMonitor:
sounds.append(sound_file.sound.pk)
# sounds in db & unchecked
sounds = Sound.objects.filter(file__startswith=subdir).exclude(pk__in=sounds)
sounds = Sound.objects.filter(file__startswith=program.path).exclude(pk__in=sounds)
self.check_sounds(sounds, program=program)
def check_sounds(self, qs, **sync_kwargs):

View File

@ -1,102 +0,0 @@
from django import forms
from django.forms.models import modelformset_factory
from aircox import models
__all__ = ("CommentForm", "PageForm", "ProgramForm", "EpisodeForm", "SoundForm", "EpisodeSoundFormSet", "TrackFormSet")
class CommentForm(forms.ModelForm):
nickname = forms.CharField()
email = forms.EmailField(required=False)
content = forms.CharField(widget=forms.Textarea())
nickname.widget.attrs.update({"class": "input"})
email.widget.attrs.update({"class": "input"})
content.widget.attrs.update({"class": "textarea"})
class Meta:
model = models.Comment
fields = ["nickname", "email", "content"]
class ImageForm(forms.Form):
file = forms.ImageField()
class PageForm(forms.ModelForm):
class Meta:
fields = ("title", "category", "status", "cover", "content")
model = models.Page
class ChildPageForm(forms.ModelForm):
class Meta:
fields = ("title", "status", "cover", "content")
model = models.Page
class ProgramForm(PageForm):
class Meta:
fields = PageForm.Meta.fields
model = models.Program
class EpisodeForm(PageForm):
class Meta:
model = models.Episode
fields = ChildPageForm.Meta.fields
class SoundForm(forms.ModelForm):
"""SoundForm used in EpisodeUpdateView."""
class Meta:
model = models.Sound
fields = ["name", "program", "file", "broadcast", "duration", "is_public", "is_downloadable"]
class SoundCreateForm(forms.ModelForm):
"""SoundForm used in EpisodeUpdateView."""
class Meta:
model = models.Sound
fields = ["name", "program", "file", "broadcast", "is_public", "is_downloadable"]
widgets = {"program": forms.HiddenInput()}
TrackFormSet = modelformset_factory(
models.Track,
fields=[
"position",
"episode",
"artist",
"title",
"tags",
],
widgets={"episode": forms.HiddenInput(), "position": forms.HiddenInput()},
can_delete=True,
extra=0,
)
"""Track formset used in EpisodeUpdateView."""
EpisodeSoundFormSet = modelformset_factory(
models.EpisodeSound,
fields=(
"position",
"episode",
"sound",
"broadcast",
),
widgets={
"broadcast": forms.CheckboxInput(),
"episode": forms.HiddenInput(),
# "sound": forms.HiddenInput(),
"position": forms.HiddenInput(),
},
can_delete=True,
extra=0,
)

23
aircox/forms/__init__.py Normal file
View File

@ -0,0 +1,23 @@
from . import widgets
from .auth import UserGroupFormSet
from .episode import EpisodeForm, EpisodeSoundFormSet
from .program import ProgramForm
from .page import CommentForm, ImageForm, PageForm, ChildPageForm
from .sound import SoundForm, SoundCreateForm
__all__ = (
widgets,
# ---- forms
EpisodeForm,
EpisodeSoundFormSet,
ProgramForm,
CommentForm,
ImageForm,
PageForm,
ChildPageForm,
SoundForm,
SoundCreateForm,
UserGroupFormSet,
)

20
aircox/forms/auth.py Normal file
View File

@ -0,0 +1,20 @@
from django import forms
from django.forms.models import modelformset_factory
from django.contrib.auth.models import User
from aircox.forms import widgets
__all__ = ("UserGroupFormSet",)
UserGroupFormSet = modelformset_factory(
User.groups.through,
fields=("group", "user"),
widgets={
"group": forms.HiddenInput(),
"user": widgets.VueAutoComplete("api:usergroup-autocomplete", lookup="username"),
},
extra=0,
can_delete=True,
)

34
aircox/forms/episode.py Normal file
View File

@ -0,0 +1,34 @@
from django import forms
from django.forms.models import modelformset_factory
from aircox import models
from .page import ChildPageForm
__all__ = ("EpisodeForm", "EpisodeSoundFormSet")
class EpisodeForm(ChildPageForm):
class Meta:
model = models.Episode
fields = ChildPageForm.Meta.fields
EpisodeSoundFormSet = modelformset_factory(
models.EpisodeSound,
fields=(
"position",
"episode",
"sound",
"broadcast",
),
widgets={
"broadcast": forms.CheckboxInput(),
"episode": forms.HiddenInput(),
# "sound": forms.HiddenInput(),
"position": forms.HiddenInput(),
},
can_delete=True,
extra=0,
)
"""Formset used in EpisodeUpdateView."""

37
aircox/forms/page.py Normal file
View File

@ -0,0 +1,37 @@
from django import forms
from aircox import models
__all__ = ("CommentForm", "ImageForm", "PageForm", "ChildPageForm")
class CommentForm(forms.ModelForm):
nickname = forms.CharField()
email = forms.EmailField(required=False)
content = forms.CharField(widget=forms.Textarea())
nickname.widget.attrs.update({"class": "input"})
email.widget.attrs.update({"class": "input"})
content.widget.attrs.update({"class": "textarea"})
class Meta:
model = models.Comment
fields = ["nickname", "email", "content"]
class ImageForm(forms.Form):
file = forms.ImageField()
class PageForm(forms.ModelForm):
class Meta:
fields = ("title", "category", "status", "cover", "content")
model = models.Page
class ChildPageForm(forms.ModelForm):
class Meta:
fields = ("title", "status", "cover", "content")
model = models.Page

11
aircox/forms/program.py Normal file
View File

@ -0,0 +1,11 @@
from aircox import models
from .page import PageForm
__all__ = ("ProgramForm",)
class ProgramForm(PageForm):
class Meta:
fields = PageForm.Meta.fields
model = models.Program

26
aircox/forms/sound.py Normal file
View File

@ -0,0 +1,26 @@
from django import forms
from aircox import models
__all__ = (
"SoundForm",
"SoundCreateForm",
)
class SoundForm(forms.ModelForm):
"""SoundForm used in EpisodeUpdateView."""
class Meta:
model = models.Sound
fields = ["name", "program", "file", "broadcast", "duration", "is_public", "is_downloadable"]
class SoundCreateForm(forms.ModelForm):
"""SoundForm used in EpisodeUpdateView."""
class Meta:
model = models.Sound
fields = ["name", "program", "file", "broadcast", "is_public", "is_downloadable"]
widgets = {"program": forms.HiddenInput()}

23
aircox/forms/track.py Normal file
View File

@ -0,0 +1,23 @@
from django import forms
from django.forms.models import modelformset_factory
from aircox import models
__all__ = ("TrackFormSet",)
TrackFormSet = modelformset_factory(
models.Track,
fields=[
"position",
"episode",
"artist",
"title",
"tags",
],
widgets={"episode": forms.HiddenInput(), "position": forms.HiddenInput()},
can_delete=True,
extra=0,
)
"""Track formset used in EpisodeUpdateView."""

View File

@ -4,14 +4,6 @@ from django.db import migrations, models
import django.db.models.deletion
def init_groups_and_permissions(app, schema_editor):
from aircox.permissions import program_permissions
Program = app.get_model("aircox", "Program")
for program in Program.objects.all():
program_permissions.init(program)
class Migration(migrations.Migration):
dependencies = [
("auth", "0012_alter_user_first_name_max_length"),
@ -25,10 +17,9 @@ class Migration(migrations.Migration):
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
on_delete=django.db.models.deletion.SET_NULL,
to="auth.group",
verbose_name="editors",
),
),
migrations.RunPython(init_groups_and_permissions),
]

View File

@ -7,7 +7,7 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("filer", "0017_image__transparent"),
("aircox", "0021_alter_schedule_timezone"),
("aircox", "0022_set_group_ownership"),
]
operations = [

View File

@ -10,7 +10,7 @@ sounds_info = {}
def get_sounds_info(apps, schema_editor):
Sound = apps.get_model("aircox", "Sound")
objs = Sound.objects.filter(episode__isnull=False).values(
objs = Sound.objects.filter().values(
"pk",
"episode_id",
"position",
@ -36,7 +36,7 @@ def restore_sounds_info(apps, schema_editor):
sound.broadcast = info["type"] == TYPE_ARCHIVE
sound.is_removed = info["type"] == TYPE_REMOVED
sounds.append(sound)
if not sound.is_removed:
if not sound.is_removed and info["episode_id"]:
obj = EpisodeSound(
sound=sound,
episode_id=info["episode_id"],

View File

@ -33,11 +33,6 @@ def _restore_for_objs(objs):
return updated
def set_group_ownership(*args):
for program in Program.objects.all():
program.set_group_ownership()
class Migration(migrations.Migration):
dependencies = [
("aircox", "0026_alter_sound_options_remove_sound_episode_and_more"),

View File

@ -107,7 +107,10 @@ class File(models.Model):
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())
exists = self.file_exists()
if self.is_removed != (not exists):
return True
return exists and self.mtime != self.get_mtime()
def file_exists(self):
"""Return true if the file still exists."""
@ -130,7 +133,7 @@ class File(models.Model):
name = name.replace("_", " ").strip()
is_removed = not self.file_exists()
mtime = self.get_mtime()
mtime = (not is_removed and self.get_mtime()) or None
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

View File

@ -63,7 +63,7 @@ class Program(Page):
default=True,
help_text=_("update later diffusions according to schedule changes"),
)
editors_group = models.ForeignKey(Group, models.CASCADE, blank=True, null=True, verbose_name=_("editors"))
editors_group = models.ForeignKey(Group, models.CASCADE, verbose_name=_("editors"))
objects = ProgramQuerySet.as_manager()
detail_url_name = "program-detail"

View File

@ -24,7 +24,7 @@ class SoundQuerySet(FileQuerySet):
def broadcast(self):
"""Return sounds that are archives."""
return self.filter(broadcast=True)
return self.filter(broadcast=True, is_removed=False)
def playlist(self, order_by="file"):
"""Return files absolute paths as a flat list (exclude sound without

View File

@ -7,7 +7,7 @@ from django.contrib.contenttypes.models import ContentType
from .models import Program
__all__ = ("PagePermissions", "program_permissions")
__all__ = ("PagePermissions", "program")
class PagePermissions:
@ -30,7 +30,6 @@ class PagePermissions:
"""Return True wether if user can edit Program or its children."""
from .models.page import ChildPage
breakpoint()
if isinstance(obj, ChildPage):
obj = obj.parent_subclass
@ -43,20 +42,23 @@ class PagePermissions:
perm = self.perms_codename_format.format(self=self, perm=perm)
return user.has_perm(perm)
# TODO: bulk init
def init(self, obj):
def init(self, obj, model=None):
"""Initialize permissions for the provided obj."""
updated = False
created_groups = []
# init groups
for infos in self.groups:
group = getattr(obj, infos["field"])
if obj.pk == 12417:
breakpoint()
if not group:
group, created = self.init_group(obj, infos)
setattr(obj, infos["field"], group.pk)
updated = True
created and created_groups.append((group, infos))
if created_groups:
if updated:
obj.save()
# init perms
@ -79,4 +81,4 @@ class PagePermissions:
group.permissions.add(perm)
program_permissions = PagePermissions(Program)
program = PagePermissions(Program)

View File

@ -1,3 +1,4 @@
from . import auth
from .admin import TrackSerializer, UserSettingsSerializer
from .episode import EpisodeSoundSerializer, EpisodeSerializer
from .log import LogInfo, LogInfoSerializer
@ -5,6 +6,7 @@ from .page import CommentSerializer
from .sound import SoundSerializer
__all__ = (
"auth",
"CommentSerializer",
"LogInfo",
"LogInfoSerializer",

View File

@ -10,6 +10,7 @@ Usefull context:
{% endcomment %}
<html>
<head>
{% block head %}
<meta charset="utf-8" />
<meta name="application-name" content="aircox" />
<meta name="description" content="{{ site.description }}" />
@ -27,17 +28,17 @@ Usefull context:
{% endblock %}
<title>
{% block head_title %}
{% if page and page.title %}{{ page.title }} &mdash; {{ station.name }}
{% else %}{{ station.name }}
{% block head-title %}
{% if page and page.title %}{{ page.title }} &mdash;
{% endif %}
{{ station.name }}
{% endblock %}
</title>
{% block head_extra %}{% endblock %}
{% endblock %}
</head>
<body {% if request.is_mobile %}class="mobile"{% endif %}>
{% block body-head %}{% endblock %}
{% block body %}
<script id="init-script">
window.addEventListener('load', function() {
{% block init-scripts %}
@ -65,7 +66,7 @@ Usefull context:
{% endfor %}
{% endblock %}
{% if user.is_authenticated %}
{% include "./dashboard/widgets/nav.html" %}
{% include "./widgets/nav.html" %}
{% endif %}
</div>
{% endblock %}
@ -115,7 +116,6 @@ Usefull context:
{% endblock %}
</span>
{% endspaceless %}
</span>
</div>
{% endblock %}
</div>
@ -157,5 +157,7 @@ Usefull context:
{% block player-container %}
<div id="player">{% include "aircox/widgets/player.html" %}</div>
{% endblock %}
{% endblock %}
</body>
</html>

View File

@ -1,10 +0,0 @@
{% extends "aircox/base.html" %}
{% comment %}Display detail of a BasePage{% endcomment %}
{% block head_title %}
{% block title %}{{ page.title }}{% endblock %}
&mdash;
{{ station.name }}
{% endblock %}
{% block header %}{% if page %}{{ block.super }}{% endif %}{% endblock %}

View File

@ -1,16 +1,8 @@
{% extends "./base.html" %}
{% extends "./public.html" %}
{% comment %}Display a list of BasePages{% endcomment %}
{% load i18n aircox %}
{% block head_title %}
{% block title %}
{{ block.super }}
{% endblock %}
&mdash;
{{ station.name }}
{% endblock %}
{% block main %}
{{ block.super }}

View File

@ -8,7 +8,8 @@
{% endblock %}
{% block head-title %}
{% block title %}{{ block.super }}{% endblock %}
&mdash;
{{ block.super }}
{% block title %}
{% if page and page.title %}{{ page.title }} &mdash;{% endif %}
{% endblock %}
{{ station.name }}
{% endblock %}

View File

@ -29,11 +29,11 @@ sound-delete-url="{% url "api:sound-detail" pk=123 %}"
{% for field in sound_form %}
{% with field.name as name %}
{% if name in "program" %}
{% include "./form_field.html" with value=field.initial field=field.field hidden=True %}
{% include "aircox/forms/form_field.html" with value=field.initial field=field.field hidden=True %}
{% elif name != "file" %}
<div class="field is-horizontal">
<label class="label mr-3">{{ field.label }}</label>
{% include "./form_field.html" with value=field.initial field=field.field %}
{% include "aircox/forms/form_field.html" with value=field.initial field=field.field %}
</div>
{% endif %}
{% endwith %}

View File

@ -1,7 +1,7 @@
{% extends "./page_form.html" %}
{% load static i18n humanize honeypot aircox %}
{% block page_form %}
{% block page-form %}
<a-episode :page="{title: &quot;{{ object.title }}&quot;, podcasts: {{ object.sounds|json }}}">
<template v-slot="{podcasts,page}">
{{ block.super }}

View File

@ -18,7 +18,7 @@
{% endblock %}
{% block content-container %}
{% block content %}
<article class="message is-danger">
<div class="message-header">
<p>{% block error_title %}{% trans "An error occurred" %}{% endblock %}</p>

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.widget.is_hidden or hidden %}
<input type="hidden" name="{{ name }}" value="{{ value|default:"" }}">
{% elif field|is_checkbox %}
<input type="checkbox" class="checkbox" name="{{ name }}" {% if value %}checked{% endif %}>
{% elif field|is_select %}
<select name="{{ name }}" class="select" value="{{ value|default:"" }}">
{% for value, label in field.widget.choices %}
<option value="{{ value }}">{{ label }}</option>
{% endfor %}
</select>
{% else %}
<input type="text" class="input" name="{{ name }}" value="{{ value|default:"" }}">
{% endif %}

View File

@ -0,0 +1,54 @@
{% comment %}
Base template for list editor based on formsets (tracklist_editor, playlist_editor).
Context:
- tag_id: id of parent component
- 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_data: formset data
{% endcomment %}
{% load aircox aircox_admin static i18n %}
{% with formset.form.base_fields as fields %}
{% block outer %}
<div id="{{ tag_id }}">
{{ formset.non_form_errors }}
<!-- formset.management_form -->
<{{ tag|default:"a-form-set" }}
{% block tag-attrs %}
:form-data="{{ formset_data|json }}"
:labels="window.aircox.labels"
:init-data="{% formset_inline_data formset=formset %}"
:columns="[{% for n, f in fields.items %}{% if not f.widget.is_hidden %}'{{ n }}',{% endif %}{% endfor %} ]"
settings-url="{% url "api:user-settings" %}"
data-prefix="{{ formset.prefix }}-"
{% endblock %}>
{% block inner %}
<template #rows-header-head>
{% 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>
{% for name, field in fields.items %}
{% if not field.widget.is_hidden and not field.is_readonly %}
<template v-slot:control-{{ name }}="{item,cell,value,attr,emit,inputName}">
{% block row-control %}
{% include "./v_form_field.html" with value="item.data."|add:name name="inputName" %}
{% endblock %}
</template>
{% endif %}
{% endfor %}
{% endblock %}
</{{ tag }}>
</div>
{% endblock %}
{% endwith %}

View File

@ -0,0 +1,24 @@
{% 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 ":v-model" attribute
- hidden: if True, hidden field
{% endcomment %}
{% load aircox %}
{% if field.widget.is_hidden or hidden %}
<input type="hidden" :name="{{ name }}" :value="{{ value|default:"" }}">
{% elif field|is_checkbox %}
<input type="checkbox" class="checkbox" :name="{{ name }}" v-model="{{ value }}">
{% elif field|is_select %}
<select :name="{{ name }}" class="select" v-model="{{ value|default:"" }}">
{% for value, label in field.widget.choices %}
<option value="{{ value }}">{{ label }}</option>
{% endfor %}
</select>
{% else %}
<input type="text" class="input" :name="{{ name }}" v-model="{{ value|default:"" }}">
{% endif %}

View File

@ -1,4 +1,4 @@
{% extends "aircox/base.html" %}
{% extends "./public.html" %}
{% load i18n aircox %}
{% block head_title %}{{ station.name }}{% endblock %}

View File

@ -1,4 +1,4 @@
{% extends "aircox/basepage_detail.html" %}
{% extends "aircox/public.html" %}
{% load static i18n humanize honeypot aircox %}
{% comment %}
Base template used to display a Page

View File

@ -1,4 +1,4 @@
{% extends "./page_detail.html" %}
{% extends "./dashboard/base.html" %}
{% load static aircox_admin i18n %}
{% block assets %}
@ -20,6 +20,7 @@ aircox.labels = {% inline_labels %}
</div>
{% endblock %}
{% block content-container %}
<a-select-file ref="cover-select"
:labels="window.aircox.labels"
@ -54,7 +55,7 @@ aircox.labels = {% inline_labels %}
<section class="container">
<form method="post" enctype="multipart/form-data">
{% block page_form %}
{% block page-form %}
{% csrf_token %}
{% for field in form %}
{% if field.name == "cover" %}
@ -69,7 +70,7 @@ aircox.labels = {% inline_labels %}
{% elif field.name == "content" %}
<textarea name="{{ field.name }}" class="is-fullwidth height-25">{{ field.value|striptags|safe }}</textarea>
{% else %}
{% include "./dashboard/widgets/form_field.html" with field=field.field name=field.name value=field.initial %}
{% include "aircox/forms/form_field.html" with field=field.field name=field.name value=field.initial %}
{% endif %}
</div>
<p class="help">{{ field.help_text }}</p>

View File

@ -1,4 +1,4 @@
{% extends "aircox/page_detail.html" %}
{% extends "./page_form.html" %}
{% load static i18n humanize honeypot aircox %}
@ -6,34 +6,13 @@
{{ form.media }}
{% endblock %}
{% block init-scripts %}
{% endblock %}
{% block page-form %}
//////
{{ block.super }}
{% block comments %}
{% endblock %}
{% block content-container %}
<section class="container">
<div>
<form method="post" enctype="multipart/form-data">{% csrf_token %}
{% csrf_token %}
{% for field in form %}
<div class="field is-horizontal">
<label class="label">{{ field.label }}</label>
<div class="control">{{ field }}</div>
</div>
{% if field.errors %}
<p class="help is-danger">{{ field.errors }}</p>
{% endif %}
{% if field.help_text %}
<p class="help">{{ field.help_text|safe }}</p>
{% endif %}
{% endfor %}
<div class="has-text-right">
<button type="submit" class="button">{% translate "Update" %}</button>
</div>
</form>
</div>
</section>
{% if editors_formset %}
<hr/>
<h2 class="title is-2">{% translate "Editors" %}</h2>
{% include "./widgets/usergroup_formset.html" with formset=editors_formset formset_data=editors_formset_data tag_id="usergroup_formset" %}
{% endif %}
{% endblock %}

View File

@ -0,0 +1,17 @@
{% extends base_template|default:"./base.html" %}
{% comment %}
Override is a trick here: it allows to change title at two different different
places inside the page: inside `<title>` tag, and inside the page
content.
{% endcomment %}
{% block head-title %}
{% block title %}
{% if page and page.title %}{{ page.title }} &mdash;{% endif %}
{% endblock %}
{{ station.name }}
{% endblock %}
{% block header %}{% if page %}{{ block.super }}{% endif %}{% endblock %}

View File

@ -0,0 +1,4 @@
<a-autocomplete
url="{{url}}"
name="{{ widget.name }}"{% if widget.value != None %} model-value="{{ widget.value|stringformat:'s' }}"{% endif %}
{% include "django/forms/widgets/attrs.html" %} />

View File

@ -25,31 +25,12 @@
{% endblock %}
{% block actions %}
{% if object.sound_set.public.count %}
<button class="button" @click="player.playButtonClick($event)"
data-sounds="{{ object.podcasts|json }}">
<span class="icon is-small">
<span class="fas fa-play"></span>
</span>
</button>
{% endif %}
{% endblock %}
{% block actions %}
{% has_perm page object.program.change_permission_codename simple=True as can_edit %}
{% if can_edit %}
<a class="button" href="{% url 'episode-edit' object.pk %}" target="_self">
<span class="icon is-small"><i class="fas fa-pen" alt="{% trans 'edit' %}"></i></span>
</a>
{% endif %}
{% if object.sound_set.public.count %}
<button class="button" @click="player.playButtonClick($event)"
data-sounds="{{ object.podcasts|json }}">
<span class="icon is-small">
<span class="fas fa-play"></span>
</span>
</button>
{% block content %}
{% if not object.content %}
{% with object.parent.content as content %}
{{ block.super }}
{% endwith %}
{% else %}
{{ block.super }}
{% endif %}
{% endblock %}

View File

@ -0,0 +1,10 @@
{% extends "aircox/forms/formset.html" %}
{% load aircox %}
{% block row-control %}
{% if name == 'user' %}
{% form_field field name value %}
{% else %}
{{ block.super }}
{% endif %}
{% endblock %}

View File

@ -14,6 +14,12 @@ random.seed()
register = template.Library()
@register.simple_tag(name="form_field")
def form_field(field, name=None, value=None, **kwargs):
name = name or field.name
return field.widget.render(name=name, value=value, **kwargs)
@register.filter(name="admin_url")
def admin_url(obj, action):
meta = obj._meta

View File

@ -25,6 +25,7 @@ router.register("images", viewsets.ImageViewSet, basename="image")
router.register("sound", viewsets.SoundViewSet, basename="sound")
router.register("track", viewsets.TrackROViewSet, basename="track")
router.register("comment", viewsets.CommentViewSet, basename="comment")
router.register("usergroup", viewsets.UserGroupViewSet, basename="usergroup")
api = [

View File

@ -1,5 +1,5 @@
from django.db.models import Q
from django.contrib.auth.mixins import LoginRequiredMixin
from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin
from django.utils.translation import gettext_lazy as _
from django.views.generic.base import TemplateView
@ -12,9 +12,13 @@ from .log import LogListView
__all__ = ("DashboardBaseView", "DashboardView", "StatisticsView")
class DashboardBaseView(LoginRequiredMixin, BaseView):
class DashboardBaseView(LoginRequiredMixin, UserPassesTestMixin, BaseView):
title = _("Dashboard")
def test_func(self):
user = self.request.user
return user.is_staff or user.is_superuser
class DashboardView(DashboardBaseView, TemplateView):
template_name = "aircox/dashboard/dashboard.html"

View File

@ -52,7 +52,7 @@ class EpisodeUpdateView(UserPassesTestMixin, VueFormDataMixin, PageUpdateView):
def test_func(self):
obj = self.get_object()
return permissions.program_permissions.can(self.request.user, "update", obj)
return permissions.program.can(self.request.user, "update", obj)
def get_tracklist_queryset(self, episode):
return Track.objects.filter(episode=episode).order_by("position")

View File

@ -194,13 +194,9 @@ class PageDetailView(BasePageDetailView):
class PageUpdateView(BaseView, UpdateView):
context_object_name = "page"
template_name = "aircox/page_form.html"
def get_page(self):
return self.object
def get_success_url(self):
return self.request.path
def get_comment_form(self):
return None

View File

@ -1,9 +1,11 @@
import random
from django.contrib.auth.models import User
from django.contrib.auth.mixins import UserPassesTestMixin
from django.urls import reverse
from aircox import models, forms, permissions
from .mixins import VueFormDataMixin
from .page import PageDetailView, PageListView, PageUpdateView
__all__ = (
@ -51,13 +53,39 @@ class ProgramListView(PageListView):
return super().get_queryset().order_by("title")
class ProgramUpdateView(UserPassesTestMixin, PageUpdateView):
class ProgramUpdateView(UserPassesTestMixin, VueFormDataMixin, PageUpdateView):
model = models.Program
form_class = forms.ProgramForm
queryset = models.Program.objects.select_related("editors_group")
def test_func(self):
obj = self.get_object()
return permissions.program_permissions.can(self.request.user, "update", obj)
return permissions.program.can(self.request.user, "update", obj)
def get_success_url(self):
return reverse("program-detail", kwargs={"slug": self.get_object().slug})
def get_editors_queryset(self, program):
# TODO: provide username in formset initials
return User.groups.through.objects.filter(group_id=program.editors_group_id).order_by("user__username")
def get_editors_formset(self, program, **kwargs):
return forms.UserGroupFormSet(
**{
**kwargs,
"prefix": "editors",
"queryset": self.get_editors_queryset(program),
"initial": {
"group": program.editors_group_id,
},
}
)
def get_context_data(self, editors_formset=None, **kwargs):
# TODO: use group and permission system
if self.request.user.is_superuser:
if editors_formset is None:
editors_formset = self.get_editors_formset(self.object)
kwargs["editors_formset_data"] = self.get_formset_data(
editors_formset, {"group": self.object.editors_group_id}
)
context = super().get_context_data(editors_formset=editors_formset, **kwargs)
return context

View File

@ -1,3 +1,4 @@
from django.contrib.auth.models import User
from django_filters import rest_framework as drf_filters
from rest_framework import status, viewsets, parsers, permissions
from rest_framework.decorators import action
@ -8,14 +9,37 @@ from filer.models.imagemodels import Image
from . import models, forms, filters, serializers
from .views import BaseAPIView
__all__ = (
"ImageViewSet",
"SoundViewSet",
"TrackROViewSet",
"UserGroupViewSet",
"UserSettingsViewSet",
)
class AutocompleteMixin:
"""Based on provided filterset and serializer, add an "autocomplete" action
to the viewset.
Url ``GET`` parameters:
- `field` (many): if provided, only return provided field names
- filterset's lookups.
Return a list of values if ``field`` is provided, result of `list()` otherwise.
"""
@action(name="autocomplete", detail=False)
def autocomplete(self, request):
field = request.GET.get("field", None)
if field:
queryset = self.filter_queryset(self.get_queryset())
values = queryset.values_list(field, flat=True).distinct()
return Response(values[:10])
return self.list(request)
class ImageViewSet(viewsets.ModelViewSet):
parsers = (parsers.MultiPartParser,)
permissions = (permissions.IsAuthenticatedOrReadOnly,)
@ -55,7 +79,7 @@ class SoundViewSet(BaseAPIView, viewsets.ModelViewSet):
return query
class TrackROViewSet(viewsets.ReadOnlyModelViewSet):
class TrackROViewSet(AutocompleteMixin, viewsets.ReadOnlyModelViewSet):
"""Track viewset used for auto completion."""
serializer_class = serializers.admin.TrackSerializer
@ -64,15 +88,6 @@ class TrackROViewSet(viewsets.ReadOnlyModelViewSet):
filterset_class = filters.TrackFilterSet
queryset = models.Track.objects.all()
@action(name="autocomplete", detail=False)
def autocomplete(self, request):
field = request.GET.get("field", None)
if field:
queryset = self.filter_queryset(self.get_queryset())
values = queryset.values_list(field, flat=True).distinct()
return Response(values[:10])
return self.list(request)
class CommentViewSet(viewsets.ModelViewSet):
serializer_class = serializers.CommentSerializer
@ -81,13 +96,19 @@ class CommentViewSet(viewsets.ModelViewSet):
# --- admin
class UserGroupViewSet(AutocompleteMixin, viewsets.ModelViewSet):
serializer_class = serializers.auth.UserGroupSerializer
permission_classes = (permissions.IsAdminUser,)
queryset = User.groups.through.objects.all().distinct().order_by("user__username")
class UserSettingsViewSet(viewsets.ViewSet):
"""User's settings specific to aircox.
Allow only to create and edit user's own settings.
"""
serializer_class = serializers.admin.UserSettingsSerializer
serializer_class = serializers.UserSettingsSerializer
permission_classes = (permissions.IsAuthenticated,)
def get_serializer(self, instance=None, **kwargs):

View File

@ -3,6 +3,7 @@ import tzlocal
from aircox.utils import to_seconds
from ..conf import settings
from .metadata import Metadata, Request
@ -76,7 +77,7 @@ class PlaylistSource(Source):
self.program = program
super().__init__(controller, id=id, **kwargs)
self.path = os.path.join(self.station.path, f"{self.id}.m3u")
self.path = settings.get_dir(self.station, f"{self.id}.m3u")
def get_sound_queryset(self):
"""Get playlist's sounds queryset."""
@ -88,6 +89,7 @@ class PlaylistSource(Source):
def write_playlist(self, playlist=[]):
"""Write playlist to file."""
playlist = playlist or self.get_playlist()
os.makedirs(os.path.dirname(self.path), exist_ok=True)
with open(self.path, "w") as file:
file.write("\n".join(playlist or []))

View File

@ -95,6 +95,8 @@ class Streamer:
data = render_to_string(
self.template_name,
{
"dir": settings.get_dir(self.station),
"log_file": settings.get_dir(self.station, "liquidsoap.log"),
"station": self.station,
"streamer": self,
},

View File

@ -53,13 +53,13 @@ class SourceSerializer(MetadataSerializer):
class PlaylistSerializer(SourceSerializer):
program = serializers.CharField(source="program.id")
url_name = "admin:api:streamer-playlist-detail"
url_name = "streamer:api:streamer-playlist-detail"
class QueueSourceSerializer(SourceSerializer):
queue = serializers.ListField(child=RequestSerializer(), source="requests")
url_name = "admin:api:streamer-queue-detail"
url_name = "streamer:api:streamer-queue-detail"
class StreamerSerializer(BaseSerializer):
@ -69,7 +69,7 @@ class StreamerSerializer(BaseSerializer):
playlists = serializers.ListField(child=PlaylistSerializer())
queues = serializers.ListField(child=QueueSourceSerializer())
url_name = "admin:api:streamer-detail"
url_name = "streamer:api:streamer-detail"
def get_url(self, obj, **kwargs):
kwargs["pk"] = obj.station.pk

View File

@ -80,7 +80,7 @@ end
{% block config %}
set("server.socket", true)
set("server.socket.path", "{{ streamer.socket_path }}")
set("log.file.path", "{{ station.path }}/liquidsoap.log")
set("log.file.path", "{{ log_file }}")
{% endblock %}
{% block config_extras %}

View File

@ -1,16 +1,13 @@
{% extends "admin/base_site.html" %}
{% comment %}Admin tools used to manage the streamer.{% endcomment %}
{% extends "aircox/dashboard/base.html" %}
{% load i18n static %}
{% block init-scripts %}
aircox.init({apiUrl: "{% url "admin:api:streamer-list" %}"},
{config: window.StreamerApp})
{% endblock %}
{% block content %}
{% block title %}{% translate "Streamer monitor" %}{% endblock %}
{% block content-container %}
{{ block.super }}
<div id="app">
<a-streamer api-url="{% url "admin:api:streamer-list" %}">
<div class="container">
<a-streamer api-url="{% url "streamer:api:streamer-list" %}">
<template v-slot="{streamer,streamers,sources,fetchStreamers,Sound}">
<div class="navbar toolbar">
<div class="navbar-start">

View File

@ -1,33 +1,25 @@
from django.contrib import admin
from django.utils.translation import gettext_lazy as _
from django.urls import include, path
from aircox.viewsets import SoundViewSet
from . import viewsets
from .views import StreamerAdminView
from . import views, viewsets
admin.site.route_view(
"tools/streamer",
StreamerAdminView.as_view(),
"tools-streamer",
label=_("Streamer Monitor"),
)
streamer_prefix = "streamer/(?P<station_pk>[0-9]+)/"
__all__ = ("api", "urls")
prefix = "(?P<station_pk>[0-9]+)/"
router = admin.site.router
router.register(
streamer_prefix + "playlist",
viewsets.PlaylistSourceViewSet,
basename="streamer-playlist",
)
router.register(
streamer_prefix + "queue",
viewsets.QueueSourceViewSet,
basename="streamer-queue",
)
router.register(prefix + "playlist", viewsets.PlaylistSourceViewSet, basename="streamer-playlist")
router.register(prefix + "queue", viewsets.QueueSourceViewSet, basename="streamer-queue")
router.register("streamer", viewsets.StreamerViewSet, basename="streamer")
router.register("sound", SoundViewSet, basename="sound")
urls = []
api = router.urls
urls = [
path("api/", include((api, "aircox_streamer"), namespace="api")),
path("", views.StreamerView.as_view(), name="dashboard-streamer"),
]

View File

@ -1,13 +1,13 @@
from django.utils.translation import gettext_lazy as _
from django.views.generic import TemplateView
from aircox.views.admin import AdminMixin
from aircox.views.dashboard import DashboardBaseView
from .controllers import streamers
class StreamerAdminView(AdminMixin, TemplateView):
class StreamerView(DashboardBaseView, TemplateView):
template_name = "aircox_streamer/streamer.html"
title = _("Streamer Monitor")
title = _("Streamer")
streamers = streamers
def dispatch(self, *args, **kwargs):

View File

@ -43,6 +43,8 @@ class ControllerViewSet(viewsets.ViewSet):
if station_pk is None:
station_pk = self.request.station.pk
self.streamers.fetch()
if station_pk is None:
return None
if station_pk not in self.streamers:
raise Http404("station not found")
return self.streamers[station_pk]
@ -78,7 +80,7 @@ class StreamerViewSet(ControllerViewSet):
def dispatch(self, request, *args, pk=None, **kwargs):
if pk is not None:
kwargs.setdefault("station_pk", pk)
self.streamer = self.get_streamer(request, **kwargs)
self.streamer = self.get_streamer(**kwargs)
self.object = self.streamer
return super().dispatch(request, *args, **kwargs)

View File

@ -27,7 +27,8 @@
"eslint-plugin-vue": "^8.0.3",
"sass": "^1.49.9",
"sass-loader": "^12.6.0",
"vue-cli": "^2.9.6"
"vue-cli": "^2.9.6",
"webpack-cli": "^5.1.4"
},
"eslintConfig": {
"root": true,

View File

@ -232,6 +232,8 @@ export default {
: fetch(url, Model.getOptions()).then(d => d.json())
promise = promise.then(items => {
if(items.results)
items = items.results
this.items = items.filter((i) => i) || []
this.promise = null;
this.move(0)

Binary file not shown.

View File

@ -184,9 +184,9 @@ THUMBNAIL_PROCESSORS = (
# Enabled applications
INSTALLED_APPS = (
"radiocampus",
"aircox_streamer.apps.AircoxStreamerConfig",
"aircox.apps.AircoxConfig",
"aircox.apps.AircoxAdminConfig",
"aircox_streamer.apps.AircoxStreamerConfig",
# Aircox dependencies
"rest_framework",
"django_filters",

View File

@ -22,16 +22,14 @@ from django.urls import include, path
import aircox.urls
import aircox_streamer.urls
urlpatterns = (
aircox.urls.urls
+ aircox_streamer.urls.urls
+ [
urlpatterns = [
*aircox.urls.urls,
path("streamer/", include((aircox_streamer.urls.urls, "aircox_streamer"), namespace="streamer")),
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:
urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT) + static(

View File

@ -1,7 +1,7 @@
{% extends "aircox/base.html" %}
{% load static %}
{% block head_extra %}
{% block assets %}
{{ block.super }}
<style>
:root {

View File

@ -1,10 +1,10 @@
Django~=4.1
djangorestframework~=3.13
django-model-utils>=4.2
Django~=5.0
djangorestframework~=3.14
django-model-utils>=4.3
django-filter~=22.1
django-content-editor~=6.3
django-filer~=2.2
django-filer~=3.1
django-honeypot~=1.0
django-taggit~=3.0
django-admin-sortable2~=2.1