manage program editors

This commit is contained in:
bkfox 2024-04-22 23:54:44 +02:00
parent b28105c659
commit a2a399e531
31 changed files with 448 additions and 153 deletions

View File

@ -1,5 +1,7 @@
import django_filters as filters from django.contrib.auth.models import User
from django.db.models import Q
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
import django_filters as filters
from . import models from . import models
@ -10,6 +12,8 @@ __all__ = (
"ImageFilterSet", "ImageFilterSet",
"SoundFilterSet", "SoundFilterSet",
"TrackFilterSet", "TrackFilterSet",
"UserFilterSet",
"UserGroupFilterSet",
) )
@ -67,3 +71,26 @@ class TrackFilterSet(filters.FilterSet):
artist = filters.CharFilter(field_name="artist", lookup_expr="icontains") artist = filters.CharFilter(field_name="artist", lookup_expr="icontains")
album = filters.CharFilter(field_name="album", lookup_expr="icontains") album = filters.CharFilter(field_name="album", lookup_expr="icontains")
title = filters.CharFilter(field_name="title", lookup_expr="icontains") title = filters.CharFilter(field_name="title", lookup_expr="icontains")
class UserFilterSet(filters.FilterSet):
search = filters.CharFilter(field_name="search", method="search_filter")
in_group = filters.NumberFilter(field_name="in_group", method="in_group_filter")
not_in_group = filters.NumberFilter(field_name="not_in_group", method="not_in_group_filter")
def in_group_filter(self, queryset, name, value):
return queryset.filter(groups__in=[value])
def not_in_group_filter(self, queryset, name, value):
return queryset.exclude(groups__in=[value])
def search_filter(self, queryset, name, value):
return queryset.filter(
Q(username__icontains=value) | Q(first_name__icontains=value) | Q(last_name__icontains=value)
)
class UserGroupFilterSet(filters.FilterSet):
class Meta:
model = User.groups.through
fields = ["group", "user"]

View File

@ -1,6 +1,5 @@
from . import widgets from . import widgets
from .auth import UserGroupFormSet
from .episode import EpisodeForm, EpisodeSoundFormSet 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
@ -19,5 +18,4 @@ __all__ = (
ChildPageForm, ChildPageForm,
SoundForm, SoundForm,
SoundCreateForm, SoundCreateForm,
UserGroupFormSet,
) )

View File

@ -1,20 +0,0 @@
from django import forms
from django.forms.models import modelformset_factory
from django.contrib.auth.models import User
from aircox.forms import widgets
__all__ = ("UserGroupFormSet",)
UserGroupFormSet = modelformset_factory(
User.groups.through,
fields=("group", "user"),
widgets={
"group": forms.HiddenInput(),
"user": widgets.VueAutoComplete("api:usergroup-autocomplete", lookup="username"),
},
extra=0,
can_delete=True,
)

View File

@ -30,17 +30,10 @@ Usefull context:
<script type="module" src="{{vue_url}}"></script> <script type="module" src="{{vue_url}}"></script>
<link rel="stylesheet" type="text/css" href="{% static "fontawesome-free/css/all.min.css" %}"/> <link rel="stylesheet" type="text/css" href="{% static "fontawesome-free/css/all.min.css" %}"/>
<link rel="stylesheet" type="text/css" href="{% static "aircox/style.css" %}"/> <link rel="stylesheet" type="text/css" href="{% static "aircox/index.css" %}"/>
<script type="module" src="{% static "aircox/public.js" %}"></script> <link rel="stylesheet" type="text/css" href="{% static "aircox/public.css" %}"/>
{% comment %} <script type="module" src="{% if app_js_url %}{{ app_js_url }}{% else %}{% static "aircox/public.js" %}{% endif %}"></script>
<link rel="stylesheet" type="text/css" href="{% static "aircox/css/chunk-common.css" %}"/>
<link rel="stylesheet" type="text/css" href="{% static "aircox/css/chunk-vendors.css" %}"/>
<link rel="stylesheet" type="text/css" href="{% static "aircox/css/public.css" %}"/>
<script src="{% static "aircox/js/chunk-common.js" %}"></script>
<script src="{% static "aircox/js/chunk-vendors.js" %}"></script>
<script src="{% static "aircox/js/public.js" %}"></script>
{% endcomment %}
{% endblock %} {% endblock %}
<title> <title>

View File

@ -2,9 +2,9 @@
{% load static i18n %} {% load static i18n %}
{% block assets %} {% block assets %}
{% static "aircox/admin.js" as app_js_url %}
{{ block.super }} {{ block.super }}
<script src="{% static "aircox/js/dashboard.js" %}"></script> <link rel="stylesheet" type="text/css" href="{% static "aircox/admin.css" %}"/>
<link rel="stylesheet" type="text/css" href="{% static "aircox/css/admin.css" %}"/>
{% endblock %} {% endblock %}
{% block head-title %} {% block head-title %}

View File

