✨(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:
committed by
Marie
parent
1e02ded7b8
commit
3b58fb7e1e
@@ -8,6 +8,8 @@ and this project adheres to
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
- ✨(invitations) refresh expired invitations
|
||||||
|
|
||||||
## [1.23.0] - 2026-02-12
|
## [1.23.0] - 2026-02-12
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ from core.plugins.registry import registry as plugin_hooks_registry
|
|||||||
from core.utils.webhooks import webhooks_synchronizer
|
from core.utils.webhooks import webhooks_synchronizer
|
||||||
from core.validators import get_field_validators_from_setting
|
from core.validators import get_field_validators_from_setting
|
||||||
|
|
||||||
from mailbox_manager.exceptions import EmailAlreadyKnownException
|
from core.exceptions import EmailAlreadyKnownException
|
||||||
|
|
||||||
logger = getLogger(__name__)
|
logger = getLogger(__name__)
|
||||||
|
|
||||||
@@ -1001,14 +1001,21 @@ class BaseInvitation(BaseModel):
|
|||||||
if User.objects.filter(email__iexact=self.email).exists():
|
if User.objects.filter(email__iexact=self.email).exists():
|
||||||
raise EmailAlreadyKnownException
|
raise EmailAlreadyKnownException
|
||||||
|
|
||||||
|
def refresh(self):
|
||||||
|
"""A simple way to refresh invitation and move expiration date."""
|
||||||
|
self.clean()
|
||||||
|
self.updated_at = timezone.now()
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def is_expired(self):
|
def is_expired(self):
|
||||||
"""Calculate if invitation is still valid or has expired."""
|
"""Calculate if invitation is still valid or has expired."""
|
||||||
if not self.created_at:
|
if not self.updated_at:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
validity_duration = timedelta(seconds=settings.INVITATION_VALIDITY_DURATION)
|
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):
|
def _get_mail_subject(self):
|
||||||
"""Get the subject of the invitation."""
|
"""Get the subject of the invitation."""
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
from django.db.models import Q, Subquery
|
from django.db.models import Q, Subquery
|
||||||
from django.http import Http404
|
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 import exceptions, filters, mixins, status, viewsets
|
||||||
from rest_framework.decorators import action
|
from rest_framework.decorators import action
|
||||||
@@ -428,6 +429,14 @@ class MailDomainInvitationViewset(
|
|||||||
)
|
)
|
||||||
raise exc
|
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(
|
class AliasViewSet(
|
||||||
mixins.CreateModelMixin,
|
mixins.CreateModelMixin,
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ from django.utils.translation import get_language, gettext, override
|
|||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
from core.models import BaseInvitation, BaseModel, Organization, User
|
from core.models import BaseInvitation, BaseModel, Organization, User
|
||||||
|
from core.exceptions import EmailAlreadyKnownException
|
||||||
|
|
||||||
from mailbox_manager.enums import (
|
from mailbox_manager.enums import (
|
||||||
MailboxStatusChoices,
|
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):
|
def __str__(self):
|
||||||
return f"{self.email} invited to {self.domain}"
|
return f"{self.email} invited to {self.domain}"
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
Reference in New Issue
Block a user