Skip to content

Commit

Permalink
Merge pull request #5571 from nyaruka/tokens_as_real_list_page
Browse files Browse the repository at this point in the history
Convert API tokens page to be real list page
  • Loading branch information
rowanseymour authored Oct 23, 2024
2 parents 671a18e + 4613faf commit 24e32d1
Show file tree
Hide file tree
Showing 11 changed files with 136 additions and 146 deletions.
35 changes: 34 additions & 1 deletion temba/api/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,39 @@ def test_record_used(self):


class APITokenCRUDLTest(CRUDLTestMixin, TembaTest):
def test_list(self):
tokens_url = reverse("api.apitoken_list")

self.assertRequestDisallowed(tokens_url, [None, self.user, self.agent])
self.assertListFetch(tokens_url, [self.admin], context_objects=[])

# add user to other org and create API tokens for both
self.org2.add_user(self.admin, OrgRole.EDITOR)
token1 = APIToken.create(self.org, self.admin)
token2 = APIToken.create(self.org, self.admin)
APIToken.create(self.org, self.editor) # other user
APIToken.create(self.org2, self.admin) # other org

response = self.assertListFetch(tokens_url, [self.admin], context_objects=[token1, token2], choose_org=self.org)
self.assertContentMenu(tokens_url, self.admin, ["New"], choose_org=self.org)

# can POST to create new token
response = self.client.post(tokens_url, {})
self.assertRedirect(response, tokens_url)
self.assertEqual(3, self.admin.get_api_tokens(self.org).count())
token3 = self.admin.get_api_tokens(self.org).order_by("created").last()

# and now option to create new token is gone because we've reached the limit
response = self.assertListFetch(
tokens_url, [self.admin], context_objects=[token1, token2, token3], choose_org=self.org
)
self.assertContentMenu(tokens_url, self.admin, [], choose_org=self.org)

# and POSTing is noop
response = self.client.post(tokens_url, {})
self.assertRedirect(response, tokens_url)
self.assertEqual(3, self.admin.get_api_tokens(self.org).count())

def test_delete(self):
token1 = APIToken.create(self.org, self.admin)
token2 = APIToken.create(self.org, self.editor)
Expand All @@ -66,7 +99,7 @@ def test_delete(self):
self.assertContains(response, f"You are about to delete the API token <b>{token1.key[:6]}…</b>")

response = self.assertDeleteSubmit(delete_url, self.admin, object_deactivated=token1)
self.assertRedirect(response, "/user/tokens/")
self.assertRedirect(response, "/apitoken/")

token1.refresh_from_db()
token2.refresh_from_db()
Expand Down
2 changes: 1 addition & 1 deletion temba/api/v2/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -611,7 +611,7 @@ def test_explorer(self):

response = self.client.get(explorer_url)
self.assertContains(response, "To use the explorer you need to first create")
self.assertContains(response, reverse("orgs.user_tokens"))
self.assertContains(response, reverse("api.apitoken_list"))

APIToken.create(self.org, self.admin)

Expand Down
40 changes: 33 additions & 7 deletions temba/api/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,19 @@
import iso8601
from rest_framework import generics, mixins, status
from rest_framework.response import Response
from smartmin.views import SmartCRUDL, SmartDeleteView
from smartmin.views import SmartCRUDL

from django.db import transaction
from django.http import HttpResponseRedirect
from django.urls import reverse
from django.utils.translation import gettext_lazy as _

from temba import mailroom
from temba.api.support import InvalidQueryError
from temba.contacts.models import URN
from temba.orgs.views.mixins import OrgObjPermsMixin
from temba.orgs.views.base import BaseDeleteModal, BaseListView
from temba.utils.models import TembaModel
from temba.utils.views.mixins import ModalFormMixin, NonAtomicMixin
from temba.utils.views.mixins import ContextMenuMixin, NonAtomicMixin

from .models import APIToken, BulkActionFailure

Expand Down Expand Up @@ -278,13 +279,38 @@ def perform_destroy(self, instance):

class APITokenCRUDL(SmartCRUDL):
model = APIToken
actions = ("delete",)
actions = ("list", "delete")

class Delete(ModalFormMixin, OrgObjPermsMixin, SmartDeleteView):
class List(ContextMenuMixin, BaseListView):
title = _("API Tokens")
menu_path = "/settings/account"
paginate_by = None
token_limit = 3

def build_context_menu(self, menu):
if self.request.user.get_api_tokens(self.request.org).count() < self.token_limit:
menu.add_url_post(_("New"), reverse("api.apitoken_list"), as_button=True)

def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context["token_limit"] = self.token_limit
return context

def get_queryset(self, **kwargs):
return self.request.user.get_api_tokens(self.request.org).order_by("created")

def post(self, request, *args, **kwargs):
# there's no create view - just a POST to this view
if self.request.user.get_api_tokens(self.request.org).count() < self.token_limit:
APIToken.create(self.request.org, self.request.user)

