Kaynağa Gözat

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 2 yıl önce
ebeveyn
işleme
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
 from django.db import migrations
 
 
 
 
 class Migration(migrations.Migration):
 class Migration(migrations.Migration):
 
 
+    initial = True
+
     dependencies = [
     dependencies = [
         ('users', '0004_netboxgroup_netboxuser'),
         ('users', '0004_netboxgroup_netboxuser'),
     ]
     ]
@@ -15,10 +17,10 @@ class Migration(migrations.Migration):
             fields=[
             fields=[
             ],
             ],
             options={
             options={
+                'verbose_name': 'token',
                 'proxy': True,
                 'proxy': True,
                 'indexes': [],
                 'indexes': [],
                 'constraints': [],
                 'constraints': [],
-                'verbose_name': 'token',
             },
             },
             bases=('users.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
 from . import views
 
 
 app_name = 'account'
 app_name = 'account'
@@ -12,8 +13,6 @@ urlpatterns = [
     path('password/', views.ChangePasswordView.as_view(), name='change_password'),
     path('password/', views.ChangePasswordView.as_view(), name='change_password'),
     path('api-tokens/', views.UserTokenListView.as_view(), name='usertoken_list'),
     path('api-tokens/', views.UserTokenListView.as_view(), name='usertoken_list'),
     path('api-tokens/add/', views.UserTokenEditView.as_view(), name='usertoken_add'),
     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',
     'taggit',
     'timezone_field',
     'timezone_field',
     'core',
     'core',
+    'account',
     'circuits',
     'circuits',
     'dcim',
     'dcim',
     'ipam',
     'ipam',

+ 3 - 4
netbox/netbox/urls.py

@@ -1,19 +1,18 @@
 from django.conf import settings
 from django.conf import settings
 from django.conf.urls import include
 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.decorators.csrf import csrf_exempt
 from django.views.static import serve
 from django.views.static import serve
 from drf_spectacular.views import SpectacularAPIView, SpectacularRedocView, SpectacularSwaggerView
 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 extras.plugins.urls import plugin_admin_patterns, plugin_patterns, plugin_api_patterns
 from netbox.api.views import APIRootView, StatusView
 from netbox.api.views import APIRootView, StatusView
 from netbox.graphql.schema import schema
 from netbox.graphql.schema import schema
 from netbox.graphql.views import GraphQLView
 from netbox.graphql.views import GraphQLView
 from netbox.views import HomeView, StaticMediaFailureView, SearchView, htmx
 from netbox.views import HomeView, StaticMediaFailureView, SearchView, htmx
-from users.views import LoginView, LogoutView
 from .admin import admin_site
 from .admin import admin_site
 
 
-
 _patterns = [
 _patterns = [
 
 
     # Base views
     # Base views
@@ -37,7 +36,7 @@ _patterns = [
     path('wireless/', include('wireless.urls')),
     path('wireless/', include('wireless.urls')),
 
 
     # Current user views
     # Current user views
-    path('user/', include('users.account_urls')),
+    path('user/', include('account.urls')),
 
 
     # HTMX views
     # HTMX views
     path('htmx/object-selector/', htmx.ObjectSelectorView.as_view(), name='htmx_object_selector'),
     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 buttons %}
 {% load helpers %}
 {% load helpers %}
 {% load render_table from django_tables2 %}
 {% load render_table from django_tables2 %}
@@ -7,7 +7,6 @@
 {% block title %}{% trans "Bookmarks" %}{% endblock %}
 {% block title %}{% trans "Bookmarks" %}{% endblock %}
 
 
 {% block content %}
 {% block content %}
-
   <form method="post" class="form form-horizontal">
   <form method="post" class="form form-horizontal">
     {% csrf_token %}
     {% csrf_token %}
     <input type="hidden" name="return_url" value="{% url 'account:bookmarks' %}" />
     <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 helpers %}
 {% load form_helpers %}
 {% load form_helpers %}
 {% load i18n %}
 {% 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 helpers %}
 {% load render_table from django_tables2 %}
 {% load render_table from django_tables2 %}
 {% load i18n %}
 {% 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',
     'ObjectPermission',
     'Token',
     'Token',
     'UserConfig',
     'UserConfig',
-    'UserToken',
 )
 )
 
 
 
 
@@ -322,18 +321,6 @@ class Token(models.Model):
         return False
         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
 # Permissions
 #
 #

+ 2 - 52
netbox/users/tables.py

@@ -1,8 +1,9 @@
 import django_tables2 as tables
 import django_tables2 as tables
 from django.utils.translation import gettext as _
 from django.utils.translation import gettext as _
 
 
+from account.tables import UserTokenTable
 from netbox.tables import NetBoxTable, columns
 from netbox.tables import NetBoxTable, columns
-from users.models import NetBoxGroup, NetBoxUser, ObjectPermission, Token, UserToken
+from users.models import NetBoxGroup, NetBoxUser, ObjectPermission, Token
 
 
 __all__ = (
 __all__ = (
     'GroupTable',
     '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):
 class TokenTable(UserTokenTable):
-    """
-    General-purpose table for API token management.
-    """
     user = tables.Column(
     user = tables.Column(
         linkify=True,
         linkify=True,
         verbose_name=_('User')
         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.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 netbox.views import generic
-from utilities.forms import ConfirmationForm
 from utilities.views import register_model_view
 from utilities.views import register_model_view
 from . import filtersets, forms, tables
 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
 
 
 
 
 #
 #