(aliases) delete aliases

delete a single alias using its pk
This commit is contained in:
Marie PUPO JEAMMET
2026-01-09 15:41:08 +01:00
committed by Marie
parent c7aab6f5b5
commit e47573e22f
8 changed files with 76 additions and 91 deletions

View File

@@ -8,6 +8,7 @@ and this project adheres to
## [Unreleased] ## [Unreleased]
- ✨(aliases) fix deleting single aliases #1002
- 🔥(plugins) remove CommuneCreation plugin - 🔥(plugins) remove CommuneCreation plugin
## [1.21.0] - 2025-12-05 ## [1.21.0] - 2025-12-05

View File

@@ -4,6 +4,7 @@ from logging import getLogger
from django.contrib.auth.hashers import make_password from django.contrib.auth.hashers import make_password
from django.core import exceptions as django_exceptions from django.core import exceptions as django_exceptions
from django.shortcuts import get_object_or_404
from requests.exceptions import HTTPError from requests.exceptions import HTTPError
from rest_framework import exceptions, serializers from rest_framework import exceptions, serializers
@@ -306,7 +307,9 @@ class MailDomainInvitationSerializer(serializers.ModelSerializer):
class AliasSerializer(serializers.ModelSerializer): class AliasSerializer(serializers.ModelSerializer):
"""Serialize mailbox.""" """Serialize aliases."""
domain = MailDomainSerializer(default="")
class Meta: class Meta:
model = models.Alias model = models.Alias
@@ -314,8 +317,13 @@ class AliasSerializer(serializers.ModelSerializer):
"id", "id",
"local_part", "local_part",
"destination", "destination",
"domain",
] ]
read_only_fields = ["id"] read_only_fields = ["id", "domain"]
def validate_domain(self, value): # pylint: disable=unused-argument
"""Forcefully set domain field to url domain."""
return get_object_or_404(models.MailDomain, slug=self.context["domain_slug"])
def create(self, validated_data): def create(self, validated_data):
""" """

View File

@@ -1,6 +1,7 @@
"""API endpoints""" """API endpoints"""
from django.db.models import Q, Subquery from django.db.models import Q, Subquery
from django.http import Http404
from rest_framework import exceptions, filters, mixins, status, viewsets from rest_framework import exceptions, filters, mixins, status, viewsets
from rest_framework.decorators import action from rest_framework.decorators import action
@@ -422,11 +423,11 @@ class AliasViewSet(
- destination: str - destination: str
Return a newly created alias Return a newly created alias
DELETE /api/<version>/mail-domains/<domain_slug>/accesses/<alias-local-part>/ DELETE /api/<version>/mail-domains/<domain_slug>/aliases/<alias_pk>/
Delete targeted alias Delete targeted alias
""" """
lookup_field = "local_part" lookup_field = "pk"
permission_classes = [permissions.DomainResourcePermission] permission_classes = [permissions.DomainResourcePermission]
serializer_class = serializers.AliasSerializer serializer_class = serializers.AliasSerializer
queryset = ( queryset = (
@@ -459,38 +460,19 @@ class AliasViewSet(
return queryset return queryset
def get_permissions(self):
"""Add a specific permission for domain viewers to delete their aliases."""
if self.action in ["destroy"]:
permission_classes = [
permissions.DomainResourcePermission
| permissions.IsAliasDestinationPermission,
]
else:
return super().get_permissions()
return [permission() for permission in permission_classes]
def perform_create(self, serializer):
"""Create new mailbox."""
domain_slug = self.kwargs.get("domain_slug", "")
if domain_slug:
serializer.validated_data["domain"] = models.MailDomain.objects.get(
slug=domain_slug
)
super().perform_create(serializer)
def destroy(self, request, *args, **kwargs): def destroy(self, request, *args, **kwargs):
"""Override destroy method to send a delete request to dimail """
and return clear message if domain out of sync.""" Override destroy method to delete specific alias and send request to dimail.
"""
instance = self.get_object() instance = self.get_object()
self.perform_destroy(instance) self.perform_destroy(instance)
client = DimailAPIClient() client = DimailAPIClient()
dimail_response = client.delete_alias(instance) dimail_response = client.delete_alias(instance)
if dimail_response.status_code == status.HTTP_404_NOT_FOUND: if dimail_response.status_code == status.HTTP_404_NOT_FOUND:
return Response( return Response(
"Alias already deleted. Domain out of sync, please contact our support.", "Domain out of sync with mailbox provider, please contact our support.",
status=status.HTTP_200_OK, status=status.HTTP_200_OK,
) )

View File

@@ -55,17 +55,3 @@ class IsMailboxOwnerPermission(permissions.BasePermission):
def has_object_permission(self, request, view, obj): def has_object_permission(self, request, view, obj):
"""If the user is trying to update their own mailbox.""" """If the user is trying to update their own mailbox."""
return obj.get_email() == request.user.email return obj.get_email() == request.user.email
class IsAliasDestinationPermission(IsAuthenticated):
"""Can delete an alias if the alias points to their own email address."""
def has_permission(self, request, view):
"""This permission is specifically about updates"""
domain = models.MailDomain.objects.get(slug=view.kwargs.get("domain_slug", ""))
abilities = domain.get_abilities(request.user)
return abilities["get"]
def has_object_permission(self, request, view, obj):
"""If the user is trying to update their own mailbox."""
return obj.destination == request.user.email

View File

@@ -0,0 +1,21 @@
# Generated by Django 5.2.9 on 2025-12-17 17:01
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('mailbox_manager', '0028_alias'),
]
operations = [
migrations.AlterUniqueTogether(
name='alias',
unique_together=set(),
),
migrations.AddConstraint(
model_name='alias',
constraint=models.UniqueConstraint(fields=('domain', 'local_part', 'destination'), name='no_duplicate'),
),
]

View File

@@ -89,6 +89,8 @@ class MailDomain(BaseModel):
def __str__(self): def __str__(self):
return self.name return self.name
objects = models.Manager()
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
"""Override save function to compute the slug.""" """Override save function to compute the slug."""
self.slug = self.get_slug() self.slug = self.get_slug()
@@ -471,8 +473,12 @@ class Alias(BaseModel):
db_table = "people_aliases" db_table = "people_aliases"
verbose_name = _("Alias") verbose_name = _("Alias")
verbose_name_plural = _("Aliases") verbose_name_plural = _("Aliases")
unique_together = ("local_part", "destination")
ordering = ["-created_at"] ordering = ["-created_at"]
constraints = [
models.UniqueConstraint(
fields=["domain", "local_part", "destination"], name="no_duplicate"
)
]
def __str__(self): def __str__(self):
return f"{self.local_part} to {self.destination}" return f"{self.local_part} to {self.destination}"

View File

@@ -60,8 +60,9 @@ def test_api_aliases_create__viewer_forbidden():
assert not models.Alias.objects.exists() assert not models.Alias.objects.exists()
@responses.activate
def test_api_aliases_create__duplicate_forbidden(): def test_api_aliases_create__duplicate_forbidden():
"""Cannot create alias if same local part + destination.""" """Cannot create alias if existing alias same domain + local part + destination."""
access = factories.MailDomainAccessFactory( access = factories.MailDomainAccessFactory(
role="owner", domain=factories.MailDomainEnabledFactory() role="owner", domain=factories.MailDomainEnabledFactory()
) )

View File

@@ -1,6 +1,6 @@
""" """
Tests for aliases API endpoint in People's app mailbox_manager. Tests for aliases API endpoint in People's app mailbox_manager.
Focus on "list" action. Focus on "delete" action.
""" """
# pylint: disable=W0613 # pylint: disable=W0613
@@ -20,11 +20,12 @@ pytestmark = pytest.mark.django_db
def test_api_aliases_delete__anonymous(): def test_api_aliases_delete__anonymous():
"""Anonymous user should not be able to delete aliases.""" """Anonymous user should not be able to delete aliases."""
alias = factories.AliasFactory() alias_ = factories.AliasFactory()
response = APIClient().delete( response = APIClient().delete(
f"/api/v1.0/mail-domains/{alias.domain.slug}/aliases/{alias.local_part}/", f"/api/v1.0/mail-domains/{alias_.domain.slug}/aliases/{alias_.pk}/"
) )
assert response.status_code == status.HTTP_401_UNAUTHORIZED assert response.status_code == status.HTTP_401_UNAUTHORIZED
assert models.Alias.objects.count() == 1 assert models.Alias.objects.count() == 1
@@ -35,12 +36,12 @@ def test_api_aliases_delete__no_access_forbidden_not_found():
mail domain to which they are not related. mail domain to which they are not related.
""" """
authenticated_user = core_factories.UserFactory() authenticated_user = core_factories.UserFactory()
alias = factories.AliasFactory() alias_ = factories.AliasFactory()
client = APIClient() client = APIClient()
client.force_login(authenticated_user) client.force_login(authenticated_user)
response = client.delete( response = client.delete(
f"/api/v1.0/mail-domains/{alias.domain.slug}/aliases/{alias.local_part}/", f"/api/v1.0/mail-domains/{alias_.domain.slug}/aliases/{alias_.pk}/"
) )
assert response.status_code == status.HTTP_404_NOT_FOUND assert response.status_code == status.HTTP_404_NOT_FOUND
@@ -56,59 +57,40 @@ def test_api_aliases_delete__viewer_forbidden():
mail_domain = factories.MailDomainFactory( mail_domain = factories.MailDomainFactory(
users=[(authenticated_user, enums.MailDomainRoleChoices.VIEWER)] users=[(authenticated_user, enums.MailDomainRoleChoices.VIEWER)]
) )
alias = factories.AliasFactory(domain=mail_domain) alias_ = factories.AliasFactory(domain=mail_domain)
client = APIClient() client = APIClient()
client.force_login(authenticated_user) client.force_login(authenticated_user)
response = client.delete( response = client.delete(
f"/api/v1.0/mail-domains/{mail_domain.slug}/aliases/{alias.local_part}/", f"/api/v1.0/mail-domains/{mail_domain.slug}/aliases/{alias_.pk}/"
) )
assert response.status_code == status.HTTP_403_FORBIDDEN assert response.status_code == status.HTTP_403_FORBIDDEN
assert models.Alias.objects.count() == 1 assert models.Alias.objects.count() == 1
@responses.activate
def test_api_aliases_delete__viewer_can_delete_self_alias(dimail_token_ok):
"""
Authenticated users should be allowed to delete aliases when linking
to their own mailbox.
"""
authenticated_user = core_factories.UserFactory()
mail_domain = factories.MailDomainFactory(
users=[(authenticated_user, enums.MailDomainRoleChoices.VIEWER)]
)
alias = factories.AliasFactory(
domain=mail_domain, destination=authenticated_user.email
)
# Mock dimail response
responses.delete(
re.compile(r".*/aliases/"),
status=status.HTTP_204_NO_CONTENT,
content_type="application/json",
)
client = APIClient()
client.force_login(authenticated_user)
response = client.delete(
f"/api/v1.0/mail-domains/{mail_domain.slug}/aliases/{alias.local_part}/",
)
assert response.status_code == status.HTTP_204_NO_CONTENT
assert not models.Alias.objects.exists()
@responses.activate @responses.activate
def test_api_aliases_delete__administrators_allowed(dimail_token_ok): def test_api_aliases_delete__administrators_allowed(dimail_token_ok):
""" """
Administrators of a mail domain should be allowed to delete accesses excepted owner accesses. Administrators of a mail domain should be allowed to delete aliases.
""" """
authenticated_user = core_factories.UserFactory() authenticated_user = core_factories.UserFactory()
mail_domain = factories.MailDomainFactory( mail_domain = factories.MailDomainFactory(
users=[(authenticated_user, enums.MailDomainRoleChoices.ADMIN)] users=[(authenticated_user, enums.MailDomainRoleChoices.ADMIN)]
) )
alias = factories.AliasFactory(domain=mail_domain) alias_ = factories.AliasFactory(domain=mail_domain)
factories.AliasFactory.create_batch(
2, domain=mail_domain, local_part=alias_.local_part
)
# additional aliases that shouldn't be affected
factories.AliasFactory.create_batch(
2, domain=mail_domain, destination=alias_.destination
)
factories.AliasFactory(
local_part=alias_.local_part,
destination=alias_.destination,
)
# Mock dimail response # Mock dimail response
responses.delete( responses.delete(
@@ -120,11 +102,10 @@ def test_api_aliases_delete__administrators_allowed(dimail_token_ok):
client = APIClient() client = APIClient()
client.force_login(authenticated_user) client.force_login(authenticated_user)
response = client.delete( response = client.delete(
f"/api/v1.0/mail-domains/{mail_domain.slug}/aliases/{alias.local_part}/", f"/api/v1.0/mail-domains/{mail_domain.slug}/aliases/{alias_.pk}/"
) )
assert response.status_code == status.HTTP_204_NO_CONTENT assert response.status_code == status.HTTP_204_NO_CONTENT
assert not models.Alias.objects.exists() assert models.Alias.objects.count() == 5
@responses.activate @responses.activate
@@ -136,8 +117,7 @@ def test_api_aliases_delete__404_out_of_sync(dimail_token_ok):
mail_domain = factories.MailDomainFactory( mail_domain = factories.MailDomainFactory(
users=[(authenticated_user, enums.MailDomainRoleChoices.ADMIN)] users=[(authenticated_user, enums.MailDomainRoleChoices.ADMIN)]
) )
alias = factories.AliasFactory(domain=mail_domain) alias_ = factories.AliasFactory(domain=mail_domain)
# Mock dimail response # Mock dimail response
responses.delete( responses.delete(
re.compile(r".*/aliases/"), re.compile(r".*/aliases/"),
@@ -149,11 +129,11 @@ def test_api_aliases_delete__404_out_of_sync(dimail_token_ok):
client = APIClient() client = APIClient()
client.force_login(authenticated_user) client.force_login(authenticated_user)
response = client.delete( response = client.delete(
f"/api/v1.0/mail-domains/{mail_domain.slug}/aliases/{alias.local_part}/", f"/api/v1.0/mail-domains/{mail_domain.slug}/aliases/{alias_.pk}/"
) )
assert response.status_code == status.HTTP_200_OK assert response.status_code == status.HTTP_200_OK
assert ( assert (
response.json() response.json()
== "Alias already deleted. Domain out of sync, please contact our support." == "Domain out of sync with mailbox provider, please contact our support."
) )
assert not models.Alias.objects.exists() assert not models.Alias.objects.exists()