return HttpResponseRedirect(reverse("api.apitoken_list"))

class Delete(BaseDeleteModal):
slug_url_kwarg = "key"
fields = ("key",)
cancel_url = "@orgs.user_tokens"
redirect_url = "@orgs.user_tokens"
cancel_url = "@api.apitoken_list"
redirect_url = "@api.apitoken_list"
submit_button_name = _("Delete")

def has_permission(self, request, *args, **kwargs):
Expand Down
33 changes: 0 additions & 33 deletions temba/orgs/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -3237,39 +3237,6 @@ def test_recover(self):
self.assertEqual(0, FailedLogin.objects.filter(username="[email protected]").count()) # deleted
self.assertEqual(1, FailedLogin.objects.filter(username="[email protected]").count()) # unaffected

def test_tokens(self):
tokens_url = reverse("orgs.user_tokens")

self.assertRequestDisallowed(tokens_url, [None, self.user, self.agent])
self.assertReadFetch(tokens_url, [self.admin], context_object=self.admin)

# add user to other org and create API tokens for both
self.org2.add_user(self.admin, OrgRole.EDITOR)
token1 = APIToken.create(self.org, self.admin)
token2 = APIToken.create(self.org, self.admin)
APIToken.create(self.org, self.editor) # other user
APIToken.create(self.org2, self.admin) # other org

response = self.assertReadFetch(tokens_url, [self.admin], context_object=self.admin, choose_org=self.org)
self.assertEqual([token1, token2], list(response.context["tokens"]))
self.assertContentMenu(tokens_url, self.admin, ["New Token"], choose_org=self.org)

# can POST to create new token
response = self.client.post(tokens_url, {"new": "1"})
self.assertRedirect(response, reverse("orgs.user_tokens"))
self.assertEqual(3, self.admin.get_api_tokens(self.org).count())
token3 = self.admin.get_api_tokens(self.org).order_by("created").last()

# and now option to create new token is gone because we've reached the limit
response = self.assertReadFetch(tokens_url, [self.admin], context_object=self.admin, choose_org=self.org)
self.assertEqual([token1, token2, token3], list(response.context["tokens"]))
self.assertContentMenu(tokens_url, self.admin, [], choose_org=self.org)

# and POSTing is noop
response = self.client.post(tokens_url, {"new": "1"})
self.assertRedirect(response, reverse("orgs.user_tokens"))
self.assertEqual(3, self.admin.get_api_tokens(self.org).count())

def test_verify_email(self):
self.assertEqual(self.admin.settings.email_status, "U")
self.assertTrue(self.admin.settings.email_verification_secret)
Expand Down
35 changes: 2 additions & 33 deletions temba/orgs/views/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@
from django.utils.functional import cached_property
from django.utils.translation import gettext_lazy as _

from temba.api.models import APIToken, Resthook
from temba.api.models import Resthook
from temba.campaigns.models import Campaign
from temba.flows.models import Flow
from temba.formax import FormaxMixin
Expand Down Expand Up @@ -346,12 +346,11 @@ class UserCRUDL(SmartCRUDL):
"two_factor_disable",
"two_factor_tokens",
"account",
"tokens",
"verify_email",
"send_verification_email",
)

class List(RequireFeatureMixin, ContextMenuMixin, BaseListView):
class List(RequireFeatureMixin, BaseListView):
require_feature = Org.FEATURE_USERS
title = _("Users")
menu_path = "/settings/users"
Expand Down Expand Up @@ -883,36 +882,6 @@ def get_context_data(self, **kwargs):
def derive_formax_sections(self, formax, context):
formax.add_section("profile", reverse("orgs.user_edit"), icon="user")

class Tokens(SpaMixin, InferUserMixin, ContextMenuMixin, OrgPermsMixin, SmartUpdateView):
class Form(forms.ModelForm):
new = forms.BooleanField(required=False)

class Meta:
model = User
fields = ()

form_class = Form
title = _("API Tokens")
menu_path = "/settings/account"
success_url = "@orgs.user_tokens"
token_limit = 3

def build_context_menu(self, menu):
if self.request.user.get_api_tokens(self.request.org).count() < self.token_limit:
menu.add_url_post(_("New Token"), reverse("orgs.user_tokens") + "?new=1", as_button=True)

def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context["tokens"] = self.request.user.get_api_tokens(self.request.org).order_by("created")
context["token_limit"] = self.token_limit
return context

def form_valid(self, form):
if self.request.user.get_api_tokens(self.request.org).count() < self.token_limit:
APIToken.create(self.request.org, self.request.user)

return super().form_valid(form)


class InvitationMixin:
@cached_property
Expand Down
5 changes: 2 additions & 3 deletions temba/settings_common.py
Original file line number Diff line number Diff line change
Expand Up @@ -396,7 +396,6 @@
"twilio_connect",
"workspace",
),
"orgs.user": ("tokens",),
"request_logs.httplog": ("webhooks", "classifier"),
"tickets.ticket": ("assign", "assignee", "menu", "note", "export_stats", "export"),
"triggers.trigger": ("archived", "type", "menu"),
Expand All @@ -415,6 +414,7 @@
"airtime.airtimetransfer_list",
"airtime.airtimetransfer_read",
"api.apitoken_explorer",
"api.apitoken_list",
"api.resthook_list",
"api.resthooksubscriber_create",
"api.resthooksubscriber_delete",
Expand Down Expand Up @@ -500,7 +500,6 @@
"orgs.org_workspace",
"orgs.orgimport.*",
"orgs.user_list",
"orgs.user_tokens",
"orgs.user_update",
"request_logs.httplog_list",
"request_logs.httplog_read",
Expand All @@ -515,6 +514,7 @@
"airtime.airtimetransfer_list",
"airtime.airtimetransfer_read",
"api.apitoken_explorer",
"api.apitoken_list",
"api.resthook_list",
"api.resthooksubscriber_create",
"api.resthooksubscriber_delete",
Expand Down Expand Up @@ -583,7 +583,6 @@
"orgs.org_resthooks",
"orgs.org_workspace",
"orgs.orgimport.*",
"orgs.user_tokens",
"request_logs.httplog_webhooks",
"templates.template_list",
"templates.template_read",
Expand Down
58 changes: 58 additions & 0 deletions templates/api/apitoken_list.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
{% extends "orgs/base/list.html" %}
{% load i18n temba %}

{% block modaxes %}
<temba-modax header="{{ _("Delete Token") |escapejs }}" id="delete-token">
</temba-modax>
{% endblock modaxes %}
{% block pre-table %}
<div class="mb-4">
{% url "api.v2.root" as api_url %}
{% blocktrans trimmed with api_url=api_url limit=token_limit %}
These are your personal tokens for accessing the <a href="{{ api_url }}">API</a>. You can have a maximum of {{ limit }}.
{% endblocktrans %}
</div>
{% endblock pre-table %}
{% block table %}
<table class="list lined scrolled">
<tr>
<th>{% trans "Key" %}</th>
<th>{% trans "Last Used" %}</th>
<th></th>
</tr>
{% for obj in object_list %}
<tr>
<td>{{ obj.key }}</td>
<td>
{% if obj.last_used_on %}
{{ obj.last_used_on|duration }}
{% else %}
--
{% endif %}
</td>
<td class="w-10">
<div style="visibility:hidden"
onclick="event.stopPropagation(); showDeleteTokenModal('{{ obj.key }}');"
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 tokens" %}</td>
</tr>
{% endfor %}
</table>
{% endblock table %}
{% block extra-script %}
{{ block.super }}
<script>
function showDeleteTokenModal(key) {
var modax = document.querySelector('#delete-token');
modax.endpoint = `/apitoken/delete/${key}/`;
modax.open = true;
}
</script>
{% endblock extra-script %}
2 changes: 1 addition & 1 deletion templates/api/v2/explorer.html
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
All operations work against real data in the <b>{{ org }}</b> workspace.
{% endblocktrans %}
{% else %}
{% url "orgs.user_tokens" as tokens_url %}
{% url "api.apitoken_list" as tokens_url %}
{% blocktrans trimmed with org=user_org.name %}
To use the explorer you need to first create <a href="{{ tokens_url }}">an API token</a>.
{% endblocktrans %}
Expand Down
4 changes: 3 additions & 1 deletion templates/orgs/base/list.html
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,9 @@
</form>
{% endblock search-form %}
{% endif %}
<div class="shadow rounded-lg rounded-bl-none rounded-br-none bg-white">{% include "includes/short_pagination.html" %}</div>
{% if view.paginate_by %}
<div class="shadow rounded-lg rounded-bl-none rounded-br-none bg-white">{% include "includes/short_pagination.html" %}</div>
{% endif %}
<div class="flex-grow overflow-y-auto shadow">
{% block table %}
{% endblock table %}
Expand Down
4 changes: 2 additions & 2 deletions templates/orgs/user_account.html
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,10 @@
</div>
</div>
</div>
{% if org_perms.orgs.user_tokens %}
{% if org_perms.api.apitoken_list %}
<div class="flex flex-row bg-gray-200 border border-gray-400 rounded-lg cursor-pointer items-center mb-4"
onclick="goto(event, this)"
href="{% url 'orgs.user_tokens' %}">
href="{% url 'api.apitoken_list' %}">
<div class="m-8 text-4xl text-gray-600 items-center">
<temba-icon name="user_token">
</temba-icon>
Expand Down
Loading

0 comments on commit 24e32d1

Please sign in to comment.