diff --git a/README.md b/README.md index 07e1764..ba6fdcd 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,7 @@ Then use ``uv`` to install the project and run its commands while you’re devel ```bash uv sync --dev uv run --env-file=.env src/manage.py migrate +uv run --env-file=.env src/manage.py createcachetable uv run --env-file=.env src/manage.py runserver ``` diff --git a/docker/run.sh b/docker/run.sh index f3c7f9a..bab8a08 100644 --- a/docker/run.sh +++ b/docker/run.sh @@ -8,6 +8,7 @@ export XDG_CONFIG_HOME="/app/config" echo "Applying database migrations" uv run src/manage.py migrate +uv run src/manage.py createcachetable echo "Starting Caddy" exec caddy run --config /app/config/caddy/Caddyfile --adapter caddyfile 2>&1 & diff --git a/src/servala/core/crd.py b/src/servala/core/crd.py new file mode 100644 index 0000000..6fc4219 --- /dev/null +++ b/src/servala/core/crd.py @@ -0,0 +1,127 @@ +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(schema, group, version, kind): + """ + Generates a virtual Django model from a Kubernetes CRD's OpenAPI v3 schema. + """ + spec = schema["properties"].get("spec") or {} + # defaults = {"apiVersion": f"{group}/{version}", "kind": kind} + + model_fields = {"__module__": "crd_models"} + 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 = kind + model_class = type(model_name, (models.Model,), model_fields) + return model_class + + +def build_object_fields(schema, name, verbose_name_prefix=None): + required_fields = schema.get("required") or [] + properties = schema.get("properties") or {} + 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"(? +
+
+ {% 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,18 +40,17 @@
-
- {% 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 not has_control_planes %} +

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

+ {% else %} +
+ {{ select_form }} +
+ {% endif %}
+
{% partial service-form %}
{% endblock content %} 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..00b7edd 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,50 @@ 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 + 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"], + 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() + return context diff --git a/src/servala/settings.py b/src/servala/settings.py index 5d670ae..3e27e35 100644 --- a/src/servala/settings.py +++ b/src/servala/settings.py @@ -132,6 +132,13 @@ STATIC_URL = "static/" # CSS, JavaScript, etc. STATIC_ROOT = BASE_DIR / "static.dist" MEDIA_URL = "media/" # User uploads, e.g. images +CACHES = { + "default": { + "BACKEND": "django.core.cache.backends.db.DatabaseCache", + "LOCATION": "servala_cache", + } +} + # Additional locations of static files STATICFILES_FINDERS = ( "django.contrib.staticfiles.finders.FileSystemFinder", 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