Compare commits

...

56 Commits

Author SHA1 Message Date
Jeremy Stretch
8f6b71df46 Merge pull request #6723 from netbox-community/develop
Release v2.11.9
2021-07-08 09:18:11 -04:00
jeremystretch
e8e3e9b0be Release v2.11.9 2021-07-08 09:01:40 -04:00
jeremystretch
28ca815c88 Fixes #6456: API schema type should be boolean for _occupied on cable termination models 2021-07-08 08:41:59 -04:00
jeremystretch
54dfa6cb7f Fixes #6714: Fix rendering of device type component creation forms 2021-07-07 15:38:59 -04:00
jeremystretch
7c667f3485 Fixes #6710: Fix assignment of VM interface parent via REST API 2021-07-07 11:55:20 -04:00
jeremystretch
c585175214 PRVB 2021-07-06 11:35:03 -04:00
Jeremy Stretch
a5b95728bf Merge pull request #6700 from netbox-community/develop
Release v2.11.8
2021-07-06 11:28:19 -04:00
Jeremy Stretch
c742501b80 Merge branch 'master' into develop 2021-07-06 11:13:29 -04:00
jeremystretch
9c1de27562 Release v2.11.8 2021-07-06 11:10:02 -04:00
jeremystretch
fc15ef6967 Changelog & cleanup for #5503 2021-07-06 10:43:08 -04:00
Jeremy Stretch
eaf0259c3d Merge pull request #5764 from ypid/feature/5503-ui-iso-date-with-tooltip
Closes #5503: ISO 8601 date in UI and alternative format as tooltip
2021-07-06 10:35:21 -04:00
jeremystretch
fe2ce03ac1 Closes #6200: Add rack reservations to global search 2021-07-06 10:17:16 -04:00
jeremystretch
70585ff32e Fixes #6695: Fix exception when importing device type with invalid front port definition 2021-07-05 09:30:52 -04:00
Robin Schneider
a479c867c4 Do not use annotated_date on custom date fields to avoid date parsing
@jeremystretch:

> It'd be better to have the custom field return a date object than to
> accommodate string values in the template filter. Let's just omit custom
> field dates for now to keep this from getting any more complex.
2021-07-02 22:30:11 +02:00
Robin Schneider
74f1b51b38 Use annotated_date also for updated datetimes
This changes the text from: Updated 5 months, 1 week ago
to: Updated 2021-01-24 00:33 (5 months, 1 week ago)

Co-authored-by: Jeremy Stretch <jstretch@ns1.com>
2021-07-02 22:22:38 +02:00
Robin Schneider
0ad9b83623 Closes #5503: ISO 8601 date in UI and alternative format as tooltip
With this commit all dates in the UI are now consistently displayed.

I changed the long date format as suggested by @xkilian and confirmed by my own
research.

* DATETIME_FORMAT
 * Before July 20, 2020 4:52 p.m.
 * Now 20th July, 2020 16:52

"20th July, 2020" would be spoken as "the 20th of July, 2020" but the "the" and
"of" are never written.

The only exception is `object_list.html`. I tried it but there it does not
work so easily because the dates are passed to Jinja as SafeString.
2021-07-02 22:22:37 +02:00
jeremystretch
631d991d8d Closes #6368: Enable virtual chassis assignment during bulk import of devices 2021-07-01 15:49:05 -04:00
jeremystretch
1be4a57bd4 Closes #6345: Introduce PermissionsViolation exception for use in generic views 2021-07-01 15:33:39 -04:00
jeremystretch
76a6119584 Closes #6138: Add an 'empty' filter modifier for character fields 2021-07-01 15:17:46 -04:00
jeremystretch
add95292ce Fixes #6680: Allow setting custom field values for VM interfaces on intial creation 2021-07-01 10:48:24 -04:00
jeremystretch
18a9e39be6 Closes #6667: Display VM memory as GB/TB as appropriate 2021-06-29 14:00:16 -04:00
jeremystretch
18934bcc69 Closes #6666: Show management-only status under interface detail view 2021-06-29 13:47:44 -04:00
jeremystretch
98ff00bc62 Fixes #6676: Fix device/VM counts per cluster under cluster type/group views 2021-06-29 13:44:46 -04:00
jeremystretch
4292d88a92 Closes #6620: Show assigned VMs count under device role view 2021-06-22 14:21:41 -04:00
jeremystretch
a8af24d7ca Fixes #6637: Fix group assignment in 'available VLANs' link under VLAN group view 2021-06-22 14:16:16 -04:00
jeremystretch
efa0fc2b09 Fixes #6640: Disallow numeric values in custom text fields 2021-06-22 14:00:54 -04:00
jeremystretch
ebb2918a88 Fixes #6652: Fix exception when adding components in bulk to multiple devices 2021-06-22 13:54:03 -04:00
jeremystretch
607039f043 Cleanup for #5139 2021-06-21 08:46:20 -04:00
jeremystretch
fb379b63ec Fixes #6626: Fix site field on VM search form; add site group 2021-06-21 08:38:46 -04:00
jeremystretch
697161beb1 PRVB 2021-06-16 16:21:19 -04:00
Jeremy Stretch
742804ecb8 Merge pull request #6616 from netbox-community/develop
Release v2.11.7
2021-06-16 16:18:52 -04:00
jeremystretch
2bf20fa501 Release NetBox v2.11.7 2021-06-16 15:59:46 -04:00
jeremystretch
685e0ce00d Closes #6588: Add support for webp files as front/rear device type images 2021-06-16 14:01:30 -04:00
jeremystretch
6a6b0236a9 Closes #6589: Standardize breadcrumb navigation for power panels and feeds 2021-06-16 13:50:35 -04:00
jeremystretch
857c70ece9 Closes #6564: Add N connector type for pass-through ports 2021-06-16 13:43:38 -04:00
jeremystretch
e68be6f041 Add Equinix Metal as a sponsor 2021-06-15 15:22:20 -04:00
Jeremy Stretch
52edeb42b5 Merge pull request #6604 from bluikko/patch-2
custom fields documentation missing word "more"
2021-06-15 10:57:58 -04:00
bluikko
c8a8bfd84d custom fields documentation missing word "more"
The "one or object types" looks like it is missing the word "more".
2021-06-15 15:05:37 +07:00
jeremystretch
9f2c4919eb Update NetDev Slack links 2021-06-14 16:41:10 -04:00
jeremystretch
f56a470cc7 Fixes #6602: Fix deletion of devices with cables attached 2021-06-14 16:38:19 -04:00
jeremystretch
54ccc705d0 Adopt IRM terminology 2021-06-14 14:08:55 -04:00
jeremystretch
7e481960f9 Optimize MPTTColumn rendering 2021-06-14 09:19:05 -04:00
jeremystretch
809d9e4697 Fixes #6584: Fix ordering of nested inventory items 2021-06-10 14:27:42 -04:00
jeremystretch
79c06442db Changelog for #6455, 6493 2021-06-08 15:39:39 -04:00
Jeremy Stretch
6195fc0d11 Merge pull request #6552 from drmsoffall/6493-diff-legacy-changes
Show change log diff for non-atomic changes
2021-06-08 15:24:22 -04:00
Jeremy Stretch
6523334a48 Merge pull request #6545 from crafty-ua/Add_ipv4_32_and_ipv6_128_prefix_support_#6455
Add ipv4 /32 and ipv6 /128 prefix support #6455
2021-06-08 15:12:25 -04:00
jeremystretch
b3cde51590 Fixes #6562: Disable ordering of secrets by assigned object 2021-06-08 14:18:24 -04:00
jeremystretch
6ec296f2a7 Fixes #6563: Fix filtering by location for cable connection forms 2021-06-08 14:15:06 -04:00
jeremystretch
cb4392628f Fixes #6553: ProviderNetwork search should match on name 2021-06-08 14:06:17 -04:00
drmsoffall
a224e5d470 Closes #6493: show ObjectChange diff for non-atomic changes 2021-06-05 19:15:25 +00:00
jeremystretch
7444110c79 PRVB 2021-06-04 11:15:12 -04:00
Jeremy Stretch
fc0c8a160b Merge pull request #6548 from netbox-community/develop
Release v2.11.6
2021-06-04 11:13:26 -04:00
Jeremy Stretch
481cc52686 Merge branch 'master' into develop 2021-06-04 11:01:33 -04:00
jeremystretch
4273b6e4fb Release v2.11.6 2021-06-04 10:59:36 -04:00
jeremystretch
5e08b2be37 Fixes #6544: Fix migration error when upgrading with VRF(s) defined 2021-06-04 10:53:13 -04:00
Your Name
a665b79f85 #6455 - initial 2021-06-04 16:46:02 +02:00
72 changed files with 430 additions and 239 deletions

View File

