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 "Ready to get started?" %}

- {% 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" %}

{% for provider in socialaccount_providers %} {% provider_login_url provider process=process scope=scope auth_params=auth_params as href %} -
+ {% csrf_token %} {{ redirect_field }}
{% endfor %} diff --git a/src/servala/frontend/templates/frontend/forms/number_input_with_addon.html b/src/servala/frontend/templates/frontend/forms/number_input_with_addon.html deleted file mode 100644 index 4fe3b54..0000000 --- a/src/servala/frontend/templates/frontend/forms/number_input_with_addon.html +++ /dev/null @@ -1,11 +0,0 @@ -
- - {{ widget.addon_text }} -
diff --git a/src/servala/frontend/templates/frontend/organizations/service_instance_detail.html b/src/servala/frontend/templates/frontend/organizations/service_instance_detail.html index 948a2df..4aaef14 100644 --- a/src/servala/frontend/templates/frontend/organizations/service_instance_detail.html +++ b/src/servala/frontend/templates/frontend/organizations/service_instance_detail.html @@ -7,15 +7,6 @@ {% endblock html_title %} {% block page_title_extra %}
- {% if instance.fqdn_url %} - - - {% translate "Open" %} - - {% endif %} {% if has_change_permission %} {% translate "Edit" %} {% endif %} diff --git a/src/servala/frontend/templates/frontend/organizations/service_instance_update.html b/src/servala/frontend/templates/frontend/organizations/service_instance_update.html index 17b9a51..74259e6 100644 --- a/src/servala/frontend/templates/frontend/organizations/service_instance_update.html +++ b/src/servala/frontend/templates/frontend/organizations/service_instance_update.html @@ -13,7 +13,7 @@ {% translate "Back" %} {% endblock page_title_extra %} {% partialdef service-form %} -{% if form or custom_form %} +{% if form %}
@@ -31,7 +31,7 @@ {% block content %}
- {% if not form and not custom_form %} + {% if not form %} diff --git a/src/servala/frontend/templates/frontend/organizations/service_offering_detail.html b/src/servala/frontend/templates/frontend/organizations/service_offering_detail.html index 927c6e3..53e32d2 100644 --- a/src/servala/frontend/templates/frontend/organizations/service_offering_detail.html +++ b/src/servala/frontend/templates/frontend/organizations/service_offering_detail.html @@ -42,9 +42,7 @@
{% translate "Service Unavailable" %} -

- {% 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." %}

diff --git a/src/servala/frontend/templates/frontend/organizations/update.html b/src/servala/frontend/templates/frontend/organizations/update.html index bc4edf8..73c2c69 100644 --- a/src/servala/frontend/templates/frontend/organizations/update.html +++ b/src/servala/frontend/templates/frontend/organizations/update.html @@ -156,85 +156,72 @@
- {% if not form.instance.origin.hide_billing_address %} - {% if form.instance.billing_entity and form.instance.billing_entity.odoo_data.invoice_address %} -
-
-

{% translate "Billing Address" %}

- {% if form.instance.has_inherited_billing_entity %} -

- {% translate "This billing address cannot be modified." %} -

- {% endif %} -
-
-
- {% with odoo_data=form.instance.billing_entity.odoo_data %} -
- - - {% if odoo_data.invoice_address %} - - - - - - - - - - {% if odoo_data.invoice_address.street2 %} - - - - - {% endif %} - - - - - - - - - - - - - - - - {% 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:"" }}
-
- {% endwith %} -
-
-
- {% endif %} - {% elif form.instance.origin.billing_message %} + {% if form.instance.billing_entity and form.instance.billing_entity.odoo_data.invoice_address %}
-

{% translate "Billing Information" %}

+

{% translate "Billing Address" %}

+ {% if form.instance.has_inherited_billing_entity %} +

+ {% translate "This billing address cannot be modified." %} +

+ {% endif %}
-

{{ form.instance.origin.billing_message }}

+ {% with odoo_data=form.instance.billing_entity.odoo_data %} +
+ + + {% if odoo_data.invoice_address %} + + + + + + + + + + {% if odoo_data.invoice_address.street2 %} + + + + + {% endif %} + + + + + + + + + + + + + + + + {% 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:"" }}
+
+ {% endwith %}
diff --git a/src/servala/frontend/templates/includes/control_plane_user_info.html b/src/servala/frontend/templates/includes/control_plane_user_info.html index fdcc995..a3a27f5 100644 --- a/src/servala/frontend/templates/includes/control_plane_user_info.html +++ b/src/servala/frontend/templates/includes/control_plane_user_info.html @@ -4,7 +4,9 @@ {% for info in control_plane.user_info %}
- {{ info.title }} + + {{ info.title }} + {% if info.help_text %} + font-size: 0.875rem;"> {% endif %}
diff --git a/src/servala/frontend/templates/includes/header.html b/src/servala/frontend/templates/includes/header.html index 7db28b4..9176a98 100644 --- a/src/servala/frontend/templates/includes/header.html +++ b/src/servala/frontend/templates/includes/header.html @@ -130,7 +130,7 @@ {% else %} - {% translate 'Sign in' %} + {% translate 'Login' %} {% endif %} diff --git a/src/servala/frontend/templates/includes/service_card.html b/src/servala/frontend/templates/includes/service_card.html index dcf739f..0dae3ae 100644 --- a/src/servala/frontend/templates/includes/service_card.html +++ b/src/servala/frontend/templates/includes/service_card.html @@ -26,6 +26,6 @@ {% else %} {% endif %} - {% translate "Get It" %} + {% translate "View Availability" %}
diff --git a/src/servala/frontend/templates/includes/tabbed_fieldset_form.html b/src/servala/frontend/templates/includes/tabbed_fieldset_form.html index 5f289b7..41f1af2 100644 --- a/src/servala/frontend/templates/includes/tabbed_fieldset_form.html +++ b/src/servala/frontend/templates/includes/tabbed_fieldset_form.html @@ -6,16 +6,16 @@ {% if form_action %}action="{{ form_action }}"{% endif %}> {% csrf_token %} {% include "frontend/forms/errors.html" %} - {% if form and expert_form and not hide_expert_mode %} -
- {% translate "Show Expert Mode" %} + {% if form %} +
+
{% endif %} -
+
{% if form and form.context %}{{ form.context }}{% endif %} {% if form and form.get_fieldsets|length == 1 %} {# Single fieldset - render without tabs #} @@ -41,14 +41,15 @@