diff --git a/src/servala/api/views.py b/src/servala/api/views.py index 456f4b2..48845d0 100644 --- a/src/servala/api/views.py +++ b/src/servala/api/views.py @@ -1,5 +1,6 @@ import json import logging +from contextlib import suppress from django.conf import settings from django.contrib.auth.decorators import login_not_required @@ -12,13 +13,7 @@ from django.views.decorators.csrf import csrf_exempt from servala.api.permissions import OSBBasicAuthPermission from servala.core.exoscale import get_exoscale_origin -from servala.core.models import ( - BillingEntity, - Organization, - OrganizationInvitation, - OrganizationRole, - User, -) +from servala.core.models import BillingEntity, Organization, User from servala.core.models.service import Service, ServiceOffering logger = logging.getLogger(__name__) @@ -107,47 +102,66 @@ class OSBServiceInstanceView(OSBBasicAuthPermission, View): ) exoscale_origin = get_exoscale_origin() - try: + with suppress(Organization.DoesNotExist): organization = Organization.objects.get( osb_guid=organization_guid, origin=exoscale_origin ) - if service in organization.limit_osb_services.all(): - return JsonResponse({"message": "Service already enabled"}, status=200) - except Organization.DoesNotExist: - try: - with transaction.atomic(): - if exoscale_origin.billing_entity: - billing_entity = exoscale_origin.billing_entity - else: - odoo_data = { - "company_name": organization_display_name, - "invoice_email": user.email, - } - billing_entity = BillingEntity.create_from_data( - name=f"{organization_display_name} (Exoscale)", - odoo_data=odoo_data, - ) - organization = Organization( - name=organization_display_name, - billing_entity=billing_entity, - origin=exoscale_origin, - osb_guid=organization_guid, - ) - organization = Organization.create_organization(organization) - invitation = OrganizationInvitation.objects.create( - organization=organization, - email=user.email.lower(), - role=OrganizationRole.OWNER, - ) - invitation.send_invitation_email(request) - except Exception: - return JsonResponse({"error": "Internal server error"}, status=500) + self._send_service_welcome_email( + request, organization, user, service, service_offering + ) + return JsonResponse({"message": "Service already enabled"}, status=200) - organization.limit_osb_services.add(service) - self._send_service_welcome_email( - request, organization, user, service, service_offering + odoo_data = { + "company_name": organization_display_name, + "invoice_email": user.email, + } + try: + with transaction.atomic(): + billing_entity = BillingEntity.create_from_data( + name=f"{organization_display_name} (Exoscale)", odoo_data=odoo_data + ) + organization = Organization( + name=organization_display_name, + billing_entity=billing_entity, + origin=exoscale_origin, + osb_guid=organization_guid, + ) + organization = Organization.create_organization(organization, user) + + self._send_invitation_email(request, organization, user) + self._send_service_welcome_email( + request, organization, user, service, service_offering + ) + + return JsonResponse( + {"message": "Successfully enabled service"}, status=201 + ) + + except Exception as e: + logger.error(f"Error creating organization for Exoscale: {str(e)}") + return JsonResponse({"error": "Internal server error"}, status=500) + + def _send_invitation_email(self, request, organization, user): + subject = f"Welcome to Servala - {organization.name}" + url = request.build_absolute_uri(organization.urls.base) + message = f"""Hello {user.first_name or user.email}, + +You have been invited to join the organization "{organization.name}" on Servala Portal. + +You can access your organization at: {url} + +Please use this email address ({user.email}) when prompted to log in. + +Best regards, +The Servala Team""" + + send_mail( + subject=subject, + message=message, + from_email=settings.EMAIL_DEFAULT_FROM, + recipient_list=[user.email], + fail_silently=False, ) - return JsonResponse({"message": "Successfully enabled service"}, status=201) def _send_service_welcome_email( self, request, organization, user, service, service_offering diff --git a/src/servala/core/admin.py b/src/servala/core/admin.py index 87da376..073d444 100644 --- a/src/servala/core/admin.py +++ b/src/servala/core/admin.py @@ -9,7 +9,6 @@ from servala.core.models import ( ControlPlane, ControlPlaneCRD, Organization, - OrganizationInvitation, OrganizationMembership, OrganizationOrigin, Service, @@ -63,15 +62,10 @@ class OrganizationAdmin(admin.ModelAdmin): search_fields = ("name", "namespace") autocomplete_fields = ("billing_entity", "origin") inlines = (OrganizationMembershipInline,) - filter_horizontal = ("limit_osb_services",) def get_readonly_fields(self, request, obj=None): readonly_fields = list(super().get_readonly_fields(request, obj) or []) readonly_fields.append("namespace") # Always read-only - - if obj and obj.has_inherited_billing_entity: - readonly_fields.append("billing_entity") - return readonly_fields @@ -83,10 +77,8 @@ class BillingEntityAdmin(admin.ModelAdmin): @admin.register(OrganizationOrigin) class OrganizationOriginAdmin(admin.ModelAdmin): - list_display = ("name", "billing_entity", "default_odoo_sale_order_id") + list_display = ("name",) search_fields = ("name",) - autocomplete_fields = ("billing_entity",) - filter_horizontal = ("limit_cloudproviders",) @admin.register(OrganizationMembership) @@ -98,58 +90,6 @@ class OrganizationMembershipAdmin(admin.ModelAdmin): date_hierarchy = "date_joined" -@admin.register(OrganizationInvitation) -class OrganizationInvitationAdmin(admin.ModelAdmin): - list_display = ("email", "organization", "role", "is_accepted", "created_at") - list_filter = ("role", "created_at", "accepted_at", "organization") - search_fields = ("email", "organization__name") - autocomplete_fields = ("organization", "accepted_by") - readonly_fields = ( - "secret", - "accepted_by", - "accepted_at", - "created_at", - "updated_at", - ) - date_hierarchy = "created_at" - actions = ["send_invitation_emails"] - - def is_accepted(self, obj): - return obj.is_accepted - - is_accepted.boolean = True - is_accepted.short_description = _("Accepted") - - def send_invitation_emails(self, request, queryset): - pending_invitations = queryset.filter(accepted_by__isnull=True) - sent_count = 0 - failed_count = 0 - - for invitation in pending_invitations: - try: - invitation.send_invitation_email(request) - sent_count += 1 - except Exception as e: - failed_count += 1 - messages.error( - request, - _(f"Failed to send invitation to {invitation.email}: {str(e)}"), - ) - - if sent_count > 0: - messages.success( - request, - _(f"Successfully sent {sent_count} invitation email(s)."), - ) - - if failed_count > 0: - messages.warning( - request, _(f"Failed to send {failed_count} invitation email(s).") - ) - - send_invitation_emails.short_description = _("Send invitation emails") - - @admin.register(ServiceCategory) class ServiceCategoryAdmin(admin.ModelAdmin): list_display = ("name", "parent") @@ -168,6 +108,7 @@ class ServiceAdmin(admin.ModelAdmin): def get_form(self, request, obj=None, **kwargs): form = super().get_form(request, obj, **kwargs) + # JSON schema for external_links field external_links_schema = { "type": "array", "title": "External Links", @@ -200,6 +141,7 @@ class CloudProviderAdmin(admin.ModelAdmin): def get_form(self, request, obj=None, **kwargs): form = super().get_form(request, obj, **kwargs) + # JSON schema for external_links field external_links_schema = { "type": "array", "title": "External Links", @@ -232,15 +174,7 @@ class ControlPlaneAdmin(admin.ModelAdmin): fieldsets = ( ( None, - { - "fields": ( - "name", - "description", - "cloud_provider", - "required_label", - "wildcard_dns", - ) - }, + {"fields": ("name", "description", "cloud_provider", "required_label")}, ), ( _("API Credentials"), @@ -310,35 +244,8 @@ class ServiceDefinitionAdmin(admin.ModelAdmin): "description": _("API definition for the Kubernetes Custom Resource"), }, ), - ( - _("Form Configuration"), - { - "fields": ("advanced_fields",), - "description": _( - "Configure which fields should be hidden behind an 'Advanced' toggle in the form" - ), - }, - ), ) - def get_form(self, request, obj=None, **kwargs): - form = super().get_form(request, obj, **kwargs) - # JSON schema for advanced_fields field - advanced_fields_schema = { - "type": "array", - "title": "Advanced Fields", - "items": { - "type": "string", - "title": "Field Name", - "description": "Field name in dot notation (e.g., spec.parameters.monitoring.enabled)", - }, - } - if "advanced_fields" in form.base_fields: - form.base_fields["advanced_fields"].widget = JSONFormWidget( - schema=advanced_fields_schema - ) - return form - def get_exclude(self, request, obj=None): # Exclude the original api_definition field as we're using our custom fields return ["api_definition"] @@ -397,23 +304,3 @@ class ServiceOfferingAdmin(admin.ModelAdmin): search_fields = ("description",) autocomplete_fields = ("service", "provider") inlines = (ControlPlaneCRDInline,) - - def get_form(self, request, obj=None, **kwargs): - form = super().get_form(request, obj, **kwargs) - external_links_schema = { - "type": "array", - "title": "External Links", - "items": { - "type": "object", - "title": "Link", - "properties": { - "url": {"type": "string", "format": "uri", "title": "URL"}, - "title": {"type": "string", "title": "Title"}, - }, - "required": ["url", "title"], - }, - } - form.base_fields["external_links"].widget = JSONFormWidget( - schema=external_links_schema - ) - return form diff --git a/src/servala/core/crd.py b/src/servala/core/crd.py index fe8edbb..44c809b 100644 --- a/src/servala/core/crd.py +++ b/src/servala/core/crd.py @@ -86,117 +86,10 @@ def build_object_fields(schema, name, verbose_name_prefix=None, parent_required= def deslugify(title): - """ - Convert camelCase, PascalCase, or snake_case to human-readable title. - Handles known acronyms (e.g., postgreSQLParameters -> PostgreSQL Parameters). - """ - ACRONYMS = { - # Database systems - "SQL": "SQL", - "MYSQL": "MySQL", - "POSTGRESQL": "PostgreSQL", - "MARIADB": "MariaDB", - "MSSQL": "MSSQL", - "MONGODB": "MongoDB", - "REDIS": "Redis", - # Protocols - "HTTP": "HTTP", - "HTTPS": "HTTPS", - "FTP": "FTP", - "SFTP": "SFTP", - "SSH": "SSH", - "TLS": "TLS", - "SSL": "SSL", - # APIs - "API": "API", - "REST": "REST", - "GRPC": "gRPC", - "GRAPHQL": "GraphQL", - # Networking - "URL": "URL", - "URI": "URI", - "FQDN": "FQDN", - "DNS": "DNS", - "IP": "IP", - "TCP": "TCP", - "UDP": "UDP", - # Data formats - "JSON": "JSON", - "XML": "XML", - "YAML": "YAML", - "CSV": "CSV", - "HTML": "HTML", - "CSS": "CSS", - # Hardware - "CPU": "CPU", - "RAM": "RAM", - "GPU": "GPU", - "SSD": "SSD", - "HDD": "HDD", - # Identifiers - "ID": "ID", - "UUID": "UUID", - "GUID": "GUID", - "ARN": "ARN", - # Cloud providers - "AWS": "AWS", - "GCP": "GCP", - "AZURE": "Azure", - "IBM": "IBM", - # Kubernetes/Cloud - "DB": "DB", - "PVC": "PVC", - "PV": "PV", - "VPN": "VPN", - # Auth - "OS": "OS", - "LDAP": "LDAP", - "SAML": "SAML", - "OAUTH": "OAuth", - "JWT": "JWT", - # AWS Services - "S3": "S3", - "EC2": "EC2", - "RDS": "RDS", - "EBS": "EBS", - "IAM": "IAM", - } - if "_" in title: - # Handle snake_case - title = title.replace("_", " ") - words = title.split() - else: - # Handle camelCase/PascalCase with smart splitting - # This regex splits on: - # - Transition from lowercase to uppercase (camelCase) - # - Transition from multiple uppercase to an uppercase followed by lowercase (SQLParameters -> SQL Parameters) - words = re.findall(r"[A-Z]+(?=[A-Z][a-z]|\b)|[A-Z][a-z]+|[a-z]+|[0-9]+", title) - - # Merge adjacent words if they form a known compound acronym (e.g., postgre + SQL = PostgreSQL) - merged_words = [] - i = 0 - while i < len(words): - if i < len(words) - 1: - # Check if current word + next word form a known acronym - combined = (words[i] + words[i + 1]).upper() - if combined in ACRONYMS: - merged_words.append(combined) - i += 2 - continue - merged_words.append(words[i]) - i += 1 - - # Capitalize each word, using proper casing for known acronyms - result = [] - for word in merged_words: - word_upper = word.upper() - if word_upper in ACRONYMS: - result.append(ACRONYMS[word_upper]) - else: - result.append(word.capitalize()) - - return " ".join(result) + title.replace("_", " ") + return title.title() + return re.sub(r"(?{error_items}") @classmethod - @transaction.atomic def create_instance(cls, name, organization, context, created_by, spec_data): # Ensure the namespace exists context.control_plane.get_or_create_namespace(organization) @@ -733,7 +704,7 @@ class ServiceInstance(ServalaModelMixin, models.Model): body=create_data, ) except Exception as e: - # Transaction will automatically roll back the instance creation + instance.delete() if isinstance(e, ApiException): try: error_body = json.loads(e.body) @@ -934,9 +905,6 @@ class ServiceInstance(ServalaModelMixin, models.Model): import base64 for key, value in secret.data.items(): - # Skip keys ending with _HOST as they're only useful for dedicated OpenShift clusters - if key.endswith("_HOST"): - continue try: credentials[key] = base64.b64decode(value).decode("utf-8") except Exception: diff --git a/src/servala/core/rules.py b/src/servala/core/rules.py index e1a0992..cf4dc1c 100644 --- a/src/servala/core/rules.py +++ b/src/servala/core/rules.py @@ -14,26 +14,20 @@ def has_organization_role(user, org, roles): @rules.predicate def is_organization_owner(user, obj): - from servala.core.models.organization import OrganizationRole - if hasattr(obj, "organization"): org = obj.organization else: org = obj - return has_organization_role(user, org, [OrganizationRole.OWNER]) + return has_organization_role(user, org, ["owner"]) @rules.predicate def is_organization_admin(user, obj): - from servala.core.models.organization import OrganizationRole - if hasattr(obj, "organization"): org = obj.organization else: org = obj - return has_organization_role( - user, org, [OrganizationRole.OWNER, OrganizationRole.ADMIN] - ) + return has_organization_role(user, org, ["owner", "admin"]) @rules.predicate diff --git a/src/servala/frontend/forms/organization.py b/src/servala/frontend/forms/organization.py index 27e6a09..915ad7b 100644 --- a/src/servala/frontend/forms/organization.py +++ b/src/servala/frontend/forms/organization.py @@ -1,18 +1,13 @@ from django import forms -from django.core.exceptions import ValidationError from django.forms import ModelForm from django.utils.translation import gettext_lazy as _ -from servala.core.models import Organization, OrganizationInvitation, OrganizationRole +from servala.core.models import Organization from servala.core.odoo import get_invoice_addresses, get_odoo_countries from servala.frontend.forms.mixins import HtmxMixin class OrganizationForm(HtmxMixin, ModelForm): - # def __init__(self, *args, **kwargs): - # super().__init__(*args, **kwargs) - # if self.instance and self.instance.has_inherited_billing_entity: - # TODO disable billing entity editing class Meta: model = Organization fields = ("name",) @@ -51,7 +46,7 @@ class OrganizationCreateForm(OrganizationForm): def __init__(self, *args, user=None, **kwargs): super().__init__(*args, **kwargs) - self.user = user + if not self.initial.get("invoice_country"): default_country_name = "Switzerland" country_choices = self.fields["invoice_country"].choices @@ -60,6 +55,7 @@ class OrganizationCreateForm(OrganizationForm): self.initial["invoice_country"] = country_id break + self.user = user self.odoo_addresses = get_invoice_addresses(self.user) if self.odoo_addresses: @@ -112,68 +108,3 @@ class OrganizationCreateForm(OrganizationForm): "existing_odoo_address_id", _("Please select an invoice address.") ) return cleaned_data - - -class OrganizationInvitationForm(forms.ModelForm): - - def __init__(self, *args, organization=None, user_role=None, **kwargs): - super().__init__(*args, **kwargs) - self.organization = organization - self.user_role = user_role - - if user_role: - allowed_roles = self._get_allowed_roles(user_role) - self.fields["role"].choices = [ - (value, label) - for value, label in OrganizationRole.choices - if value in allowed_roles - ] - - def _get_allowed_roles(self, user_role): - role_hierarchy = { - OrganizationRole.OWNER: [ - OrganizationRole.OWNER, - OrganizationRole.ADMIN, - OrganizationRole.MEMBER, - ], - OrganizationRole.ADMIN: [ - OrganizationRole.ADMIN, - OrganizationRole.MEMBER, - ], - OrganizationRole.MEMBER: [], - } - return role_hierarchy.get(user_role, []) - - def clean_email(self): - email = self.cleaned_data["email"].lower() - - if self.organization.members.filter(email__iexact=email).exists(): - raise ValidationError( - _("A user with this email is already a member of this organization.") - ) - - if OrganizationInvitation.objects.filter( - organization=self.organization, - email__iexact=email, - accepted_by__isnull=True, - ).exists(): - raise ValidationError( - _("An invitation has already been sent to this email address.") - ) - - return email - - def save(self, commit=True): - invitation = super().save(commit=False) - invitation.organization = self.organization - if commit: - invitation.save() - return invitation - - class Meta: - model = OrganizationInvitation - fields = ("email", "role") - widgets = { - "email": forms.EmailInput(attrs={"placeholder": _("user@example.com")}), - "role": forms.RadioSelect(), - } diff --git a/src/servala/frontend/forms/service.py b/src/servala/frontend/forms/service.py index 23325f3..5dd78a7 100644 --- a/src/servala/frontend/forms/service.py +++ b/src/servala/frontend/forms/service.py @@ -21,15 +21,6 @@ class ServiceFilterForm(forms.Form): ) q = forms.CharField(label=_("Search"), required=False) - def __init__(self, *args, organization=None, **kwargs): - super().__init__(*args, **kwargs) - if organization and organization.limit_cloudproviders.exists(): - allowed_providers = organization.limit_cloudproviders - if allowed_providers.count() <= 1: - self.fields.pop("cloud_provider", None) - else: - self.fields["cloud_provider"].queryset = allowed_providers - def filter_queryset(self, queryset): if category := self.cleaned_data.get("category"): queryset = queryset.filter(category=category) diff --git a/src/servala/frontend/templates/frontend/base.html b/src/servala/frontend/templates/frontend/base.html index 7c6bc54..f738a52 100644 --- a/src/servala/frontend/templates/frontend/base.html +++ b/src/servala/frontend/templates/frontend/base.html @@ -80,20 +80,13 @@ - {% block extra_js %} - {% endblock extra_js %} diff --git a/src/servala/frontend/templates/frontend/forms/dynamic_array.html b/src/servala/frontend/templates/frontend/forms/dynamic_array.html index 9d61825..4b7e68c 100644 --- a/src/servala/frontend/templates/frontend/forms/dynamic_array.html +++ b/src/servala/frontend/templates/frontend/forms/dynamic_array.html @@ -1,9 +1,6 @@
+ data-name="{{ widget.name }}">
{% for item in value_list %}
diff --git a/src/servala/frontend/templates/frontend/organizations/invitation_accept.html b/src/servala/frontend/templates/frontend/organizations/invitation_accept.html deleted file mode 100644 index fa0bfd7..0000000 --- a/src/servala/frontend/templates/frontend/organizations/invitation_accept.html +++ /dev/null @@ -1,42 +0,0 @@ -{% extends "frontend/base.html" %} -{% load i18n %} -{% block html_title %} - {% block page_title %} - {% translate "Accept Organization Invitation" %} - {% endblock page_title %} -{% endblock html_title %} -{% block content %} -
-
-
-
-
- - {% blocktranslate with org_name=invitation.organization.name role=invitation.get_role_display %} - You have been invited to join {{ org_name }} as a {{ role }}. - {% endblocktranslate %} -
- {% if user.email|lower != invitation.email|lower %} -
- - {% blocktranslate with invitation_email=invitation.email user_email=user.email %} - Note: This invitation was sent to {{ invitation_email }}, - but you are currently logged in as {{ user_email }}. - {% endblocktranslate %} -
- {% endif %} -
- {% csrf_token %} -
- {% translate "Cancel" %} - -
-
-
-
-
-
-{% endblock content %} diff --git a/src/servala/frontend/templates/frontend/organizations/service_detail.html b/src/servala/frontend/templates/frontend/organizations/service_detail.html index a101ed1..55cf31e 100644 --- a/src/servala/frontend/templates/frontend/organizations/service_detail.html +++ b/src/servala/frontend/templates/frontend/organizations/service_detail.html @@ -32,7 +32,13 @@
{% translate "External Links" %}
{% for link in service.external_links %} - {% include "includes/external_link.html" %} + + {{ link.title }} + + {% endfor %}
@@ -41,7 +47,7 @@
- {% for offering in visible_offerings %} + {% for offering in service.offerings.all %}
{% endif %} {% if instance.connection_credentials %} -
-
-
-

{% translate "Connection Credentials" %}

-
-
-
- - +
+
+

{% translate "Connection Credentials" %}

+
+
+
+
+ + + + + + + + {% for key, value in instance.connection_credentials.items %} - - + + - - - {% for key, value in instance.connection_credentials.items %} - - - - - {% endfor %} - -
{% translate "Name" %}{% translate "Value" %}
{% translate "Name" %}{% translate "Value" %}{{ key }} + {% if key == "error" %} + {{ value }} + {% else %} + {{ value }} + {% endif %} +
{{ key }} - {% if key == "error" %} - {{ value }} - {% else %} - {{ value }} - {% endif %} -
-
+ {% endfor %} + +
diff --git a/src/servala/frontend/templates/frontend/organizations/service_offering_detail.html b/src/servala/frontend/templates/frontend/organizations/service_offering_detail.html index 842e610..7f3863e 100644 --- a/src/servala/frontend/templates/frontend/organizations/service_offering_detail.html +++ b/src/servala/frontend/templates/frontend/organizations/service_offering_detail.html @@ -64,16 +64,19 @@ {{ select_form }} {% endif %} - {% if service.external_links or offering.external_links %} + {% if service.external_links %}
{% translate "External Links" %}
{% for link in service.external_links %} - {% include "includes/external_link.html" %} - {% endfor %} - {% for link in offering.external_links %} - {% include "includes/external_link.html" %} + + {{ link.title }} + + {% endfor %}
@@ -93,14 +96,3 @@
{% endblock content %} -{% block extra_js %} - {% if wildcard_dns and organization_namespace %} - - - {% endif %} -{% endblock extra_js %} diff --git a/src/servala/frontend/templates/frontend/organizations/services.html b/src/servala/frontend/templates/frontend/organizations/services.html index 3250c52..3a48ff9 100644 --- a/src/servala/frontend/templates/frontend/organizations/services.html +++ b/src/servala/frontend/templates/frontend/organizations/services.html @@ -16,37 +16,50 @@
-
+
{% for service in services %} -
{% include "includes/service_card.html" %}
- {% empty %} -
+
-
-
-

{% translate "No services found." %}

+ +
+
+ {% if service.description %}

{{ service.description|urlize }}

{% endif %} +
+
+ +
+
+ {% empty %} +
+
+
+

{% translate "No services found." %}

+
{% endfor %}
- {% if deactivated_services %} -
-
-
{% translate "You may also be interested in one of these …" %}
-

- - {% translate "These services need to be enabled first before they become available in the Servala portal." %} -

-
-
-
- {% for service in deactivated_services %} -
{% include "includes/service_card.html" %}
- {% endfor %} -
- {% endif %} {% endblock content %} diff --git a/src/servala/frontend/templates/frontend/organizations/update.html b/src/servala/frontend/templates/frontend/organizations/update.html index 73c2c69..97d266d 100644 --- a/src/servala/frontend/templates/frontend/organizations/update.html +++ b/src/servala/frontend/templates/frontend/organizations/update.html @@ -36,97 +36,6 @@ {% endpartialdef org-name-edit %} -{% partialdef members-list %} -
- - - - - - - - - - - {% for membership in memberships %} - - - - - - - {% empty %} - - - - {% endfor %} - -
{% translate "Name" %}{% translate "Email" %}{% translate "Role" %}{% translate "Joined" %}
{{ membership.user }}{{ membership.user.email }} - - {{ membership.get_role_display }} - - {{ membership.date_joined|date:"Y-m-d" }}
{% translate "No members yet" %}
-
-{% endpartialdef members-list %} -{% partialdef pending-invitations-card %} -{% if pending_invitations %} -
-
-

- {% translate "Pending Invitations" %} -

-
-
-
-
- - - - - - - - - - - {% for invitation in pending_invitations %} - - - - - - - {% endfor %} - -
{% translate "Email" %}{% translate "Role" %}{% translate "Sent" %}{% translate "Actions" %}
{{ invitation.email }} - - {{ invitation.get_role_display }} - - {{ invitation.created_at|date:"Y-m-d H:i" }} - -
- {% csrf_token %} - - -
-
-
-
-
-
-{% endif %} -{% endpartialdef pending-invitations-card %} {% block content %}
@@ -160,11 +69,6 @@

{% translate "Billing Address" %}

- {% if form.instance.has_inherited_billing_entity %} -

- {% translate "This billing address cannot be modified." %} -

- {% endif %}
@@ -226,56 +130,5 @@
{% endif %} - {% if can_manage_members %} -
-
-

- {% translate "Members" %} -

-
-
-
{% partial members-list %}
-
-
-
{% partial pending-invitations-card %}
-
-
-

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

-
-
-
-
-
- {% translate "Role Permissions" %} -
-
    -
  • - {% translate "Owner" %}: {% translate "Can manage all organization settings, members, services, and can appoint administrators." %} -
  • -
  • - {% translate "Administrator" %}: {% translate "Can manage members, invite users, and manage all services and instances." %} -
  • -
  • - {% translate "Member" %}: {% translate "Can view organization details, create and manage their own service instances." %} -
  • -
-
-
- {% csrf_token %} -
{{ invitation_form }}
-
-
- -
-
-
-
-
-
- {% endif %}
{% endblock content %} diff --git a/src/servala/frontend/templates/includes/external_link.html b/src/servala/frontend/templates/includes/external_link.html deleted file mode 100644 index e8319bf..0000000 --- a/src/servala/frontend/templates/includes/external_link.html +++ /dev/null @@ -1,7 +0,0 @@ - - {{ link.title }} - - diff --git a/src/servala/frontend/templates/includes/service_card.html b/src/servala/frontend/templates/includes/service_card.html deleted file mode 100644 index 0dae3ae..0000000 --- a/src/servala/frontend/templates/includes/service_card.html +++ /dev/null @@ -1,31 +0,0 @@ -{% load i18n %} -
- -
-
- {% if service.description %}

{{ service.description|urlize }}

{% endif %} -
-
- -
diff --git a/src/servala/frontend/templates/includes/tabbed_fieldset_form.html b/src/servala/frontend/templates/includes/tabbed_fieldset_form.html index 74fa22a..c9d947a 100644 --- a/src/servala/frontend/templates/includes/tabbed_fieldset_form.html +++ b/src/servala/frontend/templates/includes/tabbed_fieldset_form.html @@ -1,28 +1,14 @@ {% load i18n %} {% load get_field %} -{% load static %}
{% csrf_token %} {% include "frontend/forms/errors.html" %} - {% if form.ADVANCED_FIELDS %} -
- -
- {% endif %}
+

{{ subfieldset.title }}

+ {% for field in subfieldset.fields %} + {% with field=form|get_field:field %}{{ field.as_field_group }}{% endwith %} + {% endfor %} {% endif %} {% endfor %}
@@ -70,4 +54,3 @@
- diff --git a/src/servala/frontend/templatetags/version_tags.py b/src/servala/frontend/templatetags/version_tags.py index 6019738..249ecee 100644 --- a/src/servala/frontend/templatetags/version_tags.py +++ b/src/servala/frontend/templatetags/version_tags.py @@ -1,7 +1,5 @@ import os - from django import template - from servala.__about__ import __version__ register = template.Library() @@ -10,7 +8,7 @@ register = template.Library() @register.simple_tag def get_version_or_env(): """Return version number in production, environment name otherwise.""" - env = os.environ.get("SERVALA_ENVIRONMENT", "development") - if env == "production": + env = os.environ.get('SERVALA_ENVIRONMENT', 'development') + if env == 'production': return __version__ return env diff --git a/src/servala/frontend/urls.py b/src/servala/frontend/urls.py index 73d0759..7790b22 100644 --- a/src/servala/frontend/urls.py +++ b/src/servala/frontend/urls.py @@ -6,11 +6,6 @@ from servala.frontend import views urlpatterns = [ path("accounts/profile/", views.ProfileView.as_view(), name="profile"), path("accounts/logout/", views.LogoutView.as_view(), name="logout"), - path( - "invitations//accept/", - views.InvitationAcceptView.as_view(), - name="invitation.accept", - ), path( "organizations/", views.OrganizationSelectionView.as_view(), @@ -30,11 +25,6 @@ urlpatterns = [ views.OrganizationUpdateView.as_view(), name="organization.details", ), - path( - "details/invitations//delete/", - views.InvitationDeleteView.as_view(), - name="invitation.delete", - ), path( "services/", views.ServiceListView.as_view(), diff --git a/src/servala/frontend/views/__init__.py b/src/servala/frontend/views/__init__.py index 33b0560..5f11a75 100644 --- a/src/servala/frontend/views/__init__.py +++ b/src/servala/frontend/views/__init__.py @@ -8,8 +8,6 @@ from .generic import ( custom_500, ) from .organization import ( - InvitationAcceptView, - InvitationDeleteView, OrganizationCreateView, OrganizationDashboardView, OrganizationUpdateView, @@ -27,8 +25,6 @@ from .support import SupportView __all__ = [ "IndexView", - "InvitationAcceptView", - "InvitationDeleteView", "LogoutView", "OrganizationCreateView", "OrganizationDashboardView", diff --git a/src/servala/frontend/views/organization.py b/src/servala/frontend/views/organization.py index c4c1336..2f35f76 100644 --- a/src/servala/frontend/views/organization.py +++ b/src/servala/frontend/views/organization.py @@ -1,31 +1,16 @@ -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.shortcuts import redirect from django.utils.translation import gettext_lazy as _ -from django.views.generic import CreateView, DeleteView, DetailView, TemplateView -from django_scopes import scopes_disabled +from django.views.generic import CreateView, DetailView from rules.contrib.views import AutoPermissionRequiredMixin from servala.core.models import ( BillingEntity, Organization, - OrganizationInvitation, OrganizationMembership, ServiceInstance, ) -from servala.frontend.forms.organization import ( - OrganizationCreateForm, - OrganizationForm, - OrganizationInvitationForm, -) -from servala.frontend.views.mixins import ( - HtmxUpdateView, - HtmxViewMixin, - OrganizationViewMixin, -) +from servala.frontend.forms.organization import OrganizationCreateForm, OrganizationForm +from servala.frontend.views.mixins import HtmxUpdateView, OrganizationViewMixin class OrganizationCreateView(AutoPermissionRequiredMixin, CreateView): @@ -111,225 +96,10 @@ class OrganizationDashboardView( return context -class OrganizationMembershipMixin: +class OrganizationUpdateView(OrganizationViewMixin, HtmxUpdateView): 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 - 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()) + fragments = ("org-name", "org-name-edit") def get_success_url(self): 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()) diff --git a/src/servala/frontend/views/service.py b/src/servala/frontend/views/service.py index 689f381..a8f917c 100644 --- a/src/servala/frontend/views/service.py +++ b/src/servala/frontend/views/service.py @@ -1,6 +1,6 @@ from django.contrib import messages from django.core.exceptions import ValidationError -from django.http import Http404, HttpResponse +from django.http import HttpResponse, Http404 from django.shortcuts import redirect from django.utils.functional import cached_property from django.utils.translation import gettext_lazy as _ @@ -36,24 +36,22 @@ class ServiceListView(OrganizationViewMixin, ListView): def get_queryset(self): """Return all services.""" - services = self.request.organization.get_visible_services() - + services = ( + Service.objects.all() + .select_related("category") + .prefetch_related("offerings__provider") + ) if self.filter_form.is_valid(): services = self.filter_form.filter_queryset(services) return services.distinct() @cached_property def filter_form(self): - return ServiceFilterForm( - data=self.request.GET or None, organization=self.request.organization - ) + return ServiceFilterForm(data=self.request.GET or None) def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) context["filter_form"] = self.filter_form - context["deactivated_services"] = ( - self.request.organization.get_deactivated_services() - ) return context @@ -64,17 +62,10 @@ class ServiceDetailView(OrganizationViewMixin, DetailView): permission_type = "view" def get_queryset(self): - return self.request.organization.get_visible_services() - - 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 + return Service.objects.select_related("category").prefetch_related( + "offerings", + "offerings__provider", + ) class ServiceOfferingDetailView(OrganizationViewMixin, HtmxViewMixin, DetailView): @@ -85,14 +76,7 @@ class ServiceOfferingDetailView(OrganizationViewMixin, HtmxViewMixin, DetailView fragments = ("service-form", "control-plane-info") def has_permission(self): - 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 + return self.has_organization_permission() def get_queryset(self): return ServiceOffering.objects.all().select_related( @@ -134,25 +118,12 @@ class ServiceOfferingDetailView(OrganizationViewMixin, HtmxViewMixin, DetailView def get_instance_form(self): if not self.context_object or not self.context_object.model_form_class: return None - - 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( + return self.context_object.model_form_class( 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): @@ -161,10 +132,6 @@ class ServiceOfferingDetailView(OrganizationViewMixin, HtmxViewMixin, DetailView context["has_control_planes"] = self.planes.exists() context["selected_plane"] = self.selected_plane context["service_form"] = self.get_instance_form() - # Pass data for dynamic FQDN generation - if self.selected_plane and self.selected_plane.wildcard_dns: - context["wildcard_dns"] = self.selected_plane.wildcard_dns - context["organization_namespace"] = self.request.organization.namespace return context def post(self, request, *args, **kwargs): diff --git a/src/servala/static/css/servala.css b/src/servala/static/css/servala.css index 0ea8b28..9a59b8f 100644 --- a/src/servala/static/css/servala.css +++ b/src/servala/static/css/servala.css @@ -279,56 +279,3 @@ html[data-bs-theme="dark"] .crd-form .nav-tabs .nav-link .mandatory-indicator { .crd-form .nav-tabs .nav-link.has-mandatory { 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; -} diff --git a/src/servala/static/js/advanced-fields.js b/src/servala/static/js/advanced-fields.js deleted file mode 100644 index 989e61a..0000000 --- a/src/servala/static/js/advanced-fields.js +++ /dev/null @@ -1,83 +0,0 @@ -/** - * Advanced Fields Toggle - * Handles showing/hiding advanced fields in CRD forms - */ -(function() { - 'use strict'; - - function flashTabsWithAdvancedFields() { - const advancedGroups = document.querySelectorAll('.advanced-field-group'); - const tabsToFlash = new Set(); - advancedGroups.forEach(function(group) { - const tabPane = group.closest('.tab-pane'); - if (tabPane) { - const tabId = tabPane.getAttribute('id'); - if (tabId) { - const tabButton = document.querySelector(`[data-bs-target="#${tabId}"]`); - if (tabButton && !tabButton.classList.contains('active')) { - tabsToFlash.add(tabButton); - } - } - } - }); - - tabsToFlash.forEach(function(tab) { - tab.classList.add('tab-flash'); - setTimeout(function() { - tab.classList.remove('tab-flash'); - }, 2000); - }); - } - - function initializeAdvancedFields() { - const advancedInputs = document.querySelectorAll('[data-advanced="true"]'); - - if (advancedInputs.length === 0) { - return; - } - - advancedInputs.forEach(function(input) { - const formGroup = input.closest('.form-group, .mb-3, .col-12, .col-md-6'); - if (formGroup) { - formGroup.classList.add('advanced-field-group', 'collapse'); - } - }); - - const toggleButton = document.getElementById('advanced-toggle'); - if (toggleButton) { - let isExpanded = false; - - document.querySelectorAll('.advanced-field-group').forEach(function(group) { - group.addEventListener('shown.bs.collapse', function() { - toggleButton.innerHTML = ' 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 = ' 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); - } - }); -})(); diff --git a/src/servala/static/js/dynamic-array.js b/src/servala/static/js/dynamic-array.js index b6fa4e5..c198ddf 100644 --- a/src/servala/static/js/dynamic-array.js +++ b/src/servala/static/js/dynamic-array.js @@ -7,10 +7,6 @@ const initDynamicArrayWidget = () => { const containers = document.querySelectorAll('.dynamic-array-widget') containers.forEach(container => { - if (container.dataset.initialized === 'true') { - return - } - const itemsContainer = container.querySelector('.array-items') const addButton = container.querySelector('.add-array-item') const hiddenInput = container.querySelector('input[type="hidden"]') @@ -26,7 +22,6 @@ const initDynamicArrayWidget = () => { // Ensure hidden input is synced with visible inputs on initialization updateHiddenInput(container) - container.dataset.initialized = 'true' }) } diff --git a/src/servala/static/js/fqdn.js b/src/servala/static/js/fqdn.js deleted file mode 100644 index 7b61c9a..0000000 --- a/src/servala/static/js/fqdn.js +++ /dev/null @@ -1,38 +0,0 @@ - -const initializeFqdnGeneration = () => { - const nameField = document.querySelector('input[name="name"]'); - const fqdnField = document.querySelector('label[for="id_spec.parameters.service.fqdn"] + div input.array-item-input'); - - if (nameField && fqdnField) { - const generateFqdn = (instanceName) => { - if (!instanceName) return ''; - return `${instanceName}-${fqdnConfig.namespace}.${fqdnConfig.wildcardDns}`; - } - - const newNameField = nameField.cloneNode(true); - nameField.parentNode.replaceChild(newNameField, nameField); - const newFqdnField = fqdnField.cloneNode(true); - fqdnField.parentNode.replaceChild(newFqdnField, fqdnField); - - newNameField.addEventListener('input', function() { - if (!newFqdnField.dataset.manuallyEdited) { - newFqdnField.value = generateFqdn(this.value); - } - }); - - newFqdnField.addEventListener('input', function() { - this.dataset.manuallyEdited = 'true'; - }); - - if (newNameField.value && !newFqdnField.value) { - newFqdnField.value = generateFqdn(newNameField.value); - } - } -} - -document.addEventListener('DOMContentLoaded', initializeFqdnGeneration); -document.body.addEventListener('htmx:afterSwap', function(event) { - if (event.detail.target.id === 'service-form') { - initializeFqdnGeneration(); - } -}); diff --git a/src/tests/conftest.py b/src/tests/conftest.py index 09db220..32499ca 100644 --- a/src/tests/conftest.py +++ b/src/tests/conftest.py @@ -3,7 +3,6 @@ import base64 import pytest from servala.core.models import ( - BillingEntity, Organization, OrganizationMembership, OrganizationOrigin, @@ -22,11 +21,6 @@ def origin(): return OrganizationOrigin.objects.create(name="TESTORIGIN") -@pytest.fixture -def billing_entity(): - return BillingEntity.objects.create(name="Test Entity") - - @pytest.fixture def organization(origin): return Organization.objects.create(name="Test Org", origin=origin) diff --git a/src/tests/test_api_exoscale.py b/src/tests/test_api_exoscale.py index 6d10deb..a14bd44 100644 --- a/src/tests/test_api_exoscale.py +++ b/src/tests/test_api_exoscale.py @@ -73,8 +73,12 @@ def test_successful_onboarding_new_organization( assert org.origin == exoscale_origin assert org.namespace.startswith("org-") + user = User.objects.get(email="test@example.com") + assert user.first_name == "Test" + assert user.last_name == "User" with scopes_disabled(): - assert org.invitations.all().filter(email="test@example.com").exists() + membership = org.memberships.get(user=user) + assert membership.role == "owner" billing_entity = org.billing_entity assert billing_entity.name == "Test Organization Display (Exoscale)" @@ -83,14 +87,10 @@ def test_successful_onboarding_new_organization( assert org.odoo_sale_order_id == 789 assert org.odoo_sale_order_name == "SO001" - assert org.limit_osb_services.all().count() == 1 assert len(mail.outbox) == 2 invitation_email = mail.outbox[0] - assert ( - invitation_email.subject - == "You're invited to join Test Organization Display on Servala" - ) + assert invitation_email.subject == "Welcome to Servala - Test Organization Display" assert "test@example.com" in invitation_email.to welcome_email = mail.outbox[1] @@ -98,36 +98,6 @@ def test_successful_onboarding_new_organization( 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 def test_duplicate_organization_returns_existing( osb_client, @@ -137,12 +107,11 @@ def test_duplicate_organization_returns_existing( exoscale_origin, instance_id, ): - org = Organization.objects.create( + Organization.objects.create( name="Existing Org", osb_guid="test-org-guid-123", origin=exoscale_origin, ) - org.limit_osb_services.add(test_service) valid_osb_payload["service_id"] = test_service.osb_service_id valid_osb_payload["plan_id"] = test_service_offering.osb_plan_id @@ -157,7 +126,7 @@ def test_duplicate_organization_returns_existing( response_data = json.loads(response.content) assert response_data["message"] == "Service already enabled" assert Organization.objects.filter(osb_guid="test-org-guid-123").count() == 1 - assert len(mail.outbox) == 0 # No email necessary + assert len(mail.outbox) == 1 # Only one email was sent @pytest.mark.django_db