Compare commits

...

59 Commits

Author SHA1 Message Date
Jeremy Stretch
bb2235b05e Merge pull request #9354 from netbox-community/develop
Release v3.2.3
2022-05-12 14:09:30 -04:00
jeremystretch
a6aec9ebac Release v3.2.3 2022-05-12 13:53:26 -04:00
jeremystretch
5f3695d2d0 Closes #8805: Add "mixed" option for device airflow indication 2022-05-12 12:18:58 -04:00
jeremystretch
ad12ad4a77 Closes #9221: Add definition list support for Markdown 2022-05-12 11:05:34 -04:00
jeremystretch
37903776fd Fixes #9296: Improve Markdown link sanitization 2022-05-12 10:41:29 -04:00
jeremystretch
c4c93ee346 Closes #9343: Add Ubiquiti SmartPower power outlet type 2022-05-12 10:17:29 -04:00
jeremystretch
72b2ab03cc #9340: Introduce config parameters for Sentry sampling rates 2022-05-12 10:00:57 -04:00
jeremystretch
4cefe26f80 #9340: Add default Sentry DSN 2022-05-12 09:35:13 -04:00
jeremystretch
991950650b Add Sentry as a sponsor 2022-05-11 16:44:26 -04:00
Jeremy Stretch
8cc94689d8 Merge pull request #9342 from netbox-community/9340-sentry
Closes #9340: Enable Sentry integration
2022-05-11 16:26:46 -04:00
jeremystretch
312d6c890e Add sentry-sdk as a dependency 2022-05-11 15:20:18 -04:00
jeremystretch
c146596564 Implement a custom 404 handler to enable Sentry reporting 2022-05-11 14:27:18 -04:00
jeremystretch
6f5c2f1e29 Enable & document Sentry integration 2022-05-11 14:13:50 -04:00
jeremystretch
1726593fb0 Introduce MODULE_TOKEN constant 2022-05-11 10:37:04 -04:00
jeremystretch
e8575495db Changelog for #9190, #9314 2022-05-11 10:31:04 -04:00
devon-mar
cffc064a33 Add device & vm to FHRPGroupAssignmentFilterSet (#9314)
* Add device & vm to `FHRPGroupAssignmentFilterSet`

* Apply suggestions from code review

* Update netbox/ipam/tests/test_filtersets.py

* Update netbox/ipam/filtersets.py

Co-authored-by: Jeremy Stretch <jstretch@ns1.com>
2022-05-11 10:27:50 -04:00
Jeremy Stretch
3dda7e2da2 Merge pull request #9331 from kkthxbye-code/fix-9001
Fixes #9001 & #9190 - Add form validation to model installation
2022-05-11 10:04:06 -04:00
jeremystretch
22f1863475 Add security document 2022-05-11 09:12:07 -04:00
jeremystretch
bdb21da26e Fixes #9330: Add missing module_type field to REST API serializers for modular device component templates 2022-05-11 08:57:19 -04:00
jeremystretch
e759e123ac Fixes #9333: Annotate unit on interface speed field 2022-05-11 08:09:51 -04:00
kkthxbye-code
d858eceb38 Fix pep8 2022-05-10 17:53:01 +02:00
kkthxbye-code
af126fe7e3 Added form validation to model installation
Raises a ValidationError whenever installation would cause a foreign key violation.
2022-05-10 17:50:33 +02:00
jeremystretch
39a9ebaeee Fixes #9313: Remove HTML code from CSV output of many-to-many relationships 2022-05-06 10:26:02 -04:00
jeremystretch
9b4e016fe4 Fixes #9306: Include VC master interfaces when selecting a LAG/bridge for a VC member interface 2022-05-06 09:47:52 -04:00
jeremystretch
422ec7ecec Fixes #9311: Permit creating contact assignment without a priority via the REST API 2022-05-06 09:25:40 -04:00
kkthxbye
a06a280534 Merge pull request #9312 from kkthxbye-code/fix-9310
Fixes #9310 - Remove stray characters from Config Context tab
2022-05-06 13:58:47 +02:00
kkthxbye
1358469375 Remove stray characters from Config Context tab 2022-05-06 08:01:15 +02:00
jeremystretch
bddca8e232 Changelog for #9280 2022-05-05 14:14:49 -04:00
Jeremy Stretch
e9bf6a7bc5 Merge pull request #9281 from kkthxbye-code/adopt-module-component
Fixes #9280 - Add option to adopt existing DeviceComponents
2022-05-05 10:29:20 -04:00
kkthxbye-code
9c3dfdfd14 Fix test_module_component_adoption 2022-05-05 09:30:13 +02:00
kkthxbye-code
c52aa2196d Prefetch installed components when adding modules 2022-05-04 23:21:03 +02:00
kkthxbye-code
81c7fe2084 Don't adopt components already belonging to a module 2022-05-04 22:59:28 +02:00
jeremystretch
0301aec409 Closes #9260: Apply user preferences to tables under object detail views 2022-05-04 15:46:13 -04:00
jeremystretch
015bc48345 #8998: Add region filter for rack reservations; Add filter tests 2022-05-04 14:29:36 -04:00
jeremystretch
da1aabdfc1 Changelog for #8894, #8998, #9122; PEP8 fix 2022-05-04 14:19:09 -04:00
Jeremy Stretch
c2fe2ba56f Merge pull request #9147 from minitriga/issue_8998
Closes #8998: Add site group filter to racks
2022-05-04 14:16:35 -04:00
Jeremy Stretch
52b18393eb Merge pull request #9150 from minitriga/issue_8894
Closes #8894: Add first and last name username user api select
2022-05-04 14:09:38 -04:00
Jeremy Stretch
b172ae65d2 Merge pull request #9256 from kkthxbye-code/fix-9122
Fix #9122 - Clear the cache when running the upgrade script
2022-05-04 14:03:48 -04:00
jeremystretch
eab187fb6b Changelog for #9267, #9278 2022-05-04 13:59:38 -04:00
Jeremy Stretch
502a14e820 Merge pull request #9288 from huntabyte/develop
Closes #9278: Linkify device type in manufacturer table
2022-05-04 13:43:16 -04:00
kkthxbye
7de27c69c0 Fix PEP8 2022-05-04 09:16:19 +02:00
kkthxbye
f455f91ea3 Add view test for module component adoption 2022-05-04 08:58:42 +02:00
Hunter Johnston
bdaefc0e4d Closes #9278: Linkify device type in manufacturer table 2022-05-03 18:34:32 -04:00
kkthxbye-code
8040804c75 Allow mixture of component replication and adoption 2022-05-03 22:03:12 +02:00
minitriga
7cd840610b Update netbox/dcim/forms/filtersets.py
Co-authored-by: Jeremy Stretch <jstretch@ns1.com>
2022-05-03 11:47:37 +01:00
minitriga
15e91908e8 Update netbox/dcim/forms/filtersets.py
Co-authored-by: Jeremy Stretch <jstretch@ns1.com>
2022-05-03 11:47:32 +01:00
Alex Gittings
0a9ba3b2e6 add get_display to users serializer 2022-05-03 10:45:08 +00:00
minitriga
535606a185 Update netbox/users/api/nested_serializers.py
Co-authored-by: Jeremy Stretch <jstretch@ns1.com>
2022-05-03 09:01:06 +01:00
minitriga
25c266e4de Update netbox/users/api/nested_serializers.py
Co-authored-by: Jeremy Stretch <jstretch@ns1.com>
2022-05-03 09:00:52 +01:00
kkthxbye-code
977ccb01f2 Formatting: Remove whitespace on blank line 2022-05-02 21:55:34 +02:00
kkthxbye-code
c2a6a1c125 Create module components in bulk 2022-05-02 21:37:37 +02:00
Jeremy Stretch
f6402a8b62 Merge pull request #9275 from kkthxbye-code/fix-9267
Fixes #9267 - Fix early terminated tuple in IPAddressRoleChoices
2022-05-02 09:19:11 -04:00
kkthxbye
30d4097fd8 Fix early terminated tuple in IPAddressRoleChoices 2022-05-02 12:09:49 +02:00
kkthxbye-code
3fb967b482 Add ability to adopt components when adding a module 2022-04-30 02:19:11 +02:00
kkthxbye
9f3846ec5f Clear the cache when running the upgrade script 2022-04-29 09:19:37 +02:00
kkthxbye
7b5625a722 Add management command for clearing cache 2022-04-29 09:19:19 +02:00
jeremystretch
152d5a3b9a PRVB 2022-04-28 15:06:27 -04:00
Alex Gittings
bc2491e6b7 Closes #8894: Add first and last name to APISelect widget if set 2022-04-15 21:50:24 +00:00
Alex Gittings
69a1cc8759 Closes #8998: Add site group filter to racks 2022-04-15 20:36:40 +00:00
53 changed files with 805 additions and 148 deletions

View File

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

View File

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

View File

@@ -60,6 +60,8 @@ The complete documentation for NetBox can be found at [docs.netbox.dev](https://
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
[![NS1](https://raw.githubusercontent.com/wiki/netbox-community/netbox/images/sponsors/ns1.png)](https://ns1.com/)
<br />
[![Sentry](https://raw.githubusercontent.com/wiki/netbox-community/netbox/images/sponsors/sentry.png)](https://sentry.io/)
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
[![Stellar Technologies](https://raw.githubusercontent.com/wiki/netbox-community/netbox/images/sponsors/stellar.png)](https://stellar.tech/)
</div>

31
SECURITY.md Normal file
View File

@@ -0,0 +1,31 @@
# Security Policy
## No Warranty
Per the terms of the Apache 2 license, NetBox is offered "as is" and without any guarantee or warranty pertaining to its operation. While every reasonable effort is made by its maintainers to ensure the product remains free of security vulnerabilities, users are ultimately responsible for conducting their own evaluations of each software release.
## Recommendations
Administrators are encouraged to adhere to industry best practices concerning the secure operation of software, such as:
* Do not expose your NetBox installation to the public Internet
* Do not permit multiple users to share an account
* Enforce minimum password complexity requirements for local accounts
* Prohibit access to your database from clients other than the NetBox application
* Keep your deployment updated to the most recent stable release
## Reporting a Suspected Vulnerability
If you believe you've uncovered a security vulnerability and wish to report it confidentially, you may do so via email. Please note that any reported vulnerabilities **MUST** meet all the following conditions:
* Affects the most recent stable release of NetBox, or a current beta release
* Affects a NetBox instance installed and configured per the official documentation
* Is reproducible following a prescribed set of instructions
Please note that we **DO NOT** accept reports generated by automated tooling which merely suggest that a file or file(s) _may_ be vulnerable under certain conditions, as these are most often innocuous.
If you believe that you've found a vulnerability which meets all of these conditions, please email a brief description of the suspected bug and instructions for reproduction to **security@netbox.dev**. For any security concerns regarding NetBox deployed via Docker, please see the [netbox-docker](https://github.com/netbox-community/netbox-docker) project.
### Bug Bounties
As NetBox is provided as free open source software, we do not offer any monetary compensation for vulnerability or bug reports, however your contributions are greatly appreciated.

View File

@@ -102,6 +102,10 @@ psycopg2-binary
# https://github.com/yaml/pyyaml
PyYAML
# Sentry SDK
# https://github.com/getsentry/sentry-python
sentry-sdk
# Social authentication framework
# https://github.com/python-social-auth/social-core
social-auth-core

View File

@@ -0,0 +1,46 @@
# Error Reporting
## Sentry
### Enabling Error Reporting
NetBox v3.2.3 and later support native integration with [Sentry](https://sentry.io/) for automatic error reporting. To enable this functionality, simply set `SENTRY_ENABLED` to True in `configuration.py`. Errors will be sent to a Sentry ingestor maintained by the NetBox team for analysis.
```python
SENTRY_ENABLED = True
```
### Using a Custom DSN
If you prefer instead to use your own Sentry ingestor, you'll need to first create a new project under your Sentry account to represent your NetBox deployment and obtain its corresponding data source name (DSN). This looks like a URL similar to the example below:
```
https://examplePublicKey@o0.ingest.sentry.io/0
```
Once you have obtained a DSN, configure Sentry in NetBox's `configuration.py` file with the following parameters:
```python
SENTRY_ENABLED = True
SENTRY_DSN = "https://examplePublicKey@o0.ingest.sentry.io/0"
```
### Assigning Tags
You can optionally attach one or more arbitrary tags to the outgoing error reports if desired by setting the `SENTRY_TAGS` parameter:
```python
SENTRY_TAGS = {
"custom.foo": "123",
"custom.bar": "abc",
}
```
!!! warning "Reserved tag prefixes"
Avoid using any tag names which begin with `netbox.`, as this prefix is reserved by the NetBox application.
### Testing
Once the configuration has been saved, restart the NetBox service.
To test Sentry operation, try generating a 404 (page not found) error by navigating to an invalid URL, such as `https://netbox/404-error-testing`. (Be sure that debug mode has been disabled.) After receiving a 404 response from the NetBox server, you should see the issue appear shortly in Sentry.

View File

@@ -0,0 +1,54 @@
# Error Reporting Settings
## SENTRY_DSN
Default: None
Defines a Sentry data source name (DSN) for automated error reporting. `SENTRY_ENABLED` must be True for this parameter to take effect. For example:
```
SENTRY_DSN = "https://examplePublicKey@o0.ingest.sentry.io/0"
```
---
## SENTRY_ENABLED
Default: False
Set to True to enable automatic error reporting via [Sentry](https://sentry.io/).
---
## SENTRY_SAMPLE_RATE
Default: 1.0 (all)
The sampling rate for errors. Must be a value between 0 (disabled) and 1.0 (report on all errors).
---
## SENTRY_TAGS
An optional dictionary of tag names and values to apply to Sentry error reports.For example:
```
SENTRY_TAGS = {
"custom.foo": "123",
"custom.bar": "abc",
}
```
!!! warning "Reserved tag prefixes"
Avoid using any tag names which begin with `netbox.`, as this prefix is reserved by the NetBox application.
---
## SENTRY_TRACES_SAMPLE_RATE
Default: 0 (disabled)
The sampling rate for transactions. Must be a value between 0 (disabled) and 1.0 (report on all transactions).
!!! warning "Consider performance implications"
A high sampling rate for transactions can induce significant performance penalties. If transaction reporting is desired, it is recommended to use a relatively low sample rate of 10% to 20% (0.1 to 0.2).

View File

@@ -1,5 +1,33 @@
# NetBox v3.2
## v3.2.3 (2022-05-12)
### Enhancements
* [#8805](https://github.com/netbox-community/netbox/issues/8805) - Add "mixed" option for device airflow indication
* [#8894](https://github.com/netbox-community/netbox/issues/8894) - Include full names when listing users
* [#8998](https://github.com/netbox-community/netbox/issues/8998) - Enable filtering racks & reservations by site group
* [#9122](https://github.com/netbox-community/netbox/issues/9122) - Introduce `clearcache` management command & clear cache during upgrade
* [#9221](https://github.com/netbox-community/netbox/issues/9221) - Add definition list support for Markdown
* [#9260](https://github.com/netbox-community/netbox/issues/9260) - Apply user preferences to tables under object detail views
* [#9278](https://github.com/netbox-community/netbox/issues/9278) - Linkify device types count under manufacturers list
* [#9280](https://github.com/netbox-community/netbox/issues/9280) - Allow adopting existing components when installing a module
* [#9314](https://github.com/netbox-community/netbox/issues/9314) - Add device and VM filters for FHRP group assignments
* [#9340](https://github.com/netbox-community/netbox/issues/9340) - Introduce support for error reporting via Sentry
* [#9343](https://github.com/netbox-community/netbox/issues/9343) - Add Ubiquiti SmartPower power outlet type
### Bug Fixes
* [#9190](https://github.com/netbox-community/netbox/issues/9190) - Prevent exception when attempting to instantiate module components which already exist on the parent device
* [#9267](https://github.com/netbox-community/netbox/issues/9267) - Remove invalid entry in IP address role choices
* [#9296](https://github.com/netbox-community/netbox/issues/9296) - Improve Markdown link sanitization
* [#9306](https://github.com/netbox-community/netbox/issues/9306) - Include VC master interfaces when selecting a LAG/bridge for a VC member interface
* [#9311](https://github.com/netbox-community/netbox/issues/9311) - Permit creating contact assignment without a priority via the REST API
* [#9313](https://github.com/netbox-community/netbox/issues/9313) - Remove HTML code from CSV output of many-to-many relationships
* [#9330](https://github.com/netbox-community/netbox/issues/9330) - Add missing `module_type` field to REST API serializers for modular device component templates
---
## v3.2.2 (2022-04-28)
### Enhancements

View File

@@ -73,6 +73,7 @@ nav:
- Required Settings: 'configuration/required-settings.md'
- Optional Settings: 'configuration/optional-settings.md'
- Dynamic Settings: 'configuration/dynamic-settings.md'
- Error Reporting: 'configuration/error-reporting.md'
- Remote Authentication: 'configuration/remote-authentication.md'
- Core Functionality:
- IP Address Management: 'core-functionality/ipam.md'
@@ -123,6 +124,7 @@ nav:
- Microsoft Azure AD: 'administration/authentication/microsoft-azure-ad.md'
- Okta: 'administration/authentication/okta.md'
- Permissions: 'administration/permissions.md'
- Error Reporting: 'administration/error-reporting.md'
- Housekeeping: 'administration/housekeeping.md'
- Replicating NetBox: 'administration/replicating-netbox.md'
- NetBox Shell: 'administration/netbox-shell.md'

View File

@@ -59,7 +59,7 @@ class CircuitTable(NetBoxTable):
)
commit_rate = CommitRateColumn()
comments = columns.MarkdownColumn()
contacts = tables.ManyToManyColumn(
contacts = columns.ManyToManyColumn(
linkify_item=True
)
tags = columns.TagColumn(

View File

@@ -14,7 +14,7 @@ class ProviderTable(NetBoxTable):
name = tables.Column(
linkify=True
)
asns = tables.ManyToManyColumn(
asns = columns.ManyToManyColumn(
linkify_item=True,
verbose_name='ASNs'
)
@@ -31,7 +31,7 @@ class ProviderTable(NetBoxTable):
verbose_name='Circuits'
)
comments = columns.MarkdownColumn()
contacts = tables.ManyToManyColumn(
contacts = columns.ManyToManyColumn(
linkify_item=True
)
tags = columns.TagColumn(

View File

@@ -32,7 +32,7 @@ class ProviderView(generic.ObjectView):
).prefetch_related(
'type', 'tenant', 'terminations__site'
)
circuits_table = tables.CircuitTable(circuits, exclude=('provider',))
circuits_table = tables.CircuitTable(circuits, user=request.user, exclude=('provider',))
circuits_table.configure(request)
return {
@@ -93,7 +93,7 @@ class ProviderNetworkView(generic.ObjectView):
).prefetch_related(
'type', 'tenant', 'terminations__site'
)
circuits_table = tables.CircuitTable(circuits)
circuits_table = tables.CircuitTable(circuits, user=request.user)
circuits_table.configure(request)
return {
@@ -147,7 +147,7 @@ class CircuitTypeView(generic.ObjectView):
def get_extra_context(self, request, instance):
circuits = Circuit.objects.restrict(request.user, 'view').filter(type=instance)
circuits_table = tables.CircuitTable(circuits, exclude=('type',))
circuits_table = tables.CircuitTable(circuits, user=request.user, exclude=('type',))
circuits_table.configure(request)
return {

View File

@@ -315,7 +315,16 @@ class ModuleTypeSerializer(NetBoxModelSerializer):
class ConsolePortTemplateSerializer(ValidatedModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:consoleporttemplate-detail')
device_type = NestedDeviceTypeSerializer()
device_type = NestedDeviceTypeSerializer(
required=False,
allow_null=True,
default=None
)
module_type = NestedModuleTypeSerializer(
required=False,
allow_null=True,
default=None
)
type = ChoiceField(
choices=ConsolePortTypeChoices,
allow_blank=True,
@@ -325,13 +334,23 @@ class ConsolePortTemplateSerializer(ValidatedModelSerializer):
class Meta:
model = ConsolePortTemplate
fields = [
'id', 'url', 'display', 'device_type', 'name', 'label', 'type', 'description', 'created', 'last_updated',
'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'description', 'created',
'last_updated',
]
class ConsoleServerPortTemplateSerializer(ValidatedModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:consoleserverporttemplate-detail')
device_type = NestedDeviceTypeSerializer()
device_type = NestedDeviceTypeSerializer(
required=False,
allow_null=True,
default=None
)
module_type = NestedModuleTypeSerializer(
required=False,
allow_null=True,
default=None
)
type = ChoiceField(
choices=ConsolePortTypeChoices,
allow_blank=True,
@@ -341,13 +360,23 @@ class ConsoleServerPortTemplateSerializer(ValidatedModelSerializer):
class Meta:
model = ConsoleServerPortTemplate
fields = [
'id', 'url', 'display', 'device_type', 'name', 'label', 'type', 'description', 'created', 'last_updated',
'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'description', 'created',
'last_updated',
]
class PowerPortTemplateSerializer(ValidatedModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:powerporttemplate-detail')
device_type = NestedDeviceTypeSerializer()
device_type = NestedDeviceTypeSerializer(
required=False,
allow_null=True,
default=None
)
module_type = NestedModuleTypeSerializer(
required=False,
allow_null=True,
default=None
)
type = ChoiceField(
choices=PowerPortTypeChoices,
allow_blank=True,
@@ -357,14 +386,23 @@ class PowerPortTemplateSerializer(ValidatedModelSerializer):
class Meta:
model = PowerPortTemplate
fields = [
'id', 'url', 'display', 'device_type', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw',
'description', 'created', 'last_updated',
'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'maximum_draw',
'allocated_draw', 'description', 'created', 'last_updated',
]
class PowerOutletTemplateSerializer(ValidatedModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:poweroutlettemplate-detail')
device_type = NestedDeviceTypeSerializer()
device_type = NestedDeviceTypeSerializer(
required=False,
allow_null=True,
default=None
)
module_type = NestedModuleTypeSerializer(
required=False,
allow_null=True,
default=None
)
type = ChoiceField(
choices=PowerOutletTypeChoices,
allow_blank=True,
@@ -383,48 +421,75 @@ class PowerOutletTemplateSerializer(ValidatedModelSerializer):
class Meta:
model = PowerOutletTemplate
fields = [
'id', 'url', 'display', 'device_type', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description',
'created', 'last_updated',
'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'power_port', 'feed_leg',
'description', 'created', 'last_updated',
]
class InterfaceTemplateSerializer(ValidatedModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:interfacetemplate-detail')
device_type = NestedDeviceTypeSerializer()
device_type = NestedDeviceTypeSerializer(
required=False,
allow_null=True,
default=None
)
module_type = NestedModuleTypeSerializer(
required=False,
allow_null=True,
default=None
)
type = ChoiceField(choices=InterfaceTypeChoices)
class Meta:
model = InterfaceTemplate
fields = [
'id', 'url', 'display', 'device_type', 'name', 'label', 'type', 'mgmt_only', 'description', 'created',
'last_updated',
'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'mgmt_only', 'description',
'created', 'last_updated',
]
class RearPortTemplateSerializer(ValidatedModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rearporttemplate-detail')
device_type = NestedDeviceTypeSerializer()
device_type = NestedDeviceTypeSerializer(
required=False,
allow_null=True,
default=None
)
module_type = NestedModuleTypeSerializer(
required=False,
allow_null=True,
default=None
)
type = ChoiceField(choices=PortTypeChoices)
class Meta:
model = RearPortTemplate
fields = [
'id', 'url', 'display', 'device_type', 'name', 'label', 'type', 'color', 'positions', 'description',
'created', 'last_updated',
'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'color', 'positions',
'description', 'created', 'last_updated',
]
class FrontPortTemplateSerializer(ValidatedModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:frontporttemplate-detail')
device_type = NestedDeviceTypeSerializer()
device_type = NestedDeviceTypeSerializer(
required=False,
allow_null=True,
default=None
)
module_type = NestedModuleTypeSerializer(
required=False,
allow_null=True,
default=None
)
type = ChoiceField(choices=PortTypeChoices)
rear_port = NestedRearPortTemplateSerializer()
class Meta:
model = FrontPortTemplate
fields = [
'id', 'url', 'display', 'device_type', 'name', 'label', 'type', 'color', 'rear_port', 'rear_port_position',
'description', 'created', 'last_updated',
'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'color', 'rear_port',
'rear_port_position', 'description', 'created', 'last_updated',
]

View File

@@ -159,6 +159,7 @@ class DeviceAirflowChoices(ChoiceSet):
AIRFLOW_RIGHT_TO_LEFT = 'right-to-left'
AIRFLOW_SIDE_TO_REAR = 'side-to-rear'
AIRFLOW_PASSIVE = 'passive'
AIRFLOW_MIXED = 'mixed'
CHOICES = (
(AIRFLOW_FRONT_TO_REAR, 'Front to rear'),
@@ -167,6 +168,7 @@ class DeviceAirflowChoices(ChoiceSet):
(AIRFLOW_RIGHT_TO_LEFT, 'Right to left'),
(AIRFLOW_SIDE_TO_REAR, 'Side to rear'),
(AIRFLOW_PASSIVE, 'Passive'),
(AIRFLOW_MIXED, 'Mixed'),
)
@@ -575,6 +577,7 @@ class PowerOutletTypeChoices(ChoiceSet):
TYPE_NEUTRIK_POWERCON_32A = 'neutrik-powercon-32a'
TYPE_NEUTRIK_POWERCON_TRUE1 = 'neutrik-powercon-true1'
TYPE_NEUTRIK_POWERCON_TRUE1_TOP = 'neutrik-powercon-true1-top'
TYPE_UBIQUITI_SMARTPOWER = 'ubiquiti-smartpower'
# Other
TYPE_HARDWIRED = 'hardwired'
@@ -683,6 +686,7 @@ class PowerOutletTypeChoices(ChoiceSet):
(TYPE_NEUTRIK_POWERCON_32A, 'Neutrik powerCON (32A)'),
(TYPE_NEUTRIK_POWERCON_TRUE1, 'Neutrik powerCON TRUE1'),
(TYPE_NEUTRIK_POWERCON_TRUE1_TOP, 'Neutrik powerCON TRUE1 TOP'),
(TYPE_UBIQUITI_SMARTPOWER, 'Ubiquiti SmartPower'),
)),
('Other', (
(TYPE_HARDWIRED, 'Hardwired'),

View File

@@ -62,6 +62,8 @@ POWERFEED_MAX_UTILIZATION_DEFAULT = 80 # Percentage
# Device components
#
MODULE_TOKEN = '{module}'
MODULAR_COMPONENT_TEMPLATE_MODELS = Q(
app_label='dcim',
model__in=(

View File

@@ -346,6 +346,32 @@ class RackReservationFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
to_field_name='slug',
label='Site (slug)',
)
region_id = TreeNodeMultipleChoiceFilter(
queryset=Region.objects.all(),
field_name='rack__site__region',
lookup_expr='in',
label='Region (ID)',
)
region = TreeNodeMultipleChoiceFilter(
queryset=Region.objects.all(),
field_name='rack__site__region',
lookup_expr='in',
to_field_name='slug',
label='Region (slug)',
)
site_group_id = TreeNodeMultipleChoiceFilter(
queryset=SiteGroup.objects.all(),
field_name='rack__site__group',
lookup_expr='in',
label='Site group (ID)',
)
site_group = TreeNodeMultipleChoiceFilter(
queryset=SiteGroup.objects.all(),
field_name='rack__site__group',
lookup_expr='in',
to_field_name='slug',
label='Site group (slug)',
)
location_id = TreeNodeMultipleChoiceFilter(
queryset=Location.objects.all(),
field_name='rack__location',

View File

@@ -210,7 +210,7 @@ class RackFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilte
model = Rack
fieldsets = (
(None, ('q', 'tag')),
('Location', ('region_id', 'site_id', 'location_id')),
('Location', ('region_id', 'site_group_id', 'site_id', 'location_id')),
('Function', ('status', 'role_id')),
('Hardware', ('type', 'width', 'serial', 'asset_tag')),
('Tenant', ('tenant_group_id', 'tenant_id')),
@@ -229,6 +229,11 @@ class RackFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilte
},
label=_('Site')
)
site_group_id = DynamicModelMultipleChoiceField(
queryset=SiteGroup.objects.all(),
required=False,
label=_('Site group')
)
location_id = DynamicModelMultipleChoiceField(
queryset=Location.objects.all(),
required=False,
@@ -282,7 +287,7 @@ class RackReservationFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
fieldsets = (
(None, ('q', 'tag')),
('User', ('user_id',)),
('Rack', ('region_id', 'site_id', 'location_id')),
('Rack', ('region_id', 'site_group_id', 'site_id', 'location_id')),
('Tenant', ('tenant_group_id', 'tenant_id')),
)
region_id = DynamicModelMultipleChoiceField(
@@ -298,6 +303,11 @@ class RackReservationFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
},
label=_('Site')
)
site_group_id = DynamicModelMultipleChoiceField(
queryset=SiteGroup.objects.all(),
required=False,
label=_('Site group')
)
location_id = DynamicModelMultipleChoiceField(
queryset=Location.objects.prefetch_related('site'),
required=False,

View File

@@ -633,12 +633,18 @@ class ModuleForm(NetBoxModelForm):
help_text="Automatically populate components associated with this module type"
)
adopt_components = forms.BooleanField(
required=False,
initial=False,
help_text="Adopt already existing components"
)
fieldsets = (
('Module', (
'device', 'module_bay', 'manufacturer', 'module_type', 'tags',
)),
('Hardware', (
'serial', 'asset_tag', 'replicate_components',
'serial', 'asset_tag', 'replicate_components', 'adopt_components',
)),
)
@@ -646,7 +652,7 @@ class ModuleForm(NetBoxModelForm):
model = Module
fields = [
'device', 'module_bay', 'manufacturer', 'module_type', 'serial', 'asset_tag', 'tags',
'replicate_components', 'comments',
'replicate_components', 'adopt_components', 'comments',
]
def __init__(self, *args, **kwargs):
@@ -655,6 +661,8 @@ class ModuleForm(NetBoxModelForm):
if self.instance.pk:
self.fields['replicate_components'].initial = False
self.fields['replicate_components'].disabled = True
self.fields['adopt_components'].initial = False
self.fields['adopt_components'].disabled = True
def save(self, *args, **kwargs):
@@ -662,8 +670,62 @@ class ModuleForm(NetBoxModelForm):
if self.instance.pk or not self.cleaned_data['replicate_components']:
self.instance._disable_replication = True
if self.cleaned_data['adopt_components']:
self.instance._adopt_components = True
return super().save(*args, **kwargs)
def clean(self):
super().clean()
replicate_components = self.cleaned_data.get("replicate_components")
adopt_components = self.cleaned_data.get("adopt_components")
device = self.cleaned_data['device']
module_type = self.cleaned_data['module_type']
module_bay = self.cleaned_data['module_bay']
# Bail out if we are not installing a new module or if we are not replicating components
if self.instance.pk or not replicate_components:
return
for templates, component_attribute in [
("consoleporttemplates", "consoleports"),
("consoleserverporttemplates", "consoleserverports"),
("interfacetemplates", "interfaces"),
("powerporttemplates", "powerports"),
("poweroutlettemplates", "poweroutlets"),
("rearporttemplates", "rearports"),
("frontporttemplates", "frontports")
]:
# Prefetch installed components
installed_components = {
component.name: component for component in getattr(device, component_attribute).all()
}
# Get the templates for the module type.
for template in getattr(module_type, templates).all():
# Installing modules with placeholders require that the bay has a position value
if MODULE_TOKEN in template.name and not module_bay.position:
raise forms.ValidationError(
"Cannot install module with placeholder values in a module bay with no position defined"
)
resolved_name = template.name.replace(MODULE_TOKEN, module_bay.position)
existing_item = installed_components.get(resolved_name)
# It is not possible to adopt components already belonging to a module
if adopt_components and existing_item and existing_item.module:
raise forms.ValidationError(
f"Cannot adopt {template.component_model.__name__} '{resolved_name}' as it already belongs "
f"to a module"
)
# If we are not adopting components we error if the component exists
if not adopt_components and resolved_name in installed_components:
raise forms.ValidationError(
f"{template.component_model.__name__} - {resolved_name} already exists"
)
class CableForm(TenancyForm, NetBoxModelForm):
@@ -1284,6 +1346,16 @@ class InterfaceForm(InterfaceCommonForm, NetBoxModelForm):
'rf_channel_width': "Populated by selected channel (if set)",
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Restrict LAG/bridge interface assignment by device/VC
device_id = self.data['device'] if self.is_bound else self.initial.get('device')
device = Device.objects.filter(pk=device_id).first()
if device and device.virtual_chassis and device.virtual_chassis.master:
self.fields['lag'].widget.add_query_param('device_id', device.virtual_chassis.master.pk)
self.fields['bridge'].widget.add_query_param('device_id', device.virtual_chassis.master.pk)
class FrontPortForm(NetBoxModelForm):
module = DynamicModelChoiceField(

View File

@@ -121,12 +121,12 @@ class ModularComponentTemplateModel(ComponentTemplateModel):
def resolve_name(self, module):
if module:
return self.name.replace('{module}', module.module_bay.position)
return self.name.replace(MODULE_TOKEN, module.module_bay.position)
return self.name
def resolve_label(self, module):
if module:
return self.label.replace('{module}', module.module_bay.position)
return self.label.replace(MODULE_TOKEN, module.module_bay.position)
return self.label

View File

@@ -543,7 +543,8 @@ class Interface(ModularComponentModel, BaseInterface, LinkTermination, PathEndpo
)
speed = models.PositiveIntegerField(
blank=True,
null=True
null=True,
verbose_name='Speed (Kbps)'
)
duplex = models.CharField(
max_length=50,

View File

@@ -1065,30 +1065,52 @@ class Module(NetBoxModel, ConfigContextModel):
super().save(*args, **kwargs)
# If this is a new Module and component replication has not been disabled, instantiate all its
# related components per the ModuleType definition
if is_new and not getattr(self, '_disable_replication', False):
ConsolePort.objects.bulk_create(
[x.instantiate(device=self.device, module=self) for x in self.module_type.consoleporttemplates.all()]
)
ConsoleServerPort.objects.bulk_create(
[x.instantiate(device=self.device, module=self) for x in self.module_type.consoleserverporttemplates.all()]
)
PowerPort.objects.bulk_create(
[x.instantiate(device=self.device, module=self) for x in self.module_type.powerporttemplates.all()]
)
PowerOutlet.objects.bulk_create(
[x.instantiate(device=self.device, module=self) for x in self.module_type.poweroutlettemplates.all()]
)
Interface.objects.bulk_create(
[x.instantiate(device=self.device, module=self) for x in self.module_type.interfacetemplates.all()]
)
RearPort.objects.bulk_create(
[x.instantiate(device=self.device, module=self) for x in self.module_type.rearporttemplates.all()]
)
FrontPort.objects.bulk_create(
[x.instantiate(device=self.device, module=self) for x in self.module_type.frontporttemplates.all()]
)
adopt_components = getattr(self, '_adopt_components', False)
disable_replication = getattr(self, '_disable_replication', False)
# We skip adding components if the module is being edited or
# both replication and component adoption is disabled
if not is_new or (disable_replication and not adopt_components):
return
# Iterate all component types
for templates, component_attribute, component_model in [
("consoleporttemplates", "consoleports", ConsolePort),
("consoleserverporttemplates", "consoleserverports", ConsoleServerPort),
("interfacetemplates", "interfaces", Interface),
("powerporttemplates", "powerports", PowerPort),
("poweroutlettemplates", "poweroutlets", PowerOutlet),
("rearporttemplates", "rearports", RearPort),
("frontporttemplates", "frontports", FrontPort)
]:
create_instances = []
update_instances = []
# Prefetch installed components
installed_components = {
component.name: component for component in getattr(self.device, component_attribute).filter(module__isnull=True)
}
# Get the template for the module type.
for template in getattr(self.module_type, templates).all():
template_instance = template.instantiate(device=self.device, module=self)
if adopt_components:
existing_item = installed_components.get(template_instance.name)
# Check if there's a component with the same name already
if existing_item:
# Assign it to the module
existing_item.module = self
update_instances.append(existing_item)
continue
# Only create new components if replication is enabled
if not disable_replication:
create_instances.append(template_instance)
component_model.objects.bulk_create(create_instances)
component_model.objects.bulk_update(update_instances, ['module'])
#

View File

@@ -190,7 +190,7 @@ class DeviceTable(NetBoxTable):
verbose_name='VC Priority'
)
comments = columns.MarkdownColumn()
contacts = tables.ManyToManyColumn(
contacts = columns.ManyToManyColumn(
linkify_item=True
)
tags = columns.TagColumn(

View File

@@ -31,7 +31,9 @@ class ManufacturerTable(NetBoxTable):
name = tables.Column(
linkify=True
)
devicetype_count = tables.Column(
devicetype_count = columns.LinkedCountColumn(
viewname='dcim:devicetype_list',
url_params={'manufacturer_id': 'pk'},
verbose_name='Device Types'
)
inventoryitem_count = tables.Column(
@@ -41,7 +43,7 @@ class ManufacturerTable(NetBoxTable):
verbose_name='Platforms'
)
slug = tables.Column()
contacts = tables.ManyToManyColumn(
contacts = columns.ManyToManyColumn(
linkify_item=True
)
tags = columns.TagColumn(

View File

@@ -26,7 +26,7 @@ class PowerPanelTable(NetBoxTable):
url_params={'power_panel_id': 'pk'},
verbose_name='Feeds'
)
contacts = tables.ManyToManyColumn(
contacts = columns.ManyToManyColumn(
linkify_item=True
)
tags = columns.TagColumn(

View File

@@ -69,7 +69,7 @@ class RackTable(NetBoxTable):
orderable=False,
verbose_name='Power'
)
contacts = tables.ManyToManyColumn(
contacts = columns.ManyToManyColumn(
linkify_item=True
)
tags = columns.TagColumn(

View File

@@ -26,7 +26,7 @@ class RegionTable(NetBoxTable):
url_params={'region_id': 'pk'},
verbose_name='Sites'
)
contacts = tables.ManyToManyColumn(
contacts = columns.ManyToManyColumn(
linkify_item=True
)
tags = columns.TagColumn(
@@ -55,7 +55,7 @@ class SiteGroupTable(NetBoxTable):
url_params={'group_id': 'pk'},
verbose_name='Sites'
)
contacts = tables.ManyToManyColumn(
contacts = columns.ManyToManyColumn(
linkify_item=True
)
tags = columns.TagColumn(
@@ -86,7 +86,7 @@ class SiteTable(NetBoxTable):
group = tables.Column(
linkify=True
)
asns = tables.ManyToManyColumn(
asns = columns.ManyToManyColumn(
linkify_item=True,
verbose_name='ASNs'
)
@@ -98,7 +98,7 @@ class SiteTable(NetBoxTable):
)
tenant = TenantColumn()
comments = columns.MarkdownColumn()
contacts = tables.ManyToManyColumn(
contacts = columns.ManyToManyColumn(
linkify_item=True
)
tags = columns.TagColumn(
@@ -137,7 +137,7 @@ class LocationTable(NetBoxTable):
url_params={'location_id': 'pk'},
verbose_name='Devices'
)
contacts = tables.ManyToManyColumn(
contacts = columns.ManyToManyColumn(
linkify_item=True
)
tags = columns.TagColumn(

View File

@@ -523,6 +523,9 @@ class ConsolePortTemplateTest(APIViewTestCases.APIViewTestCase):
devicetype = DeviceType.objects.create(
manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'
)
moduletype = ModuleType.objects.create(
manufacturer=manufacturer, model='Module Type 1'
)
console_port_templates = (
ConsolePortTemplate(device_type=devicetype, name='Console Port Template 1'),
@@ -541,9 +544,13 @@ class ConsolePortTemplateTest(APIViewTestCases.APIViewTestCase):
'name': 'Console Port Template 5',
},
{
'device_type': devicetype.pk,
'module_type': moduletype.pk,
'name': 'Console Port Template 6',
},
{
'module_type': moduletype.pk,
'name': 'Console Port Template 7',
},
]
@@ -560,6 +567,9 @@ class ConsoleServerPortTemplateTest(APIViewTestCases.APIViewTestCase):
devicetype = DeviceType.objects.create(
manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'
)
moduletype = ModuleType.objects.create(
manufacturer=manufacturer, model='Module Type 1'
)
console_server_port_templates = (
ConsoleServerPortTemplate(device_type=devicetype, name='Console Server Port Template 1'),
@@ -578,9 +588,13 @@ class ConsoleServerPortTemplateTest(APIViewTestCases.APIViewTestCase):
'name': 'Console Server Port Template 5',
},
{
'device_type': devicetype.pk,
'module_type': moduletype.pk,
'name': 'Console Server Port Template 6',
},
{
'module_type': moduletype.pk,
'name': 'Console Server Port Template 7',
},
]
@@ -597,6 +611,9 @@ class PowerPortTemplateTest(APIViewTestCases.APIViewTestCase):
devicetype = DeviceType.objects.create(
manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'
)
moduletype = ModuleType.objects.create(
manufacturer=manufacturer, model='Module Type 1'
)
power_port_templates = (
PowerPortTemplate(device_type=devicetype, name='Power Port Template 1'),
@@ -615,9 +632,13 @@ class PowerPortTemplateTest(APIViewTestCases.APIViewTestCase):
'name': 'Power Port Template 5',
},
{
'device_type': devicetype.pk,
'module_type': moduletype.pk,
'name': 'Power Port Template 6',
},
{
'module_type': moduletype.pk,
'name': 'Power Port Template 7',
},
]
@@ -634,6 +655,9 @@ class PowerOutletTemplateTest(APIViewTestCases.APIViewTestCase):
devicetype = DeviceType.objects.create(
manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'
)
moduletype = ModuleType.objects.create(
manufacturer=manufacturer, model='Module Type 1'
)
power_port_templates = (
PowerPortTemplate(device_type=devicetype, name='Power Port Template 1'),
@@ -664,6 +688,14 @@ class PowerOutletTemplateTest(APIViewTestCases.APIViewTestCase):
'name': 'Power Outlet Template 6',
'power_port': None,
},
{
'module_type': moduletype.pk,
'name': 'Power Outlet Template 7',
},
{
'module_type': moduletype.pk,
'name': 'Power Outlet Template 8',
},
]
@@ -680,6 +712,9 @@ class InterfaceTemplateTest(APIViewTestCases.APIViewTestCase):
devicetype = DeviceType.objects.create(
manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'
)
moduletype = ModuleType.objects.create(
manufacturer=manufacturer, model='Module Type 1'
)
interface_templates = (
InterfaceTemplate(device_type=devicetype, name='Interface Template 1', type='1000base-t'),
@@ -700,10 +735,15 @@ class InterfaceTemplateTest(APIViewTestCases.APIViewTestCase):
'type': '1000base-t',
},
{
'device_type': devicetype.pk,
'module_type': moduletype.pk,
'name': 'Interface Template 6',
'type': '1000base-t',
},
{
'module_type': moduletype.pk,
'name': 'Interface Template 7',
'type': '1000base-t',
},
]
@@ -720,14 +760,19 @@ class FrontPortTemplateTest(APIViewTestCases.APIViewTestCase):
devicetype = DeviceType.objects.create(
manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'
)
moduletype = ModuleType.objects.create(
manufacturer=manufacturer, model='Module Type 1'
)
rear_port_templates = (
RearPortTemplate(device_type=devicetype, name='Rear Port Template 1', type=PortTypeChoices.TYPE_8P8C),
RearPortTemplate(device_type=devicetype, name='Rear Port Template 2', type=PortTypeChoices.TYPE_8P8C),
RearPortTemplate(device_type=devicetype, name='Rear Port Template 3', type=PortTypeChoices.TYPE_8P8C),
RearPortTemplate(device_type=devicetype, name='Rear Port Template 4', type=PortTypeChoices.TYPE_8P8C),
RearPortTemplate(device_type=devicetype, name='Rear Port Template 5', type=PortTypeChoices.TYPE_8P8C),
RearPortTemplate(device_type=devicetype, name='Rear Port Template 6', type=PortTypeChoices.TYPE_8P8C),
RearPortTemplate(module_type=moduletype, name='Rear Port Template 5', type=PortTypeChoices.TYPE_8P8C),
RearPortTemplate(module_type=moduletype, name='Rear Port Template 6', type=PortTypeChoices.TYPE_8P8C),
RearPortTemplate(module_type=moduletype, name='Rear Port Template 7', type=PortTypeChoices.TYPE_8P8C),
RearPortTemplate(module_type=moduletype, name='Rear Port Template 8', type=PortTypeChoices.TYPE_8P8C),
)
RearPortTemplate.objects.bulk_create(rear_port_templates)
@@ -745,15 +790,28 @@ class FrontPortTemplateTest(APIViewTestCases.APIViewTestCase):
rear_port=rear_port_templates[1]
),
FrontPortTemplate(
device_type=devicetype,
name='Front Port Template 3',
module_type=moduletype,
name='Front Port Template 5',
type=PortTypeChoices.TYPE_8P8C,
rear_port=rear_port_templates[2]
rear_port=rear_port_templates[4]
),
FrontPortTemplate(
module_type=moduletype,
name='Front Port Template 6',
type=PortTypeChoices.TYPE_8P8C,
rear_port=rear_port_templates[5]
),
)
FrontPortTemplate.objects.bulk_create(front_port_templates)
cls.create_data = [
{
'device_type': devicetype.pk,
'name': 'Front Port Template 3',
'type': PortTypeChoices.TYPE_8P8C,
'rear_port': rear_port_templates[2].pk,
'rear_port_position': 1,
},
{
'device_type': devicetype.pk,
'name': 'Front Port Template 4',
@@ -762,17 +820,17 @@ class FrontPortTemplateTest(APIViewTestCases.APIViewTestCase):
'rear_port_position': 1,
},
{
'device_type': devicetype.pk,
'name': 'Front Port Template 5',
'module_type': moduletype.pk,
'name': 'Front Port Template 7',
'type': PortTypeChoices.TYPE_8P8C,
'rear_port': rear_port_templates[4].pk,
'rear_port': rear_port_templates[6].pk,
'rear_port_position': 1,
},
{
'device_type': devicetype.pk,
'name': 'Front Port Template 6',
'module_type': moduletype.pk,
'name': 'Front Port Template 8',
'type': PortTypeChoices.TYPE_8P8C,
'rear_port': rear_port_templates[5].pk,
'rear_port': rear_port_templates[7].pk,
'rear_port_position': 1,
},
]
@@ -791,6 +849,9 @@ class RearPortTemplateTest(APIViewTestCases.APIViewTestCase):
devicetype = DeviceType.objects.create(
manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'
)
moduletype = ModuleType.objects.create(
manufacturer=manufacturer, model='Module Type 1'
)
rear_port_templates = (
RearPortTemplate(device_type=devicetype, name='Rear Port Template 1', type=PortTypeChoices.TYPE_8P8C),
@@ -811,10 +872,15 @@ class RearPortTemplateTest(APIViewTestCases.APIViewTestCase):
'type': PortTypeChoices.TYPE_8P8C,
},
{
'device_type': devicetype.pk,
'module_type': moduletype.pk,
'name': 'Rear Port Template 6',
'type': PortTypeChoices.TYPE_8P8C,
},
{
'module_type': moduletype.pk,
'name': 'Rear Port Template 7',
'type': PortTypeChoices.TYPE_8P8C,
},
]

View File

@@ -521,10 +521,26 @@ class RackReservationTestCase(TestCase, ChangeLoggedFilterSetTests):
@classmethod
def setUpTestData(cls):
regions = (
Region(name='Region 1', slug='region-1'),
Region(name='Region 2', slug='region-2'),
Region(name='Region 3', slug='region-3'),
)
for region in regions:
region.save()
groups = (
SiteGroup(name='Site Group 1', slug='site-group-1'),
SiteGroup(name='Site Group 2', slug='site-group-2'),
SiteGroup(name='Site Group 3', slug='site-group-3'),
)
for group in groups:
group.save()
sites = (
Site(name='Site 1', slug='site-1'),
Site(name='Site 2', slug='site-2'),
Site(name='Site 3', slug='site-3'),
Site(name='Site 1', slug='site-1', region=regions[0], group=groups[0]),
Site(name='Site 2', slug='site-2', region=regions[1], group=groups[1]),
Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2]),
)
Site.objects.bulk_create(sites)
@@ -572,6 +588,20 @@ class RackReservationTestCase(TestCase, ChangeLoggedFilterSetTests):
)
RackReservation.objects.bulk_create(reservations)
def test_region(self):
regions = Region.objects.all()[:2]
params = {'region_id': [regions[0].pk, regions[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'region': [regions[0].slug, regions[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_site_group(self):
site_groups = SiteGroup.objects.all()[:2]
params = {'site_group_id': [site_groups[0].pk, site_groups[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'site_group': [site_groups[0].slug, site_groups[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_site(self):
sites = Site.objects.all()[:2]
params = {'site_id': [sites[0].pk, sites[1].pk]}

View File

@@ -1869,6 +1869,44 @@ class ModuleTestCase(
self.assertHttpStatus(self.client.post(**request), 302)
self.assertEqual(Interface.objects.filter(device=device).count(), 5)
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
def test_module_component_adoption(self):
self.add_permissions('dcim.add_module')
interface_name = "Interface-1"
# Add an interface to the ModuleType
module_type = ModuleType.objects.first()
InterfaceTemplate(module_type=module_type, name=interface_name).save()
form_data = self.form_data.copy()
device = Device.objects.get(pk=form_data['device'])
# Create an interface to be adopted
interface = Interface(device=device, name=interface_name, type=InterfaceTypeChoices.TYPE_10GE_FIXED)
interface.save()
# Ensure that interface is created with no module
self.assertIsNone(interface.module)
# Create a module with adopted components
form_data['module_bay'] = ModuleBay.objects.filter(device=device).first()
form_data['module_type'] = module_type
form_data['replicate_components'] = False
form_data['adopt_components'] = True
request = {
'path': self._get_url('add'),
'data': post_data(form_data),
}
self.assertHttpStatus(self.client.post(**request), 302)
# Re-retrieve interface to get new module id
interface.refresh_from_db()
# Check that the Interface now has a module
self.assertIsNotNone(interface.module)
class ConsolePortTestCase(ViewTestCases.DeviceComponentViewTestCase):
model = ConsolePort

View File

@@ -166,7 +166,7 @@ class RegionView(generic.ObjectView):
sites = Site.objects.restrict(request.user, 'view').filter(
region=instance
)
sites_table = tables.SiteTable(sites, exclude=('region',))
sites_table = tables.SiteTable(sites, user=request.user, exclude=('region',))
sites_table.configure(request)
return {
@@ -251,7 +251,7 @@ class SiteGroupView(generic.ObjectView):
sites = Site.objects.restrict(request.user, 'view').filter(
group=instance
)
sites_table = tables.SiteTable(sites, exclude=('group',))
sites_table = tables.SiteTable(sites, user=request.user, exclude=('group',))
sites_table.configure(request)
return {
@@ -435,7 +435,7 @@ class LocationView(generic.ObjectView):
'rack_count',
cumulative=True
).filter(pk__in=location_ids).exclude(pk=instance.pk)
child_locations_table = tables.LocationTable(child_locations)
child_locations_table = tables.LocationTable(child_locations, user=request.user)
child_locations_table.configure(request)
nonracked_devices = Device.objects.filter(
@@ -514,7 +514,9 @@ class RackRoleView(generic.ObjectView):
role=instance
)
racks_table = tables.RackTable(racks, exclude=('role', 'get_utilization', 'get_power_utilization'))
racks_table = tables.RackTable(racks, user=request.user, exclude=(
'role', 'get_utilization', 'get_power_utilization',
))
racks_table.configure(request)
return {
@@ -767,7 +769,7 @@ class ManufacturerView(generic.ObjectView):
manufacturer=instance
)
devicetypes_table = tables.DeviceTypeTable(device_types, exclude=('manufacturer',))
devicetypes_table = tables.DeviceTypeTable(device_types, user=request.user, exclude=('manufacturer',))
devicetypes_table.configure(request)
return {
@@ -1480,7 +1482,7 @@ class DeviceRoleView(generic.ObjectView):
devices = Device.objects.restrict(request.user, 'view').filter(
device_role=instance
)
devices_table = tables.DeviceTable(devices, exclude=('device_role',))
devices_table = tables.DeviceTable(devices, user=request.user, exclude=('device_role',))
devices_table.configure(request)
return {
@@ -1544,7 +1546,7 @@ class PlatformView(generic.ObjectView):
devices = Device.objects.restrict(request.user, 'view').filter(
platform=instance
)
devices_table = tables.DeviceTable(devices, exclude=('platform',))
devices_table = tables.DeviceTable(devices, user=request.user, exclude=('platform',))
devices_table.configure(request)
return {

View File

@@ -0,0 +1,11 @@
from django.core.cache import cache
from django.core.management.base import BaseCommand
class Command(BaseCommand):
"""Command to clear the entire cache."""
help = 'Clears the cache.'
def handle(self, *args, **kwargs):
cache.clear()
self.stdout.write('Cache has been cleared.', ending="\n")

View File

@@ -91,7 +91,7 @@ class IPAddressRoleChoices(ChoiceSet):
(ROLE_VRRP, 'VRRP', 'green'),
(ROLE_HSRP, 'HSRP', 'green'),
(ROLE_GLBP, 'GLBP', 'green'),
(ROLE_CARP, 'CARP'), 'green',
(ROLE_CARP, 'CARP', 'green'),
)

View File

@@ -681,11 +681,53 @@ class FHRPGroupAssignmentFilterSet(ChangeLoggedModelFilterSet):
queryset=FHRPGroup.objects.all(),
label='Group (ID)',
)
device = MultiValueCharFilter(
method='filter_device',
field_name='name',
label='Device (name)',
)
device_id = MultiValueNumberFilter(
method='filter_device',
field_name='pk',
label='Device (ID)',
)
virtual_machine = MultiValueCharFilter(
method='filter_virtual_machine',
field_name='name',
label='Virtual machine (name)',
)
virtual_machine_id = MultiValueNumberFilter(
method='filter_virtual_machine',
field_name='pk',
label='Virtual machine (ID)',
)
class Meta:
model = FHRPGroupAssignment
fields = ['id', 'group_id', 'interface_type', 'interface_id', 'priority']
def filter_device(self, queryset, name, value):
devices = Device.objects.filter(**{f'{name}__in': value})
if not devices.exists():
return queryset.none()
interface_ids = []
for device in devices:
interface_ids.extend(device.vc_interfaces().values_list('id', flat=True))
return queryset.filter(
Q(interface_type=ContentType.objects.get_for_model(Interface), interface_id__in=interface_ids)
)
def filter_virtual_machine(self, queryset, name, value):
virtual_machines = VirtualMachine.objects.filter(**{f'{name}__in': value})
if not virtual_machines.exists():
return queryset.none()
interface_ids = []
for vm in virtual_machines:
interface_ids.extend(vm.interfaces.values_list('id', flat=True))
return queryset.filter(
Q(interface_type=ContentType.objects.get_for_model(VMInterface), interface_id__in=interface_ids)
)
class VLANGroupFilterSet(OrganizationalModelFilterSet):
scope_type = ContentTypeFilter()

View File

@@ -118,7 +118,7 @@ class ASNTable(NetBoxTable):
url_params={'asn_id': 'pk'},
verbose_name='Provider Count'
)
sites = tables.ManyToManyColumn(
sites = columns.ManyToManyColumn(
linkify_item=True,
verbose_name='Sites'
)

View File

@@ -1024,6 +1024,20 @@ class FHRPGroupAssignmentTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'priority': [10, 20]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
def test_device(self):
device = Device.objects.first()
params = {'device': [device.name]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
params = {'device_id': [device.pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
def test_virtual_machine(self):
vm = VirtualMachine.objects.first()
params = {'virtual_machine': [vm.name]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
params = {'virtual_machine_id': [vm.pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
class VLANGroupTestCase(TestCase, ChangeLoggedFilterSetTests):
queryset = VLANGroup.objects.all()

View File

@@ -161,7 +161,7 @@ class RIRView(generic.ObjectView):
aggregates = Aggregate.objects.restrict(request.user, 'view').filter(rir=instance).annotate(
child_count=RawSQL('SELECT COUNT(*) FROM ipam_prefix WHERE ipam_prefix.prefix <<= ipam_aggregate.prefix', ())
)
aggregates_table = tables.AggregateTable(aggregates, exclude=('rir', 'utilization'))
aggregates_table = tables.AggregateTable(aggregates, user=request.user, exclude=('rir', 'utilization'))
aggregates_table.configure(request)
return {
@@ -221,12 +221,12 @@ class ASNView(generic.ObjectView):
def get_extra_context(self, request, instance):
# Gather assigned Sites
sites = instance.sites.restrict(request.user, 'view')
sites_table = SiteTable(sites)
sites_table = SiteTable(sites, user=request.user)
sites_table.configure(request)
# Gather assigned Providers
providers = instance.providers.restrict(request.user, 'view')
providers_table = ProviderTable(providers)
providers_table = ProviderTable(providers, user=request.user)
providers_table.configure(request)
return {
@@ -366,7 +366,7 @@ class RoleView(generic.ObjectView):
role=instance
)
prefixes_table = tables.PrefixTable(prefixes, exclude=('role', 'utilization'))
prefixes_table = tables.PrefixTable(prefixes, user=request.user, exclude=('role', 'utilization'))
prefixes_table.configure(request)
return {
@@ -805,7 +805,7 @@ class VLANGroupView(generic.ObjectView):
vlans_count = vlans.count()
vlans = add_available_vlans(vlans, vlan_group=instance)
vlans_table = tables.VLANTable(vlans, exclude=('group',))
vlans_table = tables.VLANTable(vlans, user=request.user, exclude=('group',))
if request.user.has_perm('ipam.change_vlan') or request.user.has_perm('ipam.delete_vlan'):
vlans_table.columns.show('pk')
vlans_table.configure(request)

View File

@@ -1,3 +1,4 @@
import hashlib
import importlib
import logging
import os
@@ -8,9 +9,11 @@ import sys
import warnings
from urllib.parse import urlsplit
import sentry_sdk
from django.contrib.messages import constants as messages
from django.core.exceptions import ImproperlyConfigured, ValidationError
from django.core.validators import URLValidator
from sentry_sdk.integrations.django import DjangoIntegration
from netbox.config import PARAMS
@@ -26,7 +29,7 @@ django.utils.encoding.force_text = force_str
# Environment setup
#
VERSION = '3.2.2'
VERSION = '3.2.3'
# Hostname
HOSTNAME = platform.node()
@@ -40,6 +43,7 @@ if sys.version_info < (3, 8):
f"NetBox requires Python 3.8 or later. (Currently installed: Python {platform.python_version()})"
)
DEFAULT_SENTRY_DSN = 'https://198cf560b29d4054ab8e583a1d10ea58@o1242133.ingest.sentry.io/6396485'
#
# Configuration import
@@ -68,6 +72,9 @@ DATABASE = getattr(configuration, 'DATABASE')
REDIS = getattr(configuration, 'REDIS')
SECRET_KEY = getattr(configuration, 'SECRET_KEY')
# Calculate a unique deployment ID from the secret key
DEPLOYMENT_ID = hashlib.sha256(SECRET_KEY.encode('utf-8')).hexdigest()[:16]
# Set static config parameters
ADMINS = getattr(configuration, 'ADMINS', [])
AUTH_PASSWORD_VALIDATORS = getattr(configuration, 'AUTH_PASSWORD_VALIDATORS', [])
@@ -113,6 +120,11 @@ REMOTE_AUTH_GROUP_SEPARATOR = getattr(configuration, 'REMOTE_AUTH_GROUP_SEPARATO
REPORTS_ROOT = getattr(configuration, 'REPORTS_ROOT', os.path.join(BASE_DIR, 'reports')).rstrip('/')
RQ_DEFAULT_TIMEOUT = getattr(configuration, 'RQ_DEFAULT_TIMEOUT', 300)
SCRIPTS_ROOT = getattr(configuration, 'SCRIPTS_ROOT', os.path.join(BASE_DIR, 'scripts')).rstrip('/')
SENTRY_DSN = getattr(configuration, 'SENTRY_DSN', DEFAULT_SENTRY_DSN)
SENTRY_ENABLED = getattr(configuration, 'SENTRY_ENABLED', False)
SENTRY_SAMPLE_RATE = getattr(configuration, 'SENTRY_SAMPLE_RATE', 1.0)
SENTRY_TRACES_SAMPLE_RATE = getattr(configuration, 'SENTRY_TRACES_SAMPLE_RATE', 0)
SENTRY_TAGS = getattr(configuration, 'SENTRY_TAGS', {})
SESSION_FILE_PATH = getattr(configuration, 'SESSION_FILE_PATH', None)
SESSION_COOKIE_NAME = getattr(configuration, 'SESSION_COOKIE_NAME', 'sessionid')
SHORT_DATE_FORMAT = getattr(configuration, 'SHORT_DATE_FORMAT', 'Y-m-d')
@@ -428,6 +440,36 @@ EXEMPT_PATHS = (
)
#
# Sentry
#
if SENTRY_ENABLED:
if not SENTRY_DSN:
raise ImproperlyConfigured("SENTRY_ENABLED is True but SENTRY_DSN has not been defined.")
# If using the default DSN, force sampling rates
if SENTRY_DSN == DEFAULT_SENTRY_DSN:
SENTRY_SAMPLE_RATE = 1.0
SENTRY_TRACES_SAMPLE_RATE = 0
# Initialize the SDK
sentry_sdk.init(
dsn=SENTRY_DSN,
release=VERSION,
integrations=[DjangoIntegration()],
sample_rate=SENTRY_SAMPLE_RATE,
traces_sample_rate=SENTRY_TRACES_SAMPLE_RATE,
send_default_pii=True,
http_proxy=HTTP_PROXIES.get('http') if HTTP_PROXIES else None,
https_proxy=HTTP_PROXIES.get('https') if HTTP_PROXIES else None
)
# Assign any configured tags
for k, v in SENTRY_TAGS.items():
sentry_sdk.set_tag(k, v)
# If using the default DSN, append a unique deployment ID tag for error correlation
if SENTRY_DSN == DEFAULT_SENTRY_DSN:
sentry_sdk.set_tag('netbox.deployment_id', DEPLOYMENT_ID)
#
# Django social auth
#

View File

@@ -6,7 +6,7 @@ from django.conf import settings
from django.contrib.auth.models import AnonymousUser
from django.db.models import DateField, DateTimeField
from django.template import Context, Template
from django.urls import NoReverseMatch, reverse
from django.urls import reverse
from django.utils.formats import date_format
from django.utils.safestring import mark_safe
from django_tables2.columns import library
@@ -27,6 +27,7 @@ __all__ = (
'CustomLinkColumn',
'LinkedCountColumn',
'MarkdownColumn',
'ManyToManyColumn',
'MPTTColumn',
'TagColumn',
'TemplateColumn',
@@ -35,6 +36,10 @@ __all__ = (
)
#
# Django-tables2 overrides
#
@library.register
class DateColumn(tables.DateColumn):
"""
@@ -42,7 +47,6 @@ class DateColumn(tables.DateColumn):
tables and null when exporting data. It is registered in the tables library to use this class instead of the
default, making this behavior consistent in all fields of type DateField.
"""
def value(self, value):
return value
@@ -59,7 +63,6 @@ class DateTimeColumn(tables.DateTimeColumn):
tables and null when exporting data. It is registered in the tables library to use this class instead of the
default, making this behavior consistent in all fields of type DateTimeField.
"""
def value(self, value):
if value:
return date_format(value, format="SHORT_DATETIME_FORMAT")
@@ -71,6 +74,39 @@ class DateTimeColumn(tables.DateTimeColumn):
return cls(**kwargs)
class ManyToManyColumn(tables.ManyToManyColumn):
"""
Overrides django-tables2's stock ManyToManyColumn to ensure that value() returns only plaintext data.
"""
def value(self, value):
items = [self.transform(item) for item in self.filter(value)]
return self.separator.join(items)
class TemplateColumn(tables.TemplateColumn):
"""
Overrides django-tables2's stock TemplateColumn class to render a placeholder symbol if the returned value
is an empty string.
"""
PLACEHOLDER = mark_safe('&mdash;')
def render(self, *args, **kwargs):
ret = super().render(*args, **kwargs)
if not ret.strip():
return self.PLACEHOLDER
return ret
def value(self, **kwargs):
ret = super().value(**kwargs)
if ret == self.PLACEHOLDER:
return ''
return ret
#
# Custom columns
#
class ToggleColumn(tables.CheckBoxColumn):
"""
Extend CheckBoxColumn to add a "toggle all" checkbox in the column header.
@@ -112,26 +148,6 @@ class BooleanColumn(tables.Column):
return str(value)
class TemplateColumn(tables.TemplateColumn):
"""
Overrides django-tables2's stock TemplateColumn class to render a placeholder symbol if the returned value
is an empty string.
"""
PLACEHOLDER = mark_safe('&mdash;')
def render(self, *args, **kwargs):
ret = super().render(*args, **kwargs)
if not ret.strip():
return self.PLACEHOLDER
return ret
def value(self, **kwargs):
ret = super().value(**kwargs)
if ret == self.PLACEHOLDER:
return ''
return ret
@dataclass
class ActionsItem:
title: str

View File

@@ -100,4 +100,5 @@ urlpatterns = [
path('{}'.format(settings.BASE_PATH), include(_patterns))
]
handler404 = 'netbox.views.handler_404'
handler500 = 'netbox.views.server_error'

View File

@@ -2,7 +2,6 @@ import platform
import sys
from django.conf import settings
from django.contrib.contenttypes.models import ContentType
from django.core.cache import cache
from django.db.models import F
from django.http import HttpResponseServerError
@@ -11,9 +10,10 @@ from django.template import loader
from django.template.exceptions import TemplateDoesNotExist
from django.urls import reverse
from django.views.decorators.csrf import requires_csrf_token
from django.views.defaults import ERROR_500_TEMPLATE_NAME
from django.views.defaults import ERROR_500_TEMPLATE_NAME, page_not_found
from django.views.generic import View
from packaging import version
from sentry_sdk import capture_message
from circuits.models import Circuit, Provider
from dcim.models import (
@@ -190,13 +190,21 @@ class StaticMediaFailureView(View):
"""
Display a user-friendly error message with troubleshooting tips when a static media file fails to load.
"""
def get(self, request):
return render(request, 'media_failure.html', {
'filename': request.GET.get('filename')
})
def handler_404(request, exception):
"""
Wrap Django's default 404 handler to enable Sentry reporting.
"""
capture_message("Page not found", level="error")
return page_not_found(request, exception)
@requires_csrf_token
def server_error(request, template_name=ERROR_500_TEMPLATE_NAME):
"""

View File

@@ -43,7 +43,7 @@
<div class="float-end">
<span class="text-muted">{{ context.weight }}</span>
</div>
<strong>{{ context|linkify:"name" }}"></strong>
<strong>{{ context|linkify:"name" }}</strong>
{% if context.description %}
<br /><small>{{ context.description }}</small>
{% endif %}

View File

@@ -97,7 +97,7 @@ class ContactAssignmentSerializer(NetBoxModelSerializer):
object = serializers.SerializerMethodField(read_only=True)
contact = NestedContactSerializer()
role = NestedContactRoleSerializer(required=False, allow_null=True)
priority = ChoiceField(choices=ContactPriorityChoices, required=False)
priority = ChoiceField(choices=ContactPriorityChoices, allow_blank=True, required=False, default='')
class Meta:
model = ContactAssignment

View File

@@ -38,7 +38,7 @@ class TenantTable(NetBoxTable):
linkify=True
)
comments = columns.MarkdownColumn()
contacts = tables.ManyToManyColumn(
contacts = columns.ManyToManyColumn(
linkify_item=True
)
tags = columns.TagColumn(

View File

@@ -35,7 +35,7 @@ class TenantGroupView(generic.ObjectView):
tenants = Tenant.objects.restrict(request.user, 'view').filter(
group=instance
)
tenants_table = tables.TenantTable(tenants, exclude=('group',))
tenants_table = tables.TenantTable(tenants, user=request.user, exclude=('group',))
tenants_table.configure(request)
return {
@@ -184,7 +184,7 @@ class ContactGroupView(generic.ObjectView):
contacts = Contact.objects.restrict(request.user, 'view').filter(
group=instance
)
contacts_table = tables.ContactTable(contacts, exclude=('group',))
contacts_table = tables.ContactTable(contacts, user=request.user, exclude=('group',))
contacts_table.configure(request)
return {
@@ -250,7 +250,7 @@ class ContactRoleView(generic.ObjectView):
contact_assignments = ContactAssignment.objects.restrict(request.user, 'view').filter(
role=instance
)
contacts_table = tables.ContactAssignmentTable(contact_assignments)
contacts_table = tables.ContactAssignmentTable(contact_assignments, user=request.user)
contacts_table.columns.hide('role')
contacts_table.configure(request)
@@ -307,7 +307,7 @@ class ContactView(generic.ObjectView):
contact_assignments = ContactAssignment.objects.restrict(request.user, 'view').filter(
contact=instance
)
assignments_table = tables.ContactAssignmentTable(contact_assignments)
assignments_table = tables.ContactAssignmentTable(contact_assignments, user=request.user)
assignments_table.columns.hide('contact')
assignments_table.configure(request)

View File

@@ -28,6 +28,11 @@ class NestedUserSerializer(WritableNestedSerializer):
model = User
fields = ['id', 'url', 'display', 'username']
def get_display(self, obj):
if full_name := obj.get_full_name():
return f"{obj.username} ({full_name})"
return obj.username
class NestedTokenSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='users-api:token-detail')

View File

@@ -45,6 +45,11 @@ class UserSerializer(ValidatedModelSerializer):
return user
def get_display(self, obj):
if full_name := obj.get_full_name():
return f"{obj.username} ({full_name})"
return obj.username
class GroupSerializer(ValidatedModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='users-api:group-detail')

View File

@@ -150,15 +150,15 @@ def render_markdown(value):
value = strip_tags(value)
# Sanitize Markdown links
pattern = fr'\[([^\]]+)\]\((?!({schemes})).*:(.+)\)'
pattern = fr'\[([^\]]+)\]\(\s*(?!({schemes})).*:(.+)\)'
value = re.sub(pattern, '[\\1](\\3)', value, flags=re.IGNORECASE)
# Sanitize Markdown reference links
pattern = fr'\[(.+)\]:\s*(?!({schemes}))\w*:(.+)'
pattern = fr'\[([^\]]+)\]:\s*(?!({schemes}))\w*:(.+)'
value = re.sub(pattern, '[\\1]: \\3', value, flags=re.IGNORECASE)
# Render Markdown
html = markdown(value, extensions=['fenced_code', 'tables', StrikethroughExtension()])
html = markdown(value, extensions=['def_list', 'fenced_code', 'tables', StrikethroughExtension()])
# If the string is not empty wrap it in rendered-markdown to style tables
if html:

View File

@@ -40,7 +40,7 @@ class ClusterGroupTable(NetBoxTable):
url_params={'group_id': 'pk'},
verbose_name='Clusters'
)
contacts = tables.ManyToManyColumn(
contacts = columns.ManyToManyColumn(
linkify_item=True
)
tags = columns.TagColumn(
@@ -83,7 +83,7 @@ class ClusterTable(NetBoxTable):
verbose_name='VMs'
)
comments = columns.MarkdownColumn()
contacts = tables.ManyToManyColumn(
contacts = columns.ManyToManyColumn(
linkify_item=True
)
tags = columns.TagColumn(

View File

@@ -78,7 +78,7 @@ class VMInterfaceTable(BaseInterfaceTable):
vrf = tables.Column(
linkify=True
)
contacts = tables.ManyToManyColumn(
contacts = columns.ManyToManyColumn(
linkify_item=True
)
tags = columns.TagColumn(

View File

@@ -39,7 +39,7 @@ class ClusterTypeView(generic.ObjectView):
device_count=count_related(Device, 'cluster'),
vm_count=count_related(VirtualMachine, 'cluster')
)
clusters_table = tables.ClusterTable(clusters, exclude=('type',))
clusters_table = tables.ClusterTable(clusters, user=request.user, exclude=('type',))
clusters_table.configure(request)
return {
@@ -101,7 +101,7 @@ class ClusterGroupView(generic.ObjectView):
device_count=count_related(Device, 'cluster'),
vm_count=count_related(VirtualMachine, 'cluster')
)
clusters_table = tables.ClusterTable(clusters, exclude=('group',))
clusters_table = tables.ClusterTable(clusters, user=request.user, exclude=('group',))
clusters_table.configure(request)
return {

View File

@@ -29,7 +29,7 @@ class WirelessLANGroupView(generic.ObjectView):
wirelesslans = WirelessLAN.objects.restrict(request.user, 'view').filter(
group=instance
)
wirelesslans_table = tables.WirelessLANTable(wirelesslans, exclude=('group',))
wirelesslans_table = tables.WirelessLANTable(wirelesslans, user=request.user, exclude=('group',))
wirelesslans_table.configure(request)
return {
@@ -97,7 +97,7 @@ class WirelessLANView(generic.ObjectView):
attached_interfaces = Interface.objects.restrict(request.user, 'view').filter(
wireless_lans=instance
)
interfaces_table = tables.WirelessLANInterfacesTable(attached_interfaces)
interfaces_table = tables.WirelessLANInterfacesTable(attached_interfaces, user=request.user)
interfaces_table.configure(request)
return {

View File

@@ -1,5 +1,5 @@
Django==4.0.4
django-cors-headers==3.11.0
django-cors-headers==3.12.0
django-debug-toolbar==3.2.4
django-filter==21.1
django-graphiql-debug-toolbar==0.2.0
@@ -16,14 +16,15 @@ drf-yasg[validation]==1.20.0
graphene-django==2.15.0
gunicorn==20.1.0
Jinja2==3.1.2
Markdown==3.3.6
Markdown==3.3.7
markdown-include==0.6.0
mkdocs-material==8.2.11
mkdocs-material==8.2.14
mkdocstrings[python-legacy]==0.18.1
netaddr==0.8.0
Pillow==9.1.0
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
svgwrite==1.4.2

View File

@@ -108,6 +108,11 @@ COMMAND="python3 netbox/manage.py clearsessions"
echo "Removing expired user sessions ($COMMAND)..."
eval $COMMAND || exit 1
# Clear the cache
COMMAND="python3 netbox/manage.py clearcache"
echo "Clearing the cache ($COMMAND)..."
eval $COMMAND || exit 1
if [ -v WARN_MISSING_VENV ]; then
echo "--------------------------------------------------------------------"
echo "WARNING: No existing virtual environment was detected. A new one has"