diff --git a/pyproject.toml b/pyproject.toml index 27d42c7..71f4522 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,7 +12,6 @@ dependencies = [ "django-fernet-encrypted-fields>=0.3.0", "django-scopes>=2.0.0", "django-template-partials>=24.4", - "jsonschema>=4.23.0", "kubernetes>=32.0.1", "pillow>=11.1.0", "psycopg2-binary>=2.9.10", @@ -43,7 +42,6 @@ known_first_party = "servala" [tool.flake8] max-line-length = 160 exclude = ".venv" -ignore = "E203" [tool.djlint] extend_exclude = "src/servala/static/mazer" diff --git a/src/servala/core/admin.py b/src/servala/core/admin.py index 7d110d1..b8171f9 100644 --- a/src/servala/core/admin.py +++ b/src/servala/core/admin.py @@ -6,7 +6,6 @@ from servala.core.models import ( BillingEntity, CloudProvider, ControlPlane, - ControlPlaneCRD, Organization, OrganizationMembership, OrganizationOrigin, @@ -14,8 +13,8 @@ from servala.core.models import ( Service, ServiceCategory, ServiceDefinition, - ServiceInstance, ServiceOffering, + ServiceOfferingControlPlane, User, ) @@ -57,18 +56,12 @@ class OrganizationMembershipInline(admin.TabularInline): @admin.register(Organization) class OrganizationAdmin(admin.ModelAdmin): - list_display = ("name", "namespace", "billing_entity", "origin") + list_display = ("name", "billing_entity", "origin") list_filter = ("origin",) - search_fields = ("name", "namespace") + search_fields = ("name",) autocomplete_fields = ("billing_entity", "origin") inlines = (OrganizationMembershipInline,) - def get_readonly_fields(self, request, obj=None): - readonly_fields = list(super().get_readonly_fields(request, obj) or []) - if obj: # If this is an edit (not a new organization) - readonly_fields.append("namespace") - return readonly_fields - @admin.register(BillingEntity) class BillingEntityAdmin(admin.ModelAdmin): @@ -131,7 +124,7 @@ class ControlPlaneAdmin(admin.ModelAdmin): fieldsets = ( ( None, - {"fields": ("name", "description", "cloud_provider", "required_label")}, + {"fields": ("name", "description", "cloud_provider")}, ), ( _("API Credentials"), @@ -207,55 +200,20 @@ class ServiceDefinitionAdmin(admin.ModelAdmin): return ["api_definition"] -class ControlPlaneCRDInline(admin.TabularInline): - model = ControlPlaneCRD +class ServiceOfferingControlPlaneInline(admin.TabularInline): + model = ServiceOfferingControlPlane extra = 1 autocomplete_fields = ("control_plane", "service_definition") -@admin.register(ControlPlaneCRD) -class ControlPlaneCRDAdmin(admin.ModelAdmin): +@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(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") @@ -263,6 +221,6 @@ class ServiceOfferingAdmin(admin.ModelAdmin): search_fields = ("description",) autocomplete_fields = ("service", "provider") inlines = ( - ControlPlaneCRDInline, + ServiceOfferingControlPlaneInline, PlanInline, ) diff --git a/src/servala/core/crd.py b/src/servala/core/crd.py index 2c2b9fb..6fc4219 100644 --- a/src/servala/core/crd.py +++ b/src/servala/core/crd.py @@ -1,46 +1,20 @@ 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): """ Generates a virtual Django model from a Kubernetes CRD's OpenAPI v3 schema. """ - # We always need these three fields to know our own name and our full namespace - model_fields = {"__module__": "crd_models"} - for field_name in ("name", "organization", "context"): - model_fields[field_name] = duplicate_field(field_name, ServiceInstance) - - # All other fields are generated from the schema, except for the - # resourceRef object spec = schema["properties"].get("spec") or {} - spec["properties"].pop("resourceRef", None) - model_fields.update(build_object_fields(spec, "spec", parent_required=True)) + # defaults = {"apiVersion": f"{group}/{version}", "kind": kind} - # Store the original schema on the model class - model_fields["SCHEMA"] = schema + model_fields = {"__module__": "crd_models"} + model_fields.update(build_object_fields(spec, "spec")) meta_class = type("Meta", (), {"app_label": "crd_models"}) model_fields["Meta"] = meta_class @@ -51,13 +25,13 @@ def generate_django_model(schema, group, version, kind): return model_class -def build_object_fields(schema, name, verbose_name_prefix=None, parent_required=False): +def build_object_fields(schema, name, verbose_name_prefix=None): required_fields = schema.get("required") or [] properties = schema.get("properties") or {} fields = {} for field_name, field_schema in properties.items(): - is_required = field_name in required_fields and parent_required + is_required = field_name in required_fields full_name = f"{name}.{field_name}" result = get_django_field( field_schema, @@ -88,9 +62,8 @@ def get_django_field( verbose_name_prefix = verbose_name_prefix or "" verbose_name = f"{verbose_name_prefix} {deslugify(field_name)}".strip() - # Pass down the requirement status from parent to child fields kwargs = { - "blank": not is_required, # All fields are optional by default + "blank": not is_required, "null": not is_required, "help_text": field_schema.get("description"), "validators": [], @@ -122,12 +95,8 @@ def get_django_field( elif field_type == "boolean": return models.BooleanField(**kwargs) elif field_type == "object": - # Here we pass down the requirement status to nested objects return build_object_fields( - field_schema, - full_name, - verbose_name_prefix=f"{verbose_name}:", - parent_required=is_required, + field_schema, full_name, verbose_name_prefix=f"{verbose_name}:" ) elif field_type == "array": # TODO: handle items / validate items, build multi-select input @@ -141,126 +110,8 @@ def get_django_field( class CrdModelFormMixin: def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self.schema = self._meta.model.SCHEMA - - for field in ("organization", "context"): - self.fields[field].widget = forms.HiddenInput() - - def strip_title(self, field_name, label): - field = self.fields[field_name] - if field and field.label.startswith(label): - field.label = field.label[len(label) :] - - def get_fieldsets(self): - fieldsets = [] - - # General fieldset for non-spec fields - general_fields = [ - field for field in self.fields if not field.startswith("spec.") - ] - if general_fields: - fieldsets.append( - {"title": "General", "fields": general_fields, "fieldsets": []} - ) - - # Process spec fields - others = [] - nested_fieldsets = {} - - for field_name in self.fields: - if field_name.startswith("spec."): - parts = field_name.split(".") - if len(parts) == 2: # Top-level spec field - others.append(field_name) - else: - parent_key = parts[1] - if not nested_fieldsets.get(parent_key): - nested_fieldsets[parent_key] = { - "fields": [], - "fieldsets": {}, - "title": deslugify(parent_key), - } - parent = nested_fieldsets[parent_key] - if len(parts) == 3: # Top-level within fieldset - parent["fields"].append(field_name) - else: - sub_key = parts[2] - if not parent["fieldsets"].get(sub_key): - parent["fieldsets"][sub_key] = { - "title": deslugify(sub_key), - "fields": [], - } - parent["fieldsets"][sub_key]["fields"].append(field_name) - - # Add nested fieldsets to fieldsets - for group in nested_fieldsets.values(): - total_fields = 0 - for fieldset in group["fieldsets"].values(): - if (field_count := len(fieldset["fields"])) == 1: - group["fields"].append(fieldset["fields"][0]) - fieldset["fields"] = [] - else: - title = f"{group['title']}: {fieldset['title']}: " - for field in fieldset["fields"]: - self.strip_title(field, title) - total_fields += field_count - if (total_fields + len(group["fields"])) == 1: - others.append(group["fields"][0]) - else: - title = f"{group['title']}: " - for field in group["fields"]: - self.strip_title(field, title) - fieldsets.append(group) - - # Add 'others' tab if there are any fields - if others: - fieldsets.append({"title": "Others", "fields": others, "fieldsets": []}) - - return fieldsets - - 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 clean(self): - cleaned_data = super().clean() - self.validate_nested_data( - self.get_nested_data().get("spec", {}), self.schema["properties"]["spec"] - ) - return cleaned_data - - def validate_nested_data(self, data, schema): - """Validate data against the provided OpenAPI v3 schema""" - # TODO: actually validate the nested data. - # TODO: get jsonschema to give us a path to the failing field rather than just an error message, - # then add the validation error to that field (self.add_error()) - # try: - # validate(instance=data, schema=schema) - # except Exception as e: - # raise forms.ValidationError(f"Validation error: {e.message}") - pass + # self.fields["apiVersion"].disabled = True + # self.fields["kind"].disabled = True def generate_model_form_class(model): diff --git a/src/servala/core/management/commands/make_superuser.py b/src/servala/core/management/commands/make_superuser.py index a060385..fe43ec8 100644 --- a/src/servala/core/management/commands/make_superuser.py +++ b/src/servala/core/management/commands/make_superuser.py @@ -22,6 +22,5 @@ class Command(BaseCommand): return user.is_superuser = True - user.is_staff = True user.save() self.stdout.write(self.style.SUCCESS(f"{user} is now a superuser.")) diff --git a/src/servala/core/migrations/0001_initial.py b/src/servala/core/migrations/0001_initial.py index 21ffdba..583f43d 100644 --- a/src/servala/core/migrations/0001_initial.py +++ b/src/servala/core/migrations/0001_initial.py @@ -1,13 +1,9 @@ -# Generated by Django 5.2b1 on 2025-03-31 09:20 +# Generated by Django 5.2b1 on 2025-03-16 08:44 -import django.core.validators import django.db.models.deletion -import encrypted_fields.fields -import rules.contrib.models from django.conf import settings from django.db import migrations, models -import servala.core.models.service import servala.core.models.user @@ -15,128 +11,9 @@ class Migration(migrations.Migration): initial = True - dependencies = [ - ("auth", "0012_alter_user_first_name_max_length"), - ] + dependencies = [] operations = [ - migrations.CreateModel( - name="BillingEntity", - 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")), - ( - "description", - models.TextField(blank=True, verbose_name="Description"), - ), - ( - "erp_reference", - models.CharField( - blank=True, max_length=100, verbose_name="ERP reference" - ), - ), - ], - options={ - "verbose_name": "Billing entity", - "verbose_name_plural": "Billing entities", - }, - bases=(rules.contrib.models.RulesModelMixin, models.Model), - ), - migrations.CreateModel( - name="CloudProvider", - 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")), - ( - "description", - models.TextField(blank=True, verbose_name="Description"), - ), - ( - "logo", - models.ImageField( - blank=True, - null=True, - upload_to="public/service_providers", - verbose_name="Logo", - ), - ), - ( - "external_links", - models.JSONField( - blank=True, null=True, verbose_name="External links" - ), - ), - ], - options={ - "verbose_name": "Cloud provider", - "verbose_name_plural": "Cloud providers", - }, - bases=(rules.contrib.models.RulesModelMixin, models.Model), - ), - migrations.CreateModel( - name="OrganizationOrigin", - 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")), - ( - "description", - models.TextField(blank=True, verbose_name="Description"), - ), - ], - options={ - "verbose_name": "Organization origin", - "verbose_name_plural": "Organization origins", - }, - bases=(rules.contrib.models.RulesModelMixin, models.Model), - ), migrations.CreateModel( name="User", fields=[ @@ -156,14 +33,6 @@ class Migration(migrations.Migration): blank=True, null=True, verbose_name="last login" ), ), - ( - "created_at", - models.DateTimeField(auto_now_add=True, verbose_name="Created"), - ), - ( - "updated_at", - models.DateTimeField(auto_now=True, verbose_name="Last updated"), - ), ( "email", models.EmailField( @@ -204,38 +73,105 @@ class Migration(migrations.Migration): verbose_name="Is superuser", ), ), - ( - "groups", - models.ManyToManyField( - blank=True, - help_text="The groups this user belongs to. A user will get all permissions granted to each of their groups.", - related_name="user_set", - related_query_name="user", - to="auth.group", - verbose_name="groups", - ), - ), - ( - "user_permissions", - models.ManyToManyField( - blank=True, - help_text="Specific permissions for this user.", - related_name="user_set", - related_query_name="user", - to="auth.permission", - verbose_name="user permissions", - ), - ), ], options={ "verbose_name": "User", "verbose_name_plural": "Users", }, - bases=(rules.contrib.models.RulesModelMixin, models.Model), managers=[ ("objects", servala.core.models.user.UserManager()), ], ), + migrations.CreateModel( + name="BillingEntity", + 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"), + ), + ( + "erp_reference", + models.CharField( + blank=True, max_length=100, verbose_name="ERP reference" + ), + ), + ], + options={ + "verbose_name": "Billing entity", + "verbose_name_plural": "Billing entities", + }, + ), + migrations.CreateModel( + name="CloudProvider", + 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"), + ), + ( + "logo", + models.ImageField( + blank=True, + null=True, + upload_to="public/service_providers", + verbose_name="Logo", + ), + ), + ( + "external_links", + models.JSONField( + blank=True, null=True, verbose_name="External links" + ), + ), + ], + options={ + "verbose_name": "Cloud provider", + "verbose_name_plural": "Cloud providers", + }, + ), + migrations.CreateModel( + name="OrganizationOrigin", + 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"), + ), + ], + options={ + "verbose_name": "Organization origin", + "verbose_name_plural": "Organization origins", + }, + ), migrations.CreateModel( name="ControlPlane", fields=[ @@ -248,29 +184,16 @@ class Migration(migrations.Migration): 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")), ( "description", models.TextField(blank=True, verbose_name="Description"), ), ( - "api_credentials", - encrypted_fields.fields.EncryptedJSONField( - help_text="Required fields: certificate-authority-data, server (URL), token", - validators=[ - servala.core.models.service.validate_api_credentials - ], - verbose_name="API credentials", - ), + "k8s_api_endpoint", + models.URLField(verbose_name="Kubernetes API endpoint"), ), + ("api_credentials", models.JSONField(verbose_name="API credentials")), ( "cloud_provider", models.ForeignKey( @@ -285,7 +208,6 @@ class Migration(migrations.Migration): "verbose_name": "Control plane", "verbose_name_plural": "Control planes", }, - bases=(rules.contrib.models.RulesModelMixin, models.Model), ), migrations.CreateModel( name="Organization", @@ -299,35 +221,10 @@ class Migration(migrations.Migration): 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")), - ( - "namespace", - 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", - ), - ), ( "billing_entity", models.ForeignKey( - null=True, on_delete=django.db.models.deletion.PROTECT, related_name="organizations", to="core.billingentity", @@ -339,7 +236,6 @@ class Migration(migrations.Migration): "verbose_name": "Organization", "verbose_name_plural": "Organizations", }, - bases=(rules.contrib.models.RulesModelMixin, models.Model), ), migrations.CreateModel( name="OrganizationMembership", @@ -353,14 +249,6 @@ class Migration(migrations.Migration): 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"), - ), ( "date_joined", models.DateTimeField(auto_now_add=True, verbose_name="Date joined"), @@ -401,7 +289,6 @@ class Migration(migrations.Migration): "verbose_name": "Organization membership", "verbose_name_plural": "Organization memberships", }, - bases=(rules.contrib.models.RulesModelMixin, models.Model), ), migrations.AddField( model_name="organization", @@ -435,14 +322,6 @@ class Migration(migrations.Migration): 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")), ( "description", @@ -473,7 +352,6 @@ class Migration(migrations.Migration): "verbose_name": "Service category", "verbose_name_plural": "Service categories", }, - bases=(rules.contrib.models.RulesModelMixin, models.Model), ), migrations.CreateModel( name="Service", @@ -487,21 +365,7 @@ class Migration(migrations.Migration): 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")), - ( - "slug", - models.SlugField( - max_length=100, unique=True, verbose_name="URL slug" - ), - ), ( "description", models.TextField(blank=True, verbose_name="Description"), @@ -535,57 +399,6 @@ class Migration(migrations.Migration): "verbose_name": "Service", "verbose_name_plural": "Services", }, - bases=(rules.contrib.models.RulesModelMixin, models.Model), - ), - migrations.CreateModel( - name="ServiceDefinition", - 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")), - ( - "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", - }, - bases=(rules.contrib.models.RulesModelMixin, models.Model), ), migrations.CreateModel( name="ServiceOffering", @@ -599,18 +412,18 @@ class Migration(migrations.Migration): 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"), - ), ( "description", models.TextField(blank=True, verbose_name="Description"), ), + ( + "control_plane", + models.ManyToManyField( + related_name="offerings", + to="core.controlplane", + verbose_name="Control planes", + ), + ), ( "provider", models.ForeignKey( @@ -634,7 +447,6 @@ class Migration(migrations.Migration): "verbose_name": "Service offering", "verbose_name_plural": "Service offerings", }, - bases=(rules.contrib.models.RulesModelMixin, models.Model), ), migrations.CreateModel( name="Plan", @@ -648,14 +460,6 @@ class Migration(migrations.Migration): 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")), ( "description", @@ -689,142 +493,5 @@ class Migration(migrations.Migration): "verbose_name": "Plan", "verbose_name_plural": "Plans", }, - bases=(rules.contrib.models.RulesModelMixin, models.Model), - ), - migrations.CreateModel( - name="ControlPlaneCRD", - 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"), - ), - ( - "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")}, - }, - bases=(rules.contrib.models.RulesModelMixin, models.Model), - ), - 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=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", - ), - ), - ("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.controlplanecrd", - ), - ), - ( - "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/0002_alter_controlplanecrd_options_and_more.py b/src/servala/core/migrations/0002_alter_controlplanecrd_options_and_more.py deleted file mode 100644 index 63cab45..0000000 --- a/src/servala/core/migrations/0002_alter_controlplanecrd_options_and_more.py +++ /dev/null @@ -1,31 +0,0 @@ -# Generated by Django 5.2b1 on 2025-04-03 15:24 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("core", "0001_initial"), - ] - - operations = [ - migrations.AlterModelOptions( - name="controlplanecrd", - options={ - "verbose_name": "ControlPlane CRD", - "verbose_name_plural": "ControlPlane CRDs", - }, - ), - migrations.AddField( - model_name="controlplane", - name="required_label", - field=models.CharField( - blank=True, - help_text="Label value for the 'appcat.vshn.io/provider-config' added to every instance on this plane.", - max_length=100, - null=True, - verbose_name="Required Label", - ), - ), - ] diff --git a/src/servala/core/migrations/0002_billingentity_created_at_billingentity_updated_at_and_more.py b/src/servala/core/migrations/0002_billingentity_created_at_billingentity_updated_at_and_more.py new file mode 100644 index 0000000..83d5c00 --- /dev/null +++ b/src/servala/core/migrations/0002_billingentity_created_at_billingentity_updated_at_and_more.py @@ -0,0 +1,89 @@ +# Generated by Django 5.2b1 on 2025-03-17 06:19 + +import django.utils.timezone +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("core", "0001_initial"), + ] + + operations = [ + migrations.AddField( + model_name="billingentity", + name="created_at", + field=models.DateTimeField( + auto_now_add=True, + default=django.utils.timezone.now, + verbose_name="Created", + ), + preserve_default=False, + ), + migrations.AddField( + model_name="billingentity", + name="updated_at", + field=models.DateTimeField(auto_now=True, verbose_name="Last updated"), + ), + migrations.AddField( + model_name="organization", + name="created_at", + field=models.DateTimeField( + auto_now_add=True, + default=django.utils.timezone.now, + verbose_name="Created", + ), + preserve_default=False, + ), + migrations.AddField( + model_name="organization", + name="updated_at", + field=models.DateTimeField(auto_now=True, verbose_name="Last updated"), + ), + migrations.AddField( + model_name="organizationmembership", + name="created_at", + field=models.DateTimeField( + auto_now_add=True, + default=django.utils.timezone.now, + verbose_name="Created", + ), + preserve_default=False, + ), + migrations.AddField( + model_name="organizationmembership", + name="updated_at", + field=models.DateTimeField(auto_now=True, verbose_name="Last updated"), + ), + migrations.AddField( + model_name="organizationorigin", + name="created_at", + field=models.DateTimeField( + auto_now_add=True, + default=django.utils.timezone.now, + verbose_name="Created", + ), + preserve_default=False, + ), + migrations.AddField( + model_name="organizationorigin", + name="updated_at", + field=models.DateTimeField(auto_now=True, verbose_name="Last updated"), + ), + migrations.AddField( + model_name="user", + name="created_at", + field=models.DateTimeField( + auto_now_add=True, + default=django.utils.timezone.now, + verbose_name="Created", + ), + preserve_default=False, + ), + migrations.AddField( + model_name="user", + name="updated_at", + field=models.DateTimeField(auto_now=True, verbose_name="Last updated"), + ), + ] diff --git a/src/servala/core/migrations/0003_billing_entity_nullable.py b/src/servala/core/migrations/0003_billing_entity_nullable.py new file mode 100644 index 0000000..bae2ae2 --- /dev/null +++ b/src/servala/core/migrations/0003_billing_entity_nullable.py @@ -0,0 +1,25 @@ +# Generated by Django 5.2b1 on 2025-03-20 08:12 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("core", "0002_billingentity_created_at_billingentity_updated_at_and_more"), + ] + + operations = [ + migrations.AlterField( + model_name="organization", + name="billing_entity", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="organizations", + to="core.billingentity", + verbose_name="Billing entity", + ), + ), + ] diff --git a/src/servala/core/migrations/0004_encrypt_api_credentials.py b/src/servala/core/migrations/0004_encrypt_api_credentials.py new file mode 100644 index 0000000..9ca4acf --- /dev/null +++ b/src/servala/core/migrations/0004_encrypt_api_credentials.py @@ -0,0 +1,46 @@ +# Generated by Django 5.2b1 on 2025-03-24 06:33 + +import encrypted_fields.fields +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("auth", "0012_alter_user_first_name_max_length"), + ("core", "0003_billing_entity_nullable"), + ] + + operations = [ + migrations.AddField( + model_name="user", + name="groups", + field=models.ManyToManyField( + blank=True, + help_text="The groups this user belongs to. A user will get all permissions granted to each of their groups.", + related_name="user_set", + related_query_name="user", + to="auth.group", + verbose_name="groups", + ), + ), + migrations.AddField( + model_name="user", + name="user_permissions", + field=models.ManyToManyField( + blank=True, + help_text="Specific permissions for this user.", + related_name="user_set", + related_query_name="user", + to="auth.permission", + verbose_name="user permissions", + ), + ), + migrations.AlterField( + model_name="controlplane", + name="api_credentials", + field=encrypted_fields.fields.EncryptedJSONField( + verbose_name="API credentials" + ), + ), + ] diff --git a/src/servala/core/migrations/0005_remove_controlplane_k8s_api_endpoint.py b/src/servala/core/migrations/0005_remove_controlplane_k8s_api_endpoint.py new file mode 100644 index 0000000..b704b0d --- /dev/null +++ b/src/servala/core/migrations/0005_remove_controlplane_k8s_api_endpoint.py @@ -0,0 +1,29 @@ +# Generated by Django 5.2b1 on 2025-03-24 10:27 + +import encrypted_fields.fields +from django.db import migrations + +import servala.core.models.service + + +class Migration(migrations.Migration): + + dependencies = [ + ("core", "0004_encrypt_api_credentials"), + ] + + operations = [ + migrations.RemoveField( + model_name="controlplane", + name="k8s_api_endpoint", + ), + migrations.AlterField( + model_name="controlplane", + name="api_credentials", + field=encrypted_fields.fields.EncryptedJSONField( + help_text="Required fields: certificate-authority-data, server (URL), token", + validators=[servala.core.models.service.validate_api_credentials], + verbose_name="API credentials", + ), + ), + ] diff --git a/src/servala/core/migrations/0006_service_slug.py b/src/servala/core/migrations/0006_service_slug.py new file mode 100644 index 0000000..f15e796 --- /dev/null +++ b/src/servala/core/migrations/0006_service_slug.py @@ -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, + ), + ] diff --git a/src/servala/core/migrations/0007_service_definition.py b/src/servala/core/migrations/0007_service_definition.py new file mode 100644 index 0000000..be0fac6 --- /dev/null +++ b/src/servala/core/migrations/0007_service_definition.py @@ -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", + ), + ), + ] diff --git a/src/servala/core/models/__init__.py b/src/servala/core/models/__init__.py index 3fb0663..6d5b24d 100644 --- a/src/servala/core/models/__init__.py +++ b/src/servala/core/models/__init__.py @@ -8,13 +8,12 @@ from .organization import ( from .service import ( CloudProvider, ControlPlane, - ControlPlaneCRD, Plan, Service, ServiceCategory, ServiceDefinition, - ServiceInstance, ServiceOffering, + ServiceOfferingControlPlane, ) from .user import User @@ -22,7 +21,6 @@ __all__ = [ "BillingEntity", "CloudProvider", "ControlPlane", - "ControlPlaneCRD", "Organization", "OrganizationMembership", "OrganizationOrigin", @@ -30,8 +28,8 @@ __all__ = [ "Plan", "Service", "ServiceCategory", - "ServiceInstance", "ServiceDefinition", "ServiceOffering", + "ServiceOfferingControlPlane", "User", ] diff --git a/src/servala/core/models/organization.py b/src/servala/core/models/organization.py index 62894d5..e842c36 100644 --- a/src/servala/core/models/organization.py +++ b/src/servala/core/models/organization.py @@ -9,20 +9,10 @@ from django_scopes import ScopedManager, scopes_disabled from servala.core import rules as perms from servala.core.models.mixins import ServalaModelMixin -from servala.core.validators import kubernetes_name_validator class Organization(ServalaModelMixin, models.Model): name = models.CharField(max_length=100, verbose_name=_("Name")) - namespace = models.CharField( - max_length=63, - verbose_name=_("Kubernetes Namespace"), - unique=True, - help_text=_( - "This namespace will be used for all Kubernetes resources. Cannot be changed after creation." - ), - validators=[kubernetes_name_validator], - ) billing_entity = models.ForeignKey( to="BillingEntity", @@ -49,7 +39,6 @@ class Organization(ServalaModelMixin, models.Model): base = "/org/{self.slug}/" details = "{base}details/" services = "{base}services/" - instances = "{base}instances/" @cached_property def slug(self): diff --git a/src/servala/core/models/service.py b/src/servala/core/models/service.py index 41cc896..c4d2b79 100644 --- a/src/servala/core/models/service.py +++ b/src/servala/core/models/service.py @@ -1,22 +1,15 @@ -import json - import kubernetes -import urlman -from django.conf import settings from django.core.cache import cache from django.core.exceptions import ValidationError -from django.db import IntegrityError, models +from django.db import models from django.utils.functional import cached_property from django.utils.translation import gettext_lazy as _ from encrypted_fields.fields import EncryptedJSONField -from kubernetes import client, config +from kubernetes import config from kubernetes.client.rest import ApiException -from servala.core.models.mixins import ServalaModelMixin -from servala.core.validators import kubernetes_name_validator - -class ServiceCategory(ServalaModelMixin, models.Model): +class ServiceCategory(models.Model): """ Categories for services, e.g. "Databases", "Storage", "Compute". """ @@ -47,7 +40,7 @@ class ServiceCategory(ServalaModelMixin, models.Model): return self.name -class Service(ServalaModelMixin, models.Model): +class Service(models.Model): """ A service that can be offered, e.g. "PostgreSQL", "MinIO", "Kubernetes". """ @@ -96,11 +89,7 @@ def validate_api_credentials(value): return validate_dict(value, required_fields) -class ControlPlane(ServalaModelMixin, models.Model): - """ - Note: ControlPlanes are called "Service Provider Zone" in the user-facing frontend. - """ - +class ControlPlane(models.Model): name = models.CharField(max_length=100, verbose_name=_("Name")) description = models.TextField(blank=True, verbose_name=_("Description")) # Either contains the fields "certificate_authority_data", "server" and "token", or is empty @@ -109,15 +98,6 @@ class ControlPlane(ServalaModelMixin, models.Model): help_text="Required fields: certificate-authority-data, server (URL), token", validators=[validate_api_credentials], ) - required_label = models.CharField( - max_length=100, - blank=True, - null=True, - verbose_name=_("Required Label"), - help_text=_( - "Label value for the 'appcat.vshn.io/provider-config' added to every instance on this plane." - ), - ) cloud_provider = models.ForeignKey( to="CloudProvider", @@ -133,12 +113,6 @@ class ControlPlane(ServalaModelMixin, models.Model): def __str__(self): return self.name - @property - def service_definitions(self): - return ServiceDefinition.objects.filter( - offering_control_planes__control_plane=self - ).distinct() - @property def kubernetes_config(self): conf = kubernetes.client.Configuration() @@ -202,23 +176,8 @@ class ControlPlane(ServalaModelMixin, models.Model): except Exception as e: return False, _("Connection error: {}").format(str(e)) - def get_or_create_namespace(self, name): - api_instance = kubernetes.client.CoreV1Api(self.get_kubernetes_client()) - try: - api_instance.read_namespace(name=name) - except kubernetes.client.ApiException as e: - if e.status == 404: - # Namespace does not exist, create it - body = kubernetes.client.V1Namespace( - metadata=kubernetes.client.V1ObjectMeta(name=name) - ) - api_instance.create_namespace(body=body) - else: - # If there's another error, raise it - raise - -class CloudProvider(ServalaModelMixin, models.Model): +class CloudProvider(models.Model): """ A cloud provider, e.g. "Exoscale". """ @@ -243,7 +202,7 @@ class CloudProvider(ServalaModelMixin, models.Model): return self.name -class Plan(ServalaModelMixin, models.Model): +class Plan(models.Model): """ Each service offering can have multiple plans, e.g. for different tiers. """ @@ -277,7 +236,7 @@ def validate_api_definition(value): return validate_dict(value, required_fields) -class ServiceDefinition(ServalaModelMixin, models.Model): +class ServiceDefinition(models.Model): """ Configuration/service implementation: contains information on which CompositeResourceDefinition (aka XRD) implements a service on a ControlPlane. @@ -320,10 +279,10 @@ class ServiceDefinition(ServalaModelMixin, models.Model): return self.name -class ControlPlaneCRD(ServalaModelMixin, models.Model): +class ServiceOfferingControlPlane(models.Model): """ Each combination of ServiceOffering and ControlPlane can have a different - ServiceDefinition, which is here modeled as basically a "through" model. + ServiceDefinition, which is here modeled as the "through" model. """ service_offering = models.ForeignKey( @@ -346,8 +305,8 @@ class ControlPlaneCRD(ServalaModelMixin, models.Model): ) class Meta: - verbose_name = _("ControlPlane CRD") - verbose_name_plural = _("ControlPlane CRDs") + verbose_name = _("Service offering control plane connection") + verbose_name_plural = _("Service offering control planes connections") unique_together = [("service_offering", "control_plane")] def __str__(self): @@ -406,7 +365,7 @@ class ControlPlaneCRD(ServalaModelMixin, models.Model): return generate_model_form_class(self.django_model) -class ServiceOffering(ServalaModelMixin, models.Model): +class ServiceOffering(models.Model): """ A service offering, e.g. "PostgreSQL on AWS", "MinIO on GCP". """ @@ -423,6 +382,12 @@ class ServiceOffering(ServalaModelMixin, models.Model): related_name="offerings", verbose_name=_("Provider"), ) + control_planes = models.ManyToManyField( + to="ControlPlane", + through="ServiceOfferingControlPlane", + related_name="offerings", + verbose_name=_("Control planes"), + ) description = models.TextField(blank=True, verbose_name=_("Description")) class Meta: @@ -433,120 +398,3 @@ class ServiceOffering(ServalaModelMixin, models.Model): return _("{service_name} at {provider_name}").format( service_name=self.service.name, provider_name=self.provider.name ) - - @property - def control_planes(self): - return ControlPlane.objects.filter( - offering_connections__service_offering=self - ).distinct() - - -class ServiceInstance(ServalaModelMixin, models.Model): - """ - The source of truth for service instances is Kubernetes. - The Django model only contains metadata, all other information is queried - on the fly. - """ - - name = models.CharField( - max_length=63, verbose_name=_("Name"), validators=[kubernetes_name_validator] - ) - organization = models.ForeignKey( - to="core.Organization", - on_delete=models.PROTECT, - verbose_name=_("Organization"), - related_name="service_instances", - ) - created_by = models.ForeignKey( - to="core.User", - on_delete=models.SET_NULL, - null=True, - blank=True, - related_name="+", - ) - context = models.ForeignKey( - to="core.ControlPlaneCRD", - related_name="service_instances", - on_delete=models.PROTECT, - ) - - is_deleted = models.BooleanField(default=False) - deleted_at = models.DateTimeField(null=True, blank=True) - deleted_by = models.ForeignKey( - to="core.User", - on_delete=models.SET_NULL, - null=True, - blank=True, - related_name="+", - ) - - class Meta: - verbose_name = _("Service instance") - verbose_name_plural = _("Service instances") - # 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, name, organization, context, created_by, spec_data): - # Ensure the namespace exists - context.control_plane.get_or_create_namespace(organization.namespace) - try: - instance = cls.objects.create( - name=name, - organization=organization, - created_by=created_by, - context=context, - ) - except IntegrityError: - raise ValidationError( - _( - "An instance with this name already exists in this organization. Please choose a different name." - ) - ) - - try: - group = context.service_definition.api_definition["group"] - version = context.service_definition.api_definition["version"] - kind = context.service_definition.api_definition["kind"] - create_data = { - "apiVersion": f"{group}/{version}", - "kind": kind, - "metadata": { - "name": name, - "namespace": organization.namespace, - }, - "spec": spec_data or {}, - } - if label := context.control_plane.required_label: - create_data["metadata"]["labels"] = [ - {settings.DEFAULT_LABEL_KEY: label} - ] - api_instance = client.CustomObjectsApi( - context.control_plane.get_kubernetes_client() - ) - plural = kind.lower() - if not plural.endswith("s"): - plural = f"{plural}s" - - api_instance.create_namespaced_custom_object( - group=group, - version=version, - namespace=organization.namespace, - plural=plural, - body=create_data, - ) - except Exception as e: - instance.delete() - if isinstance(e, ApiException): - try: - error_body = json.loads(e.body) - reason = error_body.get("message", str(e)) - raise ValidationError(_("Kubernetes API error: {}").format(reason)) - except (ValueError, TypeError): - raise ValidationError(_("Kubernetes API error: {}").format(str(e))) - raise ValidationError(_("Error creating instance: {}").format(str(e))) - return instance diff --git a/src/servala/core/validators.py b/src/servala/core/validators.py deleted file mode 100644 index 1723bb5..0000000 --- a/src/servala/core/validators.py +++ /dev/null @@ -1,12 +0,0 @@ -from django.core.validators import RegexValidator -from django.utils.translation import gettext_lazy as _ - -# Kubernetes resource name validator - follows the same rules as namespace names -kubernetes_name_validator = RegexValidator( - regex=r"^[a-z0-9]([-a-z0-9]*[a-z0-9])?$", - message=_( - 'Name must consist of lowercase alphanumeric characters or "-", ' - "must start and end with an alphanumeric character." - ), - code="invalid_kubernetes_name", -) diff --git a/src/servala/frontend/forms/organization.py b/src/servala/frontend/forms/organization.py index d582ce9..41cc26c 100644 --- a/src/servala/frontend/forms/organization.py +++ b/src/servala/frontend/forms/organization.py @@ -1,6 +1,4 @@ -from django import forms from django.forms import ModelForm -from django.utils.translation import gettext_lazy as _ from servala.core.models import Organization from servala.frontend.forms.mixins import HtmxMixin @@ -9,19 +7,4 @@ from servala.frontend.forms.mixins import HtmxMixin class OrganizationForm(HtmxMixin, ModelForm): class Meta: model = Organization - fields = ("name", "namespace") - widgets = { - "namespace": forms.TextInput( - attrs={ - "pattern": "[a-z0-9]([-a-z0-9]*[a-z0-9])?", - "title": _( - 'Lowercase alphanumeric characters or "-", must start and end with alphanumeric' - ), - } - ) - } - help_texts = { - "namespace": _( - "This namespace will be used for all resources and cannot be changed later." - ) - } + fields = ("name",) diff --git a/src/servala/frontend/forms/renderers.py b/src/servala/frontend/forms/renderers.py index b6a9995..24e412b 100644 --- a/src/servala/frontend/forms/renderers.py +++ b/src/servala/frontend/forms/renderers.py @@ -17,17 +17,15 @@ class VerticalFormRenderer(TemplatesSetting): form_template_name = "frontend/forms/form.html" field_template_name = "frontend/forms/vertical_field.html" - def get_class_names(self, field): - input_type = self.get_field_input_type(field) - errors = "is-invalid " if field.errors else "" + def get_class_names(self, input_type): if input_type == "checkbox": - return f"{errors}form-check-input" - return f"{errors}form-control" + return "form-check-input" + return "form-control" def get_widget_input_type(self, widget): if isinstance(widget, Textarea): return "textarea" - return getattr(widget, "input_type", None) + return widget.input_type def get_field_input_type(self, field): widget = field.field.widget @@ -37,8 +35,9 @@ class VerticalFormRenderer(TemplatesSetting): def render(self, template_name, context, request=None): if field := context.get("field"): + input_type = self.get_field_input_type(field) field.build_widget_attrs = inject_class( - field.build_widget_attrs, self.get_class_names(field) + field.build_widget_attrs, self.get_class_names(input_type) ) return super().render(template_name, context, request) diff --git a/src/servala/frontend/forms/service.py b/src/servala/frontend/forms/service.py index 3b7a547..0afb1b5 100644 --- a/src/servala/frontend/forms/service.py +++ b/src/servala/frontend/forms/service.py @@ -1,5 +1,4 @@ from django import forms -from django.utils.translation import gettext_lazy as _ from servala.core.models import CloudProvider, ControlPlane, ServiceCategory @@ -24,11 +23,7 @@ class ServiceFilterForm(forms.Form): class ControlPlaneSelectForm(forms.Form): - control_plane = forms.ModelChoiceField( - queryset=ControlPlane.objects.none(), - label=_("Service Provider Zone"), - empty_label=None, - ) + control_plane = forms.ModelChoiceField(queryset=ControlPlane.objects.none()) def __init__(self, *args, planes=None, **kwargs): super().__init__(*args, **kwargs) diff --git a/src/servala/frontend/templates/frontend/forms/field.html b/src/servala/frontend/templates/frontend/forms/field.html index 3e0a30b..fe56619 100644 --- a/src/servala/frontend/templates/frontend/forms/field.html +++ b/src/servala/frontend/templates/frontend/forms/field.html @@ -1,6 +1,6 @@ {% load i18n %} -