diff --git a/.forgejo/workflows/build-deploy-prod.yaml b/.forgejo/workflows/build-deploy-prod.yaml
index ddaeb1c..53d1e32 100644
--- a/.forgejo/workflows/build-deploy-prod.yaml
+++ b/.forgejo/workflows/build-deploy-prod.yaml
@@ -12,9 +12,6 @@ on:
- "pyproject.toml"
- "uv.lock"
workflow_dispatch:
- release:
- types:
- - published
jobs:
build:
@@ -26,7 +23,7 @@ jobs:
steps:
- name: Checkout repository
- uses: actions/checkout@v6
+ uses: actions/checkout@v5
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
@@ -72,7 +69,7 @@ jobs:
steps:
- name: Checkout repository
- uses: actions/checkout@v6
+ uses: actions/checkout@v5
- name: Determine image tag
id: determine-tag
diff --git a/.forgejo/workflows/build-deploy-staging.yaml b/.forgejo/workflows/build-deploy-staging.yaml
index 8f438cd..93c77b2 100644
--- a/.forgejo/workflows/build-deploy-staging.yaml
+++ b/.forgejo/workflows/build-deploy-staging.yaml
@@ -22,7 +22,7 @@ jobs:
steps:
- name: Checkout repository
- uses: actions/checkout@v6
+ uses: actions/checkout@v5
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
@@ -53,7 +53,7 @@ jobs:
steps:
- name: Checkout repository
- uses: actions/checkout@v6
+ uses: actions/checkout@v5
- name: Deploy to OpenShift
uses: docker://quay.io/appuio/oc:v4.19
diff --git a/.forgejo/workflows/docs.yaml b/.forgejo/workflows/docs.yaml
index b1e5fe5..0b6c77c 100644
--- a/.forgejo/workflows/docs.yaml
+++ b/.forgejo/workflows/docs.yaml
@@ -17,7 +17,7 @@ jobs:
steps:
- name: Checkout repository
- uses: actions/checkout@v6
+ uses: actions/checkout@v5
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
@@ -49,7 +49,7 @@ jobs:
steps:
- name: Checkout repository
- uses: actions/checkout@v6
+ uses: actions/checkout@v5
- name: Deploy to OpenShift
uses: docker://quay.io/appuio/oc:v4.19
diff --git a/.forgejo/workflows/renovate.yaml b/.forgejo/workflows/renovate.yaml
index a2577d9..19e5ce8 100644
--- a/.forgejo/workflows/renovate.yaml
+++ b/.forgejo/workflows/renovate.yaml
@@ -11,7 +11,7 @@ jobs:
container: catthehacker/ubuntu:act-latest
steps:
- name: Checkout
- uses: actions/checkout@v6
+ uses: actions/checkout@v5
- name: Setup Node.js
uses: actions/setup-node@v6
@@ -19,7 +19,7 @@ jobs:
node-version: "24"
- name: Renovate
- uses: https://github.com/renovatebot/github-action@v44.0.5
+ uses: https://github.com/renovatebot/github-action@v43.0.19
with:
token: ${{ secrets.RENOVATE_TOKEN }}
env:
diff --git a/.forgejo/workflows/tests.yaml b/.forgejo/workflows/tests.yaml
index b2cddd0..e3900b3 100644
--- a/.forgejo/workflows/tests.yaml
+++ b/.forgejo/workflows/tests.yaml
@@ -18,7 +18,7 @@ jobs:
steps:
- name: Checkout repository
- uses: actions/checkout@v6
+ uses: actions/checkout@v5
- name: Setup Node.js
uses: actions/setup-node@v6
diff --git a/.python-version b/.python-version
index 6324d40..24ee5b1 100644
--- a/.python-version
+++ b/.python-version
@@ -1 +1 @@
-3.14
+3.13
diff --git a/Dockerfile b/Dockerfile
index b1af0a7..5727f03 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,4 +1,4 @@
-FROM python:3.14-slim
+FROM python:3.13-slim
EXPOSE 8000
WORKDIR /app
diff --git a/README.md b/README.md
index eaa1cdd..d814ce4 100644
--- a/README.md
+++ b/README.md
@@ -4,7 +4,7 @@
The Servala Self-Service Portal
-Latest release: 2025.11.17-0
+Latest release: 2025.10.27-0
## Documentation
diff --git a/docs/modules/ROOT/pages/web-portal-changelog.adoc b/docs/modules/ROOT/pages/web-portal-changelog.adoc
index 0c5de9c..bf2d3aa 100644
--- a/docs/modules/ROOT/pages/web-portal-changelog.adoc
+++ b/docs/modules/ROOT/pages/web-portal-changelog.adoc
@@ -1,82 +1,5 @@
= Portal Changelog
-== 2025.11.17-0
-
-=== API
-* Exoscale offboarding MVP (link:https://servala.app.codey.ch/servala/servala-portal/pulls/282[#282])
-
-=== UI/UX
-* Allow admins to disable the expert mode form (link:https://servala.app.codey.ch/servala/servala-portal/pulls/296[#296])
-* Support single (non-array) FQDN values (link:https://servala.app.codey.ch/servala/servala-portal/pulls/295[#295])
-* "View Availability" is now "Get It" (link:https://servala.app.codey.ch/servala/servala-portal/pulls/285[#285])
-* Add "open" button to instances with FQDN (link:https://servala.app.codey.ch/servala/servala-portal/pulls/283[#283])
-* Hide billing addresses (link:https://servala.app.codey.ch/servala/servala-portal/pulls/281[#281])
-* Custom form configuration (link:https://servala.app.codey.ch/servala/servala-portal/pulls/268[#268])
-* Skip offering selection if there is only one (link:https://servala.app.codey.ch/servala/servala-portal/pulls/273[#273])
-* Make it more clear how to register an account (link:https://servala.app.codey.ch/servala/servala-portal/pulls/270[#270])
-
-=== dependencies
-* Update dependency django-template-partials to >=25.3 (link:https://servala.app.codey.ch/servala/servala-portal/pulls/297[#297])
-* Lock file maintenance (link:https://servala.app.codey.ch/servala/servala-portal/pulls/298[#298])
-* Update dependency pytest to >=9.0.1 (link:https://servala.app.codey.ch/servala/servala-portal/pulls/284[#284])
-* Update Python to 3.14 tag (link:https://servala.app.codey.ch/servala/servala-portal/pulls/272[#272])
-* Update dependency django-fernet-encrypted-fields to >=0.3.1 (link:https://servala.app.codey.ch/servala/servala-portal/pulls/278[#278])
-* Update https://github.com/renovatebot/github-action action to v44.0.2 (link:https://servala.app.codey.ch/servala/servala-portal/pulls/279[#279])
-* Update dependency sentry-sdk to >=2.44.0 (link:https://servala.app.codey.ch/servala/servala-portal/pulls/280[#280])
-* Update dependency coverage to >=7.11.3 (link:https://servala.app.codey.ch/servala/servala-portal/pulls/274[#274])
-* Update dependency pytest to v9 (link:https://servala.app.codey.ch/servala/servala-portal/pulls/276[#276])
-* Update dependency black to >=25.11.0 (link:https://servala.app.codey.ch/servala/servala-portal/pulls/277[#277])
-* Update https://github.com/renovatebot/github-action action to v44 (link:https://servala.app.codey.ch/servala/servala-portal/pulls/275[#275])
-* Update dependency django to v5.2.8 (link:https://servala.app.codey.ch/servala/servala-portal/pulls/271[#271])
-* Update dependency django-allauth to >=65.13.0 (link:https://servala.app.codey.ch/servala/servala-portal/pulls/265[#265])
-* Lock file maintenance (link:https://servala.app.codey.ch/servala/servala-portal/pulls/266[#266])
-* Update https://github.com/renovatebot/github-action action to v43.0.20 (link:https://servala.app.codey.ch/servala/servala-portal/pulls/267[#267])
-* Update https://github.com/renovatebot/github-action action to v43.0.19 (link:https://servala.app.codey.ch/servala/servala-portal/pulls/259[#259])
-* Update dependency node to v24 (link:https://servala.app.codey.ch/servala/servala-portal/pulls/260[#260])
-* Update dependency sentry-sdk to >=2.43.0 (link:https://servala.app.codey.ch/servala/servala-portal/pulls/261[#261])
-
-
-== 2025.11.13-0
-
-=== UI/UX
-* "View Availability" is now "Get It" (link:https://servala.app.codey.ch/servala/servala-portal/pulls/285[#285])
-* Add "open" button to instances with FQDN (link:https://servala.app.codey.ch/servala/servala-portal/pulls/283[#283])
-* Hide billing addresses (link:https://servala.app.codey.ch/servala/servala-portal/pulls/281[#281])
-* Custom form configuration (link:https://servala.app.codey.ch/servala/servala-portal/pulls/268[#268])
-* Skip offering selection if there is only one (link:https://servala.app.codey.ch/servala/servala-portal/pulls/273[#273])
-* Make it more clear how to register an account (link:https://servala.app.codey.ch/servala/servala-portal/pulls/270[#270])
-* Restrict user input to more sensible ranges (link:https://servala.app.codey.ch/servala/servala-portal/pulls/251[#251])
-* Inline user info in service offering page (link:https://servala.app.codey.ch/servala/servala-portal/pulls/250[#250])
-
-=== bug
-* Fix generated FQDN not being submitted (link:https://servala.app.codey.ch/servala/servala-portal/pulls/249[#249])
-
-=== dependencies
-* Update dependency pytest to >=9.0.1 (link:https://servala.app.codey.ch/servala/servala-portal/pulls/284[#284])
-* Update Python to 3.14 tag (link:https://servala.app.codey.ch/servala/servala-portal/pulls/272[#272])
-* Update dependency django-fernet-encrypted-fields to >=0.3.1 (link:https://servala.app.codey.ch/servala/servala-portal/pulls/278[#278])
-* Update https://github.com/renovatebot/github-action action to v44.0.2 (link:https://servala.app.codey.ch/servala/servala-portal/pulls/279[#279])
-* Update dependency sentry-sdk to >=2.44.0 (link:https://servala.app.codey.ch/servala/servala-portal/pulls/280[#280])
-* Update dependency coverage to >=7.11.3 (link:https://servala.app.codey.ch/servala/servala-portal/pulls/274[#274])
-* Update dependency pytest to v9 (link:https://servala.app.codey.ch/servala/servala-portal/pulls/276[#276])
-* Update dependency black to >=25.11.0 (link:https://servala.app.codey.ch/servala/servala-portal/pulls/277[#277])
-* Update https://github.com/renovatebot/github-action action to v44 (link:https://servala.app.codey.ch/servala/servala-portal/pulls/275[#275])
-* Update dependency django to v5.2.8 (link:https://servala.app.codey.ch/servala/servala-portal/pulls/271[#271])
-* Update dependency django-allauth to >=65.13.0 (link:https://servala.app.codey.ch/servala/servala-portal/pulls/265[#265])
-* Lock file maintenance (link:https://servala.app.codey.ch/servala/servala-portal/pulls/266[#266])
-* Update https://github.com/renovatebot/github-action action to v43.0.20 (link:https://servala.app.codey.ch/servala/servala-portal/pulls/267[#267])
-* Update https://github.com/renovatebot/github-action action to v43.0.19 (link:https://servala.app.codey.ch/servala/servala-portal/pulls/259[#259])
-* Update dependency node to v24 (link:https://servala.app.codey.ch/servala/servala-portal/pulls/260[#260])
-* Update dependency sentry-sdk to >=2.43.0 (link:https://servala.app.codey.ch/servala/servala-portal/pulls/261[#261])
-* Update dependency isort to v7 (link:https://servala.app.codey.ch/servala/servala-portal/pulls/252[#252])
-* Update dependency pillow to v12 (link:https://servala.app.codey.ch/servala/servala-portal/pulls/253[#253])
-* Lock file maintenance (link:https://servala.app.codey.ch/servala/servala-portal/pulls/255[#255])
-* Update https://github.com/astral-sh/setup-uv action to v7 (link:https://servala.app.codey.ch/servala/servala-portal/pulls/254[#254])
-* Update dependency flake8-bugbear to v25 (link:https://servala.app.codey.ch/servala/servala-portal/pulls/248[#248])
-* Update https://github.com/renovatebot/github-action action to v43.0.18 - autoclosed (link:https://servala.app.codey.ch/servala/servala-portal/pulls/239[#239])
-* Update actions/setup-node action to v6 (link:https://servala.app.codey.ch/servala/servala-portal/pulls/247[#247])
-
-
== 2025.10.27-0
=== UI/UX
diff --git a/hack/bumpver-post-commit-hook.sh b/hack/bumpver-post-commit-hook.sh
index fdda006..ca175bd 100755
--- a/hack/bumpver-post-commit-hook.sh
+++ b/hack/bumpver-post-commit-hook.sh
@@ -138,12 +138,4 @@ if [ -f "$CHANGELOG_FILE" ]; then
rm -f "$CHANGELOG_FILE"
fi
-# Fetch the tag that Forgejo created when we made the release
-echo -e "${GREEN}Fetching tags from remote to sync the tag created by Forgejo${NC}"
-if git fetch --tags; then
- echo -e "${GREEN}Tags synced successfully${NC}"
-else
- echo -e "${YELLOW}Warning: Failed to fetch tags from remote${NC}"
-fi
-
exit 0
diff --git a/pyproject.toml b/pyproject.toml
index 4548d9f..dc94dd4 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -3,18 +3,18 @@ name = "servala"
version = "0.0.0"
description = "Servala portal server and frontend"
readme = "README.md"
-requires-python = ">=3.14.0"
+requires-python = ">=3.13"
dependencies = [
"argon2-cffi>=25.1.0",
"cryptography>=46.0.3",
- "django==5.2.8",
- "django-allauth>=65.13.1",
+ "django==5.2.7",
+ "django-allauth>=65.12.1",
"django-auditlog>=3.3.0",
- "django-fernet-encrypted-fields>=0.3.1",
+ "django-fernet-encrypted-fields>=0.3.0",
"django-jsonform>=2.23.2",
"django-scopes>=2.0.0",
"django-storages[s3]>=1.14.6",
- "django-template-partials>=25.3",
+ "django-template-partials>=25.2",
"jsonschema>=4.25.1",
"kubernetes>=34.1.0",
"pillow>=12.0.0",
@@ -22,21 +22,21 @@ dependencies = [
"pyjwt>=2.10.1",
"requests>=2.32.5",
"rules>=3.5",
- "sentry-sdk[django]>=2.46.0",
+ "sentry-sdk[django]>=2.43.0",
"urlman>=2.0.2",
]
[dependency-groups]
dev = [
- "black>=25.11.0",
+ "black>=25.9.0",
"bumpver>=2025.1131",
- "coverage>=7.12.0",
+ "coverage>=7.11.0",
"djlint>=1.36.4",
"flake8>=7.3.0",
- "flake8-bugbear>=25.11.29",
+ "flake8-bugbear>=25.10.21",
"flake8-pyproject>=1.2.3",
"isort>=7.0.0",
- "pytest>=9.0.1",
+ "pytest>=8.4.2",
"pytest-cov>=7.0.0",
"pytest-django>=4.11.1",
"pytest-mock>=3.15.1",
@@ -61,7 +61,7 @@ testpaths = "src/tests"
pythonpath = "src"
[tool.bumpver]
-current_version = "2025.11.17-0"
+current_version = "2025.10.27-0"
version_pattern = "YYYY.0M.0D-INC0"
commit_message = "bump version {old_version} -> {new_version}"
tag_message = "{new_version}"
@@ -69,7 +69,7 @@ tag_scope = "default"
pre_commit_hook = "hack/bumpver-pre-commit-hook.sh"
post_commit_hook = "hack/bumpver-post-commit-hook.sh"
commit = true
-tag = false
+tag = true
push = true
[tool.bumpver.file_patterns]
diff --git a/src/servala/__about__.py b/src/servala/__about__.py
index d6db270..6ac27d0 100644
--- a/src/servala/__about__.py
+++ b/src/servala/__about__.py
@@ -1 +1 @@
-__version__ = "2025.11.17-0"
+__version__ = "2025.10.27-0"
diff --git a/src/servala/api/views.py b/src/servala/api/views.py
index 015e091..456f4b2 100644
--- a/src/servala/api/views.py
+++ b/src/servala/api/views.py
@@ -6,7 +6,6 @@ from django.contrib.auth.decorators import login_not_required
from django.core.mail import send_mail
from django.db import transaction
from django.http import JsonResponse
-from django.urls import reverse
from django.utils.decorators import method_decorator
from django.views import View
from django.views.decorators.csrf import csrf_exempt
@@ -20,8 +19,7 @@ from servala.core.models import (
OrganizationRole,
User,
)
-from servala.core.models.service import Service, ServiceInstance, ServiceOffering
-from servala.core.odoo import create_helpdesk_ticket
+from servala.core.models.service import Service, ServiceOffering
logger = logging.getLogger(__name__)
@@ -30,7 +28,9 @@ logger = logging.getLogger(__name__)
@method_decorator(login_not_required, name="dispatch")
class OSBServiceInstanceView(OSBBasicAuthPermission, View):
"""
- OSB API endpoint for service instance management via Exoscale.
+ OSB API endpoint for service instance provisioning (onboarding).
+ Implements the PUT /v2/service_instances/:instance_id endpoint.
+ https://docs.servala.com/exoscale-osb.html#_onboarding
"""
def _error(self, error):
@@ -177,169 +177,3 @@ The Servala Team"""
recipient_list=[user.email],
fail_silently=False,
)
-
- def delete(self, request, instance_id):
- """
- This implements the Exoscale offboarding flow MVP.
- https://docs.servala.com/exoscale-osb.html#_offboarding
- """
- service_id = request.GET.get("service_id")
- plan_id = request.GET.get("plan_id")
-
- if not service_id:
- return self._error("service_id is required but missing.")
- if not plan_id:
- return self._error("plan_id is required but missing.")
-
- try:
- service = Service.objects.get(osb_service_id=service_id)
- service_offering = ServiceOffering.objects.get(
- osb_plan_id=plan_id, service=service
- )
- except Service.DoesNotExist:
- return self._error(f"Unknown service_id: {service_id}")
- except ServiceOffering.DoesNotExist:
- return self._error(
- f"Unknown plan_id: {plan_id} for service_id: {service_id}"
- )
-
- self._create_action_helpdesk_ticket(
- request=request,
- action="Offboard",
- instance_id=instance_id,
- service=service,
- service_offering=service_offering,
- )
-
- return JsonResponse({}, status=200)
-
- def patch(self, request, instance_id):
- """
- This implements the Exoscale suspension flow MVP.
- https://docs.servala.com/exoscale-osb.html#_suspension
- """
- try:
- data = json.loads(request.body)
- except json.JSONDecodeError:
- return JsonResponse({"error": "Invalid JSON in request body"}, status=400)
-
- service_id = data.get("service_id")
- plan_id = data.get("plan_id")
-
- if not service_id:
- return self._error("service_id is required but missing.")
- if not plan_id:
- return self._error("plan_id is required but missing.")
-
- try:
- service = Service.objects.get(osb_service_id=service_id)
- # Special handling: when plan_id is "suspend", don't lookup service_offering
- service_offering = None
- if plan_id != "suspend":
- service_offering = ServiceOffering.objects.get(
- osb_plan_id=plan_id, service=service
- )
- except Service.DoesNotExist: # pragma: no-cover
- return self._error(f"Unknown service_id: {service_id}")
- except ServiceOffering.DoesNotExist: # pragma: no-cover
- return self._error(
- f"Unknown plan_id: {plan_id} for service_id: {service_id}"
- )
-
- self._create_action_helpdesk_ticket(
- request=request,
- action="Suspend",
- instance_id=instance_id,
- service=service,
- service_offering=service_offering,
- users=data.get("parameters", {}).get("users"),
- )
- return JsonResponse({}, status=200)
-
- def _get_admin_url(self, model_name, pk):
- admin_path = reverse(f"admin:{model_name}", args=[pk])
- return self.request.build_absolute_uri(admin_path)
-
- def _create_action_helpdesk_ticket(
- self, request, action, instance_id, service, service_offering=None, users=None
- ):
- """
- Create an Odoo helpdesk ticket for offboarding or suspension actions.
- This is an MVP implementation that creates a ticket for manual handling.
- """
- try:
- service_instance = None
- organization = None
- try:
- # Look for instances with this name in the service offering's context
- filter_kwargs = {"name": instance_id}
- if service_offering:
- filter_kwargs["context__service_offering"] = service_offering
-
- service_instance = (
- ServiceInstance.objects.filter(**filter_kwargs)
- .select_related("organization")
- .first()
- )
-
- if service_instance:
- organization = service_instance.organization
- except Exception: # pragma: no cover
- pass
-
- description_parts = [f"Action: {action}", f"Service: {service.name}"]
- if organization:
- org_url = self._get_admin_url(
- "core_organization_change", organization.pk
- )
- description_parts.append(
- f"Organization: {organization.name} - {org_url}"
- )
-
- if service_instance:
- instance_url = self._get_admin_url(
- "core_serviceinstance_change", service_instance.pk
- )
- description_parts.append(
- f"Instance: {service_instance.name} - {instance_url}"
- )
- else:
- description_parts.append(f"Instance: {instance_id}")
-
- if service_offering:
- offering_url = self._get_admin_url(
- "core_serviceoffering_change", service_offering.pk
- )
- description_parts.append(f"Service Offering: {offering_url}")
-
- if users:
- description_parts.append("
Users:")
- for user_data in users:
- email = user_data.get("email", "N/A")
- full_name = user_data.get("full_name", "N/A")
- role = user_data.get("role", "N/A")
-
- user_link = email
- if email and email != "N/A":
- try:
- user = User.objects.get(email=email.strip().lower())
- user_link = self._get_admin_url("core_user_change", user.pk)
- except User.DoesNotExist:
- pass
-
- description_parts.append(f" - {full_name} ({user_link}) - {role}")
-
- description = "
".join(description_parts)
-
- create_helpdesk_ticket(
- title=f"Exoscale OSB {action} - {service.name} - {instance_id}",
- description=description,
- )
- logger.info(
- f"Created {action} helpdesk ticket for instance {instance_id}, service {service.name}"
- )
-
- except Exception as e:
- logger.error(
- f"Error creating Exoscale {action} helpdesk ticket for instance {instance_id}: {e}"
- )
diff --git a/src/servala/core/admin.py b/src/servala/core/admin.py
index 60fe147..fb64a2b 100644
--- a/src/servala/core/admin.py
+++ b/src/servala/core/admin.py
@@ -86,13 +86,7 @@ class BillingEntityAdmin(admin.ModelAdmin):
@admin.register(OrganizationOrigin)
class OrganizationOriginAdmin(admin.ModelAdmin):
- list_display = (
- "name",
- "billing_entity",
- "default_odoo_sale_order_id",
- "hide_billing_address",
- )
- list_filter = ("hide_billing_address",)
+ list_display = ("name", "billing_entity", "default_odoo_sale_order_id")
search_fields = ("name",)
autocomplete_fields = ("billing_entity",)
filter_horizontal = ("limit_cloudproviders",)
@@ -322,7 +316,7 @@ class ServiceDefinitionAdmin(admin.ModelAdmin):
(
_("Form Configuration"),
{
- "fields": ("form_config", "hide_expert_mode"),
+ "fields": ("form_config",),
"description": _(
"Optional custom form configuration. When provided, this will be used instead of auto-generating the form from the OpenAPI spec."
),
diff --git a/src/servala/core/crd/forms.py b/src/servala/core/crd.py
similarity index 59%
rename from src/servala/core/crd/forms.py
rename to src/servala/core/crd.py
index 0f825ce..e414168 100644
--- a/src/servala/core/crd/forms.py
+++ b/src/servala/core/crd.py
@@ -1,46 +1,291 @@
-from contextlib import suppress
+import re
from django import forms
-from django.core.validators import MaxValueValidator, MinValueValidator
+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.crd.utils import deslugify
-from servala.core.models import ControlPlaneCRD
-from servala.frontend.forms.widgets import DynamicArrayWidget, NumberInputWithAddon
+from servala.core.models import ServiceInstance, ControlPlaneCRD
+from servala.frontend.forms.widgets import DynamicArrayField, DynamicArrayWidget
-# Fields that must be present in every form
-MANDATORY_FIELDS = ["name"]
-# Default field configurations - fields that can be included with just a mapping
-# to avoid administrators having to duplicate common information
-DEFAULT_FIELD_CONFIGS = {
- "name": {
- "type": "text",
- "label": "Instance Name",
- "help_text": "Unique name for the new instance",
- "required": True,
- "max_length": 63,
- },
- "spec.parameters.service.fqdn": {
- "type": "array",
- "label": "FQDNs",
- "help_text": "Domain names for accessing this service",
- "required": False,
- },
- "spec.parameters.size.disk": {
- "type": "number",
- "label": "Disk size",
- "addon_text": "Gi",
- },
-}
+class CRDModel(models.Model):
+ """Base class for all virtual CRD models"""
+
+ def __init__(self, **kwargs):
+ if spec := kwargs.pop("spec", None):
+ kwargs.update(unnest_data({"spec": spec}))
+ super().__init__(**kwargs)
+
+ class Meta:
+ abstract = True
+
+
+def duplicate_field(field_name, model):
+ field = model._meta.get_field(field_name)
+ new_field = type(field).__new__(type(field))
+ new_field.__dict__.update(field.__dict__)
+ 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", "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=False))
+
+ # Store the original schema on the model class
+ model_fields["SCHEMA"] = schema
+
+ meta_class = type("Meta", (), {"app_label": "crd_models"})
+ model_fields["Meta"] = meta_class
+
+ # create the model class
+ model_name = kind
+ model_class = type(model_name, (CRDModel,), model_fields)
+ return model_class
+
+
+def build_object_fields(schema, name, verbose_name_prefix=None, parent_required=False):
+ 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 or parent_required
+ full_name = f"{name}.{field_name}"
+ result = get_django_field(
+ field_schema,
+ is_required,
+ field_name,
+ full_name,
+ verbose_name_prefix=verbose_name_prefix,
+ )
+ if isinstance(result, dict):
+ fields.update(result)
+ else:
+ fields[full_name] = result
+ return fields
+
+
+def deslugify(title):
+ """
+ Convert camelCase, PascalCase, or snake_case to human-readable title.
+ Handles known acronyms (e.g., postgreSQLParameters -> PostgreSQL Parameters).
+ """
+ ACRONYMS = {
+ # Database systems
+ "SQL": "SQL",
+ "MYSQL": "MySQL",
+ "POSTGRESQL": "PostgreSQL",
+ "MARIADB": "MariaDB",
+ "MSSQL": "MSSQL",
+ "MONGODB": "MongoDB",
+ "REDIS": "Redis",
+ # Protocols
+ "HTTP": "HTTP",
+ "HTTPS": "HTTPS",
+ "FTP": "FTP",
+ "SFTP": "SFTP",
+ "SSH": "SSH",
+ "TLS": "TLS",
+ "SSL": "SSL",
+ # APIs
+ "API": "API",
+ "REST": "REST",
+ "GRPC": "gRPC",
+ "GRAPHQL": "GraphQL",
+ # Networking
+ "URL": "URL",
+ "URI": "URI",
+ "FQDN": "FQDN",
+ "DNS": "DNS",
+ "IP": "IP",
+ "TCP": "TCP",
+ "UDP": "UDP",
+ # Data formats
+ "JSON": "JSON",
+ "XML": "XML",
+ "YAML": "YAML",
+ "CSV": "CSV",
+ "HTML": "HTML",
+ "CSS": "CSS",
+ # Hardware
+ "CPU": "CPU",
+ "RAM": "RAM",
+ "GPU": "GPU",
+ "SSD": "SSD",
+ "HDD": "HDD",
+ # Identifiers
+ "ID": "ID",
+ "UUID": "UUID",
+ "GUID": "GUID",
+ "ARN": "ARN",
+ # Cloud providers
+ "AWS": "AWS",
+ "GCP": "GCP",
+ "AZURE": "Azure",
+ "IBM": "IBM",
+ # Kubernetes/Cloud
+ "DB": "DB",
+ "PVC": "PVC",
+ "PV": "PV",
+ "VPN": "VPN",
+ # Auth
+ "OS": "OS",
+ "LDAP": "LDAP",
+ "SAML": "SAML",
+ "OAUTH": "OAuth",
+ "JWT": "JWT",
+ # AWS Services
+ "S3": "S3",
+ "EC2": "EC2",
+ "RDS": "RDS",
+ "EBS": "EBS",
+ "IAM": "IAM",
+ }
+
+ if "_" in title:
+ # Handle snake_case
+ title = title.replace("_", " ")
+ words = title.split()
+ else:
+ # Handle camelCase/PascalCase with smart splitting
+ # This regex splits on:
+ # - Transition from lowercase to uppercase (camelCase)
+ # - Transition from multiple uppercase to an uppercase followed by lowercase (SQLParameters -> SQL Parameters)
+ words = re.findall(r"[A-Z]+(?=[A-Z][a-z]|\b)|[A-Z][a-z]+|[a-z]+|[0-9]+", title)
+
+ # Merge adjacent words if they form a known compound acronym (e.g., postgre + SQL = PostgreSQL)
+ merged_words = []
+ i = 0
+ while i < len(words):
+ if i < len(words) - 1:
+ # Check if current word + next word form a known acronym
+ combined = (words[i] + words[i + 1]).upper()
+ if combined in ACRONYMS:
+ merged_words.append(combined)
+ i += 2
+ continue
+ merged_words.append(words[i])
+ i += 1
+
+ # Capitalize each word, using proper casing for known acronyms
+ result = []
+ for word in merged_words:
+ word_upper = word.upper()
+ if word_upper in ACRONYMS:
+ result.append(ACRONYMS[word_upper])
+ else:
+ result.append(word.capitalize())
+
+ return " ".join(result)
+
+
+def get_django_field(
+ field_schema, is_required, field_name, full_name, verbose_name_prefix=None
+):
+ field_type = field_schema.get("type") or "string"
+ format = field_schema.get("format")
+ 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
+ "null": not is_required,
+ "help_text": field_schema.get("description"),
+ "validators": [],
+ "verbose_name": verbose_name,
+ "default": field_schema.get("default"),
+ }
+
+ if minimum := field_schema.get("minimum"):
+ kwargs["validators"].append(MinValueValidator(minimum))
+ if maximum := field_schema.get("maximum"):
+ kwargs["validators"].append(MaxValueValidator(maximum))
+
+ if field_type == "string":
+ if format == "date-time":
+ return models.DateTimeField(**kwargs)
+ elif format == "date":
+ return models.DateField(**kwargs)
+ else:
+ max_length = field_schema.get("max_length") or 255
+ if pattern := field_schema.get("pattern"):
+ kwargs["validators"].append(RegexValidator(regex=pattern))
+ if choices := field_schema.get("enum"):
+ kwargs["choices"] = ((choice, choice) for choice in choices)
+ return models.CharField(max_length=max_length, **kwargs)
+ elif field_type == "integer":
+ return models.IntegerField(**kwargs)
+ elif field_type == "number":
+ return models.FloatField(**kwargs)
+ 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,
+ )
+ elif field_type == "array":
+ kwargs["help_text"] = field_schema.get("description") or _("List of values")
+ field = models.JSONField(**kwargs)
+ formfield_kwargs = {
+ "label": field.verbose_name,
+ "required": not field.blank,
+ }
+
+ array_validation = {}
+ if min_items := field_schema.get("min_items"):
+ array_validation["min_items"] = min_items
+ if max_items := field_schema.get("max_items"):
+ array_validation["max_items"] = max_items
+ if unique_items := field_schema.get("unique_items"):
+ array_validation["unique_items"] = unique_items
+ if items_schema := field_schema.get("items"):
+ array_validation["items_schema"] = items_schema
+ if array_validation:
+ formfield_kwargs["array_validation"] = array_validation
+
+ field.formfield = lambda: DynamicArrayField(**formfield_kwargs)
+
+ return field
+ return models.CharField(max_length=255, **kwargs)
+
+
+def unnest_data(data):
+ result = {}
+
+ def _flatten_dict(d, parent_key=""):
+ for key, value in d.items():
+ new_key = f"{parent_key}.{key}" if parent_key else key
+ if isinstance(value, dict):
+ _flatten_dict(value, new_key)
+ else:
+ result[new_key] = value
+
+ _flatten_dict(data)
+ return result
class FormGeneratorMixin:
- """Shared base class for ModelForm classes based on our generated CRD models.
- There are two relevant child classes:
- - CrdModelFormMixin: For fully auto-generated forms from the spec
- - CustomFormMixin: For forms built from form_config settings.
- """
+ IS_CUSTOM_FORM = False
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
@@ -306,24 +551,16 @@ class CustomFormMixin(FormGeneratorMixin):
def _apply_field_config(self):
for fieldset in self.form_config.get("fieldsets", []):
- for fc in fieldset.get("fields", []):
- field_name = fc.get("controlplane_field_mapping")
+ for field_config in fieldset.get("fields", []):
+ field_name = field_config.get("controlplane_field_mapping")
if field_name not in self.fields:
continue
- field_config = fc.copy()
- # Merge with defaults if field has default config
- if field_name in DEFAULT_FIELD_CONFIGS:
- field_config = DEFAULT_FIELD_CONFIGS[field_name].copy()
- for key, value in fc.items():
- if value or (value is False):
- field_config[key] = value
-
field = self.fields[field_name]
field_type = field_config.get("type")
- field.label = field_config.get("label", field_name)
+ field.label = field_config.get("label", field_config["name"])
field.help_text = field_config.get("help_text", "")
field.required = field_config.get("required", False)
@@ -333,50 +570,20 @@ class CustomFormMixin(FormGeneratorMixin):
)
elif field_type == "array":
field.widget = DynamicArrayWidget()
- elif field_type == "choice":
- if hasattr(field, "choices") and field.choices:
- field._controlplane_choices = list(field.choices)
- if custom_choices := field_config.get("choices"):
- field.choices = [tuple(choice) for choice in custom_choices]
if field_type == "number":
min_val = field_config.get("min_value")
max_val = field_config.get("max_value")
- unit = field_config.get("addon_text")
-
- if unit:
- field.widget = NumberInputWithAddon(addon_text=unit)
- field.addon_text = unit
- value = self.initial.get(field_name)
- if value and isinstance(value, str) and value.endswith(unit):
- numeric_value = value[: -len(unit)]
- with suppress(ValueError):
- if "." in numeric_value:
- self.initial[field_name] = float(numeric_value)
- else:
- self.initial[field_name] = int(numeric_value)
validators = []
if min_val is not None:
validators.append(MinValueValidator(min_val))
- field.widget.attrs["min"] = min_val
if max_val is not None:
validators.append(MaxValueValidator(max_val))
- field.widget.attrs["max"] = max_val
if validators:
field.validators.extend(validators)
- if "default_value" in field_config and field.initial is None:
- field.initial = field_config["default_value"]
-
- if field_type in ("text", "textarea") and field_config.get(
- "max_length"
- ):
- field.max_length = field_config.get("max_length")
- if hasattr(field.widget, "attrs"):
- field.widget.attrs["maxlength"] = field_config.get("max_length")
-
field.controlplane_field_mapping = field_name
def get_fieldsets(self):
@@ -396,25 +603,6 @@ class CustomFormMixin(FormGeneratorMixin):
return fieldsets
- def clean(self):
- cleaned_data = super().clean()
-
- for field_name, field in self.fields.items():
- if hasattr(field, "_controlplane_choices"):
- value = cleaned_data.get(field_name)
- if value:
- valid_values = [choice[0] for choice in field._controlplane_choices]
- if value not in valid_values:
- self.add_error(
- field_name,
- forms.ValidationError(
- f"'{value}' is not a valid choice. "
- f"Must be one of: {valid_values.join(', ')}"
- ),
- )
-
- return cleaned_data
-
def get_nested_data(self):
nested = {}
for field_name in self.fields.keys():
@@ -426,11 +614,6 @@ class CustomFormMixin(FormGeneratorMixin):
mapping = field_name
value = self.cleaned_data.get(field_name)
- field = self.fields[field_name]
-
- if addon_text := getattr(field, "addon_text", None):
- value = f"{value}{addon_text}"
-
parts = mapping.split(".")
current = nested
for part in parts[:-1]:
diff --git a/src/servala/core/crd/__init__.py b/src/servala/core/crd/__init__.py
deleted file mode 100644
index 3f10abb..0000000
--- a/src/servala/core/crd/__init__.py
+++ /dev/null
@@ -1,31 +0,0 @@
-from servala.core.crd.forms import (
- CrdModelFormMixin,
- CustomFormMixin,
- FormGeneratorMixin,
- generate_custom_form_class,
- generate_model_form_class,
-)
-from servala.core.crd.models import (
- CRDModel,
- build_object_fields,
- duplicate_field,
- generate_django_model,
- get_django_field,
- unnest_data,
-)
-from servala.core.crd.utils import deslugify
-
-__all__ = [
- "CrdModelFormMixin",
- "CustomFormMixin",
- "FormGeneratorMixin",
- "generate_django_model",
- "generate_model_form_class",
- "generate_custom_form_class",
- "CRDModel",
- "build_object_fields",
- "duplicate_field",
- "get_django_field",
- "unnest_data",
- "deslugify",
-]
diff --git a/src/servala/core/crd/models.py b/src/servala/core/crd/models.py
deleted file mode 100644
index 86df97f..0000000
--- a/src/servala/core/crd/models.py
+++ /dev/null
@@ -1,167 +0,0 @@
-from django.core.validators import MaxValueValidator, MinValueValidator, RegexValidator
-from django.db import models
-from django.utils.translation import gettext_lazy as _
-
-from servala.core.crd.utils import deslugify
-from servala.core.models import ServiceInstance
-from servala.frontend.forms.widgets import DynamicArrayField
-
-
-class CRDModel(models.Model):
- """Base class for all virtual CRD models"""
-
- def __init__(self, **kwargs):
- if spec := kwargs.pop("spec", None):
- kwargs.update(unnest_data({"spec": spec}))
- super().__init__(**kwargs)
-
- class Meta:
- abstract = True
-
-
-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", "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=False))
-
- # Store the original schema on the model class
- model_fields["SCHEMA"] = schema
-
- meta_class = type("Meta", (), {"app_label": "crd_models"})
- model_fields["Meta"] = meta_class
-
- # create the model class
- model_name = kind
- model_class = type(model_name, (CRDModel,), model_fields)
- return model_class
-
-
-def duplicate_field(field_name, model):
- field = model._meta.get_field(field_name)
- new_field = type(field).__new__(type(field))
- new_field.__dict__.update(field.__dict__)
- new_field.model = None
- new_field.auto_created = False
- return new_field
-
-
-def build_object_fields(schema, name, verbose_name_prefix=None, parent_required=False):
- 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 or parent_required
- full_name = f"{name}.{field_name}"
- result = get_django_field(
- field_schema,
- is_required,
- field_name,
- full_name,
- verbose_name_prefix=verbose_name_prefix,
- )
- if isinstance(result, dict):
- fields.update(result)
- else:
- fields[full_name] = result
- return fields
-
-
-def get_django_field(
- field_schema, is_required, field_name, full_name, verbose_name_prefix=None
-):
- field_type = field_schema.get("type") or "string"
- format = field_schema.get("format")
- 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
- "null": not is_required,
- "help_text": field_schema.get("description"),
- "validators": [],
- "verbose_name": verbose_name,
- "default": field_schema.get("default"),
- }
-
- if minimum := field_schema.get("minimum"):
- kwargs["validators"].append(MinValueValidator(minimum))
- if maximum := field_schema.get("maximum"):
- kwargs["validators"].append(MaxValueValidator(maximum))
-
- if field_type == "string":
- if format == "date-time":
- return models.DateTimeField(**kwargs)
- elif format == "date":
- return models.DateField(**kwargs)
- else:
- max_length = field_schema.get("max_length") or 255
- if pattern := field_schema.get("pattern"):
- kwargs["validators"].append(RegexValidator(regex=pattern))
- if choices := field_schema.get("enum"):
- kwargs["choices"] = ((choice, choice) for choice in choices)
- return models.CharField(max_length=max_length, **kwargs)
- elif field_type == "integer":
- return models.IntegerField(**kwargs)
- elif field_type == "number":
- return models.FloatField(**kwargs)
- 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,
- )
- elif field_type == "array":
- kwargs["help_text"] = field_schema.get("description") or _("List of values")
- field = models.JSONField(**kwargs)
- formfield_kwargs = {
- "label": field.verbose_name,
- "required": not field.blank,
- }
-
- array_validation = {}
- if min_items := field_schema.get("min_items"):
- array_validation["min_items"] = min_items
- if max_items := field_schema.get("max_items"):
- array_validation["max_items"] = max_items
- if unique_items := field_schema.get("unique_items"):
- array_validation["unique_items"] = unique_items
- if items_schema := field_schema.get("items"):
- array_validation["items_schema"] = items_schema
- if array_validation:
- formfield_kwargs["array_validation"] = array_validation
-
- field.formfield = lambda: DynamicArrayField(**formfield_kwargs)
-
- return field
- return models.CharField(max_length=255, **kwargs)
-
-
-def unnest_data(data):
- result = {}
-
- def _flatten_dict(d, parent_key=""):
- for key, value in d.items():
- new_key = f"{parent_key}.{key}" if parent_key else key
- if isinstance(value, dict):
- _flatten_dict(value, new_key)
- else:
- result[new_key] = value
-
- _flatten_dict(data)
- return result
diff --git a/src/servala/core/crd/utils.py b/src/servala/core/crd/utils.py
deleted file mode 100644
index a537fd9..0000000
--- a/src/servala/core/crd/utils.py
+++ /dev/null
@@ -1,115 +0,0 @@
-import re
-
-
-def deslugify(title):
- """
- Convert camelCase, PascalCase, or snake_case to human-readable title.
- Handles known acronyms (e.g., postgreSQLParameters -> PostgreSQL Parameters).
- """
- ACRONYMS = {
- # Database systems
- "SQL": "SQL",
- "MYSQL": "MySQL",
- "POSTGRESQL": "PostgreSQL",
- "MARIADB": "MariaDB",
- "MSSQL": "MSSQL",
- "MONGODB": "MongoDB",
- "REDIS": "Redis",
- # Protocols
- "HTTP": "HTTP",
- "HTTPS": "HTTPS",
- "FTP": "FTP",
- "SFTP": "SFTP",
- "SSH": "SSH",
- "TLS": "TLS",
- "SSL": "SSL",
- # APIs
- "API": "API",
- "REST": "REST",
- "GRPC": "gRPC",
- "GRAPHQL": "GraphQL",
- # Networking
- "URL": "URL",
- "URI": "URI",
- "FQDN": "FQDN",
- "DNS": "DNS",
- "IP": "IP",
- "TCP": "TCP",
- "UDP": "UDP",
- # Data formats
- "JSON": "JSON",
- "XML": "XML",
- "YAML": "YAML",
- "CSV": "CSV",
- "HTML": "HTML",
- "CSS": "CSS",
- # Hardware
- "CPU": "CPU",
- "RAM": "RAM",
- "GPU": "GPU",
- "SSD": "SSD",
- "HDD": "HDD",
- # Identifiers
- "ID": "ID",
- "UUID": "UUID",
- "GUID": "GUID",
- "ARN": "ARN",
- # Cloud providers
- "AWS": "AWS",
- "GCP": "GCP",
- "AZURE": "Azure",
- "IBM": "IBM",
- # Kubernetes/Cloud
- "DB": "DB",
- "PVC": "PVC",
- "PV": "PV",
- "VPN": "VPN",
- # Auth
- "OS": "OS",
- "LDAP": "LDAP",
- "SAML": "SAML",
- "OAUTH": "OAuth",
- "JWT": "JWT",
- # AWS Services
- "S3": "S3",
- "EC2": "EC2",
- "RDS": "RDS",
- "EBS": "EBS",
- "IAM": "IAM",
- }
-
- if "_" in title:
- # Handle snake_case
- title = title.replace("_", " ")
- words = title.split()
- else:
- # Handle camelCase/PascalCase with smart splitting
- # This regex splits on:
- # - Transition from lowercase to uppercase (camelCase)
- # - Transition from multiple uppercase to an uppercase followed by lowercase (SQLParameters -> SQL Parameters)
- words = re.findall(r"[A-Z]+(?=[A-Z][a-z]|\b)|[A-Z][a-z]+|[a-z]+|[0-9]+", title)
-
- # Merge adjacent words if they form a known compound acronym (e.g., postgre + SQL = PostgreSQL)
- merged_words = []
- i = 0
- while i < len(words):
- if i < len(words) - 1:
- # Check if current word + next word form a known acronym
- combined = (words[i] + words[i + 1]).upper()
- if combined in ACRONYMS:
- merged_words.append(combined)
- i += 2
- continue
- merged_words.append(words[i])
- i += 1
-
- # Capitalize each word, using proper casing for known acronyms
- result = []
- for word in merged_words:
- word_upper = word.upper()
- if word_upper in ACRONYMS:
- result.append(ACRONYMS[word_upper])
- else:
- result.append(word.capitalize())
-
- return " ".join(result)
diff --git a/src/servala/core/forms.py b/src/servala/core/forms.py
index 090abba..9742233 100644
--- a/src/servala/core/forms.py
+++ b/src/servala/core/forms.py
@@ -6,7 +6,6 @@ from django import forms
from django.utils.translation import gettext_lazy as _
from django_jsonform.widgets import JSONFormWidget
-from servala.core.crd.forms import DEFAULT_FIELD_CONFIGS, MANDATORY_FIELDS
from servala.core.models import ControlPlane, ServiceDefinition
CONTROL_PLANE_USER_INFO_SCHEMA = {
@@ -101,12 +100,6 @@ class ControlPlaneAdminForm(forms.ModelForm):
return super().save(*args, **kwargs)
-def fields_empty(fields):
- if not fields:
- return True
- return all(not field.get("controlplane_field_mapping") for field in fields)
-
-
class ServiceDefinitionAdminForm(forms.ModelForm):
api_group = forms.CharField(
required=False,
@@ -167,20 +160,7 @@ class ServiceDefinitionAdminForm(forms.ModelForm):
cleaned_data["api_definition"] = api_def
form_config = cleaned_data.get("form_config")
-
- # Convert empty form_config to None (no custom form)
if form_config:
- if not form_config.get("fieldsets") or all(
- fields_empty(fieldset.get("fields"))
- for fieldset in form_config.get("fieldsets")
- ):
- form_config = None
- cleaned_data["form_config"] = None
-
- if form_config:
- form_config = self._normalize_form_config_types(form_config)
- cleaned_data["form_config"] = form_config
-
try:
jsonschema.validate(
instance=form_config, schema=self.form_config_schema
@@ -198,218 +178,8 @@ class ServiceDefinitionAdminForm(forms.ModelForm):
{"form_config": _("Schema error: {}").format(e.message)}
)
- self._validate_field_mappings(form_config, cleaned_data)
-
return cleaned_data
- def _normalize_form_config_types(self, form_config):
- """
- Normalize form_config by converting string representations of numbers
- to actual integers/floats. The JSON form widget sends all values
- as strings, but the schema expects proper types.
- """
- if not isinstance(form_config, dict):
- return form_config
-
- integer_fields = ["max_length", "rows", "min_values", "max_values"]
- number_fields = ["min_value", "max_value"]
-
- for fieldset in form_config.get("fieldsets", []):
- for field in fieldset.get("fields", []):
- for field_name in integer_fields:
- if field_name in field and field[field_name] is not None:
- value = field[field_name]
- if isinstance(value, str):
- try:
- field[field_name] = int(value) if value else None
- except (ValueError, TypeError):
- pass
-
- for field_name in number_fields:
- if field_name in field and field[field_name] is not None:
- value = field[field_name]
- if isinstance(value, str):
- try:
- field[field_name] = (
- int(value) if "." not in value else float(value)
- )
- except (ValueError, TypeError):
- pass
-
- return form_config
-
- def _validate_field_mappings(self, form_config, cleaned_data):
- if not self.instance.pk:
- return
- crd = self.instance.offering_control_planes.all().first()
- if not crd:
- return
-
- schema = None
- try:
- schema = crd.resource_schema
- except Exception:
- pass
-
- if not schema or not (spec_schema := schema.get("properties", {}).get("spec")):
- return
-
- valid_paths = self._extract_field_paths(spec_schema, "spec") | {"name"}
- included_mappings = set()
- errors = []
- for fieldset in form_config.get("fieldsets", []):
- for field in fieldset.get("fields", []):
- mapping = field.get("controlplane_field_mapping")
- included_mappings.add(mapping)
-
- # Validate that fields without defaults have required properties
- if mapping not in DEFAULT_FIELD_CONFIGS:
- if not field.get("label"):
- errors.append(
- _(
- "Field with mapping '{}' must have a 'label' property "
- "(or use a mapping with default config)"
- ).format(mapping)
- )
- if not field.get("type"):
- errors.append(
- _(
- "Field with mapping '{}' must have a 'type' property "
- "(or use a mapping with default config)"
- ).format(mapping)
- )
-
- if mapping and mapping not in valid_paths:
- field_name = field.get("label", field.get("name", mapping))
- errors.append(
- _(
- "Field '{}' has invalid mapping '{}'. Valid paths are: {}"
- ).format(
- field_name,
- mapping,
- ", ".join(sorted(valid_paths)[:10])
- + ("..." if len(valid_paths) > 10 else ""),
- )
- )
-
- if field.get("type") == "choice" and field.get("choices"):
- self._validate_choice_field(
- field, mapping, spec_schema, "spec", errors
- )
-
- for mandatory_field in MANDATORY_FIELDS:
- if mandatory_field not in included_mappings:
- errors.append(
- _(
- "Required field '{}' must be included in the form configuration"
- ).format(mandatory_field)
- )
-
- if errors:
- raise forms.ValidationError({"form_config": errors})
-
- def _validate_choice_field(self, field, mapping, spec_schema, prefix, errors):
- if not mapping:
- return
-
- field_name = field.get("label", mapping)
- custom_choices = field.get("choices", [])
-
- # Single-element choices [value] are transformed to [value, value]
- for i, choice in enumerate(custom_choices):
- if not isinstance(choice, (list, tuple)):
- errors.append(
- _(
- "Field '{}': Choice at index {} must be a list or tuple, "
- "but got: {}"
- ).format(field_name, i, repr(choice))
- )
- return
-
- choice_len = len(choice)
- if choice_len == 1:
- custom_choices[i] = [choice[0], choice[0]]
- elif choice_len == 0 or choice_len > 2:
- errors.append(
- _(
- "Field '{}': Choice at index {} must have 1 or 2 elements "
- "(got {}): {}"
- ).format(field_name, i, choice_len, repr(choice))
- )
- return
-
- field_schema = self._get_field_schema(spec_schema, mapping, prefix)
- if not field_schema:
- return
-
- control_plane_choices = field_schema.get("enum", [])
- if not control_plane_choices:
- return
-
- custom_choice_values = [choice[0] for choice in custom_choices]
-
- invalid_choices = [
- value
- for value in custom_choice_values
- if value not in control_plane_choices
- ]
-
- if invalid_choices:
- errors.append(
- _(
- "Field '{}' has invalid choice values: {}. "
- "Valid choices from control plane are: {}"
- ).format(
- field_name,
- ", ".join(f"'{c}'" for c in invalid_choices),
- ", ".join(f"'{c}'" for c in control_plane_choices),
- )
- )
-
- def _get_field_schema(self, schema, field_path, prefix):
- if not field_path or not schema:
- return None
-
- if field_path.startswith(prefix + "."):
- field_path = field_path[len(prefix) + 1 :]
-
- parts = field_path.split(".")
- current_schema = schema
-
- for part in parts:
- if not isinstance(current_schema, dict):
- return None
-
- properties = current_schema.get("properties", {})
- if part not in properties:
- return None
-
- current_schema = properties[part]
-
- return current_schema
-
- def _extract_field_paths(self, schema, prefix=""):
- paths = set()
-
- if not isinstance(schema, dict):
- return paths
-
- if "type" in schema and schema["type"] != "object":
- if prefix:
- paths.add(prefix)
-
- if schema.get("properties"):
- for prop_name, prop_schema in schema["properties"].items():
- new_prefix = f"{prefix}.{prop_name}" if prefix else prop_name
- paths.add(new_prefix)
- paths.update(self._extract_field_paths(prop_schema, new_prefix))
-
- if schema.get("type") == "array" and "items" in schema:
- if prefix:
- paths.add(prefix)
-
- return paths
-
def save(self, *args, **kwargs):
self.instance.api_definition = self.cleaned_data["api_definition"]
return super().save(*args, **kwargs)
diff --git a/src/servala/core/migrations/0012_remove_advanced_fields.py b/src/servala/core/migrations/0012_remove_advanced_fields.py
index 7d0fecd..d60d4cc 100644
--- a/src/servala/core/migrations/0012_remove_advanced_fields.py
+++ b/src/servala/core/migrations/0012_remove_advanced_fields.py
@@ -7,7 +7,7 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
- ("core", "0012_convert_user_info_to_array"),
+ ("core", "0011_alter_organizationorigin_billing_entity"),
]
operations = [
diff --git a/src/servala/core/migrations/0013_add_form_config.py b/src/servala/core/migrations/0013_add_form_config.py
index 2819a6c..bd35891 100644
--- a/src/servala/core/migrations/0013_add_form_config.py
+++ b/src/servala/core/migrations/0013_add_form_config.py
@@ -15,11 +15,7 @@ class Migration(migrations.Migration):
name="form_config",
field=models.JSONField(
blank=True,
- help_text=(
- "Optional custom form configuration. When provided, this configuration will "
- "be used to render the service form instead of auto-generating it from the OpenAPI spec. "
- 'Format: {"fieldsets": [{"title": "Section", "fields": [{...}]}]}'
- ),
+ help_text='Optional custom form configuration. When provided, this configuration will be used to render the service form instead of auto-generating it from the OpenAPI spec. Format: {"fieldsets": [{"title": "Section", "fields": [{...}]}]}',
null=True,
verbose_name="Form Configuration",
),
diff --git a/src/servala/core/migrations/0014_hide_billing_address.py b/src/servala/core/migrations/0014_hide_billing_address.py
deleted file mode 100644
index 1e7f3bf..0000000
--- a/src/servala/core/migrations/0014_hide_billing_address.py
+++ /dev/null
@@ -1,44 +0,0 @@
-# Generated by Django 5.2.8 on 2025-11-12 09:31
-
-from django.db import migrations, models
-
-
-class Migration(migrations.Migration):
-
- dependencies = [
- ("core", "0013_add_form_config"),
- ]
-
- operations = [
- migrations.AddField(
- model_name="organizationorigin",
- name="billing_message",
- field=models.TextField(
- blank=True,
- help_text="Optional message to display instead of billing address (e.g., 'You will be invoiced by Exoscale').",
- verbose_name="Billing Message",
- ),
- ),
- migrations.AddField(
- model_name="organizationorigin",
- name="hide_billing_address",
- field=models.BooleanField(
- default=False,
- help_text="If enabled, the billing address will not be shown in the organization details view.",
- verbose_name="Hide Billing Address",
- ),
- ),
- migrations.AlterField(
- model_name="controlplane",
- name="user_info",
- field=models.JSONField(
- blank=True,
- help_text=(
- 'Array of info objects: [{"title": "…", "content": "…", "help_text": "…"}]. '
- "The help_text field is optional and will be shown as a hover popover on an info icon."
- ),
- null=True,
- verbose_name="User Information",
- ),
- ),
- ]
diff --git a/src/servala/core/migrations/0015_add_hide_expert_mode_to_service_definition.py b/src/servala/core/migrations/0015_add_hide_expert_mode_to_service_definition.py
deleted file mode 100644
index cc88c9d..0000000
--- a/src/servala/core/migrations/0015_add_hide_expert_mode_to_service_definition.py
+++ /dev/null
@@ -1,25 +0,0 @@
-# Generated by Django 5.2.8 on 2025-11-14 15:55
-
-from django.db import migrations, models
-
-
-class Migration(migrations.Migration):
-
- dependencies = [
- ("core", "0014_hide_billing_address"),
- ]
-
- operations = [
- migrations.AddField(
- model_name="servicedefinition",
- name="hide_expert_mode",
- field=models.BooleanField(
- default=False,
- help_text=(
- "When enabled, the 'Show Expert Mode' toggle will be hidden and only the custom form configuration will be available. "
- "Only applies when a custom form configuration is provided."
- ),
- verbose_name="Disable Expert Mode",
- ),
- ),
- ]
diff --git a/src/servala/core/models/organization.py b/src/servala/core/models/organization.py
index be4f587..09205dc 100644
--- a/src/servala/core/models/organization.py
+++ b/src/servala/core/models/organization.py
@@ -419,20 +419,6 @@ class OrganizationOrigin(ServalaModelMixin, models.Model):
"If set, this sale order will be used for new organizations with this origin."
),
)
- hide_billing_address = models.BooleanField(
- default=False,
- verbose_name=_("Hide Billing Address"),
- help_text=_(
- "If enabled, the billing address will not be shown in the organization details view."
- ),
- )
- billing_message = models.TextField(
- blank=True,
- verbose_name=_("Billing Message"),
- help_text=_(
- "Optional message to display instead of billing address (e.g., 'You will be invoiced by Exoscale')."
- ),
- )
class Meta:
verbose_name = _("Organization origin")
diff --git a/src/servala/core/models/service.py b/src/servala/core/models/service.py
index ab7b76f..1535703 100644
--- a/src/servala/core/models/service.py
+++ b/src/servala/core/models/service.py
@@ -370,14 +370,6 @@ class ServiceDefinition(ServalaModelMixin, models.Model):
null=True,
blank=True,
)
- hide_expert_mode = models.BooleanField(
- default=False,
- verbose_name=_("Disable Expert Mode"),
- help_text=_(
- "When enabled, the 'Show Expert Mode' toggle will be hidden and only the custom form "
- "configuration will be available. Only applies when a custom form configuration is provided."
- ),
- )
service = models.ForeignKey(
to="Service",
on_delete=models.CASCADE,
@@ -969,21 +961,5 @@ class ServiceInstance(ServalaModelMixin, models.Model):
except Exception as e:
return {"error": str(e)}
- @property
- def fqdn_url(self):
- try:
- fqdn = self.spec.get("parameters", {}).get("service", {}).get("fqdn")
- if not fqdn:
- return None
-
- if isinstance(fqdn, list):
- return fqdn[0]
- elif isinstance(fqdn, str):
- return fqdn
- else:
- return None
- except (AttributeError, KeyError, IndexError):
- return None
-
auditlog.register(ServiceInstance, exclude_fields=["updated_at"], serialize_data=True)
diff --git a/src/servala/core/odoo.py b/src/servala/core/odoo.py
index 517829a..ba91dc7 100644
--- a/src/servala/core/odoo.py
+++ b/src/servala/core/odoo.py
@@ -207,19 +207,3 @@ def get_invoice_addresses(user):
return invoice_addresses or []
except Exception:
return []
-
-
-def create_helpdesk_ticket(title, description, partner_id=None, sale_order_id=None):
- ticket_data = {
- "name": title,
- "team_id": settings.ODOO["HELPDESK_TEAM_ID"],
- "description": description,
- }
-
- if partner_id:
- ticket_data["partner_id"] = partner_id
-
- if sale_order_id:
- ticket_data["sale_order_id"] = sale_order_id
-
- return CLIENT.execute("helpdesk.ticket", "create", [ticket_data])
diff --git a/src/servala/core/schemas/form_config_schema.json b/src/servala/core/schemas/form_config_schema.json
index 3b01b3f..1049ed8 100644
--- a/src/servala/core/schemas/form_config_schema.json
+++ b/src/servala/core/schemas/form_config_schema.json
@@ -23,12 +23,17 @@
"minItems": 1,
"items": {
"type": "object",
- "required": ["controlplane_field_mapping"],
+ "required": ["name", "type", "label", "controlplane_field_mapping"],
"properties": {
+ "name": {
+ "type": "string",
+ "description": "Unique field name/identifier",
+ "pattern": "^[a-zA-Z_][a-zA-Z0-9_]*$"
+ },
"type": {
"type": "string",
"description": "Field type",
- "enum": ["", "text", "email", "textarea", "number", "choice", "checkbox", "array"]
+ "enum": ["text", "email", "textarea", "number", "choice", "checkbox", "array"]
},
"label": {
"type": "string",
@@ -48,21 +53,21 @@
"description": "Dot-notation path mapping to Kubernetes spec field (e.g., 'spec.parameters.service.fqdn')"
},
"max_length": {
- "type": ["integer", "null"],
+ "type": "integer",
"description": "Maximum length for text/textarea fields",
"minimum": 1
},
"rows": {
- "type": ["integer", "null"],
+ "type": "integer",
"description": "Number of rows for textarea fields",
"minimum": 1
},
"min_value": {
- "type": ["number", "null"],
+ "type": "number",
"description": "Minimum value for number fields"
},
"max_value": {
- "type": ["number", "null"],
+ "type": "number",
"description": "Maximum value for number fields"
},
"choices": {
@@ -76,12 +81,12 @@
}
},
"min_values": {
- "type": ["integer", "null"],
+ "type": "integer",
"description": "Minimum number of values for array fields",
"minimum": 0
},
"max_values": {
- "type": ["integer", "null"],
+ "type": "integer",
"description": "Maximum number of values for array fields",
"minimum": 1
},
@@ -93,9 +98,13 @@
"enum": ["email", "fqdn", "url", "ipv4", "ipv6"]
}
},
- "default_value": {
- "type": "string",
- "description": "Default value for the field when creating new instances"
+ "generators": {
+ "type": "array",
+ "description": "Array of generator function names (for future use)",
+ "items": {
+ "type": "string",
+ "enum": ["suggest_fqdn_from_name"]
+ }
}
}
}
diff --git a/src/servala/frontend/forms/organization.py b/src/servala/frontend/forms/organization.py
index 45e7b11..86ba0ab 100644
--- a/src/servala/frontend/forms/organization.py
+++ b/src/servala/frontend/forms/organization.py
@@ -8,6 +8,7 @@ from servala.core.models import Organization, OrganizationInvitation, Organizati
from servala.core.odoo import get_invoice_addresses, get_odoo_countries
from servala.frontend.forms.mixins import HtmxMixin
+
ORG_NAME_PATTERN = r"[\w\s\-.,&'()+]+"
diff --git a/src/servala/frontend/forms/widgets.py b/src/servala/frontend/forms/widgets.py
index 99b7a59..d67030f 100644
--- a/src/servala/frontend/forms/widgets.py
+++ b/src/servala/frontend/forms/widgets.py
@@ -2,7 +2,6 @@ import json
from django import forms
from django.core.exceptions import ValidationError
-from django.forms.widgets import NumberInput
class DynamicArrayWidget(forms.Widget):
@@ -217,21 +216,3 @@ class DynamicArrayField(forms.JSONField):
raise ValidationError(
f"Item {i + 1} must be one of: {', '.join(enum_values)}"
)
-
-
-class NumberInputWithAddon(NumberInput):
- """
- Widget for number input fields with a suffix add-on (e.g., "Gi", "MB").
- Renders as a Bootstrap input-group with the suffix displayed as an add-on.
- """
-
- template_name = "frontend/forms/number_input_with_addon.html"
-
- def __init__(self, addon_text="", attrs=None):
- super().__init__(attrs)
- self.addon_text = addon_text
-
- def get_context(self, name, value, attrs):
- context = super().get_context(name, value, attrs)
- context["widget"]["addon_text"] = self.addon_text
- return context
diff --git a/src/servala/frontend/templates/account/login.html b/src/servala/frontend/templates/account/login.html
index e7576b0..f4ca590 100644
--- a/src/servala/frontend/templates/account/login.html
+++ b/src/servala/frontend/templates/account/login.html
@@ -26,14 +26,12 @@
- {% translate "Sign in to your account or create a new one to access your managed service instances and the Servala service catalog" %} + {% translate "Sign in to access your managed service instances and the Servala service catalog" %}
- {% translate "We currently cannot offer this service. Please check back later or contact support for more information." %} -
+{% translate "We currently cannot offer this service. Please check back later or contact support for more information." %}
- {% translate "This billing address cannot be modified." %} -
- {% endif %} -| - {% translate "Invoice Contact Name" %} - | -{{ odoo_data.invoice_address.name|default:"" }} | -
|---|---|
| - {% translate "Street" %} - | -{{ odoo_data.invoice_address.street|default:"" }} | -
| - {% translate "Street 2" %} - | -{{ odoo_data.invoice_address.street2 }} | -
| - {% translate "City" %} - | -{{ odoo_data.invoice_address.city|default:"" }} | -
| - {% translate "ZIP Code" %} - | -{{ odoo_data.invoice_address.zip|default:"" }} | -
| - {% translate "Country" %} - | -{{ odoo_data.invoice_address.country_id.1|default:"" }} | -- {% translate "Invoice Email" %} - | -{{ odoo_data.invoice_address.email|default:"" }} | - - {% endif %} - -
+ {% translate "This billing address cannot be modified." %} +
+ {% endif %}{{ form.instance.origin.billing_message }}
+ {% with odoo_data=form.instance.billing_entity.odoo_data %} +| + {% translate "Invoice Contact Name" %} + | +{{ odoo_data.invoice_address.name|default:"" }} | +
|---|---|
| + {% translate "Street" %} + | +{{ odoo_data.invoice_address.street|default:"" }} | +
| + {% translate "Street 2" %} + | +{{ odoo_data.invoice_address.street2 }} | +
| + {% translate "City" %} + | +{{ odoo_data.invoice_address.city|default:"" }} | +
| + {% translate "ZIP Code" %} + | +{{ odoo_data.invoice_address.zip|default:"" }} | +
| + {% translate "Country" %} + | +{{ odoo_data.invoice_address.country_id.1|default:"" }} | ++ {% translate "Invoice Email" %} + | +{{ odoo_data.invoice_address.email|default:"" }} | + + {% endif %} + +