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 *
__all__ = (
'BookmarkSerializer',
'ConfigContextSerializer',
'ConfigTemplateSerializer',
'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
#

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

@ -15,6 +15,7 @@ from .filters import TagFilter
from .models import *
__all__ = (
'BookmarkFilterSet',
'ConfigContextFilterSet',
'ConfigRevisionFilterSet',
'ConfigTemplateFilterSet',
@ -199,6 +200,34 @@ class SavedFilterFilterSet(BaseFilterSet):
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):
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,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
__all__ = (
'Bookmark',
'ConfigRevision',
'CustomLink',
'ExportTemplate',
@ -595,6 +596,39 @@ class JournalEntry(CustomFieldsMixin, CustomLinksMixin, TagsMixin, ExportTemplat
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):
"""
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

@ -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.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
path('webhooks/', views.WebhookListView.as_view(), name='webhook_list'),
path('webhooks/add/', views.WebhookEditView.as_view(), name='webhook_add'),

View File

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

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,18 @@ class ExportTemplatesMixin(models.Model):
abstract = True
class BookmarksMixin(models.Model):
"""
Enables support for user bookmarks.
"""
images = GenericRelation(
to='extras.Bookmark'
)
class Meta:
abstract = True
class JobsMixin(models.Model):
"""
Enables support for job results.

View File

@ -59,6 +59,9 @@ Context:
{# Extra buttons #}
{% block extra_controls %}{% endblock %}
{% if request.user.is_authenticated %}
{% 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'))