Переглянути джерело

Closes #13228: Move token management views to primary UI

Arthur Hanson 2 роки тому
батько
коміт
7600d7b344

+ 2 - 1
netbox/netbox/navigation/menu.py

@@ -353,7 +353,7 @@ ADMIN_MENU = Menu(
     icon_class='mdi mdi-account-multiple',
     groups=(
         MenuGroup(
-            label=_('Users'),
+            label=_('Authentication'),
             items=(
                 # Proxy model for auth.User
                 MenuItem(
@@ -399,6 +399,7 @@ ADMIN_MENU = Menu(
                         )
                     )
                 ),
+                get_model_item('users', 'token', _('API Tokens')),
                 get_model_item('users', 'objectpermission', _('Permissions'), actions=['add']),
             ),
         ),

+ 1 - 0
netbox/netbox/settings.py

@@ -469,6 +469,7 @@ EXEMPT_EXCLUDE_MODELS = (
     ('auth', 'group'),
     ('auth', 'user'),
     ('users', 'objectpermission'),
+    ('users', 'token'),
 )
 
 # All URLs starting with a string listed here are exempt from login enforcement

+ 1 - 1
netbox/templates/inc/profile_button.html

@@ -34,7 +34,7 @@
         </a>
       </li>
       <li>
-        <a class="dropdown-item" href="{% url 'users:token_list' %}">
+        <a class="dropdown-item" href="{% url 'users:usertoken_list' %}">
           <i class="mdi mdi-key"></i> API Tokens
         </a>
       </li>

+ 0 - 58
netbox/templates/users/account/api_token.html

@@ -1,58 +0,0 @@
-{% extends 'generic/object.html' %}
-{% load form_helpers %}
-{% load helpers %}
-{% load plugins %}
-
-{% block content %}
-  <div class="row">
-    <div class="col col-md-12">
-      {% if not settings.ALLOW_TOKEN_RETRIEVAL %}
-        <div class="alert alert-danger" role="alert">
-          <i class="mdi mdi-alert"></i> Tokens cannot be retrieved at a later time. You must <a href="#" class="copy-content" data-clipboard-target="#token_id" title="Copy to clipboard">copy the token value</a> below and store it securely.
-        </div>
-      {% endif %}
-      <div class="card">
-        <h5 class="card-header">Token</h5>
-        <div class="card-body">
-          <table class="table table-hover attr-table">
-            <tr>
-              <th scope="row">Key</th>
-              <td>
-                <div class="float-end">
-                  {% copy_content "token_id" %}
-                </div>
-                <div id="token_id">{{ key }}</div>
-              </td>
-            </tr>
-            <tr>
-              <th scope="row">Description</th>
-              <td>{{ object.description|placeholder }}</td>
-            </tr>
-            <tr>
-              <th scope="row">User</th>
-              <td>{{ object.user }}</td>
-            </tr>
-            <tr>
-              <th scope="row">Created</th>
-              <td>{{ object.created|annotated_date }}</td>
-            </tr>
-            <tr>
-              <th scope="row">Expires</th>
-              <td>
-                {% if object.expires %}
-                  {{ object.expires|annotated_date }}
-                {% else %}
-                  <span>Never</span>
-                {% endif %}
-              </td>
-            </tr>
-          </table>
-        </div>
-      </div>
-      <div class="col col-md-12 text-center">
-        <a href="{% url 'users:token_add' %}" class="btn btn-outline-primary">Add Another</a>
-        <a href="{{ return_url }}" class="btn btn-outline-danger">Cancel</a>
-      </div>
-    </div>
-  </div>
-{% endblock %}

+ 1 - 1
netbox/templates/users/account/base.html

@@ -18,7 +18,7 @@
       </li>
     {% endif %}
     <li role="presentation" class="nav-item">
-      <a class="nav-link{% if active_tab == 'api-tokens' %} active{% endif %}" href="{% url 'users:token_list' %}">{% trans "API Tokens" %}</a>
+      <a class="nav-link{% if active_tab == 'api-tokens' %} active{% endif %}" href="{% url 'users:usertoken_list' %}">{% trans "API Tokens" %}</a>
     </li>
   </ul>
 {% endblock %}

+ 69 - 0
netbox/templates/users/account/token.html

@@ -0,0 +1,69 @@
+{% extends 'generic/object.html' %}
+{% load form_helpers %}
+{% load helpers %}
+{% load i18n %}
+{% load plugins %}
+
+{% block breadcrumbs %}
+  <li class="breadcrumb-item"><a href="{% url 'users:usertoken_list' %}">{% trans "My API Tokens" %}</a></li>
+{% endblock breadcrumbs %}
+
+{% block title %}{% trans "Token" %} {{ object }}{% endblock %}
+
+{% block subtitle %}{% endblock %}
+
+{% block content %}
+  <div class="row">
+    <div class="col col-md-12">
+      {% if key and not settings.ALLOW_TOKEN_RETRIEVAL %}
+        <div class="alert alert-danger" role="alert">
+          <i class="mdi mdi-alert"></i> Tokens cannot be retrieved at a later time. You must <a href="#" class="copy-content" data-clipboard-target="#token_id" title="Copy to clipboard">copy the token value</a> below and store it securely.
+        </div>
+      {% endif %}
+      <div class="card">
+        <h5 class="card-header">{% trans "Token" %}</h5>
+        <div class="card-body">
+          <table class="table table-hover attr-table">
+            <tr>
+              <th scope="row">{% trans "Key" %}</th>
+              <td>
+                {% if key %}
+                  <div class="float-end">
+                    {% copy_content "token_id" %}
+                  </div>
+                  <div id="token_id">{{ key }}</div>
+                {% else %}
+                  {{ object.partial }}
+                {% endif %}
+              </td>
+            </tr>
+            <tr>
+              <th scope="row">{% trans "Description" %}</th>
+              <td>{{ object.description|placeholder }}</td>
+            </tr>
+            <tr>
+              <th scope="row">{% trans "Write enabled" %}</th>
+              <td>{% checkmark object.write_enabled %}</td>
+            </tr>
+            <tr>
+              <th scope="row">{% trans "Created" %}</th>
+              <td>{{ object.created|annotated_date }}</td>
+            </tr>
+            <tr>
+              <th scope="row">{% trans "Expires" %}</th>
+              <td>{{ object.expires|placeholder }}</td>
+            </tr>
+            <tr>
+              <th scope="row">{% trans "Last used" %}</th>
+              <td>{{ object.last_used|placeholder }}</td>
+            </tr>
+            <tr>
+              <th scope="row">{% trans "Allowed IPs" %}</th>
+              <td>{{ object.allowed_ips|join:", "|placeholder }}</td>
+            </tr>
+          </table>
+        </div>
+      </div>
+    </div>
+  </div>
+{% endblock %}

+ 2 - 2
netbox/templates/users/account/api_tokens.html → netbox/templates/users/account/token_list.html

@@ -2,12 +2,12 @@
 {% load helpers %}
 {% load render_table from django_tables2 %}
 
-{% block title %}API Tokens{% endblock %}
+{% block title %}My API Tokens{% endblock %}
 
 {% block content %}
 <div class="row">
 	<div class="col col-md-12 text-end">
-    <a href="{% url 'users:token_add' %}" class="btn btn-sm btn-primary my-3">
+    <a href="{% url 'users:usertoken_add' %}" class="btn btn-sm btn-primary my-3">
       <span class="mdi mdi-plus-thick" aria-hidden="true"></span> Add a Token
     </a>
   </div>

+ 56 - 0
netbox/templates/users/token.html

@@ -0,0 +1,56 @@
+{% extends 'generic/object.html' %}
+{% load i18n %}
+{% load helpers %}
+{% load render_table from django_tables2 %}
+
+{% block title %}{% trans "Token" %} {{ object }}{% endblock %}
+
+{% block subtitle %}{% endblock %}
+
+{% block content %}
+  <div class="row mb-3">
+    <div class="col-md-6">
+      <div class="card">
+        <h5 class="card-header">{% trans "Token" %}</h5>
+        <div class="card-body">
+          <table class="table table-hover attr-table">
+            <tr>
+              <th scope="row">{% trans "Key" %}</th>
+              <td>{% if settings.ALLOW_TOKEN_RETRIEVAL %}{{ object.key }}{% else %}{{ object.partial }}{% endif %}</td>
+            </tr>
+            <tr>
+              <th scope="row">{% trans "User" %}</th>
+              <td>
+                <a href="{% url 'users:netboxuser' pk=object.user.pk %}">{{ object.user }}</a>
+              </td>
+            </tr>
+            <tr>
+              <th scope="row">{% trans "Description" %}</th>
+              <td>{{ object.description|placeholder }}</td>
+            </tr>
+            <tr>
+              <th scope="row">{% trans "Write enabled" %}</th>
+              <td>{% checkmark object.write_enabled %}</td>
+            </tr>
+            <tr>
+              <th scope="row">{% trans "Created" %}</th>
+              <td>{{ object.created|annotated_date }}</td>
+            </tr>
+            <tr>
+              <th scope="row">{% trans "Expires" %}</th>
+              <td>{{ object.expires|placeholder }}</td>
+            </tr>
+            <tr>
+              <th scope="row">{% trans "Last used" %}</th>
+              <td>{{ object.last_used|placeholder }}</td>
+            </tr>
+            <tr>
+              <th scope="row">{% trans "Allowed IPs" %}</th>
+              <td>{{ object.allowed_ips|join:", "|placeholder }}</td>
+            </tr>
+          </table>
+        </div>
+      </div>
+    </div>
+  </div>
+{% endblock %}

+ 0 - 21
netbox/users/admin/__init__.py

@@ -1,11 +1,6 @@
 from django.contrib import admin
-from django.contrib.auth.admin import UserAdmin as UserAdmin_
 from django.contrib.auth.models import Group, User
 
-from users.models import ObjectPermission, Token
-from . import filters, forms, inlines
-
-
 #
 # Users & groups
 #
@@ -13,19 +8,3 @@ from . import filters, forms, inlines
 # Unregister the built-in GroupAdmin and UserAdmin classes so that we can use our custom admin classes below
 admin.site.unregister(Group)
 admin.site.unregister(User)
-
-
-#
-# REST API tokens
-#
-
-@admin.register(Token)
-class TokenAdmin(admin.ModelAdmin):
-    form = forms.TokenAdminForm
-    list_display = [
-        'key', 'user', 'created', 'expires', 'last_used', 'write_enabled', 'description', 'list_allowed_ips'
-    ]
-
-    def list_allowed_ips(self, obj):
-        return obj.allowed_ips or 'Any'
-    list_allowed_ips.short_description = "Allowed IPs"

+ 0 - 21
netbox/users/admin/forms.py

@@ -1,21 +0,0 @@
-from django import forms
-from django.utils.translation import gettext as _
-
-from users.models import Token
-
-__all__ = (
-    'TokenAdminForm',
-)
-
-
-class TokenAdminForm(forms.ModelForm):
-    key = forms.CharField(
-        required=False,
-        help_text=_("If no key is provided, one will be generated automatically.")
-    )
-
-    class Meta:
-        fields = [
-            'user', 'key', 'write_enabled', 'expires', 'description', 'allowed_ips'
-        ]
-        model = Token

+ 1 - 0
netbox/users/filtersets.py

@@ -10,6 +10,7 @@ from users.models import ObjectPermission, Token
 __all__ = (
     'GroupFilterSet',
     'ObjectPermissionFilterSet',
+    'TokenFilterSet',
     'UserFilterSet',
 )
 

+ 41 - 2
netbox/users/forms/bulk_edit.py

@@ -1,13 +1,17 @@
 from django import forms
+from django.contrib.postgres.forms import SimpleArrayField
 from django.utils.translation import gettext_lazy as _
 
+from ipam.formfields import IPNetworkFormField
+from ipam.validators import prefix_validator
 from users.models import *
-from utilities.forms import BootstrapMixin
-from utilities.forms.widgets import BulkEditNullBooleanSelect
+from utilities.forms import BootstrapMixin, BulkEditForm
+from utilities.forms.widgets import BulkEditNullBooleanSelect, DateTimePicker
 
 __all__ = (
     'ObjectPermissionBulkEditForm',
     'UserBulkEditForm',
+    'TokenBulkEditForm',
 )
 
 
@@ -70,3 +74,38 @@ class ObjectPermissionBulkEditForm(BootstrapMixin, forms.Form):
         (None, ('enabled', 'description')),
     )
     nullable_fields = ('description',)
