Skip to content

Commit

Permalink
Add new invitations list and delete views
Browse files Browse the repository at this point in the history
  • Loading branch information
rowanseymour committed Oct 10, 2024
1 parent d5541a4 commit c8fa8c2
Show file tree
Hide file tree
Showing 7 changed files with 168 additions and 70 deletions.
2 changes: 1 addition & 1 deletion temba/api/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -180,7 +180,7 @@ def as_user(user, expected_results: list, expected_queries: int = None):
response = self._getJSON(endpoint_url, user, by_token=by_token, num_queries=expected_queries)

if results is not None:
self.assertEqual(200, response.status_code)
self.assertEqual(200, response.status_code, f"status code mismatch for user {user}")

actual_results = response.json()["results"]
full_check = expected_results and isinstance(expected_results[0], dict)
Expand Down
9 changes: 7 additions & 2 deletions temba/orgs/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -1507,6 +1507,10 @@ class Invitation(SmartModel):
secret = models.CharField(max_length=64, unique=True)
user_group = models.CharField(max_length=1, choices=ROLE_CHOICES, default=OrgRole.EDITOR.code)

@classmethod
def create(cls, org, user, email: str, role: OrgRole):
return cls.objects.create(org=org, email=email, user_group=role.code, created_by=user, modified_by=user)

def save(self, *args, **kwargs):
if not self.secret:
self.secret = generate_secret(64)
Expand Down Expand Up @@ -1535,10 +1539,11 @@ def accept(self, user):

self.release()

def release(self):
def release(self, user=None):
self.is_active = False
self.modified_on = timezone.now()
self.save(update_fields=("is_active", "modified_on"))
self.modified_by = user or self.modified_by
self.save(update_fields=("is_active", "modified_by", "modified_on"))


class BackupToken(models.Model):
Expand Down
110 changes: 55 additions & 55 deletions temba/orgs/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -118,13 +118,7 @@ def test_group_perms_wrapper(self):

class InvitationTest(TembaTest):
def test_model(self):
invitation = Invitation.objects.create(
org=self.org,
user_group="E",
email="[email protected]",
created_by=self.admin,
modified_by=self.admin,
)
invitation = Invitation.create(self.org, self.admin, "[email protected]", OrgRole.EDITOR)

self.assertEqual(OrgRole.EDITOR, invitation.role)

Expand Down Expand Up @@ -1561,12 +1555,8 @@ def test_manage_accounts(self):
self.org.save(update_fields=("features",))

# create invitations
invitation1 = Invitation.objects.create(
org=self.org, email="[email protected]", user_group="A", created_by=self.admin, modified_by=self.admin
)
invitation2 = Invitation.objects.create(
org=self.org, email="[email protected]", user_group="T", created_by=self.admin, modified_by=self.admin
)
invitation1 = Invitation.create(self.org, self.admin, "[email protected]", OrgRole.ADMINISTRATOR)
invitation2 = Invitation.create(self.org, self.admin, "[email protected]", OrgRole.AGENT)

# add a second editor to the org
editor2 = self.create_user("[email protected]", first_name="Edwina")
Expand Down Expand Up @@ -2001,13 +1991,7 @@ def test_join(self):
response = self.client.get(reverse("orgs.org_join", args=["invalid"]))
self.assertRedirect(response, reverse("public.public_index"))

invitation = Invitation.objects.create(
org=self.org,
user_group="E",
email="[email protected]",
created_by=self.admin,
modified_by=self.admin,
)
invitation = Invitation.create(self.org, self.admin, "[email protected]", OrgRole.EDITOR)

join_url = reverse("orgs.org_join", args=[invitation.secret])
join_signup_url = reverse("orgs.org_join_signup", args=[invitation.secret])
Expand All @@ -2034,13 +2018,7 @@ def test_join(self):
self.assertEqual(0, len(self.client.session.keys()))

# invitation with mismatching case email
invitation2 = Invitation.objects.create(
org=self.org2,
user_group="E",
email="[email protected]",
created_by=self.admin2,
modified_by=self.admin2,
)
invitation2 = Invitation.create(self.org2, self.admin, "[email protected]", OrgRole.EDITOR)

join_accept_url = reverse("orgs.org_join_accept", args=[invitation2.secret])
join_url = reverse("orgs.org_join", args=[invitation2.secret])
Expand All @@ -2062,13 +2040,7 @@ def test_join_signup(self):
response = self.client.get(reverse("orgs.org_join_signup", args=["invalid"]))
self.assertRedirect(response, reverse("public.public_index"))

invitation = Invitation.objects.create(
org=self.org,
user_group="A",
email="[email protected]",
created_by=self.admin,
modified_by=self.admin,
)
invitation = Invitation.create(self.org, self.admin, "[email protected]", OrgRole.ADMINISTRATOR)

join_signup_url = reverse("orgs.org_join_signup", args=[invitation.secret])
join_url = reverse("orgs.org_join", args=[invitation.secret])
Expand All @@ -2077,13 +2049,7 @@ def test_join_signup(self):
response = self.client.get(join_signup_url)
self.assertRedirect(response, join_url)

