From 38d27a6fee40303f6431ce29632214eb71afceff Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 26 Jun 2023 12:47:55 -0400 Subject: [PATCH 1/8] Initial work on #8248 --- netbox/extras/api/serializers.py | 24 +++++++++++++ netbox/extras/api/urls.py | 1 + netbox/extras/api/views.py | 11 ++++++ netbox/extras/filtersets.py | 29 +++++++++++++++ netbox/extras/forms/model_forms.py | 14 +++++++- netbox/extras/migrations/0095_bookmarks.py | 35 +++++++++++++++++++ netbox/extras/models/models.py | 34 ++++++++++++++++++ netbox/extras/tables/tables.py | 16 +++++++++ netbox/extras/urls.py | 5 +++ netbox/extras/views.py | 30 ++++++++++++++++ netbox/netbox/models/__init__.py | 1 + netbox/netbox/models/features.py | 13 +++++++ 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 +++++++++++++++++- 20 files changed, 326 insertions(+), 4 deletions(-) create mode 100644 netbox/extras/migrations/0095_bookmarks.py create mode 100644 netbox/templates/users/bookmarks.html create mode 100644 netbox/utilities/templates/buttons/bookmark.html diff --git a/netbox/extras/api/serializers.py b/netbox/extras/api/serializers.py index c71e840d55..5d2e9c3325 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,29 @@ class Meta: ] +# +# Bookmarks +# + +class BookmarkSerializer(ValidatedModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='extras-api:bookmark-detail') + object_type = ContentTypeField( + queryset=ContentType.objects.all() + ) + object = serializers.SerializerMethodField(read_only=True) + + class Meta: + model = Bookmark + fields = [ + 'id', 'url', 'display', 'object_type', 'object_id', 'object', 'created', 'last_updated', + ] + + @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/filtersets.py b/netbox/extras/filtersets.py index acb0aa359b..61e6481945 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,34 @@ def _usable(self, queryset, name, value): return queryset.filter(Q(enabled=False) | Q(Q(shared=False) & ~Q(user=user))) +class BookmarkFilterSet(BaseFilterSet): + q = django_filters.CharFilter( + method='search', + label=_('Search'), + ) + created = django_filters.DateTimeFilter() + 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_type_id', 'object_id'] + + # def search(self, queryset, name, value): + # if not value.strip(): + # return queryset + # return queryset.filter(name__icontains=value) + + 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..27dcaf4cb7 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..abc597d3cc --- /dev/null +++ b/netbox/extras/migrations/0095_bookmarks.py @@ -0,0 +1,35 @@ +# Generated by Django 4.1.9 on 2023-06-26 14:27 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('contenttypes', '0002_remove_content_type_name'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('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, null=True)), + ('last_updated', models.DateTimeField(auto_now=True, null=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..e5b81bce8e 100644 --- a/netbox/extras/models/models.py +++ b/netbox/extras/models/models.py @@ -29,6 +29,7 @@ from utilities.utils import clean_html, render_jinja2 __all__ = ( + 'Bookmark', 'ConfigRevision', 'CustomLink', 'ExportTemplate', @@ -595,6 +596,39 @@ def get_kind_color(self): return JournalEntryKindChoices.colors.get(self.kind) +class Bookmark(ChangeLoggedModel): + """ + An object bookmarked by a User. + """ + 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 + ) + + 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/urls.py b/netbox/extras/urls.py index b3909391a0..2969f7334f 100644 --- a/netbox/extras/urls.py +++ b/netbox/extras/urls.py @@ -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.BookmarkEditView.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..e7d3bae85a 100644 --- a/netbox/extras/views.py +++ b/netbox/extras/views.py @@ -237,6 +237,36 @@ class SavedFilterBulkDeleteView(SavedFilterMixin, generic.BulkDeleteView): table = tables.SavedFilterTable +# +# Bookmarks +# + +# @register_model_view(Bookmark, 'edit') +class BookmarkEditView(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..b91403c947 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,18 @@ class Meta: abstract = True +class BookmarksMixin(models.Model): + """ + Enables support for user bookmarks. + """ + images = GenericRelation( + to='extras.Bookmark' + ) + + class Meta: + abstract = True + + class JobsMixin(models.Model): """ Enables support for job results. diff --git a/netbox/templates/generic/object.html b/netbox/templates/generic/object.html index ebbeb2dfc1..4ee950ac8a 100644 --- a/netbox/templates/generic/object.html +++ b/netbox/templates/generic/object.html @@ -59,6 +59,9 @@ {# Extra buttons #} {% block extra_controls %}{% endblock %} + {% if request.user.is_authenticated %} + {% 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')) From d87e5b6d7f53d69f4475853f21b9ed7eef983656 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 27 Jun 2023 17:39:28 -0400 Subject: [PATCH 2/8] Add tests --- netbox/extras/api/nested_serializers.py | 9 ++++ netbox/extras/api/serializers.py | 3 +- netbox/extras/filtersets.py | 3 +- 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 | 4 +- netbox/extras/views.py | 3 +- netbox/templates/generic/object.html | 2 +- 9 files changed, 188 insertions(+), 7 deletions(-) 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 5d2e9c3325..8919f849d7 100644 --- a/netbox/extras/api/serializers.py +++ b/netbox/extras/api/serializers.py @@ -201,11 +201,12 @@ class BookmarkSerializer(ValidatedModelSerializer): queryset=ContentType.objects.all() ) object = serializers.SerializerMethodField(read_only=True) + user = NestedUserSerializer() class Meta: model = Bookmark fields = [ - 'id', 'url', 'display', 'object_type', 'object_id', 'object', 'created', 'last_updated', + 'id', 'url', 'display', 'object_type', 'object_id', 'object', 'user', 'created', 'last_updated', ] @extend_schema_field(serializers.JSONField(allow_null=True)) diff --git a/netbox/extras/filtersets.py b/netbox/extras/filtersets.py index 61e6481945..8f1782c8fa 100644 --- a/netbox/extras/filtersets.py +++ b/netbox/extras/filtersets.py @@ -206,6 +206,7 @@ class BookmarkFilterSet(BaseFilterSet): label=_('Search'), ) created = django_filters.DateTimeFilter() + object_type_id = MultiValueNumberFilter() object_type = ContentTypeFilter() user_id = django_filters.ModelMultipleChoiceFilter( queryset=get_user_model().objects.all(), @@ -220,7 +221,7 @@ class BookmarkFilterSet(BaseFilterSet): class Meta: model = Bookmark - fields = ['id', 'object_type_id', 'object_id'] + fields = ['id', 'object_id'] # def search(self, queryset, name, value): # if not value.strip(): 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 2969f7334f..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 @@ -41,7 +41,7 @@ path('saved-filters//', include(get_model_urls('extras', 'savedfilter'))), # Bookmarks - path('bookmarks/add/', views.BookmarkEditView.as_view(), name='bookmark_add'), + 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'))), diff --git a/netbox/extras/views.py b/netbox/extras/views.py index e7d3bae85a..e3ba9c0c38 100644 --- a/netbox/extras/views.py +++ b/netbox/extras/views.py @@ -241,8 +241,7 @@ class SavedFilterBulkDeleteView(SavedFilterMixin, generic.BulkDeleteView): # Bookmarks # -# @register_model_view(Bookmark, 'edit') -class BookmarkEditView(generic.ObjectEditView): +class BookmarkCreateView(generic.ObjectEditView): form = forms.BookmarkForm def get_queryset(self, request): diff --git a/netbox/templates/generic/object.html b/netbox/templates/generic/object.html index 4ee950ac8a..76ceb9f355 100644 --- a/netbox/templates/generic/object.html +++ b/netbox/templates/generic/object.html @@ -59,7 +59,7 @@ {# Extra buttons #} {% block extra_controls %}{% endblock %} - {% if request.user.is_authenticated %} + {% if perms.extras.add_bookmark %} {% bookmark_button object %} {% endif %} {% if request.user|can_add:object %} From 029dc8e1bf5fdd3872da9166189d5863c7e2507f Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 28 Jun 2023 08:04:32 -0400 Subject: [PATCH 3/8] Fix tests --- netbox/extras/filtersets.py | 9 --------- netbox/netbox/models/features.py | 4 +++- 2 files changed, 3 insertions(+), 10 deletions(-) diff --git a/netbox/extras/filtersets.py b/netbox/extras/filtersets.py index 8f1782c8fa..ef094c2d07 100644 --- a/netbox/extras/filtersets.py +++ b/netbox/extras/filtersets.py @@ -201,10 +201,6 @@ def _usable(self, queryset, name, value): class BookmarkFilterSet(BaseFilterSet): - q = django_filters.CharFilter( - method='search', - label=_('Search'), - ) created = django_filters.DateTimeFilter() object_type_id = MultiValueNumberFilter() object_type = ContentTypeFilter() @@ -223,11 +219,6 @@ class Meta: model = Bookmark fields = ['id', 'object_id'] - # def search(self, queryset, name, value): - # if not value.strip(): - # return queryset - # return queryset.filter(name__icontains=value) - class ImageAttachmentFilterSet(BaseFilterSet): q = django_filters.CharFilter( diff --git a/netbox/netbox/models/features.py b/netbox/netbox/models/features.py index b91403c947..3fd1d83699 100644 --- a/netbox/netbox/models/features.py +++ b/netbox/netbox/models/features.py @@ -310,7 +310,9 @@ class BookmarksMixin(models.Model): Enables support for user bookmarks. """ images = GenericRelation( - to='extras.Bookmark' + to='extras.Bookmark', + content_type_field='object_type', + object_id_field='object_id' ) class Meta: From 5f5ac03f68be0f45d42746ba809ac100076fe366 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 28 Jun 2023 08:05:04 -0400 Subject: [PATCH 4/8] Add feature query for bookmarks --- netbox/extras/api/serializers.py | 2 +- netbox/extras/forms/model_forms.py | 2 +- netbox/netbox/models/features.py | 1 + 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/netbox/extras/api/serializers.py b/netbox/extras/api/serializers.py index 8919f849d7..d551e1e8bd 100644 --- a/netbox/extras/api/serializers.py +++ b/netbox/extras/api/serializers.py @@ -198,7 +198,7 @@ class Meta: class BookmarkSerializer(ValidatedModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='extras-api:bookmark-detail') object_type = ContentTypeField( - queryset=ContentType.objects.all() + queryset=ContentType.objects.filter(FeatureQuery('bookmarks').get_query()), ) object = serializers.SerializerMethodField(read_only=True) user = NestedUserSerializer() diff --git a/netbox/extras/forms/model_forms.py b/netbox/extras/forms/model_forms.py index 27dcaf4cb7..354d2a51ae 100644 --- a/netbox/extras/forms/model_forms.py +++ b/netbox/extras/forms/model_forms.py @@ -173,7 +173,7 @@ def __init__(self, *args, initial=None, **kwargs): class BookmarkForm(BootstrapMixin, forms.ModelForm): object_type = ContentTypeChoiceField( queryset=ContentType.objects.all(), - # limit_choices_to=FeatureQuery('bookmarks').get_query() + limit_choices_to=FeatureQuery('bookmarks').get_query() ) class Meta: diff --git a/netbox/netbox/models/features.py b/netbox/netbox/models/features.py index 3fd1d83699..0928e3f905 100644 --- a/netbox/netbox/models/features.py +++ b/netbox/netbox/models/features.py @@ -495,6 +495,7 @@ def sync_data(self): FEATURES_MAP = { + 'bookmarks': BookmarksMixin, 'custom_fields': CustomFieldsMixin, 'custom_links': CustomLinksMixin, 'export_templates': ExportTemplatesMixin, From 1fee71d67d2b9bbfcda4c9264d832e00f5e13aa2 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 28 Jun 2023 09:33:55 -0400 Subject: [PATCH 5/8] Add BookmarksWidget --- netbox/extras/choices.py | 17 +++++++- netbox/extras/dashboard/widgets.py | 41 +++++++++++++++++++ .../extras/dashboard/widgets/bookmarks.html | 9 ++++ 3 files changed, 66 insertions(+), 1 deletion(-) create mode 100644 netbox/templates/extras/dashboard/widgets/bookmarks.html 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/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 %} From 9874c52577116216536e18c946b1ac108c319767 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 29 Jun 2023 07:55:40 -0400 Subject: [PATCH 6/8] Correct generic relation name --- netbox/netbox/models/features.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/netbox/models/features.py b/netbox/netbox/models/features.py index 0928e3f905..e07857145a 100644 --- a/netbox/netbox/models/features.py +++ b/netbox/netbox/models/features.py @@ -309,7 +309,7 @@ class BookmarksMixin(models.Model): """ Enables support for user bookmarks. """ - images = GenericRelation( + bookmarks = GenericRelation( to='extras.Bookmark', content_type_field='object_type', object_id_field='object_id' From c9f513ce03fccf9f3ee558424638f4e080992413 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 29 Jun 2023 09:28:13 -0400 Subject: [PATCH 7/8] Add docs for bookmarks --- docs/features/customization.md | 4 ++++ docs/models/extras/bookmark.md | 13 +++++++++++++ mkdocs.yml | 1 + 3 files changed, 18 insertions(+) create mode 100644 docs/models/extras/bookmark.md 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' From c23e3eab344f50374eb318097bb69ee5d7848d3d Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 29 Jun 2023 10:13:17 -0400 Subject: [PATCH 8/8] Remove inheritance from ChangeLoggedModel --- netbox/extras/api/serializers.py | 2 +- netbox/extras/migrations/0095_bookmarks.py | 7 +++---- netbox/extras/models/models.py | 8 ++++++-- 3 files changed, 10 insertions(+), 7 deletions(-) diff --git a/netbox/extras/api/serializers.py b/netbox/extras/api/serializers.py index d551e1e8bd..f28a5c411a 100644 --- a/netbox/extras/api/serializers.py +++ b/netbox/extras/api/serializers.py @@ -206,7 +206,7 @@ class BookmarkSerializer(ValidatedModelSerializer): class Meta: model = Bookmark fields = [ - 'id', 'url', 'display', 'object_type', 'object_id', 'object', 'user', 'created', 'last_updated', + 'id', 'url', 'display', 'object_type', 'object_id', 'object', 'user', 'created', ] @extend_schema_field(serializers.JSONField(allow_null=True)) diff --git a/netbox/extras/migrations/0095_bookmarks.py b/netbox/extras/migrations/0095_bookmarks.py index abc597d3cc..54c14c496f 100644 --- a/netbox/extras/migrations/0095_bookmarks.py +++ b/netbox/extras/migrations/0095_bookmarks.py @@ -1,4 +1,4 @@ -# Generated by Django 4.1.9 on 2023-06-26 14:27 +# Generated by Django 4.1.9 on 2023-06-29 14:07 from django.conf import settings from django.db import migrations, models @@ -8,8 +8,8 @@ class Migration(migrations.Migration): dependencies = [ - ('contenttypes', '0002_remove_content_type_name'), migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('contenttypes', '0002_remove_content_type_name'), ('extras', '0094_tag_object_types'), ] @@ -18,8 +18,7 @@ class Migration(migrations.Migration): name='Bookmark', fields=[ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)), - ('created', models.DateTimeField(auto_now_add=True, null=True)), - ('last_updated', models.DateTimeField(auto_now=True, null=True)), + ('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)), diff --git a/netbox/extras/models/models.py b/netbox/extras/models/models.py index e5b81bce8e..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 @@ -596,10 +595,13 @@ def get_kind_color(self): return JournalEntryKindChoices.colors.get(self.kind) -class Bookmark(ChangeLoggedModel): +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 @@ -614,6 +616,8 @@ class Bookmark(ChangeLoggedModel): on_delete=models.PROTECT ) + objects = RestrictedQuerySet.as_manager() + class Meta: ordering = ('created', 'pk') constraints = (