mirror of
https://github.com/netbox-community/netbox.git
synced 2026-02-07 17:39:32 +01:00
Compare commits
104 Commits
v3.4-beta1
...
v3.4.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
def3ccfaee | ||
|
|
bbc68f9484 | ||
|
|
93685d92a4 | ||
|
|
b2bf613895 | ||
|
|
80ced6b782 | ||
|
|
47dfb89c52 | ||
|
|
064e3ff605 | ||
|
|
fb27803ab0 | ||
|
|
5e32b39f25 | ||
|
|
b9888d6f86 | ||
|
|
96a796ebde | ||
|
|
996e73d5d8 | ||
|
|
5c969a8caf | ||
|
|
68faab8196 | ||
|
|
b3693099dc | ||
|
|
9bb9ac3dec | ||
|
|
a57378e780 | ||
|
|
41f631b65b | ||
|
|
2db668f5cc | ||
|
|
aacf606999 | ||
|
|
e338f7cfe3 | ||
|
|
758030733c | ||
|
|
ad78f9e075 | ||
|
|
3468e8c8ae | ||
|
|
13d39a28ce | ||
|
|
8809fc949b | ||
|
|
860805ba82 | ||
|
|
1e0b024609 | ||
|
|
8486d47d17 | ||
|
|
407365888a | ||
|
|
2ad1db0c64 | ||
|
|
83a0576ca4 | ||
|
|
0b100b8fc8 | ||
|
|
2b12138c41 | ||
|
|
97aa40f7a8 | ||
|
|
b2f34cec19 | ||
|
|
3bc9586b0c | ||
|
|
4297c65f87 | ||
|
|
62b0f034e7 | ||
|
|
6ffd8aa320 | ||
|
|
d53ddd611b | ||
|
|
080a001118 | ||
|
|
5a77791f9d | ||
|
|
ab9c253310 | ||
|
|
35596ddcbc | ||
|
|
0cacac82ee | ||
|
|
780997a568 | ||
|
|
d2d60c0607 | ||
|
|
d4d8d00d01 | ||
|
|
cb52d9c84e | ||
|
|
db61e57893 | ||
|
|
52cf9086a5 | ||
|
|
db7590df1a | ||
|
|
ee03f3d584 | ||
|
|
2577f3a786 | ||
|
|
826a1714c3 | ||
|
|
d0e0c2ff8b | ||
|
|
fb407e9076 | ||
|
|
85c60670dc | ||
|
|
f2f36c67f6 | ||
|
|
281934cf34 | ||
|
|
00d72f18cf | ||
|
|
b36afdc924 | ||
|
|
4ed45e4031 | ||
|
|
cf0258204f | ||
|
|
3bd560add8 | ||
|
|
9e51a8d9d2 | ||
|
|
f59c6699f6 | ||
|
|
39732fa861 | ||
|
|
80f5eeacdd | ||
|
|
f56e3eb784 | ||
|
|
c3dcd8937f | ||
|
|
b1da374df2 | ||
|
|
dc1da0a738 | ||
|
|
1946e8f053 | ||
|
|
4623858849 | ||
|
|
9c5891f1b6 | ||
|
|
d5538c1ca3 | ||
|
|
90f15b8d55 | ||
|
|
4e27e8d3dd | ||
|
|
150cb772fe | ||
|
|
e494d7bb22 | ||
|
|
9774bb46ce | ||
|
|
84c0c45da9 | ||
|
|
46e3883f19 | ||
|
|
3a89a676cd | ||
|
|
0885333b11 | ||
|
|
c287641363 | ||
|
|
de9646d096 | ||
|
|
dd2520d675 | ||
|
|
3a5914827b | ||
|
|
cf55e96241 | ||
|
|
bd29d15814 | ||
|
|
d3911e2a4c | ||
|
|
eb591731ef | ||
|
|
ae11419045 | ||
|
|
43bbd42d3c | ||
|
|
d4a231585a | ||
|
|
977b79ecee | ||
|
|
5202d0add9 | ||
|
|
ebf555e1fb | ||
|
|
f411c4f439 | ||
|
|
216d8d24b8 | ||
|
|
cb2b256934 |
2
.github/ISSUE_TEMPLATE/bug_report.yaml
vendored
2
.github/ISSUE_TEMPLATE/bug_report.yaml
vendored
@@ -14,7 +14,7 @@ body:
|
||||
attributes:
|
||||
label: NetBox version
|
||||
description: What version of NetBox are you currently running?
|
||||
placeholder: v3.3.8
|
||||
placeholder: v3.4.0
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
|
||||
2
.github/ISSUE_TEMPLATE/feature_request.yaml
vendored
2
.github/ISSUE_TEMPLATE/feature_request.yaml
vendored
@@ -14,7 +14,7 @@ body:
|
||||
attributes:
|
||||
label: NetBox version
|
||||
description: What version of NetBox are you currently running?
|
||||
placeholder: v3.3.8
|
||||
placeholder: v3.4.0
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
|
||||
@@ -68,7 +68,7 @@ drf-yasg[validation]
|
||||
|
||||
# Django wrapper for Graphene (GraphQL support)
|
||||
# https://github.com/graphql-python/graphene-django
|
||||
graphene_django<3.0
|
||||
graphene_django
|
||||
|
||||
# WSGI HTTP server
|
||||
# https://gunicorn.org/
|
||||
|
||||
@@ -58,9 +58,11 @@ The following model fields support configurable choices:
|
||||
* `circuits.Circuit.status`
|
||||
* `dcim.Device.status`
|
||||
* `dcim.Location.status`
|
||||
* `dcim.Module.status`
|
||||
* `dcim.PowerFeed.status`
|
||||
* `dcim.Rack.status`
|
||||
* `dcim.Site.status`
|
||||
* `dcim.VirtualDeviceContext.status`
|
||||
* `extras.JournalEntry.kind`
|
||||
* `ipam.IPAddress.status`
|
||||
* `ipam.IPRange.status`
|
||||
@@ -68,6 +70,7 @@ The following model fields support configurable choices:
|
||||
* `ipam.VLAN.status`
|
||||
* `virtualization.Cluster.status`
|
||||
* `virtualization.VirtualMachine.status`
|
||||
* `wireless.WirelessLAN.status`
|
||||
|
||||
The following colors are supported:
|
||||
|
||||
|
||||
@@ -141,6 +141,22 @@ When determining the primary IP address for a device, IPv6 is preferred over IPv
|
||||
|
||||
---
|
||||
|
||||
## QUEUE_MAPPINGS
|
||||
|
||||
Allows changing which queues are used internally for background tasks.
|
||||
|
||||
```python
|
||||
QUEUE_MAPPINGS = {
|
||||
'webhook': 'low',
|
||||
'report': 'high',
|
||||
'script': 'high',
|
||||
}
|
||||
```
|
||||
|
||||
If no queue is defined the queue named `default` will be used.
|
||||
|
||||
---
|
||||
|
||||
## RELEASE_CHECK_URL
|
||||
|
||||
Default: None (disabled)
|
||||
|
||||
@@ -137,6 +137,14 @@ The lifetime (in seconds) of the authentication cookie issued to a NetBox user u
|
||||
|
||||
---
|
||||
|
||||
## LOGOUT_REDIRECT_URL
|
||||
|
||||
Default: `'home'`
|
||||
|
||||
The view name or URL to which a user is redirected after logging out.
|
||||
|
||||
---
|
||||
|
||||
## SESSION_COOKIE_NAME
|
||||
|
||||
Default: `sessionid`
|
||||
|
||||
@@ -45,7 +45,7 @@ class DeviceConnectionsReport(Report):
|
||||
# Check that every console port for every active device has a connection defined.
|
||||
active = DeviceStatusChoices.STATUS_ACTIVE
|
||||
for console_port in ConsolePort.objects.prefetch_related('device').filter(device__status=active):
|
||||
if console_port.connected_endpoint is None:
|
||||
if not console_port.connected_endpoints:
|
||||
self.log_failure(
|
||||
console_port.device,
|
||||
"No console connection defined for {}".format(console_port.name)
|
||||
@@ -64,7 +64,7 @@ class DeviceConnectionsReport(Report):
|
||||
for device in Device.objects.filter(status=DeviceStatusChoices.STATUS_ACTIVE):
|
||||
connected_ports = 0
|
||||
for power_port in PowerPort.objects.filter(device=device):
|
||||
if power_port.connected_endpoint is not None:
|
||||
if power_port.connected_endpoints:
|
||||
connected_ports += 1
|
||||
if not power_port.path.is_active:
|
||||
self.log_warning(
|
||||
|
||||
@@ -56,11 +56,15 @@ If the new field should be filterable, add it to the `FilterSet` for the model.
|
||||
|
||||
If the new field will be included in the object list view, add a column to the model's table. For simple fields, adding the field name to `Meta.fields` will be sufficient. More complex fields may require declaring a custom column. Also add the field name to `default_columns` if the column should be present in the table by default.
|
||||
|
||||
## 8. Update the UI templates
|
||||
## 8. Update the SearchIndex
|
||||
|
||||
Where applicable, add the new field to the model's SearchIndex for inclusion in global search.
|
||||
|
||||
## 9. Update the UI templates
|
||||
|
||||
Edit the object's view template to display the new field. There may also be a custom add/edit form template that needs to be updated.
|
||||
|
||||
## 9. Create/extend test cases
|
||||
## 10. Create/extend test cases
|
||||
|
||||
Create or extend the relevant test cases to verify that the new field and any accompanying validation logic perform as expected. This is especially important for relational fields. NetBox incorporates various test suites, including:
|
||||
|
||||
@@ -72,6 +76,6 @@ Create or extend the relevant test cases to verify that the new field and any ac
|
||||
|
||||
Be diligent to ensure all of the relevant test suites are adapted or extended as necessary to test any new functionality.
|
||||
|
||||
## 10. Update the model's documentation
|
||||
## 11. Update the model's documentation
|
||||
|
||||
Each model has a dedicated page in the documentation, at `models/<app>/<model>.md`. Update this file to include any relevant information about the new field.
|
||||
|
||||
@@ -225,6 +225,9 @@ Once NetBox has been configured, we're ready to proceed with the actual installa
|
||||
* Builds the documentation locally (for offline use)
|
||||
* Aggregate static resource files on disk
|
||||
|
||||
!!! warning
|
||||
If you still have a Python virtual environment active from a previous installation step, disable it now by running the `deactivate` command. This will avoid errors on systems where `sudo` has been configured to preserve the user's current environment.
|
||||
|
||||
```no-highlight
|
||||
sudo /opt/netbox/upgrade.sh
|
||||
```
|
||||
|
||||
@@ -18,6 +18,13 @@ The [module bay](./modulebay.md) into which the module is installed.
|
||||
|
||||
The [module type](./moduletype.md) which represents the physical make & model of hardware. By default, module components will be instantiated automatically from the module type when creating a new module.
|
||||
|
||||
### Status
|
||||
|
||||
The module's operational status.
|
||||
|
||||
!!! tip
|
||||
Additional statuses may be defined by setting `Module.status` under the [`FIELD_CHOICES`](../../configuration/data-validation.md#field_choices) configuration parameter.
|
||||
|
||||
### Serial Number
|
||||
|
||||
The unique physical serial number assigned to this module by its manufacturer.
|
||||
|
||||
@@ -73,6 +73,10 @@ The maximum depth of a mounted device that the rack can accommodate, in millimet
|
||||
|
||||
The numeric weight of the rack, including a unit designation (e.g. 10 kilograms or 20 pounds).
|
||||
|
||||
### Maximum Weight
|
||||
|
||||
The maximum total weight capacity for all installed devices, inclusive of the rack itself.
|
||||
|
||||
### Descending Units
|
||||
|
||||
If selected, the rack's elevation will display unit 1 at the top of the rack. (Most racks use asceneding numbering, with unit 1 assigned to the bottommost position.)
|
||||
If selected, the rack's elevation will display unit 1 at the top of the rack. (Most racks use ascending numbering, with unit 1 assigned to the bottommost position.)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Branches
|
||||
|
||||
A branch is a collection of related [staged changes](./stagedchange.md) that have been prepared for merging into the active database. A branch can be mered by executing its `commit()` method. Deleting a branch will delete all its related changes.
|
||||
A branch is a collection of related [staged changes](./stagedchange.md) that have been prepared for merging into the active database. A branch can be merged by executing its `commit()` method. Deleting a branch will delete all its related changes.
|
||||
|
||||
## Fields
|
||||
|
||||
|
||||
@@ -1,6 +1,55 @@
|
||||
# NetBox v3.3
|
||||
|
||||
## v3.3.9 (FUTURE)
|
||||
## v3.3.10 (2022-12-13)
|
||||
|
||||
### Enhancements
|
||||
|
||||
* [#9361](https://github.com/netbox-community/netbox/issues/9361) - Add replication controls for module bulk import
|
||||
* [#10255](https://github.com/netbox-community/netbox/issues/10255) - Introduce `LOGOUT_REDIRECT_URL` config parameter to control redirection of user after logout
|
||||
* [#10447](https://github.com/netbox-community/netbox/issues/10447) - Enable reassigning an inventory item from one device to another
|
||||
* [#10516](https://github.com/netbox-community/netbox/issues/10516) - Add vertical frame & cabinet rack types
|
||||
* [#10748](https://github.com/netbox-community/netbox/issues/10748) - Add provider selection field for provider networks to circuit termination edit view
|
||||
* [#11089](https://github.com/netbox-community/netbox/issues/11089) - Permit whitespace in MAC addresses
|
||||
* [#11119](https://github.com/netbox-community/netbox/issues/11119) - Enable filtering L2VPNs by slug
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#11041](https://github.com/netbox-community/netbox/issues/11041) - Correct power utilization percentage precision
|
||||
* [#11077](https://github.com/netbox-community/netbox/issues/11077) - Honor configured date format when displaying date custom field values in tables
|
||||
* [#11087](https://github.com/netbox-community/netbox/issues/11087) - Fix background color of bottom banner content
|
||||
* [#11101](https://github.com/netbox-community/netbox/issues/11101) - Correct circuits count under site view
|
||||
* [#11109](https://github.com/netbox-community/netbox/issues/11109) - Fix nullification of custom object & multi-object fields via REST API
|
||||
* [#11128](https://github.com/netbox-community/netbox/issues/11128) - Disable ordering changelog table by object to avoid exception
|
||||
* [#11142](https://github.com/netbox-community/netbox/issues/11142) - Correct available choices for status under IP range filter form
|
||||
* [#11168](https://github.com/netbox-community/netbox/issues/11168) - Honor `RQ_DEFAULT_TIMEOUT` config parameter when using Redis Sentinel
|
||||
* [#11173](https://github.com/netbox-community/netbox/issues/11173) - Enable missing tags columns for contact, L2VPN lists
|
||||
|
||||
---
|
||||
|
||||
## v3.3.9 (2022-11-30)
|
||||
|
||||
### Enhancements
|
||||
|
||||
* [#10653](https://github.com/netbox-community/netbox/issues/10653) - Ensure logging of failed login attempts
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#6389](https://github.com/netbox-community/netbox/issues/6389) - Call `snapshot()` on object when processing deletions
|
||||
* [#9223](https://github.com/netbox-community/netbox/issues/9223) - Fix serialization of array field values in change log
|
||||
* [#9878](https://github.com/netbox-community/netbox/issues/9878) - Fix spurious error message when rendering REST API docs
|
||||
* [#10236](https://github.com/netbox-community/netbox/issues/10236) - Fix TypeError exception when viewing PDU configured for three-phase power
|
||||
* [#10241](https://github.com/netbox-community/netbox/issues/10241) - Support referencing custom field related objects by attribute in addition to PK
|
||||
* [#10579](https://github.com/netbox-community/netbox/issues/10579) - Mark cable traces terminating to a provider network as complete
|
||||
* [#10721](https://github.com/netbox-community/netbox/issues/10721) - Disable ordering by custom object field columns
|
||||
* [#10929](https://github.com/netbox-community/netbox/issues/10929) - Raise validation error when attempting to create a duplicate cable termination
|
||||
* [#10936](https://github.com/netbox-community/netbox/issues/10936) - Permit demotion of device/VM primary IP via IP address edit form
|
||||
* [#10938](https://github.com/netbox-community/netbox/issues/10938) - `render_field` template tag should respect `label` kwarg
|
||||
* [#10969](https://github.com/netbox-community/netbox/issues/10969) - Update cable paths ending at associated rear port when creating new front ports
|
||||
* [#10996](https://github.com/netbox-community/netbox/issues/10996) - Hide checkboxes on child object lists when no bulk operations are available
|
||||
* [#10997](https://github.com/netbox-community/netbox/issues/10997) - Fix exception when editing NAT IP for VM with no cluster
|
||||
* [#11014](https://github.com/netbox-community/netbox/issues/11014) - Use natural ordering when sorting rack elevations by name
|
||||
* [#11028](https://github.com/netbox-community/netbox/issues/11028) - Enable bulk clearing of color attribute of pass-through ports
|
||||
* [#11047](https://github.com/netbox-community/netbox/issues/11047) - Cloning a rack reservation should replicate rack & user
|
||||
|
||||
---
|
||||
|
||||
@@ -429,7 +478,7 @@ Custom field UI visibility has no impact on API operation.
|
||||
* 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
|
||||
* wireless.WirelessLAN
|
||||
* Added `tenant` field
|
||||
wireless.WirelessLink
|
||||
* wireless.WirelessLink
|
||||
* Added `tenant` field
|
||||
|
||||
@@ -1,22 +1,23 @@
|
||||
# NetBox v3.4
|
||||
|
||||
## v3.4.0 (2022-12-14)
|
||||
|
||||
!!! warning "PostgreSQL 11 Required"
|
||||
NetBox v3.4 requires PostgreSQL 11 or later.
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
* Device and virtual machine names are no longer case-sensitive. Attempting to create e.g. "device1" and "DEVICE1" will raise a validation error.
|
||||
* The `asn` field has been removed from the provider model. Please replicate any provider ASN assignments to the ASN model introduced in NetBox v3.1 prior to upgrading.
|
||||
* The `noc_contact`, `admin_contact`, and `portal_url` fields have been removed from the provider model. Please replicate any data remaining in these fields to the contact model introduced in NetBox v3.1 prior to upgrading.
|
||||
* The `content_type` field on the CustomLink and ExportTemplate models have been renamed to `content_types` and now supports the assignment of multiple content types.
|
||||
* The `cf` property on an object with custom fields now returns deserialized values. For example, a custom field referencing an object will return the object instance rather than its numeric ID. To access the raw serialized values, use `custom_field_data` instead.
|
||||
* Device and virtual machine names are no longer case-sensitive. Attempting to create e.g. "device1" and "DEVICE1" within the same site will raise a validation error.
|
||||
* The `asn`, `noc_contact`, `admin_contact`, and `portal_url` fields have been removed from the provider model. Please replicate any data remaining in these fields to the ASN and contact models introduced in NetBox v3.1 prior to upgrading.
|
||||
* The `content_type` fields on the CustomLink and ExportTemplate models have been renamed to `content_types` and now support the assignment of multiple content types per object.
|
||||
* Within the Python API, the `cf` property on an object with custom fields now returns deserialized values. For example, a custom field referencing an object will return the object instance rather than its numeric ID. To access the raw serialized values, reference the object's `custom_field_data` attribute instead.
|
||||
* The `NetBoxModelCSVForm` class has been renamed to `NetBoxModelImportForm`. Backward compatability with the previous name has been retained for this release, but will be dropped in NetBox v3.5.
|
||||
|
||||
### New Features
|
||||
|
||||
#### New Global Search ([#10560](https://github.com/netbox-community/netbox/issues/10560))
|
||||
|
||||
NetBox's global search functionality has been completely overhauled and replaced by a new cache-based lookup. This new implementation provides a much speedier, more intelligent search capability. Matches are returned in order of precedence regardless of object type, and matched field values are highlighted in the results. Additionally, custom field values are now included in global search results (when enabled). Plugins can also register their own models with the new global search engine.
|
||||
NetBox's global search functionality has been completely overhauled and replaced by a new cache-based lookup. This new implementation provides a much faster, more intelligent search capability. Results are returned in order of precedence regardless of object type, and matching field values are highlighted in the results. Additionally, custom field values are now included in global search results (where enabled). Plugins can also register their own models with the new global search engine.
|
||||
|
||||
#### Virtual Device Contexts ([#7854](https://github.com/netbox-community/netbox/issues/7854))
|
||||
|
||||
@@ -24,19 +25,31 @@ A new model representing virtual device contexts (VDCs) has been added. VDCs are
|
||||
|
||||
#### Saved Filters ([#9623](https://github.com/netbox-community/netbox/issues/9623))
|
||||
|
||||
Object lists can be filtered by a variety of different fields and characteristics. Applied filters can now be saved for reuse as a convenience. Saved filters can be kept private, or shared among NetBox users.
|
||||
Object lists can be filtered by a variety of different fields and characteristics. Applied filters can now be saved for reuse. For example, the query string
|
||||
|
||||
### JSON/YAML Bulk Imports ([#4347](https://github.com/netbox-community/netbox/issues/4347))
|
||||
```
|
||||
?status=active®ion_id=12&tenant=acme
|
||||
```
|
||||
|
||||
NetBox's bulk import feature, which was previously limited to CSV-formatted data for most objects, has been extended to support the import of objects from JSON and/or YAML data as well.
|
||||
can be saved and applied to future queries as
|
||||
|
||||
#### CSV-Based Bulk Updates ([#7961](https://github.com/netbox-community/netbox/issues/7961))
|
||||
```
|
||||
?filter=my-custom-filter
|
||||
```
|
||||
|
||||
Saved filters can be kept private, or shared among NetBox users. They can be applied to both UI and REST API searches.
|
||||
|
||||
#### JSON/YAML Bulk Imports ([#4347](https://github.com/netbox-community/netbox/issues/4347))
|
||||
|
||||
NetBox's bulk import feature, which was previously limited to CSV-formatted data for most types of objects, has been extended to accept data formatted in JSON or YAML as well. This enables users to directly import objects from a variety of sources without needing to first convert data to CSV. NetBox will attempt to automatically determine the format of import data if not specified by the user.
|
||||
|
||||
#### Update Existing Objects via Bulk Import ([#7961](https://github.com/netbox-community/netbox/issues/7961))
|
||||
|
||||
NetBox's CSV-based bulk import functionality has been extended to support also modifying existing objects. When an `id` column is present in the import form, it will be used to infer the object to be modified, rather than a new object being created. All fields (columns) are optional when modifying existing objects.
|
||||
|
||||
#### Scheduled Reports & Scripts ([#8366](https://github.com/netbox-community/netbox/issues/8366))
|
||||
|
||||
Reports and custom scripts can now be scheduled for execution at a desired time.
|
||||
Reports and custom scripts can now be scheduled for execution at a desired future time. Background scheduling is handled entirely by the existing RQ workers; there is no need to configure additional tasks to support scheduled jobs. When creating a scheduled job, the user may optionally specify an interval at which the job will run repeatedly (e.g. every 24 hours).
|
||||
|
||||
#### API for Staged Changes ([#10851](https://github.com/netbox-community/netbox/issues/10851))
|
||||
|
||||
@@ -47,6 +60,7 @@ This release introduces a new programmatic API that enables plugins and custom s
|
||||
|
||||
### Enhancements
|
||||
|
||||
* [#815](https://github.com/netbox-community/netbox/issues/815) - Enable specifying terminations when bulk importing circuits
|
||||
* [#6003](https://github.com/netbox-community/netbox/issues/6003) - Enable the inclusion of custom field values in global search
|
||||
* [#7376](https://github.com/netbox-community/netbox/issues/7376) - Enable the assignment of tags during CSV import
|
||||
* [#8245](https://github.com/netbox-community/netbox/issues/8245) - Enable GraphQL filtering of related objects
|
||||
@@ -59,23 +73,40 @@ This release introduces a new programmatic API that enables plugins and custom s
|
||||
* [#9817](https://github.com/netbox-community/netbox/issues/9817) - Add `assigned_object` field to GraphQL type for IP addresses and L2VPN terminations
|
||||
* [#9832](https://github.com/netbox-community/netbox/issues/9832) - Add `mounting_depth` field to rack model
|
||||
* [#9892](https://github.com/netbox-community/netbox/issues/9892) - Add optional `name` field for FHRP groups
|
||||
* [#10052](https://github.com/netbox-community/netbox/issues/10052) - The `cf` attribute now returns deserialized custom field data
|
||||
* [#10348](https://github.com/netbox-community/netbox/issues/10348) - Add decimal custom field type
|
||||
* [#10371](https://github.com/netbox-community/netbox/issues/10371) - Add `status` field for modules
|
||||
* [#10545](https://github.com/netbox-community/netbox/issues/10545) - Standardize the use of `description` and `comments` fields on all primary models
|
||||
* [#10556](https://github.com/netbox-community/netbox/issues/10556) - Include a `display` field in all GraphQL object types
|
||||
* [#10595](https://github.com/netbox-community/netbox/issues/10595) - Add GraphQL relationships for additional generic foreign key fields
|
||||
* [#10675](https://github.com/netbox-community/netbox/issues/10675) - Add `max_weight` field to track maximum load capacity for racks
|
||||
* [#10698](https://github.com/netbox-community/netbox/issues/10698) - Omit app label from content type in table columns
|
||||
* [#10710](https://github.com/netbox-community/netbox/issues/10710) - Add `status` field to WirelessLAN
|
||||
* [#10761](https://github.com/netbox-community/netbox/issues/10761) - Enable associating an export template with multiple object types
|
||||
* [#10781](https://github.com/netbox-community/netbox/issues/10781) - Add support for Python v3.11
|
||||
* [#10945](https://github.com/netbox-community/netbox/issues/10945) - Enable recurring execution of scheduled reports & scripts
|
||||
* [#11022](https://github.com/netbox-community/netbox/issues/11022) - Introduce `QUEUE_MAPPINGS` configuration parameter to allow customization of background task prioritization
|
||||
|
||||
### Bug Fixes (from v3.4-beta1)
|
||||
|
||||
* [#10946](https://github.com/netbox-community/netbox/issues/10946) - Fix AttributeError exception when viewing a device with a primary IP and no platform assigned
|
||||
* [#10948](https://github.com/netbox-community/netbox/issues/10948) - Linkify primary IPs for VDCs
|
||||
* [#10950](https://github.com/netbox-community/netbox/issues/10950) - Fix validation of VDC primary IPs
|
||||
* [#10957](https://github.com/netbox-community/netbox/issues/10957) - Add missing VDCs column to interface tables
|
||||
* [#10973](https://github.com/netbox-community/netbox/issues/10973) - Fix device links in VDC table
|
||||
* [#10980](https://github.com/netbox-community/netbox/issues/10980) - Fix view tabs for plugin objects
|
||||
* [#10982](https://github.com/netbox-community/netbox/issues/10982) - Catch `NoReverseMatch` exception when rendering tabs with no registered URL
|
||||
* [#10984](https://github.com/netbox-community/netbox/issues/10984) - Fix navigation menu expansion for plugin menus comprising multiple words
|
||||
* [#11000](https://github.com/netbox-community/netbox/issues/11000) - Improve validation of YAML-formatted import data
|
||||
* [#11046](https://github.com/netbox-community/netbox/issues/11046) - Fix exception when caching very large field values for search
|
||||
* [#11154](https://github.com/netbox-community/netbox/issues/11154) - Index VM interface MAC address and MTU for global search
|
||||
* [#11171](https://github.com/netbox-community/netbox/issues/11171) - Fix querying of related objects under GraphQL API
|
||||
|
||||
### Plugins API
|
||||
|
||||
* [#4751](https://github.com/netbox-community/netbox/issues/4751) - Add `plugin_list_buttons` template tag to embed buttons on object lists
|
||||
* [#4751](https://github.com/netbox-community/netbox/issues/4751) - Enable embedding custom content on core list views via `list_buttons()` method
|
||||
* [#8927](https://github.com/netbox-community/netbox/issues/8927) - Enable inclusion of plugin models in global search via `SearchIndex`
|
||||
* [#9071](https://github.com/netbox-community/netbox/issues/9071) - Enable plugins to register top-level navigation menus
|
||||
* [#9071](https://github.com/netbox-community/netbox/issues/9071) - Enable plugins to register top-level navigation menus using PluginMenu
|
||||
* [#9072](https://github.com/netbox-community/netbox/issues/9072) - Enable registration of tabbed plugin views for core NetBox models
|
||||
* [#9880](https://github.com/netbox-community/netbox/issues/9880) - Enable plugins to install and register other Django apps
|
||||
* [#9880](https://github.com/netbox-community/netbox/issues/9880) - Enable plugins to install and register other Django apps via `django_apps` attribute
|
||||
* [#9887](https://github.com/netbox-community/netbox/issues/9887) - Inspect `docs_url` property to determine link to model documentation
|
||||
* [#10314](https://github.com/netbox-community/netbox/issues/10314) - Move `clone()` method from NetBoxModel to CloningMixin
|
||||
* [#10543](https://github.com/netbox-community/netbox/issues/10543) - Introduce `get_plugin_config()` utility function
|
||||
@@ -85,11 +116,13 @@ This release introduces a new programmatic API that enables plugins and custom s
|
||||
|
||||
* [#9045](https://github.com/netbox-community/netbox/issues/9045) - Remove legacy ASN field from provider model
|
||||
* [#9046](https://github.com/netbox-community/netbox/issues/9046) - Remove legacy contact fields from provider model
|
||||
* [#10052](https://github.com/netbox-community/netbox/issues/10052) - The `cf` attribute on objects now returns deserialized custom field data
|
||||
* [#10358](https://github.com/netbox-community/netbox/issues/10358) - Raise minimum required PostgreSQL version from 10 to 11
|
||||
* [#10694](https://github.com/netbox-community/netbox/issues/10694) - Emit the `post_save` signal when creating device components in bulk
|
||||
* [#10697](https://github.com/netbox-community/netbox/issues/10697) - Move application registry into core app
|
||||
* [#10699](https://github.com/netbox-community/netbox/issues/10699) - Remove custom `import_object()` function
|
||||
* [#10816](https://github.com/netbox-community/netbox/issues/10816) - Pass the current request when instantiating a FilterSet within UI views
|
||||
* [#10699](https://github.com/netbox-community/netbox/issues/10699) - Remove unused custom `import_object()` function
|
||||
* [#10781](https://github.com/netbox-community/netbox/issues/10781) - Add support for Python v3.11
|
||||
* [#10816](https://github.com/netbox-community/netbox/issues/10816) - Pass the current request as context when instantiating a FilterSet within UI views
|
||||
* [#10820](https://github.com/netbox-community/netbox/issues/10820) - Switch timezone library from pytz to zoneinfo
|
||||
* [#10821](https://github.com/netbox-community/netbox/issues/10821) - Enable data localization
|
||||
|
||||
@@ -104,41 +137,39 @@ This release introduces a new programmatic API that enables plugins and custom s
|
||||
* dcim.Device
|
||||
* Added a `description` field
|
||||
* dcim.DeviceType
|
||||
* Added a `description` field
|
||||
* Added optional `weight` and `weight_unit` fields
|
||||
* Added `description`, `weight`, and `weight_unit` fields
|
||||
* dcim.Module
|
||||
* Added a `description` field
|
||||
* dcim.Interface
|
||||
* Added the `vdcs` field
|
||||
* dcim.Module
|
||||
* Added a required `status` field
|
||||
* dcim.ModuleType
|
||||
* Added a `description` field
|
||||
* Added optional `weight` and `weight_unit` fields
|
||||
* Added `description`, `weight`, and `weight_unit` fields
|
||||
* dcim.PowerFeed
|
||||
* Added a `description` field
|
||||
* dcim.PowerPanel
|
||||
* Added `description` and `comments` fields
|
||||
* dcim.Rack
|
||||
* Added a `description` field
|
||||
* Added optional `weight` and `weight_unit` fields
|
||||
* Added `description`, `mounting_depth`, `weight`, `max_weight`, and `weight_unit` fields
|
||||
* dcim.RackReservation
|
||||
* Added a `comments` field
|
||||
* dcim.VirtualChassis
|
||||
* Added `description` and `comments` fields
|
||||
* extras.CustomField
|
||||
* Added the `search_weight` field
|
||||
* Added a `search_weight` field
|
||||
* extras.CustomLink
|
||||
* Renamed `content_type` field to `content_types`
|
||||
* extras.ExportTemplate
|
||||
* Renamed `content_type` field to `content_types`
|
||||
* extras.JobResult
|
||||
* Added `scheduled` and `started` datetime fields
|
||||
* Added `interval`, `scheduled`, and `started` fields
|
||||
* ipam.Aggregate
|
||||
* Added a `comments` field
|
||||
* ipam.ASN
|
||||
* Added a `comments` field
|
||||
* ipam.FHRPGroup
|
||||
* Added a `comments` field
|
||||
* Added optional `name` field
|
||||
* Added `name` and `comments` fields
|
||||
* ipam.IPAddress
|
||||
* Added a `comments` field
|
||||
* ipam.IPRange
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
from django import forms
|
||||
|
||||
from circuits.choices import CircuitStatusChoices
|
||||
from circuits.models import *
|
||||
from dcim.models import Site
|
||||
from django.utils.translation import gettext as _
|
||||
from netbox.forms import NetBoxModelImportForm
|
||||
from tenancy.models import Tenant
|
||||
from utilities.forms import CSVChoiceField, CSVModelChoiceField, SlugField
|
||||
from utilities.forms import BootstrapMixin, CSVChoiceField, CSVModelChoiceField, SlugField
|
||||
|
||||
__all__ = (
|
||||
'CircuitImportForm',
|
||||
'CircuitTerminationImportForm',
|
||||
'CircuitTypeImportForm',
|
||||
'ProviderImportForm',
|
||||
'ProviderNetworkImportForm',
|
||||
@@ -76,3 +80,23 @@ class CircuitImportForm(NetBoxModelImportForm):
|
||||
'cid', 'provider', 'type', 'status', 'tenant', 'install_date', 'termination_date', 'commit_rate',
|
||||
'description', 'comments', 'tags'
|
||||
]
|
||||
|
||||
|
||||
class CircuitTerminationImportForm(BootstrapMixin, forms.ModelForm):
|
||||
site = CSVModelChoiceField(
|
||||
queryset=Site.objects.all(),
|
||||
to_field_name='name',
|
||||
required=False
|
||||
)
|
||||
provider_network = CSVModelChoiceField(
|
||||
queryset=ProviderNetwork.objects.all(),
|
||||
to_field_name='name',
|
||||
required=False
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = CircuitTermination
|
||||
fields = [
|
||||
'circuit', 'term_side', 'site', 'provider_network', 'port_speed', 'upstream_speed', 'xconnect_id',
|
||||
'pp_info', 'description',
|
||||
]
|
||||
|
||||
@@ -145,16 +145,28 @@ class CircuitTerminationForm(NetBoxModelForm):
|
||||
},
|
||||
required=False
|
||||
)
|
||||
provider_network_provider = DynamicModelChoiceField(
|
||||
queryset=Provider.objects.all(),
|
||||
required=False,
|
||||
label='Provider',
|
||||
initial_params={
|
||||
'networks': 'provider_network'
|
||||
}
|
||||
)
|
||||
provider_network = DynamicModelChoiceField(
|
||||
queryset=ProviderNetwork.objects.all(),
|
||||
query_params={
|
||||
'provider_id': '$provider_network_provider',
|
||||
},
|
||||
required=False
|
||||
)
|
||||
|
||||
class Meta:
|
||||
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',
|
||||
'provider', 'circuit', 'term_side', 'region', 'site_group', 'site', 'provider_network_provider',
|
||||
'provider_network', 'mark_connected', 'port_speed', 'upstream_speed', 'xconnect_id', 'pp_info',
|
||||
'description', 'tags',
|
||||
]
|
||||
help_texts = {
|
||||
'port_speed': _("Physical circuit speed"),
|
||||
|
||||
@@ -104,6 +104,10 @@ class Circuit(PrimaryModel):
|
||||
clone_fields = (
|
||||
'provider', 'type', 'status', 'tenant', 'install_date', 'termination_date', 'commit_rate', 'description',
|
||||
)
|
||||
prerequisite_models = (
|
||||
'circuits.CircuitType',
|
||||
'circuits.Provider',
|
||||
)
|
||||
|
||||
class Meta:
|
||||
ordering = ['provider', 'cid']
|
||||
@@ -117,10 +121,6 @@ class Circuit(PrimaryModel):
|
||||
def __str__(self):
|
||||
return self.cid
|
||||
|
||||
@classmethod
|
||||
def get_prerequisite_models(cls):
|
||||
return [apps.get_model('circuits.Provider'), CircuitType]
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse('circuits:circuit', args=[self.pk])
|
||||
|
||||
|
||||
@@ -108,6 +108,13 @@ class CircuitTypeTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
|
||||
class CircuitTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
||||
model = Circuit
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
|
||||
self.add_permissions(
|
||||
'circuits.add_circuittermination',
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
|
||||
|
||||
@@ -233,6 +233,16 @@ class CircuitBulkImportView(generic.BulkImportView):
|
||||
queryset = Circuit.objects.all()
|
||||
model_form = forms.CircuitImportForm
|
||||
table = tables.CircuitTable
|
||||
additional_permissions = [
|
||||
'circuits.add_circuittermination',
|
||||
]
|
||||
related_object_forms = {
|
||||
'terminations': forms.CircuitTerminationImportForm,
|
||||
}
|
||||
|
||||
def prep_related_object_data(self, parent, data):
|
||||
data.update({'circuit': parent})
|
||||
return data
|
||||
|
||||
|
||||
class CircuitBulkEditView(generic.BulkEditView):
|
||||
|
||||
@@ -210,9 +210,9 @@ class RackSerializer(NetBoxModelSerializer):
|
||||
model = Rack
|
||||
fields = [
|
||||
'id', 'url', 'display', 'name', 'facility_id', 'site', 'location', 'tenant', 'status', 'role', 'serial',
|
||||
'asset_tag', 'type', 'width', 'u_height', 'weight', 'weight_unit', 'desc_units', 'outer_width',
|
||||
'outer_depth', 'outer_unit', 'mounting_depth', 'description', 'comments', 'tags', 'custom_fields',
|
||||
'created', 'last_updated', 'device_count', 'powerfeed_count',
|
||||
'asset_tag', 'type', 'width', 'u_height', 'weight', 'max_weight', 'weight_unit', 'desc_units',
|
||||
'outer_width', 'outer_depth', 'outer_unit', 'mounting_depth', 'description', 'comments', 'tags',
|
||||
'custom_fields', 'created', 'last_updated', 'device_count', 'powerfeed_count',
|
||||
]
|
||||
|
||||
|
||||
@@ -680,11 +680,14 @@ class VirtualDeviceContextSerializer(NetBoxModelSerializer):
|
||||
primary_ip4 = NestedIPAddressSerializer(required=False, allow_null=True)
|
||||
primary_ip6 = NestedIPAddressSerializer(required=False, allow_null=True)
|
||||
|
||||
# Related object counts
|
||||
interface_count = serializers.IntegerField(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = VirtualDeviceContext
|
||||
fields = [
|
||||
'id', 'url', 'display', 'name', 'device', 'identifier', 'tenant', 'primary_ip', 'primary_ip4',
|
||||
'primary_ip6', 'status', 'comments', 'tags', 'custom_fields', 'created', 'last_updated',
|
||||
'primary_ip6', 'status', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', 'interface_count',
|
||||
]
|
||||
|
||||
|
||||
@@ -693,12 +696,13 @@ class ModuleSerializer(NetBoxModelSerializer):
|
||||
device = NestedDeviceSerializer()
|
||||
module_bay = NestedModuleBaySerializer()
|
||||
module_type = NestedModuleTypeSerializer()
|
||||
status = ChoiceField(choices=ModuleStatusChoices, required=False)
|
||||
|
||||
class Meta:
|
||||
model = Module
|
||||
fields = [
|
||||
'id', 'url', 'display', 'device', 'module_bay', 'module_type', 'serial', 'asset_tag', 'description',
|
||||
'comments', 'tags', 'custom_fields', 'created', 'last_updated',
|
||||
'id', 'url', 'display', 'device', 'module_bay', 'module_type', 'status', 'serial', 'asset_tag',
|
||||
'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated',
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -541,6 +541,8 @@ class DeviceViewSet(ConfigContextQuerySetMixin, NetBoxModelViewSet):
|
||||
class VirtualDeviceContextViewSet(NetBoxModelViewSet):
|
||||
queryset = VirtualDeviceContext.objects.prefetch_related(
|
||||
'device__device_type', 'device', 'tenant', 'tags',
|
||||
).annotate(
|
||||
interface_count=count_related(Interface, 'vdcs'),
|
||||
)
|
||||
serializer_class = serializers.VirtualDeviceContextSerializer
|
||||
filterset_class = filtersets.VirtualDeviceContextFilterSet
|
||||
|
||||
@@ -55,14 +55,18 @@ class RackTypeChoices(ChoiceSet):
|
||||
TYPE_4POST = '4-post-frame'
|
||||
TYPE_CABINET = '4-post-cabinet'
|
||||
TYPE_WALLFRAME = 'wall-frame'
|
||||
TYPE_WALLFRAME_VERTICAL = 'wall-frame-vertical'
|
||||
TYPE_WALLCABINET = 'wall-cabinet'
|
||||
TYPE_WALLCABINET_VERTICAL = 'wall-cabinet-vertical'
|
||||
|
||||
CHOICES = (
|
||||
(TYPE_2POST, '2-post frame'),
|
||||
(TYPE_4POST, '4-post frame'),
|
||||
(TYPE_CABINET, '4-post cabinet'),
|
||||
(TYPE_WALLFRAME, 'Wall-mounted frame'),
|
||||
(TYPE_WALLFRAME_VERTICAL, 'Wall-mounted frame (vertical)'),
|
||||
(TYPE_WALLCABINET, 'Wall-mounted cabinet'),
|
||||
(TYPE_WALLCABINET_VERTICAL, 'Wall-mounted cabinet (vertical)'),
|
||||
)
|
||||
|
||||
|
||||
@@ -194,6 +198,30 @@ class DeviceAirflowChoices(ChoiceSet):
|
||||
)
|
||||
|
||||
|
||||
#
|
||||
# Modules
|
||||
#
|
||||
|
||||
class ModuleStatusChoices(ChoiceSet):
|
||||
key = 'Module.status'
|
||||
|
||||
STATUS_OFFLINE = 'offline'
|
||||
STATUS_ACTIVE = 'active'
|
||||
STATUS_PLANNED = 'planned'
|
||||
STATUS_STAGED = 'staged'
|
||||
STATUS_FAILED = 'failed'
|
||||
STATUS_DECOMMISSIONING = 'decommissioning'
|
||||
|
||||
CHOICES = [
|
||||
(STATUS_OFFLINE, 'Offline', 'gray'),
|
||||
(STATUS_ACTIVE, 'Active', 'green'),
|
||||
(STATUS_PLANNED, 'Planned', 'cyan'),
|
||||
(STATUS_STAGED, 'Staged', 'blue'),
|
||||
(STATUS_FAILED, 'Failed', 'red'),
|
||||
(STATUS_DECOMMISSIONING, 'Decommissioning', 'yellow'),
|
||||
]
|
||||
|
||||
|
||||
#
|
||||
# ConsolePorts
|
||||
#
|
||||
|
||||
@@ -55,6 +55,8 @@ class MACAddressField(models.Field):
|
||||
def to_python(self, value):
|
||||
if value is None:
|
||||
return value
|
||||
if type(value) is str:
|
||||
value = value.replace(' ', '')
|
||||
try:
|
||||
return EUI(value, version=48, dialect=mac_unix_expanded_uppercase)
|
||||
except AddrFormatError:
|
||||
|
||||
@@ -322,7 +322,7 @@ class RackFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilterSe
|
||||
model = Rack
|
||||
fields = [
|
||||
'id', 'name', 'facility_id', 'asset_tag', 'u_height', 'desc_units', 'outer_width', 'outer_depth',
|
||||
'outer_unit', 'mounting_depth', 'weight', 'weight_unit'
|
||||
'outer_unit', 'mounting_depth', 'weight', 'max_weight', 'weight_unit'
|
||||
]
|
||||
|
||||
def search(self, queryset, name, value):
|
||||
@@ -1082,13 +1082,17 @@ class ModuleFilterSet(NetBoxModelFilterSet):
|
||||
queryset=Device.objects.all(),
|
||||
label=_('Device (ID)'),
|
||||
)
|
||||
status = django_filters.MultipleChoiceFilter(
|
||||
choices=ModuleStatusChoices,
|
||||
null_value=None
|
||||
)
|
||||
serial = MultiValueCharFilter(
|
||||
lookup_expr='iexact'
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Module
|
||||
fields = ['id', 'asset_tag']
|
||||
fields = ['id', 'status', 'asset_tag']
|
||||
|
||||
def search(self, queryset, name, value):
|
||||
if not value.strip():
|
||||
|
||||
@@ -294,6 +294,10 @@ class RackBulkEditForm(NetBoxModelBulkEditForm):
|
||||
min_value=0,
|
||||
required=False
|
||||
)
|
||||
max_weight = forms.IntegerField(
|
||||
min_value=0,
|
||||
required=False
|
||||
)
|
||||
weight_unit = forms.ChoiceField(
|
||||
choices=add_blank_choice(WeightUnitChoices),
|
||||
required=False,
|
||||
@@ -316,11 +320,11 @@ class RackBulkEditForm(NetBoxModelBulkEditForm):
|
||||
('Hardware', (
|
||||
'type', 'width', 'u_height', 'desc_units', 'outer_width', 'outer_depth', 'outer_unit', 'mounting_depth',
|
||||
)),
|
||||
('Weight', ('weight', 'weight_unit')),
|
||||
('Weight', ('weight', 'max_weight', 'weight_unit')),
|
||||
)
|
||||
nullable_fields = (
|
||||
'location', 'tenant', 'role', 'serial', 'asset_tag', 'outer_width', 'outer_depth', 'outer_unit', 'weight',
|
||||
'weight_unit', 'description', 'comments',
|
||||
'max_weight', 'weight_unit', 'description', 'comments',
|
||||
)
|
||||
|
||||
|
||||
@@ -574,6 +578,12 @@ class ModuleBulkEditForm(NetBoxModelBulkEditForm):
|
||||
'manufacturer_id': '$manufacturer'
|
||||
}
|
||||
)
|
||||
status = forms.ChoiceField(
|
||||
choices=add_blank_choice(ModuleStatusChoices),
|
||||
required=False,
|
||||
initial='',
|
||||
widget=StaticSelect()
|
||||
)
|
||||
serial = forms.CharField(
|
||||
max_length=50,
|
||||
required=False,
|
||||
@@ -590,7 +600,7 @@ class ModuleBulkEditForm(NetBoxModelBulkEditForm):
|
||||
|
||||
model = Module
|
||||
fieldsets = (
|
||||
(None, ('manufacturer', 'module_type', 'serial', 'description')),
|
||||
(None, ('manufacturer', 'module_type', 'status', 'serial', 'description')),
|
||||
)
|
||||
nullable_fields = ('serial', 'description', 'comments')
|
||||
|
||||
@@ -1321,7 +1331,7 @@ class FrontPortBulkEditForm(
|
||||
fieldsets = (
|
||||
(None, ('module', 'type', 'label', 'color', 'description', 'mark_connected')),
|
||||
)
|
||||
nullable_fields = ('module', 'label', 'description')
|
||||
nullable_fields = ('module', 'label', 'description', 'color')
|
||||
|
||||
|
||||
class RearPortBulkEditForm(
|
||||
@@ -1332,7 +1342,7 @@ class RearPortBulkEditForm(
|
||||
fieldsets = (
|
||||
(None, ('module', 'type', 'label', 'color', 'description', 'mark_connected')),
|
||||
)
|
||||
nullable_fields = ('module', 'label', 'description')
|
||||
nullable_fields = ('module', 'label', 'description', 'color')
|
||||
|
||||
|
||||
class ModuleBayBulkEditForm(
|
||||
|
||||
@@ -14,6 +14,7 @@ from tenancy.models import Tenant
|
||||
from utilities.forms import CSVChoiceField, CSVContentTypeField, CSVModelChoiceField, CSVTypedChoiceField, SlugField
|
||||
from virtualization.models import Cluster
|
||||
from wireless.choices import WirelessRoleChoices
|
||||
from .common import ModuleCommonForm
|
||||
|
||||
__all__ = (
|
||||
'CableImportForm',
|
||||
@@ -195,13 +196,18 @@ class RackImportForm(NetBoxModelImportForm):
|
||||
required=False,
|
||||
help_text=_('Unit for outer dimensions')
|
||||
)
|
||||
weight_unit = CSVChoiceField(
|
||||
choices=WeightUnitChoices,
|
||||
required=False,
|
||||
help_text=_('Unit for rack weights')
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Rack
|
||||
fields = (
|
||||
'site', 'location', 'name', 'facility_id', 'tenant', 'status', 'role', 'type', 'serial', 'asset_tag',
|
||||
'width', 'u_height', 'desc_units', 'outer_width', 'outer_depth', 'outer_unit', 'mounting_depth',
|
||||
'description', 'comments', 'tags',
|
||||
'width', 'u_height', 'desc_units', 'outer_width', 'outer_depth', 'outer_unit', 'mounting_depth', 'weight',
|
||||
'max_weight', 'weight_unit', 'description', 'comments', 'tags',
|
||||
)
|
||||
|
||||
def __init__(self, data=None, *args, **kwargs):
|
||||
@@ -437,24 +443,40 @@ class DeviceImportForm(BaseDeviceImportForm):
|
||||
self.fields['rack'].queryset = self.fields['rack'].queryset.filter(**params)
|
||||
|
||||
|
||||
class ModuleImportForm(NetBoxModelImportForm):
|
||||
class ModuleImportForm(ModuleCommonForm, NetBoxModelImportForm):
|
||||
device = CSVModelChoiceField(
|
||||
queryset=Device.objects.all(),
|
||||
to_field_name='name'
|
||||
to_field_name='name',
|
||||
help_text=_('The device in which this module is installed')
|
||||
)
|
||||
module_bay = CSVModelChoiceField(
|
||||
queryset=ModuleBay.objects.all(),
|
||||
to_field_name='name'
|
||||
to_field_name='name',
|
||||
help_text=_('The module bay in which this module is installed')
|
||||
)
|
||||
module_type = CSVModelChoiceField(
|
||||
queryset=ModuleType.objects.all(),
|
||||
to_field_name='model'
|
||||
to_field_name='model',
|
||||
help_text=_('The type of module')
|
||||
)
|
||||
status = CSVChoiceField(
|
||||
choices=ModuleStatusChoices,
|
||||
help_text=_('Operational status')
|
||||
)
|
||||
replicate_components = forms.BooleanField(
|
||||
required=False,
|
||||
help_text=_('Automatically populate components associated with this module type (enabled by default)')
|
||||
)
|
||||
adopt_components = forms.BooleanField(
|
||||
required=False,
|
||||
help_text=_('Adopt already existing components')
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Module
|
||||
fields = (
|
||||
'device', 'module_bay', 'module_type', 'serial', 'asset_tag', 'description', 'comments', 'tags',
|
||||
'device', 'module_bay', 'module_type', 'serial', 'asset_tag', 'status', 'description', 'comments',
|
||||
'replicate_components', 'adopt_components', 'tags',
|
||||
)
|
||||
|
||||
def __init__(self, data=None, *args, **kwargs):
|
||||
@@ -465,6 +487,13 @@ class ModuleImportForm(NetBoxModelImportForm):
|
||||
params = {f"device__{self.fields['device'].to_field_name}": data.get('device')}
|
||||
self.fields['module_bay'].queryset = self.fields['module_bay'].queryset.filter(**params)
|
||||
|
||||
def clean_replicate_components(self):
|
||||
# Make sure replicate_components is True when it's not included in the uploaded data
|
||||
if 'replicate_components' not in self.data:
|
||||
return True
|
||||
else:
|
||||
return self.cleaned_data['replicate_components']
|
||||
|
||||
|
||||
class ChildDeviceImportForm(BaseDeviceImportForm):
|
||||
parent = CSVModelChoiceField(
|
||||
|
||||
@@ -6,6 +6,7 @@ from dcim.constants import *
|
||||
|
||||
__all__ = (
|
||||
'InterfaceCommonForm',
|
||||
'ModuleCommonForm'
|
||||
)
|
||||
|
||||
|
||||
@@ -48,3 +49,61 @@ class InterfaceCommonForm(forms.Form):
|
||||
'tagged_vlans': f"The tagged VLANs ({', '.join(invalid_vlans)}) must belong to the same site as "
|
||||
f"the interface's parent device/VM, or they must be global"
|
||||
})
|
||||
|
||||
|
||||
class ModuleCommonForm(forms.Form):
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
|
||||
replicate_components = self.cleaned_data.get("replicate_components")
|
||||
adopt_components = self.cleaned_data.get("adopt_components")
|
||||
device = self.cleaned_data.get('device')
|
||||
module_type = self.cleaned_data.get('module_type')
|
||||
module_bay = self.cleaned_data.get('module_bay')
|
||||
|
||||
if adopt_components:
|
||||
self.instance._adopt_components = True
|
||||
|
||||
# Bail out if we are not installing a new module or if we are not replicating components
|
||||
if self.instance.pk or not replicate_components:
|
||||
self.instance._disable_replication = True
|
||||
return
|
||||
|
||||
for templates, component_attribute in [
|
||||
("consoleporttemplates", "consoleports"),
|
||||
("consoleserverporttemplates", "consoleserverports"),
|
||||
("interfacetemplates", "interfaces"),
|
||||
("powerporttemplates", "powerports"),
|
||||
("poweroutlettemplates", "poweroutlets"),
|
||||
("rearporttemplates", "rearports"),
|
||||
("frontporttemplates", "frontports")
|
||||
]:
|
||||
# Prefetch installed components
|
||||
installed_components = {
|
||||
component.name: component for component in getattr(device, component_attribute).all()
|
||||
}
|
||||
|
||||
# Get the templates for the module type.
|
||||
for template in getattr(module_type, templates).all():
|
||||
# Installing modules with placeholders require that the bay has a position value
|
||||
if MODULE_TOKEN in template.name and not module_bay.position:
|
||||
raise forms.ValidationError(
|
||||
"Cannot install module with placeholder values in a module bay with no position defined"
|
||||
)
|
||||
|
||||
resolved_name = template.name.replace(MODULE_TOKEN, module_bay.position)
|
||||
existing_item = installed_components.get(resolved_name)
|
||||
|
||||
# It is not possible to adopt components already belonging to a module
|
||||
if adopt_components and existing_item and existing_item.module:
|
||||
raise forms.ValidationError(
|
||||
f"Cannot adopt {template.component_model.__name__} '{resolved_name}' as it already belongs "
|
||||
f"to a module"
|
||||
)
|
||||
|
||||
# If we are not adopting components we error if the component exists
|
||||
if not adopt_components and resolved_name in installed_components:
|
||||
raise forms.ValidationError(
|
||||
f"{template.component_model.__name__} - {resolved_name} already exists"
|
||||
)
|
||||
|
||||
@@ -229,7 +229,7 @@ class RackFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilte
|
||||
('Hardware', ('type', 'width', 'serial', 'asset_tag')),
|
||||
('Tenant', ('tenant_group_id', 'tenant_id')),
|
||||
('Contacts', ('contact', 'contact_role', 'contact_group')),
|
||||
('Weight', ('weight', 'weight_unit')),
|
||||
('Weight', ('weight', 'max_weight', 'weight_unit')),
|
||||
)
|
||||
region_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Region.objects.all(),
|
||||
@@ -284,7 +284,12 @@ class RackFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilte
|
||||
)
|
||||
tag = TagFilterField(model)
|
||||
weight = forms.DecimalField(
|
||||
required=False
|
||||
required=False,
|
||||
min_value=1
|
||||
)
|
||||
max_weight = forms.IntegerField(
|
||||
required=False,
|
||||
min_value=1
|
||||
)
|
||||
weight_unit = forms.ChoiceField(
|
||||
choices=add_blank_choice(WeightUnitChoices),
|
||||
@@ -763,7 +768,7 @@ class ModuleFilterForm(LocalConfigContextFilterForm, TenancyFilterForm, NetBoxMo
|
||||
model = Module
|
||||
fieldsets = (
|
||||
(None, ('q', 'filter_id', 'tag')),
|
||||
('Hardware', ('manufacturer_id', 'module_type_id', 'serial', 'asset_tag')),
|
||||
('Hardware', ('manufacturer_id', 'module_type_id', 'status', 'serial', 'asset_tag')),
|
||||
)
|
||||
manufacturer_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Manufacturer.objects.all(),
|
||||
@@ -780,6 +785,10 @@ class ModuleFilterForm(LocalConfigContextFilterForm, TenancyFilterForm, NetBoxMo
|
||||
label=_('Type'),
|
||||
fetch_trigger='open'
|
||||
)
|
||||
status = MultipleChoiceField(
|
||||
choices=ModuleStatusChoices,
|
||||
required=False
|
||||
)
|
||||
serial = forms.CharField(
|
||||
required=False
|
||||
)
|
||||
|
||||
@@ -17,7 +17,7 @@ from utilities.forms import (
|
||||
)
|
||||
from virtualization.models import Cluster, ClusterGroup
|
||||
from wireless.models import WirelessLAN, WirelessLANGroup
|
||||
from .common import InterfaceCommonForm
|
||||
from .common import InterfaceCommonForm, ModuleCommonForm
|
||||
|
||||
__all__ = (
|
||||
'CableForm',
|
||||
@@ -279,7 +279,7 @@ class RackForm(TenancyForm, NetBoxModelForm):
|
||||
fields = [
|
||||
'region', 'site_group', 'site', 'location', 'name', 'facility_id', 'tenant_group', 'tenant', 'status',
|
||||
'role', 'serial', 'asset_tag', 'type', 'width', 'u_height', 'desc_units', 'outer_width', 'outer_depth',
|
||||
'outer_unit', 'mounting_depth', 'weight', 'weight_unit', 'description', 'comments', 'tags',
|
||||
'outer_unit', 'mounting_depth', 'weight', 'max_weight', 'weight_unit', 'description', 'comments', 'tags',
|
||||
]
|
||||
help_texts = {
|
||||
'site': _("The site at which the rack exists"),
|
||||
@@ -662,7 +662,7 @@ class DeviceForm(TenancyForm, NetBoxModelForm):
|
||||
self.fields['position'].widget.choices = [(position, f'U{position}')]
|
||||
|
||||
|
||||
class ModuleForm(NetBoxModelForm):
|
||||
class ModuleForm(ModuleCommonForm, NetBoxModelForm):
|
||||
device = DynamicModelChoiceField(
|
||||
queryset=Device.objects.all(),
|
||||
initial_params={
|
||||
@@ -703,7 +703,7 @@ class ModuleForm(NetBoxModelForm):
|
||||
|
||||
fieldsets = (
|
||||
('Module', (
|
||||
'device', 'module_bay', 'manufacturer', 'module_type', 'description', 'tags',
|
||||
'device', 'module_bay', 'manufacturer', 'module_type', 'status', 'description', 'tags',
|
||||
)),
|
||||
('Hardware', (
|
||||
'serial', 'asset_tag', 'replicate_components', 'adopt_components',
|
||||
@@ -713,7 +713,7 @@ class ModuleForm(NetBoxModelForm):
|
||||
class Meta:
|
||||
model = Module
|
||||
fields = [
|
||||
'device', 'module_bay', 'manufacturer', 'module_type', 'serial', 'asset_tag', 'tags',
|
||||
'device', 'module_bay', 'manufacturer', 'module_type', 'status', 'serial', 'asset_tag', 'tags',
|
||||
'replicate_components', 'adopt_components', 'description', 'comments',
|
||||
]
|
||||
|
||||
@@ -727,68 +727,6 @@ class ModuleForm(NetBoxModelForm):
|
||||
self.fields['adopt_components'].initial = False
|
||||
self.fields['adopt_components'].disabled = True
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
|
||||
# If replicate_components is False, disable automatic component replication on the instance
|
||||
if self.instance.pk or not self.cleaned_data['replicate_components']:
|
||||
self.instance._disable_replication = True
|
||||
|
||||
if self.cleaned_data['adopt_components']:
|
||||
self.instance._adopt_components = True
|
||||
|
||||
return super().save(*args, **kwargs)
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
|
||||
replicate_components = self.cleaned_data.get("replicate_components")
|
||||
adopt_components = self.cleaned_data.get("adopt_components")
|
||||
device = self.cleaned_data['device']
|
||||
module_type = self.cleaned_data['module_type']
|
||||
module_bay = self.cleaned_data['module_bay']
|
||||
|
||||
# Bail out if we are not installing a new module or if we are not replicating components
|
||||
if self.instance.pk or not replicate_components:
|
||||
return
|
||||
|
||||
for templates, component_attribute in [
|
||||
("consoleporttemplates", "consoleports"),
|
||||
("consoleserverporttemplates", "consoleserverports"),
|
||||
("interfacetemplates", "interfaces"),
|
||||
("powerporttemplates", "powerports"),
|
||||
("poweroutlettemplates", "poweroutlets"),
|
||||
("rearporttemplates", "rearports"),
|
||||
("frontporttemplates", "frontports")
|
||||
]:
|
||||
# Prefetch installed components
|
||||
installed_components = {
|
||||
component.name: component for component in getattr(device, component_attribute).all()
|
||||
}
|
||||
|
||||
# Get the templates for the module type.
|
||||
for template in getattr(module_type, templates).all():
|
||||
# Installing modules with placeholders require that the bay has a position value
|
||||
if MODULE_TOKEN in template.name and not module_bay.position:
|
||||
raise forms.ValidationError(
|
||||
"Cannot install module with placeholder values in a module bay with no position defined"
|
||||
)
|
||||
|
||||
resolved_name = template.name.replace(MODULE_TOKEN, module_bay.position)
|
||||
existing_item = installed_components.get(resolved_name)
|
||||
|
||||
# It is not possible to adopt components already belonging to a module
|
||||
if adopt_components and existing_item and existing_item.module:
|
||||
raise forms.ValidationError(
|
||||
f"Cannot adopt {template.component_model.__name__} '{resolved_name}' as it already belongs "
|
||||
f"to a module"
|
||||
)
|
||||
|
||||
# If we are not adopting components we error if the component exists
|
||||
if not adopt_components and resolved_name in installed_components:
|
||||
raise forms.ValidationError(
|
||||
f"{template.component_model.__name__} - {resolved_name} already exists"
|
||||
)
|
||||
|
||||
|
||||
class CableForm(TenancyForm, NetBoxModelForm):
|
||||
comments = CommentField()
|
||||
@@ -1462,7 +1400,6 @@ class InterfaceForm(InterfaceCommonForm, ModularDeviceComponentForm):
|
||||
required=False,
|
||||
label=_('VRF')
|
||||
)
|
||||
|
||||
wwn = forms.CharField(
|
||||
empty_value=None,
|
||||
required=False,
|
||||
@@ -1470,9 +1407,9 @@ class InterfaceForm(InterfaceCommonForm, ModularDeviceComponentForm):
|
||||
)
|
||||
|
||||
fieldsets = (
|
||||
('Interface', ('device', 'module', 'vdcs', 'name', 'label', 'type', 'speed', 'duplex', 'description', 'tags')),
|
||||
('Interface', ('device', 'module', 'name', 'label', 'type', 'speed', 'duplex', 'description', 'tags')),
|
||||
('Addressing', ('vrf', 'mac_address', 'wwn')),
|
||||
('Operation', ('mtu', 'tx_power', 'enabled', 'mgmt_only', 'mark_connected')),
|
||||
('Operation', ('vdcs', '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')),
|
||||
@@ -1628,6 +1565,13 @@ class InventoryItemForm(DeviceComponentForm):
|
||||
('Hardware', ('manufacturer', 'part_id', 'serial', 'asset_tag')),
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# Specifically allow editing the device of IntentoryItems
|
||||
if self.instance.pk:
|
||||
self.fields['device'].disabled = False
|
||||
|
||||
class Meta:
|
||||
model = InventoryItem
|
||||
fields = [
|
||||
@@ -1705,6 +1649,24 @@ class VirtualDeviceContextForm(TenancyForm, NetBoxModelForm):
|
||||
'rack_id': '$rack',
|
||||
}
|
||||
)
|
||||
primary_ip4 = DynamicModelChoiceField(
|
||||
queryset=IPAddress.objects.all(),
|
||||
label='Primary IPv4',
|
||||
required=False,
|
||||
query_params={
|
||||
'device_id': '$device',
|
||||
'family': '4',
|
||||
}
|
||||
)
|
||||
primary_ip6 = DynamicModelChoiceField(
|
||||
queryset=IPAddress.objects.all(),
|
||||
label='Primary IPv6',
|
||||
required=False,
|
||||
query_params={
|
||||
'device_id': '$device',
|
||||
'family': '6',
|
||||
}
|
||||
)
|
||||
|
||||
fieldsets = (
|
||||
('Assigned Device', ('region', 'site_group', 'site', 'location', 'rack', 'device')),
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
# Generated by Django 4.0.7 on 2022-09-23 01:01
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
@@ -10,11 +8,8 @@ class Migration(migrations.Migration):
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='devicetype',
|
||||
name='_abs_weight',
|
||||
field=models.PositiveBigIntegerField(blank=True, null=True),
|
||||
),
|
||||
|
||||
# Device types
|
||||
migrations.AddField(
|
||||
model_name='devicetype',
|
||||
name='weight',
|
||||
@@ -26,10 +21,12 @@ class Migration(migrations.Migration):
|
||||
field=models.CharField(blank=True, max_length=50),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='moduletype',
|
||||
model_name='devicetype',
|
||||
name='_abs_weight',
|
||||
field=models.PositiveBigIntegerField(blank=True, null=True),
|
||||
),
|
||||
|
||||
# Module types
|
||||
migrations.AddField(
|
||||
model_name='moduletype',
|
||||
name='weight',
|
||||
@@ -41,18 +38,35 @@ class Migration(migrations.Migration):
|
||||
field=models.CharField(blank=True, max_length=50),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='rack',
|
||||
model_name='moduletype',
|
||||
name='_abs_weight',
|
||||
field=models.PositiveBigIntegerField(blank=True, null=True),
|
||||
),
|
||||
|
||||
# Racks
|
||||
migrations.AddField(
|
||||
model_name='rack',
|
||||
name='weight',
|
||||
field=models.DecimalField(blank=True, decimal_places=2, max_digits=8, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='rack',
|
||||
name='max_weight',
|
||||
field=models.PositiveIntegerField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='rack',
|
||||
name='weight_unit',
|
||||
field=models.CharField(blank=True, max_length=50),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='rack',
|
||||
name='_abs_weight',
|
||||
field=models.PositiveBigIntegerField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='rack',
|
||||
name='_abs_max_weight',
|
||||
field=models.PositiveBigIntegerField(blank=True, null=True),
|
||||
),
|
||||
]
|
||||
@@ -6,7 +6,7 @@ from django.db import migrations, models
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('dcim', '0163_rack_devicetype_moduletype_weights'),
|
||||
('dcim', '0163_weight_fields'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
|
||||
18
netbox/dcim/migrations/0167_module_status.py
Normal file
18
netbox/dcim/migrations/0167_module_status.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 4.1.2 on 2022-12-09 15:09
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('dcim', '0166_virtualdevicecontext'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='module',
|
||||
name='status',
|
||||
field=models.CharField(default='active', max_length=50),
|
||||
),
|
||||
]
|
||||
@@ -279,6 +279,17 @@ class CableTermination(models.Model):
|
||||
def clean(self):
|
||||
super().clean()
|
||||
|
||||
# Check for existing termination
|
||||
existing_termination = CableTermination.objects.exclude(cable=self.cable).filter(
|
||||
termination_type=self.termination_type,
|
||||
termination_id=self.termination_id
|
||||
).first()
|
||||
if existing_termination is not None:
|
||||
raise ValidationError(
|
||||
f"Duplicate termination found for {self.termination_type.app_label}.{self.termination_type.model} "
|
||||
f"{self.termination_id}: cable {existing_termination.cable.pk}"
|
||||
)
|
||||
|
||||
# Validate interface type (if applicable)
|
||||
if self.termination_type.model == 'interface' and self.termination.type in NONCONNECTABLE_IFACE_TYPES:
|
||||
raise ValidationError(f"Cables cannot be terminated to {self.termination.get_type_display()} interfaces")
|
||||
@@ -570,6 +581,7 @@ class CablePath(models.Model):
|
||||
[object_to_path_node(circuit_termination)],
|
||||
[object_to_path_node(circuit_termination.provider_network)],
|
||||
])
|
||||
is_complete = True
|
||||
break
|
||||
elif circuit_termination.site and not circuit_termination.cable:
|
||||
# Circuit terminates to a Site
|
||||
|
||||
@@ -197,7 +197,7 @@ class PathEndpoint(models.Model):
|
||||
dcim.signals in response to changes in the cable path, and complements the `origin` GenericForeignKey field on the
|
||||
CablePath model. `_path` should not be accessed directly; rather, use the `path` property.
|
||||
|
||||
`connected_endpoint()` is a convenience method for returning the destination of the associated CablePath, if any.
|
||||
`connected_endpoints()` is a convenience method for returning the destination of the associated CablePath, if any.
|
||||
"""
|
||||
_path = models.ForeignKey(
|
||||
to='dcim.CablePath',
|
||||
@@ -1129,3 +1129,20 @@ class InventoryItem(MPTTModel, ComponentModel):
|
||||
raise ValidationError({
|
||||
"parent": "Cannot assign self as parent."
|
||||
})
|
||||
|
||||
# Validation for moving InventoryItems
|
||||
if self.pk:
|
||||
# Cannot move an InventoryItem to another device if it has a parent
|
||||
if self.parent and self.parent.device != self.device:
|
||||
raise ValidationError({
|
||||
"parent": "Parent inventory item does not belong to the same device."
|
||||
})
|
||||
|
||||
# Prevent moving InventoryItems with children
|
||||
first_child = self.get_children().first()
|
||||
if first_child and first_child.device != self.device:
|
||||
raise ValidationError("Cannot move an inventory item with dependent children")
|
||||
|
||||
# When moving an InventoryItem to another device, remove any associated component
|
||||
if self.component and self.component.device != self.device:
|
||||
self.component = None
|
||||
|
||||
@@ -3,7 +3,6 @@ import yaml
|
||||
|
||||
from functools import cached_property
|
||||
|
||||
from django.apps import apps
|
||||
from django.contrib.contenttypes.fields import GenericRelation
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.validators import MaxValueValidator, MinValueValidator
|
||||
@@ -124,6 +123,9 @@ class DeviceType(PrimaryModel, WeightMixin):
|
||||
clone_fields = (
|
||||
'manufacturer', 'u_height', 'is_full_depth', 'subdevice_role', 'airflow', 'weight', 'weight_unit'
|
||||
)
|
||||
prerequisite_models = (
|
||||
'dcim.Manufacturer',
|
||||
)
|
||||
|
||||
class Meta:
|
||||
ordering = ['manufacturer', 'model']
|
||||
@@ -151,10 +153,6 @@ class DeviceType(PrimaryModel, WeightMixin):
|
||||
self._original_front_image = self.front_image
|
||||
self._original_rear_image = self.rear_image
|
||||
|
||||
@classmethod
|
||||
def get_prerequisite_models(cls):
|
||||
return [Manufacturer, ]
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse('dcim:devicetype', args=[self.pk])
|
||||
|
||||
@@ -325,6 +323,9 @@ class ModuleType(PrimaryModel, WeightMixin):
|
||||
)
|
||||
|
||||
clone_fields = ('manufacturer', 'weight', 'weight_unit',)
|
||||
prerequisite_models = (
|
||||
'dcim.Manufacturer',
|
||||
)
|
||||
|
||||
class Meta:
|
||||
ordering = ('manufacturer', 'model')
|
||||
@@ -338,10 +339,6 @@ class ModuleType(PrimaryModel, WeightMixin):
|
||||
def __str__(self):
|
||||
return self.model
|
||||
|
||||
@classmethod
|
||||
def get_prerequisite_models(cls):
|
||||
return [Manufacturer, ]
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse('dcim:moduletype', args=[self.pk])
|
||||
|
||||
@@ -599,6 +596,11 @@ class Device(PrimaryModel, ConfigContextModel):
|
||||
'device_type', 'device_role', 'tenant', 'platform', 'site', 'location', 'rack', 'face', 'status', 'airflow',
|
||||
'cluster', 'virtual_chassis',
|
||||
)
|
||||
prerequisite_models = (
|
||||
'dcim.Site',
|
||||
'dcim.DeviceRole',
|
||||
'dcim.DeviceType',
|
||||
)
|
||||
|
||||
class Meta:
|
||||
ordering = ('_name', 'pk') # Name may be null
|
||||
@@ -638,10 +640,6 @@ class Device(PrimaryModel, ConfigContextModel):
|
||||
return f'{self.device_type.manufacturer} {self.device_type.model} ({self.pk})'
|
||||
return super().__str__()
|
||||
|
||||
@classmethod
|
||||
def get_prerequisite_models(cls):
|
||||
return [apps.get_model('dcim.Site'), DeviceRole, DeviceType, ]
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse('dcim:device', args=[self.pk])
|
||||
|
||||
@@ -927,6 +925,11 @@ class Module(PrimaryModel, ConfigContextModel):
|
||||
on_delete=models.PROTECT,
|
||||
related_name='instances'
|
||||
)
|
||||
status = models.CharField(
|
||||
max_length=50,
|
||||
choices=ModuleStatusChoices,
|
||||
default=ModuleStatusChoices.STATUS_ACTIVE
|
||||
)
|
||||
serial = models.CharField(
|
||||
max_length=50,
|
||||
blank=True,
|
||||
@@ -941,7 +944,7 @@ class Module(PrimaryModel, ConfigContextModel):
|
||||
help_text=_('A unique tag used to identify this device')
|
||||
)
|
||||
|
||||
clone_fields = ('device', 'module_type')
|
||||
clone_fields = ('device', 'module_type', 'status')
|
||||
|
||||
class Meta:
|
||||
ordering = ('module_bay',)
|
||||
@@ -952,6 +955,9 @@ class Module(PrimaryModel, ConfigContextModel):
|
||||
def get_absolute_url(self):
|
||||
return reverse('dcim:module', args=[self.pk])
|
||||
|
||||
def get_status_color(self):
|
||||
return ModuleStatusChoices.colors.get(self.status)
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
|
||||
@@ -1176,3 +1182,20 @@ class VirtualDeviceContext(PrimaryModel):
|
||||
return self.primary_ip4
|
||||
else:
|
||||
return None
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
|
||||
# Validate primary IPv4/v6 assignment
|
||||
for primary_ip, family in ((self.primary_ip4, 4), (self.primary_ip6, 6)):
|
||||
if not primary_ip:
|
||||
continue
|
||||
if primary_ip.family != family:
|
||||
raise ValidationError({
|
||||
f'primary_ip{family}': f"{primary_ip} is not an IPv{family} address."
|
||||
})
|
||||
device_interfaces = self.device.vc_interfaces(if_master=False)
|
||||
if primary_ip.assigned_object not in device_interfaces:
|
||||
raise ValidationError({
|
||||
f'primary_ip{family}': _('Primary IP address must belong to an interface on the assigned device.')
|
||||
})
|
||||
|
||||
@@ -39,7 +39,5 @@ class WeightMixin(models.Model):
|
||||
super().clean()
|
||||
|
||||
# Validate weight and weight_unit
|
||||
if self.weight is not None and not self.weight_unit:
|
||||
if self.weight and not self.weight_unit:
|
||||
raise ValidationError("Must specify a unit when setting a weight")
|
||||
elif self.weight is None:
|
||||
self.weight_unit = ''
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
from django.apps import apps
|
||||
from django.contrib.contenttypes.fields import GenericRelation
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.validators import MaxValueValidator, MinValueValidator
|
||||
@@ -48,6 +47,10 @@ class PowerPanel(PrimaryModel):
|
||||
to='extras.ImageAttachment'
|
||||
)
|
||||
|
||||
prerequisite_models = (
|
||||
'dcim.Site',
|
||||
)
|
||||
|
||||
class Meta:
|
||||
ordering = ['site', 'name']
|
||||
constraints = (
|
||||
@@ -60,10 +63,6 @@ class PowerPanel(PrimaryModel):
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
@classmethod
|
||||
def get_prerequisite_models(cls):
|
||||
return [apps.get_model('dcim.Site'), ]
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse('dcim:powerpanel', args=[self.pk])
|
||||
|
||||
@@ -137,6 +136,9 @@ class PowerFeed(PrimaryModel, PathEndpoint, CabledObjectModel):
|
||||
'power_panel', 'rack', 'status', 'type', 'mark_connected', 'supply', 'phase', 'voltage', 'amperage',
|
||||
'max_utilization',
|
||||
)
|
||||
prerequisite_models = (
|
||||
'dcim.PowerPanel',
|
||||
)
|
||||
|
||||
class Meta:
|
||||
ordering = ['power_panel', 'name']
|
||||
@@ -150,10 +152,6 @@ class PowerFeed(PrimaryModel, PathEndpoint, CabledObjectModel):
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
@classmethod
|
||||
def get_prerequisite_models(cls):
|
||||
return [PowerPanel, ]
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse('dcim:powerfeed', args=[self.pk])
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import decimal
|
||||
from functools import cached_property
|
||||
|
||||
from django.apps import apps
|
||||
from django.contrib.auth.models import User
|
||||
from django.contrib.contenttypes.fields import GenericRelation
|
||||
from django.contrib.postgres.fields import ArrayField
|
||||
@@ -18,7 +17,7 @@ from dcim.svg import RackElevationSVG
|
||||
from netbox.models import OrganizationalModel, PrimaryModel
|
||||
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, drange, to_grams
|
||||
from .device_components import PowerPort
|
||||
from .devices import Device, Module
|
||||
from .mixins import WeightMixin
|
||||
@@ -150,6 +149,16 @@ class Rack(PrimaryModel, WeightMixin):
|
||||
choices=RackDimensionUnitChoices,
|
||||
blank=True,
|
||||
)
|
||||
max_weight = models.PositiveIntegerField(
|
||||
blank=True,
|
||||
null=True,
|
||||
help_text=_('Maximum load capacity for the rack')
|
||||
)
|
||||
# Stores the normalized max weight (in grams) for database ordering
|
||||
_abs_max_weight = models.PositiveBigIntegerField(
|
||||
blank=True,
|
||||
null=True
|
||||
)
|
||||
mounting_depth = models.PositiveSmallIntegerField(
|
||||
blank=True,
|
||||
null=True,
|
||||
@@ -175,7 +184,10 @@ class Rack(PrimaryModel, WeightMixin):
|
||||
|
||||
clone_fields = (
|
||||
'site', 'location', 'tenant', 'status', 'role', 'type', 'width', 'u_height', 'desc_units', 'outer_width',
|
||||
'outer_depth', 'outer_unit', 'mounting_depth', 'weight', 'weight_unit',
|
||||
'outer_depth', 'outer_unit', 'mounting_depth', 'weight', 'max_weight', 'weight_unit',
|
||||
)
|
||||
prerequisite_models = (
|
||||
'dcim.Site',
|
||||
)
|
||||
|
||||
class Meta:
|
||||
@@ -197,10 +209,6 @@ class Rack(PrimaryModel, WeightMixin):
|
||||
return f'{self.name} ({self.facility_id})'
|
||||
return self.name
|
||||
|
||||
@classmethod
|
||||
def get_prerequisite_models(cls):
|
||||
return [apps.get_model('dcim.Site'), ]
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse('dcim:rack', args=[self.pk])
|
||||
|
||||
@@ -217,6 +225,10 @@ class Rack(PrimaryModel, WeightMixin):
|
||||
elif self.outer_width is None and self.outer_depth is None:
|
||||
self.outer_unit = ''
|
||||
|
||||
# Validate max_weight and weight_unit
|
||||
if self.max_weight and not self.weight_unit:
|
||||
raise ValidationError("Must specify a unit when setting a maximum weight")
|
||||
|
||||
if self.pk:
|
||||
# Validate that Rack is tall enough to house the installed Devices
|
||||
top_device = Device.objects.filter(
|
||||
@@ -239,6 +251,16 @@ class Rack(PrimaryModel, WeightMixin):
|
||||
'location': f"Location must be from the same site, {self.site}."
|
||||
})
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
|
||||
# Store the given max weight (if any) in grams for use in database ordering
|
||||
if self.max_weight and self.weight_unit:
|
||||
self._abs_max_weight = to_grams(self.max_weight, self.weight_unit)
|
||||
else:
|
||||
self._abs_max_weight = None
|
||||
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
@property
|
||||
def units(self):
|
||||
"""
|
||||
@@ -488,16 +510,17 @@ class RackReservation(PrimaryModel):
|
||||
max_length=200
|
||||
)
|
||||
|
||||
clone_fields = ('rack', 'user', 'tenant')
|
||||
prerequisite_models = (
|
||||
'dcim.Rack',
|
||||
)
|
||||
|
||||
class Meta:
|
||||
ordering = ['created', 'pk']
|
||||
|
||||
def __str__(self):
|
||||
return "Reservation for rack {}".format(self.rack)
|
||||
|
||||
@classmethod
|
||||
def get_prerequisite_models(cls):
|
||||
return [apps.get_model('dcim.Site'), Rack, ]
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse('dcim:rackreservation', args=[self.pk])
|
||||
|
||||
|
||||
@@ -286,6 +286,9 @@ class Location(NestedGroupModel):
|
||||
)
|
||||
|
||||
clone_fields = ('site', 'parent', 'status', 'tenant', 'description')
|
||||
prerequisite_models = (
|
||||
'dcim.Site',
|
||||
)
|
||||
|
||||
class Meta:
|
||||
ordering = ['site', 'name']
|
||||
@@ -312,10 +315,6 @@ class Location(NestedGroupModel):
|
||||
),
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def get_prerequisite_models(cls):
|
||||
return [Site, ]
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse('dcim:location', args=[self.pk])
|
||||
|
||||
|
||||
@@ -4,7 +4,9 @@ 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 import (
|
||||
Cable, CablePath, CableTermination, Device, FrontPort, PathEndpoint, PowerPanel, Rack, Location, VirtualChassis,
|
||||
)
|
||||
from .models.cables import trace_paths
|
||||
from .utils import create_cablepath, rebuild_paths
|
||||
|
||||
@@ -123,3 +125,14 @@ def nullify_connected_endpoints(instance, **kwargs):
|
||||
|
||||
for cablepath in CablePath.objects.filter(_nodes__contains=instance.cable):
|
||||
cablepath.retrace()
|
||||
|
||||
|
||||
@receiver(post_save, sender=FrontPort)
|
||||
def extend_rearport_cable_paths(instance, created, raw, **kwargs):
|
||||
"""
|
||||
When a new FrontPort is created, add it to any CablePaths which end at its corresponding RearPort.
|
||||
"""
|
||||
if created and not raw:
|
||||
rearport = instance.rear_port
|
||||
for cablepath in CablePath.objects.filter(_nodes__contains=rearport):
|
||||
cablepath.retrace()
|
||||
|
||||
@@ -139,7 +139,8 @@ class PlatformTable(NetBoxTable):
|
||||
class DeviceTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
|
||||
name = tables.TemplateColumn(
|
||||
order_by=('_name',),
|
||||
template_code=DEVICE_LINK
|
||||
template_code=DEVICE_LINK,
|
||||
linkify=True
|
||||
)
|
||||
status = columns.ChoiceFieldColumn()
|
||||
region = tables.Column(
|
||||
@@ -220,7 +221,8 @@ class DeviceTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
|
||||
|
||||
class DeviceImportTable(TenancyColumnsMixin, NetBoxTable):
|
||||
name = tables.TemplateColumn(
|
||||
template_code=DEVICE_LINK
|
||||
template_code=DEVICE_LINK,
|
||||
linkify=True
|
||||
)
|
||||
status = columns.ChoiceFieldColumn()
|
||||
site = tables.Column(
|
||||
@@ -521,6 +523,10 @@ class InterfaceTable(ModularDeviceComponentTable, BaseInterfaceTable, PathEndpoi
|
||||
orderable=False,
|
||||
verbose_name='Wireless LANs'
|
||||
)
|
||||
vdcs = columns.ManyToManyColumn(
|
||||
linkify_item=True,
|
||||
verbose_name='VDCs'
|
||||
)
|
||||
vrf = tables.Column(
|
||||
linkify=True
|
||||
)
|
||||
@@ -534,7 +540,7 @@ class InterfaceTable(ModularDeviceComponentTable, BaseInterfaceTable, PathEndpoi
|
||||
'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', 'l2vpn',
|
||||
'cable_color', 'wireless_link', 'wireless_lans', 'link_peer', 'connection', 'tags', 'vdcs', 'vrf', 'l2vpn',
|
||||
'ip_addresses', 'fhrp_groups', 'untagged_vlan', 'tagged_vlans', 'created', 'last_updated',
|
||||
)
|
||||
default_columns = ('pk', 'name', 'device', 'label', 'enabled', 'type', 'description')
|
||||
@@ -568,7 +574,7 @@ class DeviceInterfaceTable(InterfaceTable):
|
||||
'pk', 'id', 'name', 'module_bay', 'module', 'label', 'enabled', 'type', 'parent', 'bridge', 'lag',
|
||||
'mgmt_only', 'mtu', 'mode', 'mac_address', 'wwn', 'rf_role', 'rf_channel', 'rf_channel_frequency',
|
||||
'rf_channel_width', 'tx_power', 'description', 'mark_connected', 'cable', 'cable_color', 'wireless_link',
|
||||
'wireless_lans', 'link_peer', 'connection', 'tags', 'vrf', 'l2vpn', 'ip_addresses', 'fhrp_groups',
|
||||
'wireless_lans', 'link_peer', 'connection', 'tags', 'vdcs', 'vrf', 'l2vpn', 'ip_addresses', 'fhrp_groups',
|
||||
'untagged_vlan', 'tagged_vlans', 'actions',
|
||||
)
|
||||
order_by = ('name',)
|
||||
@@ -893,7 +899,8 @@ class VirtualDeviceContextTable(TenancyColumnsMixin, NetBoxTable):
|
||||
)
|
||||
device = tables.TemplateColumn(
|
||||
order_by=('_name',),
|
||||
template_code=DEVICE_LINK
|
||||
template_code=DEVICE_LINK,
|
||||
linkify=True
|
||||
)
|
||||
status = columns.ChoiceFieldColumn()
|
||||
primary_ip = tables.Column(
|
||||
@@ -909,6 +916,11 @@ class VirtualDeviceContextTable(TenancyColumnsMixin, NetBoxTable):
|
||||
linkify=True,
|
||||
verbose_name='IPv6 Address'
|
||||
)
|
||||
interface_count = columns.LinkedCountColumn(
|
||||
viewname='dcim:interface_list',
|
||||
url_params={'vdc_id': 'pk'},
|
||||
verbose_name='Interfaces'
|
||||
)
|
||||
|
||||
comments = columns.MarkdownColumn()
|
||||
|
||||
@@ -919,8 +931,8 @@ class VirtualDeviceContextTable(TenancyColumnsMixin, NetBoxTable):
|
||||
class Meta(NetBoxTable.Meta):
|
||||
model = models.VirtualDeviceContext
|
||||
fields = (
|
||||
'pk', 'id', 'name', 'status', 'identifier', 'tenant', 'tenant_group',
|
||||
'primary_ip', 'primary_ip4', 'primary_ip6', 'comments', 'tags', 'created', 'last_updated',
|
||||
'pk', 'id', 'name', 'status', 'identifier', 'tenant', 'tenant_group', 'primary_ip', 'primary_ip4',
|
||||
'primary_ip6', 'comments', 'tags', 'interface_count', 'created', 'last_updated',
|
||||
)
|
||||
default_columns = (
|
||||
'pk', 'name', 'identifier', 'status', 'tenant', 'primary_ip',
|
||||
|
||||
@@ -3,7 +3,7 @@ import django_tables2 as tables
|
||||
from dcim import models
|
||||
from netbox.tables import NetBoxTable, columns
|
||||
from tenancy.tables import ContactsColumnMixin
|
||||
from .template_code import MODULAR_COMPONENT_TEMPLATE_BUTTONS, DEVICE_WEIGHT
|
||||
from .template_code import MODULAR_COMPONENT_TEMPLATE_BUTTONS, WEIGHT
|
||||
|
||||
__all__ = (
|
||||
'ConsolePortTemplateTable',
|
||||
@@ -49,7 +49,7 @@ class ManufacturerTable(ContactsColumnMixin, NetBoxTable):
|
||||
model = models.Manufacturer
|
||||
fields = (
|
||||
'pk', 'id', 'name', 'devicetype_count', 'inventoryitem_count', 'platform_count', 'description', 'slug',
|
||||
'contacts', 'actions', 'created', 'last_updated',
|
||||
'tags', 'contacts', 'actions', 'created', 'last_updated',
|
||||
)
|
||||
default_columns = (
|
||||
'pk', 'name', 'devicetype_count', 'inventoryitem_count', 'platform_count', 'description', 'slug',
|
||||
@@ -84,7 +84,7 @@ class DeviceTypeTable(NetBoxTable):
|
||||
template_code='{{ value|floatformat }}'
|
||||
)
|
||||
weight = columns.TemplateColumn(
|
||||
template_code=DEVICE_WEIGHT,
|
||||
template_code=WEIGHT,
|
||||
order_by=('_abs_weight', 'weight_unit')
|
||||
)
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import django_tables2 as tables
|
||||
|
||||
from dcim.models import Module, ModuleType
|
||||
from netbox.tables import NetBoxTable, columns
|
||||
from .template_code import DEVICE_WEIGHT
|
||||
from .template_code import WEIGHT
|
||||
|
||||
__all__ = (
|
||||
'ModuleTable',
|
||||
@@ -28,7 +28,7 @@ class ModuleTypeTable(NetBoxTable):
|
||||
url_name='dcim:moduletype_list'
|
||||
)
|
||||
weight = columns.TemplateColumn(
|
||||
template_code=DEVICE_WEIGHT,
|
||||
template_code=WEIGHT,
|
||||
order_by=('_abs_weight', 'weight_unit')
|
||||
)
|
||||
|
||||
@@ -56,6 +56,7 @@ class ModuleTable(NetBoxTable):
|
||||
module_type = tables.Column(
|
||||
linkify=True
|
||||
)
|
||||
status = columns.ChoiceFieldColumn()
|
||||
comments = columns.MarkdownColumn()
|
||||
tags = columns.TagColumn(
|
||||
url_name='dcim:module_list'
|
||||
@@ -64,9 +65,9 @@ class ModuleTable(NetBoxTable):
|
||||
class Meta(NetBoxTable.Meta):
|
||||
model = Module
|
||||
fields = (
|
||||
'pk', 'id', 'device', 'module_bay', 'manufacturer', 'module_type', 'serial', 'asset_tag', 'description',
|
||||
'comments', 'tags',
|
||||
'pk', 'id', 'device', 'module_bay', 'manufacturer', 'module_type', 'status', 'serial', 'asset_tag',
|
||||
'description', 'comments', 'tags',
|
||||
)
|
||||
default_columns = (
|
||||
'pk', 'id', 'device', 'module_bay', 'manufacturer', 'module_type', 'serial', 'asset_tag',
|
||||
'pk', 'id', 'device', 'module_bay', 'manufacturer', 'module_type', 'status', 'serial', 'asset_tag',
|
||||
)
|
||||
|
||||
@@ -4,7 +4,7 @@ from django_tables2.utils import Accessor
|
||||
from dcim.models import Rack, RackReservation, RackRole
|
||||
from netbox.tables import NetBoxTable, columns
|
||||
from tenancy.tables import ContactsColumnMixin, TenancyColumnsMixin
|
||||
from .template_code import DEVICE_WEIGHT
|
||||
from .template_code import WEIGHT
|
||||
|
||||
__all__ = (
|
||||
'RackTable',
|
||||
@@ -81,17 +81,21 @@ class RackTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
|
||||
verbose_name='Outer Depth'
|
||||
)
|
||||
weight = columns.TemplateColumn(
|
||||
template_code=DEVICE_WEIGHT,
|
||||
template_code=WEIGHT,
|
||||
order_by=('_abs_weight', 'weight_unit')
|
||||
)
|
||||
max_weight = columns.TemplateColumn(
|
||||
template_code=WEIGHT,
|
||||
order_by=('_abs_max_weight', 'weight_unit')
|
||||
)
|
||||
|
||||
class Meta(NetBoxTable.Meta):
|
||||
model = Rack
|
||||
fields = (
|
||||
'pk', 'id', 'name', 'site', 'location', 'status', 'facility_id', 'tenant', 'tenant_group', 'role', 'serial',
|
||||
'asset_tag', 'type', 'u_height', 'width', 'outer_width', 'outer_depth', 'mounting_depth', 'weight',
|
||||
'comments', 'device_count', 'get_utilization', 'get_power_utilization', 'description', 'contacts', 'tags',
|
||||
'created', 'last_updated',
|
||||
'max_weight', 'comments', 'device_count', 'get_utilization', 'get_power_utilization', 'description',
|
||||
'contacts', 'tags', 'created', 'last_updated',
|
||||
)
|
||||
default_columns = (
|
||||
'pk', 'name', 'site', 'location', 'status', 'facility_id', 'tenant', 'role', 'u_height', 'device_count',
|
||||
|
||||
@@ -99,9 +99,9 @@ class SiteTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
|
||||
class Meta(NetBoxTable.Meta):
|
||||
model = Site
|
||||
fields = (
|
||||
'pk', 'id', 'name', 'slug', 'status', 'facility', 'region', 'group', 'tenant', 'tenant_group', 'asns', 'asn_count',
|
||||
'time_zone', 'description', 'physical_address', 'shipping_address', 'latitude', 'longitude', 'comments',
|
||||
'contacts', 'tags', 'created', 'last_updated', 'actions',
|
||||
'pk', 'id', 'name', 'slug', 'status', 'facility', 'region', 'group', 'tenant', 'tenant_group', 'asns',
|
||||
'asn_count', 'time_zone', 'description', 'physical_address', 'shipping_address', 'latitude', 'longitude',
|
||||
'comments', 'contacts', 'tags', 'created', 'last_updated', 'actions',
|
||||
)
|
||||
default_columns = ('pk', 'name', 'status', 'facility', 'region', 'group', 'tenant', 'description')
|
||||
|
||||
|
||||
@@ -15,15 +15,13 @@ CABLE_LENGTH = """
|
||||
{% if record.length %}{{ record.length|simplify_decimal }} {{ record.length_unit }}{% endif %}
|
||||
"""
|
||||
|
||||
DEVICE_WEIGHT = """
|
||||
WEIGHT = """
|
||||
{% load helpers %}
|
||||
{% if record.weight %}{{ record.weight|simplify_decimal }} {{ record.weight_unit }}{% endif %}
|
||||
{% if value %}{{ value|simplify_decimal }} {{ record.weight_unit }}{% endif %}
|
||||
"""
|
||||
|
||||
DEVICE_LINK = """
|
||||
<a href="{% url 'dcim:device' pk=record.pk %}">
|
||||
{{ record.name|default:'<span class="badge bg-info">Unnamed device</span>' }}
|
||||
</a>
|
||||
{{ value|default:'<span class="badge bg-info">Unnamed device</span>' }}
|
||||
"""
|
||||
|
||||
DEVICEBAY_STATUS = """
|
||||
|
||||
@@ -1271,6 +1271,7 @@ class ModuleTest(APIViewTestCases.APIViewTestCase):
|
||||
'device': device.pk,
|
||||
'module_bay': module_bays[3].pk,
|
||||
'module_type': module_types[0].pk,
|
||||
'status': ModuleStatusChoices.STATUS_ACTIVE,
|
||||
'serial': 'ABC123',
|
||||
'asset_tag': 'Foo1',
|
||||
},
|
||||
@@ -1278,6 +1279,7 @@ class ModuleTest(APIViewTestCases.APIViewTestCase):
|
||||
'device': device.pk,
|
||||
'module_bay': module_bays[4].pk,
|
||||
'module_type': module_types[1].pk,
|
||||
'status': ModuleStatusChoices.STATUS_ACTIVE,
|
||||
'serial': 'DEF456',
|
||||
'asset_tag': 'Foo2',
|
||||
},
|
||||
@@ -1285,6 +1287,7 @@ class ModuleTest(APIViewTestCases.APIViewTestCase):
|
||||
'device': device.pk,
|
||||
'module_bay': module_bays[5].pk,
|
||||
'module_type': module_types[2].pk,
|
||||
'status': ModuleStatusChoices.STATUS_ACTIVE,
|
||||
'serial': 'GHI789',
|
||||
'asset_tag': 'Foo3',
|
||||
},
|
||||
@@ -1954,37 +1957,37 @@ class CableTest(APIViewTestCases.APIViewTestCase):
|
||||
|
||||
class ConnectedDeviceTest(APITestCase):
|
||||
|
||||
def setUp(self):
|
||||
|
||||
super().setUp()
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
site = Site.objects.create(name='Site 1', slug='site-1')
|
||||
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
|
||||
devicetype = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1')
|
||||
devicerole = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1', color='ff0000')
|
||||
self.device1 = Device.objects.create(
|
||||
device_type=devicetype, device_role=devicerole, name='TestDevice1', site=site
|
||||
devices = (
|
||||
Device(device_type=devicetype, device_role=devicerole, name='TestDevice1', site=site),
|
||||
Device(device_type=devicetype, device_role=devicerole, name='TestDevice2', site=site),
|
||||
)
|
||||
self.device2 = Device.objects.create(
|
||||
device_type=devicetype, device_role=devicerole, name='TestDevice2', site=site
|
||||
Device.objects.bulk_create(devices)
|
||||
interfaces = (
|
||||
Interface(device=devices[0], name='eth0'),
|
||||
Interface(device=devices[1], name='eth0'),
|
||||
Interface(device=devices[0], name='eth1'), # Not connected
|
||||
)
|
||||
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.device1, name='eth1') # Not connected
|
||||
Interface.objects.bulk_create(interfaces)
|
||||
|
||||
cable = Cable(a_terminations=[self.interface1], b_terminations=[self.interface2])
|
||||
cable = Cable(a_terminations=[interfaces[0]], b_terminations=[interfaces[1]])
|
||||
cable.save()
|
||||
|
||||
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
|
||||
def test_get_connected_device(self):
|
||||
url = reverse('dcim-api:connected-device-list')
|
||||
|
||||
url_params = f'?peer_device={self.device1.name}&peer_interface={self.interface1.name}'
|
||||
url_params = f'?peer_device=TestDevice1&peer_interface=eth0'
|
||||
response = self.client.get(url + url_params, **self.header)
|
||||
self.assertHttpStatus(response, status.HTTP_200_OK)
|
||||
self.assertEqual(response.data['name'], self.device2.name)
|
||||
self.assertEqual(response.data['name'], 'TestDevice2')
|
||||
|
||||
url_params = f'?peer_device={self.device1.name}&peer_interface={self.interface3.name}'
|
||||
url_params = f'?peer_device=TestDevice1&peer_interface=eth1'
|
||||
response = self.client.get(url + url_params, **self.header)
|
||||
self.assertHttpStatus(response, status.HTTP_404_NOT_FOUND)
|
||||
|
||||
|
||||
@@ -1323,6 +1323,7 @@ class CablePathTestCase(TestCase):
|
||||
is_active=True
|
||||
)
|
||||
self.assertEqual(CablePath.objects.count(), 1)
|
||||
self.assertTrue(CablePath.objects.first().is_complete)
|
||||
|
||||
# Delete cable 1
|
||||
cable1.delete()
|
||||
|
||||
@@ -409,9 +409,9 @@ class RackTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
Tenant.objects.bulk_create(tenants)
|
||||
|
||||
racks = (
|
||||
Rack(name='Rack 1', facility_id='rack-1', site=sites[0], location=locations[0], tenant=tenants[0], status=RackStatusChoices.STATUS_ACTIVE, role=rack_roles[0], serial='ABC', asset_tag='1001', type=RackTypeChoices.TYPE_2POST, width=RackWidthChoices.WIDTH_19IN, u_height=42, desc_units=False, outer_width=100, outer_depth=100, outer_unit=RackDimensionUnitChoices.UNIT_MILLIMETER, weight=10, weight_unit=WeightUnitChoices.UNIT_POUND),
|
||||
Rack(name='Rack 2', facility_id='rack-2', site=sites[1], location=locations[1], tenant=tenants[1], status=RackStatusChoices.STATUS_PLANNED, role=rack_roles[1], serial='DEF', asset_tag='1002', type=RackTypeChoices.TYPE_4POST, width=RackWidthChoices.WIDTH_21IN, u_height=43, desc_units=False, outer_width=200, outer_depth=200, outer_unit=RackDimensionUnitChoices.UNIT_MILLIMETER, weight=20, weight_unit=WeightUnitChoices.UNIT_POUND),
|
||||
Rack(name='Rack 3', facility_id='rack-3', site=sites[2], location=locations[2], tenant=tenants[2], status=RackStatusChoices.STATUS_RESERVED, role=rack_roles[2], serial='GHI', asset_tag='1003', type=RackTypeChoices.TYPE_CABINET, width=RackWidthChoices.WIDTH_23IN, u_height=44, desc_units=True, outer_width=300, outer_depth=300, outer_unit=RackDimensionUnitChoices.UNIT_INCH, weight=30, weight_unit=WeightUnitChoices.UNIT_KILOGRAM),
|
||||
Rack(name='Rack 1', facility_id='rack-1', site=sites[0], location=locations[0], tenant=tenants[0], status=RackStatusChoices.STATUS_ACTIVE, role=rack_roles[0], serial='ABC', asset_tag='1001', type=RackTypeChoices.TYPE_2POST, width=RackWidthChoices.WIDTH_19IN, u_height=42, desc_units=False, outer_width=100, outer_depth=100, outer_unit=RackDimensionUnitChoices.UNIT_MILLIMETER, weight=10, max_weight=1000, weight_unit=WeightUnitChoices.UNIT_POUND),
|
||||
Rack(name='Rack 2', facility_id='rack-2', site=sites[1], location=locations[1], tenant=tenants[1], status=RackStatusChoices.STATUS_PLANNED, role=rack_roles[1], serial='DEF', asset_tag='1002', type=RackTypeChoices.TYPE_4POST, width=RackWidthChoices.WIDTH_21IN, u_height=43, desc_units=False, outer_width=200, outer_depth=200, outer_unit=RackDimensionUnitChoices.UNIT_MILLIMETER, weight=20, max_weight=2000, weight_unit=WeightUnitChoices.UNIT_POUND),
|
||||
Rack(name='Rack 3', facility_id='rack-3', site=sites[2], location=locations[2], tenant=tenants[2], status=RackStatusChoices.STATUS_RESERVED, role=rack_roles[2], serial='GHI', asset_tag='1003', type=RackTypeChoices.TYPE_CABINET, width=RackWidthChoices.WIDTH_23IN, u_height=44, desc_units=True, outer_width=300, outer_depth=300, outer_unit=RackDimensionUnitChoices.UNIT_INCH, weight=30, max_weight=3000, weight_unit=WeightUnitChoices.UNIT_KILOGRAM),
|
||||
)
|
||||
Rack.objects.bulk_create(racks)
|
||||
|
||||
@@ -521,6 +521,10 @@ class RackTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
params = {'weight': [10, 20]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_max_weight(self):
|
||||
params = {'max_weight': [1000, 2000]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_weight_unit(self):
|
||||
params = {'weight_unit': WeightUnitChoices.UNIT_POUND}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
@@ -1876,15 +1880,15 @@ class ModuleTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
ModuleBay.objects.bulk_create(module_bays)
|
||||
|
||||
modules = (
|
||||
Module(device=devices[0], module_bay=module_bays[0], module_type=module_types[0], serial='A', asset_tag='A'),
|
||||
Module(device=devices[0], module_bay=module_bays[1], module_type=module_types[1], serial='B', asset_tag='B'),
|
||||
Module(device=devices[0], module_bay=module_bays[2], module_type=module_types[2], serial='C', asset_tag='C'),
|
||||
Module(device=devices[1], module_bay=module_bays[3], module_type=module_types[0], serial='D', asset_tag='D'),
|
||||
Module(device=devices[1], module_bay=module_bays[4], module_type=module_types[1], serial='E', asset_tag='E'),
|
||||
Module(device=devices[1], module_bay=module_bays[5], module_type=module_types[2], serial='F', asset_tag='F'),
|
||||
Module(device=devices[2], module_bay=module_bays[6], module_type=module_types[0], serial='G', asset_tag='G'),
|
||||
Module(device=devices[2], module_bay=module_bays[7], module_type=module_types[1], serial='H', asset_tag='H'),
|
||||
Module(device=devices[2], module_bay=module_bays[8], module_type=module_types[2], serial='I', asset_tag='I'),
|
||||
Module(device=devices[0], module_bay=module_bays[0], module_type=module_types[0], status=ModuleStatusChoices.STATUS_ACTIVE, serial='A', asset_tag='A'),
|
||||
Module(device=devices[0], module_bay=module_bays[1], module_type=module_types[1], status=ModuleStatusChoices.STATUS_ACTIVE, serial='B', asset_tag='B'),
|
||||
Module(device=devices[0], module_bay=module_bays[2], module_type=module_types[2], status=ModuleStatusChoices.STATUS_ACTIVE, serial='C', asset_tag='C'),
|
||||
Module(device=devices[1], module_bay=module_bays[3], module_type=module_types[0], status=ModuleStatusChoices.STATUS_ACTIVE, serial='D', asset_tag='D'),
|
||||
Module(device=devices[1], module_bay=module_bays[4], module_type=module_types[1], status=ModuleStatusChoices.STATUS_ACTIVE, serial='E', asset_tag='E'),
|
||||
Module(device=devices[1], module_bay=module_bays[5], module_type=module_types[2], status=ModuleStatusChoices.STATUS_ACTIVE, serial='F', asset_tag='F'),
|
||||
Module(device=devices[2], module_bay=module_bays[6], module_type=module_types[0], status=ModuleStatusChoices.STATUS_ACTIVE, serial='G', asset_tag='G'),
|
||||
Module(device=devices[2], module_bay=module_bays[7], module_type=module_types[1], status=ModuleStatusChoices.STATUS_PLANNED, serial='H', asset_tag='H'),
|
||||
Module(device=devices[2], module_bay=module_bays[8], module_type=module_types[2], status=ModuleStatusChoices.STATUS_FAILED, serial='I', asset_tag='I'),
|
||||
)
|
||||
Module.objects.bulk_create(modules)
|
||||
|
||||
@@ -1912,6 +1916,10 @@ class ModuleTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
params = {'device_id': [device_types[0].pk, device_types[1].pk]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)
|
||||
|
||||
def test_status(self):
|
||||
params = {'status': [ModuleStatusChoices.STATUS_PLANNED, ModuleStatusChoices.STATUS_FAILED]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_serial(self):
|
||||
params = {'serial': ['A', 'B']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
@@ -73,7 +73,8 @@ class LocationTestCase(TestCase):
|
||||
|
||||
class RackTestCase(TestCase):
|
||||
|
||||
def setUp(self):
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
|
||||
sites = (
|
||||
Site(name='Site 1', slug='site-1'),
|
||||
@@ -240,30 +241,31 @@ class RackTestCase(TestCase):
|
||||
|
||||
class DeviceTestCase(TestCase):
|
||||
|
||||
def setUp(self):
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
|
||||
self.site = Site.objects.create(name='Test Site 1', slug='test-site-1')
|
||||
site = Site.objects.create(name='Test Site 1', slug='test-site-1')
|
||||
manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1')
|
||||
self.device_type = DeviceType.objects.create(
|
||||
device_type = DeviceType.objects.create(
|
||||
manufacturer=manufacturer, model='Test Device Type 1', slug='test-device-type-1'
|
||||
)
|
||||
self.device_role = DeviceRole.objects.create(
|
||||
device_role = DeviceRole.objects.create(
|
||||
name='Test Device Role 1', slug='test-device-role-1', color='ff0000'
|
||||
)
|
||||
|
||||
# Create DeviceType components
|
||||
ConsolePortTemplate(
|
||||
device_type=self.device_type,
|
||||
device_type=device_type,
|
||||
name='Console Port 1'
|
||||
).save()
|
||||
|
||||
ConsoleServerPortTemplate(
|
||||
device_type=self.device_type,
|
||||
device_type=device_type,
|
||||
name='Console Server Port 1'
|
||||
).save()
|
||||
|
||||
ppt = PowerPortTemplate(
|
||||
device_type=self.device_type,
|
||||
device_type=device_type,
|
||||
name='Power Port 1',
|
||||
maximum_draw=1000,
|
||||
allocated_draw=500
|
||||
@@ -271,21 +273,21 @@ class DeviceTestCase(TestCase):
|
||||
ppt.save()
|
||||
|
||||
PowerOutletTemplate(
|
||||
device_type=self.device_type,
|
||||
device_type=device_type,
|
||||
name='Power Outlet 1',
|
||||
power_port=ppt,
|
||||
feed_leg=PowerOutletFeedLegChoices.FEED_LEG_A
|
||||
).save()
|
||||
|
||||
InterfaceTemplate(
|
||||
device_type=self.device_type,
|
||||
device_type=device_type,
|
||||
name='Interface 1',
|
||||
type=InterfaceTypeChoices.TYPE_1GE_FIXED,
|
||||
mgmt_only=True
|
||||
).save()
|
||||
|
||||
rpt = RearPortTemplate(
|
||||
device_type=self.device_type,
|
||||
device_type=device_type,
|
||||
name='Rear Port 1',
|
||||
type=PortTypeChoices.TYPE_8P8C,
|
||||
positions=8
|
||||
@@ -293,7 +295,7 @@ class DeviceTestCase(TestCase):
|
||||
rpt.save()
|
||||
|
||||
FrontPortTemplate(
|
||||
device_type=self.device_type,
|
||||
device_type=device_type,
|
||||
name='Front Port 1',
|
||||
type=PortTypeChoices.TYPE_8P8C,
|
||||
rear_port=rpt,
|
||||
@@ -301,12 +303,12 @@ class DeviceTestCase(TestCase):
|
||||
).save()
|
||||
|
||||
ModuleBayTemplate(
|
||||
device_type=self.device_type,
|
||||
device_type=device_type,
|
||||
name='Module Bay 1'
|
||||
).save()
|
||||
|
||||
DeviceBayTemplate(
|
||||
device_type=self.device_type,
|
||||
device_type=device_type,
|
||||
name='Device Bay 1'
|
||||
).save()
|
||||
|
||||
@@ -315,9 +317,9 @@ class DeviceTestCase(TestCase):
|
||||
Ensure that all Device components are copied automatically from the DeviceType.
|
||||
"""
|
||||
d = Device(
|
||||
site=self.site,
|
||||
device_type=self.device_type,
|
||||
device_role=self.device_role,
|
||||
site=Site.objects.first(),
|
||||
device_type=DeviceType.objects.first(),
|
||||
device_role=DeviceRole.objects.first(),
|
||||
name='Test Device 1'
|
||||
)
|
||||
d.save()
|
||||
@@ -381,9 +383,9 @@ class DeviceTestCase(TestCase):
|
||||
def test_multiple_unnamed_devices(self):
|
||||
|
||||
device1 = Device(
|
||||
site=self.site,
|
||||
device_type=self.device_type,
|
||||
device_role=self.device_role,
|
||||
site=Site.objects.first(),
|
||||
device_type=DeviceType.objects.first(),
|
||||
device_role=DeviceRole.objects.first(),
|
||||
name=None
|
||||
)
|
||||
device1.save()
|
||||
@@ -402,9 +404,9 @@ class DeviceTestCase(TestCase):
|
||||
def test_device_name_case_sensitivity(self):
|
||||
|
||||
device1 = Device(
|
||||
site=self.site,
|
||||
device_type=self.device_type,
|
||||
device_role=self.device_role,
|
||||
site=Site.objects.first(),
|
||||
device_type=DeviceType.objects.first(),
|
||||
device_role=DeviceRole.objects.first(),
|
||||
name='device 1'
|
||||
)
|
||||
device1.save()
|
||||
@@ -423,9 +425,9 @@ class DeviceTestCase(TestCase):
|
||||
def test_device_duplicate_names(self):
|
||||
|
||||
device1 = Device(
|
||||
site=self.site,
|
||||
device_type=self.device_type,
|
||||
device_role=self.device_role,
|
||||
site=Site.objects.first(),
|
||||
device_type=DeviceType.objects.first(),
|
||||
device_role=DeviceRole.objects.first(),
|
||||
name='Test Device 1'
|
||||
)
|
||||
device1.save()
|
||||
@@ -459,7 +461,8 @@ class DeviceTestCase(TestCase):
|
||||
|
||||
class CableTestCase(TestCase):
|
||||
|
||||
def setUp(self):
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
|
||||
site = Site.objects.create(name='Test Site 1', slug='test-site-1')
|
||||
manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1')
|
||||
@@ -469,72 +472,76 @@ class CableTestCase(TestCase):
|
||||
devicerole = DeviceRole.objects.create(
|
||||
name='Test Device Role 1', slug='test-device-role-1', color='ff0000'
|
||||
)
|
||||
self.device1 = Device.objects.create(
|
||||
device1 = Device.objects.create(
|
||||
device_type=devicetype, device_role=devicerole, name='TestDevice1', site=site
|
||||
)
|
||||
self.device2 = Device.objects.create(
|
||||
device2 = Device.objects.create(
|
||||
device_type=devicetype, device_role=devicerole, name='TestDevice2', site=site
|
||||
)
|
||||
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.save()
|
||||
interface1 = Interface.objects.create(device=device1, name='eth0')
|
||||
interface2 = Interface.objects.create(device=device2, name='eth0')
|
||||
interface3 = Interface.objects.create(device=device2, name='eth1')
|
||||
Cable(a_terminations=[interface1], b_terminations=[interface2]).save()
|
||||
|
||||
self.power_port1 = PowerPort.objects.create(device=self.device2, name='psu1')
|
||||
self.patch_pannel = Device.objects.create(
|
||||
device_type=devicetype, device_role=devicerole, name='TestPatchPannel', site=site
|
||||
power_port1 = PowerPort.objects.create(device=device2, name='psu1')
|
||||
patch_pannel = Device.objects.create(
|
||||
device_type=devicetype, device_role=devicerole, name='TestPatchPanel', site=site
|
||||
)
|
||||
self.rear_port1 = RearPort.objects.create(device=self.patch_pannel, name='RP1', type='8p8c')
|
||||
self.front_port1 = FrontPort.objects.create(
|
||||
device=self.patch_pannel, name='FP1', type='8p8c', rear_port=self.rear_port1, rear_port_position=1
|
||||
rear_port1 = RearPort.objects.create(device=patch_pannel, name='RP1', type='8p8c')
|
||||
front_port1 = FrontPort.objects.create(
|
||||
device=patch_pannel, name='FP1', type='8p8c', rear_port=rear_port1, rear_port_position=1
|
||||
)
|
||||
self.rear_port2 = RearPort.objects.create(device=self.patch_pannel, name='RP2', type='8p8c', positions=2)
|
||||
self.front_port2 = FrontPort.objects.create(
|
||||
device=self.patch_pannel, name='FP2', type='8p8c', rear_port=self.rear_port2, rear_port_position=1
|
||||
rear_port2 = RearPort.objects.create(device=patch_pannel, name='RP2', type='8p8c', positions=2)
|
||||
front_port2 = FrontPort.objects.create(
|
||||
device=patch_pannel, name='FP2', type='8p8c', rear_port=rear_port2, rear_port_position=1
|
||||
)
|
||||
self.rear_port3 = RearPort.objects.create(device=self.patch_pannel, name='RP3', type='8p8c', positions=3)
|
||||
self.front_port3 = FrontPort.objects.create(
|
||||
device=self.patch_pannel, name='FP3', type='8p8c', rear_port=self.rear_port3, rear_port_position=1
|
||||
rear_port3 = RearPort.objects.create(device=patch_pannel, name='RP3', type='8p8c', positions=3)
|
||||
front_port3 = FrontPort.objects.create(
|
||||
device=patch_pannel, name='FP3', type='8p8c', rear_port=rear_port3, rear_port_position=1
|
||||
)
|
||||
self.rear_port4 = RearPort.objects.create(device=self.patch_pannel, name='RP4', type='8p8c', positions=3)
|
||||
self.front_port4 = FrontPort.objects.create(
|
||||
device=self.patch_pannel, name='FP4', type='8p8c', rear_port=self.rear_port4, rear_port_position=1
|
||||
rear_port4 = RearPort.objects.create(device=patch_pannel, name='RP4', type='8p8c', positions=3)
|
||||
front_port4 = FrontPort.objects.create(
|
||||
device=patch_pannel, name='FP4', type='8p8c', rear_port=rear_port4, rear_port_position=1
|
||||
)
|
||||
self.provider = Provider.objects.create(name='Provider 1', slug='provider-1')
|
||||
provider_network = ProviderNetwork.objects.create(name='Provider Network 1', provider=self.provider)
|
||||
self.circuittype = CircuitType.objects.create(name='Circuit Type 1', slug='circuit-type-1')
|
||||
self.circuit1 = Circuit.objects.create(provider=self.provider, type=self.circuittype, cid='1')
|
||||
self.circuit2 = Circuit.objects.create(provider=self.provider, type=self.circuittype, cid='2')
|
||||
self.circuittermination1 = CircuitTermination.objects.create(circuit=self.circuit1, site=site, term_side='A')
|
||||
self.circuittermination2 = CircuitTermination.objects.create(circuit=self.circuit1, site=site, term_side='Z')
|
||||
self.circuittermination3 = CircuitTermination.objects.create(circuit=self.circuit2, provider_network=provider_network, term_side='A')
|
||||
provider = Provider.objects.create(name='Provider 1', slug='provider-1')
|
||||
provider_network = ProviderNetwork.objects.create(name='Provider Network 1', provider=provider)
|
||||
circuittype = CircuitType.objects.create(name='Circuit Type 1', slug='circuit-type-1')
|
||||
circuit1 = Circuit.objects.create(provider=provider, type=circuittype, cid='1')
|
||||
circuit2 = Circuit.objects.create(provider=provider, type=circuittype, cid='2')
|
||||
circuittermination1 = CircuitTermination.objects.create(circuit=circuit1, site=site, term_side='A')
|
||||
circuittermination2 = CircuitTermination.objects.create(circuit=circuit1, site=site, term_side='Z')
|
||||
circuittermination3 = CircuitTermination.objects.create(circuit=circuit2, provider_network=provider_network, term_side='A')
|
||||
|
||||
def test_cable_creation(self):
|
||||
"""
|
||||
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(device__name='TestDevice1', name='eth0')
|
||||
interface2 = Interface.objects.get(device__name='TestDevice2', name='eth0')
|
||||
cable = Cable.objects.first()
|
||||
self.assertEqual(interface1.cable, cable)
|
||||
self.assertEqual(interface2.cable, cable)
|
||||
self.assertEqual(interface1.cable_end, 'A')
|
||||
self.assertEqual(interface2.cable_end, 'B')
|
||||
self.assertEqual(interface1.link_peers, [interface2])
|
||||
self.assertEqual(interface2.link_peers, [interface1])
|
||||
|
||||
def test_cable_deletion(self):
|
||||
"""
|
||||
When a Cable is deleted, the `cable` field on its termination points must be nullified. The str() method
|
||||
should still return the PK of the string even after being nullified.
|
||||
"""
|
||||
self.cable.delete()
|
||||
self.assertIsNone(self.cable.pk)
|
||||
self.assertNotEqual(str(self.cable), '#None')
|
||||
interface1 = Interface.objects.get(pk=self.interface1.pk)
|
||||
interface1 = Interface.objects.get(device__name='TestDevice1', name='eth0')
|
||||
interface2 = Interface.objects.get(device__name='TestDevice2', name='eth0')
|
||||
cable = Cable.objects.first()
|
||||
|
||||
cable.delete()
|
||||
self.assertIsNone(cable.pk)
|
||||
self.assertNotEqual(str(cable), '#None')
|
||||
interface1 = Interface.objects.get(pk=interface1.pk)
|
||||
self.assertIsNone(interface1.cable)
|
||||
self.assertListEqual(interface1.link_peers, [])
|
||||
interface2 = Interface.objects.get(pk=self.interface2.pk)
|
||||
interface2 = Interface.objects.get(pk=interface2.pk)
|
||||
self.assertIsNone(interface2.cable)
|
||||
self.assertListEqual(interface2.link_peers, [])
|
||||
|
||||
@@ -542,7 +549,10 @@ class CableTestCase(TestCase):
|
||||
"""
|
||||
The clean method should ensure that all terminations at either end of a Cable belong to the same parent object.
|
||||
"""
|
||||
cable = Cable(a_terminations=[self.interface1], b_terminations=[self.power_port1])
|
||||
interface1 = Interface.objects.get(device__name='TestDevice1', name='eth0')
|
||||
powerport1 = PowerPort.objects.get(device__name='TestDevice2', name='psu1')
|
||||
|
||||
cable = Cable(a_terminations=[interface1], b_terminations=[powerport1])
|
||||
with self.assertRaises(ValidationError):
|
||||
cable.clean()
|
||||
|
||||
@@ -550,7 +560,11 @@ class CableTestCase(TestCase):
|
||||
"""
|
||||
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])
|
||||
interface1 = Interface.objects.get(device__name='TestDevice1', name='eth0')
|
||||
frontport1 = FrontPort.objects.get(device__name='TestPatchPanel', name='FP1')
|
||||
rearport1 = RearPort.objects.get(device__name='TestPatchPanel', name='RP1')
|
||||
|
||||
cable = Cable(a_terminations=[frontport1, rearport1], b_terminations=[interface1])
|
||||
with self.assertRaises(ValidationError):
|
||||
cable.clean()
|
||||
|
||||
@@ -558,8 +572,11 @@ class CableTestCase(TestCase):
|
||||
"""
|
||||
The clean method should have a check to ensure only compatible port types can be connected by a cable
|
||||
"""
|
||||
interface1 = Interface.objects.get(device__name='TestDevice1', name='eth0')
|
||||
powerport1 = PowerPort.objects.get(device__name='TestDevice2', name='psu1')
|
||||
|
||||
# An interface cannot be connected to a power port, for example
|
||||
cable = Cable(a_terminations=[self.interface1], b_terminations=[self.power_port1])
|
||||
cable = Cable(a_terminations=[interface1], b_terminations=[powerport1])
|
||||
with self.assertRaises(ValidationError):
|
||||
cable.clean()
|
||||
|
||||
@@ -567,7 +584,10 @@ 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])
|
||||
interface3 = Interface.objects.get(device__name='TestDevice2', name='eth1')
|
||||
circuittermination3 = CircuitTermination.objects.get(circuit__cid='2', term_side='A')
|
||||
|
||||
cable = Cable(a_terminations=[interface3], b_terminations=[circuittermination3])
|
||||
with self.assertRaises(ValidationError):
|
||||
cable.clean()
|
||||
|
||||
@@ -575,8 +595,11 @@ class CableTestCase(TestCase):
|
||||
"""
|
||||
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])
|
||||
device1 = Device.objects.get(name='TestDevice1')
|
||||
interface2 = Interface.objects.get(device__name='TestDevice2', name='eth0')
|
||||
|
||||
virtual_interface = Interface(device=device1, name="V1", type=InterfaceTypeChoices.TYPE_VIRTUAL)
|
||||
cable = Cable(a_terminations=[interface2], b_terminations=[virtual_interface])
|
||||
with self.assertRaises(ValidationError):
|
||||
cable.clean()
|
||||
|
||||
@@ -584,15 +607,19 @@ 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])
|
||||
device1 = Device.objects.get(name='TestDevice1')
|
||||
interface2 = Interface.objects.get(device__name='TestDevice2', name='eth0')
|
||||
|
||||
wireless_interface = Interface(device=device1, name="W1", type=InterfaceTypeChoices.TYPE_80211A)
|
||||
cable = Cable(a_terminations=[interface2], b_terminations=[wireless_interface])
|
||||
with self.assertRaises(ValidationError):
|
||||
cable.clean()
|
||||
|
||||
|
||||
class VirtualDeviceContextTestCase(TestCase):
|
||||
|
||||
def setUp(self):
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
|
||||
site = Site.objects.create(name='Test Site 1', slug='test-site-1')
|
||||
manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1')
|
||||
@@ -602,36 +629,41 @@ class VirtualDeviceContextTestCase(TestCase):
|
||||
devicerole = DeviceRole.objects.create(
|
||||
name='Test Device Role 1', slug='test-device-role-1', color='ff0000'
|
||||
)
|
||||
self.device = Device.objects.create(
|
||||
Device.objects.create(
|
||||
device_type=devicetype, device_role=devicerole, name='TestDevice1', site=site
|
||||
)
|
||||
|
||||
def test_vdc_and_interface_creation(self):
|
||||
device = Device.objects.first()
|
||||
|
||||
vdc = VirtualDeviceContext(device=self.device, name="VDC 1", identifier=1, status='active')
|
||||
vdc = VirtualDeviceContext(device=device, name="VDC 1", identifier=1, status='active')
|
||||
vdc.full_clean()
|
||||
vdc.save()
|
||||
|
||||
interface = Interface(device=self.device, name='Eth1/1', type='10gbase-t')
|
||||
interface = Interface(device=device, name='Eth1/1', type='10gbase-t')
|
||||
interface.full_clean()
|
||||
interface.save()
|
||||
|
||||
interface.vdcs.set([vdc])
|
||||
|
||||
def test_vdc_duplicate_name(self):
|
||||
vdc1 = VirtualDeviceContext(device=self.device, name="VDC 1", identifier=1, status='active')
|
||||
device = Device.objects.first()
|
||||
|
||||
vdc1 = VirtualDeviceContext(device=device, name="VDC 1", identifier=1, status='active')
|
||||
vdc1.full_clean()
|
||||
vdc1.save()
|
||||
|
||||
vdc2 = VirtualDeviceContext(device=self.device, name="VDC 1", identifier=2, status='active')
|
||||
vdc2 = VirtualDeviceContext(device=device, name="VDC 1", identifier=2, status='active')
|
||||
with self.assertRaises(ValidationError):
|
||||
vdc2.full_clean()
|
||||
|
||||
def test_vdc_duplicate_identifier(self):
|
||||
vdc1 = VirtualDeviceContext(device=self.device, name="VDC 1", identifier=1, status='active')
|
||||
device = Device.objects.first()
|
||||
|
||||
vdc1 = VirtualDeviceContext(device=device, name="VDC 1", identifier=1, status='active')
|
||||
vdc1.full_clean()
|
||||
vdc1.save()
|
||||
|
||||
vdc2 = VirtualDeviceContext(device=self.device, name="VDC 2", identifier=1, status='active')
|
||||
vdc2 = VirtualDeviceContext(device=device, name="VDC 2", identifier=1, status='active')
|
||||
with self.assertRaises(ValidationError):
|
||||
vdc2.full_clean()
|
||||
|
||||
@@ -5,7 +5,8 @@ from dcim.models import Device, DeviceRole, DeviceType, Interface, Manufacturer,
|
||||
|
||||
class NaturalOrderingTestCase(TestCase):
|
||||
|
||||
def setUp(self):
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
|
||||
site = Site.objects.create(name='Test Site 1', slug='test-site-1')
|
||||
manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1')
|
||||
@@ -15,12 +16,12 @@ class NaturalOrderingTestCase(TestCase):
|
||||
devicerole = DeviceRole.objects.create(
|
||||
name='Test Device Role 1', slug='test-device-role-1', color='ff0000'
|
||||
)
|
||||
self.device = Device.objects.create(
|
||||
Device.objects.create(
|
||||
device_type=devicetype, device_role=devicerole, name='Test Device 1', site=site
|
||||
)
|
||||
|
||||
def test_interface_ordering_numeric(self):
|
||||
|
||||
device = Device.objects.first()
|
||||
INTERFACES = [
|
||||
'0',
|
||||
'0.0',
|
||||
@@ -57,16 +58,16 @@ class NaturalOrderingTestCase(TestCase):
|
||||
]
|
||||
|
||||
for name in INTERFACES:
|
||||
iface = Interface(device=self.device, name=name)
|
||||
iface = Interface(device=device, name=name)
|
||||
iface.save()
|
||||
|
||||
self.assertListEqual(
|
||||
list(Interface.objects.filter(device=self.device).values_list('name', flat=True)),
|
||||
list(Interface.objects.filter(device=device).values_list('name', flat=True)),
|
||||
INTERFACES
|
||||
)
|
||||
|
||||
def test_interface_ordering_linux(self):
|
||||
|
||||
device = Device.objects.first()
|
||||
INTERFACES = [
|
||||
'eth0',
|
||||
'eth0.1',
|
||||
@@ -81,16 +82,16 @@ class NaturalOrderingTestCase(TestCase):
|
||||
]
|
||||
|
||||
for name in INTERFACES:
|
||||
iface = Interface(device=self.device, name=name)
|
||||
iface = Interface(device=device, name=name)
|
||||
iface.save()
|
||||
|
||||
self.assertListEqual(
|
||||
list(Interface.objects.filter(device=self.device).values_list('name', flat=True)),
|
||||
list(Interface.objects.filter(device=device).values_list('name', flat=True)),
|
||||
INTERFACES
|
||||
)
|
||||
|
||||
def test_interface_ordering_junos(self):
|
||||
|
||||
device = Device.objects.first()
|
||||
INTERFACES = [
|
||||
'xe-0/0/0',
|
||||
'xe-0/0/1',
|
||||
@@ -134,16 +135,16 @@ class NaturalOrderingTestCase(TestCase):
|
||||
]
|
||||
|
||||
for name in INTERFACES:
|
||||
iface = Interface(device=self.device, name=name)
|
||||
iface = Interface(device=device, name=name)
|
||||
iface.save()
|
||||
|
||||
self.assertListEqual(
|
||||
list(Interface.objects.filter(device=self.device).values_list('name', flat=True)),
|
||||
list(Interface.objects.filter(device=device).values_list('name', flat=True)),
|
||||
INTERFACES
|
||||
)
|
||||
|
||||
def test_interface_ordering_ios(self):
|
||||
|
||||
device = Device.objects.first()
|
||||
INTERFACES = [
|
||||
'GigabitEthernet0/1',
|
||||
'GigabitEthernet0/2',
|
||||
@@ -161,10 +162,10 @@ class NaturalOrderingTestCase(TestCase):
|
||||
]
|
||||
|
||||
for name in INTERFACES:
|
||||
iface = Interface(device=self.device, name=name)
|
||||
iface = Interface(device=device, name=name)
|
||||
iface.save()
|
||||
|
||||
self.assertListEqual(
|
||||
list(Interface.objects.filter(device=self.device).values_list('name', flat=True)),
|
||||
list(Interface.objects.filter(device=device).values_list('name', flat=True)),
|
||||
INTERFACES
|
||||
)
|
||||
|
||||
@@ -17,6 +17,7 @@ from dcim.constants import *
|
||||
from dcim.models import *
|
||||
from ipam.models import ASN, RIR, VLAN, VRF
|
||||
from tenancy.models import Tenant
|
||||
from utilities.choices import ImportFormatChoices
|
||||
from utilities.testing import ViewTestCases, create_tags, create_test_device, post_data
|
||||
from wireless.models import WirelessLAN
|
||||
|
||||
@@ -388,15 +389,18 @@ class RackTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
||||
'outer_width': 500,
|
||||
'outer_depth': 500,
|
||||
'outer_unit': RackDimensionUnitChoices.UNIT_MILLIMETER,
|
||||
'weight': 100,
|
||||
'max_weight': 2000,
|
||||
'weight_unit': WeightUnitChoices.UNIT_POUND,
|
||||
'comments': 'Some comments',
|
||||
'tags': [t.pk for t in tags],
|
||||
}
|
||||
|
||||
cls.csv_data = (
|
||||
"site,location,name,status,width,u_height",
|
||||
"Site 1,,Rack 4,active,19,42",
|
||||
"Site 1,Location 1,Rack 5,active,19,42",
|
||||
"Site 2,Location 2,Rack 6,active,19,42",
|
||||
"site,location,name,status,width,u_height,weight,max_weight,weight_unit",
|
||||
"Site 1,,Rack 4,active,19,42,100,2000,kg",
|
||||
"Site 1,Location 1,Rack 5,active,19,42,100,2000,kg",
|
||||
"Site 2,Location 2,Rack 6,active,19,42,100,2000,kg",
|
||||
)
|
||||
|
||||
cls.csv_update_data = (
|
||||
@@ -420,6 +424,9 @@ class RackTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
||||
'outer_width': 30,
|
||||
'outer_depth': 30,
|
||||
'outer_unit': RackDimensionUnitChoices.UNIT_INCH,
|
||||
'weight': 200,
|
||||
'max_weight': 4000,
|
||||
'weight_unit': WeightUnitChoices.UNIT_POUND,
|
||||
'comments': 'New comments',
|
||||
}
|
||||
|
||||
@@ -1887,26 +1894,28 @@ class ModuleTestCase(
|
||||
'device': devices[0].pk,
|
||||
'module_bay': module_bays[3].pk,
|
||||
'module_type': module_types[0].pk,
|
||||
'status': ModuleStatusChoices.STATUS_ACTIVE,
|
||||
'serial': 'A',
|
||||
'tags': [t.pk for t in tags],
|
||||
}
|
||||
|
||||
cls.bulk_edit_data = {
|
||||
'module_type': module_types[3].pk,
|
||||
'status': ModuleStatusChoices.STATUS_PLANNED,
|
||||
}
|
||||
|
||||
cls.csv_data = (
|
||||
"device,module_bay,module_type,serial,asset_tag",
|
||||
"Device 2,Module Bay 1,Module Type 1,A,A",
|
||||
"Device 2,Module Bay 2,Module Type 2,B,B",
|
||||
"Device 2,Module Bay 3,Module Type 3,C,C",
|
||||
"device,module_bay,module_type,status,serial,asset_tag",
|
||||
"Device 2,Module Bay 1,Module Type 1,active,A,A",
|
||||
"Device 2,Module Bay 2,Module Type 2,planned,B,B",
|
||||
"Device 2,Module Bay 3,Module Type 3,failed,C,C",
|
||||
)
|
||||
|
||||
cls.csv_update_data = (
|
||||
"id,serial",
|
||||
f"{modules[0].pk},Serial 2",
|
||||
f"{modules[1].pk},Serial 3",
|
||||
f"{modules[2].pk},Serial 1",
|
||||
"id,status,serial",
|
||||
f"{modules[0].pk},offline,Serial 2",
|
||||
f"{modules[1].pk},offline,Serial 3",
|
||||
f"{modules[2].pk},offline,Serial 1",
|
||||
)
|
||||
|
||||
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
|
||||
@@ -1942,6 +1951,54 @@ class ModuleTestCase(
|
||||
self.assertHttpStatus(self.client.post(**request), 302)
|
||||
self.assertEqual(Interface.objects.filter(device=device).count(), 5)
|
||||
|
||||
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
|
||||
def test_module_bulk_replication(self):
|
||||
self.add_permissions('dcim.add_module')
|
||||
|
||||
# Add 5 InterfaceTemplates to a ModuleType
|
||||
module_type = ModuleType.objects.first()
|
||||
interface_templates = [
|
||||
InterfaceTemplate(module_type=module_type, name=f'Interface {i}')
|
||||
for i in range(1, 6)
|
||||
]
|
||||
InterfaceTemplate.objects.bulk_create(interface_templates)
|
||||
|
||||
# Create a module *without* replicating components
|
||||
device = Device.objects.get(name='Device 2')
|
||||
module_bay = ModuleBay.objects.get(device=device, name='Module Bay 4')
|
||||
csv_data = [
|
||||
"device,module_bay,module_type,status,replicate_components",
|
||||
f"{device.name},{module_bay.name},{module_type.model},active,false"
|
||||
]
|
||||
request = {
|
||||
'path': self._get_url('import'),
|
||||
'data': {
|
||||
'data': '\n'.join(csv_data),
|
||||
'format': ImportFormatChoices.CSV,
|
||||
}
|
||||
}
|
||||
|
||||
initial_count = Module.objects.count()
|
||||
self.assertHttpStatus(self.client.post(**request), 200)
|
||||
self.assertEqual(Module.objects.count(), initial_count + len(csv_data) - 1)
|
||||
self.assertEqual(Interface.objects.filter(device=device).count(), 0)
|
||||
|
||||
# Create a second module (in the next bay) with replicated components
|
||||
module_bay = ModuleBay.objects.get(device=device, name='Module Bay 5')
|
||||
csv_data[1] = f"{device.name},{module_bay.name},{module_type.model},active,true"
|
||||
request = {
|
||||
'path': self._get_url('import'),
|
||||
'data': {
|
||||
'data': '\n'.join(csv_data),
|
||||
'format': ImportFormatChoices.CSV,
|
||||
}
|
||||
}
|
||||
|
||||
initial_count = Module.objects.count()
|
||||
self.assertHttpStatus(self.client.post(**request), 200)
|
||||
self.assertEqual(Module.objects.count(), initial_count + len(csv_data) - 1)
|
||||
self.assertEqual(Interface.objects.filter(device=device).count(), 5)
|
||||
|
||||
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
|
||||
def test_module_component_adoption(self):
|
||||
self.add_permissions('dcim.add_module')
|
||||
@@ -1979,6 +2036,50 @@ class ModuleTestCase(
|
||||
# Check that the Interface now has a module
|
||||
self.assertIsNotNone(interface.module)
|
||||
|
||||
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
|
||||
def test_module_bulk_adoption(self):
|
||||
self.add_permissions('dcim.add_module')
|
||||
|
||||
interface_name = "Interface-1"
|
||||
|
||||
# Add an interface to the ModuleType
|
||||
module_type = ModuleType.objects.first()
|
||||
InterfaceTemplate(module_type=module_type, name=interface_name).save()
|
||||
|
||||
form_data = self.form_data.copy()
|
||||
device = Device.objects.get(pk=form_data['device'])
|
||||
|
||||
# Create an interface to be adopted
|
||||
interface = Interface(device=device, name=interface_name, type=InterfaceTypeChoices.TYPE_10GE_FIXED)
|
||||
interface.save()
|
||||
|
||||
# Ensure that interface is created with no module
|
||||
self.assertIsNone(interface.module)
|
||||
|
||||
# Create a module with adopted components
|
||||
module_bay = ModuleBay.objects.get(device=device, name='Module Bay 4')
|
||||
csv_data = [
|
||||
"device,module_bay,module_type,status,replicate_components,adopt_components",
|
||||
f"{device.name},{module_bay.name},{module_type.model},active,false,true"
|
||||
]
|
||||
request = {
|
||||
'path': self._get_url('import'),
|
||||
'data': {
|
||||
'data': '\n'.join(csv_data),
|
||||
'format': ImportFormatChoices.CSV,
|
||||
}
|
||||
}
|
||||
|
||||
initial_count = self._get_queryset().count()
|
||||
self.assertHttpStatus(self.client.post(**request), 200)
|
||||
self.assertEqual(self._get_queryset().count(), initial_count + len(csv_data) - 1)
|
||||
|
||||
# Re-retrieve interface to get new module id
|
||||
interface.refresh_from_db()
|
||||
|
||||
# Check that the Interface now has a module
|
||||
self.assertIsNotNone(interface.module)
|
||||
|
||||
|
||||
class ConsolePortTestCase(ViewTestCases.DeviceComponentViewTestCase):
|
||||
model = ConsolePort
|
||||
|
||||
@@ -391,7 +391,7 @@ class SiteView(generic.ObjectView):
|
||||
scope_id=instance.pk
|
||||
).count(),
|
||||
'vlan_count': VLAN.objects.restrict(request.user, 'view').filter(site=instance).count(),
|
||||
'circuit_count': Circuit.objects.restrict(request.user, 'view').filter(terminations__site=instance).count(),
|
||||
'circuit_count': Circuit.objects.restrict(request.user, 'view').filter(terminations__site=instance).distinct().count(),
|
||||
'vm_count': VirtualMachine.objects.restrict(request.user, 'view').filter(cluster__site=instance).count(),
|
||||
}
|
||||
locations = Location.objects.add_related_count(
|
||||
@@ -653,17 +653,18 @@ class RackElevationListView(generic.ObjectListView):
|
||||
racks = filtersets.RackFilterSet(request.GET, self.queryset).qs
|
||||
total_count = racks.count()
|
||||
|
||||
# Ordering
|
||||
ORDERING_CHOICES = {
|
||||
'name': 'Name (A-Z)',
|
||||
'-name': 'Name (Z-A)',
|
||||
'facility_id': 'Facility ID (A-Z)',
|
||||
'-facility_id': 'Facility ID (Z-A)',
|
||||
}
|
||||
sort = request.GET.get('sort', "name")
|
||||
sort = request.GET.get('sort', 'name')
|
||||
if sort not in ORDERING_CHOICES:
|
||||
sort = 'name'
|
||||
|
||||
racks = racks.order_by(sort)
|
||||
sort_field = sort.replace("name", "_name") # Use natural ordering
|
||||
racks = racks.order_by(sort_field)
|
||||
|
||||
# Pagination
|
||||
per_page = get_paginate_count(request)
|
||||
@@ -952,6 +953,7 @@ class DeviceTypeConsolePortsView(DeviceTypeComponentsView):
|
||||
label=_('Console Ports'),
|
||||
badge=lambda obj: obj.consoleporttemplates.count(),
|
||||
permission='dcim.view_consoleporttemplate',
|
||||
weight=550,
|
||||
hide_if_empty=True
|
||||
)
|
||||
|
||||
@@ -966,6 +968,7 @@ class DeviceTypeConsoleServerPortsView(DeviceTypeComponentsView):
|
||||
label=_('Console Server Ports'),
|
||||
badge=lambda obj: obj.consoleserverporttemplates.count(),
|
||||
permission='dcim.view_consoleserverporttemplate',
|
||||
weight=560,
|
||||
hide_if_empty=True
|
||||
)
|
||||
|
||||
@@ -980,6 +983,7 @@ class DeviceTypePowerPortsView(DeviceTypeComponentsView):
|
||||
label=_('Power Ports'),
|
||||
badge=lambda obj: obj.powerporttemplates.count(),
|
||||
permission='dcim.view_powerporttemplate',
|
||||
weight=570,
|
||||
hide_if_empty=True
|
||||
)
|
||||
|
||||
@@ -994,6 +998,7 @@ class DeviceTypePowerOutletsView(DeviceTypeComponentsView):
|
||||
label=_('Power Outlets'),
|
||||
badge=lambda obj: obj.poweroutlettemplates.count(),
|
||||
permission='dcim.view_poweroutlettemplate',
|
||||
weight=580,
|
||||
hide_if_empty=True
|
||||
)
|
||||
|
||||
@@ -1008,6 +1013,7 @@ class DeviceTypeInterfacesView(DeviceTypeComponentsView):
|
||||
label=_('Interfaces'),
|
||||
badge=lambda obj: obj.interfacetemplates.count(),
|
||||
permission='dcim.view_interfacetemplate',
|
||||
weight=520,
|
||||
hide_if_empty=True
|
||||
)
|
||||
|
||||
@@ -1022,6 +1028,7 @@ class DeviceTypeFrontPortsView(DeviceTypeComponentsView):
|
||||
label=_('Front Ports'),
|
||||
badge=lambda obj: obj.frontporttemplates.count(),
|
||||
permission='dcim.view_frontporttemplate',
|
||||
weight=530,
|
||||
hide_if_empty=True
|
||||
)
|
||||
|
||||
@@ -1036,6 +1043,7 @@ class DeviceTypeRearPortsView(DeviceTypeComponentsView):
|
||||
label=_('Rear Ports'),
|
||||
badge=lambda obj: obj.rearporttemplates.count(),
|
||||
permission='dcim.view_rearporttemplate',
|
||||
weight=540,
|
||||
hide_if_empty=True
|
||||
)
|
||||
|
||||
@@ -1050,6 +1058,7 @@ class DeviceTypeModuleBaysView(DeviceTypeComponentsView):
|
||||
label=_('Module Bays'),
|
||||
badge=lambda obj: obj.modulebaytemplates.count(),
|
||||
permission='dcim.view_modulebaytemplate',
|
||||
weight=510,
|
||||
hide_if_empty=True
|
||||
)
|
||||
|
||||
@@ -1064,6 +1073,7 @@ class DeviceTypeDeviceBaysView(DeviceTypeComponentsView):
|
||||
label=_('Device Bays'),
|
||||
badge=lambda obj: obj.devicebaytemplates.count(),
|
||||
permission='dcim.view_devicebaytemplate',
|
||||
weight=500,
|
||||
hide_if_empty=True
|
||||
)
|
||||
|
||||
@@ -1078,6 +1088,7 @@ class DeviceTypeInventoryItemsView(DeviceTypeComponentsView):
|
||||
label=_('Inventory Items'),
|
||||
badge=lambda obj: obj.inventoryitemtemplates.count(),
|
||||
permission='dcim.view_invenotryitemtemplate',
|
||||
weight=590,
|
||||
hide_if_empty=True
|
||||
)
|
||||
|
||||
@@ -1180,6 +1191,7 @@ class ModuleTypeConsolePortsView(ModuleTypeComponentsView):
|
||||
label=_('Console Ports'),
|
||||
badge=lambda obj: obj.consoleporttemplates.count(),
|
||||
permission='dcim.view_consoleporttemplate',
|
||||
weight=530,
|
||||
hide_if_empty=True
|
||||
)
|
||||
|
||||
@@ -1194,6 +1206,7 @@ class ModuleTypeConsoleServerPortsView(ModuleTypeComponentsView):
|
||||
label=_('Console Server Ports'),
|
||||
badge=lambda obj: obj.consoleserverporttemplates.count(),
|
||||
permission='dcim.view_consoleserverporttemplate',
|
||||
weight=540,
|
||||
hide_if_empty=True
|
||||
)
|
||||
|
||||
@@ -1208,6 +1221,7 @@ class ModuleTypePowerPortsView(ModuleTypeComponentsView):
|
||||
label=_('Power Ports'),
|
||||
badge=lambda obj: obj.powerporttemplates.count(),
|
||||
permission='dcim.view_powerporttemplate',
|
||||
weight=550,
|
||||
hide_if_empty=True
|
||||
)
|
||||
|
||||
@@ -1222,6 +1236,7 @@ class ModuleTypePowerOutletsView(ModuleTypeComponentsView):
|
||||
label=_('Power Outlets'),
|
||||
badge=lambda obj: obj.poweroutlettemplates.count(),
|
||||
permission='dcim.view_poweroutlettemplate',
|
||||
weight=560,
|
||||
hide_if_empty=True
|
||||
)
|
||||
|
||||
@@ -1236,6 +1251,7 @@ class ModuleTypeInterfacesView(ModuleTypeComponentsView):
|
||||
label=_('Interfaces'),
|
||||
badge=lambda obj: obj.interfacetemplates.count(),
|
||||
permission='dcim.view_interfacetemplate',
|
||||
weight=500,
|
||||
hide_if_empty=True
|
||||
)
|
||||
|
||||
@@ -1250,6 +1266,7 @@ class ModuleTypeFrontPortsView(ModuleTypeComponentsView):
|
||||
label=_('Front Ports'),
|
||||
badge=lambda obj: obj.frontporttemplates.count(),
|
||||
permission='dcim.view_frontporttemplate',
|
||||
weight=510,
|
||||
hide_if_empty=True
|
||||
)
|
||||
|
||||
@@ -1264,6 +1281,7 @@ class ModuleTypeRearPortsView(ModuleTypeComponentsView):
|
||||
label=_('Rear Ports'),
|
||||
badge=lambda obj: obj.rearporttemplates.count(),
|
||||
permission='dcim.view_rearporttemplate',
|
||||
weight=520,
|
||||
hide_if_empty=True
|
||||
)
|
||||
|
||||
@@ -1872,6 +1890,7 @@ class DeviceConsolePortsView(DeviceComponentsView):
|
||||
label=_('Console Ports'),
|
||||
badge=lambda obj: obj.consoleports.count(),
|
||||
permission='dcim.view_consoleport',
|
||||
weight=550,
|
||||
hide_if_empty=True
|
||||
)
|
||||
|
||||
@@ -1886,6 +1905,7 @@ class DeviceConsoleServerPortsView(DeviceComponentsView):
|
||||
label=_('Console Server Ports'),
|
||||
badge=lambda obj: obj.consoleserverports.count(),
|
||||
permission='dcim.view_consoleserverport',
|
||||
weight=560,
|
||||
hide_if_empty=True
|
||||
)
|
||||
|
||||
@@ -1900,6 +1920,7 @@ class DevicePowerPortsView(DeviceComponentsView):
|
||||
label=_('Power Ports'),
|
||||
badge=lambda obj: obj.powerports.count(),
|
||||
permission='dcim.view_powerport',
|
||||
weight=570,
|
||||
hide_if_empty=True
|
||||
)
|
||||
|
||||
@@ -1914,6 +1935,7 @@ class DevicePowerOutletsView(DeviceComponentsView):
|
||||
label=_('Power Outlets'),
|
||||
badge=lambda obj: obj.poweroutlets.count(),
|
||||
permission='dcim.view_poweroutlet',
|
||||
weight=580,
|
||||
hide_if_empty=True
|
||||
)
|
||||
|
||||
@@ -1928,6 +1950,7 @@ class DeviceInterfacesView(DeviceComponentsView):
|
||||
label=_('Interfaces'),
|
||||
badge=lambda obj: obj.interfaces.count(),
|
||||
permission='dcim.view_interface',
|
||||
weight=520,
|
||||
hide_if_empty=True
|
||||
)
|
||||
|
||||
@@ -1948,6 +1971,7 @@ class DeviceFrontPortsView(DeviceComponentsView):
|
||||
label=_('Front Ports'),
|
||||
badge=lambda obj: obj.frontports.count(),
|
||||
permission='dcim.view_frontport',
|
||||
weight=530,
|
||||
hide_if_empty=True
|
||||
)
|
||||
|
||||
@@ -1962,6 +1986,7 @@ class DeviceRearPortsView(DeviceComponentsView):
|
||||
label=_('Rear Ports'),
|
||||
badge=lambda obj: obj.rearports.count(),
|
||||
permission='dcim.view_rearport',
|
||||
weight=540,
|
||||
hide_if_empty=True
|
||||
)
|
||||
|
||||
@@ -1976,6 +2001,7 @@ class DeviceModuleBaysView(DeviceComponentsView):
|
||||
label=_('Module Bays'),
|
||||
badge=lambda obj: obj.modulebays.count(),
|
||||
permission='dcim.view_modulebay',
|
||||
weight=510,
|
||||
hide_if_empty=True
|
||||
)
|
||||
|
||||
@@ -1990,6 +2016,7 @@ class DeviceDeviceBaysView(DeviceComponentsView):
|
||||
label=_('Device Bays'),
|
||||
badge=lambda obj: obj.devicebays.count(),
|
||||
permission='dcim.view_devicebay',
|
||||
weight=500,
|
||||
hide_if_empty=True
|
||||
)
|
||||
|
||||
@@ -2004,6 +2031,7 @@ class DeviceInventoryView(DeviceComponentsView):
|
||||
label=_('Inventory Items'),
|
||||
badge=lambda obj: obj.inventoryitems.count(),
|
||||
permission='dcim.view_inventoryitem',
|
||||
weight=590,
|
||||
hide_if_empty=True
|
||||
)
|
||||
|
||||
@@ -2014,7 +2042,8 @@ class DeviceConfigContextView(ObjectConfigContextView):
|
||||
base_template = 'dcim/device/base.html'
|
||||
tab = ViewTab(
|
||||
label=_('Config Context'),
|
||||
permission='extras.view_configcontext'
|
||||
permission='extras.view_configcontext',
|
||||
weight=2000
|
||||
)
|
||||
|
||||
|
||||
@@ -2072,6 +2101,7 @@ class NAPALMViewTab(ViewTab):
|
||||
if not (
|
||||
instance.status == 'active' and
|
||||
instance.primary_ip and
|
||||
instance.platform and
|
||||
instance.platform.napalm_driver
|
||||
):
|
||||
return None
|
||||
@@ -2086,6 +2116,7 @@ class DeviceStatusView(generic.ObjectView):
|
||||
tab = NAPALMViewTab(
|
||||
label=_('Status'),
|
||||
permission='dcim.napalm_read_device',
|
||||
weight=3000
|
||||
)
|
||||
|
||||
|
||||
@@ -2097,6 +2128,7 @@ class DeviceLLDPNeighborsView(generic.ObjectView):
|
||||
tab = NAPALMViewTab(
|
||||
label=_('LLDP Neighbors'),
|
||||
permission='dcim.napalm_read_device',
|
||||
weight=3100
|
||||
)
|
||||
|
||||
def get_extra_context(self, request, instance):
|
||||
@@ -2119,6 +2151,7 @@ class DeviceConfigView(generic.ObjectView):
|
||||
tab = NAPALMViewTab(
|
||||
label=_('Config'),
|
||||
permission='dcim.napalm_read_device',
|
||||
weight=3200
|
||||
)
|
||||
|
||||
|
||||
@@ -3578,7 +3611,9 @@ register_model_view(PowerFeed, 'trace', kwargs={'model': PowerFeed})(PathTraceVi
|
||||
|
||||
# VDC
|
||||
class VirtualDeviceContextListView(generic.ObjectListView):
|
||||
queryset = VirtualDeviceContext.objects.all()
|
||||
queryset = VirtualDeviceContext.objects.annotate(
|
||||
interface_count=count_related(Interface, 'vdcs'),
|
||||
)
|
||||
filterset = filtersets.VirtualDeviceContextFilterSet
|
||||
filterset_form = forms.VirtualDeviceContextFilterForm
|
||||
table = tables.VirtualDeviceContextTable
|
||||
@@ -3591,6 +3626,7 @@ class VirtualDeviceContextView(generic.ObjectView):
|
||||
def get_extra_context(self, request, instance):
|
||||
interfaces_table = tables.InterfaceTable(instance.interfaces, user=request.user)
|
||||
interfaces_table.configure(request)
|
||||
interfaces_table.columns.hide('device')
|
||||
|
||||
return {
|
||||
'interfaces_table': interfaces_table,
|
||||
|
||||
@@ -5,6 +5,7 @@ from rest_framework.serializers import ValidationError
|
||||
from extras.choices import CustomFieldTypeChoices
|
||||
from extras.models import CustomField
|
||||
from netbox.constants import NESTED_SERIALIZER_PREFIX
|
||||
from utilities.api import get_serializer_for_model
|
||||
|
||||
|
||||
#
|
||||
@@ -69,6 +70,23 @@ class CustomFieldsDataField(Field):
|
||||
"values."
|
||||
)
|
||||
|
||||
# Serialize object and multi-object values
|
||||
for cf in self._get_custom_fields():
|
||||
if cf.name in data and data[cf.name] not in (None, []) and cf.type in (
|
||||
CustomFieldTypeChoices.TYPE_OBJECT,
|
||||
CustomFieldTypeChoices.TYPE_MULTIOBJECT
|
||||
):
|
||||
serializer_class = get_serializer_for_model(
|
||||
model=cf.object_type.model_class(),
|
||||
prefix=NESTED_SERIALIZER_PREFIX
|
||||
)
|
||||
many = cf.type == CustomFieldTypeChoices.TYPE_MULTIOBJECT
|
||||
serializer = serializer_class(data=data[cf.name], many=many, context=self.parent.context)
|
||||
if serializer.is_valid():
|
||||
data[cf.name] = [obj['id'] for obj in serializer.data] if many else serializer.data['id']
|
||||
else:
|
||||
raise ValidationError(f"Unknown related object(s): {data[cf.name]}")
|
||||
|
||||
# If updating an existing instance, start with existing custom_field_data
|
||||
if self.parent.instance:
|
||||
data = {**self.parent.instance.custom_field_data, **data}
|
||||
|
||||
@@ -385,8 +385,8 @@ class JobResultSerializer(BaseModelSerializer):
|
||||
class Meta:
|
||||
model = JobResult
|
||||
fields = [
|
||||
'id', 'url', 'display', 'status', 'created', 'scheduled', 'started', 'completed', 'name', 'obj_type',
|
||||
'user', 'data', 'job_id',
|
||||
'id', 'url', 'display', 'status', 'created', 'scheduled', 'interval', 'started', 'completed', 'name',
|
||||
'obj_type', 'user', 'data', 'job_id',
|
||||
]
|
||||
|
||||
|
||||
@@ -414,6 +414,7 @@ class ReportDetailSerializer(ReportSerializer):
|
||||
|
||||
class ReportInputSerializer(serializers.Serializer):
|
||||
schedule_at = serializers.DateTimeField(required=False, allow_null=True)
|
||||
interval = serializers.IntegerField(required=False, allow_null=True)
|
||||
|
||||
|
||||
#
|
||||
@@ -448,6 +449,7 @@ class ScriptInputSerializer(serializers.Serializer):
|
||||
data = serializers.JSONField()
|
||||
commit = serializers.BooleanField()
|
||||
schedule_at = serializers.DateTimeField(required=False, allow_null=True)
|
||||
interval = serializers.IntegerField(required=False, allow_null=True)
|
||||
|
||||
|
||||
class ScriptLogMessageSerializer(serializers.Serializer):
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.db.models import Q
|
||||
from django.http import Http404
|
||||
from django_rq.queues import get_connection
|
||||
from rest_framework import status
|
||||
@@ -246,16 +245,14 @@ class ReportViewSet(ViewSet):
|
||||
input_serializer = serializers.ReportInputSerializer(data=request.data)
|
||||
|
||||
if input_serializer.is_valid():
|
||||
schedule_at = input_serializer.validated_data.get('schedule_at')
|
||||
|
||||
report_content_type = ContentType.objects.get(app_label='extras', model='report')
|
||||
job_result = JobResult.enqueue_job(
|
||||
run_report,
|
||||
report.full_name,
|
||||
report_content_type,
|
||||
request.user,
|
||||
name=report.full_name,
|
||||
obj_type=ContentType.objects.get_for_model(Report),
|
||||
user=request.user,
|
||||
job_timeout=report.job_timeout,
|
||||
schedule_at=schedule_at,
|
||||
schedule_at=input_serializer.validated_data.get('schedule_at'),
|
||||
interval=input_serializer.validated_data.get('interval')
|
||||
)
|
||||
report.result = job_result
|
||||
|
||||
@@ -329,21 +326,17 @@ class ScriptViewSet(ViewSet):
|
||||
raise RQWorkerNotRunningException()
|
||||
|
||||
if input_serializer.is_valid():
|
||||
data = input_serializer.data['data']
|
||||
commit = input_serializer.data['commit']
|
||||
schedule_at = input_serializer.validated_data.get('schedule_at')
|
||||
|
||||
script_content_type = ContentType.objects.get(app_label='extras', model='script')
|
||||
job_result = JobResult.enqueue_job(
|
||||
run_script,
|
||||
script.full_name,
|
||||
script_content_type,
|
||||
request.user,
|
||||
data=data,
|
||||
name=script.full_name,
|
||||
obj_type=ContentType.objects.get_for_model(Script),
|
||||
user=request.user,
|
||||
data=input_serializer.data['data'],
|
||||
request=copy_safe_request(request),
|
||||
commit=commit,
|
||||
commit=input_serializer.data['commit'],
|
||||
job_timeout=script.job_timeout,
|
||||
schedule_at=schedule_at,
|
||||
schedule_at=input_serializer.validated_data.get('schedule_at'),
|
||||
interval=input_serializer.validated_data.get('interval')
|
||||
)
|
||||
script.result = job_result
|
||||
serializer = serializers.ScriptDetailSerializer(script, context={'request': request})
|
||||
|
||||
@@ -148,12 +148,12 @@ class JobResultStatusChoices(ChoiceSet):
|
||||
STATUS_FAILED = 'failed'
|
||||
|
||||
CHOICES = (
|
||||
(STATUS_PENDING, 'Pending'),
|
||||
(STATUS_SCHEDULED, 'Scheduled'),
|
||||
(STATUS_RUNNING, 'Running'),
|
||||
(STATUS_COMPLETED, 'Completed'),
|
||||
(STATUS_ERRORED, 'Errored'),
|
||||
(STATUS_FAILED, 'Failed'),
|
||||
(STATUS_PENDING, 'Pending', 'cyan'),
|
||||
(STATUS_SCHEDULED, 'Scheduled', 'gray'),
|
||||
(STATUS_RUNNING, 'Running', 'blue'),
|
||||
(STATUS_COMPLETED, 'Completed', 'green'),
|
||||
(STATUS_ERRORED, 'Errored', 'red'),
|
||||
(STATUS_FAILED, 'Failed', 'red'),
|
||||
)
|
||||
|
||||
TERMINAL_STATE_CHOICES = (
|
||||
|
||||
@@ -17,10 +17,10 @@ __all__ = (
|
||||
'ConfigContextFilterSet',
|
||||
'ContentTypeFilterSet',
|
||||
'CustomFieldFilterSet',
|
||||
'JobResultFilterSet',
|
||||
'CustomLinkFilterSet',
|
||||
'ExportTemplateFilterSet',
|
||||
'ImageAttachmentFilterSet',
|
||||
'JobResultFilterSet',
|
||||
'JournalEntryFilterSet',
|
||||
'LocalConfigContextFilterSet',
|
||||
'ObjectChangeFilterSet',
|
||||
@@ -537,7 +537,7 @@ class JobResultFilterSet(BaseFilterSet):
|
||||
|
||||
class Meta:
|
||||
model = JobResult
|
||||
fields = ('id', 'status', 'user', 'obj_type', 'name')
|
||||
fields = ('id', 'interval', 'status', 'user', 'obj_type', 'name')
|
||||
|
||||
def search(self, queryset, name, value):
|
||||
if not value.strip():
|
||||
|
||||
@@ -116,6 +116,7 @@ class SavedFilterForm(BootstrapMixin, forms.ModelForm):
|
||||
content_types = ContentTypeMultipleChoiceField(
|
||||
queryset=ContentType.objects.all()
|
||||
)
|
||||
parameters = JSONField()
|
||||
|
||||
fieldsets = (
|
||||
('Saved Filter', ('name', 'slug', 'content_types', 'description', 'weight', 'enabled', 'shared')),
|
||||
@@ -125,9 +126,6 @@ class SavedFilterForm(BootstrapMixin, forms.ModelForm):
|
||||
class Meta:
|
||||
model = SavedFilter
|
||||
exclude = ('user',)
|
||||
widgets = {
|
||||
'parameters': forms.Textarea(attrs={'class': 'font-monospace'}),
|
||||
}
|
||||
|
||||
def __init__(self, *args, initial=None, **kwargs):
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
from django import forms
|
||||
from django.utils import timezone
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from utilities.forms import BootstrapMixin, DateTimePicker
|
||||
from utilities.forms import BootstrapMixin, DateTimePicker, SelectDurationWidget
|
||||
|
||||
__all__ = (
|
||||
'ReportForm',
|
||||
@@ -15,3 +16,24 @@ class ReportForm(BootstrapMixin, forms.Form):
|
||||
label=_("Schedule at"),
|
||||
help_text=_("Schedule execution of report to a set time"),
|
||||
)
|
||||
interval = forms.IntegerField(
|
||||
required=False,
|
||||
min_value=1,
|
||||
label=_("Recurs every"),
|
||||
widget=SelectDurationWidget(),
|
||||
help_text=_("Interval at which this report is re-run (in minutes)")
|
||||
)
|
||||
|
||||
def clean_schedule_at(self):
|
||||
scheduled_time = self.cleaned_data['schedule_at']
|
||||
if scheduled_time and scheduled_time < timezone.now():
|
||||
raise forms.ValidationError(_('Scheduled time must be in the future.'))
|
||||
|
||||
return scheduled_time
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# Annotate the current system time for reference
|
||||
now = timezone.now().strftime('%Y-%m-%d %H:%M:%S')
|
||||
self.fields['schedule_at'].help_text += f' (current time: <strong>{now}</strong>)'
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
from django import forms
|
||||
from django.utils import timezone
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from utilities.forms import BootstrapMixin, DateTimePicker
|
||||
from utilities.forms import BootstrapMixin, DateTimePicker, SelectDurationWidget
|
||||
|
||||
__all__ = (
|
||||
'ScriptForm',
|
||||
@@ -21,19 +22,41 @@ class ScriptForm(BootstrapMixin, forms.Form):
|
||||
label=_("Schedule at"),
|
||||
help_text=_("Schedule execution of script to a set time"),
|
||||
)
|
||||
_interval = forms.IntegerField(
|
||||
required=False,
|
||||
min_value=1,
|
||||
label=_("Recurs every"),
|
||||
widget=SelectDurationWidget(),
|
||||
help_text=_("Interval at which this script is re-run (in minutes)")
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# Annotate the current system time for reference
|
||||
now = timezone.now().strftime('%Y-%m-%d %H:%M:%S')
|
||||
self.fields['_schedule_at'].help_text += f' (current time: <strong>{now}</strong>)'
|
||||
|
||||
# Move _commit and _schedule_at to the end of the form
|
||||
schedule_at = self.fields.pop('_schedule_at')
|
||||
interval = self.fields.pop('_interval')
|
||||
commit = self.fields.pop('_commit')
|
||||
self.fields['_schedule_at'] = schedule_at
|
||||
self.fields['_interval'] = interval
|
||||
self.fields['_commit'] = commit
|
||||
|
||||
def clean__schedule_at(self):
|
||||
scheduled_time = self.cleaned_data['_schedule_at']
|
||||
if scheduled_time and scheduled_time < timezone.now():
|
||||
raise forms.ValidationError({
|
||||
'_schedule_at': _('Scheduled time must be in the future.')
|
||||
})
|
||||
|
||||
return scheduled_time
|
||||
|
||||
@property
|
||||
def requires_input(self):
|
||||
"""
|
||||
A boolean indicating whether the form requires user input (ignore the _commit and _schedule_at fields).
|
||||
A boolean indicating whether the form requires user input (ignore the built-in fields).
|
||||
"""
|
||||
return bool(len(self.fields) > 2)
|
||||
return bool(len(self.fields) > 3)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import django.core.validators
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
@@ -13,6 +14,11 @@ class Migration(migrations.Migration):
|
||||
name='scheduled',
|
||||
field=models.DateTimeField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='jobresult',
|
||||
name='interval',
|
||||
field=models.PositiveIntegerField(blank=True, null=True, validators=[django.core.validators.MinValueValidator(1)]),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='jobresult',
|
||||
name='started',
|
||||
|
||||
@@ -2,6 +2,7 @@ import sys
|
||||
import uuid
|
||||
|
||||
import django.db.models.deletion
|
||||
import django.db.models.lookups
|
||||
from django.core import management
|
||||
from django.db import migrations, models
|
||||
|
||||
@@ -39,7 +40,7 @@ class Migration(migrations.Migration):
|
||||
('object_id', models.PositiveBigIntegerField()),
|
||||
('field', models.CharField(max_length=200)),
|
||||
('type', models.CharField(max_length=30)),
|
||||
('value', models.TextField(db_index=True)),
|
||||
('value', models.TextField()),
|
||||
('weight', models.PositiveSmallIntegerField(default=1000)),
|
||||
('object_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='+', to='contenttypes.contenttype')),
|
||||
],
|
||||
|
||||
@@ -7,7 +7,7 @@ from django.contrib.auth.models import User
|
||||
from django.contrib.contenttypes.fields import GenericForeignKey
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.core.cache import cache
|
||||
from django.core.validators import ValidationError
|
||||
from django.core.validators import MinValueValidator, ValidationError
|
||||
from django.db import models
|
||||
from django.http import HttpResponse, QueryDict
|
||||
from django.urls import reverse
|
||||
@@ -21,6 +21,8 @@ from extras.choices import *
|
||||
from extras.constants import *
|
||||
from extras.conditions import ConditionSet
|
||||
from extras.utils import FeatureQuery, image_upload
|
||||
from netbox.config import get_config
|
||||
from netbox.constants import RQ_QUEUE_DEFAULT
|
||||
from netbox.models import ChangeLoggedModel
|
||||
from netbox.models.features import (
|
||||
CloningMixin, CustomFieldsMixin, CustomLinksMixin, ExportTemplatesMixin, JobResultsMixin, TagsMixin, WebhooksMixin,
|
||||
@@ -585,6 +587,14 @@ class JobResult(models.Model):
|
||||
null=True,
|
||||
blank=True
|
||||
)
|
||||
interval = models.PositiveIntegerField(
|
||||
blank=True,
|
||||
null=True,
|
||||
validators=(
|
||||
MinValueValidator(1),
|
||||
),
|
||||
help_text=_("Recurrence interval (in minutes)")
|
||||
)
|
||||
started = models.DateTimeField(
|
||||
null=True,
|
||||
blank=True
|
||||
@@ -633,6 +643,9 @@ class JobResult(models.Model):
|
||||
def get_absolute_url(self):
|
||||
return reverse(f'extras:{self.obj_type.name}_result', args=[self.pk])
|
||||
|
||||
def get_status_color(self):
|
||||
return JobResultStatusChoices.colors.get(self.status)
|
||||
|
||||
@property
|
||||
def duration(self):
|
||||
if not self.completed:
|
||||
@@ -662,32 +675,32 @@ class JobResult(models.Model):
|
||||
self.completed = timezone.now()
|
||||
|
||||
@classmethod
|
||||
def enqueue_job(cls, func, name, obj_type, user, schedule_at=None, *args, **kwargs):
|
||||
def enqueue_job(cls, func, name, obj_type, user, schedule_at=None, interval=None, *args, **kwargs):
|
||||
"""
|
||||
Create a JobResult instance and enqueue a job using the given callable
|
||||
|
||||
func: The callable object to be enqueued for execution
|
||||
name: Name for the JobResult instance
|
||||
obj_type: ContentType to link to the JobResult instance obj_type
|
||||
user: User object to link to the JobResult instance
|
||||
schedule_at: Schedule the job to be executed at the passed date and time
|
||||
args: additional args passed to the callable
|
||||
kwargs: additional kargs passed to the callable
|
||||
Args:
|
||||
func: The callable object to be enqueued for execution
|
||||
name: Name for the JobResult instance
|
||||
obj_type: ContentType to link to the JobResult instance obj_type
|
||||
user: User object to link to the JobResult instance
|
||||
schedule_at: Schedule the job to be executed at the passed date and time
|
||||
interval: Recurrence interval (in minutes)
|
||||
"""
|
||||
job_result: JobResult = cls.objects.create(
|
||||
rq_queue_name = get_config().QUEUE_MAPPINGS.get(obj_type.name, RQ_QUEUE_DEFAULT)
|
||||
queue = django_rq.get_queue(rq_queue_name)
|
||||
status = JobResultStatusChoices.STATUS_SCHEDULED if schedule_at else JobResultStatusChoices.STATUS_PENDING
|
||||
job_result: JobResult = JobResult.objects.create(
|
||||
name=name,
|
||||
status=status,
|
||||
obj_type=obj_type,
|
||||
scheduled=schedule_at,
|
||||
interval=interval,
|
||||
user=user,
|
||||
job_id=uuid.uuid4()
|
||||
)
|
||||
|
||||
queue = django_rq.get_queue("default")
|
||||
|
||||
if schedule_at:
|
||||
job_result.status = JobResultStatusChoices.STATUS_SCHEDULED
|
||||
job_result.scheduled = schedule_at
|
||||
job_result.save()
|
||||
|
||||
queue.enqueue_at(schedule_at, func, job_id=str(job_result.job_id), job_result=job_result, **kwargs)
|
||||
else:
|
||||
queue.enqueue(func, job_id=str(job_result.job_id), job_result=job_result, **kwargs)
|
||||
|
||||
@@ -36,9 +36,7 @@ class CachedValue(models.Model):
|
||||
type = models.CharField(
|
||||
max_length=30
|
||||
)
|
||||
value = models.TextField(
|
||||
db_index=True
|
||||
)
|
||||
value = models.TextField()
|
||||
weight = models.PositiveSmallIntegerField(
|
||||
default=1000
|
||||
)
|
||||
|
||||
@@ -19,6 +19,10 @@ class PluginMenu:
|
||||
if icon_class is not None:
|
||||
self.icon_class = icon_class
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
return self.label.replace(' ', '_')
|
||||
|
||||
|
||||
class PluginMenuItem:
|
||||
"""
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import importlib
|
||||
import inspect
|
||||
import logging
|
||||
import pkgutil
|
||||
import traceback
|
||||
from datetime import timedelta
|
||||
|
||||
from django.conf import settings
|
||||
from django.utils import timezone
|
||||
@@ -11,7 +11,6 @@ from django_rq import job
|
||||
from .choices import JobResultStatusChoices, LogLevelChoices
|
||||
from .models import JobResult
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -85,10 +84,24 @@ def run_report(job_result, *args, **kwargs):
|
||||
try:
|
||||
job_result.start()
|
||||
report.run(job_result)
|
||||
except Exception as e:
|
||||
except Exception:
|
||||
job_result.set_status(JobResultStatusChoices.STATUS_ERRORED)
|
||||
job_result.save()
|
||||
logging.error(f"Error during execution of report {job_result.name}")
|
||||
finally:
|
||||
# Schedule the next job if an interval has been set
|
||||
start_time = job_result.scheduled or job_result.started
|
||||
if start_time and job_result.interval:
|
||||
new_scheduled_time = start_time + timedelta(minutes=job_result.interval)
|
||||
JobResult.enqueue_job(
|
||||
run_report,
|
||||
name=job_result.name,
|
||||
obj_type=job_result.obj_type,
|
||||
user=job_result.user,
|
||||
job_timeout=report.job_timeout,
|
||||
schedule_at=new_scheduled_time,
|
||||
interval=job_result.interval
|
||||
)
|
||||
|
||||
|
||||
class Report(object):
|
||||
|
||||
@@ -4,8 +4,9 @@ import logging
|
||||
import os
|
||||
import pkgutil
|
||||
import sys
|
||||
import traceback
|
||||
import threading
|
||||
import traceback
|
||||
from datetime import timedelta
|
||||
|
||||
import yaml
|
||||
from django import forms
|
||||
@@ -16,6 +17,7 @@ from django.utils.functional import classproperty
|
||||
|
||||
from extras.api.serializers import ScriptOutputSerializer
|
||||
from extras.choices import JobResultStatusChoices, LogLevelChoices
|
||||
from extras.models import JobResult
|
||||
from extras.signals import clear_webhooks
|
||||
from ipam.formfields import IPAddressFormField, IPNetworkFormField
|
||||
from ipam.validators import MaxPrefixLengthValidator, MinPrefixLengthValidator, prefix_validator
|
||||
@@ -491,6 +493,22 @@ def run_script(data, request, commit=True, *args, **kwargs):
|
||||
else:
|
||||
_run_script()
|
||||
|
||||
# Schedule the next job if an interval has been set
|
||||
if job_result.interval:
|
||||
new_scheduled_time = job_result.scheduled + timedelta(minutes=job_result.interval)
|
||||
JobResult.enqueue_job(
|
||||
run_script,
|
||||
name=job_result.name,
|
||||
obj_type=job_result.obj_type,
|
||||
user=job_result.user,
|
||||
schedule_at=new_scheduled_time,
|
||||
interval=job_result.interval,
|
||||
job_timeout=script.job_timeout,
|
||||
data=data,
|
||||
request=request,
|
||||
commit=commit
|
||||
)
|
||||
|
||||
|
||||
def get_scripts(use_names=False):
|
||||
"""
|
||||
|
||||
@@ -14,7 +14,6 @@ from .choices import ObjectChangeActionChoices
|
||||
from .models import ConfigRevision, CustomField, ObjectChange
|
||||
from .webhooks import enqueue_object, get_snapshots, serialize_for_webhook
|
||||
|
||||
|
||||
#
|
||||
# Change logging/webhooks
|
||||
#
|
||||
@@ -100,9 +99,6 @@ def handle_deleted_object(sender, instance, **kwargs):
|
||||
"""
|
||||
Fires when an object is deleted.
|
||||
"""
|
||||
if not hasattr(instance, 'to_objectchange'):
|
||||
return
|
||||
|
||||
# Get the current request, or bail if not set
|
||||
request = current_request.get()
|
||||
if request is None:
|
||||
@@ -110,6 +106,8 @@ def handle_deleted_object(sender, instance, **kwargs):
|
||||
|
||||
# Record an ObjectChange if applicable
|
||||
if hasattr(instance, 'to_objectchange'):
|
||||
if hasattr(instance, 'snapshot') and not getattr(instance, '_prechange_snapshot', None):
|
||||
instance.snapshot()
|
||||
objectchange = instance.to_objectchange(ObjectChangeActionChoices.ACTION_DELETE)
|
||||
objectchange.user = request.user
|
||||
objectchange.request_id = request.id
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import django_tables2 as tables
|
||||
from django.conf import settings
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from extras.models import *
|
||||
from netbox.tables import NetBoxTable, columns
|
||||
@@ -8,9 +9,9 @@ from .template_code import *
|
||||
__all__ = (
|
||||
'ConfigContextTable',
|
||||
'CustomFieldTable',
|
||||
'JobResultTable',
|
||||
'CustomLinkTable',
|
||||
'ExportTemplateTable',
|
||||
'JobResultTable',
|
||||
'JournalEntryTable',
|
||||
'ObjectChangeTable',
|
||||
'SavedFilterTable',
|
||||
@@ -41,7 +42,15 @@ class JobResultTable(NetBoxTable):
|
||||
name = tables.Column(
|
||||
linkify=True
|
||||
)
|
||||
|
||||
obj_type = columns.ContentTypeColumn(
|
||||
verbose_name=_('Type')
|
||||
)
|
||||
status = columns.ChoiceFieldColumn()
|
||||
created = columns.DateTimeColumn()
|
||||
scheduled = columns.DateTimeColumn()
|
||||
interval = columns.DurationColumn()
|
||||
started = columns.DateTimeColumn()
|
||||
completed = columns.DateTimeColumn()
|
||||
actions = columns.ActionsColumn(
|
||||
actions=('delete',)
|
||||
)
|
||||
@@ -49,10 +58,12 @@ class JobResultTable(NetBoxTable):
|
||||
class Meta(NetBoxTable.Meta):
|
||||
model = JobResult
|
||||
fields = (
|
||||
'pk', 'id', 'name', 'obj_type', 'status', 'created', 'scheduled', 'started', 'completed', 'user', 'job_id',
|
||||
'pk', 'id', 'obj_type', 'name', 'status', 'created', 'scheduled', 'interval', 'started', 'completed',
|
||||
'user', 'job_id',
|
||||
)
|
||||
default_columns = (
|
||||
'pk', 'id', 'name', 'obj_type', 'status', 'created', 'scheduled', 'started', 'completed', 'user',
|
||||
'pk', 'id', 'obj_type', 'name', 'status', 'created', 'scheduled', 'interval', 'started', 'completed',
|
||||
'user',
|
||||
)
|
||||
|
||||
|
||||
@@ -215,7 +226,8 @@ class ObjectChangeTable(NetBoxTable):
|
||||
object_repr = tables.TemplateColumn(
|
||||
accessor=tables.A('changed_object'),
|
||||
template_code=OBJECTCHANGE_OBJECT,
|
||||
verbose_name='Object'
|
||||
verbose_name='Object',
|
||||
orderable=False
|
||||
)
|
||||
request_id = tables.TemplateColumn(
|
||||
template_code=OBJECTCHANGE_REQUEST_ID,
|
||||
|
||||
@@ -26,7 +26,7 @@ items = (
|
||||
)
|
||||
|
||||
menu = PluginMenu(
|
||||
label=_('Dummy'),
|
||||
label=_('Dummy Plugin'),
|
||||
groups=(('Group 1', items),),
|
||||
)
|
||||
menu_items = items
|
||||
|
||||
@@ -611,73 +611,76 @@ class ScriptTest(APITestCase):
|
||||
|
||||
class CreatedUpdatedFilterTest(APITestCase):
|
||||
|
||||
def setUp(self):
|
||||
|
||||
super().setUp()
|
||||
|
||||
self.site1 = Site.objects.create(name='Test Site 1', slug='test-site-1')
|
||||
self.location1 = Location.objects.create(site=self.site1, name='Test Location 1', slug='test-location-1')
|
||||
self.rackrole1 = RackRole.objects.create(name='Test Rack Role 1', slug='test-rack-role-1', color='ff0000')
|
||||
self.rack1 = Rack.objects.create(
|
||||
site=self.site1, location=self.location1, role=self.rackrole1, name='Test Rack 1', u_height=42,
|
||||
)
|
||||
self.rack2 = Rack.objects.create(
|
||||
site=self.site1, location=self.location1, role=self.rackrole1, name='Test Rack 2', u_height=42,
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
site1 = Site.objects.create(name='Site 1', slug='site-1')
|
||||
location1 = Location.objects.create(site=site1, name='Location 1', slug='location-1')
|
||||
rackrole1 = RackRole.objects.create(name='Rack Role 1', slug='rack-role-1', color='ff0000')
|
||||
racks = (
|
||||
Rack(site=site1, location=location1, role=rackrole1, name='Rack 1', u_height=42),
|
||||
Rack(site=site1, location=location1, role=rackrole1, name='Rack 2', u_height=42)
|
||||
)
|
||||
Rack.objects.bulk_create(racks)
|
||||
|
||||
# change the created and last_updated of one
|
||||
Rack.objects.filter(pk=self.rack2.pk).update(
|
||||
# Change the created and last_updated of the second rack
|
||||
Rack.objects.filter(pk=racks[1].pk).update(
|
||||
last_updated=make_aware(datetime.datetime(2001, 2, 3, 1, 2, 3, 4)),
|
||||
created=make_aware(datetime.datetime(2001, 2, 3))
|
||||
)
|
||||
|
||||
def test_get_rack_created(self):
|
||||
rack2 = Rack.objects.get(name='Rack 2')
|
||||
self.add_permissions('dcim.view_rack')
|
||||
url = reverse('dcim-api:rack-list')
|
||||
response = self.client.get('{}?created=2001-02-03'.format(url), **self.header)
|
||||
|
||||
self.assertEqual(response.data['count'], 1)
|
||||
self.assertEqual(response.data['results'][0]['id'], self.rack2.pk)
|
||||
self.assertEqual(response.data['results'][0]['id'], rack2.pk)
|
||||
|
||||
def test_get_rack_created_gte(self):
|
||||
rack1 = Rack.objects.get(name='Rack 1')
|
||||
self.add_permissions('dcim.view_rack')
|
||||
url = reverse('dcim-api:rack-list')
|
||||
response = self.client.get('{}?created__gte=2001-02-04'.format(url), **self.header)
|
||||
|
||||
self.assertEqual(response.data['count'], 1)
|
||||
self.assertEqual(response.data['results'][0]['id'], self.rack1.pk)
|
||||
self.assertEqual(response.data['results'][0]['id'], rack1.pk)
|
||||
|
||||
def test_get_rack_created_lte(self):
|
||||
rack2 = Rack.objects.get(name='Rack 2')
|
||||
self.add_permissions('dcim.view_rack')
|
||||
url = reverse('dcim-api:rack-list')
|
||||
response = self.client.get('{}?created__lte=2001-02-04'.format(url), **self.header)
|
||||
|
||||
self.assertEqual(response.data['count'], 1)
|
||||
self.assertEqual(response.data['results'][0]['id'], self.rack2.pk)
|
||||
self.assertEqual(response.data['results'][0]['id'], rack2.pk)
|
||||
|
||||
def test_get_rack_last_updated(self):
|
||||
rack2 = Rack.objects.get(name='Rack 2')
|
||||
self.add_permissions('dcim.view_rack')
|
||||
url = reverse('dcim-api:rack-list')
|
||||
response = self.client.get('{}?last_updated=2001-02-03%2001:02:03.000004'.format(url), **self.header)
|
||||
|
||||
self.assertEqual(response.data['count'], 1)
|
||||
self.assertEqual(response.data['results'][0]['id'], self.rack2.pk)
|
||||
self.assertEqual(response.data['results'][0]['id'], rack2.pk)
|
||||
|
||||
def test_get_rack_last_updated_gte(self):
|
||||
rack1 = Rack.objects.get(name='Rack 1')
|
||||
self.add_permissions('dcim.view_rack')
|
||||
url = reverse('dcim-api:rack-list')
|
||||
response = self.client.get('{}?last_updated__gte=2001-02-04%2001:02:03.000004'.format(url), **self.header)
|
||||
|
||||
self.assertEqual(response.data['count'], 1)
|
||||
self.assertEqual(response.data['results'][0]['id'], self.rack1.pk)
|
||||
self.assertEqual(response.data['results'][0]['id'], rack1.pk)
|
||||
|
||||
def test_get_rack_last_updated_lte(self):
|
||||
rack2 = Rack.objects.get(name='Rack 2')
|
||||
self.add_permissions('dcim.view_rack')
|
||||
url = reverse('dcim-api:rack-list')
|
||||
response = self.client.get('{}?last_updated__lte=2001-02-04%2001:02:03.000004'.format(url), **self.header)
|
||||
|
||||
self.assertEqual(response.data['count'], 1)
|
||||
self.assertEqual(response.data['results'][0]['id'], self.rack2.pk)
|
||||
self.assertEqual(response.data['results'][0]['id'], rack2.pk)
|
||||
|
||||
|
||||
class ContentTypeTest(APITestCase):
|
||||
|
||||
@@ -373,7 +373,8 @@ class CustomFieldTest(TestCase):
|
||||
|
||||
class CustomFieldManagerTest(TestCase):
|
||||
|
||||
def setUp(self):
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
content_type = ContentType.objects.get_for_model(Site)
|
||||
custom_field = CustomField(type=CustomFieldTypeChoices.TYPE_TEXT, name='text_field', default='foo')
|
||||
custom_field.save()
|
||||
@@ -853,6 +854,69 @@ class CustomFieldAPITest(APITestCase):
|
||||
list(original_cfvs['multiobject_field'])
|
||||
)
|
||||
|
||||
def test_specify_related_object_by_attr(self):
|
||||
site1 = Site.objects.get(name='Site 1')
|
||||
vlans = VLAN.objects.all()[:3]
|
||||
url = reverse('dcim-api:site-detail', kwargs={'pk': site1.pk})
|
||||
self.add_permissions('dcim.change_site')
|
||||
|
||||
# Set related objects by PK
|
||||
data = {
|
||||
'custom_fields': {
|
||||
'object_field': vlans[0].pk,
|
||||
'multiobject_field': [vlans[1].pk, vlans[2].pk],
|
||||
},
|
||||
}
|
||||
response = self.client.patch(url, data, format='json', **self.header)
|
||||
self.assertHttpStatus(response, status.HTTP_200_OK)
|
||||
self.assertEqual(
|
||||
response.data['custom_fields']['object_field']['id'],
|
||||
vlans[0].pk
|
||||
)
|
||||
self.assertListEqual(
|
||||
[obj['id'] for obj in response.data['custom_fields']['multiobject_field']],
|
||||
[vlans[1].pk, vlans[2].pk]
|
||||
)
|
||||
|
||||
# Set related objects by name
|
||||
data = {
|
||||
'custom_fields': {
|
||||
'object_field': {
|
||||
'name': vlans[0].name,
|
||||
},
|
||||
'multiobject_field': [
|
||||
{
|
||||
'name': vlans[1].name
|
||||
},
|
||||
{
|
||||
'name': vlans[2].name
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
response = self.client.patch(url, data, format='json', **self.header)
|
||||
self.assertHttpStatus(response, status.HTTP_200_OK)
|
||||
self.assertEqual(
|
||||
response.data['custom_fields']['object_field']['id'],
|
||||
vlans[0].pk
|
||||
)
|
||||
self.assertListEqual(
|
||||
[obj['id'] for obj in response.data['custom_fields']['multiobject_field']],
|
||||
[vlans[1].pk, vlans[2].pk]
|
||||
)
|
||||
|
||||
# Clear related objects
|
||||
data = {
|
||||
'custom_fields': {
|
||||
'object_field': None,
|
||||
'multiobject_field': [],
|
||||
},
|
||||
}
|
||||
response = self.client.patch(url, data, format='json', **self.header)
|
||||
self.assertHttpStatus(response, status.HTTP_200_OK)
|
||||
self.assertIsNone(response.data['custom_fields']['object_field'])
|
||||
self.assertListEqual(response.data['custom_fields']['multiobject_field'], [])
|
||||
|
||||
def test_minimum_maximum_values_validation(self):
|
||||
site2 = Site.objects.get(name='Site 2')
|
||||
url = reverse('dcim-api:site-detail', kwargs={'pk': site2.pk})
|
||||
|
||||
@@ -21,32 +21,32 @@ class ConfigContextTest(TestCase):
|
||||
|
||||
It also ensures the various config context querysets are consistent.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
|
||||
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
|
||||
self.devicetype = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1')
|
||||
self.devicerole = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
|
||||
self.region = Region.objects.create(name="Region")
|
||||
self.sitegroup = SiteGroup.objects.create(name="Site Group")
|
||||
self.site = Site.objects.create(name='Site 1', slug='site-1', region=self.region, group=self.sitegroup)
|
||||
self.location = Location.objects.create(name='Location 1', slug='location-1', site=self.site)
|
||||
self.platform = Platform.objects.create(name="Platform")
|
||||
self.tenantgroup = TenantGroup.objects.create(name="Tenant Group")
|
||||
self.tenant = Tenant.objects.create(name="Tenant", group=self.tenantgroup)
|
||||
self.tag = Tag.objects.create(name="Tag", slug="tag")
|
||||
self.tag2 = Tag.objects.create(name="Tag2", slug="tag2")
|
||||
devicetype = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1')
|
||||
devicerole = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
|
||||
region = Region.objects.create(name='Region')
|
||||
sitegroup = SiteGroup.objects.create(name='Site Group')
|
||||
site = Site.objects.create(name='Site 1', slug='site-1', region=region, group=sitegroup)
|
||||
location = Location.objects.create(name='Location 1', slug='location-1', site=site)
|
||||
platform = Platform.objects.create(name='Platform')
|
||||
tenantgroup = TenantGroup.objects.create(name='Tenant Group')
|
||||
tenant = Tenant.objects.create(name='Tenant', group=tenantgroup)
|
||||
tag1 = Tag.objects.create(name='Tag', slug='tag')
|
||||
tag2 = Tag.objects.create(name='Tag2', slug='tag2')
|
||||
|
||||
self.device = Device.objects.create(
|
||||
Device.objects.create(
|
||||
name='Device 1',
|
||||
device_type=self.devicetype,
|
||||
device_role=self.devicerole,
|
||||
site=self.site,
|
||||
location=self.location
|
||||
device_type=devicetype,
|
||||
device_role=devicerole,
|
||||
site=site,
|
||||
location=location
|
||||
)
|
||||
|
||||
def test_higher_weight_wins(self):
|
||||
|
||||
device = Device.objects.first()
|
||||
context1 = ConfigContext(
|
||||
name="context 1",
|
||||
weight=101,
|
||||
@@ -72,10 +72,10 @@ class ConfigContextTest(TestCase):
|
||||
"b": 456,
|
||||
"c": 777
|
||||
}
|
||||
self.assertEqual(self.device.get_config_context(), expected_data)
|
||||
self.assertEqual(device.get_config_context(), expected_data)
|
||||
|
||||
def test_name_ordering_after_weight(self):
|
||||
|
||||
device = Device.objects.first()
|
||||
context1 = ConfigContext(
|
||||
name="context 1",
|
||||
weight=100,
|
||||
@@ -101,13 +101,14 @@ class ConfigContextTest(TestCase):
|
||||
"b": 456,
|
||||
"c": 789
|
||||
}
|
||||
self.assertEqual(self.device.get_config_context(), expected_data)
|
||||
self.assertEqual(device.get_config_context(), expected_data)
|
||||
|
||||
def test_annotation_same_as_get_for_object(self):
|
||||
"""
|
||||
This test incorperates features from all of the above tests cases to ensure
|
||||
This test incorporates features from all of the above tests cases to ensure
|
||||
the annotate_config_context_data() and get_for_object() queryset methods are the same.
|
||||
"""
|
||||
device = Device.objects.first()
|
||||
context1 = ConfigContext(
|
||||
name="context 1",
|
||||
weight=101,
|
||||
@@ -142,10 +143,19 @@ class ConfigContextTest(TestCase):
|
||||
)
|
||||
ConfigContext.objects.bulk_create([context1, context2, context3, context4])
|
||||
|
||||
annotated_queryset = Device.objects.filter(name=self.device.name).annotate_config_context_data()
|
||||
self.assertEqual(self.device.get_config_context(), annotated_queryset[0].get_config_context())
|
||||
annotated_queryset = Device.objects.filter(name=device.name).annotate_config_context_data()
|
||||
self.assertEqual(device.get_config_context(), annotated_queryset[0].get_config_context())
|
||||
|
||||
def test_annotation_same_as_get_for_object_device_relations(self):
|
||||
region = Region.objects.first()
|
||||
sitegroup = SiteGroup.objects.first()
|
||||
site = Site.objects.first()
|
||||
location = Location.objects.first()
|
||||
platform = Platform.objects.first()
|
||||
tenantgroup = TenantGroup.objects.first()
|
||||
tenant = Tenant.objects.first()
|
||||
tag = Tag.objects.first()
|
||||
|
||||
region_context = ConfigContext.objects.create(
|
||||
name="region",
|
||||
weight=100,
|
||||
@@ -153,7 +163,8 @@ class ConfigContextTest(TestCase):
|
||||
"region": 1
|
||||
}
|
||||
)
|
||||
region_context.regions.add(self.region)
|
||||
region_context.regions.add(region)
|
||||
|
||||
sitegroup_context = ConfigContext.objects.create(
|
||||
name="sitegroup",
|
||||
weight=100,
|
||||
@@ -161,7 +172,8 @@ class ConfigContextTest(TestCase):
|
||||
"sitegroup": 1
|
||||
}
|
||||
)
|
||||
sitegroup_context.site_groups.add(self.sitegroup)
|
||||
sitegroup_context.site_groups.add(sitegroup)
|
||||
|
||||
site_context = ConfigContext.objects.create(
|
||||
name="site",
|
||||
weight=100,
|
||||
@@ -169,7 +181,8 @@ class ConfigContextTest(TestCase):
|
||||
"site": 1
|
||||
}
|
||||
)
|
||||
site_context.sites.add(self.site)
|
||||
site_context.sites.add(site)
|
||||
|
||||
location_context = ConfigContext.objects.create(
|
||||
name="location",
|
||||
weight=100,
|
||||
@@ -177,7 +190,8 @@ class ConfigContextTest(TestCase):
|
||||
"location": 1
|
||||
}
|
||||
)
|
||||
location_context.locations.add(self.location)
|
||||
location_context.locations.add(location)
|
||||
|
||||
platform_context = ConfigContext.objects.create(
|
||||
name="platform",
|
||||
weight=100,
|
||||
@@ -185,7 +199,8 @@ class ConfigContextTest(TestCase):
|
||||
"platform": 1
|
||||
}
|
||||
)
|
||||
platform_context.platforms.add(self.platform)
|
||||
platform_context.platforms.add(platform)
|
||||
|
||||
tenant_group_context = ConfigContext.objects.create(
|
||||
name="tenant group",
|
||||
weight=100,
|
||||
@@ -193,7 +208,8 @@ class ConfigContextTest(TestCase):
|
||||
"tenant_group": 1
|
||||
}
|
||||
)
|
||||
tenant_group_context.tenant_groups.add(self.tenantgroup)
|
||||
tenant_group_context.tenant_groups.add(tenantgroup)
|
||||
|
||||
tenant_context = ConfigContext.objects.create(
|
||||
name="tenant",
|
||||
weight=100,
|
||||
@@ -201,7 +217,8 @@ class ConfigContextTest(TestCase):
|
||||
"tenant": 1
|
||||
}
|
||||
)
|
||||
tenant_context.tenants.add(self.tenant)
|
||||
tenant_context.tenants.add(tenant)
|
||||
|
||||
tag_context = ConfigContext.objects.create(
|
||||
name="tag",
|
||||
weight=100,
|
||||
@@ -209,23 +226,30 @@ class ConfigContextTest(TestCase):
|
||||
"tag": 1
|
||||
}
|
||||
)
|
||||
tag_context.tags.add(self.tag)
|
||||
tag_context.tags.add(tag)
|
||||
|
||||
device = Device.objects.create(
|
||||
name="Device 2",
|
||||
site=self.site,
|
||||
location=self.location,
|
||||
tenant=self.tenant,
|
||||
platform=self.platform,
|
||||
device_role=self.devicerole,
|
||||
device_type=self.devicetype
|
||||
site=site,
|
||||
location=location,
|
||||
tenant=tenant,
|
||||
platform=platform,
|
||||
device_role=DeviceRole.objects.first(),
|
||||
device_type=DeviceType.objects.first()
|
||||
)
|
||||
device.tags.add(self.tag)
|
||||
device.tags.add(tag)
|
||||
|
||||
annotated_queryset = Device.objects.filter(name=device.name).annotate_config_context_data()
|
||||
self.assertEqual(device.get_config_context(), annotated_queryset[0].get_config_context())
|
||||
|
||||
def test_annotation_same_as_get_for_object_virtualmachine_relations(self):
|
||||
region = Region.objects.first()
|
||||
sitegroup = SiteGroup.objects.first()
|
||||
site = Site.objects.first()
|
||||
platform = Platform.objects.first()
|
||||
tenantgroup = TenantGroup.objects.first()
|
||||
tenant = Tenant.objects.first()
|
||||
tag = Tag.objects.first()
|
||||
cluster_type = ClusterType.objects.create(name="Cluster Type")
|
||||
cluster_group = ClusterGroup.objects.create(name="Cluster Group")
|
||||
cluster = Cluster.objects.create(name="Cluster", group=cluster_group, type=cluster_type)
|
||||
@@ -235,49 +259,49 @@ class ConfigContextTest(TestCase):
|
||||
weight=100,
|
||||
data={"region": 1}
|
||||
)
|
||||
region_context.regions.add(self.region)
|
||||
region_context.regions.add(region)
|
||||
|
||||
sitegroup_context = ConfigContext.objects.create(
|
||||
name="sitegroup",
|
||||
weight=100,
|
||||
data={"sitegroup": 1}
|
||||
)
|
||||
sitegroup_context.site_groups.add(self.sitegroup)
|
||||
sitegroup_context.site_groups.add(sitegroup)
|
||||
|
||||
site_context = ConfigContext.objects.create(
|
||||
name="site",
|
||||
weight=100,
|
||||
data={"site": 1}
|
||||
)
|
||||
site_context.sites.add(self.site)
|
||||
site_context.sites.add(site)
|
||||
|
||||
platform_context = ConfigContext.objects.create(
|
||||
name="platform",
|
||||
weight=100,
|
||||
data={"platform": 1}
|
||||
)
|
||||
platform_context.platforms.add(self.platform)
|
||||
platform_context.platforms.add(platform)
|
||||
|
||||
tenant_group_context = ConfigContext.objects.create(
|
||||
name="tenant group",
|
||||
weight=100,
|
||||
data={"tenant_group": 1}
|
||||
)
|
||||
tenant_group_context.tenant_groups.add(self.tenantgroup)
|
||||
tenant_group_context.tenant_groups.add(tenantgroup)
|
||||
|
||||
tenant_context = ConfigContext.objects.create(
|
||||
name="tenant",
|
||||
weight=100,
|
||||
data={"tenant": 1}
|
||||
)
|
||||
tenant_context.tenants.add(self.tenant)
|
||||
tenant_context.tenants.add(tenant)
|
||||
|
||||
tag_context = ConfigContext.objects.create(
|
||||
name="tag",
|
||||
weight=100,
|
||||
data={"tag": 1}
|
||||
)
|
||||
tag_context.tags.add(self.tag)
|
||||
tag_context.tags.add(tag)
|
||||
|
||||
cluster_type_context = ConfigContext.objects.create(
|
||||
name="cluster type",
|
||||
@@ -303,11 +327,11 @@ class ConfigContextTest(TestCase):
|
||||
virtual_machine = VirtualMachine.objects.create(
|
||||
name="VM 1",
|
||||
cluster=cluster,
|
||||
tenant=self.tenant,
|
||||
platform=self.platform,
|
||||
role=self.devicerole
|
||||
tenant=tenant,
|
||||
platform=platform,
|
||||
role=DeviceRole.objects.first()
|
||||
)
|
||||
virtual_machine.tags.add(self.tag)
|
||||
virtual_machine.tags.add(tag)
|
||||
|
||||
annotated_queryset = VirtualMachine.objects.filter(name=virtual_machine.name).annotate_config_context_data()
|
||||
self.assertEqual(virtual_machine.get_config_context(), annotated_queryset[0].get_config_context())
|
||||
@@ -315,12 +339,17 @@ class ConfigContextTest(TestCase):
|
||||
def test_multiple_tags_return_distinct_objects(self):
|
||||
"""
|
||||
Tagged items use a generic relationship, which results in duplicate rows being returned when queried.
|
||||
This is combatted by by appending distinct() to the config context querysets. This test creates a config
|
||||
This is combated by appending distinct() to the config context querysets. This test creates a config
|
||||
context assigned to two tags and ensures objects related by those same two tags result in only a single
|
||||
config context record being returned.
|
||||
|
||||
See https://github.com/netbox-community/netbox/issues/5314
|
||||
"""
|
||||
site = Site.objects.first()
|
||||
platform = Platform.objects.first()
|
||||
tenant = Tenant.objects.first()
|
||||
tags = Tag.objects.all()
|
||||
|
||||
tag_context = ConfigContext.objects.create(
|
||||
name="tag",
|
||||
weight=100,
|
||||
@@ -328,19 +357,17 @@ class ConfigContextTest(TestCase):
|
||||
"tag": 1
|
||||
}
|
||||
)
|
||||
tag_context.tags.add(self.tag)
|
||||
tag_context.tags.add(self.tag2)
|
||||
tag_context.tags.set(tags)
|
||||
|
||||
device = Device.objects.create(
|
||||
name="Device 3",
|
||||
site=self.site,
|
||||
tenant=self.tenant,
|
||||
platform=self.platform,
|
||||
device_role=self.devicerole,
|
||||
device_type=self.devicetype
|
||||
site=site,
|
||||
tenant=tenant,
|
||||
platform=platform,
|
||||
device_role=DeviceRole.objects.first(),
|
||||
device_type=DeviceType.objects.first()
|
||||
)
|
||||
device.tags.add(self.tag)
|
||||
device.tags.add(self.tag2)
|
||||
device.tags.set(tags)
|
||||
|
||||
annotated_queryset = Device.objects.filter(name=device.name).annotate_config_context_data()
|
||||
self.assertEqual(ConfigContext.objects.get_for_object(device).count(), 1)
|
||||
@@ -357,6 +384,11 @@ class ConfigContextTest(TestCase):
|
||||
|
||||
See https://github.com/netbox-community/netbox/issues/5387
|
||||
"""
|
||||
site = Site.objects.first()
|
||||
platform = Platform.objects.first()
|
||||
tenant = Tenant.objects.first()
|
||||
tag1, tag2 = list(Tag.objects.all())
|
||||
|
||||
tag_context_1 = ConfigContext.objects.create(
|
||||
name="tag-1",
|
||||
weight=100,
|
||||
@@ -364,7 +396,8 @@ class ConfigContextTest(TestCase):
|
||||
"tag": 1
|
||||
}
|
||||
)
|
||||
tag_context_1.tags.add(self.tag)
|
||||
tag_context_1.tags.add(tag1)
|
||||
|
||||
tag_context_2 = ConfigContext.objects.create(
|
||||
name="tag-2",
|
||||
weight=100,
|
||||
@@ -372,18 +405,17 @@ class ConfigContextTest(TestCase):
|
||||
"tag": 1
|
||||
}
|
||||
)
|
||||
tag_context_2.tags.add(self.tag2)
|
||||
tag_context_2.tags.add(tag2)
|
||||
|
||||
device = Device.objects.create(
|
||||
name="Device 3",
|
||||
site=self.site,
|
||||
tenant=self.tenant,
|
||||
platform=self.platform,
|
||||
device_role=self.devicerole,
|
||||
device_type=self.devicetype
|
||||
site=site,
|
||||
tenant=tenant,
|
||||
platform=platform,
|
||||
device_role=DeviceRole.objects.first(),
|
||||
device_type=DeviceType.objects.first()
|
||||
)
|
||||
device.tags.add(self.tag)
|
||||
device.tags.add(self.tag2)
|
||||
device.tags.set([tag1, tag2])
|
||||
|
||||
annotated_queryset = Device.objects.filter(name=device.name).annotate_config_context_data()
|
||||
self.assertEqual(ConfigContext.objects.get_for_object(device).count(), 2)
|
||||
|
||||
@@ -76,7 +76,7 @@ class PluginTest(TestCase):
|
||||
"""
|
||||
menu = registry['plugins']['menus'][0]
|
||||
self.assertIsInstance(menu, PluginMenu)
|
||||
self.assertEqual(menu.label, 'Dummy')
|
||||
self.assertEqual(menu.label, 'Dummy Plugin')
|
||||
|
||||
def test_menu_items(self):
|
||||
"""
|
||||
|
||||
@@ -23,6 +23,7 @@ class WebhookTest(APITestCase):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
|
||||
# Ensure the queue has been cleared for each test
|
||||
self.queue = django_rq.get_queue('default')
|
||||
self.queue.empty()
|
||||
|
||||
|
||||
@@ -676,7 +676,6 @@ class ReportView(ContentTypePermissionRequiredMixin, View):
|
||||
form = ReportForm(request.POST)
|
||||
|
||||
if form.is_valid():
|
||||
schedule_at = form.cleaned_data.get("schedule_at")
|
||||
|
||||
# Allow execution only if RQ worker process is running
|
||||
if not Worker.count(get_connection('default')):
|
||||
@@ -686,14 +685,14 @@ class ReportView(ContentTypePermissionRequiredMixin, View):
|
||||
})
|
||||
|
||||
# Run the Report. A new JobResult is created.
|
||||
report_content_type = ContentType.objects.get(app_label='extras', model='report')
|
||||
job_result = JobResult.enqueue_job(
|
||||
run_report,
|
||||
report.full_name,
|
||||
report_content_type,
|
||||
request.user,
|
||||
job_timeout=report.job_timeout,
|
||||
schedule_at=schedule_at,
|
||||
name=report.full_name,
|
||||
obj_type=ContentType.objects.get_for_model(Report),
|
||||
user=request.user,
|
||||
schedule_at=form.cleaned_data.get('schedule_at'),
|
||||
interval=form.cleaned_data.get('interval'),
|
||||
job_timeout=report.job_timeout
|
||||
)
|
||||
|
||||
return redirect('extras:report_result', job_result_pk=job_result.pk)
|
||||
@@ -787,9 +786,8 @@ class ScriptView(ContentTypePermissionRequiredMixin, GetScriptMixin, View):
|
||||
form = script.as_form(initial=normalize_querydict(request.GET))
|
||||
|
||||
# Look for a pending JobResult (use the latest one by creation timestamp)
|
||||
script_content_type = ContentType.objects.get(app_label='extras', model='script')
|
||||
script.result = JobResult.objects.filter(
|
||||
obj_type=script_content_type,
|
||||
obj_type=ContentType.objects.get_for_model(Script),
|
||||
name=script.full_name,
|
||||
).exclude(
|
||||
status__in=JobResultStatusChoices.TERMINAL_STATE_CHOICES
|
||||
@@ -815,21 +813,17 @@ class ScriptView(ContentTypePermissionRequiredMixin, GetScriptMixin, View):
|
||||
messages.error(request, "Unable to run script: RQ worker process not running.")
|
||||
|
||||
elif form.is_valid():
|
||||
commit = form.cleaned_data.pop('_commit')
|
||||
schedule_at = form.cleaned_data.pop("_schedule_at")
|
||||
|
||||
script_content_type = ContentType.objects.get(app_label='extras', model='script')
|
||||
|
||||
job_result = JobResult.enqueue_job(
|
||||
run_script,
|
||||
script.full_name,
|
||||
script_content_type,
|
||||
request.user,
|
||||
name=script.full_name,
|
||||
obj_type=ContentType.objects.get_for_model(Script),
|
||||
user=request.user,
|
||||
schedule_at=form.cleaned_data.pop('_schedule_at'),
|
||||
interval=form.cleaned_data.pop('_interval'),
|
||||
data=form.cleaned_data,
|
||||
request=copy_safe_request(request),
|
||||
commit=commit,
|
||||
job_timeout=script.job_timeout,
|
||||
schedule_at=schedule_at,
|
||||
commit=form.cleaned_data.pop('_commit')
|
||||
)
|
||||
|
||||
return redirect('extras:script_result', job_result_pk=job_result.pk)
|
||||
|
||||
@@ -5,6 +5,8 @@ from django.contrib.contenttypes.models import ContentType
|
||||
from django.utils import timezone
|
||||
from django_rq import get_queue
|
||||
|
||||
from netbox.config import get_config
|
||||
from netbox.constants import RQ_QUEUE_DEFAULT
|
||||
from netbox.registry import registry
|
||||
from utilities.api import get_serializer_for_model
|
||||
from utilities.utils import serialize_object
|
||||
@@ -78,7 +80,8 @@ def flush_webhooks(queue):
|
||||
"""
|
||||
Flush a list of object representation to RQ for webhook processing.
|
||||
"""
|
||||
rq_queue = get_queue('default')
|
||||
rq_queue_name = get_config().QUEUE_MAPPINGS.get('webhook', RQ_QUEUE_DEFAULT)
|
||||
rq_queue = get_queue(rq_queue_name)
|
||||
webhooks_cache = {
|
||||
'type_create': {},
|
||||
'type_update': {},
|
||||
|
||||
@@ -962,7 +962,7 @@ class L2VPNFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
|
||||
|
||||
class Meta:
|
||||
model = L2VPN
|
||||
fields = ['id', 'identifier', 'name', 'type', 'description']
|
||||
fields = ['id', 'identifier', 'name', 'slug', 'type', 'description']
|
||||
|
||||
def search(self, queryset, name, value):
|
||||
if not value.strip():
|
||||
|
||||
@@ -249,7 +249,7 @@ class IPRangeFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
|
||||
null_option='Global'
|
||||
)
|
||||
status = MultipleChoiceField(
|
||||
choices=PrefixStatusChoices,
|
||||
choices=IPRangeStatusChoices,
|
||||
required=False
|
||||
)
|
||||
role_id = DynamicModelMultipleChoiceField(
|
||||
|
||||
@@ -436,7 +436,8 @@ class IPAddressForm(TenancyForm, NetBoxModelForm):
|
||||
initial['nat_rack'] = nat_inside_parent.device.rack.pk
|
||||
initial['nat_device'] = nat_inside_parent.device.pk
|
||||
elif type(nat_inside_parent) is VMInterface:
|
||||
initial['nat_cluster'] = nat_inside_parent.virtual_machine.cluster.pk
|
||||
if cluster := nat_inside_parent.virtual_machine.cluster:
|
||||
initial['nat_cluster'] = cluster.pk
|
||||
initial['nat_virtual_machine'] = nat_inside_parent.virtual_machine.pk
|
||||
kwargs['initial'] = initial
|
||||
|
||||
|
||||
@@ -9,8 +9,6 @@ from django.utils.functional import cached_property
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from dcim.fields import ASNField
|
||||
from dcim.models import Device
|
||||
from netbox.models import OrganizationalModel, PrimaryModel
|
||||
from ipam.choices import *
|
||||
from ipam.constants import *
|
||||
from ipam.fields import IPNetworkField, IPAddressField
|
||||
@@ -18,8 +16,7 @@ from ipam.managers import IPAddressManager
|
||||
from ipam.querysets import PrefixQuerySet
|
||||
from ipam.validators import DNSValidator
|
||||
from netbox.config import get_config
|
||||
from virtualization.models import VirtualMachine
|
||||
|
||||
from netbox.models import OrganizationalModel, PrimaryModel
|
||||
|
||||
__all__ = (
|
||||
'Aggregate',
|
||||
@@ -101,6 +98,10 @@ class ASN(PrimaryModel):
|
||||
null=True
|
||||
)
|
||||
|
||||
prerequisite_models = (
|
||||
'ipam.RIR',
|
||||
)
|
||||
|
||||
class Meta:
|
||||
ordering = ['asn']
|
||||
verbose_name = 'ASN'
|
||||
@@ -109,10 +110,6 @@ class ASN(PrimaryModel):
|
||||
def __str__(self):
|
||||
return f'AS{self.asn_with_asdot}'
|
||||
|
||||
@classmethod
|
||||
def get_prerequisite_models(cls):
|
||||
return [RIR, ]
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse('ipam:asn', args=[self.pk])
|
||||
|
||||
@@ -163,6 +160,9 @@ class Aggregate(GetAvailablePrefixesMixin, PrimaryModel):
|
||||
clone_fields = (
|
||||
'rir', 'tenant', 'date_added', 'description',
|
||||
)
|
||||
prerequisite_models = (
|
||||
'ipam.RIR',
|
||||
)
|
||||
|
||||
class Meta:
|
||||
ordering = ('prefix', 'pk') # prefix may be non-unique
|
||||
@@ -170,10 +170,6 @@ class Aggregate(GetAvailablePrefixesMixin, PrimaryModel):
|
||||
def __str__(self):
|
||||
return str(self.prefix)
|
||||
|
||||
@classmethod
|
||||
def get_prerequisite_models(cls):
|
||||
return [RIR, ]
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse('ipam:aggregate', args=[self.pk])
|
||||
|
||||
@@ -866,18 +862,6 @@ class IPAddress(PrimaryModel):
|
||||
)
|
||||
})
|
||||
|
||||
# Check for primary IP assignment that doesn't match the assigned device/VM
|
||||
if self.pk:
|
||||
for cls, attr in ((Device, 'device'), (VirtualMachine, 'virtual_machine')):
|
||||
parent = cls.objects.filter(Q(primary_ip4=self) | Q(primary_ip6=self)).first()
|
||||
if parent and getattr(self.assigned_object, attr, None) != parent:
|
||||
# Check for a NAT relationship
|
||||
if not self.nat_inside or getattr(self.nat_inside.assigned_object, attr, None) != parent:
|
||||
raise ValidationError({
|
||||
'interface': f"IP address is primary for {cls._meta.model_name} {parent} but "
|
||||
f"not assigned to it!"
|
||||
})
|
||||
|
||||
# Validate IP status selection
|
||||
if self.status == IPAddressStatusChoices.STATUS_SLAAC and self.family != 6:
|
||||
raise ValidationError({
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
from django.apps import apps
|
||||
from django.contrib.contenttypes.fields import GenericRelation, GenericForeignKey
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.core.exceptions import ValidationError
|
||||
@@ -95,6 +94,9 @@ class L2VPNTermination(NetBoxModel):
|
||||
)
|
||||
|
||||
clone_fields = ('l2vpn',)
|
||||
prerequisite_models = (
|
||||
'ipam.L2VPN',
|
||||
)
|
||||
|
||||
class Meta:
|
||||
ordering = ('l2vpn',)
|
||||
@@ -111,10 +113,6 @@ class L2VPNTermination(NetBoxModel):
|
||||
return f'{self.assigned_object} <> {self.l2vpn}'
|
||||
return super().__str__()
|
||||
|
||||
@classmethod
|
||||
def get_prerequisite_models(cls):
|
||||
return [apps.get_model('ipam.L2VPN'), ]
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse('ipam:l2vpntermination', args=[self.pk])
|
||||
|
||||
|
||||
@@ -31,16 +31,16 @@ class L2VPNTable(TenancyColumnsMixin, NetBoxTable):
|
||||
)
|
||||
comments = columns.MarkdownColumn()
|
||||
tags = columns.TagColumn(
|
||||
url_name='ipam:prefix_list'
|
||||
url_name='ipam:l2vpn_list'
|
||||
)
|
||||
|
||||
class Meta(NetBoxTable.Meta):
|
||||
model = L2VPN
|
||||
fields = (
|
||||
'pk', 'name', 'slug', 'identifier', 'type', 'import_targets', 'export_targets', 'tenant', 'tenant_group',
|
||||
'description', 'comments', 'tags', 'created', 'last_updated', 'actions',
|
||||
'description', 'comments', 'tags', 'created', 'last_updated',
|
||||
)
|
||||
default_columns = ('pk', 'name', 'identifier', 'type', 'description', 'actions')
|
||||
default_columns = ('pk', 'name', 'identifier', 'type', 'description')
|
||||
|
||||
|
||||
class L2VPNTerminationTable(NetBoxTable):
|
||||
|
||||
@@ -1505,6 +1505,10 @@ class L2VPNTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
params = {'name': ['L2VPN 1', 'L2VPN 2']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_slug(self):
|
||||
params = {'slug': ['l2vpn-1', 'l2vpn-2']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_identifier(self):
|
||||
params = {'identifier': ['65001', '65002']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
@@ -9,12 +9,17 @@ import netaddr
|
||||
class OrderingTestBase(TestCase):
|
||||
vrfs = None
|
||||
|
||||
def setUp(self):
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
"""
|
||||
Setup the VRFs for the class as a whole
|
||||
"""
|
||||
self.vrfs = (VRF(name="VRF A"), VRF(name="VRF B"), VRF(name="VRF C"))
|
||||
VRF.objects.bulk_create(self.vrfs)
|
||||
vrfs = (
|
||||
VRF(name='VRF 1'),
|
||||
VRF(name='VRF 2'),
|
||||
VRF(name='VRF 3'),
|
||||
)
|
||||
VRF.objects.bulk_create(vrfs)
|
||||
|
||||
def _compare(self, queryset, objectset):
|
||||
"""
|
||||
@@ -37,10 +42,7 @@ class PrefixOrderingTestCase(OrderingTestBase):
|
||||
"""
|
||||
This is a very basic test, which tests both prefixes without VRFs and prefixes with VRFs
|
||||
"""
|
||||
# Setup VRFs
|
||||
vrfa, vrfb, vrfc = self.vrfs
|
||||
|
||||
# Setup Prefixes
|
||||
vrf1, vrf2, vrf3 = list(VRF.objects.all())
|
||||
prefixes = (
|
||||
Prefix(status=PrefixStatusChoices.STATUS_CONTAINER, vrf=None, prefix=netaddr.IPNetwork('192.168.0.0/16')),
|
||||
Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=None, prefix=netaddr.IPNetwork('192.168.0.0/24')),
|
||||
@@ -50,37 +52,37 @@ class PrefixOrderingTestCase(OrderingTestBase):
|
||||
Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=None, prefix=netaddr.IPNetwork('192.168.4.0/24')),
|
||||
Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=None, prefix=netaddr.IPNetwork('192.168.5.0/24')),
|
||||
|
||||
Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfa, prefix=netaddr.IPNetwork('10.0.0.0/8')),
|
||||
Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfa, prefix=netaddr.IPNetwork('10.0.0.0/16')),
|
||||
Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfa, prefix=netaddr.IPNetwork('10.0.0.0/24')),
|
||||
Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfa, prefix=netaddr.IPNetwork('10.0.1.0/24')),
|
||||
Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfa, prefix=netaddr.IPNetwork('10.0.2.0/24')),
|
||||
Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfa, prefix=netaddr.IPNetwork('10.0.3.0/24')),
|
||||
Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfa, prefix=netaddr.IPNetwork('10.0.4.0/24')),
|
||||
Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfa, prefix=netaddr.IPNetwork('10.1.0.0/16')),
|
||||
Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfa, prefix=netaddr.IPNetwork('10.1.1.0/24')),
|
||||
Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfa, prefix=netaddr.IPNetwork('10.1.2.0/24')),
|
||||
Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfa, prefix=netaddr.IPNetwork('10.1.3.0/24')),
|
||||
Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfa, prefix=netaddr.IPNetwork('10.1.4.0/24')),
|
||||
Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfa, prefix=netaddr.IPNetwork('10.2.0.0/16')),
|
||||
Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfa, prefix=netaddr.IPNetwork('10.2.1.0/24')),
|
||||
Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfa, prefix=netaddr.IPNetwork('10.2.2.0/24')),
|
||||
Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfa, prefix=netaddr.IPNetwork('10.2.3.0/24')),
|
||||
Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfa, prefix=netaddr.IPNetwork('10.2.4.0/24')),
|
||||
Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrf1, prefix=netaddr.IPNetwork('10.0.0.0/8')),
|
||||
Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrf1, prefix=netaddr.IPNetwork('10.0.0.0/16')),
|
||||
Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrf1, prefix=netaddr.IPNetwork('10.0.0.0/24')),
|
||||
Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrf1, prefix=netaddr.IPNetwork('10.0.1.0/24')),
|
||||
Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrf1, prefix=netaddr.IPNetwork('10.0.2.0/24')),
|
||||
Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrf1, prefix=netaddr.IPNetwork('10.0.3.0/24')),
|
||||
Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrf1, prefix=netaddr.IPNetwork('10.0.4.0/24')),
|
||||
Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrf1, prefix=netaddr.IPNetwork('10.1.0.0/16')),
|
||||
Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrf1, prefix=netaddr.IPNetwork('10.1.1.0/24')),
|
||||
Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrf1, prefix=netaddr.IPNetwork('10.1.2.0/24')),
|
||||
Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrf1, prefix=netaddr.IPNetwork('10.1.3.0/24')),
|
||||
Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrf1, prefix=netaddr.IPNetwork('10.1.4.0/24')),
|
||||
Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrf1, prefix=netaddr.IPNetwork('10.2.0.0/16')),
|
||||
Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrf1, prefix=netaddr.IPNetwork('10.2.1.0/24')),
|
||||
Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrf1, prefix=netaddr.IPNetwork('10.2.2.0/24')),
|
||||
Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrf1, prefix=netaddr.IPNetwork('10.2.3.0/24')),
|
||||
Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrf1, prefix=netaddr.IPNetwork('10.2.4.0/24')),
|
||||
|
||||
Prefix(status=PrefixStatusChoices.STATUS_CONTAINER, vrf=vrfb, prefix=netaddr.IPNetwork('172.16.0.0/12')),
|
||||
Prefix(status=PrefixStatusChoices.STATUS_CONTAINER, vrf=vrfb, prefix=netaddr.IPNetwork('172.16.0.0/16')),
|
||||
Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfb, prefix=netaddr.IPNetwork('172.16.0.0/24')),
|
||||
Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfb, prefix=netaddr.IPNetwork('172.16.1.0/24')),
|
||||
Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfb, prefix=netaddr.IPNetwork('172.16.2.0/24')),
|
||||
Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfb, prefix=netaddr.IPNetwork('172.16.3.0/24')),
|
||||
Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfb, prefix=netaddr.IPNetwork('172.16.4.0/24')),
|
||||
Prefix(status=PrefixStatusChoices.STATUS_CONTAINER, vrf=vrfb, prefix=netaddr.IPNetwork('172.17.0.0/16')),
|
||||
Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfb, prefix=netaddr.IPNetwork('172.17.0.0/24')),
|
||||
Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfb, prefix=netaddr.IPNetwork('172.17.1.0/24')),
|
||||
Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfb, prefix=netaddr.IPNetwork('172.17.2.0/24')),
|
||||
Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfb, prefix=netaddr.IPNetwork('172.17.3.0/24')),
|
||||
Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfb, prefix=netaddr.IPNetwork('172.17.4.0/24')),
|
||||
Prefix(status=PrefixStatusChoices.STATUS_CONTAINER, vrf=vrf2, prefix=netaddr.IPNetwork('172.16.0.0/12')),
|
||||
Prefix(status=PrefixStatusChoices.STATUS_CONTAINER, vrf=vrf2, prefix=netaddr.IPNetwork('172.16.0.0/16')),
|
||||
Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrf2, prefix=netaddr.IPNetwork('172.16.0.0/24')),
|
||||
Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrf2, prefix=netaddr.IPNetwork('172.16.1.0/24')),
|
||||
Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrf2, prefix=netaddr.IPNetwork('172.16.2.0/24')),
|
||||
Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrf2, prefix=netaddr.IPNetwork('172.16.3.0/24')),
|
||||
Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrf2, prefix=netaddr.IPNetwork('172.16.4.0/24')),
|
||||
Prefix(status=PrefixStatusChoices.STATUS_CONTAINER, vrf=vrf2, prefix=netaddr.IPNetwork('172.17.0.0/16')),
|
||||
Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrf2, prefix=netaddr.IPNetwork('172.17.0.0/24')),
|
||||
Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrf2, prefix=netaddr.IPNetwork('172.17.1.0/24')),
|
||||
Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrf2, prefix=netaddr.IPNetwork('172.17.2.0/24')),
|
||||
Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrf2, prefix=netaddr.IPNetwork('172.17.3.0/24')),
|
||||
Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrf2, prefix=netaddr.IPNetwork('172.17.4.0/24')),
|
||||
)
|
||||
|
||||
Prefix.objects.bulk_create(prefixes)
|
||||
@@ -104,20 +106,17 @@ class PrefixOrderingTestCase(OrderingTestBase):
|
||||
VRF A:10.1.1.0/24
|
||||
None: 192.168.0.0/16
|
||||
"""
|
||||
# Setup VRFs
|
||||
vrfa, vrfb, vrfc = self.vrfs
|
||||
|
||||
# Setup Prefixes
|
||||
vrf1, vrf2, vrf3 = list(VRF.objects.all())
|
||||
prefixes = [
|
||||
Prefix(status=PrefixStatusChoices.STATUS_CONTAINER, vrf=None, prefix=netaddr.IPNetwork('10.0.0.0/8')),
|
||||
Prefix(status=PrefixStatusChoices.STATUS_CONTAINER, vrf=None, prefix=netaddr.IPNetwork('10.0.0.0/16')),
|
||||
Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=None, prefix=netaddr.IPNetwork('10.1.0.0/16')),
|
||||
Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=None, prefix=netaddr.IPNetwork('192.168.0.0/16')),
|
||||
Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfa, prefix=netaddr.IPNetwork('10.0.0.0/24')),
|
||||
Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfa, prefix=netaddr.IPNetwork('10.0.1.0/24')),
|
||||
Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfa, prefix=netaddr.IPNetwork('10.0.1.0/25')),
|
||||
Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfa, prefix=netaddr.IPNetwork('10.1.0.0/24')),
|
||||
Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfa, prefix=netaddr.IPNetwork('10.1.1.0/24')),
|
||||
Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrf1, prefix=netaddr.IPNetwork('10.0.0.0/24')),
|
||||
Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrf1, prefix=netaddr.IPNetwork('10.0.1.0/24')),
|
||||
Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrf1, prefix=netaddr.IPNetwork('10.0.1.0/25')),
|
||||
Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrf1, prefix=netaddr.IPNetwork('10.1.0.0/24')),
|
||||
Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrf1, prefix=netaddr.IPNetwork('10.1.1.0/24')),
|
||||
]
|
||||
Prefix.objects.bulk_create(prefixes)
|
||||
|
||||
@@ -131,37 +130,34 @@ class IPAddressOrderingTestCase(OrderingTestBase):
|
||||
"""
|
||||
This function tests ordering with the inclusion of vrfs
|
||||
"""
|
||||
# Setup VRFs
|
||||
vrfa, vrfb, vrfc = self.vrfs
|
||||
|
||||
# Setup Addresses
|
||||
vrf1, vrf2, vrf3 = list(VRF.objects.all())
|
||||
addresses = (
|
||||
IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrfa, address=netaddr.IPNetwork('10.0.0.1/24')),
|
||||
IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrfa, address=netaddr.IPNetwork('10.0.1.1/24')),
|
||||
IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrfa, address=netaddr.IPNetwork('10.0.2.1/24')),
|
||||
IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrfa, address=netaddr.IPNetwork('10.0.3.1/24')),
|
||||
IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrfa, address=netaddr.IPNetwork('10.0.4.1/24')),
|
||||
IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrfa, address=netaddr.IPNetwork('10.1.0.1/24')),
|
||||
IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrfa, address=netaddr.IPNetwork('10.1.1.1/24')),
|
||||
IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrfa, address=netaddr.IPNetwork('10.1.2.1/24')),
|
||||
IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrfa, address=netaddr.IPNetwork('10.1.3.1/24')),
|
||||
IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrfa, address=netaddr.IPNetwork('10.1.4.1/24')),
|
||||
IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrfa, address=netaddr.IPNetwork('10.2.0.1/24')),
|
||||
IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrfa, address=netaddr.IPNetwork('10.2.1.1/24')),
|
||||
IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrfa, address=netaddr.IPNetwork('10.2.2.1/24')),
|
||||
IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrfa, address=netaddr.IPNetwork('10.2.3.1/24')),
|
||||
IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrfa, address=netaddr.IPNetwork('10.2.4.1/24')),
|
||||
IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrf1, address=netaddr.IPNetwork('10.0.0.1/24')),
|
||||
IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrf1, address=netaddr.IPNetwork('10.0.1.1/24')),
|
||||
IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrf1, address=netaddr.IPNetwork('10.0.2.1/24')),
|
||||
IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrf1, address=netaddr.IPNetwork('10.0.3.1/24')),
|
||||
IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrf1, address=netaddr.IPNetwork('10.0.4.1/24')),
|
||||
IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrf1, address=netaddr.IPNetwork('10.1.0.1/24')),
|
||||
IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrf1, address=netaddr.IPNetwork('10.1.1.1/24')),
|
||||
IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrf1, address=netaddr.IPNetwork('10.1.2.1/24')),
|
||||
IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrf1, address=netaddr.IPNetwork('10.1.3.1/24')),
|
||||
IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrf1, address=netaddr.IPNetwork('10.1.4.1/24')),
|
||||
IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrf1, address=netaddr.IPNetwork('10.2.0.1/24')),
|
||||
IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrf1, address=netaddr.IPNetwork('10.2.1.1/24')),
|
||||
IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrf1, address=netaddr.IPNetwork('10.2.2.1/24')),
|
||||
IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrf1, address=netaddr.IPNetwork('10.2.3.1/24')),
|
||||
IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrf1, address=netaddr.IPNetwork('10.2.4.1/24')),
|
||||
|
||||
IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrfb, address=netaddr.IPNetwork('172.16.0.1/24')),
|
||||
IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrfb, address=netaddr.IPNetwork('172.16.1.1/24')),
|
||||
IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrfb, address=netaddr.IPNetwork('172.16.2.1/24')),
|
||||
IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrfb, address=netaddr.IPNetwork('172.16.3.1/24')),
|
||||
IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrfb, address=netaddr.IPNetwork('172.16.4.1/24')),
|
||||
IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrfb, address=netaddr.IPNetwork('172.17.0.1/24')),
|
||||
IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrfb, address=netaddr.IPNetwork('172.17.1.1/24')),
|
||||
IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrfb, address=netaddr.IPNetwork('172.17.2.1/24')),
|
||||
IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrfb, address=netaddr.IPNetwork('172.17.3.1/24')),
|
||||
IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrfb, address=netaddr.IPNetwork('172.17.4.1/24')),
|
||||
IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrf2, address=netaddr.IPNetwork('172.16.0.1/24')),
|
||||
IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrf2, address=netaddr.IPNetwork('172.16.1.1/24')),
|
||||
IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrf2, address=netaddr.IPNetwork('172.16.2.1/24')),
|
||||
IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrf2, address=netaddr.IPNetwork('172.16.3.1/24')),
|
||||
IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrf2, address=netaddr.IPNetwork('172.16.4.1/24')),
|
||||
IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrf2, address=netaddr.IPNetwork('172.17.0.1/24')),
|
||||
IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrf2, address=netaddr.IPNetwork('172.17.1.1/24')),
|
||||
IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrf2, address=netaddr.IPNetwork('172.17.2.1/24')),
|
||||
IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrf2, address=netaddr.IPNetwork('172.17.3.1/24')),
|
||||
IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrf2, address=netaddr.IPNetwork('172.17.4.1/24')),
|
||||
|
||||
IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=None, address=netaddr.IPNetwork('192.168.0.1/24')),
|
||||
IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=None, address=netaddr.IPNetwork('192.168.1.1/24')),
|
||||
|
||||
@@ -314,7 +314,8 @@ class AggregatePrefixesView(generic.ObjectChildrenView):
|
||||
tab = ViewTab(
|
||||
label=_('Prefixes'),
|
||||
badge=lambda x: x.get_child_prefixes().count(),
|
||||
permission='ipam.view_prefix'
|
||||
permission='ipam.view_prefix',
|
||||
weight=500
|
||||
)
|
||||
|
||||
def get_children(self, request, parent):
|
||||
@@ -502,7 +503,8 @@ class PrefixPrefixesView(generic.ObjectChildrenView):
|
||||
tab = ViewTab(
|
||||
label=_('Child Prefixes'),
|
||||
badge=lambda x: x.get_child_prefixes().count(),
|
||||
permission='ipam.view_prefix'
|
||||
permission='ipam.view_prefix',
|
||||
weight=500
|
||||
)
|
||||
|
||||
def get_children(self, request, parent):
|
||||
@@ -536,7 +538,8 @@ class PrefixIPRangesView(generic.ObjectChildrenView):
|
||||
tab = ViewTab(
|
||||
label=_('Child Ranges'),
|
||||
badge=lambda x: x.get_child_ranges().count(),
|
||||
permission='ipam.view_iprange'
|
||||
permission='ipam.view_iprange',
|
||||
weight=600
|
||||
)
|
||||
|
||||
def get_children(self, request, parent):
|
||||
@@ -561,7 +564,8 @@ class PrefixIPAddressesView(generic.ObjectChildrenView):
|
||||
tab = ViewTab(
|
||||
label=_('IP Addresses'),
|
||||
badge=lambda x: x.get_child_ips().count(),
|
||||
permission='ipam.view_ipaddress'
|
||||
permission='ipam.view_ipaddress',
|
||||
weight=700
|
||||
)
|
||||
|
||||
def get_children(self, request, parent):
|
||||
@@ -635,7 +639,8 @@ class IPRangeIPAddressesView(generic.ObjectChildrenView):
|
||||
tab = ViewTab(
|
||||
label=_('IP Addresses'),
|
||||
badge=lambda x: x.get_child_ips().count(),
|
||||
permission='ipam.view_ipaddress'
|
||||
permission='ipam.view_ipaddress',
|
||||
weight=500
|
||||
)
|
||||
|
||||
def get_children(self, request, parent):
|
||||
@@ -1075,7 +1080,8 @@ class VLANInterfacesView(generic.ObjectChildrenView):
|
||||
tab = ViewTab(
|
||||
label=_('Device Interfaces'),
|
||||
badge=lambda x: x.get_interfaces().count(),
|
||||
permission='dcim.view_interface'
|
||||
permission='dcim.view_interface',
|
||||
weight=500
|
||||
)
|
||||
|
||||
def get_children(self, request, parent):
|
||||
@@ -1092,7 +1098,8 @@ class VLANVMInterfacesView(generic.ObjectChildrenView):
|
||||
tab = ViewTab(
|
||||
label=_('VM Interfaces'),
|
||||
badge=lambda x: x.get_vminterfaces().count(),
|
||||
permission='virtualization.view_vminterface'
|
||||
permission='virtualization.view_vminterface',
|
||||
weight=510
|
||||
)
|
||||
|
||||
def get_children(self, request, parent):
|
||||
|
||||
@@ -137,9 +137,7 @@ class NetBoxModelViewSet(BulkUpdateModelMixin, BulkDestroyModelMixin, ObjectVali
|
||||
)
|
||||
|
||||
def list(self, request, *args, **kwargs):
|
||||
"""
|
||||
Overrides ListModelMixin to allow processing ExportTemplates.
|
||||
"""
|
||||
# Overrides ListModelMixin to allow processing ExportTemplates.
|
||||
if 'export' in request.GET:
|
||||
content_type = ContentType.objects.get_for_model(self.get_serializer_class().Meta.model)
|
||||
et = ExportTemplate.objects.filter(content_types=content_type, name=request.GET['export']).first()
|
||||
|
||||
@@ -152,6 +152,9 @@ LOGIN_REQUIRED = False
|
||||
# re-authenticate. (Default: 1209600 [14 days])
|
||||
LOGIN_TIMEOUT = None
|
||||
|
||||
# The view name or URL to which users are redirected after logging out.
|
||||
LOGOUT_REDIRECT_URL = 'home'
|
||||
|
||||
# The file path where uploaded media such as image attachments are stored. A trailing slash is not needed. Note that
|
||||
# the default value of this setting is derived from the installed location.
|
||||
# MEDIA_ROOT = '/opt/netbox/netbox/media'
|
||||
|
||||
@@ -1,2 +1,7 @@
|
||||
# Prefix for nested serializers
|
||||
NESTED_SERIALIZER_PREFIX = 'Nested'
|
||||
|
||||
# RQ queue names
|
||||
RQ_QUEUE_DEFAULT = 'default'
|
||||
RQ_QUEUE_HIGH = 'high'
|
||||
RQ_QUEUE_LOW = 'low'
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import re
|
||||
|
||||
from django import forms
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
@@ -12,6 +14,7 @@ LOOKUP_CHOICES = (
|
||||
(LookupTypes.EXACT, _('Exact match')),
|
||||
(LookupTypes.STARTSWITH, _('Starts with')),
|
||||
(LookupTypes.ENDSWITH, _('Ends with')),
|
||||
(LookupTypes.REGEX, _('Regex')),
|
||||
)
|
||||
|
||||
|
||||
@@ -43,3 +46,14 @@ class SearchForm(BootstrapMixin, forms.Form):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
self.fields['obj_types'].choices = search_backend.get_object_types()
|
||||
|
||||
def clean(self):
|
||||
|
||||
# Validate regular expressions
|
||||
if self.cleaned_data['lookup'] == LookupTypes.REGEX:
|
||||
try:
|
||||
re.compile(self.cleaned_data['q'])
|
||||
except re.error as e:
|
||||
raise forms.ValidationError({
|
||||
'q': f'Invalid regular expression: {e}'
|
||||
})
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
from django import forms
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db.models import Q
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
@@ -48,6 +47,9 @@ class NetBoxModelForm(BootstrapMixin, CustomFieldsMixin, forms.ModelForm):
|
||||
|
||||
# Save custom field data on instance
|
||||
for cf_name, customfield in self.custom_fields.items():
|
||||
if cf_name not in self.fields:
|
||||
# Custom fields may be absent when performing bulk updates via import
|
||||
continue
|
||||
key = cf_name[3:] # Strip "cf_" from field name
|
||||
value = self.cleaned_data.get(cf_name)
|
||||
|
||||
|
||||
@@ -54,8 +54,7 @@ class ObjectListField(DjangoListField):
|
||||
|
||||
@staticmethod
|
||||
def list_resolver(django_object_type, resolver, default_manager, root, info, **args):
|
||||
# Get the QuerySet from the object type
|
||||
queryset = django_object_type.get_queryset(default_manager, info)
|
||||
queryset = super(ObjectListField, ObjectListField).list_resolver(django_object_type, resolver, default_manager, root, info, **args)
|
||||
|
||||
# Instantiate and apply the FilterSet, if defined
|
||||
filterset_class = django_object_type._meta.filterset_class
|
||||
|
||||
@@ -3,9 +3,9 @@ from django.core.validators import ValidationError
|
||||
from django.db import models
|
||||
from mptt.models import MPTTModel, TreeForeignKey
|
||||
|
||||
from netbox.models.features import *
|
||||
from utilities.mptt import TreeManager
|
||||
from utilities.querysets import RestrictedQuerySet
|
||||
from netbox.models.features import *
|
||||
|
||||
__all__ = (
|
||||
'ChangeLoggedModel',
|
||||
@@ -33,14 +33,6 @@ class NetBoxFeatureSet(
|
||||
def docs_url(self):
|
||||
return f'{settings.STATIC_URL}docs/models/{self._meta.app_label}/{self._meta.model_name}/'
|
||||
|
||||
@classmethod
|
||||
def get_prerequisite_models(cls):
|
||||
"""
|
||||
Return a list of model types that are required to create this model or empty list if none. This is used for
|
||||
showing prerequisite warnings in the UI on the list and detail views.
|
||||
"""
|
||||
return []
|
||||
|
||||
|
||||
#
|
||||
# Base model classes
|
||||
|
||||
@@ -51,6 +51,10 @@ class Menu:
|
||||
icon_class: str
|
||||
groups: Sequence[MenuGroup]
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
return self.label.replace(' ', '_')
|
||||
|
||||
|
||||
#
|
||||
# Utility functions
|
||||
|
||||
@@ -299,7 +299,7 @@ OTHER_MENU = Menu(
|
||||
),
|
||||
MenuItem(
|
||||
link='extras:jobresult_list',
|
||||
link_text=_('Job Results'),
|
||||
link_text=_('Jobs'),
|
||||
permissions=['extras.view_jobresult'],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -18,6 +18,7 @@ class LookupTypes:
|
||||
EXACT = 'iexact'
|
||||
STARTSWITH = 'istartswith'
|
||||
ENDSWITH = 'iendswith'
|
||||
REGEX = 'iregex'
|
||||
|
||||
|
||||
class SearchIndex:
|
||||
|
||||
@@ -17,13 +17,14 @@ from extras.plugins import PluginConfig
|
||||
from sentry_sdk.integrations.django import DjangoIntegration
|
||||
|
||||
from netbox.config import PARAMS
|
||||
from netbox.constants import RQ_QUEUE_DEFAULT, RQ_QUEUE_HIGH, RQ_QUEUE_LOW
|
||||
|
||||
|
||||
#
|
||||
# Environment setup
|
||||
#
|
||||
|
||||
VERSION = '3.4-beta1'
|
||||
VERSION = '3.4.0'
|
||||
|
||||
# Hostname
|
||||
HOSTNAME = platform.node()
|
||||
@@ -97,10 +98,12 @@ LOGGING = getattr(configuration, 'LOGGING', {})
|
||||
LOGIN_PERSISTENCE = getattr(configuration, 'LOGIN_PERSISTENCE', False)
|
||||
LOGIN_REQUIRED = getattr(configuration, 'LOGIN_REQUIRED', False)
|
||||
LOGIN_TIMEOUT = getattr(configuration, 'LOGIN_TIMEOUT', None)
|
||||
LOGOUT_REDIRECT_URL = getattr(configuration, 'LOGOUT_REDIRECT_URL', 'home')
|
||||
MEDIA_ROOT = getattr(configuration, 'MEDIA_ROOT', os.path.join(BASE_DIR, 'media')).rstrip('/')
|
||||
METRICS_ENABLED = getattr(configuration, 'METRICS_ENABLED', False)
|
||||
PLUGINS = getattr(configuration, 'PLUGINS', [])
|
||||
PLUGINS_CONFIG = getattr(configuration, 'PLUGINS_CONFIG', {})
|
||||
QUEUE_MAPPINGS = getattr(configuration, 'QUEUE_MAPPINGS', {})
|
||||
RELEASE_CHECK_URL = getattr(configuration, 'RELEASE_CHECK_URL', None)
|
||||
REMOTE_AUTH_AUTO_CREATE_USER = getattr(configuration, 'REMOTE_AUTH_AUTO_CREATE_USER', False)
|
||||
REMOTE_AUTH_BACKEND = getattr(configuration, 'REMOTE_AUTH_BACKEND', 'netbox.authentication.RemoteUserBackend')
|
||||
@@ -440,6 +443,10 @@ EXEMPT_PATHS = (
|
||||
f'/{BASE_PATH}metrics',
|
||||
)
|
||||
|
||||
SERIALIZATION_MODULES = {
|
||||
'json': 'utilities.serializers.json',
|
||||
}
|
||||
|
||||
|
||||
#
|
||||
# Sentry
|
||||
@@ -616,8 +623,6 @@ if TASKS_REDIS_USING_SENTINEL:
|
||||
RQ_PARAMS = {
|
||||
'SENTINELS': TASKS_REDIS_SENTINELS,
|
||||
'MASTER_NAME': TASKS_REDIS_SENTINEL_SERVICE,
|
||||
'DB': TASKS_REDIS_DATABASE,
|
||||
'PASSWORD': TASKS_REDIS_PASSWORD,
|
||||
'SOCKET_TIMEOUT': None,
|
||||
'CONNECTION_KWARGS': {
|
||||
'socket_connect_timeout': TASKS_REDIS_SENTINEL_TIMEOUT
|
||||
@@ -627,19 +632,26 @@ else:
|
||||
RQ_PARAMS = {
|
||||
'HOST': TASKS_REDIS_HOST,
|
||||
'PORT': TASKS_REDIS_PORT,
|
||||
'DB': TASKS_REDIS_DATABASE,
|
||||
'PASSWORD': TASKS_REDIS_PASSWORD,
|
||||
'SSL': TASKS_REDIS_SSL,
|
||||
'SSL_CERT_REQS': None if TASKS_REDIS_SKIP_TLS_VERIFY else 'required',
|
||||
'DEFAULT_TIMEOUT': RQ_DEFAULT_TIMEOUT,
|
||||
}
|
||||
RQ_PARAMS.update({
|
||||
'DB': TASKS_REDIS_DATABASE,
|
||||
'PASSWORD': TASKS_REDIS_PASSWORD,
|
||||
'DEFAULT_TIMEOUT': RQ_DEFAULT_TIMEOUT,
|
||||
})
|
||||
|
||||
RQ_QUEUES = {
|
||||
'high': RQ_PARAMS,
|
||||
'default': RQ_PARAMS,
|
||||
'low': RQ_PARAMS,
|
||||
RQ_QUEUE_HIGH: RQ_PARAMS,
|
||||
RQ_QUEUE_DEFAULT: RQ_PARAMS,
|
||||
RQ_QUEUE_LOW: RQ_PARAMS,
|
||||
}
|
||||
|
||||
# Add any queues defined in QUEUE_MAPPINGS
|
||||
RQ_QUEUES.update({
|
||||
queue: RQ_PARAMS for queue in set(QUEUE_MAPPINGS.values()) if queue not in RQ_QUEUES
|
||||
})
|
||||
|
||||
|
||||
#
|
||||
# Plugins
|
||||
|
||||
@@ -7,6 +7,7 @@ from django.contrib.auth.models import AnonymousUser
|
||||
from django.db.models import DateField, DateTimeField
|
||||
from django.template import Context, Template
|
||||
from django.urls import reverse
|
||||
from django.utils.dateparse import parse_date
|
||||
from django.utils.encoding import escape_uri_path
|
||||
from django.utils.html import escape
|
||||
from django.utils.formats import date_format
|
||||
@@ -28,6 +29,7 @@ __all__ = (
|
||||
'ContentTypesColumn',
|
||||
'CustomFieldColumn',
|
||||
'CustomLinkColumn',
|
||||
'DurationColumn',
|
||||
'LinkedCountColumn',
|
||||
'MarkdownColumn',
|
||||
'ManyToManyColumn',
|
||||
@@ -50,6 +52,10 @@ class DateColumn(tables.DateColumn):
|
||||
tables and null when exporting data. It is registered in the tables library to use this class instead of the
|
||||
default, making this behavior consistent in all fields of type DateField.
|
||||
"""
|
||||
def render(self, value):
|
||||
if value:
|
||||
return date_format(value, format="SHORT_DATE_FORMAT")
|
||||
|
||||
def value(self, value):
|
||||
return value
|
||||
|
||||
@@ -77,6 +83,24 @@ class DateTimeColumn(tables.DateTimeColumn):
|
||||
return cls(**kwargs)
|
||||
|
||||
|
||||
class DurationColumn(tables.Column):
|
||||
"""
|
||||
Express a duration of time (in minutes) in a human-friendly format. Example: 437 minutes becomes "7h 17m"
|
||||
"""
|
||||
def render(self, value):
|
||||
ret = ''
|
||||
if days := value // 1440:
|
||||
ret += f'{days}d '
|
||||
if hours := value % 1440 // 60:
|
||||
ret += f'{hours}h '
|
||||
if minutes := value % 60:
|
||||
ret += f'{minutes}m'
|
||||
return ret.strip()
|
||||
|
||||
def value(self, value):
|
||||
return value
|
||||
|
||||
|
||||
class ManyToManyColumn(tables.ManyToManyColumn):
|
||||
"""
|
||||
Overrides django-tables2's stock ManyToManyColumn to ensure that value() returns only plaintext data.
|
||||
@@ -425,6 +449,12 @@ class CustomFieldColumn(tables.Column):
|
||||
kwargs['accessor'] = Accessor(f'custom_field_data__{customfield.name}')
|
||||
if 'verbose_name' not in kwargs:
|
||||
kwargs['verbose_name'] = customfield.label or customfield.name
|
||||
# We can't logically sort on FK values
|
||||
if customfield.type in (
|
||||
CustomFieldTypeChoices.TYPE_OBJECT,
|
||||
CustomFieldTypeChoices.TYPE_MULTIOBJECT
|
||||
):
|
||||
kwargs['orderable'] = False
|
||||
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
@@ -449,6 +479,8 @@ class CustomFieldColumn(tables.Column):
|
||||
))
|
||||
if self.customfield.type == CustomFieldTypeChoices.TYPE_LONGTEXT and value:
|
||||
return render_markdown(value)
|
||||
if self.customfield.type == CustomFieldTypeChoices.TYPE_DATE and value:
|
||||
return date_format(parse_date(value), format="SHORT_DATE_FORMAT")
|
||||
if value is not None:
|
||||
obj = self.customfield.deserialize(value)
|
||||
return mark_safe(self._linkify_item(obj))
|
||||
|
||||
@@ -3,7 +3,7 @@ from django.test import override_settings
|
||||
|
||||
from dcim.models import *
|
||||
from users.models import ObjectPermission
|
||||
from utilities.forms.choices import ImportFormatChoices
|
||||
from utilities.choices import ImportFormatChoices
|
||||
from utilities.testing import ModelViewTestCase, create_tags
|
||||
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user