Initial work on #8248

This commit is contained in:
Jeremy Stretch 2023-06-26 12:47:55 -04:00
parent 1056e513b1
commit 38d27a6fee
20 changed files with 326 additions and 4 deletions

View File

@ -31,6 +31,7 @@ from virtualization.models import Cluster, ClusterGroup, ClusterType
from .nested_serializers import * from .nested_serializers import *
__all__ = ( __all__ = (
'BookmarkSerializer',
'ConfigContextSerializer', 'ConfigContextSerializer',
'ConfigTemplateSerializer', 'ConfigTemplateSerializer',
'ContentTypeSerializer', '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 # Tags
# #

View File

@ -12,6 +12,7 @@ router.register('custom-fields', views.CustomFieldViewSet)
router.register('custom-links', views.CustomLinkViewSet) router.register('custom-links', views.CustomLinkViewSet)
router.register('export-templates', views.ExportTemplateViewSet) router.register('export-templates', views.ExportTemplateViewSet)
router.register('saved-filters', views.SavedFilterViewSet) router.register('saved-filters', views.SavedFilterViewSet)
router.register('bookmarks', views.BookmarkViewSet)
router.register('tags', views.TagViewSet) router.register('tags', views.TagViewSet)
router.register('image-attachments', views.ImageAttachmentViewSet) router.register('image-attachments', views.ImageAttachmentViewSet)
router.register('journal-entries', views.JournalEntryViewSet) router.register('journal-entries', views.JournalEntryViewSet)

View File

@ -93,6 +93,17 @@ class SavedFilterViewSet(NetBoxModelViewSet):
filterset_class = filtersets.SavedFilterFilterSet filterset_class = filtersets.SavedFilterFilterSet
#
# Bookmarks
#
class BookmarkViewSet(NetBoxModelViewSet):
metadata_class = ContentTypeMetadata
queryset = Bookmark.objects.all()
serializer_class = serializers.BookmarkSerializer
filterset_class = filtersets.BookmarkFilterSet
# #
# Tags # Tags
# #

View File

@ -15,6 +15,7 @@ from .filters import TagFilter
from .models import * from .models import *
__all__ = ( __all__ = (
'BookmarkFilterSet',
'ConfigContextFilterSet', 'ConfigContextFilterSet',
'ConfigRevisionFilterSet', 'ConfigRevisionFilterSet',
'ConfigTemplateFilterSet', 'ConfigTemplateFilterSet',
@ -199,6 +200,34 @@ class SavedFilterFilterSet(BaseFilterSet):
return queryset.filter(Q(enabled=False) | Q(Q(shared=False) & ~Q(user=user))) 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): class ImageAttachmentFilterSet(BaseFilterSet):
q = django_filters.CharFilter( q = django_filters.CharFilter(
method='search', method='search',

View File

@ -14,7 +14,7 @@ from extras.utils import FeatureQuery
from netbox.config import get_config, PARAMS from netbox.config import get_config, PARAMS
from netbox.forms import NetBoxModelForm from netbox.forms import NetBoxModelForm
from tenancy.models import Tenant, TenantGroup 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 ( from utilities.forms.fields import (
CommentField, ContentTypeChoiceField, ContentTypeMultipleChoiceField, DynamicModelMultipleChoiceField, JSONField, CommentField, ContentTypeChoiceField, ContentTypeMultipleChoiceField, DynamicModelMultipleChoiceField, JSONField,
SlugField, SlugField,
@ -23,6 +23,7 @@ from virtualization.models import Cluster, ClusterGroup, ClusterType
__all__ = ( __all__ = (
'BookmarkForm',
'ConfigContextForm', 'ConfigContextForm',
'ConfigRevisionForm', 'ConfigRevisionForm',
'ConfigTemplateForm', 'ConfigTemplateForm',
@ -169,6 +170,17 @@ class SavedFilterForm(BootstrapMixin, forms.ModelForm):
super().__init__(*args, initial=initial, **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): class WebhookForm(BootstrapMixin, forms.ModelForm):
content_types = ContentTypeMultipleChoiceField( content_types = ContentTypeMultipleChoiceField(
queryset=ContentType.objects.all(), queryset=ContentType.objects.all(),

View File

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

View File

@ -29,6 +29,7 @@ from utilities.querysets import RestrictedQuerySet
from utilities.utils import clean_html, render_jinja2 from utilities.utils import clean_html, render_jinja2
__all__ = ( __all__ = (
'Bookmark',
'ConfigRevision', 'ConfigRevision',
'CustomLink', 'CustomLink',
'ExportTemplate', 'ExportTemplate',
@ -595,6 +596,39 @@ class JournalEntry(CustomFieldsMixin, CustomLinksMixin, TagsMixin, ExportTemplat
return JournalEntryKindChoices.colors.get(self.kind) 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): class ConfigRevision(models.Model):
""" """
An atomic revision of NetBox's configuration. An atomic revision of NetBox's configuration.

View File

@ -8,6 +8,7 @@ from netbox.tables import NetBoxTable, columns
from .template_code import * from .template_code import *
__all__ = ( __all__ = (
'BookmarkTable',
'ConfigContextTable', 'ConfigContextTable',
'ConfigRevisionTable', 'ConfigRevisionTable',
'ConfigTemplateTable', '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): class WebhookTable(NetBoxTable):
name = tables.Column( name = tables.Column(
linkify=True linkify=True

View File

@ -40,6 +40,11 @@ urlpatterns = [
path('saved-filters/delete/', views.SavedFilterBulkDeleteView.as_view(), name='savedfilter_bulk_delete'), path('saved-filters/delete/', views.SavedFilterBulkDeleteView.as_view(), name='savedfilter_bulk_delete'),
path('saved-filters/<int:pk>/', include(get_model_urls('extras', 'savedfilter'))), path('saved-filters/<int:pk>/', 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/<int:pk>/', include(get_model_urls('extras', 'bookmark'))),
# Webhooks # Webhooks
path('webhooks/', views.WebhookListView.as_view(), name='webhook_list'), path('webhooks/', views.WebhookListView.as_view(), name='webhook_list'),
path('webhooks/add/', views.WebhookEditView.as_view(), name='webhook_add'), path('webhooks/add/', views.WebhookEditView.as_view(), name='webhook_add'),

View File

@ -237,6 +237,36 @@ class SavedFilterBulkDeleteView(SavedFilterMixin, generic.BulkDeleteView):
table = tables.SavedFilterTable 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 # Webhooks
# #

View File

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

View File

@ -22,6 +22,7 @@ from utilities.utils import serialize_object
from utilities.views import register_model_view from utilities.views import register_model_view
__all__ = ( __all__ = (
'BookmarksMixin',
'ChangeLoggingMixin', 'ChangeLoggingMixin',
'CloningMixin', 'CloningMixin',
'CustomFieldsMixin', 'CustomFieldsMixin',
@ -304,6 +305,18 @@ class ExportTemplatesMixin(models.Model):
abstract = True abstract = True
class BookmarksMixin(models.Model):
"""
Enables support for user bookmarks.
"""
images = GenericRelation(
to='extras.Bookmark'
)
class Meta:
abstract = True
class JobsMixin(models.Model): class JobsMixin(models.Model):
""" """
Enables support for job results. Enables support for job results.

View File

@ -59,6 +59,9 @@ Context:
{# Extra buttons #} {# Extra buttons #}
{% block extra_controls %}{% endblock %} {% block extra_controls %}{% endblock %}
{% if request.user.is_authenticated %}
{% bookmark_button object %}
{% endif %}
{% if request.user|can_add:object %} {% if request.user|can_add:object %}
{% clone_button object %} {% clone_button object %}
{% endif %} {% endif %}

View File

@ -23,6 +23,11 @@
<i class="mdi mdi-account"></i> Profile <i class="mdi mdi-account"></i> Profile
</a> </a>
</li> </li>
<li>
<a class="dropdown-item" href="{% url 'users:bookmarks' %}">
<i class="mdi mdi-bookmark"></i> Bookmarks
</a>
</li>
<li> <li>
<a class="dropdown-item" href="{% url 'users:preferences' %}"> <a class="dropdown-item" href="{% url 'users:preferences' %}">
<i class="mdi mdi-wrench"></i> Preferences <i class="mdi mdi-wrench"></i> Preferences

View File

@ -5,6 +5,9 @@
<li role="presentation" class="nav-item"> <li role="presentation" class="nav-item">
<a class="nav-link{% if active_tab == 'profile' %} active{% endif %}" href="{% url 'users:profile' %}">Profile</a> <a class="nav-link{% if active_tab == 'profile' %} active{% endif %}" href="{% url 'users:profile' %}">Profile</a>
</li> </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"> <li role="presentation" class="nav-item">
<a class="nav-link{% if active_tab == 'preferences' %} active{% endif %}" href="{% url 'users:preferences' %}">Preferences</a> <a class="nav-link{% if active_tab == 'preferences' %} active{% endif %}" href="{% url 'users:preferences' %}">Preferences</a>
</li> </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 # User
path('profile/', views.ProfileView.as_view(), name='profile'), 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('preferences/', views.UserConfigView.as_view(), name='preferences'),
path('password/', views.ChangePasswordView.as_view(), name='change_password'), 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 django.views.generic import View
from social_core.backends.utils import load_backends from social_core.backends.utils import load_backends
from extras.models import ObjectChange from extras.models import Bookmark, ObjectChange
from extras.tables import ObjectChangeTable from extras.tables import BookmarkTable, ObjectChangeTable
from netbox.authentication import get_auth_backend_display, get_saml_idps from netbox.authentication import get_auth_backend_display, get_saml_idps
from netbox.config import get_config from netbox.config import get_config
from netbox.views.generic import ObjectListView
from utilities.forms import ConfirmationForm from utilities.forms import ConfirmationForm
from utilities.views import register_model_view from utilities.views import register_model_view
from .forms import LoginForm, PasswordChangeForm, TokenForm, UserConfigForm 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 # 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.contrib.contenttypes.models import ContentType
from django.urls import NoReverseMatch, reverse 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 from utilities.utils import get_viewname, prepare_cloned_fields
__all__ = ( __all__ = (
'add_button', 'add_button',
'bookmark_button',
'bulk_delete_button', 'bulk_delete_button',
'bulk_edit_button', 'bulk_edit_button',
'clone_button', 'clone_button',
@ -24,6 +25,37 @@ register = template.Library()
# Instance buttons # 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') @register.inclusion_tag('buttons/clone.html')
def clone_button(instance): def clone_button(instance):
url = reverse(get_viewname(instance, 'add')) url = reverse(get_viewname(instance, 'add'))