(plugins) add route to create first mailbox of a domain

Add route to create first mailbox of a domain and activate the
organization linked.
This commit is contained in:
Sabrina Demagny
2025-04-11 13:41:04 +02:00
parent f1b6da2bdd
commit 5a7bd798da
6 changed files with 250 additions and 4 deletions
+4
View File
@@ -8,6 +8,10 @@ and this project adheres to
## [Unreleased]
### Added
- ✨(plugins) add route to create first mailbox of a domain
### Changed
- ✨(mailbox) remove secondary email as required field
@@ -0,0 +1,49 @@
"""API serializers for the la suite plugin."""
from django.contrib.auth.password_validation import validate_password
from django.utils.translation import gettext_lazy as _
from rest_framework import serializers
class EmailLocalPartCharField(serializers.RegexField):
"""
A field that validates an email local part.
"""
default_error_messages = {
"invalid": _("Enter a valid email local part ([a-zA-Z0-9.-])."),
}
def __init__(self, **kwargs):
"""
Initialize the EmailLocalPartCharField.
"""
super().__init__(r"^[a-zA-Z0-9.-]+$", **kwargs)
def to_internal_value(self, data):
"""
Override the to_internal_value method to convert the data to lowercase.
"""
data = super().to_internal_value(data)
return data.lower()
class OrganizationActivationSerializer(serializers.Serializer): # pylint: disable=abstract-method
"""
Serializer for organization activation.
We need enough information to create the organization's first user.
"""
first_name = serializers.CharField()
last_name = serializers.CharField()
email_local_part = EmailLocalPartCharField()
password = serializers.CharField(write_only=True)
def validate(self, attrs):
"""
Override the validate method to validate password.
"""
validate_password(attrs["password"])
return super().validate(attrs)
@@ -0,0 +1,17 @@
"""Throttles for the la suite plugin."""
from rest_framework import throttling
class OrganizationTokenAnonRateThrottle(throttling.AnonRateThrottle):
"""
Throttle for organization token requests.
"""
scope = "organization-token-anon"
def get_rate(self):
"""
Get the rate for the throttle.
"""
return "5/min"
+79 -3
View File
@@ -1,16 +1,28 @@
"""API viewsets for La Suite plugin"""
"""API viewsets for La Suite plugin."""
from functools import reduce
from operator import iconcat
from rest_framework import viewsets
from django.contrib.auth.hashers import make_password
from django.db import transaction
from django.utils import timezone
from rest_framework import status, viewsets
from rest_framework.decorators import action
from rest_framework.mixins import ListModelMixin
from rest_framework.permissions import BasePermission
from rest_framework.permissions import BasePermission, IsAuthenticated
from rest_framework.response import Response
from core.authentication.backends import AccountServiceAuthentication
from core.models import Organization
from mailbox_manager.models import Mailbox
from mailbox_manager.utils.dimail import DimailAPIClient
from plugins.la_suite.authentication import OrganizationOneTimeTokenAuthentication
from .serializers import OrganizationActivationSerializer
from .throttle import OrganizationTokenAnonRateThrottle
class ScopeAPIPermission(BasePermission):
"""Permission to check if the user has the correct scope."""
@@ -54,3 +66,67 @@ class ActiveOrganizationsSiret(viewsets.GenericViewSet, ListModelMixin):
registration_ids = reduce(iconcat, registration_ids_lists, [])
return Response(registration_ids)
class OrganizationActivation(viewsets.ViewSet):
"""ViewSet to activate an organization.
Activate organization and create a mailbox for the first user.
Endpoint: POST /la-suite/v1.0/activate-organization/
"""
authentication_classes = [OrganizationOneTimeTokenAuthentication]
throttle_classes = [OrganizationTokenAnonRateThrottle]
permission_classes = [IsAuthenticated]
@action(detail=False, methods=["post"], url_path="activate-organization")
def activate_organization(self, request):
"""Activate the organization by creating the first user.
Args:
request: The HTTP request object
Returns:
Response: Success message with 201 status code
Raises:
ValidationError: If request data is invalid
MailDomain.DoesNotExist: If organization has no mail domain
"""
organization = request.user
serializer = OrganizationActivationSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
domain = organization.mail_domains.get() # should fail on purpose
mailbox_data = {
"first_name": serializer.data["first_name"],
"last_name": serializer.data["last_name"],
"local_part": serializer.data["email_local_part"],
"domain": domain,
"password": make_password(request.data["password"]),
}
with transaction.atomic():
# Create the first mailbox which will allow the user to log in
mailbox = Mailbox.objects.create(**mailbox_data)
# send new mailbox request to dimail
client = DimailAPIClient()
client.create_mailbox(mailbox)
# Enable the organization
organization.is_active = True
organization.save(update_fields=["is_active", "updated_at"])
# Disable the one-time token
token = self.request.auth
token.enabled = False
token.used_at = timezone.now()
token.save(update_fields=["enabled", "used_at"])
return Response(
status=status.HTTP_201_CREATED,
data={"message": "Organization activated. First user created."},
)
@@ -0,0 +1,93 @@
"""Test the organization activation API."""
import re
from django.urls import reverse
import pytest
import responses
from rest_framework import status
from rest_framework.test import APIClient
from mailbox_manager import factories as mailbox_factories
from mailbox_manager import models as mailbox_models
from mailbox_manager.tests.fixtures.dimail import (
TOKEN_OK,
response_mailbox_created,
)
from plugins.la_suite import factories
pytestmark = pytest.mark.django_db
API_URL = reverse("la-suite:organizations-activate-organization")
# pylint: disable=unused-argument
def test_organization_activation_unauthorized(plugin_urls):
"""Test the organization activation API unauthorized"""
client = APIClient()
response = client.post(API_URL)
assert response.status_code == status.HTTP_401_UNAUTHORIZED
# pylint: disable=unused-argument
@responses.activate
def test_organization_activation_authorized(plugin_urls):
"""Test the organization activation API authorized"""
# create one-time token for mailbox provisioning
token = factories.OrganizationOneTimeTokenFactory()
organization = token.organization
# create domain linked to organization
domain = mailbox_factories.MailDomainEnabledFactory(
organization=organization, name="example.com"
)
# mailbox data to be used in API call to create the firstmailbox
mailbox_data = {
"first_name": "John",
"last_name": "Doe",
"email_local_part": "john.doe",
"password": "password1234",
}
# mock dimail API call for mailbox creation
responses.add(
responses.GET,
re.compile(r".*/token/"),
body=TOKEN_OK,
status=status.HTTP_200_OK,
content_type="application/json",
)
responses.add(
responses.POST,
re.compile(rf".*/domains/{domain.name}/mailboxes/"),
body=response_mailbox_created(
f"{mailbox_data['email_local_part']}@{domain.name}"
),
status=status.HTTP_201_CREATED,
content_type="application/json",
)
# call API
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"OrganizationToken {token.key}")
response = client.post(API_URL, data=mailbox_data)
assert response.status_code == status.HTTP_201_CREATED
# check organization is activated
organization.refresh_from_db()
assert organization.is_active
# check user is created
mailbox = mailbox_models.Mailbox.objects.get()
assert mailbox.first_name == "John"
assert mailbox.last_name == "Doe"
assert mailbox.local_part == "john.doe"
assert mailbox.domain == domain
assert mailbox.password
# check one-time token is disabled
token.refresh_from_db()
assert token.enabled is False
+8 -1
View File
@@ -2,7 +2,9 @@
from rest_framework.routers import DefaultRouter
from .api.viewsets import ActiveOrganizationsSiret
from .api.viewsets import ActiveOrganizationsSiret, OrganizationActivation
app_name = "la-suite"
router = DefaultRouter()
router.register(
@@ -10,5 +12,10 @@ router.register(
ActiveOrganizationsSiret,
basename="active-organization-sirets",
)
router.register(
"la-suite/v1.0",
OrganizationActivation,
basename="organizations",
)
urlpatterns = router.urls