1
0
mirror of https://github.com/netbox-community/netbox.git synced 2024-05-10 07:54:54 +00:00

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
This commit is contained in:
Jeremy Stretch
2023-06-29 14:36:11 -04:00
committed by GitHub
parent 1056e513b1
commit 6e222f8dce
30 changed files with 590 additions and 7 deletions

View File

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

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

View File

@ -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'

View File

@ -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')

View File

@ -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
#

View File

@ -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)

View File

@ -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
#

View File

@ -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):

View File

@ -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,
})

View File

@ -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',

View File

@ -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(),

View 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'),
),
]

View File

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

View File

@ -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

View File

@ -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']

View File

@ -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

View File

@ -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

View File

@ -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'),

View File

@ -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
#

View File

@ -18,6 +18,7 @@ __all__ = (
class NetBoxFeatureSet(
BookmarksMixin,
ChangeLoggingMixin,
CustomFieldsMixin,
CustomLinksMixin,

View File

@ -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,

View 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 %}

View File

@ -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 %}

View File

@ -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

View File

@ -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>

View 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 %}

View File

@ -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'),

View File

@ -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
#

View 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>

View File

@ -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'))