mirror of
https://github.com/netbox-community/netbox.git
synced 2024-05-10 07:54:54 +00:00
* 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
This commit is contained in:
@ -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.
|
||||
|
13
docs/models/extras/bookmark.md
Normal file
13
docs/models/extras/bookmark.md
Normal file
@ -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.
|
@ -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'
|
||||
|
@ -4,6 +4,7 @@ from extras import models
|
||||
from netbox.api.serializers import NestedTagSerializer, WritableNestedSerializer
|
||||
|
||||
__all__ = [
|
||||
'NestedBookmarkSerializer',
|
||||
'NestedConfigContextSerializer',
|
||||
'NestedConfigTemplateSerializer',
|
||||
'NestedCustomFieldSerializer',
|
||||
@ -73,6 +74,14 @@ class NestedSavedFilterSerializer(WritableNestedSerializer):
|
||||
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')
|
||||
|
||||
|
@ -31,6 +31,7 @@ from virtualization.models import Cluster, ClusterGroup, ClusterType
|
||||
from .nested_serializers import *
|
||||
|
||||
__all__ = (
|
||||
'BookmarkSerializer',
|
||||
'ConfigContextSerializer',
|
||||
'ConfigTemplateSerializer',
|
||||
'ContentTypeSerializer',
|
||||
@ -190,6 +191,30 @@ class SavedFilterSerializer(ValidatedModelSerializer):
|
||||
]
|
||||
|
||||
|
||||
#
|
||||
# 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
|
||||
#
|
||||
|
@ -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)
|
||||
|
@ -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
|
||||
#
|
||||
|
@ -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):
|
||||
|
@ -15,6 +15,7 @@ from django.template.loader import render_to_string
|
||||
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 utilities.utils import content_type_identifier, content_type_name, get_view
|
||||
from .utils import register_widget
|
||||
|
||||
__all__ = (
|
||||
'BookmarksWidget',
|
||||
'DashboardWidget',
|
||||
'NoteWidget',
|
||||
'ObjectCountsWidget',
|
||||
@ -318,3 +320,42 @@ class RSSFeedWidget(DashboardWidget):
|
||||
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,
|
||||
})
|
||||
|
@ -15,6 +15,7 @@ from .filters import TagFilter
|
||||
from .models import *
|
||||
|
||||
__all__ = (
|
||||
'BookmarkFilterSet',
|
||||
'ConfigContextFilterSet',
|
||||
'ConfigRevisionFilterSet',
|
||||
'ConfigTemplateFilterSet',
|
||||
@ -199,6 +200,26 @@ class SavedFilterFilterSet(BaseFilterSet):
|
||||
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',
|
||||
|
@ -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(),
|
||||
|
34
netbox/extras/migrations/0095_bookmarks.py
Normal file
34
netbox/extras/migrations/0095_bookmarks.py
Normal file
@ -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'),
|
||||
),
|
||||
]
|
@ -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.querysets import RestrictedQuerySet
|
||||
from utilities.utils import clean_html, render_jinja2
|
||||
|
||||
__all__ = (
|
||||
'Bookmark',
|
||||
'ConfigRevision',
|
||||
'CustomLink',
|
||||
'ExportTemplate',
|
||||
@ -595,6 +595,44 @@ class JournalEntry(CustomFieldsMixin, CustomLinksMixin, TagsMixin, ExportTemplat
|
||||
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.
|
||||
|
@ -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
|
||||
|
@ -268,6 +268,58 @@ class SavedFilterTest(APIViewTestCases.APIViewTestCase):
|
||||
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']
|
||||
|
@ -365,6 +365,77 @@ class SavedFilterTestCase(TestCase, BaseFilterSetTests):
|
||||
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
|
||||
|
@ -181,6 +181,54 @@ class SavedFilterTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
||||
}
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
@ -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 @@ urlpatterns = [
|
||||
path('saved-filters/delete/', views.SavedFilterBulkDeleteView.as_view(), name='savedfilter_bulk_delete'),
|
||||
path('saved-filters/<int:pk>/', 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/<int:pk>/', 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'),
|
||||
|
@ -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
|
||||
#
|
||||
|
@ -18,6 +18,7 @@ __all__ = (
|
||||
|
||||
|
||||
class NetBoxFeatureSet(
|
||||
BookmarksMixin,
|
||||
ChangeLoggingMixin,
|
||||
CustomFieldsMixin,
|
||||
CustomLinksMixin,
|
||||
|
@ -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,20 @@ class ExportTemplatesMixin(models.Model):
|
||||
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 @@ class SyncedDataMixin(models.Model):
|
||||
|
||||
|
||||
FEATURES_MAP = {
|
||||
'bookmarks': BookmarksMixin,
|
||||
'custom_fields': CustomFieldsMixin,
|
||||
'custom_links': CustomLinksMixin,
|
||||
'export_templates': ExportTemplatesMixin,
|
||||
|
9
netbox/templates/extras/dashboard/widgets/bookmarks.html
Normal file
9
netbox/templates/extras/dashboard/widgets/bookmarks.html
Normal file
@ -0,0 +1,9 @@
|
||||
{% if bookmarks %}
|
||||
<div class="list-group list-group-flush">
|
||||
{% for bookmark in bookmarks %}
|
||||
<a href="{{ bookmark.object.get_absolute_url }}" class="list-group-item list-group-item-action">
|
||||
{{ bookmark.object }}
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
@ -59,6 +59,9 @@ Context:
|
||||
{# 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 %}
|
||||
|
@ -23,6 +23,11 @@
|
||||
<i class="mdi mdi-account"></i> Profile
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="dropdown-item" href="{% url 'users:bookmarks' %}">
|
||||
<i class="mdi mdi-bookmark"></i> Bookmarks
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="dropdown-item" href="{% url 'users:preferences' %}">
|
||||
<i class="mdi mdi-wrench"></i> Preferences
|
||||
|
@ -5,6 +5,9 @@
|
||||
<li role="presentation" class="nav-item">
|
||||
<a class="nav-link{% if active_tab == 'profile' %} active{% endif %}" href="{% url 'users:profile' %}">Profile</a>
|
||||
</li>
|
||||
<li role="presentation" class="nav-item">
|
||||
<a class="nav-link{% if active_tab == 'bookmarks' %} active{% endif %}" href="{% url 'users:bookmarks' %}">Bookmarks</a>
|
||||
</li>
|
||||
<li role="presentation" class="nav-item">
|
||||
<a class="nav-link{% if active_tab == 'preferences' %} active{% endif %}" href="{% url 'users:preferences' %}">Preferences</a>
|
||||
</li>
|
||||
|
34
netbox/templates/users/bookmarks.html
Normal file
34
netbox/templates/users/bookmarks.html
Normal file
@ -0,0 +1,34 @@
|
||||
{% extends 'users/base.html' %}
|
||||
{% load buttons %}
|
||||
{% load helpers %}
|
||||
{% load render_table from django_tables2 %}
|
||||
|
||||
{% block title %}Bookmarks{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<form method="post" class="form form-horizontal">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="return_url" value="{% url 'users:bookmarks' %}" />
|
||||
|
||||
{# Table #}
|
||||
<div class="row">
|
||||
<div class="col col-md-12">
|
||||
<div class="card">
|
||||
<div class="card-body htmx-container table-responsive" id="object_list">
|
||||
{% include 'htmx/table.html' %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Form buttons #}
|
||||
<div class="noprint bulk-buttons">
|
||||
<div class="bulk-button-group">
|
||||
{% if 'bulk_delete' in actions %}
|
||||
{% bulk_delete_button model query_params=request.GET %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
@ -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'),
|
||||
|
||||
|
@ -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
|
||||
#
|
||||
|
15
netbox/utilities/templates/buttons/bookmark.html
Normal file
15
netbox/utilities/templates/buttons/bookmark.html
Normal file
@ -0,0 +1,15 @@
|
||||
<form action="{{ form_url }}?return_url={{ return_url }}" method="post">
|
||||
{% csrf_token %}
|
||||
{% for field, value in form_data.items %}
|
||||
<input type="hidden" name="{{ field }}" value="{{ value }}" />
|
||||
{% endfor %}
|
||||
{% if bookmark %}
|
||||
<button type="submit" class="btn btn-sm btn-info">
|
||||
<i class="mdi mdi-bookmark-minus"></i> Unbookmark
|
||||
</button>
|
||||
{% else %}
|
||||
<button type="submit" class="btn btn-sm btn-info">
|
||||
<i class="mdi mdi-bookmark-check"></i> Bookmark
|
||||
</button>
|
||||
{% endif %}
|
||||
</form>
|
@ -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'))
|
||||
|
Reference in New Issue
Block a user