Compare commits

...

9 commits

Author SHA1 Message Date
5aae477a17 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
2025-04-07 17:19:21 +00:00
378e88af54 Finish implementation of server-side detail view
All checks were successful
Tests / test (push) Successful in 24s
2025-04-07 19:16:33 +02:00
4495899c02 Add filter and search to instance list 2025-04-04 17:46:33 +02:00
23ad1c809b Use a table for instance display 2025-04-04 16:32:56 +02:00
ab04d9174c Improve user display 2025-04-04 14:49:57 +02:00
1ebf6a7ce0 Use card layout in instance list 2025-04-04 13:59:44 +02:00
bef41ac4f0 Add very bare service instance list 2025-04-04 13:56:26 +02:00
2fd28e7afa Add instance list to sidebar 2025-04-04 11:33:50 +02:00
7da4b77267 Add service instance permissions 2025-04-04 09:00:20 +02:00
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