13044 merge

This commit is contained in:
Arthur 2023-07-21 14:52:54 +07:00
commit 920c34231c
117 changed files with 2771 additions and 763 deletions

View File

@ -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

View File

@ -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

View File

@ -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.
[![NetBox Labs](https://raw.githubusercontent.com/wiki/netbox-community/netbox/images/sponsors/netbox_labs.png)](https://netboxlabs.com) [![NetBox Labs](https://raw.githubusercontent.com/wiki/netbox-community/netbox/images/sponsors/netbox_labs.png)](https://netboxlabs.com)
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
[![DigitalOcean](https://raw.githubusercontent.com/wiki/netbox-community/netbox/images/sponsors/digitalocean.png)](https://try.digitalocean.com/developer-cloud) [![DigitalOcean](https://raw.githubusercontent.com/wiki/netbox-community/netbox/images/sponsors/digitalocean.png)](https://try.digitalocean.com/developer-cloud)
<br />
[![Sentry](https://raw.githubusercontent.com/wiki/netbox-community/netbox/images/sponsors/sentry.png)](https://sentry.io)
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
[![Sentry](https://raw.githubusercontent.com/wiki/netbox-community/netbox/images/sponsors/sentry.png)](https://sentry.io)
<br />
[![Equinix Metal](https://raw.githubusercontent.com/wiki/netbox-community/netbox/images/sponsors/equinix.png)](https://metal.equinix.com) [![Equinix Metal](https://raw.githubusercontent.com/wiki/netbox-community/netbox/images/sponsors/equinix.png)](https://metal.equinix.com)
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
[![OneMind Services](https://raw.githubusercontent.com/wiki/netbox-community/netbox/images/sponsors/onemind_services.png)](https://onemindservices.com)
</div> </div>

View File

@ -60,7 +60,7 @@ NetBox supports limited custom validation for custom field values. Following are
### Custom Selection Fields ### Custom Selection Fields
Each custom selection field must have at least two choices. These are specified as a comma-separated list. Choices appear in forms in the order they are listed. Note that choice values are saved exactly as they appear, so it's best to avoid superfluous punctuation or symbols where possible. Each custom selection field must designate a [choice set](../models/extras/customfieldchoiceset.md) containing at least two choices. These are specified as a comma-separated list.
If a default value is specified for a selection field, it must exactly match one of the provided choices. The value of a multiple selection field will always return a list, even if only one value is selected. If a default value is specified for a selection field, it must exactly match one of the provided choices. The value of a multiple selection field will always return a list, even if only one value is selected.

View File

@ -79,9 +79,9 @@ Controls how and whether the custom field is displayed within the NetBox user in
The default value to populate for the custom field when creating new objects (optional). This value must be expressed as JSON. If this is a choice or multi-choice field, this must be one of the available choices. The default value to populate for the custom field when creating new objects (optional). This value must be expressed as JSON. If this is a choice or multi-choice field, this must be one of the available choices.
### Choices ### Choice Set
For choice and multi-choice custom fields only. A comma-delimited list of the available choices. For selection and multi-select custom fields only, this is the [set of choices](./customfieldchoiceset.md) which are valid for the field.
### Cloneable ### Cloneable

View File

@ -0,0 +1,17 @@
# Custom Field Choice Sets
Single- and multi-selection [custom fields documentation](../../customization/custom-fields.md) must define a set of valid choices from which the user may choose when defining the field value. These choices are defined as sets that may be reused among multiple custom fields.
## Fields
### Name
The human-friendly name of the choice set.
### Extra Choices
The list of valid choices, entered as a comma-separated list.
### Order Alphabetically
If enabled, the choices list will be automatically ordered alphabetically. If disabled, choices will appear in the order in which they were defined.

View File

@ -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:

View File

@ -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
--- ---

View File

@ -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
} }
) )

View File

@ -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

View File

@ -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)'),

View File

@ -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)

View File

@ -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(

View File

@ -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):

View File

@ -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):

View File

@ -7,6 +7,7 @@ __all__ = [
'NestedBookmarkSerializer', 'NestedBookmarkSerializer',
'NestedConfigContextSerializer', 'NestedConfigContextSerializer',
'NestedConfigTemplateSerializer', 'NestedConfigTemplateSerializer',
'NestedCustomFieldChoiceSetSerializer',
'NestedCustomFieldSerializer', 'NestedCustomFieldSerializer',
'NestedCustomLinkSerializer', 'NestedCustomLinkSerializer',
'NestedExportTemplateSerializer', 'NestedExportTemplateSerializer',
@ -34,6 +35,14 @@ class NestedCustomFieldSerializer(WritableNestedSerializer):
fields = ['id', 'url', 'display', 'name'] fields = ['id', 'url', 'display', 'name']
class NestedCustomFieldChoiceSetSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='extras-api:customfieldchoiceset-detail')
class Meta:
model = models.CustomFieldChoiceSet
fields = ['id', 'url', 'display', 'name', 'choices_count']
class NestedCustomLinkSerializer(WritableNestedSerializer): class NestedCustomLinkSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='extras-api:customlink-detail') url = serializers.HyperlinkedIdentityField(view_name='extras-api:customlink-detail')

View File

@ -35,6 +35,7 @@ __all__ = (
'ConfigContextSerializer', 'ConfigContextSerializer',
'ConfigTemplateSerializer', 'ConfigTemplateSerializer',
'ContentTypeSerializer', 'ContentTypeSerializer',
'CustomFieldChoiceSetSerializer',
'CustomFieldSerializer', 'CustomFieldSerializer',
'CustomLinkSerializer', 'CustomLinkSerializer',
'DashboardSerializer', 'DashboardSerializer',
@ -94,6 +95,7 @@ class CustomFieldSerializer(ValidatedModelSerializer):
) )
filter_logic = ChoiceField(choices=CustomFieldFilterLogicChoices, required=False) filter_logic = ChoiceField(choices=CustomFieldFilterLogicChoices, required=False)
data_type = serializers.SerializerMethodField() data_type = serializers.SerializerMethodField()
choice_set = NestedCustomFieldChoiceSetSerializer(required=False)
ui_visibility = ChoiceField(choices=CustomFieldVisibilityChoices, required=False) ui_visibility = ChoiceField(choices=CustomFieldVisibilityChoices, required=False)
class Meta: class Meta:
@ -101,7 +103,7 @@ class CustomFieldSerializer(ValidatedModelSerializer):
fields = [ fields = [
'id', 'url', 'display', 'content_types', 'type', 'object_type', 'data_type', 'name', 'label', 'group_name', 'id', 'url', 'display', 'content_types', 'type', 'object_type', 'data_type', 'name', 'label', 'group_name',
'description', 'required', 'search_weight', 'filter_logic', 'ui_visibility', 'is_cloneable', 'default', 'description', 'required', 'search_weight', 'filter_logic', 'ui_visibility', 'is_cloneable', 'default',
'weight', 'validation_minimum', 'validation_maximum', 'validation_regex', 'choices', 'created', 'weight', 'validation_minimum', 'validation_maximum', 'validation_regex', 'choice_set', 'created',
'last_updated', 'last_updated',
] ]
@ -127,6 +129,17 @@ class CustomFieldSerializer(ValidatedModelSerializer):
return 'string' return 'string'
class CustomFieldChoiceSetSerializer(ValidatedModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='extras-api:customfieldchoiceset-detail')
class Meta:
model = CustomFieldChoiceSet
fields = [
'id', 'url', 'display', 'name', 'description', 'extra_choices', 'order_alphabetically', 'choices_count',
'created', 'last_updated',
]
# #
# Custom links # Custom links
# #

View File

@ -9,6 +9,7 @@ router.APIRootView = views.ExtrasRootView
router.register('webhooks', views.WebhookViewSet) router.register('webhooks', views.WebhookViewSet)
router.register('custom-fields', views.CustomFieldViewSet) router.register('custom-fields', views.CustomFieldViewSet)
router.register('custom-field-choices', views.CustomFieldChoiceSetViewSet)
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)

View File

@ -1,6 +1,5 @@
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.http import Http404 from django.http import Http404
from django.shortcuts import get_object_or_404
from django_rq.queues import get_connection from django_rq.queues import get_connection
from rest_framework import status from rest_framework import status
from rest_framework.decorators import action from rest_framework.decorators import action
@ -55,11 +54,17 @@ class WebhookViewSet(NetBoxModelViewSet):
class CustomFieldViewSet(NetBoxModelViewSet): class CustomFieldViewSet(NetBoxModelViewSet):
metadata_class = ContentTypeMetadata metadata_class = ContentTypeMetadata
queryset = CustomField.objects.all() queryset = CustomField.objects.select_related('choice_set')
serializer_class = serializers.CustomFieldSerializer serializer_class = serializers.CustomFieldSerializer
filterset_class = filtersets.CustomFieldFilterSet filterset_class = filtersets.CustomFieldFilterSet
class CustomFieldChoiceSetViewSet(NetBoxModelViewSet):
queryset = CustomFieldChoiceSet.objects.all()
serializer_class = serializers.CustomFieldChoiceSetSerializer
filterset_class = filtersets.CustomFieldChoiceSetFilterSet
# #
# Custom links # Custom links
# #
@ -379,7 +384,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

View File

@ -10,7 +10,6 @@ from django.conf import settings
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.core.cache import cache from django.core.cache import cache
from django.db.models import Q from django.db.models import Q
from django.http import QueryDict
from django.template.loader import render_to_string from django.template.loader import render_to_string
from django.urls import NoReverseMatch, resolve, reverse from django.urls import NoReverseMatch, resolve, reverse
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
@ -20,7 +19,7 @@ from extras.utils import FeatureQuery
from utilities.forms import BootstrapMixin from utilities.forms import BootstrapMixin
from utilities.permissions import get_permission_for_model from utilities.permissions import get_permission_for_model
from utilities.templatetags.builtins.filters import render_markdown from utilities.templatetags.builtins.filters import render_markdown
from utilities.utils import content_type_identifier, content_type_name, get_viewname from utilities.utils import content_type_identifier, content_type_name, dict_to_querydict, get_viewname
from .utils import register_widget from .utils import register_widget
__all__ = ( __all__ = (
@ -172,8 +171,7 @@ class ObjectCountsWidget(DashboardWidget):
qs = model.objects.restrict(request.user, 'view') qs = model.objects.restrict(request.user, 'view')
# Apply any specified filters # Apply any specified filters
if filters := self.config.get('filters'): if filters := self.config.get('filters'):
params = QueryDict(mutable=True) params = dict_to_querydict(filters)
params.update(filters)
filterset = getattr(resolve(url).func.view_class, 'filterset', None) filterset = getattr(resolve(url).func.view_class, 'filterset', None)
qs = filterset(params, qs).qs qs = filterset(params, qs).qs
url = f'{url}?{params.urlencode()}' url = f'{url}?{params.urlencode()}'

View File

@ -20,6 +20,7 @@ __all__ = (
'ConfigRevisionFilterSet', 'ConfigRevisionFilterSet',
'ConfigTemplateFilterSet', 'ConfigTemplateFilterSet',
'ContentTypeFilterSet', 'ContentTypeFilterSet',
'CustomFieldChoiceSetFilterSet',
'CustomFieldFilterSet', 'CustomFieldFilterSet',
'CustomLinkFilterSet', 'CustomLinkFilterSet',
'ExportTemplateFilterSet', 'ExportTemplateFilterSet',
@ -74,6 +75,14 @@ class CustomFieldFilterSet(BaseFilterSet):
field_name='content_types__id' field_name='content_types__id'
) )
content_types = ContentTypeFilter() content_types = ContentTypeFilter()
choice_set_id = django_filters.ModelMultipleChoiceFilter(
queryset=CustomFieldChoiceSet.objects.all()
)
choice_set = django_filters.ModelMultipleChoiceFilter(
field_name='choice_set__name',
queryset=CustomFieldChoiceSet.objects.all(),
to_field_name='name'
)
class Meta: class Meta:
model = CustomField model = CustomField
@ -93,6 +102,35 @@ class CustomFieldFilterSet(BaseFilterSet):
) )
class CustomFieldChoiceSetFilterSet(BaseFilterSet):
q = django_filters.CharFilter(
method='search',
label=_('Search'),
)
choice = MultiValueCharFilter(
method='filter_by_choice'
)
class Meta:
model = CustomFieldChoiceSet
fields = [
'id', 'name', 'description', 'order_alphabetically',
]
def search(self, queryset, name, value):
if not value.strip():
return queryset
return queryset.filter(
Q(name__icontains=value) |
Q(description__icontains=value) |
Q(extra_choices__contains=value)
)
def filter_by_choice(self, queryset, name, value):
# TODO: Support case-insensitive matching
return queryset.filter(extra_choices__overlap=value)
class CustomLinkFilterSet(BaseFilterSet): class CustomLinkFilterSet(BaseFilterSet):
q = django_filters.CharFilter( q = django_filters.CharFilter(
method='search', method='search',

View File

@ -4,13 +4,14 @@ from django.utils.translation import gettext as _
from extras.choices import * from extras.choices import *
from extras.models import * from extras.models import *
from utilities.forms import BulkEditForm, add_blank_choice from utilities.forms import BulkEditForm, add_blank_choice
from utilities.forms.fields import ColorField from utilities.forms.fields import ColorField, DynamicModelChoiceField
from utilities.forms.widgets import BulkEditNullBooleanSelect from utilities.forms.widgets import BulkEditNullBooleanSelect
__all__ = ( __all__ = (
'ConfigContextBulkEditForm', 'ConfigContextBulkEditForm',
'ConfigTemplateBulkEditForm', 'ConfigTemplateBulkEditForm',
'CustomFieldBulkEditForm', 'CustomFieldBulkEditForm',
'CustomFieldChoiceSetBulkEditForm',
'CustomLinkBulkEditForm', 'CustomLinkBulkEditForm',
'ExportTemplateBulkEditForm', 'ExportTemplateBulkEditForm',
'JournalEntryBulkEditForm', 'JournalEntryBulkEditForm',
@ -38,6 +39,10 @@ class CustomFieldBulkEditForm(BulkEditForm):
weight = forms.IntegerField( weight = forms.IntegerField(
required=False required=False
) )
choice_set = DynamicModelChoiceField(
queryset=CustomFieldChoiceSet.objects.all(),
required=False
)
ui_visibility = forms.ChoiceField( ui_visibility = forms.ChoiceField(
label=_("UI visibility"), label=_("UI visibility"),
choices=add_blank_choice(CustomFieldVisibilityChoices), choices=add_blank_choice(CustomFieldVisibilityChoices),
@ -49,7 +54,23 @@ class CustomFieldBulkEditForm(BulkEditForm):
widget=BulkEditNullBooleanSelect() widget=BulkEditNullBooleanSelect()
) )
nullable_fields = ('group_name', 'description',) nullable_fields = ('group_name', 'description', 'choice_set')
class CustomFieldChoiceSetBulkEditForm(BulkEditForm):
pk = forms.ModelMultipleChoiceField(
queryset=CustomFieldChoiceSet.objects.all(),
widget=forms.MultipleHiddenInput
)
description = forms.CharField(
required=False
)
order_alphabetically = forms.NullBooleanField(
required=False,
widget=BulkEditNullBooleanSelect()
)
nullable_fields = ('description',)
class CustomLinkBulkEditForm(BulkEditForm): class CustomLinkBulkEditForm(BulkEditForm):

View File

@ -9,10 +9,13 @@ from extras.models import *
from extras.utils import FeatureQuery from extras.utils import FeatureQuery
from netbox.forms import NetBoxModelImportForm from netbox.forms import NetBoxModelImportForm
from utilities.forms import CSVModelForm from utilities.forms import CSVModelForm
from utilities.forms.fields import CSVChoiceField, CSVContentTypeField, CSVMultipleContentTypeField, SlugField from utilities.forms.fields import (
CSVChoiceField, CSVContentTypeField, CSVModelChoiceField, CSVMultipleContentTypeField, SlugField,
)
__all__ = ( __all__ = (
'ConfigTemplateImportForm', 'ConfigTemplateImportForm',
'CustomFieldChoiceSetImportForm',
'CustomFieldImportForm', 'CustomFieldImportForm',
'CustomLinkImportForm', 'CustomLinkImportForm',
'ExportTemplateImportForm', 'ExportTemplateImportForm',
@ -39,10 +42,11 @@ class CustomFieldImportForm(CSVModelForm):
required=False, required=False,
help_text=_("Object type (for object or multi-object fields)") help_text=_("Object type (for object or multi-object fields)")
) )
choices = SimpleArrayField( choice_set = CSVModelChoiceField(
base_field=forms.CharField(), queryset=CustomFieldChoiceSet.objects.all(),
to_field_name='name',
required=False, required=False,
help_text=_('Comma-separated list of field choices') help_text=_('Choice set (for selection fields)')
) )
ui_visibility = CSVChoiceField( ui_visibility = CSVChoiceField(
choices=CustomFieldVisibilityChoices, choices=CustomFieldVisibilityChoices,
@ -53,8 +57,22 @@ class CustomFieldImportForm(CSVModelForm):
model = CustomField model = CustomField
fields = ( fields = (
'name', 'label', 'group_name', 'type', 'content_types', 'object_type', 'required', 'description', 'name', 'label', 'group_name', 'type', 'content_types', 'object_type', 'required', 'description',
'search_weight', 'filter_logic', 'default', 'choices', 'weight', 'validation_minimum', 'validation_maximum', 'search_weight', 'filter_logic', 'default', 'choice_set', 'weight', 'validation_minimum',
'validation_regex', 'ui_visibility', 'is_cloneable', 'validation_maximum', 'validation_regex', 'ui_visibility', 'is_cloneable',
)
class CustomFieldChoiceSetImportForm(CSVModelForm):
extra_choices = SimpleArrayField(
base_field=forms.CharField(),
required=False,
help_text=_('Comma-separated list of field choices')
)
class Meta:
model = CustomFieldChoiceSet
fields = (
'name', 'description', 'extra_choices', 'order_alphabetically',
) )

View File

@ -20,6 +20,7 @@ __all__ = (
'ConfigContextFilterForm', 'ConfigContextFilterForm',
'ConfigRevisionFilterForm', 'ConfigRevisionFilterForm',
'ConfigTemplateFilterForm', 'ConfigTemplateFilterForm',
'CustomFieldChoiceSetFilterForm',
'CustomFieldFilterForm', 'CustomFieldFilterForm',
'CustomLinkFilterForm', 'CustomLinkFilterForm',
'ExportTemplateFilterForm', 'ExportTemplateFilterForm',
@ -37,7 +38,8 @@ class CustomFieldFilterForm(SavedFiltersMixin, FilterForm):
fieldsets = ( fieldsets = (
(None, ('q', 'filter_id')), (None, ('q', 'filter_id')),
('Attributes', ( ('Attributes', (
'type', 'content_type_id', 'group_name', 'weight', 'required', 'ui_visibility', 'is_cloneable', 'type', 'content_type_id', 'group_name', 'weight', 'required', 'choice_set_id', 'ui_visibility',
'is_cloneable',
)), )),
) )
content_type_id = ContentTypeMultipleChoiceField( content_type_id = ContentTypeMultipleChoiceField(
@ -62,6 +64,11 @@ class CustomFieldFilterForm(SavedFiltersMixin, FilterForm):
choices=BOOLEAN_WITH_BLANK_CHOICES choices=BOOLEAN_WITH_BLANK_CHOICES
) )
) )
choice_set_id = DynamicModelMultipleChoiceField(
queryset=CustomFieldChoiceSet.objects.all(),
required=False,
label=_('Choice set')
)
ui_visibility = forms.ChoiceField( ui_visibility = forms.ChoiceField(
choices=add_blank_choice(CustomFieldVisibilityChoices), choices=add_blank_choice(CustomFieldVisibilityChoices),
required=False, required=False,
@ -75,10 +82,19 @@ class CustomFieldFilterForm(SavedFiltersMixin, FilterForm):
) )
class CustomFieldChoiceSetFilterForm(SavedFiltersMixin, FilterForm):
fieldsets = (
(None, ('q', 'filter_id', 'choice')),
)
choice = forms.CharField(
required=False
)
class CustomLinkFilterForm(SavedFiltersMixin, FilterForm): class CustomLinkFilterForm(SavedFiltersMixin, FilterForm):
fieldsets = ( fieldsets = (
(None, ('q', 'filter_id')), (None, ('q', 'filter_id')),
('Attributes', ('content_types', 'enabled', 'new_window', 'weight')), (_('Attributes'), ('content_types', 'enabled', 'new_window', 'weight')),
) )
content_types = ContentTypeMultipleChoiceField( content_types = ContentTypeMultipleChoiceField(
queryset=ContentType.objects.filter(FeatureQuery('custom_links').get_query()), queryset=ContentType.objects.filter(FeatureQuery('custom_links').get_query()),

View File

@ -16,9 +16,10 @@ from netbox.forms import NetBoxModelForm
from tenancy.models import Tenant, TenantGroup from tenancy.models import Tenant, TenantGroup
from utilities.forms import 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, DynamicModelChoiceField,
SlugField, DynamicModelMultipleChoiceField, JSONField, SlugField,
) )
from utilities.forms.widgets import ArrayWidget
from virtualization.models import Cluster, ClusterGroup, ClusterType from virtualization.models import Cluster, ClusterGroup, ClusterType
@ -27,6 +28,7 @@ __all__ = (
'ConfigContextForm', 'ConfigContextForm',
'ConfigRevisionForm', 'ConfigRevisionForm',
'ConfigTemplateForm', 'ConfigTemplateForm',
'CustomFieldChoiceSetForm',
'CustomFieldForm', 'CustomFieldForm',
'CustomLinkForm', 'CustomLinkForm',
'ExportTemplateForm', 'ExportTemplateForm',
@ -50,13 +52,17 @@ class CustomFieldForm(BootstrapMixin, forms.ModelForm):
required=False, required=False,
help_text=_("Type of the related object (for object/multi-object fields only)") help_text=_("Type of the related object (for object/multi-object fields only)")
) )
choice_set = DynamicModelChoiceField(
queryset=CustomFieldChoiceSet.objects.all(),
required=False
)
fieldsets = ( fieldsets = (
('Custom Field', ( ('Custom Field', (
'content_types', 'name', 'label', 'group_name', 'type', 'object_type', 'required', 'description', 'content_types', 'name', 'label', 'group_name', 'type', 'object_type', 'required', 'description',
)), )),
('Behavior', ('search_weight', 'filter_logic', 'ui_visibility', 'weight', 'is_cloneable')), ('Behavior', ('search_weight', 'filter_logic', 'ui_visibility', 'weight', 'is_cloneable')),
('Values', ('default', 'choices')), ('Values', ('default', 'choice_set')),
('Validation', ('validation_minimum', 'validation_maximum', 'validation_regex')), ('Validation', ('validation_minimum', 'validation_maximum', 'validation_regex')),
) )
@ -78,6 +84,20 @@ class CustomFieldForm(BootstrapMixin, forms.ModelForm):
self.fields['type'].disabled = True self.fields['type'].disabled = True
class CustomFieldChoiceSetForm(BootstrapMixin, forms.ModelForm):
extra_choices = forms.CharField(
widget=ArrayWidget(),
help_text=_('Enter one choice per line.')
)
class Meta:
model = CustomFieldChoiceSet
fields = ('name', 'description', 'extra_choices', 'order_alphabetically')
def clean_extra_choices(self):
return self.cleaned_data['extra_choices'].splitlines()
class CustomLinkForm(BootstrapMixin, forms.ModelForm): class CustomLinkForm(BootstrapMixin, forms.ModelForm):
content_types = ContentTypeMultipleChoiceField( content_types = ContentTypeMultipleChoiceField(
queryset=ContentType.objects.all(), queryset=ContentType.objects.all(),

View File

@ -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)

View File

@ -25,6 +25,12 @@ class ExtrasQuery(graphene.ObjectType):
def resolve_custom_field_list(root, info, **kwargs): def resolve_custom_field_list(root, info, **kwargs):
return gql_query_optimizer(models.CustomField.objects.all(), info) return gql_query_optimizer(models.CustomField.objects.all(), info)
custom_field_choice_set = ObjectField(CustomFieldChoiceSetType)
custom_field_choice_set_list = ObjectListField(CustomFieldChoiceSetType)
def resolve_custom_field_choices_list(root, info, **kwargs):
return gql_query_optimizer(models.CustomFieldChoiceSet.objects.all(), info)
custom_link = ObjectField(CustomLinkType) custom_link = ObjectField(CustomLinkType)
custom_link_list = ObjectListField(CustomLinkType) custom_link_list = ObjectListField(CustomLinkType)

View File

@ -5,6 +5,7 @@ from netbox.graphql.types import BaseObjectType, ObjectType
__all__ = ( __all__ = (
'ConfigContextType', 'ConfigContextType',
'ConfigTemplateType', 'ConfigTemplateType',
'CustomFieldChoiceSetType',
'CustomFieldType', 'CustomFieldType',
'CustomLinkType', 'CustomLinkType',
'ExportTemplateType', 'ExportTemplateType',
@ -41,6 +42,14 @@ class CustomFieldType(ObjectType):
filterset_class = filtersets.CustomFieldFilterSet filterset_class = filtersets.CustomFieldFilterSet
class CustomFieldChoiceSetType(ObjectType):
class Meta:
model = models.CustomFieldChoiceSet
fields = '__all__'
filterset_class = filtersets.CustomFieldChoiceSetFilterSet
class CustomLinkType(ObjectType): class CustomLinkType(ObjectType):
class Meta: class Meta:

View File

@ -0,0 +1,61 @@
import django.contrib.postgres.fields
from django.db import migrations, models
from extras.choices import CustomFieldTypeChoices
def create_choice_sets(apps, schema_editor):
"""
Create a CustomFieldChoiceSet for each CustomField with choices defined.
"""
CustomField = apps.get_model('extras', 'CustomField')
CustomFieldChoiceSet = apps.get_model('extras', 'CustomFieldChoiceSet')
# Create custom field choice sets
choice_fields = CustomField.objects.filter(
type__in=(CustomFieldTypeChoices.TYPE_SELECT, CustomFieldTypeChoices.TYPE_MULTISELECT),
choices__len__gt=0
)
for cf in choice_fields:
choiceset = CustomFieldChoiceSet.objects.create(
name=f'{cf.name} Choices',
extra_choices=cf.choices
)
cf.choice_set = choiceset
# Update custom fields to point to new choice sets
CustomField.objects.bulk_update(choice_fields, ['choice_set'])
class Migration(migrations.Migration):
dependencies = [
('extras', '0095_bookmarks'),
]
operations = [
migrations.CreateModel(
name='CustomFieldChoiceSet',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
('created', models.DateTimeField(auto_now_add=True, null=True)),
('last_updated', models.DateTimeField(auto_now=True, null=True)),
('name', models.CharField(max_length=100, unique=True)),
('description', models.CharField(blank=True, max_length=200)),
('extra_choices', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=100), size=None)),
('order_alphabetically', models.BooleanField(default=False)),
],
options={
'ordering': ('name',),
},
),
migrations.AddField(
model_name='customfield',
name='choice_set',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='choices_for', to='extras.customfieldchoiceset'),
),
migrations.RunPython(
code=create_choice_sets,
reverse_code=migrations.RunPython.noop
),
]

View File

@ -0,0 +1,17 @@
# Generated by Django 4.1.10 on 2023-07-17 15:22
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('extras', '0096_customfieldchoiceset'),
]
operations = [
migrations.RemoveField(
model_name='customfield',
name='choices',
),
]

View File

@ -1,6 +1,6 @@
from .change_logging import * from .change_logging import *
from .configs import * from .configs import *
from .customfields import CustomField from .customfields import *
from .dashboard import * from .dashboard import *
from .models import * from .models import *
from .reports import * from .reports import *

View File

@ -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']

View File

@ -31,6 +31,7 @@ from utilities.validators import validate_regex
__all__ = ( __all__ = (
'CustomField', 'CustomField',
'CustomFieldChoiceSet',
'CustomFieldManager', 'CustomFieldManager',
) )
@ -158,11 +159,12 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
'example, <code>^[A-Z]{3}$</code> will limit values to exactly three uppercase letters.' 'example, <code>^[A-Z]{3}$</code> will limit values to exactly three uppercase letters.'
) )
) )
choices = ArrayField( choice_set = models.ForeignKey(
base_field=models.CharField(max_length=100), to='CustomFieldChoiceSet',
on_delete=models.PROTECT,
related_name='choices_for',
blank=True, blank=True,
null=True, null=True
help_text=_('Comma-separated list of available choices (for selection fields)')
) )
ui_visibility = models.CharField( ui_visibility = models.CharField(
max_length=50, max_length=50,
@ -181,8 +183,8 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
clone_fields = ( clone_fields = (
'content_types', 'type', 'object_type', 'group_name', 'description', 'required', 'search_weight', 'content_types', 'type', 'object_type', 'group_name', 'description', 'required', 'search_weight',
'filter_logic', 'default', 'weight', 'validation_minimum', 'validation_maximum', 'validation_regex', 'choices', 'filter_logic', 'default', 'weight', 'validation_minimum', 'validation_maximum', 'validation_regex',
'ui_visibility', 'is_cloneable', 'choice_set', 'ui_visibility', 'is_cloneable',
) )
class Meta: class Meta:
@ -208,6 +210,12 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
def search_type(self): def search_type(self):
return SEARCH_TYPES.get(self.type) return SEARCH_TYPES.get(self.type)
@property
def choices(self):
if self.choice_set:
return self.choice_set.choices
return []
def populate_initial_data(self, content_types): def populate_initial_data(self, content_types):
""" """
Populate initial custom field data upon either a) the creation of a new CustomField, or Populate initial custom field data upon either a) the creation of a new CustomField, or
@ -278,22 +286,18 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
'validation_regex': "Regular expression validation is supported only for text and URL fields" 'validation_regex': "Regular expression validation is supported only for text and URL fields"
}) })
# Choices can be set only on selection fields # Choice set must be set on selection fields, and *only* on selection fields
if self.choices and self.type not in (
CustomFieldTypeChoices.TYPE_SELECT,
CustomFieldTypeChoices.TYPE_MULTISELECT
):
raise ValidationError({
'choices': "Choices may be set only for custom selection fields."
})
# Selection fields must have at least one choice defined
if self.type in ( if self.type in (
CustomFieldTypeChoices.TYPE_SELECT, CustomFieldTypeChoices.TYPE_SELECT,
CustomFieldTypeChoices.TYPE_MULTISELECT CustomFieldTypeChoices.TYPE_MULTISELECT
) and not self.choices: ):
if not self.choice_set:
raise ValidationError({
'choice_set': "Selection fields must specify a set of choices."
})
elif self.choice_set:
raise ValidationError({ raise ValidationError({
'choices': "Selection fields must specify at least one choice." 'choice_set': "Choices may be set only on selection fields."
}) })
# A selection field's default (if any) must be present in its available choices # A selection field's default (if any) must be present in its available choices
@ -627,3 +631,52 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
elif self.required: elif self.required:
raise ValidationError("Required field cannot be empty.") raise ValidationError("Required field cannot be empty.")
class CustomFieldChoiceSet(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
"""
Represents a set of choices available for choice and multi-choice custom fields.
"""
name = models.CharField(
max_length=100,
unique=True
)
description = models.CharField(
max_length=200,
blank=True
)
extra_choices = ArrayField(
base_field=models.CharField(max_length=100),
help_text=_('List of field choices')
)
order_alphabetically = models.BooleanField(
default=False,
help_text=_('Choices are automatically ordered alphabetically on save')
)
clone_fields = ('extra_choices', 'order_alphabetically')
class Meta:
ordering = ('name',)
def __str__(self):
return self.name
def get_absolute_url(self):
return reverse('extras:customfieldchoiceset', args=[self.pk])
@property
def choices(self):
return self.extra_choices
@property
def choices_count(self):
return len(self.choices)
def save(self, *args, **kwargs):
# Sort choices if alphabetical ordering is enforced
if self.order_alphabetically:
self.extra_choices = sorted(self.choices)
return super().save(*args, **kwargs)

View File

@ -8,7 +8,7 @@ from django.contrib.contenttypes.models import ContentType
from django.core.cache import cache from django.core.cache import cache
from django.core.validators import ValidationError from django.core.validators import ValidationError
from django.db import models from django.db import models
from django.http import HttpResponse, QueryDict from django.http import HttpResponse
from django.urls import reverse from django.urls import reverse
from django.utils import timezone from django.utils import timezone
from django.utils.formats import date_format from django.utils.formats import date_format
@ -25,7 +25,7 @@ from netbox.models.features import (
CloningMixin, CustomFieldsMixin, CustomLinksMixin, ExportTemplatesMixin, SyncedDataMixin, TagsMixin, CloningMixin, CustomFieldsMixin, CustomLinksMixin, ExportTemplatesMixin, SyncedDataMixin, TagsMixin,
) )
from utilities.querysets import RestrictedQuerySet from utilities.querysets import RestrictedQuerySet
from utilities.utils import clean_html, render_jinja2 from utilities.utils import clean_html, dict_to_querydict, render_jinja2
__all__ = ( __all__ = (
'Bookmark', 'Bookmark',
@ -285,7 +285,7 @@ class CustomLink(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
text = clean_html(text, allowed_schemes) text = clean_html(text, allowed_schemes)
# Sanitize link # Sanitize link
link = urllib.parse.quote(link, safe='/:?&=%+[]@#') link = urllib.parse.quote(link, safe='/:?&=%+[]@#,')
# Verify link scheme is allowed # Verify link scheme is allowed
result = urllib.parse.urlparse(link) result = urllib.parse.urlparse(link)
@ -463,8 +463,7 @@ class SavedFilter(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
@property @property
def url_params(self): def url_params(self):
qd = QueryDict(mutable=True) qd = dict_to_querydict(self.parameters)
qd.update(self.parameters)
return qd.urlencode() return qd.urlencode()

View File

@ -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', [])

View File

@ -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)

View File

@ -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

View File

@ -2,6 +2,7 @@ import json
import django_tables2 as tables import django_tables2 as tables
from django.conf import settings from django.conf import settings
from django.utils.translation import gettext as _
from extras.models import * from extras.models import *
from netbox.tables import NetBoxTable, columns from netbox.tables import NetBoxTable, columns
@ -12,6 +13,7 @@ __all__ = (
'ConfigContextTable', 'ConfigContextTable',
'ConfigRevisionTable', 'ConfigRevisionTable',
'ConfigTemplateTable', 'ConfigTemplateTable',
'CustomFieldChoiceSetTable',
'CustomFieldTable', 'CustomFieldTable',
'CustomLinkTable', 'CustomLinkTable',
'ExportTemplateTable', 'ExportTemplateTable',
@ -64,6 +66,11 @@ class CustomFieldTable(NetBoxTable):
required = columns.BooleanColumn() required = columns.BooleanColumn()
ui_visibility = columns.ChoiceFieldColumn(verbose_name="UI visibility") ui_visibility = columns.ChoiceFieldColumn(verbose_name="UI visibility")
description = columns.MarkdownColumn() description = columns.MarkdownColumn()
choices = columns.ArrayColumn(
max_items=10,
orderable=False,
verbose_name=_('Choices')
)
is_cloneable = columns.BooleanColumn() is_cloneable = columns.BooleanColumn()
class Meta(NetBoxTable.Meta): class Meta(NetBoxTable.Meta):
@ -76,6 +83,33 @@ class CustomFieldTable(NetBoxTable):
default_columns = ('pk', 'name', 'content_types', 'label', 'group_name', 'type', 'required', 'description') default_columns = ('pk', 'name', 'content_types', 'label', 'group_name', 'type', 'required', 'description')
class CustomFieldChoiceSetTable(NetBoxTable):
name = tables.Column(
linkify=True
)
choices = columns.ArrayColumn(
max_items=10,
accessor=tables.A('extra_choices'),
orderable=False,
verbose_name=_('Choices')
)
choice_count = tables.TemplateColumn(
accessor=tables.A('extra_choices'),
template_code='{{ value|length }}',
orderable=False,
verbose_name=_('Count')
)
order_alphabetically = columns.BooleanColumn()
class Meta(NetBoxTable.Meta):
model = CustomFieldChoiceSet
fields = (
'pk', 'id', 'name', 'description', 'choice_count', 'choices', 'order_alphabetically', 'created',
'last_updated',
)
default_columns = ('pk', 'name', 'choice_count', 'description')
class CustomLinkTable(NetBoxTable): class CustomLinkTable(NetBoxTable):
name = tables.Column( name = tables.Column(
linkify=True linkify=True

View File

@ -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
@ -99,8 +98,7 @@ class CustomFieldTest(APIViewTestCases.APIViewTestCase):
{ {
'content_types': ['dcim.site'], 'content_types': ['dcim.site'],
'name': 'cf6', 'name': 'cf6',
'type': 'select', 'type': 'text',
'choices': ['A', 'B', 'C']
}, },
] ]
bulk_update_data = { bulk_update_data = {
@ -135,6 +133,42 @@ class CustomFieldTest(APIViewTestCases.APIViewTestCase):
cf.content_types.add(site_ct) cf.content_types.add(site_ct)
class CustomFieldChoiceSetTest(APIViewTestCases.APIViewTestCase):
model = CustomFieldChoiceSet
brief_fields = ['choices_count', 'display', 'id', 'name', 'url']
create_data = [
{
'name': 'Choice Set 4',
'extra_choices': ['4A', '4B', '4C'],
},
{
'name': 'Choice Set 5',
'extra_choices': ['5A', '5B', '5C'],
},
{
'name': 'Choice Set 6',
'extra_choices': ['6A', '6B', '6C'],
},
]
bulk_update_data = {
'description': 'New description',
}
update_data = {
'name': 'Choice Set X',
'extra_choices': ['X1', 'X2', 'X3'],
'description': 'New description',
}
@classmethod
def setUpTestData(cls):
choice_sets = (
CustomFieldChoiceSet(name='Choice Set 1', extra_choices=['1A', '1B', '1C', '1D', '1E']),
CustomFieldChoiceSet(name='Choice Set 2', extra_choices=['2A', '2B', '2C', '2D', '2E']),
CustomFieldChoiceSet(name='Choice Set 3', extra_choices=['3A', '3B', '3C', '3D', '3E']),
)
CustomFieldChoiceSet.objects.bulk_create(choice_sets)
class CustomLinkTest(APIViewTestCases.APIViewTestCase): class CustomLinkTest(APIViewTestCases.APIViewTestCase):
model = CustomLink model = CustomLink
brief_fields = ['display', 'id', 'name', 'url'] brief_fields = ['display', 'id', 'name', 'url']
@ -634,6 +668,7 @@ class ReportTest(APITestCase):
super().setUp() super().setUp()
# Monkey-patch the API viewset's _get_report() method to return our test Report above # Monkey-patch the API viewset's _get_report() method to return our test Report above
from extras.api.views import ReportViewSet
ReportViewSet._get_report = self.get_test_report ReportViewSet._get_report = self.get_test_report
def test_get_report(self): def test_get_report(self):
@ -676,6 +711,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):

View File

@ -5,7 +5,7 @@ from rest_framework import status
from dcim.choices import SiteStatusChoices from dcim.choices import SiteStatusChoices
from dcim.models import Site from dcim.models import Site
from extras.choices import * from extras.choices import *
from extras.models import CustomField, ObjectChange, Tag from extras.models import CustomField, CustomFieldChoiceSet, ObjectChange, Tag
from utilities.testing import APITestCase from utilities.testing import APITestCase
from utilities.testing.utils import create_tags, post_data from utilities.testing.utils import create_tags, post_data
from utilities.testing.views import ModelViewTestCase from utilities.testing.views import ModelViewTestCase
@ -16,12 +16,16 @@ class ChangeLogViewTest(ModelViewTestCase):
@classmethod @classmethod
def setUpTestData(cls): def setUpTestData(cls):
choice_set = CustomFieldChoiceSet.objects.create(
name='Custom Field Choice Set 1',
extra_choices=['Bar', 'Foo']
)
# Create a custom field on the Site model # Create a custom field on the Site model
ct = ContentType.objects.get_for_model(Site) ct = ContentType.objects.get_for_model(Site)
cf = CustomField( cf = CustomField(
type=CustomFieldTypeChoices.TYPE_TEXT, type=CustomFieldTypeChoices.TYPE_TEXT,
name='my_field', name='cf1',
required=False required=False
) )
cf.save() cf.save()
@ -30,9 +34,9 @@ class ChangeLogViewTest(ModelViewTestCase):
# Create a select custom field on the Site model # Create a select custom field on the Site model
cf_select = CustomField( cf_select = CustomField(
type=CustomFieldTypeChoices.TYPE_SELECT, type=CustomFieldTypeChoices.TYPE_SELECT,
name='my_field_select', name='cf2',
required=False, required=False,
choices=['Bar', 'Foo'] choice_set=choice_set
) )
cf_select.save() cf_select.save()
cf_select.content_types.set([ct]) cf_select.content_types.set([ct])
@ -43,8 +47,8 @@ class ChangeLogViewTest(ModelViewTestCase):
'name': 'Site 1', 'name': 'Site 1',
'slug': 'site-1', 'slug': 'site-1',
'status': SiteStatusChoices.STATUS_ACTIVE, 'status': SiteStatusChoices.STATUS_ACTIVE,
'cf_my_field': 'ABC', 'cf_cf1': 'ABC',
'cf_my_field_select': 'Bar', 'cf_cf2': 'Bar',
'tags': [tag.pk for tag in tags], 'tags': [tag.pk for tag in tags],
} }
@ -65,8 +69,8 @@ class ChangeLogViewTest(ModelViewTestCase):
self.assertEqual(oc.changed_object, site) self.assertEqual(oc.changed_object, site)
self.assertEqual(oc.action, ObjectChangeActionChoices.ACTION_CREATE) self.assertEqual(oc.action, ObjectChangeActionChoices.ACTION_CREATE)
self.assertEqual(oc.prechange_data, None) self.assertEqual(oc.prechange_data, None)
self.assertEqual(oc.postchange_data['custom_fields']['my_field'], form_data['cf_my_field']) self.assertEqual(oc.postchange_data['custom_fields']['cf1'], form_data['cf_cf1'])
self.assertEqual(oc.postchange_data['custom_fields']['my_field_select'], form_data['cf_my_field_select']) self.assertEqual(oc.postchange_data['custom_fields']['cf2'], form_data['cf_cf2'])
self.assertEqual(oc.postchange_data['tags'], ['Tag 1', 'Tag 2']) self.assertEqual(oc.postchange_data['tags'], ['Tag 1', 'Tag 2'])
def test_update_object(self): def test_update_object(self):
@ -79,8 +83,8 @@ class ChangeLogViewTest(ModelViewTestCase):
'name': 'Site X', 'name': 'Site X',
'slug': 'site-x', 'slug': 'site-x',
'status': SiteStatusChoices.STATUS_PLANNED, 'status': SiteStatusChoices.STATUS_PLANNED,
'cf_my_field': 'DEF', 'cf_cf1': 'DEF',
'cf_my_field_select': 'Foo', 'cf_cf2': 'Foo',
'tags': [tags[2].pk], 'tags': [tags[2].pk],
} }
@ -102,8 +106,8 @@ class ChangeLogViewTest(ModelViewTestCase):
self.assertEqual(oc.action, ObjectChangeActionChoices.ACTION_UPDATE) self.assertEqual(oc.action, ObjectChangeActionChoices.ACTION_UPDATE)
self.assertEqual(oc.prechange_data['name'], 'Site 1') self.assertEqual(oc.prechange_data['name'], 'Site 1')
self.assertEqual(oc.prechange_data['tags'], ['Tag 1', 'Tag 2']) self.assertEqual(oc.prechange_data['tags'], ['Tag 1', 'Tag 2'])
self.assertEqual(oc.postchange_data['custom_fields']['my_field'], form_data['cf_my_field']) self.assertEqual(oc.postchange_data['custom_fields']['cf1'], form_data['cf_cf1'])
self.assertEqual(oc.postchange_data['custom_fields']['my_field_select'], form_data['cf_my_field_select']) self.assertEqual(oc.postchange_data['custom_fields']['cf2'], form_data['cf_cf2'])
self.assertEqual(oc.postchange_data['tags'], ['Tag 3']) self.assertEqual(oc.postchange_data['tags'], ['Tag 3'])
def test_delete_object(self): def test_delete_object(self):
@ -111,8 +115,8 @@ class ChangeLogViewTest(ModelViewTestCase):
name='Site 1', name='Site 1',
slug='site-1', slug='site-1',
custom_field_data={ custom_field_data={
'my_field': 'ABC', 'cf1': 'ABC',
'my_field_select': 'Bar' 'cf2': 'Bar'
} }
) )
site.save() site.save()
@ -131,8 +135,8 @@ class ChangeLogViewTest(ModelViewTestCase):
self.assertEqual(oc.changed_object, None) self.assertEqual(oc.changed_object, None)
self.assertEqual(oc.object_repr, site.name) self.assertEqual(oc.object_repr, site.name)
self.assertEqual(oc.action, ObjectChangeActionChoices.ACTION_DELETE) self.assertEqual(oc.action, ObjectChangeActionChoices.ACTION_DELETE)
self.assertEqual(oc.prechange_data['custom_fields']['my_field'], 'ABC') self.assertEqual(oc.prechange_data['custom_fields']['cf1'], 'ABC')
self.assertEqual(oc.prechange_data['custom_fields']['my_field_select'], 'Bar') self.assertEqual(oc.prechange_data['custom_fields']['cf2'], 'Bar')
self.assertEqual(oc.prechange_data['tags'], ['Tag 1', 'Tag 2']) self.assertEqual(oc.prechange_data['tags'], ['Tag 1', 'Tag 2'])
self.assertEqual(oc.postchange_data, None) self.assertEqual(oc.postchange_data, None)
@ -213,18 +217,22 @@ class ChangeLogAPITest(APITestCase):
ct = ContentType.objects.get_for_model(Site) ct = ContentType.objects.get_for_model(Site)
cf = CustomField( cf = CustomField(
type=CustomFieldTypeChoices.TYPE_TEXT, type=CustomFieldTypeChoices.TYPE_TEXT,
name='my_field', name='cf1',
required=False required=False
) )
cf.save() cf.save()
cf.content_types.set([ct]) cf.content_types.set([ct])
# Create a select custom field on the Site model # Create a select custom field on the Site model
choice_set = CustomFieldChoiceSet.objects.create(
name='Choice Set 1',
extra_choices=['Bar', 'Foo']
)
cf_select = CustomField( cf_select = CustomField(
type=CustomFieldTypeChoices.TYPE_SELECT, type=CustomFieldTypeChoices.TYPE_SELECT,
name='my_field_select', name='cf2',
required=False, required=False,
choices=['Bar', 'Foo'] choice_set=choice_set
) )
cf_select.save() cf_select.save()
cf_select.content_types.set([ct]) cf_select.content_types.set([ct])
@ -242,8 +250,8 @@ class ChangeLogAPITest(APITestCase):
'name': 'Site 1', 'name': 'Site 1',
'slug': 'site-1', 'slug': 'site-1',
'custom_fields': { 'custom_fields': {
'my_field': 'ABC', 'cf1': 'ABC',
'my_field_select': 'Bar', 'cf2': 'Bar',
}, },
'tags': [ 'tags': [
{'name': 'Tag 1'}, {'name': 'Tag 1'},
@ -276,8 +284,8 @@ class ChangeLogAPITest(APITestCase):
'name': 'Site X', 'name': 'Site X',
'slug': 'site-x', 'slug': 'site-x',
'custom_fields': { 'custom_fields': {
'my_field': 'DEF', 'cf1': 'DEF',
'my_field_select': 'Foo', 'cf2': 'Foo',
}, },
'tags': [ 'tags': [
{'name': 'Tag 3'} {'name': 'Tag 3'}
@ -305,8 +313,8 @@ class ChangeLogAPITest(APITestCase):
name='Site 1', name='Site 1',
slug='site-1', slug='site-1',
custom_field_data={ custom_field_data={
'my_field': 'ABC', 'cf1': 'ABC',
'my_field_select': 'Bar' 'cf2': 'Bar'
} }
) )
site.save() site.save()
@ -323,8 +331,8 @@ class ChangeLogAPITest(APITestCase):
self.assertEqual(oc.changed_object, None) self.assertEqual(oc.changed_object, None)
self.assertEqual(oc.object_repr, site.name) self.assertEqual(oc.object_repr, site.name)
self.assertEqual(oc.action, ObjectChangeActionChoices.ACTION_DELETE) self.assertEqual(oc.action, ObjectChangeActionChoices.ACTION_DELETE)
self.assertEqual(oc.prechange_data['custom_fields']['my_field'], 'ABC') self.assertEqual(oc.prechange_data['custom_fields']['cf1'], 'ABC')
self.assertEqual(oc.prechange_data['custom_fields']['my_field_select'], 'Bar') self.assertEqual(oc.prechange_data['custom_fields']['cf2'], 'Bar')
self.assertEqual(oc.prechange_data['tags'], ['Tag 1', 'Tag 2']) self.assertEqual(oc.prechange_data['tags'], ['Tag 1', 'Tag 2'])
self.assertEqual(oc.postchange_data, None) self.assertEqual(oc.postchange_data, None)

View File

@ -10,7 +10,7 @@ from dcim.filtersets import SiteFilterSet
from dcim.forms import SiteImportForm from dcim.forms import SiteImportForm
from dcim.models import Manufacturer, Rack, Site from dcim.models import Manufacturer, Rack, Site
from extras.choices import * from extras.choices import *
from extras.models import CustomField from extras.models import CustomField, CustomFieldChoiceSet
from ipam.models import VLAN from ipam.models import VLAN
from utilities.testing import APITestCase, TestCase from utilities.testing import APITestCase, TestCase
from virtualization.models import VirtualMachine from virtualization.models import VirtualMachine
@ -272,12 +272,18 @@ class CustomFieldTest(TestCase):
CHOICES = ('Option A', 'Option B', 'Option C') CHOICES = ('Option A', 'Option B', 'Option C')
value = CHOICES[1] value = CHOICES[1]
# Create a set of custom field choices
choice_set = CustomFieldChoiceSet.objects.create(
name='Custom Field Choice Set 1',
extra_choices=CHOICES
)
# Create a custom field & check that initial value is null # Create a custom field & check that initial value is null
cf = CustomField.objects.create( cf = CustomField.objects.create(
name='select_field', name='select_field',
type=CustomFieldTypeChoices.TYPE_SELECT, type=CustomFieldTypeChoices.TYPE_SELECT,
required=False, required=False,
choices=CHOICES choice_set=choice_set
) )
cf.content_types.set([self.object_type]) cf.content_types.set([self.object_type])
instance = Site.objects.first() instance = Site.objects.first()
@ -299,12 +305,18 @@ class CustomFieldTest(TestCase):
CHOICES = ['Option A', 'Option B', 'Option C'] CHOICES = ['Option A', 'Option B', 'Option C']
value = [CHOICES[1], CHOICES[2]] value = [CHOICES[1], CHOICES[2]]
# Create a set of custom field choices
choice_set = CustomFieldChoiceSet.objects.create(
name='Custom Field Choice Set 1',
extra_choices=CHOICES
)
# Create a custom field & check that initial value is null # Create a custom field & check that initial value is null
cf = CustomField.objects.create( cf = CustomField.objects.create(
name='multiselect_field', name='multiselect_field',
type=CustomFieldTypeChoices.TYPE_MULTISELECT, type=CustomFieldTypeChoices.TYPE_MULTISELECT,
required=False, required=False,
choices=CHOICES choice_set=choice_set
) )
cf.content_types.set([self.object_type]) cf.content_types.set([self.object_type])
instance = Site.objects.first() instance = Site.objects.first()
@ -438,6 +450,12 @@ class CustomFieldAPITest(APITestCase):
) )
VLAN.objects.bulk_create(vlans) VLAN.objects.bulk_create(vlans)
# Create a set of custom field choices
choice_set = CustomFieldChoiceSet.objects.create(
name='Custom Field Choice Set 1',
extra_choices=('Foo', 'Bar', 'Baz')
)
custom_fields = ( custom_fields = (
CustomField(type=CustomFieldTypeChoices.TYPE_TEXT, name='text_field', default='foo'), CustomField(type=CustomFieldTypeChoices.TYPE_TEXT, name='text_field', default='foo'),
CustomField(type=CustomFieldTypeChoices.TYPE_LONGTEXT, name='longtext_field', default='ABC'), CustomField(type=CustomFieldTypeChoices.TYPE_LONGTEXT, name='longtext_field', default='ABC'),
@ -452,17 +470,13 @@ class CustomFieldAPITest(APITestCase):
type=CustomFieldTypeChoices.TYPE_SELECT, type=CustomFieldTypeChoices.TYPE_SELECT,
name='select_field', name='select_field',
default='Foo', default='Foo',
choices=( choice_set=choice_set
'Foo', 'Bar', 'Baz'
)
), ),
CustomField( CustomField(
type=CustomFieldTypeChoices.TYPE_MULTISELECT, type=CustomFieldTypeChoices.TYPE_MULTISELECT,
name='multiselect_field', name='multiselect_field',
default=['Foo'], default=['Foo'],
choices=( choice_set=choice_set
'Foo', 'Bar', 'Baz'
)
), ),
CustomField( CustomField(
type=CustomFieldTypeChoices.TYPE_OBJECT, type=CustomFieldTypeChoices.TYPE_OBJECT,
@ -1024,6 +1038,12 @@ class CustomFieldImportTest(TestCase):
@classmethod @classmethod
def setUpTestData(cls): def setUpTestData(cls):
# Create a set of custom field choices
choice_set = CustomFieldChoiceSet.objects.create(
name='Custom Field Choice Set 1',
extra_choices=('Choice A', 'Choice B', 'Choice C')
)
custom_fields = ( custom_fields = (
CustomField(name='text', type=CustomFieldTypeChoices.TYPE_TEXT), CustomField(name='text', type=CustomFieldTypeChoices.TYPE_TEXT),
CustomField(name='longtext', type=CustomFieldTypeChoices.TYPE_LONGTEXT), CustomField(name='longtext', type=CustomFieldTypeChoices.TYPE_LONGTEXT),
@ -1034,12 +1054,8 @@ class CustomFieldImportTest(TestCase):
CustomField(name='datetime', type=CustomFieldTypeChoices.TYPE_DATETIME), CustomField(name='datetime', type=CustomFieldTypeChoices.TYPE_DATETIME),
CustomField(name='url', type=CustomFieldTypeChoices.TYPE_URL), CustomField(name='url', type=CustomFieldTypeChoices.TYPE_URL),
CustomField(name='json', type=CustomFieldTypeChoices.TYPE_JSON), CustomField(name='json', type=CustomFieldTypeChoices.TYPE_JSON),
CustomField(name='select', type=CustomFieldTypeChoices.TYPE_SELECT, choices=[ CustomField(name='select', type=CustomFieldTypeChoices.TYPE_SELECT, choice_set=choice_set),
'Choice A', 'Choice B', 'Choice C', CustomField(name='multiselect', type=CustomFieldTypeChoices.TYPE_MULTISELECT, choice_set=choice_set),
]),
CustomField(name='multiselect', type=CustomFieldTypeChoices.TYPE_MULTISELECT, choices=[
'Choice A', 'Choice B', 'Choice C',
]),
) )
for cf in custom_fields: for cf in custom_fields:
cf.save() cf.save()
@ -1203,6 +1219,11 @@ class CustomFieldModelFilterTest(TestCase):
Manufacturer(name='Manufacturer 4', slug='manufacturer-4'), Manufacturer(name='Manufacturer 4', slug='manufacturer-4'),
)) ))
choice_set = CustomFieldChoiceSet.objects.create(
name='Custom Field Choice Set 1',
extra_choices=['A', 'B', 'C', 'X']
)
# Integer filtering # Integer filtering
cf = CustomField(name='cf1', type=CustomFieldTypeChoices.TYPE_INTEGER) cf = CustomField(name='cf1', type=CustomFieldTypeChoices.TYPE_INTEGER)
cf.save() cf.save()
@ -1263,7 +1284,7 @@ class CustomFieldModelFilterTest(TestCase):
cf = CustomField( cf = CustomField(
name='cf9', name='cf9',
type=CustomFieldTypeChoices.TYPE_SELECT, type=CustomFieldTypeChoices.TYPE_SELECT,
choices=['Foo', 'Bar', 'Baz'] choice_set=choice_set
) )
cf.save() cf.save()
cf.content_types.set([obj_type]) cf.content_types.set([obj_type])
@ -1272,7 +1293,7 @@ class CustomFieldModelFilterTest(TestCase):
cf = CustomField( cf = CustomField(
name='cf10', name='cf10',
type=CustomFieldTypeChoices.TYPE_MULTISELECT, type=CustomFieldTypeChoices.TYPE_MULTISELECT,
choices=['A', 'B', 'C', 'X'] choice_set=choice_set
) )
cf.save() cf.save()
cf.content_types.set([obj_type]) cf.content_types.set([obj_type])
@ -1305,7 +1326,7 @@ class CustomFieldModelFilterTest(TestCase):
'cf6': '2016-06-26', 'cf6': '2016-06-26',
'cf7': 'http://a.example.com', 'cf7': 'http://a.example.com',
'cf8': 'http://a.example.com', 'cf8': 'http://a.example.com',
'cf9': 'Foo', 'cf9': 'A',
'cf10': ['A', 'X'], 'cf10': ['A', 'X'],
'cf11': manufacturers[0].pk, 'cf11': manufacturers[0].pk,
'cf12': [manufacturers[0].pk, manufacturers[3].pk], 'cf12': [manufacturers[0].pk, manufacturers[3].pk],
@ -1319,7 +1340,7 @@ class CustomFieldModelFilterTest(TestCase):
'cf6': '2016-06-27', 'cf6': '2016-06-27',
'cf7': 'http://b.example.com', 'cf7': 'http://b.example.com',
'cf8': 'http://b.example.com', 'cf8': 'http://b.example.com',
'cf9': 'Bar', 'cf9': 'B',
'cf10': ['B', 'X'], 'cf10': ['B', 'X'],
'cf11': manufacturers[1].pk, 'cf11': manufacturers[1].pk,
'cf12': [manufacturers[1].pk, manufacturers[3].pk], 'cf12': [manufacturers[1].pk, manufacturers[3].pk],
@ -1333,7 +1354,7 @@ class CustomFieldModelFilterTest(TestCase):
'cf6': '2016-06-28', 'cf6': '2016-06-28',
'cf7': 'http://c.example.com', 'cf7': 'http://c.example.com',
'cf8': 'http://c.example.com', 'cf8': 'http://c.example.com',
'cf9': 'Baz', 'cf9': 'C',
'cf10': ['C', 'X'], 'cf10': ['C', 'X'],
'cf11': manufacturers[2].pk, 'cf11': manufacturers[2].pk,
'cf12': [manufacturers[2].pk, manufacturers[3].pk], 'cf12': [manufacturers[2].pk, manufacturers[3].pk],
@ -1399,7 +1420,7 @@ class CustomFieldModelFilterTest(TestCase):
self.assertEqual(self.filterset({'cf_cf8': ['example.com']}, self.queryset).qs.count(), 3) self.assertEqual(self.filterset({'cf_cf8': ['example.com']}, self.queryset).qs.count(), 3)
def test_filter_select(self): def test_filter_select(self):
self.assertEqual(self.filterset({'cf_cf9': ['Foo', 'Bar']}, self.queryset).qs.count(), 2) self.assertEqual(self.filterset({'cf_cf9': ['A', 'B']}, self.queryset).qs.count(), 2)
def test_filter_multiselect(self): def test_filter_multiselect(self):
self.assertEqual(self.filterset({'cf_cf10': ['A', 'B']}, self.queryset).qs.count(), 2) self.assertEqual(self.filterset({'cf_cf10': ['A', 'B']}, self.queryset).qs.count(), 2)

