mirror of
https://github.com/netbox-community/netbox.git
synced 2025-12-30 17:17:46 -06:00
Compare commits
96 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6c035eb13d | ||
|
|
b0a56a71bb | ||
|
|
201b9f635e | ||
|
|
f1d0d8e57a | ||
|
|
5838a9f3a0 | ||
|
|
998a392bd3 | ||
|
|
a0a87fc4c0 | ||
|
|
6c0b4c66c0 | ||
|
|
2c8a1ed69c | ||
|
|
fe899d9d7c | ||
|
|
6d3cded579 | ||
|
|
2e5a5f71ba | ||
|
|
72516c00fb | ||
|
|
d34d5869be | ||
|
|
72726c784a | ||
|
|
662b02e2d8 | ||
|
|
a9ec1a7b4e | ||
|
|
f03c5037c4 | ||
|
|
a52c68f4c2 | ||
|
|
a73dda35e8 | ||
|
|
0570203891 | ||
|
|
3b3247592e | ||
|
|
17292324a3 | ||
|
|
e5aa9d47f7 | ||
|
|
9e1d8beaf0 | ||
|
|
17fb562740 | ||
|
|
2910aaeec0 | ||
|
|
aeef12cdc0 | ||
|
|
8ad203f97a | ||
|
|
aba4e03d3b | ||
|
|
6a99b36cce | ||
|
|
f415d81049 | ||
|
|
24ff360ee0 | ||
|
|
2a4c728375 | ||
|
|
752a497218 | ||
|
|
1d4409c703 | ||
|
|
3c7c8c8776 | ||
|
|
bb2235b05e | ||
|
|
a6aec9ebac | ||
|
|
5f3695d2d0 | ||
|
|
ad12ad4a77 | ||
|
|
37903776fd | ||
|
|
c4c93ee346 | ||
|
|
72b2ab03cc | ||
|
|
4cefe26f80 | ||
|
|
991950650b | ||
|
|
8cc94689d8 | ||
|
|
312d6c890e | ||
|
|
c146596564 | ||
|
|
6f5c2f1e29 | ||
|
|
1726593fb0 | ||
|
|
e8575495db | ||
|
|
cffc064a33 | ||
|
|
3dda7e2da2 | ||
|
|
22f1863475 | ||
|
|
bdb21da26e | ||
|
|
e759e123ac | ||
|
|
d858eceb38 | ||
|
|
af126fe7e3 | ||
|
|
39a9ebaeee | ||
|
|
9b4e016fe4 | ||
|
|
422ec7ecec | ||
|
|
a06a280534 | ||
|
|
1358469375 | ||
|
|
bddca8e232 | ||
|
|
e9bf6a7bc5 | ||
|
|
9c3dfdfd14 | ||
|
|
c52aa2196d | ||
|
|
81c7fe2084 | ||
|
|
0301aec409 | ||
|
|
015bc48345 | ||
|
|
da1aabdfc1 | ||
|
|
c2fe2ba56f | ||
|
|
52b18393eb | ||
|
|
b172ae65d2 | ||
|
|
eab187fb6b | ||
|
|
502a14e820 | ||
|
|
7de27c69c0 | ||
|
|
f455f91ea3 | ||
|
|
bdaefc0e4d | ||
|
|
8040804c75 | ||
|
|
7cd840610b | ||
|
|
15e91908e8 | ||
|
|
0a9ba3b2e6 | ||
|
|
535606a185 | ||
|
|
25c266e4de | ||
|
|
977ccb01f2 | ||
|
|
c2a6a1c125 | ||
|
|
f6402a8b62 | ||
|
|
30d4097fd8 | ||
|
|
3fb967b482 | ||
|
|
9f3846ec5f | ||
|
|
7b5625a722 | ||
|
|
152d5a3b9a | ||
|
|
bc2491e6b7 | ||
|
|
69a1cc8759 |
2
.github/ISSUE_TEMPLATE/bug_report.yaml
vendored
2
.github/ISSUE_TEMPLATE/bug_report.yaml
vendored
@@ -14,7 +14,7 @@ body:
|
||||
attributes:
|
||||
label: NetBox version
|
||||
description: What version of NetBox are you currently running?
|
||||
placeholder: v3.2.2
|
||||
placeholder: v3.2.4
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
|
||||
2
.github/ISSUE_TEMPLATE/feature_request.yaml
vendored
2
.github/ISSUE_TEMPLATE/feature_request.yaml
vendored
@@ -14,7 +14,7 @@ body:
|
||||
attributes:
|
||||
label: NetBox version
|
||||
description: What version of NetBox are you currently running?
|
||||
placeholder: v3.2.2
|
||||
placeholder: v3.2.4
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
|
||||
2
.github/workflows/stale.yml
vendored
2
.github/workflows/stale.yml
vendored
@@ -8,7 +8,7 @@ jobs:
|
||||
stale:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/stale@v4
|
||||
- uses: actions/stale@v5
|
||||
with:
|
||||
close-issue-message: >
|
||||
This issue has been automatically closed due to lack of activity. In an
|
||||
|
||||
@@ -60,6 +60,8 @@ The complete documentation for NetBox can be found at [docs.netbox.dev](https://
|
||||
|
||||
[](https://ns1.com/)
|
||||
<br />
|
||||
[](https://sentry.io/)
|
||||
|
||||
[](https://stellar.tech/)
|
||||
|
||||
</div>
|
||||
|
||||
31
SECURITY.md
Normal file
31
SECURITY.md
Normal 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.
|
||||
@@ -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
|
||||
|
||||
46
docs/administration/error-reporting.md
Normal file
46
docs/administration/error-reporting.md
Normal 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.
|
||||
54
docs/configuration/error-reporting.md
Normal file
54
docs/configuration/error-reporting.md
Normal 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).
|
||||
@@ -66,6 +66,14 @@ CORS_ORIGIN_WHITELIST = [
|
||||
|
||||
---
|
||||
|
||||
## CSRF_COOKIE_NAME
|
||||
|
||||
Default: `csrftoken`
|
||||
|
||||
The name of the cookie to use for the cross-site request forgery (CSRF) authentication token. See the [Django documentation](https://docs.djangoproject.com/en/stable/ref/settings/#csrf-cookie-name) for more detail.
|
||||
|
||||
---
|
||||
|
||||
## CSRF_TRUSTED_ORIGINS
|
||||
|
||||
Default: `[]`
|
||||
|
||||
@@ -85,4 +85,5 @@ The table column classes listed below are supported for use in plugins. These cl
|
||||
|
||||
::: netbox.tables.TemplateColumn
|
||||
selection:
|
||||
members: false
|
||||
members:
|
||||
- __init__
|
||||
|
||||
@@ -1,5 +1,61 @@
|
||||
# NetBox v3.2
|
||||
|
||||
## v3.2.4 (2022-05-31)
|
||||
|
||||
### Enhancements
|
||||
|
||||
* [#8374](https://github.com/netbox-community/netbox/issues/8374) - Display device type and asset tag if name is blank but asset tag is populated
|
||||
* [#8922](https://github.com/netbox-community/netbox/issues/8922) - Add service list to IP address view
|
||||
* [#9098](https://github.com/netbox-community/netbox/issues/9098) - Add "other" types for power ports/outlets, pass-through ports
|
||||
* [#9239](https://github.com/netbox-community/netbox/issues/9239) - Enable filtering by contact group for all models which support contact assignment
|
||||
* [#9277](https://github.com/netbox-community/netbox/issues/9277) - Introduce `CSRF_COOKIE_NAME` configuration parameter
|
||||
* [#9347](https://github.com/netbox-community/netbox/issues/9347) - Include services in global search
|
||||
* [#9379](https://github.com/netbox-community/netbox/issues/9379) - Redirect to virtual chassis view after adding a member device
|
||||
* [#9451](https://github.com/netbox-community/netbox/issues/9451) - Add `export_raw` argument for TemplateColumn
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#9094](https://github.com/netbox-community/netbox/issues/9094) - Fix partial address search within Prefix and Aggregate filters
|
||||
* [#9291](https://github.com/netbox-community/netbox/issues/9291) - Improve data validation for MultiObjectVar script fields
|
||||
* [#9358](https://github.com/netbox-community/netbox/issues/9358) - Annotate circuit count for providers list under ASN view
|
||||
* [#9387](https://github.com/netbox-community/netbox/issues/9387) - Ensure ActionsColumn `extra_buttons` are always displayed
|
||||
* [#9402](https://github.com/netbox-community/netbox/issues/9402) - Fix custom field population when creating a virtual chassis
|
||||
* [#9407](https://github.com/netbox-community/netbox/issues/9407) - Clean up display of prefixes values when exporting prefixes list
|
||||
* [#9420](https://github.com/netbox-community/netbox/issues/9420) - Fix custom script class inheritance
|
||||
* [#9425](https://github.com/netbox-community/netbox/issues/9425) - Fix bulk import for object and multi-object custom fields
|
||||
* [#9430](https://github.com/netbox-community/netbox/issues/9430) - Fix passing of initial form data for DynamicModelChoiceFields
|
||||
|
||||
|
||||
---
|
||||
|
||||
## v3.2.3 (2022-05-12)
|
||||
|
||||
### 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
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -23,7 +23,7 @@ class ProviderFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm):
|
||||
(None, ('q', 'tag')),
|
||||
('Location', ('region_id', 'site_group_id', 'site_id')),
|
||||
('ASN', ('asn',)),
|
||||
('Contacts', ('contact', 'contact_role')),
|
||||
('Contacts', ('contact', 'contact_role', 'contact_group')),
|
||||
)
|
||||
region_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Region.objects.all(),
|
||||
@@ -87,7 +87,7 @@ class CircuitFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFi
|
||||
('Attributes', ('type_id', 'status', 'commit_rate')),
|
||||
('Location', ('region_id', 'site_group_id', 'site_id')),
|
||||
('Tenant', ('tenant_group_id', 'tenant_id')),
|
||||
('Contacts', ('contact', 'contact_role')),
|
||||
('Contacts', ('contact', 'contact_role', 'contact_group')),
|
||||
)
|
||||
type_id = DynamicModelMultipleChoiceField(
|
||||
queryset=CircuitType.objects.all(),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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',
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -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'),
|
||||
)
|
||||
|
||||
|
||||
@@ -352,6 +354,7 @@ class PowerPortTypeChoices(ChoiceSet):
|
||||
TYPE_UBIQUITI_SMARTPOWER = 'ubiquiti-smartpower'
|
||||
# Other
|
||||
TYPE_HARDWIRED = 'hardwired'
|
||||
TYPE_OTHER = 'other'
|
||||
|
||||
CHOICES = (
|
||||
('IEC 60320', (
|
||||
@@ -469,6 +472,7 @@ class PowerPortTypeChoices(ChoiceSet):
|
||||
)),
|
||||
('Other', (
|
||||
(TYPE_HARDWIRED, 'Hardwired'),
|
||||
(TYPE_OTHER, 'Other'),
|
||||
)),
|
||||
)
|
||||
|
||||
@@ -575,8 +579,10 @@ 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'
|
||||
TYPE_OTHER = 'other'
|
||||
|
||||
CHOICES = (
|
||||
('IEC 60320', (
|
||||
@@ -683,9 +689,11 @@ 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'),
|
||||
(TYPE_OTHER, 'Other'),
|
||||
)),
|
||||
)
|
||||
|
||||
@@ -1043,6 +1051,7 @@ class PortTypeChoices(ChoiceSet):
|
||||
TYPE_URM_P2 = 'urm-p2'
|
||||
TYPE_URM_P4 = 'urm-p4'
|
||||
TYPE_URM_P8 = 'urm-p8'
|
||||
TYPE_OTHER = 'other'
|
||||
|
||||
CHOICES = (
|
||||
(
|
||||
@@ -1095,6 +1104,12 @@ class PortTypeChoices(ChoiceSet):
|
||||
(TYPE_URM_P4, 'URM-P4'),
|
||||
(TYPE_URM_P8, 'URM-P8'),
|
||||
(TYPE_SPLICE, 'Splice'),
|
||||
),
|
||||
),
|
||||
(
|
||||
'Other',
|
||||
(
|
||||
(TYPE_OTHER, 'Other'),
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
@@ -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=(
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -108,7 +108,7 @@ class RegionFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm):
|
||||
model = Region
|
||||
fieldsets = (
|
||||
(None, ('q', 'tag', 'parent_id')),
|
||||
('Contacts', ('contact', 'contact_role'))
|
||||
('Contacts', ('contact', 'contact_role', 'contact_group'))
|
||||
)
|
||||
parent_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Region.objects.all(),
|
||||
@@ -122,7 +122,7 @@ class SiteGroupFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm):
|
||||
model = SiteGroup
|
||||
fieldsets = (
|
||||
(None, ('q', 'tag', 'parent_id')),
|
||||
('Contacts', ('contact', 'contact_role'))
|
||||
('Contacts', ('contact', 'contact_role', 'contact_group'))
|
||||
)
|
||||
parent_id = DynamicModelMultipleChoiceField(
|
||||
queryset=SiteGroup.objects.all(),
|
||||
@@ -138,7 +138,7 @@ class SiteFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilte
|
||||
(None, ('q', 'tag')),
|
||||
('Attributes', ('status', 'region_id', 'group_id', 'asn_id')),
|
||||
('Tenant', ('tenant_group_id', 'tenant_id')),
|
||||
('Contacts', ('contact', 'contact_role')),
|
||||
('Contacts', ('contact', 'contact_role', 'contact_group')),
|
||||
)
|
||||
status = MultipleChoiceField(
|
||||
choices=SiteStatusChoices,
|
||||
@@ -168,7 +168,7 @@ class LocationFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelF
|
||||
(None, ('q', 'tag')),
|
||||
('Parent', ('region_id', 'site_group_id', 'site_id', 'parent_id')),
|
||||
('Tenant', ('tenant_group_id', 'tenant_id')),
|
||||
('Contacts', ('contact', 'contact_role')),
|
||||
('Contacts', ('contact', 'contact_role', 'contact_group')),
|
||||
)
|
||||
region_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Region.objects.all(),
|
||||
@@ -210,11 +210,11 @@ 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')),
|
||||
('Contacts', ('contact', 'contact_role')),
|
||||
('Contacts', ('contact', 'contact_role', 'contact_group')),
|
||||
)
|
||||
region_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Region.objects.all(),
|
||||
@@ -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,
|
||||
@@ -319,7 +329,7 @@ class ManufacturerFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm):
|
||||
model = Manufacturer
|
||||
fieldsets = (
|
||||
(None, ('q', 'tag')),
|
||||
('Contacts', ('contact', 'contact_role'))
|
||||
('Contacts', ('contact', 'contact_role', 'contact_group'))
|
||||
)
|
||||
tag = TagFilterField(model)
|
||||
|
||||
@@ -508,7 +518,7 @@ class DeviceFilterForm(
|
||||
('Operation', ('status', 'role_id', 'airflow', 'serial', 'asset_tag', 'mac_address')),
|
||||
('Hardware', ('manufacturer_id', 'device_type_id', 'platform_id')),
|
||||
('Tenant', ('tenant_group_id', 'tenant_id')),
|
||||
('Contacts', ('contact', 'contact_role')),
|
||||
('Contacts', ('contact', 'contact_role', 'contact_group')),
|
||||
('Components', (
|
||||
'console_ports', 'console_server_ports', 'power_ports', 'power_outlets', 'interfaces', 'pass_through_ports',
|
||||
)),
|
||||
@@ -778,7 +788,7 @@ class PowerPanelFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm):
|
||||
fieldsets = (
|
||||
(None, ('q', 'tag')),
|
||||
('Location', ('region_id', 'site_group_id', 'site_id', 'location_id')),
|
||||
('Contacts', ('contact', 'contact_role')),
|
||||
('Contacts', ('contact', 'contact_role', 'contact_group')),
|
||||
)
|
||||
region_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Region.objects.all(),
|
||||
@@ -1092,7 +1102,7 @@ class InventoryItemFilterForm(DeviceComponentFilterForm):
|
||||
model = InventoryItem
|
||||
fieldsets = (
|
||||
(None, ('q', 'tag')),
|
||||
('Attributes', ('name', 'label', 'manufacturer_id', 'serial', 'asset_tag', 'discovered')),
|
||||
('Attributes', ('name', 'label', 'role_id', 'manufacturer_id', 'serial', 'asset_tag', 'discovered')),
|
||||
('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'virtual_chassis_id', 'device_id')),
|
||||
)
|
||||
role_id = DynamicModelMultipleChoiceField(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -256,6 +256,8 @@ class VirtualChassisCreateForm(NetBoxModelForm):
|
||||
]
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
|
||||
if self.cleaned_data['members'] and self.cleaned_data['initial_position'] is None:
|
||||
raise forms.ValidationError({
|
||||
'initial_position': "A position must be specified for the first VC member."
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -748,8 +748,12 @@ class Device(NetBoxModel, ConfigContextModel):
|
||||
return f'{self.name} ({self.asset_tag})'
|
||||
elif self.name:
|
||||
return self.name
|
||||
elif self.virtual_chassis and self.asset_tag:
|
||||
return f'{self.virtual_chassis.name}:{self.vc_position} ({self.asset_tag})'
|
||||
elif self.virtual_chassis:
|
||||
return f'{self.virtual_chassis.name}:{self.vc_position} ({self.pk})'
|
||||
elif self.device_type and self.asset_tag:
|
||||
return f'{self.device_type.manufacturer} {self.device_type.model} ({self.asset_tag})'
|
||||
elif self.device_type:
|
||||
return f'{self.device_type.manufacturer} {self.device_type.model} ({self.pk})'
|
||||
return super().__str__()
|
||||
@@ -1065,30 +1069,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'])
|
||||
|
||||
|
||||
#
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -69,7 +69,7 @@ class RackTable(NetBoxTable):
|
||||
orderable=False,
|
||||
verbose_name='Power'
|
||||
)
|
||||
contacts = tables.ManyToManyColumn(
|
||||
contacts = columns.ManyToManyColumn(
|
||||
linkify_item=True
|
||||
)
|
||||
tags = columns.TagColumn(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -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]}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -27,6 +27,12 @@ class CustomFieldCSVForm(CSVModelForm):
|
||||
choices=CustomFieldTypeChoices,
|
||||
help_text='Field data type (e.g. text, integer, etc.)'
|
||||
)
|
||||
object_type = CSVContentTypeField(
|
||||
queryset=ContentType.objects.all(),
|
||||
limit_choices_to=FeatureQuery('custom_fields'),
|
||||
required=False,
|
||||
help_text="Object type (for object or multi-object fields)"
|
||||
)
|
||||
choices = SimpleArrayField(
|
||||
base_field=forms.CharField(),
|
||||
required=False,
|
||||
@@ -36,8 +42,9 @@ class CustomFieldCSVForm(CSVModelForm):
|
||||
class Meta:
|
||||
model = CustomField
|
||||
fields = (
|
||||
'name', 'label', 'type', 'content_types', 'required', 'description', 'weight', 'filter_logic', 'default',
|
||||
'choices', 'weight', 'validation_minimum', 'validation_maximum', 'validation_regex',
|
||||
'name', 'label', 'type', 'content_types', 'object_type', 'required', 'description', 'weight',
|
||||
'filter_logic', 'default', 'choices', 'weight', 'validation_minimum', 'validation_maximum',
|
||||
'validation_regex',
|
||||
)
|
||||
|
||||
|
||||
|
||||
11
netbox/extras/management/commands/clearcache.py
Normal file
11
netbox/extras/management/commands/clearcache.py
Normal 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")
|
||||
@@ -306,9 +306,16 @@ class BaseScript:
|
||||
@classmethod
|
||||
def _get_vars(cls):
|
||||
vars = {}
|
||||
for name, attr in cls.__dict__.items():
|
||||
if name not in vars and issubclass(attr.__class__, ScriptVariable):
|
||||
vars[name] = attr
|
||||
|
||||
# Iterate all base classes looking for ScriptVariables
|
||||
for base_class in inspect.getmro(cls):
|
||||
# When object is reached there's no reason to continue
|
||||
if base_class is object:
|
||||
break
|
||||
|
||||
for name, attr in base_class.__dict__.items():
|
||||
if name not in vars and issubclass(attr.__class__, ScriptVariable):
|
||||
vars[name] = attr
|
||||
|
||||
# Order variables according to field_order
|
||||
field_order = getattr(cls.Meta, 'field_order', None)
|
||||
|
||||
@@ -39,10 +39,11 @@ class CustomFieldTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
||||
}
|
||||
|
||||
cls.csv_data = (
|
||||
'name,label,type,content_types,weight,filter_logic,choices,validation_minimum,validation_maximum,validation_regex',
|
||||
'field4,Field 4,text,dcim.site,100,exact,,,,[a-z]{3}',
|
||||
'field5,Field 5,integer,dcim.site,100,exact,,1,100,',
|
||||
'field6,Field 6,select,dcim.site,100,exact,"A,B,C",,,',
|
||||
'name,label,type,content_types,object_type,weight,filter_logic,choices,validation_minimum,validation_maximum,validation_regex',
|
||||
'field4,Field 4,text,dcim.site,,100,exact,,,,[a-z]{3}',
|
||||
'field5,Field 5,integer,dcim.site,,100,exact,,1,100,',
|
||||
'field6,Field 6,select,dcim.site,,100,exact,"A,B,C",,,',
|
||||
'field7,Field 7,object,dcim.site,dcim.region,100,exact,,,,',
|
||||
)
|
||||
|
||||
cls.bulk_edit_data = {
|
||||
|
||||
@@ -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'),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -145,9 +145,11 @@ class AggregateFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
|
||||
if not value.strip():
|
||||
return queryset
|
||||
qs_filter = Q(description__icontains=value)
|
||||
qs_filter |= Q(prefix__contains=value.strip())
|
||||
try:
|
||||
prefix = str(netaddr.IPNetwork(value.strip()).cidr)
|
||||
qs_filter |= Q(prefix__net_contains_or_equals=prefix)
|
||||
qs_filter |= Q(prefix__contains=value.strip())
|
||||
except (AddrFormatError, ValueError):
|
||||
pass
|
||||
return queryset.filter(qs_filter)
|
||||
@@ -334,9 +336,11 @@ class PrefixFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
|
||||
if not value.strip():
|
||||
return queryset
|
||||
qs_filter = Q(description__icontains=value)
|
||||
qs_filter |= Q(prefix__contains=value.strip())
|
||||
try:
|
||||
prefix = str(netaddr.IPNetwork(value.strip()).cidr)
|
||||
qs_filter |= Q(prefix__net_contains_or_equals=prefix)
|
||||
qs_filter |= Q(prefix__contains=value.strip())
|
||||
except (AddrFormatError, ValueError):
|
||||
pass
|
||||
return queryset.filter(qs_filter)
|
||||
@@ -681,11 +685,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()
|
||||
|
||||
@@ -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'
|
||||
)
|
||||
@@ -226,8 +226,9 @@ class PrefixUtilizationColumn(columns.UtilizationColumn):
|
||||
|
||||
|
||||
class PrefixTable(NetBoxTable):
|
||||
prefix = tables.TemplateColumn(
|
||||
prefix = columns.TemplateColumn(
|
||||
template_code=PREFIX_LINK,
|
||||
export_raw=True,
|
||||
attrs={'td': {'class': 'text-nowrap'}}
|
||||
)
|
||||
prefix_flat = tables.TemplateColumn(
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -4,7 +4,7 @@ from django.db.models.expressions import RawSQL
|
||||
from django.shortcuts import get_object_or_404, redirect, render
|
||||
from django.urls import reverse
|
||||
|
||||
from circuits.models import Provider
|
||||
from circuits.models import Provider, Circuit
|
||||
from circuits.tables import ProviderTable
|
||||
from dcim.filtersets import InterfaceFilterSet
|
||||
from dcim.models import Interface, Site
|
||||
@@ -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,14 @@ 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 = instance.providers.restrict(request.user, 'view').annotate(
|
||||
count_circuits=count_related(Circuit, 'provider')
|
||||
)
|
||||
providers_table = ProviderTable(providers, user=request.user)
|
||||
providers_table.configure(request)
|
||||
|
||||
return {
|
||||
@@ -366,7 +368,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 {
|
||||
@@ -674,11 +676,14 @@ class IPAddressView(generic.ObjectView):
|
||||
related_ips_table = tables.IPAddressTable(related_ips, orderable=False)
|
||||
related_ips_table.configure(request)
|
||||
|
||||
services = Service.objects.restrict(request.user, 'view').filter(ipaddresses=instance)
|
||||
|
||||
return {
|
||||
'parent_prefixes_table': parent_prefixes_table,
|
||||
'duplicate_ips_table': duplicate_ips_table,
|
||||
'more_duplicate_ips': duplicate_ips.count() > 10,
|
||||
'related_ips_table': related_ips_table,
|
||||
'services': services,
|
||||
}
|
||||
|
||||
|
||||
@@ -805,7 +810,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)
|
||||
|
||||
@@ -202,6 +202,9 @@ RQ_DEFAULT_TIMEOUT = 300
|
||||
# this setting is derived from the installed location.
|
||||
# SCRIPTS_ROOT = '/opt/netbox/netbox/scripts'
|
||||
|
||||
# The name to use for the csrf token cookie.
|
||||
CSRF_COOKIE_NAME = 'csrftoken'
|
||||
|
||||
# The name to use for the session cookie.
|
||||
SESSION_COOKIE_NAME = 'sessionid'
|
||||
|
||||
|
||||
@@ -1,32 +1,24 @@
|
||||
from collections import OrderedDict
|
||||
from typing import Dict
|
||||
|
||||
from circuits.filtersets import CircuitFilterSet, ProviderFilterSet, ProviderNetworkFilterSet
|
||||
import circuits.filtersets
|
||||
import circuits.tables
|
||||
import dcim.filtersets
|
||||
import dcim.tables
|
||||
import ipam.filtersets
|
||||
import ipam.tables
|
||||
import tenancy.filtersets
|
||||
import tenancy.tables
|
||||
import virtualization.filtersets
|
||||
import virtualization.tables
|
||||
from circuits.models import Circuit, ProviderNetwork, Provider
|
||||
from circuits.tables import CircuitTable, ProviderNetworkTable, ProviderTable
|
||||
from dcim.filtersets import (
|
||||
CableFilterSet, DeviceFilterSet, DeviceTypeFilterSet, LocationFilterSet, ModuleFilterSet, ModuleTypeFilterSet,
|
||||
PowerFeedFilterSet, RackFilterSet, RackReservationFilterSet, SiteFilterSet, VirtualChassisFilterSet,
|
||||
)
|
||||
from dcim.models import (
|
||||
Cable, Device, DeviceType, Location, Module, ModuleType, PowerFeed, Rack, RackReservation, Site, VirtualChassis,
|
||||
)
|
||||
from dcim.tables import (
|
||||
CableTable, DeviceTable, DeviceTypeTable, LocationTable, ModuleTable, ModuleTypeTable, PowerFeedTable, RackTable,
|
||||
RackReservationTable, SiteTable, VirtualChassisTable,
|
||||
)
|
||||
from ipam.filtersets import (
|
||||
AggregateFilterSet, ASNFilterSet, IPAddressFilterSet, PrefixFilterSet, VLANFilterSet, VRFFilterSet,
|
||||
)
|
||||
from ipam.models import Aggregate, ASN, IPAddress, Prefix, VLAN, VRF
|
||||
from ipam.tables import AggregateTable, ASNTable, IPAddressTable, PrefixTable, VLANTable, VRFTable
|
||||
from tenancy.filtersets import ContactFilterSet, TenantFilterSet
|
||||
from ipam.models import Aggregate, ASN, IPAddress, Prefix, Service, VLAN, VRF
|
||||
from tenancy.models import Contact, Tenant, ContactAssignment
|
||||
from tenancy.tables import ContactTable, TenantTable
|
||||
from utilities.utils import count_related
|
||||
from virtualization.filtersets import ClusterFilterSet, VirtualMachineFilterSet
|
||||
from virtualization.models import Cluster, VirtualMachine
|
||||
from virtualization.tables import ClusterTable, VirtualMachineTable
|
||||
|
||||
SEARCH_MAX_RESULTS = 15
|
||||
|
||||
@@ -36,22 +28,22 @@ CIRCUIT_TYPES = OrderedDict(
|
||||
'queryset': Provider.objects.annotate(
|
||||
count_circuits=count_related(Circuit, 'provider')
|
||||
),
|
||||
'filterset': ProviderFilterSet,
|
||||
'table': ProviderTable,
|
||||
'filterset': circuits.filtersets.ProviderFilterSet,
|
||||
'table': circuits.tables.ProviderTable,
|
||||
'url': 'circuits:provider_list',
|
||||
}),
|
||||
('circuit', {
|
||||
'queryset': Circuit.objects.prefetch_related(
|
||||
'type', 'provider', 'tenant', 'terminations__site'
|
||||
),
|
||||
'filterset': CircuitFilterSet,
|
||||
'table': CircuitTable,
|
||||
'filterset': circuits.filtersets.CircuitFilterSet,
|
||||
'table': circuits.tables.CircuitTable,
|
||||
'url': 'circuits:circuit_list',
|
||||
}),
|
||||
('providernetwork', {
|
||||
'queryset': ProviderNetwork.objects.prefetch_related('provider'),
|
||||
'filterset': ProviderNetworkFilterSet,
|
||||
'table': ProviderNetworkTable,
|
||||
'filterset': circuits.filtersets.ProviderNetworkFilterSet,
|
||||
'table': circuits.tables.ProviderNetworkTable,
|
||||
'url': 'circuits:providernetwork_list',
|
||||
}),
|
||||
)
|
||||
@@ -62,22 +54,22 @@ DCIM_TYPES = OrderedDict(
|
||||
(
|
||||
('site', {
|
||||
'queryset': Site.objects.prefetch_related('region', 'tenant'),
|
||||
'filterset': SiteFilterSet,
|
||||
'table': SiteTable,
|
||||
'filterset': dcim.filtersets.SiteFilterSet,
|
||||
'table': dcim.tables.SiteTable,
|
||||
'url': 'dcim:site_list',
|
||||
}),
|
||||
('rack', {
|
||||
'queryset': Rack.objects.prefetch_related('site', 'location', 'tenant', 'role').annotate(
|
||||
device_count=count_related(Device, 'rack')
|
||||
),
|
||||
'filterset': RackFilterSet,
|
||||
'table': RackTable,
|
||||
'filterset': dcim.filtersets.RackFilterSet,
|
||||
'table': dcim.tables.RackTable,
|
||||
'url': 'dcim:rack_list',
|
||||
}),
|
||||
('rackreservation', {
|
||||
'queryset': RackReservation.objects.prefetch_related('site', 'rack', 'user'),
|
||||
'filterset': RackReservationFilterSet,
|
||||
'table': RackReservationTable,
|
||||
'filterset': dcim.filtersets.RackReservationFilterSet,
|
||||
'table': dcim.tables.RackReservationTable,
|
||||
'url': 'dcim:rackreservation_list',
|
||||
}),
|
||||
('location', {
|
||||
@@ -94,60 +86,60 @@ DCIM_TYPES = OrderedDict(
|
||||
'rack_count',
|
||||
cumulative=True
|
||||
).prefetch_related('site'),
|
||||
'filterset': LocationFilterSet,
|
||||
'table': LocationTable,
|
||||
'filterset': dcim.filtersets.LocationFilterSet,
|
||||
'table': dcim.tables.LocationTable,
|
||||
'url': 'dcim:location_list',
|
||||
}),
|
||||
('devicetype', {
|
||||
'queryset': DeviceType.objects.prefetch_related('manufacturer').annotate(
|
||||
instance_count=count_related(Device, 'device_type')
|
||||
),
|
||||
'filterset': DeviceTypeFilterSet,
|
||||
'table': DeviceTypeTable,
|
||||
'filterset': dcim.filtersets.DeviceTypeFilterSet,
|
||||
'table': dcim.tables.DeviceTypeTable,
|
||||
'url': 'dcim:devicetype_list',
|
||||
}),
|
||||
('device', {
|
||||
'queryset': Device.objects.prefetch_related(
|
||||
'device_type__manufacturer', 'device_role', 'tenant', 'site', 'rack', 'primary_ip4', 'primary_ip6',
|
||||
),
|
||||
'filterset': DeviceFilterSet,
|
||||
'table': DeviceTable,
|
||||
'filterset': dcim.filtersets.DeviceFilterSet,
|
||||
'table': dcim.tables.DeviceTable,
|
||||
'url': 'dcim:device_list',
|
||||
}),
|
||||
('moduletype', {
|
||||
'queryset': ModuleType.objects.prefetch_related('manufacturer').annotate(
|
||||
instance_count=count_related(Module, 'module_type')
|
||||
),
|
||||
'filterset': ModuleTypeFilterSet,
|
||||
'table': ModuleTypeTable,
|
||||
'filterset': dcim.filtersets.ModuleTypeFilterSet,
|
||||
'table': dcim.tables.ModuleTypeTable,
|
||||
'url': 'dcim:moduletype_list',
|
||||
}),
|
||||
('module', {
|
||||
'queryset': Module.objects.prefetch_related(
|
||||
'module_type__manufacturer', 'device', 'module_bay',
|
||||
),
|
||||
'filterset': ModuleFilterSet,
|
||||
'table': ModuleTable,
|
||||
'filterset': dcim.filtersets.ModuleFilterSet,
|
||||
'table': dcim.tables.ModuleTable,
|
||||
'url': 'dcim:module_list',
|
||||
}),
|
||||
('virtualchassis', {
|
||||
'queryset': VirtualChassis.objects.prefetch_related('master').annotate(
|
||||
member_count=count_related(Device, 'virtual_chassis')
|
||||
),
|
||||
'filterset': VirtualChassisFilterSet,
|
||||
'table': VirtualChassisTable,
|
||||
'filterset': dcim.filtersets.VirtualChassisFilterSet,
|
||||
'table': dcim.tables.VirtualChassisTable,
|
||||
'url': 'dcim:virtualchassis_list',
|
||||
}),
|
||||
('cable', {
|
||||
'queryset': Cable.objects.all(),
|
||||
'filterset': CableFilterSet,
|
||||
'table': CableTable,
|
||||
'filterset': dcim.filtersets.CableFilterSet,
|
||||
'table': dcim.tables.CableTable,
|
||||
'url': 'dcim:cable_list',
|
||||
}),
|
||||
('powerfeed', {
|
||||
'queryset': PowerFeed.objects.all(),
|
||||
'filterset': PowerFeedFilterSet,
|
||||
'table': PowerFeedTable,
|
||||
'filterset': dcim.filtersets.PowerFeedFilterSet,
|
||||
'table': dcim.tables.PowerFeedTable,
|
||||
'url': 'dcim:powerfeed_list',
|
||||
}),
|
||||
)
|
||||
@@ -157,40 +149,46 @@ IPAM_TYPES = OrderedDict(
|
||||
(
|
||||
('vrf', {
|
||||
'queryset': VRF.objects.prefetch_related('tenant'),
|
||||
'filterset': VRFFilterSet,
|
||||
'table': VRFTable,
|
||||
'filterset': ipam.filtersets.VRFFilterSet,
|
||||
'table': ipam.tables.VRFTable,
|
||||
'url': 'ipam:vrf_list',
|
||||
}),
|
||||
('aggregate', {
|
||||
'queryset': Aggregate.objects.prefetch_related('rir'),
|
||||
'filterset': AggregateFilterSet,
|
||||
'table': AggregateTable,
|
||||
'filterset': ipam.filtersets.AggregateFilterSet,
|
||||
'table': ipam.tables.AggregateTable,
|
||||
'url': 'ipam:aggregate_list',
|
||||
}),
|
||||
('prefix', {
|
||||
'queryset': Prefix.objects.prefetch_related('site', 'vrf__tenant', 'tenant', 'vlan', 'role'),
|
||||
'filterset': PrefixFilterSet,
|
||||
'table': PrefixTable,
|
||||
'filterset': ipam.filtersets.PrefixFilterSet,
|
||||
'table': ipam.tables.PrefixTable,
|
||||
'url': 'ipam:prefix_list',
|
||||
}),
|
||||
('ipaddress', {
|
||||
'queryset': IPAddress.objects.prefetch_related('vrf__tenant', 'tenant'),
|
||||
'filterset': IPAddressFilterSet,
|
||||
'table': IPAddressTable,
|
||||
'filterset': ipam.filtersets.IPAddressFilterSet,
|
||||
'table': ipam.tables.IPAddressTable,
|
||||
'url': 'ipam:ipaddress_list',
|
||||
}),
|
||||
('vlan', {
|
||||
'queryset': VLAN.objects.prefetch_related('site', 'group', 'tenant', 'role'),
|
||||
'filterset': VLANFilterSet,
|
||||
'table': VLANTable,
|
||||
'filterset': ipam.filtersets.VLANFilterSet,
|
||||
'table': ipam.tables.VLANTable,
|
||||
'url': 'ipam:vlan_list',
|
||||
}),
|
||||
('asn', {
|
||||
'queryset': ASN.objects.prefetch_related('rir', 'tenant'),
|
||||
'filterset': ASNFilterSet,
|
||||
'table': ASNTable,
|
||||
'filterset': ipam.filtersets.ASNFilterSet,
|
||||
'table': ipam.tables.ASNTable,
|
||||
'url': 'ipam:asn_list',
|
||||
}),
|
||||
('service', {
|
||||
'queryset': Service.objects.prefetch_related('device', 'virtual_machine'),
|
||||
'filterset': ipam.filtersets.ServiceFilterSet,
|
||||
'table': ipam.tables.ServiceTable,
|
||||
'url': 'ipam:service_list',
|
||||
}),
|
||||
)
|
||||
)
|
||||
|
||||
@@ -198,15 +196,15 @@ TENANCY_TYPES = OrderedDict(
|
||||
(
|
||||
('tenant', {
|
||||
'queryset': Tenant.objects.prefetch_related('group'),
|
||||
'filterset': TenantFilterSet,
|
||||
'table': TenantTable,
|
||||
'filterset': tenancy.filtersets.TenantFilterSet,
|
||||
'table': tenancy.tables.TenantTable,
|
||||
'url': 'tenancy:tenant_list',
|
||||
}),
|
||||
('contact', {
|
||||
'queryset': Contact.objects.prefetch_related('group', 'assignments').annotate(
|
||||
assignment_count=count_related(ContactAssignment, 'contact')),
|
||||
'filterset': ContactFilterSet,
|
||||
'table': ContactTable,
|
||||
'filterset': tenancy.filtersets.ContactFilterSet,
|
||||
'table': tenancy.tables.ContactTable,
|
||||
'url': 'tenancy:contact_list',
|
||||
}),
|
||||
)
|
||||
@@ -219,16 +217,16 @@ VIRTUALIZATION_TYPES = OrderedDict(
|
||||
device_count=count_related(Device, 'cluster'),
|
||||
vm_count=count_related(VirtualMachine, 'cluster')
|
||||
),
|
||||
'filterset': ClusterFilterSet,
|
||||
'table': ClusterTable,
|
||||
'filterset': virtualization.filtersets.ClusterFilterSet,
|
||||
'table': virtualization.tables.ClusterTable,
|
||||
'url': 'virtualization:cluster_list',
|
||||
}),
|
||||
('virtualmachine', {
|
||||
'queryset': VirtualMachine.objects.prefetch_related(
|
||||
'cluster', 'tenant', 'platform', 'primary_ip4', 'primary_ip6',
|
||||
),
|
||||
'filterset': VirtualMachineFilterSet,
|
||||
'table': VirtualMachineTable,
|
||||
'filterset': virtualization.filtersets.VirtualMachineFilterSet,
|
||||
'table': virtualization.tables.VirtualMachineTable,
|
||||
'url': 'virtualization:virtualmachine_list',
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -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.4'
|
||||
|
||||
# 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', [])
|
||||
@@ -77,6 +84,7 @@ if BASE_PATH:
|
||||
CORS_ORIGIN_ALLOW_ALL = getattr(configuration, 'CORS_ORIGIN_ALLOW_ALL', False)
|
||||
CORS_ORIGIN_REGEX_WHITELIST = getattr(configuration, 'CORS_ORIGIN_REGEX_WHITELIST', [])
|
||||
CORS_ORIGIN_WHITELIST = getattr(configuration, 'CORS_ORIGIN_WHITELIST', [])
|
||||
CSRF_COOKIE_NAME = getattr(configuration, 'CSRF_COOKIE_NAME', 'csrftoken')
|
||||
CSRF_TRUSTED_ORIGINS = getattr(configuration, 'CSRF_TRUSTED_ORIGINS', [])
|
||||
DATE_FORMAT = getattr(configuration, 'DATE_FORMAT', 'N j, Y')
|
||||
DATETIME_FORMAT = getattr(configuration, 'DATETIME_FORMAT', 'N j, Y g:i a')
|
||||
@@ -113,6 +121,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 +441,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
|
||||
#
|
||||
|
||||
@@ -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,52 @@ 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('—')
|
||||
|
||||
def __init__(self, export_raw=False, **kwargs):
|
||||
"""
|
||||
Args:
|
||||
export_raw: If true, data export returns the raw field value rather than the rendered template. (Default:
|
||||
False)
|
||||
"""
|
||||
super().__init__(**kwargs)
|
||||
self.export_raw = export_raw
|
||||
|
||||
def render(self, *args, **kwargs):
|
||||
ret = super().render(*args, **kwargs)
|
||||
if not ret.strip():
|
||||
return self.PLACEHOLDER
|
||||
return ret
|
||||
|
||||
def value(self, **kwargs):
|
||||
if self.export_raw:
|
||||
# Skip template rendering and export raw value
|
||||
return kwargs.get('value')
|
||||
|
||||
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 +161,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('—')
|
||||
|
||||
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
|
||||
@@ -176,32 +205,35 @@ class ActionsColumn(tables.Column):
|
||||
model = table.Meta.model
|
||||
request = getattr(table, 'context', {}).get('request')
|
||||
url_appendix = f'?return_url={request.path}' if request else ''
|
||||
html = ''
|
||||
|
||||
# Compile actions menu
|
||||
links = []
|
||||
user = getattr(request, 'user', AnonymousUser())
|
||||
for action, attrs in self.actions.items():
|
||||
permission = f'{model._meta.app_label}.{attrs.permission}_{model._meta.model_name}'
|
||||
if attrs.permission is None or user.has_perm(permission):
|
||||
url = reverse(get_viewname(model, action), kwargs={'pk': record.pk})
|
||||
links.append(f'<li><a class="dropdown-item" href="{url}{url_appendix}">'
|
||||
f'<i class="mdi mdi-{attrs.icon}"></i> {attrs.title}</a></li>')
|
||||
|
||||
if not links:
|
||||
return ''
|
||||
|
||||
menu = f'<span class="dropdown">' \
|
||||
f'<a class="btn btn-sm btn-secondary dropdown-toggle" href="#" type="button" data-bs-toggle="dropdown">' \
|
||||
f'<i class="mdi mdi-wrench"></i></a>' \
|
||||
f'<ul class="dropdown-menu">{"".join(links)}</ul></span>'
|
||||
links.append(
|
||||
f'<li><a class="dropdown-item" href="{url}{url_appendix}">'
|
||||
f'<i class="mdi mdi-{attrs.icon}"></i> {attrs.title}</a></li>'
|
||||
)
|
||||
if links:
|
||||
html += (
|
||||
f'<span class="dropdown">'
|
||||
f'<a class="btn btn-sm btn-secondary dropdown-toggle" href="#" type="button" data-bs-toggle="dropdown">'
|
||||
f'<i class="mdi mdi-wrench"></i></a>'
|
||||
f'<ul class="dropdown-menu">{"".join(links)}</ul></span>'
|
||||
)
|
||||
|
||||
# Render any extra buttons from template code
|
||||
if self.extra_buttons:
|
||||
template = Template(self.extra_buttons)
|
||||
context = getattr(table, "context", Context())
|
||||
context.update({'record': record})
|
||||
menu = template.render(context) + menu
|
||||
html = template.render(context) + html
|
||||
|
||||
return mark_safe(menu)
|
||||
return mark_safe(html)
|
||||
|
||||
|
||||
class ChoiceFieldColumn(tables.Column):
|
||||
|
||||
@@ -100,4 +100,5 @@ urlpatterns = [
|
||||
path('{}'.format(settings.BASE_PATH), include(_patterns))
|
||||
]
|
||||
|
||||
handler404 = 'netbox.views.handler_404'
|
||||
handler500 = 'netbox.views.server_error'
|
||||
|
||||
@@ -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):
|
||||
"""
|
||||
|
||||
@@ -15,74 +15,70 @@
|
||||
{% block content %}
|
||||
<div class="row">
|
||||
<div class="col col-md-4">
|
||||
<div class="card">
|
||||
<h5 class="card-header">
|
||||
Virtual Chassis
|
||||
</h5>
|
||||
<div class="card-body">
|
||||
<table class="table table-hover attr-table">
|
||||
<tr>
|
||||
<th scope="row">Domain</th>
|
||||
<td>{{ object.domain|placeholder }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">Master</th>
|
||||
<td>{{ object.master|linkify }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% include 'inc/panels/custom_fields.html' %}
|
||||
{% include 'inc/panels/tags.html' %}
|
||||
{% plugin_left_page object %}
|
||||
<div class="card">
|
||||
<h5 class="card-header">Virtual Chassis</h5>
|
||||
<div class="card-body">
|
||||
<table class="table table-hover attr-table">
|
||||
<tr>
|
||||
<th scope="row">Domain</th>
|
||||
<td>{{ object.domain|placeholder }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">Master</th>
|
||||
<td>{{ object.master|linkify }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% include 'inc/panels/custom_fields.html' %}
|
||||
{% include 'inc/panels/tags.html' %}
|
||||
{% plugin_left_page object %}
|
||||
</div>
|
||||
<div class="col col-md-8">
|
||||
<div class="card">
|
||||
<h5 class="card-header">
|
||||
Members
|
||||
</h5>
|
||||
<div class="card-body">
|
||||
<table class="table table-hover attr-table">
|
||||
<tr>
|
||||
<th>Device</th>
|
||||
<th>Position</th>
|
||||
<th>Master</th>
|
||||
<th>Priority</th>
|
||||
</tr>
|
||||
{% for vc_member in members %}
|
||||
<tr{% if vc_member == device %} class="info"{% endif %}>
|
||||
<td>
|
||||
{{ vc_member|linkify }}
|
||||
</td>
|
||||
<td>
|
||||
{% badge vc_member.vc_position show_empty=True %}
|
||||
</td>
|
||||
<td>
|
||||
{% if object.master == vc_member %}
|
||||
{% checkmark True %}
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{{ vc_member.vc_priority|placeholder }}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
</div>
|
||||
{% if perms.dcim.change_virtualchassis %}
|
||||
<div class="card-footer text-end noprint">
|
||||
<a href="{% url 'dcim:virtualchassis_add_member' pk=object.pk %}?site={{ object.master.site.pk }}&rack={{ object.master.rack.pk }}" class="btn btn-primary btn-sm">
|
||||
<i class="mdi mdi-plus-thick" aria-hidden="true"></i> Add Member
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="card">
|
||||
<h5 class="card-header">Members</h5>
|
||||
<div class="card-body">
|
||||
<table class="table table-hover attr-table">
|
||||
<tr>
|
||||
<th>Device</th>
|
||||
<th>Position</th>
|
||||
<th>Master</th>
|
||||
<th>Priority</th>
|
||||
</tr>
|
||||
{% for vc_member in members %}
|
||||
<tr{% if vc_member == device %} class="info"{% endif %}>
|
||||
<td>
|
||||
{{ vc_member|linkify }}
|
||||
</td>
|
||||
<td>
|
||||
{% badge vc_member.vc_position show_empty=True %}
|
||||
</td>
|
||||
<td>
|
||||
{% if object.master == vc_member %}
|
||||
{% checkmark True %}
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{{ vc_member.vc_priority|placeholder }}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
</div>
|
||||
{% plugin_right_page object %}
|
||||
{% if perms.dcim.change_virtualchassis %}
|
||||
<div class="card-footer text-end noprint">
|
||||
<a href="{% url 'dcim:virtualchassis_add_member' pk=object.pk %}?site={{ object.master.site.pk }}&rack={{ object.master.rack.pk }}&return_url={{ object.get_absolute_url }}" class="btn btn-primary btn-sm">
|
||||
<i class="mdi mdi-plus-thick" aria-hidden="true"></i> Add Member
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% plugin_right_page object %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col col-md-12">
|
||||
{% plugin_full_width_page object %}
|
||||
</div>
|
||||
<div class="col col-md-12">
|
||||
{% plugin_full_width_page object %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -128,6 +128,24 @@
|
||||
<div class="my-3">
|
||||
{% include 'inc/panel_table.html' with table=related_ips_table heading='Related IP Addresses' %}
|
||||
</div>
|
||||
<div class="card">
|
||||
<h5 class="card-header">
|
||||
Services
|
||||
</h5>
|
||||
<div class="card-body">
|
||||
{% if services %}
|
||||
<table class="table table-hover">
|
||||
{% for service in services %}
|
||||
{% include 'ipam/inc/service.html' %}
|
||||
{% endfor %}
|
||||
</table>
|
||||
{% else %}
|
||||
<div class="text-muted">
|
||||
None
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% plugin_right_page object %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -112,6 +112,12 @@ class ContactModelFilterSet(django_filters.FilterSet):
|
||||
queryset=ContactRole.objects.all(),
|
||||
label='Contact Role'
|
||||
)
|
||||
contact_group = TreeNodeMultipleChoiceFilter(
|
||||
queryset=ContactGroup.objects.all(),
|
||||
field_name='contacts__contact__group',
|
||||
lookup_expr='in',
|
||||
label='Contact group',
|
||||
)
|
||||
|
||||
|
||||
#
|
||||
|
||||
@@ -32,7 +32,7 @@ class TenantFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm):
|
||||
model = Tenant
|
||||
fieldsets = (
|
||||
(None, ('q', 'tag', 'group_id')),
|
||||
('Contacts', ('contact', 'contact_role'))
|
||||
('Contacts', ('contact', 'contact_role', 'contact_group'))
|
||||
)
|
||||
group_id = DynamicModelMultipleChoiceField(
|
||||
queryset=TenantGroup.objects.all(),
|
||||
|
||||
@@ -58,3 +58,8 @@ class ContactModelFilterForm(forms.Form):
|
||||
required=False,
|
||||
label=_('Contact Role')
|
||||
)
|
||||
contact_group = DynamicModelMultipleChoiceField(
|
||||
queryset=ContactGroup.objects.all(),
|
||||
required=False,
|
||||
label=_('Contact Group')
|
||||
)
|
||||
|
||||
@@ -38,7 +38,7 @@ class TenantTable(NetBoxTable):
|
||||
linkify=True
|
||||
)
|
||||
comments = columns.MarkdownColumn()
|
||||
contacts = tables.ManyToManyColumn(
|
||||
contacts = columns.ManyToManyColumn(
|
||||
linkify_item=True
|
||||
)
|
||||
tags = columns.TagColumn(
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -88,7 +88,12 @@ class DynamicModelChoiceMixin:
|
||||
# Modify the QuerySet of the field before we return it. Limit choices to any data already bound: Options
|
||||
# will be populated on-demand via the APISelect widget.
|
||||
data = bound_field.value()
|
||||
|
||||
if data:
|
||||
# When the field is multiple choice pass the data as a list if it's not already
|
||||
if isinstance(bound_field.field, DynamicModelMultipleChoiceField) and not type(data) is list:
|
||||
data = [data]
|
||||
|
||||
field_name = getattr(self, 'to_field_name') or 'pk'
|
||||
filter = self.filter(field_name=field_name)
|
||||
try:
|
||||
@@ -130,11 +135,12 @@ class DynamicModelMultipleChoiceField(DynamicModelChoiceMixin, forms.ModelMultip
|
||||
widget = widgets.APISelectMultiple
|
||||
|
||||
def clean(self, value):
|
||||
"""
|
||||
When null option is enabled and "None" is sent as part of a form to be submitted, it is sent as the
|
||||
string 'null'. This will check for that condition and gracefully handle the conversion to a NoneType.
|
||||
"""
|
||||
value = value or []
|
||||
|
||||
# When null option is enabled and "None" is sent as part of a form to be submitted, it is sent as the
|
||||
# string 'null'. This will check for that condition and gracefully handle the conversion to a NoneType.
|
||||
if self.null_option is not None and settings.FILTERS_NULL_CHOICE_VALUE in value:
|
||||
value = [v for v in value if v != settings.FILTERS_NULL_CHOICE_VALUE]
|
||||
return [None, *value]
|
||||
|
||||
return super().clean(value)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -29,6 +29,10 @@ class ClusterTypeFilterForm(NetBoxModelFilterSetForm):
|
||||
class ClusterGroupFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm):
|
||||
model = ClusterGroup
|
||||
tag = TagFilterField(model)
|
||||
fieldsets = (
|
||||
(None, ('q', 'tag')),
|
||||
('Contacts', ('contact', 'contact_role', 'contact_group')),
|
||||
)
|
||||
|
||||
|
||||
class ClusterFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilterSetForm):
|
||||
@@ -38,7 +42,7 @@ class ClusterFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFi
|
||||
('Attributes', ('group_id', 'type_id')),
|
||||
('Location', ('region_id', 'site_group_id', 'site_id')),
|
||||
('Tenant', ('tenant_group_id', 'tenant_id')),
|
||||
('Contacts', ('contact', 'contact_role')),
|
||||
('Contacts', ('contact', 'contact_role', 'contact_group')),
|
||||
)
|
||||
type_id = DynamicModelMultipleChoiceField(
|
||||
queryset=ClusterType.objects.all(),
|
||||
@@ -87,7 +91,7 @@ class VirtualMachineFilterForm(
|
||||
('Location', ('region_id', 'site_group_id', 'site_id')),
|
||||
('Attriubtes', ('status', 'role_id', 'platform_id', 'mac_address', 'has_primary_ip', 'local_context_data')),
|
||||
('Tenant', ('tenant_group_id', 'tenant_id')),
|
||||
('Contacts', ('contact', 'contact_role')),
|
||||
('Contacts', ('contact', 'contact_role', 'contact_group')),
|
||||
)
|
||||
cluster_group_id = DynamicModelMultipleChoiceField(
|
||||
queryset=ClusterGroup.objects.all(),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -78,7 +78,7 @@ class VMInterfaceTable(BaseInterfaceTable):
|
||||
vrf = tables.Column(
|
||||
linkify=True
|
||||
)
|
||||
contacts = tables.ManyToManyColumn(
|
||||
contacts = columns.ManyToManyColumn(
|
||||
linkify_item=True
|
||||
)
|
||||
tags = columns.TagColumn(
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
mkdocstrings[python-legacy]==0.18.1
|
||||
mkdocs-material==8.2.16
|
||||
mkdocstrings[python-legacy]==0.19.0
|
||||
netaddr==0.8.0
|
||||
Pillow==9.1.0
|
||||
Pillow==9.1.1
|
||||
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
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user