M2M list editor; users list & group mgt; add missing files

This commit is contained in:
bkfox 2024-04-27 21:05:36 +02:00
parent ae176dc623
commit 8f1ec9cbc1
43 changed files with 613 additions and 257 deletions

View File

@ -176,5 +176,8 @@ class Settings(BaseSettings):
IMPORT_PLAYLIST_CSV_TEXT_QUOTE = '"'
"""Text delimiter of csv text files."""
ALLOW_COMMENTS = True
"""Allow comments."""
settings = Settings("AIRCOX")

View File

@ -13,6 +13,7 @@ __all__ = (
"SoundFilterSet",
"TrackFilterSet",
"UserFilterSet",
"GroupFilterSet",
"UserGroupFilterSet",
)
@ -90,6 +91,17 @@ class UserFilterSet(filters.FilterSet):
)
class GroupFilterSet(filters.FilterSet):
search = filters.CharFilter(field_name="search", method="search_filter")
no_user = filters.NumberFilter(field_name="no_user", method="no_user_filter")
def no_user_filter(self, queryset, name, value):
return queryset.exclude(user__in=[value])
def search_filter(self, queryset, name, value):
return queryset.filter(Q(name__icontains=value) | Q(program__title__icontains=value))
class UserGroupFilterSet(filters.FilterSet):
class Meta:
model = User.groups.through

View File

@ -4,6 +4,7 @@ from .episode import EpisodeForm, EpisodeSoundFormSet
from .program import ProgramForm
from .page import CommentForm, ImageForm, PageForm, ChildPageForm
from .sound import SoundForm, SoundCreateForm
from .track import TrackFormSet
__all__ = (
@ -18,4 +19,5 @@ __all__ = (
ChildPageForm,
SoundForm,
SoundCreateForm,
TrackFormSet,
)

89
aircox/forms/widgets.py Normal file
View File

@ -0,0 +1,89 @@
from itertools import chain
from functools import cached_property
from django import forms, http
from django.urls import reverse
__all__ = (
"VueWidget",
"VueAutoComplete",
)
class VueWidget(forms.Widget):
binds = None
"""Dict of `{attribute: value}` attrs set as bindings."""
events = None
"""Dict of `{event: value}` attrs set as events."""
v_model = ""
"""ES6 Model instance to bind to (`v-model`)."""
def __init__(self, *args, binds=None, events=None, v_model=None, **kwargs):
super().__init__(*args, **kwargs)
self.binds = binds or []
self.events = events or []
@cached_property
def vue_attrs(self):
"""Dict of Vue specific attributes."""
binds, events = self.binds, self.events
if isinstance(binds, dict):
binds = binds.items()
if isinstance(events, dict):
events = events.items()
return dict(
chain(
((":" + key, value) for key, value in binds),
(("@" + key, value) for key, value in events),
)
)
def build_attrs(self, base_attrs, extra_attrs=None):
extra_attrs = extra_attrs or {}
extra_attrs.update(self.vue_attrs)
return super().build_attrs(base_attrs, extra_attrs)
class VueAutoComplete(VueWidget, forms.TextInput):
"""Autocomplete Vue component."""
template_name = "aircox/widgets/autocomplete.html"
url: str = ""
"""Url to autocomplete API view.
If it has query parameters, does not generate it based on lookup
(see `get_url()` doc).
"""
lookup: str = ""
"""Field name used as lookup (instead as provided one)."""
params: http.QueryDict
def __init__(self, url_name, *args, lookup=None, params=None, **kwargs):
self.url_name = url_name
self.lookup = lookup
self.params = params
super().__init__(*args, **kwargs)
def get_context(self, name, value, attrs):
context = super().get_context(name, value, attrs)
context["url"] = self.get_url(name, self.lookup, self.params)
return context
def get_url(self, name, lookup, params=None):
"""Return url to autocomplete API. When query parameters are not
provided generate them using `?{lookup}=${query}&field={name}` (where
`${query} is Vue `a-autocomplete` specific).
:param str name: field name (not used by default)
:param str lookup: lookup query parameter
:param http.QueryDict params: additional mutable parameter
"""
url = reverse(self.url_name)
query = http.QueryDict(mutable=True)
if params:
query.update(params)
query.update({lookup: "${query}"})
return f"{url}?{query.urlencode()}"

View File

@ -0,0 +1,31 @@
from django.db import migrations, models, transaction
def init_groups_and_permissions(app, schema_editor):
from aircox import permissions
Program = app.get_model("aircox", "Program")
with transaction.atomic():
for program in Program.objects.all():
permissions.program.init(program)
class Migration(migrations.Migration):
atomic = False
dependencies = [
("aircox", "0021_alter_schedule_timezone"),
]
operations = [
migrations.RunPython(init_groups_and_permissions),
migrations.AlterField(
model_name="program",
name="editors_group",
field=models.ForeignKey(
on_delete=models.deletion.CASCADE,
to="auth.group",
verbose_name="editors",
),
),
]

View File

@ -305,9 +305,9 @@ class StaticPage(BasePage):
)
def get_related_view(self):
from ..views import attached
from ..views.page import attached_views
return self.attach_to and attached.get(self.attach_to) or None
return self.attach_to and attached_views.get(self.attach_to) or None
def get_absolute_url(self):
if self.attach_to:

