Compare commits

..

60 Commits

Author SHA1 Message Date
Jeremy Stretch
63c6687a87 Merge pull request #10026 from netbox-community/develop
Release v3.2.9
2022-08-16 12:29:59 -04:00
jeremystretch
e01b7951f2 Release v3.2.9 2022-08-16 11:50:14 -04:00
jeremystretch
8c220cc04f Fixes #9491: Remove button for adding inventory item templates to module type components 2022-08-16 11:39:51 -04:00
jeremystretch
9e9e90f88b Closes #9933: Add DOCSIS interface type 2022-08-16 10:11:40 -04:00
jeremystretch
6d328a82e9 Cleanup for #9505 2022-08-16 10:04:47 -04:00
jeremystretch
dedee0f9d9 #9979: Fix fallback to default value 2022-08-16 09:53:13 -04:00
jeremystretch
0ef1bc8490 Clean up bulk edit buttons 2022-08-16 09:49:51 -04:00
jeremystretch
30ab1e5a5e Changelog for #8723, #9505, #9979 2022-08-16 09:14:19 -04:00
Jeremy Stretch
14821eed44 Merge pull request #9639 from cpund/8723-branch
PR for #8723
2022-08-16 09:10:24 -04:00
Jeremy Stretch
c8ecee9682 Merge pull request #9712 from renatoalmeidaoliveira/develop
Include Network information in Prefix Template Issue:#9505
2022-08-16 09:06:56 -04:00
Jeremy Stretch
a8dd809f8e Merge pull request #9981 from chcon/develop
re-enable markdown in custom columns
2022-08-16 08:57:37 -04:00
Christoph Schneider
15f4b1fd5d add newline 2022-08-13 14:02:26 +02:00
Christoph Schneider
36491b13d8 remove class definition 2022-08-13 14:01:07 +02:00
Christoph Schneider
ac540b6183 remove import 2022-08-13 13:59:19 +02:00
Christoph Schneider
6f09d94e2a remove commented line 2022-08-13 13:56:51 +02:00
Christoph Schneider
f942216f3f re-enable markup in longtext custom columns 2022-08-13 13:54:38 +02:00
jeremystretch
ca0b21bef5 Closes #9980: Use standard table controls template for device interfaces list 2022-08-12 11:25:03 -04:00
jeremystretch
e4fa8af47f Changelog for #8595 2022-08-12 10:48:16 -04:00
Jeremy Stretch
6cf898fa13 Merge pull request #9982 from DorianXGH/pon_if_types
Closes #8595: Added new PON interface types
2022-08-12 10:43:15 -04:00
jeremystretch
41ad9b242c Fixes #9986: Workaround for upstream timezone data bug 2022-08-12 10:12:01 -04:00
Dorian Bourgeoisat
693ad700e8 Swapping NG-PON2 as main name instead of TWDM-PON 2022-08-12 00:49:13 +02:00
Craig Pund
5873ad95dc handle objects without names 2022-08-11 15:16:42 -04:00
Craig Pund
6a687a9ed1 not necessary to prefetch 2022-08-11 15:16:01 -04:00
jeremystretch
e2d5313940 Changelog for #9857 2022-08-11 13:02:37 -04:00
Jeremy Stretch
a59169fa96 Merge pull request #9964 from jsenecal/feat9857
Add a "clear" button for quick search
2022-08-11 12:20:33 -04:00
Jonathan Senecal
f74b7aa7ac Add a "clear" button for quick search
Fixes #9857
2022-08-11 08:26:25 -04:00
Christoph Schneider
9a80a491c9 re-enable markdown in custom columns 2022-08-11 14:11:41 +02:00
jeremystretch
aabe8f7c5b Changelog for #9625 2022-08-10 16:18:30 -04:00
Jeremy Stretch
10af44c12a Merge pull request #9970 from barnebyte-timewarp/develop
Closes #9625: Add Contact Phone/Email to quick view panes to save time
2022-08-10 16:16:04 -04:00
jeremystretch
a9aaa8939c Closes #9161: Pretty print JSON custom field data when editing 2022-08-10 16:12:04 -04:00
jeremystretch
8f1e70f01d Fixes #9961: Correct typo 2022-08-10 15:24:45 -04:00
Dorian Bourgeoisat
1c7ef73d1f Closes #8595: Added new PON interface types 2022-08-10 15:33:33 +02:00
Barnabas Lovas
c24f1f14ec Closes #9625: Add Contact Phone/Email to quick view panes to save time 2022-08-10 13:22:58 +02:00
Jeremy Stretch
b318b79027 Merge pull request #9958 from threadedstream/fix_typo_virt_filtersets
fix typo in virtualization/forms/filtersets.py
2022-08-09 14:29:58 -04:00
gildarov
c7faca9480 fix typo in virtualization/forms/filtersets.py 2022-08-09 11:56:19 +03:00
jeremystretch
064d7f3bd0 PRVB 2022-08-08 15:34:13 -04:00
Jeremy Stretch
f1877c0c5f Merge pull request #9955 from netbox-community/develop
Release v3.2.8
2022-08-08 15:32:38 -04:00
jeremystretch
ce7fb8ab17 Release v3.2.8 2022-08-08 15:17:36 -04:00
jeremystretch
caca074161 Fixes #9950: Prevent redirection to arbitrary URLs via 'next' parameter on login URL 2022-08-08 14:21:42 -04:00
jeremystretch
8721ad987c Fixes #9952: Prevent InvalidMove when attempting to assign a nested child object as parent 2022-08-08 12:22:22 -04:00
jeremystretch
876251c1cf Fixes #9948: Fix TypeError exception when requesting API tokens list as non-authenticated user 2022-08-08 12:22:01 -04:00
jeremystretch
36ac83a319 Fixes #9949: Fix KeyError exception resulting from invalid API token provisioning request 2022-08-08 11:43:27 -04:00
jeremystretch
90317adae7 Clean up usages of mark_safe() 2022-08-08 10:47:07 -04:00
jeremystretch
135543683d Changelog for #9919 2022-08-08 10:24:49 -04:00
Jeremy Stretch
38350a1023 Merge pull request #9940 from osamu-kj/develop
Fixes #9919: XSS Bypass in custom fields displayed in tables
2022-08-08 10:10:11 -04:00
jeremystretch
0e1947bc4b PEP8 fix 2022-08-08 09:58:58 -04:00
Osamu-kj
7141fc8eb0 Custom fields - removed the debug lines 2022-08-06 17:17:43 +02:00
Osamu-kj
db38ed4f19 Fixed the XSS protection code inside custom fields 2022-08-06 15:10:31 +02:00
Osamu-kj
f874e9932d Added HTML Sanitization to the custom fields 2022-08-04 18:52:25 +02:00
jeremystretch
a2e84dd279 Changelog for #9827, #9906 2022-08-03 15:22:51 -04:00
Jeremy Stretch
a397ce234a Merge pull request #9850 from sleepinggenius2/issue_9827
Adds patterned_fields support for bulk component creation
2022-08-03 15:22:16 -04:00
Jeremy Stretch
3694e5e846 Merge pull request #9911 from oasys/9906-support-color-on-frontrearport-import-export
Fixes #9906 import/export front/rearport color field for module- and device-types
2022-08-03 14:58:02 -04:00
Jason Lavoie
c6e25f068d import/export color field on front- and rear-ports for module-types and device-types
Closes: #9906

- Adds `color` field to front and rearport template import forms
- Adds `color` field to `to_yaml` export for front and rearport
  templates
2022-08-03 09:22:06 -04:00
sleepinggenius2
bbf4b906e4 Adds patterned_fields support for bulk components 2022-07-26 17:16:03 -04:00
Renato Almeida de Oliveira
7d6882bec2 Change display to Modal 2022-07-23 20:24:33 +00:00
Renato Almeida de Oliveira
e135f8e74d Include Network information in Prefix Template Issue:#9505 2022-07-13 02:49:14 +00:00
Craig Pund
76e634330f draft for error handling on device with no name 2022-06-30 16:00:03 -04:00
Craig Pund
ef03a2f383 fix return url to account 4 filtered device lists 2022-06-30 14:13:56 -04:00
Craig Pund
5dff7433e8 add bulk device rename button to device_list 2022-06-30 01:38:53 -04:00
Craig Pund
fa014fcbf0 add device bulk rename view and url 2022-06-30 01:38:38 -04:00
335 changed files with 4616 additions and 9676 deletions

View File

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

View File

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

View File

@@ -4,7 +4,7 @@ bleach
# The Python web framework on which NetBox is built
# https://github.com/django/django
Django
Django<4.1
# Django middleware which permits cross-domain API requests
# https://github.com/OttoYiu/django-cors-headers
@@ -34,14 +34,10 @@ django-pglocks
# https://github.com/korfuri/django-prometheus
django-prometheus
# Django caching backend using Redis
# Django chaching backend using Redis
# https://github.com/jazzband/django-redis
django-redis
# Django extensions for Rich (terminal text rendering)
# https://github.com/adamchainz/django-rich
django-rich
# Django integration for RQ (Reqis queuing)
# https://github.com/rq/django-rq
django-rq
@@ -52,7 +48,8 @@ django-tables2
# User-defined tags for objects
# https://github.com/alex/django-taggit
django-taggit
# Will evaluate v3.0 during NetBox v3.3 beta
django-taggit>=2.1.0,<3.0
# A Django field for representing time zones
# https://github.com/mfogel/django-timezone-field/

View File

@@ -4,7 +4,7 @@ NetBox v2.9 introduced a new object-based permissions framework, which replaces
{!models/users/objectpermission.md!}
#### Example Constraint Definitions
### Example Constraint Definitions
| Constraints | Description |
| ----------- | ----------- |

View File

@@ -212,7 +212,6 @@ The following model fields support configurable choices:
* `circuits.Circuit.status`
* `dcim.Device.status`
* `dcim.Location.status`
* `dcim.PowerFeed.status`
* `dcim.Rack.status`
* `dcim.Site.status`
@@ -221,7 +220,6 @@ The following model fields support configurable choices:
* `ipam.IPRange.status`
* `ipam.Prefix.status`
* `ipam.VLAN.status`
* `virtualization.Cluster.status`
* `virtualization.VirtualMachine.status`
The following colors are supported:

View File

@@ -26,8 +26,3 @@
---
{!models/ipam/asn.md!}
---
{!models/ipam/l2vpn.md!}
{!models/ipam/l2vpntermination.md!}

View File

@@ -45,8 +45,6 @@ The Django [content types](https://docs.djangoproject.com/en/stable/ref/contrib/
* [ipam.FHRPGroup](../models/ipam/fhrpgroup.md)
* [ipam.IPAddress](../models/ipam/ipaddress.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.RouteTarget](../models/ipam/routetarget.md)
* [ipam.Service](../models/ipam/service.md)

View File

@@ -13,7 +13,7 @@ Each circuit is also assigned one of the following operational statuses:
* Deprovisioning
* Decommissioned
Circuits also have optional fields for annotating their installation and termination dates and commit rate, and may be assigned to NetBox tenants.
Circuits also have optional fields for annotating their installation date and commit rate, and may be assigned to NetBox tenants.
!!! note
NetBox currently models only physical circuits: those which have exactly two endpoints. It is common to layer virtualized constructs (_virtual circuits_) such as MPLS or EVPN tunnels on top of these, however NetBox does not yet support virtual circuit modeling.

View File

@@ -1,3 +1,3 @@
## Front Ports
Front ports are pass-through ports used to represent physical cable connections that comprise part of a longer path. For example, the ports on the front face of a UTP patch panel would be modeled in NetBox as front ports. Each port is assigned a physical type, and must be mapped to a specific rear port on the same device. A single rear port may be mapped to multiple rear ports, using numeric positions to annotate the specific alignment of each.
Front ports are pass-through ports used to represent physical cable connections that comprise part of a longer path. For example, the ports on the front face of a UTP patch panel would be modeled in NetBox as front ports. Each port is assigned a physical type, and must be mapped to a specific rear port on the same device. A single rear port may be mapped to multiple front ports, using numeric positions to annotate the specific alignment of each.

View File

@@ -11,13 +11,6 @@ Interfaces may be physical or virtual in nature, but only physical interfaces ma
Physical interfaces may be arranged into a link aggregation group (LAG) and associated with a parent LAG (virtual) interface. LAG interfaces can be recursively nested to model bonding of trunk groups. Like all virtual interfaces, LAG interfaces cannot be connected physically.
### Power over Ethernet (PoE)
!!! note
This feature was added in NetBox v3.3.
Physical interfaces can be assigned a PoE mode to indicate PoE capability: power supplying equipment (PSE) or powered device (PD). Additionally, a PoE mode may be specified. This can be one of the listed IEEE 802.3 standards, or a passive setting (24 or 48 volts across two or four pairs).
### Wireless Interfaces
Wireless interfaces may additionally track the following attributes:

View File

@@ -1,3 +1,3 @@
## Interface Templates
A template for a network interface that will be created on all instantiations of the parent device type. Each interface may be assigned a physical or virtual type, and may be designated as "management-only." Power over Ethernet (PoE) mode and type may also be assigned to interface templates.
A template for a network interface that will be created on all instantiations of the parent device type. Each interface may be assigned a physical or virtual type, and may be designated as "management-only."

View File

@@ -2,4 +2,5 @@
Racks and devices can be grouped by location within a site. A location may represent a floor, room, cage, or similar organizational unit. Locations can be nested to form a hierarchy. For example, you may have floors within a site, and rooms within a floor.
Each location must have a name that is unique within its parent site and location, if any, and must be assigned an operational status. (The set of available statuses is configurable.)
Each location must have a name that is unique within its parent site and location, if any.

View File

@@ -5,11 +5,9 @@ Sometimes it is desirable to associate additional data with a group of devices o
* Region
* Site group
* Site
* Location (devices only)
* Device type (devices only)
* Role
* Platform
* Cluster type (VMs only)
* Cluster group (VMs only)
* Cluster (VMs only)
* Tenant group

View File

@@ -26,35 +26,11 @@ Each custom field must have a name. This should be a simple database-friendly st
Marking a field as required will force the user to provide a value for the field when creating a new object or when saving an existing object. A default value for the field may also be provided. Use "true" or "false" for boolean fields, or the exact value of a choice for selection fields.
A custom field must be assigned to one or more object types, or models, in NetBox. Once created, custom fields will automatically appear as part of these models in the web UI and REST API. Note that not all models support custom fields.
### Filtering
The filter logic controls how values are matched when filtering objects by the custom field. Loose filtering (the default) matches on a partial value, whereas exact matching requires a complete match of the given string to a field's value. For example, exact filtering with the string "red" will only match the exact value "red", whereas loose filtering will match on the values "red", "red-orange", or "bored". Setting the filter logic to "disabled" disables filtering by the field entirely.
### Grouping
A custom field must be assigned to one or more object types, or models, in NetBox. Once created, custom fields will automatically appear as part of these models in the web UI and REST API. Note that not all models support custom fields.
!!! note
This feature was introduced in NetBox v3.3.
Related custom fields can be grouped together within the UI by assigning each the same group name. When at least one custom field for an object type has a group defined, it will appear under the group heading within the custom fields panel under the object view. All custom fields with the same group name will appear under that heading. (Note that the group names must match exactly, or each will appear as a separate heading.)
This parameter has no effect on the API representation of custom field data.
### Visibility
!!! note
This feature was introduced in NetBox v3.3.
When creating a custom field, there are three options for UI visibility. These control how and whether the custom field is displayed within the NetBox UI.
* **Read/write** (default): The custom field is included when viewing and editing objects.
* **Read-only**: The custom field is displayed when viewing an object, but it cannot be edited via the UI. (It will appear in the form as a read-only field.)
* **Hidden**: The custom field will never be displayed within the UI. This option is recommended for fields which are not intended for use by human users.
Note that this setting has no impact on the REST or GraphQL APIs: Custom field data will always be available via either API.
### Validation
### Custom Field Validation
NetBox supports limited custom validation for custom field values. Following are the types of validation enforced for each field type:

View File

@@ -1,21 +0,0 @@
# 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 two terminations to a given L2VPN.

View File

@@ -1,15 +0,0 @@
# L2VPN Termination
A L2VPN Termination is the termination point of a L2VPN. Certain types of L2VPNs 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 L2VPNs are considered point-to-point:
* VPWS
* EPL
* EP-LAN
* EP-TREE
!!! note
Choosing any of the above types will result in only being able to add 2 terminations to a given L2VPN.

View File

@@ -53,17 +53,3 @@ 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.
### User Token
!!! 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.

View File

@@ -10,10 +10,3 @@ Each token contains a 160-bit key represented as 40 hexadecimal characters. When
By default, a token can be used to perform all actions via the API that a user would be permitted to do via the web UI. Deselecting the "write enabled" option will restrict API requests made with the token to read operations (e.g. GET) only.
Additionally, a token can be set to expire at a specific time. This can be useful if an external client needs to be granted temporary access to NetBox.
### Client IP Restriction
!!! note
This feature was introduced in NetBox v3.3.
Each API token can optionally be restricted by client IP address. If one or more allowed IP prefixes/addresses is defined for a token, authentication will fail for any client connecting from an IP address outside the defined range(s). This enables restricting the use a token to a specific client. (By default, any client IP address is permitted.)

View File

@@ -1,5 +1,5 @@
# Clusters
A cluster is a logical grouping of physical resources within which virtual machines run. A cluster must be assigned a type (technological classification) and operational status, and may optionally be assigned to a cluster group, site, and/or tenant. Each cluster must have a unique name within its assigned group and/or site, if any.
A cluster is a logical grouping of physical resources within which virtual machines run. A cluster must be assigned a type (technological classification), and may optionally be assigned to a cluster group, site, and/or tenant. Each cluster must have a unique name within its assigned group and/or site, if any.
Physical devices may be associated with clusters as hosts. This allows users to track on which host(s) a particular virtual machine may reside. However, NetBox does not support pinning a specific VM within a cluster to a particular host device.

View File

@@ -1,6 +1,6 @@
# Virtual Machines
A virtual machine represents a virtual compute instance hosted within a cluster. Each VM must be assigned to a site and/or cluster, and may optionally be assigned to a particular host device within a cluster.
A virtual machine represents a virtual compute instance hosted within a cluster. Each VM must be assigned to exactly one cluster.
Like devices, each VM can be assigned a platform and/or functional role, and must have one of the following operational statuses assigned to it:

View File

@@ -1,6 +1,6 @@
# Wireless LANs
A wireless LAN is a set of interfaces connected via a common wireless channel. Each instance must have an SSID, and may optionally be correlated to a VLAN. Wireless LANs can be arranged into hierarchical groups, and each may be associated with a particular tenant.
A wireless LAN is a set of interfaces connected via a common wireless channel. Each instance must have an SSID, and may optionally be correlated to a VLAN. Wireless LANs can be arranged into hierarchical groups.
An interface may be attached to multiple wireless LANs, provided they are all operating on the same channel. Only wireless interfaces may be attached to wireless LANs.

View File

@@ -1,6 +1,6 @@
# Wireless Links
A wireless link represents a connection between exactly two wireless interfaces. It may optionally be assigned an SSID and a description. It may also have a status assigned to it, similar to the cable model. Each wireless link may also be assigned to a particular tenant.
A wireless link represents a connection between exactly two wireless interfaces. It may optionally be assigned an SSID and a description. It may also have a status assigned to it, similar to the cable model.
Each wireless link may have authentication attributes associated with it, including:

View File

@@ -1,28 +0,0 @@
# 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.

View File

@@ -37,7 +37,7 @@ This class performs two crucial functions:
1. Apply any fields, methods, and/or attributes necessary to the operation of these features
2. Register the model with NetBox as utilizing these features
Simply subclass NetBoxModel when defining a model in your plugin:
Simply subclass BaseModel when defining a model in your plugin:
```python
# models.py
@@ -49,24 +49,6 @@ class MyModel(NetBoxModel):
...
```
### The `clone()` Method
!!! info
This method was introduced in NetBox v3.3.
The `NetBoxModel` class includes a `clone()` method to be used for gathering attributes which can be used to create a "cloned" instance. This is used primarily for form initialization, e.g. when using the "clone" button in the NetBox UI. By default, this method will replicate any fields listed in the model's `clone_fields` list, if defined.
Plugin models can leverage this method by defining `clone_fields` as a list of field names to be replicated, or override this method to replace or extend its content:
```python
class MyModel(NetBoxModel):
def clone(self):
attrs = super().clone()
attrs['extra-value'] = 123
return attrs
```
### Enabling Features Individually
If you prefer instead to enable only a subset of these features for a plugin model, NetBox provides a discrete "mix-in" class for each feature. You can subclass each of these individually when defining your model. (Your model will also need to inherit from Django's built-in `Model` class.)

View File

@@ -215,8 +215,6 @@ The following custom template tags are available in NetBox.
::: utilities.templatetags.builtins.tags.checkmark
::: utilities.templatetags.builtins.tags.customfield_value
::: utilities.templatetags.builtins.tags.tag
## Filters

View File

@@ -51,16 +51,15 @@ 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.
| View Class | Description |
|----------------------|--------------------------------------------------------|
| `ObjectView` | View a single object |
| `ObjectEditView` | Create or edit a single object |
| `ObjectDeleteView` | Delete a single object |
| `ObjectChildrenView` | A list of child objects within the context of a parent |
| `ObjectListView` | View a list of objects |
| `BulkImportView` | Import a set of new objects |
| `BulkEditView` | Edit multiple objects |
| `BulkDeleteView` | Delete multiple objects |
| View Class | Description |
|--------------------|--------------------------------|
| `ObjectView` | View a single object |
| `ObjectEditView` | Create or edit a single object |
| `ObjectDeleteView` | Delete a single object |
| `ObjectListView` | View a list of objects |
| `BulkImportView` | Import a set of new objects |
| `BulkEditView` | Edit multiple objects |
| `BulkDeleteView` | Delete multiple objects |
!!! 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.
@@ -100,12 +99,6 @@ Below are the class definitions for NetBox's object views. These views handle CR
members:
- get_object
::: netbox.views.generic.ObjectChildrenView
selection:
members:
- get_children
- prep_table_data
## 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.

View File

@@ -10,17 +10,6 @@ Minor releases are published in April, August, and December of each calendar yea
This page contains a history of all major and minor releases since NetBox v2.0. For more detail on a specific patch release, please see the release notes page for that specific minor release.
#### [Version 3.3](./version-3.3.md) (August 2022)
* Multi-object Cable Terminations ([#9102](https://github.com/netbox-community/netbox/issues/9102))
* L2VPN Modeling ([#8157](https://github.com/netbox-community/netbox/issues/8157))
* PoE Interface Attributes ([#1099](https://github.com/netbox-community/netbox/issues/1099))
* Half-Height Rack Units ([#51](https://github.com/netbox-community/netbox/issues/51))
* 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))
* Custom Field Grouping ([#8495](https://github.com/netbox-community/netbox/issues/8495))
* Toggle Custom Field Visibility ([#9166](https://github.com/netbox-community/netbox/issues/9166))
#### [Version 3.2](./version-3.2.md) (April 2022)
* Plugins Framework Extensions ([#8333](https://github.com/netbox-community/netbox/issues/8333))

View File

@@ -1,6 +1,26 @@
# NetBox v3.2
## v3.2.8 (FUTURE)
## v3.2.9 (2022-08-16)
### Enhancements
* [#8595](https://github.com/netbox-community/netbox/issues/8595) - Add PON interface types
* [#8723](https://github.com/netbox-community/netbox/issues/8723) - Enable bulk renaming of devices
* [#9161](https://github.com/netbox-community/netbox/issues/9161) - Pretty print JSON custom field data when editing
* [#9505](https://github.com/netbox-community/netbox/issues/9505) - Display extra addressing details for IPv4 prefixes
* [#9625](https://github.com/netbox-community/netbox/issues/9625) - Add phone & email details to contacts panel
* [#9857](https://github.com/netbox-community/netbox/issues/9857) - Add clear button to quick search fields
* [#9933](https://github.com/netbox-community/netbox/issues/9933) - Add DOCSIS interface type
### Bug Fixes
* [#9491](https://github.com/netbox-community/netbox/issues/9491) - Remove button for adding inventory item templates to module type components
* [#9979](https://github.com/netbox-community/netbox/issues/9979) - Fix Markdown rendering for custom fields in table columns
* [#9986](https://github.com/netbox-community/netbox/issues/9986) - Workaround for upstream timezone data bug
---
## v3.2.8 (2022-08-08)
### Enhancements
@@ -11,13 +31,20 @@
* [#9881](https://github.com/netbox-community/netbox/issues/9881) - Increase granularity in utilization graph values
* [#9882](https://github.com/netbox-community/netbox/issues/9882) - Add manufacturer column to modules table
* [#9883](https://github.com/netbox-community/netbox/issues/9883) - Linkify location column in power panels table
* [#9906](https://github.com/netbox-community/netbox/issues/9906) - Include `color` attribute in front & rear port YAML import/export
### Bug Fixes
* [#9827](https://github.com/netbox-community/netbox/issues/9827) - Fix assignment of module bay position during bulk creation
* [#9871](https://github.com/netbox-community/netbox/issues/9871) - Fix utilization graph value alignments
* [#9884](https://github.com/netbox-community/netbox/issues/9884) - Prevent querying assigned VRF on prefix object init
* [#9885](https://github.com/netbox-community/netbox/issues/9885) - Fix child prefix counts when editing/deleting aggregates in bulk
* [#9891](https://github.com/netbox-community/netbox/issues/9891) - Ensure consistent ordering for tags during object serialization
* [#9919](https://github.com/netbox-community/netbox/issues/9919) - Fix potential XSS avenue via linked objects in tables
* [#9948](https://github.com/netbox-community/netbox/issues/9948) - Fix TypeError exception when requesting API tokens list as non-authenticated user
* [#9949](https://github.com/netbox-community/netbox/issues/9949) - Fix KeyError exception resulting from invalid API token provisioning request
* [#9950](https://github.com/netbox-community/netbox/issues/9950) - Prevent redirection to arbitrary URLs via `next` parameter on login URL
* [#9952](https://github.com/netbox-community/netbox/issues/9952) - Prevent InvalidMove when attempting to assign a nested child object as parent
---

View File

@@ -1,236 +0,0 @@
# NetBox v3.3
## v3.3-beta2 (2022-08-03)
### Breaking Changes
* Device position and rack unit values are now reported as decimals (e.g. `1.0` or `1.5`) to support modeling half-height rack units.
* The `nat_outside` relation on the IP address model now returns a list of zero or more related IP addresses, rather than a single instance (or None).
* Several fields on the cable API serializers have been altered or removed to support multiple-object cable terminations:
| Old Name | Old Type | New Name | New Type |
|----------------------|----------|-----------------------|----------|
| `termination_a_type` | string | _Removed_ | - |
| `termination_b_type` | string | _Removed_ | - |
| `termination_a_id` | integer | _Removed_ | - |
| `termination_b_id` | integer | _Removed_ | - |
| `termination_a` | object | `a_terminations` | list |
| `termination_b` | object | `b_terminations` | list |
* As with the cable model, several API fields on all objects to which cables can be connected (interfaces, circuit terminations, etc.) have been changed:
| Old Name | Old Type | New Name | New Type |
|--------------------------------|----------|---------------------------------|----------|
| `link_peer` | object | `link_peers` | list |
| `link_peer_type` | string | `link_peers_type` | string |
| `connected_endpoint` | object | `connected_endpoints` | list |
| `connected_endpoint_type` | string | `connected_endpoints_type` | string |
| `connected_endpoint_reachable` | boolean | `connected_endpoints_reachable` | boolean |
* The cable path serialization returned by the `/paths/` endpoint for pass-through ports has been simplified, and the following fields removed: `origin_type`, `origin`, `destination_type`, `destination`. (Additionally, `is_complete` has been added.)
### New Features
#### Multi-object Cable Terminations ([#9102](https://github.com/netbox-community/netbox/issues/9102))
When creating a cable in NetBox, each end can now be attached to multiple objects. This allows accurate modeling of duplex fiber connections to individual termination ports and breakout cables, as examples. (Note that all terminations attached to one end of a cable must be the same object type, but do not need to connect to the same parent object.) Additionally, cable terminations can now be modified without needing to delete and recreate the cable.
#### L2VPN Modeling ([#8157](https://github.com/netbox-community/netbox/issues/8157))
NetBox can now model a variety of L2 VPN technologies, including VXLAN, VPLS, and others. Each L2VPN can be terminated to multiple device or virtual machine interfaces and/or VLANs to track connectivity across an overlay. Similarly to VRFs, each L2VPN can also have import and export route targets associated with it.
#### PoE Interface Attributes ([#1099](https://github.com/netbox-community/netbox/issues/1099))
Two new fields have been added to the device interface model to track power over Ethernet (PoE) capabilities:
* **PoE mode**: Power supplying equipment (PSE) or powered device (PD)
* **PoE type**: Applicable IEEE standard or other power type
#### Half-Height Rack Units ([#51](https://github.com/netbox-community/netbox/issues/51))
Device type height can now be specified in 0.5U increments, allowing for the creation of half-height devices. Additionally, a device can be installed at the half-unit mark within a rack (e.g. U2.5). For example, two half-height devices positioned in sequence will consume a single rack unit; two consecutive 1.5U devices will consume 3U of space.
#### Restrict API Tokens by Client IP ([#8233](https://github.com/netbox-community/netbox/issues/8233))
API tokens can now be restricted to use by certain client IP addresses or networks. For example, an API token with its `allowed_ips` list set to `[192.0.2.0/24]` will only permit authentication from API clients within that network; requests from other sources will fail authentication. This can be very useful for restricting the use of a token to specific clients.
#### Reference User in Permission Constraints ([#9074](https://github.com/netbox-community/netbox/issues/9074))
NetBox's permission constraints have been expanded to support referencing the current user associated with a request using the special `$user` token. As an example, this enables an administrator to efficiently grant each user to edit his or her own journal entries, but not those created by other users.
```json
{
"created_by": "$user"
}
```
#### Custom Field Grouping ([#8495](https://github.com/netbox-community/netbox/issues/8495))
A `group_name` field has been added to the custom field model to enable organizing related custom fields by group. Similarly to custom links, custom links which have been assigned to a common group will be rendered within that group when viewing an object in the UI. (Custom field grouping has no effect on API operation.)
#### Toggle Custom Field Visibility ([#9166](https://github.com/netbox-community/netbox/issues/9166))
The behavior of each custom field within the NetBox UI can now be controlled individually by toggling its UI visibility. Three settings are available:
* **Read/write**: The custom field is included when viewing and editing objects (default).
* **Read-only**: The custom field is displayed when viewing an object, but it cannot be edited via the UI. (It will appear in the form as a read-only field.)
* **Hidden**: The custom field will never be displayed within the UI. This option is recommended for fields which are not intended for use by human users.
Custom field UI visibility has no impact on API operation.
### Enhancements
* [#1202](https://github.com/netbox-community/netbox/issues/1202) - Support overlapping assignment of NAT IP addresses
* [#4350](https://github.com/netbox-community/netbox/issues/4350) - Illustrate reservations vertically alongside rack elevations
* [#4434](https://github.com/netbox-community/netbox/issues/4434) - Enable highlighting devices within rack elevations
* [#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
* [#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
* [#8471](https://github.com/netbox-community/netbox/issues/8471) - Add `status` field to Cluster
* [#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
* [#9070](https://github.com/netbox-community/netbox/issues/9070) - Hide navigation menu items based on user permissions
* [#9177](https://github.com/netbox-community/netbox/issues/9177) - Add tenant assignment for wireless LANs & links
* [#9391](https://github.com/netbox-community/netbox/issues/9391) - Remove 500-character limit for custom link text & URL fields
* [#9536](https://github.com/netbox-community/netbox/issues/9536) - Track API token usage times
* [#9582](https://github.com/netbox-community/netbox/issues/9582) - Enable assigning config contexts based on device location
### Bug Fixes (from Beta1)
* [#9728](https://github.com/netbox-community/netbox/issues/9728) - Fix validation when assigning a virtual machine to a device
* [#9729](https://github.com/netbox-community/netbox/issues/9729) - Fix ordering of content type creation to ensure compatability with demo data
* [#9730](https://github.com/netbox-community/netbox/issues/9730) - Fix validation error when creating a new cable via UI form
* [#9733](https://github.com/netbox-community/netbox/issues/9733) - Handle split paths during trace when fanning out to front ports with differing cables
* [#9765](https://github.com/netbox-community/netbox/issues/9765) - Report correct segment count under cable trace UI view
* [#9778](https://github.com/netbox-community/netbox/issues/9778) - Fix exception during cable deletion after deleting a connected termination
* [#9788](https://github.com/netbox-community/netbox/issues/9788) - Ensure denormalized fields on CableTermination are kept in sync with related objects
* [#9789](https://github.com/netbox-community/netbox/issues/9789) - Fix rendering of cable traces ending at provider networks
* [#9794](https://github.com/netbox-community/netbox/issues/9794) - Fix link to connect a rear port to a circuit termination
* [#9818](https://github.com/netbox-community/netbox/issues/9818) - Fix circuit side selection when connecting a cable to a circuit termination
* [#9829](https://github.com/netbox-community/netbox/issues/9829) - Arrange custom fields by group when editing objects
* [#9843](https://github.com/netbox-community/netbox/issues/9843) - Fix rendering of custom field values (regression from #9647)
* [#9844](https://github.com/netbox-community/netbox/issues/9844) - Fix interface api request when creating/editing L2VPN termination
* [#9847](https://github.com/netbox-community/netbox/issues/9847) - Respect `desc_units` when ordering rack units
### 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
* [#9647](https://github.com/netbox-community/netbox/issues/9647) - Introduce `customfield_value` template tag
### Other Changes
* [#9261](https://github.com/netbox-community/netbox/issues/9261) - `NetBoxTable` no longer automatically clears pre-existing calls to `prefetch_related()` on its queryset
* [#9434](https://github.com/netbox-community/netbox/issues/9434) - Enabled `django-rich` test runner for more user-friendly output
* [#9903](https://github.com/netbox-community/netbox/issues/9903) - Implement a mechanism for automatically updating denormalized fields
### REST API Changes
* List results can now be ordered by field, by appending `?ordering={fieldname}` to the query. Multiple fields can be specified by separating the field names with a comma, e.g. `?ordering=site,name`. To invert the ordering, prepend a hyphen to the field name, e.g. `?ordering=-name`.
* Added the following endpoints:
* `/api/dcim/cable-terminations/`
* `/api/ipam/l2vpns/`
* `/api/ipam/l2vpn-terminations/`
* circuits.Circuit
* Added optional `termination_date` field
* circuits.CircuitTermination
* `link_peer` has been renamed to `link_peers` and now returns a list of objects
* `link_peer_type` has been renamed to `link_peers_type`
* `connected_endpoint` has been renamed to `connected_endpoints` and now returns a list of objects
* `connected_endpoint_type` has been renamed to `connected_endpoints_type`
* `connected_endpoint_reachable` has been renamed to `connected_endpoints_reachable`
* Added `custom_fields` and `tags` fields
* dcim.Cable
* `termination_a_type` has been renamed to `a_terminations_type`
* `termination_b_type` has been renamed to `b_terminations_type`
* `termination_a` renamed to `a_terminations` and now returns a list of objects
* `termination_b` renamed to `b_terminations` and now returns a list of objects
* `termination_a_id` has been removed
* `termination_b_id` has been removed
* dcim.ConsolePort
* `link_peer` has been renamed to `link_peers` and now returns a list of objects
* `link_peer_type` has been renamed to `link_peers_type`
* `connected_endpoint` has been renamed to `connected_endpoints` and now returns a list of objects
* `connected_endpoint_type` has been renamed to `connected_endpoints_type`
* `connected_endpoint_reachable` has been renamed to `connected_endpoints_reachable`
* dcim.ConsoleServerPort
* `link_peer` has been renamed to `link_peers` and now returns a list of objects
* `link_peer_type` has been renamed to `link_peers_type`
* `connected_endpoint` has been renamed to `connected_endpoints` and now returns a list of objects
* `connected_endpoint_type` has been renamed to `connected_endpoints_type`
* `connected_endpoint_reachable` has been renamed to `connected_endpoints_reachable`
* dcim.Device
* The `position` field has been changed from an integer to a decimal
* dcim.DeviceType
* The `u_height` field has been changed from an integer to a decimal
* dcim.FrontPort
* `link_peer` has been renamed to `link_peers` and now returns a list of objects
* `link_peer_type` has been renamed to `link_peers_type`
* `connected_endpoint` has been renamed to `connected_endpoints` and now returns a list of objects
* `connected_endpoint_type` has been renamed to `connected_endpoints_type`
* `connected_endpoint_reachable` has been renamed to `connected_endpoints_reachable`
* dcim.Interface
* `link_peer` has been renamed to `link_peers` and now returns a list of objects
* `link_peer_type` has been renamed to `link_peers_type`
* `connected_endpoint` has been renamed to `connected_endpoints` and now returns a list of objects
* `connected_endpoint_type` has been renamed to `connected_endpoints_type`
* `connected_endpoint_reachable` has been renamed to `connected_endpoints_reachable`
* Added the optional `poe_mode` and `poe_type` fields
* Added the `l2vpn_termination` read-only field
* dcim.InterfaceTemplate
* Added the optional `poe_mode` and `poe_type` fields
* dcim.Location
* Added required `status` field (default value: `active`)
* dcim.PowerOutlet
* `link_peer` has been renamed to `link_peers` and now returns a list of objects
* `link_peer_type` has been renamed to `link_peers_type`
* `connected_endpoint` has been renamed to `connected_endpoints` and now returns a list of objects
* `connected_endpoint_type` has been renamed to `connected_endpoints_type`
* `connected_endpoint_reachable` has been renamed to `connected_endpoints_reachable`
* dcim.PowerFeed
* `link_peer` has been renamed to `link_peers` and now returns a list of objects
* `link_peer_type` has been renamed to `link_peers_type`
* `connected_endpoint` has been renamed to `connected_endpoints` and now returns a list of objects
* `connected_endpoint_type` has been renamed to `connected_endpoints_type`
* `connected_endpoint_reachable` has been renamed to `connected_endpoints_reachable`
* dcim.PowerPort
* `link_peer` has been renamed to `link_peers` and now returns a list of objects
* `link_peer_type` has been renamed to `link_peers_type`
* `connected_endpoint` has been renamed to `connected_endpoints` and now returns a list of objects
* `connected_endpoint_type` has been renamed to `connected_endpoints_type`
* `connected_endpoint_reachable` has been renamed to `connected_endpoints_reachable`
* dcim.Rack
* The `elevation` endpoint now includes half-height rack units, and utilizes decimal values for the ID and name of each unit
* dcim.RearPort
* `link_peer` has been renamed to `link_peers` and now returns a list of objects
* `link_peer_type` has been renamed to `link_peers_type`
* `connected_endpoint` has been renamed to `connected_endpoints` and now returns a list of objects
* `connected_endpoint_type` has been renamed to `connected_endpoints_type`
* `connected_endpoint_reachable` has been renamed to `connected_endpoints_reachable`
* extras.ConfigContext
* Added the `locations` many-to-many field to track the assignment of ConfigContexts to Locations
* extras.CustomField
* Added `group_name` and `ui_visibility` fields
* ipam.IPAddress
* 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
* ipam.VLAN
* Added the `l2vpn_termination` read-only field
* users.Token
* Added the `allowed_ips` array field
* Added the read-only `last_used` datetime field
* virtualization.Cluster
* Added required `status` field (default value: `active`)
* virtualization.VirtualMachine
* 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.
* Added the optional `device` field
* Added the `l2vpn_termination` read-only field
wireless.WirelessLAN
* Added `tenant` field
wireless.WirelessLink
* Added `tenant` field

View File

@@ -29,11 +29,6 @@ $ curl https://netbox/api/dcim/sites/
}
```
When a token is used to authenticate a request, its `last_updated` time updated to the current time if its last use was recorded more than 60 seconds ago (or was never recorded). This allows users to determine which tokens have been active recently.
!!! note
The "last used" time for tokens will not be updated while maintenance mode is enabled.
## Initial Token Provisioning
Ideally, each user should provision his or her own REST API token(s) via the web UI. However, you may encounter where a token must be created by a user via the REST API itself. NetBox provides a special endpoint to provision tokens using a valid username and password combination.

View File

@@ -106,23 +106,3 @@ expression: `n`. Here is an example of a lookup expression on a foreign key, it
```no-highlight
GET /api/ipam/vlans/?group_id__n=3203
```
## Ordering Objects
To order results by a particular field, include the `ordering` query parameter. For example, order the list of sites according to their facility values:
```no-highlight
GET /api/dcim/sites/?ordering=facility
```
To invert the ordering, prepend a hyphen to the field name:
```no-highlight
GET /api/dcim/sites/?ordering=-facility
```
Multiple fields can be specified by separating the field names with a comma. For example:
```no-highlight
GET /api/dcim/sites/?ordering=facility,-name
```

View File

@@ -118,7 +118,6 @@ nav:
- REST API: 'plugins/development/rest-api.md'
- GraphQL API: 'plugins/development/graphql-api.md'
- Background Tasks: 'plugins/development/background-tasks.md'
- Exceptions: 'plugins/development/exceptions.md'
- Administration:
- Authentication:
- Overview: 'administration/authentication/overview.md'
@@ -131,7 +130,7 @@ nav:
- NetBox Shell: 'administration/netbox-shell.md'
- REST API:
- Overview: 'rest-api/overview.md'
- Filtering & Ordering: 'rest-api/filtering.md'
- Filtering: 'rest-api/filtering.md'
- Authentication: 'rest-api/authentication.md'
- GraphQL API:
- Overview: 'graphql-api/overview.md'
@@ -152,7 +151,6 @@ nav:
- Release Checklist: 'development/release-checklist.md'
- Release Notes:
- Summary: 'release-notes/index.md'
- Version 3.3: 'release-notes/version-3.3.md'
- Version 3.2: 'release-notes/version-3.2.md'
- Version 3.1: 'release-notes/version-3.1.md'
- Version 3.0: 'release-notes/version-3.0.md'

View File

@@ -1,7 +1,7 @@
from rest_framework import serializers
from circuits.models import *
from netbox.api.serializers import WritableNestedSerializer
from netbox.api import WritableNestedSerializer
__all__ = [
'NestedCircuitSerializer',

View File

@@ -2,12 +2,12 @@ from rest_framework import serializers
from circuits.choices import CircuitStatusChoices
from circuits.models import *
from dcim.api.nested_serializers import NestedSiteSerializer
from dcim.api.serializers import CabledObjectSerializer
from dcim.api.nested_serializers import NestedCableSerializer, NestedSiteSerializer
from dcim.api.serializers import LinkTerminationSerializer
from ipam.models import ASN
from ipam.api.nested_serializers import NestedASNSerializer
from netbox.api.fields import ChoiceField, SerializedPKRelatedField
from netbox.api.serializers import NetBoxModelSerializer, WritableNestedSerializer
from netbox.api import ChoiceField, SerializedPKRelatedField
from netbox.api.serializers import NetBoxModelSerializer, ValidatedModelSerializer, WritableNestedSerializer
from tenancy.api.nested_serializers import NestedTenantSerializer
from .nested_serializers import *
@@ -92,22 +92,23 @@ class CircuitSerializer(NetBoxModelSerializer):
class Meta:
model = Circuit
fields = [
'id', 'url', 'display', 'cid', 'provider', 'type', 'status', 'tenant', 'install_date', 'termination_date',
'commit_rate', 'description', 'termination_a', 'termination_z', 'comments', 'tags', 'custom_fields',
'created', 'last_updated',
'id', 'url', 'display', 'cid', 'provider', 'type', 'status', 'tenant', 'install_date', 'commit_rate',
'description', 'termination_a', 'termination_z', 'comments', 'tags', 'custom_fields', 'created',
'last_updated',
]
class CircuitTerminationSerializer(NetBoxModelSerializer, CabledObjectSerializer):
class CircuitTerminationSerializer(ValidatedModelSerializer, LinkTerminationSerializer):
url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuittermination-detail')
circuit = NestedCircuitSerializer()
site = NestedSiteSerializer(required=False, allow_null=True)
provider_network = NestedProviderNetworkSerializer(required=False, allow_null=True)
cable = NestedCableSerializer(read_only=True)
class Meta:
model = CircuitTermination
fields = [
'id', 'url', 'display', 'circuit', 'term_side', 'site', 'provider_network', 'port_speed', 'upstream_speed',
'xconnect_id', 'pp_info', 'description', 'mark_connected', 'cable', 'cable_end', 'link_peers',
'link_peers_type', 'tags', 'custom_fields', 'created', 'last_updated', '_occupied',
'xconnect_id', 'pp_info', 'description', 'mark_connected', 'cable', 'link_peer', 'link_peer_type',
'_occupied', 'created', 'last_updated',
]

View File

@@ -1,4 +1,4 @@
from netbox.api.routers import NetBoxRouter
from netbox.api import NetBoxRouter
from . import views

View File

@@ -58,7 +58,7 @@ class CircuitViewSet(NetBoxModelViewSet):
class CircuitTerminationViewSet(PassThroughPortMixin, NetBoxModelViewSet):
queryset = CircuitTermination.objects.prefetch_related(
'circuit', 'site', 'provider_network', 'cable__terminations'
'circuit', 'site', 'provider_network', 'cable'
)
serializer_class = serializers.CircuitTerminationSerializer
filterset_class = filtersets.CircuitTerminationFilterSet

View File

@@ -1,10 +1,10 @@
import django_filters
from django.db.models import Q
from dcim.filtersets import CabledObjectFilterSet
from dcim.filtersets import CableTerminationFilterSet
from dcim.models import Region, Site, SiteGroup
from ipam.models import ASN
from netbox.filtersets import NetBoxModelFilterSet, OrganizationalModelFilterSet
from netbox.filtersets import ChangeLoggedModelFilterSet, NetBoxModelFilterSet, OrganizationalModelFilterSet
from tenancy.filtersets import ContactModelFilterSet, TenancyFilterSet
from utilities.filters import TreeNodeMultipleChoiceFilter
from .choices import *
@@ -183,7 +183,7 @@ class CircuitFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilte
class Meta:
model = Circuit
fields = ['id', 'cid', 'description', 'install_date', 'termination_date', 'commit_rate']
fields = ['id', 'cid', 'description', 'install_date', 'commit_rate']
def search(self, queryset, name, value):
if not value.strip():
@@ -198,7 +198,7 @@ class CircuitFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilte
).distinct()
class CircuitTerminationFilterSet(NetBoxModelFilterSet, CabledObjectFilterSet):
class CircuitTerminationFilterSet(ChangeLoggedModelFilterSet, CableTerminationFilterSet):
q = django_filters.CharFilter(
method='search',
label='Search',
@@ -224,7 +224,7 @@ class CircuitTerminationFilterSet(NetBoxModelFilterSet, CabledObjectFilterSet):
class Meta:
model = CircuitTermination
fields = ['id', 'term_side', 'port_speed', 'upstream_speed', 'xconnect_id', 'description', 'cable_end']
fields = ['id', 'term_side', 'port_speed', 'upstream_speed', 'xconnect_id', 'description']
def search(self, queryset, name, value):
if not value.strip():

View File

@@ -7,7 +7,7 @@ from ipam.models import ASN
from netbox.forms import NetBoxModelBulkEditForm
from tenancy.models import Tenant
from utilities.forms import (
add_blank_choice, CommentField, DatePicker, DynamicModelChoiceField, DynamicModelMultipleChoiceField, SmallTextarea,
add_blank_choice, CommentField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, SmallTextarea,
StaticSelect,
)
@@ -122,14 +122,6 @@ class CircuitBulkEditForm(NetBoxModelBulkEditForm):
queryset=Tenant.objects.all(),
required=False
)
install_date = forms.DateField(
required=False,
widget=DatePicker()
)
termination_date = forms.DateField(
required=False,
widget=DatePicker()
)
commit_rate = forms.IntegerField(
required=False,
label='Commit rate (Kbps)'
@@ -145,9 +137,7 @@ class CircuitBulkEditForm(NetBoxModelBulkEditForm):
model = Circuit
fieldsets = (
('Circuit', ('provider', 'type', 'status', 'description')),
('Service Parameters', ('install_date', 'termination_date', 'commit_rate')),
('Tenancy', ('tenant',)),
(None, ('type', 'provider', 'status', 'tenant', 'commit_rate', 'description')),
)
nullable_fields = (
'tenant', 'commit_rate', 'description', 'comments',

View File

@@ -72,6 +72,5 @@ class CircuitCSVForm(NetBoxModelCSVForm):
class Meta:
model = Circuit
fields = [
'cid', 'provider', 'type', 'status', 'tenant', 'install_date', 'termination_date', 'commit_rate',
'description', 'comments',
'cid', 'provider', 'type', 'status', 'tenant', 'install_date', 'commit_rate', 'description', 'comments',
]

View File

@@ -7,7 +7,7 @@ from dcim.models import Region, Site, SiteGroup
from ipam.models import ASN
from netbox.forms import NetBoxModelFilterSetForm
from tenancy.forms import TenancyFilterForm, ContactModelFilterForm
from utilities.forms import DatePicker, DynamicModelMultipleChoiceField, MultipleChoiceField, TagFilterField
from utilities.forms import DynamicModelMultipleChoiceField, MultipleChoiceField, TagFilterField
__all__ = (
'CircuitFilterForm',
@@ -84,7 +84,7 @@ class CircuitFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFi
fieldsets = (
(None, ('q', 'tag')),
('Provider', ('provider_id', 'provider_network_id')),
('Attributes', ('type_id', 'status', 'install_date', 'termination_date', 'commit_rate')),
('Attributes', ('type_id', 'status', 'commit_rate')),
('Location', ('region_id', 'site_group_id', 'site_id')),
('Tenant', ('tenant_group_id', 'tenant_id')),
('Contacts', ('contact', 'contact_role', 'contact_group')),
@@ -130,14 +130,6 @@ class CircuitFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFi
},
label=_('Site')
)
install_date = forms.DateField(
required=False,
widget=DatePicker
)
termination_date = forms.DateField(
required=False,
widget=DatePicker
)
commit_rate = forms.IntegerField(
required=False,
min_value=0,

View File

@@ -93,16 +93,15 @@ class CircuitForm(TenancyForm, NetBoxModelForm):
comments = CommentField()
fieldsets = (
('Circuit', ('provider', 'cid', 'type', 'status', 'description', 'tags')),
('Service Parameters', ('install_date', 'termination_date', 'commit_rate')),
('Circuit', ('provider', 'cid', 'type', 'status', 'install_date', 'commit_rate', 'description', 'tags')),
('Tenancy', ('tenant_group', 'tenant')),
)
class Meta:
model = Circuit
fields = [
'cid', 'type', 'provider', 'status', 'install_date', 'termination_date', 'commit_rate', 'description',
'tenant_group', 'tenant', 'comments', 'tags',
'cid', 'type', 'provider', 'status', 'install_date', 'commit_rate', 'description', 'tenant_group', 'tenant',
'comments', 'tags',
]
help_texts = {
'cid': "Unique circuit ID",
@@ -111,12 +110,11 @@ class CircuitForm(TenancyForm, NetBoxModelForm):
widgets = {
'status': StaticSelect(),
'install_date': DatePicker(),
'termination_date': DatePicker(),
'commit_rate': SelectSpeedWidget(),
}
class CircuitTerminationForm(NetBoxModelForm):
class CircuitTerminationForm(BootstrapMixin, forms.ModelForm):
provider = DynamicModelChoiceField(
queryset=Provider.objects.all(),
required=False,
@@ -161,7 +159,7 @@ class CircuitTerminationForm(NetBoxModelForm):
model = CircuitTermination
fields = [
'provider', 'circuit', 'term_side', 'region', 'site_group', 'site', 'provider_network', 'mark_connected',
'port_speed', 'upstream_speed', 'xconnect_id', 'pp_info', 'description', 'tags',
'port_speed', 'upstream_speed', 'xconnect_id', 'pp_info', 'description',
]
help_texts = {
'port_speed': "Physical circuit speed",

View File

@@ -1,6 +1,4 @@
from circuits import filtersets, models
from dcim.graphql.mixins import CabledObjectMixin
from extras.graphql.mixins import CustomFieldsMixin, TagsMixin
from netbox.graphql.types import ObjectType, OrganizationalObjectType, NetBoxObjectType
__all__ = (
@@ -12,7 +10,7 @@ __all__ = (
)
class CircuitTerminationType(CustomFieldsMixin, TagsMixin, CabledObjectMixin, ObjectType):
class CircuitTerminationType(ObjectType):
class Meta:
model = models.CircuitTermination

View File

@@ -1,28 +0,0 @@
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'),
),
]

View File

@@ -1,16 +0,0 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('circuits', '0036_circuit_termination_date_tags_custom_fields'),
]
operations = [
migrations.AddField(
model_name='circuittermination',
name='cable_end',
field=models.CharField(blank=True, max_length=1),
),
]

View File

@@ -1,20 +0,0 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('circuits', '0037_new_cabling_models'),
('dcim', '0160_populate_cable_ends'),
]
operations = [
migrations.RemoveField(
model_name='circuittermination',
name='_link_peer_id',
),
migrations.RemoveField(
model_name='circuittermination',
name='_link_peer_type',
),
]

View File

@@ -4,10 +4,8 @@ from django.db import models
from django.urls import reverse
from circuits.choices import *
from dcim.models import CabledObjectModel
from netbox.models import (
ChangeLoggedModel, CustomFieldsMixin, CustomLinksMixin, OrganizationalModel, NetBoxModel, TagsMixin,
)
from dcim.models import LinkTermination
from netbox.models import ChangeLoggedModel, OrganizationalModel, NetBoxModel
from netbox.models.features import WebhooksMixin
__all__ = (
@@ -80,12 +78,7 @@ class Circuit(NetBoxModel):
install_date = models.DateField(
blank=True,
null=True,
verbose_name='Installed'
)
termination_date = models.DateField(
blank=True,
null=True,
verbose_name='Terminates'
verbose_name='Date installed'
)
commit_rate = models.PositiveIntegerField(
blank=True,
@@ -126,7 +119,7 @@ class Circuit(NetBoxModel):
)
clone_fields = [
'provider', 'type', 'status', 'tenant', 'install_date', 'termination_date', 'commit_rate', 'description',
'provider', 'type', 'status', 'tenant', 'install_date', 'commit_rate', 'description',
]
class Meta:
@@ -143,14 +136,7 @@ class Circuit(NetBoxModel):
return CircuitStatusChoices.colors.get(self.status)
class CircuitTermination(
CustomFieldsMixin,
CustomLinksMixin,
TagsMixin,
WebhooksMixin,
ChangeLoggedModel,
CabledObjectModel
):
class CircuitTermination(WebhooksMixin, ChangeLoggedModel, LinkTermination):
circuit = models.ForeignKey(
to='circuits.Circuit',
on_delete=models.CASCADE,

View File

@@ -24,4 +24,4 @@ def rebuild_cablepaths(instance, raw=False, **kwargs):
if not raw:
peer_termination = instance.get_peer_termination()
if peer_termination:
rebuild_paths([peer_termination])
rebuild_paths(peer_termination)

View File

@@ -68,9 +68,8 @@ class CircuitTable(TenancyColumnsMixin, NetBoxTable):
class Meta(NetBoxTable.Meta):
model = Circuit
fields = (
'pk', 'id', 'cid', 'provider', 'type', 'status', 'tenant', 'tenant_group', 'termination_a', 'termination_z',
'install_date', 'termination_date', 'commit_rate', 'description', 'comments', 'contacts', 'tags', 'created',
'last_updated',
'pk', 'id', 'cid', 'provider', 'type', 'status', 'tenant', 'tenant_group', 'termination_a', 'termination_z', 'install_date',
'commit_rate', 'description', 'comments', 'contacts', 'tags', 'created', 'last_updated',
)
default_columns = (
'pk', 'cid', 'provider', 'type', 'status', 'tenant', 'termination_a', 'termination_z', 'description',

View File

@@ -208,12 +208,12 @@ class CircuitTestCase(TestCase, ChangeLoggedFilterSetTests):
ProviderNetwork.objects.bulk_create(provider_networks)
circuits = (
Circuit(provider=providers[0], tenant=tenants[0], type=circuit_types[0], cid='Test Circuit 1', install_date='2020-01-01', termination_date='2021-01-01', commit_rate=1000, status=CircuitStatusChoices.STATUS_ACTIVE, description='foobar1'),
Circuit(provider=providers[0], tenant=tenants[0], type=circuit_types[0], cid='Test Circuit 2', install_date='2020-01-02', termination_date='2021-01-02', commit_rate=2000, status=CircuitStatusChoices.STATUS_ACTIVE, description='foobar2'),
Circuit(provider=providers[0], tenant=tenants[1], type=circuit_types[0], cid='Test Circuit 3', install_date='2020-01-03', termination_date='2021-01-03', commit_rate=3000, status=CircuitStatusChoices.STATUS_PLANNED),
Circuit(provider=providers[1], tenant=tenants[1], type=circuit_types[1], cid='Test Circuit 4', install_date='2020-01-04', termination_date='2021-01-04', commit_rate=4000, status=CircuitStatusChoices.STATUS_PLANNED),
Circuit(provider=providers[1], tenant=tenants[2], type=circuit_types[1], cid='Test Circuit 5', install_date='2020-01-05', termination_date='2021-01-05', commit_rate=5000, status=CircuitStatusChoices.STATUS_OFFLINE),
Circuit(provider=providers[1], tenant=tenants[2], type=circuit_types[1], cid='Test Circuit 6', install_date='2020-01-06', termination_date='2021-01-06', commit_rate=6000, status=CircuitStatusChoices.STATUS_OFFLINE),
Circuit(provider=providers[0], tenant=tenants[0], type=circuit_types[0], cid='Test Circuit 1', install_date='2020-01-01', commit_rate=1000, status=CircuitStatusChoices.STATUS_ACTIVE, description='foobar1'),
Circuit(provider=providers[0], tenant=tenants[0], type=circuit_types[0], cid='Test Circuit 2', install_date='2020-01-02', commit_rate=2000, status=CircuitStatusChoices.STATUS_ACTIVE, description='foobar2'),
Circuit(provider=providers[0], tenant=tenants[1], type=circuit_types[0], cid='Test Circuit 3', install_date='2020-01-03', commit_rate=3000, status=CircuitStatusChoices.STATUS_PLANNED),
Circuit(provider=providers[1], tenant=tenants[1], type=circuit_types[1], cid='Test Circuit 4', install_date='2020-01-04', commit_rate=4000, status=CircuitStatusChoices.STATUS_PLANNED),
Circuit(provider=providers[1], tenant=tenants[2], type=circuit_types[1], cid='Test Circuit 5', install_date='2020-01-05', commit_rate=5000, status=CircuitStatusChoices.STATUS_OFFLINE),
Circuit(provider=providers[1], tenant=tenants[2], type=circuit_types[1], cid='Test Circuit 6', install_date='2020-01-06', commit_rate=6000, status=CircuitStatusChoices.STATUS_OFFLINE),
)
Circuit.objects.bulk_create(circuits)
@@ -235,10 +235,6 @@ class CircuitTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'install_date': ['2020-01-01', '2020-01-02']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_termination_date(self):
params = {'termination_date': ['2021-01-01', '2021-01-02']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_commit_rate(self):
params = {'commit_rate': ['1000', '2000']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
@@ -360,7 +356,7 @@ class CircuitTerminationTestCase(TestCase, ChangeLoggedFilterSetTests):
))
CircuitTermination.objects.bulk_create(circuit_terminations)
Cable(a_terminations=[circuit_terminations[0]], b_terminations=[circuit_terminations[1]]).save()
Cable(termination_a=circuit_terminations[0], termination_b=circuit_terminations[1]).save()
def test_term_side(self):
params = {'term_side': 'A'}

View File

@@ -130,7 +130,6 @@ class CircuitTestCase(ViewTestCases.PrimaryObjectViewTestCase):
'status': CircuitStatusChoices.STATUS_DECOMMISSIONED,
'tenant': None,
'install_date': datetime.date(2020, 1, 1),
'termination_date': datetime.date(2021, 1, 1),
'commit_rate': 1000,
'description': 'A new circuit',
'comments': 'Some comments',
@@ -246,7 +245,7 @@ class CircuitTerminationTestCase(
device=device,
name='Interface 1'
)
Cable(a_terminations=[circuittermination], b_terminations=[interface]).save()
Cable(termination_a=circuittermination, termination_b=interface).save()
response = self.client.get(reverse('circuits:circuittermination_trace', kwargs={'pk': circuittermination.pk}))
self.assertHttpStatus(response, 200)

View File

@@ -1,6 +1,6 @@
from django.urls import path
from dcim.views import PathTraceView
from dcim.views import CableCreateView, PathTraceView
from netbox.views.generic import ObjectChangeLogView, ObjectJournalView
from . import views
from .models import *
@@ -60,6 +60,7 @@ urlpatterns = [
path('circuit-terminations/add/', views.CircuitTerminationEditView.as_view(), name='circuittermination_add'),
path('circuit-terminations/<int:pk>/edit/', views.CircuitTerminationEditView.as_view(), name='circuittermination_edit'),
path('circuit-terminations/<int:pk>/delete/', views.CircuitTerminationDeleteView.as_view(), name='circuittermination_delete'),
path('circuit-terminations/<int:termination_a_id>/connect/<str:termination_b_type>/', CableCreateView.as_view(), name='circuittermination_connect', kwargs={'termination_a_type': CircuitTermination}),
path('circuit-terminations/<int:pk>/trace/', PathTraceView.as_view(), name='circuittermination_trace', kwargs={'model': CircuitTermination}),
]

View File

@@ -30,8 +30,7 @@ class ProviderView(generic.ObjectView):
circuits = Circuit.objects.restrict(request.user, 'view').filter(
provider=instance
).prefetch_related(
'tenant__group', 'termination_a__site', 'termination_z__site',
'termination_a__provider_network', 'termination_z__provider_network',
'type', 'tenant', 'tenant__group', 'terminations__site'
)
circuits_table = tables.CircuitTable(circuits, user=request.user, exclude=('provider',))
circuits_table.configure(request)
@@ -92,8 +91,7 @@ class ProviderNetworkView(generic.ObjectView):
Q(termination_a__provider_network=instance.pk) |
Q(termination_z__provider_network=instance.pk)
).prefetch_related(
'tenant__group', 'termination_a__site', 'termination_z__site',
'termination_a__provider_network', 'termination_z__provider_network',
'type', 'tenant', 'tenant__group', 'terminations__site'
)
circuits_table = tables.CircuitTable(circuits, user=request.user)
circuits_table.configure(request)
@@ -194,8 +192,7 @@ class CircuitTypeBulkDeleteView(generic.BulkDeleteView):
class CircuitListView(generic.ObjectListView):
queryset = Circuit.objects.prefetch_related(
'tenant__group', 'termination_a__site', 'termination_z__site',
'termination_a__provider_network', 'termination_z__provider_network',
'provider', 'type', 'tenant', 'tenant__group', 'termination_a', 'termination_z'
)
filterset = filtersets.CircuitFilterSet
filterset_form = forms.CircuitFilterForm
@@ -223,8 +220,7 @@ class CircuitBulkImportView(generic.BulkImportView):
class CircuitBulkEditView(generic.BulkEditView):
queryset = Circuit.objects.prefetch_related(
'termination_a__site', 'termination_z__site',
'termination_a__provider_network', 'termination_z__provider_network',
'provider', 'type', 'tenant', 'terminations'
)
filterset = filtersets.CircuitFilterSet
table = tables.CircuitTable
@@ -233,8 +229,7 @@ class CircuitBulkEditView(generic.BulkEditView):
class CircuitBulkDeleteView(generic.BulkDeleteView):
queryset = Circuit.objects.prefetch_related(
'termination_a__site', 'termination_z__site',
'termination_a__provider_network', 'termination_z__provider_network',
'provider', 'type', 'tenant', 'terminations'
)
filterset = filtersets.CircuitFilterSet
table = tables.CircuitTable

View File

@@ -1,5 +1,3 @@
import decimal
from django.contrib.contenttypes.models import ContentType
from drf_yasg.utils import swagger_serializer_method
from rest_framework import serializers
@@ -9,14 +7,12 @@ from dcim.choices import *
from dcim.constants import *
from dcim.models import *
from ipam.api.nested_serializers import (
NestedASNSerializer, NestedIPAddressSerializer, NestedL2VPNTerminationSerializer, NestedVLANSerializer,
NestedVRFSerializer,
NestedASNSerializer, NestedIPAddressSerializer, NestedVLANSerializer, NestedVRFSerializer,
)
from ipam.models import ASN, VLAN
from netbox.api.fields import ChoiceField, ContentTypeField, SerializedPKRelatedField
from netbox.api import ChoiceField, ContentTypeField, SerializedPKRelatedField
from netbox.api.serializers import (
GenericObjectSerializer, NestedGroupModelSerializer, NetBoxModelSerializer, ValidatedModelSerializer,
WritableNestedSerializer,
NestedGroupModelSerializer, NetBoxModelSerializer, ValidatedModelSerializer, WritableNestedSerializer,
)
from netbox.config import ConfigItem
from netbox.constants import NESTED_SERIALIZER_PREFIX
@@ -30,68 +26,58 @@ from wireless.models import WirelessLAN
from .nested_serializers import *
class CabledObjectSerializer(serializers.ModelSerializer):
cable = NestedCableSerializer(read_only=True)
cable_end = serializers.CharField(read_only=True)
link_peers_type = serializers.SerializerMethodField(read_only=True)
link_peers = serializers.SerializerMethodField(read_only=True)
class LinkTerminationSerializer(serializers.ModelSerializer):
link_peer_type = serializers.SerializerMethodField(read_only=True)
link_peer = serializers.SerializerMethodField(read_only=True)
_occupied = serializers.SerializerMethodField(read_only=True)
def get_link_peers_type(self, obj):
"""
Return the type of the peer link terminations, or None.
"""
if not obj.cable:
return None
if obj.link_peers:
return f'{obj.link_peers[0]._meta.app_label}.{obj.link_peers[0]._meta.model_name}'
def get_link_peer_type(self, obj):
if obj._link_peer is not None:
return f'{obj._link_peer._meta.app_label}.{obj._link_peer._meta.model_name}'
return None
@swagger_serializer_method(serializer_or_field=serializers.ListField)
def get_link_peers(self, obj):
@swagger_serializer_method(serializer_or_field=serializers.DictField)
def get_link_peer(self, obj):
"""
Return the appropriate serializer for the link termination model.
"""
if not obj.link_peers:
return []
# Return serialized peer termination objects
serializer = get_serializer_for_model(obj.link_peers[0], prefix=NESTED_SERIALIZER_PREFIX)
context = {'request': self.context['request']}
return serializer(obj.link_peers, context=context, many=True).data
if obj._link_peer is not None:
serializer = get_serializer_for_model(obj._link_peer, prefix=NESTED_SERIALIZER_PREFIX)
context = {'request': self.context['request']}
return serializer(obj._link_peer, context=context).data
return None
@swagger_serializer_method(serializer_or_field=serializers.BooleanField)
def get__occupied(self, obj):
return obj._occupied
class ConnectedEndpointsSerializer(serializers.ModelSerializer):
"""
Legacy serializer for pre-v3.3 connections
"""
connected_endpoints_type = serializers.SerializerMethodField(read_only=True)
connected_endpoints = serializers.SerializerMethodField(read_only=True)
connected_endpoints_reachable = serializers.SerializerMethodField(read_only=True)
class ConnectedEndpointSerializer(serializers.ModelSerializer):
connected_endpoint_type = serializers.SerializerMethodField(read_only=True)
connected_endpoint = serializers.SerializerMethodField(read_only=True)
connected_endpoint_reachable = serializers.SerializerMethodField(read_only=True)
def get_connected_endpoints_type(self, obj):
if endpoints := obj.connected_endpoints:
return f'{endpoints[0]._meta.app_label}.{endpoints[0]._meta.model_name}'
def get_connected_endpoint_type(self, obj):
if obj._path is not None and obj._path.destination is not None:
return f'{obj._path.destination._meta.app_label}.{obj._path.destination._meta.model_name}'
return None
@swagger_serializer_method(serializer_or_field=serializers.ListField)
def get_connected_endpoints(self, obj):
@swagger_serializer_method(serializer_or_field=serializers.DictField)
def get_connected_endpoint(self, obj):
"""
Return the appropriate serializer for the type of connected object.
"""
if endpoints := obj.connected_endpoints:
serializer = get_serializer_for_model(endpoints[0], prefix=NESTED_SERIALIZER_PREFIX)
if obj._path is not None and obj._path.destination is not None:
serializer = get_serializer_for_model(obj._path.destination, prefix=NESTED_SERIALIZER_PREFIX)
context = {'request': self.context['request']}
return serializer(endpoints, many=True, context=context).data
return serializer(obj._path.destination, context=context).data
return None
@swagger_serializer_method(serializer_or_field=serializers.BooleanField)
def get_connected_endpoints_reachable(self, obj):
return obj._path and obj._path.is_complete and obj._path.is_active
def get_connected_endpoint_reachable(self, obj):
if obj._path is not None:
return obj._path.is_active
return None
#
@@ -164,7 +150,6 @@ class LocationSerializer(NestedGroupModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:location-detail')
site = NestedSiteSerializer()
parent = NestedLocationSerializer(required=False, allow_null=True)
status = ChoiceField(choices=LocationStatusChoices, required=False)
tenant = NestedTenantSerializer(required=False, allow_null=True)
rack_count = serializers.IntegerField(read_only=True)
device_count = serializers.IntegerField(read_only=True)
@@ -172,8 +157,8 @@ class LocationSerializer(NestedGroupModelSerializer):
class Meta:
model = Location
fields = [
'id', 'url', 'display', 'name', 'slug', 'site', 'parent', 'status', 'tenant', 'description', 'tags',
'custom_fields', 'created', 'last_updated', 'rack_count', 'device_count', '_depth',
'id', 'url', 'display', 'name', 'slug', 'site', 'parent', 'tenant', 'description', 'tags', 'custom_fields',
'created', 'last_updated', 'rack_count', 'device_count', '_depth',
]
@@ -217,11 +202,7 @@ class RackUnitSerializer(serializers.Serializer):
"""
A rack unit is an abstraction formed by the set (rack, position, face); it does not exist as a row in the database.
"""
id = serializers.DecimalField(
max_digits=4,
decimal_places=1,
read_only=True
)
id = serializers.IntegerField(read_only=True)
name = serializers.CharField(read_only=True)
face = ChoiceField(choices=DeviceFaceChoices, read_only=True)
device = NestedDeviceSerializer(read_only=True)
@@ -266,10 +247,7 @@ class RackElevationDetailFilterSerializer(serializers.Serializer):
default=ConfigItem('RACK_ELEVATION_DEFAULT_UNIT_HEIGHT')
)
legend_width = serializers.IntegerField(
default=RACK_ELEVATION_DEFAULT_LEGEND_WIDTH
)
margin_width = serializers.IntegerField(
default=RACK_ELEVATION_DEFAULT_MARGIN_WIDTH
default=RACK_ELEVATION_LEGEND_WIDTH_DEFAULT
)
exclude = serializers.IntegerField(
required=False,
@@ -306,13 +284,6 @@ class ManufacturerSerializer(NetBoxModelSerializer):
class DeviceTypeSerializer(NetBoxModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicetype-detail')
manufacturer = NestedManufacturerSerializer()
u_height = serializers.DecimalField(
max_digits=4,
decimal_places=1,
label='Position (U)',
min_value=decimal.Decimal(0.5),
default=1.0
)
subdevice_role = ChoiceField(choices=SubdeviceRoleChoices, allow_blank=True, required=False)
airflow = ChoiceField(choices=DeviceAirflowChoices, allow_blank=True, required=False)
device_count = serializers.IntegerField(read_only=True)
@@ -469,22 +440,12 @@ class InterfaceTemplateSerializer(ValidatedModelSerializer):
default=None
)
type = ChoiceField(choices=InterfaceTypeChoices)
poe_mode = ChoiceField(
choices=InterfacePoEModeChoices,
required=False,
allow_blank=True
)
poe_type = ChoiceField(
choices=InterfacePoETypeChoices,
required=False,
allow_blank=True
)
class Meta:
model = InterfaceTemplate
fields = [
'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'mgmt_only', 'description',
'poe_mode', 'poe_type', 'created', 'last_updated',
'created', 'last_updated',
]
@@ -629,14 +590,7 @@ class DeviceSerializer(NetBoxModelSerializer):
location = NestedLocationSerializer(required=False, allow_null=True, default=None)
rack = NestedRackSerializer(required=False, allow_null=True, default=None)
face = ChoiceField(choices=DeviceFaceChoices, allow_blank=True, default='')
position = serializers.DecimalField(
max_digits=4,
decimal_places=1,
allow_null=True,
label='Position (U)',
min_value=decimal.Decimal(0.5),
default=None
)
position = serializers.IntegerField(allow_null=True, label='Position (U)', min_value=1, default=None)
status = ChoiceField(choices=DeviceStatusChoices, required=False)
airflow = ChoiceField(choices=DeviceAirflowChoices, allow_blank=True, required=False)
primary_ip = NestedIPAddressSerializer(read_only=True)
@@ -706,7 +660,7 @@ class DeviceNAPALMSerializer(serializers.Serializer):
# Device components
#
class ConsoleServerPortSerializer(NetBoxModelSerializer, CabledObjectSerializer, ConnectedEndpointsSerializer):
class ConsoleServerPortSerializer(NetBoxModelSerializer, LinkTerminationSerializer, ConnectedEndpointSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:consoleserverport-detail')
device = NestedDeviceSerializer()
module = ComponentNestedModuleSerializer(
@@ -723,18 +677,18 @@ class ConsoleServerPortSerializer(NetBoxModelSerializer, CabledObjectSerializer,
allow_null=True,
required=False
)
cable = NestedCableSerializer(read_only=True)
class Meta:
model = ConsoleServerPort
fields = [
'id', 'url', 'display', 'device', 'module', 'name', 'label', 'type', 'speed', 'description',
'mark_connected', 'cable', 'cable_end', 'link_peers', 'link_peers_type', 'connected_endpoints',
'connected_endpoints_type', 'connected_endpoints_reachable', 'tags', 'custom_fields', 'created',
'last_updated', '_occupied',
'mark_connected', 'cable', 'link_peer', 'link_peer_type', 'connected_endpoint', 'connected_endpoint_type',
'connected_endpoint_reachable', 'tags', 'custom_fields', 'created', 'last_updated', '_occupied',
]
class ConsolePortSerializer(NetBoxModelSerializer, CabledObjectSerializer, ConnectedEndpointsSerializer):
class ConsolePortSerializer(NetBoxModelSerializer, LinkTerminationSerializer, ConnectedEndpointSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:consoleport-detail')
device = NestedDeviceSerializer()
module = ComponentNestedModuleSerializer(
@@ -751,18 +705,18 @@ class ConsolePortSerializer(NetBoxModelSerializer, CabledObjectSerializer, Conne
allow_null=True,
required=False
)
cable = NestedCableSerializer(read_only=True)
class Meta:
model = ConsolePort
fields = [
'id', 'url', 'display', 'device', 'module', 'name', 'label', 'type', 'speed', 'description',
'mark_connected', 'cable', 'cable_end', 'link_peers', 'link_peers_type', 'connected_endpoints',
'connected_endpoints_type', 'connected_endpoints_reachable', 'tags', 'custom_fields', 'created',
'last_updated', '_occupied',
'mark_connected', 'cable', 'link_peer', 'link_peer_type', 'connected_endpoint', 'connected_endpoint_type',
'connected_endpoint_reachable', 'tags', 'custom_fields', 'created', 'last_updated', '_occupied',
]
class PowerOutletSerializer(NetBoxModelSerializer, CabledObjectSerializer, ConnectedEndpointsSerializer):
class PowerOutletSerializer(NetBoxModelSerializer, LinkTerminationSerializer, ConnectedEndpointSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:poweroutlet-detail')
device = NestedDeviceSerializer()
module = ComponentNestedModuleSerializer(
@@ -783,18 +737,21 @@ class PowerOutletSerializer(NetBoxModelSerializer, CabledObjectSerializer, Conne
allow_blank=True,
required=False
)
cable = NestedCableSerializer(
read_only=True
)
class Meta:
model = PowerOutlet
fields = [
'id', 'url', 'display', 'device', 'module', 'name', 'label', 'type', 'power_port', 'feed_leg',
'description', 'mark_connected', 'cable', 'cable_end', 'link_peers', 'link_peers_type',
'connected_endpoints', 'connected_endpoints_type', 'connected_endpoints_reachable', 'tags', 'custom_fields',
'created', 'last_updated', '_occupied',
'description', 'mark_connected', 'cable', 'link_peer', 'link_peer_type', 'connected_endpoint',
'connected_endpoint_type', 'connected_endpoint_reachable', 'tags', 'custom_fields', 'created',
'last_updated', '_occupied',
]
class PowerPortSerializer(NetBoxModelSerializer, CabledObjectSerializer, ConnectedEndpointsSerializer):
class PowerPortSerializer(NetBoxModelSerializer, LinkTerminationSerializer, ConnectedEndpointSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:powerport-detail')
device = NestedDeviceSerializer()
module = ComponentNestedModuleSerializer(
@@ -806,18 +763,19 @@ class PowerPortSerializer(NetBoxModelSerializer, CabledObjectSerializer, Connect
allow_blank=True,
required=False
)
cable = NestedCableSerializer(read_only=True)
class Meta:
model = PowerPort
fields = [
'id', 'url', 'display', 'device', 'module', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw',
'description', 'mark_connected', 'cable', 'cable_end', 'link_peers', 'link_peers_type',
'connected_endpoints', 'connected_endpoints_type', 'connected_endpoints_reachable', 'tags', 'custom_fields',
'created', 'last_updated', '_occupied',
'description', 'mark_connected', 'cable', 'link_peer', 'link_peer_type', 'connected_endpoint',
'connected_endpoint_type', 'connected_endpoint_reachable', 'tags', 'custom_fields', 'created',
'last_updated', '_occupied',
]
class InterfaceSerializer(NetBoxModelSerializer, CabledObjectSerializer, ConnectedEndpointsSerializer):
class InterfaceSerializer(NetBoxModelSerializer, LinkTerminationSerializer, ConnectedEndpointSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:interface-detail')
device = NestedDeviceSerializer()
module = ComponentNestedModuleSerializer(
@@ -832,8 +790,6 @@ class InterfaceSerializer(NetBoxModelSerializer, CabledObjectSerializer, Connect
duplex = ChoiceField(choices=InterfaceDuplexChoices, required=False, allow_blank=True)
rf_role = ChoiceField(choices=WirelessRoleChoices, required=False, allow_blank=True)
rf_channel = ChoiceField(choices=WirelessChannelChoices, required=False, allow_blank=True)
poe_mode = ChoiceField(choices=InterfacePoEModeChoices, required=False, allow_blank=True)
poe_type = ChoiceField(choices=InterfacePoETypeChoices, required=False, allow_blank=True)
untagged_vlan = NestedVLANSerializer(required=False, allow_null=True)
tagged_vlans = SerializedPKRelatedField(
queryset=VLAN.objects.all(),
@@ -842,7 +798,7 @@ class InterfaceSerializer(NetBoxModelSerializer, CabledObjectSerializer, Connect
many=True
)
vrf = NestedVRFSerializer(required=False, allow_null=True)
l2vpn_termination = NestedL2VPNTerminationSerializer(read_only=True)
cable = NestedCableSerializer(read_only=True)
wireless_link = NestedWirelessLinkSerializer(read_only=True)
wireless_lans = SerializedPKRelatedField(
queryset=WirelessLAN.objects.all(),
@@ -858,11 +814,10 @@ class InterfaceSerializer(NetBoxModelSerializer, CabledObjectSerializer, Connect
fields = [
'id', 'url', 'display', 'device', 'module', 'name', 'label', 'type', 'enabled', 'parent', 'bridge', 'lag',
'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',
'tagged_vlans', 'mark_connected', 'cable', 'cable_end', 'wireless_link', 'link_peers', 'link_peers_type',
'wireless_lans', 'vrf', 'l2vpn_termination', 'connected_endpoints', 'connected_endpoints_type',
'connected_endpoints_reachable', 'tags', 'custom_fields', 'created', 'last_updated', 'count_ipaddresses',
'count_fhrp_groups', '_occupied',
'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'untagged_vlan', 'tagged_vlans', 'mark_connected',
'cable', 'wireless_link', 'link_peer', 'link_peer_type', 'wireless_lans', 'vrf', 'connected_endpoint',
'connected_endpoint_type', 'connected_endpoint_reachable', 'tags', 'custom_fields', 'created',
'last_updated', 'count_ipaddresses', 'count_fhrp_groups', '_occupied',
]
def validate(self, data):
@@ -879,7 +834,7 @@ class InterfaceSerializer(NetBoxModelSerializer, CabledObjectSerializer, Connect
return super().validate(data)
class RearPortSerializer(NetBoxModelSerializer, CabledObjectSerializer):
class RearPortSerializer(NetBoxModelSerializer, LinkTerminationSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rearport-detail')
device = NestedDeviceSerializer()
module = ComponentNestedModuleSerializer(
@@ -887,12 +842,13 @@ class RearPortSerializer(NetBoxModelSerializer, CabledObjectSerializer):
allow_null=True
)
type = ChoiceField(choices=PortTypeChoices)
cable = NestedCableSerializer(read_only=True)
class Meta:
model = RearPort
fields = [
'id', 'url', 'display', 'device', 'module', 'name', 'label', 'type', 'color', 'positions', 'description',
'mark_connected', 'cable', 'cable_end', 'link_peers', 'link_peers_type', 'tags', 'custom_fields', 'created',
'mark_connected', 'cable', 'link_peer', 'link_peer_type', 'tags', 'custom_fields', 'created',
'last_updated', '_occupied',
]
@@ -908,7 +864,7 @@ class FrontPortRearPortSerializer(WritableNestedSerializer):
fields = ['id', 'url', 'display', 'name', 'label']
class FrontPortSerializer(NetBoxModelSerializer, CabledObjectSerializer):
class FrontPortSerializer(NetBoxModelSerializer, LinkTerminationSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:frontport-detail')
device = NestedDeviceSerializer()
module = ComponentNestedModuleSerializer(
@@ -917,13 +873,14 @@ class FrontPortSerializer(NetBoxModelSerializer, CabledObjectSerializer):
)
type = ChoiceField(choices=PortTypeChoices)
rear_port = FrontPortRearPortSerializer()
cable = NestedCableSerializer(read_only=True)
class Meta:
model = FrontPort
fields = [
'id', 'url', 'display', 'device', 'module', 'name', 'label', 'type', 'color', 'rear_port',
'rear_port_position', 'description', 'mark_connected', 'cable', 'cable_end', 'link_peers',
'link_peers_type', 'tags', 'custom_fields', 'created', 'last_updated', '_occupied',
'rear_port_position', 'description', 'mark_connected', 'cable', 'link_peer', 'link_peer_type', 'tags',
'custom_fields', 'created', 'last_updated', '_occupied',
]
@@ -1006,8 +963,14 @@ class InventoryItemRoleSerializer(NetBoxModelSerializer):
class CableSerializer(NetBoxModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:cable-detail')
a_terminations = GenericObjectSerializer(many=True, required=False)
b_terminations = GenericObjectSerializer(many=True, required=False)
termination_a_type = ContentTypeField(
queryset=ContentType.objects.filter(CABLE_TERMINATION_MODELS)
)
termination_b_type = ContentTypeField(
queryset=ContentType.objects.filter(CABLE_TERMINATION_MODELS)
)
termination_a = serializers.SerializerMethodField(read_only=True)
termination_b = serializers.SerializerMethodField(read_only=True)
status = ChoiceField(choices=LinkStatusChoices, required=False)
tenant = NestedTenantSerializer(required=False, allow_null=True)
length_unit = ChoiceField(choices=CableLengthUnitChoices, allow_blank=True, required=False)
@@ -1015,10 +978,34 @@ class CableSerializer(NetBoxModelSerializer):
class Meta:
model = Cable
fields = [
'id', 'url', 'display', 'type', 'a_terminations', 'b_terminations', 'status', 'tenant', 'label', 'color',
'length', 'length_unit', 'tags', 'custom_fields', 'created', 'last_updated',
'id', 'url', 'display', 'termination_a_type', 'termination_a_id', 'termination_a', 'termination_b_type',
'termination_b_id', 'termination_b', 'type', 'status', 'tenant', 'label', 'color', 'length', 'length_unit',
'tags', 'custom_fields', 'created', 'last_updated',
]
def _get_termination(self, obj, side):
"""
Serialize a nested representation of a termination.
"""
if side.lower() not in ['a', 'b']:
raise ValueError("Termination side must be either A or B.")
termination = getattr(obj, 'termination_{}'.format(side.lower()))
if termination is None:
return None
serializer = get_serializer_for_model(termination, prefix=NESTED_SERIALIZER_PREFIX)
context = {'request': self.context['request']}
data = serializer(termination, context=context).data
return data
@swagger_serializer_method(serializer_or_field=serializers.DictField)
def get_termination_a(self, obj):
return self._get_termination(obj, 'a')
@swagger_serializer_method(serializer_or_field=serializers.DictField)
def get_termination_b(self, obj):
return self._get_termination(obj, 'b')
class TracedCableSerializer(serializers.ModelSerializer):
"""
@@ -1033,40 +1020,46 @@ class TracedCableSerializer(serializers.ModelSerializer):
]
class CableTerminationSerializer(NetBoxModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:cabletermination-detail')
termination_type = ContentTypeField(
queryset=ContentType.objects.filter(CABLE_TERMINATION_MODELS)
)
termination = serializers.SerializerMethodField(read_only=True)
class Meta:
model = CableTermination
fields = [
'id', 'url', 'display', 'cable', 'cable_end', 'termination_type', 'termination_id', 'termination'
]
@swagger_serializer_method(serializer_or_field=serializers.DictField)
def get_termination(self, obj):
serializer = get_serializer_for_model(obj.termination, prefix=NESTED_SERIALIZER_PREFIX)
context = {'request': self.context['request']}
return serializer(obj.termination, context=context).data
class CablePathSerializer(serializers.ModelSerializer):
origin_type = ContentTypeField(read_only=True)
origin = serializers.SerializerMethodField(read_only=True)
destination_type = ContentTypeField(read_only=True)
destination = serializers.SerializerMethodField(read_only=True)
path = serializers.SerializerMethodField(read_only=True)
class Meta:
model = CablePath
fields = ['id', 'path', 'is_active', 'is_complete', 'is_split']
fields = [
'id', 'origin_type', 'origin', 'destination_type', 'destination', 'path', 'is_active', 'is_split',
]
@swagger_serializer_method(serializer_or_field=serializers.DictField)
def get_origin(self, obj):
"""
Return the appropriate serializer for the origin.
"""
serializer = get_serializer_for_model(obj.origin, prefix=NESTED_SERIALIZER_PREFIX)
context = {'request': self.context['request']}
return serializer(obj.origin, context=context).data
@swagger_serializer_method(serializer_or_field=serializers.DictField)
def get_destination(self, obj):
"""
Return the appropriate serializer for the destination, if any.
"""
if obj.destination_id is not None:
serializer = get_serializer_for_model(obj.destination, prefix=NESTED_SERIALIZER_PREFIX)
context = {'request': self.context['request']}
return serializer(obj.destination, context=context).data
return None
@swagger_serializer_method(serializer_or_field=serializers.ListField)
def get_path(self, obj):
ret = []
for nodes in obj.path_objects:
serializer = get_serializer_for_model(nodes[0], prefix=NESTED_SERIALIZER_PREFIX)
for node in obj.get_path():
serializer = get_serializer_for_model(node, prefix=NESTED_SERIALIZER_PREFIX)
context = {'request': self.context['request']}
ret.append(serializer(nodes, context=context, many=True).data)
ret.append(serializer(node, context=context).data)
return ret
@@ -1109,7 +1102,7 @@ class PowerPanelSerializer(NetBoxModelSerializer):
]
class PowerFeedSerializer(NetBoxModelSerializer, CabledObjectSerializer, ConnectedEndpointsSerializer):
class PowerFeedSerializer(NetBoxModelSerializer, LinkTerminationSerializer, ConnectedEndpointSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:powerfeed-detail')
power_panel = NestedPowerPanelSerializer()
rack = NestedRackSerializer(
@@ -1133,12 +1126,13 @@ class PowerFeedSerializer(NetBoxModelSerializer, CabledObjectSerializer, Connect
choices=PowerFeedPhaseChoices,
default=PowerFeedPhaseChoices.PHASE_SINGLE
)
cable = NestedCableSerializer(read_only=True)
class Meta:
model = PowerFeed
fields = [
'id', 'url', 'display', 'power_panel', 'rack', 'name', 'status', 'type', 'supply', 'phase', 'voltage',
'amperage', 'max_utilization', 'comments', 'mark_connected', 'cable', 'cable_end', 'link_peers',
'link_peers_type', 'connected_endpoints', 'connected_endpoints_type', 'connected_endpoints_reachable',
'tags', 'custom_fields', 'created', 'last_updated', '_occupied',
'amperage', 'max_utilization', 'comments', 'mark_connected', 'cable', 'link_peer', 'link_peer_type',
'connected_endpoint', 'connected_endpoint_type', 'connected_endpoint_reachable', 'tags', 'custom_fields',
'created', 'last_updated', '_occupied',
]

View File

@@ -1,4 +1,4 @@
from netbox.api.routers import NetBoxRouter
from netbox.api import NetBoxRouter
from . import views
@@ -56,7 +56,6 @@ router.register('inventory-item-roles', views.InventoryItemRoleViewSet)
# Cables
router.register('cables', views.CableViewSet)
router.register('cable-terminations', views.CableTerminationViewSet)
# Virtual chassis
router.register('virtual-chassis', views.VirtualChassisViewSet)

View File

@@ -1,4 +1,5 @@
import socket
from collections import OrderedDict
from django.http import Http404, HttpResponse, HttpResponseForbidden
from django.shortcuts import get_object_or_404
@@ -12,9 +13,7 @@ from rest_framework.viewsets import ViewSet
from circuits.models import Circuit
from dcim import filtersets
from dcim.constants import CABLE_TRACE_SVG_DEFAULT_WIDTH
from dcim.models import *
from dcim.svg import CableTraceSVG
from extras.api.views import ConfigContextQuerySetMixin
from ipam.models import Prefix, VLAN
from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired
@@ -53,30 +52,37 @@ class PathEndpointMixin(object):
# Initialize the path array
path = []
# Render SVG image if requested
if request.GET.get('render', None) == 'svg':
# Render SVG
try:
width = int(request.GET.get('width', CABLE_TRACE_SVG_DEFAULT_WIDTH))
width = min(int(request.GET.get('width')), 1600)
except (ValueError, TypeError):
width = CABLE_TRACE_SVG_DEFAULT_WIDTH
drawing = CableTraceSVG(obj, base_url=request.build_absolute_uri('/'), width=width)
return HttpResponse(drawing.render().tostring(), content_type='image/svg+xml')
width = None
drawing = obj.get_trace_svg(
base_url=request.build_absolute_uri('/'),
width=width
)
return HttpResponse(drawing.tostring(), content_type='image/svg+xml')
# Serialize path objects, iterating over each three-tuple in the path
for near_ends, cable, far_ends in obj.trace():
if near_ends:
serializer_a = get_serializer_for_model(near_ends[0], prefix=NESTED_SERIALIZER_PREFIX)
near_ends = serializer_a(near_ends, many=True, context={'request': request}).data
else:
# Path is split; stop here
for near_end, cable, far_end in obj.trace():
if near_end is None:
# Split paths
break
if cable:
cable = serializers.TracedCableSerializer(cable[0], context={'request': request}).data
if far_ends:
serializer_b = get_serializer_for_model(far_ends[0], prefix=NESTED_SERIALIZER_PREFIX)
far_ends = serializer_b(far_ends, many=True, context={'request': request}).data
path.append((near_ends, cable, far_ends))
# Serialize each object
serializer_a = get_serializer_for_model(near_end, prefix=NESTED_SERIALIZER_PREFIX)
x = serializer_a(near_end, context={'request': request}).data
if cable is not None:
y = serializers.TracedCableSerializer(cable, context={'request': request}).data
else:
y = None
if far_end is not None:
serializer_b = get_serializer_for_model(far_end, prefix=NESTED_SERIALIZER_PREFIX)
z = serializer_b(far_end, context={'request': request}).data
else:
z = None
path.append((x, y, z))
return Response(path)
@@ -89,7 +95,7 @@ class PassThroughPortMixin(object):
Return all CablePaths which traverse a given pass-through port.
"""
obj = get_object_or_404(self.queryset, pk=pk)
cablepaths = CablePath.objects.filter(_nodes__contains=obj)
cablepaths = CablePath.objects.filter(path__contains=obj).prefetch_related('origin', 'destination')
serializer = serializers.CablePathSerializer(cablepaths, context={'request': request}, many=True)
return Response(serializer.data)
@@ -210,14 +216,6 @@ class RackViewSet(NetBoxModelViewSet):
data = serializer.validated_data
if data['render'] == 'svg':
# Determine attributes for highlighting devices (if any)
highlight_params = []
for param in request.GET.getlist('highlight'):
try:
highlight_params.append(param.split(':', 1))
except ValueError:
pass
# Render and return the elevation as an SVG drawing with the correct content type
drawing = rack.get_elevation_svg(
face=data['face'],
@@ -226,8 +224,7 @@ class RackViewSet(NetBoxModelViewSet):
unit_height=data['unit_height'],
legend_width=data['legend_width'],
include_images=data['include_images'],
base_url=request.build_absolute_uri('/'),
highlight_params=highlight_params
base_url=request.build_absolute_uri('/')
)
return HttpResponse(drawing.tostring(), content_type='image/svg+xml')
@@ -483,7 +480,7 @@ class DeviceViewSet(ConfigContextQuerySetMixin, NetBoxModelViewSet):
return HttpResponseForbidden()
napalm_methods = request.GET.getlist('method')
response = {m: None for m in napalm_methods}
response = OrderedDict([(m, None) for m in napalm_methods])
config = get_config()
username = config.NAPALM_USERNAME
@@ -552,7 +549,7 @@ class ModuleViewSet(NetBoxModelViewSet):
class ConsolePortViewSet(PathEndpointMixin, NetBoxModelViewSet):
queryset = ConsolePort.objects.prefetch_related(
'device', 'module__module_bay', '_path', 'cable__terminations', 'tags'
'device', 'module__module_bay', '_path__destination', 'cable', '_link_peer', 'tags'
)
serializer_class = serializers.ConsolePortSerializer
filterset_class = filtersets.ConsolePortFilterSet
@@ -561,7 +558,7 @@ class ConsolePortViewSet(PathEndpointMixin, NetBoxModelViewSet):
class ConsoleServerPortViewSet(PathEndpointMixin, NetBoxModelViewSet):
queryset = ConsoleServerPort.objects.prefetch_related(
'device', 'module__module_bay', '_path', 'cable__terminations', 'tags'
'device', 'module__module_bay', '_path__destination', 'cable', '_link_peer', 'tags'
)
serializer_class = serializers.ConsoleServerPortSerializer
filterset_class = filtersets.ConsoleServerPortFilterSet
@@ -570,7 +567,7 @@ class ConsoleServerPortViewSet(PathEndpointMixin, NetBoxModelViewSet):
class PowerPortViewSet(PathEndpointMixin, NetBoxModelViewSet):
queryset = PowerPort.objects.prefetch_related(
'device', 'module__module_bay', '_path', 'cable__terminations', 'tags'
'device', 'module__module_bay', '_path__destination', 'cable', '_link_peer', 'tags'
)
serializer_class = serializers.PowerPortSerializer
filterset_class = filtersets.PowerPortFilterSet
@@ -579,7 +576,7 @@ class PowerPortViewSet(PathEndpointMixin, NetBoxModelViewSet):
class PowerOutletViewSet(PathEndpointMixin, NetBoxModelViewSet):
queryset = PowerOutlet.objects.prefetch_related(
'device', 'module__module_bay', '_path', 'cable__terminations', 'tags'
'device', 'module__module_bay', '_path__destination', 'cable', '_link_peer', 'tags'
)
serializer_class = serializers.PowerOutletSerializer
filterset_class = filtersets.PowerOutletFilterSet
@@ -588,8 +585,8 @@ class PowerOutletViewSet(PathEndpointMixin, NetBoxModelViewSet):
class InterfaceViewSet(PathEndpointMixin, NetBoxModelViewSet):
queryset = Interface.objects.prefetch_related(
'device', 'module__module_bay', 'parent', 'bridge', 'lag', '_path', 'cable__terminations', 'wireless_lans',
'untagged_vlan', 'tagged_vlans', 'vrf', 'ip_addresses', 'fhrp_group_assignments', 'tags'
'device', 'module__module_bay', 'parent', 'bridge', 'lag', '_path__destination', 'cable', '_link_peer',
'wireless_lans', 'untagged_vlan', 'tagged_vlans', 'vrf', 'ip_addresses', 'fhrp_group_assignments', 'tags'
)
serializer_class = serializers.InterfaceSerializer
filterset_class = filtersets.InterfaceFilterSet
@@ -598,7 +595,7 @@ class InterfaceViewSet(PathEndpointMixin, NetBoxModelViewSet):
class FrontPortViewSet(PassThroughPortMixin, NetBoxModelViewSet):
queryset = FrontPort.objects.prefetch_related(
'device__device_type__manufacturer', 'module__module_bay', 'rear_port', 'cable__terminations', 'tags'
'device__device_type__manufacturer', 'module__module_bay', 'rear_port', 'cable', 'tags'
)
serializer_class = serializers.FrontPortSerializer
filterset_class = filtersets.FrontPortFilterSet
@@ -607,7 +604,7 @@ class FrontPortViewSet(PassThroughPortMixin, NetBoxModelViewSet):
class RearPortViewSet(PassThroughPortMixin, NetBoxModelViewSet):
queryset = RearPort.objects.prefetch_related(
'device__device_type__manufacturer', 'module__module_bay', 'cable__terminations', 'tags'
'device__device_type__manufacturer', 'module__module_bay', 'cable', 'tags'
)
serializer_class = serializers.RearPortSerializer
filterset_class = filtersets.RearPortFilterSet
@@ -652,18 +649,14 @@ class InventoryItemRoleViewSet(NetBoxModelViewSet):
#
class CableViewSet(NetBoxModelViewSet):
queryset = Cable.objects.prefetch_related('terminations__termination')
metadata_class = ContentTypeMetadata
queryset = Cable.objects.prefetch_related(
'termination_a', 'termination_b'
)
serializer_class = serializers.CableSerializer
filterset_class = filtersets.CableFilterSet
class CableTerminationViewSet(NetBoxModelViewSet):
metadata_class = ContentTypeMetadata
queryset = CableTermination.objects.prefetch_related('cable', 'termination')
serializer_class = serializers.CableTerminationSerializer
filterset_class = filtersets.CableTerminationFilterSet
#
# Virtual chassis
#
@@ -697,7 +690,7 @@ class PowerPanelViewSet(NetBoxModelViewSet):
class PowerFeedViewSet(PathEndpointMixin, NetBoxModelViewSet):
queryset = PowerFeed.objects.prefetch_related(
'power_panel', 'rack', '_path', 'cable__terminations', 'tags'
'power_panel', 'rack', '_path__destination', 'cable', '_link_peer', 'tags'
)
serializer_class = serializers.PowerFeedSerializer
filterset_class = filtersets.PowerFeedFilterSet
@@ -757,13 +750,13 @@ class ConnectedDeviceViewSet(ViewSet):
device=peer_device,
name=peer_interface_name
)
endpoints = peer_interface.connected_endpoints
endpoint = peer_interface.connected_endpoint
# If an Interface, return the parent device
if endpoints and type(endpoints[0]) is Interface:
if type(endpoint) is Interface:
device = get_object_or_404(
Device.objects.restrict(request.user, 'view'),
pk=endpoints[0].device_id
pk=endpoint.device_id
)
return Response(serializers.DeviceSerializer(device, context={'request': request}).data)

View File

@@ -1,26 +1,10 @@
from django.apps import AppConfig
from netbox import denormalized
class DCIMConfig(AppConfig):
name = "dcim"
verbose_name = "DCIM"
def ready(self):
import dcim.signals
from .models import CableTermination
# Register denormalized fields
denormalized.register(CableTermination, '_device', {
'_rack': 'rack',
'_location': 'location',
'_site': 'site',
})
denormalized.register(CableTermination, '_rack', {
'_location': 'location',
'_site': 'site',
})
denormalized.register(CableTermination, '_location', {
'_site': 'site',
})
import dcim.signals

View File

@@ -23,28 +23,6 @@ class SiteStatusChoices(ChoiceSet):
]
#
# Locations
#
class LocationStatusChoices(ChoiceSet):
key = 'Location.status'
STATUS_PLANNED = 'planned'
STATUS_STAGING = 'staging'
STATUS_ACTIVE = 'active'
STATUS_DECOMMISSIONING = 'decommissioning'
STATUS_RETIRED = 'retired'
CHOICES = [
(STATUS_PLANNED, 'Planned', 'cyan'),
(STATUS_STAGING, 'Staging', 'blue'),
(STATUS_ACTIVE, 'Active', 'green'),
(STATUS_DECOMMISSIONING, 'Decommissioning', 'yellow'),
(STATUS_RETIRED, 'Retired', 'red'),
]
#
# Racks
#
@@ -836,6 +814,17 @@ class InterfaceTypeChoices(ChoiceSet):
# ATM/DSL
TYPE_XDSL = 'xdsl'
# Coaxial
TYPE_DOCSIS = 'docsis'
# PON
TYPE_GPON = 'gpon'
TYPE_XG_PON = 'xg-pon'
TYPE_XGS_PON = 'xgs-pon'
TYPE_NG_PON2 = 'ng-pon2'
TYPE_EPON = 'epon'
TYPE_10G_EPON = '10g-epon'
# Stacking
TYPE_STACKWISE = 'cisco-stackwise'
TYPE_STACKWISE_PLUS = 'cisco-stackwise-plus'
@@ -972,6 +961,23 @@ class InterfaceTypeChoices(ChoiceSet):
(TYPE_XDSL, 'xDSL'),
)
),
(
'Coaxial',
(
(TYPE_DOCSIS, 'DOCSIS'),
)
),
(
'PON',
(
(TYPE_GPON, 'GPON (2.5 Gbps / 1.25 Gps)'),
(TYPE_XG_PON, 'XG-PON (10 Gbps / 2.5 Gbps)'),
(TYPE_XGS_PON, 'XGS-PON (10 Gbps)'),
(TYPE_NG_PON2, 'NG-PON2 (TWDM-PON) (4x10 Gbps)'),
(TYPE_EPON, 'EPON (1 Gbps)'),
(TYPE_10G_EPON, '10G-EPON (10 Gbps)'),
)
),
(
'Stacking',
(
@@ -1025,51 +1031,6 @@ class InterfaceModeChoices(ChoiceSet):
)
class InterfacePoEModeChoices(ChoiceSet):
MODE_PD = 'pd'
MODE_PSE = 'pse'
CHOICES = (
(MODE_PD, 'PD'),
(MODE_PSE, 'PSE'),
)
class InterfacePoETypeChoices(ChoiceSet):
TYPE_1_8023AF = 'type1-ieee802.3af'
TYPE_2_8023AT = 'type2-ieee802.3at'
TYPE_3_8023BT = 'type3-ieee802.3bt'
TYPE_4_8023BT = 'type4-ieee802.3bt'
PASSIVE_24V_2PAIR = 'passive-24v-2pair'
PASSIVE_24V_4PAIR = 'passive-24v-4pair'
PASSIVE_48V_2PAIR = 'passive-48v-2pair'
PASSIVE_48V_4PAIR = 'passive-48v-4pair'
CHOICES = (
(
'IEEE Standard',
(
(TYPE_1_8023AF, '802.3af (Type 1)'),
(TYPE_2_8023AT, '802.3at (Type 2)'),
(TYPE_3_8023BT, '802.3bt (Type 3)'),
(TYPE_4_8023BT, '802.3bt (Type 4)'),
)
),
(
'Passive',
(
(PASSIVE_24V_2PAIR, 'Passive 24V (2-pair)'),
(PASSIVE_24V_4PAIR, 'Passive 24V (4-pair)'),
(PASSIVE_48V_2PAIR, 'Passive 48V (2-pair)'),
(PASSIVE_48V_2PAIR, 'Passive 48V (4-pair)'),
)
),
)
#
# FrontPorts/RearPorts
#
@@ -1282,22 +1243,6 @@ class CableLengthUnitChoices(ChoiceSet):
)
#
# CableTerminations
#
class CableEndChoices(ChoiceSet):
SIDE_A = 'A'
SIDE_B = 'B'
CHOICES = (
(SIDE_A, 'A'),
(SIDE_B, 'B'),
# ('', ''),
)
#
# PowerFeeds
#

View File

@@ -13,8 +13,7 @@ DEVICETYPE_IMAGE_FORMATS = 'image/bmp,image/gif,image/jpeg,image/png,image/tiff,
RACK_U_HEIGHT_DEFAULT = 42
RACK_ELEVATION_BORDER_WIDTH = 2
RACK_ELEVATION_DEFAULT_LEGEND_WIDTH = 30
RACK_ELEVATION_DEFAULT_MARGIN_WIDTH = 15
RACK_ELEVATION_LEGEND_WIDTH_DEFAULT = 30
#
@@ -85,8 +84,6 @@ MODULAR_COMPONENT_MODELS = Q(
# Cabling and connections
#
CABLE_TRACE_SVG_DEFAULT_WIDTH = 400
# Cable endpoint types
CABLE_TERMINATION_MODELS = Q(
Q(app_label='circuits', model__in=(

View File

@@ -21,7 +21,6 @@ from .models import *
__all__ = (
'CableFilterSet',
'CabledObjectFilterSet',
'CableTerminationFilterSet',
'ConsoleConnectionFilterSet',
'ConsolePortFilterSet',
@@ -217,14 +216,10 @@ class LocationFilterSet(TenancyFilterSet, ContactModelFilterSet, OrganizationalM
to_field_name='slug',
label='Location (slug)',
)
status = django_filters.MultipleChoiceFilter(
choices=LocationStatusChoices,
null_value=None
)
class Meta:
model = Location
fields = ['id', 'name', 'slug', 'status', 'description']
fields = ['id', 'name', 'slug', 'description']
def search(self, queryset, name, value):
if not value.strip():
@@ -652,12 +647,6 @@ class InterfaceTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceTypeCo
choices=InterfaceTypeChoices,
null_value=None
)
poe_mode = django_filters.MultipleChoiceFilter(
choices=InterfacePoEModeChoices
)
poe_type = django_filters.MultipleChoiceFilter(
choices=InterfacePoETypeChoices
)
class Meta:
model = InterfaceTemplate
@@ -1127,7 +1116,7 @@ class ModularDeviceComponentFilterSet(DeviceComponentFilterSet):
)
class CabledObjectFilterSet(django_filters.FilterSet):
class CableTerminationFilterSet(django_filters.FilterSet):
cabled = django_filters.BooleanFilter(
field_name='cable',
lookup_expr='isnull',
@@ -1150,7 +1139,7 @@ class PathEndpointFilterSet(django_filters.FilterSet):
class ConsolePortFilterSet(
ModularDeviceComponentFilterSet,
NetBoxModelFilterSet,
CabledObjectFilterSet,
CableTerminationFilterSet,
PathEndpointFilterSet
):
type = django_filters.MultipleChoiceFilter(
@@ -1160,13 +1149,13 @@ class ConsolePortFilterSet(
class Meta:
model = ConsolePort
fields = ['id', 'name', 'label', 'description', 'cable_end']
fields = ['id', 'name', 'label', 'description']
class ConsoleServerPortFilterSet(
ModularDeviceComponentFilterSet,
NetBoxModelFilterSet,
CabledObjectFilterSet,
CableTerminationFilterSet,
PathEndpointFilterSet
):
type = django_filters.MultipleChoiceFilter(
@@ -1176,13 +1165,13 @@ class ConsoleServerPortFilterSet(
class Meta:
model = ConsoleServerPort
fields = ['id', 'name', 'label', 'description', 'cable_end']
fields = ['id', 'name', 'label', 'description']
class PowerPortFilterSet(
ModularDeviceComponentFilterSet,
NetBoxModelFilterSet,
CabledObjectFilterSet,
CableTerminationFilterSet,
PathEndpointFilterSet
):
type = django_filters.MultipleChoiceFilter(
@@ -1192,13 +1181,13 @@ class PowerPortFilterSet(
class Meta:
model = PowerPort
fields = ['id', 'name', 'label', 'maximum_draw', 'allocated_draw', 'description', 'cable_end']
fields = ['id', 'name', 'label', 'maximum_draw', 'allocated_draw', 'description']
class PowerOutletFilterSet(
ModularDeviceComponentFilterSet,
NetBoxModelFilterSet,
CabledObjectFilterSet,
CableTerminationFilterSet,
PathEndpointFilterSet
):
type = django_filters.MultipleChoiceFilter(
@@ -1212,13 +1201,13 @@ class PowerOutletFilterSet(
class Meta:
model = PowerOutlet
fields = ['id', 'name', 'label', 'feed_leg', 'description', 'cable_end']
fields = ['id', 'name', 'label', 'feed_leg', 'description']
class InterfaceFilterSet(
ModularDeviceComponentFilterSet,
NetBoxModelFilterSet,
CabledObjectFilterSet,
CableTerminationFilterSet,
PathEndpointFilterSet
):
# Override device and device_id filters from DeviceComponentFilterSet to match against any peer virtual chassis
@@ -1258,12 +1247,6 @@ class InterfaceFilterSet(
)
mac_address = MultiValueMACAddressFilter()
wwn = MultiValueWWNFilter()
poe_mode = django_filters.MultipleChoiceFilter(
choices=InterfacePoEModeChoices
)
poe_type = django_filters.MultipleChoiceFilter(
choices=InterfacePoETypeChoices
)
vlan_id = django_filters.CharFilter(
method='filter_vlan_id',
label='Assigned VLAN'
@@ -1297,8 +1280,8 @@ class InterfaceFilterSet(
class Meta:
model = Interface
fields = [
'id', 'name', 'label', 'type', 'enabled', 'mtu', 'mgmt_only', 'poe_mode', 'poe_type', 'mode', 'rf_role',
'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'description', 'cable_end',
'id', 'name', 'label', 'type', 'enabled', 'mtu', 'mgmt_only', 'mode', 'rf_role', 'rf_channel',
'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'description',
]
def filter_device(self, queryset, name, value):
@@ -1352,7 +1335,7 @@ class InterfaceFilterSet(
class FrontPortFilterSet(
ModularDeviceComponentFilterSet,
NetBoxModelFilterSet,
CabledObjectFilterSet
CableTerminationFilterSet
):
type = django_filters.MultipleChoiceFilter(
choices=PortTypeChoices,
@@ -1361,13 +1344,13 @@ class FrontPortFilterSet(
class Meta:
model = FrontPort
fields = ['id', 'name', 'label', 'type', 'color', 'description', 'cable_end']
fields = ['id', 'name', 'label', 'type', 'color', 'description']
class RearPortFilterSet(
ModularDeviceComponentFilterSet,
NetBoxModelFilterSet,
CabledObjectFilterSet
CableTerminationFilterSet
):
type = django_filters.MultipleChoiceFilter(
choices=PortTypeChoices,
@@ -1376,7 +1359,7 @@ class RearPortFilterSet(
class Meta:
model = RearPort
fields = ['id', 'name', 'label', 'type', 'color', 'positions', 'description', 'cable_end']
fields = ['id', 'name', 'label', 'type', 'color', 'positions', 'description']
class ModuleBayFilterSet(DeviceComponentFilterSet, NetBoxModelFilterSet):
@@ -1524,18 +1507,10 @@ class VirtualChassisFilterSet(NetBoxModelFilterSet):
class CableFilterSet(TenancyFilterSet, NetBoxModelFilterSet):
termination_a_type = ContentTypeFilter(
field_name='terminations__termination_type'
)
termination_a_id = MultiValueNumberFilter(
field_name='terminations__termination_id'
)
termination_b_type = ContentTypeFilter(
field_name='terminations__termination_type'
)
termination_b_id = MultiValueNumberFilter(
field_name='terminations__termination_id'
)
termination_a_type = ContentTypeFilter()
termination_a_id = MultiValueNumberFilter()
termination_b_type = ContentTypeFilter()
termination_b_id = MultiValueNumberFilter()
type = django_filters.MultipleChoiceFilter(
choices=CableTypeChoices
)
@@ -1546,57 +1521,44 @@ class CableFilterSet(TenancyFilterSet, NetBoxModelFilterSet):
choices=ColorChoices
)
device_id = MultiValueNumberFilter(
method='filter_by_termination'
method='filter_device'
)
device = MultiValueCharFilter(
method='filter_by_termination',
method='filter_device',
field_name='device__name'
)
rack_id = MultiValueNumberFilter(
method='filter_by_termination',
field_name='rack_id'
method='filter_device',
field_name='device__rack_id'
)
rack = MultiValueCharFilter(
method='filter_by_termination',
field_name='rack__name'
)
location_id = MultiValueNumberFilter(
method='filter_by_termination',
field_name='location_id'
)
location = MultiValueCharFilter(
method='filter_by_termination',
field_name='location__name'
method='filter_device',
field_name='device__rack__name'
)
site_id = MultiValueNumberFilter(
method='filter_by_termination',
field_name='site_id'
method='filter_device',
field_name='device__site_id'
)
site = MultiValueCharFilter(
method='filter_by_termination',
field_name='site__slug'
method='filter_device',
field_name='device__site__slug'
)
class Meta:
model = Cable
fields = ['id', 'label', 'length', 'length_unit']
fields = ['id', 'label', 'length', 'length_unit', 'termination_a_id', 'termination_b_id']
def search(self, queryset, name, value):
if not value.strip():
return queryset
return queryset.filter(label__icontains=value)
def filter_by_termination(self, queryset, name, value):
# Filter by a related object cached on CableTermination. Note the underscore preceding the field name.
# Supported objects: device, rack, location, site
return queryset.filter(**{f'terminations___{name}__in': value}).distinct()
class CableTerminationFilterSet(BaseFilterSet):
class Meta:
model = CableTermination
fields = ['id', 'cable', 'cable_end', 'termination_type', 'termination_id']
def filter_device(self, queryset, name, value):
queryset = queryset.filter(
Q(**{'_termination_a_{}__in'.format(name): value}) |
Q(**{'_termination_b_{}__in'.format(name): value})
)
return queryset
class PowerPanelFilterSet(NetBoxModelFilterSet, ContactModelFilterSet):
@@ -1656,7 +1618,7 @@ class PowerPanelFilterSet(NetBoxModelFilterSet, ContactModelFilterSet):
return queryset.filter(qs_filter)
class PowerFeedFilterSet(NetBoxModelFilterSet, CabledObjectFilterSet, PathEndpointFilterSet):
class PowerFeedFilterSet(NetBoxModelFilterSet, CableTerminationFilterSet, PathEndpointFilterSet):
region_id = TreeNodeMultipleChoiceFilter(
queryset=Region.objects.all(),
field_name='power_panel__site__region',
@@ -1710,9 +1672,7 @@ class PowerFeedFilterSet(NetBoxModelFilterSet, CabledObjectFilterSet, PathEndpoi
class Meta:
model = PowerFeed
fields = [
'id', 'name', 'status', 'type', 'supply', 'phase', 'voltage', 'amperage', 'max_utilization', 'cable_end',
]
fields = ['id', 'name', 'status', 'type', 'supply', 'phase', 'voltage', 'amperage', 'max_utilization']
def search(self, queryset, name, value):
if not value.strip():

View File

@@ -72,15 +72,12 @@ class PowerOutletBulkCreateForm(
class InterfaceBulkCreateForm(
form_from_model(Interface, [
'type', 'enabled', 'speed', 'duplex', 'mtu', 'mgmt_only', 'mark_connected', 'poe_mode', 'poe_type',
]),
form_from_model(Interface, ['type', 'enabled', 'speed', 'duplex', 'mtu', 'mgmt_only', 'mark_connected']),
DeviceBulkAddComponentForm
):
model = Interface
field_order = (
'name_pattern', 'label_pattern', 'type', 'enabled', 'speed', 'duplex', 'mtu', 'mgmt_only', 'poe_mode',
'poe_type', 'mark_connected', 'description', 'tags',
'name_pattern', 'label_pattern', 'type', 'enabled', 'speed', 'duplex', 'mtu', 'mgmt_only', 'mark_connected', 'description', 'tags',
)

View File

@@ -158,12 +158,6 @@ class LocationBulkEditForm(NetBoxModelBulkEditForm):
'site_id': '$site'
}
)
status = forms.ChoiceField(
choices=add_blank_choice(LocationStatusChoices),
required=False,
initial='',
widget=StaticSelect()
)
tenant = DynamicModelChoiceField(
queryset=Tenant.objects.all(),
required=False
@@ -175,7 +169,7 @@ class LocationBulkEditForm(NetBoxModelBulkEditForm):
model = Location
fieldsets = (
(None, ('site', 'parent', 'status', 'tenant', 'description')),
(None, ('site', 'parent', 'tenant', 'description')),
)
nullable_fields = ('parent', 'tenant', 'description')
@@ -818,22 +812,8 @@ class InterfaceTemplateBulkEditForm(BulkEditForm):
description = forms.CharField(
required=False
)
poe_mode = forms.ChoiceField(
choices=add_blank_choice(InterfacePoEModeChoices),
required=False,
initial='',
widget=StaticSelect(),
label='PoE mode'
)
poe_type = forms.ChoiceField(
choices=add_blank_choice(InterfacePoETypeChoices),
required=False,
initial='',
widget=StaticSelect(),
label='PoE type'
)
nullable_fields = ('label', 'description', 'poe_mode', 'poe_type')
nullable_fields = ('label', 'description')
class FrontPortTemplateBulkEditForm(BulkEditForm):
@@ -1083,20 +1063,6 @@ class InterfaceBulkEditForm(
widget=BulkEditNullBooleanSelect,
label='Management only'
)
poe_mode = forms.ChoiceField(
choices=add_blank_choice(InterfacePoEModeChoices),
required=False,
initial='',
widget=StaticSelect(),
label='PoE mode'
)
poe_type = forms.ChoiceField(
choices=add_blank_choice(InterfacePoETypeChoices),
required=False,
initial='',
widget=StaticSelect(),
label='PoE type'
)
mark_connected = forms.NullBooleanField(
required=False,
widget=BulkEditNullBooleanSelect
@@ -1139,15 +1105,14 @@ class InterfaceBulkEditForm(
(None, ('module', 'type', 'label', 'speed', 'duplex', 'description')),
('Addressing', ('vrf', 'mac_address', 'wwn')),
('Operation', ('mtu', 'tx_power', 'enabled', 'mgmt_only', 'mark_connected')),
('PoE', ('poe_mode', 'poe_type')),
('Related Interfaces', ('parent', 'bridge', 'lag')),
('802.1Q Switching', ('mode', 'vlan_group', 'untagged_vlan', 'tagged_vlans')),
('Wireless', ('rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width')),
)
nullable_fields = (
'module', 'label', 'parent', 'bridge', 'lag', 'speed', 'duplex', 'mac_address', 'wwn', 'mtu', 'description',
'poe_mode', 'poe_type', 'mode', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power',
'vlan_group', 'untagged_vlan', 'tagged_vlans', 'vrf',
'mode', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'vlan_group', 'untagged_vlan',
'tagged_vlans', 'vrf',
)
def __init__(self, *args, **kwargs):

View File

@@ -124,10 +124,6 @@ class LocationCSVForm(NetBoxModelCSVForm):
'invalid_choice': 'Location not found.',
}
)
status = CSVChoiceField(
choices=LocationStatusChoices,
help_text='Operational status'
)
tenant = CSVModelChoiceField(
queryset=Tenant.objects.all(),
required=False,
@@ -137,7 +133,7 @@ class LocationCSVForm(NetBoxModelCSVForm):
class Meta:
model = Location
fields = ('site', 'parent', 'name', 'slug', 'status', 'tenant', 'description')
fields = ('site', 'parent', 'name', 'slug', 'tenant', 'description')
class RackRoleCSVForm(NetBoxModelCSVForm):
@@ -626,16 +622,6 @@ class InterfaceCSVForm(NetBoxModelCSVForm):
choices=InterfaceDuplexChoices,
required=False
)
poe_mode = CSVChoiceField(
choices=InterfacePoEModeChoices,
required=False,
help_text='PoE mode'
)
poe_type = CSVChoiceField(
choices=InterfacePoETypeChoices,
required=False,
help_text='PoE type'
)
mode = CSVChoiceField(
choices=InterfaceModeChoices,
required=False,
@@ -656,9 +642,9 @@ class InterfaceCSVForm(NetBoxModelCSVForm):
class Meta:
model = Interface
fields = (
'device', 'name', 'label', 'parent', 'bridge', 'lag', 'type', 'speed', 'duplex', 'enabled',
'mark_connected', 'mac_address', 'wwn', 'mtu', 'mgmt_only', 'description', 'poe_mode', 'poe_type', 'mode',
'vrf', 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power',
'device', 'name', 'label', 'parent', 'bridge', 'lag', 'type', 'speed', 'duplex', 'enabled', 'mark_connected', 'mac_address',
'wwn', 'mtu', 'mgmt_only', 'description', 'mode', 'vrf', 'rf_role', 'rf_channel', 'rf_channel_frequency',
'rf_channel_width', 'tx_power',
)
def __init__(self, data=None, *args, **kwargs):
@@ -955,7 +941,7 @@ class CableCSVForm(NetBoxModelCSVForm):
except ObjectDoesNotExist:
raise forms.ValidationError(f"{side.upper()} side termination not found: {device} {name}")
setattr(self.instance, f'{side}_terminations', [termination_object])
setattr(self.instance, f'termination_{side}', termination_object)
return termination_object
def clean_side_a_name(self):

View File

@@ -1,170 +1,279 @@
from django import forms
from circuits.models import Circuit, CircuitTermination, Provider
from dcim.models import *
from utilities.forms import DynamicModelChoiceField, DynamicModelMultipleChoiceField
from .models import CableForm
from extras.models import Tag
from netbox.forms import NetBoxModelForm
from tenancy.forms import TenancyForm
from utilities.forms import DynamicModelChoiceField, DynamicModelMultipleChoiceField, StaticSelect
__all__ = (
'ConnectCableToCircuitTerminationForm',
'ConnectCableToConsolePortForm',
'ConnectCableToConsoleServerPortForm',
'ConnectCableToFrontPortForm',
'ConnectCableToInterfaceForm',
'ConnectCableToPowerFeedForm',
'ConnectCableToPowerPortForm',
'ConnectCableToPowerOutletForm',
'ConnectCableToRearPortForm',
)
def get_cable_form(a_type, b_type):
class ConnectCableToDeviceForm(TenancyForm, NetBoxModelForm):
"""
Base form for connecting a Cable to a Device component
"""
termination_b_region = DynamicModelChoiceField(
queryset=Region.objects.all(),
label='Region',
required=False
)
termination_b_sitegroup = DynamicModelChoiceField(
queryset=SiteGroup.objects.all(),
label='Site group',
required=False
)
termination_b_site = DynamicModelChoiceField(
queryset=Site.objects.all(),
label='Site',
required=False,
query_params={
'region_id': '$termination_b_region',
'group_id': '$termination_b_sitegroup',
}
)
termination_b_location = DynamicModelChoiceField(
queryset=Location.objects.all(),
label='Location',
required=False,
null_option='None',
query_params={
'site_id': '$termination_b_site'
}
)
termination_b_rack = DynamicModelChoiceField(
queryset=Rack.objects.all(),
label='Rack',
required=False,
null_option='None',
query_params={
'site_id': '$termination_b_site',
'location_id': '$termination_b_location',
}
)
termination_b_device = DynamicModelChoiceField(
queryset=Device.objects.all(),
label='Device',
required=False,
query_params={
'site_id': '$termination_b_site',
'location_id': '$termination_b_location',
'rack_id': '$termination_b_rack',
}
)
class FormMetaclass(forms.models.ModelFormMetaclass):
class Meta:
model = Cable
fields = [
'termination_b_region', 'termination_b_sitegroup', 'termination_b_site', 'termination_b_rack',
'termination_b_device', 'termination_b_id', 'type', 'status', 'tenant_group', 'tenant', 'label', 'color',
'length', 'length_unit', 'tags',
]
widgets = {
'status': StaticSelect,
'type': StaticSelect,
'length_unit': StaticSelect,
}
def __new__(mcs, name, bases, attrs):
def clean_termination_b_id(self):
# Return the PK rather than the object
return getattr(self.cleaned_data['termination_b_id'], 'pk', None)
for cable_end, term_cls in (('a', a_type), ('b', b_type)):
attrs[f'termination_{cable_end}_region'] = DynamicModelChoiceField(
queryset=Region.objects.all(),
label='Region',
required=False,
initial_params={
'sites': f'$termination_{cable_end}_site'
}
)
attrs[f'termination_{cable_end}_sitegroup'] = DynamicModelChoiceField(
queryset=SiteGroup.objects.all(),
label='Site group',
required=False,
initial_params={
'sites': f'$termination_{cable_end}_site'
}
)
attrs[f'termination_{cable_end}_site'] = DynamicModelChoiceField(
queryset=Site.objects.all(),
label='Site',
required=False,
query_params={
'region_id': f'$termination_{cable_end}_region',
'group_id': f'$termination_{cable_end}_sitegroup',
}
)
attrs[f'termination_{cable_end}_location'] = DynamicModelChoiceField(
queryset=Location.objects.all(),
label='Location',
required=False,
null_option='None',
query_params={
'site_id': f'$termination_{cable_end}_site'
}
)
class ConnectCableToConsolePortForm(ConnectCableToDeviceForm):
termination_b_id = DynamicModelChoiceField(
queryset=ConsolePort.objects.all(),
label='Name',
disabled_indicator='_occupied',
query_params={
'device_id': '$termination_b_device'
}
)
# Device component
if hasattr(term_cls, 'device'):
attrs[f'termination_{cable_end}_rack'] = DynamicModelChoiceField(
queryset=Rack.objects.all(),
label='Rack',
required=False,
null_option='None',
initial_params={
'devices': f'$termination_{cable_end}_device'
},
query_params={
'site_id': f'$termination_{cable_end}_site',
'location_id': f'$termination_{cable_end}_location',
}
)
attrs[f'termination_{cable_end}_device'] = DynamicModelChoiceField(
queryset=Device.objects.all(),
label='Device',
required=False,
initial_params={
f'{term_cls._meta.model_name}s__in': f'${cable_end}_terminations'
},
query_params={
'site_id': f'$termination_{cable_end}_site',
'location_id': f'$termination_{cable_end}_location',
'rack_id': f'$termination_{cable_end}_rack',
}
)
attrs[f'{cable_end}_terminations'] = DynamicModelMultipleChoiceField(
queryset=term_cls.objects.all(),
label=term_cls._meta.verbose_name.title(),
disabled_indicator='_occupied',
query_params={
'device_id': f'$termination_{cable_end}_device',
}
)
class ConnectCableToConsoleServerPortForm(ConnectCableToDeviceForm):
termination_b_id = DynamicModelChoiceField(
queryset=ConsoleServerPort.objects.all(),
label='Name',
disabled_indicator='_occupied',
query_params={
'device_id': '$termination_b_device'
}
)
# PowerFeed
elif term_cls == PowerFeed:
attrs[f'termination_{cable_end}_powerpanel'] = DynamicModelChoiceField(
queryset=PowerPanel.objects.all(),
label='Power Panel',
required=False,
initial_params={
'powerfeeds__in': f'${cable_end}_terminations'
},
query_params={
'site_id': f'$termination_{cable_end}_site',
'location_id': f'$termination_{cable_end}_location',
}
)
attrs[f'{cable_end}_terminations'] = DynamicModelMultipleChoiceField(
queryset=term_cls.objects.all(),
label='Power Feed',
disabled_indicator='_occupied',
query_params={
'powerpanel_id': f'$termination_{cable_end}_powerpanel',
}
)
class ConnectCableToPowerPortForm(ConnectCableToDeviceForm):
termination_b_id = DynamicModelChoiceField(
queryset=PowerPort.objects.all(),
label='Name',
disabled_indicator='_occupied',
query_params={
'device_id': '$termination_b_device'
}
)
# CircuitTermination
elif term_cls == CircuitTermination:
attrs[f'termination_{cable_end}_provider'] = DynamicModelChoiceField(
queryset=Provider.objects.all(),
label='Provider',
initial_params={
'circuits': f'$termination_{cable_end}_circuit'
},
required=False
)
attrs[f'termination_{cable_end}_circuit'] = DynamicModelChoiceField(
queryset=Circuit.objects.all(),
label='Circuit',
initial_params={
'terminations__in': f'${cable_end}_terminations'
},
query_params={
'provider_id': f'$termination_{cable_end}_provider',
'site_id': f'$termination_{cable_end}_site',
}
)
attrs[f'{cable_end}_terminations'] = DynamicModelMultipleChoiceField(
queryset=term_cls.objects.all(),
label='Side',
disabled_indicator='_occupied',
query_params={
'circuit_id': f'$termination_{cable_end}_circuit',
}
)
class ConnectCableToPowerOutletForm(ConnectCableToDeviceForm):
termination_b_id = DynamicModelChoiceField(
queryset=PowerOutlet.objects.all(),
label='Name',
disabled_indicator='_occupied',
query_params={
'device_id': '$termination_b_device'
}
)
return super().__new__(mcs, name, bases, attrs)
class _CableForm(CableForm, metaclass=FormMetaclass):
class ConnectCableToInterfaceForm(ConnectCableToDeviceForm):
termination_b_id = DynamicModelChoiceField(
queryset=Interface.objects.all(),
label='Name',
disabled_indicator='_occupied',
query_params={
'device_id': '$termination_b_device',
'kind': 'physical',
}
)
def __init__(self, *args, **kwargs):
# TODO: Temporary hack to work around list handling limitations with utils.normalize_querydict()
for field_name in ('a_terminations', 'b_terminations'):
if field_name in kwargs.get('initial', {}) and type(kwargs['initial'][field_name]) is not list:
kwargs['initial'][field_name] = [kwargs['initial'][field_name]]
class ConnectCableToFrontPortForm(ConnectCableToDeviceForm):
termination_b_id = DynamicModelChoiceField(
queryset=FrontPort.objects.all(),
label='Name',
disabled_indicator='_occupied',
query_params={
'device_id': '$termination_b_device'
}
)
super().__init__(*args, **kwargs)
if self.instance and self.instance.pk:
# Initialize A/B terminations when modifying an existing Cable instance
self.initial['a_terminations'] = self.instance.a_terminations
self.initial['b_terminations'] = self.instance.b_terminations
class ConnectCableToRearPortForm(ConnectCableToDeviceForm):
termination_b_id = DynamicModelChoiceField(
queryset=RearPort.objects.all(),
label='Name',
disabled_indicator='_occupied',
query_params={
'device_id': '$termination_b_device'
}
)
def clean(self):
super().clean()
# Set the A/B terminations on the Cable instance
self.instance.a_terminations = self.cleaned_data['a_terminations']
self.instance.b_terminations = self.cleaned_data['b_terminations']
class ConnectCableToCircuitTerminationForm(TenancyForm, NetBoxModelForm):
termination_b_provider = DynamicModelChoiceField(
queryset=Provider.objects.all(),
label='Provider',
required=False
)
termination_b_region = DynamicModelChoiceField(
queryset=Region.objects.all(),
label='Region',
required=False
)
termination_b_sitegroup = DynamicModelChoiceField(
queryset=SiteGroup.objects.all(),
label='Site group',
required=False
)
termination_b_site = DynamicModelChoiceField(
queryset=Site.objects.all(),
label='Site',
required=False,
query_params={
'region_id': '$termination_b_region',
'group_id': '$termination_b_sitegroup',
}
)
termination_b_circuit = DynamicModelChoiceField(
queryset=Circuit.objects.all(),
label='Circuit',
query_params={
'provider_id': '$termination_b_provider',
'site_id': '$termination_b_site',
}
)
termination_b_id = DynamicModelChoiceField(
queryset=CircuitTermination.objects.all(),
label='Side',
disabled_indicator='_occupied',
query_params={
'circuit_id': '$termination_b_circuit'
}
)
return _CableForm
class Meta(ConnectCableToDeviceForm.Meta):
fields = [
'termination_b_provider', 'termination_b_region', 'termination_b_sitegroup', 'termination_b_site',
'termination_b_circuit', 'termination_b_id', 'type', 'status', 'tenant_group', 'tenant', 'label', 'color',
'length', 'length_unit', 'tags',
]
def clean_termination_b_id(self):
# Return the PK rather than the object
return getattr(self.cleaned_data['termination_b_id'], 'pk', None)
class ConnectCableToPowerFeedForm(TenancyForm, NetBoxModelForm):
termination_b_region = DynamicModelChoiceField(
queryset=Region.objects.all(),
label='Region',
required=False
)
termination_b_sitegroup = DynamicModelChoiceField(
queryset=SiteGroup.objects.all(),
label='Site group',
required=False
)
termination_b_site = DynamicModelChoiceField(
queryset=Site.objects.all(),
label='Site',
required=False,
query_params={
'region_id': '$termination_b_region',
'group_id': '$termination_b_sitegroup',
}
)
termination_b_location = DynamicModelChoiceField(
queryset=Location.objects.all(),
label='Location',
required=False,
query_params={
'site_id': '$termination_b_site'
}
)
termination_b_powerpanel = DynamicModelChoiceField(
queryset=PowerPanel.objects.all(),
label='Power Panel',
required=False,
query_params={
'site_id': '$termination_b_site',
'location_id': '$termination_b_location',
}
)
termination_b_id = DynamicModelChoiceField(
queryset=PowerFeed.objects.all(),
label='Name',
disabled_indicator='_occupied',
query_params={
'power_panel_id': '$termination_b_powerpanel'
}
)
class Meta(ConnectCableToDeviceForm.Meta):
fields = [
'termination_b_region', 'termination_b_sitegroup', 'termination_b_site', 'termination_b_location',
'termination_b_powerpanel', 'termination_b_id', 'type', 'status', 'tenant_group', 'tenant', 'label',
'color', 'length', 'length_unit', 'tags',
]
def clean_termination_b_id(self):
# Return the PK rather than the object
return getattr(self.cleaned_data['termination_b_id'], 'pk', None)

View File

@@ -166,7 +166,7 @@ class LocationFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelF
model = Location
fieldsets = (
(None, ('q', 'tag')),
('Attributes', ('region_id', 'site_group_id', 'site_id', 'parent_id', 'status')),
('Parent', ('region_id', 'site_group_id', 'site_id', 'parent_id')),
('Tenant', ('tenant_group_id', 'tenant_id')),
('Contacts', ('contact', 'contact_role', 'contact_group')),
)
@@ -198,10 +198,6 @@ class LocationFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelF
},
label=_('Parent')
)
status = MultipleChoiceField(
choices=LocationStatusChoices,
required=False
)
tag = TagFilterField(model)
@@ -743,7 +739,7 @@ class CableFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
model = Cable
fieldsets = (
(None, ('q', 'tag')),
('Location', ('site_id', 'location_id', 'rack_id', 'device_id')),
('Location', ('site_id', 'rack_id', 'device_id')),
('Attributes', ('type', 'status', 'color', 'length', 'length_unit')),
('Tenant', ('tenant_group_id', 'tenant_id')),
)
@@ -760,23 +756,13 @@ class CableFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
},
label=_('Site')
)
location_id = DynamicModelMultipleChoiceField(
queryset=Location.objects.all(),
required=False,
label=_('Location'),
null_option='None',
query_params={
'site_id': '$site_id'
}
)
rack_id = DynamicModelMultipleChoiceField(
queryset=Rack.objects.all(),
required=False,
label=_('Rack'),
null_option='None',
query_params={
'site_id': '$site_id',
'location_id': '$location_id',
'site_id': '$site_id'
}
)
device_id = DynamicModelMultipleChoiceField(
@@ -784,9 +770,8 @@ class CableFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
required=False,
query_params={
'site_id': '$site_id',
'location_id': '$location_id',
'rack_id': '$rack_id',
'tenant_id': '$tenant_id',
'rack_id': '$rack_id',
},
label=_('Device')
)
@@ -997,7 +982,6 @@ class InterfaceFilterForm(DeviceComponentFilterForm):
(None, ('q', 'tag')),
('Attributes', ('name', 'label', 'kind', 'type', 'speed', 'duplex', 'enabled', 'mgmt_only')),
('Addressing', ('vrf_id', 'mac_address', 'wwn')),
('PoE', ('poe_mode', 'poe_type')),
('Wireless', ('rf_role', 'rf_channel', 'rf_channel_width', 'tx_power')),
('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'virtual_chassis_id', 'device_id')),
)
@@ -1038,16 +1022,6 @@ class InterfaceFilterForm(DeviceComponentFilterForm):
required=False,
label='WWN'
)
poe_mode = MultipleChoiceField(
choices=InterfacePoEModeChoices,
required=False,
label='PoE mode'
)
poe_type = MultipleChoiceField(
choices=InterfacePoEModeChoices,
required=False,
label='PoE type'
)
rf_role = MultipleChoiceField(
choices=WirelessRoleChoices,
required=False,

View File

@@ -194,7 +194,7 @@ class LocationForm(TenancyForm, NetBoxModelForm):
fieldsets = (
('Location', (
'region', 'site_group', 'site', 'parent', 'name', 'slug', 'status', 'description', 'tags',
'region', 'site_group', 'site', 'parent', 'name', 'slug', 'description', 'tags',
)),
('Tenancy', ('tenant_group', 'tenant')),
)
@@ -202,12 +202,8 @@ class LocationForm(TenancyForm, NetBoxModelForm):
class Meta:
model = Location
fields = (
'region', 'site_group', 'site', 'parent', 'name', 'slug', 'status', 'description', 'tenant_group', 'tenant',
'tags',
'region', 'site_group', 'site', 'parent', 'name', 'slug', 'description', 'tenant_group', 'tenant', 'tags',
)
widgets = {
'status': StaticSelect(),
}
class RackRoleForm(NetBoxModelForm):
@@ -471,7 +467,7 @@ class DeviceForm(TenancyForm, NetBoxModelForm):
'location_id': '$location',
}
)
position = forms.DecimalField(
position = forms.IntegerField(
required=False,
help_text="The lowest-numbered unit occupied by the device",
widget=APISelect(
@@ -1052,14 +1048,12 @@ class InterfaceTemplateForm(BootstrapMixin, forms.ModelForm):
class Meta:
model = InterfaceTemplate
fields = [
'device_type', 'module_type', 'name', 'label', 'type', 'mgmt_only', 'description', 'poe_mode', 'poe_type',
'device_type', 'module_type', 'name', 'label', 'type', 'mgmt_only', 'description',
]
widgets = {
'device_type': forms.HiddenInput(),
'module_type': forms.HiddenInput(),
'type': StaticSelect(),
'poe_mode': StaticSelect(),
'poe_type': StaticSelect(),
}
@@ -1335,7 +1329,6 @@ class InterfaceForm(InterfaceCommonForm, NetBoxModelForm):
('Addressing', ('vrf', 'mac_address', 'wwn')),
('Operation', ('mtu', 'tx_power', 'enabled', 'mgmt_only', 'mark_connected')),
('Related Interfaces', ('parent', 'bridge', 'lag')),
('PoE', ('poe_mode', 'poe_type')),
('802.1Q Switching', ('mode', 'vlan_group', 'untagged_vlan', 'tagged_vlans')),
('Wireless', (
'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'wireless_lan_group', 'wireless_lans',
@@ -1346,16 +1339,14 @@ class InterfaceForm(InterfaceCommonForm, NetBoxModelForm):
model = Interface
fields = [
'device', 'module', 'name', 'label', 'type', 'speed', 'duplex', 'enabled', 'parent', 'bridge', 'lag',
'mac_address', 'wwn', 'mtu', 'mgmt_only', 'mark_connected', 'description', 'poe_mode', 'poe_type', 'mode',
'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'wireless_lans',
'untagged_vlan', 'tagged_vlans', 'vrf', 'tags',
'mac_address', 'wwn', 'mtu', 'mgmt_only', 'mark_connected', 'description', 'mode', 'rf_role', 'rf_channel',
'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'wireless_lans', 'untagged_vlan', 'tagged_vlans',
'vrf', 'tags',
]
widgets = {
'device': forms.HiddenInput(),
'type': StaticSelect(),
'speed': SelectSpeedWidget(),
'poe_mode': StaticSelect(),
'poe_type': StaticSelect(),
'duplex': StaticSelect(),
'mode': StaticSelect(),
'rf_role': StaticSelect(),

View File

@@ -1,6 +1,6 @@
from django import forms
from dcim.choices import InterfacePoEModeChoices, InterfacePoETypeChoices, InterfaceTypeChoices, PortTypeChoices
from dcim.choices import InterfaceTypeChoices, PortTypeChoices
from dcim.models import *
from utilities.forms import BootstrapMixin
@@ -112,21 +112,11 @@ class InterfaceTemplateImportForm(ComponentTemplateImportForm):
type = forms.ChoiceField(
choices=InterfaceTypeChoices.CHOICES
)
poe_mode = forms.ChoiceField(
choices=InterfacePoEModeChoices,
required=False,
label='PoE mode'
)
poe_type = forms.ChoiceField(
choices=InterfacePoETypeChoices,
required=False,
label='PoE type'
)
class Meta:
model = InterfaceTemplate
fields = [
'device_type', 'module_type', 'name', 'label', 'type', 'mgmt_only', 'description', 'poe_mode', 'poe_type',
'device_type', 'module_type', 'name', 'label', 'type', 'mgmt_only', 'description',
]
@@ -156,7 +146,7 @@ class FrontPortTemplateImportForm(ComponentTemplateImportForm):
class Meta:
model = FrontPortTemplate
fields = [
'device_type', 'module_type', 'name', 'type', 'rear_port', 'rear_port_position', 'label', 'description',
'device_type', 'module_type', 'name', 'type', 'color', 'rear_port', 'rear_port_position', 'label', 'description',
]
@@ -168,7 +158,7 @@ class RearPortTemplateImportForm(ComponentTemplateImportForm):
class Meta:
model = RearPortTemplate
fields = [
'device_type', 'module_type', 'name', 'type', 'positions', 'label', 'description',
'device_type', 'module_type', 'name', 'type', 'color', 'positions', 'label', 'description',
]

View File

@@ -1,5 +0,0 @@
class CabledObjectMixin:
def resolve_cable_end(self, info):
# Handle empty values
return self.cable_end or None

View File

@@ -7,7 +7,6 @@ from extras.graphql.mixins import (
from ipam.graphql.mixins import IPAddressesMixin, VLANGroupsMixin
from netbox.graphql.scalars import BigInt
from netbox.graphql.types import BaseObjectType, OrganizationalObjectType, NetBoxObjectType
from .mixins import CabledObjectMixin
__all__ = (
'CableType',
@@ -100,15 +99,7 @@ class CableType(NetBoxObjectType):
return self.length_unit or None
class CableTerminationType(NetBoxObjectType):
class Meta:
model = models.CableTermination
fields = '__all__'
filterset_class = filtersets.CableTerminationFilterSet
class ConsolePortType(ComponentObjectType, CabledObjectMixin):
class ConsolePortType(ComponentObjectType):
class Meta:
model = models.ConsolePort
@@ -130,7 +121,7 @@ class ConsolePortTemplateType(ComponentTemplateObjectType):
return self.type or None
class ConsoleServerPortType(ComponentObjectType, CabledObjectMixin):
class ConsoleServerPortType(ComponentObjectType):
class Meta:
model = models.ConsoleServerPort
@@ -212,7 +203,7 @@ class DeviceTypeType(NetBoxObjectType):
return self.airflow or None
class FrontPortType(ComponentObjectType, CabledObjectMixin):
class FrontPortType(ComponentObjectType):
class Meta:
model = models.FrontPort
@@ -228,19 +219,13 @@ class FrontPortTemplateType(ComponentTemplateObjectType):
filterset_class = filtersets.FrontPortTemplateFilterSet
class InterfaceType(IPAddressesMixin, ComponentObjectType, CabledObjectMixin):
class InterfaceType(IPAddressesMixin, ComponentObjectType):
class Meta:
model = models.Interface
exclude = ('_path',)
filterset_class = filtersets.InterfaceFilterSet
def resolve_poe_mode(self, info):
return self.poe_mode or None
def resolve_poe_type(self, info):
return self.poe_type or None
def resolve_mode(self, info):
return self.mode or None
@@ -258,12 +243,6 @@ class InterfaceTemplateType(ComponentTemplateObjectType):
fields = '__all__'
filterset_class = filtersets.InterfaceTemplateFilterSet
def resolve_poe_mode(self, info):
return self.poe_mode or None
def resolve_poe_type(self, info):
return self.poe_type or None
class InventoryItemType(ComponentObjectType):
@@ -337,7 +316,7 @@ class PlatformType(OrganizationalObjectType):
filterset_class = filtersets.PlatformFilterSet
class PowerFeedType(NetBoxObjectType, CabledObjectMixin):
class PowerFeedType(NetBoxObjectType):
class Meta:
model = models.PowerFeed
@@ -345,7 +324,7 @@ class PowerFeedType(NetBoxObjectType, CabledObjectMixin):
filterset_class = filtersets.PowerFeedFilterSet
class PowerOutletType(ComponentObjectType, CabledObjectMixin):
class PowerOutletType(ComponentObjectType):
class Meta:
model = models.PowerOutlet
@@ -381,7 +360,7 @@ class PowerPanelType(NetBoxObjectType):
filterset_class = filtersets.PowerPanelFilterSet
class PowerPortType(ComponentObjectType, CabledObjectMixin):
class PowerPortType(ComponentObjectType):
class Meta:
model = models.PowerPort
@@ -433,7 +412,7 @@ class RackRoleType(OrganizationalObjectType):
filterset_class = filtersets.RackRoleFilterSet
class RearPortType(ComponentObjectType, CabledObjectMixin):
class RearPortType(ComponentObjectType):
class Meta:
model = models.RearPort

View File

@@ -81,7 +81,7 @@ class Command(BaseCommand):
self.stdout.write(f'Retracing {origins_count} cabled {model._meta.verbose_name_plural}...')
i = 0
for i, obj in enumerate(origins, start=1):
create_cablepath([obj])
create_cablepath(obj)
if not i % 100:
self.draw_progress_bar(i * 100 / origins_count)
self.draw_progress_bar(100)

View File

@@ -1,23 +0,0 @@
import django.contrib.postgres.fields
import django.core.validators
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dcim', '0153_created_datetimefield'),
]
operations = [
migrations.AlterField(
model_name='devicetype',
name='u_height',
field=models.DecimalField(decimal_places=1, default=1.0, max_digits=4),
),
migrations.AlterField(
model_name='device',
name='position',
field=models.DecimalField(blank=True, decimal_places=1, max_digits=4, null=True, validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(99.5)]),
),
]

View File

@@ -1,33 +0,0 @@
# Generated by Django 4.0.5 on 2022-06-22 00:36
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dcim', '0154_half_height_rack_units'),
]
operations = [
migrations.AddField(
model_name='interface',
name='poe_mode',
field=models.CharField(blank=True, max_length=50),
),
migrations.AddField(
model_name='interface',
name='poe_type',
field=models.CharField(blank=True, max_length=50),
),
migrations.AddField(
model_name='interfacetemplate',
name='poe_mode',
field=models.CharField(blank=True, max_length=50),
),
migrations.AddField(
model_name='interfacetemplate',
name='poe_type',
field=models.CharField(blank=True, max_length=50),
),
]

View File

@@ -1,18 +0,0 @@
# Generated by Django 4.0.5 on 2022-06-22 17:10
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dcim', '0155_interface_poe_mode_type'),
]
operations = [
migrations.AddField(
model_name='location',
name='status',
field=models.CharField(default='active', max_length=50),
),
]

View File

@@ -1,95 +0,0 @@
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('contenttypes', '0002_remove_content_type_name'),
('dcim', '0156_location_status'),
]
operations = [
# Create CableTermination model
migrations.CreateModel(
name='CableTermination',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
('cable_end', models.CharField(max_length=1)),
('termination_id', models.PositiveBigIntegerField()),
('cable', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='terminations', to='dcim.cable')),
('termination_type', models.ForeignKey(limit_choices_to=models.Q(models.Q(models.Q(('app_label', 'circuits'), ('model__in', ('circuittermination',))), models.Q(('app_label', 'dcim'), ('model__in', ('consoleport', 'consoleserverport', 'frontport', 'interface', 'powerfeed', 'poweroutlet', 'powerport', 'rearport'))), _connector='OR')), on_delete=django.db.models.deletion.PROTECT, related_name='+', to='contenttypes.contenttype')),
('_device', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='dcim.device')),
('_rack', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='dcim.rack')),
('_location', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='dcim.location')),
('_site', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='dcim.site')),
],
options={
'ordering': ('cable', 'cable_end', 'pk'),
},
),
migrations.AddConstraint(
model_name='cabletermination',
constraint=models.UniqueConstraint(fields=('termination_type', 'termination_id'), name='dcim_cable_termination_unique_termination'),
),
# Update CablePath model
migrations.RenameField(
model_name='cablepath',
old_name='path',
new_name='_nodes',
),
migrations.AddField(
model_name='cablepath',
name='path',
field=models.JSONField(default=list),
),
migrations.AddField(
model_name='cablepath',
name='is_complete',
field=models.BooleanField(default=False),
),
# Add cable_end field to cable termination models
migrations.AddField(
model_name='consoleport',
name='cable_end',
field=models.CharField(blank=True, max_length=1),
),
migrations.AddField(
model_name='consoleserverport',
name='cable_end',
field=models.CharField(blank=True, max_length=1),
),
migrations.AddField(
model_name='frontport',
name='cable_end',
field=models.CharField(blank=True, max_length=1),
),
migrations.AddField(
model_name='interface',
name='cable_end',
field=models.CharField(blank=True, max_length=1),
),
migrations.AddField(
model_name='powerfeed',
name='cable_end',
field=models.CharField(blank=True, max_length=1),
),
migrations.AddField(
model_name='poweroutlet',
name='cable_end',
field=models.CharField(blank=True, max_length=1),
),
migrations.AddField(
model_name='powerport',
name='cable_end',
field=models.CharField(blank=True, max_length=1),
),
migrations.AddField(
model_name='rearport',
name='cable_end',
field=models.CharField(blank=True, max_length=1),
),
]

View File

@@ -1,87 +0,0 @@
import sys
from django.db import migrations
def cache_related_objects(termination):
"""
Replicate caching logic from CableTermination.cache_related_objects()
"""
attrs = {}
# Device components
if getattr(termination, 'device', None):
attrs['_device'] = termination.device
attrs['_rack'] = termination.device.rack
attrs['_location'] = termination.device.location
attrs['_site'] = termination.device.site
# Power feeds
elif getattr(termination, 'rack', None):
attrs['_rack'] = termination.rack
attrs['_location'] = termination.rack.location
attrs['_site'] = termination.rack.site
# Circuit terminations
elif getattr(termination, 'site', None):
attrs['_site'] = termination.site
return attrs
def populate_cable_terminations(apps, schema_editor):
"""
Replicate terminations from the Cable model into CableTermination instances.
"""
ContentType = apps.get_model('contenttypes', 'ContentType')
Cable = apps.get_model('dcim', 'Cable')
CableTermination = apps.get_model('dcim', 'CableTermination')
# Retrieve the necessary data from Cable objects
cables = Cable.objects.values(
'id', 'termination_a_type', 'termination_a_id', 'termination_b_type', 'termination_b_id'
)
# Queue CableTerminations to be created
cable_terminations = []
cable_count = cables.count()
for i, cable in enumerate(cables, start=1):
for cable_end in ('a', 'b'):
# We must manually instantiate the termination object, because GFK fields are not
# supported within migrations.
termination_ct = ContentType.objects.get(pk=cable[f'termination_{cable_end}_type'])
termination_model = apps.get_model(termination_ct.app_label, termination_ct.model)
termination = termination_model.objects.get(pk=cable[f'termination_{cable_end}_id'])
cable_terminations.append(CableTermination(
cable_id=cable['id'],
cable_end=cable_end.upper(),
termination_type_id=cable[f'termination_{cable_end}_type'],
termination_id=cable[f'termination_{cable_end}_id'],
**cache_related_objects(termination)
))
# Output progress occasionally
if 'test' not in sys.argv and not i % 100:
progress = float(i) * 100 / cable_count
if i == 100:
print('')
sys.stdout.write(f"\r Updated {i}/{cable_count} cables ({progress:.2f}%)")
sys.stdout.flush()
# Bulk create the termination objects
CableTermination.objects.bulk_create(cable_terminations, batch_size=100)
class Migration(migrations.Migration):
dependencies = [
('dcim', '0157_new_cabling_models'),
]
operations = [
migrations.RunPython(
code=populate_cable_terminations,
reverse_code=migrations.RunPython.noop
),
]

View File

@@ -1,50 +0,0 @@
from django.db import migrations
from dcim.utils import compile_path_node
def populate_cable_paths(apps, schema_editor):
"""
Replicate terminations from the Cable model into CableTermination instances.
"""
CablePath = apps.get_model('dcim', 'CablePath')
# Construct the new two-dimensional path, and add the origin & destination objects to the nodes list
cable_paths = []
for cablepath in CablePath.objects.all():
# Origin
origin = compile_path_node(cablepath.origin_type_id, cablepath.origin_id)
cablepath.path.append([origin])
cablepath._nodes.insert(0, origin)
# Transit nodes
cablepath.path.extend([
[node] for node in cablepath._nodes[1:]
])
# Destination
if cablepath.destination_id:
destination = compile_path_node(cablepath.destination_type_id, cablepath.destination_id)
cablepath.path.append([destination])
cablepath._nodes.append(destination)
cablepath.is_complete = True
cable_paths.append(cablepath)
# Bulk update all CableTerminations
CablePath.objects.bulk_update(cable_paths, fields=('path', '_nodes', 'is_complete'), batch_size=100)
class Migration(migrations.Migration):
dependencies = [
('dcim', '0158_populate_cable_terminations'),
]
operations = [
migrations.RunPython(
code=populate_cable_paths,
reverse_code=migrations.RunPython.noop
),
]

View File

@@ -1,46 +0,0 @@
from django.db import migrations
def populate_cable_terminations(apps, schema_editor):
Cable = apps.get_model('dcim', 'Cable')
cable_termination_models = (
apps.get_model('dcim', 'ConsolePort'),
apps.get_model('dcim', 'ConsoleServerPort'),
apps.get_model('dcim', 'PowerPort'),
apps.get_model('dcim', 'PowerOutlet'),
apps.get_model('dcim', 'Interface'),
apps.get_model('dcim', 'FrontPort'),
apps.get_model('dcim', 'RearPort'),
apps.get_model('dcim', 'PowerFeed'),
apps.get_model('circuits', 'CircuitTermination'),
)
for model in cable_termination_models:
model.objects.filter(
id__in=Cable.objects.filter(
termination_a_type__app_label=model._meta.app_label,
termination_a_type__model=model._meta.model_name
).values_list('termination_a_id', flat=True)
).update(cable_end='A')
model.objects.filter(
id__in=Cable.objects.filter(
termination_b_type__app_label=model._meta.app_label,
termination_b_type__model=model._meta.model_name
).values_list('termination_b_id', flat=True)
).update(cable_end='B')
class Migration(migrations.Migration):
dependencies = [
('circuits', '0037_new_cabling_models'),
('dcim', '0159_populate_cable_paths'),
]
operations = [
migrations.RunPython(
code=populate_cable_terminations,
reverse_code=migrations.RunPython.noop
),
]

View File

@@ -1,134 +0,0 @@
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('dcim', '0160_populate_cable_ends'),
]
operations = [
# Remove old fields from Cable
migrations.AlterModelOptions(
name='cable',
options={'ordering': ('pk',)},
),
migrations.AlterUniqueTogether(
name='cable',
unique_together=set(),
),
migrations.RemoveField(
model_name='cable',
name='termination_a_id',
),
migrations.RemoveField(
model_name='cable',
name='termination_a_type',
),
migrations.RemoveField(
model_name='cable',
name='termination_b_id',
),
migrations.RemoveField(
model_name='cable',
name='termination_b_type',
),
migrations.RemoveField(
model_name='cable',
name='_termination_a_device',
),
migrations.RemoveField(
model_name='cable',
name='_termination_b_device',
),
# Remove old fields from CablePath
migrations.AlterUniqueTogether(
name='cablepath',
unique_together=set(),
),
migrations.RemoveField(
model_name='cablepath',
name='destination_id',
),
migrations.RemoveField(
model_name='cablepath',
name='destination_type',
),
migrations.RemoveField(
model_name='cablepath',
name='origin_id',
),
migrations.RemoveField(
model_name='cablepath',
name='origin_type',
),
# Remove link peer type/ID fields from cable termination models
migrations.RemoveField(
model_name='consoleport',
name='_link_peer_id',
),
migrations.RemoveField(
model_name='consoleport',
name='_link_peer_type',
),
migrations.RemoveField(
model_name='consoleserverport',
name='_link_peer_id',
),
migrations.RemoveField(
model_name='consoleserverport',
name='_link_peer_type',
),
migrations.RemoveField(
model_name='frontport',
name='_link_peer_id',
),
migrations.RemoveField(
model_name='frontport',
name='_link_peer_type',
),
migrations.RemoveField(
model_name='interface',
name='_link_peer_id',
),
migrations.RemoveField(
model_name='interface',
name='_link_peer_type',
),
migrations.RemoveField(
model_name='powerfeed',
name='_link_peer_id',
),
migrations.RemoveField(
model_name='powerfeed',
name='_link_peer_type',
),
migrations.RemoveField(
model_name='poweroutlet',
name='_link_peer_id',
),
migrations.RemoveField(
model_name='poweroutlet',
name='_link_peer_type',
),
migrations.RemoveField(
model_name='powerport',
name='_link_peer_id',
),
migrations.RemoveField(
model_name='powerport',
name='_link_peer_type',
),
migrations.RemoveField(
model_name='rearport',
name='_link_peer_id',
),
migrations.RemoveField(
model_name='rearport',
name='_link_peer_type',
),
]

View File

@@ -1,12 +1,10 @@
import itertools
from collections import defaultdict
from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ValidationError
from django.core.exceptions import ObjectDoesNotExist, ValidationError
from django.db import models
from django.db.models import Sum
from django.dispatch import Signal
from django.urls import reverse
from dcim.choices import *
@@ -15,21 +13,17 @@ from dcim.fields import PathField
from dcim.utils import decompile_path_node, object_to_path_node, path_node_to_object
from netbox.models import NetBoxModel
from utilities.fields import ColorField
from utilities.querysets import RestrictedQuerySet
from utilities.utils import to_meters
from wireless.models import WirelessLink
from .devices import Device
from .device_components import FrontPort, RearPort
__all__ = (
'Cable',
'CablePath',
'CableTermination',
)
trace_paths = Signal()
#
# Cables
#
@@ -38,6 +32,28 @@ class Cable(NetBoxModel):
"""
A physical connection between two endpoints.
"""
termination_a_type = models.ForeignKey(
to=ContentType,
limit_choices_to=CABLE_TERMINATION_MODELS,
on_delete=models.PROTECT,
related_name='+'
)
termination_a_id = models.PositiveBigIntegerField()
termination_a = GenericForeignKey(
ct_field='termination_a_type',
fk_field='termination_a_id'
)
termination_b_type = models.ForeignKey(
to=ContentType,
limit_choices_to=CABLE_TERMINATION_MODELS,
on_delete=models.PROTECT,
related_name='+'
)
termination_b_id = models.PositiveBigIntegerField()
termination_b = GenericForeignKey(
ct_field='termination_b_type',
fk_field='termination_b_id'
)
type = models.CharField(
max_length=50,
choices=CableTypeChoices,
@@ -80,11 +96,31 @@ class Cable(NetBoxModel):
blank=True,
null=True
)
# Cache the associated device (where applicable) for the A and B terminations. This enables filtering of Cables by
# their associated Devices.
_termination_a_device = models.ForeignKey(
to=Device,
on_delete=models.CASCADE,
related_name='+',
blank=True,
null=True
)
_termination_b_device = models.ForeignKey(
to=Device,
on_delete=models.CASCADE,
related_name='+',
blank=True,
null=True
)
class Meta:
ordering = ('pk',)
ordering = ['pk']
unique_together = (
('termination_a_type', 'termination_a_id'),
('termination_b_type', 'termination_b_id'),
)
def __init__(self, *args, a_terminations=None, b_terminations=None, **kwargs):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# A copy of the PK to be used by __str__ in case the object is deleted
@@ -93,13 +129,19 @@ class Cable(NetBoxModel):
# Cache the original status so we can check later if it's been changed
self._orig_status = self.status
self._terminations_modified = False
@classmethod
def from_db(cls, db, field_names, values):
"""
Cache the original A and B terminations of existing Cable instances for later reference inside clean().
"""
instance = super().from_db(db, field_names, values)
# Assign or retrieve A/B terminations
if a_terminations:
self.a_terminations = a_terminations
if b_terminations:
self.b_terminations = b_terminations
instance._orig_termination_a_type_id = instance.termination_a_type_id
instance._orig_termination_a_id = instance.termination_a_id
instance._orig_termination_b_type_id = instance.termination_b_type_id
instance._orig_termination_b_id = instance.termination_b_id
return instance
def __str__(self):
pk = self.pk or self._pk
@@ -108,68 +150,124 @@ class Cable(NetBoxModel):
def get_absolute_url(self):
return reverse('dcim:cable', args=[self.pk])
@property
def a_terminations(self):
if hasattr(self, '_a_terminations'):
return self._a_terminations
# Query self.terminations.all() to leverage cached results
return [
ct.termination for ct in self.terminations.all() if ct.cable_end == CableEndChoices.SIDE_A
]
@a_terminations.setter
def a_terminations(self, value):
self._terminations_modified = True
self._a_terminations = value
@property
def b_terminations(self):
if hasattr(self, '_b_terminations'):
return self._b_terminations
# Query self.terminations.all() to leverage cached results
return [
ct.termination for ct in self.terminations.all() if ct.cable_end == CableEndChoices.SIDE_B
]
@b_terminations.setter
def b_terminations(self, value):
self._terminations_modified = True
self._b_terminations = value
def clean(self):
from circuits.models import CircuitTermination
super().clean()
# Validate that termination A exists
if not hasattr(self, 'termination_a_type'):
raise ValidationError('Termination A type has not been specified')
try:
self.termination_a_type.model_class().objects.get(pk=self.termination_a_id)
except ObjectDoesNotExist:
raise ValidationError({
'termination_a': 'Invalid ID for type {}'.format(self.termination_a_type)
})
# Validate that termination B exists
if not hasattr(self, 'termination_b_type'):
raise ValidationError('Termination B type has not been specified')
try:
self.termination_b_type.model_class().objects.get(pk=self.termination_b_id)
except ObjectDoesNotExist:
raise ValidationError({
'termination_b': 'Invalid ID for type {}'.format(self.termination_b_type)
})
# If editing an existing Cable instance, check that neither termination has been modified.
if self.pk:
err_msg = 'Cable termination points may not be modified. Delete and recreate the cable instead.'
if (
self.termination_a_type_id != self._orig_termination_a_type_id or
self.termination_a_id != self._orig_termination_a_id
):
raise ValidationError({
'termination_a': err_msg
})
if (
self.termination_b_type_id != self._orig_termination_b_type_id or
self.termination_b_id != self._orig_termination_b_id
):
raise ValidationError({
'termination_b': err_msg
})
type_a = self.termination_a_type.model
type_b = self.termination_b_type.model
# Validate interface types
if type_a == 'interface' and self.termination_a.type in NONCONNECTABLE_IFACE_TYPES:
raise ValidationError({
'termination_a_id': 'Cables cannot be terminated to {} interfaces'.format(
self.termination_a.get_type_display()
)
})
if type_b == 'interface' and self.termination_b.type in NONCONNECTABLE_IFACE_TYPES:
raise ValidationError({
'termination_b_id': 'Cables cannot be terminated to {} interfaces'.format(
self.termination_b.get_type_display()
)
})
# Check that termination types are compatible
if type_b not in COMPATIBLE_TERMINATION_TYPES.get(type_a):
raise ValidationError(
f"Incompatible termination types: {self.termination_a_type} and {self.termination_b_type}"
)
# Check that two connected RearPorts have the same number of positions (if both are >1)
if isinstance(self.termination_a, RearPort) and isinstance(self.termination_b, RearPort):
if self.termination_a.positions > 1 and self.termination_b.positions > 1:
if self.termination_a.positions != self.termination_b.positions:
raise ValidationError(
f"{self.termination_a} has {self.termination_a.positions} position(s) but "
f"{self.termination_b} has {self.termination_b.positions}. "
f"Both terminations must have the same number of positions (if greater than one)."
)
# A termination point cannot be connected to itself
if self.termination_a == self.termination_b:
raise ValidationError(f"Cannot connect {self.termination_a_type} to itself")
# A front port cannot be connected to its corresponding rear port
if (
type_a in ['frontport', 'rearport'] and
type_b in ['frontport', 'rearport'] and
(
getattr(self.termination_a, 'rear_port', None) == self.termination_b or
getattr(self.termination_b, 'rear_port', None) == self.termination_a
)
):
raise ValidationError("A front port cannot be connected to it corresponding rear port")
# A CircuitTermination attached to a ProviderNetwork cannot have a Cable
if isinstance(self.termination_a, CircuitTermination) and self.termination_a.provider_network is not None:
raise ValidationError({
'termination_a_id': "Circuit terminations attached to a provider network may not be cabled."
})
if isinstance(self.termination_b, CircuitTermination) and self.termination_b.provider_network is not None:
raise ValidationError({
'termination_b_id': "Circuit terminations attached to a provider network may not be cabled."
})
# Check for an existing Cable connected to either termination object
if self.termination_a.cable not in (None, self):
raise ValidationError("{} already has a cable attached (#{})".format(
self.termination_a, self.termination_a.cable_id
))
if self.termination_b.cable not in (None, self):
raise ValidationError("{} already has a cable attached (#{})".format(
self.termination_b, self.termination_b.cable_id
))
# Validate length and length_unit
if self.length is not None and not self.length_unit:
raise ValidationError("Must specify a unit when setting a cable length")
elif self.length is None:
self.length_unit = ''
if self.pk is None and (not self.a_terminations or not self.b_terminations):
raise ValidationError("Must define A and B terminations when creating a new cable.")
if self._terminations_modified:
# Check that all termination objects for either end are of the same type
for terms in (self.a_terminations, self.b_terminations):
if len(terms) > 1 and not all(isinstance(t, type(terms[0])) for t in terms[1:]):
raise ValidationError("Cannot connect different termination types to same end of cable.")
# Check that termination types are compatible
if self.a_terminations and self.b_terminations:
a_type = self.a_terminations[0]._meta.model_name
b_type = self.b_terminations[0]._meta.model_name
if b_type not in COMPATIBLE_TERMINATION_TYPES.get(a_type):
raise ValidationError(f"Incompatible termination types: {a_type} and {b_type}")
# Run clean() on any new CableTerminations
for termination in self.a_terminations:
CableTermination(cable=self, cable_end='A', termination=termination).clean()
for termination in self.b_terminations:
CableTermination(cable=self, cable_end='B', termination=termination).clean()
def save(self, *args, **kwargs):
_created = self.pk is None
# Store the given length (if any) in meters for use in database ordering
if self.length and self.length_unit:
@@ -177,447 +275,199 @@ class Cable(NetBoxModel):
else:
self._abs_length = None
# Store the parent Device for the A and B terminations (if applicable) to enable filtering
if hasattr(self.termination_a, 'device'):
self._termination_a_device = self.termination_a.device
if hasattr(self.termination_b, 'device'):
self._termination_b_device = self.termination_b.device
super().save(*args, **kwargs)
# Update the private pk used in __str__ in case this is a new object (i.e. just got its pk)
self._pk = self.pk
# Retrieve existing A/B terminations for the Cable
a_terminations = {ct.termination: ct for ct in self.terminations.filter(cable_end='A')}
b_terminations = {ct.termination: ct for ct in self.terminations.filter(cable_end='B')}
# Delete stale CableTerminations
if self._terminations_modified:
for termination, ct in a_terminations.items():
if termination.pk and termination not in self.a_terminations:
ct.delete()
for termination, ct in b_terminations.items():
if termination.pk and termination not in self.b_terminations:
ct.delete()
# Save new CableTerminations (if any)
if self._terminations_modified:
for termination in self.a_terminations:
if not termination.pk or termination not in a_terminations:
CableTermination(cable=self, cable_end='A', termination=termination).save()
for termination in self.b_terminations:
if not termination.pk or termination not in b_terminations:
CableTermination(cable=self, cable_end='B', termination=termination).save()
trace_paths.send(Cable, instance=self, created=_created)
def get_status_color(self):
return LinkStatusChoices.colors.get(self.status)
class CableTermination(models.Model):
"""
A mapping between side A or B of a Cable and a terminating object (e.g. an Interface or CircuitTermination).
"""
cable = models.ForeignKey(
to='dcim.Cable',
on_delete=models.CASCADE,
related_name='terminations'
)
cable_end = models.CharField(
max_length=1,
choices=CableEndChoices,
verbose_name='End'
)
termination_type = models.ForeignKey(
to=ContentType,
limit_choices_to=CABLE_TERMINATION_MODELS,
on_delete=models.PROTECT,
related_name='+'
)
termination_id = models.PositiveBigIntegerField()
termination = GenericForeignKey(
ct_field='termination_type',
fk_field='termination_id'
)
# Cached associations to enable efficient filtering
_device = models.ForeignKey(
to='dcim.Device',
on_delete=models.CASCADE,
blank=True,
null=True
)
_rack = models.ForeignKey(
to='dcim.Rack',
on_delete=models.CASCADE,
blank=True,
null=True
)
_location = models.ForeignKey(
to='dcim.Location',
on_delete=models.CASCADE,
blank=True,
null=True
)
_site = models.ForeignKey(
to='dcim.Site',
on_delete=models.CASCADE,
blank=True,
null=True
)
objects = RestrictedQuerySet.as_manager()
class Meta:
ordering = ('cable', 'cable_end', 'pk')
constraints = (
models.UniqueConstraint(
fields=('termination_type', 'termination_id'),
name='dcim_cable_termination_unique_termination'
),
)
def __str__(self):
return f'Cable {self.cable} to {self.termination}'
def clean(self):
super().clean()
# Validate interface type (if applicable)
if self.termination_type.model == 'interface' and self.termination.type in NONCONNECTABLE_IFACE_TYPES:
raise ValidationError({
'termination': f'Cables cannot be terminated to {self.termination.get_type_display()} interfaces'
})
# A CircuitTermination attached to a ProviderNetwork cannot have a Cable
if self.termination_type.model == 'circuittermination' and self.termination.provider_network is not None:
raise ValidationError({
'termination': "Circuit terminations attached to a provider network may not be cabled."
})
def save(self, *args, **kwargs):
# Cache objects associated with the terminating object (for filtering)
self.cache_related_objects()
super().save(*args, **kwargs)
# Set the cable on the terminating object
termination_model = self.termination._meta.model
termination_model.objects.filter(pk=self.termination_id).update(
cable=self.cable,
cable_end=self.cable_end
)
def delete(self, *args, **kwargs):
# Delete the cable association on the terminating object
termination_model = self.termination._meta.model
termination_model.objects.filter(pk=self.termination_id).update(
cable=None,
cable_end=''
)
super().delete(*args, **kwargs)
def cache_related_objects(self):
def get_compatible_types(self):
"""
Cache objects related to the termination (e.g. device, rack, site) directly on the object to
enable efficient filtering.
Return all termination types compatible with termination A.
"""
assert self.termination is not None
# Device components
if getattr(self.termination, 'device', None):
self._device = self.termination.device
self._rack = self.termination.device.rack
self._location = self.termination.device.location
self._site = self.termination.device.site
# Power feeds
elif getattr(self.termination, 'rack', None):
self._rack = self.termination.rack
self._location = self.termination.rack.location
self._site = self.termination.rack.site
# Circuit terminations
elif getattr(self.termination, 'site', None):
self._site = self.termination.site
if self.termination_a is None:
return
return COMPATIBLE_TERMINATION_TYPES[self.termination_a._meta.model_name]
class CablePath(models.Model):
"""
A CablePath instance represents the physical path from a set of origin nodes to a set of destination nodes,
including all intermediate elements.
A CablePath instance represents the physical path from an origin to a destination, including all intermediate
elements in the path. Every instance must specify an `origin`, whereas `destination` may be null (for paths which do
not terminate on a PathEndpoint).
`path` contains the ordered set of nodes, arranged in lists of (type, ID) tuples. (Each cable in the path can
terminate to one or more objects.) For example, consider the following
`path` contains a list of nodes within the path, each represented by a tuple of (type, ID). The first element in the
path must be a Cable instance, followed by a pair of pass-through ports. For example, consider the following
topology:
A B C
Interface 1 --- Front Port 1 | Rear Port 1 --- Rear Port 2 | Front Port 3 --- Interface 2
Front Port 2 Front Port 4
1 2 3
Interface A --- Front Port A | Rear Port A --- Rear Port B | Front Port B --- Interface B
This path would be expressed as:
CablePath(
path = [
[Interface 1],
[Cable A],
[Front Port 1, Front Port 2],
[Rear Port 1],
[Cable B],
[Rear Port 2],
[Front Port 3, Front Port 4],
[Cable C],
[Interface 2],
]
origin = Interface A
destination = Interface B
path = [Cable 1, Front Port A, Rear Port A, Cable 2, Rear Port B, Front Port B, Cable 3]
)
`is_active` is set to True only if every Cable within the path has a status of "connected". `is_complete` is True
if the instance represents a complete end-to-end path from origin(s) to destination(s). `is_split` is True if the
path diverges across multiple cables.
`_nodes` retains a flattened list of all nodes within the path to enable simple filtering.
`is_active` is set to True only if 1) `destination` is not null, and 2) every Cable within the path has a status of
"connected".
"""
path = models.JSONField(
default=list
origin_type = models.ForeignKey(
to=ContentType,
on_delete=models.CASCADE,
related_name='+'
)
origin_id = models.PositiveBigIntegerField()
origin = GenericForeignKey(
ct_field='origin_type',
fk_field='origin_id'
)
destination_type = models.ForeignKey(
to=ContentType,
on_delete=models.CASCADE,
related_name='+',
blank=True,
null=True
)
destination_id = models.PositiveBigIntegerField(
blank=True,
null=True
)
destination = GenericForeignKey(
ct_field='destination_type',
fk_field='destination_id'
)
path = PathField()
is_active = models.BooleanField(
default=False
)
is_complete = models.BooleanField(
default=False
)
is_split = models.BooleanField(
default=False
)
_nodes = PathField()
class Meta:
unique_together = ('origin_type', 'origin_id')
def __str__(self):
return f"Path #{self.pk}: {len(self.path)} hops"
status = ' (active)' if self.is_active else ' (split)' if self.is_split else ''
return f"Path #{self.pk}: {self.origin} to {self.destination} via {len(self.path)} nodes{status}"
def save(self, *args, **kwargs):
# Save the flattened nodes list
self._nodes = list(itertools.chain(*self.path))
super().save(*args, **kwargs)
# Record a direct reference to this CablePath on its originating object(s)
origin_model = self.origin_type.model_class()
origin_ids = [decompile_path_node(node)[1] for node in self.path[0]]
origin_model.objects.filter(pk__in=origin_ids).update(_path=self.pk)
@property
def origin_type(self):
if self.path:
ct_id, _ = decompile_path_node(self.path[0][0])
return ContentType.objects.get_for_id(ct_id)
@property
def destination_type(self):
if self.is_complete:
ct_id, _ = decompile_path_node(self.path[-1][0])
return ContentType.objects.get_for_id(ct_id)
@property
def path_objects(self):
"""
Cache and return the complete path as lists of objects, derived from their annotation within the path.
"""
if not hasattr(self, '_path_objects'):
self._path_objects = self._get_path()
return self._path_objects
@property
def origins(self):
"""
Return the list of originating objects.
"""
return self.path_objects[0]
@property
def destinations(self):
"""
Return the list of destination objects, if the path is complete.
"""
if not self.is_complete:
return []
return self.path_objects[-1]
# Record a direct reference to this CablePath on its originating object
model = self.origin._meta.model
model.objects.filter(pk=self.origin.pk).update(_path=self.pk)
@property
def segment_count(self):
return int(len(self.path) / 3)
total_length = 1 + len(self.path) + (1 if self.destination else 0)
return int(total_length / 3)
@classmethod
def from_origin(cls, terminations):
def from_origin(cls, origin):
"""
Create a new CablePath instance as traced from the given termination objects. These can be any object to which a
Cable or WirelessLink connects (interfaces, console ports, circuit termination, etc.). All terminations must be
of the same type and must belong to the same parent object.
Create a new CablePath instance as traced from the given path origin.
"""
from circuits.models import CircuitTermination
if not terminations:
if origin is None or origin.link is None:
return None
# Ensure all originating terminations are attached to the same link
if len(terminations) > 1:
assert all(t.link == terminations[0].link for t in terminations[1:])
destination = None
path = []
position_stack = []
is_complete = False
is_active = True
is_split = False
while terminations:
# Terminations must all be of the same type
assert all(isinstance(t, type(terminations[0])) for t in terminations[1:])
# Check for a split path (e.g. rear port fanning out to multiple front ports with
# different cables attached)
if len(set(t.link for t in terminations)) > 1:
is_split = True
break
# Step 1: Record the near-end termination object(s)
path.append([
object_to_path_node(t) for t in terminations
])
# Step 2: Determine the attached link (Cable or WirelessLink), if any
link = terminations[0].link
if link is None and len(path) == 1:
# If this is the start of the path and no link exists, return None
return None
elif link is None:
# Otherwise, halt the trace if no link exists
break
assert type(link) in (Cable, WirelessLink)
# Step 3: Record the link and update path status if not "connected"
path.append([object_to_path_node(link)])
if hasattr(link, 'status') and link.status != LinkStatusChoices.STATUS_CONNECTED:
node = origin
while node.link is not None:
if hasattr(node.link, 'status') and node.link.status != LinkStatusChoices.STATUS_CONNECTED:
is_active = False
# Step 4: Determine the far-end terminations
if isinstance(link, Cable):
termination_type = ContentType.objects.get_for_model(terminations[0])
local_cable_terminations = CableTermination.objects.filter(
termination_type=termination_type,
termination_id__in=[t.pk for t in terminations]
)
# Terminations must all belong to same end of Cable
local_cable_end = local_cable_terminations[0].cable_end
assert all(ct.cable_end == local_cable_end for ct in local_cable_terminations[1:])
remote_cable_terminations = CableTermination.objects.filter(
cable=link,
cable_end='A' if local_cable_end == 'B' else 'B'
)
remote_terminations = [ct.termination for ct in remote_cable_terminations]
else:
# WirelessLink
remote_terminations = [link.interface_b] if link.interface_a is terminations[0] else [link.interface_a]
# Follow the link to its far-end termination
path.append(object_to_path_node(node.link))
peer_termination = node.get_link_peer()
# Step 5: Record the far-end termination object(s)
path.append([
object_to_path_node(t) for t in remote_terminations
])
# Follow a FrontPort to its corresponding RearPort
if isinstance(peer_termination, FrontPort):
path.append(object_to_path_node(peer_termination))
node = peer_termination.rear_port
if node.positions > 1:
position_stack.append(peer_termination.rear_port_position)
path.append(object_to_path_node(node))
# Step 6: Determine the "next hop" terminations, if applicable
if not remote_terminations:
break
# Follow a RearPort to its corresponding FrontPort (if any)
elif isinstance(peer_termination, RearPort):
path.append(object_to_path_node(peer_termination))
if isinstance(remote_terminations[0], FrontPort):
# Follow FrontPorts to their corresponding RearPorts
rear_ports = RearPort.objects.filter(
pk__in=[t.rear_port_id for t in remote_terminations]
)
if len(rear_ports) > 1:
assert all(rp.positions == 1 for rp in rear_ports)
elif rear_ports[0].positions > 1:
position_stack.append([fp.rear_port_position for fp in remote_terminations])
terminations = rear_ports
elif isinstance(remote_terminations[0], RearPort):
if len(remote_terminations) > 1 or remote_terminations[0].positions == 1:
front_ports = FrontPort.objects.filter(
rear_port_id__in=[rp.pk for rp in remote_terminations],
rear_port_position=1
)
# Determine the peer FrontPort's position
if peer_termination.positions == 1:
position = 1
elif position_stack:
front_ports = FrontPort.objects.filter(
rear_port_id=remote_terminations[0].pk,
rear_port_position__in=position_stack.pop()
)
position = position_stack.pop()
else:
# No position indicated: path has split, so we stop at the RearPorts
# No position indicated: path has split, so we stop at the RearPort
is_split = True
break
terminations = front_ports
elif isinstance(remote_terminations[0], CircuitTermination):
# Follow a CircuitTermination to its corresponding CircuitTermination (A to Z or vice versa)
term_side = remote_terminations[0].term_side
assert all(ct.term_side == term_side for ct in remote_terminations[1:])
circuit_termination = CircuitTermination.objects.filter(
circuit=remote_terminations[0].circuit,
term_side='Z' if term_side == 'A' else 'A'
).first()
if circuit_termination is None:
break
elif circuit_termination.provider_network:
# Circuit terminates to a ProviderNetwork
path.extend([
[object_to_path_node(circuit_termination)],
[object_to_path_node(circuit_termination.provider_network)],
])
break
elif circuit_termination.site and not circuit_termination.cable:
# Circuit terminates to a Site
path.extend([
[object_to_path_node(circuit_termination)],
[object_to_path_node(circuit_termination.site)],
])
try:
node = FrontPort.objects.get(rear_port=peer_termination, rear_port_position=position)
path.append(object_to_path_node(node))
except ObjectDoesNotExist:
# No corresponding FrontPort found for the RearPort
break
terminations = [circuit_termination]
# Follow a CircuitTermination to its corresponding CircuitTermination (A to Z or vice versa)
elif isinstance(peer_termination, CircuitTermination):
path.append(object_to_path_node(peer_termination))
# Get peer CircuitTermination
node = peer_termination.get_peer_termination()
if node:
path.append(object_to_path_node(node))
if node.provider_network:
destination = node.provider_network
break
elif node.site and not node.cable:
destination = node.site
break
else:
# No peer CircuitTermination exists; halt the trace
break
# Anything else marks the end of the path
else:
is_complete = True
destination = peer_termination
break
if destination is None:
is_active = False
return cls(
origin=origin,
destination=destination,
path=path,
is_complete=is_complete,
is_active=is_active,
is_split=is_split
)
def retrace(self):
"""
Retrace the path from the currently-defined originating termination(s)
"""
_new = self.from_origin(self.origins)
if _new:
self.path = _new.path
self.is_complete = _new.is_complete
self.is_active = _new.is_active
self.is_split = _new.is_split
self.save()
else:
self.delete()
def _get_path(self):
def get_path(self):
"""
Return the path as a list of prefetched objects.
"""
# Compile a list of IDs to prefetch for each type of model in the path
to_prefetch = defaultdict(list)
for node in self._nodes:
for node in self.path:
ct_id, object_id = decompile_path_node(node)
to_prefetch[ct_id].append(object_id)
@@ -634,19 +484,19 @@ class CablePath(models.Model):
# Replicate the path using the prefetched objects.
path = []
for step in self.path:
nodes = []
for node in step:
ct_id, object_id = decompile_path_node(node)
try:
nodes.append(prefetched[ct_id][object_id])
except KeyError:
# Ignore stale (deleted) object IDs
pass
path.append(nodes)
for node in self.path:
ct_id, object_id = decompile_path_node(node)
path.append(prefetched[ct_id][object_id])
return path
@property
def last_node(self):
"""
Return either the destination or the last node within the path.
"""
return self.destination or path_node_to_object(self.path[-1])
def get_cable_ids(self):
"""
Return all Cable IDs within the path.
@@ -654,7 +504,7 @@ class CablePath(models.Model):
cable_ct = ContentType.objects.get_for_model(Cable).pk
cable_ids = []
for node in self._nodes:
for node in self.path:
ct, id = decompile_path_node(node)
if ct == cable_ct:
cable_ids.append(id)
@@ -677,6 +527,6 @@ class CablePath(models.Model):
"""
Return all available next segments in a split cable path.
"""
rearport = path_node_to_object(self._nodes[-1])
rearport = path_node_to_object(self.path[-1])
return FrontPort.objects.filter(rear_port=rearport)

View File

@@ -1,6 +1,6 @@
from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ValidationError
from django.core.exceptions import ObjectDoesNotExist, ValidationError
from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models
from mptt.models import MPTTModel, TreeForeignKey
@@ -357,18 +357,6 @@ class InterfaceTemplate(ModularComponentTemplateModel):
default=False,
verbose_name='Management only'
)
poe_mode = models.CharField(
max_length=50,
choices=InterfacePoEModeChoices,
blank=True,
verbose_name='PoE mode'
)
poe_type = models.CharField(
max_length=50,
choices=InterfacePoETypeChoices,
blank=True,
verbose_name='PoE type'
)
component_model = Interface
@@ -385,8 +373,6 @@ class InterfaceTemplate(ModularComponentTemplateModel):
label=self.resolve_label(kwargs.get('module')),
type=self.type,
mgmt_only=self.mgmt_only,
poe_mode=self.poe_mode,
poe_type=self.poe_type,
**kwargs
)
@@ -397,8 +383,6 @@ class InterfaceTemplate(ModularComponentTemplateModel):
'mgmt_only': self.mgmt_only,
'label': self.label,
'description': self.description,
'poe_mode': self.poe_mode,
'poe_type': self.poe_type,
}
@@ -478,6 +462,7 @@ class FrontPortTemplate(ModularComponentTemplateModel):
return {
'name': self.name,
'type': self.type,
'color': self.color,
'rear_port': self.rear_port.name,
'rear_port_position': self.rear_port_position,
'label': self.label,
@@ -527,6 +512,7 @@ class RearPortTemplate(ModularComponentTemplateModel):
return {
'name': self.name,
'type': self.type,
'color': self.color,
'positions': self.positions,
'label': self.label,
'description': self.description,

View File

@@ -1,8 +1,6 @@
from functools import cached_property
from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ValidationError
from django.core.exceptions import ObjectDoesNotExist, ValidationError
from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models
from django.db.models import Sum
@@ -12,6 +10,7 @@ from mptt.models import MPTTModel, TreeForeignKey
from dcim.choices import *
from dcim.constants import *
from dcim.fields import MACAddressField, WWNField
from dcim.svg import CableTraceSVG
from netbox.models import OrganizationalModel, NetBoxModel
from utilities.choices import ColorChoices
from utilities.fields import ColorField, NaturalOrderingField
@@ -24,7 +23,7 @@ from wireless.utils import get_channel_attr
__all__ = (
'BaseInterface',
'CabledObjectModel',
'LinkTermination',
'ConsolePort',
'ConsoleServerPort',
'DeviceBay',
@@ -103,10 +102,14 @@ class ModularComponentModel(ComponentModel):
abstract = True
class CabledObjectModel(models.Model):
class LinkTermination(models.Model):
"""
An abstract model inherited by all models to which a Cable can terminate. Provides the `cable` and `cable_end`
fields for caching cable associations, as well as `mark_connected` to designate "fake" connections.
An abstract model inherited by all models to which a Cable, WirelessLink, or other such link can terminate. Examples
include most device components, CircuitTerminations, and PowerFeeds. The `cable` and `wireless_link` fields
reference the attached Cable or WirelessLink instance, respectively.
`_link_peer` is a GenericForeignKey used to cache the far-end LinkTermination on the local instance; this is a
shortcut to referencing `instance.link.termination_b`, for example.
"""
cable = models.ForeignKey(
to='dcim.Cable',
@@ -115,21 +118,36 @@ class CabledObjectModel(models.Model):
blank=True,
null=True
)
cable_end = models.CharField(
max_length=1,
_link_peer_type = models.ForeignKey(
to=ContentType,
on_delete=models.SET_NULL,
related_name='+',
blank=True,
choices=CableEndChoices
null=True
)
_link_peer_id = models.PositiveBigIntegerField(
blank=True,
null=True
)
_link_peer = GenericForeignKey(
ct_field='_link_peer_type',
fk_field='_link_peer_id'
)
mark_connected = models.BooleanField(
default=False,
help_text="Treat as if a cable is connected"
)
cable_terminations = GenericRelation(
to='dcim.CableTermination',
content_type_field='termination_type',
object_id_field='termination_id',
related_query_name='%(class)s',
# Generic relations to Cable. These ensure that an attached Cable is deleted if the terminated object is deleted.
_cabled_as_a = GenericRelation(
to='dcim.Cable',
content_type_field='termination_a_type',
object_id_field='termination_a_id'
)
_cabled_as_b = GenericRelation(
to='dcim.Cable',
content_type_field='termination_b_type',
object_id_field='termination_b_id'
)
class Meta:
@@ -138,19 +156,22 @@ class CabledObjectModel(models.Model):
def clean(self):
super().clean()
if self.cable and not self.cable_end:
raise ValidationError({
"cable_end": "Must specify cable end (A or B) when attaching a cable."
})
if self.cable_end and not self.cable:
raise ValidationError({
"cable_end": "Cable end must not be set without a cable."
})
if self.mark_connected and self.cable:
if self.mark_connected and self.cable_id:
raise ValidationError({
"mark_connected": "Cannot mark as connected with a cable attached."
})
def get_link_peer(self):
return self._link_peer
@property
def _occupied(self):
return bool(self.mark_connected or self.cable_id)
@property
def parent_object(self):
raise NotImplementedError("CableTermination models must implement parent_object()")
@property
def link(self):
"""
@@ -158,31 +179,10 @@ class CabledObjectModel(models.Model):
"""
return self.cable
@cached_property
def link_peers(self):
if self.cable:
peers = self.cable.terminations.exclude(cable_end=self.cable_end).prefetch_related('termination')
return [peer.termination for peer in peers]
return []
@property
def _occupied(self):
return bool(self.mark_connected or self.cable_id)
@property
def parent_object(self):
raise NotImplementedError(f"{self.__class__.__name__} models must declare a parent_object property")
@property
def opposite_cable_end(self):
if not self.cable_end:
return None
return CableEndChoices.SIDE_A if self.cable_end == CableEndChoices.SIDE_B else CableEndChoices.SIDE_B
class PathEndpoint(models.Model):
"""
An abstract model inherited by any CabledObjectModel subclass which represents the end of a CablePath; specifically,
An abstract model inherited by any CableTermination subclass which represents the end of a CablePath; specifically,
these include ConsolePort, ConsoleServerPort, PowerPort, PowerOutlet, Interface, and PowerFeed.
`_path` references the CablePath originating from this instance, if any. It is set or cleared by the receivers in
@@ -205,48 +205,50 @@ class PathEndpoint(models.Model):
origin = self
path = []
# Construct the complete path (including e.g. bridged interfaces)
# Construct the complete path
while origin is not None:
if origin._path is None:
break
path.extend(origin._path.path_objects)
path.extend([origin, *origin._path.get_path()])
while (len(path) + 1) % 3:
# Pad to ensure we have complete three-tuples (e.g. for paths that end at a non-connected FrontPort)
path.append(None)
path.append(origin._path.destination)
# If the path ends at a non-connected pass-through port, pad out the link and far-end terminations
if len(path) % 3 == 1:
path.extend(([], []))
# If the path ends at a site or provider network, inject a null "link" to render an attachment
elif len(path) % 3 == 2:
path.insert(-1, [])
# Check for bridge interface to continue the trace
origin = getattr(origin._path.destination, 'bridge', None)
# Check for a bridged relationship to continue the trace
destinations = origin._path.destinations
if len(destinations) == 1:
origin = getattr(destinations[0], 'bridge', None)
else:
origin = None
# Return the path as a list of three-tuples (A termination(s), cable(s), B termination(s))
# Return the path as a list of three-tuples (A termination, cable, B termination)
return list(zip(*[iter(path)] * 3))
def get_trace_svg(self, base_url=None, width=None):
if width is not None:
trace = CableTraceSVG(self, base_url=base_url, width=width)
else:
trace = CableTraceSVG(self, base_url=base_url)
return trace.render()
@property
def path(self):
return self._path
@cached_property
def connected_endpoints(self):
@property
def connected_endpoint(self):
"""
Caching accessor for the attached CablePath's destination (if any)
"""
return self._path.destinations if self._path else []
if not hasattr(self, '_connected_endpoint'):
self._connected_endpoint = self._path.destination if self._path else None
return self._connected_endpoint
#
# Console components
#
class ConsolePort(ModularComponentModel, CabledObjectModel, PathEndpoint):
class ConsolePort(ModularComponentModel, LinkTermination, PathEndpoint):
"""
A physical console port within a Device. ConsolePorts connect to ConsoleServerPorts.
"""
@@ -273,7 +275,7 @@ class ConsolePort(ModularComponentModel, CabledObjectModel, PathEndpoint):
return reverse('dcim:consoleport', kwargs={'pk': self.pk})
class ConsoleServerPort(ModularComponentModel, CabledObjectModel, PathEndpoint):
class ConsoleServerPort(ModularComponentModel, LinkTermination, PathEndpoint):
"""
A physical port within a Device (typically a designated console server) which provides access to ConsolePorts.
"""
@@ -304,7 +306,7 @@ class ConsoleServerPort(ModularComponentModel, CabledObjectModel, PathEndpoint):
# Power components
#
class PowerPort(ModularComponentModel, CabledObjectModel, PathEndpoint):
class PowerPort(ModularComponentModel, LinkTermination, PathEndpoint):
"""
A physical power supply (intake) port within a Device. PowerPorts connect to PowerOutlets.
"""
@@ -345,57 +347,36 @@ class PowerPort(ModularComponentModel, CabledObjectModel, PathEndpoint):
'allocated_draw': f"Allocated draw cannot exceed the maximum draw ({self.maximum_draw}W)."
})
def get_downstream_powerports(self, leg=None):
"""
Return a queryset of all PowerPorts connected via cable to a child PowerOutlet. For example, in the topology
below, PP1.get_downstream_powerports() would return PP2-4.
---- PO1 <---> PP2
/
PP1 ------- PO2 <---> PP3
\
---- PO3 <---> PP4
"""
poweroutlets = self.poweroutlets.filter(cable__isnull=False)
if leg:
poweroutlets = poweroutlets.filter(feed_leg=leg)
if not poweroutlets:
return PowerPort.objects.none()
q = Q()
for poweroutlet in poweroutlets:
q |= Q(
cable=poweroutlet.cable,
cable_end=poweroutlet.opposite_cable_end
)
return PowerPort.objects.filter(q)
def get_power_draw(self):
"""
Return the allocated and maximum power draw (in VA) and child PowerOutlet count for this PowerPort.
"""
from dcim.models import PowerFeed
# Calculate aggregate draw of all child power outlets if no numbers have been defined manually
if self.allocated_draw is None and self.maximum_draw is None:
utilization = self.get_downstream_powerports().aggregate(
poweroutlet_ct = ContentType.objects.get_for_model(PowerOutlet)
outlet_ids = PowerOutlet.objects.filter(power_port=self).values_list('pk', flat=True)
utilization = PowerPort.objects.filter(
_link_peer_type=poweroutlet_ct,
_link_peer_id__in=outlet_ids
).aggregate(
maximum_draw_total=Sum('maximum_draw'),
allocated_draw_total=Sum('allocated_draw'),
)
ret = {
'allocated': utilization['allocated_draw_total'] or 0,
'maximum': utilization['maximum_draw_total'] or 0,
'outlet_count': self.poweroutlets.count(),
'outlet_count': len(outlet_ids),
'legs': [],
}
# Calculate per-leg aggregates for three-phase power feeds
if len(self.link_peers) == 1 and isinstance(self.link_peers[0], PowerFeed) and \
self.link_peers[0].phase == PowerFeedPhaseChoices.PHASE_3PHASE:
# Calculate per-leg aggregates for three-phase feeds
if getattr(self._link_peer, 'phase', None) == PowerFeedPhaseChoices.PHASE_3PHASE:
for leg, leg_name in PowerOutletFeedLegChoices:
utilization = self.get_downstream_powerports(leg=leg).aggregate(
outlet_ids = PowerOutlet.objects.filter(power_port=self, feed_leg=leg).values_list('pk', flat=True)
utilization = PowerPort.objects.filter(
_link_peer_type=poweroutlet_ct,
_link_peer_id__in=outlet_ids
).aggregate(
maximum_draw_total=Sum('maximum_draw'),
allocated_draw_total=Sum('allocated_draw'),
)
@@ -403,7 +384,7 @@ class PowerPort(ModularComponentModel, CabledObjectModel, PathEndpoint):
'name': leg_name,
'allocated': utilization['allocated_draw_total'] or 0,
'maximum': utilization['maximum_draw_total'] or 0,
'outlet_count': self.poweroutlets.filter(feed_leg=leg).count(),
'outlet_count': len(outlet_ids),
})
return ret
@@ -412,12 +393,12 @@ class PowerPort(ModularComponentModel, CabledObjectModel, PathEndpoint):
return {
'allocated': self.allocated_draw or 0,
'maximum': self.maximum_draw or 0,
'outlet_count': self.poweroutlets.count(),
'outlet_count': PowerOutlet.objects.filter(power_port=self).count(),
'legs': [],
}
class PowerOutlet(ModularComponentModel, CabledObjectModel, PathEndpoint):
class PowerOutlet(ModularComponentModel, LinkTermination, PathEndpoint):
"""
A physical power outlet (output) within a Device which provides power to a PowerPort.
"""
@@ -455,7 +436,9 @@ class PowerOutlet(ModularComponentModel, CabledObjectModel, PathEndpoint):
# Validate power port assignment
if self.power_port and self.power_port.device != self.device:
raise ValidationError(f"Parent power port ({self.power_port}) must belong to the same device")
raise ValidationError(
"Parent power port ({}) must belong to the same device".format(self.power_port)
)
#
@@ -529,7 +512,7 @@ class BaseInterface(models.Model):
return self.fhrp_group_assignments.count()
class Interface(ModularComponentModel, BaseInterface, CabledObjectModel, PathEndpoint):
class Interface(ModularComponentModel, BaseInterface, LinkTermination, PathEndpoint):
"""
A network interface within a Device. A physical Interface can connect to exactly one other Interface.
"""
@@ -606,18 +589,6 @@ class Interface(ModularComponentModel, BaseInterface, CabledObjectModel, PathEnd
validators=(MaxValueValidator(127),),
verbose_name='Transmit power (dBm)'
)
poe_mode = models.CharField(
max_length=50,
choices=InterfacePoEModeChoices,
blank=True,
verbose_name='PoE mode'
)
poe_type = models.CharField(
max_length=50,
choices=InterfacePoETypeChoices,
blank=True,
verbose_name='PoE type'
)
wireless_link = models.ForeignKey(
to='wireless.WirelessLink',
on_delete=models.SET_NULL,
@@ -665,14 +636,8 @@ class Interface(ModularComponentModel, BaseInterface, CabledObjectModel, PathEnd
object_id_field='interface_id',
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']
class Meta:
ordering = ('device', CollateAsChar('_name'))
@@ -760,24 +725,6 @@ class Interface(ModularComponentModel, BaseInterface, CabledObjectModel, PathEnd
f"of virtual chassis {self.device.virtual_chassis}."
})
# PoE validation
# Only physical interfaces may have a PoE mode/type assigned
if self.poe_mode and self.is_virtual:
raise ValidationError({
'poe_mode': "Virtual interfaces cannot have a PoE mode."
})
if self.poe_type and self.is_virtual:
raise ValidationError({
'poe_type': "Virtual interfaces cannot have a PoE type."
})
# An interface with a PoE type set must also specify a mode
if self.poe_type and not self.poe_mode:
raise ValidationError({
'poe_type': "Must specify PoE mode when designating a PoE type."
})
# Wireless validation
# RF role & channel may only be set for wireless interfaces
@@ -845,28 +792,12 @@ class Interface(ModularComponentModel, BaseInterface, CabledObjectModel, PathEnd
def link(self):
return self.cable or self.wireless_link
@cached_property
def link_peers(self):
if self.cable:
return super().link_peers
if self.wireless_link:
# Return the opposite side of the attached wireless link
if self.wireless_link.interface_a == self:
return [self.wireless_link.interface_b]
else:
return [self.wireless_link.interface_a]
return []
@property
def l2vpn_termination(self):
return self.l2vpn_terminations.first()
#
# Pass-through ports
#
class FrontPort(ModularComponentModel, CabledObjectModel):
class FrontPort(ModularComponentModel, LinkTermination):
"""
A pass-through port on the front of a Device.
"""
@@ -919,7 +850,7 @@ class FrontPort(ModularComponentModel, CabledObjectModel):
})
class RearPort(ModularComponentModel, CabledObjectModel):
class RearPort(ModularComponentModel, LinkTermination):
"""
A pass-through port on the rear of a Device.
"""

View File

@@ -1,5 +1,3 @@
import decimal
import yaml
from django.contrib.contenttypes.fields import GenericRelation
from django.core.exceptions import ValidationError
@@ -99,10 +97,8 @@ class DeviceType(NetBoxModel):
blank=True,
help_text='Discrete part number (optional)'
)
u_height = models.DecimalField(
max_digits=4,
decimal_places=1,
default=1.0,
u_height = models.PositiveSmallIntegerField(
default=1,
verbose_name='Height (U)'
)
is_full_depth = models.BooleanField(
@@ -168,7 +164,7 @@ class DeviceType(NetBoxModel):
'model': self.model,
'slug': self.slug,
'part_number': self.part_number,
'u_height': float(self.u_height),
'u_height': self.u_height,
'is_full_depth': self.is_full_depth,
'subdevice_role': self.subdevice_role,
'airflow': self.airflow,
@@ -218,12 +214,6 @@ class DeviceType(NetBoxModel):
def clean(self):
super().clean()
# U height must be divisible by 0.5
if self.u_height % decimal.Decimal(0.5):
raise ValidationError({
'u_height': "U height must be in increments of 0.5 rack units."
})
# If editing an existing DeviceType to have a larger u_height, first validate that *all* instances of it have
# room to expand within their racks. This validation will impose a very high performance penalty when there are
# many instances to check, but increasing the u_height of a DeviceType should be a very rare occurrence.
@@ -551,12 +541,10 @@ class Device(NetBoxModel, ConfigContextModel):
blank=True,
null=True
)
position = models.DecimalField(
max_digits=4,
decimal_places=1,
position = models.PositiveSmallIntegerField(
blank=True,
null=True,
validators=[MinValueValidator(1), MaxValueValidator(99.5)],
validators=[MinValueValidator(1)],
verbose_name='Position (U)',
help_text='The lowest-numbered unit occupied by the device'
)
@@ -706,11 +694,7 @@ class Device(NetBoxModel, ConfigContextModel):
'position': "Cannot select a rack position without assigning a rack.",
})
# Validate rack position and face
if self.position and self.position % decimal.Decimal(0.5):
raise ValidationError({
'position': "Position must be in increments of 0.5 rack units."
})
# Validate position/face combination
if self.position and not self.face:
raise ValidationError({
'face': "Must specify rack face when defining rack position.",

View File

@@ -9,7 +9,7 @@ from dcim.constants import *
from netbox.config import ConfigItem
from netbox.models import NetBoxModel
from utilities.validators import ExclusionValidator
from .device_components import CabledObjectModel, PathEndpoint
from .device_components import LinkTermination, PathEndpoint
__all__ = (
'PowerFeed',
@@ -67,7 +67,7 @@ class PowerPanel(NetBoxModel):
)
class PowerFeed(NetBoxModel, PathEndpoint, CabledObjectModel):
class PowerFeed(NetBoxModel, PathEndpoint, LinkTermination):
"""
An electrical circuit delivered from a PowerPanel.
"""

View File

@@ -1,4 +1,4 @@
import decimal
from collections import OrderedDict
from django.contrib.auth.models import User
from django.contrib.contenttypes.fields import GenericRelation
@@ -13,10 +13,11 @@ from django.urls import reverse
from dcim.choices import *
from dcim.constants import *
from dcim.svg import RackElevationSVG
from netbox.config import get_config
from netbox.models import OrganizationalModel, NetBoxModel
from utilities.choices import ColorChoices
from utilities.fields import ColorField, NaturalOrderingField
from utilities.utils import array_to_string, drange
from utilities.utils import array_to_string
from .device_components import PowerOutlet, PowerPort
from .devices import Device
from .power import PowerFeed
@@ -241,12 +242,10 @@ class Rack(NetBoxModel):
@property
def units(self):
"""
Return a list of unit numbers, top to bottom.
"""
if self.desc_units:
return drange(decimal.Decimal(1.0), self.u_height + 1, 0.5)
return drange(self.u_height + decimal.Decimal(0.5), 0.5, -0.5)
return range(1, self.u_height + 1)
else:
return reversed(range(1, self.u_height + 1))
def get_status_color(self):
return RackStatusChoices.colors.get(self.status)
@@ -264,12 +263,12 @@ class Rack(NetBoxModel):
reference to the device. When False, only the bottom most unit for a device is included and that unit
contains a height attribute for the device
"""
elevation = {}
elevation = OrderedDict()
for u in self.units:
u_name = f'U{u}'.split('.')[0] if not u % 1 else f'U{u}'
elevation[u] = {
'id': u,
'name': u_name,
'name': f'U{u}',
'face': face,
'device': None,
'occupied': False
@@ -279,7 +278,7 @@ class Rack(NetBoxModel):
if self.pk:
# Retrieve all devices installed within the rack
devices = Device.objects.prefetch_related(
queryset = Device.objects.prefetch_related(
'device_type',
'device_type__manufacturer',
'device_role'
@@ -300,9 +299,9 @@ class Rack(NetBoxModel):
if user is not None:
permitted_device_ids = self.devices.restrict(user, 'view').values_list('pk', flat=True)
for device in devices:
for device in queryset:
if expand_devices:
for u in drange(device.position, device.position + device.device_type.u_height, 0.5):
for u in range(device.position, device.position + device.device_type.u_height):
if user is None or device.pk in permitted_device_ids:
elevation[u]['device'] = device
elevation[u]['occupied'] = True
@@ -311,6 +310,8 @@ class Rack(NetBoxModel):
elevation[device.position]['device'] = device
elevation[device.position]['occupied'] = True
elevation[device.position]['height'] = device.device_type.u_height
for u in range(device.position + 1, device.position + device.device_type.u_height):
elevation.pop(u, None)
return [u for u in elevation.values()]
@@ -330,12 +331,12 @@ class Rack(NetBoxModel):
devices = devices.exclude(pk__in=exclude)
# Initialize the rack unit skeleton
units = list(self.units)
units = list(range(1, self.u_height + 1))
# Remove units consumed by installed devices
for d in devices:
if rack_face is None or d.face == rack_face or d.device_type.is_full_depth:
for u in drange(d.position, d.position + d.device_type.u_height, 0.5):
for u in range(d.position, d.position + d.device_type.u_height):
try:
units.remove(u)
except ValueError:
@@ -345,7 +346,7 @@ class Rack(NetBoxModel):
# Remove units without enough space above them to accommodate a device of the specified height
available_units = []
for u in units:
if set(drange(u, u + u_height, 0.5)).issubset(units):
if set(range(u, u + u_height)).issubset(units):
available_units.append(u)
return list(reversed(available_units))
@@ -355,9 +356,9 @@ class Rack(NetBoxModel):
Return a dictionary mapping all reserved units within the rack to their reservation.
"""
reserved_units = {}
for reservation in self.reservations.all():
for u in reservation.units:
reserved_units[u] = reservation
for r in self.reservations.all():
for u in r.units:
reserved_units[u] = r
return reserved_units
def get_elevation_svg(
@@ -366,11 +367,9 @@ class Rack(NetBoxModel):
user=None,
unit_width=None,
unit_height=None,
legend_width=RACK_ELEVATION_DEFAULT_LEGEND_WIDTH,
margin_width=RACK_ELEVATION_DEFAULT_MARGIN_WIDTH,
legend_width=RACK_ELEVATION_LEGEND_WIDTH_DEFAULT,
include_images=True,
base_url=None,
highlight_params=None
base_url=None
):
"""
Return an SVG of the rack elevation
@@ -382,23 +381,16 @@ class Rack(NetBoxModel):
:param unit_height: Height of each rack unit for the rendered drawing. Note this is not the total
height of the elevation
:param legend_width: Width of the unit legend, in pixels
:param margin_width: Width of the rigth-hand margin, in pixels
:param include_images: Embed front/rear device images where available
:param base_url: Base URL for links and images. If none, URLs will be relative.
"""
elevation = RackElevationSVG(
self,
unit_width=unit_width,
unit_height=unit_height,
legend_width=legend_width,
margin_width=margin_width,
user=user,
include_images=include_images,
base_url=base_url,
highlight_params=highlight_params
)
elevation = RackElevationSVG(self, user=user, include_images=include_images, base_url=base_url)
if unit_width is None or unit_height is None:
config = get_config()
unit_width = unit_width or config.RACK_ELEVATION_DEFAULT_UNIT_WIDTH
unit_height = unit_height or config.RACK_ELEVATION_DEFAULT_UNIT_HEIGHT
return elevation.render(face)
return elevation.render(face, unit_width, unit_height, legend_width)
def get_0u_devices(self):
return self.devices.filter(position=0)
@@ -409,7 +401,6 @@ class Rack(NetBoxModel):
as utilized.
"""
# Determine unoccupied units
total_units = len(list(self.units))
available_units = self.get_available_units()
# Remove reserved units
@@ -417,8 +408,8 @@ class Rack(NetBoxModel):
if u in available_units:
available_units.remove(u)
occupied_unit_count = total_units - len(available_units)
percentage = float(occupied_unit_count) / total_units * 100
occupied_unit_count = self.u_height - len(available_units)
percentage = float(occupied_unit_count) / self.u_height * 100
return percentage
@@ -431,17 +422,17 @@ class Rack(NetBoxModel):
if not available_power_total:
return 0
powerports = []
for powerfeed in powerfeeds:
powerports.extend([
peer for peer in powerfeed.link_peers if isinstance(peer, PowerPort)
])
pf_powerports = PowerPort.objects.filter(
_link_peer_type=ContentType.objects.get_for_model(PowerFeed),
_link_peer_id__in=powerfeeds.values_list('id', flat=True)
)
poweroutlets = PowerOutlet.objects.filter(power_port_id__in=pf_powerports)
allocated_draw_total = PowerPort.objects.filter(
_link_peer_type=ContentType.objects.get_for_model(PowerOutlet),
_link_peer_id__in=poweroutlets.values_list('id', flat=True)
).aggregate(Sum('allocated_draw'))['allocated_draw__sum'] or 0
allocated_draw = sum([
powerport.get_power_draw()['allocated'] for powerport in powerports
])
return int(allocated_draw / available_power_total * 100)
return int(allocated_draw_total / available_power_total * 100)
class RackReservation(NetBoxModel):

View File

@@ -341,11 +341,6 @@ class Location(NestedGroupModel):
null=True,
db_index=True
)
status = models.CharField(
max_length=50,
choices=LocationStatusChoices,
default=LocationStatusChoices.STATUS_ACTIVE
)
tenant = models.ForeignKey(
to='tenancy.Tenant',
on_delete=models.PROTECT,
@@ -372,7 +367,7 @@ class Location(NestedGroupModel):
to='extras.ImageAttachment'
)
clone_fields = ['site', 'parent', 'status', 'tenant', 'description']
clone_fields = ['site', 'parent', 'tenant', 'description']
class Meta:
ordering = ['site', 'name']
@@ -414,9 +409,6 @@ class Location(NestedGroupModel):
def get_absolute_url(self):
return reverse('dcim:location', args=[self.pk])
def get_status_color(self):
return LocationStatusChoices.colors.get(self.status)
def clean(self):
super().clean()

View File

@@ -1,11 +1,11 @@
import logging
from django.contrib.contenttypes.models import ContentType
from django.db.models.signals import post_save, post_delete, pre_delete
from django.dispatch import receiver
from .choices import CableEndChoices, LinkStatusChoices
from .models import Cable, CablePath, CableTermination, Device, PathEndpoint, PowerPanel, Rack, Location, VirtualChassis
from .models.cables import trace_paths
from .choices import LinkStatusChoices
from .models import Cable, CablePath, Device, PathEndpoint, PowerPanel, Rack, Location, VirtualChassis
from .utils import create_cablepath, rebuild_paths
@@ -68,58 +68,73 @@ def clear_virtualchassis_members(instance, **kwargs):
# Cables
#
@receiver(trace_paths, sender=Cable)
@receiver(post_save, sender=Cable)
def update_connected_endpoints(instance, created, raw=False, **kwargs):
"""
When a Cable is saved with new terminations, retrace any affected cable paths.
When a Cable is saved, check for and update its two connected endpoints
"""
logger = logging.getLogger('netbox.dcim.cable')
if raw:
logger.debug(f"Skipping endpoint updates for imported cable {instance}")
return
# Update cable paths if new terminations have been set
if instance._terminations_modified:
a_terminations = []
b_terminations = []
for t in instance.terminations.all():
if t.cable_end == CableEndChoices.SIDE_A:
a_terminations.append(t.termination)
else:
b_terminations.append(t.termination)
for nodes in [a_terminations, b_terminations]:
# Examine type of first termination to determine object type (all must be the same)
if not nodes:
continue
if isinstance(nodes[0], PathEndpoint):
create_cablepath(nodes)
else:
rebuild_paths(nodes)
# Cache the Cable on its two termination points
if instance.termination_a.cable != instance:
logger.debug(f"Updating termination A for cable {instance}")
instance.termination_a.cable = instance
instance.termination_a._link_peer = instance.termination_b
instance.termination_a.save()
if instance.termination_b.cable != instance:
logger.debug(f"Updating termination B for cable {instance}")
instance.termination_b.cable = instance
instance.termination_b._link_peer = instance.termination_a
instance.termination_b.save()
# Update status of CablePaths if Cable status has been changed
# Create/update cable paths
if created:
for termination in (instance.termination_a, instance.termination_b):
if isinstance(termination, PathEndpoint):
create_cablepath(termination)
else:
rebuild_paths(termination)
elif instance.status != instance._orig_status:
# We currently don't support modifying either termination of an existing Cable. (This
# may change in the future.) However, we do need to capture status changes and update
# any CablePaths accordingly.
if instance.status != LinkStatusChoices.STATUS_CONNECTED:
CablePath.objects.filter(_nodes__contains=instance).update(is_active=False)
CablePath.objects.filter(path__contains=instance).update(is_active=False)
else:
rebuild_paths([instance])
rebuild_paths(instance)
@receiver(post_delete, sender=Cable)
def retrace_cable_paths(instance, **kwargs):
"""
When a Cable is deleted, check for and update its connected endpoints
"""
for cablepath in CablePath.objects.filter(_nodes__contains=instance):
cablepath.retrace()
@receiver(post_delete, sender=CableTermination)
def nullify_connected_endpoints(instance, **kwargs):
"""
Disassociate the Cable from the termination object, and retrace any affected CablePaths.
When a Cable is deleted, check for and update its two connected endpoints
"""
model = instance.termination_type.model_class()
model.objects.filter(pk=instance.termination_id).update(cable=None, cable_end='')
logger = logging.getLogger('netbox.dcim.cable')
for cablepath in CablePath.objects.filter(_nodes__contains=instance.cable):
cablepath.retrace()
# Disassociate the Cable from its termination points
if instance.termination_a is not None:
logger.debug(f"Nullifying termination A for cable {instance}")
model = instance.termination_a._meta.model
model.objects.filter(pk=instance.termination_a.pk).update(_link_peer_type=None, _link_peer_id=None)
if instance.termination_b is not None:
logger.debug(f"Nullifying termination B for cable {instance}")
model = instance.termination_b._meta.model
model.objects.filter(pk=instance.termination_b.pk).update(_link_peer_type=None, _link_peer_id=None)
# Delete and retrace any dependent cable paths
for cablepath in CablePath.objects.filter(path__contains=instance):
cp = CablePath.from_origin(cablepath.origin)
if cp:
CablePath.objects.filter(pk=cablepath.pk).update(
path=cp.path,
destination_type=ContentType.objects.get_for_model(cp.destination) if cp.destination else None,
destination_id=cp.destination.pk if cp.destination else None,
is_active=cp.is_active,
is_split=cp.is_split
)
else:
cablepath.delete()

599
netbox/dcim/svg.py Normal file
View File

@@ -0,0 +1,599 @@
import svgwrite
from svgwrite.container import Group, Hyperlink
from svgwrite.shapes import Line, Rect
from svgwrite.text import Text
from django.conf import settings
from django.urls import reverse
from django.utils.http import urlencode
from utilities.utils import foreground_color
from .choices import DeviceFaceChoices
from .constants import RACK_ELEVATION_BORDER_WIDTH
__all__ = (
'CableTraceSVG',
'RackElevationSVG',
)
def get_device_name(device):
if device.virtual_chassis:
return f'{device.virtual_chassis.name}:{device.vc_position}'
elif device.name:
return device.name
else:
return str(device.device_type)
class RackElevationSVG:
"""
Use this class to render a rack elevation as an SVG image.
:param rack: A NetBox Rack instance
:param user: User instance. If specified, only devices viewable by this user will be fully displayed.
:param include_images: If true, the SVG document will embed front/rear device face images, where available
:param base_url: Base URL for links within the SVG document. If none, links will be relative.
"""
def __init__(self, rack, user=None, include_images=True, base_url=None):
self.rack = rack
self.include_images = include_images
if base_url is not None:
self.base_url = base_url.rstrip('/')
else:
self.base_url = ''
# Determine the subset of devices within this rack that are viewable by the user, if any
permitted_devices = self.rack.devices
if user is not None:
permitted_devices = permitted_devices.restrict(user, 'view')
self.permitted_device_ids = permitted_devices.values_list('pk', flat=True)
@staticmethod
def _get_device_description(device):
return '{} ({}) — {} {} ({}U) {} {}'.format(
device.name,
device.device_role,
device.device_type.manufacturer.name,
device.device_type.model,
device.device_type.u_height,
device.asset_tag or '',
device.serial or ''
)
@staticmethod
def _add_gradient(drawing, id_, color):
gradient = drawing.linearGradient(
start=(0, 0),
end=(0, 25),
spreadMethod='repeat',
id_=id_,
gradientTransform='rotate(45, 0, 0)',
gradientUnits='userSpaceOnUse'
)
gradient.add_stop_color(offset='0%', color='#f7f7f7')
gradient.add_stop_color(offset='50%', color='#f7f7f7')
gradient.add_stop_color(offset='50%', color=color)
gradient.add_stop_color(offset='100%', color=color)
drawing.defs.add(gradient)
@staticmethod
def _setup_drawing(width, height):
drawing = svgwrite.Drawing(size=(width, height))
# add the stylesheet
with open('{}/rack_elevation.css'.format(settings.STATIC_ROOT)) as css_file:
drawing.defs.add(drawing.style(css_file.read()))
# add gradients
RackElevationSVG._add_gradient(drawing, 'reserved', '#c7c7ff')
RackElevationSVG._add_gradient(drawing, 'occupied', '#d7d7d7')
RackElevationSVG._add_gradient(drawing, 'blocked', '#ffc0c0')
return drawing
def _draw_device_front(self, drawing, device, start, end, text):
name = get_device_name(device)
if device.devicebay_count:
name += ' ({}/{})'.format(device.get_children().count(), device.devicebay_count)
color = device.device_role.color
link = drawing.add(
drawing.a(
href='{}{}'.format(self.base_url, reverse('dcim:device', kwargs={'pk': device.pk})),
target='_top',
fill='black'
)
)
link.set_desc(self._get_device_description(device))
link.add(drawing.rect(start, end, style='fill: #{}'.format(color), class_='slot'))
hex_color = '#{}'.format(foreground_color(color))
link.add(drawing.text(str(name), insert=text, fill=hex_color))
# Embed front device type image if one exists
if self.include_images and device.device_type.front_image:
url = device.device_type.front_image.url
# Convert any relative URLs to absolute
if url.startswith('/'):
url = '{}{}'.format(self.base_url, url)
image = drawing.image(
href=url,
insert=start,
size=end,
class_='device-image'
)
image.fit(scale='slice')
link.add(image)
link.add(drawing.text(str(name), insert=text, stroke='black',
stroke_width='0.2em', stroke_linejoin='round', class_='device-image-label'))
link.add(drawing.text(str(name), insert=text, fill='white', class_='device-image-label'))
def _draw_device_rear(self, drawing, device, start, end, text):
link = drawing.add(
drawing.a(
href='{}{}'.format(self.base_url, reverse('dcim:device', kwargs={'pk': device.pk})),
target='_top',
fill='black'
)
)
link.set_desc(self._get_device_description(device))
link.add(drawing.rect(start, end, class_="slot blocked"))
link.add(drawing.text(get_device_name(device), insert=text))
# Embed rear device type image if one exists
if self.include_images and device.device_type.rear_image:
url = device.device_type.rear_image.url
# Convert any relative URLs to absolute
if url.startswith('/'):
url = '{}{}'.format(self.base_url, url)
image = drawing.image(
href=url,
insert=start,
size=end,
class_='device-image'
)
image.fit(scale='slice')
link.add(image)
link.add(drawing.text(get_device_name(device), insert=text, stroke='black',
stroke_width='0.2em', stroke_linejoin='round', class_='device-image-label'))
link.add(drawing.text(get_device_name(device), insert=text, fill='white', class_='device-image-label'))
def _draw_empty(self, drawing, rack, start, end, text, id_, face_id, class_, reservation):
link_url = '{}{}?{}'.format(
self.base_url,
reverse('dcim:device_add'),
urlencode({
'site': rack.site.pk,
'location': rack.location.pk if rack.location else '',
'rack': rack.pk,
'face': face_id,
'position': id_
})
)
link = drawing.add(
drawing.a(href=link_url, target='_top')
)
if reservation:
link.set_desc('{}{} · {}'.format(
reservation.description, reservation.user, reservation.created
))
link.add(drawing.rect(start, end, class_=class_))
link.add(drawing.text("add device", insert=text, class_='add-device'))
def merge_elevations(self, face):
elevation = self.rack.get_rack_units(face=face, expand_devices=False)
if face == DeviceFaceChoices.FACE_REAR:
other_face = DeviceFaceChoices.FACE_FRONT
else:
other_face = DeviceFaceChoices.FACE_REAR
other = self.rack.get_rack_units(face=other_face)
unit_cursor = 0
for u in elevation:
o = other[unit_cursor]
if not u['device'] and o['device'] and o['device'].device_type.is_full_depth:
u['device'] = o['device']
u['height'] = 1
unit_cursor += u.get('height', 1)
return elevation
def render(self, face, unit_width, unit_height, legend_width):
"""
Return an SVG document representing a rack elevation.
"""
drawing = self._setup_drawing(
unit_width + legend_width + RACK_ELEVATION_BORDER_WIDTH * 2,
unit_height * self.rack.u_height + RACK_ELEVATION_BORDER_WIDTH * 2
)
reserved_units = self.rack.get_reserved_units()
unit_cursor = 0
for ru in range(0, self.rack.u_height):
start_y = ru * unit_height
position_coordinates = (legend_width / 2, start_y + unit_height / 2 + RACK_ELEVATION_BORDER_WIDTH)
unit = ru + 1 if self.rack.desc_units else self.rack.u_height - ru
drawing.add(
drawing.text(str(unit), position_coordinates, class_="unit")
)
for unit in self.merge_elevations(face):
# Loop through all units in the elevation
device = unit['device']
height = unit.get('height', 1)
# Setup drawing coordinates
x_offset = legend_width + RACK_ELEVATION_BORDER_WIDTH
y_offset = unit_cursor * unit_height + RACK_ELEVATION_BORDER_WIDTH
end_y = unit_height * height
start_cordinates = (x_offset, y_offset)
end_cordinates = (unit_width, end_y)
text_cordinates = (x_offset + (unit_width / 2), y_offset + end_y / 2)
# Draw the device
if device and device.face == face and device.pk in self.permitted_device_ids:
self._draw_device_front(drawing, device, start_cordinates, end_cordinates, text_cordinates)
elif device and device.device_type.is_full_depth and device.pk in self.permitted_device_ids:
self._draw_device_rear(drawing, device, start_cordinates, end_cordinates, text_cordinates)
elif device:
# Devices which the user does not have permission to view are rendered only as unavailable space
drawing.add(drawing.rect(start_cordinates, end_cordinates, class_='blocked'))
else:
# Draw shallow devices, reservations, or empty units
class_ = 'slot'
reservation = reserved_units.get(unit["id"])
if device:
class_ += ' occupied'
if reservation:
class_ += ' reserved'
self._draw_empty(
drawing,
self.rack,
start_cordinates,
end_cordinates,
text_cordinates,
unit["id"],
face,
class_,
reservation
)
unit_cursor += height
# Wrap the drawing with a border
border_width = RACK_ELEVATION_BORDER_WIDTH
border_offset = RACK_ELEVATION_BORDER_WIDTH / 2
frame = drawing.rect(
insert=(legend_width + border_offset, border_offset),
size=(unit_width + border_width, self.rack.u_height * unit_height + border_width),
class_='rack'
)
drawing.add(frame)
return drawing
OFFSET = 0.5
PADDING = 10
LINE_HEIGHT = 20
class CableTraceSVG:
"""
Generate a graphical representation of a CablePath in SVG format.
:param origin: The originating termination
:param width: Width of the generated image (in pixels)
:param base_url: Base URL for links within the SVG document. If none, links will be relative.
"""
def __init__(self, origin, width=400, base_url=None):
self.origin = origin
self.width = width
self.base_url = base_url.rstrip('/') if base_url is not None else ''
# Establish a cursor to track position on the y axis
# Center edges on pixels to render sharp borders
self.cursor = OFFSET
@property
def center(self):
return self.width / 2
@classmethod
def _get_labels(cls, instance):
"""
Return a list of text labels for the given instance based on model type.
"""
labels = [str(instance)]
if instance._meta.model_name == 'device':
labels.append(f'{instance.device_type.manufacturer} {instance.device_type}')
location_label = f'{instance.site}'
if instance.location:
location_label += f' / {instance.location}'
if instance.rack:
location_label += f' / {instance.rack}'
labels.append(location_label)
elif instance._meta.model_name == 'circuit':
labels[0] = f'Circuit {instance}'
labels.append(instance.provider)
elif instance._meta.model_name == 'circuittermination':
if instance.xconnect_id:
labels.append(f'{instance.xconnect_id}')
elif instance._meta.model_name == 'providernetwork':
labels.append(instance.provider)
return labels
@classmethod
def _get_color(cls, instance):
"""
Return the appropriate fill color for an object within a cable path.
"""
if hasattr(instance, 'parent_object'):
# Termination
return 'f0f0f0'
if hasattr(instance, 'device_role'):
# Device
return instance.device_role.color
else:
# Other parent object
return 'e0e0e0'
def _draw_box(self, width, color, url, labels, y_indent=0, padding_multiplier=1, radius=10):
"""
Return an SVG Link element containing a Rect and one or more text labels representing a
parent object or cable termination point.
:param width: Box width
:param color: Box fill color
:param url: Hyperlink URL
:param labels: Iterable of text labels
:param y_indent: Vertical indent (for overlapping other boxes) (default: 0)
:param padding_multiplier: Add extra vertical padding (default: 1)
:param radius: Box corner radius (default: 10)
"""
self.cursor -= y_indent
# Create a hyperlink
link = Hyperlink(href=f'{self.base_url}{url}', target='_blank')
# Add the box
position = (
OFFSET + (self.width - width) / 2,
self.cursor
)
height = PADDING * padding_multiplier \
+ LINE_HEIGHT * len(labels) \
+ PADDING * padding_multiplier
box = Rect(position, (width - 2, height), rx=radius, class_='parent-object', style=f'fill: #{color}')
link.add(box)
self.cursor += PADDING * padding_multiplier
# Add text label(s)
for i, label in enumerate(labels):
self.cursor += LINE_HEIGHT
text_coords = (self.center, self.cursor - LINE_HEIGHT / 2)
text_color = f'#{foreground_color(color, dark="303030")}'
text = Text(label, insert=text_coords, fill=text_color, class_='bold' if not i else [])
link.add(text)
self.cursor += PADDING * padding_multiplier
return link
def _draw_cable(self, color, url, labels):
"""
Return an SVG group containing a line element and text labels representing a Cable.
:param color: Cable (line) color
:param url: Hyperlink URL
:param labels: Iterable of text labels
"""
group = Group(class_='connector')
# Draw a "shadow" line to give the cable a border
start = (OFFSET + self.center, self.cursor)
height = PADDING * 2 + LINE_HEIGHT * len(labels) + PADDING * 2
end = (start[0], start[1] + height)
cable_shadow = Line(start=start, end=end, class_='cable-shadow')
group.add(cable_shadow)
# Draw the cable
cable = Line(start=start, end=end, style=f'stroke: #{color}')
group.add(cable)
self.cursor += PADDING * 2
# Add link
link = Hyperlink(href=f'{self.base_url}{url}', target='_blank')
# Add text label(s)
for i, label in enumerate(labels):
self.cursor += LINE_HEIGHT
text_coords = (self.center + PADDING * 2, self.cursor - LINE_HEIGHT / 2)
text = Text(label, insert=text_coords, class_='bold' if not i else [])
link.add(text)
group.add(link)
self.cursor += PADDING * 2
return group
def _draw_wirelesslink(self, url, labels):
"""
Draw a line with labels representing a WirelessLink.
:param url: Hyperlink URL
:param labels: Iterable of text labels
"""
group = Group(class_='connector')
# Draw the wireless link
start = (OFFSET + self.center, self.cursor)
height = PADDING * 2 + LINE_HEIGHT * len(labels) + PADDING * 2
end = (start[0], start[1] + height)
line = Line(start=start, end=end, class_='wireless-link')
group.add(line)
self.cursor += PADDING * 2
# Add link
link = Hyperlink(href=f'{self.base_url}{url}', target='_blank')
# Add text label(s)
for i, label in enumerate(labels):
self.cursor += LINE_HEIGHT
text_coords = (self.center + PADDING * 2, self.cursor - LINE_HEIGHT / 2)
text = Text(label, insert=text_coords, class_='bold' if not i else [])
link.add(text)
group.add(link)
self.cursor += PADDING * 2
return group
def _draw_attachment(self):
"""
Return an SVG group containing a line element and "Attachment" label.
"""
group = Group(class_='connector')
# Draw attachment (line)
start = (OFFSET + self.center, OFFSET + self.cursor)
height = PADDING * 2 + LINE_HEIGHT + PADDING * 2
end = (start[0], start[1] + height)
line = Line(start=start, end=end, class_='attachment')
group.add(line)
self.cursor += PADDING * 4
return group
def render(self):
"""
Return an SVG document representing a cable trace.
"""
from dcim.models import Cable
from wireless.models import WirelessLink
traced_path = self.origin.trace()
# Prep elements list
parent_objects = []
terminations = []
connectors = []
# Iterate through each (term, cable, term) segment in the path
for i, segment in enumerate(traced_path):
near_end, connector, far_end = segment
# Near end parent
if i == 0:
# If this is the first segment, draw the originating termination's parent object
parent_object = self._draw_box(
width=self.width,
color=self._get_color(near_end.parent_object),
url=near_end.parent_object.get_absolute_url(),
labels=self._get_labels(near_end.parent_object),
padding_multiplier=2
)
parent_objects.append(parent_object)
# Near end termination
if near_end is not None:
termination = self._draw_box(
width=self.width * .8,
color=self._get_color(near_end),
url=near_end.get_absolute_url(),
labels=self._get_labels(near_end),
y_indent=PADDING,
radius=5
)
terminations.append(termination)
# Connector (a Cable or WirelessLink)
if connector is not None:
# Cable
if type(connector) is Cable:
connector_labels = [
f'Cable {connector}',
connector.get_status_display()
]
if connector.type:
connector_labels.append(connector.get_type_display())
if connector.length and connector.length_unit:
connector_labels.append(f'{connector.length} {connector.get_length_unit_display()}')
cable = self._draw_cable(
color=connector.color or '000000',
url=connector.get_absolute_url(),
labels=connector_labels
)
connectors.append(cable)
# WirelessLink
elif type(connector) is WirelessLink:
connector_labels = [
f'Wireless link {connector}',
connector.get_status_display()
]
if connector.ssid:
connector_labels.append(connector.ssid)
wirelesslink = self._draw_wirelesslink(
url=connector.get_absolute_url(),
labels=connector_labels
)
connectors.append(wirelesslink)
# Far end termination
termination = self._draw_box(
width=self.width * .8,
color=self._get_color(far_end),
url=far_end.get_absolute_url(),
labels=self._get_labels(far_end),
radius=5
)
terminations.append(termination)
# Far end parent
parent_object = self._draw_box(
width=self.width,
color=self._get_color(far_end.parent_object),
url=far_end.parent_object.get_absolute_url(),
labels=self._get_labels(far_end.parent_object),
y_indent=PADDING,
padding_multiplier=2
)
parent_objects.append(parent_object)
elif far_end:
# Attachment
attachment = self._draw_attachment()
connectors.append(attachment)
# ProviderNetwork
parent_object = self._draw_box(
width=self.width,
color=self._get_color(far_end),
url=far_end.get_absolute_url(),
labels=self._get_labels(far_end),
padding_multiplier=2
)
parent_objects.append(parent_object)
# Determine drawing size
self.drawing = svgwrite.Drawing(
size=(self.width, self.cursor + 2)
)
# Attach CSS stylesheet
with open(f'{settings.STATIC_ROOT}/cable_trace.css') as css_file:
self.drawing.defs.add(self.drawing.style(css_file.read()))
# Add elements to the drawing in order of depth (Z axis)
for element in connectors + parent_objects + terminations:
self.drawing.add(element)
return self.drawing

View File

@@ -1,2 +0,0 @@
from .cables import *
from .racks import *

View File

@@ -1,399 +0,0 @@
import svgwrite
from svgwrite.container import Group, Hyperlink
from svgwrite.shapes import Line, Polyline, Rect
from svgwrite.text import Text
from django.conf import settings
from dcim.constants import CABLE_TRACE_SVG_DEFAULT_WIDTH
from utilities.utils import foreground_color
__all__ = (
'CableTraceSVG',
)
OFFSET = 0.5
PADDING = 10
LINE_HEIGHT = 20
FANOUT_HEIGHT = 35
FANOUT_LEG_HEIGHT = 15
class Node(Hyperlink):
"""
Create a node to be represented in the SVG document as a rectangular box with a hyperlink.
Arguments:
position: (x, y) coordinates of the box's top left corner
width: Box width
url: Hyperlink URL
color: Box fill color (RRGGBB format)
labels: An iterable of text strings. Each label will render on a new line within the box.
radius: Box corner radius, for rounded corners (default: 10)
"""
def __init__(self, position, width, url, color, labels, radius=10, **extra):
super(Node, self).__init__(href=url, target='_blank', **extra)
x, y = position
# Add the box
dimensions = (width - 2, PADDING + LINE_HEIGHT * len(labels) + PADDING)
box = Rect((x + OFFSET, y), dimensions, rx=radius, class_='parent-object', style=f'fill: #{color}')
self.add(box)
cursor = y + PADDING
# Add text label(s)
for i, label in enumerate(labels):
cursor += LINE_HEIGHT
text_coords = (x + width / 2, cursor - LINE_HEIGHT / 2)
text_color = f'#{foreground_color(color, dark="303030")}'
text = Text(label, insert=text_coords, fill=text_color, class_='bold' if not i else [])
self.add(text)
@property
def box(self):
return self.elements[0] if self.elements else None
@property
def top_center(self):
return self.box['x'] + self.box['width'] / 2, self.box['y']
@property
def bottom_center(self):
return self.box['x'] + self.box['width'] / 2, self.box['y'] + self.box['height']
class Connector(Group):
"""
Return an SVG group containing a line element and text labels representing a Cable.
Arguments:
color: Cable (line) color
url: Hyperlink URL
labels: Iterable of text labels
"""
def __init__(self, start, url, color, labels=[], **extra):
super().__init__(class_='connector', **extra)
self.start = start
self.height = PADDING * 2 + LINE_HEIGHT * len(labels) + PADDING * 2
self.end = (start[0], start[1] + self.height)
self.color = color or '000000'
# Draw a "shadow" line to give the cable a border
cable_shadow = Line(start=self.start, end=self.end, class_='cable-shadow')
self.add(cable_shadow)
# Draw the cable
cable = Line(start=self.start, end=self.end, style=f'stroke: #{self.color}')
self.add(cable)
# Add link
link = Hyperlink(href=url, target='_blank')
# Add text label(s)
cursor = start[1]
cursor += PADDING * 2
for i, label in enumerate(labels):
cursor += LINE_HEIGHT
text_coords = (start[0] + PADDING * 2, cursor - LINE_HEIGHT / 2)
text = Text(label, insert=text_coords, class_='bold' if not i else [])
link.add(text)
self.add(link)
class CableTraceSVG:
"""
Generate a graphical representation of a CablePath in SVG format.
:param origin: The originating termination
:param width: Width of the generated image (in pixels)
:param base_url: Base URL for links within the SVG document. If none, links will be relative.
"""
def __init__(self, origin, width=CABLE_TRACE_SVG_DEFAULT_WIDTH, base_url=None):
self.origin = origin
self.width = width
self.base_url = base_url.rstrip('/') if base_url is not None else ''
# Establish a cursor to track position on the y axis
# Center edges on pixels to render sharp borders
self.cursor = OFFSET
# Prep elements lists
self.parent_objects = []
self.terminations = []
self.connectors = []
@property
def center(self):
return self.width / 2
@classmethod
def _get_labels(cls, instance):
"""
Return a list of text labels for the given instance based on model type.
"""
labels = [str(instance)]
if instance._meta.model_name == 'device':
labels.append(f'{instance.device_type.manufacturer} {instance.device_type}')
location_label = f'{instance.site}'
if instance.location:
location_label += f' / {instance.location}'
if instance.rack:
location_label += f' / {instance.rack}'
labels.append(location_label)
elif instance._meta.model_name == 'circuit':
labels[0] = f'Circuit {instance}'
labels.append(instance.provider)
elif instance._meta.model_name == 'circuittermination':
if instance.xconnect_id:
labels.append(f'{instance.xconnect_id}')
elif instance._meta.model_name == 'providernetwork':
labels.append(instance.provider)
return labels
@classmethod
def _get_color(cls, instance):
"""
Return the appropriate fill color for an object within a cable path.
"""
if hasattr(instance, 'parent_object'):
# Termination
return 'f0f0f0'
if hasattr(instance, 'device_role'):
# Device
return instance.device_role.color
else:
# Other parent object
return 'e0e0e0'
def draw_parent_objects(self, obj_list):
"""
Draw a set of parent objects.
"""
width = self.width / len(obj_list)
for i, obj in enumerate(obj_list):
node = Node(
position=(i * width, self.cursor),
width=width,
url=f'{self.base_url}{obj.get_absolute_url()}',
color=self._get_color(obj),
labels=self._get_labels(obj)
)
self.parent_objects.append(node)
if i + 1 == len(obj_list):
self.cursor += node.box['height']
def draw_terminations(self, terminations):
"""
Draw a row of terminating objects (e.g. interfaces), all of which are attached to the same end of a cable.
"""
nodes = []
nodes_height = 0
width = self.width / len(terminations)
for i, term in enumerate(terminations):
node = Node(
position=(i * width, self.cursor),
width=width,
url=f'{self.base_url}{term.get_absolute_url()}',
color=self._get_color(term),
labels=self._get_labels(term),
radius=5
)
nodes_height = max(nodes_height, node.box['height'])
nodes.append(node)
self.cursor += nodes_height
self.terminations.extend(nodes)
return nodes
def draw_fanin(self, node, connector):
points = (
node.bottom_center,
(node.bottom_center[0], node.bottom_center[1] + FANOUT_LEG_HEIGHT),
connector.start,
)
self.connectors.extend((
Polyline(points=points, class_='cable-shadow'),
Polyline(points=points, style=f'stroke: #{connector.color}'),
))
def draw_fanout(self, node, connector):
points = (
connector.end,
(node.top_center[0], node.top_center[1] - FANOUT_LEG_HEIGHT),
node.top_center,
)
self.connectors.extend((
Polyline(points=points, class_='cable-shadow'),
Polyline(points=points, style=f'stroke: #{connector.color}'),
))
def draw_cable(self, cable):
labels = [
f'Cable {cable}',
cable.get_status_display()
]
if cable.type:
labels.append(cable.get_type_display())
if cable.length and cable.length_unit:
labels.append(f'{cable.length} {cable.get_length_unit_display()}')
connector = Connector(
start=(self.center + OFFSET, self.cursor),
color=cable.color or '000000',
url=f'{self.base_url}{cable.get_absolute_url()}',
labels=labels
)
self.cursor += connector.height
return connector
def draw_wirelesslink(self, wirelesslink):
"""
Draw a line with labels representing a WirelessLink.
"""
group = Group(class_='connector')
labels = [
f'Wireless link {wirelesslink}',
wirelesslink.get_status_display()
]
if wirelesslink.ssid:
labels.append(wirelesslink.ssid)
# Draw the wireless link
start = (OFFSET + self.center, self.cursor)
height = PADDING * 2 + LINE_HEIGHT * len(labels) + PADDING * 2
end = (start[0], start[1] + height)
line = Line(start=start, end=end, class_='wireless-link')
group.add(line)
self.cursor += PADDING * 2
# Add link
link = Hyperlink(href=f'{self.base_url}{wirelesslink.get_absolute_url()}', target='_blank')
# Add text label(s)
for i, label in enumerate(labels):
self.cursor += LINE_HEIGHT
text_coords = (self.center + PADDING * 2, self.cursor - LINE_HEIGHT / 2)
text = Text(label, insert=text_coords, class_='bold' if not i else [])
link.add(text)
group.add(link)
self.cursor += PADDING * 2
return group
def draw_attachment(self):
"""
Return an SVG group containing a line element and "Attachment" label.
"""
group = Group(class_='connector')
# Draw attachment (line)
start = (OFFSET + self.center, OFFSET + self.cursor)
height = PADDING * 2 + LINE_HEIGHT + PADDING * 2
end = (start[0], start[1] + height)
line = Line(start=start, end=end, class_='attachment')
group.add(line)
self.cursor += PADDING * 4
return group
def render(self):
"""
Return an SVG document representing a cable trace.
"""
from dcim.models import Cable
from wireless.models import WirelessLink
traced_path = self.origin.trace()
# Iterate through each (terms, cable, terms) segment in the path
for i, segment in enumerate(traced_path):
near_ends, links, far_ends = segment
# Near end parent
if i == 0:
# If this is the first segment, draw the originating termination's parent object
self.draw_parent_objects(set(end.parent_object for end in near_ends))
# Near end termination(s)
terminations = self.draw_terminations(near_ends)
# Connector (a Cable or WirelessLink)
if links:
link = links[0] # Remove Cable from list
# Cable
if type(link) is Cable:
# Account for fan-ins height
if len(near_ends) > 1:
self.cursor += FANOUT_HEIGHT
cable = self.draw_cable(link)
self.connectors.append(cable)
# Draw fan-ins
if len(near_ends) > 1:
for term in terminations:
self.draw_fanin(term, cable)
# WirelessLink
elif type(link) is WirelessLink:
wirelesslink = self.draw_wirelesslink(link)
self.connectors.append(wirelesslink)
# Far end termination(s)
if len(far_ends) > 1:
self.cursor += FANOUT_HEIGHT
terminations = self.draw_terminations(far_ends)
for term in terminations:
self.draw_fanout(term, cable)
elif far_ends:
self.draw_terminations(far_ends)
else:
# Link is not connected to anything
break
# Far end parent
parent_objects = set(end.parent_object for end in far_ends)
self.draw_parent_objects(parent_objects)
# Render a far-end object not connected via a link (e.g. a ProviderNetwork or Site associated with
# a CircuitTermination)
elif far_ends:
# Attachment
attachment = self.draw_attachment()
self.connectors.append(attachment)
# Object
self.draw_parent_objects(far_ends)
# Determine drawing size
self.drawing = svgwrite.Drawing(
size=(self.width, self.cursor + 2)
)
# Attach CSS stylesheet
with open(f'{settings.STATIC_ROOT}/cable_trace.css') as css_file:
self.drawing.defs.add(self.drawing.style(css_file.read()))
# Add elements to the drawing in order of depth (Z axis)
for element in self.connectors + self.parent_objects + self.terminations:
self.drawing.add(element)
return self.drawing

View File

@@ -1,323 +0,0 @@
import decimal
import svgwrite
from svgwrite.container import Hyperlink
from svgwrite.image import Image
from svgwrite.gradients import LinearGradient
from svgwrite.shapes import Rect
from svgwrite.text import Text
from django.conf import settings
from django.core.exceptions import FieldError
from django.db.models import Q
from django.urls import reverse
from django.utils.http import urlencode
from netbox.config import get_config
from utilities.utils import foreground_color, array_to_ranges
from dcim.constants import RACK_ELEVATION_BORDER_WIDTH
__all__ = (
'RackElevationSVG',
)
def get_device_name(device):
if device.virtual_chassis:
name = f'{device.virtual_chassis.name}:{device.vc_position}'
elif device.name:
name = device.name
else:
name = str(device.device_type)
if device.devicebay_count:
name += ' ({}/{})'.format(device.get_children().count(), device.devicebay_count)
return name
def get_device_description(device):
return '{} ({}) — {} {} ({}U) {} {}'.format(
device.name,
device.device_role,
device.device_type.manufacturer.name,
device.device_type.model,
device.device_type.u_height,
device.asset_tag or '',
device.serial or ''
)
class RackElevationSVG:
"""
Use this class to render a rack elevation as an SVG image.
:param rack: A NetBox Rack instance
:param unit_width: Rendered unit width, in pixels
:param unit_height: Rendered unit height, in pixels
:param legend_width: Legend width, in pixels (where the unit labels appear)
:param margin_width: Margin width, in pixels (where reservations appear)
:param user: User instance. If specified, only devices viewable by this user will be fully displayed.
:param include_images: If true, the SVG document will embed front/rear device face images, where available
:param base_url: Base URL for links within the SVG document. If none, links will be relative.
:param highlight_params: Iterable of two-tuples which identifies attributes of devices to highlight
"""
def __init__(self, rack, unit_height=None, unit_width=None, legend_width=None, margin_width=None, user=None,
include_images=True, base_url=None, highlight_params=None):
self.rack = rack
self.include_images = include_images
self.base_url = base_url.rstrip('/') if base_url is not None else ''
# Set drawing dimensions
config = get_config()
self.unit_width = unit_width or config.RACK_ELEVATION_DEFAULT_UNIT_WIDTH
self.unit_height = unit_height or config.RACK_ELEVATION_DEFAULT_UNIT_HEIGHT
self.legend_width = legend_width or config.RACK_ELEVATION_DEFAULT_LEGEND_WIDTH
self.margin_width = margin_width or config.RACK_ELEVATION_DEFAULT_MARGIN_WIDTH
# Determine the subset of devices within this rack that are viewable by the user, if any
permitted_devices = self.rack.devices
if user is not None:
permitted_devices = permitted_devices.restrict(user, 'view')
self.permitted_device_ids = permitted_devices.values_list('pk', flat=True)
# Determine device(s) to highlight within the elevation (if any)
self.highlight_devices = []
if highlight_params:
q = Q()
for k, v in highlight_params:
q |= Q(**{k: v})
try:
self.highlight_devices = permitted_devices.filter(q)
except FieldError:
pass
@staticmethod
def _add_gradient(drawing, id_, color):
gradient = LinearGradient(
start=(0, 0),
end=(0, 25),
spreadMethod='repeat',
id_=id_,
gradientTransform='rotate(45, 0, 0)',
gradientUnits='userSpaceOnUse'
)
gradient.add_stop_color(offset='0%', color='#f7f7f7')
gradient.add_stop_color(offset='50%', color='#f7f7f7')
gradient.add_stop_color(offset='50%', color=color)
gradient.add_stop_color(offset='100%', color=color)
drawing.defs.add(gradient)
def _setup_drawing(self):
width = self.unit_width + self.legend_width + self.margin_width + RACK_ELEVATION_BORDER_WIDTH * 2
height = self.unit_height * self.rack.u_height + RACK_ELEVATION_BORDER_WIDTH * 2
drawing = svgwrite.Drawing(size=(width, height))
# Add the stylesheet
with open(f'{settings.STATIC_ROOT}/rack_elevation.css') as css_file:
drawing.defs.add(drawing.style(css_file.read()))
# Add gradients
RackElevationSVG._add_gradient(drawing, 'reserved', '#b0b0ff')
RackElevationSVG._add_gradient(drawing, 'occupied', '#d7d7d7')
RackElevationSVG._add_gradient(drawing, 'blocked', '#ffc0c0')
return drawing
def _get_device_coords(self, position, height):
"""
Return the X, Y coordinates of the top left corner for a device in the specified rack unit.
"""
x = self.legend_width + RACK_ELEVATION_BORDER_WIDTH
y = RACK_ELEVATION_BORDER_WIDTH
if self.rack.desc_units:
y += int((position - 1) * self.unit_height)
else:
y += int((self.rack.u_height - position + 1) * self.unit_height) - int(height * self.unit_height)
return x, y
def _draw_device(self, device, coords, size, color=None, image=None):
name = get_device_name(device)
description = get_device_description(device)
text_color = f'#{foreground_color(color)}' if color else '#000000'
text_coords = (
coords[0] + size[0] / 2,
coords[1] + size[1] / 2
)
# Determine whether highlighting is in use, and if so, whether to shade this device
is_shaded = self.highlight_devices and device not in self.highlight_devices
css_extra = ' shaded' if is_shaded else ''
# Create hyperlink element
link = Hyperlink(href=f'{self.base_url}{device.get_absolute_url()}', target='_blank')
link.set_desc(description)
# Add rect element to hyperlink
if color:
link.add(Rect(coords, size, style=f'fill: #{color}', class_=f'slot{css_extra}'))
else:
link.add(Rect(coords, size, class_=f'slot blocked{css_extra}'))
link.add(Text(name, insert=text_coords, fill=text_color, class_=f'label{css_extra}'))
# Embed device type image if provided
if self.include_images and image:
url = f'{self.base_url}{image.url}' if image.url.startswith('/') else image.url
image = Image(
href=url,
insert=coords,
size=size,
class_=f'device-image{css_extra}'
)
image.fit(scale='slice')
link.add(image)
link.add(
Text(name, insert=text_coords, stroke='black', stroke_width='0.2em', stroke_linejoin='round',
class_=f'device-image-label{css_extra}')
)
link.add(
Text(name, insert=text_coords, fill='white', class_=f'device-image-label{css_extra}')
)
self.drawing.add(link)
def draw_device_front(self, device, coords, size):
"""
Draw the front (mounted) face of a device.
"""
color = device.device_role.color
image = device.device_type.front_image
self._draw_device(device, coords, size, color=color, image=image)
def draw_device_rear(self, device, coords, size):
"""
Draw the rear (opposite) face of a device.
"""
image = device.device_type.rear_image
self._draw_device(device, coords, size, image=image)
def draw_border(self):
"""
Draw a border around the collection of rack units.
"""
border_width = RACK_ELEVATION_BORDER_WIDTH
border_offset = RACK_ELEVATION_BORDER_WIDTH / 2
frame = Rect(
insert=(self.legend_width + border_offset, border_offset),
size=(self.unit_width + border_width, self.rack.u_height * self.unit_height + border_width),
class_='rack'
)
self.drawing.add(frame)
def draw_legend(self):
"""
Draw the rack unit labels along the lefthand side of the elevation.
"""
for ru in range(0, self.rack.u_height):
start_y = ru * self.unit_height + RACK_ELEVATION_BORDER_WIDTH
position_coordinates = (self.legend_width / 2, start_y + self.unit_height / 2 + RACK_ELEVATION_BORDER_WIDTH)
unit = ru + 1 if self.rack.desc_units else self.rack.u_height - ru
self.drawing.add(
Text(str(unit), position_coordinates, class_='unit')
)
def draw_margin(self):
"""
Draw any rack reservations in the right-hand margin alongside the rack elevation.
"""
for reservation in self.rack.reservations.all():
for segment in array_to_ranges(reservation.units):
u_height = 1 if len(segment) == 1 else segment[1] + 1 - segment[0]
coords = self._get_device_coords(segment[0], u_height)
coords = (coords[0] + self.unit_width + RACK_ELEVATION_BORDER_WIDTH * 2, coords[1])
size = (
self.margin_width,
u_height * self.unit_height
)
link = Hyperlink(
href='{}{}'.format(self.base_url, reservation.get_absolute_url()),
target='_blank'
)
link.set_desc(f'Reservation #{reservation.pk}: {reservation.description}')
link.add(
Rect(coords, size, class_='reservation')
)
self.drawing.add(link)
def draw_background(self, face):
"""
Draw the rack unit placeholders which form the "background" of the rack elevation.
"""
x_offset = RACK_ELEVATION_BORDER_WIDTH + self.legend_width
url_string = '{}?{}&position={{}}'.format(
reverse('dcim:device_add'),
urlencode({
'site': self.rack.site.pk,
'location': self.rack.location.pk if self.rack.location else '',
'rack': self.rack.pk,
'face': face,
})
)
for ru in range(0, self.rack.u_height):
unit = ru + 1 if self.rack.desc_units else self.rack.u_height - ru
y_offset = RACK_ELEVATION_BORDER_WIDTH + ru * self.unit_height
text_coords = (
x_offset + self.unit_width / 2,
y_offset + self.unit_height / 2
)
link = Hyperlink(href=url_string.format(unit), target='_blank')
link.add(Rect((x_offset, y_offset), (self.unit_width, self.unit_height), class_='slot'))
link.add(Text('add device', insert=text_coords, class_='add-device'))
self.drawing.add(link)
def draw_face(self, face, opposite=False):
"""
Draw any occupied rack units for the specified rack face.
"""
for unit in self.rack.get_rack_units(face=face, expand_devices=False):
# Loop through all units in the elevation
device = unit['device']
height = unit.get('height', decimal.Decimal(1.0))
device_coords = self._get_device_coords(unit['id'], height)
device_size = (
self.unit_width,
int(self.unit_height * height)
)
# Draw the device
if device and device.pk in self.permitted_device_ids:
if device.face == face and not opposite:
self.draw_device_front(device, device_coords, device_size)
else:
self.draw_device_rear(device, device_coords, device_size)
elif device:
# Devices which the user does not have permission to view are rendered only as unavailable space
self.drawing.add(Rect(device_coords, device_size, class_='blocked'))
def render(self, face):
"""
Return an SVG document representing a rack elevation.
"""
# Initialize the drawing
self.drawing = self._setup_drawing()
# Draw the empty rack, legend, and margin
self.draw_legend()
self.draw_background(face)
self.draw_margin()
# Draw the rack face
self.draw_face(face)
# Draw the rack border last
self.draw_border()
return self.drawing

View File

@@ -1,109 +1,56 @@
import django_tables2 as tables
from django_tables2.utils import Accessor
from django.utils.safestring import mark_safe
from dcim.models import Cable
from netbox.tables import NetBoxTable, columns
from tenancy.tables import TenancyColumnsMixin
from .template_code import CABLE_LENGTH
from .template_code import CABLE_LENGTH, CABLE_TERMINATION_PARENT
__all__ = (
'CableTable',
)
class CableTerminationsColumn(tables.Column):
"""
Args:
cable_end: Which side of the cable to report on (A or B)
attr: The CableTermination attribute to return for each instance (returns the termination object by default)
"""
def __init__(self, cable_end, attr='termination', *args, **kwargs):
self.cable_end = cable_end
self.attr = attr
super().__init__(accessor=Accessor('terminations'), *args, **kwargs)
def _get_terminations(self, manager):
terminations = set()
for cabletermination in manager.all():
if cabletermination.cable_end == self.cable_end:
if termination := getattr(cabletermination, self.attr, None):
terminations.add(termination)
return terminations
def render(self, value):
links = [
f'<a href="{term.get_absolute_url()}">{term}</a>' for term in self._get_terminations(value)
]
return mark_safe('<br />'.join(links) or '&mdash;')
def value(self, value):
return ','.join([str(t) for t in self._get_terminations(value)])
#
# Cables
#
class CableTable(TenancyColumnsMixin, NetBoxTable):
a_terminations = CableTerminationsColumn(
cable_end='A',
termination_a_parent = tables.TemplateColumn(
template_code=CABLE_TERMINATION_PARENT,
accessor=Accessor('termination_a'),
orderable=False,
verbose_name='Termination A'
verbose_name='Side A'
)
b_terminations = CableTerminationsColumn(
cable_end='B',
orderable=False,
verbose_name='Termination B'
)
device_a = CableTerminationsColumn(
cable_end='A',
attr='_device',
orderable=False,
verbose_name='Device A'
)
device_b = CableTerminationsColumn(
cable_end='B',
attr='_device',
orderable=False,
verbose_name='Device B'
)
location_a = CableTerminationsColumn(
cable_end='A',
attr='_location',
orderable=False,
verbose_name='Location A'
)
location_b = CableTerminationsColumn(
cable_end='B',
attr='_location',
orderable=False,
verbose_name='Location B'
)
rack_a = CableTerminationsColumn(
cable_end='A',
attr='_rack',
rack_a = tables.Column(
accessor=Accessor('termination_a__device__rack'),
orderable=False,
linkify=True,
verbose_name='Rack A'
)
rack_b = CableTerminationsColumn(
cable_end='B',
attr='_rack',
termination_a = tables.Column(
accessor=Accessor('termination_a'),
orderable=False,
linkify=True,
verbose_name='Termination A'
)
termination_b_parent = tables.TemplateColumn(
template_code=CABLE_TERMINATION_PARENT,
accessor=Accessor('termination_b'),
orderable=False,
verbose_name='Side B'
)
rack_b = tables.Column(
accessor=Accessor('termination_b__device__rack'),
orderable=False,
linkify=True,
verbose_name='Rack B'
)
site_a = CableTerminationsColumn(
cable_end='A',
attr='_site',
termination_b = tables.Column(
accessor=Accessor('termination_b'),
orderable=False,
verbose_name='Site A'
)
site_b = CableTerminationsColumn(
cable_end='B',
attr='_site',
orderable=False,
verbose_name='Site B'
linkify=True,
verbose_name='Termination B'
)
status = columns.ChoiceFieldColumn()
length = columns.TemplateColumn(
@@ -118,10 +65,10 @@ class CableTable(TenancyColumnsMixin, NetBoxTable):
class Meta(NetBoxTable.Meta):
model = Cable
fields = (
'pk', 'id', 'label', 'a_terminations', 'b_terminations', 'device_a', 'device_b', 'rack_a', 'rack_b',
'location_a', 'location_b', 'site_a', 'site_b', 'status', 'type', 'tenant', 'tenant_group', 'color',
'length', 'tags', 'created', 'last_updated',
'pk', 'id', 'label', 'termination_a_parent', 'rack_a', 'termination_a', 'termination_b_parent', 'rack_b', 'termination_b',
'status', 'type', 'tenant', 'tenant_group', 'color', 'length', 'tags', 'created', 'last_updated',
)
default_columns = (
'pk', 'id', 'label', 'a_terminations', 'b_terminations', 'status', 'type',
'pk', 'id', 'label', 'termination_a_parent', 'termination_a', 'termination_b_parent', 'termination_b',
'status', 'type',
)

View File

@@ -274,17 +274,17 @@ class CableTerminationTable(NetBoxTable):
verbose_name='Cable Color'
)
link_peer = columns.TemplateColumn(
accessor='link_peers',
accessor='_link_peer',
template_code=LINKTERMINATION,
orderable=False,
verbose_name='Link Peers'
verbose_name='Link Peer'
)
mark_connected = columns.BooleanColumn()
class PathEndpointTable(CableTerminationTable):
connection = columns.TemplateColumn(
accessor='_path__destinations',
accessor='_path__last_node',
template_code=LINKTERMINATION,
verbose_name='Connection',
orderable=False
@@ -518,10 +518,10 @@ class InterfaceTable(ModularDeviceComponentTable, BaseInterfaceTable, PathEndpoi
model = Interface
fields = (
'pk', 'id', 'name', 'device', 'module_bay', 'module', 'label', 'enabled', 'type', 'mgmt_only', 'mtu',
'speed', 'duplex', 'mode', 'mac_address', 'wwn', 'poe_mode', 'poe_type', 'rf_role', 'rf_channel',
'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'description', 'mark_connected', 'cable',
'cable_color', 'wireless_link', 'wireless_lans', 'link_peer', 'connection', 'tags', 'vrf', 'ip_addresses',
'fhrp_groups', 'untagged_vlan', 'tagged_vlans', 'created', 'last_updated',
'speed', 'duplex', 'mode', 'mac_address', 'wwn', 'rf_role', 'rf_channel', 'rf_channel_frequency',
'rf_channel_width', 'tx_power', 'description', 'mark_connected', 'cable', 'cable_color', 'wireless_link',
'wireless_lans', 'link_peer', 'connection', 'tags', 'vrf', 'ip_addresses', 'fhrp_groups', 'untagged_vlan',
'tagged_vlans', 'created', 'last_updated',
)
default_columns = ('pk', 'name', 'device', 'label', 'enabled', 'type', 'description')

View File

@@ -172,7 +172,7 @@ class InterfaceTemplateTable(ComponentTemplateTable):
class Meta(ComponentTemplateTable.Meta):
model = InterfaceTemplate
fields = ('pk', 'name', 'label', 'mgmt_only', 'type', 'description', 'poe_mode', 'poe_type', 'actions')
fields = ('pk', 'name', 'label', 'mgmt_only', 'type', 'description', 'actions')
empty_text = "None"

View File

@@ -125,7 +125,6 @@ class LocationTable(TenancyColumnsMixin, NetBoxTable):
site = tables.Column(
linkify=True
)
status = columns.ChoiceFieldColumn()
rack_count = columns.LinkedCountColumn(
viewname='dcim:rack_list',
url_params={'location_id': 'pk'},
@@ -149,7 +148,7 @@ class LocationTable(TenancyColumnsMixin, NetBoxTable):
class Meta(NetBoxTable.Meta):
model = Location
fields = (
'pk', 'id', 'name', 'site', 'status', 'tenant', 'tenant_group', 'rack_count', 'device_count', 'description',
'slug', 'contacts', 'tags', 'actions', 'created', 'last_updated',
'pk', 'id', 'name', 'site', 'tenant', 'tenant_group', 'rack_count', 'device_count', 'description', 'slug', 'contacts',
'tags', 'actions', 'created', 'last_updated',
)
default_columns = ('pk', 'name', 'site', 'status', 'tenant', 'rack_count', 'device_count', 'description')
default_columns = ('pk', 'name', 'site', 'tenant', 'rack_count', 'device_count', 'description')

View File

@@ -1,9 +1,11 @@
LINKTERMINATION = """
{% for termination in value %}
<a href="{{ termination.get_absolute_url }}">{{ termination }}</a>{% if not forloop.last %},{% endif %}
{% empty %}
{{ ''|placeholder }}
{% endfor %}
{% if value %}
{% if value.parent_object %}
<a href="{{ value.parent_object.get_absolute_url }}">{{ value.parent_object }}</a>
<i class="mdi mdi-chevron-right"></i>
{% endif %}
<a href="{{ value.get_absolute_url }}">{{ value }}</a>
{% endif %}
"""
CABLE_LENGTH = """
@@ -11,6 +13,16 @@ CABLE_LENGTH = """
{% if record.length %}{{ record.length|simplify_decimal }} {{ record.length_unit }}{% endif %}
"""
CABLE_TERMINATION_PARENT = """
{% if value.device %}
<a href="{{ value.device.get_absolute_url }}">{{ value.device }}</a>
{% elif value.circuit %}
<a href="{{ value.circuit.get_absolute_url }}">{{ value.circuit }}</a>
{% elif value.power_panel %}
<a href="{{ value.power_panel.get_absolute_url }}">{{ value.power_panel }}</a>
{% endif %}
"""
DEVICE_LINK = """
<a href="{% url 'dcim:device' pk=record.pk %}">
{{ record.name|default:'<span class="badge bg-info">Unnamed device</span>' }}
@@ -88,7 +100,7 @@ LOCATION_BUTTONS = """
MODULAR_COMPONENT_TEMPLATE_BUTTONS = """
{% load helpers %}
{% if perms.dcim.add_inventoryitemtemplate %}
{% if perms.dcim.add_inventoryitemtemplate and record.device_type_id %}
<a href="{% url 'dcim:inventoryitemtemplate_add' %}?device_type={{ record.device_type_id }}&component_type={{ record|content_type_id }}&component_id={{ record.pk }}&return_url={{ request.path }}" title="Add inventory item" class="btn btn-primary btn-sm">
<i class="mdi mdi-plus-thick" aria-hidden="true"></i>
</a>
@@ -121,9 +133,9 @@ CONSOLEPORT_BUTTONS = """
<span class="mdi mdi-ethernet-cable" aria-hidden="true"></span>
</button>
<ul class="dropdown-menu dropdown-menu-end">
<li><a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.consoleport&a_terminations={{ record.pk }}&b_terminations_type=dcim.consoleserverport&return_url={% url 'dcim:device_consoleports' pk=object.pk %}">Console Server Port</a></li>
<li><a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.consoleport&a_terminations={{ record.pk }}&b_terminations_type=dcim.frontport&return_url={% url 'dcim:device_consoleports' pk=object.pk %}">Front Port</a></li>
<li><a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.consoleport&a_terminations={{ record.pk }}&b_terminations_type=dcim.rearport&return_url={% url 'dcim:device_consoleports' pk=object.pk %}">Rear Port</a></li>
<li><a class="dropdown-item" href="{% url 'dcim:consoleport_connect' termination_a_id=record.pk termination_b_type='console-server-port' %}?return_url={% url 'dcim:device_consoleports' pk=object.pk %}">Console Server Port</a></li>
<li><a class="dropdown-item" href="{% url 'dcim:consoleport_connect' termination_a_id=record.pk termination_b_type='front-port' %}?return_url={% url 'dcim:device_consoleports' pk=object.pk %}">Front Port</a></li>
<li><a class="dropdown-item" href="{% url 'dcim:consoleport_connect' termination_a_id=record.pk termination_b_type='rear-port' %}?return_url={% url 'dcim:device_consoleports' pk=object.pk %}">Rear Port</a></li>
</ul>
</span>
{% else %}
@@ -153,9 +165,9 @@ CONSOLESERVERPORT_BUTTONS = """
<span class="mdi mdi-ethernet-cable" aria-hidden="true"></span>
</button>
<ul class="dropdown-menu dropdown-menu-end">
<li><a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.consoleserverport&a_terminations={{ record.pk }}&b_terminations_type=dcim.consoleport&return_url={% url 'dcim:device_consoleserverports' pk=object.pk %}">Console Port</a></li>
<li><a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.consoleserverport&a_terminations={{ record.pk }}&b_terminations_type=dcim.frontport&return_url={% url 'dcim:device_consoleserverports' pk=object.pk %}">Front Port</a></li>
<li><a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.consoleserverport&a_terminations={{ record.pk }}&b_terminations_type=dcim.rearport&return_url={% url 'dcim:device_consoleserverports' pk=object.pk %}">Rear Port</a></li>
<li><a class="dropdown-item" href="{% url 'dcim:consoleserverport_connect' termination_a_id=record.pk termination_b_type='console-port' %}?return_url={% url 'dcim:device_consoleserverports' pk=object.pk %}">Console Port</a></li>
<li><a class="dropdown-item" href="{% url 'dcim:consoleserverport_connect' termination_a_id=record.pk termination_b_type='front-port' %}?return_url={% url 'dcim:device_consoleserverports' pk=object.pk %}">Front Port</a></li>
<li><a class="dropdown-item" href="{% url 'dcim:consoleserverport_connect' termination_a_id=record.pk termination_b_type='rear-port' %}?return_url={% url 'dcim:device_consoleserverports' pk=object.pk %}">Rear Port</a></li>
</ul>
</span>
{% else %}
@@ -185,8 +197,8 @@ POWERPORT_BUTTONS = """
<span class="mdi mdi-ethernet-cable" aria-hidden="true"></span>
</button>
<ul class="dropdown-menu dropdown-menu-end">
<li><a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.powerport&a_terminations={{ record.pk }}&b_terminations_type=dcim.poweroutlet&return_url={% url 'dcim:device_powerports' pk=object.pk %}">Power Outlet</a></li>
<li><a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.powerport&a_terminations={{ record.pk }}&b_terminations_type=dcim.powerfeed&return_url={% url 'dcim:device_powerports' pk=object.pk %}">Power Feed</a></li>
<li><a class="dropdown-item" href="{% url 'dcim:powerport_connect' termination_a_id=record.pk termination_b_type='power-outlet' %}?return_url={% url 'dcim:device_powerports' pk=object.pk %}">Power Outlet</a></li>
<li><a class="dropdown-item" href="{% url 'dcim:powerport_connect' termination_a_id=record.pk termination_b_type='power-feed' %}?return_url={% url 'dcim:device_powerports' pk=object.pk %}">Power Feed</a></li>
</ul>
</span>
{% else %}
@@ -212,7 +224,7 @@ POWEROUTLET_BUTTONS = """
<a href="#" class="btn btn-outline-dark btn-sm disabled"><i class="mdi mdi-transit-connection-variant" aria-hidden="true"></i></a>
<a href="#" class="btn btn-outline-dark btn-sm disabled"><i class="mdi mdi-lan-connect" aria-hidden="true"></i></a>
{% if not record.mark_connected %}
<a href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.poweroutlet&a_terminations={{ record.pk }}&b_terminations_type=dcim.powerport&return_url={% url 'dcim:device_poweroutlets' pk=object.pk %}" title="Connect" class="btn btn-success btn-sm">
<a href="{% url 'dcim:poweroutlet_connect' termination_a_id=record.pk termination_b_type='power-port' %}?return_url={% url 'dcim:device_poweroutlets' pk=object.pk %}" title="Connect" class="btn btn-success btn-sm">
<i class="mdi mdi-ethernet-cable" aria-hidden="true"></i>
</a>
{% else %}
@@ -262,10 +274,10 @@ INTERFACE_BUTTONS = """
<span class="mdi mdi-ethernet-cable" aria-hidden="true"></span>
</button>
<ul class="dropdown-menu dropdown-menu-end">
<li><a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.interface&a_terminations={{ record.pk }}&b_terminations_type=dcim.interface&return_url={% url 'dcim:device_interfaces' pk=object.pk %}">Interface</a></li>
<li><a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.interface&a_terminations={{ record.pk }}&b_terminations_type=dcim.frontport&return_url={% url 'dcim:device_interfaces' pk=object.pk %}">Front Port</a></li>
<li><a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.interface&a_terminations={{ record.pk }}&b_terminations_type=dcim.rearport&return_url={% url 'dcim:device_interfaces' pk=object.pk %}">Rear Port</a></li>
<li><a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.interface&a_terminations={{ record.pk }}&b_terminations_type=circuits.circuittermination&return_url={% url 'dcim:device_interfaces' pk=object.pk %}">Circuit Termination</a></li>
<li><a class="dropdown-item" href="{% url 'dcim:interface_connect' termination_a_id=record.pk termination_b_type='interface' %}?return_url={% url 'dcim:device_interfaces' pk=object.pk %}">Interface</a></li>
<li><a class="dropdown-item" href="{% url 'dcim:interface_connect' termination_a_id=record.pk termination_b_type='front-port' %}?return_url={% url 'dcim:device_interfaces' pk=object.pk %}">Front Port</a></li>
<li><a class="dropdown-item" href="{% url 'dcim:interface_connect' termination_a_id=record.pk termination_b_type='rear-port' %}?return_url={% url 'dcim:device_interfaces' pk=object.pk %}">Rear Port</a></li>
<li><a class="dropdown-item" href="{% url 'dcim:interface_connect' termination_a_id=record.pk termination_b_type='circuit-termination' %}?return_url={% url 'dcim:device_interfaces' pk=object.pk %}">Circuit Termination</a></li>
</ul>
</span>
{% else %}
@@ -301,12 +313,12 @@ FRONTPORT_BUTTONS = """
<span class="mdi mdi-ethernet-cable" aria-hidden="true"></span>
</button>
<ul class="dropdown-menu dropdown-menu-end">
<li><a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.frontport&a_terminations={{ record.pk }}&b_terminations_type=dcim.interface&return_url={% url 'dcim:device_frontports' pk=object.pk %}">Interface</a></li>
<li><a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.frontport&a_terminations={{ record.pk }}&b_terminations_type=dcim.consoleserverport&return_url={% url 'dcim:device_frontports' pk=object.pk %}">Console Server Port</a></li>
<li><a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.frontport&a_terminations={{ record.pk }}&b_terminations_type=dcim.consoleport&return_url={% url 'dcim:device_frontports' pk=object.pk %}">Console Port</a></li>
<li><a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.frontport&a_terminations={{ record.pk }}&b_terminations_type=dcim.frontport&return_url={% url 'dcim:device_frontports' pk=object.pk %}">Front Port</a></li>
<li><a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.frontport&a_terminations={{ record.pk }}&b_terminations_type=dcim.rearport&return_url={% url 'dcim:device_frontports' pk=object.pk %}">Rear Port</a></li>
<li><a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.frontport&a_terminations={{ record.pk }}&b_terminations_type=circuits.circuittermination&return_url={% url 'dcim:device_frontports' pk=object.pk %}">Circuit Termination</a></li>
<li><a class="dropdown-item" href="{% url 'dcim:frontport_connect' termination_a_id=record.pk termination_b_type='interface' %}?return_url={% url 'dcim:device_frontports' pk=object.pk %}">Interface</a></li>
<li><a class="dropdown-item" href="{% url 'dcim:frontport_connect' termination_a_id=record.pk termination_b_type='console-server-port' %}?return_url={% url 'dcim:device_frontports' pk=object.pk %}">Console Server Port</a></li>
<li><a class="dropdown-item" href="{% url 'dcim:frontport_connect' termination_a_id=record.pk termination_b_type='console-port' %}?return_url={% url 'dcim:device_frontports' pk=object.pk %}">Console Port</a></li>
<li><a class="dropdown-item" href="{% url 'dcim:frontport_connect' termination_a_id=record.pk termination_b_type='front-port' %}?return_url={% url 'dcim:device_frontports' pk=object.pk %}">Front Port</a></li>
<li><a class="dropdown-item" href="{% url 'dcim:frontport_connect' termination_a_id=record.pk termination_b_type='rear-port' %}?return_url={% url 'dcim:device_frontports' pk=object.pk %}">Rear Port</a></li>
<li><a class="dropdown-item" href="{% url 'dcim:frontport_connect' termination_a_id=record.pk termination_b_type='circuit-termination' %}?return_url={% url 'dcim:device_frontports' pk=object.pk %}">Circuit Termination</a></li>
</ul>
</span>
{% else %}
@@ -338,12 +350,12 @@ REARPORT_BUTTONS = """
<span class="mdi mdi-ethernet-cable" aria-hidden="true"></span>
</button>
<ul class="dropdown-menu dropdown-menu-end">
<li><a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.rearport&a_terminations={{ record.pk }}&b_terminations_type=dcim.interface&return_url={% url 'dcim:device_rearports' pk=object.pk %}">Interface</a></li>
<li><a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.rearport&a_terminations={{ record.pk }}&b_terminations_type=dcim.consoleserverport&return_url={% url 'dcim:device_rearports' pk=object.pk %}">Console Server Port</a></li>
<li><a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.rearport&a_terminations={{ record.pk }}&b_terminations_type=dcim.consoleport&return_url={% url 'dcim:device_rearports' pk=object.pk %}">Console Port</a></li>
<li><a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.rearport&a_terminations={{ record.pk }}&b_terminations_type=dcim.frontport&return_url={% url 'dcim:device_rearports' pk=object.pk %}">Front Port</a></li>
<li><a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.rearport&a_terminations={{ record.pk }}&b_terminations_type=dcim.rearport&return_url={% url 'dcim:device_rearports' pk=object.pk %}">Rear Port</a></li>
<li><a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.rearport&a_terminations={{ record.pk }}&b_terminations_type=circuits.circuittermination&return_url={% url 'dcim:device_rearports' pk=object.pk %}">Circuit Termination</a></li>
<li><a class="dropdown-item" href="{% url 'dcim:rearport_connect' termination_a_id=record.pk termination_b_type='interface' %}?return_url={% url 'dcim:device_rearports' pk=object.pk %}">Interface</a></li>
<li><a class="dropdown-item" href="{% url 'dcim:rearport_connect' termination_a_id=record.pk termination_b_type='console-server-port' %}?return_url={% url 'dcim:device_rearports' pk=object.pk %}">Console Server Port</a></li>
<li><a class="dropdown-item" href="{% url 'dcim:rearport_connect' termination_a_id=record.pk termination_b_type='console-port' %}?return_url={% url 'dcim:device_rearports' pk=object.pk %}">Console Port</a></li>
<li><a class="dropdown-item" href="{% url 'dcim:rearport_connect' termination_a_id=record.pk termination_b_type='front-port' %}?return_url={% url 'dcim:device_rearports' pk=object.pk %}">Front Port</a></li>
<li><a class="dropdown-item" href="{% url 'dcim:rearport_connect' termination_a_id=record.pk termination_b_type='rear-port' %}?return_url={% url 'dcim:device_rearports' pk=object.pk %}">Rear Port</a></li>
<li><a class="dropdown-item" href="{% url 'dcim:rearport_connect' termination_a_id=record.pk termination_b_type='circuit-termination' %}?return_url={% url 'dcim:device_rearports' pk=object.pk %}">Circuit Termination</a></li>
</ul>
</span>
{% else %}

View File

@@ -7,7 +7,6 @@ from dcim.choices import *
from dcim.constants import *
from dcim.models import *
from ipam.models import ASN, RIR, VLAN, VRF
from netbox.api.serializers import GenericObjectSerializer
from utilities.testing import APITestCase, APIViewTestCases, create_test_device
from virtualization.models import Cluster, ClusterType
from wireless.choices import WirelessChannelChoices
@@ -46,7 +45,7 @@ class Mixins:
device=peer_device,
name='Peer Termination'
)
cable = Cable(a_terminations=[obj], b_terminations=[peer_obj], label='Cable 1')
cable = Cable(termination_a=obj, termination_b=peer_obj, label='Cable 1')
cable.save()
self.add_permissions(f'dcim.view_{self.model._meta.model_name}')
@@ -56,9 +55,9 @@ class Mixins:
self.assertHttpStatus(response, status.HTTP_200_OK)
self.assertEqual(len(response.data), 1)
segment1 = response.data[0]
self.assertEqual(segment1[0][0]['name'], obj.name)
self.assertEqual(segment1[0]['name'], obj.name)
self.assertEqual(segment1[1]['label'], cable.label)
self.assertEqual(segment1[2][0]['name'], peer_obj.name)
self.assertEqual(segment1[2]['name'], peer_obj.name)
class RegionTest(APIViewTestCases.APIViewTestCase):
@@ -198,13 +197,13 @@ class LocationTest(APIViewTestCases.APIViewTestCase):
Site.objects.bulk_create(sites)
parent_locations = (
Location.objects.create(site=sites[0], name='Parent Location 1', slug='parent-location-1', status=LocationStatusChoices.STATUS_ACTIVE),
Location.objects.create(site=sites[1], name='Parent Location 2', slug='parent-location-2', status=LocationStatusChoices.STATUS_ACTIVE),
Location.objects.create(site=sites[0], name='Parent Location 1', slug='parent-location-1'),
Location.objects.create(site=sites[1], name='Parent Location 2', slug='parent-location-2'),
)
Location.objects.create(site=sites[0], name='Location 1', slug='location-1', parent=parent_locations[0], status=LocationStatusChoices.STATUS_ACTIVE)
Location.objects.create(site=sites[0], name='Location 2', slug='location-2', parent=parent_locations[0], status=LocationStatusChoices.STATUS_ACTIVE)
Location.objects.create(site=sites[0], name='Location 3', slug='location-3', parent=parent_locations[0], status=LocationStatusChoices.STATUS_ACTIVE)
Location.objects.create(site=sites[0], name='Location 1', slug='location-1', parent=parent_locations[0])
Location.objects.create(site=sites[0], name='Location 2', slug='location-2', parent=parent_locations[0])
Location.objects.create(site=sites[0], name='Location 3', slug='location-3', parent=parent_locations[0])
cls.create_data = [
{
@@ -212,21 +211,18 @@ class LocationTest(APIViewTestCases.APIViewTestCase):
'slug': 'test-location-4',
'site': sites[1].pk,
'parent': parent_locations[1].pk,
'status': LocationStatusChoices.STATUS_PLANNED,
},
{
'name': 'Test Location 5',
'slug': 'test-location-5',
'site': sites[1].pk,
'parent': parent_locations[1].pk,
'status': LocationStatusChoices.STATUS_PLANNED,
},
{
'name': 'Test Location 6',
'slug': 'test-location-6',
'site': sites[1].pk,
'parent': parent_locations[1].pk,
'status': LocationStatusChoices.STATUS_PLANNED,
},
]
@@ -331,15 +327,15 @@ class RackTest(APIViewTestCases.APIViewTestCase):
# Retrieve all units
response = self.client.get(url, **self.header)
self.assertEqual(response.data['count'], 84)
self.assertEqual(response.data['count'], 42)
# Search for specific units
response = self.client.get(f'{url}?q=3', **self.header)
self.assertEqual(response.data['count'], 26)
self.assertEqual(response.data['count'], 13)
response = self.client.get(f'{url}?q=U3', **self.header)
self.assertEqual(response.data['count'], 22)
self.assertEqual(response.data['count'], 11)
response = self.client.get(f'{url}?q=U10', **self.header)
self.assertEqual(response.data['count'], 2)
self.assertEqual(response.data['count'], 1)
def test_get_rack_elevation_svg(self):
"""
@@ -1511,8 +1507,6 @@ class InterfaceTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase
'speed': 1000000,
'duplex': 'full',
'vrf': vrfs[0].pk,
'poe_mode': InterfacePoEModeChoices.MODE_PD,
'poe_type': InterfacePoETypeChoices.TYPE_1_8023AF,
'tagged_vlans': [vlans[0].pk, vlans[1].pk],
'untagged_vlan': vlans[2].pk,
},
@@ -1865,17 +1859,6 @@ class CableTest(APIViewTestCases.APIViewTestCase):
# TODO: Allow updating cable terminations
test_update_object = None
def model_to_dict(self, *args, **kwargs):
data = super().model_to_dict(*args, **kwargs)
# Serialize termination objects
if 'a_terminations' in data:
data['a_terminations'] = GenericObjectSerializer(data['a_terminations'], many=True).data
if 'b_terminations' in data:
data['b_terminations'] = GenericObjectSerializer(data['b_terminations'], many=True).data
return data
@classmethod
def setUpTestData(cls):
site = Site.objects.create(name='Site 1', slug='site-1')
@@ -1896,45 +1879,33 @@ class CableTest(APIViewTestCases.APIViewTestCase):
Interface.objects.bulk_create(interfaces)
cables = (
Cable(a_terminations=[interfaces[0]], b_terminations=[interfaces[10]], label='Cable 1'),
Cable(a_terminations=[interfaces[1]], b_terminations=[interfaces[11]], label='Cable 2'),
Cable(a_terminations=[interfaces[2]], b_terminations=[interfaces[12]], label='Cable 3'),
Cable(termination_a=interfaces[0], termination_b=interfaces[10], label='Cable 1'),
Cable(termination_a=interfaces[1], termination_b=interfaces[11], label='Cable 2'),
Cable(termination_a=interfaces[2], termination_b=interfaces[12], label='Cable 3'),
)
for cable in cables:
cable.save()
cls.create_data = [
{
'a_terminations': [{
'object_type': 'dcim.interface',
'object_id': interfaces[4].pk,
}],
'b_terminations': [{
'object_type': 'dcim.interface',
'object_id': interfaces[14].pk,
}],
'termination_a_type': 'dcim.interface',
'termination_a_id': interfaces[4].pk,
'termination_b_type': 'dcim.interface',
'termination_b_id': interfaces[14].pk,
'label': 'Cable 4',
},
{
'a_terminations': [{
'object_type': 'dcim.interface',
'object_id': interfaces[5].pk,
}],
'b_terminations': [{
'object_type': 'dcim.interface',
'object_id': interfaces[15].pk,
}],
'termination_a_type': 'dcim.interface',
'termination_a_id': interfaces[5].pk,
'termination_b_type': 'dcim.interface',
'termination_b_id': interfaces[15].pk,
'label': 'Cable 5',
},
{
'a_terminations': [{
'object_type': 'dcim.interface',
'object_id': interfaces[6].pk,
}],
'b_terminations': [{
'object_type': 'dcim.interface',
'object_id': interfaces[16].pk,
}],
'termination_a_type': 'dcim.interface',
'termination_a_id': interfaces[6].pk,
'termination_b_type': 'dcim.interface',
'termination_b_id': interfaces[16].pk,
'label': 'Cable 6',
},
]
@@ -1960,7 +1931,7 @@ class ConnectedDeviceTest(APITestCase):
self.interface2 = Interface.objects.create(device=self.device2, name='eth0')
self.interface3 = Interface.objects.create(device=self.device1, name='eth1') # Not connected
cable = Cable(a_terminations=[self.interface1], b_terminations=[self.interface2])
cable = Cable(termination_a=self.interface1, termination_b=self.interface2)
cable.save()
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])

File diff suppressed because it is too large Load Diff

View File

@@ -265,9 +265,9 @@ class LocationTestCase(TestCase, ChangeLoggedFilterSetTests):
location.save()
locations = (
Location(name='Location 1', slug='location-1', site=sites[0], parent=parent_locations[0], status=LocationStatusChoices.STATUS_PLANNED, description='A'),
Location(name='Location 2', slug='location-2', site=sites[1], parent=parent_locations[1], status=LocationStatusChoices.STATUS_STAGING, description='B'),
Location(name='Location 3', slug='location-3', site=sites[2], parent=parent_locations[2], status=LocationStatusChoices.STATUS_DECOMMISSIONING, description='C'),
Location(name='Location 1', slug='location-1', site=sites[0], parent=parent_locations[0], description='A'),
Location(name='Location 2', slug='location-2', site=sites[1], parent=parent_locations[1], description='B'),
Location(name='Location 3', slug='location-3', site=sites[2], parent=parent_locations[2], description='C'),
)
for location in locations:
location.save()
@@ -280,10 +280,6 @@ class LocationTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'slug': ['location-1', 'location-2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_status(self):
params = {'status': [LocationStatusChoices.STATUS_PLANNED, LocationStatusChoices.STATUS_STAGING]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_description(self):
params = {'description': ['A', 'B']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
@@ -1089,8 +1085,8 @@ class InterfaceTemplateTestCase(TestCase, ChangeLoggedFilterSetTests):
DeviceType.objects.bulk_create(device_types)
InterfaceTemplate.objects.bulk_create((
InterfaceTemplate(device_type=device_types[0], name='Interface 1', type=InterfaceTypeChoices.TYPE_1GE_FIXED, mgmt_only=True, poe_mode=InterfacePoEModeChoices.MODE_PD, poe_type=InterfacePoETypeChoices.TYPE_1_8023AF),
InterfaceTemplate(device_type=device_types[1], name='Interface 2', type=InterfaceTypeChoices.TYPE_1GE_GBIC, mgmt_only=False, poe_mode=InterfacePoEModeChoices.MODE_PSE, poe_type=InterfacePoETypeChoices.TYPE_2_8023AT),
InterfaceTemplate(device_type=device_types[0], name='Interface 1', type=InterfaceTypeChoices.TYPE_1GE_FIXED, mgmt_only=True),
InterfaceTemplate(device_type=device_types[1], name='Interface 2', type=InterfaceTypeChoices.TYPE_1GE_GBIC, mgmt_only=False),
InterfaceTemplate(device_type=device_types[2], name='Interface 3', type=InterfaceTypeChoices.TYPE_1GE_SFP, mgmt_only=False),
))
@@ -1113,14 +1109,6 @@ class InterfaceTemplateTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'mgmt_only': 'false'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_poe_mode(self):
params = {'poe_mode': [InterfacePoEModeChoices.MODE_PD, InterfacePoEModeChoices.MODE_PSE]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_poe_type(self):
params = {'poe_type': [InterfacePoETypeChoices.TYPE_1_8023AF, InterfacePoETypeChoices.TYPE_2_8023AT]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
class FrontPortTemplateTestCase(TestCase, ChangeLoggedFilterSetTests):
queryset = FrontPortTemplate.objects.all()
@@ -1960,8 +1948,8 @@ class ConsolePortTestCase(TestCase, ChangeLoggedFilterSetTests):
ConsolePort.objects.bulk_create(console_ports)
# Cables
Cable(a_terminations=[console_ports[0]], b_terminations=[console_server_ports[0]]).save()
Cable(a_terminations=[console_ports[1]], b_terminations=[console_server_ports[1]]).save()
Cable(termination_a=console_ports[0], termination_b=console_server_ports[0]).save()
Cable(termination_a=console_ports[1], termination_b=console_server_ports[1]).save()
# Third port is not connected
def test_name(self):
@@ -2107,8 +2095,8 @@ class ConsoleServerPortTestCase(TestCase, ChangeLoggedFilterSetTests):
ConsoleServerPort.objects.bulk_create(console_server_ports)
# Cables
Cable(a_terminations=[console_server_ports[0]], b_terminations=[console_ports[0]]).save()
Cable(a_terminations=[console_server_ports[1]], b_terminations=[console_ports[1]]).save()
Cable(termination_a=console_server_ports[0], termination_b=console_ports[0]).save()
Cable(termination_a=console_server_ports[1], termination_b=console_ports[1]).save()
# Third port is not connected
def test_name(self):
@@ -2254,8 +2242,8 @@ class PowerPortTestCase(TestCase, ChangeLoggedFilterSetTests):
PowerPort.objects.bulk_create(power_ports)
# Cables
Cable(a_terminations=[power_ports[0]], b_terminations=[power_outlets[0]]).save()
Cable(a_terminations=[power_ports[1]], b_terminations=[power_outlets[1]]).save()
Cable(termination_a=power_ports[0], termination_b=power_outlets[0]).save()
Cable(termination_a=power_ports[1], termination_b=power_outlets[1]).save()
# Third port is not connected
def test_name(self):
@@ -2409,8 +2397,8 @@ class PowerOutletTestCase(TestCase, ChangeLoggedFilterSetTests):
PowerOutlet.objects.bulk_create(power_outlets)
# Cables
Cable(a_terminations=[power_outlets[0]], b_terminations=[power_ports[0]]).save()
Cable(a_terminations=[power_outlets[1]], b_terminations=[power_ports[1]]).save()
Cable(termination_a=power_outlets[0], termination_b=power_ports[0]).save()
Cable(termination_a=power_outlets[1], termination_b=power_ports[1]).save()
# Third port is not connected
def test_name(self):
@@ -2559,115 +2547,20 @@ class InterfaceTestCase(TestCase, ChangeLoggedFilterSetTests):
Device.objects.filter(pk=devices[1].pk).update(virtual_chassis=virtual_chassis, vc_position=2, vc_priority=2)
interfaces = (
Interface(
device=devices[0],
module=modules[0],
name='Interface 1',
label='A',
type=InterfaceTypeChoices.TYPE_1GE_SFP,
enabled=True,
mgmt_only=True,
mtu=100,
mode=InterfaceModeChoices.MODE_ACCESS,
mac_address='00-00-00-00-00-01',
description='First',
vrf=vrfs[0],
speed=1000000,
duplex='half',
poe_mode=InterfacePoEModeChoices.MODE_PSE,
poe_type=InterfacePoETypeChoices.TYPE_1_8023AF
),
Interface(
device=devices[1],
module=modules[1],
name='Interface 2',
label='B',
type=InterfaceTypeChoices.TYPE_1GE_GBIC,
enabled=True,
mgmt_only=True,
mtu=200,
mode=InterfaceModeChoices.MODE_TAGGED,
mac_address='00-00-00-00-00-02',
description='Second',
vrf=vrfs[1],
speed=1000000,
duplex='full',
poe_mode=InterfacePoEModeChoices.MODE_PD,
poe_type=InterfacePoETypeChoices.TYPE_1_8023AF
),
Interface(
device=devices[2],
module=modules[2],
name='Interface 3',
label='C',
type=InterfaceTypeChoices.TYPE_1GE_FIXED,
enabled=False,
mgmt_only=False,
mtu=300,
mode=InterfaceModeChoices.MODE_TAGGED_ALL,
mac_address='00-00-00-00-00-03',
description='Third',
vrf=vrfs[2],
speed=100000,
duplex='half',
poe_mode=InterfacePoEModeChoices.MODE_PSE,
poe_type=InterfacePoETypeChoices.TYPE_2_8023AT
),
Interface(
device=devices[3],
name='Interface 4',
label='D',
type=InterfaceTypeChoices.TYPE_OTHER,
enabled=True,
mgmt_only=True,
tx_power=40,
speed=100000,
duplex='full',
poe_mode=InterfacePoEModeChoices.MODE_PD,
poe_type=InterfacePoETypeChoices.TYPE_2_8023AT
),
Interface(
device=devices[3],
name='Interface 5',
label='E',
type=InterfaceTypeChoices.TYPE_OTHER,
enabled=True,
mgmt_only=True,
tx_power=40
),
Interface(
device=devices[3],
name='Interface 6',
label='F',
type=InterfaceTypeChoices.TYPE_OTHER,
enabled=False,
mgmt_only=False,
tx_power=40
),
Interface(
device=devices[3],
name='Interface 7',
type=InterfaceTypeChoices.TYPE_80211AC,
rf_role=WirelessRoleChoices.ROLE_AP,
rf_channel=WirelessChannelChoices.CHANNEL_24G_1,
rf_channel_frequency=2412,
rf_channel_width=22
),
Interface(
device=devices[3],
name='Interface 8',
type=InterfaceTypeChoices.TYPE_80211AC,
rf_role=WirelessRoleChoices.ROLE_STATION,
rf_channel=WirelessChannelChoices.CHANNEL_5G_32,
rf_channel_frequency=5160,
rf_channel_width=20
),
Interface(device=devices[0], module=modules[0], name='Interface 1', label='A', type=InterfaceTypeChoices.TYPE_1GE_SFP, enabled=True, mgmt_only=True, mtu=100, mode=InterfaceModeChoices.MODE_ACCESS, mac_address='00-00-00-00-00-01', description='First', vrf=vrfs[0], speed=1000000, duplex='half'),
Interface(device=devices[1], module=modules[1], name='Interface 2', label='B', type=InterfaceTypeChoices.TYPE_1GE_GBIC, enabled=True, mgmt_only=True, mtu=200, mode=InterfaceModeChoices.MODE_TAGGED, mac_address='00-00-00-00-00-02', description='Second', vrf=vrfs[1], speed=1000000, duplex='full'),
Interface(device=devices[2], module=modules[2], name='Interface 3', label='C', type=InterfaceTypeChoices.TYPE_1GE_FIXED, enabled=False, mgmt_only=False, mtu=300, mode=InterfaceModeChoices.MODE_TAGGED_ALL, mac_address='00-00-00-00-00-03', description='Third', vrf=vrfs[2], speed=100000, duplex='half'),
Interface(device=devices[3], name='Interface 4', label='D', type=InterfaceTypeChoices.TYPE_OTHER, enabled=True, mgmt_only=True, tx_power=40, speed=100000, duplex='full'),
Interface(device=devices[3], name='Interface 5', label='E', type=InterfaceTypeChoices.TYPE_OTHER, enabled=True, mgmt_only=True, tx_power=40),
Interface(device=devices[3], name='Interface 6', label='F', type=InterfaceTypeChoices.TYPE_OTHER, enabled=False, mgmt_only=False, tx_power=40),
Interface(device=devices[3], name='Interface 7', type=InterfaceTypeChoices.TYPE_80211AC, rf_role=WirelessRoleChoices.ROLE_AP, rf_channel=WirelessChannelChoices.CHANNEL_24G_1, rf_channel_frequency=2412, rf_channel_width=22),
Interface(device=devices[3], name='Interface 8', type=InterfaceTypeChoices.TYPE_80211AC, rf_role=WirelessRoleChoices.ROLE_STATION, rf_channel=WirelessChannelChoices.CHANNEL_5G_32, rf_channel_frequency=5160, rf_channel_width=20),
)
Interface.objects.bulk_create(interfaces)
# Cables
Cable(a_terminations=[interfaces[0]], b_terminations=[interfaces[3]]).save()
Cable(a_terminations=[interfaces[1]], b_terminations=[interfaces[4]]).save()
Cable(termination_a=interfaces[0], termination_b=interfaces[3]).save()
Cable(termination_a=interfaces[1], termination_b=interfaces[4]).save()
# Third pair is not connected
def test_name(self):
@@ -2708,14 +2601,6 @@ class InterfaceTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'mgmt_only': 'false'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
def test_poe_mode(self):
params = {'poe_mode': [InterfacePoEModeChoices.MODE_PD, InterfacePoEModeChoices.MODE_PSE]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
def test_poe_type(self):
params = {'poe_type': [InterfacePoETypeChoices.TYPE_1_8023AF, InterfacePoETypeChoices.TYPE_2_8023AT]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
def test_mode(self):
params = {'mode': InterfaceModeChoices.MODE_ACCESS}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
@@ -2942,8 +2827,8 @@ class FrontPortTestCase(TestCase, ChangeLoggedFilterSetTests):
FrontPort.objects.bulk_create(front_ports)
# Cables
Cable(a_terminations=[front_ports[0]], b_terminations=[front_ports[3]]).save()
Cable(a_terminations=[front_ports[1]], b_terminations=[front_ports[4]]).save()
Cable(termination_a=front_ports[0], termination_b=front_ports[3]).save()
Cable(termination_a=front_ports[1], termination_b=front_ports[4]).save()
# Third port is not connected
def test_name(self):
@@ -3088,8 +2973,8 @@ class RearPortTestCase(TestCase, ChangeLoggedFilterSetTests):
RearPort.objects.bulk_create(rear_ports)
# Cables
Cable(a_terminations=[rear_ports[0]], b_terminations=[rear_ports[3]]).save()
Cable(a_terminations=[rear_ports[1]], b_terminations=[rear_ports[4]]).save()
Cable(termination_a=rear_ports[0], termination_b=rear_ports[3]).save()
Cable(termination_a=rear_ports[1], termination_b=rear_ports[4]).save()
# Third port is not connected
def test_name(self):
@@ -3673,21 +3558,6 @@ class CableTestCase(TestCase, ChangeLoggedFilterSetTests):
)
Site.objects.bulk_create(sites)
locations = (
Location(name='Location 1', site=sites[0], slug='location-1'),
Location(name='Location 2', site=sites[1], slug='location-1'),
Location(name='Location 3', site=sites[2], slug='location-1'),
)
for location in locations:
location.save()
racks = (
Rack(name='Rack 1', site=sites[0], location=locations[0]),
Rack(name='Rack 2', site=sites[1], location=locations[1]),
Rack(name='Rack 3', site=sites[2], location=locations[2]),
)
Rack.objects.bulk_create(racks)
tenants = (
Tenant(name='Tenant 1', slug='tenant-1'),
Tenant(name='Tenant 2', slug='tenant-2'),
@@ -3695,17 +3565,24 @@ class CableTestCase(TestCase, ChangeLoggedFilterSetTests):
)
Tenant.objects.bulk_create(tenants)
racks = (
Rack(name='Rack 1', site=sites[0]),
Rack(name='Rack 2', site=sites[1]),
Rack(name='Rack 3', site=sites[2]),
)
Rack.objects.bulk_create(racks)
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Model 1', slug='model-1')
device_role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
devices = (
Device(name='Device 1', device_type=device_type, device_role=device_role, site=sites[0], rack=racks[0], location=locations[0], position=1),
Device(name='Device 2', device_type=device_type, device_role=device_role, site=sites[0], rack=racks[0], location=locations[0], position=2),
Device(name='Device 3', device_type=device_type, device_role=device_role, site=sites[1], rack=racks[1], location=locations[1], position=1),
Device(name='Device 4', device_type=device_type, device_role=device_role, site=sites[1], rack=racks[1], location=locations[1], position=2),
Device(name='Device 5', device_type=device_type, device_role=device_role, site=sites[2], rack=racks[2], location=locations[2], position=1),
Device(name='Device 6', device_type=device_type, device_role=device_role, site=sites[2], rack=racks[2], location=locations[2], position=2),
Device(name='Device 1', device_type=device_type, device_role=device_role, site=sites[0], rack=racks[0], position=1),
Device(name='Device 2', device_type=device_type, device_role=device_role, site=sites[0], rack=racks[0], position=2),
Device(name='Device 3', device_type=device_type, device_role=device_role, site=sites[1], rack=racks[1], position=1),
Device(name='Device 4', device_type=device_type, device_role=device_role, site=sites[1], rack=racks[1], position=2),
Device(name='Device 5', device_type=device_type, device_role=device_role, site=sites[2], rack=racks[2], position=1),
Device(name='Device 6', device_type=device_type, device_role=device_role, site=sites[2], rack=racks[2], position=2),
)
Device.objects.bulk_create(devices)
@@ -3729,13 +3606,13 @@ class CableTestCase(TestCase, ChangeLoggedFilterSetTests):
console_server_port = ConsoleServerPort.objects.create(device=devices[0], name='Console Server Port 1')
# Cables
Cable(a_terminations=[interfaces[1]], b_terminations=[interfaces[2]], label='Cable 1', type=CableTypeChoices.TYPE_CAT3, tenant=tenants[0], status=LinkStatusChoices.STATUS_CONNECTED, color='aa1409', length=10, length_unit=CableLengthUnitChoices.UNIT_FOOT).save()
Cable(a_terminations=[interfaces[3]], b_terminations=[interfaces[4]], label='Cable 2', type=CableTypeChoices.TYPE_CAT3, tenant=tenants[0], status=LinkStatusChoices.STATUS_CONNECTED, color='aa1409', length=20, length_unit=CableLengthUnitChoices.UNIT_FOOT).save()
Cable(a_terminations=[interfaces[5]], b_terminations=[interfaces[6]], label='Cable 3', type=CableTypeChoices.TYPE_CAT5E, tenant=tenants[1], status=LinkStatusChoices.STATUS_CONNECTED, color='f44336', length=30, length_unit=CableLengthUnitChoices.UNIT_FOOT).save()
Cable(a_terminations=[interfaces[7]], b_terminations=[interfaces[8]], label='Cable 4', type=CableTypeChoices.TYPE_CAT5E, tenant=tenants[1], status=LinkStatusChoices.STATUS_PLANNED, color='f44336', length=40, length_unit=CableLengthUnitChoices.UNIT_FOOT).save()
Cable(a_terminations=[interfaces[9]], b_terminations=[interfaces[10]], label='Cable 5', type=CableTypeChoices.TYPE_CAT6, tenant=tenants[2], status=LinkStatusChoices.STATUS_PLANNED, color='e91e63', length=10, length_unit=CableLengthUnitChoices.UNIT_METER).save()
Cable(a_terminations=[interfaces[11]], b_terminations=[interfaces[0]], label='Cable 6', type=CableTypeChoices.TYPE_CAT6, tenant=tenants[2], status=LinkStatusChoices.STATUS_PLANNED, color='e91e63', length=20, length_unit=CableLengthUnitChoices.UNIT_METER).save()
Cable(a_terminations=[console_port], b_terminations=[console_server_port], label='Cable 7').save()
Cable(termination_a=interfaces[1], termination_b=interfaces[2], label='Cable 1', type=CableTypeChoices.TYPE_CAT3, tenant=tenants[0], status=LinkStatusChoices.STATUS_CONNECTED, color='aa1409', length=10, length_unit=CableLengthUnitChoices.UNIT_FOOT).save()
Cable(termination_a=interfaces[3], termination_b=interfaces[4], label='Cable 2', type=CableTypeChoices.TYPE_CAT3, tenant=tenants[0], status=LinkStatusChoices.STATUS_CONNECTED, color='aa1409', length=20, length_unit=CableLengthUnitChoices.UNIT_FOOT).save()
Cable(termination_a=interfaces[5], termination_b=interfaces[6], label='Cable 3', type=CableTypeChoices.TYPE_CAT5E, tenant=tenants[1], status=LinkStatusChoices.STATUS_CONNECTED, color='f44336', length=30, length_unit=CableLengthUnitChoices.UNIT_FOOT).save()
Cable(termination_a=interfaces[7], termination_b=interfaces[8], label='Cable 4', type=CableTypeChoices.TYPE_CAT5E, tenant=tenants[1], status=LinkStatusChoices.STATUS_PLANNED, color='f44336', length=40, length_unit=CableLengthUnitChoices.UNIT_FOOT).save()
Cable(termination_a=interfaces[9], termination_b=interfaces[10], label='Cable 5', type=CableTypeChoices.TYPE_CAT6, tenant=tenants[2], status=LinkStatusChoices.STATUS_PLANNED, color='e91e63', length=10, length_unit=CableLengthUnitChoices.UNIT_METER).save()
Cable(termination_a=interfaces[11], termination_b=interfaces[0], label='Cable 6', type=CableTypeChoices.TYPE_CAT6, tenant=tenants[2], status=LinkStatusChoices.STATUS_PLANNED, color='e91e63', length=20, length_unit=CableLengthUnitChoices.UNIT_METER).save()
Cable(termination_a=console_port, termination_b=console_server_port, label='Cable 7').save()
def test_label(self):
params = {'label': ['Cable 1', 'Cable 2']}
@@ -3777,13 +3654,6 @@ class CableTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'rack': [racks[0].name, racks[1].name]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)
def test_location(self):
locations = Location.objects.all()[:2]
params = {'location_id': [locations[0].pk, locations[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)
params = {'location': [locations[0].name, locations[1].name]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)
def test_site(self):
site = Site.objects.all()[:2]
params = {'site_id': [site[0].pk, site[1].pk]}
@@ -3805,10 +3675,7 @@ class CableTestCase(TestCase, ChangeLoggedFilterSetTests):
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
def test_termination_ids(self):
interface_ids = CableTermination.objects.filter(
cable__in=Cable.objects.all()[:3],
cable_end='A'
).values_list('termination_id', flat=True)
interface_ids = Cable.objects.values_list('termination_a_id', flat=True)[:3]
params = {
'termination_a_type': 'dcim.interface',
'termination_a_id': list(interface_ids),
@@ -3952,8 +3819,8 @@ class PowerFeedTestCase(TestCase, ChangeLoggedFilterSetTests):
PowerPort(device=device, name='Power Port 2'),
]
PowerPort.objects.bulk_create(power_ports)
Cable(a_terminations=[power_feeds[0]], b_terminations=[power_ports[0]]).save()
Cable(a_terminations=[power_feeds[1]], b_terminations=[power_ports[1]]).save()
Cable(termination_a=power_feeds[0], termination_b=power_ports[0]).save()
Cable(termination_a=power_feeds[1], termination_b=power_ports[1]).save()
def test_name(self):
params = {'name': ['Power Feed 1', 'Power Feed 2']}

View File

@@ -5,7 +5,6 @@ from circuits.models import *
from dcim.choices import *
from dcim.models import *
from tenancy.models import Tenant
from utilities.utils import drange
class LocationTestCase(TestCase):
@@ -75,142 +74,148 @@ class RackTestCase(TestCase):
def setUp(self):
sites = (
Site(name='Site 1', slug='site-1'),
Site(name='Site 2', slug='site-2'),
self.site1 = Site.objects.create(
name='TestSite1',
slug='test-site-1'
)
Site.objects.bulk_create(sites)
locations = (
Location(name='Location 1', slug='location-1', site=sites[0]),
Location(name='Location 2', slug='location-2', site=sites[1]),
self.site2 = Site.objects.create(
name='TestSite2',
slug='test-site-2'
)
for location in locations:
location.save()
Rack.objects.create(
name='Rack 1',
self.location1 = Location.objects.create(
name='TestGroup1',
slug='test-group-1',
site=self.site1
)
self.location2 = Location.objects.create(
name='TestGroup2',
slug='test-group-2',
site=self.site2
)
self.rack = Rack.objects.create(
name='TestRack1',
facility_id='A101',
site=sites[0],
location=locations[0],
site=self.site1,
location=self.location1,
u_height=42
)
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
device_types = (
DeviceType(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1', u_height=1),
DeviceType(manufacturer=manufacturer, model='Device Type 2', slug='device-type-2', u_height=0),
DeviceType(manufacturer=manufacturer, model='Device Type 3', slug='device-type-3', u_height=0.5),
self.manufacturer = Manufacturer.objects.create(
name='Acme',
slug='acme'
)
DeviceType.objects.bulk_create(device_types)
DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
self.device_type = {
'ff2048': DeviceType.objects.create(
manufacturer=self.manufacturer,
model='FrameForwarder 2048',
slug='ff2048'
),
'cc5000': DeviceType.objects.create(
manufacturer=self.manufacturer,
model='CurrentCatapult 5000',
slug='cc5000',
u_height=0
),
}
self.role = {
'Server': DeviceRole.objects.create(
name='Server',
slug='server',
),
'Switch': DeviceRole.objects.create(
name='Switch',
slug='switch',
),
'Console Server': DeviceRole.objects.create(
name='Console Server',
slug='console-server',
),
'PDU': DeviceRole.objects.create(
name='PDU',
slug='pdu',
),
}
def test_rack_device_outside_height(self):
site = Site.objects.first()
rack = Rack.objects.first()
rack1 = Rack(
name='TestRack2',
facility_id='A102',
site=self.site1,
u_height=42
)
rack1.save()
device1 = Device(
name='Device 1',
device_type=DeviceType.objects.first(),
device_role=DeviceRole.objects.first(),
site=site,
rack=rack,
name='TestSwitch1',
device_type=DeviceType.objects.get(manufacturer__slug='acme', slug='ff2048'),
device_role=DeviceRole.objects.get(slug='switch'),
site=self.site1,
rack=rack1,
position=43,
face=DeviceFaceChoices.FACE_FRONT,
)
device1.save()
with self.assertRaises(ValidationError):
rack.clean()
rack1.clean()
def test_location_site(self):
site1 = Site.objects.get(name='Site 1')
location2 = Location.objects.get(name='Location 2')
rack2 = Rack(
name='Rack 2',
site=site1,
location=location2,
u_height=42
rack_invalid_location = Rack(
name='TestRack2',
facility_id='A102',
site=self.site1,
u_height=42,
location=self.location2
)
rack2.save()
rack_invalid_location.save()
with self.assertRaises(ValidationError):
rack2.clean()
rack_invalid_location.clean()
def test_mount_single_device(self):
site = Site.objects.first()
rack = Rack.objects.first()
device1 = Device(
name='TestSwitch1',
device_type=DeviceType.objects.first(),
device_role=DeviceRole.objects.first(),
site=site,
rack=rack,
position=10.0,
device_type=DeviceType.objects.get(manufacturer__slug='acme', slug='ff2048'),
device_role=DeviceRole.objects.get(slug='switch'),
site=self.site1,
rack=self.rack,
position=10,
face=DeviceFaceChoices.FACE_REAR,
)
device1.save()
# Validate rack height
self.assertEqual(list(rack.units), list(drange(42.5, 0.5, -0.5)))
self.assertEqual(list(self.rack.units), list(reversed(range(1, 43))))
# Validate inventory (front face)
rack1_inventory_front = {
u['id']: u for u in rack.get_rack_units(face=DeviceFaceChoices.FACE_FRONT)
}
self.assertEqual(rack1_inventory_front[10.0]['device'], device1)
self.assertEqual(rack1_inventory_front[10.5]['device'], device1)
del rack1_inventory_front[10.0]
del rack1_inventory_front[10.5]
for u in rack1_inventory_front.values():
rack1_inventory_front = self.rack.get_rack_units(face=DeviceFaceChoices.FACE_FRONT)
self.assertEqual(rack1_inventory_front[-10]['device'], device1)
del rack1_inventory_front[-10]
for u in rack1_inventory_front:
self.assertIsNone(u['device'])
# Validate inventory (rear face)
rack1_inventory_rear = {
u['id']: u for u in rack.get_rack_units(face=DeviceFaceChoices.FACE_REAR)
}
self.assertEqual(rack1_inventory_rear[10.0]['device'], device1)
self.assertEqual(rack1_inventory_rear[10.5]['device'], device1)
del rack1_inventory_rear[10.0]
del rack1_inventory_rear[10.5]
for u in rack1_inventory_rear.values():
rack1_inventory_rear = self.rack.get_rack_units(face=DeviceFaceChoices.FACE_REAR)
self.assertEqual(rack1_inventory_rear[-10]['device'], device1)
del rack1_inventory_rear[-10]
for u in rack1_inventory_rear:
self.assertIsNone(u['device'])
def test_mount_zero_ru(self):
"""
Check that a 0RU device can be mounted in a rack with no face/position.
"""
site = Site.objects.first()
rack = Rack.objects.first()
Device(
name='Device 1',
device_role=DeviceRole.objects.first(),
device_type=DeviceType.objects.first(),
site=site,
rack=rack
).save()
def test_mount_half_u_devices(self):
"""
Check that two 0.5U devices can be mounted in the same rack unit.
"""
rack = Rack.objects.first()
attrs = {
'device_type': DeviceType.objects.get(u_height=0.5),
'device_role': DeviceRole.objects.first(),
'site': Site.objects.first(),
'rack': rack,
'face': DeviceFaceChoices.FACE_FRONT,
}
Device(name='Device 1', position=1, **attrs).save()
Device(name='Device 2', position=1.5, **attrs).save()
self.assertEqual(len(rack.get_available_units()), rack.u_height * 2 - 3)
pdu = Device.objects.create(
name='TestPDU',
device_role=self.role.get('PDU'),
device_type=self.device_type.get('cc5000'),
site=self.site1,
rack=self.rack,
position=None,
face='',
)
self.assertTrue(pdu)
def test_change_rack_site(self):
"""
@@ -219,16 +224,19 @@ class RackTestCase(TestCase):
site_a = Site.objects.create(name='Site A', slug='site-a')
site_b = Site.objects.create(name='Site B', slug='site-b')
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
device_type = DeviceType.objects.create(
manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'
)
device_role = DeviceRole.objects.create(
name='Device Role 1', slug='device-role-1', color='ff0000'
)
# Create Rack1 in Site A
rack1 = Rack.objects.create(site=site_a, name='Rack 1')
# Create Device1 in Rack1
device1 = Device.objects.create(
site=site_a,
rack=rack1,
device_type=DeviceType.objects.first(),
device_role=DeviceRole.objects.first()
)
device1 = Device.objects.create(site=site_a, rack=rack1, device_type=device_type, device_role=device_role)
# Move Rack1 to Site B
rack1.site = site_b
@@ -457,7 +465,7 @@ class CableTestCase(TestCase):
self.interface1 = Interface.objects.create(device=self.device1, name='eth0')
self.interface2 = Interface.objects.create(device=self.device2, name='eth0')
self.interface3 = Interface.objects.create(device=self.device2, name='eth1')
self.cable = Cable(a_terminations=[self.interface1], b_terminations=[self.interface2])
self.cable = Cable(termination_a=self.interface1, termination_b=self.interface2)
self.cable.save()
self.power_port1 = PowerPort.objects.create(device=self.device2, name='psu1')
@@ -493,14 +501,12 @@ class CableTestCase(TestCase):
"""
When a new Cable is created, it must be cached on either termination point.
"""
self.interface1.refresh_from_db()
self.interface2.refresh_from_db()
self.assertEqual(self.interface1.cable, self.cable)
self.assertEqual(self.interface2.cable, self.cable)
self.assertEqual(self.interface1.cable_end, 'A')
self.assertEqual(self.interface2.cable_end, 'B')
self.assertEqual(self.interface1.link_peers, [self.interface2])
self.assertEqual(self.interface2.link_peers, [self.interface1])
interface1 = Interface.objects.get(pk=self.interface1.pk)
interface2 = Interface.objects.get(pk=self.interface2.pk)
self.assertEqual(self.cable.termination_a, interface1)
self.assertEqual(interface1._link_peer, interface2)
self.assertEqual(self.cable.termination_b, interface2)
self.assertEqual(interface2._link_peer, interface1)
def test_cable_deletion(self):
"""
@@ -512,33 +518,50 @@ class CableTestCase(TestCase):
self.assertNotEqual(str(self.cable), '#None')
interface1 = Interface.objects.get(pk=self.interface1.pk)
self.assertIsNone(interface1.cable)
self.assertListEqual(interface1.link_peers, [])
self.assertIsNone(interface1._link_peer)
interface2 = Interface.objects.get(pk=self.interface2.pk)
self.assertIsNone(interface2.cable)
self.assertListEqual(interface2.link_peers, [])
self.assertIsNone(interface2._link_peer)
def test_cable_validates_same_parent_object(self):
def test_cabletermination_deletion(self):
"""
The clean method should ensure that all terminations at either end of a Cable belong to the same parent object.
When a CableTermination object is deleted, its attached Cable (if any) must also be deleted.
"""
cable = Cable(a_terminations=[self.interface1], b_terminations=[self.power_port1])
with self.assertRaises(ValidationError):
cable.clean()
def test_cable_validates_same_type(self):
"""
The clean method should ensure that all terminations at either end of a Cable are of the same type.
"""
cable = Cable(a_terminations=[self.front_port1, self.rear_port1], b_terminations=[self.interface1])
with self.assertRaises(ValidationError):
cable.clean()
self.interface1.delete()
cable = Cable.objects.filter(pk=self.cable.pk).first()
self.assertIsNone(cable)
def test_cable_validates_compatible_types(self):
"""
The clean method should have a check to ensure only compatible port types can be connected by a cable
"""
# An interface cannot be connected to a power port, for example
cable = Cable(a_terminations=[self.interface1], b_terminations=[self.power_port1])
# An interface cannot be connected to a power port
cable = Cable(termination_a=self.interface1, termination_b=self.power_port1)
with self.assertRaises(ValidationError):
cable.clean()
def test_cable_cannot_have_the_same_terminination_on_both_ends(self):
"""
A cable cannot be made with the same A and B side terminations
"""
cable = Cable(termination_a=self.interface1, termination_b=self.interface1)
with self.assertRaises(ValidationError):
cable.clean()
def test_cable_front_port_cannot_connect_to_corresponding_rear_port(self):
"""
A cable cannot connect a front port to its corresponding rear port
"""
cable = Cable(termination_a=self.front_port1, termination_b=self.rear_port1)
with self.assertRaises(ValidationError):
cable.clean()
def test_cable_cannot_terminate_to_an_existing_connection(self):
"""
Either side of a cable cannot be terminated when that side already has a connection
"""
# Try to create a cable with the same interface terminations
cable = Cable(termination_a=self.interface2, termination_b=self.interface1)
with self.assertRaises(ValidationError):
cable.clean()
@@ -546,16 +569,45 @@ class CableTestCase(TestCase):
"""
Neither side of a cable can be terminated to a CircuitTermination which is attached to a ProviderNetwork
"""
cable = Cable(a_terminations=[self.interface3], b_terminations=[self.circuittermination3])
cable = Cable(termination_a=self.interface3, termination_b=self.circuittermination3)
with self.assertRaises(ValidationError):
cable.clean()
def test_rearport_connections(self):
"""
Test various combinations of RearPort connections.
"""
# Connecting a single-position RearPort to a multi-position RearPort is ok
Cable(termination_a=self.rear_port1, termination_b=self.rear_port2).full_clean()
# Connecting a single-position RearPort to an Interface is ok
Cable(termination_a=self.rear_port1, termination_b=self.interface3).full_clean()
# Connecting a single-position RearPort to a CircuitTermination is ok
Cable(termination_a=self.rear_port1, termination_b=self.circuittermination1).full_clean()
# Connecting a multi-position RearPort to another RearPort with the same number of positions is ok
Cable(termination_a=self.rear_port3, termination_b=self.rear_port4).full_clean()
# Connecting a multi-position RearPort to an Interface is ok
Cable(termination_a=self.rear_port2, termination_b=self.interface3).full_clean()
# Connecting a multi-position RearPort to a CircuitTermination is ok
Cable(termination_a=self.rear_port2, termination_b=self.circuittermination1).full_clean()
# Connecting a two-position RearPort to a three-position RearPort is NOT ok
with self.assertRaises(
ValidationError,
msg='Connecting a 2-position RearPort to a 3-position RearPort should fail'
):
Cable(termination_a=self.rear_port2, termination_b=self.rear_port3).full_clean()
def test_cable_cannot_terminate_to_a_virtual_interface(self):
"""
A cable cannot terminate to a virtual interface
"""
virtual_interface = Interface(device=self.device1, name="V1", type=InterfaceTypeChoices.TYPE_VIRTUAL)
cable = Cable(a_terminations=[self.interface2], b_terminations=[virtual_interface])
cable = Cable(termination_a=self.interface2, termination_b=virtual_interface)
with self.assertRaises(ValidationError):
cable.clean()
@@ -564,6 +616,6 @@ class CableTestCase(TestCase):
A cable cannot terminate to a wireless interface
"""
wireless_interface = Interface(device=self.device1, name="W1", type=InterfaceTypeChoices.TYPE_80211A)
cable = Cable(a_terminations=[self.interface2], b_terminations=[wireless_interface])
cable = Cable(termination_a=self.interface2, termination_b=wireless_interface)
with self.assertRaises(ValidationError):
cable.clean()

View File

@@ -12,7 +12,6 @@ from dcim.choices import *
from dcim.constants import *
from dcim.models import *
from ipam.models import ASN, RIR, VLAN, VRF
from netbox.api.serializers import GenericObjectSerializer
from tenancy.models import Tenant
from utilities.testing import ViewTestCases, create_tags, create_test_device, post_data
from wireless.models import WirelessLAN
@@ -176,9 +175,9 @@ class LocationTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
tenant = Tenant.objects.create(name='Tenant 1', slug='tenant-1')
locations = (
Location(name='Location 1', slug='location-1', site=site, status=LocationStatusChoices.STATUS_ACTIVE, tenant=tenant),
Location(name='Location 2', slug='location-2', site=site, status=LocationStatusChoices.STATUS_ACTIVE, tenant=tenant),
Location(name='Location 3', slug='location-3', site=site, status=LocationStatusChoices.STATUS_ACTIVE, tenant=tenant),
Location(name='Location 1', slug='location-1', site=site, tenant=tenant),
Location(name='Location 2', slug='location-2', site=site, tenant=tenant),
Location(name='Location 3', slug='location-3', site=site, tenant=tenant),
)
for location in locations:
location.save()
@@ -189,17 +188,16 @@ class LocationTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
'name': 'Location X',
'slug': 'location-x',
'site': site.pk,
'status': LocationStatusChoices.STATUS_PLANNED,
'tenant': tenant.pk,
'description': 'A new location',
'tags': [t.pk for t in tags],
}
cls.csv_data = (
"site,tenant,name,slug,status,description",
"Site 1,Tenant 1,Location 4,location-4,planned,Fourth location",
"Site 1,Tenant 1,Location 5,location-5,planned,Fifth location",
"Site 1,Tenant 1,Location 6,location-6,planned,Sixth location",
"site,tenant,name,slug,description",
"Site 1,Tenant 1,Location 4,location-4,Fourth location",
"Site 1,Tenant 1,Location 5,location-5,Fifth location",
"Site 1,Tenant 1,Location 6,location-6,Sixth location",
)
cls.bulk_edit_data = {
@@ -1962,7 +1960,7 @@ class ConsolePortTestCase(ViewTestCases.DeviceComponentViewTestCase):
device=consoleport.device,
name='Console Server Port 1'
)
Cable(a_terminations=[consoleport], b_terminations=[consoleserverport]).save()
Cable(termination_a=consoleport, termination_b=consoleserverport).save()
response = self.client.get(reverse('dcim:consoleport_trace', kwargs={'pk': consoleport.pk}))
self.assertHttpStatus(response, 200)
@@ -2018,7 +2016,7 @@ class ConsoleServerPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
device=consoleserverport.device,
name='Console Port 1'
)
Cable(a_terminations=[consoleserverport], b_terminations=[consoleport]).save()
Cable(termination_a=consoleserverport, termination_b=consoleport).save()
response = self.client.get(reverse('dcim:consoleserverport_trace', kwargs={'pk': consoleserverport.pk}))
self.assertHttpStatus(response, 200)
@@ -2080,7 +2078,7 @@ class PowerPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
device=powerport.device,
name='Power Outlet 1'
)
Cable(a_terminations=[powerport], b_terminations=[poweroutlet]).save()
Cable(termination_a=powerport, termination_b=poweroutlet).save()
response = self.client.get(reverse('dcim:powerport_trace', kwargs={'pk': powerport.pk}))
self.assertHttpStatus(response, 200)
@@ -2145,7 +2143,7 @@ class PowerOutletTestCase(ViewTestCases.DeviceComponentViewTestCase):
def test_trace(self):
poweroutlet = PowerOutlet.objects.first()
powerport = PowerPort.objects.first()
Cable(a_terminations=[poweroutlet], b_terminations=[powerport]).save()
Cable(termination_a=poweroutlet, termination_b=powerport).save()
response = self.client.get(reverse('dcim:poweroutlet_trace', kwargs={'pk': poweroutlet.pk}))
self.assertHttpStatus(response, 200)
@@ -2206,8 +2204,6 @@ class InterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase):
'description': 'A front port',
'mode': InterfaceModeChoices.MODE_TAGGED,
'tx_power': 10,
'poe_mode': InterfacePoEModeChoices.MODE_PSE,
'poe_type': InterfacePoETypeChoices.TYPE_1_8023AF,
'untagged_vlan': vlans[0].pk,
'tagged_vlans': [v.pk for v in vlans[1:4]],
'wireless_lans': [wireless_lans[0].pk, wireless_lans[1].pk],
@@ -2229,8 +2225,6 @@ class InterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase):
'duplex': 'half',
'mgmt_only': True,
'description': 'A front port',
'poe_mode': InterfacePoEModeChoices.MODE_PSE,
'poe_type': InterfacePoETypeChoices.TYPE_1_8023AF,
'mode': InterfaceModeChoices.MODE_TAGGED,
'untagged_vlan': vlans[0].pk,
'tagged_vlans': [v.pk for v in vlans[1:4]],
@@ -2250,8 +2244,6 @@ class InterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase):
'duplex': 'full',
'mgmt_only': True,
'description': 'New description',
'poe_mode': InterfacePoEModeChoices.MODE_PD,
'poe_type': InterfacePoETypeChoices.TYPE_2_8023AT,
'mode': InterfaceModeChoices.MODE_TAGGED,
'tx_power': 10,
'untagged_vlan': vlans[0].pk,
@@ -2260,16 +2252,16 @@ class InterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase):
}
cls.csv_data = (
f"device,name,type,vrf.pk,poe_mode,poe_type",
f"Device 1,Interface 4,1000base-t,{vrfs[0].pk},pse,type1-ieee802.3af",
f"Device 1,Interface 5,1000base-t,{vrfs[0].pk},pse,type1-ieee802.3af",
f"Device 1,Interface 6,1000base-t,{vrfs[0].pk},pse,type1-ieee802.3af",
f"device,name,type,vrf.pk",
f"Device 1,Interface 4,1000base-t,{vrfs[0].pk}",
f"Device 1,Interface 5,1000base-t,{vrfs[0].pk}",
f"Device 1,Interface 6,1000base-t,{vrfs[0].pk}",
)
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
def test_trace(self):
interface1, interface2 = Interface.objects.all()[:2]
Cable(a_terminations=[interface1], b_terminations=[interface2]).save()
Cable(termination_a=interface1, termination_b=interface2).save()
response = self.client.get(reverse('dcim:interface_trace', kwargs={'pk': interface1.pk}))
self.assertHttpStatus(response, 200)
@@ -2340,7 +2332,7 @@ class FrontPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
device=frontport.device,
name='Interface 1'
)
Cable(a_terminations=[frontport], b_terminations=[interface]).save()
Cable(termination_a=frontport, termination_b=interface).save()
response = self.client.get(reverse('dcim:frontport_trace', kwargs={'pk': frontport.pk}))
self.assertHttpStatus(response, 200)
@@ -2398,7 +2390,7 @@ class RearPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
device=rearport.device,
name='Interface 1'
)
Cable(a_terminations=[rearport], b_terminations=[interface]).save()
Cable(termination_a=rearport, termination_b=interface).save()
response = self.client.get(reverse('dcim:rearport_trace', kwargs={'pk': rearport.pk}))
self.assertHttpStatus(response, 200)
@@ -2631,18 +2623,19 @@ class CableTestCase(
)
Interface.objects.bulk_create(interfaces)
Cable(a_terminations=[interfaces[0]], b_terminations=[interfaces[3]], type=CableTypeChoices.TYPE_CAT6).save()
Cable(a_terminations=[interfaces[1]], b_terminations=[interfaces[4]], type=CableTypeChoices.TYPE_CAT6).save()
Cable(a_terminations=[interfaces[2]], b_terminations=[interfaces[5]], type=CableTypeChoices.TYPE_CAT6).save()
Cable(termination_a=interfaces[0], termination_b=interfaces[3], type=CableTypeChoices.TYPE_CAT6).save()
Cable(termination_a=interfaces[1], termination_b=interfaces[4], type=CableTypeChoices.TYPE_CAT6).save()
Cable(termination_a=interfaces[2], termination_b=interfaces[5], type=CableTypeChoices.TYPE_CAT6).save()
tags = create_tags('Alpha', 'Bravo', 'Charlie')
interface_ct = ContentType.objects.get_for_model(Interface)
cls.form_data = {
# TODO: Revisit this limitation
# Changing terminations not supported when editing an existing Cable
'a_terminations': [interfaces[0].pk],
'b_terminations': [interfaces[3].pk],
'termination_a_type': interface_ct.pk,
'termination_a_id': interfaces[0].pk,
'termination_b_type': interface_ct.pk,
'termination_b_id': interfaces[3].pk,
'type': CableTypeChoices.TYPE_CAT6,
'status': LinkStatusChoices.STATUS_PLANNED,
'label': 'Label',
@@ -2668,17 +2661,6 @@ class CableTestCase(
'length_unit': CableLengthUnitChoices.UNIT_METER,
}
def model_to_dict(self, *args, **kwargs):
data = super().model_to_dict(*args, **kwargs)
# Serialize termination objects
if 'a_terminations' in data:
data['a_terminations'] = [obj.pk for obj in data['a_terminations']]
if 'b_terminations' in data:
data['b_terminations'] = [obj.pk for obj in data['b_terminations']]
return data
class VirtualChassisTestCase(ViewTestCases.PrimaryObjectViewTestCase):
model = VirtualChassis
@@ -2875,7 +2857,7 @@ class PowerFeedTestCase(ViewTestCases.PrimaryObjectViewTestCase):
device=device,
name='Power Port 1'
)
Cable(a_terminations=[powerfeed], b_terminations=[powerport]).save()
Cable(termination_a=powerfeed, termination_b=powerport).save()
response = self.client.get(reverse('dcim:powerfeed_trace', kwargs={'pk': powerfeed.pk}))
self.assertHttpStatus(response, 200)

View File

@@ -248,6 +248,7 @@ urlpatterns = [
path('devices/import/', views.DeviceBulkImportView.as_view(), name='device_import'),
path('devices/import/child-devices/', views.ChildDeviceBulkImportView.as_view(), name='device_import_child'),
path('devices/edit/', views.DeviceBulkEditView.as_view(), name='device_bulk_edit'),
path('devices/rename/', views.DeviceBulkRenameView.as_view(), name='device_bulk_rename'),
path('devices/delete/', views.DeviceBulkDeleteView.as_view(), name='device_bulk_delete'),
path('devices/<int:pk>/', views.DeviceView.as_view(), name='device'),
path('devices/<int:pk>/edit/', views.DeviceEditView.as_view(), name='device_edit'),
@@ -294,6 +295,7 @@ urlpatterns = [
path('console-ports/<int:pk>/delete/', views.ConsolePortDeleteView.as_view(), name='consoleport_delete'),
path('console-ports/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='consoleport_changelog', kwargs={'model': ConsolePort}),
path('console-ports/<int:pk>/trace/', views.PathTraceView.as_view(), name='consoleport_trace', kwargs={'model': ConsolePort}),
path('console-ports/<int:termination_a_id>/connect/<str:termination_b_type>/', views.CableCreateView.as_view(), name='consoleport_connect', kwargs={'termination_a_type': ConsolePort}),
path('devices/console-ports/add/', views.DeviceBulkAddConsolePortView.as_view(), name='device_bulk_add_consoleport'),
# Console server ports
@@ -309,6 +311,7 @@ urlpatterns = [
path('console-server-ports/<int:pk>/delete/', views.ConsoleServerPortDeleteView.as_view(), name='consoleserverport_delete'),
path('console-server-ports/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='consoleserverport_changelog', kwargs={'model': ConsoleServerPort}),
path('console-server-ports/<int:pk>/trace/', views.PathTraceView.as_view(), name='consoleserverport_trace', kwargs={'model': ConsoleServerPort}),
path('console-server-ports/<int:termination_a_id>/connect/<str:termination_b_type>/', views.CableCreateView.as_view(), name='consoleserverport_connect', kwargs={'termination_a_type': ConsoleServerPort}),
path('devices/console-server-ports/add/', views.DeviceBulkAddConsoleServerPortView.as_view(), name='device_bulk_add_consoleserverport'),
# Power ports
@@ -324,6 +327,7 @@ urlpatterns = [
path('power-ports/<int:pk>/delete/', views.PowerPortDeleteView.as_view(), name='powerport_delete'),
path('power-ports/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='powerport_changelog', kwargs={'model': PowerPort}),
path('power-ports/<int:pk>/trace/', views.PathTraceView.as_view(), name='powerport_trace', kwargs={'model': PowerPort}),
path('power-ports/<int:termination_a_id>/connect/<str:termination_b_type>/', views.CableCreateView.as_view(), name='powerport_connect', kwargs={'termination_a_type': PowerPort}),
path('devices/power-ports/add/', views.DeviceBulkAddPowerPortView.as_view(), name='device_bulk_add_powerport'),
# Power outlets
@@ -339,6 +343,7 @@ urlpatterns = [
path('power-outlets/<int:pk>/delete/', views.PowerOutletDeleteView.as_view(), name='poweroutlet_delete'),
path('power-outlets/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='poweroutlet_changelog', kwargs={'model': PowerOutlet}),
path('power-outlets/<int:pk>/trace/', views.PathTraceView.as_view(), name='poweroutlet_trace', kwargs={'model': PowerOutlet}),
path('power-outlets/<int:termination_a_id>/connect/<str:termination_b_type>/', views.CableCreateView.as_view(), name='poweroutlet_connect', kwargs={'termination_a_type': PowerOutlet}),
path('devices/power-outlets/add/', views.DeviceBulkAddPowerOutletView.as_view(), name='device_bulk_add_poweroutlet'),
# Interfaces
@@ -354,6 +359,7 @@ urlpatterns = [
path('interfaces/<int:pk>/delete/', views.InterfaceDeleteView.as_view(), name='interface_delete'),
path('interfaces/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='interface_changelog', kwargs={'model': Interface}),
path('interfaces/<int:pk>/trace/', views.PathTraceView.as_view(), name='interface_trace', kwargs={'model': Interface}),
path('interfaces/<int:termination_a_id>/connect/<str:termination_b_type>/', views.CableCreateView.as_view(), name='interface_connect', kwargs={'termination_a_type': Interface}),
path('devices/interfaces/add/', views.DeviceBulkAddInterfaceView.as_view(), name='device_bulk_add_interface'),
# Front ports
@@ -369,6 +375,7 @@ urlpatterns = [
path('front-ports/<int:pk>/delete/', views.FrontPortDeleteView.as_view(), name='frontport_delete'),
path('front-ports/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='frontport_changelog', kwargs={'model': FrontPort}),
path('front-ports/<int:pk>/trace/', views.PathTraceView.as_view(), name='frontport_trace', kwargs={'model': FrontPort}),
path('front-ports/<int:termination_a_id>/connect/<str:termination_b_type>/', views.CableCreateView.as_view(), name='frontport_connect', kwargs={'termination_a_type': FrontPort}),
# path('devices/front-ports/add/', views.DeviceBulkAddFrontPortView.as_view(), name='device_bulk_add_frontport'),
# Rear ports
@@ -384,6 +391,7 @@ urlpatterns = [
path('rear-ports/<int:pk>/delete/', views.RearPortDeleteView.as_view(), name='rearport_delete'),
path('rear-ports/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='rearport_changelog', kwargs={'model': RearPort}),
path('rear-ports/<int:pk>/trace/', views.PathTraceView.as_view(), name='rearport_trace', kwargs={'model': RearPort}),
path('rear-ports/<int:termination_a_id>/connect/<str:termination_b_type>/', views.CableCreateView.as_view(), name='rearport_connect', kwargs={'termination_a_type': RearPort}),
path('devices/rear-ports/add/', views.DeviceBulkAddRearPortView.as_view(), name='device_bulk_add_rearport'),
# Module bays
@@ -440,7 +448,6 @@ urlpatterns = [
# Cables
path('cables/', views.CableListView.as_view(), name='cable_list'),
path('cables/add/', views.CableEditView.as_view(), name='cable_add'),
path('cables/import/', views.CableBulkImportView.as_view(), name='cable_import'),
path('cables/edit/', views.CableBulkEditView.as_view(), name='cable_bulk_edit'),
path('cables/delete/', views.CableBulkDeleteView.as_view(), name='cable_bulk_delete'),
@@ -494,5 +501,6 @@ urlpatterns = [
path('power-feeds/<int:pk>/trace/', views.PathTraceView.as_view(), name='powerfeed_trace', kwargs={'model': PowerFeed}),
path('power-feeds/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='powerfeed_changelog', kwargs={'model': PowerFeed}),
path('power-feeds/<int:pk>/journal/', ObjectJournalView.as_view(), name='powerfeed_journal', kwargs={'model': PowerFeed}),
path('power-feeds/<int:termination_a_id>/connect/<str:termination_b_type>/', views.CableCreateView.as_view(), name='powerfeed_connect', kwargs={'termination_a_type': PowerFeed}),
]

Some files were not shown because too many files have changed in this diff Show More