Procházet zdrojové kódy

Closes #13309: Introduce the account app (#13310)

* Introduce 'accounts' app for user-specific views & resources
* Move UserTokenTable to account app
* Move login & logout views to account app
Jeremy Stretch před 2 roky
rodič
revize
80376abedf

+ 0 - 0
netbox/account/__init__.py


+ 4 - 2
netbox/users/migrations/0005_usertoken.py → netbox/account/migrations/0001_initial.py

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

+ 0 - 0
netbox/account/migrations/__init__.py


+ 15 - 0
netbox/account/models.py

@@ -0,0 +1,15 @@
+from django.urls import reverse
+
+from users.models import Token
+
+
+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('account:usertoken', args=[self.pk])

+ 55 - 0
netbox/account/tables.py

@@ -0,0 +1,55 @@
+from django.utils.translation import gettext as _
+
+from account.models import UserToken
+from netbox.tables import NetBoxTable, columns
+
+__all__ = (
+    'UserTokenTable',
+)
+
+
+TOKEN = """<samp><span id="token_{{ record.pk }}">{{ record }}</span></samp>"""
+
+ALLOWED_IPS = """{{ value|join:", " }}"""
+
+COPY_BUTTON = """
+{% if settings.ALLOW_TOKEN_RETRIEVAL %}
+  {% copy_content record.pk prefix="token_" color="success" %}
+{% endif %}
+"""
+
+
+class UserTokenTable(NetBoxTable):
+    """
+    Table for users to manager their own API tokens under account views.
+    """
+    key = columns.TemplateColumn(
+        verbose_name=_('Key'),
+        template_code=TOKEN,
+    )
+    write_enabled = columns.BooleanColumn(
+        verbose_name=_('Write Enabled')
+    )
+    created = columns.DateColumn(
+        verbose_name=_('Created'),
+    )
+    expires = columns.DateColumn(
+        verbose_name=_('Expires'),
+    )
+    last_used = columns.DateTimeColumn(
+        verbose_name=_('Last Used'),
+    )
+    allowed_ips = columns.TemplateColumn(
+        verbose_name=_('Allowed IPs'),
+        template_code=ALLOWED_IPS
+    )
+    actions = columns.ActionsColumn(
+        actions=('edit', 'delete'),
+        extra_buttons=COPY_BUTTON
+    )
+
+    class Meta(NetBoxTable.Meta):
+        model = UserToken
+        fields = (
+            'pk', 'id', 'key', 'description', 'write_enabled', 'created', 'expires', 'last_used', 'allowed_ips',
+        )

+ 3 - 4
netbox/users/account_urls.py → netbox/account/urls.py

@@ -1,5 +1,6 @@
-from django.urls import path
+from django.urls import include, path
 
+from utilities.urls import get_model_urls
 from . import views
 
 app_name = 'account'
@@ -12,8 +13,6 @@ urlpatterns = [
     path('password/', views.ChangePasswordView.as_view(), name='change_password'),
     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>/', views.UserTokenView.as_view(), name='usertoken'),
-    path('api-tokens/<int:pk>/edit/', views.UserTokenEditView.as_view(), name='usertoken_edit'),
-    path('api-tokens/<int:pk>/delete/', views.UserTokenDeleteView.as_view(), name='usertoken_delete'),
+    path('api-tokens/<int:pk>/', include(get_model_urls('account', 'usertoken'))),
 
 ]

+ 298 - 0
netbox/account/views.py

