mirror of
https://github.com/netbox-community/netbox.git
synced 2025-09-08 23:20:49 -06:00
Merge in updated feature
This commit is contained in:
commit
21ca8d7d6d
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.6.5
|
||||
placeholder: v3.6.6
|
||||
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.6.5
|
||||
placeholder: v3.6.6
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
|
49
docs/features/vpn-tunnels.md
Normal file
49
docs/features/vpn-tunnels.md
Normal file
@ -0,0 +1,49 @@
|
||||
# Tunnels
|
||||
|
||||
NetBox can model private tunnels formed among virtual termination points across your network. Typical tunnel implementations include GRE, IP-in-IP, and IPSec. A tunnel may be terminated to two or more device or virtual machine interfaces.
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
Termination1[TunnelTermination]
|
||||
Termination2[TunnelTermination]
|
||||
Interface1[Interface]
|
||||
Interface2[Interface]
|
||||
Tunnel --> Termination1 & Termination2
|
||||
Termination1 --> Interface1
|
||||
Termination2 --> Interface2
|
||||
Interface1 --> Device
|
||||
Interface2 --> VirtualMachine
|
||||
|
||||
click Tunnel "../../models/vpn/tunnel/"
|
||||
click TunnelTermination1 "../../models/vpn/tunneltermination/"
|
||||
click TunnelTermination2 "../../models/vpn/tunneltermination/"
|
||||
```
|
||||
|
||||
# IPSec & IKE
|
||||
|
||||
NetBox includes robust support for modeling IPSec & IKE policies. These are used to define encryption and authentication parameters for IPSec tunnels.
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
subgraph IKEProposals[Proposals]
|
||||
IKEProposal1[IKEProposal]
|
||||
IKEProposal2[IKEProposal]
|
||||
end
|
||||
subgraph IPSecProposals[Proposals]
|
||||
IPSecProposal1[IPSecProposal]
|
||||
IPSecProposal2[IPSecProposal]
|
||||
end
|
||||
IKEProposals --> IKEPolicy
|
||||
IPSecProposals --> IPSecPolicy
|
||||
IKEPolicy & IPSecPolicy--> IPSecProfile
|
||||
IPSecProfile --> Tunnel
|
||||
|
||||
click IKEProposal1 "../../models/vpn/ikeproposal/"
|
||||
click IKEProposal2 "../../models/vpn/ikeproposal/"
|
||||
click IKEPolicy "../../models/vpn/ikepolicy/"
|
||||
click IPSecProposal1 "../../models/vpn/ipsecproposal/"
|
||||
click IPSecProposal2 "../../models/vpn/ipsecproposal/"
|
||||
click IPSecPolicy "../../models/vpn/ipsecpolicy/"
|
||||
click IPSecProfile "../../models/vpn/ipsecprofile/"
|
||||
click Tunnel "../../models/vpn/tunnel/"
|
||||
```
|
13
docs/models/virtualization/virtualdisk.md
Normal file
13
docs/models/virtualization/virtualdisk.md
Normal file
@ -0,0 +1,13 @@
|
||||
# Virtual Disks
|
||||
|
||||
A virtual disk is used to model discrete virtual hard disks assigned to [virtual machines](./virtualmachine.md).
|
||||
|
||||
## Fields
|
||||
|
||||
### Name
|
||||
|
||||
A human-friendly name that is unique to the assigned virtual machine.
|
||||
|
||||
### Size
|
||||
|
||||
The allocated disk size, in gigabytes.
|
25
docs/models/vpn/ikepolicy.md
Normal file
25
docs/models/vpn/ikepolicy.md
Normal file
@ -0,0 +1,25 @@
|
||||
# IKE Policies
|
||||
|
||||
An [Internet Key Exhcnage (IKE)](https://en.wikipedia.org/wiki/Internet_Key_Exchange) policy defines an IKE version, mode, and set of [proposals](./ikeproposal.md) to be used in IKE negotiation. These policies are referenced by [IPSec profiles](./ipsecprofile.md).
|
||||
|
||||
## Fields
|
||||
|
||||
### Name
|
||||
|
||||
The unique user-assigned name for the policy.
|
||||
|
||||
### Version
|
||||
|
||||
The IKE version employed (v1 or v2).
|
||||
|
||||
### Mode
|
||||
|
||||
The IKE mode employed (main or aggressive).
|
||||
|
||||
### Proposals
|
||||
|
||||
One or more [IKE proposals](./ikeproposal.md) supported for use by this policy.
|
||||
|
||||
### Pre-shared Key
|
||||
|
||||
A pre-shared secret key associated with this policy (optional).
|
39
docs/models/vpn/ikeproposal.md
Normal file
39
docs/models/vpn/ikeproposal.md
Normal file
@ -0,0 +1,39 @@
|
||||
# IKE Proposals
|
||||
|
||||
An [Internet Key Exhcnage (IKE)](https://en.wikipedia.org/wiki/Internet_Key_Exchange) proposal defines a set of parameters used to establish a secure bidirectional connection across an untrusted medium, such as the Internet. IKE proposals defined in NetBox can be referenced by [IKE policies](./ikepolicy.md), which are in turn employed by [IPSec profiles](./ipsecprofile.md).
|
||||
|
||||
!!! note
|
||||
Some platforms refer to IKE proposals as [ISAKMP](https://en.wikipedia.org/wiki/Internet_Security_Association_and_Key_Management_Protocol), which is a framework for authentication and key exchange which employs IKE.
|
||||
|
||||
## Fields
|
||||
|
||||
### Name
|
||||
|
||||
The unique user-assigned name for the proposal.
|
||||
|
||||
### Authentication Method
|
||||
|
||||
The strategy employed for authenticating the IKE peer. Available options are listed below.
|
||||
|
||||
| Name |
|
||||
|----------------|
|
||||
| Pre-shared key |
|
||||
| Certificate |
|
||||
| RSA signature |
|
||||
| DSA signature |
|
||||
|
||||
### Encryption Algorithm
|
||||
|
||||
The protocol employed for data encryption. Options include DES, 3DES, and various flavors of AES.
|
||||
|
||||
### Authentication Algorithm
|
||||
|
||||
The mechanism employed to ensure data integrity. Options include MD5 and SHA HMAC implementations.
|
||||
|
||||
### Group
|
||||
|
||||
The [Diffie-Hellman group](https://en.wikipedia.org/wiki/Diffie%E2%80%93Hellman_key_exchange) supported by the proposal. Group IDs are [managed by IANA](https://www.iana.org/assignments/ikev2-parameters/ikev2-parameters.xhtml#ikev2-parameters-8).
|
||||
|
||||
### SA Lifetime
|
||||
|
||||
The maximum lifetime for the IKE security association (SA), in seconds.
|
17
docs/models/vpn/ipsecpolicy.md
Normal file
17
docs/models/vpn/ipsecpolicy.md
Normal file
@ -0,0 +1,17 @@
|
||||
# IPSec Policy
|
||||
|
||||
An [IPSec](https://en.wikipedia.org/wiki/IPsec) policy defines a set of [proposals](./ikeproposal.md) to be used in the formation of IPSec tunnels. A perfect forward secrecy (PFS) group may optionally also be defined. These policies are referenced by [IPSec profiles](./ipsecprofile.md).
|
||||
|
||||
## Fields
|
||||
|
||||
### Name
|
||||
|
||||
The unique user-assigned name for the policy.
|
||||
|
||||
### Proposals
|
||||
|
||||
One or more [IPSec proposals](./ipsecproposal.md) supported for use by this policy.
|
||||
|
||||
### PFS Group
|
||||
|
||||
The [perfect forward secrecy (PFS)](https://en.wikipedia.org/wiki/Forward_secrecy) group supported by this policy (optional).
|
21
docs/models/vpn/ipsecprofile.md
Normal file
21
docs/models/vpn/ipsecprofile.md
Normal file
@ -0,0 +1,21 @@
|
||||
# IPSec Profile
|
||||
|
||||
An [IPSec](https://en.wikipedia.org/wiki/IPsec) profile defines an [IKE policy](./ikepolicy.md), [IPSec policy](./ipsecpolicy.md), and IPSec mode used for establishing an IPSec tunnel.
|
||||
|
||||
## Fields
|
||||
|
||||
### Name
|
||||
|
||||
The unique user-assigned name for the profile.
|
||||
|
||||
### Mode
|
||||
|
||||
The IPSec mode employed by the profile: Encapsulating Security Payload (ESP) or Authentication Header (AH).
|
||||
|
||||
### IKE Policy
|
||||
|
||||
The [IKE policy](./ikepolicy.md) associated with the profile.
|
||||
|
||||
### IPSec Policy
|
||||
|
||||
The [IPSec policy](./ipsecpolicy.md) associated with the profile.
|
25
docs/models/vpn/ipsecproposal.md
Normal file
25
docs/models/vpn/ipsecproposal.md
Normal file
@ -0,0 +1,25 @@
|
||||
# IPSec Proposal
|
||||
|
||||
An [IPSec](https://en.wikipedia.org/wiki/IPsec) proposal defines a set of parameters used in negotiating security associations for IPSec tunnels. IPSec proposals defined in NetBox can be referenced by [IPSec policies](./ipsecpolicy.md), which are in turn employed by [IPSec profiles](./ipsecprofile.md).
|
||||
|
||||
## Fields
|
||||
|
||||
### Name
|
||||
|
||||
The unique user-assigned name for the proposal.
|
||||
|
||||
### Encryption Algorithm
|
||||
|
||||
The protocol employed for data encryption. Options include DES, 3DES, and various flavors of AES.
|
||||
|
||||
### Authentication Algorithm
|
||||
|
||||
The mechanism employed to ensure data integrity. Options include MD5 and SHA HMAC implementations.
|
||||
|
||||
### SA Lifetime (Seconds)
|
||||
|
||||
The maximum amount of time for which the security association (SA) may be active, in seconds.
|
||||
|
||||
### SA Lifetime (Data)
|
||||
|
||||
The maximum amount of data which can be transferred within the security association (SA) before it must be rebuilt, in kilobytes.
|
36
docs/models/vpn/tunnel.md
Normal file
36
docs/models/vpn/tunnel.md
Normal file
@ -0,0 +1,36 @@
|
||||
# Tunnels
|
||||
|
||||
A tunnel represents a private virtual connection established among two or more endpoints across a shared infrastructure by employing protocol encapsulation. Common encapsulation techniques include [Generic Routing Encapsulation (GRE)](https://en.wikipedia.org/wiki/Generic_Routing_Encapsulation), [IP-in-IP](https://en.wikipedia.org/wiki/IP_in_IP), and [IPSec](https://en.wikipedia.org/wiki/IPsec). NetBox supports modeling both peer-to-peer and hub-and-spoke tunnel topologies.
|
||||
|
||||
Device and virtual machine interfaces are associated to tunnels by creating [tunnel terminations](./tunneltermination.md).
|
||||
|
||||
## Fields
|
||||
|
||||
### Name
|
||||
|
||||
A unique name assigned to the tunnel for identification.
|
||||
|
||||
### Status
|
||||
|
||||
The operational status of the tunnel. By default, the following statuses are available:
|
||||
|
||||
| Name |
|
||||
|----------------|
|
||||
| Planned |
|
||||
| Active |
|
||||
| Disabled |
|
||||
|
||||
!!! tip "Custom tunnel statuses"
|
||||
Additional tunnel statuses may be defined by setting `Tunnel.status` under the [`FIELD_CHOICES`](../../configuration/data-validation.md#field_choices) configuration parameter.
|
||||
|
||||
### Encapsulation
|
||||
|
||||
The encapsulation protocol or technique employed to effect the tunnel. NetBox supports GRE, IP-in-IP, and IPSec encapsulations.
|
||||
|
||||
### Tunnel ID
|
||||
|
||||
An optional numeric identifier for the tunnel.
|
||||
|
||||
### IPSec Profile
|
||||
|
||||
For IPSec tunnels, this is the [IPSec Profile](./ipsecprofile.md) employed to negotiate security associations.
|
30
docs/models/vpn/tunneltermination.md
Normal file
30
docs/models/vpn/tunneltermination.md
Normal file
@ -0,0 +1,30 @@
|
||||
# Tunnel Terminations
|
||||
|
||||
A tunnel termination connects a device or virtual machine interface to a [tunnel](./tunnel.md). The tunnel must be created before any terminations may be added.
|
||||
|
||||
## Fields
|
||||
|
||||
### Tunnel
|
||||
|
||||
The [tunnel](./tunnel.md) to which this termination is made.
|
||||
|
||||
### Role
|
||||
|
||||
The functional role of the attached interface. The following options are available:
|
||||
|
||||
| Name | Description |
|
||||
|-------|--------------------------------------------------|
|
||||
| Peer | An endpoint in a point-to-point or mesh topology |
|
||||
| Hub | A central point in a hub-and-spoke topology |
|
||||
| Spoke | An edge point in a hub-and-spoke topology |
|
||||
|
||||
!!! note
|
||||
Multiple hub terminations may be attached to a tunnel.
|
||||
|
||||
### Termination
|
||||
|
||||
The device or virtual machine interface terminated to the tunnel.
|
||||
|
||||
### Outside IP
|
||||
|
||||
The public or underlay IP address with which this termination is associated. This is the IP to which peers will route tunneled traffic.
|
@ -60,6 +60,10 @@ class MyModel(NetBoxModel):
|
||||
|
||||
This attribute specifies the URL at which the documentation for this model can be reached. By default, it will return `/static/docs/models/<app_label>/<model_name>/`. Plugin models can override this to return a custom URL. For example, you might direct the user to your plugin's documentation hosted on [ReadTheDocs](https://readthedocs.org/).
|
||||
|
||||
#### `_netbox_private`
|
||||
|
||||
By default, any model introduced by a plugin will appear in the list of available object types e.g. when creating a custom field or certain dashboard widgets. If your model is intended only for "behind the scenes use" and should not be exposed to end users, set `_netbox_private` to True. This will omit it from the list of general-purpose object types.
|
||||
|
||||
### Enabling Features Individually
|
||||
|
||||
If you prefer instead to enable only a subset of these features for a plugin model, NetBox provides a discrete "mix-in" class for each feature. You can subclass each of these individually when defining your model. (Your model will also need to inherit from Django's built-in `Model` class.)
|
||||
|
@ -1,6 +1,29 @@
|
||||
# NetBox v3.6
|
||||
|
||||
## v3.6.6 (FUTURE)
|
||||
## v3.6.7 (FUTURE)
|
||||
|
||||
---
|
||||
|
||||
## v3.6.6 (2023-11-29)
|
||||
|
||||
### Enhancements
|
||||
|
||||
* [#13735](https://github.com/netbox-community/netbox/issues/13735) - Show complete region hierarchy in UI for all relevant objects
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#14056](https://github.com/netbox-community/netbox/issues/14056) - Record a pre-change snapshot when bulk editing objects via CSV
|
||||
* [#14187](https://github.com/netbox-community/netbox/issues/14187) - Raise a validation error when attempting to create a duplicate script or report
|
||||
* [#14199](https://github.com/netbox-community/netbox/issues/14199) - Fix jobs list for reports with a custom name
|
||||
* [#14239](https://github.com/netbox-community/netbox/issues/14239) - Fix CustomFieldChoiceSet search filter
|
||||
* [#14242](https://github.com/netbox-community/netbox/issues/14242) - Enable export templates for contact assignments
|
||||
* [#14299](https://github.com/netbox-community/netbox/issues/14299) - Webhook timestamps should be in proper ISO 8601 format
|
||||
* [#14325](https://github.com/netbox-community/netbox/issues/14325) - Fix numeric ordering of service ports
|
||||
* [#14339](https://github.com/netbox-community/netbox/issues/14339) - Correctly hash local user password when set via REST API
|
||||
* [#14343](https://github.com/netbox-community/netbox/issues/14343) - Fix ordering ASN table by ASDOT column
|
||||
* [#14346](https://github.com/netbox-community/netbox/issues/14346) - Fix running reports via REST API
|
||||
* [#14349](https://github.com/netbox-community/netbox/issues/14349) - Fix custom validation support for remote data sources
|
||||
* [#14363](https://github.com/netbox-community/netbox/issues/14363) - Fix bulk editing of interfaces assigned to VM with no cluster
|
||||
|
||||
---
|
||||
|
||||
|
92
docs/release-notes/version-3.7.md
Normal file
92
docs/release-notes/version-3.7.md
Normal file
@ -0,0 +1,92 @@
|
||||
## v3.7-beta1 (FUTURE)
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
* The `ui_visibility` field on the [custom field model](../models/extras/customfield.md) has been replaced with two new fields: `ui_visible` and `ui_editable`. Existing values will be migrated automatically upon upgrade.
|
||||
* The `FeatureQuery` class for querying content types by model feature has been removed. Plugins should now use the new `with_feature()` manager method on NetBox's proxy model for ContentType.
|
||||
* The ConfigRevision model has been moved from `extras` to `core`. Configuration history will be retained throughout the upgrade process.
|
||||
* The L2VPN and L2VPNTermination models have been moved from the `ipam` app to the new `vpn` app. All object data will be retained however please note that the relevant API endpoints have been moved to `/api/vpn/`.
|
||||
* The `CustomFieldsMixin`, `SavedFiltersMixin`, and `TagsMixin` classes have moved from the `extras.forms.mixins` to `netbox.forms.mixins`.
|
||||
|
||||
### New Features
|
||||
|
||||
#### VPN Tunnels ([#9816](https://github.com/netbox-community/netbox/issues/9816))
|
||||
|
||||
Several new models have been introduced to enable [VPN tunnel management](../features/vpn-tunnels.md). Users can now define tunnels with two or more terminations to replicate peer-to-peer or hub-and-spoke topologies. Each termination is made to a virtual interface on a device or VM. Additionally, users can define IKE and IPSec policies which can be applied to tunnels to document encryption and authentication strategies.
|
||||
|
||||
#### Virtual Machine Disks ([#8356](https://github.com/netbox-community/netbox/issues/8356))
|
||||
|
||||
A new [VirtualDisk](../models/virtualization/virtualdisk.md) model has been introduced to enable tracking the assignment of discrete virtual disks to virtual machines. The original `size` field has been retained on the VirtualMachine model, and will be automatically updated with the aggregate size of all assigned virtual disks. (Users who opt to eschew the new model may continue using the VirtualMachine `size` attribute as before.)
|
||||
|
||||
#### Protection Rules ([#10244](https://github.com/netbox-community/netbox/issues/10244))
|
||||
|
||||
A new [`PROTECTION_RULES`](../configuration/data-validation.md#protection_rules) configuration parameter is now available. Similar to how [custom validation rules](../customization/custom-validation.md) can be used to enforce certain values for object attributes, protection rules guard against the deletion of objects which do not meet specified criteria. This enables an administrator to prevent, for example, the deletion of a site which has a status of "active."
|
||||
|
||||
#### Improved Custom Field Visibility Controls ([#13299](https://github.com/netbox-community/netbox/issues/13299))
|
||||
|
||||
The old `ui_visible` field on the custom field model](../models/extras/customfield.md) has been replaced by two new fields, `ui_visible` and `ui_editable`, which control how and whether a custom field is displayed when view and editing an object, respectively. Separating these two functions into discrete fields enables more control over how each custom field is presented to users. The values of these fields will be appropriately set automatically during the upgrade process depending on the value of the original field.
|
||||
|
||||
#### Extend Display of Global Search Results ([#14134](https://github.com/netbox-community/netbox/issues/14134))
|
||||
|
||||
Global search results now include additional context about each object, such as a description, status, and/or related objects. The set of attributes to be displayed is specific to each object type, and is defined by setting `display_attrs` under the object's [SearchIndex class](../plugins/development/search.md#netbox.search.SearchIndex).
|
||||
|
||||
#### Table Column Registration for Plugins ([#14173](https://github.com/netbox-community/netbox/issues/14173))
|
||||
|
||||
Plugins can now [register their own custom columns](../plugins/development/tables.md#extending-core-tables) for inclusion on core NetBox tables. For example, a plugin can register a new column on SiteTable using the new `register_table_column()` utility function, and it will become available for users to select for display.
|
||||
|
||||
#### Data Backend Registration for Plugins ([#13381](https://github.com/netbox-community/netbox/issues/13381))
|
||||
|
||||
Plugins can now [register their own data backends](../plugins/development/data-backends.md) for use with [synchronized data sources](../features/synchronized-data.md). This enables plugins to introduce new backends in addition to the git, S3, and local path backends provided natively.
|
||||
|
||||
### Enhancements
|
||||
|
||||
* [#12135](https://github.com/netbox-community/netbox/issues/12135) - Avoid orphaned interfaces by preventing the deletion of interfaces which have children assigned
|
||||
* [#12216](https://github.com/netbox-community/netbox/issues/12216) - Add a `color` field for circuit types
|
||||
* [#13230](https://github.com/netbox-community/netbox/issues/13230) - Allow device types to be excluded from consideration when calculating a rack's utilization
|
||||
* [#13334](https://github.com/netbox-community/netbox/issues/13334) - Added an `error` field to the Job model to record any errors associated with its execution
|
||||
* [#13427](https://github.com/netbox-community/netbox/issues/13427) - Introduced a mechanism for omitting models from general-purpose lists of object types
|
||||
* [#13690](https://github.com/netbox-community/netbox/issues/13690) - Display any dependent objects to be deleted prior to deleting an object via the web UI
|
||||
* [#13794](https://github.com/netbox-community/netbox/issues/13794) - Any models with a relationship to Tenant are now included automatically in the list of related objects under the tenant view
|
||||
* [#13808](https://github.com/netbox-community/netbox/issues/13808) - Added a `/render-config` REST API endpoint for virtual machines
|
||||
* [#14035](https://github.com/netbox-community/netbox/issues/14035) - Order objects of equivalent weight by value in global search results to improve readability
|
||||
* [#14156](https://github.com/netbox-community/netbox/issues/14156) - Enable custom fields for contact assignments
|
||||
|
||||
### Other Changes
|
||||
|
||||
* [#13550](https://github.com/netbox-community/netbox/issues/13550) - Optimized the format for declaring view actions under `ActionsMixin` (backward compatibility has been retained)
|
||||
* [#13645](https://github.com/netbox-community/netbox/issues/13645) - Installation of the `sentry-sdk` Python library is now required only if Sentry reporting is enabled
|
||||
* [#14036](https://github.com/netbox-community/netbox/issues/14036) - Move plugin resources from the `extras` app into `netbox` (backward compatibility has been retained)
|
||||
* [#14153](https://github.com/netbox-community/netbox/issues/14153) - Replace `FeatureQuery` with new `with_feature()` method on ContentType manager
|
||||
* [#14311](https://github.com/netbox-community/netbox/issues/14311) - Move the L2VPN models from the `ipam` app to the new `vpn` app
|
||||
* [#14312](https://github.com/netbox-community/netbox/issues/14312) - Move the ConfigRevision model from the `extras` app to `core`
|
||||
* [#14326](https://github.com/netbox-community/netbox/issues/14326) - Form feature mixin classes have been moved from the `extras` app to `netbox`
|
||||
|
||||
### REST API Changes
|
||||
|
||||
* Introduced the following endpoints:
|
||||
* `/api/virtualization/virtual-disks/`
|
||||
* `/api/vpn/ike-policies/`
|
||||
* `/api/vpn/ike-proposals/`
|
||||
* `/api/vpn/ipsec-policies/`
|
||||
* `/api/vpn/ipsec-profiles/`
|
||||
* `/api/vpn/ipsec-proposals/`
|
||||
* `/api/vpn/tunnels/`
|
||||
* `/api/vpn/tunnel-terminations/`
|
||||
* The following endpoints have been moved:
|
||||
* `/api/ipam/l2vpns/` -> `/api/vpn/l2vpns/`
|
||||
* `/api/ipam/l2vpn-terminations/` -> `/api/vpn/l2vpn-terminations/`
|
||||
* circuits.CircuitType
|
||||
* Added the optional `color` choice field
|
||||
* core.Job
|
||||
* Added the read-only `error` character field
|
||||
* dcim.DeviceType
|
||||
* Added the `exclude_from_utilization` boolean field
|
||||
* extras.CustomField
|
||||
* Removed the `ui_visibility` field
|
||||
* Added the `ui_visible` and `ui_editable` choice fields
|
||||
* tenancy.ContactAssignment
|
||||
* Added support for custom fields
|
||||
* virtualization.VirtualDisk
|
||||
* Added the read-only `virtual_disk_count` integer field
|
||||
* virtualization.VirtualMachine
|
||||
* Added the `/render-config` endpoint
|
@ -74,6 +74,7 @@ nav:
|
||||
- Circuits: 'features/circuits.md'
|
||||
- Wireless: 'features/wireless.md'
|
||||
- Virtualization: 'features/virtualization.md'
|
||||
- VPN Tunnels: 'features/vpn-tunnels.md'
|
||||
- Tenancy: 'features/tenancy.md'
|
||||
- Contacts: 'features/contacts.md'
|
||||
- Search: 'features/search.md'
|
||||
@ -252,6 +253,14 @@ nav:
|
||||
- ClusterType: 'models/virtualization/clustertype.md'
|
||||
- VMInterface: 'models/virtualization/vminterface.md'
|
||||
- VirtualMachine: 'models/virtualization/virtualmachine.md'
|
||||
- VPN:
|
||||
- IKEPolicy: 'models/vpn/ikepolicy.md'
|
||||
- IKEProposal: 'models/vpn/ikeproposal.md'
|
||||
- IPSecPolicy: 'models/vpn/ipsecpolicy.md'
|
||||
- IPSecProfile: 'models/vpn/ipsecprofile.md'
|
||||
- IPSecProposal: 'models/vpn/ipsecproposal.md'
|
||||
- Tunnel: 'models/vpn/tunnel.md'
|
||||
- TunnelTermination: 'models/vpn/tunneltermination.md'
|
||||
- Wireless:
|
||||
- WirelessLAN: 'models/wireless/wirelesslan.md'
|
||||
- WirelessLANGroup: 'models/wireless/wirelesslangroup.md'
|
||||
|
@ -9,6 +9,7 @@ from .choices import *
|
||||
from .models import *
|
||||
|
||||
__all__ = (
|
||||
'ConfigRevisionFilterSet',
|
||||
'DataFileFilterSet',
|
||||
'DataSourceFilterSet',
|
||||
'JobFilterSet',
|
||||
@ -123,3 +124,23 @@ class JobFilterSet(BaseFilterSet):
|
||||
Q(user__username__icontains=value) |
|
||||
Q(name__icontains=value)
|
||||
)
|
||||
|
||||
|
||||
class ConfigRevisionFilterSet(BaseFilterSet):
|
||||
q = django_filters.CharFilter(
|
||||
method='search',
|
||||
label=_('Search'),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = ConfigRevision
|
||||
fields = [
|
||||
'id',
|
||||
]
|
||||
|
||||
def search(self, queryset, name, value):
|
||||
if not value.strip():
|
||||
return queryset
|
||||
return queryset.filter(
|
||||
Q(comment__icontains=value)
|
||||
)
|
||||
|
@ -4,14 +4,15 @@ from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from core.choices import *
|
||||
from core.models import *
|
||||
from extras.forms.mixins import SavedFiltersMixin
|
||||
from netbox.forms import NetBoxModelFilterSetForm
|
||||
from netbox.forms.mixins import SavedFiltersMixin
|
||||
from netbox.utils import get_data_backend_choices
|
||||
from utilities.forms import BOOLEAN_WITH_BLANK_CHOICES, FilterForm
|
||||
from utilities.forms.fields import ContentTypeChoiceField, DynamicModelMultipleChoiceField
|
||||
from utilities.forms.widgets import APISelectMultiple, DateTimePicker
|
||||
|
||||
__all__ = (
|
||||
'ConfigRevisionFilterForm',
|
||||
'DataFileFilterForm',
|
||||
'DataSourceFilterForm',
|
||||
'JobFilterForm',
|
||||
@ -123,3 +124,9 @@ class JobFilterForm(SavedFiltersMixin, FilterForm):
|
||||
api_url='/api/users/users/',
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class ConfigRevisionFilterForm(SavedFiltersMixin, FilterForm):
|
||||
fieldsets = (
|
||||
(None, ('q', 'filter_id')),
|
||||
)
|
||||
|
@ -1,22 +1,28 @@
|
||||
import copy
|
||||
import json
|
||||
|
||||
from django import forms
|
||||
from django.conf import settings
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from core.forms.mixins import SyncedDataMixin
|
||||
from core.models import *
|
||||
from netbox.config import get_config, PARAMS
|
||||
from netbox.forms import NetBoxModelForm
|
||||
from netbox.registry import registry
|
||||
from netbox.utils import get_data_backend_choices
|
||||
from utilities.forms import get_field_value
|
||||
from utilities.forms import BootstrapMixin, get_field_value
|
||||
from utilities.forms.fields import CommentField
|
||||
from utilities.forms.widgets import HTMXSelect
|
||||
|
||||
__all__ = (
|
||||
'ConfigRevisionForm',
|
||||
'DataSourceForm',
|
||||
'ManagedFileForm',
|
||||
)
|
||||
|
||||
EMPTY_VALUES = ('', None, [], ())
|
||||
|
||||
|
||||
class DataSourceForm(NetBoxModelForm):
|
||||
type = forms.ChoiceField(
|
||||
@ -111,3 +117,113 @@ class ManagedFileForm(SyncedDataMixin, NetBoxModelForm):
|
||||
new_file.write(self.cleaned_data['upload_file'].read())
|
||||
|
||||
return super().save(*args, **kwargs)
|
||||
|
||||
|
||||
class ConfigFormMetaclass(forms.models.ModelFormMetaclass):
|
||||
|
||||
def __new__(mcs, name, bases, attrs):
|
||||
|
||||
# Emulate a declared field for each supported configuration parameter
|
||||
param_fields = {}
|
||||
for param in PARAMS:
|
||||
field_kwargs = {
|
||||
'required': False,
|
||||
'label': param.label,
|
||||
'help_text': param.description,
|
||||
}
|
||||
field_kwargs.update(**param.field_kwargs)
|
||||
param_fields[param.name] = param.field(**field_kwargs)
|
||||
attrs.update(param_fields)
|
||||
|
||||
return super().__new__(mcs, name, bases, attrs)
|
||||
|
||||
|
||||
class ConfigRevisionForm(BootstrapMixin, forms.ModelForm, metaclass=ConfigFormMetaclass):
|
||||
"""
|
||||
Form for creating a new ConfigRevision.
|
||||
"""
|
||||
|
||||
fieldsets = (
|
||||
(_('Rack Elevations'), ('RACK_ELEVATION_DEFAULT_UNIT_HEIGHT', 'RACK_ELEVATION_DEFAULT_UNIT_WIDTH')),
|
||||
(_('Power'), ('POWERFEED_DEFAULT_VOLTAGE', 'POWERFEED_DEFAULT_AMPERAGE', 'POWERFEED_DEFAULT_MAX_UTILIZATION')),
|
||||
(_('IPAM'), ('ENFORCE_GLOBAL_UNIQUE', 'PREFER_IPV4')),
|
||||
(_('Security'), ('ALLOWED_URL_SCHEMES',)),
|
||||
(_('Banners'), ('BANNER_LOGIN', 'BANNER_MAINTENANCE', 'BANNER_TOP', 'BANNER_BOTTOM')),
|
||||
(_('Pagination'), ('PAGINATE_COUNT', 'MAX_PAGE_SIZE')),
|
||||
(_('Validation'), ('CUSTOM_VALIDATORS', 'PROTECTION_RULES')),
|
||||
(_('User Preferences'), ('DEFAULT_USER_PREFERENCES',)),
|
||||
(_('Miscellaneous'), (
|
||||
'MAINTENANCE_MODE', 'GRAPHQL_ENABLED', 'CHANGELOG_RETENTION', 'JOB_RETENTION', 'MAPS_URL',
|
||||
)),
|
||||
(_('Config Revision'), ('comment',))
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = ConfigRevision
|
||||
fields = '__all__'
|
||||
widgets = {
|
||||
'BANNER_LOGIN': forms.Textarea(attrs={'class': 'font-monospace'}),
|
||||
'BANNER_MAINTENANCE': forms.Textarea(attrs={'class': 'font-monospace'}),
|
||||
'BANNER_TOP': forms.Textarea(attrs={'class': 'font-monospace'}),
|
||||
'BANNER_BOTTOM': forms.Textarea(attrs={'class': 'font-monospace'}),
|
||||
'CUSTOM_VALIDATORS': forms.Textarea(attrs={'class': 'font-monospace'}),
|
||||
'PROTECTION_RULES': forms.Textarea(attrs={'class': 'font-monospace'}),
|
||||
'comment': forms.Textarea(),
|
||||
}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# Append current parameter values to form field help texts and check for static configurations
|
||||
config = get_config()
|
||||
for param in PARAMS:
|
||||
value = getattr(config, param.name)
|
||||
|
||||
# Set the field's initial value, if it can be serialized. (This may not be the case e.g. for
|
||||
# CUSTOM_VALIDATORS, which may reference Python objects.)
|
||||
try:
|
||||
json.dumps(value)
|
||||
if type(value) in (tuple, list):
|
||||
self.fields[param.name].initial = ', '.join(value)
|
||||
else:
|
||||
self.fields[param.name].initial = value
|
||||
except TypeError:
|
||||
pass
|
||||
|
||||
# Check whether this parameter is statically configured (e.g. in configuration.py)
|
||||
if hasattr(settings, param.name):
|
||||
self.fields[param.name].disabled = True
|
||||
self.fields[param.name].help_text = _(
|
||||
'This parameter has been defined statically and cannot be modified.'
|
||||
)
|
||||
continue
|
||||
|
||||
# Set the field's help text
|
||||
help_text = self.fields[param.name].help_text
|
||||
if help_text:
|
||||
help_text += '<br />' # Line break
|
||||
help_text += _('Current value: <strong>{value}</strong>').format(value=value or '—')
|
||||
if value == param.default:
|
||||
help_text += _(' (default)')
|
||||
self.fields[param.name].help_text = help_text
|
||||
|
||||
def save(self, commit=True):
|
||||
instance = super().save(commit=False)
|
||||
|
||||
# Populate JSON data on the instance
|
||||
instance.data = self.render_json()
|
||||
|
||||
if commit:
|
||||
instance.save()
|
||||
|
||||
return instance
|
||||
|
||||
def render_json(self):
|
||||
json = {}
|
||||
|
||||
# Iterate through each field and populate non-empty values
|
||||
for field_name in self.declared_fields:
|
||||
if field_name in self.cleaned_data and self.cleaned_data[field_name] not in EMPTY_VALUES:
|
||||
json[field_name] = self.cleaned_data[field_name]
|
||||
|
||||
return json
|
||||
|
@ -1,7 +1,7 @@
|
||||
from django.core.cache import cache
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
from extras.models import ConfigRevision
|
||||
from core.models import ConfigRevision
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
|
@ -9,7 +9,7 @@ from django.contrib.auth import get_user_model
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
APPS = ('circuits', 'core', 'dcim', 'extras', 'ipam', 'tenancy', 'users', 'virtualization', 'wireless')
|
||||
APPS = ('circuits', 'core', 'dcim', 'extras', 'ipam', 'tenancy', 'users', 'virtualization', 'vpn', 'wireless')
|
||||
|
||||
BANNER_TEXT = """### NetBox interactive shell ({node})
|
||||
### Python {python} | Django {django} | NetBox {netbox}
|
||||
|
31
netbox/core/migrations/0009_configrevision.py
Normal file
31
netbox/core/migrations/0009_configrevision.py
Normal file
@ -0,0 +1,31 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0008_contenttype_proxy'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.SeparateDatabaseAndState(
|
||||
state_operations=[
|
||||
migrations.CreateModel(
|
||||
name='ConfigRevision',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
|
||||
('created', models.DateTimeField(auto_now_add=True)),
|
||||
('comment', models.CharField(blank=True, max_length=200)),
|
||||
('data', models.JSONField(blank=True, null=True)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'config revision',
|
||||
'verbose_name_plural': 'config revisions',
|
||||
'ordering': ['-created'],
|
||||
},
|
||||
),
|
||||
],
|
||||
# Table will be renamed from extras_configrevision in extras/0101_move_configrevision
|
||||
database_operations=[],
|
||||
),
|
||||
]
|
@ -1,3 +1,4 @@
|
||||
from .config import *
|
||||
from .contenttypes import *
|
||||
from .data import *
|
||||
from .files import *
|
||||
|
66
netbox/core/models/config.py
Normal file
66
netbox/core/models/config.py
Normal file
@ -0,0 +1,66 @@
|
||||
from django.core.cache import cache
|
||||
from django.db import models
|
||||
from django.urls import reverse
|
||||
from django.utils.translation import gettext, gettext_lazy as _
|
||||
|
||||
from utilities.querysets import RestrictedQuerySet
|
||||
|
||||
__all__ = (
|
||||
'ConfigRevision',
|
||||
)
|
||||
|
||||
|
||||
class ConfigRevision(models.Model):
|
||||
"""
|
||||
An atomic revision of NetBox's configuration.
|
||||
"""
|
||||
created = models.DateTimeField(
|
||||
verbose_name=_('created'),
|
||||
auto_now_add=True
|
||||
)
|
||||
comment = models.CharField(
|
||||
verbose_name=_('comment'),
|
||||
max_length=200,
|
||||
blank=True
|
||||
)
|
||||
data = models.JSONField(
|
||||
blank=True,
|
||||
null=True,
|
||||
verbose_name=_('configuration data')
|
||||
)
|
||||
|
||||
objects = RestrictedQuerySet.as_manager()
|
||||
|
||||
class Meta:
|
||||
ordering = ['-created']
|
||||
verbose_name = _('config revision')
|
||||
verbose_name_plural = _('config revisions')
|
||||
|
||||
def __str__(self):
|
||||
if not self.pk:
|
||||
return gettext('Default configuration')
|
||||
if self.is_active:
|
||||
return gettext('Current configuration')
|
||||
return gettext('Config revision #{id}').format(id=self.pk)
|
||||
|
||||
def __getattr__(self, item):
|
||||
if item in self.data:
|
||||
return self.data[item]
|
||||
return super().__getattribute__(item)
|
||||
|
||||
def get_absolute_url(self):
|
||||
if not self.pk:
|
||||
return reverse('core:config') # Default config view
|
||||
return reverse('core:configrevision', args=[self.pk])
|
||||
|
||||
def activate(self):
|
||||
"""
|
||||
Cache the configuration data.
|
||||
"""
|
||||
cache.set('config', self.data, None)
|
||||
cache.set('config_version', self.pk, None)
|
||||
activate.alters_data = True
|
||||
|
||||
@property
|
||||
def is_active(self):
|
||||
return cache.get('config_version') == self.pk
|
@ -116,6 +116,7 @@ class DataSource(JobsMixin, PrimaryModel):
|
||||
)
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
|
||||
# Validate data backend type
|
||||
if self.type and self.type not in registry['data_backends']:
|
||||
|
@ -2,6 +2,7 @@ import logging
|
||||
import os
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import models
|
||||
from django.urls import reverse
|
||||
from django.utils.translation import gettext as _
|
||||
@ -85,6 +86,14 @@ class ManagedFile(SyncedDataMixin, models.Model):
|
||||
self.file_path = os.path.basename(self.data_path)
|
||||
self.data_file.write_to_disk(self.full_path, overwrite=True)
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
|
||||
# Ensure that the file root and path make a unique pair
|
||||
if self._meta.model.objects.filter(file_root=self.file_root, file_path=self.file_path).exclude(pk=self.pk).exists():
|
||||
raise ValidationError(
|
||||
f"A {self._meta.verbose_name.lower()} with this file path already exists ({self.file_root}/{self.file_path}).")
|
||||
|
||||
def delete(self, *args, **kwargs):
|
||||
# Delete file from disk
|
||||
try:
|
||||
|
@ -244,7 +244,7 @@ class Job(models.Model):
|
||||
model_name=self.object_type.model,
|
||||
event=event,
|
||||
data=self.data,
|
||||
timestamp=str(timezone.now()),
|
||||
timestamp=timezone.now().isoformat(),
|
||||
username=self.user.username,
|
||||
retry=get_rq_retry()
|
||||
)
|
||||
|
@ -1,5 +1,8 @@
|
||||
from django.db.models.signals import post_save
|
||||
from django.dispatch import Signal, receiver
|
||||
|
||||
from .models import ConfigRevision
|
||||
|
||||
__all__ = (
|
||||
'post_sync',
|
||||
'pre_sync',
|
||||
@ -19,3 +22,11 @@ def auto_sync(instance, **kwargs):
|
||||
|
||||
for autosync in AutoSyncRecord.objects.filter(datafile__source=instance).prefetch_related('object'):
|
||||
autosync.object.sync(save=True)
|
||||
|
||||
|
||||
@receiver(post_save, sender=ConfigRevision)
|
||||
def update_config(sender, instance, **kwargs):
|
||||
"""
|
||||
Update the cached NetBox configuration when a new ConfigRevision is created.
|
||||
"""
|
||||
instance.activate()
|
||||
|
@ -1,2 +1,3 @@
|
||||
from .config import *
|
||||
from .data import *
|
||||
from .jobs import *
|
||||
|
33
netbox/core/tables/config.py
Normal file
33
netbox/core/tables/config.py
Normal file
@ -0,0 +1,33 @@
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from core.models import ConfigRevision
|
||||
from netbox.tables import NetBoxTable, columns
|
||||
|
||||
__all__ = (
|
||||
'ConfigRevisionTable',
|
||||
)
|
||||
|
||||
REVISION_BUTTONS = """
|
||||
{% if not record.is_active %}
|
||||
<a href="{% url 'core:configrevision_restore' pk=record.pk %}" class="btn btn-sm btn-primary" title="Restore config">
|
||||
<i class="mdi mdi-file-restore"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
"""
|
||||
|
||||
|
||||
class ConfigRevisionTable(NetBoxTable):
|
||||
is_active = columns.BooleanColumn(
|
||||
verbose_name=_('Is Active'),
|
||||
)
|
||||
actions = columns.ActionsColumn(
|
||||
actions=('delete',),
|
||||
extra_buttons=REVISION_BUTTONS
|
||||
)
|
||||
|
||||
class Meta(NetBoxTable.Meta):
|
||||
model = ConfigRevision
|
||||
fields = (
|
||||
'pk', 'id', 'is_active', 'created', 'comment',
|
||||
)
|
||||
default_columns = ('pk', 'id', 'is_active', 'created', 'comment')
|
@ -25,6 +25,13 @@ urlpatterns = (
|
||||
path('jobs/<int:pk>/', views.JobView.as_view(), name='job'),
|
||||
path('jobs/<int:pk>/delete/', views.JobDeleteView.as_view(), name='job_delete'),
|
||||
|
||||
# Config revisions
|
||||
path('config-revisions/', views.ConfigRevisionListView.as_view(), name='configrevision_list'),
|
||||
path('config-revisions/add/', views.ConfigRevisionEditView.as_view(), name='configrevision_add'),
|
||||
path('config-revisions/delete/', views.ConfigRevisionBulkDeleteView.as_view(), name='configrevision_bulk_delete'),
|
||||
path('config-revisions/<int:pk>/restore/', views.ConfigRevisionRestoreView.as_view(), name='configrevision_restore'),
|
||||
path('config-revisions/<int:pk>/', include(get_model_urls('core', 'configrevision'))),
|
||||
|
||||
# Configuration
|
||||
path('config/', views.ConfigView.as_view(), name='config'),
|
||||
|
||||
|
@ -1,12 +1,13 @@
|
||||
from django.contrib import messages
|
||||
from django.shortcuts import get_object_or_404, redirect
|
||||
from django.http import HttpResponseForbidden
|
||||
from django.shortcuts import get_object_or_404, redirect, render
|
||||
from django.views.generic import View
|
||||
|
||||
from extras.models import ConfigRevision
|
||||
from netbox.config import get_config
|
||||
from netbox.config import get_config, PARAMS
|
||||
from netbox.views import generic
|
||||
from netbox.views.generic.base import BaseObjectView
|
||||
from utilities.utils import count_related
|
||||
from utilities.views import register_model_view
|
||||
from utilities.views import ContentTypePermissionRequiredMixin, register_model_view
|
||||
from . import filtersets, forms, tables
|
||||
from .models import *
|
||||
|
||||
@ -164,3 +165,67 @@ class ConfigView(generic.ObjectView):
|
||||
return ConfigRevision(
|
||||
data=get_config().defaults
|
||||
)
|
||||
|
||||
|
||||
class ConfigRevisionListView(generic.ObjectListView):
|
||||
queryset = ConfigRevision.objects.all()
|
||||
filterset = filtersets.ConfigRevisionFilterSet
|
||||
filterset_form = forms.ConfigRevisionFilterForm
|
||||
table = tables.ConfigRevisionTable
|
||||
|
||||
|
||||
@register_model_view(ConfigRevision)
|
||||
class ConfigRevisionView(generic.ObjectView):
|
||||
queryset = ConfigRevision.objects.all()
|
||||
|
||||
|
||||
class ConfigRevisionEditView(generic.ObjectEditView):
|
||||
queryset = ConfigRevision.objects.all()
|
||||
form = forms.ConfigRevisionForm
|
||||
|
||||
|
||||
@register_model_view(ConfigRevision, 'delete')
|
||||
class ConfigRevisionDeleteView(generic.ObjectDeleteView):
|
||||
queryset = ConfigRevision.objects.all()
|
||||
|
||||
|
||||
class ConfigRevisionBulkDeleteView(generic.BulkDeleteView):
|
||||
queryset = ConfigRevision.objects.all()
|
||||
filterset = filtersets.ConfigRevisionFilterSet
|
||||
table = tables.ConfigRevisionTable
|
||||
|
||||
|
||||
class ConfigRevisionRestoreView(ContentTypePermissionRequiredMixin, View):
|
||||
|
||||
def get_required_permission(self):
|
||||
return 'core.configrevision_edit'
|
||||
|
||||
def get(self, request, pk):
|
||||
candidate_config = get_object_or_404(ConfigRevision, pk=pk)
|
||||
|
||||
# Get the current ConfigRevision
|
||||
config_version = get_config().version
|
||||
current_config = ConfigRevision.objects.filter(pk=config_version).first()
|
||||
|
||||
params = []
|
||||
for param in PARAMS:
|
||||
params.append((
|
||||
param.name,
|
||||
current_config.data.get(param.name, None),
|
||||
candidate_config.data.get(param.name, None)
|
||||
))
|
||||
|
||||
return render(request, 'core/configrevision_restore.html', {
|
||||
'object': candidate_config,
|
||||
'params': params,
|
||||
})
|
||||
|
||||
def post(self, request, pk):
|
||||
if not request.user.has_perm('core.configrevision_edit'):
|
||||
return HttpResponseForbidden()
|
||||
|
||||
candidate_config = get_object_or_404(ConfigRevision, pk=pk)
|
||||
candidate_config.activate()
|
||||
messages.success(request, f"Restored configuration revision #{pk}")
|
||||
|
||||
return redirect(candidate_config.get_absolute_url())
|
||||
|
@ -2,8 +2,8 @@ import decimal
|
||||
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.utils.translation import gettext as _
|
||||
from drf_spectacular.utils import extend_schema_field
|
||||
from drf_spectacular.types import OpenApiTypes
|
||||
from drf_spectacular.utils import extend_schema_field
|
||||
from rest_framework import serializers
|
||||
from timezone_field.rest_framework import TimeZoneSerializerField
|
||||
|
||||
@ -12,8 +12,7 @@ from dcim.constants import *
|
||||
from dcim.models import *
|
||||
from extras.api.nested_serializers import NestedConfigTemplateSerializer
|
||||
from ipam.api.nested_serializers import (
|
||||
NestedASNSerializer, NestedIPAddressSerializer, NestedL2VPNTerminationSerializer, NestedVLANSerializer,
|
||||
NestedVRFSerializer,
|
||||
NestedASNSerializer, NestedIPAddressSerializer, NestedVLANSerializer, NestedVRFSerializer,
|
||||
)
|
||||
from ipam.models import ASN, VLAN
|
||||
from netbox.api.fields import ChoiceField, ContentTypeField, SerializedPKRelatedField
|
||||
@ -27,6 +26,7 @@ from tenancy.api.nested_serializers import NestedTenantSerializer
|
||||
from users.api.nested_serializers import NestedUserSerializer
|
||||
from utilities.api import get_serializer_for_model
|
||||
from virtualization.api.nested_serializers import NestedClusterSerializer
|
||||
from vpn.api.nested_serializers import NestedL2VPNTerminationSerializer
|
||||
from wireless.api.nested_serializers import NestedWirelessLANSerializer, NestedWirelessLinkSerializer
|
||||
from wireless.choices import *
|
||||
from wireless.models import WirelessLAN
|
||||
|
@ -5,7 +5,7 @@ from django.utils.translation import gettext as _
|
||||
from extras.filtersets import LocalConfigContextFilterSet
|
||||
from extras.models import ConfigTemplate
|
||||
from ipam.filtersets import PrimaryIPFilterSet
|
||||
from ipam.models import ASN, L2VPN, IPAddress, VRF
|
||||
from ipam.models import ASN, IPAddress, VRF
|
||||
from netbox.filtersets import (
|
||||
BaseFilterSet, ChangeLoggedModelFilterSet, OrganizationalModelFilterSet, NetBoxModelFilterSet,
|
||||
)
|
||||
@ -17,6 +17,7 @@ from utilities.filters import (
|
||||
TreeNodeMultipleChoiceFilter,
|
||||
)
|
||||
from virtualization.models import Cluster
|
||||
from vpn.models import L2VPN
|
||||
from wireless.choices import WirelessRoleChoices, WirelessChannelChoices
|
||||
from .choices import *
|
||||
from .constants import *
|
||||
|
@ -1,9 +1,9 @@
|
||||
from django import forms
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from dcim.models import *
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from extras.forms import CustomFieldsMixin
|
||||
from extras.models import Tag
|
||||
from netbox.forms.mixins import CustomFieldsMixin
|
||||
from utilities.forms import BootstrapMixin, form_from_model
|
||||
from utilities.forms.fields import DynamicModelMultipleChoiceField, ExpandableNameField
|
||||
from .object_create import ComponentCreateForm
|
||||
|
@ -7,12 +7,13 @@ from dcim.constants import *
|
||||
from dcim.models import *
|
||||
from extras.forms import LocalConfigContextFilterForm
|
||||
from extras.models import ConfigTemplate
|
||||
from ipam.models import ASN, L2VPN, VRF
|
||||
from ipam.models import ASN, VRF
|
||||
from netbox.forms import NetBoxModelFilterSetForm
|
||||
from tenancy.forms import ContactModelFilterForm, TenancyFilterForm
|
||||
from utilities.forms import BOOLEAN_WITH_BLANK_CHOICES, FilterForm, add_blank_choice
|
||||
from utilities.forms.fields import ColorField, DynamicModelMultipleChoiceField, TagFilterField
|
||||
from utilities.forms.widgets import APISelectMultiple, NumberWithOptions
|
||||
from vpn.models import L2VPN
|
||||
from wireless.choices import *
|
||||
|
||||
__all__ = (
|
||||
|
@ -566,6 +566,10 @@ class BaseInterface(models.Model):
|
||||
|
||||
return super().save(*args, **kwargs)
|
||||
|
||||
@property
|
||||
def tunnel_termination(self):
|
||||
return self.tunnel_terminations.first()
|
||||
|
||||
@property
|
||||
def count_ipaddresses(self):
|
||||
return self.ip_addresses.count()
|
||||
@ -719,8 +723,14 @@ class Interface(ModularComponentModel, BaseInterface, CabledObjectModel, PathEnd
|
||||
object_id_field='interface_id',
|
||||
related_query_name='+'
|
||||
)
|
||||
tunnel_terminations = GenericRelation(
|
||||
to='vpn.TunnelTermination',
|
||||
content_type_field='termination_type',
|
||||
object_id_field='termination_id',
|
||||
related_query_name='interface'
|
||||
)
|
||||
l2vpn_terminations = GenericRelation(
|
||||
to='ipam.L2VPNTermination',
|
||||
to='vpn.L2VPNTermination',
|
||||
content_type_field='assigned_object_type',
|
||||
object_id_field='assigned_object_id',
|
||||
related_query_name='interface',
|
||||
|
@ -584,6 +584,12 @@ class BaseInterfaceTable(NetBoxTable):
|
||||
orderable=False,
|
||||
verbose_name=_('L2VPN')
|
||||
)
|
||||
tunnel = tables.Column(
|
||||
accessor=tables.A('tunnel_termination__tunnel'),
|
||||
linkify=True,
|
||||
orderable=False,
|
||||
verbose_name=_('Tunnel')
|
||||
)
|
||||
untagged_vlan = tables.Column(
|
||||
verbose_name=_('Untagged VLAN'),
|
||||
linkify=True
|
||||
@ -646,7 +652,8 @@ class InterfaceTable(ModularDeviceComponentTable, BaseInterfaceTable, PathEndpoi
|
||||
'speed', 'speed_formatted', 'duplex', 'mode', 'mac_address', 'wwn', 'poe_mode', 'poe_type', 'rf_role', 'rf_channel',
|
||||
'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'description', 'mark_connected', 'cable',
|
||||
'cable_color', 'wireless_link', 'wireless_lans', 'link_peer', 'connection', 'tags', 'vdcs', 'vrf', 'l2vpn',
|
||||
'ip_addresses', 'fhrp_groups', 'untagged_vlan', 'tagged_vlans', 'inventory_items', 'created', 'last_updated',
|
||||
'tunnel', 'ip_addresses', 'fhrp_groups', 'untagged_vlan', 'tagged_vlans', 'inventory_items', 'created',
|
||||
'last_updated',
|
||||
)
|
||||
default_columns = ('pk', 'name', 'device', 'label', 'enabled', 'type', 'description')
|
||||
|
||||
@ -682,8 +689,8 @@ class DeviceInterfaceTable(InterfaceTable):
|
||||
'pk', 'id', 'name', 'module_bay', 'module', 'label', 'enabled', 'type', 'parent', 'bridge', 'lag',
|
||||
'mgmt_only', 'mtu', 'mode', 'mac_address', 'wwn', 'rf_role', 'rf_channel', 'rf_channel_frequency',
|
||||
'rf_channel_width', 'tx_power', 'description', 'mark_connected', 'cable', 'cable_color', 'wireless_link',
|
||||
'wireless_lans', 'link_peer', 'connection', 'tags', 'vdcs', 'vrf', 'l2vpn', 'ip_addresses', 'fhrp_groups',
|
||||
'untagged_vlan', 'tagged_vlans', 'actions',
|
||||
'wireless_lans', 'link_peer', 'connection', 'tags', 'vdcs', 'vrf', 'l2vpn', 'tunnel', 'ip_addresses',
|
||||
'fhrp_groups', 'untagged_vlan', 'tagged_vlans', 'actions',
|
||||
)
|
||||
default_columns = (
|
||||
'pk', 'name', 'label', 'enabled', 'type', 'parent', 'lag', 'mtu', 'mode', 'description', 'ip_addresses',
|
||||
|
@ -316,8 +316,8 @@ INTERFACE_BUTTONS = """
|
||||
{% if perms.dcim.add_interface %}
|
||||
<li><a class="dropdown-item" href="{% url 'dcim:interface_add' %}?device={{ record.device_id }}&parent={{ record.pk }}&name={{ record.name }}.&type=virtual&return_url={% url 'dcim:device_interfaces' pk=object.pk %}">Child Interface</a></li>
|
||||
{% endif %}
|
||||
{% if perms.ipam.add_l2vpntermination %}
|
||||
<li><a class="dropdown-item" href="{% url 'ipam:l2vpntermination_add' %}?device={{ object.pk }}&interface={{ record.pk }}&return_url={% url 'dcim:device_interfaces' pk=object.pk %}">L2VPN Termination</a></li>
|
||||
{% if perms.vpn.add_l2vpntermination %}
|
||||
<li><a class="dropdown-item" href="{% url 'vpn:l2vpntermination_add' %}?device={{ object.pk }}&interface={{ record.pk }}&return_url={% url 'dcim:device_interfaces' pk=object.pk %}">L2VPN Termination</a></li>
|
||||
{% endif %}
|
||||
{% if perms.ipam.add_fhrpgroupassignment %}
|
||||
<li><a class="dropdown-item" href="{% url 'ipam:fhrpgroupassignment_add' %}?interface_type={{ record|content_type_id }}&interface_id={{ record.pk }}&return_url={% url 'dcim:device_interfaces' pk=object.pk %}">Assign FHRP Group</a></li>
|
||||
@ -359,6 +359,16 @@ INTERFACE_BUTTONS = """
|
||||
<i class="mdi mdi-wifi-off" aria-hidden="true"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
{% elif record.type == 'virtual' %}
|
||||
{% if perms.vpn.add_tunnel and not record.tunnel_termination %}
|
||||
<a href="{% url 'vpn:tunnel_add' %}?termination1_type=dcim.device&termination1_parent={{ record.device.pk }}&termination1_interface={{ record.pk }}&return_url={% url 'dcim:device_interfaces' pk=object.pk %}" title="Create a tunnel" class="btn btn-success btn-sm">
|
||||
<i class="mdi mdi-tunnel-outline" aria-hidden="true"></i>
|
||||
</a>
|
||||
{% elif perms.vpn.delete_tunneltermination and record.tunnel_termination %}
|
||||
<a href="{% url 'vpn:tunneltermination_delete' pk=record.tunnel_termination.pk %}?return_url={% url 'dcim:device_interfaces' pk=object.pk %}" title="Remove tunnel" class="btn btn-danger btn-sm">
|
||||
<i class="mdi mdi-tunnel-outline" aria-hidden="true"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
{% elif record.is_wired and perms.dcim.add_cable %}
|
||||
<a href="#" class="btn btn-outline-dark btn-sm disabled"><i class="mdi mdi-transit-connection-variant" aria-hidden="true"></i></a>
|
||||
<a href="#" class="btn btn-outline-dark btn-sm disabled"><i class="mdi mdi-lan-connect" aria-hidden="true"></i></a>
|
||||
|
@ -1,2 +0,0 @@
|
||||
# TODO: Removing this import triggers an import loop due to how form mixins are currently organized
|
||||
from .forms import ConfigRevisionForm
|
@ -283,7 +283,7 @@ class ReportViewSet(ViewSet):
|
||||
|
||||
# Retrieve and run the Report. This will create a new Job.
|
||||
module, report_cls = self._get_report(pk)
|
||||
report = report_cls()
|
||||
report = report_cls
|
||||
input_serializer = serializers.ReportInputSerializer(
|
||||
data=request.data,
|
||||
context={'report': report}
|
||||
|
@ -17,7 +17,6 @@ from .models import *
|
||||
__all__ = (
|
||||
'BookmarkFilterSet',
|
||||
'ConfigContextFilterSet',
|
||||
'ConfigRevisionFilterSet',
|
||||
'ConfigTemplateFilterSet',
|
||||
'ContentTypeFilterSet',
|
||||
'CustomFieldChoiceSetFilterSet',
|
||||
@ -122,8 +121,7 @@ class CustomFieldChoiceSetFilterSet(BaseFilterSet):
|
||||
return queryset
|
||||
return queryset.filter(
|
||||
Q(name__icontains=value) |
|
||||
Q(description__icontains=value) |
|
||||
Q(extra_choices__contains=value)
|
||||
Q(description__icontains=value)
|
||||
)
|
||||
|
||||
def filter_by_choice(self, queryset, name, value):
|
||||
@ -625,27 +623,3 @@ class ContentTypeFilterSet(django_filters.FilterSet):
|
||||
Q(app_label__icontains=value) |
|
||||
Q(model__icontains=value)
|
||||
)
|
||||
|
||||
|
||||
#
|
||||
# ConfigRevisions
|
||||
#
|
||||
|
||||
class ConfigRevisionFilterSet(BaseFilterSet):
|
||||
q = django_filters.CharFilter(
|
||||
method='search',
|
||||
label=_('Search'),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = ConfigRevision
|
||||
fields = [
|
||||
'id',
|
||||
]
|
||||
|
||||
def search(self, queryset, name, value):
|
||||
if not value.strip():
|
||||
return queryset
|
||||
return queryset.filter(
|
||||
Q(comment__icontains=value)
|
||||
)
|
||||
|
@ -3,5 +3,4 @@ from .filtersets import *
|
||||
from .bulk_edit import *
|
||||
from .bulk_import import *
|
||||
from .misc import *
|
||||
from .mixins import *
|
||||
from .scripts import *
|
||||
|
@ -7,6 +7,7 @@ from dcim.models import DeviceRole, DeviceType, Location, Platform, Region, Site
|
||||
from extras.choices import *
|
||||
from extras.models import *
|
||||
from netbox.forms.base import NetBoxModelFilterSetForm
|
||||
from netbox.forms.mixins import SavedFiltersMixin
|
||||
from tenancy.models import Tenant, TenantGroup
|
||||
from utilities.forms import BOOLEAN_WITH_BLANK_CHOICES, FilterForm, add_blank_choice
|
||||
from utilities.forms.fields import (
|
||||
@ -14,11 +15,9 @@ from utilities.forms.fields import (
|
||||
)
|
||||
from utilities.forms.widgets import APISelectMultiple, DateTimePicker
|
||||
from virtualization.models import Cluster, ClusterGroup, ClusterType
|
||||
from .mixins import *
|
||||
|
||||
__all__ = (
|
||||
'ConfigContextFilterForm',
|
||||
'ConfigRevisionFilterForm',
|
||||
'ConfigTemplateFilterForm',
|
||||
'CustomFieldChoiceSetFilterForm',
|
||||
'CustomFieldFilterForm',
|
||||
@ -499,9 +498,3 @@ class ObjectChangeFilterForm(SavedFiltersMixin, FilterForm):
|
||||
api_url='/api/extras/content-types/',
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class ConfigRevisionFilterForm(SavedFiltersMixin, FilterForm):
|
||||
fieldsets = (
|
||||
(None, ('q', 'filter_id')),
|
||||
)
|
||||
|
@ -1,7 +1,6 @@
|
||||
import json
|
||||
|
||||
from django import forms
|
||||
from django.conf import settings
|
||||
from django.utils.safestring import mark_safe
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
@ -10,7 +9,6 @@ from core.models import ContentType
|
||||
from dcim.models import DeviceRole, DeviceType, Location, Platform, Region, Site, SiteGroup
|
||||
from extras.choices import *
|
||||
from extras.models import *
|
||||
from netbox.config import get_config, PARAMS
|
||||
from netbox.forms import NetBoxModelForm
|
||||
from tenancy.models import Tenant, TenantGroup
|
||||
from utilities.forms import BootstrapMixin, add_blank_choice
|
||||
@ -21,11 +19,9 @@ from utilities.forms.fields import (
|
||||
from utilities.forms.widgets import ChoicesWidget
|
||||
from virtualization.models import Cluster, ClusterGroup, ClusterType
|
||||
|
||||
|
||||
__all__ = (
|
||||
'BookmarkForm',
|
||||
'ConfigContextForm',
|
||||
'ConfigRevisionForm',
|
||||
'ConfigTemplateForm',
|
||||
'CustomFieldChoiceSetForm',
|
||||
'CustomFieldForm',
|
||||
@ -445,116 +441,3 @@ class JournalEntryForm(NetBoxModelForm):
|
||||
'assigned_object_type': forms.HiddenInput,
|
||||
'assigned_object_id': forms.HiddenInput,
|
||||
}
|
||||
|
||||
|
||||
EMPTY_VALUES = ('', None, [], ())
|
||||
|
||||
|
||||
class ConfigFormMetaclass(forms.models.ModelFormMetaclass):
|
||||
|
||||
def __new__(mcs, name, bases, attrs):
|
||||
|
||||
# Emulate a declared field for each supported configuration parameter
|
||||
param_fields = {}
|
||||
for param in PARAMS:
|
||||
field_kwargs = {
|
||||
'required': False,
|
||||
'label': param.label,
|
||||
'help_text': param.description,
|
||||
}
|
||||
field_kwargs.update(**param.field_kwargs)
|
||||
param_fields[param.name] = param.field(**field_kwargs)
|
||||
attrs.update(param_fields)
|
||||
|
||||
return super().__new__(mcs, name, bases, attrs)
|
||||
|
||||
|
||||
class ConfigRevisionForm(BootstrapMixin, forms.ModelForm, metaclass=ConfigFormMetaclass):
|
||||
"""
|
||||
Form for creating a new ConfigRevision.
|
||||
"""
|
||||
|
||||
fieldsets = (
|
||||
(_('Rack Elevations'), ('RACK_ELEVATION_DEFAULT_UNIT_HEIGHT', 'RACK_ELEVATION_DEFAULT_UNIT_WIDTH')),
|
||||
(_('Power'), ('POWERFEED_DEFAULT_VOLTAGE', 'POWERFEED_DEFAULT_AMPERAGE', 'POWERFEED_DEFAULT_MAX_UTILIZATION')),
|
||||
(_('IPAM'), ('ENFORCE_GLOBAL_UNIQUE', 'PREFER_IPV4')),
|
||||
(_('Security'), ('ALLOWED_URL_SCHEMES',)),
|
||||
(_('Banners'), ('BANNER_LOGIN', 'BANNER_MAINTENANCE', 'BANNER_TOP', 'BANNER_BOTTOM')),
|
||||
(_('Pagination'), ('PAGINATE_COUNT', 'MAX_PAGE_SIZE')),
|
||||
(_('Validation'), ('CUSTOM_VALIDATORS', 'PROTECTION_RULES')),
|
||||
(_('User Preferences'), ('DEFAULT_USER_PREFERENCES',)),
|
||||
(_('Miscellaneous'), (
|
||||
'MAINTENANCE_MODE', 'GRAPHQL_ENABLED', 'CHANGELOG_RETENTION', 'JOB_RETENTION', 'MAPS_URL',
|
||||
)),
|
||||
(_('Config Revision'), ('comment',))
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = ConfigRevision
|
||||
fields = '__all__'
|
||||
widgets = {
|
||||
'BANNER_LOGIN': forms.Textarea(attrs={'class': 'font-monospace'}),
|
||||
'BANNER_MAINTENANCE': forms.Textarea(attrs={'class': 'font-monospace'}),
|
||||
'BANNER_TOP': forms.Textarea(attrs={'class': 'font-monospace'}),
|
||||
'BANNER_BOTTOM': forms.Textarea(attrs={'class': 'font-monospace'}),
|
||||
'CUSTOM_VALIDATORS': forms.Textarea(attrs={'class': 'font-monospace'}),
|
||||
'PROTECTION_RULES': forms.Textarea(attrs={'class': 'font-monospace'}),
|
||||
'comment': forms.Textarea(),
|
||||
}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# Append current parameter values to form field help texts and check for static configurations
|
||||
config = get_config()
|
||||
for param in PARAMS:
|
||||
value = getattr(config, param.name)
|
||||
|
||||
# Set the field's initial value, if it can be serialized. (This may not be the case e.g. for
|
||||
# CUSTOM_VALIDATORS, which may reference Python objects.)
|
||||
try:
|
||||
json.dumps(value)
|
||||
if type(value) in (tuple, list):
|
||||
self.fields[param.name].initial = ', '.join(value)
|
||||
else:
|
||||
self.fields[param.name].initial = value
|
||||
except TypeError:
|
||||
pass
|
||||
|
||||
# Check whether this parameter is statically configured (e.g. in configuration.py)
|
||||
if hasattr(settings, param.name):
|
||||
self.fields[param.name].disabled = True
|
||||
self.fields[param.name].help_text = _(
|
||||
'This parameter has been defined statically and cannot be modified.'
|
||||
)
|
||||
continue
|
||||
|
||||
# Set the field's help text
|
||||
help_text = self.fields[param.name].help_text
|
||||
if help_text:
|
||||
help_text += '<br />' # Line break
|
||||
help_text += _('Current value: <strong>{value}</strong>').format(value=value or '—')
|
||||
if value == param.default:
|
||||
help_text += _(' (default)')
|
||||
self.fields[param.name].help_text = help_text
|
||||
|
||||
def save(self, commit=True):
|
||||
instance = super().save(commit=False)
|
||||
|
||||
# Populate JSON data on the instance
|
||||
instance.data = self.render_json()
|
||||
|
||||
if commit:
|
||||
instance.save()
|
||||
|
||||
return instance
|
||||
|
||||
def render_json(self):
|
||||
json = {}
|
||||
|
||||
# Iterate through each field and populate non-empty values
|
||||
for field_name in self.declared_fields:
|
||||
if field_name in self.cleaned_data and self.cleaned_data[field_name] not in EMPTY_VALUES:
|
||||
json[field_name] = self.cleaned_data[field_name]
|
||||
|
||||
return json
|
||||
|
39
netbox/extras/migrations/0101_move_configrevision.py
Normal file
39
netbox/extras/migrations/0101_move_configrevision.py
Normal file
@ -0,0 +1,39 @@
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
def update_content_type(apps, schema_editor):
|
||||
ContentType = apps.get_model('contenttypes', 'ContentType')
|
||||
|
||||
# Delete the new ContentType effected by the introduction of core.ConfigRevision
|
||||
ContentType.objects.filter(app_label='core', model='configrevision').delete()
|
||||
|
||||
# Update the app label of the original ContentType for extras.ConfigRevision to ensure any foreign key
|
||||
# references are preserved
|
||||
ContentType.objects.filter(app_label='extras', model='configrevision').update(app_label='core')
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('extras', '0100_customfield_ui_attrs'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.SeparateDatabaseAndState(
|
||||
state_operations=[
|
||||
migrations.DeleteModel(
|
||||
name='ConfigRevision',
|
||||
),
|
||||
],
|
||||
database_operations=[
|
||||
migrations.AlterModelTable(
|
||||
name='ConfigRevision',
|
||||
table='core_configrevision',
|
||||
),
|
||||
],
|
||||
),
|
||||
migrations.RunPython(
|
||||
code=update_content_type,
|
||||
reverse_code=migrations.RunPython.noop
|
||||
),
|
||||
]
|
@ -3,14 +3,13 @@ import urllib.parse
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.contenttypes.fields import GenericForeignKey
|
||||
from django.core.cache import cache
|
||||
from django.core.validators import ValidationError
|
||||
from django.db import models
|
||||
from django.http import HttpResponse
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from django.utils.formats import date_format
|
||||
from django.utils.translation import gettext, gettext_lazy as _
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from rest_framework.utils.encoders import JSONEncoder
|
||||
|
||||
from core.models import ContentType
|
||||
@ -28,7 +27,6 @@ from utilities.utils import clean_html, dict_to_querydict, render_jinja2
|
||||
|
||||
__all__ = (
|
||||
'Bookmark',
|
||||
'ConfigRevision',
|
||||
'CustomLink',
|
||||
'ExportTemplate',
|
||||
'ImageAttachment',
|
||||
@ -710,59 +708,3 @@ class Bookmark(models.Model):
|
||||
raise ValidationError(
|
||||
_("Bookmarks cannot be assigned to this object type ({type}).").format(type=self.object_type)
|
||||
)
|
||||
|
||||
|
||||
class ConfigRevision(models.Model):
|
||||
"""
|
||||
An atomic revision of NetBox's configuration.
|
||||
"""
|
||||
created = models.DateTimeField(
|
||||
verbose_name=_('created'),
|
||||
auto_now_add=True
|
||||
)
|
||||
comment = models.CharField(
|
||||
verbose_name=_('comment'),
|
||||
max_length=200,
|
||||
blank=True
|
||||
)
|
||||
data = models.JSONField(
|
||||
blank=True,
|
||||
null=True,
|
||||
verbose_name=_('configuration data')
|
||||
)
|
||||
|
||||
objects = RestrictedQuerySet.as_manager()
|
||||
|
||||
class Meta:
|
||||
ordering = ['-created']
|
||||
verbose_name = _('config revision')
|
||||
verbose_name_plural = _('config revisions')
|
||||
|
||||
def __str__(self):
|
||||
if not self.pk:
|
||||
return gettext('Default configuration')
|
||||
if self.is_active:
|
||||
return gettext('Current configuration')
|
||||
return gettext('Config revision #{id}').format(id=self.pk)
|
||||
|
||||
def __getattr__(self, item):
|
||||
if item in self.data:
|
||||
return self.data[item]
|
||||
return super().__getattribute__(item)
|
||||
|
||||
def get_absolute_url(self):
|
||||
if not self.pk:
|
||||
return reverse('core:config') # Default config view
|
||||
return reverse('extras:configrevision', args=[self.pk])
|
||||
|
||||
def activate(self):
|
||||
"""
|
||||
Cache the configuration data.
|
||||
"""
|
||||
cache.set('config', self.data, None)
|
||||
cache.set('config_version', self.pk, None)
|
||||
activate.alters_data = True
|
||||
|
||||
@property
|
||||
def is_active(self):
|
||||
return cache.get('config_version') == self.pk
|
||||
|
@ -14,7 +14,7 @@ from netbox.context import current_request, webhooks_queue
|
||||
from netbox.signals import post_clean
|
||||
from utilities.exceptions import AbortRequest
|
||||
from .choices import ObjectChangeActionChoices
|
||||
from .models import ConfigRevision, CustomField, ObjectChange, TaggedItem
|
||||
from .models import CustomField, ObjectChange, TaggedItem
|
||||
from .webhooks import enqueue_object, get_snapshots, serialize_for_webhook
|
||||
|
||||
#
|
||||
@ -219,18 +219,6 @@ def run_delete_validators(sender, instance, **kwargs):
|
||||
)
|
||||
|
||||
|
||||
#
|
||||
# Dynamic configuration
|
||||
#
|
||||
|
||||
@receiver(post_save, sender=ConfigRevision)
|
||||
def update_config(sender, instance, **kwargs):
|
||||
"""
|
||||
Update the cached NetBox configuration when a new ConfigRevision is created.
|
||||
"""
|
||||
instance.activate()
|
||||
|
||||
|
||||
#
|
||||
# Tags
|
||||
#
|
||||
|
@ -11,7 +11,6 @@ from .template_code import *
|
||||
__all__ = (
|
||||
'BookmarkTable',
|
||||
'ConfigContextTable',
|
||||
'ConfigRevisionTable',
|
||||
'ConfigTemplateTable',
|
||||
'CustomFieldChoiceSetTable',
|
||||
'CustomFieldTable',
|
||||
@ -34,31 +33,6 @@ IMAGEATTACHMENT_IMAGE = '''
|
||||
{% endif %}
|
||||
'''
|
||||
|
||||
REVISION_BUTTONS = """
|
||||
{% if not record.is_active %}
|
||||
<a href="{% url 'extras:configrevision_restore' pk=record.pk %}" class="btn btn-sm btn-primary" title="Restore config">
|
||||
<i class="mdi mdi-file-restore"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
"""
|
||||
|
||||
|
||||
class ConfigRevisionTable(NetBoxTable):
|
||||
is_active = columns.BooleanColumn(
|
||||
verbose_name=_('Is Active'),
|
||||
)
|
||||
actions = columns.ActionsColumn(
|
||||
actions=('delete',),
|
||||
extra_buttons=REVISION_BUTTONS
|
||||
)
|
||||
|
||||
class Meta(NetBoxTable.Meta):
|
||||
model = ConfigRevision
|
||||
fields = (
|
||||
'pk', 'id', 'is_active', 'created', 'comment',
|
||||
)
|
||||
default_columns = ('pk', 'id', 'is_active', 'created', 'comment')
|
||||
|
||||
|
||||
class CustomFieldTable(NetBoxTable):
|
||||
name = tables.Column(
|
||||
|
@ -98,13 +98,6 @@ urlpatterns = [
|
||||
path('journal-entries/import/', views.JournalEntryBulkImportView.as_view(), name='journalentry_import'),
|
||||
path('journal-entries/<int:pk>/', include(get_model_urls('extras', 'journalentry'))),
|
||||
|
||||
# Config revisions
|
||||
path('config-revisions/', views.ConfigRevisionListView.as_view(), name='configrevision_list'),
|
||||
path('config-revisions/add/', views.ConfigRevisionEditView.as_view(), name='configrevision_add'),
|
||||
path('config-revisions/delete/', views.ConfigRevisionBulkDeleteView.as_view(), name='configrevision_bulk_delete'),
|
||||
path('config-revisions/<int:pk>/restore/', views.ConfigRevisionRestoreView.as_view(), name='configrevision_restore'),
|
||||
path('config-revisions/<int:pk>/', include(get_model_urls('extras', 'configrevision'))),
|
||||
|
||||
# Change logging
|
||||
path('changelog/', views.ObjectChangeListView.as_view(), name='objectchange_list'),
|
||||
path('changelog/<int:pk>/', include(get_model_urls('extras', 'objectchange'))),
|
||||
|
@ -15,7 +15,6 @@ from core.models import Job
|
||||
from core.tables import JobTable
|
||||
from extras.dashboard.forms import DashboardWidgetAddForm, DashboardWidgetForm
|
||||
from extras.dashboard.utils import get_widget_class
|
||||
from netbox.config import get_config, PARAMS
|
||||
from netbox.constants import DEFAULT_ACTION_PERMISSIONS
|
||||
from netbox.views import generic
|
||||
from utilities.forms import ConfirmationForm, get_field_value
|
||||
@ -1094,7 +1093,7 @@ class ReportJobsView(ContentTypePermissionRequiredMixin, View):
|
||||
jobs = Job.objects.filter(
|
||||
object_type=object_type,
|
||||
object_id=module.pk,
|
||||
name=report.name
|
||||
name=report.class_name
|
||||
)
|
||||
|
||||
jobs_table = JobTable(
|
||||
@ -1316,74 +1315,6 @@ class ScriptResultView(ContentTypePermissionRequiredMixin, View):
|
||||
})
|
||||
|
||||
|
||||
#
|
||||
# Config Revisions
|
||||
#
|
||||
|
||||
class ConfigRevisionListView(generic.ObjectListView):
|
||||
queryset = ConfigRevision.objects.all()
|
||||
filterset = filtersets.ConfigRevisionFilterSet
|
||||
filterset_form = forms.ConfigRevisionFilterForm
|
||||
table = tables.ConfigRevisionTable
|
||||
|
||||
|
||||
@register_model_view(ConfigRevision)
|
||||
class ConfigRevisionView(generic.ObjectView):
|
||||
queryset = ConfigRevision.objects.all()
|
||||
|
||||
|
||||
class ConfigRevisionEditView(generic.ObjectEditView):
|
||||
queryset = ConfigRevision.objects.all()
|
||||
form = forms.ConfigRevisionForm
|
||||
|
||||
|
||||
@register_model_view(ConfigRevision, 'delete')
|
||||
class ConfigRevisionDeleteView(generic.ObjectDeleteView):
|
||||
queryset = ConfigRevision.objects.all()
|
||||
|
||||
|
||||
class ConfigRevisionBulkDeleteView(generic.BulkDeleteView):
|
||||
queryset = ConfigRevision.objects.all()
|
||||
filterset = filtersets.ConfigRevisionFilterSet
|
||||
table = tables.ConfigRevisionTable
|
||||
|
||||
|
||||
class ConfigRevisionRestoreView(ContentTypePermissionRequiredMixin, View):
|
||||
|
||||
def get_required_permission(self):
|
||||
return 'extras.configrevision_edit'
|
||||
|
||||
def get(self, request, pk):
|
||||
candidate_config = get_object_or_404(ConfigRevision, pk=pk)
|
||||
|
||||
# Get the current ConfigRevision
|
||||
config_version = get_config().version
|
||||
current_config = ConfigRevision.objects.filter(pk=config_version).first()
|
||||
|
||||
params = []
|
||||
for param in PARAMS:
|
||||
params.append((
|
||||
param.name,
|
||||
current_config.data.get(param.name, None),
|
||||
candidate_config.data.get(param.name, None)
|
||||
))
|
||||
|
||||
return render(request, 'extras/configrevision_restore.html', {
|
||||
'object': candidate_config,
|
||||
'params': params,
|
||||
})
|
||||
|
||||
def post(self, request, pk):
|
||||
if not request.user.has_perm('extras.configrevision_edit'):
|
||||
return HttpResponseForbidden()
|
||||
|
||||
candidate_config = get_object_or_404(ConfigRevision, pk=pk)
|
||||
candidate_config.activate()
|
||||
messages.success(request, f"Restored configuration revision #{pk}")
|
||||
|
||||
return redirect(candidate_config.get_absolute_url())
|
||||
|
||||
|
||||
#
|
||||
# Markdown
|
||||
#
|
||||
|
@ -115,7 +115,7 @@ def flush_webhooks(queue):
|
||||
event=data['event'],
|
||||
data=data['data'],
|
||||
snapshots=data['snapshots'],
|
||||
timestamp=str(timezone.now()),
|
||||
timestamp=timezone.now().isoformat(),
|
||||
username=data['username'],
|
||||
request_id=data['request_id'],
|
||||
retry=get_rq_retry()
|
||||
|
@ -2,7 +2,6 @@ from drf_spectacular.utils import extend_schema_serializer
|
||||
from rest_framework import serializers
|
||||
|
||||
from ipam import models
|
||||
from ipam.models.l2vpn import L2VPNTermination, L2VPN
|
||||
from netbox.api.serializers import WritableNestedSerializer
|
||||
from .field_serializers import IPAddressField
|
||||
|
||||
@ -14,8 +13,6 @@ __all__ = [
|
||||
'NestedFHRPGroupAssignmentSerializer',
|
||||
'NestedIPAddressSerializer',
|
||||
'NestedIPRangeSerializer',
|
||||
'NestedL2VPNSerializer',
|
||||
'NestedL2VPNTerminationSerializer',
|
||||
'NestedPrefixSerializer',
|
||||
'NestedRIRSerializer',
|
||||
'NestedRoleSerializer',
|
||||
@ -223,28 +220,3 @@ class NestedServiceSerializer(WritableNestedSerializer):
|
||||
class Meta:
|
||||
model = models.Service
|
||||
fields = ['id', 'url', 'display', 'name', 'protocol', 'ports']
|
||||
|
||||
#
|
||||
# L2VPN
|
||||
#
|
||||
|
||||
|
||||
class NestedL2VPNSerializer(WritableNestedSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='ipam-api:l2vpn-detail')
|
||||
|
||||
class Meta:
|
||||
model = L2VPN
|
||||
fields = [
|
||||
'id', 'url', 'display', 'identifier', 'name', 'slug', 'type'
|
||||
]
|
||||
|
||||
|
||||
class NestedL2VPNTerminationSerializer(WritableNestedSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='ipam-api:l2vpntermination-detail')
|
||||
l2vpn = NestedL2VPNSerializer()
|
||||
|
||||
class Meta:
|
||||
model = L2VPNTermination
|
||||
fields = [
|
||||
'id', 'url', 'display', 'l2vpn'
|
||||
]
|
||||
|
@ -12,8 +12,9 @@ from netbox.constants import NESTED_SERIALIZER_PREFIX
|
||||
from tenancy.api.nested_serializers import NestedTenantSerializer
|
||||
from utilities.api import get_serializer_for_model
|
||||
from virtualization.api.nested_serializers import NestedVirtualMachineSerializer
|
||||
from .nested_serializers import *
|
||||
from vpn.api.nested_serializers import NestedL2VPNTerminationSerializer
|
||||
from .field_serializers import IPAddressField, IPNetworkField
|
||||
from .nested_serializers import *
|
||||
|
||||
|
||||
#
|
||||
@ -479,55 +480,3 @@ class ServiceSerializer(NetBoxModelSerializer):
|
||||
'id', 'url', 'display', 'device', 'virtual_machine', 'name', 'ports', 'protocol', 'ipaddresses',
|
||||
'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated',
|
||||
]
|
||||
|
||||
#
|
||||
# L2VPN
|
||||
#
|
||||
|
||||
|
||||
class L2VPNSerializer(NetBoxModelSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='ipam-api:l2vpn-detail')
|
||||
type = ChoiceField(choices=L2VPNTypeChoices, required=False)
|
||||
import_targets = SerializedPKRelatedField(
|
||||
queryset=RouteTarget.objects.all(),
|
||||
serializer=NestedRouteTargetSerializer,
|
||||
required=False,
|
||||
many=True
|
||||
)
|
||||
export_targets = SerializedPKRelatedField(
|
||||
queryset=RouteTarget.objects.all(),
|
||||
serializer=NestedRouteTargetSerializer,
|
||||
required=False,
|
||||
many=True
|
||||
)
|
||||
tenant = NestedTenantSerializer(required=False, allow_null=True)
|
||||
|
||||
class Meta:
|
||||
model = L2VPN
|
||||
fields = [
|
||||
'id', 'url', 'display', 'identifier', 'name', 'slug', 'type', 'import_targets', 'export_targets',
|
||||
'description', 'comments', 'tenant', 'tags', 'custom_fields', 'created', 'last_updated'
|
||||
]
|
||||
|
||||
|
||||
class L2VPNTerminationSerializer(NetBoxModelSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='ipam-api:l2vpntermination-detail')
|
||||
l2vpn = NestedL2VPNSerializer()
|
||||
device = NestedDeviceSerializer(required=False)
|
||||
assigned_object_type = ContentTypeField(
|
||||
queryset=ContentType.objects.all()
|
||||
)
|
||||
assigned_object = serializers.SerializerMethodField(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = L2VPNTermination
|
||||
fields = [
|
||||
'id', 'url', 'display', 'l2vpn', 'device', 'assigned_object_type', 'assigned_object_id',
|
||||
'assigned_object', 'tags', 'custom_fields', 'created', 'last_updated'
|
||||
]
|
||||
|
||||
@extend_schema_field(serializers.JSONField(allow_null=True))
|
||||
def get_assigned_object(self, instance):
|
||||
serializer = get_serializer_for_model(instance.assigned_object, prefix=NESTED_SERIALIZER_PREFIX)
|
||||
context = {'request': self.context['request']}
|
||||
return serializer(instance.assigned_object, context=context).data
|
||||
|
@ -23,8 +23,6 @@ router.register('vlan-groups', views.VLANGroupViewSet)
|
||||
router.register('vlans', views.VLANViewSet)
|
||||
router.register('service-templates', views.ServiceTemplateViewSet)
|
||||
router.register('services', views.ServiceViewSet)
|
||||
router.register('l2vpns', views.L2VPNViewSet)
|
||||
router.register('l2vpn-terminations', views.L2VPNTerminationViewSet)
|
||||
|
||||
app_name = 'ipam-api'
|
||||
|
||||
|
@ -14,7 +14,6 @@ from circuits.models import Provider
|
||||
from dcim.models import Site
|
||||
from ipam import filtersets
|
||||
from ipam.models import *
|
||||
from ipam.models import L2VPN, L2VPNTermination
|
||||
from ipam.utils import get_next_available_prefix
|
||||
from netbox.api.viewsets import NetBoxModelViewSet
|
||||
from netbox.api.viewsets.mixins import ObjectValidationMixin
|
||||
@ -178,18 +177,6 @@ class ServiceViewSet(NetBoxModelViewSet):
|
||||
filterset_class = filtersets.ServiceFilterSet
|
||||
|
||||
|
||||
class L2VPNViewSet(NetBoxModelViewSet):
|
||||
queryset = L2VPN.objects.prefetch_related('import_targets', 'export_targets', 'tenant', 'tags')
|
||||
serializer_class = serializers.L2VPNSerializer
|
||||
filterset_class = filtersets.L2VPNFilterSet
|
||||
|
||||
|
||||
class L2VPNTerminationViewSet(NetBoxModelViewSet):
|
||||
queryset = L2VPNTermination.objects.prefetch_related('assigned_object')
|
||||
serializer_class = serializers.L2VPNTerminationSerializer
|
||||
filterset_class = filtersets.L2VPNTerminationFilterSet
|
||||
|
||||
|
||||
#
|
||||
# Views
|
||||
#
|
||||
|
@ -172,52 +172,3 @@ class ServiceProtocolChoices(ChoiceSet):
|
||||
(PROTOCOL_UDP, 'UDP'),
|
||||
(PROTOCOL_SCTP, 'SCTP'),
|
||||
)
|
||||
|
||||
|
||||
class L2VPNTypeChoices(ChoiceSet):
|
||||
TYPE_VPLS = 'vpls'
|
||||
TYPE_VPWS = 'vpws'
|
||||
TYPE_EPL = 'epl'
|
||||
TYPE_EVPL = 'evpl'
|
||||
TYPE_EPLAN = 'ep-lan'
|
||||
TYPE_EVPLAN = 'evp-lan'
|
||||
TYPE_EPTREE = 'ep-tree'
|
||||
TYPE_EVPTREE = 'evp-tree'
|
||||
TYPE_VXLAN = 'vxlan'
|
||||
TYPE_VXLAN_EVPN = 'vxlan-evpn'
|
||||
TYPE_MPLS_EVPN = 'mpls-evpn'
|
||||
TYPE_PBB_EVPN = 'pbb-evpn'
|
||||
|
||||
CHOICES = (
|
||||
('VPLS', (
|
||||
(TYPE_VPWS, 'VPWS'),
|
||||
(TYPE_VPLS, 'VPLS'),
|
||||
)),
|
||||
('VXLAN', (
|
||||
(TYPE_VXLAN, 'VXLAN'),
|
||||
(TYPE_VXLAN_EVPN, 'VXLAN-EVPN'),
|
||||
)),
|
||||
('L2VPN E-VPN', (
|
||||
(TYPE_MPLS_EVPN, 'MPLS EVPN'),
|
||||
(TYPE_PBB_EVPN, 'PBB EVPN'),
|
||||
)),
|
||||
('E-Line', (
|
||||
(TYPE_EPL, 'EPL'),
|
||||
(TYPE_EVPL, 'EVPL'),
|
||||
)),
|
||||
('E-LAN', (
|
||||
(TYPE_EPLAN, 'Ethernet Private LAN'),
|
||||
(TYPE_EVPLAN, 'Ethernet Virtual Private LAN'),
|
||||
)),
|
||||
('E-Tree', (
|
||||
(TYPE_EPTREE, 'Ethernet Private Tree'),
|
||||
(TYPE_EVPTREE, 'Ethernet Virtual Private Tree'),
|
||||
)),
|
||||
)
|
||||
|
||||
P2P = (
|
||||
TYPE_VPWS,
|
||||
TYPE_EPL,
|
||||
TYPE_EPLAN,
|
||||
TYPE_EPTREE
|
||||
)
|
||||
|
@ -86,9 +86,3 @@ VLANGROUP_SCOPE_TYPES = (
|
||||
# 16-bit port number
|
||||
SERVICE_PORT_MIN = 1
|
||||
SERVICE_PORT_MAX = 65535
|
||||
|
||||
L2VPN_ASSIGNMENT_MODELS = Q(
|
||||
Q(app_label='dcim', model='interface') |
|
||||
Q(app_label='ipam', model='vlan') |
|
||||
Q(app_label='virtualization', model='vminterface')
|
||||
)
|
||||
|
@ -4,8 +4,8 @@ from django.contrib.contenttypes.models import ContentType
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db.models import Q
|
||||
from django.utils.translation import gettext as _
|
||||
from drf_spectacular.utils import extend_schema_field
|
||||
from drf_spectacular.types import OpenApiTypes
|
||||
from drf_spectacular.utils import extend_schema_field
|
||||
from netaddr.core import AddrFormatError
|
||||
|
||||
from dcim.models import Device, Interface, Region, Site, SiteGroup
|
||||
@ -15,6 +15,7 @@ from utilities.filters import (
|
||||
ContentTypeFilter, MultiValueCharFilter, MultiValueNumberFilter, NumericArrayFilter, TreeNodeMultipleChoiceFilter,
|
||||
)
|
||||
from virtualization.models import VirtualMachine, VMInterface
|
||||
from vpn.models import L2VPN
|
||||
from .choices import *
|
||||
from .models import *
|
||||
|
||||
@ -26,8 +27,6 @@ __all__ = (
|
||||
'FHRPGroupFilterSet',
|
||||
'IPAddressFilterSet',
|
||||
'IPRangeFilterSet',
|
||||
'L2VPNFilterSet',
|
||||
'L2VPNTerminationFilterSet',
|
||||
'PrefixFilterSet',
|
||||
'PrimaryIPFilterSet',
|
||||
'RIRFilterSet',
|
||||
@ -1059,191 +1058,6 @@ class ServiceFilterSet(NetBoxModelFilterSet):
|
||||
return queryset.filter(qs_filter)
|
||||
|
||||
|
||||
#
|
||||
# L2VPN
|
||||
#
|
||||
|
||||
class L2VPNFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
|
||||
type = django_filters.MultipleChoiceFilter(
|
||||
choices=L2VPNTypeChoices,
|
||||
null_value=None
|
||||
)
|
||||
import_target_id = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='import_targets',
|
||||
queryset=RouteTarget.objects.all(),
|
||||
label=_('Import target'),
|
||||
)
|
||||
import_target = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='import_targets__name',
|
||||
queryset=RouteTarget.objects.all(),
|
||||
to_field_name='name',
|
||||
label=_('Import target (name)'),
|
||||
)
|
||||
export_target_id = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='export_targets',
|
||||
queryset=RouteTarget.objects.all(),
|
||||
label=_('Export target'),
|
||||
)
|
||||
export_target = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='export_targets__name',
|
||||
queryset=RouteTarget.objects.all(),
|
||||
to_field_name='name',
|
||||
label=_('Export target (name)'),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = L2VPN
|
||||
fields = ['id', 'identifier', 'name', 'slug', 'type', 'description']
|
||||
|
||||
def search(self, queryset, name, value):
|
||||
if not value.strip():
|
||||
return queryset
|
||||
qs_filter = Q(name__icontains=value) | Q(description__icontains=value)
|
||||
try:
|
||||
qs_filter |= Q(identifier=int(value))
|
||||
except ValueError:
|
||||
pass
|
||||
return queryset.filter(qs_filter)
|
||||
|
||||
|
||||
class L2VPNTerminationFilterSet(NetBoxModelFilterSet):
|
||||
l2vpn_id = django_filters.ModelMultipleChoiceFilter(
|
||||
queryset=L2VPN.objects.all(),
|
||||
label=_('L2VPN (ID)'),
|
||||
)
|
||||
l2vpn = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='l2vpn__slug',
|
||||
queryset=L2VPN.objects.all(),
|
||||
to_field_name='slug',
|
||||
label=_('L2VPN (slug)'),
|
||||
)
|
||||
region = MultiValueCharFilter(
|
||||
method='filter_region',
|
||||
field_name='slug',
|
||||
label=_('Region (slug)'),
|
||||
)
|
||||
region_id = MultiValueNumberFilter(
|
||||
method='filter_region',
|
||||
field_name='pk',
|
||||
label=_('Region (ID)'),
|
||||
)
|
||||
site = MultiValueCharFilter(
|
||||
method='filter_site',
|
||||
field_name='slug',
|
||||
label=_('Site (slug)'),
|
||||
)
|
||||
site_id = MultiValueNumberFilter(
|
||||
method='filter_site',
|
||||
field_name='pk',
|
||||
label=_('Site (ID)'),
|
||||
)
|
||||
device = MultiValueCharFilter(
|
||||
label=_('Device (name)'),
|
||||
field_name='name',
|
||||
method='filter_device',
|
||||
)
|
||||
device_id = MultiValueNumberFilter(
|
||||
label=_('Device (ID)'),
|
||||
field_name='pk',
|
||||
method='filter_device',
|
||||
)
|
||||
virtual_machine = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='vminterface__virtual_machine__name',
|
||||
queryset=VirtualMachine.objects.all(),
|
||||
to_field_name='name',
|
||||
label=_('Virtual machine (name)'),
|
||||
)
|
||||
virtual_machine_id = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='vminterface__virtual_machine',
|
||||
queryset=VirtualMachine.objects.all(),
|
||||
label=_('Virtual machine (ID)'),
|
||||
)
|
||||
interface = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='interface__name',
|
||||
queryset=Interface.objects.all(),
|
||||
to_field_name='name',
|
||||
label=_('Interface (name)'),
|
||||
)
|
||||
interface_id = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='interface',
|
||||
queryset=Interface.objects.all(),
|
||||
label=_('Interface (ID)'),
|
||||
)
|
||||
vminterface = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='vminterface__name',
|
||||
queryset=VMInterface.objects.all(),
|
||||
to_field_name='name',
|
||||
label=_('VM interface (name)'),
|
||||
)
|
||||
vminterface_id = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='vminterface',
|
||||
queryset=VMInterface.objects.all(),
|
||||
label=_('VM Interface (ID)'),
|
||||
)
|
||||
vlan = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='vlan__name',
|
||||
queryset=VLAN.objects.all(),
|
||||
to_field_name='name',
|
||||
label=_('VLAN (name)'),
|
||||
)
|
||||
vlan_vid = django_filters.NumberFilter(
|
||||
field_name='vlan__vid',
|
||||
label=_('VLAN number (1-4094)'),
|
||||
)
|
||||
vlan_id = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='vlan',
|
||||
queryset=VLAN.objects.all(),
|
||||
label=_('VLAN (ID)'),
|
||||
)
|
||||
assigned_object_type = ContentTypeFilter()
|
||||
|
||||
class Meta:
|
||||
model = L2VPNTermination
|
||||
fields = ('id', 'assigned_object_type_id')
|
||||
|
||||
def search(self, queryset, name, value):
|
||||
if not value.strip():
|
||||
return queryset
|
||||
qs_filter = Q(l2vpn__name__icontains=value)
|
||||
return queryset.filter(qs_filter)
|
||||
|
||||
def filter_assigned_object(self, queryset, name, value):
|
||||
qs = queryset.filter(
|
||||
Q(**{'{}__in'.format(name): value})
|
||||
)
|
||||
return qs
|
||||
|
||||
def filter_site(self, queryset, name, value):
|
||||
qs = queryset.filter(
|
||||
Q(
|
||||
Q(**{'vlan__site__{}__in'.format(name): value}) |
|
||||
Q(**{'interface__device__site__{}__in'.format(name): value}) |
|
||||
Q(**{'vminterface__virtual_machine__site__{}__in'.format(name): value})
|
||||
)
|
||||
)
|
||||
return qs
|
||||
|
||||
def filter_region(self, queryset, name, value):
|
||||
qs = queryset.filter(
|
||||
Q(
|
||||
Q(**{'vlan__site__region__{}__in'.format(name): value}) |
|
||||
Q(**{'interface__device__site__region__{}__in'.format(name): value}) |
|
||||
Q(**{'vminterface__virtual_machine__site__region__{}__in'.format(name): value})
|
||||
)
|
||||
)
|
||||
return qs
|
||||
|
||||
def filter_device(self, queryset, name, value):
|
||||
qs = queryset.filter(
|
||||
Q(
|
||||
Q(**{'device__{}__in'.format(name): value}) |
|
||||
Q(**{'interface__device__{}__in'.format(name): value})
|
||||
)
|
||||
)
|
||||
|
||||
return qs
|
||||
|
||||
|
||||
class PrimaryIPFilterSet(django_filters.FilterSet):
|
||||
"""
|
||||
An inheritable FilterSet for models which support primary IP assignment.
|
||||
|
@ -23,8 +23,6 @@ __all__ = (
|
||||
'FHRPGroupBulkEditForm',
|
||||
'IPAddressBulkEditForm',
|
||||
'IPRangeBulkEditForm',
|
||||
'L2VPNBulkEditForm',
|
||||
'L2VPNTerminationBulkEditForm',
|
||||
'PrefixBulkEditForm',
|
||||
'RIRBulkEditForm',
|
||||
'RoleBulkEditForm',
|
||||
@ -596,32 +594,3 @@ class ServiceTemplateBulkEditForm(NetBoxModelBulkEditForm):
|
||||
|
||||
class ServiceBulkEditForm(ServiceTemplateBulkEditForm):
|
||||
model = Service
|
||||
|
||||
|
||||
class L2VPNBulkEditForm(NetBoxModelBulkEditForm):
|
||||
type = forms.ChoiceField(
|
||||
label=_('Type'),
|
||||
choices=add_blank_choice(L2VPNTypeChoices),
|
||||
required=False
|
||||
)
|
||||
tenant = DynamicModelChoiceField(
|
||||
label=_('Tenant'),
|
||||
queryset=Tenant.objects.all(),
|
||||
required=False
|
||||
)
|
||||
description = forms.CharField(
|
||||
label=_('Description'),
|
||||
max_length=200,
|
||||
required=False
|
||||
)
|
||||
comments = CommentField()
|
||||
|
||||
model = L2VPN
|
||||
fieldsets = (
|
||||
(None, ('type', 'tenant', 'description')),
|
||||
)
|
||||
nullable_fields = ('tenant', 'description', 'comments')
|
||||
|
||||
|
||||
class L2VPNTerminationBulkEditForm(NetBoxModelBulkEditForm):
|
||||
model = L2VPN
|
||||
|
@ -1,6 +1,5 @@
|
||||
from django import forms
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from dcim.models import Device, Interface, Site
|
||||
@ -21,8 +20,6 @@ __all__ = (
|
||||
'FHRPGroupImportForm',
|
||||
'IPAddressImportForm',
|
||||
'IPRangeImportForm',
|
||||
'L2VPNImportForm',
|
||||
'L2VPNTerminationImportForm',
|
||||
'PrefixImportForm',
|
||||
'RIRImportForm',
|
||||
'RoleImportForm',
|
||||
@ -529,92 +526,3 @@ class ServiceImportForm(NetBoxModelImportForm):
|
||||
)
|
||||
|
||||
return self.cleaned_data['ipaddresses']
|
||||
|
||||
|
||||
class L2VPNImportForm(NetBoxModelImportForm):
|
||||
tenant = CSVModelChoiceField(
|
||||
label=_('Tenant'),
|
||||
queryset=Tenant.objects.all(),
|
||||
required=False,
|
||||
to_field_name='name',
|
||||
)
|
||||
type = CSVChoiceField(
|
||||
label=_('Type'),
|
||||
choices=L2VPNTypeChoices,
|
||||
help_text=_('L2VPN type')
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = L2VPN
|
||||
fields = ('identifier', 'name', 'slug', 'tenant', 'type', 'description',
|
||||
'comments', 'tags')
|
||||
|
||||
|
||||
class L2VPNTerminationImportForm(NetBoxModelImportForm):
|
||||
l2vpn = CSVModelChoiceField(
|
||||
queryset=L2VPN.objects.all(),
|
||||
required=True,
|
||||
to_field_name='name',
|
||||
label=_('L2VPN'),
|
||||
)
|
||||
device = CSVModelChoiceField(
|
||||
label=_('Device'),
|
||||
queryset=Device.objects.all(),
|
||||
required=False,
|
||||
to_field_name='name',
|
||||
help_text=_('Parent device (for interface)')
|
||||
)
|
||||
virtual_machine = CSVModelChoiceField(
|
||||
label=_('Virtual machine'),
|
||||
queryset=VirtualMachine.objects.all(),
|
||||
required=False,
|
||||
to_field_name='name',
|
||||
help_text=_('Parent virtual machine (for interface)')
|
||||
)
|
||||
interface = CSVModelChoiceField(
|
||||
label=_('Interface'),
|
||||
queryset=Interface.objects.none(), # Can also refer to VMInterface
|
||||
required=False,
|
||||
to_field_name='name',
|
||||
help_text=_('Assigned interface (device or VM)')
|
||||
)
|
||||
vlan = CSVModelChoiceField(
|
||||
label=_('VLAN'),
|
||||
queryset=VLAN.objects.all(),
|
||||
required=False,
|
||||
to_field_name='name',
|
||||
help_text=_('Assigned VLAN')
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = L2VPNTermination
|
||||
fields = ('l2vpn', 'device', 'virtual_machine', 'interface', 'vlan', 'tags')
|
||||
|
||||
def __init__(self, data=None, *args, **kwargs):
|
||||
super().__init__(data, *args, **kwargs)
|
||||
|
||||
if data:
|
||||
|
||||
# Limit interface queryset by device or VM
|
||||
if data.get('device'):
|
||||
self.fields['interface'].queryset = Interface.objects.filter(
|
||||
**{f"device__{self.fields['device'].to_field_name}": data['device']}
|
||||
)
|
||||
elif data.get('virtual_machine'):
|
||||
self.fields['interface'].queryset = VMInterface.objects.filter(
|
||||
**{f"virtual_machine__{self.fields['virtual_machine'].to_field_name}": data['virtual_machine']}
|
||||
)
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
|
||||
if self.cleaned_data.get('device') and self.cleaned_data.get('virtual_machine'):
|
||||
raise ValidationError(_('Cannot import device and VM interface terminations simultaneously.'))
|
||||
if not self.instance and not (self.cleaned_data.get('interface') or self.cleaned_data.get('vlan')):
|
||||
raise ValidationError(_('Each termination must specify either an interface or a VLAN.'))
|
||||
if self.cleaned_data.get('interface') and self.cleaned_data.get('vlan'):
|
||||
raise ValidationError(_('Cannot assign both an interface and a VLAN.'))
|
||||
|
||||
# if this is an update we might not have interface or vlan in the form data
|
||||
if self.cleaned_data.get('interface') or self.cleaned_data.get('vlan'):
|
||||
self.instance.assigned_object = self.cleaned_data.get('interface') or self.cleaned_data.get('vlan')
|
||||
|
@ -1,5 +1,4 @@
|
||||
from django import forms
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from dcim.models import Location, Rack, Region, Site, SiteGroup, Device
|
||||
@ -9,10 +8,9 @@ from ipam.models import *
|
||||
from netbox.forms import NetBoxModelFilterSetForm
|
||||
from tenancy.forms import TenancyFilterForm
|
||||
from utilities.forms import BOOLEAN_WITH_BLANK_CHOICES, add_blank_choice
|
||||
from utilities.forms.fields import (
|
||||
ContentTypeMultipleChoiceField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, TagFilterField,
|
||||
)
|
||||
from utilities.forms.fields import DynamicModelChoiceField, DynamicModelMultipleChoiceField, TagFilterField
|
||||
from virtualization.models import VirtualMachine
|
||||
from vpn.models import L2VPN
|
||||
|
||||
__all__ = (
|
||||
'AggregateFilterForm',
|
||||
@ -21,8 +19,6 @@ __all__ = (
|
||||
'FHRPGroupFilterForm',
|
||||
'IPAddressFilterForm',
|
||||
'IPRangeFilterForm',
|
||||
'L2VPNFilterForm',
|
||||
'L2VPNTerminationFilterForm',
|
||||
'PrefixFilterForm',
|
||||
'RIRFilterForm',
|
||||
'RoleFilterForm',
|
||||
@ -539,90 +535,3 @@ class ServiceFilterForm(ServiceTemplateFilterForm):
|
||||
label=_('Virtual Machine'),
|
||||
)
|
||||
tag = TagFilterField(model)
|
||||
|
||||
|
||||
class L2VPNFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
|
||||
model = L2VPN
|
||||
fieldsets = (
|
||||
(None, ('q', 'filter_id', 'tag')),
|
||||
(_('Attributes'), ('type', 'import_target_id', 'export_target_id')),
|
||||
(_('Tenant'), ('tenant_group_id', 'tenant_id')),
|
||||
)
|
||||
type = forms.ChoiceField(
|
||||
label=_('Type'),
|
||||
choices=add_blank_choice(L2VPNTypeChoices),
|
||||
required=False
|
||||
)
|
||||
import_target_id = DynamicModelMultipleChoiceField(
|
||||
queryset=RouteTarget.objects.all(),
|
||||
required=False,
|
||||
label=_('Import targets')
|
||||
)
|
||||
export_target_id = DynamicModelMultipleChoiceField(
|
||||
queryset=RouteTarget.objects.all(),
|
||||
required=False,
|
||||
label=_('Export targets')
|
||||
)
|
||||
tag = TagFilterField(model)
|
||||
|
||||
|
||||
class L2VPNTerminationFilterForm(NetBoxModelFilterSetForm):
|
||||
model = L2VPNTermination
|
||||
fieldsets = (
|
||||
(None, ('filter_id', 'l2vpn_id',)),
|
||||
(_('Assigned Object'), (
|
||||
'assigned_object_type_id', 'region_id', 'site_id', 'device_id', 'virtual_machine_id', 'vlan_id',
|
||||
)),
|
||||
)
|
||||
l2vpn_id = DynamicModelChoiceField(
|
||||
queryset=L2VPN.objects.all(),
|
||||
required=False,
|
||||
label=_('L2VPN')
|
||||
)
|
||||
assigned_object_type_id = ContentTypeMultipleChoiceField(
|
||||
queryset=ContentType.objects.filter(L2VPN_ASSIGNMENT_MODELS),
|
||||
required=False,
|
||||
label=_('Assigned Object Type'),
|
||||
limit_choices_to=L2VPN_ASSIGNMENT_MODELS
|
||||
)
|
||||
region_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Region.objects.all(),
|
||||
required=False,
|
||||
label=_('Region')
|
||||
)
|
||||
site_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Site.objects.all(),
|
||||
required=False,
|
||||
null_option='None',
|
||||
query_params={
|
||||
'region_id': '$region_id'
|
||||
},
|
||||
label=_('Site')
|
||||
)
|
||||
device_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Device.objects.all(),
|
||||
required=False,
|
||||
null_option='None',
|
||||
query_params={
|
||||
'site_id': '$site_id'
|
||||
},
|
||||
label=_('Device')
|
||||
)
|
||||
vlan_id = DynamicModelMultipleChoiceField(
|
||||
queryset=VLAN.objects.all(),
|
||||
required=False,
|
||||
null_option='None',
|
||||
query_params={
|
||||
'site_id': '$site_id'
|
||||
},
|
||||
label=_('VLAN')
|
||||
)
|
||||
virtual_machine_id = DynamicModelMultipleChoiceField(
|
||||
queryset=VirtualMachine.objects.all(),
|
||||
required=False,
|
||||
null_option='None',
|
||||
query_params={
|
||||
'site_id': '$site_id'
|
||||
},
|
||||
label=_('Virtual Machine')
|
||||
)
|
||||
|
@ -29,8 +29,6 @@ __all__ = (
|
||||
'IPAddressBulkAddForm',
|
||||
'IPAddressForm',
|
||||
'IPRangeForm',
|
||||
'L2VPNForm',
|
||||
'L2VPNTerminationForm',
|
||||
'PrefixForm',
|
||||
'RIRForm',
|
||||
'RoleForm',
|
||||
@ -754,103 +752,3 @@ class ServiceCreateForm(ServiceForm):
|
||||
self.cleaned_data['description'] = service_template.description
|
||||
elif not all(self.cleaned_data[f] for f in ('name', 'protocol', 'ports')):
|
||||
raise forms.ValidationError("Must specify name, protocol, and port(s) if not using a service template.")
|
||||
|
||||
|
||||
#
|
||||
# L2VPN
|
||||
#
|
||||
|
||||
|
||||
class L2VPNForm(TenancyForm, NetBoxModelForm):
|
||||
slug = SlugField()
|
||||
import_targets = DynamicModelMultipleChoiceField(
|
||||
label=_('Import targets'),
|
||||
queryset=RouteTarget.objects.all(),
|
||||
required=False
|
||||
)
|
||||
export_targets = DynamicModelMultipleChoiceField(
|
||||
label=_('Export targets'),
|
||||
queryset=RouteTarget.objects.all(),
|
||||
required=False
|
||||
)
|
||||
comments = CommentField()
|
||||
|
||||
fieldsets = (
|
||||
(_('L2VPN'), ('name', 'slug', 'type', 'identifier', 'description', 'tags')),
|
||||
(_('Route Targets'), ('import_targets', 'export_targets')),
|
||||
(_('Tenancy'), ('tenant_group', 'tenant')),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = L2VPN
|
||||
fields = (
|
||||
'name', 'slug', 'type', 'identifier', 'import_targets', 'export_targets', 'tenant', 'description',
|
||||
'comments', 'tags'
|
||||
)
|
||||
|
||||
|
||||
class L2VPNTerminationForm(NetBoxModelForm):
|
||||
l2vpn = DynamicModelChoiceField(
|
||||
queryset=L2VPN.objects.all(),
|
||||
required=True,
|
||||
query_params={},
|
||||
label=_('L2VPN'),
|
||||
fetch_trigger='open'
|
||||
)
|
||||
device = DynamicModelChoiceField(
|
||||
queryset=Device.objects.all(),
|
||||
required=False,
|
||||
selector=True,
|
||||
label=_('Device')
|
||||
)
|
||||
vlan = DynamicModelChoiceField(
|
||||
queryset=VLAN.objects.all(),
|
||||
required=False,
|
||||
selector=True,
|
||||
label=_('VLAN')
|
||||
)
|
||||
interface = DynamicModelChoiceField(
|
||||
label=_('Interface'),
|
||||
queryset=Interface.objects.all(),
|
||||
required=False,
|
||||
selector=True
|
||||
)
|
||||
vminterface = DynamicModelChoiceField(
|
||||
queryset=VMInterface.objects.all(),
|
||||
required=False,
|
||||
selector=True,
|
||||
label=_('Interface')
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = L2VPNTermination
|
||||
fields = ('l2vpn', 'device')
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
instance = kwargs.get('instance')
|
||||
initial = kwargs.get('initial', {}).copy()
|
||||
|
||||
if instance:
|
||||
if type(instance.assigned_object) is Interface:
|
||||
initial['interface'] = instance.assigned_object
|
||||
elif type(instance.assigned_object) is VLAN:
|
||||
initial['vlan'] = instance.assigned_object
|
||||
elif type(instance.assigned_object) is VMInterface:
|
||||
initial['vminterface'] = instance.assigned_object
|
||||
kwargs['initial'] = initial
|
||||
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
|
||||
interface = self.cleaned_data.get('interface')
|
||||
vminterface = self.cleaned_data.get('vminterface')
|
||||
vlan = self.cleaned_data.get('vlan')
|
||||
|
||||
if not (interface or vminterface or vlan):
|
||||
raise ValidationError(_('A termination must specify an interface or VLAN.'))
|
||||
if len([x for x in (interface, vminterface, vlan) if x]) > 1:
|
||||
raise ValidationError(_('A termination can only have one terminating object (an interface or VLAN).'))
|
||||
|
||||
self.instance.assigned_object = interface or vminterface or vlan
|
||||
|
@ -1,9 +1,8 @@
|
||||
import graphene
|
||||
|
||||
from ipam import models
|
||||
from utilities.graphql_optimizer import gql_query_optimizer
|
||||
|
||||
from netbox.graphql.fields import ObjectField, ObjectListField
|
||||
|
||||
from utilities.graphql_optimizer import gql_query_optimizer
|
||||
from .types import *
|
||||
|
||||
|
||||
@ -38,18 +37,6 @@ class IPAMQuery(graphene.ObjectType):
|
||||
def resolve_ip_range_list(root, info, **kwargs):
|
||||
return gql_query_optimizer(models.IPRange.objects.all(), info)
|
||||
|
||||
l2vpn = ObjectField(L2VPNType)
|
||||
l2vpn_list = ObjectListField(L2VPNType)
|
||||
|
||||
def resolve_l2vpn_list(root, info, **kwargs):
|
||||
return gql_query_optimizer(models.L2VPN.objects.all(), info)
|
||||
|
||||
l2vpn_termination = ObjectField(L2VPNTerminationType)
|
||||
l2vpn_termination_list = ObjectListField(L2VPNTerminationType)
|
||||
|
||||
def resolve_l2vpn_termination_list(root, info, **kwargs):
|
||||
return gql_query_optimizer(models.L2VPNTermination.objects.all(), info)
|
||||
|
||||
prefix = ObjectField(PrefixType)
|
||||
prefix_list = ObjectListField(PrefixType)
|
||||
|
||||
|
@ -1,6 +1,5 @@
|
||||
import graphene
|
||||
|
||||
from extras.graphql.mixins import ContactsMixin
|
||||
from ipam import filtersets, models
|
||||
from netbox.graphql.scalars import BigInt
|
||||
from netbox.graphql.types import BaseObjectType, OrganizationalObjectType, NetBoxObjectType
|
||||
@ -13,8 +12,6 @@ __all__ = (
|
||||
'FHRPGroupAssignmentType',
|
||||
'IPAddressType',
|
||||
'IPRangeType',
|
||||
'L2VPNType',
|
||||
'L2VPNTerminationType',
|
||||
'PrefixType',
|
||||
'RIRType',
|
||||
'RoleType',
|
||||
@ -188,19 +185,3 @@ class VRFType(NetBoxObjectType):
|
||||
model = models.VRF
|
||||
fields = '__all__'
|
||||
filterset_class = filtersets.VRFFilterSet
|
||||
|
||||
|
||||
class L2VPNType(ContactsMixin, NetBoxObjectType):
|
||||
class Meta:
|
||||
model = models.L2VPN
|
||||
fields = '__all__'
|
||||
filtersets_class = filtersets.L2VPNFilterSet
|
||||
|
||||
|
||||
class L2VPNTerminationType(NetBoxObjectType):
|
||||
assigned_object = graphene.Field('ipam.graphql.gfk_mixins.L2VPNAssignmentType')
|
||||
|
||||
class Meta:
|
||||
model = models.L2VPNTermination
|
||||
exclude = ('assigned_object_type', 'assigned_object_id')
|
||||
filtersets_class = filtersets.L2VPNTerminationFilterSet
|
||||
|
@ -1,32 +0,0 @@
|
||||
# Generated by Django 4.1.10 on 2023-08-28 20:23
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('dcim', '0181_rename_device_role_device_role'),
|
||||
('ipam', '0067_ipaddress_index_host'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveConstraint(
|
||||
model_name='l2vpntermination',
|
||||
name='ipam_l2vpntermination_assigned_object',
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='l2vpntermination',
|
||||
name='device',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='l2vpns', to='dcim.device'),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='l2vpntermination',
|
||||
constraint=models.UniqueConstraint(fields=('device', 'assigned_object_type', 'assigned_object_id'), name='ipam_l2vpntermination_device_assigned_object'),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='l2vpntermination',
|
||||
constraint=models.UniqueConstraint(condition=models.Q(('device__isnull', True)), fields=('assigned_object_type', 'assigned_object_id'), name='ipam_l2vpntermination_assigned_object', violation_error_message='This object is already assigned to this l2vpn without a device specified'),
|
||||
),
|
||||
]
|
64
netbox/ipam/migrations/0068_move_l2vpn.py
Normal file
64
netbox/ipam/migrations/0068_move_l2vpn.py
Normal file
@ -0,0 +1,64 @@
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
def update_content_types(apps, schema_editor):
|
||||
ContentType = apps.get_model('contenttypes', 'ContentType')
|
||||
|
||||
# Delete the new ContentTypes effected by the new models in the vpn app
|
||||
ContentType.objects.filter(app_label='vpn', model='l2vpn').delete()
|
||||
ContentType.objects.filter(app_label='vpn', model='l2vpntermination').delete()
|
||||
|
||||
# Update the app labels of the original ContentTypes for ipam.L2VPN and ipam.L2VPNTermination to ensure
|
||||
# that any foreign key references are preserved
|
||||
ContentType.objects.filter(app_label='ipam', model='l2vpn').update(app_label='vpn')
|
||||
ContentType.objects.filter(app_label='ipam', model='l2vpntermination').update(app_label='vpn')
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('ipam', '0067_ipaddress_index_host'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveConstraint(
|
||||
model_name='l2vpntermination',
|
||||
name='ipam_l2vpntermination_assigned_object',
|
||||
),
|
||||
migrations.SeparateDatabaseAndState(
|
||||
state_operations=[
|
||||
migrations.RemoveField(
|
||||
model_name='l2vpntermination',
|
||||
name='assigned_object_type',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='l2vpntermination',
|
||||
name='l2vpn',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='l2vpntermination',
|
||||
name='tags',
|
||||
),
|
||||
migrations.DeleteModel(
|
||||
name='L2VPN',
|
||||
),
|
||||
migrations.DeleteModel(
|
||||
name='L2VPNTermination',
|
||||
),
|
||||
],
|
||||
database_operations=[
|
||||
migrations.AlterModelTable(
|
||||
name='L2VPN',
|
||||
table='vpn_l2vpn',
|
||||
),
|
||||
migrations.AlterModelTable(
|
||||
name='L2VPNTermination',
|
||||
table='vpn_l2vpntermination',
|
||||
),
|
||||
],
|
||||
),
|
||||
migrations.RunPython(
|
||||
code=update_content_types,
|
||||
reverse_code=migrations.RunPython.noop
|
||||
),
|
||||
]
|
@ -3,27 +3,5 @@ from .asns import *
|
||||
from .fhrp import *
|
||||
from .vrfs import *
|
||||
from .ip import *
|
||||
from .l2vpn import *
|
||||
from .services import *
|
||||
from .vlans import *
|
||||
|
||||
__all__ = (
|
||||
'ASN',
|
||||
'ASNRange',
|
||||
'Aggregate',
|
||||
'IPAddress',
|
||||
'IPRange',
|
||||
'FHRPGroup',
|
||||
'FHRPGroupAssignment',
|
||||
'L2VPN',
|
||||
'L2VPNTermination',
|
||||
'Prefix',
|
||||
'RIR',
|
||||
'Role',
|
||||
'RouteTarget',
|
||||
'Service',
|
||||
'ServiceTemplate',
|
||||
'VLAN',
|
||||
'VLANGroup',
|
||||
'VRF',
|
||||
)
|
||||
|
@ -183,9 +183,8 @@ class VLAN(PrimaryModel):
|
||||
null=True,
|
||||
help_text=_("The primary function of this VLAN")
|
||||
)
|
||||
|
||||
l2vpn_terminations = GenericRelation(
|
||||
to='ipam.L2VPNTermination',
|
||||
to='vpn.L2VPNTermination',
|
||||
content_type_field='assigned_object_type',
|
||||
object_id_field='assigned_object_id',
|
||||
related_query_name='vlan'
|
||||
|
@ -1,5 +1,5 @@
|
||||
from . import models
|
||||
from netbox.search import SearchIndex, register_search
|
||||
from . import models
|
||||
|
||||
|
||||
@register_search
|
||||
@ -69,18 +69,6 @@ class IPRangeIndex(SearchIndex):
|
||||
display_attrs = ('vrf', 'tenant', 'status', 'role', 'description')
|
||||
|
||||
|
||||
@register_search
|
||||
class L2VPNIndex(SearchIndex):
|
||||
model = models.L2VPN
|
||||
fields = (
|
||||
('name', 100),
|
||||
('slug', 110),
|
||||
('description', 500),
|
||||
('comments', 5000),
|
||||
)
|
||||
display_attrs = ('type', 'identifier', 'tenant', 'description')
|
||||
|
||||
|
||||
@register_search
|
||||
class PrefixIndex(SearchIndex):
|
||||
model = models.Prefix
|
||||
|
@ -1,7 +1,6 @@
|
||||
from .asn import *
|
||||
from .fhrp import *
|
||||
from .ip import *
|
||||
from .l2vpn import *
|
||||
from .services import *
|
||||
from .vlans import *
|
||||
from .vrfs import *
|
||||
|
@ -48,6 +48,7 @@ class ASNTable(TenancyColumnsMixin, NetBoxTable):
|
||||
asn_asdot = tables.Column(
|
||||
accessor=tables.A('asn_asdot'),
|
||||
linkify=True,
|
||||
order_by=tables.A('asn'),
|
||||
verbose_name=_('ASDOT')
|
||||
)
|
||||
site_count = columns.LinkedCountColumn(
|
||||
|
@ -1100,107 +1100,3 @@ class ServiceTest(APIViewTestCases.APIViewTestCase):
|
||||
'ports': [6],
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
class L2VPNTest(APIViewTestCases.APIViewTestCase):
|
||||
model = L2VPN
|
||||
brief_fields = ['display', 'id', 'identifier', 'name', 'slug', 'type', 'url']
|
||||
create_data = [
|
||||
{
|
||||
'name': 'L2VPN 4',
|
||||
'slug': 'l2vpn-4',
|
||||
'type': 'vxlan',
|
||||
'identifier': 33343344
|
||||
},
|
||||
{
|
||||
'name': 'L2VPN 5',
|
||||
'slug': 'l2vpn-5',
|
||||
'type': 'vxlan',
|
||||
'identifier': 33343345
|
||||
},
|
||||
{
|
||||
'name': 'L2VPN 6',
|
||||
'slug': 'l2vpn-6',
|
||||
'type': 'vpws',
|
||||
'identifier': 33343346
|
||||
},
|
||||
]
|
||||
bulk_update_data = {
|
||||
'description': 'New description',
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
|
||||
l2vpns = (
|
||||
L2VPN(name='L2VPN 1', slug='l2vpn-1', type='vxlan', identifier=650001),
|
||||
L2VPN(name='L2VPN 2', slug='l2vpn-2', type='vpws', identifier=650002),
|
||||
L2VPN(name='L2VPN 3', slug='l2vpn-3', type='vpls'), # No RD
|
||||
)
|
||||
L2VPN.objects.bulk_create(l2vpns)
|
||||
|
||||
|
||||
class L2VPNTerminationTest(APIViewTestCases.APIViewTestCase):
|
||||
model = L2VPNTermination
|
||||
brief_fields = ['display', 'id', 'l2vpn', 'url']
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
site = Site.objects.create(name='Site 1', slug='site-1')
|
||||
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
|
||||
devicetype = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1')
|
||||
role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
|
||||
|
||||
devices = (
|
||||
Device(name='Device 1', site=site, device_type=devicetype, role=role),
|
||||
Device(name='Device 2', site=site, device_type=devicetype, role=role),
|
||||
)
|
||||
Device.objects.bulk_create(devices)
|
||||
|
||||
vlans = (
|
||||
VLAN(name='VLAN 1', vid=651),
|
||||
VLAN(name='VLAN 2', vid=652),
|
||||
VLAN(name='VLAN 3', vid=653),
|
||||
VLAN(name='VLAN 4', vid=654),
|
||||
VLAN(name='VLAN 5', vid=655),
|
||||
VLAN(name='VLAN 6', vid=656),
|
||||
VLAN(name='VLAN 7', vid=657)
|
||||
)
|
||||
VLAN.objects.bulk_create(vlans)
|
||||
|
||||
l2vpns = (
|
||||
L2VPN(name='L2VPN 1', slug='l2vpn-1', type='vxlan', identifier=650001),
|
||||
L2VPN(name='L2VPN 2', slug='l2vpn-2', type='vpws', identifier=650002),
|
||||
L2VPN(name='L2VPN 3', slug='l2vpn-3', type='vpls'), # No RD
|
||||
)
|
||||
L2VPN.objects.bulk_create(l2vpns)
|
||||
|
||||
l2vpnterminations = (
|
||||
L2VPNTermination(l2vpn=l2vpns[0], assigned_object=vlans[0]),
|
||||
L2VPNTermination(l2vpn=l2vpns[0], assigned_object=vlans[1]),
|
||||
L2VPNTermination(device=devices[0], l2vpn=l2vpns[0], assigned_object=vlans[2])
|
||||
)
|
||||
L2VPNTermination.objects.bulk_create(l2vpnterminations)
|
||||
|
||||
cls.create_data = [
|
||||
{
|
||||
'l2vpn': l2vpns[0].pk,
|
||||
'assigned_object_type': 'ipam.vlan',
|
||||
'assigned_object_id': vlans[3].pk,
|
||||
},
|
||||
{
|
||||
'l2vpn': l2vpns[0].pk,
|
||||
'assigned_object_type': 'ipam.vlan',
|
||||
'assigned_object_id': vlans[4].pk,
|
||||
},
|
||||
{
|
||||
'l2vpn': l2vpns[0].pk,
|
||||
'device': devices[1].pk,
|
||||
'assigned_object_type': 'ipam.vlan',
|
||||
'assigned_object_id': vlans[5].pk,
|
||||
},
|
||||
]
|
||||
|
||||
cls.bulk_update_data = {
|
||||
'l2vpn': l2vpns[2].pk
|
||||
}
|
||||
|
@ -7,9 +7,9 @@ from dcim.models import Device, DeviceRole, DeviceType, Interface, Location, Man
|
||||
from ipam.choices import *
|
||||
from ipam.filtersets import *
|
||||
from ipam.models import *
|
||||
from tenancy.models import Tenant, TenantGroup
|
||||
from utilities.testing import ChangeLoggedFilterSetTests, create_test_device, create_test_virtualmachine
|
||||
from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface
|
||||
from tenancy.models import Tenant, TenantGroup
|
||||
|
||||
|
||||
class ASNRangeTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
@ -1616,163 +1616,3 @@ class ServiceTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
params = {'ipaddress': [str(ips[0].address), str(ips[1].address)]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
|
||||
class L2VPNTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
queryset = L2VPN.objects.all()
|
||||
filterset = L2VPNFilterSet
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
|
||||
route_targets = (
|
||||
RouteTarget(name='1:1'),
|
||||
RouteTarget(name='1:2'),
|
||||
RouteTarget(name='1:3'),
|
||||
RouteTarget(name='2:1'),
|
||||
RouteTarget(name='2:2'),
|
||||
RouteTarget(name='2:3'),
|
||||
)
|
||||
RouteTarget.objects.bulk_create(route_targets)
|
||||
|
||||
l2vpns = (
|
||||
L2VPN(name='L2VPN 1', slug='l2vpn-1', type=L2VPNTypeChoices.TYPE_VXLAN, identifier=65001),
|
||||
L2VPN(name='L2VPN 2', slug='l2vpn-2', type=L2VPNTypeChoices.TYPE_VPWS, identifier=65002),
|
||||
L2VPN(name='L2VPN 3', slug='l2vpn-3', type=L2VPNTypeChoices.TYPE_VPLS),
|
||||
)
|
||||
L2VPN.objects.bulk_create(l2vpns)
|
||||
l2vpns[0].import_targets.add(route_targets[0])
|
||||
l2vpns[1].import_targets.add(route_targets[1])
|
||||
l2vpns[2].import_targets.add(route_targets[2])
|
||||
l2vpns[0].export_targets.add(route_targets[3])
|
||||
l2vpns[1].export_targets.add(route_targets[4])
|
||||
l2vpns[2].export_targets.add(route_targets[5])
|
||||
|
||||
def test_name(self):
|
||||
params = {'name': ['L2VPN 1', 'L2VPN 2']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_slug(self):
|
||||
params = {'slug': ['l2vpn-1', 'l2vpn-2']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_identifier(self):
|
||||
params = {'identifier': ['65001', '65002']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_type(self):
|
||||
params = {'type': [L2VPNTypeChoices.TYPE_VXLAN, L2VPNTypeChoices.TYPE_VPWS]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_import_targets(self):
|
||||
route_targets = RouteTarget.objects.filter(name__in=['1:1', '1:2'])
|
||||
params = {'import_target_id': [route_targets[0].pk, route_targets[1].pk]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
params = {'import_target': [route_targets[0].name, route_targets[1].name]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_export_targets(self):
|
||||
route_targets = RouteTarget.objects.filter(name__in=['2:1', '2:2'])
|
||||
params = {'export_target_id': [route_targets[0].pk, route_targets[1].pk]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
params = {'export_target': [route_targets[0].name, route_targets[1].name]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
|
||||
class L2VPNTerminationTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
queryset = L2VPNTermination.objects.all()
|
||||
filterset = L2VPNTerminationFilterSet
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
device = create_test_device('Device 1')
|
||||
interfaces = (
|
||||
Interface(name='Interface 1', device=device, type=InterfaceTypeChoices.TYPE_1GE_FIXED),
|
||||
Interface(name='Interface 2', device=device, type=InterfaceTypeChoices.TYPE_1GE_FIXED),
|
||||
Interface(name='Interface 3', device=device, type=InterfaceTypeChoices.TYPE_1GE_FIXED),
|
||||
)
|
||||
Interface.objects.bulk_create(interfaces)
|
||||
|
||||
vm = create_test_virtualmachine('Virtual Machine 1')
|
||||
vminterfaces = (
|
||||
VMInterface(name='Interface 1', virtual_machine=vm),
|
||||
VMInterface(name='Interface 2', virtual_machine=vm),
|
||||
VMInterface(name='Interface 3', virtual_machine=vm),
|
||||
)
|
||||
VMInterface.objects.bulk_create(vminterfaces)
|
||||
|
||||
vlans = (
|
||||
VLAN(name='VLAN 1', vid=101),
|
||||
VLAN(name='VLAN 2', vid=102),
|
||||
VLAN(name='VLAN 3', vid=103),
|
||||
)
|
||||
VLAN.objects.bulk_create(vlans)
|
||||
|
||||
l2vpns = (
|
||||
L2VPN(name='L2VPN 1', slug='l2vpn-1', type='vxlan', identifier=65001),
|
||||
L2VPN(name='L2VPN 2', slug='l2vpn-2', type='vpws', identifier=65002),
|
||||
L2VPN(name='L2VPN 3', slug='l2vpn-3', type='vpls'), # No RD,
|
||||
)
|
||||
L2VPN.objects.bulk_create(l2vpns)
|
||||
|
||||
l2vpnterminations = (
|
||||
L2VPNTermination(l2vpn=l2vpns[0], assigned_object=vlans[0]),
|
||||
L2VPNTermination(l2vpn=l2vpns[1], assigned_object=vlans[1]),
|
||||
L2VPNTermination(device=device, l2vpn=l2vpns[2], assigned_object=vlans[2]),
|
||||
L2VPNTermination(l2vpn=l2vpns[0], assigned_object=interfaces[0]),
|
||||
L2VPNTermination(l2vpn=l2vpns[1], assigned_object=interfaces[1]),
|
||||
L2VPNTermination(l2vpn=l2vpns[2], assigned_object=interfaces[2]),
|
||||
L2VPNTermination(l2vpn=l2vpns[0], assigned_object=vminterfaces[0]),
|
||||
L2VPNTermination(l2vpn=l2vpns[1], assigned_object=vminterfaces[1]),
|
||||
L2VPNTermination(l2vpn=l2vpns[2], assigned_object=vminterfaces[2]),
|
||||
)
|
||||
L2VPNTermination.objects.bulk_create(l2vpnterminations)
|
||||
|
||||
def test_l2vpn(self):
|
||||
l2vpns = L2VPN.objects.all()[:2]
|
||||
params = {'l2vpn_id': [l2vpns[0].pk, l2vpns[1].pk]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)
|
||||
params = {'l2vpn': [l2vpns[0].slug, l2vpns[1].slug]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)
|
||||
|
||||
def test_content_type(self):
|
||||
params = {'assigned_object_type_id': ContentType.objects.get(model='vlan').pk}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
|
||||
|
||||
def test_interface(self):
|
||||
interfaces = Interface.objects.all()[:2]
|
||||
params = {'interface_id': [interfaces[0].pk, interfaces[1].pk]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_vminterface(self):
|
||||
vminterfaces = VMInterface.objects.all()[:2]
|
||||
params = {'vminterface_id': [vminterfaces[0].pk, vminterfaces[1].pk]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_vlan(self):
|
||||
vlans = VLAN.objects.all()[:2]
|
||||
params = {'vlan_id': [vlans[0].pk, vlans[1].pk]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
params = {'vlan': ['VLAN 1', 'VLAN 2']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_site(self):
|
||||
site = Site.objects.all().first()
|
||||
params = {'site_id': [site.pk]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
|
||||
params = {'site': ['site-1']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
|
||||
|
||||
def test_device(self):
|
||||
device = Device.objects.all().first()
|
||||
params = {'device_id': [device.pk]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
|
||||
params = {'device': ['Device 1']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
|
||||
|
||||
def test_virtual_machine(self):
|
||||
virtual_machine = VirtualMachine.objects.all().first()
|
||||
params = {'virtual_machine_id': [virtual_machine.pk]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
|
||||
params = {'virtual_machine': ['Virtual Machine 1']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
|
||||
|
@ -1,11 +1,9 @@
|
||||
from netaddr import IPNetwork, IPSet
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db.utils import IntegrityError
|
||||
from django.test import TestCase, override_settings
|
||||
from netaddr import IPNetwork, IPSet
|
||||
|
||||
from dcim.models import Interface, Device, DeviceRole, DeviceType, Manufacturer, Site
|
||||
from ipam.choices import IPAddressRoleChoices, PrefixStatusChoices
|
||||
from ipam.models import Aggregate, IPAddress, IPRange, Prefix, RIR, VLAN, VLANGroup, VRF, L2VPN, L2VPNTermination
|
||||
from ipam.choices import *
|
||||
from ipam.models import *
|
||||
|
||||
|
||||
class TestAggregate(TestCase):
|
||||
@ -540,103 +538,3 @@ class TestVLANGroup(TestCase):
|
||||
|
||||
VLAN.objects.create(name='VLAN 104', vid=104, group=vlangroup)
|
||||
self.assertEqual(vlangroup.get_next_available_vid(), 105)
|
||||
|
||||
|
||||
class TestL2VPNTermination(TestCase):
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
|
||||
site = Site.objects.create(name='Site 1')
|
||||
manufacturer = Manufacturer.objects.create(name='Manufacturer 1')
|
||||
device_type = DeviceType.objects.create(model='Device Type 1', manufacturer=manufacturer)
|
||||
role = DeviceRole.objects.create(name='Switch')
|
||||
device = Device.objects.create(
|
||||
name='Device 1',
|
||||
site=site,
|
||||
device_type=device_type,
|
||||
role=role,
|
||||
status='active'
|
||||
)
|
||||
|
||||
interfaces = (
|
||||
Interface(name='Interface 1', device=device, type='1000baset'),
|
||||
Interface(name='Interface 2', device=device, type='1000baset'),
|
||||
Interface(name='Interface 3', device=device, type='1000baset'),
|
||||
Interface(name='Interface 4', device=device, type='1000baset'),
|
||||
Interface(name='Interface 5', device=device, type='1000baset'),
|
||||
)
|
||||
|
||||
Interface.objects.bulk_create(interfaces)
|
||||
|
||||
vlans = (
|
||||
VLAN(name='VLAN 1', vid=651),
|
||||
VLAN(name='VLAN 2', vid=652),
|
||||
VLAN(name='VLAN 3', vid=653),
|
||||
VLAN(name='VLAN 4', vid=654),
|
||||
VLAN(name='VLAN 5', vid=655),
|
||||
VLAN(name='VLAN 6', vid=656),
|
||||
VLAN(name='VLAN 7', vid=657)
|
||||
)
|
||||
|
||||
VLAN.objects.bulk_create(vlans)
|
||||
|
||||
l2vpns = (
|
||||
L2VPN(name='L2VPN 1', slug='l2vpn-1', type='vxlan', identifier=650001),
|
||||
L2VPN(name='L2VPN 2', slug='l2vpn-2', type='vpws', identifier=650002),
|
||||
L2VPN(name='L2VPN 3', slug='l2vpn-3', type='vpls'), # No RD
|
||||
)
|
||||
L2VPN.objects.bulk_create(l2vpns)
|
||||
|
||||
l2vpnterminations = (
|
||||
L2VPNTermination(l2vpn=l2vpns[0], assigned_object=vlans[0]),
|
||||
L2VPNTermination(l2vpn=l2vpns[0], assigned_object=vlans[1]),
|
||||
L2VPNTermination(l2vpn=l2vpns[0], assigned_object=vlans[2])
|
||||
)
|
||||
|
||||
L2VPNTermination.objects.bulk_create(l2vpnterminations)
|
||||
|
||||
def test_vlan_device_creation(self):
|
||||
device = Device.objects.first()
|
||||
vlan = Interface.objects.first()
|
||||
l2vpn = L2VPN.objects.first()
|
||||
termination = L2VPNTermination.objects.first()
|
||||
|
||||
termination.device = device
|
||||
termination.save()
|
||||
|
||||
device = Device.objects.last()
|
||||
|
||||
L2VPNTermination.objects.create(device=device, l2vpn=l2vpn, assigned_object=vlan)
|
||||
|
||||
self.assertEqual(L2VPNTermination.objects.all().count(), 4)
|
||||
|
||||
def test_duplicate_vlan_device_creation(self):
|
||||
device = Device.objects.first()
|
||||
vlan = Interface.objects.first()
|
||||
l2vpn = L2VPN.objects.first()
|
||||
|
||||
L2VPNTermination.objects.create(device=device, l2vpn=l2vpn, assigned_object=vlan)
|
||||
|
||||
duplicate = L2VPNTermination(device=device, l2vpn=l2vpn, assigned_object=vlan)
|
||||
self.assertRaises(ValidationError, duplicate.clean)
|
||||
self.assertRaises(IntegrityError, duplicate.save)
|
||||
|
||||
def test_duplicate_interface_terminations(self):
|
||||
device = Device.objects.first()
|
||||
interface = Interface.objects.filter(device=device).first()
|
||||
l2vpn = L2VPN.objects.first()
|
||||
|
||||
L2VPNTermination.objects.create(l2vpn=l2vpn, assigned_object=interface)
|
||||
duplicate = L2VPNTermination(l2vpn=l2vpn, assigned_object=interface)
|
||||
|
||||
self.assertRaises(ValidationError, duplicate.clean)
|
||||
self.assertRaises(IntegrityError, duplicate.save)
|
||||
|
||||
def test_duplicate_vlan_terminations(self):
|
||||
vlan = VLAN.objects.first()
|
||||
l2vpn = L2VPN.objects.first()
|
||||
|
||||
duplicate = L2VPNTermination(l2vpn=l2vpn, assigned_object=vlan)
|
||||
self.assertRaises(ValidationError, duplicate.clean)
|
||||
self.assertRaises(IntegrityError, duplicate.save)
|
||||
|
@ -9,7 +9,7 @@ from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Site, Inte
|
||||
from ipam.choices import *
|
||||
from ipam.models import *
|
||||
from tenancy.models import Tenant
|
||||
from utilities.testing import ViewTestCases, create_test_device, create_tags
|
||||
from utilities.testing import ViewTestCases, create_tags
|
||||
|
||||
|
||||
class ASNRangeTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
||||
@ -986,142 +986,3 @@ class ServiceTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
||||
self.assertEqual(instance.protocol, service_template.protocol)
|
||||
self.assertEqual(instance.ports, service_template.ports)
|
||||
self.assertEqual(instance.description, service_template.description)
|
||||
|
||||
|
||||
class L2VPNTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
||||
model = L2VPN
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
rts = (
|
||||
RouteTarget(name='64534:123'),
|
||||
RouteTarget(name='64534:321')
|
||||
)
|
||||
RouteTarget.objects.bulk_create(rts)
|
||||
|
||||
l2vpns = (
|
||||
L2VPN(name='L2VPN 1', slug='l2vpn-1', type=L2VPNTypeChoices.TYPE_VXLAN, identifier='650001'),
|
||||
L2VPN(name='L2VPN 2', slug='l2vpn-2', type=L2VPNTypeChoices.TYPE_VXLAN, identifier='650002'),
|
||||
L2VPN(name='L2VPN 3', slug='l2vpn-3', type=L2VPNTypeChoices.TYPE_VXLAN, identifier='650003')
|
||||
)
|
||||
L2VPN.objects.bulk_create(l2vpns)
|
||||
|
||||
cls.csv_data = (
|
||||
'name,slug,type,identifier',
|
||||
'L2VPN 5,l2vpn-5,vxlan,456',
|
||||
'L2VPN 6,l2vpn-6,vxlan,444',
|
||||
)
|
||||
|
||||
cls.csv_update_data = (
|
||||
'id,name,description',
|
||||
f'{l2vpns[0].pk},L2VPN 7,New description 7',
|
||||
f'{l2vpns[1].pk},L2VPN 8,New description 8',
|
||||
)
|
||||
|
||||
cls.bulk_edit_data = {
|
||||
'description': 'New Description',
|
||||
}
|
||||
|
||||
cls.form_data = {
|
||||
'name': 'L2VPN 8',
|
||||
'slug': 'l2vpn-8',
|
||||
'type': L2VPNTypeChoices.TYPE_VXLAN,
|
||||
'identifier': 123,
|
||||
'description': 'Description',
|
||||
'import_targets': [rts[0].pk],
|
||||
'export_targets': [rts[1].pk]
|
||||
}
|
||||
|
||||
|
||||
class L2VPNTerminationTestCase(
|
||||
ViewTestCases.GetObjectViewTestCase,
|
||||
ViewTestCases.GetObjectChangelogViewTestCase,
|
||||
ViewTestCases.CreateObjectViewTestCase,
|
||||
ViewTestCases.EditObjectViewTestCase,
|
||||
ViewTestCases.DeleteObjectViewTestCase,
|
||||
ViewTestCases.ListObjectsViewTestCase,
|
||||
ViewTestCases.BulkImportObjectsViewTestCase,
|
||||
ViewTestCases.BulkDeleteObjectsViewTestCase,
|
||||
):
|
||||
|
||||
model = L2VPNTermination
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
device = create_test_device('Device 1')
|
||||
interface = Interface.objects.create(name='Interface 1', device=device, type='1000baset')
|
||||
l2vpns = (
|
||||
L2VPN(name='L2VPN 1', slug='l2vpn-1', type=L2VPNTypeChoices.TYPE_VXLAN, identifier=650001),
|
||||
L2VPN(name='L2VPN 2', slug='l2vpn-2', type=L2VPNTypeChoices.TYPE_VXLAN, identifier=650002),
|
||||
)
|
||||
L2VPN.objects.bulk_create(l2vpns)
|
||||
|
||||
vlans = (
|
||||
VLAN(name='Vlan 1', vid=1001),
|
||||
VLAN(name='Vlan 2', vid=1002),
|
||||
VLAN(name='Vlan 3', vid=1003),
|
||||
VLAN(name='Vlan 4', vid=1004),
|
||||
VLAN(name='Vlan 5', vid=1005),
|
||||
VLAN(name='Vlan 6', vid=1006)
|
||||
)
|
||||
VLAN.objects.bulk_create(vlans)
|
||||
|
||||
terminations = (
|
||||
L2VPNTermination(l2vpn=l2vpns[0], assigned_object=vlans[0]),
|
||||
L2VPNTermination(l2vpn=l2vpns[0], assigned_object=vlans[1]),
|
||||
L2VPNTermination(l2vpn=l2vpns[0], assigned_object=vlans[2])
|
||||
)
|
||||
L2VPNTermination.objects.bulk_create(terminations)
|
||||
|
||||
cls.form_data = {
|
||||
'l2vpn': l2vpns[0].pk,
|
||||
'device': device.pk,
|
||||
'interface': interface.pk,
|
||||
}
|
||||
|
||||
cls.csv_data = (
|
||||
"l2vpn,vlan",
|
||||
"L2VPN 1,Vlan 4",
|
||||
"L2VPN 1,Vlan 5",
|
||||
"L2VPN 1,Vlan 6",
|
||||
)
|
||||
|
||||
cls.csv_update_data = (
|
||||
f"id,l2vpn",
|
||||
f"{terminations[0].pk},{l2vpns[0].name}",
|
||||
f"{terminations[1].pk},{l2vpns[0].name}",
|
||||
f"{terminations[2].pk},{l2vpns[0].name}",
|
||||
)
|
||||
|
||||
cls.bulk_edit_data = {}
|
||||
|
||||
# TODO: Fix L2VPNTerminationImportForm validation to support bulk updates
|
||||
def test_bulk_update_objects_with_permission(self):
|
||||
pass
|
||||
|
||||
#
|
||||
# Custom assertions
|
||||
#
|
||||
|
||||
# TODO: Remove this
|
||||
def assertInstanceEqual(self, instance, data, exclude=None, api=False):
|
||||
"""
|
||||
Override parent
|
||||
"""
|
||||
if exclude is None:
|
||||
exclude = []
|
||||
|
||||
fields = [k for k in data.keys() if k not in exclude]
|
||||
model_dict = self.model_to_dict(instance, fields=fields, api=api)
|
||||
|
||||
# Omit any dictionary keys which are not instance attributes or have been excluded
|
||||
relevant_data = {
|
||||
k: v for k, v in data.items() if hasattr(instance, k) and k not in exclude
|
||||
}
|
||||
|
||||
# Handle relations on the model
|
||||
for k, v in model_dict.items():
|
||||
if isinstance(v, object) and hasattr(v, 'first'):
|
||||
model_dict[k] = v.first().pk
|
||||
|
||||
self.assertDictEqual(model_dict, relevant_data)
|
||||
|
@ -131,20 +131,4 @@ urlpatterns = [
|
||||
path('services/edit/', views.ServiceBulkEditView.as_view(), name='service_bulk_edit'),
|
||||
path('services/delete/', views.ServiceBulkDeleteView.as_view(), name='service_bulk_delete'),
|
||||
path('services/<int:pk>/', include(get_model_urls('ipam', 'service'))),
|
||||
|
||||
# L2VPN
|
||||
path('l2vpns/', views.L2VPNListView.as_view(), name='l2vpn_list'),
|
||||
path('l2vpns/add/', views.L2VPNEditView.as_view(), name='l2vpn_add'),
|
||||
path('l2vpns/import/', views.L2VPNBulkImportView.as_view(), name='l2vpn_import'),
|
||||
path('l2vpns/edit/', views.L2VPNBulkEditView.as_view(), name='l2vpn_bulk_edit'),
|
||||
path('l2vpns/delete/', views.L2VPNBulkDeleteView.as_view(), name='l2vpn_bulk_delete'),
|
||||
path('l2vpns/<int:pk>/', include(get_model_urls('ipam', 'l2vpn'))),
|
||||
|
||||
# L2VPN terminations
|
||||
path('l2vpn-terminations/', views.L2VPNTerminationListView.as_view(), name='l2vpntermination_list'),
|
||||
path('l2vpn-terminations/add/', views.L2VPNTerminationEditView.as_view(), name='l2vpntermination_add'),
|
||||
path('l2vpn-terminations/import/', views.L2VPNTerminationBulkImportView.as_view(), name='l2vpntermination_import'),
|
||||
path('l2vpn-terminations/edit/', views.L2VPNTerminationBulkEditView.as_view(), name='l2vpntermination_bulk_edit'),
|
||||
path('l2vpn-terminations/delete/', views.L2VPNTerminationBulkDeleteView.as_view(), name='l2vpntermination_bulk_delete'),
|
||||
path('l2vpn-terminations/<int:pk>/', include(get_model_urls('ipam', 'l2vpntermination'))),
|
||||
]
|
||||
|
@ -1,5 +1,5 @@
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.db.models import F, Prefetch
|
||||
from django.db.models import Prefetch
|
||||
from django.db.models.expressions import RawSQL
|
||||
from django.shortcuts import get_object_or_404, redirect, render
|
||||
from django.urls import reverse
|
||||
@ -9,7 +9,6 @@ from circuits.models import Provider
|
||||
from dcim.filtersets import InterfaceFilterSet
|
||||
from dcim.models import Interface, Site
|
||||
from netbox.views import generic
|
||||
from tenancy.views import ObjectContactsView
|
||||
from utilities.tables import get_table_ordering
|
||||
from utilities.utils import count_related
|
||||
from utilities.views import ViewTab, register_model_view
|
||||
@ -19,7 +18,6 @@ from . import filtersets, forms, tables
|
||||
from .choices import PrefixStatusChoices
|
||||
from .constants import *
|
||||
from .models import *
|
||||
from .tables.l2vpn import L2VPNTable, L2VPNTerminationTable
|
||||
from .utils import add_requested_prefixes, add_available_ipaddresses, add_available_vlans
|
||||
|
||||
|
||||
@ -1243,112 +1241,3 @@ class ServiceBulkDeleteView(generic.BulkDeleteView):
|
||||
queryset = Service.objects.prefetch_related('device', 'virtual_machine')
|
||||
filterset = filtersets.ServiceFilterSet
|
||||
table = tables.ServiceTable
|
||||
|
||||
|
||||
# L2VPN
|
||||
|
||||
class L2VPNListView(generic.ObjectListView):
|
||||
queryset = L2VPN.objects.all()
|
||||
table = L2VPNTable
|
||||
filterset = filtersets.L2VPNFilterSet
|
||||
filterset_form = forms.L2VPNFilterForm
|
||||
|
||||
|
||||
@register_model_view(L2VPN)
|
||||
class L2VPNView(generic.ObjectView):
|
||||
queryset = L2VPN.objects.all()
|
||||
|
||||
def get_extra_context(self, request, instance):
|
||||
import_targets_table = tables.RouteTargetTable(
|
||||
instance.import_targets.prefetch_related('tenant'),
|
||||
orderable=False
|
||||
)
|
||||
export_targets_table = tables.RouteTargetTable(
|
||||
instance.export_targets.prefetch_related('tenant'),
|
||||
orderable=False
|
||||
)
|
||||
|
||||
return {
|
||||
'import_targets_table': import_targets_table,
|
||||
'export_targets_table': export_targets_table,
|
||||
}
|
||||
|
||||
|
||||
@register_model_view(L2VPN, 'edit')
|
||||
class L2VPNEditView(generic.ObjectEditView):
|
||||
queryset = L2VPN.objects.all()
|
||||
form = forms.L2VPNForm
|
||||
|
||||
|
||||
@register_model_view(L2VPN, 'delete')
|
||||
class L2VPNDeleteView(generic.ObjectDeleteView):
|
||||
queryset = L2VPN.objects.all()
|
||||
|
||||
|
||||
class L2VPNBulkImportView(generic.BulkImportView):
|
||||
queryset = L2VPN.objects.all()
|
||||
model_form = forms.L2VPNImportForm
|
||||
|
||||
|
||||
class L2VPNBulkEditView(generic.BulkEditView):
|
||||
queryset = L2VPN.objects.all()
|
||||
filterset = filtersets.L2VPNFilterSet
|
||||
table = tables.L2VPNTable
|
||||
form = forms.L2VPNBulkEditForm
|
||||
|
||||
|
||||
class L2VPNBulkDeleteView(generic.BulkDeleteView):
|
||||
queryset = L2VPN.objects.all()
|
||||
filterset = filtersets.L2VPNFilterSet
|
||||
table = tables.L2VPNTable
|
||||
|
||||
|
||||
@register_model_view(L2VPN, 'contacts')
|
||||
class L2VPNContactsView(ObjectContactsView):
|
||||
queryset = L2VPN.objects.all()
|
||||
|
||||
|
||||
#
|
||||
# L2VPN terminations
|
||||
#
|
||||
|
||||
class L2VPNTerminationListView(generic.ObjectListView):
|
||||
queryset = L2VPNTermination.objects.all()
|
||||
table = L2VPNTerminationTable
|
||||
filterset = filtersets.L2VPNTerminationFilterSet
|
||||
filterset_form = forms.L2VPNTerminationFilterForm
|
||||
|
||||
|
||||
@register_model_view(L2VPNTermination)
|
||||
class L2VPNTerminationView(generic.ObjectView):
|
||||
queryset = L2VPNTermination.objects.all()
|
||||
|
||||
|
||||
@register_model_view(L2VPNTermination, 'edit')
|
||||
class L2VPNTerminationEditView(generic.ObjectEditView):
|
||||
queryset = L2VPNTermination.objects.all()
|
||||
form = forms.L2VPNTerminationForm
|
||||
template_name = 'ipam/l2vpntermination_edit.html'
|
||||
|
||||
|
||||
@register_model_view(L2VPNTermination, 'delete')
|
||||
class L2VPNTerminationDeleteView(generic.ObjectDeleteView):
|
||||
queryset = L2VPNTermination.objects.all()
|
||||
|
||||
|
||||
class L2VPNTerminationBulkImportView(generic.BulkImportView):
|
||||
queryset = L2VPNTermination.objects.all()
|
||||
model_form = forms.L2VPNTerminationImportForm
|
||||
|
||||
|
||||
class L2VPNTerminationBulkEditView(generic.BulkEditView):
|
||||
queryset = L2VPNTermination.objects.all()
|
||||
filterset = filtersets.L2VPNTerminationFilterSet
|
||||
table = tables.L2VPNTerminationTable
|
||||
form = forms.L2VPNTerminationBulkEditForm
|
||||
|
||||
|
||||
class L2VPNTerminationBulkDeleteView(generic.BulkDeleteView):
|
||||
queryset = L2VPNTermination.objects.all()
|
||||
filterset = filtersets.L2VPNTerminationFilterSet
|
||||
table = tables.L2VPNTerminationTable
|
||||
|
@ -39,6 +39,7 @@ class APIRootView(APIView):
|
||||
'tenancy': reverse('tenancy-api:api-root', request=request, format=format),
|
||||
'users': reverse('users-api:api-root', request=request, format=format),
|
||||
'virtualization': reverse('virtualization-api:api-root', request=request, format=format),
|
||||
'vpn': reverse('vpn-api:api-root', request=request, format=format),
|
||||
'wireless': reverse('wireless-api:api-root', request=request, format=format),
|
||||
})
|
||||
|
||||
|
@ -74,7 +74,7 @@ class Config:
|
||||
|
||||
def _populate_from_db(self):
|
||||
"""Cache data from latest ConfigRevision, then populate from cache"""
|
||||
from extras.models import ConfigRevision
|
||||
from core.models import ConfigRevision
|
||||
|
||||
try:
|
||||
revision = ConfigRevision.objects.last()
|
||||
|
@ -4,11 +4,11 @@ from django.db.models import Q
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from extras.choices import *
|
||||
from extras.forms.mixins import CustomFieldsMixin, SavedFiltersMixin, TagsMixin
|
||||
from extras.models import CustomField, Tag
|
||||
from utilities.forms import CSVModelForm
|
||||
from utilities.forms.fields import CSVModelMultipleChoiceField, DynamicModelMultipleChoiceField
|
||||
from utilities.forms.mixins import BootstrapMixin, CheckLastUpdatedMixin
|
||||
from .mixins import CustomFieldsMixin, SavedFiltersMixin, TagsMixin
|
||||
|
||||
__all__ = (
|
||||
'NetBoxModelForm',
|
||||
|
@ -9,6 +9,7 @@ from netbox.registry import registry
|
||||
from tenancy.graphql.schema import TenancyQuery
|
||||
from users.graphql.schema import UsersQuery
|
||||
from virtualization.graphql.schema import VirtualizationQuery
|
||||
from vpn.graphql.schema import VPNQuery
|
||||
from wireless.graphql.schema import WirelessQuery
|
||||
|
||||
|
||||
@ -21,6 +22,7 @@ class Query(
|
||||
IPAMQuery,
|
||||
TenancyQuery,
|
||||
VirtualizationQuery,
|
||||
VPNQuery,
|
||||
WirelessQuery,
|
||||
*registry['plugins']['graphql_schemas'], # Append plugin schemas
|
||||
graphene.ObjectType
|
||||
|
@ -195,15 +195,32 @@ IPAM_MENU = Menu(
|
||||
),
|
||||
)
|
||||
|
||||
OVERLAY_MENU = Menu(
|
||||
label=_('Overlay'),
|
||||
VPN_MENU = Menu(
|
||||
label=_('VPN'),
|
||||
icon_class='mdi mdi-graph-outline',
|
||||
groups=(
|
||||
MenuGroup(
|
||||
label='L2VPNs',
|
||||
label=_('Tunnels'),
|
||||
items=(
|
||||
get_model_item('ipam', 'l2vpn', _('L2VPNs')),
|
||||
get_model_item('ipam', 'l2vpntermination', _('Terminations')),
|
||||
get_model_item('vpn', 'tunnel', _('Tunnels')),
|
||||
get_model_item('vpn', 'tunneltermination', _('Tunnel Terminations')),
|
||||
),
|
||||
),
|
||||
MenuGroup(
|
||||
label=_('L2VPNs'),
|
||||
items=(
|
||||
get_model_item('vpn', 'l2vpn', _('L2VPNs')),
|
||||
get_model_item('vpn', 'l2vpntermination', _('Terminations')),
|
||||
),
|
||||
),
|
||||
MenuGroup(
|
||||
label=_('Security'),
|
||||
items=(
|
||||
get_model_item('vpn', 'ikeproposal', _('IKE Proposals')),
|
||||
get_model_item('vpn', 'ikepolicy', _('IKE Policies')),
|
||||
get_model_item('vpn', 'ipsecproposal', _('IPSec Proposals')),
|
||||
get_model_item('vpn', 'ipsecpolicy', _('IPSec Policies')),
|
||||
get_model_item('vpn', 'ipsecprofile', _('IPSec Profiles')),
|
||||
),
|
||||
),
|
||||
),
|
||||
@ -424,13 +441,13 @@ ADMIN_MENU = Menu(
|
||||
MenuItem(
|
||||
link='core:config',
|
||||
link_text=_('Current Config'),
|
||||
permissions=['extras.view_configrevision'],
|
||||
permissions=['core.view_configrevision'],
|
||||
staff_only=True
|
||||
),
|
||||
MenuItem(
|
||||
link='extras:configrevision_list',
|
||||
link='core:configrevision_list',
|
||||
link_text=_('Config Revisions'),
|
||||
permissions=['extras.view_configrevision'],
|
||||
permissions=['core.view_configrevision'],
|
||||
staff_only=True
|
||||
),
|
||||
),
|
||||
@ -444,7 +461,7 @@ MENUS = [
|
||||
CONNECTIONS_MENU,
|
||||
WIRELESS_MENU,
|
||||
IPAM_MENU,
|
||||
OVERLAY_MENU,
|
||||
VPN_MENU,
|
||||
VIRTUALIZATION_MENU,
|
||||
CIRCUITS_MENU,
|
||||
POWER_MENU,
|
||||
|
@ -27,7 +27,7 @@ from netbox.plugins import PluginConfig
|
||||
# Environment setup
|
||||
#
|
||||
|
||||
VERSION = '3.6.6-dev'
|
||||
VERSION = '3.6.7-dev'
|
||||
|
||||
# Hostname
|
||||
HOSTNAME = platform.node()
|
||||
@ -379,6 +379,7 @@ INSTALLED_APPS = [
|
||||
'users',
|
||||
'utilities',
|
||||
'virtualization',
|
||||
'vpn',
|
||||
'wireless',
|
||||
'django_rq', # Must come after extras to allow overriding management commands
|
||||
'drf_spectacular',
|
||||
|
@ -2,7 +2,7 @@ from django.conf import settings
|
||||
from django.core.cache import cache
|
||||
from django.test import override_settings, TestCase
|
||||
|
||||
from extras.models import ConfigRevision
|
||||
from core.models import ConfigRevision
|
||||
from netbox.config import clear_config, get_config
|
||||
|
||||
|
||||
|
@ -33,6 +33,7 @@ _patterns = [
|
||||
path('tenancy/', include('tenancy.urls')),
|
||||
path('users/', include('users.urls')),
|
||||
path('virtualization/', include('virtualization.urls')),
|
||||
path('vpn/', include('vpn.urls')),
|
||||
path('wireless/', include('wireless.urls')),
|
||||
|
||||
# Current user views
|
||||
@ -51,6 +52,7 @@ _patterns = [
|
||||
path('api/tenancy/', include('tenancy.api.urls')),
|
||||
path('api/users/', include('users.api.urls')),
|
||||
path('api/virtualization/', include('virtualization.api.urls')),
|
||||
path('api/vpn/', include('vpn.api.urls')),
|
||||
path('api/wireless/', include('wireless.api.urls')),
|
||||
path('api/status/', StatusView.as_view(), name='api-status'),
|
||||
|
||||
|
@ -394,6 +394,10 @@ class BulkImportView(GetReturnURLMixin, BaseMultiObjectView):
|
||||
form.add_error('data', f"Row {i}: Object with ID {object_id} does not exist")
|
||||
raise ValidationError('')
|
||||
|
||||
# Take a snapshot for change logging
|
||||
if instance.pk and hasattr(instance, 'snapshot'):
|
||||
instance.snapshot()
|
||||
|
||||
# Instantiate the model form for the object
|
||||
model_form_kwargs = {
|
||||
'data': record,
|
||||
|
@ -14,11 +14,11 @@
|
||||
<div class="controls">
|
||||
<div class="control-group">
|
||||
{% plugin_buttons object %}
|
||||
{% if not object.pk or object.is_active and perms.extras.add_configrevision %}
|
||||
{% url 'extras:configrevision_add' as edit_url %}
|
||||
{% if not object.pk or object.is_active and perms.core.add_configrevision %}
|
||||
{% url 'core:configrevision_add' as edit_url %}
|
||||
{% include "buttons/edit.html" with url=edit_url %}
|
||||
{% endif %}
|
||||
{% if object.pk and not object.is_active and perms.extras.delete_configrevision %}
|
||||
{% if object.pk and not object.is_active and perms.core.delete_configrevision %}
|
||||
{% delete_button object %}
|
||||
{% endif %}
|
||||
</div>
|
@ -18,8 +18,8 @@
|
||||
<div class="col col-md-12">
|
||||
<nav class="breadcrumb-container px-3" aria-label="breadcrumb">
|
||||
<ol class="breadcrumb">
|
||||
<li class="breadcrumb-item"><a href="{% url 'extras:configrevision_list' %}">{% trans "Config revisions" %}</a></li>
|
||||
<li class="breadcrumb-item"><a href="{% url 'extras:configrevision' pk=object.pk %}">{{ object }}</a></li>
|
||||
<li class="breadcrumb-item"><a href="{% url 'core:configrevision_list' %}">{% trans "Config revisions" %}</a></li>
|
||||
<li class="breadcrumb-item"><a href="{% url 'core:configrevision' pk=object.pk %}">{{ object }}</a></li>
|
||||
</ol>
|
||||
</nav>
|
||||
</div>
|
||||
@ -77,7 +77,7 @@
|
||||
<div class="controls">
|
||||
<div class="control-group">
|
||||
<button type="submit" name="restore" class="btn btn-primary">{% trans "Restore" %}</button>
|
||||
<a href="{% url 'extras:configrevision_list' %}" id="cancel" name="cancel" class="btn btn-outline-danger">{% trans "Cancel" %}</a>
|
||||
<a href="{% url 'core:configrevision_list' %}" id="cancel" name="cancel" class="btn btn-outline-danger">{% trans "Cancel" %}</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
@ -5,6 +5,7 @@
|
||||
{% load helpers %}
|
||||
{% load plugins %}
|
||||
{% load i18n %}
|
||||
{% load mptt %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row">
|
||||
@ -15,16 +16,7 @@
|
||||
<table class="table table-hover attr-table">
|
||||
<tr>
|
||||
<th scope="row">{% trans "Region" %}</th>
|
||||
<td>
|
||||
{% if object.site.region %}
|
||||
{% for region in object.site.region.get_ancestors %}
|
||||
{{ region|linkify }} /
|
||||
{% endfor %}
|
||||
{{ object.site.region|linkify }}
|
||||
{% else %}
|
||||
{{ ''|placeholder }}
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{% nested_tree object.site.region %}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Site" %}</th>
|
||||
@ -32,16 +24,7 @@
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Location" %}</th>
|
||||
<td>
|
||||
{% if object.location %}
|
||||
{% for location in object.location.get_ancestors %}
|
||||
{{ location|linkify }} /
|
||||
{% endfor %}
|
||||
{{ object.location|linkify }}
|
||||
{% else %}
|
||||
{{ ''|placeholder }}
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{% nested_tree object.location %}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Rack" %}</th>
|
||||
|
@ -4,6 +4,7 @@
|
||||
{% load static %}
|
||||
{% load plugins %}
|
||||
{% load i18n %}
|
||||
{% load mptt %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row">
|
||||
@ -15,26 +16,18 @@
|
||||
<div class="card-body">
|
||||
<table class="table table-hover attr-table">
|
||||
<tr>
|
||||
<th scope="row">{% trans "Site" %}</th>
|
||||
<th scope="row">{% trans "Region" %}</th>
|
||||
<td>
|
||||
{% if object.site.region %}
|
||||
{{ object.site.region|linkify }} /
|
||||
{% endif %}
|
||||
{{ object.site|linkify }}
|
||||
{% nested_tree object.site.region %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Site" %}</th>
|
||||
<td>{{ object.site|linkify }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Location" %}</th>
|
||||
<td>
|
||||
{% if object.location %}
|
||||
{% for location in object.location.get_ancestors %}
|
||||
{{ location|linkify }} /
|
||||
{% endfor %}
|
||||
{{ object.location|linkify }}
|
||||
{% else %}
|
||||
{{ ''|placeholder }}
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{% nested_tree object.location %}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Facility ID" %}</th>
|
||||
|
@ -4,6 +4,7 @@
|
||||
{% load static %}
|
||||
{% load plugins %}
|
||||
{% load i18n %}
|
||||
{% load mptt %}
|
||||
|
||||
{% block breadcrumbs %}
|
||||
{{ block.super }}
|
||||
@ -20,25 +21,24 @@
|
||||
</h5>
|
||||
<div class="card-body">
|
||||
<table class="table table-hover attr-table">
|
||||
{% with rack=object.rack %}
|
||||
<tr>
|
||||
<th scope="row">{% trans "Site" %}</th>
|
||||
<td>
|
||||
{% if rack.site.region %}
|
||||
{{ rack.site.region|linkify }} /
|
||||
{% endif %}
|
||||
{{ rack.site|linkify }}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Location" %}</th>
|
||||
<td>{{ rack.location|linkify|placeholder }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Rack" %}</th>
|
||||
<td>{{ rack|linkify }}</td>
|
||||
</tr>
|
||||
{% endwith %}
|
||||
<tr>
|
||||
<th scope="row">{% trans "Region" %}</th>
|
||||
<td>
|
||||
{% nested_tree object.rack.site.region %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Site" %}</th>
|
||||
<td>{{ object.rack.site|linkify }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Location" %}</th>
|
||||
<td>{{ object.rack.location|linkify|placeholder }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Rack" %}</th>
|
||||
<td>{{ object.rack|linkify }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -3,6 +3,7 @@
|
||||
{% load plugins %}
|
||||
{% load tz %}
|
||||
{% load i18n %}
|
||||
{% load mptt %}
|
||||
|
||||
{% block breadcrumbs %}
|
||||
{{ block.super }}
|
||||
@ -29,27 +30,13 @@
|
||||
<tr>
|
||||
<th scope="row">{% trans "Region" %}</th>
|
||||
<td>
|
||||
{% if object.region %}
|
||||
{% for region in object.region.get_ancestors %}
|
||||
{{ region|linkify }} /
|
||||
{% endfor %}
|
||||
{{ object.region|linkify }}
|
||||
{% else %}
|
||||
{{ ''|placeholder }}
|
||||
{% endif %}
|
||||
{% nested_tree object.region %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Group" %}</th>
|
||||
<td>
|
||||
{% if object.group %}
|
||||
{% for group in object.group.get_ancestors %}
|
||||
{{ group|linkify }} /
|
||||
{% endfor %}
|
||||
{{ object.group|linkify }}
|
||||
{% else %}
|
||||
{{ ''|placeholder }}
|
||||
{% endif %}
|
||||
{% nested_tree object.group %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
|
@ -3,6 +3,7 @@
|
||||
{% load helpers %}
|
||||
{% load plugins %}
|
||||
{% load i18n %}
|
||||
{% load mptt %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row">
|
||||
@ -44,18 +45,17 @@
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% if object.site.region %}
|
||||
<tr>
|
||||
<th scope="row">{% trans "Region" %}</th>
|
||||
<td>
|
||||
{% nested_tree object.site.region %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
<tr>
|
||||
<th scope="row">{% trans "Site" %}</th>
|
||||
<td>
|
||||
{% if object.site %}
|
||||
{% if object.site.region %}
|
||||
{{ object.site.region|linkify }} /
|
||||
{% endif %}
|
||||
{{ object.site|linkify }}
|
||||
{% else %}
|
||||
{{ ''|placeholder }}
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ object.site|linkify|placeholder }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "VLAN" %}</th>
|
||||
|
@ -59,7 +59,7 @@
|
||||
<div class="card">
|
||||
<h5 class="card-header">{% trans "Importing L2VPNs" %}</h5>
|
||||
<div class="card-body htmx-container table-responsive"
|
||||
hx-get="{% url 'ipam:l2vpn_list' %}?import_target_id={{ object.pk }}"
|
||||
hx-get="{% url 'vpn:l2vpn_list' %}?import_target_id={{ object.pk }}"
|
||||
hx-trigger="load"
|
||||
></div>
|
||||
</div>
|
||||
@ -68,7 +68,7 @@
|
||||
<div class="card">
|
||||
<h5 class="card-header">{% trans "Exporting L2VPNs" %}</h5>
|
||||
<div class="card-body htmx-container table-responsive"
|
||||
hx-get="{% url 'ipam:l2vpn_list' %}?export_target_id={{ object.pk }}"
|
||||
hx-get="{% url 'vpn:l2vpn_list' %}?export_target_id={{ object.pk }}"
|
||||
hx-trigger="load"
|
||||
></div>
|
||||
</div>
|
||||
|
@ -3,6 +3,7 @@
|
||||
{% load render_table from django_tables2 %}
|
||||
{% load plugins %}
|
||||
{% load i18n %}
|
||||
{% load mptt %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row">
|
||||
@ -13,18 +14,17 @@
|
||||
</h5>
|
||||
<div class="card-body">
|
||||
<table class="table table-hover attr-table">
|
||||
{% if object.site.region %}
|
||||
<tr>
|
||||
<th scope="row">{% trans "Region" %}</th>
|
||||
<td>
|
||||
{% nested_tree object.site.region %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
<tr>
|
||||
<th scope="row">{% trans "Site" %}</th>
|
||||
<td>
|
||||
{% if object.site %}
|
||||
{% if object.site.region %}
|
||||
{{ object.site.region|linkify }} /
|
||||
{% endif %}
|
||||
{{ object.site|linkify }}
|
||||
{% else %}
|
||||
{{ ''|placeholder }}
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ object.site|linkify|placeholder }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Group" %}</th>
|
||||
|
67
netbox/templates/vpn/ikepolicy.html
Normal file
67
netbox/templates/vpn/ikepolicy.html
Normal file
@ -0,0 +1,67 @@
|
||||
{% extends 'generic/object.html' %}
|
||||
{% load helpers %}
|
||||
{% load plugins %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row">
|
||||
<div class="col col-md-6">
|
||||
<div class="card">
|
||||
<h5 class="card-header">{% trans "IKE Policy" %}</h5>
|
||||
<div class="card-body">
|
||||
<table class="table table-hover attr-table">
|
||||
<tr>
|
||||
<th scope="row">{% trans "Name" %}</th>
|
||||
<td>{{ object.name }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Description" %}</th>
|
||||
<td>{{ object.description|placeholder }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "IKE Version" %}</th>
|
||||
<td>{{ object.get_version_display }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Mode" %}</th>
|
||||
<td>{{ object.get_mode_display }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Pre-Shared Key" %}</th>
|
||||
<td>
|
||||
<span id="secret" class="font-monospace" data-secret="{{ object.preshared_key }}">{{ object.preshared_key|placeholder }}</span>
|
||||
{% if object.preshared_key %}
|
||||
<button type="button" class="btn btn-sm btn-primary toggle-secret float-end" data-bs-toggle="button">{% trans "Show Secret" %}</button>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "IPSec Profiles" %}</th>
|
||||
<td>
|
||||
<a href="{% url 'vpn:ipsecprofile_list' %}?ike_policy_id={{ object.pk }}">{{ object.ipsec_profiles.count }}</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% plugin_left_page object %}
|
||||
</div>
|
||||
<div class="col col-md-6">
|
||||
{% include 'inc/panels/custom_fields.html' %}
|
||||
{% include 'inc/panels/tags.html' %}
|
||||
{% plugin_right_page object %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col col-md-12">
|
||||
<div class="card">
|
||||
<h5 class="card-header">{% trans "Proposals" %}</h5>
|
||||
<div class="card-body htmx-container table-responsive"
|
||||
hx-get="{% url 'vpn:ikeproposal_list' %}?ike_policy_id={{ object.pk }}"
|
||||
hx-trigger="load"
|
||||
></div>
|
||||
</div>
|
||||
{% plugin_full_width_page object %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
63
netbox/templates/vpn/ikeproposal.html
Normal file
63
netbox/templates/vpn/ikeproposal.html
Normal file
@ -0,0 +1,63 @@
|
||||
{% extends 'generic/object.html' %}
|
||||
{% load helpers %}
|
||||
{% load plugins %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row">
|
||||
<div class="col col-md-6">
|
||||
<div class="card">
|
||||
<h5 class="card-header">{% trans "IKE Proposal" %}</h5>
|
||||
<div class="card-body">
|
||||
<table class="table table-hover attr-table">
|
||||
<tr>
|
||||
<th scope="row">{% trans "Name" %}</th>
|
||||
<td>{{ object.name }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Description" %}</th>
|
||||
<td>{{ object.description|placeholder }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Authentication method" %}</th>
|
||||
<td>{{ object.get_authentication_method_display }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Encryption algorithm" %}</th>
|
||||
<td>{{ object.get_encryption_algorithm_display }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Authentication algorithm" %}</th>
|
||||
<td>{{ object.get_authentication_algorithm_display }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "DH group" %}</th>
|
||||
<td>{{ object.get_group_display }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "SA lifetime (seconds)" %}</th>
|
||||
<td>{{ object.sa_lifetime|placeholder }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "IKE Policies" %}</th>
|
||||
<td>
|
||||
<a href="{% url 'vpn:ikepolicy_list' %}?proposal_id={{ object.pk }}">{{ object.ike_policies.count }}</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% plugin_left_page object %}
|
||||
</div>
|
||||
<div class="col col-md-6">
|
||||
{% include 'inc/panels/custom_fields.html' %}
|
||||
{% include 'inc/panels/tags.html' %}
|
||||
{% plugin_right_page object %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col col-md-12">
|
||||
{% plugin_full_width_page object %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
55
netbox/templates/vpn/ipsecpolicy.html
Normal file
55
netbox/templates/vpn/ipsecpolicy.html
Normal file
@ -0,0 +1,55 @@
|
||||
{% extends 'generic/object.html' %}
|
||||
{% load helpers %}
|
||||
{% load plugins %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row">
|
||||
<div class="col col-md-6">
|
||||
<div class="card">
|
||||
<h5 class="card-header">{% trans "IPSec Policy" %}</h5>
|
||||
<div class="card-body">
|
||||
<table class="table table-hover attr-table">
|
||||
<tr>
|
||||
<th scope="row">{% trans "Name" %}</th>
|
||||
<td>{{ object.name }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Description" %}</th>
|
||||
<td>{{ object.description|placeholder }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "PFS group" %}</th>
|
||||
<td>{{ object.get_pfs_group_display|placeholder }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "IPSec Profiles" %}</th>
|
||||
<td>
|
||||
<a href="{% url 'vpn:ipsecprofile_list' %}?ipsec_policy_id={{ object.pk }}">{{ object.ipsec_profiles.count }}</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% plugin_left_page object %}
|
||||
</div>
|
||||
<div class="col col-md-6">
|
||||
{% include 'inc/panels/custom_fields.html' %}
|
||||
{% include 'inc/panels/tags.html' %}
|
||||
{% plugin_right_page object %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col col-md-12">
|
||||
<div class="col col-md-12">
|
||||
<div class="card">
|
||||
<h5 class="card-header">{% trans "Proposals" %}</h5>
|
||||
<div class="card-body htmx-container table-responsive"
|
||||
hx-get="{% url 'vpn:ipsecproposal_list' %}?ipsec_policy_id={{ object.pk }}"
|
||||
hx-trigger="load"
|
||||
></div>
|
||||
</div>
|
||||
{% plugin_full_width_page object %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
112
netbox/templates/vpn/ipsecprofile.html
Normal file
112
netbox/templates/vpn/ipsecprofile.html
Normal file
@ -0,0 +1,112 @@
|
||||
{% extends 'generic/object.html' %}
|
||||
{% load helpers %}
|
||||
{% load plugins %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row">
|
||||
<div class="col col-md-6">
|
||||
<div class="card">
|
||||
<h5 class="card-header">{% trans "IPSec Profile" %}</h5>
|
||||
<div class="card-body">
|
||||
<table class="table table-hover attr-table">
|
||||
<tr>
|
||||
<th scope="row">{% trans "Name" %}</th>
|
||||
<td>{{ object.name }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Description" %}</th>
|
||||
<td>{{ object.description|placeholder }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Mode" %}</th>
|
||||
<td>{{ object.get_mode_display }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% include 'inc/panels/tags.html' %}
|
||||
{% include 'inc/panels/custom_fields.html' %}
|
||||
{% include 'inc/panels/comments.html' %}
|
||||
{% plugin_left_page object %}
|
||||
</div>
|
||||
<div class="col col-md-6">
|
||||
<div class="card">
|
||||
<h5 class="card-header">{% trans "IKE Policy" %}</h5>
|
||||
<div class="card-body">
|
||||
<table class="table table-hover attr-table">
|
||||
<tr>
|
||||
<th scope="row">{% trans "Name" %}</th>
|
||||
<td>{{ object.ike_policy|linkify }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Description" %}</th>
|
||||
<td>{{ object.ike_policy.description|placeholder }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Version" %}</th>
|
||||
<td>{{ object.ike_policy.get_version_display }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Mode" %}</th>
|
||||
<td>{{ object.ike_policy.get_mode_display }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Proposals" %}</th>
|
||||
<td>
|
||||
<ul class="list-unstyled mb-0">
|
||||
{% for proposal in object.ike_policy.proposals.all %}
|
||||
<li>
|
||||
<a href="{{ proposal.get_absolute_url }}">{{ proposal }}</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Pre-Shared Key" %}</th>
|
||||
<td>{% checkmark object.ike_policy.preshared_key %}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h5 class="card-header">{% trans "IPSec Policy" %}</h5>
|
||||
<div class="card-body">
|
||||
<table class="table table-hover attr-table">
|
||||
<tr>
|
||||
<th scope="row">{% trans "Name" %}</th>
|
||||
<td>{{ object.ipsec_policy|linkify }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Description" %}</th>
|
||||
<td>{{ object.ipsec_policy.description|placeholder }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Proposals" %}</th>
|
||||
<td>
|
||||
<ul class="list-unstyled mb-0">
|
||||
{% for proposal in object.ipsec_policy.proposals.all %}
|
||||
<li>
|
||||
<a href="{{ proposal.get_absolute_url }}">{{ proposal }}</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "PFS Group" %}</th>
|
||||
<td>{{ object.ipsec_policy.get_pfs_group_display }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% plugin_right_page object %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col col-md-12">
|
||||
{% plugin_full_width_page object %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user