From b4d239a1a6aba0fe8ca8307d490047ac50b1f990 Mon Sep 17 00:00:00 2001 From: Tobias Kunze Date: Wed, 22 Oct 2025 11:12:18 +0200 Subject: [PATCH 1/4] 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 2/4] 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 3/4] 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 4/4] 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", - ), - ), - ]