From 272451c92ffc8c0922e5371fe6581ede2d13fab0 Mon Sep 17 00:00:00 2001 From: Tobias Kunze Date: Mon, 6 Oct 2025 11:12:43 +0200 Subject: [PATCH 01/36] Code style --- .../frontend/templates/frontend/base.html | 17 +++++++++++------ .../frontend/templatetags/version_tags.py | 6 ++++-- 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/src/servala/frontend/templates/frontend/base.html b/src/servala/frontend/templates/frontend/base.html index f738a52..1301bec 100644 --- a/src/servala/frontend/templates/frontend/base.html +++ b/src/servala/frontend/templates/frontend/base.html @@ -80,12 +80,17 @@ diff --git a/src/servala/frontend/templatetags/version_tags.py b/src/servala/frontend/templatetags/version_tags.py index 249ecee..6019738 100644 --- a/src/servala/frontend/templatetags/version_tags.py +++ b/src/servala/frontend/templatetags/version_tags.py @@ -1,5 +1,7 @@ import os + from django import template + from servala.__about__ import __version__ register = template.Library() @@ -8,7 +10,7 @@ register = template.Library() @register.simple_tag def get_version_or_env(): """Return version number in production, environment name otherwise.""" - env = os.environ.get('SERVALA_ENVIRONMENT', 'development') - if env == 'production': + env = os.environ.get("SERVALA_ENVIRONMENT", "development") + if env == "production": return __version__ return env From a644ad4e75832109ad2c33afde5a83e89f651c5c Mon Sep 17 00:00:00 2001 From: Tobias Kunze Date: Mon, 6 Oct 2025 12:12:32 +0200 Subject: [PATCH 02/36] Add new limitation fields to Organization model ref #38 --- ...anization_limit_cloudproviders_and_more.py | 33 +++++++++++++++++++ src/servala/core/models/organization.py | 10 ++++++ 2 files changed, 43 insertions(+) create mode 100644 src/servala/core/migrations/0009_organization_limit_cloudproviders_and_more.py diff --git a/src/servala/core/migrations/0009_organization_limit_cloudproviders_and_more.py b/src/servala/core/migrations/0009_organization_limit_cloudproviders_and_more.py new file mode 100644 index 0000000..3ec1032 --- /dev/null +++ b/src/servala/core/migrations/0009_organization_limit_cloudproviders_and_more.py @@ -0,0 +1,33 @@ +# Generated by Django 5.2.7 on 2025-10-16 22:52 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("core", "0008_organization_osb_guid_service_osb_service_id_and_more"), + ] + + operations = [ + migrations.AddField( + model_name="organization", + name="limit_cloudproviders", + field=models.ManyToManyField( + blank=True, + related_name="+", + to="core.cloudprovider", + verbose_name="Limit to these Cloud providers", + ), + ), + migrations.AddField( + model_name="organization", + name="limit_osb_services", + field=models.ManyToManyField( + blank=True, + related_name="+", + to="core.service", + verbose_name="Services activated from OSB", + ), + ), + ] diff --git a/src/servala/core/models/organization.py b/src/servala/core/models/organization.py index 083bc50..f96aa67 100644 --- a/src/servala/core/models/organization.py +++ b/src/servala/core/models/organization.py @@ -46,6 +46,16 @@ class Organization(ServalaModelMixin, models.Model): related_name="organizations", verbose_name=_("Members"), ) + limit_cloudproviders = models.ManyToManyField( + to="CloudProvider", + related_name="+", + verbose_name=_("Limit to these Cloud providers"), + ) + limit_osb_services = models.ManyToManyField( + to="Service", + related_name="+", + verbose_name=_("Services activated from OSB"), + ) odoo_sale_order_id = models.IntegerField( null=True, blank=True, verbose_name=_("Odoo Sale Order ID") From df3e3d5f0c55f8d330d934bde75b3540d062f073 Mon Sep 17 00:00:00 2001 From: Tobias Kunze Date: Tue, 7 Oct 2025 10:44:46 +0200 Subject: [PATCH 03/36] Only show services with permitted CloudProvider ref #38 --- src/servala/api/views.py | 1 + src/servala/core/models/organization.py | 13 ++++++++ .../organizations/service_detail.html | 2 +- src/servala/frontend/views/service.py | 33 ++++++++++++------- 4 files changed, 37 insertions(+), 12 deletions(-) diff --git a/src/servala/api/views.py b/src/servala/api/views.py index 48845d0..8462f66 100644 --- a/src/servala/api/views.py +++ b/src/servala/api/views.py @@ -109,6 +109,7 @@ class OSBServiceInstanceView(OSBBasicAuthPermission, View): self._send_service_welcome_email( request, organization, user, service, service_offering ) + organization.limit_osb_services.add(service) return JsonResponse({"message": "Service already enabled"}, status=200) odoo_data = { diff --git a/src/servala/core/models/organization.py b/src/servala/core/models/organization.py index f96aa67..32dc12e 100644 --- a/src/servala/core/models/organization.py +++ b/src/servala/core/models/organization.py @@ -50,11 +50,13 @@ class Organization(ServalaModelMixin, models.Model): to="CloudProvider", related_name="+", verbose_name=_("Limit to these Cloud providers"), + blank=True, ) limit_osb_services = models.ManyToManyField( to="Service", related_name="+", verbose_name=_("Services activated from OSB"), + blank=True, ) odoo_sale_order_id = models.IntegerField( @@ -141,6 +143,17 @@ class Organization(ServalaModelMixin, models.Model): return instance + def get_visible_services(self): + from servala.core.models import Service + + queryset = Service.objects.select_related("category") + if self.limit_cloudproviders.exists(): + allowed_providers = self.limit_cloudproviders.all() + queryset = queryset.filter( + offerings__provider__in=allowed_providers + ).distinct() + return queryset.prefetch_related("offerings", "offerings__provider") + class Meta: verbose_name = _("Organization") verbose_name_plural = _("Organizations") diff --git a/src/servala/frontend/templates/frontend/organizations/service_detail.html b/src/servala/frontend/templates/frontend/organizations/service_detail.html index 55cf31e..c72fbef 100644 --- a/src/servala/frontend/templates/frontend/organizations/service_detail.html +++ b/src/servala/frontend/templates/frontend/organizations/service_detail.html @@ -47,7 +47,7 @@
- {% for offering in service.offerings.all %} + {% for offering in visible_offerings %}
-
+
{% for service in services %} -
-
- -
-
- {% if service.description %}

{{ service.description|urlize }}

{% endif %} -
-
- -
-
+
{% include "includes/service_card.html" %}
{% empty %}
@@ -60,6 +29,22 @@
{% endfor %}
+ {% if deactivated_services %} +
+
+
{% translate "You may also be interested in one of these …" %}
+

+ + {% translate "These services need to be enabled on Exoscale first before they become available in the Servala portal." %} +

+
+
+
+ {% for service in deactivated_services %} +
{% include "includes/service_card.html" %}
+ {% endfor %} +
+ {% endif %} {% endblock content %} diff --git a/src/servala/frontend/templates/includes/service_card.html b/src/servala/frontend/templates/includes/service_card.html new file mode 100644 index 0000000..0dae3ae --- /dev/null +++ b/src/servala/frontend/templates/includes/service_card.html @@ -0,0 +1,31 @@ +{% load i18n %} +
+ +
+
+ {% if service.description %}

{{ service.description|urlize }}

{% endif %} +
+
+ +
diff --git a/src/servala/frontend/views/service.py b/src/servala/frontend/views/service.py index 97d1a72..a0390b8 100644 --- a/src/servala/frontend/views/service.py +++ b/src/servala/frontend/views/service.py @@ -49,6 +49,9 @@ class ServiceListView(OrganizationViewMixin, ListView): def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) context["filter_form"] = self.filter_form + context["deactivated_services"] = ( + self.request.organization.get_deactivated_services() + ) return context diff --git a/src/servala/static/css/servala.css b/src/servala/static/css/servala.css index 9a59b8f..eb4fd01 100644 --- a/src/servala/static/css/servala.css +++ b/src/servala/static/css/servala.css @@ -279,3 +279,26 @@ html[data-bs-theme="dark"] .crd-form .nav-tabs .nav-link .mandatory-indicator { .crd-form .nav-tabs .nav-link.has-mandatory { position: relative; } + +.service-deactivated .card { + opacity: 50%; + cursor: not-allowed; + img { + opacity: 75% + } + h4, small, p { + color: var(--bs-secondary-color) !important; + } + a.btn-outline-secondary { + color: var(--bs-btn-disabled-color) !important; + background-color: var(--bs-btn-disabled-bg) !important; + border-color: var(--bs-btn-disabled-border-color) !important; + opacity: var(--bs-btn-disabled-opacity); + } + a.btn-secondary { + color: white !important; + } + a.btn { + pointer-events: none; + } +} From 6443582c0ef8790825b586640f1644824b188e4a Mon Sep 17 00:00:00 2001 From: Tobias Kunze Date: Tue, 7 Oct 2025 15:04:12 +0200 Subject: [PATCH 06/36] Add default billing entity from origin ref #198 --- src/servala/api/views.py | 27 +++++++++-------- .../0010_organizationorigin_billing_entity.py | 26 ++++++++++++++++ src/servala/core/models/organization.py | 10 +++++++ src/tests/conftest.py | 6 ++++ src/tests/test_api_exoscale.py | 30 +++++++++++++++++++ 5 files changed, 87 insertions(+), 12 deletions(-) create mode 100644 src/servala/core/migrations/0010_organizationorigin_billing_entity.py diff --git a/src/servala/api/views.py b/src/servala/api/views.py index 0aa73a2..5d67754 100644 --- a/src/servala/api/views.py +++ b/src/servala/api/views.py @@ -108,16 +108,19 @@ class OSBServiceInstanceView(OSBBasicAuthPermission, View): if service in organization.limit_osb_services.all(): return JsonResponse({"message": "Service already enabled"}, status=200) except Organization.DoesNotExist: - odoo_data = { - "company_name": organization_display_name, - "invoice_email": user.email, - } - with transaction.atomic(): - try: - billing_entity = BillingEntity.create_from_data( - name=f"{organization_display_name} (Exoscale)", - odoo_data=odoo_data, - ) + try: + with transaction.atomic(): + if exoscale_origin.billing_entity: + billing_entity = exoscale_origin.billing_entity + else: + odoo_data = { + "company_name": organization_display_name, + "invoice_email": user.email, + } + billing_entity = BillingEntity.create_from_data( + name=f"{organization_display_name} (Exoscale)", + odoo_data=odoo_data, + ) organization = Organization( name=organization_display_name, billing_entity=billing_entity, @@ -126,8 +129,8 @@ class OSBServiceInstanceView(OSBBasicAuthPermission, View): ) organization = Organization.create_organization(organization, user) self._send_invitation_email(request, organization, user) - except Exception: - return JsonResponse({"error": "Internal server error"}, status=500) + except Exception: + return JsonResponse({"error": "Internal server error"}, status=500) organization.limit_osb_services.add(service) self._send_service_welcome_email( diff --git a/src/servala/core/migrations/0010_organizationorigin_billing_entity.py b/src/servala/core/migrations/0010_organizationorigin_billing_entity.py new file mode 100644 index 0000000..d61a75f --- /dev/null +++ b/src/servala/core/migrations/0010_organizationorigin_billing_entity.py @@ -0,0 +1,26 @@ +# Generated by Django 5.2.7 on 2025-10-17 00:22 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("core", "0009_organization_limit_cloudproviders_and_more"), + ] + + operations = [ + migrations.AddField( + model_name="organizationorigin", + name="billing_entity", + field=models.ForeignKey( + help_text="If set, this billing entity will be used on new organizations with this origin.", + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="origins", + to="core.billingentity", + verbose_name="Billing entity", + ), + ), + ] diff --git a/src/servala/core/models/organization.py b/src/servala/core/models/organization.py index 84453b9..7b06a41 100644 --- a/src/servala/core/models/organization.py +++ b/src/servala/core/models/organization.py @@ -355,6 +355,16 @@ class OrganizationOrigin(ServalaModelMixin, models.Model): name = models.CharField(max_length=100, verbose_name=_("Name")) description = models.TextField(blank=True, verbose_name=_("Description")) + billing_entity = models.ForeignKey( + to="BillingEntity", + on_delete=models.PROTECT, + related_name="origins", + verbose_name=_("Billing entity"), + help_text=_( + "If set, this billing entity will be used on new organizations with this origin." + ), + null=True, + ) class Meta: verbose_name = _("Organization origin") diff --git a/src/tests/conftest.py b/src/tests/conftest.py index 32499ca..09db220 100644 --- a/src/tests/conftest.py +++ b/src/tests/conftest.py @@ -3,6 +3,7 @@ import base64 import pytest from servala.core.models import ( + BillingEntity, Organization, OrganizationMembership, OrganizationOrigin, @@ -21,6 +22,11 @@ def origin(): return OrganizationOrigin.objects.create(name="TESTORIGIN") +@pytest.fixture +def billing_entity(): + return BillingEntity.objects.create(name="Test Entity") + + @pytest.fixture def organization(origin): return Organization.objects.create(name="Test Org", origin=origin) diff --git a/src/tests/test_api_exoscale.py b/src/tests/test_api_exoscale.py index 725eddf..b6fa4dc 100644 --- a/src/tests/test_api_exoscale.py +++ b/src/tests/test_api_exoscale.py @@ -99,6 +99,36 @@ def test_successful_onboarding_new_organization( assert "redis/offering/" in welcome_email.body +@pytest.mark.django_db +def test_new_organization_inherits_origin( + osb_client, + test_service, + test_service_offering, + valid_osb_payload, + exoscale_origin, + instance_id, + billing_entity, +): + valid_osb_payload["service_id"] = test_service.osb_service_id + valid_osb_payload["plan_id"] = test_service_offering.osb_plan_id + exoscale_origin.billing_entity = billing_entity + exoscale_origin.save() + + response = osb_client.put( + f"/api/osb/v2/service_instances/{instance_id}", + data=json.dumps(valid_osb_payload), + content_type="application/json", + ) + + assert response.status_code == 201 + response_data = json.loads(response.content) + assert response_data["message"] == "Successfully enabled service" + + org = Organization.objects.get(osb_guid="test-org-guid-123") + assert org.name == "Test Organization Display" + assert org.billing_entity == exoscale_origin.billing_entity + + @pytest.mark.django_db def test_duplicate_organization_returns_existing( osb_client, From 842b66a84d1a8e6ba571b0f5011dc4b6be05f7aa Mon Sep 17 00:00:00 2001 From: Tobias Kunze Date: Tue, 7 Oct 2025 16:39:04 +0200 Subject: [PATCH 07/36] Prepare to block billing entity editing ref #198 --- src/servala/core/admin.py | 17 ++++++++++++++++- src/servala/core/models/organization.py | 4 ++++ src/servala/frontend/forms/organization.py | 7 +++++-- .../frontend/organizations/update.html | 5 +++++ 4 files changed, 30 insertions(+), 3 deletions(-) diff --git a/src/servala/core/admin.py b/src/servala/core/admin.py index 073d444..0a4208d 100644 --- a/src/servala/core/admin.py +++ b/src/servala/core/admin.py @@ -66,8 +66,22 @@ class OrganizationAdmin(admin.ModelAdmin): def get_readonly_fields(self, request, obj=None): readonly_fields = list(super().get_readonly_fields(request, obj) or []) readonly_fields.append("namespace") # Always read-only + + if obj and obj.has_inherited_billing_entity: + readonly_fields.append("billing_entity") + return readonly_fields + def get_form(self, request, obj=None, **kwargs): + form = super().get_form(request, obj, **kwargs) + + if obj and obj.has_inherited_billing_entity: + form.base_fields["billing_entity"].help_text = _( + "This billing entity is inherited from the organization's origin and cannot be modified." + ) + + return form + @admin.register(BillingEntity) class BillingEntityAdmin(admin.ModelAdmin): @@ -77,8 +91,9 @@ class BillingEntityAdmin(admin.ModelAdmin): @admin.register(OrganizationOrigin) class OrganizationOriginAdmin(admin.ModelAdmin): - list_display = ("name",) + list_display = ("name", "billing_entity") search_fields = ("name",) + autocomplete_fields = ("billing_entity",) @admin.register(OrganizationMembership) diff --git a/src/servala/core/models/organization.py b/src/servala/core/models/organization.py index 7b06a41..b0d58d5 100644 --- a/src/servala/core/models/organization.py +++ b/src/servala/core/models/organization.py @@ -89,6 +89,10 @@ class Organization(ServalaModelMixin, models.Model): def get_absolute_url(self): return self.urls.base + @property + def has_inherited_billing_entity(self): + return self.origin and self.billing_entity == self.origin.billing_entity + def set_owner(self, user): with scopes_disabled(): OrganizationMembership.objects.filter(user=user, organization=self).delete() diff --git a/src/servala/frontend/forms/organization.py b/src/servala/frontend/forms/organization.py index 915ad7b..6fd04b4 100644 --- a/src/servala/frontend/forms/organization.py +++ b/src/servala/frontend/forms/organization.py @@ -8,6 +8,10 @@ from servala.frontend.forms.mixins import HtmxMixin class OrganizationForm(HtmxMixin, ModelForm): + # def __init__(self, *args, **kwargs): + # super().__init__(*args, **kwargs) + # if self.instance and self.instance.has_inherited_billing_entity: + # TODO disable billing entity editing class Meta: model = Organization fields = ("name",) @@ -46,7 +50,7 @@ class OrganizationCreateForm(OrganizationForm): def __init__(self, *args, user=None, **kwargs): super().__init__(*args, **kwargs) - + self.user = user if not self.initial.get("invoice_country"): default_country_name = "Switzerland" country_choices = self.fields["invoice_country"].choices @@ -55,7 +59,6 @@ class OrganizationCreateForm(OrganizationForm): self.initial["invoice_country"] = country_id break - self.user = user self.odoo_addresses = get_invoice_addresses(self.user) if self.odoo_addresses: diff --git a/src/servala/frontend/templates/frontend/organizations/update.html b/src/servala/frontend/templates/frontend/organizations/update.html index 97d266d..2e1b9b0 100644 --- a/src/servala/frontend/templates/frontend/organizations/update.html +++ b/src/servala/frontend/templates/frontend/organizations/update.html @@ -69,6 +69,11 @@

{% translate "Billing Address" %}

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

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

+ {% endif %}
From 8b1e0f74bb6c94edb19a916a8b3e6b9858bbfb9e Mon Sep 17 00:00:00 2001 From: Tobias Kunze Date: Wed, 8 Oct 2025 11:00:16 +0200 Subject: [PATCH 08/36] Add Invitation model ref #19 --- src/servala/core/admin.py | 23 ++++ .../migrations/0011_organizationinvitation.py | 107 ++++++++++++++++++ src/servala/core/models/__init__.py | 2 + src/servala/core/models/organization.py | 69 +++++++++++ 4 files changed, 201 insertions(+) create mode 100644 src/servala/core/migrations/0011_organizationinvitation.py diff --git a/src/servala/core/admin.py b/src/servala/core/admin.py index 0a4208d..f966f38 100644 --- a/src/servala/core/admin.py +++ b/src/servala/core/admin.py @@ -9,6 +9,7 @@ from servala.core.models import ( ControlPlane, ControlPlaneCRD, Organization, + OrganizationInvitation, OrganizationMembership, OrganizationOrigin, Service, @@ -105,6 +106,28 @@ class OrganizationMembershipAdmin(admin.ModelAdmin): date_hierarchy = "date_joined" +@admin.register(OrganizationInvitation) +class OrganizationInvitationAdmin(admin.ModelAdmin): + list_display = ("email", "organization", "role", "is_accepted", "created_at") + list_filter = ("role", "created_at", "accepted_at", "organization") + search_fields = ("email", "organization__name") + autocomplete_fields = ("organization", "accepted_by") + readonly_fields = ( + "secret", + "accepted_by", + "accepted_at", + "created_at", + "updated_at", + ) + date_hierarchy = "created_at" + + def is_accepted(self, obj): + return obj.is_accepted + + is_accepted.boolean = True + is_accepted.short_description = _("Accepted") + + @admin.register(ServiceCategory) class ServiceCategoryAdmin(admin.ModelAdmin): list_display = ("name", "parent") diff --git a/src/servala/core/migrations/0011_organizationinvitation.py b/src/servala/core/migrations/0011_organizationinvitation.py new file mode 100644 index 0000000..25fb4b1 --- /dev/null +++ b/src/servala/core/migrations/0011_organizationinvitation.py @@ -0,0 +1,107 @@ +# Generated by Django 5.2.7 on 2025-10-17 00:58 + +import django.db.models.deletion +import rules.contrib.models +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("core", "0010_organizationorigin_billing_entity"), + ] + + operations = [ + migrations.CreateModel( + name="OrganizationInvitation", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "created_at", + models.DateTimeField(auto_now_add=True, verbose_name="Created"), + ), + ( + "updated_at", + models.DateTimeField(auto_now=True, verbose_name="Last updated"), + ), + ( + "email", + models.EmailField(max_length=254, verbose_name="Email address"), + ), + ( + "role", + models.CharField( + choices=[ + ("member", "Member"), + ("admin", "Administrator"), + ("owner", "Owner"), + ], + default="member", + max_length=20, + verbose_name="Role", + ), + ), + ( + "secret", + models.CharField( + editable=False, + max_length=64, + unique=True, + verbose_name="Secret token", + ), + ), + ( + "accepted_at", + models.DateTimeField( + blank=True, null=True, verbose_name="Accepted at" + ), + ), + ( + "accepted_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="accepted_invitations", + to=settings.AUTH_USER_MODEL, + verbose_name="Accepted by", + ), + ), + ( + "created_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="created_invitations", + to=settings.AUTH_USER_MODEL, + verbose_name="Created by", + ), + ), + ( + "organization", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="invitations", + to="core.organization", + verbose_name="Organization", + ), + ), + ], + options={ + "verbose_name": "Organization invitation", + "verbose_name_plural": "Organization invitations", + "unique_together": {("organization", "email")}, + }, + bases=(rules.contrib.models.RulesModelMixin, models.Model), + ), + ] diff --git a/src/servala/core/models/__init__.py b/src/servala/core/models/__init__.py index 22e8e8a..4c23f18 100644 --- a/src/servala/core/models/__init__.py +++ b/src/servala/core/models/__init__.py @@ -1,6 +1,7 @@ from .organization import ( BillingEntity, Organization, + OrganizationInvitation, OrganizationMembership, OrganizationOrigin, OrganizationRole, @@ -23,6 +24,7 @@ __all__ = [ "ControlPlane", "ControlPlaneCRD", "Organization", + "OrganizationInvitation", "OrganizationMembership", "OrganizationOrigin", "OrganizationRole", diff --git a/src/servala/core/models/organization.py b/src/servala/core/models/organization.py index b0d58d5..b6d7e9f 100644 --- a/src/servala/core/models/organization.py +++ b/src/servala/core/models/organization.py @@ -1,3 +1,5 @@ +import secrets + import rules import urlman from django.conf import settings @@ -417,3 +419,70 @@ class OrganizationMembership(ServalaModelMixin, models.Model): def __str__(self): return f"{self.user} in {self.organization} as {self.role}" + + +class OrganizationInvitation(ServalaModelMixin, models.Model): + organization = models.ForeignKey( + to=Organization, + on_delete=models.CASCADE, + related_name="invitations", + verbose_name=_("Organization"), + ) + email = models.EmailField(verbose_name=_("Email address")) + role = models.CharField( + max_length=20, + choices=OrganizationRole.choices, + default=OrganizationRole.MEMBER, + verbose_name=_("Role"), + ) + secret = models.CharField( + max_length=64, + unique=True, + editable=False, + verbose_name=_("Secret token"), + ) + created_by = models.ForeignKey( + to="core.User", + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="created_invitations", + verbose_name=_("Created by"), + ) + accepted_by = models.ForeignKey( + to="core.User", + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="accepted_invitations", + verbose_name=_("Accepted by"), + ) + accepted_at = models.DateTimeField( + null=True, blank=True, verbose_name=_("Accepted at") + ) + + class urls(urlman.Urls): + accept = "/invitations/{self.secret}/accept/" + + class Meta: + verbose_name = _("Organization invitation") + verbose_name_plural = _("Organization invitations") + unique_together = [["organization", "email"]] + + def __str__(self): + return f"Invitation for {self.email} to {self.organization}" + + def save(self, *args, **kwargs): + if not self.secret: + self.secret = secrets.token_urlsafe(48) + super().save(*args, **kwargs) + + @property + def is_accepted(self): + # We check both accepted_by and accepted_at to avoid a deleted user + # freeing up an invitation + return self.accepted_by or self.accepted_at + + @property + def can_be_accepted(self): + return not self.is_accepted From 09ab83d1e448519ced0058bd3622c67cb72c1dcf Mon Sep 17 00:00:00 2001 From: Tobias Kunze Date: Wed, 8 Oct 2025 11:24:44 +0200 Subject: [PATCH 09/36] Allow users to accept invitations ref #19 --- .../organizations/invitation_accept.html | 42 ++++++++++++ src/servala/frontend/urls.py | 5 ++ src/servala/frontend/views/__init__.py | 2 + src/servala/frontend/views/organization.py | 64 ++++++++++++++++++- 4 files changed, 111 insertions(+), 2 deletions(-) create mode 100644 src/servala/frontend/templates/frontend/organizations/invitation_accept.html diff --git a/src/servala/frontend/templates/frontend/organizations/invitation_accept.html b/src/servala/frontend/templates/frontend/organizations/invitation_accept.html new file mode 100644 index 0000000..fa0bfd7 --- /dev/null +++ b/src/servala/frontend/templates/frontend/organizations/invitation_accept.html @@ -0,0 +1,42 @@ +{% extends "frontend/base.html" %} +{% load i18n %} +{% block html_title %} + {% block page_title %} + {% translate "Accept Organization Invitation" %} + {% endblock page_title %} +{% endblock html_title %} +{% block content %} +
+
+
+
+
+ + {% blocktranslate with org_name=invitation.organization.name role=invitation.get_role_display %} + You have been invited to join {{ org_name }} as a {{ role }}. + {% endblocktranslate %} +
+ {% if user.email|lower != invitation.email|lower %} +
+ + {% blocktranslate with invitation_email=invitation.email user_email=user.email %} + Note: This invitation was sent to {{ invitation_email }}, + but you are currently logged in as {{ user_email }}. + {% endblocktranslate %} +
+ {% endif %} +
+ {% csrf_token %} +
+ {% translate "Cancel" %} + +
+
+
+
+
+
+{% endblock content %} diff --git a/src/servala/frontend/urls.py b/src/servala/frontend/urls.py index 7790b22..3aa9b08 100644 --- a/src/servala/frontend/urls.py +++ b/src/servala/frontend/urls.py @@ -6,6 +6,11 @@ from servala.frontend import views urlpatterns = [ path("accounts/profile/", views.ProfileView.as_view(), name="profile"), path("accounts/logout/", views.LogoutView.as_view(), name="logout"), + path( + "invitations//accept/", + views.InvitationAcceptView.as_view(), + name="invitation.accept", + ), path( "organizations/", views.OrganizationSelectionView.as_view(), diff --git a/src/servala/frontend/views/__init__.py b/src/servala/frontend/views/__init__.py index 5f11a75..6167221 100644 --- a/src/servala/frontend/views/__init__.py +++ b/src/servala/frontend/views/__init__.py @@ -8,6 +8,7 @@ from .generic import ( custom_500, ) from .organization import ( + InvitationAcceptView, OrganizationCreateView, OrganizationDashboardView, OrganizationUpdateView, @@ -25,6 +26,7 @@ from .support import SupportView __all__ = [ "IndexView", + "InvitationAcceptView", "LogoutView", "OrganizationCreateView", "OrganizationDashboardView", diff --git a/src/servala/frontend/views/organization.py b/src/servala/frontend/views/organization.py index 2f35f76..e56416d 100644 --- a/src/servala/frontend/views/organization.py +++ b/src/servala/frontend/views/organization.py @@ -1,11 +1,17 @@ -from django.shortcuts import redirect +from django.contrib import messages +from django.shortcuts import get_object_or_404, redirect +from django.urls import reverse +from django.utils import timezone +from django.utils.decorators import method_decorator from django.utils.translation import gettext_lazy as _ -from django.views.generic import CreateView, DetailView +from django.views.generic import CreateView, DetailView, TemplateView +from django_scopes import scopes_disabled from rules.contrib.views import AutoPermissionRequiredMixin from servala.core.models import ( BillingEntity, Organization, + OrganizationInvitation, OrganizationMembership, ServiceInstance, ) @@ -103,3 +109,57 @@ class OrganizationUpdateView(OrganizationViewMixin, HtmxUpdateView): def get_success_url(self): return self.request.path + + +@method_decorator(scopes_disabled(), name="dispatch") +class InvitationAcceptView(TemplateView): + template_name = "frontend/organizations/invitation_accept.html" + + def get_invitation(self): + secret = self.kwargs.get("secret") + return get_object_or_404(OrganizationInvitation, secret=secret) + + def dispatch(self, request, *args, **kwargs): + invitation = self.get_invitation() + + if invitation.is_accepted: + messages.warning( + request, + _("This invitation has already been accepted."), + ) + return redirect("frontend:organization.selection") + if not request.user.is_authenticated: + request.session["invitation_next"] = request.path + messages.info( + request, + _("Please log in or sign up to accept this invitation."), + ) + return redirect(f"{reverse('account_login')}?next={request.path}") + return super().dispatch(request, *args, **kwargs) + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context["invitation"] = self.get_invitation() + return context + + def post(self, request, *args, **kwargs): + invitation = self.get_invitation() + invitation.accepted_by = request.user + invitation.accepted_at = timezone.now() + invitation.save() + + OrganizationMembership.objects.get_or_create( + user=request.user, + organization=invitation.organization, + defaults={"role": invitation.role}, + ) + + messages.success( + request, + _("You have successfully joined {organization}!").format( + organization=invitation.organization.name + ), + ) + + request.session.pop("invitation_next", None) + return redirect(invitation.organization.urls.base) From 21c26f9e5d846fe366a844056df2f606178bec78 Mon Sep 17 00:00:00 2001 From: Tobias Kunze Date: Wed, 8 Oct 2025 15:04:46 +0200 Subject: [PATCH 10/36] Allow users to invite other users ref #19 --- src/servala/frontend/forms/organization.py | 68 +++++++++++- .../frontend/organizations/update.html | 102 ++++++++++++++++++ src/servala/frontend/views/organization.py | 89 ++++++++++++++- 3 files changed, 256 insertions(+), 3 deletions(-) diff --git a/src/servala/frontend/forms/organization.py b/src/servala/frontend/forms/organization.py index 6fd04b4..27e6a09 100644 --- a/src/servala/frontend/forms/organization.py +++ b/src/servala/frontend/forms/organization.py @@ -1,8 +1,9 @@ from django import forms +from django.core.exceptions import ValidationError from django.forms import ModelForm from django.utils.translation import gettext_lazy as _ -from servala.core.models import Organization +from servala.core.models import Organization, OrganizationInvitation, OrganizationRole from servala.core.odoo import get_invoice_addresses, get_odoo_countries from servala.frontend.forms.mixins import HtmxMixin @@ -111,3 +112,68 @@ class OrganizationCreateForm(OrganizationForm): "existing_odoo_address_id", _("Please select an invoice address.") ) return cleaned_data + + +class OrganizationInvitationForm(forms.ModelForm): + + def __init__(self, *args, organization=None, user_role=None, **kwargs): + super().__init__(*args, **kwargs) + self.organization = organization + self.user_role = user_role + + if user_role: + allowed_roles = self._get_allowed_roles(user_role) + self.fields["role"].choices = [ + (value, label) + for value, label in OrganizationRole.choices + if value in allowed_roles + ] + + def _get_allowed_roles(self, user_role): + role_hierarchy = { + OrganizationRole.OWNER: [ + OrganizationRole.OWNER, + OrganizationRole.ADMIN, + OrganizationRole.MEMBER, + ], + OrganizationRole.ADMIN: [ + OrganizationRole.ADMIN, + OrganizationRole.MEMBER, + ], + OrganizationRole.MEMBER: [], + } + return role_hierarchy.get(user_role, []) + + def clean_email(self): + email = self.cleaned_data["email"].lower() + + if self.organization.members.filter(email__iexact=email).exists(): + raise ValidationError( + _("A user with this email is already a member of this organization.") + ) + + if OrganizationInvitation.objects.filter( + organization=self.organization, + email__iexact=email, + accepted_by__isnull=True, + ).exists(): + raise ValidationError( + _("An invitation has already been sent to this email address.") + ) + + return email + + def save(self, commit=True): + invitation = super().save(commit=False) + invitation.organization = self.organization + if commit: + invitation.save() + return invitation + + class Meta: + model = OrganizationInvitation + fields = ("email", "role") + widgets = { + "email": forms.EmailInput(attrs={"placeholder": _("user@example.com")}), + "role": forms.RadioSelect(), + } diff --git a/src/servala/frontend/templates/frontend/organizations/update.html b/src/servala/frontend/templates/frontend/organizations/update.html index 2e1b9b0..d55dc56 100644 --- a/src/servala/frontend/templates/frontend/organizations/update.html +++ b/src/servala/frontend/templates/frontend/organizations/update.html @@ -36,6 +36,74 @@ {% endpartialdef org-name-edit %} +{% partialdef members-list %} +
+ + + + + + + + + + + {% for membership in memberships %} + + + + + + + {% empty %} + + + + {% endfor %} + +
{% translate "Name" %}{% translate "Email" %}{% translate "Role" %}{% translate "Joined" %}
{{ membership.user }}{{ membership.user.email }} + + {{ membership.get_role_display }} + + {{ membership.date_joined|date:"Y-m-d" }}
{% translate "No members yet" %}
+
+{% if pending_invitations %} +
+ {% translate "Pending Invitations" %} +
+
+ + + + + + + + + + + {% for invitation in pending_invitations %} + + + + + + + {% endfor %} + +
{% translate "Email" %}{% translate "Role" %}{% translate "Sent" %}{% translate "Link" %}
{{ invitation.email }} + + {{ invitation.get_role_display }} + + {{ invitation.created_at|date:"Y-m-d H:i" }} + +
+
+{% endif %} +{% endpartialdef members-list %} {% block content %}
@@ -135,5 +203,39 @@
{% endif %} + {% if can_manage_members %} +
+
+

+ {% translate "Members" %} +

+
+
+
{% partial members-list %}
+
+
+
+
+

+ {% translate "Invite New Member" %} +

+
+
+
+
+ {% csrf_token %} +
{{ invitation_form }}
+
+
+ +
+
+
+
+
+
+ {% endif %} {% endblock content %} diff --git a/src/servala/frontend/views/organization.py b/src/servala/frontend/views/organization.py index e56416d..651b18c 100644 --- a/src/servala/frontend/views/organization.py +++ b/src/servala/frontend/views/organization.py @@ -3,6 +3,7 @@ from django.shortcuts import get_object_or_404, redirect from django.urls import reverse from django.utils import timezone from django.utils.decorators import method_decorator +from django.utils.functional import cached_property from django.utils.translation import gettext_lazy as _ from django.views.generic import CreateView, DetailView, TemplateView from django_scopes import scopes_disabled @@ -13,9 +14,14 @@ from servala.core.models import ( Organization, OrganizationInvitation, OrganizationMembership, + OrganizationRole, ServiceInstance, ) -from servala.frontend.forms.organization import OrganizationCreateForm, OrganizationForm +from servala.frontend.forms.organization import ( + OrganizationCreateForm, + OrganizationForm, + OrganizationInvitationForm, +) from servala.frontend.views.mixins import HtmxUpdateView, OrganizationViewMixin @@ -105,7 +111,86 @@ class OrganizationDashboardView( class OrganizationUpdateView(OrganizationViewMixin, HtmxUpdateView): template_name = "frontend/organizations/update.html" form_class = OrganizationForm - fragments = ("org-name", "org-name-edit") + fragments = ("org-name", "org-name-edit", "members-list") + + @cached_property + def user_role(self): + membership = ( + OrganizationMembership.objects.filter( + user=self.request.user, organization=self.get_object() + ) + .order_by("role") + .first() + ) + return membership.role if membership else None + + @cached_property + def can_manage_members(self): + return self.user_role in [ + OrganizationRole.ADMIN, + OrganizationRole.OWNER, + ] + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + organization = self.get_object() + + if self.can_manage_members: + memberships = ( + OrganizationMembership.objects.filter(organization=organization) + .select_related("user") + .order_by("role", "user__email") + ) + pending_invitations = OrganizationInvitation.objects.filter( + organization=organization, accepted_by__isnull=True + ).order_by("-created_at") + invitation_form = OrganizationInvitationForm( + organization=organization, user_role=self.user_role + ) + context.update( + { + "memberships": memberships, + "pending_invitations": pending_invitations, + "invitation_form": invitation_form, + "can_manage_members": self.can_manage_members, + "user_role": self.user_role, + } + ) + + return context + + def post(self, request, *args, **kwargs): + if "invite_email" in request.POST: + return self.handle_invitation(request) + return super().post(request, *args, **kwargs) + + def handle_invitation(self, request): + organization = self.get_object() + if not self.can_manage_members: + messages.error(request, _("You do not have permission to invite members.")) + return redirect(self.get_success_url()) + + form = OrganizationInvitationForm( + request.POST, organization=organization, user_role=self.user_role + ) + + if form.is_valid(): + invitation = form.save(commit=False) + invitation.created_by = request.user + invitation.save() + + messages.success( + request, + _("Invitation sent to {email}. Share this link: {url}").format( + email=invitation.email, + url=request.build_absolute_uri(invitation.urls.accept), + ), + ) + else: + for error in form.errors.values(): + messages.error(request, error.as_text()) + + return redirect(self.get_success_url()) def get_success_url(self): return self.request.path From b9ff0e61daac5a55e84e512902ef3bdf6d29b57b Mon Sep 17 00:00:00 2001 From: Tobias Kunze Date: Wed, 8 Oct 2025 17:53:27 +0200 Subject: [PATCH 11/36] Send invitation emails ref #19 --- src/servala/api/views.py | 39 ++++++---------- src/servala/core/admin.py | 30 ++++++++++++ src/servala/core/models/organization.py | 54 +++++++++++++++++++++- src/servala/frontend/views/organization.py | 25 +++++++--- src/tests/test_api_exoscale.py | 11 ++--- 5 files changed, 119 insertions(+), 40 deletions(-) diff --git a/src/servala/api/views.py b/src/servala/api/views.py index 5d67754..456f4b2 100644 --- a/src/servala/api/views.py +++ b/src/servala/api/views.py @@ -12,7 +12,13 @@ from django.views.decorators.csrf import csrf_exempt from servala.api.permissions import OSBBasicAuthPermission from servala.core.exoscale import get_exoscale_origin -from servala.core.models import BillingEntity, Organization, User +from servala.core.models import ( + BillingEntity, + Organization, + OrganizationInvitation, + OrganizationRole, + User, +) from servala.core.models.service import Service, ServiceOffering logger = logging.getLogger(__name__) @@ -127,8 +133,13 @@ class OSBServiceInstanceView(OSBBasicAuthPermission, View): origin=exoscale_origin, osb_guid=organization_guid, ) - organization = Organization.create_organization(organization, user) - self._send_invitation_email(request, organization, user) + organization = Organization.create_organization(organization) + invitation = OrganizationInvitation.objects.create( + organization=organization, + email=user.email.lower(), + role=OrganizationRole.OWNER, + ) + invitation.send_invitation_email(request) except Exception: return JsonResponse({"error": "Internal server error"}, status=500) @@ -138,28 +149,6 @@ class OSBServiceInstanceView(OSBBasicAuthPermission, View): ) return JsonResponse({"message": "Successfully enabled service"}, status=201) - def _send_invitation_email(self, request, organization, user): - subject = f"Welcome to Servala - {organization.name}" - url = request.build_absolute_uri(organization.urls.base) - message = f"""Hello {user.first_name or user.email}, - -You have been invited to join the organization "{organization.name}" on Servala Portal. - -You can access your organization at: {url} - -Please use this email address ({user.email}) when prompted to log in. - -Best regards, -The Servala Team""" - - send_mail( - subject=subject, - message=message, - from_email=settings.EMAIL_DEFAULT_FROM, - recipient_list=[user.email], - fail_silently=False, - ) - def _send_service_welcome_email( self, request, organization, user, service, service_offering ): diff --git a/src/servala/core/admin.py b/src/servala/core/admin.py index f966f38..6e3aff6 100644 --- a/src/servala/core/admin.py +++ b/src/servala/core/admin.py @@ -120,6 +120,7 @@ class OrganizationInvitationAdmin(admin.ModelAdmin): "updated_at", ) date_hierarchy = "created_at" + actions = ["send_invitation_emails"] def is_accepted(self, obj): return obj.is_accepted @@ -127,6 +128,35 @@ class OrganizationInvitationAdmin(admin.ModelAdmin): is_accepted.boolean = True is_accepted.short_description = _("Accepted") + def send_invitation_emails(self, request, queryset): + pending_invitations = queryset.filter(accepted_by__isnull=True) + sent_count = 0 + failed_count = 0 + + for invitation in pending_invitations: + try: + invitation.send_invitation_email(request) + sent_count += 1 + except Exception as e: + failed_count += 1 + messages.error( + request, + _(f"Failed to send invitation to {invitation.email}: {str(e)}"), + ) + + if sent_count > 0: + messages.success( + request, + _(f"Successfully sent {sent_count} invitation email(s)."), + ) + + if failed_count > 0: + messages.warning( + request, _(f"Failed to send {failed_count} invitation email(s).") + ) + + send_invitation_emails.short_description = _("Send invitation emails") + @admin.register(ServiceCategory) class ServiceCategoryAdmin(admin.ModelAdmin): diff --git a/src/servala/core/models/organization.py b/src/servala/core/models/organization.py index b6d7e9f..d308bfa 100644 --- a/src/servala/core/models/organization.py +++ b/src/servala/core/models/organization.py @@ -3,7 +3,10 @@ import secrets import rules import urlman from django.conf import settings +from django.contrib.sites.shortcuts import get_current_site +from django.core.mail import send_mail from django.db import models, transaction +from django.http import HttpRequest from django.utils.functional import cached_property from django.utils.safestring import mark_safe from django.utils.text import slugify @@ -112,7 +115,7 @@ class Organization(ServalaModelMixin, models.Model): @classmethod @transaction.atomic - def create_organization(cls, instance, owner): + def create_organization(cls, instance, owner=None): try: instance.origin except Exception: @@ -120,7 +123,8 @@ class Organization(ServalaModelMixin, models.Model): pk=settings.SERVALA_DEFAULT_ORIGIN ) instance.save() - instance.set_owner(owner) + if owner: + instance.set_owner(owner) if ( instance.billing_entity.odoo_company_id @@ -486,3 +490,49 @@ class OrganizationInvitation(ServalaModelMixin, models.Model): @property def can_be_accepted(self): return not self.is_accepted + + def send_invitation_email(self, request=None): + subject = _("You're invited to join {organization} on Servala").format( + organization=self.organization.name + ) + + if request: + invitation_url = request.build_absolute_uri(self.urls.accept) + organization_url = request.build_absolute_uri(self.organization.urls.base) + else: + fake_request = HttpRequest() + fake_request.META["SERVER_NAME"] = get_current_site(None).domain + fake_request.META["SERVER_PORT"] = "443" + fake_request.META["wsgi.url_scheme"] = "https" + invitation_url = fake_request.build_absolute_uri(self.urls.accept) + organization_url = fake_request.build_absolute_uri( + self.organization.urls.base + ) + + message = _( + """Hello, + +You have been invited to join the organization "{organization}" on Servala Portal as a {role}. + +To accept this invitation, please click the link below: +{invitation_url} + +Once you accept, you'll be able to access the organization at: +{organization_url} + +Best regards, +The Servala Team""" + ).format( + organization=self.organization.name, + role=self.get_role_display(), + invitation_url=invitation_url, + organization_url=organization_url, + ) + + send_mail( + subject=subject, + message=message, + from_email=settings.EMAIL_DEFAULT_FROM, + recipient_list=[self.email], + fail_silently=False, + ) diff --git a/src/servala/frontend/views/organization.py b/src/servala/frontend/views/organization.py index 651b18c..7013a6c 100644 --- a/src/servala/frontend/views/organization.py +++ b/src/servala/frontend/views/organization.py @@ -179,13 +179,24 @@ class OrganizationUpdateView(OrganizationViewMixin, HtmxUpdateView): invitation.created_by = request.user invitation.save() - messages.success( - request, - _("Invitation sent to {email}. Share this link: {url}").format( - email=invitation.email, - url=request.build_absolute_uri(invitation.urls.accept), - ), - ) + try: + invitation.send_invitation_email(request) + messages.success( + request, + _( + "Invitation sent to {email}. They will receive an email with the invitation link." + ).format(email=invitation.email), + ) + except Exception: + messages.warning( + request, + _( + "Invitation created for {email}, but email failed to send. Share this link manually: {url}" + ).format( + email=invitation.email, + url=request.build_absolute_uri(invitation.urls.accept), + ), + ) else: for error in form.errors.values(): messages.error(request, error.as_text()) diff --git a/src/tests/test_api_exoscale.py b/src/tests/test_api_exoscale.py index b6fa4dc..6d10deb 100644 --- a/src/tests/test_api_exoscale.py +++ b/src/tests/test_api_exoscale.py @@ -73,12 +73,8 @@ def test_successful_onboarding_new_organization( assert org.origin == exoscale_origin assert org.namespace.startswith("org-") - user = User.objects.get(email="test@example.com") - assert user.first_name == "Test" - assert user.last_name == "User" with scopes_disabled(): - membership = org.memberships.get(user=user) - assert membership.role == "owner" + assert org.invitations.all().filter(email="test@example.com").exists() billing_entity = org.billing_entity assert billing_entity.name == "Test Organization Display (Exoscale)" @@ -91,7 +87,10 @@ def test_successful_onboarding_new_organization( assert len(mail.outbox) == 2 invitation_email = mail.outbox[0] - assert invitation_email.subject == "Welcome to Servala - Test Organization Display" + assert ( + invitation_email.subject + == "You're invited to join Test Organization Display on Servala" + ) assert "test@example.com" in invitation_email.to welcome_email = mail.outbox[1] From 4bf35260ad6419ec0c392751131baf776ad2a4ac Mon Sep 17 00:00:00 2001 From: Tobias Kunze Date: Mon, 13 Oct 2025 11:24:40 +0200 Subject: [PATCH 12/36] Add external links to service offerings ref #197 --- src/servala/core/admin.py | 22 ++++++++++++++++-- .../0012_serviceoffering_external_links.py | 23 +++++++++++++++++++ src/servala/core/models/service.py | 6 +++++ .../organizations/service_detail.html | 8 +------ .../service_offering_detail.html | 13 ++++------- .../templates/includes/external_link.html | 7 ++++++ 6 files changed, 62 insertions(+), 17 deletions(-) create mode 100644 src/servala/core/migrations/0012_serviceoffering_external_links.py create mode 100644 src/servala/frontend/templates/includes/external_link.html diff --git a/src/servala/core/admin.py b/src/servala/core/admin.py index 6e3aff6..51af3fe 100644 --- a/src/servala/core/admin.py +++ b/src/servala/core/admin.py @@ -176,7 +176,6 @@ class ServiceAdmin(admin.ModelAdmin): def get_form(self, request, obj=None, **kwargs): form = super().get_form(request, obj, **kwargs) - # JSON schema for external_links field external_links_schema = { "type": "array", "title": "External Links", @@ -209,7 +208,6 @@ class CloudProviderAdmin(admin.ModelAdmin): def get_form(self, request, obj=None, **kwargs): form = super().get_form(request, obj, **kwargs) - # JSON schema for external_links field external_links_schema = { "type": "array", "title": "External Links", @@ -372,3 +370,23 @@ class ServiceOfferingAdmin(admin.ModelAdmin): search_fields = ("description",) autocomplete_fields = ("service", "provider") inlines = (ControlPlaneCRDInline,) + + def get_form(self, request, obj=None, **kwargs): + form = super().get_form(request, obj, **kwargs) + external_links_schema = { + "type": "array", + "title": "External Links", + "items": { + "type": "object", + "title": "Link", + "properties": { + "url": {"type": "string", "format": "uri", "title": "URL"}, + "title": {"type": "string", "title": "Title"}, + }, + "required": ["url", "title"], + }, + } + form.base_fields["external_links"].widget = JSONFormWidget( + schema=external_links_schema + ) + return form diff --git a/src/servala/core/migrations/0012_serviceoffering_external_links.py b/src/servala/core/migrations/0012_serviceoffering_external_links.py new file mode 100644 index 0000000..d8c2ac7 --- /dev/null +++ b/src/servala/core/migrations/0012_serviceoffering_external_links.py @@ -0,0 +1,23 @@ +# Generated by Django 5.2.7 on 2025-10-17 02:23 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("core", "0011_organizationinvitation"), + ] + + operations = [ + migrations.AddField( + model_name="serviceoffering", + name="external_links", + field=models.JSONField( + blank=True, + help_text='JSON array of link objects: {"url": "…", "title": "…"}. ', + null=True, + verbose_name="External links", + ), + ), + ] diff --git a/src/servala/core/models/service.py b/src/servala/core/models/service.py index 846b24e..37b3d18 100644 --- a/src/servala/core/models/service.py +++ b/src/servala/core/models/service.py @@ -511,6 +511,12 @@ class ServiceOffering(ServalaModelMixin, models.Model): verbose_name=_("Provider"), ) description = models.TextField(blank=True, verbose_name=_("Description")) + external_links = models.JSONField( + null=True, + blank=True, + verbose_name=_("External links"), + help_text=('JSON array of link objects: {"url": "…", "title": "…"}. '), + ) osb_plan_id = models.CharField( max_length=100, null=True, diff --git a/src/servala/frontend/templates/frontend/organizations/service_detail.html b/src/servala/frontend/templates/frontend/organizations/service_detail.html index c72fbef..a101ed1 100644 --- a/src/servala/frontend/templates/frontend/organizations/service_detail.html +++ b/src/servala/frontend/templates/frontend/organizations/service_detail.html @@ -32,13 +32,7 @@
{% translate "External Links" %}
{% for link in service.external_links %} - - {{ link.title }} - - + {% include "includes/external_link.html" %} {% endfor %}
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 7f3863e..c2049c7 100644 --- a/src/servala/frontend/templates/frontend/organizations/service_offering_detail.html +++ b/src/servala/frontend/templates/frontend/organizations/service_offering_detail.html @@ -64,19 +64,16 @@ {{ select_form }} {% endif %} - {% if service.external_links %} + {% if service.external_links or offering.external_links %}
{% translate "External Links" %}
{% for link in service.external_links %} - - {{ link.title }} - - + {% include "includes/external_link.html" %} + {% endfor %} + {% for link in offering.external_links %} + {% include "includes/external_link.html" %} {% endfor %}
diff --git a/src/servala/frontend/templates/includes/external_link.html b/src/servala/frontend/templates/includes/external_link.html new file mode 100644 index 0000000..e8319bf --- /dev/null +++ b/src/servala/frontend/templates/includes/external_link.html @@ -0,0 +1,7 @@ + + {{ link.title }} + + From 27b9133ad4f876f0f4fa49616e76fee4e91d5dd4 Mon Sep 17 00:00:00 2001 From: Tobias Kunze Date: Mon, 13 Oct 2025 12:56:06 +0200 Subject: [PATCH 13/36] Do not display _HOST keys in connection details ref #200 --- src/servala/core/models/service.py | 3 ++ .../service_instance_detail.html | 54 ++++++++++--------- 2 files changed, 31 insertions(+), 26 deletions(-) diff --git a/src/servala/core/models/service.py b/src/servala/core/models/service.py index 37b3d18..cecd7dd 100644 --- a/src/servala/core/models/service.py +++ b/src/servala/core/models/service.py @@ -911,6 +911,9 @@ class ServiceInstance(ServalaModelMixin, models.Model): import base64 for key, value in secret.data.items(): + # Skip keys ending with _HOST as they're only useful for dedicated OpenShift clusters + if key.endswith("_HOST"): + continue try: credentials[key] = base64.b64decode(value).decode("utf-8") except Exception: 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 c17dca0..d375344 100644 --- a/src/servala/frontend/templates/frontend/organizations/service_instance_detail.html +++ b/src/servala/frontend/templates/frontend/organizations/service_instance_detail.html @@ -173,34 +173,36 @@
{% endif %} {% if instance.connection_credentials %} -
-
-

{% translate "Connection Credentials" %}

-
-
-
- - - - - - - - - {% for key, value in instance.connection_credentials.items %} +
+
+
+

{% translate "Connection Credentials" %}

+
+
+
+
{% translate "Name" %}{% translate "Value" %}
+ - - + + - {% endfor %} - -
{{ key }} - {% if key == "error" %} - {{ value }} - {% else %} - {{ value }} - {% endif %} - {% translate "Name" %}{% translate "Value" %}
+ + + {% for key, value in instance.connection_credentials.items %} + + {{ key }} + + {% if key == "error" %} + {{ value }} + {% else %} + {{ value }} + {% endif %} + + + {% endfor %} + + +
From 72fedefb7f7c00af12988d47387f3fa131d3d96f Mon Sep 17 00:00:00 2001 From: Tobias Kunze Date: Tue, 14 Oct 2025 11:16:03 +0200 Subject: [PATCH 14/36] Roll back service creation on error ref #202 --- src/servala/core/models/service.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/servala/core/models/service.py b/src/servala/core/models/service.py index cecd7dd..7be6003 100644 --- a/src/servala/core/models/service.py +++ b/src/servala/core/models/service.py @@ -663,6 +663,7 @@ class ServiceInstance(ServalaModelMixin, models.Model): return mark_safe(f"
    {error_items}
") @classmethod + @transaction.atomic def create_instance(cls, name, organization, context, created_by, spec_data): # Ensure the namespace exists context.control_plane.get_or_create_namespace(organization) @@ -710,7 +711,7 @@ class ServiceInstance(ServalaModelMixin, models.Model): body=create_data, ) except Exception as e: - instance.delete() + # Transaction will automatically roll back the instance creation if isinstance(e, ApiException): try: error_body = json.loads(e.body) From 573b7a5eb5c42f9675908bd70b3653325ddb3fb4 Mon Sep 17 00:00:00 2001 From: Tobias Kunze Date: Tue, 14 Oct 2025 14:34:27 +0200 Subject: [PATCH 15/36] Add wildcard_dns field ref #203 --- src/servala/core/admin.py | 10 +++++++- .../0013_controlplane_wildcard_dns.py | 24 +++++++++++++++++++ src/servala/core/models/service.py | 9 +++++++ 3 files changed, 42 insertions(+), 1 deletion(-) create mode 100644 src/servala/core/migrations/0013_controlplane_wildcard_dns.py diff --git a/src/servala/core/admin.py b/src/servala/core/admin.py index 51af3fe..efe935c 100644 --- a/src/servala/core/admin.py +++ b/src/servala/core/admin.py @@ -240,7 +240,15 @@ class ControlPlaneAdmin(admin.ModelAdmin): fieldsets = ( ( None, - {"fields": ("name", "description", "cloud_provider", "required_label")}, + { + "fields": ( + "name", + "description", + "cloud_provider", + "required_label", + "wildcard_dns", + ) + }, ), ( _("API Credentials"), diff --git a/src/servala/core/migrations/0013_controlplane_wildcard_dns.py b/src/servala/core/migrations/0013_controlplane_wildcard_dns.py new file mode 100644 index 0000000..26dbaaf --- /dev/null +++ b/src/servala/core/migrations/0013_controlplane_wildcard_dns.py @@ -0,0 +1,24 @@ +# Generated by Django 5.2.7 on 2025-10-17 02:51 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("core", "0012_serviceoffering_external_links"), + ] + + operations = [ + migrations.AddField( + model_name="controlplane", + name="wildcard_dns", + field=models.CharField( + blank=True, + help_text="Wildcard DNS domain for auto-generating FQDNs (e.g., apps.exoscale-ch-gva-2-prod2.services.servala.com)", + max_length=255, + null=True, + verbose_name="Wildcard DNS", + ), + ), + ] diff --git a/src/servala/core/models/service.py b/src/servala/core/models/service.py index 7be6003..f67f454 100644 --- a/src/servala/core/models/service.py +++ b/src/servala/core/models/service.py @@ -159,6 +159,15 @@ class ControlPlane(ServalaModelMixin, models.Model): "Key-value information displayed to users when selecting this control plane" ), ) + wildcard_dns = models.CharField( + max_length=255, + blank=True, + null=True, + verbose_name=_("Wildcard DNS"), + help_text=_( + "Wildcard DNS domain for auto-generating FQDNs (e.g., apps.exoscale-ch-gva-2-prod2.services.servala.com)" + ), + ) class Meta: verbose_name = _("Control plane") From 3375a1c8f3e181b483ab96d1cef56ced8f190bfd Mon Sep 17 00:00:00 2001 From: Tobias Kunze Date: Tue, 14 Oct 2025 17:05:22 +0200 Subject: [PATCH 16/36] Generate wildcard DNS in frontend ref #203 --- .../frontend/templates/frontend/base.html | 2 + .../service_offering_detail.html | 11 ++++++ src/servala/frontend/views/service.py | 27 ++++++++++--- src/servala/static/js/fqdn.js | 38 +++++++++++++++++++ 4 files changed, 73 insertions(+), 5 deletions(-) create mode 100644 src/servala/static/js/fqdn.js diff --git a/src/servala/frontend/templates/frontend/base.html b/src/servala/frontend/templates/frontend/base.html index 1301bec..7c6bc54 100644 --- a/src/servala/frontend/templates/frontend/base.html +++ b/src/servala/frontend/templates/frontend/base.html @@ -93,5 +93,7 @@ })(); + {% block extra_js %} + {% endblock extra_js %} 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 c2049c7..842e610 100644 --- a/src/servala/frontend/templates/frontend/organizations/service_offering_detail.html +++ b/src/servala/frontend/templates/frontend/organizations/service_offering_detail.html @@ -93,3 +93,14 @@
{% endblock content %} +{% block extra_js %} + {% if wildcard_dns and organization_namespace %} + + + {% endif %} +{% endblock extra_js %} diff --git a/src/servala/frontend/views/service.py b/src/servala/frontend/views/service.py index a0390b8..ba4f0a4 100644 --- a/src/servala/frontend/views/service.py +++ b/src/servala/frontend/views/service.py @@ -132,12 +132,25 @@ class ServiceOfferingDetailView(OrganizationViewMixin, HtmxViewMixin, DetailView def get_instance_form(self): if not self.context_object or not self.context_object.model_form_class: return None - return self.context_object.model_form_class( + + initial = { + "organization": self.request.organization, + "context": self.context_object, + } + + # Pre-populate FQDN field if it exists and control plane has wildcard DNS + form_class = self.context_object.model_form_class + if ( + "spec.parameters.service.fqdn" in form_class.base_fields + and self.context_object.control_plane.wildcard_dns + ): + # Generate initial FQDN: instancename-namespace.wildcard_dns + # We'll set a placeholder that JavaScript will replace dynamically + initial["spec.parameters.service.fqdn"] = "" + + return form_class( data=self.request.POST if self.request.method == "POST" else None, - initial={ - "organization": self.request.organization, - "context": self.context_object, - }, + initial=initial, ) def get_context_data(self, **kwargs): @@ -146,6 +159,10 @@ class ServiceOfferingDetailView(OrganizationViewMixin, HtmxViewMixin, DetailView context["has_control_planes"] = self.planes.exists() context["selected_plane"] = self.selected_plane context["service_form"] = self.get_instance_form() + # Pass data for dynamic FQDN generation + if self.selected_plane and self.selected_plane.wildcard_dns: + context["wildcard_dns"] = self.selected_plane.wildcard_dns + context["organization_namespace"] = self.request.organization.namespace return context def post(self, request, *args, **kwargs): diff --git a/src/servala/static/js/fqdn.js b/src/servala/static/js/fqdn.js new file mode 100644 index 0000000..7b61c9a --- /dev/null +++ b/src/servala/static/js/fqdn.js @@ -0,0 +1,38 @@ + +const initializeFqdnGeneration = () => { + const nameField = document.querySelector('input[name="name"]'); + const fqdnField = document.querySelector('label[for="id_spec.parameters.service.fqdn"] + div input.array-item-input'); + + if (nameField && fqdnField) { + const generateFqdn = (instanceName) => { + if (!instanceName) return ''; + return `${instanceName}-${fqdnConfig.namespace}.${fqdnConfig.wildcardDns}`; + } + + const newNameField = nameField.cloneNode(true); + nameField.parentNode.replaceChild(newNameField, nameField); + const newFqdnField = fqdnField.cloneNode(true); + fqdnField.parentNode.replaceChild(newFqdnField, fqdnField); + + newNameField.addEventListener('input', function() { + if (!newFqdnField.dataset.manuallyEdited) { + newFqdnField.value = generateFqdn(this.value); + } + }); + + newFqdnField.addEventListener('input', function() { + this.dataset.manuallyEdited = 'true'; + }); + + if (newNameField.value && !newFqdnField.value) { + newFqdnField.value = generateFqdn(newNameField.value); + } + } +} + +document.addEventListener('DOMContentLoaded', initializeFqdnGeneration); +document.body.addEventListener('htmx:afterSwap', function(event) { + if (event.detail.target.id === 'service-form') { + initializeFqdnGeneration(); + } +}); From 4124add14608b3b44b80e3e1db704104bc3899c5 Mon Sep 17 00:00:00 2001 From: Tobias Kunze Date: Wed, 15 Oct 2025 11:58:02 +0200 Subject: [PATCH 17/36] Fix common abbreviations ref #204 --- src/servala/core/crd.py | 113 ++++++++++++++++++++++++++++++++++++++-- 1 file changed, 110 insertions(+), 3 deletions(-) diff --git a/src/servala/core/crd.py b/src/servala/core/crd.py index 44c809b..5d5c34e 100644 --- a/src/servala/core/crd.py +++ b/src/servala/core/crd.py @@ -86,10 +86,117 @@ def build_object_fields(schema, name, verbose_name_prefix=None, parent_required= 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: - title.replace("_", " ") - return title.title() - return re.sub(r"(? 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( From cd886df05b7e3959cdcca7a0fa00f9d6837f887a Mon Sep 17 00:00:00 2001 From: Tobias Kunze Date: Wed, 15 Oct 2025 14:34:38 +0200 Subject: [PATCH 18/36] Add ServiceDefinition.advanced_fields ref #204 --- src/servala/core/admin.py | 27 +++++++++++++++++++ .../0014_servicedefinition_advanced_fields.py | 27 +++++++++++++++++++ src/servala/core/models/service.py | 15 ++++++++++- 3 files changed, 68 insertions(+), 1 deletion(-) create mode 100644 src/servala/core/migrations/0014_servicedefinition_advanced_fields.py diff --git a/src/servala/core/admin.py b/src/servala/core/admin.py index efe935c..1aec22a 100644 --- a/src/servala/core/admin.py +++ b/src/servala/core/admin.py @@ -318,8 +318,35 @@ class ServiceDefinitionAdmin(admin.ModelAdmin): "description": _("API definition for the Kubernetes Custom Resource"), }, ), + ( + _("Form Configuration"), + { + "fields": ("advanced_fields",), + "description": _( + "Configure which fields should be hidden behind an 'Advanced' toggle in the form" + ), + }, + ), ) + def get_form(self, request, obj=None, **kwargs): + form = super().get_form(request, obj, **kwargs) + # JSON schema for advanced_fields field + advanced_fields_schema = { + "type": "array", + "title": "Advanced Fields", + "items": { + "type": "string", + "title": "Field Name", + "description": "Field name in dot notation (e.g., spec.parameters.monitoring.enabled)", + }, + } + if "advanced_fields" in form.base_fields: + form.base_fields["advanced_fields"].widget = JSONFormWidget( + schema=advanced_fields_schema + ) + return form + def get_exclude(self, request, obj=None): # Exclude the original api_definition field as we're using our custom fields return ["api_definition"] diff --git a/src/servala/core/migrations/0014_servicedefinition_advanced_fields.py b/src/servala/core/migrations/0014_servicedefinition_advanced_fields.py new file mode 100644 index 0000000..20632b9 --- /dev/null +++ b/src/servala/core/migrations/0014_servicedefinition_advanced_fields.py @@ -0,0 +1,27 @@ +# Generated by Django 5.2.7 on 2025-10-17 03:23 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("core", "0013_controlplane_wildcard_dns"), + ] + + operations = [ + migrations.AddField( + model_name="servicedefinition", + name="advanced_fields", + field=models.JSONField( + blank=True, + default=list, + help_text=( + "Array of field names that should be hidden behind an 'Advanced' toggle. " + "Use dot notation (e.g., ['spec.parameters.monitoring.enabled', 'spec.parameters.backup.schedule'])" + ), + null=True, + verbose_name="Advanced fields", + ), + ), + ] diff --git a/src/servala/core/models/service.py b/src/servala/core/models/service.py index f67f454..42fc500 100644 --- a/src/servala/core/models/service.py +++ b/src/servala/core/models/service.py @@ -359,6 +359,16 @@ class ServiceDefinition(ServalaModelMixin, models.Model): null=True, blank=True, ) + advanced_fields = models.JSONField( + verbose_name=_("Advanced fields"), + help_text=_( + "Array of field names that should be hidden behind an 'Advanced' toggle. " + "Use dot notation (e.g., ['spec.parameters.monitoring.enabled', 'spec.parameters.backup.schedule'])" + ), + null=True, + blank=True, + default=list, + ) service = models.ForeignKey( to="Service", on_delete=models.CASCADE, @@ -499,7 +509,10 @@ class ControlPlaneCRD(ServalaModelMixin, models.Model): if not self.django_model: return - return generate_model_form_class(self.django_model) + advanced_fields = self.service_definition.advanced_fields or [] + return generate_model_form_class( + self.django_model, advanced_fields=advanced_fields + ) class ServiceOffering(ServalaModelMixin, models.Model): From 31018298852e28af7f16324d3790002dffb60ace Mon Sep 17 00:00:00 2001 From: Tobias Kunze Date: Wed, 15 Oct 2025 16:42:57 +0200 Subject: [PATCH 19/36] Allow users to toggle advanced fields ref #204 --- src/servala/core/crd.py | 16 +++- .../includes/tabbed_fieldset_form.html | 14 ++++ src/servala/static/css/servala.css | 30 +++++++ src/servala/static/js/advanced-fields.js | 83 +++++++++++++++++++ 4 files changed, 142 insertions(+), 1 deletion(-) create mode 100644 src/servala/static/js/advanced-fields.js diff --git a/src/servala/core/crd.py b/src/servala/core/crd.py index 5d5c34e..276e9c2 100644 --- a/src/servala/core/crd.py +++ b/src/servala/core/crd.py @@ -327,6 +327,19 @@ class CrdModelFormMixin: field.widget = forms.HiddenInput() field.required = False + # Mark advanced fields with a CSS class and data attribute + advanced_fields = getattr(self, "ADVANCED_FIELDS", []) + for name, field in self.fields.items(): + if name in advanced_fields: + field.widget.attrs.update( + { + "class": ( + field.widget.attrs.get("class", "") + " advanced-field" + ).strip(), + "data-advanced": "true", + } + ) + if self.instance and self.instance.pk: self.fields["name"].disabled = True self.fields["name"].help_text = _("Name cannot be changed after creation.") @@ -513,7 +526,7 @@ class CrdModelFormMixin: pass -def generate_model_form_class(model): +def generate_model_form_class(model, advanced_fields=None): meta_attrs = { "model": model, "fields": "__all__", @@ -521,6 +534,7 @@ def generate_model_form_class(model): fields = { "Meta": type("Meta", (object,), meta_attrs), "__module__": "crd_models", + "ADVANCED_FIELDS": advanced_fields or [], } class_name = f"{model.__name__}ModelForm" return ModelFormMetaclass(class_name, (CrdModelFormMixin, ModelForm), fields) diff --git a/src/servala/frontend/templates/includes/tabbed_fieldset_form.html b/src/servala/frontend/templates/includes/tabbed_fieldset_form.html index c9d947a..5857bdf 100644 --- a/src/servala/frontend/templates/includes/tabbed_fieldset_form.html +++ b/src/servala/frontend/templates/includes/tabbed_fieldset_form.html @@ -1,10 +1,23 @@ {% load i18n %} {% load get_field %} +{% load static %}
{% csrf_token %} {% include "frontend/forms/errors.html" %} + {% if form.ADVANCED_FIELDS %} +
+ +
+ {% endif %}
+ diff --git a/src/servala/static/css/servala.css b/src/servala/static/css/servala.css index eb4fd01..0ea8b28 100644 --- a/src/servala/static/css/servala.css +++ b/src/servala/static/css/servala.css @@ -302,3 +302,33 @@ html[data-bs-theme="dark"] .crd-form .nav-tabs .nav-link .mandatory-indicator { pointer-events: none; } } +.ml-auto { + margin-left: auto !important +} + +/* Advanced fields tab flash animation */ +@keyframes tab-pulse { + 0%, 100% { + background-color: transparent; + box-shadow: none; + } + 50% { + background-color: var(--brand-light); + box-shadow: 0 0 10px rgba(154, 99, 236, 0.3); + } +} + +html[data-bs-theme="dark"] @keyframes tab-pulse { + 0%, 100% { + background-color: transparent; + box-shadow: none; + } + 50% { + background-color: rgba(154, 99, 236, 0.2); + box-shadow: 0 0 10px rgba(154, 99, 236, 0.4); + } +} + +.nav-tabs .nav-link.tab-flash { + animation: tab-pulse 1s ease-in-out 2; +} diff --git a/src/servala/static/js/advanced-fields.js b/src/servala/static/js/advanced-fields.js new file mode 100644 index 0000000..989e61a --- /dev/null +++ b/src/servala/static/js/advanced-fields.js @@ -0,0 +1,83 @@ +/** + * Advanced Fields Toggle + * Handles showing/hiding advanced fields in CRD forms + */ +(function() { + 'use strict'; + + function flashTabsWithAdvancedFields() { + const advancedGroups = document.querySelectorAll('.advanced-field-group'); + const tabsToFlash = new Set(); + advancedGroups.forEach(function(group) { + const tabPane = group.closest('.tab-pane'); + if (tabPane) { + const tabId = tabPane.getAttribute('id'); + if (tabId) { + const tabButton = document.querySelector(`[data-bs-target="#${tabId}"]`); + if (tabButton && !tabButton.classList.contains('active')) { + tabsToFlash.add(tabButton); + } + } + } + }); + + tabsToFlash.forEach(function(tab) { + tab.classList.add('tab-flash'); + setTimeout(function() { + tab.classList.remove('tab-flash'); + }, 2000); + }); + } + + function initializeAdvancedFields() { + const advancedInputs = document.querySelectorAll('[data-advanced="true"]'); + + if (advancedInputs.length === 0) { + return; + } + + advancedInputs.forEach(function(input) { + const formGroup = input.closest('.form-group, .mb-3, .col-12, .col-md-6'); + if (formGroup) { + formGroup.classList.add('advanced-field-group', 'collapse'); + } + }); + + const toggleButton = document.getElementById('advanced-toggle'); + if (toggleButton) { + let isExpanded = false; + + document.querySelectorAll('.advanced-field-group').forEach(function(group) { + group.addEventListener('shown.bs.collapse', function() { + toggleButton.innerHTML = ' Hide Advanced Options'; + if (!isExpanded) { + isExpanded = true; + setTimeout(flashTabsWithAdvancedFields, 100); + } + }); + + group.addEventListener('hidden.bs.collapse', function() { + const anyVisible = Array.from(document.querySelectorAll('.advanced-field-group')).some( + g => g.classList.contains('show') + ); + if (!anyVisible) { + toggleButton.innerHTML = ' Show Advanced Options'; + isExpanded = false; + } + }); + }); + } + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', initializeAdvancedFields); + } else { + initializeAdvancedFields(); + } + + document.body.addEventListener('htmx:afterSwap', function(event) { + if (event.detail.target.id === 'service-form' || event.detail.target.closest('.crd-form')) { + setTimeout(initializeAdvancedFields, 100); + } + }); +})(); From 8ba9787d4bc68b360d6bc789d38f447b68f18189 Mon Sep 17 00:00:00 2001 From: Tobias Kunze Date: Fri, 17 Oct 2025 06:11:27 +0200 Subject: [PATCH 20/36] Fix "Add item" button adding multiple items ref #224 --- src/servala/static/js/dynamic-array.js | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/servala/static/js/dynamic-array.js b/src/servala/static/js/dynamic-array.js index c198ddf..b6fa4e5 100644 --- a/src/servala/static/js/dynamic-array.js +++ b/src/servala/static/js/dynamic-array.js @@ -7,6 +7,10 @@ const initDynamicArrayWidget = () => { const containers = document.querySelectorAll('.dynamic-array-widget') containers.forEach(container => { + if (container.dataset.initialized === 'true') { + return + } + const itemsContainer = container.querySelector('.array-items') const addButton = container.querySelector('.add-array-item') const hiddenInput = container.querySelector('input[type="hidden"]') @@ -22,6 +26,7 @@ const initDynamicArrayWidget = () => { // Ensure hidden input is synced with visible inputs on initialization updateHiddenInput(container) + container.dataset.initialized = 'true' }) } From 014e88aa24f9e1d2c40f8cf6129fd7a18d193d0e Mon Sep 17 00:00:00 2001 From: Tobias Brunner Date: Fri, 17 Oct 2025 10:35:47 +0200 Subject: [PATCH 21/36] remove the help text about billing entity being read only The issue is that when obj.has_inherited_billing_entity is True, the code adds "billing_entity" to the readonly fields. When a field is marked as readonly, Django may exclude it from form.base_fields, which causes a KeyError when trying to access it. --- src/servala/core/admin.py | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/src/servala/core/admin.py b/src/servala/core/admin.py index 1aec22a..c0beb9e 100644 --- a/src/servala/core/admin.py +++ b/src/servala/core/admin.py @@ -73,16 +73,6 @@ class OrganizationAdmin(admin.ModelAdmin): return readonly_fields - def get_form(self, request, obj=None, **kwargs): - form = super().get_form(request, obj, **kwargs) - - if obj and obj.has_inherited_billing_entity: - form.base_fields["billing_entity"].help_text = _( - "This billing entity is inherited from the organization's origin and cannot be modified." - ) - - return form - @admin.register(BillingEntity) class BillingEntityAdmin(admin.ModelAdmin): From 54998ab9d0f6265297afb17ba6330380cc9eef26 Mon Sep 17 00:00:00 2001 From: Tobias Brunner Date: Fri, 17 Oct 2025 10:40:00 +0200 Subject: [PATCH 22/36] explicitely convert is_accepted to boolean The is_accepted property returns self.accepted_by or self.accepted_at. When accepted_by is a User object (not None), it returns the User object instead of a boolean. The Django admin's boolean field renderer expects a boolean value (True, False, or None), not a User object. --- src/servala/core/models/organization.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/servala/core/models/organization.py b/src/servala/core/models/organization.py index d308bfa..1669f39 100644 --- a/src/servala/core/models/organization.py +++ b/src/servala/core/models/organization.py @@ -485,7 +485,7 @@ class OrganizationInvitation(ServalaModelMixin, models.Model): def is_accepted(self): # We check both accepted_by and accepted_at to avoid a deleted user # freeing up an invitation - return self.accepted_by or self.accepted_at + return bool(self.accepted_by or self.accepted_at) @property def can_be_accepted(self): From ce34afa10a8dd18aebaa13666bc3eff4180c76e4 Mon Sep 17 00:00:00 2001 From: Tobias Brunner Date: Fri, 17 Oct 2025 10:54:43 +0200 Subject: [PATCH 23/36] service enabling / disabling is not specific to a csp --- .../frontend/templates/frontend/organizations/services.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/servala/frontend/templates/frontend/organizations/services.html b/src/servala/frontend/templates/frontend/organizations/services.html index 4b8c95a..461d37d 100644 --- a/src/servala/frontend/templates/frontend/organizations/services.html +++ b/src/servala/frontend/templates/frontend/organizations/services.html @@ -35,7 +35,7 @@
{% translate "You may also be interested in one of these …" %}

- {% translate "These services need to be enabled on Exoscale first before they become available in the Servala portal." %} + {% translate "These services need to be enabled first before they become available in the Servala portal." %}

From 359bc587496d915a7aa0bf8fdf294ebf50b48cfc Mon Sep 17 00:00:00 2001 From: Tobias Kunze Date: Mon, 20 Oct 2025 11:56:35 +0200 Subject: [PATCH 24/36] Fix advanced fields not working with array fields ref #204 --- .../frontend/templates/frontend/forms/dynamic_array.html | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/servala/frontend/templates/frontend/forms/dynamic_array.html b/src/servala/frontend/templates/frontend/forms/dynamic_array.html index 4b7e68c..9d61825 100644 --- a/src/servala/frontend/templates/frontend/forms/dynamic_array.html +++ b/src/servala/frontend/templates/frontend/forms/dynamic_array.html @@ -1,6 +1,9 @@
+ data-name="{{ widget.name }}" + {% for name, value in widget.attrs.items %}{% if value is not False and name != "id" and name != "class" %} {{ name }}{% if value is not True %}="{{ value|stringformat:'s' }}"{% endif %} + {% endif %} + {% endfor %}>
{% for item in value_list %}
From 864c0ffc06c7c597a55d6048cea775736df14a61 Mon Sep 17 00:00:00 2001 From: Tobias Kunze Date: Mon, 20 Oct 2025 13:54:33 +0200 Subject: [PATCH 25/36] Fix advanced fields not working with categories ref #204 --- src/servala/core/crd.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/servala/core/crd.py b/src/servala/core/crd.py index 276e9c2..35d9240 100644 --- a/src/servala/core/crd.py +++ b/src/servala/core/crd.py @@ -330,7 +330,9 @@ class CrdModelFormMixin: # Mark advanced fields with a CSS class and data attribute advanced_fields = getattr(self, "ADVANCED_FIELDS", []) for name, field in self.fields.items(): - if name in advanced_fields: + if name in advanced_fields or any( + name.startswith(f"{af}.") for af in advanced_fields + ): field.widget.attrs.update( { "class": ( From 850a79185117bccf0c762b9c51f2385be5169658 Mon Sep 17 00:00:00 2001 From: Tobias Kunze Date: Mon, 20 Oct 2025 15:02:41 +0200 Subject: [PATCH 26/36] Handle whole form sections being advanced ref #204 --- src/servala/core/crd.py | 23 +++++++++++++++---- .../includes/tabbed_fieldset_form.html | 13 +++++++---- 2 files changed, 27 insertions(+), 9 deletions(-) diff --git a/src/servala/core/crd.py b/src/servala/core/crd.py index 35d9240..fe8edbb 100644 --- a/src/servala/core/crd.py +++ b/src/servala/core/crd.py @@ -328,11 +328,8 @@ class CrdModelFormMixin: field.required = False # Mark advanced fields with a CSS class and data attribute - advanced_fields = getattr(self, "ADVANCED_FIELDS", []) for name, field in self.fields.items(): - if name in advanced_fields or any( - name.startswith(f"{af}.") for af in advanced_fields - ): + if self.is_field_advanced(name): field.widget.attrs.update( { "class": ( @@ -358,6 +355,17 @@ class CrdModelFormMixin: return True return False + def is_field_advanced(self, field_name): + advanced_fields = getattr(self, "ADVANCED_FIELDS", []) + return field_name in advanced_fields or any( + field_name.startswith(f"{af}.") for af in advanced_fields + ) + + def are_all_fields_advanced(self, field_list): + if not field_list: + return False + return all(self.is_field_advanced(field_name) for field_name in field_list) + def get_fieldsets(self): fieldsets = [] @@ -373,6 +381,7 @@ class CrdModelFormMixin: "fields": general_fields, "fieldsets": [], "has_mandatory": self.has_mandatory_fields(general_fields), + "is_advanced": self.are_all_fields_advanced(general_fields), } if all( [ @@ -439,6 +448,9 @@ class CrdModelFormMixin: title = f"{fieldset['title']}: {sub_fieldset['title']}: " for field in sub_fieldset["fields"]: self.strip_title(field, title) + sub_fieldset["is_advanced"] = self.are_all_fields_advanced( + sub_fieldset["fields"] + ) nested_fieldsets_list.append(sub_fieldset) fieldset["fieldsets"] = nested_fieldsets_list @@ -455,6 +467,8 @@ class CrdModelFormMixin: all_fields.extend(sub_fieldset["fields"]) fieldset["has_mandatory"] = self.has_mandatory_fields(all_fields) + fieldset["is_advanced"] = self.are_all_fields_advanced(all_fields) + fieldsets.append(fieldset) # Add 'others' tab if there are any fields @@ -465,6 +479,7 @@ class CrdModelFormMixin: "fields": others, "fieldsets": [], "has_mandatory": self.has_mandatory_fields(others), + "is_advanced": self.are_all_fields_advanced(others), } ) diff --git a/src/servala/frontend/templates/includes/tabbed_fieldset_form.html b/src/servala/frontend/templates/includes/tabbed_fieldset_form.html index 5857bdf..74fa22a 100644 --- a/src/servala/frontend/templates/includes/tabbed_fieldset_form.html +++ b/src/servala/frontend/templates/includes/tabbed_fieldset_form.html @@ -21,7 +21,8 @@
From 45b2b93aba8dc6f743dac21354768e30193b6160 Mon Sep 17 00:00:00 2001 From: Tobias Kunze Date: Tue, 21 Oct 2025 10:03:34 +0200 Subject: [PATCH 27/36] Add Invites to auditlog ref #19 --- src/servala/core/models/organization.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/servala/core/models/organization.py b/src/servala/core/models/organization.py index 1669f39..26435f3 100644 --- a/src/servala/core/models/organization.py +++ b/src/servala/core/models/organization.py @@ -2,6 +2,7 @@ import secrets import rules import urlman +from auditlog.registry import auditlog from django.conf import settings from django.contrib.sites.shortcuts import get_current_site from django.core.mail import send_mail @@ -536,3 +537,7 @@ The Servala Team""" recipient_list=[self.email], fail_silently=False, ) + + +auditlog.register(OrganizationInvitation, serialize_data=True) +auditlog.register(OrganizationMembership, serialize_data=True) From 7c6464330ddeea5daea2039f0d09f35009baec24 Mon Sep 17 00:00:00 2001 From: Tobias Kunze Date: Tue, 21 Oct 2025 13:29:07 +0200 Subject: [PATCH 28/36] Remove star in front of error message ref #19 --- src/servala/frontend/views/organization.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/servala/frontend/views/organization.py b/src/servala/frontend/views/organization.py index 7013a6c..63c84bb 100644 --- a/src/servala/frontend/views/organization.py +++ b/src/servala/frontend/views/organization.py @@ -199,7 +199,8 @@ class OrganizationUpdateView(OrganizationViewMixin, HtmxUpdateView): ) else: for error in form.errors.values(): - messages.error(request, error.as_text()) + for error_msg in error: + messages.error(request, error_msg) return redirect(self.get_success_url()) From 892a19bbcc2a2d4f228a812f4850d6aa1447a5e8 Mon Sep 17 00:00:00 2001 From: Tobias Kunze Date: Tue, 21 Oct 2025 14:54:12 +0200 Subject: [PATCH 29/36] Explain user roles ref #19 --- .../frontend/organizations/update.html | 26 +++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/src/servala/frontend/templates/frontend/organizations/update.html b/src/servala/frontend/templates/frontend/organizations/update.html index d55dc56..2785e9a 100644 --- a/src/servala/frontend/templates/frontend/organizations/update.html +++ b/src/servala/frontend/templates/frontend/organizations/update.html @@ -222,12 +222,34 @@
-
+
+
+ {% translate "Role Permissions" %} +
+
    +
  • + {% translate "Owner" %}: {% translate "Can manage all organization settings, members, services, and can appoint administrators." %} +
  • +
  • + {% translate "Administrator" %}: {% translate "Can manage members, invite users, and manage all services and instances." %} +
  • +
  • + {% translate "Member" %}: {% translate "Can view organization details, create and manage their own service instances." %} +
  • +
+
+ {% csrf_token %} + +
{{ invitation_form }}
-
From 714cd9be5452adf635b4e602c19c83b07e63074b Mon Sep 17 00:00:00 2001 From: Tobias Kunze Date: Tue, 21 Oct 2025 15:10:57 +0200 Subject: [PATCH 30/36] Implement organization delete, fix style, use rules ref #19 --- src/servala/core/models/organization.py | 1 + src/servala/core/rules.py | 10 +- .../frontend/organizations/update.html | 102 ++++++++++-------- src/servala/frontend/urls.py | 5 + src/servala/frontend/views/__init__.py | 2 + src/servala/frontend/views/organization.py | 93 ++++++++++++++-- 6 files changed, 159 insertions(+), 54 deletions(-) diff --git a/src/servala/core/models/organization.py b/src/servala/core/models/organization.py index 26435f3..bbcc16f 100644 --- a/src/servala/core/models/organization.py +++ b/src/servala/core/models/organization.py @@ -468,6 +468,7 @@ class OrganizationInvitation(ServalaModelMixin, models.Model): class urls(urlman.Urls): accept = "/invitations/{self.secret}/accept/" + delete = "{self.organization.urls.details}invitations/{self.pk}/delete/" class Meta: verbose_name = _("Organization invitation") diff --git a/src/servala/core/rules.py b/src/servala/core/rules.py index cf4dc1c..e1a0992 100644 --- a/src/servala/core/rules.py +++ b/src/servala/core/rules.py @@ -14,20 +14,26 @@ def has_organization_role(user, org, roles): @rules.predicate def is_organization_owner(user, obj): + from servala.core.models.organization import OrganizationRole + if hasattr(obj, "organization"): org = obj.organization else: org = obj - return has_organization_role(user, org, ["owner"]) + return has_organization_role(user, org, [OrganizationRole.OWNER]) @rules.predicate def is_organization_admin(user, obj): + from servala.core.models.organization import OrganizationRole + if hasattr(obj, "organization"): org = obj.organization else: org = obj - return has_organization_role(user, org, ["owner", "admin"]) + return has_organization_role( + user, org, [OrganizationRole.OWNER, OrganizationRole.ADMIN] + ) @rules.predicate diff --git a/src/servala/frontend/templates/frontend/organizations/update.html b/src/servala/frontend/templates/frontend/organizations/update.html index 2785e9a..73c2c69 100644 --- a/src/servala/frontend/templates/frontend/organizations/update.html +++ b/src/servala/frontend/templates/frontend/organizations/update.html @@ -67,43 +67,66 @@
+{% endpartialdef members-list %} +{% partialdef pending-invitations-card %} {% if pending_invitations %} -
- {% translate "Pending Invitations" %} -
-
- - - - - - - - - - - {% for invitation in pending_invitations %} - - - - - - - {% endfor %} - -
{% translate "Email" %}{% translate "Role" %}{% translate "Sent" %}{% translate "Link" %}
{{ invitation.email }} - - {{ invitation.get_role_display }} - - {{ invitation.created_at|date:"Y-m-d H:i" }} - -
+
+
+

+ {% translate "Pending Invitations" %} +

+
+
+
+
+ + + + + + + + + + + {% for invitation in pending_invitations %} + + + + + + + {% endfor %} + +
{% translate "Email" %}{% translate "Role" %}{% translate "Sent" %}{% translate "Actions" %}
{{ invitation.email }} + + {{ invitation.get_role_display }} + + {{ invitation.created_at|date:"Y-m-d H:i" }} + + + {% csrf_token %} + + + +
+
+
+
{% endif %} -{% endpartialdef members-list %} +{% endpartialdef pending-invitations-card %} {% block content %}
@@ -214,6 +237,7 @@
{% partial members-list %}
+
{% partial pending-invitations-card %}

@@ -238,18 +262,12 @@

-
+ {% csrf_token %} - -
{{ invitation_form }}
-
diff --git a/src/servala/frontend/urls.py b/src/servala/frontend/urls.py index 3aa9b08..73d0759 100644 --- a/src/servala/frontend/urls.py +++ b/src/servala/frontend/urls.py @@ -30,6 +30,11 @@ urlpatterns = [ views.OrganizationUpdateView.as_view(), name="organization.details", ), + path( + "details/invitations//delete/", + views.InvitationDeleteView.as_view(), + name="invitation.delete", + ), path( "services/", views.ServiceListView.as_view(), diff --git a/src/servala/frontend/views/__init__.py b/src/servala/frontend/views/__init__.py index 6167221..33b0560 100644 --- a/src/servala/frontend/views/__init__.py +++ b/src/servala/frontend/views/__init__.py @@ -9,6 +9,7 @@ from .generic import ( ) from .organization import ( InvitationAcceptView, + InvitationDeleteView, OrganizationCreateView, OrganizationDashboardView, OrganizationUpdateView, @@ -27,6 +28,7 @@ from .support import SupportView __all__ = [ "IndexView", "InvitationAcceptView", + "InvitationDeleteView", "LogoutView", "OrganizationCreateView", "OrganizationDashboardView", diff --git a/src/servala/frontend/views/organization.py b/src/servala/frontend/views/organization.py index 63c84bb..c4c1336 100644 --- a/src/servala/frontend/views/organization.py +++ b/src/servala/frontend/views/organization.py @@ -5,7 +5,7 @@ from django.utils import timezone from django.utils.decorators import method_decorator from django.utils.functional import cached_property from django.utils.translation import gettext_lazy as _ -from django.views.generic import CreateView, DetailView, TemplateView +from django.views.generic import CreateView, DeleteView, DetailView, TemplateView from django_scopes import scopes_disabled from rules.contrib.views import AutoPermissionRequiredMixin @@ -14,7 +14,6 @@ from servala.core.models import ( Organization, OrganizationInvitation, OrganizationMembership, - OrganizationRole, ServiceInstance, ) from servala.frontend.forms.organization import ( @@ -22,7 +21,11 @@ from servala.frontend.forms.organization import ( OrganizationForm, OrganizationInvitationForm, ) -from servala.frontend.views.mixins import HtmxUpdateView, OrganizationViewMixin +from servala.frontend.views.mixins import ( + HtmxUpdateView, + HtmxViewMixin, + OrganizationViewMixin, +) class OrganizationCreateView(AutoPermissionRequiredMixin, CreateView): @@ -108,10 +111,8 @@ class OrganizationDashboardView( return context -class OrganizationUpdateView(OrganizationViewMixin, HtmxUpdateView): +class OrganizationMembershipMixin: template_name = "frontend/organizations/update.html" - form_class = OrganizationForm - fragments = ("org-name", "org-name-edit", "members-list") @cached_property def user_role(self): @@ -126,10 +127,9 @@ class OrganizationUpdateView(OrganizationViewMixin, HtmxUpdateView): @cached_property def can_manage_members(self): - return self.user_role in [ - OrganizationRole.ADMIN, - OrganizationRole.OWNER, - ] + return self.request.user.has_perm( + "core.change_organization", self.request.organization + ) def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) @@ -159,6 +159,18 @@ class OrganizationUpdateView(OrganizationViewMixin, HtmxUpdateView): return context + +class OrganizationUpdateView( + OrganizationViewMixin, OrganizationMembershipMixin, HtmxUpdateView +): + form_class = OrganizationForm + fragments = ( + "org-name", + "org-name-edit", + "members-list", + "pending-invitations-card", + ) + def post(self, request, *args, **kwargs): if "invite_email" in request.POST: return self.handle_invitation(request) @@ -202,6 +214,9 @@ class OrganizationUpdateView(OrganizationViewMixin, HtmxUpdateView): for error_msg in error: messages.error(request, error_msg) + if self.is_htmx and self._get_fragment(): + return self.get(request, *self.args, **self.kwargs) + return redirect(self.get_success_url()) def get_success_url(self): @@ -260,3 +275,61 @@ class InvitationAcceptView(TemplateView): request.session.pop("invitation_next", None) return redirect(invitation.organization.urls.base) + + +class InvitationDeleteView(HtmxViewMixin, OrganizationMembershipMixin, DeleteView): + model = OrganizationInvitation + http_method_names = ["get", "post"] + fragments = ("pending-invitations-card",) + + def get_queryset(self): + return OrganizationInvitation.objects.filter(accepted_by__isnull=True) + + def get_success_url(self): + return self.object.organization.urls.details + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + organization = self.request.organization + context["pending_invitations"] = OrganizationInvitation.objects.filter( + organization=organization, accepted_by__isnull=True + ).order_by("-created_at") + return context + + def _check_permission(self): + return self.request.user.has_perm( + "core.change_organization", self.request.organization + ) + + def get_object(self): + if self.request.method == "POST" and self.is_htmx: + try: + return super().get_object() + except Exception: + return + return super().get_object() + + def post(self, request, *args, **kwargs): + self.object = self.get_object() + organization = self.object.organization + + if not self._check_permission(): + if not self.is_htmx: + messages.error( + request, + _("You do not have permission to delete this invitation."), + ) + return redirect(organization.urls.details) + + email = self.object.email + self.object.delete() + if not self.is_htmx: + messages.success( + request, + _("Invitation for {email} has been deleted.").format(email=email), + ) + + if self.is_htmx and self._get_fragment(): + return self.get(request, *args, **kwargs) + + return redirect(self.get_success_url()) From b4d239a1a6aba0fe8ca8307d490047ac50b1f990 Mon Sep 17 00:00:00 2001 From: Tobias Kunze Date: Wed, 22 Oct 2025 11:12:18 +0200 Subject: [PATCH 31/36] Limit cloud providers per organization origin ref #38 --- src/servala/core/admin.py | 2 ++ ...anization_limit_cloudproviders_and_more.py | 10 ------- ...organizationorigin_limit_cloudproviders.py | 24 +++++++++++++++ src/servala/core/models/organization.py | 29 ++++++++++++------- src/servala/frontend/forms/service.py | 9 ++++++ src/servala/frontend/views/service.py | 4 ++- 6 files changed, 57 insertions(+), 21 deletions(-) create mode 100644 src/servala/core/migrations/0015_organizationorigin_limit_cloudproviders.py diff --git a/src/servala/core/admin.py b/src/servala/core/admin.py index c0beb9e..b54ba65 100644 --- a/src/servala/core/admin.py +++ b/src/servala/core/admin.py @@ -63,6 +63,7 @@ class OrganizationAdmin(admin.ModelAdmin): search_fields = ("name", "namespace") autocomplete_fields = ("billing_entity", "origin") inlines = (OrganizationMembershipInline,) + filter_horizontal = ("limit_osb_services",) def get_readonly_fields(self, request, obj=None): readonly_fields = list(super().get_readonly_fields(request, obj) or []) @@ -85,6 +86,7 @@ class OrganizationOriginAdmin(admin.ModelAdmin): list_display = ("name", "billing_entity") search_fields = ("name",) autocomplete_fields = ("billing_entity",) + filter_horizontal = ("limit_cloudproviders",) @admin.register(OrganizationMembership) diff --git a/src/servala/core/migrations/0009_organization_limit_cloudproviders_and_more.py b/src/servala/core/migrations/0009_organization_limit_cloudproviders_and_more.py index 3ec1032..1558d07 100644 --- a/src/servala/core/migrations/0009_organization_limit_cloudproviders_and_more.py +++ b/src/servala/core/migrations/0009_organization_limit_cloudproviders_and_more.py @@ -10,16 +10,6 @@ class Migration(migrations.Migration): ] operations = [ - migrations.AddField( - model_name="organization", - name="limit_cloudproviders", - field=models.ManyToManyField( - blank=True, - related_name="+", - to="core.cloudprovider", - verbose_name="Limit to these Cloud providers", - ), - ), migrations.AddField( model_name="organization", name="limit_osb_services", diff --git a/src/servala/core/migrations/0015_organizationorigin_limit_cloudproviders.py b/src/servala/core/migrations/0015_organizationorigin_limit_cloudproviders.py new file mode 100644 index 0000000..f119b99 --- /dev/null +++ b/src/servala/core/migrations/0015_organizationorigin_limit_cloudproviders.py @@ -0,0 +1,24 @@ +# Generated by Django 5.2.7 on 2025-10-21 16:04 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("core", "0014_servicedefinition_advanced_fields"), + ] + + operations = [ + migrations.AddField( + model_name="organizationorigin", + name="limit_cloudproviders", + field=models.ManyToManyField( + blank=True, + help_text="If set, all organizations with this origin will be limited to these cloud providers.", + related_name="+", + to="core.cloudprovider", + verbose_name="Limit to these Cloud providers", + ), + ), + ] diff --git a/src/servala/core/models/organization.py b/src/servala/core/models/organization.py index bbcc16f..1137b1c 100644 --- a/src/servala/core/models/organization.py +++ b/src/servala/core/models/organization.py @@ -52,12 +52,6 @@ class Organization(ServalaModelMixin, models.Model): related_name="organizations", verbose_name=_("Members"), ) - limit_cloudproviders = models.ManyToManyField( - to="CloudProvider", - related_name="+", - verbose_name=_("Limit to these Cloud providers"), - blank=True, - ) limit_osb_services = models.ManyToManyField( to="Service", related_name="+", @@ -99,6 +93,14 @@ class Organization(ServalaModelMixin, models.Model): def has_inherited_billing_entity(self): return self.origin and self.billing_entity == self.origin.billing_entity + @property + def limit_cloudproviders(self): + if self.origin: + return self.origin.limit_cloudproviders.all() + from servala.core.models import CloudProvider + + return CloudProvider.objects.none() + def set_owner(self, user): with scopes_disabled(): OrganizationMembership.objects.filter(user=user, organization=self).delete() @@ -161,9 +163,8 @@ class Organization(ServalaModelMixin, models.Model): if self.limit_osb_services.exists(): queryset = self.limit_osb_services.all() if self.limit_cloudproviders.exists(): - allowed_providers = self.limit_cloudproviders.all() queryset = queryset.filter( - offerings__provider__in=allowed_providers + offerings__provider__in=self.limit_cloudproviders ).distinct() return queryset.prefetch_related( "offerings", "offerings__provider" @@ -177,9 +178,8 @@ class Organization(ServalaModelMixin, models.Model): queryset = Service.objects.select_related("category") if self.limit_cloudproviders.exists(): - allowed_providers = self.limit_cloudproviders.all() queryset = queryset.filter( - offerings__provider__in=allowed_providers + offerings__provider__in=self.limit_cloudproviders ).distinct() queryset = queryset.exclude(id__in=self.limit_osb_services.all()) return queryset.prefetch_related("offerings", "offerings__provider") @@ -376,6 +376,15 @@ class OrganizationOrigin(ServalaModelMixin, models.Model): ), null=True, ) + limit_cloudproviders = models.ManyToManyField( + to="CloudProvider", + related_name="+", + verbose_name=_("Limit to these Cloud providers"), + blank=True, + help_text=_( + "If set, all organizations with this origin will be limited to these cloud providers." + ), + ) class Meta: verbose_name = _("Organization origin") diff --git a/src/servala/frontend/forms/service.py b/src/servala/frontend/forms/service.py index 5dd78a7..23325f3 100644 --- a/src/servala/frontend/forms/service.py +++ b/src/servala/frontend/forms/service.py @@ -21,6 +21,15 @@ class ServiceFilterForm(forms.Form): ) q = forms.CharField(label=_("Search"), required=False) + def __init__(self, *args, organization=None, **kwargs): + super().__init__(*args, **kwargs) + if organization and organization.limit_cloudproviders.exists(): + allowed_providers = organization.limit_cloudproviders + if allowed_providers.count() <= 1: + self.fields.pop("cloud_provider", None) + else: + self.fields["cloud_provider"].queryset = allowed_providers + def filter_queryset(self, queryset): if category := self.cleaned_data.get("category"): queryset = queryset.filter(category=category) diff --git a/src/servala/frontend/views/service.py b/src/servala/frontend/views/service.py index ba4f0a4..689f381 100644 --- a/src/servala/frontend/views/service.py +++ b/src/servala/frontend/views/service.py @@ -44,7 +44,9 @@ class ServiceListView(OrganizationViewMixin, ListView): @cached_property def filter_form(self): - return ServiceFilterForm(data=self.request.GET or None) + return ServiceFilterForm( + data=self.request.GET or None, organization=self.request.organization + ) def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) From d8ceaf4b1b0078db930464ffa64b97e3c1692cb6 Mon Sep 17 00:00:00 2001 From: Tobias Kunze Date: Wed, 22 Oct 2025 11:12:49 +0200 Subject: [PATCH 32/36] Fix display of empty service list --- .../templates/frontend/organizations/services.html | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/servala/frontend/templates/frontend/organizations/services.html b/src/servala/frontend/templates/frontend/organizations/services.html index 461d37d..3250c52 100644 --- a/src/servala/frontend/templates/frontend/organizations/services.html +++ b/src/servala/frontend/templates/frontend/organizations/services.html @@ -20,10 +20,12 @@ {% for service in services %}
{% include "includes/service_card.html" %}
{% empty %} -
-
-
-

{% translate "No services found." %}

+
+
+
+
+

{% translate "No services found." %}

+
From 3b49b173603752bd56c6c16913c5985cd286c743 Mon Sep 17 00:00:00 2001 From: Tobias Kunze Date: Wed, 22 Oct 2025 11:37:07 +0200 Subject: [PATCH 33/36] Implement per-origin default odoo sales orders ref #227 --- src/servala/core/admin.py | 2 +- ...zationorigin_default_odoo_sale_order_id.py | 23 +++++++++++++++++++ src/servala/core/models/organization.py | 23 ++++++++++++++++++- 3 files changed, 46 insertions(+), 2 deletions(-) create mode 100644 src/servala/core/migrations/0016_organizationorigin_default_odoo_sale_order_id.py diff --git a/src/servala/core/admin.py b/src/servala/core/admin.py index b54ba65..87da376 100644 --- a/src/servala/core/admin.py +++ b/src/servala/core/admin.py @@ -83,7 +83,7 @@ class BillingEntityAdmin(admin.ModelAdmin): @admin.register(OrganizationOrigin) class OrganizationOriginAdmin(admin.ModelAdmin): - list_display = ("name", "billing_entity") + list_display = ("name", "billing_entity", "default_odoo_sale_order_id") search_fields = ("name",) autocomplete_fields = ("billing_entity",) filter_horizontal = ("limit_cloudproviders",) diff --git a/src/servala/core/migrations/0016_organizationorigin_default_odoo_sale_order_id.py b/src/servala/core/migrations/0016_organizationorigin_default_odoo_sale_order_id.py new file mode 100644 index 0000000..1432324 --- /dev/null +++ b/src/servala/core/migrations/0016_organizationorigin_default_odoo_sale_order_id.py @@ -0,0 +1,23 @@ +# Generated by Django 5.2.7 on 2025-10-22 09:32 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("core", "0015_organizationorigin_limit_cloudproviders"), + ] + + operations = [ + migrations.AddField( + model_name="organizationorigin", + name="default_odoo_sale_order_id", + field=models.IntegerField( + blank=True, + help_text="If set, this sale order will be used for new organizations with this origin.", + null=True, + verbose_name="Default Odoo Sale Order ID", + ), + ), + ] diff --git a/src/servala/core/models/organization.py b/src/servala/core/models/organization.py index 1137b1c..2bc76ff 100644 --- a/src/servala/core/models/organization.py +++ b/src/servala/core/models/organization.py @@ -129,7 +129,20 @@ class Organization(ServalaModelMixin, models.Model): if owner: instance.set_owner(owner) - if ( + if instance.origin and instance.origin.default_odoo_sale_order_id: + sale_order_id = instance.origin.default_odoo_sale_order_id + sale_order_data = CLIENT.search_read( + model="sale.order", + domain=[["id", "=", sale_order_id]], + fields=["name"], + limit=1, + ) + + instance.odoo_sale_order_id = sale_order_id + if sale_order_data: + instance.odoo_sale_order_name = sale_order_data[0]["name"] + instance.save(update_fields=["odoo_sale_order_id", "odoo_sale_order_name"]) + elif ( instance.billing_entity.odoo_company_id and instance.billing_entity.odoo_invoice_id ): @@ -385,6 +398,14 @@ class OrganizationOrigin(ServalaModelMixin, models.Model): "If set, all organizations with this origin will be limited to these cloud providers." ), ) + default_odoo_sale_order_id = models.IntegerField( + null=True, + blank=True, + verbose_name=_("Default Odoo Sale Order ID"), + help_text=_( + "If set, this sale order will be used for new organizations with this origin." + ), + ) class Meta: verbose_name = _("Organization origin") From 090827bbbf2ed21c611ae26cc9c20be17e27fb6e Mon Sep 17 00:00:00 2001 From: Tobias Kunze Date: Wed, 22 Oct 2025 11:39:19 +0200 Subject: [PATCH 34/36] Squash new migrations --- ...009_controlplane_wildcard_dns_and_more.py} | 82 ++++++++++++++++++- ...anization_limit_cloudproviders_and_more.py | 23 ------ .../0010_organizationorigin_billing_entity.py | 26 ------ .../0012_serviceoffering_external_links.py | 23 ------ .../0013_controlplane_wildcard_dns.py | 24 ------ .../0014_servicedefinition_advanced_fields.py | 27 ------ ...organizationorigin_limit_cloudproviders.py | 24 ------ ...zationorigin_default_odoo_sale_order_id.py | 23 ------ 8 files changed, 80 insertions(+), 172 deletions(-) rename src/servala/core/migrations/{0011_organizationinvitation.py => 0009_controlplane_wildcard_dns_and_more.py} (53%) delete mode 100644 src/servala/core/migrations/0009_organization_limit_cloudproviders_and_more.py delete mode 100644 src/servala/core/migrations/0010_organizationorigin_billing_entity.py delete mode 100644 src/servala/core/migrations/0012_serviceoffering_external_links.py delete mode 100644 src/servala/core/migrations/0013_controlplane_wildcard_dns.py delete mode 100644 src/servala/core/migrations/0014_servicedefinition_advanced_fields.py delete mode 100644 src/servala/core/migrations/0015_organizationorigin_limit_cloudproviders.py delete mode 100644 src/servala/core/migrations/0016_organizationorigin_default_odoo_sale_order_id.py diff --git a/src/servala/core/migrations/0011_organizationinvitation.py b/src/servala/core/migrations/0009_controlplane_wildcard_dns_and_more.py similarity index 53% rename from src/servala/core/migrations/0011_organizationinvitation.py rename to src/servala/core/migrations/0009_controlplane_wildcard_dns_and_more.py index 25fb4b1..811c843 100644 --- a/src/servala/core/migrations/0011_organizationinvitation.py +++ b/src/servala/core/migrations/0009_controlplane_wildcard_dns_and_more.py @@ -1,4 +1,4 @@ -# Generated by Django 5.2.7 on 2025-10-17 00:58 +# Generated by Django 5.2.7 on 2025-10-22 09:38 import django.db.models.deletion import rules.contrib.models @@ -9,10 +9,88 @@ from django.db import migrations, models class Migration(migrations.Migration): dependencies = [ - ("core", "0010_organizationorigin_billing_entity"), + ("core", "0008_organization_osb_guid_service_osb_service_id_and_more"), ] operations = [ + migrations.AddField( + model_name="controlplane", + name="wildcard_dns", + field=models.CharField( + blank=True, + help_text="Wildcard DNS domain for auto-generating FQDNs (e.g., apps.exoscale-ch-gva-2-prod2.services.servala.com)", + max_length=255, + null=True, + verbose_name="Wildcard DNS", + ), + ), + migrations.AddField( + model_name="organization", + name="limit_osb_services", + field=models.ManyToManyField( + blank=True, + related_name="+", + to="core.service", + verbose_name="Services activated from OSB", + ), + ), + migrations.AddField( + model_name="organizationorigin", + name="billing_entity", + field=models.ForeignKey( + help_text="If set, this billing entity will be used on new organizations with this origin.", + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="origins", + to="core.billingentity", + verbose_name="Billing entity", + ), + ), + migrations.AddField( + model_name="organizationorigin", + name="default_odoo_sale_order_id", + field=models.IntegerField( + blank=True, + help_text="If set, this sale order will be used for new organizations with this origin.", + null=True, + verbose_name="Default Odoo Sale Order ID", + ), + ), + migrations.AddField( + model_name="organizationorigin", + name="limit_cloudproviders", + field=models.ManyToManyField( + blank=True, + help_text="If set, all organizations with this origin will be limited to these cloud providers.", + related_name="+", + to="core.cloudprovider", + verbose_name="Limit to these Cloud providers", + ), + ), + migrations.AddField( + model_name="servicedefinition", + name="advanced_fields", + field=models.JSONField( + blank=True, + default=list, + help_text=( + "Array of field names that should be hidden behind an 'Advanced' toggle." + "Use dot notation (e.g., ['spec.parameters.monitoring.enabled', 'spec.parameters.backup.schedule'])" + ), + null=True, + verbose_name="Advanced fields", + ), + ), + migrations.AddField( + model_name="serviceoffering", + name="external_links", + field=models.JSONField( + blank=True, + help_text='JSON array of link objects: {"url": "…", "title": "…"}. ', + null=True, + verbose_name="External links", + ), + ), migrations.CreateModel( name="OrganizationInvitation", fields=[ diff --git a/src/servala/core/migrations/0009_organization_limit_cloudproviders_and_more.py b/src/servala/core/migrations/0009_organization_limit_cloudproviders_and_more.py deleted file mode 100644 index 1558d07..0000000 --- a/src/servala/core/migrations/0009_organization_limit_cloudproviders_and_more.py +++ /dev/null @@ -1,23 +0,0 @@ -# Generated by Django 5.2.7 on 2025-10-16 22:52 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("core", "0008_organization_osb_guid_service_osb_service_id_and_more"), - ] - - operations = [ - migrations.AddField( - model_name="organization", - name="limit_osb_services", - field=models.ManyToManyField( - blank=True, - related_name="+", - to="core.service", - verbose_name="Services activated from OSB", - ), - ), - ] diff --git a/src/servala/core/migrations/0010_organizationorigin_billing_entity.py b/src/servala/core/migrations/0010_organizationorigin_billing_entity.py deleted file mode 100644 index d61a75f..0000000 --- a/src/servala/core/migrations/0010_organizationorigin_billing_entity.py +++ /dev/null @@ -1,26 +0,0 @@ -# Generated by Django 5.2.7 on 2025-10-17 00:22 - -import django.db.models.deletion -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("core", "0009_organization_limit_cloudproviders_and_more"), - ] - - operations = [ - migrations.AddField( - model_name="organizationorigin", - name="billing_entity", - field=models.ForeignKey( - help_text="If set, this billing entity will be used on new organizations with this origin.", - null=True, - on_delete=django.db.models.deletion.PROTECT, - related_name="origins", - to="core.billingentity", - verbose_name="Billing entity", - ), - ), - ] diff --git a/src/servala/core/migrations/0012_serviceoffering_external_links.py b/src/servala/core/migrations/0012_serviceoffering_external_links.py deleted file mode 100644 index d8c2ac7..0000000 --- a/src/servala/core/migrations/0012_serviceoffering_external_links.py +++ /dev/null @@ -1,23 +0,0 @@ -# Generated by Django 5.2.7 on 2025-10-17 02:23 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("core", "0011_organizationinvitation"), - ] - - operations = [ - migrations.AddField( - model_name="serviceoffering", - name="external_links", - field=models.JSONField( - blank=True, - help_text='JSON array of link objects: {"url": "…", "title": "…"}. ', - null=True, - verbose_name="External links", - ), - ), - ] diff --git a/src/servala/core/migrations/0013_controlplane_wildcard_dns.py b/src/servala/core/migrations/0013_controlplane_wildcard_dns.py deleted file mode 100644 index 26dbaaf..0000000 --- a/src/servala/core/migrations/0013_controlplane_wildcard_dns.py +++ /dev/null @@ -1,24 +0,0 @@ -# Generated by Django 5.2.7 on 2025-10-17 02:51 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("core", "0012_serviceoffering_external_links"), - ] - - operations = [ - migrations.AddField( - model_name="controlplane", - name="wildcard_dns", - field=models.CharField( - blank=True, - help_text="Wildcard DNS domain for auto-generating FQDNs (e.g., apps.exoscale-ch-gva-2-prod2.services.servala.com)", - max_length=255, - null=True, - verbose_name="Wildcard DNS", - ), - ), - ] diff --git a/src/servala/core/migrations/0014_servicedefinition_advanced_fields.py b/src/servala/core/migrations/0014_servicedefinition_advanced_fields.py deleted file mode 100644 index 20632b9..0000000 --- a/src/servala/core/migrations/0014_servicedefinition_advanced_fields.py +++ /dev/null @@ -1,27 +0,0 @@ -# Generated by Django 5.2.7 on 2025-10-17 03:23 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("core", "0013_controlplane_wildcard_dns"), - ] - - operations = [ - migrations.AddField( - model_name="servicedefinition", - name="advanced_fields", - field=models.JSONField( - blank=True, - default=list, - help_text=( - "Array of field names that should be hidden behind an 'Advanced' toggle. " - "Use dot notation (e.g., ['spec.parameters.monitoring.enabled', 'spec.parameters.backup.schedule'])" - ), - null=True, - verbose_name="Advanced fields", - ), - ), - ] diff --git a/src/servala/core/migrations/0015_organizationorigin_limit_cloudproviders.py b/src/servala/core/migrations/0015_organizationorigin_limit_cloudproviders.py deleted file mode 100644 index f119b99..0000000 --- a/src/servala/core/migrations/0015_organizationorigin_limit_cloudproviders.py +++ /dev/null @@ -1,24 +0,0 @@ -# Generated by Django 5.2.7 on 2025-10-21 16:04 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("core", "0014_servicedefinition_advanced_fields"), - ] - - operations = [ - migrations.AddField( - model_name="organizationorigin", - name="limit_cloudproviders", - field=models.ManyToManyField( - blank=True, - help_text="If set, all organizations with this origin will be limited to these cloud providers.", - related_name="+", - to="core.cloudprovider", - verbose_name="Limit to these Cloud providers", - ), - ), - ] diff --git a/src/servala/core/migrations/0016_organizationorigin_default_odoo_sale_order_id.py b/src/servala/core/migrations/0016_organizationorigin_default_odoo_sale_order_id.py deleted file mode 100644 index 1432324..0000000 --- a/src/servala/core/migrations/0016_organizationorigin_default_odoo_sale_order_id.py +++ /dev/null @@ -1,23 +0,0 @@ -# Generated by Django 5.2.7 on 2025-10-22 09:32 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("core", "0015_organizationorigin_limit_cloudproviders"), - ] - - operations = [ - migrations.AddField( - model_name="organizationorigin", - name="default_odoo_sale_order_id", - field=models.IntegerField( - blank=True, - help_text="If set, this sale order will be used for new organizations with this origin.", - null=True, - verbose_name="Default Odoo Sale Order ID", - ), - ), - ] From cb3464d9b5d794c81f13d5f7820fc06ee3985779 Mon Sep 17 00:00:00 2001 From: Tobias Kunze Date: Wed, 22 Oct 2025 15:37:25 +0200 Subject: [PATCH 35/36] Remove invite uniqueness constraint --- ...010_remove_invitation_unique_constraint.py | 31 +++++++++++++++++++ src/servala/core/models/organization.py | 1 - 2 files changed, 31 insertions(+), 1 deletion(-) create mode 100644 src/servala/core/migrations/0010_remove_invitation_unique_constraint.py diff --git a/src/servala/core/migrations/0010_remove_invitation_unique_constraint.py b/src/servala/core/migrations/0010_remove_invitation_unique_constraint.py new file mode 100644 index 0000000..78c2c45 --- /dev/null +++ b/src/servala/core/migrations/0010_remove_invitation_unique_constraint.py @@ -0,0 +1,31 @@ +# Generated by Django 5.2.7 on 2025-10-22 13:19 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("core", "0009_controlplane_wildcard_dns_and_more"), + ] + + operations = [ + migrations.AlterUniqueTogether( + name="organizationinvitation", + unique_together=set(), + ), + migrations.AlterField( + model_name="servicedefinition", + name="advanced_fields", + field=models.JSONField( + blank=True, + default=list, + help_text=( + "Array of field names that should be hidden behind an 'Advanced' toggle. " + "Use dot notation (e.g., ['spec.parameters.monitoring.enabled', 'spec.parameters.backup.schedule'])" + ), + null=True, + verbose_name="Advanced fields", + ), + ), + ] diff --git a/src/servala/core/models/organization.py b/src/servala/core/models/organization.py index 2bc76ff..553fa12 100644 --- a/src/servala/core/models/organization.py +++ b/src/servala/core/models/organization.py @@ -503,7 +503,6 @@ class OrganizationInvitation(ServalaModelMixin, models.Model): class Meta: verbose_name = _("Organization invitation") verbose_name_plural = _("Organization invitations") - unique_together = [["organization", "email"]] def __str__(self): return f"Invitation for {self.email} to {self.organization}" From 534b2e8d72115e41cf9f485527f72af6a4ca4477 Mon Sep 17 00:00:00 2001 From: Tobias Kunze Date: Wed, 22 Oct 2025 15:40:34 +0200 Subject: [PATCH 36/36] Make sure origin billing entity is not mandatory ref #38 --- ...alter_organizationorigin_billing_entity.py | 27 +++++++++++++++++++ src/servala/core/models/organization.py | 2 +- 2 files changed, 28 insertions(+), 1 deletion(-) create mode 100644 src/servala/core/migrations/0011_alter_organizationorigin_billing_entity.py diff --git a/src/servala/core/migrations/0011_alter_organizationorigin_billing_entity.py b/src/servala/core/migrations/0011_alter_organizationorigin_billing_entity.py new file mode 100644 index 0000000..b122d68 --- /dev/null +++ b/src/servala/core/migrations/0011_alter_organizationorigin_billing_entity.py @@ -0,0 +1,27 @@ +# Generated by Django 5.2.7 on 2025-10-22 13:40 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("core", "0010_remove_invitation_unique_constraint"), + ] + + operations = [ + migrations.AlterField( + model_name="organizationorigin", + name="billing_entity", + field=models.ForeignKey( + blank=True, + help_text="If set, this billing entity will be used on new organizations with this origin.", + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="origins", + to="core.billingentity", + verbose_name="Billing entity", + ), + ), + ] diff --git a/src/servala/core/models/organization.py b/src/servala/core/models/organization.py index 553fa12..78605f6 100644 --- a/src/servala/core/models/organization.py +++ b/src/servala/core/models/organization.py @@ -387,7 +387,7 @@ class OrganizationOrigin(ServalaModelMixin, models.Model): help_text=_( "If set, this billing entity will be used on new organizations with this origin." ), - null=True, + null=True, blank=True, ) limit_cloudproviders = models.ManyToManyField( to="CloudProvider",