mirror of
https://github.com/netbox-community/netbox.git
synced 2025-07-23 04:22:01 -06:00
Merge branch 'feature' into 9102-cabling
This commit is contained in:
commit
ba079b9ee5
6
NOTICE
6
NOTICE
@ -1 +1,7 @@
|
|||||||
Copyrighted and licensed under Apache License 2.0 by DigitalOcean, LLC.
|
Copyrighted and licensed under Apache License 2.0 by DigitalOcean, LLC.
|
||||||
|
|
||||||
|
This project contains code developed expressly for NetBox, and its reuse in
|
||||||
|
other projects may introduce issues affecting performance, data integrity,
|
||||||
|
and security.
|
||||||
|
|
||||||
|
For more information, please see https://github.com/netbox-community/netbox.
|
||||||
|
@ -4,7 +4,7 @@ NetBox v2.9 introduced a new object-based permissions framework, which replaces
|
|||||||
|
|
||||||
{!models/users/objectpermission.md!}
|
{!models/users/objectpermission.md!}
|
||||||
|
|
||||||
### Example Constraint Definitions
|
#### Example Constraint Definitions
|
||||||
|
|
||||||
| Constraints | Description |
|
| Constraints | Description |
|
||||||
| ----------- | ----------- |
|
| ----------- | ----------- |
|
||||||
|
@ -43,18 +43,6 @@ changes in the database indefinitely.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## JOBRESULT_RETENTION
|
|
||||||
|
|
||||||
Default: 90
|
|
||||||
|
|
||||||
The number of days to retain job results (scripts and reports). Set this to `0` to retain
|
|
||||||
job results in the database indefinitely.
|
|
||||||
|
|
||||||
!!! warning
|
|
||||||
If enabling indefinite job results retention, it is recommended to periodically delete old entries. Otherwise, the database may eventually exceed capacity.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## CUSTOM_VALIDATORS
|
## CUSTOM_VALIDATORS
|
||||||
|
|
||||||
This is a mapping of models to [custom validators](../customization/custom-validation.md) that have been defined locally to enforce custom validation logic. An example is provided below:
|
This is a mapping of models to [custom validators](../customization/custom-validation.md) that have been defined locally to enforce custom validation logic. An example is provided below:
|
||||||
@ -110,6 +98,18 @@ Setting this to False will disable the GraphQL API.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## JOBRESULT_RETENTION
|
||||||
|
|
||||||
|
Default: 90
|
||||||
|
|
||||||
|
The number of days to retain job results (scripts and reports). Set this to `0` to retain
|
||||||
|
job results in the database indefinitely.
|
||||||
|
|
||||||
|
!!! warning
|
||||||
|
If enabling indefinite job results retention, it is recommended to periodically delete old entries. Otherwise, the database may eventually exceed capacity.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## MAINTENANCE_MODE
|
## MAINTENANCE_MODE
|
||||||
|
|
||||||
Default: False
|
Default: False
|
||||||
@ -185,6 +185,30 @@ The default maximum number of objects to display per page within each list of ob
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## POWERFEED_DEFAULT_AMPERAGE
|
||||||
|
|
||||||
|
Default: 15
|
||||||
|
|
||||||
|
The default value for the `amperage` field when creating new power feeds.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## POWERFEED_DEFAULT_MAX_UTILIZATION
|
||||||
|
|
||||||
|
Default: 80
|
||||||
|
|
||||||
|
The default value (percentage) for the `max_utilization` field when creating new power feeds.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## POWERFEED_DEFAULT_VOLTAGE
|
||||||
|
|
||||||
|
Default: 120
|
||||||
|
|
||||||
|
The default value for the `voltage` field when creating new power feeds.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## PREFER_IPV4
|
## PREFER_IPV4
|
||||||
|
|
||||||
Default: False
|
Default: False
|
||||||
|
@ -26,3 +26,8 @@
|
|||||||
---
|
---
|
||||||
|
|
||||||
{!models/ipam/asn.md!}
|
{!models/ipam/asn.md!}
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
{!models/ipam/l2vpn.md!}
|
||||||
|
{!models/ipam/l2vpntermination.md!}
|
||||||
|
@ -45,6 +45,8 @@ The Django [content types](https://docs.djangoproject.com/en/stable/ref/contrib/
|
|||||||
* [ipam.FHRPGroup](../models/ipam/fhrpgroup.md)
|
* [ipam.FHRPGroup](../models/ipam/fhrpgroup.md)
|
||||||
* [ipam.IPAddress](../models/ipam/ipaddress.md)
|
* [ipam.IPAddress](../models/ipam/ipaddress.md)
|
||||||
* [ipam.IPRange](../models/ipam/iprange.md)
|
* [ipam.IPRange](../models/ipam/iprange.md)
|
||||||
|
* [ipam.L2VPN](../models/ipam/l2vpn.md)
|
||||||
|
* [ipam.L2VPNTermination](../models/ipam/l2vpntermination.md)
|
||||||
* [ipam.Prefix](../models/ipam/prefix.md)
|
* [ipam.Prefix](../models/ipam/prefix.md)
|
||||||
* [ipam.RouteTarget](../models/ipam/routetarget.md)
|
* [ipam.RouteTarget](../models/ipam/routetarget.md)
|
||||||
* [ipam.Service](../models/ipam/service.md)
|
* [ipam.Service](../models/ipam/service.md)
|
||||||
|
21
docs/models/ipam/l2vpn.md
Normal file
21
docs/models/ipam/l2vpn.md
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
# L2VPN
|
||||||
|
|
||||||
|
A L2VPN object is NetBox is a representation of a layer 2 bridge technology such as VXLAN, VPLS or EPL. Each L2VPN can be identified by name as well as an optional unique identifier (VNI would be an example).
|
||||||
|
|
||||||
|
Each L2VPN instance must have one of the following type associated with it:
|
||||||
|
|
||||||
|
* VPLS
|
||||||
|
* VPWS
|
||||||
|
* EPL
|
||||||
|
* EVPL
|
||||||
|
* EP-LAN
|
||||||
|
* EVP-LAN
|
||||||
|
* EP-TREE
|
||||||
|
* EVP-TREE
|
||||||
|
* VXLAN
|
||||||
|
* VXLAN EVPN
|
||||||
|
* MPLS-EVPN
|
||||||
|
* PBB-EVPN
|
||||||
|
|
||||||
|
!!!note
|
||||||
|
Choosing VPWS, EPL, EP-LAN, EP-TREE will result in only being able to add 2 terminations to a given L2VPN.
|
15
docs/models/ipam/l2vpntermination.md
Normal file
15
docs/models/ipam/l2vpntermination.md
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
# L2VPN Termination
|
||||||
|
|
||||||
|
A L2VPN Termination is the termination point of a L2VPN. Certain types of L2VPN's may only have 2 termination points (point-to-point) while others may have many terminations (multipoint).
|
||||||
|
|
||||||
|
Each termination consists of a L2VPN it is a member of as well as the connected endpoint which can be an interface or a VLAN.
|
||||||
|
|
||||||
|
The following types of L2VPN's are considered point-to-point:
|
||||||
|
|
||||||
|
* VPWS
|
||||||
|
* EPL
|
||||||
|
* EP-LAN
|
||||||
|
* EP-TREE
|
||||||
|
|
||||||
|
!!!note
|
||||||
|
Choosing any of the above types of L2VPN's will result in only being able to add 2 terminations to a given L2VPN.
|
@ -53,3 +53,17 @@ To achieve a logical OR with a different set of constraints, define multiple obj
|
|||||||
```
|
```
|
||||||
|
|
||||||
Additionally, where multiple permissions have been assigned for an object type, their collective constraints will be merged using a logical "OR" operation.
|
Additionally, where multiple permissions have been assigned for an object type, their collective constraints will be merged using a logical "OR" operation.
|
||||||
|
|
||||||
|
### Tokens
|
||||||
|
|
||||||
|
!!! info "This feature was introduced in NetBox v3.3"
|
||||||
|
|
||||||
|
When defining a permission constraint, administrators may use the special token `$user` to reference the current user at the time of evaluation. This can be helpful to restrict users to editing only their own journal entries, for example. Such a constraint might be defined as:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"created_by": "$user"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The `$user` token can be used only as a constraint value, or as an item within a list of values. It cannot be modified or extended to reference specific user attributes.
|
||||||
|
28
docs/plugins/development/exceptions.md
Normal file
28
docs/plugins/development/exceptions.md
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
# Exceptions
|
||||||
|
|
||||||
|
The exception classes listed here may be raised by a plugin to alter NetBox's default behavior in various scenarios.
|
||||||
|
|
||||||
|
## `AbortRequest`
|
||||||
|
|
||||||
|
NetBox provides several [generic views](./views.md) and [REST API viewsets](./rest-api.md) which facilitate the creation, modification, and deletion of objects, either individually or in bulk. Under certain conditions, it may be desirable for a plugin to interrupt these actions and cleanly abort the request, reporting an error message to the end user or API consumer.
|
||||||
|
|
||||||
|
For example, a plugin may prohibit the creation of a site with a prohibited name by connecting a receiver to Django's `pre_save` signal for the Site model:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from django.db.models.signals import pre_save
|
||||||
|
from django.dispatch import receiver
|
||||||
|
from dcim.models import Site
|
||||||
|
from utilities.exceptions import AbortRequest
|
||||||
|
|
||||||
|
PROHIBITED_NAMES = ('foo', 'bar', 'baz')
|
||||||
|
|
||||||
|
@receiver(pre_save, sender=Site)
|
||||||
|
def test_abort_request(instance, **kwargs):
|
||||||
|
if instance.name.lower() in PROHIBITED_NAMES:
|
||||||
|
raise AbortRequest(f"Site name can't be {instance.name}!")
|
||||||
|
```
|
||||||
|
|
||||||
|
An error message must be supplied when raising `AbortRequest`. This will be conveyed to the user and should clearly explain the reason for which the request was aborted, as well as any potential remedy.
|
||||||
|
|
||||||
|
!!! tip "Consider custom validation rules"
|
||||||
|
This exception is intended to be used for handling complex evaluation logic and should be used sparingly. For simple object validation (such as the contrived example above), consider using [custom validation rules](../../customization/custom-validation.md) instead.
|
@ -215,6 +215,8 @@ The following custom template tags are available in NetBox.
|
|||||||
|
|
||||||
::: utilities.templatetags.builtins.tags.checkmark
|
::: utilities.templatetags.builtins.tags.checkmark
|
||||||
|
|
||||||
|
::: utilities.templatetags.builtins.tags.customfield_value
|
||||||
|
|
||||||
::: utilities.templatetags.builtins.tags.tag
|
::: utilities.templatetags.builtins.tags.tag
|
||||||
|
|
||||||
## Filters
|
## Filters
|
||||||
|
@ -51,15 +51,16 @@ This makes our view accessible at the URL `/plugins/animal-sounds/random/`. (Rem
|
|||||||
|
|
||||||
NetBox provides several generic view classes (documented below) to facilitate common operations, such as creating, viewing, modifying, and deleting objects. Plugins can subclass these views for their own use.
|
NetBox provides several generic view classes (documented below) to facilitate common operations, such as creating, viewing, modifying, and deleting objects. Plugins can subclass these views for their own use.
|
||||||
|
|
||||||
| View Class | Description |
|
| View Class | Description |
|
||||||
|--------------------|--------------------------------|
|
|----------------------|--------------------------------------------------------|
|
||||||
| `ObjectView` | View a single object |
|
| `ObjectView` | View a single object |
|
||||||
| `ObjectEditView` | Create or edit a single object |
|
| `ObjectEditView` | Create or edit a single object |
|
||||||
| `ObjectDeleteView` | Delete a single object |
|
| `ObjectDeleteView` | Delete a single object |
|
||||||
| `ObjectListView` | View a list of objects |
|
| `ObjectChildrenView` | A list of child objects within the context of a parent |
|
||||||
| `BulkImportView` | Import a set of new objects |
|
| `ObjectListView` | View a list of objects |
|
||||||
| `BulkEditView` | Edit multiple objects |
|
| `BulkImportView` | Import a set of new objects |
|
||||||
| `BulkDeleteView` | Delete multiple objects |
|
| `BulkEditView` | Edit multiple objects |
|
||||||
|
| `BulkDeleteView` | Delete multiple objects |
|
||||||
|
|
||||||
!!! warning
|
!!! warning
|
||||||
Please note that only the classes which appear in this documentation are currently supported. Although other classes may be present within the `views.generic` module, they are not yet supported for use by plugins.
|
Please note that only the classes which appear in this documentation are currently supported. Although other classes may be present within the `views.generic` module, they are not yet supported for use by plugins.
|
||||||
@ -99,6 +100,12 @@ Below are the class definitions for NetBox's object views. These views handle CR
|
|||||||
members:
|
members:
|
||||||
- get_object
|
- get_object
|
||||||
|
|
||||||
|
::: netbox.views.generic.ObjectChildrenView
|
||||||
|
selection:
|
||||||
|
members:
|
||||||
|
- get_children
|
||||||
|
- prep_table_data
|
||||||
|
|
||||||
## Multi-Object Views
|
## Multi-Object Views
|
||||||
|
|
||||||
Below are the class definitions for NetBox's multi-object views. These views handle simultaneous actions for sets objects. The list, import, edit, and delete views each inherit from `BaseMultiObjectView`, which is not intended to be used directly.
|
Below are the class definitions for NetBox's multi-object views. These views handle simultaneous actions for sets objects. The list, import, edit, and delete views each inherit from `BaseMultiObjectView`, which is not intended to be used directly.
|
||||||
|
@ -2,6 +2,19 @@
|
|||||||
|
|
||||||
## v3.2.6 (FUTURE)
|
## v3.2.6 (FUTURE)
|
||||||
|
|
||||||
|
### Enhancements
|
||||||
|
|
||||||
|
* [#7702](https://github.com/netbox-community/netbox/issues/7702) - Enable dynamic configuration for default powerfeed attributes
|
||||||
|
* [#9396](https://github.com/netbox-community/netbox/issues/9396) - Allow filtering modules by bay ID
|
||||||
|
* [#9403](https://github.com/netbox-community/netbox/issues/9403) - Enable modifying virtual chassis properties when creating/editing a device
|
||||||
|
* [#9540](https://github.com/netbox-community/netbox/issues/9540) - Add filters for assigned device & VM to IP addresses list
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* [#8854](https://github.com/netbox-community/netbox/issues/8854) - Fix `REMOTE_AUTH_DEFAULT_GROUPS` for social-auth backends
|
||||||
|
* [#9575](https://github.com/netbox-community/netbox/issues/9575) - Fix AttributeError exception for FHRP group with an IP address assigned
|
||||||
|
* [#9597](https://github.com/netbox-community/netbox/issues/9597) - Include `installed_module` in module bay REST API serializer
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## v3.2.5 (2022-06-20)
|
## v3.2.5 (2022-06-20)
|
||||||
|
@ -13,8 +13,12 @@
|
|||||||
|
|
||||||
#### PoE Interface Attributes ([#1099](https://github.com/netbox-community/netbox/issues/1099))
|
#### PoE Interface Attributes ([#1099](https://github.com/netbox-community/netbox/issues/1099))
|
||||||
|
|
||||||
|
#### L2VPN Modeling ([#8157](https://github.com/netbox-community/netbox/issues/8157))
|
||||||
|
|
||||||
#### Restrict API Tokens by Client IP ([#8233](https://github.com/netbox-community/netbox/issues/8233))
|
#### Restrict API Tokens by Client IP ([#8233](https://github.com/netbox-community/netbox/issues/8233))
|
||||||
|
|
||||||
|
#### Reference User in Permission Constraints ([#9074](https://github.com/netbox-community/netbox/issues/9074))
|
||||||
|
|
||||||
### Enhancements
|
### Enhancements
|
||||||
|
|
||||||
* [#1202](https://github.com/netbox-community/netbox/issues/1202) - Support overlapping assignment of NAT IP addresses
|
* [#1202](https://github.com/netbox-community/netbox/issues/1202) - Support overlapping assignment of NAT IP addresses
|
||||||
@ -23,10 +27,13 @@
|
|||||||
* [#5303](https://github.com/netbox-community/netbox/issues/5303) - A virtual machine may be assigned to a site and/or cluster
|
* [#5303](https://github.com/netbox-community/netbox/issues/5303) - A virtual machine may be assigned to a site and/or cluster
|
||||||
* [#7120](https://github.com/netbox-community/netbox/issues/7120) - Add `termination_date` field to Circuit
|
* [#7120](https://github.com/netbox-community/netbox/issues/7120) - Add `termination_date` field to Circuit
|
||||||
* [#7744](https://github.com/netbox-community/netbox/issues/7744) - Add `status` field to Location
|
* [#7744](https://github.com/netbox-community/netbox/issues/7744) - Add `status` field to Location
|
||||||
|
* [#8171](https://github.com/netbox-community/netbox/issues/8171) - Populate next available address when cloning an IP
|
||||||
* [#8222](https://github.com/netbox-community/netbox/issues/8222) - Enable the assignment of a VM to a specific host device within a cluster
|
* [#8222](https://github.com/netbox-community/netbox/issues/8222) - Enable the assignment of a VM to a specific host device within a cluster
|
||||||
* [#8471](https://github.com/netbox-community/netbox/issues/8471) - Add `status` field to Cluster
|
* [#8471](https://github.com/netbox-community/netbox/issues/8471) - Add `status` field to Cluster
|
||||||
* [#8495](https://github.com/netbox-community/netbox/issues/8495) - Enable custom field grouping
|
* [#8495](https://github.com/netbox-community/netbox/issues/8495) - Enable custom field grouping
|
||||||
|
* [#8511](https://github.com/netbox-community/netbox/issues/8511) - Enable custom fields and tags for circuit terminations
|
||||||
* [#8995](https://github.com/netbox-community/netbox/issues/8995) - Enable arbitrary ordering of REST API results
|
* [#8995](https://github.com/netbox-community/netbox/issues/8995) - Enable arbitrary ordering of REST API results
|
||||||
|
* [#9070](https://github.com/netbox-community/netbox/issues/9070) - Hide navigation menu items based on user permissions
|
||||||
* [#9166](https://github.com/netbox-community/netbox/issues/9166) - Add UI visibility toggle for custom fields
|
* [#9166](https://github.com/netbox-community/netbox/issues/9166) - Add UI visibility toggle for custom fields
|
||||||
* [#9177](https://github.com/netbox-community/netbox/issues/9177) - Add tenant assignment for wireless LANs & links
|
* [#9177](https://github.com/netbox-community/netbox/issues/9177) - Add tenant assignment for wireless LANs & links
|
||||||
* [#9536](https://github.com/netbox-community/netbox/issues/9536) - Track API token usage times
|
* [#9536](https://github.com/netbox-community/netbox/issues/9536) - Track API token usage times
|
||||||
@ -34,7 +41,11 @@
|
|||||||
|
|
||||||
### Plugins API
|
### Plugins API
|
||||||
|
|
||||||
|
* [#9075](https://github.com/netbox-community/netbox/issues/9075) - Introduce `AbortRequest` exception for cleanly interrupting object mutations
|
||||||
|
* [#9092](https://github.com/netbox-community/netbox/issues/9092) - Add support for `ObjectChildrenView` generic view
|
||||||
|
* [#9228](https://github.com/netbox-community/netbox/issues/9228) - Subclasses of `ChangeLoggingMixin` can override `serialize_object()` to control JSON serialization for change logging
|
||||||
* [#9414](https://github.com/netbox-community/netbox/issues/9414) - Add `clone()` method to NetBoxModel for copying instance attributes
|
* [#9414](https://github.com/netbox-community/netbox/issues/9414) - Add `clone()` method to NetBoxModel for copying instance attributes
|
||||||
|
* [#9647](https://github.com/netbox-community/netbox/issues/9647) - Introduce `customfield_value` template tag
|
||||||
|
|
||||||
### Other Changes
|
### Other Changes
|
||||||
|
|
||||||
@ -43,14 +54,20 @@
|
|||||||
|
|
||||||
### REST API Changes
|
### REST API Changes
|
||||||
|
|
||||||
|
* Added the following endpoints:
|
||||||
|
* `/api/ipam/l2vpns/`
|
||||||
|
* `/api/ipam/l2vpn-terminations/`
|
||||||
* circuits.Circuit
|
* circuits.Circuit
|
||||||
* Added optional `termination_date` field
|
* Added optional `termination_date` field
|
||||||
|
* circuits.CircuitTermination
|
||||||
|
* Added 'custom_fields' and 'tags' fields
|
||||||
* dcim.Device
|
* dcim.Device
|
||||||
* The `position` field has been changed from an integer to a decimal
|
* The `position` field has been changed from an integer to a decimal
|
||||||
* dcim.DeviceType
|
* dcim.DeviceType
|
||||||
* The `u_height` field has been changed from an integer to a decimal
|
* The `u_height` field has been changed from an integer to a decimal
|
||||||
* dcim.Interface
|
* dcim.Interface
|
||||||
* Added the optional `poe_mode` and `poe_type` fields
|
* Added the optional `poe_mode` and `poe_type` fields
|
||||||
|
* Added the `l2vpn_termination` read-only field
|
||||||
* dcim.Location
|
* dcim.Location
|
||||||
* Added required `status` field (default value: `active`)
|
* Added required `status` field (default value: `active`)
|
||||||
* dcim.Rack
|
* dcim.Rack
|
||||||
@ -62,15 +79,18 @@
|
|||||||
* ipam.IPAddress
|
* ipam.IPAddress
|
||||||
* The `nat_inside` field no longer requires a unique value
|
* The `nat_inside` field no longer requires a unique value
|
||||||
* The `nat_outside` field has changed from a single IP address instance to a list of multiple IP addresses
|
* The `nat_outside` field has changed from a single IP address instance to a list of multiple IP addresses
|
||||||
|
* ipam.VLAN
|
||||||
|
* Added the `l2vpn_termination` read-only field
|
||||||
* users.Token
|
* users.Token
|
||||||
* Added the `allowed_ips` array field
|
* Added the `allowed_ips` array field
|
||||||
* Added the read-only `last_used` datetime field
|
* Added the read-only `last_used` datetime field
|
||||||
* virtualization.Cluster
|
* virtualization.Cluster
|
||||||
* Added required `status` field (default value: `active`)
|
* Added required `status` field (default value: `active`)
|
||||||
* virtualization.VirtualMachine
|
* virtualization.VirtualMachine
|
||||||
* Added `device` field
|
|
||||||
* The `site` field is now directly writable (rather than being inferred from the assigned cluster)
|
* The `site` field is now directly writable (rather than being inferred from the assigned cluster)
|
||||||
* The `cluster` field is now optional. A virtual machine must have a site and/or cluster assigned.
|
* The `cluster` field is now optional. A virtual machine must have a site and/or cluster assigned.
|
||||||
|
* Added the `device` field
|
||||||
|
* Added the `l2vpn_termination` read-only field
|
||||||
wireless.WirelessLAN
|
wireless.WirelessLAN
|
||||||
* Added `tenant` field
|
* Added `tenant` field
|
||||||
wireless.WirelessLink
|
wireless.WirelessLink
|
||||||
|
@ -118,6 +118,7 @@ nav:
|
|||||||
- REST API: 'plugins/development/rest-api.md'
|
- REST API: 'plugins/development/rest-api.md'
|
||||||
- GraphQL API: 'plugins/development/graphql-api.md'
|
- GraphQL API: 'plugins/development/graphql-api.md'
|
||||||
- Background Tasks: 'plugins/development/background-tasks.md'
|
- Background Tasks: 'plugins/development/background-tasks.md'
|
||||||
|
- Exceptions: 'plugins/development/exceptions.md'
|
||||||
- Administration:
|
- Administration:
|
||||||
- Authentication:
|
- Authentication:
|
||||||
- Overview: 'administration/authentication/overview.md'
|
- Overview: 'administration/authentication/overview.md'
|
||||||
|
@ -98,7 +98,7 @@ class CircuitSerializer(NetBoxModelSerializer):
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
class CircuitTerminationSerializer(ValidatedModelSerializer, LinkTerminationSerializer):
|
class CircuitTerminationSerializer(NetBoxModelSerializer, LinkTerminationSerializer):
|
||||||
url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuittermination-detail')
|
url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuittermination-detail')
|
||||||
circuit = NestedCircuitSerializer()
|
circuit = NestedCircuitSerializer()
|
||||||
site = NestedSiteSerializer(required=False, allow_null=True)
|
site = NestedSiteSerializer(required=False, allow_null=True)
|
||||||
@ -110,5 +110,5 @@ class CircuitTerminationSerializer(ValidatedModelSerializer, LinkTerminationSeri
|
|||||||
fields = [
|
fields = [
|
||||||
'id', 'url', 'display', 'circuit', 'term_side', 'site', 'provider_network', 'port_speed', 'upstream_speed',
|
'id', 'url', 'display', 'circuit', 'term_side', 'site', 'provider_network', 'port_speed', 'upstream_speed',
|
||||||
'xconnect_id', 'pp_info', 'description', 'mark_connected', 'cable', 'link_peers', 'link_peers_type',
|
'xconnect_id', 'pp_info', 'description', 'mark_connected', 'cable', 'link_peers', 'link_peers_type',
|
||||||
'_occupied', 'created', 'last_updated',
|
'_occupied', 'tags', 'custom_fields', 'created', 'last_updated',
|
||||||
]
|
]
|
||||||
|
@ -198,7 +198,7 @@ class CircuitFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilte
|
|||||||
).distinct()
|
).distinct()
|
||||||
|
|
||||||
|
|
||||||
class CircuitTerminationFilterSet(ChangeLoggedModelFilterSet, CabledObjectFilterSet):
|
class CircuitTerminationFilterSet(NetBoxModelFilterSet, CabledObjectFilterSet):
|
||||||
q = django_filters.CharFilter(
|
q = django_filters.CharFilter(
|
||||||
method='search',
|
method='search',
|
||||||
label='Search',
|
label='Search',
|
||||||
|
@ -116,7 +116,7 @@ class CircuitForm(TenancyForm, NetBoxModelForm):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
class CircuitTerminationForm(BootstrapMixin, forms.ModelForm):
|
class CircuitTerminationForm(NetBoxModelForm):
|
||||||
provider = DynamicModelChoiceField(
|
provider = DynamicModelChoiceField(
|
||||||
queryset=Provider.objects.all(),
|
queryset=Provider.objects.all(),
|
||||||
required=False,
|
required=False,
|
||||||
@ -161,7 +161,7 @@ class CircuitTerminationForm(BootstrapMixin, forms.ModelForm):
|
|||||||
model = CircuitTermination
|
model = CircuitTermination
|
||||||
fields = [
|
fields = [
|
||||||
'provider', 'circuit', 'term_side', 'region', 'site_group', 'site', 'provider_network', 'mark_connected',
|
'provider', 'circuit', 'term_side', 'region', 'site_group', 'site', 'provider_network', 'mark_connected',
|
||||||
'port_speed', 'upstream_speed', 'xconnect_id', 'pp_info', 'description',
|
'port_speed', 'upstream_speed', 'xconnect_id', 'pp_info', 'description', 'tags',
|
||||||
]
|
]
|
||||||
help_texts = {
|
help_texts = {
|
||||||
'port_speed': "Physical circuit speed",
|
'port_speed': "Physical circuit speed",
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
from circuits import filtersets, models
|
from circuits import filtersets, models
|
||||||
|
from extras.graphql.mixins import CustomFieldsMixin, TagsMixin
|
||||||
from netbox.graphql.types import ObjectType, OrganizationalObjectType, NetBoxObjectType
|
from netbox.graphql.types import ObjectType, OrganizationalObjectType, NetBoxObjectType
|
||||||
|
|
||||||
__all__ = (
|
__all__ = (
|
||||||
@ -10,7 +11,7 @@ __all__ = (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class CircuitTerminationType(ObjectType):
|
class CircuitTerminationType(CustomFieldsMixin, TagsMixin, ObjectType):
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = models.CircuitTermination
|
model = models.CircuitTermination
|
||||||
|
@ -1,18 +0,0 @@
|
|||||||
# Generated by Django 4.0.5 on 2022-06-22 18:51
|
|
||||||
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
('circuits', '0035_provider_asns'),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.AddField(
|
|
||||||
model_name='circuit',
|
|
||||||
name='termination_date',
|
|
||||||
field=models.DateField(blank=True, null=True),
|
|
||||||
),
|
|
||||||
]
|
|
@ -0,0 +1,28 @@
|
|||||||
|
import django.core.serializers.json
|
||||||
|
from django.db import migrations, models
|
||||||
|
import taggit.managers
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('circuits', '0035_provider_asns'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='circuit',
|
||||||
|
name='termination_date',
|
||||||
|
field=models.DateField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='circuittermination',
|
||||||
|
name='custom_field_data',
|
||||||
|
field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='circuittermination',
|
||||||
|
name='tags',
|
||||||
|
field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'),
|
||||||
|
),
|
||||||
|
]
|
@ -4,7 +4,7 @@ from django.db import migrations, models
|
|||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
('circuits', '0036_circuit_termination_date'),
|
('circuits', '0036_circuit_termination_date_tags_custom_fields'),
|
||||||
]
|
]
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
|
@ -5,7 +5,9 @@ from django.urls import reverse
|
|||||||
|
|
||||||
from circuits.choices import *
|
from circuits.choices import *
|
||||||
from dcim.models import LinkTermination
|
from dcim.models import LinkTermination
|
||||||
from netbox.models import ChangeLoggedModel, OrganizationalModel, NetBoxModel
|
from netbox.models import (
|
||||||
|
ChangeLoggedModel, CustomFieldsMixin, CustomLinksMixin, OrganizationalModel, NetBoxModel, TagsMixin,
|
||||||
|
)
|
||||||
from netbox.models.features import WebhooksMixin
|
from netbox.models.features import WebhooksMixin
|
||||||
|
|
||||||
__all__ = (
|
__all__ = (
|
||||||
@ -141,7 +143,14 @@ class Circuit(NetBoxModel):
|
|||||||
return CircuitStatusChoices.colors.get(self.status)
|
return CircuitStatusChoices.colors.get(self.status)
|
||||||
|
|
||||||
|
|
||||||
class CircuitTermination(WebhooksMixin, ChangeLoggedModel, LinkTermination):
|
class CircuitTermination(
|
||||||
|
CustomFieldsMixin,
|
||||||
|
CustomLinksMixin,
|
||||||
|
TagsMixin,
|
||||||
|
WebhooksMixin,
|
||||||
|
ChangeLoggedModel,
|
||||||
|
LinkTermination
|
||||||
|
):
|
||||||
circuit = models.ForeignKey(
|
circuit = models.ForeignKey(
|
||||||
to='circuits.Circuit',
|
to='circuits.Circuit',
|
||||||
on_delete=models.CASCADE,
|
on_delete=models.CASCADE,
|
||||||
|
@ -5,6 +5,7 @@ from netbox.api.serializers import BaseModelSerializer, WritableNestedSerializer
|
|||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
'ComponentNestedModuleSerializer',
|
'ComponentNestedModuleSerializer',
|
||||||
|
'ModuleBayNestedModuleSerializer',
|
||||||
'NestedCableSerializer',
|
'NestedCableSerializer',
|
||||||
'NestedConsolePortSerializer',
|
'NestedConsolePortSerializer',
|
||||||
'NestedConsolePortTemplateSerializer',
|
'NestedConsolePortTemplateSerializer',
|
||||||
@ -281,6 +282,14 @@ class ModuleNestedModuleBaySerializer(WritableNestedSerializer):
|
|||||||
fields = ['id', 'url', 'display', 'name']
|
fields = ['id', 'url', 'display', 'name']
|
||||||
|
|
||||||
|
|
||||||
|
class ModuleBayNestedModuleSerializer(WritableNestedSerializer):
|
||||||
|
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:module-detail')
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = models.Module
|
||||||
|
fields = ['id', 'url', 'display', 'serial']
|
||||||
|
|
||||||
|
|
||||||
class ComponentNestedModuleSerializer(WritableNestedSerializer):
|
class ComponentNestedModuleSerializer(WritableNestedSerializer):
|
||||||
"""
|
"""
|
||||||
Used by device component serializers.
|
Used by device component serializers.
|
||||||
|
@ -10,7 +10,8 @@ from dcim.constants import *
|
|||||||
from dcim.models import *
|
from dcim.models import *
|
||||||
from extras.api.serializers import ContentTypeSerializer
|
from extras.api.serializers import ContentTypeSerializer
|
||||||
from ipam.api.nested_serializers import (
|
from ipam.api.nested_serializers import (
|
||||||
NestedASNSerializer, NestedIPAddressSerializer, NestedVLANSerializer, NestedVRFSerializer,
|
NestedASNSerializer, NestedIPAddressSerializer, NestedL2VPNTerminationSerializer, NestedVLANSerializer,
|
||||||
|
NestedVRFSerializer,
|
||||||
)
|
)
|
||||||
from ipam.models import ASN, VLAN
|
from ipam.models import ASN, VLAN
|
||||||
from netbox.api import ChoiceField, ContentTypeField, SerializedPKRelatedField
|
from netbox.api import ChoiceField, ContentTypeField, SerializedPKRelatedField
|
||||||
@ -868,6 +869,7 @@ class InterfaceSerializer(NetBoxModelSerializer, LinkTerminationSerializer, Conn
|
|||||||
many=True
|
many=True
|
||||||
)
|
)
|
||||||
vrf = NestedVRFSerializer(required=False, allow_null=True)
|
vrf = NestedVRFSerializer(required=False, allow_null=True)
|
||||||
|
l2vpn_termination = NestedL2VPNTerminationSerializer(read_only=True)
|
||||||
cable = NestedCableSerializer(read_only=True)
|
cable = NestedCableSerializer(read_only=True)
|
||||||
wireless_link = NestedWirelessLinkSerializer(read_only=True)
|
wireless_link = NestedWirelessLinkSerializer(read_only=True)
|
||||||
wireless_lans = SerializedPKRelatedField(
|
wireless_lans = SerializedPKRelatedField(
|
||||||
@ -886,8 +888,9 @@ class InterfaceSerializer(NetBoxModelSerializer, LinkTerminationSerializer, Conn
|
|||||||
'mtu', 'mac_address', 'speed', 'duplex', 'wwn', 'mgmt_only', 'description', 'mode', 'rf_role', 'rf_channel',
|
'mtu', 'mac_address', 'speed', 'duplex', 'wwn', 'mgmt_only', 'description', 'mode', 'rf_role', 'rf_channel',
|
||||||
'poe_mode', 'poe_type', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'untagged_vlan',
|
'poe_mode', 'poe_type', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'untagged_vlan',
|
||||||
'tagged_vlans', 'mark_connected', 'cable', 'wireless_link', 'link_peers', 'link_peers_type',
|
'tagged_vlans', 'mark_connected', 'cable', 'wireless_link', 'link_peers', 'link_peers_type',
|
||||||
'wireless_lans', 'vrf', 'connected_endpoints', 'connected_endpoints_type', 'connected_endpoints_reachable',
|
'wireless_lans', 'vrf', 'l2vpn_termination', 'connected_endpoints', 'connected_endpoints_type',
|
||||||
'tags', 'custom_fields', 'created', 'last_updated', 'count_ipaddresses', 'count_fhrp_groups', '_occupied',
|
'connected_endpoints_reachable', 'tags', 'custom_fields', 'created', 'last_updated', 'count_ipaddresses',
|
||||||
|
'count_fhrp_groups', '_occupied',
|
||||||
]
|
]
|
||||||
|
|
||||||
def validate(self, data):
|
def validate(self, data):
|
||||||
@ -957,12 +960,12 @@ class FrontPortSerializer(NetBoxModelSerializer, LinkTerminationSerializer):
|
|||||||
class ModuleBaySerializer(NetBoxModelSerializer):
|
class ModuleBaySerializer(NetBoxModelSerializer):
|
||||||
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:modulebay-detail')
|
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:modulebay-detail')
|
||||||
device = NestedDeviceSerializer()
|
device = NestedDeviceSerializer()
|
||||||
# installed_module = NestedModuleSerializer(required=False, allow_null=True)
|
installed_module = ModuleBayNestedModuleSerializer(required=False, allow_null=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = ModuleBay
|
model = ModuleBay
|
||||||
fields = [
|
fields = [
|
||||||
'id', 'url', 'display', 'device', 'name', 'label', 'position', 'description', 'tags', 'custom_fields',
|
'id', 'url', 'display', 'device', 'name', 'installed_module', 'label', 'position', 'description', 'tags', 'custom_fields',
|
||||||
'created', 'last_updated',
|
'created', 'last_updated',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
@ -621,7 +621,7 @@ class RearPortViewSet(PassThroughPortMixin, NetBoxModelViewSet):
|
|||||||
|
|
||||||
|
|
||||||
class ModuleBayViewSet(NetBoxModelViewSet):
|
class ModuleBayViewSet(NetBoxModelViewSet):
|
||||||
queryset = ModuleBay.objects.prefetch_related('tags')
|
queryset = ModuleBay.objects.prefetch_related('tags', 'installed_module')
|
||||||
serializer_class = serializers.ModuleBaySerializer
|
serializer_class = serializers.ModuleBaySerializer
|
||||||
filterset_class = filtersets.ModuleBayFilterSet
|
filterset_class = filtersets.ModuleBayFilterSet
|
||||||
brief_prefetch_fields = ['device']
|
brief_prefetch_fields = ['device']
|
||||||
|
@ -50,15 +50,6 @@ WIRELESS_IFACE_TYPES = [
|
|||||||
NONCONNECTABLE_IFACE_TYPES = VIRTUAL_IFACE_TYPES + WIRELESS_IFACE_TYPES
|
NONCONNECTABLE_IFACE_TYPES = VIRTUAL_IFACE_TYPES + WIRELESS_IFACE_TYPES
|
||||||
|
|
||||||
|
|
||||||
#
|
|
||||||
# Power feeds
|
|
||||||
#
|
|
||||||
|
|
||||||
POWERFEED_VOLTAGE_DEFAULT = 120
|
|
||||||
POWERFEED_AMPERAGE_DEFAULT = 20
|
|
||||||
POWERFEED_MAX_UTILIZATION_DEFAULT = 80 # Percentage
|
|
||||||
|
|
||||||
|
|
||||||
#
|
#
|
||||||
# Device components
|
# Device components
|
||||||
#
|
#
|
||||||
|
@ -997,6 +997,12 @@ class ModuleFilterSet(NetBoxModelFilterSet):
|
|||||||
to_field_name='model',
|
to_field_name='model',
|
||||||
label='Module type (model)',
|
label='Module type (model)',
|
||||||
)
|
)
|
||||||
|
module_bay_id = django_filters.ModelMultipleChoiceFilter(
|
||||||
|
field_name='module_bay',
|
||||||
|
queryset=ModuleBay.objects.all(),
|
||||||
|
to_field_name='id',
|
||||||
|
label='Module Bay (ID)'
|
||||||
|
)
|
||||||
device_id = django_filters.ModelMultipleChoiceFilter(
|
device_id = django_filters.ModelMultipleChoiceFilter(
|
||||||
queryset=Device.objects.all(),
|
queryset=Device.objects.all(),
|
||||||
label='Device (ID)',
|
label='Device (ID)',
|
||||||
|
@ -525,13 +525,28 @@ class DeviceForm(TenancyForm, NetBoxModelForm):
|
|||||||
required=False,
|
required=False,
|
||||||
label=''
|
label=''
|
||||||
)
|
)
|
||||||
|
virtual_chassis = DynamicModelChoiceField(
|
||||||
|
queryset=VirtualChassis.objects.all(),
|
||||||
|
required=False
|
||||||
|
)
|
||||||
|
vc_position = forms.IntegerField(
|
||||||
|
required=False,
|
||||||
|
label='Position',
|
||||||
|
help_text="The position in the virtual chassis this device is identified by"
|
||||||
|
)
|
||||||
|
vc_priority = forms.IntegerField(
|
||||||
|
required=False,
|
||||||
|
label='Priority',
|
||||||
|
help_text="The priority of the device in the virtual chassis"
|
||||||
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Device
|
model = Device
|
||||||
fields = [
|
fields = [
|
||||||
'name', 'device_role', 'device_type', 'serial', 'asset_tag', 'region', 'site_group', 'site', 'rack',
|
'name', 'device_role', 'device_type', 'serial', 'asset_tag', 'region', 'site_group', 'site', 'rack',
|
||||||
'location', 'position', 'face', 'status', 'airflow', 'platform', 'primary_ip4', 'primary_ip6',
|
'location', 'position', 'face', 'status', 'airflow', 'platform', 'primary_ip4', 'primary_ip6',
|
||||||
'cluster_group', 'cluster', 'tenant_group', 'tenant', 'comments', 'tags', 'local_context_data'
|
'cluster_group', 'cluster', 'tenant_group', 'tenant', 'virtual_chassis', 'vc_position', 'vc_priority',
|
||||||
|
'comments', 'tags', 'local_context_data'
|
||||||
]
|
]
|
||||||
help_texts = {
|
help_texts = {
|
||||||
'device_role': "The function this device serves",
|
'device_role': "The function this device serves",
|
||||||
|
@ -386,9 +386,9 @@ class Migration(migrations.Migration):
|
|||||||
('type', models.CharField(default='primary', max_length=50)),
|
('type', models.CharField(default='primary', max_length=50)),
|
||||||
('supply', models.CharField(default='ac', max_length=50)),
|
('supply', models.CharField(default='ac', max_length=50)),
|
||||||
('phase', models.CharField(default='single-phase', max_length=50)),
|
('phase', models.CharField(default='single-phase', max_length=50)),
|
||||||
('voltage', models.SmallIntegerField(default=120, validators=[utilities.validators.ExclusionValidator([0])])),
|
('voltage', models.SmallIntegerField(validators=[utilities.validators.ExclusionValidator([0])])),
|
||||||
('amperage', models.PositiveSmallIntegerField(default=20, validators=[django.core.validators.MinValueValidator(1)])),
|
('amperage', models.PositiveSmallIntegerField(validators=[django.core.validators.MinValueValidator(1)])),
|
||||||
('max_utilization', models.PositiveSmallIntegerField(default=80, validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(100)])),
|
('max_utilization', models.PositiveSmallIntegerField(validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(100)])),
|
||||||
('available_power', models.PositiveIntegerField(default=0, editable=False)),
|
('available_power', models.PositiveIntegerField(default=0, editable=False)),
|
||||||
('comments', models.TextField(blank=True)),
|
('comments', models.TextField(blank=True)),
|
||||||
],
|
],
|
||||||
|
@ -648,6 +648,12 @@ class Interface(ModularComponentModel, BaseInterface, LinkTermination, PathEndpo
|
|||||||
object_id_field='interface_id',
|
object_id_field='interface_id',
|
||||||
related_query_name='+'
|
related_query_name='+'
|
||||||
)
|
)
|
||||||
|
l2vpn_terminations = GenericRelation(
|
||||||
|
to='ipam.L2VPNTermination',
|
||||||
|
content_type_field='assigned_object_type',
|
||||||
|
object_id_field='assigned_object_id',
|
||||||
|
related_query_name='interface',
|
||||||
|
)
|
||||||
|
|
||||||
clone_fields = ['device', 'parent', 'bridge', 'lag', 'type', 'mgmt_only', 'poe_mode', 'poe_type']
|
clone_fields = ['device', 'parent', 'bridge', 'lag', 'type', 'mgmt_only', 'poe_mode', 'poe_type']
|
||||||
|
|
||||||
@ -822,6 +828,10 @@ class Interface(ModularComponentModel, BaseInterface, LinkTermination, PathEndpo
|
|||||||
def link(self):
|
def link(self):
|
||||||
return self.cable or self.wireless_link
|
return self.cable or self.wireless_link
|
||||||
|
|
||||||
|
@property
|
||||||
|
def l2vpn_termination(self):
|
||||||
|
return self.l2vpn_terminations.first()
|
||||||
|
|
||||||
|
|
||||||
#
|
#
|
||||||
# Pass-through ports
|
# Pass-through ports
|
||||||
|
@ -6,6 +6,7 @@ from django.urls import reverse
|
|||||||
|
|
||||||
from dcim.choices import *
|
from dcim.choices import *
|
||||||
from dcim.constants import *
|
from dcim.constants import *
|
||||||
|
from netbox.config import ConfigItem
|
||||||
from netbox.models import NetBoxModel
|
from netbox.models import NetBoxModel
|
||||||
from utilities.validators import ExclusionValidator
|
from utilities.validators import ExclusionValidator
|
||||||
from .device_components import LinkTermination, PathEndpoint
|
from .device_components import LinkTermination, PathEndpoint
|
||||||
@ -105,16 +106,16 @@ class PowerFeed(NetBoxModel, PathEndpoint, LinkTermination):
|
|||||||
default=PowerFeedPhaseChoices.PHASE_SINGLE
|
default=PowerFeedPhaseChoices.PHASE_SINGLE
|
||||||
)
|
)
|
||||||
voltage = models.SmallIntegerField(
|
voltage = models.SmallIntegerField(
|
||||||
default=POWERFEED_VOLTAGE_DEFAULT,
|
default=ConfigItem('POWERFEED_DEFAULT_VOLTAGE'),
|
||||||
validators=[ExclusionValidator([0])]
|
validators=[ExclusionValidator([0])]
|
||||||
)
|
)
|
||||||
amperage = models.PositiveSmallIntegerField(
|
amperage = models.PositiveSmallIntegerField(
|
||||||
validators=[MinValueValidator(1)],
|
validators=[MinValueValidator(1)],
|
||||||
default=POWERFEED_AMPERAGE_DEFAULT
|
default=ConfigItem('POWERFEED_DEFAULT_AMPERAGE')
|
||||||
)
|
)
|
||||||
max_utilization = models.PositiveSmallIntegerField(
|
max_utilization = models.PositiveSmallIntegerField(
|
||||||
validators=[MinValueValidator(1), MaxValueValidator(100)],
|
validators=[MinValueValidator(1), MaxValueValidator(100)],
|
||||||
default=POWERFEED_MAX_UTILIZATION_DEFAULT,
|
default=ConfigItem('POWERFEED_DEFAULT_MAX_UTILIZATION'),
|
||||||
help_text="Maximum permissible draw (percentage)"
|
help_text="Maximum permissible draw (percentage)"
|
||||||
)
|
)
|
||||||
available_power = models.PositiveIntegerField(
|
available_power = models.PositiveIntegerField(
|
||||||
|
@ -1853,6 +1853,11 @@ class ModuleTestCase(TestCase, ChangeLoggedFilterSetTests):
|
|||||||
params = {'module_type': [module_types[0].model, module_types[1].model]}
|
params = {'module_type': [module_types[0].model, module_types[1].model]}
|
||||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)
|
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)
|
||||||
|
|
||||||
|
def test_module_bay(self):
|
||||||
|
module_bays = ModuleBay.objects.all()[:2]
|
||||||
|
params = {'module_bay_id': [module_bays[0].pk, module_bays[1].pk]}
|
||||||
|
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||||
|
|
||||||
def test_device(self):
|
def test_device(self):
|
||||||
device_types = Device.objects.all()[:2]
|
device_types = Device.objects.all()[:2]
|
||||||
params = {'device_id': [device_types[0].pk, device_types[1].pk]}
|
params = {'device_id': [device_types[0].pk, device_types[1].pk]}
|
||||||
|
@ -15,6 +15,9 @@ class ConfigRevisionAdmin(admin.ModelAdmin):
|
|||||||
('Rack Elevations', {
|
('Rack Elevations', {
|
||||||
'fields': ('RACK_ELEVATION_DEFAULT_UNIT_HEIGHT', 'RACK_ELEVATION_DEFAULT_UNIT_WIDTH'),
|
'fields': ('RACK_ELEVATION_DEFAULT_UNIT_HEIGHT', 'RACK_ELEVATION_DEFAULT_UNIT_WIDTH'),
|
||||||
}),
|
}),
|
||||||
|
('Power', {
|
||||||
|
'fields': ('POWERFEED_DEFAULT_VOLTAGE', 'POWERFEED_DEFAULT_AMPERAGE', 'POWERFEED_DEFAULT_MAX_UTILIZATION')
|
||||||
|
}),
|
||||||
('IPAM', {
|
('IPAM', {
|
||||||
'fields': ('ENFORCE_GLOBAL_UNIQUE', 'PREFER_IPV4'),
|
'fields': ('ENFORCE_GLOBAL_UNIQUE', 'PREFER_IPV4'),
|
||||||
}),
|
}),
|
||||||
|
@ -1,6 +1,5 @@
|
|||||||
import hashlib
|
import hashlib
|
||||||
import hmac
|
import hmac
|
||||||
from collections import defaultdict
|
|
||||||
|
|
||||||
from django.contrib.contenttypes.models import ContentType
|
from django.contrib.contenttypes.models import ContentType
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
@ -27,10 +26,18 @@ def serialize_for_webhook(instance):
|
|||||||
|
|
||||||
|
|
||||||
def get_snapshots(instance, action):
|
def get_snapshots(instance, action):
|
||||||
return {
|
snapshots = {
|
||||||
'prechange': getattr(instance, '_prechange_snapshot', None),
|
'prechange': getattr(instance, '_prechange_snapshot', None),
|
||||||
'postchange': serialize_object(instance) if action != ObjectChangeActionChoices.ACTION_DELETE else None,
|
'postchange': None,
|
||||||
}
|
}
|
||||||
|
if action != ObjectChangeActionChoices.ACTION_DELETE:
|
||||||
|
# Use model's serialize() method if defined; fall back to serialize_object
|
||||||
|
if hasattr(instance, 'serialize_object'):
|
||||||
|
snapshots['postchange'] = instance.serialize_object()
|
||||||
|
else:
|
||||||
|
snapshots['postchange'] = serialize_object(instance)
|
||||||
|
|
||||||
|
return snapshots
|
||||||
|
|
||||||
|
|
||||||
def generate_signature(request_body, secret):
|
def generate_signature(request_body, secret):
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
|
|
||||||
from ipam import models
|
from ipam import models
|
||||||
|
from ipam.models.l2vpn import L2VPNTermination, L2VPN
|
||||||
from netbox.api import WritableNestedSerializer
|
from netbox.api import WritableNestedSerializer
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
@ -10,6 +11,8 @@ __all__ = [
|
|||||||
'NestedFHRPGroupAssignmentSerializer',
|
'NestedFHRPGroupAssignmentSerializer',
|
||||||
'NestedIPAddressSerializer',
|
'NestedIPAddressSerializer',
|
||||||
'NestedIPRangeSerializer',
|
'NestedIPRangeSerializer',
|
||||||
|
'NestedL2VPNSerializer',
|
||||||
|
'NestedL2VPNTerminationSerializer',
|
||||||
'NestedPrefixSerializer',
|
'NestedPrefixSerializer',
|
||||||
'NestedRIRSerializer',
|
'NestedRIRSerializer',
|
||||||
'NestedRoleSerializer',
|
'NestedRoleSerializer',
|
||||||
@ -190,3 +193,28 @@ class NestedServiceSerializer(WritableNestedSerializer):
|
|||||||
class Meta:
|
class Meta:
|
||||||
model = models.Service
|
model = models.Service
|
||||||
fields = ['id', 'url', 'display', 'name', 'protocol', 'ports']
|
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'
|
||||||
|
]
|
||||||
|
@ -19,6 +19,9 @@ from .nested_serializers import *
|
|||||||
#
|
#
|
||||||
# ASNs
|
# ASNs
|
||||||
#
|
#
|
||||||
|
from .nested_serializers import NestedL2VPNSerializer
|
||||||
|
from ..models.l2vpn import L2VPNTermination, L2VPN
|
||||||
|
|
||||||
|
|
||||||
class ASNSerializer(NetBoxModelSerializer):
|
class ASNSerializer(NetBoxModelSerializer):
|
||||||
url = serializers.HyperlinkedIdentityField(view_name='ipam-api:asn-detail')
|
url = serializers.HyperlinkedIdentityField(view_name='ipam-api:asn-detail')
|
||||||
@ -204,13 +207,14 @@ class VLANSerializer(NetBoxModelSerializer):
|
|||||||
tenant = NestedTenantSerializer(required=False, allow_null=True)
|
tenant = NestedTenantSerializer(required=False, allow_null=True)
|
||||||
status = ChoiceField(choices=VLANStatusChoices, required=False)
|
status = ChoiceField(choices=VLANStatusChoices, required=False)
|
||||||
role = NestedRoleSerializer(required=False, allow_null=True)
|
role = NestedRoleSerializer(required=False, allow_null=True)
|
||||||
|
l2vpn_termination = NestedL2VPNTerminationSerializer(read_only=True)
|
||||||
prefix_count = serializers.IntegerField(read_only=True)
|
prefix_count = serializers.IntegerField(read_only=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = VLAN
|
model = VLAN
|
||||||
fields = [
|
fields = [
|
||||||
'id', 'url', 'display', 'site', 'group', 'vid', 'name', 'tenant', 'status', 'role', 'description', 'tags',
|
'id', 'url', 'display', 'site', 'group', 'vid', 'name', 'tenant', 'status', 'role', 'description',
|
||||||
'custom_fields', 'created', 'last_updated', 'prefix_count',
|
'l2vpn_termination', 'tags', 'custom_fields', 'created', 'last_updated', 'prefix_count',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@ -433,3 +437,54 @@ class ServiceSerializer(NetBoxModelSerializer):
|
|||||||
'id', 'url', 'display', 'device', 'virtual_machine', 'name', 'ports', 'protocol', 'ipaddresses',
|
'id', 'url', 'display', 'device', 'virtual_machine', 'name', 'ports', 'protocol', 'ipaddresses',
|
||||||
'description', 'tags', 'custom_fields', 'created', 'last_updated',
|
'description', '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', 'tenant', 'tags', 'custom_fields', 'created', 'last_updated'
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class L2VPNTerminationSerializer(NetBoxModelSerializer):
|
||||||
|
url = serializers.HyperlinkedIdentityField(view_name='ipam-api:l2vpntermination-detail')
|
||||||
|
l2vpn = NestedL2VPNSerializer()
|
||||||
|
assigned_object_type = ContentTypeField(
|
||||||
|
queryset=ContentType.objects.all()
|
||||||
|
)
|
||||||
|
assigned_object = serializers.SerializerMethodField(read_only=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = L2VPNTermination
|
||||||
|
fields = [
|
||||||
|
'id', 'url', 'display', 'l2vpn', 'assigned_object_type', 'assigned_object_id',
|
||||||
|
'assigned_object', 'tags', 'custom_fields', 'created', 'last_updated'
|
||||||
|
]
|
||||||
|
|
||||||
|
@swagger_serializer_method(serializer_or_field=serializers.DictField)
|
||||||
|
def get_assigned_object(self, instance):
|
||||||
|
serializer = get_serializer_for_model(instance.assigned_object, prefix='Nested')
|
||||||
|
context = {'request': self.context['request']}
|
||||||
|
return serializer(instance.assigned_object, context=context).data
|
||||||
|
@ -45,6 +45,10 @@ router.register('vlans', views.VLANViewSet)
|
|||||||
router.register('service-templates', views.ServiceTemplateViewSet)
|
router.register('service-templates', views.ServiceTemplateViewSet)
|
||||||
router.register('services', views.ServiceViewSet)
|
router.register('services', views.ServiceViewSet)
|
||||||
|
|
||||||
|
# L2VPN
|
||||||
|
router.register('l2vpns', views.L2VPNViewSet)
|
||||||
|
router.register('l2vpn-terminations', views.L2VPNTerminationViewSet)
|
||||||
|
|
||||||
app_name = 'ipam-api'
|
app_name = 'ipam-api'
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
|
@ -18,6 +18,7 @@ from netbox.config import get_config
|
|||||||
from utilities.constants import ADVISORY_LOCK_KEYS
|
from utilities.constants import ADVISORY_LOCK_KEYS
|
||||||
from utilities.utils import count_related
|
from utilities.utils import count_related
|
||||||
from . import serializers
|
from . import serializers
|
||||||
|
from ipam.models import L2VPN, L2VPNTermination
|
||||||
|
|
||||||
|
|
||||||
class IPAMRootView(APIRootView):
|
class IPAMRootView(APIRootView):
|
||||||
@ -157,6 +158,18 @@ class ServiceViewSet(NetBoxModelViewSet):
|
|||||||
filterset_class = filtersets.ServiceFilterSet
|
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
|
# Views
|
||||||
#
|
#
|
||||||
|
@ -170,3 +170,52 @@ class ServiceProtocolChoices(ChoiceSet):
|
|||||||
(PROTOCOL_UDP, 'UDP'),
|
(PROTOCOL_UDP, 'UDP'),
|
||||||
(PROTOCOL_SCTP, 'SCTP'),
|
(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
|
||||||
|
)
|
||||||
|
@ -90,3 +90,9 @@ VLANGROUP_SCOPE_TYPES = (
|
|||||||
# 16-bit port number
|
# 16-bit port number
|
||||||
SERVICE_PORT_MIN = 1
|
SERVICE_PORT_MIN = 1
|
||||||
SERVICE_PORT_MAX = 65535
|
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')
|
||||||
|
)
|
||||||
|
@ -23,6 +23,8 @@ __all__ = (
|
|||||||
'FHRPGroupFilterSet',
|
'FHRPGroupFilterSet',
|
||||||
'IPAddressFilterSet',
|
'IPAddressFilterSet',
|
||||||
'IPRangeFilterSet',
|
'IPRangeFilterSet',
|
||||||
|
'L2VPNFilterSet',
|
||||||
|
'L2VPNTerminationFilterSet',
|
||||||
'PrefixFilterSet',
|
'PrefixFilterSet',
|
||||||
'RIRFilterSet',
|
'RIRFilterSet',
|
||||||
'RoleFilterSet',
|
'RoleFilterSet',
|
||||||
@ -922,3 +924,113 @@ class ServiceFilterSet(NetBoxModelFilterSet):
|
|||||||
return queryset
|
return queryset
|
||||||
qs_filter = Q(name__icontains=value) | Q(description__icontains=value)
|
qs_filter = Q(name__icontains=value) | Q(description__icontains=value)
|
||||||
return queryset.filter(qs_filter)
|
return queryset.filter(qs_filter)
|
||||||
|
|
||||||
|
|
||||||
|
#
|
||||||
|
# L2VPN
|
||||||
|
#
|
||||||
|
|
||||||
|
|
||||||
|
class L2VPNFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
|
||||||
|
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', 'type', 'description']
|
||||||
|
|
||||||
|
def search(self, queryset, name, value):
|
||||||
|
if not value.strip():
|
||||||
|
return queryset
|
||||||
|
qs_filter = Q(identifier=value) | Q(name__icontains=value) | Q(description__icontains=value)
|
||||||
|
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__name',
|
||||||
|
queryset=L2VPN.objects.all(),
|
||||||
|
to_field_name='name',
|
||||||
|
label='L2VPN (name)',
|
||||||
|
)
|
||||||
|
device = MultiValueCharFilter(
|
||||||
|
method='filter_device',
|
||||||
|
field_name='name',
|
||||||
|
label='Device (name)',
|
||||||
|
)
|
||||||
|
device_id = MultiValueNumberFilter(
|
||||||
|
method='filter_device',
|
||||||
|
field_name='pk',
|
||||||
|
label='Device (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)',
|
||||||
|
)
|
||||||
|
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)',
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = L2VPNTermination
|
||||||
|
fields = ['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_device(self, queryset, name, value):
|
||||||
|
devices = Device.objects.filter(**{'{}__in'.format(name): value})
|
||||||
|
if not devices.exists():
|
||||||
|
return queryset.none()
|
||||||
|
interface_ids = []
|
||||||
|
for device in devices:
|
||||||
|
interface_ids.extend(device.vc_interfaces().values_list('id', flat=True))
|
||||||
|
return queryset.filter(
|
||||||
|
interface__in=interface_ids
|
||||||
|
)
|
||||||
|
@ -18,6 +18,8 @@ __all__ = (
|
|||||||
'FHRPGroupBulkEditForm',
|
'FHRPGroupBulkEditForm',
|
||||||
'IPAddressBulkEditForm',
|
'IPAddressBulkEditForm',
|
||||||
'IPRangeBulkEditForm',
|
'IPRangeBulkEditForm',
|
||||||
|
'L2VPNBulkEditForm',
|
||||||
|
'L2VPNTerminationBulkEditForm',
|
||||||
'PrefixBulkEditForm',
|
'PrefixBulkEditForm',
|
||||||
'RIRBulkEditForm',
|
'RIRBulkEditForm',
|
||||||
'RoleBulkEditForm',
|
'RoleBulkEditForm',
|
||||||
@ -440,3 +442,24 @@ class ServiceTemplateBulkEditForm(NetBoxModelBulkEditForm):
|
|||||||
|
|
||||||
class ServiceBulkEditForm(ServiceTemplateBulkEditForm):
|
class ServiceBulkEditForm(ServiceTemplateBulkEditForm):
|
||||||
model = Service
|
model = Service
|
||||||
|
|
||||||
|
|
||||||
|
class L2VPNBulkEditForm(NetBoxModelBulkEditForm):
|
||||||
|
tenant = DynamicModelChoiceField(
|
||||||
|
queryset=Tenant.objects.all(),
|
||||||
|
required=False
|
||||||
|
)
|
||||||
|
description = forms.CharField(
|
||||||
|
max_length=100,
|
||||||
|
required=False
|
||||||
|
)
|
||||||
|
|
||||||
|
model = L2VPN
|
||||||
|
fieldsets = (
|
||||||
|
(None, ('tenant', 'description')),
|
||||||
|
)
|
||||||
|
nullable_fields = ('tenant', 'description',)
|
||||||
|
|
||||||
|
|
||||||
|
class L2VPNTerminationBulkEditForm(NetBoxModelBulkEditForm):
|
||||||
|
model = L2VPN
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
from django import forms
|
from django import forms
|
||||||
from django.contrib.contenttypes.models import ContentType
|
from django.contrib.contenttypes.models import ContentType
|
||||||
|
from django.core.exceptions import ValidationError
|
||||||
|
|
||||||
from dcim.models import Device, Interface, Site
|
from dcim.models import Device, Interface, Site
|
||||||
from ipam.choices import *
|
from ipam.choices import *
|
||||||
@ -16,6 +17,8 @@ __all__ = (
|
|||||||
'FHRPGroupCSVForm',
|
'FHRPGroupCSVForm',
|
||||||
'IPAddressCSVForm',
|
'IPAddressCSVForm',
|
||||||
'IPRangeCSVForm',
|
'IPRangeCSVForm',
|
||||||
|
'L2VPNCSVForm',
|
||||||
|
'L2VPNTerminationCSVForm',
|
||||||
'PrefixCSVForm',
|
'PrefixCSVForm',
|
||||||
'RIRCSVForm',
|
'RIRCSVForm',
|
||||||
'RoleCSVForm',
|
'RoleCSVForm',
|
||||||
@ -425,3 +428,83 @@ class ServiceCSVForm(NetBoxModelCSVForm):
|
|||||||
class Meta:
|
class Meta:
|
||||||
model = Service
|
model = Service
|
||||||
fields = ('device', 'virtual_machine', 'name', 'protocol', 'ports', 'description')
|
fields = ('device', 'virtual_machine', 'name', 'protocol', 'ports', 'description')
|
||||||
|
|
||||||
|
|
||||||
|
class L2VPNCSVForm(NetBoxModelCSVForm):
|
||||||
|
tenant = CSVModelChoiceField(
|
||||||
|
queryset=Tenant.objects.all(),
|
||||||
|
required=False,
|
||||||
|
to_field_name='name',
|
||||||
|
)
|
||||||
|
type = CSVChoiceField(
|
||||||
|
choices=L2VPNTypeChoices,
|
||||||
|
help_text='IP protocol'
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = L2VPN
|
||||||
|
fields = ('identifier', 'name', 'slug', 'type', 'description')
|
||||||
|
|
||||||
|
|
||||||
|
class L2VPNTerminationCSVForm(NetBoxModelCSVForm):
|
||||||
|
l2vpn = CSVModelChoiceField(
|
||||||
|
queryset=L2VPN.objects.all(),
|
||||||
|
required=True,
|
||||||
|
to_field_name='name',
|
||||||
|
label='L2VPN',
|
||||||
|
)
|
||||||
|
device = CSVModelChoiceField(
|
||||||
|
queryset=Device.objects.all(),
|
||||||
|
required=False,
|
||||||
|
to_field_name='name',
|
||||||
|
help_text='Parent device (for interface)'
|
||||||
|
)
|
||||||
|
virtual_machine = CSVModelChoiceField(
|
||||||
|
queryset=VirtualMachine.objects.all(),
|
||||||
|
required=False,
|
||||||
|
to_field_name='name',
|
||||||
|
help_text='Parent virtual machine (for interface)'
|
||||||
|
)
|
||||||
|
interface = CSVModelChoiceField(
|
||||||
|
queryset=Interface.objects.none(), # Can also refer to VMInterface
|
||||||
|
required=False,
|
||||||
|
to_field_name='name',
|
||||||
|
help_text='Assigned interface (device or VM)'
|
||||||
|
)
|
||||||
|
vlan = CSVModelChoiceField(
|
||||||
|
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')
|
||||||
|
|
||||||
|
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.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.')
|
||||||
|
|
||||||
|
self.instance.assigned_object = self.cleaned_data.get('interface') or self.cleaned_data.get('vlan')
|
||||||
|
@ -1,7 +1,8 @@
|
|||||||
from django import forms
|
from django import forms
|
||||||
from django.utils.translation import gettext as _
|
from django.utils.translation import gettext as _
|
||||||
|
|
||||||
from dcim.models import Location, Rack, Region, Site, SiteGroup
|
from dcim.models import Location, Rack, Region, Site, SiteGroup, Device
|
||||||
|
from virtualization.models import VirtualMachine
|
||||||
from ipam.choices import *
|
from ipam.choices import *
|
||||||
from ipam.constants import *
|
from ipam.constants import *
|
||||||
from ipam.models import *
|
from ipam.models import *
|
||||||
@ -19,6 +20,8 @@ __all__ = (
|
|||||||
'FHRPGroupFilterForm',
|
'FHRPGroupFilterForm',
|
||||||
'IPAddressFilterForm',
|
'IPAddressFilterForm',
|
||||||
'IPRangeFilterForm',
|
'IPRangeFilterForm',
|
||||||
|
'L2VPNFilterForm',
|
||||||
|
'L2VPNTerminationFilterForm',
|
||||||
'PrefixFilterForm',
|
'PrefixFilterForm',
|
||||||
'RIRFilterForm',
|
'RIRFilterForm',
|
||||||
'RoleFilterForm',
|
'RoleFilterForm',
|
||||||
@ -265,6 +268,7 @@ class IPAddressFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
|
|||||||
('Attributes', ('parent', 'family', 'status', 'role', 'mask_length', 'assigned_to_interface')),
|
('Attributes', ('parent', 'family', 'status', 'role', 'mask_length', 'assigned_to_interface')),
|
||||||
('VRF', ('vrf_id', 'present_in_vrf_id')),
|
('VRF', ('vrf_id', 'present_in_vrf_id')),
|
||||||
('Tenant', ('tenant_group_id', 'tenant_id')),
|
('Tenant', ('tenant_group_id', 'tenant_id')),
|
||||||
|
('Device/VM', ('device_id', 'virtual_machine_id')),
|
||||||
)
|
)
|
||||||
parent = forms.CharField(
|
parent = forms.CharField(
|
||||||
required=False,
|
required=False,
|
||||||
@ -298,6 +302,16 @@ class IPAddressFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
|
|||||||
required=False,
|
required=False,
|
||||||
label=_('Present in VRF')
|
label=_('Present in VRF')
|
||||||
)
|
)
|
||||||
|
device_id = DynamicModelMultipleChoiceField(
|
||||||
|
queryset=Device.objects.all(),
|
||||||
|
required=False,
|
||||||
|
label=_('Assigned Device'),
|
||||||
|
)
|
||||||
|
virtual_machine_id = DynamicModelMultipleChoiceField(
|
||||||
|
queryset=VirtualMachine.objects.all(),
|
||||||
|
required=False,
|
||||||
|
label=_('Assigned VM'),
|
||||||
|
)
|
||||||
status = MultipleChoiceField(
|
status = MultipleChoiceField(
|
||||||
choices=IPAddressStatusChoices,
|
choices=IPAddressStatusChoices,
|
||||||
required=False
|
required=False
|
||||||
@ -463,3 +477,30 @@ class ServiceTemplateFilterForm(NetBoxModelFilterSetForm):
|
|||||||
|
|
||||||
class ServiceFilterForm(ServiceTemplateFilterForm):
|
class ServiceFilterForm(ServiceTemplateFilterForm):
|
||||||
model = Service
|
model = Service
|
||||||
|
|
||||||
|
|
||||||
|
class L2VPNFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
|
||||||
|
model = L2VPN
|
||||||
|
fieldsets = (
|
||||||
|
(None, ('type', )),
|
||||||
|
('Tenant', ('tenant_group_id', 'tenant_id')),
|
||||||
|
)
|
||||||
|
type = forms.ChoiceField(
|
||||||
|
choices=add_blank_choice(L2VPNTypeChoices),
|
||||||
|
required=False,
|
||||||
|
widget=StaticSelect()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class L2VPNTerminationFilterForm(NetBoxModelFilterSetForm):
|
||||||
|
model = L2VPNTermination
|
||||||
|
fieldsets = (
|
||||||
|
(None, ('l2vpn', )),
|
||||||
|
)
|
||||||
|
l2vpn = DynamicModelChoiceField(
|
||||||
|
queryset=L2VPN.objects.all(),
|
||||||
|
required=True,
|
||||||
|
query_params={},
|
||||||
|
label='L2VPN',
|
||||||
|
fetch_trigger='open'
|
||||||
|
)
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
from django import forms
|
from django import forms
|
||||||
from django.contrib.contenttypes.models import ContentType
|
from django.contrib.contenttypes.models import ContentType
|
||||||
|
from django.core.exceptions import ValidationError
|
||||||
|
|
||||||
from dcim.models import Device, Interface, Location, Rack, Region, Site, SiteGroup
|
from dcim.models import Device, Interface, Location, Rack, Region, Site, SiteGroup
|
||||||
from extras.models import Tag
|
from extras.models import Tag
|
||||||
@ -7,9 +8,9 @@ from ipam.choices import *
|
|||||||
from ipam.constants import *
|
from ipam.constants import *
|
||||||
from ipam.formfields import IPNetworkFormField
|
from ipam.formfields import IPNetworkFormField
|
||||||
from ipam.models import *
|
from ipam.models import *
|
||||||
from ipam.models import ASN
|
|
||||||
from netbox.forms import NetBoxModelForm
|
from netbox.forms import NetBoxModelForm
|
||||||
from tenancy.forms import TenancyForm
|
from tenancy.forms import TenancyForm
|
||||||
|
from tenancy.models import Tenant
|
||||||
from utilities.exceptions import PermissionsViolation
|
from utilities.exceptions import PermissionsViolation
|
||||||
from utilities.forms import (
|
from utilities.forms import (
|
||||||
add_blank_choice, BootstrapMixin, ContentTypeChoiceField, DatePicker, DynamicModelChoiceField,
|
add_blank_choice, BootstrapMixin, ContentTypeChoiceField, DatePicker, DynamicModelChoiceField,
|
||||||
@ -26,6 +27,8 @@ __all__ = (
|
|||||||
'IPAddressBulkAddForm',
|
'IPAddressBulkAddForm',
|
||||||
'IPAddressForm',
|
'IPAddressForm',
|
||||||
'IPRangeForm',
|
'IPRangeForm',
|
||||||
|
'L2VPNForm',
|
||||||
|
'L2VPNTerminationForm',
|
||||||
'PrefixForm',
|
'PrefixForm',
|
||||||
'RIRForm',
|
'RIRForm',
|
||||||
'RoleForm',
|
'RoleForm',
|
||||||
@ -861,3 +864,110 @@ class ServiceCreateForm(ServiceForm):
|
|||||||
self.cleaned_data['description'] = service_template.description
|
self.cleaned_data['description'] = service_template.description
|
||||||
elif not all(self.cleaned_data[f] for f in ('name', 'protocol', 'ports')):
|
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.")
|
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(
|
||||||
|
queryset=RouteTarget.objects.all(),
|
||||||
|
required=False
|
||||||
|
)
|
||||||
|
export_targets = DynamicModelMultipleChoiceField(
|
||||||
|
queryset=RouteTarget.objects.all(),
|
||||||
|
required=False
|
||||||
|
)
|
||||||
|
|
||||||
|
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', 'description', 'import_targets', 'export_targets', 'tenant', 'tags'
|
||||||
|
)
|
||||||
|
widgets = {
|
||||||
|
'type': StaticSelect(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
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,
|
||||||
|
query_params={}
|
||||||
|
)
|
||||||
|
vlan = DynamicModelChoiceField(
|
||||||
|
queryset=VLAN.objects.all(),
|
||||||
|
required=False,
|
||||||
|
query_params={
|
||||||
|
'available_on_device': '$device'
|
||||||
|
}
|
||||||
|
)
|
||||||
|
interface = DynamicModelChoiceField(
|
||||||
|
queryset=Interface.objects.all(),
|
||||||
|
required=False,
|
||||||
|
query_params={
|
||||||
|
'device_id': '$device'
|
||||||
|
}
|
||||||
|
)
|
||||||
|
virtual_machine = DynamicModelChoiceField(
|
||||||
|
queryset=VirtualMachine.objects.all(),
|
||||||
|
required=False,
|
||||||
|
query_params={}
|
||||||
|
)
|
||||||
|
vminterface = DynamicModelChoiceField(
|
||||||
|
queryset=VMInterface.objects.all(),
|
||||||
|
required=False,
|
||||||
|
query_params={
|
||||||
|
'virtual_machine_id': '$virtual_machine'
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = L2VPNTermination
|
||||||
|
fields = ('l2vpn', )
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
instance = kwargs.get('instance')
|
||||||
|
initial = kwargs.get('initial', {}).copy()
|
||||||
|
|
||||||
|
if instance:
|
||||||
|
if type(instance.assigned_object) is Interface:
|
||||||
|
initial['device'] = instance.assigned_object.parent
|
||||||
|
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
|
||||||
|
@ -17,6 +17,12 @@ class IPAMQuery(graphene.ObjectType):
|
|||||||
ip_range = ObjectField(IPRangeType)
|
ip_range = ObjectField(IPRangeType)
|
||||||
ip_range_list = ObjectListField(IPRangeType)
|
ip_range_list = ObjectListField(IPRangeType)
|
||||||
|
|
||||||
|
l2vpn = ObjectField(L2VPNType)
|
||||||
|
l2vpn_list = ObjectListField(L2VPNType)
|
||||||
|
|
||||||
|
l2vpn_termination = ObjectField(L2VPNTerminationType)
|
||||||
|
l2vpn_termination_list = ObjectListField(L2VPNTerminationType)
|
||||||
|
|
||||||
prefix = ObjectField(PrefixType)
|
prefix = ObjectField(PrefixType)
|
||||||
prefix_list = ObjectListField(PrefixType)
|
prefix_list = ObjectListField(PrefixType)
|
||||||
|
|
||||||
|
@ -11,6 +11,8 @@ __all__ = (
|
|||||||
'FHRPGroupAssignmentType',
|
'FHRPGroupAssignmentType',
|
||||||
'IPAddressType',
|
'IPAddressType',
|
||||||
'IPRangeType',
|
'IPRangeType',
|
||||||
|
'L2VPNType',
|
||||||
|
'L2VPNTerminationType',
|
||||||
'PrefixType',
|
'PrefixType',
|
||||||
'RIRType',
|
'RIRType',
|
||||||
'RoleType',
|
'RoleType',
|
||||||
@ -151,3 +153,17 @@ class VRFType(NetBoxObjectType):
|
|||||||
model = models.VRF
|
model = models.VRF
|
||||||
fields = '__all__'
|
fields = '__all__'
|
||||||
filterset_class = filtersets.VRFFilterSet
|
filterset_class = filtersets.VRFFilterSet
|
||||||
|
|
||||||
|
|
||||||
|
class L2VPNType(NetBoxObjectType):
|
||||||
|
class Meta:
|
||||||
|
model = models.L2VPN
|
||||||
|
fields = '__all__'
|
||||||
|
filtersets_class = filtersets.L2VPNFilterSet
|
||||||
|
|
||||||
|
|
||||||
|
class L2VPNTerminationType(NetBoxObjectType):
|
||||||
|
class Meta:
|
||||||
|
model = models.L2VPNTermination
|
||||||
|
fields = '__all__'
|
||||||
|
filtersets_class = filtersets.L2VPNTerminationFilterSet
|
||||||
|
62
netbox/ipam/migrations/0059_l2vpn.py
Normal file
62
netbox/ipam/migrations/0059_l2vpn.py
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
# Generated by Django 4.0.5 on 2022-07-06 16:51
|
||||||
|
|
||||||
|
import django.core.serializers.json
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
import taggit.managers
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('extras', '0076_configcontext_locations'),
|
||||||
|
('contenttypes', '0002_remove_content_type_name'),
|
||||||
|
('tenancy', '0007_contact_link'),
|
||||||
|
('ipam', '0058_ipaddress_nat_inside_nonunique'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='L2VPN',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
|
||||||
|
('created', models.DateTimeField(auto_now_add=True, null=True)),
|
||||||
|
('last_updated', models.DateTimeField(auto_now=True, null=True)),
|
||||||
|
('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)),
|
||||||
|
('name', models.CharField(max_length=100, unique=True)),
|
||||||
|
('slug', models.SlugField()),
|
||||||
|
('type', models.CharField(max_length=50)),
|
||||||
|
('identifier', models.BigIntegerField(blank=True, null=True, unique=True)),
|
||||||
|
('description', models.TextField(blank=True, null=True)),
|
||||||
|
('export_targets', models.ManyToManyField(blank=True, related_name='exporting_l2vpns', to='ipam.routetarget')),
|
||||||
|
('import_targets', models.ManyToManyField(blank=True, related_name='importing_l2vpns', to='ipam.routetarget')),
|
||||||
|
('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')),
|
||||||
|
('tenant', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='l2vpns', to='tenancy.tenant')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'L2VPN',
|
||||||
|
'ordering': ('identifier', 'name'),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='L2VPNTermination',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
|
||||||
|
('created', models.DateTimeField(auto_now_add=True, null=True)),
|
||||||
|
('last_updated', models.DateTimeField(auto_now=True, null=True)),
|
||||||
|
('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)),
|
||||||
|
('assigned_object_id', models.PositiveBigIntegerField()),
|
||||||
|
('assigned_object_type', models.ForeignKey(limit_choices_to=models.Q(models.Q(models.Q(('app_label', 'dcim'), ('model', 'interface')), models.Q(('app_label', 'ipam'), ('model', 'vlan')), models.Q(('app_label', 'virtualization'), ('model', 'vminterface')), _connector='OR')), on_delete=django.db.models.deletion.PROTECT, related_name='+', to='contenttypes.contenttype')),
|
||||||
|
('l2vpn', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='terminations', to='ipam.l2vpn')),
|
||||||
|
('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'L2VPN Termination',
|
||||||
|
'ordering': ('l2vpn',),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.AddConstraint(
|
||||||
|
model_name='l2vpntermination',
|
||||||
|
constraint=models.UniqueConstraint(fields=('assigned_object_type', 'assigned_object_id'), name='ipam_l2vpntermination_assigned_object'),
|
||||||
|
),
|
||||||
|
]
|
@ -2,6 +2,7 @@
|
|||||||
from .fhrp import *
|
from .fhrp import *
|
||||||
from .vrfs import *
|
from .vrfs import *
|
||||||
from .ip import *
|
from .ip import *
|
||||||
|
from .l2vpn import *
|
||||||
from .services import *
|
from .services import *
|
||||||
from .vlans import *
|
from .vlans import *
|
||||||
|
|
||||||
@ -12,6 +13,8 @@ __all__ = (
|
|||||||
'IPRange',
|
'IPRange',
|
||||||
'FHRPGroup',
|
'FHRPGroup',
|
||||||
'FHRPGroupAssignment',
|
'FHRPGroupAssignment',
|
||||||
|
'L2VPN',
|
||||||
|
'L2VPNTermination',
|
||||||
'Prefix',
|
'Prefix',
|
||||||
'RIR',
|
'RIR',
|
||||||
'Role',
|
'Role',
|
||||||
|
@ -857,6 +857,25 @@ class IPAddress(NetBoxModel):
|
|||||||
address__net_host=str(self.address.ip)
|
address__net_host=str(self.address.ip)
|
||||||
).exclude(pk=self.pk)
|
).exclude(pk=self.pk)
|
||||||
|
|
||||||
|
def get_next_available_ip(self):
|
||||||
|
"""
|
||||||
|
Return the next available IP address within this IP's network (if any)
|
||||||
|
"""
|
||||||
|
if self.address and self.address.broadcast:
|
||||||
|
start_ip = self.address.ip + 1
|
||||||
|
end_ip = self.address.broadcast - 1
|
||||||
|
if start_ip <= end_ip:
|
||||||
|
available_ips = netaddr.IPSet(netaddr.IPRange(start_ip, end_ip))
|
||||||
|
available_ips -= netaddr.IPSet([
|
||||||
|
address.ip for address in IPAddress.objects.filter(
|
||||||
|
vrf=self.vrf,
|
||||||
|
address__gt=self.address,
|
||||||
|
address__net_contained_or_equal=self.address.cidr
|
||||||
|
).values_list('address', flat=True)
|
||||||
|
])
|
||||||
|
if available_ips:
|
||||||
|
return next(iter(available_ips))
|
||||||
|
|
||||||
def clean(self):
|
def clean(self):
|
||||||
super().clean()
|
super().clean()
|
||||||
|
|
||||||
@ -907,6 +926,15 @@ class IPAddress(NetBoxModel):
|
|||||||
|
|
||||||
super().save(*args, **kwargs)
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
|
def clone(self):
|
||||||
|
attrs = super().clone()
|
||||||
|
|
||||||
|
# Populate the address field with the next available IP (if any)
|
||||||
|
if next_available_ip := self.get_next_available_ip():
|
||||||
|
attrs['address'] = next_available_ip
|
||||||
|
|
||||||
|
return attrs
|
||||||
|
|
||||||
def to_objectchange(self, action):
|
def to_objectchange(self, action):
|
||||||
objectchange = super().to_objectchange(action)
|
objectchange = super().to_objectchange(action)
|
||||||
objectchange.related_object = self.assigned_object
|
objectchange.related_object = self.assigned_object
|
||||||
|
112
netbox/ipam/models/l2vpn.py
Normal file
112
netbox/ipam/models/l2vpn.py
Normal file
@ -0,0 +1,112 @@
|
|||||||
|
from django.contrib.contenttypes.fields import GenericRelation, GenericForeignKey
|
||||||
|
from django.contrib.contenttypes.models import ContentType
|
||||||
|
from django.core.exceptions import ValidationError
|
||||||
|
from django.db import models
|
||||||
|
from django.urls import reverse
|
||||||
|
|
||||||
|
from ipam.choices import L2VPNTypeChoices
|
||||||
|
from ipam.constants import L2VPN_ASSIGNMENT_MODELS
|
||||||
|
from netbox.models import NetBoxModel
|
||||||
|
|
||||||
|
|
||||||
|
class L2VPN(NetBoxModel):
|
||||||
|
name = models.CharField(
|
||||||
|
max_length=100,
|
||||||
|
unique=True
|
||||||
|
)
|
||||||
|
slug = models.SlugField()
|
||||||
|
type = models.CharField(max_length=50, choices=L2VPNTypeChoices)
|
||||||
|
identifier = models.BigIntegerField(
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
unique=True
|
||||||
|
)
|
||||||
|
import_targets = models.ManyToManyField(
|
||||||
|
to='ipam.RouteTarget',
|
||||||
|
related_name='importing_l2vpns',
|
||||||
|
blank=True,
|
||||||
|
)
|
||||||
|
export_targets = models.ManyToManyField(
|
||||||
|
to='ipam.RouteTarget',
|
||||||
|
related_name='exporting_l2vpns',
|
||||||
|
blank=True
|
||||||
|
)
|
||||||
|
description = models.TextField(null=True, blank=True)
|
||||||
|
tenant = models.ForeignKey(
|
||||||
|
to='tenancy.Tenant',
|
||||||
|
on_delete=models.PROTECT,
|
||||||
|
related_name='l2vpns',
|
||||||
|
blank=True,
|
||||||
|
null=True
|
||||||
|
)
|
||||||
|
contacts = GenericRelation(
|
||||||
|
to='tenancy.ContactAssignment'
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ('identifier', 'name')
|
||||||
|
verbose_name = 'L2VPN'
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
if self.identifier:
|
||||||
|
return f'{self.name} ({self.identifier})'
|
||||||
|
return f'{self.name}'
|
||||||
|
|
||||||
|
def get_absolute_url(self):
|
||||||
|
return reverse('ipam:l2vpn', args=[self.pk])
|
||||||
|
|
||||||
|
|
||||||
|
class L2VPNTermination(NetBoxModel):
|
||||||
|
l2vpn = models.ForeignKey(
|
||||||
|
to='ipam.L2VPN',
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name='terminations'
|
||||||
|
)
|
||||||
|
assigned_object_type = models.ForeignKey(
|
||||||
|
to=ContentType,
|
||||||
|
limit_choices_to=L2VPN_ASSIGNMENT_MODELS,
|
||||||
|
on_delete=models.PROTECT,
|
||||||
|
related_name='+'
|
||||||
|
)
|
||||||
|
assigned_object_id = models.PositiveBigIntegerField()
|
||||||
|
assigned_object = GenericForeignKey(
|
||||||
|
ct_field='assigned_object_type',
|
||||||
|
fk_field='assigned_object_id'
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ('l2vpn',)
|
||||||
|
verbose_name = 'L2VPN Termination'
|
||||||
|
constraints = (
|
||||||
|
models.UniqueConstraint(
|
||||||
|
fields=('assigned_object_type', 'assigned_object_id'),
|
||||||
|
name='ipam_l2vpntermination_assigned_object'
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
if self.pk is not None:
|
||||||
|
return f'{self.assigned_object} <> {self.l2vpn}'
|
||||||
|
return super().__str__()
|
||||||
|
|
||||||
|
def get_absolute_url(self):
|
||||||
|
return reverse('ipam:l2vpntermination', args=[self.pk])
|
||||||
|
|
||||||
|
def clean(self):
|
||||||
|
# Only check is assigned_object is set. Required otherwise we have an Integrity Error thrown.
|
||||||
|
if self.assigned_object:
|
||||||
|
obj_id = self.assigned_object.pk
|
||||||
|
obj_type = ContentType.objects.get_for_model(self.assigned_object)
|
||||||
|
if L2VPNTermination.objects.filter(assigned_object_id=obj_id, assigned_object_type=obj_type).\
|
||||||
|
exclude(pk=self.pk).count() > 0:
|
||||||
|
raise ValidationError(f'L2VPN Termination already assigned ({self.assigned_object})')
|
||||||
|
|
||||||
|
# Only check if L2VPN is set and is of type P2P
|
||||||
|
if self.l2vpn and self.l2vpn.type in L2VPNTypeChoices.P2P:
|
||||||
|
terminations_count = L2VPNTermination.objects.filter(l2vpn=self.l2vpn).exclude(pk=self.pk).count()
|
||||||
|
if terminations_count >= 2:
|
||||||
|
l2vpn_type = self.l2vpn.get_type_display()
|
||||||
|
raise ValidationError(
|
||||||
|
f'{l2vpn_type} L2VPNs cannot have more than two terminations; found {terminations_count} already '
|
||||||
|
f'defined.'
|
||||||
|
)
|
@ -1,4 +1,4 @@
|
|||||||
from django.contrib.contenttypes.fields import GenericForeignKey
|
from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation
|
||||||
from django.contrib.contenttypes.models import ContentType
|
from django.contrib.contenttypes.models import ContentType
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
from django.core.validators import MaxValueValidator, MinValueValidator
|
from django.core.validators import MaxValueValidator, MinValueValidator
|
||||||
@ -8,6 +8,7 @@ from django.urls import reverse
|
|||||||
from dcim.models import Interface
|
from dcim.models import Interface
|
||||||
from ipam.choices import *
|
from ipam.choices import *
|
||||||
from ipam.constants import *
|
from ipam.constants import *
|
||||||
|
from ipam.models import L2VPNTermination
|
||||||
from ipam.querysets import VLANQuerySet
|
from ipam.querysets import VLANQuerySet
|
||||||
from netbox.models import OrganizationalModel, NetBoxModel
|
from netbox.models import OrganizationalModel, NetBoxModel
|
||||||
from virtualization.models import VMInterface
|
from virtualization.models import VMInterface
|
||||||
@ -173,6 +174,13 @@ class VLAN(NetBoxModel):
|
|||||||
blank=True
|
blank=True
|
||||||
)
|
)
|
||||||
|
|
||||||
|
l2vpn_terminations = GenericRelation(
|
||||||
|
to='ipam.L2VPNTermination',
|
||||||
|
content_type_field='assigned_object_type',
|
||||||
|
object_id_field='assigned_object_id',
|
||||||
|
related_query_name='vlan'
|
||||||
|
)
|
||||||
|
|
||||||
objects = VLANQuerySet.as_manager()
|
objects = VLANQuerySet.as_manager()
|
||||||
|
|
||||||
clone_fields = [
|
clone_fields = [
|
||||||
@ -227,3 +235,7 @@ class VLAN(NetBoxModel):
|
|||||||
Q(untagged_vlan_id=self.pk) |
|
Q(untagged_vlan_id=self.pk) |
|
||||||
Q(tagged_vlans=self.pk)
|
Q(tagged_vlans=self.pk)
|
||||||
).distinct()
|
).distinct()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def l2vpn_termination(self):
|
||||||
|
return self.l2vpn_terminations.first()
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
from .fhrp import *
|
from .fhrp import *
|
||||||
from .ip import *
|
from .ip import *
|
||||||
|
from .l2vpn import *
|
||||||
from .services import *
|
from .services import *
|
||||||
from .vlans import *
|
from .vlans import *
|
||||||
from .vrfs import *
|
from .vrfs import *
|
||||||
|
57
netbox/ipam/tables/l2vpn.py
Normal file
57
netbox/ipam/tables/l2vpn.py
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
import django_tables2 as tables
|
||||||
|
|
||||||
|
from ipam.models import *
|
||||||
|
from ipam.models.l2vpn import L2VPN, L2VPNTermination
|
||||||
|
from netbox.tables import NetBoxTable, columns
|
||||||
|
|
||||||
|
__all__ = (
|
||||||
|
'L2VPNTable',
|
||||||
|
'L2VPNTerminationTable',
|
||||||
|
)
|
||||||
|
|
||||||
|
L2VPN_TARGETS = """
|
||||||
|
{% for rt in value.all %}
|
||||||
|
<a href="{{ rt.get_absolute_url }}">{{ rt }}</a>{% if not forloop.last %}<br />{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class L2VPNTable(NetBoxTable):
|
||||||
|
pk = columns.ToggleColumn()
|
||||||
|
name = tables.Column(
|
||||||
|
linkify=True
|
||||||
|
)
|
||||||
|
import_targets = columns.TemplateColumn(
|
||||||
|
template_code=L2VPN_TARGETS,
|
||||||
|
orderable=False
|
||||||
|
)
|
||||||
|
export_targets = columns.TemplateColumn(
|
||||||
|
template_code=L2VPN_TARGETS,
|
||||||
|
orderable=False
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta(NetBoxTable.Meta):
|
||||||
|
model = L2VPN
|
||||||
|
fields = ('pk', 'name', 'slug', 'type', 'description', 'import_targets', 'export_targets', 'tenant', 'actions')
|
||||||
|
default_columns = ('pk', 'name', 'type', 'description', 'actions')
|
||||||
|
|
||||||
|
|
||||||
|
class L2VPNTerminationTable(NetBoxTable):
|
||||||
|
pk = columns.ToggleColumn()
|
||||||
|
l2vpn = tables.Column(
|
||||||
|
verbose_name='L2VPN',
|
||||||
|
linkify=True
|
||||||
|
)
|
||||||
|
assigned_object_type = columns.ContentTypeColumn(
|
||||||
|
verbose_name='Object Type'
|
||||||
|
)
|
||||||
|
assigned_object = tables.Column(
|
||||||
|
verbose_name='Assigned Object',
|
||||||
|
linkify=True,
|
||||||
|
orderable=False
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta(NetBoxTable.Meta):
|
||||||
|
model = L2VPNTermination
|
||||||
|
fields = ('pk', 'l2vpn', 'assigned_object_type', 'assigned_object', 'actions')
|
||||||
|
default_columns = ('pk', 'l2vpn', 'assigned_object_type', 'assigned_object', 'actions')
|
@ -914,3 +914,98 @@ class ServiceTest(APIViewTestCases.APIViewTestCase):
|
|||||||
'ports': [6],
|
'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):
|
||||||
|
|
||||||
|
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', type='vxlan', identifier=650001),
|
||||||
|
L2VPN(name='L2VPN 2', type='vpws', identifier=650002),
|
||||||
|
L2VPN(name='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)
|
||||||
|
|
||||||
|
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,
|
||||||
|
'assigned_object_type': 'ipam.vlan',
|
||||||
|
'assigned_object_id': vlans[5].pk,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
cls.bulk_update_data = {
|
||||||
|
'l2vpn': l2vpns[2].pk
|
||||||
|
}
|
||||||
|
@ -1463,3 +1463,100 @@ class ServiceTestCase(TestCase, ChangeLoggedFilterSetTests):
|
|||||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||||
params = {'virtual_machine': [vms[0].name, vms[1].name]}
|
params = {'virtual_machine': [vms[0].name, vms[1].name]}
|
||||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||||
|
|
||||||
|
|
||||||
|
class L2VPNTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||||
|
queryset = L2VPN.objects.all()
|
||||||
|
filterset = L2VPNFilterSet
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def setUpTestData(cls):
|
||||||
|
|
||||||
|
l2vpns = (
|
||||||
|
L2VPN(name='L2VPN 1', type='vxlan', identifier=650001),
|
||||||
|
L2VPN(name='L2VPN 2', type='vpws', identifier=650002),
|
||||||
|
L2VPN(name='L2VPN 3', type='vpls'), # No RD
|
||||||
|
)
|
||||||
|
L2VPN.objects.bulk_create(l2vpns)
|
||||||
|
|
||||||
|
|
||||||
|
class L2VPNTerminationTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||||
|
queryset = L2VPNTermination.objects.all()
|
||||||
|
filterset = L2VPNTerminationFilterSet
|
||||||
|
|
||||||
|
@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)
|
||||||
|
device_role = DeviceRole.objects.create(name='Switch')
|
||||||
|
device = Device.objects.create(
|
||||||
|
name='Device 1',
|
||||||
|
site=site,
|
||||||
|
device_type=device_type,
|
||||||
|
device_role=device_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(name='Interface 6', 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.objects.bulk_create(vlans)
|
||||||
|
|
||||||
|
l2vpns = (
|
||||||
|
L2VPN(name='L2VPN 1', type='vxlan', identifier=650001),
|
||||||
|
L2VPN(name='L2VPN 2', type='vpws', identifier=650002),
|
||||||
|
L2VPN(name='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(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.objects.bulk_create(l2vpnterminations)
|
||||||
|
|
||||||
|
def test_l2vpns(self):
|
||||||
|
l2vpns = L2VPN.objects.all()[:2]
|
||||||
|
params = {'l2vpn_id': [l2vpns[0].pk, l2vpns[1].pk]}
|
||||||
|
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
|
||||||
|
params = {'l2vpn': ['L2VPN 1', 'L2VPN 2']}
|
||||||
|
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
|
||||||
|
|
||||||
|
def test_interfaces(self):
|
||||||
|
interfaces = Interface.objects.all()[:2]
|
||||||
|
params = {'interface_id': [interfaces[0].pk, interfaces[1].pk]}
|
||||||
|
qs = self.filterset(params, self.queryset).qs
|
||||||
|
results = qs.all()
|
||||||
|
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||||
|
params = {'interface': ['Interface 1', 'Interface 2']}
|
||||||
|
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||||
|
|
||||||
|
def test_vlans(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)
|
||||||
|
@ -2,8 +2,9 @@ from netaddr import IPNetwork, IPSet
|
|||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
from django.test import TestCase, override_settings
|
from django.test import TestCase, override_settings
|
||||||
|
|
||||||
|
from dcim.models import Interface, Device, DeviceRole, DeviceType, Manufacturer, Site
|
||||||
from ipam.choices import IPAddressRoleChoices, PrefixStatusChoices
|
from ipam.choices import IPAddressRoleChoices, PrefixStatusChoices
|
||||||
from ipam.models import Aggregate, IPAddress, IPRange, Prefix, RIR, VLAN, VLANGroup, VRF
|
from ipam.models import Aggregate, IPAddress, IPRange, Prefix, RIR, VLAN, VLANGroup, VRF, L2VPN, L2VPNTermination
|
||||||
|
|
||||||
|
|
||||||
class TestAggregate(TestCase):
|
class TestAggregate(TestCase):
|
||||||
@ -538,3 +539,76 @@ class TestVLANGroup(TestCase):
|
|||||||
|
|
||||||
VLAN.objects.create(name='VLAN 104', vid=104, group=vlangroup)
|
VLAN.objects.create(name='VLAN 104', vid=104, group=vlangroup)
|
||||||
self.assertEqual(vlangroup.get_next_available_vid(), 105)
|
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)
|
||||||
|
device_role = DeviceRole.objects.create(name='Switch')
|
||||||
|
device = Device.objects.create(
|
||||||
|
name='Device 1',
|
||||||
|
site=site,
|
||||||
|
device_type=device_type,
|
||||||
|
device_role=device_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', type='vxlan', identifier=650001),
|
||||||
|
L2VPN(name='L2VPN 2', type='vpws', identifier=650002),
|
||||||
|
L2VPN(name='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_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)
|
||||||
|
|
||||||
|
def test_duplicate_vlan_terminations(self):
|
||||||
|
vlan = Interface.objects.first()
|
||||||
|
l2vpn = L2VPN.objects.first()
|
||||||
|
|
||||||
|
L2VPNTermination.objects.create(l2vpn=l2vpn, assigned_object=vlan)
|
||||||
|
duplicate = L2VPNTermination(l2vpn=l2vpn, assigned_object=vlan)
|
||||||
|
self.assertRaises(ValidationError, duplicate.clean)
|
||||||
|
@ -1,14 +1,18 @@
|
|||||||
import datetime
|
import datetime
|
||||||
|
|
||||||
|
from django.contrib.contenttypes.models import ContentType
|
||||||
from django.test import override_settings
|
from django.test import override_settings
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from netaddr import IPNetwork
|
from netaddr import IPNetwork
|
||||||
|
|
||||||
from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Site
|
from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Site, Interface
|
||||||
|
from extras.choices import ObjectChangeActionChoices
|
||||||
|
from extras.models import ObjectChange
|
||||||
from ipam.choices import *
|
from ipam.choices import *
|
||||||
from ipam.models import *
|
from ipam.models import *
|
||||||
from tenancy.models import Tenant
|
from tenancy.models import Tenant
|
||||||
from utilities.testing import ViewTestCases, create_tags
|
from users.models import ObjectPermission
|
||||||
|
from utilities.testing import ViewTestCases, create_tags, post_data
|
||||||
|
|
||||||
|
|
||||||
class ASNTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
class ASNTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
||||||
@ -746,3 +750,133 @@ class ServiceTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
|||||||
self.assertEqual(instance.protocol, service_template.protocol)
|
self.assertEqual(instance.protocol, service_template.protocol)
|
||||||
self.assertEqual(instance.ports, service_template.ports)
|
self.assertEqual(instance.ports, service_template.ports)
|
||||||
self.assertEqual(instance.description, service_template.description)
|
self.assertEqual(instance.description, service_template.description)
|
||||||
|
|
||||||
|
|
||||||
|
class L2VPNTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
||||||
|
model = L2VPN
|
||||||
|
csv_data = (
|
||||||
|
'name,slug,type,identifier',
|
||||||
|
'L2VPN 5,l2vpn-5,vxlan,456',
|
||||||
|
'L2VPN 6,l2vpn-6,vxlan,444',
|
||||||
|
)
|
||||||
|
bulk_edit_data = {
|
||||||
|
'description': 'New Description',
|
||||||
|
}
|
||||||
|
|
||||||
|
@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='vxlan', identifier='650001'),
|
||||||
|
L2VPN(name='L2VPN 2', slug='l2vpn-2', type='vxlan', identifier='650002'),
|
||||||
|
L2VPN(name='L2VPN 3', slug='l2vpn-3', type='vxlan', identifier='650003')
|
||||||
|
)
|
||||||
|
|
||||||
|
L2VPN.objects.bulk_create(l2vpns)
|
||||||
|
|
||||||
|
cls.form_data = {
|
||||||
|
'name': 'L2VPN 8',
|
||||||
|
'slug': 'l2vpn-8',
|
||||||
|
'type': 'vxlan',
|
||||||
|
'identifier': 123,
|
||||||
|
'description': 'Description',
|
||||||
|
'import_targets': [rts[0].pk],
|
||||||
|
'export_targets': [rts[1].pk]
|
||||||
|
}
|
||||||
|
|
||||||
|
print(cls.form_data)
|
||||||
|
|
||||||
|
|
||||||
|
class L2VPNTerminationTestCase(
|
||||||
|
ViewTestCases.GetObjectViewTestCase,
|
||||||
|
ViewTestCases.GetObjectChangelogViewTestCase,
|
||||||
|
ViewTestCases.CreateObjectViewTestCase,
|
||||||
|
ViewTestCases.EditObjectViewTestCase,
|
||||||
|
ViewTestCases.DeleteObjectViewTestCase,
|
||||||
|
ViewTestCases.ListObjectsViewTestCase,
|
||||||
|
ViewTestCases.BulkImportObjectsViewTestCase,
|
||||||
|
ViewTestCases.BulkDeleteObjectsViewTestCase,
|
||||||
|
):
|
||||||
|
|
||||||
|
model = L2VPNTermination
|
||||||
|
|
||||||
|
@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)
|
||||||
|
device_role = DeviceRole.objects.create(name='Switch')
|
||||||
|
device = Device.objects.create(
|
||||||
|
name='Device 1',
|
||||||
|
site=site,
|
||||||
|
device_type=device_type,
|
||||||
|
device_role=device_role,
|
||||||
|
status='active'
|
||||||
|
)
|
||||||
|
|
||||||
|
interface = Interface.objects.create(name='Interface 1', device=device, type='1000baset')
|
||||||
|
l2vpn = L2VPN.objects.create(name='L2VPN 1', type='vxlan', identifier=650001)
|
||||||
|
l2vpn_vlans = L2VPN.objects.create(name='L2VPN 2', type='vxlan', identifier=650002)
|
||||||
|
|
||||||
|
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=l2vpn, assigned_object=vlans[0]),
|
||||||
|
L2VPNTermination(l2vpn=l2vpn, assigned_object=vlans[1]),
|
||||||
|
L2VPNTermination(l2vpn=l2vpn, assigned_object=vlans[2])
|
||||||
|
)
|
||||||
|
L2VPNTermination.objects.bulk_create(terminations)
|
||||||
|
|
||||||
|
cls.form_data = {
|
||||||
|
'l2vpn': l2vpn.pk,
|
||||||
|
'device': device.pk,
|
||||||
|
'interface': interface.pk,
|
||||||
|
}
|
||||||
|
|
||||||
|
cls.csv_data = (
|
||||||
|
"l2vpn,vlan",
|
||||||
|
"L2VPN 2,Vlan 4",
|
||||||
|
"L2VPN 2,Vlan 5",
|
||||||
|
"L2VPN 2,Vlan 6",
|
||||||
|
)
|
||||||
|
|
||||||
|
cls.bulk_edit_data = {}
|
||||||
|
|
||||||
|
#
|
||||||
|
# Custom assertions
|
||||||
|
#
|
||||||
|
|
||||||
|
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)
|
||||||
|
@ -186,4 +186,26 @@ urlpatterns = [
|
|||||||
path('services/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='service_changelog', kwargs={'model': Service}),
|
path('services/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='service_changelog', kwargs={'model': Service}),
|
||||||
path('services/<int:pk>/journal/', ObjectJournalView.as_view(), name='service_journal', kwargs={'model': Service}),
|
path('services/<int:pk>/journal/', ObjectJournalView.as_view(), name='service_journal', kwargs={'model': 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>/', views.L2VPNView.as_view(), name='l2vpn'),
|
||||||
|
path('l2vpns/<int:pk>/edit/', views.L2VPNEditView.as_view(), name='l2vpn_edit'),
|
||||||
|
path('l2vpns/<int:pk>/delete/', views.L2VPNDeleteView.as_view(), name='l2vpn_delete'),
|
||||||
|
path('l2vpns/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='l2vpn_changelog', kwargs={'model': L2VPN}),
|
||||||
|
path('l2vpns/<int:pk>/journal/', ObjectJournalView.as_view(), name='l2vpn_journal', kwargs={'model': L2VPN}),
|
||||||
|
|
||||||
|
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>/', views.L2VPNTerminationView.as_view(), name='l2vpntermination'),
|
||||||
|
path('l2vpn-terminations/<int:pk>/edit/', views.L2VPNTerminationEditView.as_view(), name='l2vpntermination_edit'),
|
||||||
|
path('l2vpn-terminations/<int:pk>/delete/', views.L2VPNTerminationDeleteView.as_view(), name='l2vpntermination_delete'),
|
||||||
|
path('l2vpn-terminations/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='l2vpntermination_changelog', kwargs={'model': L2VPNTermination}),
|
||||||
|
path('l2vpn-terminations/<int:pk>/journal/', ObjectJournalView.as_view(), name='l2vpntermination_journal', kwargs={'model': L2VPNTermination}),
|
||||||
]
|
]
|
||||||
|
@ -17,6 +17,7 @@ from . import filtersets, forms, tables
|
|||||||
from .constants import *
|
from .constants import *
|
||||||
from .models import *
|
from .models import *
|
||||||
from .models import ASN
|
from .models import ASN
|
||||||
|
from .tables.l2vpn import L2VPNTable, L2VPNTerminationTable
|
||||||
from .utils import add_requested_prefixes, add_available_ipaddresses, add_available_vlans
|
from .utils import add_requested_prefixes, add_available_ipaddresses, add_available_vlans
|
||||||
|
|
||||||
|
|
||||||
@ -680,13 +681,16 @@ class IPAddressView(generic.ObjectView):
|
|||||||
service_filter = Q(ipaddresses=instance)
|
service_filter = Q(ipaddresses=instance)
|
||||||
|
|
||||||
# Find services listening on all IPs on the assigned device/vm
|
# Find services listening on all IPs on the assigned device/vm
|
||||||
if instance.assigned_object and instance.assigned_object.parent_object:
|
try:
|
||||||
parent_object = instance.assigned_object.parent_object
|
if instance.assigned_object and instance.assigned_object.parent_object:
|
||||||
|
parent_object = instance.assigned_object.parent_object
|
||||||
|
|
||||||
if isinstance(parent_object, VirtualMachine):
|
if isinstance(parent_object, VirtualMachine):
|
||||||
service_filter |= (Q(virtual_machine=parent_object) & Q(ipaddresses=None))
|
service_filter |= (Q(virtual_machine=parent_object) & Q(ipaddresses=None))
|
||||||
elif isinstance(parent_object, Device):
|
elif isinstance(parent_object, Device):
|
||||||
service_filter |= (Q(device=parent_object) & Q(ipaddresses=None))
|
service_filter |= (Q(device=parent_object) & Q(ipaddresses=None))
|
||||||
|
except AttributeError:
|
||||||
|
pass
|
||||||
|
|
||||||
services = Service.objects.restrict(request.user, 'view').filter(service_filter)
|
services = Service.objects.restrict(request.user, 'view').filter(service_filter)
|
||||||
|
|
||||||
@ -1147,3 +1151,105 @@ class ServiceBulkDeleteView(generic.BulkDeleteView):
|
|||||||
queryset = Service.objects.prefetch_related('device', 'virtual_machine')
|
queryset = Service.objects.prefetch_related('device', 'virtual_machine')
|
||||||
filterset = filtersets.ServiceFilterSet
|
filterset = filtersets.ServiceFilterSet
|
||||||
table = tables.ServiceTable
|
table = tables.ServiceTable
|
||||||
|
|
||||||
|
|
||||||
|
# L2VPN
|
||||||
|
|
||||||
|
|
||||||
|
class L2VPNListView(generic.ObjectListView):
|
||||||
|
queryset = L2VPN.objects.all()
|
||||||
|
table = L2VPNTable
|
||||||
|
filterset = filtersets.L2VPNFilterSet
|
||||||
|
filterset_form = forms.L2VPNFilterForm
|
||||||
|
|
||||||
|
|
||||||
|
class L2VPNView(generic.ObjectView):
|
||||||
|
queryset = L2VPN.objects.all()
|
||||||
|
|
||||||
|
def get_extra_context(self, request, instance):
|
||||||
|
terminations = L2VPNTermination.objects.restrict(request.user, 'view').filter(l2vpn=instance)
|
||||||
|
terminations_table = tables.L2VPNTerminationTable(terminations, user=request.user, exclude=('l2vpn', ))
|
||||||
|
terminations_table.configure(request)
|
||||||
|
|
||||||
|
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 {
|
||||||
|
'terminations_table': terminations_table,
|
||||||
|
'import_targets_table': import_targets_table,
|
||||||
|
'export_targets_table': export_targets_table,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class L2VPNEditView(generic.ObjectEditView):
|
||||||
|
queryset = L2VPN.objects.all()
|
||||||
|
form = forms.L2VPNForm
|
||||||
|
|
||||||
|
|
||||||
|
class L2VPNDeleteView(generic.ObjectDeleteView):
|
||||||
|
queryset = L2VPN.objects.all()
|
||||||
|
|
||||||
|
|
||||||
|
class L2VPNBulkImportView(generic.BulkImportView):
|
||||||
|
queryset = L2VPN.objects.all()
|
||||||
|
model_form = forms.L2VPNCSVForm
|
||||||
|
table = tables.L2VPNTable
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
class L2VPNTerminationListView(generic.ObjectListView):
|
||||||
|
queryset = L2VPNTermination.objects.all()
|
||||||
|
table = L2VPNTerminationTable
|
||||||
|
filterset = filtersets.L2VPNTerminationFilterSet
|
||||||
|
filterset_form = forms.L2VPNTerminationFilterForm
|
||||||
|
|
||||||
|
|
||||||
|
class L2VPNTerminationView(generic.ObjectView):
|
||||||
|
queryset = L2VPNTermination.objects.all()
|
||||||
|
|
||||||
|
|
||||||
|
class L2VPNTerminationEditView(generic.ObjectEditView):
|
||||||
|
queryset = L2VPNTermination.objects.all()
|
||||||
|
form = forms.L2VPNTerminationForm
|
||||||
|
template_name = 'ipam/l2vpntermination_edit.html'
|
||||||
|
|
||||||
|
|
||||||
|
class L2VPNTerminationDeleteView(generic.ObjectDeleteView):
|
||||||
|
queryset = L2VPNTermination.objects.all()
|
||||||
|
|
||||||
|
|
||||||
|
class L2VPNTerminationBulkImportView(generic.BulkImportView):
|
||||||
|
queryset = L2VPNTermination.objects.all()
|
||||||
|
model_form = forms.L2VPNTerminationCSVForm
|
||||||
|
table = tables.L2VPNTerminationTable
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
@ -11,6 +11,7 @@ from rest_framework.viewsets import ModelViewSet
|
|||||||
from extras.models import ExportTemplate
|
from extras.models import ExportTemplate
|
||||||
from netbox.api.exceptions import SerializerNotFound
|
from netbox.api.exceptions import SerializerNotFound
|
||||||
from utilities.api import get_serializer_for_model
|
from utilities.api import get_serializer_for_model
|
||||||
|
from utilities.exceptions import AbortRequest
|
||||||
from .mixins import *
|
from .mixins import *
|
||||||
|
|
||||||
__all__ = (
|
__all__ = (
|
||||||
@ -125,6 +126,14 @@ class NetBoxModelViewSet(BulkUpdateModelMixin, BulkDestroyModelMixin, ObjectVali
|
|||||||
*args,
|
*args,
|
||||||
**kwargs
|
**kwargs
|
||||||
)
|
)
|
||||||
|
except AbortRequest as e:
|
||||||
|
logger.debug(e.message)
|
||||||
|
return self.finalize_response(
|
||||||
|
request,
|
||||||
|
Response({'detail': e.message}, status=400),
|
||||||
|
*args,
|
||||||
|
**kwargs
|
||||||
|
)
|
||||||
|
|
||||||
def list(self, request, *args, **kwargs):
|
def list(self, request, *args, **kwargs):
|
||||||
"""
|
"""
|
||||||
|
@ -8,8 +8,11 @@ from django.contrib.auth.models import Group, AnonymousUser
|
|||||||
from django.core.exceptions import ImproperlyConfigured
|
from django.core.exceptions import ImproperlyConfigured
|
||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
|
|
||||||
|
from users.constants import CONSTRAINT_TOKEN_USER
|
||||||
from users.models import ObjectPermission
|
from users.models import ObjectPermission
|
||||||
from utilities.permissions import permission_is_exempt, resolve_permission, resolve_permission_ct
|
from utilities.permissions import (
|
||||||
|
permission_is_exempt, qs_filter_from_constraints, resolve_permission, resolve_permission_ct,
|
||||||
|
)
|
||||||
|
|
||||||
UserModel = get_user_model()
|
UserModel = get_user_model()
|
||||||
|
|
||||||
@ -99,8 +102,10 @@ class ObjectPermissionMixin:
|
|||||||
if not user_obj.is_active or user_obj.is_anonymous:
|
if not user_obj.is_active or user_obj.is_anonymous:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
object_permissions = self.get_all_permissions(user_obj)
|
||||||
|
|
||||||
# If no applicable ObjectPermissions have been created for this user/permission, deny permission
|
# If no applicable ObjectPermissions have been created for this user/permission, deny permission
|
||||||
if perm not in self.get_all_permissions(user_obj):
|
if perm not in object_permissions:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# If no object has been specified, grant permission. (The presence of a permission in this set tells
|
# If no object has been specified, grant permission. (The presence of a permission in this set tells
|
||||||
@ -113,21 +118,16 @@ class ObjectPermissionMixin:
|
|||||||
if model._meta.label_lower != '.'.join((app_label, model_name)):
|
if model._meta.label_lower != '.'.join((app_label, model_name)):
|
||||||
raise ValueError(f"Invalid permission {perm} for model {model}")
|
raise ValueError(f"Invalid permission {perm} for model {model}")
|
||||||
|
|
||||||
# Compile a query filter that matches all instances of the specified model
|
# Compile a QuerySet filter that matches all instances of the specified model
|
||||||
obj_perm_constraints = self.get_all_permissions(user_obj)[perm]
|
tokens = {
|
||||||
constraints = Q()
|
CONSTRAINT_TOKEN_USER: user_obj,
|
||||||
for perm_constraints in obj_perm_constraints:
|
}
|
||||||
if perm_constraints:
|
qs_filter = qs_filter_from_constraints(object_permissions[perm], tokens)
|
||||||
constraints |= Q(**perm_constraints)
|
|
||||||
else:
|
|
||||||
# Found ObjectPermission with null constraints; allow model-level access
|
|
||||||
constraints = Q()
|
|
||||||
break
|
|
||||||
|
|
||||||
# Permission to perform the requested action on the object depends on whether the specified object matches
|
# Permission to perform the requested action on the object depends on whether the specified object matches
|
||||||
# the specified constraints. Note that this check is made against the *database* record representing the object,
|
# the specified constraints. Note that this check is made against the *database* record representing the object,
|
||||||
# not the instance itself.
|
# not the instance itself.
|
||||||
return model.objects.filter(constraints, pk=obj.pk).exists()
|
return model.objects.filter(qs_filter, pk=obj.pk).exists()
|
||||||
|
|
||||||
|
|
||||||
class ObjectPermissionBackend(ObjectPermissionMixin, ModelBackend):
|
class ObjectPermissionBackend(ObjectPermissionMixin, ModelBackend):
|
||||||
@ -348,3 +348,26 @@ class LDAPBackend:
|
|||||||
ldap.set_option(ldap.OPT_X_TLS_REQUIRE_CERT, ldap.OPT_X_TLS_NEVER)
|
ldap.set_option(ldap.OPT_X_TLS_REQUIRE_CERT, ldap.OPT_X_TLS_NEVER)
|
||||||
|
|
||||||
return obj
|
return obj
|
||||||
|
|
||||||
|
|
||||||
|
# Custom Social Auth Pipeline Handlers
|
||||||
|
def user_default_groups_handler(backend, user, response, *args, **kwargs):
|
||||||
|
"""
|
||||||
|
Custom pipeline handler which adds remote auth users to the default group specified in the
|
||||||
|
configuration file.
|
||||||
|
"""
|
||||||
|
logger = logging.getLogger('netbox.auth.user_default_groups_handler')
|
||||||
|
if settings.REMOTE_AUTH_DEFAULT_GROUPS:
|
||||||
|
# Assign default groups to the user
|
||||||
|
group_list = []
|
||||||
|
for name in settings.REMOTE_AUTH_DEFAULT_GROUPS:
|
||||||
|
try:
|
||||||
|
group_list.append(Group.objects.get(name=name))
|
||||||
|
except Group.DoesNotExist:
|
||||||
|
logging.error(
|
||||||
|
f"Could not assign group {name} to remotely-authenticated user {user}: Group not found")
|
||||||
|
if group_list:
|
||||||
|
user.groups.add(*group_list)
|
||||||
|
else:
|
||||||
|
user.groups.clear()
|
||||||
|
logger.debug(f"Stripping user {user} from Groups")
|
||||||
|
@ -82,6 +82,31 @@ PARAMS = (
|
|||||||
field=forms.IntegerField
|
field=forms.IntegerField
|
||||||
),
|
),
|
||||||
|
|
||||||
|
# Power
|
||||||
|
ConfigParam(
|
||||||
|
name='POWERFEED_DEFAULT_VOLTAGE',
|
||||||
|
label='Powerfeed voltage',
|
||||||
|
default=120,
|
||||||
|
description="Default voltage for powerfeeds",
|
||||||
|
field=forms.IntegerField
|
||||||
|
),
|
||||||
|
|
||||||
|
ConfigParam(
|
||||||
|
name='POWERFEED_DEFAULT_AMPERAGE',
|
||||||
|
label='Powerfeed amperage',
|
||||||
|
default=15,
|
||||||
|
description="Default amperage for powerfeeds",
|
||||||
|
field=forms.IntegerField
|
||||||
|
),
|
||||||
|
|
||||||
|
ConfigParam(
|
||||||
|
name='POWERFEED_DEFAULT_MAX_UTILIZATION',
|
||||||
|
label='Powerfeed max utilization',
|
||||||
|
default=80,
|
||||||
|
description="Default max utilization for powerfeeds",
|
||||||
|
field=forms.IntegerField
|
||||||
|
),
|
||||||
|
|
||||||
# Security
|
# Security
|
||||||
ConfigParam(
|
ConfigParam(
|
||||||
name='ALLOWED_URL_SCHEMES',
|
name='ALLOWED_URL_SCHEMES',
|
||||||
|
@ -49,11 +49,19 @@ class ChangeLoggingMixin(models.Model):
|
|||||||
class Meta:
|
class Meta:
|
||||||
abstract = True
|
abstract = True
|
||||||
|
|
||||||
|
def serialize_object(self):
|
||||||
|
"""
|
||||||
|
Return a JSON representation of the instance. Models can override this method to replace or extend the default
|
||||||
|
serialization logic provided by the `serialize_object()` utility function.
|
||||||
|
"""
|
||||||
|
return serialize_object(self)
|
||||||
|
|
||||||
def snapshot(self):
|
def snapshot(self):
|
||||||
"""
|
"""
|
||||||
Save a snapshot of the object's current state in preparation for modification.
|
Save a snapshot of the object's current state in preparation for modification. The snapshot is saved as
|
||||||
|
`_prechange_snapshot` on the instance.
|
||||||
"""
|
"""
|
||||||
self._prechange_snapshot = serialize_object(self)
|
self._prechange_snapshot = self.serialize_object()
|
||||||
|
|
||||||
def to_objectchange(self, action):
|
def to_objectchange(self, action):
|
||||||
"""
|
"""
|
||||||
@ -69,7 +77,7 @@ class ChangeLoggingMixin(models.Model):
|
|||||||
if hasattr(self, '_prechange_snapshot'):
|
if hasattr(self, '_prechange_snapshot'):
|
||||||
objectchange.prechange_data = self._prechange_snapshot
|
objectchange.prechange_data = self._prechange_snapshot
|
||||||
if action in (ObjectChangeActionChoices.ACTION_CREATE, ObjectChangeActionChoices.ACTION_UPDATE):
|
if action in (ObjectChangeActionChoices.ACTION_CREATE, ObjectChangeActionChoices.ACTION_UPDATE):
|
||||||
objectchange.postchange_data = serialize_object(self)
|
objectchange.postchange_data = self.serialize_object()
|
||||||
|
|
||||||
return objectchange
|
return objectchange
|
||||||
|
|
||||||
|
@ -260,6 +260,13 @@ IPAM_MENU = Menu(
|
|||||||
get_model_item('ipam', 'vlangroup', 'VLAN Groups'),
|
get_model_item('ipam', 'vlangroup', 'VLAN Groups'),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
MenuGroup(
|
||||||
|
label='L2VPNs',
|
||||||
|
items=(
|
||||||
|
get_model_item('ipam', 'l2vpn', 'L2VPNs'),
|
||||||
|
get_model_item('ipam', 'l2vpntermination', 'Terminations'),
|
||||||
|
),
|
||||||
|
),
|
||||||
MenuGroup(
|
MenuGroup(
|
||||||
label='Other',
|
label='Other',
|
||||||
items=(
|
items=(
|
||||||
|
@ -485,6 +485,19 @@ for param in dir(configuration):
|
|||||||
|
|
||||||
SOCIAL_AUTH_JSONFIELD_ENABLED = True
|
SOCIAL_AUTH_JSONFIELD_ENABLED = True
|
||||||
|
|
||||||
|
SOCIAL_AUTH_PIPELINE = (
|
||||||
|
'social_core.pipeline.social_auth.social_details',
|
||||||
|
'social_core.pipeline.social_auth.social_uid',
|
||||||
|
'social_core.pipeline.social_auth.social_user',
|
||||||
|
'social_core.pipeline.user.get_username',
|
||||||
|
'social_core.pipeline.social_auth.associate_by_email',
|
||||||
|
'social_core.pipeline.user.create_user',
|
||||||
|
'social_core.pipeline.social_auth.associate_user',
|
||||||
|
'netbox.authentication.user_default_groups_handler',
|
||||||
|
'social_core.pipeline.social_auth.load_extra_data',
|
||||||
|
'social_core.pipeline.user.user_details',
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
#
|
#
|
||||||
# Django Prometheus
|
# Django Prometheus
|
||||||
|
@ -1,6 +1,5 @@
|
|||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
from collections import defaultdict
|
|
||||||
from copy import deepcopy
|
from copy import deepcopy
|
||||||
|
|
||||||
from django.contrib import messages
|
from django.contrib import messages
|
||||||
@ -12,11 +11,12 @@ from django.forms import Form, ModelMultipleChoiceField, MultipleHiddenInput
|
|||||||
from django.http import HttpResponse
|
from django.http import HttpResponse
|
||||||
from django.shortcuts import get_object_or_404, redirect, render
|
from django.shortcuts import get_object_or_404, redirect, render
|
||||||
from django_tables2.export import TableExport
|
from django_tables2.export import TableExport
|
||||||
|
from django.utils.safestring import mark_safe
|
||||||
|
|
||||||
from extras.models import ExportTemplate
|
from extras.models import ExportTemplate
|
||||||
from extras.signals import clear_webhooks
|
from extras.signals import clear_webhooks
|
||||||
from utilities.error_handlers import handle_protectederror
|
from utilities.error_handlers import handle_protectederror
|
||||||
from utilities.exceptions import PermissionsViolation
|
from utilities.exceptions import AbortRequest, PermissionsViolation
|
||||||
from utilities.forms import (
|
from utilities.forms import (
|
||||||
BootstrapMixin, BulkRenameForm, ConfirmationForm, CSVDataField, CSVFileField, restrict_form_fields,
|
BootstrapMixin, BulkRenameForm, ConfirmationForm, CSVDataField, CSVFileField, restrict_form_fields,
|
||||||
)
|
)
|
||||||
@ -24,6 +24,7 @@ from utilities.htmx import is_htmx
|
|||||||
from utilities.permissions import get_permission_for_model
|
from utilities.permissions import get_permission_for_model
|
||||||
from utilities.views import GetReturnURLMixin
|
from utilities.views import GetReturnURLMixin
|
||||||
from .base import BaseMultiObjectView
|
from .base import BaseMultiObjectView
|
||||||
|
from .mixins import ActionsMixin, TableMixin
|
||||||
|
|
||||||
__all__ = (
|
__all__ = (
|
||||||
'BulkComponentCreateView',
|
'BulkComponentCreateView',
|
||||||
@ -36,9 +37,9 @@ __all__ = (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class ObjectListView(BaseMultiObjectView):
|
class ObjectListView(BaseMultiObjectView, ActionsMixin, TableMixin):
|
||||||
"""
|
"""
|
||||||
Display multiple objects, all of the same type, as a table.
|
Display multiple objects, all the same type, as a table.
|
||||||
|
|
||||||
Attributes:
|
Attributes:
|
||||||
filterset: A django-filter FilterSet that is applied to the queryset
|
filterset: A django-filter FilterSet that is applied to the queryset
|
||||||
@ -50,31 +51,10 @@ class ObjectListView(BaseMultiObjectView):
|
|||||||
template_name = 'generic/object_list.html'
|
template_name = 'generic/object_list.html'
|
||||||
filterset = None
|
filterset = None
|
||||||
filterset_form = None
|
filterset_form = None
|
||||||
actions = ('add', 'import', 'export', 'bulk_edit', 'bulk_delete')
|
|
||||||
action_perms = defaultdict(set, **{
|
|
||||||
'add': {'add'},
|
|
||||||
'import': {'add'},
|
|
||||||
'bulk_edit': {'change'},
|
|
||||||
'bulk_delete': {'delete'},
|
|
||||||
})
|
|
||||||
|
|
||||||
def get_required_permission(self):
|
def get_required_permission(self):
|
||||||
return get_permission_for_model(self.queryset.model, 'view')
|
return get_permission_for_model(self.queryset.model, 'view')
|
||||||
|
|
||||||
def get_table(self, request, bulk_actions=True):
|
|
||||||
"""
|
|
||||||
Return the django-tables2 Table instance to be used for rendering the objects list.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
request: The current request
|
|
||||||
bulk_actions: Show checkboxes for object selection
|
|
||||||
"""
|
|
||||||
table = self.table(self.queryset, user=request.user)
|
|
||||||
if 'pk' in table.base_columns and bulk_actions:
|
|
||||||
table.columns.show('pk')
|
|
||||||
|
|
||||||
return table
|
|
||||||
|
|
||||||
#
|
#
|
||||||
# Export methods
|
# Export methods
|
||||||
#
|
#
|
||||||
@ -147,19 +127,14 @@ class ObjectListView(BaseMultiObjectView):
|
|||||||
self.queryset = self.filterset(request.GET, self.queryset).qs
|
self.queryset = self.filterset(request.GET, self.queryset).qs
|
||||||
|
|
||||||
# Determine the available actions
|
# Determine the available actions
|
||||||
actions = []
|
actions = self.get_permitted_actions(request.user)
|
||||||
for action in self.actions:
|
|
||||||
if request.user.has_perms([
|
|
||||||
get_permission_for_model(model, name) for name in self.action_perms[action]
|
|
||||||
]):
|
|
||||||
actions.append(action)
|
|
||||||
has_bulk_actions = any([a.startswith('bulk_') for a in actions])
|
has_bulk_actions = any([a.startswith('bulk_') for a in actions])
|
||||||
|
|
||||||
if 'export' in request.GET:
|
if 'export' in request.GET:
|
||||||
|
|
||||||
# Export the current table view
|
# Export the current table view
|
||||||
if request.GET['export'] == 'table':
|
if request.GET['export'] == 'table':
|
||||||
table = self.get_table(request, has_bulk_actions)
|
table = self.get_table(self.queryset, request, has_bulk_actions)
|
||||||
columns = [name for name, _ in table.selected_columns]
|
columns = [name for name, _ in table.selected_columns]
|
||||||
return self.export_table(table, columns)
|
return self.export_table(table, columns)
|
||||||
|
|
||||||
@ -177,12 +152,11 @@ class ObjectListView(BaseMultiObjectView):
|
|||||||
|
|
||||||
# Fall back to default table/YAML export
|
# Fall back to default table/YAML export
|
||||||
else:
|
else:
|
||||||
table = self.get_table(request, has_bulk_actions)
|
table = self.get_table(self.queryset, request, has_bulk_actions)
|
||||||
return self.export_table(table)
|
return self.export_table(table)
|
||||||
|
|
||||||
# Render the objects table
|
# Render the objects table
|
||||||
table = self.get_table(request, has_bulk_actions)
|
table = self.get_table(self.queryset, request, has_bulk_actions)
|
||||||
table.configure(request)
|
|
||||||
|
|
||||||
# If this is an HTMX request, return only the rendered table HTML
|
# If this is an HTMX request, return only the rendered table HTML
|
||||||
if is_htmx(request):
|
if is_htmx(request):
|
||||||
@ -190,15 +164,13 @@ class ObjectListView(BaseMultiObjectView):
|
|||||||
'table': table,
|
'table': table,
|
||||||
})
|
})
|
||||||
|
|
||||||
context = {
|
return render(request, self.template_name, {
|
||||||
'model': model,
|
'model': model,
|
||||||
'table': table,
|
'table': table,
|
||||||
'actions': actions,
|
'actions': actions,
|
||||||
'filter_form': self.filterset_form(request.GET, label_suffix='') if self.filterset_form else None,
|
'filter_form': self.filterset_form(request.GET, label_suffix='') if self.filterset_form else None,
|
||||||
**self.get_extra_context(request),
|
**self.get_extra_context(request),
|
||||||
}
|
})
|
||||||
|
|
||||||
return render(request, self.template_name, context)
|
|
||||||
|
|
||||||
|
|
||||||
class BulkCreateView(GetReturnURLMixin, BaseMultiObjectView):
|
class BulkCreateView(GetReturnURLMixin, BaseMultiObjectView):
|
||||||
@ -292,10 +264,10 @@ class BulkCreateView(GetReturnURLMixin, BaseMultiObjectView):
|
|||||||
except IntegrityError:
|
except IntegrityError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
except PermissionsViolation:
|
except (AbortRequest, PermissionsViolation) as e:
|
||||||
msg = "Object creation failed due to object-level permissions violation"
|
logger.debug(e.message)
|
||||||
logger.debug(msg)
|
form.add_error(None, e.message)
|
||||||
form.add_error(None, msg)
|
clear_webhooks.send(sender=self)
|
||||||
|
|
||||||
else:
|
else:
|
||||||
logger.debug("Form validation failed")
|
logger.debug("Form validation failed")
|
||||||
@ -420,10 +392,9 @@ class BulkImportView(GetReturnURLMixin, BaseMultiObjectView):
|
|||||||
except ValidationError:
|
except ValidationError:
|
||||||
clear_webhooks.send(sender=self)
|
clear_webhooks.send(sender=self)
|
||||||
|
|
||||||
except PermissionsViolation:
|
except (AbortRequest, PermissionsViolation) as e:
|
||||||
msg = "Object import failed due to object-level permissions violation"
|
logger.debug(e.message)
|
||||||
logger.debug(msg)
|
form.add_error(None, e.message)
|
||||||
form.add_error(None, msg)
|
|
||||||
clear_webhooks.send(sender=self)
|
clear_webhooks.send(sender=self)
|
||||||
|
|
||||||
else:
|
else:
|
||||||
@ -570,10 +541,9 @@ class BulkEditView(GetReturnURLMixin, BaseMultiObjectView):
|
|||||||
messages.error(self.request, ", ".join(e.messages))
|
messages.error(self.request, ", ".join(e.messages))
|
||||||
clear_webhooks.send(sender=self)
|
clear_webhooks.send(sender=self)
|
||||||
|
|
||||||
except PermissionsViolation:
|
except (AbortRequest, PermissionsViolation) as e:
|
||||||
msg = "Object update failed due to object-level permissions violation"
|
logger.debug(e.message)
|
||||||
logger.debug(msg)
|
form.add_error(None, e.message)
|
||||||
form.add_error(None, msg)
|
|
||||||
clear_webhooks.send(sender=self)
|
clear_webhooks.send(sender=self)
|
||||||
|
|
||||||
else:
|
else:
|
||||||
@ -667,10 +637,9 @@ class BulkRenameView(GetReturnURLMixin, BaseMultiObjectView):
|
|||||||
messages.success(request, f"Renamed {len(selected_objects)} {model_name}")
|
messages.success(request, f"Renamed {len(selected_objects)} {model_name}")
|
||||||
return redirect(self.get_return_url(request))
|
return redirect(self.get_return_url(request))
|
||||||
|
|
||||||
except PermissionsViolation:
|
except (AbortRequest, PermissionsViolation) as e:
|
||||||
msg = "Object update failed due to object-level permissions violation"
|
logger.debug(e.message)
|
||||||
logger.debug(msg)
|
form.add_error(None, e.message)
|
||||||
form.add_error(None, msg)
|
|
||||||
clear_webhooks.send(sender=self)
|
clear_webhooks.send(sender=self)
|
||||||
|
|
||||||
else:
|
else:
|
||||||
@ -745,11 +714,17 @@ class BulkDeleteView(GetReturnURLMixin, BaseMultiObjectView):
|
|||||||
if hasattr(obj, 'snapshot'):
|
if hasattr(obj, 'snapshot'):
|
||||||
obj.snapshot()
|
obj.snapshot()
|
||||||
obj.delete()
|
obj.delete()
|
||||||
|
|
||||||
except ProtectedError as e:
|
except ProtectedError as e:
|
||||||
logger.info("Caught ProtectedError while attempting to delete objects")
|
logger.info("Caught ProtectedError while attempting to delete objects")
|
||||||
handle_protectederror(queryset, request, e)
|
handle_protectederror(queryset, request, e)
|
||||||
return redirect(self.get_return_url(request))
|
return redirect(self.get_return_url(request))
|
||||||
|
|
||||||
|
except AbortRequest as e:
|
||||||
|
logger.debug(e.message)
|
||||||
|
messages.error(request, mark_safe(e.message))
|
||||||
|
return redirect(self.get_return_url(request))
|
||||||
|
|
||||||
msg = f"Deleted {deleted_count} {model._meta.verbose_name_plural}"
|
msg = f"Deleted {deleted_count} {model._meta.verbose_name_plural}"
|
||||||
logger.info(msg)
|
logger.info(msg)
|
||||||
messages.success(request, msg)
|
messages.success(request, msg)
|
||||||
@ -857,10 +832,9 @@ class BulkComponentCreateView(GetReturnURLMixin, BaseMultiObjectView):
|
|||||||
except IntegrityError:
|
except IntegrityError:
|
||||||
clear_webhooks.send(sender=self)
|
clear_webhooks.send(sender=self)
|
||||||
|
|
||||||
except PermissionsViolation:
|
except (AbortRequest, PermissionsViolation) as e:
|
||||||
msg = "Component creation failed due to object-level permissions violation"
|
logger.debug(e.message)
|
||||||
logger.debug(msg)
|
form.add_error(None, e.message)
|
||||||
form.add_error(None, msg)
|
|
||||||
clear_webhooks.send(sender=self)
|
clear_webhooks.send(sender=self)
|
||||||
|
|
||||||
if not form.errors:
|
if not form.errors:
|
||||||
|
48
netbox/netbox/views/generic/mixins.py
Normal file
48
netbox/netbox/views/generic/mixins.py
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
from collections import defaultdict
|
||||||
|
|
||||||
|
from utilities.permissions import get_permission_for_model
|
||||||
|
|
||||||
|
__all__ = (
|
||||||
|
'ActionsMixin',
|
||||||
|
'TableMixin',
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ActionsMixin:
|
||||||
|
actions = ('add', 'import', 'export', 'bulk_edit', 'bulk_delete')
|
||||||
|
action_perms = defaultdict(set, **{
|
||||||
|
'add': {'add'},
|
||||||
|
'import': {'add'},
|
||||||
|
'bulk_edit': {'change'},
|
||||||
|
'bulk_delete': {'delete'},
|
||||||
|
})
|
||||||
|
|
||||||
|
def get_permitted_actions(self, user, model=None):
|
||||||
|
"""
|
||||||
|
Return a tuple of actions for which the given user is permitted to do.
|
||||||
|
"""
|
||||||
|
model = model or self.queryset.model
|
||||||
|
return [
|
||||||
|
action for action in self.actions if user.has_perms([
|
||||||
|
get_permission_for_model(model, name) for name in self.action_perms[action]
|
||||||
|
])
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class TableMixin:
|
||||||
|
|
||||||
|
def get_table(self, data, request, bulk_actions=True):
|
||||||
|
"""
|
||||||
|
Return the django-tables2 Table instance to be used for rendering the objects list.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
data: Queryset or iterable containing table data
|
||||||
|
request: The current request
|
||||||
|
bulk_actions: Render checkboxes for object selection
|
||||||
|
"""
|
||||||
|
table = self.table(data, user=request.user)
|
||||||
|
if 'pk' in table.base_columns and bulk_actions:
|
||||||
|
table.columns.show('pk')
|
||||||
|
table.configure(request)
|
||||||
|
|
||||||
|
return table
|
@ -13,13 +13,14 @@ from django.utils.safestring import mark_safe
|
|||||||
|
|
||||||
from extras.signals import clear_webhooks
|
from extras.signals import clear_webhooks
|
||||||
from utilities.error_handlers import handle_protectederror
|
from utilities.error_handlers import handle_protectederror
|
||||||
from utilities.exceptions import AbortTransaction, PermissionsViolation
|
from utilities.exceptions import AbortRequest, AbortTransaction, PermissionsViolation
|
||||||
from utilities.forms import ConfirmationForm, ImportForm, restrict_form_fields
|
from utilities.forms import ConfirmationForm, ImportForm, restrict_form_fields
|
||||||
from utilities.htmx import is_htmx
|
from utilities.htmx import is_htmx
|
||||||
from utilities.permissions import get_permission_for_model
|
from utilities.permissions import get_permission_for_model
|
||||||
from utilities.utils import get_viewname, normalize_querydict, prepare_cloned_fields
|
from utilities.utils import get_viewname, normalize_querydict, prepare_cloned_fields
|
||||||
from utilities.views import GetReturnURLMixin
|
from utilities.views import GetReturnURLMixin
|
||||||
from .base import BaseObjectView
|
from .base import BaseObjectView
|
||||||
|
from .mixins import ActionsMixin, TableMixin
|
||||||
|
|
||||||
__all__ = (
|
__all__ = (
|
||||||
'ComponentCreateView',
|
'ComponentCreateView',
|
||||||
@ -69,12 +70,17 @@ class ObjectView(BaseObjectView):
|
|||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
class ObjectChildrenView(ObjectView):
|
class ObjectChildrenView(ObjectView, ActionsMixin, TableMixin):
|
||||||
"""
|
"""
|
||||||
Display a table of child objects associated with the parent object.
|
Display a table of child objects associated with the parent object.
|
||||||
|
|
||||||
Attributes:
|
Attributes:
|
||||||
table: Table class used to render child objects list
|
child_model: The model class which represents the child objects
|
||||||
|
table: The django-tables2 Table class used to render the child objects list
|
||||||
|
filterset: A django-filter FilterSet that is applied to the queryset
|
||||||
|
actions: Supported actions for the model. When adding custom actions, bulk action names must
|
||||||
|
be prefixed with `bulk_`. Default actions: add, import, export, bulk_edit, bulk_delete
|
||||||
|
action_perms: A dictionary mapping supported actions to a set of permissions required for each
|
||||||
"""
|
"""
|
||||||
child_model = None
|
child_model = None
|
||||||
table = None
|
table = None
|
||||||
@ -84,8 +90,9 @@ class ObjectChildrenView(ObjectView):
|
|||||||
"""
|
"""
|
||||||
Return a QuerySet of child objects.
|
Return a QuerySet of child objects.
|
||||||
|
|
||||||
request: The current request
|
Args:
|
||||||
parent: The parent object
|
request: The current request
|
||||||
|
parent: The parent object
|
||||||
"""
|
"""
|
||||||
raise NotImplementedError(f'{self.__class__.__name__} must implement get_children()')
|
raise NotImplementedError(f'{self.__class__.__name__} must implement get_children()')
|
||||||
|
|
||||||
@ -114,16 +121,11 @@ class ObjectChildrenView(ObjectView):
|
|||||||
if self.filterset:
|
if self.filterset:
|
||||||
child_objects = self.filterset(request.GET, child_objects).qs
|
child_objects = self.filterset(request.GET, child_objects).qs
|
||||||
|
|
||||||
permissions = {}
|
# Determine the available actions
|
||||||
for action in ('change', 'delete'):
|
actions = self.get_permitted_actions(request.user, model=self.child_model)
|
||||||
perm_name = get_permission_for_model(self.child_model, action)
|
|
||||||
permissions[action] = request.user.has_perm(perm_name)
|
|
||||||
|
|
||||||
table = self.table(self.prep_table_data(request, child_objects, instance), user=request.user)
|
table_data = self.prep_table_data(request, child_objects, instance)
|
||||||
# Determine whether to display bulk action checkboxes
|
table = self.get_table(table_data, request, bool(actions))
|
||||||
if 'pk' in table.base_columns and (permissions['change'] or permissions['delete']):
|
|
||||||
table.columns.show('pk')
|
|
||||||
table.configure(request)
|
|
||||||
|
|
||||||
# If this is an HTMX request, return only the rendered table HTML
|
# If this is an HTMX request, return only the rendered table HTML
|
||||||
if is_htmx(request):
|
if is_htmx(request):
|
||||||
@ -134,8 +136,9 @@ class ObjectChildrenView(ObjectView):
|
|||||||
|
|
||||||
return render(request, self.get_template_name(), {
|
return render(request, self.get_template_name(), {
|
||||||
'object': instance,
|
'object': instance,
|
||||||
|
'child_model': self.child_model,
|
||||||
'table': table,
|
'table': table,
|
||||||
'permissions': permissions,
|
'actions': actions,
|
||||||
**self.get_extra_context(request, instance),
|
**self.get_extra_context(request, instance),
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -243,10 +246,9 @@ class ObjectImportView(GetReturnURLMixin, BaseObjectView):
|
|||||||
except AbortTransaction:
|
except AbortTransaction:
|
||||||
clear_webhooks.send(sender=self)
|
clear_webhooks.send(sender=self)
|
||||||
|
|
||||||
except PermissionsViolation:
|
except (AbortRequest, PermissionsViolation) as e:
|
||||||
msg = "Object creation failed due to object-level permissions violation"
|
logger.debug(e.message)
|
||||||
logger.debug(msg)
|
form.add_error(None, e.message)
|
||||||
form.add_error(None, msg)
|
|
||||||
clear_webhooks.send(sender=self)
|
clear_webhooks.send(sender=self)
|
||||||
|
|
||||||
if not model_form.errors:
|
if not model_form.errors:
|
||||||
@ -407,10 +409,9 @@ class ObjectEditView(GetReturnURLMixin, BaseObjectView):
|
|||||||
|
|
||||||
return redirect(return_url)
|
return redirect(return_url)
|
||||||
|
|
||||||
except PermissionsViolation:
|
except (AbortRequest, PermissionsViolation) as e:
|
||||||
msg = "Object save failed due to object-level permissions violation"
|
logger.debug(e.message)
|
||||||
logger.debug(msg)
|
form.add_error(None, e.message)
|
||||||
form.add_error(None, msg)
|
|
||||||
clear_webhooks.send(sender=self)
|
clear_webhooks.send(sender=self)
|
||||||
|
|
||||||
else:
|
else:
|
||||||
@ -486,11 +487,17 @@ class ObjectDeleteView(GetReturnURLMixin, BaseObjectView):
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
obj.delete()
|
obj.delete()
|
||||||
|
|
||||||
except ProtectedError as e:
|
except ProtectedError as e:
|
||||||
logger.info("Caught ProtectedError while attempting to delete object")
|
logger.info("Caught ProtectedError while attempting to delete object")
|
||||||
handle_protectederror([obj], request, e)
|
handle_protectederror([obj], request, e)
|
||||||
return redirect(obj.get_absolute_url())
|
return redirect(obj.get_absolute_url())
|
||||||
|
|
||||||
|
except AbortRequest as e:
|
||||||
|
logger.debug(e.message)
|
||||||
|
messages.error(request, mark_safe(e.message))
|
||||||
|
return redirect(obj.get_absolute_url())
|
||||||
|
|
||||||
msg = 'Deleted {} {}'.format(self.queryset.model._meta.verbose_name, obj)
|
msg = 'Deleted {} {}'.format(self.queryset.model._meta.verbose_name, obj)
|
||||||
logger.info(msg)
|
logger.info(msg)
|
||||||
messages.success(request, msg)
|
messages.success(request, msg)
|
||||||
@ -600,10 +607,9 @@ class ComponentCreateView(GetReturnURLMixin, BaseObjectView):
|
|||||||
else:
|
else:
|
||||||
return redirect(self.get_return_url(request))
|
return redirect(self.get_return_url(request))
|
||||||
|
|
||||||
except PermissionsViolation:
|
except (AbortRequest, PermissionsViolation) as e:
|
||||||
msg = "Component creation failed due to object-level permissions violation"
|
logger.debug(e.message)
|
||||||
logger.debug(msg)
|
form.add_error(None, e.message)
|
||||||
form.add_error(None, msg)
|
|
||||||
clear_webhooks.send(sender=self)
|
clear_webhooks.send(sender=self)
|
||||||
|
|
||||||
return render(request, self.template_name, {
|
return render(request, self.template_name, {
|
||||||
|
@ -8,74 +8,78 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col col-md-6">
|
<div class="col col-md-6">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h5 class="card-header">
|
<h5 class="card-header">Circuit</h5>
|
||||||
Circuit
|
<div class="card-body">
|
||||||
</h5>
|
<table class="table table-hover attr-table">
|
||||||
<div class="card-body">
|
<tr>
|
||||||
<table class="table table-hover attr-table">
|
<th scope="row">Provider</th>
|
||||||
<tr>
|
<td>{{ object.provider|linkify }}</td>
|
||||||
<th scope="row">Provider</th>
|
</tr>
|
||||||
<td>{{ object.provider|linkify }}</td>
|
<tr>
|
||||||
</tr>
|
<th scope="row">Circuit ID</th>
|
||||||
<tr>
|
<td>{{ object.cid }}</td>
|
||||||
<th scope="row">Circuit ID</th>
|
</tr>
|
||||||
<td>{{ object.cid }}</td>
|
<tr>
|
||||||
</tr>
|
<th scope="row">Type</th>
|
||||||
<tr>
|
<td>{{ object.type|linkify }}</td>
|
||||||
<th scope="row">Type</th>
|
</tr>
|
||||||
<td>{{ object.type|linkify }}</td>
|
<tr>
|
||||||
</tr>
|
<th scope="row">Status</th>
|
||||||
<tr>
|
<td>{% badge object.get_status_display bg_color=object.get_status_color %}</td>
|
||||||
<th scope="row">Status</th>
|
</tr>
|
||||||
<td>{% badge object.get_status_display bg_color=object.get_status_color %}</td>
|
<tr>
|
||||||
</tr>
|
<th scope="row">Tenant</th>
|
||||||
<tr>
|
<td>
|
||||||
<th scope="row">Tenant</th>
|
{% if object.tenant.group %}
|
||||||
<td>
|
{{ object.tenant.group|linkify }} /
|
||||||
{% if object.tenant.group %}
|
{% endif %}
|
||||||
{{ object.tenant.group|linkify }} /
|
{{ object.tenant|linkify|placeholder }}
|
||||||
{% endif %}
|
</td>
|
||||||
{{ object.tenant|linkify|placeholder }}
|
</tr>
|
||||||
</td>
|
<tr>
|
||||||
</tr>
|
<th scope="row">Install Date</th>
|
||||||
<tr>
|
<td>{{ object.install_date|annotated_date|placeholder }}</td>
|
||||||
<th scope="row">Install Date</th>
|
</tr>
|
||||||
<td>{{ object.install_date|annotated_date|placeholder }}</td>
|
<tr>
|
||||||
</tr>
|
<th scope="row">Termination Date</th>
|
||||||
<tr>
|
<td>{{ object.termination_date|annotated_date|placeholder }}</td>
|
||||||
<th scope="row">Termination Date</th>
|
</tr>
|
||||||
<td>{{ object.termination_date|annotated_date|placeholder }}</td>
|
<tr>
|
||||||
</tr>
|
<th scope="row">Commit Rate</th>
|
||||||
<tr>
|
<td>{{ object.commit_rate|humanize_speed|placeholder }}</td>
|
||||||
<th scope="row">Commit Rate</th>
|
</tr>
|
||||||
<td>{{ object.commit_rate|humanize_speed|placeholder }}</td>
|
<tr>
|
||||||
</tr>
|
<th scope="row">Description</th>
|
||||||
<tr>
|
<td>{{ object.description|placeholder }}</td>
|
||||||
<th scope="row">Description</th>
|
</tr>
|
||||||
<td>{{ object.description|placeholder }}</td>
|
</table>
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
{% include 'inc/panels/custom_fields.html' %}
|
</div>
|
||||||
{% include 'inc/panels/tags.html' %}
|
{% include 'inc/panels/custom_fields.html' %}
|
||||||
{% include 'inc/panels/comments.html' %}
|
{% include 'inc/panels/tags.html' %}
|
||||||
{% plugin_left_page object %}
|
{% plugin_left_page object %}
|
||||||
</div>
|
|
||||||
<div class="col col-md-6">
|
|
||||||
{% include 'circuits/inc/circuit_termination.html' with termination=object.termination_a side='A' %}
|
|
||||||
{% include 'circuits/inc/circuit_termination.html' with termination=object.termination_z side='Z' %}
|
|
||||||
{% include 'inc/panels/contacts.html' %}
|
|
||||||
{% include 'inc/panels/image_attachments.html' %}
|
|
||||||
{% plugin_right_page object %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="row">
|
|
||||||
<div class="col col-md-12">
|
|
||||||
{% plugin_full_width_page object %}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div class="col col-md-6">
|
||||||
|
{% include 'inc/panels/comments.html' %}
|
||||||
|
{% include 'inc/panels/contacts.html' %}
|
||||||
|
{% include 'inc/panels/image_attachments.html' %}
|
||||||
|
{% plugin_right_page object %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col col-md-6">
|
||||||
|
{% include 'circuits/inc/circuit_termination.html' with termination=object.termination_a side='A' %}
|
||||||
|
</div>
|
||||||
|
<div class="col col-md-6">
|
||||||
|
{% include 'circuits/inc/circuit_termination.html' with termination=object.termination_z side='Z' %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col col-md-12">
|
||||||
|
{% plugin_full_width_page object %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
@ -10,6 +10,7 @@
|
|||||||
{% render_field form.provider %}
|
{% render_field form.provider %}
|
||||||
{% render_field form.circuit %}
|
{% render_field form.circuit %}
|
||||||
{% render_field form.term_side %}
|
{% render_field form.term_side %}
|
||||||
|
{% render_field form.tags %}
|
||||||
{% render_field form.mark_connected %}
|
{% render_field form.mark_connected %}
|
||||||
{% with providernetwork_tab_active=form.initial.provider_network %}
|
{% with providernetwork_tab_active=form.initial.provider_network %}
|
||||||
<div class="row mb-2">
|
<div class="row mb-2">
|
||||||
@ -47,6 +48,13 @@
|
|||||||
{% render_field form.pp_info %}
|
{% render_field form.pp_info %}
|
||||||
{% render_field form.description %}
|
{% render_field form.description %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="field-group my-5">
|
||||||
|
<div class="row mb-2">
|
||||||
|
<h5 class="offset-sm-3">Custom Fields</h5>
|
||||||
|
</div>
|
||||||
|
{% render_custom_fields form %}
|
||||||
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{# Override buttons block, 'Create & Add Another'/'_addanother' is not needed on a circuit. #}
|
{# Override buttons block, 'Create & Add Another'/'_addanother' is not needed on a circuit. #}
|
||||||
|
@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<strong class="d-block d-md-inline mb-3 mb-md-0">Termination - {{ side }} Side</strong>
|
|
||||||
<div class="float-md-end">
|
<div class="float-md-end">
|
||||||
{% if not termination and perms.circuits.add_circuittermination %}
|
{% if not termination and perms.circuits.add_circuittermination %}
|
||||||
<a href="{% url 'circuits:circuittermination_add' %}?circuit={{ object.pk }}&term_side={{ side }}&return_url={{ object.get_absolute_url }}" class="btn btn-sm btn-success lh-1">
|
<a href="{% url 'circuits:circuittermination_add' %}?circuit={{ object.pk }}&term_side={{ side }}&return_url={{ object.get_absolute_url }}" class="btn btn-sm btn-success lh-1">
|
||||||
@ -10,10 +9,10 @@
|
|||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if termination and perms.circuits.change_circuittermination %}
|
{% if termination and perms.circuits.change_circuittermination %}
|
||||||
<a href="{% url 'circuits:circuittermination_edit' pk=termination.pk %}" class="btn btn-sm btn-warning lh-1">
|
<a href="{% url 'circuits:circuittermination_edit' pk=termination.pk %}?return_url={{ object.get_absolute_url }}" class="btn btn-sm btn-warning lh-1">
|
||||||
<span class="mdi mdi-pencil" aria-hidden="true"></span> Edit
|
<span class="mdi mdi-pencil" aria-hidden="true"></span> Edit
|
||||||
</a>
|
</a>
|
||||||
<a href="{% url 'circuits:circuit_terminations_swap' pk=object.pk %}" class="btn btn-sm btn-primary lh-1">
|
<a href="{% url 'circuits:circuit_terminations_swap' pk=object.pk %}?return_url={{ object.get_absolute_url }}" class="btn btn-sm btn-primary lh-1">
|
||||||
<span class="mdi mdi-swap-vertical" aria-hidden="true"></span> Swap
|
<span class="mdi mdi-swap-vertical" aria-hidden="true"></span> Swap
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@ -23,6 +22,7 @@
|
|||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
<h5>Termination {{ side }}</h5>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
{% if termination %}
|
{% if termination %}
|
||||||
@ -109,6 +109,33 @@
|
|||||||
<td>Description</td>
|
<td>Description</td>
|
||||||
<td>{{ termination.description|placeholder }}</td>
|
<td>{{ termination.description|placeholder }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Tags</td>
|
||||||
|
<td>
|
||||||
|
{% for tag in termination.tags.all %}
|
||||||
|
{% tag tag %}
|
||||||
|
{% empty %}
|
||||||
|
{{ ''|placeholder }}
|
||||||
|
{% endfor %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% for group_name, fields in termination.get_custom_fields_by_group.items %}
|
||||||
|
<tr>
|
||||||
|
<td colspan="2">
|
||||||
|
<strong>{{ group_name|default:"Custom Fields" }}</strong>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% for field, value in fields.items %}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<span title="{{ field.description|escape }}">{{ field }}</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{% customfield_value field value %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
{% endfor %}
|
||||||
</table>
|
</table>
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="text-muted">None</span>
|
<span class="text-muted">None</span>
|
||||||
|
@ -17,7 +17,7 @@
|
|||||||
|
|
||||||
<div class="noprint bulk-buttons">
|
<div class="noprint bulk-buttons">
|
||||||
<div class="bulk-button-group">
|
<div class="bulk-button-group">
|
||||||
{% if perms.dcim.change_consoleport %}
|
{% if 'bulk_edit' in actions %}
|
||||||
<button type="submit" name="_rename" formaction="{% url 'dcim:consoleport_bulk_rename' %}?return_url={% url 'dcim:device_consoleports' pk=object.pk %}" class="btn btn-outline-warning btn-sm">
|
<button type="submit" name="_rename" formaction="{% url 'dcim:consoleport_bulk_rename' %}?return_url={% url 'dcim:device_consoleports' pk=object.pk %}" class="btn btn-outline-warning btn-sm">
|
||||||
<i class="mdi mdi-pencil-outline" aria-hidden="true"></i> Rename
|
<i class="mdi mdi-pencil-outline" aria-hidden="true"></i> Rename
|
||||||
</button>
|
</button>
|
||||||
@ -28,7 +28,7 @@
|
|||||||
<span class="mdi mdi-ethernet-cable-off" aria-hidden="true"></span> Disconnect
|
<span class="mdi mdi-ethernet-cable-off" aria-hidden="true"></span> Disconnect
|
||||||
</button>
|
</button>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if perms.dcim.delete_consoleport %}
|
{% if 'bulk_delete' in actions %}
|
||||||
<button type="submit" name="_delete" formaction="{% url 'dcim:consoleport_bulk_delete' %}?return_url={% url 'dcim:device_consoleports' pk=object.pk %}" class="btn btn-danger btn-sm">
|
<button type="submit" name="_delete" formaction="{% url 'dcim:consoleport_bulk_delete' %}?return_url={% url 'dcim:device_consoleports' pk=object.pk %}" class="btn btn-danger btn-sm">
|
||||||
<i class="mdi mdi-trash-can-outline" aria-hidden="true"></i> Delete
|
<i class="mdi mdi-trash-can-outline" aria-hidden="true"></i> Delete
|
||||||
</button>
|
</button>
|
||||||
|
@ -17,7 +17,7 @@
|
|||||||
|
|
||||||
<div class="noprint bulk-buttons">
|
<div class="noprint bulk-buttons">
|
||||||
<div class="bulk-button-group">
|
<div class="bulk-button-group">
|
||||||
{% if perms.dcim.change_consoleserverport %}
|
{% if 'bulk_edit' in actions %}
|
||||||
<button type="submit" name="_rename" formaction="{% url 'dcim:consoleserverport_bulk_rename' %}?return_url={% url 'dcim:device_consoleserverports' pk=object.pk %}" class="btn btn-outline-warning btn-sm">
|
<button type="submit" name="_rename" formaction="{% url 'dcim:consoleserverport_bulk_rename' %}?return_url={% url 'dcim:device_consoleserverports' pk=object.pk %}" class="btn btn-outline-warning btn-sm">
|
||||||
<i class="mdi mdi-pencil-outline" aria-hidden="true"></i> Rename
|
<i class="mdi mdi-pencil-outline" aria-hidden="true"></i> Rename
|
||||||
</button>
|
</button>
|
||||||
@ -28,7 +28,7 @@
|
|||||||
<span class="mdi mdi-ethernet-cable-off" aria-hidden="true"></span> Disconnect
|
<span class="mdi mdi-ethernet-cable-off" aria-hidden="true"></span> Disconnect
|
||||||
</button>
|
</button>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if perms.dcim.delete_consoleserverport %}
|
{% if 'bulk_delete' in actions %}
|
||||||
<button type="submit" formaction="{% url 'dcim:consoleserverport_bulk_delete' %}?return_url={% url 'dcim:device_consoleserverports' pk=object.pk %}" class="btn btn-danger btn-sm">
|
<button type="submit" formaction="{% url 'dcim:consoleserverport_bulk_delete' %}?return_url={% url 'dcim:device_consoleserverports' pk=object.pk %}" class="btn btn-danger btn-sm">
|
||||||
<i class="mdi mdi-trash-can-outline" aria-hidden="true"></i> Delete
|
<i class="mdi mdi-trash-can-outline" aria-hidden="true"></i> Delete
|
||||||
</button>
|
</button>
|
||||||
|
@ -17,7 +17,7 @@
|
|||||||
|
|
||||||
<div class="noprint bulk-buttons">
|
<div class="noprint bulk-buttons">
|
||||||
<div class="bulk-button-group">
|
<div class="bulk-button-group">
|
||||||
{% if perms.dcim.change_devicebay %}
|
{% if 'bulk_edit' in actions %}
|
||||||
<button type="submit" name="_rename" formaction="{% url 'dcim:devicebay_bulk_rename' %}?return_url={% url 'dcim:device_devicebays' pk=object.pk %}" class="btn btn-outline-warning btn-sm">
|
<button type="submit" name="_rename" formaction="{% url 'dcim:devicebay_bulk_rename' %}?return_url={% url 'dcim:device_devicebays' pk=object.pk %}" class="btn btn-outline-warning btn-sm">
|
||||||
<i class="mdi mdi-pencil-outline" aria-hidden="true"></i> Rename
|
<i class="mdi mdi-pencil-outline" aria-hidden="true"></i> Rename
|
||||||
</button>
|
</button>
|
||||||
@ -25,7 +25,7 @@
|
|||||||
<i class="mdi mdi-pencil" aria-hidden="true"></i> Edit
|
<i class="mdi mdi-pencil" aria-hidden="true"></i> Edit
|
||||||
</button>
|
</button>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if perms.dcim.delete_devicebay %}
|
{% if 'bulk_delete' in actions %}
|
||||||
<button type="submit" formaction="{% url 'dcim:devicebay_bulk_delete' %}?return_url={% url 'dcim:device_devicebays' pk=object.pk %}" class="btn btn-outline-danger btn-sm">
|
<button type="submit" formaction="{% url 'dcim:devicebay_bulk_delete' %}?return_url={% url 'dcim:device_devicebays' pk=object.pk %}" class="btn btn-outline-danger btn-sm">
|
||||||
<i class="mdi mdi-trash-can-outline" aria-hidden="true"></i> Delete selected
|
<i class="mdi mdi-trash-can-outline" aria-hidden="true"></i> Delete selected
|
||||||
</button>
|
</button>
|
||||||
|
@ -17,7 +17,7 @@
|
|||||||
|
|
||||||
<div class="noprint bulk-buttons">
|
<div class="noprint bulk-buttons">
|
||||||
<div class="bulk-button-group">
|
<div class="bulk-button-group">
|
||||||
{% if perms.dcim.change_frontport %}
|
{% if 'bulk_edit' in actions %}
|
||||||
<button type="submit" name="_rename" formaction="{% url 'dcim:frontport_bulk_rename' %}?return_url={% url 'dcim:device_frontports' pk=object.pk %}" class="btn btn-outline-warning btn-sm">
|
<button type="submit" name="_rename" formaction="{% url 'dcim:frontport_bulk_rename' %}?return_url={% url 'dcim:device_frontports' pk=object.pk %}" class="btn btn-outline-warning btn-sm">
|
||||||
<i class="mdi mdi-pencil-outline" aria-hidden="true"></i> Rename
|
<i class="mdi mdi-pencil-outline" aria-hidden="true"></i> Rename
|
||||||
</button>
|
</button>
|
||||||
@ -28,7 +28,7 @@
|
|||||||
<span class="mdi mdi-ethernet-cable-off" aria-hidden="true"></span> Disconnect
|
<span class="mdi mdi-ethernet-cable-off" aria-hidden="true"></span> Disconnect
|
||||||
</button>
|
</button>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if perms.dcim.delete_frontport %}
|
{% if 'bulk_delete' in actions %}
|
||||||
<button type="submit" formaction="{% url 'dcim:frontport_bulk_delete' %}?return_url={% url 'dcim:device_frontports' pk=object.pk %}" class="btn btn-danger btn-sm">
|
<button type="submit" formaction="{% url 'dcim:frontport_bulk_delete' %}?return_url={% url 'dcim:device_frontports' pk=object.pk %}" class="btn btn-danger btn-sm">
|
||||||
<i class="mdi mdi-trash-can-outline" aria-hidden="true"></i> Delete
|
<i class="mdi mdi-trash-can-outline" aria-hidden="true"></i> Delete
|
||||||
</button>
|
</button>
|
||||||
|
@ -53,7 +53,7 @@
|
|||||||
|
|
||||||
<div class="noprint bulk-buttons">
|
<div class="noprint bulk-buttons">
|
||||||
<div class="bulk-button-group">
|
<div class="bulk-button-group">
|
||||||
{% if perms.dcim.change_interface %}
|
{% if 'bulk_edit' in actions %}
|
||||||
<button type="submit" name="_rename" formaction="{% url 'dcim:interface_bulk_rename' %}?return_url={% url 'dcim:device_interfaces' pk=object.pk %}" class="btn btn-outline-warning btn-sm">
|
<button type="submit" name="_rename" formaction="{% url 'dcim:interface_bulk_rename' %}?return_url={% url 'dcim:device_interfaces' pk=object.pk %}" class="btn btn-outline-warning btn-sm">
|
||||||
<i class="mdi mdi-pencil-outline" aria-hidden="true"></i> Rename
|
<i class="mdi mdi-pencil-outline" aria-hidden="true"></i> Rename
|
||||||
</button>
|
</button>
|
||||||
@ -64,7 +64,7 @@
|
|||||||
<span class="mdi mdi-ethernet-cable-off" aria-hidden="true"></span> Disconnect
|
<span class="mdi mdi-ethernet-cable-off" aria-hidden="true"></span> Disconnect
|
||||||
</button>
|
</button>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if perms.dcim.delete_interface %}
|
{% if 'bulk_delete' in actions %}
|
||||||
<button type="submit" name="_delete" formaction="{% url 'dcim:interface_bulk_delete' %}?return_url={% url 'dcim:device_interfaces' pk=object.pk %}" class="btn btn-danger btn-sm">
|
<button type="submit" name="_delete" formaction="{% url 'dcim:interface_bulk_delete' %}?return_url={% url 'dcim:device_interfaces' pk=object.pk %}" class="btn btn-danger btn-sm">
|
||||||
<i class="mdi mdi-trash-can-outline" aria-hidden="true"></i> Delete
|
<i class="mdi mdi-trash-can-outline" aria-hidden="true"></i> Delete
|
||||||
</button>
|
</button>
|
||||||
|
@ -17,7 +17,7 @@
|
|||||||
|
|
||||||
<div class="noprint bulk-buttons">
|
<div class="noprint bulk-buttons">
|
||||||
<div class="bulk-button-group">
|
<div class="bulk-button-group">
|
||||||
{% if perms.dcim.change_inventoryitem %}
|
{% if 'bulk_edit' in actions %}
|
||||||
<button type="submit" name="_rename" formaction="{% url 'dcim:inventoryitem_bulk_rename' %}?return_url={% url 'dcim:device_inventory' pk=object.pk %}" class="btn btn-warning btn-sm">
|
<button type="submit" name="_rename" formaction="{% url 'dcim:inventoryitem_bulk_rename' %}?return_url={% url 'dcim:device_inventory' pk=object.pk %}" class="btn btn-warning btn-sm">
|
||||||
<i class="mdi mdi-pencil-outline" aria-hidden="true"></i> Rename
|
<i class="mdi mdi-pencil-outline" aria-hidden="true"></i> Rename
|
||||||
</button>
|
</button>
|
||||||
@ -25,7 +25,7 @@
|
|||||||
<i class="mdi mdi-pencil" aria-hidden="true"></i> Edit
|
<i class="mdi mdi-pencil" aria-hidden="true"></i> Edit
|
||||||
</button>
|
</button>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if perms.dcim.delete_inventoryitem %}
|
{% if 'bulk_delete' in actions %}
|
||||||
<button type="submit" name="_delete" formaction="{% url 'dcim:inventoryitem_bulk_delete' %}?return_url={% url 'dcim:device_inventory' pk=object.pk %}" class="btn btn-danger btn-sm">
|
<button type="submit" name="_delete" formaction="{% url 'dcim:inventoryitem_bulk_delete' %}?return_url={% url 'dcim:device_inventory' pk=object.pk %}" class="btn btn-danger btn-sm">
|
||||||
<i class="mdi mdi-trash-can-outline" aria-hidden="true"></i> Delete
|
<i class="mdi mdi-trash-can-outline" aria-hidden="true"></i> Delete
|
||||||
</button>
|
</button>
|
||||||
|
@ -17,7 +17,7 @@
|
|||||||
|
|
||||||
<div class="noprint bulk-buttons">
|
<div class="noprint bulk-buttons">
|
||||||
<div class="bulk-button-group">
|
<div class="bulk-button-group">
|
||||||
{% if perms.dcim.change_modulebay %}
|
{% if 'bulk_edit' in actions %}
|
||||||
<button type="submit" name="_rename" formaction="{% url 'dcim:modulebay_bulk_rename' %}?return_url={% url 'dcim:device_modulebays' pk=object.pk %}" class="btn btn-outline-warning btn-sm">
|
<button type="submit" name="_rename" formaction="{% url 'dcim:modulebay_bulk_rename' %}?return_url={% url 'dcim:device_modulebays' pk=object.pk %}" class="btn btn-outline-warning btn-sm">
|
||||||
<i class="mdi mdi-pencil-outline" aria-hidden="true"></i> Rename
|
<i class="mdi mdi-pencil-outline" aria-hidden="true"></i> Rename
|
||||||
</button>
|
</button>
|
||||||
@ -25,7 +25,7 @@
|
|||||||
<i class="mdi mdi-pencil" aria-hidden="true"></i> Edit
|
<i class="mdi mdi-pencil" aria-hidden="true"></i> Edit
|
||||||
</button>
|
</button>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if perms.dcim.delete_modulebay %}
|
{% if 'bulk_delete' in actions %}
|
||||||
<button type="submit" formaction="{% url 'dcim:modulebay_bulk_delete' %}?return_url={% url 'dcim:device_modulebays' pk=object.pk %}" class="btn btn-outline-danger btn-sm">
|
<button type="submit" formaction="{% url 'dcim:modulebay_bulk_delete' %}?return_url={% url 'dcim:device_modulebays' pk=object.pk %}" class="btn btn-outline-danger btn-sm">
|
||||||
<i class="mdi mdi-trash-can-outline" aria-hidden="true"></i> Delete selected
|
<i class="mdi mdi-trash-can-outline" aria-hidden="true"></i> Delete selected
|
||||||
</button>
|
</button>
|
||||||
|
@ -17,7 +17,7 @@
|
|||||||
|
|
||||||
<div class="noprint bulk-buttons">
|
<div class="noprint bulk-buttons">
|
||||||
<div class="bulk-button-group">
|
<div class="bulk-button-group">
|
||||||
{% if perms.dcim.change_powerport %}
|
{% if 'bulk_edit' in actions %}
|
||||||
<button type="submit" name="_rename" formaction="{% url 'dcim:poweroutlet_bulk_rename' %}?return_url={% url 'dcim:device_poweroutlets' pk=object.pk %}" class="btn btn-outline-warning btn-sm">
|
<button type="submit" name="_rename" formaction="{% url 'dcim:poweroutlet_bulk_rename' %}?return_url={% url 'dcim:device_poweroutlets' pk=object.pk %}" class="btn btn-outline-warning btn-sm">
|
||||||
<i class="mdi mdi-pencil-outline" aria-hidden="true"></i> Rename
|
<i class="mdi mdi-pencil-outline" aria-hidden="true"></i> Rename
|
||||||
</button>
|
</button>
|
||||||
@ -28,7 +28,7 @@
|
|||||||
<span class="mdi mdi-ethernet-cable-off" aria-hidden="true"></span> Disconnect
|
<span class="mdi mdi-ethernet-cable-off" aria-hidden="true"></span> Disconnect
|
||||||
</button>
|
</button>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if perms.dcim.delete_poweroutlet %}
|
{% if 'bulk_delete' in actions %}
|
||||||
<button type="submit" formaction="{% url 'dcim:poweroutlet_bulk_delete' %}?return_url={% url 'dcim:device_poweroutlets' pk=object.pk %}" class="btn btn-danger btn-sm">
|
<button type="submit" formaction="{% url 'dcim:poweroutlet_bulk_delete' %}?return_url={% url 'dcim:device_poweroutlets' pk=object.pk %}" class="btn btn-danger btn-sm">
|
||||||
<i class="mdi mdi-trash-can-outline" aria-hidden="true"></i> Delete
|
<i class="mdi mdi-trash-can-outline" aria-hidden="true"></i> Delete
|
||||||
</button>
|
</button>
|
||||||
|
@ -17,7 +17,7 @@
|
|||||||
|
|
||||||
<div class="noprint bulk-buttons">
|
<div class="noprint bulk-buttons">
|
||||||
<div class="bulk-button-group">
|
<div class="bulk-button-group">
|
||||||
{% if perms.dcim.change_powerport %}
|
{% if 'bulk_edit' in actions %}
|
||||||
<button type="submit" name="_rename" formaction="{% url 'dcim:powerport_bulk_rename' %}?return_url={% url 'dcim:device_powerports' pk=object.pk %}" class="btn btn-outline-warning btn-sm">
|
<button type="submit" name="_rename" formaction="{% url 'dcim:powerport_bulk_rename' %}?return_url={% url 'dcim:device_powerports' pk=object.pk %}" class="btn btn-outline-warning btn-sm">
|
||||||
<i class="mdi mdi-pencil-outline" aria-hidden="true"></i> Rename
|
<i class="mdi mdi-pencil-outline" aria-hidden="true"></i> Rename
|
||||||
</button>
|
</button>
|
||||||
@ -28,7 +28,7 @@
|
|||||||
<span class="mdi mdi-ethernet-cable-off" aria-hidden="true"></span> Disconnect
|
<span class="mdi mdi-ethernet-cable-off" aria-hidden="true"></span> Disconnect
|
||||||
</button>
|
</button>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if perms.dcim.delete_powerport %}
|
{% if 'bulk_delete' in actions %}
|
||||||
<button type="submit" name="_delete" formaction="{% url 'dcim:powerport_bulk_delete' %}?return_url={% url 'dcim:device_powerports' pk=object.pk %}" class="btn btn-danger btn-sm">
|
<button type="submit" name="_delete" formaction="{% url 'dcim:powerport_bulk_delete' %}?return_url={% url 'dcim:device_powerports' pk=object.pk %}" class="btn btn-danger btn-sm">
|
||||||
<i class="mdi mdi-trash-can-outline" aria-hidden="true"></i> Delete
|
<i class="mdi mdi-trash-can-outline" aria-hidden="true"></i> Delete
|
||||||
</button>
|
</button>
|
||||||
|
@ -17,7 +17,7 @@
|
|||||||
|
|
||||||
<div class="noprint bulk-buttons">
|
<div class="noprint bulk-buttons">
|
||||||
<div class="bulk-button-group">
|
<div class="bulk-button-group">
|
||||||
{% if perms.dcim.change_rearport %}
|
{% if 'bulk_edit' in actions %}
|
||||||
<button type="submit" name="_rename" formaction="{% url 'dcim:rearport_bulk_rename' %}?return_url={% url 'dcim:device_rearports' pk=object.pk %}" class="btn btn-outline-warning btn-sm">
|
<button type="submit" name="_rename" formaction="{% url 'dcim:rearport_bulk_rename' %}?return_url={% url 'dcim:device_rearports' pk=object.pk %}" class="btn btn-outline-warning btn-sm">
|
||||||
<i class="mdi mdi-pencil-outline" aria-hidden="true"></i> Rename
|
<i class="mdi mdi-pencil-outline" aria-hidden="true"></i> Rename
|
||||||
</button>
|
</button>
|
||||||
@ -28,7 +28,7 @@
|
|||||||
<span class="mdi mdi-ethernet-cable-off" aria-hidden="true"></span> Disconnect
|
<span class="mdi mdi-ethernet-cable-off" aria-hidden="true"></span> Disconnect
|
||||||
</button>
|
</button>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if perms.dcim.delete_rearport %}
|
{% if 'bulk_delete' in actions %}
|
||||||
<button type="submit" formaction="{% url 'dcim:rearport_bulk_delete' %}?return_url={% url 'dcim:device_rearports' pk=object.pk %}" class="btn btn-danger btn-sm">
|
<button type="submit" formaction="{% url 'dcim:rearport_bulk_delete' %}?return_url={% url 'dcim:device_rearports' pk=object.pk %}" class="btn btn-danger btn-sm">
|
||||||
<i class="mdi mdi-trash-can-outline" aria-hidden="true"></i> Delete
|
<i class="mdi mdi-trash-can-outline" aria-hidden="true"></i> Delete
|
||||||
</button>
|
</button>
|
||||||
|
@ -86,6 +86,15 @@
|
|||||||
{% render_field form.tenant %}
|
{% render_field form.tenant %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="field-group my-5">
|
||||||
|
<div class="row mb-2">
|
||||||
|
<h5 class="offset-sm-3">Virtual Chassis</h5>
|
||||||
|
</div>
|
||||||
|
{% render_field form.virtual_chassis %}
|
||||||
|
{% render_field form.vc_position %}
|
||||||
|
{% render_field form.vc_priority %}
|
||||||
|
</div>
|
||||||
|
|
||||||
{% if form.custom_fields %}
|
{% if form.custom_fields %}
|
||||||
<div class="field-group my-5">
|
<div class="field-group my-5">
|
||||||
<div class="row mb-2">
|
<div class="row mb-2">
|
||||||
|
@ -104,6 +104,10 @@
|
|||||||
<th scope="row">LAG</th>
|
<th scope="row">LAG</th>
|
||||||
<td>{{ object.lag|linkify|placeholder }}</td>
|
<td>{{ object.lag|linkify|placeholder }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th scope="row">L2VPN</th>
|
||||||
|
<td>{{ object.l2vpn_termination.l2vpn|linkify|placeholder }}</td>
|
||||||
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -72,6 +72,14 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="field-group my-5">
|
||||||
|
<div class="row mb-2">
|
||||||
|
<h5 class="offset-sm-3">Power over Ethernet (PoE)</h5>
|
||||||
|
</div>
|
||||||
|
{% render_field form.poe_mode %}
|
||||||
|
{% render_field form.poe_type %}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="field-group my-5">
|
<div class="field-group my-5">
|
||||||
<div class="row mb-2">
|
<div class="row mb-2">
|
||||||
<h5 class="offset-sm-3">802.1Q Switching</h5>
|
<h5 class="offset-sm-3">802.1Q Switching</h5>
|
||||||
|
@ -16,33 +16,7 @@
|
|||||||
<span title="{{ field.description|escape }}">{{ field }}</span>
|
<span title="{{ field.description|escape }}">{{ field }}</span>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{% if field.type == 'integer' and value is not None %}
|
{% customfield_value field value %}
|
||||||
{{ value }}
|
|
||||||
{% elif field.type == 'longtext' and value %}
|
|
||||||
{{ value|markdown }}
|
|
||||||
{% elif field.type == 'boolean' and value == True %}
|
|
||||||
{% checkmark value true="True" %}
|
|
||||||
{% elif field.type == 'boolean' and value == False %}
|
|
||||||
{% checkmark value false="False" %}
|
|
||||||
{% elif field.type == 'url' and value %}
|
|
||||||
<a href="{{ value }}">{{ value|truncatechars:70 }}</a>
|
|
||||||
{% elif field.type == 'json' and value %}
|
|
||||||
<pre>{{ value|json }}</pre>
|
|
||||||
{% elif field.type == 'multiselect' and value %}
|
|
||||||
{{ value|join:", " }}
|
|
||||||
{% elif field.type == 'object' and value %}
|
|
||||||
{{ value|linkify }}
|
|
||||||
{% elif field.type == 'multiobject' and value %}
|
|
||||||
{% for obj in value %}
|
|
||||||
{{ obj|linkify }}{% if not forloop.last %}<br />{% endif %}
|
|
||||||
{% endfor %}
|
|
||||||
{% elif value %}
|
|
||||||
{{ value }}
|
|
||||||
{% elif field.required %}
|
|
||||||
<span class="text-warning"><i class="mdi mdi-alert"></i> Not defined</span>
|
|
||||||
{% else %}
|
|
||||||
{{ ''|placeholder }}
|
|
||||||
{% endif %}
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
@ -25,12 +25,12 @@
|
|||||||
|
|
||||||
<div class="noprint bulk-buttons">
|
<div class="noprint bulk-buttons">
|
||||||
<div class="bulk-button-group">
|
<div class="bulk-button-group">
|
||||||
{% if perms.ipam.change_prefix %}
|
{% if 'bulk_edit' in actions %}
|
||||||
<button type="submit" name="_edit" formaction="{% url 'ipam:prefix_bulk_edit' %}?return_url={% url 'ipam:aggregate_prefixes' pk=object.pk %}" class="btn btn-warning btn-sm">
|
<button type="submit" name="_edit" formaction="{% url 'ipam:prefix_bulk_edit' %}?return_url={% url 'ipam:aggregate_prefixes' pk=object.pk %}" class="btn btn-warning btn-sm">
|
||||||
<i class="mdi mdi-pencil" aria-hidden="true"></i> Edit
|
<i class="mdi mdi-pencil" aria-hidden="true"></i> Edit
|
||||||
</button>
|
</button>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if perms.ipam.delete_prefix %}
|
{% if 'bulk_delete' in actions %}
|
||||||
<button type="submit" name="_delete" formaction="{% url 'ipam:prefix_bulk_delete' %}?return_url={% url 'ipam:aggregate_prefixes' pk=object.pk %}" class="btn btn-danger btn-sm">
|
<button type="submit" name="_delete" formaction="{% url 'ipam:prefix_bulk_delete' %}?return_url={% url 'ipam:aggregate_prefixes' pk=object.pk %}" class="btn btn-danger btn-sm">
|
||||||
<i class="mdi mdi-trash-can-outline" aria-hidden="true"></i> Delete
|
<i class="mdi mdi-trash-can-outline" aria-hidden="true"></i> Delete
|
||||||
</button>
|
</button>
|
||||||
|
@ -23,12 +23,12 @@
|
|||||||
|
|
||||||
<div class="noprint bulk-buttons">
|
<div class="noprint bulk-buttons">
|
||||||
<div class="bulk-button-group">
|
<div class="bulk-button-group">
|
||||||
{% if perms.ipam.change_ipaddress %}
|
{% if 'bulk_edit' in actions %}
|
||||||
<button type="submit" name="_edit" formaction="{% url 'ipam:ipaddress_bulk_edit' %}?return_url={% url 'ipam:iprange_ipaddresses' pk=object.pk %}" class="btn btn-warning btn-sm">
|
<button type="submit" name="_edit" formaction="{% url 'ipam:ipaddress_bulk_edit' %}?return_url={% url 'ipam:iprange_ipaddresses' pk=object.pk %}" class="btn btn-warning btn-sm">
|
||||||
<i class="mdi mdi-pencil" aria-hidden="true"></i> Edit
|
<i class="mdi mdi-pencil" aria-hidden="true"></i> Edit
|
||||||
</button>
|
</button>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if perms.ipam.delete_ipaddress %}
|
{% if 'bulk_delete' in actions %}
|
||||||
<button type="submit" name="_delete" formaction="{% url 'ipam:ipaddress_bulk_delete' %}?return_url={% url 'ipam:iprange_ipaddresses' pk=object.pk %}" class="btn btn-danger btn-sm">
|
<button type="submit" name="_delete" formaction="{% url 'ipam:ipaddress_bulk_delete' %}?return_url={% url 'ipam:iprange_ipaddresses' pk=object.pk %}" class="btn btn-danger btn-sm">
|
||||||
<i class="mdi mdi-trash-can-outline" aria-hidden="true"></i> Delete
|
<i class="mdi mdi-trash-can-outline" aria-hidden="true"></i> Delete
|
||||||
</button>
|
</button>
|
||||||
|
81
netbox/templates/ipam/l2vpn.html
Normal file
81
netbox/templates/ipam/l2vpn.html
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
{% extends 'generic/object.html' %}
|
||||||
|
{% load helpers %}
|
||||||
|
{% load plugins %}
|
||||||
|
{% load render_table from django_tables2 %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col col-md-6">
|
||||||
|
<div class="card">
|
||||||
|
<h5 class="card-header">
|
||||||
|
L2VPN Attributes
|
||||||
|
</h5>
|
||||||
|
<div class="card-body">
|
||||||
|
<table class="table table-hover attr-table
|
||||||
|
<tr>
|
||||||
|
<th scope="row">Name</th>
|
||||||
|
<td>{{ object.name|placeholder }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th scope="row">Slug</th>
|
||||||
|
<td>{{ object.slug|placeholder }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th scope="row">Identifier</th>
|
||||||
|
<td>{{ object.identifier|placeholder }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th scope="row">Type</th>
|
||||||
|
<td>{{ object.get_type_display }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th scope="row">Description</th>
|
||||||
|
<td>{{ object.description|placeholder }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th scope="row">Tenant</th>
|
||||||
|
<td>{{ object.tenant|placeholder }}</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% include 'inc/panels/contacts.html' %}
|
||||||
|
{% plugin_left_page object %}
|
||||||
|
</div>
|
||||||
|
<div class="col col-md-6">
|
||||||
|
{% include 'inc/panels/tags.html' with tags=object.tags.all url='circuits:circuit_list' %}
|
||||||
|
{% include 'inc/panels/custom_fields.html' %}
|
||||||
|
{% plugin_right_page object %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col col-md-6">
|
||||||
|
{% include 'inc/panel_table.html' with table=import_targets_table heading="Import Route Targets" %}
|
||||||
|
</div>
|
||||||
|
<div class="col col-md-6">
|
||||||
|
{% include 'inc/panel_table.html' with table=export_targets_table heading="Export Route Targets" %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col col-md-12">
|
||||||
|
<div class="card">
|
||||||
|
<h5 class="card-header">Terminations</h5>
|
||||||
|
<div class="card-body">
|
||||||
|
{% render_table terminations_table 'inc/table.html' %}
|
||||||
|
</div>
|
||||||
|
{% if perms.ipam.add_l2vpntermination %}
|
||||||
|
<div class="card-footer text-end noprint">
|
||||||
|
<a href="{% url 'ipam:l2vpntermination_add' %}?l2vpn={{ object.pk }}&return_url={{ object.get_absolute_url }}" class="btn btn-primary btn-sm">
|
||||||
|
<i class="mdi mdi-plus-thick" aria-hidden="true"></i> Add a Termination
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col col-md-12">
|
||||||
|
{% plugin_full_width_page object %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
31
netbox/templates/ipam/l2vpntermination.html
Normal file
31
netbox/templates/ipam/l2vpntermination.html
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
{% extends 'generic/object.html' %}
|
||||||
|
{% load helpers %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="row">
|
||||||
|
<div class="col col-md-6">
|
||||||
|
<div class="card">
|
||||||
|
<h5 class="card-header">
|
||||||
|
L2VPN Attributes
|
||||||
|
</h5>
|
||||||
|
<div class="card-body">
|
||||||
|
<table class="table table-hover">
|
||||||
|
<tr>
|
||||||
|
<th scope="row">L2VPN</th>
|
||||||
|
<td>{{ object.l2vpn|linkify }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th scope="row">Assigned Object</th>
|
||||||
|
<td>{{ object.assigned_object|linkify }}</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col col-md-6">
|
||||||
|
{% include 'inc/panels/custom_fields.html' %}
|
||||||
|
{% include 'inc/panels/tags.html' with tags=object.tags.all url='ipam:l2vpntermination_list' %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% endblock %}
|
49
netbox/templates/ipam/l2vpntermination_edit.html
Normal file
49
netbox/templates/ipam/l2vpntermination_edit.html
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
{% extends 'generic/object_edit.html' %}
|
||||||
|
{% load helpers %}
|
||||||
|
{% load form_helpers %}
|
||||||
|
|
||||||
|
{% block form %}
|
||||||
|
<div class="field-group my-5">
|
||||||
|
<div class="row mb-2">
|
||||||
|
<h5 class="offset-sm-3">L2VPN Termination</h5>
|
||||||
|
</div>
|
||||||
|
{% render_field form.l2vpn %}
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="offset-sm-3">
|
||||||
|
<ul class="nav nav-pills" role="tablist">
|
||||||
|
<li role="presentation" class="nav-item">
|
||||||
|
<button role="tab" type="button" id="vlan_tab" data-bs-toggle="tab" aria-controls="vlan" data-bs-target="#vlan" class="nav-link {% if not form.initial.interface or form.initial.vminterface %}active{% endif %}">
|
||||||
|
VLAN
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
<li role="presentation" class="nav-item">
|
||||||
|
<button role="tab" type="button" id="interface_tab" data-bs-toggle="tab" aria-controls="interface" data-bs-target="#interface" class="nav-link {% if form.initial.interface %}active{% endif %}">
|
||||||
|
Interface
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
<li role="presentation" class="nav-item">
|
||||||
|
<button role="tab" type="button" id="vminterface_tab" data-bs-toggle="tab" aria-controls="vminterface" data-bs-target="#vminterface" class="nav-link {% if form.initial.vminterface %}active{% endif %}">
|
||||||
|
VM Interface
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="tab-content p-0 border-0">
|
||||||
|
<div class="tab-pane {% if not form.initial.interface or form.initial.vminterface %}active{% endif %}" id="vlan" role="tabpanel" aria-labeled-by="vlan_tab">
|
||||||
|
{% render_field form.device %}
|
||||||
|
{% render_field form.vlan %}
|
||||||
|
</div>
|
||||||
|
<div class="tab-pane {% if form.initial.interface %}active{% endif %}" id="interface" role="tabpanel" aria-labeled-by="interface_tab">
|
||||||
|
{% render_field form.device %}
|
||||||
|
{% render_field form.interface %}
|
||||||
|
</div>
|
||||||
|
<div class="tab-pane {% if form.initial.vminterface %}active{% endif %}" id="vminterface" role="tabpanel" aria-labeled-by="vminterface_tab">
|
||||||
|
{% render_field form.virtual_machine %}
|
||||||
|
{% render_field form.vminterface %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
@ -23,12 +23,12 @@
|
|||||||
|
|
||||||
<div class="noprint bulk-buttons">
|
<div class="noprint bulk-buttons">
|
||||||
<div class="bulk-button-group">
|
<div class="bulk-button-group">
|
||||||
{% if perms.ipam.change_ipaddress %}
|
{% if 'bulk_edit' in actions %}
|
||||||
<button type="submit" name="_edit" formaction="{% url 'ipam:ipaddress_bulk_edit' %}?return_url={% url 'ipam:prefix_ipaddresses' pk=object.pk %}" class="btn btn-warning btn-sm">
|
<button type="submit" name="_edit" formaction="{% url 'ipam:ipaddress_bulk_edit' %}?return_url={% url 'ipam:prefix_ipaddresses' pk=object.pk %}" class="btn btn-warning btn-sm">
|
||||||
<i class="mdi mdi-pencil" aria-hidden="true"></i> Edit
|
<i class="mdi mdi-pencil" aria-hidden="true"></i> Edit
|
||||||
</button>
|
</button>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if perms.ipam.delete_ipaddress %}
|
{% if 'bulk_delete' in actions %}
|
||||||
<button type="submit" name="_delete" formaction="{% url 'ipam:ipaddress_bulk_delete' %}?return_url={% url 'ipam:prefix_ipaddresses' pk=object.pk %}" class="btn btn-danger btn-sm">
|
<button type="submit" name="_delete" formaction="{% url 'ipam:ipaddress_bulk_delete' %}?return_url={% url 'ipam:prefix_ipaddresses' pk=object.pk %}" class="btn btn-danger btn-sm">
|
||||||
<i class="mdi mdi-trash-can-outline" aria-hidden="true"></i> Delete
|
<i class="mdi mdi-trash-can-outline" aria-hidden="true"></i> Delete
|
||||||
</button>
|
</button>
|
||||||
|
@ -23,12 +23,12 @@
|
|||||||
|
|
||||||
<div class="noprint bulk-buttons">
|
<div class="noprint bulk-buttons">
|
||||||
<div class="bulk-button-group">
|
<div class="bulk-button-group">
|
||||||
{% if perms.ipam.change_iprange %}
|
{% if 'bulk_edit' in actions %}
|
||||||
<button type="submit" name="_edit" formaction="{% url 'ipam:iprange_bulk_edit' %}?return_url={% url 'ipam:prefix_ipranges' pk=object.pk %}" class="btn btn-warning btn-sm">
|
<button type="submit" name="_edit" formaction="{% url 'ipam:iprange_bulk_edit' %}?return_url={% url 'ipam:prefix_ipranges' pk=object.pk %}" class="btn btn-warning btn-sm">
|
||||||
<i class="mdi mdi-pencil" aria-hidden="true"></i> Edit
|
<i class="mdi mdi-pencil" aria-hidden="true"></i> Edit
|
||||||
</button>
|
</button>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if perms.ipam.delete_iprange %}
|
{% if 'bulk_delete' in actions %}
|
||||||
<button type="submit" name="_delete" formaction="{% url 'ipam:iprange_bulk_delete' %}?return_url={% url 'ipam:prefix_ipranges' pk=object.pk %}" class="btn btn-danger btn-sm">
|
<button type="submit" name="_delete" formaction="{% url 'ipam:iprange_bulk_delete' %}?return_url={% url 'ipam:prefix_ipranges' pk=object.pk %}" class="btn btn-danger btn-sm">
|
||||||
<i class="mdi mdi-trash-can-outline" aria-hidden="true"></i> Delete
|
<i class="mdi mdi-trash-can-outline" aria-hidden="true"></i> Delete
|
||||||
</button>
|
</button>
|
||||||
|
@ -25,12 +25,12 @@
|
|||||||
|
|
||||||
<div class="noprint bulk-buttons">
|
<div class="noprint bulk-buttons">
|
||||||
<div class="bulk-button-group">
|
<div class="bulk-button-group">
|
||||||
{% if perms.ipam.change_prefix %}
|
{% if 'bulk_edit' in actions %}
|
||||||
<button type="submit" name="_edit" formaction="{% url 'ipam:prefix_bulk_edit' %}?return_url={% url 'ipam:prefix_prefixes' pk=object.pk %}" class="btn btn-warning btn-sm">
|
<button type="submit" name="_edit" formaction="{% url 'ipam:prefix_bulk_edit' %}?return_url={% url 'ipam:prefix_prefixes' pk=object.pk %}" class="btn btn-warning btn-sm">
|
||||||
<i class="mdi mdi-pencil" aria-hidden="true"></i> Edit
|
<i class="mdi mdi-pencil" aria-hidden="true"></i> Edit
|
||||||
</button>
|
</button>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if perms.ipam.delete_prefix %}
|
{% if 'bulk_delete' in actions %}
|
||||||
<button type="submit" name="_delete" formaction="{% url 'ipam:prefix_bulk_delete' %}?return_url={% url 'ipam:prefix_prefixes' pk=object.pk %}" class="btn btn-danger btn-sm">
|
<button type="submit" name="_delete" formaction="{% url 'ipam:prefix_bulk_delete' %}?return_url={% url 'ipam:prefix_prefixes' pk=object.pk %}" class="btn btn-danger btn-sm">
|
||||||
<i class="mdi mdi-trash-can-outline" aria-hidden="true"></i> Delete
|
<i class="mdi mdi-trash-can-outline" aria-hidden="true"></i> Delete
|
||||||
</button>
|
</button>
|
||||||
|
@ -64,6 +64,10 @@
|
|||||||
<th scope="row">Description</th>
|
<th scope="row">Description</th>
|
||||||
<td>{{ object.description|placeholder }}</td>
|
<td>{{ object.description|placeholder }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th scope="row">L2VPN</th>
|
||||||
|
<td>{{ object.l2vpn_termination.l2vpn|linkify|placeholder }}</td>
|
||||||
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -14,12 +14,12 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="noprint bulk-buttons">
|
<div class="noprint bulk-buttons">
|
||||||
<div class="bulk-button-group">
|
<div class="bulk-button-group">
|
||||||
{% if perms.virtualization.change_virtualmachine %}
|
{% if 'bulk_edit' in actions %}
|
||||||
<button type="submit" name="_edit" formaction="{% url 'virtualization:virtualmachine_bulk_edit' %}?return_url={% url 'virtualization:cluster_virtualmachines' pk=object.pk %}" class="btn btn-warning btn-sm">
|
<button type="submit" name="_edit" formaction="{% url 'virtualization:virtualmachine_bulk_edit' %}?return_url={% url 'virtualization:cluster_virtualmachines' pk=object.pk %}" class="btn btn-warning btn-sm">
|
||||||
<i class="mdi mdi-pencil" aria-hidden="true"></i> Edit
|
<i class="mdi mdi-pencil" aria-hidden="true"></i> Edit
|
||||||
</button>
|
</button>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if perms.virtualization.delete_virtualmachine %}
|
{% if 'bulk_delete' in actions %}
|
||||||
<button type="submit" name="_delete" formaction="{% url 'virtualization:virtualmachine_bulk_delete' %}?return_url={% url 'virtualization:cluster_virtualmachines' pk=object.pk %}" class="btn btn-danger btn-sm">
|
<button type="submit" name="_delete" formaction="{% url 'virtualization:virtualmachine_bulk_delete' %}?return_url={% url 'virtualization:cluster_virtualmachines' pk=object.pk %}" class="btn btn-danger btn-sm">
|
||||||
<i class="mdi mdi-trash-can-outline" aria-hidden="true"></i> Delete
|
<i class="mdi mdi-trash-can-outline" aria-hidden="true"></i> Delete
|
||||||
</button>
|
</button>
|
||||||
|
@ -3,11 +3,11 @@ from django.contrib.auth.models import Group, User
|
|||||||
from django.contrib.admin.widgets import FilteredSelectMultiple
|
from django.contrib.admin.widgets import FilteredSelectMultiple
|
||||||
from django.contrib.contenttypes.models import ContentType
|
from django.contrib.contenttypes.models import ContentType
|
||||||
from django.core.exceptions import FieldError, ValidationError
|
from django.core.exceptions import FieldError, ValidationError
|
||||||
from django.db.models import Q
|
|
||||||
|
|
||||||
from users.constants import OBJECTPERMISSION_OBJECT_TYPES
|
from users.constants import CONSTRAINT_TOKEN_USER, OBJECTPERMISSION_OBJECT_TYPES
|
||||||
from users.models import ObjectPermission, Token
|
from users.models import ObjectPermission, Token
|
||||||
from utilities.forms.fields import ContentTypeMultipleChoiceField
|
from utilities.forms.fields import ContentTypeMultipleChoiceField
|
||||||
|
from utilities.permissions import qs_filter_from_constraints
|
||||||
|
|
||||||
__all__ = (
|
__all__ = (
|
||||||
'GroupAdminForm',
|
'GroupAdminForm',
|
||||||
@ -125,7 +125,10 @@ class ObjectPermissionForm(forms.ModelForm):
|
|||||||
for ct in object_types:
|
for ct in object_types:
|
||||||
model = ct.model_class()
|
model = ct.model_class()
|
||||||
try:
|
try:
|
||||||
model.objects.filter(*[Q(**c) for c in constraints]).exists()
|
tokens = {
|
||||||
|
CONSTRAINT_TOKEN_USER: 0, # Replace token with a null user ID
|
||||||
|
}
|
||||||
|
model.objects.filter(qs_filter_from_constraints(constraints, tokens)).exists()
|
||||||
except FieldError as e:
|
except FieldError as e:
|
||||||
raise ValidationError({
|
raise ValidationError({
|
||||||
'constraints': f'Invalid filter for {model}: {e}'
|
'constraints': f'Invalid filter for {model}: {e}'
|
||||||
|
@ -6,3 +6,5 @@ OBJECTPERMISSION_OBJECT_TYPES = Q(
|
|||||||
Q(app_label='auth', model__in=['group', 'user']) |
|
Q(app_label='auth', model__in=['group', 'user']) |
|
||||||
Q(app_label='users', model__in=['objectpermission', 'token'])
|
Q(app_label='users', model__in=['objectpermission', 'token'])
|
||||||
)
|
)
|
||||||
|
|
||||||
|
CONSTRAINT_TOKEN_USER = '$user'
|
||||||
|
@ -1,6 +1,13 @@
|
|||||||
from rest_framework import status
|
from rest_framework import status
|
||||||
from rest_framework.exceptions import APIException
|
from rest_framework.exceptions import APIException
|
||||||
|
|
||||||
|
__all__ = (
|
||||||
|
'AbortRequest',
|
||||||
|
'AbortTransaction',
|
||||||
|
'PermissionsViolation',
|
||||||
|
'RQWorkerNotRunningException',
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class AbortTransaction(Exception):
|
class AbortTransaction(Exception):
|
||||||
"""
|
"""
|
||||||
@ -9,12 +16,20 @@ class AbortTransaction(Exception):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class AbortRequest(Exception):
|
||||||
|
"""
|
||||||
|
Raised to cleanly abort a request (for example, by a pre_save signal receiver).
|
||||||
|
"""
|
||||||
|
def __init__(self, message):
|
||||||
|
self.message = message
|
||||||
|
|
||||||
|
|
||||||
class PermissionsViolation(Exception):
|
class PermissionsViolation(Exception):
|
||||||
"""
|
"""
|
||||||
Raised when an operation was prevented because it would violate the
|
Raised when an operation was prevented because it would violate the
|
||||||
allowed permissions.
|
allowed permissions.
|
||||||
"""
|
"""
|
||||||
pass
|
message = "Operation failed due to object-level permissions violation"
|
||||||
|
|
||||||
|
|
||||||
class RQWorkerNotRunningException(APIException):
|
class RQWorkerNotRunningException(APIException):
|
||||||
|
@ -1,6 +1,8 @@
|
|||||||
from django.db import models
|
from django.db import models
|
||||||
from timezone_field import TimeZoneField
|
from timezone_field import TimeZoneField
|
||||||
|
|
||||||
|
from netbox.config import ConfigItem
|
||||||
|
|
||||||
|
|
||||||
SKIP_FIELDS = (
|
SKIP_FIELDS = (
|
||||||
TimeZoneField,
|
TimeZoneField,
|
||||||
@ -26,4 +28,9 @@ def custom_deconstruct(field):
|
|||||||
for attr in EXEMPT_ATTRS:
|
for attr in EXEMPT_ATTRS:
|
||||||
kwargs.pop(attr, None)
|
kwargs.pop(attr, None)
|
||||||
|
|
||||||
|
# Ignore any field defaults which reference a ConfigItem
|
||||||
|
kwargs = {
|
||||||
|
k: v for k, v in kwargs.items() if not isinstance(v, ConfigItem)
|
||||||
|
}
|
||||||
|
|
||||||
return name, path, args, kwargs
|
return name, path, args, kwargs
|
||||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user