Merge pull request 'Display service catalogs' (#22) from 13-service-catalog into main
Reviewed-on: https://servala-2nkgm.app.codey.ch/servala/servala-portal/pulls/22
This commit is contained in:
commit
a520fdeb4a
21 changed files with 704 additions and 52 deletions
|
@ -1,7 +1,7 @@
|
||||||
from django.contrib import admin, messages
|
from django.contrib import admin, messages
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
from servala.core.forms import ControlPlaneAdminForm
|
from servala.core.forms import ControlPlaneAdminForm, ServiceDefinitionAdminForm
|
||||||
from servala.core.models import (
|
from servala.core.models import (
|
||||||
BillingEntity,
|
BillingEntity,
|
||||||
CloudProvider,
|
CloudProvider,
|
||||||
|
@ -12,7 +12,9 @@ from servala.core.models import (
|
||||||
Plan,
|
Plan,
|
||||||
Service,
|
Service,
|
||||||
ServiceCategory,
|
ServiceCategory,
|
||||||
|
ServiceDefinition,
|
||||||
ServiceOffering,
|
ServiceOffering,
|
||||||
|
ServiceOfferingControlPlane,
|
||||||
User,
|
User,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -101,6 +103,7 @@ class ServiceAdmin(admin.ModelAdmin):
|
||||||
list_filter = ("category",)
|
list_filter = ("category",)
|
||||||
search_fields = ("name", "description")
|
search_fields = ("name", "description")
|
||||||
autocomplete_fields = ("category",)
|
autocomplete_fields = ("category",)
|
||||||
|
prepopulated_fields = {"slug": ["name"]}
|
||||||
|
|
||||||
|
|
||||||
@admin.register(CloudProvider)
|
@admin.register(CloudProvider)
|
||||||
|
@ -121,7 +124,7 @@ class ControlPlaneAdmin(admin.ModelAdmin):
|
||||||
fieldsets = (
|
fieldsets = (
|
||||||
(
|
(
|
||||||
None,
|
None,
|
||||||
{"fields": ("name", "description", "cloud_provider")},
|
{"fields": ("name", "description", "cloud_provider", "service_definition")},
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
_("API Credentials"),
|
_("API Credentials"),
|
||||||
|
@ -170,11 +173,54 @@ class PlanAdmin(admin.ModelAdmin):
|
||||||
autocomplete_fields = ("service_offering",)
|
autocomplete_fields = ("service_offering",)
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(ServiceDefinition)
|
||||||
|
class ServiceDefinitionAdmin(admin.ModelAdmin):
|
||||||
|
form = ServiceDefinitionAdminForm
|
||||||
|
list_display = ("name", "service")
|
||||||
|
list_filter = ("service",)
|
||||||
|
search_fields = ("name", "description")
|
||||||
|
autocomplete_fields = ("service",)
|
||||||
|
|
||||||
|
fieldsets = (
|
||||||
|
(
|
||||||
|
None,
|
||||||
|
{"fields": ("name", "description", "service")},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
_("API Definition"),
|
||||||
|
{
|
||||||
|
"fields": ("api_group", "api_version", "api_kind"),
|
||||||
|
"description": _("API definition for the Kubernetes Custom Resource"),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_exclude(self, request, obj=None):
|
||||||
|
# Exclude the original api_definition field as we're using our custom fields
|
||||||
|
return ["api_definition"]
|
||||||
|
|
||||||
|
|
||||||
|
class ServiceOfferingControlPlaneInline(admin.TabularInline):
|
||||||
|
model = ServiceOfferingControlPlane
|
||||||
|
extra = 1
|
||||||
|
autocomplete_fields = ("control_plane", "service_definition")
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(ServiceOfferingControlPlane)
|
||||||
|
class ServiceOfferingControlPlaneAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ("service_offering", "control_plane", "service_definition")
|
||||||
|
list_filter = ("service_offering", "control_plane", "service_definition")
|
||||||
|
search_fields = ("service_offering__service__name", "control_plane__name")
|
||||||
|
autocomplete_fields = ("service_offering", "control_plane", "service_definition")
|
||||||
|
|
||||||
|
|
||||||
@admin.register(ServiceOffering)
|
@admin.register(ServiceOffering)
|
||||||
class ServiceOfferingAdmin(admin.ModelAdmin):
|
class ServiceOfferingAdmin(admin.ModelAdmin):
|
||||||
list_display = ("id", "service", "provider")
|
list_display = ("id", "service", "provider")
|
||||||
list_filter = ("service", "provider", "control_plane")
|
list_filter = ("service", "provider")
|
||||||
search_fields = ("description",)
|
search_fields = ("description",)
|
||||||
autocomplete_fields = ("service", "provider")
|
autocomplete_fields = ("service", "provider")
|
||||||
filter_horizontal = ("control_plane",)
|
inlines = (
|
||||||
inlines = (PlanInline,)
|
ServiceOfferingControlPlaneInline,
|
||||||
|
PlanInline,
|
||||||
|
)
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
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 ControlPlane
|
from servala.core.models import ControlPlane, ServiceDefinition
|
||||||
|
|
||||||
|
|
||||||
class ControlPlaneAdminForm(forms.ModelForm):
|
class ControlPlaneAdminForm(forms.ModelForm):
|
||||||
|
@ -70,3 +70,65 @@ class ControlPlaneAdminForm(forms.ModelForm):
|
||||||
def save(self, *args, **kwargs):
|
def save(self, *args, **kwargs):
|
||||||
self.instance.api_credentials = self.cleaned_data["api_credentials"]
|
self.instance.api_credentials = self.cleaned_data["api_credentials"]
|
||||||
return super().save(*args, **kwargs)
|
return super().save(*args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
class ServiceDefinitionAdminForm(forms.ModelForm):
|
||||||
|
api_group = forms.CharField(
|
||||||
|
required=False,
|
||||||
|
help_text=_("API Group"),
|
||||||
|
)
|
||||||
|
api_version = forms.CharField(
|
||||||
|
required=False,
|
||||||
|
help_text=_("API Version"),
|
||||||
|
)
|
||||||
|
api_kind = forms.CharField(
|
||||||
|
required=False,
|
||||||
|
help_text=_("API Kind"),
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = ServiceDefinition
|
||||||
|
fields = "__all__"
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
# If we have existing api_definition, populate the individual fields
|
||||||
|
if self.instance.pk and self.instance.api_definition:
|
||||||
|
api_def = self.instance.api_definition
|
||||||
|
self.fields["api_group"].initial = api_def.get("group", "")
|
||||||
|
self.fields["api_version"].initial = api_def.get("version", "")
|
||||||
|
self.fields["api_kind"].initial = api_def.get("kind", "")
|
||||||
|
|
||||||
|
def clean(self):
|
||||||
|
cleaned_data = super().clean()
|
||||||
|
|
||||||
|
api_group = cleaned_data.get("api_group")
|
||||||
|
api_version = cleaned_data.get("api_version")
|
||||||
|
api_kind = cleaned_data.get("api_kind")
|
||||||
|
|
||||||
|
if api_group and api_version and api_kind:
|
||||||
|
cleaned_data["api_definition"] = {
|
||||||
|
"group": api_group,
|
||||||
|
"version": api_version,
|
||||||
|
"kind": api_kind,
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
if not (api_group or api_version or api_kind):
|
||||||
|
cleaned_data["api_definition"] = {}
|
||||||
|
else:
|
||||||
|
# Some fields are filled but not all – validation will fail at model level.
|
||||||
|
api_def = {}
|
||||||
|
if api_group:
|
||||||
|
api_def["group"] = api_group
|
||||||
|
if api_version:
|
||||||
|
api_def["version"] = api_version
|
||||||
|
if api_kind:
|
||||||
|
api_def["kind"] = api_kind
|
||||||
|
cleaned_data["api_definition"] = api_def
|
||||||
|
|
||||||
|
return cleaned_data
|
||||||
|
|
||||||
|
def save(self, *args, **kwargs):
|
||||||
|
self.instance.api_definition = self.cleaned_data["api_definition"]
|
||||||
|
return super().save(*args, **kwargs)
|
||||||
|
|
21
src/servala/core/migrations/0006_service_slug.py
Normal file
21
src/servala/core/migrations/0006_service_slug.py
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
# Generated by Django 5.2b1 on 2025-03-24 14:29
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("core", "0005_remove_controlplane_k8s_api_endpoint"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="service",
|
||||||
|
name="slug",
|
||||||
|
field=models.SlugField(
|
||||||
|
default="slug", max_length=100, unique=True, verbose_name="URL slug"
|
||||||
|
),
|
||||||
|
preserve_default=False,
|
||||||
|
),
|
||||||
|
]
|
115
src/servala/core/migrations/0007_service_definition.py
Normal file
115
src/servala/core/migrations/0007_service_definition.py
Normal file
|
@ -0,0 +1,115 @@
|
||||||
|
# Generated by Django 5.2b1 on 2025-03-25 11:02
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("core", "0006_service_slug"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name="serviceoffering",
|
||||||
|
name="control_plane",
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="ServiceDefinition",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
models.BigAutoField(
|
||||||
|
auto_created=True,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
verbose_name="ID",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("name", models.CharField(max_length=100, verbose_name="Name")),
|
||||||
|
(
|
||||||
|
"description",
|
||||||
|
models.TextField(blank=True, verbose_name="Description"),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"api_definition",
|
||||||
|
models.JSONField(
|
||||||
|
blank=True,
|
||||||
|
help_text="Contains group, version, and kind information",
|
||||||
|
null=True,
|
||||||
|
verbose_name="API Definition",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"service",
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="service_definitions",
|
||||||
|
to="core.service",
|
||||||
|
verbose_name="Service",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"verbose_name": "Service definition",
|
||||||
|
"verbose_name_plural": "Service definitions",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="ServiceOfferingControlPlane",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
models.BigAutoField(
|
||||||
|
auto_created=True,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
verbose_name="ID",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"control_plane",
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="offering_connections",
|
||||||
|
to="core.controlplane",
|
||||||
|
verbose_name="Control plane",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"service_definition",
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.PROTECT,
|
||||||
|
related_name="offering_control_planes",
|
||||||
|
to="core.servicedefinition",
|
||||||
|
verbose_name="Service definition",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"service_offering",
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="control_plane_connections",
|
||||||
|
to="core.serviceoffering",
|
||||||
|
verbose_name="Service offering",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"verbose_name": "Service offering control plane connection",
|
||||||
|
"verbose_name_plural": "Service offering control planes connections",
|
||||||
|
"unique_together": {("service_offering", "control_plane")},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="serviceoffering",
|
||||||
|
name="control_planes",
|
||||||
|
field=models.ManyToManyField(
|
||||||
|
related_name="offerings",
|
||||||
|
through="core.ServiceOfferingControlPlane",
|
||||||
|
to="core.controlplane",
|
||||||
|
verbose_name="Control planes",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
|
@ -11,7 +11,9 @@ from .service import (
|
||||||
Plan,
|
Plan,
|
||||||
Service,
|
Service,
|
||||||
ServiceCategory,
|
ServiceCategory,
|
||||||
|
ServiceDefinition,
|
||||||
ServiceOffering,
|
ServiceOffering,
|
||||||
|
ServiceOfferingControlPlane,
|
||||||
)
|
)
|
||||||
from .user import User
|
from .user import User
|
||||||
|
|
||||||
|
@ -26,6 +28,8 @@ __all__ = [
|
||||||
"Plan",
|
"Plan",
|
||||||
"Service",
|
"Service",
|
||||||
"ServiceCategory",
|
"ServiceCategory",
|
||||||
|
"ServiceDefinition",
|
||||||
"ServiceOffering",
|
"ServiceOffering",
|
||||||
|
"ServiceOfferingControlPlane",
|
||||||
"User",
|
"User",
|
||||||
]
|
]
|
||||||
|
|
|
@ -38,6 +38,7 @@ class Organization(ServalaModelMixin, models.Model):
|
||||||
class urls(urlman.Urls):
|
class urls(urlman.Urls):
|
||||||
base = "/org/{self.slug}/"
|
base = "/org/{self.slug}/"
|
||||||
details = "{base}details/"
|
details = "{base}details/"
|
||||||
|
services = "{base}services/"
|
||||||
|
|
||||||
@cached_property
|
@cached_property
|
||||||
def slug(self):
|
def slug(self):
|
||||||
|
|
|
@ -44,6 +44,7 @@ class Service(models.Model):
|
||||||
"""
|
"""
|
||||||
|
|
||||||
name = models.CharField(max_length=100, verbose_name=_("Name"))
|
name = models.CharField(max_length=100, verbose_name=_("Name"))
|
||||||
|
slug = models.SlugField(max_length=100, verbose_name=_("URL slug"), unique=True)
|
||||||
category = models.ForeignKey(
|
category = models.ForeignKey(
|
||||||
to="ServiceCategory",
|
to="ServiceCategory",
|
||||||
on_delete=models.PROTECT,
|
on_delete=models.PROTECT,
|
||||||
|
@ -67,18 +68,13 @@ class Service(models.Model):
|
||||||
return self.name
|
return self.name
|
||||||
|
|
||||||
|
|
||||||
def validate_api_credentials(value):
|
def validate_dict(data, required_fields=None, allow_empty=True):
|
||||||
"""
|
if not data:
|
||||||
Validates that api_credentials either contains all required fields or is empty.
|
if allow_empty:
|
||||||
"""
|
return
|
||||||
# If empty dict, that's valid
|
raise ValidationError(_("Data may not be empty!"))
|
||||||
if not value:
|
|
||||||
return
|
|
||||||
|
|
||||||
# Check for required fields
|
|
||||||
required_fields = ("certificate-authority-data", "server", "token")
|
|
||||||
missing_fields = required_fields - set(value)
|
|
||||||
|
|
||||||
|
missing_fields = required_fields - set(data)
|
||||||
if missing_fields:
|
if missing_fields:
|
||||||
raise ValidationError(
|
raise ValidationError(
|
||||||
_("Missing required fields in API credentials: %(fields)s"),
|
_("Missing required fields in API credentials: %(fields)s"),
|
||||||
|
@ -86,6 +82,11 @@ def validate_api_credentials(value):
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def validate_api_credentials(value):
|
||||||
|
required_fields = ("certificate-authority-data", "server", "token")
|
||||||
|
return validate_dict(value, required_fields)
|
||||||
|
|
||||||
|
|
||||||
class ControlPlane(models.Model):
|
class ControlPlane(models.Model):
|
||||||
name = models.CharField(max_length=100, verbose_name=_("Name"))
|
name = models.CharField(max_length=100, verbose_name=_("Name"))
|
||||||
description = models.TextField(blank=True, verbose_name=_("Description"))
|
description = models.TextField(blank=True, verbose_name=_("Description"))
|
||||||
|
@ -228,6 +229,88 @@ class Plan(models.Model):
|
||||||
return self.name
|
return self.name
|
||||||
|
|
||||||
|
|
||||||
|
def validate_api_definition(value):
|
||||||
|
required_fields = ("group", "version", "kind")
|
||||||
|
return validate_dict(value, required_fields)
|
||||||
|
|
||||||
|
|
||||||
|
class ServiceDefinition(models.Model):
|
||||||
|
"""
|
||||||
|
Configuration/service implementation: contains information on which
|
||||||
|
CompositeResourceDefinition (aka XRD) implements a service on a ControlPlane.
|
||||||
|
|
||||||
|
Is required in order to query the OpenAPI spec for dynamic form generation.
|
||||||
|
"""
|
||||||
|
|
||||||
|
name = models.CharField(max_length=100, verbose_name=_("Name"))
|
||||||
|
description = models.TextField(blank=True, verbose_name=_("Description"))
|
||||||
|
api_definition = models.JSONField(
|
||||||
|
verbose_name=_("API Definition"),
|
||||||
|
help_text=_("Contains group, version, and kind information"),
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
)
|
||||||
|
service = models.ForeignKey(
|
||||||
|
to="Service",
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name="service_definitions",
|
||||||
|
verbose_name=_("Service"),
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def control_planes(self):
|
||||||
|
return ControlPlane.objects.filter(
|
||||||
|
offering_connections__service_definition=self
|
||||||
|
).distinct()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def service_offerings(self):
|
||||||
|
return ServiceOffering.objects.filter(
|
||||||
|
control_plane_connections__service_definition=self
|
||||||
|
).distinct()
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = _("Service definition")
|
||||||
|
verbose_name_plural = _("Service definitions")
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.name
|
||||||
|
|
||||||
|
|
||||||
|
class ServiceOfferingControlPlane(models.Model):
|
||||||
|
"""
|
||||||
|
Each combination of ServiceOffering and ControlPlane can have a different
|
||||||
|
ServiceDefinition, which is here modeled as the "through" model.
|
||||||
|
"""
|
||||||
|
|
||||||
|
service_offering = models.ForeignKey(
|
||||||
|
to="ServiceOffering",
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name="control_plane_connections",
|
||||||
|
verbose_name=_("Service offering"),
|
||||||
|
)
|
||||||
|
control_plane = models.ForeignKey(
|
||||||
|
to="ControlPlane",
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name="offering_connections",
|
||||||
|
verbose_name=_("Control plane"),
|
||||||
|
)
|
||||||
|
service_definition = models.ForeignKey(
|
||||||
|
to="ServiceDefinition",
|
||||||
|
on_delete=models.PROTECT,
|
||||||
|
related_name="offering_control_planes",
|
||||||
|
verbose_name=_("Service definition"),
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = _("Service offering control plane connection")
|
||||||
|
verbose_name_plural = _("Service offering control planes connections")
|
||||||
|
unique_together = [("service_offering", "control_plane")]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.service_offering} on {self.control_plane} with {self.service_definition}"
|
||||||
|
|
||||||
|
|
||||||
class ServiceOffering(models.Model):
|
class ServiceOffering(models.Model):
|
||||||
"""
|
"""
|
||||||
A service offering, e.g. "PostgreSQL on AWS", "MinIO on GCP".
|
A service offering, e.g. "PostgreSQL on AWS", "MinIO on GCP".
|
||||||
|
@ -245,8 +328,9 @@ class ServiceOffering(models.Model):
|
||||||
related_name="offerings",
|
related_name="offerings",
|
||||||
verbose_name=_("Provider"),
|
verbose_name=_("Provider"),
|
||||||
)
|
)
|
||||||
control_plane = models.ManyToManyField(
|
control_planes = models.ManyToManyField(
|
||||||
to="ControlPlane",
|
to="ControlPlane",
|
||||||
|
through="ServiceOfferingControlPlane",
|
||||||
related_name="offerings",
|
related_name="offerings",
|
||||||
verbose_name=_("Control planes"),
|
verbose_name=_("Control planes"),
|
||||||
)
|
)
|
||||||
|
@ -255,3 +339,8 @@ class ServiceOffering(models.Model):
|
||||||
class Meta:
|
class Meta:
|
||||||
verbose_name = _("Service offering")
|
verbose_name = _("Service offering")
|
||||||
verbose_name_plural = _("Service offerings")
|
verbose_name_plural = _("Service offerings")
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return _("{service_name} at {provider_name}").format(
|
||||||
|
service_name=self.service.name, provider_name=self.provider.name
|
||||||
|
)
|
||||||
|
|
22
src/servala/frontend/forms/service.py
Normal file
22
src/servala/frontend/forms/service.py
Normal file
|
@ -0,0 +1,22 @@
|
||||||
|
from django import forms
|
||||||
|
|
||||||
|
from servala.core.models import CloudProvider, ServiceCategory
|
||||||
|
|
||||||
|
|
||||||
|
class ServiceFilterForm(forms.Form):
|
||||||
|
category = forms.ModelChoiceField(
|
||||||
|
queryset=ServiceCategory.objects.all(), required=False
|
||||||
|
)
|
||||||
|
cloud_provider = forms.ModelChoiceField(
|
||||||
|
queryset=CloudProvider.objects.all(), required=False
|
||||||
|
)
|
||||||
|
q = forms.CharField(required=False)
|
||||||
|
|
||||||
|
def filter_queryset(self, queryset):
|
||||||
|
if category := self.cleaned_data.get("category"):
|
||||||
|
queryset = queryset.filter(category=category)
|
||||||
|
if cloud_provider := self.cleaned_data.get("cloud_provider"):
|
||||||
|
queryset = queryset.filter(
|
||||||
|
offerings__control_planes__cloud_provider=cloud_provider
|
||||||
|
)
|
||||||
|
return queryset
|
|
@ -0,0 +1,65 @@
|
||||||
|
{% extends "frontend/base.html" %}
|
||||||
|
{% load i18n %}
|
||||||
|
{% load static %}
|
||||||
|
{% block html_title %}
|
||||||
|
{% block page_title %}
|
||||||
|
{{ service.name }}
|
||||||
|
{% endblock page_title %}
|
||||||
|
{% endblock html_title %}
|
||||||
|
{% block content %}
|
||||||
|
<section class="section">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header d-flex align-items-center">
|
||||||
|
{% if service.logo %}
|
||||||
|
<img src="{{ service.logo.url }}"
|
||||||
|
alt="{{ service.name }}"
|
||||||
|
class="me-3"
|
||||||
|
style="max-width: 48px;
|
||||||
|
max-height: 48px">
|
||||||
|
{% endif %}
|
||||||
|
<div class="d-flex flex-column">
|
||||||
|
<h4 class="mb-0">{{ service.name }}</h4>
|
||||||
|
<small class="text-muted">{{ service.category }}</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row">
|
||||||
|
<p>{{ service.description|default:"No description available." }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% for offering in service.offerings.all %}
|
||||||
|
<div class="card col-6 col-lg-3 col-md-4">
|
||||||
|
<div class="card-header d-flex align-items-center">
|
||||||
|
{% if offering.provider.logo %}
|
||||||
|
<img src="{{ offering.provider.logo.url }}"
|
||||||
|
alt="{{ offering.provider.name }}"
|
||||||
|
class="me-3"
|
||||||
|
style="max-width: 48px;
|
||||||
|
max-height: 48px">
|
||||||
|
{% endif %}
|
||||||
|
<div class="d-flex flex-column">
|
||||||
|
<h4 class="mb-0">{{ offering.provider.name }}</h4>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
{% if offering.description %}
|
||||||
|
<p class="card-text">{{ offering.description }}</p>
|
||||||
|
{% elif offering.provider.description %}
|
||||||
|
<p class="card-text">{{ offering.provider.description }}</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="card-footer d-flex justify-content-between">
|
||||||
|
<span></span>
|
||||||
|
<a href="offering/{{ offering.pk }}/" class="btn btn-light-primary">{% translate "Read More" %}</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% empty %}
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body">
|
||||||
|
<p>{% translate "No offerings found." %}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</section>
|
||||||
|
{% endblock content %}
|
|
@ -0,0 +1,40 @@
|
||||||
|
{% extends "frontend/base.html" %}
|
||||||
|
{% load i18n %}
|
||||||
|
{% load static %}
|
||||||
|
{% block html_title %}
|
||||||
|
{% block page_title %}
|
||||||
|
{{ offering }}
|
||||||
|
{% endblock page_title %}
|
||||||
|
{% endblock html_title %}
|
||||||
|
{% block content %}
|
||||||
|
<section class="section">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header d-flex align-items-center">
|
||||||
|
{% if service.logo %}
|
||||||
|
<img src="{{ service.logo.url }}"
|
||||||
|
alt="{{ service.name }}"
|
||||||
|
class="me-3"
|
||||||
|
style="max-width: 48px;
|
||||||
|
max-height: 48px">
|
||||||
|
{% endif %}
|
||||||
|
<div class="d-flex flex-column">
|
||||||
|
<h4 class="mb-0">{{ offering }}</h4>
|
||||||
|
<small class="text-muted">{{ offering.service.category }}</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row">
|
||||||
|
{% if offering.control_planes.all.count > 1 %}
|
||||||
|
<p>{% translate "Please choose your zone." %}</p>
|
||||||
|
{% else %}
|
||||||
|
<p>
|
||||||
|
{% blocktranslate trimmed with zone=offering.control_planes.all.first.name %}
|
||||||
|
Your zone will be <strong>{{ zone }}</strong>.
|
||||||
|
{% endblocktranslate %}
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% endblock content %}
|
|
@ -0,0 +1,51 @@
|
||||||
|
{% extends "frontend/base.html" %}
|
||||||
|
{% load i18n static %}
|
||||||
|
{% block html_title %}
|
||||||
|
{% block page_title %}
|
||||||
|
{% translate "Services" %}
|
||||||
|
{% 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>
|
||||||
|
{% for service in services %}
|
||||||
|
<div class="card col-6 col-lg-3 col-md-4">
|
||||||
|
<div class="card-header d-flex align-items-center">
|
||||||
|
{% if service.logo %}
|
||||||
|
<img src="{{ service.logo.url }}"
|
||||||
|
alt="{{ service.name }}"
|
||||||
|
class="me-3"
|
||||||
|
style="max-width: 48px;
|
||||||
|
max-height: 48px">
|
||||||
|
{% endif %}
|
||||||
|
<div class="d-flex flex-column">
|
||||||
|
<h4 class="mb-0">{{ service.name }}</h4>
|
||||||
|
<small class="text-muted">{{ service.category }}</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
{% if service.description %}<p class="card-text">{{ service.description }}</p>{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="card-footer d-flex justify-content-between">
|
||||||
|
<span></span>
|
||||||
|
<a href="{{ service.slug }}/" class="btn btn-light-primary">{% translate "Read More" %}</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% empty %}
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body">
|
||||||
|
<p>{% translate "No services found." %}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</section>
|
||||||
|
<script src="{% static "js/autosubmit.js" %}" defer></script>
|
||||||
|
{% endblock content %}
|
|
@ -73,7 +73,7 @@
|
||||||
<div class="col-md-6 col-12">
|
<div class="col-md-6 col-12">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<h4 class="card-title">{% translate "Profile" %}</h4>
|
<h4 class="">{% translate "Profile" %}</h4>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-content">
|
<div class="card-content">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
|
@ -106,7 +106,7 @@
|
||||||
<div class="col-md-6 col-12">
|
<div class="col-md-6 col-12">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<h4 class="card-title">{% translate "Account" %}</h4>
|
<h4 class="">{% translate "Account" %}</h4>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-content">
|
<div class="card-content">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
|
|
|
@ -116,6 +116,12 @@
|
||||||
<span>{% translate 'Details' %}</span>
|
<span>{% translate 'Details' %}</span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
|
<li class="sidebar-item">
|
||||||
|
<a href="{{ request.organization.urls.services }}" class='sidebar-link'>
|
||||||
|
<i class="bi bi-card-list"></i>
|
||||||
|
<span>{% translate 'Services' %}</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<li class="sidebar-title">{% translate 'Account' %}</li>
|
<li class="sidebar-title">{% translate 'Account' %}</li>
|
||||||
<li class="sidebar-item">
|
<li class="sidebar-item">
|
||||||
|
|
|
@ -20,6 +20,21 @@ urlpatterns = [
|
||||||
views.OrganizationUpdateView.as_view(),
|
views.OrganizationUpdateView.as_view(),
|
||||||
name="organization.details",
|
name="organization.details",
|
||||||
),
|
),
|
||||||
|
path(
|
||||||
|
"services/",
|
||||||
|
views.ServiceListView.as_view(),
|
||||||
|
name="organization.services",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"services/<slug:slug>/",
|
||||||
|
views.ServiceDetailView.as_view(),
|
||||||
|
name="organization.service",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"services/<slug:slug>/offering/<int:pk>/",
|
||||||
|
views.ServiceOfferingDetailView.as_view(),
|
||||||
|
name="organization.offering",
|
||||||
|
),
|
||||||
path(
|
path(
|
||||||
"",
|
"",
|
||||||
views.OrganizationDashboardView.as_view(),
|
views.OrganizationDashboardView.as_view(),
|
||||||
|
|
|
@ -5,6 +5,7 @@ from .organization import (
|
||||||
OrganizationDashboardView,
|
OrganizationDashboardView,
|
||||||
OrganizationUpdateView,
|
OrganizationUpdateView,
|
||||||
)
|
)
|
||||||
|
from .service import ServiceDetailView, ServiceListView, ServiceOfferingDetailView
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"IndexView",
|
"IndexView",
|
||||||
|
@ -12,5 +13,8 @@ __all__ = [
|
||||||
"OrganizationCreateView",
|
"OrganizationCreateView",
|
||||||
"OrganizationDashboardView",
|
"OrganizationDashboardView",
|
||||||
"OrganizationUpdateView",
|
"OrganizationUpdateView",
|
||||||
|
"ServiceDetailView",
|
||||||
|
"ServiceListView",
|
||||||
|
"ServiceOfferingDetailView",
|
||||||
"ProfileView",
|
"ProfileView",
|
||||||
]
|
]
|
||||||
|
|
|
@ -1,6 +1,8 @@
|
||||||
from django.utils.functional import cached_property
|
from django.utils.functional import cached_property
|
||||||
from django.views.generic import UpdateView
|
from django.views.generic import UpdateView
|
||||||
from rules.contrib.views import AutoPermissionRequiredMixin
|
from rules.contrib.views import AutoPermissionRequiredMixin, PermissionRequiredMixin
|
||||||
|
|
||||||
|
from servala.core.models import Organization
|
||||||
|
|
||||||
|
|
||||||
class HtmxUpdateView(AutoPermissionRequiredMixin, UpdateView):
|
class HtmxUpdateView(AutoPermissionRequiredMixin, UpdateView):
|
||||||
|
@ -57,3 +59,31 @@ class HtmxUpdateView(AutoPermissionRequiredMixin, UpdateView):
|
||||||
if self.is_htmx and self._get_fragment():
|
if self.is_htmx and self._get_fragment():
|
||||||
return self.get(self.request, *self.args, **self.kwargs)
|
return self.get(self.request, *self.args, **self.kwargs)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
class OrganizationViewMixin(PermissionRequiredMixin):
|
||||||
|
model = Organization
|
||||||
|
context_object_name = "organization"
|
||||||
|
permission_required = "core.view_organization"
|
||||||
|
|
||||||
|
@cached_property
|
||||||
|
def organization(self):
|
||||||
|
return self.request.organization
|
||||||
|
|
||||||
|
def get_object(self):
|
||||||
|
if self.model == Organization:
|
||||||
|
return self.organization
|
||||||
|
return super().get_object()
|
||||||
|
|
||||||
|
@cached_property
|
||||||
|
def object(self):
|
||||||
|
return self.get_object()
|
||||||
|
|
||||||
|
def get_permission_object(self):
|
||||||
|
return self.organization
|
||||||
|
|
||||||
|
def has_permission(self):
|
||||||
|
return (
|
||||||
|
self.request.user.has_perm("core.view_organization", self.organization)
|
||||||
|
and super().has_permission()
|
||||||
|
)
|
||||||
|
|
|
@ -1,11 +1,10 @@
|
||||||
from django.shortcuts import redirect
|
from django.shortcuts import redirect
|
||||||
from django.utils.functional import cached_property
|
|
||||||
from django.views.generic import CreateView, DetailView
|
from django.views.generic import CreateView, DetailView
|
||||||
from rules.contrib.views import AutoPermissionRequiredMixin
|
from rules.contrib.views import AutoPermissionRequiredMixin
|
||||||
|
|
||||||
from servala.core.models import Organization
|
from servala.core.models import Organization
|
||||||
from servala.frontend.forms import OrganizationForm
|
from servala.frontend.forms import OrganizationForm
|
||||||
from servala.frontend.views.mixins import HtmxUpdateView
|
from servala.frontend.views.mixins import HtmxUpdateView, OrganizationViewMixin
|
||||||
|
|
||||||
|
|
||||||
class OrganizationCreateView(AutoPermissionRequiredMixin, CreateView):
|
class OrganizationCreateView(AutoPermissionRequiredMixin, CreateView):
|
||||||
|
@ -20,22 +19,6 @@ class OrganizationCreateView(AutoPermissionRequiredMixin, CreateView):
|
||||||
return redirect(instance.urls.base)
|
return redirect(instance.urls.base)
|
||||||
|
|
||||||
|
|
||||||
class OrganizationViewMixin:
|
|
||||||
model = Organization
|
|
||||||
context_object_name = "organization"
|
|
||||||
|
|
||||||
@cached_property
|
|
||||||
def organization(self):
|
|
||||||
return self.request.organization
|
|
||||||
|
|
||||||
def get_object(self):
|
|
||||||
return self.organization
|
|
||||||
|
|
||||||
@cached_property
|
|
||||||
def object(self):
|
|
||||||
return self.get_object()
|
|
||||||
|
|
||||||
|
|
||||||
class OrganizationDashboardView(
|
class OrganizationDashboardView(
|
||||||
AutoPermissionRequiredMixin, OrganizationViewMixin, DetailView
|
AutoPermissionRequiredMixin, OrganizationViewMixin, DetailView
|
||||||
):
|
):
|
||||||
|
|
56
src/servala/frontend/views/service.py
Normal file
56
src/servala/frontend/views/service.py
Normal file
|
@ -0,0 +1,56 @@
|
||||||
|
from django.utils.functional import cached_property
|
||||||
|
from django.views.generic import DetailView, ListView
|
||||||
|
|
||||||
|
from servala.core.models import Service, ServiceOffering
|
||||||
|
from servala.frontend.forms.service import ServiceFilterForm
|
||||||
|
from servala.frontend.views.mixins import OrganizationViewMixin
|
||||||
|
|
||||||
|
|
||||||
|
class ServiceListView(OrganizationViewMixin, ListView):
|
||||||
|
"""View to display all available services for an organization."""
|
||||||
|
|
||||||
|
template_name = "frontend/organizations/services.html"
|
||||||
|
context_object_name = "services"
|
||||||
|
model = Service
|
||||||
|
permission_type = "view"
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
"""Return all services."""
|
||||||
|
services = Service.objects.all().select_related("category")
|
||||||
|
if self.filter_form.is_valid():
|
||||||
|
services = self.filter_form.filter_queryset(services)
|
||||||
|
return services
|
||||||
|
|
||||||
|
@cached_property
|
||||||
|
def filter_form(self):
|
||||||
|
return ServiceFilterForm(data=self.request.GET or None)
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
context = super().get_context_data(**kwargs)
|
||||||
|
context["filter_form"] = self.filter_form
|
||||||
|
return context
|
||||||
|
|
||||||
|
|
||||||
|
class ServiceDetailView(OrganizationViewMixin, DetailView):
|
||||||
|
template_name = "frontend/organizations/service_detail.html"
|
||||||
|
context_object_name = "service"
|
||||||
|
model = Service
|
||||||
|
permission_type = "view"
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
return Service.objects.select_related("category").prefetch_related(
|
||||||
|
"offerings",
|
||||||
|
"offerings__provider",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ServiceOfferingDetailView(OrganizationViewMixin, DetailView):
|
||||||
|
template_name = "frontend/organizations/service_offering_detail.html"
|
||||||
|
context_object_name = "offering"
|
||||||
|
model = ServiceOffering
|
||||||
|
permission_type = "view"
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
return ServiceOffering.objects.all().select_related(
|
||||||
|
"service", "service__category", "provider"
|
||||||
|
)
|
|
@ -1,3 +1,11 @@
|
||||||
.form-group.d-inline {
|
.form-group.d-inline {
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.search-form .form-body>.row {
|
||||||
|
display: flex;
|
||||||
|
&>.col-12 {
|
||||||
|
width: auto;
|
||||||
|
flex-grow: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
25
src/servala/static/js/autosubmit.js
Normal file
25
src/servala/static/js/autosubmit.js
Normal file
|
@ -0,0 +1,25 @@
|
||||||
|
/**
|
||||||
|
* Auto-submit functionality for forms
|
||||||
|
*
|
||||||
|
* This script looks for forms with the 'auto-submit' attribute
|
||||||
|
* and automatically submits them when any input, select, or textarea
|
||||||
|
* within the form changes, useful for search/filter forms.
|
||||||
|
*/
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
document.querySelectorAll('form[auto-submit]').forEach(form => {
|
||||||
|
const formElements = form.querySelectorAll('input, select, textarea')
|
||||||
|
|
||||||
|
formElements.forEach(element => {
|
||||||
|
if (element.type === 'checkbox' || element.type === 'radio') {
|
||||||
|
element.addEventListener('change', () => {
|
||||||
|
form.submit()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
else if (element.tagName.toLowerCase() === 'select') {
|
||||||
|
element.addEventListener('change', () => {
|
||||||
|
form.submit()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
|
@ -2,21 +2,30 @@
|
||||||
* This script marks the current path as active in the sidebar.
|
* This script marks the current path as active in the sidebar.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
const markActive = (link) => {
|
||||||
|
const parentItem = link.closest('.sidebar-item');
|
||||||
|
if (parentItem) {
|
||||||
|
parentItem.classList.add('active');
|
||||||
|
} else {
|
||||||
|
link.classList.add('active');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const checkLink = (fuzzy) => {
|
||||||
|
}
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
const currentPath = window.location.pathname;
|
const currentPath = window.location.pathname;
|
||||||
const sidebarLinks = document.querySelectorAll('.sidebar-link');
|
const sidebarLinks = [...document.querySelectorAll('a.sidebar-link')]
|
||||||
|
|
||||||
sidebarLinks.forEach(link => {
|
const exactMatches = sidebarLinks.filter(link => link.getAttribute('href') === currentPath)
|
||||||
// Skip links that are inside buttons (like logout)
|
if (exactMatches.length > 0) {
|
||||||
if (link.tagName === 'BUTTON') return;
|
markActive(exactMatches[0])
|
||||||
|
} else {
|
||||||
if (link.getAttribute('href') === currentPath) {
|
fuzzyMatches = sidebarLinks.filter(link => currentPath.startsWith(link.getAttribute('href')))
|
||||||
const parentItem = link.closest('.sidebar-item');
|
if (fuzzyMatches.length > 0) {
|
||||||
if (parentItem) {
|
const longestMatch = fuzzyMatches.sort((a, b) => b.href.length - a.href.length)[0]
|
||||||
parentItem.classList.add('active');
|
markActive(longestMatch)
|
||||||
} else {
|
|
||||||
link.classList.add('active');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
})
|
}
|
||||||
})
|
})
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue