mirror of
https://github.com/suitenumerique/messages.git
synced 2026-08-17 21:25:41 +02:00
✨(admin) add maildomainaccess model, api route and backend tests (#102)
* ✨(admin) add maildomainacess model, api route and backend tests * ♻️(drf) simplify API code
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
# Protect workflow files
|
||||
.github/workflows/ @suitenumerique/admins
|
||||
.github/CODEOWNERS @suitenumerique/admins
|
||||
@@ -83,7 +83,7 @@ bootstrap: \
|
||||
create-env-files \
|
||||
build \
|
||||
migrate \
|
||||
back-i18n-compile \
|
||||
# back-i18n-compile \
|
||||
frontend-install-frozen
|
||||
.PHONY: bootstrap
|
||||
|
||||
@@ -174,7 +174,7 @@ lint-ruff-check: ## lint back-end python sources with ruff
|
||||
|
||||
lint-back: ## lint back-end python sources with pylint
|
||||
@echo 'lint:pylint started…'
|
||||
@$(COMPOSE_RUN_APP_TOOLS) sh -c "pylint **/*.py"
|
||||
@$(COMPOSE_RUN_APP_TOOLS) sh -c "pylint ."
|
||||
.PHONY: lint-back
|
||||
|
||||
lint-mta-in: ## lint mta-in python sources with pylint
|
||||
|
||||
@@ -4,6 +4,7 @@ source "$(dirname "${BASH_SOURCE[0]}")/_config.sh"
|
||||
|
||||
_dc_run \
|
||||
-e DJANGO_CONFIGURATION=Test \
|
||||
--no-deps \
|
||||
backend-dev \
|
||||
python manage.py spectacular \
|
||||
--api-version 'v1.0' \
|
||||
|
||||
@@ -438,6 +438,8 @@ valid-metaclass-classmethod-first-arg=mcs
|
||||
# Maximum number of arguments for function / method
|
||||
max-args=10
|
||||
|
||||
max-positional-arguments=15
|
||||
|
||||
# Maximum number of attributes for a class (see R0902).
|
||||
max-attributes=7
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -97,7 +97,6 @@ class IsAllowedToAccess(IsAuthenticated):
|
||||
|
||||
# If it's a detail action (retrieve, update, destroy), object-level permission is checked
|
||||
# by has_object_permission. If it's a list action without filters, deny access.
|
||||
# Check if view has 'action' attribute and if it's 'list'
|
||||
is_list_action = hasattr(view, "action") and view.action == "list"
|
||||
|
||||
if not is_list_action:
|
||||
@@ -236,48 +235,6 @@ class IsAllowedToCreateMessage(IsAuthenticated):
|
||||
return True
|
||||
|
||||
|
||||
# class IsAllowedToSendMessage(IsAuthenticated):
|
||||
# """Permission class for access to send a message."""
|
||||
#
|
||||
# def has_permission(self, request, view):
|
||||
# """Check if user is allowed to send a message."""
|
||||
# # a sender is required to create a message
|
||||
#
|
||||
# if not IsAuthenticated.has_permission(self, request, view):
|
||||
# return False
|
||||
#
|
||||
# sender_id = request.data.get("senderId")
|
||||
# if not sender_id:
|
||||
# return False
|
||||
# # get mailbox instance from sender id
|
||||
# try:
|
||||
# view.mailbox = models.Mailbox.objects.get(id=sender_id)
|
||||
# except models.Mailbox.DoesNotExist:
|
||||
# return False
|
||||
# # required permissions to send a message
|
||||
# permissions_required = [
|
||||
# enums.MailboxPermissionChoices.SEND,
|
||||
# enums.MailboxPermissionChoices.ADMIN,
|
||||
# ]
|
||||
# # check if user has access required to send a message with this mailbox
|
||||
# if not view.mailbox.accesses.filter(
|
||||
# user=request.user,
|
||||
# permission__in=permissions_required,
|
||||
# ).exists():
|
||||
# # user does not have permission to send a message with this mailbox
|
||||
# return False
|
||||
#
|
||||
# # check if user has access to the thread
|
||||
# if models.ThreadAccess.objects.filter(
|
||||
# mailbox=view.mailbox,
|
||||
# thread=view.thread,
|
||||
# role=models.ThreadAccessRoleChoices.EDITOR,
|
||||
# ).exists():
|
||||
# return True
|
||||
#
|
||||
# return False
|
||||
|
||||
|
||||
class IsAllowedToManageThreadAccess(IsAuthenticated):
|
||||
"""Permission class for access to create, update, delete and list thread accesses."""
|
||||
|
||||
@@ -290,7 +247,8 @@ class IsAllowedToManageThreadAccess(IsAuthenticated):
|
||||
# if create action, check if user has admin/editor access to the mailbox and the thread access role is editor
|
||||
if view.action == "create":
|
||||
# authenticated user wants to create a thread access for a specific thread
|
||||
# check if user has admin/editor access to the mailbox and the thread access role is editor already exists for them
|
||||
# check if user has admin/editor access to the mailbox and the
|
||||
# thread access role is editor already exists for them
|
||||
return (
|
||||
models.ThreadAccess.objects.select_related("mailbox")
|
||||
.filter(
|
||||
@@ -304,7 +262,7 @@ class IsAllowedToManageThreadAccess(IsAuthenticated):
|
||||
)
|
||||
.exists()
|
||||
)
|
||||
elif view.action == "list":
|
||||
if view.action == "list":
|
||||
# list is only allowed for a user with access to the thread
|
||||
return (
|
||||
models.ThreadAccess.objects.select_related("mailbox")
|
||||
@@ -319,15 +277,15 @@ class IsAllowedToManageThreadAccess(IsAuthenticated):
|
||||
)
|
||||
.exists()
|
||||
)
|
||||
else:
|
||||
return True # to proceed to object-level checks
|
||||
|
||||
return True # to proceed to object-level checks
|
||||
|
||||
def has_object_permission(self, request, view, obj):
|
||||
"""Check if user has permission to access the specific object (ThreadAccess).
|
||||
Manage retrieve, update, destroy actions here.
|
||||
"""
|
||||
# Verify the thread access belongs to the thread in the URL
|
||||
if str(obj.thread.id) != view.kwargs.get("thread_id"):
|
||||
if obj.thread.id != view.kwargs.get("thread_id"):
|
||||
return False
|
||||
|
||||
return (
|
||||
@@ -343,3 +301,109 @@ class IsAllowedToManageThreadAccess(IsAuthenticated):
|
||||
)
|
||||
.exists()
|
||||
)
|
||||
|
||||
|
||||
class IsMailDomainAdmin(permissions.BasePermission):
|
||||
"""
|
||||
Allows access only to users who have ADMIN MailDomainAccess
|
||||
to the maildomain specified by 'maildomain_pk' in the URL.
|
||||
Used for viewsets nested under a maildomain.
|
||||
"""
|
||||
|
||||
message = "You do not have administrative rights for this mail domain."
|
||||
|
||||
def has_permission(self, request, view):
|
||||
if not request.user or not request.user.is_authenticated:
|
||||
return False
|
||||
|
||||
maildomain_pk = view.kwargs.get("maildomain_pk")
|
||||
if not maildomain_pk:
|
||||
return False
|
||||
|
||||
return models.MailDomainAccess.objects.filter(
|
||||
user=request.user,
|
||||
maildomain_id=maildomain_pk,
|
||||
role=models.MailDomainAccessRoleChoices.ADMIN,
|
||||
).exists()
|
||||
|
||||
# No has_object_permission, assumes objects are correctly scoped by view's get_queryset
|
||||
# based on the maildomain_pk.
|
||||
|
||||
|
||||
class IsMailboxAdmin(permissions.BasePermission):
|
||||
"""
|
||||
Allows access if the user has ADMIN MailboxAccess to the specific Mailbox
|
||||
identified by `view.kwargs['mailbox_id']`, OR if the user has ADMIN
|
||||
MailDomainAccess to the domain of that Mailbox.
|
||||
"""
|
||||
|
||||
message = "You do not have administrative rights for this mailbox or its domain."
|
||||
|
||||
def has_permission(self, request, view):
|
||||
if not request.user or not request.user.is_authenticated:
|
||||
return False
|
||||
|
||||
user = request.user
|
||||
mailbox_id_from_url = view.kwargs.get("mailbox_id")
|
||||
if not mailbox_id_from_url:
|
||||
return False # Should not happen with correct URL configuration
|
||||
|
||||
try:
|
||||
target_mailbox = models.Mailbox.objects.select_related("domain").get(
|
||||
pk=mailbox_id_from_url
|
||||
)
|
||||
except (models.Mailbox.DoesNotExist, ValueError): # ValueError for invalid UUID
|
||||
return False
|
||||
|
||||
# Check 1: Is user an admin of the specific mailbox?
|
||||
is_mailbox_admin = models.MailboxAccess.objects.filter(
|
||||
user=user, mailbox=target_mailbox, role=models.MailboxRoleChoices.ADMIN
|
||||
).exists()
|
||||
|
||||
if is_mailbox_admin:
|
||||
return True
|
||||
|
||||
# Check 2: Is user an admin of the mailbox's domain?
|
||||
if target_mailbox.domain:
|
||||
is_domain_admin = models.MailDomainAccess.objects.filter(
|
||||
user=user,
|
||||
maildomain=target_mailbox.domain,
|
||||
role=models.MailDomainAccessRoleChoices.ADMIN,
|
||||
).exists()
|
||||
if is_domain_admin:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def has_object_permission(self, request, view, obj):
|
||||
# obj is a MailboxAccess instance.
|
||||
if not request.user or not request.user.is_authenticated:
|
||||
return False
|
||||
|
||||
if not hasattr(obj, "mailbox") or not obj.mailbox or not obj.mailbox.domain:
|
||||
return False # MailboxAccess must be linked to a Mailbox with a Domain
|
||||
|
||||
# Ensure the object being acted upon belongs to the mailbox specified in the URL
|
||||
mailbox_id_from_url = view.kwargs.get("mailbox_id")
|
||||
if str(obj.mailbox.id) != str(mailbox_id_from_url):
|
||||
return False # Object's mailbox does not match URL mailbox
|
||||
|
||||
user = request.user
|
||||
target_mailbox = obj.mailbox # The mailbox related to the MailboxAccess object
|
||||
|
||||
# Check 1: Is user an admin of this specific mailbox?
|
||||
is_mailbox_admin = models.MailboxAccess.objects.filter(
|
||||
user=user, mailbox=target_mailbox, role=models.MailboxRoleChoices.ADMIN
|
||||
).exists()
|
||||
|
||||
if is_mailbox_admin:
|
||||
return True
|
||||
|
||||
# Check 2: Is user an admin of the mailbox's domain?
|
||||
is_domain_admin = models.MailDomainAccess.objects.filter(
|
||||
user=user,
|
||||
maildomain=target_mailbox.domain,
|
||||
role=models.MailDomainAccessRoleChoices.ADMIN,
|
||||
).exists()
|
||||
|
||||
return is_domain_admin
|
||||
|
||||
@@ -27,6 +27,7 @@ class MailboxAvailableSerializer(serializers.ModelSerializer):
|
||||
"""Return the contact of the mailbox."""
|
||||
if instance.contact:
|
||||
return instance.contact.name
|
||||
return None
|
||||
|
||||
def get_email(self, instance):
|
||||
"""Return the email of the mailbox."""
|
||||
@@ -332,3 +333,86 @@ class ThreadAccessSerializer(serializers.ModelSerializer):
|
||||
model = models.ThreadAccess
|
||||
fields = ["id", "thread", "mailbox", "role", "created_at", "updated_at"]
|
||||
read_only_fields = ["id", "created_at", "updated_at"]
|
||||
|
||||
|
||||
class MailboxAccessReadSerializer(serializers.ModelSerializer):
|
||||
"""Serialize mailbox access information for read operations with nested user details.
|
||||
Mailbox context is implied by the URL, so mailbox details are not included here.
|
||||
"""
|
||||
|
||||
user_details = UserSerializer(source="user", read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = models.MailboxAccess
|
||||
fields = ["id", "user_details", "role", "created_at", "updated_at"]
|
||||
read_only_fields = fields # All fields are effectively read-only from this serializer's perspective
|
||||
|
||||
|
||||
class MailboxAccessWriteSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for creating and updating mailbox access records.
|
||||
Mailbox is set from the view based on URL parameters.
|
||||
"""
|
||||
|
||||
class Meta:
|
||||
model = models.MailboxAccess
|
||||
fields = ["id", "user", "role", "created_at", "updated_at"]
|
||||
read_only_fields = ["id", "created_at", "updated_at"]
|
||||
|
||||
def validate(self, attrs):
|
||||
"""Additional validation that applies to the whole object."""
|
||||
if self.instance and "user" in attrs and attrs["user"] != self.instance.user:
|
||||
raise serializers.ValidationError(
|
||||
{
|
||||
"user": [
|
||||
"Cannot change the user of an existing mailbox access record. Delete and create a new one."
|
||||
]
|
||||
}
|
||||
)
|
||||
return attrs
|
||||
|
||||
|
||||
class MailDomainAdminSerializer(serializers.ModelSerializer):
|
||||
"""Serialize MailDomain basic information for admin listing."""
|
||||
|
||||
class Meta:
|
||||
model = models.MailDomain
|
||||
fields = ["id", "name", "created_at", "updated_at"]
|
||||
read_only_fields = fields
|
||||
|
||||
|
||||
class MailboxAccessNestedUserSerializer(serializers.ModelSerializer):
|
||||
"""
|
||||
Serialize MailboxAccess for nesting within MailboxAdminSerializer.
|
||||
Shows user details and their role on the mailbox.
|
||||
"""
|
||||
|
||||
user = UserSerializer(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = models.MailboxAccess
|
||||
fields = ["id", "user", "role"] # 'user' will be nested UserSerializer output
|
||||
read_only_fields = fields
|
||||
|
||||
|
||||
class MailboxAdminSerializer(serializers.ModelSerializer):
|
||||
"""
|
||||
Serialize Mailbox details for admin view, including users with access.
|
||||
"""
|
||||
|
||||
domain_name = serializers.CharField(source="domain.name", read_only=True)
|
||||
accesses = MailboxAccessNestedUserSerializer(
|
||||
many=True, read_only=True
|
||||
) # accesses is the related_name
|
||||
|
||||
class Meta:
|
||||
model = models.Mailbox
|
||||
fields = [
|
||||
"id",
|
||||
"local_part",
|
||||
"domain_name",
|
||||
"alias_of", # show if it's an alias
|
||||
"accesses", # List of users and their roles
|
||||
"created_at",
|
||||
"updated_at",
|
||||
]
|
||||
read_only_fields = fields
|
||||
|
||||
@@ -145,6 +145,7 @@ class BlobViewSet(ViewSet):
|
||||
{"error": "You do not have permission to download this blob"},
|
||||
status=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
# pylint: disable=broad-exception-caught
|
||||
except Exception as e:
|
||||
logger.exception("Error downloading file: %s", str(e))
|
||||
return Response(
|
||||
|
||||
@@ -273,7 +273,8 @@ class DraftMessageView(APIView):
|
||||
|
||||
if not blob_id:
|
||||
logger.warning(
|
||||
f"Missing blobId in attachment data: {attachment_data}"
|
||||
"Missing blobId in attachment data: %s",
|
||||
attachment_data,
|
||||
)
|
||||
continue
|
||||
|
||||
@@ -292,13 +293,17 @@ class DraftMessageView(APIView):
|
||||
|
||||
if created:
|
||||
logger.info(
|
||||
f"Created new attachment {attachment.id} for blob {blob_id}"
|
||||
"Created new attachment %s for blob %s",
|
||||
attachment.id,
|
||||
blob_id,
|
||||
)
|
||||
|
||||
new_attachment_ids.append(attachment.id)
|
||||
|
||||
except (ValueError, models.Blob.DoesNotExist) as e:
|
||||
logger.warning(f"Invalid or missing blob {blob_id}: {str(e)}")
|
||||
logger.warning(
|
||||
"Invalid or missing blob %s: %s", blob_id, str(e)
|
||||
)
|
||||
|
||||
# Combine all valid attachment IDs
|
||||
new_attachments = set(new_attachment_ids)
|
||||
|
||||
@@ -40,7 +40,7 @@ class MailboxViewSet(
|
||||
],
|
||||
)
|
||||
@action(detail=True, methods=["get"])
|
||||
def search(self, request, pk=None):
|
||||
def search(self, request, **kwargs):
|
||||
"""
|
||||
Search mailboxes by domain, local part and contact name.
|
||||
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
"""API ViewSet for MailboxAccess model, managed by MailDomain admins or Mailbox admins."""
|
||||
|
||||
from django.shortcuts import get_object_or_404
|
||||
|
||||
from drf_spectacular.utils import extend_schema
|
||||
from rest_framework import mixins, viewsets
|
||||
|
||||
from core import models
|
||||
from core.api import permissions as core_permissions
|
||||
from core.api import serializers as core_serializers
|
||||
|
||||
|
||||
@extend_schema(tags=["mailbox-accesses"])
|
||||
class MailboxAccessViewSet(
|
||||
mixins.CreateModelMixin,
|
||||
mixins.RetrieveModelMixin,
|
||||
mixins.UpdateModelMixin,
|
||||
mixins.DestroyModelMixin,
|
||||
mixins.ListModelMixin,
|
||||
viewsets.GenericViewSet,
|
||||
):
|
||||
"""
|
||||
ViewSet for managing MailboxAccess records for a specific Mailbox.
|
||||
The mailbox_id is expected as part of the URL.
|
||||
Access is allowed if the user has MailboxAccess (ADMIN role)
|
||||
to the target Mailbox itself, or is a domain admin of the mailbox's domain.
|
||||
"""
|
||||
|
||||
permission_classes = [
|
||||
core_permissions.IsAuthenticated,
|
||||
core_permissions.IsMailboxAdmin,
|
||||
]
|
||||
|
||||
# The lookup_field for the MailboxAccess instance itself (for retrieve, update, destroy)
|
||||
lookup_field = "pk"
|
||||
# The URL kwarg 'mailbox_id' for the parent Mailbox will be passed by the nested router
|
||||
|
||||
def get_serializer_class(self):
|
||||
"""Select serializer based on action."""
|
||||
if self.action in ["create", "update", "partial_update"]:
|
||||
return core_serializers.MailboxAccessWriteSerializer
|
||||
return core_serializers.MailboxAccessReadSerializer
|
||||
|
||||
def get_mailbox_object(self):
|
||||
"""Helper to get the parent Mailbox object from URL kwarg."""
|
||||
return get_object_or_404(models.Mailbox, pk=self.kwargs["mailbox_id"])
|
||||
|
||||
def get_queryset(self):
|
||||
"""
|
||||
Return MailboxAccess instances for the specific Mailbox from the URL.
|
||||
Permissions should have already verified the user can access this mailbox.
|
||||
"""
|
||||
mailbox = self.get_mailbox_object() # Ensures mailbox exists and handles 404
|
||||
return mailbox.accesses.select_related("user", "mailbox__domain").order_by(
|
||||
"-created_at"
|
||||
)
|
||||
|
||||
def get_serializer_context(self):
|
||||
"""Add mailbox to serializer context for validation."""
|
||||
context = super().get_serializer_context()
|
||||
if self.action == "create":
|
||||
# Add mailbox to context for validation in serializer
|
||||
context["mailbox"] = self.get_mailbox_object()
|
||||
return context
|
||||
|
||||
def perform_create(self, serializer):
|
||||
"""Set the mailbox from the URL when creating a MailboxAccess."""
|
||||
mailbox = self.get_mailbox_object()
|
||||
serializer.save(mailbox=mailbox)
|
||||
@@ -0,0 +1,128 @@
|
||||
"""Admin ViewSets for MailDomain and Mailbox management."""
|
||||
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.utils.translation import gettext_lazy as _ # For user-facing error messages
|
||||
|
||||
from rest_framework import mixins, status, viewsets
|
||||
from rest_framework.response import Response
|
||||
|
||||
from core import models
|
||||
from core.api import permissions as core_permissions
|
||||
from core.api import serializers as core_serializers
|
||||
|
||||
|
||||
class MailDomainAdminViewSet(mixins.ListModelMixin, viewsets.GenericViewSet):
|
||||
"""
|
||||
ViewSet for listing MailDomains the user administers.
|
||||
Provides a top-level entry for mail domain administration.
|
||||
Endpoint: /maildomains/
|
||||
"""
|
||||
|
||||
serializer_class = core_serializers.MailDomainAdminSerializer
|
||||
permission_classes = [core_permissions.IsAuthenticated]
|
||||
|
||||
def get_queryset(self):
|
||||
user = self.request.user
|
||||
if not user or not user.is_authenticated:
|
||||
return models.MailDomain.objects.none()
|
||||
|
||||
accessible_maildomain_ids = models.MailDomainAccess.objects.filter(
|
||||
user=user, role=models.MailDomainAccessRoleChoices.ADMIN
|
||||
).values_list("maildomain_id", flat=True)
|
||||
|
||||
return models.MailDomain.objects.filter(
|
||||
id__in=list(accessible_maildomain_ids)
|
||||
).order_by("name")
|
||||
|
||||
|
||||
class MailboxAdminViewSet(
|
||||
mixins.CreateModelMixin,
|
||||
mixins.RetrieveModelMixin,
|
||||
mixins.UpdateModelMixin,
|
||||
mixins.DestroyModelMixin,
|
||||
mixins.ListModelMixin,
|
||||
viewsets.GenericViewSet,
|
||||
):
|
||||
"""
|
||||
ViewSet for managing Mailboxes within a specific MailDomain.
|
||||
Nested under /maildomains/{maildomain_pk}/mailboxes/
|
||||
Permissions are checked by IsMailDomainAdmin for the maildomain_pk.
|
||||
|
||||
This viewset serves a different purpose than the one in mailbox.py (/api/v1.0/mailboxes/).
|
||||
That other one is for listing the mailboxes a user has access to in regular app use.
|
||||
This one is for managing mailboxes within a specific maildomain in the admin interface.
|
||||
"""
|
||||
|
||||
permission_classes = [
|
||||
core_permissions.IsAuthenticated,
|
||||
core_permissions.IsMailDomainAdmin,
|
||||
]
|
||||
serializer_class = core_serializers.MailboxAdminSerializer
|
||||
|
||||
def get_queryset(self):
|
||||
maildomain_pk = self.kwargs.get("maildomain_pk")
|
||||
return models.Mailbox.objects.filter(domain_id=maildomain_pk).order_by(
|
||||
"local_part"
|
||||
)
|
||||
|
||||
def create(self, request, *args, **kwargs):
|
||||
maildomain_pk = self.kwargs.get("maildomain_pk")
|
||||
domain = get_object_or_404(models.MailDomain, pk=maildomain_pk)
|
||||
|
||||
local_part = request.data.get("local_part")
|
||||
alias_of_id = request.data.get("alias_of")
|
||||
|
||||
# --- Validation for local_part ---
|
||||
if not local_part:
|
||||
return Response(
|
||||
{"local_part": [_("This field may not be blank.")]},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
# --- Uniqueness Validation ---
|
||||
if models.Mailbox.objects.filter(
|
||||
domain=domain, local_part__iexact=local_part
|
||||
).exists():
|
||||
return Response(
|
||||
{
|
||||
"local_part": [
|
||||
_(
|
||||
"A mailbox with this local part already exists in this domain."
|
||||
)
|
||||
]
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
alias_of = None
|
||||
if alias_of_id:
|
||||
try:
|
||||
alias_of = models.Mailbox.objects.get(pk=alias_of_id, domain=domain)
|
||||
except models.Mailbox.DoesNotExist:
|
||||
return Response(
|
||||
{
|
||||
"alias_of": [
|
||||
_(
|
||||
"Invalid mailbox ID for alias, or mailbox not in the same domain."
|
||||
)
|
||||
]
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
if alias_of.alias_of is not None: # Prevent chaining aliases for now
|
||||
return Response(
|
||||
{"alias_of": [_("Cannot create an alias of an existing alias.")]},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
# --- Create Mailbox ---
|
||||
# Will validate local_part format via the model's validator
|
||||
mailbox = models.Mailbox.objects.create(
|
||||
domain=domain, local_part=local_part, alias_of=alias_of
|
||||
)
|
||||
|
||||
serializer = self.get_serializer(mailbox)
|
||||
headers = self.get_success_headers(serializer.data)
|
||||
return Response(
|
||||
serializer.data, status=status.HTTP_201_CREATED, headers=headers
|
||||
)
|
||||
@@ -62,7 +62,3 @@ class ThreadAccessViewSet(
|
||||
"""Create a new thread access."""
|
||||
request.data["thread"] = self.kwargs.get("thread_id")
|
||||
return super().create(request, *args, **kwargs)
|
||||
|
||||
def list(self, request, *args, **kwargs):
|
||||
"""List thread accesses for the specified thread."""
|
||||
return super().list(request, *args, **kwargs)
|
||||
|
||||
@@ -53,3 +53,9 @@ class MessageDeliveryStatusChoices(models.TextChoices):
|
||||
SENT = "sent", _("Sent")
|
||||
FAILED = "failed", _("Failed")
|
||||
RETRY = "retry", _("Retry")
|
||||
|
||||
|
||||
class MailDomainAccessRoleChoices(models.TextChoices):
|
||||
"""Defines the unique roles a user can have to access a mail domain."""
|
||||
|
||||
ADMIN = "ADMIN", _("Admin")
|
||||
|
||||
@@ -108,6 +108,19 @@ class MailboxAccessFactory(factory.django.DjangoModelFactory):
|
||||
)
|
||||
|
||||
|
||||
class MailDomainAccessFactory(factory.django.DjangoModelFactory):
|
||||
"""A factory to random mail domain accesses for testing purposes."""
|
||||
|
||||
class Meta:
|
||||
model = models.MailDomainAccess
|
||||
|
||||
maildomain = factory.SubFactory(MailDomainFactory)
|
||||
user = factory.SubFactory(UserFactory)
|
||||
role = factory.fuzzy.FuzzyChoice(
|
||||
[role[0] for role in models.MailDomainAccessRoleChoices.choices]
|
||||
)
|
||||
|
||||
|
||||
class ThreadFactory(factory.django.DjangoModelFactory):
|
||||
"""A factory to random threads for testing purposes."""
|
||||
|
||||
|
||||
@@ -56,7 +56,7 @@ def _process_attachments(
|
||||
# Link the attachment to the message
|
||||
message.attachments.add(attachment)
|
||||
except Exception as e:
|
||||
logger.exception(f"Error processing attachment: {str(e)}")
|
||||
logger.exception("Error processing attachment: %s", e)
|
||||
|
||||
|
||||
def check_local_recipient(
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
# Generated by Django 5.1.8 on 2025-05-18 11:56
|
||||
|
||||
import django.db.models.deletion
|
||||
import uuid
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0012_mailbox_contact'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='MailDomainAccess',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='primary key for the record as UUID', primary_key=True, serialize=False, verbose_name='id')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, help_text='date and time at which a record was created', verbose_name='created on')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, help_text='date and time at which a record was last updated', verbose_name='updated on')),
|
||||
('role', models.CharField(choices=[('ADMIN', 'Admin')], default='ADMIN', max_length=20, verbose_name='role')),
|
||||
('maildomain', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='accesses', to='core.maildomain')),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='maildomain_accesses', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'mail domain access',
|
||||
'verbose_name_plural': 'mail domain accesses',
|
||||
'db_table': 'messages_maildomainaccess',
|
||||
'unique_together': {('maildomain', 'user')},
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,19 @@
|
||||
# Generated by Django 5.1.8 on 2025-05-21 20:06
|
||||
|
||||
import django.core.validators
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0013_maildomainaccess'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='mailbox',
|
||||
name='local_part',
|
||||
field=models.CharField(max_length=255, validators=[django.core.validators.RegexValidator(regex='^[a-zA-Z0-9_.-]+$')], verbose_name='local part'),
|
||||
),
|
||||
]
|
||||
@@ -19,6 +19,7 @@ from timezone_field import TimeZoneField
|
||||
|
||||
from core.enums import (
|
||||
MailboxRoleChoices,
|
||||
MailDomainAccessRoleChoices,
|
||||
MessageDeliveryStatusChoices,
|
||||
MessageRecipientTypeChoices,
|
||||
ThreadAccessRoleChoices,
|
||||
@@ -233,7 +234,11 @@ class MailDomain(BaseModel):
|
||||
class Mailbox(BaseModel):
|
||||
"""Mailbox model to store mailbox information."""
|
||||
|
||||
local_part = models.CharField(_("local part"), max_length=255)
|
||||
local_part = models.CharField(
|
||||
_("local part"),
|
||||
max_length=255,
|
||||
validators=[validators.RegexValidator(regex=r"^[a-zA-Z0-9_.-]+$")],
|
||||
)
|
||||
domain = models.ForeignKey("MailDomain", on_delete=models.CASCADE)
|
||||
contact = models.ForeignKey(
|
||||
"Contact",
|
||||
@@ -652,3 +657,29 @@ class Attachment(BaseModel):
|
||||
def sha256(self):
|
||||
"""Return the SHA-256 hash of the associated blob."""
|
||||
return self.blob.sha256
|
||||
|
||||
|
||||
class MailDomainAccess(BaseModel):
|
||||
"""Mail domain access model to store mail domain access information for a user."""
|
||||
|
||||
maildomain = models.ForeignKey(
|
||||
"MailDomain", on_delete=models.CASCADE, related_name="accesses"
|
||||
)
|
||||
user = models.ForeignKey(
|
||||
"User", on_delete=models.CASCADE, related_name="maildomain_accesses"
|
||||
)
|
||||
role = models.CharField(
|
||||
_("role"),
|
||||
max_length=20,
|
||||
choices=MailDomainAccessRoleChoices.choices,
|
||||
default=MailDomainAccessRoleChoices.ADMIN,
|
||||
)
|
||||
|
||||
class Meta:
|
||||
db_table = "messages_maildomainaccess"
|
||||
verbose_name = _("mail domain access")
|
||||
verbose_name_plural = _("mail domain accesses")
|
||||
unique_together = ("maildomain", "user")
|
||||
|
||||
def __str__(self):
|
||||
return f"Access to {self.maildomain} for {self.user} with {self.role} role"
|
||||
|
||||
@@ -241,8 +241,8 @@ class TestDraftWithAttachments:
|
||||
thread=thread, sender=sender, is_draft=True, subject="Existing draft"
|
||||
)
|
||||
|
||||
text_body = "This is a test draft with an attachment %i" % random.randint(
|
||||
0, 10000000
|
||||
text_body = (
|
||||
f"This is a test draft with an attachment {random.randint(0, 10000000)}"
|
||||
)
|
||||
|
||||
# Update the draft to add the blob as attachment
|
||||
@@ -281,7 +281,7 @@ class TestDraftWithAttachments:
|
||||
{
|
||||
"messageId": draft.id,
|
||||
"textBody": text_body,
|
||||
"htmlBody": "<p>%s</p>" % text_body,
|
||||
"htmlBody": f"<p>{text_body}</p>",
|
||||
"senderId": user_mailbox.id,
|
||||
},
|
||||
format="json",
|
||||
|
||||
@@ -0,0 +1,371 @@
|
||||
"""Tests for the MailboxAccessViewSet API endpoint (nested under mailboxes)."""
|
||||
# pylint: disable=unused-argument
|
||||
|
||||
from django.urls import reverse
|
||||
|
||||
import pytest
|
||||
from rest_framework import status
|
||||
|
||||
from core import factories, models
|
||||
from core.enums import MailboxRoleChoices, MailDomainAccessRoleChoices
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
# --- Users ---
|
||||
@pytest.fixture(name="domain_admin_user")
|
||||
def fixture_domain_admin_user(mail_domain1):
|
||||
"""User with ADMIN access to mail_domain1."""
|
||||
user = factories.UserFactory(email="domain.admin@example.com")
|
||||
factories.MailDomainAccessFactory(
|
||||
user=user, maildomain=mail_domain1, role=MailDomainAccessRoleChoices.ADMIN
|
||||
)
|
||||
return user
|
||||
|
||||
|
||||
@pytest.fixture(name="mailbox1_admin_user")
|
||||
def fixture_mailbox1_admin_user(mailbox1_domain1):
|
||||
"""User with ADMIN access to mailbox1_domain1, but not domain admin."""
|
||||
user = factories.UserFactory(email="mailbox1.admin@example.com")
|
||||
factories.MailboxAccessFactory(
|
||||
user=user, mailbox=mailbox1_domain1, role=MailboxRoleChoices.ADMIN
|
||||
)
|
||||
return user
|
||||
|
||||
|
||||
@pytest.fixture(name="regular_user")
|
||||
def fixture_regular_user():
|
||||
"""User with no specific admin rights relevant to these tests."""
|
||||
return factories.UserFactory(email="regular@example.com")
|
||||
|
||||
|
||||
# --- Domains & Mailboxes ---
|
||||
@pytest.fixture(name="mail_domain1")
|
||||
def fixture_mail_domain1():
|
||||
"""Create a mail domain for testing."""
|
||||
return factories.MailDomainFactory(name="domain1.com")
|
||||
|
||||
|
||||
@pytest.fixture(name="mail_domain2")
|
||||
def fixture_mail_domain2():
|
||||
"""Create a second mail domain for testing."""
|
||||
return factories.MailDomainFactory(name="domain2.com")
|
||||
|
||||
|
||||
@pytest.fixture(name="mailbox1_domain1")
|
||||
def fixture_mailbox1_domain1(mail_domain1):
|
||||
"""Create a mailbox in mail_domain1."""
|
||||
return factories.MailboxFactory(domain=mail_domain1, local_part="box1")
|
||||
|
||||
|
||||
@pytest.fixture(name="mailbox2_domain1")
|
||||
def fixture_mailbox2_domain1(mail_domain1):
|
||||
"""Create a second mailbox in mail_domain1."""
|
||||
return factories.MailboxFactory(domain=mail_domain1, local_part="box2")
|
||||
|
||||
|
||||
@pytest.fixture(name="mailbox1_domain2")
|
||||
def fixture_mailbox1_domain2(mail_domain2):
|
||||
"""Create a mailbox in mail_domain2."""
|
||||
return factories.MailboxFactory(domain=mail_domain2, local_part="boxA")
|
||||
|
||||
|
||||
# --- Initial Mailbox Accesses ---
|
||||
@pytest.fixture(name="user_alpha")
|
||||
def fixture_user_alpha():
|
||||
"""Create a user for testing mailbox access."""
|
||||
return factories.UserFactory(email="alpha@example.com")
|
||||
|
||||
|
||||
@pytest.fixture(name="user_beta")
|
||||
def fixture_user_beta():
|
||||
"""Create another user for testing mailbox access."""
|
||||
return factories.UserFactory(email="beta@example.com")
|
||||
|
||||
|
||||
@pytest.fixture(name="access_m1d1_alpha")
|
||||
def fixture_access_m1d1_alpha(mailbox1_domain1, user_alpha):
|
||||
"""Create EDITOR access for user_alpha to mailbox1_domain1."""
|
||||
return factories.MailboxAccessFactory(
|
||||
mailbox=mailbox1_domain1, user=user_alpha, role=MailboxRoleChoices.EDITOR
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(name="access_m1d1_beta")
|
||||
def fixture_access_m1d1_beta(mailbox1_domain1, user_beta):
|
||||
"""Create VIEWER access for user_beta to mailbox1_domain1."""
|
||||
return factories.MailboxAccessFactory(
|
||||
mailbox=mailbox1_domain1, user=user_beta, role=MailboxRoleChoices.VIEWER
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(name="access_m2d1_alpha")
|
||||
def fixture_access_m2d1_alpha(mailbox2_domain1, user_alpha):
|
||||
"""Create EDITOR access for user_alpha to mailbox2_domain1."""
|
||||
return factories.MailboxAccessFactory(
|
||||
mailbox=mailbox2_domain1, user=user_alpha, role=MailboxRoleChoices.EDITOR
|
||||
)
|
||||
|
||||
|
||||
class TestMailboxAccessViewSet:
|
||||
"""Tests for the MailboxAccessViewSet API endpoints."""
|
||||
|
||||
BASE_URL_LIST_CREATE_SUFFIX = "-list"
|
||||
BASE_URL_DETAIL_SUFFIX = "-detail"
|
||||
URL_BASENAME = "mailboxaccess"
|
||||
|
||||
def list_create_url(self, mailbox_id):
|
||||
"""Generate URL for listing/creating mailbox accesses."""
|
||||
# URLs are /mailboxes/{mailbox_id}/accesses/
|
||||
return reverse(
|
||||
self.URL_BASENAME + self.BASE_URL_LIST_CREATE_SUFFIX,
|
||||
kwargs={"mailbox_id": mailbox_id},
|
||||
)
|
||||
|
||||
def detail_url(self, mailbox_id, pk):
|
||||
"""Generate URL for operations on a specific mailbox access."""
|
||||
# URLs are /mailboxes/{mailbox_id}/accesses/{pk}/
|
||||
return reverse(
|
||||
self.URL_BASENAME + self.BASE_URL_DETAIL_SUFFIX,
|
||||
kwargs={"mailbox_id": mailbox_id, "pk": pk},
|
||||
)
|
||||
|
||||
# --- LIST Tests ---
|
||||
def test_list_as_domain_admin_for_managed_mailbox(
|
||||
self,
|
||||
api_client,
|
||||
domain_admin_user,
|
||||
mailbox1_domain1,
|
||||
access_m1d1_alpha,
|
||||
access_m1d1_beta,
|
||||
access_m2d1_alpha,
|
||||
):
|
||||
"""Domain admin should see accesses for the specified mailbox in their administered domain."""
|
||||
api_client.force_authenticate(
|
||||
user=domain_admin_user
|
||||
) # Admin for domain1, which mailbox1_domain1 is in
|
||||
response = api_client.get(self.list_create_url(mailbox_id=mailbox1_domain1.pk))
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
|
||||
# access_m2d1_alpha is for a different mailbox, so should not be listed here.
|
||||
assert {item["id"] for item in response.data["results"]} == {
|
||||
str(access_m1d1_alpha.pk),
|
||||
str(access_m1d1_beta.pk),
|
||||
}
|
||||
assert response.data["count"] == 2
|
||||
|
||||
def test_list_as_mailbox_admin_for_their_mailbox(
|
||||
self,
|
||||
api_client,
|
||||
domain_admin_user,
|
||||
mailbox1_admin_user,
|
||||
mailbox1_domain1,
|
||||
access_m1d1_alpha,
|
||||
access_m1d1_beta,
|
||||
access_m2d1_alpha,
|
||||
):
|
||||
"""Mailbox admin should see accesses for their specific mailbox."""
|
||||
api_client.force_authenticate(
|
||||
user=mailbox1_admin_user
|
||||
) # Admin for mailbox1_domain1
|
||||
response = api_client.get(self.list_create_url(mailbox_id=mailbox1_domain1.pk))
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
|
||||
# access_m2d1_alpha is for a different mailbox, so should not be listed here.
|
||||
# However the mailbox1_admin_user has an explicit mailboxaccess for this mailbox, so should see it.
|
||||
mailbox1_admin_user_access = models.MailboxAccess.objects.get(
|
||||
mailbox=mailbox1_domain1, user=mailbox1_admin_user
|
||||
)
|
||||
assert mailbox1_admin_user_access.role == MailboxRoleChoices.ADMIN
|
||||
|
||||
assert {item["id"] for item in response.data["results"]} == {
|
||||
str(access_m1d1_alpha.pk),
|
||||
str(access_m1d1_beta.pk),
|
||||
str(mailbox1_admin_user_access.pk),
|
||||
}
|
||||
assert response.data["count"] == 3
|
||||
|
||||
def test_list_as_mailbox_admin_for_other_mailbox_forbidden(
|
||||
self, api_client, mailbox1_admin_user, mailbox2_domain1
|
||||
):
|
||||
"""Mailbox admin should NOT see accesses for a mailbox they don't administer."""
|
||||
api_client.force_authenticate(user=mailbox1_admin_user)
|
||||
response = api_client.get(self.list_create_url(mailbox_id=mailbox2_domain1.pk))
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
|
||||
def test_list_as_regular_user_forbidden(
|
||||
self, api_client, regular_user, mailbox1_domain1
|
||||
):
|
||||
"""Regular users should not be able to list mailbox accesses."""
|
||||
api_client.force_authenticate(user=regular_user)
|
||||
response = api_client.get(self.list_create_url(mailbox_id=mailbox1_domain1.pk))
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
|
||||
def test_list_unauthenticated(self, api_client, mailbox1_domain1):
|
||||
"""Unauthenticated requests to list mailbox accesses should be rejected."""
|
||||
response = api_client.get(self.list_create_url(mailbox_id=mailbox1_domain1.pk))
|
||||
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
|
||||
# --- CREATE Tests ---
|
||||
@pytest.mark.parametrize("admin_type", ["domain_admin", "mailbox_admin"])
|
||||
def test_create_access_success(
|
||||
self,
|
||||
api_client,
|
||||
admin_type,
|
||||
domain_admin_user,
|
||||
mailbox1_admin_user,
|
||||
mailbox1_domain1,
|
||||
user_beta,
|
||||
):
|
||||
"""Domain and mailbox admins should be able to create new accesses."""
|
||||
user_performing_action = (
|
||||
domain_admin_user if admin_type == "domain_admin" else mailbox1_admin_user
|
||||
)
|
||||
api_client.force_authenticate(user=user_performing_action)
|
||||
|
||||
data = { # No 'mailbox' field in data, it comes from URL
|
||||
"user": str(user_beta.pk),
|
||||
"role": MailboxRoleChoices.EDITOR.value,
|
||||
}
|
||||
response = api_client.post(
|
||||
self.list_create_url(mailbox_id=mailbox1_domain1.pk), data
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
# Serializer might return mailbox PK if not read_only=True, or nested details.
|
||||
# For now, check what's guaranteed by create.
|
||||
assert response.data["user"] == user_beta.pk
|
||||
assert response.data["role"] == MailboxRoleChoices.EDITOR.value
|
||||
assert models.MailboxAccess.objects.filter(
|
||||
mailbox=mailbox1_domain1, user=user_beta, role=MailboxRoleChoices.EDITOR
|
||||
).exists()
|
||||
|
||||
# Try creating the same access again
|
||||
response = api_client.post(
|
||||
self.list_create_url(mailbox_id=mailbox1_domain1.pk), data
|
||||
)
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
|
||||
def test_create_access_by_mailbox_admin_for_unmanaged_mailbox_forbidden(
|
||||
self, api_client, mailbox1_admin_user, mailbox1_domain2, user_beta
|
||||
):
|
||||
"""Mailbox admin should not be able to create accesses for unmanaged mailboxes."""
|
||||
api_client.force_authenticate(user=mailbox1_admin_user)
|
||||
data = {"user": str(user_beta.pk), "role": MailboxRoleChoices.EDITOR.value}
|
||||
response = api_client.post(
|
||||
self.list_create_url(mailbox_id=mailbox1_domain2.pk), data
|
||||
) # Attempt on mailbox1_domain2
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
|
||||
def test_create_access_by_domain_admin_for_unmanaged_domain_mailbox_forbidden(
|
||||
self, api_client, domain_admin_user, mailbox1_domain2, user_beta
|
||||
):
|
||||
"""Domain admin should not be able to create accesses for mailboxes in unmanaged domains."""
|
||||
api_client.force_authenticate(user=domain_admin_user) # Admin for domain1
|
||||
data = {"user": str(user_beta.pk), "role": MailboxRoleChoices.EDITOR.value}
|
||||
response = api_client.post(
|
||||
self.list_create_url(mailbox_id=mailbox1_domain2.pk), data
|
||||
) # mailbox1_domain2 is in domain2
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
|
||||
# --- RETRIEVE Tests ---
|
||||
@pytest.mark.parametrize("admin_type", ["domain_admin", "mailbox_admin"])
|
||||
def test_retrieve_access_success(
|
||||
self,
|
||||
api_client,
|
||||
admin_type,
|
||||
domain_admin_user,
|
||||
mailbox1_admin_user,
|
||||
mailbox1_domain1,
|
||||
access_m1d1_alpha,
|
||||
):
|
||||
"""Domain and mailbox admins should be able to retrieve mailbox access details."""
|
||||
user_performing_action = (
|
||||
domain_admin_user if admin_type == "domain_admin" else mailbox1_admin_user
|
||||
)
|
||||
api_client.force_authenticate(user=user_performing_action)
|
||||
response = api_client.get(
|
||||
self.detail_url(mailbox_id=mailbox1_domain1.pk, pk=access_m1d1_alpha.pk)
|
||||
)
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.data["id"] == str(access_m1d1_alpha.pk)
|
||||
|
||||
def test_retrieve_access_for_wrong_mailbox_forbidden(
|
||||
self,
|
||||
api_client,
|
||||
domain_admin_user,
|
||||
mailbox1_domain1,
|
||||
mailbox2_domain1,
|
||||
access_m1d1_alpha,
|
||||
):
|
||||
"""Attempting to retrieve an access using a mailbox_id in URL that doesn't match the access's actual mailbox."""
|
||||
api_client.force_authenticate(user=domain_admin_user)
|
||||
# access_m1d1_alpha belongs to mailbox1_domain1, but we use mailbox2_domain1 in URL
|
||||
response = api_client.get(
|
||||
self.detail_url(mailbox_id=mailbox2_domain1.pk, pk=access_m1d1_alpha.pk)
|
||||
)
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
|
||||
# --- UPDATE Tests ---
|
||||
@pytest.mark.parametrize("admin_type", ["domain_admin", "mailbox_admin"])
|
||||
def test_update_access_role_success(
|
||||
self,
|
||||
api_client,
|
||||
admin_type,
|
||||
user_beta,
|
||||
domain_admin_user,
|
||||
mailbox1_admin_user,
|
||||
mailbox1_domain1,
|
||||
access_m1d1_alpha,
|
||||
):
|
||||
"""Test that domain and mailbox admins can update mailbox access roles."""
|
||||
user_performing_action = (
|
||||
domain_admin_user if admin_type == "domain_admin" else mailbox1_admin_user
|
||||
)
|
||||
api_client.force_authenticate(user=user_performing_action)
|
||||
data = {"role": MailboxRoleChoices.ADMIN.value}
|
||||
response = api_client.patch(
|
||||
self.detail_url(mailbox_id=mailbox1_domain1.pk, pk=access_m1d1_alpha.pk),
|
||||
data,
|
||||
)
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
access_m1d1_alpha.refresh_from_db()
|
||||
assert access_m1d1_alpha.role == MailboxRoleChoices.ADMIN
|
||||
|
||||
data = {"role": "invalid"}
|
||||
response = api_client.patch(
|
||||
self.detail_url(mailbox_id=mailbox1_domain1.pk, pk=access_m1d1_alpha.pk),
|
||||
data,
|
||||
)
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
|
||||
data = {"role": MailboxRoleChoices.ADMIN.value, "user": str(user_beta.pk)}
|
||||
response = api_client.patch(
|
||||
self.detail_url(mailbox_id=mailbox1_domain1.pk, pk=access_m1d1_alpha.pk),
|
||||
data,
|
||||
)
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
|
||||
# --- DELETE Tests ---
|
||||
@pytest.mark.parametrize("admin_type", ["domain_admin", "mailbox_admin"])
|
||||
def test_delete_access_success(
|
||||
self,
|
||||
api_client,
|
||||
admin_type,
|
||||
domain_admin_user,
|
||||
mailbox1_admin_user,
|
||||
mailbox1_domain1,
|
||||
access_m1d1_alpha,
|
||||
):
|
||||
"""Test that domain and mailbox admins can delete mailbox accesses."""
|
||||
user_performing_action = (
|
||||
domain_admin_user if admin_type == "domain_admin" else mailbox1_admin_user
|
||||
)
|
||||
api_client.force_authenticate(user=user_performing_action)
|
||||
response = api_client.delete(
|
||||
self.detail_url(mailbox_id=mailbox1_domain1.pk, pk=access_m1d1_alpha.pk)
|
||||
)
|
||||
assert response.status_code == status.HTTP_204_NO_CONTENT
|
||||
assert not models.MailboxAccess.objects.filter(pk=access_m1d1_alpha.pk).exists()
|
||||
@@ -143,7 +143,9 @@ class TestMailboxViewSet:
|
||||
reverse("mailboxes-search", kwargs={"pk": str(context_mailbox.id)}),
|
||||
)
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert len(response.data) == 3 # All mailboxes in example.com domain except context mailbox
|
||||
assert (
|
||||
len(response.data) == 3
|
||||
) # All mailboxes in example.com domain except context mailbox
|
||||
assert {mailbox["id"] for mailbox in response.data} == {
|
||||
str(john_doe_mailbox.id),
|
||||
str(jane_doe_mailbox.id),
|
||||
|
||||
@@ -0,0 +1,303 @@
|
||||
"""Tests for the MailDomain Admin API endpoints."""
|
||||
# pylint: disable=unused-argument
|
||||
|
||||
from django.urls import reverse
|
||||
|
||||
import pytest
|
||||
from rest_framework import status
|
||||
|
||||
from core import factories, models
|
||||
from core.enums import MailboxRoleChoices, MailDomainAccessRoleChoices
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
@pytest.fixture(name="domain_admin_user")
|
||||
def fixture_domain_admin_user():
|
||||
"""Create a user for domain administration testing."""
|
||||
return factories.UserFactory()
|
||||
|
||||
|
||||
@pytest.fixture(name="other_user")
|
||||
def fixture_other_user():
|
||||
"""Create another user without admin privileges."""
|
||||
return factories.UserFactory()
|
||||
|
||||
|
||||
@pytest.fixture(name="mail_domain1")
|
||||
def fixture_mail_domain1():
|
||||
"""Create the first mail domain for testing."""
|
||||
return factories.MailDomainFactory(name="admin-domain1.com")
|
||||
|
||||
|
||||
@pytest.fixture(name="mail_domain2")
|
||||
def fixture_mail_domain2():
|
||||
"""Create the second mail domain for testing."""
|
||||
return factories.MailDomainFactory(name="admin-domain2.com")
|
||||
|
||||
|
||||
@pytest.fixture(name="unmanaged_domain")
|
||||
def fixture_unmanaged_domain():
|
||||
"""Create a mail domain that has no admin access set up."""
|
||||
return factories.MailDomainFactory(name="unmanaged-domain.com")
|
||||
|
||||
|
||||
@pytest.fixture(name="domain_admin_access1")
|
||||
def fixture_domain_admin_access1(domain_admin_user, mail_domain1):
|
||||
"""Create admin access for domain_admin_user to mail_domain1."""
|
||||
return factories.MailDomainAccessFactory(
|
||||
user=domain_admin_user,
|
||||
maildomain=mail_domain1,
|
||||
role=MailDomainAccessRoleChoices.ADMIN,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(name="domain_admin_access2")
|
||||
def fixture_domain_admin_access2(domain_admin_user, mail_domain2):
|
||||
"""Create admin access for domain_admin_user to mail_domain2."""
|
||||
return factories.MailDomainAccessFactory(
|
||||
user=domain_admin_user,
|
||||
maildomain=mail_domain2,
|
||||
role=MailDomainAccessRoleChoices.ADMIN,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(name="mailbox1_domain1")
|
||||
def fixture_mailbox1_domain1(mail_domain1):
|
||||
"""Create the first mailbox in mail_domain1."""
|
||||
return factories.MailboxFactory(domain=mail_domain1, local_part="box1")
|
||||
|
||||
|
||||
@pytest.fixture(name="mailbox2_domain1")
|
||||
def fixture_mailbox2_domain1(mail_domain1):
|
||||
"""Create the second mailbox in mail_domain1."""
|
||||
return factories.MailboxFactory(domain=mail_domain1, local_part="box2")
|
||||
|
||||
|
||||
@pytest.fixture(name="mailbox1_domain2")
|
||||
def fixture_mailbox1_domain2(mail_domain2):
|
||||
"""Create a mailbox in mail_domain2."""
|
||||
return factories.MailboxFactory(domain=mail_domain2, local_part="boxA")
|
||||
|
||||
|
||||
@pytest.fixture(name="user_for_access1")
|
||||
def fixture_user_for_access1():
|
||||
"""Create a user for mailbox access testing."""
|
||||
return factories.UserFactory(email="access.user1@example.com")
|
||||
|
||||
|
||||
@pytest.fixture(name="user_for_access2")
|
||||
def fixture_user_for_access2():
|
||||
"""Create another user for mailbox access testing."""
|
||||
return factories.UserFactory(email="access.user2@example.com")
|
||||
|
||||
|
||||
@pytest.fixture(name="access_mailbox1_user1")
|
||||
def fixture_access_mailbox1_user1(mailbox1_domain1, user_for_access1):
|
||||
"""Create EDITOR access for user_for_access1 to mailbox1_domain1."""
|
||||
return factories.MailboxAccessFactory(
|
||||
mailbox=mailbox1_domain1, user=user_for_access1, role=MailboxRoleChoices.EDITOR
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(name="access_mailbox1_user2")
|
||||
def fixture_access_mailbox1_user2(mailbox1_domain1, user_for_access2):
|
||||
"""Create VIEWER access for user_for_access2 to mailbox1_domain1."""
|
||||
return factories.MailboxAccessFactory(
|
||||
mailbox=mailbox1_domain1, user=user_for_access2, role=MailboxRoleChoices.VIEWER
|
||||
)
|
||||
|
||||
|
||||
class TestMailDomainAdminViewSet:
|
||||
"""Tests for the MailDomainAdminViewSet."""
|
||||
|
||||
LIST_DOMAINS_URL = reverse("maildomains-list")
|
||||
|
||||
def mailboxes_url(self, maildomain_pk):
|
||||
"""Generate URL for listing mailboxes in a specific domain."""
|
||||
return reverse("domainmailbox-list", kwargs={"maildomain_pk": maildomain_pk})
|
||||
|
||||
def mailbox_detail_url(self, maildomain_pk, mailbox_pk):
|
||||
"""Generate URL for mailbox detail in a specific domain."""
|
||||
return reverse(
|
||||
"domainmailbox-detail",
|
||||
kwargs={"maildomain_pk": maildomain_pk, "pk": mailbox_pk},
|
||||
)
|
||||
|
||||
def test_list_administered_maildomains_success(
|
||||
self,
|
||||
api_client,
|
||||
domain_admin_user,
|
||||
domain_admin_access1,
|
||||
domain_admin_access2,
|
||||
mail_domain1,
|
||||
mail_domain2,
|
||||
unmanaged_domain,
|
||||
):
|
||||
"""Test that a domain admin can list domains they have admin access to."""
|
||||
api_client.force_authenticate(user=domain_admin_user)
|
||||
response = api_client.get(self.LIST_DOMAINS_URL)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.data["count"] == 2
|
||||
domain_ids = [item["id"] for item in response.data["results"]]
|
||||
assert str(mail_domain1.id) in domain_ids
|
||||
assert str(mail_domain2.id) in domain_ids
|
||||
assert str(unmanaged_domain.id) not in domain_ids
|
||||
|
||||
def test_list_administered_maildomains_no_admin_access(
|
||||
self, api_client, other_user, mail_domain1
|
||||
):
|
||||
"""Test that users without domain admin access get an empty list."""
|
||||
# other_user has no MailDomainAccess records
|
||||
api_client.force_authenticate(user=other_user)
|
||||
response = api_client.get(self.LIST_DOMAINS_URL)
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.data["count"] == 0
|
||||
|
||||
def test_list_administered_maildomains_unauthenticated(self, api_client):
|
||||
"""Test that unauthenticated requests to list domains are rejected."""
|
||||
response = api_client.get(self.LIST_DOMAINS_URL)
|
||||
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
|
||||
|
||||
class TestMailboxAdminViewSet:
|
||||
"""Tests for the MailboxAdminViewSet."""
|
||||
|
||||
# Fixtures are inherited or can be passed directly to test methods
|
||||
|
||||
# pylint: disable=too-many-arguments
|
||||
def test_list_mailboxes_for_domain_success(
|
||||
self,
|
||||
api_client,
|
||||
domain_admin_user,
|
||||
domain_admin_access1,
|
||||
mail_domain1,
|
||||
mailbox1_domain1,
|
||||
mailbox2_domain1,
|
||||
access_mailbox1_user1,
|
||||
access_mailbox1_user2,
|
||||
user_for_access1,
|
||||
user_for_access2,
|
||||
):
|
||||
"""Test that a domain admin can list mailboxes in a domain they administer."""
|
||||
api_client.force_authenticate(user=domain_admin_user)
|
||||
url = TestMailDomainAdminViewSet().mailboxes_url(mail_domain1.pk)
|
||||
response = api_client.get(url)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.data["count"] == 2
|
||||
results = response.data["results"]
|
||||
|
||||
# Find data for mailbox1_domain1 for detailed check
|
||||
mb1_data = next(
|
||||
(item for item in results if item["id"] == str(mailbox1_domain1.pk)), None
|
||||
)
|
||||
assert mb1_data is not None
|
||||
assert mb1_data["local_part"] == mailbox1_domain1.local_part
|
||||
assert mb1_data["domain_name"] == mail_domain1.name
|
||||
assert len(mb1_data["accesses"]) == 2
|
||||
|
||||
user1_access_data = next(
|
||||
(
|
||||
acc
|
||||
for acc in mb1_data["accesses"]
|
||||
if acc["user"]["id"] == str(user_for_access1.pk)
|
||||
),
|
||||
None,
|
||||
)
|
||||
assert user1_access_data is not None
|
||||
assert user1_access_data["role"] == MailboxRoleChoices.EDITOR.value
|
||||
assert user1_access_data["user"]["email"] == user_for_access1.email
|
||||
|
||||
user2_access_data = next(
|
||||
(
|
||||
acc
|
||||
for acc in mb1_data["accesses"]
|
||||
if acc["user"]["id"] == str(user_for_access2.pk)
|
||||
),
|
||||
None,
|
||||
)
|
||||
assert user2_access_data is not None
|
||||
assert user2_access_data["role"] == MailboxRoleChoices.VIEWER.value
|
||||
|
||||
# Check that mailbox2_domain1 is also present
|
||||
mb2_data = next(
|
||||
(item for item in results if item["id"] == str(mailbox2_domain1.pk)), None
|
||||
)
|
||||
assert mb2_data is not None
|
||||
assert (
|
||||
len(mb2_data["accesses"]) == 0
|
||||
) # No accesses created for mailbox2 in this test
|
||||
|
||||
def test_list_mailboxes_for_domain_forbidden_not_admin(
|
||||
self, api_client, other_user, mail_domain1
|
||||
):
|
||||
"""Test that users without domain admin access cannot list mailboxes."""
|
||||
api_client.force_authenticate(user=other_user)
|
||||
url = TestMailDomainAdminViewSet().mailboxes_url(mail_domain1.pk)
|
||||
response = api_client.get(url)
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
|
||||
def test_list_mailboxes_for_domain_unauthenticated(self, api_client, mail_domain1):
|
||||
"""Test that unauthenticated requests to list mailboxes are rejected."""
|
||||
url = TestMailDomainAdminViewSet().mailboxes_url(mail_domain1.pk)
|
||||
response = api_client.get(url)
|
||||
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
|
||||
@pytest.mark.parametrize("valid_local_part", ["valid", "valid-pa_rt09.xx"])
|
||||
def test_create_mailbox_success(
|
||||
self,
|
||||
valid_local_part,
|
||||
api_client,
|
||||
domain_admin_user,
|
||||
domain_admin_access1,
|
||||
mail_domain1,
|
||||
):
|
||||
"""Test that domain admins can create mailboxes in domains they administer."""
|
||||
api_client.force_authenticate(user=domain_admin_user)
|
||||
url = TestMailDomainAdminViewSet().mailboxes_url(mail_domain1.pk)
|
||||
data = {"local_part": valid_local_part}
|
||||
response = api_client.post(url, data=data)
|
||||
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
assert response.data["local_part"] == valid_local_part
|
||||
new_mailbox = models.Mailbox.objects.get(id=response.data["id"])
|
||||
assert new_mailbox.domain == mail_domain1
|
||||
assert new_mailbox.local_part == valid_local_part
|
||||
|
||||
def test_create_mailbox_duplicate_local_part(
|
||||
self,
|
||||
api_client,
|
||||
domain_admin_user,
|
||||
domain_admin_access1,
|
||||
mail_domain1,
|
||||
mailbox1_domain1,
|
||||
):
|
||||
"""Test that creating a mailbox with a duplicate local_part fails."""
|
||||
api_client.force_authenticate(user=domain_admin_user)
|
||||
url = TestMailDomainAdminViewSet().mailboxes_url(mail_domain1.pk)
|
||||
data = {"local_part": mailbox1_domain1.local_part} # Duplicate
|
||||
response = api_client.post(url, data=data)
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
# Model unique_together should enforce this, serializer might catch it too.
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"invalid_local_part",
|
||||
["invalid@example.com", "invalid part", "invalidé", "", " "],
|
||||
)
|
||||
def test_create_mailbox_invalid_local_part(
|
||||
self,
|
||||
invalid_local_part,
|
||||
api_client,
|
||||
domain_admin_user,
|
||||
domain_admin_access1,
|
||||
mail_domain1,
|
||||
):
|
||||
"""Test that creating a mailbox with an invalid local_part fails."""
|
||||
api_client.force_authenticate(user=domain_admin_user)
|
||||
url = TestMailDomainAdminViewSet().mailboxes_url(mail_domain1.pk)
|
||||
data = {"local_part": invalid_local_part}
|
||||
response = api_client.post(url, data=data)
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
assert "local_part" in response.data
|
||||
@@ -15,12 +15,14 @@ pytestmark = pytest.mark.django_db
|
||||
def get_thread_access_url(thread_id, access_id=None):
|
||||
"""Helper function to get the thread access URL."""
|
||||
if access_id:
|
||||
return reverse("thread-access-detail", args=[thread_id, access_id])
|
||||
return reverse("thread-access-list", args=[thread_id])
|
||||
return reverse(
|
||||
"thread-access-detail", kwargs={"thread_id": thread_id, "id": access_id}
|
||||
)
|
||||
return reverse("thread-access-list", kwargs={"thread_id": thread_id})
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mailbox_with_access():
|
||||
@pytest.fixture(name="mailbox_with_access")
|
||||
def fixture_mailbox_with_access():
|
||||
"""Create a mailbox with access for a user."""
|
||||
user = factories.UserFactory()
|
||||
mailbox = factories.MailboxFactory()
|
||||
@@ -32,8 +34,8 @@ def mailbox_with_access():
|
||||
return user, mailbox
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def thread_with_editor_access(mailbox_with_access):
|
||||
@pytest.fixture(name="thread_with_editor_access")
|
||||
def fixture_thread_with_editor_access(mailbox_with_access):
|
||||
"""Create a thread with access for a mailbox."""
|
||||
user, mailbox = mailbox_with_access
|
||||
thread = factories.ThreadFactory()
|
||||
@@ -98,7 +100,7 @@ class TestThreadAccessList:
|
||||
self, api_client, thread_with_editor_access, django_assert_num_queries
|
||||
):
|
||||
"""Test listing thread accesses filtered by mailbox."""
|
||||
user, mailbox, thread, thread_access = thread_with_editor_access
|
||||
user, mailbox, thread, _ = thread_with_editor_access
|
||||
api_client.force_authenticate(user=user)
|
||||
|
||||
# Create another thread access for a different mailbox
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
from django.core.exceptions import ValidationError
|
||||
|
||||
import pytest
|
||||
|
||||
from core import factories, models
|
||||
from core.enums import MailDomainAccessRoleChoices
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
class TestMailDomainAccessModel:
|
||||
def test_create_mail_domain_access(self):
|
||||
user = factories.UserFactory()
|
||||
maildomain = factories.MailDomainFactory()
|
||||
|
||||
access = models.MailDomainAccess.objects.create(
|
||||
user=user, maildomain=maildomain, role=MailDomainAccessRoleChoices.ADMIN
|
||||
)
|
||||
|
||||
assert access.user == user
|
||||
assert access.maildomain == maildomain
|
||||
assert access.role == MailDomainAccessRoleChoices.ADMIN
|
||||
assert (
|
||||
str(access)
|
||||
== f"Access to {maildomain.name} for {user} with {MailDomainAccessRoleChoices.ADMIN.value} role"
|
||||
)
|
||||
|
||||
def test_unique_together_constraint(self):
|
||||
user = factories.UserFactory()
|
||||
maildomain = factories.MailDomainFactory()
|
||||
|
||||
models.MailDomainAccess.objects.create(
|
||||
user=user, maildomain=maildomain, role=MailDomainAccessRoleChoices.ADMIN
|
||||
)
|
||||
|
||||
with pytest.raises(ValidationError):
|
||||
models.MailDomainAccess.objects.create(
|
||||
user=user, # Same user
|
||||
maildomain=maildomain, # Same maildomain
|
||||
role=MailDomainAccessRoleChoices.ADMIN,
|
||||
)
|
||||
|
||||
def test_related_names(self):
|
||||
user = factories.UserFactory()
|
||||
maildomain = factories.MailDomainFactory()
|
||||
access = factories.MailDomainAccessFactory(user=user, maildomain=maildomain)
|
||||
|
||||
assert user.maildomain_accesses.first() == access
|
||||
assert maildomain.accesses.first() == access
|
||||
+41
-14
@@ -1,7 +1,7 @@
|
||||
"""URL configuration for the core app."""
|
||||
|
||||
from django.conf import settings
|
||||
from django.urls import include, path, re_path
|
||||
from django.urls import include, path
|
||||
|
||||
from rest_framework.routers import DefaultRouter
|
||||
|
||||
@@ -10,6 +10,10 @@ from core.api.viewsets.config import ConfigView
|
||||
from core.api.viewsets.draft import DraftMessageView
|
||||
from core.api.viewsets.flag import ChangeFlagViewSet
|
||||
from core.api.viewsets.mailbox import MailboxViewSet
|
||||
from core.api.viewsets.mailbox_access import MailboxAccessViewSet
|
||||
|
||||
# Import the viewsets from the correctly named file
|
||||
from core.api.viewsets.maildomain import MailboxAdminViewSet, MailDomainAdminViewSet
|
||||
from core.api.viewsets.message import MessageViewSet
|
||||
from core.api.viewsets.mta import MTAViewSet
|
||||
from core.api.viewsets.send import SendMessageView
|
||||
@@ -23,18 +27,28 @@ from core.authentication.urls import urlpatterns as oidc_urls
|
||||
router = DefaultRouter()
|
||||
router.register("mta", MTAViewSet, basename="mta")
|
||||
router.register("users", UserViewSet, basename="users")
|
||||
router.register("mailboxes", MailboxViewSet, basename="mailboxes")
|
||||
router.register("messages", MessageViewSet, basename="messages")
|
||||
router.register("blob", BlobViewSet, basename="blob")
|
||||
router.register("thread-access", ThreadAccessViewSet, basename="thread-access")
|
||||
router.register("threads", ThreadViewSet, basename="threads")
|
||||
router.register("mailboxes", MailboxViewSet, basename="mailboxes")
|
||||
router.register("maildomains", MailDomainAdminViewSet, basename="maildomains")
|
||||
|
||||
# Add nested router for thread accesses
|
||||
thread_router = DefaultRouter()
|
||||
thread_router.register("threads", ThreadViewSet, basename="threads")
|
||||
# Router for /threads/{thread_id}/accesses/
|
||||
thread_access_nested_router = DefaultRouter()
|
||||
thread_access_nested_router.register(
|
||||
r"accesses", ThreadAccessViewSet, basename="thread-access"
|
||||
)
|
||||
|
||||
thread_related_router = DefaultRouter()
|
||||
thread_related_router.register(
|
||||
"accesses", ThreadAccessViewSet, basename="thread-access"
|
||||
# Router for /mailboxes/{mailbox_id}/accesses/
|
||||
mailbox_access_nested_router = DefaultRouter()
|
||||
mailbox_access_nested_router.register(
|
||||
r"accesses", MailboxAccessViewSet, basename="mailboxaccess"
|
||||
)
|
||||
|
||||
# Router for /maildomains/{maildomain_id}/mailboxes/
|
||||
mailbox_management_nested_router = DefaultRouter()
|
||||
mailbox_management_nested_router.register(
|
||||
r"mailboxes", MailboxAdminViewSet, basename="domainmailbox"
|
||||
)
|
||||
|
||||
urlpatterns = [
|
||||
@@ -42,11 +56,24 @@ urlpatterns = [
|
||||
f"api/{settings.API_VERSION}/",
|
||||
include(
|
||||
[
|
||||
*router.urls,
|
||||
*thread_router.urls,
|
||||
re_path(
|
||||
r"^threads/(?P<thread_id>[\w-]+)/",
|
||||
include(thread_related_router.urls),
|
||||
*router.urls, # Includes mta, users, messages, blob, ... (top-level)
|
||||
path(
|
||||
"threads/<uuid:thread_id>/",
|
||||
include(
|
||||
thread_access_nested_router.urls
|
||||
), # Includes /threads/{id}/accesses/
|
||||
),
|
||||
path(
|
||||
"mailboxes/<uuid:mailbox_id>/",
|
||||
include(
|
||||
mailbox_access_nested_router.urls
|
||||
), # Includes /mailboxes/{id}/accesses/
|
||||
),
|
||||
path(
|
||||
"maildomains/<uuid:maildomain_pk>/",
|
||||
include(
|
||||
mailbox_management_nested_router.urls
|
||||
), # Includes /maildomains/{id}/mailboxes/
|
||||
),
|
||||
*oidc_urls,
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user