mirror of
https://github.com/netbox-community/netbox.git
synced 2025-08-25 08:46:10 -06:00
Initial work on #8248
This commit is contained in:
parent
1056e513b1
commit
38d27a6fee
@ -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
|
||||
#
|
||||
|
@ -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
|
||||
#
|
||||
|
@ -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',
|
||||
|
@ -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(),
|
||||
|
35
netbox/extras/migrations/0095_bookmarks.py
Normal file
35
netbox/extras/migrations/0095_bookmarks.py
Normal 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'),
|
||||
),
|
||||
]
|
@ -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.
|
||||
|
@ -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
|
||||
|
@ -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'),
|
||||
|
@ -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
|
||||
#
|
||||
|
@ -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,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.
|
||||
|
@ -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 %}
|
||||
|
@ -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'))
|
||||
|
Loading…
Reference in New Issue
Block a user