Skip to content

Commit

Permalink
Closes #8248: User bookmarks (#13035)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
jeremystretch authored Jun 29, 2023
1 parent 1056e51 commit 6e222f8
Show file tree
Hide file tree
Showing 30 changed files with 590 additions and 7 deletions.
4 changes: 4 additions & 0 deletions docs/features/customization.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
13 changes: 13 additions & 0 deletions docs/models/extras/bookmark.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
9 changes: 9 additions & 0 deletions netbox/extras/api/nested_serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from netbox.api.serializers import NestedTagSerializer, WritableNestedSerializer

__all__ = [
'NestedBookmarkSerializer',
'NestedConfigContextSerializer',
'NestedConfigTemplateSerializer',
'NestedCustomFieldSerializer',
Expand Down Expand Up @@ -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')

Expand Down
25 changes: 25 additions & 0 deletions netbox/extras/api/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
from .nested_serializers import *

__all__ = (
'BookmarkSerializer',
'ConfigContextSerializer',
'ConfigTemplateSerializer',
'ContentTypeSerializer',
Expand Down Expand Up @@ -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
#
Expand Down
1 change: 1 addition & 0 deletions netbox/extras/api/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
11 changes: 11 additions & 0 deletions netbox/extras/api/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
#
Expand Down
17 changes: 16 additions & 1 deletion netbox/extras/choices.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
#
Expand All @@ -98,7 +113,7 @@ class ObjectChangeActionChoices(ChoiceSet):


#
# Jounral entries
# Journal entries
#

class JournalEntryKindChoices(ChoiceSet):
Expand Down
41 changes: 41 additions & 0 deletions netbox/extras/dashboard/widgets.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -23,6 +24,7 @@
from .utils import register_widget

__all__ = (
'BookmarksWidget',
'DashboardWidget',
'NoteWidget',
'ObjectCountsWidget',
Expand Down Expand Up @@ -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,
})
21 changes: 21 additions & 0 deletions netbox/extras/filtersets.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
from .models import *

__all__ = (
'BookmarkFilterSet',
'ConfigContextFilterSet',
'ConfigRevisionFilterSet',
'ConfigTemplateFilterSet',
Expand Down Expand Up @@ -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',
Expand Down
14 changes: 13 additions & 1 deletion netbox/extras/forms/model_forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -23,6 +23,7 @@


__all__ = (
'BookmarkForm',
'ConfigContextForm',
'ConfigRevisionForm',
'ConfigTemplateForm',
Expand Down Expand Up @@ -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(),
Expand Down
34 changes: 34 additions & 0 deletions netbox/extras/migrations/0095_bookmarks.py
Original file line number Diff line number Diff line change
@@ -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'),
),
]
40 changes: 39 additions & 1 deletion netbox/extras/models/models.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -29,6 +28,7 @@
from utilities.utils import clean_html, render_jinja2

__all__ = (
'Bookmark',
'ConfigRevision',
'CustomLink',
'ExportTemplate',
Expand Down Expand Up @@ -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.
Expand Down
Loading

0 comments on commit 6e222f8

Please sign in to comment.