diff --git a/action/forms.py b/action/forms.py new file mode 100644 index 0000000..cf86646 --- /dev/null +++ b/action/forms.py @@ -0,0 +1,16 @@ +from django import forms +from .models import State + + +class ChangeStateForm(forms.Form): + previous_state = forms.CharField(widget=forms.HiddenInput()) + snapshot_uuid = forms.UUIDField(widget=forms.HiddenInput()) + new_state = forms.CharField(widget=forms.HiddenInput()) + + +class AddNoteForm(forms.Form): + snapshot_uuid = forms.UUIDField(widget=forms.HiddenInput()) + note = forms.CharField( + required=True, + widget=forms.Textarea(attrs={'rows': 4, 'maxlength': 200, 'placeholder': 'Max 200 characters'}), + ) diff --git a/action/management/commands/create_default_states.py b/action/management/commands/create_default_states.py new file mode 100644 index 0000000..6daccb2 --- /dev/null +++ b/action/management/commands/create_default_states.py @@ -0,0 +1,43 @@ +#!/usr/bin/env python3 +import logging +from django.core.management.base import BaseCommand +from action.models import StateDefinition, Institution +from django.utils.translation import gettext as _ + +logger = logging.getLogger('django') + +class Command(BaseCommand): + help = 'Create default StateDefinitions for a given institution. "' + + def add_arguments(self, parser): + parser.add_argument('institution_name', type=str, help='The name of the institution') + + def handle(self, *args, **kwargs): + default_states = [ + _("INBOX"), + _("VISUAL INSPECTION"), + _("REPAIR"), + _("INSTALL"), + _("TEST"), + _("PACKAGING"), + _("DONATION"), + _("DISMANTLE") + ] + + institution_name = kwargs['institution_name'] + institution = Institution.objects.filter(name=institution_name).first() + + if not institution: + txt = "No institution found for: %s. Please create an institution first" + logger.error(txt, institution.name) + return + + for state in default_states: + state_def, created = StateDefinition.objects.get_or_create( + institution=institution, + state=state + ) + if created: + self.stdout.write(self.style.SUCCESS(f'Successfully created state: {state}')) + else: + self.stdout.write(self.style.WARNING(f'State already exists: {state}')) diff --git a/action/migrations/0001_initial.py b/action/migrations/0001_initial.py new file mode 100644 index 0000000..8de0505 --- /dev/null +++ b/action/migrations/0001_initial.py @@ -0,0 +1,83 @@ +# Generated by Django 5.0.6 on 2024-12-11 18:05 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ("user", "0001_initial"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="State", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("date", models.DateTimeField(auto_now_add=True)), + ("state", models.CharField(max_length=50)), + ("snapshot_uuid", models.UUIDField()), + ( + "institution", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to="user.institution", + ), + ), + ( + "user", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to=settings.AUTH_USER_MODEL, + ), + ), + ], + ), + migrations.CreateModel( + name="StateDefinition", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("order", models.PositiveIntegerField(default=0)), + ("state", models.CharField(max_length=50)), + ( + "institution", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="user.institution", + ), + ), + ], + options={ + "ordering": ["order"], + }, + ), + migrations.AddConstraint( + model_name="statedefinition", + constraint=models.UniqueConstraint( + fields=("institution", "state"), name="unique_institution_state" + ), + ), + ] diff --git a/action/migrations/0002_devicelog_note.py b/action/migrations/0002_devicelog_note.py new file mode 100644 index 0000000..c14c0f6 --- /dev/null +++ b/action/migrations/0002_devicelog_note.py @@ -0,0 +1,89 @@ +# Generated by Django 5.0.6 on 2024-12-17 19:40 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("action", "0001_initial"), + ("user", "0001_initial"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="DeviceLog", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("date", models.DateTimeField(auto_now_add=True)), + ("event", models.CharField(max_length=255)), + ("snapshot_uuid", models.UUIDField()), + ( + "institution", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="user.institution", + ), + ), + ( + "user", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "ordering": ["-date"], + }, + ), + migrations.CreateModel( + name="Note", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("date", models.DateTimeField(auto_now_add=True)), + ("description", models.TextField()), + ("snapshot_uuid", models.UUIDField()), + ( + "institution", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="user.institution", + ), + ), + ( + "user", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "ordering": ["-date"], + }, + ), + ] diff --git a/action/models.py b/action/models.py index 71a8362..bf49e17 100644 --- a/action/models.py +++ b/action/models.py @@ -1,3 +1,82 @@ -from django.db import models +from django.db import models, connection +from django.db.models import Max +from user.models import User, Institution +from django.core.exceptions import ValidationError -# Create your models here. +class State(models.Model): + date = models.DateTimeField(auto_now_add=True) + institution = models.ForeignKey(Institution, on_delete=models.SET_NULL, null=True) + user = models.ForeignKey(User, on_delete=models.SET_NULL, null=True) + + state = models.CharField(max_length=50) + snapshot_uuid = models.UUIDField() + + def clean(self): + if not StateDefinition.objects.filter(institution=self.institution, state=self.state).exists(): + raise ValidationError(f"The state '{self.state}' is not valid for the institution '{self.institution.name}'.") + + def save(self, *args, **kwargs): + self.clean() + super().save(*args, **kwargs) + + def __str__(self): + return f"{self.institution.name} - {self.state} - {self.snapshot_uuid}" + +class StateDefinition(models.Model): + institution = models.ForeignKey(Institution, on_delete=models.CASCADE) + + order = models.PositiveIntegerField(default=0) + state = models.CharField(max_length=50) + + class Meta: + ordering = ['order'] + constraints = [ + models.UniqueConstraint(fields=['institution', 'state'], name='unique_institution_state') + ] + + def save(self, *args, **kwargs): + if not self.pk: + # set the order to be last + max_order = StateDefinition.objects.filter(institution=self.institution).aggregate(Max('order'))['order__max'] + self.order = (max_order or 0) + 1 + super().save(*args, **kwargs) + + + def delete(self, *args, **kwargs): + institution = self.institution + order = self.order + super().delete(*args, **kwargs) + # Adjust the order of other instances + StateDefinition.objects.filter(institution=institution, order__gt=order).update(order=models.F('order') - 1) + + + def __str__(self): + return f"{self.institution.name} - {self.state}" + + +class Note(models.Model): + institution = models.ForeignKey(Institution, on_delete=models.CASCADE) + user = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True) + date = models.DateTimeField(auto_now_add=True) + description = models.TextField() + snapshot_uuid = models.UUIDField() + + class Meta: + ordering = ['-date'] + + def __str__(self): + return f" Note: {self.description}, by {self.user.username} @ {self.user.institution} - {self.date}, for {self.snapshot_uuid}" + + +class DeviceLog(models.Model): + institution = models.ForeignKey(Institution, on_delete=models.CASCADE) + user = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True) + date = models.DateTimeField(auto_now_add=True) + event = models.CharField(max_length=255) + snapshot_uuid = models.UUIDField() + + class Meta: + ordering = ['-date'] + + def __str__(self): + return f"{self.event} by {self.user.username} @ {self.institution.name} - {self.date}, for {self.snapshot_uuid}" diff --git a/action/urls.py b/action/urls.py index 9373af6..193a431 100644 --- a/action/urls.py +++ b/action/urls.py @@ -1 +1,12 @@ from django.urls import path, include +from action import views + +app_name = 'action' + +urlpatterns = [ + + path("new/", views.ChangeStateView.as_view(), name="change_state"), + path('state//undo/', views.UndoStateView.as_view(), name='undo_state'), + path('note/add/', views.AddNoteView.as_view(), name='add_note'), + +] diff --git a/action/views.py b/action/views.py index 5d608b0..54f3c71 100644 --- a/action/views.py +++ b/action/views.py @@ -1 +1,82 @@ -# from django.shortcuts import render +from django.views import View +from django.shortcuts import redirect, get_object_or_404 +from django.contrib import messages +from action.forms import ChangeStateForm, AddNoteForm +from django.views.generic.edit import DeleteView, CreateView, FormView +from django.urls import reverse_lazy +from django.utils.translation import gettext_lazy as _ +from action.models import State, StateDefinition, Note, DeviceLog +from device.models import Device +import logging + + +class ChangeStateView(View): + + def post(self, request, *args, **kwargs): + form = ChangeStateForm(request.POST) + + if form.is_valid(): + previous_state = form.cleaned_data['previous_state'] + new_state = form.cleaned_data['new_state'] + snapshot_uuid = form.cleaned_data['snapshot_uuid'] + + State.objects.create( + snapshot_uuid=snapshot_uuid, + state=new_state, + user=self.request.user, + institution=self.request.user.institution, + ) + + message = _(" State '{}'. Previous State: '{}' ".format(new_state, previous_state) ) + DeviceLog.objects.create( + snapshot_uuid=snapshot_uuid, + event=message, + user=self.request.user, + institution=self.request.user.institution, + ) + messages.success(request,message) + else: + messages.error(request, "There was an error with your submission.") + + return redirect(request.META.get('HTTP_REFERER') ) + + +class UndoStateView(DeleteView): + model = State + + def delete(self, request, *args, **kwargs): + self.object = self.get_object() + return super().delete(request, *args, **kwarg) + + def get_success_url(self): + messages.info(self.request, f"Action to state: {self.object.state} has been deleted.") + return self.request.META.get('HTTP_REFERER', reverse_lazy('device:details', args=[self.object.snapshot_uuid])) + + +class AddNoteView(View): + + def post(self, request, *args, **kwargs): + form = AddNoteForm(request.POST) + + if form.is_valid(): + note = form.cleaned_data['note'] + snapshot_uuid = form.cleaned_data['snapshot_uuid'] + Note.objects.create( + snapshot_uuid=snapshot_uuid, + description=note, + user=self.request.user, + institution=self.request.user.institution, + ) + + message = _(" Note: '{}' ".format(note) ) + DeviceLog.objects.create( + snapshot_uuid=snapshot_uuid, + event=message, + user=self.request.user, + institution=self.request.user.institution, + ) + messages.success(request, _("Note has been added")) + else: + messages.error(request, "There was an error with your submission.") + + return redirect(request.META.get('HTTP_REFERER') ) diff --git a/admin/forms.py b/admin/forms.py new file mode 100644 index 0000000..f39cb84 --- /dev/null +++ b/admin/forms.py @@ -0,0 +1,5 @@ +from django import forms + + +class OrderingStateForm(forms.Form): + ordering = forms.CharField() diff --git a/admin/templates/states_panel.html b/admin/templates/states_panel.html new file mode 100644 index 0000000..430aae6 --- /dev/null +++ b/admin/templates/states_panel.html @@ -0,0 +1,225 @@ +{% extends "base.html" %} +{% load i18n django_bootstrap5 %} + +{% block content %} +
+
+

