Merge pull request 'Service Instances: List and detail view' (#39) from 28-service-instance-listing into main
All checks were successful
Build and Deploy Staging / build (push) Successful in 1m10s
Tests / test (push) Successful in 23s
Build and Deploy Staging / deploy (push) Successful in 8s

Reviewed-on: #39
This commit is contained in:
Tobias Kunze 2025-04-07 17:19:21 +00:00
commit 5aae477a17
9 changed files with 260 additions and 7 deletions

View file

@ -1,6 +1,7 @@
import json import json
import kubernetes import kubernetes
import rules
import urlman import urlman
from django.conf import settings from django.conf import settings
from django.core.cache import cache from django.core.cache import cache
@ -12,6 +13,7 @@ from encrypted_fields.fields import EncryptedJSONField
from kubernetes import client, config from kubernetes import client, config
from kubernetes.client.rest import ApiException from kubernetes.client.rest import ApiException
from servala.core import rules as perms
from servala.core.models.mixins import ServalaModelMixin from servala.core.models.mixins import ServalaModelMixin
from servala.core.validators import kubernetes_name_validator 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 # Names are unique per de-facto namespace, which is defined by the
# Organization + ServiceDefinition (group, version) + the ControlPlane. # Organization + ServiceDefinition (group, version) + the ControlPlane.
unique_together = [("name", "organization", "context")] 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): class urls(urlman.Urls):
base = "{self.organization.urls.instances}{self.name}/" base = "{self.organization.urls.instances}{self.name}/"

View file

@ -69,9 +69,7 @@ class User(ServalaModelMixin, PermissionsMixin, AbstractBaseUser):
verbose_name_plural = _("Users") verbose_name_plural = _("Users")
def __str__(self): def __str__(self):
if self.first_name and self.last_name: return f"{self.first_name} {self.last_name}".strip() or self.email
return f"{self.first_name} {self.last_name}"
return self.email
def normalize_username(self, username): def normalize_username(self, username):
return super().normalize_username(username).strip().lower() return super().normalize_username(username).strip().lower()

View file

@ -1,7 +1,13 @@
from django import forms from django import forms
from django.utils.translation import gettext_lazy as _ 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): class ServiceFilterForm(forms.Form):
@ -33,3 +39,52 @@ class ControlPlaneSelectForm(forms.Form):
def __init__(self, *args, planes=None, **kwargs): def __init__(self, *args, planes=None, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.fields["control_plane"].queryset = planes 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

View file

@ -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 %}
<section class="section">
<div class="card">
<div class="card-body">
<div class="row">
<div class="col-md-6">
<h5>{% translate "Details" %}</h5>
<dl class="row">
<dt class="col-sm-4">{% translate "Service" %}</dt>
<dd class="col-sm-8">
{{ instance.context.service_definition.service.name }}
</dd>
<dt class="col-sm-4">{% translate "Service Provider" %}</dt>
<dd class="col-sm-8">
{{ instance.context.service_offering.provider.name }}
</dd>
<dt class="col-sm-4">{% translate "Control Plane" %}</dt>
<dd class="col-sm-8">
{{ instance.context.control_plane.name }}
</dd>
<dt class="col-sm-4">{% translate "Created By" %}</dt>
<dd class="col-sm-8">
{{ instance.created_by }}
</dd>
<dt class="col-sm-4">{% translate "Created At" %}</dt>
<dd class="col-sm-8">
{{ instance.created_at|date:"SHORT_DATETIME_FORMAT" }}
</dd>
<dt class="col-sm-4">{% translate "Updated At" %}</dt>
<dd class="col-sm-8">
{{ instance.updated_at|date:"SHORT_DATETIME_FORMAT" }}
</dd>
<dt class="col-sm-4">{% translate "Status" %}</dt>
<dd class="col-sm-8">
{% if instance.is_deleted %}
<span class="badge text-bg-secondary">{% translate "Deleted" %}</span>
{% else %}
<span class="badge text-bg-success">{% translate "Active" %}</span>
{% endif %}
</dd>
</dl>
</div>
</div>
</div>
</div>
</section>
{% endblock content %}