invitation = Invitation.objects.create(
org=self.org,
user_group="E",
email="[email protected]",
created_by=self.admin,
modified_by=self.admin,
)
invitation = Invitation.create(self.org, self.admin, "[email protected]", OrgRole.EDITOR)

join_signup_url = reverse("orgs.org_join_signup", args=[invitation.secret])
join_url = reverse("orgs.org_join", args=[invitation.secret])
Expand Down Expand Up @@ -2116,13 +2082,7 @@ def test_join_accept(self):
response = self.client.get(reverse("orgs.org_join_accept", args=["invalid"]))
self.assertRedirect(response, reverse("public.public_index"))

invitation = Invitation.objects.create(
org=self.org,
user_group="E",
email="[email protected]",
created_by=self.admin,
modified_by=self.admin,
)
invitation = Invitation.create(self.org, self.admin, "[email protected]", OrgRole.EDITOR)

join_accept_url = reverse("orgs.org_join_accept", args=[invitation.secret])
join_url = reverse("orgs.org_join", args=[invitation.secret])
Expand Down Expand Up @@ -3438,13 +3398,7 @@ def test_forget(self):
self.assertRedirect(response, forget_url, status_code=301)

FailedLogin.objects.create(username="[email protected]")
invitation = Invitation.objects.create(
org=self.org,
user_group="A",
email="[email protected]",
created_by=self.admin,
modified_by=self.admin,
)
invitation = Invitation.create(self.org, self.admin, "[email protected]", OrgRole.ADMINISTRATOR)

# no login required to access
response = self.client.get(forget_url)
Expand Down Expand Up @@ -4371,9 +4325,30 @@ def test_prevent_flow_type_changes(self):


class InvitationCRUDLTest(TembaTest, CRUDLTestMixin):
def test_list(self):
list_url = reverse("orgs.invitation_list")

# nobody can access if users feature not enabled
response = self.requestView(list_url, self.admin)
self.assertRedirect(response, reverse("orgs.org_workspace"))

self.org.features = [Org.FEATURE_USERS]
self.org.save(update_fields=("features",))

self.assertRequestDisallowed(list_url, [None, self.user, self.editor, self.agent])

inv1 = Invitation.create(self.org, self.admin, "[email protected]", OrgRole.EDITOR)
inv2 = Invitation.create(self.org, self.admin, "[email protected]", OrgRole.AGENT)

self.assertListFetch(list_url, [self.admin], context_objects=[inv2, inv1])

def test_create(self):
create_url = reverse("orgs.invitation_create")

# nobody can access if users feature not enabled
response = self.requestView(create_url, self.admin)
self.assertRedirect(response, reverse("orgs.org_workspace"))

self.org.features = [Org.FEATURE_CHILD_ORGS, Org.FEATURE_USERS]
self.org.save(update_fields=("features",))

Expand Down Expand Up @@ -4443,6 +4418,31 @@ def test_create(self):
response = self.client.get(create_url + f"?org={self.org2.id}")
self.assertEqual(404, response.status_code)

def test_delete(self):
inv1 = Invitation.create(self.org, self.admin, "[email protected]", OrgRole.EDITOR)
inv2 = Invitation.create(self.org, self.admin, "[email protected]", OrgRole.AGENT)

delete_url = reverse("orgs.invitation_delete", args=[inv1.id])

# nobody can access if users feature not enabled
response = self.requestView(delete_url, self.admin)
self.assertRedirect(response, reverse("orgs.org_workspace"))

self.org.features = [Org.FEATURE_USERS]
self.org.save(update_fields=("features",))

self.assertRequestDisallowed(delete_url, [None, self.user, self.editor, self.agent])

response = self.assertDeleteFetch(delete_url, [self.admin], as_modal=True)
self.assertContains(
response, "You are about to cancel the invitation sent to <b>[email protected]</b>. Are you sure?"
)

response = self.assertDeleteSubmit(delete_url, self.admin, object_deactivated=inv1)

self.assertRedirect(response, reverse("orgs.invitation_list"))
self.assertEqual({inv2}, set(self.org.invitations.filter(is_active=True)))


class BackupTokenTest(TembaTest):
def test_model(self):
Expand Down
42 changes: 30 additions & 12 deletions temba/orgs/views/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@
User,
UserSettings,
)
from .base import BaseMenuView
from .base import BaseDeleteModal, BaseListView, BaseMenuView
from .forms import SignupForm, SMTPForm
from .mixins import InferOrgMixin, OrgObjPermsMixin, OrgPermsMixin, RequireFeatureMixin

Expand Down Expand Up @@ -2310,9 +2310,26 @@ def has_permission(self, request, *args, **kwargs):

class InvitationCRUDL(SmartCRUDL):
model = Invitation
actions = ("create",)
actions = ("list", "create", "delete")

class Create(ModalFormMixin, OrgPermsMixin, SmartCreateView):
class List(RequireFeatureMixin, ContextMenuMixin, BaseListView):
require_feature = Org.FEATURE_USERS
title = _("Invitations")
menu_path = "/settings/users"
default_order = ("-created_on",)

def build_context_menu(self, menu):
menu.add_modax(_("New"), "invite-create", reverse("orgs.invitation_create"), as_button=True)

def derive_queryset(self, **kwargs):
return super().derive_queryset(**kwargs).filter(is_active=True)

def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context["validity_days"] = settings.INVITATION_VALIDITY.days
return context

class Create(RequireFeatureMixin, ModalFormMixin, OrgPermsMixin, SmartCreateView):
class Form(forms.ModelForm):
ROLE_CHOICES = [(r.code, r.display) for r in (OrgRole.AGENT, OrgRole.EDITOR, OrgRole.ADMINISTRATOR)]

Expand Down Expand Up @@ -2342,6 +2359,7 @@ class Meta:
fields = ("email", "role")

form_class = Form
require_feature = Org.FEATURE_USERS
title = ""
submit_button_name = _("Send")
success_url = "@orgs.org_manage_accounts"
Expand All @@ -2363,21 +2381,21 @@ def get_context_data(self, **kwargs):
context["validity_days"] = settings.INVITATION_VALIDITY.days
return context

def pre_save(self, obj):
org = self.get_dest_org()

assert Org.FEATURE_USERS in org.features

obj.org = org
obj.user_group = self.form.cleaned_data["role"]

return super().pre_save(obj)
def save(self, obj):
self.object = Invitation.create(
self.get_dest_org(), self.request.user, obj.email, OrgRole.from_code(self.form.cleaned_data["role"])
)

def post_save(self, obj):
obj.send()

return super().post_save(obj)

class Delete(RequireFeatureMixin, BaseDeleteModal):
require_feature = Org.FEATURE_USERS
cancel_url = "@orgs.invitation_list"
redirect_url = "@orgs.invitation_list"


class OrgImportCRUDL(SmartCRUDL):
model = OrgImport
Expand Down
1 change: 1 addition & 0 deletions temba/settings_common.py
Original file line number Diff line number Diff line change
Expand Up @@ -684,6 +684,7 @@
# extra permissions that only apply to API requests (wildcard notation not supported here)
API_PERMISSIONS = {
"Editors": ("orgs.user_list",),
"Viewers": ("orgs.user_list",),
"Agents": (
"contacts.contact_create",
"contacts.contact_list",
Expand Down
8 changes: 8 additions & 0 deletions templates/orgs/invitation_delete.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{% extends "includes/modax.html" %}
{% load i18n %}

{% block fields %}
{% blocktrans trimmed with email=object.email %}
You are about to cancel the invitation sent to <b>{{ email }}</b>. Are you sure?
{% endblocktrans %}
{% endblock fields %}
66 changes: 66 additions & 0 deletions templates/orgs/invitation_list.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
{% extends "smartmin/list.html" %}
{% load smartmin temba i18n %}

{% block content %}
<div class="mb-4">
{% blocktrans trimmed with days=validity_days %}
These are pending invitations to join your workspace. Invitations expire after {{ days }} days.
{% endblocktrans %}
</div>
{% block pre-table %}
<temba-modax header="{{ _("Cancel Invitation") |escapejs }}" id="delete-invitation">
</temba-modax>
{% endblock pre-table %}
<div class="mt-4 shadow rounded-lg rounded-bl-none rounded-br-none bg-white">{% include "includes/short_pagination.html" %}</div>
<div class="flex-grow overflow-y-auto shadow">
<table class="list lined scrolled">
<thead>
<tr>
<th>{% trans "Email" %}</th>
<th>{% trans "Role" %}</th>
<th>{% trans "Sent On" %}</th>
<th></th>
</tr>
</thead>
<tbody>
{% for obj in object_list %}
<tr>
<td>{{ obj.email }}</td>
<td>{{ obj.role.display }}</td>
<td>{{ obj.created_on|day }}</td>
<td class="w-10">
<div style="visibility:hidden"
onclick="event.stopPropagation(); showDeleteInvitationModal({{ obj.id }});"
class="pl-2 pt-1 delete-link linked text-gray-400">
<temba-icon name="delete_small">
</temba-icon>
</div>
</td>
</tr>
{% empty %}
<tr class="empty_list">
<td colspan="99" class="text-center">{% trans "No invitations" %}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endblock content %}
{% block extra-script %}
{{ block.super }}
<script>
function showDeleteInvitationModal(id) {
var modax = document.querySelector('#delete-invitation');
modax.endpoint = `/invitation/delete/${id}/`;
modax.open = true;
}
</script>
{% endblock extra-script %}
{% block extra-style %}
{{ block.super }}
<style type="text/css">
tr:hover .delete-link {
visibility: visible !important;
}
</style>
{% endblock extra-style %}

0 comments on commit c8fa8c2

Please sign in to comment.