@ -0,0 +1,18 @@
{% load i18n %}
<a-modal ref="group-users-modal">
<template #title="{item}">[[ item?.name ]]</template>
<template #default="{item}">
<a-group-users v-if="item" ref="group-users"
:url="'{% url 'api:usergroup-list' %}?group=' + item.id"
commit-url="{% url 'api:usergroup-commit' %}"
:search-url="'{% url 'api:user-autocomplete' %}?search=${query}&group=' + item.id"
:initials="{group_id: item.id }"
/>
</template>
<template #footer="{item, close}">
<button type="button" class="button" @click="$refs['group-users'].save(); close()">
Save
</button>
</template>
</a-modal>

View File

@ -18,11 +18,10 @@ Context:
{{ formset.non_form_errors }} {{ formset.non_form_errors }}
<!-- formset.management_form --> <!-- formset.management_form -->
<{{ tag|default:"a-form-set" }} <{{ tag|default:"a-form-set" }} ref="formset"
{% block tag-attrs %} {% block tag-attrs %}
:form-data="{{ formset_data|json }}" :form-data="{{ formset_data|json }}"
:labels="window.aircox.labels" :labels="window.aircox.labels"
:init-data="{% formset_inline_data formset=formset %}"
:columns="[{% for n, f in fields.items %}{% if not f.widget.is_hidden %}'{{ n }}',{% endif %}{% endfor %} ]" :columns="[{% for n, f in fields.items %}{% if not f.widget.is_hidden %}'{{ n }}',{% endif %}{% endfor %} ]"
settings-url="{% url "api:user-settings" %}" settings-url="{% url "api:user-settings" %}"
data-prefix="{{ formset.prefix }}-" data-prefix="{{ formset.prefix }}-"
@ -40,7 +39,7 @@ Context:
</template> </template>
{% for name, field in fields.items %} {% for name, field in fields.items %}
{% if not field.widget.is_hidden and not field.is_readonly %} {% if not field.widget.is_hidden and not field.is_readonly %}
<template v-slot:control-{{ name }}="{item,cell,value,attr,emit,inputName}"> <template v-slot:control-{{ name }}="{context,item,cell,value,attr,emit,inputName}">
{% block row-control %} {% block row-control %}
{% include "./v_form_field.html" with value="item.data."|add:name name="inputName" %} {% include "./v_form_field.html" with value="item.data."|add:name name="inputName" %}
{% endblock %} {% endblock %}

View File

@ -1,11 +1,6 @@
{% extends "./dashboard/base.html" %} {% extends "./dashboard/base.html" %}
{% load static aircox_admin i18n %} {% load static aircox_admin i18n %}
{% block assets %}
{{ block.super }}
<script src="{% static "aircox/js/dashboard.js" %}"></script>
{% endblock %}
{% block init-scripts %} {% block init-scripts %}
aircox.labels = {% inline_labels %} aircox.labels = {% inline_labels %}
{{ block.super }} {{ block.super }}
@ -86,9 +81,11 @@ aircox.labels = {% inline_labels %}
{% endblock %} {% endblock %}
<hr/> <hr/>
<div class="has-text-right"> <div class="flex-row">
<button type="submit" class="button">{% translate "Update" %}</button> <div class="flex-grow-1">{% block page-form-actions %}{% endblock %}</div>
</div> <div class="has-text-right">
<button type="submit" class="button">{% translate "Update" %}</button>
</div>
</form> </form>
</section> </section>
{% endblock %} {% endblock %}

View File

@ -6,13 +6,13 @@
{{ form.media }} {{ form.media }}
{% endblock %} {% endblock %}
{% block page-form %} {% block page-form-actions %}
////// {% if request.user.is_superuser %}
{{ block.super }} <button type="button"
class="button secondary"
@click="$refs['group-users-modal'].open({id: {{ object.editors_group_id }}, name: '{{ object.editors_group.name }}' })">Editors</button>
{% if editors_formset %} {% include "./dashboard/widgets/group_users.html" %}
<hr/> {{ block.super }}
<h2 class="title is-2">{% translate "Editors" %}</h2>
{% include "./widgets/usergroup_formset.html" with formset=editors_formset formset_data=editors_formset_data tag_id="usergroup_formset" %}
{% endif %} {% endif %}
{% endblock %} {% endblock %}

View File

@ -1,4 +1,4 @@
<a-autocomplete <a-autocomplete
url="{{url}}" url="{{url}}"
name="{{ widget.name }}"{% if widget.value != None %} model-value="{{ widget.value|stringformat:'s' }}"{% endif %} {% if ":name" not in widget.attrs %}name="{{ name|default:widget.name }}"{% endif %}{% if widget.value != None %} model-value="{{ widget.value|stringformat:'s' }}"{% endif %}
{% include "django/forms/widgets/attrs.html" %} /> {% include "django/forms/widgets/attrs.html" %} {{ extra|default:"" }}/>

View File

