mirror of
https://github.com/netbox-community/netbox.git
synced 2026-01-14 06:13:32 +01:00
Compare commits
56 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8f6b71df46 | ||
|
|
e8e3e9b0be | ||
|
|
28ca815c88 | ||
|
|
54dfa6cb7f | ||
|
|
7c667f3485 | ||
|
|
c585175214 | ||
|
|
a5b95728bf | ||
|
|
c742501b80 | ||
|
|
9c1de27562 | ||
|
|
fc15ef6967 | ||
|
|
eaf0259c3d | ||
|
|
fe2ce03ac1 | ||
|
|
70585ff32e | ||
|
|
a479c867c4 | ||
|
|
74f1b51b38 | ||
|
|
0ad9b83623 | ||
|
|
631d991d8d | ||
|
|
1be4a57bd4 | ||
|
|
76a6119584 | ||
|
|
add95292ce | ||
|
|
18a9e39be6 | ||
|
|
18934bcc69 | ||
|
|
98ff00bc62 | ||
|
|
4292d88a92 | ||
|
|
a8af24d7ca | ||
|
|
efa0fc2b09 | ||
|
|
ebb2918a88 | ||
|
|
607039f043 | ||
|
|
fb379b63ec | ||
|
|
697161beb1 | ||
|
|
742804ecb8 | ||
|
|
2bf20fa501 | ||
|
|
685e0ce00d | ||
|
|
6a6b0236a9 | ||
|
|
857c70ece9 | ||
|
|
e68be6f041 | ||
|
|
52edeb42b5 | ||
|
|
c8a8bfd84d | ||
|
|
9f2c4919eb | ||
|
|
f56a470cc7 | ||
|
|
54ccc705d0 | ||
|
|
7e481960f9 | ||
|
|
809d9e4697 | ||
|
|
79c06442db | ||
|
|
6195fc0d11 | ||
|
|
6523334a48 | ||
|
|
b3cde51590 | ||
|
|
6ec296f2a7 | ||
|
|
cb4392628f | ||
|
|
a224e5d470 | ||
|
|
7444110c79 | ||
|
|
fc0c8a160b | ||
|
|
481cc52686 | ||
|
|
4273b6e4fb | ||
|
|
5e08b2be37 | ||
|
|
a665b79f85 |
2
.github/ISSUE_TEMPLATE/bug_report.yaml
vendored
2
.github/ISSUE_TEMPLATE/bug_report.yaml
vendored
@@ -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
|
||||
|
||||
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: v2.11.5
|
||||
placeholder: v2.11.9
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
|
||||
@@ -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.
|
||||
|
||||
17
README.md
17
README.md
@@ -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
|
||||

|
||||
|
||||
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** |  |
|
||||
| **develop** |  |
|
||||
|
||||
<div align="center">
|
||||
<h4>Thank you to our sponsors!</h4>
|
||||
|
||||
[](https://try.digitalocean.com/developer-cloud)
|
||||
|
||||
[](https://ns1.com/)
|
||||
[](https://metal.equinix.com/)
|
||||
|
||||
[](https://ns1.com/)
|
||||
<br />
|
||||
[](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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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'),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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
17
netbox/extras/lookups.py
Normal 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)
|
||||
@@ -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
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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.'))
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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'])
|
||||
|
||||
@@ -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'):
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -16,7 +16,7 @@ from django.core.validators import URLValidator
|
||||
# Environment setup
|
||||
#
|
||||
|
||||
VERSION = '2.11.5'
|
||||
VERSION = '2.11.9'
|
||||
|
||||
# Hostname
|
||||
HOSTNAME = platform.node()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -37,6 +37,7 @@ class SecretTable(BaseTable):
|
||||
)
|
||||
assigned_object = tables.Column(
|
||||
linkify=True,
|
||||
orderable=False,
|
||||
verbose_name='Assigned object'
|
||||
)
|
||||
role = tables.Column(
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 %}
|
||||
—
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -268,7 +268,7 @@
|
||||
</td>
|
||||
<td>
|
||||
{{ resv.description }}<br />
|
||||
<small>{{ resv.user }} · {{ resv.created }}</small>
|
||||
<small>{{ resv.user }} · {{ resv.created|annotated_date }}</small>
|
||||
</td>
|
||||
<td class="text-right noprint">
|
||||
{% if perms.dcim.change_rackreservation %}
|
||||
|
||||
@@ -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">—</span>
|
||||
{% endif %}
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
<tr>
|
||||
<td>Created</td>
|
||||
<td>
|
||||
{{ object.created }}
|
||||
{{ object.created|annotated_date }}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -42,8 +42,8 @@
|
||||
<h1 class="title">{% block title %}{{ object }}{% endblock %}</h1>
|
||||
<p>
|
||||
<small class="text-muted">
|
||||
Created {{ object.created }} ·
|
||||
Updated <span title="{{ object.last_updated }}">{{ object.last_updated|timesince }}</span> ago
|
||||
Created {{ object.created|annotated_date }} ·
|
||||
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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 }} · Updated <span title="{{ object.last_updated }}">{{ object.last_updated|timesince }}</span> ago</small>
|
||||
<small class="text-muted">
|
||||
Created {{ object.created|annotated_date }} ·
|
||||
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 %}
|
||||
|
||||
@@ -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">—</span>
|
||||
{% endif %}
|
||||
|
||||
@@ -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 %}
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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__(
|
||||
|
||||
@@ -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):
|
||||
"""
|
||||
|
||||
@@ -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']
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user