M2M list editor; users list & group mgt; add missing files
This commit is contained in:
parent
ae176dc623
commit
8f1ec9cbc1
|
@ -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")
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
89
aircox/forms/widgets.py
Normal 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()}"
|
31
aircox/migrations/0022_set_group_ownership.py
Normal file
31
aircox/migrations/0022_set_group_ownership.py
Normal 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",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
|
@ -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:
|
||||||
|
|
28
aircox/serializers/auth.py
Normal file
28
aircox/serializers/auth.py
Normal 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
|
@ -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 }} :
|
|
||||||
|
|
||||||
<a href="{% url 'program-detail' slug=p.slug %}">
|
|
||||||
<span title="{% translate 'View' %} {{ page }}">{% translate 'View' %}</span>
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<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 %}
|
|
|
@ -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 %}
|
||||||
|
|
|
@ -9,7 +9,8 @@
|
||||||
|
|
||||||
{% block head-title %}
|
{% block head-title %}
|
||||||
{% block title %}
|
{% block title %}
|
||||||
{% if page and page.title %}{{ page.title }} —{% endif %}
|
{% if page and page.title %}{{ page.title }}{% endif %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
{% if page and page.title %}—{% endif %}
|
||||||
{{ station.name }}
|
{{ station.name }}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
@ -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 %}
|
||||||
|
|
53
aircox/templates/aircox/dashboard/user_list.html
Normal file
53
aircox/templates/aircox/dashboard/user_list.html
Normal 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 }}
|
||||||
|
—
|
||||||
|
{% 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 %}
|
|
@ -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}&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}&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>
|
||||||
|
—
|
||||||
|
<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()">
|
||||||
|
|
30
aircox/templates/aircox/dashboard/widgets/user_groups.html
Normal file
30
aircox/templates/aircox/dashboard/widgets/user_groups.html
Normal 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}&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>
|
|
@ -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>
|
||||||
|
|
|
@ -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 }}
|
||||||
|
|
|
@ -9,8 +9,9 @@ content.
|
||||||
|
|
||||||
{% block head-title %}
|
{% block head-title %}
|
||||||
{% block title %}
|
{% block title %}
|
||||||
{% if page and page.title %}{{ page.title }} —{% endif %}
|
{% if page and page.title %}{{ page.title }}{% endif %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
{% if page and page.title %}—{% endif %}
|
||||||
{{ station.name }}
|
{{ station.name }}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
|
39
aircox/templates/aircox/widgets/list_pagination.html
Normal file
39
aircox/templates/aircox/widgets/list_pagination.html
Normal 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 %}
|
|
@ -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 %}
|
||||||
|
|
|
@ -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 }}
|
||||||
|
|
|
@ -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 %}
|
|
|
@ -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")
|
||||||
|
|
|
@ -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"),
|
||||||
]
|
]
|
||||||
|
|
|
@ -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
|
|
||||||
|
|
|
@ -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
28
aircox/views/auth.py
Normal 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)
|
|
@ -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"
|
||||||
|
|
|
@ -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")
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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,)
|
||||||
|
|
|
@ -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)
|
||||||
})
|
})
|
||||||
|
|
|
@ -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>
|
|
||||||
—
|
|
||||||
<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]
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
},
|
},
|
|
@ -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,
|
||||||
|
|
|
@ -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),
|
||||||
|
|
|
@ -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,
|
||||||
|
}
|
||||||
|
|
|
@ -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 }
|
||||||
|
|
Loading…
Reference in New Issue
Block a user