@ -3,7 +3,7 @@
{% block row-control %} {% block row-control %}
{% if name == 'user' %} {% if name == 'user' %}
{% form_field field name value %} {% form_field field "inputName" value %}
{% else %} {% else %}
{{ block.super }} {{ block.super }}
{% endif %} {% endif %}

View File

@ -45,7 +45,7 @@ def do_formset_inline_data(context, formset):
# hack for sound list # hack for sound list
if duration := item.get("duration"): if duration := item.get("duration"):
item["duration"] = duration.strftime("%H:%M") item["duration"] = duration.strftime("%H:%M")
if sound := getattr(form.instance, "sound"): if sound := getattr(form.instance, "sound", None):
item["name"] = sound.name item["name"] = sound.name
fields["name"] = str(_("Sound")).capitalize() fields["name"] = str(_("Sound")).capitalize()
@ -55,7 +55,7 @@ def do_formset_inline_data(context, formset):
item["tags"] = ", ".join(tag.name for tag in tags) item["tags"] = ", ".join(tag.name for tag in tags)
items.append(item) items.append(item)
data = {"items": items, "fields": fields} data = {"items": items, "fields": fields, "initial": formset.initial and formset.initial[0]}
user = context["request"].user user = context["request"].user
settings = getattr(user, "aircox_settings", None) settings = getattr(user, "aircox_settings", None)
data["settings"] = settings and UserSettingsSerializer(settings).data data["settings"] = settings and UserSettingsSerializer(settings).data

View File

@ -21,11 +21,13 @@ register_converter(WeekConverter, "week")
router = DefaultRouter() router = DefaultRouter()
router.register("user", viewsets.UserViewSet, basename="user")
router.register("usergroup", viewsets.UserGroupViewSet, basename="usergroup")
router.register("images", viewsets.ImageViewSet, basename="image") router.register("images", viewsets.ImageViewSet, basename="image")
router.register("sound", viewsets.SoundViewSet, basename="sound") router.register("sound", viewsets.SoundViewSet, basename="sound")
router.register("track", viewsets.TrackROViewSet, basename="track") router.register("track", viewsets.TrackROViewSet, basename="track")
router.register("comment", viewsets.CommentViewSet, basename="comment") router.register("comment", viewsets.CommentViewSet, basename="comment")
router.register("usergroup", viewsets.UserGroupViewSet, basename="usergroup")
api = [ api = [

View File

@ -115,6 +115,9 @@ class VueFormDataMixin:
# Note: values corresponds to AFormSet expected one # Note: values corresponds to AFormSet expected one
def get_form_items(self, formset):
return [form.initial for form in formset.forms]
def get_form_field_data(self, form, values=None): def get_form_field_data(self, form, values=None):
"""Return form fields as data.""" """Return form fields as data."""
model = form.Meta.model model = form.Meta.model
@ -140,5 +143,7 @@ class VueFormDataMixin:
"max_num_forms": formset.max_num, "max_num_forms": formset.max_num,
}, },
"fields": self.get_form_field_data(formset.form, field_values), "fields": self.get_form_field_data(formset.form, field_values),
"initial_extra": formset.initial_extra and formset.initial_extra[0],
"initials": self.get_form_items(formset),
**kwargs, **kwargs,
} }

View File

@ -1,6 +1,5 @@
import random import random
from django.contrib.auth.models import User
from django.contrib.auth.mixins import UserPassesTestMixin from django.contrib.auth.mixins import UserPassesTestMixin
from django.urls import reverse from django.urls import reverse
@ -61,31 +60,3 @@ class ProgramUpdateView(UserPassesTestMixin, VueFormDataMixin, 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)
def get_editors_queryset(self, program):
# TODO: provide username in formset initials
return User.groups.through.objects.filter(group_id=program.editors_group_id).order_by("user__username")
def get_editors_formset(self, program, **kwargs):
return forms.UserGroupFormSet(
**{
**kwargs,
"prefix": "editors",
"queryset": self.get_editors_queryset(program),
"initial": {
"group": program.editors_group_id,
},
}
)
def get_context_data(self, editors_formset=None, **kwargs):
# TODO: use group and permission system
if self.request.user.is_superuser:
if editors_formset is None:
editors_formset = self.get_editors_formset(self.object)
kwargs["editors_formset_data"] = self.get_formset_data(
editors_formset, {"group": self.object.editors_group_id}
)
context = super().get_context_data(editors_formset=editors_formset, **kwargs)
return context

View File