View File

@ -27,7 +27,11 @@ class CustomFieldTestCase(TestCase, BaseFilterSetTests):
@classmethod @classmethod
def setUpTestData(cls): def setUpTestData(cls):
content_types = ContentType.objects.filter(model__in=['site', 'rack', 'device']) choice_sets = (
CustomFieldChoiceSet(name='Choice Set 1', extra_choices=['A', 'B', 'C']),
CustomFieldChoiceSet(name='Choice Set 2', extra_choices=['D', 'E', 'F']),
)
CustomFieldChoiceSet.objects.bulk_create(choice_sets)
custom_fields = ( custom_fields = (
CustomField( CustomField(
@ -54,11 +58,31 @@ class CustomFieldTestCase(TestCase, BaseFilterSetTests):
filter_logic=CustomFieldFilterLogicChoices.FILTER_DISABLED, filter_logic=CustomFieldFilterLogicChoices.FILTER_DISABLED,
ui_visibility=CustomFieldVisibilityChoices.VISIBILITY_HIDDEN ui_visibility=CustomFieldVisibilityChoices.VISIBILITY_HIDDEN
), ),
CustomField(
name='Custom Field 4',
type=CustomFieldTypeChoices.TYPE_SELECT,
required=False,
weight=400,
filter_logic=CustomFieldFilterLogicChoices.FILTER_DISABLED,
ui_visibility=CustomFieldVisibilityChoices.VISIBILITY_HIDDEN,
choice_set=choice_sets[0]
),
CustomField(
name='Custom Field 5',
type=CustomFieldTypeChoices.TYPE_MULTISELECT,
required=False,
weight=500,
filter_logic=CustomFieldFilterLogicChoices.FILTER_DISABLED,
ui_visibility=CustomFieldVisibilityChoices.VISIBILITY_HIDDEN,
choice_set=choice_sets[1]
),
) )
CustomField.objects.bulk_create(custom_fields) CustomField.objects.bulk_create(custom_fields)
custom_fields[0].content_types.add(content_types[0]) custom_fields[0].content_types.add(ContentType.objects.get_by_natural_key('dcim', 'site'))
custom_fields[1].content_types.add(content_types[1]) custom_fields[1].content_types.add(ContentType.objects.get_by_natural_key('dcim', 'rack'))
custom_fields[2].content_types.add(content_types[2]) custom_fields[2].content_types.add(ContentType.objects.get_by_natural_key('dcim', 'device'))
custom_fields[3].content_types.add(ContentType.objects.get_by_natural_key('dcim', 'device'))
custom_fields[4].content_types.add(ContentType.objects.get_by_natural_key('dcim', 'device'))
def test_name(self): def test_name(self):
params = {'name': ['Custom Field 1', 'Custom Field 2']} params = {'name': ['Custom Field 1', 'Custom Field 2']}
@ -67,7 +91,7 @@ class CustomFieldTestCase(TestCase, BaseFilterSetTests):
def test_content_types(self): def test_content_types(self):
params = {'content_types': 'dcim.site'} params = {'content_types': 'dcim.site'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
params = {'content_type_id': [ContentType.objects.get_for_model(Site).pk]} params = {'content_type_id': [ContentType.objects.get_by_natural_key('dcim', 'site').pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
def test_required(self): def test_required(self):
@ -86,6 +110,34 @@ class CustomFieldTestCase(TestCase, BaseFilterSetTests):
params = {'ui_visibility': CustomFieldVisibilityChoices.VISIBILITY_READ_WRITE} params = {'ui_visibility': CustomFieldVisibilityChoices.VISIBILITY_READ_WRITE}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
def test_choice_set(self):
params = {'choice_set': ['Choice Set 1', 'Choice Set 2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'choice_set_id': CustomFieldChoiceSet.objects.values_list('pk', flat=True)}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
class CustomFieldChoiceSetTestCase(TestCase, BaseFilterSetTests):
queryset = CustomFieldChoiceSet.objects.all()
filterset = CustomFieldChoiceSetFilterSet
@classmethod
def setUpTestData(cls):
choice_sets = (
CustomFieldChoiceSet(name='Choice Set 1', extra_choices=['A', 'B', 'C']),
CustomFieldChoiceSet(name='Choice Set 2', extra_choices=['D', 'E', 'F']),
CustomFieldChoiceSet(name='Choice Set 3', extra_choices=['G', 'H', 'I']),
)
CustomFieldChoiceSet.objects.bulk_create(choice_sets)
def test_name(self):
params = {'name': ['Choice Set 1', 'Choice Set 2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_choice(self):
params = {'choice': ['A', 'D']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
class WebhookTestCase(TestCase, BaseFilterSetTests): class WebhookTestCase(TestCase, BaseFilterSetTests):
queryset = Webhook.objects.all() queryset = Webhook.objects.all()

View File

@ -5,7 +5,7 @@ from dcim.forms import SiteForm
from dcim.models import Site from dcim.models import Site
from extras.choices import CustomFieldTypeChoices from extras.choices import CustomFieldTypeChoices
from extras.forms import SavedFilterForm from extras.forms import SavedFilterForm
from extras.models import CustomField from extras.models import CustomField, CustomFieldChoiceSet
class CustomFieldModelFormTest(TestCase): class CustomFieldModelFormTest(TestCase):
@ -13,7 +13,10 @@ class CustomFieldModelFormTest(TestCase):
@classmethod @classmethod
def setUpTestData(cls): def setUpTestData(cls):
obj_type = ContentType.objects.get_for_model(Site) obj_type = ContentType.objects.get_for_model(Site)
CHOICES = ('A', 'B', 'C') choice_set = CustomFieldChoiceSet.objects.create(
name='Custom Field Choice Set 1',
extra_choices=('A', 'B', 'C')
)
cf_text = CustomField.objects.create(name='text', type=CustomFieldTypeChoices.TYPE_TEXT) cf_text = CustomField.objects.create(name='text', type=CustomFieldTypeChoices.TYPE_TEXT)
cf_text.content_types.set([obj_type]) cf_text.content_types.set([obj_type])
@ -42,13 +45,17 @@ class CustomFieldModelFormTest(TestCase):
cf_json = CustomField.objects.create(name='json', type=CustomFieldTypeChoices.TYPE_JSON) cf_json = CustomField.objects.create(name='json', type=CustomFieldTypeChoices.TYPE_JSON)
cf_json.content_types.set([obj_type]) cf_json.content_types.set([obj_type])
cf_select = CustomField.objects.create(name='select', type=CustomFieldTypeChoices.TYPE_SELECT, choices=CHOICES) cf_select = CustomField.objects.create(
name='select',
type=CustomFieldTypeChoices.TYPE_SELECT,
choice_set=choice_set
)
cf_select.content_types.set([obj_type]) cf_select.content_types.set([obj_type])
cf_multiselect = CustomField.objects.create( cf_multiselect = CustomField.objects.create(
name='multiselect', name='multiselect',
type=CustomFieldTypeChoices.TYPE_MULTISELECT, type=CustomFieldTypeChoices.TYPE_MULTISELECT,
choices=CHOICES choice_set=choice_set
) )
cf_multiselect.content_types.set([obj_type]) cf_multiselect.content_types.set([obj_type])

View File

@ -21,6 +21,11 @@ class CustomFieldTestCase(ViewTestCases.PrimaryObjectViewTestCase):
def setUpTestData(cls): def setUpTestData(cls):
site_ct = ContentType.objects.get_for_model(Site) site_ct = ContentType.objects.get_for_model(Site)
CustomFieldChoiceSet.objects.create(
name='Choice Set 1',
extra_choices=('A', 'B', 'C')
)
custom_fields = ( custom_fields = (
CustomField(name='field1', label='Field 1', type=CustomFieldTypeChoices.TYPE_TEXT), CustomField(name='field1', label='Field 1', type=CustomFieldTypeChoices.TYPE_TEXT),
CustomField(name='field2', label='Field 2', type=CustomFieldTypeChoices.TYPE_TEXT), CustomField(name='field2', label='Field 2', type=CustomFieldTypeChoices.TYPE_TEXT),
@ -44,10 +49,10 @@ class CustomFieldTestCase(ViewTestCases.PrimaryObjectViewTestCase):
} }
cls.csv_data = ( cls.csv_data = (
'name,label,type,content_types,object_type,weight,search_weight,filter_logic,choices,validation_minimum,validation_maximum,validation_regex,ui_visibility', 'name,label,type,content_types,object_type,weight,search_weight,filter_logic,choice_set,validation_minimum,validation_maximum,validation_regex,ui_visibility',
'field4,Field 4,text,dcim.site,,100,1000,exact,,,,[a-z]{3},read-write', 'field4,Field 4,text,dcim.site,,100,1000,exact,,,,[a-z]{3},read-write',
'field5,Field 5,integer,dcim.site,,100,2000,exact,,1,100,,read-write', 'field5,Field 5,integer,dcim.site,,100,2000,exact,,1,100,,read-write',
'field6,Field 6,select,dcim.site,,100,3000,exact,"A,B,C",,,,read-write', 'field6,Field 6,select,dcim.site,,100,3000,exact,Choice Set 1,,,,read-write',
'field7,Field 7,object,dcim.site,dcim.region,100,4000,exact,,,,,read-write', 'field7,Field 7,object,dcim.site,dcim.region,100,4000,exact,,,,,read-write',
) )
@ -64,6 +69,43 @@ class CustomFieldTestCase(ViewTestCases.PrimaryObjectViewTestCase):
} }
class CustomFieldChoiceSetTestCase(ViewTestCases.PrimaryObjectViewTestCase):
model = CustomFieldChoiceSet
@classmethod
def setUpTestData(cls):
choice_sets = (
CustomFieldChoiceSet(name='Choice Set 1', extra_choices=['1A', '1B', '1C', '1D', '1E']),
CustomFieldChoiceSet(name='Choice Set 2', extra_choices=['2A', '2B', '2C', '2D', '2E']),
CustomFieldChoiceSet(name='Choice Set 3', extra_choices=['3A', '3B', '3C', '3D', '3E']),
)
CustomFieldChoiceSet.objects.bulk_create(choice_sets)
cls.form_data = {
'name': 'Choice Set X',
'extra_choices': 'X1,X2,X3,X4,X5',
}
cls.csv_data = (
'name,extra_choices',
'Choice Set 4,"4A,4B,4C,4D,4E"',
'Choice Set 5,"5A,5B,5C,5D,5E"',
'Choice Set 6,"6A,6B,6C,6D,6E"',
)
cls.csv_update_data = (
'id,extra_choices',
f'{choice_sets[0].pk},"1X,1Y,1Z"',
f'{choice_sets[1].pk},"2X,2Y,2Z"',
f'{choice_sets[2].pk},"3X,3Y,3Z"',
)
cls.bulk_edit_data = {
'description': 'New description',
}
class CustomLinkTestCase(ViewTestCases.PrimaryObjectViewTestCase): class CustomLinkTestCase(ViewTestCases.PrimaryObjectViewTestCase):
model = CustomLink model = CustomLink

View File

@ -15,6 +15,14 @@ urlpatterns = [
path('custom-fields/delete/', views.CustomFieldBulkDeleteView.as_view(), name='customfield_bulk_delete'), path('custom-fields/delete/', views.CustomFieldBulkDeleteView.as_view(), name='customfield_bulk_delete'),
path('custom-fields/<int:pk>/', include(get_model_urls('extras', 'customfield'))), path('custom-fields/<int:pk>/', include(get_model_urls('extras', 'customfield'))),
# Custom field choices
path('custom-field-choices/', views.CustomFieldChoiceSetListView.as_view(), name='customfieldchoiceset_list'),
path('custom-field-choices/add/', views.CustomFieldChoiceSetEditView.as_view(), name='customfieldchoiceset_add'),
path('custom-field-choices/import/', views.CustomFieldChoiceSetBulkImportView.as_view(), name='customfieldchoiceset_import'),
path('custom-field-choices/edit/', views.CustomFieldChoiceSetBulkEditView.as_view(), name='customfieldchoiceset_bulk_edit'),
path('custom-field-choices/delete/', views.CustomFieldChoiceSetBulkDeleteView.as_view(), name='customfieldchoiceset_bulk_delete'),
path('custom-field-choices/<int:pk>/', include(get_model_urls('extras', 'customfieldchoiceset'))),
# Custom links # Custom links
path('custom-links/', views.CustomLinkListView.as_view(), name='customlink_list'), path('custom-links/', views.CustomLinkListView.as_view(), name='customlink_list'),
path('custom-links/add/', views.CustomLinkEditView.as_view(), name='customlink_add'), path('custom-links/add/', views.CustomLinkEditView.as_view(), name='customlink_add'),

View File

@ -34,7 +34,7 @@ from .scripts import run_script
# #
class CustomFieldListView(generic.ObjectListView): class CustomFieldListView(generic.ObjectListView):
queryset = CustomField.objects.all() queryset = CustomField.objects.select_related('choice_set')
filterset = filtersets.CustomFieldFilterSet filterset = filtersets.CustomFieldFilterSet
filterset_form = forms.CustomFieldFilterForm filterset_form = forms.CustomFieldFilterForm
table = tables.CustomFieldTable table = tables.CustomFieldTable
@ -42,38 +42,83 @@ class CustomFieldListView(generic.ObjectListView):
@register_model_view(CustomField) @register_model_view(CustomField)
class CustomFieldView(generic.ObjectView): class CustomFieldView(generic.ObjectView):
queryset = CustomField.objects.all() queryset = CustomField.objects.select_related('choice_set')
@register_model_view(CustomField, 'edit') @register_model_view(CustomField, 'edit')
class CustomFieldEditView(generic.ObjectEditView): class CustomFieldEditView(generic.ObjectEditView):
queryset = CustomField.objects.all() queryset = CustomField.objects.select_related('choice_set')
form = forms.CustomFieldForm form = forms.CustomFieldForm
@register_model_view(CustomField, 'delete') @register_model_view(CustomField, 'delete')
class CustomFieldDeleteView(generic.ObjectDeleteView): class CustomFieldDeleteView(generic.ObjectDeleteView):
queryset = CustomField.objects.all() queryset = CustomField.objects.select_related('choice_set')
class CustomFieldBulkImportView(generic.BulkImportView): class CustomFieldBulkImportView(generic.BulkImportView):
queryset = CustomField.objects.all() queryset = CustomField.objects.select_related('choice_set')
model_form = forms.CustomFieldImportForm model_form = forms.CustomFieldImportForm
class CustomFieldBulkEditView(generic.BulkEditView): class CustomFieldBulkEditView(generic.BulkEditView):
queryset = CustomField.objects.all() queryset = CustomField.objects.select_related('choice_set')
filterset = filtersets.CustomFieldFilterSet filterset = filtersets.CustomFieldFilterSet
table = tables.CustomFieldTable table = tables.CustomFieldTable
form = forms.CustomFieldBulkEditForm form = forms.CustomFieldBulkEditForm
class CustomFieldBulkDeleteView(generic.BulkDeleteView): class CustomFieldBulkDeleteView(generic.BulkDeleteView):
queryset = CustomField.objects.all() queryset = CustomField.objects.select_related('choice_set')
filterset = filtersets.CustomFieldFilterSet filterset = filtersets.CustomFieldFilterSet
table = tables.CustomFieldTable table = tables.CustomFieldTable
#
# Custom field choices
#
class CustomFieldChoiceSetListView(generic.ObjectListView):
queryset = CustomFieldChoiceSet.objects.all()
filterset = filtersets.CustomFieldChoiceSetFilterSet
filterset_form = forms.CustomFieldChoiceSetFilterForm
table = tables.CustomFieldChoiceSetTable
@register_model_view(CustomFieldChoiceSet)
class CustomFieldChoiceSetView(generic.ObjectView):
queryset = CustomFieldChoiceSet.objects.all()
@register_model_view(CustomFieldChoiceSet, 'edit')
class CustomFieldChoiceSetEditView(generic.ObjectEditView):
queryset = CustomFieldChoiceSet.objects.all()
form = forms.CustomFieldChoiceSetForm
@register_model_view(CustomFieldChoiceSet, 'delete')
class CustomFieldChoiceSetDeleteView(generic.ObjectDeleteView):
queryset = CustomFieldChoiceSet.objects.all()
class CustomFieldChoiceSetBulkImportView(generic.BulkImportView):
queryset = CustomFieldChoiceSet.objects.all()
model_form = forms.CustomFieldChoiceSetImportForm
class CustomFieldChoiceSetBulkEditView(generic.BulkEditView):
queryset = CustomFieldChoiceSet.objects.all()
filterset = filtersets.CustomFieldChoiceSetFilterSet
table = tables.CustomFieldChoiceSetTable
form = forms.CustomFieldChoiceSetBulkEditForm
class CustomFieldChoiceSetBulkDeleteView(generic.BulkDeleteView):
queryset = CustomFieldChoiceSet.objects.all()
filterset = filtersets.CustomFieldChoiceSetFilterSet
table = tables.CustomFieldChoiceSetTable
# #
# Custom links # Custom links
# #
@ -541,7 +586,7 @@ class ConfigTemplateBulkSyncDataView(generic.BulkSyncDataView):
# #
class ObjectChangeListView(generic.ObjectListView): class ObjectChangeListView(generic.ObjectListView):
queryset = ObjectChange.objects.all() queryset = ObjectChange.objects.valid_models()
filterset = filtersets.ObjectChangeFilterSet filterset = filtersets.ObjectChangeFilterSet
filterset_form = forms.ObjectChangeFilterForm filterset_form = forms.ObjectChangeFilterForm
table = tables.ObjectChangeTable table = tables.ObjectChangeTable
@ -551,10 +596,10 @@ class ObjectChangeListView(generic.ObjectListView):
@register_model_view(ObjectChange) @register_model_view(ObjectChange)
class ObjectChangeView(generic.ObjectView): class ObjectChangeView(generic.ObjectView):
queryset = ObjectChange.objects.all() queryset = ObjectChange.objects.valid_models()
def get_extra_context(self, request, instance): def get_extra_context(self, request, instance):
related_changes = ObjectChange.objects.restrict(request.user, 'view').filter( related_changes = ObjectChange.objects.valid_models().restrict(request.user, 'view').filter(
request_id=instance.request_id request_id=instance.request_id
).exclude( ).exclude(
pk=instance.pk pk=instance.pk
@ -564,7 +609,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,
) )

View File

@ -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 = []

View File

@ -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

View File

@ -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)

View File

@ -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

View File

@ -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'

View File

@ -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()])

View File

@ -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 = (

View File

@ -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):

View File

@ -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(

View File

@ -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

View File

@ -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')
# #

View File

@ -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

View File

@ -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()

View File

@ -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.'

View File

@ -1,6 +1,7 @@
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
from netbox.registry import registry from netbox.registry import registry
from utilities.choices import ButtonColorChoices
from . import * from . import *
# #
@ -288,6 +289,7 @@ CUSTOMIZATION_MENU = Menu(
label=_('Customization'), label=_('Customization'),
items=( items=(
get_model_item('extras', 'customfield', _('Custom Fields')), get_model_item('extras', 'customfield', _('Custom Fields')),
get_model_item('extras', 'customfieldchoiceset', _('Custom Field Choices')),
get_model_item('extras', 'customlink', _('Custom Links')), get_model_item('extras', 'customlink', _('Custom Links')),
get_model_item('extras', 'exporttemplate', _('Export Templates')), get_model_item('extras', 'exporttemplate', _('Export Templates')),
get_model_item('extras', 'savedfilter', _('Saved Filters')), get_model_item('extras', 'savedfilter', _('Saved Filters')),
@ -350,6 +352,56 @@ ADMIN_MENU = Menu(
label=_('Admin'), label=_('Admin'),
icon_class='mdi mdi-account-multiple', icon_class='mdi mdi-account-multiple',
groups=( groups=(
MenuGroup(
label=_('Users'),
items=(
# Proxy model for auth.User
MenuItem(
link=f'users:netboxuser_list',
link_text=_('Users'),
permissions=[f'auth.view_user'],
buttons=(
MenuItemButton(
link=f'users:netboxuser_add',
title='Add',
icon_class='mdi mdi-plus-thick',
permissions=[f'auth.add_user'],
color=ButtonColorChoices.GREEN
),
MenuItemButton(
link=f'users:netboxuser_import',
title='Import',
icon_class='mdi mdi-upload',
permissions=[f'auth.add_user'],
color=ButtonColorChoices.CYAN
)
)
),
# Proxy model for auth.Group
MenuItem(
link=f'users:netboxgroup_list',
link_text=_('Groups'),
permissions=[f'auth.view_group'],
buttons=(
MenuItemButton(
link=f'users:netboxgroup_add',
title='Add',
icon_class='mdi mdi-plus-thick',
permissions=[f'auth.add_group'],
color=ButtonColorChoices.GREEN
),
MenuItemButton(
link=f'users:netboxgroup_import',
title='Import',
icon_class='mdi mdi-upload',
permissions=[f'auth.add_group'],
color=ButtonColorChoices.CYAN
)
)
),
get_model_item('users', 'objectpermission', _('Permissions'), actions=['add']),
),
),
MenuGroup( MenuGroup(
label=_('Configuration'), label=_('Configuration'),
items=( items=(

View File

@ -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()

View File

@ -21,6 +21,7 @@ from utilities.utils import content_type_identifier, content_type_name, get_view
__all__ = ( __all__ = (
'ActionsColumn', 'ActionsColumn',
'ArrayColumn',
'BooleanColumn', 'BooleanColumn',
'ChoiceFieldColumn', 'ChoiceFieldColumn',
'ColorColumn', 'ColorColumn',
@ -591,3 +592,22 @@ class MarkdownColumn(tables.TemplateColumn):
def value(self, value): def value(self, value):
return value return value
class ArrayColumn(tables.Column):
"""
List array items as a comma-separated list.
"""
def __init__(self, *args, max_items=None, **kwargs):
self.max_items = max_items
super().__init__(*args, **kwargs)
def render(self, value):
if self.max_items:
# Limit the returned items to the specified maximum number
omitted = len(value) - self.max_items
value = value[:self.max_items - 1]
if omitted > 0:
value.append(f'({omitted} more)')
return ', '.join(value)

View File

@ -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

View File

@ -22,6 +22,7 @@ class ActionsMixin:
Return a tuple of actions for which the given user is permitted to do. Return a tuple of actions for which the given user is permitted to do.
""" """
model = model or self.queryset.model model = model or self.queryset.model
return [ return [
action for action in self.actions if user.has_perms([ action for action in self.actions if user.has_perms([
get_permission_for_model(model, name) for name in self.action_perms[action] get_permission_for_model(model, name) for name in self.action_perms[action]

Binary file not shown.

Binary file not shown.

View File

@ -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);
} }
} }

View File

@ -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>

View File

@ -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 %}

View File

@ -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>

View File

@ -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>

View File

@ -15,14 +15,6 @@
<th scope="row">Name</th> <th scope="row">Name</th>
<td>{{ object.name }}</td> <td>{{ object.name }}</td>
</tr> </tr>
<tr>
<th scope="row">Label</th>
<td>{{ object.label|placeholder }}</td>
</tr>
<tr>
<th scope="row">Group Name</th>
<td>{{ object.group_name|placeholder }}</td>
</tr>
<tr> <tr>
<th scope="row">Type</th> <th scope="row">Type</th>
<td> <td>
@ -30,6 +22,14 @@
{% if object.object_type %}({{ object.object_type.model|bettertitle }}){% endif %} {% if object.object_type %}({{ object.object_type.model|bettertitle }}){% endif %}
</td> </td>
</tr> </tr>
<tr>
<th scope="row">Label</th>
<td>{{ object.label|placeholder }}</td>
</tr>
<tr>
<th scope="row">Group</th>
<td>{{ object.group_name|placeholder }}</td>
</tr>
<tr> <tr>
<th scope="row">Description</th> <th scope="row">Description</th>
<td>{{ object.description|markdown|placeholder }}</td> <td>{{ object.description|markdown|placeholder }}</td>
@ -38,6 +38,27 @@
<th scope="row">Required</th> <th scope="row">Required</th>
<td>{% checkmark object.required %}</td> <td>{% checkmark object.required %}</td>
</tr> </tr>
<tr>
<th scope="row">Cloneable</th>
<td>{% checkmark object.is_cloneable %}</td>
</tr>
{% if object.choice_set %}
<tr>
<th scope="row">Choice Set</th>
<td>{{ object.choice_set|linkify }} ({{ object.choice_set.choices|length }} choices)</td>
</tr>
{% endif %}
<tr>
<th scope="row">Default Value</th>
<td>{{ object.default }}</td>
</tr>
</table>
</div>
</div>
<div class="card">
<h5 class="card-header">Behavior</h5>
<div class="card-body">
<table class="table table-hover attr-table">
<tr> <tr>
<th scope="row">Search Weight</th> <th scope="row">Search Weight</th>
<td> <td>
@ -60,33 +81,6 @@
<th scope="row">UI Visibility</th> <th scope="row">UI Visibility</th>
<td>{{ object.get_ui_visibility_display }}</td> <td>{{ object.get_ui_visibility_display }}</td>
</tr> </tr>
<tr>
<th scope="row">Cloneable</th>
<td>{% checkmark object.is_cloneable %}</td>
</tr>
</table>
</div>
</div>
<div class="card">
<h5 class="card-header">
Values
</h5>
<div class="card-body">
<table class="table table-hover attr-table">
<tr>
<th scope="row">Default Value</th>
<td>{{ object.default }}</td>
</tr>
<tr>
<th scope="row">Choices</th>
<td>
{% if object.choices %}
{{ object.choices|join:", " }}
{% else %}
{{ ''|placeholder }}
{% endif %}
</td>
</tr>
</table> </table>
</div> </div>
</div> </div>
@ -94,9 +88,7 @@
</div> </div>
<div class="col col-md-6"> <div class="col col-md-6">
<div class="card"> <div class="card">
<h5 class="card-header"> <h5 class="card-header">Object Types</h5>
Assigned Models
</h5>
<div class="card-body"> <div class="card-body">
<table class="table table-hover attr-table"> <table class="table table-hover attr-table">
{% for ct in object.content_types.all %} {% for ct in object.content_types.all %}
@ -108,9 +100,7 @@
</div> </div>
</div> </div>
<div class="card"> <div class="card">
<h5 class="card-header"> <h5 class="card-header">Validation Rules</h5>
Validation Rules
</h5>
<div class="card-body"> <div class="card-body">
<table class="table table-hover attr-table"> <table class="table table-hover attr-table">
<tr> <tr>
@ -138,8 +128,8 @@
</div> </div>
</div> </div>
<div class="row"> <div class="row">
<div class="col col-md-12"> <div class="col col-md-12">
{% plugin_full_width_page object %} {% plugin_full_width_page object %}
</div> </div>
</div> </div>
{% endblock %} {% endblock %}

View File

@ -0,0 +1,64 @@
{% extends 'generic/object.html' %}
{% load helpers %}
{% load plugins %}
{% block content %}
<div class="row mb-3">
<div class="col col-md-6">
<div class="card">
<h5 class="card-header">Custom Field Choice Set</h5>
<div class="card-body">
<table class="table table-hover attr-table">
<tr>
<th scope="row">Name</th>
<td>{{ object.name }}</td>
</tr>
<tr>
<th scope="row">Description</th>
<td>{{ object.description|markdown|placeholder }}</td>
</tr>
<tr>
<th scope="row">Choices</th>
<td>{{ object.choices|length }}</td>
</tr>
<tr>
<th scope="row">Order Alphabetically</th>
<td>{% checkmark object.order_alphabetically %}</td>
</tr>
<tr>
<th scope="row">Used by</th>
<td>
<ul class="list-unstyled mb-0">
{% for cf in object.choices_for.all %}
<li>{{ cf|linkify }}</li>
{% endfor %}
</ul>
</td>
</tr>
</table>
</div>
</div>
{% plugin_left_page object %}
</div>
<div class="col col-md-6">
<div class="card">
<h5 class="card-header">Choices</h5>
<div class="card-body">
<table class="table table-hover attr-table">
{% for choice in object.choices %}
<tr>
<td>{{ choice }}</td>
</tr>
{% endfor %}
</table>
</div>
</div>
{% plugin_right_page object %}
</div>
</div>
<div class="row">
<div class="col col-md-12">
{% plugin_full_width_page object %}
</div>
</div>
{% endblock %}

View File

@ -38,71 +38,77 @@
</h5> </h5>
<div class="card-body"> <div class="card-body">
{% include 'inc/sync_warning.html' with object=module %} {% include 'inc/sync_warning.html' with object=module %}
<table class="table table-hover table-headings reports"> {% if module.reports %}
<thead> <table class="table table-hover table-headings reports">
<tr> <thead>
<th width="250">Name</th> <tr>
<th>Description</th> <th width="250">Name</th>
<th>Last Run</th> <th>Description</th>
<th>Status</th> <th>Last Run</th>
<th width="120"></th> <th>Status</th>
</tr> <th width="120"></th>
</thead> </tr>
<tbody> </thead>
{% with jobs=module.get_latest_jobs %} <tbody>
{% for report_name, report in module.reports.items %} {% with jobs=module.get_latest_jobs %}
{% with last_job=jobs|get_key:report.name %} {% for report_name, report in module.reports.items %}
<tr> {% with last_job=jobs|get_key:report.class_name %}
<td>
<a href="{% url 'extras:report' module=module.python_name name=report.class_name %}" id="{{ report.module }}.{{ report.class_name }}">{{ report.name }}</a>
</td>
<td>{{ report.description|markdown|placeholder }}</td>
{% if last_job %}
<td>
<a href="{% url 'extras:report_result' job_pk=last_job.pk %}">{{ last_job.created|annotated_date }}</a>
</td>
<td>
{% badge last_job.get_status_display last_job.get_status_color %}
</td>
{% else %}
<td class="text-muted">Never</td>
<td>{{ ''|placeholder }}</td>
{% endif %}
<td>
{% if perms.extras.run_report %}
<div class="float-end noprint">
<form action="{% url 'extras:report' module=report.module name=report.class_name %}" method="post">
{% csrf_token %}
<button type="submit" name="_run" class="btn btn-primary btn-sm" style="width: 110px">
{% if last_job %}
<i class="mdi mdi-replay"></i> Run Again
{% else %}
<i class="mdi mdi-play"></i> Run Report
{% endif %}
</button>
</form>
</div>
{% endif %}
</td>
</tr>
{% for method, stats in last_job.data.items %}
<tr> <tr>
<td colspan="4" class="method"> <td>
<span class="ps-3">{{ method }}</span> <a href="{% url 'extras:report' module=module.python_name name=report.class_name %}" id="{{ report.module }}.{{ report.class_name }}">{{ report.name }}</a>
</td> </td>
<td class="text-end text-nowrap report-stats"> <td>{{ report.description|markdown|placeholder }}</td>
<span class="badge bg-success">{{ stats.success }}</span> {% if last_job %}
<span class="badge bg-info">{{ stats.info }}</span> <td>
<span class="badge bg-warning">{{ stats.warning }}</span> <a href="{% url 'extras:report_result' job_pk=last_job.pk %}">{{ last_job.created|annotated_date }}</a>
<span class="badge bg-danger">{{ stats.failure }}</span> </td>
<td>
{% badge last_job.get_status_display last_job.get_status_color %}
</td>
{% else %}
<td class="text-muted">Never</td>
<td>{{ ''|placeholder }}</td>
{% endif %}
<td>
{% if perms.extras.run_report %}
<div class="float-end noprint">
<form action="{% url 'extras:report' module=report.module name=report.class_name %}" method="post">
{% csrf_token %}
<button type="submit" name="_run" class="btn btn-primary btn-sm" style="width: 110px">
{% if last_job %}
<i class="mdi mdi-replay"></i> Run Again
{% else %}
<i class="mdi mdi-play"></i> Run Report
{% endif %}
</button>
</form>
</div>
{% endif %}
</td> </td>
</tr> </tr>
{% endfor %} {% for method, stats in last_job.data.items %}
{% endwith %} <tr>
{% endfor %} <td colspan="4" class="method">
{% endwith %} <span class="ps-3">{{ method }}</span>
</tbody> </td>
</table> <td class="text-end text-nowrap report-stats">
<span class="badge bg-success">{{ stats.success }}</span>
<span class="badge bg-info">{{ stats.info }}</span>
<span class="badge bg-warning">{{ stats.warning }}</span>
<span class="badge bg-danger">{{ stats.failure }}</span>
</td>
</tr>
{% endfor %}
{% endwith %}
{% endfor %}
{% endwith %}
</tbody>
</table>
{% else %}
<div class="alert alert-warning" role="alert">
<i class="mdi mdi-alert"></i> Could not load reports from {{ module.name }}
</div>
{% endif %}
</div> </div>
</div> </div>
{% empty %} {% empty %}

View File

@ -15,9 +15,9 @@
<form action="" method="post" enctype="multipart/form-data" class="form form-object-edit"> <form action="" method="post" enctype="multipart/form-data" class="form form-object-edit">
{% csrf_token %} {% csrf_token %}
<div class="field-group my-4"> <div class="field-group my-4">
{% if form.requires_input %} {# Render grouped fields according to declared fieldsets #}
{# Render grouped fields according to declared fieldsets #} {% for group, fields in script.get_fieldsets %}
{% for group, fields in script.get_fieldsets %} {% if fields %}
<div class="field-group mb-5"> <div class="field-group mb-5">
<div class="row mb-2"> <div class="row mb-2">
<h5 class="offset-sm-3">{{ group }}</h5> <h5 class="offset-sm-3">{{ group }}</h5>
@ -28,14 +28,8 @@
{% endwith %} {% endwith %}
{% endfor %} {% endfor %}
</div> </div>
{% endfor %} {% endif %}
{% else %} {% endfor %}
<div class="alert alert-info">
<i class="mdi mdi-information"></i>
This script does not require any input to run.
</div>
{% render_form form %}
{% endif %}
</div> </div>
<div class="float-end"> <div class="float-end">
<a href="{% url 'extras:script_list' %}" class="btn btn-outline-danger">Cancel</a> <a href="{% url 'extras:script_list' %}" class="btn btn-outline-danger">Cancel</a>

View File

@ -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>

View File

@ -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>

View File

@ -2,10 +2,12 @@
{% load helpers %} {% load helpers %}
{% block extra_controls %} {% block extra_controls %}
{% if perms.tenancy.add_contactassignment %} {% if perms.tenancy.add_contactassignment %}
<a href="{% url 'tenancy:contactassignment_add' %}?content_type={{ object|content_type_id }}&object_id={{ object.pk }}&return_url={{ object.get_absolute_url }}" class="btn btn-primary btn-sm"> {% with viewname=object|viewname:"contacts" %}
<a href="{% url 'tenancy:contactassignment_add' %}?content_type={{ object|content_type_id }}&object_id={{ object.pk }}&return_url={% url viewname pk=object.pk %}" class="btn btn-primary btn-sm">
<i class="mdi mdi-plus-thick" aria-hidden="true"></i> Add a contact <i class="mdi mdi-plus-thick" aria-hidden="true"></i> Add a contact
</a> </a>
{% endwith %}
{% endif %} {% endif %}
{% endblock %} {% endblock %}

View File

@ -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>

View File

@ -1,4 +1,4 @@
{% extends 'users/base.html' %} {% extends 'users/account/base.html' %}
{% load helpers %} {% load helpers %}
{% load render_table from django_tables2 %} {% load render_table from django_tables2 %}

View File

@ -1,23 +1,24 @@
{% extends 'base/layout.html' %} {% extends 'base/layout.html' %}
{% load i18n %}
{% block tabs %} {% block tabs %}
<ul class="nav nav-tabs px-3"> <ul class="nav nav-tabs px-3">
<li role="presentation" class="nav-item"> <li role="presentation" class="nav-item">
<a class="nav-link{% if active_tab == 'profile' %} active{% endif %}" href="{% url 'users:profile' %}">Profile</a> <a class="nav-link{% if active_tab == 'profile' %} active{% endif %}" href="{% url 'users:profile' %}">{% trans "Profile" %}</a>
</li> </li>
<li role="presentation" class="nav-item"> <li role="presentation" class="nav-item">
<a class="nav-link{% if active_tab == 'bookmarks' %} active{% endif %}" href="{% url 'users:bookmarks' %}">Bookmarks</a> <a class="nav-link{% if active_tab == 'bookmarks' %} active{% endif %}" href="{% url 'users:bookmarks' %}">{% trans "Bookmarks" %}</a>
</li> </li>
<li role="presentation" class="nav-item"> <li role="presentation" class="nav-item">
<a class="nav-link{% if active_tab == 'preferences' %} active{% endif %}" href="{% url 'users:preferences' %}">Preferences</a> <a class="nav-link{% if active_tab == 'preferences' %} active{% endif %}" href="{% url 'users:preferences' %}">{% trans "Preferences" %}</a>
</li> </li>
{% if not request.user.ldap_username %} {% if not request.user.ldap_username %}
<li role="presentation" class="nav-item"> <li role="presentation" class="nav-item">
<a class="nav-link{% if active_tab == 'password' %} active{% endif %}" href="{% url 'users:change_password' %}">Password</a> <a class="nav-link{% if active_tab == 'password' %} active{% endif %}" href="{% url 'users:change_password' %}">{% trans "Password" %}</a>
</li> </li>
{% endif %} {% endif %}
<li role="presentation" class="nav-item"> <li role="presentation" class="nav-item">
<a class="nav-link{% if active_tab == 'api-tokens' %} active{% endif %}" href="{% url 'users:token_list' %}">API Tokens</a> <a class="nav-link{% if active_tab == 'api-tokens' %} active{% endif %}" href="{% url 'users:token_list' %}">{% trans "API Tokens" %}</a>
</li> </li>
</ul> </ul>
{% endblock %} {% endblock %}

View File

@ -1,4 +1,4 @@
{% extends 'users/base.html' %} {% extends 'users/account/base.html' %}
{% load buttons %} {% load buttons %}
{% load helpers %} {% load helpers %}
{% load render_table from django_tables2 %} {% load render_table from django_tables2 %}

View File

@ -1,4 +1,4 @@
{% extends 'users/base.html' %} {% extends 'users/account/base.html' %}
{% load form_helpers %} {% load form_helpers %}
{% block title %}Change Password{% endblock %} {% block title %}Change Password{% endblock %}

View File

@ -1,4 +1,4 @@
{% extends 'users/base.html' %} {% extends 'users/account/base.html' %}
{% load helpers %} {% load helpers %}
{% load form_helpers %} {% load form_helpers %}

View File

@ -1,4 +1,4 @@
{% extends 'users/base.html' %} {% extends 'users/account/base.html' %}
{% load helpers %} {% load helpers %}
{% load render_table from django_tables2 %} {% load render_table from django_tables2 %}

View File

@ -0,0 +1,48 @@
{% extends 'generic/object.html' %}
{% load i18n %}
{% load helpers %}
{% load render_table from django_tables2 %}
{% block title %}{% trans "Group" %} {{ object.name }}{% endblock %}
{% block subtitle %}{% endblock %}
{% block content %}
<div class="row mb-3">
<div class="col-md-6">
<div class="card">
<h5 class="card-header">{% trans "Group" %}</h5>
<div class="card-body">
<table class="table table-hover attr-table">
<tr>
<th scope="row">{% trans "Name" %}</th>
<td>{{ object.name }}</td>
</tr>
</table>
</div>
</div>
</div>
<div class="col-md-6">
<div class="card">
<h5 class="card-header">{% trans "Users" %}</h5>
<div class="list-group list-group-flush">
{% for user in object.user_set.all %}
<a href="{% url 'users:netboxuser' pk=user.pk %}" class="list-group-item list-group-item-action">{{ user }}</a>
{% empty %}
<div class="list-group-item text-muted">{% trans "None" %}</div>
{% endfor %}
</div>
</div>
<div class="card">
<h5 class="card-header">{% trans "Assigned Permissions" %}</h5>
<div class="list-group list-group-flush">
{% for perm in object.object_permissions.all %}
<a href="{% url 'users:objectpermission' pk=perm.pk %}" class="list-group-item list-group-item-action">{{ perm }}</a>
{% empty %}
<div class="list-group-item text-muted">{% trans "None" %}</div>
{% endfor %}
</div>
</div>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,97 @@
{% extends 'generic/object.html' %}
{% load i18n %}
{% load helpers %}
{% load render_table from django_tables2 %}
{% block title %}{% trans "Permission" %} {{ object.name }}{% endblock %}
{% block subtitle %}{% endblock %}
{% block content %}
<div class="row mb-3">
<div class="col-md-6">
<div class="card">
<h5 class="card-header">{% trans "Permission" %}</h5>
<div class="card-body">
<table class="table table-hover attr-table">
<tr>
<th scope="row">{% trans "Name" %}</th>
<td>{{ object.name }}</td>
</tr>
<tr>
<th scope="row">{% trans "Description" %}</th>
<td>{{ object.description|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Enabled" %}</th>
<td>{% checkmark object.enabled %}</td>
</tr>
</table>
</div>
</div>
<div class="card">
<h5 class="card-header">{% trans "Actions" %}</h5>
<div class="card-body">
<table class="table table-hover attr-table">
<tr>
<th scope="row">{% trans "View" %}</th>
<td>{% checkmark object.can_view %}</td>
</tr>
<tr>
<th scope="row">{% trans "Add" %}</th>
<td>{% checkmark object.can_add %}</td>
</tr>
<tr>
<th scope="row">{% trans "Change" %}</th>
<td>{% checkmark object.can_change %}</td>
</tr>
<tr>
<th scope="row">{% trans "Delete" %}</th>
<td>{% checkmark object.can_delete %}</td>
</tr>
</table>
</div>
</div>
<div class="card">
<h5 class="card-header">{% trans "Constraints" %}</h5>
<div class="card-body">
{% if object.constraints %}
<pre>{{ object.constraints|json }}</pre>
{% else %}
<span class="text-muted">None</span>
{% endif %}
</div>
</div>
</div>
<div class="col-md-6">
<div class="card">
<h5 class="card-header">{% trans "Object Types" %}</h5>
<ul class="list-group list-group-flush">
{% for user in object.object_types.all %}
<li class="list-group-item">{{ user }}</li>
{% endfor %}
</ul>
</div>
<div class="card">
<h5 class="card-header">{% trans "Assigned Users" %}</h5>
<div class="list-group list-group-flush">
{% for user in object.users.all %}
<a href="{% url 'users:netboxuser' pk=user.pk %}" class="list-group-item list-group-item-action">{{ user }}</a>
{% empty %}
<div class="list-group-item text-muted">{% trans "None" %}</div>
{% endfor %}
</div>
</div>
<div class="card">
<h5 class="card-header">{% trans "Assigned Groups" %}</h5>
<div class="list-group list-group-flush">
{% for group in object.groups.all %}
<a href="{% url 'users:netboxgroup' pk=group.pk %}" class="list-group-item list-group-item-action">{{ group }}</a>
{% empty %}
<div class="list-group-item text-muted">{% trans "None" %}</div>
{% endfor %}
</div>
</div>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,84 @@
{% extends 'generic/object.html' %}
{% load i18n %}
{% load helpers %}
{% load render_table from django_tables2 %}
{% block title %}{% trans "User" %} {{ object.username }}{% endblock %}
{% block subtitle %}{% endblock %}
{% block content %}
<div class="row mb-3">
<div class="col-md-6">
<div class="card">
<h5 class="card-header">{% trans "User" %}</h5>
<div class="card-body">
<table class="table table-hover attr-table">
<tr>
<th scope="row">{% trans "Username" %}</th>
<td>{{ object.username }}</td>
</tr>
<tr>
<th scope="row">{% trans "Full Name" %}</th>
<td>{{ object.get_full_name|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Email" %}</th>
<td>{{ object.email|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Account Created" %}</th>
<td>{{ object.date_joined|annotated_date }}</td>
</tr>
<tr>
<th scope="row">{% trans "Active" %}</th>
<td>{% checkmark object.active %}</td>
</tr>
<tr>
<th scope="row">{% trans "Staff" %}</th>
<td>{% checkmark object.is_staff %}</td>
</tr>
<tr>
<th scope="row">{% trans "Superuser" %}</th>
<td>{% checkmark object.is_superuser %}</td>
</tr>
</table>
</div>
</div>
</div>
<div class="col-md-6">
<div class="card">
<h5 class="card-header">{% trans "Assigned Groups" %}</h5>
<div class="list-group list-group-flush">
{% for group in object.groups.all %}
<a href="{% url 'users:netboxgroup' pk=group.pk %}" class="list-group-item list-group-item-action">{{ group }}</a>
{% empty %}
<div class="list-group-item text-muted">{% trans "None" %}</div>
{% endfor %}
</div>
</div>
<div class="card">
<h5 class="card-header">{% trans "Assigned Permissions" %}</h5>
<div class="list-group list-group-flush">
{% for perm in object.object_permissions.all %}
<a href="{% url 'users:objectpermission' pk=perm.pk %}" class="list-group-item list-group-item-action">{{ perm }}</a>
{% empty %}
<div class="list-group-item text-muted">{% trans "None" %}</div>
{% endfor %}
</div>
</div>
</div>
</div>
{% if perms.extras.view_objectchange %}
<div class="row">
<div class="col-md-12">
<div class="card">
<h5 class="card-header text-center">{% trans "Recent Activity" %}</h5>
<div class="card-body table-responsive">
{% render_table changelog_table 'inc/table.html' %}
</div>
</div>
</div>
</div>
{% endif %}
{% endblock %}

View File

@ -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 %}

View File

@ -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

View File

@ -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'
)

View File

@ -15,41 +15,6 @@ admin.site.unregister(Group)
admin.site.unregister(User) admin.site.unregister(User)
@admin.register(Group)
class GroupAdmin(admin.ModelAdmin):
form = forms.GroupAdminForm
list_display = ('name', 'user_count')
ordering = ('name',)
search_fields = ('name',)
inlines = [inlines.GroupObjectPermissionInline]
@staticmethod
def user_count(obj):
return obj.user_set.count()
@admin.register(User)
class UserAdmin(UserAdmin_):
list_display = [
'username', 'email', 'first_name', 'last_name', 'is_superuser', 'is_staff', 'is_active'
]
fieldsets = (
(None, {'fields': ('username', 'password', 'first_name', 'last_name', 'email')}),
('Groups', {'fields': ('groups',)}),
('Status', {
'fields': ('is_active', 'is_staff', 'is_superuser'),
}),
('Important dates', {'fields': ('last_login', 'date_joined')}),
)
filter_horizontal = ('groups',)
list_filter = ('is_active', 'is_staff', 'is_superuser', 'groups__name')
def get_inlines(self, request, obj):
if obj is not None:
return (inlines.UserObjectPermissionInline, inlines.UserConfigInline)
return ()
# #
# REST API tokens # REST API tokens
# #
@ -64,66 +29,3 @@ class TokenAdmin(admin.ModelAdmin):
def list_allowed_ips(self, obj): def list_allowed_ips(self, obj):
return obj.allowed_ips or 'Any' return obj.allowed_ips or 'Any'
list_allowed_ips.short_description = "Allowed IPs" list_allowed_ips.short_description = "Allowed IPs"
#
# Permissions
#
@admin.register(ObjectPermission)
class ObjectPermissionAdmin(admin.ModelAdmin):
actions = ('enable', 'disable')
fieldsets = (
(None, {
'fields': ('name', 'description', 'enabled')
}),
('Actions', {
'fields': (('can_view', 'can_add', 'can_change', 'can_delete'), 'actions')
}),
('Objects', {
'fields': ('object_types',)
}),
('Assignment', {
'fields': ('groups', 'users')
}),
('Constraints', {
'fields': ('constraints',),
'classes': ('monospace',)
}),
)
filter_horizontal = ('object_types', 'groups', 'users')
form = forms.ObjectPermissionForm
list_display = [
'name', 'enabled', 'list_models', 'list_users', 'list_groups', 'actions', 'constraints', 'description',
]
list_filter = [
'enabled', filters.ActionListFilter, filters.ObjectTypeListFilter, 'groups', 'users'
]
search_fields = ['actions', 'constraints', 'description', 'name']
def get_queryset(self, request):
return super().get_queryset(request).prefetch_related('object_types', 'users', 'groups')
def list_models(self, obj):
return ', '.join([f"{ct}" for ct in obj.object_types.all()])
list_models.short_description = 'Models'
def list_users(self, obj):
return ', '.join([u.username for u in obj.users.all()])
list_users.short_description = 'Users'
def list_groups(self, obj):
return ', '.join([g.name for g in obj.groups.all()])
list_groups.short_description = 'Groups'
#
# Admin actions
#
def enable(self, request, queryset):
updated = queryset.update(enabled=True)
self.message_user(request, f"Enabled {updated} permissions")
def disable(self, request, queryset):
updated = queryset.update(enabled=False)
self.message_user(request, f"Disabled {updated} permissions")

View File

@ -1,49 +1,13 @@
from django import forms from django import forms
from django.contrib.auth.models import Group, User
from django.contrib.admin.widgets import FilteredSelectMultiple
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import FieldError, ValidationError
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
from users.constants import CONSTRAINT_TOKEN_USER, OBJECTPERMISSION_OBJECT_TYPES from users.models import Token
from users.models import ObjectPermission, Token
from utilities.forms.fields import ContentTypeMultipleChoiceField
from utilities.permissions import qs_filter_from_constraints
__all__ = ( __all__ = (
'GroupAdminForm',
'ObjectPermissionForm',
'TokenAdminForm', 'TokenAdminForm',
) )
class GroupAdminForm(forms.ModelForm):
users = forms.ModelMultipleChoiceField(
queryset=User.objects.all(),
required=False,
widget=FilteredSelectMultiple('users', False)
)
class Meta:
model = Group
fields = ('name', 'users')
def __init__(self, *args, **kwargs):
super(GroupAdminForm, self).__init__(*args, **kwargs)
if self.instance.pk:
self.fields['users'].initial = self.instance.user_set.all()
def save_m2m(self):
self.instance.user_set.set(self.cleaned_data['users'])
def save(self, *args, **kwargs):
instance = super(GroupAdminForm, self).save()
self.save_m2m()
return instance
class TokenAdminForm(forms.ModelForm): class TokenAdminForm(forms.ModelForm):
key = forms.CharField( key = forms.CharField(
required=False, required=False,
@ -55,82 +19,3 @@ class TokenAdminForm(forms.ModelForm):
'user', 'key', 'write_enabled', 'expires', 'description', 'allowed_ips' 'user', 'key', 'write_enabled', 'expires', 'description', 'allowed_ips'
] ]
model = Token model = Token
class ObjectPermissionForm(forms.ModelForm):
object_types = ContentTypeMultipleChoiceField(
queryset=ContentType.objects.all(),
limit_choices_to=OBJECTPERMISSION_OBJECT_TYPES
)
can_view = forms.BooleanField(required=False)
can_add = forms.BooleanField(required=False)
can_change = forms.BooleanField(required=False)
can_delete = forms.BooleanField(required=False)
class Meta:
model = ObjectPermission
exclude = []
help_texts = {
'actions': _('Actions granted in addition to those listed above'),
'constraints': _('JSON expression of a queryset filter that will return only permitted objects. Leave null '
'to match all objects of this type. A list of multiple objects will result in a logical OR '
'operation.')
}
labels = {
'actions': 'Additional actions'
}
widgets = {
'constraints': forms.Textarea(attrs={'class': 'vLargeTextField'})
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Make the actions field optional since the admin form uses it only for non-CRUD actions
self.fields['actions'].required = False
# Order group and user fields
self.fields['groups'].queryset = self.fields['groups'].queryset.order_by('name')
self.fields['users'].queryset = self.fields['users'].queryset.order_by('username')
# Check the appropriate checkboxes when editing an existing ObjectPermission
if self.instance.pk:
for action in ['view', 'add', 'change', 'delete']:
if action in self.instance.actions:
self.fields[f'can_{action}'].initial = True
self.instance.actions.remove(action)
def clean(self):
super().clean()
object_types = self.cleaned_data.get('object_types')
constraints = self.cleaned_data.get('constraints')
# Append any of the selected CRUD checkboxes to the actions list
if not self.cleaned_data.get('actions'):
self.cleaned_data['actions'] = list()
for action in ['view', 'add', 'change', 'delete']:
if self.cleaned_data[f'can_{action}'] and action not in self.cleaned_data['actions']:
self.cleaned_data['actions'].append(action)
# At least one action must be specified
if not self.cleaned_data['actions']:
raise ValidationError("At least one action must be selected.")
# Validate the specified model constraints by attempting to execute a query. We don't care whether the query
# returns anything; we just want to make sure the specified constraints are valid.
if object_types and constraints:
# Normalize the constraints to a list of dicts
if type(constraints) is not list:
constraints = [constraints]
for ct in object_types:
model = ct.model_class()
try:
tokens = {
CONSTRAINT_TOKEN_USER: 0, # Replace token with a null user ID
}
model.objects.filter(qs_filter_from_constraints(constraints, tokens)).exists()
except FieldError as e:
raise ValidationError({
'constraints': f'Invalid filter for {model}: {e}'
})

View File

@ -49,7 +49,7 @@ class UserFilterSet(BaseFilterSet):
class Meta: class Meta:
model = get_user_model() model = get_user_model()
fields = ['id', 'username', 'first_name', 'last_name', 'email', 'is_staff', 'is_active'] fields = ['id', 'username', 'first_name', 'last_name', 'email', 'is_staff', 'is_active', 'is_superuser']
def search(self, queryset, name, value): def search(self, queryset, name, value):
if not value.strip(): if not value.strip():
@ -115,6 +115,18 @@ class ObjectPermissionFilterSet(BaseFilterSet):
method='search', method='search',
label=_('Search'), label=_('Search'),
) )
can_view = django_filters.BooleanFilter(
method='_check_action'
)
can_add = django_filters.BooleanFilter(
method='_check_action'
)
can_change = django_filters.BooleanFilter(
method='_check_action'
)
can_delete = django_filters.BooleanFilter(
method='_check_action'
)
user_id = django_filters.ModelMultipleChoiceFilter( user_id = django_filters.ModelMultipleChoiceFilter(
field_name='users', field_name='users',
queryset=get_user_model().objects.all(), queryset=get_user_model().objects.all(),
@ -149,3 +161,10 @@ class ObjectPermissionFilterSet(BaseFilterSet):
Q(name__icontains=value) | Q(name__icontains=value) |
Q(description__icontains=value) Q(description__icontains=value)
) )
def _check_action(self, queryset, name, value):
action = name.split('_')[1]
if value:
return queryset.filter(actions__contains=[action])
else:
return queryset.exclude(actions__contains=[action])

View File

@ -1,3 +1,4 @@
from .authentication import *
from .bulk_edit import * from .bulk_edit import *
from .bulk_import import * from .bulk_import import *
from .filtersets import * from .filtersets import *

View File

@ -0,0 +1,25 @@
from django.contrib.auth.forms import (
AuthenticationForm,
PasswordChangeForm as DjangoPasswordChangeForm,
)
from utilities.forms import BootstrapMixin
__all__ = (
'LoginForm',
'PasswordChangeForm',
)
class LoginForm(BootstrapMixin, AuthenticationForm):
"""
Used to authenticate a user by username and password.
"""
pass
class PasswordChangeForm(BootstrapMixin, DjangoPasswordChangeForm):
"""
This form enables a user to change his or her own password.
"""
pass

View File

@ -1,22 +1,39 @@
from django import forms from django import forms
from django.utils.translation import gettext as _ from django.utils.translation import gettext_lazy as _
from users.models import * from users.models import *
from utilities.forms import BulkEditForm, add_blank_choice from utilities.forms import BootstrapMixin, BulkEditForm, add_blank_choice
from utilities.forms.widgets import BulkEditNullBooleanSelect
__all__ = ( __all__ = (
'ObjectPermissionBulkEditForm',
'TokenBulkEditForm', 'TokenBulkEditForm',
'UserBulkEditForm',
) )
class TokenBulkEditForm(BulkEditForm): class TokenBulkEditForm(BulkEditForm):
pk = forms.ModelMultipleChoiceField( pk = forms.ModelMultipleChoiceField(queryset=Token.objects.all(), widget=forms.MultipleHiddenInput)
queryset=Token.objects.all(), description = forms.CharField(max_length=200, required=False)
widget=forms.MultipleHiddenInput
)
description = forms.CharField(
max_length=200,
required=False
)
class UserBulkEditForm(BootstrapMixin, forms.Form):
pk = forms.ModelMultipleChoiceField(queryset=NetBoxUser.objects.all(), widget=forms.MultipleHiddenInput)
first_name = forms.CharField(label=_('First name'), max_length=150, required=False)
last_name = forms.CharField(label=_('Last name'), max_length=150, required=False)
is_active = forms.NullBooleanField(required=False, widget=BulkEditNullBooleanSelect, label=_('Active'))
is_staff = forms.NullBooleanField(required=False, widget=BulkEditNullBooleanSelect, label=_('Staff status'))
is_superuser = forms.NullBooleanField(required=False, widget=BulkEditNullBooleanSelect, label=_('Superuser status'))
model = NetBoxUser
fieldsets = ((None, ('first_name', 'last_name', 'is_active', 'is_staff', 'is_superuser')),)
nullable_fields = ('first_name', 'last_name')
class ObjectPermissionBulkEditForm(BootstrapMixin, forms.Form):
pk = forms.ModelMultipleChoiceField(queryset=ObjectPermission.objects.all(), widget=forms.MultipleHiddenInput)
description = forms.CharField(label=_('Description'), max_length=200, required=False)
enabled = forms.NullBooleanField(required=False, widget=BulkEditNullBooleanSelect, label=_('Enabled'))
model = ObjectPermission
fieldsets = ((None, ('enabled', 'description')),)
nullable_fields = ('description',) nullable_fields = ('description',)

View File

@ -3,7 +3,9 @@ from utilities.forms import CSVModelForm
__all__ = ( __all__ = (
'GroupImportForm',
'TokenImportForm', 'TokenImportForm',
'UserImportForm',
) )
@ -12,3 +14,28 @@ class TokenImportForm(CSVModelForm):
class Meta: class Meta:
model = Token model = Token
fields = ('description', ) fields = ('description', )
class GroupImportForm(CSVModelForm):
class Meta:
model = NetBoxGroup
fields = (
'name',
)
class UserImportForm(CSVModelForm):
class Meta:
model = NetBoxUser
fields = (
'username', 'first_name', 'last_name', 'email', 'password', 'is_staff',
'is_active', 'is_superuser'
)
def save(self, *args, **kwargs):
# Set the hashed password
self.instance.set_password(self.cleaned_data.get('password'))
return super().save(*args, **kwargs)

View File

@ -2,11 +2,118 @@ from django import forms
from extras.forms.mixins import SavedFiltersMixin from extras.forms.mixins import SavedFiltersMixin
from utilities.forms import FilterForm from utilities.forms import FilterForm
from users.models import Token from users.models import Token
from django.contrib.auth import get_user_model
from django.contrib.auth.models import Group
from django.utils.translation import gettext_lazy as _
from netbox.forms import NetBoxModelFilterSetForm
from users.models import NetBoxGroup, NetBoxUser, ObjectPermission
from utilities.forms import BOOLEAN_WITH_BLANK_CHOICES
from utilities.forms.fields import DynamicModelMultipleChoiceField
__all__ = ( __all__ = (
'GroupFilterForm',
'ObjectPermissionFilterForm',
'TokenFilterForm', 'TokenFilterForm',
'UserFilterForm',
) )
class TokenFilterForm(SavedFiltersMixin, FilterForm): class TokenFilterForm(SavedFiltersMixin, FilterForm):
model = Token model = Token
class GroupFilterForm(NetBoxModelFilterSetForm):
model = NetBoxGroup
fieldsets = (
(None, ('q', 'filter_id',)),
)
class UserFilterForm(NetBoxModelFilterSetForm):
model = NetBoxUser
fieldsets = (
(None, ('q', 'filter_id',)),
(_('Group'), ('group_id',)),
(_('Status'), ('is_active', 'is_staff', 'is_superuser')),
)
group_id = DynamicModelMultipleChoiceField(
queryset=Group.objects.all(),
required=False,
label=_('Group')
)
is_active = forms.NullBooleanField(
required=False,
widget=forms.Select(
choices=BOOLEAN_WITH_BLANK_CHOICES
),
label=_('Is Active'),
)
is_staff = forms.NullBooleanField(
required=False,
widget=forms.Select(
choices=BOOLEAN_WITH_BLANK_CHOICES
),
label=_('Is Staff'),
)
is_superuser = forms.NullBooleanField(
required=False,
widget=forms.Select(
choices=BOOLEAN_WITH_BLANK_CHOICES
),
label=_('Is Superuser'),
)
class ObjectPermissionFilterForm(NetBoxModelFilterSetForm):
model = ObjectPermission
fieldsets = (
(None, ('q', 'filter_id',)),
(_('Permission'), ('enabled', 'group_id', 'user_id')),
(_('Actions'), ('can_view', 'can_add', 'can_change', 'can_delete')),
)
enabled = forms.NullBooleanField(
label=_('Enabled'),
required=False,
widget=forms.Select(
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)
group_id = DynamicModelMultipleChoiceField(
queryset=Group.objects.all(),
required=False,
label=_('Group')
)
user_id = DynamicModelMultipleChoiceField(
queryset=get_user_model().objects.all(),
required=False,
label=_('User')
)
can_view = forms.NullBooleanField(
required=False,
widget=forms.Select(
choices=BOOLEAN_WITH_BLANK_CHOICES
),
label=_('Can View'),
)
can_add = forms.NullBooleanField(
required=False,
widget=forms.Select(
choices=BOOLEAN_WITH_BLANK_CHOICES
),
label=_('Can Add'),
)
can_change = forms.NullBooleanField(
required=False,
widget=forms.Select(
choices=BOOLEAN_WITH_BLANK_CHOICES
),
label=_('Can Change'),
)
can_delete = forms.NullBooleanField(
required=False,
widget=forms.Select(
choices=BOOLEAN_WITH_BLANK_CHOICES
),
label=_('Can Delete'),
)

Some files were not shown because too many files have changed in this diff Show More