Compare commits
9 commits
555462a99e
...
6e644dfe44
Author | SHA1 | Date | |
---|---|---|---|
6e644dfe44 | |||
4e44b283b1 | |||
174837a870 | |||
2f607f8271 | |||
33b82af67d | |||
172bdd7261 | |||
8a1f72b317 | |||
57945c8e51 | |||
b6260b4e9e |
9 changed files with 325 additions and 24 deletions
|
@ -13,6 +13,7 @@ from servala.core.models import (
|
||||||
Service,
|
Service,
|
||||||
ServiceCategory,
|
ServiceCategory,
|
||||||
ServiceDefinition,
|
ServiceDefinition,
|
||||||
|
ServiceInstance,
|
||||||
ServiceOffering,
|
ServiceOffering,
|
||||||
ServiceOfferingControlPlane,
|
ServiceOfferingControlPlane,
|
||||||
User,
|
User,
|
||||||
|
@ -220,6 +221,41 @@ class ServiceOfferingControlPlaneAdmin(admin.ModelAdmin):
|
||||||
autocomplete_fields = ("service_offering", "control_plane", "service_definition")
|
autocomplete_fields = ("service_offering", "control_plane", "service_definition")
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(ServiceInstance)
|
||||||
|
class ServiceInstanceAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ("name", "organization", "context", "created_by", "is_deleted")
|
||||||
|
list_filter = ("organization", "context", "is_deleted")
|
||||||
|
search_fields = (
|
||||||
|
"name",
|
||||||
|
"organization__name",
|
||||||
|
"context__service_offering__service__name",
|
||||||
|
)
|
||||||
|
readonly_fields = ("name", "organization", "context")
|
||||||
|
autocomplete_fields = ("organization", "context")
|
||||||
|
|
||||||
|
def get_readonly_fields(self, request, obj=None):
|
||||||
|
if obj: # If this is an edit (not a new instance)
|
||||||
|
return self.readonly_fields
|
||||||
|
return []
|
||||||
|
|
||||||
|
fieldsets = (
|
||||||
|
(
|
||||||
|
None,
|
||||||
|
{
|
||||||
|
"fields": (
|
||||||
|
"name",
|
||||||
|
"organization",
|
||||||
|
"context",
|
||||||
|
"created_by",
|
||||||
|
"is_deleted",
|
||||||
|
"deleted_at",
|
||||||
|
"deleted_by",
|
||||||
|
)
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@admin.register(ServiceOffering)
|
@admin.register(ServiceOffering)
|
||||||
class ServiceOfferingAdmin(admin.ModelAdmin):
|
class ServiceOfferingAdmin(admin.ModelAdmin):
|
||||||
list_display = ("id", "service", "provider")
|
list_display = ("id", "service", "provider")
|
||||||
|
|
|
@ -1,10 +1,28 @@
|
||||||
import re
|
import re
|
||||||
|
|
||||||
|
from django import forms
|
||||||
from django.core.validators import MaxValueValidator, MinValueValidator, RegexValidator
|
from django.core.validators import MaxValueValidator, MinValueValidator, RegexValidator
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.forms.models import ModelForm, ModelFormMetaclass
|
from django.forms.models import ModelForm, ModelFormMetaclass
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
|
from servala.core.models import ServiceInstance
|
||||||
|
|
||||||
|
|
||||||
|
def duplicate_field(field_name, model):
|
||||||
|
# Get the field from the model
|
||||||
|
field = model._meta.get_field(field_name)
|
||||||
|
|
||||||
|
# Create a new field with the same attributes
|
||||||
|
new_field = type(field).__new__(type(field))
|
||||||
|
new_field.__dict__.update(field.__dict__)
|
||||||
|
|
||||||
|
# Ensure the field is not linked to the original model
|
||||||
|
new_field.model = None
|
||||||
|
new_field.auto_created = False
|
||||||
|
|
||||||
|
return new_field
|
||||||
|
|
||||||
|
|
||||||
def generate_django_model(schema, group, version, kind):
|
def generate_django_model(schema, group, version, kind):
|
||||||
"""
|
"""
|
||||||
|
@ -14,6 +32,8 @@ def generate_django_model(schema, group, version, kind):
|
||||||
# defaults = {"apiVersion": f"{group}/{version}", "kind": kind}
|
# defaults = {"apiVersion": f"{group}/{version}", "kind": kind}
|
||||||
|
|
||||||
model_fields = {"__module__": "crd_models"}
|
model_fields = {"__module__": "crd_models"}
|
||||||
|
for field_name in ("name", "organization", "context"):
|
||||||
|
model_fields[field_name] = duplicate_field(field_name, ServiceInstance)
|
||||||
model_fields.update(build_object_fields(spec, "spec"))
|
model_fields.update(build_object_fields(spec, "spec"))
|
||||||
|
|
||||||
meta_class = type("Meta", (), {"app_label": "crd_models"})
|
meta_class = type("Meta", (), {"app_label": "crd_models"})
|
||||||
|
@ -110,8 +130,35 @@ def get_django_field(
|
||||||
class CrdModelFormMixin:
|
class CrdModelFormMixin:
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
# self.fields["apiVersion"].disabled = True
|
|
||||||
# self.fields["kind"].disabled = True
|
for field in ("organization", "context"):
|
||||||
|
self.fields[field].widget = forms.HiddenInput()
|
||||||
|
|
||||||
|
def get_nested_data(self):
|
||||||
|
"""
|
||||||
|
Builds the original nested JSON structure from flat form data.
|
||||||
|
Form fields are named with dot notation (e.g., 'spec.replicas')
|
||||||
|
"""
|
||||||
|
result = {}
|
||||||
|
|
||||||
|
for field_name, value in self.cleaned_data.items():
|
||||||
|
if value is None or value == "":
|
||||||
|
continue
|
||||||
|
|
||||||
|
parts = field_name.split(".")
|
||||||
|
current = result
|
||||||
|
|
||||||
|
# Navigate through the nested structure
|
||||||
|
for i, part in enumerate(parts):
|
||||||
|
if i == len(parts) - 1:
|
||||||
|
# Last part, set the value
|
||||||
|
current[part] = value
|
||||||
|
else:
|
||||||
|
# Create nested dict if it doesn't exist
|
||||||
|
if part not in current:
|
||||||
|
current[part] = {}
|
||||||
|
current = current[part]
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
def generate_model_form_class(model):
|
def generate_model_form_class(model):
|
||||||
|
|
117
src/servala/core/migrations/0010_service_instance.py
Normal file
117
src/servala/core/migrations/0010_service_instance.py
Normal file
|
@ -0,0 +1,117 @@
|
||||||
|
# Generated by Django 5.2b1 on 2025-03-28 10:29
|
||||||
|
|
||||||
|
import django.core.validators
|
||||||
|
import django.db.models.deletion
|
||||||
|
import rules.contrib.models
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("core", "0009_organization_namespace"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="organization",
|
||||||
|
name="namespace",
|
||||||
|
field=models.CharField(
|
||||||
|
help_text="This namespace will be used for all Kubernetes resources. Cannot be changed after creation.",
|
||||||
|
max_length=63,
|
||||||
|
unique=True,
|
||||||
|
validators=[
|
||||||
|
django.core.validators.RegexValidator(
|
||||||
|
code="invalid_kubernetes_name",
|
||||||
|
message='Name must consist of lowercase alphanumeric characters or "-", must start and end with an alphanumeric character.',
|
||||||
|
regex="^[a-z0-9]([-a-z0-9]*[a-z0-9])?$",
|
||||||
|
)
|
||||||
|
],
|
||||||
|
verbose_name="Kubernetes Namespace",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="servicecategory",
|
||||||
|
name="name",
|
||||||
|
field=models.CharField(
|
||||||
|
max_length=100,
|
||||||
|
validators=[
|
||||||
|
django.core.validators.RegexValidator(
|
||||||
|
code="invalid_kubernetes_name",
|
||||||
|
message='Name must consist of lowercase alphanumeric characters or "-", must start and end with an alphanumeric character.',
|
||||||
|
regex="^[a-z0-9]([-a-z0-9]*[a-z0-9])?$",
|
||||||
|
)
|
||||||
|
],
|
||||||
|
verbose_name="Name",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="ServiceInstance",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
models.BigAutoField(
|
||||||
|
auto_created=True,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
verbose_name="ID",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"created_at",
|
||||||
|
models.DateTimeField(auto_now_add=True, verbose_name="Created"),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"updated_at",
|
||||||
|
models.DateTimeField(auto_now=True, verbose_name="Last updated"),
|
||||||
|
),
|
||||||
|
("name", models.CharField(max_length=100, verbose_name="Name")),
|
||||||
|
("is_deleted", models.BooleanField(default=False)),
|
||||||
|
("deleted_at", models.DateTimeField(blank=True, null=True)),
|
||||||
|
(
|
||||||
|
"context",
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.PROTECT,
|
||||||
|
related_name="service_instances",
|
||||||
|
to="core.serviceofferingcontrolplane",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"created_by",
|
||||||
|
models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.SET_NULL,
|
||||||
|
related_name="+",
|
||||||
|
to=settings.AUTH_USER_MODEL,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"deleted_by",
|
||||||
|
models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.SET_NULL,
|
||||||
|
related_name="+",
|
||||||
|
to=settings.AUTH_USER_MODEL,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"organization",
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.PROTECT,
|
||||||
|
related_name="service_instances",
|
||||||
|
to="core.organization",
|
||||||
|
verbose_name="Organization",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"verbose_name": "Service instance",
|
||||||
|
"verbose_name_plural": "Service instances",
|
||||||
|
"unique_together": {("name", "organization", "context")},
|
||||||
|
},
|
||||||
|
bases=(rules.contrib.models.RulesModelMixin, models.Model),
|
||||||
|
),
|
||||||
|
]
|
|
@ -0,0 +1,34 @@
|
||||||
|
# Generated by Django 5.2b1 on 2025-03-28 11:51
|
||||||
|
|
||||||
|
import django.core.validators
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("core", "0010_service_instance"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="servicecategory",
|
||||||
|
name="name",
|
||||||
|
field=models.CharField(max_length=100, verbose_name="Name"),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="serviceinstance",
|
||||||
|
name="name",
|
||||||
|
field=models.CharField(
|
||||||
|
max_length=63,
|
||||||
|
validators=[
|
||||||
|
django.core.validators.RegexValidator(
|
||||||
|
code="invalid_kubernetes_name",
|
||||||
|
message='Name must consist of lowercase alphanumeric characters or "-", must start and end with an alphanumeric character.',
|
||||||
|
regex="^[a-z0-9]([-a-z0-9]*[a-z0-9])?$",
|
||||||
|
)
|
||||||
|
],
|
||||||
|
verbose_name="Name",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
|
@ -12,6 +12,7 @@ from .service import (
|
||||||
Service,
|
Service,
|
||||||
ServiceCategory,
|
ServiceCategory,
|
||||||
ServiceDefinition,
|
ServiceDefinition,
|
||||||
|
ServiceInstance,
|
||||||
ServiceOffering,
|
ServiceOffering,
|
||||||
ServiceOfferingControlPlane,
|
ServiceOfferingControlPlane,
|
||||||
)
|
)
|
||||||
|
@ -28,6 +29,7 @@ __all__ = [
|
||||||
"Plan",
|
"Plan",
|
||||||
"Service",
|
"Service",
|
||||||
"ServiceCategory",
|
"ServiceCategory",
|
||||||
|
"ServiceInstance",
|
||||||
"ServiceDefinition",
|
"ServiceDefinition",
|
||||||
"ServiceOffering",
|
"ServiceOffering",
|
||||||
"ServiceOfferingControlPlane",
|
"ServiceOfferingControlPlane",
|
||||||
|
|
|
@ -49,6 +49,7 @@ class Organization(ServalaModelMixin, models.Model):
|
||||||
base = "/org/{self.slug}/"
|
base = "/org/{self.slug}/"
|
||||||
details = "{base}details/"
|
details = "{base}details/"
|
||||||
services = "{base}services/"
|
services = "{base}services/"
|
||||||
|
services = "{base}instances/"
|
||||||
|
|
||||||
@cached_property
|
@cached_property
|
||||||
def slug(self):
|
def slug(self):
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
import kubernetes
|
import kubernetes
|
||||||
|
import urlman
|
||||||
from django.core.cache import cache
|
from django.core.cache import cache
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
from django.db import models
|
from django.db import models
|
||||||
|
@ -17,9 +18,7 @@ class ServiceCategory(ServalaModelMixin, models.Model):
|
||||||
Categories for services, e.g. "Databases", "Storage", "Compute".
|
Categories for services, e.g. "Databases", "Storage", "Compute".
|
||||||
"""
|
"""
|
||||||
|
|
||||||
name = models.CharField(
|
name = models.CharField(max_length=100, verbose_name=_("Name"))
|
||||||
max_length=100, verbose_name=_("Name"), validators=[kubernetes_name_validator]
|
|
||||||
)
|
|
||||||
description = models.TextField(blank=True, verbose_name=_("Description"))
|
description = models.TextField(blank=True, verbose_name=_("Description"))
|
||||||
logo = models.ImageField(
|
logo = models.ImageField(
|
||||||
upload_to="public/service_categories",
|
upload_to="public/service_categories",
|
||||||
|
@ -416,7 +415,9 @@ class ServiceInstance(ServalaModelMixin, models.Model):
|
||||||
on the fly.
|
on the fly.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
name = models.CharField(max_length=100, verbose_name=_("Name"))
|
name = models.CharField(
|
||||||
|
max_length=63, verbose_name=_("Name"), validators=[kubernetes_name_validator]
|
||||||
|
)
|
||||||
organization = models.ForeignKey(
|
organization = models.ForeignKey(
|
||||||
to="core.Organization",
|
to="core.Organization",
|
||||||
on_delete=models.PROTECT,
|
on_delete=models.PROTECT,
|
||||||
|
@ -452,3 +453,16 @@ 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")]
|
||||||
|
|
||||||
|
class urls(urlman.Urls):
|
||||||
|
base = "{self.organization.urls.instances}{self.name}/"
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def create_instance(cls, organization, context, created_by, spec_data):
|
||||||
|
name = spec_data.get("spec.name")
|
||||||
|
return cls.objects.create(
|
||||||
|
name=name,
|
||||||
|
organization=organization,
|
||||||
|
created_by=created_by,
|
||||||
|
context=context,
|
||||||
|
)
|
||||||
|
|
|
@ -25,7 +25,9 @@ class ServiceFilterForm(forms.Form):
|
||||||
|
|
||||||
class ControlPlaneSelectForm(forms.Form):
|
class ControlPlaneSelectForm(forms.Form):
|
||||||
control_plane = forms.ModelChoiceField(
|
control_plane = forms.ModelChoiceField(
|
||||||
queryset=ControlPlane.objects.none(), label=_("Service Provider Zone")
|
queryset=ControlPlane.objects.none(),
|
||||||
|
label=_("Service Provider Zone"),
|
||||||
|
empty_label=None,
|
||||||
)
|
)
|
||||||
|
|
||||||
def __init__(self, *args, planes=None, **kwargs):
|
def __init__(self, *args, planes=None, **kwargs):
|
||||||
|
|
|
@ -1,7 +1,14 @@
|
||||||
|
from django.contrib import messages
|
||||||
|
from django.shortcuts import redirect
|
||||||
from django.utils.functional import cached_property
|
from django.utils.functional import cached_property
|
||||||
from django.views.generic import DetailView, ListView
|
from django.views.generic import DetailView, ListView
|
||||||
|
|
||||||
from servala.core.models import Service, ServiceOffering, ServiceOfferingControlPlane
|
from servala.core.models import (
|
||||||
|
Service,
|
||||||
|
ServiceInstance,
|
||||||
|
ServiceOffering,
|
||||||
|
ServiceOfferingControlPlane,
|
||||||
|
)
|
||||||
from servala.frontend.forms.service import ControlPlaneSelectForm, ServiceFilterForm
|
from servala.frontend.forms.service import ControlPlaneSelectForm, ServiceFilterForm
|
||||||
from servala.frontend.views.mixins import HtmxViewMixin, OrganizationViewMixin
|
from servala.frontend.views.mixins import HtmxViewMixin, OrganizationViewMixin
|
||||||
|
|
||||||
|
@ -68,26 +75,67 @@ class ServiceOfferingDetailView(OrganizationViewMixin, HtmxViewMixin, DetailView
|
||||||
data = None
|
data = None
|
||||||
if "control_plane" in self.request.GET:
|
if "control_plane" in self.request.GET:
|
||||||
data = self.request.GET
|
data = self.request.GET
|
||||||
|
elif self.request.method == "POST" and self.context_object:
|
||||||
|
data = {"control_plane": self.context_object.control_plane_id}
|
||||||
return ControlPlaneSelectForm(data=data, planes=self.planes)
|
return ControlPlaneSelectForm(data=data, planes=self.planes)
|
||||||
|
|
||||||
|
@cached_property
|
||||||
|
def selected_plane(self):
|
||||||
|
if self.select_form.data and self.select_form.is_valid():
|
||||||
|
return self.select_form.cleaned_data["control_plane"]
|
||||||
|
field = self.select_form.fields["control_plane"]
|
||||||
|
return field.initial or field.queryset.first()
|
||||||
|
|
||||||
|
@cached_property
|
||||||
|
def context_object(self):
|
||||||
|
if self.request.method == "POST":
|
||||||
|
return ServiceOfferingControlPlane.objects.filter(
|
||||||
|
pk=self.request.POST.get("context"),
|
||||||
|
# Make sure we don’t use a malicious ID
|
||||||
|
control_plane__in=self.planes,
|
||||||
|
).first()
|
||||||
|
return ServiceOfferingControlPlane.objects.filter(
|
||||||
|
control_plane=self.selected_plane, service_offering=self.object
|
||||||
|
).first()
|
||||||
|
|
||||||
|
def get_instance_form(self):
|
||||||
|
return self.context_object.model_form_class(
|
||||||
|
data=self.request.POST if self.request.method == "POST" else None,
|
||||||
|
initial={
|
||||||
|
"organization": self.request.organization,
|
||||||
|
"context": self.context_object,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
def get_context_data(self, **kwargs):
|
def get_context_data(self, **kwargs):
|
||||||
context = super().get_context_data(**kwargs)
|
context = super().get_context_data(**kwargs)
|
||||||
context["select_form"] = self.select_form
|
context["select_form"] = self.select_form
|
||||||
context["has_control_planes"] = self.planes.exists()
|
context["has_control_planes"] = self.planes.exists()
|
||||||
if "control_plane" in self.request.GET:
|
context["selected_plane"] = self.selected_plane
|
||||||
if self.select_form.is_valid():
|
context["service_form"] = self.get_instance_form()
|
||||||
context["selected_plane"] = self.select_form.cleaned_data[
|
|
||||||
"control_plane"
|
|
||||||
]
|
|
||||||
try:
|
|
||||||
so_cp = ServiceOfferingControlPlane.objects.filter(
|
|
||||||
control_plane=self.select_form.cleaned_data["control_plane"],
|
|
||||||
service_offering=self.object,
|
|
||||||
).first()
|
|
||||||
if not so_cp:
|
|
||||||
context["form_error"] = True
|
|
||||||
except Exception:
|
|
||||||
context["form_error"] = True
|
|
||||||
else:
|
|
||||||
context["service_form"] = so_cp.model_form_class()
|
|
||||||
return context
|
return context
|
||||||
|
|
||||||
|
def post(self, request, *args, **kwargs):
|
||||||
|
self.object = self.get_object()
|
||||||
|
context = self.get_context_data(object=self.object)
|
||||||
|
|
||||||
|
if not self.context_object:
|
||||||
|
context["form_error"] = True
|
||||||
|
return self.render_to_response(context)
|
||||||
|
|
||||||
|
form = self.get_instance_form()
|
||||||
|
if form.is_valid():
|
||||||
|
try:
|
||||||
|
service_instance = ServiceInstance.create_instance(
|
||||||
|
organization=self.organization,
|
||||||
|
context=self.context_object,
|
||||||
|
created_by=request.user,
|
||||||
|
spec_data=form.get_nested_data(),
|
||||||
|
)
|
||||||
|
return redirect(service_instance.urls.base)
|
||||||
|
except Exception as e:
|
||||||
|
messages.error(self.request, str(e))
|
||||||
|
|
||||||
|
# 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)
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue