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}/" 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() 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_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/templates/frontend/organizations/service_instances.html b/src/servala/frontend/templates/frontend/organizations/service_instances.html new file mode 100644 index 0000000..e5c7c0e --- /dev/null +++ b/src/servala/frontend/templates/frontend/organizations/service_instances.html @@ -0,0 +1,63 @@ +{% extends "frontend/base.html" %} +{% load i18n static %} +{% block html_title %} + {% block page_title %} + {% translate "Instances" %} + {% endblock page_title %} +{% endblock html_title %} +{% 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/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 %} diff --git a/src/servala/frontend/urls.py b/src/servala/frontend/urls.py index d6c7c3a..2838dae 100644 --- a/src/servala/frontend/urls.py +++ b/src/servala/frontend/urls.py @@ -40,6 +40,16 @@ urlpatterns = [ views.OrganizationDashboardView.as_view(), name="organization.dashboard", ), + path( + "instances/", + 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 1a180a1..5de38ee 100644 --- a/src/servala/frontend/views/__init__.py +++ b/src/servala/frontend/views/__init__.py @@ -5,7 +5,13 @@ from .organization import ( OrganizationDashboardView, OrganizationUpdateView, ) -from .service import ServiceDetailView, ServiceListView, ServiceOfferingDetailView +from .service import ( + ServiceDetailView, + ServiceInstanceDetailView, + ServiceInstanceListView, + ServiceListView, + ServiceOfferingDetailView, +) __all__ = [ "IndexView", @@ -14,6 +20,8 @@ __all__ = [ "OrganizationDashboardView", "OrganizationUpdateView", "ServiceDetailView", + "ServiceInstanceDetailView", + "ServiceInstanceListView", "ServiceListView", "ServiceOfferingDetailView", "ProfileView", diff --git a/src/servala/frontend/views/service.py b/src/servala/frontend/views/service.py index edf889a..6ee6b1e 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 @@ -143,3 +147,49 @@ 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 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" + 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 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