View File

@ -0,0 +1,28 @@
from django.contrib.auth.models import User, Group
from rest_framework import serializers
__all__ = ("UserSerializer", "GroupSerializer", "UserGroupSerializer")
class UserSerializer(serializers.ModelSerializer):
class Meta:
exclude = ("password",)
model = User
class GroupSerializer(serializers.ModelSerializer):
class Meta:
exclude = ("permissions",)
model = Group
class UserGroupSerializer(serializers.ModelSerializer):
group = GroupSerializer(read_only=True)
user = UserSerializer(read_only=True)
user_id = serializers.IntegerField()
group_id = serializers.IntegerField()
class Meta:
model = User.groups.through
fields = ("id", "group_id", "user_id", "group", "user")

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1,34 +0,0 @@
{% extends "aircox/base.html" %}
{% load i18n aircox %}
{% block head_title %}
{% block title %}{{ user.username }}{% endblock %}
{% endblock %}
{% block content-container %}
<div class="container content page-content">
<h2 class="subtitle">Mon Profil</h2>
{% translate "Username" %} : {{ user.username|title }}<br/>
<!-- Connexion: {{ user.last_login }} -->
<h2 class="subtitle is-1">Mes émissions</h2>
{% if programs|length %}
<ul>
{% for p in programs %}
<li>{{ p.title }} :
&nbsp;
<a href="{% url 'program-detail' slug=p.slug %}">
<span title="{% translate 'View' %} {{ page }}">{% translate 'View' %}</span>
</a>
&nbsp;
<a href="{% url 'program-edit' pk=p.pk %}">
<span title="{% translate 'Edit' %} {{ page }}">{% translate 'Edit' %} </span>
</a>
</li>
{% endfor %}
</ul>
{% else %}
{% trans 'You are not listed as a program editor yet' %}
{% endif %}
</div>
{% endblock %}

View File

@ -21,44 +21,11 @@
{% endblock %}
</section>
{% block list-pagination %}
{% if is_paginated %}
<hr/>
{% update_query request.GET.copy page=None as GET %}
{% with GET.urlencode as GET %}
<nav class="nav-urls is-centered" role="pagination" aria-label="{% translate "pagination" %}">
<ul class="urls">
{% if page_obj.has_previous %}
{% comment %}Translators: Bottom of the list, "previous page"{% endcomment %}
<a href="?{{ GET }}&page={{ page_obj.previous_page_number }}" class="left"
title="{% translate "Previous" %}"
aria-label="{% translate "Previous" %}">
<span class="icon"><i class="fa fa-chevron-left"></i></span>
</a>
{% endif %}
<span>
{{ page_obj.number }} / {{ page_obj.paginator.num_pages }}
</span>
{% if page_obj.has_next %}
{% comment %}Translators: Bottom of the list, "Nextpage"{% endcomment %}
<a href="?{{ GET }}&page={{ page_obj.next_page_number }}" class="right"
title="{% translate "Next" %}"
aria-label="{% translate "Next" %}">
<span class="icon"><i class="fa fa-chevron-right"></i></span>
</a>
{% endif %}
</ul>
</nav>
{% endwith %}
{% endif %}
{% include "./widgets/list_pagination.html" %}
{% endblock %}
{% endblock %}
</div>
{% endblock %}

View File

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

View File

@ -25,17 +25,6 @@
</div>
</div>
{% if comments %}
<div>
<h2 class="title is-2 mb-3">{% translate "Last Comments" %}</h2>
<div class="box box-shadow p-3" style="max-height: 35rem; overflow-y:auto;">
{% for object in comments|slice:"0:25" %}
{% page_widget "item" object admin=True is_tiny=True %}
{% endfor %}
</div>
</div>
{% endif %}
<div>
<h2 class="title is-2 mb-3">{% translate "Programs" %}</h2>
<div class="box box-shadow p-3" style="max-height: 35rem; overflow-y:auto;">
@ -47,5 +36,16 @@
</div>
</div>
{% if comments %}
<div>
<h2 class="title is-2 mb-3">{% translate "Last Comments" %}</h2>
<div class="box box-shadow p-3" style="max-height: 35rem; overflow-y:auto;">
{% for object in comments|slice:"0:25" %}
{% page_widget "item" object admin=True is_tiny=True %}
{% endfor %}
</div>
</div>
{% endif %}
</section>
{% endblock %}

View File