+
+
+class TokenBulkEditForm(BulkEditForm):
+    pk = forms.ModelMultipleChoiceField(
+        queryset=Token.objects.all(),
+        widget=forms.MultipleHiddenInput
+    )
+    write_enabled = forms.NullBooleanField(
+        required=False,
+        widget=BulkEditNullBooleanSelect,
+        label=_('Write enabled')
+    )
+    description = forms.CharField(
+        max_length=200,
+        required=False,
+        label=_('Description')
+    )
+    expires = forms.DateTimeField(
+        required=False,
+        widget=DateTimePicker(),
+        label=_('Expires')
+    )
+    allowed_ips = SimpleArrayField(
+        base_field=IPNetworkFormField(validators=[prefix_validator]),
+        required=False,
+        label=_('Allowed IPs')
+    )
+
+    model = Token
+    fieldsets = (
+        (None, ('write_enabled', 'description', 'expires', 'allowed_ips')),
+    )
+    nullable_fields = (
+        'expires', 'description', 'allowed_ips',
+    )

+ 17 - 1
netbox/users/forms/bulk_import.py

@@ -1,9 +1,13 @@
-from users.models import NetBoxGroup, NetBoxUser
+from django import forms
+from django.utils.translation import gettext as _
+from users.models import *
 from utilities.forms import CSVModelForm
 
