diff --git a/netbox/extras/api/serializers.py b/netbox/extras/api/serializers.py index c71e840d5..5d2e9c332 100644 --- a/netbox/extras/api/serializers.py +++ b/netbox/extras/api/serializers.py @@ -31,6 +31,7 @@ from virtualization.models import Cluster, ClusterGroup, ClusterType from .nested_serializers import * __all__ = ( + 'BookmarkSerializer', 'ConfigContextSerializer', 'ConfigTemplateSerializer', 'ContentTypeSerializer', @@ -190,6 +191,29 @@ class SavedFilterSerializer(ValidatedModelSerializer): ] +# +# 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 80dc56ae1..6e610097f 100644 --- a/netbox/extras/api/urls.py +++ b/netbox/extras/api/urls.py @@ -12,6 +12,7 @@ router.register('custom-fields', views.CustomFieldViewSet) router.register('custom-links', views.CustomLinkViewSet) router.register('export-templates', views.ExportTemplateViewSet) router.register('saved-filters', views.SavedFilterViewSet) +router.register('bookmarks', views.BookmarkViewSet) router.register('tags', views.TagViewSet) router.register('image-attachments', views.ImageAttachmentViewSet) router.register('journal-entries', views.JournalEntryViewSet) diff --git a/netbox/extras/api/views.py b/netbox/extras/api/views.py index 3f796d7f8..3c7e6bfcc 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 acb0aa359..61e648194 100644 --- a/netbox/extras/filtersets.py +++ b/netbox/extras/filtersets.py @@ -15,6 +15,7 @@ from .filters import TagFilter from .models import * __all__ = ( + 'BookmarkFilterSet', 'ConfigContextFilterSet', 'ConfigRevisionFilterSet', 'ConfigTemplateFilterSet', @@ -199,6 +200,34 @@ class SavedFilterFilterSet(BaseFilterSet): 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 f8aa982bc..27dcaf4cb 100644 --- a/netbox/extras/forms/model_forms.py +++ b/netbox/extras/forms/model_forms.py @@ -14,7 +14,7 @@ from extras.utils import FeatureQuery from netbox.config import get_config, PARAMS from netbox.forms import NetBoxModelForm from tenancy.models import Tenant, TenantGroup -from utilities.forms import BOOLEAN_WITH_BLANK_CHOICES, BootstrapMixin, add_blank_choice +from utilities.forms import BootstrapMixin, add_blank_choice from utilities.forms.fields import ( CommentField, ContentTypeChoiceField, ContentTypeMultipleChoiceField, DynamicModelMultipleChoiceField, JSONField, SlugField, @@ -23,6 +23,7 @@ from virtualization.models import Cluster, ClusterGroup, ClusterType __all__ = ( + 'BookmarkForm', 'ConfigContextForm', 'ConfigRevisionForm', 'ConfigTemplateForm', @@ -169,6 +170,17 @@ class SavedFilterForm(BootstrapMixin, forms.ModelForm): super().__init__(*args, initial=initial, **kwargs) +class BookmarkForm(BootstrapMixin, forms.ModelForm): + object_type = ContentTypeChoiceField( + queryset=ContentType.objects.all(), + # limit_choices_to=FeatureQuery('bookmarks').get_query() + ) + + class Meta: + model = Bookmark + fields = ('object_type', 'object_id') + + class WebhookForm(BootstrapMixin, forms.ModelForm): content_types = ContentTypeMultipleChoiceField( queryset=ContentType.objects.all(), diff --git a/netbox/extras/migrations/0095_bookmarks.py b/netbox/extras/migrations/0095_bookmarks.py new file mode 100644 index 000000000..abc597d3c --- /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 0cbc7a1de..e5b81bce8 100644 --- a/netbox/extras/models/models.py +++ b/netbox/extras/models/models.py @@ -29,6 +29,7 @@ from utilities.querysets import RestrictedQuerySet from utilities.utils import clean_html, render_jinja2 __all__ = ( + 'Bookmark', 'ConfigRevision', 'CustomLink', 'ExportTemplate', @@ -595,6 +596,39 @@ class JournalEntry(CustomFieldsMixin, CustomLinksMixin, TagsMixin, ExportTemplat 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 35d53d1a6..6cb363c01 100644 --- a/netbox/extras/tables/tables.py +++ b/netbox/extras/tables/tables.py @@ -8,6 +8,7 @@ from netbox.tables import NetBoxTable, columns from .template_code import * __all__ = ( + 'BookmarkTable', 'ConfigContextTable', 'ConfigRevisionTable', 'ConfigTemplateTable', @@ -167,6 +168,21 @@ class SavedFilterTable(NetBoxTable): ) +class BookmarkTable(NetBoxTable): + object_type = columns.ContentTypeColumn() + object = tables.Column( + linkify=True + ) + actions = columns.ActionsColumn( + actions=('delete',) + ) + + class Meta(NetBoxTable.Meta): + model = Bookmark + fields = ('pk', 'object', 'object_type', 'created') + default_columns = ('object', 'object_type', 'created') + + class WebhookTable(NetBoxTable): name = tables.Column( linkify=True diff --git a/netbox/extras/urls.py b/netbox/extras/urls.py index b3909391a..2969f7334 100644 --- a/netbox/extras/urls.py +++ b/netbox/extras/urls.py @@ -40,6 +40,11 @@ urlpatterns = [ 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 9e02b5019..e7d3bae85 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 c0f679e4f..21ca0087b 100644 --- a/netbox/netbox/models/__init__.py +++ b/netbox/netbox/models/__init__.py @@ -18,6 +18,7 @@ __all__ = ( class NetBoxFeatureSet( + BookmarksMixin, ChangeLoggingMixin, CustomFieldsMixin, CustomLinksMixin, diff --git a/netbox/netbox/models/features.py b/netbox/netbox/models/features.py index 8d79dd6bc..b91403c94 100644 --- a/netbox/netbox/models/features.py +++ b/netbox/netbox/models/features.py @@ -22,6 +22,7 @@ from utilities.utils import serialize_object from utilities.views import register_model_view __all__ = ( + 'BookmarksMixin', 'ChangeLoggingMixin', 'CloningMixin', 'CustomFieldsMixin', @@ -304,6 +305,18 @@ class ExportTemplatesMixin(models.Model): 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 ebbeb2dfc..4ee950ac8 100644 --- a/netbox/templates/generic/object.html +++ b/netbox/templates/generic/object.html @@ -59,6 +59,9 @@ Context: {# 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 b63b25464..932b91275 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 58861ee90..e07e28ced 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 000000000..66f367a1c --- /dev/null +++ b/netbox/templates/users/bookmarks.html @@ -0,0 +1,34 @@ +{% extends 'users/base.html' %} +{% load buttons %} +{% load helpers %} +{% load render_table from django_tables2 %} + +{% block title %}Bookmarks{% endblock %} + +{% block content %} + +
    + {% 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 ed1c21c02..7cb1f3435 100644 --- a/netbox/users/urls.py +++ b/netbox/users/urls.py @@ -8,6 +8,7 @@ urlpatterns = [ # User path('profile/', views.ProfileView.as_view(), name='profile'), + path('bookmarks/', views.BookmarkListView.as_view(), name='bookmarks'), path('preferences/', views.UserConfigView.as_view(), name='preferences'), path('password/', views.ChangePasswordView.as_view(), name='change_password'), diff --git a/netbox/users/views.py b/netbox/users/views.py index a82620914..4dcdaebab 100644 --- a/netbox/users/views.py +++ b/netbox/users/views.py @@ -15,10 +15,11 @@ from django.views.decorators.debug import sensitive_post_parameters from django.views.generic import View from social_core.backends.utils import load_backends -from extras.models import ObjectChange -from extras.tables import ObjectChangeTable +from extras.models import Bookmark, ObjectChange +from extras.tables import BookmarkTable, ObjectChangeTable from netbox.authentication import get_auth_backend_display, get_saml_idps from netbox.config import get_config +from netbox.views.generic import ObjectListView from utilities.forms import ConfirmationForm from utilities.views import register_model_view from .forms import LoginForm, PasswordChangeForm, TokenForm, UserConfigForm @@ -228,6 +229,23 @@ class ChangePasswordView(LoginRequiredMixin, View): }) +# +# Bookmarks +# + +class BookmarkListView(LoginRequiredMixin, ObjectListView): + table = BookmarkTable + template_name = 'users/bookmarks.html' + + def get_queryset(self, request): + return Bookmark.objects.filter(user=request.user) + + def get_extra_context(self, request): + return { + 'active_tab': 'bookmarks', + } + + # # API tokens # diff --git a/netbox/utilities/templates/buttons/bookmark.html b/netbox/utilities/templates/buttons/bookmark.html new file mode 100644 index 000000000..b11d1e82e --- /dev/null +++ b/netbox/utilities/templates/buttons/bookmark.html @@ -0,0 +1,15 @@ +
    + {% 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 1556b29a0..828af3b43 100644 --- a/netbox/utilities/templatetags/buttons.py +++ b/netbox/utilities/templatetags/buttons.py @@ -2,11 +2,12 @@ from django import template from django.contrib.contenttypes.models import ContentType from django.urls import NoReverseMatch, reverse -from extras.models import ExportTemplate +from extras.models import Bookmark, ExportTemplate from utilities.utils import get_viewname, prepare_cloned_fields __all__ = ( 'add_button', + 'bookmark_button', 'bulk_delete_button', 'bulk_edit_button', 'clone_button', @@ -24,6 +25,37 @@ register = template.Library() # Instance buttons # +@register.inclusion_tag('buttons/bookmark.html', takes_context=True) +def bookmark_button(context, instance): + # Check if this user has already bookmarked the object + content_type = ContentType.objects.get_for_model(instance) + bookmark = Bookmark.objects.filter( + object_type=content_type, + object_id=instance.pk, + user=context['request'].user + ).first() + + # Compile form URL & data + if bookmark: + form_url = reverse('extras:bookmark_delete', kwargs={'pk': bookmark.pk}) + form_data = { + 'confirm': 'true', + } + else: + form_url = reverse('extras:bookmark_add') + form_data = { + 'object_type': content_type.pk, + 'object_id': instance.pk, + } + + return { + 'bookmark': bookmark, + 'form_url': form_url, + 'form_data': form_data, + 'return_url': instance.get_absolute_url(), + } + + @register.inclusion_tag('buttons/clone.html') def clone_button(instance): url = reverse(get_viewname(instance, 'add'))