- various __all__

- serializer: track search, reorder module files
- autocomplete: allow simple string value selection
- playlist editor:
    - ui & flow improve
    - init data
    - save user settings
    - autocomplete
    - fix bugs
    - discard changes
This commit is contained in:
bkfox
2022-12-12 00:25:57 +01:00
parent 61af53eecb
commit 180cc8bc02
30 changed files with 708 additions and 259 deletions

View File

@ -1,10 +1,11 @@
from .article import Article
from .page import Category, Page, StaticPage, Comment, NavItem
from .program import Program, Stream, Schedule
from .episode import Episode, Diffusion
from .log import Log
from .sound import Sound, Track
from .station import Station, Port
from .article import *
from .page import *
from .program import *
from .episode import *
from .log import *
from .sound import *
from .station import *
from .user_settings import *
from . import signals

View File

@ -1,8 +1,10 @@
from django.db import models
from django.utils.translation import gettext_lazy as _
from .page import Page, PageQuerySet
from .program import Program, ProgramChildQuerySet
from .page import Page
from .program import ProgramChildQuerySet
__all__ = ('Article',)
class Article(Page):

View File

@ -9,12 +9,12 @@ from django.utils.functional import cached_property
from easy_thumbnails.files import get_thumbnailer
from aircox import settings, utils
from .program import Program, ProgramChildQuerySet, \
from .program import ProgramChildQuerySet, \
BaseRerun, BaseRerunQuerySet, Schedule
from .page import Page, PageQuerySet
from .page import Page
__all__ = ['Episode', 'Diffusion', 'DiffusionQuerySet']
__all__ = ('Episode', 'Diffusion', 'DiffusionQuerySet')
class Episode(Page):
@ -31,9 +31,9 @@ class Episode(Page):
""" Return serialized data about podcasts. """
from ..serializers import PodcastSerializer
podcasts = [PodcastSerializer(s).data
for s in self.sound_set.public().order_by('type') ]
for s in self.sound_set.public().order_by('type')]
if self.cover:
options = {'size': (128,128), 'crop':'scale'}
options = {'size': (128, 128), 'crop': 'scale'}
cover = get_thumbnailer(self.cover).get_thumbnail(options).url
else:
cover = None
@ -84,7 +84,7 @@ class DiffusionQuerySet(BaseRerunQuerySet):
def episode(self, episode=None, id=None):
""" Diffusions for this episode """
return self.filter(episode=episode) if id is None else \
self.filter(episode__id=id)
self.filter(episode__id=id)
def on_air(self):
""" On air diffusions """
@ -104,13 +104,13 @@ class DiffusionQuerySet(BaseRerunQuerySet):
end = tz.datetime.combine(date, datetime.time(23, 59, 59, 999))
# start = tz.get_current_timezone().localize(start)
# end = tz.get_current_timezone().localize(end)
qs = self.filter(start__range = (start, end))
qs = self.filter(start__range=(start, end))
return qs.order_by('start') if order else qs
def at(self, date, order=True):
""" Return diffusions at specified date or datetime """
return self.now(date, order) if isinstance(date, tz.datetime) else \
self.date(date, order)
self.date(date, order)
def after(self, date=None):
"""
@ -201,7 +201,7 @@ class Diffusion(BaseRerun):
def __str__(self):
str_ = '{episode} - {date}'.format(
self=self, episode=self.episode and self.episode.title,
episode=self.episode and self.episode.title,
date=self.local_start.strftime('%Y/%m/%d %H:%M%z'),
)
if self.initial:
@ -324,5 +324,3 @@ class Diffusion(BaseRerun):
'end': self.end,
'episode': getattr(self, 'episode', None),
}

View File

@ -20,7 +20,7 @@ from .station import Station
logger = logging.getLogger('aircox')
__all__ = ['Log', 'LogQuerySet', 'LogArchiver']
__all__ = ('Log', 'LogQuerySet', 'LogArchiver')
class LogQuerySet(models.QuerySet):
@ -31,7 +31,7 @@ class LogQuerySet(models.QuerySet):
def date(self, date):
start = tz.datetime.combine(date, datetime.time())
end = tz.datetime.combine(date, datetime.time(23, 59, 59, 999))
return self.filter(date__range = (start, end))
return self.filter(date__range=(start, end))
# this filter does not work with mysql
# return self.filter(date__date=date)

View File

@ -1,4 +1,3 @@
from enum import IntEnum
import re
from django.db import models
@ -18,7 +17,8 @@ from model_utils.managers import InheritanceQuerySet
from .station import Station
__all__ = ['Category', 'PageQuerySet', 'Page', 'Comment', 'NavItem']
__all__ = ('Category', 'PageQuerySet',
'Page', 'StaticPage', 'Comment', 'NavItem')
headline_re = re.compile(r'(<p>)?'

View File

@ -1,6 +1,5 @@
import calendar
from collections import OrderedDict
import datetime
from enum import IntEnum
import logging
import os
@ -10,7 +9,7 @@ import pytz
from django.conf import settings as conf
from django.core.exceptions import ValidationError
from django.db import models
from django.db.models import F, Q
from django.db.models import F
from django.db.models.functions import Concat, Substr
from django.utils import timezone as tz
from django.utils.translation import gettext_lazy as _
@ -24,8 +23,8 @@ from .station import Station
logger = logging.getLogger('aircox')
__all__ = ['Program', 'ProgramQuerySet', 'Stream', 'Schedule',
'ProgramChildQuerySet', 'BaseRerun', 'BaseRerunQuerySet']
__all__ = ('Program', 'ProgramQuerySet', 'Stream', 'Schedule',
'ProgramChildQuerySet', 'BaseRerun', 'BaseRerunQuerySet')
class ProgramQuerySet(PageQuerySet):

View File

@ -2,7 +2,7 @@ import pytz
from django.contrib.auth.models import User, Group, Permission
from django.db import transaction
from django.db.models import F, signals
from django.db.models import signals
from django.dispatch import receiver
from django.utils import timezone as tz

View File

@ -1,17 +1,14 @@
from enum import IntEnum
import logging
import os
from django.conf import settings as conf
from django.db import models
from django.db.models import Q, Value as V
from django.db.models.functions import Concat
from django.db.models import Q
from django.utils import timezone as tz
from django.utils.translation import gettext_lazy as _
from taggit.managers import TaggableManager
from aircox import settings
from .program import Program
from .episode import Episode
@ -19,7 +16,7 @@ from .episode import Episode
logger = logging.getLogger('aircox')
__all__ = ['Sound', 'SoundQuerySet', 'Track']
__all__ = ('Sound', 'SoundQuerySet', 'Track')
class SoundQuerySet(models.QuerySet):

View File

@ -8,7 +8,7 @@ from filer.fields.image import FilerImageField
from .. import settings
__all__ = ['Station', 'StationQuerySet', 'Port']
__all__ = ('Station', 'StationQuerySet', 'Port')
class StationQuerySet(models.QuerySet):

View File

@ -0,0 +1,16 @@
from django.db import models
from django.contrib.auth.models import User
from django.utils.translation import gettext_lazy as _
class UserSettings(models.Model):
"""
Store user's settings.
"""
user = models.OneToOneField(
User, models.CASCADE, verbose_name=_('User'),
related_name='aircox_settings')
playlist_editor_columns = models.JSONField(
_('Playlist Editor Columns'))
playlist_editor_sep = models.CharField(
_('Playlist Editor Separator'), max_length=16)

View File

@ -0,0 +1,3 @@
from .log import *
from .sound import *
from .admin import *

View File

@ -0,0 +1,30 @@
from rest_framework import serializers
from taggit.serializers import TagListSerializerField, TaggitSerializer
from ..models import Track, UserSettings
__all__ = ('TrackSerializer', 'UserSettingsSerializer')
class TrackSerializer(TaggitSerializer, serializers.ModelSerializer):
tags = TagListSerializerField()
class Meta:
model = Track
fields = ('pk', 'artist', 'title', 'album', 'year', 'position',
'info', 'tags', 'episode', 'sound')
class UserSettingsSerializer(serializers.ModelSerializer):
# TODO: validate fields values (playlist_editor_columns at least)
class Meta:
model = UserSettings
fields = ('playlist_editor_columns', 'playlist_editor_sep')
def create(self, validated_data):
user = self.context.get('user')
if user:
validated_data['user_id'] = user.id
return super().create(validated_data)

View File

@ -1,12 +1,9 @@
from rest_framework import serializers
from taggit.serializers import TagListSerializerField, TaggitSerializer
from .models import Diffusion, Log, Sound, Track
from ..models import Diffusion, Log
__all__ = ['LogInfo', 'LogInfoSerializer', 'SoundSerializer',
'PodcastSerializer',
'AdminTrackSerializer']
__all__ = ('LogInfo', 'LogInfoSerializer')
class LogInfo:
@ -54,30 +51,3 @@ class LogInfoSerializer(serializers.Serializer):
info = serializers.CharField(max_length=200, required=False)
url = serializers.URLField(required=False)
cover = serializers.URLField(required=False)
class SoundSerializer(serializers.ModelSerializer):
file = serializers.FileField(use_url=False)
class Meta:
model = Sound
fields = ['pk', 'name', 'program', 'episode', 'type', 'file',
'duration', 'mtime', 'is_good_quality', 'is_public', 'url']
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']
class AdminTrackSerializer(TaggitSerializer, serializers.ModelSerializer):
tags = TagListSerializerField()
class Meta:
model = Track
fields = ('pk', 'artist', 'title', 'album', 'year', 'position',
'info', 'tags', 'episode', 'sound')

View File

@ -0,0 +1,21 @@
from rest_framework import serializers
from ..models import Sound
class SoundSerializer(serializers.ModelSerializer):
file = serializers.FileField(use_url=False)
class Meta:
model = Sound
fields = ['pk', 'name', 'program', 'episode', 'type', 'file',
'duration', 'mtime', 'is_good_quality', 'is_public', 'url']
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']

View File

@ -16,7 +16,7 @@
\**********************/
/***/ (function(__unused_webpack_module, __webpack_exports__, __webpack_require__) {
eval("__webpack_require__.r(__webpack_exports__);\n/* harmony import */ var _assets_styles_scss__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! ./assets/styles.scss */ \"./src/assets/styles.scss\");\n/* harmony import */ var _assets_admin_scss__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(/*! ./assets/admin.scss */ \"./src/assets/admin.scss\");\n/* harmony import */ var _index_js__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(/*! ./index.js */ \"./src/index.js\");\n/* harmony import */ var _app__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(/*! ./app */ \"./src/app.js\");\n/* harmony import */ var _components__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(/*! ./components */ \"./src/components/index.js\");\n\n\n\n\n\nconst AdminApp = {\n ..._app__WEBPACK_IMPORTED_MODULE_3__[\"default\"],\n components: {\n ..._app__WEBPACK_IMPORTED_MODULE_3__[\"default\"].components,\n ..._components__WEBPACK_IMPORTED_MODULE_4__.admin\n }\n};\n/* harmony default export */ __webpack_exports__[\"default\"] = (AdminApp);\nwindow.App = AdminApp;\n\n//# sourceURL=webpack://aircox-assets/./src/admin.js?");
eval("__webpack_require__.r(__webpack_exports__);\n/* harmony import */ var _assets_styles_scss__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! ./assets/styles.scss */ \"./src/assets/styles.scss\");\n/* harmony import */ var _assets_admin_scss__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(/*! ./assets/admin.scss */ \"./src/assets/admin.scss\");\n/* harmony import */ var _index_js__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(/*! ./index.js */ \"./src/index.js\");\n/* harmony import */ var _app__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(/*! ./app */ \"./src/app.js\");\n/* harmony import */ var _components__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(/*! ./components */ \"./src/components/index.js\");\n/* harmony import */ var _track__WEBPACK_IMPORTED_MODULE_5__ = __webpack_require__(/*! ./track */ \"./src/track.js\");\n\n\n\n\n\n\nconst AdminApp = {\n ..._app__WEBPACK_IMPORTED_MODULE_3__[\"default\"],\n components: {\n ..._app__WEBPACK_IMPORTED_MODULE_3__[\"default\"].components,\n ..._components__WEBPACK_IMPORTED_MODULE_4__.admin\n },\n data() {\n return {\n ...super.data,\n Track: _track__WEBPACK_IMPORTED_MODULE_5__[\"default\"]\n };\n }\n};\n/* harmony default export */ __webpack_exports__[\"default\"] = (AdminApp);\nwindow.App = AdminApp;\n\n//# sourceURL=webpack://aircox-assets/./src/admin.js?");
/***/ }),

File diff suppressed because one or more lines are too long

View File

@ -1,17 +1,16 @@
{% comment %}Inline block to edit playlists{% endcomment %}
{% load aircox aircox_admin static i18n %}
{# include "adminsortable2/edit_inline/tabular-django-4.1.html" #}
{% with inline_admin_formset as admin_formset %}
{% with admin_formset.formset as formset %}
<script id="{{ formset.prefix }}-init-data">
{{ formset|inline_data|json }}
</script>
<div id="inline-tracks" class="box mb-5">
{{ admin_formset.non_form_errors }}
<a-playlist-editor data-el="{{ formset.prefix }}-init-data"
<a-playlist-editor
:labels="{% track_inline_labels %}"
:init-data="{% track_inline_data formset=formset %}"
settings-url="{% url "api:user-settings" %}"
data-prefix="{{ formset.prefix }}-">
<template #title>
<h5 class="title is-4">{% trans "Playlist" %}</h5>
@ -57,13 +56,21 @@
{% if not field.widget.is_hidden and not field.is_readonly %}
<template v-slot:row-{{ field.name }}="{item,col,row,value,attr,emit}">
<div class="field">
{% if field.name in 'artist,title,album' %}
<a-autocomplete
:input-class="['input', item.error(attr) ? 'is-danger' : 'half-field']"
url="{% url 'api:track-autocomplete' %}?{{ field.name }}=${query}&field={{ field.name }}"
{% else %}
<div class="control">
<input type="{{ widget.type }}"
:class="['input', item.error(attr) ? 'is-danger' : 'half-field']"
{% endif %}
:name="'{{ formset.prefix }}-' + row + '-{{ field.name }}'"
v-model="item.data[attr]"
@change="emit('change', col)"/>
{% if field.name not in 'artist,title,album' %}
</div>
{% endif %}
<p v-for="error in item.error(attr)" class="help is-danger">
[[ error ]] !
</p>

View File

@ -1,5 +1,14 @@
import json
from django import template
from django.contrib import admin
from django.utils.safestring import mark_safe
from django.utils.translation import gettext_lazy as _, gettext as __
from aircox.serializers.admin import UserSettingsSerializer
__all__ = ('register', 'do_get_admin_tools', 'do_track_inline_data',
'do_track_inline_column_labels')
register = template.Library()
@ -10,8 +19,14 @@ def do_get_admin_tools():
return admin.site.get_tools()
@register.filter(name='inline_data')
def do_inline_data(formset):
@register.simple_tag(name='track_inline_data', takes_context=True)
def do_track_inline_data(context, formset, safe_string=False):
"""
Return initial data for playlist editor as dict. Keys are:
- ``items``: list of items. Extra keys:
- ``__error__``: dict of form fields errors
- ``settings``: user's settings
"""
items = []
for form in formset.forms:
item = {name: form[name].value()
@ -23,5 +38,23 @@ def do_inline_data(formset):
if tags and not isinstance(tags, str):
item['tags'] = ', '.join(tag.name for tag in tags)
items.append(item)
return {"items": items}
data = {"items": items}
user = context['request'].user
settings = getattr(user, 'aircox_settings', None)
data['settings'] = settings and UserSettingsSerializer(settings).data
source = json.dumps(data)
return safe_string and mark_safe(source) or source
@register.simple_tag(name='track_inline_labels')
def do_track_inline_labels():
""" Return labels for columns in playlist editor as dict """
return json.dumps({
'artist': __('Artist'), 'album': __('Album'), 'title': __('Title'),
'tags': __('Tags'), 'year': __('Year'),
'save_settings': __('Save Settings'),
'discard_changes': __('Discard changes'),
'columns': __('Columns'),
})

View File

@ -24,10 +24,14 @@ register_converter(WeekConverter, 'week')
router = DefaultRouter()
router.register('sound', viewsets.SoundViewSet, basename='sound')
router.register('track', viewsets.TrackROViewSet, basename='track')
api = [
path('logs/', views.LogListAPIView.as_view(), name='live'),
path('user/settings/', viewsets.UserSettingsViewSet.as_view(
{'get': 'retrieve', 'post': 'update', 'put': 'update'}),
name='user-settings'),
] + router.urls

View File

@ -62,6 +62,7 @@ class BaseView(TemplateResponseMixin, ContextMixin):
return super().get_context_data(**kwargs)
# FIXME: rename to sth like [Base]?StationAPIView
class BaseAPIView:
@property
def station(self):

View File

@ -1,13 +1,18 @@
from django.db.models import Q
from rest_framework import viewsets
from rest_framework import status, viewsets
from rest_framework.decorators import action
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from django_filters import rest_framework as filters
from .models import Sound
from .serializers import SoundSerializer
from .models import Sound, Track
from .serializers import SoundSerializer, admin
from .views import BaseAPIView
__all__ = ('SoundFilter', 'SoundViewSet', 'TrackFilter', 'TrackROViewSet',
'UserSettingsViewSet')
class SoundFilter(filters.FilterSet):
station = filters.NumberFilter(field_name='program__station__id')
program = filters.NumberFilter(field_name='program_id')
@ -24,3 +29,63 @@ class SoundViewSet(BaseAPIView, viewsets.ModelViewSet):
filter_backends = (filters.DjangoFilterBackend,)
filterset_class = SoundFilter
# --- admin
class TrackFilter(filters.FilterSet):
artist = filters.CharFilter(field_name='artist', lookup_expr='icontains')
album = filters.CharFilter(field_name='album', lookup_expr='icontains')
title = filters.CharFilter(field_name='title', lookup_expr='icontains')
class TrackROViewSet(viewsets.ReadOnlyModelViewSet):
""" Track viewset used for auto completion """
serializer_class = admin.TrackSerializer
permission_classes = [IsAuthenticated]
filter_backends = (filters.DjangoFilterBackend,)
filterset_class = TrackFilter
queryset = 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)
return self.list(request)
class UserSettingsViewSet(viewsets.ViewSet):
"""
User's settings specific to aircox. Allow only to create and edit
user's own settings.
"""
serializer_class = admin.UserSettingsSerializer
permission_classes = [IsAuthenticated]
def get_serializer(self, instance=None, **kwargs):
return self.serializer_class(
instance=instance, context={'user': self.request.user},
**kwargs)
@action(detail=False, methods=['GET'])
def retrieve(self, request):
user = self.request.user
settings = getattr(user, 'aircox_settings', None)
data = settings and self.get_serializer(settings) or None
return Response(data)
@action(detail=False, methods=['POST', 'PUT'])
def update(self, request):
user = self.request.user
settings = getattr(user, 'aircox_settings', None)
data = dict(request.data)
data['user_id'] = self.request.user
serializer = self.get_serializer(instance=settings, data=request.data)
if serializer.is_valid():
serializer.save()
return Response({'status': 'ok'})
else:
return Response({'errors': serializer.errors},
status=status.HTTP_400_BAD_REQUEST)