+
 __all__ = (
     'GroupImportForm',
     'UserImportForm',
+    'TokenImportForm',
 )
 
 
@@ -30,3 +34,15 @@ class UserImportForm(CSVModelForm):
         self.instance.set_password(self.cleaned_data.get('password'))
 
         return super().save(*args, **kwargs)
+
+
+class TokenImportForm(CSVModelForm):
+    key = forms.CharField(
+        label=_('Key'),
+        required=False,
+        help_text=_("If no key is provided, one will be generated automatically.")
+    )
+
+    class Meta:
+        model = Token
+        fields = ('user', 'key', 'write_enabled', 'expires', 'description',)

+ 35 - 0
netbox/users/forms/filtersets.py

@@ -1,4 +1,7 @@
 from django import forms
+from extras.forms.mixins import SavedFiltersMixin
+from utilities.forms import FilterForm
+from users.models import Token
 from django.contrib.auth import get_user_model
 from django.contrib.auth.models import Group
 from django.utils.translation import gettext_lazy as _
@@ -7,11 +10,13 @@ from netbox.forms import NetBoxModelFilterSetForm
 from users.models import NetBoxGroup, NetBoxUser, ObjectPermission
 from utilities.forms import BOOLEAN_WITH_BLANK_CHOICES
 from utilities.forms.fields import DynamicModelMultipleChoiceField
