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..9bbee6115 100644 --- a/base_requirements.txt +++ b/base_requirements.txt @@ -30,10 +30,14 @@ django-pglocks # https://github.com/korfuri/django-prometheus django-prometheus -# Django chaching backend using Redis +# Django caching backend using Redis # https://github.com/jazzband/django-redis django-redis +# Django extensions for Rich (terminal text rendering) +# https://github.com/adamchainz/django-rich +django-rich + # Django integration for RQ (Reqis queuing) # https://github.com/rq/django-rq django-rq @@ -44,7 +48,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 +130,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 ea5e580b8..059fc8924 100644 --- a/docs/release-notes/version-3.2.md +++ b/docs/release-notes/version-3.2.md @@ -1,6 +1,39 @@ # NetBox v3.2 -## v3.2.5 (FUTURE) +## v3.2.6 (FUTURE) + +--- + +## 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 rack 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 --- diff --git a/docs/release-notes/version-3.3.md b/docs/release-notes/version-3.3.md index 63fd9731f..229509b9c 100644 --- a/docs/release-notes/version-3.3.md +++ b/docs/release-notes/version-3.3.md @@ -4,8 +4,13 @@ ### Breaking Changes +* Device position and rack unit values are now reported as decimals (e.g. `1.0` or `1.5`) to support modeling half-height rack units. * The `nat_outside` relation on the IP address model now returns a list of zero or more related IP addresses, rather than a single instance (or None). +### New Features + +#### Half-Height Rack Units ([#51](https://github.com/netbox-community/netbox/issues/51)) + ### Enhancements * [#1202](https://github.com/netbox-community/netbox/issues/1202) - Support overlapping assignment of NAT IP addresses @@ -19,9 +24,16 @@ ### Other Changes * [#9261](https://github.com/netbox-community/netbox/issues/9261) - `NetBoxTable` no longer automatically clears pre-existing calls to `prefetch_related()` on its queryset +* [#9434](https://github.com/netbox-community/netbox/issues/9434) - Enabled `django-rich` test runner for more user-friendly output ### REST API Changes +* dcim.Device + * The `position` field has been changed from an integer to a decimal +* dcim.DeviceType + * The `u_height` field has been changed from an integer to a decimal +* dcim.Rack + * The `elevation` endpoint now includes half-height rack units, and utilizes decimal values for the ID and name of each unit * extras.CustomField * Added `group_name` and `ui_visibility` fields * ipam.IPAddress 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/circuits/migrations/0037_cabling_cleanup.py b/netbox/circuits/migrations/0037_cabling_cleanup.py index a7f550749..243748e8b 100644 --- a/netbox/circuits/migrations/0037_cabling_cleanup.py +++ b/netbox/circuits/migrations/0037_cabling_cleanup.py @@ -5,7 +5,7 @@ class Migration(migrations.Migration): dependencies = [ ('circuits', '0036_new_cabling_models'), - ('dcim', '0157_populate_cable_ends'), + ('dcim', '0158_populate_cable_ends'), ] operations = [ diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index fe7c0ba2f..104201182 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -1,3 +1,5 @@ +import decimal + from django.contrib.contenttypes.models import ContentType from drf_yasg.utils import swagger_serializer_method from rest_framework import serializers @@ -246,7 +248,11 @@ class RackUnitSerializer(serializers.Serializer): """ A rack unit is an abstraction formed by the set (rack, position, face); it does not exist as a row in the database. """ - id = serializers.IntegerField(read_only=True) + id = serializers.DecimalField( + max_digits=4, + decimal_places=1, + read_only=True + ) name = serializers.CharField(read_only=True) face = ChoiceField(choices=DeviceFaceChoices, read_only=True) device = NestedDeviceSerializer(read_only=True) @@ -328,6 +334,13 @@ class ManufacturerSerializer(NetBoxModelSerializer): class DeviceTypeSerializer(NetBoxModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicetype-detail') manufacturer = NestedManufacturerSerializer() + u_height = serializers.DecimalField( + max_digits=4, + decimal_places=1, + label='Position (U)', + min_value=decimal.Decimal(0.5), + default=1.0 + ) subdevice_role = ChoiceField(choices=SubdeviceRoleChoices, allow_blank=True, required=False) airflow = ChoiceField(choices=DeviceAirflowChoices, allow_blank=True, required=False) device_count = serializers.IntegerField(read_only=True) @@ -634,7 +647,14 @@ class DeviceSerializer(NetBoxModelSerializer): location = NestedLocationSerializer(required=False, allow_null=True, default=None) rack = NestedRackSerializer(required=False, allow_null=True, default=None) face = ChoiceField(choices=DeviceFaceChoices, allow_blank=True, default='') - position = serializers.IntegerField(allow_null=True, label='Position (U)', min_value=1, default=None) + position = serializers.DecimalField( + max_digits=4, + decimal_places=1, + allow_null=True, + label='Position (U)', + min_value=decimal.Decimal(0.5), + default=None + ) status = ChoiceField(choices=DeviceStatusChoices, required=False) airflow = ChoiceField(choices=DeviceAirflowChoices, allow_blank=True, required=False) primary_ip = NestedIPAddressSerializer(read_only=True) diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index ef549caa6..bcfa8128d 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 6bb95a981..00a51937a 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -164,7 +164,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/forms/models.py b/netbox/dcim/forms/models.py index 179893219..fe461b061 100644 --- a/netbox/dcim/forms/models.py +++ b/netbox/dcim/forms/models.py @@ -467,7 +467,7 @@ class DeviceForm(TenancyForm, NetBoxModelForm): 'location_id': '$location', } ) - position = forms.IntegerField( + position = forms.DecimalField( required=False, help_text="The lowest-numbered unit occupied by the device", widget=APISelect( diff --git a/netbox/dcim/migrations/0154_half_height_rack_units.py b/netbox/dcim/migrations/0154_half_height_rack_units.py new file mode 100644 index 000000000..dd21fddcf --- /dev/null +++ b/netbox/dcim/migrations/0154_half_height_rack_units.py @@ -0,0 +1,23 @@ +import django.contrib.postgres.fields +import django.core.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0153_created_datetimefield'), + ] + + operations = [ + migrations.AlterField( + model_name='devicetype', + name='u_height', + field=models.DecimalField(decimal_places=1, default=1.0, max_digits=4), + ), + migrations.AlterField( + model_name='device', + name='position', + field=models.DecimalField(blank=True, decimal_places=1, max_digits=4, null=True, validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(99.5)]), + ), + ] diff --git a/netbox/dcim/migrations/0154_new_cabling_models.py b/netbox/dcim/migrations/0155_new_cabling_models.py similarity index 98% rename from netbox/dcim/migrations/0154_new_cabling_models.py rename to netbox/dcim/migrations/0155_new_cabling_models.py index 6d8bd9ab6..4b4c6bf3c 100644 --- a/netbox/dcim/migrations/0154_new_cabling_models.py +++ b/netbox/dcim/migrations/0155_new_cabling_models.py @@ -6,7 +6,7 @@ class Migration(migrations.Migration): dependencies = [ ('contenttypes', '0002_remove_content_type_name'), - ('dcim', '0153_created_datetimefield'), + ('dcim', '0154_half_height_rack_units'), ] operations = [ diff --git a/netbox/dcim/migrations/0155_populate_cable_terminations.py b/netbox/dcim/migrations/0156_populate_cable_terminations.py similarity index 97% rename from netbox/dcim/migrations/0155_populate_cable_terminations.py rename to netbox/dcim/migrations/0156_populate_cable_terminations.py index 64d0caf6c..94126dc67 100644 --- a/netbox/dcim/migrations/0155_populate_cable_terminations.py +++ b/netbox/dcim/migrations/0156_populate_cable_terminations.py @@ -40,7 +40,7 @@ def populate_cable_terminations(apps, schema_editor): class Migration(migrations.Migration): dependencies = [ - ('dcim', '0154_new_cabling_models'), + ('dcim', '0155_new_cabling_models'), ] operations = [ diff --git a/netbox/dcim/migrations/0156_populate_cable_paths.py b/netbox/dcim/migrations/0157_populate_cable_paths.py similarity index 96% rename from netbox/dcim/migrations/0156_populate_cable_paths.py rename to netbox/dcim/migrations/0157_populate_cable_paths.py index efc0ab514..a75358635 100644 --- a/netbox/dcim/migrations/0156_populate_cable_paths.py +++ b/netbox/dcim/migrations/0157_populate_cable_paths.py @@ -39,7 +39,7 @@ def populate_cable_paths(apps, schema_editor): class Migration(migrations.Migration): dependencies = [ - ('dcim', '0155_populate_cable_terminations'), + ('dcim', '0156_populate_cable_terminations'), ] operations = [ diff --git a/netbox/dcim/migrations/0157_populate_cable_ends.py b/netbox/dcim/migrations/0158_populate_cable_ends.py similarity index 96% rename from netbox/dcim/migrations/0157_populate_cable_ends.py rename to netbox/dcim/migrations/0158_populate_cable_ends.py index 3bff31a1d..a2fab646c 100644 --- a/netbox/dcim/migrations/0157_populate_cable_ends.py +++ b/netbox/dcim/migrations/0158_populate_cable_ends.py @@ -31,7 +31,7 @@ class Migration(migrations.Migration): dependencies = [ ('circuits', '0036_new_cabling_models'), - ('dcim', '0156_populate_cable_paths'), + ('dcim', '0157_populate_cable_paths'), ] operations = [ diff --git a/netbox/dcim/migrations/0158_cabling_cleanup.py b/netbox/dcim/migrations/0159_cabling_cleanup.py similarity index 98% rename from netbox/dcim/migrations/0158_cabling_cleanup.py rename to netbox/dcim/migrations/0159_cabling_cleanup.py index 51c4b6a42..61c9221e0 100644 --- a/netbox/dcim/migrations/0158_cabling_cleanup.py +++ b/netbox/dcim/migrations/0159_cabling_cleanup.py @@ -4,7 +4,7 @@ from django.db import migrations class Migration(migrations.Migration): dependencies = [ - ('dcim', '0157_populate_cable_ends'), + ('dcim', '0158_populate_cable_ends'), ] operations = [ diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py index e88af2d05..43b84974b 100644 --- a/netbox/dcim/models/devices.py +++ b/netbox/dcim/models/devices.py @@ -99,8 +99,10 @@ class DeviceType(NetBoxModel): blank=True, help_text='Discrete part number (optional)' ) - u_height = models.PositiveSmallIntegerField( - default=1, + u_height = models.DecimalField( + max_digits=4, + decimal_places=1, + default=1.0, verbose_name='Height (U)' ) is_full_depth = models.BooleanField( @@ -166,7 +168,7 @@ class DeviceType(NetBoxModel): ('model', self.model), ('slug', self.slug), ('part_number', self.part_number), - ('u_height', self.u_height), + ('u_height', float(self.u_height)), ('is_full_depth', self.is_full_depth), ('subdevice_role', self.subdevice_role), ('airflow', self.airflow), @@ -654,10 +656,12 @@ class Device(NetBoxModel, ConfigContextModel): blank=True, null=True ) - position = models.PositiveSmallIntegerField( + position = models.DecimalField( + max_digits=4, + decimal_places=1, blank=True, null=True, - validators=[MinValueValidator(1)], + validators=[MinValueValidator(1), MaxValueValidator(99.5)], verbose_name='Position (U)', help_text='The lowest-numbered unit occupied by the device' ) diff --git a/netbox/dcim/models/racks.py b/netbox/dcim/models/racks.py index 81d699b11..12cc4dd38 100644 --- a/netbox/dcim/models/racks.py +++ b/netbox/dcim/models/racks.py @@ -1,4 +1,4 @@ -from collections import OrderedDict +import decimal from django.contrib.auth.models import User from django.contrib.contenttypes.fields import GenericRelation @@ -13,11 +13,10 @@ from django.urls import reverse from dcim.choices import * from dcim.constants import * from dcim.svg import RackElevationSVG -from netbox.config import get_config from netbox.models import OrganizationalModel, NetBoxModel from utilities.choices import ColorChoices from utilities.fields import ColorField, NaturalOrderingField -from utilities.utils import array_to_string +from utilities.utils import array_to_string, drange from .device_components import PowerOutlet, PowerPort from .devices import Device from .power import PowerFeed @@ -242,10 +241,13 @@ class Rack(NetBoxModel): @property def units(self): + """ + Return a list of unit numbers, top to bottom. + """ + max_position = self.u_height + decimal.Decimal(0.5) if self.desc_units: - return range(1, self.u_height + 1) - else: - return reversed(range(1, self.u_height + 1)) + drange(0.5, max_position, 0.5) + return drange(max_position, 0.5, -0.5) def get_status_color(self): return RackStatusChoices.colors.get(self.status) @@ -263,12 +265,12 @@ class Rack(NetBoxModel): reference to the device. When False, only the bottom most unit for a device is included and that unit contains a height attribute for the device """ - - elevation = OrderedDict() + elevation = {} for u in self.units: + u_name = f'U{u}'.split('.')[0] if not u % 1 else f'U{u}' elevation[u] = { 'id': u, - 'name': f'U{u}', + 'name': u_name, 'face': face, 'device': None, 'occupied': False @@ -278,7 +280,7 @@ class Rack(NetBoxModel): if self.pk: # Retrieve all devices installed within the rack - queryset = Device.objects.prefetch_related( + devices = Device.objects.prefetch_related( 'device_type', 'device_type__manufacturer', 'device_role' @@ -299,9 +301,9 @@ class Rack(NetBoxModel): if user is not None: permitted_device_ids = self.devices.restrict(user, 'view').values_list('pk', flat=True) - for device in queryset: + for device in devices: if expand_devices: - for u in range(device.position, device.position + device.device_type.u_height): + for u in drange(device.position, device.position + device.device_type.u_height, 0.5): if user is None or device.pk in permitted_device_ids: elevation[u]['device'] = device elevation[u]['occupied'] = True @@ -310,8 +312,6 @@ class Rack(NetBoxModel): elevation[device.position]['device'] = device elevation[device.position]['occupied'] = True elevation[device.position]['height'] = device.device_type.u_height - for u in range(device.position + 1, device.position + device.device_type.u_height): - elevation.pop(u, None) return [u for u in elevation.values()] @@ -331,12 +331,12 @@ class Rack(NetBoxModel): devices = devices.exclude(pk__in=exclude) # Initialize the rack unit skeleton - units = list(range(1, self.u_height + 1)) + units = list(self.units) # Remove units consumed by installed devices for d in devices: if rack_face is None or d.face == rack_face or d.device_type.is_full_depth: - for u in range(d.position, d.position + d.device_type.u_height): + for u in drange(d.position, d.position + d.device_type.u_height, 0.5): try: units.remove(u) except ValueError: @@ -346,7 +346,7 @@ class Rack(NetBoxModel): # Remove units without enough space above them to accommodate a device of the specified height available_units = [] for u in units: - if set(range(u, u + u_height)).issubset(units): + if set(drange(u, u + u_height, 0.5)).issubset(units): available_units.append(u) return list(reversed(available_units)) @@ -356,9 +356,9 @@ class Rack(NetBoxModel): Return a dictionary mapping all reserved units within the rack to their reservation. """ reserved_units = {} - for r in self.reservations.all(): - for u in r.units: - reserved_units[u] = r + for reservation in self.reservations.all(): + for u in reservation.units: + reserved_units[u] = reservation return reserved_units def get_elevation_svg( @@ -384,13 +384,17 @@ class Rack(NetBoxModel): :param include_images: Embed front/rear device images where available :param base_url: Base URL for links and images. If none, URLs will be relative. """ - elevation = RackElevationSVG(self, user=user, include_images=include_images, base_url=base_url) - if unit_width is None or unit_height is None: - config = get_config() - unit_width = unit_width or config.RACK_ELEVATION_DEFAULT_UNIT_WIDTH - unit_height = unit_height or config.RACK_ELEVATION_DEFAULT_UNIT_HEIGHT + elevation = RackElevationSVG( + self, + unit_width=unit_width, + unit_height=unit_height, + legend_width=legend_width, + user=user, + include_images=include_images, + base_url=base_url + ) - return elevation.render(face, unit_width, unit_height, legend_width) + return elevation.render(face) def get_0u_devices(self): return self.devices.filter(position=0) @@ -401,6 +405,7 @@ class Rack(NetBoxModel): as utilized. """ # Determine unoccupied units + total_units = len(list(self.units)) available_units = self.get_available_units() # Remove reserved units @@ -408,8 +413,8 @@ class Rack(NetBoxModel): if u in available_units: available_units.remove(u) - occupied_unit_count = self.u_height - len(available_units) - percentage = float(occupied_unit_count) / self.u_height * 100 + occupied_unit_count = total_units - len(available_units) + percentage = float(occupied_unit_count) / total_units * 100 return percentage diff --git a/netbox/dcim/svg/racks.py b/netbox/dcim/svg/racks.py index 841adb323..4d518adf1 100644 --- a/netbox/dcim/svg/racks.py +++ b/netbox/dcim/svg/racks.py @@ -1,9 +1,16 @@ +import decimal import svgwrite +from svgwrite.container import Hyperlink +from svgwrite.image import Image +from svgwrite.gradients import LinearGradient +from svgwrite.shapes import Rect +from svgwrite.text import Text from django.conf import settings from django.urls import reverse from django.utils.http import urlencode +from netbox.config import get_config from utilities.utils import foreground_color from dcim.choices import DeviceFaceChoices from dcim.constants import RACK_ELEVATION_BORDER_WIDTH @@ -16,11 +23,27 @@ __all__ = ( def get_device_name(device): if device.virtual_chassis: - return f'{device.virtual_chassis.name}:{device.vc_position}' + name = f'{device.virtual_chassis.name}:{device.vc_position}' elif device.name: - return device.name + name = device.name else: - return str(device.device_type) + name = str(device.device_type) + if device.devicebay_count: + name += ' ({}/{})'.format(device.get_children().count(), device.devicebay_count) + + return name + + +def get_device_description(device): + return '{} ({}) — {} {} ({}U) {} {}'.format( + device.name, + device.device_role, + device.device_type.manufacturer.name, + device.device_type.model, + device.device_type.u_height, + device.asset_tag or '', + device.serial or '' + ) class RackElevationSVG: @@ -32,13 +55,17 @@ class RackElevationSVG: :param include_images: If true, the SVG document will embed front/rear device face images, where available :param base_url: Base URL for links within the SVG document. If none, links will be relative. """ - def __init__(self, rack, user=None, include_images=True, base_url=None): + def __init__(self, rack, unit_height=None, unit_width=None, legend_width=None, user=None, include_images=True, + base_url=None): self.rack = rack self.include_images = include_images - if base_url is not None: - self.base_url = base_url.rstrip('/') - else: - self.base_url = '' + self.base_url = base_url.rstrip('/') if base_url is not None else '' + + # Set drawing dimensions + config = get_config() + self.unit_width = unit_width or config.RACK_ELEVATION_DEFAULT_UNIT_WIDTH + self.unit_height = unit_height or config.RACK_ELEVATION_DEFAULT_UNIT_HEIGHT + self.legend_width = legend_width or config.RACK_ELEVATION_LEGEND_WIDTH_DEFAULT # Determine the subset of devices within this rack that are viewable by the user, if any permitted_devices = self.rack.devices @@ -46,21 +73,9 @@ class RackElevationSVG: permitted_devices = permitted_devices.restrict(user, 'view') self.permitted_device_ids = permitted_devices.values_list('pk', flat=True) - @staticmethod - def _get_device_description(device): - return '{} ({}) — {} {} ({}U) {} {}'.format( - device.name, - device.device_role, - device.device_type.manufacturer.name, - device.device_type.model, - device.device_type.u_height, - device.asset_tag or '', - device.serial or '' - ) - @staticmethod def _add_gradient(drawing, id_, color): - gradient = drawing.linearGradient( + gradient = LinearGradient( start=(0, 0), end=(0, 25), spreadMethod='repeat', @@ -72,192 +87,193 @@ class RackElevationSVG: gradient.add_stop_color(offset='50%', color='#f7f7f7') gradient.add_stop_color(offset='50%', color=color) gradient.add_stop_color(offset='100%', color=color) + drawing.defs.add(gradient) - @staticmethod - def _setup_drawing(width, height): + def _setup_drawing(self): + width = self.unit_width + self.legend_width + RACK_ELEVATION_BORDER_WIDTH * 2 + height = self.unit_height * self.rack.u_height + RACK_ELEVATION_BORDER_WIDTH * 2 drawing = svgwrite.Drawing(size=(width, height)) - # add the stylesheet - with open('{}/rack_elevation.css'.format(settings.STATIC_ROOT)) as css_file: + # Add the stylesheet + with open(f'{settings.STATIC_ROOT}/rack_elevation.css') as css_file: drawing.defs.add(drawing.style(css_file.read())) - # add gradients - RackElevationSVG._add_gradient(drawing, 'reserved', '#c7c7ff') + # Add gradients RackElevationSVG._add_gradient(drawing, 'occupied', '#d7d7d7') RackElevationSVG._add_gradient(drawing, 'blocked', '#ffc0c0') return drawing - def _draw_device_front(self, drawing, device, start, end, text): + def _get_device_coords(self, position, height): + """ + Return the X, Y coordinates of the top left corner for a device in the specified rack unit. + """ + x = self.legend_width + RACK_ELEVATION_BORDER_WIDTH + y = RACK_ELEVATION_BORDER_WIDTH + if self.rack.desc_units: + y += int((position - 1) * self.unit_height) + else: + y += int((self.rack.u_height - position + 1) * self.unit_height) - int(height * self.unit_height) + + return x, y + + def _draw_device(self, device, coords, size, color=None, image=None): name = get_device_name(device) - if device.devicebay_count: - name += ' ({}/{})'.format(device.get_children().count(), device.devicebay_count) + description = get_device_description(device) + text_coords = ( + coords[0] + size[0] / 2, + coords[1] + size[1] / 2 + ) + text_color = f'#{foreground_color(color)}' if color else '#000000' + # Create hyperlink element + link = Hyperlink( + href='{}{}'.format( + self.base_url, + reverse('dcim:device', kwargs={'pk': device.pk}) + ), + target='_blank', + ) + link.set_desc(description) + if color: + link.add(Rect(coords, size, style=f'fill: #{color}', class_='slot')) + else: + link.add(Rect(coords, size, class_='slot blocked')) + link.add(Text(name, insert=text_coords, fill=text_color)) + + # Embed device type image if provided + if self.include_images and image: + image = Image( + href='{}{}'.format(self.base_url, image.url), + insert=coords, + size=size, + class_='device-image' + ) + image.fit(scale='slice') + link.add(image) + link.add(Text(name, insert=text_coords, stroke='black', + stroke_width='0.2em', stroke_linejoin='round', class_='device-image-label')) + link.add(Text(name, insert=text_coords, fill='white', class_='device-image-label')) + + self.drawing.add(link) + + def draw_device_front(self, device, coords, size): + """ + Draw the front (mounted) face of a device. + """ color = device.device_role.color - link = drawing.add( - drawing.a( - href='{}{}'.format(self.base_url, reverse('dcim:device', kwargs={'pk': device.pk})), - target='_top', - fill='black' - ) + image = device.device_type.front_image + self._draw_device(device, coords, size, color=color, image=image) + + def draw_device_rear(self, device, coords, size): + """ + Draw the rear (opposite) face of a device. + """ + image = device.device_type.rear_image + self._draw_device(device, coords, size, image=image) + + def draw_border(self): + """ + Draw a border around the collection of rack units. + """ + border_width = RACK_ELEVATION_BORDER_WIDTH + border_offset = RACK_ELEVATION_BORDER_WIDTH / 2 + frame = Rect( + insert=(self.legend_width + border_offset, border_offset), + size=(self.unit_width + border_width, self.rack.u_height * self.unit_height + border_width), + class_='rack' ) - link.set_desc(self._get_device_description(device)) - link.add(drawing.rect(start, end, style='fill: #{}'.format(color), class_='slot')) - hex_color = '#{}'.format(foreground_color(color)) - link.add(drawing.text(str(name), insert=text, fill=hex_color)) + self.drawing.add(frame) - # 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, - insert=start, - size=end, - class_='device-image' + def draw_legend(self): + """ + Draw the rack unit labels along the lefthand side of the elevation. + """ + for ru in range(0, self.rack.u_height): + start_y = ru * self.unit_height + RACK_ELEVATION_BORDER_WIDTH + position_coordinates = (self.legend_width / 2, start_y + self.unit_height / 2 + RACK_ELEVATION_BORDER_WIDTH) + unit = ru + 1 if self.rack.desc_units else self.rack.u_height - ru + self.drawing.add( + Text(str(unit), position_coordinates, class_='unit') ) - image.fit(scale='slice') - link.add(image) - link.add(drawing.text(str(name), insert=text, stroke='black', - stroke_width='0.2em', stroke_linejoin='round', class_='device-image-label')) - link.add(drawing.text(str(name), insert=text, fill='white', class_='device-image-label')) - def _draw_device_rear(self, drawing, device, start, end, text): - link = drawing.add( - drawing.a( - href='{}{}'.format(self.base_url, reverse('dcim:device', kwargs={'pk': device.pk})), - target='_top', - fill='black' - ) - ) - link.set_desc(self._get_device_description(device)) - link.add(drawing.rect(start, end, class_="slot blocked")) - link.add(drawing.text(get_device_name(device), insert=text)) - - # 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, - insert=start, - size=end, - class_='device-image' - ) - image.fit(scale='slice') - link.add(image) - link.add(drawing.text(get_device_name(device), insert=text, stroke='black', - 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_background(self, face): + """ + Draw the rack unit placeholders which form the "background" of the rack elevation. + """ + x_offset = RACK_ELEVATION_BORDER_WIDTH + self.legend_width + url_string = '{}?{}&position={{}}'.format( reverse('dcim:device_add'), urlencode({ - 'site': rack.site.pk, - 'location': rack.location.pk if rack.location else '', - 'rack': rack.pk, - 'face': face_id, - 'position': id_ + 'site': self.rack.site.pk, + 'location': self.rack.location.pk if self.rack.location else '', + 'rack': self.rack.pk, + 'face': face, }) ) - link = drawing.add( - drawing.a(href=link_url, target='_top') - ) - if reservation: - link.set_desc('{} — {} · {}'.format( - reservation.description, reservation.user, reservation.created - )) - link.add(drawing.rect(start, end, class_=class_)) - link.add(drawing.text("add device", insert=text, class_='add-device')) - def merge_elevations(self, face): - elevation = self.rack.get_rack_units(face=face, expand_devices=False) - if face == DeviceFaceChoices.FACE_REAR: - other_face = DeviceFaceChoices.FACE_FRONT - else: - other_face = DeviceFaceChoices.FACE_REAR - other = self.rack.get_rack_units(face=other_face) - - unit_cursor = 0 - for u in elevation: - o = other[unit_cursor] - if not u['device'] and o['device'] and o['device'].device_type.is_full_depth: - u['device'] = o['device'] - u['height'] = 1 - unit_cursor += u.get('height', 1) - - return elevation - - def render(self, face, unit_width, unit_height, legend_width): - """ - Return an SVG document representing a rack elevation. - """ - drawing = self._setup_drawing( - unit_width + legend_width + RACK_ELEVATION_BORDER_WIDTH * 2, - unit_height * self.rack.u_height + RACK_ELEVATION_BORDER_WIDTH * 2 - ) - reserved_units = self.rack.get_reserved_units() - - unit_cursor = 0 for ru in range(0, self.rack.u_height): - start_y = ru * unit_height - position_coordinates = (legend_width / 2, start_y + unit_height / 2 + RACK_ELEVATION_BORDER_WIDTH) - unit = ru + 1 if self.rack.desc_units else self.rack.u_height - ru - drawing.add( - drawing.text(str(unit), position_coordinates, class_="unit") + y_offset = RACK_ELEVATION_BORDER_WIDTH + ru * self.unit_height + text_coords = ( + x_offset + self.unit_width / 2, + y_offset + self.unit_height / 2 ) - for unit in self.merge_elevations(face): + link = Hyperlink(href=url_string.format(ru), target='_blank') + link.add(Rect((x_offset, y_offset), (self.unit_width, self.unit_height), class_='slot')) + link.add(Text('add device', insert=text_coords, class_='add-device')) + + self.drawing.add(link) + + def draw_face(self, face, opposite=False): + """ + Draw any occupied rack units for the specified rack face. + """ + for unit in self.rack.get_rack_units(face=face, expand_devices=False): # Loop through all units in the elevation device = unit['device'] - height = unit.get('height', 1) + height = unit.get('height', decimal.Decimal(1.0)) - # Setup drawing coordinates - x_offset = legend_width + RACK_ELEVATION_BORDER_WIDTH - y_offset = unit_cursor * unit_height + RACK_ELEVATION_BORDER_WIDTH - end_y = unit_height * height - start_cordinates = (x_offset, y_offset) - end_cordinates = (unit_width, end_y) - text_cordinates = (x_offset + (unit_width / 2), y_offset + end_y / 2) + device_coords = self._get_device_coords(unit['id'], height) + device_size = ( + self.unit_width, + int(self.unit_height * height) + ) # Draw the device - if device and device.face == face and device.pk in self.permitted_device_ids: - self._draw_device_front(drawing, device, start_cordinates, end_cordinates, text_cordinates) - elif device and device.device_type.is_full_depth and device.pk in self.permitted_device_ids: - self._draw_device_rear(drawing, device, start_cordinates, end_cordinates, text_cordinates) + if device and device.pk in self.permitted_device_ids: + if device.face == face and not opposite: + self.draw_device_front(device, device_coords, device_size) + else: + self.draw_device_rear(device, device_coords, device_size) + elif device: # Devices which the user does not have permission to view are rendered only as unavailable space - drawing.add(drawing.rect(start_cordinates, end_cordinates, class_='blocked')) - else: - # Draw shallow devices, reservations, or empty units - class_ = 'slot' - reservation = reserved_units.get(unit["id"]) - if device: - class_ += ' occupied' - if reservation: - class_ += ' reserved' - self._draw_empty( - drawing, - self.rack, - start_cordinates, - end_cordinates, - text_cordinates, - unit["id"], - face, - class_, - reservation - ) + self.drawing.add(Rect(device_coords, device_size, class_='blocked')) - unit_cursor += height + def render(self, face): + """ + Return an SVG document representing a rack elevation. + """ - # Wrap the drawing with a border - border_width = RACK_ELEVATION_BORDER_WIDTH - border_offset = RACK_ELEVATION_BORDER_WIDTH / 2 - frame = drawing.rect( - insert=(legend_width + border_offset, border_offset), - size=(unit_width + border_width, self.rack.u_height * unit_height + border_width), - class_='rack' - ) - drawing.add(frame) + # Initialize the drawing + self.drawing = self._setup_drawing() - return drawing + # Draw the empty rack & legend + self.draw_legend() + self.draw_background(face) + + # Draw the opposite rack face first, then the near face + if face == DeviceFaceChoices.FACE_REAR: + opposite_face = DeviceFaceChoices.FACE_FRONT + else: + opposite_face = DeviceFaceChoices.FACE_REAR + # self.draw_face(opposite_face, opposite=True) + self.draw_face(face) + + # Draw the rack border last + self.draw_border() + + return self.drawing diff --git a/netbox/dcim/tables/template_code.py b/netbox/dcim/tables/template_code.py index d26bc23ac..39f40c816 100644 --- a/netbox/dcim/tables/template_code.py +++ b/netbox/dcim/tables/template_code.py @@ -391,7 +391,7 @@ MODULEBAY_BUTTONS = """ {% else %} - + {% endif %} diff --git a/netbox/dcim/tests/test_api.py b/netbox/dcim/tests/test_api.py index f18c7793a..896634d2f 100644 --- a/netbox/dcim/tests/test_api.py +++ b/netbox/dcim/tests/test_api.py @@ -327,15 +327,15 @@ class RackTest(APIViewTestCases.APIViewTestCase): # Retrieve all units response = self.client.get(url, **self.header) - self.assertEqual(response.data['count'], 42) + self.assertEqual(response.data['count'], 84) # Search for specific units response = self.client.get(f'{url}?q=3', **self.header) - self.assertEqual(response.data['count'], 13) + self.assertEqual(response.data['count'], 26) response = self.client.get(f'{url}?q=U3', **self.header) - self.assertEqual(response.data['count'], 11) + self.assertEqual(response.data['count'], 22) response = self.client.get(f'{url}?q=U10', **self.header) - self.assertEqual(response.data['count'], 1) + self.assertEqual(response.data['count'], 2) def test_get_rack_elevation_svg(self): """ diff --git a/netbox/dcim/tests/test_models.py b/netbox/dcim/tests/test_models.py index 84347c4f2..84a5fc1a1 100644 --- a/netbox/dcim/tests/test_models.py +++ b/netbox/dcim/tests/test_models.py @@ -5,6 +5,7 @@ from circuits.models import * from dcim.choices import * from dcim.models import * from tenancy.models import Tenant +from utilities.utils import drange class LocationTestCase(TestCase): @@ -74,148 +75,142 @@ class RackTestCase(TestCase): def setUp(self): - self.site1 = Site.objects.create( - name='TestSite1', - slug='test-site-1' + sites = ( + Site(name='Site 1', slug='site-1'), + Site(name='Site 2', slug='site-2'), ) - self.site2 = Site.objects.create( - name='TestSite2', - slug='test-site-2' + Site.objects.bulk_create(sites) + + locations = ( + Location(name='Location 1', slug='location-1', site=sites[0]), + Location(name='Location 2', slug='location-2', site=sites[1]), ) - self.location1 = Location.objects.create( - name='TestGroup1', - slug='test-group-1', - site=self.site1 - ) - self.location2 = Location.objects.create( - name='TestGroup2', - slug='test-group-2', - site=self.site2 - ) - self.rack = Rack.objects.create( - name='TestRack1', + for location in locations: + location.save() + + Rack.objects.create( + name='Rack 1', facility_id='A101', - site=self.site1, - location=self.location1, + site=sites[0], + location=locations[0], u_height=42 ) - self.manufacturer = Manufacturer.objects.create( - name='Acme', - slug='acme' + + manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1') + device_types = ( + DeviceType(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1', u_height=1), + DeviceType(manufacturer=manufacturer, model='Device Type 2', slug='device-type-2', u_height=0), + DeviceType(manufacturer=manufacturer, model='Device Type 3', slug='device-type-3', u_height=0.5), ) + DeviceType.objects.bulk_create(device_types) - self.device_type = { - 'ff2048': DeviceType.objects.create( - manufacturer=self.manufacturer, - model='FrameForwarder 2048', - slug='ff2048' - ), - 'cc5000': DeviceType.objects.create( - manufacturer=self.manufacturer, - model='CurrentCatapult 5000', - slug='cc5000', - u_height=0 - ), - } - self.role = { - 'Server': DeviceRole.objects.create( - name='Server', - slug='server', - ), - 'Switch': DeviceRole.objects.create( - name='Switch', - slug='switch', - ), - 'Console Server': DeviceRole.objects.create( - name='Console Server', - slug='console-server', - ), - 'PDU': DeviceRole.objects.create( - name='PDU', - slug='pdu', - ), - - } + DeviceRole.objects.create(name='Device Role 1', slug='device-role-1') def test_rack_device_outside_height(self): - - rack1 = Rack( - name='TestRack2', - facility_id='A102', - site=self.site1, - u_height=42 - ) - rack1.save() + site = Site.objects.first() + rack = Rack.objects.first() device1 = Device( - name='TestSwitch1', - device_type=DeviceType.objects.get(manufacturer__slug='acme', slug='ff2048'), - device_role=DeviceRole.objects.get(slug='switch'), - site=self.site1, - rack=rack1, + name='Device 1', + device_type=DeviceType.objects.first(), + device_role=DeviceRole.objects.first(), + site=site, + rack=rack, position=43, face=DeviceFaceChoices.FACE_FRONT, ) device1.save() with self.assertRaises(ValidationError): - rack1.clean() + rack.clean() def test_location_site(self): + site1 = Site.objects.get(name='Site 1') + location2 = Location.objects.get(name='Location 2') - rack_invalid_location = Rack( - name='TestRack2', - facility_id='A102', - site=self.site1, - u_height=42, - location=self.location2 + rack2 = Rack( + name='Rack 2', + site=site1, + location=location2, + u_height=42 ) - rack_invalid_location.save() + rack2.save() with self.assertRaises(ValidationError): - rack_invalid_location.clean() + rack2.clean() def test_mount_single_device(self): + site = Site.objects.first() + rack = Rack.objects.first() device1 = Device( name='TestSwitch1', - device_type=DeviceType.objects.get(manufacturer__slug='acme', slug='ff2048'), - device_role=DeviceRole.objects.get(slug='switch'), - site=self.site1, - rack=self.rack, - position=10, + device_type=DeviceType.objects.first(), + device_role=DeviceRole.objects.first(), + site=site, + rack=rack, + position=10.0, face=DeviceFaceChoices.FACE_REAR, ) device1.save() # Validate rack height - self.assertEqual(list(self.rack.units), list(reversed(range(1, 43)))) + self.assertEqual(list(rack.units), list(drange(42.5, 0.5, -0.5))) # Validate inventory (front face) - rack1_inventory_front = self.rack.get_rack_units(face=DeviceFaceChoices.FACE_FRONT) - self.assertEqual(rack1_inventory_front[-10]['device'], device1) - del(rack1_inventory_front[-10]) - for u in rack1_inventory_front: + rack1_inventory_front = { + u['id']: u for u in rack.get_rack_units(face=DeviceFaceChoices.FACE_FRONT) + } + self.assertEqual(rack1_inventory_front[10.0]['device'], device1) + self.assertEqual(rack1_inventory_front[10.5]['device'], device1) + del(rack1_inventory_front[10.0]) + del(rack1_inventory_front[10.5]) + for u in rack1_inventory_front.values(): self.assertIsNone(u['device']) # Validate inventory (rear face) - rack1_inventory_rear = self.rack.get_rack_units(face=DeviceFaceChoices.FACE_REAR) - self.assertEqual(rack1_inventory_rear[-10]['device'], device1) - del(rack1_inventory_rear[-10]) - for u in rack1_inventory_rear: + rack1_inventory_rear = { + u['id']: u for u in rack.get_rack_units(face=DeviceFaceChoices.FACE_REAR) + } + self.assertEqual(rack1_inventory_rear[10.0]['device'], device1) + self.assertEqual(rack1_inventory_rear[10.5]['device'], device1) + del(rack1_inventory_rear[10.0]) + del(rack1_inventory_rear[10.5]) + for u in rack1_inventory_rear.values(): self.assertIsNone(u['device']) def test_mount_zero_ru(self): - pdu = Device.objects.create( - name='TestPDU', - device_role=self.role.get('PDU'), - device_type=self.device_type.get('cc5000'), - site=self.site1, - rack=self.rack, - position=None, - face='', - ) - self.assertTrue(pdu) + """ + Check that a 0RU device can be mounted in a rack with no face/position. + """ + site = Site.objects.first() + rack = Rack.objects.first() + + Device( + name='Device 1', + device_role=DeviceRole.objects.first(), + device_type=DeviceType.objects.first(), + site=site, + rack=rack + ).save() + + def test_mount_half_u_devices(self): + """ + Check that two 0.5U devices can be mounted in the same rack unit. + """ + rack = Rack.objects.first() + attrs = { + 'device_type': DeviceType.objects.get(u_height=0.5), + 'device_role': DeviceRole.objects.first(), + 'site': Site.objects.first(), + 'rack': rack, + 'face': DeviceFaceChoices.FACE_FRONT, + } + + Device(name='Device 1', position=1, **attrs).save() + Device(name='Device 2', position=1.5, **attrs).save() + + self.assertEqual(len(rack.get_available_units()), rack.u_height * 2 - 3) def test_change_rack_site(self): """ @@ -224,19 +219,16 @@ class RackTestCase(TestCase): site_a = Site.objects.create(name='Site A', slug='site-a') site_b = Site.objects.create(name='Site B', slug='site-b') - manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1') - device_type = DeviceType.objects.create( - manufacturer=manufacturer, model='Device Type 1', slug='device-type-1' - ) - device_role = DeviceRole.objects.create( - name='Device Role 1', slug='device-role-1', color='ff0000' - ) - # Create Rack1 in Site A rack1 = Rack.objects.create(site=site_a, name='Rack 1') # Create Device1 in Rack1 - device1 = Device.objects.create(site=site_a, rack=rack1, device_type=device_type, device_role=device_role) + device1 = Device.objects.create( + site=site_a, + rack=rack1, + device_type=DeviceType.objects.first(), + device_role=DeviceRole.objects.first() + ) # Move Rack1 to Site B rack1.site = site_b diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index d142eb99f..ca4ea60b8 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 d89d6a711..6682fc920 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 fd3730e2c..446198c61 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -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) @@ -423,6 +424,8 @@ LOGIN_REDIRECT_URL = f'/{BASE_PATH}' DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' +TEST_RUNNER = "django_rich.test.RichRunner" + # Exclude potentially sensitive models from wildcard view exemption. These may still be exempted # by specifying the model individually in the EXEMPT_VIEW_PERMISSIONS configuration parameter. EXEMPT_EXCLUDE_MODELS = ( 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 0614dc49e..b7d3aed56 100644 --- a/netbox/templates/circuits/inc/circuit_termination.html +++ b/netbox/templates/circuits/inc/circuit_termination.html @@ -93,7 +93,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 effb55c02..f557792c1 100644 --- a/netbox/templates/dcim/cable.html +++ b/netbox/templates/dcim/cable.html @@ -38,7 +38,7 @@ {% if object.color %}   {% else %} - + {{ ''|placeholder }} {% endif %} @@ -48,7 +48,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 bec52471a..b69701017 100644 --- a/netbox/templates/dcim/interface.html +++ b/netbox/templates/dcim/interface.html @@ -313,7 +313,7 @@ {% if object.rf_channel_frequency %} {{ object.rf_channel_frequency|simplify_decimal }} MHz {% else %} - + {{ ''|placeholder }} {% endif %} {% if peer %} @@ -321,7 +321,7 @@ {% if peer.rf_channel_frequency %} {{ peer.rf_channel_frequency|simplify_decimal }} MHz {% else %} - + {{ ''|placeholder }} {% endif %} {% endif %} @@ -332,7 +332,7 @@ {% if object.rf_channel_width %} {{ object.rf_channel_width|simplify_decimal }} MHz {% else %} - + {{ ''|placeholder }} {% endif %} {% if peer %} @@ -340,7 +340,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 064e0031f..3972b30f3 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 4fecb05a0..930d60996 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 aca0b5012..ff4e6e08c 100644 --- a/netbox/templates/extras/customfield.html +++ b/netbox/templates/extras/customfield.html @@ -69,7 +69,7 @@ {% if object.choices %} {{ object.choices|join:", " }} {% else %} - + {{ ''|placeholder }} {% endif %} @@ -113,7 +113,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/ipam/ipaddress.html b/netbox/templates/ipam/ipaddress.html index 7981ea0fe..8b628c2f7 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 2831a452a..f62da6fed 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 %} @@ -123,7 +123,7 @@ {% if object.memory %} {{ object.memory|humanize_megabytes }} {% else %} - + {{ ''|placeholder }} {% endif %} @@ -133,7 +133,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/forms/utils.py b/netbox/utilities/forms/utils.py index 9a4b011e0..a6f037e0b 100644 --- a/netbox/utilities/forms/utils.py +++ b/netbox/utilities/forms/utils.py @@ -1,7 +1,6 @@ import re from django import forms -from django.conf import settings from django.forms.models import fields_for_model from utilities.choices import unpack_grouped_choices 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..6a1b560e1 100644 --- a/netbox/utilities/utils.py +++ b/netbox/utilities/utils.py @@ -1,9 +1,11 @@ import datetime +import decimal import json 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 +16,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 @@ -224,6 +227,21 @@ def deepmerge(original, new): return merged +def drange(start, end, step=decimal.Decimal(1)): + """ + Decimal-compatible implementation of Python's range() + """ + start, end, step = decimal.Decimal(start), decimal.Decimal(end), decimal.Decimal(step) + if start < end: + while start < end: + yield start + start += step + else: + while start > end: + yield start + start += step + + def to_meters(length, unit): """ Convert the given length to meters. @@ -257,7 +275,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 +402,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 88dee3978..b2429744b 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 Device, 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 ( @@ -202,13 +202,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(), @@ -220,7 +233,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 cfafd7e39..018b50c99 100644 --- a/netbox/virtualization/forms/models.py +++ b/netbox/virtualization/forms/models.py @@ -323,7 +323,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..d5b86eafa 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,12 +1,14 @@ -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 django-prometheus==2.2.0 django-redis==5.2.0 +django-rich==1.4.0 django-rq==2.5.1 django-tables2==2.4.1 django-taggit==2.1.0 @@ -18,7 +20,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 +28,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