(invitations) refresh invitations

invitations can now be refreshed, de-expiring
them or moving their expiration date further
in the future.
This commit is contained in:
Marie PUPO JEAMMET
2026-01-07 19:10:33 +01:00
committed by Marie
parent 1e02ded7b8
commit 3b58fb7e1e
5 changed files with 151 additions and 3 deletions

View File

@@ -8,6 +8,8 @@ and this project adheres to
## [Unreleased]
- ✨(invitations) refresh expired invitations
## [1.23.0] - 2026-02-12
### Added

View File

@@ -36,7 +36,7 @@ from core.plugins.registry import registry as plugin_hooks_registry
from core.utils.webhooks import webhooks_synchronizer
from core.validators import get_field_validators_from_setting
from mailbox_manager.exceptions import EmailAlreadyKnownException
from core.exceptions import EmailAlreadyKnownException
logger = getLogger(__name__)
@@ -1001,14 +1001,21 @@ class BaseInvitation(BaseModel):
if User.objects.filter(email__iexact=self.email).exists():
raise EmailAlreadyKnownException
def refresh(self):
"""A simple way to refresh invitation and move expiration date."""
self.clean()
self.updated_at = timezone.now()
@property
def is_expired(self):
"""Calculate if invitation is still valid or has expired."""
if not self.created_at:
if not self.updated_at:
return None
validity_duration = timedelta(seconds=settings.INVITATION_VALIDITY_DURATION)
return timezone.now() > (self.created_at + validity_duration)
return timezone.now() > (self.updated_at + validity_duration)
def _get_mail_subject(self):
"""Get the subject of the invitation."""

View File

@@ -2,6 +2,7 @@
from django.db.models import Q, Subquery
from django.http import Http404
from django.shortcuts import get_object_or_404
from rest_framework import exceptions, filters, mixins, status, viewsets
from rest_framework.decorators import action
@@ -428,6 +429,14 @@ class MailDomainInvitationViewset(
)
raise exc
@action(detail=True, methods=["post"])
def refresh(self, request, *args, **kwargs): # pylint: disable=unused-argument
"""Enable mailbox. Send a request to dimail and change status in our DB"""
invitation = get_object_or_404(models.MailDomainInvitation, id=kwargs["id"])
invitation.refresh()
invitation.email_invitation()
return Response(serializers.MailDomainInvitationSerializer(invitation).data)
class AliasViewSet(
mixins.CreateModelMixin,

View File

@@ -18,6 +18,7 @@ from django.utils.translation import get_language, gettext, override
from django.utils.translation import gettext_lazy as _
from core.models import BaseInvitation, BaseModel, Organization, User
from core.exceptions import EmailAlreadyKnownException
from mailbox_manager.enums import (
MailboxStatusChoices,
@@ -413,6 +414,23 @@ class MailDomainInvitation(BaseInvitation):
)
]
def refresh(self):
"""Create domain access if refresh fail because user was created
after invitation expiration"""
try:
super().refresh()
except EmailAlreadyKnownException as exc:
user = User.objects.get(email__iexact=self.email)
MailDomainAccess.objects.create(
user=user,
domain=self.domain,
role=self.role,
)
self.delete() # delete related invitation
raise exc
def __str__(self):
return f"{self.email} invited to {self.domain}"

View File

@@ -0,0 +1,112 @@
"""
Tests for MailDomainInvitations API endpoint in People's app mailbox_manager.
Focus on "refresh" action.
"""
import pytest
from freezegun import freeze_time
from rest_framework import status
from rest_framework.test import APIClient
from core import factories as core_factories
from mailbox_manager import enums, factories, models
pytestmark = pytest.mark.django_db
def test_api_domain_invitations__anonymous_cannot_refresh():
"""Anonymous are not allowed to refresh."""
invitation = factories.MailDomainInvitationFactory()
response = APIClient().post(
f"/api/v1.0/mail-domains/{invitation.domain.slug}/invitations/{invitation.id}/refresh/"
)
assert response.status_code == status.HTTP_401_UNAUTHORIZED
def test_api_domain_invitations__no_access_cannot_see_invitation():
"""Users with no permission on the domain should be returned a 404 not found."""
invitation = factories.MailDomainInvitationFactory()
otro_access = factories.MailDomainAccessFactory(
role=enums.MailDomainRoleChoices.ADMIN
)
client = APIClient()
client.force_login(otro_access.user)
response = client.post(
f"/api/v1.0/mail-domains/{invitation.domain.slug}/invitations/{invitation.id}/refresh/"
)
assert response.status_code == status.HTTP_404_NOT_FOUND
def test_api_domain_invitations__viewer_cannot_refresh():
"""Viewers cannot refresh invitations."""
invitation = factories.MailDomainInvitationFactory()
access = factories.MailDomainAccessFactory(
domain=invitation.domain, role=enums.MailDomainRoleChoices.VIEWER
)
client = APIClient()
client.force_login(access.user)
response = client.post(
f"/api/v1.0/mail-domains/{invitation.domain.slug}/invitations/{invitation.id}/refresh/"
)
assert response.status_code == status.HTTP_403_FORBIDDEN
def test_api_domain_invitations__admins_can_refresh_expired_invitations():
"""Admins can refresh invitations."""
with freeze_time("2025-12-16"):
invitation = factories.MailDomainInvitationFactory()
access = factories.MailDomainAccessFactory(
domain=invitation.domain, role=enums.MailDomainRoleChoices.OWNER
)
assert invitation.is_expired is True # check invitation is correctly expired
client = APIClient()
client.force_login(access.user)
# can refresh expired invitations
response = client.post(
f"/api/v1.0/mail-domains/{invitation.domain.slug}/invitations/{invitation.id}/refresh/"
)
assert response.status_code == status.HTTP_200_OK
assert response.json()["is_expired"] is False
assert models.MailDomainInvitation.objects.count() == 1
invitation.refresh_from_db()
# can also refresh valid
response = client.post(
f"/api/v1.0/mail-domains/{invitation.domain.slug}/invitations/{invitation.id}/refresh/"
)
assert response.status_code == status.HTTP_200_OK
assert response.json()["is_expired"] is False
assert models.MailDomainInvitation.objects.count() == 1
def test_api_domain_invitations__refreshing_invitation_can_convert_to_access():
"""In the event of someone creating their account after their invitation expired,
said invitation has not been converted to access.
We don't want to create a new invitation but create an access instead."""
with freeze_time("2025-12-16"):
invitation = factories.MailDomainInvitationFactory()
core_factories.UserFactory(email=invitation.email)
access = factories.MailDomainAccessFactory(
domain=invitation.domain, role=enums.MailDomainRoleChoices.OWNER
)
client = APIClient()
client.force_login(access.user)
response = client.post(
f"/api/v1.0/mail-domains/{invitation.domain.slug}/invitations/{invitation.id}/refresh/"
)
assert response.status_code == status.HTTP_201_CREATED
assert (
response.json()["detail"]
== "Email already known. Invitation not sent but access created instead."
)
assert models.MailDomainAccess.objects.count() == 2
assert not models.MailDomainInvitation.objects.exists()