mirror of
https://github.com/netbox-community/netbox.git
synced 2025-07-23 04:22:01 -06:00
commit
6b6ea36b4c
2
.github/ISSUE_TEMPLATE/bug_report.yaml
vendored
2
.github/ISSUE_TEMPLATE/bug_report.yaml
vendored
@ -14,7 +14,7 @@ body:
|
||||
attributes:
|
||||
label: NetBox version
|
||||
description: What version of NetBox are you currently running?
|
||||
placeholder: v3.4.5
|
||||
placeholder: v3.4.6
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
|
2
.github/ISSUE_TEMPLATE/feature_request.yaml
vendored
2
.github/ISSUE_TEMPLATE/feature_request.yaml
vendored
@ -14,7 +14,7 @@ body:
|
||||
attributes:
|
||||
label: NetBox version
|
||||
description: What version of NetBox are you currently running?
|
||||
placeholder: v3.4.5
|
||||
placeholder: v3.4.6
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
|
@ -13,9 +13,9 @@ NetBox provides the ideal "source of truth" to power network automation.
|
||||
Available as open source software under the Apache 2.0 license, NetBox serves
|
||||
as the cornerstone for network automation in thousands of organizations.
|
||||
|
||||
* **Physical infrasucture:** Accurately model the physical world, from global regions down to individual racks of gear. Then connect everything - network, console, and power!
|
||||
* **Physical infrastructure:** Accurately model the physical world, from global regions down to individual racks of gear. Then connect everything - network, console, and power!
|
||||
* **Modern IPAM:** All the standard IPAM functionality you expect, plus VRF import/export tracking, VLAN management, and overlay support.
|
||||
* **Data circuits:** Confidently manage the delivery of crtical circuits from various service providers, modeled seamlessly alongside your own infrastructure.
|
||||
* **Data circuits:** Confidently manage the delivery of critical circuits from various service providers, modeled seamlessly alongside your own infrastructure.
|
||||
* **Power tracking:** Map the distribution of power from upstream sources to individual feeds and outlets.
|
||||
* **Organization:** Manage tenant and contact assignments natively.
|
||||
* **Powerful search:** Easily find anything you need using a single global search function.
|
||||
|
@ -1,3 +1,12 @@
|
||||
<VirtualHost *:80>
|
||||
# CHANGE THIS TO YOUR SERVER'S NAME
|
||||
ServerName netbox.example.com
|
||||
|
||||
RewriteEngine On
|
||||
RewriteCond %{HTTPS} !=on
|
||||
RewriteRule ^/?(.*) https://%{SERVER_NAME}/$1 [R,L]
|
||||
</VirtualHost>
|
||||
|
||||
<VirtualHost *:443>
|
||||
ProxyPreserveHost On
|
||||
|
||||
|
@ -65,7 +65,7 @@ sudo cp /opt/netbox/contrib/apache.conf /etc/apache2/sites-available/netbox.conf
|
||||
Finally, ensure that the required Apache modules are enabled, enable the `netbox` site, and reload Apache:
|
||||
|
||||
```no-highlight
|
||||
sudo a2enmod ssl proxy proxy_http headers
|
||||
sudo a2enmod ssl proxy proxy_http headers rewrite
|
||||
sudo a2ensite netbox
|
||||
sudo systemctl restart apache2
|
||||
```
|
||||
|
@ -1,5 +1,33 @@
|
||||
# NetBox v3.4
|
||||
|
||||
## v3.4.6 (2023-03-13)
|
||||
|
||||
### Enhancements
|
||||
|
||||
* [#10058](https://github.com/netbox-community/netbox/issues/10058) - Enable searching for devices/VMs by primary IP address
|
||||
* [#11011](https://github.com/netbox-community/netbox/issues/11011) - Add ability to toggle visibility of virtual interfaces under device view
|
||||
* [#11294](https://github.com/netbox-community/netbox/issues/11294) - Enable live preview of Markdown content
|
||||
* [#11807](https://github.com/netbox-community/netbox/issues/11807) - Restore default page size when navigating between views
|
||||
* [#11817](https://github.com/netbox-community/netbox/issues/11817) - Add `connected_endpoints` field to GraphQL API for cabled objects
|
||||
* [#11851](https://github.com/netbox-community/netbox/issues/11851) - Include IP version in GraphQL API representations of aggregates, prefixes, and IP addresses
|
||||
* [#11862](https://github.com/netbox-community/netbox/issues/11862) - Add Cisco StackWise 1T interface type
|
||||
* [#11871](https://github.com/netbox-community/netbox/issues/11871) - Add IEEE 802.3az PoE type for interfaces
|
||||
* [#11929](https://github.com/netbox-community/netbox/issues/11929) - Strip whitespace from CSV headers prior to validation
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#11470](https://github.com/netbox-community/netbox/issues/11470) - Avoid raising exception when filtering IPs by an invalid address
|
||||
* [#11565](https://github.com/netbox-community/netbox/issues/11565) - Apply custom field defaults to IP address created during FHRP group creation
|
||||
* [#11631](https://github.com/netbox-community/netbox/issues/11631) - Fix filtering changelog & journal entries by multiple content type IDs
|
||||
* [#11758](https://github.com/netbox-community/netbox/issues/11758) - Support non-URL-safe characters in plugin menu titles
|
||||
* [#11796](https://github.com/netbox-community/netbox/issues/11796) - When importing devices, restrict rack by location only if the location field is specified
|
||||
* [#11819](https://github.com/netbox-community/netbox/issues/11819) - Fix filtering of cable terminations by object type
|
||||
* [#11850](https://github.com/netbox-community/netbox/issues/11850) - Fix loading of CSV files containing a byte order mark
|
||||
* [#11903](https://github.com/netbox-community/netbox/issues/11903) - Fix escaping of return URL values for action buttons in tables
|
||||
* [#11927](https://github.com/netbox-community/netbox/issues/11927) - Correct loading of plugin resources with custom paths
|
||||
|
||||
---
|
||||
|
||||
## v3.4.5 (2023-02-21)
|
||||
|
||||
### Enhancements
|
||||
|
@ -7,7 +7,7 @@ from ipam.models import ASN
|
||||
from netbox.forms import NetBoxModelBulkEditForm
|
||||
from tenancy.models import Tenant
|
||||
from utilities.forms import (
|
||||
add_blank_choice, CommentField, DatePicker, DynamicModelChoiceField, DynamicModelMultipleChoiceField, SmallTextarea,
|
||||
add_blank_choice, CommentField, DatePicker, DynamicModelChoiceField, DynamicModelMultipleChoiceField,
|
||||
StaticSelect,
|
||||
)
|
||||
|
||||
@ -35,7 +35,6 @@ class ProviderBulkEditForm(NetBoxModelBulkEditForm):
|
||||
required=False
|
||||
)
|
||||
comments = CommentField(
|
||||
widget=SmallTextarea,
|
||||
label=_('Comments')
|
||||
)
|
||||
|
||||
@ -63,7 +62,6 @@ class ProviderNetworkBulkEditForm(NetBoxModelBulkEditForm):
|
||||
required=False
|
||||
)
|
||||
comments = CommentField(
|
||||
widget=SmallTextarea,
|
||||
label=_('Comments')
|
||||
)
|
||||
|
||||
@ -125,7 +123,6 @@ class CircuitBulkEditForm(NetBoxModelBulkEditForm):
|
||||
required=False
|
||||
)
|
||||
comments = CommentField(
|
||||
widget=SmallTextarea,
|
||||
label=_('Comments')
|
||||
)
|
||||
|
||||
|
@ -902,6 +902,7 @@ class InterfaceTypeChoices(ChoiceSet):
|
||||
TYPE_STACKWISE160 = 'cisco-stackwise-160'
|
||||
TYPE_STACKWISE320 = 'cisco-stackwise-320'
|
||||
TYPE_STACKWISE480 = 'cisco-stackwise-480'
|
||||
TYPE_STACKWISE1T = 'cisco-stackwise-1t'
|
||||
TYPE_JUNIPER_VCP = 'juniper-vcp'
|
||||
TYPE_SUMMITSTACK = 'extreme-summitstack'
|
||||
TYPE_SUMMITSTACK128 = 'extreme-summitstack-128'
|
||||
@ -1078,6 +1079,7 @@ class InterfaceTypeChoices(ChoiceSet):
|
||||
(TYPE_STACKWISE160, 'Cisco StackWise-160'),
|
||||
(TYPE_STACKWISE320, 'Cisco StackWise-320'),
|
||||
(TYPE_STACKWISE480, 'Cisco StackWise-480'),
|
||||
(TYPE_STACKWISE1T, 'Cisco StackWise-1T'),
|
||||
(TYPE_JUNIPER_VCP, 'Juniper VCP'),
|
||||
(TYPE_SUMMITSTACK, 'Extreme SummitStack'),
|
||||
(TYPE_SUMMITSTACK128, 'Extreme SummitStack-128'),
|
||||
@ -1135,6 +1137,7 @@ class InterfacePoETypeChoices(ChoiceSet):
|
||||
|
||||
TYPE_1_8023AF = 'type1-ieee802.3af'
|
||||
TYPE_2_8023AT = 'type2-ieee802.3at'
|
||||
TYPE_2_8023AZ = 'type2-ieee802.3az'
|
||||
TYPE_3_8023BT = 'type3-ieee802.3bt'
|
||||
TYPE_4_8023BT = 'type4-ieee802.3bt'
|
||||
|
||||
@ -1149,6 +1152,7 @@ class InterfacePoETypeChoices(ChoiceSet):
|
||||
(
|
||||
(TYPE_1_8023AF, '802.3af (Type 1)'),
|
||||
(TYPE_2_8023AT, '802.3at (Type 2)'),
|
||||
(TYPE_2_8023AZ, '802.3az (Type 2)'),
|
||||
(TYPE_3_8023BT, '802.3bt (Type 3)'),
|
||||
(TYPE_4_8023BT, '802.3bt (Type 4)'),
|
||||
)
|
||||
|
@ -981,7 +981,9 @@ class DeviceFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilter
|
||||
Q(serial__icontains=value.strip()) |
|
||||
Q(inventoryitems__serial__icontains=value.strip()) |
|
||||
Q(asset_tag__icontains=value.strip()) |
|
||||
Q(comments__icontains=value)
|
||||
Q(comments__icontains=value) |
|
||||
Q(primary_ip4__address__startswith=value) |
|
||||
Q(primary_ip6__address__startswith=value)
|
||||
).distinct()
|
||||
|
||||
def _has_primary_ip(self, queryset, name, value):
|
||||
@ -1725,6 +1727,7 @@ class CableFilterSet(TenancyFilterSet, NetBoxModelFilterSet):
|
||||
|
||||
|
||||
class CableTerminationFilterSet(BaseFilterSet):
|
||||
termination_type = ContentTypeFilter()
|
||||
|
||||
class Meta:
|
||||
model = CableTermination
|
||||
|
@ -11,7 +11,7 @@ from netbox.forms import NetBoxModelBulkEditForm
|
||||
from tenancy.models import Tenant
|
||||
from utilities.forms import (
|
||||
add_blank_choice, BulkEditForm, BulkEditNullBooleanSelect, ColorField, CommentField, DynamicModelChoiceField,
|
||||
DynamicModelMultipleChoiceField, form_from_model, SmallTextarea, StaticSelect, SelectSpeedWidget,
|
||||
DynamicModelMultipleChoiceField, form_from_model, StaticSelect, SelectSpeedWidget
|
||||
)
|
||||
|
||||
__all__ = (
|
||||
@ -138,7 +138,6 @@ class SiteBulkEditForm(NetBoxModelBulkEditForm):
|
||||
required=False
|
||||
)
|
||||
comments = CommentField(
|
||||
widget=SmallTextarea,
|
||||
label='Comments'
|
||||
)
|
||||
|
||||
@ -309,7 +308,6 @@ class RackBulkEditForm(NetBoxModelBulkEditForm):
|
||||
required=False
|
||||
)
|
||||
comments = CommentField(
|
||||
widget=SmallTextarea,
|
||||
label='Comments'
|
||||
)
|
||||
|
||||
@ -345,7 +343,6 @@ class RackReservationBulkEditForm(NetBoxModelBulkEditForm):
|
||||
required=False
|
||||
)
|
||||
comments = CommentField(
|
||||
widget=SmallTextarea,
|
||||
label='Comments'
|
||||
)
|
||||
|
||||
@ -406,7 +403,6 @@ class DeviceTypeBulkEditForm(NetBoxModelBulkEditForm):
|
||||
required=False
|
||||
)
|
||||
comments = CommentField(
|
||||
widget=SmallTextarea,
|
||||
label='Comments'
|
||||
)
|
||||
|
||||
@ -441,7 +437,6 @@ class ModuleTypeBulkEditForm(NetBoxModelBulkEditForm):
|
||||
required=False
|
||||
)
|
||||
comments = CommentField(
|
||||
widget=SmallTextarea,
|
||||
label='Comments'
|
||||
)
|
||||
|
||||
@ -551,7 +546,6 @@ class DeviceBulkEditForm(NetBoxModelBulkEditForm):
|
||||
required=False
|
||||
)
|
||||
comments = CommentField(
|
||||
widget=SmallTextarea,
|
||||
label='Comments'
|
||||
)
|
||||
|
||||
@ -594,7 +588,6 @@ class ModuleBulkEditForm(NetBoxModelBulkEditForm):
|
||||
required=False
|
||||
)
|
||||
comments = CommentField(
|
||||
widget=SmallTextarea,
|
||||
label='Comments'
|
||||
)
|
||||
|
||||
@ -644,7 +637,6 @@ class CableBulkEditForm(NetBoxModelBulkEditForm):
|
||||
required=False
|
||||
)
|
||||
comments = CommentField(
|
||||
widget=SmallTextarea,
|
||||
label='Comments'
|
||||
)
|
||||
|
||||
@ -668,7 +660,6 @@ class VirtualChassisBulkEditForm(NetBoxModelBulkEditForm):
|
||||
required=False
|
||||
)
|
||||
comments = CommentField(
|
||||
widget=SmallTextarea,
|
||||
label='Comments'
|
||||
)
|
||||
|
||||
@ -714,7 +705,6 @@ class PowerPanelBulkEditForm(NetBoxModelBulkEditForm):
|
||||
required=False
|
||||
)
|
||||
comments = CommentField(
|
||||
widget=SmallTextarea,
|
||||
label='Comments'
|
||||
)
|
||||
|
||||
@ -776,7 +766,6 @@ class PowerFeedBulkEditForm(NetBoxModelBulkEditForm):
|
||||
required=False
|
||||
)
|
||||
comments = CommentField(
|
||||
widget=SmallTextarea,
|
||||
label=_('Comments')
|
||||
)
|
||||
|
||||
|
@ -447,11 +447,14 @@ class DeviceImportForm(BaseDeviceImportForm):
|
||||
self.fields['location'].queryset = self.fields['location'].queryset.filter(**params)
|
||||
self.fields['parent'].queryset = self.fields['parent'].queryset.filter(**params)
|
||||
|
||||
# Limit rack queryset by assigned site and group
|
||||
# Limit rack queryset by assigned site and location
|
||||
params = {
|
||||
f"site__{self.fields['site'].to_field_name}": data.get('site'),
|
||||
f"location__{self.fields['location'].to_field_name}": data.get('location'),
|
||||
}
|
||||
if 'location' in data:
|
||||
params.update({
|
||||
f"location__{self.fields['location'].to_field_name}": data.get('location'),
|
||||
})
|
||||
self.fields['rack'].queryset = self.fields['rack'].queryset.filter(**params)
|
||||
|
||||
# Limit device bay queryset by parent device
|
||||
|
@ -10,3 +10,11 @@ class CabledObjectMixin:
|
||||
|
||||
def resolve_link_peers(self, info):
|
||||
return self.link_peers
|
||||
|
||||
|
||||
class PathEndpointMixin:
|
||||
connected_endpoints = graphene.List('dcim.graphql.gfk_mixins.LinkPeerType')
|
||||
|
||||
def resolve_connected_endpoints(self, info):
|
||||
# Handle empty values
|
||||
return self.connected_endpoints or None
|
||||
|
@ -7,7 +7,7 @@ from extras.graphql.mixins import (
|
||||
from ipam.graphql.mixins import IPAddressesMixin, VLANGroupsMixin
|
||||
from netbox.graphql.scalars import BigInt
|
||||
from netbox.graphql.types import BaseObjectType, OrganizationalObjectType, NetBoxObjectType
|
||||
from .mixins import CabledObjectMixin
|
||||
from .mixins import CabledObjectMixin, PathEndpointMixin
|
||||
|
||||
__all__ = (
|
||||
'CableType',
|
||||
@ -117,7 +117,7 @@ class CableTerminationType(NetBoxObjectType):
|
||||
filterset_class = filtersets.CableTerminationFilterSet
|
||||
|
||||
|
||||
class ConsolePortType(ComponentObjectType, CabledObjectMixin):
|
||||
class ConsolePortType(ComponentObjectType, CabledObjectMixin, PathEndpointMixin):
|
||||
|
||||
class Meta:
|
||||
model = models.ConsolePort
|
||||
@ -139,7 +139,7 @@ class ConsolePortTemplateType(ComponentTemplateObjectType):
|
||||
return self.type or None
|
||||
|
||||
|
||||
class ConsoleServerPortType(ComponentObjectType, CabledObjectMixin):
|
||||
class ConsoleServerPortType(ComponentObjectType, CabledObjectMixin, PathEndpointMixin):
|
||||
|
||||
class Meta:
|
||||
model = models.ConsoleServerPort
|
||||
@ -241,7 +241,7 @@ class FrontPortTemplateType(ComponentTemplateObjectType):
|
||||
filterset_class = filtersets.FrontPortTemplateFilterSet
|
||||
|
||||
|
||||
class InterfaceType(IPAddressesMixin, ComponentObjectType, CabledObjectMixin):
|
||||
class InterfaceType(IPAddressesMixin, ComponentObjectType, CabledObjectMixin, PathEndpointMixin):
|
||||
|
||||
class Meta:
|
||||
model = models.Interface
|
||||
@ -354,7 +354,7 @@ class PlatformType(OrganizationalObjectType):
|
||||
filterset_class = filtersets.PlatformFilterSet
|
||||
|
||||
|
||||
class PowerFeedType(NetBoxObjectType, CabledObjectMixin):
|
||||
class PowerFeedType(NetBoxObjectType, CabledObjectMixin, PathEndpointMixin):
|
||||
|
||||
class Meta:
|
||||
model = models.PowerFeed
|
||||
@ -362,7 +362,7 @@ class PowerFeedType(NetBoxObjectType, CabledObjectMixin):
|
||||
filterset_class = filtersets.PowerFeedFilterSet
|
||||
|
||||
|
||||
class PowerOutletType(ComponentObjectType, CabledObjectMixin):
|
||||
class PowerOutletType(ComponentObjectType, CabledObjectMixin, PathEndpointMixin):
|
||||
|
||||
class Meta:
|
||||
model = models.PowerOutlet
|
||||
@ -398,7 +398,7 @@ class PowerPanelType(NetBoxObjectType, ContactsMixin):
|
||||
filterset_class = filtersets.PowerPanelFilterSet
|
||||
|
||||
|
||||
class PowerPortType(ComponentObjectType, CabledObjectMixin):
|
||||
class PowerPortType(ComponentObjectType, CabledObjectMixin, PathEndpointMixin):
|
||||
|
||||
class Meta:
|
||||
model = models.PowerPort
|
||||
|
@ -588,6 +588,7 @@ class DeviceInterfaceTable(InterfaceTable):
|
||||
'class': get_interface_row_class,
|
||||
'data-name': lambda record: record.name,
|
||||
'data-enabled': get_interface_state_attribute,
|
||||
'data-type': lambda record: record.type,
|
||||
}
|
||||
|
||||
|
||||
|
@ -210,6 +210,9 @@ class ImageAttachmentFilterSet(BaseFilterSet):
|
||||
class JournalEntryFilterSet(NetBoxModelFilterSet):
|
||||
created = django_filters.DateTimeFromToRangeFilter()
|
||||
assigned_object_type = ContentTypeFilter()
|
||||
assigned_object_type_id = django_filters.ModelMultipleChoiceFilter(
|
||||
queryset=ContentType.objects.all()
|
||||
)
|
||||
created_by_id = django_filters.ModelMultipleChoiceFilter(
|
||||
queryset=User.objects.all(),
|
||||
label=_('User (ID)'),
|
||||
@ -458,6 +461,9 @@ class ObjectChangeFilterSet(BaseFilterSet):
|
||||
)
|
||||
time = django_filters.DateTimeFromToRangeFilter()
|
||||
changed_object_type = ContentTypeFilter()
|
||||
changed_object_type_id = django_filters.ModelMultipleChoiceFilter(
|
||||
queryset=ContentType.objects.all()
|
||||
)
|
||||
user_id = django_filters.ModelMultipleChoiceFilter(
|
||||
queryset=User.objects.all(),
|
||||
label=_('User (ID)'),
|
||||
|
@ -2,6 +2,7 @@ from .model_forms import *
|
||||
from .filtersets import *
|
||||
from .bulk_edit import *
|
||||
from .bulk_import import *
|
||||
from .misc import *
|
||||
from .mixins import *
|
||||
from .config import *
|
||||
from .scripts import *
|
||||
|
14
netbox/extras/forms/misc.py
Normal file
14
netbox/extras/forms/misc.py
Normal file
@ -0,0 +1,14 @@
|
||||
from django import forms
|
||||
|
||||
__all__ = (
|
||||
'RenderMarkdownForm',
|
||||
)
|
||||
|
||||
|
||||
class RenderMarkdownForm(forms.Form):
|
||||
"""
|
||||
Provides basic validation for markup to be rendered.
|
||||
"""
|
||||
text = forms.CharField(
|
||||
required=False
|
||||
)
|
@ -78,8 +78,8 @@ class PluginConfig(AppConfig):
|
||||
|
||||
def _load_resource(self, name):
|
||||
# Import from the configured path, if defined.
|
||||
if getattr(self, name):
|
||||
return import_string(f"{self.__module__}.{self.name}")
|
||||
if path := getattr(self, name, None):
|
||||
return import_string(f"{self.__module__}.{path}")
|
||||
|
||||
# Fall back to the resource's default path. Return None if the module has not been provided.
|
||||
default_path = f'{self.__module__}.{DEFAULT_RESOURCE_PATHS[name]}'
|
||||
|
@ -1,5 +1,6 @@
|
||||
from netbox.navigation import MenuGroup
|
||||
from utilities.choices import ButtonColorChoices
|
||||
from django.utils.text import slugify
|
||||
|
||||
__all__ = (
|
||||
'PluginMenu',
|
||||
@ -21,7 +22,7 @@ class PluginMenu:
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
return self.label.replace(' ', '_')
|
||||
return slugify(self.label)
|
||||
|
||||
|
||||
class PluginMenuItem:
|
||||
|
@ -502,7 +502,7 @@ class JournalEntryTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
def test_assigned_object_type(self):
|
||||
params = {'assigned_object_type': 'dcim.site'}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
|
||||
params = {'assigned_object_type_id': ContentType.objects.get(app_label='dcim', model='site').pk}
|
||||
params = {'assigned_object_type_id': [ContentType.objects.get(app_label='dcim', model='site').pk]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
|
||||
|
||||
def test_assigned_object(self):
|
||||
@ -876,7 +876,5 @@ class ObjectChangeTestCase(TestCase, BaseFilterSetTests):
|
||||
def test_changed_object_type(self):
|
||||
params = {'changed_object_type': 'dcim.site'}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
|
||||
|
||||
def test_changed_object_type_id(self):
|
||||
params = {'changed_object_type_id': ContentType.objects.get(app_label='dcim', model='site').pk}
|
||||
params = {'changed_object_type_id': [ContentType.objects.get(app_label='dcim', model='site').pk]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
|
||||
|
@ -92,4 +92,6 @@ urlpatterns = [
|
||||
path('scripts/results/<int:job_result_pk>/', views.ScriptResultView.as_view(), name='script_result'),
|
||||
re_path(r'^scripts/(?P<module>.([^.]+)).(?P<name>.(.+))/', views.ScriptView.as_view(), name='script'),
|
||||
|
||||
# Markdown
|
||||
path('render/markdown/', views.RenderMarkdownView.as_view(), name="render_markdown")
|
||||
]
|
||||
|
@ -1,7 +1,7 @@
|
||||
from django.contrib import messages
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.db.models import Count, Q
|
||||
from django.http import Http404, HttpResponseForbidden
|
||||
from django.http import Http404, HttpResponseBadRequest, HttpResponseForbidden, HttpResponse
|
||||
from django.shortcuts import get_object_or_404, redirect, render
|
||||
from django.urls import reverse
|
||||
from django.views.generic import View
|
||||
@ -10,6 +10,7 @@ from rq import Worker
|
||||
|
||||
from netbox.views import generic
|
||||
from utilities.htmx import is_htmx
|
||||
from utilities.templatetags.builtins.filters import render_markdown
|
||||
from utilities.utils import copy_safe_request, count_related, get_viewname, normalize_querydict, shallow_compare_dict
|
||||
from utilities.views import ContentTypePermissionRequiredMixin, register_model_view
|
||||
from . import filtersets, forms, tables
|
||||
@ -885,3 +886,18 @@ class JobResultBulkDeleteView(generic.BulkDeleteView):
|
||||
queryset = JobResult.objects.all()
|
||||
filterset = filtersets.JobResultFilterSet
|
||||
table = tables.JobResultTable
|
||||
|
||||
|
||||
#
|
||||
# Markdown
|
||||
#
|
||||
|
||||
class RenderMarkdownView(View):
|
||||
|
||||
def post(self, request):
|
||||
form = forms.RenderMarkdownForm(request.POST)
|
||||
if not form.is_valid():
|
||||
HttpResponseBadRequest()
|
||||
rendered = render_markdown(form.cleaned_data['text'])
|
||||
|
||||
return HttpResponse(rendered)
|
||||
|
@ -1,4 +1,4 @@
|
||||
#!/usr/bin/env python
|
||||
#!/usr/bin/env python3
|
||||
# This script will generate a random 50-character string suitable for use as a SECRET_KEY.
|
||||
import secrets
|
||||
|
||||
|
@ -16,6 +16,7 @@ from virtualization.models import VirtualMachine, VMInterface
|
||||
from .choices import *
|
||||
from .models import *
|
||||
|
||||
from rest_framework import serializers
|
||||
|
||||
__all__ = (
|
||||
'AggregateFilterSet',
|
||||
@ -599,7 +600,33 @@ class IPAddressFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
|
||||
return queryset.none()
|
||||
return queryset.filter(q)
|
||||
|
||||
def parse_inet_addresses(self, value):
|
||||
'''
|
||||
Parse networks or IP addresses and cast to a format
|
||||
acceptable by the Postgres inet type.
|
||||
|
||||
Skips invalid values.
|
||||
'''
|
||||
parsed = []
|
||||
for addr in value:
|
||||
if netaddr.valid_ipv4(addr) or netaddr.valid_ipv6(addr):
|
||||
parsed.append(addr)
|
||||
continue
|
||||
try:
|
||||
network = netaddr.IPNetwork(addr)
|
||||
parsed.append(str(network))
|
||||
except (AddrFormatError, ValueError):
|
||||
continue
|
||||
return parsed
|
||||
|
||||
def filter_address(self, queryset, name, value):
|
||||
# Let's first parse the addresses passed
|
||||
# as argument. If they are all invalid,
|
||||
# we return an empty queryset
|
||||
value = self.parse_inet_addresses(value)
|
||||
if (len(value) == 0):
|
||||
return queryset.none()
|
||||
|
||||
try:
|
||||
return queryset.filter(address__net_in=value)
|
||||
except ValidationError:
|
||||
|
@ -10,7 +10,7 @@ from netbox.forms import NetBoxModelBulkEditForm
|
||||
from tenancy.models import Tenant
|
||||
from utilities.forms import (
|
||||
add_blank_choice, BulkEditNullBooleanSelect, CommentField, DynamicModelChoiceField, NumericArrayField,
|
||||
SmallTextarea, StaticSelect, DynamicModelMultipleChoiceField,
|
||||
StaticSelect, DynamicModelMultipleChoiceField
|
||||
)
|
||||
|
||||
__all__ = (
|
||||
@ -48,7 +48,6 @@ class VRFBulkEditForm(NetBoxModelBulkEditForm):
|
||||
required=False
|
||||
)
|
||||
comments = CommentField(
|
||||
widget=SmallTextarea,
|
||||
label='Comments'
|
||||
)
|
||||
|
||||
@ -69,7 +68,6 @@ class RouteTargetBulkEditForm(NetBoxModelBulkEditForm):
|
||||
required=False
|
||||
)
|
||||
comments = CommentField(
|
||||
widget=SmallTextarea,
|
||||
label='Comments'
|
||||
)
|
||||
|
||||
@ -116,7 +114,6 @@ class ASNBulkEditForm(NetBoxModelBulkEditForm):
|
||||
required=False
|
||||
)
|
||||
comments = CommentField(
|
||||
widget=SmallTextarea,
|
||||
label='Comments'
|
||||
)
|
||||
|
||||
@ -145,7 +142,6 @@ class AggregateBulkEditForm(NetBoxModelBulkEditForm):
|
||||
required=False
|
||||
)
|
||||
comments = CommentField(
|
||||
widget=SmallTextarea,
|
||||
label='Comments'
|
||||
)
|
||||
|
||||
@ -227,7 +223,6 @@ class PrefixBulkEditForm(NetBoxModelBulkEditForm):
|
||||
required=False
|
||||
)
|
||||
comments = CommentField(
|
||||
widget=SmallTextarea,
|
||||
label='Comments'
|
||||
)
|
||||
|
||||
@ -266,7 +261,6 @@ class IPRangeBulkEditForm(NetBoxModelBulkEditForm):
|
||||
required=False
|
||||
)
|
||||
comments = CommentField(
|
||||
widget=SmallTextarea,
|
||||
label='Comments'
|
||||
)
|
||||
|
||||
@ -314,7 +308,6 @@ class IPAddressBulkEditForm(NetBoxModelBulkEditForm):
|
||||
required=False
|
||||
)
|
||||
comments = CommentField(
|
||||
widget=SmallTextarea,
|
||||
label='Comments'
|
||||
)
|
||||
|
||||
@ -359,7 +352,6 @@ class FHRPGroupBulkEditForm(NetBoxModelBulkEditForm):
|
||||
required=False
|
||||
)
|
||||
comments = CommentField(
|
||||
widget=SmallTextarea,
|
||||
label='Comments'
|
||||
)
|
||||
|
||||
@ -442,7 +434,6 @@ class VLANBulkEditForm(NetBoxModelBulkEditForm):
|
||||
required=False
|
||||
)
|
||||
comments = CommentField(
|
||||
widget=SmallTextarea,
|
||||
label='Comments'
|
||||
)
|
||||
|
||||
@ -474,7 +465,6 @@ class ServiceTemplateBulkEditForm(NetBoxModelBulkEditForm):
|
||||
required=False
|
||||
)
|
||||
comments = CommentField(
|
||||
widget=SmallTextarea,
|
||||
label='Comments'
|
||||
)
|
||||
|
||||
@ -504,7 +494,6 @@ class L2VPNBulkEditForm(NetBoxModelBulkEditForm):
|
||||
required=False
|
||||
)
|
||||
comments = CommentField(
|
||||
widget=SmallTextarea,
|
||||
label='Comments'
|
||||
)
|
||||
|
||||
|
@ -578,6 +578,7 @@ class FHRPGroupForm(NetBoxModelForm):
|
||||
role=FHRP_PROTOCOL_ROLE_MAPPINGS.get(self.cleaned_data['protocol'], IPAddressRoleChoices.ROLE_VIP),
|
||||
assigned_object=instance
|
||||
)
|
||||
ipaddress.populate_custom_field_defaults()
|
||||
ipaddress.save()
|
||||
|
||||
# Check that the new IPAddress conforms with any assigned object-level permissions
|
||||
|
@ -27,6 +27,28 @@ __all__ = (
|
||||
)
|
||||
|
||||
|
||||
class IPAddressFamilyType(graphene.ObjectType):
|
||||
|
||||
value = graphene.Int()
|
||||
label = graphene.String()
|
||||
|
||||
def __init__(self, value):
|
||||
self.value = value
|
||||
self.label = f'IPv{value}'
|
||||
|
||||
|
||||
class BaseIPAddressFamilyType:
|
||||
'''
|
||||
Base type for models that need to expose their IPAddress family type.
|
||||
'''
|
||||
family = graphene.Field(IPAddressFamilyType)
|
||||
|
||||
def resolve_family(self, _):
|
||||
# Note that self, is an instance of models.IPAddress
|
||||
# thus resolves to the address family value.
|
||||
return IPAddressFamilyType(self.family)
|
||||
|
||||
|
||||
class ASNType(NetBoxObjectType):
|
||||
asn = graphene.Field(BigInt)
|
||||
|
||||
@ -36,7 +58,7 @@ class ASNType(NetBoxObjectType):
|
||||
filterset_class = filtersets.ASNFilterSet
|
||||
|
||||
|
||||
class AggregateType(NetBoxObjectType):
|
||||
class AggregateType(NetBoxObjectType, BaseIPAddressFamilyType):
|
||||
|
||||
class Meta:
|
||||
model = models.Aggregate
|
||||
@ -64,7 +86,7 @@ class FHRPGroupAssignmentType(BaseObjectType):
|
||||
filterset_class = filtersets.FHRPGroupAssignmentFilterSet
|
||||
|
||||
|
||||
class IPAddressType(NetBoxObjectType):
|
||||
class IPAddressType(NetBoxObjectType, BaseIPAddressFamilyType):
|
||||
assigned_object = graphene.Field('ipam.graphql.gfk_mixins.IPAddressAssignmentType')
|
||||
|
||||
class Meta:
|
||||
@ -87,7 +109,7 @@ class IPRangeType(NetBoxObjectType):
|
||||
return self.role or None
|
||||
|
||||
|
||||
class PrefixType(NetBoxObjectType):
|
||||
class PrefixType(NetBoxObjectType, BaseIPAddressFamilyType):
|
||||
|
||||
class Meta:
|
||||
model = models.Prefix
|
||||
|
@ -10,6 +10,7 @@ from ipam.models import *
|
||||
from utilities.testing import ChangeLoggedFilterSetTests, create_test_device, create_test_virtualmachine
|
||||
from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface
|
||||
from tenancy.models import Tenant, TenantGroup
|
||||
from rest_framework import serializers
|
||||
|
||||
|
||||
class ASNTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
@ -851,6 +852,26 @@ class IPAddressTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
params = {'address': ['2001:db8::1/64', '2001:db8::1/65']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
# Check for valid edge cases. Note that Postgres inet type
|
||||
# only accepts netmasks in the int form, so the filterset
|
||||
# casts netmasks in the xxx.xxx.xxx.xxx format.
|
||||
params = {'address': ['24']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 0)
|
||||
params = {'address': ['10.0.0.1/255.255.255.0']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
|
||||
params = {'address': ['10.0.0.1/255.255.255.0', '10.0.0.1/25']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
# Check for invalid input.
|
||||
params = {'address': ['/24']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 0)
|
||||
params = {'address': ['10.0.0.1/255.255.999.0']} # Invalid netmask
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 0)
|
||||
|
||||
# Check for partially invalid input.
|
||||
params = {'address': ['10.0.0.1', '/24', '10.0.0.10/24']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_mask_length(self):
|
||||
params = {'mask_length': '24'}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 5)
|
||||
|
@ -216,6 +216,13 @@ class CustomFieldsMixin(models.Model):
|
||||
|
||||
return dict(groups)
|
||||
|
||||
def populate_custom_field_defaults(self):
|
||||
"""
|
||||
Apply the default value for each custom field
|
||||
"""
|
||||
for cf in self.custom_fields:
|
||||
self.custom_field_data[cf.name] = cf.default
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
from extras.models import CustomField
|
||||
|
@ -24,7 +24,7 @@ PREFERENCES = {
|
||||
'pagination.per_page': UserPreference(
|
||||
label=_('Page length'),
|
||||
choices=get_page_lengths(),
|
||||
description=_('The number of objects to display per page'),
|
||||
description=_('The default number of objects to display per page'),
|
||||
coerce=lambda x: int(x)
|
||||
),
|
||||
'pagination.placement': UserPreference(
|
||||
|
@ -24,7 +24,7 @@ from netbox.constants import RQ_QUEUE_DEFAULT, RQ_QUEUE_HIGH, RQ_QUEUE_LOW
|
||||
# Environment setup
|
||||
#
|
||||
|
||||
VERSION = '3.4.5'
|
||||
VERSION = '3.4.6'
|
||||
|
||||
# Hostname
|
||||
HOSTNAME = platform.node()
|
||||
|
@ -1,5 +1,6 @@
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
from urllib.parse import quote
|
||||
|
||||
import django_tables2 as tables
|
||||
from django.conf import settings
|
||||
@ -8,7 +9,6 @@ from django.db.models import DateField, DateTimeField
|
||||
from django.template import Context, Template
|
||||
from django.urls import reverse
|
||||
from django.utils.dateparse import parse_date
|
||||
from django.utils.encoding import escape_uri_path
|
||||
from django.utils.html import escape
|
||||
from django.utils.formats import date_format
|
||||
from django.utils.safestring import mark_safe
|
||||
@ -235,7 +235,7 @@ class ActionsColumn(tables.Column):
|
||||
|
||||
model = table.Meta.model
|
||||
request = getattr(table, 'context', {}).get('request')
|
||||
url_appendix = f'?return_url={escape_uri_path(request.get_full_path())}' if request else ''
|
||||
url_appendix = f'?return_url={quote(request.get_full_path())}' if request else ''
|
||||
html = ''
|
||||
|
||||
# Compile actions menu
|
||||
|
BIN
netbox/project-static/dist/netbox-dark.css
vendored
BIN
netbox/project-static/dist/netbox-dark.css
vendored
Binary file not shown.
BIN
netbox/project-static/dist/netbox-light.css
vendored
BIN
netbox/project-static/dist/netbox-light.css
vendored
Binary file not shown.
BIN
netbox/project-static/dist/netbox-print.css
vendored
BIN
netbox/project-static/dist/netbox-print.css
vendored
Binary file not shown.
BIN
netbox/project-static/dist/netbox.js
vendored
BIN
netbox/project-static/dist/netbox.js
vendored
Binary file not shown.
BIN
netbox/project-static/dist/netbox.js.map
vendored
BIN
netbox/project-static/dist/netbox.js.map
vendored
Binary file not shown.
@ -4,6 +4,7 @@ import { initMoveButtons } from './moveOptions';
|
||||
import { initReslug } from './reslug';
|
||||
import { initSelectAll } from './selectAll';
|
||||
import { initSelectMultiple } from './selectMultiple';
|
||||
import { initMarkdownPreviews } from './markdownPreview';
|
||||
|
||||
export function initButtons(): void {
|
||||
for (const func of [
|
||||
@ -13,6 +14,7 @@ export function initButtons(): void {
|
||||
initSelectAll,
|
||||
initSelectMultiple,
|
||||
initMoveButtons,
|
||||
initMarkdownPreviews,
|
||||
]) {
|
||||
func();
|
||||
}
|
||||
|
45
netbox/project-static/src/buttons/markdownPreview.ts
Normal file
45
netbox/project-static/src/buttons/markdownPreview.ts
Normal file
@ -0,0 +1,45 @@
|
||||
import { isTruthy } from 'src/util';
|
||||
|
||||
/**
|
||||
* interface for htmx configRequest event
|
||||
*/
|
||||
declare global {
|
||||
interface HTMLElementEventMap {
|
||||
'htmx:configRequest': CustomEvent<{
|
||||
parameters: Record<string, string>;
|
||||
headers: Record<string, string>;
|
||||
}>;
|
||||
}
|
||||
}
|
||||
|
||||
function initMarkdownPreview(markdownWidget: HTMLDivElement) {
|
||||
const previewButton = markdownWidget.querySelector('button.preview-button') as HTMLButtonElement;
|
||||
const textarea = markdownWidget.querySelector('textarea') as HTMLTextAreaElement;
|
||||
const preview = markdownWidget.querySelector('div.preview') as HTMLDivElement;
|
||||
|
||||
/**
|
||||
* Make sure the textarea has style attribute height
|
||||
* So that it can be copied over to preview div.
|
||||
*/
|
||||
if (!isTruthy(textarea.style.height)) {
|
||||
const { height } = textarea.getBoundingClientRect();
|
||||
textarea.style.height = `${height}px`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add the value of the textarea to the body of the htmx request
|
||||
* and copy the height of text are to the preview div
|
||||
*/
|
||||
previewButton.addEventListener('htmx:configRequest', e => {
|
||||
e.detail.parameters = { text: textarea.value || '' };
|
||||
e.detail.headers['X-CSRFToken'] = window.CSRF_TOKEN;
|
||||
preview.style.minHeight = textarea.style.height;
|
||||
preview.innerHTML = '';
|
||||
});
|
||||
}
|
||||
|
||||
export function initMarkdownPreviews(): void {
|
||||
for (const markdownWidget of document.querySelectorAll<HTMLDivElement>('.markdown-widget')) {
|
||||
initMarkdownPreview(markdownWidget);
|
||||
}
|
||||
}
|
@ -1,6 +1,5 @@
|
||||
import { getElements, replaceAll, findFirstAdjacent } from '../util';
|
||||
|
||||
type InterfaceState = 'enabled' | 'disabled';
|
||||
type ShowHide = 'show' | 'hide';
|
||||
|
||||
function isShowHide(value: unknown): value is ShowHide {
|
||||
@ -27,54 +26,23 @@ class ButtonState {
|
||||
* Underlying Button DOM Element
|
||||
*/
|
||||
public button: HTMLButtonElement;
|
||||
/**
|
||||
* Table rows with `data-enabled` set to `"enabled"`
|
||||
*/
|
||||
private enabledRows: NodeListOf<HTMLTableRowElement>;
|
||||
/**
|
||||
* Table rows with `data-enabled` set to `"disabled"`
|
||||
*/
|
||||
private disabledRows: NodeListOf<HTMLTableRowElement>;
|
||||
|
||||
constructor(button: HTMLButtonElement, table: HTMLTableElement) {
|
||||
/**
|
||||
* Table rows provided in constructor
|
||||
*/
|
||||
private rows: NodeListOf<HTMLTableRowElement>;
|
||||
|
||||
constructor(button: HTMLButtonElement, rows: NodeListOf<HTMLTableRowElement>) {
|
||||
this.button = button;
|
||||
this.enabledRows = table.querySelectorAll<HTMLTableRowElement>('tr[data-enabled="enabled"]');
|
||||
this.disabledRows = table.querySelectorAll<HTMLTableRowElement>('tr[data-enabled="disabled"]');
|
||||
this.rows = rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* This button's controlled type. For example, a button with the class `toggle-disabled` has
|
||||
* directive 'disabled' because it controls the visibility of rows with
|
||||
* `data-enabled="disabled"`. Likewise, `toggle-enabled` controls rows with
|
||||
* `data-enabled="enabled"`.
|
||||
* Remove visibility of button state rows.
|
||||
*/
|
||||
private get directive(): InterfaceState {
|
||||
if (this.button.classList.contains('toggle-disabled')) {
|
||||
return 'disabled';
|
||||
} else if (this.button.classList.contains('toggle-enabled')) {
|
||||
return 'enabled';
|
||||
}
|
||||
// If this class has been instantiated but doesn't contain these classes, it's probably because
|
||||
// the classes are missing in the HTML template.
|
||||
console.warn(this.button);
|
||||
throw new Error('Toggle button does not contain expected class');
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle visibility of rows with `data-enabled="enabled"`.
|
||||
*/
|
||||
private toggleEnabledRows(): void {
|
||||
for (const row of this.enabledRows) {
|
||||
row.classList.toggle('d-none');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle visibility of rows with `data-enabled="disabled"`.
|
||||
*/
|
||||
private toggleDisabledRows(): void {
|
||||
for (const row of this.disabledRows) {
|
||||
row.classList.toggle('d-none');
|
||||
private hideRows(): void {
|
||||
for (const row of this.rows) {
|
||||
row.classList.add('d-none');
|
||||
}
|
||||
}
|
||||
|
||||
@ -111,17 +79,6 @@ class ButtonState {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle visibility for the rows this element controls.
|
||||
*/
|
||||
private toggleRows(): void {
|
||||
if (this.directive === 'enabled') {
|
||||
this.toggleEnabledRows();
|
||||
} else if (this.directive === 'disabled') {
|
||||
this.toggleDisabledRows();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle the DOM element's `data-state` attribute.
|
||||
*/
|
||||
@ -139,17 +96,20 @@ class ButtonState {
|
||||
private toggle(): void {
|
||||
this.toggleState();
|
||||
this.toggleButton();
|
||||
this.toggleRows();
|
||||
}
|
||||
|
||||
/**
|
||||
* When the button is clicked, toggle all controlled elements.
|
||||
* When the button is clicked, toggle all controlled elements and hide rows based on
|
||||
* buttonstate.
|
||||
*/
|
||||
public handleClick(event: Event): void {
|
||||
const button = event.currentTarget as HTMLButtonElement;
|
||||
if (button.isEqualNode(this.button)) {
|
||||
this.toggle();
|
||||
}
|
||||
if (this.buttonState === 'hide') {
|
||||
this.hideRows();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -174,14 +134,25 @@ class TableState {
|
||||
// @ts-expect-error null handling is performed in the constructor
|
||||
private disabledButton: ButtonState;
|
||||
|
||||
/**
|
||||
* Instance of ButtonState for the 'show/hide virtual rows' button.
|
||||
*/
|
||||
// @ts-expect-error null handling is performed in the constructor
|
||||
private virtualButton: ButtonState;
|
||||
|
||||
/**
|
||||
* Underlying DOM Table Caption Element.
|
||||
*/
|
||||
private caption: Nullable<HTMLTableCaptionElement> = null;
|
||||
|
||||
/**
|
||||
* All table rows in table
|
||||
*/
|
||||
private rows: NodeListOf<HTMLTableRowElement>;
|
||||
|
||||
constructor(table: HTMLTableElement) {
|
||||
this.table = table;
|
||||
|
||||
this.rows = this.table.querySelectorAll('tr');
|
||||
try {
|
||||
const toggleEnabledButton = findFirstAdjacent<HTMLButtonElement>(
|
||||
this.table,
|
||||
@ -191,6 +162,10 @@ class TableState {
|
||||
this.table,
|
||||
'button.toggle-disabled',
|
||||
);
|
||||
const toggleVirtualButton = findFirstAdjacent<HTMLButtonElement>(
|
||||
this.table,
|
||||
'button.toggle-virtual',
|
||||
);
|
||||
|
||||
const caption = this.table.querySelector('caption');
|
||||
this.caption = caption;
|
||||
@ -203,13 +178,28 @@ class TableState {
|
||||
throw new TableStateError("Table is missing a 'toggle-disabled' button.", table);
|
||||
}
|
||||
|
||||
if (toggleVirtualButton === null) {
|
||||
throw new TableStateError("Table is missing a 'toggle-virtual' button.", table);
|
||||
}
|
||||
|
||||
// Attach event listeners to the buttons elements.
|
||||
toggleEnabledButton.addEventListener('click', event => this.handleClick(event, this));
|
||||
toggleDisabledButton.addEventListener('click', event => this.handleClick(event, this));
|
||||
toggleVirtualButton.addEventListener('click', event => this.handleClick(event, this));
|
||||
|
||||
// Instantiate ButtonState for each button for state management.
|
||||
this.enabledButton = new ButtonState(toggleEnabledButton, this.table);
|
||||
this.disabledButton = new ButtonState(toggleDisabledButton, this.table);
|
||||
this.enabledButton = new ButtonState(
|
||||
toggleEnabledButton,
|
||||
table.querySelectorAll<HTMLTableRowElement>('tr[data-enabled="enabled"]'),
|
||||
);
|
||||
this.disabledButton = new ButtonState(
|
||||
toggleDisabledButton,
|
||||
table.querySelectorAll<HTMLTableRowElement>('tr[data-enabled="disabled"]'),
|
||||
);
|
||||
this.virtualButton = new ButtonState(
|
||||
toggleVirtualButton,
|
||||
table.querySelectorAll<HTMLTableRowElement>('tr[data-type="virtual"]'),
|
||||
);
|
||||
} catch (err) {
|
||||
if (err instanceof TableStateError) {
|
||||
// This class is useless for tables that don't have toggle buttons.
|
||||
@ -246,37 +236,42 @@ class TableState {
|
||||
private toggleCaption(): void {
|
||||
const showEnabled = this.enabledButton.buttonState === 'show';
|
||||
const showDisabled = this.disabledButton.buttonState === 'show';
|
||||
const showVirtual = this.virtualButton.buttonState === 'show';
|
||||
|
||||
if (showEnabled && !showDisabled) {
|
||||
if (showEnabled && !showDisabled && !showVirtual) {
|
||||
this.captionText = 'Showing Enabled Interfaces';
|
||||
} else if (showEnabled && showDisabled) {
|
||||
} else if (showEnabled && showDisabled && !showVirtual) {
|
||||
this.captionText = 'Showing Enabled & Disabled Interfaces';
|
||||
} else if (!showEnabled && showDisabled) {
|
||||
} else if (!showEnabled && showDisabled && !showVirtual) {
|
||||
this.captionText = 'Showing Disabled Interfaces';
|
||||
} else if (!showEnabled && !showDisabled) {
|
||||
this.captionText = 'Hiding Enabled & Disabled Interfaces';
|
||||
} else if (!showEnabled && !showDisabled && !showVirtual) {
|
||||
this.captionText = 'Hiding Enabled, Disabled & Virtual Interfaces';
|
||||
} else if (!showEnabled && !showDisabled && showVirtual) {
|
||||
this.captionText = 'Showing Virtual Interfaces';
|
||||
} else if (showEnabled && !showDisabled && showVirtual) {
|
||||
this.captionText = 'Showing Enabled & Virtual Interfaces';
|
||||
} else if (showEnabled && showDisabled && showVirtual) {
|
||||
this.captionText = 'Showing Enabled, Disabled & Virtual Interfaces';
|
||||
} else {
|
||||
this.captionText = '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* When toggle buttons are clicked, pass the event to the relevant button's handler and update
|
||||
* this instance's state.
|
||||
* When toggle buttons are clicked, reapply visability all rows and
|
||||
* pass the event to all button handlers
|
||||
*
|
||||
* @param event onClick event for toggle buttons.
|
||||
* @param instance Instance of TableState (`this` cannot be used since that's context-specific).
|
||||
*/
|
||||
public handleClick(event: Event, instance: TableState): void {
|
||||
const button = event.currentTarget as HTMLButtonElement;
|
||||
const enabled = button.isEqualNode(instance.enabledButton.button);
|
||||
const disabled = button.isEqualNode(instance.disabledButton.button);
|
||||
|
||||
if (enabled) {
|
||||
instance.enabledButton.handleClick(event);
|
||||
} else if (disabled) {
|
||||
instance.disabledButton.handleClick(event);
|
||||
for (const row of this.rows) {
|
||||
row.classList.remove('d-none');
|
||||
}
|
||||
|
||||
instance.enabledButton.handleClick(event);
|
||||
instance.disabledButton.handleClick(event);
|
||||
instance.virtualButton.handleClick(event);
|
||||
instance.toggleCaption();
|
||||
}
|
||||
}
|
||||
|
@ -236,12 +236,12 @@ table {
|
||||
}
|
||||
|
||||
th.asc > a::after {
|
||||
content: "\f0140";
|
||||
content: '\f0140';
|
||||
font-family: 'Material Design Icons';
|
||||
}
|
||||
|
||||
th.desc > a::after {
|
||||
content: "\f0143";
|
||||
content: '\f0143';
|
||||
font-family: 'Material Design Icons';
|
||||
}
|
||||
|
||||
@ -416,18 +416,18 @@ nav.search {
|
||||
}
|
||||
}
|
||||
|
||||
// Styles for the quicksearch and its clear button;
|
||||
// Styles for the quicksearch and its clear button;
|
||||
// Overrides input-group styles and adds transition effects
|
||||
.quicksearch {
|
||||
input[type="search"] {
|
||||
border-radius: $border-radius !important;
|
||||
input[type='search'] {
|
||||
border-radius: $border-radius !important;
|
||||
}
|
||||
|
||||
button {
|
||||
margin-left: -32px !important;
|
||||
z-index: 100 !important;
|
||||
outline: none !important;
|
||||
border-radius: $border-radius !important;
|
||||
border-radius: $border-radius !important;
|
||||
transition: visibility 0s, opacity 0.2s linear;
|
||||
}
|
||||
|
||||
@ -998,9 +998,24 @@ div.card-overlay {
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
/* Markdown widget */
|
||||
.markdown-widget {
|
||||
.nav-link {
|
||||
border-bottom: 0;
|
||||
|
||||
&.active {
|
||||
background-color: var(--nbx-body-bg);
|
||||
}
|
||||
}
|
||||
|
||||
.nav-tabs {
|
||||
background-color: var(--nbx-pre-bg);
|
||||
}
|
||||
}
|
||||
|
||||
// Preformatted text blocks
|
||||
td pre {
|
||||
margin-bottom: 0
|
||||
margin-bottom: 0;
|
||||
}
|
||||
pre.block {
|
||||
padding: $spacer;
|
||||
|
@ -7,5 +7,6 @@
|
||||
<ul class="dropdown-menu">
|
||||
<button type="button" class="dropdown-item toggle-enabled" data-state="show">Hide Enabled</button>
|
||||
<button type="button" class="dropdown-item toggle-disabled" data-state="show">Hide Disabled</button>
|
||||
<button type="button" class="dropdown-item toggle-virtual" data-state="show">Hide Virtual</button>
|
||||
</ul>
|
||||
{% endblock extra_table_controls %}
|
||||
|
@ -2,7 +2,7 @@ from django import forms
|
||||
|
||||
from netbox.forms import NetBoxModelBulkEditForm
|
||||
from tenancy.models import *
|
||||
from utilities.forms import CommentField, DynamicModelChoiceField, SmallTextarea
|
||||
from utilities.forms import CommentField, DynamicModelChoiceField
|
||||
|
||||
__all__ = (
|
||||
'ContactBulkEditForm',
|
||||
@ -106,7 +106,6 @@ class ContactBulkEditForm(NetBoxModelBulkEditForm):
|
||||
required=False
|
||||
)
|
||||
comments = CommentField(
|
||||
widget=SmallTextarea,
|
||||
label='Comments'
|
||||
)
|
||||
|
||||
|
@ -27,7 +27,7 @@ class CommentField(forms.CharField):
|
||||
"""
|
||||
A textarea with support for Markdown rendering. Exists mostly just to add a standard `help_text`.
|
||||
"""
|
||||
widget = forms.Textarea
|
||||
widget = widgets.MarkdownWidget
|
||||
help_text = f"""
|
||||
<i class="mdi mdi-information-outline"></i>
|
||||
<a href="{static('docs/reference/markdown/')}" target="_blank" tabindex="-1">
|
||||
|
@ -180,7 +180,7 @@ class ImportForm(BootstrapMixin, forms.Form):
|
||||
if 'data_file' in self.files:
|
||||
self.data_field = 'data_file'
|
||||
file = self.files.get('data_file')
|
||||
data = file.read().decode('utf-8')
|
||||
data = file.read().decode('utf-8-sig')
|
||||
else:
|
||||
data = self.cleaned_data['data']
|
||||
|
||||
|
@ -195,6 +195,7 @@ def parse_csv(reader):
|
||||
# `site.slug` header, to indicate the related site is being referenced by its slug.
|
||||
|
||||
for header in next(reader):
|
||||
header = header.strip()
|
||||
if '.' in header:
|
||||
field, to_field = header.split('.', 1)
|
||||
headers[field] = to_field
|
||||
|
@ -16,6 +16,7 @@ __all__ = (
|
||||
'ColorSelect',
|
||||
'DatePicker',
|
||||
'DateTimePicker',
|
||||
'MarkdownWidget',
|
||||
'NumericArrayField',
|
||||
'SelectDurationWidget',
|
||||
'SelectSpeedWidget',
|
||||
@ -116,6 +117,10 @@ class SelectDurationWidget(forms.NumberInput):
|
||||
template_name = 'widgets/select_duration.html'
|
||||
|
||||
|
||||
class MarkdownWidget(forms.Textarea):
|
||||
template_name = 'widgets/markdown_input.html'
|
||||
|
||||
|
||||
class NumericArrayField(SimpleArrayField):
|
||||
|
||||
def clean(self, value):
|
||||
|
@ -76,8 +76,6 @@ def get_paginate_count(request):
|
||||
if 'per_page' in request.GET:
|
||||
try:
|
||||
per_page = int(request.GET.get('per_page'))
|
||||
if request.user.is_authenticated:
|
||||
request.user.config.set('pagination.per_page', per_page, commit=True)
|
||||
return _max_allowed(per_page)
|
||||
except ValueError:
|
||||
pass
|
||||
|
@ -6,7 +6,7 @@
|
||||
{# Render the field label, except for: #}
|
||||
{# 1. Checkboxes (label appears to the right of the field #}
|
||||
{# 2. Textareas with no label set (will expand across entire row) #}
|
||||
{% if field|widget_type == 'checkboxinput' or field|widget_type == 'textarea' and not label %}
|
||||
{% if field|widget_type == 'checkboxinput' or field|widget_type == 'textarea' or field|widget_type == 'markdownwidget' and not label %}
|
||||
{% else %}
|
||||
<label for="{{ field.id_for_label }}" class="col-sm-3 col-form-label text-lg-end{% if field.field.required %} required{% endif %}">
|
||||
{{ label }}
|
||||
|
22
netbox/utilities/templates/widgets/markdown_input.html
Normal file
22
netbox/utilities/templates/widgets/markdown_input.html
Normal file
@ -0,0 +1,22 @@
|
||||
<div class="border rounded markdown-widget">
|
||||
<ul class="nav nav-tabs px-3 pt-2 rounded-top border-0">
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link active " id="{{ widget.name }}-input-tab" data-bs-toggle="tab" data-bs-target="#{{ widget.name }}-input" type="button" role="tab" aria-controls="{{ widget.name }}-input" aria-selected="true">
|
||||
Write
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button hx-target="#{{ widget.name }}-preview" hx-swap="innerHTML" hx-post="{% url 'extras:render_markdown' %}" class="nav-link preview-button" id="{{ widget.name }}-markdown-preview-tab" data-bs-toggle="tab" data-bs-target="#{{ widget.name }}-markdown-preview" type="button" role="tab" aria-controls="{{ widget.name }}-markdown-preview" aria-selected="false">
|
||||
Preview
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="tab-content bg-body rounded-bottom border-top">
|
||||
<div class="tab-pane show active" id="{{ widget.name }}-input" role="tabpanel" aria-labelledby="{{ widget.name }}-input-tab">
|
||||
{% include "django/forms/widgets/textarea.html" %}
|
||||
</div>
|
||||
<div class="tab-pane show" id="{{ widget.name }}-markdown-preview" role="tabpanel" aria-labelledby="{{ widget.name }}-markdown-preview-tab">
|
||||
<div id="{{ widget.name }}-preview" class="preview px-3 py-2">Testing</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
@ -17,6 +17,8 @@ from utilities.api import get_graphql_type_for_model
|
||||
from .base import ModelTestCase
|
||||
from .utils import disable_warnings
|
||||
|
||||
from ipam.graphql.types import IPAddressFamilyType
|
||||
|
||||
|
||||
__all__ = (
|
||||
'APITestCase',
|
||||
@ -460,6 +462,8 @@ class APIViewTestCases:
|
||||
# TODO: Come up with something more elegant
|
||||
# Temporary hack to support automated testing of reverse generic relations
|
||||
fields_string += f'{field_name} {{ id }}\n'
|
||||
elif inspect.isclass(field.type) and issubclass(field.type, IPAddressFamilyType):
|
||||
fields_string += f'{field_name} {{ value, label }}\n'
|
||||
else:
|
||||
fields_string += f'{field_name}\n'
|
||||
|
||||
|
@ -359,18 +359,18 @@ def prepare_cloned_fields(instance):
|
||||
return QueryDict(urlencode(params), mutable=True)
|
||||
|
||||
|
||||
def shallow_compare_dict(source_dict, destination_dict, exclude=None):
|
||||
def shallow_compare_dict(source_dict, destination_dict, exclude=tuple()):
|
||||
"""
|
||||
Return a new dictionary of the different keys. The values of `destination_dict` are returned. Only the equality of
|
||||
the first layer of keys/values is checked. `exclude` is a list or tuple of keys to be ignored.
|
||||
"""
|
||||
difference = {}
|
||||
|
||||
for key in destination_dict:
|
||||
if source_dict.get(key) != destination_dict[key]:
|
||||
if isinstance(exclude, (list, tuple)) and key in exclude:
|
||||
continue
|
||||
difference[key] = destination_dict[key]
|
||||
for key, value in destination_dict.items():
|
||||
if key in exclude:
|
||||
continue
|
||||
if source_dict.get(key) != value:
|
||||
difference[key] = value
|
||||
|
||||
return difference
|
||||
|
||||
|
@ -238,7 +238,9 @@ class VirtualMachineFilterSet(
|
||||
return queryset
|
||||
return queryset.filter(
|
||||
Q(name__icontains=value) |
|
||||
Q(comments__icontains=value)
|
||||
Q(comments__icontains=value) |
|
||||
Q(primary_ip4__address__startswith=value) |
|
||||
Q(primary_ip6__address__startswith=value)
|
||||
)
|
||||
|
||||
def _has_primary_ip(self, queryset, name, value):
|
||||
|
@ -9,7 +9,7 @@ from netbox.forms import NetBoxModelBulkEditForm
|
||||
from tenancy.models import Tenant
|
||||
from utilities.forms import (
|
||||
add_blank_choice, BulkEditNullBooleanSelect, BulkRenameForm, CommentField, DynamicModelChoiceField,
|
||||
DynamicModelMultipleChoiceField, SmallTextarea, StaticSelect
|
||||
DynamicModelMultipleChoiceField, StaticSelect
|
||||
)
|
||||
from virtualization.choices import *
|
||||
from virtualization.models import *
|
||||
@ -90,7 +90,6 @@ class ClusterBulkEditForm(NetBoxModelBulkEditForm):
|
||||
required=False
|
||||
)
|
||||
comments = CommentField(
|
||||
widget=SmallTextarea,
|
||||
label=_('Comments')
|
||||
)
|
||||
|
||||
@ -163,7 +162,6 @@ class VirtualMachineBulkEditForm(NetBoxModelBulkEditForm):
|
||||
required=False
|
||||
)
|
||||
comments = CommentField(
|
||||
widget=SmallTextarea,
|
||||
label=_('Comments')
|
||||
)
|
||||
|
||||
|
@ -5,7 +5,7 @@ from dcim.choices import LinkStatusChoices
|
||||
from ipam.models import VLAN
|
||||
from netbox.forms import NetBoxModelBulkEditForm
|
||||
from tenancy.models import Tenant
|
||||
from utilities.forms import add_blank_choice, CommentField, DynamicModelChoiceField, SmallTextarea
|
||||
from utilities.forms import add_blank_choice, CommentField, DynamicModelChoiceField
|
||||
from wireless.choices import *
|
||||
from wireless.constants import SSID_MAX_LENGTH
|
||||
from wireless.models import *
|
||||
@ -74,7 +74,6 @@ class WirelessLANBulkEditForm(NetBoxModelBulkEditForm):
|
||||
required=False
|
||||
)
|
||||
comments = CommentField(
|
||||
widget=SmallTextarea,
|
||||
label='Comments'
|
||||
)
|
||||
|
||||
@ -119,7 +118,6 @@ class WirelessLinkBulkEditForm(NetBoxModelBulkEditForm):
|
||||
required=False
|
||||
)
|
||||
comments = CommentField(
|
||||
widget=SmallTextarea,
|
||||
label='Comments'
|
||||
)
|
||||
|
||||
|
@ -1,6 +1,6 @@
|
||||
bleach==5.0.1
|
||||
Django==4.1.7
|
||||
django-cors-headers==3.13.0
|
||||
django-cors-headers==3.14.0
|
||||
django-debug-toolbar==3.8.1
|
||||
django-filter==22.1
|
||||
django-graphiql-debug-toolbar==0.2.0
|
||||
@ -8,9 +8,9 @@ django-mptt==0.14
|
||||
django-pglocks==1.0.4
|
||||
django-prometheus==2.2.0
|
||||
django-redis==5.2.0
|
||||
django-rich==1.4.0
|
||||
django-rich==1.5.0
|
||||
django-rq==2.7.0
|
||||
django-tables2==2.5.2
|
||||
django-tables2==2.5.3
|
||||
django-taggit==3.1.0
|
||||
django-timezone-field==5.0
|
||||
djangorestframework==3.14.0
|
||||
@ -19,13 +19,13 @@ graphene-django==3.0.0
|
||||
gunicorn==20.1.0
|
||||
Jinja2==3.1.2
|
||||
Markdown==3.3.7
|
||||
mkdocs-material==9.0.13
|
||||
mkdocs-material==9.1.2
|
||||
mkdocstrings[python-legacy]==0.20.0
|
||||
netaddr==0.8.0
|
||||
Pillow==9.4.0
|
||||
psycopg2-binary==2.9.5
|
||||
PyYAML==6.0
|
||||
sentry-sdk==1.15.0
|
||||
sentry-sdk==1.16.0
|
||||
social-auth-app-django==5.0.0
|
||||
social-auth-core[openidconnect]==4.3.0
|
||||
svgwrite==1.4.3
|
||||
|
Loading…
Reference in New Issue
Block a user