Merge pull request 'October feature list' (#226) from october into main
Reviewed-on: #226
This commit is contained in:
commit
5431f6ab83
34 changed files with 1747 additions and 199 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
@ -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",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
@ -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",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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 %}
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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 %}
|
||||||
|
|
|
||||||
|
|
@ -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 %}
|
||||||
|
|
|
||||||
|
|
@ -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 %}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
31
src/servala/frontend/templates/includes/service_card.html
Normal file
31
src/servala/frontend/templates/includes/service_card.html
Normal 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>
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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())
|
||||||
|
|
|
||||||
|
|
@ -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):
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
|
||||||
83
src/servala/static/js/advanced-fields.js
Normal file
83
src/servala/static/js/advanced-fields.js
Normal 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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
|
@ -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'
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
38
src/servala/static/js/fqdn.js
Normal file
38
src/servala/static/js/fqdn.js
Normal 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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue