Skip to content

Commit a3495c8

Browse files
jbpenrathsylvinus
andcommitted
✨(backend) complete admin maildomain api
- Create user (if needed) and mailbox access on mailbox creation - If identify provider is keycloak, generate a one time password at mailbox creation - At nested endpoint to search users related to a maildomain Co-authored-by: Sylvain Zimmer <[email protected]>
1 parent d3df815 commit a3495c8

File tree

8 files changed

+1052
-77
lines changed

8 files changed

+1052
-77
lines changed

src/backend/core/admin.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,11 @@ class UserAdmin(auth_admin.UserAdmin):
141141
)
142142
search_fields = ("id", "sub", "admin_email", "email", "full_name")
143143

144+
class MailDomainAccessInline(admin.TabularInline):
145+
"""Inline class for the MailDomainAccess model"""
146+
147+
model = models.MailDomainAccess
148+
144149

145150
class MailDomainAccessInline(admin.TabularInline):
146151
"""Inline class for the MailDomainAccess model"""

src/backend/core/api/openapi.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4827,4 +4827,4 @@
48274827
}
48284828
}
48294829
}
4830-
}
4830+
}

src/backend/core/api/serializers.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -532,6 +532,23 @@ class Meta:
532532
]
533533
read_only_fields = fields
534534

535+
class MailboxAdminCreateSerializer(MailboxAdminSerializer):
536+
"""
537+
Serialize Mailbox details for create admin endpoint, including users with access and
538+
metadata.
539+
"""
540+
541+
one_time_password = serializers.SerializerMethodField(read_only=True, required=False)
542+
543+
def get_one_time_password(self, instance) -> str | None:
544+
"""
545+
Fake method just to make the OpenAPI schema valid.
546+
"""
547+
548+
class Meta:
549+
model = models.Mailbox
550+
fields = MailboxAdminSerializer.Meta.fields + ["one_time_password"]
551+
read_only_fields = fields
535552

536553
class ImportBaseSerializer(serializers.Serializer):
537554
"""Base serializer for import actions that disables create and update."""

src/backend/core/api/viewsets/maildomain.py

Lines changed: 120 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,27 @@
11
"""Admin ViewSets for MailDomain and Mailbox management."""
22

3+
from django.conf import settings
4+
from django.db.models import Q
35
from django.shortcuts import get_object_or_404
46
from django.utils.translation import gettext_lazy as _ # For user-facing error messages
57

6-
from rest_framework import mixins, status, viewsets
8+
from drf_spectacular.utils import (
9+
OpenApiParameter,
10+
OpenApiResponse,
11+
OpenApiTypes,
12+
extend_schema,
13+
inline_serializer,
14+
)
15+
from rest_framework import mixins, response, status, viewsets, serializers as drf_serializers
716
from rest_framework.response import Response
817

918
from core import models
1019
from core.api import permissions as core_permissions
1120
from core.api import serializers as core_serializers
21+
from core.identity.keycloak import reset_keycloak_user_password
1222

1323