{{ subtitle }}

+
+
+ +
+
+ +
+
+ {% if state_definitions %} + + + + + + + + + + + {% for state_definition in state_definitions %} + + + + + + + + + {% endfor %} + +
+ {% trans 'Move and drag state definitions to reorder' %} +
+ {% trans "State Definition" %} + {% trans "Actions" %} +
+ + {{ state_definition.order }} + + {{ state_definition.state }} + +
+ + +
+ +
+ +
+ {% csrf_token %} + + +
+ + + {% else %} + + {% endif %} +
+
+ + + + + +{% for state_definition in state_definitions %} + +{% endfor %} + + + +{% for state_definition in state_definitions %} + + +{% endfor %} + + + + + +{% endblock %} diff --git a/admin/urls.py b/admin/urls.py index 9a26cbf..8aafced 100644 --- a/admin/urls.py +++ b/admin/urls.py @@ -10,4 +10,9 @@ urlpatterns = [ path("users/edit/", views.EditUserView.as_view(), name="edit_user"), path("users/delete/", views.DeleteUserView.as_view(), name="delete_user"), path("institution/", views.InstitutionView.as_view(), name="institution"), + path("states/", views.StatesPanelView.as_view(), name="states_panel"), + path("states/add", views.AddStateDefinitionView.as_view(), name="add_state_definition"), + path("states/delete/", views.DeleteStateDefinitionView.as_view(), name='delete_state_definition'), + path("states/update_order/", views.UpdateStateOrderView.as_view(), name='update_state_order'), + path("states/edit//", views.UpdateStateDefinitionView.as_view(), name='edit_state_definition'), ] diff --git a/admin/views.py b/admin/views.py index 9ce25c7..5b2c0cc 100644 --- a/admin/views.py +++ b/admin/views.py @@ -1,16 +1,23 @@ +import logging from smtplib import SMTPException +from django.contrib import messages from django.urls import reverse_lazy -from django.shortcuts import get_object_or_404 +from django.shortcuts import get_object_or_404, redirect, Http404 from django.utils.translation import gettext_lazy as _ -from django.views.generic.base import TemplateView +from django.contrib.messages.views import SuccessMessageMixin +from django.views.generic.base import TemplateView, ContextMixin from django.views.generic.edit import ( CreateView, UpdateView, DeleteView, ) +from django.core.exceptions import ValidationError +from django.db import IntegrityError, transaction from dashboard.mixins import DashboardView, Http403 +from admin.forms import OrderingStateForm from user.models import User, Institution from admin.email import NotifyActivateUserByEmail +from action.models import StateDefinition class AdminView(DashboardView): @@ -124,3 +131,101 @@ class InstitutionView(AdminView, UpdateView): self.object = self.request.user.institution kwargs = super().get_form_kwargs() return kwargs + + +class StateDefinitionContextMixin(ContextMixin): + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context.update({ + "state_definitions": StateDefinition.objects.filter(institution=self.request.user.institution).order_by('order'), + "help_text": _('State definitions are the custom finite states that a device can be in.'), + }) + return context + + +class StatesPanelView(AdminView, StateDefinitionContextMixin, TemplateView): + template_name = "states_panel.html" + title = _("States Panel") + breadcrumb = _("admin / States Panel") + " /" + + +class AddStateDefinitionView(AdminView, StateDefinitionContextMixin, CreateView): + template_name = "states_panel.html" + title = _("New State Definition") + breadcrumb = "Admin / New state" + success_url = reverse_lazy('admin:states_panel') + model = StateDefinition + fields = ('state',) + + def form_valid(self, form): + form.instance.institution = self.request.user.institution + form.instance.user = self.request.user + try: + response = super().form_valid(form) + messages.success(self.request, _("State definition successfully added.")) + return response + except IntegrityError: + messages.error(self.request, _("State is already defined.")) + return self.form_invalid(form) + + def form_invalid(self, form): + return super().form_invalid(form) + + +class DeleteStateDefinitionView(AdminView, StateDefinitionContextMixin, SuccessMessageMixin, DeleteView): + model = StateDefinition + success_url = reverse_lazy('admin:states_panel') + + def get_success_message(self, cleaned_data): + return f'State definition: {self.object.state}, has been deleted' + + def delete(self, request, *args, **kwargs): + self.object = self.get_object() + + #only an admin of current institution can delete + if not object.institution == self.request.user.institution: + raise Http404 + + return super().delete(request, *args, **kwargs) + + +class UpdateStateOrderView(AdminView, TemplateView): + success_url = reverse_lazy('admin:states_panel') + + def post(self, request, *args, **kwargs): + form = OrderingStateForm(request.POST) + + if form.is_valid(): + ordered_ids = form.cleaned_data["ordering"].split(',') + + with transaction.atomic(): + current_order = 1 + _log = [] + for lookup_id in ordered_ids: + state_definition = StateDefinition.objects.get(id=lookup_id) + state_definition.order = current_order + state_definition.save() + _log.append(f"{state_definition.state} (ID: {lookup_id} -> Order: {current_order})") + current_order += 1 + + messages.success(self.request, _("Order changed succesfuly.")) + return redirect(self.success_url) + else: + return Http404 + + +class UpdateStateDefinitionView(AdminView, UpdateView): + model = StateDefinition + template_name = 'states_panel.html' + fields = ['state'] + pk_url_kwarg = 'pk' + + def get_queryset(self): + return StateDefinition.objects.filter(institution=self.request.user.institution) + + def get_success_url(self): + messages.success(self.request, _("State definition updated successfully.")) + return reverse_lazy('admin:states_panel') + + def form_valid(self, form): + return super().form_valid(form) diff --git a/api/urls.py b/api/urls.py index 4fa86d6..a5b157f 100644 --- a/api/urls.py +++ b/api/urls.py @@ -7,7 +7,7 @@ app_name = 'api' urlpatterns = [ path('v1/snapshot/', views.NewSnapshotView.as_view(), name='new_snapshot'), - path('v1/annotation//', views.AddAnnotationView.as_view(), name='new_annotation'), + path('v1/property//', views.AddPropertyView.as_view(), name='new_property'), path('v1/device//', views.DetailsDeviceView.as_view(), name='device'), path('v1/tokens/', views.TokenView.as_view(), name='tokens'), path('v1/tokens/new', views.TokenNewView.as_view(), name='new_token'), diff --git a/api/views.py b/api/views.py index 0de3e5a..42d130b 100644 --- a/api/views.py +++ b/api/views.py @@ -21,7 +21,7 @@ from django.views.generic.edit import ( from utils.save_snapshots import move_json, save_in_disk from django.views.generic.edit import View from dashboard.mixins import DashboardView -from evidence.models import Annotation +from evidence.models import SystemProperty, UserProperty from evidence.parse_details import ParseSnapshot from evidence.parse import Build from device.models import Device @@ -90,11 +90,11 @@ class NewSnapshotView(ApiMixing): logger.error("%s", txt) return JsonResponse({'status': txt}, status=500) - exist_annotation = Annotation.objects.filter( + exist_property = SystemProperty.objects.filter( uuid=data['uuid'] ).first() - if exist_annotation: + if exist_property: txt = "error: the snapshot {} exist".format(data['uuid']) logger.warning("%s", txt) return JsonResponse({'status': txt}, status=500) @@ -111,25 +111,24 @@ class NewSnapshotView(ApiMixing): text = "fail: It is not possible to parse snapshot" return JsonResponse({'status': text}, status=500) - annotation = Annotation.objects.filter( + property = SystemProperty.objects.filter( uuid=data['uuid'], - type=Annotation.Type.SYSTEM, # TODO this is hardcoded, it should select the user preferred algorithm key="hidalgo1", owner=self.tk.owner.institution ).first() - if not annotation: - logger.error("Error: No annotation for uuid: %s", data["uuid"]) + if not property: + logger.error("Error: No property for uuid: %s", data["uuid"]) return JsonResponse({'status': 'fail'}, status=500) - url_args = reverse_lazy("device:details", args=(annotation.value,)) + url_args = reverse_lazy("device:details", args=(property.value,)) url = request.build_absolute_uri(url_args) response = { "status": "success", - "dhid": annotation.value[:6].upper(), + "dhid": property.value[:6].upper(), "url": url, # TODO replace with public_url when available "public_url": url @@ -255,22 +254,21 @@ class DetailsDeviceView(ApiMixing): "components": snapshot.get("components"), }) - uuids = Annotation.objects.filter( + uuids = SystemProperty.objects.filter( owner=self.tk.owner.institution, value=self.pk ).values("uuid") - annotations = Annotation.objects.filter( + properties = UserProperty.objects.filter( uuid__in=uuids, owner=self.tk.owner.institution, - type = Annotation.Type.USER ).values_list("key", "value") - data.update({"annotations": list(annotations)}) + data.update({"properties": list(properties)}) return data -class AddAnnotationView(ApiMixing): +class AddPropertyView(ApiMixing): def post(self, request, *args, **kwargs): response = self.auth() @@ -279,13 +277,12 @@ class AddAnnotationView(ApiMixing): self.pk = kwargs['pk'] institution = self.tk.owner.institution - self.annotation = Annotation.objects.filter( + self.property = SystemProperty.objects.filter( owner=institution, value=self.pk, - type=Annotation.Type.SYSTEM ).first() - if not self.annotation: + if not self.property: return JsonResponse({}, status=404) try: @@ -296,10 +293,9 @@ class AddAnnotationView(ApiMixing): logger.error("Invalid Snapshot of user %s", self.tk.owner) return JsonResponse({'error': 'Invalid JSON'}, status=500) - Annotation.objects.create( - uuid=self.annotation.uuid, + UserProperty.objects.create( + uuid=self.property.uuid, owner=self.tk.owner.institution, - type = Annotation.Type.USER, key = key, value = value ) diff --git a/dashboard/mixins.py b/dashboard/mixins.py index af01a1b..b245c96 100644 --- a/dashboard/mixins.py +++ b/dashboard/mixins.py @@ -6,7 +6,7 @@ from django.core.exceptions import PermissionDenied from django.contrib.auth.mixins import LoginRequiredMixin from django.views.generic.base import TemplateView from device.models import Device -from evidence.models import Annotation +from evidence.models import SystemProperty from lot.models import LotTag @@ -49,7 +49,7 @@ class DashboardView(LoginRequiredMixin): dev_ids = self.request.session.pop("devices", []) self._devices = [] - for x in Annotation.objects.filter(value__in=dev_ids).filter( + for x in SystemProperty.objects.filter(value__in=dev_ids).filter( owner=self.request.user.institution ).distinct(): self._devices.append(Device(id=x.value)) diff --git a/dashboard/static/js/Sortable.min.js b/dashboard/static/js/Sortable.min.js new file mode 100644 index 0000000..95423a6 --- /dev/null +++ b/dashboard/static/js/Sortable.min.js @@ -0,0 +1,2 @@ +/*! Sortable 1.15.6 - MIT | git://github.com/SortableJS/Sortable.git */ +!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?module.exports=e():"function"==typeof define&&define.amd?define(e):(t=t||self).Sortable=e()}(this,function(){"use strict";function e(e,t){var n,o=Object.keys(e);return Object.getOwnPropertySymbols&&(n=Object.getOwnPropertySymbols(e),t&&(n=n.filter(function(t){return Object.getOwnPropertyDescriptor(e,t).enumerable})),o.push.apply(o,n)),o}function I(o){for(var t=1;tt.length)&&(e=t.length);for(var n=0,o=new Array(e);n"===e[0]&&(e=e.substring(1)),t))try{if(t.matches)return t.matches(e);if(t.msMatchesSelector)return t.msMatchesSelector(e);if(t.webkitMatchesSelector)return t.webkitMatchesSelector(e)}catch(t){return}}function g(t){return t.host&&t!==document&&t.host.nodeType?t.host:t.parentNode}function P(t,e,n,o){if(t){n=n||document;do{if(null!=e&&(">"!==e[0]||t.parentNode===n)&&f(t,e)||o&&t===n)return t}while(t!==n&&(t=g(t)))}return null}var m,v=/\s+/g;function k(t,e,n){var o;t&&e&&(t.classList?t.classList[n?"add":"remove"](e):(o=(" "+t.className+" ").replace(v," ").replace(" "+e+" "," "),t.className=(o+(n?" "+e:"")).replace(v," ")))}function R(t,e,n){var o=t&&t.style;if(o){if(void 0===n)return document.defaultView&&document.defaultView.getComputedStyle?n=document.defaultView.getComputedStyle(t,""):t.currentStyle&&(n=t.currentStyle),void 0===e?n:n[e];o[e=!(e in o||-1!==e.indexOf("webkit"))?"-webkit-"+e:e]=n+("string"==typeof n?"":"px")}}function b(t,e){var n="";if("string"==typeof t)n=t;else do{var o=R(t,"transform")}while(o&&"none"!==o&&(n=o+" "+n),!e&&(t=t.parentNode));var i=window.DOMMatrix||window.WebKitCSSMatrix||window.CSSMatrix||window.MSCSSMatrix;return i&&new i(n)}function D(t,e,n){if(t){var o=t.getElementsByTagName(e),i=0,r=o.length;if(n)for(;i=n.left-e&&i<=n.right+e,e=r>=n.top-e&&r<=n.bottom+e;return o&&e?a=t:void 0}}),a);if(e){var n,o={};for(n in t)t.hasOwnProperty(n)&&(o[n]=t[n]);o.target=o.rootEl=e,o.preventDefault=void 0,o.stopPropagation=void 0,e[K]._onDragOver(o)}}var i,r,a}function Ft(t){Z&&Z.parentNode[K]._isOutsideThisEl(t.target)}function jt(t,e){if(!t||!t.nodeType||1!==t.nodeType)throw"Sortable: `el` must be an HTMLElement, not ".concat({}.toString.call(t));this.el=t,this.options=e=a({},e),t[K]=this;var n,o,i={group:null,sort:!0,disabled:!1,store:null,handle:null,draggable:/^[uo]l$/i.test(t.nodeName)?">li":">*",swapThreshold:1,invertSwap:!1,invertedSwapThreshold:null,removeCloneOnHide:!0,direction:function(){return kt(t,this.options)},ghostClass:"sortable-ghost",chosenClass:"sortable-chosen",dragClass:"sortable-drag",ignore:"a, img",filter:null,preventOnFilter:!0,animation:0,easing:null,setData:function(t,e){t.setData("Text",e.textContent)},dropBubble:!1,dragoverBubble:!1,dataIdAttr:"data-id",delay:0,delayOnTouchOnly:!1,touchStartThreshold:(Number.parseInt?Number:window).parseInt(window.devicePixelRatio,10)||1,forceFallback:!1,fallbackClass:"sortable-fallback",fallbackOnBody:!1,fallbackTolerance:0,fallbackOffset:{x:0,y:0},supportPointer:!1!==jt.supportPointer&&"PointerEvent"in window&&(!u||c),emptyInsertThreshold:5};for(n in z.initializePlugins(this,t,i),i)n in e||(e[n]=i[n]);for(o in Rt(e),this)"_"===o.charAt(0)&&"function"==typeof this[o]&&(this[o]=this[o].bind(this));this.nativeDraggable=!e.forceFallback&&It,this.nativeDraggable&&(this.options.touchStartThreshold=1),e.supportPointer?h(t,"pointerdown",this._onTapStart):(h(t,"mousedown",this._onTapStart),h(t,"touchstart",this._onTapStart)),this.nativeDraggable&&(h(t,"dragover",this),h(t,"dragenter",this)),St.push(this.el),e.store&&e.store.get&&this.sort(e.store.get(this)||[]),a(this,A())}function Ht(t,e,n,o,i,r,a,l){var s,c,u=t[K],d=u.options.onMove;return!window.CustomEvent||y||w?(s=document.createEvent("Event")).initEvent("move",!0,!0):s=new CustomEvent("move",{bubbles:!0,cancelable:!0}),s.to=e,s.from=t,s.dragged=n,s.draggedRect=o,s.related=i||e,s.relatedRect=r||X(e),s.willInsertAfter=l,s.originalEvent=a,t.dispatchEvent(s),c=d?d.call(u,s,a):c}function Lt(t){t.draggable=!1}function Kt(){xt=!1}function Wt(t){return setTimeout(t,0)}function zt(t){return clearTimeout(t)}jt.prototype={constructor:jt,_isOutsideThisEl:function(t){this.el.contains(t)||t===this.el||(vt=null)},_getDirection:function(t,e){return"function"==typeof this.options.direction?this.options.direction.call(this,t,e,Z):this.options.direction},_onTapStart:function(e){if(e.cancelable){var n=this,o=this.el,t=this.options,i=t.preventOnFilter,r=e.type,a=e.touches&&e.touches[0]||e.pointerType&&"touch"===e.pointerType&&e,l=(a||e).target,s=e.target.shadowRoot&&(e.path&&e.path[0]||e.composedPath&&e.composedPath()[0])||l,c=t.filter;if(!function(t){Ot.length=0;var e=t.getElementsByTagName("input"),n=e.length;for(;n--;){var o=e[n];o.checked&&Ot.push(o)}}(o),!Z&&!(/mousedown|pointerdown/.test(r)&&0!==e.button||t.disabled)&&!s.isContentEditable&&(this.nativeDraggable||!u||!l||"SELECT"!==l.tagName.toUpperCase())&&!((l=P(l,t.draggable,o,!1))&&l.animated||et===l)){if(it=j(l),at=j(l,t.draggable),"function"==typeof c){if(c.call(this,e,l,this))return V({sortable:n,rootEl:s,name:"filter",targetEl:l,toEl:o,fromEl:o}),U("filter",n,{evt:e}),void(i&&e.preventDefault())}else if(c=c&&c.split(",").some(function(t){if(t=P(s,t.trim(),o,!1))return V({sortable:n,rootEl:t,name:"filter",targetEl:l,fromEl:o,toEl:o}),U("filter",n,{evt:e}),!0}))return void(i&&e.preventDefault());t.handle&&!P(s,t.handle,o,!1)||this._prepareDragStart(e,a,l)}}},_prepareDragStart:function(t,e,n){var o,i=this,r=i.el,a=i.options,l=r.ownerDocument;n&&!Z&&n.parentNode===r&&(o=X(n),J=r,$=(Z=n).parentNode,tt=Z.nextSibling,et=n,st=a.group,ut={target:jt.dragged=Z,clientX:(e||t).clientX,clientY:(e||t).clientY},ft=ut.clientX-o.left,gt=ut.clientY-o.top,this._lastX=(e||t).clientX,this._lastY=(e||t).clientY,Z.style["will-change"]="all",o=function(){U("delayEnded",i,{evt:t}),jt.eventCanceled?i._onDrop():(i._disableDelayedDragEvents(),!s&&i.nativeDraggable&&(Z.draggable=!0),i._triggerDragStart(t,e),V({sortable:i,name:"choose",originalEvent:t}),k(Z,a.chosenClass,!0))},a.ignore.split(",").forEach(function(t){D(Z,t.trim(),Lt)}),h(l,"dragover",Bt),h(l,"mousemove",Bt),h(l,"touchmove",Bt),a.supportPointer?(h(l,"pointerup",i._onDrop),this.nativeDraggable||h(l,"pointercancel",i._onDrop)):(h(l,"mouseup",i._onDrop),h(l,"touchend",i._onDrop),h(l,"touchcancel",i._onDrop)),s&&this.nativeDraggable&&(this.options.touchStartThreshold=4,Z.draggable=!0),U("delayStart",this,{evt:t}),!a.delay||a.delayOnTouchOnly&&!e||this.nativeDraggable&&(w||y)?o():jt.eventCanceled?this._onDrop():(a.supportPointer?(h(l,"pointerup",i._disableDelayedDrag),h(l,"pointercancel",i._disableDelayedDrag)):(h(l,"mouseup",i._disableDelayedDrag),h(l,"touchend",i._disableDelayedDrag),h(l,"touchcancel",i._disableDelayedDrag)),h(l,"mousemove",i._delayedDragTouchMoveHandler),h(l,"touchmove",i._delayedDragTouchMoveHandler),a.supportPointer&&h(l,"pointermove",i._delayedDragTouchMoveHandler),i._dragStartTimer=setTimeout(o,a.delay)))},_delayedDragTouchMoveHandler:function(t){t=t.touches?t.touches[0]:t;Math.max(Math.abs(t.clientX-this._lastX),Math.abs(t.clientY-this._lastY))>=Math.floor(this.options.touchStartThreshold/(this.nativeDraggable&&window.devicePixelRatio||1))&&this._disableDelayedDrag()},_disableDelayedDrag:function(){Z&&Lt(Z),clearTimeout(this._dragStartTimer),this._disableDelayedDragEvents()},_disableDelayedDragEvents:function(){var t=this.el.ownerDocument;p(t,"mouseup",this._disableDelayedDrag),p(t,"touchend",this._disableDelayedDrag),p(t,"touchcancel",this._disableDelayedDrag),p(t,"pointerup",this._disableDelayedDrag),p(t,"pointercancel",this._disableDelayedDrag),p(t,"mousemove",this._delayedDragTouchMoveHandler),p(t,"touchmove",this._delayedDragTouchMoveHandler),p(t,"pointermove",this._delayedDragTouchMoveHandler)},_triggerDragStart:function(t,e){e=e||"touch"==t.pointerType&&t,!this.nativeDraggable||e?this.options.supportPointer?h(document,"pointermove",this._onTouchMove):h(document,e?"touchmove":"mousemove",this._onTouchMove):(h(Z,"dragend",this),h(J,"dragstart",this._onDragStart));try{document.selection?Wt(function(){document.selection.empty()}):window.getSelection().removeAllRanges()}catch(t){}},_dragStarted:function(t,e){var n;Dt=!1,J&&Z?(U("dragStarted",this,{evt:e}),this.nativeDraggable&&h(document,"dragover",Ft),n=this.options,t||k(Z,n.dragClass,!1),k(Z,n.ghostClass,!0),jt.active=this,t&&this._appendGhost(),V({sortable:this,name:"start",originalEvent:e})):this._nulling()},_emulateDragOver:function(){if(dt){this._lastX=dt.clientX,this._lastY=dt.clientY,Xt();for(var t=document.elementFromPoint(dt.clientX,dt.clientY),e=t;t&&t.shadowRoot&&(t=t.shadowRoot.elementFromPoint(dt.clientX,dt.clientY))!==e;)e=t;if(Z.parentNode[K]._isOutsideThisEl(t),e)do{if(e[K])if(e[K]._onDragOver({clientX:dt.clientX,clientY:dt.clientY,target:t,rootEl:e})&&!this.options.dragoverBubble)break}while(e=g(t=e));Yt()}},_onTouchMove:function(t){if(ut){var e=this.options,n=e.fallbackTolerance,o=e.fallbackOffset,i=t.touches?t.touches[0]:t,r=Q&&b(Q,!0),a=Q&&r&&r.a,l=Q&&r&&r.d,e=At&&wt&&E(wt),a=(i.clientX-ut.clientX+o.x)/(a||1)+(e?e[0]-Tt[0]:0)/(a||1),l=(i.clientY-ut.clientY+o.y)/(l||1)+(e?e[1]-Tt[1]:0)/(l||1);if(!jt.active&&!Dt){if(n&&Math.max(Math.abs(i.clientX-this._lastX),Math.abs(i.clientY-this._lastY))E.right+10||S.clientY>x.bottom&&S.clientX>x.left:S.clientY>E.bottom+10||S.clientX>x.right&&S.clientY>x.top)||m.animated)){if(m&&(t=n,e=r,C=X(B((_=this).el,0,_.options,!0)),_=L(_.el,_.options,Q),e?t.clientX<_.left-10||t.clientY +