mirror of
https://github.com/netbox-community/netbox.git
synced 2025-07-14 09:51:22 -06:00
Merge branch 'develop' into feature
This commit is contained in:
commit
837be4d45f
2
.github/ISSUE_TEMPLATE/bug_report.yaml
vendored
2
.github/ISSUE_TEMPLATE/bug_report.yaml
vendored
@ -14,7 +14,7 @@ body:
|
|||||||
attributes:
|
attributes:
|
||||||
label: NetBox version
|
label: NetBox version
|
||||||
description: What version of NetBox are you currently running?
|
description: What version of NetBox are you currently running?
|
||||||
placeholder: v3.5.4
|
placeholder: v3.5.6
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
- type: dropdown
|
- type: dropdown
|
||||||
|
2
.github/ISSUE_TEMPLATE/feature_request.yaml
vendored
2
.github/ISSUE_TEMPLATE/feature_request.yaml
vendored
@ -14,7 +14,7 @@ body:
|
|||||||
attributes:
|
attributes:
|
||||||
label: NetBox version
|
label: NetBox version
|
||||||
description: What version of NetBox are you currently running?
|
description: What version of NetBox are you currently running?
|
||||||
placeholder: v3.5.4
|
placeholder: v3.5.6
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
- type: dropdown
|
- type: dropdown
|
||||||
|
14
README.md
14
README.md
@ -52,10 +52,10 @@ as the cornerstone for network automation in thousands of organizations.
|
|||||||
## Project Stats
|
## Project Stats
|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
<a href="https://github.com/netbox-community/netbox/commits"><img src="https://images.repography.com/29023055/netbox-community/netbox/recent-activity/31db894eee74b8a5475e3af307a81b6c_timeline.svg" alt="Timeline graph"></a>
|
<a href="https://github.com/netbox-community/netbox/commits"><img src="https://images.repography.com/29023055/netbox-community/netbox/recent-activity/whQtEr_TGD9PhW1BPlhlEQ5jnrgQ0KJpm-LlGtpoGO0/3Kx_iWUSBRJ5-AI4QwJEJWrUDEz3KrX2lvh8aYE0WXY_timeline.svg" alt="Timeline graph"></a>
|
||||||
<a href="https://github.com/netbox-community/netbox/issues"><img src="https://images.repography.com/29023055/netbox-community/netbox/recent-activity/31db894eee74b8a5475e3af307a81b6c_issues.svg" alt="Issues graph"></a>
|
<a href="https://github.com/netbox-community/netbox/issues"><img src="https://images.repography.com/29023055/netbox-community/netbox/recent-activity/whQtEr_TGD9PhW1BPlhlEQ5jnrgQ0KJpm-LlGtpoGO0/3Kx_iWUSBRJ5-AI4QwJEJWrUDEz3KrX2lvh8aYE0WXY_issues.svg" alt="Issues graph"></a>
|
||||||
<a href="https://github.com/netbox-community/netbox/pulls"><img src="https://images.repography.com/29023055/netbox-community/netbox/recent-activity/31db894eee74b8a5475e3af307a81b6c_prs.svg" alt="Pull requests graph"></a>
|
<a href="https://github.com/netbox-community/netbox/pulls"><img src="https://images.repography.com/29023055/netbox-community/netbox/recent-activity/whQtEr_TGD9PhW1BPlhlEQ5jnrgQ0KJpm-LlGtpoGO0/3Kx_iWUSBRJ5-AI4QwJEJWrUDEz3KrX2lvh8aYE0WXY_prs.svg" alt="Pull requests graph"></a>
|
||||||
<a href="https://github.com/netbox-community/netbox/graphs/contributors"><img src="https://images.repography.com/29023055/netbox-community/netbox/recent-activity/31db894eee74b8a5475e3af307a81b6c_users.svg" alt="Top contributors"></a>
|
<a href="https://github.com/netbox-community/netbox/graphs/contributors"><img src="https://images.repography.com/29023055/netbox-community/netbox/recent-activity/whQtEr_TGD9PhW1BPlhlEQ5jnrgQ0KJpm-LlGtpoGO0/3Kx_iWUSBRJ5-AI4QwJEJWrUDEz3KrX2lvh8aYE0WXY_users.svg" alt="Top contributors"></a>
|
||||||
<br />Stats via <a href="https://repography.com">Repography</a>
|
<br />Stats via <a href="https://repography.com">Repography</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -66,10 +66,12 @@ as the cornerstone for network automation in thousands of organizations.
|
|||||||
[](https://netboxlabs.com)
|
[](https://netboxlabs.com)
|
||||||
|
|
||||||
[](https://try.digitalocean.com/developer-cloud)
|
[](https://try.digitalocean.com/developer-cloud)
|
||||||
<br />
|
|
||||||
[](https://sentry.io)
|
|
||||||
|
|
||||||
|
[](https://sentry.io)
|
||||||
|
<br />
|
||||||
[](https://metal.equinix.com)
|
[](https://metal.equinix.com)
|
||||||
|
|
||||||
|
[](https://onemindservices.com)
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@ -19,6 +19,9 @@ class MyModel(models.Model):
|
|||||||
|
|
||||||
Every model includes by default a numeric primary key. This value is generated automatically by the database, and can be referenced as `pk` or `id`.
|
Every model includes by default a numeric primary key. This value is generated automatically by the database, and can be referenced as `pk` or `id`.
|
||||||
|
|
||||||
|
!!! note
|
||||||
|
Model names should adhere to [PEP8](https://www.python.org/dev/peps/pep-0008/#class-names) standards and be CapWords (no underscores). Using underscores in model names will result in problems with permissions.
|
||||||
|
|
||||||
## Enabling NetBox Features
|
## Enabling NetBox Features
|
||||||
|
|
||||||
Plugin models can leverage certain NetBox features by inheriting from NetBox's `NetBoxModel` class. This class extends the plugin model to enable features unique to NetBox, including:
|
Plugin models can leverage certain NetBox features by inheriting from NetBox's `NetBoxModel` class. This class extends the plugin model to enable features unique to NetBox, including:
|
||||||
|
@ -1,6 +1,53 @@
|
|||||||
# NetBox v3.5
|
# NetBox v3.5
|
||||||
|
|
||||||
## v3.5.5 (FUTURE)
|
## v3.5.7 (FUTURE)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v3.5.6 (2023-07-10)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* [#13061](https://github.com/netbox-community/netbox/issues/13061) - Fix display of last result for scripts & reports with a custom name defined
|
||||||
|
* [#13096](https://github.com/netbox-community/netbox/issues/13096) - Hide scheduling fields for all scripts with scheduling disabled
|
||||||
|
* [#13105](https://github.com/netbox-community/netbox/issues/13105) - Fix exception when attempting to allocate next available IP address from prefix marked as utilized
|
||||||
|
* [#13116](https://github.com/netbox-community/netbox/issues/13116) - Catch ProgrammingError exception when starting NetBox without pre-populated content types
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v3.5.5 (2023-07-06)
|
||||||
|
|
||||||
|
### Enhancements
|
||||||
|
|
||||||
|
* [#11738](https://github.com/netbox-community/netbox/issues/11738) - Annotate VLAN group utilization
|
||||||
|
* [#12499](https://github.com/netbox-community/netbox/issues/12499) - Add "copy to clipboard" buttons in UI for IP addresses
|
||||||
|
* [#12945](https://github.com/netbox-community/netbox/issues/12945) - Add 100GE QSFP-DD interface type
|
||||||
|
* [#12955](https://github.com/netbox-community/netbox/issues/12955) - Include additional contact details on contact assignments table
|
||||||
|
* [#13065](https://github.com/netbox-community/netbox/issues/13065) - Associate contact assignments with their objects in the change log
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* [#11335](https://github.com/netbox-community/netbox/issues/11335) - Exclude stale content types when retrieving changelog records
|
||||||
|
* [#12533](https://github.com/netbox-community/netbox/issues/12533) - Fix REST API validation of null values for several interface attributes
|
||||||
|
* [#12579](https://github.com/netbox-community/netbox/issues/12579) - Fix exception when clicking "create and add another" to add a cable
|
||||||
|
* [#12617](https://github.com/netbox-community/netbox/issues/12617) - Populate prechange snapshot on parent object when assigning/removing primary IP address
|
||||||
|
* [#12760](https://github.com/netbox-community/netbox/issues/12760) - Avoid rendering partial HTMX responses when restoring browser tabs
|
||||||
|
* [#12842](https://github.com/netbox-community/netbox/issues/12842) - Improve handling of exceptions when loading reports
|
||||||
|
* [#12849](https://github.com/netbox-community/netbox/issues/12849) - Fix LDAP group permissions assignment for API clients
|
||||||
|
* [#12951](https://github.com/netbox-community/netbox/issues/12951) - Display consistent parent information for each termination under cable view
|
||||||
|
* [#12953](https://github.com/netbox-community/netbox/issues/12953) - Fix designation of primary IP addresses during interface assignment
|
||||||
|
* [#12960](https://github.com/netbox-community/netbox/issues/12960) - Fix OpenAPI schema for various choice fields
|
||||||
|
* [#12961](https://github.com/netbox-community/netbox/issues/12961) - Set correct return URL for object contacts tabs
|
||||||
|
* [#12966](https://github.com/netbox-community/netbox/issues/12966) - Avoid catching database exceptions when maintenance mode is disabled
|
||||||
|
* [#12975](https://github.com/netbox-community/netbox/issues/12975) - Correct URL for VirtualDeviceContext API serializer
|
||||||
|
* [#12977](https://github.com/netbox-community/netbox/issues/12977) - Fix URL parameters for object count dashboard widgets
|
||||||
|
* [#12983](https://github.com/netbox-community/netbox/issues/12983) - Avoid erroneously clearing many-to-many assignments during bulk edit
|
||||||
|
* [#12989](https://github.com/netbox-community/netbox/issues/12989) - Fix bulk import of tags for device & module types
|
||||||
|
* [#13011](https://github.com/netbox-community/netbox/issues/13011) - Do not escape commas when rendering custom links
|
||||||
|
* [#13047](https://github.com/netbox-community/netbox/issues/13047) - Correct ASN count under ASN ranges list
|
||||||
|
* [#13056](https://github.com/netbox-community/netbox/issues/13056) - Add `config_template` field to device API serializer
|
||||||
|
* [#13092](https://github.com/netbox-community/netbox/issues/13092) - Allow nullifying power port max & allocated draw values during bulk edit
|
||||||
|
* [#13100](https://github.com/netbox-community/netbox/issues/13100) - Fix ValueError exception when searching for virtual device context for non-numeric values
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
import re
|
import re
|
||||||
import typing
|
import typing
|
||||||
|
from collections import OrderedDict
|
||||||
|
|
||||||
from drf_spectacular.extensions import OpenApiSerializerFieldExtension
|
from drf_spectacular.extensions import OpenApiSerializerFieldExtension
|
||||||
from drf_spectacular.openapi import AutoSchema
|
from drf_spectacular.openapi import AutoSchema
|
||||||
@ -28,14 +29,19 @@ class ChoiceFieldFix(OpenApiSerializerFieldExtension):
|
|||||||
target_class = 'netbox.api.fields.ChoiceField'
|
target_class = 'netbox.api.fields.ChoiceField'
|
||||||
|
|
||||||
def map_serializer_field(self, auto_schema, direction):
|
def map_serializer_field(self, auto_schema, direction):
|
||||||
|
build_cf = build_choice_field(self.target)
|
||||||
|
|
||||||
if direction == 'request':
|
if direction == 'request':
|
||||||
return build_choice_field(self.target)
|
return build_cf
|
||||||
|
|
||||||
elif direction == "response":
|
elif direction == "response":
|
||||||
|
value = build_cf
|
||||||
|
label = {**build_basic_type(OpenApiTypes.STR), "enum": list(OrderedDict.fromkeys(self.target.choices.values()))}
|
||||||
|
|
||||||
return build_object_type(
|
return build_object_type(
|
||||||
properties={
|
properties={
|
||||||
"value": build_basic_type(OpenApiTypes.STR),
|
"value": value,
|
||||||
"label": build_basic_type(OpenApiTypes.STR),
|
"label": label
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -699,7 +699,8 @@ class DeviceWithConfigContextSerializer(DeviceSerializer):
|
|||||||
'id', 'url', 'display', 'name', 'device_type', 'device_role', 'tenant', 'platform', 'serial', 'asset_tag',
|
'id', 'url', 'display', 'name', 'device_type', 'device_role', 'tenant', 'platform', 'serial', 'asset_tag',
|
||||||
'site', 'location', 'rack', 'position', 'face', 'parent_device', 'status', 'airflow', 'primary_ip',
|
'site', 'location', 'rack', 'position', 'face', 'parent_device', 'status', 'airflow', 'primary_ip',
|
||||||
'primary_ip4', 'primary_ip6', 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'description',
|
'primary_ip4', 'primary_ip6', 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'description',
|
||||||
'comments', 'local_context_data', 'tags', 'custom_fields', 'config_context', 'created', 'last_updated',
|
'comments', 'local_context_data', 'tags', 'custom_fields', 'config_context', 'config_template',
|
||||||
|
'created', 'last_updated',
|
||||||
]
|
]
|
||||||
|
|
||||||
@extend_schema_field(serializers.JSONField(allow_null=True))
|
@extend_schema_field(serializers.JSONField(allow_null=True))
|
||||||
@ -708,7 +709,7 @@ class DeviceWithConfigContextSerializer(DeviceSerializer):
|
|||||||
|
|
||||||
|
|
||||||
class VirtualDeviceContextSerializer(NetBoxModelSerializer):
|
class VirtualDeviceContextSerializer(NetBoxModelSerializer):
|
||||||
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:device-detail')
|
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:virtualdevicecontext-detail')
|
||||||
device = NestedDeviceSerializer()
|
device = NestedDeviceSerializer()
|
||||||
tenant = NestedTenantSerializer(required=False, allow_null=True, default=None)
|
tenant = NestedTenantSerializer(required=False, allow_null=True, default=None)
|
||||||
primary_ip = NestedIPAddressSerializer(read_only=True, allow_null=True)
|
primary_ip = NestedIPAddressSerializer(read_only=True, allow_null=True)
|
||||||
@ -881,12 +882,12 @@ class InterfaceSerializer(NetBoxModelSerializer, CabledObjectSerializer, Connect
|
|||||||
parent = NestedInterfaceSerializer(required=False, allow_null=True)
|
parent = NestedInterfaceSerializer(required=False, allow_null=True)
|
||||||
bridge = NestedInterfaceSerializer(required=False, allow_null=True)
|
bridge = NestedInterfaceSerializer(required=False, allow_null=True)
|
||||||
lag = NestedInterfaceSerializer(required=False, allow_null=True)
|
lag = NestedInterfaceSerializer(required=False, allow_null=True)
|
||||||
mode = ChoiceField(choices=InterfaceModeChoices, required=False, allow_blank=True, allow_null=True)
|
mode = ChoiceField(choices=InterfaceModeChoices, required=False, allow_blank=True)
|
||||||
duplex = ChoiceField(choices=InterfaceDuplexChoices, required=False, allow_blank=True, allow_null=True)
|
duplex = ChoiceField(choices=InterfaceDuplexChoices, required=False, allow_blank=True, allow_null=True)
|
||||||
rf_role = ChoiceField(choices=WirelessRoleChoices, required=False, allow_blank=True, allow_null=True)
|
rf_role = ChoiceField(choices=WirelessRoleChoices, required=False, allow_blank=True)
|
||||||
rf_channel = ChoiceField(choices=WirelessChannelChoices, required=False, allow_blank=True, allow_null=True)
|
rf_channel = ChoiceField(choices=WirelessChannelChoices, required=False, allow_blank=True)
|
||||||
poe_mode = ChoiceField(choices=InterfacePoEModeChoices, required=False, allow_blank=True, allow_null=True)
|
poe_mode = ChoiceField(choices=InterfacePoEModeChoices, required=False, allow_blank=True)
|
||||||
poe_type = ChoiceField(choices=InterfacePoETypeChoices, required=False, allow_blank=True, allow_null=True)
|
poe_type = ChoiceField(choices=InterfacePoETypeChoices, required=False, allow_blank=True)
|
||||||
untagged_vlan = NestedVLANSerializer(required=False, allow_null=True)
|
untagged_vlan = NestedVLANSerializer(required=False, allow_null=True)
|
||||||
tagged_vlans = SerializedPKRelatedField(
|
tagged_vlans = SerializedPKRelatedField(
|
||||||
queryset=VLAN.objects.all(),
|
queryset=VLAN.objects.all(),
|
||||||
@ -908,9 +909,10 @@ class InterfaceSerializer(NetBoxModelSerializer, CabledObjectSerializer, Connect
|
|||||||
mac_address = serializers.CharField(
|
mac_address = serializers.CharField(
|
||||||
required=False,
|
required=False,
|
||||||
default=None,
|
default=None,
|
||||||
|
allow_blank=True,
|
||||||
allow_null=True
|
allow_null=True
|
||||||
)
|
)
|
||||||
wwn = serializers.CharField(required=False, default=None)
|
wwn = serializers.CharField(required=False, default=None, allow_blank=True, allow_null=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Interface
|
model = Interface
|
||||||
|
@ -810,6 +810,7 @@ class InterfaceTypeChoices(ChoiceSet):
|
|||||||
TYPE_100GE_CXP = '100gbase-x-cxp'
|
TYPE_100GE_CXP = '100gbase-x-cxp'
|
||||||
TYPE_100GE_CPAK = '100gbase-x-cpak'
|
TYPE_100GE_CPAK = '100gbase-x-cpak'
|
||||||
TYPE_100GE_QSFP28 = '100gbase-x-qsfp28'
|
TYPE_100GE_QSFP28 = '100gbase-x-qsfp28'
|
||||||
|
TYPE_100GE_QSFP_DD = '100gbase-x-qsfpdd'
|
||||||
TYPE_200GE_CFP2 = '200gbase-x-cfp2'
|
TYPE_200GE_CFP2 = '200gbase-x-cfp2'
|
||||||
TYPE_200GE_QSFP56 = '200gbase-x-qsfp56'
|
TYPE_200GE_QSFP56 = '200gbase-x-qsfp56'
|
||||||
TYPE_200GE_QSFP_DD = '200gbase-x-qsfpdd'
|
TYPE_200GE_QSFP_DD = '200gbase-x-qsfpdd'
|
||||||
@ -959,6 +960,7 @@ class InterfaceTypeChoices(ChoiceSet):
|
|||||||
(TYPE_100GE_CXP, 'CXP (100GE)'),
|
(TYPE_100GE_CXP, 'CXP (100GE)'),
|
||||||
(TYPE_100GE_CPAK, 'Cisco CPAK (100GE)'),
|
(TYPE_100GE_CPAK, 'Cisco CPAK (100GE)'),
|
||||||
(TYPE_100GE_QSFP28, 'QSFP28 (100GE)'),
|
(TYPE_100GE_QSFP28, 'QSFP28 (100GE)'),
|
||||||
|
(TYPE_100GE_QSFP_DD, 'QSFP-DD (100GE)'),
|
||||||
(TYPE_200GE_QSFP56, 'QSFP56 (200GE)'),
|
(TYPE_200GE_QSFP56, 'QSFP56 (200GE)'),
|
||||||
(TYPE_200GE_QSFP_DD, 'QSFP-DD (200GE)'),
|
(TYPE_200GE_QSFP_DD, 'QSFP-DD (200GE)'),
|
||||||
(TYPE_400GE_QSFP_DD, 'QSFP-DD (400GE)'),
|
(TYPE_400GE_QSFP_DD, 'QSFP-DD (400GE)'),
|
||||||
|
@ -1077,10 +1077,13 @@ class VirtualDeviceContextFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
|
|||||||
def search(self, queryset, name, value):
|
def search(self, queryset, name, value):
|
||||||
if not value.strip():
|
if not value.strip():
|
||||||
return queryset
|
return queryset
|
||||||
return queryset.filter(
|
|
||||||
Q(name__icontains=value) |
|
qs_filter = Q(name__icontains=value)
|
||||||
Q(identifier=value.strip())
|
try:
|
||||||
).distinct()
|
qs_filter |= Q(identifier=int(value))
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
return queryset.filter(qs_filter).distinct()
|
||||||
|
|
||||||
def _has_primary_ip(self, queryset, name, value):
|
def _has_primary_ip(self, queryset, name, value):
|
||||||
params = Q(primary_ip4__isnull=False) | Q(primary_ip6__isnull=False)
|
params = Q(primary_ip4__isnull=False) | Q(primary_ip6__isnull=False)
|
||||||
|
@ -1102,7 +1102,7 @@ class PowerPortBulkEditForm(
|
|||||||
(None, ('module', 'type', 'label', 'description', 'mark_connected')),
|
(None, ('module', 'type', 'label', 'description', 'mark_connected')),
|
||||||
('Power', ('maximum_draw', 'allocated_draw')),
|
('Power', ('maximum_draw', 'allocated_draw')),
|
||||||
)
|
)
|
||||||
nullable_fields = ('module', 'label', 'description')
|
nullable_fields = ('module', 'label', 'description', 'maximum_draw', 'allocated_draw')
|
||||||
|
|
||||||
|
|
||||||
class PowerOutletBulkEditForm(
|
class PowerOutletBulkEditForm(
|
||||||
|
@ -306,7 +306,7 @@ class DeviceTypeImportForm(NetBoxModelImportForm):
|
|||||||
model = DeviceType
|
model = DeviceType
|
||||||
fields = [
|
fields = [
|
||||||
'manufacturer', 'default_platform', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth',
|
'manufacturer', 'default_platform', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth',
|
||||||
'subdevice_role', 'airflow', 'description', 'weight', 'weight_unit', 'comments',
|
'subdevice_role', 'airflow', 'description', 'weight', 'weight_unit', 'comments', 'tags',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@ -327,7 +327,7 @@ class ModuleTypeImportForm(NetBoxModelImportForm):
|
|||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = ModuleType
|
model = ModuleType
|
||||||
fields = ['manufacturer', 'model', 'part_number', 'description', 'weight', 'weight_unit', 'comments']
|
fields = ['manufacturer', 'model', 'part_number', 'description', 'weight', 'weight_unit', 'comments', 'tags']
|
||||||
|
|
||||||
|
|
||||||
class DeviceRoleImportForm(NetBoxModelImportForm):
|
class DeviceRoleImportForm(NetBoxModelImportForm):
|
||||||
|
@ -3131,6 +3131,19 @@ class CableEditView(generic.ObjectEditView):
|
|||||||
|
|
||||||
return obj
|
return obj
|
||||||
|
|
||||||
|
def get_extra_addanother_params(self, request):
|
||||||
|
|
||||||
|
params = {
|
||||||
|
'a_terminations_type': request.GET.get('a_terminations_type'),
|
||||||
|
'b_terminations_type': request.GET.get('b_terminations_type')
|
||||||
|
}
|
||||||
|
|
||||||
|
for key in request.POST:
|
||||||
|
if 'device' in key or 'power_panel' in key or 'circuit' in key:
|
||||||
|
params.update({key: request.POST.get(key)})
|
||||||
|
|
||||||
|
return params
|
||||||
|
|
||||||
|
|
||||||
@register_model_view(Cable, 'delete')
|
@register_model_view(Cable, 'delete')
|
||||||
class CableDeleteView(generic.ObjectDeleteView):
|
class CableDeleteView(generic.ObjectDeleteView):
|
||||||
|
@ -379,7 +379,7 @@ class ObjectChangeViewSet(ReadOnlyModelViewSet):
|
|||||||
Retrieve a list of recent changes.
|
Retrieve a list of recent changes.
|
||||||
"""
|
"""
|
||||||
metadata_class = ContentTypeMetadata
|
metadata_class = ContentTypeMetadata
|
||||||
queryset = ObjectChange.objects.prefetch_related('user')
|
queryset = ObjectChange.objects.valid_models().prefetch_related('user')
|
||||||
serializer_class = serializers.ObjectChangeSerializer
|
serializer_class = serializers.ObjectChangeSerializer
|
||||||
filterset_class = filtersets.ObjectChangeFilterSet
|
filterset_class = filtersets.ObjectChangeFilterSet
|
||||||
|
|
||||||
|
@ -10,7 +10,6 @@ from django.conf import settings
|
|||||||
from django.contrib.contenttypes.models import ContentType
|
from django.contrib.contenttypes.models import ContentType
|
||||||
from django.core.cache import cache
|
from django.core.cache import cache
|
||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
from django.http import QueryDict
|
|
||||||
from django.template.loader import render_to_string
|
from django.template.loader import render_to_string
|
||||||
from django.urls import NoReverseMatch, resolve, reverse
|
from django.urls import NoReverseMatch, resolve, reverse
|
||||||
from django.utils.translation import gettext as _
|
from django.utils.translation import gettext as _
|
||||||
@ -20,7 +19,7 @@ from extras.utils import FeatureQuery
|
|||||||
from utilities.forms import BootstrapMixin
|
from utilities.forms import BootstrapMixin
|
||||||
from utilities.permissions import get_permission_for_model
|
from utilities.permissions import get_permission_for_model
|
||||||
from utilities.templatetags.builtins.filters import render_markdown
|
from utilities.templatetags.builtins.filters import render_markdown
|
||||||
from utilities.utils import content_type_identifier, content_type_name, get_viewname
|
from utilities.utils import content_type_identifier, content_type_name, dict_to_querydict, get_viewname
|
||||||
from .utils import register_widget
|
from .utils import register_widget
|
||||||
|
|
||||||
__all__ = (
|
__all__ = (
|
||||||
@ -172,8 +171,7 @@ class ObjectCountsWidget(DashboardWidget):
|
|||||||
qs = model.objects.restrict(request.user, 'view')
|
qs = model.objects.restrict(request.user, 'view')
|
||||||
# Apply any specified filters
|
# Apply any specified filters
|
||||||
if filters := self.config.get('filters'):
|
if filters := self.config.get('filters'):
|
||||||
params = QueryDict(mutable=True)
|
params = dict_to_querydict(filters)
|
||||||
params.update(filters)
|
|
||||||
filterset = getattr(resolve(url).func.view_class, 'filterset', None)
|
filterset = getattr(resolve(url).func.view_class, 'filterset', None)
|
||||||
qs = filterset(params, qs).qs
|
qs = filterset(params, qs).qs
|
||||||
url = f'{url}?{params.urlencode()}'
|
url = f'{url}?{params.urlencode()}'
|
||||||
|
@ -56,10 +56,3 @@ class ScriptForm(BootstrapMixin, forms.Form):
|
|||||||
self.cleaned_data['_schedule_at'] = local_now()
|
self.cleaned_data['_schedule_at'] = local_now()
|
||||||
|
|
||||||
return self.cleaned_data
|
return self.cleaned_data
|
||||||
|
|
||||||
@property
|
|
||||||
def requires_input(self):
|
|
||||||
"""
|
|
||||||
A boolean indicating whether the form requires user input (ignore the built-in fields).
|
|
||||||
"""
|
|
||||||
return bool(len(self.fields) > 3)
|
|
||||||
|
@ -5,7 +5,7 @@ from django.db import models
|
|||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
|
||||||
from extras.choices import *
|
from extras.choices import *
|
||||||
from utilities.querysets import RestrictedQuerySet
|
from ..querysets import ObjectChangeQuerySet
|
||||||
|
|
||||||
__all__ = (
|
__all__ = (
|
||||||
'ObjectChange',
|
'ObjectChange',
|
||||||
@ -82,7 +82,7 @@ class ObjectChange(models.Model):
|
|||||||
null=True
|
null=True
|
||||||
)
|
)
|
||||||
|
|
||||||
objects = RestrictedQuerySet.as_manager()
|
objects = ObjectChangeQuerySet.as_manager()
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
ordering = ['-time']
|
ordering = ['-time']
|
||||||
|
@ -8,7 +8,7 @@ from django.contrib.contenttypes.models import ContentType
|
|||||||
from django.core.cache import cache
|
from django.core.cache import cache
|
||||||
from django.core.validators import ValidationError
|
from django.core.validators import ValidationError
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.http import HttpResponse, QueryDict
|
from django.http import HttpResponse
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django.utils.formats import date_format
|
from django.utils.formats import date_format
|
||||||
@ -25,7 +25,7 @@ from netbox.models.features import (
|
|||||||
CloningMixin, CustomFieldsMixin, CustomLinksMixin, ExportTemplatesMixin, SyncedDataMixin, TagsMixin,
|
CloningMixin, CustomFieldsMixin, CustomLinksMixin, ExportTemplatesMixin, SyncedDataMixin, TagsMixin,
|
||||||
)
|
)
|
||||||
from utilities.querysets import RestrictedQuerySet
|
from utilities.querysets import RestrictedQuerySet
|
||||||
from utilities.utils import clean_html, render_jinja2
|
from utilities.utils import clean_html, dict_to_querydict, render_jinja2
|
||||||
|
|
||||||
__all__ = (
|
__all__ = (
|
||||||
'Bookmark',
|
'Bookmark',
|
||||||
@ -285,7 +285,7 @@ class CustomLink(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
|
|||||||
text = clean_html(text, allowed_schemes)
|
text = clean_html(text, allowed_schemes)
|
||||||
|
|
||||||
# Sanitize link
|
# Sanitize link
|
||||||
link = urllib.parse.quote(link, safe='/:?&=%+[]@#')
|
link = urllib.parse.quote(link, safe='/:?&=%+[]@#,')
|
||||||
|
|
||||||
# Verify link scheme is allowed
|
# Verify link scheme is allowed
|
||||||
result = urllib.parse.urlparse(link)
|
result = urllib.parse.urlparse(link)
|
||||||
@ -463,8 +463,7 @@ class SavedFilter(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def url_params(self):
|
def url_params(self):
|
||||||
qd = QueryDict(mutable=True)
|
qd = dict_to_querydict(self.parameters)
|
||||||
qd.update(self.parameters)
|
|
||||||
return qd.urlencode()
|
return qd.urlencode()
|
||||||
|
|
||||||
|
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import inspect
|
import inspect
|
||||||
|
import logging
|
||||||
from functools import cached_property
|
from functools import cached_property
|
||||||
|
|
||||||
from django.contrib.contenttypes.fields import GenericRelation
|
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
|
||||||
@ -12,6 +12,8 @@ from netbox.models.features import JobsMixin, WebhooksMixin
|
|||||||
from utilities.querysets import RestrictedQuerySet
|
from utilities.querysets import RestrictedQuerySet
|
||||||
from .mixins import PythonModuleMixin
|
from .mixins import PythonModuleMixin
|
||||||
|
|
||||||
|
logger = logging.getLogger('netbox.reports')
|
||||||
|
|
||||||
__all__ = (
|
__all__ = (
|
||||||
'Report',
|
'Report',
|
||||||
'ReportModule',
|
'ReportModule',
|
||||||
@ -56,7 +58,8 @@ class ReportModule(PythonModuleMixin, JobsMixin, ManagedFile):
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
module = self.get_module()
|
module = self.get_module()
|
||||||
except ImportError:
|
except (ImportError, SyntaxError) as e:
|
||||||
|
logger.error(f"Unable to load report module {self.name}, exception: {e}")
|
||||||
return {}
|
return {}
|
||||||
reports = {}
|
reports = {}
|
||||||
ordered = getattr(module, 'report_order', [])
|
ordered = getattr(module, 'report_order', [])
|
||||||
|
@ -1,5 +1,8 @@
|
|||||||
|
from django.apps import apps
|
||||||
|
from django.contrib.contenttypes.models import ContentType
|
||||||
from django.contrib.postgres.aggregates import JSONBAgg
|
from django.contrib.postgres.aggregates import JSONBAgg
|
||||||
from django.db.models import OuterRef, Subquery, Q
|
from django.db.models import OuterRef, Subquery, Q
|
||||||
|
from django.db.utils import ProgrammingError
|
||||||
|
|
||||||
from extras.models.tags import TaggedItem
|
from extras.models.tags import TaggedItem
|
||||||
from utilities.query_functions import EmptyGroupByJSONBAgg
|
from utilities.query_functions import EmptyGroupByJSONBAgg
|
||||||
@ -151,3 +154,20 @@ class ConfigContextModelQuerySet(RestrictedQuerySet):
|
|||||||
)
|
)
|
||||||
|
|
||||||
return base_query
|
return base_query
|
||||||
|
|
||||||
|
|
||||||
|
class ObjectChangeQuerySet(RestrictedQuerySet):
|
||||||
|
|
||||||
|
def valid_models(self):
|
||||||
|
# Exclude any change records which refer to an instance of a model that's no longer installed. This
|
||||||
|
# can happen when a plugin is removed but its data remains in the database, for example.
|
||||||
|
try:
|
||||||
|
content_types = ContentType.objects.get_for_models(*apps.get_models()).values()
|
||||||
|
except ProgrammingError:
|
||||||
|
# Handle the case where the database schema has not yet been initialized
|
||||||
|
content_types = ContentType.objects.none()
|
||||||
|
|
||||||
|
content_type_ids = set(
|
||||||
|
ct.pk for ct in content_types
|
||||||
|
)
|
||||||
|
return self.filter(changed_object_type_id__in=content_type_ids)
|
||||||
|
@ -366,7 +366,7 @@ class BaseScript:
|
|||||||
if self.fieldsets:
|
if self.fieldsets:
|
||||||
fieldsets.extend(self.fieldsets)
|
fieldsets.extend(self.fieldsets)
|
||||||
else:
|
else:
|
||||||
fields = (name for name, _ in self._get_vars().items())
|
fields = list(name for name, _ in self._get_vars().items())
|
||||||
fieldsets.append(('Script Data', fields))
|
fieldsets.append(('Script Data', fields))
|
||||||
|
|
||||||
# Append the default fieldset if defined in the Meta class
|
# Append the default fieldset if defined in the Meta class
|
||||||
@ -390,6 +390,11 @@ class BaseScript:
|
|||||||
# Set initial "commit" checkbox state based on the script's Meta parameter
|
# Set initial "commit" checkbox state based on the script's Meta parameter
|
||||||
form.fields['_commit'].initial = self.commit_default
|
form.fields['_commit'].initial = self.commit_default
|
||||||
|
|
||||||
|
# Hide fields if scheduling has been disabled
|
||||||
|
if not self.scheduling_enabled:
|
||||||
|
form.fields['_schedule_at'].widget = forms.HiddenInput()
|
||||||
|
form.fields['_interval'].widget = forms.HiddenInput()
|
||||||
|
|
||||||
return form
|
return form
|
||||||
|
|
||||||
# Logging
|
# Logging
|
||||||
|
@ -8,7 +8,6 @@ from rest_framework import status
|
|||||||
|
|
||||||
from core.choices import ManagedFileRootPathChoices
|
from core.choices import ManagedFileRootPathChoices
|
||||||
from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Rack, Location, RackRole, Site
|
from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Rack, Location, RackRole, Site
|
||||||
from extras.api.views import ReportViewSet, ScriptViewSet
|
|
||||||
from extras.models import *
|
from extras.models import *
|
||||||
from extras.reports import Report
|
from extras.reports import Report
|
||||||
from extras.scripts import BooleanVar, IntegerVar, Script, StringVar
|
from extras.scripts import BooleanVar, IntegerVar, Script, StringVar
|
||||||
@ -634,6 +633,7 @@ class ReportTest(APITestCase):
|
|||||||
super().setUp()
|
super().setUp()
|
||||||
|
|
||||||
# Monkey-patch the API viewset's _get_report() method to return our test Report above
|
# Monkey-patch the API viewset's _get_report() method to return our test Report above
|
||||||
|
from extras.api.views import ReportViewSet
|
||||||
ReportViewSet._get_report = self.get_test_report
|
ReportViewSet._get_report = self.get_test_report
|
||||||
|
|
||||||
def test_get_report(self):
|
def test_get_report(self):
|
||||||
@ -676,6 +676,7 @@ class ScriptTest(APITestCase):
|
|||||||
super().setUp()
|
super().setUp()
|
||||||
|
|
||||||
# Monkey-patch the API viewset's _get_script() method to return our test Script above
|
# Monkey-patch the API viewset's _get_script() method to return our test Script above
|
||||||
|
from extras.api.views import ScriptViewSet
|
||||||
ScriptViewSet._get_script = self.get_test_script
|
ScriptViewSet._get_script = self.get_test_script
|
||||||
|
|
||||||
def test_get_script(self):
|
def test_get_script(self):
|
||||||
|
@ -541,7 +541,7 @@ class ConfigTemplateBulkSyncDataView(generic.BulkSyncDataView):
|
|||||||
#
|
#
|
||||||
|
|
||||||
class ObjectChangeListView(generic.ObjectListView):
|
class ObjectChangeListView(generic.ObjectListView):
|
||||||
queryset = ObjectChange.objects.all()
|
queryset = ObjectChange.objects.valid_models()
|
||||||
filterset = filtersets.ObjectChangeFilterSet
|
filterset = filtersets.ObjectChangeFilterSet
|
||||||
filterset_form = forms.ObjectChangeFilterForm
|
filterset_form = forms.ObjectChangeFilterForm
|
||||||
table = tables.ObjectChangeTable
|
table = tables.ObjectChangeTable
|
||||||
@ -551,10 +551,10 @@ class ObjectChangeListView(generic.ObjectListView):
|
|||||||
|
|
||||||
@register_model_view(ObjectChange)
|
@register_model_view(ObjectChange)
|
||||||
class ObjectChangeView(generic.ObjectView):
|
class ObjectChangeView(generic.ObjectView):
|
||||||
queryset = ObjectChange.objects.all()
|
queryset = ObjectChange.objects.valid_models()
|
||||||
|
|
||||||
def get_extra_context(self, request, instance):
|
def get_extra_context(self, request, instance):
|
||||||
related_changes = ObjectChange.objects.restrict(request.user, 'view').filter(
|
related_changes = ObjectChange.objects.valid_models().restrict(request.user, 'view').filter(
|
||||||
request_id=instance.request_id
|
request_id=instance.request_id
|
||||||
).exclude(
|
).exclude(
|
||||||
pk=instance.pk
|
pk=instance.pk
|
||||||
@ -564,7 +564,7 @@ class ObjectChangeView(generic.ObjectView):
|
|||||||
orderable=False
|
orderable=False
|
||||||
)
|
)
|
||||||
|
|
||||||
objectchanges = ObjectChange.objects.restrict(request.user, 'view').filter(
|
objectchanges = ObjectChange.objects.valid_models().restrict(request.user, 'view').filter(
|
||||||
changed_object_type=instance.changed_object_type,
|
changed_object_type=instance.changed_object_type,
|
||||||
changed_object_id=instance.changed_object_id,
|
changed_object_id=instance.changed_object_id,
|
||||||
)
|
)
|
||||||
|
@ -219,12 +219,13 @@ class VLANGroupSerializer(NetBoxModelSerializer):
|
|||||||
scope_id = serializers.IntegerField(allow_null=True, required=False, default=None)
|
scope_id = serializers.IntegerField(allow_null=True, required=False, default=None)
|
||||||
scope = serializers.SerializerMethodField(read_only=True)
|
scope = serializers.SerializerMethodField(read_only=True)
|
||||||
vlan_count = serializers.IntegerField(read_only=True)
|
vlan_count = serializers.IntegerField(read_only=True)
|
||||||
|
utilization = serializers.CharField(read_only=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = VLANGroup
|
model = VLANGroup
|
||||||
fields = [
|
fields = [
|
||||||
'id', 'url', 'display', 'name', 'slug', 'scope_type', 'scope_id', 'scope', 'min_vid', 'max_vid',
|
'id', 'url', 'display', 'name', 'slug', 'scope_type', 'scope_id', 'scope', 'min_vid', 'max_vid',
|
||||||
'description', 'tags', 'custom_fields', 'created', 'last_updated', 'vlan_count',
|
'description', 'tags', 'custom_fields', 'created', 'last_updated', 'vlan_count', 'utilization'
|
||||||
]
|
]
|
||||||
validators = []
|
validators = []
|
||||||
|
|
||||||
|
@ -1,5 +1,7 @@
|
|||||||
from django.core.exceptions import ObjectDoesNotExist, PermissionDenied
|
from django.core.exceptions import ObjectDoesNotExist, PermissionDenied
|
||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
|
from django.db.models import F
|
||||||
|
from django.db.models.functions import Round
|
||||||
from django.shortcuts import get_object_or_404
|
from django.shortcuts import get_object_or_404
|
||||||
from django_pglocks import advisory_lock
|
from django_pglocks import advisory_lock
|
||||||
from drf_spectacular.utils import extend_schema
|
from drf_spectacular.utils import extend_schema
|
||||||
@ -149,9 +151,7 @@ class FHRPGroupAssignmentViewSet(NetBoxModelViewSet):
|
|||||||
|
|
||||||
|
|
||||||
class VLANGroupViewSet(NetBoxModelViewSet):
|
class VLANGroupViewSet(NetBoxModelViewSet):
|
||||||
queryset = VLANGroup.objects.annotate(
|
queryset = VLANGroup.objects.annotate_utilization().prefetch_related('tags')
|
||||||
vlan_count=count_related(VLAN, 'group')
|
|
||||||
).prefetch_related('tags')
|
|
||||||
serializer_class = serializers.VLANGroupSerializer
|
serializer_class = serializers.VLANGroupSerializer
|
||||||
filterset_class = filtersets.VLANGroupFilterSet
|
filterset_class = filtersets.VLANGroupFilterSet
|
||||||
|
|
||||||
|
@ -4,6 +4,8 @@ from django.contrib.contenttypes.models import ContentType
|
|||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
from django.utils.translation import gettext as _
|
from django.utils.translation import gettext as _
|
||||||
|
from drf_spectacular.utils import extend_schema_field
|
||||||
|
from drf_spectacular.types import OpenApiTypes
|
||||||
from netaddr.core import AddrFormatError
|
from netaddr.core import AddrFormatError
|
||||||
|
|
||||||
from dcim.models import Device, Interface, Region, Site, SiteGroup
|
from dcim.models import Device, Interface, Region, Site, SiteGroup
|
||||||
@ -414,6 +416,7 @@ class PrefixFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
|
|||||||
except (AddrFormatError, ValueError):
|
except (AddrFormatError, ValueError):
|
||||||
return queryset.none()
|
return queryset.none()
|
||||||
|
|
||||||
|
@extend_schema_field(OpenApiTypes.STR)
|
||||||
def filter_present_in_vrf(self, queryset, name, vrf):
|
def filter_present_in_vrf(self, queryset, name, vrf):
|
||||||
if vrf is None:
|
if vrf is None:
|
||||||
return queryset.none
|
return queryset.none
|
||||||
@ -659,6 +662,7 @@ class IPAddressFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
|
|||||||
return queryset
|
return queryset
|
||||||
return queryset.filter(address__net_mask_length=value)
|
return queryset.filter(address__net_mask_length=value)
|
||||||
|
|
||||||
|
@extend_schema_field(OpenApiTypes.STR)
|
||||||
def filter_present_in_vrf(self, queryset, name, vrf):
|
def filter_present_in_vrf(self, queryset, name, vrf):
|
||||||
if vrf is None:
|
if vrf is None:
|
||||||
return queryset.none
|
return queryset.none
|
||||||
@ -727,6 +731,7 @@ class FHRPGroupFilterSet(NetBoxModelFilterSet):
|
|||||||
Q(name__icontains=value)
|
Q(name__icontains=value)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@extend_schema_field(OpenApiTypes.STR)
|
||||||
def filter_related_ip(self, queryset, name, value):
|
def filter_related_ip(self, queryset, name, value):
|
||||||
"""
|
"""
|
||||||
Filter by VRF & prefix of assigned IP addresses.
|
Filter by VRF & prefix of assigned IP addresses.
|
||||||
@ -941,9 +946,11 @@ class VLANFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
|
|||||||
pass
|
pass
|
||||||
return queryset.filter(qs_filter)
|
return queryset.filter(qs_filter)
|
||||||
|
|
||||||
|
@extend_schema_field(OpenApiTypes.STR)
|
||||||
def get_for_device(self, queryset, name, value):
|
def get_for_device(self, queryset, name, value):
|
||||||
return queryset.get_for_device(value)
|
return queryset.get_for_device(value)
|
||||||
|
|
||||||
|
@extend_schema_field(OpenApiTypes.STR)
|
||||||
def get_for_virtualmachine(self, queryset, name, value):
|
def get_for_virtualmachine(self, queryset, name, value):
|
||||||
return queryset.get_for_virtualmachine(value)
|
return queryset.get_for_virtualmachine(value)
|
||||||
|
|
||||||
|
@ -345,7 +345,7 @@ class IPAddressForm(TenancyForm, NetBoxModelForm):
|
|||||||
})
|
})
|
||||||
elif selected_objects:
|
elif selected_objects:
|
||||||
assigned_object = self.cleaned_data[selected_objects[0]]
|
assigned_object = self.cleaned_data[selected_objects[0]]
|
||||||
if self.cleaned_data['primary_for_parent'] and assigned_object != self.instance.assigned_object:
|
if self.instance.pk and self.cleaned_data['primary_for_parent'] and assigned_object != self.instance.assigned_object:
|
||||||
raise ValidationError(
|
raise ValidationError(
|
||||||
"Cannot reassign IP address while it is designated as the primary IP for the parent object"
|
"Cannot reassign IP address while it is designated as the primary IP for the parent object"
|
||||||
)
|
)
|
||||||
@ -379,6 +379,7 @@ class IPAddressForm(TenancyForm, NetBoxModelForm):
|
|||||||
interface = self.instance.assigned_object
|
interface = self.instance.assigned_object
|
||||||
if type(interface) in (Interface, VMInterface):
|
if type(interface) in (Interface, VMInterface):
|
||||||
parent = interface.parent_object
|
parent = interface.parent_object
|
||||||
|
parent.snapshot()
|
||||||
if self.cleaned_data['primary_for_parent']:
|
if self.cleaned_data['primary_for_parent']:
|
||||||
if ipaddress.address.version == 4:
|
if ipaddress.address.version == 4:
|
||||||
parent.primary_ip4 = ipaddress
|
parent.primary_ip4 = ipaddress
|
||||||
|
@ -4,6 +4,7 @@ from django.urls import reverse
|
|||||||
from django.utils.translation import gettext as _
|
from django.utils.translation import gettext as _
|
||||||
|
|
||||||
from ipam.fields import ASNField
|
from ipam.fields import ASNField
|
||||||
|
from ipam.querysets import ASNRangeQuerySet
|
||||||
from netbox.models import OrganizationalModel, PrimaryModel
|
from netbox.models import OrganizationalModel, PrimaryModel
|
||||||
|
|
||||||
__all__ = (
|
__all__ = (
|
||||||
@ -37,6 +38,8 @@ class ASNRange(OrganizationalModel):
|
|||||||
null=True
|
null=True
|
||||||
)
|
)
|
||||||
|
|
||||||
|
objects = ASNRangeQuerySet.as_manager()
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
ordering = ('name',)
|
ordering = ('name',)
|
||||||
verbose_name = 'ASN range'
|
verbose_name = 'ASN range'
|
||||||
|
@ -406,7 +406,7 @@ class Prefix(GetAvailablePrefixesMixin, PrimaryModel):
|
|||||||
Return all available IPs within this prefix as an IPSet.
|
Return all available IPs within this prefix as an IPSet.
|
||||||
"""
|
"""
|
||||||
if self.mark_utilized:
|
if self.mark_utilized:
|
||||||
return list()
|
return netaddr.IPSet()
|
||||||
|
|
||||||
prefix = netaddr.IPSet(self.prefix)
|
prefix = netaddr.IPSet(self.prefix)
|
||||||
child_ips = netaddr.IPSet([ip.address.ip for ip in self.get_child_ips()])
|
child_ips = netaddr.IPSet([ip.address.ip for ip in self.get_child_ips()])
|
||||||
|
@ -9,7 +9,7 @@ from django.utils.translation import gettext as _
|
|||||||
from dcim.models import Interface
|
from dcim.models import Interface
|
||||||
from ipam.choices import *
|
from ipam.choices import *
|
||||||
from ipam.constants import *
|
from ipam.constants import *
|
||||||
from ipam.querysets import VLANQuerySet
|
from ipam.querysets import VLANQuerySet, VLANGroupQuerySet
|
||||||
from netbox.models import OrganizationalModel, PrimaryModel
|
from netbox.models import OrganizationalModel, PrimaryModel
|
||||||
from virtualization.models import VMInterface
|
from virtualization.models import VMInterface
|
||||||
|
|
||||||
@ -63,6 +63,8 @@ class VLANGroup(OrganizationalModel):
|
|||||||
help_text=_('Highest permissible ID of a child VLAN')
|
help_text=_('Highest permissible ID of a child VLAN')
|
||||||
)
|
)
|
||||||
|
|
||||||
|
objects = VLANGroupQuerySet.as_manager()
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
ordering = ('name', 'pk') # Name may be non-unique
|
ordering = ('name', 'pk') # Name may be non-unique
|
||||||
constraints = (
|
constraints = (
|
||||||
|
@ -1,8 +1,34 @@
|
|||||||
from django.contrib.contenttypes.models import ContentType
|
from django.contrib.contenttypes.models import ContentType
|
||||||
from django.db.models import Q
|
from django.db.models import Count, F, OuterRef, Q, Subquery, Value
|
||||||
from django.db.models.expressions import RawSQL
|
from django.db.models.expressions import RawSQL
|
||||||
|
from django.db.models.functions import Round
|
||||||
|
|
||||||
from utilities.querysets import RestrictedQuerySet
|
from utilities.querysets import RestrictedQuerySet
|
||||||
|
from utilities.utils import count_related
|
||||||
|
|
||||||
|
__all__ = (
|
||||||
|
'ASNRangeQuerySet',
|
||||||
|
'PrefixQuerySet',
|
||||||
|
'VLANQuerySet',
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ASNRangeQuerySet(RestrictedQuerySet):
|
||||||
|
|
||||||
|
def annotate_asn_counts(self):
|
||||||
|
"""
|
||||||
|
Annotate the number of ASNs which appear within each range.
|
||||||
|
"""
|
||||||
|
from .models import ASN
|
||||||
|
|
||||||
|
# Because ASN does not have a foreign key to ASNRange, we create a fake column "_" with a consistent value
|
||||||
|
# that we can use to count ASNs and return a single value per ASNRange.
|
||||||
|
asns = ASN.objects.filter(
|
||||||
|
asn__gte=OuterRef('start'),
|
||||||
|
asn__lte=OuterRef('end')
|
||||||
|
).order_by().annotate(_=Value(1)).values('_').annotate(c=Count('*')).values('c')
|
||||||
|
|
||||||
|
return self.annotate(asn_count=Subquery(asns))
|
||||||
|
|
||||||
|
|
||||||
class PrefixQuerySet(RestrictedQuerySet):
|
class PrefixQuerySet(RestrictedQuerySet):
|
||||||
@ -30,6 +56,17 @@ class PrefixQuerySet(RestrictedQuerySet):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class VLANGroupQuerySet(RestrictedQuerySet):
|
||||||
|
|
||||||
|
def annotate_utilization(self):
|
||||||
|
from .models import VLAN
|
||||||
|
|
||||||
|
return self.annotate(
|
||||||
|
vlan_count=count_related(VLAN, 'group'),
|
||||||
|
utilization=Round(F('vlan_count') / (F('max_vid') - F('min_vid') + 1.0) * 100, 2)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class VLANQuerySet(RestrictedQuerySet):
|
class VLANQuerySet(RestrictedQuerySet):
|
||||||
|
|
||||||
def get_for_device(self, device):
|
def get_for_device(self, device):
|
||||||
|
@ -21,10 +21,8 @@ class ASNRangeTable(TenancyColumnsMixin, NetBoxTable):
|
|||||||
tags = columns.TagColumn(
|
tags = columns.TagColumn(
|
||||||
url_name='ipam:asnrange_list'
|
url_name='ipam:asnrange_list'
|
||||||
)
|
)
|
||||||
asn_count = columns.LinkedCountColumn(
|
asn_count = tables.Column(
|
||||||
viewname='ipam:asn_list',
|
verbose_name=_('ASNs')
|
||||||
url_params={'asn_id': 'pk'},
|
|
||||||
verbose_name=_('ASN Count')
|
|
||||||
)
|
)
|
||||||
|
|
||||||
class Meta(NetBoxTable.Meta):
|
class Meta(NetBoxTable.Meta):
|
||||||
@ -59,7 +57,8 @@ class ASNTable(TenancyColumnsMixin, NetBoxTable):
|
|||||||
verbose_name=_('Provider Count')
|
verbose_name=_('Provider Count')
|
||||||
)
|
)
|
||||||
sites = columns.ManyToManyColumn(
|
sites = columns.ManyToManyColumn(
|
||||||
linkify_item=True
|
linkify_item=True,
|
||||||
|
verbose_name=_('Sites')
|
||||||
)
|
)
|
||||||
comments = columns.MarkdownColumn()
|
comments = columns.MarkdownColumn()
|
||||||
tags = columns.TagColumn(
|
tags = columns.TagColumn(
|
||||||
|
@ -19,14 +19,22 @@ __all__ = (
|
|||||||
|
|
||||||
AVAILABLE_LABEL = mark_safe('<span class="badge bg-success">Available</span>')
|
AVAILABLE_LABEL = mark_safe('<span class="badge bg-success">Available</span>')
|
||||||
|
|
||||||
|
AGGREGATE_COPY_BUTTON = """
|
||||||
|
{% copy_content record.pk prefix="aggregate_" %}
|
||||||
|
"""
|
||||||
|
|
||||||
PREFIX_LINK = """
|
PREFIX_LINK = """
|
||||||
{% if record.pk %}
|
{% if record.pk %}
|
||||||
<a href="{{ record.get_absolute_url }}">{{ record.prefix }}</a>
|
<a href="{{ record.get_absolute_url }}" id="prefix_{{ record.pk }}">{{ record.prefix }}</a>
|
||||||
{% else %}
|
{% else %}
|
||||||
<a href="{% url 'ipam:prefix_add' %}?prefix={{ record }}{% if object.vrf %}&vrf={{ object.vrf.pk }}{% endif %}{% if object.site %}&site={{ object.site.pk }}{% endif %}{% if object.tenant %}&tenant_group={{ object.tenant.group.pk }}&tenant={{ object.tenant.pk }}{% endif %}">{{ record.prefix }}</a>
|
<a href="{% url 'ipam:prefix_add' %}?prefix={{ record }}{% if object.vrf %}&vrf={{ object.vrf.pk }}{% endif %}{% if object.site %}&site={{ object.site.pk }}{% endif %}{% if object.tenant %}&tenant_group={{ object.tenant.group.pk }}&tenant={{ object.tenant.pk }}{% endif %}">{{ record.prefix }}</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
PREFIX_COPY_BUTTON = """
|
||||||
|
{% copy_content record.pk prefix="prefix_" %}
|
||||||
|
"""
|
||||||
|
|
||||||
PREFIX_LINK_WITH_DEPTH = """
|
PREFIX_LINK_WITH_DEPTH = """
|
||||||
{% load helpers %}
|
{% load helpers %}
|
||||||
{% if record.depth %}
|
{% if record.depth %}
|
||||||
@ -40,7 +48,7 @@ PREFIX_LINK_WITH_DEPTH = """
|
|||||||
|
|
||||||
IPADDRESS_LINK = """
|
IPADDRESS_LINK = """
|
||||||
{% if record.pk %}
|
{% if record.pk %}
|
||||||
<a href="{{ record.get_absolute_url }}">{{ record.address }}</a>
|
<a href="{{ record.get_absolute_url }}" id="ipaddress_{{ record.pk }}">{{ record.address }}</a>
|
||||||
{% elif perms.ipam.add_ipaddress %}
|
{% elif perms.ipam.add_ipaddress %}
|
||||||
<a href="{% url 'ipam:ipaddress_add' %}?address={{ record.1 }}{% if object.vrf %}&vrf={{ object.vrf.pk }}{% endif %}{% if object.tenant %}&tenant={{ object.tenant.pk }}{% endif %}" class="btn btn-sm btn-success">{% if record.0 <= 65536 %}{{ record.0 }}{% else %}Many{% endif %} IP{{ record.0|pluralize }} available</a>
|
<a href="{% url 'ipam:ipaddress_add' %}?address={{ record.1 }}{% if object.vrf %}&vrf={{ object.vrf.pk }}{% endif %}{% if object.tenant %}&tenant={{ object.tenant.pk }}{% endif %}" class="btn btn-sm btn-success">{% if record.0 <= 65536 %}{{ record.0 }}{% else %}Many{% endif %} IP{{ record.0|pluralize }} available</a>
|
||||||
{% else %}
|
{% else %}
|
||||||
@ -48,6 +56,10 @@ IPADDRESS_LINK = """
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
IPADDRESS_COPY_BUTTON = """
|
||||||
|
{% copy_content record.pk prefix="ipaddress_" %}
|
||||||
|
"""
|
||||||
|
|
||||||
IPADDRESS_ASSIGN_LINK = """
|
IPADDRESS_ASSIGN_LINK = """
|
||||||
<a href="{% url 'ipam:ipaddress_edit' pk=record.pk %}?{% if request.GET.interface %}interface={{ request.GET.interface }}{% elif request.GET.vminterface %}vminterface={{ request.GET.vminterface }}{% endif %}&return_url={{ request.GET.return_url }}">{{ record }}</a>
|
<a href="{% url 'ipam:ipaddress_edit' pk=record.pk %}?{% if request.GET.interface %}interface={{ request.GET.interface }}{% elif request.GET.vminterface %}vminterface={{ request.GET.vminterface }}{% endif %}&return_url={{ request.GET.return_url }}">{{ record }}</a>
|
||||||
"""
|
"""
|
||||||
@ -99,7 +111,11 @@ class RIRTable(NetBoxTable):
|
|||||||
class AggregateTable(TenancyColumnsMixin, NetBoxTable):
|
class AggregateTable(TenancyColumnsMixin, NetBoxTable):
|
||||||
prefix = tables.Column(
|
prefix = tables.Column(
|
||||||
linkify=True,
|
linkify=True,
|
||||||
verbose_name='Aggregate'
|
verbose_name='Aggregate',
|
||||||
|
attrs={
|
||||||
|
# Allow the aggregate to be copied to the clipboard
|
||||||
|
'a': {'id': lambda record: f"aggregate_{record.pk}"}
|
||||||
|
}
|
||||||
)
|
)
|
||||||
date_added = tables.DateColumn(
|
date_added = tables.DateColumn(
|
||||||
format="Y-m-d",
|
format="Y-m-d",
|
||||||
@ -116,6 +132,9 @@ class AggregateTable(TenancyColumnsMixin, NetBoxTable):
|
|||||||
tags = columns.TagColumn(
|
tags = columns.TagColumn(
|
||||||
url_name='ipam:aggregate_list'
|
url_name='ipam:aggregate_list'
|
||||||
)
|
)
|
||||||
|
actions = columns.ActionsColumn(
|
||||||
|
extra_buttons=AGGREGATE_COPY_BUTTON
|
||||||
|
)
|
||||||
|
|
||||||
class Meta(NetBoxTable.Meta):
|
class Meta(NetBoxTable.Meta):
|
||||||
model = Aggregate
|
model = Aggregate
|
||||||
@ -242,6 +261,9 @@ class PrefixTable(TenancyColumnsMixin, NetBoxTable):
|
|||||||
tags = columns.TagColumn(
|
tags = columns.TagColumn(
|
||||||
url_name='ipam:prefix_list'
|
url_name='ipam:prefix_list'
|
||||||
)
|
)
|
||||||
|
actions = columns.ActionsColumn(
|
||||||
|
extra_buttons=PREFIX_COPY_BUTTON
|
||||||
|
)
|
||||||
|
|
||||||
class Meta(NetBoxTable.Meta):
|
class Meta(NetBoxTable.Meta):
|
||||||
model = Prefix
|
model = Prefix
|
||||||
@ -348,6 +370,9 @@ class IPAddressTable(TenancyColumnsMixin, NetBoxTable):
|
|||||||
tags = columns.TagColumn(
|
tags = columns.TagColumn(
|
||||||
url_name='ipam:ipaddress_list'
|
url_name='ipam:ipaddress_list'
|
||||||
)
|
)
|
||||||
|
actions = columns.ActionsColumn(
|
||||||
|
extra_buttons=IPADDRESS_COPY_BUTTON
|
||||||
|
)
|
||||||
|
|
||||||
class Meta(NetBoxTable.Meta):
|
class Meta(NetBoxTable.Meta):
|
||||||
model = IPAddress
|
model = IPAddress
|
||||||
|
@ -70,6 +70,10 @@ class VLANGroupTable(NetBoxTable):
|
|||||||
url_params={'group_id': 'pk'},
|
url_params={'group_id': 'pk'},
|
||||||
verbose_name='VLANs'
|
verbose_name='VLANs'
|
||||||
)
|
)
|
||||||
|
utilization = columns.UtilizationColumn(
|
||||||
|
orderable=False,
|
||||||
|
verbose_name='Utilization'
|
||||||
|
)
|
||||||
tags = columns.TagColumn(
|
tags = columns.TagColumn(
|
||||||
url_name='ipam:vlangroup_list'
|
url_name='ipam:vlangroup_list'
|
||||||
)
|
)
|
||||||
@ -81,9 +85,9 @@ class VLANGroupTable(NetBoxTable):
|
|||||||
model = VLANGroup
|
model = VLANGroup
|
||||||
fields = (
|
fields = (
|
||||||
'pk', 'id', 'name', 'scope_type', 'scope', 'min_vid', 'max_vid', 'vlan_count', 'slug', 'description',
|
'pk', 'id', 'name', 'scope_type', 'scope', 'min_vid', 'max_vid', 'vlan_count', 'slug', 'description',
|
||||||
'tags', 'created', 'last_updated', 'actions',
|
'tags', 'created', 'last_updated', 'actions', 'utilization',
|
||||||
)
|
)
|
||||||
default_columns = ('pk', 'name', 'scope_type', 'scope', 'vlan_count', 'description')
|
default_columns = ('pk', 'name', 'scope_type', 'scope', 'vlan_count', 'utilization', 'description')
|
||||||
|
|
||||||
|
|
||||||
#
|
#
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
from django.contrib.contenttypes.models import ContentType
|
from django.contrib.contenttypes.models import ContentType
|
||||||
from django.db.models import Prefetch
|
from django.db.models import F, Prefetch
|
||||||
from django.db.models.expressions import RawSQL
|
from django.db.models.expressions import RawSQL
|
||||||
|
from django.db.models.functions import Round
|
||||||
from django.shortcuts import get_object_or_404, redirect, render
|
from django.shortcuts import get_object_or_404, redirect, render
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.utils.translation import gettext as _
|
from django.utils.translation import gettext as _
|
||||||
@ -198,7 +199,7 @@ class RIRBulkDeleteView(generic.BulkDeleteView):
|
|||||||
#
|
#
|
||||||
|
|
||||||
class ASNRangeListView(generic.ObjectListView):
|
class ASNRangeListView(generic.ObjectListView):
|
||||||
queryset = ASNRange.objects.all()
|
queryset = ASNRange.objects.annotate_asn_counts()
|
||||||
filterset = filtersets.ASNRangeFilterSet
|
filterset = filtersets.ASNRangeFilterSet
|
||||||
filterset_form = forms.ASNRangeFilterForm
|
filterset_form = forms.ASNRangeFilterForm
|
||||||
table = tables.ASNRangeTable
|
table = tables.ASNRangeTable
|
||||||
@ -247,18 +248,14 @@ class ASNRangeBulkImportView(generic.BulkImportView):
|
|||||||
|
|
||||||
|
|
||||||
class ASNRangeBulkEditView(generic.BulkEditView):
|
class ASNRangeBulkEditView(generic.BulkEditView):
|
||||||
queryset = ASNRange.objects.annotate(
|
queryset = ASNRange.objects.annotate_asn_counts()
|
||||||
site_count=count_related(Site, 'asns')
|
|
||||||
)
|
|
||||||
filterset = filtersets.ASNRangeFilterSet
|
filterset = filtersets.ASNRangeFilterSet
|
||||||
table = tables.ASNRangeTable
|
table = tables.ASNRangeTable
|
||||||
form = forms.ASNRangeBulkEditForm
|
form = forms.ASNRangeBulkEditForm
|
||||||
|
|
||||||
|
|
||||||
class ASNRangeBulkDeleteView(generic.BulkDeleteView):
|
class ASNRangeBulkDeleteView(generic.BulkDeleteView):
|
||||||
queryset = ASNRange.objects.annotate(
|
queryset = ASNRange.objects.annotate_asn_counts()
|
||||||
site_count=count_related(Site, 'asns')
|
|
||||||
)
|
|
||||||
filterset = filtersets.ASNRangeFilterSet
|
filterset = filtersets.ASNRangeFilterSet
|
||||||
table = tables.ASNRangeTable
|
table = tables.ASNRangeTable
|
||||||
|
|
||||||
@ -886,9 +883,7 @@ class IPAddressRelatedIPsView(generic.ObjectChildrenView):
|
|||||||
#
|
#
|
||||||
|
|
||||||
class VLANGroupListView(generic.ObjectListView):
|
class VLANGroupListView(generic.ObjectListView):
|
||||||
queryset = VLANGroup.objects.annotate(
|
queryset = VLANGroup.objects.annotate_utilization().prefetch_related('tags')
|
||||||
vlan_count=count_related(VLAN, 'group')
|
|
||||||
)
|
|
||||||
filterset = filtersets.VLANGroupFilterSet
|
filterset = filtersets.VLANGroupFilterSet
|
||||||
filterset_form = forms.VLANGroupFilterForm
|
filterset_form = forms.VLANGroupFilterForm
|
||||||
table = tables.VLANGroupTable
|
table = tables.VLANGroupTable
|
||||||
@ -896,7 +891,7 @@ class VLANGroupListView(generic.ObjectListView):
|
|||||||
|
|
||||||
@register_model_view(VLANGroup)
|
@register_model_view(VLANGroup)
|
||||||
class VLANGroupView(generic.ObjectView):
|
class VLANGroupView(generic.ObjectView):
|
||||||
queryset = VLANGroup.objects.all()
|
queryset = VLANGroup.objects.annotate_utilization().prefetch_related('tags')
|
||||||
|
|
||||||
def get_extra_context(self, request, instance):
|
def get_extra_context(self, request, instance):
|
||||||
related_models = (
|
related_models = (
|
||||||
@ -938,18 +933,14 @@ class VLANGroupBulkImportView(generic.BulkImportView):
|
|||||||
|
|
||||||
|
|
||||||
class VLANGroupBulkEditView(generic.BulkEditView):
|
class VLANGroupBulkEditView(generic.BulkEditView):
|
||||||
queryset = VLANGroup.objects.annotate(
|
queryset = VLANGroup.objects.annotate_utilization().prefetch_related('tags')
|
||||||
vlan_count=count_related(VLAN, 'group')
|
|
||||||
)
|
|
||||||
filterset = filtersets.VLANGroupFilterSet
|
filterset = filtersets.VLANGroupFilterSet
|
||||||
table = tables.VLANGroupTable
|
table = tables.VLANGroupTable
|
||||||
form = forms.VLANGroupBulkEditForm
|
form = forms.VLANGroupBulkEditForm
|
||||||
|
|
||||||
|
|
||||||
class VLANGroupBulkDeleteView(generic.BulkDeleteView):
|
class VLANGroupBulkDeleteView(generic.BulkDeleteView):
|
||||||
queryset = VLANGroup.objects.annotate(
|
queryset = VLANGroup.objects.annotate_utilization().prefetch_related('tags')
|
||||||
vlan_count=count_related(VLAN, 'group')
|
|
||||||
)
|
|
||||||
filterset = filtersets.VLANGroupFilterSet
|
filterset = filtersets.VLANGroupFilterSet
|
||||||
table = tables.VLANGroupTable
|
table = tables.VLANGroupTable
|
||||||
|
|
||||||
|
@ -60,7 +60,7 @@ class TokenAuthentication(authentication.TokenAuthentication):
|
|||||||
|
|
||||||
user = token.user
|
user = token.user
|
||||||
# When LDAP authentication is active try to load user data from LDAP directory
|
# When LDAP authentication is active try to load user data from LDAP directory
|
||||||
if settings.REMOTE_AUTH_BACKEND == 'netbox.authentication.LDAPBackend':
|
if 'netbox.authentication.LDAPBackend' in settings.REMOTE_AUTH_BACKEND:
|
||||||
from netbox.authentication import LDAPBackend
|
from netbox.authentication import LDAPBackend
|
||||||
ldap_backend = LDAPBackend()
|
ldap_backend = LDAPBackend()
|
||||||
|
|
||||||
|
@ -49,6 +49,9 @@ class CoreMiddleware:
|
|||||||
# Attach the unique request ID as an HTTP header.
|
# Attach the unique request ID as an HTTP header.
|
||||||
response['X-Request-ID'] = request.id
|
response['X-Request-ID'] = request.id
|
||||||
|
|
||||||
|
# Enable the Vary header to help with caching of HTMX responses
|
||||||
|
response['Vary'] = 'HX-Request'
|
||||||
|
|
||||||
# If this is an API request, attach an HTTP header annotating the API version (e.g. '3.5').
|
# If this is an API request, attach an HTTP header annotating the API version (e.g. '3.5').
|
||||||
if is_api_request(request):
|
if is_api_request(request):
|
||||||
response['API-Version'] = settings.REST_FRAMEWORK_VERSION
|
response['API-Version'] = settings.REST_FRAMEWORK_VERSION
|
||||||
@ -203,7 +206,7 @@ class MaintenanceModeMiddleware:
|
|||||||
"""
|
"""
|
||||||
Prevent any write-related database operations if an exception is raised.
|
Prevent any write-related database operations if an exception is raised.
|
||||||
"""
|
"""
|
||||||
if isinstance(exception, InternalError):
|
if get_config().MAINTENANCE_MODE and isinstance(exception, InternalError):
|
||||||
error_message = 'NetBox is currently operating in maintenance mode and is unable to perform write ' \
|
error_message = 'NetBox is currently operating in maintenance mode and is unable to perform write ' \
|
||||||
'operations. Please try again later.'
|
'operations. Please try again later.'
|
||||||
|
|
||||||
|
@ -25,7 +25,7 @@ from netbox.constants import RQ_QUEUE_DEFAULT, RQ_QUEUE_HIGH, RQ_QUEUE_LOW
|
|||||||
# Environment setup
|
# Environment setup
|
||||||
#
|
#
|
||||||
|
|
||||||
VERSION = '3.5.5-dev'
|
VERSION = '3.5.7-dev'
|
||||||
|
|
||||||
# Hostname
|
# Hostname
|
||||||
HOSTNAME = platform.node()
|
HOSTNAME = platform.node()
|
||||||
|
@ -551,7 +551,7 @@ class BulkEditView(GetReturnURLMixin, BaseMultiObjectView):
|
|||||||
for name, m2m_field in m2m_fields.items():
|
for name, m2m_field in m2m_fields.items():
|
||||||
if name in form.nullable_fields and name in nullified_fields:
|
if name in form.nullable_fields and name in nullified_fields:
|
||||||
getattr(obj, name).clear()
|
getattr(obj, name).clear()
|
||||||
else:
|
elif form.cleaned_data[name]:
|
||||||
getattr(obj, name).set(form.cleaned_data[name])
|
getattr(obj, name).set(form.cleaned_data[name])
|
||||||
|
|
||||||
# Add/remove tags
|
# Add/remove tags
|
||||||
|
BIN
netbox/project-static/dist/netbox.js
vendored
BIN
netbox/project-static/dist/netbox.js
vendored
Binary file not shown.
BIN
netbox/project-static/dist/netbox.js.map
vendored
BIN
netbox/project-static/dist/netbox.js.map
vendored
Binary file not shown.
@ -2,7 +2,7 @@ import Clipboard from 'clipboard';
|
|||||||
import { getElements } from './util';
|
import { getElements } from './util';
|
||||||
|
|
||||||
export function initClipboard(): void {
|
export function initClipboard(): void {
|
||||||
for (const element of getElements('a.copy-token', 'button.copy-secret')) {
|
for (const element of getElements('a.copy-content')) {
|
||||||
new Clipboard(element);
|
new Clipboard(element);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -39,9 +39,7 @@
|
|||||||
<th scope="row">Path</th>
|
<th scope="row">Path</th>
|
||||||
<td>
|
<td>
|
||||||
<span class="font-monospace" id="datafile_path">{{ object.path }}</span>
|
<span class="font-monospace" id="datafile_path">{{ object.path }}</span>
|
||||||
<a class="btn btn-sm btn-primary copy-token" data-clipboard-target="#datafile_path" title="Copy to clipboard">
|
{% copy_content "datafile_path" %}
|
||||||
<i class="mdi mdi-content-copy"></i>
|
|
||||||
</a>
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
@ -56,9 +54,7 @@
|
|||||||
<th scope="row">SHA256 Hash</th>
|
<th scope="row">SHA256 Hash</th>
|
||||||
<td>
|
<td>
|
||||||
<span class="font-monospace" id="datafile_hash">{{ object.hash }}</span>
|
<span class="font-monospace" id="datafile_hash">{{ object.hash }}</span>
|
||||||
<a class="btn btn-sm btn-primary copy-token" data-clipboard-target="#datafile_hash" title="Copy to clipboard">
|
{% copy_content "datafile_hash" %}
|
||||||
<i class="mdi mdi-content-copy"></i>
|
|
||||||
</a>
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
|
@ -211,12 +211,13 @@
|
|||||||
<th scope="row">Primary IPv4</th>
|
<th scope="row">Primary IPv4</th>
|
||||||
<td>
|
<td>
|
||||||
{% if object.primary_ip4 %}
|
{% if object.primary_ip4 %}
|
||||||
<a href="{{ object.primary_ip4.get_absolute_url }}">{{ object.primary_ip4.address.ip }}</a>
|
<a href="{{ object.primary_ip4.get_absolute_url }}" id="primary_ip4">{{ object.primary_ip4.address.ip }}</a>
|
||||||
{% if object.primary_ip4.nat_inside %}
|
{% if object.primary_ip4.nat_inside %}
|
||||||
(NAT for <a href="{{ object.primary_ip4.nat_inside.get_absolute_url }}">{{ object.primary_ip4.nat_inside.address.ip }}</a>)
|
(NAT for <a href="{{ object.primary_ip4.nat_inside.get_absolute_url }}">{{ object.primary_ip4.nat_inside.address.ip }}</a>)
|
||||||
{% elif object.primary_ip4.nat_outside.exists %}
|
{% elif object.primary_ip4.nat_outside.exists %}
|
||||||
(NAT: {% for nat in object.primary_ip4.nat_outside.all %}<a href="{{ nat.get_absolute_url }}">{{ nat.address.ip }}</a>{% if not forloop.last %}, {% endif %}{% endfor %})
|
(NAT: {% for nat in object.primary_ip4.nat_outside.all %}<a href="{{ nat.get_absolute_url }}">{{ nat.address.ip }}</a>{% if not forloop.last %}, {% endif %}{% endfor %})
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% copy_content "primary_ip4" %}
|
||||||
{% else %}
|
{% else %}
|
||||||
{{ ''|placeholder }}
|
{{ ''|placeholder }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@ -226,12 +227,13 @@
|
|||||||
<th scope="row">Primary IPv6</th>
|
<th scope="row">Primary IPv6</th>
|
||||||
<td>
|
<td>
|
||||||
{% if object.primary_ip6 %}
|
{% if object.primary_ip6 %}
|
||||||
<a href="{{ object.primary_ip6.get_absolute_url }}">{{ object.primary_ip6.address.ip }}</a>
|
<a href="{{ object.primary_ip6.get_absolute_url }}" id="primary_ip6">{{ object.primary_ip6.address.ip }}</a>
|
||||||
{% if object.primary_ip6.nat_inside %}
|
{% if object.primary_ip6.nat_inside %}
|
||||||
(NAT for <a href="{{ object.primary_ip6.nat_inside.get_absolute_url }}">{{ object.primary_ip6.nat_inside.address.ip }}</a>)
|
(NAT for <a href="{{ object.primary_ip6.nat_inside.get_absolute_url }}">{{ object.primary_ip6.nat_inside.address.ip }}</a>)
|
||||||
{% elif object.primary_ip6.nat_outside.exists %}
|
{% elif object.primary_ip6.nat_outside.exists %}
|
||||||
(NAT: {% for nat in object.primary_ip6.nat_outside.all %}<a href="{{ nat.get_absolute_url }}">{{ nat.address.ip }}</a>{% if not forloop.last %}, {% endif %}{% endfor %})
|
(NAT: {% for nat in object.primary_ip6.nat_outside.all %}<a href="{{ nat.get_absolute_url }}">{{ nat.address.ip }}</a>{% if not forloop.last %}, {% endif %}{% endfor %})
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% copy_content "primary_ip6" %}
|
||||||
{% else %}
|
{% else %}
|
||||||
{{ ''|placeholder }}
|
{{ ''|placeholder }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
@ -15,15 +15,14 @@
|
|||||||
<td>Rack</td>
|
<td>Rack</td>
|
||||||
<td>{{ terminations.0.device.rack|linkify|placeholder }}</td>
|
<td>{{ terminations.0.device.rack|linkify|placeholder }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
|
||||||
<td>Device</td>
|
|
||||||
<td>{{ terminations.0.device|linkify }}</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
<tr>
|
||||||
<td>{{ terminations.0|meta:"verbose_name"|capfirst }}</td>
|
<td>{{ terminations.0|meta:"verbose_name"|capfirst }}</td>
|
||||||
<td>
|
<td>
|
||||||
{% for term in terminations %}
|
{% for term in terminations %}
|
||||||
{{ term|linkify }}{% if not forloop.last %},{% endif %}
|
{{term.device|linkify}}
|
||||||
|
<i class="mdi mdi-chevron-right" aria-hidden="true"></i>
|
||||||
|
{{ term|linkify }}
|
||||||
|
{% if not forloop.last %}<br/>{% endif %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
@ -31,13 +31,23 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<th scope="row">Primary IPv4</th>
|
<th scope="row">Primary IPv4</th>
|
||||||
<td>
|
<td>
|
||||||
{{ object.primary_ip4|linkify|placeholder }}
|
{% if object.primary_ip4 %}
|
||||||
|
<a href="{{ object.primary_ip4.get_absolute_url }}" id="primary_ip4">{{ object.primary_ip4 }}</a>
|
||||||
|
{% copy_content "primary_ip4" %}
|
||||||
|
{% else %}
|
||||||
|
<span class="text-muted">—</span>
|
||||||
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<th scope="row">Primary IPv6</th>
|
<th scope="row">Primary IPv6</th>
|
||||||
<td>
|
<td>
|
||||||
{{ object.primary_ip6|linkify|placeholder }}
|
{% if object.primary_ip6 %}
|
||||||
|
<a href="{{ object.primary_ip6.get_absolute_url }}" id="primary_ip6">{{ object.primary_ip6 }}</a>
|
||||||
|
{% copy_content "primary_ip6" %}
|
||||||
|
{% else %}
|
||||||
|
<span class="text-muted">—</span>
|
||||||
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
|
@ -38,71 +38,77 @@
|
|||||||
</h5>
|
</h5>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
{% include 'inc/sync_warning.html' with object=module %}
|
{% include 'inc/sync_warning.html' with object=module %}
|
||||||
<table class="table table-hover table-headings reports">
|
{% if module.reports %}
|
||||||
<thead>
|
<table class="table table-hover table-headings reports">
|
||||||
<tr>
|
<thead>
|
||||||
<th width="250">Name</th>
|
<tr>
|
||||||
<th>Description</th>
|
<th width="250">Name</th>
|
||||||
<th>Last Run</th>
|
<th>Description</th>
|
||||||
<th>Status</th>
|
<th>Last Run</th>
|
||||||
<th width="120"></th>
|
<th>Status</th>
|
||||||
</tr>
|
<th width="120"></th>
|
||||||
</thead>
|
</tr>
|
||||||
<tbody>
|
</thead>
|
||||||
{% with jobs=module.get_latest_jobs %}
|
<tbody>
|
||||||
{% for report_name, report in module.reports.items %}
|
{% with jobs=module.get_latest_jobs %}
|
||||||
{% with last_job=jobs|get_key:report.name %}
|
{% for report_name, report in module.reports.items %}
|
||||||
<tr>
|
{% with last_job=jobs|get_key:report.class_name %}
|
||||||
<td>
|
|
||||||
<a href="{% url 'extras:report' module=module.python_name name=report.class_name %}" id="{{ report.module }}.{{ report.class_name }}">{{ report.name }}</a>
|
|
||||||
</td>
|
|
||||||
<td>{{ report.description|markdown|placeholder }}</td>
|
|
||||||
{% if last_job %}
|
|
||||||
<td>
|
|
||||||
<a href="{% url 'extras:report_result' job_pk=last_job.pk %}">{{ last_job.created|annotated_date }}</a>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
{% badge last_job.get_status_display last_job.get_status_color %}
|
|
||||||
</td>
|
|
||||||
{% else %}
|
|
||||||
<td class="text-muted">Never</td>
|
|
||||||
<td>{{ ''|placeholder }}</td>
|
|
||||||
{% endif %}
|
|
||||||
<td>
|
|
||||||
{% if perms.extras.run_report %}
|
|
||||||
<div class="float-end noprint">
|
|
||||||
<form action="{% url 'extras:report' module=report.module name=report.class_name %}" method="post">
|
|
||||||
{% csrf_token %}
|
|
||||||
<button type="submit" name="_run" class="btn btn-primary btn-sm" style="width: 110px">
|
|
||||||
{% if last_job %}
|
|
||||||
<i class="mdi mdi-replay"></i> Run Again
|
|
||||||
{% else %}
|
|
||||||
<i class="mdi mdi-play"></i> Run Report
|
|
||||||
{% endif %}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{% for method, stats in last_job.data.items %}
|
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="4" class="method">
|
<td>
|
||||||
<span class="ps-3">{{ method }}</span>
|
<a href="{% url 'extras:report' module=module.python_name name=report.class_name %}" id="{{ report.module }}.{{ report.class_name }}">{{ report.name }}</a>
|
||||||
</td>
|
</td>
|
||||||
<td class="text-end text-nowrap report-stats">
|
<td>{{ report.description|markdown|placeholder }}</td>
|
||||||
<span class="badge bg-success">{{ stats.success }}</span>
|
{% if last_job %}
|
||||||
<span class="badge bg-info">{{ stats.info }}</span>
|
<td>
|
||||||
<span class="badge bg-warning">{{ stats.warning }}</span>
|
<a href="{% url 'extras:report_result' job_pk=last_job.pk %}">{{ last_job.created|annotated_date }}</a>
|
||||||
<span class="badge bg-danger">{{ stats.failure }}</span>
|
</td>
|
||||||
|
<td>
|
||||||
|
{% badge last_job.get_status_display last_job.get_status_color %}
|
||||||
|
</td>
|
||||||
|
{% else %}
|
||||||
|
<td class="text-muted">Never</td>
|
||||||
|
<td>{{ ''|placeholder }}</td>
|
||||||
|
{% endif %}
|
||||||
|
<td>
|
||||||
|
{% if perms.extras.run_report %}
|
||||||
|
<div class="float-end noprint">
|
||||||
|
<form action="{% url 'extras:report' module=report.module name=report.class_name %}" method="post">
|
||||||
|
{% csrf_token %}
|
||||||
|
<button type="submit" name="_run" class="btn btn-primary btn-sm" style="width: 110px">
|
||||||
|
{% if last_job %}
|
||||||
|
<i class="mdi mdi-replay"></i> Run Again
|
||||||
|
{% else %}
|
||||||
|
<i class="mdi mdi-play"></i> Run Report
|
||||||
|
{% endif %}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% for method, stats in last_job.data.items %}
|
||||||
{% endwith %}
|
<tr>
|
||||||
{% endfor %}
|
<td colspan="4" class="method">
|
||||||
{% endwith %}
|
<span class="ps-3">{{ method }}</span>
|
||||||
</tbody>
|
</td>
|
||||||
</table>
|
<td class="text-end text-nowrap report-stats">
|
||||||
|
<span class="badge bg-success">{{ stats.success }}</span>
|
||||||
|
<span class="badge bg-info">{{ stats.info }}</span>
|
||||||
|
<span class="badge bg-warning">{{ stats.warning }}</span>
|
||||||
|
<span class="badge bg-danger">{{ stats.failure }}</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
{% endwith %}
|
||||||
|
{% endfor %}
|
||||||
|
{% endwith %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% else %}
|
||||||
|
<div class="alert alert-warning" role="alert">
|
||||||
|
<i class="mdi mdi-alert"></i> Could not load reports from {{ module.name }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% empty %}
|
{% empty %}
|
||||||
|
@ -15,9 +15,9 @@
|
|||||||
<form action="" method="post" enctype="multipart/form-data" class="form form-object-edit">
|
<form action="" method="post" enctype="multipart/form-data" class="form form-object-edit">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
<div class="field-group my-4">
|
<div class="field-group my-4">
|
||||||
{% if form.requires_input %}
|
{# Render grouped fields according to declared fieldsets #}
|
||||||
{# Render grouped fields according to declared fieldsets #}
|
{% for group, fields in script.get_fieldsets %}
|
||||||
{% for group, fields in script.get_fieldsets %}
|
{% if fields %}
|
||||||
<div class="field-group mb-5">
|
<div class="field-group mb-5">
|
||||||
<div class="row mb-2">
|
<div class="row mb-2">
|
||||||
<h5 class="offset-sm-3">{{ group }}</h5>
|
<h5 class="offset-sm-3">{{ group }}</h5>
|
||||||
@ -28,14 +28,8 @@
|
|||||||
{% endwith %}
|
{% endwith %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endif %}
|
||||||
{% else %}
|
{% endfor %}
|
||||||
<div class="alert alert-info">
|
|
||||||
<i class="mdi mdi-information"></i>
|
|
||||||
This script does not require any input to run.
|
|
||||||
</div>
|
|
||||||
{% render_form form %}
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
</div>
|
||||||
<div class="float-end">
|
<div class="float-end">
|
||||||
<a href="{% url 'extras:script_list' %}" class="btn btn-outline-danger">Cancel</a>
|
<a href="{% url 'extras:script_list' %}" class="btn btn-outline-danger">Cancel</a>
|
||||||
|
@ -61,7 +61,7 @@
|
|||||||
<td>
|
<td>
|
||||||
{{ script_class.Meta.description|markdown|placeholder }}
|
{{ script_class.Meta.description|markdown|placeholder }}
|
||||||
</td>
|
</td>
|
||||||
{% with last_result=jobs|get_key:script_class.name %}
|
{% with last_result=jobs|get_key:script_class.class_name %}
|
||||||
{% if last_result %}
|
{% if last_result %}
|
||||||
<td>
|
<td>
|
||||||
<a href="{% url 'extras:script_result' job_pk=last_result.pk %}">{{ last_result.created|annotated_date }}</a>
|
<a href="{% url 'extras:script_result' job_pk=last_result.pk %}">{{ last_result.created|annotated_date }}</a>
|
||||||
|
@ -42,6 +42,10 @@
|
|||||||
<th scope="row">Permitted VIDs</th>
|
<th scope="row">Permitted VIDs</th>
|
||||||
<td>{{ object.min_vid }} - {{ object.max_vid }}</td>
|
<td>{{ object.min_vid }} - {{ object.max_vid }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th scope="row">Utilization</th>
|
||||||
|
<td>{% utilization_graph object.utilization %}</td>
|
||||||
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -2,10 +2,12 @@
|
|||||||
{% load helpers %}
|
{% load helpers %}
|
||||||
|
|
||||||
{% block extra_controls %}
|
{% block extra_controls %}
|
||||||
{% if perms.tenancy.add_contactassignment %}
|
{% if perms.tenancy.add_contactassignment %}
|
||||||
<a href="{% url 'tenancy:contactassignment_add' %}?content_type={{ object|content_type_id }}&object_id={{ object.pk }}&return_url={{ object.get_absolute_url }}" class="btn btn-primary btn-sm">
|
{% with viewname=object|viewname:"contacts" %}
|
||||||
|
<a href="{% url 'tenancy:contactassignment_add' %}?content_type={{ object|content_type_id }}&object_id={{ object.pk }}&return_url={% url viewname pk=object.pk %}" class="btn btn-primary btn-sm">
|
||||||
<i class="mdi mdi-plus-thick" aria-hidden="true"></i> Add a contact
|
<i class="mdi mdi-plus-thick" aria-hidden="true"></i> Add a contact
|
||||||
</a>
|
</a>
|
||||||
|
{% endwith %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
@ -8,7 +8,7 @@
|
|||||||
<div class="col col-md-12">
|
<div class="col col-md-12">
|
||||||
{% if not settings.ALLOW_TOKEN_RETRIEVAL %}
|
{% if not settings.ALLOW_TOKEN_RETRIEVAL %}
|
||||||
<div class="alert alert-danger" role="alert">
|
<div class="alert alert-danger" role="alert">
|
||||||
<i class="mdi mdi-alert"></i> Tokens cannot be retrieved at a later time. You must <a href="#" class="copy-token" data-clipboard-target="#token_id" title="Copy to clipboard">copy the token value</a> below and store it securely.
|
<i class="mdi mdi-alert"></i> Tokens cannot be retrieved at a later time. You must <a href="#" class="copy-content" data-clipboard-target="#token_id" title="Copy to clipboard">copy the token value</a> below and store it securely.
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<div class="card">
|
<div class="card">
|
||||||
@ -19,9 +19,7 @@
|
|||||||
<th scope="row">Key</th>
|
<th scope="row">Key</th>
|
||||||
<td>
|
<td>
|
||||||
<div class="float-end">
|
<div class="float-end">
|
||||||
<a class="btn btn-sm btn-success copy-token" data-clipboard-target="#token_id" title="Copy to clipboard">
|
{% copy_content "token_id" %}
|
||||||
<i class="mdi mdi-content-copy"></i>
|
|
||||||
</a>
|
|
||||||
</div>
|
</div>
|
||||||
<div id="token_id">{{ key }}</div>
|
<div id="token_id">{{ key }}</div>
|
||||||
</td>
|
</td>
|
||||||
|
@ -46,12 +46,13 @@
|
|||||||
<th scope="row">Primary IPv4</th>
|
<th scope="row">Primary IPv4</th>
|
||||||
<td>
|
<td>
|
||||||
{% if object.primary_ip4 %}
|
{% if object.primary_ip4 %}
|
||||||
<a href="{% url 'ipam:ipaddress' pk=object.primary_ip4.pk %}">{{ object.primary_ip4.address.ip }}</a>
|
<a href="{% url 'ipam:ipaddress' pk=object.primary_ip4.pk %}" id="primary_ip4">{{ object.primary_ip4.address.ip }}</a>
|
||||||
{% if object.primary_ip4.nat_inside %}
|
{% if object.primary_ip4.nat_inside %}
|
||||||
(NAT for <a href="{{ object.primary_ip4.nat_inside.get_absolute_url }}">{{ object.primary_ip4.nat_inside.address.ip }}</a>)
|
(NAT for <a href="{{ object.primary_ip4.nat_inside.get_absolute_url }}">{{ object.primary_ip4.nat_inside.address.ip }}</a>)
|
||||||
{% elif object.primary_ip4.nat_outside.exists %}
|
{% elif object.primary_ip4.nat_outside.exists %}
|
||||||
(NAT: {% for nat in object.primary_ip4.nat_outside.all %}<a href="{{ nat.get_absolute_url }}">{{ nat.address.ip }}</a>{% if not forloop.last %}, {% endif %}{% endfor %})
|
(NAT: {% for nat in object.primary_ip4.nat_outside.all %}<a href="{{ nat.get_absolute_url }}">{{ nat.address.ip }}</a>{% if not forloop.last %}, {% endif %}{% endfor %})
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% copy_content "primary_ip4" %}
|
||||||
{% else %}
|
{% else %}
|
||||||
{{ ''|placeholder }}
|
{{ ''|placeholder }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@ -61,12 +62,13 @@
|
|||||||
<th scope="row">Primary IPv6</th>
|
<th scope="row">Primary IPv6</th>
|
||||||
<td>
|
<td>
|
||||||
{% if object.primary_ip6 %}
|
{% if object.primary_ip6 %}
|
||||||
<a href="{% url 'ipam:ipaddress' pk=object.primary_ip6.pk %}">{{ object.primary_ip6.address.ip }}</a>
|
<a href="{% url 'ipam:ipaddress' pk=object.primary_ip6.pk %}" id="primary_ip6">{{ object.primary_ip6.address.ip }}</a>
|
||||||
{% if object.primary_ip6.nat_inside %}
|
{% if object.primary_ip6.nat_inside %}
|
||||||
(NAT for <a href="{{ object.primary_ip6.nat_inside.get_absolute_url }}">{{ object.primary_ip6.nat_inside.address.ip }}</a>)
|
(NAT for <a href="{{ object.primary_ip6.nat_inside.get_absolute_url }}">{{ object.primary_ip6.nat_inside.address.ip }}</a>)
|
||||||
{% elif object.primary_ip6.nat_outside.exists %}
|
{% elif object.primary_ip6.nat_outside.exists %}
|
||||||
(NAT: {% for nat in object.primary_ip6.nat_outside.all %}<a href="{{ nat.get_absolute_url }}">{{ nat.address.ip }}</a>{% if not forloop.last %}, {% endif %}{% endfor %})
|
(NAT: {% for nat in object.primary_ip6.nat_outside.all %}<a href="{{ nat.get_absolute_url }}">{{ nat.address.ip }}</a>{% if not forloop.last %}, {% endif %}{% endfor %})
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% copy_content "primary_ip6" %}
|
||||||
{% else %}
|
{% else %}
|
||||||
{{ ''|placeholder }}
|
{{ ''|placeholder }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
@ -136,3 +136,8 @@ class ContactAssignment(ChangeLoggedModel):
|
|||||||
|
|
||||||
def get_absolute_url(self):
|
def get_absolute_url(self):
|
||||||
return reverse('tenancy:contact', args=[self.contact.pk])
|
return reverse('tenancy:contact', args=[self.contact.pk])
|
||||||
|
|
||||||
|
def to_objectchange(self, action):
|
||||||
|
objectchange = super().to_objectchange(action)
|
||||||
|
objectchange.related_object = self.object
|
||||||
|
return objectchange
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import django_tables2 as tables
|
import django_tables2 as tables
|
||||||
|
from django_tables2.utils import Accessor
|
||||||
|
|
||||||
from netbox.tables import NetBoxTable, columns
|
from netbox.tables import NetBoxTable, columns
|
||||||
from tenancy.models import *
|
from tenancy.models import *
|
||||||
@ -90,11 +91,40 @@ class ContactAssignmentTable(NetBoxTable):
|
|||||||
role = tables.Column(
|
role = tables.Column(
|
||||||
linkify=True
|
linkify=True
|
||||||
)
|
)
|
||||||
|
contact_title = tables.Column(
|
||||||
|
accessor=Accessor('contact__title'),
|
||||||
|
verbose_name='Contact Title'
|
||||||
|
)
|
||||||
|
contact_phone = tables.Column(
|
||||||
|
accessor=Accessor('contact__phone'),
|
||||||
|
verbose_name='Contact Phone'
|
||||||
|
)
|
||||||
|
contact_email = tables.Column(
|
||||||
|
accessor=Accessor('contact__email'),
|
||||||
|
verbose_name='Contact Email'
|
||||||
|
)
|
||||||
|
contact_address = tables.Column(
|
||||||
|
accessor=Accessor('contact__address'),
|
||||||
|
verbose_name='Contact Address'
|
||||||
|
)
|
||||||
|
contact_link = tables.Column(
|
||||||
|
accessor=Accessor('contact__link'),
|
||||||
|
verbose_name='Contact Link'
|
||||||
|
)
|
||||||
|
contact_description = tables.Column(
|
||||||
|
accessor=Accessor('contact__description'),
|
||||||
|
verbose_name='Contact Description'
|
||||||
|
)
|
||||||
actions = columns.ActionsColumn(
|
actions = columns.ActionsColumn(
|
||||||
actions=('edit', 'delete')
|
actions=('edit', 'delete')
|
||||||
)
|
)
|
||||||
|
|
||||||
class Meta(NetBoxTable.Meta):
|
class Meta(NetBoxTable.Meta):
|
||||||
model = ContactAssignment
|
model = ContactAssignment
|
||||||
fields = ('pk', 'content_type', 'object', 'contact', 'role', 'priority', 'actions')
|
fields = (
|
||||||
default_columns = ('pk', 'content_type', 'object', 'contact', 'role', 'priority')
|
'pk', 'content_type', 'object', 'contact', 'role', 'priority', 'contact_title', 'contact_phone',
|
||||||
|
'contact_email', 'contact_address', 'contact_link', 'contact_description', 'actions'
|
||||||
|
)
|
||||||
|
default_columns = (
|
||||||
|
'pk', 'content_type', 'object', 'contact', 'role', 'priority', 'contact_email', 'contact_phone'
|
||||||
|
)
|
||||||
|
@ -12,9 +12,7 @@ ALLOWED_IPS = """{{ value|join:", " }}"""
|
|||||||
|
|
||||||
COPY_BUTTON = """
|
COPY_BUTTON = """
|
||||||
{% if settings.ALLOW_TOKEN_RETRIEVAL %}
|
{% if settings.ALLOW_TOKEN_RETRIEVAL %}
|
||||||
<a class="btn btn-sm btn-success copy-token" data-clipboard-target="#token_{{ record.pk }}" title="Copy to clipboard">
|
{% copy_content record.pk prefix="token_" color="success" %}
|
||||||
<i class="mdi mdi-content-copy"></i>
|
|
||||||
</a>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
@ -160,7 +160,9 @@ class ProfileView(LoginRequiredMixin, View):
|
|||||||
def get(self, request):
|
def get(self, request):
|
||||||
|
|
||||||
# Compile changelog table
|
# Compile changelog table
|
||||||
changelog = ObjectChange.objects.restrict(request.user, 'view').filter(user=request.user).prefetch_related(
|
changelog = ObjectChange.objects.valid_models().restrict(request.user, 'view').filter(
|
||||||
|
user=request.user
|
||||||
|
).prefetch_related(
|
||||||
'changed_object_type'
|
'changed_object_type'
|
||||||
)[:20]
|
)[:20]
|
||||||
changelog_table = ObjectChangeTable(changelog)
|
changelog_table = ObjectChangeTable(changelog)
|
||||||
|
3
netbox/utilities/templates/builtins/copy_content.html
Normal file
3
netbox/utilities/templates/builtins/copy_content.html
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
<a class="btn btn-sm {{ color }} copy-content" data-clipboard-target="{{ target }}" title="Copy to clipboard">
|
||||||
|
<i class="mdi mdi-content-copy"></i>
|
||||||
|
</a>
|
@ -1,9 +1,12 @@
|
|||||||
from django import template
|
from django import template
|
||||||
from django.http import QueryDict
|
from django.http import QueryDict
|
||||||
|
|
||||||
|
from utilities.utils import dict_to_querydict
|
||||||
|
|
||||||
__all__ = (
|
__all__ = (
|
||||||
'badge',
|
'badge',
|
||||||
'checkmark',
|
'checkmark',
|
||||||
|
'copy_content',
|
||||||
'customfield_value',
|
'customfield_value',
|
||||||
'tag',
|
'tag',
|
||||||
)
|
)
|
||||||
@ -77,6 +80,17 @@ def checkmark(value, show_false=True, true='Yes', false='No'):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@register.inclusion_tag('builtins/copy_content.html')
|
||||||
|
def copy_content(target, prefix=None, color='primary'):
|
||||||
|
"""
|
||||||
|
Display a copy button to copy the content of a field.
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
'target': f'#{prefix or ""}{target}',
|
||||||
|
'color': f'btn-{color}'
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@register.inclusion_tag('builtins/htmx_table.html', takes_context=True)
|
@register.inclusion_tag('builtins/htmx_table.html', takes_context=True)
|
||||||
def htmx_table(context, viewname, return_url=None, **kwargs):
|
def htmx_table(context, viewname, return_url=None, **kwargs):
|
||||||
"""
|
"""
|
||||||
@ -87,8 +101,7 @@ def htmx_table(context, viewname, return_url=None, **kwargs):
|
|||||||
viewname: The name of the view to use for the HTMX request (e.g. `dcim:site_list`)
|
viewname: The name of the view to use for the HTMX request (e.g. `dcim:site_list`)
|
||||||
return_url: The URL to pass as the `return_url`. If not provided, the current request's path will be used.
|
return_url: The URL to pass as the `return_url`. If not provided, the current request's path will be used.
|
||||||
"""
|
"""
|
||||||
url_params = QueryDict(mutable=True)
|
url_params = dict_to_querydict(kwargs)
|
||||||
url_params.update(kwargs)
|
|
||||||
url_params['return_url'] = return_url or context['request'].path
|
url_params['return_url'] = return_url or context['request'].path
|
||||||
return {
|
return {
|
||||||
'viewname': viewname,
|
'viewname': viewname,
|
||||||
|
@ -11,8 +11,9 @@ from django.core import serializers
|
|||||||
from django.db.models import Count, OuterRef, Subquery
|
from django.db.models import Count, OuterRef, Subquery
|
||||||
from django.db.models.functions import Coalesce
|
from django.db.models.functions import Coalesce
|
||||||
from django.http import QueryDict
|
from django.http import QueryDict
|
||||||
from django.utils.html import escape
|
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
from django.utils.datastructures import MultiValueDict
|
||||||
|
from django.utils.html import escape
|
||||||
from django.utils.timezone import localtime
|
from django.utils.timezone import localtime
|
||||||
from jinja2.sandbox import SandboxedEnvironment
|
from jinja2.sandbox import SandboxedEnvironment
|
||||||
from mptt.models import MPTTModel
|
from mptt.models import MPTTModel
|
||||||
@ -231,6 +232,19 @@ def dict_to_filter_params(d, prefix=''):
|
|||||||
return params
|
return params
|
||||||
|
|
||||||
|
|
||||||
|
def dict_to_querydict(d, mutable=True):
|
||||||
|
"""
|
||||||
|
Create a QueryDict instance from a regular Python dictionary.
|
||||||
|
"""
|
||||||
|
qd = QueryDict(mutable=True)
|
||||||
|
for k, v in d.items():
|
||||||
|
item = MultiValueDict({k: v}) if isinstance(v, (list, tuple, set)) else {k: v}
|
||||||
|
qd.update(item)
|
||||||
|
if not mutable:
|
||||||
|
qd._mutable = False
|
||||||
|
return qd
|
||||||
|
|
||||||
|
|
||||||
def normalize_querydict(querydict):
|
def normalize_querydict(querydict):
|
||||||
"""
|
"""
|
||||||
Convert a QueryDict to a normal, mutable dictionary, preserving list values. For example,
|
Convert a QueryDict to a normal, mutable dictionary, preserving list values. For example,
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
bleach==6.0.0
|
bleach==6.0.0
|
||||||
boto3==1.26.156
|
boto3==1.28.1
|
||||||
Django==4.2.2
|
Django==4.2.2
|
||||||
django-cors-headers==4.1.0
|
django-cors-headers==4.2.0
|
||||||
django-debug-toolbar==4.1.0
|
django-debug-toolbar==4.1.0
|
||||||
django-filter==23.2
|
django-filter==23.2
|
||||||
django-graphiql-debug-toolbar==0.2.0
|
django-graphiql-debug-toolbar==0.2.0
|
||||||
@ -9,27 +9,27 @@ django-mptt==0.14
|
|||||||
django-pglocks==1.0.4
|
django-pglocks==1.0.4
|
||||||
django-prometheus==2.3.1
|
django-prometheus==2.3.1
|
||||||
django-redis==5.3.0
|
django-redis==5.3.0
|
||||||
django-rich==1.6.0
|
django-rich==1.7.0
|
||||||
django-rq==2.8.1
|
django-rq==2.8.1
|
||||||
django-tables2==2.5.3
|
django-tables2==2.6.0
|
||||||
django-taggit==4.0.0
|
django-taggit==4.0.0
|
||||||
django-timezone-field==5.1
|
django-timezone-field==5.1
|
||||||
djangorestframework==3.14.0
|
djangorestframework==3.14.0
|
||||||
drf-spectacular==0.26.2
|
drf-spectacular==0.26.3
|
||||||
drf-spectacular-sidecar==2023.6.1
|
drf-spectacular-sidecar==2023.7.1
|
||||||
dulwich==0.21.5
|
dulwich==0.21.5
|
||||||
feedparser==6.0.10
|
feedparser==6.0.10
|
||||||
graphene-django==3.0.0
|
graphene-django==3.0.0
|
||||||
gunicorn==20.1.0
|
gunicorn==20.1.0
|
||||||
Jinja2==3.1.2
|
Jinja2==3.1.2
|
||||||
Markdown==3.3.7
|
Markdown==3.3.7
|
||||||
mkdocs-material==9.1.16
|
mkdocs-material==9.1.18
|
||||||
mkdocstrings[python-legacy]==0.22.0
|
mkdocstrings[python-legacy]==0.22.0
|
||||||
netaddr==0.8.0
|
netaddr==0.8.0
|
||||||
Pillow==9.5.0
|
Pillow==10.0.0
|
||||||
psycopg[binary,pool]==3.1.9
|
psycopg[binary,pool]==3.1.9
|
||||||
PyYAML==6.0
|
PyYAML==6.0
|
||||||
sentry-sdk==1.25.1
|
sentry-sdk==1.28.0
|
||||||
social-auth-app-django==5.2.0
|
social-auth-app-django==5.2.0
|
||||||
social-auth-core[openidconnect]==4.4.2
|
social-auth-core[openidconnect]==4.4.2
|
||||||
svgwrite==1.4.3
|
svgwrite==1.4.3
|
||||||
|
Loading…
Reference in New Issue
Block a user