invite: Add backend support for "Never expires" option.

The database value for expiry_date is None for the invite
that will never expire and the clients send -1 as value
in the API similar to the message retention setting.

Also, when passing invite_expire_in_days as an argument
in various functions, invite_expire_in_days is passed as
-1 for "Never expires" option since invite_expire_in_days
is an optional argument in some functions and thus we cannot
pass "None" value.
This commit is contained in:
Sahil Batra 2021-11-30 18:04:37 +05:30 committed by Tim Abbott
parent c19d6fb3ef
commit 392b17da5f
12 changed files with 241 additions and 35 deletions

View File

@ -92,7 +92,9 @@ def get_confirmations(
link_status = "" link_status = ""
now = timezone_now() now = timezone_now()
if now < expiry_date: if expiry_date is None:
expires_in = "Never"
elif now < expiry_date:
expires_in = timesince(now, expiry_date) expires_in = timesince(now, expiry_date)
else: else:
expires_in = "Expired" expires_in = "Expired"

View File

@ -0,0 +1,18 @@
# Generated by Django 3.2.9 on 2021-11-30 17:44
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("confirmation", "0010_alter_confirmation_expiry_date"),
]
operations = [
migrations.AlterField(
model_name="confirmation",
name="expiry_date",
field=models.DateTimeField(db_index=True, null=True),
),
]

View File

@ -18,6 +18,7 @@ from django.urls import reverse
from django.utils.timezone import now as timezone_now from django.utils.timezone import now as timezone_now
from typing_extensions import Protocol from typing_extensions import Protocol
from zerver.lib.types import UnspecifiedValue
from zerver.models import EmailChangeStatus, MultiuseInvite, PreregistrationUser, Realm, UserProfile from zerver.models import EmailChangeStatus, MultiuseInvite, PreregistrationUser, Realm, UserProfile
@ -70,7 +71,7 @@ def get_object_from_key(
except Confirmation.DoesNotExist: except Confirmation.DoesNotExist:
raise ConfirmationKeyException(ConfirmationKeyException.DOES_NOT_EXIST) raise ConfirmationKeyException(ConfirmationKeyException.DOES_NOT_EXIST)
if timezone_now() > confirmation.expiry_date: if confirmation.expiry_date is not None and timezone_now() > confirmation.expiry_date:
raise ConfirmationKeyException(ConfirmationKeyException.EXPIRED) raise ConfirmationKeyException(ConfirmationKeyException.EXPIRED)
obj = confirmation.content_object obj = confirmation.content_object
@ -85,7 +86,7 @@ def create_confirmation_link(
obj: Union[Realm, HasRealmObject, OptionalHasRealmObject], obj: Union[Realm, HasRealmObject, OptionalHasRealmObject],
confirmation_type: int, confirmation_type: int,
*, *,
validity_in_days: Optional[int] = None, validity_in_days: Union[Optional[int], UnspecifiedValue] = UnspecifiedValue(),
url_args: Mapping[str, str] = {}, url_args: Mapping[str, str] = {},
) -> str: ) -> str:
# validity_in_days is an override for the default values which are # validity_in_days is an override for the default values which are
@ -100,8 +101,12 @@ def create_confirmation_link(
current_time = timezone_now() current_time = timezone_now()
expiry_date = None expiry_date = None
if validity_in_days: if not isinstance(validity_in_days, UnspecifiedValue):
expiry_date = current_time + datetime.timedelta(days=validity_in_days) if validity_in_days is None:
expiry_date = None
else:
assert validity_in_days is not None
expiry_date = current_time + datetime.timedelta(days=validity_in_days)
else: else:
expiry_date = current_time + datetime.timedelta( expiry_date = current_time + datetime.timedelta(
days=_properties[confirmation_type].validity_in_days days=_properties[confirmation_type].validity_in_days
@ -138,7 +143,7 @@ class Confirmation(models.Model):
content_object = GenericForeignKey("content_type", "object_id") content_object = GenericForeignKey("content_type", "object_id")
date_sent: datetime.datetime = models.DateTimeField(db_index=True) date_sent: datetime.datetime = models.DateTimeField(db_index=True)
confirmation_key: str = models.CharField(max_length=40, db_index=True) confirmation_key: str = models.CharField(max_length=40, db_index=True)
expiry_date: datetime.datetime = models.DateTimeField(db_index=True) expiry_date: Optional[datetime.datetime] = models.DateTimeField(db_index=True, null=True)
realm: Optional[Realm] = models.ForeignKey(Realm, null=True, on_delete=CASCADE) realm: Optional[Realm] = models.ForeignKey(Realm, null=True, on_delete=CASCADE)
# The following list is the set of valid types # The following list is the set of valid types

View File

@ -21,6 +21,12 @@ format used by the Zulip server that they are interacting with.
## Changes in Zulip 5.0 ## Changes in Zulip 5.0
**Feature level 117**
* `POST /invites`, `POST /invites/multiuse`: Added support for passing
`null` as the `invite_expires_in_days` parameter to request an
invitation that never expires.
**Feature level 116** **Feature level 116**
* [`GET /server_settings`](/api/get-server-settings): Added * [`GET /server_settings`](/api/get-server-settings): Added

View File

@ -33,7 +33,7 @@ DESKTOP_WARNING_VERSION = "5.4.3"
# Changes should be accompanied by documentation explaining what the # Changes should be accompanied by documentation explaining what the
# new level means in templates/zerver/api/changelog.md, as well as # new level means in templates/zerver/api/changelog.md, as well as
# "**Changes**" entries in the endpoint's documentation in `zulip.yaml`. # "**Changes**" entries in the endpoint's documentation in `zulip.yaml`.
API_FEATURE_LEVEL = 116 API_FEATURE_LEVEL = 117
# Bump the minor PROVISION_VERSION to indicate that folks should provision # Bump the minor PROVISION_VERSION to indicate that folks should provision
# only when going from an old version of the code to a newer version. Bump # only when going from an old version of the code to a newer version. Bump

View File

@ -174,7 +174,7 @@ from zerver.lib.topic import (
update_messages_for_topic_edit, update_messages_for_topic_edit,
) )
from zerver.lib.topic_mutes import add_topic_mute, get_topic_mutes, remove_topic_mute from zerver.lib.topic_mutes import add_topic_mute, get_topic_mutes, remove_topic_mute
from zerver.lib.types import ProfileDataElementValue, ProfileFieldData from zerver.lib.types import ProfileDataElementValue, ProfileFieldData, UnspecifiedValue
from zerver.lib.upload import ( from zerver.lib.upload import (
claim_attachment, claim_attachment,
delete_avatar_image, delete_avatar_image,
@ -7526,7 +7526,7 @@ def do_send_confirmation_email(
invitee: PreregistrationUser, invitee: PreregistrationUser,
referrer: UserProfile, referrer: UserProfile,
email_language: str, email_language: str,
invite_expires_in_days: Optional[int] = None, invite_expires_in_days: Union[Optional[int], UnspecifiedValue] = UnspecifiedValue(),
) -> str: ) -> str:
""" """
Send the confirmation/welcome e-mail to an invited user. Send the confirmation/welcome e-mail to an invited user.
@ -7625,7 +7625,7 @@ def do_invite_users(
invitee_emails: Collection[str], invitee_emails: Collection[str],
streams: Collection[Stream], streams: Collection[Stream],
*, *,
invite_expires_in_days: int, invite_expires_in_days: Optional[int],
invite_as: int = PreregistrationUser.INVITE_AS["MEMBER"], invite_as: int = PreregistrationUser.INVITE_AS["MEMBER"],
) -> None: ) -> None:
num_invites = len(invitee_emails) num_invites = len(invitee_emails)
@ -7737,6 +7737,13 @@ def do_invite_users(
notify_invites_changed(user_profile.realm) notify_invites_changed(user_profile.realm)
def get_invitation_expiry_date(confirmation_obj: Confirmation) -> Optional[int]:
expiry_date = confirmation_obj.expiry_date
if expiry_date is None:
return expiry_date
return datetime_to_timestamp(expiry_date)
def do_get_invites_controlled_by_user(user_profile: UserProfile) -> List[Dict[str, Any]]: def do_get_invites_controlled_by_user(user_profile: UserProfile) -> List[Dict[str, Any]]:
""" """
Returns a list of dicts representing invitations that can be controlled by user_profile. Returns a list of dicts representing invitations that can be controlled by user_profile.
@ -7755,13 +7762,12 @@ def do_get_invites_controlled_by_user(user_profile: UserProfile) -> List[Dict[st
invites = [] invites = []
for invitee in prereg_users: for invitee in prereg_users:
expiry_date = invitee.confirmation.get().expiry_date
invites.append( invites.append(
dict( dict(
email=invitee.email, email=invitee.email,
invited_by_user_id=invitee.referred_by.id, invited_by_user_id=invitee.referred_by.id,
invited=datetime_to_timestamp(invitee.invited_at), invited=datetime_to_timestamp(invitee.invited_at),
expiry_date=datetime_to_timestamp(expiry_date), expiry_date=get_invitation_expiry_date(invitee.confirmation.get()),
id=invitee.id, id=invitee.id,
invited_as=invitee.invited_as, invited_as=invitee.invited_as,
is_multiuse=False, is_multiuse=False,
@ -7773,8 +7779,8 @@ def do_get_invites_controlled_by_user(user_profile: UserProfile) -> List[Dict[st
return invites return invites
multiuse_confirmation_objs = Confirmation.objects.filter( multiuse_confirmation_objs = Confirmation.objects.filter(
realm=user_profile.realm, type=Confirmation.MULTIUSE_INVITE, expiry_date__gte=timezone_now() realm=user_profile.realm, type=Confirmation.MULTIUSE_INVITE
) ).filter(Q(expiry_date__gte=timezone_now()) | Q(expiry_date=None))
for confirmation_obj in multiuse_confirmation_objs: for confirmation_obj in multiuse_confirmation_objs:
invite = confirmation_obj.content_object invite = confirmation_obj.content_object
assert invite is not None assert invite is not None
@ -7782,7 +7788,7 @@ def do_get_invites_controlled_by_user(user_profile: UserProfile) -> List[Dict[st
dict( dict(
invited_by_user_id=invite.referred_by.id, invited_by_user_id=invite.referred_by.id,
invited=datetime_to_timestamp(confirmation_obj.date_sent), invited=datetime_to_timestamp(confirmation_obj.date_sent),
expiry_date=datetime_to_timestamp(confirmation_obj.expiry_date), expiry_date=get_invitation_expiry_date(confirmation_obj),
id=invite.id, id=invite.id,
link_url=confirmation_url( link_url=confirmation_url(
confirmation_obj.confirmation_key, confirmation_obj.confirmation_key,
@ -7812,9 +7818,8 @@ def get_valid_invite_confirmations_generated_by_user(
confirmations += list( confirmations += list(
Confirmation.objects.filter( Confirmation.objects.filter(
type=Confirmation.MULTIUSE_INVITE, type=Confirmation.MULTIUSE_INVITE,
expiry_date__gte=timezone_now(),
object_id__in=multiuse_invite_ids, object_id__in=multiuse_invite_ids,
) ).filter(Q(expiry_date__gte=timezone_now()) | Q(expiry_date=None))
) )
return confirmations return confirmations
@ -7834,7 +7839,7 @@ def revoke_invites_generated_by_user(user_profile: UserProfile) -> None:
def do_create_multiuse_invite_link( def do_create_multiuse_invite_link(
referred_by: UserProfile, referred_by: UserProfile,
invited_as: int, invited_as: int,
invite_expires_in_days: int, invite_expires_in_days: Optional[int],
streams: Sequence[Stream] = [], streams: Sequence[Stream] = [],
) -> str: ) -> str:
realm = referred_by.realm realm = referred_by.realm
@ -7883,9 +7888,14 @@ def do_resend_user_invite_email(prereg_user: PreregistrationUser) -> int:
prereg_user.invited_at = timezone_now() prereg_user.invited_at = timezone_now()
prereg_user.save() prereg_user.save()
invite_expires_in_days = (
prereg_user.confirmation.get().expiry_date - prereg_user.invited_at expiry_date = prereg_user.confirmation.get().expiry_date
).days if expiry_date is None:
invite_expires_in_days = None
else:
# The resent invitation is reset to expire as long after the
# reminder is sent as it lasted originally.
invite_expires_in_days = (expiry_date - prereg_user.invited_at).days
prereg_user.confirmation.clear() prereg_user.confirmation.clear()
do_increment_logging_stat( do_increment_logging_stat(

View File

@ -72,3 +72,20 @@ class SAMLIdPConfigDict(TypedDict, total=False):
extra_attrs: List[str] extra_attrs: List[str]
x509cert: str x509cert: str
x509cert_path: str x509cert_path: str
class UnspecifiedValue:
"""In most API endpoints, we use a default value of `None"` to encode
parameters that the client did not pass, which is nicely Pythonic.
However, that design does not work for those few endpoints where
we want to allow clients to pass an explicit `null` (which
JSON-decodes to `None`).
We use this type as an explicit sentinel value for such endpoints.
TODO: Can this be merged with the _NotSpecified class, which is
currently an internal implementation detail of the REQ class?
"""
pass

View File

@ -86,6 +86,7 @@ from zerver.lib.types import (
ProfileDataElementBase, ProfileDataElementBase,
ProfileDataElementValue, ProfileDataElementValue,
RealmUserValidator, RealmUserValidator,
UnspecifiedValue,
UserFieldElement, UserFieldElement,
Validator, Validator,
) )
@ -2184,17 +2185,30 @@ class PreregistrationUser(models.Model):
def filter_to_valid_prereg_users( def filter_to_valid_prereg_users(
query: QuerySet, query: QuerySet,
invite_expires_in_days: Optional[int] = None, invite_expires_in_days: Union[Optional[int], UnspecifiedValue] = UnspecifiedValue(),
) -> QuerySet: ) -> QuerySet:
"""
If invite_expires_in_days is specified, we return only those PreregistrationUser
objects that were created at most that many days in the past.
"""
active_value = confirmation_settings.STATUS_ACTIVE active_value = confirmation_settings.STATUS_ACTIVE
revoked_value = confirmation_settings.STATUS_REVOKED revoked_value = confirmation_settings.STATUS_REVOKED
query = query.exclude(status__in=[active_value, revoked_value]) query = query.exclude(status__in=[active_value, revoked_value])
if invite_expires_in_days: if invite_expires_in_days is None:
# Since invite_expires_in_days is None, we're invitation will never
# expire, we do not need to check anything else and can simply return
# after excluding objects with active and revoked status.
return query
assert invite_expires_in_days is not None
if not isinstance(invite_expires_in_days, UnspecifiedValue):
lowest_datetime = timezone_now() - datetime.timedelta(days=invite_expires_in_days) lowest_datetime = timezone_now() - datetime.timedelta(days=invite_expires_in_days)
return query.filter(invited_at__gte=lowest_datetime) return query.filter(invited_at__gte=lowest_datetime)
else: else:
return query.filter(confirmation__expiry_date__gte=timezone_now()) return query.filter(
Q(confirmation__expiry_date=None) | Q(confirmation__expiry_date__gte=timezone_now())
)
class MultiuseInvite(models.Model): class MultiuseInvite(models.Model):

View File

@ -1045,7 +1045,7 @@ class InviteUserBase(ZulipTestCase):
self, self,
invitee_emails: str, invitee_emails: str,
stream_names: Sequence[str], stream_names: Sequence[str],
invite_expires_in_days: int = settings.INVITATION_LINK_VALIDITY_DAYS, invite_expires_in_days: Optional[int] = settings.INVITATION_LINK_VALIDITY_DAYS,
body: str = "", body: str = "",
invite_as: int = PreregistrationUser.INVITE_AS["MEMBER"], invite_as: int = PreregistrationUser.INVITE_AS["MEMBER"],
) -> HttpResponse: ) -> HttpResponse:
@ -1060,11 +1060,16 @@ class InviteUserBase(ZulipTestCase):
stream_ids = [] stream_ids = []
for stream_name in stream_names: for stream_name in stream_names:
stream_ids.append(self.get_stream_id(stream_name)) stream_ids.append(self.get_stream_id(stream_name))
invite_expires_in: Union[str, Optional[int]] = invite_expires_in_days
if invite_expires_in is None:
invite_expires_in = orjson.dumps(None).decode()
return self.client_post( return self.client_post(
"/json/invites", "/json/invites",
{ {
"invitee_emails": invitee_emails, "invitee_emails": invitee_emails,
"invite_expires_in_days": invite_expires_in_days, "invite_expires_in_days": invite_expires_in,
"stream_ids": orjson.dumps(stream_ids).decode(), "stream_ids": orjson.dumps(stream_ids).decode(),
"invite_as": invite_as, "invite_as": invite_as,
}, },
@ -2085,6 +2090,26 @@ so we didn't send them an invitation. We did send invitations to everyone else!"
"Whoops. The confirmation link has expired or been deactivated.", result "Whoops. The confirmation link has expired or been deactivated.", result
) )
def test_never_expire_confirmation_obejct(self) -> None:
email = self.nonreg_email("alice")
realm = get_realm("zulip")
inviter = self.example_user("iago")
prereg_user = PreregistrationUser.objects.create(
email=email, referred_by=inviter, realm=realm
)
activation_url = create_confirmation_link(
prereg_user, Confirmation.INVITATION, validity_in_days=None
)
confirmation = Confirmation.objects.last()
assert confirmation is not None
self.assertEqual(confirmation.expiry_date, None)
activation_key = activation_url.split("/")[-1]
response = self.client_post(
"/accounts/register/",
{"key": activation_key, "from_confirmation": 1, "full_nme": "alice"},
)
self.assertEqual(response.status_code, 200)
def test_send_more_than_one_invite_to_same_user(self) -> None: def test_send_more_than_one_invite_to_same_user(self) -> None:
self.user_profile = self.example_user("iago") self.user_profile = self.example_user("iago")
streams = [] streams = []
@ -2349,6 +2374,53 @@ class InvitationsTestCase(InviteUserBase):
self.assertTrue(invites[1]["is_multiuse"]) self.assertTrue(invites[1]["is_multiuse"])
self.assertEqual(invites[1]["invited_by_user_id"], hamlet.id) self.assertEqual(invites[1]["invited_by_user_id"], hamlet.id)
def test_get_never_expiring_invitations(self) -> None:
self.login("iago")
user_profile = self.example_user("iago")
streams = []
for stream_name in ["Denmark", "Scotland"]:
streams.append(get_stream(stream_name, user_profile.realm))
with patch(
"confirmation.models.timezone_now",
return_value=timezone_now() - datetime.timedelta(days=1000),
):
# Testing the invitation with expiry date set to "None" exists
# after a large amount of days.
do_invite_users(
user_profile,
["TestOne@zulip.com"],
streams,
invite_expires_in_days=None,
)
do_invite_users(
user_profile,
["TestTwo@zulip.com"],
streams,
invite_expires_in_days=100,
)
do_create_multiuse_invite_link(
user_profile, PreregistrationUser.INVITE_AS["MEMBER"], None
)
do_create_multiuse_invite_link(
user_profile, PreregistrationUser.INVITE_AS["MEMBER"], 100
)
result = self.client_get("/json/invites")
self.assertEqual(result.status_code, 200)
invites = orjson.loads(result.content)["invites"]
# We only get invitations that will never expire because we have mocked time such
# that the other invitations are created in the deep past.
self.assert_length(invites, 2)
self.assertFalse(invites[0]["is_multiuse"])
self.assertEqual(invites[0]["email"], "TestOne@zulip.com")
self.assertEqual(invites[0]["expiry_date"], None)
self.assertTrue(invites[1]["is_multiuse"])
self.assertEqual(invites[1]["invited_by_user_id"], user_profile.id)
self.assertEqual(invites[1]["expiry_date"], None)
def test_successful_delete_invitation(self) -> None: def test_successful_delete_invitation(self) -> None:
""" """
A DELETE call to /json/invites/<ID> should delete the invite and A DELETE call to /json/invites/<ID> should delete the invite and
@ -2635,6 +2707,23 @@ class InvitationsTestCase(InviteUserBase):
original_timestamp, scheduledemail_filter.values_list("scheduled_timestamp", flat=True) original_timestamp, scheduledemail_filter.values_list("scheduled_timestamp", flat=True)
) )
def test_resend_never_expiring_invitation(self) -> None:
self.login("iago")
invitee = "resend@zulip.com"
self.assert_json_success(self.invite(invitee, ["Denmark"], None))
prereg_user = PreregistrationUser.objects.get(email=invitee)
# Verify and then clear from the outbox the original invite email
self.check_sent_emails([invitee])
from django.core.mail import outbox
outbox.pop()
result = self.client_post("/json/invites/" + str(prereg_user.id) + "/resend")
self.assert_json_success(result)
self.check_sent_emails([invitee])
def test_accessing_invites_in_another_realm(self) -> None: def test_accessing_invites_in_another_realm(self) -> None:
inviter = UserProfile.objects.exclude(realm=get_realm("zulip")).first() inviter = UserProfile.objects.exclude(realm=get_realm("zulip")).first()
assert inviter is not None assert inviter is not None

View File

@ -1461,6 +1461,21 @@ class ActivateTest(ZulipTestCase):
invite_as=PreregistrationUser.INVITE_AS["REALM_ADMIN"], invite_as=PreregistrationUser.INVITE_AS["REALM_ADMIN"],
) )
do_invite_users(
iago,
["new5@zulip.com"],
[],
invite_expires_in_days=None,
invite_as=PreregistrationUser.INVITE_AS["REALM_ADMIN"],
)
do_invite_users(
desdemona,
["new6@zulip.com"],
[],
invite_expires_in_days=None,
invite_as=PreregistrationUser.INVITE_AS["REALM_ADMIN"],
)
iago_multiuse_key = do_create_multiuse_invite_link( iago_multiuse_key = do_create_multiuse_invite_link(
iago, PreregistrationUser.INVITE_AS["MEMBER"], invite_expires_in_days iago, PreregistrationUser.INVITE_AS["MEMBER"], invite_expires_in_days
).split("/")[-2] ).split("/")[-2]
@ -1468,17 +1483,24 @@ class ActivateTest(ZulipTestCase):
desdemona, PreregistrationUser.INVITE_AS["MEMBER"], invite_expires_in_days desdemona, PreregistrationUser.INVITE_AS["MEMBER"], invite_expires_in_days
).split("/")[-2] ).split("/")[-2]
iago_never_expire_multiuse_key = do_create_multiuse_invite_link(
iago, PreregistrationUser.INVITE_AS["MEMBER"], None
).split("/")[-2]
desdemona_never_expire_multiuse_key = do_create_multiuse_invite_link(
desdemona, PreregistrationUser.INVITE_AS["MEMBER"], None
).split("/")[-2]
self.assertEqual( self.assertEqual(
filter_to_valid_prereg_users( filter_to_valid_prereg_users(
PreregistrationUser.objects.filter(referred_by=iago) PreregistrationUser.objects.filter(referred_by=iago)
).count(), ).count(),
2, 3,
) )
self.assertEqual( self.assertEqual(
filter_to_valid_prereg_users( filter_to_valid_prereg_users(
PreregistrationUser.objects.filter(referred_by=desdemona) PreregistrationUser.objects.filter(referred_by=desdemona)
).count(), ).count(),
2, 3,
) )
self.assertTrue( self.assertTrue(
Confirmation.objects.get(confirmation_key=iago_multiuse_key).expiry_date Confirmation.objects.get(confirmation_key=iago_multiuse_key).expiry_date
@ -1488,6 +1510,14 @@ class ActivateTest(ZulipTestCase):
Confirmation.objects.get(confirmation_key=desdemona_multiuse_key).expiry_date Confirmation.objects.get(confirmation_key=desdemona_multiuse_key).expiry_date
> timezone_now() > timezone_now()
) )
self.assertIsNone(
Confirmation.objects.get(confirmation_key=iago_never_expire_multiuse_key).expiry_date
)
self.assertIsNone(
Confirmation.objects.get(
confirmation_key=desdemona_never_expire_multiuse_key
).expiry_date
)
do_deactivate_user(iago, acting_user=None) do_deactivate_user(iago, acting_user=None)
@ -1503,7 +1533,7 @@ class ActivateTest(ZulipTestCase):
filter_to_valid_prereg_users( filter_to_valid_prereg_users(
PreregistrationUser.objects.filter(referred_by=desdemona) PreregistrationUser.objects.filter(referred_by=desdemona)
).count(), ).count(),
2, 3,
) )
self.assertTrue( self.assertTrue(
Confirmation.objects.get(confirmation_key=iago_multiuse_key).expiry_date Confirmation.objects.get(confirmation_key=iago_multiuse_key).expiry_date
@ -1513,6 +1543,15 @@ class ActivateTest(ZulipTestCase):
Confirmation.objects.get(confirmation_key=desdemona_multiuse_key).expiry_date Confirmation.objects.get(confirmation_key=desdemona_multiuse_key).expiry_date
> timezone_now() > timezone_now()
) )
self.assertTrue(
Confirmation.objects.get(confirmation_key=iago_never_expire_multiuse_key).expiry_date
<= timezone_now()
)
self.assertIsNone(
Confirmation.objects.get(
confirmation_key=desdemona_never_expire_multiuse_key
).expiry_date
)
def test_clear_scheduled_jobs(self) -> None: def test_clear_scheduled_jobs(self) -> None:
user = self.example_user("hamlet") user = self.example_user("hamlet")

View File

@ -1,5 +1,5 @@
import re import re
from typing import List, Sequence, Set from typing import List, Optional, Sequence, Set
from django.conf import settings from django.conf import settings
from django.http import HttpRequest, HttpResponse from django.http import HttpRequest, HttpResponse
@ -18,7 +18,7 @@ from zerver.lib.exceptions import JsonableError, OrganizationOwnerRequired
from zerver.lib.request import REQ, has_request_variables from zerver.lib.request import REQ, has_request_variables
from zerver.lib.response import json_success from zerver.lib.response import json_success
from zerver.lib.streams import access_stream_by_id from zerver.lib.streams import access_stream_by_id
from zerver.lib.validator import check_int, check_list from zerver.lib.validator import check_int, check_list, check_none_or
from zerver.models import MultiuseInvite, PreregistrationUser, Stream, UserProfile from zerver.models import MultiuseInvite, PreregistrationUser, Stream, UserProfile
@ -36,8 +36,8 @@ def invite_users_backend(
request: HttpRequest, request: HttpRequest,
user_profile: UserProfile, user_profile: UserProfile,
invitee_emails_raw: str = REQ("invitee_emails"), invitee_emails_raw: str = REQ("invitee_emails"),
invite_expires_in_days: int = REQ( invite_expires_in_days: Optional[int] = REQ(
json_validator=check_int, default=settings.INVITATION_LINK_VALIDITY_DAYS json_validator=check_none_or(check_int), default=settings.INVITATION_LINK_VALIDITY_DAYS
), ),
invite_as: int = REQ(json_validator=check_int, default=PreregistrationUser.INVITE_AS["MEMBER"]), invite_as: int = REQ(json_validator=check_int, default=PreregistrationUser.INVITE_AS["MEMBER"]),
stream_ids: List[int] = REQ(json_validator=check_list(check_int)), stream_ids: List[int] = REQ(json_validator=check_list(check_int)),
@ -174,8 +174,8 @@ def resend_user_invite_email(
def generate_multiuse_invite_backend( def generate_multiuse_invite_backend(
request: HttpRequest, request: HttpRequest,
user_profile: UserProfile, user_profile: UserProfile,
invite_expires_in_days: int = REQ( invite_expires_in_days: Optional[int] = REQ(
json_validator=check_int, default=settings.INVITATION_LINK_VALIDITY_DAYS json_validator=check_none_or(check_int), default=settings.INVITATION_LINK_VALIDITY_DAYS
), ),
invite_as: int = REQ(json_validator=check_int, default=PreregistrationUser.INVITE_AS["MEMBER"]), invite_as: int = REQ(json_validator=check_int, default=PreregistrationUser.INVITE_AS["MEMBER"]),
stream_ids: Sequence[int] = REQ(json_validator=check_list(check_int), default=[]), stream_ids: Sequence[int] = REQ(json_validator=check_list(check_int), default=[]),

View File

@ -461,6 +461,12 @@ class ConfirmationEmailWorker(QueueProcessingWorker):
activate_url = do_send_confirmation_email( activate_url = do_send_confirmation_email(
invitee, referrer, email_language, invite_expires_in_days invitee, referrer, email_language, invite_expires_in_days
) )
if invite_expires_in_days is None:
# We do not queue reminder email for never expiring
# invitations. This is probably a low importance bug; it
# would likely be more natural to send a reminder after 7
# days.
return
# queue invitation reminder # queue invitation reminder
if invite_expires_in_days >= 4: if invite_expires_in_days >= 4: