diff --git a/src/servala/core/admin.py b/src/servala/core/admin.py index 454be56..66a4b30 100644 --- a/src/servala/core/admin.py +++ b/src/servala/core/admin.py @@ -13,7 +13,6 @@ from servala.core.models import ( Service, ServiceCategory, ServiceDefinition, - ServiceInstance, ServiceOffering, ServiceOfferingControlPlane, User, @@ -221,41 +220,6 @@ 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") diff --git a/src/servala/core/crd.py b/src/servala/core/crd.py index a164b1a..6fc4219 100644 --- a/src/servala/core/crd.py +++ b/src/servala/core/crd.py @@ -1,28 +1,10 @@ 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): """ @@ -32,8 +14,6 @@ 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"}) @@ -130,35 +110,8 @@ def get_django_field( class CrdModelFormMixin: def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - - 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 + # self.fields["apiVersion"].disabled = True + # self.fields["kind"].disabled = True def generate_model_form_class(model): diff --git a/src/servala/core/migrations/0010_service_instance.py b/src/servala/core/migrations/0010_service_instance.py deleted file mode 100644 index 9989650..0000000 --- a/src/servala/core/migrations/0010_service_instance.py +++ /dev/null @@ -1,117 +0,0 @@ -# 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), - ), - ] diff --git a/src/servala/core/migrations/0011_alter_servicecategory_name_and_more.py b/src/servala/core/migrations/0011_alter_servicecategory_name_and_more.py deleted file mode 100644 index 859e261..0000000 --- a/src/servala/core/migrations/0011_alter_servicecategory_name_and_more.py +++ /dev/null @@ -1,34 +0,0 @@ -# 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", - ), - ), - ] diff --git a/src/servala/core/models/__init__.py b/src/servala/core/models/__init__.py index 722aabd..6d5b24d 100644 --- a/src/servala/core/models/__init__.py +++ b/src/servala/core/models/__init__.py @@ -12,7 +12,6 @@ from .service import ( Service, ServiceCategory, ServiceDefinition, - ServiceInstance, ServiceOffering, ServiceOfferingControlPlane, ) @@ -29,7 +28,6 @@ __all__ = [ "Plan", "Service", "ServiceCategory", - "ServiceInstance", "ServiceDefinition", "ServiceOffering", "ServiceOfferingControlPlane", diff --git a/src/servala/core/models/organization.py b/src/servala/core/models/organization.py index e3c2bf1..a3c9a15 100644 --- a/src/servala/core/models/organization.py +++ b/src/servala/core/models/organization.py @@ -49,7 +49,6 @@ class Organization(ServalaModelMixin, models.Model): base = "/org/{self.slug}/" details = "{base}details/" services = "{base}services/" - services = "{base}instances/" @cached_property def slug(self): diff --git a/src/servala/core/models/service.py b/src/servala/core/models/service.py index ef6f9c9..04a6f8f 100644 --- a/src/servala/core/models/service.py +++ b/src/servala/core/models/service.py @@ -1,5 +1,4 @@ import kubernetes -import urlman from django.core.cache import cache from django.core.exceptions import ValidationError from django.db import models @@ -18,7 +17,9 @@ class ServiceCategory(ServalaModelMixin, models.Model): Categories for services, e.g. "Databases", "Storage", "Compute". """ - name = models.CharField(max_length=100, verbose_name=_("Name")) + name = models.CharField( + max_length=100, verbose_name=_("Name"), validators=[kubernetes_name_validator] + ) description = models.TextField(blank=True, verbose_name=_("Description")) logo = models.ImageField( upload_to="public/service_categories", @@ -415,9 +416,7 @@ class ServiceInstance(ServalaModelMixin, models.Model): on the fly. """ - name = models.CharField( - max_length=63, verbose_name=_("Name"), validators=[kubernetes_name_validator] - ) + name = models.CharField(max_length=100, verbose_name=_("Name")) organization = models.ForeignKey( to="core.Organization", on_delete=models.PROTECT, @@ -453,16 +452,3 @@ 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, - ) diff --git a/src/servala/frontend/forms/service.py b/src/servala/frontend/forms/service.py index 3b7a547..afef88c 100644 --- a/src/servala/frontend/forms/service.py +++ b/src/servala/frontend/forms/service.py @@ -25,9 +25,7 @@ class ServiceFilterForm(forms.Form): class ControlPlaneSelectForm(forms.Form): control_plane = forms.ModelChoiceField( - queryset=ControlPlane.objects.none(), - label=_("Service Provider Zone"), - empty_label=None, + queryset=ControlPlane.objects.none(), label=_("Service Provider Zone") ) def __init__(self, *args, planes=None, **kwargs): diff --git a/src/servala/frontend/views/service.py b/src/servala/frontend/views/service.py index e99e5a8..00b7edd 100644 --- a/src/servala/frontend/views/service.py +++ b/src/servala/frontend/views/service.py @@ -1,14 +1,7 @@ -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, - ServiceInstance, - ServiceOffering, - ServiceOfferingControlPlane, -) +from servala.core.models import Service, ServiceOffering, ServiceOfferingControlPlane from servala.frontend.forms.service import ControlPlaneSelectForm, ServiceFilterForm from servala.frontend.views.mixins import HtmxViewMixin, OrganizationViewMixin @@ -75,67 +68,26 @@ 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 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): context = super().get_context_data(**kwargs) context["select_form"] = self.select_form context["has_control_planes"] = self.planes.exists() - context["selected_plane"] = self.selected_plane - context["service_form"] = self.get_instance_form() + 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() 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)