Merge pull request 'October feature list' (#226) from october into main
All checks were successful
Build and Deploy Staging / build (push) Successful in 41s
Tests / test (push) Successful in 26s
Build and Deploy Staging / deploy (push) Successful in 6s

Reviewed-on: #226
This commit is contained in:
Tobias Brunner 2025-10-22 13:43:33 +00:00
commit 5431f6ab83
34 changed files with 1747 additions and 199 deletions

View file

@ -1,6 +1,5 @@
import json import json
import logging import logging
from contextlib import suppress
from django.conf import settings from django.conf import settings
from django.contrib.auth.decorators import login_not_required 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.api.permissions import OSBBasicAuthPermission
from servala.core.exoscale import get_exoscale_origin 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 from servala.core.models.service import Service, ServiceOffering
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -102,66 +107,47 @@ class OSBServiceInstanceView(OSBBasicAuthPermission, View):
) )
exoscale_origin = get_exoscale_origin() exoscale_origin = get_exoscale_origin()
with suppress(Organization.DoesNotExist): try:
organization = Organization.objects.get( organization = Organization.objects.get(
osb_guid=organization_guid, origin=exoscale_origin osb_guid=organization_guid, origin=exoscale_origin
) )
self._send_service_welcome_email( if service in organization.limit_osb_services.all():
request, organization, user, service, service_offering return JsonResponse({"message": "Service already enabled"}, status=200)
) except Organization.DoesNotExist:
return JsonResponse({"message": "Service already enabled"}, status=200) 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 = { organization.limit_osb_services.add(service)
"company_name": organization_display_name, self._send_service_welcome_email(
"invoice_email": user.email, request, organization, user, service, service_offering
}
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( def _send_service_welcome_email(
self, request, organization, user, service, service_offering self, request, organization, user, service, service_offering

View file

@ -9,6 +9,7 @@ from servala.core.models import (
ControlPlane, ControlPlane,
ControlPlaneCRD, ControlPlaneCRD,
Organization, Organization,
OrganizationInvitation,
OrganizationMembership, OrganizationMembership,
OrganizationOrigin, OrganizationOrigin,
Service, Service,
@ -62,10 +63,15 @@ class OrganizationAdmin(admin.ModelAdmin):
search_fields = ("name", "namespace") search_fields = ("name", "namespace")
autocomplete_fields = ("billing_entity", "origin") autocomplete_fields = ("billing_entity", "origin")
inlines = (OrganizationMembershipInline,) inlines = (OrganizationMembershipInline,)
filter_horizontal = ("limit_osb_services",)
def get_readonly_fields(self, request, obj=None): def get_readonly_fields(self, request, obj=None):
readonly_fields = list(super().get_readonly_fields(request, obj) or []) readonly_fields = list(super().get_readonly_fields(request, obj) or [])
readonly_fields.append("namespace") # Always read-only readonly_fields.append("namespace") # Always read-only
if obj and obj.has_inherited_billing_entity:
readonly_fields.append("billing_entity")
return readonly_fields return readonly_fields
@ -77,8 +83,10 @@ class BillingEntityAdmin(admin.ModelAdmin):
@admin.register(OrganizationOrigin) @admin.register(OrganizationOrigin)
class OrganizationOriginAdmin(admin.ModelAdmin): class OrganizationOriginAdmin(admin.ModelAdmin):
list_display = ("name",) list_display = ("name", "billing_entity", "default_odoo_sale_order_id")
search_fields = ("name",) search_fields = ("name",)
autocomplete_fields = ("billing_entity",)
filter_horizontal = ("limit_cloudproviders",)
@admin.register(OrganizationMembership) @admin.register(OrganizationMembership)
@ -90,6 +98,58 @@ class OrganizationMembershipAdmin(admin.ModelAdmin):
date_hierarchy = "date_joined" 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) @admin.register(ServiceCategory)
class ServiceCategoryAdmin(admin.ModelAdmin): class ServiceCategoryAdmin(admin.ModelAdmin):
list_display = ("name", "parent") list_display = ("name", "parent")
@ -108,7 +168,6 @@ class ServiceAdmin(admin.ModelAdmin):
def get_form(self, request, obj=None, **kwargs): def get_form(self, request, obj=None, **kwargs):
form = super().get_form(request, obj, **kwargs) form = super().get_form(request, obj, **kwargs)
# JSON schema for external_links field
external_links_schema = { external_links_schema = {
"type": "array", "type": "array",
"title": "External Links", "title": "External Links",
@ -141,7 +200,6 @@ class CloudProviderAdmin(admin.ModelAdmin):
def get_form(self, request, obj=None, **kwargs): def get_form(self, request, obj=None, **kwargs):
form = super().get_form(request, obj, **kwargs) form = super().get_form(request, obj, **kwargs)
# JSON schema for external_links field
external_links_schema = { external_links_schema = {
"type": "array", "type": "array",
"title": "External Links", "title": "External Links",
@ -174,7 +232,15 @@ class ControlPlaneAdmin(admin.ModelAdmin):
fieldsets = ( fieldsets = (
( (
None, None,
{"fields": ("name", "description", "cloud_provider", "required_label")}, {
"fields": (
"name",
"description",
"cloud_provider",
"required_label",
"wildcard_dns",
)
},
), ),
( (
_("API Credentials"), _("API Credentials"),
@ -244,8 +310,35 @@ class ServiceDefinitionAdmin(admin.ModelAdmin):
"description": _("API definition for the Kubernetes Custom Resource"), "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): def get_exclude(self, request, obj=None):
# Exclude the original api_definition field as we're using our custom fields # Exclude the original api_definition field as we're using our custom fields
return ["api_definition"] return ["api_definition"]
@ -304,3 +397,23 @@ class ServiceOfferingAdmin(admin.ModelAdmin):
search_fields = ("description",) search_fields = ("description",)
autocomplete_fields = ("service", "provider") autocomplete_fields = ("service", "provider")
inlines = (ControlPlaneCRDInline,) 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

View file

@ -86,10 +86,117 @@ def build_object_fields(schema, name, verbose_name_prefix=None, parent_required=
def deslugify(title): 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: if "_" in title:
title.replace("_", " ") # Handle snake_case
return title.title() title = title.replace("_", " ")
return re.sub(r"(?<!^)(?=[A-Z])", " ", title).capitalize() 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)
def get_django_field( def get_django_field(
@ -220,6 +327,18 @@ class CrdModelFormMixin:
field.widget = forms.HiddenInput() field.widget = forms.HiddenInput()
field.required = False 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: if self.instance and self.instance.pk:
self.fields["name"].disabled = True self.fields["name"].disabled = True
self.fields["name"].help_text = _("Name cannot be changed after creation.") self.fields["name"].help_text = _("Name cannot be changed after creation.")
@ -236,6 +355,17 @@ class CrdModelFormMixin:
return True return True
return False 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): def get_fieldsets(self):
fieldsets = [] fieldsets = []
@ -251,6 +381,7 @@ class CrdModelFormMixin:
"fields": general_fields, "fields": general_fields,
"fieldsets": [], "fieldsets": [],
"has_mandatory": self.has_mandatory_fields(general_fields), "has_mandatory": self.has_mandatory_fields(general_fields),
"is_advanced": self.are_all_fields_advanced(general_fields),
} }
if all( if all(
[ [
@ -317,6 +448,9 @@ class CrdModelFormMixin:
title = f"{fieldset['title']}: {sub_fieldset['title']}: " title = f"{fieldset['title']}: {sub_fieldset['title']}: "
for field in sub_fieldset["fields"]: for field in sub_fieldset["fields"]:
self.strip_title(field, title) self.strip_title(field, title)
sub_fieldset["is_advanced"] = self.are_all_fields_advanced(
sub_fieldset["fields"]
)
nested_fieldsets_list.append(sub_fieldset) nested_fieldsets_list.append(sub_fieldset)
fieldset["fieldsets"] = nested_fieldsets_list fieldset["fieldsets"] = nested_fieldsets_list
@ -333,6 +467,8 @@ class CrdModelFormMixin:
all_fields.extend(sub_fieldset["fields"]) all_fields.extend(sub_fieldset["fields"])
fieldset["has_mandatory"] = self.has_mandatory_fields(all_fields) fieldset["has_mandatory"] = self.has_mandatory_fields(all_fields)
fieldset["is_advanced"] = self.are_all_fields_advanced(all_fields)
fieldsets.append(fieldset) fieldsets.append(fieldset)
# Add 'others' tab if there are any fields # Add 'others' tab if there are any fields
@ -343,6 +479,7 @@ class CrdModelFormMixin:
"fields": others, "fields": others,
"fieldsets": [], "fieldsets": [],
"has_mandatory": self.has_mandatory_fields(others), "has_mandatory": self.has_mandatory_fields(others),
"is_advanced": self.are_all_fields_advanced(others),
} }
) )
@ -406,7 +543,7 @@ class CrdModelFormMixin:
pass pass
def generate_model_form_class(model): def generate_model_form_class(model, advanced_fields=None):
meta_attrs = { meta_attrs = {
"model": model, "model": model,
"fields": "__all__", "fields": "__all__",
@ -414,6 +551,7 @@ def generate_model_form_class(model):
fields = { fields = {
"Meta": type("Meta", (object,), meta_attrs), "Meta": type("Meta", (object,), meta_attrs),
"__module__": "crd_models", "__module__": "crd_models",
"ADVANCED_FIELDS": advanced_fields or [],
} }
class_name = f"{model.__name__}ModelForm" class_name = f"{model.__name__}ModelForm"
return ModelFormMetaclass(class_name, (CrdModelFormMixin, ModelForm), fields) return ModelFormMetaclass(class_name, (CrdModelFormMixin, ModelForm), fields)

View file

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

View file

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

View file

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

View file

@ -1,6 +1,7 @@
from .organization import ( from .organization import (
BillingEntity, BillingEntity,
Organization, Organization,
OrganizationInvitation,
OrganizationMembership, OrganizationMembership,
OrganizationOrigin, OrganizationOrigin,
OrganizationRole, OrganizationRole,
@ -23,6 +24,7 @@ __all__ = [
"ControlPlane", "ControlPlane",
"ControlPlaneCRD", "ControlPlaneCRD",
"Organization", "Organization",
"OrganizationInvitation",
"OrganizationMembership", "OrganizationMembership",
"OrganizationOrigin", "OrganizationOrigin",
"OrganizationRole", "OrganizationRole",

View file

@ -1,7 +1,13 @@
import secrets
import rules import rules
import urlman import urlman
from auditlog.registry import auditlog
from django.conf import settings 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.db import models, transaction
from django.http import HttpRequest
from django.utils.functional import cached_property from django.utils.functional import cached_property
from django.utils.safestring import mark_safe from django.utils.safestring import mark_safe
from django.utils.text import slugify from django.utils.text import slugify
@ -46,6 +52,12 @@ class Organization(ServalaModelMixin, models.Model):
related_name="organizations", related_name="organizations",
verbose_name=_("Members"), 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( odoo_sale_order_id = models.IntegerField(
null=True, blank=True, verbose_name=_("Odoo Sale Order ID") null=True, blank=True, verbose_name=_("Odoo Sale Order ID")
@ -77,6 +89,18 @@ class Organization(ServalaModelMixin, models.Model):
def get_absolute_url(self): def get_absolute_url(self):
return self.urls.base 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): def set_owner(self, user):
with scopes_disabled(): with scopes_disabled():
OrganizationMembership.objects.filter(user=user, organization=self).delete() OrganizationMembership.objects.filter(user=user, organization=self).delete()
@ -94,7 +118,7 @@ class Organization(ServalaModelMixin, models.Model):
@classmethod @classmethod
@transaction.atomic @transaction.atomic
def create_organization(cls, instance, owner): def create_organization(cls, instance, owner=None):
try: try:
instance.origin instance.origin
except Exception: except Exception:
@ -102,9 +126,23 @@ class Organization(ServalaModelMixin, models.Model):
pk=settings.SERVALA_DEFAULT_ORIGIN pk=settings.SERVALA_DEFAULT_ORIGIN
) )
instance.save() 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 instance.billing_entity.odoo_company_id
and instance.billing_entity.odoo_invoice_id and instance.billing_entity.odoo_invoice_id
): ):
@ -131,6 +169,34 @@ class Organization(ServalaModelMixin, models.Model):
return instance 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: class Meta:
verbose_name = _("Organization") verbose_name = _("Organization")
verbose_name_plural = _("Organizations") verbose_name_plural = _("Organizations")
@ -313,6 +379,33 @@ class OrganizationOrigin(ServalaModelMixin, models.Model):
name = models.CharField(max_length=100, verbose_name=_("Name")) name = models.CharField(max_length=100, verbose_name=_("Name"))
description = models.TextField(blank=True, verbose_name=_("Description")) 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: class Meta:
verbose_name = _("Organization origin") verbose_name = _("Organization origin")
@ -361,3 +454,120 @@ class OrganizationMembership(ServalaModelMixin, models.Model):
def __str__(self): def __str__(self):
return f"{self.user} in {self.organization} as {self.role}" 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)

View file

@ -159,6 +159,15 @@ class ControlPlane(ServalaModelMixin, models.Model):
"Key-value information displayed to users when selecting this control plane" "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: class Meta:
verbose_name = _("Control plane") verbose_name = _("Control plane")
@ -350,6 +359,16 @@ class ServiceDefinition(ServalaModelMixin, models.Model):
null=True, null=True,
blank=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( service = models.ForeignKey(
to="Service", to="Service",
on_delete=models.CASCADE, on_delete=models.CASCADE,
@ -490,7 +509,10 @@ class ControlPlaneCRD(ServalaModelMixin, models.Model):
if not self.django_model: if not self.django_model:
return 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): class ServiceOffering(ServalaModelMixin, models.Model):
@ -511,6 +533,12 @@ class ServiceOffering(ServalaModelMixin, models.Model):
verbose_name=_("Provider"), verbose_name=_("Provider"),
) )
description = models.TextField(blank=True, verbose_name=_("Description")) 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( osb_plan_id = models.CharField(
max_length=100, max_length=100,
null=True, null=True,
@ -657,6 +685,7 @@ class ServiceInstance(ServalaModelMixin, models.Model):
return mark_safe(f"<ul>{error_items}</ul>") return mark_safe(f"<ul>{error_items}</ul>")
@classmethod @classmethod
@transaction.atomic
def create_instance(cls, name, organization, context, created_by, spec_data): def create_instance(cls, name, organization, context, created_by, spec_data):
# Ensure the namespace exists # Ensure the namespace exists
context.control_plane.get_or_create_namespace(organization) context.control_plane.get_or_create_namespace(organization)
@ -704,7 +733,7 @@ class ServiceInstance(ServalaModelMixin, models.Model):
body=create_data, body=create_data,
) )
except Exception as e: except Exception as e:
instance.delete() # Transaction will automatically roll back the instance creation
if isinstance(e, ApiException): if isinstance(e, ApiException):
try: try:
error_body = json.loads(e.body) error_body = json.loads(e.body)
@ -905,6 +934,9 @@ class ServiceInstance(ServalaModelMixin, models.Model):
import base64 import base64
for key, value in secret.data.items(): 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: try:
credentials[key] = base64.b64decode(value).decode("utf-8") credentials[key] = base64.b64decode(value).decode("utf-8")
except Exception: except Exception:

View file

@ -14,20 +14,26 @@ def has_organization_role(user, org, roles):
@rules.predicate @rules.predicate
def is_organization_owner(user, obj): def is_organization_owner(user, obj):
from servala.core.models.organization import OrganizationRole
if hasattr(obj, "organization"): if hasattr(obj, "organization"):
org = obj.organization org = obj.organization
else: else:
org = obj org = obj
return has_organization_role(user, org, ["owner"]) return has_organization_role(user, org, [OrganizationRole.OWNER])
@rules.predicate @rules.predicate
def is_organization_admin(user, obj): def is_organization_admin(user, obj):
from servala.core.models.organization import OrganizationRole
if hasattr(obj, "organization"): if hasattr(obj, "organization"):
org = obj.organization org = obj.organization
else: else:
org = obj org = obj
return has_organization_role(user, org, ["owner", "admin"]) return has_organization_role(
user, org, [OrganizationRole.OWNER, OrganizationRole.ADMIN]
)
@rules.predicate @rules.predicate

View file

@ -1,13 +1,18 @@
from django import forms from django import forms
from django.core.exceptions import ValidationError
from django.forms import ModelForm from django.forms import ModelForm
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from servala.core.models import Organization from servala.core.models import Organization, OrganizationInvitation, OrganizationRole
from servala.core.odoo import get_invoice_addresses, get_odoo_countries from servala.core.odoo import get_invoice_addresses, get_odoo_countries
from servala.frontend.forms.mixins import HtmxMixin from servala.frontend.forms.mixins import HtmxMixin
class OrganizationForm(HtmxMixin, ModelForm): 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: class Meta:
model = Organization model = Organization
fields = ("name",) fields = ("name",)
@ -46,7 +51,7 @@ class OrganizationCreateForm(OrganizationForm):
def __init__(self, *args, user=None, **kwargs): def __init__(self, *args, user=None, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.user = user
if not self.initial.get("invoice_country"): if not self.initial.get("invoice_country"):
default_country_name = "Switzerland" default_country_name = "Switzerland"
country_choices = self.fields["invoice_country"].choices country_choices = self.fields["invoice_country"].choices
@ -55,7 +60,6 @@ class OrganizationCreateForm(OrganizationForm):
self.initial["invoice_country"] = country_id self.initial["invoice_country"] = country_id
break break
self.user = user
self.odoo_addresses = get_invoice_addresses(self.user) self.odoo_addresses = get_invoice_addresses(self.user)
if self.odoo_addresses: if self.odoo_addresses:
@ -108,3 +112,68 @@ class OrganizationCreateForm(OrganizationForm):
"existing_odoo_address_id", _("Please select an invoice address.") "existing_odoo_address_id", _("Please select an invoice address.")
) )
return cleaned_data 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(),
}

View file

@ -21,6 +21,15 @@ class ServiceFilterForm(forms.Form):
) )
q = forms.CharField(label=_("Search"), required=False) 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): def filter_queryset(self, queryset):
if category := self.cleaned_data.get("category"): if category := self.cleaned_data.get("category"):
queryset = queryset.filter(category=category) queryset = queryset.filter(category=category)

View file

@ -80,13 +80,20 @@
<script src="{% static 'js/dynamic-array.js' %}"></script> <script src="{% static 'js/dynamic-array.js' %}"></script>
<!-- Ybug code start (https://ybug.io) --> <!-- Ybug code start (https://ybug.io) -->
<script type='text/javascript'> <script type='text/javascript'>
(function() { (function() {
window.ybug_settings = {"id":"q1tgbdjp26ydh8gygggv"}; window.ybug_settings = {
var ybug = document.createElement('script'); ybug.type = 'text/javascript'; ybug.async = true; "id": "q1tgbdjp26ydh8gygggv"
ybug.src = 'https://widget.ybug.io/button/'+window.ybug_settings.id+'.js'; };
var s = document.getElementsByTagName('script')[0]; s.parentNode.insertBefore(ybug, s); var ybug = document.createElement('script');
})(); ybug.type = 'text/javascript';
ybug.async = true;
ybug.src = 'https://widget.ybug.io/button/' + window.ybug_settings.id + '.js';
var s = document.getElementsByTagName('script')[0];
s.parentNode.insertBefore(ybug, s);
})();
</script> </script>
<!-- Ybug code end --> <!-- Ybug code end -->
{% block extra_js %}
{% endblock extra_js %}
</body> </body>
</html> </html>

View file

@ -1,6 +1,9 @@
<div class="dynamic-array-widget" <div class="dynamic-array-widget"
id="{{ widget.attrs.id|default:'id_'|add:widget.name }}_container" id="{{ widget.attrs.id|default:'id_'|add:widget.name }}_container"
data-name="{{ widget.name }}"> data-name="{{ widget.name }}"
{% for name, value in widget.attrs.items %}{% if value is not False and name != "id" and name != "class" %} {{ name }}{% if value is not True %}="{{ value|stringformat:'s' }}"{% endif %}
{% endif %}
{% endfor %}>
<div class="array-items"> <div class="array-items">
{% for item in value_list %} {% for item in value_list %}
<div class="array-item d-flex mb-2"> <div class="array-item d-flex mb-2">

View file

@ -0,0 +1,42 @@
{% extends "frontend/base.html" %}
{% load i18n %}
{% block html_title %}
{% block page_title %}
{% translate "Accept Organization Invitation" %}
{% endblock page_title %}
{% endblock html_title %}
{% block content %}
<section class="section">
<div class="card">
<div class="card-content">
<div class="card-body">
<div class="alert alert-info">
<i class="bi bi-info-circle"></i>
{% blocktranslate with org_name=invitation.organization.name role=invitation.get_role_display %}
You have been invited to join <strong>{{ org_name }}</strong> as a <strong>{{ role }}</strong>.
{% endblocktranslate %}
</div>
{% if user.email|lower != invitation.email|lower %}
<div class="alert alert-warning">
<i class="bi bi-exclamation-triangle"></i>
{% blocktranslate with invitation_email=invitation.email user_email=user.email %}
<strong>Note:</strong> This invitation was sent to <strong>{{ invitation_email }}</strong>,
but you are currently logged in as <strong>{{ user_email }}</strong>.
{% endblocktranslate %}
</div>
{% endif %}
<form method="post">
{% csrf_token %}
<div class="d-flex justify-content-end gap-2">
<a href="{% url 'frontend:organization.selection' %}"
class="btn btn-secondary">{% translate "Cancel" %}</a>
<button type="submit" class="btn btn-primary">
<i class="bi bi-check-circle"></i> {% translate "Accept Invitation" %}
</button>
</div>
</form>
</div>
</div>
</div>
</section>
{% endblock content %}

View file

@ -32,13 +32,7 @@
<h6 class="mb-3">{% translate "External Links" %}</h6> <h6 class="mb-3">{% translate "External Links" %}</h6>
<div class="d-flex flex-wrap gap-2"> <div class="d-flex flex-wrap gap-2">
{% for link in service.external_links %} {% for link in service.external_links %}
<a href="{{ link.url }}" {% include "includes/external_link.html" %}
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 %} {% endfor %}
</div> </div>
</div> </div>
@ -47,7 +41,7 @@
</div> </div>
</div> </div>
<div class="row match-height card-grid"> <div class="row match-height card-grid">
{% for offering in service.offerings.all %} {% for offering in visible_offerings %}
<div class="col-6 col-lg-3 col-md-4"> <div class="col-6 col-lg-3 col-md-4">
<div class="card"> <div class="card">
<div class="card-header card-header-with-logo"> <div class="card-header card-header-with-logo">

View file

@ -173,34 +173,36 @@
</div> </div>
{% endif %} {% endif %}
{% if instance.connection_credentials %} {% if instance.connection_credentials %}
<div class="card"> <div class="col-12">
<div class="card-header"> <div class="card">
<h4>{% translate "Connection Credentials" %}</h4> <div class="card-header">
</div> <h4>{% translate "Connection Credentials" %}</h4>
<div class="card-body"> </div>
<div class="table-responsive"> <div class="card-body">
<table class="table table-bordered"> <div class="table-responsive">
<thead> <table class="table table-bordered">
<tr> <thead>
<th>{% translate "Name" %}</th>
<th>{% translate "Value" %}</th>
</tr>
</thead>
<tbody>
{% for key, value in instance.connection_credentials.items %}
<tr> <tr>
<td>{{ key }}</td> <th>{% translate "Name" %}</th>
<td> <th>{% translate "Value" %}</th>
{% if key == "error" %}
<span class="text-danger">{{ value }}</span>
{% else %}
<code>{{ value }}</code>
{% endif %}
</td>
</tr> </tr>
{% endfor %} </thead>
</tbody> <tbody>
</table> {% for key, value in instance.connection_credentials.items %}
<tr>
<td>{{ key }}</td>
<td>
{% if key == "error" %}
<span class="text-danger">{{ value }}</span>
{% else %}
<code>{{ value }}</code>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div> </div>
</div> </div>
</div> </div>

View file

@ -64,19 +64,16 @@
{{ select_form }} {{ select_form }}
</form> </form>
{% endif %} {% endif %}
{% if service.external_links %} {% if service.external_links or offering.external_links %}
<div class="row mt-3"> <div class="row mt-3">
<div class="col-12"> <div class="col-12">
<h6 class="mb-3">{% translate "External Links" %}</h6> <h6 class="mb-3">{% translate "External Links" %}</h6>
<div class="d-flex flex-wrap gap-2"> <div class="d-flex flex-wrap gap-2">
{% for link in service.external_links %} {% for link in service.external_links %}
<a href="{{ link.url }}" {% include "includes/external_link.html" %}
target="_blank" {% endfor %}
rel="noopener noreferrer" {% for link in offering.external_links %}
class="btn btn-outline-primary btn-sm"> {% include "includes/external_link.html" %}
{{ link.title }}
<i class="bi bi-box-arrow-up-right ms-1"></i>
</a>
{% endfor %} {% endfor %}
</div> </div>
</div> </div>
@ -96,3 +93,14 @@
</div> </div>
</section> </section>
{% endblock content %} {% 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 %}

View file

@ -16,50 +16,37 @@
</div> </div>
</div> </div>
</div> </div>
<div class="row match-height card-grid service-cards-container mb-5"> <div class="row match-height card-grid service-cards-container {% if not deactivated_services %}mb-5{% endif %}">
{% for service in services %} {% for service in services %}
<div class="col-12 col-md-6 col-lg-3"> <div class="col-12 col-md-6 col-lg-3">{% include "includes/service_card.html" %}</div>
<div class="card">
<div class="card-header card-header-with-logo">
{% if service.logo %}<img src="{{ service.logo.url }}" alt="{{ service.name }}">{% endif %}
<div class="card-header-content">
<h4>{{ service.name }}</h4>
<small class="text-muted">{{ service.category }}</small>
</div>
</div>
<div class="card-content">
<div class="card-body flex-grow-1">
{% if service.description %}<p class="card-text">{{ service.description|urlize }}</p>{% endif %}
</div>
</div>
<div class="card-footer d-flex justify-content-between align-items-center gap-2">
{% if service.featured_links %}
{% with featured_link=service.featured_links.0 %}
<a href="{{ featured_link.url }}"
target="_blank"
rel="noopener noreferrer"
class="btn btn-outline-primary">
{{ featured_link.title }}
<i class="bi bi-box-arrow-up-right ms-1"></i>
</a>
{% endwith %}
{% else %}
<span></span>
{% endif %}
<a href="{{ service.slug }}/" class="btn btn-light-primary">{% translate "View Availability" %}</a>
</div>
</div>
</div>
{% empty %} {% empty %}
<div class="card"> <div class="col-12">
<div class="card-body"> <div class="card">
<div class="card-content"> <div class="card-body">
<p>{% translate "No services found." %}</p> <div class="card-content">
<p>{% translate "No services found." %}</p>
</div>
</div> </div>
</div> </div>
</div> </div>
{% endfor %} {% endfor %}
</div> </div>
{% if deactivated_services %}
<div class="card">
<div class="card-body">
<h5 class="card-title">{% translate "You may also be interested in one of these …" %}</h5>
<p class="text-muted">
<i class="bi bi-info-circle mt-1"></i>
{% translate "These services need to be enabled first before they become available in the Servala portal." %}
</p>
</div>
</div>
<div class="row match-height card-grid service-cards-container mb-5">
{% for service in deactivated_services %}
<div class="col-12 col-md-6 col-lg-3 service-deactivated">{% include "includes/service_card.html" %}</div>
{% endfor %}
</div>
{% endif %}
</section> </section>
<script src="{% static "js/autosubmit.js" %}" defer></script> <script src="{% static "js/autosubmit.js" %}" defer></script>
{% endblock content %} {% endblock content %}

View file

@ -36,6 +36,97 @@
</form> </form>
</td> </td>
{% endpartialdef org-name-edit %} {% 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>
{% endpartialdef members-list %}
{% partialdef pending-invitations-card %}
{% if pending_invitations %}
<div class="card">
<div class="card-header">
<h4 class="card-title">
<i class="bi bi-envelope"></i> {% translate "Pending Invitations" %}
</h4>
</div>
<div class="card-content">
<div class="card-body">
<div class="table-responsive">
<table class="table table-hover">
<thead>
<tr>
<th>{% translate "Email" %}</th>
<th>{% translate "Role" %}</th>
<th>{% translate "Sent" %}</th>
<th>{% translate "Actions" %}</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>
<form method="post"
action="{{ invitation.urls.delete }}"
style="display: inline"
hx-post="{{ invitation.urls.delete }}"
hx-target="#pending-invitations-card"
hx-swap="outerHTML"
hx-confirm="{% translate 'Are you sure you want to delete this invitation?' %}">
{% csrf_token %}
<input type="hidden" name="fragment" value="pending-invitations-card">
<button type="submit" class="btn btn-sm btn-outline-danger">
<i class="bi bi-trash"></i> {% translate "Delete" %}
</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div>
{% endif %}
{% endpartialdef pending-invitations-card %}
{% block content %} {% block content %}
<section class="section"> <section class="section">
<div class="card"> <div class="card">
@ -69,6 +160,11 @@
<div class="card"> <div class="card">
<div class="card-header"> <div class="card-header">
<h4 class="card-title">{% translate "Billing Address" %}</h4> <h4 class="card-title">{% translate "Billing Address" %}</h4>
{% if form.instance.has_inherited_billing_entity %}
<p class="text-muted">
<small>{% translate "This billing address cannot be modified." %}</small>
</p>
{% endif %}
</div> </div>
<div class="card-content"> <div class="card-content">
<div class="card-body"> <div class="card-body">
@ -130,5 +226,56 @@
</div> </div>
</div> </div>
{% endif %} {% 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 id="pending-invitations-card">{% partial pending-invitations-card %}</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">
<div class="alert alert-light mb-3">
<h6>
<i class="bi bi-info-circle"></i> {% translate "Role Permissions" %}
</h6>
<ul class="mb-0">
<li>
<strong>{% translate "Owner" %}:</strong> {% translate "Can manage all organization settings, members, services, and can appoint administrators." %}
</li>
<li>
<strong>{% translate "Administrator" %}:</strong> {% translate "Can manage members, invite users, and manage all services and instances." %}
</li>
<li>
<strong>{% translate "Member" %}:</strong> {% translate "Can view organization details, create and manage their own service instances." %}
</li>
</ul>
</div>
<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> </section>
{% endblock content %} {% endblock content %}

View file

@ -0,0 +1,7 @@
<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>

View file

@ -0,0 +1,31 @@
{% load i18n %}
<div class="card">
<div class="card-header card-header-with-logo">
{% if service.logo %}<img src="{{ service.logo.url }}" alt="{{ service.name }}">{% endif %}
<div class="card-header-content">
<h4>{{ service.name }}</h4>
<small class="text-muted">{{ service.category }}</small>
</div>
</div>
<div class="card-content">
<div class="card-body flex-grow-1">
{% if service.description %}<p class="card-text">{{ service.description|urlize }}</p>{% endif %}
</div>
</div>
<div class="card-footer d-flex justify-content-between align-items-center gap-2">
{% if service.featured_links %}
{% with featured_link=service.featured_links.0 %}
<a href="{{ featured_link.url }}"
target="_blank"
rel="noopener noreferrer"
class="btn btn-outline-primary">
{{ featured_link.title }}
<i class="bi bi-box-arrow-up-right ms-1"></i>
</a>
{% endwith %}
{% else %}
<span></span>
{% endif %}
<a href="{{ service.slug }}/" class="btn btn-light-primary">{% translate "View Availability" %}</a>
</div>
</div>

View file

@ -1,14 +1,28 @@
{% load i18n %} {% load i18n %}
{% load get_field %} {% load get_field %}
{% load static %}
<form class="form form-vertical crd-form" <form class="form form-vertical crd-form"
method="post" method="post"
{% if form_action %}action="{{ form_action }}"{% endif %}> {% if form_action %}action="{{ form_action }}"{% endif %}>
{% csrf_token %} {% csrf_token %}
{% include "frontend/forms/errors.html" %} {% 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"> <ul class="nav nav-tabs" id="myTab" role="tablist">
{% for fieldset in form.get_fieldsets %} {% for fieldset in form.get_fieldsets %}
{% if not fieldset.hidden %} {% if not fieldset.hidden %}
<li class="nav-item" role="presentation"> <li class="nav-item{% if fieldset.is_advanced %} advanced-field-group collapse{% endif %}"
role="presentation">
<button class="nav-link {% if forloop.first %}active{% endif %}{% if fieldset.has_mandatory %} has-mandatory{% endif %}" <button class="nav-link {% if forloop.first %}active{% endif %}{% if fieldset.has_mandatory %} has-mandatory{% endif %}"
id="{{ fieldset.title|slugify }}-tab" id="{{ fieldset.title|slugify }}-tab"
data-bs-toggle="tab" data-bs-toggle="tab"
@ -35,10 +49,12 @@
{% endfor %} {% endfor %}
{% for subfieldset in fieldset.fieldsets %} {% for subfieldset in fieldset.fieldsets %}
{% if subfieldset.fields %} {% if subfieldset.fields %}
<h4 class="mt-3">{{ subfieldset.title }}</h4> <div {% if subfieldset.is_advanced %}class="advanced-field-group collapse"{% endif %}>
{% for field in subfieldset.fields %} <h4 class="mt-3">{{ subfieldset.title }}</h4>
{% with field=form|get_field:field %}{{ field.as_field_group }}{% endwith %} {% for field in subfieldset.fields %}
{% endfor %} {% with field=form|get_field:field %}{{ field.as_field_group }}{% endwith %}
{% endfor %}
</div>
{% endif %} {% endif %}
{% endfor %} {% endfor %}
</div> </div>
@ -54,3 +70,4 @@
</button> </button>
</div> </div>
</form> </form>
<script defer src="{% static 'js/advanced-fields.js' %}"></script>

View file

@ -1,5 +1,7 @@
import os import os
from django import template from django import template
from servala.__about__ import __version__ from servala.__about__ import __version__
register = template.Library() register = template.Library()
@ -8,7 +10,7 @@ register = template.Library()
@register.simple_tag @register.simple_tag
def get_version_or_env(): def get_version_or_env():
"""Return version number in production, environment name otherwise.""" """Return version number in production, environment name otherwise."""
env = os.environ.get('SERVALA_ENVIRONMENT', 'development') env = os.environ.get("SERVALA_ENVIRONMENT", "development")
if env == 'production': if env == "production":
return __version__ return __version__
return env return env

View file

@ -6,6 +6,11 @@ from servala.frontend import views
urlpatterns = [ urlpatterns = [
path("accounts/profile/", views.ProfileView.as_view(), name="profile"), path("accounts/profile/", views.ProfileView.as_view(), name="profile"),
path("accounts/logout/", views.LogoutView.as_view(), name="logout"), path("accounts/logout/", views.LogoutView.as_view(), name="logout"),
path(
"invitations/<str:secret>/accept/",
views.InvitationAcceptView.as_view(),
name="invitation.accept",
),
path( path(
"organizations/", "organizations/",
views.OrganizationSelectionView.as_view(), views.OrganizationSelectionView.as_view(),
@ -25,6 +30,11 @@ urlpatterns = [
views.OrganizationUpdateView.as_view(), views.OrganizationUpdateView.as_view(),
name="organization.details", name="organization.details",
), ),
path(
"details/invitations/<int:pk>/delete/",
views.InvitationDeleteView.as_view(),
name="invitation.delete",
),
path( path(
"services/", "services/",
views.ServiceListView.as_view(), views.ServiceListView.as_view(),

View file

@ -8,6 +8,8 @@ from .generic import (
custom_500, custom_500,
) )
from .organization import ( from .organization import (
InvitationAcceptView,
InvitationDeleteView,
OrganizationCreateView, OrganizationCreateView,
OrganizationDashboardView, OrganizationDashboardView,
OrganizationUpdateView, OrganizationUpdateView,
@ -25,6 +27,8 @@ from .support import SupportView
__all__ = [ __all__ = [
"IndexView", "IndexView",
"InvitationAcceptView",
"InvitationDeleteView",
"LogoutView", "LogoutView",
"OrganizationCreateView", "OrganizationCreateView",
"OrganizationDashboardView", "OrganizationDashboardView",

View file

@ -1,16 +1,31 @@
from django.shortcuts import redirect from django.contrib import messages
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.utils.translation import gettext_lazy as _
from django.views.generic import CreateView, DetailView from django.views.generic import CreateView, DeleteView, DetailView, TemplateView
from django_scopes import scopes_disabled
from rules.contrib.views import AutoPermissionRequiredMixin from rules.contrib.views import AutoPermissionRequiredMixin
from servala.core.models import ( from servala.core.models import (
BillingEntity, BillingEntity,
Organization, Organization,
OrganizationInvitation,
OrganizationMembership, OrganizationMembership,
ServiceInstance, ServiceInstance,
) )
from servala.frontend.forms.organization import OrganizationCreateForm, OrganizationForm from servala.frontend.forms.organization import (
from servala.frontend.views.mixins import HtmxUpdateView, OrganizationViewMixin OrganizationCreateForm,
OrganizationForm,
OrganizationInvitationForm,
)
from servala.frontend.views.mixins import (
HtmxUpdateView,
HtmxViewMixin,
OrganizationViewMixin,
)
class OrganizationCreateView(AutoPermissionRequiredMixin, CreateView): class OrganizationCreateView(AutoPermissionRequiredMixin, CreateView):
@ -96,10 +111,225 @@ class OrganizationDashboardView(
return context return context
class OrganizationUpdateView(OrganizationViewMixin, HtmxUpdateView): class OrganizationMembershipMixin:
template_name = "frontend/organizations/update.html" template_name = "frontend/organizations/update.html"
@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.request.user.has_perm(
"core.change_organization", self.request.organization
)
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
class OrganizationUpdateView(
OrganizationViewMixin, OrganizationMembershipMixin, HtmxUpdateView
):
form_class = OrganizationForm form_class = OrganizationForm
fragments = ("org-name", "org-name-edit") fragments = (
"org-name",
"org-name-edit",
"members-list",
"pending-invitations-card",
)
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():
for error_msg in error:
messages.error(request, error_msg)
if self.is_htmx and self._get_fragment():
return self.get(request, *self.args, **self.kwargs)
return redirect(self.get_success_url())
def get_success_url(self): def get_success_url(self):
return self.request.path return self.request.path
@method_decorator(scopes_disabled(), name="dispatch")
class InvitationAcceptView(TemplateView):
template_name = "frontend/organizations/invitation_accept.html"
def get_invitation(self):
secret = self.kwargs.get("secret")
return get_object_or_404(OrganizationInvitation, secret=secret)
def dispatch(self, request, *args, **kwargs):
invitation = self.get_invitation()
if invitation.is_accepted:
messages.warning(
request,
_("This invitation has already been accepted."),
)
return redirect("frontend:organization.selection")
if not request.user.is_authenticated:
request.session["invitation_next"] = request.path
messages.info(
request,
_("Please log in or sign up to accept this invitation."),
)
return redirect(f"{reverse('account_login')}?next={request.path}")
return super().dispatch(request, *args, **kwargs)
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context["invitation"] = self.get_invitation()
return context
def post(self, request, *args, **kwargs):
invitation = self.get_invitation()
invitation.accepted_by = request.user
invitation.accepted_at = timezone.now()
invitation.save()
OrganizationMembership.objects.get_or_create(
user=request.user,
organization=invitation.organization,
defaults={"role": invitation.role},
)
messages.success(
request,
_("You have successfully joined {organization}!").format(
organization=invitation.organization.name
),
)
request.session.pop("invitation_next", None)
return redirect(invitation.organization.urls.base)
class InvitationDeleteView(HtmxViewMixin, OrganizationMembershipMixin, DeleteView):
model = OrganizationInvitation
http_method_names = ["get", "post"]
fragments = ("pending-invitations-card",)
def get_queryset(self):
return OrganizationInvitation.objects.filter(accepted_by__isnull=True)
def get_success_url(self):
return self.object.organization.urls.details
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
organization = self.request.organization
context["pending_invitations"] = OrganizationInvitation.objects.filter(
organization=organization, accepted_by__isnull=True
).order_by("-created_at")
return context
def _check_permission(self):
return self.request.user.has_perm(
"core.change_organization", self.request.organization
)
def get_object(self):
if self.request.method == "POST" and self.is_htmx:
try:
return super().get_object()
except Exception:
return
return super().get_object()
def post(self, request, *args, **kwargs):
self.object = self.get_object()
organization = self.object.organization
if not self._check_permission():
if not self.is_htmx:
messages.error(
request,
_("You do not have permission to delete this invitation."),
)
return redirect(organization.urls.details)
email = self.object.email
self.object.delete()
if not self.is_htmx:
messages.success(
request,
_("Invitation for {email} has been deleted.").format(email=email),
)
if self.is_htmx and self._get_fragment():
return self.get(request, *args, **kwargs)
return redirect(self.get_success_url())

View file

@ -1,6 +1,6 @@
from django.contrib import messages from django.contrib import messages
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.http import HttpResponse, Http404 from django.http import Http404, HttpResponse
from django.shortcuts import redirect from django.shortcuts import redirect
from django.utils.functional import cached_property from django.utils.functional import cached_property
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
@ -36,22 +36,24 @@ class ServiceListView(OrganizationViewMixin, ListView):
def get_queryset(self): def get_queryset(self):
"""Return all services.""" """Return all services."""
services = ( services = self.request.organization.get_visible_services()
Service.objects.all()
.select_related("category")
.prefetch_related("offerings__provider")
)
if self.filter_form.is_valid(): if self.filter_form.is_valid():
services = self.filter_form.filter_queryset(services) services = self.filter_form.filter_queryset(services)
return services.distinct() return services.distinct()
@cached_property @cached_property
def filter_form(self): def filter_form(self):
return ServiceFilterForm(data=self.request.GET or None) return ServiceFilterForm(
data=self.request.GET or None, organization=self.request.organization
)
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs) context = super().get_context_data(**kwargs)
context["filter_form"] = self.filter_form context["filter_form"] = self.filter_form
context["deactivated_services"] = (
self.request.organization.get_deactivated_services()
)
return context return context
@ -62,10 +64,17 @@ class ServiceDetailView(OrganizationViewMixin, DetailView):
permission_type = "view" permission_type = "view"
def get_queryset(self): def get_queryset(self):
return Service.objects.select_related("category").prefetch_related( return self.request.organization.get_visible_services()
"offerings",
"offerings__provider", def get_context_data(self, **kwargs):
) context = super().get_context_data(**kwargs)
offerings = context["service"].offerings.all()
if self.request.organization.limit_cloudproviders.exists():
offerings = offerings.filter(
provider__in=self.request.organization.limit_cloudproviders.all()
)
context["visible_offerings"] = offerings.select_related("provider")
return context
class ServiceOfferingDetailView(OrganizationViewMixin, HtmxViewMixin, DetailView): class ServiceOfferingDetailView(OrganizationViewMixin, HtmxViewMixin, DetailView):
@ -76,7 +85,14 @@ class ServiceOfferingDetailView(OrganizationViewMixin, HtmxViewMixin, DetailView
fragments = ("service-form", "control-plane-info") fragments = ("service-form", "control-plane-info")
def has_permission(self): def has_permission(self):
return self.has_organization_permission() if not self.has_organization_permission():
return False
if self.request.organization.limit_cloudproviders.exists():
return (
self.get_object().provider
in self.request.organization.limit_cloudproviders.all()
)
return True
def get_queryset(self): def get_queryset(self):
return ServiceOffering.objects.all().select_related( return ServiceOffering.objects.all().select_related(
@ -118,12 +134,25 @@ class ServiceOfferingDetailView(OrganizationViewMixin, HtmxViewMixin, DetailView
def get_instance_form(self): def get_instance_form(self):
if not self.context_object or not self.context_object.model_form_class: if not self.context_object or not self.context_object.model_form_class:
return None return None
return self.context_object.model_form_class(
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, data=self.request.POST if self.request.method == "POST" else None,
initial={ initial=initial,
"organization": self.request.organization,
"context": self.context_object,
},
) )
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
@ -132,6 +161,10 @@ class ServiceOfferingDetailView(OrganizationViewMixin, HtmxViewMixin, DetailView
context["has_control_planes"] = self.planes.exists() context["has_control_planes"] = self.planes.exists()
context["selected_plane"] = self.selected_plane context["selected_plane"] = self.selected_plane
context["service_form"] = self.get_instance_form() 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 return context
def post(self, request, *args, **kwargs): def post(self, request, *args, **kwargs):

View file

@ -279,3 +279,56 @@ html[data-bs-theme="dark"] .crd-form .nav-tabs .nav-link .mandatory-indicator {
.crd-form .nav-tabs .nav-link.has-mandatory { .crd-form .nav-tabs .nav-link.has-mandatory {
position: relative; position: relative;
} }
.service-deactivated .card {
opacity: 50%;
cursor: not-allowed;
img {
opacity: 75%
}
h4, small, p {
color: var(--bs-secondary-color) !important;
}
a.btn-outline-secondary {
color: var(--bs-btn-disabled-color) !important;
background-color: var(--bs-btn-disabled-bg) !important;
border-color: var(--bs-btn-disabled-border-color) !important;
opacity: var(--bs-btn-disabled-opacity);
}
a.btn-secondary {
color: white !important;
}
a.btn {
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;
}

View file

@ -0,0 +1,83 @@
/**
* 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);
}
});
})();

View file

@ -7,6 +7,10 @@ const initDynamicArrayWidget = () => {
const containers = document.querySelectorAll('.dynamic-array-widget') const containers = document.querySelectorAll('.dynamic-array-widget')
containers.forEach(container => { containers.forEach(container => {
if (container.dataset.initialized === 'true') {
return
}
const itemsContainer = container.querySelector('.array-items') const itemsContainer = container.querySelector('.array-items')
const addButton = container.querySelector('.add-array-item') const addButton = container.querySelector('.add-array-item')
const hiddenInput = container.querySelector('input[type="hidden"]') const hiddenInput = container.querySelector('input[type="hidden"]')
@ -22,6 +26,7 @@ const initDynamicArrayWidget = () => {
// Ensure hidden input is synced with visible inputs on initialization // Ensure hidden input is synced with visible inputs on initialization
updateHiddenInput(container) updateHiddenInput(container)
container.dataset.initialized = 'true'
}) })
} }

View file

@ -0,0 +1,38 @@
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();
}
});

View file

@ -3,6 +3,7 @@ import base64
import pytest import pytest
from servala.core.models import ( from servala.core.models import (
BillingEntity,
Organization, Organization,
OrganizationMembership, OrganizationMembership,
OrganizationOrigin, OrganizationOrigin,
@ -21,6 +22,11 @@ def origin():
return OrganizationOrigin.objects.create(name="TESTORIGIN") return OrganizationOrigin.objects.create(name="TESTORIGIN")
@pytest.fixture
def billing_entity():
return BillingEntity.objects.create(name="Test Entity")
@pytest.fixture @pytest.fixture
def organization(origin): def organization(origin):
return Organization.objects.create(name="Test Org", origin=origin) return Organization.objects.create(name="Test Org", origin=origin)

View file

@ -73,12 +73,8 @@ def test_successful_onboarding_new_organization(
assert org.origin == exoscale_origin assert org.origin == exoscale_origin
assert org.namespace.startswith("org-") 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(): with scopes_disabled():
membership = org.memberships.get(user=user) assert org.invitations.all().filter(email="test@example.com").exists()
assert membership.role == "owner"
billing_entity = org.billing_entity billing_entity = org.billing_entity
assert billing_entity.name == "Test Organization Display (Exoscale)" assert billing_entity.name == "Test Organization Display (Exoscale)"
@ -87,10 +83,14 @@ def test_successful_onboarding_new_organization(
assert org.odoo_sale_order_id == 789 assert org.odoo_sale_order_id == 789
assert org.odoo_sale_order_name == "SO001" assert org.odoo_sale_order_name == "SO001"
assert org.limit_osb_services.all().count() == 1
assert len(mail.outbox) == 2 assert len(mail.outbox) == 2
invitation_email = mail.outbox[0] invitation_email = mail.outbox[0]
assert invitation_email.subject == "Welcome to Servala - Test Organization Display" assert (
invitation_email.subject
== "You're invited to join Test Organization Display on Servala"
)
assert "test@example.com" in invitation_email.to assert "test@example.com" in invitation_email.to
welcome_email = mail.outbox[1] welcome_email = mail.outbox[1]
@ -98,6 +98,36 @@ def test_successful_onboarding_new_organization(
assert "redis/offering/" in welcome_email.body assert "redis/offering/" in welcome_email.body
@pytest.mark.django_db
def test_new_organization_inherits_origin(
osb_client,
test_service,
test_service_offering,
valid_osb_payload,
exoscale_origin,
instance_id,
billing_entity,
):
valid_osb_payload["service_id"] = test_service.osb_service_id
valid_osb_payload["plan_id"] = test_service_offering.osb_plan_id
exoscale_origin.billing_entity = billing_entity
exoscale_origin.save()
response = osb_client.put(
f"/api/osb/v2/service_instances/{instance_id}",
data=json.dumps(valid_osb_payload),
content_type="application/json",
)
assert response.status_code == 201
response_data = json.loads(response.content)
assert response_data["message"] == "Successfully enabled service"
org = Organization.objects.get(osb_guid="test-org-guid-123")
assert org.name == "Test Organization Display"
assert org.billing_entity == exoscale_origin.billing_entity
@pytest.mark.django_db @pytest.mark.django_db
def test_duplicate_organization_returns_existing( def test_duplicate_organization_returns_existing(
osb_client, osb_client,
@ -107,11 +137,12 @@ def test_duplicate_organization_returns_existing(
exoscale_origin, exoscale_origin,
instance_id, instance_id,
): ):
Organization.objects.create( org = Organization.objects.create(
name="Existing Org", name="Existing Org",
osb_guid="test-org-guid-123", osb_guid="test-org-guid-123",
origin=exoscale_origin, origin=exoscale_origin,
) )
org.limit_osb_services.add(test_service)
valid_osb_payload["service_id"] = test_service.osb_service_id valid_osb_payload["service_id"] = test_service.osb_service_id
valid_osb_payload["plan_id"] = test_service_offering.osb_plan_id valid_osb_payload["plan_id"] = test_service_offering.osb_plan_id
@ -126,7 +157,7 @@ def test_duplicate_organization_returns_existing(
response_data = json.loads(response.content) response_data = json.loads(response.content)
assert response_data["message"] == "Service already enabled" assert response_data["message"] == "Service already enabled"
assert Organization.objects.filter(osb_guid="test-org-guid-123").count() == 1 assert Organization.objects.filter(osb_guid="test-org-guid-123").count() == 1
assert len(mail.outbox) == 1 # Only one email was sent assert len(mail.outbox) == 0 # No email necessary
@pytest.mark.django_db @pytest.mark.django_db