14-
class MailDomainAdminViewSet(mixins.ListModelMixin, viewsets.GenericViewSet):
24+
class AdminMailDomainViewSet(mixins.ListModelMixin, viewsets.GenericViewSet):
1525
"""
1626
ViewSet for listing MailDomains the user administers.
1727
Provides a top-level entry for mail domain administration.
@@ -26,16 +36,13 @@ def get_queryset(self):
2636
if not user or not user.is_authenticated:
2737
return models.MailDomain.objects.none()
2838

29-
accessible_maildomain_ids = models.MailDomainAccess.objects.filter(
30-
user=user, role=models.MailDomainAccessRoleChoices.ADMIN
31-
).values_list("maildomain_id", flat=True)
32-
3339
return models.MailDomain.objects.filter(
34-
id__in=list(accessible_maildomain_ids)
40+
accesses__user=user,
41+
accesses__role=models.MailDomainAccessRoleChoices.ADMIN
3542
).order_by("name")
3643

3744

38-
class MailboxAdminViewSet(
45+
class AdminMailDomainMailboxViewSet(
3946
mixins.CreateModelMixin,
4047
mixins.RetrieveModelMixin,
4148
mixins.UpdateModelMixin,
@@ -65,10 +72,36 @@ def get_queryset(self):
6572
"local_part"
6673
)
6774

75+
@extend_schema(
76+
description="Create new mailbox in a specific maildomain.",
77+
request=inline_serializer(
78+
name="MailboxAdminCreatePayload",
79+
fields={
80+
"local_part": drf_serializers.CharField(required=True),
81+
"alias_of": drf_serializers.UUIDField(required=False),
82+
"metadata": inline_serializer(
83+
name="MailboxAdminCreateMetadata",
84+
fields={
85+
"type": drf_serializers.ChoiceField(choices=("personal", "shared", "redirect"), required=True),
86+
"first_name": drf_serializers.CharField(required=False, allow_blank=True),
87+
"last_name": drf_serializers.CharField(required=False, allow_blank=True),
88+
},
89+
),
90+
},
91+
),
92+
responses={
93+
200: OpenApiResponse(
94+
response=core_serializers.MailboxAdminCreateSerializer(),
95+
description="The new mailbox with one extra field `one_time_password` if identity provider is keycloak.",
96+
),
97+
},
98+
)
6899
def create(self, request, *args, **kwargs):
69100
maildomain_pk = self.kwargs.get("maildomain_pk")
70101
domain = get_object_or_404(models.MailDomain, pk=maildomain_pk)
102+
metadata = request.data.get("metadata", {})
71103

104+
mailbox_type = metadata.get("type")
72105
local_part = request.data.get("local_part")
73106
alias_of_id = request.data.get("alias_of")
74107

@@ -121,8 +154,86 @@ def create(self, request, *args, **kwargs):
121154
domain=domain, local_part=local_part, alias_of=alias_of
122155
)
123156

157+
# --- Create user and mailbox access if type is personal ---
158+
if mailbox_type == "personal":
159+
email = f"{local_part}@{domain.name}"
160+
first_name = metadata.get("first_name")
161+
last_name = metadata.get("last_name")
162+
user, _created = models.User.objects.get_or_create(
163+
email=email,
164+
defaults={
165+
"full_name": f"{first_name} {last_name}",
166+
"short_name": first_name,
167+
"password": "?",
168+
}
169+
)
170+
models.MailboxAccess.objects.create(
171+
mailbox=mailbox,
172+
user=user,
173+
role=models.MailboxRoleChoices.ADMIN,
174+
)
175+
124176
serializer = self.get_serializer(mailbox)
125177
headers = self.get_success_headers(serializer.data)
178+
payload = serializer.data
179+
if mailbox_type == "personal" and settings.IDENTITY_PROVIDER == "keycloak":
180+
mailbox_password = reset_keycloak_user_password(email)
181+
payload["one_time_password"] = mailbox_password
126182
return Response(
127-
serializer.data, status=status.HTTP_201_CREATED, headers=headers
183+
payload, status=status.HTTP_201_CREATED, headers=headers
128184
)
185+
186+
class AdminMailDomainUserViewSet(mixins.ListModelMixin, viewsets.GenericViewSet):
187+
"""
188+
ViewSet for listing users in a specific MailDomain.
189+
Nested under /maildomains/{maildomain_pk}/users/
190+
Permissions are checked by IsMailDomainAdmin for the maildomain_pk.
191+
"""
192+
193+
permission_classes = [
194+
core_permissions.IsAuthenticated,
195+
core_permissions.IsMailDomainAdmin,
196+
]
197+
serializer_class = core_serializers.UserSerializer
198+
pagination_class = None
199+
200+
def get_queryset(self):
201+
"""
202+
Get all users having an access to a mailbox or an admin access to the maildomain.
203+
"""
204+
maildomain_pk = self.kwargs.get("maildomain_pk")
205+
# Get all users with an email ending with maildomain.name or with an admin access to the maildomain
206+
return models.User.objects.filter(
207+
Q(mailbox_accesses__mailbox__domain_id=maildomain_pk)
208+
| Q(
209+
maildomain_accesses__maildomain_id=maildomain_pk,
210+
maildomain_accesses__role=models.MailDomainAccessRoleChoices.ADMIN
211+
)).distinct().order_by("full_name", "short_name", "email")
212+
213+
@extend_schema(
214+
tags=["admin-maildomain-user"],
215+
parameters=[
216+
OpenApiParameter(
217+
name="q",
218+
type=OpenApiTypes.STR,
219+
location=OpenApiParameter.QUERY,
220+
description="Search maildomains user by full name, short name or email.",
221+
),
222+
],
223+
responses=core_serializers.UserSerializer(many=True),
224+
)
225+
def list(self, request, *args, **kwargs):
226+
"""
227+
Search users by email, first name and last name.
228+
"""
229+
queryset = self.get_queryset()
230+
231+
if query := request.query_params.get("q", ""):
232+
queryset = queryset.filter(
233+
Q(email__unaccent__icontains=query)
234+
| Q(full_name__unaccent__icontains=query)
235+
| Q(short_name__unaccent__icontains=query)
236+
)
237+
238+
serializer = core_serializers.UserSerializer(queryset, many=True)
239+
return response.Response(serializer.data)
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
"""Tests for the MailDomain Admin API endpoints."""
2+
# pylint: disable=unused-argument
3+
4+
from django.urls import reverse
5+
6+
import pytest
7+
from rest_framework import status
8+
9+
from core import factories
10+
from core.enums import MailboxRoleChoices, MailDomainAccessRoleChoices
11+
12+
pytestmark = pytest.mark.django_db
13+
14+
15+
@pytest.fixture(name="domain_admin_user")
16+
def fixture_domain_admin_user():
17+
"""Create a user for domain administration testing."""
18+
return factories.UserFactory()
19+
20+
21+
@pytest.fixture(name="other_user")
22+
def fixture_other_user():
23+
"""Create another user without admin privileges."""
24+
return factories.UserFactory()
25+
26+
27+
@pytest.fixture(name="mail_domain1")
28+
def fixture_mail_domain1():
29+
"""Create the first mail domain for testing."""
30+
return factories.MailDomainFactory(name="admin-domain1.com")
31+
32+
33+
@pytest.fixture(name="mail_domain2")
34+
def fixture_mail_domain2():
35+
"""Create the second mail domain for testing."""
36+
return factories.MailDomainFactory(name="admin-domain2.com")
37+
38+
39+
@pytest.fixture(name="unmanaged_domain")
40+
def fixture_unmanaged_domain():
41+
"""Create a mail domain that has no admin access set up."""
42+
return factories.MailDomainFactory(name="unmanaged-domain.com")
43+
44+
45+
@pytest.fixture(name="domain_admin_access1")
46+
def fixture_domain_admin_access1(domain_admin_user, mail_domain1):
47+
"""Create admin access for domain_admin_user to mail_domain1."""
48+
return factories.MailDomainAccessFactory(
49+
user=domain_admin_user,
50+
maildomain=mail_domain1,
51+
role=MailDomainAccessRoleChoices.ADMIN,
52+
)
53+
54+
55+
@pytest.fixture(name="domain_admin_access2")
56+
def fixture_domain_admin_access2(domain_admin_user, mail_domain2):
57+
"""Create admin access for domain_admin_user to mail_domain2."""
58+
return factories.MailDomainAccessFactory(
59+
user=domain_admin_user,
60+
maildomain=mail_domain2,
61+
role=MailDomainAccessRoleChoices.ADMIN,
62+
)
63+
64+
65+
@pytest.fixture(name="mailbox1_domain1")
66+
def fixture_mailbox1_domain1(mail_domain1):
67+
"""Create the first mailbox in mail_domain1."""
68+
return factories.MailboxFactory(domain=mail_domain1, local_part="box1")
69+
70+
71+
@pytest.fixture(name="mailbox2_domain1")
72+
def fixture_mailbox2_domain1(mail_domain1):
73+
"""Create the second mailbox in mail_domain1."""
74+
return factories.MailboxFactory(domain=mail_domain1, local_part="box2")
75+
76+
77+
@pytest.fixture(name="mailbox1_domain2")
78+
def fixture_mailbox1_domain2(mail_domain2):
79+
"""Create a mailbox in mail_domain2."""
80+
return factories.MailboxFactory(domain=mail_domain2, local_part="boxA")
81+
82+
83+
@pytest.fixture(name="user_for_access1")
84+
def fixture_user_for_access1():
85+
"""Create a user for mailbox access testing."""
86+
return factories.UserFactory(email="[email protected]")
87+
88+
89+
@pytest.fixture(name="user_for_access2")
90+
def fixture_user_for_access2():
91+
"""Create another user for mailbox access testing."""
92+
return factories.UserFactory(email="[email protected]")
93+
94+
95+
@pytest.fixture(name="access_mailbox1_user1")
96+
def fixture_access_mailbox1_user1(mailbox1_domain1, user_for_access1):
97+
"""Create EDITOR access for user_for_access1 to mailbox1_domain1."""
98+
return factories.MailboxAccessFactory(
99+
mailbox=mailbox1_domain1, user=user_for_access1, role=MailboxRoleChoices.EDITOR
100+
)
101+
102+
103+
@pytest.fixture(name="access_mailbox1_user2")
104+
def fixture_access_mailbox1_user2(mailbox1_domain1, user_for_access2):
105+
"""Create VIEWER access for user_for_access2 to mailbox1_domain1."""
106+
return factories.MailboxAccessFactory(
107+
mailbox=mailbox1_domain1, user=user_for_access2, role=MailboxRoleChoices.VIEWER
108+
)
109+
110+
111+
class TestAdminMailDomainViewSet:
112+
"""Tests for the AdminMailDomainViewSet."""
113+
114+
LIST_DOMAINS_URL = reverse("admin-maildomains-list")
115+
116+
def test_admin_maildomains_list_administered_maildomains_success(
117+
self,
118+
api_client,
119+
domain_admin_user,
120+
domain_admin_access1,
121+
domain_admin_access2,
122+
mail_domain1,
123+
mail_domain2,
124+
unmanaged_domain,
125+
):
126+
"""Test that a domain admin can list domains they have admin access to."""
127+
api_client.force_authenticate(user=domain_admin_user)
128+
response = api_client.get(self.LIST_DOMAINS_URL)
129+
130+
assert response.status_code == status.HTTP_200_OK
131+
assert response.data["count"] == 2
132+
domain_ids = [item["id"] for item in response.data["results"]]
133+
assert str(mail_domain1.id) in domain_ids
134+
assert str(mail_domain2.id) in domain_ids
135+
assert str(unmanaged_domain.id) not in domain_ids
136+
137+
def test_admin_maildomains_list_administered_maildomains_no_admin_access(
138+
self, api_client, other_user, mail_domain1
139+
):
140+
"""Test that users without domain admin access get an empty list."""
141+
# other_user has no MailDomainAccess records
142+
api_client.force_authenticate(user=other_user)
143+
response = api_client.get(self.LIST_DOMAINS_URL)
144+
assert response.status_code == status.HTTP_200_OK
145+
assert response.data["count"] == 0
146+
147+
def test_admin_maildomains_list_administered_maildomains_unauthenticated(self, api_client):
148+
"""Test that unauthenticated requests to list domains are rejected."""
149+
response = api_client.get(self.LIST_DOMAINS_URL)
150+
assert response.status_code == status.HTTP_401_UNAUTHORIZED

0 commit comments

Comments
 (0)