integrate statistics
This commit is contained in:
		@ -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),
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
@ -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")
 | 
			
		||||
 | 
			
		||||
@ -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
									
								
							
							
						
						
									
										82
									
								
								aircox/permissions.py
									
									
									
									
									
										Normal 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)
 | 
			
		||||
@ -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",
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										12
									
								
								aircox/serializers/page.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										12
									
								
								aircox/serializers/page.py
									
									
									
									
									
										Normal 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"]
 | 
			
		||||
@ -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
											
										
									
								
							@ -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 %}
 | 
			
		||||
 | 
			
		||||
@ -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 %}
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										92
									
								
								aircox/templates/aircox/dashboard/statistics.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										92
									
								
								aircox/templates/aircox/dashboard/statistics.html
									
									
									
									
									
										Normal 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 %}
 | 
			
		||||
@ -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>
 | 
			
		||||
@ -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 %}
 | 
			
		||||
 | 
			
		||||
@ -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>
 | 
			
		||||
 | 
			
		||||
@ -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 %}
 | 
			
		||||
 | 
			
		||||
@ -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 %}>
 | 
			
		||||
 | 
			
		||||
@ -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."""
 | 
			
		||||
 | 
			
		||||
@ -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"),
 | 
			
		||||
]
 | 
			
		||||
 | 
			
		||||
@ -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
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -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
 | 
			
		||||
 | 
			
		||||
@ -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")
 | 
			
		||||
 | 
			
		||||
@ -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)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -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})
 | 
			
		||||
 | 
			
		||||
@ -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.
 | 
			
		||||
 | 
			
		||||
@ -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 = {
 | 
			
		||||
 | 
			
		||||
@ -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
 | 
			
		||||
        },
 | 
			
		||||
 | 
			
		||||
@ -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;
 | 
			
		||||
        },
 | 
			
		||||
 | 
			
		||||
@ -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,
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -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') || ''
 | 
			
		||||
 | 
			
		||||
@ -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;
 | 
			
		||||
 | 
			
		||||
		Reference in New Issue
	
	Block a user