+
{% block page-form-actions %}{% endblock %}
+
+
+
{% endblock %}
diff --git a/aircox/templates/aircox/program_form.html b/aircox/templates/aircox/program_form.html
index 413cd33..3734c38 100644
--- a/aircox/templates/aircox/program_form.html
+++ b/aircox/templates/aircox/program_form.html
@@ -6,13 +6,13 @@
{{ form.media }}
{% endblock %}
-{% block page-form %}
-//////
-{{ block.super }}
+{% block page-form-actions %}
+{% if request.user.is_superuser %}
+
-{% if editors_formset %}
-
-
{% translate "Editors" %}
-{% include "./widgets/usergroup_formset.html" with formset=editors_formset formset_data=editors_formset_data tag_id="usergroup_formset" %}
+{% include "./dashboard/widgets/group_users.html" %}
+{{ block.super }}
{% endif %}
{% endblock %}
diff --git a/aircox/templates/aircox/widgets/autocomplete.html b/aircox/templates/aircox/widgets/autocomplete.html
index e74e0ce..d4b8ffa 100644
--- a/aircox/templates/aircox/widgets/autocomplete.html
+++ b/aircox/templates/aircox/widgets/autocomplete.html
@@ -1,4 +1,4 @@
+ {% 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" %} {{ extra|default:"" }}/>
diff --git a/aircox/templates/aircox/widgets/usergroup_formset.html b/aircox/templates/aircox/widgets/usergroup_formset.html
index b900de2..57c6b75 100644
--- a/aircox/templates/aircox/widgets/usergroup_formset.html
+++ b/aircox/templates/aircox/widgets/usergroup_formset.html
@@ -3,7 +3,7 @@
{% block row-control %}
{% if name == 'user' %}
- {% form_field field name value %}
+ {% form_field field "inputName" value %}
{% else %}
{{ block.super }}
{% endif %}
diff --git a/aircox/templatetags/aircox_admin.py b/aircox/templatetags/aircox_admin.py
index b21365b..55986c5 100644
--- a/aircox/templatetags/aircox_admin.py
+++ b/aircox/templatetags/aircox_admin.py
@@ -45,7 +45,7 @@ def do_formset_inline_data(context, formset):
# hack for sound list
if duration := item.get("duration"):
item["duration"] = duration.strftime("%H:%M")
- if sound := getattr(form.instance, "sound"):
+ if sound := getattr(form.instance, "sound", None):
item["name"] = sound.name
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)
items.append(item)
- data = {"items": items, "fields": fields}
+ data = {"items": items, "fields": fields, "initial": formset.initial and formset.initial[0]}
user = context["request"].user
settings = getattr(user, "aircox_settings", None)
data["settings"] = settings and UserSettingsSerializer(settings).data
diff --git a/aircox/urls.py b/aircox/urls.py
index 690d1d1..bdadc1e 100755
--- a/aircox/urls.py
+++ b/aircox/urls.py
@@ -21,11 +21,13 @@ register_converter(WeekConverter, "week")
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("sound", viewsets.SoundViewSet, basename="sound")
router.register("track", viewsets.TrackROViewSet, basename="track")
router.register("comment", viewsets.CommentViewSet, basename="comment")
-router.register("usergroup", viewsets.UserGroupViewSet, basename="usergroup")
api = [
diff --git a/aircox/views/mixins.py b/aircox/views/mixins.py
index 72e9aa3..6190d01 100644
--- a/aircox/views/mixins.py
+++ b/aircox/views/mixins.py
@@ -115,6 +115,9 @@ class VueFormDataMixin:
# 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):
"""Return form fields as data."""
model = form.Meta.model
@@ -140,5 +143,7 @@ class VueFormDataMixin:
"max_num_forms": formset.max_num,
},
"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,
}
diff --git a/aircox/views/program.py b/aircox/views/program.py
index 7df25b8..2e1e214 100644
--- a/aircox/views/program.py
+++ b/aircox/views/program.py
@@ -1,6 +1,5 @@
import random
-from django.contrib.auth.models import User
from django.contrib.auth.mixins import UserPassesTestMixin
from django.urls import reverse
@@ -61,31 +60,3 @@ class ProgramUpdateView(UserPassesTestMixin, VueFormDataMixin, PageUpdateView):
def test_func(self):
obj = self.get_object()
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
diff --git a/aircox/viewsets.py b/aircox/viewsets.py
index 00fe67f..7dc2af2 100644
--- a/aircox/viewsets.py
+++ b/aircox/viewsets.py
@@ -40,6 +40,49 @@ class AutocompleteMixin:
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):
parsers = (parsers.MultiPartParser,)
permissions = (permissions.IsAuthenticatedOrReadOnly,)
@@ -84,7 +127,6 @@ class TrackROViewSet(AutocompleteMixin, viewsets.ReadOnlyModelViewSet):
serializer_class = serializers.admin.TrackSerializer
permission_classes = (permissions.IsAuthenticated,)
- filter_backends = (drf_filters.DjangoFilterBackend,)
filterset_class = filters.TrackFilterSet
queryset = models.Track.objects.all()
@@ -96,10 +138,19 @@ class CommentViewSet(viewsets.ModelViewSet):
# --- 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
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):
diff --git a/assets/src/admin.js b/assets/src/admin.js
index ff11096..5b9fa68 100644
--- a/assets/src/admin.js
+++ b/assets/src/admin.js
@@ -2,7 +2,7 @@ import './styles/admin.scss'
import './index.js'
import App from './app';
-import {admin as components} from './components'
+import components from './components/admin.js'
const AdminApp = {
...App,
diff --git a/assets/src/app.js b/assets/src/app.js
index 3de02bf..881db6f 100644
--- a/assets/src/app.js
+++ b/assets/src/app.js
@@ -17,10 +17,22 @@ const App = {
},
methods: {
+ //! Delete elements from DOM using provided selector.
deleteElements(sel) {
for(var el of document.querySelectorAll(sel))
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
+ }
+ },
}
}
diff --git a/assets/src/components/AAutocomplete.vue b/assets/src/components/AAutocomplete.vue
index 44805cc..d46d001 100644
--- a/assets/src/components/AAutocomplete.vue
+++ b/assets/src/components/AAutocomplete.vue
@@ -20,24 +20,23 @@
- {{ labelField && selected.data[labelField] || selected }}
+ {{ selectedLabel }}
@@ -56,12 +55,14 @@ export default {
props: {
//! Search URL (where `${query}` is replaced by search term)
url: String,
+ //! Extra GET url parameters
+ urlParams: Object,
//! Items' model
model: Function,
//! Input tag class
inputClass: Array,
//! input text placeholder
- placeholder: String,
+ placeholder: Object,
//! input form field name
name: String,
//! Field on items to use as label
@@ -105,6 +106,20 @@ export default {
},
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 },
selected() {
@@ -136,12 +151,34 @@ export default {
},
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) {
- return this.valueField ? item && item[this.valueField] : item;
+ return this.valueField ? this.getValue(item, this.valueField) : item;
},
itemLabel(item) {
- return this.labelField ? item && item[this.labelField] : item;
+ return this.labelField ? this.getValue(item, this.labelField) : item;
},
hide() {
@@ -183,7 +220,7 @@ export default {
if(!this.items.length)
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)
this.select(index, false, false)
this.cursor = -1;
@@ -227,7 +264,7 @@ export default {
return
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})
: fetch(url, Model.getOptions()).then(d => d.json())
diff --git a/assets/src/components/AFormSet.vue b/assets/src/components/AFormSet.vue
index fcf03fb..7121550 100644
--- a/assets/src/components/AFormSet.vue
+++ b/assets/src/components/AFormSet.vue
@@ -6,7 +6,7 @@
:value="value"/>
-
$emit('cell', e)">
@@ -45,7 +45,7 @@
-
+
@@ -118,8 +118,6 @@
formData: Object,
//! Model class used for item's set
model: {type: Function, default: Model},
- //! initial data set load at mount
- initials: Array,
},
data() {
@@ -184,7 +182,7 @@
//! Reset forms to initials
reset() {
- this.load(this.initials || [], true)
+ this.load(this.formData?.initials || [], true)
},
},
diff --git a/assets/src/components/AGroupUsers.vue b/assets/src/components/AGroupUsers.vue
new file mode 100644
index 0000000..34862d9
--- /dev/null
+++ b/assets/src/components/AGroupUsers.vue
@@ -0,0 +1,102 @@
+
+
+
+
+
+
+
+ {{ item.username }}
+ {{ item.first_name }} {{ item.last_name }}
+ —
+ {{ item.email }}
+
+
+
+
+
+
diff --git a/assets/src/components/AModal.vue b/assets/src/components/AModal.vue
index 700e4d7..664447d 100644
--- a/assets/src/components/AModal.vue
+++ b/assets/src/components/AModal.vue
@@ -4,9 +4,9 @@