diff --git a/src/servala/api/views.py b/src/servala/api/views.py index 456f4b2..5d67754 100644 --- a/src/servala/api/views.py +++ b/src/servala/api/views.py @@ -12,13 +12,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__) @@ -133,13 +127,8 @@ class OSBServiceInstanceView(OSBBasicAuthPermission, View): 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) + organization = Organization.create_organization(organization, user) + self._send_invitation_email(request, organization, user) except Exception: return JsonResponse({"error": "Internal server error"}, status=500) @@ -149,6 +138,28 @@ class OSBServiceInstanceView(OSBBasicAuthPermission, View): ) return JsonResponse({"message": "Successfully enabled service"}, status=201) + 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, + ) + 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 1aec22a..f966f38 100644 --- a/src/servala/core/admin.py +++ b/src/servala/core/admin.py @@ -120,7 +120,6 @@ class OrganizationInvitationAdmin(admin.ModelAdmin): "updated_at", ) date_hierarchy = "created_at" - actions = ["send_invitation_emails"] def is_accepted(self, obj): return obj.is_accepted @@ -128,35 +127,6 @@ class OrganizationInvitationAdmin(admin.ModelAdmin): 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): @@ -176,6 +146,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", @@ -208,6 +179,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", @@ -240,15 +212,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"), @@ -318,35 +282,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"] @@ -405,23 +342,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 276e9c2..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/frontend/forms/organization.py b/src/servala/frontend/forms/organization.py index 27e6a09..6fd04b4 100644 --- a/src/servala/frontend/forms/organization.py +++ b/src/servala/frontend/forms/organization.py @@ -1,9 +1,8 @@ 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 @@ -112,68 +111,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/templates/frontend/base.html b/src/servala/frontend/templates/frontend/base.html index 7c6bc54..1301bec 100644 --- a/src/servala/frontend/templates/frontend/base.html +++ b/src/servala/frontend/templates/frontend/base.html @@ -93,7 +93,5 @@ })(); - {% block extra_js %} - {% endblock extra_js %} diff --git a/src/servala/frontend/templates/frontend/organizations/service_detail.html b/src/servala/frontend/templates/frontend/organizations/service_detail.html index a101ed1..c72fbef 100644 --- a/src/servala/frontend/templates/frontend/organizations/service_detail.html +++ b/src/servala/frontend/templates/frontend/organizations/service_detail.html @@ -32,7 +32,13 @@
{% translate "External Links" %}
{% for link in service.external_links %} - {% include "includes/external_link.html" %} + + {{ link.title }} + + {% endfor %}
diff --git a/src/servala/frontend/templates/frontend/organizations/service_instance_detail.html b/src/servala/frontend/templates/frontend/organizations/service_instance_detail.html index d375344..c17dca0 100644 --- a/src/servala/frontend/templates/frontend/organizations/service_instance_detail.html +++ b/src/servala/frontend/templates/frontend/organizations/service_instance_detail.html @@ -173,36 +173,34 @@ {% endif %} {% if instance.connection_credentials %} -
-
-
-

{% translate "Connection Credentials" %}

-
-
-
- - +
+
+

{% translate "Connection Credentials" %}

+
+
+
+
+ + + + + + + + {% for key, value in instance.connection_credentials.items %} - - + + - - - {% for key, value in instance.connection_credentials.items %} - - - - - {% endfor %} - -
{% translate "Name" %}{% translate "Value" %}
{% translate "Name" %}{% translate "Value" %}{{ key }} + {% if key == "error" %} + {{ value }} + {% else %} + {{ value }} + {% endif %} +
{{ key }} - {% if key == "error" %} - {{ value }} - {% else %} - {{ value }} - {% endif %} -
-
+ {% endfor %} + +
diff --git a/src/servala/frontend/templates/frontend/organizations/service_offering_detail.html b/src/servala/frontend/templates/frontend/organizations/service_offering_detail.html index 842e610..7f3863e 100644 --- a/src/servala/frontend/templates/frontend/organizations/service_offering_detail.html +++ b/src/servala/frontend/templates/frontend/organizations/service_offering_detail.html @@ -64,16 +64,19 @@ {{ select_form }} {% endif %} - {% if service.external_links or offering.external_links %} + {% if service.external_links %}
{% translate "External Links" %}
{% for link in service.external_links %} - {% include "includes/external_link.html" %} - {% endfor %} - {% for link in offering.external_links %} - {% include "includes/external_link.html" %} + + {{ link.title }} + + {% endfor %}
@@ -93,14 +96,3 @@
{% endblock content %} -{% block extra_js %} - {% if wildcard_dns and organization_namespace %} - - - {% endif %} -{% endblock extra_js %} diff --git a/src/servala/frontend/templates/frontend/organizations/update.html b/src/servala/frontend/templates/frontend/organizations/update.html index d55dc56..2e1b9b0 100644 --- a/src/servala/frontend/templates/frontend/organizations/update.html +++ b/src/servala/frontend/templates/frontend/organizations/update.html @@ -36,74 +36,6 @@ {% endpartialdef org-name-edit %} -{% partialdef members-list %} -
- - - - - - - - - - - {% for membership in memberships %} - - - - - - - {% empty %} - - - - {% endfor %} - -
{% translate "Name" %}{% translate "Email" %}{% translate "Role" %}{% translate "Joined" %}
{{ membership.user }}{{ membership.user.email }} - - {{ membership.get_role_display }} - - {{ membership.date_joined|date:"Y-m-d" }}
{% translate "No members yet" %}
-
-{% if pending_invitations %} -
- {% translate "Pending Invitations" %} -
-
- - - - - - - - - - - {% for invitation in pending_invitations %} - - - - - - - {% endfor %} - -
{% translate "Email" %}{% translate "Role" %}{% translate "Sent" %}{% translate "Link" %}
{{ invitation.email }} - - {{ invitation.get_role_display }} - - {{ invitation.created_at|date:"Y-m-d H:i" }} - -
-
-{% endif %} -{% endpartialdef members-list %} {% block content %}
@@ -203,39 +135,5 @@
{% endif %} - {% if can_manage_members %} -
-
-

- {% translate "Members" %} -

-
-
-
{% partial members-list %}
-
-
-
-
-

- {% translate "Invite New Member" %} -

-
-
-
-
- {% csrf_token %} -
{{ invitation_form }}
-
-
- -
-
-
-
-
-
- {% endif %}
{% endblock content %} diff --git a/src/servala/frontend/templates/includes/external_link.html b/src/servala/frontend/templates/includes/external_link.html deleted file mode 100644 index e8319bf..0000000 --- a/src/servala/frontend/templates/includes/external_link.html +++ /dev/null @@ -1,7 +0,0 @@ - - {{ link.title }} - - diff --git a/src/servala/frontend/templates/includes/tabbed_fieldset_form.html b/src/servala/frontend/templates/includes/tabbed_fieldset_form.html index 5857bdf..c9d947a 100644 --- a/src/servala/frontend/templates/includes/tabbed_fieldset_form.html +++ b/src/servala/frontend/templates/includes/tabbed_fieldset_form.html @@ -1,23 +1,10 @@ {% load i18n %} {% load get_field %} -{% load static %}
{% csrf_token %} {% include "frontend/forms/errors.html" %} - {% if form.ADVANCED_FIELDS %} -
- -
- {% endif %}