@ -0,0 +1,53 @@
{% extends "./base.html" %}
{% load i18n aircox %}
{% block title %}{% translate "Users" %}{% endblock %}
{% block content-container %}
<div class="container">
<table class="table is-stripped is-fullwidth">
<thead>
<td>{% trans "User" %}</td>
<td>{% trans "Programs" %}</td>
<td></td>
</thead>
<tbody>
{% for obj in object_list %}
<tr>
<td>
{% if obj.first_name or obj.last_name %}
{{ obj.first_name }} {{ obj.last_name }}
&mdash;
{% endif %}
<i>{{ obj.username }}</i>
</td>
<td>
{% for p in obj.programs %}
<a href="{% url "program-edit" p.pk %}">{{ p.title }}</a>
{% if not forloop.last %}
<br/>
{% endif %}
{% endfor %}
</td>
<td>
{% if user.is_superuser %}
<button type="button" class="button secondary"
@click="$refs['user-groups-modal'].open({id: {{ obj.id }}, name: '{{ obj.username }}' })">
{% trans "Groups" %}
</button>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% include "aircox/widgets/list_pagination.html" %}
{% if user.is_superuser %}
{% include "./widgets/user_groups.html" %}
{% endif %}
{% endblock %}

View File

@ -3,12 +3,28 @@
<a-modal ref="group-users-modal">
<template #title="{item}">[[ item?.name ]]</template>
<template #default="{item}">
<a-group-users v-if="item" ref="group-users"
<a-many-to-many-edit v-if="item" ref="group-users"
:url="'{% url 'api:usergroup-list' %}?group=' + item.id"
commit-url="{% url 'api:usergroup-commit' %}"
:search-url="'{% url 'api:user-autocomplete' %}?search=${query}&amp;not_in_group=' + item.id"
:initials="{group_id: item.id }"
/>
:source_id="item.id" source_field="group"
target_field="user"
:autocomplete="{url:'{% url 'api:user-autocomplete' %}?search=${query}&amp;not_in_group=' + item.id, 'label-field':'username', 'value-field':'id'}"
>
<template #items-title>{% translate "Members" %}</template>
<template #item="{item}">
<b class="mr-3">[[ item.data.user.username ]]</b>
<span>[[ item.data.user.first_name ]] [[ item.data.user.last_name ]]</span>
</template>
<template #autocomplete-item="{item}">
<b class="mr-3">[[ item.username ]]</b>
<span class="text-light">[[ item.first_name ]] [[ item.last_name ]]</span>
&mdash;
<i>[[ item.email ]]</i>
</template>
</a-many-to-many-edit>
</template>
<template #footer="{item, close}">
<button type="button" class="button" @click="$refs['group-users'].save(); close()">

View File

@ -0,0 +1,30 @@
{% load i18n %}
<a-modal ref="user-groups-modal">
<template #title="{item}">[[ item?.username ]]</template>
<template #default="{item}">
<a-many-to-many-edit v-if="item" ref="user-groups"
:url="'{% url 'api:usergroup-list' %}?user=' + item.id"
commit-url="{% url 'api:usergroup-commit' %}"
:source_id="item.id" source_field="user"
target_field="group"
:autocomplete="{url:'{% url 'api:group-autocomplete' %}?search=${query}&amp;no_user=' + item.id, 'label-field':'name', 'value-field':'id'}"
>
<template #items-title>{% translate "Groups" %}</template>
<template #item="{item}">
[[ item.data.group.name ]]
</template>
<template #autocomplete-item="{item}">
[[ item.name ]]
</template>
</a-many-to-many-edit>
</template>
<template #footer="{item, close}">
<button type="button" class="button" @click="$refs['group-users'].save(); close()">
Save
</button>
</template>
</a-modal>

View File

@ -1,18 +1,21 @@
{% extends "./dashboard/base.html" %}
{% load static aircox_admin i18n %}
{% load static aircox aircox_admin i18n %}
{% block init-scripts %}
aircox.labels = {% inline_labels %}
{{ block.super }}
{% endblock %}
{% block header-cover %}
<div class="flex-column">
<img src="{{ cover }}" ref="cover" class="cover">
<button type="button" class="button" @click="$refs['cover-select'].open()">
{% translate "Change cover" %}
</button>
</div>
{% block header-cover %}{% endblock %}
{% block title %}
{% if not object %}
{% with view.model|verbose_name as model %}
{% blocktranslate %}Create a {{model}}{% endblocktranslate %}
{% endwith %}
{% else %}
{{ block.super }}
{% endif %}
{% endblock %}
@ -52,18 +55,16 @@ aircox.labels = {% inline_labels %}
<form method="post" enctype="multipart/form-data">
{% block page-form %}
{% csrf_token %}
<div class="flex-row">
<div class="flex-grow-3">
{% for field in form %}
{% if field.name == "cover" %}
<input type="hidden" name="{{ field.name }}" value="{{ field.value }}" ref="cover-input"/>
{% else %}
{% if field.name not in "cover,content" %}
<div class="field {% if field.name != "content" %}is-horizontal{% endif %}">
<label class="label">{{ field.label }}</label>
<div class="control clear-unset">
{% if field.name == "pub_date" %}
<input type="datetime-local" class="input" name="{{ field.name }}"
value="{{ field.value|date:"Y-m-d" }}T{{ field.value|date:"H:i" }}"/>
{% elif field.name == "content" %}
<textarea name="{{ field.name }}" class="is-fullwidth height-25">{{ field.value|striptags|safe }}</textarea>
{% else %}
{% include "aircox/forms/form_field.html" with field=field.field name=field.name value=field.initial %}
{% endif %}
@ -78,8 +79,45 @@ aircox.labels = {% inline_labels %}
<p class="help">{{ field.help_text|safe }}</p>
{% endif %}
{% endfor %}
</div>
<div class="flex-grow-1">
{% with form.cover as field %}
{% block page-form-cover %}
<input type="hidden" name="{{ field.name }}" value="{{ field.value }}" ref="cover-input"/>
{% spaceless %}
<figure class="header-cover">
<img src="{{ cover }}" ref="cover" class="cover">
<button type="button" class="button" @click="$refs['cover-select'].open()">
{% translate "Change cover" %}
</button>
</figure>
{% endspaceless %}
{% endblock %}
{% endwith %}
</div>
</div>
{% with form.content as field %}
{% block page-form-content %}
<div>
<div class="field {% if field.name != "content" %}is-horizontal{% endif %}">
<label class="label">{{ field.label }}</label>
<div class="control clear-unset">
<textarea name="{{ field.name }}" class="is-fullwidth height-25">{{ field.value|default:""|striptags|safe }}</textarea>
</div>
<p class="help">{{ field.help_text }}</p>
</div>
{% if field.errors %}
<p class="help is-danger">{{ field.errors }}</p>
{% endif %}
{% if field.help_text %}
<p class="help">{{ field.help_text|safe }}</p>
{% endif %}
</div>
{% endblock %}
{% endwith %}
{% endblock %}
<hr/>
<div class="flex-row">
<div class="flex-grow-1">{% block page-form-actions %}{% endblock %}</div>