@@ -0,0 +1,298 @@
+import logging
+
+from django.conf import settings
+from django.contrib import messages
+from django.contrib.auth import login as auth_login, logout as auth_logout
+from django.contrib.auth import update_session_auth_hash
+from django.contrib.auth.mixins import LoginRequiredMixin
+from django.contrib.auth.models import update_last_login
+from django.contrib.auth.signals import user_logged_in
+from django.http import HttpResponseRedirect
+from django.shortcuts import get_object_or_404, redirect
+from django.shortcuts import render, resolve_url
+from django.urls import reverse
+from django.utils.decorators import method_decorator
+from django.utils.http import url_has_allowed_host_and_scheme, urlencode
+from django.views.decorators.debug import sensitive_post_parameters
+from django.views.generic import View
+from social_core.backends.utils import load_backends
+
+from account.models import UserToken
+from extras.models import Bookmark, ObjectChange
+from extras.tables import BookmarkTable, ObjectChangeTable
+from netbox.authentication import get_auth_backend_display, get_saml_idps
+from netbox.config import get_config
+from netbox.views import generic
+from users import forms, tables
+from users.models import UserConfig
+from utilities.views import register_model_view
+
+
+#
+# Login/logout
+#
+
+class LoginView(View):
+    """
+    Perform user authentication via the web UI.
+    """
+    template_name = 'login.html'
+
+    @method_decorator(sensitive_post_parameters('password'))
+    def dispatch(self, *args, **kwargs):
+        return super().dispatch(*args, **kwargs)
+
+    def gen_auth_data(self, name, url, params):
+        display_name, icon_name = get_auth_backend_display(name)
+        return {
+            'display_name': display_name,
+            'icon_name': icon_name,
+            'url': f'{url}?{urlencode(params)}',
+        }
+
+    def get_auth_backends(self, request):
+        auth_backends = []
+        saml_idps = get_saml_idps()
+
+        for name in load_backends(settings.AUTHENTICATION_BACKENDS).keys():
+            url = reverse('social:begin', args=[name])
+            params = {}
+            if next := request.GET.get('next'):
+                params['next'] = next
+            if name.lower() == 'saml' and saml_idps:
+                for idp in saml_idps:
+                    params['idp'] = idp
+                    data = self.gen_auth_data(name, url, params)
+                    data['display_name'] = f'{data["display_name"]} ({idp})'
+                    auth_backends.append(data)
+            else:
+                auth_backends.append(self.gen_auth_data(name, url, params))
+
+        return auth_backends
+
+    def get(self, request):
+        form = forms.LoginForm(request)
+
+        if request.user.is_authenticated:
+            logger = logging.getLogger('netbox.auth.login')
+            return self.redirect_to_next(request, logger)
+
+        return render(request, self.template_name, {
+            'form': form,
+            'auth_backends': self.get_auth_backends(request),
+        })
+
+    def post(self, request):
+        logger = logging.getLogger('netbox.auth.login')
+        form = forms.LoginForm(request, data=request.POST)
+
+        if form.is_valid():
+            logger.debug("Login form validation was successful")
+
+            # If maintenance mode is enabled, assume the database is read-only, and disable updating the user's
+            # last_login time upon authentication.
+            if get_config().MAINTENANCE_MODE:
+                logger.warning("Maintenance mode enabled: disabling update of most recent login time")
+                user_logged_in.disconnect(update_last_login, dispatch_uid='update_last_login')
+
+            # Authenticate user
+            auth_login(request, form.get_user())
+            logger.info(f"User {request.user} successfully authenticated")
+            messages.success(request, f"Logged in as {request.user}.")
+
+            # Ensure the user has a UserConfig defined. (This should normally be handled by
+            # create_userconfig() on user creation.)
+            if not hasattr(request.user, 'config'):
+                config = get_config()
+                UserConfig(user=request.user, data=config.DEFAULT_USER_PREFERENCES).save()
+
+            return self.redirect_to_next(request, logger)
+
+        else:
+            logger.debug(f"Login form validation failed for username: {form['username'].value()}")
+
+        return render(request, self.template_name, {
+            'form': form,
+            'auth_backends': self.get_auth_backends(request),
+        })
+
+    def redirect_to_next(self, request, logger):
+        data = request.POST if request.method == "POST" else request.GET
+        redirect_url = data.get('next', settings.LOGIN_REDIRECT_URL)
+
+        if redirect_url and url_has_allowed_host_and_scheme(redirect_url, allowed_hosts=None):
+            logger.debug(f"Redirecting user to {redirect_url}")
+        else:
+            if redirect_url:
+                logger.warning(f"Ignoring unsafe 'next' URL passed to login form: {redirect_url}")
+            redirect_url = reverse('home')
+
+        return HttpResponseRedirect(redirect_url)
+
+
+class LogoutView(View):
+    """
+    Deauthenticate a web user.
+    """
+
+    def get(self, request):
+        logger = logging.getLogger('netbox.auth.logout')
+
+        # Log out the user
+        username = request.user
+        auth_logout(request)
+        logger.info(f"User {username} has logged out")
+        messages.info(request, "You have logged out.")
+
+        # Delete session key cookie (if set) upon logout
+        response = HttpResponseRedirect(resolve_url(settings.LOGOUT_REDIRECT_URL))
+        response.delete_cookie('session_key')
+
+        return response
+
+
+#
+# User profiles
+#
+
+class ProfileView(LoginRequiredMixin, View):
+    template_name = 'account/profile.html'
+
+    def get(self, request):
+
+        # Compile changelog table
+        changelog = ObjectChange.objects.valid_models().restrict(request.user, 'view').filter(
+            user=request.user
+        ).prefetch_related(
+            'changed_object_type'
+        )[:20]
+        changelog_table = ObjectChangeTable(changelog)
+
+        return render(request, self.template_name, {
+            'changelog_table': changelog_table,
+            'active_tab': 'profile',
+        })
+
+
+class UserConfigView(LoginRequiredMixin, View):
+    template_name = 'account/preferences.html'
+
+    def get(self, request):
+        userconfig = request.user.config
+        form = forms.UserConfigForm(instance=userconfig)
+
+        return render(request, self.template_name, {
+            'form': form,
+            'active_tab': 'preferences',
+        })
+
+    def post(self, request):
+        userconfig = request.user.config
+        form = forms.UserConfigForm(request.POST, instance=userconfig)
+
+        if form.is_valid():
+            form.save()
+
+            messages.success(request, "Your preferences have been updated.")
+            return redirect('account:preferences')
+
+        return render(request, self.template_name, {
+            'form': form,
+            'active_tab': 'preferences',
+        })
+
+
+class ChangePasswordView(LoginRequiredMixin, View):
+    template_name = 'account/password.html'
+
+    def get(self, request):
+        # LDAP users cannot change their password here
+        if getattr(request.user, 'ldap_username', None):
+            messages.warning(request, "LDAP-authenticated user credentials cannot be changed within NetBox.")
+            return redirect('account:profile')
+
+        form = forms.PasswordChangeForm(user=request.user)
+
+        return render(request, self.template_name, {
+            'form': form,
+            'active_tab': 'password',
+        })
+
+    def post(self, request):
+        form = forms.PasswordChangeForm(user=request.user, data=request.POST)
+        if form.is_valid():
+            form.save()
+            update_session_auth_hash(request, form.user)
+            messages.success(request, "Your password has been changed successfully.")
+            return redirect('account:profile')
+
+        return render(request, self.template_name, {
+            'form': form,
+            'active_tab': 'change_password',
+        })
+
+
+#
+# Bookmarks
+#
+
+class BookmarkListView(LoginRequiredMixin, generic.ObjectListView):
+    table = BookmarkTable
+    template_name = 'account/bookmarks.html'
+
+    def get_queryset(self, request):
+        return Bookmark.objects.filter(user=request.user)
+
+    def get_extra_context(self, request):
+        return {
+            'active_tab': 'bookmarks',
+        }
+
+
+#
+# User views for token management
+#
+
+class UserTokenListView(LoginRequiredMixin, View):
+
+    def get(self, request):
+        tokens = UserToken.objects.filter(user=request.user)
+        table = tables.UserTokenTable(tokens)
+        table.configure(request)
+
+        return render(request, 'account/token_list.html', {
+            'tokens': tokens,
+            'active_tab': 'api-tokens',
+            'table': table,
+        })
+
+
+@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, 'account/token.html', {
+            'object': token,
+            'key': key,
+        })
+
+
+@register_model_view(UserToken, 'edit')
+class UserTokenEditView(generic.ObjectEditView):
+    queryset = UserToken.objects.all()
+    form = forms.UserTokenForm
+    default_return_url = 'account:usertoken_list'
+
+    def alter_object(self, obj, request, url_args, url_kwargs):
+        if not obj.pk:
+            obj.user = request.user
+        return obj
+
+
+@register_model_view(UserToken, 'delete')
+class UserTokenDeleteView(generic.ObjectDeleteView):
+    queryset = UserToken.objects.all()
+    default_return_url = 'account:usertoken_list'

