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
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"),
@ -22,4 +30,5 @@ class Migration(migrations.Migration):
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()
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:
verbose_name = _("Comment")

View File

@ -1,8 +1,7 @@
import os
from django.conf import settings as conf
from django.contrib.auth.models import Group, Permission
from django.contrib.contenttypes.models import ContentType
from django.contrib.auth.models import Group
from django.db import models
from django.utils.translation import gettext_lazy as _
@ -117,37 +116,6 @@ class Program(Page):
os.makedirs(path, exist_ok=True)
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:
verbose_name = _("Program")
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 .log import LogInfo, LogInfoSerializer
from .sound import SoundSerializer
from .episode import EpisodeSoundSerializer, EpisodeSerializer
from .log import LogInfo, LogInfoSerializer
from .page import CommentSerializer
from .sound import SoundSerializer
__all__ = (
"CommentSerializer",
"LogInfo",
"LogInfoSerializer",
"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);
}
.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;
}
.border-bottom-main {
border-bottom: 1px solid var(--main-color);
}
.border-bottom-secondary {
border-bottom: 1px solid var(--secondary-color);
}
.is-success {
background-color: #0e0 !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 %}
</head>
<body {% if request.is_mobile %}class="mobile"{% endif %}>
{% block body-head %}{% endblock %}
<script id="init-script">
window.addEventListener('load', function() {
{% block init-scripts %}
@ -64,7 +65,7 @@ Usefull context:
{% endfor %}
{% endblock %}
{% if user.is_authenticated %}
{% include "./dashboard/nav.html" %}
{% include "./dashboard/widgets/nav.html" %}
{% endif %}
</div>
{% endblock %}

View File

@ -1,5 +1,11 @@
{% 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 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-content">
{% block user-menu %}
<a class="dropdown-item" href="{% url "dashboard" %}">
<a class="dropdown-item" href="{% url "dashboard" %}" data-force-reload="1">
{% translate "Dashboard" %}
</a>
{% endblock %}
{% if user.is_admin %}
{% if user.is_superuser %}
<hr class="dropdown-divider" />
{% block admin-menu %}
<a class="dropdown-item" href="{% url "admin:index" %}" target="new">
{% translate "Admin" %}
</a>
<a class="dropdown-item" href="{% url "dashboard-statistics" %}">
{% translate "Statistics" %}
</a>
{% endblock %}
<hr class="dropdown-divider" />
{% endif %}
<a class="dropdown-item" href="{% url "logout" %}">
<a class="dropdown-item" href="{% url "logout" %}" data-force-reload="1">
{% translate "Disconnect" %}
</a>
</div>

View File

@ -6,10 +6,10 @@
<template v-slot="{podcasts,page}">
{{ block.super }}
<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/>
<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>
</a-episode>
{% endblock %}

View File

@ -69,7 +69,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/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 %}
</div>
<p class="help">{{ field.help_text }}</p>

View File

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

View File

@ -20,9 +20,10 @@ Styling related context:
- tag_extra: extra tag attributes
{% endcomment %}
{% load aircox %}
{% 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 headings-container %}
<header class="headings{% block headings-class %}{% endblock %}"{% block headings-tag-extra %}{% endblock %}>

View File

@ -2,6 +2,7 @@ import json
import random
from django import template, forms
from django.db import models
from django.contrib.admin.templatetags.admin_urls import admin_urlname
from django.template.loader import render_to_string
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])
@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)
def do_page_widget(context, widget, object, dir="aircox/widgets", **ctx):
"""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("sound", viewsets.SoundViewSet, basename="sound")
router.register("track", viewsets.TrackROViewSet, basename="track")
router.register("comment", viewsets.CommentViewSet, basename="comment")
api = [
@ -121,6 +122,8 @@ urls = [
path(_("dashboard/"), views.dashboard.DashboardView.as_view(), name="dashboard"),
path(_("dashboard/program/<pk>/"), views.ProgramUpdateView.as_view(), name="program-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
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):
template_name = "admin/aircox/statistics.html"
redirect_date_url = "admin:tools-stats"
# redirect_date_url = "admin:tools-stats"
title = _("Statistics")
date = None

View File

@ -4,13 +4,21 @@ from django.utils.translation import gettext_lazy as _
from django.views.generic.base import TemplateView
from aircox import models
from aircox.controllers.log_archiver import LogArchiver
from .base import BaseView
from .log import LogListView
class DashboardView(LoginRequiredMixin, BaseView, TemplateView):
template_name = "aircox/dashboard/dashboard.html"
__all__ = ("DashboardBaseView", "DashboardView", "StatisticsView")
class DashboardBaseView(LoginRequiredMixin, BaseView):
title = _("Dashboard")
class DashboardView(DashboardBaseView, TemplateView):
template_name = "aircox/dashboard/dashboard.html"
def get_context_data(self, **kwargs):
programs = models.Program.objects.editor(self.request.user)
comments = models.Comment.objects.filter(
@ -29,3 +37,17 @@ class DashboardView(LoginRequiredMixin, BaseView, TemplateView):
}
)
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 aircox.models import Episode, Program, StaticPage, Track
from aircox import forms, filters
from aircox import forms, filters, permissions
from .mixins import VueFormDataMixin
from .page import PageDetailView, PageListView, PageUpdateView
@ -51,8 +51,8 @@ class EpisodeUpdateView(UserPassesTestMixin, VueFormDataMixin, PageUpdateView):
template_name = "aircox/episode_form.html"
def test_func(self):
obj = self.get_object().parent_subclass
return obj.can_update(self.request.user)
obj = self.get_object()
return permissions.program_permissions.can(self.request.user, "update", obj)
def get_tracklist_queryset(self, episode):
return Track.objects.filter(episode=episode).order_by("position")

View File

@ -55,6 +55,7 @@ class LogListMixin(GetDateMixin):
diffs = self.get_diffusions_queryset()
if self.request.user.is_staff and full:
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)

View File

@ -3,8 +3,7 @@ import random
from django.contrib.auth.mixins import UserPassesTestMixin
from django.urls import reverse
from aircox.forms import ProgramForm
from aircox.models import Article, Episode, Program, StaticPage
from aircox import models, forms, permissions
from .page import PageDetailView, PageListView, PageUpdateView
__all__ = (
@ -14,7 +13,7 @@ __all__ = (
class ProgramDetailView(PageDetailView):
model = Program
model = models.Program
def get_related_queryset(self):
queryset = (
@ -30,9 +29,9 @@ class ProgramDetailView(PageDetailView):
return reverse("program-list") + f"?category__id={self.object.category_id}"
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()
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(
articles=articles[: self.related_count],
episodes=episodes[: self.related_count],
@ -45,20 +44,20 @@ class ProgramDetailView(PageDetailView):
class ProgramListView(PageListView):
model = Program
attach_to_value = StaticPage.Target.PROGRAMS
model = models.Program
attach_to_value = models.StaticPage.Target.PROGRAMS
def get_queryset(self):
return super().get_queryset().order_by("title")
class ProgramUpdateView(UserPassesTestMixin, PageUpdateView):
model = Program
form_class = ProgramForm
model = models.Program
form_class = forms.ProgramForm
def test_func(self):
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):
return reverse("program-detail", kwargs={"slug": self.get_object().slug})

View File

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

View File

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

View File

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

View File

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

View File

@ -34,11 +34,12 @@ export default base
export const admin = {
...base,
AStatistics, AStreamer, ATrackListEditor
ATrackListEditor
}
export const dashboard = {
...base,
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 target = submit || event.target.tagName == 'A'
? event.target : event.target.closest('a');
if(!target || target.hasAttribute('target'))
if(!target || target.hasAttribute('target') || target.data.forceReload)
return;
let url = submit ? target.getAttribute('action') || ''

View File

@ -123,8 +123,15 @@
.main-color { color: var(--main-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; }
.border-bottom-main { border-bottom: 1px solid var(--main-color); }
.border-bottom-secondary { border-bottom: 1px solid var(--secondary-color); }
.is-success {
background-color: v.$green !important;
border-color: v.$green-dark !important;