View File

@ -7,10 +7,10 @@
{% endblock %}
{% block page-form-actions %}
{% if request.user.is_superuser %}
{% if object and object.pk and request.user.is_superuser %}
<button type="button"
class="button secondary"
@click="$refs['group-users-modal'].open({id: {{ object.editors_group_id }}, name: '{{ object.editors_group.name }}' })">Editors</button>
@click="$refs['group-users-modal'].open({id: {{ object.editors_group_id }}, name: '{{ object.editors_group.name }}' })">{% translate "Editors" %}</button>
{% include "./dashboard/widgets/group_users.html" %}
{{ block.super }}

View File

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

View File

@ -0,0 +1,39 @@
{% comment %}
Context:
- is_paginated: if True, page is paginated
- page_obj: page object from list view;
{% endcomment %}
{% load i18n aircox %}
{% if is_paginated %}
<hr/>
{% update_query request.GET.copy page=None as GET %}
{% with GET.urlencode as GET %}
<nav class="nav-urls is-centered" role="pagination" aria-label="{% translate "pagination" %}">
<ul class="urls">
{% if page_obj.has_previous %}
{% comment %}Translators: Bottom of the list, "previous page"{% endcomment %}
<a href="?{{ GET }}&page={{ page_obj.previous_page_number }}" class="left"
title="{% translate "Previous" %}"
aria-label="{% translate "Previous" %}">
<span class="icon"><i class="fa fa-chevron-left"></i></span>
</a>
{% endif %}
<span>
{{ page_obj.number }} / {{ page_obj.paginator.num_pages }}
</span>
{% if page_obj.has_next %}
{% comment %}Translators: Bottom of the list, "Nextpage"{% endcomment %}
<a href="?{{ GET }}&page={{ page_obj.next_page_number }}" class="right"
title="{% translate "Next" %}"
aria-label="{% translate "Next" %}">
<span class="icon"><i class="fa fa-chevron-right"></i></span>
</a>
{% endif %}
</ul>
</nav>
{% endwith %}
{% endif %}

View File

@ -1,4 +1,4 @@
{% load i18n %}
{% load aircox i18n %}
<div class="dropdown is-hoverable is-right">
<div class="dropdown-trigger">
<button class="button square" aria-haspopup="true" aria-controls="dropdown-menu" type="button">
@ -13,7 +13,23 @@
<a class="dropdown-item" href="{% url "dashboard" %}" data-force-reload="1">
{% translate "Dashboard" %}
</a>
{% if user|has_perm:"list_user" %}
<a class="dropdown-item" href="{% url "user-list" %}" data-force-reload="1">
{% translate "Users" %}
</a>
{% endif %}
{% endblock %}
{% comment %}
{% block edit-menu %}
{% if request.user|has_perm:"aircox.create_program" %}
<a class="dropdown-item" href="{% url "program-create" %}">
{% translate "Create Program" %}
</a>
{% endif %}
{% endblock %}
{% endcomment %}
{% if user.is_superuser %}
<hr class="dropdown-divider" />
{% block admin-menu %}

View File

@ -1,6 +1,6 @@
{% load aircox i18n %}
{% block user-actions-container %}
{% has_perm page page.program.change_permission_codename simple=True as can_edit %}
{% has_obj_perm page page.program.change_permission_codename simple=True as can_edit %}
{% if user.is_authenticated %}
{{ object.get_status_display }}

View File

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

View File

