Skip to content

Commit 6e222f8

Browse files
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
1 parent 1056e51 commit 6e222f8

File tree

30 files changed

+590
-7
lines changed

30 files changed

+590
-7
lines changed

docs/features/customization.md

+4
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,10 @@ The `tag` filter can be specified multiple times to match only objects which hav
1818
GET /api/dcim/devices/?tag=monitored&tag=deprecated
1919
```
2020

21+
## Bookmarks
22+
23+
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.
24+
2125
## Custom Fields
2226

2327
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.

docs/models/extras/bookmark.md

+13
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
# Bookmarks
2+
3+
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.
4+
5+
## Fields
6+
7+
### User
8+
9+
The user to whom the bookmark belongs.
10+
11+
### Object
12+
13+
The bookmarked object.

mkdocs.yml

+1
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,7 @@ nav:
206206
- VirtualChassis: 'models/dcim/virtualchassis.md'
207207
- VirtualDeviceContext: 'models/dcim/virtualdevicecontext.md'
208208
- Extras:
209+
- Bookmark: 'models/extras/bookmark.md'
209210
- Branch: 'models/extras/branch.md'
210211
- ConfigContext: 'models/extras/configcontext.md'
211212
- ConfigTemplate: 'models/extras/configtemplate.md'

netbox/extras/api/nested_serializers.py

+9
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from netbox.api.serializers import NestedTagSerializer, WritableNestedSerializer
55

66
__all__ = [
7+
'NestedBookmarkSerializer',
78
'NestedConfigContextSerializer',
89
'NestedConfigTemplateSerializer',
910
'NestedCustomFieldSerializer',
@@ -73,6 +74,14 @@ class Meta:
7374
fields = ['id', 'url', 'display', 'name', 'slug']
7475

7576

77+
class NestedBookmarkSerializer(WritableNestedSerializer):
78+
url = serializers.HyperlinkedIdentityField(view_name='extras-api:bookmark-detail')
79+
80+
class Meta:
81+
model = models.Bookmark
82+
fields = ['id', 'url', 'display', 'object_id', 'object_type']
83+
84+
7685
class NestedImageAttachmentSerializer(WritableNestedSerializer):
7786
url = serializers.HyperlinkedIdentityField(view_name='extras-api:imageattachment-detail')
7887

netbox/extras/api/serializers.py

+25
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
from .nested_serializers import *
3232

3333
__all__ = (
34+
'BookmarkSerializer',
3435
'ConfigContextSerializer',
3536
'ConfigTemplateSerializer',
3637
'ContentTypeSerializer',
@@ -190,6 +191,30 @@ class Meta:
190191
]
191192

192193

194+
#
195+
# Bookmarks
196+
#
197+
198+
class BookmarkSerializer(ValidatedModelSerializer):
199+
url = serializers.HyperlinkedIdentityField(view_name='extras-api:bookmark-detail')
200+
object_type = ContentTypeField(
201+
queryset=ContentType.objects.filter(FeatureQuery('bookmarks').get_query()),
202+
)
203+
object = serializers.SerializerMethodField(read_only=True)
204+
user = NestedUserSerializer()
205+
206+
class Meta:
207+
model = Bookmark
208+
fields = [
209+
'id', 'url', 'display', 'object_type', 'object_id', 'object', 'user', 'created',
210+
]
211+
212+
@extend_schema_field(serializers.JSONField(allow_null=True))
213+
def get_object(self, instance):
214+
serializer = get_serializer_for_model(instance.object, prefix=NESTED_SERIALIZER_PREFIX)
215+
return serializer(instance.object, context={'request': self.context['request']}).data
216+
217+
193218
#
194219
# Tags
195220
#

netbox/extras/api/urls.py

+1
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
router.register('custom-links', views.CustomLinkViewSet)
1313
router.register('export-templates', views.ExportTemplateViewSet)
1414
router.register('saved-filters', views.SavedFilterViewSet)
15+
router.register('bookmarks', views.BookmarkViewSet)
1516
router.register('tags', views.TagViewSet)
1617
router.register('image-attachments', views.ImageAttachmentViewSet)
1718
router.register('journal-entries', views.JournalEntryViewSet)

netbox/extras/api/views.py

+11
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,17 @@ class SavedFilterViewSet(NetBoxModelViewSet):
9393
filterset_class = filtersets.SavedFilterFilterSet
9494

9595

96+
#
97+
# Bookmarks
98+
#
99+
100+
class BookmarkViewSet(NetBoxModelViewSet):
101+
metadata_class = ContentTypeMetadata
102+
queryset = Bookmark.objects.all()
103+
serializer_class = serializers.BookmarkSerializer
104+
filterset_class = filtersets.BookmarkFilterSet
105+
106+
96107
#
97108
# Tags
98109
#

netbox/extras/choices.py

+16-1
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,21 @@ class CustomLinkButtonClassChoices(ButtonColorChoices):
7979
(LINK, 'Link'),
8080
)
8181

82+
83+
#
84+
# Bookmarks
85+
#
86+
87+
class BookmarkOrderingChoices(ChoiceSet):
88+
89+
ORDERING_NEWEST = '-created'
90+
ORDERING_OLDEST = 'created'
91+
92+
CHOICES = (
93+
(ORDERING_NEWEST, 'Newest'),
94+
(ORDERING_OLDEST, 'Oldest'),
95+
)
96+
8297
#
8398
# ObjectChanges
8499
#
@@ -98,7 +113,7 @@ class ObjectChangeActionChoices(ChoiceSet):
98113

99114

100115
#
101-
# Jounral entries
116+
# Journal entries
102117
#
103118

104119
class JournalEntryKindChoices(ChoiceSet):

netbox/extras/dashboard/widgets.py

+41
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
from django.urls import NoReverseMatch, resolve, reverse
1616
from django.utils.translation import gettext as _
1717

18+
from extras.choices import BookmarkOrderingChoices
1819
from extras.utils import FeatureQuery
1920
from utilities.forms import BootstrapMixin
2021
from utilities.permissions import get_permission_for_model
@@ -23,6 +24,7 @@
2324
from .utils import register_widget
2425

2526
__all__ = (
27+
'BookmarksWidget',
2628
'DashboardWidget',
2729
'NoteWidget',
2830
'ObjectCountsWidget',
@@ -318,3 +320,42 @@ def get_feed(self):
318320
return {
319321
'feed': feed,
320322
}
323+
324+
325+
@register_widget
326+
class BookmarksWidget(DashboardWidget):
327+
default_title = _('Bookmarks')
328+
default_config = {
329+
'order_by': BookmarkOrderingChoices.ORDERING_NEWEST,
330+
}
331+
description = _('Show your personal bookmarks')
332+
template_name = 'extras/dashboard/widgets/bookmarks.html'
333+
334+
class ConfigForm(WidgetConfigForm):
335+
object_types = forms.MultipleChoiceField(
336+
# TODO: Restrict the choices by FeatureQuery('bookmarks')
337+
choices=get_content_type_labels,
338+
required=False
339+
)
340+
order_by = forms.ChoiceField(
341+
choices=BookmarkOrderingChoices
342+
)
343+
max_items = forms.IntegerField(
344+
min_value=1,
345+
required=False
346+
)
347+
348+
def render(self, request):
349+
from extras.models import Bookmark
350+
351+
bookmarks = Bookmark.objects.filter(user=request.user).order_by(self.config['order_by'])
352+
if object_types := self.config.get('object_types'):
353+
models = get_models_from_content_types(object_types)
354+
conent_types = ContentType.objects.get_for_models(*models).values()
355+
bookmarks = bookmarks.filter(object_type__in=conent_types)
356+
if max_items := self.config.get('max_items'):
357+
bookmarks = bookmarks[:max_items]
358+
359+
return render_to_string(self.template_name, {
360+
'bookmarks': bookmarks,
361+
})

netbox/extras/filtersets.py

+21
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
from .models import *
1616

1717
__all__ = (
18+
'BookmarkFilterSet',
1819
'ConfigContextFilterSet',
1920
'ConfigRevisionFilterSet',
2021
'ConfigTemplateFilterSet',
@@ -199,6 +200,26 @@ def _usable(self, queryset, name, value):
199200
return queryset.filter(Q(enabled=False) | Q(Q(shared=False) & ~Q(user=user)))
200201

201202

203+
class BookmarkFilterSet(BaseFilterSet):
204+
created = django_filters.DateTimeFilter()
205+
object_type_id = MultiValueNumberFilter()
206+
object_type = ContentTypeFilter()
207+
user_id = django_filters.ModelMultipleChoiceFilter(
208+
queryset=get_user_model().objects.all(),
209+
label=_('User (ID)'),
210+
)
211+
user = django_filters.ModelMultipleChoiceFilter(
212+
field_name='user__username',
213+
queryset=get_user_model().objects.all(),
214+
to_field_name='username',
215+
label=_('User (name)'),
216+
)
217+
218+
class Meta:
219+
model = Bookmark
220+
fields = ['id', 'object_id']
221+
222+
202223
class ImageAttachmentFilterSet(BaseFilterSet):
203224
q = django_filters.CharFilter(
204225
method='search',

netbox/extras/forms/model_forms.py

+13-1
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
from netbox.config import get_config, PARAMS
1515
from netbox.forms import NetBoxModelForm
1616
from tenancy.models import Tenant, TenantGroup
17-
from utilities.forms import BOOLEAN_WITH_BLANK_CHOICES, BootstrapMixin, add_blank_choice
17+
from utilities.forms import BootstrapMixin, add_blank_choice
1818
from utilities.forms.fields import (
1919
CommentField, ContentTypeChoiceField, ContentTypeMultipleChoiceField, DynamicModelMultipleChoiceField, JSONField,
2020
SlugField,
@@ -23,6 +23,7 @@
2323

2424

2525
__all__ = (
26+
'BookmarkForm',
2627
'ConfigContextForm',
2728
'ConfigRevisionForm',
2829
'ConfigTemplateForm',
@@ -169,6 +170,17 @@ def __init__(self, *args, initial=None, **kwargs):
169170
super().__init__(*args, initial=initial, **kwargs)
170171

171172

173+
class BookmarkForm(BootstrapMixin, forms.ModelForm):
174+
object_type = ContentTypeChoiceField(
175+
queryset=ContentType.objects.all(),
176+
limit_choices_to=FeatureQuery('bookmarks').get_query()
177+
)
178+
179+
class Meta:
180+
model = Bookmark
181+
fields = ('object_type', 'object_id')
182+
183+
172184
class WebhookForm(BootstrapMixin, forms.ModelForm):
173185
content_types = ContentTypeMultipleChoiceField(
174186
queryset=ContentType.objects.all(),
+34
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
# Generated by Django 4.1.9 on 2023-06-29 14:07
2+
3+
from django.conf import settings
4+
from django.db import migrations, models
5+
import django.db.models.deletion
6+
7+
8+
class Migration(migrations.Migration):
9+
10+
dependencies = [
11+
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
12+
('contenttypes', '0002_remove_content_type_name'),
13+
('extras', '0094_tag_object_types'),
14+
]
15+
16+
operations = [
17+
migrations.CreateModel(
18+
name='Bookmark',
19+
fields=[
20+
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
21+
('created', models.DateTimeField(auto_now_add=True)),
22+
('object_id', models.PositiveBigIntegerField()),
23+
('object_type', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='contenttypes.contenttype')),
24+
('user', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL)),
25+
],
26+
options={
27+
'ordering': ('created', 'pk'),
28+
},
29+
),
30+
migrations.AddConstraint(
31+
model_name='bookmark',
32+
constraint=models.UniqueConstraint(fields=('object_type', 'object_id', 'user'), name='extras_bookmark_unique_per_object_and_user'),
33+
),
34+
]

netbox/extras/models/models.py

+39-1
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import json
22
import urllib.parse
33

4-
from django.conf import settings
54
from django.contrib import admin
65
from django.conf import settings
76
from django.contrib.contenttypes.fields import GenericForeignKey
@@ -29,6 +28,7 @@
2928
from utilities.utils import clean_html, render_jinja2
3029

3130
__all__ = (
31+
'Bookmark',
3232
'ConfigRevision',
3333
'CustomLink',
3434
'ExportTemplate',
@@ -595,6 +595,44 @@ def get_kind_color(self):
595595
return JournalEntryKindChoices.colors.get(self.kind)
596596

597597

598+
class Bookmark(models.Model):
599+
"""
600+
An object bookmarked by a User.
601+
"""
602+
created = models.DateTimeField(
603+
auto_now_add=True
604+
)
605+
object_type = models.ForeignKey(
606+
to=ContentType,
607+
on_delete=models.PROTECT
608+
)
609+
object_id = models.PositiveBigIntegerField()
610+
object = GenericForeignKey(
611+
ct_field='object_type',
612+
fk_field='object_id'
613+
)
614+
user = models.ForeignKey(
615+
to=settings.AUTH_USER_MODEL,
616+
on_delete=models.PROTECT
617+
)
618+
619+
objects = RestrictedQuerySet.as_manager()
620+
621+
class Meta:
622+
ordering = ('created', 'pk')
623+
constraints = (
624+
models.UniqueConstraint(
625+
fields=('object_type', 'object_id', 'user'),
626+
name='%(app_label)s_%(class)s_unique_per_object_and_user'
627+
),
628+
)
629+
630+
def __str__(self):
631+
if self.object:
632+
return str(self.object)
633+
return super().__str__()
634+
635+
598636
class ConfigRevision(models.Model):
599637
"""
600638
An atomic revision of NetBox's configuration.

0 commit comments

Comments
 (0)