+from utilities.forms.widgets import DateTimePicker
 
 __all__ = (
     'GroupFilterForm',
     'ObjectPermissionFilterForm',
     'UserFilterForm',
+    'TokenFilterForm',
 )
 
 
@@ -109,3 +114,33 @@ class ObjectPermissionFilterForm(NetBoxModelFilterSetForm):
         ),
         label=_('Can Delete'),
     )
+
+
+class TokenFilterForm(SavedFiltersMixin, FilterForm):
+    model = Token
+    fieldsets = (
+        (None, ('q', 'filter_id',)),
+        (_('Token'), ('user_id', 'write_enabled', 'expires', 'last_used')),
+    )
+    user_id = DynamicModelMultipleChoiceField(
+        queryset=get_user_model().objects.all(),
+        required=False,
+        label=_('User')
+    )
+    write_enabled = forms.NullBooleanField(
+        required=False,
+        widget=forms.Select(
+            choices=BOOLEAN_WITH_BLANK_CHOICES
+        ),
+        label=_('Write Enabled'),
+    )
+    expires = forms.DateTimeField(
+        required=False,
+        label=_('Expires'),
+        widget=DateTimePicker()
+    )
+    last_used = forms.DateTimeField(
+        required=False,
+        label=_('Last Used'),
+        widget=DateTimePicker()
+    )

+ 25 - 3
netbox/users/forms/model_forms.py

@@ -20,11 +20,13 @@ from utilities.permissions import qs_filter_from_constraints
 from utilities.utils import flatten_dict
 
 __all__ = (
+    'UserTokenForm',
     'GroupForm',
     'ObjectPermissionForm',
     'TokenForm',
     'UserConfigForm',
     'UserForm',
+    'TokenForm',
 )
 
 
@@ -107,7 +109,7 @@ class UserConfigForm(BootstrapMixin, forms.ModelForm, metaclass=UserConfigFormMe
         ]
 
 