+ 1 - 0
netbox/netbox/settings.py

@@ -363,6 +363,7 @@ INSTALLED_APPS = [
     'taggit',
     'timezone_field',
     'core',
+    'account',
     'circuits',
     'dcim',
     'ipam',

+ 3 - 4
netbox/netbox/urls.py

@@ -1,19 +1,18 @@
 from django.conf import settings
 from django.conf.urls import include
-from django.urls import path, re_path
+from django.urls import path
 from django.views.decorators.csrf import csrf_exempt
 from django.views.static import serve
 from drf_spectacular.views import SpectacularAPIView, SpectacularRedocView, SpectacularSwaggerView
 
+from account.views import LoginView, LogoutView
 from extras.plugins.urls import plugin_admin_patterns, plugin_patterns, plugin_api_patterns
 from netbox.api.views import APIRootView, StatusView
 from netbox.graphql.schema import schema
 from netbox.graphql.views import GraphQLView
 from netbox.views import HomeView, StaticMediaFailureView, SearchView, htmx
-from users.views import LoginView, LogoutView
 from .admin import admin_site
 
-
 _patterns = [
 
     # Base views
@@ -37,7 +36,7 @@ _patterns = [
     path('wireless/', include('wireless.urls')),
 
     # Current user views
-    path('user/', include('users.account_urls')),
+    path('user/', include('account.urls')),
 
     # HTMX views
     path('htmx/object-selector/', htmx.ObjectSelectorView.as_view(), name='htmx_object_selector'),

+ 0 - 0
netbox/templates/users/account/base.html → netbox/templates/account/base.html


+ 1 - 2
netbox/templates/users/account/bookmarks.html → netbox/templates/account/bookmarks.html

@@ -1,4 +1,4 @@
-{% extends 'users/account/base.html' %}
+{% extends 'account/base.html' %}
 {% load buttons %}
 {% load helpers %}
 {% load render_table from django_tables2 %}
@@ -7,7 +7,6 @@
 {% block title %}{% trans "Bookmarks" %}{% endblock %}
 
 {% block content %}
-
   <form method="post" class="form form-horizontal">
     {% csrf_token %}
     <input type="hidden" name="return_url" value="{% url 'account:bookmarks' %}" />

+ 21 - 0
netbox/templates/account/password.html

@@ -0,0 +1,21 @@
+{% extends 'account/base.html' %}
+{% load form_helpers %}
+{% load i18n %}
+
+{% block title %}{% trans "Change Password" %}{% endblock %}
+
+{% block content %}
+  <form action="." method="post" class="form form-horizontal col-md-8 offset-md-2">
+    {% csrf_token %}
+    <div class="field-group">
+      <h5 class="text-center">{% trans "Password" %}</h5>
+      {% render_field form.old_password %}
+      {% render_field form.new_password1 %}
+      {% render_field form.new_password2 %}
+    </div>
+    <div class="text-end">
+      <a href="{% url 'account:profile' %}" class="btn btn-outline-danger">{% trans "Cancel" %}</a>
+      <button type="submit" name="_update" class="btn btn-primary">{% trans "Save" %}</button>
+    </div>
+  </form>
+{% endblock %}

+ 1 - 1
netbox/templates/users/account/preferences.html → netbox/templates/account/preferences.html

@@ -1,4 +1,4 @@
-{% extends 'users/account/base.html' %}
+{% extends 'account/base.html' %}
 {% load helpers %}
 {% load form_helpers %}
 {% load i18n %}

+ 1 - 1
netbox/templates/users/account/profile.html → netbox/templates/account/profile.html

@@ -1,4 +1,4 @@
-{% extends 'users/account/base.html' %}
+{% extends 'account/base.html' %}
 {% load helpers %}
 {% load render_table from django_tables2 %}
 {% load i18n %}

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


+ 26 - 0
netbox/templates/account/token_list.html

@@ -0,0 +1,26 @@
+{% extends 'account/base.html' %}
+{% load helpers %}
+{% load render_table from django_tables2 %}
+{% load i18n %}
+
+{% block title %}{% trans "My API Tokens" %}{% endblock %}
+
+{% block content %}
+  <div class="row">
+    <div class="col col-md-12 text-end">
+      <a href="{% url 'account:usertoken_add' %}" class="btn btn-sm btn-primary my-3">
+        <span class="mdi mdi-plus-thick" aria-hidden="true"></span> {% trans "Add a Token" %}
+      </a>
+    </div>
+  </div>
+  <div class="row mb-3">
+    <div class="col col-md-12">
+      <div class="card">
+        <div class="card-body table-responsive">
+          {% render_table table 'inc/table.html' %}
+          {% include 'inc/paginator.html' with paginator=table.paginator page=table.page %}
+        </div>
+      </div>
+    </div>
+  </div>
+{% endblock %}

+ 0 - 21
netbox/templates/users/account/password.html

@@ -1,21 +0,0 @@
-{% extends 'users/account/base.html' %}
-{% load form_helpers %}
-{% load i18n %}
-
-{% block title %}{% trans "Change Password" %}{% endblock %}
-
-{% block content %}
-    <form action="." method="post" class="form form-horizontal col-md-8 offset-md-2">
-        {% csrf_token %}
-        <div class="field-group">
-            <h5 class="text-center">{% trans "Password" %}</h5>
-            {% render_field form.old_password %}
-            {% render_field form.new_password1 %}
-            {% render_field form.new_password2 %}
-        </div>
-        <div class="text-end">
-            <a href="{% url 'account:profile' %}" class="btn btn-outline-danger">{% trans "Cancel" %}</a>
-            <button type="submit" name="_update" class="btn btn-primary">{% trans "Save" %}</button>
-        </div>
-    </form>
-{% endblock %}

+ 0 - 26
netbox/templates/users/account/token_list.html

@@ -1,26 +0,0 @@
-{% extends 'users/account/base.html' %}
-{% load helpers %}
-{% load render_table from django_tables2 %}
-{% load i18n %}
-
-{% block title %}{% trans "My API Tokens" %}{% endblock %}
-
-{% block content %}
-<div class="row">
-	<div class="col col-md-12 text-end">
-    <a href="{% url 'account:usertoken_add' %}" class="btn btn-sm btn-primary my-3">
-      <span class="mdi mdi-plus-thick" aria-hidden="true"></span> {% trans "Add a Token" %}
-    </a>
-  </div>
-</div>
-<div class="row mb-3">
-	<div class="col col-md-12">
-    <div class="card">
-      <div class="card-body table-responsive">
-        {% render_table table 'inc/table.html' %}
-        {% include 'inc/paginator.html' with paginator=table.paginator page=table.page %}
-      </div>
-    </div>
-  </div>
-</div>
-{% endblock %}

+ 0 - 13
netbox/users/models.py

@@ -26,7 +26,6 @@ __all__ = (
     'ObjectPermission',
     'Token',
     'UserConfig',
-    'UserToken',
 )
 
 
@@ -322,18 +321,6 @@ 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('account:usertoken', args=[self.pk])
-
-
 #
 # Permissions
 #

+ 2 - 52
netbox/users/tables.py

@@ -1,8 +1,9 @@
 import django_tables2 as tables
 from django.utils.translation import gettext as _
 
+from account.tables import UserTokenTable
 from netbox.tables import NetBoxTable, columns
-from users.models import NetBoxGroup, NetBoxUser, ObjectPermission, Token, UserToken
+from users.models import NetBoxGroup, NetBoxUser, ObjectPermission, Token
 
 __all__ = (
     'GroupTable',
@@ -12,58 +13,7 @@ __all__ = (
 )
 
 
-TOKEN = """<samp><span id="token_{{ record.pk }}">{{ record }}</span></samp>"""
-
-ALLOWED_IPS = """{{ value|join:", " }}"""
-
-COPY_BUTTON = """
-{% if settings.ALLOW_TOKEN_RETRIEVAL %}
-  {% copy_content record.pk prefix="token_" color="success" %}
-{% endif %}
-"""
-
-
-class UserTokenTable(NetBoxTable):
-    """
-    Table for users to manager their own API tokens under account views.
-    """
-    key = columns.TemplateColumn(
-        verbose_name=_('Key'),
-        template_code=TOKEN,
-    )
-    write_enabled = columns.BooleanColumn(
-        verbose_name=_('Write Enabled')
-    )
-    created = columns.DateColumn(
-        verbose_name=_('Created'),
-    )
-    expires = columns.DateColumn(
-        verbose_name=_('Expires'),
-    )
-    last_used = columns.DateTimeColumn(
-        verbose_name=_('Last Used'),
-    )
-    allowed_ips = columns.TemplateColumn(
-        verbose_name=_('Allowed IPs'),
-        template_code=ALLOWED_IPS
-    )
-    # TODO: Fix permissions evaluation & viewname resolution
-    actions = columns.ActionsColumn(
-        actions=('edit', 'delete'),
-        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')

+ 4 - 291
netbox/users/views.py

@@ -1,298 +1,11 @@
-import logging
-
-from django.conf import settings
-from django.contrib import messages
-from django.contrib.auth import login as auth_login, logout as auth_logout, update_session_auth_hash
-from django.contrib.auth.mixins import LoginRequiredMixin
-from django.contrib.auth.models import update_last_login
-from django.contrib.auth.signals import user_logged_in
 from django.db.models import Count
-from django.http import HttpResponseRedirect
-from django.shortcuts import get_object_or_404, redirect, render, resolve_url
-from django.urls import reverse
-from django.utils.decorators import method_decorator
-from django.utils.http import url_has_allowed_host_and_scheme, urlencode
-from django.views.decorators.debug import sensitive_post_parameters
-from django.views.generic import View
-from social_core.backends.utils import load_backends
-
-from extras.models import Bookmark, ObjectChange
-from extras.tables import BookmarkTable, ObjectChangeTable
-from netbox.authentication import get_auth_backend_display, get_saml_idps
-from netbox.config import get_config
+
+from extras.models import ObjectChange
+from extras.tables import ObjectChangeTable
 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 NetBoxGroup, NetBoxUser, ObjectPermission, Token, UserConfig, UserToken
-
-
-#
-# Login/logout
-#
-
-class LoginView(View):
-    """
-    Perform user authentication via the web UI.
-    """
-    template_name = 'login.html'
-
-    @method_decorator(sensitive_post_parameters('password'))
-    def dispatch(self, *args, **kwargs):
-        return super().dispatch(*args, **kwargs)
-
-    def gen_auth_data(self, name, url, params):
-        display_name, icon_name = get_auth_backend_display(name)
-        return {
-            'display_name': display_name,
-            'icon_name': icon_name,
-            'url': f'{url}?{urlencode(params)}',
-        }
-
-    def get_auth_backends(self, request):
-        auth_backends = []
-        saml_idps = get_saml_idps()
-
-        for name in load_backends(settings.AUTHENTICATION_BACKENDS).keys():
-            url = reverse('social:begin', args=[name])
-            params = {}
-            if next := request.GET.get('next'):
-                params['next'] = next
-            if name.lower() == 'saml' and saml_idps:
-                for idp in saml_idps:
-                    params['idp'] = idp
-                    data = self.gen_auth_data(name, url, params)
-                    data['display_name'] = f'{data["display_name"]} ({idp})'
-                    auth_backends.append(data)
-            else:
-                auth_backends.append(self.gen_auth_data(name, url, params))
-
-        return auth_backends
-
-    def get(self, request):
-        form = forms.LoginForm(request)
-
-        if request.user.is_authenticated:
-            logger = logging.getLogger('netbox.auth.login')
-            return self.redirect_to_next(request, logger)
-
-        return render(request, self.template_name, {
-            'form': form,
-            'auth_backends': self.get_auth_backends(request),
-        })
-
-    def post(self, request):
-        logger = logging.getLogger('netbox.auth.login')
-        form = forms.LoginForm(request, data=request.POST)
-
-        if form.is_valid():
-            logger.debug("Login form validation was successful")
-
-            # If maintenance mode is enabled, assume the database is read-only, and disable updating the user's
-            # last_login time upon authentication.
-            if get_config().MAINTENANCE_MODE:
-                logger.warning("Maintenance mode enabled: disabling update of most recent login time")
-                user_logged_in.disconnect(update_last_login, dispatch_uid='update_last_login')
-
-            # Authenticate user
-            auth_login(request, form.get_user())
-            logger.info(f"User {request.user} successfully authenticated")
-            messages.success(request, f"Logged in as {request.user}.")
-
-            # Ensure the user has a UserConfig defined. (This should normally be handled by
-            # create_userconfig() on user creation.)
-            if not hasattr(request.user, 'config'):
-                config = get_config()
-                UserConfig(user=request.user, data=config.DEFAULT_USER_PREFERENCES).save()
-
-            return self.redirect_to_next(request, logger)
-
-        else:
-            logger.debug(f"Login form validation failed for username: {form['username'].value()}")
-
-        return render(request, self.template_name, {
-            'form': form,
-            'auth_backends': self.get_auth_backends(request),
-        })
-
-    def redirect_to_next(self, request, logger):
-        data = request.POST if request.method == "POST" else request.GET
-        redirect_url = data.get('next', settings.LOGIN_REDIRECT_URL)
-
-        if redirect_url and url_has_allowed_host_and_scheme(redirect_url, allowed_hosts=None):
-            logger.debug(f"Redirecting user to {redirect_url}")
-        else:
-            if redirect_url:
-                logger.warning(f"Ignoring unsafe 'next' URL passed to login form: {redirect_url}")
-            redirect_url = reverse('home')
-
-        return HttpResponseRedirect(redirect_url)
-
-
-class LogoutView(View):
-    """
-    Deauthenticate a web user.
-    """
-
-    def get(self, request):
-        logger = logging.getLogger('netbox.auth.logout')
-
-        # Log out the user
-        username = request.user
-        auth_logout(request)
-        logger.info(f"User {username} has logged out")
-        messages.info(request, "You have logged out.")
-
-        # Delete session key cookie (if set) upon logout
-        response = HttpResponseRedirect(resolve_url(settings.LOGOUT_REDIRECT_URL))
-        response.delete_cookie('session_key')
-
-        return response
-
-
-#
-# User profiles
-#
-
-class ProfileView(LoginRequiredMixin, View):
-    template_name = 'users/account/profile.html'
-
-    def get(self, request):
-
-        # Compile changelog table
-        changelog = ObjectChange.objects.valid_models().restrict(request.user, 'view').filter(
-            user=request.user
-        ).prefetch_related(
-            'changed_object_type'
-        )[:20]
-        changelog_table = ObjectChangeTable(changelog)
-
-        return render(request, self.template_name, {
-            'changelog_table': changelog_table,
-            'active_tab': 'profile',
-        })
-
-
-class UserConfigView(LoginRequiredMixin, View):
-    template_name = 'users/account/preferences.html'
-
-    def get(self, request):
-        userconfig = request.user.config
-        form = forms.UserConfigForm(instance=userconfig)
-
-        return render(request, self.template_name, {
-            'form': form,
-            'active_tab': 'preferences',
-        })
-
-    def post(self, request):
-        userconfig = request.user.config
-        form = forms.UserConfigForm(request.POST, instance=userconfig)
-
-        if form.is_valid():
-            form.save()
-
-            messages.success(request, "Your preferences have been updated.")
-            return redirect('account:preferences')
-
-        return render(request, self.template_name, {
-            'form': form,
-            'active_tab': 'preferences',
-        })
-
-
-class ChangePasswordView(LoginRequiredMixin, View):
-    template_name = 'users/account/password.html'
-
-    def get(self, request):
-        # LDAP users cannot change their password here
-        if getattr(request.user, 'ldap_username', None):
-            messages.warning(request, "LDAP-authenticated user credentials cannot be changed within NetBox.")
-            return redirect('account:profile')
-
-        form = forms.PasswordChangeForm(user=request.user)
-
-        return render(request, self.template_name, {
-            'form': form,
-            'active_tab': 'password',
-        })
-
-    def post(self, request):
-        form = forms.PasswordChangeForm(user=request.user, data=request.POST)
-        if form.is_valid():
-            form.save()
-            update_session_auth_hash(request, form.user)
-            messages.success(request, "Your password has been changed successfully.")
-            return redirect('account:profile')
-
-        return render(request, self.template_name, {
-            'form': form,
-            'active_tab': 'change_password',
-        })
-
-
-#
-# Bookmarks
-#
-
-class BookmarkListView(LoginRequiredMixin, generic.ObjectListView):
-    table = BookmarkTable
-    template_name = 'users/account/bookmarks.html'
-
-    def get_queryset(self, request):
-        return Bookmark.objects.filter(user=request.user)
-
-    def get_extra_context(self, request):
-        return {
-            'active_tab': 'bookmarks',
-        }
-
-
-#
-# User views for token management
-#
-
-class UserTokenListView(LoginRequiredMixin, View):
-
-    def get(self, request):
-        tokens = UserToken.objects.filter(user=request.user)
-        table = tables.UserTokenTable(tokens)
-        table.configure(request)
-
-        return render(request, 'users/account/token_list.html', {
-            'tokens': tokens,
-            'active_tab': 'api-tokens',
-            'table': table,
-        })
-
-
-@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,
-        })
-
-
-class UserTokenEditView(generic.ObjectEditView):
-    queryset = UserToken.objects.all()
-    form = forms.UserTokenForm
-    default_return_url = 'account:usertoken_list'
-
-    def alter_object(self, obj, request, url_args, url_kwargs):
-        if not obj.pk:
-            obj.user = request.user
-        return obj
-
-
-class UserTokenDeleteView(generic.ObjectDeleteView):
-    queryset = UserToken.objects.all()
-    default_return_url = 'account:usertoken_list'
+from .models import NetBoxGroup, NetBoxUser, ObjectPermission, Token
 
 
 #