integrate statistics

This commit is contained in:
bkfox 2024-04-12 15:48:55 +02:00
parent 7841fed17d
commit 0ee72f30c5
35 changed files with 348 additions and 83 deletions

View File

@ -4,6 +4,14 @@ from django.db import migrations, models
import django.db.models.deletion 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): class Migration(migrations.Migration):
dependencies = [ dependencies = [
("auth", "0012_alter_user_first_name_max_length"), ("auth", "0012_alter_user_first_name_max_length"),
@ -22,4 +30,5 @@ class Migration(migrations.Migration):
verbose_name="editors", verbose_name="editors",
), ),
), ),
migrations.RunPython(init_groups_and_permissions),
] ]

View File

@ -336,7 +336,7 @@ class Comment(Renderable, models.Model):
return Page.objects.select_subclasses().filter(id=self.page_id).first() return Page.objects.select_subclasses().filter(id=self.page_id).first()
def get_absolute_url(self): def get_absolute_url(self):
return self.parent.get_absolute_url() + f"#comment-{self.pk}" return self.parent.get_absolute_url() + f"#{self._meta.label_lower}-{self.pk}"
class Meta: class Meta:
verbose_name = _("Comment") verbose_name = _("Comment")

View File

@ -1,8 +1,7 @@
import os import os
from django.conf import settings as conf from django.conf import settings as conf
from django.contrib.auth.models import Group, Permission from django.contrib.auth.models import Group
from django.contrib.contenttypes.models import ContentType
from django.db import models from django.db import models
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
@ -117,37 +116,6 @@ class Program(Page):
os.makedirs(path, exist_ok=True) os.makedirs(path, exist_ok=True)
return os.path.exists(path) return os.path.exists(path)
def can_update(self, user):
"""Return True if user can update program."""
if user.is_superuser:
return True
perm = self._perm_update_codename.format(self=self)
return user.has_perm("aircox." + perm)
# permissions and editor group format. Use of pk in codename makes it
# consistent in case program title changes.
_editor_group_name = "{self.title}: editors"
_perm_update_codename = "program_{self.pk}_update"
_perm_update_name = "{self.title}: update"
def init_editor_group(self):
if not self.editors_group:
name = self._editor_group_name.format(self=self)
self.editors_group, created = Group.objects.get_or_create(name=name)
else:
created = False
if created:
if not self.pk:
self.save(check_groups=False)
permission, _ = Permission.objects.get_or_create(
codename=self._perm_update_codename.format(self=self),
content_type=ContentType.objects.get_for_model(self),
defaults={"name": self._perm_update_name.format(self=self)},
)
if permission not in self.editors_group.permissions.all():
self.editors_group.permissions.add(permission)
class Meta: class Meta:
verbose_name = _("Program") verbose_name = _("Program")
verbose_name_plural = _("Programs") verbose_name_plural = _("Programs")

82
aircox/permissions.py Normal file
View File