-class TokenForm(BootstrapMixin, forms.ModelForm):
+class UserTokenForm(BootstrapMixin, forms.ModelForm):
     key = forms.CharField(
         label=_('Key'),
         required=False,
@@ -117,8 +119,10 @@ class TokenForm(BootstrapMixin, forms.ModelForm):
         base_field=IPNetworkFormField(validators=[prefix_validator]),
         required=False,
         label=_('Allowed IPs'),
-        help_text=_('Allowed IPv4/IPv6 networks from where the token can be used. Leave blank for no restrictions. '
-                    'Example: <code>10.1.1.0/24,192.168.10.16/32,2001:db8:1::/64</code>'),
+        help_text=_(
+            'Allowed IPv4/IPv6 networks from where the token can be used. Leave blank for no restrictions. '
+            'Example: <code>10.1.1.0/24,192.168.10.16/32,2001:db8:1::/64</code>'
+        ),
     )
 
     class Meta:
@@ -138,6 +142,24 @@ class TokenForm(BootstrapMixin, forms.ModelForm):
             del self.fields['key']
 
 
+class TokenForm(UserTokenForm):
+    user = forms.ModelChoiceField(
+        queryset=get_user_model().objects.order_by(
+            'username'
+        ),
+        required=False
+    )
+
+    class Meta:
+        model = Token
+        fields = [
+            'user', 'key', 'write_enabled', 'expires', 'description', 'allowed_ips',
+        ]
+        widgets = {
+            'expires': DateTimePicker(),
+        }
+
+
 class UserForm(BootstrapMixin, forms.ModelForm):
     password = forms.CharField(
         label=_('Password'),

+ 25 - 0
netbox/users/migrations/0005_usertoken.py

@@ -0,0 +1,25 @@
+# Generated by Django 4.1.10 on 2023-07-25 15:19
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('users', '0004_netboxgroup_netboxuser'),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='UserToken',
+            fields=[
+            ],
+            options={
+                'proxy': True,
+                'indexes': [],
+                'constraints': [],
+                'verbose_name': 'token',
+            },
+            bases=('users.token',),
+        ),
+    ]

+ 22 - 2
netbox/users/models.py

@@ -26,6 +26,7 @@ __all__ = (
     'ObjectPermission',
     'Token',
     'UserConfig',
+    'UserToken',
 )
 
 
@@ -273,13 +274,20 @@ class Token(models.Model):
         blank=True,
         null=True,
         verbose_name='Allowed IPs',
-        help_text=_('Allowed IPv4/IPv6 networks from where the token can be used. Leave blank for no restrictions. '
-                    'Ex: "10.1.1.0/24, 192.168.10.16/32, 2001:DB8:1::/64"'),
+        help_text=_(
+            'Allowed IPv4/IPv6 networks from where the token can be used. Leave blank for no restrictions. '
+            'Ex: "10.1.1.0/24, 192.168.10.16/32, 2001:DB8:1::/64"'
+        ),
     )
 
+    objects = RestrictedQuerySet.as_manager()
+
     def __str__(self):
         return self.key if settings.ALLOW_TOKEN_RETRIEVAL else self.partial
 
+    def get_absolute_url(self):
+        return reverse('users:token', args=[self.pk])
+
     @property
     def partial(self):
         return f'**********************************{self.key[-6:]}' if self.key else ''
@@ -314,6 +322,18 @@ class Token(models.Model):
         return False
 
 
+class UserToken(Token):
+    """
+    Proxy model for users to manage their own API tokens.
+    """
+    class Meta:
+        proxy = True
+        verbose_name = 'token'
+
+    def get_absolute_url(self):
+        return reverse('users:usertoken', args=[self.pk])
+
+
 #
 # Permissions
 #

+ 36 - 9
netbox/users/tables.py

@@ -1,8 +1,8 @@
 import django_tables2 as tables
+from django.utils.translation import gettext as _
 
 from netbox.tables import NetBoxTable, columns
-from users.models import NetBoxGroup, NetBoxUser, ObjectPermission
-from .models import Token
+from users.models import NetBoxGroup, NetBoxUser, ObjectPermission, Token, UserToken
 
 __all__ = (
     'GroupTable',
@@ -31,17 +31,28 @@ class TokenActionsColumn(columns.ActionsColumn):
     }
 
 
