Compare commits

...

9 commits

9 changed files with 325 additions and 24 deletions

View file

@ -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")

View file

@ -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):

View 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),
),
]

View file

@ -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",
),
),
]

View file

@ -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",

View file

@ -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):

View file

@ -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,
)

View file

@ -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):

View file

@ -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 dont 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)