View file

@ -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 %}
<section class="section">
<div class="card">
<div class="card-content">
<div class="card-body">
<form class="search-form" auto-submit>
{{ filter_form }}
</form>
</div>
</div>
</div>
<div class="card">
<div class="card-content">
<div class="card-body">
<table class="table table-striped">
<thead>
<tr>
<th>{% translate "Name" %}</th>
<th>{% translate "Service" %}</th>
<th>{% translate "Service Provider" %}</th>
<th>{% translate "Service Provider Zone" %}</th>
<th>{% translate "Created At" %}</th>
<th>{% translate "Status" %}</th>
</tr>
</thead>
<tbody>
{% for instance in instances %}
<tr>
<td>
<a href="{{ instance.urls.base }}">{{ instance.name }}</a>
</td>
<td>{{ instance.context.service_definition.service.name }}</td>
<td>{{ instance.context.service_offering.provider.name }}</td>
<td>{{ instance.context.control_plane.name }}</td>
<td>{{ instance.created_at|date:"SHORT_DATETIME_FORMAT" }}</td>
<td>
{% if instance.is_deleted %}
<span class="badge text-bg-secondary">{% translate "Deleted" %}</span>
{% else %}
<span class="badge text-bg-success">{% translate "Active" %}</span>
{% endif %}
</td>
</tr>
{% empty %}
<tr>
<td colspan="6">{% translate "No service instances found." %}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</section>
<script src="{% static "js/autosubmit.js" %}" defer></script>
{% endblock %}

View file

@ -116,10 +116,17 @@
<span>{% translate 'Details' %}</span> <span>{% translate 'Details' %}</span>
</a> </a>
</li> </li>
<li class="sidebar-title">{% translate 'Services' %}</li>
<li class="sidebar-item"> <li class="sidebar-item">
<a href="{{ request.organization.urls.services }}" class='sidebar-link'> <a href="{{ request.organization.urls.services }}" class='sidebar-link'>
<i class="bi bi-card-list"></i> <i class="bi bi-card-list"></i>
<span>{% translate 'Services' %}</span> <span>{% translate 'Available Services' %}</span>
</a>
</li>
<li class="sidebar-item">
<a href="{{ request.organization.urls.instances }}" class='sidebar-link'>
<i class="bi bi-server"></i>
<span>{% translate 'Instances' %}</span>
</a> </a>
</li> </li>
{% endif %} {% endif %}

View file

@ -40,6 +40,16 @@ urlpatterns = [
views.OrganizationDashboardView.as_view(), views.OrganizationDashboardView.as_view(),
name="organization.dashboard", name="organization.dashboard",
), ),
path(
"instances/",
views.ServiceInstanceListView.as_view(),
name="organization.instances",
),
path(
"instances/<slug:slug>/",
views.ServiceInstanceDetailView.as_view(),
name="organization.instance",
),
] ]
), ),
), ),

View file

@ -5,7 +5,13 @@ from .organization import (
OrganizationDashboardView, OrganizationDashboardView,
OrganizationUpdateView, OrganizationUpdateView,
) )
from .service import ServiceDetailView, ServiceListView, ServiceOfferingDetailView from .service import (
ServiceDetailView,
ServiceInstanceDetailView,
ServiceInstanceListView,
ServiceListView,
ServiceOfferingDetailView,
)
__all__ = [ __all__ = [
"IndexView", "IndexView",
@ -14,6 +20,8 @@ __all__ = [
"OrganizationDashboardView", "OrganizationDashboardView",
"OrganizationUpdateView", "OrganizationUpdateView",
"ServiceDetailView", "ServiceDetailView",
"ServiceInstanceDetailView",
"ServiceInstanceListView",
"ServiceListView", "ServiceListView",
"ServiceOfferingDetailView", "ServiceOfferingDetailView",
"ProfileView", "ProfileView",

View file

@ -10,7 +10,11 @@ from servala.core.models import (
ServiceInstance, ServiceInstance,
ServiceOffering, 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 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 # 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 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