-class TokenTable(NetBoxTable):
+class UserTokenTable(NetBoxTable):
+    """
+    Table for users to manager their own API tokens under account views.
+    """
     key = columns.TemplateColumn(
-        template_code=TOKEN
+        verbose_name=_('Key'),
+        template_code=TOKEN,
     )
     write_enabled = columns.BooleanColumn(
-        verbose_name='Write'
+        verbose_name=_('Write Enabled')
+    )
+    created = columns.DateColumn(
+        verbose_name=_('Created'),
+    )
+    expires = columns.DateColumn(
+        verbose_name=_('Expires'),
+    )
+    last_used = columns.DateTimeColumn(
+        verbose_name=_('Last Used'),
     )
-    created = columns.DateColumn()
-    expired = columns.DateColumn()
-    last_used = columns.DateTimeColumn()
     allowed_ips = columns.TemplateColumn(
+        verbose_name=_('Allowed IPs'),
         template_code=ALLOWED_IPS
     )
     actions = TokenActionsColumn(
@@ -49,10 +60,26 @@ class TokenTable(NetBoxTable):
         extra_buttons=COPY_BUTTON
     )
 
+    class Meta(NetBoxTable.Meta):
+        model = UserToken
+        fields = (
+            'pk', 'id', 'key', 'description', 'write_enabled', 'created', 'expires', 'last_used', 'allowed_ips',
+        )
+
+
+class TokenTable(UserTokenTable):
+    """
+    General-purpose table for API token management.
+    """
+    user = tables.Column(
+        linkify=True,
+        verbose_name=_('User')
+    )
+
     class Meta(NetBoxTable.Meta):
         model = Token
         fields = (
-            'pk', 'description', 'key', 'write_enabled', 'created', 'expires', 'last_used', 'allowed_ips',
+            'pk', 'id', 'key', 'user', 'description', 'write_enabled', 'created', 'expires', 'last_used', 'allowed_ips',
         )
 
 

+ 51 - 1
netbox/users/tests/test_views.py

@@ -2,7 +2,7 @@ from django.contrib.auth.models import Group
 from django.contrib.contenttypes.models import ContentType
 
 from users.models import *
