diff --git a/src/servala/api/views.py b/src/servala/api/views.py index 48845d0..456f4b2 100644 --- a/src/servala/api/views.py +++ b/src/servala/api/views.py @@ -1,6 +1,5 @@ import json import logging -from contextlib import suppress from django.conf import settings from django.contrib.auth.decorators import login_not_required @@ -13,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__) @@ -102,66 +107,47 @@ class OSBServiceInstanceView(OSBBasicAuthPermission, View): ) exoscale_origin = get_exoscale_origin() - with suppress(Organization.DoesNotExist): + try: organization = Organization.objects.get( osb_guid=organization_guid, origin=exoscale_origin ) - self._send_service_welcome_email( - request, organization, user, service, service_offering - ) - return JsonResponse({"message": "Service already enabled"}, status=200) + if service in organization.limit_osb_services.all(): + return JsonResponse({"message": "Service already enabled"}, status=200) + except Organization.DoesNotExist: + 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, + origin=exoscale_origin, + osb_guid=organization_guid, + ) + 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) - odoo_data = { - "company_name": organization_display_name, - "invoice_email": user.email, - } - try: - with transaction.atomic(): - 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, - origin=exoscale_origin, - osb_guid=organization_guid, - ) - organization = Organization.create_organization(organization, user) - - self._send_invitation_email(request, organization, user) - self._send_service_welcome_email( - request, organization, user, service, service_offering - ) - - return JsonResponse( - {"message": "Successfully enabled service"}, status=201 - ) - - except Exception as e: - logger.error(f"Error creating organization for Exoscale: {str(e)}") - return JsonResponse({"error": "Internal server error"}, status=500) - - 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, + organization.limit_osb_services.add(service) + self._send_service_welcome_email( + request, organization, user, service, service_offering ) + return JsonResponse({"message": "Successfully enabled service"}, status=201) 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 073d444..87da376 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, @@ -62,10 +63,15 @@ 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 []) readonly_fields.append("namespace") # Always read-only + + if obj and obj.has_inherited_billing_entity: + readonly_fields.append("billing_entity") + return readonly_fields @@ -77,8 +83,10 @@ class BillingEntityAdmin(admin.ModelAdmin): @admin.register(OrganizationOrigin) class OrganizationOriginAdmin(admin.ModelAdmin): - list_display = ("name",) + list_display = ("name", "billing_entity", "default_odoo_sale_order_id") search_fields = ("name",) + autocomplete_fields = ("billing_entity",) + filter_horizontal = ("limit_cloudproviders",) @admin.register(OrganizationMembership) @@ -90,6 +98,58 @@ 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" + actions = ["send_invitation_emails"] + + def is_accepted(self, obj): + return obj.is_accepted + + 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): list_display = ("name", "parent") @@ -108,7 +168,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", @@ -141,7 +200,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", @@ -174,7 +232,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"), @@ -244,8 +310,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"] @@ -304,3 +397,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/crd.py b/src/servala/core/crd.py index 44c809b..fe8edbb 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( @@ -220,6 +327,18 @@ class CrdModelFormMixin: field.widget = forms.HiddenInput() field.required = False + # Mark advanced fields with a CSS class and data attribute + for name, field in self.fields.items(): + if self.is_field_advanced(name): + 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.") @@ -236,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 = [] @@ -251,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( [ @@ -317,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 @@ -333,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 @@ -343,6 +479,7 @@ class CrdModelFormMixin: "fields": others, "fieldsets": [], "has_mandatory": self.has_mandatory_fields(others), + "is_advanced": self.are_all_fields_advanced(others), } ) @@ -406,7 +543,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__", @@ -414,6 +551,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/core/migrations/0009_controlplane_wildcard_dns_and_more.py b/src/servala/core/migrations/0009_controlplane_wildcard_dns_and_more.py new file mode 100644 index 0000000..811c843 --- /dev/null +++ b/src/servala/core/migrations/0009_controlplane_wildcard_dns_and_more.py @@ -0,0 +1,185 @@ +# Generated by Django 5.2.7 on 2025-10-22 09:38 + +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", "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=[ + ( + "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/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/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/__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 083bc50..78605f6 100644 --- a/src/servala/core/models/organization.py +++ b/src/servala/core/models/organization.py @@ -1,7 +1,13 @@ +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 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 @@ -46,6 +52,12 @@ class Organization(ServalaModelMixin, models.Model): related_name="organizations", verbose_name=_("Members"), ) + limit_osb_services = models.ManyToManyField( + to="Service", + related_name="+", + verbose_name=_("Services activated from OSB"), + blank=True, + ) odoo_sale_order_id = models.IntegerField( null=True, blank=True, verbose_name=_("Odoo Sale Order ID") @@ -77,6 +89,18 @@ 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 + + @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() @@ -94,7 +118,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: @@ -102,9 +126,23 @@ class Organization(ServalaModelMixin, models.Model): pk=settings.SERVALA_DEFAULT_ORIGIN ) instance.save() - instance.set_owner(owner) + 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 ): @@ -131,6 +169,34 @@ class Organization(ServalaModelMixin, models.Model): return instance + def get_visible_services(self): + from servala.core.models import Service + + queryset = Service.objects.all() + if self.limit_osb_services.exists(): + queryset = self.limit_osb_services.all() + if self.limit_cloudproviders.exists(): + queryset = queryset.filter( + offerings__provider__in=self.limit_cloudproviders + ).distinct() + return queryset.prefetch_related( + "offerings", "offerings__provider" + ).select_related("category") + + def get_deactivated_services(self): + from servala.core.models import Service + + if not self.limit_osb_services.exists(): + return Service.objects.none() + + queryset = Service.objects.select_related("category") + if self.limit_cloudproviders.exists(): + queryset = queryset.filter( + offerings__provider__in=self.limit_cloudproviders + ).distinct() + queryset = queryset.exclude(id__in=self.limit_osb_services.all()) + return queryset.prefetch_related("offerings", "offerings__provider") + class Meta: verbose_name = _("Organization") verbose_name_plural = _("Organizations") @@ -313,6 +379,33 @@ 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, blank=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") @@ -361,3 +454,120 @@ 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/" + delete = "{self.organization.urls.details}invitations/{self.pk}/delete/" + + class Meta: + verbose_name = _("Organization invitation") + verbose_name_plural = _("Organization invitations") + + 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 bool(self.accepted_by or self.accepted_at) + + @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, + ) + + +auditlog.register(OrganizationInvitation, serialize_data=True) +auditlog.register(OrganizationMembership, serialize_data=True) diff --git a/src/servala/core/models/service.py b/src/servala/core/models/service.py index 846b24e..42fc500 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") @@ -350,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, @@ -490,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): @@ -511,6 +533,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, @@ -657,6 +685,7 @@ class ServiceInstance(ServalaModelMixin, models.Model): return mark_safe(f"