Răsfoiți Sursa

Closes #8248: User bookmarks (#13035)

* Initial work on #8248

* Add tests

* Fix tests

* Add feature query for bookmarks

* Add BookmarksWidget

* Correct generic relation name

* Add docs for bookmarks

* Remove inheritance from ChangeLoggedModel
Jeremy Stretch 2 ani în urmă
părinte
comite
6e222f8dce

+ 4 - 0
docs/features/customization.md

@@ -18,6 +18,10 @@ The `tag` filter can be specified multiple times to match only objects which hav
 GET /api/dcim/devices/?tag=monitored&tag=deprecated
 ```
 
+## Bookmarks
+
+Users can bookmark their most commonly visited objects for convenient access. Bookmarks are listed under a user's profile, and can be displayed with custom filtering and ordering on the user's personal dashboard.
+
 ## Custom Fields
 
 While NetBox provides a rather extensive data model out of the box, the need may arise to store certain additional data associated with NetBox objects. For example, you might need to record the invoice ID alongside an installed device, or record an approving authority when creating a new IP prefix. NetBox administrators can create custom fields on built-in objects to meet these needs.

+ 13 - 0
docs/models/extras/bookmark.md

@@ -0,0 +1,13 @@
+# Bookmarks
+
+A user can bookmark individual objects for convenient access. Bookmarks are listed under a user's profile and can be displayed using a dashboard widget.
+
+## Fields
+
+### User
+
+The user to whom the bookmark belongs.
+
+### Object
+
+The bookmarked object.

+ 1 - 0
mkdocs.yml

@@ -206,6 +206,7 @@ nav:
             - VirtualChassis: 'models/dcim/virtualchassis.md'
             - VirtualDeviceContext: 'models/dcim/virtualdevicecontext.md'
         - Extras:
+            - Bookmark: 'models/extras/bookmark.md'
             - Branch: 'models/extras/branch.md'
             - ConfigContext: 'models/extras/configcontext.md'
             - ConfigTemplate: 'models/extras/configtemplate.md'

+ 9 - 0
netbox/extras/api/nested_serializers.py

@@ -4,6 +4,7 @@ from extras import models
 from netbox.api.serializers import NestedTagSerializer, WritableNestedSerializer
 
 __all__ = [
+    'NestedBookmarkSerializer',
     'NestedConfigContextSerializer',
     'NestedConfigTemplateSerializer',
     'NestedCustomFieldSerializer',
@@ -73,6 +74,14 @@ class NestedSavedFilterSerializer(WritableNestedSerializer):
         fields = ['id', 'url', 'display', 'name', 'slug']
 
 
+class NestedBookmarkSerializer(WritableNestedSerializer):
+    url = serializers.HyperlinkedIdentityField(view_name='extras-api:bookmark-detail')
+
+    class Meta:
+        model = models.Bookmark
+        fields = ['id', 'url', 'display', 'object_id', 'object_type']
+
+
 class NestedImageAttachmentSerializer(WritableNestedSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='extras-api:imageattachment-detail')
 

+ 25 - 0
netbox/extras/api/serializers.py

@@ -31,6 +31,7 @@ from virtualization.models import Cluster, ClusterGroup, ClusterType
 from .nested_serializers import *
 
 __all__ = (
+    'BookmarkSerializer',
     'ConfigContextSerializer',
     'ConfigTemplateSerializer',
     'ContentTypeSerializer',
@@ -190,6 +191,30 @@ class SavedFilterSerializer(ValidatedModelSerializer):
         ]
 
 
+#
+# Bookmarks
+#
+
+class BookmarkSerializer(ValidatedModelSerializer):
+    url = serializers.HyperlinkedIdentityField(view_name='extras-api:bookmark-detail')
+    object_type = ContentTypeField(
+        queryset=ContentType.objects.filter(FeatureQuery('bookmarks').get_query()),
+    )
+    object = serializers.SerializerMethodField(read_only=True)
+    user = NestedUserSerializer()
+
+    class Meta:
+        model = Bookmark
+        fields = [
+            'id', 'url', 'display', 'object_type', 'object_id', 'object', 'user', 'created',
+        ]
+
+    @extend_schema_field(serializers.JSONField(allow_null=True))
+    def get_object(self, instance):
+        serializer = get_serializer_for_model(instance.object, prefix=NESTED_SERIALIZER_PREFIX)
+        return serializer(instance.object, context={'request': self.context['request']}).data
+
+
 #
 # Tags
 #

+ 1 - 0
netbox/extras/api/urls.py

@@ -12,6 +12,7 @@ router.register('custom-fields', views.CustomFieldViewSet)
 router.register('custom-links', views.CustomLinkViewSet)
 router.register('export-templates', views.ExportTemplateViewSet)
 router.register('saved-filters', views.SavedFilterViewSet)
+router.register('bookmarks', views.BookmarkViewSet)
 router.register('tags', views.TagViewSet)
 router.register('image-attachments', views.ImageAttachmentViewSet)
 router.register('journal-entries', views.JournalEntryViewSet)

+ 11 - 0
netbox/extras/api/views.py

@@ -93,6 +93,17 @@ class SavedFilterViewSet(NetBoxModelViewSet):
     filterset_class = filtersets.SavedFilterFilterSet
 
 
+#
+# Bookmarks
+#
+
+class BookmarkViewSet(NetBoxModelViewSet):
+    metadata_class = ContentTypeMetadata
+    queryset = Bookmark.objects.all()
+    serializer_class = serializers.BookmarkSerializer
+    filterset_class = filtersets.BookmarkFilterSet
+
+
 #
 # Tags
 #

+ 16 - 1
netbox/extras/choices.py

@@ -79,6 +79,21 @@ class CustomLinkButtonClassChoices(ButtonColorChoices):
         (LINK, 'Link'),
     )
 
+
+#
+# Bookmarks
+#
+
+class BookmarkOrderingChoices(ChoiceSet):
+
+    ORDERING_NEWEST = '-created'
+    ORDERING_OLDEST = 'created'
+
+    CHOICES = (
+        (ORDERING_NEWEST, 'Newest'),
+        (ORDERING_OLDEST, 'Oldest'),
+    )
+
 #
 # ObjectChanges
 #
@@ -98,7 +113,7 @@ class ObjectChangeActionChoices(ChoiceSet):
 
 
 #
-# Jounral entries
+# Journal entries
 #
 
 class JournalEntryKindChoices(ChoiceSet):

+ 41 - 0
netbox/extras/dashboard/widgets.py

@@ -15,6 +15,7 @@ from django.template.loader import render_to_string
 from django.urls import NoReverseMatch, resolve, reverse
 from django.utils.translation import gettext as _
 
+from extras.choices import BookmarkOrderingChoices
 from extras.utils import FeatureQuery
 from utilities.forms import BootstrapMixin
 from utilities.permissions import get_permission_for_model
@@ -23,6 +24,7 @@ from utilities.utils import content_type_identifier, content_type_name, get_view
 from .utils import register_widget
 
 __all__ = (
+    'BookmarksWidget',
     'DashboardWidget',
     'NoteWidget',
     'ObjectCountsWidget',
@@ -318,3 +320,42 @@ class RSSFeedWidget(DashboardWidget):
         return {
             'feed': feed,
         }
+
+
+@register_widget
+class BookmarksWidget(DashboardWidget):
+    default_title = _('Bookmarks')
+    default_config = {
+        'order_by': BookmarkOrderingChoices.ORDERING_NEWEST,
+    }
+    description = _('Show your personal bookmarks')
+    template_name = 'extras/dashboard/widgets/bookmarks.html'
+
+    class ConfigForm(WidgetConfigForm):
+        object_types = forms.MultipleChoiceField(
+            # TODO: Restrict the choices by FeatureQuery('bookmarks')
+            choices=get_content_type_labels,
+            required=False
+        )
+        order_by = forms.ChoiceField(
+            choices=BookmarkOrderingChoices
+        )
+        max_items = forms.IntegerField(
+            min_value=1,
+            required=False
+        )
+
+    def render(self, request):
+        from extras.models import Bookmark
+
+        bookmarks = Bookmark.objects.filter(user=request.user).order_by(self.config['order_by'])
+        if object_types := self.config.get('object_types'):
+            models = get_models_from_content_types(object_types)
+            conent_types = ContentType.objects.get_for_models(*models).values()
+            bookmarks = bookmarks.filter(object_type__in=conent_types)
+        if max_items := self.config.get('max_items'):
+            bookmarks = bookmarks[:max_items]
+
+        return render_to_string(self.template_name, {
+            'bookmarks': bookmarks,
+        })

+ 21 - 0
netbox/extras/filtersets.py

@@ -15,6 +15,7 @@ from .filters import TagFilter
 from .models import *
 
 __all__ = (
+    'BookmarkFilterSet',
     'ConfigContextFilterSet',
     'ConfigRevisionFilterSet',
     'ConfigTemplateFilterSet',
@@ -199,6 +200,26 @@ class SavedFilterFilterSet(BaseFilterSet):
         return queryset.filter(Q(enabled=False) | Q(Q(shared=False) & ~Q(user=user)))
 
 
+class BookmarkFilterSet(BaseFilterSet):
+    created = django_filters.DateTimeFilter()
+    object_type_id = MultiValueNumberFilter()
+    object_type = ContentTypeFilter()
+    user_id = django_filters.ModelMultipleChoiceFilter(
+        queryset=get_user_model().objects.all(),
+        label=_('User (ID)'),
+    )
+    user = django_filters.ModelMultipleChoiceFilter(
+        field_name='user__username',
+        queryset=get_user_model().objects.all(),
+        to_field_name='username',
+        label=_('User (name)'),
+    )
+
+    class Meta:
+        model = Bookmark
+        fields = ['id', 'object_id']
+
+
 class ImageAttachmentFilterSet(BaseFilterSet):
     q = django_filters.CharFilter(
         method='search',

+ 13 - 1
netbox/extras/forms/model_forms.py

@@ -14,7 +14,7 @@ from extras.utils import FeatureQuery
 from netbox.config import get_config, PARAMS
 from netbox.forms import NetBoxModelForm
 from tenancy.models import Tenant, TenantGroup
-from utilities.forms import BOOLEAN_WITH_BLANK_CHOICES, BootstrapMixin, add_blank_choice
+from utilities.forms import BootstrapMixin, add_blank_choice
 from utilities.forms.fields import (
     CommentField, ContentTypeChoiceField, ContentTypeMultipleChoiceField, DynamicModelMultipleChoiceField, JSONField,
     SlugField,
@@ -23,6 +23,7 @@ from virtualization.models import Cluster, ClusterGroup, ClusterType
 
 
 __all__ = (
+    'BookmarkForm',
     'ConfigContextForm',
     'ConfigRevisionForm',
     'ConfigTemplateForm',
@@ -169,6 +170,17 @@ class SavedFilterForm(BootstrapMixin, forms.ModelForm):
         super().__init__(*args, initial=initial, **kwargs)
 
 
+class BookmarkForm(BootstrapMixin, forms.ModelForm):
+    object_type = ContentTypeChoiceField(
+        queryset=ContentType.objects.all(),
+        limit_choices_to=FeatureQuery('bookmarks').get_query()
+    )
+
+    class Meta:
+        model = Bookmark
+        fields = ('object_type', 'object_id')
+
+
 class WebhookForm(BootstrapMixin, forms.ModelForm):
     content_types = ContentTypeMultipleChoiceField(
         queryset=ContentType.objects.all(),

+ 34 - 0
netbox/extras/migrations/0095_bookmarks.py

@@ -0,0 +1,34 @@
+# Generated by Django 4.1.9 on 2023-06-29 14:07
+
+from django.conf import settings
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+        ('contenttypes', '0002_remove_content_type_name'),
+        ('extras', '0094_tag_object_types'),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='Bookmark',
+            fields=[
+                ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
+                ('created', models.DateTimeField(auto_now_add=True)),
+                ('object_id', models.PositiveBigIntegerField()),
+                ('object_type', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='contenttypes.contenttype')),
+                ('user', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL)),
+            ],
+            options={
+                'ordering': ('created', 'pk'),
+            },
+        ),
+        migrations.AddConstraint(
+            model_name='bookmark',
+            constraint=models.UniqueConstraint(fields=('object_type', 'object_id', 'user'), name='extras_bookmark_unique_per_object_and_user'),
+        ),
+    ]

+ 39 - 1
netbox/extras/models/models.py

@@ -1,7 +1,6 @@
 import json
 import urllib.parse
 
-from django.conf import settings
 from django.contrib import admin
 from django.conf import settings
 from django.contrib.contenttypes.fields import GenericForeignKey
@@ -29,6 +28,7 @@ from utilities.querysets import RestrictedQuerySet
 from utilities.utils import clean_html, render_jinja2
 
 __all__ = (
+    'Bookmark',
     'ConfigRevision',
     'CustomLink',
     'ExportTemplate',
@@ -595,6 +595,44 @@ class JournalEntry(CustomFieldsMixin, CustomLinksMixin, TagsMixin, ExportTemplat
         return JournalEntryKindChoices.colors.get(self.kind)
 
 
+class Bookmark(models.Model):
+    """
+    An object bookmarked by a User.
+    """
+    created = models.DateTimeField(
+        auto_now_add=True
+    )
+    object_type = models.ForeignKey(
+        to=ContentType,
+        on_delete=models.PROTECT
+    )
+    object_id = models.PositiveBigIntegerField()
+    object = GenericForeignKey(
+        ct_field='object_type',
+        fk_field='object_id'
+    )
+    user = models.ForeignKey(
+        to=settings.AUTH_USER_MODEL,
+        on_delete=models.PROTECT
+    )
+
+    objects = RestrictedQuerySet.as_manager()
+
+    class Meta:
+        ordering = ('created', 'pk')
+        constraints = (
+            models.UniqueConstraint(
+                fields=('object_type', 'object_id', 'user'),
+                name='%(app_label)s_%(class)s_unique_per_object_and_user'
+            ),
+        )
+
+    def __str__(self):
+        if self.object:
+            return str(self.object)
+        return super().__str__()
+
+
 class ConfigRevision(models.Model):
     """
     An atomic revision of NetBox's configuration.

+ 16 - 0
netbox/extras/tables/tables.py

@@ -8,6 +8,7 @@ from netbox.tables import NetBoxTable, columns
 from .template_code import *
 
 __all__ = (
+    'BookmarkTable',
     'ConfigContextTable',
     'ConfigRevisionTable',
     'ConfigTemplateTable',
@@ -167,6 +168,21 @@ class SavedFilterTable(NetBoxTable):
         )
 
 
+class BookmarkTable(NetBoxTable):
+    object_type = columns.ContentTypeColumn()
+    object = tables.Column(
+        linkify=True
+    )
+    actions = columns.ActionsColumn(
+        actions=('delete',)
+    )
+
+    class Meta(NetBoxTable.Meta):
+        model = Bookmark
+        fields = ('pk', 'object', 'object_type', 'created')
+        default_columns = ('object', 'object_type', 'created')
+
+
 class WebhookTable(NetBoxTable):
     name = tables.Column(
         linkify=True

+ 52 - 0
netbox/extras/tests/test_api.py

@@ -268,6 +268,58 @@ class SavedFilterTest(APIViewTestCases.APIViewTestCase):
             savedfilter.content_types.set([site_ct])
 
 
+class BookmarkTest(
+    APIViewTestCases.GetObjectViewTestCase,
+    APIViewTestCases.ListObjectsViewTestCase,
+    APIViewTestCases.CreateObjectViewTestCase,
+    APIViewTestCases.DeleteObjectViewTestCase
+):
+    model = Bookmark
+    brief_fields = ['display', 'id', 'object_id', 'object_type', 'url']
+
+    @classmethod
+    def setUpTestData(cls):
+        sites = (
+            Site(name='Site 1', slug='site-1'),
+            Site(name='Site 2', slug='site-2'),
+            Site(name='Site 3', slug='site-3'),
+            Site(name='Site 4', slug='site-4'),
+            Site(name='Site 5', slug='site-5'),
+            Site(name='Site 6', slug='site-6'),
+        )
+        Site.objects.bulk_create(sites)
+
+    def setUp(self):
+        super().setUp()
+
+        sites = Site.objects.all()
+
+        bookmarks = (
+            Bookmark(object=sites[0], user=self.user),
+            Bookmark(object=sites[1], user=self.user),
+            Bookmark(object=sites[2], user=self.user),
+        )
+        Bookmark.objects.bulk_create(bookmarks)
+
+        self.create_data = [
+            {
+                'object_type': 'dcim.site',
+                'object_id': sites[3].pk,
+                'user': self.user.pk,
+            },
+            {
+                'object_type': 'dcim.site',
+                'object_id': sites[4].pk,
+                'user': self.user.pk,
+            },
+            {
+                'object_type': 'dcim.site',
+                'object_id': sites[5].pk,
+                'user': self.user.pk,
+            },
+        ]
+
+
 class ExportTemplateTest(APIViewTestCases.APIViewTestCase):
     model = ExportTemplate
     brief_fields = ['display', 'id', 'name', 'url']

+ 71 - 0
netbox/extras/tests/test_filtersets.py

@@ -365,6 +365,77 @@ class SavedFilterTestCase(TestCase, BaseFilterSetTests):
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
 
 
+class BookmarkTestCase(TestCase, BaseFilterSetTests):
+    queryset = Bookmark.objects.all()
+    filterset = BookmarkFilterSet
+
+    @classmethod
+    def setUpTestData(cls):
+        content_types = ContentType.objects.filter(model__in=['site', 'rack', 'device'])
+
+        users = (
+            User(username='User 1'),
+            User(username='User 2'),
+            User(username='User 3'),
+        )
+        User.objects.bulk_create(users)
+
+        sites = (
+            Site(name='Site 1', slug='site-1'),
+            Site(name='Site 2', slug='site-2'),
+            Site(name='Site 3', slug='site-3'),
+        )
+        Site.objects.bulk_create(sites)
+
+        tenants = (
+            Tenant(name='Tenant 1', slug='tenant-1'),
+            Tenant(name='Tenant 2', slug='tenant-2'),
+            Tenant(name='Tenant 3', slug='tenant-3'),
+        )
+        Tenant.objects.bulk_create(tenants)
+
+        bookmarks = (
+            Bookmark(
+                object=sites[0],
+                user=users[0],
+            ),
+            Bookmark(
+                object=sites[1],
+                user=users[1],
+            ),
+            Bookmark(
+                object=sites[2],
+                user=users[2],
+            ),
+            Bookmark(
+                object=tenants[0],
+                user=users[0],
+            ),
+            Bookmark(
+                object=tenants[1],
+                user=users[1],
+            ),
+            Bookmark(
+                object=tenants[2],
+                user=users[2],
+            ),
+        )
+        Bookmark.objects.bulk_create(bookmarks)
+
+    def test_object_type(self):
+        params = {'object_type': 'dcim.site'}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
+        params = {'object_type_id': [ContentType.objects.get_for_model(Site).pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
+
+    def test_user(self):
+        users = User.objects.filter(username__startswith='User')
+        params = {'user': [users[0].username, users[1].username]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
+        params = {'user_id': [users[0].pk, users[1].pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
+
+
 class ExportTemplateTestCase(TestCase, BaseFilterSetTests):
     queryset = ExportTemplate.objects.all()
     filterset = ExportTemplateFilterSet

+ 48 - 0
netbox/extras/tests/test_views.py

@@ -181,6 +181,54 @@ class SavedFilterTestCase(ViewTestCases.PrimaryObjectViewTestCase):
         }
 
 
+class BookmarkTestCase(
+    ViewTestCases.DeleteObjectViewTestCase,
+    ViewTestCases.ListObjectsViewTestCase,
+    ViewTestCases.BulkDeleteObjectsViewTestCase
+):
+    model = Bookmark
+
+    @classmethod
+    def setUpTestData(cls):
+        site_ct = ContentType.objects.get_for_model(Site)
+        sites = (
+            Site(name='Site 1', slug='site-1'),
+            Site(name='Site 2', slug='site-2'),
+            Site(name='Site 3', slug='site-3'),
+            Site(name='Site 4', slug='site-4'),
+        )
+        Site.objects.bulk_create(sites)
+
+        cls.form_data = {
+            'object_type': site_ct.pk,
+            'object_id': sites[3].pk,
+        }
+
+    def setUp(self):
+        super().setUp()
+
+        sites = Site.objects.all()
+        user = self.user
+
+        bookmarks = (
+            Bookmark(object=sites[0], user=user),
+            Bookmark(object=sites[1], user=user),
+            Bookmark(object=sites[2], user=user),
+        )
+        Bookmark.objects.bulk_create(bookmarks)
+
+    def _get_url(self, action, instance=None):
+        if action == 'list':
+            return reverse('users:bookmarks')
+        return super()._get_url(action, instance)
+
+    def test_list_objects_anonymous(self):
+        return
+
+    def test_list_objects_with_constrained_permission(self):
+        return
+
+
 class ExportTemplateTestCase(ViewTestCases.PrimaryObjectViewTestCase):
     model = ExportTemplate
 

+ 6 - 1
netbox/extras/urls.py

@@ -1,4 +1,4 @@
-from django.urls import include, path, re_path
+from django.urls import include, path
 
 from extras import views
 from utilities.urls import get_model_urls
@@ -40,6 +40,11 @@ urlpatterns = [
     path('saved-filters/delete/', views.SavedFilterBulkDeleteView.as_view(), name='savedfilter_bulk_delete'),
     path('saved-filters/<int:pk>/', include(get_model_urls('extras', 'savedfilter'))),
 
+    # Bookmarks
+    path('bookmarks/add/', views.BookmarkCreateView.as_view(), name='bookmark_add'),
+    path('bookmarks/delete/', views.BookmarkBulkDeleteView.as_view(), name='bookmark_bulk_delete'),
+    path('bookmarks/<int:pk>/', include(get_model_urls('extras', 'bookmark'))),
+
     # Webhooks
     path('webhooks/', views.WebhookListView.as_view(), name='webhook_list'),
     path('webhooks/add/', views.WebhookEditView.as_view(), name='webhook_add'),

+ 29 - 0
netbox/extras/views.py

@@ -237,6 +237,35 @@ class SavedFilterBulkDeleteView(SavedFilterMixin, generic.BulkDeleteView):
     table = tables.SavedFilterTable
 
 
+#
+# Bookmarks
+#
+
+class BookmarkCreateView(generic.ObjectEditView):
+    form = forms.BookmarkForm
+
+    def get_queryset(self, request):
+        return Bookmark.objects.filter(user=request.user)
+
+    def alter_object(self, obj, request, url_args, url_kwargs):
+        obj.user = request.user
+        return obj
+
+
+@register_model_view(Bookmark, 'delete')
+class BookmarkDeleteView(generic.ObjectDeleteView):
+
+    def get_queryset(self, request):
+        return Bookmark.objects.filter(user=request.user)
+
+
+class BookmarkBulkDeleteView(generic.BulkDeleteView):
+    table = tables.BookmarkTable
+
+    def get_queryset(self, request):
+        return Bookmark.objects.filter(user=request.user)
+
+
 #
 # Webhooks
 #

+ 1 - 0
netbox/netbox/models/__init__.py

@@ -18,6 +18,7 @@ __all__ = (
 
 
 class NetBoxFeatureSet(
+    BookmarksMixin,
     ChangeLoggingMixin,
     CustomFieldsMixin,
     CustomLinksMixin,

+ 16 - 0
netbox/netbox/models/features.py

@@ -22,6 +22,7 @@ from utilities.utils import serialize_object
 from utilities.views import register_model_view
 
 __all__ = (
+    'BookmarksMixin',
     'ChangeLoggingMixin',
     'CloningMixin',
     'CustomFieldsMixin',
@@ -304,6 +305,20 @@ class ExportTemplatesMixin(models.Model):
         abstract = True
 
 
+class BookmarksMixin(models.Model):
+    """
+    Enables support for user bookmarks.
+    """
+    bookmarks = GenericRelation(
+        to='extras.Bookmark',
+        content_type_field='object_type',
+        object_id_field='object_id'
+    )
+
+    class Meta:
+        abstract = True
+
+
 class JobsMixin(models.Model):
     """
     Enables support for job results.
@@ -480,6 +495,7 @@ class SyncedDataMixin(models.Model):
 
 
 FEATURES_MAP = {
+    'bookmarks': BookmarksMixin,
     'custom_fields': CustomFieldsMixin,
     'custom_links': CustomLinksMixin,
     'export_templates': ExportTemplatesMixin,

+ 9 - 0
netbox/templates/extras/dashboard/widgets/bookmarks.html

@@ -0,0 +1,9 @@
+{% if bookmarks %}
+  <div class="list-group list-group-flush">
+    {% for bookmark in bookmarks %}
+      <a href="{{ bookmark.object.get_absolute_url }}" class="list-group-item list-group-item-action">
+        {{ bookmark.object }}
+      </a>
+    {% endfor %}
+  </div>
+{% endif %}

+ 3 - 0
netbox/templates/generic/object.html

@@ -59,6 +59,9 @@ Context:
       {# Extra buttons #}
       {% block extra_controls %}{% endblock %}
 
+      {% if perms.extras.add_bookmark %}
+        {% bookmark_button object %}
+      {% endif %}
       {% if request.user|can_add:object %}
         {% clone_button object %}
       {% endif %}

+ 5 - 0
netbox/templates/inc/profile_button.html

@@ -23,6 +23,11 @@
           <i class="mdi mdi-account"></i> Profile
         </a>
       </li>
+      <li>
+        <a class="dropdown-item" href="{% url 'users:bookmarks' %}">
+          <i class="mdi mdi-bookmark"></i> Bookmarks
+        </a>
+      </li>
       <li>
         <a class="dropdown-item" href="{% url 'users:preferences' %}">
           <i class="mdi mdi-wrench"></i> Preferences

+ 3 - 0
netbox/templates/users/base.html

@@ -5,6 +5,9 @@
     <li role="presentation" class="nav-item">
       <a class="nav-link{% if active_tab == 'profile' %} active{% endif %}" href="{% url 'users:profile' %}">Profile</a>
     </li>
+    <li role="presentation" class="nav-item">
+      <a class="nav-link{% if active_tab == 'bookmarks' %} active{% endif %}" href="{% url 'users:bookmarks' %}">Bookmarks</a>
+    </li>
     <li role="presentation" class="nav-item">
       <a class="nav-link{% if active_tab == 'preferences' %} active{% endif %}" href="{% url 'users:preferences' %}">Preferences</a>
     </li>

+ 34 - 0
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 %}
+
+  <form method="post" class="form form-horizontal">
+    {% csrf_token %}
+    <input type="hidden" name="return_url" value="{% url 'users:bookmarks' %}" />
+
+    {# Table #}
+    <div class="row">
+      <div class="col col-md-12">
+        <div class="card">
+          <div class="card-body htmx-container table-responsive" id="object_list">
+            {% include 'htmx/table.html' %}
+          </div>
+        </div>
+      </div>
+    </div>
+
+    {# Form buttons #}
+    <div class="noprint bulk-buttons">
+      <div class="bulk-button-group">
+        {% if 'bulk_delete' in actions %}
+          {% bulk_delete_button model query_params=request.GET %}
+        {% endif %}
+      </div>
+    </div>
+  </form>
+{% endblock %}

+ 1 - 0
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'),
 

+ 20 - 2
netbox/users/views.py

@@ -15,10 +15,11 @@ 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.generic import ObjectListView
 from utilities.forms import ConfirmationForm
 from utilities.views import register_model_view
 from .forms import LoginForm, PasswordChangeForm, TokenForm, UserConfigForm
@@ -228,6 +229,23 @@ class ChangePasswordView(LoginRequiredMixin, View):
         })
 
 
+#
+# Bookmarks
+#
+
+class BookmarkListView(LoginRequiredMixin, 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
 #

+ 15 - 0
netbox/utilities/templates/buttons/bookmark.html

@@ -0,0 +1,15 @@
+<form action="{{ form_url }}?return_url={{ return_url }}" method="post">
+  {% csrf_token %}
+  {% for field, value in form_data.items %}
+    <input type="hidden" name="{{ field }}" value="{{ value }}" />
+  {% endfor %}
+  {% if bookmark %}
+    <button type="submit" class="btn btn-sm btn-info">
+      <i class="mdi mdi-bookmark-minus"></i> Unbookmark
+    </button>
+  {% else %}
+    <button type="submit" class="btn btn-sm btn-info">
+      <i class="mdi mdi-bookmark-check"></i> Bookmark
+    </button>
+  {% endif %}
+</form>

+ 33 - 1
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'))