diff --git a/src/servala/core/admin.py b/src/servala/core/admin.py index c0beb9e..87da376 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 []) @@ -82,9 +83,10 @@ 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",) @admin.register(OrganizationMembership) 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 3ec1032..0000000 --- a/src/servala/core/migrations/0009_organization_limit_cloudproviders_and_more.py +++ /dev/null @@ -1,33 +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_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/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/models/organization.py b/src/servala/core/models/organization.py index bbcc16f..2bc76ff 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() @@ -127,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 ): @@ -161,9 +176,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 +191,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 +389,23 @@ 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." + ), + ) + 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") 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/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." %}

+
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)