-from utilities.testing import ViewTestCases
+from utilities.testing import ViewTestCases, create_test_user
 
 
 class UserTestCase(
@@ -149,3 +149,53 @@ class ObjectPermissionTestCase(
         cls.bulk_edit_data = {
             'description': 'New description',
         }
+
+
+class TokenTestCase(
+    ViewTestCases.GetObjectViewTestCase,
+    ViewTestCases.CreateObjectViewTestCase,
+    ViewTestCases.EditObjectViewTestCase,
+    ViewTestCases.DeleteObjectViewTestCase,
+    ViewTestCases.ListObjectsViewTestCase,
+    ViewTestCases.BulkImportObjectsViewTestCase,
+    ViewTestCases.BulkEditObjectsViewTestCase,
+    ViewTestCases.BulkDeleteObjectsViewTestCase,
+):
+    model = Token
+    maxDiff = None
+
+    @classmethod
+    def setUpTestData(cls):
+        users = (
+            create_test_user('User 1'),
+            create_test_user('User 2'),
+        )
+        tokens = (
+            Token(key='123456790123456789012345678901234567890A', user=users[0]),
+            Token(key='123456790123456789012345678901234567890B', user=users[0]),
+            Token(key='123456790123456789012345678901234567890C', user=users[1]),
+        )
+        Token.objects.bulk_create(tokens)
+
+        cls.form_data = {
+            'user': users[0].pk,
+            'description': 'testdescription',
+        }
+
+        cls.csv_data = (
+            "key,user,description",
+            f"123456790123456789012345678901234567890D,{users[0].pk},testdescriptionD",
+            f"123456790123456789012345678901234567890E,{users[1].pk},testdescriptionE",
+            f"123456790123456789012345678901234567890F,{users[1].pk},testdescriptionF",
+        )
+
+        cls.csv_update_data = (
+            "id,description",
+            f"{tokens[0].pk},testdescriptionH",
+            f"{tokens[1].pk},testdescriptionI",
+            f"{tokens[2].pk},testdescriptionJ",
+        )
+
+        cls.bulk_edit_data = {
+            'description': 'newdescription',
+        }

+ 11 - 3
netbox/users/urls.py

@@ -11,9 +11,17 @@ urlpatterns = [
     path('bookmarks/', views.BookmarkListView.as_view(), name='bookmarks'),
     path('preferences/', views.UserConfigView.as_view(), name='preferences'),
     path('password/', views.ChangePasswordView.as_view(), name='change_password'),
-    path('api-tokens/', views.TokenListView.as_view(), name='token_list'),
-    path('api-tokens/add/', views.TokenEditView.as_view(), name='token_add'),
-    path('api-tokens/<int:pk>/', include(get_model_urls('users', 'token'))),
+    path('api-tokens/', views.UserTokenListView.as_view(), name='usertoken_list'),
+    path('api-tokens/add/', views.UserTokenEditView.as_view(), name='usertoken_add'),
+    path('api-tokens/<int:pk>/', include(get_model_urls('users', 'usertoken'))),
+
+    # Tokens
+    path('tokens/', views.TokenListView.as_view(), name='token_list'),
+    path('tokens/add/', views.TokenEditView.as_view(), name='token_add'),
+    path('tokens/import/', views.TokenBulkImportView.as_view(), name='token_import'),
+    path('tokens/edit/', views.TokenBulkEditView.as_view(), name='token_bulk_edit'),
+    path('tokens/delete/', views.TokenBulkDeleteView.as_view(), name='token_bulk_delete'),
+    path('tokens/<int:pk>/', include(get_model_urls('users', 'token'))),
 
     # Users
     path('users/', views.UserListView.as_view(), name='netboxuser_list'),

+ 86 - 41
netbox/users/views.py

@@ -24,7 +24,7 @@ from netbox.views import generic
 from utilities.forms import ConfirmationForm
 from utilities.views import register_model_view
 from . import filtersets, forms, tables
-from .models import Token, UserConfig, NetBoxGroup, NetBoxUser, ObjectPermission
+from .models import NetBoxGroup, NetBoxUser, ObjectPermission, Token, UserConfig, UserToken
 
 
 #
@@ -249,53 +249,61 @@ class BookmarkListView(LoginRequiredMixin, generic.ObjectListView):
 
 
 #
-# API tokens
+# User views for token management
 #
 
-class TokenListView(LoginRequiredMixin, View):
+class UserTokenListView(LoginRequiredMixin, View):
 
     def get(self, request):
-
-        tokens = Token.objects.filter(user=request.user)
-        table = tables.TokenTable(tokens)
+        tokens = UserToken.objects.filter(user=request.user)
+        table = tables.UserTokenTable(tokens)
         table.configure(request)
 
-        return render(request, 'users/account/api_tokens.html', {
+        return render(request, 'users/account/token_list.html', {
             'tokens': tokens,
             'active_tab': 'api-tokens',
             'table': table,
         })
 
 
-@register_model_view(Token, 'edit')
-class TokenEditView(LoginRequiredMixin, View):
+@register_model_view(UserToken)
+class UserTokenView(LoginRequiredMixin, View):
 
-    def get(self, request, pk=None):
+    def get(self, request, pk):
+        token = get_object_or_404(UserToken.objects.filter(user=request.user), pk=pk)
+        key = token.key if settings.ALLOW_TOKEN_RETRIEVAL else None
 
+        return render(request, 'users/account/token.html', {
+            'object': token,
+            'key': key,
+        })
+
+
+@register_model_view(UserToken, 'edit')
+class UserTokenEditView(LoginRequiredMixin, View):
+
+    def get(self, request, pk=None):
         if pk:
-            token = get_object_or_404(Token.objects.filter(user=request.user), pk=pk)
+            token = get_object_or_404(UserToken.objects.filter(user=request.user), pk=pk)
         else:
-            token = Token(user=request.user)
-
-        form = forms.TokenForm(instance=token)
+            token = UserToken(user=request.user)
+        form = forms.UserTokenForm(instance=token)
 
         return render(request, 'generic/object_edit.html', {
             'object': token,
             'form': form,
-            'return_url': reverse('users:token_list'),
+            'return_url': reverse('users:usertoken_list'),
         })
 
     def post(self, request, pk=None):
-
         if pk:
-            token = get_object_or_404(Token.objects.filter(user=request.user), pk=pk)
-            form = forms.TokenForm(request.POST, instance=token)
+            token = get_object_or_404(UserToken.objects.filter(user=request.user), pk=pk)
+            form = forms.UserTokenForm(request.POST, instance=token)
         else:
-            token = Token(user=request.user)
-            form = forms.TokenForm(request.POST)
+            token = UserToken(user=request.user)
+            form = forms.UserTokenForm(request.POST)
 
         if form.is_valid():
-
             token = form.save(commit=False)
             token.user = request.user
             token.save()
@@ -304,7 +312,7 @@ class TokenEditView(LoginRequiredMixin, View):
             messages.success(request, msg)
 
             if not pk and not settings.ALLOW_TOKEN_RETRIEVAL:
-                return render(request, 'users/account/api_token.html', {
+                return render(request, 'users/account/token.html', {
                     'object': token,
                     'key': token.key,
                     'return_url': reverse('users:token_list'),
@@ -312,52 +320,90 @@ class TokenEditView(LoginRequiredMixin, View):
             elif '_addanother' in request.POST:
                 return redirect(request.path)
             else:
-                return redirect('users:token_list')
+                return redirect('users:usertoken_list')
 
         return render(request, 'generic/object_edit.html', {
             'object': token,
             'form': form,
-            'return_url': reverse('users:token_list'),
+            'return_url': reverse('users:usertoken_list'),
             'disable_addanother': not settings.ALLOW_TOKEN_RETRIEVAL
         })
 
 
-@register_model_view(Token, 'delete')
-class TokenDeleteView(LoginRequiredMixin, View):
+@register_model_view(UserToken, 'delete')
+class UserTokenDeleteView(LoginRequiredMixin, View):
 
     def get(self, request, pk):
-
-        token = get_object_or_404(Token.objects.filter(user=request.user), pk=pk)
-        initial_data = {
-            'return_url': reverse('users:token_list'),
-        }
-        form = ConfirmationForm(initial=initial_data)
+        token = get_object_or_404(UserToken.objects.filter(user=request.user), pk=pk)
 
         return render(request, 'generic/object_delete.html', {
             'object': token,
-            'form': form,
-            'return_url': reverse('users:token_list'),
+            'form': ConfirmationForm(),
+            'return_url': reverse('users:usertoken_list'),
         })
 
     def post(self, request, pk):
-
-        token = get_object_or_404(Token.objects.filter(user=request.user), pk=pk)
+        token = get_object_or_404(UserToken.objects.filter(user=request.user), pk=pk)
         form = ConfirmationForm(request.POST)
+
         if form.is_valid():
             token.delete()
             messages.success(request, "Token deleted")
-            return redirect('users:token_list')
+            return redirect('users:usertoken_list')
 
         return render(request, 'generic/object_delete.html', {
             'object': token,
             'form': form,
-            'return_url': reverse('users:token_list'),
+            'return_url': reverse('users:usertoken_list'),
         })
 
+
 #
-# Users
+# Tokens
 #
 
+class TokenListView(generic.ObjectListView):
+    queryset = Token.objects.all()
+    filterset = filtersets.TokenFilterSet
+    filterset_form = forms.TokenFilterForm
+    table = tables.TokenTable
+
+
+@register_model_view(Token)
+class TokenView(generic.ObjectView):
+    queryset = Token.objects.all()
+
+
+@register_model_view(Token, 'edit')
+class TokenEditView(generic.ObjectEditView):
+    queryset = Token.objects.all()
+    form = forms.TokenForm
+
+
+@register_model_view(Token, 'delete')
+class TokenDeleteView(generic.ObjectDeleteView):
+    queryset = Token.objects.all()
+
+
+class TokenBulkImportView(generic.BulkImportView):
+    queryset = Token.objects.all()
+    model_form = forms.TokenImportForm
+
+
+class TokenBulkEditView(generic.BulkEditView):
+    queryset = Token.objects.all()
+    table = tables.TokenTable
+    form = forms.TokenBulkEditForm
+
+
+class TokenBulkDeleteView(generic.BulkDeleteView):
+    queryset = Token.objects.all()
+    table = tables.TokenTable
+
+
+#
+# Users
+#
 
 class UserListView(generic.ObjectListView):
     queryset = NetBoxUser.objects.all()
@@ -413,7 +459,6 @@ class UserBulkDeleteView(generic.BulkDeleteView):
 # Groups
 #
 
-
 class GroupListView(generic.ObjectListView):
     queryset = NetBoxGroup.objects.annotate(users_count=Count('user'))
     filterset = filtersets.GroupFilterSet
@@ -448,11 +493,11 @@ class GroupBulkDeleteView(generic.BulkDeleteView):
     filterset = filtersets.GroupFilterSet
     table = tables.GroupTable
 
+
 #
 # ObjectPermissions
 #
 
-
 class ObjectPermissionListView(generic.ObjectListView):
     queryset = ObjectPermission.objects.all()
     filterset = filtersets.ObjectPermissionFilterSet