From b9ff0e61daac5a55e84e512902ef3bdf6d29b57b Mon Sep 17 00:00:00 2001 From: Tobias Kunze Date: Wed, 8 Oct 2025 17:53:27 +0200 Subject: [PATCH] Send invitation emails ref #19 --- src/servala/api/views.py | 39 ++++++---------- src/servala/core/admin.py | 30 ++++++++++++ src/servala/core/models/organization.py | 54 +++++++++++++++++++++- src/servala/frontend/views/organization.py | 25 +++++++--- src/tests/test_api_exoscale.py | 11 ++--- 5 files changed, 119 insertions(+), 40 deletions(-) diff --git a/src/servala/api/views.py b/src/servala/api/views.py index 5d67754..456f4b2 100644 --- a/src/servala/api/views.py +++ b/src/servala/api/views.py @@ -12,7 +12,13 @@ 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, User +from servala.core.models import ( + BillingEntity, + Organization, + OrganizationInvitation, + OrganizationRole, + User, +) from servala.core.models.service import Service, ServiceOffering logger = logging.getLogger(__name__) @@ -127,8 +133,13 @@ class OSBServiceInstanceView(OSBBasicAuthPermission, View): origin=exoscale_origin, osb_guid=organization_guid, ) - organization = Organization.create_organization(organization, user) - self._send_invitation_email(request, organization, user) + 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) @@ -138,28 +149,6 @@ class OSBServiceInstanceView(OSBBasicAuthPermission, View): ) return JsonResponse({"message": "Successfully enabled service"}, status=201) - def _send_invitation_email(self, request, organization, user): - subject = f"Welcome to Servala - {organization.name}" - url = request.build_absolute_uri(organization.urls.base) - message = f"""Hello {user.first_name or user.email}, - -You have been invited to join the organization "{organization.name}" on Servala Portal. - -You can access your organization at: {url} - -Please use this email address ({user.email}) when prompted to log in. - -Best regards, -The Servala Team""" - - send_mail( - subject=subject, - message=message, - from_email=settings.EMAIL_DEFAULT_FROM, - recipient_list=[user.email], - fail_silently=False, - ) - def _send_service_welcome_email( self, request, organization, user, service, service_offering ): diff --git a/src/servala/core/admin.py b/src/servala/core/admin.py index f966f38..6e3aff6 100644 --- a/src/servala/core/admin.py +++ b/src/servala/core/admin.py @@ -120,6 +120,7 @@ class OrganizationInvitationAdmin(admin.ModelAdmin): "updated_at", ) date_hierarchy = "created_at" + actions = ["send_invitation_emails"] def is_accepted(self, obj): return obj.is_accepted @@ -127,6 +128,35 @@ class OrganizationInvitationAdmin(admin.ModelAdmin): is_accepted.boolean = True is_accepted.short_description = _("Accepted") + def send_invitation_emails(self, request, queryset): + pending_invitations = queryset.filter(accepted_by__isnull=True) + sent_count = 0 + failed_count = 0 + + for invitation in pending_invitations: + try: + invitation.send_invitation_email(request) + sent_count += 1 + except Exception as e: + failed_count += 1 + messages.error( + request, + _(f"Failed to send invitation to {invitation.email}: {str(e)}"), + ) + + if sent_count > 0: + messages.success( + request, + _(f"Successfully sent {sent_count} invitation email(s)."), + ) + + if failed_count > 0: + messages.warning( + request, _(f"Failed to send {failed_count} invitation email(s).") + ) + + send_invitation_emails.short_description = _("Send invitation emails") + @admin.register(ServiceCategory) class ServiceCategoryAdmin(admin.ModelAdmin): diff --git a/src/servala/core/models/organization.py b/src/servala/core/models/organization.py index b6d7e9f..d308bfa 100644 --- a/src/servala/core/models/organization.py +++ b/src/servala/core/models/organization.py @@ -3,7 +3,10 @@ import secrets import rules import urlman from django.conf import settings +from django.contrib.sites.shortcuts import get_current_site +from django.core.mail import send_mail from django.db import models, transaction +from django.http import HttpRequest from django.utils.functional import cached_property from django.utils.safestring import mark_safe from django.utils.text import slugify @@ -112,7 +115,7 @@ class Organization(ServalaModelMixin, models.Model): @classmethod @transaction.atomic - def create_organization(cls, instance, owner): + def create_organization(cls, instance, owner=None): try: instance.origin except Exception: @@ -120,7 +123,8 @@ class Organization(ServalaModelMixin, models.Model): pk=settings.SERVALA_DEFAULT_ORIGIN ) instance.save() - instance.set_owner(owner) + if owner: + instance.set_owner(owner) if ( instance.billing_entity.odoo_company_id @@ -486,3 +490,49 @@ class OrganizationInvitation(ServalaModelMixin, models.Model): @property def can_be_accepted(self): return not self.is_accepted + + def send_invitation_email(self, request=None): + subject = _("You're invited to join {organization} on Servala").format( + organization=self.organization.name + ) + + if request: + invitation_url = request.build_absolute_uri(self.urls.accept) + organization_url = request.build_absolute_uri(self.organization.urls.base) + else: + fake_request = HttpRequest() + fake_request.META["SERVER_NAME"] = get_current_site(None).domain + fake_request.META["SERVER_PORT"] = "443" + fake_request.META["wsgi.url_scheme"] = "https" + invitation_url = fake_request.build_absolute_uri(self.urls.accept) + organization_url = fake_request.build_absolute_uri( + self.organization.urls.base + ) + + message = _( + """Hello, + +You have been invited to join the organization "{organization}" on Servala Portal as a {role}. + +To accept this invitation, please click the link below: +{invitation_url} + +Once you accept, you'll be able to access the organization at: +{organization_url} + +Best regards, +The Servala Team""" + ).format( + organization=self.organization.name, + role=self.get_role_display(), + invitation_url=invitation_url, + organization_url=organization_url, + ) + + send_mail( + subject=subject, + message=message, + from_email=settings.EMAIL_DEFAULT_FROM, + recipient_list=[self.email], + fail_silently=False, + ) diff --git a/src/servala/frontend/views/organization.py b/src/servala/frontend/views/organization.py index 651b18c..7013a6c 100644 --- a/src/servala/frontend/views/organization.py +++ b/src/servala/frontend/views/organization.py @@ -179,13 +179,24 @@ class OrganizationUpdateView(OrganizationViewMixin, HtmxUpdateView): invitation.created_by = request.user invitation.save() - messages.success( - request, - _("Invitation sent to {email}. Share this link: {url}").format( - email=invitation.email, - url=request.build_absolute_uri(invitation.urls.accept), - ), - ) + try: + invitation.send_invitation_email(request) + messages.success( + request, + _( + "Invitation sent to {email}. They will receive an email with the invitation link." + ).format(email=invitation.email), + ) + except Exception: + messages.warning( + request, + _( + "Invitation created for {email}, but email failed to send. Share this link manually: {url}" + ).format( + email=invitation.email, + url=request.build_absolute_uri(invitation.urls.accept), + ), + ) else: for error in form.errors.values(): messages.error(request, error.as_text()) diff --git a/src/tests/test_api_exoscale.py b/src/tests/test_api_exoscale.py index b6fa4dc..6d10deb 100644 --- a/src/tests/test_api_exoscale.py +++ b/src/tests/test_api_exoscale.py @@ -73,12 +73,8 @@ 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(): - membership = org.memberships.get(user=user) - assert membership.role == "owner" + assert org.invitations.all().filter(email="test@example.com").exists() billing_entity = org.billing_entity assert billing_entity.name == "Test Organization Display (Exoscale)" @@ -91,7 +87,10 @@ def test_successful_onboarding_new_organization( assert len(mail.outbox) == 2 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 welcome_email = mail.outbox[1]