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

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 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=[

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

View file

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

View file

@ -20,10 +20,12 @@
{% for service in services %}
<div class="col-12 col-md-6 col-lg-3">{% include "includes/service_card.html" %}</div>
{% empty %}
<div class="card">
<div class="card-body">
<div class="card-content">
<p>{% translate "No services found." %}</p>
<div class="col-12">
<div class="card">
<div class="card-body">
<div class="card-content">
<p>{% translate "No services found." %}</p>
</div>
</div>
</div>
</div>

View file

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