Compare commits
No commits in common. "4bb52cda4fc99a49125a4a609dbce8362ded8037" and "9830eebcdae7d208c5ddece7c4ace275a1cd56a9" have entirely different histories.
4bb52cda4f
...
9830eebcda
9 changed files with 23 additions and 246 deletions
|
@ -173,11 +173,6 @@ class CrdModelFormMixin:
|
||||||
for field in ("organization", "context"):
|
for field in ("organization", "context"):
|
||||||
self.fields[field].widget = forms.HiddenInput()
|
self.fields[field].widget = forms.HiddenInput()
|
||||||
|
|
||||||
if self.instance and self.instance.pk:
|
|
||||||
self.fields["name"].disabled = True
|
|
||||||
self.fields["name"].help_text = _("Name cannot be changed after creation.")
|
|
||||||
self.fields["name"].widget = forms.HiddenInput()
|
|
||||||
|
|
||||||
def strip_title(self, field_name, label):
|
def strip_title(self, field_name, label):
|
||||||
field = self.fields[field_name]
|
field = self.fields[field_name]
|
||||||
if field and field.label.startswith(label):
|
if field and field.label.startswith(label):
|
||||||
|
@ -188,10 +183,7 @@ class CrdModelFormMixin:
|
||||||
|
|
||||||
# General fieldset for non-spec fields
|
# General fieldset for non-spec fields
|
||||||
general_fields = [
|
general_fields = [
|
||||||
field_name
|
field for field in self.fields if not field.startswith("spec.")
|
||||||
for field_name, field in self.fields.items()
|
|
||||||
if not field_name.startswith("spec.")
|
|
||||||
and not isinstance(field.widget, forms.HiddenInput)
|
|
||||||
]
|
]
|
||||||
if general_fields:
|
if general_fields:
|
||||||
fieldsets.append(
|
fieldsets.append(
|
||||||
|
|
|
@ -188,10 +188,6 @@ class ControlPlane(ServalaModelMixin, models.Model):
|
||||||
def get_kubernetes_client(self):
|
def get_kubernetes_client(self):
|
||||||
return kubernetes.client.ApiClient(self.kubernetes_config)
|
return kubernetes.client.ApiClient(self.kubernetes_config)
|
||||||
|
|
||||||
@cached_property
|
|
||||||
def custom_objects_api(self):
|
|
||||||
return client.CustomObjectsApi(self.get_kubernetes_client())
|
|
||||||
|
|
||||||
def test_connection(self):
|
def test_connection(self):
|
||||||
if not self.api_credentials:
|
if not self.api_credentials:
|
||||||
return False, _("No API credentials provided")
|
return False, _("No API credentials provided")
|
||||||
|
@ -513,19 +509,6 @@ class ServiceInstance(ServalaModelMixin, models.Model):
|
||||||
|
|
||||||
class urls(urlman.Urls):
|
class urls(urlman.Urls):
|
||||||
base = "{self.organization.urls.instances}{self.name}/"
|
base = "{self.organization.urls.instances}{self.name}/"
|
||||||
update = "{base}update/"
|
|
||||||
|
|
||||||
def _clear_kubernetes_caches(self):
|
|
||||||
"""Clears cached properties that depend on Kubernetes state."""
|
|
||||||
attrs = self.__dict__.keys()
|
|
||||||
for key in (
|
|
||||||
"kubernetes_object",
|
|
||||||
"spec",
|
|
||||||
"status_conditions",
|
|
||||||
"connection_credentials",
|
|
||||||
):
|
|
||||||
if key in attrs:
|
|
||||||
delattr(self, key)
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def create_instance(cls, name, organization, context, created_by, spec_data):
|
def create_instance(cls, name, organization, context, created_by, spec_data):
|
||||||
|
@ -557,7 +540,9 @@ class ServiceInstance(ServalaModelMixin, models.Model):
|
||||||
}
|
}
|
||||||
if label := context.control_plane.required_label:
|
if label := context.control_plane.required_label:
|
||||||
create_data["metadata"]["labels"] = {settings.DEFAULT_LABEL_KEY: label}
|
create_data["metadata"]["labels"] = {settings.DEFAULT_LABEL_KEY: label}
|
||||||
api_instance = context.control_plane.custom_objects_api
|
api_instance = client.CustomObjectsApi(
|
||||||
|
context.control_plane.get_kubernetes_client()
|
||||||
|
)
|
||||||
api_instance.create_namespaced_custom_object(
|
api_instance.create_namespaced_custom_object(
|
||||||
group=context.group,
|
group=context.group,
|
||||||
version=context.version,
|
version=context.version,
|
||||||
|
@ -577,47 +562,6 @@ class ServiceInstance(ServalaModelMixin, models.Model):
|
||||||
raise ValidationError(_("Error creating instance: {}").format(str(e)))
|
raise ValidationError(_("Error creating instance: {}").format(str(e)))
|
||||||
return instance
|
return instance
|
||||||
|
|
||||||
def update_spec(self, spec_data, updated_by):
|
|
||||||
try:
|
|
||||||
api_instance = self.context.control_plane.custom_objects_api
|
|
||||||
patch_body = {"spec": spec_data}
|
|
||||||
|
|
||||||
api_instance.patch_namespaced_custom_object(
|
|
||||||
group=self.context.group,
|
|
||||||
version=self.context.version,
|
|
||||||
namespace=self.organization.namespace,
|
|
||||||
plural=self.context.kind_plural,
|
|
||||||
name=self.name,
|
|
||||||
body=patch_body,
|
|
||||||
)
|
|
||||||
self._clear_kubernetes_caches()
|
|
||||||
self.save() # Updates updated_at timestamp
|
|
||||||
except ApiException as e:
|
|
||||||
if e.status == 404:
|
|
||||||
raise ValidationError(
|
|
||||||
_(
|
|
||||||
"Service instance not found in Kubernetes. It may have been deleted externally."
|
|
||||||
)
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
error_body = json.loads(e.body)
|
|
||||||
reason = error_body.get("message", str(e))
|
|
||||||
raise ValidationError(
|
|
||||||
_("Kubernetes API error updating instance: {error}").format(
|
|
||||||
error=reason
|
|
||||||
)
|
|
||||||
)
|
|
||||||
except (ValueError, TypeError):
|
|
||||||
raise ValidationError(
|
|
||||||
_("Kubernetes API error updating instance: {error}").format(
|
|
||||||
error=str(e)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
raise ValidationError(
|
|
||||||
_("Error updating instance: {error}").format(error=str(e))
|
|
||||||
)
|
|
||||||
|
|
||||||
@cached_property
|
@cached_property
|
||||||
def kubernetes_object(self):
|
def kubernetes_object(self):
|
||||||
"""Fetch the Kubernetes custom resource object"""
|
"""Fetch the Kubernetes custom resource object"""
|
||||||
|
@ -651,20 +595,6 @@ class ServiceInstance(ServalaModelMixin, models.Model):
|
||||||
spec.pop("writeConnectionSecretToRef", None)
|
spec.pop("writeConnectionSecretToRef", None)
|
||||||
return spec
|
return spec
|
||||||
|
|
||||||
@cached_property
|
|
||||||
def spec_object(self):
|
|
||||||
"""Dynamically generated CRD object."""
|
|
||||||
return self.context.django_model(
|
|
||||||
name=self.name,
|
|
||||||
organization=self.organization,
|
|
||||||
context=self.context,
|
|
||||||
spec=self.spec,
|
|
||||||
# We pass -1 as ID in order to make it clear that a) this object exists (remotely),
|
|
||||||
# and b) it’s not a normal database object. This allows us to treat e.g. update
|
|
||||||
# forms differently from create forms.
|
|
||||||
pk=-1,
|
|
||||||
)
|
|
||||||
|
|
||||||
@cached_property
|
@cached_property
|
||||||
def status_conditions(self):
|
def status_conditions(self):
|
||||||
if not self.kubernetes_object:
|
if not self.kubernetes_object:
|
||||||
|
|
|
@ -25,13 +25,9 @@
|
||||||
<div class="content-wrapper container">
|
<div class="content-wrapper container">
|
||||||
<div class="page-heading">
|
<div class="page-heading">
|
||||||
<h3>
|
<h3>
|
||||||
<span>
|
{% block page_title %}
|
||||||
{% block page_title %}
|
Dashboard
|
||||||
Dashboard
|
{% endblock page_title %}
|
||||||
{% endblock page_title %}
|
|
||||||
</span>
|
|
||||||
{% block page_title_extra %}
|
|
||||||
{% endblock page_title_extra %}
|
|
||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
<div class="page-content">
|
<div class="page-content">
|
||||||
|
|
|
@ -5,11 +5,6 @@
|
||||||
{{ instance.name }}
|
{{ instance.name }}
|
||||||
{% endblock page_title %}
|
{% endblock page_title %}
|
||||||
{% endblock html_title %}
|
{% endblock html_title %}
|
||||||
{% block page_title_extra %}
|
|
||||||
{% if has_change_permission %}
|
|
||||||
<a href="{{ instance.urls.update }}" class="btn btn-primary me-1 mb-1">{% translate "Edit" %}</a>
|
|
||||||
{% endif %}
|
|
||||||
{% endblock page_title_extra %}
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<section class="section">
|
<section class="section">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
|
|
|
@ -1,42 +0,0 @@
|
||||||
{% extends "frontend/base.html" %}
|
|
||||||
{% load i18n %}
|
|
||||||
{% load static %}
|
|
||||||
{% load partials %}
|
|
||||||
{% block html_title %}
|
|
||||||
{% block page_title %}
|
|
||||||
{% block title %}
|
|
||||||
{% blocktranslate with instance_name=instance.name organization_name=request.organization.name %}Update {{ instance_name }} in {{ organization_name }}{% endblocktranslate %}
|
|
||||||
{% endblock %}
|
|
||||||
{% endblock page_title %}
|
|
||||||
{% endblock html_title %}
|
|
||||||
{% block page_title_extra %}
|
|
||||||
<a href="{{ instance.urls.base }}" class="btn btn-secondary me-1 mb-1">{% translate "Back" %}</a>
|
|
||||||
{% endblock page_title_extra %}
|
|
||||||
{% partialdef service-form %}
|
|
||||||
{% if form %}
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-header d-flex align-items-center"></div>
|
|
||||||
<div class="card-body">
|
|
||||||
{% if form_error %}
|
|
||||||
<div class="alert alert-danger">
|
|
||||||
{% translate "Oops! Something went wrong with the service form generation. Please try again later." %}
|
|
||||||
</div>
|
|
||||||
{% else %}
|
|
||||||
{% include "includes/tabbed_fieldset_form.html" with form=form %}
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
{% endpartialdef %}
|
|
||||||
{% block content %}
|
|
||||||
<section class="section">
|
|
||||||
<div class="card">
|
|
||||||
{% if not form %}
|
|
||||||
<div class="alert alert-warning" role="alert">
|
|
||||||
{% translate "Cannot update this service instance because its details could not be retrieved from the underlying system. It might have been deleted externally." %}
|
|
||||||
</div>
|
|
||||||
{% else %}
|
|
||||||
<div id="service-form">{% partial service-form %}</div>
|
|
||||||
{% endif %}
|
|
||||||
</section>
|
|
||||||
{% endblock %}
|
|
|
@ -50,11 +50,6 @@ urlpatterns = [
|
||||||
views.ServiceInstanceDetailView.as_view(),
|
views.ServiceInstanceDetailView.as_view(),
|
||||||
name="organization.instance",
|
name="organization.instance",
|
||||||
),
|
),
|
||||||
path(
|
|
||||||
"instances/<slug:slug>/update/",
|
|
||||||
views.ServiceInstanceUpdateView.as_view(),
|
|
||||||
name="organization.instance.update",
|
|
||||||
),
|
|
||||||
]
|
]
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
|
@ -9,7 +9,6 @@ from .service import (
|
||||||
ServiceDetailView,
|
ServiceDetailView,
|
||||||
ServiceInstanceDetailView,
|
ServiceInstanceDetailView,
|
||||||
ServiceInstanceListView,
|
ServiceInstanceListView,
|
||||||
ServiceInstanceUpdateView,
|
|
||||||
ServiceListView,
|
ServiceListView,
|
||||||
ServiceOfferingDetailView,
|
ServiceOfferingDetailView,
|
||||||
)
|
)
|
||||||
|
@ -23,7 +22,6 @@ __all__ = [
|
||||||
"ServiceDetailView",
|
"ServiceDetailView",
|
||||||
"ServiceInstanceDetailView",
|
"ServiceInstanceDetailView",
|
||||||
"ServiceInstanceListView",
|
"ServiceInstanceListView",
|
||||||
"ServiceInstanceUpdateView",
|
|
||||||
"ServiceListView",
|
"ServiceListView",
|
||||||
"ServiceOfferingDetailView",
|
"ServiceOfferingDetailView",
|
||||||
"ProfileView",
|
"ProfileView",
|
||||||
|
|
|
@ -2,7 +2,6 @@ from django.contrib import messages
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
from django.shortcuts import redirect
|
from django.shortcuts import redirect
|
||||||
from django.utils.functional import cached_property
|
from django.utils.functional import cached_property
|
||||||
from django.utils.translation import gettext_lazy as _
|
|
||||||
from django.views.generic import DetailView, ListView
|
from django.views.generic import DetailView, ListView
|
||||||
|
|
||||||
from servala.core.crd import deslugify
|
from servala.core.crd import deslugify
|
||||||
|
@ -17,11 +16,7 @@ from servala.frontend.forms.service import (
|
||||||
ServiceFilterForm,
|
ServiceFilterForm,
|
||||||
ServiceInstanceFilterForm,
|
ServiceInstanceFilterForm,
|
||||||
)
|
)
|
||||||
from servala.frontend.views.mixins import (
|
from servala.frontend.views.mixins import HtmxViewMixin, OrganizationViewMixin
|
||||||
HtmxUpdateView,
|
|
||||||
HtmxViewMixin,
|
|
||||||
OrganizationViewMixin,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class ServiceListView(OrganizationViewMixin, ListView):
|
class ServiceListView(OrganizationViewMixin, ListView):
|
||||||
|
@ -110,8 +105,6 @@ class ServiceOfferingDetailView(OrganizationViewMixin, HtmxViewMixin, DetailView
|
||||||
).first()
|
).first()
|
||||||
|
|
||||||
def get_instance_form(self):
|
def get_instance_form(self):
|
||||||
if not self.context_object:
|
|
||||||
return None
|
|
||||||
return self.context_object.model_form_class(
|
return self.context_object.model_form_class(
|
||||||
data=self.request.POST if self.request.method == "POST" else None,
|
data=self.request.POST if self.request.method == "POST" else None,
|
||||||
initial={
|
initial={
|
||||||
|
@ -137,10 +130,6 @@ class ServiceOfferingDetailView(OrganizationViewMixin, HtmxViewMixin, DetailView
|
||||||
return self.render_to_response(context)
|
return self.render_to_response(context)
|
||||||
|
|
||||||
form = self.get_instance_form()
|
form = self.get_instance_form()
|
||||||
if not form: # Should not happen if context_object is valid, but as a safeguard
|
|
||||||
messages.error(self.request, _("Could not initialize service form."))
|
|
||||||
return self.render_to_response(context)
|
|
||||||
|
|
||||||
if form.is_valid():
|
if form.is_valid():
|
||||||
try:
|
try:
|
||||||
service_instance = ServiceInstance.create_instance(
|
service_instance = ServiceInstance.create_instance(
|
||||||
|
@ -154,23 +143,21 @@ class ServiceOfferingDetailView(OrganizationViewMixin, HtmxViewMixin, DetailView
|
||||||
except ValidationError as e:
|
except ValidationError as e:
|
||||||
messages.error(self.request, e.message or str(e))
|
messages.error(self.request, e.message or str(e))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
messages.error(
|
messages.error(self.request, str(e))
|
||||||
self.request, _("Error creating instance: {}").format(str(e))
|
|
||||||
)
|
|
||||||
|
|
||||||
# If the form is not valid or if the service creation failed, we render it again
|
# If the form is not valid or if the service creation failed, we render it again
|
||||||
context["service_form"] = form
|
context["service_form"] = form
|
||||||
return self.render_to_response(context)
|
return self.render_to_response(context)
|
||||||
|
|
||||||
|
|
||||||
class ServiceInstanceMixin:
|
class ServiceInstanceDetailView(OrganizationViewMixin, DetailView):
|
||||||
model = ServiceInstance
|
"""View to display details of a specific service instance."""
|
||||||
context_object_name = "instance"
|
|
||||||
slug_field = "name"
|
|
||||||
|
|
||||||
def dispatch(self, *args, **kwargs):
|
template_name = "frontend/organizations/service_instance_detail.html"
|
||||||
self._has_warned = False
|
context_object_name = "instance"
|
||||||
return super().dispatch(*args, **kwargs)
|
model = ServiceInstance
|
||||||
|
permission_type = "view"
|
||||||
|
slug_field = "name"
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
"""Return service instance for the current organization."""
|
"""Return service instance for the current organization."""
|
||||||
|
@ -182,39 +169,6 @@ class ServiceInstanceMixin:
|
||||||
"context__service_definition__service",
|
"context__service_definition__service",
|
||||||
)
|
)
|
||||||
|
|
||||||
def get_object(self, **kwargs):
|
|
||||||
instance = super().get_object(**kwargs)
|
|
||||||
if (
|
|
||||||
not instance.kubernetes_object
|
|
||||||
and not instance.is_deleted
|
|
||||||
and not self._has_warned
|
|
||||||
):
|
|
||||||
messages.warning(
|
|
||||||
self.request,
|
|
||||||
_(
|
|
||||||
"Could not retrieve instance details from Kubernetes. It might have been deleted externally."
|
|
||||||
),
|
|
||||||
)
|
|
||||||
self._has_warned = True
|
|
||||||
return instance
|
|
||||||
|
|
||||||
|
|
||||||
class ServiceInstanceDetailView(
|
|
||||||
ServiceInstanceMixin, OrganizationViewMixin, DetailView
|
|
||||||
):
|
|
||||||
template_name = "frontend/organizations/service_instance_detail.html"
|
|
||||||
permission_type = "view"
|
|
||||||
|
|
||||||
def get_context_data(self, **kwargs):
|
|
||||||
context = super().get_context_data(**kwargs)
|
|
||||||
if self.object.kubernetes_object and self.object.spec:
|
|
||||||
context["spec_fieldsets"] = self.get_nested_spec()
|
|
||||||
permission_required = ServiceInstance.get_perm("change")
|
|
||||||
context["has_change_permission"] = self.request.user.has_perm(
|
|
||||||
permission_required, self.object
|
|
||||||
)
|
|
||||||
return context
|
|
||||||
|
|
||||||
def get_nested_spec(self):
|
def get_nested_spec(self):
|
||||||
"""
|
"""
|
||||||
Organize spec data into fieldsets similar to how the form does it.
|
Organize spec data into fieldsets similar to how the form does it.
|
||||||
|
@ -318,43 +272,12 @@ class ServiceInstanceDetailView(
|
||||||
|
|
||||||
return fieldsets
|
return fieldsets
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
class ServiceInstanceUpdateView(
|
"""Return service instance for the current organization."""
|
||||||
ServiceInstanceMixin, OrganizationViewMixin, HtmxUpdateView
|
context = super().get_context_data(**kwargs)
|
||||||
):
|
if self.object.spec:
|
||||||
template_name = "frontend/organizations/service_instance_update.html"
|
context["spec_fieldsets"] = self.get_nested_spec()
|
||||||
permission_type = "change"
|
return context
|
||||||
|
|
||||||
def get_form_class(self):
|
|
||||||
return self.object.context.model_form_class
|
|
||||||
|
|
||||||
def get_form_kwargs(self):
|
|
||||||
kwargs = super().get_form_kwargs()
|
|
||||||
kwargs["instance"] = self.object.spec_object
|
|
||||||
return kwargs
|
|
||||||
|
|
||||||
def form_valid(self, form):
|
|
||||||
try:
|
|
||||||
spec_data = form.get_nested_data().get("spec")
|
|
||||||
self.object.update_spec(spec_data=spec_data, updated_by=self.request.user)
|
|
||||||
messages.success(
|
|
||||||
self.request,
|
|
||||||
_("Service instance '{name}' updated successfully.").format(
|
|
||||||
name=self.object.name
|
|
||||||
),
|
|
||||||
)
|
|
||||||
return redirect(self.object.urls.base)
|
|
||||||
except ValidationError as e:
|
|
||||||
messages.error(self.request, e.message or str(e))
|
|
||||||
return self.form_invalid(form)
|
|
||||||
except Exception as e:
|
|
||||||
messages.error(
|
|
||||||
self.request, _("Error updating instance: {error}").format(error=str(e))
|
|
||||||
)
|
|
||||||
return self.form_invalid(form)
|
|
||||||
|
|
||||||
def get_success_url(self):
|
|
||||||
return self.object.urls.base
|
|
||||||
|
|
||||||
|
|
||||||
class ServiceInstanceListView(OrganizationViewMixin, ListView):
|
class ServiceInstanceListView(OrganizationViewMixin, ListView):
|
||||||
|
@ -370,12 +293,7 @@ class ServiceInstanceListView(OrganizationViewMixin, ListView):
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
"""Return all service instances for the current organization with filtering."""
|
"""Return all service instances for the current organization with filtering."""
|
||||||
queryset = ServiceInstance.objects.filter(
|
queryset = ServiceInstance.objects.filter(
|
||||||
organization=self.request.organization,
|
organization=self.request.organization
|
||||||
is_deleted=False, # Exclude soft-deleted
|
|
||||||
).select_related(
|
|
||||||
"context__service_offering__provider",
|
|
||||||
"context__control_plane",
|
|
||||||
"context__service_definition__service",
|
|
||||||
)
|
)
|
||||||
if self.filter_form.is_valid():
|
if self.filter_form.is_valid():
|
||||||
queryset = self.filter_form.filter_queryset(queryset)
|
queryset = self.filter_form.filter_queryset(queryset)
|
||||||
|
|
|
@ -85,8 +85,3 @@ html[data-bs-theme="dark"] .btn-outline-primary, .btn-outline-primary {
|
||||||
a.btn-keycloak {
|
a.btn-keycloak {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
}
|
}
|
||||||
.page-heading h3 {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
justify-content: space-between;
|
|
||||||
}
|
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue