From c8fa8c2a44f21e0d6f5aac8c18ab6120caf8383c Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Thu, 10 Oct 2024 16:46:17 +0000 Subject: [PATCH] Add new invitations list and delete views --- temba/api/tests.py | 2 +- temba/orgs/models.py | 9 ++- temba/orgs/tests.py | 110 +++++++++++++------------- temba/orgs/views/views.py | 42 +++++++--- temba/settings_common.py | 1 + templates/orgs/invitation_delete.html | 8 ++ templates/orgs/invitation_list.html | 66 ++++++++++++++++ 7 files changed, 168 insertions(+), 70 deletions(-) create mode 100644 templates/orgs/invitation_delete.html create mode 100644 templates/orgs/invitation_list.html diff --git a/temba/api/tests.py b/temba/api/tests.py index bdd7d7c6b4b..2e994791767 100644 --- a/temba/api/tests.py +++ b/temba/api/tests.py @@ -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) diff --git a/temba/orgs/models.py b/temba/orgs/models.py index d371863edab..3627d047305 100644 --- a/temba/orgs/models.py +++ b/temba/orgs/models.py @@ -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) @@ -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): diff --git a/temba/orgs/tests.py b/temba/orgs/tests.py index 35abb19c8f1..c35ff9e8110 100644 --- a/temba/orgs/tests.py +++ b/temba/orgs/tests.py @@ -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="invitededitor@nyaruka.com", - created_by=self.admin, - modified_by=self.admin, - ) + invitation = Invitation.create(self.org, self.admin, "invitededitor@nyaruka.com", OrgRole.EDITOR) self.assertEqual(OrgRole.EDITOR, invitation.role) @@ -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="norkans7@gmail.com", user_group="A", created_by=self.admin, modified_by=self.admin - ) - invitation2 = Invitation.objects.create( - org=self.org, email="bob@tickets.com", user_group="T", created_by=self.admin, modified_by=self.admin - ) + invitation1 = Invitation.create(self.org, self.admin, "norkans7@gmail.com", OrgRole.ADMINISTRATOR) + invitation2 = Invitation.create(self.org, self.admin, "bob@tickets.com", OrgRole.AGENT) # add a second editor to the org editor2 = self.create_user("editor2@nyaruka.com", first_name="Edwina") @@ -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="edwin@nyaruka.com", - created_by=self.admin, - modified_by=self.admin, - ) + invitation = Invitation.create(self.org, self.admin, "edwin@nyaruka.com", OrgRole.EDITOR) join_url = reverse("orgs.org_join", args=[invitation.secret]) join_signup_url = reverse("orgs.org_join_signup", args=[invitation.secret]) @@ -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="eDwin@nyaruka.com", - created_by=self.admin2, - modified_by=self.admin2, - ) + invitation2 = Invitation.create(self.org2, self.admin, "eDwin@nyaruka.com", OrgRole.EDITOR) join_accept_url = reverse("orgs.org_join_accept", args=[invitation2.secret]) join_url = reverse("orgs.org_join", args=[invitation2.secret]) @@ -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="administrator@trileet.com", - created_by=self.admin, - modified_by=self.admin, - ) + invitation = Invitation.create(self.org, self.admin, "administrator@trileet.com", OrgRole.ADMINISTRATOR) join_signup_url = reverse("orgs.org_join_signup", args=[invitation.secret]) join_url = reverse("orgs.org_join", args=[invitation.secret]) @@ -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="edwin@nyaruka.com", - created_by=self.admin, - modified_by=self.admin, - ) + invitation = Invitation.create(self.org, self.admin, "edwin@nyaruka.com", OrgRole.EDITOR) join_signup_url = reverse("orgs.org_join_signup", args=[invitation.secret]) join_url = reverse("orgs.org_join", args=[invitation.secret]) @@ -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="edwin@nyaruka.com", - created_by=self.admin, - modified_by=self.admin, - ) + invitation = Invitation.create(self.org, self.admin, "edwin@nyaruka.com", OrgRole.EDITOR) join_accept_url = reverse("orgs.org_join_accept", args=[invitation.secret]) join_url = reverse("orgs.org_join", args=[invitation.secret]) @@ -3438,13 +3398,7 @@ def test_forget(self): self.assertRedirect(response, forget_url, status_code=301) FailedLogin.objects.create(username="admin@nyaruka.com") - invitation = Invitation.objects.create( - org=self.org, - user_group="A", - email="invited@nyaruka.com", - created_by=self.admin, - modified_by=self.admin, - ) + invitation = Invitation.create(self.org, self.admin, "invited@nyaruka.com", OrgRole.ADMINISTRATOR) # no login required to access response = self.client.get(forget_url) @@ -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, "bob@nyaruka.com", OrgRole.EDITOR) + inv2 = Invitation.create(self.org, self.admin, "jim@nyaruka.com", 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",)) @@ -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, "bob@nyaruka.com", OrgRole.EDITOR) + inv2 = Invitation.create(self.org, self.admin, "jim@nyaruka.com", 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 bob@nyaruka.com. 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): diff --git a/temba/orgs/views/views.py b/temba/orgs/views/views.py index ef544b432e4..baac2c921d2 100644 --- a/temba/orgs/views/views.py +++ b/temba/orgs/views/views.py @@ -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 @@ -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)] @@ -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" @@ -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 diff --git a/temba/settings_common.py b/temba/settings_common.py index 03a2c8519c3..ca629084f9a 100644 --- a/temba/settings_common.py +++ b/temba/settings_common.py @@ -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", diff --git a/templates/orgs/invitation_delete.html b/templates/orgs/invitation_delete.html new file mode 100644 index 00000000000..76fcb211a1b --- /dev/null +++ b/templates/orgs/invitation_delete.html @@ -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 {{ email }}. Are you sure? + {% endblocktrans %} +{% endblock fields %} diff --git a/templates/orgs/invitation_list.html b/templates/orgs/invitation_list.html new file mode 100644 index 00000000000..bb554b4eeea --- /dev/null +++ b/templates/orgs/invitation_list.html @@ -0,0 +1,66 @@ +{% extends "smartmin/list.html" %} +{% load smartmin temba i18n %} + +{% block content %} +
+ {% blocktrans trimmed with days=validity_days %} + These are pending invitations to join your workspace. Invitations expire after {{ days }} days. + {% endblocktrans %} +
+ {% block pre-table %} + + + {% endblock pre-table %} +
{% include "includes/short_pagination.html" %}
+
+ + + + + + + + + + + {% for obj in object_list %} + + + + + + + {% empty %} + + + + {% endfor %} + +
{% trans "Email" %}{% trans "Role" %}{% trans "Sent On" %}
{{ obj.email }}{{ obj.role.display }}{{ obj.created_on|day }} + +
{% trans "No invitations" %}
+
+{% endblock content %} +{% block extra-script %} + {{ block.super }} + +{% endblock extra-script %} +{% block extra-style %} + {{ block.super }} + +{% endblock extra-style %}