Merge pull request #9569 from netbox-community/develop

Release v3.2.5
This commit is contained in:
Jeremy Stretch 2022-06-20 11:35:48 -04:00 committed by GitHub
commit 7c09259b7d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
68 changed files with 821 additions and 155 deletions

View File

@ -14,7 +14,7 @@ body:
attributes: attributes:
label: NetBox version label: NetBox version
description: What version of NetBox are you currently running? description: What version of NetBox are you currently running?
placeholder: v3.2.4 placeholder: v3.2.5
validations: validations:
required: true required: true
- type: dropdown - type: dropdown

View File

@ -14,7 +14,7 @@ body:
attributes: attributes:
label: NetBox version label: NetBox version
description: What version of NetBox are you currently running? description: What version of NetBox are you currently running?
placeholder: v3.2.4 placeholder: v3.2.5
validations: validations:
required: true required: true
- type: dropdown - type: dropdown

View File

@ -27,7 +27,10 @@ jobs:
This issue has been automatically marked as stale because it has not had 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 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 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-label: 'pending closure'
stale-pr-message: > stale-pr-message: >
This PR has been automatically marked as stale because it has not had This PR has been automatically marked as stale because it has not had

View File

@ -160,9 +160,9 @@ to aid in issue management.
It is natural that some new issues get more attention than others. The stale 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 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 been overlooked. **Do not** comment on a stale issue merely to "bump" it in an
an effort to circumvent the bot: Doing so will not remove the stale label. effort to circumvent the bot: This will result in the immediate closure of the
(Stale labels can be removed only by maintainers.) issue, and you may be barred from participating in future discussions.
## Maintainer Guidance ## Maintainer Guidance

View File

@ -44,7 +44,8 @@ django-tables2
# User-defined tags for objects # User-defined tags for objects
# https://github.com/alex/django-taggit # 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 # A Django field for representing time zones
# https://github.com/mfogel/django-timezone-field/ # https://github.com/mfogel/django-timezone-field/
@ -125,3 +126,7 @@ tablib
# Timezone data (required by django-timezone-field on Python 3.9+) # Timezone data (required by django-timezone-field on Python 3.9+)
# https://github.com/python/tzdata # https://github.com/python/tzdata
tzdata tzdata
# HTML sanitizer
# https://github.com/mozilla/bleach
bleach

View File

@ -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 ## INTERNAL_IPS
Default: `('127.0.0.1', '::1')` Default: `('127.0.0.1', '::1')`

353
docs/reference/markdown.md Normal file
View File

@ -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)
<a name="headers"></a>
## 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
<a name="emphasis"></a>
## 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.~~
<a name="lists"></a>
## 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
<a name="links"></a>
## 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 <http://www.example.com> 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 <http://www.example.com> 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
<a name="images"></a>
## 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"
<a name="code"></a>
## 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 <code>```</code>
````
```
var s = "Code block";
alert(s);
```
````
```
var s = "Code block";
alert(s);
```
<a name="tables"></a>
## 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
<a name="blockquotes"></a>
## 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.
<a name="html"></a>
## Inline HTML
You can also use raw HTML in your Markdown, and it'll mostly work pretty well.
```no-highlight
<dl>
<dt>Definition list</dt>
<dd>Is something people use sometimes.</dd>
<dt>Markdown in HTML</dt>
<dd>Does *not* work **very** well. Use HTML <em>tags</em>.</dd>
</dl>
```
<dl>
<dt>Definition list</dt>
<dd>Is something people use sometimes.</dd>
<dt>Markdown in HTML</dt>
<dd>Does *not* work **very** well. Use HTML <em>tags</em>.</dd>
</dl>
<a name="hr"></a>
## Horizontal Rule
```
Three or more...
---
Hyphens
***
Asterisks
___
Underscores
```
Three or more...
---
Hyphens
***
Asterisks
___
Underscores
<a name="lines"></a>
## 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/)

View File