@ -0,0 +1,82 @@
# Provide permissions handling
# we don't import models at module level in order to avoid migration problems
from django.utils.translation import gettext_lazy as _
from django.contrib.auth.models import Group, Permission
from django.contrib.contenttypes.models import ContentType
from .models import Program
__all__ = ("PagePermissions", "program_permissions")
class PagePermissions:
"""Handles obj permissions initialization of page subclass."""
model = None
groups = ({"label": _("editors"), "field": "editors_group_id", "perms": ["update"]},)
"""Groups informations initialized."""
groups_name_format = "{obj.title}: {group_label}"
"""Format used for groups name."""
perms_name_format = "{obj.title}: can {perm}"
"""Format used for permission name (displayed to humans)."""
perms_codename_format = "{obj._meta.label_lower}_{obj.pk}_{perm}"
"""Format used for permissions codename."""
def __init__(self, model):
self.model = model
def can(self, user, perm, obj):
"""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
if not isinstance(obj, self.model):
return False
if user.is_superuser:
return True
perm = self.perms_codename_format.format(self=self, perm=perm)
return user.has_perm(perm)
# TODO: bulk init
def init(self, obj):
"""Initialize permissions for the provided obj."""
created_groups = []
# init groups
for infos in self.groups:
group = getattr(obj, infos["field"])
if not group:
group, created = self.init_group(obj, infos)
setattr(obj, infos["field"], group.pk)
created and created_groups.append((group, infos))
if created_groups:
obj.save()
# init perms
for group, infos in created_groups:
self.init_perms(obj, group, infos)
def init_group(self, obj, infos):
name = self.groups_name_format.format(obj=obj, group_label=infos["label"])
return Group.objects.get_or_create(name=name)
def init_perms(self, obj, group, infos):
# TODO: avoid multiple database hits
for name in infos["perms"]:
perm, _ = Permission.objects.get_or_create(
codename=self.perms_codename_format.format(obj=obj, perm=name),
content_type=ContentType.objects.get_for_model(obj),
defaults={"name": self.perms_name_format.format(obj=obj, perm=name)},
)
if perm not in group.permissions.all():
group.permissions.add(perm)
program_permissions = PagePermissions(Program)

View File

@ -1,9 +1,11 @@
from .admin import TrackSerializer, UserSettingsSerializer from .admin import TrackSerializer, UserSettingsSerializer
from .log import LogInfo, LogInfoSerializer
from .sound import SoundSerializer
from .episode import EpisodeSoundSerializer, EpisodeSerializer from .episode import EpisodeSoundSerializer, EpisodeSerializer
from .log import LogInfo, LogInfoSerializer
from .page import CommentSerializer
from .sound import SoundSerializer
__all__ = ( __all__ = (
"CommentSerializer",
"LogInfo", "LogInfo",
"LogInfoSerializer", "LogInfoSerializer",
"EpisodeSoundSerializer", "EpisodeSoundSerializer",

View File

@ -0,0 +1,12 @@
from rest_framework import serializers
from aircox import models
__all__ = ("CommentSerializer",)
class CommentSerializer(serializers.ModelSerializer):
class Meta:
model = models.Comment
fields = ["page", "nickname", "email", "date", "content"]

View File

@ -9834,10 +9834,34 @@ a.tag:hover {
color: var(--secondary-color); color: var(--secondary-color);
} }
.bg-main {
background-color: var(--main-color);
}
.bg-main-light {
background-color: var(--main-color-light);
}
.bg-secondary {
background-color: var(--secondary-color);
}
.bg-secondary-light {
background-color: var(--secondary-color-light);
}
.bg-transparent { .bg-transparent {
background-color: transparent; background-color: transparent;
} }
.border-bottom-main {
border-bottom: 1px solid var(--main-color);
}
.border-bottom-secondary {
border-bottom: 1px solid var(--secondary-color);
}
.is-success { .is-success {
background-color: #0e0 !important; background-color: #0e0 !important;
border-color: #0b0 !important; border-color: #0b0 !important;

File diff suppressed because one or more lines are too long

View File

@ -37,6 +37,7 @@ Usefull context:
{% block head_extra %}{% endblock %} {% block head_extra %}{% endblock %}
</head> </head>
<body {% if request.is_mobile %}class="mobile"{% endif %}> <body {% if request.is_mobile %}class="mobile"{% endif %}>
{% block body-head %}{% endblock %}
<script id="init-script"> <script id="init-script">
window.addEventListener('load', function() { window.addEventListener('load', function() {
{% block init-scripts %} {% block init-scripts %}
@ -64,7 +65,7 @@ Usefull context:
{% endfor %} {% endfor %}
{% endblock %} {% endblock %}
{% if user.is_authenticated %} {% if user.is_authenticated %}
{% include "./dashboard/nav.html" %} {% include "./dashboard/widgets/nav.html" %}
{% endif %} {% endif %}
</div> </div>
{% endblock %} {% endblock %}

View File

@ -1,5 +1,11 @@
{% extends "../base.html" %} {% extends "../base.html" %}
{% load i18n %} {% load static i18n %}
{% block assets %}
{{ block.super }}
<script src="{% static "aircox/js/dashboard.js" %}"></script>
<link rel="stylesheet" type="text/css" href="{% static "aircox/css/admin.css" %}"/>
{% endblock %}
{% block head-title %} {% block head-title %}
{% block title %}{{ block.super }}{% endblock %} {% block title %}{{ block.super }}{% endblock %}

View File

@ -0,0 +1,92 @@
{% extends "./base.html" %}
{% load i18n aircox %}
{% block title %}{% translate "Statistics" %}{% endblock %}
{% block content-container %}
<div class="container">
{# TODO: date subtitle #}
{% comment %}
<nav class="navbar" role="menu">
{% with "admin:tools-stats" as url_name %}
{% include "aircox/widgets/dates_menu.html" %}
{% endwith %}
</nav>
{% endcomment %}
<a-statistics class="column">
<template v-slot="{counts}">
<table class="table is-hoverable is-fullwidth">
<thead>
<tr>
<th>{% translate "Time" %}</th>
<th>{% translate "Episode" %} / {% translate "Track" %}</th>
<th>{% translate "Tags" %}</th>
</tr>
</thead>
<tbody>
{% for object in object_list %}
{% with object|is_diffusion as is_diff %}
{% if is_diff %}
<tr class="bg-main">
<td>{{ object.start|time:"H:i" }} - {{ object.end|time:"H:i" }}</td>
<td colspan="2">
<a href="{% url "episode-detail" slug=object.episode.slug %}" target="new">{{ object.episode|default:"" }}</a>
</td>
</tr>
{% endif %}
{% with object|get_tracks as tracks %}
{% for track in tracks %}
<tr {% if is_diff %}class="bg-main-light"{% endif %}>
{% if forloop.first %}
<td rowspan="{{ tracks|length }}">{{ object.start|time:"H:i" }} {% if object|is_diffusion %} - {{ object.end|time:"H:i" }}{% endif %}</td>
{% endif %}
<td>
{% if object.source %}{{ object.source }} / {% endif %}
{% include "aircox/widgets/track_item.html" with object=track %}
</td>
{% with track.tags.all|join:', ' as tags %}
<td>
{% if tags and tags.strip %}
<label class="checkbox">
<input type="checkbox" checked value="{{ tags|escape }}" name="data">
{{ tags }}
</label>
{% endif %}
</td>
{% endwith %}
</tr>
{% empty %}
{% if is_diff %}
<tr class="bg-main-light">
<td colspan="3">{% translate "No tracks" %}</td>
</tr>
{% endif %}
{% endfor %}
{% endwith %}
{% endwith %}
{% endfor %}
</tbody>
<tfoot>
<tr>
<td class="is-size-6">{% translate "Totals" %}</td>
<td colspan="100">
<div class="columns is-size-6">
<span v-for="(count, tag) in counts" class="column">
<b>[[ tag ]]</b>
[[ count ]]
</span>
</div>
</td>
</tr>
</tfoot>
</table>
</template>
</a-statistics>
</div>
{% endblock %}

View File

@ -10,19 +10,23 @@
<div class="dropdown-menu" id="dropdown-menu" role="menu" style="z-index:200"> <div class="dropdown-menu" id="dropdown-menu" role="menu" style="z-index:200">
<div class="dropdown-content"> <div class="dropdown-content">
{% block user-menu %} {% block user-menu %}
<a class="dropdown-item" href="{% url "dashboard" %}"> <a class="dropdown-item" href="{% url "dashboard" %}" data-force-reload="1">
{% translate "Dashboard" %} {% translate "Dashboard" %}
</a> </a>
{% endblock %} {% endblock %}
{% if user.is_admin %} {% if user.is_superuser %}
<hr class="dropdown-divider" />
{% block admin-menu %} {% block admin-menu %}
<a class="dropdown-item" href="{% url "admin:index" %}" target="new"> <a class="dropdown-item" href="{% url "admin:index" %}" target="new">
{% translate "Admin" %} {% translate "Admin" %}
</a> </a>
<a class="dropdown-item" href="{% url "dashboard-statistics" %}">
{% translate "Statistics" %}
</a>
{% endblock %} {% endblock %}
<hr class="dropdown-divider" /> <hr class="dropdown-divider" />
{% endif %} {% endif %}
<a class="dropdown-item" href="{% url "logout" %}"> <a class="dropdown-item" href="{% url "logout" %}" data-force-reload="1">
{% translate "Disconnect" %} {% translate "Disconnect" %}
</a> </a>
</div> </div>

View File

@ -6,10 +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 formset_data=tracklist_formset_data %} {% include "./dashboard/widgets/tracklist_editor.html" with formset=tracklist_formset formset_data=tracklist_formset_data %}
<hr/> <hr/>
<h2 class="title is-2">{% translate "Podcasts" %}</h2> <h2 class="title is-2">{% translate "Podcasts" %}</h2>
{% include "./dashboard/soundlist_editor.html" with formset=soundlist_formset formset_data=soundlist_formset_data %} {% include "./dashboard/widgets/soundlist_editor.html" with formset=soundlist_formset formset_data=soundlist_formset_data %}
</template> </template>
</a-episode> </a-episode>
{% endblock %} {% endblock %}

View File

@ -69,7 +69,7 @@ aircox.labels = {% inline_labels %}
{% 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 %}
{% include "./dashboard/form_field.html" with field=field.field name=field.name value=field.initial %} {% include "./dashboard/widgets/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>

View File

@ -2,7 +2,6 @@
{% load i18n humanize aircox %} {% load i18n humanize aircox %}
{% block tag-class %}{{ block.super }} comment{% endblock %} {% block tag-class %}{{ block.super }} comment{% endblock %}
{% block tag-extra %} id="comment-{{ object.pk }}"{% endblock %}
{% block outer %} {% block outer %}
{% with url=object.get_absolute_url %} {% with url=object.get_absolute_url %}
@ -33,18 +32,23 @@
{% block actions %} {% block actions %}
{{ block.super }} {{ block.super }}
{% if request.user.is_staff %} {% if admin %}
{% if user.is_staff %}
<a href="{% url "admin:aircox_comment_change" object.pk %}" class="button" <a href="{% url "admin:aircox_comment_change" object.pk %}" class="button"
title="{% trans "Edit comment" %}" title="{% trans "Edit comment" %}"
aria-label="{% trans "Edit comment" %}"> aria-label="{% trans "Edit comment" %}">
<span class="fa fa-edit"></span> <span class="fa fa-edit"></span>
</a> </a>
<a class="button is-danger" {% endif %}
title="{% trans "Delete comment" %}" <a-action-button class="button is-danger"
aria-label="{% trans "Delete comment" %}" title="{% trans "Delete comment" %}"
href="{% url "admin:aircox_comment_delete" object.pk %}"> aria-label="{% trans "Delete comment" %}"
<span class="fa fa-trash-alt"></span> url="{% url "api:comment-detail" object.pk %}"
</a> icon="fa fa-trash-alt"
method="delete"
confirm="{% translate "Delete comment?" %}"
@done="deleteElements('#{{ object|object_id }}')"
/>
{# <a href="mailto:{{ object.email }}">{{ object.nickname }}</a> #} {# <a href="mailto:{{ object.email }}">{{ object.nickname }}</a> #}
{% endif %} {% endif %}

View File

@ -20,9 +20,10 @@ Styling related context:
- tag_extra: extra tag attributes - tag_extra: extra tag attributes
{% endcomment %} {% endcomment %}
{% load aircox %}
{% block outer %} {% block outer %}
<{{ tag|default:"article" }} class="preview {% if not cover %}no-cover {% endif %}{% if is_active %}active {% endif %}{% if is_tiny %}tiny{% elif is_small %}small{% endif %}{% block tag-class %}{{ tag_class|default:"" }} {% endblock %}" {% block tag-extra %}{% endblock %}> <{{ tag|default:"article" }} id="{{ object|object_id }}" class="preview {% if not cover %}no-cover {% endif %}{% if is_active %}active {% endif %}{% if is_tiny %}tiny{% elif is_small %}small{% endif %}{% block tag-class %}{{ tag_class|default:"" }} {% endblock %}" {% block tag-extra %}{% endblock %}>
{% block inner %} {% block inner %}
{% block headings-container %} {% block headings-container %}
<header class="headings{% block headings-class %}{% endblock %}"{% block headings-tag-extra %}{% endblock %}> <header class="headings{% block headings-class %}{% endblock %}"{% block headings-tag-extra %}{% endblock %}>

View File

@ -2,6 +2,7 @@ import json
import random import random
from django import template, forms from django import template, forms
from django.db import models
from django.contrib.admin.templatetags.admin_urls import admin_urlname from django.contrib.admin.templatetags.admin_urls import admin_urlname
from django.template.loader import render_to_string from django.template.loader import render_to_string
from django.urls import reverse from django.urls import reverse
@ -19,6 +20,18 @@ def admin_url(obj, action):
return reverse(f"admin:{meta.app_label}_{meta.model_name}_{action}", args=[obj.id]) return reverse(f"admin:{meta.app_label}_{meta.model_name}_{action}", args=[obj.id])
@register.filter(name="model_label")
def model_label(obj):
if isinstance(obj, models.Model):
obj = type(obj)
return obj._meta.label_lower.replace(".", "-")
@register.filter(name="object_id")
def object_id(obj):
return f"{model_label(obj)}-{obj.pk}"
@register.simple_tag(name="page_widget", takes_context=True) @register.simple_tag(name="page_widget", takes_context=True)
def do_page_widget(context, widget, object, dir="aircox/widgets", **ctx): def do_page_widget(context, widget, object, dir="aircox/widgets", **ctx):
"""Render widget for the provided page and context.""" """Render widget for the provided page and context."""

View File

@ -24,6 +24,7 @@ router = DefaultRouter()
router.register("images", viewsets.ImageViewSet, basename="image") router.register("images", viewsets.ImageViewSet, basename="image")
router.register("sound", viewsets.SoundViewSet, basename="sound") router.register("sound", viewsets.SoundViewSet, basename="sound")
router.register("track", viewsets.TrackROViewSet, basename="track") router.register("track", viewsets.TrackROViewSet, basename="track")
router.register("comment", viewsets.CommentViewSet, basename="comment")
api = [ api = [
@ -121,6 +122,8 @@ urls = [
path(_("dashboard/"), views.dashboard.DashboardView.as_view(), name="dashboard"), path(_("dashboard/"), views.dashboard.DashboardView.as_view(), name="dashboard"),
path(_("dashboard/program/<pk>/"), views.ProgramUpdateView.as_view(), name="program-edit"), path(_("dashboard/program/<pk>/"), views.ProgramUpdateView.as_view(), name="program-edit"),
path(_("dashboard/episodes/<pk>/"), views.EpisodeUpdateView.as_view(), name="episode-edit"), path(_("dashboard/episodes/<pk>/"), views.EpisodeUpdateView.as_view(), name="episode-edit"),
path(_("dashboard/statistics/"), views.dashboard.StatisticsView.as_view(), name="dashboard-statistics"),
path(_("dashboard/statistics/<date:date>/"), views.dashboard.StatisticsView.as_view(), name="dashboard-statistics"),
# ---- others # ---- others
path("errors/no-station/", views.errors.NoStationErrorView.as_view(), name="errors-no-station"), path("errors/no-station/", views.errors.NoStationErrorView.as_view(), name="errors-no-station"),
] ]

View File

@ -31,7 +31,7 @@ class AdminMixin(LoginRequiredMixin, UserPassesTestMixin):
class StatisticsView(AdminMixin, LogListView, ListView): class StatisticsView(AdminMixin, LogListView, ListView):
template_name = "admin/aircox/statistics.html" template_name = "admin/aircox/statistics.html"
redirect_date_url = "admin:tools-stats" # redirect_date_url = "admin:tools-stats"
title = _("Statistics") title = _("Statistics")
date = None date = None

View File

@ -4,13 +4,21 @@ from django.utils.translation import gettext_lazy as _
from django.views.generic.base import TemplateView from django.views.generic.base import TemplateView
from aircox import models from aircox import models
from aircox.controllers.log_archiver import LogArchiver
from .base import BaseView from .base import BaseView
from .log import LogListView
class DashboardView(LoginRequiredMixin, BaseView, TemplateView): __all__ = ("DashboardBaseView", "DashboardView", "StatisticsView")
template_name = "aircox/dashboard/dashboard.html"
class DashboardBaseView(LoginRequiredMixin, BaseView):
title = _("Dashboard") title = _("Dashboard")
class DashboardView(DashboardBaseView, TemplateView):
template_name = "aircox/dashboard/dashboard.html"
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
programs = models.Program.objects.editor(self.request.user) programs = models.Program.objects.editor(self.request.user)
comments = models.Comment.objects.filter( comments = models.Comment.objects.filter(
@ -29,3 +37,17 @@ class DashboardView(LoginRequiredMixin, BaseView, TemplateView):
} }
) )
return super().get_context_data(**kwargs) return super().get_context_data(**kwargs)
class StatisticsView(DashboardBaseView, LogListView):
template_name = "aircox/dashboard/statistics.html"
date = None
redirect_date_url = "dashboard-statistics"
# TOOD: test_func & perms check
def get_object_list(self, logs, full=False):
if not logs.exists():
logs = LogArchiver().load(self.station, self.date) if self.date else []
objs = super().get_object_list(logs, True)
return objs

View File

@ -2,7 +2,7 @@ from django.contrib.auth.mixins import UserPassesTestMixin
from django.urls import reverse from django.urls import reverse
from aircox.models import Episode, Program, StaticPage, Track from aircox.models import Episode, Program, StaticPage, Track
from aircox import forms, filters from aircox import forms, filters, permissions
from .mixins import VueFormDataMixin from .mixins import VueFormDataMixin
from .page import PageDetailView, PageListView, PageUpdateView from .page import PageDetailView, PageListView, PageUpdateView
@ -51,8 +51,8 @@ class EpisodeUpdateView(UserPassesTestMixin, VueFormDataMixin, PageUpdateView):
template_name = "aircox/episode_form.html" template_name = "aircox/episode_form.html"
def test_func(self): def test_func(self):
obj = self.get_object().parent_subclass obj = self.get_object()
return obj.can_update(self.request.user) return permissions.program_permissions.can(self.request.user, "update", obj)
def get_tracklist_queryset(self, episode): def get_tracklist_queryset(self, episode):
return Track.objects.filter(episode=episode).order_by("position") return Track.objects.filter(episode=episode).order_by("position")

View File

@ -55,6 +55,7 @@ class LogListMixin(GetDateMixin):
diffs = self.get_diffusions_queryset() diffs = self.get_diffusions_queryset()
if self.request.user.is_staff and full: if self.request.user.is_staff and full:
return sorted(list(logs) + list(diffs), key=lambda obj: obj.start) return sorted(list(logs) + list(diffs), key=lambda obj: obj.start)
print(">>>>", len(logs), len(diffs), Log.merge_diffusions(logs, diffs))
return Log.merge_diffusions(logs, diffs) return Log.merge_diffusions(logs, diffs)

View File

@ -3,8 +3,7 @@ import random
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.forms import ProgramForm from aircox import models, forms, permissions
from aircox.models import Article, Episode, Program, StaticPage
from .page import PageDetailView, PageListView, PageUpdateView from .page import PageDetailView, PageListView, PageUpdateView
__all__ = ( __all__ = (
@ -14,7 +13,7 @@ __all__ = (
class ProgramDetailView(PageDetailView): class ProgramDetailView(PageDetailView):
model = Program model = models.Program
def get_related_queryset(self): def get_related_queryset(self):
queryset = ( queryset = (
@ -30,9 +29,9 @@ class ProgramDetailView(PageDetailView):
return reverse("program-list") + f"?category__id={self.object.category_id}" return reverse("program-list") + f"?category__id={self.object.category_id}"
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
episodes = Episode.objects.program(self.object).published().order_by("-pub_date") episodes = models.Episode.objects.program(self.object).published().order_by("-pub_date")
podcasts = episodes.with_podcasts() podcasts = episodes.with_podcasts()
articles = Article.objects.parent(self.object).published().order_by("-pub_date") articles = models.Article.objects.parent(self.object).published().order_by("-pub_date")
return super().get_context_data( return super().get_context_data(
articles=articles[: self.related_count], articles=articles[: self.related_count],
episodes=episodes[: self.related_count], episodes=episodes[: self.related_count],
@ -45,20 +44,20 @@ class ProgramDetailView(PageDetailView):
class ProgramListView(PageListView): class ProgramListView(PageListView):
model = Program model = models.Program
attach_to_value = StaticPage.Target.PROGRAMS attach_to_value = models.StaticPage.Target.PROGRAMS
def get_queryset(self): def get_queryset(self):
return super().get_queryset().order_by("title") return super().get_queryset().order_by("title")
class ProgramUpdateView(UserPassesTestMixin, PageUpdateView): class ProgramUpdateView(UserPassesTestMixin, PageUpdateView):
model = Program model = models.Program
form_class = ProgramForm form_class = forms.ProgramForm
def test_func(self): def test_func(self):
obj = self.get_object() obj = self.get_object()
return obj.can_update(self.request.user) return permissions.program_permissions.can(self.request.user, "update", obj)
def get_success_url(self): def get_success_url(self):
return reverse("program-detail", kwargs={"slug": self.get_object().slug}) return reverse("program-detail", kwargs={"slug": self.get_object().slug})

View File

@ -74,6 +74,12 @@ class TrackROViewSet(viewsets.ReadOnlyModelViewSet):
return self.list(request) return self.list(request)
class CommentViewSet(viewsets.ModelViewSet):
serializer_class = serializers.CommentSerializer
permission_classes = (permissions.IsAuthenticatedOrReadOnly,)
queryset = models.Comment.objects.all()
# --- admin # --- admin
class UserSettingsViewSet(viewsets.ViewSet): class UserSettingsViewSet(viewsets.ViewSet):
"""User's settings specific to aircox. """User's settings specific to aircox.

