diff --git a/.github/ISSUE_TEMPLATE/bug_report.yaml b/.github/ISSUE_TEMPLATE/bug_report.yaml index a9af9c653..3b87a49e4 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yaml +++ b/.github/ISSUE_TEMPLATE/bug_report.yaml @@ -14,7 +14,7 @@ body: attributes: label: NetBox version description: What version of NetBox are you currently running? - placeholder: v3.2.4 + placeholder: v3.2.5 validations: required: true - type: dropdown diff --git a/.github/ISSUE_TEMPLATE/feature_request.yaml b/.github/ISSUE_TEMPLATE/feature_request.yaml index 1fff99f1d..1fc0268ab 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yaml +++ b/.github/ISSUE_TEMPLATE/feature_request.yaml @@ -14,7 +14,7 @@ body: attributes: label: NetBox version description: What version of NetBox are you currently running? - placeholder: v3.2.4 + placeholder: v3.2.5 validations: required: true - type: dropdown diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index 7390ec1df..57666417a 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -27,7 +27,10 @@ jobs: This issue has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs. NetBox is governed by a small group of core maintainers which means not all opened - issues may receive direct feedback. Please see our [contributing guide](https://github.com/netbox-community/netbox/blob/develop/CONTRIBUTING.md). + issues may receive direct feedback. **Do not** attempt to circumvent this + process by "bumping" the issue; doing so will result in its immediate closure + and you may be barred from participating in any future discussions. Please see + our [contributing guide](https://github.com/netbox-community/netbox/blob/develop/CONTRIBUTING.md). stale-pr-label: 'pending closure' stale-pr-message: > This PR has been automatically marked as stale because it has not had diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c01adf4c9..1b4733cbe 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -160,9 +160,9 @@ to aid in issue management. It is natural that some new issues get more attention than others. The stale bot helps bring renewed attention to potentially valuable issues that may have -been overlooked. **Do not** comment on an issue that has been marked stale in -an effort to circumvent the bot: Doing so will not remove the stale label. -(Stale labels can be removed only by maintainers.) +been overlooked. **Do not** comment on a stale issue merely to "bump" it in an +effort to circumvent the bot: This will result in the immediate closure of the +issue, and you may be barred from participating in future discussions. ## Maintainer Guidance diff --git a/base_requirements.txt b/base_requirements.txt index 6bb537a6a..98d3f78c2 100644 --- a/base_requirements.txt +++ b/base_requirements.txt @@ -44,7 +44,8 @@ django-tables2 # User-defined tags for objects # https://github.com/alex/django-taggit -django-taggit +# Will evaluate v3.0 during NetBox v3.3 beta +django-taggit>=2.1.0,<3.0 # A Django field for representing time zones # https://github.com/mfogel/django-timezone-field/ @@ -125,3 +126,7 @@ tablib # Timezone data (required by django-timezone-field on Python 3.9+) # https://github.com/python/tzdata tzdata + +# HTML sanitizer +# https://github.com/mozilla/bleach +bleach \ No newline at end of file diff --git a/docs/configuration/optional-settings.md b/docs/configuration/optional-settings.md index 670cf524b..3b1c848a7 100644 --- a/docs/configuration/optional-settings.md +++ b/docs/configuration/optional-settings.md @@ -255,6 +255,23 @@ HTTP_PROXIES = { --- +## JINJA2_FILTERS + +Default: `{}` + +A dictionary of custom jinja2 filters with the key being the filter name and the value being a callable. For more information see the [Jinja2 documentation](https://jinja.palletsprojects.com/en/3.1.x/api/#custom-filters). For example: + +```python +def uppercase(x): + return str(x).upper() + +JINJA2_FILTERS = { + 'uppercase': uppercase, +} +``` + +--- + ## INTERNAL_IPS Default: `('127.0.0.1', '::1')` diff --git a/docs/reference/markdown.md b/docs/reference/markdown.md new file mode 100644 index 000000000..896d5dcf7 --- /dev/null +++ b/docs/reference/markdown.md @@ -0,0 +1,353 @@ +--- +hide: + - toc +--- + +# Markdown + +NetBox supports markdown rendering for certain text fields. + +## Syntax + +##### Table of Contents +[Headers](#headers) +[Emphasis](#emphasis) +[Lists](#lists) +[Links](#links) +[Images](#images) +[Code Blocks](#code) +[Tables](#tables) +[Blockquotes](#blockquotes) +[Inline HTML](#html) +[Horizontal Rule](#hr) +[Line Breaks](#lines) + + + +## Headers + +```no-highlight +# H1 +## H2 +### H3 +#### H4 +##### H5 +###### H6 + +Alternatively, for H1 and H2, an underline-ish style: + +Alt-H1 +====== + +Alt-H2 +------ +``` + +# H1 +## H2 +### H3 +#### H4 +##### H5 +###### H6 + + + +## Emphasis + +```no-highlight +Emphasis, aka italics, with *asterisks* or _underscores_. + +Strong emphasis, aka bold, with **asterisks** or __underscores__. + +Combined emphasis with **asterisks and _underscores_**. + +Strikethrough uses two tildes. ~~Scratch this.~~ +``` + +Emphasis, aka italics, with *asterisks* or _underscores_. + +Strong emphasis, aka bold, with **asterisks** or __underscores__. + +Combined emphasis with **asterisks and _underscores_**. + +Strikethrough uses two tildes. ~~Scratch this.~~ + + + + +## Lists + +(In this example, leading and trailing spaces are shown with with dots: ⋅) + +```no-highlight +1. First ordered list item +2. Another item +⋅⋅* Unordered sub-list. +1. Actual numbers don't matter, just that it's a number +⋅⋅1. Ordered sub-list +4. And another item. + +⋅⋅⋅You can have properly indented paragraphs within list items. Notice the blank line above, and the leading spaces (at least one, but we'll use three here to also align the raw Markdown). + +⋅⋅⋅To have a line break without a paragraph, you will need to use two trailing spaces.⋅⋅ +⋅⋅⋅Note that this line is separate, but within the same paragraph.⋅⋅ +⋅⋅⋅(This is contrary to the typical GFM line break behaviour, where trailing spaces are not required.) + +* Unordered list can use asterisks +- Or minuses ++ Or pluses +``` + +1. First ordered list item +2. Another item + * Unordered sub-list. +1. Actual numbers don't matter, just that it's a number + 1. Ordered sub-list +4. And another item. + + You can have properly indented paragraphs within list items. Notice the blank line above, and the leading spaces (at least one, but we'll use three here to also align the raw Markdown). + + To have a line break without a paragraph, you will need to use two trailing spaces. + Note that this line is separate, but within the same paragraph. + (This is contrary to the typical GFM line break behaviour, where trailing spaces are not required.) + +* Unordered list can use asterisks +- Or minuses ++ Or pluses + + + +## Links + +There are two ways to create links. + +```no-highlight +[I'm an inline-style link](https://www.google.com) + +[I'm an inline-style link with title](https://www.google.com "Google's Homepage") + +[I'm a reference-style link][Arbitrary case-insensitive reference text] + +[You can use numbers for reference-style link definitions][1] + +Or leave it empty and use the [link text itself]. + +URLs and URLs in angle brackets will automatically get turned into links. +http://www.example.com or and sometimes +example.com (but not on Github, for example). + +Some text to show that the reference links can follow later. + +[arbitrary case-insensitive reference text]: https://www.mozilla.org +[1]: http://slashdot.org +[link text itself]: http://www.reddit.com +``` + +[I'm an inline-style link](https://www.google.com) + +[I'm an inline-style link with title](https://www.google.com "Google's Homepage") + +[I'm a reference-style link][Arbitrary case-insensitive reference text] + +[You can use numbers for reference-style link definitions][1] + +Or leave it empty and use the [link text itself]. + +URLs and URLs in angle brackets will automatically get turned into links. +http://www.example.com or and sometimes +example.com (but not on Github, for example). + +Some text to show that the reference links can follow later. + +[arbitrary case-insensitive reference text]: https://www.mozilla.org +[1]: http://slashdot.org +[link text itself]: http://www.reddit.com + + + +## Images + +``` +Here's the Netbox logo (hover to see the title text): + +Inline-style: +![alt text](/static/netbox_logo.png "Logo Title Text 1") + +Reference-style: +![alt text][logo] + +[logo]: /static/netbox_logo.png "Logo Title Text 2" +``` + +Here's the Netbox logo (hover to see the title text): + +Inline-style: +![alt text](/static/netbox_logo.png "Logo Title Text 1") + +Reference-style: +![alt text][logo] + +[logo]: /static/netbox_logo.png "Logo Title Text 2" + + + +## Code blocks + +``` +Inline `code` has `back-ticks around` it. +``` + +Inline `code` has `back-ticks around` it. + +Blocks of code are fenced by lines with three back-ticks ``` + +```` +``` +var s = "Code block"; +alert(s); +``` +```` + +``` +var s = "Code block"; +alert(s); +``` + + + +## Tables + +```no-highlight +Colons can be used to align columns. + +| Tables | Are | Cool | +| ------------- |:-------------:| -----:| +| col 3 is | right-aligned | $1600 | +| col 2 is | centered | $12 | +| zebra stripes | are neat | $1 | + +There must be at least 3 dashes separating each header cell. +The outer pipes (|) are optional, and you don't need to make the +raw Markdown line up prettily. You can also use inline Markdown. + +Markdown | Less | Pretty +--- | --- | --- +*Still* | `renders` | **nicely** +1 | 2 | 3 +``` + +Colons can be used to align columns. + +| Tables | Are | Cool | +| ------------- |:-------------:| -----:| +| col 3 is | right-aligned | $1600 | +| col 2 is | centered | $12 | +| zebra stripes | are neat | $1 | + +There must be at least 3 dashes separating each header cell. The outer pipes (|) are optional, and you don't need to make the raw Markdown line up prettily. You can also use inline Markdown. + +Markdown | Less | Pretty +--- | --- | --- +*Still* | `renders` | **nicely** +1 | 2 | 3 + + + +## Blockquotes + +```no-highlight +> Blockquotes are very handy in email to emulate reply text. +> This line is part of the same quote. + +Quote break. + +> This is a very long line that will still be quoted properly when it wraps. Oh boy let's keep writing to make sure this is long enough to actually wrap for everyone. Oh, you can *put* **Markdown** into a blockquote. +``` + +> Blockquotes are very handy in email to emulate reply text. +> This line is part of the same quote. + +Quote break. + +> This is a very long line that will still be quoted properly when it wraps. Oh boy let's keep writing to make sure this is long enough to actually wrap for everyone. Oh, you can *put* **Markdown** into a blockquote. + + + +## Inline HTML + +You can also use raw HTML in your Markdown, and it'll mostly work pretty well. + +```no-highlight +
+
Definition list
+
Is something people use sometimes.
+ +
Markdown in HTML
+
Does *not* work **very** well. Use HTML tags.
+
+``` + +
+
Definition list
+
Is something people use sometimes.
+ +
Markdown in HTML
+
Does *not* work **very** well. Use HTML tags.
+
+ + + +## Horizontal Rule + +``` +Three or more... + +--- + +Hyphens + +*** + +Asterisks + +___ + +Underscores +``` + +Three or more... + +--- + +Hyphens + +*** + +Asterisks + +___ + +Underscores + + + +## Line Breaks + + +``` +Here's a line for us to start with. + +This line is separated from the one above by two newlines, so it will be a *separate paragraph*. + +This line is also a separate paragraph, but... +This line is only separated by a single newline, so it's a separate line in the *same paragraph*. +``` + +Here's a line for us to start with. + +This line is separated from the one above by two newlines, so it will be a *separate paragraph*. + +This line is also begins a separate paragraph, but... +This line is only separated by a single newline, so it's a separate line in the *same paragraph*. + +Based on [Markdown-Cheatsheet](https://github.com/adam-p/markdown-here/wiki/Markdown-Cheatsheet) by [adam-p](https://github.com/adam-p) licensed under [CC-BY](https://creativecommons.org/licenses/by/3.0/) \ No newline at end of file diff --git a/docs/release-notes/version-3.2.md b/docs/release-notes/version-3.2.md index fa533a475..bb5182702 100644 --- a/docs/release-notes/version-3.2.md +++ b/docs/release-notes/version-3.2.md @@ -1,5 +1,38 @@ # NetBox v3.2 +## v3.2.5 (2022-06-20) + +### Enhancements + +* [#8704](https://github.com/netbox-community/netbox/issues/8704) - Shift-click to select multiple objects in a list +* [#8882](https://github.com/netbox-community/netbox/issues/8882) - Support filtering IP addresses by multiple parent prefixes +* [#8893](https://github.com/netbox-community/netbox/issues/8893) - Include count of IP ranges under tenant view +* [#9417](https://github.com/netbox-community/netbox/issues/9417) - Initialize manufacturer selection when inserting a new module +* [#9501](https://github.com/netbox-community/netbox/issues/9501) - Add support for custom Jinja2 filters +* [#9517](https://github.com/netbox-community/netbox/issues/9517) - Linkify related power port on power outlet view +* [#9525](https://github.com/netbox-community/netbox/issues/9525) - Provide one-click edit link for objects in tables +* [#9533](https://github.com/netbox-community/netbox/issues/9533) - Move Markdown reference to local documentation +* [#9534](https://github.com/netbox-community/netbox/issues/9534) - Add VLAN group selector to interface bulk edit forms +* [#9556](https://github.com/netbox-community/netbox/issues/9556) - Leave dropdown open upon selection for multi-select fields + +### Bug Fixes + +* [#8944](https://github.com/netbox-community/netbox/issues/8944) - Fix rendering of Markdown links with colons +* [#9108](https://github.com/netbox-community/netbox/issues/9108) - Fix rendering of bracketed Markdown links +* [#9374](https://github.com/netbox-community/netbox/issues/9374) - Improve performance when retrieving devices/VMs with config context data +* [#9466](https://github.com/netbox-community/netbox/issues/9466) - Avoid sending webhooks after script/report failure +* [#9480](https://github.com/netbox-community/netbox/issues/9480) - Fix sorting services & service templates by port numbers +* [#9484](https://github.com/netbox-community/netbox/issues/9484) - Include services listening on "all IPs" under IP address view +* [#9486](https://github.com/netbox-community/netbox/issues/9486) - Fix redirect URL when adding device components from the module view +* [#9495](https://github.com/netbox-community/netbox/issues/9495) - Correct link to contacts in contact groups table column +* [#9503](https://github.com/netbox-community/netbox/issues/9503) - Hyperlinks in ack elevation SVGs must always use absolute URLs +* [#9512](https://github.com/netbox-community/netbox/issues/9512) - Fix duplicate site results when searching by ASN +* [#9524](https://github.com/netbox-community/netbox/issues/9524) - Correct order of VLAN fields under VM interface creation form +* [#9537](https://github.com/netbox-community/netbox/issues/9537) - Ensure consistent use of placeholder tag throughout UI +* [#9549](https://github.com/netbox-community/netbox/issues/9549) - Fix device counts for rack list under rack role view + +--- + ## v3.2.4 (2022-05-31) ### Enhancements @@ -25,7 +58,6 @@ * [#9425](https://github.com/netbox-community/netbox/issues/9425) - Fix bulk import for object and multi-object custom fields * [#9430](https://github.com/netbox-community/netbox/issues/9430) - Fix passing of initial form data for DynamicModelChoiceFields - --- ## v3.2.3 (2022-05-12) diff --git a/mkdocs.yml b/mkdocs.yml index 5c973e0d6..507b25627 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -136,6 +136,7 @@ nav: - Overview: 'graphql-api/overview.md' - Reference: - Conditions: 'reference/conditions.md' + - Markdown: 'reference/markdown.md' - Development: - Introduction: 'development/index.md' - Getting Started: 'development/getting-started.md' diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index e99ef333a..c4c25f654 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -19,6 +19,7 @@ from ipam.models import Prefix, VLAN from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired from netbox.api.exceptions import ServiceUnavailable from netbox.api.metadata import ContentTypeMetadata +from netbox.api.pagination import StripCountAnnotationsPaginator from netbox.api.viewsets import NetBoxModelViewSet from netbox.config import get_config from utilities.api import get_serializer_for_model @@ -392,6 +393,7 @@ class DeviceViewSet(ConfigContextQuerySetMixin, NetBoxModelViewSet): 'virtual_chassis__master', 'primary_ip4__nat_outside', 'primary_ip6__nat_outside', 'tags', ) filterset_class = filtersets.DeviceFilterSet + pagination_class = StripCountAnnotationsPaginator def get_serializer_class(self): """ diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index d57d0a59b..f052a8be9 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -163,7 +163,7 @@ class SiteFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilterSe qs_filter |= Q(asns__asn=int(value.strip())) except ValueError: pass - return queryset.filter(qs_filter) + return queryset.filter(qs_filter).distinct() class LocationFilterSet(TenancyFilterSet, ContactModelFilterSet, OrganizationalModelFilterSet): diff --git a/netbox/dcim/forms/bulk_edit.py b/netbox/dcim/forms/bulk_edit.py index 9e4f5e400..231d01ddd 100644 --- a/netbox/dcim/forms/bulk_edit.py +++ b/netbox/dcim/forms/bulk_edit.py @@ -6,7 +6,7 @@ from timezone_field import TimeZoneFormField from dcim.choices import * from dcim.constants import * from dcim.models import * -from ipam.models import ASN, VLAN, VRF +from ipam.models import ASN, VLAN, VLANGroup, VRF from netbox.forms import NetBoxModelBulkEditForm from tenancy.models import Tenant from utilities.forms import ( @@ -1067,13 +1067,32 @@ class InterfaceBulkEditForm( required=False, widget=BulkEditNullBooleanSelect ) + mode = forms.ChoiceField( + choices=add_blank_choice(InterfaceModeChoices), + required=False, + initial='', + widget=StaticSelect() + ) + vlan_group = DynamicModelChoiceField( + queryset=VLANGroup.objects.all(), + required=False, + label='VLAN group' + ) untagged_vlan = DynamicModelChoiceField( queryset=VLAN.objects.all(), - required=False + required=False, + query_params={ + 'group_id': '$vlan_group', + }, + label='Untagged VLAN' ) tagged_vlans = DynamicModelMultipleChoiceField( queryset=VLAN.objects.all(), - required=False + required=False, + query_params={ + 'group_id': '$vlan_group', + }, + label='Tagged VLANs' ) vrf = DynamicModelChoiceField( queryset=VRF.objects.all(), @@ -1087,13 +1106,13 @@ class InterfaceBulkEditForm( ('Addressing', ('vrf', 'mac_address', 'wwn')), ('Operation', ('mtu', 'tx_power', 'enabled', 'mgmt_only', 'mark_connected')), ('Related Interfaces', ('parent', 'bridge', 'lag')), - ('802.1Q Switching', ('mode', 'untagged_vlan', 'tagged_vlans')), + ('802.1Q Switching', ('mode', 'vlan_group', 'untagged_vlan', 'tagged_vlans')), ('Wireless', ('rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width')), ) nullable_fields = ( 'module', 'label', 'parent', 'bridge', 'lag', 'speed', 'duplex', 'mac_address', 'wwn', 'mtu', 'description', - 'mode', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'untagged_vlan', 'tagged_vlans', - 'vrf', + 'mode', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'vlan_group', 'untagged_vlan', + 'tagged_vlans', 'vrf', ) def __init__(self, *args, **kwargs): diff --git a/netbox/dcim/svg.py b/netbox/dcim/svg.py index 7cd0fa417..1de68ec36 100644 --- a/netbox/dcim/svg.py +++ b/netbox/dcim/svg.py @@ -114,7 +114,7 @@ class RackElevationSVG: # Embed front device type image if one exists if self.include_images and device.device_type.front_image: image = drawing.image( - href=device.device_type.front_image.url, + href='{}{}'.format(self.base_url, device.device_type.front_image.url), insert=start, size=end, class_='device-image' @@ -140,7 +140,7 @@ class RackElevationSVG: # Embed rear device type image if one exists if self.include_images and device.device_type.rear_image: image = drawing.image( - href=device.device_type.rear_image.url, + href='{}{}'.format(self.base_url, device.device_type.rear_image.url), insert=start, size=end, class_='device-image' @@ -151,9 +151,9 @@ class RackElevationSVG: stroke_width='0.2em', stroke_linejoin='round', class_='device-image-label')) link.add(drawing.text(get_device_name(device), insert=text, fill='white', class_='device-image-label')) - @staticmethod - def _draw_empty(drawing, rack, start, end, text, id_, face_id, class_, reservation): - link_url = '{}?{}'.format( + def _draw_empty(self, drawing, rack, start, end, text, id_, face_id, class_, reservation): + link_url = '{}{}?{}'.format( + self.base_url, reverse('dcim:device_add'), urlencode({ 'site': rack.site.pk, diff --git a/netbox/dcim/tables/template_code.py b/netbox/dcim/tables/template_code.py index 92739c6ed..7124c2b1f 100644 --- a/netbox/dcim/tables/template_code.py +++ b/netbox/dcim/tables/template_code.py @@ -385,7 +385,7 @@ MODULEBAY_BUTTONS = """ {% else %} - + {% endif %} diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 57e8b1c79..35a1056b2 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -510,8 +510,8 @@ class RackRoleView(generic.ObjectView): queryset = RackRole.objects.all() def get_extra_context(self, request, instance): - racks = Rack.objects.restrict(request.user, 'view').filter( - role=instance + racks = Rack.objects.restrict(request.user, 'view').filter(role=instance).annotate( + device_count=count_related(Device, 'rack') ) racks_table = tables.RackTable(racks, user=request.user, exclude=( diff --git a/netbox/extras/management/commands/runscript.py b/netbox/extras/management/commands/runscript.py index 12188619f..2296ce1ff 100644 --- a/netbox/extras/management/commands/runscript.py +++ b/netbox/extras/management/commands/runscript.py @@ -14,6 +14,7 @@ from extras.choices import JobResultStatusChoices from extras.context_managers import change_logging from extras.models import JobResult from extras.scripts import get_script +from extras.signals import clear_webhooks from utilities.exceptions import AbortTransaction from utilities.utils import NetBoxFakeRequest @@ -49,7 +50,7 @@ class Command(BaseCommand): except AbortTransaction: script.log_info("Database changes have been reverted automatically.") - + clear_webhooks.send(request) except Exception as e: stacktrace = traceback.format_exc() script.log_failure( @@ -58,7 +59,7 @@ class Command(BaseCommand): script.log_info("Database changes have been reverted due to error.") logger.error(f"Exception raised during script execution: {e}") job_result.set_status(JobResultStatusChoices.STATUS_ERRORED) - + clear_webhooks.send(request) finally: job_result.data = ScriptOutputSerializer(script).data job_result.save() diff --git a/netbox/extras/scripts.py b/netbox/extras/scripts.py index 29fab5be8..cee264878 100644 --- a/netbox/extras/scripts.py +++ b/netbox/extras/scripts.py @@ -17,6 +17,7 @@ from django.utils.functional import classproperty from extras.api.serializers import ScriptOutputSerializer from extras.choices import JobResultStatusChoices, LogLevelChoices +from extras.signals import clear_webhooks from ipam.formfields import IPAddressFormField, IPNetworkFormField from ipam.validators import MaxPrefixLengthValidator, MinPrefixLengthValidator, prefix_validator from utilities.exceptions import AbortTransaction @@ -465,7 +466,7 @@ def run_script(data, request, commit=True, *args, **kwargs): except AbortTransaction: script.log_info("Database changes have been reverted automatically.") - + clear_webhooks.send(request) except Exception as e: stacktrace = traceback.format_exc() script.log_failure( @@ -474,7 +475,7 @@ def run_script(data, request, commit=True, *args, **kwargs): script.log_info("Database changes have been reverted due to error.") logger.error(f"Exception raised during script execution: {e}") job_result.set_status(JobResultStatusChoices.STATUS_ERRORED) - + clear_webhooks.send(request) finally: job_result.data = ScriptOutputSerializer(script).data job_result.save() diff --git a/netbox/ipam/filtersets.py b/netbox/ipam/filtersets.py index a445022ca..d9cf6eefc 100644 --- a/netbox/ipam/filtersets.py +++ b/netbox/ipam/filtersets.py @@ -464,7 +464,7 @@ class IPAddressFilterSet(NetBoxModelFilterSet, TenancyFilterSet): field_name='address', lookup_expr='family' ) - parent = django_filters.CharFilter( + parent = MultiValueCharFilter( method='search_by_parent', label='Parent prefix', ) @@ -571,14 +571,16 @@ class IPAddressFilterSet(NetBoxModelFilterSet, TenancyFilterSet): return queryset.filter(qs_filter) def search_by_parent(self, queryset, name, value): - value = value.strip() if not value: return queryset - try: - query = str(netaddr.IPNetwork(value.strip()).cidr) - return queryset.filter(address__net_host_contained=query) - except (AddrFormatError, ValueError): - return queryset.none() + q = Q() + for prefix in value: + try: + query = str(netaddr.IPNetwork(prefix.strip()).cidr) + q |= Q(address__net_host_contained=query) + except (AddrFormatError, ValueError): + return queryset.none() + return queryset.filter(q) def filter_address(self, queryset, name, value): try: diff --git a/netbox/ipam/tables/services.py b/netbox/ipam/tables/services.py index 8c81a28c2..58d0a9aff 100644 --- a/netbox/ipam/tables/services.py +++ b/netbox/ipam/tables/services.py @@ -14,7 +14,8 @@ class ServiceTemplateTable(NetBoxTable): linkify=True ) ports = tables.Column( - accessor=tables.A('port_list') + accessor=tables.A('port_list'), + order_by=tables.A('ports'), ) tags = columns.TagColumn( url_name='ipam:servicetemplate_list' @@ -35,7 +36,8 @@ class ServiceTable(NetBoxTable): order_by=('device', 'virtual_machine') ) ports = tables.Column( - accessor=tables.A('port_list') + accessor=tables.A('port_list'), + order_by=tables.A('ports'), ) tags = columns.TagColumn( url_name='ipam:service_list' diff --git a/netbox/ipam/tests/test_filtersets.py b/netbox/ipam/tests/test_filtersets.py index 198f9d62d..d98fe889e 100644 --- a/netbox/ipam/tests/test_filtersets.py +++ b/netbox/ipam/tests/test_filtersets.py @@ -823,10 +823,8 @@ class IPAddressTestCase(TestCase, ChangeLoggedFilterSetTests): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) def test_parent(self): - params = {'parent': '10.0.0.0/24'} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6) - params = {'parent': '2001:db8::/64'} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6) + params = {'parent': ['10.0.0.0/30', '2001:db8::/126']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 8) def test_filter_address(self): # Check IPv4 and IPv6, with and without a mask diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index 84f6db6d5..a01f2d052 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -7,12 +7,12 @@ from django.urls import reverse from circuits.models import Provider, Circuit from circuits.tables import ProviderTable from dcim.filtersets import InterfaceFilterSet -from dcim.models import Interface, Site +from dcim.models import Interface, Site, Device from dcim.tables import SiteTable from netbox.views import generic from utilities.utils import count_related from virtualization.filtersets import VMInterfaceFilterSet -from virtualization.models import VMInterface +from virtualization.models import VMInterface, VirtualMachine from . import filtersets, forms, tables from .constants import * from .models import * @@ -676,7 +676,19 @@ class IPAddressView(generic.ObjectView): related_ips_table = tables.IPAddressTable(related_ips, orderable=False) related_ips_table.configure(request) - services = Service.objects.restrict(request.user, 'view').filter(ipaddresses=instance) + # Find services belonging to the IP + service_filter = Q(ipaddresses=instance) + + # Find services listening on all IPs on the assigned device/vm + if instance.assigned_object and instance.assigned_object.parent_object: + parent_object = instance.assigned_object.parent_object + + if isinstance(parent_object, VirtualMachine): + service_filter |= (Q(virtual_machine=parent_object) & Q(ipaddresses=None)) + elif isinstance(parent_object, Device): + service_filter |= (Q(device=parent_object) & Q(ipaddresses=None)) + + services = Service.objects.restrict(request.user, 'view').filter(service_filter) return { 'parent_prefixes_table': parent_prefixes_table, diff --git a/netbox/netbox/api/pagination.py b/netbox/netbox/api/pagination.py index d89e32124..5ecade264 100644 --- a/netbox/netbox/api/pagination.py +++ b/netbox/netbox/api/pagination.py @@ -16,7 +16,7 @@ class OptionalLimitOffsetPagination(LimitOffsetPagination): def paginate_queryset(self, queryset, request, view=None): if isinstance(queryset, QuerySet): - self.count = queryset.count() + self.count = self.get_queryset_count(queryset) else: # We're dealing with an iterable, not a QuerySet self.count = len(queryset) @@ -52,6 +52,9 @@ class OptionalLimitOffsetPagination(LimitOffsetPagination): return self.default_limit + def get_queryset_count(self, queryset): + return queryset.count() + def get_next_link(self): # Pagination has been disabled @@ -67,3 +70,16 @@ class OptionalLimitOffsetPagination(LimitOffsetPagination): return None return super().get_previous_link() + + +class StripCountAnnotationsPaginator(OptionalLimitOffsetPagination): + """ + Strips the annotations on the queryset before getting the count + to optimize pagination of complex queries. + """ + def get_queryset_count(self, queryset): + # Clone the queryset to avoid messing up the actual query + cloned_queryset = queryset.all() + cloned_queryset.query.annotations.clear() + + return cloned_queryset.count() diff --git a/netbox/netbox/configuration_testing.py b/netbox/netbox/configuration_testing.py index 59529b80c..621671f04 100644 --- a/netbox/netbox/configuration_testing.py +++ b/netbox/netbox/configuration_testing.py @@ -36,3 +36,8 @@ REDIS = { } SECRET_KEY = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789' + +LOGGING = { + 'version': 1, + 'disable_existing_loggers': True +} diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index bd351a0a1..f86f3a8f2 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -29,7 +29,7 @@ django.utils.encoding.force_text = force_str # Environment setup # -VERSION = '3.2.4' +VERSION = '3.2.5' # Hostname HOSTNAME = platform.node() @@ -96,6 +96,7 @@ EXEMPT_VIEW_PERMISSIONS = getattr(configuration, 'EXEMPT_VIEW_PERMISSIONS', []) FIELD_CHOICES = getattr(configuration, 'FIELD_CHOICES', {}) HTTP_PROXIES = getattr(configuration, 'HTTP_PROXIES', None) INTERNAL_IPS = getattr(configuration, 'INTERNAL_IPS', ('127.0.0.1', '::1')) +JINJA2_FILTERS = getattr(configuration, 'JINJA2_FILTERS', {}) LOGGING = getattr(configuration, 'LOGGING', {}) LOGIN_PERSISTENCE = getattr(configuration, 'LOGIN_PERSISTENCE', False) LOGIN_REQUIRED = getattr(configuration, 'LOGIN_REQUIRED', False) diff --git a/netbox/netbox/tables/columns.py b/netbox/netbox/tables/columns.py index e82e8a1ea..7da241566 100644 --- a/netbox/netbox/tables/columns.py +++ b/netbox/netbox/tables/columns.py @@ -166,6 +166,7 @@ class ActionsItem: title: str icon: str permission: Optional[str] = None + css_class: Optional[str] = 'secondary' class ActionsColumn(tables.Column): @@ -175,19 +176,22 @@ class ActionsColumn(tables.Column): :param actions: The ordered list of dropdown menu items to include :param extra_buttons: A Django template string which renders additional buttons preceding the actions dropdown + :param split_actions: When True, converts the actions dropdown menu into a split button with first action as the + direct button link and icon (default: True) """ attrs = {'td': {'class': 'text-end text-nowrap noprint'}} empty_values = () actions = { - 'edit': ActionsItem('Edit', 'pencil', 'change'), - 'delete': ActionsItem('Delete', 'trash-can-outline', 'delete'), + 'edit': ActionsItem('Edit', 'pencil', 'change', 'warning'), + 'delete': ActionsItem('Delete', 'trash-can-outline', 'delete', 'danger'), 'changelog': ActionsItem('Changelog', 'history'), } - def __init__(self, *args, actions=('edit', 'delete', 'changelog'), extra_buttons='', **kwargs): + def __init__(self, *args, actions=('edit', 'delete', 'changelog'), extra_buttons='', split_actions=True, **kwargs): super().__init__(*args, **kwargs) self.extra_buttons = extra_buttons + self.split_actions = split_actions # Determine which actions to enable self.actions = { @@ -208,22 +212,49 @@ class ActionsColumn(tables.Column): html = '' # Compile actions menu - links = [] + button = None + dropdown_class = 'secondary' + dropdown_links = [] user = getattr(request, 'user', AnonymousUser()) - for action, attrs in self.actions.items(): + for idx, (action, attrs) in enumerate(self.actions.items()): permission = f'{model._meta.app_label}.{attrs.permission}_{model._meta.model_name}' if attrs.permission is None or user.has_perm(permission): url = reverse(get_viewname(model, action), kwargs={'pk': record.pk}) - links.append( - f'
  • ' - f' {attrs.title}
  • ' - ) - if links: + + # Render a separate button if a) only one action exists, or b) if split_actions is True + if len(self.actions) == 1 or (self.split_actions and idx == 0): + dropdown_class = attrs.css_class + button = ( + f'' + f'' + ) + + # Add dropdown menu items + else: + dropdown_links.append( + f'
  • ' + f' {attrs.title}
  • ' + ) + + # Create the actions dropdown menu + if button and dropdown_links: html += ( - f'' - f'' - f'' - f'' + f'' + f' {button}' + f' ' + f' Toggle Dropdown' + f' ' + f'' + ) + elif button: + html += button + elif dropdown_links: + html += ( + f'' + f' ' + f' Toggle Dropdown' + f' ' + f'' ) # Render any extra buttons from template code diff --git a/netbox/project-static/dist/netbox.js b/netbox/project-static/dist/netbox.js index acd1abbf2..bc0cabef0 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 ebf3e0a39..26bb1c514 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/src/buttons/index.ts b/netbox/project-static/src/buttons/index.ts index 6a9001cd1..e677ff599 100644 --- a/netbox/project-static/src/buttons/index.ts +++ b/netbox/project-static/src/buttons/index.ts @@ -3,6 +3,7 @@ import { initDepthToggle } from './depthToggle'; import { initMoveButtons } from './moveOptions'; import { initReslug } from './reslug'; import { initSelectAll } from './selectAll'; +import { initSelectMultiple } from './selectMultiple'; export function initButtons(): void { for (const func of [ @@ -10,6 +11,7 @@ export function initButtons(): void { initConnectionToggle, initReslug, initSelectAll, + initSelectMultiple, initMoveButtons, ]) { func(); diff --git a/netbox/project-static/src/buttons/selectMultiple.ts b/netbox/project-static/src/buttons/selectMultiple.ts new file mode 100644 index 000000000..d8bad3105 --- /dev/null +++ b/netbox/project-static/src/buttons/selectMultiple.ts @@ -0,0 +1,105 @@ +import { getElements } from '../util'; +import { StateManager } from 'src/state'; +import { previousPkCheckState } from '../stores'; + +type PreviousPkCheckState = { element: Nullable }; + +/** + * If there is a text selection, removes it. + */ +function removeTextSelection(): void { + window.getSelection()?.removeAllRanges(); +} + +/** + * Sets the state object passed in to the eventTargetElement object passed in. + * + * @param eventTargetElement HTML Input Element, retrieved from getting the target of the + * event passed in from handlePkCheck() + * @param state PreviousPkCheckState object. + */ +function updatePreviousPkCheckState( + eventTargetElement: HTMLInputElement, + state: StateManager, +): void { + state.set('element', eventTargetElement); +} + +/** + * For all checkboxes between eventTargetElement and previousStateElement in elementList, toggle + * "checked" value to eventTargetElement.checked + * + * @param eventTargetElement HTML Input Element, retrieved from getting the target of the + * event passed in from handlePkCheck() + * @param state PreviousPkCheckState object. + */ +function toggleCheckboxRange( + eventTargetElement: HTMLInputElement, + previousStateElement: HTMLInputElement, + elementList: Generator, +): void { + let changePkCheckboxState = false; + for (const element of elementList) { + const typedElement = element as HTMLInputElement; + //Change loop's current checkbox state to eventTargetElement checkbox state + if (changePkCheckboxState === true) { + typedElement.checked = eventTargetElement.checked; + } + //The previously clicked checkbox was above the shift clicked checkbox + if (element === previousStateElement) { + if (changePkCheckboxState === true) { + changePkCheckboxState = false; + return; + } + changePkCheckboxState = true; + typedElement.checked = eventTargetElement.checked; + } + //The previously clicked checkbox was below the shift clicked checkbox + if (element === eventTargetElement) { + if (changePkCheckboxState === true) { + changePkCheckboxState = false; + return; + } + changePkCheckboxState = true; + } + } +} + +/** + * IF the shift key is pressed and there is state is not null, toggleCheckboxRange between the + * event target element and the state element. + * + * @param event Mouse event. + * @param state PreviousPkCheckState object. + */ +function handlePkCheck(event: MouseEvent, state: StateManager): void { + const eventTargetElement = event.target as HTMLInputElement; + const previousStateElement = state.get('element'); + updatePreviousPkCheckState(eventTargetElement, state); + //Stop if user is not holding shift key + if (!event.shiftKey) { + return; + } + removeTextSelection(); + //If no previous state, store event target element as previous state and return + if (previousStateElement === null) { + return updatePreviousPkCheckState(eventTargetElement, state); + } + const checkboxList = getElements('input[type="checkbox"][name="pk"]'); + toggleCheckboxRange(eventTargetElement, previousStateElement, checkboxList); +} + +/** + * Initialize table select all elements. + */ +export function initSelectMultiple(): void { + const checkboxElements = getElements('input[type="checkbox"][name="pk"]'); + for (const element of checkboxElements) { + element.addEventListener('click', event => { + removeTextSelection(); + //Stop propogation to avoid event firing multiple times + event.stopPropagation(); + handlePkCheck(event, previousPkCheckState); + }); + } +} diff --git a/netbox/project-static/src/select/api/apiSelect.ts b/netbox/project-static/src/select/api/apiSelect.ts index 88b35a0e9..f5b605d58 100644 --- a/netbox/project-static/src/select/api/apiSelect.ts +++ b/netbox/project-static/src/select/api/apiSelect.ts @@ -205,6 +205,11 @@ export class APISelect { onChange: () => this.handleSlimChange(), }); + // Don't close on select if multiple select + if (this.base.multiple) { + this.slim.config.closeOnSelect = false; + } + // Initialize API query properties. this.getStaticParams(); this.getDynamicParams(); diff --git a/netbox/project-static/src/stores/index.ts b/netbox/project-static/src/stores/index.ts index 42d4aa0b5..d4644e619 100644 --- a/netbox/project-static/src/stores/index.ts +++ b/netbox/project-static/src/stores/index.ts @@ -1,2 +1,3 @@ export * from './objectDepth'; export * from './rackImages'; +export * from './previousPkCheck'; diff --git a/netbox/project-static/src/stores/previousPkCheck.ts b/netbox/project-static/src/stores/previousPkCheck.ts new file mode 100644 index 000000000..19b244ec7 --- /dev/null +++ b/netbox/project-static/src/stores/previousPkCheck.ts @@ -0,0 +1,6 @@ +import { createState } from '../state'; + +export const previousPkCheckState = createState<{ element: Nullable }>( + { element: null }, + { persist: false }, +); diff --git a/netbox/templates/circuits/circuit_terminations_swap.html b/netbox/templates/circuits/circuit_terminations_swap.html index 27eebb3d8..b2b30d635 100644 --- a/netbox/templates/circuits/circuit_terminations_swap.html +++ b/netbox/templates/circuits/circuit_terminations_swap.html @@ -10,7 +10,7 @@ {% if termination_a %} {{ termination_a.site }} {% if termination_a.interface %}- {{ termination_a.interface.device }} {{ termination_a.interface }}{% endif %} {% else %} - None + {{ ''|placeholder }} {% endif %}
  • @@ -18,7 +18,7 @@ {% if termination_z %} {{ termination_z.site }} {% if termination_z.interface %}- {{ termination_z.interface.device }} {{ termination_z.interface }}{% endif %} {% else %} - None + {{ ''|placeholder }} {% endif %}
  • diff --git a/netbox/templates/circuits/inc/circuit_termination.html b/netbox/templates/circuits/inc/circuit_termination.html index fdb01e803..b673cd4a3 100644 --- a/netbox/templates/circuits/inc/circuit_termination.html +++ b/netbox/templates/circuits/inc/circuit_termination.html @@ -94,7 +94,7 @@ {% elif termination.port_speed %} {{ termination.port_speed|humanize_speed }} {% else %} - + {{ ''|placeholder }} {% endif %} diff --git a/netbox/templates/circuits/provider.html b/netbox/templates/circuits/provider.html index 1bf63f2d5..60bf8cfbc 100644 --- a/netbox/templates/circuits/provider.html +++ b/netbox/templates/circuits/provider.html @@ -50,7 +50,7 @@ {% if object.portal_url %} {{ object.portal_url }} {% else %} - + {{ ''|placeholder }} {% endif %} diff --git a/netbox/templates/dcim/cable.html b/netbox/templates/dcim/cable.html index f1cf986e6..cd171cbb3 100644 --- a/netbox/templates/dcim/cable.html +++ b/netbox/templates/dcim/cable.html @@ -40,7 +40,7 @@ {% if object.color %}   {% else %} - + {{ ''|placeholder }} {% endif %} @@ -50,7 +50,7 @@ {% if object.length %} {{ object.length|floatformat }} {{ object.get_length_unit_display }} {% else %} - + {{ ''|placeholder }} {% endif %} diff --git a/netbox/templates/dcim/device.html b/netbox/templates/dcim/device.html index d075a801d..d3d6f03dc 100644 --- a/netbox/templates/dcim/device.html +++ b/netbox/templates/dcim/device.html @@ -23,7 +23,7 @@ {% endfor %} {{ object.site.region|linkify }} {% else %} - None + {{ ''|placeholder }} {% endif %} @@ -40,7 +40,7 @@ {% endfor %} {{ object.location|linkify }} {% else %} - None + {{ ''|placeholder }} {% endif %} @@ -50,7 +50,7 @@ {% if object.rack %} {{ object.rack }} {% else %} - None + {{ ''|placeholder }} {% endif %} @@ -69,7 +69,7 @@ {% elif object.rack and object.device_type.u_height %} Not racked {% else %} - + {{ ''|placeholder }} {% endif %} @@ -180,7 +180,7 @@ (NAT: {{ object.primary_ip4.nat_outside.address.ip|linkify }}) {% endif %} {% else %} - + {{ ''|placeholder }} {% endif %} @@ -195,7 +195,7 @@ (NAT: {{ object.primary_ip6.nat_outside.address.ip|linkify }}) {% endif %} {% else %} - + {{ ''|placeholder }} {% endif %} diff --git a/netbox/templates/dcim/devicerole.html b/netbox/templates/dcim/devicerole.html index 288101c08..610c53071 100644 --- a/netbox/templates/dcim/devicerole.html +++ b/netbox/templates/dcim/devicerole.html @@ -54,7 +54,7 @@ {% if object.vm_role %} {{ virtualmachine_count }} {% else %} - — + {{ ''|placeholder }} {% endif %} diff --git a/netbox/templates/dcim/devicetype.html b/netbox/templates/dcim/devicetype.html index e717a48aa..bb3ec9d2e 100644 --- a/netbox/templates/dcim/devicetype.html +++ b/netbox/templates/dcim/devicetype.html @@ -55,7 +55,7 @@ {{ object.front_image.name }} {% else %} - + {{ ''|placeholder }} {% endif %} @@ -67,7 +67,7 @@ {{ object.rear_image.name }} {% else %} - + {{ ''|placeholder }} {% endif %} diff --git a/netbox/templates/dcim/interface.html b/netbox/templates/dcim/interface.html index 358922730..c4cb8b72f 100644 --- a/netbox/templates/dcim/interface.html +++ b/netbox/templates/dcim/interface.html @@ -321,7 +321,7 @@ {% if object.rf_channel_frequency %} {{ object.rf_channel_frequency|simplify_decimal }} MHz {% else %} - + {{ ''|placeholder }} {% endif %} {% if peer %} @@ -329,7 +329,7 @@ {% if peer.rf_channel_frequency %} {{ peer.rf_channel_frequency|simplify_decimal }} MHz {% else %} - + {{ ''|placeholder }} {% endif %} {% endif %} @@ -340,7 +340,7 @@ {% if object.rf_channel_width %} {{ object.rf_channel_width|simplify_decimal }} MHz {% else %} - + {{ ''|placeholder }} {% endif %} {% if peer %} @@ -348,7 +348,7 @@ {% if peer.rf_channel_width %} {{ peer.rf_channel_width|simplify_decimal }} MHz {% else %} - + {{ ''|placeholder }} {% endif %} {% endif %} diff --git a/netbox/templates/dcim/module.html b/netbox/templates/dcim/module.html index 130cd046f..f2dac38f2 100644 --- a/netbox/templates/dcim/module.html +++ b/netbox/templates/dcim/module.html @@ -18,25 +18,25 @@ diff --git a/netbox/templates/dcim/powerfeed.html b/netbox/templates/dcim/powerfeed.html index 777af5563..ed1f9a1cd 100644 --- a/netbox/templates/dcim/powerfeed.html +++ b/netbox/templates/dcim/powerfeed.html @@ -44,7 +44,7 @@ {% if object.connected_endpoint %} {{ object.connected_endpoint.device|linkify }} ({{ object.connected_endpoint }}) {% else %} - None + {{ ''|placeholder }} {% endif %} diff --git a/netbox/templates/dcim/poweroutlet.html b/netbox/templates/dcim/poweroutlet.html index 6408bc759..c312bee03 100644 --- a/netbox/templates/dcim/poweroutlet.html +++ b/netbox/templates/dcim/poweroutlet.html @@ -44,7 +44,7 @@ Power Port - {{ object.power_port }} + {{ object.power_port|linkify|placeholder }} Feed Leg diff --git a/netbox/templates/dcim/rack.html b/netbox/templates/dcim/rack.html index 6574e9b74..42f6a8e99 100644 --- a/netbox/templates/dcim/rack.html +++ b/netbox/templates/dcim/rack.html @@ -53,7 +53,7 @@ {% endfor %} {{ object.location|linkify }} {% else %} - None + {{ ''|placeholder }} {% endif %} @@ -115,7 +115,7 @@ {% if object.type %} {{ object.get_type_display }} {% else %} - None + {{ ''|placeholder }} {% endif %} @@ -133,7 +133,7 @@ {% if object.outer_width %} {{ object.outer_width }} {{ object.get_outer_unit_display }} {% else %} - + {{ ''|placeholder }} {% endif %} @@ -143,7 +143,7 @@ {% if object.outer_depth %} {{ object.outer_depth }} {{ object.get_outer_unit_display }} {% else %} - + {{ ''|placeholder }} {% endif %} diff --git a/netbox/templates/dcim/site.html b/netbox/templates/dcim/site.html index c15cab468..ab04ea018 100644 --- a/netbox/templates/dcim/site.html +++ b/netbox/templates/dcim/site.html @@ -34,7 +34,7 @@ {% endfor %} {{ object.region|linkify }} {% else %} - None + {{ ''|placeholder }} {% endif %} @@ -47,7 +47,7 @@ {% endfor %} {{ object.group|linkify }} {% else %} - None + {{ ''|placeholder }} {% endif %} @@ -79,7 +79,7 @@ {{ object.time_zone }} (UTC {{ object.time_zone|tzoffset }})
    Site time: {% timezone object.time_zone %}{% annotated_now %}{% endtimezone %} {% else %} - + {{ ''|placeholder }} {% endif %} @@ -94,7 +94,7 @@ {{ object.physical_address|linebreaksbr }} {% else %} - + {{ ''|placeholder }} {% endif %} @@ -113,7 +113,7 @@ {{ object.latitude }}, {{ object.longitude }} {% else %} - + {{ ''|placeholder }} {% endif %} diff --git a/netbox/templates/dcim/virtualchassis_edit.html b/netbox/templates/dcim/virtualchassis_edit.html index 327f20531..275391c61 100644 --- a/netbox/templates/dcim/virtualchassis_edit.html +++ b/netbox/templates/dcim/virtualchassis_edit.html @@ -57,7 +57,7 @@ {% if device.rack %} {{ device.rack }} / {{ device.position }} {% else %} - + {{ ''|placeholder }} {% endif %} {{ device.serial|placeholder }} diff --git a/netbox/templates/extras/customfield.html b/netbox/templates/extras/customfield.html index e8c3df460..a5618c6db 100644 --- a/netbox/templates/extras/customfield.html +++ b/netbox/templates/extras/customfield.html @@ -57,7 +57,7 @@ {% if object.choices %} {{ object.choices|join:", " }} {% else %} - + {{ ''|placeholder }} {% endif %} @@ -105,7 +105,7 @@ {% if object.validation_regex %} {{ object.validation_regex }} {% else %} - — + {{ ''|placeholder }} {% endif %} diff --git a/netbox/templates/extras/htmx/report_result.html b/netbox/templates/extras/htmx/report_result.html index 9b3e9db5f..c20bf5fe2 100644 --- a/netbox/templates/extras/htmx/report_result.html +++ b/netbox/templates/extras/htmx/report_result.html @@ -57,7 +57,7 @@ {% elif obj %} {{ obj }} {% else %} - + {{ ''|placeholder }} {% endif %} {{ message|markdown }} diff --git a/netbox/templates/generic/bulk_import.html b/netbox/templates/generic/bulk_import.html index 43e078826..1a85c3a21 100644 --- a/netbox/templates/generic/bulk_import.html +++ b/netbox/templates/generic/bulk_import.html @@ -76,14 +76,14 @@ Context: {% if field.required %} {% checkmark True true="Required" %} {% else %} - + {{ ''|placeholder }} {% endif %} {% if field.to_field_name %} {{ field.to_field_name }} {% else %} - + {{ ''|placeholder }} {% endif %} diff --git a/netbox/templates/inc/panels/custom_fields.html b/netbox/templates/inc/panels/custom_fields.html index 32e586d3a..6d23c81aa 100644 --- a/netbox/templates/inc/panels/custom_fields.html +++ b/netbox/templates/inc/panels/custom_fields.html @@ -37,7 +37,7 @@ {% elif field.required %} Not defined {% else %} - + {{ ''|placeholder }} {% endif %} diff --git a/netbox/templates/ipam/ipaddress.html b/netbox/templates/ipam/ipaddress.html index ab47c11af..d1d49e7cc 100644 --- a/netbox/templates/ipam/ipaddress.html +++ b/netbox/templates/ipam/ipaddress.html @@ -52,7 +52,7 @@ {% if object.role %} {{ object.get_role_display }} {% else %} - None + {{ ''|placeholder }} {% endif %} @@ -73,7 +73,7 @@ {% endif %} {{ object.assigned_object|linkify }} {% else %} - + {{ ''|placeholder }} {% endif %} @@ -86,7 +86,7 @@ ({{ object.nat_inside.assigned_object.parent_object|linkify }}) {% endif %} {% else %} - None + {{ ''|placeholder }} {% endif %} diff --git a/netbox/templates/ipam/prefix.html b/netbox/templates/ipam/prefix.html index e2ba76a82..a47566ff7 100644 --- a/netbox/templates/ipam/prefix.html +++ b/netbox/templates/ipam/prefix.html @@ -39,7 +39,7 @@ {% if aggregate %} {{ aggregate.prefix }} ({{ aggregate.rir }}) {% else %} - None + {{ ''|placeholder }} {% endif %} @@ -52,7 +52,7 @@ {% endif %} {{ object.site|linkify }} {% else %} - None + {{ ''|placeholder }} {% endif %} @@ -65,7 +65,7 @@ {% endif %} {{ object.vlan|linkify }} {% else %} - None + {{ ''|placeholder }} {% endif %} @@ -138,7 +138,7 @@ {{ first_available_ip }} {% endif %} {% else %} - None + {{ ''|placeholder }} {% endif %} {% endwith %} diff --git a/netbox/templates/ipam/role.html b/netbox/templates/ipam/role.html index 49570099d..a6ef2c6d4 100644 --- a/netbox/templates/ipam/role.html +++ b/netbox/templates/ipam/role.html @@ -45,7 +45,7 @@ {% if ipranges_count %} {{ ipranges_count }} {% else %} - — + {{ ''|placeholder }} {% endif %} {% endwith %} @@ -57,7 +57,7 @@ {% if vlans_count %} {{ vlans_count }} {% else %} - — + {{ ''|placeholder }} {% endif %} {% endwith %} diff --git a/netbox/templates/ipam/service.html b/netbox/templates/ipam/service.html index 71ea20fa5..47ae70dc9 100644 --- a/netbox/templates/ipam/service.html +++ b/netbox/templates/ipam/service.html @@ -44,7 +44,7 @@ {% for ipaddress in object.ipaddresses.all %} {{ ipaddress|linkify }}
    {% empty %} - None + {{ ''|placeholder }} {% endfor %} diff --git a/netbox/templates/ipam/vlan.html b/netbox/templates/ipam/vlan.html index f74149ad6..fd0ba36a3 100644 --- a/netbox/templates/ipam/vlan.html +++ b/netbox/templates/ipam/vlan.html @@ -21,7 +21,7 @@ {% endif %} {{ object.site|linkify }} {% else %} - None + {{ ''|placeholder }} {% endif %} @@ -56,7 +56,7 @@ {% if object.role %} {{ object.role }} {% else %} - None + {{ ''|placeholder }} {% endif %} diff --git a/netbox/templates/tenancy/contact.html b/netbox/templates/tenancy/contact.html index f55e87895..8e71628e9 100644 --- a/netbox/templates/tenancy/contact.html +++ b/netbox/templates/tenancy/contact.html @@ -35,7 +35,7 @@ {% if object.phone %} {{ object.phone }} {% else %} - None + {{ ''|placeholder }} {% endif %} @@ -45,7 +45,7 @@ {% if object.email %} {{ object.email }} {% else %} - None + {{ ''|placeholder }} {% endif %} diff --git a/netbox/templates/tenancy/tenant.html b/netbox/templates/tenancy/tenant.html index e4c1db006..52c13e1aa 100644 --- a/netbox/templates/tenancy/tenant.html +++ b/netbox/templates/tenancy/tenant.html @@ -77,6 +77,10 @@

    {{ stats.prefix_count }}

    Prefixes

    +
    +

    {{ stats.iprange_count }}

    +

    IP Ranges

    +

    {{ stats.ipaddress_count }}

    IP addresses

    diff --git a/netbox/templates/users/profile.html b/netbox/templates/users/profile.html index 112603126..913784c94 100644 --- a/netbox/templates/users/profile.html +++ b/netbox/templates/users/profile.html @@ -21,7 +21,7 @@ {% if request.user.first_name or request.user.last_name %} {{ request.user.first_name }} {{ request.user.last_name }} {% else %} - + {{ ''|placeholder }} {% endif %} diff --git a/netbox/templates/virtualization/virtualmachine.html b/netbox/templates/virtualization/virtualmachine.html index 0dec4968c..61f9aa61a 100644 --- a/netbox/templates/virtualization/virtualmachine.html +++ b/netbox/templates/virtualization/virtualmachine.html @@ -49,7 +49,7 @@ (NAT: {{ object.primary_ip4.nat_outside.address.ip }}) {% endif %} {% else %} - + {{ ''|placeholder }} {% endif %} @@ -64,7 +64,7 @@ (NAT: {{ object.primary_ip6.nat_outside.address.ip }}) {% endif %} {% else %} - + {{ ''|placeholder }} {% endif %} @@ -115,7 +115,7 @@ {% if object.memory %} {{ object.memory|humanize_megabytes }} {% else %} - + {{ ''|placeholder }} {% endif %} @@ -125,7 +125,7 @@ {% if object.disk %} {{ object.disk }} GB {% else %} - + {{ ''|placeholder }} {% endif %} diff --git a/netbox/templates/wireless/inc/wirelesslink_interface.html b/netbox/templates/wireless/inc/wirelesslink_interface.html index db4f84f0a..7732816a7 100644 --- a/netbox/templates/wireless/inc/wirelesslink_interface.html +++ b/netbox/templates/wireless/inc/wirelesslink_interface.html @@ -33,7 +33,7 @@ {% if interface.rf_channel_frequency %} {{ interface.rf_channel_frequency|simplify_decimal }} MHz {% else %} - + {{ ''|placeholder }} {% endif %} @@ -43,7 +43,7 @@ {% if interface.rf_channel_width %} {{ interface.rf_channel_width|simplify_decimal }} MHz {% else %} - + {{ ''|placeholder }} {% endif %} diff --git a/netbox/tenancy/tables/contacts.py b/netbox/tenancy/tables/contacts.py index 17abc5a5b..234dc2ad7 100644 --- a/netbox/tenancy/tables/contacts.py +++ b/netbox/tenancy/tables/contacts.py @@ -18,7 +18,7 @@ class ContactGroupTable(NetBoxTable): ) contact_count = columns.LinkedCountColumn( viewname='tenancy:contact_list', - url_params={'role_id': 'pk'}, + url_params={'group_id': 'pk'}, verbose_name='Contacts' ) tags = columns.TagColumn( diff --git a/netbox/tenancy/views.py b/netbox/tenancy/views.py index 58ad98e8f..f6f95b123 100644 --- a/netbox/tenancy/views.py +++ b/netbox/tenancy/views.py @@ -3,7 +3,7 @@ from django.shortcuts import get_object_or_404 from circuits.models import Circuit from dcim.models import Cable, Device, Location, Rack, RackReservation, Site -from ipam.models import Aggregate, IPAddress, Prefix, VLAN, VRF, ASN +from ipam.models import Aggregate, IPAddress, IPRange, Prefix, VLAN, VRF, ASN from netbox.views import generic from utilities.utils import count_related from virtualization.models import VirtualMachine, Cluster @@ -104,8 +104,9 @@ class TenantView(generic.ObjectView): 'location_count': Location.objects.restrict(request.user, 'view').filter(tenant=instance).count(), 'device_count': Device.objects.restrict(request.user, 'view').filter(tenant=instance).count(), 'vrf_count': VRF.objects.restrict(request.user, 'view').filter(tenant=instance).count(), - 'prefix_count': Prefix.objects.restrict(request.user, 'view').filter(tenant=instance).count(), 'aggregate_count': Aggregate.objects.restrict(request.user, 'view').filter(tenant=instance).count(), + 'prefix_count': Prefix.objects.restrict(request.user, 'view').filter(tenant=instance).count(), + 'iprange_count': IPRange.objects.restrict(request.user, 'view').filter(tenant=instance).count(), 'ipaddress_count': IPAddress.objects.restrict(request.user, 'view').filter(tenant=instance).count(), 'vlan_count': VLAN.objects.restrict(request.user, 'view').filter(tenant=instance).count(), 'circuit_count': Circuit.objects.restrict(request.user, 'view').filter(tenant=instance).count(), diff --git a/netbox/utilities/forms/fields/fields.py b/netbox/utilities/forms/fields/fields.py index 0d09d2ac7..9168189a1 100644 --- a/netbox/utilities/forms/fields/fields.py +++ b/netbox/utilities/forms/fields/fields.py @@ -3,6 +3,7 @@ import json from django import forms from django.db.models import Count from django.forms.fields import JSONField as _JSONField, InvalidJSONInput +from django.templatetags.static import static from netaddr import AddrFormatError, EUI from utilities.forms import widgets @@ -26,10 +27,9 @@ class CommentField(forms.CharField): A textarea with support for Markdown rendering. Exists mostly just to add a standard `help_text`. """ widget = forms.Textarea - # TODO: Port Markdown cheat sheet to internal documentation - help_text = """ + help_text = f""" - + Markdown syntax is supported """ diff --git a/netbox/utilities/templatetags/builtins/filters.py b/netbox/utilities/templatetags/builtins/filters.py index 44ad5ac47..738dc0e00 100644 --- a/netbox/utilities/templatetags/builtins/filters.py +++ b/netbox/utilities/templatetags/builtins/filters.py @@ -11,7 +11,7 @@ from markdown import markdown from netbox.config import get_config from utilities.markdown import StrikethroughExtension -from utilities.utils import foreground_color +from utilities.utils import clean_html, foreground_color register = template.Library() @@ -144,18 +144,6 @@ def render_markdown(value): {{ md_source_text|markdown }} """ - schemes = '|'.join(get_config().ALLOWED_URL_SCHEMES) - - # Strip HTML tags - value = strip_tags(value) - - # Sanitize Markdown links - pattern = fr'\[([^\]]+)\]\(\s*(?!({schemes})).*:(.+)\)' - value = re.sub(pattern, '[\\1](\\3)', value, flags=re.IGNORECASE) - - # Sanitize Markdown reference links - pattern = fr'\[([^\]]+)\]:\s*(?!({schemes}))\w*:(.+)' - value = re.sub(pattern, '[\\1]: \\3', value, flags=re.IGNORECASE) # Render Markdown html = markdown(value, extensions=['def_list', 'fenced_code', 'tables', StrikethroughExtension()]) @@ -164,6 +152,11 @@ def render_markdown(value): if html: html = f'
    {html}
    ' + schemes = get_config().ALLOWED_URL_SCHEMES + + # Sanitize HTML + html = clean_html(html, schemes) + return mark_safe(html) diff --git a/netbox/utilities/utils.py b/netbox/utilities/utils.py index 7b37c0b70..2b939471c 100644 --- a/netbox/utilities/utils.py +++ b/netbox/utilities/utils.py @@ -4,6 +4,7 @@ from collections import OrderedDict from decimal import Decimal from itertools import count, groupby +import bleach from django.core.serializers import serialize from django.db.models import Count, OuterRef, Subquery from django.db.models.functions import Coalesce @@ -14,6 +15,7 @@ from mptt.models import MPTTModel from dcim.choices import CableLengthUnitChoices from extras.plugins import PluginConfig from extras.utils import is_taggable +from netbox.config import get_config from utilities.constants import HTTP_REQUEST_META_SAFE_COPY @@ -257,7 +259,9 @@ def render_jinja2(template_code, context): """ Render a Jinja2 template with the provided context. Return the rendered content. """ - return SandboxedEnvironment().from_string(source=template_code).render(**context) + environment = SandboxedEnvironment() + environment.filters.update(get_config().JINJA2_FILTERS) + return environment.from_string(source=template_code).render(**context) def prepare_cloned_fields(instance): @@ -382,3 +386,33 @@ def copy_safe_request(request): 'path': request.path, 'id': getattr(request, 'id', None), # UUID assigned by middleware }) + + +def clean_html(html, schemes): + """ + Sanitizes HTML based on a whitelist of allowed tags and attributes. + Also takes a list of allowed URI schemes. + """ + + ALLOWED_TAGS = [ + "div", "pre", "code", "blockquote", "del", + "hr", "h1", "h2", "h3", "h4", "h5", "h6", + "ul", "ol", "li", "p", "br", + "strong", "em", "a", "b", "i", "img", + "table", "thead", "tbody", "tr", "th", "td", + "dl", "dt", "dd", + ] + + ALLOWED_ATTRIBUTES = { + "div": ['class'], + "h1": ["id"], "h2": ["id"], "h3": ["id"], "h4": ["id"], "h5": ["id"], "h6": ["id"], + "a": ["href", "title"], + "img": ["src", "title", "alt"], + } + + return bleach.clean( + html, + tags=ALLOWED_TAGS, + attributes=ALLOWED_ATTRIBUTES, + protocols=schemes + ) diff --git a/netbox/virtualization/forms/bulk_edit.py b/netbox/virtualization/forms/bulk_edit.py index d5d33df2a..4894d78cf 100644 --- a/netbox/virtualization/forms/bulk_edit.py +++ b/netbox/virtualization/forms/bulk_edit.py @@ -3,7 +3,7 @@ from django import forms from dcim.choices import InterfaceModeChoices from dcim.constants import INTERFACE_MTU_MAX, INTERFACE_MTU_MIN from dcim.models import DeviceRole, Platform, Region, Site, SiteGroup -from ipam.models import VLAN, VRF +from ipam.models import VLAN, VLANGroup, VRF from netbox.forms import NetBoxModelBulkEditForm from tenancy.models import Tenant from utilities.forms import ( @@ -182,13 +182,26 @@ class VMInterfaceBulkEditForm(NetBoxModelBulkEditForm): required=False, widget=StaticSelect() ) + vlan_group = DynamicModelChoiceField( + queryset=VLANGroup.objects.all(), + required=False, + label='VLAN group' + ) untagged_vlan = DynamicModelChoiceField( queryset=VLAN.objects.all(), - required=False + required=False, + query_params={ + 'group_id': '$vlan_group', + }, + label='Untagged VLAN' ) tagged_vlans = DynamicModelMultipleChoiceField( queryset=VLAN.objects.all(), - required=False + required=False, + query_params={ + 'group_id': '$vlan_group', + }, + label='Tagged VLANs' ) vrf = DynamicModelChoiceField( queryset=VRF.objects.all(), @@ -200,7 +213,7 @@ class VMInterfaceBulkEditForm(NetBoxModelBulkEditForm): fieldsets = ( (None, ('mtu', 'enabled', 'vrf', 'description')), ('Related Interfaces', ('parent', 'bridge')), - ('802.1Q Switching', ('mode', 'untagged_vlan', 'tagged_vlans')), + ('802.1Q Switching', ('mode', 'vlan_group', 'untagged_vlan', 'tagged_vlans')), ) nullable_fields = ( 'parent', 'bridge', 'mtu', 'vrf', 'description', diff --git a/netbox/virtualization/forms/models.py b/netbox/virtualization/forms/models.py index 314b0bddf..d2ebe5345 100644 --- a/netbox/virtualization/forms/models.py +++ b/netbox/virtualization/forms/models.py @@ -307,7 +307,7 @@ class VMInterfaceForm(InterfaceCommonForm, NetBoxModelForm): model = VMInterface fields = [ 'virtual_machine', 'name', 'parent', 'bridge', 'enabled', 'mac_address', 'mtu', 'description', 'mode', - 'untagged_vlan', 'tagged_vlans', 'vrf', 'tags', + 'vlan_group', 'untagged_vlan', 'tagged_vlans', 'vrf', 'tags', ] widgets = { 'virtual_machine': forms.HiddenInput(), diff --git a/requirements.txt b/requirements.txt index 293a33542..1fdace4f0 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,7 +1,8 @@ -Django==4.0.4 -django-cors-headers==3.12.0 -django-debug-toolbar==3.2.4 -django-filter==21.1 +bleach==5.0.0 +Django==4.0.5 +django-cors-headers==3.13.0 +django-debug-toolbar==3.4.0 +django-filter==22.1 django-graphiql-debug-toolbar==0.2.0 django-mptt==0.13.4 django-pglocks==1.0.4 @@ -18,7 +19,7 @@ gunicorn==20.1.0 Jinja2==3.1.2 Markdown==3.3.7 markdown-include==0.6.0 -mkdocs-material==8.2.16 +mkdocs-material==8.3.6 mkdocstrings[python-legacy]==0.19.0 netaddr==0.8.0 Pillow==9.1.1 @@ -26,7 +27,7 @@ psycopg2-binary==2.9.3 PyYAML==6.0 sentry-sdk==1.5.12 social-auth-app-django==5.0.0 -social-auth-core==4.2.0 +social-auth-core==4.3.0 svgwrite==1.4.2 tablib==3.2.1 tzdata==2022.1