From 7da4b7726731732de4828ea9021d018431d2d692 Mon Sep 17 00:00:00 2001 From: Tobias Kunze Date: Fri, 4 Apr 2025 09:00:20 +0200 Subject: [PATCH 1/8] Add service instance permissions --- src/servala/core/models/service.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/servala/core/models/service.py b/src/servala/core/models/service.py index 41cc896..b2d56e8 100644 --- a/src/servala/core/models/service.py +++ b/src/servala/core/models/service.py @@ -1,6 +1,7 @@ import json import kubernetes +import rules import urlman from django.conf import settings from django.core.cache import cache @@ -12,6 +13,7 @@ from encrypted_fields.fields import EncryptedJSONField from kubernetes import client, config from kubernetes.client.rest import ApiException +from servala.core import rules as perms from servala.core.models.mixins import ServalaModelMixin from servala.core.validators import kubernetes_name_validator @@ -486,6 +488,12 @@ class ServiceInstance(ServalaModelMixin, models.Model): # Names are unique per de-facto namespace, which is defined by the # Organization + ServiceDefinition (group, version) + the ControlPlane. unique_together = [("name", "organization", "context")] + rules_permissions = { + "view": rules.is_staff | perms.is_organization_member, + "change": rules.is_staff | perms.is_organization_member, + "delete": rules.is_staff | perms.is_organization_admin, + "add": rules.is_authenticated, + } class urls(urlman.Urls): base = "{self.organization.urls.instances}{self.name}/" From 2fd28e7afa688dd1cb5d8dc43d2baf370d968a92 Mon Sep 17 00:00:00 2001 From: Tobias Kunze Date: Fri, 4 Apr 2025 11:33:50 +0200 Subject: [PATCH 2/8] Add instance list to sidebar --- src/servala/frontend/templates/includes/sidebar.html | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/servala/frontend/templates/includes/sidebar.html b/src/servala/frontend/templates/includes/sidebar.html index e2a98a2..2da0eba 100644 --- a/src/servala/frontend/templates/includes/sidebar.html +++ b/src/servala/frontend/templates/includes/sidebar.html @@ -116,10 +116,17 @@ {% translate 'Details' %} + + {% endif %} From bef41ac4f013575a4d1d0f4901cf86184e8afbf4 Mon Sep 17 00:00:00 2001 From: Tobias Kunze Date: Fri, 4 Apr 2025 13:56:26 +0200 Subject: [PATCH 3/8] Add very bare service instance list --- .../organizations/service_instances.html | 13 +++++++++++++ src/servala/frontend/urls.py | 5 +++++ src/servala/frontend/views/__init__.py | 8 +++++++- src/servala/frontend/views/service.py | 16 ++++++++++++++++ 4 files changed, 41 insertions(+), 1 deletion(-) create mode 100644 src/servala/frontend/templates/frontend/organizations/service_instances.html diff --git a/src/servala/frontend/templates/frontend/organizations/service_instances.html b/src/servala/frontend/templates/frontend/organizations/service_instances.html new file mode 100644 index 0000000..b09b579 --- /dev/null +++ b/src/servala/frontend/templates/frontend/organizations/service_instances.html @@ -0,0 +1,13 @@ +{% extends "frontend/base.html" %} +{% block content %} +

Service Instances for {{ organization.name }}

+
    + {% for instance in instances %} +
  • + {{ instance.name }} +
  • + {% empty %} +
  • No service instances found.
  • + {% endfor %} +
