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 = '"' IMPORT_PLAYLIST_CSV_TEXT_QUOTE = '"'
"""Text delimiter of csv text files.""" """Text delimiter of csv text files."""
ALLOW_COMMENTS = True
"""Allow comments."""
settings = Settings("AIRCOX") settings = Settings("AIRCOX")

View File

@ -13,6 +13,7 @@ __all__ = (
"SoundFilterSet", "SoundFilterSet",
"TrackFilterSet", "TrackFilterSet",
"UserFilterSet", "UserFilterSet",
"GroupFilterSet",
"UserGroupFilterSet", "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 UserGroupFilterSet(filters.FilterSet):
class Meta: class Meta:
model = User.groups.through model = User.groups.through

View File

@ -4,6 +4,7 @@ from .episode import EpisodeForm, EpisodeSoundFormSet
from .program import ProgramForm from .program import ProgramForm
from .page import CommentForm, ImageForm, PageForm, ChildPageForm from .page import CommentForm, ImageForm, PageForm, ChildPageForm
from .sound import SoundForm, SoundCreateForm from .sound import SoundForm, SoundCreateForm
from .track import TrackFormSet
__all__ = ( __all__ = (
@ -18,4 +19,5 @@ __all__ = (
ChildPageForm, ChildPageForm,
SoundForm, SoundForm,
SoundCreateForm, 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): 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): def get_absolute_url(self):
if self.attach_to: 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 %} {% endblock %}
</section> </section>
{% block list-pagination %} {% block list-pagination %}
{% if is_paginated %} {% include "./widgets/list_pagination.html" %}
<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 %}
{% endblock %} {% endblock %}
{% endblock %} {% endblock %}
</div> </div>
{% endblock %} {% endblock %}

View File

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

View File

