diff --git a/src/servala/api/views.py b/src/servala/api/views.py
index 456f4b2..48845d0 100644
--- a/src/servala/api/views.py
+++ b/src/servala/api/views.py
@@ -1,5 +1,6 @@
import json
import logging
+from contextlib import suppress
from django.conf import settings
from django.contrib.auth.decorators import login_not_required
@@ -12,13 +13,7 @@ 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,
- OrganizationInvitation,
- OrganizationRole,
- User,
-)
+from servala.core.models import BillingEntity, Organization, User
from servala.core.models.service import Service, ServiceOffering
logger = logging.getLogger(__name__)
@@ -107,47 +102,66 @@ class OSBServiceInstanceView(OSBBasicAuthPermission, View):
)
exoscale_origin = get_exoscale_origin()
- try:
+ with suppress(Organization.DoesNotExist):
organization = Organization.objects.get(
osb_guid=organization_guid, origin=exoscale_origin
)
- 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)
+ self._send_service_welcome_email(
+ request, organization, user, service, service_offering
+ )
+ return JsonResponse({"message": "Service already enabled"}, status=200)
- organization.limit_osb_services.add(service)
- self._send_service_welcome_email(
- request, organization, user, service, service_offering
+ 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,
)
- 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 87da376..073d444 100644
--- a/src/servala/core/admin.py
+++ b/src/servala/core/admin.py
@@ -9,7 +9,6 @@ from servala.core.models import (
ControlPlane,
ControlPlaneCRD,
Organization,
- OrganizationInvitation,
OrganizationMembership,
OrganizationOrigin,
Service,
@@ -63,15 +62,10 @@ 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
@@ -83,10 +77,8 @@ class BillingEntityAdmin(admin.ModelAdmin):
@admin.register(OrganizationOrigin)
class OrganizationOriginAdmin(admin.ModelAdmin):
- list_display = ("name", "billing_entity", "default_odoo_sale_order_id")
+ list_display = ("name",)
search_fields = ("name",)
- autocomplete_fields = ("billing_entity",)
- filter_horizontal = ("limit_cloudproviders",)
@admin.register(OrganizationMembership)
@@ -98,58 +90,6 @@ 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")
@@ -168,6 +108,7 @@ 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",
@@ -200,6 +141,7 @@ 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",
@@ -232,15 +174,7 @@ class ControlPlaneAdmin(admin.ModelAdmin):
fieldsets = (
(
None,
- {
- "fields": (
- "name",
- "description",
- "cloud_provider",
- "required_label",
- "wildcard_dns",
- )
- },
+ {"fields": ("name", "description", "cloud_provider", "required_label")},
),
(
_("API Credentials"),
@@ -310,35 +244,8 @@ 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"]
@@ -397,23 +304,3 @@ 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 fe8edbb..44c809b 100644
--- a/src/servala/core/crd.py
+++ b/src/servala/core/crd.py
@@ -86,117 +86,10 @@ 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:
- # Handle snake_case
- title = title.replace("_", " ")
- words = title.split()
- else:
- # Handle camelCase/PascalCase with smart splitting
- # This regex splits on:
- # - Transition from lowercase to uppercase (camelCase)
- # - Transition from multiple uppercase to an uppercase followed by lowercase (SQLParameters -> 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)
+ title.replace("_", " ")
+ return title.title()
+ return re.sub(r"(?{error_items}")
@classmethod
- @transaction.atomic
def create_instance(cls, name, organization, context, created_by, spec_data):
# Ensure the namespace exists
context.control_plane.get_or_create_namespace(organization)
@@ -733,7 +704,7 @@ class ServiceInstance(ServalaModelMixin, models.Model):
body=create_data,
)
except Exception as e:
- # Transaction will automatically roll back the instance creation
+ instance.delete()
if isinstance(e, ApiException):
try:
error_body = json.loads(e.body)
@@ -934,9 +905,6 @@ class ServiceInstance(ServalaModelMixin, models.Model):
import base64
for key, value in secret.data.items():
- # Skip keys ending with _HOST as they're only useful for dedicated OpenShift clusters
- if key.endswith("_HOST"):
- continue
try:
credentials[key] = base64.b64decode(value).decode("utf-8")
except Exception:
diff --git a/src/servala/core/rules.py b/src/servala/core/rules.py
index e1a0992..cf4dc1c 100644
--- a/src/servala/core/rules.py
+++ b/src/servala/core/rules.py
@@ -14,26 +14,20 @@ def has_organization_role(user, org, roles):
@rules.predicate
def is_organization_owner(user, obj):
- from servala.core.models.organization import OrganizationRole
-
if hasattr(obj, "organization"):
org = obj.organization
else:
org = obj
- return has_organization_role(user, org, [OrganizationRole.OWNER])
+ return has_organization_role(user, org, ["owner"])
@rules.predicate
def is_organization_admin(user, obj):
- from servala.core.models.organization import OrganizationRole
-
if hasattr(obj, "organization"):
org = obj.organization
else:
org = obj
- return has_organization_role(
- user, org, [OrganizationRole.OWNER, OrganizationRole.ADMIN]
- )
+ return has_organization_role(user, org, ["owner", "admin"])
@rules.predicate
diff --git a/src/servala/frontend/forms/organization.py b/src/servala/frontend/forms/organization.py
index 27e6a09..915ad7b 100644
--- a/src/servala/frontend/forms/organization.py
+++ b/src/servala/frontend/forms/organization.py
@@ -1,18 +1,13 @@
from django import forms
-from django.core.exceptions import ValidationError
from django.forms import ModelForm
from django.utils.translation import gettext_lazy as _
-from servala.core.models import Organization, OrganizationInvitation, OrganizationRole
+from servala.core.models import Organization
from servala.core.odoo import get_invoice_addresses, get_odoo_countries
from servala.frontend.forms.mixins import HtmxMixin
class OrganizationForm(HtmxMixin, ModelForm):
- # def __init__(self, *args, **kwargs):
- # super().__init__(*args, **kwargs)
- # if self.instance and self.instance.has_inherited_billing_entity:
- # TODO disable billing entity editing
class Meta:
model = Organization
fields = ("name",)
@@ -51,7 +46,7 @@ class OrganizationCreateForm(OrganizationForm):
def __init__(self, *args, user=None, **kwargs):
super().__init__(*args, **kwargs)
- self.user = user
+
if not self.initial.get("invoice_country"):
default_country_name = "Switzerland"
country_choices = self.fields["invoice_country"].choices
@@ -60,6 +55,7 @@ class OrganizationCreateForm(OrganizationForm):
self.initial["invoice_country"] = country_id
break
+ self.user = user
self.odoo_addresses = get_invoice_addresses(self.user)
if self.odoo_addresses:
@@ -112,68 +108,3 @@ class OrganizationCreateForm(OrganizationForm):
"existing_odoo_address_id", _("Please select an invoice address.")
)
return cleaned_data
-
-
-class OrganizationInvitationForm(forms.ModelForm):
-
- def __init__(self, *args, organization=None, user_role=None, **kwargs):
- super().__init__(*args, **kwargs)
- self.organization = organization
- self.user_role = user_role
-
- if user_role:
- allowed_roles = self._get_allowed_roles(user_role)
- self.fields["role"].choices = [
- (value, label)
- for value, label in OrganizationRole.choices
- if value in allowed_roles
- ]
-
- def _get_allowed_roles(self, user_role):
- role_hierarchy = {
- OrganizationRole.OWNER: [
- OrganizationRole.OWNER,
- OrganizationRole.ADMIN,
- OrganizationRole.MEMBER,
- ],
- OrganizationRole.ADMIN: [
- OrganizationRole.ADMIN,
- OrganizationRole.MEMBER,
- ],
- OrganizationRole.MEMBER: [],
- }
- return role_hierarchy.get(user_role, [])
-
- def clean_email(self):
- email = self.cleaned_data["email"].lower()
-
- if self.organization.members.filter(email__iexact=email).exists():
- raise ValidationError(
- _("A user with this email is already a member of this organization.")
- )
-
- if OrganizationInvitation.objects.filter(
- organization=self.organization,
- email__iexact=email,
- accepted_by__isnull=True,
- ).exists():
- raise ValidationError(
- _("An invitation has already been sent to this email address.")
- )
-
- return email
-
- def save(self, commit=True):
- invitation = super().save(commit=False)
- invitation.organization = self.organization
- if commit:
- invitation.save()
- return invitation
-
- class Meta:
- model = OrganizationInvitation
- fields = ("email", "role")
- widgets = {
- "email": forms.EmailInput(attrs={"placeholder": _("user@example.com")}),
- "role": forms.RadioSelect(),
- }
diff --git a/src/servala/frontend/forms/service.py b/src/servala/frontend/forms/service.py
index 23325f3..5dd78a7 100644
--- a/src/servala/frontend/forms/service.py
+++ b/src/servala/frontend/forms/service.py
@@ -21,15 +21,6 @@ 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/base.html b/src/servala/frontend/templates/frontend/base.html
index 7c6bc54..f738a52 100644
--- a/src/servala/frontend/templates/frontend/base.html
+++ b/src/servala/frontend/templates/frontend/base.html
@@ -80,20 +80,13 @@
- {% block extra_js %}
- {% endblock extra_js %}