diff --git a/src/servala/core/models/service.py b/src/servala/core/models/service.py index ba57c9c..901a822 100644 --- a/src/servala/core/models/service.py +++ b/src/servala/core/models/service.py @@ -1,4 +1,3 @@ -import copy import json import kubernetes @@ -7,8 +6,7 @@ import urlman from django.conf import settings from django.core.cache import cache from django.core.exceptions import ValidationError -from django.db import IntegrityError, models, transaction -from django.utils import timezone +from django.db import IntegrityError, models from django.utils.functional import cached_property from django.utils.translation import gettext_lazy as _ from encrypted_fields.fields import EncryptedJSONField @@ -375,15 +373,7 @@ class ControlPlaneCRD(ServalaModelMixin, models.Model): @cached_property def kind_plural(self): - if ( - hasattr(self.resource_definition, "status") - and hasattr(self.resource_definition.status, "accepted_names") - and self.resource_definition.status.accepted_names - ): - return self.resource_definition.status.accepted_names.plural - if self.kind.endswith("s"): - return self.kind.lower() - return f"{self.kind.lower()}s" + return self.resource_definition.status.accepted_names.plural @cached_property def resource_definition(self): @@ -409,13 +399,8 @@ class ControlPlaneCRD(ServalaModelMixin, models.Model): if result := cache.get(cache_key): return result - if not self.resource_definition: - return - for v in self.resource_definition.spec.versions: if v.name == self.version: - if not v.schema or not v.schema.open_apiv3_schema: - return result = v.schema.open_apiv3_schema.to_dict() timeout_seconds = 60 * 60 * 24 cache.set(cache_key, result, timeout=timeout_seconds) @@ -425,9 +410,6 @@ class ControlPlaneCRD(ServalaModelMixin, models.Model): def django_model(self): from servala.core.crd import generate_django_model - if not self.resource_schema: - return - kwargs = { "group": self.group, "version": self.version, @@ -439,8 +421,6 @@ class ControlPlaneCRD(ServalaModelMixin, models.Model): def model_form_class(self): from servala.core.crd import generate_model_form_class - if not self.django_model: - return return generate_model_form_class(self.django_model) @@ -534,7 +514,6 @@ class ServiceInstance(ServalaModelMixin, models.Model): class urls(urlman.Urls): base = "{self.organization.urls.instances}{self.name}/" update = "{base}update/" - delete = "{base}delete/" def _clear_kubernetes_caches(self): """Clears cached properties that depend on Kubernetes state.""" @@ -639,51 +618,9 @@ class ServiceInstance(ServalaModelMixin, models.Model): _("Error updating instance: {error}").format(error=str(e)) ) - @transaction.atomic - def delete_instance(self, user): - """ - Soft deletes the instance in Django and initiates deletion of the - corresponding Kubernetes custom resource. - """ - if self.is_deleted: - return - - if ( - self.spec.get("parameters", {}) - .get("security", {}) - .get("deletionProtection") - ): - spec = copy.copy(self.spec) - spec["parameters"]["security"]["deletionProtection"] = False - self.update_spec(spec, user) - - try: - api_instance = self.context.control_plane.custom_objects_api - api_instance.delete_namespaced_custom_object( - group=self.context.group, - version=self.context.version, - namespace=self.organization.namespace, - plural=self.context.kind_plural, - name=self.name, - body=client.V1DeleteOptions(), - ) - except ApiException as e: - if e.status != 404: - # 404 is fine, the object was deleted already. - raise - self.is_deleted = True - self.deleted_at = timezone.now() - self.deleted_by = user - self.save( - update_fields=["is_deleted", "deleted_at", "deleted_by", "updated_at"] - ) - self._clear_kubernetes_caches() - @cached_property def kubernetes_object(self): """Fetch the Kubernetes custom resource object""" - if self.is_deleted: - return try: api_instance = client.CustomObjectsApi( self.context.control_plane.get_kubernetes_client() @@ -717,8 +654,6 @@ class ServiceInstance(ServalaModelMixin, models.Model): @cached_property def spec_object(self): """Dynamically generated CRD object.""" - if not self.context.django_model: - return return self.context.django_model( name=self.name, organization=self.organization, diff --git a/src/servala/frontend/forms/service.py b/src/servala/frontend/forms/service.py index 04fe2df..1b134a2 100644 --- a/src/servala/frontend/forms/service.py +++ b/src/servala/frontend/forms/service.py @@ -6,7 +6,6 @@ from servala.core.models import ( ControlPlane, Service, ServiceCategory, - ServiceInstance, ServiceOffering, ) @@ -38,8 +37,6 @@ class ControlPlaneSelectForm(forms.Form): def __init__(self, *args, planes=None, **kwargs): super().__init__(*args, **kwargs) self.fields["control_plane"].queryset = planes - if planes and planes.count() == 1: - self.fields["control_plane"].initial = planes.first() class ServiceInstanceFilterForm(forms.Form): @@ -71,50 +68,21 @@ class ServiceInstanceFilterForm(forms.Form): def filter_queryset(self, queryset): if self.is_valid(): data = self.cleaned_data - if data.get("name"): + if data["name"]: queryset = queryset.filter(name__icontains=data["name"]) - if data.get("service"): + if data["service"]: queryset = queryset.filter( context__service_definition__service=data["service"] ) - if data.get("provider"): + if data["provider"]: queryset = queryset.filter( context__service_offering__provider=data["provider"] ) - if data.get("control_plane"): + if data["control_plane"]: queryset = queryset.filter(context__control_plane=data["control_plane"]) - status = data.get("status") - if status == "active": - queryset = queryset.filter(is_deleted=False) - elif status == "deleted": - queryset = queryset.filter(is_deleted=True) + if data["status"]: + if data["status"] == "active": + queryset = queryset.filter(is_deleted=False) + else: + queryset = queryset.filter(is_deleted=True) return queryset - - -class ServiceInstanceDeleteForm(forms.ModelForm): - name = forms.CharField( - label=_("Instance Name"), - max_length=63, - widget=forms.TextInput(attrs={"class": "form-control"}), - ) - - def __init__(self, *args, **kwargs): - kwargs["initial"] = {"name": ""} - super().__init__(*args, **kwargs) - self.fields["name"].help_text = _( - "To confirm deletion, please type the instance name: {instance_name}" - ).format(instance_name=self.instance.name) - - def clean_name(self): - entered_name = self.cleaned_data.get("name") - if entered_name != self.instance.name: - raise forms.ValidationError( - _( - "The entered name does not match the instance name. Deletion not confirmed." - ) - ) - return entered_name - - class Meta: - model = ServiceInstance - fields = ("name",) diff --git a/src/servala/frontend/templates/frontend/forms/form.html b/src/servala/frontend/templates/frontend/forms/form.html index 8ab90d1..b45c41f 100644 --- a/src/servala/frontend/templates/frontend/forms/form.html +++ b/src/servala/frontend/templates/frontend/forms/form.html @@ -1,6 +1,6 @@ {% load i18n %} {% if form.non_field_errors or form.errors %} - - {% if not instance.is_deleted and instance.status_conditions %} + {% if instance.status_conditions %}
@@ -105,9 +88,9 @@ {{ condition.status }} {% endif %} - {{ condition.lastTransitionTime|date:"SHORT_DATETIME_FORMAT" }} - {{ condition.reason|default:"-" }} - {{ condition.message|truncatewords:20|default:"-" }} + {{ condition.lastTransitionTime }} + {{ condition.reason }} + {{ condition.message }} {% endfor %} @@ -118,7 +101,7 @@
{% endif %} - {% if not instance.is_deleted and instance.spec and spec_fieldsets %} + {% if instance.spec %}
@@ -136,8 +119,6 @@ data-bs-toggle="tab" role="tab">{{ fieldset.title }} - {% empty %} - {% endfor %} @@ -154,7 +135,7 @@ {% if field.value|default:""|stringformat:"s"|slice:":1" == "{" or field.value|default:""|stringformat:"s"|slice:":1" == "[" %}
{{ field.value|pprint }}
{% else %} - {{ field.value|default:"-" }} + {{ field.value }} {% endif %} {% endfor %} @@ -169,15 +150,13 @@ {% if field.value|default:""|stringformat:"s"|slice:":1" == "{" or field.value|default:""|stringformat:"s"|slice:":1" == "[" %}
{{ field.value|pprint }}
{% else %} - {{ field.value|default:"-" }} + {{ field.value }} {% endif %} {% endfor %} {% endfor %}
- {% empty %} -

