From 6e222f8dce8dcef928729ea0f1e846cc8fe6ad05 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 29 Jun 2023 14:36:11 -0400 Subject: [PATCH] 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 --- docs/features/customization.md | 4 ++ docs/models/extras/bookmark.md | 13 ++++ mkdocs.yml | 1 + netbox/extras/api/nested_serializers.py | 9 +++ netbox/extras/api/serializers.py | 25 +++++++ netbox/extras/api/urls.py | 1 + netbox/extras/api/views.py | 11 +++ netbox/extras/choices.py | 17 ++++- netbox/extras/dashboard/widgets.py | 41 +++++++++++ netbox/extras/filtersets.py | 21 ++++++ netbox/extras/forms/model_forms.py | 14 +++- netbox/extras/migrations/0095_bookmarks.py | 34 +++++++++ netbox/extras/models/models.py | 40 ++++++++++- netbox/extras/tables/tables.py | 16 +++++ netbox/extras/tests/test_api.py | 52 ++++++++++++++ netbox/extras/tests/test_filtersets.py | 71 +++++++++++++++++++ netbox/extras/tests/test_views.py | 48 +++++++++++++ netbox/extras/urls.py | 7 +- netbox/extras/views.py | 29 ++++++++ netbox/netbox/models/__init__.py | 1 + netbox/netbox/models/features.py | 16 +++++ .../extras/dashboard/widgets/bookmarks.html | 9 +++ netbox/templates/generic/object.html | 3 + netbox/templates/inc/profile_button.html | 5 ++ netbox/templates/users/base.html | 3 + netbox/templates/users/bookmarks.html | 34 +++++++++ netbox/users/urls.py | 1 + netbox/users/views.py | 22 +++++- .../utilities/templates/buttons/bookmark.html | 15 ++++ netbox/utilities/templatetags/buttons.py | 34 ++++++++- 30 files changed, 590 insertions(+), 7 deletions(-) create mode 100644 docs/models/extras/bookmark.md create mode 100644 netbox/extras/migrations/0095_bookmarks.py create mode 100644 netbox/templates/extras/dashboard/widgets/bookmarks.html create mode 100644 netbox/templates/users/bookmarks.html create mode 100644 netbox/utilities/templates/buttons/bookmark.html diff --git a/docs/features/customization.md b/docs/features/customization.md index abce4bcba2..1fbace3c5b 100644 --- a/docs/features/customization.md +++ b/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. diff --git a/docs/models/extras/bookmark.md b/docs/models/extras/bookmark.md new file mode 100644 index 0000000000..1fd006be92 --- /dev/null +++ b/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. diff --git a/mkdocs.yml b/mkdocs.yml index 6be33d592c..cde4a0acdb 100644 --- a/mkdocs.yml +++ b/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' diff --git a/netbox/extras/api/nested_serializers.py b/netbox/extras/api/nested_serializers.py index 29ef679437..4271e17482 100644 --- a/netbox/extras/api/nested_serializers.py +++ b/netbox/extras/api/nested_serializers.py @@ -4,6 +4,7 @@ from netbox.api.serializers import NestedTagSerializer, WritableNestedSerializer __all__ = [ + 'NestedBookmarkSerializer', 'NestedConfigContextSerializer', 'NestedConfigTemplateSerializer', 'NestedCustomFieldSerializer', @@ -73,6 +74,14 @@ class Meta: 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') diff --git a/netbox/extras/api/serializers.py b/netbox/extras/api/serializers.py index c71e840d55..f28a5c411a 100644 --- a/netbox/extras/api/serializers.py +++ b/netbox/extras/api/serializers.py @@ -31,6 +31,7 @@ from .nested_serializers import * __all__ = ( + 'BookmarkSerializer', 'ConfigContextSerializer', 'ConfigTemplateSerializer', 'ContentTypeSerializer', @@ -190,6 +191,30 @@ class Meta: ] +# +# 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 # diff --git a/netbox/extras/api/urls.py b/netbox/extras/api/urls.py index 80dc56ae11..6e610097fd 100644 --- a/netbox/extras/api/urls.py +++ b/netbox/extras/api/urls.py @@ -12,6 +12,7 @@ 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) diff --git a/netbox/extras/api/views.py b/netbox/extras/api/views.py index 3f796d7f88..3c7e6bfcc5 100644 --- a/netbox/extras/api/views.py +++ b/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 # diff --git a/netbox/extras/choices.py b/netbox/extras/choices.py index 63bdbf7dbe..a8dc40bf0b 100644 --- a/netbox/extras/choices.py +++ b/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): diff --git a/netbox/extras/dashboard/widgets.py b/netbox/extras/dashboard/widgets.py index b3a4d090c6..3b9ce6c46d 100644 --- a/netbox/extras/dashboard/widgets.py +++ b/netbox/extras/dashboard/widgets.py @@ -15,6 +15,7 @@ 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 .utils import register_widget __all__ = ( + 'BookmarksWidget', 'DashboardWidget', 'NoteWidget', 'ObjectCountsWidget', @@ -318,3 +320,42 @@ def get_feed(self): 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, + }) diff --git a/netbox/extras/filtersets.py b/netbox/extras/filtersets.py index acb0aa359b..ef094c2d07 100644 --- a/netbox/extras/filtersets.py +++ b/netbox/extras/filtersets.py @@ -15,6 +15,7 @@ from .models import * __all__ = ( + 'BookmarkFilterSet', 'ConfigContextFilterSet', 'ConfigRevisionFilterSet', 'ConfigTemplateFilterSet', @@ -199,6 +200,26 @@ def _usable(self, queryset, name, value): 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', diff --git a/netbox/extras/forms/model_forms.py b/netbox/extras/forms/model_forms.py index f8aa982bc2..354d2a51ae 100644 --- a/netbox/extras/forms/model_forms.py +++ b/netbox/extras/forms/model_forms.py @@ -14,7 +14,7 @@ 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 @@ __all__ = ( + 'BookmarkForm', 'ConfigContextForm', 'ConfigRevisionForm', 'ConfigTemplateForm', @@ -169,6 +170,17 @@ def __init__(self, *args, initial=None, **kwargs): 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(), diff --git a/netbox/extras/migrations/0095_bookmarks.py b/netbox/extras/migrations/0095_bookmarks.py new file mode 100644 index 0000000000..54c14c496f --- /dev/null +++ b/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'), + ), + ] diff --git a/netbox/extras/models/models.py b/netbox/extras/models/models.py index 0cbc7a1def..20bf879039 100644 --- a/netbox/extras/models/models.py +++ b/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.utils import clean_html, render_jinja2 __all__ = ( + 'Bookmark', 'ConfigRevision', 'CustomLink', 'ExportTemplate', @@ -595,6 +595,44 @@ def get_kind_color(self): 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. diff --git a/netbox/extras/tables/tables.py b/netbox/extras/tables/tables.py index 35d53d1a6c..6cb363c01b 100644 --- a/netbox/extras/tables/tables.py +++ b/netbox/extras/tables/tables.py @@ -8,6 +8,7 @@ from .template_code import * __all__ = ( + 'BookmarkTable', 'ConfigContextTable', 'ConfigRevisionTable', 'ConfigTemplateTable', @@ -167,6 +168,21 @@ class Meta(NetBoxTable.Meta): ) +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 diff --git a/netbox/extras/tests/test_api.py b/netbox/extras/tests/test_api.py index 4c48aa73e8..e09d4de78b 100644 --- a/netbox/extras/tests/test_api.py +++ b/netbox/extras/tests/test_api.py @@ -268,6 +268,58 @@ def setUpTestData(cls): 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'] diff --git a/netbox/extras/tests/test_filtersets.py b/netbox/extras/tests/test_filtersets.py index 7dff14cc05..b4b2162445 100644 --- a/netbox/extras/tests/test_filtersets.py +++ b/netbox/extras/tests/test_filtersets.py @@ -365,6 +365,77 @@ def test_usable(self): 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 diff --git a/netbox/extras/tests/test_views.py b/netbox/extras/tests/test_views.py index 3dcb908759..57efc5be74 100644 --- a/netbox/extras/tests/test_views.py +++ b/netbox/extras/tests/test_views.py @@ -181,6 +181,54 @@ def setUpTestData(cls): } +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 diff --git a/netbox/extras/urls.py b/netbox/extras/urls.py index b3909391a0..086537b991 100644 --- a/netbox/extras/urls.py +++ b/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 @@ path('saved-filters/delete/', views.SavedFilterBulkDeleteView.as_view(), name='savedfilter_bulk_delete'), path('saved-filters//', 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//', 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'), diff --git a/netbox/extras/views.py b/netbox/extras/views.py index 9e02b50193..e3ba9c0c38 100644 --- a/netbox/extras/views.py +++ b/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 # diff --git a/netbox/netbox/models/__init__.py b/netbox/netbox/models/__init__.py index c0f679e4fb..21ca0087b1 100644 --- a/netbox/netbox/models/__init__.py +++ b/netbox/netbox/models/__init__.py @@ -18,6 +18,7 @@ class NetBoxFeatureSet( + BookmarksMixin, ChangeLoggingMixin, CustomFieldsMixin, CustomLinksMixin, diff --git a/netbox/netbox/models/features.py b/netbox/netbox/models/features.py index 8d79dd6bc6..e07857145a 100644 --- a/netbox/netbox/models/features.py +++ b/netbox/netbox/models/features.py @@ -22,6 +22,7 @@ from utilities.views import register_model_view __all__ = ( + 'BookmarksMixin', 'ChangeLoggingMixin', 'CloningMixin', 'CustomFieldsMixin', @@ -304,6 +305,20 @@ class Meta: 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 @@ def sync_data(self): FEATURES_MAP = { + 'bookmarks': BookmarksMixin, 'custom_fields': CustomFieldsMixin, 'custom_links': CustomLinksMixin, 'export_templates': ExportTemplatesMixin, diff --git a/netbox/templates/extras/dashboard/widgets/bookmarks.html b/netbox/templates/extras/dashboard/widgets/bookmarks.html new file mode 100644 index 0000000000..2189cc55f3 --- /dev/null +++ b/netbox/templates/extras/dashboard/widgets/bookmarks.html @@ -0,0 +1,9 @@ +{% if bookmarks %} +
+ {% for bookmark in bookmarks %} + + {{ bookmark.object }} + + {% endfor %} +
+{% endif %} diff --git a/netbox/templates/generic/object.html b/netbox/templates/generic/object.html index ebbeb2dfc1..76ceb9f355 100644 --- a/netbox/templates/generic/object.html +++ b/netbox/templates/generic/object.html @@ -59,6 +59,9 @@ {# 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 %} diff --git a/netbox/templates/inc/profile_button.html b/netbox/templates/inc/profile_button.html index b63b254641..932b91275e 100644 --- a/netbox/templates/inc/profile_button.html +++ b/netbox/templates/inc/profile_button.html @@ -23,6 +23,11 @@ Profile +
  • + + Bookmarks + +
  • Preferences diff --git a/netbox/templates/users/base.html b/netbox/templates/users/base.html index 58861ee90a..e07e28ced6 100644 --- a/netbox/templates/users/base.html +++ b/netbox/templates/users/base.html @@ -5,6 +5,9 @@
  • + diff --git a/netbox/templates/users/bookmarks.html b/netbox/templates/users/bookmarks.html new file mode 100644 index 0000000000..66f367a1c9 --- /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 %} + +
    + {% csrf_token %} + + + {# Table #} +
    +
    +
    +
    + {% include 'htmx/table.html' %} +
    +
    +
    +
    + + {# Form buttons #} +
    +
    + {% if 'bulk_delete' in actions %} + {% bulk_delete_button model query_params=request.GET %} + {% endif %} +
    +
    +
    +{% endblock %} diff --git a/netbox/users/urls.py b/netbox/users/urls.py index ed1c21c024..7cb1f34356 100644 --- a/netbox/users/urls.py +++ b/netbox/users/urls.py @@ -8,6 +8,7 @@ # 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 a82620914a..4dcdaebab3 100644 --- a/netbox/users/views.py +++ b/netbox/users/views.py @@ -15,10 +15,11 @@ 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 @@ def post(self, request): }) +# +# 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 # diff --git a/netbox/utilities/templates/buttons/bookmark.html b/netbox/utilities/templates/buttons/bookmark.html new file mode 100644 index 0000000000..b11d1e82e7 --- /dev/null +++ b/netbox/utilities/templates/buttons/bookmark.html @@ -0,0 +1,15 @@ +
    + {% csrf_token %} + {% for field, value in form_data.items %} + + {% endfor %} + {% if bookmark %} + + {% else %} + + {% endif %} +
    diff --git a/netbox/utilities/templatetags/buttons.py b/netbox/utilities/templatetags/buttons.py index 1556b29a03..828af3b439 100644 --- a/netbox/utilities/templatetags/buttons.py +++ b/netbox/utilities/templatetags/buttons.py @@ -2,11 +2,12 @@ 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 @@ # 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'))