@ -75,16 +75,18 @@ def do_get_tracks(obj):
return obj.track_set.all()
@register.simple_tag(name="has_perm", takes_context=True)
def do_has_perm(context, obj, perm, user=None, simple=False):
@register.filter(name="has_perm")
def do_has_perm(user, perm):
"""Return True if user has permission."""
return user.has_perm(perm)
@register.simple_tag(name="has_obj_perm", takes_context=True)
def do_has_obj_perm(context, obj, perm, user=None):
"""Return True if ``user.has_perm('[APP].[perm]_[MODEL]')``"""
user = user or context["request"].user
if not obj:
return
if user is None:
user = context["request"].user
if simple:
return user.has_perm("aircox.{}".format(perm)) or user.is_superuser
else:
return False
return user.has_perm("{}.{}_{}".format(obj._meta.app_label, perm, obj._meta.model_name))

View File

@ -22,6 +22,7 @@ register_converter(WeekConverter, "week")
router = DefaultRouter()
router.register("user", viewsets.UserViewSet, basename="user")
router.register("group", viewsets.GroupViewSet, basename="group")
router.register("usergroup", viewsets.UserGroupViewSet, basename="usergroup")
router.register("images", viewsets.ImageViewSet, basename="image")
@ -31,7 +32,7 @@ router.register("comment", viewsets.CommentViewSet, basename="comment")
api = [
path("logs/", views.LogListAPIView.as_view(), name="live"),
path("logs/", views.log.LogListAPIView.as_view(), name="live"),
path(
"user/settings/",
viewsets.UserSettingsViewSet.as_view({"get": "retrieve", "post": "update", "put": "update"}),
@ -41,30 +42,30 @@ api = [
urls = [
path("", views.HomeView.as_view(), name="home"),
path("", views.home.HomeView.as_view(), name="home"),
path("api/", include((api, "aircox"), namespace="api")),
# ---- ---- objects views
# ---- articles
path(
_("articles/<slug:slug>/"),
views.ArticleDetailView.as_view(),
views.article.ArticleDetailView.as_view(),
name="article-detail",
),
path(
_("articles/"),
views.ArticleListView.as_view(model=models.Article),
views.article.ArticleListView.as_view(model=models.article.Article),
name="article-list",
),
path(
_("articles/c/<slug:category_slug>/"),
views.ArticleListView.as_view(model=models.Article),
views.article.ArticleListView.as_view(model=models.article.Article),
name="article-list",
),
# ---- timetable
path(_("timetable/"), views.TimeTableView.as_view(), name="timetable-list"),
path(_("timetable/"), views.diffusion.TimeTableView.as_view(), name="timetable-list"),
path(
_("timetable/<date:date>/"),
views.TimeTableView.as_view(),
views.diffusion.TimeTableView.as_view(),
name="timetable-list",
),
# ---- pages
@ -95,38 +96,41 @@ urls = [
name="static-page-list",
),
# ---- programs
path(_("programs/"), views.ProgramListView.as_view(), name="program-list"),
path(_("programs/c/<slug:category_slug>/"), views.ProgramListView.as_view(), name="program-list"),
path(_("programs/"), views.program.ProgramListView.as_view(), name="program-list"),
path(_("programs/c/<slug:category_slug>/"), views.program.ProgramListView.as_view(), name="program-list"),
path(
_("programs/<slug:slug>"),
views.ProgramDetailView.as_view(),
views.program.ProgramDetailView.as_view(),
name="program-detail",
),
path(_("programs/<slug:parent_slug>/articles/"), views.ArticleListView.as_view(), name="article-list"),
path(_("programs/<slug:parent_slug>/podcasts/"), views.PodcastListView.as_view(), name="podcast-list"),
path(_("programs/<slug:parent_slug>/episodes/"), views.EpisodeListView.as_view(), name="episode-list"),
path(_("programs/<slug:parent_slug>/diffusions/"), views.DiffusionListView.as_view(), name="diffusion-list"),
path(_("programs/<slug:parent_slug>/articles/"), views.article.ArticleListView.as_view(), name="article-list"),
path(_("programs/<slug:parent_slug>/podcasts/"), views.episode.PodcastListView.as_view(), name="podcast-list"),
path(_("programs/<slug:parent_slug>/episodes/"), views.episode.EpisodeListView.as_view(), name="episode-list"),
path(
_("programs/<slug:parent_slug>/diffusions/"), views.diffusion.DiffusionListView.as_view(), name="diffusion-list"
),
path(
_("programs/<slug:parent_slug>/publications/"),
views.PageListView.as_view(model=models.Page, attach_to_value=models.StaticPage.Target.PAGES),
name="page-list",
),
# ---- episodes
path(_("programs/episodes/"), views.EpisodeListView.as_view(), name="episode-list"),
path(_("programs/episodes/c/<slug:category_slug>/"), views.EpisodeListView.as_view(), name="episode-list"),
path(_("programs/episodes/"), views.episode.EpisodeListView.as_view(), name="episode-list"),
path(_("programs/episodes/c/<slug:category_slug>/"), views.episode.EpisodeListView.as_view(), name="episode-list"),
path(
_("programs/episodes/<slug:slug>/"),
views.EpisodeDetailView.as_view(),
views.episode.EpisodeDetailView.as_view(),
name="episode-detail",
),
path(_("podcasts/"), views.PodcastListView.as_view(), name="podcast-list"),
path(_("podcasts/c/<slug:category_slug>/"), views.PodcastListView.as_view(), name="podcast-list"),
path(_("podcasts/"), views.episode.PodcastListView.as_view(), name="podcast-list"),
path(_("podcasts/c/<slug:category_slug>/"), views.episode.PodcastListView.as_view(), name="podcast-list"),
# ---- dashboard
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/program/<pk>/"), views.program.ProgramUpdateView.as_view(), name="program-edit"),
path(_("dashboard/episodes/<pk>/"), views.episode.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"),
path(_("dashboard/users/"), views.auth.UserListView.as_view(), name="user-list"),
# ---- others
path("errors/no-station/", views.errors.NoStationErrorView.as_view(), name="errors-no-station"),
]

View File

@ -1,10 +1,7 @@
from . import admin, dashboard, errors
from .article import ArticleDetailView, ArticleListView
from . import admin, dashboard, errors, auth
from . import article, program, episode, diffusion, log
from . import home
from .base import BaseAPIView, BaseView
from .diffusion import DiffusionListView, TimeTableView
from .episode import EpisodeDetailView, EpisodeListView, PodcastListView, EpisodeUpdateView
from .home import HomeView
from .log import LogListAPIView, LogListView
from .page import (
BasePageDetailView,
BasePageListView,
@ -12,47 +9,24 @@ from .page import (
PageListView,
PageUpdateView,
)
from .program import (
ProgramDetailView,
ProgramListView,
ProgramUpdateView,
)
__all__ = (
"admin",
"dashboard",
"errors",
"attached",
"dashboard",
"auth",
"article",
"program",
"episode",
"diffusion",
"log",
"home",
"BaseAPIView",
"BaseView",
"ArticleDetailView",
"ArticleListView",
"DiffusionListView",
"TimeTableView",
"EpisodeDetailView",
"EpisodeListView",
"PodcastListView",
"EpisodeUpdateView",
"HomeView",
"LogListAPIView",
"LogListView",
"BasePageDetailView",
"BasePageListView",
"PageDetailView",
"PageUpdateView",
"PageListView",
"ProgramDetailView",
"ProgramListView",
"ProgramUpdateView",
)
attached = {}
for key in __all__:
view = globals().get(key)
if key == "attached":
continue
if attach := getattr(view, "attach_to_value", None):
attached[attach] = view

View File

@ -1,14 +1,15 @@
from ..models import Article, Program, StaticPage
from .page import PageDetailView, PageListView
from . import page
__all__ = ["ArticleDetailView", "ArticleListView"]
class ArticleDetailView(PageDetailView):
class ArticleDetailView(page.PageDetailView):
model = Article
class ArticleListView(PageListView):
@page.attach
class ArticleListView(page.PageListView):
model = Article
parent_model = Program
attach_to_value = StaticPage.Target.ARTICLES

28
aircox/views/auth.py Normal file
View File

@ -0,0 +1,28 @@
from django.contrib.auth.models import User
from django.views.generic import ListView
from aircox.models import Program
class UserListView(ListView):
model = User
queryset = User.objects.all().order_by("first_name").prefetch_related("groups")
paginate_by = 100
permission_required = [
"auth.list_user",
]
template_name = "aircox/dashboard/user_list.html"
def get_users_programs(self, users):
groups = {g for u in users for g in u.groups.all()}
programs = Program.objects.filter(editors_group__in=groups)
programs = {p.editors_group_id: p for p in programs}
for user in users:
user.programs = [programs[g.id] for g in user.groups.all() if g.id in programs]
return programs
def get_context_data(self, **kwargs):
kwargs["programs"] = self.get_users_programs(self.object_list)
return super().get_context_data(**kwargs)

View File

@ -6,6 +6,7 @@ from django.views.generic import ListView
from aircox.models import Diffusion, Log, StaticPage
from .base import BaseView
from .mixins import AttachedToMixin, GetDateMixin
from .page import attach
__all__ = ("DiffusionListView", "TimeTableView")
@ -21,6 +22,7 @@ class DiffusionListView(BaseDiffusionListView):
model = Diffusion
@attach
class TimeTableView(GetDateMixin, BaseDiffusionListView):
model = Diffusion
redirect_date_url = "timetable-list"

View File

@ -5,7 +5,7 @@ from aircox.models import Episode, Program, StaticPage, Track
from aircox import forms, filters, permissions
from .mixins import VueFormDataMixin
from .page import PageDetailView, PageListView, PageUpdateView
from .page import attach, PageDetailView, PageListView, PageUpdateView
__all__ = (
@ -33,6 +33,7 @@ class EpisodeDetailView(PageDetailView):
return reverse("episode-list", kwargs={"parent_slug": self.object.parent.slug})
@attach
class EpisodeListView(PageListView):
model = Episode
filterset_class = filters.EpisodeFilters
@ -40,6 +41,7 @@ class EpisodeListView(PageListView):
attach_to_value = StaticPage.Target.EPISODES
@attach
class PodcastListView(EpisodeListView):
attach_to_value = StaticPage.Target.PODCASTS
queryset = Episode.objects.published().with_podcasts().order_by("-pub_date")

View File

@ -6,8 +6,10 @@ from django.views.generic import ListView
from ..models import Diffusion, Episode, Log, Page, StaticPage
from .base import BaseView
from .mixins import AttachedToMixin
from .page import attach
@attach
class HomeView(AttachedToMixin, BaseView, ListView):
template_name = "aircox/home.html"
attach_to_value = StaticPage.Target.HOME

View File

@ -1,10 +1,11 @@
from django.http import HttpResponse
from django.utils.translation import gettext_lazy as _
from django.views.generic import DetailView, ListView
from django.views.generic.edit import UpdateView
from django.views.generic.edit import CreateView, UpdateView
from django.urls import reverse
from honeypot.decorators import check_honeypot
from aircox.conf import settings
from ..filters import PageFilters
from ..forms import CommentForm
from ..models import Comment, Category
@ -12,6 +13,8 @@ from .base import BaseView
from .mixins import AttachedToMixin, FiltersMixin, ParentMixin
__all__ = [
"attached_views",
"attach",
"BasePageListView",
"BasePageDetailView",
"PageDetailView",
@ -20,6 +23,16 @@ __all__ = [
]
attached_views = {}
"""Register views by StaticPage.Target."""
def attach(cls):
"""Add decorated view class to `attached_views`"""
attached_views[cls.attach_to_value] = cls
return cls
class BasePageMixin:
category = None
@ -171,7 +184,7 @@ class PageDetailView(BasePageDetailView):
return super().get_context_data(**kwargs)
def get_comment_form(self):
if self.object.allow_comments:
if settings.ALLOW_COMMENTS and self.object.allow_comments:
return CommentForm()
return None
@ -192,9 +205,15 @@ class PageDetailView(BasePageDetailView):
return self.get(request, *args, **kwargs)
class PageUpdateView(BaseView, UpdateView):
context_object_name = "page"
class PageCreateView(BaseView, CreateView):
def get_page(self):
return self.object
def get_success_url(self):
return self.request.path
class PageUpdateView(BaseView, UpdateView):
def get_page(self):
return self.object

View File

@ -1,19 +1,22 @@
import random
from django.contrib.auth.mixins import UserPassesTestMixin
from django.contrib.auth.mixins import PermissionRequiredMixin, UserPassesTestMixin
from django.urls import reverse
from aircox import models, forms, permissions
from . import page
from .mixins import VueFormDataMixin
from .page import PageDetailView, PageListView, PageUpdateView
__all__ = (
"ProgramDetailView",
"ProgramDetailView",
"ProgramCreateView",
"ProgramUpdateView",
)
class ProgramDetailView(PageDetailView):
class ProgramDetailView(page.PageDetailView):
model = models.Program
def get_related_queryset(self):
@ -44,7 +47,8 @@ class ProgramDetailView(PageDetailView):
return super().get_template_names() + ["aircox/program_detail.html"]
class ProgramListView(PageListView):
@page.attach
class ProgramListView(page.PageListView):
model = models.Program
attach_to_value = models.StaticPage.Target.PROGRAMS
@ -52,11 +56,18 @@ class ProgramListView(PageListView):
return super().get_queryset().order_by("title")
class ProgramUpdateView(UserPassesTestMixin, VueFormDataMixin, PageUpdateView):
class ProgramEditMixin(VueFormDataMixin):
model = models.Program
form_class = forms.ProgramForm
queryset = models.Program.objects.select_related("editors_group")
# FIXME: not used as long there is no complete administration mgt (schedule, etc.)
class ProgramCreateView(PermissionRequiredMixin, ProgramEditMixin, page.PageCreateView):
permission_required = "aircox.add_program"
class ProgramUpdateView(UserPassesTestMixin, ProgramEditMixin, page.PageUpdateView):
def test_func(self):
obj = self.get_object()
return permissions.program.can(self.request.user, "update", obj)

View File

@ -1,4 +1,4 @@
from django.contrib.auth.models import User
from django.contrib.auth.models import User, Group
from django_filters import rest_framework as drf_filters
from rest_framework import status, viewsets, parsers, permissions
from rest_framework.decorators import action
@ -145,6 +145,13 @@ class UserViewSet(AutocompleteMixin, viewsets.ModelViewSet):
queryset = User.objects.all().distinct().order_by("username")
class GroupViewSet(AutocompleteMixin, viewsets.ModelViewSet):
serializer_class = serializers.auth.GroupSerializer
permission_classes = (permissions.IsAdminUser,)
filterset_class = filters.GroupFilterSet
queryset = Group.objects.all().distinct().order_by("name")
class UserGroupViewSet(ListCommitMixin, viewsets.ModelViewSet):
serializer_class = serializers.auth.UserGroupSerializer
permission_classes = (permissions.IsAdminUser,)

View File

@ -283,7 +283,7 @@ export default {
mounted() {
const form = this.$el.closest('form')
form.addEventListener('reset', () => {
form && form.addEventListener('reset', () => {
this.inputValue = this.value;
this.select(-1)
})

View File

@ -1,10 +1,10 @@
<template>
<div class="a-group-users">
<div class="a-m2m-edit">
<table class="table is-fullwidth">
<thead>
<tr>
<th>
Members
<slot name="items-title"></slot>
</th>
<th style="width: 1rem">
<span class="icon">
@ -17,8 +17,9 @@
<template v-for="item of items" :key="item.id">
<tr :class="[item.created && 'has-text-info', item.deleted && 'has-text-danger']">
<td>
<b class="mr-3">{{ item.data.user.username }}</b>
<span>{{ item.data.user.first_name }} {{ item.data.user.last_name }}</span>
<slot name="item" :item="item">
{{ item.data }}
</slot>
</td>
<td class="align-center">
<input type="checkbox" class="checkbox" @change="item.deleted = $event.target.checked">
@ -30,18 +31,14 @@
<div>
<label>
<span class="icon">
<i class="fa fa-user"/>
<i class="fa fa-plus"/>
</span>
Add user
Add
</label>
<a-autocomplete ref="autocomplete" :url="searchUrl"
label-field="username" value-field="id"
@select="onUserSelect">
<a-autocomplete ref="autocomplete" v-bind="autocomplete"
@select="onSelect">
<template #item="{item}">
<b class="mr-3">{{ item.username }}</b>
<span class="text-light">{{ item.first_name }} {{ item.last_name }}</span>
&mdash;
<i>{{ item.email }}</i>
<slot name="autocomplete-item" :item="item">{{ item }}</slot>
</template>
</a-autocomplete>
</div>
@ -57,12 +54,14 @@ export default {
model: {type: Function, default: Model },
// List url
url: String,
// User autocomplete url
searchUrl: String,
// POST url
commitUrl: String,
// default values
initials: {type: Object, default: () => ({})},
// v-bind to autocomplete search box
autocomplete: {type: Object },
source_id: Number,
source_field: String,
target_field: String,
},
data() {
@ -73,24 +72,32 @@ export default {
computed: {
items() { return this.set?.items || [] },
user_ids() { return this.set?.items.map(i => i.data.user.id) },
initials() {
let obj = {}
obj[this.source_id_attr] = this.source_id
return obj
},
source_id_attr() { return this.source_field + "_id" },
target_id_attr() { return this.target_field + "_id" },
target_ids() { return this.set?.items.map(i => i.data[this.target_id_attr]) },
},
methods: {
onUserSelect(index, item, value) {
if(this.user_ids.indexOf(item.id) != -1)
onSelect(index, item, value) {
if(this.target_ids.indexOf(item.id) != -1)
return
this.set.push({
...this.initials,
user: {...item},
})
let obj = {...this.initials}
obj[this.target_field] = {...item}
obj[this.target_id_attr] = item.id
this.set.push(obj)
this.$refs.autocomplete.reset()
},
save() {
this.set.commit(this.commitUrl, {
getData: i => ({...this.initials, user_id: i.data.user.id})
fields: [...Object.keys(this.initials), this.target_id_attr]
})
},
},

View File

@ -7,14 +7,14 @@ import AFormSet from './AFormSet.vue'
import ATrackListEditor from './ATrackListEditor.vue'
import ASoundListEditor from './ASoundListEditor.vue'
import AGroupUsers from "./AGroupUsers.vue"
import AManyToManyEdit from "./AManyToManyEdit.vue"
import base from "./index.js"
export const admin = {
...base,
AGroupUsers,
AManyToManyEdit,
AFileUpload, ASelectFile,
AFormSet, ATrackListEditor, ASoundListEditor,
AStatistics, AStreamer,

View File

@ -232,9 +232,14 @@ export class Set {
/**
* Commit changes to server.
* ref: `views.mixin.ListCommitMixin`
* py-ref: `views.mixin.ListCommitMixin`
*/
commit(url, {getData=null, ...options}={}) {
commit(url, {getData=null, fields=null, ...options}={}) {
if(!getData && fields)
getData = (i) => fields.reduce((r, f) => {
r[f] = i.data[f]
return r
}, {})
const createdItems = this.createdItems
const body = {
delete: this.deletedItems.map(i => i.id),

View File

@ -91,3 +91,8 @@ h1, h2, h3, h4, h5, h6, .heading, .title, .subtitle {
display: flex;
flex-direction: column;
}
.modal .dropdown-menu {
z-index: 50,
}

View File

@ -88,6 +88,11 @@
.flex-column { display: flex; flex-direction: column }
.flex-grow-0 { flex-grow: 0 !important; }
.flex-grow-1 { flex-grow: 1 !important; }
.flex-grow-2 { flex-grow: 2 !important; }
.flex-grow-3 { flex-grow: 3 !important; }
.flex-grow-4 { flex-grow: 4 !important; }
.flex-grow-5 { flex-grow: 5 !important; }
.flex-grow-6 { flex-grow: 6 !important; }
.float-right { float: right }
.float-left { float: left }