{% translate "No specification details to display." %}

{% endfor %}
@@ -221,33 +200,4 @@ {% endif %}
- - {% endblock content %} diff --git a/src/servala/frontend/urls.py b/src/servala/frontend/urls.py index 6ab6949..64c84bf 100644 --- a/src/servala/frontend/urls.py +++ b/src/servala/frontend/urls.py @@ -55,11 +55,6 @@ urlpatterns = [ views.ServiceInstanceUpdateView.as_view(), name="organization.instance.update", ), - path( - "instances//delete/", - views.ServiceInstanceDeleteView.as_view(), - name="organization.instance.delete", - ), ] ), ), diff --git a/src/servala/frontend/views/__init__.py b/src/servala/frontend/views/__init__.py index cc43552..d13d4d5 100644 --- a/src/servala/frontend/views/__init__.py +++ b/src/servala/frontend/views/__init__.py @@ -7,7 +7,6 @@ from .organization import ( ) from .service import ( ServiceDetailView, - ServiceInstanceDeleteView, ServiceInstanceDetailView, ServiceInstanceListView, ServiceInstanceUpdateView, @@ -22,7 +21,6 @@ __all__ = [ "OrganizationDashboardView", "OrganizationUpdateView", "ServiceDetailView", - "ServiceInstanceDeleteView", "ServiceInstanceDetailView", "ServiceInstanceListView", "ServiceInstanceUpdateView", diff --git a/src/servala/frontend/views/service.py b/src/servala/frontend/views/service.py index 3bd6060..298d5bc 100644 --- a/src/servala/frontend/views/service.py +++ b/src/servala/frontend/views/service.py @@ -1,10 +1,9 @@ from django.contrib import messages from django.core.exceptions import ValidationError -from django.http import HttpResponse from django.shortcuts import redirect from django.utils.functional import cached_property from django.utils.translation import gettext_lazy as _ -from django.views.generic import DetailView, ListView, UpdateView +from django.views.generic import DetailView, ListView from servala.core.crd import deslugify from servala.core.models import ( @@ -16,7 +15,6 @@ from servala.core.models import ( from servala.frontend.forms.service import ( ControlPlaneSelectForm, ServiceFilterForm, - ServiceInstanceDeleteForm, ServiceInstanceFilterForm, ) from servala.frontend.views.mixins import ( @@ -36,14 +34,10 @@ class ServiceListView(OrganizationViewMixin, ListView): def get_queryset(self): """Return all services.""" - services = ( - Service.objects.all() - .select_related("category") - .prefetch_related("offerings__provider") - ) + services = Service.objects.all().select_related("category") if self.filter_form.is_valid(): services = self.filter_form.filter_queryset(services) - return services.distinct() + return services @cached_property def filter_form(self): @@ -98,7 +92,7 @@ class ServiceOfferingDetailView(OrganizationViewMixin, HtmxViewMixin, DetailView @cached_property def selected_plane(self): - if self.select_form.is_valid() and self.select_form.cleaned_data: + if self.select_form.data and self.select_form.is_valid(): return self.select_form.cleaned_data["control_plane"] field = self.select_form.fields["control_plane"] return field.initial or field.queryset.first() @@ -116,7 +110,7 @@ class ServiceOfferingDetailView(OrganizationViewMixin, HtmxViewMixin, DetailView ).first() def get_instance_form(self): - if not self.context_object or not self.context_object.model_form_class: + if not self.context_object: return None return self.context_object.model_form_class( data=self.request.POST if self.request.method == "POST" else None, @@ -150,7 +144,7 @@ class ServiceOfferingDetailView(OrganizationViewMixin, HtmxViewMixin, DetailView if form.is_valid(): try: service_instance = ServiceInstance.create_instance( - organization=self.request.organization, + organization=self.organization, name=form.cleaned_data["name"], context=self.context_object, created_by=request.user, @@ -191,8 +185,8 @@ class ServiceInstanceMixin: def get_object(self, **kwargs): instance = super().get_object(**kwargs) if ( - not instance.is_deleted - and not instance.kubernetes_object + not instance.kubernetes_object + and not instance.is_deleted and not self._has_warned ): messages.warning( @@ -213,17 +207,11 @@ class ServiceInstanceDetailView( def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) - if ( - not self.object.is_deleted - and self.object.kubernetes_object - and self.object.spec - ): + 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( - ServiceInstance.get_perm("change"), self.object - ) - context["has_delete_permission"] = self.request.user.has_perm( - ServiceInstance.get_perm("delete"), self.object + permission_required, self.object ) return context @@ -232,9 +220,8 @@ class ServiceInstanceDetailView( Organize spec data into fieldsets similar to how the form does it. """ spec = self.object.spec or {} - if not spec: - return [] + # Process spec fields others = [] nested_fieldsets = {} @@ -384,6 +371,7 @@ class ServiceInstanceListView(OrganizationViewMixin, ListView): """Return all service instances for the current organization with filtering.""" queryset = ServiceInstance.objects.filter( organization=self.request.organization, + is_deleted=False, # Exclude soft-deleted ).select_related( "context__service_offering__provider", "context__control_plane", @@ -391,53 +379,10 @@ class ServiceInstanceListView(OrganizationViewMixin, ListView): ) if self.filter_form.is_valid(): queryset = self.filter_form.filter_queryset(queryset) - status_filter = ( - self.filter_form.cleaned_data.get("status") - if self.filter_form.is_valid() - else "active" - ) - if status_filter == "active": - queryset = queryset.filter(is_deleted=False) - elif status_filter == "deleted": - queryset = queryset.filter(is_deleted=True) - return queryset.order_by("-created_at") + return queryset def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) context["organization"] = self.request.organization context["filter_form"] = self.filter_form return context - - -class ServiceInstanceDeleteView( - ServiceInstanceMixin, OrganizationViewMixin, HtmxViewMixin, UpdateView -): - template_name = "frontend/organizations/service_instance_delete_form.html" - form_class = ServiceInstanceDeleteForm - permission_type = "delete" - - def form_valid(self, form): - try: - self.object.delete_instance(user=self.request.user) - messages.success( - self.request, - _("Service instance '{name}' has been scheduled for deletion.").format( - name=self.object.name - ), - ) - response = HttpResponse() - response["HX-Redirect"] = self.get_success_url() - return response - except Exception as e: - messages.error( - self.request, - _( - "An error occurred while trying to delete instance '{name}': {error}" - ).format(name=self.object.name, error=str(e)), - ) - response = HttpResponse() - response["HX-Redirect"] = str(self.object.urls.base) - return response - - def get_success_url(self): - return str(self.request.organization.urls.instances) diff --git a/src/servala/static/css/servala.css b/src/servala/static/css/servala.css index 1e418fb..e607205 100644 --- a/src/servala/static/css/servala.css +++ b/src/servala/static/css/servala.css @@ -90,6 +90,3 @@ a.btn-keycloak { flex-wrap: wrap; justify-content: space-between; } -.hide-form-errors .alert.form-errors { - display: none; -}