@ -40,6 +40,49 @@ class AutocompleteMixin:
return self.list(request) return self.list(request)
class ListCommitMixin:
@action(name="commit", detail=False, methods=["POST"])
def commit(self, request):
"""
Data:
{
"delete": [pk],
"update": [{pk, **object}],
"create": [object_data]
}
Return:
{
"deleted": [pk],
"updated": [object],
"created": [object],
}
"""
queryset = self.get_queryset()
resp = {"deleted": [], "updated": [], "created": []}
if ids := request.data.get("delete"):
q = queryset.filter(id__in=ids)
resp["deleted"] = list(q.values_list("id", flat=True))
q.delete()
# TODO: bulk save and update
if items := request.data.get("update"):
resp["updated"] = self._commit_save_many(items)
if items := request.data.get("create"):
resp["created"] = self._commit_save_many(items)
return Response(data=resp)
def _commit_save_many(self, data):
ser = self.get_serializer(data=data, many=True)
ser.is_valid(raise_exception=True)
items = ser.save()
ser = self.get_serializer(items, many=True)
return ser.data
class ImageViewSet(viewsets.ModelViewSet): class ImageViewSet(viewsets.ModelViewSet):
parsers = (parsers.MultiPartParser,) parsers = (parsers.MultiPartParser,)
permissions = (permissions.IsAuthenticatedOrReadOnly,) permissions = (permissions.IsAuthenticatedOrReadOnly,)
@ -84,7 +127,6 @@ class TrackROViewSet(AutocompleteMixin, viewsets.ReadOnlyModelViewSet):
serializer_class = serializers.admin.TrackSerializer serializer_class = serializers.admin.TrackSerializer
permission_classes = (permissions.IsAuthenticated,) permission_classes = (permissions.IsAuthenticated,)
filter_backends = (drf_filters.DjangoFilterBackend,)
filterset_class = filters.TrackFilterSet filterset_class = filters.TrackFilterSet
queryset = models.Track.objects.all() queryset = models.Track.objects.all()
@ -96,10 +138,19 @@ class CommentViewSet(viewsets.ModelViewSet):
# --- admin # --- admin
class UserGroupViewSet(AutocompleteMixin, viewsets.ModelViewSet): class UserViewSet(AutocompleteMixin, viewsets.ModelViewSet):
serializer_class = serializers.auth.UserSerializer
permission_classes = (permissions.IsAdminUser,)
filterset_class = filters.UserFilterSet
queryset = User.objects.all().distinct().order_by("username")
class UserGroupViewSet(ListCommitMixin, viewsets.ModelViewSet):
serializer_class = serializers.auth.UserGroupSerializer serializer_class = serializers.auth.UserGroupSerializer
permission_classes = (permissions.IsAdminUser,) permission_classes = (permissions.IsAdminUser,)
queryset = User.groups.through.objects.all().distinct().order_by("user__username") filterset_class = filters.UserGroupFilterSet
model = User.groups.through
queryset = model.objects.all().distinct().order_by("user__username")
class UserSettingsViewSet(viewsets.ViewSet): class UserSettingsViewSet(viewsets.ViewSet):

View File

@ -2,7 +2,7 @@ import './styles/admin.scss'
import './index.js' import './index.js'
import App from './app'; import App from './app';
import {admin as components} from './components' import components from './components/admin.js'
const AdminApp = { const AdminApp = {
...App, ...App,

View File

@ -17,10 +17,22 @@ const App = {
}, },
methods: { methods: {
//! Delete elements from DOM using provided selector.
deleteElements(sel) { deleteElements(sel) {
for(var el of document.querySelectorAll(sel)) for(var el of document.querySelectorAll(sel))
el.parentNode.removeChild(el) el.parentNode.removeChild(el)
} },
//! File has been selected
//! TODO: replace using regular ref and bindings.
fileSelected(select, input, preview) {
const item = this.$refs[select].item
if(item) {
this.$refs[input].value = item.id
if(preview)
preview.src = item.file
}
},
} }
} }

View File

