diff --git a/docs/features/customization.md b/docs/features/customization.md index abce4bcba20..1fbace3c5b7 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 00000000000..1fd006be928 --- /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 6be33d592c6..cde4a0acdb6 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 29ef679437e..4271e174824 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 c71e840d552..f28a5c411ad 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 80dc56ae11d..6e610097fd9 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 3f796d7f88d..3c7e6bfcc53 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 63bdbf7dbe0..a8dc40bf0b5 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 b3a4d090c63..3b9ce6c46d8 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 acb0aa359b3..ef094c2d075 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 f8aa982bc2d..354d2a51ae0 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 00000000000..54c14c496f8 --- /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 0cbc7a1def4..20bf879039d 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 35d53d1a6ce..6cb363c01ba 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 4c48aa73e84..e09d4de78bb 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 7dff14cc056..b4b21624457 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 3dcb9087592..57efc5be741 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 b3909391a0f..086537b991c 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 9e02b50193a..e3ba9c0c38a 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 c0f679e4fbe..21ca0087b1f 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 8d79dd6bc6c..e07857145a5 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 00000000000..2189cc55f3a --- /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 ebbeb2dfc1d..76ceb9f355a 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 b63b2546416..932b91275e9 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 58861ee90af..e07e28ced6c 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 00000000000..66f367a1c99 --- /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 ed1c21c024b..7cb1f343565 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 a82620914ad..4dcdaebab3a 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 00000000000..b11d1e82e7c --- /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 1556b29a034..828af3b4397 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'))