+{% endblock %} diff --git a/src/servala/frontend/urls.py b/src/servala/frontend/urls.py index d6c7c3a..f02fd1c 100644 --- a/src/servala/frontend/urls.py +++ b/src/servala/frontend/urls.py @@ -40,6 +40,11 @@ urlpatterns = [ views.OrganizationDashboardView.as_view(), name="organization.dashboard", ), + path( + "instances/", + views.ServiceInstanceListView.as_view(), + name="organization.instances", + ), ] ), ), diff --git a/src/servala/frontend/views/__init__.py b/src/servala/frontend/views/__init__.py index 1a180a1..f7d9daa 100644 --- a/src/servala/frontend/views/__init__.py +++ b/src/servala/frontend/views/__init__.py @@ -5,7 +5,12 @@ from .organization import ( OrganizationDashboardView, OrganizationUpdateView, ) -from .service import ServiceDetailView, ServiceListView, ServiceOfferingDetailView +from .service import ( + ServiceDetailView, + ServiceInstanceListView, + ServiceListView, + ServiceOfferingDetailView, +) __all__ = [ "IndexView", @@ -14,6 +19,7 @@ __all__ = [ "OrganizationDashboardView", "OrganizationUpdateView", "ServiceDetailView", + "ServiceInstanceListView", "ServiceListView", "ServiceOfferingDetailView", "ProfileView", diff --git a/src/servala/frontend/views/service.py b/src/servala/frontend/views/service.py index edf889a..c955cde 100644 --- a/src/servala/frontend/views/service.py +++ b/src/servala/frontend/views/service.py @@ -143,3 +143,19 @@ class ServiceOfferingDetailView(OrganizationViewMixin, HtmxViewMixin, DetailView # If the form is not valid or if the service creation failed, we render it again context["service_form"] = form return self.render_to_response(context) + + +class ServiceInstanceListView(OrganizationViewMixin, ListView): + template_name = "frontend/organizations/service_instances.html" + context_object_name = "instances" + model = ServiceInstance + permission_type = "view" + + def get_queryset(self): + """Return all service instances for the current organization.""" + return ServiceInstance.objects.filter(organization=self.request.organization) + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context["organization"] = self.request.organization + return context From 1ebf6a7ce0bdebf7005bc590bb318623f64e1e91 Mon Sep 17 00:00:00 2001 From: Tobias Kunze Date: Fri, 4 Apr 2025 13:59:44 +0200 Subject: [PATCH 4/8] Use card layout in instance list --- .../frontend/organizations/service_instances.html | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/servala/frontend/templates/frontend/organizations/service_instances.html b/src/servala/frontend/templates/frontend/organizations/service_instances.html index b09b579..cc2e835 100644 --- a/src/servala/frontend/templates/frontend/organizations/service_instances.html +++ b/src/servala/frontend/templates/frontend/organizations/service_instances.html @@ -1,6 +1,11 @@ {% extends "frontend/base.html" %} -{% block content %} -

Service Instances for {{ organization.name }}

+{% load i18n static %} +{% block html_title %} + {% block page_title %} + {% translate "Instances" %} + {% endblock page_title %} +{% endblock html_title %} +{% block card_content %}
    {% for instance in instances %}
  • From ab04d9174c1add2a6cd2322a8c6662de86ecb6b1 Mon Sep 17 00:00:00 2001 From: Tobias Kunze Date: Fri, 4 Apr 2025 14:49:57 +0200 Subject: [PATCH 5/8] Improve user display --- src/servala/core/models/user.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/servala/core/models/user.py b/src/servala/core/models/user.py index 8fa3b88..0084593 100644 --- a/src/servala/core/models/user.py +++ b/src/servala/core/models/user.py @@ -69,9 +69,7 @@ class User(ServalaModelMixin, PermissionsMixin, AbstractBaseUser): verbose_name_plural = _("Users") def __str__(self): - if self.first_name and self.last_name: - return f"{self.first_name} {self.last_name}" - return self.email + return f"{self.first_name} {self.last_name}".strip() or self.email def normalize_username(self, username): return super().normalize_username(username).strip().lower() From 23ad1c809b01fccd968a6d6dbb9f5435f3e1e62c Mon Sep 17 00:00:00 2001 From: Tobias Kunze Date: Fri, 4 Apr 2025 16:32:56 +0200 Subject: [PATCH 6/8] Use a table for instance display --- .../organizations/service_instances.html | 45 +++++++++++++++---- 1 file changed, 36 insertions(+), 9 deletions(-) diff --git a/src/servala/frontend/templates/frontend/organizations/service_instances.html b/src/servala/frontend/templates/frontend/organizations/service_instances.html index cc2e835..d335049 100644 --- a/src/servala/frontend/templates/frontend/organizations/service_instances.html +++ b/src/servala/frontend/templates/frontend/organizations/service_instances.html @@ -6,13 +6,40 @@ {% endblock page_title %} {% endblock html_title %} {% block card_content %} -
      - {% for instance in instances %} -
    • - {{ instance.name }} -
    • - {% empty %} -
    • No service instances found.
    • - {% endfor %} -
    + + + + + + + + + + + + + {% for instance in instances %} + + + + + + + + + {% empty %} + + + + {% endfor %} + +
    {% translate "Name" %}{% translate "Service" %}{% translate "Service Provider" %}{% translate "Service Provider Zone" %}{% translate "Created At" %}{% translate "Status" %}
    + {{ instance.name }} + {{ instance.context.service_definition.service.name }}{{ instance.context.service_offering.provider.name }}{{ instance.context.control_plane.name }}{{ instance.created_at|date:"SHORT_DATETIME_FORMAT" }} + {% if instance.is_deleted %} + {% translate "Deleted" %} + {% else %} + {% translate "Active" %} + {% endif %} +
    {% translate "No service instances found." %}
    {% endblock %} From 4495899c0201308e4f6c1270bdb4c67bb6e6d4fb Mon Sep 17 00:00:00 2001 From: Tobias Kunze Date: Fri, 4 Apr 2025 17:46:33 +0200 Subject: [PATCH 7/8] Add filter and search to instance list --- src/servala/frontend/forms/service.py | 57 +++++++++++- .../organizations/service_instances.html | 92 +++++++++++-------- src/servala/frontend/views/service.py | 20 +++- 3 files changed, 128 insertions(+), 41 deletions(-) diff --git a/src/servala/frontend/forms/service.py b/src/servala/frontend/forms/service.py index 3b7a547..428f1bc 100644 --- a/src/servala/frontend/forms/service.py +++ b/src/servala/frontend/forms/service.py @@ -1,7 +1,13 @@ from django import forms from django.utils.translation import gettext_lazy as _ -from servala.core.models import CloudProvider, ControlPlane, ServiceCategory +from servala.core.models import ( + CloudProvider, + ControlPlane, + Service, + ServiceCategory, + ServiceOffering, +) class ServiceFilterForm(forms.Form): @@ -33,3 +39,52 @@ class ControlPlaneSelectForm(forms.Form): def __init__(self, *args, planes=None, **kwargs): super().__init__(*args, **kwargs) self.fields["control_plane"].queryset = planes + + +class ServiceInstanceFilterForm(forms.Form): + name = forms.CharField(required=False, label=_("Name")) + service = forms.ModelChoiceField( + queryset=Service.objects.all(), required=False, label=_("Service") + ) + provider = forms.ModelChoiceField( + queryset=ServiceOffering.objects.all() + .values_list("provider", flat=True) + .distinct(), + required=False, + label=_("Provider"), + ) + control_plane = forms.ModelChoiceField( + queryset=ControlPlane.objects.all(), + required=False, + label=_("Service Provider Zone"), + ) + status = forms.ChoiceField( + choices=( + ("active", _("Active")), + ("deleted", _("Deleted")), + ), + required=False, + label=_("Status"), + ) + + def filter_queryset(self, queryset): + if self.is_valid(): + data = self.cleaned_data + if data["name"]: + queryset = queryset.filter(name__icontains=data["name"]) + if data["service"]: + queryset = queryset.filter( + context__service_definition__service=data["service"] + ) + if data["provider"]: + queryset = queryset.filter( + context__service_offering__provider=data["provider"] + ) + if data["control_plane"]: + queryset = queryset.filter(context__control_plane=data["control_plane"]) + if data["status"]: + if data["status"] == "active": + queryset = queryset.filter(is_deleted=False) + else: + queryset = queryset.filter(is_deleted=True) + return queryset diff --git a/src/servala/frontend/templates/frontend/organizations/service_instances.html b/src/servala/frontend/templates/frontend/organizations/service_instances.html index d335049..e5c7c0e 100644 --- a/src/servala/frontend/templates/frontend/organizations/service_instances.html +++ b/src/servala/frontend/templates/frontend/organizations/service_instances.html @@ -5,41 +5,59 @@ {% translate "Instances" %} {% endblock page_title %} {% endblock html_title %} -{% block card_content %} - - - - - - - - - - - - - {% for instance in instances %} - - - - - - - - - {% empty %} - - - - {% endfor %} - -
    {% translate "Name" %}{% translate "Service" %}{% translate "Service Provider" %}{% translate "Service Provider Zone" %}{% translate "Created At" %}{% translate "Status" %}
    - {{ instance.name }} - {{ instance.context.service_definition.service.name }}{{ instance.context.service_offering.provider.name }}{{ instance.context.control_plane.name }}{{ instance.created_at|date:"SHORT_DATETIME_FORMAT" }} - {% if instance.is_deleted %} - {% translate "Deleted" %} - {% else %} - {% translate "Active" %} - {% endif %} -
    {% translate "No service instances found." %}
    +{% block content %} +
    +
    +
    +
    +
    + {{ filter_form }} +
    +
    +
    +
    +
    +
    +
    + + + + + + + + + + + + + {% for instance in instances %} + + + + + + + + + {% empty %} + + + + {% endfor %} + +
    {% translate "Name" %}{% translate "Service" %}{% translate "Service Provider" %}{% translate "Service Provider Zone" %}{% translate "Created At" %}{% translate "Status" %}
    + {{ instance.name }} + {{ instance.context.service_definition.service.name }}{{ instance.context.service_offering.provider.name }}{{ instance.context.control_plane.name }}{{ instance.created_at|date:"SHORT_DATETIME_FORMAT" }} + {% if instance.is_deleted %} + {% translate "Deleted" %} + {% else %} + {% translate "Active" %} + {% endif %} +
    {% translate "No service instances found." %}
    +
    +
    +
    +
    + {% endblock %} diff --git a/src/servala/frontend/views/service.py b/src/servala/frontend/views/service.py index c955cde..380f393 100644 --- a/src/servala/frontend/views/service.py +++ b/src/servala/frontend/views/service.py @@ -10,7 +10,11 @@ from servala.core.models import ( ServiceInstance, ServiceOffering, ) -from servala.frontend.forms.service import ControlPlaneSelectForm, ServiceFilterForm +from servala.frontend.forms.service import ( + ControlPlaneSelectForm, + ServiceFilterForm, + ServiceInstanceFilterForm, +) from servala.frontend.views.mixins import HtmxViewMixin, OrganizationViewMixin @@ -151,11 +155,21 @@ class ServiceInstanceListView(OrganizationViewMixin, ListView): model = ServiceInstance permission_type = "view" + @cached_property + def filter_form(self): + return ServiceInstanceFilterForm(data=self.request.GET or None) + def get_queryset(self): - """Return all service instances for the current organization.""" - return ServiceInstance.objects.filter(organization=self.request.organization) + """Return all service instances for the current organization with filtering.""" + queryset = ServiceInstance.objects.filter( + organization=self.request.organization + ) + if self.filter_form.is_valid(): + queryset = self.filter_form.filter_queryset(queryset) + 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 From 378e88af5416c45544f196619431711ef86e04b6 Mon Sep 17 00:00:00 2001 From: Tobias Kunze Date: Mon, 7 Apr 2025 19:16:33 +0200 Subject: [PATCH 8/8] Finish implementation of server-side detail view --- .../service_instance_detail.html | 54 +++++++++++++++++++ src/servala/frontend/urls.py | 5 ++ src/servala/frontend/views/__init__.py | 2 + src/servala/frontend/views/service.py | 20 +++++++ 4 files changed, 81 insertions(+) create mode 100644 src/servala/frontend/templates/frontend/organizations/service_instance_detail.html diff --git a/src/servala/frontend/templates/frontend/organizations/service_instance_detail.html b/src/servala/frontend/templates/frontend/organizations/service_instance_detail.html new file mode 100644 index 0000000..7a949f1 --- /dev/null +++ b/src/servala/frontend/templates/frontend/organizations/service_instance_detail.html @@ -0,0 +1,54 @@ +{% extends "frontend/base.html" %} +{% load i18n static %} +{% block html_title %} + {% block page_title %} + {{ instance.name }} + {% endblock page_title %} +{% endblock html_title %} +{% block content %} +
    +
    +
    +
    +
    +
    {% translate "Details" %}
    +
    +
    {% translate "Service" %}
    +
    + {{ instance.context.service_definition.service.name }} +
    +
    {% translate "Service Provider" %}
    +
    + {{ instance.context.service_offering.provider.name }} +
    +
    {% translate "Control Plane" %}
    +
    + {{ instance.context.control_plane.name }} +
    +
    {% translate "Created By" %}
    +
    + {{ instance.created_by }} +
    +
    {% translate "Created At" %}
    +
    + {{ instance.created_at|date:"SHORT_DATETIME_FORMAT" }} +
    +
    {% translate "Updated At" %}
    +
    + {{ instance.updated_at|date:"SHORT_DATETIME_FORMAT" }} +
    +
    {% translate "Status" %}
    +
    + {% if instance.is_deleted %} + {% translate "Deleted" %} + {% else %} + {% translate "Active" %} + {% endif %} +
    +
    +
    +
    +
    +
    +
    +{% endblock content %} diff --git a/src/servala/frontend/urls.py b/src/servala/frontend/urls.py index f02fd1c..2838dae 100644 --- a/src/servala/frontend/urls.py +++ b/src/servala/frontend/urls.py @@ -45,6 +45,11 @@ urlpatterns = [ views.ServiceInstanceListView.as_view(), name="organization.instances", ), + path( + "instances//", + views.ServiceInstanceDetailView.as_view(), + name="organization.instance", + ), ] ), ), diff --git a/src/servala/frontend/views/__init__.py b/src/servala/frontend/views/__init__.py index f7d9daa..5de38ee 100644 --- a/src/servala/frontend/views/__init__.py +++ b/src/servala/frontend/views/__init__.py @@ -7,6 +7,7 @@ from .organization import ( ) from .service import ( ServiceDetailView, + ServiceInstanceDetailView, ServiceInstanceListView, ServiceListView, ServiceOfferingDetailView, @@ -19,6 +20,7 @@ __all__ = [ "OrganizationDashboardView", "OrganizationUpdateView", "ServiceDetailView", + "ServiceInstanceDetailView", "ServiceInstanceListView", "ServiceListView", "ServiceOfferingDetailView", diff --git a/src/servala/frontend/views/service.py b/src/servala/frontend/views/service.py index 380f393..6ee6b1e 100644 --- a/src/servala/frontend/views/service.py +++ b/src/servala/frontend/views/service.py @@ -149,6 +149,26 @@ class ServiceOfferingDetailView(OrganizationViewMixin, HtmxViewMixin, DetailView return self.render_to_response(context) +class ServiceInstanceDetailView(OrganizationViewMixin, DetailView): + """View to display details of a specific service instance.""" + + template_name = "frontend/organizations/service_instance_detail.html" + context_object_name = "instance" + model = ServiceInstance + permission_type = "view" + slug_field = "name" + + def get_queryset(self): + """Return service instance for the current organization.""" + return ServiceInstance.objects.filter( + organization=self.request.organization + ).select_related( + "context__service_offering__provider", + "context__control_plane", + "context__service_definition__service", + ) + + class ServiceInstanceListView(OrganizationViewMixin, ListView): template_name = "frontend/organizations/service_instances.html" context_object_name = "instances"