@ -25,17 +25,6 @@
</div> </div>
</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> <div>
<h2 class="title is-2 mb-3">{% translate "Programs" %}</h2> <h2 class="title is-2 mb-3">{% translate "Programs" %}</h2>
<div class="box box-shadow p-3" style="max-height: 35rem; overflow-y:auto;"> <div class="box box-shadow p-3" style="max-height: 35rem; overflow-y:auto;">
@ -47,5 +36,16 @@
</div> </div>
</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> </section>
{% endblock %} {% 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"> <a-modal ref="group-users-modal">
<template #title="{item}">[[ item?.name ]]</template> <template #title="{item}">[[ item?.name ]]</template>
<template #default="{item}"> <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" :url="'{% url 'api:usergroup-list' %}?group=' + item.id"
commit-url="{% url 'api:usergroup-commit' %}" 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>
<template #footer="{item, close}"> <template #footer="{item, close}">
<button type="button" class="button" @click="$refs['group-users'].save(); 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" %} {% extends "./dashboard/base.html" %}
{% load static aircox_admin i18n %} {% load static aircox aircox_admin i18n %}
{% block init-scripts %} {% block init-scripts %}
aircox.labels = {% inline_labels %} aircox.labels = {% inline_labels %}
{{ block.super }} {{ block.super }}
{% endblock %} {% endblock %}
{% block header-cover %} {% block header-cover %}{% endblock %}
<div class="flex-column">
<img src="{{ cover }}" ref="cover" class="cover"> {% block title %}
<button type="button" class="button" @click="$refs['cover-select'].open()"> {% if not object %}
{% translate "Change cover" %} {% with view.model|verbose_name as model %}
</button> {% blocktranslate %}Create a {{model}}{% endblocktranslate %}
</div> {% endwith %}
{% else %}
{{ block.super }}
{% endif %}
{% endblock %} {% endblock %}
@ -52,34 +55,69 @@ aircox.labels = {% inline_labels %}
<form method="post" enctype="multipart/form-data"> <form method="post" enctype="multipart/form-data">
{% block page-form %} {% block page-form %}
{% csrf_token %} {% csrf_token %}
{% for field in form %} <div class="flex-row">
{% if field.name == "cover" %} <div class="flex-grow-3">
<input type="hidden" name="{{ field.name }}" value="{{ field.value }}" ref="cover-input"/> {% for field in form %}
{% else %} {% if field.name not in "cover,content" %}
<div class="field {% if field.name != "content" %}is-horizontal{% endif %}"> <div class="field {% if field.name != "content" %}is-horizontal{% endif %}">
<label class="label">{{ field.label }}</label> <label class="label">{{ field.label }}</label>
<div class="control clear-unset"> <div class="control clear-unset">
{% if field.name == "pub_date" %} {% if field.name == "pub_date" %}
<input type="datetime-local" class="input" name="{{ field.name }}" <input type="datetime-local" class="input" name="{{ field.name }}"
value="{{ field.value|date:"Y-m-d" }}T{{ field.value|date:"H:i" }}"/> value="{{ field.value|date:"Y-m-d" }}T{{ field.value|date:"H:i" }}"/>
{% elif field.name == "content" %} {% else %}
<textarea name="{{ field.name }}" class="is-fullwidth height-25">{{ field.value|striptags|safe }}</textarea> {% include "aircox/forms/form_field.html" with field=field.field name=field.name value=field.initial %}
{% else %} {% endif %}
{% include "aircox/forms/form_field.html" with field=field.field name=field.name value=field.initial %} </div>
<p class="help">{{ field.help_text }}</p>
</div>
{% endif %} {% endif %}
{% 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 %}
{% 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>
<p class="help">{{ field.help_text }}</p>
</div> </div>
{% endif %}
{% 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 %}
{% endfor %}
{% endblock %}
{% 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/> <hr/>
<div class="flex-row"> <div class="flex-row">
<div class="flex-grow-1">{% block page-form-actions %}{% endblock %}</div> <div class="flex-grow-1">{% block page-form-actions %}{% endblock %}</div>

View File

@ -7,10 +7,10 @@
{% endblock %} {% endblock %}
{% block page-form-actions %} {% block page-form-actions %}
{% if request.user.is_superuser %} {% if object and object.pk and request.user.is_superuser %}
<button type="button" <button type="button"
class="button secondary" 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" %} {% include "./dashboard/widgets/group_users.html" %}
{{ block.super }} {{ block.super }}

View File

@ -9,8 +9,9 @@ content.
{% block head-title %} {% block head-title %}
{% block title %} {% block title %}
{% if page and page.title %}{{ page.title }} &mdash;{% endif %} {% if page and page.title %}{{ page.title }}{% endif %}
{% endblock %} {% endblock %}
{% if page and page.title %}&mdash;{% endif %}
{{ station.name }} {{ station.name }}
{% endblock %} {% 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 is-hoverable is-right">
<div class="dropdown-trigger"> <div class="dropdown-trigger">
<button class="button square" aria-haspopup="true" aria-controls="dropdown-menu" type="button"> <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"> <a class="dropdown-item" href="{% url "dashboard" %}" data-force-reload="1">
{% translate "Dashboard" %} {% translate "Dashboard" %}
</a> </a>
{% if user|has_perm:"list_user" %}
<a class="dropdown-item" href="{% url "user-list" %}" data-force-reload="1">
{% translate "Users" %}
</a>
{% endif %}
{% endblock %} {% 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 %} {% if user.is_superuser %}
<hr class="dropdown-divider" /> <hr class="dropdown-divider" />
{% block admin-menu %} {% block admin-menu %}

View File

@ -1,6 +1,6 @@
{% load aircox i18n %} {% load aircox i18n %}
{% block user-actions-container %} {% 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 %} {% if user.is_authenticated %}
{{ object.get_status_display }} {{ 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,17 +75,19 @@ def do_get_tracks(obj):
return obj.track_set.all() return obj.track_set.all()
@register.simple_tag(name="has_perm", takes_context=True) @register.filter(name="has_perm")
def do_has_perm(context, obj, perm, user=None, simple=False): 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]')``""" """Return True if ``user.has_perm('[APP].[perm]_[MODEL]')``"""
user = user or context["request"].user
if not obj: if not obj:
return return False
if user is None: return user.has_perm("{}.{}_{}".format(obj._meta.app_label, perm, obj._meta.model_name))
user = context["request"].user
if simple:
return user.has_perm("aircox.{}".format(perm)) or user.is_superuser
else:
return user.has_perm("{}.{}_{}".format(obj._meta.app_label, perm, obj._meta.model_name))
@register.filter(name="is_diffusion") @register.filter(name="is_diffusion")

View File

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

View File

@ -1,10 +1,7 @@
from . import admin, dashboard, errors from . import admin, dashboard, errors, auth
from .article import ArticleDetailView, ArticleListView from . import article, program, episode, diffusion, log
from . import home
from .base import BaseAPIView, BaseView 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 ( from .page import (
BasePageDetailView, BasePageDetailView,
BasePageListView, BasePageListView,
@ -12,47 +9,24 @@ from .page import (
PageListView, PageListView,
PageUpdateView, PageUpdateView,
) )
from .program import (
ProgramDetailView,
ProgramListView,
ProgramUpdateView,
)
__all__ = ( __all__ = (
"admin", "admin",
"dashboard",
"errors", "errors",
"attached", "dashboard",
"auth",
"article",
"program",
"episode",
"diffusion",
"log",
"home",
"BaseAPIView", "BaseAPIView",
"BaseView", "BaseView",
"ArticleDetailView",
"ArticleListView",
"DiffusionListView",
"TimeTableView",
"EpisodeDetailView",
"EpisodeListView",
"PodcastListView",
"EpisodeUpdateView",
"HomeView",
"LogListAPIView",
"LogListView",
"BasePageDetailView", "BasePageDetailView",
"BasePageListView", "BasePageListView",
"PageDetailView", "PageDetailView",
"PageUpdateView", "PageUpdateView",
"PageListView", "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 ..models import Article, Program, StaticPage
from .page import PageDetailView, PageListView from . import page
__all__ = ["ArticleDetailView", "ArticleListView"] __all__ = ["ArticleDetailView", "ArticleListView"]
class ArticleDetailView(PageDetailView): class ArticleDetailView(page.PageDetailView):
model = Article model = Article
class ArticleListView(PageListView): @page.attach
class ArticleListView(page.PageListView):
model = Article model = Article
parent_model = Program parent_model = Program
attach_to_value = StaticPage.Target.ARTICLES 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 aircox.models import Diffusion, Log, StaticPage
from .base import BaseView from .base import BaseView
from .mixins import AttachedToMixin, GetDateMixin from .mixins import AttachedToMixin, GetDateMixin
from .page import attach
__all__ = ("DiffusionListView", "TimeTableView") __all__ = ("DiffusionListView", "TimeTableView")
@ -21,6 +22,7 @@ class DiffusionListView(BaseDiffusionListView):
model = Diffusion model = Diffusion
@attach
class TimeTableView(GetDateMixin, BaseDiffusionListView): class TimeTableView(GetDateMixin, BaseDiffusionListView):
model = Diffusion model = Diffusion
redirect_date_url = "timetable-list" 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 aircox import forms, filters, permissions
from .mixins import VueFormDataMixin from .mixins import VueFormDataMixin
from .page import PageDetailView, PageListView, PageUpdateView from .page import attach, PageDetailView, PageListView, PageUpdateView
__all__ = ( __all__ = (
@ -33,6 +33,7 @@ class EpisodeDetailView(PageDetailView):
return reverse("episode-list", kwargs={"parent_slug": self.object.parent.slug}) return reverse("episode-list", kwargs={"parent_slug": self.object.parent.slug})
@attach
class EpisodeListView(PageListView): class EpisodeListView(PageListView):
model = Episode model = Episode
filterset_class = filters.EpisodeFilters filterset_class = filters.EpisodeFilters
@ -40,6 +41,7 @@ class EpisodeListView(PageListView):
attach_to_value = StaticPage.Target.EPISODES attach_to_value = StaticPage.Target.EPISODES
@attach
class PodcastListView(EpisodeListView): class PodcastListView(EpisodeListView):
attach_to_value = StaticPage.Target.PODCASTS attach_to_value = StaticPage.Target.PODCASTS
queryset = Episode.objects.published().with_podcasts().order_by("-pub_date") 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 ..models import Diffusion, Episode, Log, Page, StaticPage
from .base import BaseView from .base import BaseView
from .mixins import AttachedToMixin from .mixins import AttachedToMixin
from .page import attach
@attach
class HomeView(AttachedToMixin, BaseView, ListView): class HomeView(AttachedToMixin, BaseView, ListView):
template_name = "aircox/home.html" template_name = "aircox/home.html"
attach_to_value = StaticPage.Target.HOME attach_to_value = StaticPage.Target.HOME

View File

@ -1,10 +1,11 @@
from django.http import HttpResponse from django.http import HttpResponse
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django.views.generic import DetailView, ListView 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 django.urls import reverse
from honeypot.decorators import check_honeypot from honeypot.decorators import check_honeypot
from aircox.conf import settings
from ..filters import PageFilters from ..filters import PageFilters
from ..forms import CommentForm from ..forms import CommentForm
from ..models import Comment, Category from ..models import Comment, Category
@ -12,6 +13,8 @@ from .base import BaseView
from .mixins import AttachedToMixin, FiltersMixin, ParentMixin from .mixins import AttachedToMixin, FiltersMixin, ParentMixin
__all__ = [ __all__ = [
"attached_views",
"attach",
"BasePageListView", "BasePageListView",
"BasePageDetailView", "BasePageDetailView",
"PageDetailView", "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: class BasePageMixin:
category = None category = None
@ -171,7 +184,7 @@ class PageDetailView(BasePageDetailView):
return super().get_context_data(**kwargs) return super().get_context_data(**kwargs)
def get_comment_form(self): def get_comment_form(self):
if self.object.allow_comments: if settings.ALLOW_COMMENTS and self.object.allow_comments:
return CommentForm() return CommentForm()
return None return None
@ -192,9 +205,15 @@ class PageDetailView(BasePageDetailView):
return self.get(request, *args, **kwargs) return self.get(request, *args, **kwargs)
class PageUpdateView(BaseView, UpdateView): class PageCreateView(BaseView, CreateView):
context_object_name = "page" def get_page(self):
return self.object
def get_success_url(self):
return self.request.path
class PageUpdateView(BaseView, UpdateView):
def get_page(self): def get_page(self):
return self.object return self.object

View File

@ -1,19 +1,22 @@
import random import random
from django.contrib.auth.mixins import UserPassesTestMixin from django.contrib.auth.mixins import PermissionRequiredMixin, UserPassesTestMixin
from django.urls import reverse from django.urls import reverse
from aircox import models, forms, permissions from aircox import models, forms, permissions
from . import page
from .mixins import VueFormDataMixin from .mixins import VueFormDataMixin
from .page import PageDetailView, PageListView, PageUpdateView
__all__ = ( __all__ = (
"ProgramDetailView", "ProgramDetailView",
"ProgramDetailView", "ProgramDetailView",
"ProgramCreateView",
"ProgramUpdateView",
) )
class ProgramDetailView(PageDetailView): class ProgramDetailView(page.PageDetailView):
model = models.Program model = models.Program
def get_related_queryset(self): def get_related_queryset(self):
@ -44,7 +47,8 @@ class ProgramDetailView(PageDetailView):
return super().get_template_names() + ["aircox/program_detail.html"] return super().get_template_names() + ["aircox/program_detail.html"]
class ProgramListView(PageListView): @page.attach
class ProgramListView(page.PageListView):
model = models.Program model = models.Program
attach_to_value = models.StaticPage.Target.PROGRAMS attach_to_value = models.StaticPage.Target.PROGRAMS
@ -52,11 +56,18 @@ class ProgramListView(PageListView):
return super().get_queryset().order_by("title") return super().get_queryset().order_by("title")
class ProgramUpdateView(UserPassesTestMixin, VueFormDataMixin, PageUpdateView): class ProgramEditMixin(VueFormDataMixin):
model = models.Program model = models.Program
form_class = forms.ProgramForm form_class = forms.ProgramForm
queryset = models.Program.objects.select_related("editors_group") 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): def test_func(self):
obj = self.get_object() obj = self.get_object()
return permissions.program.can(self.request.user, "update", obj) 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 django_filters import rest_framework as drf_filters
from rest_framework import status, viewsets, parsers, permissions from rest_framework import status, viewsets, parsers, permissions
from rest_framework.decorators import action from rest_framework.decorators import action
@ -145,6 +145,13 @@ class UserViewSet(AutocompleteMixin, viewsets.ModelViewSet):
queryset = User.objects.all().distinct().order_by("username") 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): class UserGroupViewSet(ListCommitMixin, viewsets.ModelViewSet):
serializer_class = serializers.auth.UserGroupSerializer serializer_class = serializers.auth.UserGroupSerializer
permission_classes = (permissions.IsAdminUser,) permission_classes = (permissions.IsAdminUser,)

View File

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

View File

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

View File

@ -232,9 +232,14 @@ export class Set {
/** /**
* Commit changes to server. * 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 createdItems = this.createdItems
const body = { const body = {
delete: this.deletedItems.map(i => i.id), delete: this.deletedItems.map(i => i.id),

View File

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

View File

@ -88,6 +88,11 @@
.flex-column { display: flex; flex-direction: column } .flex-column { display: flex; flex-direction: column }
.flex-grow-0 { flex-grow: 0 !important; } .flex-grow-0 { flex-grow: 0 !important; }
.flex-grow-1 { flex-grow: 1 !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-right { float: right }
.float-left { float: left } .float-left { float: left }