@ -20,24 +20,23 @@
<span class="is-inline-block" v-if="selected"> <span class="is-inline-block" v-if="selected">
<slot name="button" :index="selectedIndex" :item="selected" <slot name="button" :index="selectedIndex" :item="selected"
:value-field="valueField" :labelField="labelField"> :value-field="valueField" :labelField="labelField">
{{ labelField && selected.data[labelField] || selected }} {{ selectedLabel }}
</slot> </slot>
</span> </span>
</a> </a>
<div :class="dropdownClass"> <div :class="dropdownClass">
<div class="dropdown-menu is-fullwidth"> <div class="dropdown-menu is-fullwidth">
<div class="dropdown-content" style="overflow: hidden"> <div class="dropdown-content" style="overflow: hidden">
<a v-for="(item, index) in items" :key="item.id" <span v-for="(item, index) in items" :key="item.id"
href="#" :data-autocomplete-index="index" :data-autocomplete-index="index"
@click="select(index, false, false)" @click="select(index, false, false)"
:class="['dropdown-item', (index == this.cursor) ? 'is-active':'']" :class="['dropdown-item', (index == this.cursor) ? 'is-active':'']"
:title="labelField && item.data[labelField] || item"
tabindex="-1"> tabindex="-1">
<slot name="item" :index="index" :item="item" :value-field="valueField" <slot name="item" :index="index" :item="item" :value-field="valueField"
:labelField="labelField"> :labelField="labelField">
{{ labelField && item.data[labelField] || item }} {{ getValue(item, labelField) || item }}
</slot> </slot>
</a> </span>
</div> </div>
</div> </div>
</div> </div>
@ -56,12 +55,14 @@ export default {
props: { props: {
//! Search URL (where `${query}` is replaced by search term) //! Search URL (where `${query}` is replaced by search term)
url: String, url: String,
//! Extra GET url parameters
urlParams: Object,
//! Items' model //! Items' model
model: Function, model: Function,
//! Input tag class //! Input tag class
inputClass: Array, inputClass: Array,
//! input text placeholder //! input text placeholder
placeholder: String, placeholder: Object,
//! input form field name //! input form field name
name: String, name: String,
//! Field on items to use as label //! Field on items to use as label
@ -105,6 +106,20 @@ export default {
}, },
computed: { computed: {
fullUrl() {
if(!this.urlParams)
return this.url
const url = new URL(this.url, window.location.origin)
const params = new URLSearchParams(url.searchParams)
for(var key in this.urlParams)
params.set(key, this.urlParams[key])
const join = this.url.indexOf("?") >= 0 ? "&" : "?"
url.search = params.toString()
return url.href
},
isFetching() { return !!this.promise }, isFetching() { return !!this.promise },
selected() { selected() {
@ -136,12 +151,34 @@ export default {
}, },
methods: { methods: {
reset() {
this.inputValue = ""
this.selectedIndex = -1
this.items = []
},
// TODO: move to utils/data
getValue(data, path=null) {
if(!data)
return null
if(!path)
return data
const paths = path.split('.')
for(const key of paths) {
if(key in data)
data = data[key]
else return null;
}
return data
},
itemValue(item) { itemValue(item) {
return this.valueField ? item && item[this.valueField] : item; return this.valueField ? this.getValue(item, this.valueField) : item;
}, },
itemLabel(item) { itemLabel(item) {
return this.labelField ? item && item[this.labelField] : item; return this.labelField ? this.getValue(item, this.labelField) : item;
}, },
hide() { hide() {
@ -183,7 +220,7 @@ export default {
if(!this.items.length) if(!this.items.length)
return return
var index = event.relatedTarget && Math.parseInt(event.relatedTarget.dataset.autocompleteIndex); var index = event.relatedTarget && Math.floor(event.relatedTarget.dataset.autocompleteIndex);
if(index !== undefined && index !== null) if(index !== undefined && index !== null)
this.select(index, false, false) this.select(index, false, false)
this.cursor = -1; this.cursor = -1;
@ -227,7 +264,7 @@ export default {
return return
this.query = query this.query = query
var url = this.url.replace('${query}', query) var url = this.fullUrl.replace('${query}', query).replace('%24%7Bquery%7D', query)
var promise = this.model ? this.model.fetch(url, {many:true}) var promise = this.model ? this.model.fetch(url, {many:true})
: fetch(url, Model.getOptions()).then(d => d.json()) : fetch(url, Model.getOptions()).then(d => d.json())

View File

@ -6,7 +6,7 @@
:value="value"/> :value="value"/>
</template> </template>
<a-rows ref="rows" :set="set" <a-rows ref="rows" :set="set" :context="this"
:columns="visibleFields" :columnsOrderable="columnsOrderable" :columns="visibleFields" :columnsOrderable="columnsOrderable"
:orderable="orderable" @move="moveItem" @colmove="onColumnMove" :orderable="orderable" @move="moveItem" @colmove="onColumnMove"
@cell="e => $emit('cell', e)"> @cell="e => $emit('cell', e)">
@ -45,7 +45,7 @@
<template v-for="(field,slot) of fieldSlots" v-bind:key="field.name" <template v-for="(field,slot) of fieldSlots" v-bind:key="field.name"
v-slot:[slot]="data"> v-slot:[slot]="data">
<slot :name="slot" v-bind="data" :field="field" :input-name="_prefix + data.cell.row + '-' + field.name"> <slot :name="slot" v-bind="data" :field="field" :input-name="_prefix + data.cell.row + '-' + field.name">
<div class="field"> <div class="field">
<div class="control"> <div class="control">
<slot :name="'control-' + field.name" v-bind="data" :field="field" :input-name="_prefix + data.cell.row + '-' + field.name"/> <slot :name="'control-' + field.name" v-bind="data" :field="field" :input-name="_prefix + data.cell.row + '-' + field.name"/>
@ -118,8 +118,6 @@
formData: Object, formData: Object,
//! Model class used for item's set //! Model class used for item's set
model: {type: Function, default: Model}, model: {type: Function, default: Model},
//! initial data set load at mount
initials: Array,
}, },
data() { data() {
@ -184,7 +182,7 @@
//! Reset forms to initials //! Reset forms to initials
reset() { reset() {
this.load(this.initials || [], true) this.load(this.formData?.initials || [], true)
}, },
}, },

View File

@ -0,0 +1,102 @@
<template>
<div class="a-group-users">
<table class="table is-fullwidth">
<thead>
<tr>
<th>
Members
</th>
<th style="width: 1rem">
<span class="icon">
<i class="fa fa-trash"/>
</span>
</th>
</tr>
</thead>
<tbody>
<template v-for="item of items" :key="item.id">
<tr>
<td>
<b class="mr-3">{{ item.data.user.username }}</b>
<span class="text-light">{{ item.data.user.first_name }} {{ item.data.user.last_name }}</span>
</td>
<td class="align-center">
<input type="checkbox" class="checkbox" @change="item.deleted = $event.target.checked">
</td>
</tr>
</template>
</tbody>
</table>
<div>
<label>
<span class="icon">
<i class="fa fa-user"/>
</span>
Add user
</label>
<a-autocomplete ref="autocomplete" :url="searchUrl"
label-field="username" value-field="id"
@select="onUserSelect">
<template #item="{item}">
<b class="mr-3">{{ item.username }}</b>
<span class="text-light">{{ item.first_name }} {{ item.last_name }}</span>
&mdash;
<i>{{ item.email }}</i>
</template>
</a-autocomplete>
</div>
</div>
</template>
<script>
import Model, { Set } from "../model.js"
import AAutocomplete from "./AAutocomplete.vue"
export default {
components: {AAutocomplete},
props: {
model: {type: Function, default: Model },
// List url
url: String,
// User autocomplete url
searchUrl: String,
// POST url
commitUrl: String,
// default values
initials: {type: Object, default: () => ({})},
},
data() {
return {
set: new Set(this.model, {url: this.url, unique: true}),
}
},
computed: {
items() { return this.set?.items || [] },
user_ids() { return this.set?.items.map(i => i.data.user.id) },
},
methods: {
onUserSelect(index, item, value) {
if(this.user_ids.indexOf(item.id) != -1)
return
this.set.push({
...this.initials,
user: {...item},
})
this.$refs.autocomplete.reset()
},
save() {
this.set.commit(this.commitUrl, {
getData: i => ({...this.initials, user_id: i.data.user.id})
})
},
},
mounted() {
this.set.fetch()
},
}
</script>

View File

@ -4,9 +4,9 @@
<div class="modal-card"> <div class="modal-card">
<header class="modal-card-head"> <header class="modal-card-head">
<div class="modal-card-title"> <div class="modal-card-title">
<slot name="title">{{ title }}</slot> <slot name="title" :item="item">{{ title }}</slot>
</div> </div>
<slot name="bar"></slot> <slot name="bar" :item="item"></slot>
<button type="button" class="delete square" aria-label="close" @click="close"> <button type="button" class="delete square" aria-label="close" @click="close">
<span class="icon"> <span class="icon">
<i class="fa fa-close"></i> <i class="fa fa-close"></i>

View File

@ -1,25 +1,25 @@
<template> <template>
<tr> <tr>
<slot name="head" :item="item" :row="row"/> <slot name="head" :context="context" :item="item" :row="row"/>
<template v-for="(attr,col) in columns" :key="col"> <template v-for="(attr,col) in columns" :key="col">
<slot name="cell-before" :item="item" :cell="cells[col]" <slot name="cell-before" :context="context" :item="item" :cell="cells[col]"
:attr="attr"/> :attr="attr"/>
<component :is="cellTag" :class="['cell', 'cell-' + attr]" :data-col="col" <component :is="cellTag" :class="['cell', 'cell-' + attr]" :data-col="col"
:draggable="orderable" :draggable="orderable"
@dragstart="onDragStart" @dragover="onDragOver" @drop="onDrop"> @dragstart="onDragStart" @dragover="onDragOver" @drop="onDrop">
<slot :name="attr" :item="item" :cell="cells[col]" <slot :name="attr" :context="context" :item="item" :cell="cells[col]"
:data="itemData" :attr="attr" :emit="cellEmit" :data="itemData" :attr="attr" :emit="cellEmit"
:value="itemData && itemData[attr]"> :value="itemData && itemData[attr]">
{{ itemData && itemData[attr] }} {{ itemData && itemData[attr] }}
</slot> </slot>
<slot name="cell" :item="item" :cell="cells[col]" <slot name="cell" :context="context" :item="item" :cell="cells[col]"
:data="itemData" :attr="attr" :emit="cellEmit" :data="itemData" :attr="attr" :emit="cellEmit"
:value="itemData && itemData[attr]"/> :value="itemData && itemData[attr]"/>
</component> </component>
<slot name="cell-after" :item="item" :col="col" :cell="cells[col]" <slot name="cell-after" :context="context" :item="item" :col="col" :cell="cells[col]"
:attr="attr"/> :attr="attr"/>
</template> </template>
<slot name="tail" :item="item" :row="row"/> <slot name="tail" :context="context" :item="item" :row="row"/>
</tr> </tr>
</template> </template>
<script> <script>
@ -30,6 +30,8 @@ export default {
emits: ['move', 'cell'], emits: ['move', 'cell'],
props: { props: {
//! Context object
context: {type: Object, default: () => ({})},
//! Item to display in row //! Item to display in row
item: {type: Object, default: () => ({})}, item: {type: Object, default: () => ({})},
//! Columns to display, as items' attributes //! Columns to display, as items' attributes

View File

@ -1,7 +1,7 @@
<template> <template>
<table class="table is-stripped is-fullwidth"> <table class="table is-stripped is-fullwidth">
<thead> <thead>
<a-row :columns="columnNames" <a-row :context="context" :columns="columnNames"
:orderable="columnsOrderable" cellTag="th" :orderable="columnsOrderable" cellTag="th"
@move="moveColumn"> @move="moveColumn">
<template v-if="$slots['header-head']" v-slot:head="data"> <template v-if="$slots['header-head']" v-slot:head="data">
@ -26,7 +26,7 @@
<slot name="head"/> <slot name="head"/>
<template v-for="(item,row) in items" :key="row"> <template v-for="(item,row) in items" :key="row">
<!-- data-index comes from AList component drag & drop --> <!-- data-index comes from AList component drag & drop -->
<a-row :item="item" :cell="{row}" :columns="columnNames" :data-index="row" <a-row :context="context" :item="item" :cell="{row}" :columns="columnNames" :data-index="row"
:data-row="row" :data-row="row"
:draggable="orderable" :draggable="orderable"
@dragstart="onDragStart" @dragover="onDragOver" @drop="onDrop" @dragstart="onDragStart" @dragover="onDragOver" @drop="onDrop"
@ -54,6 +54,9 @@ const Component = {
props: { props: {
...AList.props, ...AList.props,
//! Context object
context: {type: Object, default: () => ({})},
//! Ordered list of columns, as objects with: //! Ordered list of columns, as objects with:
//! - name: item attribute value //! - name: item attribute value
//! - label: display label //! - label: display label

View File

@ -0,0 +1,23 @@
import AFileUpload from "./AFileUpload.vue"
import ASelectFile from "./ASelectFile.vue"
import AStatistics from './AStatistics.vue'
import AStreamer from './AStreamer.vue'
import AFormSet from './AFormSet.vue'
import ATrackListEditor from './ATrackListEditor.vue'
import ASoundListEditor from './ASoundListEditor.vue'
import AGroupUsers from "./AGroupUsers.vue"
import base from "./index.js"
export const admin = {
...base,
AGroupUsers,
AFileUpload, ASelectFile,
AFormSet, ATrackListEditor, ASoundListEditor,
AStatistics, AStreamer,
}
export default admin

View File

@ -1,7 +1,8 @@
import AActionButton from './AActionButton.vue'
import AAutocomplete from './AAutocomplete.vue' import AAutocomplete from './AAutocomplete.vue'
import ACarousel from './ACarousel.vue' import AModal from "./AModal.vue"
import AActionButton from './AActionButton.vue'
import ADropdown from "./ADropdown.vue" import ADropdown from "./ADropdown.vue"
import ACarousel from './ACarousel.vue'
import AEpisode from './AEpisode.vue' import AEpisode from './AEpisode.vue'
import AList from './AList.vue' import AList from './AList.vue'
import APage from './APage.vue' import APage from './APage.vue'
@ -11,31 +12,15 @@ import AProgress from './AProgress.vue'
import ASoundItem from './ASoundItem.vue' import ASoundItem from './ASoundItem.vue'
import ASwitch from './ASwitch.vue' import ASwitch from './ASwitch.vue'
import AModal from "./AModal.vue"
import AFileUpload from "./AFileUpload.vue"
import ASelectFile from "./ASelectFile.vue"
import AStatistics from './AStatistics.vue'
import AStreamer from './AStreamer.vue'
import AFormSet from './AFormSet.vue'
import ATrackListEditor from './ATrackListEditor.vue'
import ASoundListEditor from './ASoundListEditor.vue'
/** /**
* Core components * Core components
*/ */
export const base = { export const base = {
AAutocomplete, ACarousel, ADropdown, AEpisode, AList, APage, APlayer, APlaylist, AActionButton, AAutocomplete, AModal,
ACarousel, ADropdown, AEpisode, AList, APage, APlayer, APlaylist,
AProgress, ASoundItem, ASwitch, AProgress, ASoundItem, ASwitch,
} }
export default base export default base
export const admin = {
...base,
AActionButton, AFileUpload, ASelectFile, AModal,
AFormSet, ATrackListEditor, ASoundListEditor,
AStatistics, AStreamer,
}

View File

@ -113,7 +113,7 @@ export default class Model {
} }
/** /**
* Update instance's data with provided data. Return None * Set instance's data with provided data. Return None
*/ */
commit(data) { commit(data) {
this.data = data; this.data = data;
@ -121,11 +121,17 @@ export default class Model {
} }
/** /**
* Update model data, without reset previous value * Update model data, without reset previous value.
* Item is marked as updated.
*/ */
update(data) { update(data) {
this.data = {...this.data, ...data} this.data = {...this.data, ...data}
this.id = this.constructor.getId(this.data) this.id = this.constructor.getId(this.data)
this.updated = true
}
delete() {
this.deleted = true
} }
/** /**
@ -177,8 +183,24 @@ export class Set {
this.push(item, {args: args, save: false}); this.push(item, {args: args, save: false});
} }
//! Return total items count
get length() { return this.items.length } get length() { return this.items.length }
//! Return a list of items marked as deleted
get deletedItems() {
return this.items.filter(i => i.deleted)
}
//! Return a list of created items
get createdItems() {
return this.items.filter(i => !i.deleted && !i.id)
}
//! Return a list of updated items
get updatedItems() {
return this.items.filter(i => i.updated)
}
/** /**
* Fetch multiple items from server * Fetch multiple items from server
*/ */
@ -190,6 +212,58 @@ export class Set {
.map(d => new model(d, {url: url, ...args}))) .map(d => new model(d, {url: url, ...args})))
} }
fetch({url=null, reset=false, ...options}={}, args=null) {
url = url || this.url
options = this.model.getOptions(options)
return fetch(url, options)
.then(response => response.json())
.then(data =>
(data instanceof Array ? data : data.results)
.map(d => new this.model(d, {url: url, ...args}))
)
.then(data => {
if(reset)
this.items = data
else
// TODO: remove duplicate
this.items = [...this.items, ...data]
return data
})
}
/**
* Commit changes to server.
* ref: `views.mixin.ListCommitMixin`
*/
commit(url, {getData=null, ...options}={}) {
const createdItems = this.createdItems
const body = {
delete: this.deletedItems.map(i => i.id),
update: this.updatedItems.map(getData),
create: createdItems.map(getData),
}
if(!body.delete && !body.update && !body.create)
return
getData = getData || ((i) => i.data);
options = this.model.getOptions(options)
options.method = "POST"
options.body = JSON.stringify(body)
return fetch(url, options)
.then(response => response.json())
.then(data => {
const {created, updated, deleted} = data
if(createdItems)
this.items = this.items.filter(i => createdItems.indexOf(i) == -1)
if(deleted)
this.items = this.items.filter(i => deleted.indexOf(i.id) == -1)
this.extend(created)
this.extend(updated)
return data
})
}
/** /**
* Load list from localStorage * Load list from localStorage
*/ */
@ -234,22 +308,30 @@ export class Set {
: this.items.findIndex(x => x.id == pred.id); : this.items.findIndex(x => x.id == pred.id);
} }
extend(items, options) {
items.forEach(i => this.push(i, options))
}
/** /**
* Add item to set, return index. * Add item to set, return index.
* If item already exists, replace it.
*/ */
push(item, {args={},save=true}={}) { push(item, {args={},save=true}={}) {
item = item instanceof this.model ? item : new this.model(item, args); item = item instanceof this.model ? item : new this.model(item, args);
if(this.unique) { let index = -1
let index = this.findIndex(item); if(this.unique && item.id) {
index = this.findIndex(item);
if(index > -1) if(index > -1)
return index; this.items[index] = item
} }
if(this.max && this.items.length >= this.max) if(index == -1) {
this.items.splice(0,this.items.length-this.max) if(this.max && this.items.length >= this.max)
this.items.splice(0,this.items.length-this.max)
this.items.push(item); this.items.push(item)
save && this.save(); index = this.items.length-1
return this.items.length-1; }
save && this.save()
return index;
} }
/** /**

View File

@ -309,9 +309,9 @@
.preview-header { .preview-header {
width: 100%; width: 100%;
&:not(.no-cover) { /*&:not(.no-cover) {
min-height: var(--header-height); min-height: var(--header-height);
} }*/
&.no-cover { &.no-cover {
height: unset; height: unset;

View File

@ -21,6 +21,10 @@
&.x { padding-right: 0px !important; } &.x { padding-right: 0px !important; }
} }
.align-center {
text-align: center !important;
justify-content: center;
}
.clear-left { clear: left !important } .clear-left { clear: left !important }
.clear-right { clear: right !important } .clear-right { clear: right !important }

View File

@ -25,6 +25,8 @@ export default defineConfig({
globals: { globals: {
vue: 'Vue', vue: 'Vue',
}, },
assetFileNames: "[name].[ext]",
chunkFileNames: "[name].js",
entryFileNames: "[name].js", entryFileNames: "[name].js",
}, },
plugins: [commonjs()], plugins: [commonjs()],

View File

@ -254,4 +254,8 @@ WSGI_APPLICATION = "instance.wsgi.application"
LOGOUT_REDIRECT_URL = "/" LOGOUT_REDIRECT_URL = "/"
REST_FRAMEWORK = {"DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.LimitOffsetPagination", "PAGE_SIZE": 50} REST_FRAMEWORK = {
"DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.LimitOffsetPagination",
"PAGE_SIZE": 50,
"DEFAULT_FILTER_BACKENDS": ["django_filters.rest_framework.DjangoFilterBackend"],
}