From 0688e8f48545a6fbe64288b596bbf12bac21193a Mon Sep 17 00:00:00 2001 From: Cloorc Date: Thu, 25 Jun 2026 18:25:03 +0800 Subject: [PATCH] feat: support a default expiry for organisation invite links Invite links (InviteLink) never expire unless an explicit expires_at is provided at creation time, so by default they remain valid indefinitely. This adds an opt-in INVITE_LINK_EXPIRY_DAYS setting: when configured, a newly created invite link without an explicit expires_at defaults to now + INVITE_LINK_EXPIRY_DAYS days. The setting defaults to None, preserving the existing indefinite behaviour, and an explicitly provided expires_at is never overridden. Signed-off-by: Cloorc --- api/app/settings/common.py | 4 ++ api/organisations/invites/models.py | 9 ++++ .../invites/test_unit_invites_models.py | 48 +++++++++++++++++++ 3 files changed, 61 insertions(+) diff --git a/api/app/settings/common.py b/api/app/settings/common.py index ba394155f34f..29581af025fe 100644 --- a/api/app/settings/common.py +++ b/api/app/settings/common.py @@ -1194,6 +1194,10 @@ ) DISABLE_INVITE_LINKS = env.bool("DISABLE_INVITE_LINKS", False) +# Number of days after which a newly created invite link expires when no explicit +# expiry is provided. Defaults to None, which keeps the previous behaviour of +# links never expiring unless an expiry date is set explicitly. +INVITE_LINK_EXPIRY_DAYS = env.int("INVITE_LINK_EXPIRY_DAYS", default=None) PREVENT_SIGNUP = env.bool("PREVENT_SIGNUP", default=False) PREVENT_EMAIL_PASSWORD = env.bool("PREVENT_EMAIL_PASSWORD", default=False) COOKIE_AUTH_ENABLED = env.bool("COOKIE_AUTH_ENABLED", default=False) diff --git a/api/organisations/invites/models.py b/api/organisations/invites/models.py index 2095dff497a9..dae53b580e98 100644 --- a/api/organisations/invites/models.py +++ b/api/organisations/invites/models.py @@ -1,3 +1,5 @@ +from datetime import timedelta + from django.conf import settings from django.core.mail import EmailMultiAlternatives from django.db import models @@ -53,6 +55,13 @@ def validate_invite_links_are_enabled(self): # type: ignore[no-untyped-def] if settings.DISABLE_INVITE_LINKS: raise InviteLinksDisabledError() + @hook(BEFORE_CREATE) + def set_default_expiry(self) -> None: + if self.expires_at is None and settings.INVITE_LINK_EXPIRY_DAYS is not None: + self.expires_at = timezone.now() + timedelta( + days=settings.INVITE_LINK_EXPIRY_DAYS + ) + class Invite(LifecycleModelMixin, AbstractBaseInviteModel): # type: ignore[misc] email = models.EmailField() diff --git a/api/tests/unit/organisations/invites/test_unit_invites_models.py b/api/tests/unit/organisations/invites/test_unit_invites_models.py index d3f33221ce3a..4aa35751df47 100644 --- a/api/tests/unit/organisations/invites/test_unit_invites_models.py +++ b/api/tests/unit/organisations/invites/test_unit_invites_models.py @@ -59,6 +59,54 @@ def test_invite_link_is_expired__no_expiry_date__returns_false( assert not is_expired +def test_create_invite_link__default_expiry_setting__sets_expires_at( + organisation: Organisation, + settings: "SettingsWrapper", +) -> None: + # Given + settings.INVITE_LINK_EXPIRY_DAYS = 7 + before = timezone.now() + + # When + invite_link = InviteLink.objects.create(organisation=organisation) + + # Then + assert invite_link.expires_at is not None + expected = before + timedelta(days=7) + assert abs((invite_link.expires_at - expected).total_seconds()) < 60 + + +def test_create_invite_link__no_default_expiry_setting__stays_indefinite( + organisation: Organisation, + settings: "SettingsWrapper", +) -> None: + # Given + settings.INVITE_LINK_EXPIRY_DAYS = None + + # When + invite_link = InviteLink.objects.create(organisation=organisation) + + # Then + assert invite_link.expires_at is None + + +def test_create_invite_link__explicit_expiry__not_overridden_by_default( + organisation: Organisation, + settings: "SettingsWrapper", +) -> None: + # Given + settings.INVITE_LINK_EXPIRY_DAYS = 7 + explicit_expiry = timezone.now() + timedelta(days=1) + + # When + invite_link = InviteLink.objects.create( + organisation=organisation, expires_at=explicit_expiry + ) + + # Then + assert invite_link.expires_at == explicit_expiry + + @pytest.mark.django_db def test_create_invite_link__invite_links_disabled__raises_error( settings: "SettingsWrapper",