View File

@ -15,6 +15,13 @@ const App = {
computed: { computed: {
player() { return window.aircox.player; }, player() { return window.aircox.player; },
}, },
methods: {
deleteElements(sel) {
for(var el of document.querySelectorAll(sel))
el.parentNode.removeChild(el)
}
}
} }
export const PlayerApp = { export const PlayerApp = {

View File

@ -70,11 +70,11 @@ export default {
method: this.method, method: this.method,
body: JSON.stringify(this.item.data), body: JSON.stringify(this.item.data),
}) })
this.promise = fetch(this.url, options).then(data => { this.promise = fetch(this.url, options).then(data => data.text()).then(data => {
const response = data.json(); data = data && JSON.parse(data) || null
this.promise = null; this.promise = null;
this.$emit('done', response) this.$emit('done', data)
return response return data
}, data => { this.promise = null; return data }) }, data => { this.promise = null; return data })
return this.promise return this.promise
}, },

View File

@ -5,7 +5,7 @@
</template> </template>
<script> <script>
const splitReg = new RegExp(',\\s*', 'g'); const splitReg = new RegExp(',\\s*|\\s+', 'g');
export default { export default {
data() { data() {
@ -22,7 +22,8 @@ export default {
for(var item of items) for(var item of items)
if(item.value) if(item.value)
for(var tag of item.value.split(splitReg)) for(var tag of item.value.split(splitReg))
counts[tag.trim()] = (counts[tag.trim()] || 0) + 1; if(tag.trim())
counts[tag.trim()] = (counts[tag.trim()] || 0) + 1;
this.counts = counts; this.counts = counts;
}, },

View File

@ -34,11 +34,12 @@ export default base
export const admin = { export const admin = {
...base, ...base,
AStatistics, AStreamer, ATrackListEditor ATrackListEditor
} }
export const dashboard = { export const dashboard = {
...base, ...base,
AActionButton, AFileUpload, ASelectFile, AModal, AActionButton, AFileUpload, ASelectFile, AModal,
AFormSet, ATrackListEditor, ASoundListEditor AFormSet, ATrackListEditor, ASoundListEditor,
AStatistics, AStreamer,
} }

View File

@ -141,7 +141,7 @@ export default class PageLoad {
let submit = event.type == 'submit'; let submit = event.type == 'submit';
let target = submit || event.target.tagName == 'A' let target = submit || event.target.tagName == 'A'
? event.target : event.target.closest('a'); ? event.target : event.target.closest('a');
if(!target || target.hasAttribute('target')) if(!target || target.hasAttribute('target') || target.data.forceReload)
return; return;
let url = submit ? target.getAttribute('action') || '' let url = submit ? target.getAttribute('action') || ''

View File

@ -123,8 +123,15 @@
.main-color { color: var(--main-color); } .main-color { color: var(--main-color); }
.secondary-color { color: var(--secondary-color); } .secondary-color { color: var(--secondary-color); }
.bg-main { background-color: var(--main-color); }
.bg-main-light { background-color: var(--main-color-light); }
.bg-secondary { background-color: var(--secondary-color); }
.bg-secondary-light { background-color: var(--secondary-color-light); }
.bg-transparent { background-color: transparent; } .bg-transparent { background-color: transparent; }
.border-bottom-main { border-bottom: 1px solid var(--main-color); }
.border-bottom-secondary { border-bottom: 1px solid var(--secondary-color); }
.is-success { .is-success {
background-color: v.$green !important; background-color: v.$green !important;
border-color: v.$green-dark !important; border-color: v.$green-dark !important;