diff --git a/CHANGELOG.md b/CHANGELOG.md index 797ba4d5..e92ece7d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/src/backend/plugins/la_suite/api/serializers.py b/src/backend/plugins/la_suite/api/serializers.py new file mode 100644 index 00000000..3a5cbb47 --- /dev/null +++ b/src/backend/plugins/la_suite/api/serializers.py @@ -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) diff --git a/src/backend/plugins/la_suite/api/throttle.py b/src/backend/plugins/la_suite/api/throttle.py new file mode 100644 index 00000000..ba8316cc --- /dev/null +++ b/src/backend/plugins/la_suite/api/throttle.py @@ -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" diff --git a/src/backend/plugins/la_suite/api/viewsets.py b/src/backend/plugins/la_suite/api/viewsets.py index 34f05b2e..0639dd58 100644 --- a/src/backend/plugins/la_suite/api/viewsets.py +++ b/src/backend/plugins/la_suite/api/viewsets.py @@ -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."}, + ) diff --git a/src/backend/plugins/la_suite/tests/api/test_organization_activation.py b/src/backend/plugins/la_suite/tests/api/test_organization_activation.py new file mode 100644 index 00000000..95c4d34e --- /dev/null +++ b/src/backend/plugins/la_suite/tests/api/test_organization_activation.py @@ -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 diff --git a/src/backend/plugins/la_suite/urls.py b/src/backend/plugins/la_suite/urls.py index d1fb80f8..199ac30f 100644 --- a/src/backend/plugins/la_suite/urls.py +++ b/src/backend/plugins/la_suite/urls.py @@ -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