/', include(get_model_urls('extras', 'subscription'))),
+
# Webhooks
path('webhooks/', views.WebhookListView.as_view(), name='webhook_list'),
path('webhooks/add/', views.WebhookEditView.as_view(), name='webhook_add'),
diff --git a/netbox/extras/views.py b/netbox/extras/views.py
index c902b1499..d3e346feb 100644
--- a/netbox/extras/views.py
+++ b/netbox/extras/views.py
@@ -6,6 +6,7 @@ from django.db.models import Count, Q
from django.http import HttpResponseBadRequest, HttpResponseForbidden, HttpResponse
from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse
+from django.utils import timezone
from django.utils.translation import gettext as _
from django.views.generic import View
@@ -14,6 +15,7 @@ from core.forms import ManagedFileForm
from core.models import Job
from core.tables import JobTable
from dcim.models import Device, DeviceRole, Platform
+from extras.choices import LogLevelChoices
from extras.dashboard.forms import DashboardWidgetAddForm, DashboardWidgetForm
from extras.dashboard.utils import get_widget_class
from netbox.constants import DEFAULT_ACTION_PERMISSIONS
@@ -30,6 +32,7 @@ from utilities.templatetags.builtins.filters import render_markdown
from utilities.views import ContentTypePermissionRequiredMixin, get_viewname, register_model_view
from virtualization.models import VirtualMachine
from . import filtersets, forms, tables
+from .constants import LOG_LEVEL_RANK
from .models import *
from .scripts import run_script
from .tables import ReportResultsTable, ScriptResultsTable
@@ -354,6 +357,139 @@ class BookmarkBulkDeleteView(generic.BulkDeleteView):
return Bookmark.objects.filter(user=request.user)
+#
+# Notification groups
+#
+
+class NotificationGroupListView(generic.ObjectListView):
+ queryset = NotificationGroup.objects.all()
+ filterset = filtersets.NotificationGroupFilterSet
+ filterset_form = forms.NotificationGroupFilterForm
+ table = tables.NotificationGroupTable
+
+
+@register_model_view(NotificationGroup)
+class NotificationGroupView(generic.ObjectView):
+ queryset = NotificationGroup.objects.all()
+
+
+@register_model_view(NotificationGroup, 'edit')
+class NotificationGroupEditView(generic.ObjectEditView):
+ queryset = NotificationGroup.objects.all()
+ form = forms.NotificationGroupForm
+
+
+@register_model_view(NotificationGroup, 'delete')
+class NotificationGroupDeleteView(generic.ObjectDeleteView):
+ queryset = NotificationGroup.objects.all()
+
+
+class NotificationGroupBulkImportView(generic.BulkImportView):
+ queryset = NotificationGroup.objects.all()
+ model_form = forms.NotificationGroupImportForm
+
+
+class NotificationGroupBulkEditView(generic.BulkEditView):
+ queryset = NotificationGroup.objects.all()
+ filterset = filtersets.NotificationGroupFilterSet
+ table = tables.NotificationGroupTable
+ form = forms.NotificationGroupBulkEditForm
+
+
+class NotificationGroupBulkDeleteView(generic.BulkDeleteView):
+ queryset = NotificationGroup.objects.all()
+ filterset = filtersets.NotificationGroupFilterSet
+ table = tables.NotificationGroupTable
+
+
+#
+# Notifications
+#
+
+class NotificationsView(LoginRequiredMixin, View):
+ """
+ HTMX-only user-specific notifications list.
+ """
+ def get(self, request):
+ return render(request, 'htmx/notifications.html', {
+ 'notifications': request.user.notifications.unread(),
+ 'total_count': request.user.notifications.count(),
+ })
+
+
+@register_model_view(Notification, 'read')
+class NotificationReadView(LoginRequiredMixin, View):
+ """
+ Mark the Notification read and redirect the user to its attached object.
+ """
+ def get(self, request, pk):
+ notification = get_object_or_404(request.user.notifications, pk=pk)
+ notification.read = timezone.now()
+ notification.save()
+
+ return redirect(notification.object.get_absolute_url())
+
+
+@register_model_view(Notification, 'dismiss')
+class NotificationDismissView(LoginRequiredMixin, View):
+ """
+ A convenience view which allows deleting notifications with one click.
+ """
+ def get(self, request, pk):
+ notification = get_object_or_404(request.user.notifications, pk=pk)
+ notification.delete()
+
+ if htmx_partial(request):
+ return render(request, 'htmx/notifications.html', {
+ 'notifications': request.user.notifications.unread()[:10],
+ })
+
+ return redirect('account:notifications')
+
+
+@register_model_view(Notification, 'delete')
+class NotificationDeleteView(generic.ObjectDeleteView):
+
+ def get_queryset(self, request):
+ return Notification.objects.filter(user=request.user)
+
+
+class NotificationBulkDeleteView(generic.BulkDeleteView):
+ table = tables.NotificationTable
+
+ def get_queryset(self, request):
+ return Notification.objects.filter(user=request.user)
+
+
+#
+# Subscriptions
+#
+
+class SubscriptionCreateView(generic.ObjectEditView):
+ form = forms.SubscriptionForm
+
+ def get_queryset(self, request):
+ return Subscription.objects.filter(user=request.user)
+
+ def alter_object(self, obj, request, url_args, url_kwargs):
+ obj.user = request.user
+ return obj
+
+
+@register_model_view(Subscription, 'delete')
+class SubscriptionDeleteView(generic.ObjectDeleteView):
+
+ def get_queryset(self, request):
+ return Subscription.objects.filter(user=request.user)
+
+
+class SubscriptionBulkDeleteView(generic.BulkDeleteView):
+ table = tables.SubscriptionTable
+
+ def get_queryset(self, request):
+ return Subscription.objects.filter(user=request.user)
+
+
#
# Webhooks
#
@@ -1119,22 +1255,27 @@ class ScriptResultView(TableMixin, generic.ObjectView):
tests = None
table = None
index = 0
+
+ log_threshold = LOG_LEVEL_RANK.get(request.GET.get('log_threshold', LogLevelChoices.LOG_DEFAULT))
if job.data:
+
if 'log' in job.data:
if 'tests' in job.data:
tests = job.data['tests']
for log in job.data['log']:
- index += 1
- result = {
- 'index': index,
- 'time': log.get('time'),
- 'status': log.get('status'),
- 'message': log.get('message'),
- 'object': log.get('obj'),
- 'url': log.get('url'),
- }
- data.append(result)
+ log_level = LOG_LEVEL_RANK.get(log.get('status'), LogLevelChoices.LOG_DEFAULT)
+ if log_level >= log_threshold:
+ index += 1
+ result = {
+ 'index': index,
+ 'time': log.get('time'),
+ 'status': log.get('status'),
+ 'message': log.get('message'),
+ 'object': log.get('obj'),
+ 'url': log.get('url'),
+ }
+ data.append(result)
table = ScriptResultsTable(data, user=request.user)
table.configure(request)
@@ -1146,17 +1287,19 @@ class ScriptResultView(TableMixin, generic.ObjectView):
for method, test_data in tests.items():
if 'log' in test_data:
for time, status, obj, url, message in test_data['log']:
- index += 1
- result = {
- 'index': index,
- 'method': method,
- 'time': time,
- 'status': status,
- 'object': obj,
- 'url': url,
- 'message': message,
- }
- data.append(result)
+ log_level = LOG_LEVEL_RANK.get(status, LogLevelChoices.LOG_DEFAULT)
+ if log_level >= log_threshold:
+ index += 1
+ result = {
+ 'index': index,
+ 'method': method,
+ 'time': time,
+ 'status': status,
+ 'object': obj,
+ 'url': url,
+ 'message': message,
+ }
+ data.append(result)
table = ReportResultsTable(data, user=request.user)
table.configure(request)
@@ -1174,6 +1317,8 @@ class ScriptResultView(TableMixin, generic.ObjectView):
'script': job.object,
'job': job,
'table': table,
+ 'log_levels': dict(LogLevelChoices),
+ 'log_threshold': request.GET.get('log_threshold', LogLevelChoices.LOG_DEFAULT)
}
if job.data and 'log' in job.data:
@@ -1200,7 +1345,7 @@ class ScriptResultView(TableMixin, generic.ObjectView):
# Markdown
#
-class RenderMarkdownView(View):
+class RenderMarkdownView(LoginRequiredMixin, View):
def post(self, request):
form = forms.RenderMarkdownForm(request.POST)
diff --git a/netbox/extras/webhooks.py b/netbox/extras/webhooks.py
index 53ec161d7..889c97ac2 100644
--- a/netbox/extras/webhooks.py
+++ b/netbox/extras/webhooks.py
@@ -25,7 +25,7 @@ def generate_signature(request_body, secret):
@job('default')
-def send_webhook(event_rule, model_name, event, data, timestamp, username, request_id=None, snapshots=None):
+def send_webhook(event_rule, model_name, event_type, data, timestamp, username, request_id=None, snapshots=None):
"""
Make a POST request to the defined Webhook
"""
@@ -33,7 +33,7 @@ def send_webhook(event_rule, model_name, event, data, timestamp, username, reque
# Prepare context data for headers & body templates
context = {
- 'event': WEBHOOK_EVENT_TYPES[event],
+ 'event': WEBHOOK_EVENT_TYPES.get(event_type, event_type),
'timestamp': timestamp,
'model': model_name,
'username': username,
diff --git a/netbox/ipam/api/serializers_/vlans.py b/netbox/ipam/api/serializers_/vlans.py
index 5525545a8..608fcf0b4 100644
--- a/netbox/ipam/api/serializers_/vlans.py
+++ b/netbox/ipam/api/serializers_/vlans.py
@@ -6,7 +6,7 @@ from dcim.api.serializers_.sites import SiteSerializer
from ipam.choices import *
from ipam.constants import VLANGROUP_SCOPE_TYPES
from ipam.models import VLAN, VLANGroup
-from netbox.api.fields import ChoiceField, ContentTypeField, RelatedObjectCountField
+from netbox.api.fields import ChoiceField, ContentTypeField, IntegerRangeSerializer, RelatedObjectCountField
from netbox.api.serializers import NetBoxModelSerializer
from tenancy.api.serializers_.tenants import TenantSerializer
from utilities.api import get_serializer_for_model
@@ -32,6 +32,7 @@ class VLANGroupSerializer(NetBoxModelSerializer):
)
scope_id = serializers.IntegerField(allow_null=True, required=False, default=None)
scope = serializers.SerializerMethodField(read_only=True)
+ vid_ranges = IntegerRangeSerializer(many=True, required=False)
utilization = serializers.CharField(read_only=True)
# Related object counts
@@ -40,8 +41,8 @@ class VLANGroupSerializer(NetBoxModelSerializer):
class Meta:
model = VLANGroup
fields = [
- 'id', 'url', 'display_url', 'display', 'name', 'slug', 'scope_type', 'scope_id', 'scope', 'min_vid',
- 'max_vid', 'description', 'tags', 'custom_fields', 'created', 'last_updated', 'vlan_count', 'utilization'
+ 'id', 'url', 'display_url', 'display', 'name', 'slug', 'scope_type', 'scope_id', 'scope', 'vid_ranges',
+ 'description', 'tags', 'custom_fields', 'created', 'last_updated', 'vlan_count', 'utilization'
]
brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'vlan_count')
validators = []
diff --git a/netbox/ipam/filtersets.py b/netbox/ipam/filtersets.py
index 5cdfac34e..30634850a 100644
--- a/netbox/ipam/filtersets.py
+++ b/netbox/ipam/filtersets.py
@@ -911,10 +911,13 @@ class VLANGroupFilterSet(OrganizationalModelFilterSet):
cluster = django_filters.NumberFilter(
method='filter_scope'
)
+ contains_vid = django_filters.NumberFilter(
+ method='filter_contains_vid'
+ )
class Meta:
model = VLANGroup
- fields = ('id', 'name', 'slug', 'min_vid', 'max_vid', 'description', 'scope_id')
+ fields = ('id', 'name', 'slug', 'description', 'scope_id')
def search(self, queryset, name, value):
if not value.strip():
@@ -932,6 +935,21 @@ class VLANGroupFilterSet(OrganizationalModelFilterSet):
scope_id=value
)
+ def filter_contains_vid(self, queryset, name, value):
+ """
+ Return all VLANGroups which contain the given VLAN ID.
+ """
+ table_name = VLANGroup._meta.db_table
+ # TODO: See if this can be optimized without compromising queryset integrity
+ # Expand VLAN ID ranges to query by integer
+ groups = VLANGroup.objects.raw(
+ f'SELECT id FROM {table_name}, unnest(vid_ranges) vid_range WHERE %s <@ vid_range',
+ params=(value,)
+ )
+ return queryset.filter(
+ pk__in=[g.id for g in groups]
+ )
+
class VLANFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
region_id = TreeNodeMultipleChoiceFilter(
diff --git a/netbox/ipam/forms/bulk_edit.py b/netbox/ipam/forms/bulk_edit.py
index c7f64ab1d..2f59c564f 100644
--- a/netbox/ipam/forms/bulk_edit.py
+++ b/netbox/ipam/forms/bulk_edit.py
@@ -12,6 +12,7 @@ from tenancy.models import Tenant
from utilities.forms import add_blank_choice
from utilities.forms.fields import (
CommentField, ContentTypeChoiceField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, NumericArrayField,
+ NumericRangeArrayField,
)
from utilities.forms.rendering import FieldSet
from utilities.forms.widgets import BulkEditNullBooleanSelect
@@ -408,18 +409,6 @@ class FHRPGroupBulkEditForm(NetBoxModelBulkEditForm):
class VLANGroupBulkEditForm(NetBoxModelBulkEditForm):
- min_vid = forms.IntegerField(
- min_value=VLAN_VID_MIN,
- max_value=VLAN_VID_MAX,
- required=False,
- label=_('Minimum child VLAN VID')
- )
- max_vid = forms.IntegerField(
- min_value=VLAN_VID_MIN,
- max_value=VLAN_VID_MAX,
- required=False,
- label=_('Maximum child VLAN VID')
- )
description = forms.CharField(
label=_('Description'),
max_length=200,
@@ -483,10 +472,14 @@ class VLANGroupBulkEditForm(NetBoxModelBulkEditForm):
'group_id': '$clustergroup',
}
)
+ vid_ranges = NumericRangeArrayField(
+ label=_('VLAN ID ranges'),
+ required=False
+ )
model = VLANGroup
fieldsets = (
- FieldSet('site', 'min_vid', 'max_vid', 'description'),
+ FieldSet('site', 'vid_ranges', 'description'),
FieldSet(
'scope_type', 'region', 'sitegroup', 'site', 'location', 'rack', 'clustergroup', 'cluster', name=_('Scope')
),
diff --git a/netbox/ipam/forms/bulk_import.py b/netbox/ipam/forms/bulk_import.py
index bfff1f4f4..dea250c79 100644
--- a/netbox/ipam/forms/bulk_import.py
+++ b/netbox/ipam/forms/bulk_import.py
@@ -9,7 +9,8 @@ from ipam.models import *
from netbox.forms import NetBoxModelImportForm
from tenancy.models import Tenant
from utilities.forms.fields import (
- CSVChoiceField, CSVContentTypeField, CSVModelChoiceField, CSVModelMultipleChoiceField, SlugField
+ CSVChoiceField, CSVContentTypeField, CSVModelChoiceField, CSVModelMultipleChoiceField, SlugField,
+ NumericRangeArrayField,
)
from virtualization.models import VirtualMachine, VMInterface
@@ -411,22 +412,13 @@ class VLANGroupImportForm(NetBoxModelImportForm):
required=False,
label=_('Scope type (app & model)')
)
- min_vid = forms.IntegerField(
- min_value=VLAN_VID_MIN,
- max_value=VLAN_VID_MAX,
- required=False,
- label=_('Minimum child VLAN VID (default: {minimum})').format(minimum=VLAN_VID_MIN)
- )
- max_vid = forms.IntegerField(
- min_value=VLAN_VID_MIN,
- max_value=VLAN_VID_MAX,
- required=False,
- label=_('Maximum child VLAN VID (default: {maximum})').format(maximum=VLAN_VID_MIN)
+ vid_ranges = NumericRangeArrayField(
+ required=False
)
class Meta:
model = VLANGroup
- fields = ('name', 'slug', 'scope_type', 'scope_id', 'min_vid', 'max_vid', 'description', 'tags')
+ fields = ('name', 'slug', 'scope_type', 'scope_id', 'vid_ranges', 'description', 'tags')
labels = {
'scope_id': 'Scope ID',
}
diff --git a/netbox/ipam/forms/filtersets.py b/netbox/ipam/forms/filtersets.py
index 80fb04226..a32694321 100644
--- a/netbox/ipam/forms/filtersets.py
+++ b/netbox/ipam/forms/filtersets.py
@@ -413,7 +413,7 @@ class VLANGroupFilterForm(NetBoxModelFilterSetForm):
FieldSet('q', 'filter_id', 'tag'),
FieldSet('region', 'sitegroup', 'site', 'location', 'rack', name=_('Location')),
FieldSet('cluster_group', 'cluster', name=_('Cluster')),
- FieldSet('min_vid', 'max_vid', name=_('VLAN ID')),
+ FieldSet('contains_vid', name=_('VLANs')),
)
model = VLANGroup
region = DynamicModelMultipleChoiceField(
@@ -441,18 +441,6 @@ class VLANGroupFilterForm(NetBoxModelFilterSetForm):
required=False,
label=_('Rack')
)
- min_vid = forms.IntegerField(
- required=False,
- min_value=VLAN_VID_MIN,
- max_value=VLAN_VID_MAX,
- label=_('Minimum VID')
- )
- max_vid = forms.IntegerField(
- required=False,
- min_value=VLAN_VID_MIN,
- max_value=VLAN_VID_MAX,
- label=_('Maximum VID')
- )
cluster = DynamicModelMultipleChoiceField(
queryset=Cluster.objects.all(),
required=False,
@@ -463,6 +451,11 @@ class VLANGroupFilterForm(NetBoxModelFilterSetForm):
required=False,
label=_('Cluster group')
)
+ contains_vid = forms.IntegerField(
+ min_value=0,
+ required=False,
+ label=_('Contains VLAN ID')
+ )
tag = TagFilterField(model)
diff --git a/netbox/ipam/forms/model_forms.py b/netbox/ipam/forms/model_forms.py
index 4e405a035..e6060d1af 100644
--- a/netbox/ipam/forms/model_forms.py
+++ b/netbox/ipam/forms/model_forms.py
@@ -1,5 +1,6 @@
from django import forms
from django.contrib.contenttypes.models import ContentType
+from django.contrib.postgres.forms import IntegerRangeField, SimpleArrayField
from django.core.exceptions import ValidationError
from django.utils.translation import gettext_lazy as _
@@ -14,7 +15,7 @@ from utilities.exceptions import PermissionsViolation
from utilities.forms import add_blank_choice
from utilities.forms.fields import (
CommentField, ContentTypeChoiceField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, NumericArrayField,
- SlugField,
+ NumericRangeArrayField, SlugField
)
from utilities.forms.rendering import FieldSet, InlineFields, ObjectAttribute, TabbedGroups
from utilities.forms.widgets import DatePicker
@@ -632,10 +633,13 @@ class VLANGroupForm(NetBoxModelForm):
}
)
slug = SlugField()
+ vid_ranges = NumericRangeArrayField(
+ label=_('VLAN IDs')
+ )
fieldsets = (
FieldSet('name', 'slug', 'description', 'tags', name=_('VLAN Group')),
- FieldSet('min_vid', 'max_vid', name=_('Child VLANs')),
+ FieldSet('vid_ranges', name=_('Child VLANs')),
FieldSet(
'scope_type', 'region', 'sitegroup', 'site', 'location', 'rack', 'clustergroup', 'cluster',
name=_('Scope')
@@ -646,7 +650,7 @@ class VLANGroupForm(NetBoxModelForm):
model = VLANGroup
fields = [
'name', 'slug', 'description', 'scope_type', 'region', 'sitegroup', 'site', 'location', 'rack',
- 'clustergroup', 'cluster', 'min_vid', 'max_vid', 'tags',
+ 'clustergroup', 'cluster', 'vid_ranges', 'tags',
]
def __init__(self, *args, **kwargs):
diff --git a/netbox/ipam/graphql/types.py b/netbox/ipam/graphql/types.py
index 36e09eaac..46d45816e 100644
--- a/netbox/ipam/graphql/types.py
+++ b/netbox/ipam/graphql/types.py
@@ -251,6 +251,7 @@ class VLANType(NetBoxObjectType):
class VLANGroupType(OrganizationalObjectType):
vlans: List[VLANType]
+ vid_ranges: List[str]
@strawberry_django.field
def scope(self) -> Annotated[Union[
diff --git a/netbox/ipam/migrations/0070_vlangroup_vlan_id_ranges.py b/netbox/ipam/migrations/0070_vlangroup_vlan_id_ranges.py
new file mode 100644
index 000000000..b01941401
--- /dev/null
+++ b/netbox/ipam/migrations/0070_vlangroup_vlan_id_ranges.py
@@ -0,0 +1,55 @@
+import django.contrib.postgres.fields
+import django.contrib.postgres.fields.ranges
+from django.db import migrations, models
+from django.db.backends.postgresql.psycopg_any import NumericRange
+
+import ipam.models.vlans
+
+
+def set_vid_ranges(apps, schema_editor):
+ """
+ Convert the min_vid & max_vid fields to a range in the new vid_ranges ArrayField.
+ """
+ VLANGroup = apps.get_model('ipam', 'VLANGroup')
+ for group in VLANGroup.objects.all():
+ group.vid_ranges = [
+ NumericRange(group.min_vid, group.max_vid, bounds='[]')
+ ]
+ group._total_vlan_ids = group.max_vid - group.min_vid + 1
+ group.save()
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('ipam', '0069_gfk_indexes'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='vlangroup',
+ name='vid_ranges',
+ field=django.contrib.postgres.fields.ArrayField(
+ base_field=django.contrib.postgres.fields.ranges.IntegerRangeField(),
+ default=ipam.models.vlans.default_vid_ranges,
+ size=None
+ ),
+ ),
+ migrations.AddField(
+ model_name='vlangroup',
+ name='_total_vlan_ids',
+ field=models.PositiveBigIntegerField(default=4094),
+ ),
+ migrations.RunPython(
+ code=set_vid_ranges,
+ reverse_code=migrations.RunPython.noop
+ ),
+ migrations.RemoveField(
+ model_name='vlangroup',
+ name='max_vid',
+ ),
+ migrations.RemoveField(
+ model_name='vlangroup',
+ name='min_vid',
+ ),
+ ]
diff --git a/netbox/ipam/models/vlans.py b/netbox/ipam/models/vlans.py
index 7434bd0b4..ca6b27d07 100644
--- a/netbox/ipam/models/vlans.py
+++ b/netbox/ipam/models/vlans.py
@@ -1,7 +1,9 @@
from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation
+from django.contrib.postgres.fields import ArrayField, IntegerRangeField
from django.core.exceptions import ValidationError
from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models
+from django.db.backends.postgresql.psycopg_any import NumericRange
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
@@ -10,6 +12,7 @@ from ipam.choices import *
from ipam.constants import *
from ipam.querysets import VLANQuerySet, VLANGroupQuerySet
from netbox.models import OrganizationalModel, PrimaryModel
+from utilities.data import check_ranges_overlap, ranges_to_string
from virtualization.models import VMInterface
__all__ = (
@@ -18,9 +21,16 @@ __all__ = (
)
+def default_vid_ranges():
+ return [
+ NumericRange(VLAN_VID_MIN, VLAN_VID_MAX, bounds='[]')
+ ]
+
+
class VLANGroup(OrganizationalModel):
"""
- A VLAN group is an arbitrary collection of VLANs within which VLAN IDs and names must be unique.
+ A VLAN group is an arbitrary collection of VLANs within which VLAN IDs and names must be unique. Each group must
+ define one or more ranges of valid VLAN IDs, and may be assigned a specific scope.
"""
name = models.CharField(
verbose_name=_('name'),
@@ -45,23 +55,13 @@ class VLANGroup(OrganizationalModel):
ct_field='scope_type',
fk_field='scope_id'
)
- min_vid = models.PositiveSmallIntegerField(
- verbose_name=_('minimum VLAN ID'),
- default=VLAN_VID_MIN,
- validators=(
- MinValueValidator(VLAN_VID_MIN),
- MaxValueValidator(VLAN_VID_MAX)
- ),
- help_text=_('Lowest permissible ID of a child VLAN')
+ vid_ranges = ArrayField(
+ IntegerRangeField(),
+ verbose_name=_('VLAN ID ranges'),
+ default=default_vid_ranges
)
- max_vid = models.PositiveSmallIntegerField(
- verbose_name=_('maximum VLAN ID'),
- default=VLAN_VID_MAX,
- validators=(
- MinValueValidator(VLAN_VID_MIN),
- MaxValueValidator(VLAN_VID_MAX)
- ),
- help_text=_('Highest permissible ID of a child VLAN')
+ _total_vlan_ids = models.PositiveBigIntegerField(
+ default=VLAN_VID_MAX - VLAN_VID_MIN + 1
)
objects = VLANGroupQuerySet.as_manager()
@@ -96,17 +96,33 @@ class VLANGroup(OrganizationalModel):
if self.scope_id and not self.scope_type:
raise ValidationError(_("Cannot set scope_id without scope_type."))
- # Validate min/max child VID limits
- if self.max_vid < self.min_vid:
- raise ValidationError({
- 'max_vid': _("Maximum child VID must be greater than or equal to minimum child VID")
- })
+ # Validate VID ranges
+ if self.vid_ranges and check_ranges_overlap(self.vid_ranges):
+ raise ValidationError({'vid_ranges': _("Ranges cannot overlap.")})
+ for vid_range in self.vid_ranges:
+ if vid_range.lower >= vid_range.upper:
+ raise ValidationError({
+ 'vid_ranges': _(
+ "Maximum child VID must be greater than or equal to minimum child VID ({value})"
+ ).format(value=vid_range)
+ })
+
+ def save(self, *args, **kwargs):
+ self._total_vlan_ids = 0
+ for vid_range in self.vid_ranges:
+ self._total_vlan_ids += vid_range.upper - vid_range.lower + 1
+
+ super().save(*args, **kwargs)
def get_available_vids(self):
"""
Return all available VLANs within this group.
"""
- available_vlans = {vid for vid in range(self.min_vid, self.max_vid + 1)}
+ available_vlans = set()
+ for vlan_range in self.vid_ranges:
+ available_vlans = available_vlans.union({
+ vid for vid in range(vlan_range.lower, vlan_range.upper)
+ })
available_vlans -= set(VLAN.objects.filter(group=self).values_list('vid', flat=True))
return sorted(available_vlans)
@@ -126,6 +142,10 @@ class VLANGroup(OrganizationalModel):
"""
return VLAN.objects.filter(group=self).order_by('vid')
+ @property
+ def vid_ranges_list(self):
+ return ranges_to_string(self.vid_ranges)
+
class VLAN(PrimaryModel):
"""
@@ -231,13 +251,14 @@ class VLAN(PrimaryModel):
).format(group=self.group, scope=self.group.scope, site=self.site)
)
- # Validate group min/max VIDs
- if self.group and not self.group.min_vid <= self.vid <= self.group.max_vid:
- raise ValidationError({
- 'vid': _(
- "VID must be between {minimum} and {maximum} for VLANs in group {group}"
- ).format(minimum=self.group.min_vid, maximum=self.group.max_vid, group=self.group)
- })
+ # Check that the VLAN ID is permitted in the assigned group (if any)
+ if self.group:
+ if not any([self.vid in r for r in self.group.vid_ranges]):
+ raise ValidationError({
+ 'vid': _(
+ "VID must be in ranges {ranges} for VLANs in group {group}"
+ ).format(ranges=ranges_to_string(self.group.vid_ranges), group=self.group)
+ })
def get_status_color(self):
return VLANStatusChoices.colors.get(self.status)
diff --git a/netbox/ipam/querysets.py b/netbox/ipam/querysets.py
index a3f37fe3c..717c63a37 100644
--- a/netbox/ipam/querysets.py
+++ b/netbox/ipam/querysets.py
@@ -9,6 +9,7 @@ from utilities.querysets import RestrictedQuerySet
__all__ = (
'ASNRangeQuerySet',
'PrefixQuerySet',
+ 'VLANGroupQuerySet',
'VLANQuerySet',
)
@@ -63,7 +64,7 @@ class VLANGroupQuerySet(RestrictedQuerySet):
return self.annotate(
vlan_count=count_related(VLAN, 'group'),
- utilization=Round(F('vlan_count') / (F('max_vid') - F('min_vid') + 1.0) * 100, 2)
+ utilization=Round(F('vlan_count') * 100 / F('_total_vlan_ids'), 2)
)
diff --git a/netbox/ipam/search.py b/netbox/ipam/search.py
index a1cddbb1a..59b741b8f 100644
--- a/netbox/ipam/search.py
+++ b/netbox/ipam/search.py
@@ -154,9 +154,8 @@ class VLANGroupIndex(SearchIndex):
('name', 100),
('slug', 110),
('description', 500),
- ('max_vid', 2000),
)
- display_attrs = ('scope_type', 'min_vid', 'max_vid', 'description')
+ display_attrs = ('scope_type', 'description')
@register_search
diff --git a/netbox/ipam/tables/vlans.py b/netbox/ipam/tables/vlans.py
index 11de0381c..1b428aeb6 100644
--- a/netbox/ipam/tables/vlans.py
+++ b/netbox/ipam/tables/vlans.py
@@ -72,6 +72,10 @@ class VLANGroupTable(NetBoxTable):
linkify=True,
orderable=False
)
+ vid_ranges_list = tables.Column(
+ verbose_name=_('VID Ranges'),
+ orderable=False
+ )
vlan_count = columns.LinkedCountColumn(
viewname='ipam:vlan_list',
url_params={'group_id': 'pk'},
@@ -91,7 +95,7 @@ class VLANGroupTable(NetBoxTable):
class Meta(NetBoxTable.Meta):
model = VLANGroup
fields = (
- 'pk', 'id', 'name', 'scope_type', 'scope', 'min_vid', 'max_vid', 'vlan_count', 'slug', 'description',
+ 'pk', 'id', 'name', 'scope_type', 'scope', 'vid_ranges_list', 'vlan_count', 'slug', 'description',
'tags', 'created', 'last_updated', 'actions', 'utilization',
)
default_columns = ('pk', 'name', 'scope_type', 'scope', 'vlan_count', 'utilization', 'description')
diff --git a/netbox/ipam/tests/test_api.py b/netbox/ipam/tests/test_api.py
index 2cf7a2f1c..00c240769 100644
--- a/netbox/ipam/tests/test_api.py
+++ b/netbox/ipam/tests/test_api.py
@@ -8,6 +8,7 @@ from dcim.models import Device, DeviceRole, DeviceType, Interface, Manufacturer,
from ipam.choices import *
from ipam.models import *
from tenancy.models import Tenant
+from utilities.data import string_to_ranges
from utilities.testing import APITestCase, APIViewTestCases, create_test_device, disable_warnings
@@ -882,8 +883,7 @@ class VLANGroupTest(APIViewTestCases.APIViewTestCase):
vlangroup = VLANGroup.objects.create(
name='VLAN Group X',
slug='vlan-group-x',
- min_vid=MIN_VID,
- max_vid=MAX_VID
+ vid_ranges=string_to_ranges(f"{MIN_VID}-{MAX_VID}")
)
# Create a set of VLANs within the group
diff --git a/netbox/ipam/tests/test_filtersets.py b/netbox/ipam/tests/test_filtersets.py
index 8f07a241a..e149c0a8d 100644
--- a/netbox/ipam/tests/test_filtersets.py
+++ b/netbox/ipam/tests/test_filtersets.py
@@ -1,4 +1,5 @@
from django.contrib.contenttypes.models import ContentType
+from django.db.backends.postgresql.psycopg_any import NumericRange
from django.test import TestCase
from netaddr import IPNetwork
@@ -1465,6 +1466,7 @@ class FHRPGroupAssignmentTestCase(TestCase, ChangeLoggedFilterSetTests):
class VLANGroupTestCase(TestCase, ChangeLoggedFilterSetTests):
queryset = VLANGroup.objects.all()
filterset = VLANGroupFilterSet
+ ignore_fields = ('vid_ranges',)
@classmethod
def setUpTestData(cls):
@@ -1494,14 +1496,55 @@ class VLANGroupTestCase(TestCase, ChangeLoggedFilterSetTests):
cluster.save()
vlan_groups = (
- VLANGroup(name='VLAN Group 1', slug='vlan-group-1', scope=region, description='foobar1'),
- VLANGroup(name='VLAN Group 2', slug='vlan-group-2', scope=sitegroup, description='foobar2'),
- VLANGroup(name='VLAN Group 3', slug='vlan-group-3', scope=site, description='foobar3'),
- VLANGroup(name='VLAN Group 4', slug='vlan-group-4', scope=location),
- VLANGroup(name='VLAN Group 5', slug='vlan-group-5', scope=rack),
- VLANGroup(name='VLAN Group 6', slug='vlan-group-6', scope=clustergroup),
- VLANGroup(name='VLAN Group 7', slug='vlan-group-7', scope=cluster),
- VLANGroup(name='VLAN Group 8', slug='vlan-group-8'),
+ VLANGroup(
+ name='VLAN Group 1',
+ slug='vlan-group-1',
+ vid_ranges=[NumericRange(1, 11), NumericRange(100, 200)],
+ scope=region,
+ description='foobar1'
+ ),
+ VLANGroup(
+ name='VLAN Group 2',
+ slug='vlan-group-2',
+ vid_ranges=[NumericRange(1, 11), NumericRange(200, 300)],
+ scope=sitegroup,
+ description='foobar2'
+ ),
+ VLANGroup(
+ name='VLAN Group 3',
+ slug='vlan-group-3',
+ vid_ranges=[NumericRange(1, 11), NumericRange(300, 400)],
+ scope=site,
+ description='foobar3'
+ ),
+ VLANGroup(
+ name='VLAN Group 4',
+ slug='vlan-group-4',
+ vid_ranges=[NumericRange(1, 11), NumericRange(400, 500)],
+ scope=location
+ ),
+ VLANGroup(
+ name='VLAN Group 5',
+ slug='vlan-group-5',
+ vid_ranges=[NumericRange(1, 11), NumericRange(500, 600)],
+ scope=rack
+ ),
+ VLANGroup(
+ name='VLAN Group 6',
+ slug='vlan-group-6',
+ vid_ranges=[NumericRange(1, 11), NumericRange(600, 700)],
+ scope=clustergroup
+ ),
+ VLANGroup(
+ name='VLAN Group 7',
+ slug='vlan-group-7',
+ vid_ranges=[NumericRange(1, 11), NumericRange(700, 800)],
+ scope=cluster
+ ),
+ VLANGroup(
+ name='VLAN Group 8',
+ slug='vlan-group-8'
+ ),
)
VLANGroup.objects.bulk_create(vlan_groups)
@@ -1521,6 +1564,12 @@ class VLANGroupTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'description': ['foobar1', 'foobar2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+ def test_contains_vid(self):
+ params = {'contains_vid': 123}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+ params = {'contains_vid': 1}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 8)
+
def test_region(self):
params = {'region': Region.objects.first().pk}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
diff --git a/netbox/ipam/tests/test_models.py b/netbox/ipam/tests/test_models.py
index d0f42e8a6..39eb33a4f 100644
--- a/netbox/ipam/tests/test_models.py
+++ b/netbox/ipam/tests/test_models.py
@@ -1,6 +1,7 @@
from django.core.exceptions import ValidationError
from django.test import TestCase, override_settings
from netaddr import IPNetwork, IPSet
+from utilities.data import string_to_ranges
from ipam.choices import *
from ipam.models import *
@@ -509,8 +510,7 @@ class TestVLANGroup(TestCase):
vlangroup = VLANGroup.objects.create(
name='VLAN Group 1',
slug='vlan-group-1',
- min_vid=100,
- max_vid=199
+ vid_ranges=string_to_ranges('100-199'),
)
VLAN.objects.bulk_create((
VLAN(name='VLAN 100', vid=100, group=vlangroup),
@@ -533,3 +533,13 @@ class TestVLANGroup(TestCase):
VLAN.objects.create(name='VLAN 104', vid=104, group=vlangroup)
self.assertEqual(vlangroup.get_next_available_vid(), 105)
+
+ def test_vid_validation(self):
+ vlangroup = VLANGroup.objects.first()
+
+ vlan = VLAN(vid=1, name='VLAN 1', group=vlangroup)
+ with self.assertRaises(ValidationError):
+ vlan.full_clean()
+
+ vlan = VLAN(vid=109, name='VLAN 109', group=vlangroup)
+ vlan.full_clean()
diff --git a/netbox/ipam/tests/test_views.py b/netbox/ipam/tests/test_views.py
index bc42341ba..2acb80ac1 100644
--- a/netbox/ipam/tests/test_views.py
+++ b/netbox/ipam/tests/test_views.py
@@ -764,9 +764,8 @@ class VLANGroupTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
cls.form_data = {
'name': 'VLAN Group X',
'slug': 'vlan-group-x',
- 'min_vid': 1,
- 'max_vid': 4094,
'description': 'A new VLAN group',
+ 'vid_ranges': '100-199,300-399',
'tags': [t.pk for t in tags],
}
diff --git a/netbox/ipam/utils.py b/netbox/ipam/utils.py
index 21b90fbcd..ccf6cb632 100644
--- a/netbox/ipam/utils.py
+++ b/netbox/ipam/utils.py
@@ -90,12 +90,12 @@ def add_available_ipaddresses(prefix, ipaddress_list, is_pool=False):
return output
-def add_available_vlans(vlans, vlan_group=None):
+def available_vlans_from_range(vlans, vlan_group, vlan_range):
"""
Create fake records for all gaps between used VLANs
"""
- min_vid = vlan_group.min_vid if vlan_group else VLAN_VID_MIN
- max_vid = vlan_group.max_vid if vlan_group else VLAN_VID_MAX
+ min_vid = int(vlan_range.lower) if vlan_range else VLAN_VID_MIN
+ max_vid = int(vlan_range.upper) if vlan_range else VLAN_VID_MAX
if not vlans:
return [{
@@ -128,6 +128,17 @@ def add_available_vlans(vlans, vlan_group=None):
'available': max_vid - prev_vid,
})
+ return new_vlans
+
+
+def add_available_vlans(vlans, vlan_group):
+ """
+ Create fake records for all gaps between used VLANs
+ """
+ new_vlans = []
+ for vlan_range in vlan_group.vid_ranges:
+ new_vlans.extend(available_vlans_from_range(vlans, vlan_group, vlan_range))
+
vlans = list(vlans) + new_vlans
vlans.sort(key=lambda v: v.vid if type(v) is VLAN else v['vid'])
diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py
index 12c86c533..67d56f15e 100644
--- a/netbox/ipam/views.py
+++ b/netbox/ipam/views.py
@@ -7,6 +7,7 @@ from django.utils.translation import gettext as _
from circuits.models import Provider
from dcim.filtersets import InterfaceFilterSet
+from dcim.forms import InterfaceFilterForm
from dcim.models import Interface, Site
from netbox.views import generic
from tenancy.views import ObjectContactsView
@@ -14,6 +15,7 @@ from utilities.query import count_related
from utilities.tables import get_table_ordering
from utilities.views import GetRelatedModelsMixin, ViewTab, register_model_view
from virtualization.filtersets import VMInterfaceFilterSet
+from virtualization.forms import VMInterfaceFilterForm
from virtualization.models import VMInterface
from . import filtersets, forms, tables
from .choices import PrefixStatusChoices
@@ -206,6 +208,7 @@ class ASNRangeASNsView(generic.ObjectChildrenView):
child_model = ASN
table = tables.ASNTable
filterset = filtersets.ASNFilterSet
+ filterset_form = forms.ASNFilterForm
tab = ViewTab(
label=_('ASNs'),
badge=lambda x: x.get_child_asns().count(),
@@ -337,6 +340,7 @@ class AggregatePrefixesView(generic.ObjectChildrenView):
child_model = Prefix
table = tables.PrefixTable
filterset = filtersets.PrefixFilterSet
+ filterset_form = forms.PrefixFilterForm
template_name = 'ipam/aggregate/prefixes.html'
tab = ViewTab(
label=_('Prefixes'),
@@ -523,6 +527,7 @@ class PrefixPrefixesView(generic.ObjectChildrenView):
child_model = Prefix
table = tables.PrefixTable
filterset = filtersets.PrefixFilterSet
+ filterset_form = forms.PrefixFilterForm
template_name = 'ipam/prefix/prefixes.html'
tab = ViewTab(
label=_('Child Prefixes'),
@@ -558,6 +563,7 @@ class PrefixIPRangesView(generic.ObjectChildrenView):
child_model = IPRange
table = tables.IPRangeTable
filterset = filtersets.IPRangeFilterSet
+ filterset_form = forms.IPRangeFilterForm
template_name = 'ipam/prefix/ip_ranges.html'
tab = ViewTab(
label=_('Child Ranges'),
@@ -584,6 +590,7 @@ class PrefixIPAddressesView(generic.ObjectChildrenView):
child_model = IPAddress
table = tables.IPAddressTable
filterset = filtersets.IPAddressFilterSet
+ filterset_form = forms.IPAddressFilterForm
template_name = 'ipam/prefix/ip_addresses.html'
tab = ViewTab(
label=_('IP Addresses'),
@@ -683,6 +690,7 @@ class IPRangeIPAddressesView(generic.ObjectChildrenView):
child_model = IPAddress
table = tables.IPAddressTable
filterset = filtersets.IPAddressFilterSet
+ filterset_form = forms.IPRangeFilterForm
template_name = 'ipam/iprange/ip_addresses.html'
tab = ViewTab(
label=_('IP Addresses'),
@@ -885,6 +893,7 @@ class IPAddressRelatedIPsView(generic.ObjectChildrenView):
child_model = IPAddress
table = tables.IPAddressTable
filterset = filtersets.IPAddressFilterSet
+ filterset_form = forms.IPAddressFilterForm
tab = ViewTab(
label=_('Related IPs'),
badge=lambda x: x.get_related_ips().count(),
@@ -906,7 +915,7 @@ class IPAddressContactsView(ObjectContactsView):
#
class VLANGroupListView(generic.ObjectListView):
- queryset = VLANGroup.objects.annotate_utilization().prefetch_related('tags')
+ queryset = VLANGroup.objects.annotate_utilization()
filterset = filtersets.VLANGroupFilterSet
filterset_form = forms.VLANGroupFilterForm
table = tables.VLANGroupTable
@@ -914,7 +923,7 @@ class VLANGroupListView(generic.ObjectListView):
@register_model_view(VLANGroup)
class VLANGroupView(GetRelatedModelsMixin, generic.ObjectView):
- queryset = VLANGroup.objects.annotate_utilization().prefetch_related('tags')
+ queryset = VLANGroup.objects.annotate_utilization()
def get_extra_context(self, request, instance):
return {
@@ -957,6 +966,7 @@ class VLANGroupVLANsView(generic.ObjectChildrenView):
child_model = VLAN
table = tables.VLANTable
filterset = filtersets.VLANFilterSet
+ filterset_form = forms.VLANFilterForm
tab = ViewTab(
label=_('VLANs'),
badge=lambda x: x.get_child_vlans().count(),
@@ -1112,6 +1122,7 @@ class VLANInterfacesView(generic.ObjectChildrenView):
child_model = Interface
table = tables.VLANDevicesTable
filterset = InterfaceFilterSet
+ filterset_form = InterfaceFilterForm
tab = ViewTab(
label=_('Device Interfaces'),
badge=lambda x: x.get_interfaces().count(),
@@ -1129,6 +1140,7 @@ class VLANVMInterfacesView(generic.ObjectChildrenView):
child_model = VMInterface
table = tables.VLANVirtualMachinesTable
filterset = VMInterfaceFilterSet
+ filterset_form = VMInterfaceFilterForm
tab = ViewTab(
label=_('VM Interfaces'),
badge=lambda x: x.get_vminterfaces().count(),
diff --git a/netbox/netbox/api/fields.py b/netbox/netbox/api/fields.py
index 08ffd0bc4..e7d1ef574 100644
--- a/netbox/netbox/api/fields.py
+++ b/netbox/netbox/api/fields.py
@@ -1,4 +1,5 @@
from django.core.exceptions import ObjectDoesNotExist
+from django.db.backends.postgresql.psycopg_any import NumericRange
from django.utils.translation import gettext as _
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import extend_schema_field
@@ -11,6 +12,7 @@ __all__ = (
'ChoiceField',
'ContentTypeField',
'IPNetworkSerializer',
+ 'IntegerRangeSerializer',
'RelatedObjectCountField',
'SerializedPKRelatedField',
)
@@ -154,3 +156,19 @@ class RelatedObjectCountField(serializers.ReadOnlyField):
self.relation = relation
super().__init__(**kwargs)
+
+
+class IntegerRangeSerializer(serializers.Serializer):
+ """
+ Represents a range of integers.
+ """
+ def to_internal_value(self, data):
+ if not isinstance(data, (list, tuple)) or len(data) != 2:
+ raise ValidationError(_("Ranges must be specified in the form (lower, upper)."))
+ if type(data[0]) is not int or type(data[1]) is not int:
+ raise ValidationError(_("Range boundaries must be defined as integers."))
+
+ return NumericRange(data[0], data[1], bounds='[]')
+
+ def to_representation(self, instance):
+ return instance.lower, instance.upper - 1
diff --git a/netbox/netbox/events.py b/netbox/netbox/events.py
new file mode 100644
index 000000000..15691aafb
--- /dev/null
+++ b/netbox/netbox/events.py
@@ -0,0 +1,45 @@
+from dataclasses import dataclass
+
+from netbox.registry import registry
+
+EVENT_TYPE_INFO = 'info'
+EVENT_TYPE_SUCCESS = 'success'
+EVENT_TYPE_WARNING = 'warning'
+EVENT_TYPE_DANGER = 'danger'
+
+__all__ = (
+ 'EVENT_TYPE_DANGER',
+ 'EVENT_TYPE_INFO',
+ 'EVENT_TYPE_SUCCESS',
+ 'EVENT_TYPE_WARNING',
+ 'Event',
+)
+
+
+@dataclass
+class Event:
+ name: str
+ text: str
+ type: str = EVENT_TYPE_INFO
+
+ def __str__(self):
+ return self.text
+
+ def register(self):
+ registry['events'][self.name] = self
+
+ def color(self):
+ return {
+ EVENT_TYPE_INFO: 'blue',
+ EVENT_TYPE_SUCCESS: 'green',
+ EVENT_TYPE_WARNING: 'orange',
+ EVENT_TYPE_DANGER: 'red',
+ }.get(self.type)
+
+ def icon(self):
+ return {
+ EVENT_TYPE_INFO: 'mdi mdi-information',
+ EVENT_TYPE_SUCCESS: 'mdi mdi-check-circle',
+ EVENT_TYPE_WARNING: 'mdi mdi-alert-box',
+ EVENT_TYPE_DANGER: 'mdi mdi-alert-octagon',
+ }.get(self.type)
diff --git a/netbox/netbox/forms/__init__.py b/netbox/netbox/forms/__init__.py
index fa82689a5..f88fb18bc 100644
--- a/netbox/netbox/forms/__init__.py
+++ b/netbox/netbox/forms/__init__.py
@@ -1,7 +1,7 @@
import re
from django import forms
-from django.utils.translation import gettext as _
+from django.utils.translation import gettext_lazy as _
from netbox.search import LookupTypes
from netbox.search.backends import search_backend
@@ -36,7 +36,8 @@ class SearchForm(forms.Form):
lookup = forms.ChoiceField(
choices=LOOKUP_CHOICES,
initial=LookupTypes.PARTIAL,
- required=False
+ required=False,
+ label=_('Lookup')
)
def __init__(self, *args, **kwargs):
diff --git a/netbox/netbox/middleware.py b/netbox/netbox/middleware.py
index 58c70451c..8012965a4 100644
--- a/netbox/netbox/middleware.py
+++ b/netbox/netbox/middleware.py
@@ -1,6 +1,5 @@
import logging
import uuid
-from urllib import parse
from django.conf import settings
from django.contrib import auth, messages
@@ -33,20 +32,15 @@ class CoreMiddleware:
# Assign a random unique ID to the request. This will be used for change logging.
request.id = uuid.uuid4()
- # Enforce the LOGIN_REQUIRED config parameter. If true, redirect all non-exempt unauthenticated requests
- # to the login page.
- if (
- settings.LOGIN_REQUIRED and
- not request.user.is_authenticated and
- not request.path_info.startswith(settings.AUTH_EXEMPT_PATHS)
- ):
- login_url = f'{settings.LOGIN_URL}?next={parse.quote(request.get_full_path_info())}'
- return HttpResponseRedirect(login_url)
-
# Enable the event_tracking context manager and process the request.
with event_tracking(request):
response = self.get_response(request)
+ # Check if language cookie should be renewed
+ if request.user.is_authenticated and settings.SESSION_SAVE_EVERY_REQUEST:
+ if language := request.user.config.get('locale.language'):
+ response.set_cookie(settings.LANGUAGE_COOKIE_NAME, language, max_age=request.session.get_expiry_age())
+
# Attach the unique request ID as an HTTP header.
response['X-Request-ID'] = request.id
diff --git a/netbox/netbox/models/__init__.py b/netbox/netbox/models/__init__.py
index 2c262b258..4ba5f60da 100644
--- a/netbox/netbox/models/__init__.py
+++ b/netbox/netbox/models/__init__.py
@@ -29,6 +29,7 @@ class NetBoxFeatureSet(
CustomValidationMixin,
ExportTemplatesMixin,
JournalingMixin,
+ NotificationsMixin,
TagsMixin,
EventRulesMixin
):
diff --git a/netbox/netbox/models/features.py b/netbox/netbox/models/features.py
index 0393bf25d..b270382d3 100644
--- a/netbox/netbox/models/features.py
+++ b/netbox/netbox/models/features.py
@@ -34,6 +34,7 @@ __all__ = (
'ImageAttachmentsMixin',
'JobsMixin',
'JournalingMixin',
+ 'NotificationsMixin',
'SyncedDataMixin',
'TagsMixin',
'register_models',
@@ -377,6 +378,25 @@ class BookmarksMixin(models.Model):
abstract = True
+class NotificationsMixin(models.Model):
+ """
+ Enables support for user notifications.
+ """
+ notifications = GenericRelation(
+ to='extras.Notification',
+ content_type_field='object_type',
+ object_id_field='object_id'
+ )
+ subscriptions = GenericRelation(
+ to='extras.Subscription',
+ content_type_field='object_type',
+ object_id_field='object_id'
+ )
+
+ class Meta:
+ abstract = True
+
+
class JobsMixin(models.Model):
"""
Enables support for job results.
@@ -582,13 +602,14 @@ FEATURES_MAP = {
'custom_fields': CustomFieldsMixin,
'custom_links': CustomLinksMixin,
'custom_validation': CustomValidationMixin,
+ 'event_rules': EventRulesMixin,
'export_templates': ExportTemplatesMixin,
'image_attachments': ImageAttachmentsMixin,
'jobs': JobsMixin,
'journaling': JournalingMixin,
+ 'notifications': NotificationsMixin,
'synced_data': SyncedDataMixin,
'tags': TagsMixin,
- 'event_rules': EventRulesMixin,
}
registry['model_features'].update({
diff --git a/netbox/netbox/navigation/menu.py b/netbox/netbox/navigation/menu.py
index 6db7ac14c..b96465c35 100644
--- a/netbox/netbox/navigation/menu.py
+++ b/netbox/netbox/navigation/menu.py
@@ -24,6 +24,7 @@ ORGANIZATION_MENU = Menu(
label=_('Racks'),
items=(
get_model_item('dcim', 'rack', _('Racks')),
+ get_model_item('dcim', 'racktype', _('Rack Types')),
get_model_item('dcim', 'rackrole', _('Rack Roles')),
get_model_item('dcim', 'rackreservation', _('Reservations')),
MenuItem(
@@ -355,6 +356,7 @@ OPERATIONS_MENU = Menu(
MenuGroup(
label=_('Logging'),
items=(
+ get_model_item('extras', 'notificationgroup', _('Notification Groups')),
get_model_item('extras', 'journalentry', _('Journal Entries'), actions=['import']),
get_model_item('core', 'objectchange', _('Change Log'), actions=[]),
),
@@ -462,16 +464,13 @@ MENUS = [
PROVISIONING_MENU,
CUSTOMIZATION_MENU,
OPERATIONS_MENU,
- ADMIN_MENU,
]
-#
-# Add plugin menus
-#
-
+# Add top-level plugin menus
for menu in registry['plugins']['menus']:
MENUS.append(menu)
+# Add the default "plugins" menu
if registry['plugins']['menu_items']:
# Build the default plugins menu
@@ -485,3 +484,6 @@ if registry['plugins']['menu_items']:
groups=groups
)
MENUS.append(plugins_menu)
+
+# Add the admin menu last
+MENUS.append(ADMIN_MENU)
diff --git a/netbox/netbox/plugins/registration.py b/netbox/netbox/plugins/registration.py
index fbece12e5..c84572794 100644
--- a/netbox/netbox/plugins/registration.py
+++ b/netbox/netbox/plugins/registration.py
@@ -18,8 +18,8 @@ def register_template_extensions(class_list):
"""
Register a list of PluginTemplateExtension classes
"""
- # Validation
for template_extension in class_list:
+ # Validation
if not inspect.isclass(template_extension):
raise TypeError(
_("PluginTemplateExtension class {template_extension} was passed as an instance!").format(
@@ -33,7 +33,17 @@ def register_template_extensions(class_list):
)
)
- registry['plugins']['template_extensions'][template_extension.model].append(template_extension)
+ if template_extension.models:
+ # Registration for multiple models
+ models = template_extension.models
+ elif template_extension.model:
+ # Registration for a single model
+ models = [template_extension.model]
+ else:
+ # Global registration (no specific models)
+ models = [None]
+ for model in models:
+ registry['plugins']['template_extensions'][model].append(template_extension)
def register_menu(menu):
diff --git a/netbox/netbox/plugins/templates.py b/netbox/netbox/plugins/templates.py
index ccd549160..e1f4b7a47 100644
--- a/netbox/netbox/plugins/templates.py
+++ b/netbox/netbox/plugins/templates.py
@@ -20,6 +20,7 @@ class PluginTemplateExtension:
* settings - Global NetBox settings
* config - Plugin-specific configuration parameters
"""
+ models = None
model = None
def __init__(self, context):
@@ -37,6 +38,10 @@ class PluginTemplateExtension:
return get_template(template_name).render({**self.context, **extra_context})
+ #
+ # Global methods
+ #
+
def navbar(self):
"""
Content that will be rendered inside the top navigation menu. Content should be returned as an HTML
@@ -44,6 +49,37 @@ class PluginTemplateExtension:
"""
raise NotImplementedError
+ #
+ # Object list views
+ #
+
+ def list_buttons(self):
+ """
+ Buttons that will be rendered and added to the existing list of buttons on the list view. Content
+ should be returned as an HTML string. Note that content does not need to be marked as safe because this is
+ automatically handled.
+ """
+ raise NotImplementedError
+
+ #
+ # Object detail views
+ #
+
+ def buttons(self):
+ """
+ Buttons that will be rendered and added to the existing list of buttons on the detail page view. Content
+ should be returned as an HTML string. Note that content does not need to be marked as safe because this is
+ automatically handled.
+ """
+ raise NotImplementedError
+
+ def alerts(self):
+ """
+ Arbitrary content to be inserted at the top of an object's detail view. Content should be returned as an
+ HTML string. Note that content does not need to be marked as safe because this is automatically handled.
+ """
+ raise NotImplementedError
+
def left_page(self):
"""
Content that will be rendered on the left of the detail page view. Content should be returned as an
@@ -64,19 +100,3 @@ class PluginTemplateExtension:
HTML string. Note that content does not need to be marked as safe because this is automatically handled.
"""
raise NotImplementedError
-
- def buttons(self):
- """
- Buttons that will be rendered and added to the existing list of buttons on the detail page view. Content
- should be returned as an HTML string. Note that content does not need to be marked as safe because this is
- automatically handled.
- """
- raise NotImplementedError
-
- def list_buttons(self):
- """
- Buttons that will be rendered and added to the existing list of buttons on the list view. Content
- should be returned as an HTML string. Note that content does not need to be marked as safe because this is
- automatically handled.
- """
- raise NotImplementedError
diff --git a/netbox/netbox/registry.py b/netbox/netbox/registry.py
index d783647ec..44cdfb92b 100644
--- a/netbox/netbox/registry.py
+++ b/netbox/netbox/registry.py
@@ -25,6 +25,7 @@ registry = Registry({
'counter_fields': collections.defaultdict(dict),
'data_backends': dict(),
'denormalized_fields': collections.defaultdict(list),
+ 'events': dict(),
'model_features': dict(),
'models': collections.defaultdict(set),
'plugins': dict(),
diff --git a/netbox/netbox/search/backends.py b/netbox/netbox/search/backends.py
index 227a79205..12243e9b6 100644
--- a/netbox/netbox/search/backends.py
+++ b/netbox/netbox/search/backends.py
@@ -8,6 +8,7 @@ from django.db.models.fields.related import ForeignKey
from django.db.models.functions import window
from django.db.models.signals import post_delete, post_save
from django.utils.module_loading import import_string
+from django.utils.translation import gettext_lazy as _
import netaddr
from netaddr.core import AddrFormatError
@@ -39,7 +40,7 @@ class SearchBackend:
# Organize choices by category
categories = defaultdict(dict)
for label, idx in registry['search'].items():
- categories[idx.get_category()][label] = title(idx.model._meta.verbose_name)
+ categories[idx.get_category()][label] = _(title(idx.model._meta.verbose_name))
# Compile a nested tuple of choices for form rendering
results = (
diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py
index 7c586e109..64fb24f09 100644
--- a/netbox/netbox/settings.py
+++ b/netbox/netbox/settings.py
@@ -84,6 +84,16 @@ DEFAULT_PERMISSIONS = getattr(configuration, 'DEFAULT_PERMISSIONS', {
'extras.add_bookmark': ({'user': '$user'},),
'extras.change_bookmark': ({'user': '$user'},),
'extras.delete_bookmark': ({'user': '$user'},),
+ # Permit users to manage their own notifications
+ 'extras.view_notification': ({'user': '$user'},),
+ 'extras.add_notification': ({'user': '$user'},),
+ 'extras.change_notification': ({'user': '$user'},),
+ 'extras.delete_notification': ({'user': '$user'},),
+ # Permit users to manage their own subscriptions
+ 'extras.view_subscription': ({'user': '$user'},),
+ 'extras.add_subscription': ({'user': '$user'},),
+ 'extras.change_subscription': ({'user': '$user'},),
+ 'extras.delete_subscription': ({'user': '$user'},),
# Permit users to manage their own API tokens
'users.view_token': ({'user': '$user'},),
'users.add_token': ({'user': '$user'},),
@@ -149,6 +159,7 @@ SECURE_SSL_REDIRECT = getattr(configuration, 'SECURE_SSL_REDIRECT', False)
SENTRY_DSN = getattr(configuration, 'SENTRY_DSN', None)
SENTRY_ENABLED = getattr(configuration, 'SENTRY_ENABLED', False)
SENTRY_SAMPLE_RATE = getattr(configuration, 'SENTRY_SAMPLE_RATE', 1.0)
+SENTRY_SEND_DEFAULT_PII = getattr(configuration, 'SENTRY_SEND_DEFAULT_PII', False)
SENTRY_TAGS = getattr(configuration, 'SENTRY_TAGS', {})
SENTRY_TRACES_SAMPLE_RATE = getattr(configuration, 'SENTRY_TRACES_SAMPLE_RATE', 0)
SESSION_COOKIE_NAME = getattr(configuration, 'SESSION_COOKIE_NAME', 'sessionid')
@@ -227,6 +238,23 @@ if STORAGE_BACKEND is not None:
return globals().get(name, default)
storages.utils.setting = _setting
+ # django-storage-swift
+ elif STORAGE_BACKEND == 'swift.storage.SwiftStorage':
+ try:
+ import swift.utils # type: ignore
+ except ModuleNotFoundError as e:
+ if getattr(e, 'name') == 'swift':
+ raise ImproperlyConfigured(
+ f"STORAGE_BACKEND is set to {STORAGE_BACKEND} but django-storage-swift is not present. "
+ "It can be installed by running 'pip install django-storage-swift'."
+ )
+ raise e
+
+ # Load all SWIFT_* settings from the user configuration
+ for param, value in STORAGE_CONFIG.items():
+ if param.startswith('SWIFT_'):
+ globals()[param] = value
+
if STORAGE_CONFIG and STORAGE_BACKEND is None:
warnings.warn(
"STORAGE_CONFIG has been set in configuration.py but STORAGE_BACKEND is not defined. STORAGE_CONFIG will be "
@@ -502,15 +530,6 @@ EXEMPT_EXCLUDE_MODELS = (
('users', 'user'),
)
-# All URLs starting with a string listed here are exempt from login enforcement
-AUTH_EXEMPT_PATHS = (
- f'/{BASE_PATH}api/',
- f'/{BASE_PATH}graphql/',
- f'/{BASE_PATH}login/',
- f'/{BASE_PATH}oauth/',
- f'/{BASE_PATH}metrics',
-)
-
# All URLs starting with a string listed here are exempt from maintenance mode enforcement
MAINTENANCE_EXEMPT_PATHS = (
f'/{BASE_PATH}admin/',
@@ -538,7 +557,7 @@ if SENTRY_ENABLED:
release=RELEASE.full_version,
sample_rate=SENTRY_SAMPLE_RATE,
traces_sample_rate=SENTRY_TRACES_SAMPLE_RATE,
- send_default_pii=True,
+ send_default_pii=SENTRY_SEND_DEFAULT_PII,
http_proxy=HTTP_PROXIES.get('http') if HTTP_PROXIES else None,
https_proxy=HTTP_PROXIES.get('https') if HTTP_PROXIES else None
)
diff --git a/netbox/netbox/tests/dummy_plugin/template_content.py b/netbox/netbox/tests/dummy_plugin/template_content.py
index 764faa60e..e9a6b9da1 100644
--- a/netbox/netbox/tests/dummy_plugin/template_content.py
+++ b/netbox/netbox/tests/dummy_plugin/template_content.py
@@ -8,7 +8,13 @@ class GlobalContent(PluginTemplateExtension):
class SiteContent(PluginTemplateExtension):
- model = 'dcim.site'
+ models = ['dcim.site']
+
+ def buttons(self):
+ return "SITE CONTENT - BUTTONS"
+
+ def alerts(self):
+ return "SITE CONTENT - ALERTS"
def left_page(self):
return "SITE CONTENT - LEFT PAGE"
@@ -19,9 +25,6 @@ class SiteContent(PluginTemplateExtension):
def full_width_page(self):
return "SITE CONTENT - FULL WIDTH PAGE"
- def buttons(self):
- return "SITE CONTENT - BUTTONS"
-
def list_buttons(self):
return "SITE CONTENT - LIST BUTTONS"
diff --git a/netbox/netbox/views/generic/bulk_views.py b/netbox/netbox/views/generic/bulk_views.py
index 87e352710..71ce411ba 100644
--- a/netbox/netbox/views/generic/bulk_views.py
+++ b/netbox/netbox/views/generic/bulk_views.py
@@ -176,7 +176,7 @@ class ObjectListView(BaseMultiObjectView, ActionsMixin, TableMixin):
'model': model,
'table': table,
'actions': actions,
- 'filter_form': self.filterset_form(request.GET, label_suffix='') if self.filterset_form else None,
+ 'filter_form': self.filterset_form(request.GET) if self.filterset_form else None,
'prerequisite_model': get_prerequisite_model(self.queryset),
**self.get_extra_context(request),
}
diff --git a/netbox/netbox/views/generic/feature_views.py b/netbox/netbox/views/generic/feature_views.py
index 9d898be2f..821d87e17 100644
--- a/netbox/netbox/views/generic/feature_views.py
+++ b/netbox/netbox/views/generic/feature_views.py
@@ -1,3 +1,4 @@
+from django.contrib.auth.mixins import LoginRequiredMixin
from django.contrib.contenttypes.models import ContentType
from django.contrib import messages
from django.db import transaction
@@ -12,7 +13,7 @@ from extras.forms import JournalEntryForm
from extras.models import JournalEntry
from extras.tables import JournalEntryTable
from utilities.permissions import get_permission_for_model
-from utilities.views import GetReturnURLMixin, ViewTab
+from utilities.views import ConditionalLoginRequiredMixin, GetReturnURLMixin, ViewTab
from .base import BaseMultiObjectView
__all__ = (
@@ -24,7 +25,7 @@ __all__ = (
)
-class ObjectChangeLogView(View):
+class ObjectChangeLogView(ConditionalLoginRequiredMixin, View):
"""
Present a history of changes made to a particular object. The model class must be passed as a keyword argument
when referencing this view in a URL path. For example:
@@ -77,7 +78,7 @@ class ObjectChangeLogView(View):
})
-class ObjectJournalView(View):
+class ObjectJournalView(ConditionalLoginRequiredMixin, View):
"""
Show all journal entries for an object. The model class must be passed as a keyword argument when referencing this
view in a URL path. For example:
@@ -138,7 +139,7 @@ class ObjectJournalView(View):
})
-class ObjectJobsView(View):
+class ObjectJobsView(ConditionalLoginRequiredMixin, View):
"""
Render a list of all Job assigned to an object. For example:
@@ -191,7 +192,7 @@ class ObjectJobsView(View):
})
-class ObjectSyncDataView(View):
+class ObjectSyncDataView(LoginRequiredMixin, View):
def post(self, request, model, **kwargs):
"""
diff --git a/netbox/netbox/views/generic/object_views.py b/netbox/netbox/views/generic/object_views.py
index 243ae2547..cad7facd3 100644
--- a/netbox/netbox/views/generic/object_views.py
+++ b/netbox/netbox/views/generic/object_views.py
@@ -87,12 +87,14 @@ class ObjectChildrenView(ObjectView, ActionsMixin, TableMixin):
child_model: The model class which represents the child objects
table: The django-tables2 Table class used to render the child objects list
filterset: A django-filter FilterSet that is applied to the queryset
+ filterset_form: The form class used to render filter options
actions: A mapping of supported actions to their required permissions. When adding custom actions, bulk
action names must be prefixed with `bulk_`. (See ActionsMixin.)
"""
child_model = None
table = None
filterset = None
+ filterset_form = None
template_name = 'generic/object_children.html'
def get_children(self, request, parent):
@@ -152,6 +154,7 @@ class ObjectChildrenView(ObjectView, ActionsMixin, TableMixin):
'base_template': f'{instance._meta.app_label}/{instance._meta.model_name}.html',
'table': table,
'table_config': f'{table.name}_config',
+ 'filter_form': self.filterset_form(request.GET) if self.filterset_form else None,
'actions': actions,
'tab': self.tab,
'return_url': request.get_full_path(),
diff --git a/netbox/netbox/views/htmx.py b/netbox/netbox/views/htmx.py
index 04ddcb06b..b7894e36c 100644
--- a/netbox/netbox/views/htmx.py
+++ b/netbox/netbox/views/htmx.py
@@ -1,3 +1,4 @@
+from django.contrib.auth.mixins import LoginRequiredMixin
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ObjectDoesNotExist
from django.http import Http404
@@ -6,7 +7,7 @@ from django.utils.module_loading import import_string
from django.views.generic import View
-class ObjectSelectorView(View):
+class ObjectSelectorView(LoginRequiredMixin, View):
template_name = 'htmx/object_selector.html'
def get(self, request):
diff --git a/netbox/netbox/views/misc.py b/netbox/netbox/views/misc.py
index 569fcf728..c584e99e4 100644
--- a/netbox/netbox/views/misc.py
+++ b/netbox/netbox/views/misc.py
@@ -19,6 +19,7 @@ from netbox.search.backends import search_backend
from netbox.tables import SearchTable
from utilities.htmx import htmx_partial
from utilities.paginator import EnhancedPaginator, get_paginate_count
+from utilities.views import ConditionalLoginRequiredMixin
__all__ = (
'HomeView',
@@ -28,7 +29,7 @@ __all__ = (
Link = namedtuple('Link', ('label', 'viewname', 'permission', 'count'))
-class HomeView(View):
+class HomeView(ConditionalLoginRequiredMixin, View):
template_name = 'home.html'
def get(self, request):
@@ -62,7 +63,7 @@ class HomeView(View):
})
-class SearchView(View):
+class SearchView(ConditionalLoginRequiredMixin, View):
def get(self, request):
results = []
diff --git a/netbox/project-static/dist/netbox.css b/netbox/project-static/dist/netbox.css
index 9e5931960..bd2bd1134 100644
Binary files a/netbox/project-static/dist/netbox.css and b/netbox/project-static/dist/netbox.css differ
diff --git a/netbox/project-static/dist/netbox.js b/netbox/project-static/dist/netbox.js
index 27db4718b..5624e7298 100644
Binary files a/netbox/project-static/dist/netbox.js and b/netbox/project-static/dist/netbox.js differ
diff --git a/netbox/project-static/dist/netbox.js.map b/netbox/project-static/dist/netbox.js.map
index d54ded87b..af923dd4f 100644
Binary files a/netbox/project-static/dist/netbox.js.map and b/netbox/project-static/dist/netbox.js.map differ
diff --git a/netbox/project-static/package.json b/netbox/project-static/package.json
index 482e78598..9cb819f84 100644
--- a/netbox/project-static/package.json
+++ b/netbox/project-static/package.json
@@ -28,7 +28,7 @@
"bootstrap": "5.3.3",
"clipboard": "2.0.11",
"flatpickr": "4.6.13",
- "gridstack": "10.2.1",
+ "gridstack": "10.3.0",
"htmx.org": "1.9.12",
"query-string": "9.0.0",
"sass": "1.77.6",
diff --git a/netbox/project-static/src/select/classes/dynamicTomSelect.ts b/netbox/project-static/src/select/classes/dynamicTomSelect.ts
index 758462b60..72c9fe518 100644
--- a/netbox/project-static/src/select/classes/dynamicTomSelect.ts
+++ b/netbox/project-static/src/select/classes/dynamicTomSelect.ts
@@ -74,20 +74,25 @@ export class DynamicTomSelect extends TomSelect {
load(value: string) {
const self = this;
- const url = self.getRequestUrl(value);
// Automatically clear any cached options. (Only options included
// in the API response should be present.)
self.clearOptions();
- addClasses(self.wrapper, self.settings.loadingClass);
- self.loading++;
-
// Populate the null option (if any) if not searching
if (self.nullOption && !value) {
self.addOption(self.nullOption);
}
+ // Get the API request URL. If none is provided, abort as no request can be made.
+ const url = self.getRequestUrl(value);
+ if (!url) {
+ return;
+ }
+
+ addClasses(self.wrapper, self.settings.loadingClass);
+ self.loading++;
+
// Make the API request
fetch(url)
.then(response => response.json())
@@ -129,6 +134,9 @@ export class DynamicTomSelect extends TomSelect {
for (const result of this.api_url.matchAll(new RegExp(`({{${key}}})`, 'g'))) {
if (value) {
url = replaceAll(url, result[1], value.toString());
+ } else {
+ // No value is available to replace the token; abort.
+ return '';
}
}
}
diff --git a/netbox/project-static/styles/custom/_notifications.scss b/netbox/project-static/styles/custom/_notifications.scss
new file mode 100644
index 000000000..4777362aa
--- /dev/null
+++ b/netbox/project-static/styles/custom/_notifications.scss
@@ -0,0 +1,9 @@
+@use 'sass:map';
+
+// Mute read notifications
+tr[data-read=True] {
+ td {
+ background-color: var(--#{$prefix}bg-surface-secondary);
+ color: $text-muted;
+ }
+}
diff --git a/netbox/project-static/styles/netbox.scss b/netbox/project-static/styles/netbox.scss
index 46d8da9aa..17cadf21c 100644
--- a/netbox/project-static/styles/netbox.scss
+++ b/netbox/project-static/styles/netbox.scss
@@ -25,3 +25,4 @@
@import 'custom/interfaces';
@import 'custom/markdown';
@import 'custom/misc';
+@import 'custom/notifications';
diff --git a/netbox/project-static/yarn.lock b/netbox/project-static/yarn.lock
index 4f126692b..ac1759cb4 100644
--- a/netbox/project-static/yarn.lock
+++ b/netbox/project-static/yarn.lock
@@ -1759,10 +1759,10 @@ graphql@16.8.1:
resolved "https://registry.yarnpkg.com/graphql/-/graphql-16.8.1.tgz#1930a965bef1170603702acdb68aedd3f3cf6f07"
integrity sha512-59LZHPdGZVh695Ud9lRzPBVTtlX9ZCV150Er2W43ro37wVof0ctenSaskPPjN7lVTIN8mSZt8PHUNKZuNQUuxw==
-gridstack@10.2.1:
- version "10.2.1"
- resolved "https://registry.yarnpkg.com/gridstack/-/gridstack-10.2.1.tgz#3ce6119ae86cfb0a533c5f0d15b03777a55384ca"
- integrity sha512-UAPKnIvd9sIqPDFMtKMqj0G5GDj8MUFPcelRJq7FzQFSxSYBblKts/Gd52iEJg0EvTFP51t6ZuMWGx0pSSFBdw==
+gridstack@10.3.0:
+ version "10.3.0"
+ resolved "https://registry.yarnpkg.com/gridstack/-/gridstack-10.3.0.tgz#8fa065f896d0a880c5c54c24d189f3197184488a"
+ integrity sha512-eGKsmU2TppV4coyDu9IIdIkm4qjgLLdjlEOFwQyQMuSwfOpzSfLdPc8du0HuebGr7CvAIrJxN4lBOmGrWSBg9g==
has-bigints@^1.0.1, has-bigints@^1.0.2:
version "1.0.2"
diff --git a/netbox/templates/account/base.html b/netbox/templates/account/base.html
index b0e4e702a..01d288ea6 100644
--- a/netbox/templates/account/base.html
+++ b/netbox/templates/account/base.html
@@ -9,6 +9,12 @@
{% trans "Bookmarks" %}
+
+ {% trans "Notifications" %}
+
+
+ {% trans "Subscriptions" %}
+
{% trans "Preferences" %}
diff --git a/netbox/templates/account/notifications.html b/netbox/templates/account/notifications.html
new file mode 100644
index 000000000..5a471ef25
--- /dev/null
+++ b/netbox/templates/account/notifications.html
@@ -0,0 +1,32 @@
+{% extends 'account/base.html' %}
+{% load buttons %}
+{% load helpers %}
+{% load render_table from django_tables2 %}
+{% load i18n %}
+
+{% block title %}{% trans "Notifications" %}{% endblock %}
+
+{% block content %}
+
+{% endblock %}
diff --git a/netbox/templates/account/subscriptions.html b/netbox/templates/account/subscriptions.html
new file mode 100644
index 000000000..d97053d63
--- /dev/null
+++ b/netbox/templates/account/subscriptions.html
@@ -0,0 +1,32 @@
+{% extends 'account/base.html' %}
+{% load buttons %}
+{% load helpers %}
+{% load render_table from django_tables2 %}
+{% load i18n %}
+
+{% block title %}{% trans "Subscriptions" %}{% endblock %}
+
+{% block content %}
+
+{% endblock %}
diff --git a/netbox/templates/base/layout.html b/netbox/templates/base/layout.html
index d374b98cc..fc6c4d60d 100644
--- a/netbox/templates/base/layout.html
+++ b/netbox/templates/base/layout.html
@@ -117,6 +117,10 @@ Blocks:
+ {# Page alerts #}
+ {% block alerts %}{% endblock %}
+ {# /Page alerts #}
+
{# Page content #}
{% block content %}{% endblock %}
{# /Page content #}
diff --git a/netbox/templates/core/system.html b/netbox/templates/core/system.html
index 7109e54c9..8b6858054 100644
--- a/netbox/templates/core/system.html
+++ b/netbox/templates/core/system.html
@@ -93,9 +93,8 @@
- {% include 'core/inc/config_data.html' with config=config.data %}
+ {% include 'core/inc/config_data.html' %}
-
{% endblock content %}
diff --git a/netbox/templates/dcim/device.html b/netbox/templates/dcim/device.html
index 1b2488e56..19c3adc07 100644
--- a/netbox/templates/dcim/device.html
+++ b/netbox/templates/dcim/device.html
@@ -125,28 +125,30 @@
-
- {% trans "Device" %} |
- {% trans "Position" %} |
- {% trans "Master" %} |
- {% trans "Priority" %} |
+
+
+ {% trans "Device" %} |
+ {% trans "Position" %} |
+ {% trans "Master" %} |
+ {% trans "Priority" %} |
+
+
{% for vc_member in vc_members %}
-
-
- {{ vc_member|linkify }}
- |
-
- {% badge vc_member.vc_position show_empty=True %}
- |
-
- {% if object.virtual_chassis.master == vc_member %}{% endif %}
- |
-
- {{ vc_member.vc_priority|placeholder }}
- |
-
+
+ {{ vc_member|linkify }} |
+ {% badge vc_member.vc_position show_empty=True %} |
+
+ {% if object.virtual_chassis.master == vc_member %}
+ {% checkmark True %}
+ {% else %}
+ {{ ''|placeholder }}
+ {% endif %}
+ |
+ {{ vc_member.vc_priority|placeholder }} |
+
{% endfor %}
+
{% endif %}
@@ -221,6 +223,11 @@
{% if object.oob_ip %}
{{ object.oob_ip.address.ip }}
+ {% if object.oob_ip.nat_inside %}
+ ({% trans "NAT for" %} {{ object.oob_ip.nat_inside.address.ip }})
+ {% elif object.oob_ip.nat_outside.exists %}
+ ({% trans "NAT" %}: {% for nat in object.oob_ip.nat_outside.all %}{{ nat.address.ip }}{% if not forloop.last %}, {% endif %}{% endfor %})
+ {% endif %}
{% copy_content "oob_ip" %}
{% else %}
{{ ''|placeholder }}
diff --git a/netbox/templates/dcim/inc/panels/racktype_dimensions.html b/netbox/templates/dcim/inc/panels/racktype_dimensions.html
new file mode 100644
index 000000000..03eab981b
--- /dev/null
+++ b/netbox/templates/dcim/inc/panels/racktype_dimensions.html
@@ -0,0 +1,48 @@
+{% load i18n %}
+
+
+
+
+ {% trans "Form factor" %} |
+ {{ object.get_form_factor_display|placeholder }} |
+
+
+ {% trans "Width" %} |
+ {{ object.get_width_display }} |
+
+
+ {% trans "Height" %} |
+ {{ object.u_height }}U |
+
+
+ {% trans "Outer Width" %} |
+
+ {% if object.outer_width %}
+ {{ object.outer_width }} {{ object.get_outer_unit_display }}
+ {% else %}
+ {{ ''|placeholder }}
+ {% endif %}
+ |
+
+
+ {% trans "Outer Depth" %} |
+
+ {% if object.outer_depth %}
+ {{ object.outer_depth }} {{ object.get_outer_unit_display }}
+ {% else %}
+ {{ ''|placeholder }}
+ {% endif %}
+ |
+
+
+ {% trans "Mounting Depth" %} |
+
+ {% if object.mounting_depth %}
+ {{ object.mounting_depth }} {% trans "Millimeters" %}
+ {% else %}
+ {{ ''|placeholder }}
+ {% endif %}
+ |
+
+
+
diff --git a/netbox/templates/dcim/inc/panels/racktype_numbering.html b/netbox/templates/dcim/inc/panels/racktype_numbering.html
new file mode 100644
index 000000000..c8259042e
--- /dev/null
+++ b/netbox/templates/dcim/inc/panels/racktype_numbering.html
@@ -0,0 +1,14 @@
+{% load i18n %}
+
+
+
+
+ {% trans "Starting Unit" %} |
+ {{ object.starting_unit }} |
+
+
+ {% trans "Descending Units" %} |
+ {% checkmark object.desc_units %} |
+
+
+
diff --git a/netbox/templates/dcim/rack.html b/netbox/templates/dcim/rack.html
index a472b838b..1c1b79fbe 100644
--- a/netbox/templates/dcim/rack.html
+++ b/netbox/templates/dcim/rack.html
@@ -9,157 +9,107 @@
{% block content %}
-
-
-
-
- {% trans "Region" %} |
-
- {% nested_tree object.site.region %}
- |
-
-
- {% trans "Site" %} |
- {{ object.site|linkify }} |
-
-
- {% trans "Location" %} |
- {% nested_tree object.location %} |
-
-
- {% trans "Facility ID" %} |
- {{ object.facility_id|placeholder }} |
-
-
- {% trans "Tenant" %} |
-
- {% if object.tenant.group %}
- {{ object.tenant.group|linkify }} /
- {% endif %}
- {{ object.tenant|linkify|placeholder }}
- |
-
-
- {% trans "Status" %} |
- {% badge object.get_status_display bg_color=object.get_status_color %} |
-
-
- {% trans "Role" %} |
- {{ object.role|linkify|placeholder }} |
-
-
- {% trans "Description" %} |
- {{ object.description|placeholder }} |
-
-
- {% trans "Serial Number" %} |
- {{ object.serial|placeholder }} |
-
-
- {% trans "Asset Tag" %} |
- {{ object.asset_tag|placeholder }} |
-
-
- {% trans "Space Utilization" %} |
- {% utilization_graph object.get_utilization %} |
-
-
- {% trans "Power Utilization" %} |
- {% utilization_graph object.get_power_utilization %} |
-
-
-
-
-
-
-
- {% trans "Type" %} |
-
- {% if object.type %}
- {{ object.get_type_display }}
- {% else %}
- {{ ''|placeholder }}
- {% endif %}
- |
-
-
- {% trans "Width" %} |
- {{ object.get_width_display }} |
-
-
- {% trans "Height" %} |
- {{ object.u_height }}U ({% if object.desc_units %}{% trans "descending" %}{% else %}{% trans "ascending" %}{% endif %}) |
-
-
- {% trans "Starting Unit" %} |
-
- {{ object.starting_unit }}
- |
-
-
- {% trans "Outer Width" %} |
-
- {% if object.outer_width %}
- {{ object.outer_width }} {{ object.get_outer_unit_display }}
- {% else %}
- {{ ''|placeholder }}
- {% endif %}
- |
-
-
- {% trans "Outer Depth" %} |
-
- {% if object.outer_depth %}
- {{ object.outer_depth }} {{ object.get_outer_unit_display }}
- {% else %}
- {{ ''|placeholder }}
- {% endif %}
- |
-
-
- {% trans "Mounting Depth" %} |
-
- {% if object.mounting_depth %}
- {{ object.mounting_depth }} {% trans "Millimeters" %}
- {% else %}
- {{ ''|placeholder }}
- {% endif %}
- |
-
-
- {% trans "Rack Weight" %} |
-
- {% if object.weight %}
- {{ object.weight|floatformat }} {{ object.get_weight_unit_display }}
- {% else %}
- {{ ''|placeholder }}
- {% endif %}
- |
-
-
- {% trans "Maximum Weight" %} |
-
- {% if object.max_weight %}
- {{ object.max_weight }} {{ object.get_weight_unit_display }}
- {% else %}
- {{ ''|placeholder }}
- {% endif %}
- |
-
-
- {% trans "Total Weight" %} |
-
- {{ object.total_weight|floatformat }} {% trans "Kilograms" %}
- ({{ object.total_weight|kg_to_pounds|floatformat }} {% trans "Pounds" %})
- |
-
-
-
- {% include 'inc/panels/custom_fields.html' %}
- {% include 'inc/panels/tags.html' %}
- {% include 'inc/panels/comments.html' %}
- {% include 'inc/panels/image_attachments.html' %}
- {% plugin_left_page object %}
+
+
+
+
+ {% trans "Region" %} |
+ {% nested_tree object.site.region %} |
+
+
+ {% trans "Site" %} |
+ {{ object.site|linkify }} |
+
+
+ {% trans "Location" %} |
+ {% nested_tree object.location %} |
+
+
+ {% trans "Facility ID" %} |
+ {{ object.facility_id|placeholder }} |
+
+
+ {% trans "Tenant" %} |
+
+ {% if object.tenant.group %}
+ {{ object.tenant.group|linkify }} /
+ {% endif %}
+ {{ object.tenant|linkify|placeholder }}
+ |
+
+
+ {% trans "Status" %} |
+ {% badge object.get_status_display bg_color=object.get_status_color %} |
+
+
+ {% trans "Rack Type" %} |
+ {{ object.rack_type|linkify|placeholder }} |
+
+
+ {% trans "Role" %} |
+ {{ object.role|linkify|placeholder }} |
+
+
+ {% trans "Description" %} |
+ {{ object.description|placeholder }} |
+
+
+ {% trans "Serial Number" %} |
+ {{ object.serial|placeholder }} |
+
+
+ {% trans "Asset Tag" %} |
+ {{ object.asset_tag|placeholder }} |
+
+
+ {% trans "Space Utilization" %} |
+ {% utilization_graph object.get_utilization %} |
+
+
+ {% trans "Power Utilization" %} |
+ {% utilization_graph object.get_power_utilization %} |
+
+
+
+ {% include 'dcim/inc/panels/racktype_dimensions.html' %}
+ {% include 'dcim/inc/panels/racktype_numbering.html' %}
+
+
+
+
+ {% trans "Rack Weight" %} |
+
+ {% if object.weight %}
+ {{ object.weight|floatformat }} {{ object.get_weight_unit_display }}
+ {% else %}
+ {{ ''|placeholder }}
+ {% endif %}
+ |
+
+
+ {% trans "Maximum Weight" %} |
+
+ {% if object.max_weight %}
+ {{ object.max_weight }} {{ object.get_weight_unit_display }}
+ {% else %}
+ {{ ''|placeholder }}
+ {% endif %}
+ |
+
+
+ {% trans "Total Weight" %} |
+
+ {{ object.total_weight|floatformat }} {% trans "Kilograms" %}
+ ({{ object.total_weight|kg_to_pounds|floatformat }} {% trans "Pounds" %})
+ |
+
+
+
+ {% include 'inc/panels/custom_fields.html' %}
+ {% include 'inc/panels/tags.html' %}
+ {% include 'inc/panels/comments.html' %}
+ {% include 'inc/panels/image_attachments.html' %}
+ {% plugin_left_page object %}
@@ -170,26 +120,26 @@
-
-
- {% trans "Front" %}
- {% include 'dcim/inc/rack_elevation.html' with face='front' extra_params=svg_extra %}
-
+
+
+ {% trans "Front" %}
+ {% include 'dcim/inc/rack_elevation.html' with face='front' extra_params=svg_extra %}
-
-
- {% trans "Rear" %}
- {% include 'dcim/inc/rack_elevation.html' with face='rear' extra_params=svg_extra %}
-
+
+
+
+ {% trans "Rear" %}
+ {% include 'dcim/inc/rack_elevation.html' with face='rear' extra_params=svg_extra %}
+
{% include 'inc/panels/related_objects.html' %}
{% plugin_right_page object %}
-
- {% plugin_full_width_page object %}
-
+
+ {% plugin_full_width_page object %}
+
{% endblock %}
diff --git a/netbox/templates/dcim/racktype.html b/netbox/templates/dcim/racktype.html
new file mode 100644
index 000000000..0c82b13d1
--- /dev/null
+++ b/netbox/templates/dcim/racktype.html
@@ -0,0 +1,71 @@
+{% extends 'generic/object.html' %}
+{% load buttons %}
+{% load helpers %}
+{% load static %}
+{% load plugins %}
+{% load i18n %}
+{% load mptt %}
+
+{% block content %}
+
+
+
+
+
+
+ {% trans "Manufacturer" %} |
+ {{ object.manufacturer|linkify }} |
+
+
+ {% trans "Name" %} |
+ {{ object.name }} |
+
+
+ {% trans "Description" %} |
+ {{ object.description|placeholder }} |
+
+
+
+ {% include 'dcim/inc/panels/racktype_dimensions.html' %}
+ {% include 'inc/panels/tags.html' %}
+ {% include 'inc/panels/comments.html' %}
+ {% plugin_left_page object %}
+
+
+ {% include 'dcim/inc/panels/racktype_numbering.html' %}
+
+
+
+
+ {% trans "Rack Weight" %} |
+
+ {% if object.weight %}
+ {{ object.weight|floatformat }} {{ object.get_weight_unit_display }}
+ {% else %}
+ {{ ''|placeholder }}
+ {% endif %}
+ |
+
+
+ {% trans "Maximum Weight" %} |
+
+ {% if object.max_weight %}
+ {{ object.max_weight }} {{ object.get_weight_unit_display }}
+ {% else %}
+ {{ ''|placeholder }}
+ {% endif %}
+ |
+
+
+
+ {% include 'inc/panels/custom_fields.html' %}
+ {% include 'inc/panels/related_objects.html' %}
+ {% plugin_right_page object %}
+
+
+
+
+ {% plugin_full_width_page object %}
+
+
+{% endblock %}
diff --git a/netbox/templates/extras/notificationgroup.html b/netbox/templates/extras/notificationgroup.html
new file mode 100644
index 000000000..ab514f8bf
--- /dev/null
+++ b/netbox/templates/extras/notificationgroup.html
@@ -0,0 +1,57 @@
+{% extends 'generic/object.html' %}
+{% load helpers %}
+{% load plugins %}
+{% load render_table from django_tables2 %}
+{% load i18n %}
+
+{% block content %}
+
+
+
+
+
+
+ {% trans "Name" %} |
+
+ {{ object.name }}
+ |
+
+
+ {% trans "Description" %} |
+
+ {{ object.description|placeholder }}
+ |
+
+
+
+ {% plugin_left_page object %}
+
+
+
+
+
+ {% for group in object.groups.all %}
+ {{ group }}
+ {% empty %}
+ {% trans "None assigned" %}
+ {% endfor %}
+
+
+
+
+
+ {% for user in object.users.all %}
+ {{ user }}
+ {% empty %}
+ {% trans "None assigned" %}
+ {% endfor %}
+
+
+ {% plugin_right_page object %}
+
+
+
+ {% plugin_full_width_page object %}
+
+
+{% endblock %}
diff --git a/netbox/templates/extras/script_result.html b/netbox/templates/extras/script_result.html
index 1b297673b..40be0456e 100644
--- a/netbox/templates/extras/script_result.html
+++ b/netbox/templates/extras/script_result.html
@@ -42,8 +42,26 @@
{# Object table controls #}
-
-
+
+ {% trans "Log threshold" %}
+
+
+
+
+
+
+
+
+
{% if request.user.is_authenticated and job.completed %}
- |