mirror of
https://github.com/netbox-community/netbox.git
synced 2024-05-10 07:54:54 +00:00
Closes #13228: Move token management views to primary UI
This commit is contained in:
@ -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']),
|
||||
),
|
||||
),
|
||||
|
@ -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
|
||||
|
@ -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>
|
||||
|
@ -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 %}
|
@ -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
netbox/templates/users/account/token.html
Normal file
69
netbox/templates/users/account/token.html
Normal file
@ -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,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
netbox/templates/users/token.html
Normal file
56
netbox/templates/users/token.html
Normal file
@ -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 %}
|
@ -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"
|
||||
|
@ -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
|
@ -10,6 +10,7 @@ from users.models import ObjectPermission, Token
|
||||
__all__ = (
|
||||
'GroupFilterSet',
|
||||
'ObjectPermissionFilterSet',
|
||||
'TokenFilterSet',
|
||||
'UserFilterSet',
|
||||
)
|
||||
|
||||
|
@ -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',
|
||||
)
|
||||
|
@ -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',)
|
||||
|
@ -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()
|
||||
)
|
||||
|
@ -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
netbox/users/migrations/0005_usertoken.py
Normal file
25
netbox/users/migrations/0005_usertoken.py
Normal file
@ -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',),
|
||||
),
|
||||
]
|
@ -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
|
||||
#
|
||||
|
@ -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',
|
||||
)
|
||||
|
||||
|
||||
|
@ -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,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'),
|
||||
|
@ -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):
|
||||
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,53 +320,91 @@ 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'),
|
||||
})
|
||||
|
||||
|
||||
#
|
||||
# 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()
|
||||
filterset = filtersets.UserFilterSet
|
||||
@ -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
|
||||
|
Reference in New Issue
Block a user