@ -1,5 +1,38 @@
# NetBox v3.2 # NetBox v3.2
## v3.2.5 (2022-06-20)
### Enhancements
* [#8704](https://github.com/netbox-community/netbox/issues/8704) - Shift-click to select multiple objects in a list
* [#8882](https://github.com/netbox-community/netbox/issues/8882) - Support filtering IP addresses by multiple parent prefixes
* [#8893](https://github.com/netbox-community/netbox/issues/8893) - Include count of IP ranges under tenant view
* [#9417](https://github.com/netbox-community/netbox/issues/9417) - Initialize manufacturer selection when inserting a new module
* [#9501](https://github.com/netbox-community/netbox/issues/9501) - Add support for custom Jinja2 filters
* [#9517](https://github.com/netbox-community/netbox/issues/9517) - Linkify related power port on power outlet view
* [#9525](https://github.com/netbox-community/netbox/issues/9525) - Provide one-click edit link for objects in tables
* [#9533](https://github.com/netbox-community/netbox/issues/9533) - Move Markdown reference to local documentation
* [#9534](https://github.com/netbox-community/netbox/issues/9534) - Add VLAN group selector to interface bulk edit forms
* [#9556](https://github.com/netbox-community/netbox/issues/9556) - Leave dropdown open upon selection for multi-select fields
### Bug Fixes
* [#8944](https://github.com/netbox-community/netbox/issues/8944) - Fix rendering of Markdown links with colons
* [#9108](https://github.com/netbox-community/netbox/issues/9108) - Fix rendering of bracketed Markdown links
* [#9374](https://github.com/netbox-community/netbox/issues/9374) - Improve performance when retrieving devices/VMs with config context data
* [#9466](https://github.com/netbox-community/netbox/issues/9466) - Avoid sending webhooks after script/report failure
* [#9480](https://github.com/netbox-community/netbox/issues/9480) - Fix sorting services & service templates by port numbers
* [#9484](https://github.com/netbox-community/netbox/issues/9484) - Include services listening on "all IPs" under IP address view
* [#9486](https://github.com/netbox-community/netbox/issues/9486) - Fix redirect URL when adding device components from the module view
* [#9495](https://github.com/netbox-community/netbox/issues/9495) - Correct link to contacts in contact groups table column
* [#9503](https://github.com/netbox-community/netbox/issues/9503) - Hyperlinks in ack elevation SVGs must always use absolute URLs
* [#9512](https://github.com/netbox-community/netbox/issues/9512) - Fix duplicate site results when searching by ASN
* [#9524](https://github.com/netbox-community/netbox/issues/9524) - Correct order of VLAN fields under VM interface creation form
* [#9537](https://github.com/netbox-community/netbox/issues/9537) - Ensure consistent use of placeholder tag throughout UI
* [#9549](https://github.com/netbox-community/netbox/issues/9549) - Fix device counts for rack list under rack role view
---
## v3.2.4 (2022-05-31) ## v3.2.4 (2022-05-31)
### Enhancements ### Enhancements
@ -25,7 +58,6 @@
* [#9425](https://github.com/netbox-community/netbox/issues/9425) - Fix bulk import for object and multi-object custom fields * [#9425](https://github.com/netbox-community/netbox/issues/9425) - Fix bulk import for object and multi-object custom fields
* [#9430](https://github.com/netbox-community/netbox/issues/9430) - Fix passing of initial form data for DynamicModelChoiceFields * [#9430](https://github.com/netbox-community/netbox/issues/9430) - Fix passing of initial form data for DynamicModelChoiceFields
--- ---
## v3.2.3 (2022-05-12) ## v3.2.3 (2022-05-12)

View File

@ -136,6 +136,7 @@ nav:
- Overview: 'graphql-api/overview.md' - Overview: 'graphql-api/overview.md'
- Reference: - Reference:
- Conditions: 'reference/conditions.md' - Conditions: 'reference/conditions.md'
- Markdown: 'reference/markdown.md'
- Development: - Development:
- Introduction: 'development/index.md' - Introduction: 'development/index.md'
- Getting Started: 'development/getting-started.md' - Getting Started: 'development/getting-started.md'

View File

@ -19,6 +19,7 @@ from ipam.models import Prefix, VLAN
from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired
from netbox.api.exceptions import ServiceUnavailable from netbox.api.exceptions import ServiceUnavailable
from netbox.api.metadata import ContentTypeMetadata from netbox.api.metadata import ContentTypeMetadata
from netbox.api.pagination import StripCountAnnotationsPaginator
from netbox.api.viewsets import NetBoxModelViewSet from netbox.api.viewsets import NetBoxModelViewSet
from netbox.config import get_config from netbox.config import get_config
from utilities.api import get_serializer_for_model 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', 'virtual_chassis__master', 'primary_ip4__nat_outside', 'primary_ip6__nat_outside', 'tags',
) )
filterset_class = filtersets.DeviceFilterSet filterset_class = filtersets.DeviceFilterSet
pagination_class = StripCountAnnotationsPaginator
def get_serializer_class(self): def get_serializer_class(self):
""" """

View File

@ -163,7 +163,7 @@ class SiteFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilterSe
qs_filter |= Q(asns__asn=int(value.strip())) qs_filter |= Q(asns__asn=int(value.strip()))
except ValueError: except ValueError:
pass pass
return queryset.filter(qs_filter) return queryset.filter(qs_filter).distinct()
class LocationFilterSet(TenancyFilterSet, ContactModelFilterSet, OrganizationalModelFilterSet): class LocationFilterSet(TenancyFilterSet, ContactModelFilterSet, OrganizationalModelFilterSet):

View File

@ -6,7 +6,7 @@ from timezone_field import TimeZoneFormField
from dcim.choices import * from dcim.choices import *
from dcim.constants import * from dcim.constants import *
from dcim.models 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 netbox.forms import NetBoxModelBulkEditForm
from tenancy.models import Tenant from tenancy.models import Tenant
from utilities.forms import ( from utilities.forms import (
@ -1067,13 +1067,32 @@ class InterfaceBulkEditForm(
required=False, required=False,
widget=BulkEditNullBooleanSelect 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( untagged_vlan = DynamicModelChoiceField(
queryset=VLAN.objects.all(), queryset=VLAN.objects.all(),
required=False required=False,
query_params={
'group_id': '$vlan_group',
},
label='Untagged VLAN'
) )
tagged_vlans = DynamicModelMultipleChoiceField( tagged_vlans = DynamicModelMultipleChoiceField(
queryset=VLAN.objects.all(), queryset=VLAN.objects.all(),
required=False required=False,
query_params={
'group_id': '$vlan_group',
},
label='Tagged VLANs'
) )
vrf = DynamicModelChoiceField( vrf = DynamicModelChoiceField(
queryset=VRF.objects.all(), queryset=VRF.objects.all(),
@ -1087,13 +1106,13 @@ class InterfaceBulkEditForm(
('Addressing', ('vrf', 'mac_address', 'wwn')), ('Addressing', ('vrf', 'mac_address', 'wwn')),
('Operation', ('mtu', 'tx_power', 'enabled', 'mgmt_only', 'mark_connected')), ('Operation', ('mtu', 'tx_power', 'enabled', 'mgmt_only', 'mark_connected')),
('Related Interfaces', ('parent', 'bridge', 'lag')), ('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')), ('Wireless', ('rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width')),
) )
nullable_fields = ( nullable_fields = (
'module', 'label', 'parent', 'bridge', 'lag', 'speed', 'duplex', 'mac_address', 'wwn', 'mtu', 'description', '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', 'mode', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'vlan_group', 'untagged_vlan',
'vrf', 'tagged_vlans', 'vrf',
) )
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):

View File

@ -114,7 +114,7 @@ class RackElevationSVG:
# Embed front device type image if one exists # Embed front device type image if one exists
if self.include_images and device.device_type.front_image: if self.include_images and device.device_type.front_image:
image = drawing.image( image = drawing.image(
href=device.device_type.front_image.url, href='{}{}'.format(self.base_url, device.device_type.front_image.url),
insert=start, insert=start,
size=end, size=end,
class_='device-image' class_='device-image'
@ -140,7 +140,7 @@ class RackElevationSVG:
# Embed rear device type image if one exists # Embed rear device type image if one exists
if self.include_images and device.device_type.rear_image: if self.include_images and device.device_type.rear_image:
image = drawing.image( image = drawing.image(
href=device.device_type.rear_image.url, href='{}{}'.format(self.base_url, device.device_type.rear_image.url),
insert=start, insert=start,
size=end, size=end,
class_='device-image' class_='device-image'
@ -151,9 +151,9 @@ class RackElevationSVG:
stroke_width='0.2em', stroke_linejoin='round', class_='device-image-label')) 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')) link.add(drawing.text(get_device_name(device), insert=text, fill='white', class_='device-image-label'))
@staticmethod def _draw_empty(self, drawing, rack, start, end, text, id_, face_id, class_, reservation):
def _draw_empty(drawing, rack, start, end, text, id_, face_id, class_, reservation): link_url = '{}{}?{}'.format(
link_url = '{}?{}'.format( self.base_url,
reverse('dcim:device_add'), reverse('dcim:device_add'),
urlencode({ urlencode({
'site': rack.site.pk, 'site': rack.site.pk,

View File

@ -385,7 +385,7 @@ MODULEBAY_BUTTONS = """
<i class="mdi mdi-server-minus" aria-hidden="true" title="Remove module"></i> <i class="mdi mdi-server-minus" aria-hidden="true" title="Remove module"></i>
</a> </a>
{% else %} {% else %}
<a href="{% url 'dcim:module_add' %}?device={{ record.device.pk }}&module_bay={{ record.pk }}&return_url={% url 'dcim:device_modulebays' pk=object.pk %}" class="btn btn-success btn-sm"> <a href="{% url 'dcim:module_add' %}?device={{ record.device.pk }}&module_bay={{ record.pk }}&manufacturer={{ object.device_type.manufacturer_id }}&return_url={% url 'dcim:device_modulebays' pk=object.pk %}" class="btn btn-success btn-sm">
<i class="mdi mdi-server-plus" aria-hidden="true" title="Install module"></i> <i class="mdi mdi-server-plus" aria-hidden="true" title="Install module"></i>
</a> </a>
{% endif %} {% endif %}

View File

@ -510,8 +510,8 @@ class RackRoleView(generic.ObjectView):
queryset = RackRole.objects.all() queryset = RackRole.objects.all()
def get_extra_context(self, request, instance): def get_extra_context(self, request, instance):
racks = Rack.objects.restrict(request.user, 'view').filter( racks = Rack.objects.restrict(request.user, 'view').filter(role=instance).annotate(
role=instance device_count=count_related(Device, 'rack')
) )
racks_table = tables.RackTable(racks, user=request.user, exclude=( racks_table = tables.RackTable(racks, user=request.user, exclude=(

View File

@ -14,6 +14,7 @@ from extras.choices import JobResultStatusChoices
from extras.context_managers import change_logging from extras.context_managers import change_logging
from extras.models import JobResult from extras.models import JobResult
from extras.scripts import get_script from extras.scripts import get_script
from extras.signals import clear_webhooks
from utilities.exceptions import AbortTransaction from utilities.exceptions import AbortTransaction
from utilities.utils import NetBoxFakeRequest from utilities.utils import NetBoxFakeRequest
@ -49,7 +50,7 @@ class Command(BaseCommand):
except AbortTransaction: except AbortTransaction:
script.log_info("Database changes have been reverted automatically.") script.log_info("Database changes have been reverted automatically.")
clear_webhooks.send(request)
except Exception as e: except Exception as e:
stacktrace = traceback.format_exc() stacktrace = traceback.format_exc()
script.log_failure( script.log_failure(
@ -58,7 +59,7 @@ class Command(BaseCommand):
script.log_info("Database changes have been reverted due to error.") script.log_info("Database changes have been reverted due to error.")
logger.error(f"Exception raised during script execution: {e}") logger.error(f"Exception raised during script execution: {e}")
job_result.set_status(JobResultStatusChoices.STATUS_ERRORED) job_result.set_status(JobResultStatusChoices.STATUS_ERRORED)
clear_webhooks.send(request)
finally: finally:
job_result.data = ScriptOutputSerializer(script).data job_result.data = ScriptOutputSerializer(script).data
job_result.save() job_result.save()

View File

@ -17,6 +17,7 @@ from django.utils.functional import classproperty
from extras.api.serializers import ScriptOutputSerializer from extras.api.serializers import ScriptOutputSerializer
from extras.choices import JobResultStatusChoices, LogLevelChoices from extras.choices import JobResultStatusChoices, LogLevelChoices
from extras.signals import clear_webhooks
from ipam.formfields import IPAddressFormField, IPNetworkFormField from ipam.formfields import IPAddressFormField, IPNetworkFormField
from ipam.validators import MaxPrefixLengthValidator, MinPrefixLengthValidator, prefix_validator from ipam.validators import MaxPrefixLengthValidator, MinPrefixLengthValidator, prefix_validator
from utilities.exceptions import AbortTransaction from utilities.exceptions import AbortTransaction
@ -465,7 +466,7 @@ def run_script(data, request, commit=True, *args, **kwargs):
except AbortTransaction: except AbortTransaction:
script.log_info("Database changes have been reverted automatically.") script.log_info("Database changes have been reverted automatically.")
clear_webhooks.send(request)
except Exception as e: except Exception as e:
stacktrace = traceback.format_exc() stacktrace = traceback.format_exc()
script.log_failure( 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.") script.log_info("Database changes have been reverted due to error.")
logger.error(f"Exception raised during script execution: {e}") logger.error(f"Exception raised during script execution: {e}")
job_result.set_status(JobResultStatusChoices.STATUS_ERRORED) job_result.set_status(JobResultStatusChoices.STATUS_ERRORED)
clear_webhooks.send(request)
finally: finally:
job_result.data = ScriptOutputSerializer(script).data job_result.data = ScriptOutputSerializer(script).data
job_result.save() job_result.save()

View File

@ -464,7 +464,7 @@ class IPAddressFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
field_name='address', field_name='address',
lookup_expr='family' lookup_expr='family'
) )
parent = django_filters.CharFilter( parent = MultiValueCharFilter(
method='search_by_parent', method='search_by_parent',
label='Parent prefix', label='Parent prefix',
) )
@ -571,14 +571,16 @@ class IPAddressFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
return queryset.filter(qs_filter) return queryset.filter(qs_filter)
def search_by_parent(self, queryset, name, value): def search_by_parent(self, queryset, name, value):
value = value.strip()
if not value: if not value:
return queryset return queryset
q = Q()
for prefix in value:
try: try:
query = str(netaddr.IPNetwork(value.strip()).cidr) query = str(netaddr.IPNetwork(prefix.strip()).cidr)
return queryset.filter(address__net_host_contained=query) q |= Q(address__net_host_contained=query)
except (AddrFormatError, ValueError): except (AddrFormatError, ValueError):
return queryset.none() return queryset.none()
return queryset.filter(q)
def filter_address(self, queryset, name, value): def filter_address(self, queryset, name, value):
try: try:

View File

@ -14,7 +14,8 @@ class ServiceTemplateTable(NetBoxTable):
linkify=True linkify=True
) )
ports = tables.Column( ports = tables.Column(
accessor=tables.A('port_list') accessor=tables.A('port_list'),
order_by=tables.A('ports'),
) )
tags = columns.TagColumn( tags = columns.TagColumn(
url_name='ipam:servicetemplate_list' url_name='ipam:servicetemplate_list'
@ -35,7 +36,8 @@ class ServiceTable(NetBoxTable):
order_by=('device', 'virtual_machine') order_by=('device', 'virtual_machine')
) )
ports = tables.Column( ports = tables.Column(
accessor=tables.A('port_list') accessor=tables.A('port_list'),
order_by=tables.A('ports'),
) )
tags = columns.TagColumn( tags = columns.TagColumn(
url_name='ipam:service_list' url_name='ipam:service_list'

View File

@ -823,10 +823,8 @@ class IPAddressTestCase(TestCase, ChangeLoggedFilterSetTests):
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_parent(self): def test_parent(self):
params = {'parent': '10.0.0.0/24'} params = {'parent': ['10.0.0.0/30', '2001:db8::/126']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 8)
params = {'parent': '2001:db8::/64'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)
def test_filter_address(self): def test_filter_address(self):
# Check IPv4 and IPv6, with and without a mask # Check IPv4 and IPv6, with and without a mask

View File

@ -7,12 +7,12 @@ from django.urls import reverse
from circuits.models import Provider, Circuit from circuits.models import Provider, Circuit
from circuits.tables import ProviderTable from circuits.tables import ProviderTable
from dcim.filtersets import InterfaceFilterSet from dcim.filtersets import InterfaceFilterSet
from dcim.models import Interface, Site from dcim.models import Interface, Site, Device
from dcim.tables import SiteTable from dcim.tables import SiteTable
from netbox.views import generic from netbox.views import generic
from utilities.utils import count_related from utilities.utils import count_related
from virtualization.filtersets import VMInterfaceFilterSet from virtualization.filtersets import VMInterfaceFilterSet
from virtualization.models import VMInterface from virtualization.models import VMInterface, VirtualMachine
from . import filtersets, forms, tables from . import filtersets, forms, tables
from .constants import * from .constants import *
from .models import * from .models import *
@ -676,7 +676,19 @@ class IPAddressView(generic.ObjectView):
related_ips_table = tables.IPAddressTable(related_ips, orderable=False) related_ips_table = tables.IPAddressTable(related_ips, orderable=False)
related_ips_table.configure(request) 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 { return {
'parent_prefixes_table': parent_prefixes_table, 'parent_prefixes_table': parent_prefixes_table,

View File

@ -16,7 +16,7 @@ class OptionalLimitOffsetPagination(LimitOffsetPagination):
def paginate_queryset(self, queryset, request, view=None): def paginate_queryset(self, queryset, request, view=None):
if isinstance(queryset, QuerySet): if isinstance(queryset, QuerySet):
self.count = queryset.count() self.count = self.get_queryset_count(queryset)
else: else:
# We're dealing with an iterable, not a QuerySet # We're dealing with an iterable, not a QuerySet
self.count = len(queryset) self.count = len(queryset)
@ -52,6 +52,9 @@ class OptionalLimitOffsetPagination(LimitOffsetPagination):
return self.default_limit return self.default_limit
def get_queryset_count(self, queryset):
return queryset.count()
def get_next_link(self): def get_next_link(self):
# Pagination has been disabled # Pagination has been disabled
@ -67,3 +70,16 @@ class OptionalLimitOffsetPagination(LimitOffsetPagination):
return None return None
return super().get_previous_link() 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()

View File

@ -36,3 +36,8 @@ REDIS = {
} }
SECRET_KEY = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789' SECRET_KEY = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'
LOGGING = {
'version': 1,
'disable_existing_loggers': True
}

View File

@ -29,7 +29,7 @@ django.utils.encoding.force_text = force_str
# Environment setup # Environment setup
# #
VERSION = '3.2.4' VERSION = '3.2.5'
# Hostname # Hostname
HOSTNAME = platform.node() HOSTNAME = platform.node()
@ -96,6 +96,7 @@ EXEMPT_VIEW_PERMISSIONS = getattr(configuration, 'EXEMPT_VIEW_PERMISSIONS', [])
FIELD_CHOICES = getattr(configuration, 'FIELD_CHOICES', {}) FIELD_CHOICES = getattr(configuration, 'FIELD_CHOICES', {})
HTTP_PROXIES = getattr(configuration, 'HTTP_PROXIES', None) HTTP_PROXIES = getattr(configuration, 'HTTP_PROXIES', None)
INTERNAL_IPS = getattr(configuration, 'INTERNAL_IPS', ('127.0.0.1', '::1')) INTERNAL_IPS = getattr(configuration, 'INTERNAL_IPS', ('127.0.0.1', '::1'))
JINJA2_FILTERS = getattr(configuration, 'JINJA2_FILTERS', {})
LOGGING = getattr(configuration, 'LOGGING', {}) LOGGING = getattr(configuration, 'LOGGING', {})
LOGIN_PERSISTENCE = getattr(configuration, 'LOGIN_PERSISTENCE', False) LOGIN_PERSISTENCE = getattr(configuration, 'LOGIN_PERSISTENCE', False)
LOGIN_REQUIRED = getattr(configuration, 'LOGIN_REQUIRED', False) LOGIN_REQUIRED = getattr(configuration, 'LOGIN_REQUIRED', False)

View File

@ -166,6 +166,7 @@ class ActionsItem:
title: str title: str
icon: str icon: str
permission: Optional[str] = None permission: Optional[str] = None
css_class: Optional[str] = 'secondary'
class ActionsColumn(tables.Column): class ActionsColumn(tables.Column):
@ -175,19 +176,22 @@ class ActionsColumn(tables.Column):
:param actions: The ordered list of dropdown menu items to include :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 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'}} attrs = {'td': {'class': 'text-end text-nowrap noprint'}}
empty_values = () empty_values = ()
actions = { actions = {
'edit': ActionsItem('Edit', 'pencil', 'change'), 'edit': ActionsItem('Edit', 'pencil', 'change', 'warning'),
'delete': ActionsItem('Delete', 'trash-can-outline', 'delete'), 'delete': ActionsItem('Delete', 'trash-can-outline', 'delete', 'danger'),
'changelog': ActionsItem('Changelog', 'history'), '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) super().__init__(*args, **kwargs)
self.extra_buttons = extra_buttons self.extra_buttons = extra_buttons
self.split_actions = split_actions
# Determine which actions to enable # Determine which actions to enable
self.actions = { self.actions = {
@ -208,22 +212,49 @@ class ActionsColumn(tables.Column):
html = '' html = ''
# Compile actions menu # Compile actions menu
links = [] button = None
dropdown_class = 'secondary'
dropdown_links = []
user = getattr(request, 'user', AnonymousUser()) 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}' permission = f'{model._meta.app_label}.{attrs.permission}_{model._meta.model_name}'
if attrs.permission is None or user.has_perm(permission): if attrs.permission is None or user.has_perm(permission):
url = reverse(get_viewname(model, action), kwargs={'pk': record.pk}) url = reverse(get_viewname(model, action), kwargs={'pk': record.pk})
links.append(
# 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'<a class="btn btn-sm btn-{attrs.css_class}" href="{url}{url_appendix}" type="button">'
f'<i class="mdi mdi-{attrs.icon}"></i></a>'
)
# Add dropdown menu items
else:
dropdown_links.append(
f'<li><a class="dropdown-item" href="{url}{url_appendix}">' f'<li><a class="dropdown-item" href="{url}{url_appendix}">'
f'<i class="mdi mdi-{attrs.icon}"></i> {attrs.title}</a></li>' f'<i class="mdi mdi-{attrs.icon}"></i> {attrs.title}</a></li>'
) )
if links:
# Create the actions dropdown menu
if button and dropdown_links:
html += ( html += (
f'<span class="dropdown">' f'<span class="btn-group dropdown">'
f'<a class="btn btn-sm btn-secondary dropdown-toggle" href="#" type="button" data-bs-toggle="dropdown">' f' {button}'
f'<i class="mdi mdi-wrench"></i></a>' f' <a class="btn btn-sm btn-{dropdown_class} dropdown-toggle" type="button" data-bs-toggle="dropdown" style="padding-left: 2px">'
f'<ul class="dropdown-menu">{"".join(links)}</ul></span>' f' <span class="visually-hidden">Toggle Dropdown</span></a>'
f' <ul class="dropdown-menu">{"".join(dropdown_links)}</ul>'
f'</span>'
)
elif button:
html += button
elif dropdown_links:
html += (
f'<span class="btn-group dropdown">'
f' <a class="btn btn-sm btn-secondary dropdown-toggle" type="button" data-bs-toggle="dropdown">'
f' <span class="visually-hidden">Toggle Dropdown</span></a>'
f' <ul class="dropdown-menu">{"".join(dropdown_links)}</ul>'
f'</span>'
) )
# Render any extra buttons from template code # Render any extra buttons from template code

Binary file not shown.

Binary file not shown.

View File

@ -3,6 +3,7 @@ import { initDepthToggle } from './depthToggle';
import { initMoveButtons } from './moveOptions'; import { initMoveButtons } from './moveOptions';
import { initReslug } from './reslug'; import { initReslug } from './reslug';
import { initSelectAll } from './selectAll'; import { initSelectAll } from './selectAll';
import { initSelectMultiple } from './selectMultiple';
export function initButtons(): void { export function initButtons(): void {
for (const func of [ for (const func of [
@ -10,6 +11,7 @@ export function initButtons(): void {
initConnectionToggle, initConnectionToggle,
initReslug, initReslug,
initSelectAll, initSelectAll,
initSelectMultiple,
initMoveButtons, initMoveButtons,
]) { ]) {
func(); func();

View File

@ -0,0 +1,105 @@
import { getElements } from '../util';
import { StateManager } from 'src/state';
import { previousPkCheckState } from '../stores';
type PreviousPkCheckState = { element: Nullable<HTMLInputElement> };
/**
* 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<PreviousPkCheckState>,
): 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<PreviousPkCheckState>): 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<HTMLInputElement>('input[type="checkbox"][name="pk"]');
toggleCheckboxRange(eventTargetElement, previousStateElement, checkboxList);
}
/**
* Initialize table select all elements.
*/
export function initSelectMultiple(): void {
const checkboxElements = getElements<HTMLInputElement>('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);
});
}
}

View File

@ -205,6 +205,11 @@ export class APISelect {
onChange: () => this.handleSlimChange(), onChange: () => this.handleSlimChange(),
}); });
// Don't close on select if multiple select
if (this.base.multiple) {
this.slim.config.closeOnSelect = false;
}
// Initialize API query properties. // Initialize API query properties.
this.getStaticParams(); this.getStaticParams();
this.getDynamicParams(); this.getDynamicParams();

View File

@ -1,2 +1,3 @@
export * from './objectDepth'; export * from './objectDepth';
export * from './rackImages'; export * from './rackImages';
export * from './previousPkCheck';

View File

@ -0,0 +1,6 @@
import { createState } from '../state';
export const previousPkCheckState = createState<{ element: Nullable<HTMLInputElement> }>(
{ element: null },
{ persist: false },
);

View File

@ -10,7 +10,7 @@
{% if termination_a %} {% if termination_a %}
{{ termination_a.site }} {% if termination_a.interface %}- {{ termination_a.interface.device }} {{ termination_a.interface }}{% endif %} {{ termination_a.site }} {% if termination_a.interface %}- {{ termination_a.interface.device }} {{ termination_a.interface }}{% endif %}
{% else %} {% else %}
<span class="text-muted">None</span> {{ ''|placeholder }}
{% endif %} {% endif %}
</li> </li>
<li> <li>
@ -18,7 +18,7 @@
{% if termination_z %} {% if termination_z %}
{{ termination_z.site }} {% if termination_z.interface %}- {{ termination_z.interface.device }} {{ termination_z.interface }}{% endif %} {{ termination_z.site }} {% if termination_z.interface %}- {{ termination_z.interface.device }} {{ termination_z.interface }}{% endif %}
{% else %} {% else %}
<span class="text-muted">None</span> {{ ''|placeholder }}
{% endif %} {% endif %}
</li> </li>
</ul> </ul>

View File

@ -94,7 +94,7 @@
{% elif termination.port_speed %} {% elif termination.port_speed %}
{{ termination.port_speed|humanize_speed }} {{ termination.port_speed|humanize_speed }}
{% else %} {% else %}
<span class="text-muted">&mdash;</span> {{ ''|placeholder }}
{% endif %} {% endif %}
</td> </td>
</tr> </tr>

View File

@ -50,7 +50,7 @@
{% if object.portal_url %} {% if object.portal_url %}
<a href="{{ object.portal_url }}">{{ object.portal_url }}</a> <a href="{{ object.portal_url }}">{{ object.portal_url }}</a>
{% else %} {% else %}
<span class="text-muted">&mdash;</span> {{ ''|placeholder }}
{% endif %} {% endif %}
</td> </td>
</tr> </tr>

View File

@ -40,7 +40,7 @@
{% if object.color %} {% if object.color %}
<span class="color-label" style="background-color: #{{ object.color }}">&nbsp;</span> <span class="color-label" style="background-color: #{{ object.color }}">&nbsp;</span>
{% else %} {% else %}
<span class="text-muted">&mdash;</span> {{ ''|placeholder }}
{% endif %} {% endif %}
</td> </td>
</tr> </tr>
@ -50,7 +50,7 @@
{% if object.length %} {% if object.length %}
{{ object.length|floatformat }} {{ object.get_length_unit_display }} {{ object.length|floatformat }} {{ object.get_length_unit_display }}
{% else %} {% else %}
<span class="text-muted">&mdash;</span> {{ ''|placeholder }}
{% endif %} {% endif %}
</td> </td>
</tr> </tr>

View File

@ -23,7 +23,7 @@
{% endfor %} {% endfor %}
{{ object.site.region|linkify }} {{ object.site.region|linkify }}
{% else %} {% else %}
<span class="text-muted">None</span> {{ ''|placeholder }}
{% endif %} {% endif %}
</td> </td>
</tr> </tr>
@ -40,7 +40,7 @@
{% endfor %} {% endfor %}
{{ object.location|linkify }} {{ object.location|linkify }}
{% else %} {% else %}
<span class="text-muted">None</span> {{ ''|placeholder }}
{% endif %} {% endif %}
</td> </td>
</tr> </tr>
@ -50,7 +50,7 @@
{% if object.rack %} {% if object.rack %}
<a href="{% url 'dcim:rack' pk=object.rack.pk %}">{{ object.rack }}</a> <a href="{% url 'dcim:rack' pk=object.rack.pk %}">{{ object.rack }}</a>
{% else %} {% else %}
<span class="text-muted">None</span> {{ ''|placeholder }}
{% endif %} {% endif %}
</td> </td>
</tr> </tr>
@ -69,7 +69,7 @@
{% elif object.rack and object.device_type.u_height %} {% elif object.rack and object.device_type.u_height %}
<span class="badge bg-warning">Not racked</span> <span class="badge bg-warning">Not racked</span>
{% else %} {% else %}
<span class="text-muted">&mdash;</span> {{ ''|placeholder }}
{% endif %} {% endif %}
</td> </td>
</tr> </tr>
@ -180,7 +180,7 @@
(NAT: {{ object.primary_ip4.nat_outside.address.ip|linkify }}) (NAT: {{ object.primary_ip4.nat_outside.address.ip|linkify }})
{% endif %} {% endif %}
{% else %} {% else %}
<span class="text-muted">&mdash;</span> {{ ''|placeholder }}
{% endif %} {% endif %}
</td> </td>
</tr> </tr>
@ -195,7 +195,7 @@
(NAT: {{ object.primary_ip6.nat_outside.address.ip|linkify }}) (NAT: {{ object.primary_ip6.nat_outside.address.ip|linkify }})
{% endif %} {% endif %}
{% else %} {% else %}
<span class="text-muted">&mdash;</span> {{ ''|placeholder }}
{% endif %} {% endif %}
</td> </td>
</tr> </tr>

View File

@ -54,7 +54,7 @@
{% if object.vm_role %} {% if object.vm_role %}
<a href="{% url 'virtualization:virtualmachine_list' %}?role_id={{ object.pk }}">{{ virtualmachine_count }}</a> <a href="{% url 'virtualization:virtualmachine_list' %}?role_id={{ object.pk }}">{{ virtualmachine_count }}</a>
{% else %} {% else %}
&mdash; {{ ''|placeholder }}
{% endif %} {% endif %}
</td> </td>
</tr> </tr>

View File

@ -55,7 +55,7 @@
<img src="{{ object.front_image.url }}" alt="{{ object.front_image.name }}" class="img-fluid" /> <img src="{{ object.front_image.url }}" alt="{{ object.front_image.name }}" class="img-fluid" />
</a> </a>
{% else %} {% else %}
<span class="text-muted">&mdash;</span> {{ ''|placeholder }}
{% endif %} {% endif %}
</td> </td>
</tr> </tr>
@ -67,7 +67,7 @@
<img src="{{ object.rear_image.url }}" alt="{{ object.rear_image.name }}" class="img-fluid" /> <img src="{{ object.rear_image.url }}" alt="{{ object.rear_image.name }}" class="img-fluid" />
</a> </a>
{% else %} {% else %}
<span class="text-muted">&mdash;</span> {{ ''|placeholder }}
{% endif %} {% endif %}
</td> </td>
</tr> </tr>

View File

@ -321,7 +321,7 @@
{% if object.rf_channel_frequency %} {% if object.rf_channel_frequency %}
{{ object.rf_channel_frequency|simplify_decimal }} MHz {{ object.rf_channel_frequency|simplify_decimal }} MHz
{% else %} {% else %}
<span class="text-muted">&mdash;</span> {{ ''|placeholder }}
{% endif %} {% endif %}
</td> </td>
{% if peer %} {% if peer %}
@ -329,7 +329,7 @@
{% if peer.rf_channel_frequency %} {% if peer.rf_channel_frequency %}
{{ peer.rf_channel_frequency|simplify_decimal }} MHz {{ peer.rf_channel_frequency|simplify_decimal }} MHz
{% else %} {% else %}
<span class="text-muted">&mdash;</span> {{ ''|placeholder }}
{% endif %} {% endif %}
</td> </td>
{% endif %} {% endif %}
@ -340,7 +340,7 @@
{% if object.rf_channel_width %} {% if object.rf_channel_width %}
{{ object.rf_channel_width|simplify_decimal }} MHz {{ object.rf_channel_width|simplify_decimal }} MHz
{% else %} {% else %}
<span class="text-muted">&mdash;</span> {{ ''|placeholder }}
{% endif %} {% endif %}
</td> </td>
{% if peer %} {% if peer %}
@ -348,7 +348,7 @@
{% if peer.rf_channel_width %} {% if peer.rf_channel_width %}
{{ peer.rf_channel_width|simplify_decimal }} MHz {{ peer.rf_channel_width|simplify_decimal }} MHz
{% else %} {% else %}
<span class="text-muted">&mdash;</span> {{ ''|placeholder }}
{% endif %} {% endif %}
</td> </td>
{% endif %} {% endif %}

View File

@ -18,25 +18,25 @@
</button> </button>
<ul class="dropdown-menu" aria-labeled-by="add-components"> <ul class="dropdown-menu" aria-labeled-by="add-components">
{% if perms.dcim.add_consoleport %} {% if perms.dcim.add_consoleport %}
<li><a class="dropdown-item" href="{% url 'dcim:consoleport_add' %}?device={{ object.device.pk }}&module={{ object.pk }}&return_url={% url 'dcim:device_consoleports' pk=object.pk %}">Console Ports</a></li> <li><a class="dropdown-item" href="{% url 'dcim:consoleport_add' %}?device={{ object.device.pk }}&module={{ object.pk }}&return_url={% url 'dcim:device_consoleports' pk=object.device.pk %}">Console Ports</a></li>
{% endif %} {% endif %}
{% if perms.dcim.add_consoleserverport %} {% if perms.dcim.add_consoleserverport %}
<li><a class="dropdown-item" href="{% url 'dcim:consoleserverport_add' %}?device={{ object.device.pk }}&module={{ object.pk }}&return_url={% url 'dcim:device_consoleserverports' pk=object.pk %}">Console Server Ports</a></li> <li><a class="dropdown-item" href="{% url 'dcim:consoleserverport_add' %}?device={{ object.device.pk }}&module={{ object.pk }}&return_url={% url 'dcim:device_consoleserverports' pk=object.device.pk %}">Console Server Ports</a></li>
{% endif %} {% endif %}
{% if perms.dcim.add_powerport %} {% if perms.dcim.add_powerport %}
<li><a class="dropdown-item" href="{% url 'dcim:powerport_add' %}?device={{ object.device.pk }}&module={{ object.pk }}&return_url={% url 'dcim:device_powerports' pk=object.pk %}">Power Ports</a></li> <li><a class="dropdown-item" href="{% url 'dcim:powerport_add' %}?device={{ object.device.pk }}&module={{ object.pk }}&return_url={% url 'dcim:device_powerports' pk=object.device.pk %}">Power Ports</a></li>
{% endif %} {% endif %}
{% if perms.dcim.add_poweroutlet %} {% if perms.dcim.add_poweroutlet %}
<li><a class="dropdown-item" href="{% url 'dcim:poweroutlet_add' %}?device={{ object.device.pk }}&module={{ object.pk }}&return_url={% url 'dcim:device_poweroutlets' pk=object.pk %}">Power Outlets</a></li> <li><a class="dropdown-item" href="{% url 'dcim:poweroutlet_add' %}?device={{ object.device.pk }}&module={{ object.pk }}&return_url={% url 'dcim:device_poweroutlets' pk=object.device.pk %}">Power Outlets</a></li>
{% endif %} {% endif %}
{% if perms.dcim.add_interface %} {% if perms.dcim.add_interface %}
<li><a class="dropdown-item" href="{% url 'dcim:interface_add' %}?device={{ object.device.pk }}&module={{ object.pk }}&return_url={% url 'dcim:device_interfaces' pk=object.pk %}">Interfaces</a></li> <li><a class="dropdown-item" href="{% url 'dcim:interface_add' %}?device={{ object.device.pk }}&module={{ object.pk }}&return_url={% url 'dcim:device_interfaces' pk=object.device.pk %}">Interfaces</a></li>
{% endif %} {% endif %}
{% if perms.dcim.add_frontport %} {% if perms.dcim.add_frontport %}
<li><a class="dropdown-item" href="{% url 'dcim:frontport_add' %}?device={{ object.device.pk }}&module={{ object.pk }}&return_url={% url 'dcim:device_frontports' pk=object.pk %}">Front Ports</a></li> <li><a class="dropdown-item" href="{% url 'dcim:frontport_add' %}?device={{ object.device.pk }}&module={{ object.pk }}&return_url={% url 'dcim:device_frontports' pk=object.device.pk %}">Front Ports</a></li>
{% endif %} {% endif %}
{% if perms.dcim.add_rearport %} {% if perms.dcim.add_rearport %}
<li><a class="dropdown-item" href="{% url 'dcim:rearport_add' %}?device={{ object.device.pk }}&module={{ object.pk }}&return_url={% url 'dcim:device_rearports' pk=object.pk %}">Rear Ports</a></li> <li><a class="dropdown-item" href="{% url 'dcim:rearport_add' %}?device={{ object.device.pk }}&module={{ object.pk }}&return_url={% url 'dcim:device_rearports' pk=object.device.pk %}">Rear Ports</a></li>
{% endif %} {% endif %}
</ul> </ul>
</div> </div>

View File

@ -44,7 +44,7 @@
{% if object.connected_endpoint %} {% if object.connected_endpoint %}
{{ object.connected_endpoint.device|linkify }} ({{ object.connected_endpoint }}) {{ object.connected_endpoint.device|linkify }} ({{ object.connected_endpoint }})
{% else %} {% else %}
<span class="text-muted">None</span> {{ ''|placeholder }}
{% endif %} {% endif %}
</td> </td>
</tr> </tr>

View File

@ -44,7 +44,7 @@
</tr> </tr>
<tr> <tr>
<th scope="row">Power Port</th> <th scope="row">Power Port</th>
<td>{{ object.power_port }}</td> <td>{{ object.power_port|linkify|placeholder }}</td>
</tr> </tr>
<tr> <tr>
<th scope="row">Feed Leg</th> <th scope="row">Feed Leg</th>

View File

@ -53,7 +53,7 @@
{% endfor %} {% endfor %}
{{ object.location|linkify }} {{ object.location|linkify }}
{% else %} {% else %}
<span class="text-muted">None</span> {{ ''|placeholder }}
{% endif %} {% endif %}
</td> </td>
</tr> </tr>
@ -115,7 +115,7 @@
{% if object.type %} {% if object.type %}
{{ object.get_type_display }} {{ object.get_type_display }}
{% else %} {% else %}
<span class="text-muted">None</span> {{ ''|placeholder }}
{% endif %} {% endif %}
</td> </td>
</tr> </tr>
@ -133,7 +133,7 @@
{% if object.outer_width %} {% if object.outer_width %}
<span>{{ object.outer_width }} {{ object.get_outer_unit_display }}</span> <span>{{ object.outer_width }} {{ object.get_outer_unit_display }}</span>
{% else %} {% else %}
<span class="text-muted">&mdash;</span> {{ ''|placeholder }}
{% endif %} {% endif %}
</td> </td>
</tr> </tr>
@ -143,7 +143,7 @@
{% if object.outer_depth %} {% if object.outer_depth %}
<span>{{ object.outer_depth }} {{ object.get_outer_unit_display }}</span> <span>{{ object.outer_depth }} {{ object.get_outer_unit_display }}</span>
{% else %} {% else %}
<span class="text-muted">&mdash;</span> {{ ''|placeholder }}
{% endif %} {% endif %}
</td> </td>
</tr> </tr>

View File

@ -34,7 +34,7 @@
{% endfor %} {% endfor %}
{{ object.region|linkify }} {{ object.region|linkify }}
{% else %} {% else %}
<span class="text-muted">None</span> {{ ''|placeholder }}
{% endif %} {% endif %}
</td> </td>
</tr> </tr>
@ -47,7 +47,7 @@
{% endfor %} {% endfor %}
{{ object.group|linkify }} {{ object.group|linkify }}
{% else %} {% else %}
<span class="text-muted">None</span> {{ ''|placeholder }}
{% endif %} {% endif %}
</td> </td>
</tr> </tr>
@ -79,7 +79,7 @@
{{ object.time_zone }} (UTC {{ object.time_zone|tzoffset }})<br /> {{ object.time_zone }} (UTC {{ object.time_zone|tzoffset }})<br />
<small class="text-muted">Site time: {% timezone object.time_zone %}{% annotated_now %}{% endtimezone %}</small> <small class="text-muted">Site time: {% timezone object.time_zone %}{% annotated_now %}{% endtimezone %}</small>
{% else %} {% else %}
<span class="text-muted">&mdash;</span> {{ ''|placeholder }}
{% endif %} {% endif %}
</td> </td>
</tr> </tr>
@ -94,7 +94,7 @@
</div> </div>
<span>{{ object.physical_address|linebreaksbr }}</span> <span>{{ object.physical_address|linebreaksbr }}</span>
{% else %} {% else %}
<span class="text-muted">&mdash;</span> {{ ''|placeholder }}
{% endif %} {% endif %}
</td> </td>
</tr> </tr>
@ -113,7 +113,7 @@
</div> </div>
<span>{{ object.latitude }}, {{ object.longitude }}</span> <span>{{ object.latitude }}, {{ object.longitude }}</span>
{% else %} {% else %}
<span class="text-muted">&mdash;</span> {{ ''|placeholder }}
{% endif %} {% endif %}
</td> </td>
</tr> </tr>

View File

@ -57,7 +57,7 @@
{% if device.rack %} {% if device.rack %}
{{ device.rack }} / {{ device.position }} {{ device.rack }} / {{ device.position }}
{% else %} {% else %}
<span class="text-muted">&mdash;</span> {{ ''|placeholder }}
{% endif %} {% endif %}
</td> </td>
<td>{{ device.serial|placeholder }}</td> <td>{{ device.serial|placeholder }}</td>

View File

@ -57,7 +57,7 @@
{% if object.choices %} {% if object.choices %}
{{ object.choices|join:", " }} {{ object.choices|join:", " }}
{% else %} {% else %}
<span class="text-muted">&mdash;</span> {{ ''|placeholder }}
{% endif %} {% endif %}
</td> </td>
</tr> </tr>
@ -105,7 +105,7 @@
{% if object.validation_regex %} {% if object.validation_regex %}
<code>{{ object.validation_regex }}</code> <code>{{ object.validation_regex }}</code>
{% else %} {% else %}
&mdash; {{ ''|placeholder }}
{% endif %} {% endif %}
</td> </td>
</tr> </tr>

View File

@ -57,7 +57,7 @@
{% elif obj %} {% elif obj %}
{{ obj }} {{ obj }}
{% else %} {% else %}
<span class="muted">&mdash;</span> {{ ''|placeholder }}
{% endif %} {% endif %}
</td> </td>
<td class="rendered-markdown">{{ message|markdown }}</td> <td class="rendered-markdown">{{ message|markdown }}</td>

View File

@ -76,14 +76,14 @@ Context:
{% if field.required %} {% if field.required %}
{% checkmark True true="Required" %} {% checkmark True true="Required" %}
{% else %} {% else %}
<span class="text-muted">&mdash;</span> {{ ''|placeholder }}
{% endif %} {% endif %}
</td> </td>
<td> <td>
{% if field.to_field_name %} {% if field.to_field_name %}
<code>{{ field.to_field_name }}</code> <code>{{ field.to_field_name }}</code>
{% else %} {% else %}
<span class="text-muted">&mdash;</span> {{ ''|placeholder }}
{% endif %} {% endif %}
</td> </td>
<td> <td>

View File

@ -37,7 +37,7 @@
{% elif field.required %} {% elif field.required %}
<span class="text-warning">Not defined</span> <span class="text-warning">Not defined</span>
{% else %} {% else %}
<span class="text-muted">&mdash;</span> {{ ''|placeholder }}
{% endif %} {% endif %}
</td> </td>
</tr> </tr>

View File

@ -52,7 +52,7 @@
{% if object.role %} {% if object.role %}
<a href="{% url 'ipam:ipaddress_list' %}?role={{ object.role }}">{{ object.get_role_display }}</a> <a href="{% url 'ipam:ipaddress_list' %}?role={{ object.role }}">{{ object.get_role_display }}</a>
{% else %} {% else %}
<span class="text-muted">None</span> {{ ''|placeholder }}
{% endif %} {% endif %}
</td> </td>
</tr> </tr>
@ -73,7 +73,7 @@
{% endif %} {% endif %}
{{ object.assigned_object|linkify }} {{ object.assigned_object|linkify }}
{% else %} {% else %}
<span class="text-muted">&mdash;</span> {{ ''|placeholder }}
{% endif %} {% endif %}
</td> </td>
</tr> </tr>
@ -86,7 +86,7 @@
({{ object.nat_inside.assigned_object.parent_object|linkify }}) ({{ object.nat_inside.assigned_object.parent_object|linkify }})
{% endif %} {% endif %}
{% else %} {% else %}
<span class="text-muted">None</span> {{ ''|placeholder }}
{% endif %} {% endif %}
</td> </td>
</tr> </tr>

View File

@ -39,7 +39,7 @@
{% if aggregate %} {% if aggregate %}
<a href="{% url 'ipam:aggregate' pk=aggregate.pk %}">{{ aggregate.prefix }}</a> ({{ aggregate.rir }}) <a href="{% url 'ipam:aggregate' pk=aggregate.pk %}">{{ aggregate.prefix }}</a> ({{ aggregate.rir }})
{% else %} {% else %}
<span class="text-warning">None</span> {{ ''|placeholder }}
{% endif %} {% endif %}
</td> </td>
</tr> </tr>
@ -52,7 +52,7 @@
{% endif %} {% endif %}
{{ object.site|linkify }} {{ object.site|linkify }}
{% else %} {% else %}
<span class="text-muted">None</span> {{ ''|placeholder }}
{% endif %} {% endif %}
</td> </td>
</tr> </tr>
@ -65,7 +65,7 @@
{% endif %} {% endif %}
{{ object.vlan|linkify }} {{ object.vlan|linkify }}
{% else %} {% else %}
<span class="text-muted">None</span> {{ ''|placeholder }}
{% endif %} {% endif %}
</td> </td>
</tr> </tr>
@ -138,7 +138,7 @@
{{ first_available_ip }} {{ first_available_ip }}
{% endif %} {% endif %}
{% else %} {% else %}
<span class="text-muted">None</span> {{ ''|placeholder }}
{% endif %} {% endif %}
{% endwith %} {% endwith %}
</td> </td>

View File

@ -45,7 +45,7 @@
{% if ipranges_count %} {% if ipranges_count %}
<a href="{% url 'ipam:iprange_list' %}?role_id={{ object.pk }}">{{ ipranges_count }}</a> <a href="{% url 'ipam:iprange_list' %}?role_id={{ object.pk }}">{{ ipranges_count }}</a>
{% else %} {% else %}
&mdash; {{ ''|placeholder }}
{% endif %} {% endif %}
{% endwith %} {% endwith %}
</td> </td>
@ -57,7 +57,7 @@
{% if vlans_count %} {% if vlans_count %}
<a href="{% url 'ipam:vlan_list' %}?role_id={{ object.pk }}">{{ vlans_count }}</a> <a href="{% url 'ipam:vlan_list' %}?role_id={{ object.pk }}">{{ vlans_count }}</a>
{% else %} {% else %}
&mdash; {{ ''|placeholder }}
{% endif %} {% endif %}
{% endwith %} {% endwith %}
</td> </td>

View File

@ -44,7 +44,7 @@
{% for ipaddress in object.ipaddresses.all %} {% for ipaddress in object.ipaddresses.all %}
{{ ipaddress|linkify }}<br /> {{ ipaddress|linkify }}<br />
{% empty %} {% empty %}
<span class="text-muted">None</span> {{ ''|placeholder }}
{% endfor %} {% endfor %}
</td> </td>
</tr> </tr>

View File

@ -21,7 +21,7 @@
{% endif %} {% endif %}
{{ object.site|linkify }} {{ object.site|linkify }}
{% else %} {% else %}
<span class="text-muted">None</span> {{ ''|placeholder }}
{% endif %} {% endif %}
</td> </td>
</tr> </tr>
@ -56,7 +56,7 @@
{% if object.role %} {% if object.role %}
<a href="{% url 'ipam:vlan_list' %}?role={{ object.role.slug }}">{{ object.role }}</a> <a href="{% url 'ipam:vlan_list' %}?role={{ object.role.slug }}">{{ object.role }}</a>
{% else %} {% else %}
<span class="text-muted">None</span> {{ ''|placeholder }}
{% endif %} {% endif %}
</td> </td>
</tr> </tr>

View File

@ -35,7 +35,7 @@
{% if object.phone %} {% if object.phone %}
<a href="tel:{{ object.phone }}">{{ object.phone }}</a> <a href="tel:{{ object.phone }}">{{ object.phone }}</a>
{% else %} {% else %}
<span class="text-muted">None</span> {{ ''|placeholder }}
{% endif %} {% endif %}
</td> </td>
</tr> </tr>
@ -45,7 +45,7 @@
{% if object.email %} {% if object.email %}
<a href="mailto:{{ object.email }}">{{ object.email }}</a> <a href="mailto:{{ object.email }}">{{ object.email }}</a>
{% else %} {% else %}
<span class="text-muted">None</span> {{ ''|placeholder }}
{% endif %} {% endif %}
</td> </td>
</tr> </tr>

View File

@ -77,6 +77,10 @@
<h2><a href="{% url 'ipam:prefix_list' %}?tenant_id={{ object.pk }}" class="stat-btn btn {% if stats.prefix_count %}btn-primary{% else %}btn-outline-dark{% endif %} btn-lg">{{ stats.prefix_count }}</a></h2> <h2><a href="{% url 'ipam:prefix_list' %}?tenant_id={{ object.pk }}" class="stat-btn btn {% if stats.prefix_count %}btn-primary{% else %}btn-outline-dark{% endif %} btn-lg">{{ stats.prefix_count }}</a></h2>
<p>Prefixes</p> <p>Prefixes</p>
</div> </div>
<div class="col col-md-4 text-center">
<h2><a href="{% url 'ipam:iprange_list' %}?tenant_id={{ object.pk }}" class="stat-btn btn {% if stats.iprange_count %}btn-primary{% else %}btn-outline-dark{% endif %} btn-lg">{{ stats.iprange_count }}</a></h2>
<p>IP Ranges</p>
</div>
<div class="col col-md-4 text-center"> <div class="col col-md-4 text-center">
<h2><a href="{% url 'ipam:ipaddress_list' %}?tenant_id={{ object.pk }}" class="stat-btn btn {% if stats.ipaddress_count %}btn-primary{% else %}btn-outline-dark{% endif %} btn-lg">{{ stats.ipaddress_count }}</a></h2> <h2><a href="{% url 'ipam:ipaddress_list' %}?tenant_id={{ object.pk }}" class="stat-btn btn {% if stats.ipaddress_count %}btn-primary{% else %}btn-outline-dark{% endif %} btn-lg">{{ stats.ipaddress_count }}</a></h2>
<p>IP addresses</p> <p>IP addresses</p>

View File

@ -21,7 +21,7 @@
{% if request.user.first_name or request.user.last_name %} {% if request.user.first_name or request.user.last_name %}
{{ request.user.first_name }} {{ request.user.last_name }} {{ request.user.first_name }} {{ request.user.last_name }}
{% else %} {% else %}
<span class="text-muted">&mdash;</span> {{ ''|placeholder }}
{% endif %} {% endif %}
</td> </td>
</tr> </tr>

View File

@ -49,7 +49,7 @@
(NAT: <a href="{{ object.primary_ip4.nat_outside.get_absolute_url }}">{{ object.primary_ip4.nat_outside.address.ip }}</a>) (NAT: <a href="{{ object.primary_ip4.nat_outside.get_absolute_url }}">{{ object.primary_ip4.nat_outside.address.ip }}</a>)
{% endif %} {% endif %}
{% else %} {% else %}
<span class="text-muted">&mdash;</span> {{ ''|placeholder }}
{% endif %} {% endif %}
</td> </td>
</tr> </tr>
@ -64,7 +64,7 @@
(NAT: <a href="{{ object.primary_ip6.nat_outside.get_absolute_url }}">{{ object.primary_ip6.nat_outside.address.ip }}</a>) (NAT: <a href="{{ object.primary_ip6.nat_outside.get_absolute_url }}">{{ object.primary_ip6.nat_outside.address.ip }}</a>)
{% endif %} {% endif %}
{% else %} {% else %}
<span class="text-muted">&mdash;</span> {{ ''|placeholder }}
{% endif %} {% endif %}
</td> </td>
</tr> </tr>
@ -115,7 +115,7 @@
{% if object.memory %} {% if object.memory %}
{{ object.memory|humanize_megabytes }} {{ object.memory|humanize_megabytes }}
{% else %} {% else %}
<span class="text-muted">&mdash;</span> {{ ''|placeholder }}
{% endif %} {% endif %}
</td> </td>
</tr> </tr>
@ -125,7 +125,7 @@
{% if object.disk %} {% if object.disk %}
{{ object.disk }} GB {{ object.disk }} GB
{% else %} {% else %}
<span class="text-muted">&mdash;</span> {{ ''|placeholder }}
{% endif %} {% endif %}
</td> </td>
</tr> </tr>

View File

@ -33,7 +33,7 @@
{% if interface.rf_channel_frequency %} {% if interface.rf_channel_frequency %}
{{ interface.rf_channel_frequency|simplify_decimal }} MHz {{ interface.rf_channel_frequency|simplify_decimal }} MHz
{% else %} {% else %}
<span class="text-muted">&mdash;</span> {{ ''|placeholder }}
{% endif %} {% endif %}
</td> </td>
</tr> </tr>
@ -43,7 +43,7 @@
{% if interface.rf_channel_width %} {% if interface.rf_channel_width %}
{{ interface.rf_channel_width|simplify_decimal }} MHz {{ interface.rf_channel_width|simplify_decimal }} MHz
{% else %} {% else %}
<span class="text-muted">&mdash;</span> {{ ''|placeholder }}
{% endif %} {% endif %}
</td> </td>
</tr> </tr>

View File

@ -18,7 +18,7 @@ class ContactGroupTable(NetBoxTable):
) )
contact_count = columns.LinkedCountColumn( contact_count = columns.LinkedCountColumn(
viewname='tenancy:contact_list', viewname='tenancy:contact_list',
url_params={'role_id': 'pk'}, url_params={'group_id': 'pk'},
verbose_name='Contacts' verbose_name='Contacts'
) )
tags = columns.TagColumn( tags = columns.TagColumn(

View File

@ -3,7 +3,7 @@ from django.shortcuts import get_object_or_404
from circuits.models import Circuit from circuits.models import Circuit
from dcim.models import Cable, Device, Location, Rack, RackReservation, Site 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 netbox.views import generic
from utilities.utils import count_related from utilities.utils import count_related
from virtualization.models import VirtualMachine, Cluster 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(), 'location_count': Location.objects.restrict(request.user, 'view').filter(tenant=instance).count(),
'device_count': Device.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(), '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(), '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(), 'ipaddress_count': IPAddress.objects.restrict(request.user, 'view').filter(tenant=instance).count(),
'vlan_count': VLAN.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(), 'circuit_count': Circuit.objects.restrict(request.user, 'view').filter(tenant=instance).count(),

View File

@ -3,6 +3,7 @@ import json
from django import forms from django import forms
from django.db.models import Count from django.db.models import Count
from django.forms.fields import JSONField as _JSONField, InvalidJSONInput from django.forms.fields import JSONField as _JSONField, InvalidJSONInput
from django.templatetags.static import static
from netaddr import AddrFormatError, EUI from netaddr import AddrFormatError, EUI
from utilities.forms import widgets 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`. A textarea with support for Markdown rendering. Exists mostly just to add a standard `help_text`.
""" """
widget = forms.Textarea widget = forms.Textarea
# TODO: Port Markdown cheat sheet to internal documentation help_text = f"""
help_text = """
<i class="mdi mdi-information-outline"></i> <i class="mdi mdi-information-outline"></i>
<a href="https://github.com/adam-p/markdown-here/wiki/Markdown-Cheatsheet" target="_blank" tabindex="-1"> <a href="{static('docs/reference/markdown/')}" target="_blank" tabindex="-1">
Markdown</a> syntax is supported Markdown</a> syntax is supported
""" """

View File

@ -11,7 +11,7 @@ from markdown import markdown
from netbox.config import get_config from netbox.config import get_config
from utilities.markdown import StrikethroughExtension from utilities.markdown import StrikethroughExtension
from utilities.utils import foreground_color from utilities.utils import clean_html, foreground_color
register = template.Library() register = template.Library()
@ -144,18 +144,6 @@ def render_markdown(value):
{{ md_source_text|markdown }} {{ 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 # Render Markdown
html = markdown(value, extensions=['def_list', 'fenced_code', 'tables', StrikethroughExtension()]) html = markdown(value, extensions=['def_list', 'fenced_code', 'tables', StrikethroughExtension()])
@ -164,6 +152,11 @@ def render_markdown(value):
if html: if html:
html = f'<div class="rendered-markdown">{html}</div>' html = f'<div class="rendered-markdown">{html}</div>'
schemes = get_config().ALLOWED_URL_SCHEMES
# Sanitize HTML
html = clean_html(html, schemes)
return mark_safe(html) return mark_safe(html)

View File

@ -4,6 +4,7 @@ from collections import OrderedDict
from decimal import Decimal from decimal import Decimal
from itertools import count, groupby from itertools import count, groupby
import bleach
from django.core.serializers import serialize from django.core.serializers import serialize
from django.db.models import Count, OuterRef, Subquery from django.db.models import Count, OuterRef, Subquery
from django.db.models.functions import Coalesce from django.db.models.functions import Coalesce
@ -14,6 +15,7 @@ from mptt.models import MPTTModel
from dcim.choices import CableLengthUnitChoices from dcim.choices import CableLengthUnitChoices
from extras.plugins import PluginConfig from extras.plugins import PluginConfig
from extras.utils import is_taggable from extras.utils import is_taggable
from netbox.config import get_config
from utilities.constants import HTTP_REQUEST_META_SAFE_COPY from utilities.constants import HTTP_REQUEST_META_SAFE_COPY
@ -257,7 +259,9 @@ def render_jinja2(template_code, context):
""" """
Render a Jinja2 template with the provided context. Return the rendered content. 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): def prepare_cloned_fields(instance):
@ -382,3 +386,33 @@ def copy_safe_request(request):
'path': request.path, 'path': request.path,
'id': getattr(request, 'id', None), # UUID assigned by middleware '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
)

View File

@ -3,7 +3,7 @@ from django import forms
from dcim.choices import InterfaceModeChoices from dcim.choices import InterfaceModeChoices
from dcim.constants import INTERFACE_MTU_MAX, INTERFACE_MTU_MIN from dcim.constants import INTERFACE_MTU_MAX, INTERFACE_MTU_MIN
from dcim.models import DeviceRole, Platform, Region, Site, SiteGroup from dcim.models import DeviceRole, Platform, Region, Site, SiteGroup
from ipam.models import VLAN, VRF from ipam.models import VLAN, VLANGroup, VRF
from netbox.forms import NetBoxModelBulkEditForm from netbox.forms import NetBoxModelBulkEditForm
from tenancy.models import Tenant from tenancy.models import Tenant
from utilities.forms import ( from utilities.forms import (
@ -182,13 +182,26 @@ class VMInterfaceBulkEditForm(NetBoxModelBulkEditForm):
required=False, required=False,
widget=StaticSelect() widget=StaticSelect()
) )
vlan_group = DynamicModelChoiceField(
queryset=VLANGroup.objects.all(),
required=False,
label='VLAN group'
)
untagged_vlan = DynamicModelChoiceField( untagged_vlan = DynamicModelChoiceField(
queryset=VLAN.objects.all(), queryset=VLAN.objects.all(),
required=False required=False,
query_params={
'group_id': '$vlan_group',
},
label='Untagged VLAN'
) )
tagged_vlans = DynamicModelMultipleChoiceField( tagged_vlans = DynamicModelMultipleChoiceField(
queryset=VLAN.objects.all(), queryset=VLAN.objects.all(),
required=False required=False,
query_params={
'group_id': '$vlan_group',
},
label='Tagged VLANs'
) )
vrf = DynamicModelChoiceField( vrf = DynamicModelChoiceField(
queryset=VRF.objects.all(), queryset=VRF.objects.all(),
@ -200,7 +213,7 @@ class VMInterfaceBulkEditForm(NetBoxModelBulkEditForm):
fieldsets = ( fieldsets = (
(None, ('mtu', 'enabled', 'vrf', 'description')), (None, ('mtu', 'enabled', 'vrf', 'description')),
('Related Interfaces', ('parent', 'bridge')), ('Related Interfaces', ('parent', 'bridge')),
('802.1Q Switching', ('mode', 'untagged_vlan', 'tagged_vlans')), ('802.1Q Switching', ('mode', 'vlan_group', 'untagged_vlan', 'tagged_vlans')),
) )
nullable_fields = ( nullable_fields = (
'parent', 'bridge', 'mtu', 'vrf', 'description', 'parent', 'bridge', 'mtu', 'vrf', 'description',

View File

@ -307,7 +307,7 @@ class VMInterfaceForm(InterfaceCommonForm, NetBoxModelForm):
model = VMInterface model = VMInterface
fields = [ fields = [
'virtual_machine', 'name', 'parent', 'bridge', 'enabled', 'mac_address', 'mtu', 'description', 'mode', '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 = { widgets = {
'virtual_machine': forms.HiddenInput(), 'virtual_machine': forms.HiddenInput(),

View File

@ -1,7 +1,8 @@
Django==4.0.4 bleach==5.0.0
django-cors-headers==3.12.0 Django==4.0.5
django-debug-toolbar==3.2.4 django-cors-headers==3.13.0
django-filter==21.1 django-debug-toolbar==3.4.0
django-filter==22.1
django-graphiql-debug-toolbar==0.2.0 django-graphiql-debug-toolbar==0.2.0
django-mptt==0.13.4 django-mptt==0.13.4
django-pglocks==1.0.4 django-pglocks==1.0.4
@ -18,7 +19,7 @@ gunicorn==20.1.0
Jinja2==3.1.2 Jinja2==3.1.2
Markdown==3.3.7 Markdown==3.3.7
markdown-include==0.6.0 markdown-include==0.6.0
mkdocs-material==8.2.16 mkdocs-material==8.3.6
mkdocstrings[python-legacy]==0.19.0 mkdocstrings[python-legacy]==0.19.0
netaddr==0.8.0 netaddr==0.8.0
Pillow==9.1.1 Pillow==9.1.1
@ -26,7 +27,7 @@ psycopg2-binary==2.9.3
PyYAML==6.0 PyYAML==6.0
sentry-sdk==1.5.12 sentry-sdk==1.5.12
social-auth-app-django==5.0.0 social-auth-app-django==5.0.0
social-auth-core==4.2.0 social-auth-core==4.3.0
svgwrite==1.4.2 svgwrite==1.4.2
tablib==3.2.1 tablib==3.2.1
tzdata==2022.1 tzdata==2022.1