Compare commits

...

4 commits

Author SHA1 Message Date
090827bbbf Squash new migrations
All checks were successful
Tests / test (push) Successful in 28s
2025-10-22 11:39:47 +02:00
3b49b17360 Implement per-origin default odoo sales orders
ref #227
2025-10-22 11:39:35 +02:00
d8ceaf4b1b Fix display of empty service list 2025-10-22 11:13:24 +02:00
b4d239a1a6 Limit cloud providers per organization origin
ref #38
2025-10-22 11:13:20 +02:00
11 changed files with 142 additions and 152 deletions

View file

@ -63,6 +63,7 @@ class OrganizationAdmin(admin.ModelAdmin):
search_fields = ("name", "namespace") search_fields = ("name", "namespace")
autocomplete_fields = ("billing_entity", "origin") autocomplete_fields = ("billing_entity", "origin")
inlines = (OrganizationMembershipInline,) inlines = (OrganizationMembershipInline,)
filter_horizontal = ("limit_osb_services",)
def get_readonly_fields(self, request, obj=None): def get_readonly_fields(self, request, obj=None):
readonly_fields = list(super().get_readonly_fields(request, obj) or []) readonly_fields = list(super().get_readonly_fields(request, obj) or [])
@ -82,9 +83,10 @@ class BillingEntityAdmin(admin.ModelAdmin):
@admin.register(OrganizationOrigin) @admin.register(OrganizationOrigin)
class OrganizationOriginAdmin(admin.ModelAdmin): class OrganizationOriginAdmin(admin.ModelAdmin):
list_display = ("name", "billing_entity") list_display = ("name", "billing_entity", "default_odoo_sale_order_id")
search_fields = ("name",) search_fields = ("name",)
autocomplete_fields = ("billing_entity",) autocomplete_fields = ("billing_entity",)
filter_horizontal = ("limit_cloudproviders",)
@admin.register(OrganizationMembership) @admin.register(OrganizationMembership)

View file

@ -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 django.db.models.deletion
import rules.contrib.models import rules.contrib.models
@ -9,10 +9,88 @@ from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
("core", "0010_organizationorigin_billing_entity"), ("core", "0008_organization_osb_guid_service_osb_service_id_and_more"),
] ]
operations = [ 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( migrations.CreateModel(
name="OrganizationInvitation", name="OrganizationInvitation",
fields=[ fields=[

View file

@ -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",
),
),
]

View file

@ -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",
),
),
]

View file

@ -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",
),
),
]

View file

@ -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",
),
),
]

View file

@ -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",
),
),
]

View file

@ -52,12 +52,6 @@ class Organization(ServalaModelMixin, models.Model):
related_name="organizations", related_name="organizations",
verbose_name=_("Members"), 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( limit_osb_services = models.ManyToManyField(
to="Service", to="Service",
related_name="+", related_name="+",
@ -99,6 +93,14 @@ class Organization(ServalaModelMixin, models.Model):
def has_inherited_billing_entity(self): def has_inherited_billing_entity(self):
return self.origin and self.billing_entity == self.origin.billing_entity 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): def set_owner(self, user):
with scopes_disabled(): with scopes_disabled():
OrganizationMembership.objects.filter(user=user, organization=self).delete() OrganizationMembership.objects.filter(user=user, organization=self).delete()
@ -127,7 +129,20 @@ class Organization(ServalaModelMixin, models.Model):
if owner: if owner:
instance.set_owner(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 instance.billing_entity.odoo_company_id
and instance.billing_entity.odoo_invoice_id and instance.billing_entity.odoo_invoice_id
): ):
@ -161,9 +176,8 @@ class Organization(ServalaModelMixin, models.Model):
if self.limit_osb_services.exists(): if self.limit_osb_services.exists():
queryset = self.limit_osb_services.all() queryset = self.limit_osb_services.all()
if self.limit_cloudproviders.exists(): if self.limit_cloudproviders.exists():
allowed_providers = self.limit_cloudproviders.all()
queryset = queryset.filter( queryset = queryset.filter(
offerings__provider__in=allowed_providers offerings__provider__in=self.limit_cloudproviders
).distinct() ).distinct()
return queryset.prefetch_related( return queryset.prefetch_related(
"offerings", "offerings__provider" "offerings", "offerings__provider"
@ -177,9 +191,8 @@ class Organization(ServalaModelMixin, models.Model):
queryset = Service.objects.select_related("category") queryset = Service.objects.select_related("category")
if self.limit_cloudproviders.exists(): if self.limit_cloudproviders.exists():
allowed_providers = self.limit_cloudproviders.all()
queryset = queryset.filter( queryset = queryset.filter(
offerings__provider__in=allowed_providers offerings__provider__in=self.limit_cloudproviders
).distinct() ).distinct()
queryset = queryset.exclude(id__in=self.limit_osb_services.all()) queryset = queryset.exclude(id__in=self.limit_osb_services.all())
return queryset.prefetch_related("offerings", "offerings__provider") return queryset.prefetch_related("offerings", "offerings__provider")
@ -376,6 +389,23 @@ class OrganizationOrigin(ServalaModelMixin, models.Model):
), ),
null=True, 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: class Meta:
verbose_name = _("Organization origin") verbose_name = _("Organization origin")

View file

@ -21,6 +21,15 @@ class ServiceFilterForm(forms.Form):
) )
q = forms.CharField(label=_("Search"), required=False) 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): def filter_queryset(self, queryset):
if category := self.cleaned_data.get("category"): if category := self.cleaned_data.get("category"):
queryset = queryset.filter(category=category) queryset = queryset.filter(category=category)

View file

@ -20,6 +20,7 @@
{% for service in services %} {% for service in services %}
<div class="col-12 col-md-6 col-lg-3">{% include "includes/service_card.html" %}</div> <div class="col-12 col-md-6 col-lg-3">{% include "includes/service_card.html" %}</div>
{% empty %} {% empty %}
<div class="col-12">
<div class="card"> <div class="card">
<div class="card-body"> <div class="card-body">
<div class="card-content"> <div class="card-content">
@ -27,6 +28,7 @@
</div> </div>
</div> </div>
</div> </div>
</div>
{% endfor %} {% endfor %}
</div> </div>
{% if deactivated_services %} {% if deactivated_services %}

View file

@ -44,7 +44,9 @@ class ServiceListView(OrganizationViewMixin, ListView):
@cached_property @cached_property
def filter_form(self): 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): def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs) context = super().get_context_data(**kwargs)