@@ -17,7 +17,7 @@ body:
What version of NetBox are you currently running? (If you don't have access to the most
recent NetBox release, consider testing on our [demo instance](https://demo.netbox.dev/)
before opening a bug report to see if your issue has already been addressed.)
placeholder: v2.11.5
placeholder: v2.11.9
validations:
required: true
- type: dropdown

View File

@@ -14,7 +14,7 @@ body:
attributes:
label: NetBox version
description: What version of NetBox are you currently running?
placeholder: v2.11.5
placeholder: v2.11.9
validations:
required: true
- type: dropdown

View File

@@ -25,7 +25,7 @@ discussions.
### Slack
For real-time chat, you can join the **#netbox** Slack channel on [NetDev Community](https://slack.netbox.dev/).
For real-time chat, you can join the **#netbox** Slack channel on [NetDev Community](https://netdev.chat/).
Unfortunately, the Slack channel does not provide long-term retention of chat
history, so try to avoid it for any discussions would benefit from being
preserved for future reference.

View File

@@ -2,8 +2,10 @@
<img src="https://raw.githubusercontent.com/netbox-community/netbox/develop/docs/netbox_logo.svg" width="400" alt="NetBox logo" />
</div>
NetBox is an IP address management (IPAM) and data center infrastructure
management (DCIM) tool. Initially conceived by the network engineering team at
![Master branch build status](https://github.com/netbox-community/netbox/workflows/CI/badge.svg?branch=master)
NetBox is an infrastructure resource modeling (IRM) tool designed to empower
network automation. Initially conceived by the network engineering team at
[DigitalOcean](https://www.digitalocean.com/), NetBox was developed specifically
to address the needs of network and infrastructure engineers. It is intended to
function as a domain-specific source of truth for network operations.
@@ -14,18 +16,15 @@ complete list of requirements, see `requirements.txt`. The code is available [on
The complete documentation for NetBox can be found at [Read the Docs](https://netbox.readthedocs.io/en/stable/). A public demo instance is available at https://demo.netbox.dev.
| | status |
|-------------|------------|
| **master** | ![Build status](https://github.com/netbox-community/netbox/workflows/CI/badge.svg?branch=master) |
| **develop** | ![Build status](https://github.com/netbox-community/netbox/workflows/CI/badge.svg?branch=develop) |
<div align="center">
<h4>Thank you to our sponsors!</h4>
[![DigitalOcean](https://raw.githubusercontent.com/wiki/netbox-community/netbox/images/sponsors/digitalocean.png)](https://try.digitalocean.com/developer-cloud)
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
[![NS1](https://raw.githubusercontent.com/wiki/netbox-community/netbox/images/sponsors/ns1.png)](https://ns1.com/)
[![Equinix Metal](https://raw.githubusercontent.com/wiki/netbox-community/netbox/images/sponsors/equinix.png)](https://metal.equinix.com/)
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
[![NS1](https://raw.githubusercontent.com/wiki/netbox-community/netbox/images/sponsors/ns1.png)](https://ns1.com/)
<br />
[![Stellar Technologies](https://raw.githubusercontent.com/wiki/netbox-community/netbox/images/sponsors/stellar.png)](https://stellar.tech/)
</div>
@@ -33,7 +32,7 @@ The complete documentation for NetBox can be found at [Read the Docs](https://ne
### Discussion
* [GitHub Discussions](https://github.com/netbox-community/netbox/discussions) - Discussion forum hosted by GitHub; ideal for Q&A and other structured discussions
* [Slack](https://slack.netbox.dev/) - Real-time chat hosted by the NetDev Community; best for unstructured discussion or just hanging out
* [Slack](https://netdev.chat/) - Real-time chat hosted by the NetDev Community; best for unstructured discussion or just hanging out
* [Google Group](https://groups.google.com/g/netbox-discuss) - Legacy mailing list; slowly being replaced by GitHub discussions
### Installation

View File

@@ -24,7 +24,7 @@ Marking a field as required will force the user to provide a value for the field
The filter logic controls how values are matched when filtering objects by the custom field. Loose filtering (the default) matches on a partial value, whereas exact matching requires a complete match of the given string to a field's value. For example, exact filtering with the string "red" will only match the exact value "red", whereas loose filtering will match on the values "red", "red-orange", or "bored". Setting the filter logic to "disabled" disables filtering by the field entirely.
A custom field must be assigned to one or object types, or models, in NetBox. Once created, custom fields will automatically appear as part of these models in the web UI and REST API. Note that not all models support custom fields.
A custom field must be assigned to one or more object types, or models, in NetBox. Once created, custom fields will automatically appear as part of these models in the web UI and REST API. Note that not all models support custom fields.
### Custom Field Validation

View File

@@ -8,7 +8,7 @@ There are several official forums for communication among the developers and com
* [GitHub issues](https://github.com/netbox-community/netbox/issues) - All feature requests, bug reports, and other substantial changes to the code base **must** be documented in an issue.
* [GitHub Discussions](https://github.com/netbox-community/netbox/discussions) - The preferred forum for general discussion and support issues. Ideal for shaping a feature request prior to submitting an issue.
* [#netbox on NetDev Community Slack](https://slack.netbox.dev/) - Good for quick chats. Avoid any discussion that might need to be referenced later on, as the chat history is not retained long.
* [#netbox on NetDev Community Slack](https://netdev.chat/) - Good for quick chats. Avoid any discussion that might need to be referenced later on, as the chat history is not retained long.
* [Google Group](https://groups.google.com/g/netbox-discuss) - Legacy mailing list; slowly being phased out in favor of GitHub discussions.
## Governance

View File

@@ -2,7 +2,7 @@
# What is NetBox?
NetBox is an open source web application designed to help manage and document computer networks. Initially conceived by the network engineering team at [DigitalOcean](https://www.digitalocean.com/), NetBox was developed specifically to address the needs of network and infrastructure engineers. It encompasses the following aspects of network management:
NetBox is an infrastructure resource modeling (IRM) application designed to empower network automation. Initially conceived by the network engineering team at [DigitalOcean](https://www.digitalocean.com/), NetBox was developed specifically to address the needs of network and infrastructure engineers. NetBox is made available as open source under the Apache 2 license. It encompasses the following aspects of network management:
* **IP address management (IPAM)** - IP networks and addresses, VRFs, and VLANs
* **Equipment racks** - Organized by group and site

View File

@@ -1,5 +1,67 @@
# NetBox v2.11
## v2.11.9 (2021-07-08)
### Bug Fixes
* [#6456](https://github.com/netbox-community/netbox/issues/6456) - API schema type should be boolean for `_occupied` on cable termination models
* [#6710](https://github.com/netbox-community/netbox/issues/6710) - Fix assignment of VM interface parent via REST API
* [#6714](https://github.com/netbox-community/netbox/issues/6714) - Fix rendering of device type component creation forms
---
## v2.11.8 (2021-07-06)
### Enhancements
* [#5503](https://github.com/netbox-community/netbox/issues/5503) - Annotate short date & time fields with their longer form
* [#6138](https://github.com/netbox-community/netbox/issues/6138) - Add an `empty` filter modifier for character fields
* [#6200](https://github.com/netbox-community/netbox/issues/6200) - Add rack reservations to global search
* [#6368](https://github.com/netbox-community/netbox/issues/6368) - Enable virtual chassis assignment during bulk import of devices
* [#6620](https://github.com/netbox-community/netbox/issues/6620) - Show assigned VMs count under device role view
* [#6666](https://github.com/netbox-community/netbox/issues/6666) - Show management-only status under interface detail view
* [#6667](https://github.com/netbox-community/netbox/issues/6667) - Display VM memory as GB/TB as appropriate
### Bug Fixes
* [#6626](https://github.com/netbox-community/netbox/issues/6626) - Fix site field on VM search form; add site group
* [#6637](https://github.com/netbox-community/netbox/issues/6637) - Fix group assignment in "available VLANs" link under VLAN group view
* [#6640](https://github.com/netbox-community/netbox/issues/6640) - Disallow numeric values in custom text fields
* [#6652](https://github.com/netbox-community/netbox/issues/6652) - Fix exception when adding components in bulk to multiple devices
* [#6676](https://github.com/netbox-community/netbox/issues/6676) - Fix device/VM counts per cluster under cluster type/group views
* [#6680](https://github.com/netbox-community/netbox/issues/6680) - Allow setting custom field values for VM interfaces on initial creation
* [#6695](https://github.com/netbox-community/netbox/issues/6695) - Fix exception when importing device type with invalid front port definition
---
## v2.11.7 (2021-06-16)
### Enhancements
* [#6455](https://github.com/netbox-community/netbox/issues/6455) - Permit /32 IPv4 and /128 IPv6 prefixes
* [#6493](https://github.com/netbox-community/netbox/issues/6493) - Show change log diff for non-atomic (pre-2.11) changes
* [#6564](https://github.com/netbox-community/netbox/issues/6564) - Add N connector type for pass-through ports
* [#6588](https://github.com/netbox-community/netbox/issues/6588) - Add support for webp files as front/rear device type images
* [#6589](https://github.com/netbox-community/netbox/issues/6589) - Standardize breadcrumb navigation for power panels and feeds
### Bug Fixes
* [#6553](https://github.com/netbox-community/netbox/issues/6553) - ProviderNetwork search should match on name
* [#6562](https://github.com/netbox-community/netbox/issues/6562) - Disable ordering of secrets by assigned object
* [#6563](https://github.com/netbox-community/netbox/issues/6563) - Fix filtering by location for cable connection forms
* [#6584](https://github.com/netbox-community/netbox/issues/6584) - Fix ordering of nested inventory items
* [#6602](https://github.com/netbox-community/netbox/issues/6602) - Fix deletion of devices with cables attached
---
## v2.11.6 (2021-06-04)
### Bug Fixes
* [#6544](https://github.com/netbox-community/netbox/issues/6544) - Fix migration error when upgrading with VRF(s) defined
---
## v2.11.5 (2021-06-04)
**NOTE:** This release includes a database migration that calculates and annotates prefix depth. It may impose a noticeable delay on the upgrade process: Users should anticipate roughly one minute of delay per 100 thousand prefixes being updated.

View File

@@ -61,25 +61,30 @@ These lookup expressions can be applied by adding a suffix to the desired field'
Numeric based fields (ASN, VLAN ID, etc) support these lookup expressions:
- `n` - not equal to (negation)
- `lt` - less than
- `lte` - less than or equal
- `gt` - greater than
- `gte` - greater than or equal
| Filter | Description |
|--------|-------------|
| `n` | Not equal to |
| `lt` | Less than |
| `lte` | Less than or equal to |
| `gt` | Greater than |
| `gte` | Greater than or equal to |
### String Fields
String based (char) fields (Name, Address, etc) support these lookup expressions:
- `n` - not equal to (negation)
- `ic` - case insensitive contains
- `nic` - negated case insensitive contains
- `isw` - case insensitive starts with
- `nisw` - negated case insensitive starts with
- `iew` - case insensitive ends with
- `niew` - negated case insensitive ends with
- `ie` - case insensitive exact match
- `nie` - negated case insensitive exact match
| Filter | Description |
|--------|-------------|
| `n` | Not equal to |
| `ic` | Contains (case-insensitive) |
| `nic` | Does not contain (case-insensitive) |
| `isw` | Starts with (case-insensitive) |
| `nisw` | Does not start with (case-insensitive) |
| `iew` | Ends with (case-insensitive) |
| `niew` | Does not end with (case-insensitive) |
| `ie` | Exact match (case-insensitive) |
| `nie` | Inverse exact match (case-insensitive) |
| `empty` | Is empty (boolean) |
### Foreign Keys & Other Fields

View File

@@ -104,6 +104,7 @@ class ProviderNetworkFilterSet(PrimaryModelFilterSet):
if not value.strip():
return queryset
return queryset.filter(
Q(name__icontains=value) |
Q(description__icontains=value) |
Q(comments__icontains=value)
).distinct()

View File

@@ -25,6 +25,7 @@ from .nested_serializers import *
class CableTerminationSerializer(serializers.ModelSerializer):
cable_peer_type = serializers.SerializerMethodField(read_only=True)
cable_peer = serializers.SerializerMethodField(read_only=True)
_occupied = serializers.SerializerMethodField(read_only=True)
def get_cable_peer_type(self, obj):
if obj._cable_peer is not None:
@@ -42,6 +43,10 @@ class CableTerminationSerializer(serializers.ModelSerializer):
return serializer(obj._cable_peer, context=context).data
return None
@swagger_serializer_method(serializer_or_field=serializers.BooleanField)
def get__occupied(self, obj):
return obj._occupied
class ConnectedEndpointSerializer(serializers.ModelSerializer):
connected_endpoint_type = serializers.SerializerMethodField(read_only=True)

View File

@@ -924,6 +924,7 @@ class PortTypeChoices(ChoiceSet):
TYPE_110_PUNCH = '110-punch'
TYPE_BNC = 'bnc'
TYPE_F = 'f'
TYPE_N = 'n'
TYPE_MRJ21 = 'mrj21'
TYPE_ST = 'st'
TYPE_SC = 'sc'
@@ -954,6 +955,7 @@ class PortTypeChoices(ChoiceSet):
(TYPE_110_PUNCH, '110 Punch'),
(TYPE_BNC, 'BNC'),
(TYPE_F, 'F Connector'),
(TYPE_N, 'N Connector'),
(TYPE_MRJ21, 'MRJ21'),
),
),

View File

@@ -2,6 +2,9 @@ from django.db.models import Q
from .choices import InterfaceTypeChoices
# Exclude SVG images (unsupported by PIL)
DEVICETYPE_IMAGE_FORMATS = 'image/bmp,image/gif,image/jpeg,image/png,image/tiff,image/webp'
#
# Racks

View File

@@ -1172,12 +1172,11 @@ class DeviceTypeForm(BootstrapMixin, CustomFieldModelForm):
)
widgets = {
'subdevice_role': StaticSelect2(),
# Exclude SVG images (unsupported by PIL)
'front_image': forms.ClearableFileInput(attrs={
'accept': 'image/bmp,image/gif,image/jpeg,image/png,image/tiff'
'accept': DEVICETYPE_IMAGE_FORMATS
}),
'rear_image': forms.ClearableFileInput(attrs={
'accept': 'image/bmp,image/gif,image/jpeg,image/png,image/tiff'
'accept': DEVICETYPE_IMAGE_FORMATS
})
}
@@ -1879,8 +1878,7 @@ class FrontPortTemplateImportForm(ComponentTemplateImportForm):
)
rear_port = forms.ModelChoiceField(
queryset=RearPortTemplate.objects.all(),
to_field_name='name',
required=False
to_field_name='name'
)
class Meta:
@@ -2237,6 +2235,12 @@ class BaseDeviceCSVForm(CustomFieldModelCSVForm):
choices=DeviceStatusChoices,
help_text='Operational status'
)
virtual_chassis = CSVModelChoiceField(
queryset=VirtualChassis.objects.all(),
to_field_name='name',
required=False,
help_text='Virtual chassis'
)
cluster = CSVModelChoiceField(
queryset=Cluster.objects.all(),
to_field_name='name',
@@ -2247,6 +2251,10 @@ class BaseDeviceCSVForm(CustomFieldModelCSVForm):
class Meta:
fields = []
model = Device
help_texts = {
'vc_position': 'Virtual chassis position',
'vc_priority': 'Virtual chassis priority',
}
def __init__(self, data=None, *args, **kwargs):
super().__init__(data, *args, **kwargs)
@@ -2285,7 +2293,8 @@ class DeviceCSVForm(BaseDeviceCSVForm):
class Meta(BaseDeviceCSVForm.Meta):
fields = [
'name', 'device_role', 'tenant', 'manufacturer', 'device_type', 'platform', 'serial', 'asset_tag', 'status',
'site', 'location', 'rack', 'position', 'face', 'cluster', 'comments',
'site', 'location', 'rack', 'position', 'face', 'virtual_chassis', 'vc_position', 'vc_priority', 'cluster',
'comments',
]
def __init__(self, data=None, *args, **kwargs):
@@ -2320,7 +2329,7 @@ class ChildDeviceCSVForm(BaseDeviceCSVForm):
class Meta(BaseDeviceCSVForm.Meta):
fields = [
'name', 'device_role', 'tenant', 'manufacturer', 'device_type', 'platform', 'serial', 'asset_tag', 'status',
'parent', 'device_bay', 'cluster', 'comments',
'parent', 'device_bay', 'virtual_chassis', 'vc_position', 'vc_priority', 'cluster', 'comments',
]
def __init__(self, data=None, *args, **kwargs):
@@ -3948,6 +3957,7 @@ class ConnectCableToDeviceForm(BootstrapMixin, CustomFieldModelForm):
required=False,
query_params={
'site_id': '$termination_b_site',
'location_id': '$termination_b_location',
'rack_id': '$termination_b_rack',
}
)

View File

@@ -290,19 +290,24 @@ class FrontPortTemplate(ComponentTemplateModel):
def clean(self):
super().clean()
# Validate rear port assignment
if self.rear_port.device_type != self.device_type:
raise ValidationError(
"Rear port ({}) must belong to the same device type".format(self.rear_port)
)
try:
# Validate rear port position assignment
if self.rear_port_position > self.rear_port.positions:
raise ValidationError(
"Invalid rear port position ({}); rear port {} has only {} positions".format(
self.rear_port_position, self.rear_port.name, self.rear_port.positions
# Validate rear port assignment
if self.rear_port.device_type != self.device_type:
raise ValidationError(
"Rear port ({}) must belong to the same device type".format(self.rear_port)
)
)
# Validate rear port position assignment
if self.rear_port_position > self.rear_port.positions:
raise ValidationError(
"Invalid rear port position ({}); rear port {} has only {} positions".format(
self.rear_port_position, self.rear_port.name, self.rear_port.positions
)
)
except RearPortTemplate.DoesNotExist:
pass
def instantiate(self, device):
if self.rear_port:

View File

@@ -146,14 +146,12 @@ def nullify_connected_endpoints(instance, **kwargs):
# Disassociate the Cable from its termination points
if instance.termination_a is not None:
logger.debug(f"Nullifying termination A for cable {instance}")
instance.termination_a.cable = None
instance.termination_a._cable_peer = None
instance.termination_a.save()
model = instance.termination_a._meta.model
model.objects.filter(pk=instance.termination_a.pk).update(_cable_peer_type=None, _cable_peer_id=None)
if instance.termination_b is not None:
logger.debug(f"Nullifying termination B for cable {instance}")
instance.termination_b.cable = None
instance.termination_b._cable_peer = None
instance.termination_b.save()
model = instance.termination_b._meta.model
model.objects.filter(pk=instance.termination_b.pk).update(_cable_peer_type=None, _cable_peer_id=None)
# Delete and retrace any dependent cable paths
for cablepath in CablePath.objects.filter(path__contains=instance):

View File

@@ -694,7 +694,7 @@ class InventoryItemTable(DeviceComponentTable):
)
cable = None # Override DeviceComponentTable
class Meta(DeviceComponentTable.Meta):
class Meta(BaseTable.Meta):
model = InventoryItem
fields = (
'pk', 'device', 'name', 'label', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'description',
@@ -715,7 +715,7 @@ class DeviceInventoryItemTable(InventoryItemTable):
buttons=('edit', 'delete')
)
class Meta(DeviceComponentTable.Meta):
class Meta(BaseTable.Meta):
model = InventoryItem
fields = (
'pk', 'name', 'label', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'description', 'discovered',

View File

@@ -1211,8 +1211,9 @@ class InterfaceTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase
{
'device': device.pk,
'name': 'Interface 6',
'type': '1000base-t',
'type': 'virtual',
'mode': InterfaceModeChoices.MODE_TAGGED,
'parent': interfaces[0].pk,
'tagged_vlans': [vlans[0].pk, vlans[1].pk],
'untagged_vlan': vlans[2].pk,
},

View File

@@ -1023,6 +1023,8 @@ class DeviceTestCase(ViewTestCases.PrimaryObjectViewTestCase):
tags = create_tags('Alpha', 'Bravo', 'Charlie')
VirtualChassis.objects.create(name='Virtual Chassis 1')
cls.form_data = {
'device_type': devicetypes[1].pk,
'device_role': deviceroles[1].pk,
@@ -1048,10 +1050,10 @@ class DeviceTestCase(ViewTestCases.PrimaryObjectViewTestCase):
}
cls.csv_data = (
"device_role,manufacturer,device_type,status,name,site,location,rack,position,face",
"Device Role 1,Manufacturer 1,Device Type 1,active,Device 4,Site 1,Location 1,Rack 1,10,front",
"Device Role 1,Manufacturer 1,Device Type 1,active,Device 5,Site 1,Location 1,Rack 1,20,front",
"Device Role 1,Manufacturer 1,Device Type 1,active,Device 6,Site 1,Location 1,Rack 1,30,front",
"device_role,manufacturer,device_type,status,name,site,location,rack,position,face,virtual_chassis,vc_position,vc_priority",
"Device Role 1,Manufacturer 1,Device Type 1,active,Device 4,Site 1,Location 1,Rack 1,10,front,Virtual Chassis 1,1,10",
"Device Role 1,Manufacturer 1,Device Type 1,active,Device 5,Site 1,Location 1,Rack 1,20,front,Virtual Chassis 1,2,20",
"Device Role 1,Manufacturer 1,Device Type 1,active,Device 6,Site 1,Location 1,Rack 1,30,front,Virtual Chassis 1,3,30",
)
cls.bulk_edit_data = {

View File

@@ -1169,6 +1169,8 @@ class DeviceRoleView(generic.ObjectView):
return {
'devices_table': devices_table,
'device_count': Device.objects.filter(device_role=instance).count(),
'virtualmachine_count': VirtualMachine.objects.filter(role=instance).count(),
}

View File

@@ -5,4 +5,5 @@ class ExtrasConfig(AppConfig):
name = "extras"
def ready(self):
import extras.lookups
import extras.signals

17
netbox/extras/lookups.py Normal file
View File

@@ -0,0 +1,17 @@
from django.db.models import CharField, Lookup
class Empty(Lookup):
"""
Filter on whether a string is empty.
"""
lookup_name = 'empty'
def as_sql(self, qn, connection):
lhs, lhs_params = self.process_lhs(qn, connection)
rhs, rhs_params = self.process_rhs(qn, connection)
params = lhs_params + rhs_params
return 'CAST(LENGTH(%s) AS BOOLEAN) != %s' % (lhs, rhs), params
CharField.register_lookup(Empty)

View File

@@ -280,8 +280,10 @@ class CustomField(BigIDModel):
if value not in [None, '']:
# Validate text field
if self.type == CustomFieldTypeChoices.TYPE_TEXT and self.validation_regex:
if not re.match(self.validation_regex, value):
if self.type == CustomFieldTypeChoices.TYPE_TEXT:
if type(value) is not str:
raise ValidationError(f"Value must be a string.")
if self.validation_regex and not re.match(self.validation_regex, value):
raise ValidationError(f"Value must match regex '{self.validation_regex}'")
# Validate integer

View File

@@ -431,9 +431,8 @@ class JournalEntry(ChangeLoggedModel):
verbose_name_plural = 'journal entries'
def __str__(self):
created_date = timezone.localdate(self.created)
created_time = timezone.localtime(self.created)
return f"{date_format(created_date)} - {time_format(created_time)} ({self.get_kind_display()})"
created = timezone.localtime(self.created)
return f"{date_format(created, format='SHORT_DATETIME_FORMAT')} ({self.get_kind_display()})"
def get_absolute_url(self):
return reverse('extras:journalentry', args=[self.pk])

View File

@@ -202,15 +202,22 @@ class ObjectChangeView(generic.ObjectView):
next_change = objectchanges.filter(time__gt=instance.time).order_by('time').first()
prev_change = objectchanges.filter(time__lt=instance.time).order_by('-time').first()
if instance.prechange_data and instance.postchange_data:
if not instance.prechange_data and instance.action in ['update', 'delete'] and prev_change:
non_atomic_change = True
prechange_data = prev_change.postchange_data
else:
non_atomic_change = False
prechange_data = instance.prechange_data
if prechange_data and instance.postchange_data:
diff_added = shallow_compare_dict(
instance.prechange_data or dict(),
prechange_data or dict(),
instance.postchange_data or dict(),
exclude=['last_updated'],
)
diff_removed = {
x: instance.prechange_data.get(x) for x in diff_added
} if instance.prechange_data else {}
x: prechange_data.get(x) for x in diff_added
} if prechange_data else {}
else:
diff_added = None
diff_removed = None
@@ -221,7 +228,8 @@ class ObjectChangeView(generic.ObjectView):
'next_change': next_change,
'prev_change': prev_change,
'related_changes_table': related_changes_table,
'related_changes_count': related_changes.count()
'related_changes_count': related_changes.count(),
'non_atomic_change': non_atomic_change
}

View File

@@ -22,6 +22,6 @@ class Command(BaseCommand):
for vrf in VRF.objects.all():
vrf_count = Prefix.objects.filter(vrf=vrf).count()
self.stdout.write(f'VRF {vrf}: {vrf_count} prefixes...')
rebuild_prefixes(vrf)
rebuild_prefixes(vrf.pk)
self.stdout.write(self.style.SUCCESS('Finished.'))

View File

@@ -20,7 +20,7 @@ def populate_prefix_hierarchy(apps, schema_editor):
# Iterate through all VRFs, rebuilding each
for vrf in VRF.objects.all():
rebuild_prefixes(vrf)
rebuild_prefixes(vrf.pk)
class Migration(migrations.Migration):

View File

@@ -340,16 +340,6 @@ class Prefix(PrimaryModel):
'prefix': "Cannot create prefix with /0 mask."
})
# Disallow host masks
if self.prefix.version == 4 and self.prefix.prefixlen == 32:
raise ValidationError({
'prefix': "Cannot create host addresses (/32) as prefixes. Create an IPv4 address instead."
})
elif self.prefix.version == 6 and self.prefix.prefixlen == 128:
raise ValidationError({
'prefix': "Cannot create host addresses (/128) as prefixes. Create an IPv6 address instead."
})
# Enforce unique IP space (if applicable)
if (self.vrf is None and settings.ENFORCE_GLOBAL_UNIQUE) or (self.vrf and self.vrf.enforce_unique):
duplicate_prefixes = self.get_duplicates()
@@ -471,8 +461,8 @@ class Prefix(PrimaryModel):
child_ips = netaddr.IPSet([ip.address.ip for ip in self.get_child_ips()])
available_ips = prefix - child_ips
# IPv6, pool, or IPv4 /31 sets are fully usable
if self.family == 6 or self.is_pool or self.prefix.prefixlen == 31:
# IPv6, pool, or IPv4 /31-/32 sets are fully usable
if self.family == 6 or self.is_pool or (self.family == 4 and self.prefix.prefixlen >= 31):
return available_ips
# For "normal" IPv4 prefixes, omit first and last addresses

View File

@@ -65,7 +65,7 @@ VLAN_LINK = """
{% if record.pk %}
<a href="{{ record.get_absolute_url }}">{{ record.vid }}</a>
{% elif perms.ipam.add_vlan %}
<a href="{% url 'ipam:vlan_add' %}?vid={{ record.vid }}&group={{ vlan_group.pk }}{% if vlan_group.site %}&site={{ vlan_group.site.pk }}{% endif %}" class="btn btn-xs btn-success">{{ record.available }} VLAN{{ record.available|pluralize }} available</a>
<a href="{% url 'ipam:vlan_add' %}?vid={{ record.vid }}{% if record.vlan_group %}&group={{ record.vlan_group.pk }}{% endif %}" class="btn btn-xs btn-success">{{ record.available }} VLAN{{ record.available|pluralize }} available</a>
{% else %}
{{ record.available }} VLAN{{ record.available|pluralize }} available
{% endif %}

View File

@@ -68,24 +68,40 @@ def add_available_ipaddresses(prefix, ipaddress_list, is_pool=False):
return output
def add_available_vlans(vlan_group, vlans):
def add_available_vlans(vlans, vlan_group=None):
"""
Create fake records for all gaps between used VLANs
"""
if not vlans:
return [{'vid': VLAN_VID_MIN, 'available': VLAN_VID_MAX - VLAN_VID_MIN + 1}]
return [{
'vid': VLAN_VID_MIN,
'vlan_group': vlan_group,
'available': VLAN_VID_MAX - VLAN_VID_MIN + 1
}]
prev_vid = VLAN_VID_MAX
new_vlans = []
for vlan in vlans:
if vlan.vid - prev_vid > 1:
new_vlans.append({'vid': prev_vid + 1, 'available': vlan.vid - prev_vid - 1})
new_vlans.append({
'vid': prev_vid + 1,
'vlan_group': vlan_group,
'available': vlan.vid - prev_vid - 1,
})
prev_vid = vlan.vid
if vlans[0].vid > VLAN_VID_MIN:
new_vlans.append({'vid': VLAN_VID_MIN, 'available': vlans[0].vid - VLAN_VID_MIN})
new_vlans.append({
'vid': VLAN_VID_MIN,
'vlan_group': vlan_group,
'available': vlans[0].vid - VLAN_VID_MIN,
})
if prev_vid < VLAN_VID_MAX:
new_vlans.append({'vid': prev_vid + 1, 'available': VLAN_VID_MAX - prev_vid})
new_vlans.append({
'vid': prev_vid + 1,
'vlan_group': vlan_group,
'available': VLAN_VID_MAX - prev_vid,
})
vlans = list(vlans) + new_vlans
vlans.sort(key=lambda v: v.vid if type(v) == VLAN else v['vid'])

View File

@@ -145,7 +145,6 @@ class RIRListView(generic.ObjectListView):
filterset = filtersets.RIRFilterSet
filterset_form = forms.RIRFilterForm
table = tables.RIRTable
template_name = 'ipam/rir_list.html'
class RIRView(generic.ObjectView):
@@ -522,7 +521,7 @@ class IPAddressView(generic.ObjectView):
# Parent prefixes table
parent_prefixes = Prefix.objects.restrict(request.user, 'view').filter(
vrf=instance.vrf,
prefix__net_contains=str(instance.address.ip)
prefix__net_contains_or_equals=str(instance.address.ip)
).prefetch_related(
'site', 'role'
)
@@ -676,7 +675,7 @@ class VLANGroupView(generic.ObjectView):
Prefetch('prefixes', queryset=Prefix.objects.restrict(request.user))
).order_by('vid')
vlans_count = vlans.count()
vlans = add_available_vlans(instance, vlans)
vlans = add_available_vlans(vlans, vlan_group=instance)
vlans_table = tables.VLANDetailTable(vlans)
if request.user.has_perm('ipam.change_vlan') or request.user.has_perm('ipam.delete_vlan'):

View File

@@ -4,12 +4,12 @@ from circuits.filtersets import CircuitFilterSet, ProviderFilterSet, ProviderNet
from circuits.models import Circuit, ProviderNetwork, Provider
from circuits.tables import CircuitTable, ProviderNetworkTable, ProviderTable
from dcim.filtersets import (
CableFilterSet, DeviceFilterSet, DeviceTypeFilterSet, PowerFeedFilterSet, RackFilterSet, LocationFilterSet,
SiteFilterSet, VirtualChassisFilterSet,
CableFilterSet, DeviceFilterSet, DeviceTypeFilterSet, PowerFeedFilterSet, RackFilterSet, RackReservationFilterSet,
LocationFilterSet, SiteFilterSet, VirtualChassisFilterSet,
)
from dcim.models import Cable, Device, DeviceType, PowerFeed, Rack, Location, Site, VirtualChassis
from dcim.models import Cable, Device, DeviceType, Location, PowerFeed, Rack, RackReservation, Site, VirtualChassis
from dcim.tables import (
CableTable, DeviceTable, DeviceTypeTable, PowerFeedTable, RackTable, LocationTable, SiteTable,
CableTable, DeviceTable, DeviceTypeTable, PowerFeedTable, RackTable, RackReservationTable, LocationTable, SiteTable,
VirtualChassisTable,
)
from ipam.filtersets import AggregateFilterSet, IPAddressFilterSet, PrefixFilterSet, VLANFilterSet, VRFFilterSet
@@ -64,6 +64,12 @@ SEARCH_TYPES = OrderedDict((
'table': RackTable,
'url': 'dcim:rack_list',
}),
('rackreservation', {
'queryset': RackReservation.objects.prefetch_related('site', 'rack', 'user'),
'filterset': RackReservationFilterSet,
'table': RackReservationTable,
'url': 'dcim:rackreservation_list',
}),
('location', {
'queryset': Location.objects.add_related_count(
Location.objects.all(),

View File

@@ -89,13 +89,13 @@ class BaseFilterSet(django_filters.FilterSet):
filters.MultiValueNumberFilter,
filters.MultiValueTimeFilter
)):
lookup_map = FILTER_NUMERIC_BASED_LOOKUP_MAP
return FILTER_NUMERIC_BASED_LOOKUP_MAP
elif isinstance(existing_filter, (
filters.TreeNodeMultipleChoiceFilter,
)):
# TreeNodeMultipleChoiceFilter only support negation but must maintain the `in` lookup expression
lookup_map = FILTER_TREENODE_NEGATION_LOOKUP_MAP
return FILTER_TREENODE_NEGATION_LOOKUP_MAP
elif isinstance(existing_filter, (
django_filters.ModelChoiceFilter,
@@ -103,7 +103,7 @@ class BaseFilterSet(django_filters.FilterSet):
TagFilter
)) or existing_filter.extra.get('choices'):
# These filter types support only negation
lookup_map = FILTER_NEGATION_LOOKUP_MAP
return FILTER_NEGATION_LOOKUP_MAP
elif isinstance(existing_filter, (
django_filters.filters.CharFilter,
@@ -111,12 +111,9 @@ class BaseFilterSet(django_filters.FilterSet):
filters.MultiValueCharFilter,
filters.MultiValueMACAddressFilter
)):
lookup_map = FILTER_CHAR_BASED_LOOKUP_MAP
return FILTER_CHAR_BASED_LOOKUP_MAP
else:
lookup_map = None
return lookup_map
return None
@classmethod
def get_filters(cls):

View File

@@ -11,12 +11,13 @@ OBJ_TYPE_CHOICES = (
('DCIM', (
('site', 'Sites'),
('rack', 'Racks'),
('rackreservation', 'Rack reservations'),
('location', 'Locations'),
('devicetype', 'Device types'),
('device', 'Devices'),
('virtualchassis', 'Virtual Chassis'),
('virtualchassis', 'Virtual chassis'),
('cable', 'Cables'),
('powerfeed', 'Power Feeds'),
('powerfeed', 'Power feeds'),
)),
('IPAM', (
('vrf', 'VRFs'),

View File

@@ -16,7 +16,7 @@ from django.core.validators import URLValidator
# Environment setup
#
VERSION = '2.11.5'
VERSION = '2.11.9'
# Hostname
HOSTNAME = platform.node()

View File

@@ -18,7 +18,7 @@ from django_tables2.export import TableExport
from extras.models import CustomField, ExportTemplate
from utilities.error_handlers import handle_protectederror
from utilities.exceptions import AbortTransaction
from utilities.exceptions import AbortTransaction, PermissionsViolation
from utilities.forms import (
BootstrapMixin, BulkRenameForm, ConfirmationForm, CSVDataField, ImportForm, TableConfigForm, restrict_form_fields,
)
@@ -290,7 +290,8 @@ class ObjectEditView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
obj = form.save()
# Check that the new object conforms with any assigned object-level permissions
self.queryset.get(pk=obj.pk)
if not self.queryset.filter(pk=obj.pk).first():
raise PermissionsViolation()
msg = '{} {}'.format(
'Created' if object_created else 'Modified',
@@ -318,7 +319,7 @@ class ObjectEditView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
else:
return redirect(self.get_return_url(request, obj))
except ObjectDoesNotExist:
except PermissionsViolation:
msg = "Object save failed due to object-level permissions violation"
logger.debug(msg)
form.add_error(None, msg)
@@ -480,7 +481,7 @@ class BulkCreateView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
# Enforce object-level permissions
if self.queryset.filter(pk__in=[obj.pk for obj in new_objs]).count() != len(new_objs):
raise ObjectDoesNotExist
raise PermissionsViolation
# If we make it to this point, validation has succeeded on all new objects.
msg = "Added {} {}".format(len(new_objs), model._meta.verbose_name_plural)
@@ -494,7 +495,7 @@ class BulkCreateView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
except IntegrityError:
pass
except ObjectDoesNotExist:
except PermissionsViolation:
msg = "Object creation failed due to object-level permissions violation"
logger.debug(msg)
form.add_error(None, msg)
@@ -565,7 +566,8 @@ class ObjectImportView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
obj = model_form.save()
# Enforce object-level permissions
self.queryset.get(pk=obj.pk)
if not self.queryset.filter(pk=obj.pk).first():
raise PermissionsViolation()
logger.debug(f"Created {obj} (PK: {obj.pk})")
@@ -601,7 +603,7 @@ class ObjectImportView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
except AbortTransaction:
pass
except ObjectDoesNotExist:
except PermissionsViolation:
msg = "Object creation failed due to object-level permissions violation"
logger.debug(msg)
form.add_error(None, msg)
@@ -712,7 +714,7 @@ class BulkImportView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
# Enforce object-level permissions
if self.queryset.filter(pk__in=[obj.pk for obj in new_objs]).count() != len(new_objs):
raise ObjectDoesNotExist
raise PermissionsViolation
# Compile a table containing the imported objects
obj_table = self.table(new_objs)
@@ -730,7 +732,7 @@ class BulkImportView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
except ValidationError:
pass
except ObjectDoesNotExist:
except PermissionsViolation:
msg = "Object import failed due to object-level permissions violation"
logger.debug(msg)
form.add_error(None, msg)
@@ -845,7 +847,7 @@ class BulkEditView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
# Enforce object-level permissions
if self.queryset.filter(pk__in=[obj.pk for obj in updated_objects]).count() != len(updated_objects):
raise ObjectDoesNotExist
raise PermissionsViolation
if updated_objects:
msg = 'Updated {} {}'.format(len(updated_objects), model._meta.verbose_name_plural)
@@ -857,7 +859,7 @@ class BulkEditView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
except ValidationError as e:
messages.error(self.request, "{} failed validation: {}".format(obj, e))
except ObjectDoesNotExist:
except PermissionsViolation:
msg = "Object update failed due to object-level permissions violation"
logger.debug(msg)
form.add_error(None, msg)
@@ -952,7 +954,7 @@ class BulkRenameView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
# Enforce constrained permissions
if self.queryset.filter(pk__in=renamed_pks).count() != len(selected_objects):
raise ObjectDoesNotExist
raise PermissionsViolation
messages.success(request, "Renamed {} {}".format(
len(selected_objects),
@@ -960,7 +962,7 @@ class BulkRenameView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
))
return redirect(self.get_return_url(request))
except ObjectDoesNotExist:
except PermissionsViolation:
msg = "Object update failed due to object-level permissions violation"
logger.debug(msg)
form.add_error(None, msg)
@@ -1146,7 +1148,7 @@ class ComponentCreateView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View
# Enforce object-level permissions
if self.queryset.filter(pk__in=[obj.pk for obj in new_objs]).count() != len(new_objs):
raise ObjectDoesNotExist
raise PermissionsViolation
messages.success(request, "Added {} {}".format(
len(new_components), self.queryset.model._meta.verbose_name_plural
@@ -1156,7 +1158,7 @@ class ComponentCreateView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View
else:
return redirect(self.get_return_url(request))
except ObjectDoesNotExist:
except PermissionsViolation:
msg = "Component creation failed due to object-level permissions violation"
logger.debug(msg)
form.add_error(None, msg)
@@ -1229,7 +1231,7 @@ class BulkComponentCreateView(GetReturnURLMixin, ObjectPermissionRequiredMixin,
component_form = self.model_form(component_data)
if component_form.is_valid():
instance = component_form.save()
logger.debug(f"Created {instance} on {instance.parent}")
logger.debug(f"Created {instance} on {instance.parent_object}")
new_components.append(instance)
else:
for field, errors in component_form.errors.as_data().items():
@@ -1238,12 +1240,12 @@ class BulkComponentCreateView(GetReturnURLMixin, ObjectPermissionRequiredMixin,
# Enforce object-level permissions
if self.queryset.filter(pk__in=[obj.pk for obj in new_components]).count() != len(new_components):
raise ObjectDoesNotExist
raise PermissionsViolation
except IntegrityError:
pass
except ObjectDoesNotExist:
except PermissionsViolation:
msg = "Component creation failed due to object-level permissions violation"
logger.debug(msg)
form.add_error(None, msg)

View File

@@ -37,6 +37,7 @@ class SecretTable(BaseTable):
)
assigned_object = tables.Column(
linkify=True,
orderable=False,
verbose_name='Assigned object'
)
role = tables.Column(

View File

@@ -67,7 +67,7 @@
<p class="text-muted">{{ settings.HOSTNAME }} (v{{ settings.VERSION }})</p>
</div>
<div class="col-xs-4 text-center">
<p class="text-muted">{% now 'Y-m-d H:i:s T' %}</p>
<p class="text-muted">{% annotated_now %} {% now 'T' %}</p>
</div>
<div class="col-xs-4 text-right noprint">
<p class="text-muted">

View File

@@ -51,7 +51,7 @@
</tr>
<tr>
<td>Install Date</td>
<td>{{ object.install_date|placeholder }}</td>
<td>{{ object.install_date|annotated_date|placeholder }}</td>
</tr>
<tr>
<td>Commit Rate</td>

View File

@@ -1,4 +1,5 @@
{% extends 'base.html' %}
{% load helpers %}
{% load form_helpers %}
{% block title %}Create {{ component_type }}{% endblock %}
@@ -18,19 +19,34 @@
{% endif %}
<div class="panel panel-default">
<div class="panel-heading">
<strong>{{ component_type|title }}</strong>
<strong>{{ component_type|bettertitle }}</strong>
</div>
<div class="panel-body">
{% render_form form %}
{% for field in form.hidden_fields %}
{{ field }}
{% endfor %}
{% for field in form.visible_fields %}
{% if not form.custom_fields or field.name not in form.custom_fields %}
{% render_field field %}
{% endif %}
{% endfor %}
</div>
</div>
<div class="form-group">
{% if form.custom_fields %}
<div class="panel panel-default">
<div class="panel-heading"><strong>Custom Fields</strong></div>
<div class="panel-body">
{% render_custom_fields form %}
</div>
</div>
{% endif %}
<div class="form-group">
<div class="col-md-9 col-md-offset-3">
<button type="submit" name="_create" class="btn btn-primary">Create</button>
<button type="submit" name="_addanother" class="btn btn-primary">Create and Add More</button>
<a href="{{ return_url }}" class="btn btn-default">Cancel</a>
</div>
</div>
</div>
</div>
</div>
</form>

View File

@@ -42,7 +42,17 @@
<tr>
<td>Devices</td>
<td>
<a href="{% url 'dcim:device_list' %}?role_id={{ object.pk }}">{{ devices_table.rows|length }}</a>
<a href="{% url 'dcim:device_list' %}?role_id={{ object.pk }}">{{ device_count }}</a>
</td>
</tr>
<tr>
<td>Virtual Machines</td>
<td>
{% if object.vm_role %}
<a href="{% url 'virtualization:virtualmachine_list' %}?role_id={{ object.pk }}">{{ virtualmachine_count }}</a>
{% else %}
&mdash;
{% endif %}
</td>
</tr>
</table>

View File

@@ -54,6 +54,16 @@
{% endif %}
</td>
</tr>
<tr>
<td>Management Only</td>
<td>
{% if object.mgmt_only %}
<span class="text-success"><i class="mdi mdi-check-bold"></i></span>
{% else %}
<span class="text-danger"><i class="mdi mdi-close"></i></span>
{% endif %}
</td>
</tr>
<tr>
<td>Parent</td>
<td>
@@ -88,7 +98,7 @@
</tr>
<tr>
<td>802.1Q Mode</td>
<td>{{ object.get_mode_display }}</td>
<td>{{ object.get_mode_display|placeholder }}</td>
</tr>
</table>
</div>

View File

@@ -7,10 +7,10 @@
{% block breadcrumbs %}
<li><a href="{% url 'dcim:powerfeed_list' %}">Power Feeds</a></li>
<li><a href="{{ object.power_panel.site.get_absolute_url }}">{{ object.power_panel.site }}</a></li>
<li><a href="{{ object.power_panel.get_absolute_url }}">{{ object.power_panel }}</a></li>
<li><a href="{% url 'dcim:powerfeed_list' %}?site_id={{ object.power_panel.site.pk }}">{{ object.power_panel.site }}</a></li>
<li><a href="{% url 'dcim:powerfeed_list' %}?power_panel_id={{ object.power_panel.pk }}">{{ object.power_panel }}</a></li>
{% if object.rack %}
<li><a href="{{ object.rack.get_absolute_url }}">{{ object.rack }}</a></li>
<li><a href="{% url 'dcim:powerfeed_list' %}?rack_id={{ object.rack.pk }}">{{ object.rack }}</a></li>
{% endif %}
<li>{{ object }}</li>
{% endblock %}

View File

@@ -5,7 +5,7 @@
{% block breadcrumbs %}
<li><a href="{% url 'dcim:powerpanel_list' %}">Power Panels</a></li>
<li><a href="{{ object.site.get_absolute_url }}">{{ object.site }}</a></li>
<li><a href="{% url 'dcim:powerpanel_list' %}?site_id={{ object.site.pk }}">{{ object.site }}</a></li>
{% if object.location %}
<li><a href="{{ object.location.get_absolute_url }}">{{ object.location }}</a></li>
{% endif %}

View File

@@ -268,7 +268,7 @@
</td>
<td>
{{ resv.description }}<br />
<small>{{ resv.user }} &middot; {{ resv.created }}</small>
<small>{{ resv.user }} &middot; {{ resv.created|annotated_date }}</small>
</td>
<td class="text-right noprint">
{% if perms.dcim.change_rackreservation %}

View File

@@ -80,7 +80,11 @@
<td>
{% if object.time_zone %}
{{ object.time_zone }} (UTC {{ object.time_zone|tzoffset }})<br />
<small class="text-muted">Site time: {% timezone object.time_zone %}{% now "SHORT_DATETIME_FORMAT" %}{% endtimezone %}</small>
<small class="text-muted">Site time:
{% timezone object.time_zone %}
{% annotated_now %}
{% endtimezone %}
</small>
{% else %}
<span class="text-muted">&mdash;</span>
{% endif %}

View File

@@ -25,7 +25,7 @@
<tr>
<td>Created</td>
<td>
{{ object.created }}
{{ object.created|annotated_date }}
</td>
</tr>
<tr>

View File

@@ -44,7 +44,7 @@
<tr>
<td>Time</td>
<td>
{{ object.time }}
{{ object.time|annotated_date }}
</td>
</tr>
<tr>
@@ -128,6 +128,8 @@
<span{% if k in diff_removed %} style="background-color: #ffdce0"{% endif %}>{{ k }}: {{ v|render_json }}</span>
{% endspaceless %}
{% endfor %}</pre>
{% elif non_atomic_change %}
Warning: Comparing non-atomic change to previous change record (<a href="{% url 'extras:objectchange' pk=prev_change.pk %}">{{ prev_change.pk }}</a>)
{% else %}
<span class="text-muted">None</span>
{% endif %}

View File

@@ -38,7 +38,7 @@
<div class="col-md-12">
{% if report.result %}
Last run: <a href="{% url 'extras:report_result' job_result_pk=report.result.pk %}">
<strong>{{ report.result.created }}</strong>
<strong>{{ report.result.created|annotated_date }}</strong>
</a>
{% endif %}
</div>

View File

@@ -32,7 +32,7 @@
<td class="rendered-markdown">{{ report.description|render_markdown|placeholder }}</td>
<td class="text-right">
{% if report.result %}
<a href="{% url 'extras:report_result' job_result_pk=report.result.pk %}">{{ report.result.created }}</a>
<a href="{% url 'extras:report_result' job_result_pk=report.result.pk %}">{{ report.result.created|annotated_date }}</a>
{% else %}
<span class="text-muted">Never</span>
{% endif %}

View File

@@ -8,7 +8,7 @@
<div class="row">
<div class="col-md-12">
<p>
Run: <strong>{{ result.created }}</strong>
Run: <strong>{{ result.created|annotated_date }}</strong>
{% if result.completed %}
Duration: <strong>{{ result.duration }}</strong>
{% else %}

View File

@@ -29,7 +29,7 @@
<td>{{ script.Meta.description|render_markdown }}</td>
{% if script.result %}
<td class="text-right">
<a href="{% url 'extras:script_result' job_result_pk=script.result.pk %}">{{ script.result.created }}</a>
<a href="{% url 'extras:script_result' job_result_pk=script.result.pk %}">{{ script.result.created|annotated_date }}</a>
</td>
{% else %}
<td class="text-right text-muted">Never</td>

View File

@@ -13,7 +13,7 @@
<li><a href="{% url 'extras:script_list' %}">Scripts</a></li>
<li><a href="{% url 'extras:script_list' %}#module.{{ script.module }}">{{ script.module|bettertitle }}</a></li>
<li><a href="{% url 'extras:script' module=script.module name=class_name %}">{{ script }}</a></li>
<li>{{ result.created }}</li>
<li>{{ result.created|annotated_date }}</li>
</ol>
</div>
</div>
@@ -32,7 +32,7 @@
</ul>
<div class="tab-content">
<p>
Run: <strong>{{ result.created }}</strong>
Run: <strong>{{ result.created|annotated_date }}</strong>
{% if result.completed %}
Duration: <strong>{{ result.duration }}</strong>
{% else %}

View File

@@ -42,8 +42,8 @@
<h1 class="title">{% block title %}{{ object }}{% endblock %}</h1>
<p>
<small class="text-muted">
Created {{ object.created }} &middot;
Updated <span title="{{ object.last_updated }}">{{ object.last_updated|timesince }}</span> ago
Created {{ object.created|annotated_date }} &middot;
Updated {{ object.last_updated|annotated_date }} ({{ object.last_updated|timesince }} ago)
</small>
<span class="label label-default">{{ object|meta:"app_label" }}.{{ object|meta:"model_name" }}:{{ object.pk }}</span>
</p>

View File

@@ -291,7 +291,7 @@
{% for result in report_results %}
<tr>
<td><a href="{% url 'extras:report_result' job_result_pk=result.pk %}">{{ result.name }}</a></td>
<td class="text-right"><span title="{{ result.created }}">{% include 'extras/inc/job_label.html' %}</span></td>
<td class="text-right"><span title="{{ result.created|date:'SHORT_DATETIME_FORMAT' }}">{% include 'extras/inc/job_label.html' %}</span></td>
</tr>
{% endfor %}
</table>

View File

@@ -1,3 +1,4 @@
{% load helpers %}
{% if images %}
<table class="table table-hover panel-body">
<tr>
@@ -13,7 +14,7 @@
<a class="image-preview" href="{{ attachment.image.url }}" target="_blank">{{ attachment }}</a>
</td>
<td>{{ attachment.size|filesizeformat }}</td>
<td>{{ attachment.created }}</td>
<td>{{ attachment.created|annotated_date }}</td>
<td class="text-right noprint">
{% if perms.extras.change_imageattachment %}
<a href="{% url 'extras:imageattachment_edit' pk=attachment.pk %}" class="btn btn-warning btn-xs" title="Edit image">

View File

@@ -54,7 +54,7 @@
</tr>
<tr>
<td>Date Added</td>
<td>{{ object.date_added|placeholder }}</td>
<td>{{ object.date_added|annotated_date|placeholder }}</td>
</tr>
<tr>
<td>Description</td>

View File

@@ -1,23 +0,0 @@
{% extends 'generic/object_list.html' %}
{% block buttons %}
{% if request.GET.family == '6' %}
<a href="{% url 'ipam:rir_list' %}" class="btn btn-default">
<span class="mdi mdi-table" aria-hidden="true"></span>
IPv4 Stats
</a>
{% else %}
<a href="{% url 'ipam:rir_list' %}?family=6{% if request.GET %}&{{ request.GET.urlencode }}{% endif %}" class="btn btn-default">
<span class="mdi mdi-table" aria-hidden="true"></span>
IPv6 Stats
</a>
{% endif %}
{% endblock %}
{% block sidebar %}
{% if request.GET.family == '6' %}
<div class="alert alert-info">
<i class="mdi mdi-information-outline"></i> Numbers shown indicate /64 prefixes.
</div>
{% endif %}
{% endblock %}

View File

@@ -24,12 +24,12 @@
<div class="row">
<div class="col-md-4">
<small class="text-muted">Created</small><br />
<span title="{{ token.created }}">{{ token.created|date }}</span>
{{ token.created|annotated_date }}
</div>
<div class="col-md-4">
<small class="text-muted">Expires</small><br />
{% if token.expires %}
<span title="{{ token.expires }}">{{ token.expires|date }}</span>
{{ token.expires|annotated_date }}
{% else %}
<span>Never</span>
{% endif %}

View File

@@ -11,7 +11,7 @@
<small class="text-muted">Email</small>
<h5>{{ request.user.email }}</h5>
<small class="text-muted">Registered</small>
<h5>{{ request.user.date_joined }}</h5>
<h5>{{ request.user.date_joined|annotated_date }}</h5>
<small class="text-muted">Groups</small>
<h5>{{ request.user.groups.all|join:', ' }}</h5>
<small class="text-muted">Admin access</small>

View File

@@ -1,4 +1,5 @@
{% extends 'users/base.html' %}
{% load helpers %}
{% block title %}User Key{% endblock %}
@@ -19,7 +20,9 @@
{% endif %}
</h4>
<p>
<small class="text-muted">Created {{ object.created }} &middot; Updated <span title="{{ object.last_updated }}">{{ object.last_updated|timesince }}</span> ago</small>
<small class="text-muted">
Created {{ object.created|annotated_date }} &middot;
Updated {{ object.last_updated|annotated_date }} ({{ object.last_updated|timesince }} ago)
</p>
{% if not object.is_active %}
<div class="alert alert-warning" role="alert">
@@ -37,7 +40,7 @@
</a>
</div>
<h4>Session key: <span class="label label-success">Active</span></h4>
<small class="text-muted">Created {{ object.session_key.created }}</small>
<small class="text-muted">Created {{ object.session_key.created|annotated_date }}</small>
{% else %}
<h4>No active session key</h4>
{% endif %}

View File

@@ -138,7 +138,7 @@
<td><i class="mdi mdi-chip"></i> Memory</td>
<td>
{% if object.memory %}
{{ object.memory }} MB
<span title="{{ object.memory }} MB">{{ object.memory|humanize_megabytes }}</span>
{% else %}
<span class="text-muted">&mdash;</span>
{% endif %}

View File

@@ -1,38 +0,0 @@
{% extends 'base.html' %}
{% load helpers %}
{% load form_helpers %}
{% block title %}Create {{ component_type }}{% endblock %}
{% block content %}
<form action="" method="post" class="form form-horizontal">
{% csrf_token %}
<div class="row">
<div class="col-md-6 col-md-offset-3">
{% if form.non_field_errors %}
<div class="panel panel-danger">
<div class="panel-heading"><strong>Errors</strong></div>
<div class="panel-body">
{{ form.non_field_errors }}
</div>
</div>
{% endif %}
<div class="panel panel-default">
<div class="panel-heading">
<strong>{{ component_type|bettertitle }}</strong>
</div>
<div class="panel-body">
{% render_form form %}
</div>
</div>
<div class="form-group">
<div class="col-md-9 col-md-offset-3">
<button type="submit" name="_create" class="btn btn-primary">Create</button>
<button type="submit" name="_addanother" class="btn btn-primary">Create and Add More</button>
<a href="{{ return_url }}" class="btn btn-default">Cancel</a>
</div>
</div>
</div>
</div>
</form>
{% endblock %}

View File

@@ -11,7 +11,8 @@ FILTER_CHAR_BASED_LOOKUP_MAP = dict(
isw='istartswith',
nisw='istartswith',
ie='iexact',
nie='iexact'
nie='iexact',
empty='empty',
)
FILTER_NUMERIC_BASED_LOOKUP_MAP = dict(

View File

@@ -9,6 +9,14 @@ class AbortTransaction(Exception):
pass
class PermissionsViolation(Exception):
"""
Raised when an operation was prevented because it would violate the
allowed permissions.
"""
pass
class RQWorkerNotRunningException(APIException):
"""
Indicates the temporary inability to enqueue a new task (e.g. custom script execution) because no RQ worker

View File

@@ -340,8 +340,11 @@ class MPTTColumn(tables.TemplateColumn):
"""
Display a nested hierarchy for MPTT-enabled models.
"""
template_code = """{% for i in record.get_ancestors %}<i class="mdi mdi-circle-small"></i>{% endfor %}""" \
"""<a href="{{ record.get_absolute_url }}">{{ record.name }}</a>"""
template_code = """
{% load helpers %}
{% for i in record.level|as_range %}<i class="mdi mdi-circle-small"></i>{% endfor %}
<a href="{{ record.get_absolute_url }}">{{ record.name }}</a>
"""
def __init__(self, *args, **kwargs):
super().__init__(

View File

@@ -5,7 +5,9 @@ import re
import yaml
from django import template
from django.conf import settings
from django.template.defaultfilters import date
from django.urls import NoReverseMatch, reverse
from django.utils import timezone
from django.utils.html import strip_tags
from django.utils.safestring import mark_safe
from markdown import markdown
@@ -129,6 +131,20 @@ def humanize_speed(speed):
return '{} Kbps'.format(speed)
@register.filter()
def humanize_megabytes(mb):
"""
Express a number of megabytes in the most suitable unit (e.g. gigabytes or terabytes).
"""
if not mb:
return ''
if mb >= 1048576:
return f'{int(mb / 1048576)} TB'
if mb >= 1024:
return f'{int(mb / 1024)} GB'
return f'{mb} MB'
@register.filter()
def tzoffset(value):
"""
@@ -137,6 +153,36 @@ def tzoffset(value):
return datetime.datetime.now(value).strftime('%z')
@register.filter(expects_localtime=True)
def annotated_date(date_value):
"""
Returns date as HTML span with short date format as the content and the
(long) date format as the title.
"""
if not date_value:
return ''
if type(date_value) == datetime.date:
long_ts = date(date_value, 'DATE_FORMAT')
short_ts = date(date_value, 'SHORT_DATE_FORMAT')
else:
long_ts = date(date_value, 'DATETIME_FORMAT')
short_ts = date(date_value, 'SHORT_DATETIME_FORMAT')
span = f'<span title="{long_ts}">{short_ts}</span>'
return mark_safe(span)
@register.simple_tag
def annotated_now():
"""
Returns the current date piped through the annotated_date filter.
"""
tzinfo = timezone.get_current_timezone() if settings.USE_TZ else None
return annotated_date(datetime.datetime.now(tz=tzinfo))
@register.filter()
def fgcolor(value):
"""

View File

@@ -1,8 +1,7 @@
from rest_framework import serializers
from dcim.models import Interface
from netbox.api import WritableNestedSerializer
from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine
from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface
__all__ = [
'NestedClusterGroupSerializer',
@@ -61,5 +60,5 @@ class NestedVMInterfaceSerializer(WritableNestedSerializer):
virtual_machine = NestedVirtualMachineSerializer(read_only=True)
class Meta:
model = Interface
model = VMInterface
fields = ['id', 'url', 'display', 'virtual_machine', 'name']

View File

@@ -8,7 +8,8 @@ from dcim.constants import INTERFACE_MTU_MAX, INTERFACE_MTU_MIN
from dcim.forms import InterfaceCommonForm, INTERFACE_MODE_HELP_TEXT
from dcim.models import Device, DeviceRole, Platform, Rack, Region, Site, SiteGroup
from extras.forms import (
AddRemoveTagsForm, CustomFieldBulkEditForm, CustomFieldModelCSVForm, CustomFieldModelForm, CustomFieldFilterForm,
AddRemoveTagsForm, CustomFieldForm, CustomFieldBulkEditForm, CustomFieldModelCSVForm, CustomFieldModelForm,
CustomFieldFilterForm,
)
from extras.models import Tag
from ipam.models import IPAddress, VLAN
@@ -527,8 +528,8 @@ class VirtualMachineBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldB
class VirtualMachineFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm):
model = VirtualMachine
field_order = [
'q', 'cluster_group_id', 'cluster_type_id', 'cluster_id', 'status', 'role_id', 'region_id', 'site_id',
'tenant_group_id', 'tenant_id', 'platform_id', 'mac_address',
'q', 'cluster_group_id', 'cluster_type_id', 'cluster_id', 'status', 'role_id', 'region_id', 'site_group_id',
'site_id', 'tenant_group_id', 'tenant_id', 'platform_id', 'mac_address',
]
q = forms.CharField(
required=False,
@@ -556,14 +557,20 @@ class VirtualMachineFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFil
required=False,
label=_('Region')
)
site_group_id = DynamicModelMultipleChoiceField(
queryset=SiteGroup.objects.all(),
required=False,
label=_('Site group')
)
site_id = DynamicModelMultipleChoiceField(
queryset=Site.objects.all(),
required=False,
null_option='None',
query_params={
'region_id': '$region_id'
'region_id': '$region_id',
'group_id': '$site_group_id',
},
label=_('Cluster')
label=_('Site')
)
role_id = DynamicModelMultipleChoiceField(
queryset=DeviceRole.objects.all(),
@@ -653,7 +660,8 @@ class VMInterfaceForm(BootstrapMixin, InterfaceCommonForm, CustomFieldModelForm)
self.fields['tagged_vlans'].widget.add_query_param('available_on_virtualmachine', vm_id)
class VMInterfaceCreateForm(BootstrapMixin, InterfaceCommonForm):
class VMInterfaceCreateForm(BootstrapMixin, CustomFieldForm, InterfaceCommonForm):
model = VMInterface
virtual_machine = DynamicModelChoiceField(
queryset=VirtualMachine.objects.all()
)
@@ -717,7 +725,7 @@ class VMInterfaceCreateForm(BootstrapMixin, InterfaceCommonForm):
self.fields['tagged_vlans'].widget.add_query_param('available_on_virtualmachine', vm_id)
class VMInterfaceCSVForm(CSVModelForm):
class VMInterfaceCSVForm(CustomFieldModelCSVForm):
virtual_machine = CSVModelChoiceField(
queryset=VirtualMachine.objects.all(),
to_field_name='name'
@@ -740,7 +748,7 @@ class VMInterfaceCSVForm(CSVModelForm):
return self.cleaned_data['enabled']
class VMInterfaceBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm):
class VMInterfaceBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditForm):
pk = forms.ModelMultipleChoiceField(
queryset=VMInterface.objects.all(),
widget=forms.MultipleHiddenInput()

View File

@@ -251,6 +251,7 @@ class VMInterfaceTest(APIViewTestCases.APIViewTestCase):
{
'virtual_machine': virtualmachine.pk,
'name': 'Interface 6',
'parent': interfaces[0].pk,
'mode': InterfaceModeChoices.MODE_TAGGED,
'tagged_vlans': [vlans[0].pk, vlans[1].pk],
'untagged_vlan': vlans[2].pk,

View File

@@ -34,6 +34,9 @@ class ClusterTypeView(generic.ObjectView):
def get_extra_context(self, request, instance):
clusters = Cluster.objects.restrict(request.user, 'view').filter(
type=instance
).annotate(
device_count=count_related(Device, 'cluster'),
vm_count=count_related(VirtualMachine, 'cluster')
)
clusters_table = tables.ClusterTable(clusters)
@@ -93,6 +96,9 @@ class ClusterGroupView(generic.ObjectView):
def get_extra_context(self, request, instance):
clusters = Cluster.objects.restrict(request.user, 'view').filter(
group=instance
).annotate(
device_count=count_related(Device, 'cluster'),
vm_count=count_related(VirtualMachine, 'cluster')
)
clusters_table = tables.ClusterTable(clusters)
@@ -455,7 +461,7 @@ class VMInterfaceCreateView(generic.ComponentCreateView):
queryset = VMInterface.objects.all()
form = forms.VMInterfaceCreateForm
model_form = forms.VMInterfaceForm
template_name = 'virtualization/virtualmachine_component_add.html'
template_name = 'dcim/device_component_add.html'
class VMInterfaceEditView(generic.ObjectEditView):

View File

@@ -1,4 +1,4 @@
Django==3.2.4
Django==3.2.5
django-cacheops==6.0
django-cors-headers==3.7.0
django-debug-toolbar==3.2.1
@@ -8,7 +8,7 @@ django-pglocks==1.0.4
django-prometheus==2.1.0
django-rq==2.4.1
django-tables2==2.4.0
django-taggit==1.4.0
django-taggit==1.5.1
django-timezone-field==4.1.2
djangorestframework==3.12.4
drf-yasg[validation]==1.20.0
@@ -16,8 +16,8 @@ gunicorn==20.1.0
Jinja2==3.0.1
Markdown==3.3.4
netaddr==0.8.0
Pillow==8.2.0
psycopg2-binary==2.8.6
Pillow==8.3.1
psycopg2-binary==2.9.1
pycryptodome==3.10.1
PyYAML==5.4.1
svgwrite==1.4.1