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,
ServiceCategory,
ServiceDefinition,
ServiceInstance,
ServiceOffering,
ServiceOfferingControlPlane,
User,
@ -220,6 +221,41 @@ class ServiceOfferingControlPlaneAdmin(admin.ModelAdmin):
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)
class ServiceOfferingAdmin(admin.ModelAdmin):
list_display = ("id", "service", "provider")

View file

@ -1,10 +1,28 @@
import re
from django import forms
from django.core.validators import MaxValueValidator, MinValueValidator, RegexValidator
from django.db import models
from django.forms.models import ModelForm, ModelFormMetaclass
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):
"""
@ -14,6 +32,8 @@ def generate_django_model(schema, group, version, kind):
# defaults = {"apiVersion": f"{group}/{version}", "kind": kind}
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"))
meta_class = type("Meta", (), {"app_label": "crd_models"})
@ -110,8 +130,35 @@ def get_django_field(
class CrdModelFormMixin:
def __init__(self, *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):

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,
ServiceCategory,
ServiceDefinition,
ServiceInstance,
ServiceOffering,
ServiceOfferingControlPlane,
)
@ -28,6 +29,7 @@ __all__ = [
"Plan",
"Service",
"ServiceCategory",
"ServiceInstance",
"ServiceDefinition",
"ServiceOffering",
"ServiceOfferingControlPlane",

View file

@ -49,6 +49,7 @@ class Organization(ServalaModelMixin, models.Model):
base = "/org/{self.slug}/"
details = "{base}details/"
services = "{base}services/"
services = "{base}instances/"
@cached_property
def slug(self):

View file

@ -1,4 +1,5 @@
import kubernetes
import urlman
from django.core.cache import cache
from django.core.exceptions import ValidationError
from django.db import models
@ -17,9 +18,7 @@ class ServiceCategory(ServalaModelMixin, models.Model):
Categories for services, e.g. "Databases", "Storage", "Compute".
"""
name = models.CharField(
max_length=100, verbose_name=_("Name"), validators=[kubernetes_name_validator]
)
name = models.CharField(max_length=100, verbose_name=_("Name"))
description = models.TextField(blank=True, verbose_name=_("Description"))
logo = models.ImageField(
upload_to="public/service_categories",
@ -416,7 +415,9 @@ class ServiceInstance(ServalaModelMixin, models.Model):
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(
to="core.Organization",
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
# Organization + ServiceDefinition (group, version) + the ControlPlane.
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):
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):

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.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.views.mixins import HtmxViewMixin, OrganizationViewMixin
@ -68,26 +75,67 @@ class ServiceOfferingDetailView(OrganizationViewMixin, HtmxViewMixin, DetailView
data = None
if "control_plane" in 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)
@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):
context = super().get_context_data(**kwargs)
context["select_form"] = self.select_form
context["has_control_planes"] = self.planes.exists()
if "control_plane" in self.request.GET:
if self.select_form.is_valid():
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()
context["selected_plane"] = self.selected_plane
context["service_form"] = self.get_instance_form()
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)