Merge pull request 'Service Instances: List and detail view' (#39) from 28-service-instance-listing into main
Reviewed-on: #39
This commit is contained in:
commit
5aae477a17
9 changed files with 260 additions and 7 deletions
|
@ -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}/"
|
||||||
|
|
|
@ -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()
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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 %}
|
|
@ -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 %}
|
|
@ -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 %}
|
||||||
|
|
|
@ -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",
|
||||||
|
),
|
||||||
]
|
]
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
|
@ -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",
|
||||||
|
|
|
@ -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
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue