mirror of https://github.com/zulip/zulip.git
Add UI for viewing and cancelling open Zulip invitations.
Lets administrators view a list of open(unconfirmed) invitations and resend or revoke a chosen invitation. There are a few changes that we can expect for the future: * It is currently possible to invite an email that you have already invited, it might make sense to change this behavior. * Resend currently sends an invite reminder instead of resending the original invite, this is because 'custom_body' was not stored when the first invite was sent. Tweaked in various minor ways, primarily in the backend, by tabbott, mostly for style consistency with the rest of the codebase. Fixes: #1180.
This commit is contained in:
parent
b831df8f7f
commit
09cd47c6ad
|
@ -50,6 +50,7 @@
|
||||||
"settings_users": false,
|
"settings_users": false,
|
||||||
"settings_streams": false,
|
"settings_streams": false,
|
||||||
"settings_filters": false,
|
"settings_filters": false,
|
||||||
|
"settings_invites": false,
|
||||||
"settings": false,
|
"settings": false,
|
||||||
"resize": false,
|
"resize": false,
|
||||||
"loading": false,
|
"loading": false,
|
||||||
|
|
|
@ -238,6 +238,39 @@ function render(template_name, args) {
|
||||||
assert.equal(filter_format.text(), 'https://trac.example.com/ticket/%(id)s');
|
assert.equal(filter_format.text(), 'https://trac.example.com/ticket/%(id)s');
|
||||||
}());
|
}());
|
||||||
|
|
||||||
|
(function admin_invites_list() {
|
||||||
|
var html = '<table>';
|
||||||
|
var invites = ['alice', 'bob', 'carl'];
|
||||||
|
var invite_id = 0;
|
||||||
|
_.each(invites, function (invite) {
|
||||||
|
var args = {
|
||||||
|
invite: {
|
||||||
|
email: invite + '@zulip.com',
|
||||||
|
ref: 'iago@zulip.com',
|
||||||
|
invited: "2017-01-01 01:01:01",
|
||||||
|
id: invite_id,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
html += render('admin_invites_list', args);
|
||||||
|
invite_id += 1;
|
||||||
|
});
|
||||||
|
html += "</table>";
|
||||||
|
var buttons = $(html).find('.button');
|
||||||
|
|
||||||
|
assert.equal($(buttons[0]).text().trim(), "Revoke");
|
||||||
|
assert($(buttons[0]).hasClass("revoke"));
|
||||||
|
assert.equal($(buttons[0]).attr("data-invite-id"), 0);
|
||||||
|
|
||||||
|
assert.equal($(buttons[3]).text().trim(), "Resend");
|
||||||
|
assert($(buttons[3]).hasClass("resend"));
|
||||||
|
assert.equal($(buttons[3]).attr("data-invite-id"), 1);
|
||||||
|
|
||||||
|
var span = $(html).find(".email:first");
|
||||||
|
assert.equal(span.text(), "alice@zulip.com");
|
||||||
|
|
||||||
|
global.write_handlebars_output("admin_invites_list", html);
|
||||||
|
}());
|
||||||
|
|
||||||
(function admin_streams_list() {
|
(function admin_streams_list() {
|
||||||
var html = '<table>';
|
var html = '<table>';
|
||||||
var streams = ['devel', 'trac', 'zulip'];
|
var streams = ['devel', 'trac', 'zulip'];
|
||||||
|
@ -257,7 +290,8 @@ function render(template_name, args) {
|
||||||
};
|
};
|
||||||
var html = render('admin_tab', args);
|
var html = render('admin_tab', args);
|
||||||
var admin_features = ["admin_users_table", "admin_bots_table",
|
var admin_features = ["admin_users_table", "admin_bots_table",
|
||||||
"admin_streams_table", "admin_deactivated_users_table"];
|
"admin_streams_table", "admin_deactivated_users_table",
|
||||||
|
"admin_invites_table"];
|
||||||
_.each(admin_features, function (admin_feature) {
|
_.each(admin_features, function (admin_feature) {
|
||||||
assert.notEqual($(html).find("#" + admin_feature).length, 0);
|
assert.notEqual($(html).find("#" + admin_feature).length, 0);
|
||||||
});
|
});
|
||||||
|
|
|
@ -29,6 +29,9 @@ exports.load_admin_section = function (name) {
|
||||||
case 'filter-settings':
|
case 'filter-settings':
|
||||||
section = 'filters';
|
section = 'filters';
|
||||||
break;
|
break;
|
||||||
|
case 'invites-list-admin':
|
||||||
|
section = 'invites';
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
blueslip.error('Unknown admin id ' + name);
|
blueslip.error('Unknown admin id ' + name);
|
||||||
return;
|
return;
|
||||||
|
@ -56,6 +59,9 @@ exports.load_admin_section = function (name) {
|
||||||
case 'filters':
|
case 'filters':
|
||||||
settings_filters.set_up();
|
settings_filters.set_up();
|
||||||
break;
|
break;
|
||||||
|
case 'invites':
|
||||||
|
settings_invites.set_up();
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
blueslip.error('programming error for section ' + section);
|
blueslip.error('programming error for section ' + section);
|
||||||
return;
|
return;
|
||||||
|
@ -71,6 +77,7 @@ exports.reset_sections = function () {
|
||||||
settings_users.reset();
|
settings_users.reset();
|
||||||
settings_streams.reset();
|
settings_streams.reset();
|
||||||
settings_filters.reset();
|
settings_filters.reset();
|
||||||
|
settings_invites.reset();
|
||||||
};
|
};
|
||||||
|
|
||||||
return exports;
|
return exports;
|
||||||
|
|
|
@ -83,6 +83,7 @@ function _setup_page() {
|
||||||
"streams-list-admin": i18n.t("Streams"),
|
"streams-list-admin": i18n.t("Streams"),
|
||||||
"default-streams-list": i18n.t("Default streams"),
|
"default-streams-list": i18n.t("Default streams"),
|
||||||
"filter-settings": i18n.t("Filter settings"),
|
"filter-settings": i18n.t("Filter settings"),
|
||||||
|
"invites-list-admin": i18n.t("Invitations"),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,153 @@
|
||||||
|
var settings_invites = (function () {
|
||||||
|
|
||||||
|
var exports = {};
|
||||||
|
|
||||||
|
var meta = {
|
||||||
|
loaded: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.reset = function () {
|
||||||
|
meta.loaded = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
function failed_listing_invites(xhr) {
|
||||||
|
loading.destroy_indicator($('#admin_page_invites_loading_indicator'));
|
||||||
|
ui_report.error(i18n.t("Error listing invites"), xhr, $("#organization-status"));
|
||||||
|
}
|
||||||
|
|
||||||
|
function populate_invites(invites_data) {
|
||||||
|
if (!meta.loaded) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var invites_table = $("#admin_invites_table").expectOne();
|
||||||
|
|
||||||
|
list_render(invites_table, invites_data.invites, {
|
||||||
|
name: "admin_invites_list",
|
||||||
|
modifier: function (item) {
|
||||||
|
return templates.render("admin_invites_list", { invite: item });
|
||||||
|
},
|
||||||
|
filter: {
|
||||||
|
element: invites_table.closest(".settings-section").find(".search"),
|
||||||
|
callback: function (item, value) {
|
||||||
|
return item.email.toLowerCase().indexOf(value) >= 0;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}).init();
|
||||||
|
|
||||||
|
loading.destroy_indicator($('#admin_page_invites_loading_indicator'));
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
exports.set_up = function () {
|
||||||
|
meta.loaded = true;
|
||||||
|
|
||||||
|
// create loading indicators
|
||||||
|
loading.make_indicator($('#admin_page_invites_loading_indicator'));
|
||||||
|
|
||||||
|
// Populate invites table
|
||||||
|
channel.get({
|
||||||
|
url: '/json/invites',
|
||||||
|
idempotent: true,
|
||||||
|
timeout: 10*1000,
|
||||||
|
success: exports.on_load_success,
|
||||||
|
error: failed_listing_invites,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.on_load_success = function (invites_data) {
|
||||||
|
meta.loaded = true;
|
||||||
|
populate_invites(invites_data);
|
||||||
|
|
||||||
|
$(".admin_invites_table").on("click", ".revoke", function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
var row = $(e.target).closest(".invite_row");
|
||||||
|
var email = row.find('.email').text();
|
||||||
|
meta.current_revoke_invite_user_modal_row = row;
|
||||||
|
meta.invite_id = $(e.currentTarget).attr("data-invite-id");
|
||||||
|
|
||||||
|
$("#revoke_invite_modal .email").text(email);
|
||||||
|
$("#revoke_invite_modal #do_revoke_invite_button").attr("data-invite-id", meta.invite_id);
|
||||||
|
$("#revoke_invite_modal").modal("show");
|
||||||
|
});
|
||||||
|
|
||||||
|
$(".admin_invites_table").on("click", ".resend", function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
var row = $(e.target).closest(".invite_row");
|
||||||
|
var email = row.find('.email').text();
|
||||||
|
meta.current_resend_invite_user_modal_row = row;
|
||||||
|
meta.invite_id = $(e.currentTarget).attr("data-invite-id");
|
||||||
|
|
||||||
|
$("#resend_invite_modal .email").text(email);
|
||||||
|
$("#resend_invite_modal #do_resend_invite_button").attr("data-invite-id", meta.invite_id);
|
||||||
|
$("#resend_invite_modal").modal("show");
|
||||||
|
});
|
||||||
|
|
||||||
|
$("#do_revoke_invite_button").click(function () {
|
||||||
|
var modal_invite_id = $("#revoke_invite_modal #do_revoke_invite_button").attr("data-invite-id");
|
||||||
|
var revoke_button = meta.current_revoke_invite_user_modal_row.find("button.revoke");
|
||||||
|
|
||||||
|
if (modal_invite_id !== meta.invite_id) {
|
||||||
|
blueslip.error("Invite revoking canceled due to non-matching fields.");
|
||||||
|
ui_report.message("Resending encountered an error. Please reload and try again.",
|
||||||
|
$("#home-error"), 'alert-error');
|
||||||
|
}
|
||||||
|
$("#revoke_invite_modal").modal("hide");
|
||||||
|
revoke_button.prop("disabled", true).text(i18n.t("Working…"));
|
||||||
|
channel.del({
|
||||||
|
url: '/json/invites/' + meta.invite_id,
|
||||||
|
error: function (xhr) {
|
||||||
|
if (xhr.status.toString().charAt(0) === "4") {
|
||||||
|
revoke_button.closest("td").html(
|
||||||
|
$("<p>").addClass("text-error").text(JSON.parse(xhr.responseText).msg)
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
revoke_button.text(i18n.t("Failed!"));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
success: function () {
|
||||||
|
meta.current_revoke_invite_user_modal_row.remove();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
$("#do_resend_invite_button").click(function () {
|
||||||
|
var modal_invite_id = $("#resend_invite_modal #do_resend_invite_button").attr("data-invite-id");
|
||||||
|
var resend_button = meta.current_resend_invite_user_modal_row.find("button.resend");
|
||||||
|
|
||||||
|
if (modal_invite_id !== meta.invite_id) {
|
||||||
|
blueslip.error("Invite resending canceled due to non-matching fields.");
|
||||||
|
ui_report.message("Resending encountered an error. Please reload and try again.",
|
||||||
|
$("#home-error"), 'alert-error');
|
||||||
|
}
|
||||||
|
$("#resend_invite_modal").modal("hide");
|
||||||
|
resend_button.prop("disabled", true).text(i18n.t("Working…"));
|
||||||
|
channel.post({
|
||||||
|
url: '/json/invites/' + meta.invite_id + "/resend",
|
||||||
|
error: function (xhr) {
|
||||||
|
if (xhr.status.toString().charAt(0) === "4") {
|
||||||
|
resend_button.closest("td").html(
|
||||||
|
$("<p>").addClass("text-error").text(JSON.parse(xhr.responseText).msg)
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
resend_button.text(i18n.t("Failed!"));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
success: function (data) {
|
||||||
|
resend_button.text(i18n.t("Resent!"));
|
||||||
|
resend_button.removeClass('resend btn-warning').addClass('sea-green');
|
||||||
|
meta.current_resend_invite_user_modal_row.find(".invited_at").text(data.timestamp);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return exports;
|
||||||
|
}());
|
||||||
|
|
||||||
|
if (typeof module !== 'undefined') {
|
||||||
|
module.exports = settings_invites;
|
||||||
|
}
|
|
@ -858,6 +858,11 @@ input[type=checkbox].inline-block {
|
||||||
width: 100px;
|
width: 100px;
|
||||||
height: 100px;
|
height: 100px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.invite-user-link i {
|
||||||
|
text-decoration: none;
|
||||||
|
margin-right: 5px;
|
||||||
|
}
|
||||||
/* -- new settings overlay -- */
|
/* -- new settings overlay -- */
|
||||||
#settings_page {
|
#settings_page {
|
||||||
min-height: 550px;
|
min-height: 550px;
|
||||||
|
|
|
@ -0,0 +1,21 @@
|
||||||
|
{{#with invite}}
|
||||||
|
<tr class="invite_row">
|
||||||
|
<td>
|
||||||
|
<span class="email">{{email}}</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="referred_by">{{ref}}</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="invited_at">{{invited}}</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<button class="button rounded small revoke btn-danger" data-invite-id="{{id}}">
|
||||||
|
{{t "Revoke" }}
|
||||||
|
</button>
|
||||||
|
<button class="button rounded small resend btn-warning" data-invite-id="{{id}}">
|
||||||
|
{{t "Resend" }}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{{/with}}
|
|
@ -3,6 +3,8 @@
|
||||||
{{ partial "deactivation-user-modal" }}
|
{{ partial "deactivation-user-modal" }}
|
||||||
{{ partial "deactivation-stream-modal" }}
|
{{ partial "deactivation-stream-modal" }}
|
||||||
{{ partial "realm-domains-modal" }}
|
{{ partial "realm-domains-modal" }}
|
||||||
|
{{ partial "revoke-invite-modal" }}
|
||||||
|
{{ partial "resend-invite-modal" }}
|
||||||
|
|
||||||
{{ partial "organization-profile-admin" }}
|
{{ partial "organization-profile-admin" }}
|
||||||
{{ partial "organization-settings-admin" }}
|
{{ partial "organization-settings-admin" }}
|
||||||
|
@ -23,3 +25,5 @@
|
||||||
{{ partial "auth-methods-settings-admin" }}
|
{{ partial "auth-methods-settings-admin" }}
|
||||||
|
|
||||||
{{ partial "realm-filter-settings-admin" }}
|
{{ partial "realm-filter-settings-admin" }}
|
||||||
|
|
||||||
|
{{ partial "invites-list-admin" }}
|
||||||
|
|
|
@ -0,0 +1,18 @@
|
||||||
|
<div id="admin-invites-list" class="settings-section" data-name="invites-list-admin">
|
||||||
|
<a class="invite-user-link" href="#invite"><i class="icon-vector-plus-sign"></i>{{t "Invite more users" }}</a>
|
||||||
|
<input type="text" class="search" placeholder="{{t 'Filter invites' }}" aria-label="{{t 'Filter invites' }}"/>
|
||||||
|
<div class="clear-float"></div>
|
||||||
|
|
||||||
|
<div class="progressive-table-wrapper">
|
||||||
|
<table class="table table-condensed table-striped">
|
||||||
|
<thead>
|
||||||
|
<th>{{t "Email" }}</th>
|
||||||
|
<th>{{t "Invited by" }}</th>
|
||||||
|
<th>{{t "Invited at" }}</th>
|
||||||
|
<th class="actions">{{t "Actions" }}</th>
|
||||||
|
</thead>
|
||||||
|
<tbody id="admin_invites_table" class="required-text thick admin_invites_table" data-empty="{{t 'No invites match your current filter.' }}"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div id="admin_page_invites_loading_indicator"></div>
|
||||||
|
</div>
|
|
@ -0,0 +1,13 @@
|
||||||
|
<div id="resend_invite_modal" class="modal hide fade" tabindex="-1" role="dialog" aria-labelledby="resend_invite_modal_label" aria-hidden="true">
|
||||||
|
<div class="modal-header">
|
||||||
|
<button type="button" class="close" data-dismiss="modal" aria-label="{{t 'Close' }}"><span aria-hidden="true">×</span></button>
|
||||||
|
<h3 id="resend_invite_modal_label">{{#tr this}}Resend invitation to <span class="email"></span>{{/tr}}</h3>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<p>{{#tr this}}Are you sure you want to resend the invitation to <strong><span class="email"></span></strong>?{{/tr}}</p>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button class="button rounded" data-dismiss="modal">{{t "Cancel" }}</button>
|
||||||
|
<button class="button rounded btn-danger" id="do_resend_invite_button" data-invite-id="{{invite_id}}">{{t "Resend now" }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
|
@ -0,0 +1,13 @@
|
||||||
|
<div id="revoke_invite_modal" class="modal hide fade" tabindex="-1" role="dialog" aria-labelledby="revoke_invite_modal_label" aria-hidden="true">
|
||||||
|
<div class="modal-header">
|
||||||
|
<button type="button" class="close" data-dismiss="modal" aria-label="{{t 'Close' }}"><span aria-hidden="true">×</span></button>
|
||||||
|
<h3 id="revoke_invite_modal_label">{{#tr this}}Revoke invitation to <span class="email"></span>{{/tr}}</h3>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<p>{{#tr this}}Are you sure you want to revoke the invitation to <strong><span class="email"></span></strong>?{{/tr}}</p>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button class="button rounded" data-dismiss="modal">{{t "Cancel" }}</button>
|
||||||
|
<button class="button rounded btn-danger" id="do_revoke_invite_button">{{t "Revoke now" }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
|
@ -92,6 +92,12 @@
|
||||||
<i class="icon icon-vector-font"></i>
|
<i class="icon icon-vector-font"></i>
|
||||||
<div class="text">{{ _('Filter settings') }}</div>
|
<div class="text">{{ _('Filter settings') }}</div>
|
||||||
</li>
|
</li>
|
||||||
|
{% if is_admin %}
|
||||||
|
<li class="admin" tabindex="0" data-section="invites-list-admin">
|
||||||
|
<i class="icon icon-vector-user"></i>
|
||||||
|
<div class="text">{{ _('Invitations') }}</div>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
@ -4,6 +4,7 @@ from typing import (
|
||||||
)
|
)
|
||||||
from mypy_extensions import TypedDict
|
from mypy_extensions import TypedDict
|
||||||
|
|
||||||
|
from django.contrib.contenttypes.models import ContentType
|
||||||
from django.utils.html import escape
|
from django.utils.html import escape
|
||||||
from django.utils.translation import ugettext as _
|
from django.utils.translation import ugettext as _
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
@ -71,6 +72,7 @@ from django.core.mail import EmailMessage
|
||||||
from django.utils.timezone import now as timezone_now
|
from django.utils.timezone import now as timezone_now
|
||||||
|
|
||||||
from confirmation.models import Confirmation, create_confirmation_link
|
from confirmation.models import Confirmation, create_confirmation_link
|
||||||
|
from confirmation import settings as confirmation_settings
|
||||||
from six.moves import filter
|
from six.moves import filter
|
||||||
from six.moves import map
|
from six.moves import map
|
||||||
from six import unichr
|
from six import unichr
|
||||||
|
@ -3805,6 +3807,82 @@ def do_invite_users(user_profile, invitee_emails, streams, invite_as_admin=False
|
||||||
"invitations to everyone else!"),
|
"invitations to everyone else!"),
|
||||||
skipped, sent_invitations=True)
|
skipped, sent_invitations=True)
|
||||||
|
|
||||||
|
def do_get_user_invites(user_profile):
|
||||||
|
# type: (UserProfile) -> List[Dict[str, Any]]
|
||||||
|
days_to_activate = getattr(settings, 'ACCOUNT_ACTIVATION_DAYS', 7)
|
||||||
|
active_value = getattr(confirmation_settings, 'STATUS_ACTIVE', 1)
|
||||||
|
|
||||||
|
lowest_datetime = timezone_now() - datetime.timedelta(days=days_to_activate)
|
||||||
|
prereg_users = PreregistrationUser.objects.exclude(status=active_value).filter(
|
||||||
|
invited_at__gte=lowest_datetime,
|
||||||
|
referred_by__realm=user_profile.realm)
|
||||||
|
|
||||||
|
invites = []
|
||||||
|
|
||||||
|
for invitee in prereg_users:
|
||||||
|
invites.append(dict(email=invitee.email,
|
||||||
|
ref=invitee.referred_by.email,
|
||||||
|
invited=invitee.invited_at.strftime("%Y-%m-%d %H:%M:%S"),
|
||||||
|
id=invitee.id))
|
||||||
|
|
||||||
|
return invites
|
||||||
|
|
||||||
|
def do_revoke_user_invite(invite_id, realm_id):
|
||||||
|
# type: (int, int) -> None
|
||||||
|
try:
|
||||||
|
prereg_user = PreregistrationUser.objects.get(id=invite_id)
|
||||||
|
except PreregistrationUser.DoesNotExist:
|
||||||
|
raise JsonableError(_("Invalid invitation ID."))
|
||||||
|
|
||||||
|
if prereg_user.referred_by.realm_id != realm_id:
|
||||||
|
raise JsonableError(_("Invalid invitation ID."))
|
||||||
|
|
||||||
|
email = prereg_user.email
|
||||||
|
|
||||||
|
# Delete both the confirmation objects and the prereg_user object.
|
||||||
|
# TODO: Probably we actaully want to set the confirmation objects
|
||||||
|
# to a "revoked" status so that we can give the user a better
|
||||||
|
# error message.
|
||||||
|
content_type = ContentType.objects.get_for_model(PreregistrationUser)
|
||||||
|
Confirmation.objects.filter(content_type=content_type,
|
||||||
|
object_id=prereg_user.id).delete()
|
||||||
|
prereg_user.delete()
|
||||||
|
clear_scheduled_invitation_emails(email)
|
||||||
|
|
||||||
|
def do_resend_user_invite_email(invite_id, realm_id):
|
||||||
|
# type: (int, int) -> str
|
||||||
|
try:
|
||||||
|
prereg_user = PreregistrationUser.objects.get(id=invite_id)
|
||||||
|
except PreregistrationUser.DoesNotExist:
|
||||||
|
raise JsonableError(_("Invalid invitation ID."))
|
||||||
|
|
||||||
|
if (prereg_user.referred_by.realm_id != realm_id):
|
||||||
|
raise JsonableError(_("Invalid invitation ID."))
|
||||||
|
|
||||||
|
prereg_user.invited_at = timezone_now()
|
||||||
|
prereg_user.save()
|
||||||
|
|
||||||
|
# sends a invitation reminder since 'custom_body' can not be resent
|
||||||
|
# imported here to avoid import cycle error
|
||||||
|
from zerver.context_processors import common_context
|
||||||
|
clear_scheduled_invitation_emails(prereg_user.email)
|
||||||
|
|
||||||
|
link = create_confirmation_link(prereg_user, prereg_user.referred_by.realm.host, Confirmation.INVITATION)
|
||||||
|
context = common_context(prereg_user.referred_by)
|
||||||
|
context.update({
|
||||||
|
'activate_url': link,
|
||||||
|
'referrer_name': prereg_user.referred_by.full_name,
|
||||||
|
'referrer_email': prereg_user.referred_by.email,
|
||||||
|
'referrer_realm_name': prereg_user.referred_by.realm.name,
|
||||||
|
})
|
||||||
|
send_email(
|
||||||
|
"zerver/emails/invitation_reminder",
|
||||||
|
to_email=prereg_user.email,
|
||||||
|
from_address=FromAddress.NOREPLY,
|
||||||
|
context=context)
|
||||||
|
|
||||||
|
return prereg_user.invited_at.strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
|
||||||
def notify_realm_emoji(realm):
|
def notify_realm_emoji(realm):
|
||||||
# type: (Realm) -> None
|
# type: (Realm) -> None
|
||||||
event = dict(type="realm_emoji", op="update",
|
event = dict(type="realm_emoji", op="update",
|
||||||
|
|
|
@ -12,6 +12,7 @@ from zerver.lib.test_helpers import MockLDAP
|
||||||
|
|
||||||
from confirmation.models import Confirmation, create_confirmation_link, MultiuseInvite, \
|
from confirmation.models import Confirmation, create_confirmation_link, MultiuseInvite, \
|
||||||
generate_key, confirmation_url
|
generate_key, confirmation_url
|
||||||
|
from confirmation import settings as confirmation_settings
|
||||||
|
|
||||||
from zerver.forms import HomepageForm, WRONG_SUBDOMAIN_ERROR
|
from zerver.forms import HomepageForm, WRONG_SUBDOMAIN_ERROR
|
||||||
from zerver.lib.actions import do_change_password, gather_subscriptions
|
from zerver.lib.actions import do_change_password, gather_subscriptions
|
||||||
|
@ -442,7 +443,6 @@ class InviteUserBase(ZulipTestCase):
|
||||||
|
|
||||||
self.assertIn(FromAddress.NOREPLY, outbox[0].from_email)
|
self.assertIn(FromAddress.NOREPLY, outbox[0].from_email)
|
||||||
|
|
||||||
class InviteUserTest(InviteUserBase):
|
|
||||||
def invite(self, users, streams, body='', invite_as_admin="false"):
|
def invite(self, users, streams, body='', invite_as_admin="false"):
|
||||||
# type: (Text, List[Text], str, str) -> HttpResponse
|
# type: (Text, List[Text], str, str) -> HttpResponse
|
||||||
"""
|
"""
|
||||||
|
@ -460,6 +460,7 @@ class InviteUserTest(InviteUserBase):
|
||||||
"invite_as_admin": invite_as_admin,
|
"invite_as_admin": invite_as_admin,
|
||||||
"custom_body": body})
|
"custom_body": body})
|
||||||
|
|
||||||
|
class InviteUserTest(InviteUserBase):
|
||||||
def test_successful_invite_user(self):
|
def test_successful_invite_user(self):
|
||||||
# type: () -> None
|
# type: () -> None
|
||||||
"""
|
"""
|
||||||
|
@ -845,6 +846,93 @@ so we didn't send them an invitation. We did send invitations to everyone else!"
|
||||||
scheduled_timestamp__lte=timezone_now(), type=ScheduledEmail.INVITATION_REMINDER)
|
scheduled_timestamp__lte=timezone_now(), type=ScheduledEmail.INVITATION_REMINDER)
|
||||||
self.assertEqual(len(email_jobs_to_deliver), 0)
|
self.assertEqual(len(email_jobs_to_deliver), 0)
|
||||||
|
|
||||||
|
class InvitationsTestCase(InviteUserBase):
|
||||||
|
def test_successful_get_open_invitations(self):
|
||||||
|
# type: () -> None
|
||||||
|
"""
|
||||||
|
A GET call to /json/invites returns all unexpired invitations.
|
||||||
|
"""
|
||||||
|
|
||||||
|
days_to_activate = getattr(settings, 'ACCOUNT_ACTIVATION_DAYS', "Wrong")
|
||||||
|
active_value = getattr(confirmation_settings, 'STATUS_ACTIVE', "Wrong")
|
||||||
|
self.assertNotEqual(days_to_activate, "Wrong")
|
||||||
|
self.assertNotEqual(active_value, "Wrong")
|
||||||
|
|
||||||
|
self.login(self.example_email("iago"))
|
||||||
|
user_profile = self.example_user("iago")
|
||||||
|
|
||||||
|
prereg_user_one = PreregistrationUser(email="TestOne@zulip.com", referred_by=user_profile)
|
||||||
|
prereg_user_one.save()
|
||||||
|
expired_datetime = timezone_now() - datetime.timedelta(days=(days_to_activate+1))
|
||||||
|
prereg_user_two = PreregistrationUser(email="TestTwo@zulip.com", referred_by=user_profile)
|
||||||
|
prereg_user_two.save()
|
||||||
|
PreregistrationUser.objects.filter(id=prereg_user_two.id).update(invited_at=expired_datetime)
|
||||||
|
prereg_user_three = PreregistrationUser(email="TestThree@zulip.com",
|
||||||
|
referred_by=user_profile, status=active_value)
|
||||||
|
prereg_user_three.save()
|
||||||
|
|
||||||
|
result = self.client_get("/json/invites")
|
||||||
|
self.assertEqual(result.status_code, 200)
|
||||||
|
self.assert_in_success_response(["TestOne@zulip.com"], result)
|
||||||
|
self.assert_not_in_success_response(["TestTwo@zulip.com", "TestThree@zulip.com"], result)
|
||||||
|
|
||||||
|
def test_successful_delete_invitation(self):
|
||||||
|
# type: () -> None
|
||||||
|
"""
|
||||||
|
A DELETE call to /json/invites/<ID> should delete the invite and
|
||||||
|
any scheduled invitation reminder emails.
|
||||||
|
"""
|
||||||
|
self.login(self.example_email("iago"))
|
||||||
|
|
||||||
|
invitee = "DeleteMe@zulip.com"
|
||||||
|
self.assert_json_success(self.invite(invitee, ['Denmark']))
|
||||||
|
prereg_user = PreregistrationUser.objects.get(email=invitee)
|
||||||
|
|
||||||
|
# Verify that the scheduled email exists.
|
||||||
|
ScheduledEmail.objects.get(address__iexact=invitee,
|
||||||
|
type=ScheduledEmail.INVITATION_REMINDER)
|
||||||
|
|
||||||
|
result = self.client_delete('/json/invites/' + str(prereg_user.id))
|
||||||
|
self.assertEqual(result.status_code, 200)
|
||||||
|
error_result = self.client_delete('/json/invites/' + str(prereg_user.id))
|
||||||
|
self.assert_json_error(error_result, "Invalid invitation ID.")
|
||||||
|
|
||||||
|
self.assertRaises(ScheduledEmail.DoesNotExist,
|
||||||
|
lambda: ScheduledEmail.objects.get(address__iexact=invitee,
|
||||||
|
type=ScheduledEmail.INVITATION_REMINDER))
|
||||||
|
|
||||||
|
def test_successful_resend_invitation(self):
|
||||||
|
# type: () -> None
|
||||||
|
"""
|
||||||
|
A POST call to /json/invites/<ID>/resend should send an invitation reminder email
|
||||||
|
and delete any scheduled invitation reminder email.
|
||||||
|
"""
|
||||||
|
self.login(self.example_email("iago"))
|
||||||
|
invitee = "ResendMe@zulip.com"
|
||||||
|
|
||||||
|
self.assert_json_success(self.invite(invitee, ['Denmark']))
|
||||||
|
prereg_user = PreregistrationUser.objects.get(email=invitee)
|
||||||
|
|
||||||
|
# Verify and then clear from the outbox the original invite email
|
||||||
|
self.check_sent_emails([invitee], custom_from_name="Zulip")
|
||||||
|
from django.core.mail import outbox
|
||||||
|
outbox.pop()
|
||||||
|
|
||||||
|
# Verify that the scheduled email exists.
|
||||||
|
ScheduledEmail.objects.get(address__iexact=invitee,
|
||||||
|
type=ScheduledEmail.INVITATION_REMINDER)
|
||||||
|
result = self.client_post('/json/invites/' + str(prereg_user.id) + '/resend')
|
||||||
|
|
||||||
|
self.assertEqual(result.status_code, 200)
|
||||||
|
error_result = self.client_post('/json/invites/' + str(9999) + '/resend')
|
||||||
|
self.assert_json_error(error_result, "Invalid invitation ID.")
|
||||||
|
|
||||||
|
self.check_sent_emails([invitee], custom_from_name="Zulip")
|
||||||
|
|
||||||
|
self.assertRaises(ScheduledEmail.DoesNotExist,
|
||||||
|
lambda: ScheduledEmail.objects.get(address__iexact=invitee,
|
||||||
|
type=ScheduledEmail.INVITATION_REMINDER))
|
||||||
|
|
||||||
class InviteeEmailsParserTests(TestCase):
|
class InviteeEmailsParserTests(TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
# type: () -> None
|
# type: () -> None
|
||||||
|
|
|
@ -5,11 +5,11 @@ from django.http import HttpRequest, HttpResponse
|
||||||
from django.utils.translation import ugettext as _
|
from django.utils.translation import ugettext as _
|
||||||
from typing import List, Optional, Set, Text
|
from typing import List, Optional, Set, Text
|
||||||
|
|
||||||
from zerver.decorator import authenticated_json_post_view
|
from zerver.decorator import authenticated_json_post_view, require_realm_admin, to_non_negative_int
|
||||||
from zerver.lib.actions import do_invite_users, \
|
from zerver.lib.actions import do_invite_users, do_revoke_user_invite, do_resend_user_invite_email, \
|
||||||
get_default_subs
|
get_default_subs, do_get_user_invites
|
||||||
from zerver.lib.request import REQ, has_request_variables, JsonableError
|
from zerver.lib.request import REQ, has_request_variables, JsonableError
|
||||||
from zerver.lib.response import json_success, json_error
|
from zerver.lib.response import json_success, json_error, json_response
|
||||||
from zerver.lib.streams import access_stream_by_name
|
from zerver.lib.streams import access_stream_by_name
|
||||||
from zerver.lib.validator import check_string, check_list, check_bool
|
from zerver.lib.validator import check_string, check_list, check_bool
|
||||||
from zerver.models import PreregistrationUser, Stream, UserProfile
|
from zerver.models import PreregistrationUser, Stream, UserProfile
|
||||||
|
@ -65,3 +65,23 @@ def get_invitee_emails_set(invitee_emails_raw):
|
||||||
email = is_email_with_name.group('email')
|
email = is_email_with_name.group('email')
|
||||||
invitee_emails.add(email.strip())
|
invitee_emails.add(email.strip())
|
||||||
return invitee_emails
|
return invitee_emails
|
||||||
|
|
||||||
|
@require_realm_admin
|
||||||
|
def get_user_invites(request, user_profile):
|
||||||
|
# type: (HttpRequest, UserProfile) -> HttpResponse
|
||||||
|
all_users = do_get_user_invites(user_profile)
|
||||||
|
return json_success({'invites': all_users})
|
||||||
|
|
||||||
|
@require_realm_admin
|
||||||
|
@has_request_variables
|
||||||
|
def revoke_user_invite(request, user_profile, prereg_id):
|
||||||
|
# type: (HttpRequest, UserProfile, int) -> HttpResponse
|
||||||
|
do_revoke_user_invite(prereg_id, user_profile.realm_id)
|
||||||
|
return json_success()
|
||||||
|
|
||||||
|
@require_realm_admin
|
||||||
|
@has_request_variables
|
||||||
|
def resend_user_invite_email(request, user_profile, prereg_id):
|
||||||
|
# type: (HttpRequest, UserProfile, int) -> HttpResponse
|
||||||
|
timestamp = do_resend_user_invite_email(prereg_id, user_profile.realm_id)
|
||||||
|
return json_success({'timestamp': timestamp})
|
||||||
|
|
|
@ -1018,6 +1018,7 @@ JS_SPECS = {
|
||||||
'js/settings_users.js',
|
'js/settings_users.js',
|
||||||
'js/settings_streams.js',
|
'js/settings_streams.js',
|
||||||
'js/settings_filters.js',
|
'js/settings_filters.js',
|
||||||
|
'js/settings_invites.js',
|
||||||
'js/settings.js',
|
'js/settings.js',
|
||||||
'js/admin_sections.js',
|
'js/admin_sections.js',
|
||||||
'js/admin.js',
|
'js/admin.js',
|
||||||
|
|
|
@ -130,7 +130,12 @@ v1_api_and_json_patterns = [
|
||||||
|
|
||||||
# invites -> zerver.views.invite
|
# invites -> zerver.views.invite
|
||||||
url(r'^invites$', rest_dispatch,
|
url(r'^invites$', rest_dispatch,
|
||||||
{'POST': 'zerver.views.invite.invite_users_backend'}),
|
{'GET': 'zerver.views.invite.get_user_invites',
|
||||||
|
'POST': 'zerver.views.invite.invite_users_backend'}),
|
||||||
|
url(r'^invites/(?P<prereg_id>[0-9]+)$', rest_dispatch,
|
||||||
|
{'DELETE': 'zerver.views.invite.revoke_user_invite'}),
|
||||||
|
url(r'^invites/(?P<prereg_id>[0-9]+)/resend$', rest_dispatch,
|
||||||
|
{'POST': 'zerver.views.invite.resend_user_invite_email'}),
|
||||||
|
|
||||||
# mark messages as read (in bulk)
|
# mark messages as read (in bulk)
|
||||||
url(r'^mark_all_as_read$', rest_dispatch,
|
url(r'^mark_all_as_read$', rest_dispatch,
|
||||||
|
|
Loading…
Reference in New Issue