From af64d5468f89f28177a5dbbefc96875e62e67cf5 Mon Sep 17 00:00:00 2001 From: Tobias Kunze Date: Tue, 25 Mar 2025 16:08:27 +0100 Subject: [PATCH 01/14] Implement CRD retrieval --- src/servala/core/models/service.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/servala/core/models/service.py b/src/servala/core/models/service.py index f1dc20b..b659b2d 100644 --- a/src/servala/core/models/service.py +++ b/src/servala/core/models/service.py @@ -310,6 +310,15 @@ class ServiceOfferingControlPlane(models.Model): def __str__(self): return f"{self.service_offering} on {self.control_plane} with {self.service_definition}" + def get_resource_definition(self): + client = self.control_plane.get_kubernetes_client() + api_instance = kubernetes.client.ApiextensionsV1Api(client) + kind = self.service_definition.api_definition["kind"].lower() + group = self.service_definition.api_definition["group"] + name = f"{kind}.{group}" + crd = api_instance.read_custom_resource_definition(name) + return crd + class ServiceOffering(models.Model): """ -- 2.47.2 From 234ff8e1d657f5d4773c326a8df3ebf5a34e9c19 Mon Sep 17 00:00:00 2001 From: Tobias Kunze Date: Tue, 25 Mar 2025 17:13:43 +0100 Subject: [PATCH 02/14] Build dynamic model and modelform generation --- src/servala/core/crd.py | 92 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 92 insertions(+) create mode 100644 src/servala/core/crd.py diff --git a/src/servala/core/crd.py b/src/servala/core/crd.py new file mode 100644 index 0000000..5a3803c --- /dev/null +++ b/src/servala/core/crd.py @@ -0,0 +1,92 @@ +from django.core.validators import MaxValueValidator, MinValueValidator, RegexValidator +from django.db import models +from django.forms.models import ModelForm, ModelFormMetaclass + + +def generate_django_model(crd, group, version, kind): + """ + Generates a virtual Django model from a Kubernetes CRD's OpenAPI v3 schema. + """ + schema = crd.spec.versions[0].schema.open_apiv3_schema + properties = schema.properties + required_fields = schema.required + model_fields = {"__module__": "crd_models"} + + defaults = {"apiVersion": f"{group}/{version}", "kind": kind} + + for prop_name, prop_schema in properties.items(): + is_required = prop_name in required_fields + field = get_django_field( + prop_schema, is_required, default=defaults.get(prop_name) + ) + model_fields[prop_name] = field + + meta_class = type("Meta", (), {"app_label": "crd_models"}) + model_fields["Meta"] = meta_class + + # create the model class + model_name = crd.spec.names.kind + model_class = type(model_name, (models.Model,), model_fields) + return model_class + + +def get_django_field(prop_schema, is_required=False, default=None): + field_type = prop_schema.type or "string" + format = prop_schema.format + + kwargs = { + "blank": not is_required, + "null": not is_required, + "help_text": prop_schema.description or "", + "validators": [], + "default": default, + # TODO: verbose_name? + } + + if prop_schema.minimum: + kwargs["validators"].append(MinValueValidator(prop_schema.minimum)) + if prop_schema.maximum: + kwargs["validators"].append(MaxValueValidator(prop_schema.maximum)) + + if field_type == "string": + if format == "date-time": + return models.DateTimeField(**kwargs) + elif format == "date": + return models.DateField(**kwargs) + else: + max_length = prop_schema.max_length or 255 + if prop_schema.pattern: + kwargs["validators"].append(RegexValidator(regex=prop_schema.pattern)) + return models.CharField(max_length=max_length, **kwargs) + elif field_type == "integer": + return models.IntegerField(**kwargs) + elif field_type == "number": + return models.FloatField(**kwargs) + elif field_type == "boolean": + return models.BooleanField(**kwargs) + elif field_type == "object": + kwargs["help_text"] += " (JSON object)" + return models.JSONField(**kwargs) + elif field_type == "array": + kwargs["help_text"] += " (JSON array)" + return models.JSONField(**kwargs) + return models.CharField(max_length=255, **kwargs) + + +class CrdModelFormMixin: + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fields["apiVersion"].disabled = True + + +def generate_model_form_class(model): + meta_attrs = { + "model": model, + "fields": "__all__", + } + fields = { + "Meta": type("Meta", (object,), meta_attrs), + "__module__": "crd_models", + } + class_name = f"{model.__name__}ModelForm" + return ModelFormMetaclass(class_name, (CrdModelFormMixin, ModelForm), fields) -- 2.47.2 From ee8fba07efe3774376c6d2af84900312c8c27f84 Mon Sep 17 00:00:00 2001 From: Tobias Kunze Date: Tue, 25 Mar 2025 18:30:08 +0100 Subject: [PATCH 03/14] Use serialized data for schema --- src/servala/core/crd.py | 95 ++++++++++++++++++++---------- src/servala/core/models/service.py | 30 +++++++++- 2 files changed, 93 insertions(+), 32 deletions(-) diff --git a/src/servala/core/crd.py b/src/servala/core/crd.py index 5a3803c..bf22bcc 100644 --- a/src/servala/core/crd.py +++ b/src/servala/core/crd.py @@ -1,52 +1,80 @@ +import re + from django.core.validators import MaxValueValidator, MinValueValidator, RegexValidator from django.db import models from django.forms.models import ModelForm, ModelFormMetaclass +from django.utils.translation import gettext_lazy as _ -def generate_django_model(crd, group, version, kind): +def generate_django_model(schema, group, version, kind): """ Generates a virtual Django model from a Kubernetes CRD's OpenAPI v3 schema. """ - schema = crd.spec.versions[0].schema.open_apiv3_schema - properties = schema.properties - required_fields = schema.required + spec = schema["properties"].get("spec") or {} + # defaults = {"apiVersion": f"{group}/{version}", "kind": kind} + model_fields = {"__module__": "crd_models"} - - defaults = {"apiVersion": f"{group}/{version}", "kind": kind} - - for prop_name, prop_schema in properties.items(): - is_required = prop_name in required_fields - field = get_django_field( - prop_schema, is_required, default=defaults.get(prop_name) - ) - model_fields[prop_name] = field + model_fields.update(build_object_fields(spec, "spec")) meta_class = type("Meta", (), {"app_label": "crd_models"}) model_fields["Meta"] = meta_class # create the model class - model_name = crd.spec.names.kind + model_name = kind model_class = type(model_name, (models.Model,), model_fields) return model_class -def get_django_field(prop_schema, is_required=False, default=None): - field_type = prop_schema.type or "string" - format = prop_schema.format +def build_object_fields(schema, name, verbose_name_prefix=None): + required_fields = schema.get("required", []) + properties = schema.get("properties", {}) + fields = {} + + for field_name, field_schema in properties.items(): + is_required = field_name in required_fields + full_name = f"{name}.{field_name}" + result = get_django_field( + field_schema, + is_required, + field_name, + full_name, + verbose_name_prefix=verbose_name_prefix, + ) + if isinstance(result, dict): + fields.update(result) + else: + fields[full_name] = result + return fields + + +def deslugify(title): + if "_" in title: + title.replace("_", " ") + return title.title() + return re.sub(r"(? Date: Tue, 25 Mar 2025 18:30:31 +0100 Subject: [PATCH 04/14] Show service ordering form --- src/servala/frontend/forms/service.py | 10 ++++- .../service_offering_detail.html | 33 ++++++++++----- src/servala/frontend/views/mixins.py | 41 ++++++++++--------- src/servala/frontend/views/service.py | 38 +++++++++++++++-- 4 files changed, 87 insertions(+), 35 deletions(-) diff --git a/src/servala/frontend/forms/service.py b/src/servala/frontend/forms/service.py index d7ea60f..0afb1b5 100644 --- a/src/servala/frontend/forms/service.py +++ b/src/servala/frontend/forms/service.py @@ -1,6 +1,6 @@ from django import forms -from servala.core.models import CloudProvider, ServiceCategory +from servala.core.models import CloudProvider, ControlPlane, ServiceCategory class ServiceFilterForm(forms.Form): @@ -20,3 +20,11 @@ class ServiceFilterForm(forms.Form): offerings__control_planes__cloud_provider=cloud_provider ) return queryset + + +class ControlPlaneSelectForm(forms.Form): + control_plane = forms.ModelChoiceField(queryset=ControlPlane.objects.none()) + + def __init__(self, *args, planes=None, **kwargs): + super().__init__(*args, **kwargs) + self.fields["control_plane"].queryset = planes diff --git a/src/servala/frontend/templates/frontend/organizations/service_offering_detail.html b/src/servala/frontend/templates/frontend/organizations/service_offering_detail.html index 1ed9a12..bd6a575 100644 --- a/src/servala/frontend/templates/frontend/organizations/service_offering_detail.html +++ b/src/servala/frontend/templates/frontend/organizations/service_offering_detail.html @@ -1,11 +1,23 @@ {% extends "frontend/base.html" %} {% load i18n %} {% load static %} +{% load partials %} {% block html_title %} {% block page_title %} {{ offering }} {% endblock page_title %} {% endblock html_title %} +{% partialdef service-form %} +{% if service_form %} + {% if form_error %} +
+ {% translate "Oops! Something went wrong with the service form generation. Please try again later." %} +
+ {% else %} + {% include "includes/form.html" with form=service_form %} + {% endif %} +{% endif %} +{% endpartialdef %} {% block content %}
@@ -23,17 +35,16 @@
-
- {% if offering.control_planes.all.count > 1 %} -

{% translate "Please choose your zone." %}

- {% else %} -

- {% blocktranslate trimmed with zone=offering.control_planes.all.first.name %} - Your zone will be {{ zone }}. - {% endblocktranslate %} -

- {% endif %} -
+ {% if offering.control_planes.all.count == 0 %} +

{% translate "We currently cannot offer this service, sorry!" %}

+ {% else %} +
+ {{ select_form }} +
+
{% partial service-form %}
+ {% endif %}
diff --git a/src/servala/frontend/views/mixins.py b/src/servala/frontend/views/mixins.py index f304e42..fd5a494 100644 --- a/src/servala/frontend/views/mixins.py +++ b/src/servala/frontend/views/mixins.py @@ -5,13 +5,30 @@ from rules.contrib.views import AutoPermissionRequiredMixin, PermissionRequiredM from servala.core.models import Organization -class HtmxUpdateView(AutoPermissionRequiredMixin, UpdateView): +class HtmxViewMixin: fragments = [] @cached_property def is_htmx(self): return self.request.headers.get("HX-Request") + def _get_fragment(self): + if self.request.method == "POST": + fragment = self.request.POST.get("fragment") + else: + fragment = self.request.GET.get("fragment") + if fragment and fragment in self.fragments: + return fragment + + def get_template_names(self): + template_names = super().get_template_names() + if self.is_htmx and (fragment := self._get_fragment()): + return [f"{template_names[0]}#{fragment}"] + return template_names + + +class HtmxUpdateView(AutoPermissionRequiredMixin, HtmxViewMixin, UpdateView): + @property def permission_type(self): if self.request.method == "POST" or getattr( @@ -31,20 +48,6 @@ class HtmxUpdateView(AutoPermissionRequiredMixin, UpdateView): result["has_change_permission"] = self.has_change_permission() return result - def _get_fragment(self): - if self.request.method == "POST": - fragment = self.request.POST.get("fragment") - else: - fragment = self.request.GET.get("fragment") - if fragment and fragment in self.fragments: - return fragment - - def get_template_names(self): - template_names = super().get_template_names() - if self.is_htmx and (fragment := self._get_fragment()): - return [f"{template_names[0]}#{fragment}"] - return template_names - def get_form_kwargs(self): result = super().get_form_kwargs() if self.is_htmx: @@ -82,8 +85,8 @@ class OrganizationViewMixin(PermissionRequiredMixin): def get_permission_object(self): return self.organization + def has_organization_permission(self): + return self.request.user.has_perm("core.view_organization", self.organization) + def has_permission(self): - return ( - self.request.user.has_perm("core.view_organization", self.organization) - and super().has_permission() - ) + return self.has_organization_permission() and super().has_permission() diff --git a/src/servala/frontend/views/service.py b/src/servala/frontend/views/service.py index f5c33f0..faead0f 100644 --- a/src/servala/frontend/views/service.py +++ b/src/servala/frontend/views/service.py @@ -1,9 +1,9 @@ from django.utils.functional import cached_property from django.views.generic import DetailView, ListView -from servala.core.models import Service, ServiceOffering -from servala.frontend.forms.service import ServiceFilterForm -from servala.frontend.views.mixins import OrganizationViewMixin +from servala.core.models import Service, ServiceOffering, ServiceOfferingControlPlane +from servala.frontend.forms.service import ControlPlaneSelectForm, ServiceFilterForm +from servala.frontend.views.mixins import HtmxViewMixin, OrganizationViewMixin class ServiceListView(OrganizationViewMixin, ListView): @@ -44,13 +44,43 @@ class ServiceDetailView(OrganizationViewMixin, DetailView): ) -class ServiceOfferingDetailView(OrganizationViewMixin, DetailView): +class ServiceOfferingDetailView(OrganizationViewMixin, HtmxViewMixin, DetailView): template_name = "frontend/organizations/service_offering_detail.html" context_object_name = "offering" model = ServiceOffering permission_type = "view" + fragments = ("service-form",) + + def has_permission(self): + return self.has_organization_permission() def get_queryset(self): return ServiceOffering.objects.all().select_related( "service", "service__category", "provider" ) + + @cached_property + def planes(self): + return self.object.control_planes.all() + + @cached_property + def select_form(self): + data = None + if "control_plane" in self.request.GET: + data = self.request.GET + return ControlPlaneSelectForm(data=data, planes=self.planes) + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context["select_form"] = self.select_form + if "control_plane" in self.request.GET: + if self.select_form.is_valid(): + so_cp = ServiceOfferingControlPlane.objects.filter( + control_plane=self.select_form.cleaned_data["control_plane"], + service_offering=self.object, + ).first() + if not so_cp: + context["form_error"] = True + else: + context["service_form"] = so_cp.model_form_class() + return context -- 2.47.2 From c4700932a56d5f55a1870971f7956592eaef64fd Mon Sep 17 00:00:00 2001 From: Tobias Kunze Date: Tue, 25 Mar 2025 18:34:50 +0100 Subject: [PATCH 05/14] Fix form label display --- src/servala/core/crd.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/servala/core/crd.py b/src/servala/core/crd.py index bf22bcc..d70ddec 100644 --- a/src/servala/core/crd.py +++ b/src/servala/core/crd.py @@ -51,7 +51,7 @@ def deslugify(title): if "_" in title: title.replace("_", " ") return title.title() - return re.sub(r"(? Date: Tue, 25 Mar 2025 18:42:14 +0100 Subject: [PATCH 06/14] Correctly show enums in CharFields --- src/servala/core/crd.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/servala/core/crd.py b/src/servala/core/crd.py index d70ddec..1c2bcef 100644 --- a/src/servala/core/crd.py +++ b/src/servala/core/crd.py @@ -85,7 +85,7 @@ def get_django_field( max_length = field_schema.get("max_length") or 255 if pattern := field_schema.get("pattern"): kwargs["validators"].append(RegexValidator(regex=pattern)) - if field_schema.get("items") and (choices := field_schema["items"].get("enum")): + if choices := field_schema.get("enum"): kwargs["choices"] = ((choice, choice) for choice in choices) return models.CharField(max_length=max_length, **kwargs) elif field_type == "integer": -- 2.47.2 From 24e1ce8cb82ef8331154267ef1a9580aa71fb13c Mon Sep 17 00:00:00 2001 From: Tobias Kunze Date: Wed, 26 Mar 2025 11:17:59 +0100 Subject: [PATCH 07/14] Show specific schema version, handle errors --- src/servala/core/models/service.py | 2 ++ src/servala/frontend/views/service.py | 13 ++++++++----- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/src/servala/core/models/service.py b/src/servala/core/models/service.py index 917b707..ca70e03 100644 --- a/src/servala/core/models/service.py +++ b/src/servala/core/models/service.py @@ -324,6 +324,8 @@ class ServiceOfferingControlPlane(models.Model): @cached_property def resource_schema(self): for version in self.resource_definition.spec.versions: + if self.service_definition.api_definition["version"] != version.name: + continue if version.schema and version.schema.open_apiv3_schema: schema_dict = kubernetes.client.ApiClient().sanitize_for_serialization( version.schema.open_apiv3_schema diff --git a/src/servala/frontend/views/service.py b/src/servala/frontend/views/service.py index faead0f..427f6be 100644 --- a/src/servala/frontend/views/service.py +++ b/src/servala/frontend/views/service.py @@ -75,11 +75,14 @@ class ServiceOfferingDetailView(OrganizationViewMixin, HtmxViewMixin, DetailView context["select_form"] = self.select_form if "control_plane" in self.request.GET: if self.select_form.is_valid(): - so_cp = ServiceOfferingControlPlane.objects.filter( - control_plane=self.select_form.cleaned_data["control_plane"], - service_offering=self.object, - ).first() - if not so_cp: + try: + so_cp = ServiceOfferingControlPlane.objects.filter( + control_plane=self.select_form.cleaned_data["control_plane"], + service_offering=self.object, + ).first() + if not so_cp: + context["form_error"] = True + except Exception: context["form_error"] = True else: context["service_form"] = so_cp.model_form_class() -- 2.47.2 From df1ebaa2d488f5a1f9157d4f8b5d904539a3595e Mon Sep 17 00:00:00 2001 From: Tobias Kunze Date: Wed, 26 Mar 2025 11:24:42 +0100 Subject: [PATCH 08/14] Display offering form in separate card --- .../service_offering_detail.html | 21 ++++++++++++------- src/servala/frontend/views/service.py | 4 ++++ 2 files changed, 17 insertions(+), 8 deletions(-) diff --git a/src/servala/frontend/templates/frontend/organizations/service_offering_detail.html b/src/servala/frontend/templates/frontend/organizations/service_offering_detail.html index bd6a575..066c1af 100644 --- a/src/servala/frontend/templates/frontend/organizations/service_offering_detail.html +++ b/src/servala/frontend/templates/frontend/organizations/service_offering_detail.html @@ -9,13 +9,18 @@ {% endblock html_title %} {% partialdef service-form %} {% if service_form %} - {% if form_error %} -
- {% translate "Oops! Something went wrong with the service form generation. Please try again later." %} +
+
+
+ {% if form_error %} +
+ {% translate "Oops! Something went wrong with the service form generation. Please try again later." %} +
+ {% else %} + {% include "includes/form.html" with form=service_form %} + {% endif %}
- {% else %} - {% include "includes/form.html" with form=service_form %} - {% endif %} +
{% endif %} {% endpartialdef %} {% block content %} @@ -35,7 +40,7 @@
- {% if offering.control_planes.all.count == 0 %} + {% if not has_control_planes %}

{% translate "We currently cannot offer this service, sorry!" %}

{% else %}
{{ select_form }}
-
{% partial service-form %}
{% endif %}
+
{% partial service-form %}
{% endblock content %} diff --git a/src/servala/frontend/views/service.py b/src/servala/frontend/views/service.py index 427f6be..00b7edd 100644 --- a/src/servala/frontend/views/service.py +++ b/src/servala/frontend/views/service.py @@ -73,8 +73,12 @@ class ServiceOfferingDetailView(OrganizationViewMixin, HtmxViewMixin, DetailView def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) context["select_form"] = self.select_form + context["has_control_planes"] = self.planes.exists() if "control_plane" in self.request.GET: if self.select_form.is_valid(): + context["selected_plane"] = self.select_form.cleaned_data[ + "control_plane" + ] try: so_cp = ServiceOfferingControlPlane.objects.filter( control_plane=self.select_form.cleaned_data["control_plane"], -- 2.47.2 From 6a521f3d0a8198eb97742e557e1e750a4b975848 Mon Sep 17 00:00:00 2001 From: Tobias Kunze Date: Wed, 26 Mar 2025 13:31:39 +0100 Subject: [PATCH 09/14] Implement full crd discovery and schema retrieval --- src/servala/core/models/service.py | 86 +++++++++++++++++++++++++----- 1 file changed, 74 insertions(+), 12 deletions(-) diff --git a/src/servala/core/models/service.py b/src/servala/core/models/service.py index ca70e03..0b28a7a 100644 --- a/src/servala/core/models/service.py +++ b/src/servala/core/models/service.py @@ -313,24 +313,86 @@ class ServiceOfferingControlPlane(models.Model): @cached_property def resource_definition(self): - client = self.control_plane.get_kubernetes_client() - api_instance = kubernetes.client.ApiextensionsV1Api(client) - kind = self.service_definition.api_definition["kind"].lower() + kind = self.service_definition.api_definition["kind"] group = self.service_definition.api_definition["group"] - name = f"{kind}.{group}" - crd = api_instance.read_custom_resource_definition(name) + version = self.service_definition.api_definition["version"] + client = self.control_plane.get_kubernetes_client() + + # We have to determine the ``name`` first, via the discovery API + # The official kubernetes client APIs only include the regular discovery API, + # but not the APIGroupDiscoveryList that we actually need, and passing it as + # response_type leads to the client receiving the data, but refusing to return + # it since the model is not defined. Instead, we have to manually + # construct the API call: + content_type = "application/json" + response_type = "APIGroupDiscoveryList" + accept_header = ",".join( + [ + f"{content_type};g=apidiscovery.k8s.io;v={api_version};as={response_type}" + for api_version in ("v2", "v2beta1") + ] + ) + accept_header = f"{accept_header},{content_type}" + groups = client.call_api( + "/apis", + "GET", + header_params={"Accept": accept_header}, + auth_settings=["BearerToken"], + _return_http_data_only=True, + _preload_content=False, + ).json() + api_group = [ + g for g in groups.get("items", []) if g["metadata"]["name"] == group + ][0] + api_version = [v for v in api_group["versions"] if v["version"] == version][0] + resource = [ + r for r in api_version["resources"] if r["responseKind"]["kind"] == kind + ][0] + # This is the thing we went to all that effort for! + name = f"{resource['resource']}.{group}" + + extensions_api = kubernetes.client.ApiextensionsV1Api(client) + crd = extensions_api.read_custom_resource_definition(name) return crd @cached_property def resource_schema(self): - for version in self.resource_definition.spec.versions: - if self.service_definition.api_definition["version"] != version.name: + # We extract the schema directly from the API. As an alternative, you can + # also get it using self.resource_definition, though that requires some + # extra serialization to remove the API client types: + # for version in self.resource_definition.spec.versions: + # if self.service_definition.api_definition["version"] != version.name: + # continue + # if version.schema and version.schema.open_apiv3_schema: + # schema_dict = kubernetes.client.ApiClient().sanitize_for_serialization( + # version.schema.open_apiv3_schema + # ) + # return schema_dict + kind = self.service_definition.api_definition["kind"] + group = self.service_definition.api_definition["group"] + version = self.service_definition.api_definition["version"] + client = self.control_plane.get_kubernetes_client() + response = client.call_api( + f"/openapi/v3/apis/{group}/{version}", + "GET", + header_params={"Accept": "application/json"}, + auth_settings=["BearerToken"], + _return_http_data_only=True, + _preload_content=False, + ).json() + for schema in response["components"]["schemas"].values(): + gkvs = schema.get("x-kubernetes-group-version-kind") + if not gkvs or not isinstance(gkvs, list): continue - if version.schema and version.schema.open_apiv3_schema: - schema_dict = kubernetes.client.ApiClient().sanitize_for_serialization( - version.schema.open_apiv3_schema - ) - return schema_dict + if any( + [ + gkv["group"] == group + and gkv["version"] == version + and gkv["kind"] == kind + for gkv in gkvs + ] + ): + return schema @cached_property def django_model(self): -- 2.47.2 From 751f1ed0b085077035bb0347ac4ab489163cf640 Mon Sep 17 00:00:00 2001 From: Tobias Brunner Date: Wed, 26 Mar 2025 15:18:10 +0100 Subject: [PATCH 10/14] add perfect scrollbar extension for mazer --- .../perfect-scrollbar.min.js | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 src/servala/static/mazer/extensions/perfect-scrollbar/perfect-scrollbar.min.js diff --git a/src/servala/static/mazer/extensions/perfect-scrollbar/perfect-scrollbar.min.js b/src/servala/static/mazer/extensions/perfect-scrollbar/perfect-scrollbar.min.js new file mode 100644 index 0000000..7465c13 --- /dev/null +++ b/src/servala/static/mazer/extensions/perfect-scrollbar/perfect-scrollbar.min.js @@ -0,0 +1,19 @@ +/*! + * perfect-scrollbar v1.5.3 + * Copyright 2021 Hyunje Jun, MDBootstrap and Contributors + * Licensed under MIT + */(function(a,b){"object"==typeof exports&&"undefined"!=typeof module?module.exports=b():"function"==typeof define&&define.amd?define(b):(a=a||self,a.PerfectScrollbar=b())})(this,function(){'use strict';var u=Math.abs,v=Math.floor;function a(a){return getComputedStyle(a)}function b(a,b){for(var c in b){var d=b[c];"number"==typeof d&&(d+="px"),a.style[c]=d}return a}function c(a){var b=document.createElement("div");return b.className=a,b}function d(a,b){if(!w)throw new Error("No element matching method supported");return w.call(a,b)}function e(a){a.remove?a.remove():a.parentNode&&a.parentNode.removeChild(a)}function f(a,b){return Array.prototype.filter.call(a.children,function(a){return d(a,b)})}function g(a,b){var c=a.element.classList,d=z.state.scrolling(b);c.contains(d)?clearTimeout(A[b]):c.add(d)}function h(a,b){A[b]=setTimeout(function(){return a.isAlive&&a.element.classList.remove(z.state.scrolling(b))},a.settings.scrollingThreshold)}function j(a,b){g(a,b),h(a,b)}function k(a){if("function"==typeof window.CustomEvent)return new CustomEvent(a);var b=document.createEvent("CustomEvent");return b.initCustomEvent(a,!1,!1,void 0),b}function l(a,b,c,d,e){void 0===d&&(d=!0),void 0===e&&(e=!1);var f;if("top"===b)f=["contentHeight","containerHeight","scrollTop","y","up","down"];else if("left"===b)f=["contentWidth","containerWidth","scrollLeft","x","left","right"];else throw new Error("A proper axis should be provided");m(a,c,f,d,e)}function m(a,b,c,d,e){var f=c[0],g=c[1],h=c[2],i=c[3],l=c[4],m=c[5];void 0===d&&(d=!0),void 0===e&&(e=!1);var n=a.element;// reset reach +a.reach[i]=null,1>n[h]&&(a.reach[i]="start"),n[h]>a[f]-a[g]-1&&(a.reach[i]="end"),b&&(n.dispatchEvent(k("ps-scroll-"+i)),0>b?n.dispatchEvent(k("ps-scroll-"+l)):0=a.railXWidth-a.scrollbarXWidth&&(a.scrollbarXLeft=a.railXWidth-a.scrollbarXWidth),a.scrollbarYTop>=a.railYHeight-a.scrollbarYHeight&&(a.scrollbarYTop=a.railYHeight-a.scrollbarYHeight),s(c,a),a.scrollbarXActive?c.classList.add(z.state.active("x")):(c.classList.remove(z.state.active("x")),a.scrollbarXWidth=0,a.scrollbarXLeft=0,c.scrollLeft=!0===a.isRtl?a.contentWidth:0),a.scrollbarYActive?c.classList.add(z.state.active("y")):(c.classList.remove(z.state.active("y")),a.scrollbarYHeight=0,a.scrollbarYTop=0,c.scrollTop=0)}function r(a,b){var c=Math.min,d=Math.max;return a.settings.minScrollbarLength&&(b=d(b,a.settings.minScrollbarLength)),a.settings.maxScrollbarLength&&(b=c(b,a.settings.maxScrollbarLength)),b}function s(a,c){var d={width:c.railXWidth},e=v(a.scrollTop);d.left=c.isRtl?c.negativeScrollAdjustment+a.scrollLeft+c.containerWidth-c.contentWidth:a.scrollLeft,c.isScrollbarXUsingBottom?d.bottom=c.scrollbarXBottom-e:d.top=c.scrollbarXTop+e,b(c.scrollbarXRail,d);var f={top:e,height:c.railYHeight};c.isScrollbarYUsingRight?c.isRtl?f.right=c.contentWidth-(c.negativeScrollAdjustment+a.scrollLeft)-c.scrollbarYRight-c.scrollbarYOuterWidth-9:f.right=c.scrollbarYRight-a.scrollLeft:c.isRtl?f.left=c.negativeScrollAdjustment+a.scrollLeft+2*c.containerWidth-c.contentWidth-c.scrollbarYLeft-c.scrollbarYOuterWidth:f.left=c.scrollbarYLeft+a.scrollLeft,b(c.scrollbarYRail,f),b(c.scrollbarX,{left:c.scrollbarXLeft,width:c.scrollbarXWidth-c.railBorderXWidth}),b(c.scrollbarY,{top:c.scrollbarYTop,height:c.scrollbarYHeight-c.railBorderYWidth})}function t(a,b){function c(b){b.touches&&b.touches[0]&&(b[k]=b.touches[0].pageY),s[o]=t+v*(b[k]-u),g(a,p),q(a),b.stopPropagation(),b.type.startsWith("touch")&&1a.scrollbarYTop?1:-1;a.element.scrollTop+=d*a.containerHeight,q(a),b.stopPropagation()}),a.event.bind(a.scrollbarX,"mousedown",function(a){return a.stopPropagation()}),a.event.bind(a.scrollbarXRail,"mousedown",function(b){var c=b.pageX-window.pageXOffset-a.scrollbarXRail.getBoundingClientRect().left,d=c>a.scrollbarXLeft?1:-1;a.element.scrollLeft+=d*a.containerWidth,q(a),b.stopPropagation()})},"drag-thumb":function(a){t(a,["containerWidth","contentWidth","pageX","railXWidth","scrollbarX","scrollbarXWidth","scrollLeft","x","scrollbarXRail"]),t(a,["containerHeight","contentHeight","pageY","railYHeight","scrollbarY","scrollbarYHeight","scrollTop","y","scrollbarYRail"])},keyboard:function(a){function b(b,d){var e=v(c.scrollTop);if(0===b){if(!a.scrollbarYActive)return!1;if(0===e&&0=a.contentHeight-a.containerHeight&&0>d)return!a.settings.wheelPropagation}var f=c.scrollLeft;if(0===d){if(!a.scrollbarXActive)return!1;if(0===f&&0>b||f>=a.contentWidth-a.containerWidth&&0u(a)?f||g:i||j,!d||!b.settings.wheelPropagation}function d(a){var b=a.deltaX,c=-1*a.deltaY;return("undefined"==typeof b||"undefined"==typeof c)&&(b=-1*a.wheelDeltaX/6,c=a.wheelDeltaY/6),a.deltaMode&&1===a.deltaMode&&(b*=10,c*=10),b!==b&&c!==c/* NaN checks */&&(b=0,c=a.wheelDelta),a.shiftKey?[-c,-b]:[b,c]}function f(b,c,d){// FIXME: this is a workaround for