mirror of
https://github.com/netbox-community/netbox.git
synced 2025-08-25 08:46:10 -06:00
12589 merge and add missing files
This commit is contained in:
commit
b851f33e81
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>
|
||||||
|
|
||||||
|
@ -18,6 +18,10 @@ The `tag` filter can be specified multiple times to match only objects which hav
|
|||||||
GET /api/dcim/devices/?tag=monitored&tag=deprecated
|
GET /api/dcim/devices/?tag=monitored&tag=deprecated
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Bookmarks
|
||||||
|
|
||||||
|
Users can bookmark their most commonly visited objects for convenient access. Bookmarks are listed under a user's profile, and can be displayed with custom filtering and ordering on the user's personal dashboard.
|
||||||
|
|
||||||
## Custom Fields
|
## Custom Fields
|
||||||
|
|
||||||
While NetBox provides a rather extensive data model out of the box, the need may arise to store certain additional data associated with NetBox objects. For example, you might need to record the invoice ID alongside an installed device, or record an approving authority when creating a new IP prefix. NetBox administrators can create custom fields on built-in objects to meet these needs.
|
While NetBox provides a rather extensive data model out of the box, the need may arise to store certain additional data associated with NetBox objects. For example, you might need to record the invoice ID alongside an installed device, or record an approving authority when creating a new IP prefix. NetBox administrators can create custom fields on built-in objects to meet these needs.
|
||||||
|
13
docs/models/extras/bookmark.md
Normal file
13
docs/models/extras/bookmark.md
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
# Bookmarks
|
||||||
|
|
||||||
|
A user can bookmark individual objects for convenient access. Bookmarks are listed under a user's profile and can be displayed using a dashboard widget.
|
||||||
|
|
||||||
|
## Fields
|
||||||
|
|
||||||
|
### User
|
||||||
|
|
||||||
|
The user to whom the bookmark belongs.
|
||||||
|
|
||||||
|
### Object
|
||||||
|
|
||||||
|
The bookmarked object.
|
@ -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
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
@ -206,6 +206,7 @@ nav:
|
|||||||
- VirtualChassis: 'models/dcim/virtualchassis.md'
|
- VirtualChassis: 'models/dcim/virtualchassis.md'
|
||||||
- VirtualDeviceContext: 'models/dcim/virtualdevicecontext.md'
|
- VirtualDeviceContext: 'models/dcim/virtualdevicecontext.md'
|
||||||
- Extras:
|
- Extras:
|
||||||
|
- Bookmark: 'models/extras/bookmark.md'
|
||||||
- Branch: 'models/extras/branch.md'
|
- Branch: 'models/extras/branch.md'
|
||||||
- ConfigContext: 'models/extras/configcontext.md'
|
- ConfigContext: 'models/extras/configcontext.md'
|
||||||
- ConfigTemplate: 'models/extras/configtemplate.md'
|
- ConfigTemplate: 'models/extras/configtemplate.md'
|
||||||
|
@ -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):
|
||||||
|
@ -4,6 +4,7 @@ from extras import models
|
|||||||
from netbox.api.serializers import NestedTagSerializer, WritableNestedSerializer
|
from netbox.api.serializers import NestedTagSerializer, WritableNestedSerializer
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
|
'NestedBookmarkSerializer',
|
||||||
'NestedConfigContextSerializer',
|
'NestedConfigContextSerializer',
|
||||||
'NestedConfigTemplateSerializer',
|
'NestedConfigTemplateSerializer',
|
||||||
'NestedCustomFieldSerializer',
|
'NestedCustomFieldSerializer',
|
||||||
@ -73,6 +74,14 @@ class NestedSavedFilterSerializer(WritableNestedSerializer):
|
|||||||
fields = ['id', 'url', 'display', 'name', 'slug']
|
fields = ['id', 'url', 'display', 'name', 'slug']
|
||||||
|
|
||||||
|
|
||||||
|
class NestedBookmarkSerializer(WritableNestedSerializer):
|
||||||
|
url = serializers.HyperlinkedIdentityField(view_name='extras-api:bookmark-detail')
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = models.Bookmark
|
||||||
|
fields = ['id', 'url', 'display', 'object_id', 'object_type']
|
||||||
|
|
||||||
|
|
||||||
class NestedImageAttachmentSerializer(WritableNestedSerializer):
|
class NestedImageAttachmentSerializer(WritableNestedSerializer):
|
||||||
url = serializers.HyperlinkedIdentityField(view_name='extras-api:imageattachment-detail')
|
url = serializers.HyperlinkedIdentityField(view_name='extras-api:imageattachment-detail')
|
||||||
|
|
||||||
|
@ -31,6 +31,7 @@ from virtualization.models import Cluster, ClusterGroup, ClusterType
|
|||||||
from .nested_serializers import *
|
from .nested_serializers import *
|
||||||
|
|
||||||
__all__ = (
|
__all__ = (
|
||||||
|
'BookmarkSerializer',
|
||||||
'ConfigContextSerializer',
|
'ConfigContextSerializer',
|
||||||
'ConfigTemplateSerializer',
|
'ConfigTemplateSerializer',
|
||||||
'ContentTypeSerializer',
|
'ContentTypeSerializer',
|
||||||
@ -190,6 +191,30 @@ class SavedFilterSerializer(ValidatedModelSerializer):
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
#
|
||||||
|
# Bookmarks
|
||||||
|
#
|
||||||
|
|
||||||
|
class BookmarkSerializer(ValidatedModelSerializer):
|
||||||
|
url = serializers.HyperlinkedIdentityField(view_name='extras-api:bookmark-detail')
|
||||||
|
object_type = ContentTypeField(
|
||||||
|
queryset=ContentType.objects.filter(FeatureQuery('bookmarks').get_query()),
|
||||||
|
)
|
||||||
|
object = serializers.SerializerMethodField(read_only=True)
|
||||||
|
user = NestedUserSerializer()
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = Bookmark
|
||||||
|
fields = [
|
||||||
|
'id', 'url', 'display', 'object_type', 'object_id', 'object', 'user', 'created',
|
||||||
|
]
|
||||||
|
|
||||||
|
@extend_schema_field(serializers.JSONField(allow_null=True))
|
||||||
|
def get_object(self, instance):
|
||||||
|
serializer = get_serializer_for_model(instance.object, prefix=NESTED_SERIALIZER_PREFIX)
|
||||||
|
return serializer(instance.object, context={'request': self.context['request']}).data
|
||||||
|
|
||||||
|
|
||||||
#
|
#
|
||||||
# Tags
|
# Tags
|
||||||
#
|
#
|
||||||
|
@ -12,6 +12,7 @@ router.register('custom-fields', views.CustomFieldViewSet)
|
|||||||
router.register('custom-links', views.CustomLinkViewSet)
|
router.register('custom-links', views.CustomLinkViewSet)
|
||||||
router.register('export-templates', views.ExportTemplateViewSet)
|
router.register('export-templates', views.ExportTemplateViewSet)
|
||||||
router.register('saved-filters', views.SavedFilterViewSet)
|
router.register('saved-filters', views.SavedFilterViewSet)
|
||||||
|
router.register('bookmarks', views.BookmarkViewSet)
|
||||||
router.register('tags', views.TagViewSet)
|
router.register('tags', views.TagViewSet)
|
||||||
router.register('image-attachments', views.ImageAttachmentViewSet)
|
router.register('image-attachments', views.ImageAttachmentViewSet)
|
||||||
router.register('journal-entries', views.JournalEntryViewSet)
|
router.register('journal-entries', views.JournalEntryViewSet)
|
||||||
|
@ -93,6 +93,17 @@ class SavedFilterViewSet(NetBoxModelViewSet):
|
|||||||
filterset_class = filtersets.SavedFilterFilterSet
|
filterset_class = filtersets.SavedFilterFilterSet
|
||||||
|
|
||||||
|
|
||||||
|
#
|
||||||
|
# Bookmarks
|
||||||
|
#
|
||||||
|
|
||||||
|
class BookmarkViewSet(NetBoxModelViewSet):
|
||||||
|
metadata_class = ContentTypeMetadata
|
||||||
|
queryset = Bookmark.objects.all()
|
||||||
|
serializer_class = serializers.BookmarkSerializer
|
||||||
|
filterset_class = filtersets.BookmarkFilterSet
|
||||||
|
|
||||||
|
|
||||||
#
|
#
|
||||||
# Tags
|
# Tags
|
||||||
#
|
#
|
||||||
@ -368,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
|
||||||
|
|
||||||
|
@ -79,6 +79,21 @@ class CustomLinkButtonClassChoices(ButtonColorChoices):
|
|||||||
(LINK, 'Link'),
|
(LINK, 'Link'),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
#
|
||||||
|
# Bookmarks
|
||||||
|
#
|
||||||
|
|
||||||
|
class BookmarkOrderingChoices(ChoiceSet):
|
||||||
|
|
||||||
|
ORDERING_NEWEST = '-created'
|
||||||
|
ORDERING_OLDEST = 'created'
|
||||||
|
|
||||||
|
CHOICES = (
|
||||||
|
(ORDERING_NEWEST, 'Newest'),
|
||||||
|
(ORDERING_OLDEST, 'Oldest'),
|
||||||
|
)
|
||||||
|
|
||||||
#
|
#
|
||||||
# ObjectChanges
|
# ObjectChanges
|
||||||
#
|
#
|
||||||
@ -98,7 +113,7 @@ class ObjectChangeActionChoices(ChoiceSet):
|
|||||||
|
|
||||||
|
|
||||||
#
|
#
|
||||||
# Jounral entries
|
# Journal entries
|
||||||
#
|
#
|
||||||
|
|
||||||
class JournalEntryKindChoices(ChoiceSet):
|
class JournalEntryKindChoices(ChoiceSet):
|
||||||
|
@ -10,19 +10,20 @@ 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 _
|
||||||
|
|
||||||
|
from extras.choices import BookmarkOrderingChoices
|
||||||
from extras.utils import FeatureQuery
|
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__ = (
|
||||||
|
'BookmarksWidget',
|
||||||
'DashboardWidget',
|
'DashboardWidget',
|
||||||
'NoteWidget',
|
'NoteWidget',
|
||||||
'ObjectCountsWidget',
|
'ObjectCountsWidget',
|
||||||
@ -170,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()}'
|
||||||
@ -318,3 +318,42 @@ class RSSFeedWidget(DashboardWidget):
|
|||||||
return {
|
return {
|
||||||
'feed': feed,
|
'feed': feed,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@register_widget
|
||||||
|
class BookmarksWidget(DashboardWidget):
|
||||||
|
default_title = _('Bookmarks')
|
||||||
|
default_config = {
|
||||||
|
'order_by': BookmarkOrderingChoices.ORDERING_NEWEST,
|
||||||
|
}
|
||||||
|
description = _('Show your personal bookmarks')
|
||||||
|
template_name = 'extras/dashboard/widgets/bookmarks.html'
|
||||||
|
|
||||||
|
class ConfigForm(WidgetConfigForm):
|
||||||
|
object_types = forms.MultipleChoiceField(
|
||||||
|
# TODO: Restrict the choices by FeatureQuery('bookmarks')
|
||||||
|
choices=get_content_type_labels,
|
||||||
|
required=False
|
||||||
|
)
|
||||||
|
order_by = forms.ChoiceField(
|
||||||
|
choices=BookmarkOrderingChoices
|
||||||
|
)
|
||||||
|
max_items = forms.IntegerField(
|
||||||
|
min_value=1,
|
||||||
|
required=False
|
||||||
|
)
|
||||||
|
|
||||||
|
def render(self, request):
|
||||||
|
from extras.models import Bookmark
|
||||||
|
|
||||||
|
bookmarks = Bookmark.objects.filter(user=request.user).order_by(self.config['order_by'])
|
||||||
|
if object_types := self.config.get('object_types'):
|
||||||
|
models = get_models_from_content_types(object_types)
|
||||||
|
conent_types = ContentType.objects.get_for_models(*models).values()
|
||||||
|
bookmarks = bookmarks.filter(object_type__in=conent_types)
|
||||||
|
if max_items := self.config.get('max_items'):
|
||||||
|
bookmarks = bookmarks[:max_items]
|
||||||
|
|
||||||
|
return render_to_string(self.template_name, {
|
||||||
|
'bookmarks': bookmarks,
|
||||||
|
})
|
||||||
|
@ -15,6 +15,7 @@ from .filters import TagFilter
|
|||||||
from .models import *
|
from .models import *
|
||||||
|
|
||||||
__all__ = (
|
__all__ = (
|
||||||
|
'BookmarkFilterSet',
|
||||||
'ConfigContextFilterSet',
|
'ConfigContextFilterSet',
|
||||||
'ConfigRevisionFilterSet',
|
'ConfigRevisionFilterSet',
|
||||||
'ConfigTemplateFilterSet',
|
'ConfigTemplateFilterSet',
|
||||||
@ -199,6 +200,26 @@ class SavedFilterFilterSet(BaseFilterSet):
|
|||||||
return queryset.filter(Q(enabled=False) | Q(Q(shared=False) & ~Q(user=user)))
|
return queryset.filter(Q(enabled=False) | Q(Q(shared=False) & ~Q(user=user)))
|
||||||
|
|
||||||
|
|
||||||
|
class BookmarkFilterSet(BaseFilterSet):
|
||||||
|
created = django_filters.DateTimeFilter()
|
||||||
|
object_type_id = MultiValueNumberFilter()
|
||||||
|
object_type = ContentTypeFilter()
|
||||||
|
user_id = django_filters.ModelMultipleChoiceFilter(
|
||||||
|
queryset=get_user_model().objects.all(),
|
||||||
|
label=_('User (ID)'),
|
||||||
|
)
|
||||||
|
user = django_filters.ModelMultipleChoiceFilter(
|
||||||
|
field_name='user__username',
|
||||||
|
queryset=get_user_model().objects.all(),
|
||||||
|
to_field_name='username',
|
||||||
|
label=_('User (name)'),
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = Bookmark
|
||||||
|
fields = ['id', 'object_id']
|
||||||
|
|
||||||
|
|
||||||
class ImageAttachmentFilterSet(BaseFilterSet):
|
class ImageAttachmentFilterSet(BaseFilterSet):
|
||||||
q = django_filters.CharFilter(
|
q = django_filters.CharFilter(
|
||||||
method='search',
|
method='search',
|
||||||
|
@ -14,7 +14,7 @@ from extras.utils import FeatureQuery
|
|||||||
from netbox.config import get_config, PARAMS
|
from netbox.config import get_config, PARAMS
|
||||||
from netbox.forms import NetBoxModelForm
|
from netbox.forms import NetBoxModelForm
|
||||||
from tenancy.models import Tenant, TenantGroup
|
from tenancy.models import Tenant, TenantGroup
|
||||||
from utilities.forms import BOOLEAN_WITH_BLANK_CHOICES, BootstrapMixin, add_blank_choice
|
from utilities.forms import BootstrapMixin, add_blank_choice
|
||||||
from utilities.forms.fields import (
|
from utilities.forms.fields import (
|
||||||
CommentField, ContentTypeChoiceField, ContentTypeMultipleChoiceField, DynamicModelMultipleChoiceField, JSONField,
|
CommentField, ContentTypeChoiceField, ContentTypeMultipleChoiceField, DynamicModelMultipleChoiceField, JSONField,
|
||||||
SlugField,
|
SlugField,
|
||||||
@ -23,6 +23,7 @@ from virtualization.models import Cluster, ClusterGroup, ClusterType
|
|||||||
|
|
||||||
|
|
||||||
__all__ = (
|
__all__ = (
|
||||||
|
'BookmarkForm',
|
||||||
'ConfigContextForm',
|
'ConfigContextForm',
|
||||||
'ConfigRevisionForm',
|
'ConfigRevisionForm',
|
||||||
'ConfigTemplateForm',
|
'ConfigTemplateForm',
|
||||||
@ -169,6 +170,17 @@ class SavedFilterForm(BootstrapMixin, forms.ModelForm):
|
|||||||
super().__init__(*args, initial=initial, **kwargs)
|
super().__init__(*args, initial=initial, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
class BookmarkForm(BootstrapMixin, forms.ModelForm):
|
||||||
|
object_type = ContentTypeChoiceField(
|
||||||
|
queryset=ContentType.objects.all(),
|
||||||
|
limit_choices_to=FeatureQuery('bookmarks').get_query()
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = Bookmark
|
||||||
|
fields = ('object_type', 'object_id')
|
||||||
|
|
||||||
|
|
||||||
class WebhookForm(BootstrapMixin, forms.ModelForm):
|
class WebhookForm(BootstrapMixin, forms.ModelForm):
|
||||||
content_types = ContentTypeMultipleChoiceField(
|
content_types = ContentTypeMultipleChoiceField(
|
||||||
queryset=ContentType.objects.all(),
|
queryset=ContentType.objects.all(),
|
||||||
|
@ -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)
|
|
||||||
|
34
netbox/extras/migrations/0095_bookmarks.py
Normal file
34
netbox/extras/migrations/0095_bookmarks.py
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
# Generated by Django 4.1.9 on 2023-06-29 14:07
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
('contenttypes', '0002_remove_content_type_name'),
|
||||||
|
('extras', '0094_tag_object_types'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Bookmark',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
|
||||||
|
('created', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('object_id', models.PositiveBigIntegerField()),
|
||||||
|
('object_type', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='contenttypes.contenttype')),
|
||||||
|
('user', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'ordering': ('created', 'pk'),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.AddConstraint(
|
||||||
|
model_name='bookmark',
|
||||||
|
constraint=models.UniqueConstraint(fields=('object_type', 'object_id', 'user'), name='extras_bookmark_unique_per_object_and_user'),
|
||||||
|
),
|
||||||
|
]
|
@ -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']
|
||||||
|
@ -1,7 +1,6 @@
|
|||||||
import json
|
import json
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
|
|
||||||
from django.conf import settings
|
|
||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib.contenttypes.fields import GenericForeignKey
|
from django.contrib.contenttypes.fields import GenericForeignKey
|
||||||
@ -9,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
|
||||||
@ -26,9 +25,10 @@ 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',
|
||||||
'ConfigRevision',
|
'ConfigRevision',
|
||||||
'CustomLink',
|
'CustomLink',
|
||||||
'ExportTemplate',
|
'ExportTemplate',
|
||||||
@ -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()
|
||||||
|
|
||||||
|
|
||||||
@ -595,6 +594,44 @@ class JournalEntry(CustomFieldsMixin, CustomLinksMixin, TagsMixin, ExportTemplat
|
|||||||
return JournalEntryKindChoices.colors.get(self.kind)
|
return JournalEntryKindChoices.colors.get(self.kind)
|
||||||
|
|
||||||
|
|
||||||
|
class Bookmark(models.Model):
|
||||||
|
"""
|
||||||
|
An object bookmarked by a User.
|
||||||
|
"""
|
||||||
|
created = models.DateTimeField(
|
||||||
|
auto_now_add=True
|
||||||
|
)
|
||||||
|
object_type = models.ForeignKey(
|
||||||
|
to=ContentType,
|
||||||
|
on_delete=models.PROTECT
|
||||||
|
)
|
||||||
|
object_id = models.PositiveBigIntegerField()
|
||||||
|
object = GenericForeignKey(
|
||||||
|
ct_field='object_type',
|
||||||
|
fk_field='object_id'
|
||||||
|
)
|
||||||
|
user = models.ForeignKey(
|
||||||
|
to=settings.AUTH_USER_MODEL,
|
||||||
|
on_delete=models.PROTECT
|
||||||
|
)
|
||||||
|
|
||||||
|
objects = RestrictedQuerySet.as_manager()
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ('created', 'pk')
|
||||||
|
constraints = (
|
||||||
|
models.UniqueConstraint(
|
||||||
|
fields=('object_type', 'object_id', 'user'),
|
||||||
|
name='%(app_label)s_%(class)s_unique_per_object_and_user'
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
if self.object:
|
||||||
|
return str(self.object)
|
||||||
|
return super().__str__()
|
||||||
|
|
||||||
|
|
||||||
class ConfigRevision(models.Model):
|
class ConfigRevision(models.Model):
|
||||||
"""
|
"""
|
||||||
An atomic revision of NetBox's configuration.
|
An atomic revision of NetBox's configuration.
|
||||||
|
@ -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,6 +8,7 @@ from netbox.tables import NetBoxTable, columns
|
|||||||
from .template_code import *
|
from .template_code import *
|
||||||
|
|
||||||
__all__ = (
|
__all__ = (
|
||||||
|
'BookmarkTable',
|
||||||
'ConfigContextTable',
|
'ConfigContextTable',
|
||||||
'ConfigRevisionTable',
|
'ConfigRevisionTable',
|
||||||
'ConfigTemplateTable',
|
'ConfigTemplateTable',
|
||||||
@ -167,6 +168,21 @@ class SavedFilterTable(NetBoxTable):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class BookmarkTable(NetBoxTable):
|
||||||
|
object_type = columns.ContentTypeColumn()
|
||||||
|
object = tables.Column(
|
||||||
|
linkify=True
|
||||||
|
)
|
||||||
|
actions = columns.ActionsColumn(
|
||||||
|
actions=('delete',)
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta(NetBoxTable.Meta):
|
||||||
|
model = Bookmark
|
||||||
|
fields = ('pk', 'object', 'object_type', 'created')
|
||||||
|
default_columns = ('object', 'object_type', 'created')
|
||||||
|
|
||||||
|
|
||||||
class WebhookTable(NetBoxTable):
|
class WebhookTable(NetBoxTable):
|
||||||
name = tables.Column(
|
name = tables.Column(
|
||||||
linkify=True
|
linkify=True
|
||||||
|
@ -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
|
||||||
@ -268,6 +267,58 @@ class SavedFilterTest(APIViewTestCases.APIViewTestCase):
|
|||||||
savedfilter.content_types.set([site_ct])
|
savedfilter.content_types.set([site_ct])
|
||||||
|
|
||||||
|
|
||||||
|
class BookmarkTest(
|
||||||
|
APIViewTestCases.GetObjectViewTestCase,
|
||||||
|
APIViewTestCases.ListObjectsViewTestCase,
|
||||||
|
APIViewTestCases.CreateObjectViewTestCase,
|
||||||
|
APIViewTestCases.DeleteObjectViewTestCase
|
||||||
|
):
|
||||||
|
model = Bookmark
|
||||||
|
brief_fields = ['display', 'id', 'object_id', 'object_type', 'url']
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def setUpTestData(cls):
|
||||||
|
sites = (
|
||||||
|
Site(name='Site 1', slug='site-1'),
|
||||||
|
Site(name='Site 2', slug='site-2'),
|
||||||
|
Site(name='Site 3', slug='site-3'),
|
||||||
|
Site(name='Site 4', slug='site-4'),
|
||||||
|
Site(name='Site 5', slug='site-5'),
|
||||||
|
Site(name='Site 6', slug='site-6'),
|
||||||
|
)
|
||||||
|
Site.objects.bulk_create(sites)
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
|
||||||
|
sites = Site.objects.all()
|
||||||
|
|
||||||
|
bookmarks = (
|
||||||
|
Bookmark(object=sites[0], user=self.user),
|
||||||
|
Bookmark(object=sites[1], user=self.user),
|
||||||
|
Bookmark(object=sites[2], user=self.user),
|
||||||
|
)
|
||||||
|
Bookmark.objects.bulk_create(bookmarks)
|
||||||
|
|
||||||
|
self.create_data = [
|
||||||
|
{
|
||||||
|
'object_type': 'dcim.site',
|
||||||
|
'object_id': sites[3].pk,
|
||||||
|
'user': self.user.pk,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'object_type': 'dcim.site',
|
||||||
|
'object_id': sites[4].pk,
|
||||||
|
'user': self.user.pk,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'object_type': 'dcim.site',
|
||||||
|
'object_id': sites[5].pk,
|
||||||
|
'user': self.user.pk,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
class ExportTemplateTest(APIViewTestCases.APIViewTestCase):
|
class ExportTemplateTest(APIViewTestCases.APIViewTestCase):
|
||||||
model = ExportTemplate
|
model = ExportTemplate
|
||||||
brief_fields = ['display', 'id', 'name', 'url']
|
brief_fields = ['display', 'id', 'name', 'url']
|
||||||
@ -582,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):
|
||||||
@ -624,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):
|
||||||
|
@ -365,6 +365,77 @@ class SavedFilterTestCase(TestCase, BaseFilterSetTests):
|
|||||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
|
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
|
||||||
|
|
||||||
|
|
||||||
|
class BookmarkTestCase(TestCase, BaseFilterSetTests):
|
||||||
|
queryset = Bookmark.objects.all()
|
||||||
|
filterset = BookmarkFilterSet
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def setUpTestData(cls):
|
||||||
|
content_types = ContentType.objects.filter(model__in=['site', 'rack', 'device'])
|
||||||
|
|
||||||
|
users = (
|
||||||
|
User(username='User 1'),
|
||||||
|
User(username='User 2'),
|
||||||
|
User(username='User 3'),
|
||||||
|
)
|
||||||
|
User.objects.bulk_create(users)
|
||||||
|
|
||||||
|
sites = (
|
||||||
|
Site(name='Site 1', slug='site-1'),
|
||||||
|
Site(name='Site 2', slug='site-2'),
|
||||||
|
Site(name='Site 3', slug='site-3'),
|
||||||
|
)
|
||||||
|
Site.objects.bulk_create(sites)
|
||||||
|
|
||||||
|
tenants = (
|
||||||
|
Tenant(name='Tenant 1', slug='tenant-1'),
|
||||||
|
Tenant(name='Tenant 2', slug='tenant-2'),
|
||||||
|
Tenant(name='Tenant 3', slug='tenant-3'),
|
||||||
|
)
|
||||||
|
Tenant.objects.bulk_create(tenants)
|
||||||
|
|
||||||
|
bookmarks = (
|
||||||
|
Bookmark(
|
||||||
|
object=sites[0],
|
||||||
|
user=users[0],
|
||||||
|
),
|
||||||
|
Bookmark(
|
||||||
|
object=sites[1],
|
||||||
|
user=users[1],
|
||||||
|
),
|
||||||
|
Bookmark(
|
||||||
|
object=sites[2],
|
||||||
|
user=users[2],
|
||||||
|
),
|
||||||
|
Bookmark(
|
||||||
|
object=tenants[0],
|
||||||
|
user=users[0],
|
||||||
|
),
|
||||||
|
Bookmark(
|
||||||
|
object=tenants[1],
|
||||||
|
user=users[1],
|
||||||
|
),
|
||||||
|
Bookmark(
|
||||||
|
object=tenants[2],
|
||||||
|
user=users[2],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
Bookmark.objects.bulk_create(bookmarks)
|
||||||
|
|
||||||
|
def test_object_type(self):
|
||||||
|
params = {'object_type': 'dcim.site'}
|
||||||
|
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
|
||||||
|
params = {'object_type_id': [ContentType.objects.get_for_model(Site).pk]}
|
||||||
|
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
|
||||||
|
|
||||||
|
def test_user(self):
|
||||||
|
users = User.objects.filter(username__startswith='User')
|
||||||
|
params = {'user': [users[0].username, users[1].username]}
|
||||||
|
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
|
||||||
|
params = {'user_id': [users[0].pk, users[1].pk]}
|
||||||
|
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
|
||||||
|
|
||||||
|
|
||||||
class ExportTemplateTestCase(TestCase, BaseFilterSetTests):
|
class ExportTemplateTestCase(TestCase, BaseFilterSetTests):
|
||||||
queryset = ExportTemplate.objects.all()
|
queryset = ExportTemplate.objects.all()
|
||||||
filterset = ExportTemplateFilterSet
|
filterset = ExportTemplateFilterSet
|
||||||
|
@ -181,6 +181,54 @@ class SavedFilterTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class BookmarkTestCase(
|
||||||
|
ViewTestCases.DeleteObjectViewTestCase,
|
||||||
|
ViewTestCases.ListObjectsViewTestCase,
|
||||||
|
ViewTestCases.BulkDeleteObjectsViewTestCase
|
||||||
|
):
|
||||||
|
model = Bookmark
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def setUpTestData(cls):
|
||||||
|
site_ct = ContentType.objects.get_for_model(Site)
|
||||||
|
sites = (
|
||||||
|
Site(name='Site 1', slug='site-1'),
|
||||||
|
Site(name='Site 2', slug='site-2'),
|
||||||
|
Site(name='Site 3', slug='site-3'),
|
||||||
|
Site(name='Site 4', slug='site-4'),
|
||||||
|
)
|
||||||
|
Site.objects.bulk_create(sites)
|
||||||
|
|
||||||
|
cls.form_data = {
|
||||||
|
'object_type': site_ct.pk,
|
||||||
|
'object_id': sites[3].pk,
|
||||||
|
}
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
|
||||||
|
sites = Site.objects.all()
|
||||||
|
user = self.user
|
||||||
|
|
||||||
|
bookmarks = (
|
||||||
|
Bookmark(object=sites[0], user=user),
|
||||||
|
Bookmark(object=sites[1], user=user),
|
||||||
|
Bookmark(object=sites[2], user=user),
|
||||||
|
)
|
||||||
|
Bookmark.objects.bulk_create(bookmarks)
|
||||||
|
|
||||||
|
def _get_url(self, action, instance=None):
|
||||||
|
if action == 'list':
|
||||||
|
return reverse('users:bookmarks')
|
||||||
|
return super()._get_url(action, instance)
|
||||||
|
|
||||||
|
def test_list_objects_anonymous(self):
|
||||||
|
return
|
||||||
|
|
||||||
|
def test_list_objects_with_constrained_permission(self):
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
class ExportTemplateTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
class ExportTemplateTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
||||||
model = ExportTemplate
|
model = ExportTemplate
|
||||||
|
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
from django.urls import include, path, re_path
|
from django.urls import include, path
|
||||||
|
|
||||||
from extras import views
|
from extras import views
|
||||||
from utilities.urls import get_model_urls
|
from utilities.urls import get_model_urls
|
||||||
@ -40,6 +40,11 @@ urlpatterns = [
|
|||||||
path('saved-filters/delete/', views.SavedFilterBulkDeleteView.as_view(), name='savedfilter_bulk_delete'),
|
path('saved-filters/delete/', views.SavedFilterBulkDeleteView.as_view(), name='savedfilter_bulk_delete'),
|
||||||
path('saved-filters/<int:pk>/', include(get_model_urls('extras', 'savedfilter'))),
|
path('saved-filters/<int:pk>/', include(get_model_urls('extras', 'savedfilter'))),
|
||||||
|
|
||||||
|
# Bookmarks
|
||||||
|
path('bookmarks/add/', views.BookmarkCreateView.as_view(), name='bookmark_add'),
|
||||||
|
path('bookmarks/delete/', views.BookmarkBulkDeleteView.as_view(), name='bookmark_bulk_delete'),
|
||||||
|
path('bookmarks/<int:pk>/', include(get_model_urls('extras', 'bookmark'))),
|
||||||
|
|
||||||
# Webhooks
|
# Webhooks
|
||||||
path('webhooks/', views.WebhookListView.as_view(), name='webhook_list'),
|
path('webhooks/', views.WebhookListView.as_view(), name='webhook_list'),
|
||||||
path('webhooks/add/', views.WebhookEditView.as_view(), name='webhook_add'),
|
path('webhooks/add/', views.WebhookEditView.as_view(), name='webhook_add'),
|
||||||
|
@ -237,6 +237,35 @@ class SavedFilterBulkDeleteView(SavedFilterMixin, generic.BulkDeleteView):
|
|||||||
table = tables.SavedFilterTable
|
table = tables.SavedFilterTable
|
||||||
|
|
||||||
|
|
||||||
|
#
|
||||||
|
# Bookmarks
|
||||||
|
#
|
||||||
|
|
||||||
|
class BookmarkCreateView(generic.ObjectEditView):
|
||||||
|
form = forms.BookmarkForm
|
||||||
|
|
||||||
|
def get_queryset(self, request):
|
||||||
|
return Bookmark.objects.filter(user=request.user)
|
||||||
|
|
||||||
|
def alter_object(self, obj, request, url_args, url_kwargs):
|
||||||
|
obj.user = request.user
|
||||||
|
return obj
|
||||||
|
|
||||||
|
|
||||||
|
@register_model_view(Bookmark, 'delete')
|
||||||
|
class BookmarkDeleteView(generic.ObjectDeleteView):
|
||||||
|
|
||||||
|
def get_queryset(self, request):
|
||||||
|
return Bookmark.objects.filter(user=request.user)
|
||||||
|
|
||||||
|
|
||||||
|
class BookmarkBulkDeleteView(generic.BulkDeleteView):
|
||||||
|
table = tables.BookmarkTable
|
||||||
|
|
||||||
|
def get_queryset(self, request):
|
||||||
|
return Bookmark.objects.filter(user=request.user)
|
||||||
|
|
||||||
|
|
||||||
#
|
#
|
||||||
# Webhooks
|
# Webhooks
|
||||||
#
|
#
|
||||||
@ -512,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
|
||||||
@ -522,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
|
||||||
@ -535,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.'
|
||||||
|
|
||||||
|
@ -18,6 +18,7 @@ __all__ = (
|
|||||||
|
|
||||||
|
|
||||||
class NetBoxFeatureSet(
|
class NetBoxFeatureSet(
|
||||||
|
BookmarksMixin,
|
||||||
ChangeLoggingMixin,
|
ChangeLoggingMixin,
|
||||||
CustomFieldsMixin,
|
CustomFieldsMixin,
|
||||||
CustomLinksMixin,
|
CustomLinksMixin,
|
||||||
|
@ -22,6 +22,7 @@ from utilities.utils import serialize_object
|
|||||||
from utilities.views import register_model_view
|
from utilities.views import register_model_view
|
||||||
|
|
||||||
__all__ = (
|
__all__ = (
|
||||||
|
'BookmarksMixin',
|
||||||
'ChangeLoggingMixin',
|
'ChangeLoggingMixin',
|
||||||
'CloningMixin',
|
'CloningMixin',
|
||||||
'CustomFieldsMixin',
|
'CustomFieldsMixin',
|
||||||
@ -304,6 +305,20 @@ class ExportTemplatesMixin(models.Model):
|
|||||||
abstract = True
|
abstract = True
|
||||||
|
|
||||||
|
|
||||||
|
class BookmarksMixin(models.Model):
|
||||||
|
"""
|
||||||
|
Enables support for user bookmarks.
|
||||||
|
"""
|
||||||
|
bookmarks = GenericRelation(
|
||||||
|
to='extras.Bookmark',
|
||||||
|
content_type_field='object_type',
|
||||||
|
object_id_field='object_id'
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
abstract = True
|
||||||
|
|
||||||
|
|
||||||
class JobsMixin(models.Model):
|
class JobsMixin(models.Model):
|
||||||
"""
|
"""
|
||||||
Enables support for job results.
|
Enables support for job results.
|
||||||
@ -480,6 +495,7 @@ class SyncedDataMixin(models.Model):
|
|||||||
|
|
||||||
|
|
||||||
FEATURES_MAP = {
|
FEATURES_MAP = {
|
||||||
|
'bookmarks': BookmarksMixin,
|
||||||
'custom_fields': CustomFieldsMixin,
|
'custom_fields': CustomFieldsMixin,
|
||||||
'custom_links': CustomLinksMixin,
|
'custom_links': CustomLinksMixin,
|
||||||
'export_templates': ExportTemplatesMixin,
|
'export_templates': ExportTemplatesMixin,
|
||||||
|
@ -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>
|
||||||
|
9
netbox/templates/extras/dashboard/widgets/bookmarks.html
Normal file
9
netbox/templates/extras/dashboard/widgets/bookmarks.html
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
{% if bookmarks %}
|
||||||
|
<div class="list-group list-group-flush">
|
||||||
|
{% for bookmark in bookmarks %}
|
||||||
|
<a href="{{ bookmark.object.get_absolute_url }}" class="list-group-item list-group-item-action">
|
||||||
|
{{ bookmark.object }}
|
||||||
|
</a>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
@ -38,6 +38,7 @@
|
|||||||
</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 %}
|
||||||
|
{% if module.reports %}
|
||||||
<table class="table table-hover table-headings reports">
|
<table class="table table-hover table-headings reports">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
@ -51,7 +52,7 @@
|
|||||||
<tbody>
|
<tbody>
|
||||||
{% with jobs=module.get_latest_jobs %}
|
{% with jobs=module.get_latest_jobs %}
|
||||||
{% for report_name, report in module.reports.items %}
|
{% for report_name, report in module.reports.items %}
|
||||||
{% with last_job=jobs|get_key:report.name %}
|
{% with last_job=jobs|get_key:report.class_name %}
|
||||||
<tr>
|
<tr>
|
||||||
<td>
|
<td>
|
||||||
<a href="{% url 'extras:report' module=module.python_name name=report.class_name %}" id="{{ report.module }}.{{ report.class_name }}">{{ report.name }}</a>
|
<a href="{% url 'extras:report' module=module.python_name name=report.class_name %}" id="{{ report.module }}.{{ report.class_name }}">{{ report.name }}</a>
|
||||||
@ -103,6 +104,11 @@
|
|||||||
{% endwith %}
|
{% endwith %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</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 %}
|
|
||||||
{% else %}
|
|
||||||
<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 %}
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
</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>
|
||||||
|
@ -59,6 +59,9 @@ Context:
|
|||||||
{# Extra buttons #}
|
{# Extra buttons #}
|
||||||
{% block extra_controls %}{% endblock %}
|
{% block extra_controls %}{% endblock %}
|
||||||
|
|
||||||
|
{% if perms.extras.add_bookmark %}
|
||||||
|
{% bookmark_button object %}
|
||||||
|
{% endif %}
|
||||||
{% if request.user|can_add:object %}
|
{% if request.user|can_add:object %}
|
||||||
{% clone_button object %}
|
{% clone_button object %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
@ -23,6 +23,11 @@
|
|||||||
<i class="mdi mdi-account"></i> Profile
|
<i class="mdi mdi-account"></i> Profile
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
|
<li>
|
||||||
|
<a class="dropdown-item" href="{% url 'users:bookmarks' %}">
|
||||||
|
<i class="mdi mdi-bookmark"></i> Bookmarks
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a class="dropdown-item" href="{% url 'users:preferences' %}">
|
<a class="dropdown-item" href="{% url 'users:preferences' %}">
|
||||||
<i class="mdi mdi-wrench"></i> Preferences
|
<i class="mdi mdi-wrench"></i> Preferences
|
||||||
|
@ -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>
|
||||||
|
@ -3,9 +3,11 @@
|
|||||||
|
|
||||||
{% 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>
|
||||||
|
@ -4,7 +4,6 @@
|
|||||||
{% load helpers %}
|
{% load helpers %}
|
||||||
{% load plugins %}
|
{% load plugins %}
|
||||||
|
|
||||||
|
|
||||||
{% block content-wrapper %}
|
{% block content-wrapper %}
|
||||||
<div class="tab-content">
|
<div class="tab-content">
|
||||||
{% block content %}{% endblock %}
|
{% block content %}{% endblock %}
|
||||||
|
27
netbox/templates/users/base_profile.html
Normal file
27
netbox/templates/users/base_profile.html
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
{% extends 'base/layout.html' %}
|
||||||
|
|
||||||
|
|
||||||
|
{% block tabs %}
|
||||||
|
<ul class="nav nav-tabs px-3">
|
||||||
|
<li role="presentation" class="nav-item">
|
||||||
|
<a class="nav-link{% if active_tab == 'profile' %} active{% endif %}" href="{% url 'users:profile' %}">{% trans "Profile" %}</a>
|
||||||
|
</li>
|
||||||
|
<li role="presentation" class="nav-item">
|
||||||
|
<a class="nav-link{% if active_tab == 'preferences' %} active{% endif %}" href="{% url 'users:preferences' %}">{% trans "Preferences" %}</a>
|
||||||
|
</li>
|
||||||
|
{% if not request.user.ldap_username %}
|
||||||
|
<li role="presentation" class="nav-item">
|
||||||
|
<a class="nav-link{% if active_tab == 'password' %} active{% endif %}" href="{% url 'users:change_password' %}">{% trans "Password" %}</a>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
<li role="presentation" class="nav-item">
|
||||||
|
<a class="nav-link{% if active_tab == 'api-tokens' %} active{% endif %}" href="{% url 'users:token_list' %}">{% trans "API Tokens" %}</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content-wrapper %}
|
||||||
|
<div class="tab-content">
|
||||||
|
{% block content %}{% endblock %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
34
netbox/templates/users/bookmarks.html
Normal file
34
netbox/templates/users/bookmarks.html
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
{% extends 'users/base.html' %}
|
||||||
|
{% load buttons %}
|
||||||
|
{% load helpers %}
|
||||||
|
{% load render_table from django_tables2 %}
|
||||||
|
|
||||||
|
{% block title %}Bookmarks{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
<form method="post" class="form form-horizontal">
|
||||||
|
{% csrf_token %}
|
||||||
|
<input type="hidden" name="return_url" value="{% url 'users:bookmarks' %}" />
|
||||||
|
|
||||||
|
{# Table #}
|
||||||
|
<div class="row">
|
||||||
|
<div class="col col-md-12">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body htmx-container table-responsive" id="object_list">
|
||||||
|
{% include 'htmx/table.html' %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Form buttons #}
|
||||||
|
<div class="noprint bulk-buttons">
|
||||||
|
<div class="bulk-button-group">
|
||||||
|
{% if 'bulk_delete' in actions %}
|
||||||
|
{% bulk_delete_button model query_params=request.GET %}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{% endblock %}
|
27
netbox/templates/users/passworduser.html
Normal file
27
netbox/templates/users/passworduser.html
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
{% extends 'users/base_profile.html' %}
|
||||||
|
{% load form_helpers %}
|
||||||
|
|
||||||
|
{% block title %}{% trans "Change Password" %}{% endblock %}
|
||||||
|
|
||||||
|
{% block tabs %}
|
||||||
|
<ul class="nav nav-tabs px-3">
|
||||||
|
<li role="presentation" class="nav-item">
|
||||||
|
<a class="nav-link{% if active_tab == 'password' %} active{% endif %}" href="{% url 'users:change_user_password' pk=object.pk %}">{% trans "Password" %}</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
{% endblock tabs %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<form action="." method="post" class="form form-horizontal col-md-8 offset-md-2">
|
||||||
|
{% csrf_token %}
|
||||||
|
<div class="field-group">
|
||||||
|
<h5 class="text-center">{% trans "Password" %}</h5>
|
||||||
|
{% render_field form.new_password1 %}
|
||||||
|
{% render_field form.new_password2 %}
|
||||||
|
</div>
|
||||||
|
<div class="text-end">
|
||||||
|
<a href="{% url 'users:profile' %}" class="btn btn-outline-danger">{% trans "Cancel" %}</a>
|
||||||
|
<button type="submit" name="_update" class="btn btn-primary">{% trans "Save" %}</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{% endblock %}
|
@ -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'
|
||||||
|
)
|
||||||
|
@ -18,9 +18,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 %}
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
@ -8,6 +8,7 @@ urlpatterns = [
|
|||||||
|
|
||||||
# User
|
# User
|
||||||
path('profile/', views.ProfileView.as_view(), name='profile'),
|
path('profile/', views.ProfileView.as_view(), name='profile'),
|
||||||
|
path('bookmarks/', views.BookmarkListView.as_view(), name='bookmarks'),
|
||||||
path('preferences/', views.UserConfigView.as_view(), name='preferences'),
|
path('preferences/', views.UserConfigView.as_view(), name='preferences'),
|
||||||
path('password/', views.ChangePasswordView.as_view(), name='change_password'),
|
path('password/', views.ChangePasswordView.as_view(), name='change_password'),
|
||||||
|
|
||||||
|
@ -17,8 +17,8 @@ from django.views.decorators.debug import sensitive_post_parameters
|
|||||||
from django.views.generic import View
|
from django.views.generic import View
|
||||||
from social_core.backends.utils import load_backends
|
from social_core.backends.utils import load_backends
|
||||||
|
|
||||||
from extras.models import ObjectChange
|
from extras.models import Bookmark, ObjectChange
|
||||||
from extras.tables import ObjectChangeTable
|
from extras.tables import BookmarkTable, ObjectChangeTable
|
||||||
from netbox.authentication import get_auth_backend_display, get_saml_idps
|
from netbox.authentication import get_auth_backend_display, get_saml_idps
|
||||||
from netbox.config import get_config
|
from netbox.config import get_config
|
||||||
from netbox.views import generic
|
from netbox.views import generic
|
||||||
@ -163,7 +163,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)
|
||||||
@ -232,6 +234,23 @@ class ChangePasswordView(LoginRequiredMixin, View):
|
|||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
#
|
||||||
|
# Bookmarks
|
||||||
|
#
|
||||||
|
|
||||||
|
class BookmarkListView(LoginRequiredMixin, generic.ObjectListView):
|
||||||
|
table = BookmarkTable
|
||||||
|
template_name = 'users/bookmarks.html'
|
||||||
|
|
||||||
|
def get_queryset(self, request):
|
||||||
|
return Bookmark.objects.filter(user=request.user)
|
||||||
|
|
||||||
|
def get_extra_context(self, request):
|
||||||
|
return {
|
||||||
|
'active_tab': 'bookmarks',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
#
|
#
|
||||||
# API tokens
|
# API tokens
|
||||||
#
|
#
|
||||||
|
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>
|
15
netbox/utilities/templates/buttons/bookmark.html
Normal file
15
netbox/utilities/templates/buttons/bookmark.html
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
<form action="{{ form_url }}?return_url={{ return_url }}" method="post">
|
||||||
|
{% csrf_token %}
|
||||||
|
{% for field, value in form_data.items %}
|
||||||
|
<input type="hidden" name="{{ field }}" value="{{ value }}" />
|
||||||
|
{% endfor %}
|
||||||
|
{% if bookmark %}
|
||||||
|
<button type="submit" class="btn btn-sm btn-info">
|
||||||
|
<i class="mdi mdi-bookmark-minus"></i> Unbookmark
|
||||||
|
</button>
|
||||||
|
{% else %}
|
||||||
|
<button type="submit" class="btn btn-sm btn-info">
|
||||||
|
<i class="mdi mdi-bookmark-check"></i> Bookmark
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
</form>
|
@ -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,
|
||||||
|
@ -2,11 +2,12 @@ from django import template
|
|||||||
from django.contrib.contenttypes.models import ContentType
|
from django.contrib.contenttypes.models import ContentType
|
||||||
from django.urls import NoReverseMatch, reverse
|
from django.urls import NoReverseMatch, reverse
|
||||||
|
|
||||||
from extras.models import ExportTemplate
|
from extras.models import Bookmark, ExportTemplate
|
||||||
from utilities.utils import get_viewname, prepare_cloned_fields
|
from utilities.utils import get_viewname, prepare_cloned_fields
|
||||||
|
|
||||||
__all__ = (
|
__all__ = (
|
||||||
'add_button',
|
'add_button',
|
||||||
|
'bookmark_button',
|
||||||
'bulk_delete_button',
|
'bulk_delete_button',
|
||||||
'bulk_edit_button',
|
'bulk_edit_button',
|
||||||
'clone_button',
|
'clone_button',
|
||||||
@ -24,6 +25,37 @@ register = template.Library()
|
|||||||
# Instance buttons
|
# Instance buttons
|
||||||
#
|
#
|
||||||
|
|
||||||
|
@register.inclusion_tag('buttons/bookmark.html', takes_context=True)
|
||||||
|
def bookmark_button(context, instance):
|
||||||
|
# Check if this user has already bookmarked the object
|
||||||
|
content_type = ContentType.objects.get_for_model(instance)
|
||||||
|
bookmark = Bookmark.objects.filter(
|
||||||
|
object_type=content_type,
|
||||||
|
object_id=instance.pk,
|
||||||
|
user=context['request'].user
|
||||||
|
).first()
|
||||||
|
|
||||||
|
# Compile form URL & data
|
||||||
|
if bookmark:
|
||||||
|
form_url = reverse('extras:bookmark_delete', kwargs={'pk': bookmark.pk})
|
||||||
|
form_data = {
|
||||||
|
'confirm': 'true',
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
form_url = reverse('extras:bookmark_add')
|
||||||
|
form_data = {
|
||||||
|
'object_type': content_type.pk,
|
||||||
|
'object_id': instance.pk,
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
'bookmark': bookmark,
|
||||||
|
'form_url': form_url,
|
||||||
|
'form_data': form_data,
|
||||||
|
'return_url': instance.get_absolute_url(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@register.inclusion_tag('buttons/clone.html')
|
@register.inclusion_tag('buttons/clone.html')
|
||||||
def clone_button(instance):
|
def clone_button(instance):
|
||||||
url = reverse(get_viewname(instance, 'add'))
|
url = reverse(get_viewname(instance, 'add'))
|
||||||
|
@ -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