Send invitation emails

ref #19
This commit is contained in:
Tobias Kunze 2025-10-08 17:53:27 +02:00
parent 21c26f9e5d
commit b9ff0e61da
5 changed files with 119 additions and 40 deletions

View file

@ -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
):

View file

@ -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):

View file

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

View file

@ -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())

View file

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