diff --git a/netbox/templates/users/base.html b/netbox/templates/users/base.html
index 3d09f8526..9711dc79e 100644
--- a/netbox/templates/users/base.html
+++ b/netbox/templates/users/base.html
@@ -4,7 +4,6 @@
{% load helpers %}
{% load plugins %}
-
{% block content-wrapper %}
{% block content %}{% endblock %}
diff --git a/netbox/templates/users/base_profile.html b/netbox/templates/users/base_profile.html
new file mode 100644
index 000000000..76cc53b33
--- /dev/null
+++ b/netbox/templates/users/base_profile.html
@@ -0,0 +1,27 @@
+{% extends 'base/layout.html' %}
+
+
+{% block tabs %}
+
+{% endblock %}
+
+{% block content-wrapper %}
+
+ {% block content %}{% endblock %}
+
+{% endblock %}
diff --git a/netbox/templates/users/bookmarks.html b/netbox/templates/users/bookmarks.html
new file mode 100644
index 000000000..66f367a1c
--- /dev/null
+++ b/netbox/templates/users/bookmarks.html
@@ -0,0 +1,34 @@
+{% extends 'users/base.html' %}
+{% load buttons %}
+{% load helpers %}
+{% load render_table from django_tables2 %}
+
+{% block title %}Bookmarks{% endblock %}
+
+{% block content %}
+
+
+{% endblock %}
diff --git a/netbox/templates/users/passworduser.html b/netbox/templates/users/passworduser.html
new file mode 100644
index 000000000..d0ebc12e5
--- /dev/null
+++ b/netbox/templates/users/passworduser.html
@@ -0,0 +1,27 @@
+{% extends 'users/base_profile.html' %}
+{% load form_helpers %}
+
+{% block title %}{% trans "Change Password" %}{% endblock %}
+
+{% block tabs %}
+
+{% endblock tabs %}
+
+{% block content %}
+
+{% endblock %}
diff --git a/netbox/templates/virtualization/virtualmachine.html b/netbox/templates/virtualization/virtualmachine.html
index 51fd8aa80..3d3b498ad 100644
--- a/netbox/templates/virtualization/virtualmachine.html
+++ b/netbox/templates/virtualization/virtualmachine.html
@@ -46,12 +46,13 @@
Primary IPv4 |
{% if object.primary_ip4 %}
- {{ object.primary_ip4.address.ip }}
+ {{ object.primary_ip4.address.ip }}
{% if object.primary_ip4.nat_inside %}
(NAT for {{ object.primary_ip4.nat_inside.address.ip }})
{% elif object.primary_ip4.nat_outside.exists %}
(NAT: {% for nat in object.primary_ip4.nat_outside.all %}{{ nat.address.ip }}{% if not forloop.last %}, {% endif %}{% endfor %})
{% endif %}
+ {% copy_content "primary_ip4" %}
{% else %}
{{ ''|placeholder }}
{% endif %}
@@ -61,12 +62,13 @@
| Primary IPv6 |
{% if object.primary_ip6 %}
- {{ object.primary_ip6.address.ip }}
+ {{ object.primary_ip6.address.ip }}
{% if object.primary_ip6.nat_inside %}
(NAT for {{ object.primary_ip6.nat_inside.address.ip }})
{% elif object.primary_ip6.nat_outside.exists %}
(NAT: {% for nat in object.primary_ip6.nat_outside.all %}{{ nat.address.ip }}{% if not forloop.last %}, {% endif %}{% endfor %})
{% endif %}
+ {% copy_content "primary_ip6" %}
{% else %}
{{ ''|placeholder }}
{% endif %}
diff --git a/netbox/tenancy/models/contacts.py b/netbox/tenancy/models/contacts.py
index 440541b5f..1df5e3305 100644
--- a/netbox/tenancy/models/contacts.py
+++ b/netbox/tenancy/models/contacts.py
@@ -136,3 +136,8 @@ class ContactAssignment(ChangeLoggedModel):
def get_absolute_url(self):
return reverse('tenancy:contact', args=[self.contact.pk])
+
+ def to_objectchange(self, action):
+ objectchange = super().to_objectchange(action)
+ objectchange.related_object = self.object
+ return objectchange
diff --git a/netbox/tenancy/tables/contacts.py b/netbox/tenancy/tables/contacts.py
index 0c697af79..7de8ffceb 100644
--- a/netbox/tenancy/tables/contacts.py
+++ b/netbox/tenancy/tables/contacts.py
@@ -1,4 +1,5 @@
import django_tables2 as tables
+from django_tables2.utils import Accessor
from netbox.tables import NetBoxTable, columns
from tenancy.models import *
@@ -90,11 +91,40 @@ class ContactAssignmentTable(NetBoxTable):
role = tables.Column(
linkify=True
)
+ contact_title = tables.Column(
+ accessor=Accessor('contact__title'),
+ verbose_name='Contact Title'
+ )
+ contact_phone = tables.Column(
+ accessor=Accessor('contact__phone'),
+ verbose_name='Contact Phone'
+ )
+ contact_email = tables.Column(
+ accessor=Accessor('contact__email'),
+ verbose_name='Contact Email'
+ )
+ contact_address = tables.Column(
+ accessor=Accessor('contact__address'),
+ verbose_name='Contact Address'
+ )
+ contact_link = tables.Column(
+ accessor=Accessor('contact__link'),
+ verbose_name='Contact Link'
+ )
+ contact_description = tables.Column(
+ accessor=Accessor('contact__description'),
+ verbose_name='Contact Description'
+ )
actions = columns.ActionsColumn(
actions=('edit', 'delete')
)
class Meta(NetBoxTable.Meta):
model = ContactAssignment
- fields = ('pk', 'content_type', 'object', 'contact', 'role', 'priority', 'actions')
- default_columns = ('pk', 'content_type', 'object', 'contact', 'role', 'priority')
+ fields = (
+ 'pk', 'content_type', 'object', 'contact', 'role', 'priority', 'contact_title', 'contact_phone',
+ 'contact_email', 'contact_address', 'contact_link', 'contact_description', 'actions'
+ )
+ default_columns = (
+ 'pk', 'content_type', 'object', 'contact', 'role', 'priority', 'contact_email', 'contact_phone'
+ )
diff --git a/netbox/users/tables.py b/netbox/users/tables.py
index ba81c2828..2fcdc3a05 100644
--- a/netbox/users/tables.py
+++ b/netbox/users/tables.py
@@ -18,9 +18,7 @@ ALLOWED_IPS = """{{ value|join:", " }}"""
COPY_BUTTON = """
{% if settings.ALLOW_TOKEN_RETRIEVAL %}
-
-
-
+ {% copy_content record.pk prefix="token_" color="success" %}
{% endif %}
"""
diff --git a/netbox/users/urls.py b/netbox/users/urls.py
index 031c38c44..6e9f3ef70 100644
--- a/netbox/users/urls.py
+++ b/netbox/users/urls.py
@@ -8,6 +8,7 @@ urlpatterns = [
# User
path('profile/', views.ProfileView.as_view(), name='profile'),
+ 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'),
diff --git a/netbox/users/views.py b/netbox/users/views.py
index a316985e9..52ff09140 100644
--- a/netbox/users/views.py
+++ b/netbox/users/views.py
@@ -17,8 +17,8 @@ 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 ObjectChange
-from extras.tables import ObjectChangeTable
+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
@@ -163,7 +163,9 @@ class ProfileView(LoginRequiredMixin, View):
def get(self, request):
# Compile changelog table
- changelog = ObjectChange.objects.restrict(request.user, 'view').filter(user=request.user).prefetch_related(
+ changelog = ObjectChange.objects.valid_models().restrict(request.user, 'view').filter(
+ user=request.user
+ ).prefetch_related(
'changed_object_type'
)[:20]
changelog_table = ObjectChangeTable(changelog)
@@ -232,6 +234,23 @@ class ChangePasswordView(LoginRequiredMixin, View):
})
+#
+# Bookmarks
+#
+
+class BookmarkListView(LoginRequiredMixin, generic.ObjectListView):
+ table = BookmarkTable
+ template_name = 'users/bookmarks.html'
+
+ def get_queryset(self, request):
+ return Bookmark.objects.filter(user=request.user)
+
+ def get_extra_context(self, request):
+ return {
+ 'active_tab': 'bookmarks',
+ }
+
+
#
# API tokens
#
diff --git a/netbox/utilities/templates/builtins/copy_content.html b/netbox/utilities/templates/builtins/copy_content.html
new file mode 100644
index 000000000..9025a71a1
--- /dev/null
+++ b/netbox/utilities/templates/builtins/copy_content.html
@@ -0,0 +1,3 @@
+
+
+
diff --git a/netbox/utilities/templates/buttons/bookmark.html b/netbox/utilities/templates/buttons/bookmark.html
new file mode 100644
index 000000000..b11d1e82e
--- /dev/null
+++ b/netbox/utilities/templates/buttons/bookmark.html
@@ -0,0 +1,15 @@
+
diff --git a/netbox/utilities/templatetags/builtins/tags.py b/netbox/utilities/templatetags/builtins/tags.py
index dc86586e7..35aec1000 100644
--- a/netbox/utilities/templatetags/builtins/tags.py
+++ b/netbox/utilities/templatetags/builtins/tags.py
@@ -1,9 +1,12 @@
from django import template
from django.http import QueryDict
+from utilities.utils import dict_to_querydict
+
__all__ = (
'badge',
'checkmark',
+ 'copy_content',
'customfield_value',
'tag',
)
@@ -77,6 +80,17 @@ def checkmark(value, show_false=True, true='Yes', false='No'):
}
+@register.inclusion_tag('builtins/copy_content.html')
+def copy_content(target, prefix=None, color='primary'):
+ """
+ Display a copy button to copy the content of a field.
+ """
+ return {
+ 'target': f'#{prefix or ""}{target}',
+ 'color': f'btn-{color}'
+ }
+
+
@register.inclusion_tag('builtins/htmx_table.html', takes_context=True)
def htmx_table(context, viewname, return_url=None, **kwargs):
"""
@@ -87,8 +101,7 @@ def htmx_table(context, viewname, return_url=None, **kwargs):
viewname: The name of the view to use for the HTMX request (e.g. `dcim:site_list`)
return_url: The URL to pass as the `return_url`. If not provided, the current request's path will be used.
"""
- url_params = QueryDict(mutable=True)
- url_params.update(kwargs)
+ url_params = dict_to_querydict(kwargs)
url_params['return_url'] = return_url or context['request'].path
return {
'viewname': viewname,
diff --git a/netbox/utilities/templatetags/buttons.py b/netbox/utilities/templatetags/buttons.py
index 1556b29a0..828af3b43 100644
--- a/netbox/utilities/templatetags/buttons.py
+++ b/netbox/utilities/templatetags/buttons.py
@@ -2,11 +2,12 @@ from django import template
from django.contrib.contenttypes.models import ContentType
from django.urls import NoReverseMatch, reverse
-from extras.models import ExportTemplate
+from extras.models import Bookmark, ExportTemplate
from utilities.utils import get_viewname, prepare_cloned_fields
__all__ = (
'add_button',
+ 'bookmark_button',
'bulk_delete_button',
'bulk_edit_button',
'clone_button',
@@ -24,6 +25,37 @@ register = template.Library()
# Instance buttons
#
+@register.inclusion_tag('buttons/bookmark.html', takes_context=True)
+def bookmark_button(context, instance):
+ # Check if this user has already bookmarked the object
+ content_type = ContentType.objects.get_for_model(instance)
+ bookmark = Bookmark.objects.filter(
+ object_type=content_type,
+ object_id=instance.pk,
+ user=context['request'].user
+ ).first()
+
+ # Compile form URL & data
+ if bookmark:
+ form_url = reverse('extras:bookmark_delete', kwargs={'pk': bookmark.pk})
+ form_data = {
+ 'confirm': 'true',
+ }
+ else:
+ form_url = reverse('extras:bookmark_add')
+ form_data = {
+ 'object_type': content_type.pk,
+ 'object_id': instance.pk,
+ }
+
+ return {
+ 'bookmark': bookmark,
+ 'form_url': form_url,
+ 'form_data': form_data,
+ 'return_url': instance.get_absolute_url(),
+ }
+
+
@register.inclusion_tag('buttons/clone.html')
def clone_button(instance):
url = reverse(get_viewname(instance, 'add'))
diff --git a/netbox/utilities/utils.py b/netbox/utilities/utils.py
index 4b4a2631a..114397dae 100644
--- a/netbox/utilities/utils.py
+++ b/netbox/utilities/utils.py
@@ -11,8 +11,9 @@ from django.core import serializers
from django.db.models import Count, OuterRef, Subquery
from django.db.models.functions import Coalesce
from django.http import QueryDict
-from django.utils.html import escape
from django.utils import timezone
+from django.utils.datastructures import MultiValueDict
+from django.utils.html import escape
from django.utils.timezone import localtime
from jinja2.sandbox import SandboxedEnvironment
from mptt.models import MPTTModel
@@ -231,6 +232,19 @@ def dict_to_filter_params(d, prefix=''):
return params
+def dict_to_querydict(d, mutable=True):
+ """
+ Create a QueryDict instance from a regular Python dictionary.
+ """
+ qd = QueryDict(mutable=True)
+ for k, v in d.items():
+ item = MultiValueDict({k: v}) if isinstance(v, (list, tuple, set)) else {k: v}
+ qd.update(item)
+ if not mutable:
+ qd._mutable = False
+ return qd
+
+
def normalize_querydict(querydict):
"""
Convert a QueryDict to a normal, mutable dictionary, preserving list values. For example,
diff --git a/requirements.txt b/requirements.txt
index 2ffcd852b..f707c60c5 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,7 +1,7 @@
bleach==6.0.0
-boto3==1.26.156
+boto3==1.28.1
Django==4.2.2
-django-cors-headers==4.1.0
+django-cors-headers==4.2.0
django-debug-toolbar==4.1.0
django-filter==23.2
django-graphiql-debug-toolbar==0.2.0
@@ -9,27 +9,27 @@ django-mptt==0.14
django-pglocks==1.0.4
django-prometheus==2.3.1
django-redis==5.3.0
-django-rich==1.6.0
+django-rich==1.7.0
django-rq==2.8.1
-django-tables2==2.5.3
+django-tables2==2.6.0
django-taggit==4.0.0
django-timezone-field==5.1
djangorestframework==3.14.0
-drf-spectacular==0.26.2
-drf-spectacular-sidecar==2023.6.1
+drf-spectacular==0.26.3
+drf-spectacular-sidecar==2023.7.1
dulwich==0.21.5
feedparser==6.0.10
graphene-django==3.0.0
gunicorn==20.1.0
Jinja2==3.1.2
Markdown==3.3.7
-mkdocs-material==9.1.16
+mkdocs-material==9.1.18
mkdocstrings[python-legacy]==0.22.0
netaddr==0.8.0
-Pillow==9.5.0
+Pillow==10.0.0
psycopg[binary,pool]==3.1.9
PyYAML==6.0
-sentry-sdk==1.25.1
+sentry-sdk==1.28.0
social-auth-app-django==5.2.0
social-auth-core[openidconnect]==4.4.2
svgwrite==1.4.3
|