Compare commits
No commits in common. "31018298852e28af7f16324d3790002dffb60ace" and "09ab83d1e448519ced0058bd3622c67cb72c1dcf" have entirely different histories.
3101829885
...
09ab83d1e4
22 changed files with 91 additions and 898 deletions
|
|
@ -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
|
||||
):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"(?<!^)(?=[A-Z])", " ", title).capitalize()
|
||||
|
||||
|
||||
def get_django_field(
|
||||
|
|
@ -327,19 +220,6 @@ class CrdModelFormMixin:
|
|||
field.widget = forms.HiddenInput()
|
||||
field.required = False
|
||||
|
||||
# Mark advanced fields with a CSS class and data attribute
|
||||
advanced_fields = getattr(self, "ADVANCED_FIELDS", [])
|
||||
for name, field in self.fields.items():
|
||||
if name in advanced_fields:
|
||||
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.")
|
||||
|
|
@ -526,7 +406,7 @@ class CrdModelFormMixin:
|
|||
pass
|
||||
|
||||
|
||||
def generate_model_form_class(model, advanced_fields=None):
|
||||
def generate_model_form_class(model):
|
||||
meta_attrs = {
|
||||
"model": model,
|
||||
"fields": "__all__",
|
||||
|
|
@ -534,7 +414,6 @@ def generate_model_form_class(model, advanced_fields=None):
|
|||
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)
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
),
|
||||
),
|
||||
]
|
||||
|
|
@ -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",
|
||||
),
|
||||
),
|
||||
]
|
||||
|
|
@ -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",
|
||||
),
|
||||
),
|
||||
]
|
||||
|
|
@ -3,10 +3,7 @@ import secrets
|
|||
import rules
|
||||
import urlman
|
||||
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
|
||||
|
|
@ -115,7 +112,7 @@ class Organization(ServalaModelMixin, models.Model):
|
|||
|
||||
@classmethod
|
||||
@transaction.atomic
|
||||
def create_organization(cls, instance, owner=None):
|
||||
def create_organization(cls, instance, owner):
|
||||
try:
|
||||
instance.origin
|
||||
except Exception:
|
||||
|
|
@ -123,7 +120,6 @@ class Organization(ServalaModelMixin, models.Model):
|
|||
pk=settings.SERVALA_DEFAULT_ORIGIN
|
||||
)
|
||||
instance.save()
|
||||
if owner:
|
||||
instance.set_owner(owner)
|
||||
|
||||
if (
|
||||
|
|
@ -490,49 +486,3 @@ class OrganizationInvitation(ServalaModelMixin, models.Model):
|
|||
@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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -159,15 +159,6 @@ 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")
|
||||
|
|
@ -359,16 +350,6 @@ 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,
|
||||
|
|
@ -509,10 +490,7 @@ class ControlPlaneCRD(ServalaModelMixin, models.Model):
|
|||
|
||||
if not self.django_model:
|
||||
return
|
||||
advanced_fields = self.service_definition.advanced_fields or []
|
||||
return generate_model_form_class(
|
||||
self.django_model, advanced_fields=advanced_fields
|
||||
)
|
||||
return generate_model_form_class(self.django_model)
|
||||
|
||||
|
||||
class ServiceOffering(ServalaModelMixin, models.Model):
|
||||
|
|
@ -533,12 +511,6 @@ 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,
|
||||
|
|
@ -685,7 +657,6 @@ class ServiceInstance(ServalaModelMixin, models.Model):
|
|||
return mark_safe(f"<ul>{error_items}</ul>")
|
||||
|
||||
@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:
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -93,7 +93,5 @@
|
|||
})();
|
||||
</script>
|
||||
<!-- Ybug code end -->
|
||||
{% block extra_js %}
|
||||
{% endblock extra_js %}
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -32,7 +32,13 @@
|
|||
<h6 class="mb-3">{% translate "External Links" %}</h6>
|
||||
<div class="d-flex flex-wrap gap-2">
|
||||
{% for link in service.external_links %}
|
||||
{% include "includes/external_link.html" %}
|
||||
<a href="{{ link.url }}"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="btn btn-outline-primary btn-sm">
|
||||
{{ link.title }}
|
||||
<i class="bi bi-box-arrow-up-right ms-1"></i>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -173,7 +173,6 @@
|
|||
</div>
|
||||
{% endif %}
|
||||
{% if instance.connection_credentials %}
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h4>{% translate "Connection Credentials" %}</h4>
|
||||
|
|
@ -205,7 +204,6 @@
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
|
|
|
|||
|
|
@ -64,16 +64,19 @@
|
|||
{{ select_form }}
|
||||
</form>
|
||||
{% endif %}
|
||||
{% if service.external_links or offering.external_links %}
|
||||
{% if service.external_links %}
|
||||
<div class="row mt-3">
|
||||
<div class="col-12">
|
||||
<h6 class="mb-3">{% translate "External Links" %}</h6>
|
||||
<div class="d-flex flex-wrap gap-2">
|
||||
{% for link in service.external_links %}
|
||||
{% include "includes/external_link.html" %}
|
||||
{% endfor %}
|
||||
{% for link in offering.external_links %}
|
||||
{% include "includes/external_link.html" %}
|
||||
<a href="{{ link.url }}"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="btn btn-outline-primary btn-sm">
|
||||
{{ link.title }}
|
||||
<i class="bi bi-box-arrow-up-right ms-1"></i>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -93,14 +96,3 @@
|
|||
</div>
|
||||
</section>
|
||||
{% endblock content %}
|
||||
{% block extra_js %}
|
||||
{% if wildcard_dns and organization_namespace %}
|
||||
<script>
|
||||
const fqdnConfig = {
|
||||
wildcardDns: '{{ wildcard_dns }}',
|
||||
namespace: '{{ organization_namespace }}'
|
||||
};
|
||||
</script>
|
||||
<script defer src="{% static "js/fqdn.js" %}"></script>
|
||||
{% endif %}
|
||||
{% endblock extra_js %}
|
||||
|
|
|
|||
|
|
@ -36,74 +36,6 @@
|
|||
</form>
|
||||
</td>
|
||||
{% endpartialdef org-name-edit %}
|
||||
{% partialdef members-list %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{% translate "Name" %}</th>
|
||||
<th>{% translate "Email" %}</th>
|
||||
<th>{% translate "Role" %}</th>
|
||||
<th>{% translate "Joined" %}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for membership in memberships %}
|
||||
<tr>
|
||||
<td>{{ membership.user }}</td>
|
||||
<td>{{ membership.user.email }}</td>
|
||||
<td>
|
||||
<span class="badge bg-{% if membership.role == 'owner' %}primary{% elif membership.role == 'admin' %}info{% else %}secondary{% endif %}">
|
||||
{{ membership.get_role_display }}
|
||||
</span>
|
||||
</td>
|
||||
<td>{{ membership.date_joined|date:"Y-m-d" }}</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr>
|
||||
<td colspan="4" class="text-muted text-center">{% translate "No members yet" %}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% if pending_invitations %}
|
||||
<h5 class="mt-4">
|
||||
<i class="bi bi-envelope"></i> {% translate "Pending Invitations" %}
|
||||
</h5>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{% translate "Email" %}</th>
|
||||
<th>{% translate "Role" %}</th>
|
||||
<th>{% translate "Sent" %}</th>
|
||||
<th>{% translate "Link" %}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for invitation in pending_invitations %}
|
||||
<tr>
|
||||
<td>{{ invitation.email }}</td>
|
||||
<td>
|
||||
<span class="badge bg-{% if invitation.role == 'owner' %}primary{% elif invitation.role == 'admin' %}info{% else %}secondary{% endif %}">
|
||||
{{ invitation.get_role_display }}
|
||||
</span>
|
||||
</td>
|
||||
<td>{{ invitation.created_at|date:"Y-m-d H:i" }}</td>
|
||||
<td>
|
||||
<button class="btn btn-sm btn-outline-secondary"
|
||||
onclick="navigator.clipboard.writeText('{{ request.scheme }}://{{ request.get_host }}{{ invitation.urls.accept }}'); this.textContent='Copied!'">
|
||||
<i class="bi bi-clipboard"></i> {% translate "Copy Link" %}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endpartialdef members-list %}
|
||||
{% block content %}
|
||||
<section class="section">
|
||||
<div class="card">
|
||||
|
|
@ -203,39 +135,5 @@
|
|||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if can_manage_members %}
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h4 class="card-title">
|
||||
<i class="bi bi-people"></i> {% translate "Members" %}
|
||||
</h4>
|
||||
</div>
|
||||
<div class="card-content">
|
||||
<div class="card-body">{% partial members-list %}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h4 class="card-title">
|
||||
<i class="bi bi-person-plus"></i> {% translate "Invite New Member" %}
|
||||
</h4>
|
||||
</div>
|
||||
<div class="card-content">
|
||||
<div class="card-body">
|
||||
<form method="post" class="form">
|
||||
{% csrf_token %}
|
||||
<div class="row">{{ invitation_form }}</div>
|
||||
<div class="row mt-3">
|
||||
<div class="col-12">
|
||||
<button type="submit" class="btn btn-primary" name="invite_email" value="1">
|
||||
<i class="bi bi-send"></i> {% translate "Send Invitation" %}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</section>
|
||||
{% endblock content %}
|
||||
|
|
|
|||
|
|
@ -1,7 +0,0 @@
|
|||
<a href="{{ link.url }}"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="btn btn-outline-primary btn-sm">
|
||||
{{ link.title }}
|
||||
<i class="bi bi-box-arrow-up-right ms-1"></i>
|
||||
</a>
|
||||
|
|
@ -1,23 +1,10 @@
|
|||
{% load i18n %}
|
||||
{% load get_field %}
|
||||
{% load static %}
|
||||
<form class="form form-vertical crd-form"
|
||||
method="post"
|
||||
{% if form_action %}action="{{ form_action }}"{% endif %}>
|
||||
{% csrf_token %}
|
||||
{% include "frontend/forms/errors.html" %}
|
||||
{% if form.ADVANCED_FIELDS %}
|
||||
<div class="mb-3">
|
||||
<button type="button"
|
||||
class="btn btn-sm btn-outline-secondary ml-auto d-block"
|
||||
id="advanced-toggle"
|
||||
data-bs-toggle="collapse"
|
||||
data-bs-target=".advanced-field-group"
|
||||
aria-expanded="false">
|
||||
<i class="bi bi-gear"></i> {% translate "Show Advanced Options" %}
|
||||
</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
<ul class="nav nav-tabs" id="myTab" role="tablist">
|
||||
{% for fieldset in form.get_fieldsets %}
|
||||
{% if not fieldset.hidden %}
|
||||
|
|
@ -67,4 +54,3 @@
|
|||
</button>
|
||||
</div>
|
||||
</form>
|
||||
<script defer src="{% static 'js/advanced-fields.js' %}"></script>
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ from django.shortcuts import get_object_or_404, redirect
|
|||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.utils.functional import cached_property
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.views.generic import CreateView, DetailView, TemplateView
|
||||
from django_scopes import scopes_disabled
|
||||
|
|
@ -14,14 +13,9 @@ from servala.core.models import (
|
|||
Organization,
|
||||
OrganizationInvitation,
|
||||
OrganizationMembership,
|
||||
OrganizationRole,
|
||||
ServiceInstance,
|
||||
)
|
||||
from servala.frontend.forms.organization import (
|
||||
OrganizationCreateForm,
|
||||
OrganizationForm,
|
||||
OrganizationInvitationForm,
|
||||
)
|
||||
from servala.frontend.forms.organization import OrganizationCreateForm, OrganizationForm
|
||||
from servala.frontend.views.mixins import HtmxUpdateView, OrganizationViewMixin
|
||||
|
||||
|
||||
|
|
@ -111,97 +105,7 @@ class OrganizationDashboardView(
|
|||
class OrganizationUpdateView(OrganizationViewMixin, HtmxUpdateView):
|
||||
template_name = "frontend/organizations/update.html"
|
||||
form_class = OrganizationForm
|
||||
fragments = ("org-name", "org-name-edit", "members-list")
|
||||
|
||||
@cached_property
|
||||
def user_role(self):
|
||||
membership = (
|
||||
OrganizationMembership.objects.filter(
|
||||
user=self.request.user, organization=self.get_object()
|
||||
)
|
||||
.order_by("role")
|
||||
.first()
|
||||
)
|
||||
return membership.role if membership else None
|
||||
|
||||
@cached_property
|
||||
def can_manage_members(self):
|
||||
return self.user_role in [
|
||||
OrganizationRole.ADMIN,
|
||||
OrganizationRole.OWNER,
|
||||
]
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
organization = self.get_object()
|
||||
|
||||
if self.can_manage_members:
|
||||
memberships = (
|
||||
OrganizationMembership.objects.filter(organization=organization)
|
||||
.select_related("user")
|
||||
.order_by("role", "user__email")
|
||||
)
|
||||
pending_invitations = OrganizationInvitation.objects.filter(
|
||||
organization=organization, accepted_by__isnull=True
|
||||
).order_by("-created_at")
|
||||
invitation_form = OrganizationInvitationForm(
|
||||
organization=organization, user_role=self.user_role
|
||||
)
|
||||
context.update(
|
||||
{
|
||||
"memberships": memberships,
|
||||
"pending_invitations": pending_invitations,
|
||||
"invitation_form": invitation_form,
|
||||
"can_manage_members": self.can_manage_members,
|
||||
"user_role": self.user_role,
|
||||
}
|
||||
)
|
||||
|
||||
return context
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
if "invite_email" in request.POST:
|
||||
return self.handle_invitation(request)
|
||||
return super().post(request, *args, **kwargs)
|
||||
|
||||
def handle_invitation(self, request):
|
||||
organization = self.get_object()
|
||||
if not self.can_manage_members:
|
||||
messages.error(request, _("You do not have permission to invite members."))
|
||||
return redirect(self.get_success_url())
|
||||
|
||||
form = OrganizationInvitationForm(
|
||||
request.POST, organization=organization, user_role=self.user_role
|
||||
)
|
||||
|
||||
if form.is_valid():
|
||||
invitation = form.save(commit=False)
|
||||
invitation.created_by = request.user
|
||||
invitation.save()
|
||||
|
||||
try:
|
||||
invitation.send_invitation_email(request)
|
||||
messages.success(
|
||||
request,
|
||||
_(
|
||||
"Invitation sent to {email}. They will receive an email with the invitation link."
|
||||
).format(email=invitation.email),
|
||||
)
|
||||
except Exception:
|
||||
messages.warning(
|
||||
request,
|
||||
_(
|
||||
"Invitation created for {email}, but email failed to send. Share this link manually: {url}"
|
||||
).format(
|
||||
email=invitation.email,
|
||||
url=request.build_absolute_uri(invitation.urls.accept),
|
||||
),
|
||||
)
|
||||
else:
|
||||
for error in form.errors.values():
|
||||
messages.error(request, error.as_text())
|
||||
|
||||
return redirect(self.get_success_url())
|
||||
fragments = ("org-name", "org-name-edit")
|
||||
|
||||
def get_success_url(self):
|
||||
return self.request.path
|
||||
|
|
|
|||
|
|
@ -132,25 +132,12 @@ class ServiceOfferingDetailView(OrganizationViewMixin, HtmxViewMixin, DetailView
|
|||
def get_instance_form(self):
|
||||
if not self.context_object or not self.context_object.model_form_class:
|
||||
return None
|
||||
|
||||
return self.context_object.model_form_class(
|
||||
data=self.request.POST if self.request.method == "POST" else None,
|
||||
initial={
|
||||
"organization": self.request.organization,
|
||||
"context": self.context_object,
|
||||
}
|
||||
|
||||
# Pre-populate FQDN field if it exists and control plane has wildcard DNS
|
||||
form_class = self.context_object.model_form_class
|
||||
if (
|
||||
"spec.parameters.service.fqdn" in form_class.base_fields
|
||||
and self.context_object.control_plane.wildcard_dns
|
||||
):
|
||||
# Generate initial FQDN: instancename-namespace.wildcard_dns
|
||||
# We'll set a placeholder that JavaScript will replace dynamically
|
||||
initial["spec.parameters.service.fqdn"] = ""
|
||||
|
||||
return form_class(
|
||||
data=self.request.POST if self.request.method == "POST" else None,
|
||||
initial=initial,
|
||||
},
|
||||
)
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
|
|
@ -159,10 +146,6 @@ class ServiceOfferingDetailView(OrganizationViewMixin, HtmxViewMixin, DetailView
|
|||
context["has_control_planes"] = self.planes.exists()
|
||||
context["selected_plane"] = self.selected_plane
|
||||
context["service_form"] = self.get_instance_form()
|
||||
# Pass data for dynamic FQDN generation
|
||||
if self.selected_plane and self.selected_plane.wildcard_dns:
|
||||
context["wildcard_dns"] = self.selected_plane.wildcard_dns
|
||||
context["organization_namespace"] = self.request.organization.namespace
|
||||
return context
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
|
|
|
|||
|
|
@ -302,33 +302,3 @@ html[data-bs-theme="dark"] .crd-form .nav-tabs .nav-link .mandatory-indicator {
|
|||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
.ml-auto {
|
||||
margin-left: auto !important
|
||||
}
|
||||
|
||||
/* Advanced fields tab flash animation */
|
||||
@keyframes tab-pulse {
|
||||
0%, 100% {
|
||||
background-color: transparent;
|
||||
box-shadow: none;
|
||||
}
|
||||
50% {
|
||||
background-color: var(--brand-light);
|
||||
box-shadow: 0 0 10px rgba(154, 99, 236, 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
html[data-bs-theme="dark"] @keyframes tab-pulse {
|
||||
0%, 100% {
|
||||
background-color: transparent;
|
||||
box-shadow: none;
|
||||
}
|
||||
50% {
|
||||
background-color: rgba(154, 99, 236, 0.2);
|
||||
box-shadow: 0 0 10px rgba(154, 99, 236, 0.4);
|
||||
}
|
||||
}
|
||||
|
||||
.nav-tabs .nav-link.tab-flash {
|
||||
animation: tab-pulse 1s ease-in-out 2;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,83 +0,0 @@
|
|||
/**
|
||||
* Advanced Fields Toggle
|
||||
* Handles showing/hiding advanced fields in CRD forms
|
||||
*/
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
function flashTabsWithAdvancedFields() {
|
||||
const advancedGroups = document.querySelectorAll('.advanced-field-group');
|
||||
const tabsToFlash = new Set();
|
||||
advancedGroups.forEach(function(group) {
|
||||
const tabPane = group.closest('.tab-pane');
|
||||
if (tabPane) {
|
||||
const tabId = tabPane.getAttribute('id');
|
||||
if (tabId) {
|
||||
const tabButton = document.querySelector(`[data-bs-target="#${tabId}"]`);
|
||||
if (tabButton && !tabButton.classList.contains('active')) {
|
||||
tabsToFlash.add(tabButton);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
tabsToFlash.forEach(function(tab) {
|
||||
tab.classList.add('tab-flash');
|
||||
setTimeout(function() {
|
||||
tab.classList.remove('tab-flash');
|
||||
}, 2000);
|
||||
});
|
||||
}
|
||||
|
||||
function initializeAdvancedFields() {
|
||||
const advancedInputs = document.querySelectorAll('[data-advanced="true"]');
|
||||
|
||||
if (advancedInputs.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
advancedInputs.forEach(function(input) {
|
||||
const formGroup = input.closest('.form-group, .mb-3, .col-12, .col-md-6');
|
||||
if (formGroup) {
|
||||
formGroup.classList.add('advanced-field-group', 'collapse');
|
||||
}
|
||||
});
|
||||
|
||||
const toggleButton = document.getElementById('advanced-toggle');
|
||||
if (toggleButton) {
|
||||
let isExpanded = false;
|
||||
|
||||
document.querySelectorAll('.advanced-field-group').forEach(function(group) {
|
||||
group.addEventListener('shown.bs.collapse', function() {
|
||||
toggleButton.innerHTML = '<i class="bi bi-gear-fill"></i> Hide Advanced Options';
|
||||
if (!isExpanded) {
|
||||
isExpanded = true;
|
||||
setTimeout(flashTabsWithAdvancedFields, 100);
|
||||
}
|
||||
});
|
||||
|
||||
group.addEventListener('hidden.bs.collapse', function() {
|
||||
const anyVisible = Array.from(document.querySelectorAll('.advanced-field-group')).some(
|
||||
g => g.classList.contains('show')
|
||||
);
|
||||
if (!anyVisible) {
|
||||
toggleButton.innerHTML = '<i class="bi bi-gear"></i> Show Advanced Options';
|
||||
isExpanded = false;
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', initializeAdvancedFields);
|
||||
} else {
|
||||
initializeAdvancedFields();
|
||||
}
|
||||
|
||||
document.body.addEventListener('htmx:afterSwap', function(event) {
|
||||
if (event.detail.target.id === 'service-form' || event.detail.target.closest('.crd-form')) {
|
||||
setTimeout(initializeAdvancedFields, 100);
|
||||
}
|
||||
});
|
||||
})();
|
||||
|
|
@ -1,38 +0,0 @@
|
|||
|
||||
const initializeFqdnGeneration = () => {
|
||||
const nameField = document.querySelector('input[name="name"]');
|
||||
const fqdnField = document.querySelector('label[for="id_spec.parameters.service.fqdn"] + div input.array-item-input');
|
||||
|
||||
if (nameField && fqdnField) {
|
||||
const generateFqdn = (instanceName) => {
|
||||
if (!instanceName) return '';
|
||||
return `${instanceName}-${fqdnConfig.namespace}.${fqdnConfig.wildcardDns}`;
|
||||
}
|
||||
|
||||
const newNameField = nameField.cloneNode(true);
|
||||
nameField.parentNode.replaceChild(newNameField, nameField);
|
||||
const newFqdnField = fqdnField.cloneNode(true);
|
||||
fqdnField.parentNode.replaceChild(newFqdnField, fqdnField);
|
||||
|
||||
newNameField.addEventListener('input', function() {
|
||||
if (!newFqdnField.dataset.manuallyEdited) {
|
||||
newFqdnField.value = generateFqdn(this.value);
|
||||
}
|
||||
});
|
||||
|
||||
newFqdnField.addEventListener('input', function() {
|
||||
this.dataset.manuallyEdited = 'true';
|
||||
});
|
||||
|
||||
if (newNameField.value && !newFqdnField.value) {
|
||||
newFqdnField.value = generateFqdn(newNameField.value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', initializeFqdnGeneration);
|
||||
document.body.addEventListener('htmx:afterSwap', function(event) {
|
||||
if (event.detail.target.id === 'service-form') {
|
||||
initializeFqdnGeneration();
|
||||
}
|
||||
});
|
||||
|
|
@ -73,8 +73,12 @@ def test_successful_onboarding_new_organization(
|
|||
assert org.origin == exoscale_origin
|
||||
assert org.namespace.startswith("org-")
|
||||
|
||||
user = User.objects.get(email="test@example.com")
|
||||
assert user.first_name == "Test"
|
||||
assert user.last_name == "User"
|
||||
with scopes_disabled():
|
||||
assert org.invitations.all().filter(email="test@example.com").exists()
|
||||
membership = org.memberships.get(user=user)
|
||||
assert membership.role == "owner"
|
||||
|
||||
billing_entity = org.billing_entity
|
||||
assert billing_entity.name == "Test Organization Display (Exoscale)"
|
||||
|
|
@ -87,10 +91,7 @@ def test_successful_onboarding_new_organization(
|
|||
|
||||
assert len(mail.outbox) == 2
|
||||
invitation_email = mail.outbox[0]
|
||||
assert (
|
||||
invitation_email.subject
|
||||
== "You're invited to join Test Organization Display on Servala"
|
||||
)
|
||||
assert invitation_email.subject == "Welcome to Servala - Test Organization Display"
|
||||
assert "test@example.com" in invitation_email.to
|
||||
|
||||
welcome_email = mail.outbox[1]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue