Merge branch 'feature' into feat/13170-rf_role

This commit is contained in:
Jeremy Stretch 2023-07-26 08:18:21 -04:00 committed by GitHub
commit eb66dc1e8f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
110 changed files with 3905 additions and 812 deletions

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

@ -8,6 +8,10 @@ The registry can be inspected by importing `registry` from `extras.registry`.
## Stores ## Stores
### `counter_fields`
A dictionary mapping of models to foreign keys with which cached counter fields are associated.
### `data_backends` ### `data_backends`
A dictionary mapping data backend types to their respective classes. These are used to interact with [remote data sources](../models/core/datasource.md). A dictionary mapping data backend types to their respective classes. These are used to interact with [remote data sources](../models/core/datasource.md).

View File

@ -87,6 +87,10 @@ Each device may designate one primary IPv4 address and/or one primary IPv6 addre
!!! tip !!! tip
NetBox will prefer IPv6 addresses over IPv4 addresses by default. This can be changed by setting the `PREFER_IPV4` configuration parameter. NetBox will prefer IPv6 addresses over IPv4 addresses by default. This can be changed by setting the `PREFER_IPV4` configuration parameter.
### Out-of-band (OOB) IP Address
Each device may designate its out-of-band IP address. Out-of-band IPs are typically used to access network infrastructure via a physically separate management network.
### Cluster ### Cluster
If this device will serve as a host for a virtualization [cluster](../virtualization/cluster.md), it can be assigned here. (Host devices can also be assigned by editing the cluster.) If this device will serve as a host for a virtualization [cluster](../virtualization/cluster.md), it can be assigned here. (Host devices can also be assigned by editing the cluster.)

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

@ -7,16 +7,47 @@
* PostgreSQL 11 is no longer supported (due to adopting Django 4.2). NetBox v3.6 requires PostgreSQL 12 or later. * PostgreSQL 11 is no longer supported (due to adopting Django 4.2). NetBox v3.6 requires PostgreSQL 12 or later.
* The `napalm_driver` and `napalm_args` fields (which were deprecated in v3.5) have been removed from the platform model. * The `napalm_driver` and `napalm_args` fields (which were deprecated in v3.5) have been removed from the platform model.
### New Features
#### Relocated Admin Views ([#12589](https://github.com/netbox-community/netbox/issues/12589), [#12590](https://github.com/netbox-community/netbox/issues/12590), [#12591](https://github.com/netbox-community/netbox/issues/12591), [#13044](https://github.com/netbox-community/netbox/issues/13044))
Management views for the following object types, previously available only under the backend admin interface, have been relocated to the primary user interface:
* Users
* Groups
* Object permissions
* API tokens
* Configuration revisions
The admin UI is scheduled for removal in NetBox v4.0.
#### User Bookmarks ([#8248](https://github.com/netbox-community/netbox/issues/8248))
Users can now bookmark their most commonly-visited objects in NetBox. Bookmarks will display both on the dashboard (if configured) and on a user-specific bookmarks view.
#### Custom Field Choice Sets ([#12988](https://github.com/netbox-community/netbox/issues/12988))
Select and multi-select custom fields now employ discrete, reusable choice sets containing the valid options for each field. A choice set may be shared by multiple custom fields.
#### Restrict Tag Usage by Object Type ([#11541](https://github.com/netbox-community/netbox/issues/11541))
Tags may now be restricted to use with designated object types. Tags that have no specific object types assigned may be used with any object that supports tag assignment.
### Enhancements ### Enhancements
* [#6347](https://github.com/netbox-community/netbox/issues/6347) - Cache the number of assigned components for devices and virtual machines
* [#8137](https://github.com/netbox-community/netbox/issues/8137) - Add a field for designating the out-of-band (OOB) IP address for devices
* [#10197](https://github.com/netbox-community/netbox/issues/10197) - Cache the number of member devices on each virtual chassis
* [#11305](https://github.com/netbox-community/netbox/issues/11305) - Add GPS coordinate fields to the device model * [#11305](https://github.com/netbox-community/netbox/issues/11305) - Add GPS coordinate fields to the device model
* [#12175](https://github.com/netbox-community/netbox/issues/12175) - Permit racks to start numbering at values greater than one * [#12175](https://github.com/netbox-community/netbox/issues/12175) - Permit racks to start numbering at values greater than one
* [#13269](https://github.com/netbox-community/netbox/issues/13269) - Cache the number of assigned component templates for device types
### Other Changes ### Other Changes
* [#9077](https://github.com/netbox-community/netbox/issues/9077) - Prevent the errant execution of dangerous instance methods in Django templates * [#9077](https://github.com/netbox-community/netbox/issues/9077) - Prevent the errant execution of dangerous instance methods in Django templates
* [#11766](https://github.com/netbox-community/netbox/issues/11766) - Remove obsolete custom `ChoiceField` and `MultipleChoiceField` classes * [#11766](https://github.com/netbox-community/netbox/issues/11766) - Remove obsolete custom `ChoiceField` and `MultipleChoiceField` classes
* [#12180](https://github.com/netbox-community/netbox/issues/12180) - All API endpoints for available objects (e.g. IP addresses) now inherit from a common parent view * [#12180](https://github.com/netbox-community/netbox/issues/12180) - All API endpoints for available objects (e.g. IP addresses) now inherit from a common parent view
* [#12237](https://github.com/netbox-community/netbox/issues/12237) - Upgrade Django to v4.2
* [#12794](https://github.com/netbox-community/netbox/issues/12794) - Avoid direct imports of Django's stock user model * [#12794](https://github.com/netbox-community/netbox/issues/12794) - Avoid direct imports of Django's stock user model
* [#12320](https://github.com/netbox-community/netbox/issues/12320) - Remove obsolete fields `napalm_driver` and `napalm_args` from Platform * [#12320](https://github.com/netbox-community/netbox/issues/12320) - Remove obsolete fields `napalm_driver` and `napalm_args` from Platform
* [#12964](https://github.com/netbox-community/netbox/issues/12964) - Drop support for PostgreSQL * [#12964](https://github.com/netbox-community/netbox/issues/12964) - Drop support for PostgreSQL 11

View File

@ -327,12 +327,28 @@ class DeviceTypeSerializer(NetBoxModelSerializer):
weight_unit = ChoiceField(choices=WeightUnitChoices, allow_blank=True, required=False, allow_null=True) weight_unit = ChoiceField(choices=WeightUnitChoices, allow_blank=True, required=False, allow_null=True)
device_count = serializers.IntegerField(read_only=True) device_count = serializers.IntegerField(read_only=True)
# Counter fields
console_port_template_count = serializers.IntegerField(read_only=True)
console_server_port_template_count = serializers.IntegerField(read_only=True)
power_port_template_count = serializers.IntegerField(read_only=True)
power_outlet_template_count = serializers.IntegerField(read_only=True)
interface_template_count = serializers.IntegerField(read_only=True)
front_port_template_count = serializers.IntegerField(read_only=True)
rear_port_template_count = serializers.IntegerField(read_only=True)
device_bay_template_count = serializers.IntegerField(read_only=True)
module_bay_template_count = serializers.IntegerField(read_only=True)
inventory_item_template_count = serializers.IntegerField(read_only=True)
class Meta: class Meta:
model = DeviceType model = DeviceType
fields = [ fields = [
'id', 'url', 'display', 'manufacturer', 'default_platform', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'id', 'url', 'display', 'manufacturer', 'default_platform', 'model', 'slug', 'part_number', 'u_height',
'subdevice_role', 'airflow', 'weight', 'weight_unit', 'front_image', 'rear_image', 'description', 'is_full_depth', 'subdevice_role', 'airflow', 'weight', 'weight_unit', 'front_image', 'rear_image',
'comments', 'tags', 'custom_fields', 'created', 'last_updated', 'device_count', 'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', 'device_count',
'console_port_template_count', 'console_server_port_template_count', 'power_port_template_count',
'power_outlet_template_count', 'interface_template_count', 'front_port_template_count',
'rear_port_template_count', 'device_bay_template_count', 'module_bay_template_count',
'inventory_item_template_count',
] ]
@ -669,20 +685,35 @@ class DeviceSerializer(NetBoxModelSerializer):
primary_ip = NestedIPAddressSerializer(read_only=True) primary_ip = NestedIPAddressSerializer(read_only=True)
primary_ip4 = NestedIPAddressSerializer(required=False, allow_null=True) primary_ip4 = NestedIPAddressSerializer(required=False, allow_null=True)
primary_ip6 = NestedIPAddressSerializer(required=False, allow_null=True) primary_ip6 = NestedIPAddressSerializer(required=False, allow_null=True)
oob_ip = NestedIPAddressSerializer(required=False, allow_null=True)
parent_device = serializers.SerializerMethodField() parent_device = serializers.SerializerMethodField()
cluster = NestedClusterSerializer(required=False, allow_null=True) cluster = NestedClusterSerializer(required=False, allow_null=True)
virtual_chassis = NestedVirtualChassisSerializer(required=False, allow_null=True, default=None) virtual_chassis = NestedVirtualChassisSerializer(required=False, allow_null=True, default=None)
vc_position = serializers.IntegerField(allow_null=True, max_value=255, min_value=0, default=None) vc_position = serializers.IntegerField(allow_null=True, max_value=255, min_value=0, default=None)
config_template = NestedConfigTemplateSerializer(required=False, allow_null=True, default=None) config_template = NestedConfigTemplateSerializer(required=False, allow_null=True, default=None)
# Counter fields
console_port_count = serializers.IntegerField(read_only=True)
console_server_port_count = serializers.IntegerField(read_only=True)
power_port_count = serializers.IntegerField(read_only=True)
power_outlet_count = serializers.IntegerField(read_only=True)
interface_count = serializers.IntegerField(read_only=True)
front_port_count = serializers.IntegerField(read_only=True)
rear_port_count = serializers.IntegerField(read_only=True)
device_bay_count = serializers.IntegerField(read_only=True)
module_bay_count = serializers.IntegerField(read_only=True)
inventory_item_count = serializers.IntegerField(read_only=True)
class Meta: class Meta:
model = Device model = Device
fields = [ fields = [
'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', 'latitude', 'longitude', 'parent_device', 'status', 'airflow', 'site', 'location', 'rack', 'position', 'face', 'latitude', 'longitude', 'parent_device', 'status',
'primary_ip', 'primary_ip4', 'primary_ip6', 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'airflow', 'primary_ip', 'primary_ip4', 'primary_ip6', 'oob_ip', 'cluster', 'virtual_chassis',
'description', 'comments', 'config_template', 'local_context_data', 'tags', 'custom_fields', 'created', 'vc_position', 'vc_priority', 'description', 'comments', 'config_template', 'local_context_data', 'tags',
'last_updated', 'custom_fields', 'created', 'last_updated', 'console_port_count', 'console_server_port_count',
'power_port_count', 'power_outlet_count', 'interface_count', 'front_port_count', 'rear_port_count',
'device_bay_count', 'module_bay_count', 'inventory_item_count',
] ]
@extend_schema_field(NestedDeviceSerializer) @extend_schema_field(NestedDeviceSerializer)
@ -704,9 +735,11 @@ class DeviceWithConfigContextSerializer(DeviceSerializer):
fields = [ fields = [
'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', 'oob_ip', 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority',
'comments', 'local_context_data', 'tags', 'custom_fields', 'config_context', 'config_template', 'description', 'comments', 'local_context_data', 'tags', 'custom_fields', 'config_context',
'created', 'last_updated', 'config_template', 'created', 'last_updated', 'console_port_count', 'console_server_port_count',
'power_port_count', 'power_outlet_count', 'interface_count', 'front_port_count', 'rear_port_count',
'device_bay_count', 'module_bay_count', 'inventory_item_count',
] ]
@extend_schema_field(serializers.JSONField(allow_null=True)) @extend_schema_field(serializers.JSONField(allow_null=True))
@ -1145,13 +1178,15 @@ class CablePathSerializer(serializers.ModelSerializer):
class VirtualChassisSerializer(NetBoxModelSerializer): class VirtualChassisSerializer(NetBoxModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:virtualchassis-detail') url = serializers.HyperlinkedIdentityField(view_name='dcim-api:virtualchassis-detail')
master = NestedDeviceSerializer(required=False, allow_null=True, default=None) master = NestedDeviceSerializer(required=False, allow_null=True, default=None)
# Counter fields
member_count = serializers.IntegerField(read_only=True) member_count = serializers.IntegerField(read_only=True)
class Meta: class Meta:
model = VirtualChassis model = VirtualChassis
fields = [ fields = [
'id', 'url', 'display', 'name', 'domain', 'master', 'description', 'comments', 'tags', 'custom_fields', 'id', 'url', 'display', 'name', 'domain', 'master', 'description', 'comments', 'tags', 'custom_fields',
'member_count', 'created', 'last_updated', 'created', 'last_updated', 'member_count',
] ]

View File

@ -579,9 +579,7 @@ class CableTerminationViewSet(NetBoxModelViewSet):
# #
class VirtualChassisViewSet(NetBoxModelViewSet): class VirtualChassisViewSet(NetBoxModelViewSet):
queryset = VirtualChassis.objects.prefetch_related('tags').annotate( queryset = VirtualChassis.objects.prefetch_related('tags')
member_count=count_related(Device, 'virtual_chassis')
)
serializer_class = serializers.VirtualChassisSerializer serializer_class = serializers.VirtualChassisSerializer
filterset_class = filtersets.VirtualChassisFilterSet filterset_class = filtersets.VirtualChassisFilterSet
brief_prefetch_fields = ['master'] brief_prefetch_fields = ['master']

View File

@ -9,7 +9,8 @@ class DCIMConfig(AppConfig):
def ready(self): def ready(self):
from . import signals, search from . import signals, search
from .models import CableTermination from .models import CableTermination, Device, DeviceType, VirtualChassis
from utilities.counters import connect_counters
# Register denormalized fields # Register denormalized fields
denormalized.register(CableTermination, '_device', { denormalized.register(CableTermination, '_device', {
@ -24,3 +25,6 @@ class DCIMConfig(AppConfig):
denormalized.register(CableTermination, '_location', { denormalized.register(CableTermination, '_location', {
'_site': 'site', '_site': 'site',
}) })
# Register counters
connect_counters(Device, DeviceType, VirtualChassis)

View File

@ -944,6 +944,10 @@ class DeviceFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilter
method='_has_primary_ip', method='_has_primary_ip',
label=_('Has a primary IP'), label=_('Has a primary IP'),
) )
has_oob_ip = django_filters.BooleanFilter(
method='_has_oob_ip',
label=_('Has an out-of-band IP'),
)
virtual_chassis_id = django_filters.ModelMultipleChoiceFilter( virtual_chassis_id = django_filters.ModelMultipleChoiceFilter(
field_name='virtual_chassis', field_name='virtual_chassis',
queryset=VirtualChassis.objects.all(), queryset=VirtualChassis.objects.all(),
@ -999,6 +1003,11 @@ class DeviceFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilter
queryset=IPAddress.objects.all(), queryset=IPAddress.objects.all(),
label=_('Primary IPv6 (ID)'), label=_('Primary IPv6 (ID)'),
) )
oob_ip_id = django_filters.ModelMultipleChoiceFilter(
field_name='oob_ip',
queryset=IPAddress.objects.all(),
label=_('OOB IP (ID)'),
)
class Meta: class Meta:
model = Device model = Device
@ -1023,6 +1032,12 @@ class DeviceFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilter
return queryset.filter(params) return queryset.filter(params)
return queryset.exclude(params) return queryset.exclude(params)
def _has_oob_ip(self, queryset, name, value):
params = Q(oob_ip__isnull=False)
if value:
return queryset.filter(params)
return queryset.exclude(params)
def _virtual_chassis_member(self, queryset, name, value): def _virtual_chassis_member(self, queryset, name, value):
return queryset.exclude(virtual_chassis__isnull=value) return queryset.exclude(virtual_chassis__isnull=value)

View File

@ -629,7 +629,7 @@ class DeviceFilterForm(
('Components', ( ('Components', (
'console_ports', 'console_server_ports', 'power_ports', 'power_outlets', 'interfaces', 'pass_through_ports', 'console_ports', 'console_server_ports', 'power_ports', 'power_outlets', 'interfaces', 'pass_through_ports',
)), )),
('Miscellaneous', ('has_primary_ip', 'virtual_chassis_member', 'config_template_id', 'local_context_data')) ('Miscellaneous', ('has_primary_ip', 'has_oob_ip', 'virtual_chassis_member', 'config_template_id', 'local_context_data'))
) )
region_id = DynamicModelMultipleChoiceField( region_id = DynamicModelMultipleChoiceField(
queryset=Region.objects.all(), queryset=Region.objects.all(),
@ -723,6 +723,13 @@ class DeviceFilterForm(
choices=BOOLEAN_WITH_BLANK_CHOICES choices=BOOLEAN_WITH_BLANK_CHOICES
) )
) )
has_oob_ip = forms.NullBooleanField(
required=False,
label='Has an OOB IP',
widget=forms.Select(
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)
virtual_chassis_member = forms.NullBooleanField( virtual_chassis_member = forms.NullBooleanField(
required=False, required=False,
label='Virtual chassis member', label='Virtual chassis member',

View File

@ -449,9 +449,9 @@ class DeviceForm(TenancyForm, NetBoxModelForm):
model = Device model = Device
fields = [ fields = [
'name', 'device_role', 'device_type', 'serial', 'asset_tag', 'site', 'rack', 'location', 'position', 'face', 'name', 'device_role', 'device_type', 'serial', 'asset_tag', 'site', 'rack', 'location', 'position', 'face',
'latitude', 'longitude', 'status', 'airflow', 'platform', 'primary_ip4', 'primary_ip6', 'cluster', 'latitude', 'longitude', 'status', 'airflow', 'platform', 'primary_ip4', 'primary_ip6', 'oob_ip', 'cluster',
'tenant_group', 'tenant', 'virtual_chassis', 'vc_position', 'vc_priority', 'description', 'config_template', 'tenant_group', 'tenant', 'virtual_chassis', 'vc_position', 'vc_priority', 'description', 'config_template',
'comments', 'tags', 'local_context_data' 'comments', 'tags', 'local_context_data',
] ]
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
@ -460,6 +460,7 @@ class DeviceForm(TenancyForm, NetBoxModelForm):
if self.instance.pk: if self.instance.pk:
# Compile list of choices for primary IPv4 and IPv6 addresses # Compile list of choices for primary IPv4 and IPv6 addresses
oob_ip_choices = [(None, '---------')]
for family in [4, 6]: for family in [4, 6]:
ip_choices = [(None, '---------')] ip_choices = [(None, '---------')]
@ -475,6 +476,7 @@ class DeviceForm(TenancyForm, NetBoxModelForm):
if interface_ips: if interface_ips:
ip_list = [(ip.id, f'{ip.address} ({ip.assigned_object})') for ip in interface_ips] ip_list = [(ip.id, f'{ip.address} ({ip.assigned_object})') for ip in interface_ips]
ip_choices.append(('Interface IPs', ip_list)) ip_choices.append(('Interface IPs', ip_list))
oob_ip_choices.extend(ip_list)
# Collect NAT IPs # Collect NAT IPs
nat_ips = IPAddress.objects.prefetch_related('nat_inside').filter( nat_ips = IPAddress.objects.prefetch_related('nat_inside').filter(
address__family=family, address__family=family,
@ -485,6 +487,7 @@ class DeviceForm(TenancyForm, NetBoxModelForm):
ip_list = [(ip.id, f'{ip.address} (NAT)') for ip in nat_ips] ip_list = [(ip.id, f'{ip.address} (NAT)') for ip in nat_ips]
ip_choices.append(('NAT IPs', ip_list)) ip_choices.append(('NAT IPs', ip_list))
self.fields['primary_ip{}'.format(family)].choices = ip_choices self.fields['primary_ip{}'.format(family)].choices = ip_choices
self.fields['oob_ip'].choices = oob_ip_choices
# If editing an existing device, exclude it from the list of occupied rack units. This ensures that a device # If editing an existing device, exclude it from the list of occupied rack units. This ensures that a device
# can be flipped from one face to another. # can be flipped from one face to another.
@ -504,6 +507,8 @@ class DeviceForm(TenancyForm, NetBoxModelForm):
self.fields['primary_ip4'].widget.attrs['readonly'] = True self.fields['primary_ip4'].widget.attrs['readonly'] = True
self.fields['primary_ip6'].choices = [] self.fields['primary_ip6'].choices = []
self.fields['primary_ip6'].widget.attrs['readonly'] = True self.fields['primary_ip6'].widget.attrs['readonly'] = True
self.fields['oob_ip'].choices = []
self.fields['oob_ip'].widget.attrs['readonly'] = True
# Rack position # Rack position
position = self.data.get('position') or self.initial.get('position') position = self.data.get('position') or self.initial.get('position')

View File

@ -0,0 +1,25 @@
# Generated by Django 4.1.9 on 2023-07-24 20:29
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('ipam', '0066_iprange_mark_utilized'),
('dcim', '0174_rack_starting_unit'),
]
operations = [
migrations.AddField(
model_name='device',
name='oob_ip',
field=models.OneToOneField(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name='+',
to='ipam.ipaddress',
),
),
]

View File

@ -0,0 +1,108 @@
from django.db import migrations
from django.db.models import Count
import utilities.fields
def recalculate_device_counts(apps, schema_editor):
Device = apps.get_model("dcim", "Device")
devices = list(Device.objects.all().annotate(
_console_port_count=Count('consoleports', distinct=True),
_console_server_port_count=Count('consoleserverports', distinct=True),
_power_port_count=Count('powerports', distinct=True),
_power_outlet_count=Count('poweroutlets', distinct=True),
_interface_count=Count('interfaces', distinct=True),
_front_port_count=Count('frontports', distinct=True),
_rear_port_count=Count('rearports', distinct=True),
_device_bay_count=Count('devicebays', distinct=True),
_module_bay_count=Count('modulebays', distinct=True),
_inventory_item_count=Count('inventoryitems', distinct=True),
))
for device in devices:
device.console_port_count = device._console_port_count
device.console_server_port_count = device._console_server_port_count
device.power_port_count = device._power_port_count
device.power_outlet_count = device._power_outlet_count
device.interface_count = device._interface_count
device.front_port_count = device._front_port_count
device.rear_port_count = device._rear_port_count
device.device_bay_count = device._device_bay_count
device.module_bay_count = device._module_bay_count
device.inventory_item_count = device._inventory_item_count
Device.objects.bulk_update(devices, [
'console_port_count',
'console_server_port_count',
'power_port_count',
'power_outlet_count',
'interface_count',
'front_port_count',
'rear_port_count',
'device_bay_count',
'module_bay_count',
'inventory_item_count',
])
class Migration(migrations.Migration):
dependencies = [
('dcim', '0175_device_oob_ip'),
]
operations = [
migrations.AddField(
model_name='device',
name='console_port_count',
field=utilities.fields.CounterCacheField(default=0, to_field='device', to_model='dcim.ConsolePort'),
),
migrations.AddField(
model_name='device',
name='console_server_port_count',
field=utilities.fields.CounterCacheField(default=0, to_field='device', to_model='dcim.ConsoleServerPort'),
),
migrations.AddField(
model_name='device',
name='power_port_count',
field=utilities.fields.CounterCacheField(default=0, to_field='device', to_model='dcim.PowerPort'),
),
migrations.AddField(
model_name='device',
name='power_outlet_count',
field=utilities.fields.CounterCacheField(default=0, to_field='device', to_model='dcim.PowerOutlet'),
),
migrations.AddField(
model_name='device',
name='interface_count',
field=utilities.fields.CounterCacheField(default=0, to_field='device', to_model='dcim.Interface'),
),
migrations.AddField(
model_name='device',
name='front_port_count',
field=utilities.fields.CounterCacheField(default=0, to_field='device', to_model='dcim.FrontPort'),
),
migrations.AddField(
model_name='device',
name='rear_port_count',
field=utilities.fields.CounterCacheField(default=0, to_field='device', to_model='dcim.RearPort'),
),
migrations.AddField(
model_name='device',
name='device_bay_count',
field=utilities.fields.CounterCacheField(default=0, to_field='device', to_model='dcim.DeviceBay'),
),
migrations.AddField(
model_name='device',
name='module_bay_count',
field=utilities.fields.CounterCacheField(default=0, to_field='device', to_model='dcim.ModuleBay'),
),
migrations.AddField(
model_name='device',
name='inventory_item_count',
field=utilities.fields.CounterCacheField(default=0, to_field='device', to_model='dcim.InventoryItem'),
),
migrations.RunPython(
recalculate_device_counts,
reverse_code=migrations.RunPython.noop
),
]

View File

@ -0,0 +1,108 @@
from django.db import migrations
from django.db.models import Count
import utilities.fields
def recalculate_devicetype_template_counts(apps, schema_editor):
DeviceType = apps.get_model("dcim", "DeviceType")
device_types = list(DeviceType.objects.all().annotate(
_console_port_template_count=Count('consoleporttemplates', distinct=True),
_console_server_port_template_count=Count('consoleserverporttemplates', distinct=True),
_power_port_template_count=Count('powerporttemplates', distinct=True),
_power_outlet_template_count=Count('poweroutlettemplates', distinct=True),
_interface_template_count=Count('interfacetemplates', distinct=True),
_front_port_template_count=Count('frontporttemplates', distinct=True),
_rear_port_template_count=Count('rearporttemplates', distinct=True),
_device_bay_template_count=Count('devicebaytemplates', distinct=True),
_module_bay_template_count=Count('modulebaytemplates', distinct=True),
_inventory_item_template_count=Count('inventoryitemtemplates', distinct=True),
))
for devicetype in device_types:
devicetype.console_port_template_count = devicetype._console_port_template_count
devicetype.console_server_port_template_count = devicetype._console_server_port_template_count
devicetype.power_port_template_count = devicetype._power_port_template_count
devicetype.power_outlet_template_count = devicetype._power_outlet_template_count
devicetype.interface_template_count = devicetype._interface_template_count
devicetype.front_port_template_count = devicetype._front_port_template_count
devicetype.rear_port_template_count = devicetype._rear_port_template_count
devicetype.device_bay_template_count = devicetype._device_bay_template_count
devicetype.module_bay_template_count = devicetype._module_bay_template_count
devicetype.inventory_item_template_count = devicetype._inventory_item_template_count
DeviceType.objects.bulk_update(device_types, [
'console_port_template_count',
'console_server_port_template_count',
'power_port_template_count',
'power_outlet_template_count',
'interface_template_count',
'front_port_template_count',
'rear_port_template_count',
'device_bay_template_count',
'module_bay_template_count',
'inventory_item_template_count',
])
class Migration(migrations.Migration):
dependencies = [
('dcim', '0176_device_component_counters'),
]
operations = [
migrations.AddField(
model_name='devicetype',
name='console_port_template_count',
field=utilities.fields.CounterCacheField(default=0, to_field='device_type', to_model='dcim.ConsolePortTemplate'),
),
migrations.AddField(
model_name='devicetype',
name='console_server_port_template_count',
field=utilities.fields.CounterCacheField(default=0, to_field='device_type', to_model='dcim.ConsoleServerPortTemplate'),
),
migrations.AddField(
model_name='devicetype',
name='power_port_template_count',
field=utilities.fields.CounterCacheField(default=0, to_field='device_type', to_model='dcim.PowerPortTemplate'),
),
migrations.AddField(
model_name='devicetype',
name='power_outlet_template_count',
field=utilities.fields.CounterCacheField(default=0, to_field='device_type', to_model='dcim.PowerOutletTemplate'),
),
migrations.AddField(
model_name='devicetype',
name='interface_template_count',
field=utilities.fields.CounterCacheField(default=0, to_field='device_type', to_model='dcim.InterfaceTemplate'),
),
migrations.AddField(
model_name='devicetype',
name='front_port_template_count',
field=utilities.fields.CounterCacheField(default=0, to_field='device_type', to_model='dcim.FrontPortTemplate'),
),
migrations.AddField(
model_name='devicetype',
name='rear_port_template_count',
field=utilities.fields.CounterCacheField(default=0, to_field='device_type', to_model='dcim.RearPortTemplate'),
),
migrations.AddField(
model_name='devicetype',
name='device_bay_template_count',
field=utilities.fields.CounterCacheField(default=0, to_field='device_type', to_model='dcim.DeviceBayTemplate'),
),
migrations.AddField(
model_name='devicetype',
name='module_bay_template_count',
field=utilities.fields.CounterCacheField(default=0, to_field='device_type', to_model='dcim.ModuleBayTemplate'),
),
migrations.AddField(
model_name='devicetype',
name='inventory_item_template_count',
field=utilities.fields.CounterCacheField(default=0, to_field='device_type', to_model='dcim.InventoryItemTemplate'),
),
migrations.RunPython(
recalculate_devicetype_template_counts,
reverse_code=migrations.RunPython.noop
),
]

View File

@ -0,0 +1,35 @@
from django.db import migrations
from django.db.models import Count
import utilities.fields
def populate_virtualchassis_members(apps, schema_editor):
VirtualChassis = apps.get_model('dcim', 'VirtualChassis')
vcs = list(VirtualChassis.objects.annotate(_member_count=Count('members', distinct=True)))
for vc in vcs:
vc.member_count = vc._member_count
VirtualChassis.objects.bulk_update(vcs, ['member_count'])
class Migration(migrations.Migration):
dependencies = [
('dcim', '0177_devicetype_component_counters'),
]
operations = [
migrations.AddField(
model_name='virtualchassis',
name='member_count',
field=utilities.fields.CounterCacheField(
default=0, to_field='virtual_chassis', to_model='dcim.Device'
),
),
migrations.RunPython(
code=populate_virtualchassis_members,
reverse_code=migrations.RunPython.noop
),
]

View File

@ -12,6 +12,7 @@ from netbox.models import ChangeLoggedModel
from utilities.fields import ColorField, NaturalOrderingField from utilities.fields import ColorField, NaturalOrderingField
from utilities.mptt import TreeManager from utilities.mptt import TreeManager
from utilities.ordering import naturalize_interface from utilities.ordering import naturalize_interface
from utilities.tracking import TrackingModelMixin
from wireless.choices import WirelessRoleChoices from wireless.choices import WirelessRoleChoices
from .device_components import ( from .device_components import (
ConsolePort, ConsoleServerPort, DeviceBay, FrontPort, Interface, InventoryItem, ModuleBay, PowerOutlet, PowerPort, ConsolePort, ConsoleServerPort, DeviceBay, FrontPort, Interface, InventoryItem, ModuleBay, PowerOutlet, PowerPort,
@ -33,7 +34,7 @@ __all__ = (
) )
class ComponentTemplateModel(ChangeLoggedModel): class ComponentTemplateModel(ChangeLoggedModel, TrackingModelMixin):
device_type = models.ForeignKey( device_type = models.ForeignKey(
to='dcim.DeviceType', to='dcim.DeviceType',
on_delete=models.CASCADE, on_delete=models.CASCADE,

View File

@ -19,6 +19,7 @@ from utilities.fields import ColorField, NaturalOrderingField
from utilities.mptt import TreeManager from utilities.mptt import TreeManager
from utilities.ordering import naturalize_interface from utilities.ordering import naturalize_interface
from utilities.query_functions import CollateAsChar from utilities.query_functions import CollateAsChar
from utilities.tracking import TrackingModelMixin
from wireless.choices import * from wireless.choices import *
from wireless.utils import get_channel_attr from wireless.utils import get_channel_attr
@ -269,7 +270,7 @@ class PathEndpoint(models.Model):
# Console components # Console components
# #
class ConsolePort(ModularComponentModel, CabledObjectModel, PathEndpoint): class ConsolePort(ModularComponentModel, CabledObjectModel, PathEndpoint, TrackingModelMixin):
""" """
A physical console port within a Device. ConsolePorts connect to ConsoleServerPorts. A physical console port within a Device. ConsolePorts connect to ConsoleServerPorts.
""" """
@ -292,7 +293,7 @@ class ConsolePort(ModularComponentModel, CabledObjectModel, PathEndpoint):
return reverse('dcim:consoleport', kwargs={'pk': self.pk}) return reverse('dcim:consoleport', kwargs={'pk': self.pk})
class ConsoleServerPort(ModularComponentModel, CabledObjectModel, PathEndpoint): class ConsoleServerPort(ModularComponentModel, CabledObjectModel, PathEndpoint, TrackingModelMixin):
""" """
A physical port within a Device (typically a designated console server) which provides access to ConsolePorts. A physical port within a Device (typically a designated console server) which provides access to ConsolePorts.
""" """
@ -319,7 +320,7 @@ class ConsoleServerPort(ModularComponentModel, CabledObjectModel, PathEndpoint):
# Power components # Power components
# #
class PowerPort(ModularComponentModel, CabledObjectModel, PathEndpoint): class PowerPort(ModularComponentModel, CabledObjectModel, PathEndpoint, TrackingModelMixin):
""" """
A physical power supply (intake) port within a Device. PowerPorts connect to PowerOutlets. A physical power supply (intake) port within a Device. PowerPorts connect to PowerOutlets.
""" """
@ -428,7 +429,7 @@ class PowerPort(ModularComponentModel, CabledObjectModel, PathEndpoint):
} }
class PowerOutlet(ModularComponentModel, CabledObjectModel, PathEndpoint): class PowerOutlet(ModularComponentModel, CabledObjectModel, PathEndpoint, TrackingModelMixin):
""" """
A physical power outlet (output) within a Device which provides power to a PowerPort. A physical power outlet (output) within a Device which provides power to a PowerPort.
""" """
@ -537,7 +538,7 @@ class BaseInterface(models.Model):
return self.fhrp_group_assignments.count() return self.fhrp_group_assignments.count()
class Interface(ModularComponentModel, BaseInterface, CabledObjectModel, PathEndpoint): class Interface(ModularComponentModel, BaseInterface, CabledObjectModel, PathEndpoint, TrackingModelMixin):
""" """
A network interface within a Device. A physical Interface can connect to exactly one other Interface. A network interface within a Device. A physical Interface can connect to exactly one other Interface.
""" """
@ -888,7 +889,7 @@ class Interface(ModularComponentModel, BaseInterface, CabledObjectModel, PathEnd
# Pass-through ports # Pass-through ports
# #
class FrontPort(ModularComponentModel, CabledObjectModel): class FrontPort(ModularComponentModel, CabledObjectModel, TrackingModelMixin):
""" """
A pass-through port on the front of a Device. A pass-through port on the front of a Device.
""" """
@ -949,7 +950,7 @@ class FrontPort(ModularComponentModel, CabledObjectModel):
}) })
class RearPort(ModularComponentModel, CabledObjectModel): class RearPort(ModularComponentModel, CabledObjectModel, TrackingModelMixin):
""" """
A pass-through port on the rear of a Device. A pass-through port on the rear of a Device.
""" """
@ -990,7 +991,7 @@ class RearPort(ModularComponentModel, CabledObjectModel):
# Bays # Bays
# #
class ModuleBay(ComponentModel): class ModuleBay(ComponentModel, TrackingModelMixin):
""" """
An empty space within a Device which can house a child device An empty space within a Device which can house a child device
""" """
@ -1006,7 +1007,7 @@ class ModuleBay(ComponentModel):
return reverse('dcim:modulebay', kwargs={'pk': self.pk}) return reverse('dcim:modulebay', kwargs={'pk': self.pk})
class DeviceBay(ComponentModel): class DeviceBay(ComponentModel, TrackingModelMixin):
""" """
An empty space within a Device which can house a child device An empty space within a Device which can house a child device
""" """
@ -1064,7 +1065,7 @@ class InventoryItemRole(OrganizationalModel):
return reverse('dcim:inventoryitemrole', args=[self.pk]) return reverse('dcim:inventoryitemrole', args=[self.pk])
class InventoryItem(MPTTModel, ComponentModel): class InventoryItem(MPTTModel, ComponentModel, TrackingModelMixin):
""" """
An InventoryItem represents a serialized piece of hardware within a Device, such as a line card or power supply. An InventoryItem represents a serialized piece of hardware within a Device, such as a line card or power supply.
InventoryItems are used only for inventory purposes. InventoryItems are used only for inventory purposes.

View File

@ -21,7 +21,8 @@ from extras.querysets import ConfigContextModelQuerySet
from netbox.config import ConfigItem from netbox.config import ConfigItem
from netbox.models import OrganizationalModel, PrimaryModel from netbox.models import OrganizationalModel, PrimaryModel
from utilities.choices import ColorChoices from utilities.choices import ColorChoices
from utilities.fields import ColorField, NaturalOrderingField from utilities.fields import ColorField, CounterCacheField, NaturalOrderingField
from utilities.tracking import TrackingModelMixin
from .device_components import * from .device_components import *
from .mixins import WeightMixin from .mixins import WeightMixin
@ -128,6 +129,48 @@ class DeviceType(PrimaryModel, WeightMixin):
blank=True blank=True
) )
# Counter fields
console_port_template_count = CounterCacheField(
to_model='dcim.ConsolePortTemplate',
to_field='device_type'
)
console_server_port_template_count = CounterCacheField(
to_model='dcim.ConsoleServerPortTemplate',
to_field='device_type'
)
power_port_template_count = CounterCacheField(
to_model='dcim.PowerPortTemplate',
to_field='device_type'
)
power_outlet_template_count = CounterCacheField(
to_model='dcim.PowerOutletTemplate',
to_field='device_type'
)
interface_template_count = CounterCacheField(
to_model='dcim.InterfaceTemplate',
to_field='device_type'
)
front_port_template_count = CounterCacheField(
to_model='dcim.FrontPortTemplate',
to_field='device_type'
)
rear_port_template_count = CounterCacheField(
to_model='dcim.RearPortTemplate',
to_field='device_type'
)
device_bay_template_count = CounterCacheField(
to_model='dcim.DeviceBayTemplate',
to_field='device_type'
)
module_bay_template_count = CounterCacheField(
to_model='dcim.ModuleBayTemplate',
to_field='device_type'
)
inventory_item_template_count = CounterCacheField(
to_model='dcim.InventoryItemTemplate',
to_field='device_type'
)
images = GenericRelation( images = GenericRelation(
to='extras.ImageAttachment' to='extras.ImageAttachment'
) )
@ -469,7 +512,7 @@ def update_interface_bridges(device, interface_templates, module=None):
interface.save() interface.save()
class Device(PrimaryModel, ConfigContextModel): class Device(PrimaryModel, ConfigContextModel, TrackingModelMixin):
""" """
A Device represents a piece of physical hardware mounted within a Rack. Each Device is assigned a DeviceType, A Device represents a piece of physical hardware mounted within a Rack. Each Device is assigned a DeviceType,
DeviceRole, and (optionally) a Platform. Device names are not required, however if one is set it must be unique. DeviceRole, and (optionally) a Platform. Device names are not required, however if one is set it must be unique.
@ -591,6 +634,14 @@ class Device(PrimaryModel, ConfigContextModel):
null=True, null=True,
verbose_name='Primary IPv6' verbose_name='Primary IPv6'
) )
oob_ip = models.OneToOneField(
to='ipam.IPAddress',
on_delete=models.SET_NULL,
related_name='+',
blank=True,
null=True,
verbose_name='Out-of-band IP'
)
cluster = models.ForeignKey( cluster = models.ForeignKey(
to='virtualization.Cluster', to='virtualization.Cluster',
on_delete=models.SET_NULL, on_delete=models.SET_NULL,
@ -639,6 +690,48 @@ class Device(PrimaryModel, ConfigContextModel):
help_text=_("GPS coordinate in decimal format (xx.yyyyyy)") help_text=_("GPS coordinate in decimal format (xx.yyyyyy)")
) )
# Counter fields
console_port_count = CounterCacheField(
to_model='dcim.ConsolePort',
to_field='device'
)
console_server_port_count = CounterCacheField(
to_model='dcim.ConsoleServerPort',
to_field='device'
)
power_port_count = CounterCacheField(
to_model='dcim.PowerPort',
to_field='device'
)
power_outlet_count = CounterCacheField(
to_model='dcim.PowerOutlet',
to_field='device'
)
interface_count = CounterCacheField(
to_model='dcim.Interface',
to_field='device'
)
front_port_count = CounterCacheField(
to_model='dcim.FrontPort',
to_field='device'
)
rear_port_count = CounterCacheField(
to_model='dcim.RearPort',
to_field='device'
)
device_bay_count = CounterCacheField(
to_model='dcim.DeviceBay',
to_field='device'
)
module_bay_count = CounterCacheField(
to_model='dcim.ModuleBay',
to_field='device'
)
inventory_item_count = CounterCacheField(
to_model='dcim.InventoryItem',
to_field='device'
)
# Generic relations # Generic relations
contacts = GenericRelation( contacts = GenericRelation(
to='tenancy.ContactAssignment' to='tenancy.ContactAssignment'
@ -774,7 +867,7 @@ class Device(PrimaryModel, ConfigContextModel):
except DeviceType.DoesNotExist: except DeviceType.DoesNotExist:
pass pass
# Validate primary IP addresses # Validate primary & OOB IP addresses
vc_interfaces = self.vc_interfaces(if_master=False) vc_interfaces = self.vc_interfaces(if_master=False)
if self.primary_ip4: if self.primary_ip4:
if self.primary_ip4.family != 4: if self.primary_ip4.family != 4:
@ -802,6 +895,15 @@ class Device(PrimaryModel, ConfigContextModel):
raise ValidationError({ raise ValidationError({
'primary_ip6': f"The specified IP address ({self.primary_ip6}) is not assigned to this device." 'primary_ip6': f"The specified IP address ({self.primary_ip6}) is not assigned to this device."
}) })
if self.oob_ip:
if self.oob_ip.assigned_object in vc_interfaces:
pass
elif self.oob_ip.nat_inside is not None and self.oob_ip.nat_inside.assigned_object in vc_interfaces:
pass
else:
raise ValidationError({
'oob_ip': f"The specified IP address ({self.oob_ip}) is not assigned to this device."
})
# Validate manufacturer/platform # Validate manufacturer/platform
if hasattr(self, 'device_type') and self.platform: if hasattr(self, 'device_type') and self.platform:
@ -1147,6 +1249,12 @@ class VirtualChassis(PrimaryModel):
blank=True blank=True
) )
# Counter fields
member_count = CounterCacheField(
to_model='dcim.Device',
to_field='virtual_chassis'
)
class Meta: class Meta:
ordering = ['name'] ordering = ['name']
verbose_name_plural = 'virtual chassis' verbose_name_plural = 'virtual chassis'

View File

@ -1,10 +1,10 @@
import django_tables2 as tables import django_tables2 as tables
from dcim import models
from django_tables2.utils import Accessor from django_tables2.utils import Accessor
from tenancy.tables import ContactsColumnMixin, TenancyColumnsMixin from django.utils.translation import gettext as _
from dcim import models
from netbox.tables import NetBoxTable, columns from netbox.tables import NetBoxTable, columns
from tenancy.tables import ContactsColumnMixin, TenancyColumnsMixin
from .template_code import * from .template_code import *
__all__ = ( __all__ = (
@ -201,6 +201,10 @@ class DeviceTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
linkify=True, linkify=True,
verbose_name='IPv6 Address' verbose_name='IPv6 Address'
) )
oob_ip = tables.Column(
linkify=True,
verbose_name='OOB IP'
)
cluster = tables.Column( cluster = tables.Column(
linkify=True linkify=True
) )
@ -230,6 +234,36 @@ class DeviceTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
tags = columns.TagColumn( tags = columns.TagColumn(
url_name='dcim:device_list' url_name='dcim:device_list'
) )
console_port_count = tables.Column(
verbose_name=_('Console ports')
)
console_server_port_count = tables.Column(
verbose_name=_('Console server ports')
)
power_port_count = tables.Column(
verbose_name=_('Power ports')
)
power_outlet_count = tables.Column(
verbose_name=_('Power outlets')
)
interface_count = tables.Column(
verbose_name=_('Interfaces')
)
front_port_count = tables.Column(
verbose_name=_('Front ports')
)
rear_port_count = tables.Column(
verbose_name=_('Rear ports')
)
device_bay_count = tables.Column(
verbose_name=_('Device bays')
)
module_bay_count = tables.Column(
verbose_name=_('Module bays')
)
inventory_item_count = tables.Column(
verbose_name=_('Inventory items')
)
class Meta(NetBoxTable.Meta): class Meta(NetBoxTable.Meta):
model = models.Device model = models.Device
@ -237,8 +271,8 @@ class DeviceTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
'pk', 'id', 'name', 'status', 'tenant', 'tenant_group', 'device_role', 'manufacturer', 'device_type', 'pk', 'id', 'name', 'status', 'tenant', 'tenant_group', 'device_role', 'manufacturer', 'device_type',
'platform', 'serial', 'asset_tag', 'region', 'site_group', 'site', 'location', 'rack', 'parent_device', 'platform', 'serial', 'asset_tag', 'region', 'site_group', 'site', 'location', 'rack', 'parent_device',
'device_bay_position', 'position', 'face', 'latitude', 'longitude', 'airflow', 'primary_ip', 'primary_ip4', 'device_bay_position', 'position', 'face', 'latitude', 'longitude', 'airflow', 'primary_ip', 'primary_ip4',
'primary_ip6', 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'description', 'config_template', 'primary_ip6', 'oob_ip', 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'description',
'comments', 'contacts', 'tags', 'created', 'last_updated', 'config_template', 'comments', 'contacts', 'tags', 'created', 'last_updated',
) )
default_columns = ( default_columns = (
'pk', 'name', 'status', 'tenant', 'site', 'location', 'rack', 'device_role', 'manufacturer', 'device_type', 'pk', 'name', 'status', 'tenant', 'site', 'location', 'rack', 'device_role', 'manufacturer', 'device_type',

View File

@ -1,4 +1,5 @@
import django_tables2 as tables import django_tables2 as tables
from django.utils.translation import gettext as _
from dcim import models from dcim import models
from netbox.tables import NetBoxTable, columns from netbox.tables import NetBoxTable, columns
@ -83,11 +84,6 @@ class DeviceTypeTable(NetBoxTable):
is_full_depth = columns.BooleanColumn( is_full_depth = columns.BooleanColumn(
verbose_name='Full Depth' verbose_name='Full Depth'
) )
instance_count = columns.LinkedCountColumn(
viewname='dcim:device_list',
url_params={'device_type_id': 'pk'},
verbose_name='Instances'
)
comments = columns.MarkdownColumn() comments = columns.MarkdownColumn()
tags = columns.TagColumn( tags = columns.TagColumn(
url_name='dcim:devicetype_list' url_name='dcim:devicetype_list'
@ -99,12 +95,48 @@ class DeviceTypeTable(NetBoxTable):
template_code=WEIGHT, template_code=WEIGHT,
order_by=('_abs_weight', 'weight_unit') order_by=('_abs_weight', 'weight_unit')
) )
instance_count = columns.LinkedCountColumn(
viewname='dcim:device_list',
url_params={'device_type_id': 'pk'},
verbose_name='Instances'
)
console_port_template_count = tables.Column(
verbose_name=_('Console ports')
)
console_server_port_template_count = tables.Column(
verbose_name=_('Console server ports')
)
power_port_template_count = tables.Column(
verbose_name=_('Power ports')
)
power_outlet_template_count = tables.Column(
verbose_name=_('Power outlets')
)
interface_template_count = tables.Column(
verbose_name=_('Interfaces')
)
front_port_template_count = tables.Column(
verbose_name=_('Front ports')
)
rear_port_template_count = tables.Column(
verbose_name=_('Rear ports')
)
device_bay_template_count = tables.Column(
verbose_name=_('Device bays')
)
module_bay_template_count = tables.Column(
verbose_name=_('Module bays')
)
inventory_item_template_count = tables.Column(
verbose_name=_('Inventory items')
)
class Meta(NetBoxTable.Meta): class Meta(NetBoxTable.Meta):
model = models.DeviceType model = models.DeviceType
fields = ( fields = (
'pk', 'id', 'model', 'manufacturer', 'default_platform', 'slug', 'part_number', 'u_height', 'is_full_depth', 'subdevice_role', 'pk', 'id', 'model', 'manufacturer', 'default_platform', 'slug', 'part_number', 'u_height', 'is_full_depth',
'airflow', 'weight', 'description', 'comments', 'instance_count', 'tags', 'created', 'last_updated', 'subdevice_role', 'airflow', 'weight', 'description', 'comments', 'instance_count', 'tags', 'created',
'last_updated',
) )
default_columns = ( default_columns = (
'pk', 'model', 'manufacturer', 'part_number', 'u_height', 'is_full_depth', 'instance_count', 'pk', 'model', 'manufacturer', 'part_number', 'u_height', 'is_full_depth', 'instance_count',

View File

@ -951,7 +951,7 @@ class DeviceTypeConsolePortsView(DeviceTypeComponentsView):
viewname = 'dcim:devicetype_consoleports' viewname = 'dcim:devicetype_consoleports'
tab = ViewTab( tab = ViewTab(
label=_('Console Ports'), label=_('Console Ports'),
badge=lambda obj: obj.consoleporttemplates.count(), badge=lambda obj: obj.console_port_template_count,
permission='dcim.view_consoleporttemplate', permission='dcim.view_consoleporttemplate',
weight=550, weight=550,
hide_if_empty=True hide_if_empty=True
@ -966,7 +966,7 @@ class DeviceTypeConsoleServerPortsView(DeviceTypeComponentsView):
viewname = 'dcim:devicetype_consoleserverports' viewname = 'dcim:devicetype_consoleserverports'
tab = ViewTab( tab = ViewTab(
label=_('Console Server Ports'), label=_('Console Server Ports'),
badge=lambda obj: obj.consoleserverporttemplates.count(), badge=lambda obj: obj.console_server_port_template_count,
permission='dcim.view_consoleserverporttemplate', permission='dcim.view_consoleserverporttemplate',
weight=560, weight=560,
hide_if_empty=True hide_if_empty=True
@ -981,7 +981,7 @@ class DeviceTypePowerPortsView(DeviceTypeComponentsView):
viewname = 'dcim:devicetype_powerports' viewname = 'dcim:devicetype_powerports'
tab = ViewTab( tab = ViewTab(
label=_('Power Ports'), label=_('Power Ports'),
badge=lambda obj: obj.powerporttemplates.count(), badge=lambda obj: obj.power_port_template_count,
permission='dcim.view_powerporttemplate', permission='dcim.view_powerporttemplate',
weight=570, weight=570,
hide_if_empty=True hide_if_empty=True
@ -996,7 +996,7 @@ class DeviceTypePowerOutletsView(DeviceTypeComponentsView):
viewname = 'dcim:devicetype_poweroutlets' viewname = 'dcim:devicetype_poweroutlets'
tab = ViewTab( tab = ViewTab(
label=_('Power Outlets'), label=_('Power Outlets'),
badge=lambda obj: obj.poweroutlettemplates.count(), badge=lambda obj: obj.power_outlet_template_count,
permission='dcim.view_poweroutlettemplate', permission='dcim.view_poweroutlettemplate',
weight=580, weight=580,
hide_if_empty=True hide_if_empty=True
@ -1011,7 +1011,7 @@ class DeviceTypeInterfacesView(DeviceTypeComponentsView):
viewname = 'dcim:devicetype_interfaces' viewname = 'dcim:devicetype_interfaces'
tab = ViewTab( tab = ViewTab(
label=_('Interfaces'), label=_('Interfaces'),
badge=lambda obj: obj.interfacetemplates.count(), badge=lambda obj: obj.interface_template_count,
permission='dcim.view_interfacetemplate', permission='dcim.view_interfacetemplate',
weight=520, weight=520,
hide_if_empty=True hide_if_empty=True
@ -1026,7 +1026,7 @@ class DeviceTypeFrontPortsView(DeviceTypeComponentsView):
viewname = 'dcim:devicetype_frontports' viewname = 'dcim:devicetype_frontports'
tab = ViewTab( tab = ViewTab(
label=_('Front Ports'), label=_('Front Ports'),
badge=lambda obj: obj.frontporttemplates.count(), badge=lambda obj: obj.front_port_template_count,
permission='dcim.view_frontporttemplate', permission='dcim.view_frontporttemplate',
weight=530, weight=530,
hide_if_empty=True hide_if_empty=True
@ -1041,7 +1041,7 @@ class DeviceTypeRearPortsView(DeviceTypeComponentsView):
viewname = 'dcim:devicetype_rearports' viewname = 'dcim:devicetype_rearports'
tab = ViewTab( tab = ViewTab(
label=_('Rear Ports'), label=_('Rear Ports'),
badge=lambda obj: obj.rearporttemplates.count(), badge=lambda obj: obj.rear_port_template_count,
permission='dcim.view_rearporttemplate', permission='dcim.view_rearporttemplate',
weight=540, weight=540,
hide_if_empty=True hide_if_empty=True
@ -1056,7 +1056,7 @@ class DeviceTypeModuleBaysView(DeviceTypeComponentsView):
viewname = 'dcim:devicetype_modulebays' viewname = 'dcim:devicetype_modulebays'
tab = ViewTab( tab = ViewTab(
label=_('Module Bays'), label=_('Module Bays'),
badge=lambda obj: obj.modulebaytemplates.count(), badge=lambda obj: obj.module_bay_template_count,
permission='dcim.view_modulebaytemplate', permission='dcim.view_modulebaytemplate',
weight=510, weight=510,
hide_if_empty=True hide_if_empty=True
@ -1071,7 +1071,7 @@ class DeviceTypeDeviceBaysView(DeviceTypeComponentsView):
viewname = 'dcim:devicetype_devicebays' viewname = 'dcim:devicetype_devicebays'
tab = ViewTab( tab = ViewTab(
label=_('Device Bays'), label=_('Device Bays'),
badge=lambda obj: obj.devicebaytemplates.count(), badge=lambda obj: obj.device_bay_template_count,
permission='dcim.view_devicebaytemplate', permission='dcim.view_devicebaytemplate',
weight=500, weight=500,
hide_if_empty=True hide_if_empty=True
@ -1086,7 +1086,7 @@ class DeviceTypeInventoryItemsView(DeviceTypeComponentsView):
viewname = 'dcim:devicetype_inventoryitems' viewname = 'dcim:devicetype_inventoryitems'
tab = ViewTab( tab = ViewTab(
label=_('Inventory Items'), label=_('Inventory Items'),
badge=lambda obj: obj.inventoryitemtemplates.count(), badge=lambda obj: obj.inventory_item_template_count,
permission='dcim.view_invenotryitemtemplate', permission='dcim.view_invenotryitemtemplate',
weight=590, weight=590,
hide_if_empty=True hide_if_empty=True
@ -1876,7 +1876,7 @@ class DeviceConsolePortsView(DeviceComponentsView):
template_name = 'dcim/device/consoleports.html', template_name = 'dcim/device/consoleports.html',
tab = ViewTab( tab = ViewTab(
label=_('Console Ports'), label=_('Console Ports'),
badge=lambda obj: obj.consoleports.count(), badge=lambda obj: obj.console_port_count,
permission='dcim.view_consoleport', permission='dcim.view_consoleport',
weight=550, weight=550,
hide_if_empty=True hide_if_empty=True
@ -1891,7 +1891,7 @@ class DeviceConsoleServerPortsView(DeviceComponentsView):
template_name = 'dcim/device/consoleserverports.html' template_name = 'dcim/device/consoleserverports.html'
tab = ViewTab( tab = ViewTab(
label=_('Console Server Ports'), label=_('Console Server Ports'),
badge=lambda obj: obj.consoleserverports.count(), badge=lambda obj: obj.console_server_port_count,
permission='dcim.view_consoleserverport', permission='dcim.view_consoleserverport',
weight=560, weight=560,
hide_if_empty=True hide_if_empty=True
@ -1906,7 +1906,7 @@ class DevicePowerPortsView(DeviceComponentsView):
template_name = 'dcim/device/powerports.html' template_name = 'dcim/device/powerports.html'
tab = ViewTab( tab = ViewTab(
label=_('Power Ports'), label=_('Power Ports'),
badge=lambda obj: obj.powerports.count(), badge=lambda obj: obj.power_port_count,
permission='dcim.view_powerport', permission='dcim.view_powerport',
weight=570, weight=570,
hide_if_empty=True hide_if_empty=True
@ -1921,7 +1921,7 @@ class DevicePowerOutletsView(DeviceComponentsView):
template_name = 'dcim/device/poweroutlets.html' template_name = 'dcim/device/poweroutlets.html'
tab = ViewTab( tab = ViewTab(
label=_('Power Outlets'), label=_('Power Outlets'),
badge=lambda obj: obj.poweroutlets.count(), badge=lambda obj: obj.power_outlet_count,
permission='dcim.view_poweroutlet', permission='dcim.view_poweroutlet',
weight=580, weight=580,
hide_if_empty=True hide_if_empty=True
@ -1957,7 +1957,7 @@ class DeviceFrontPortsView(DeviceComponentsView):
template_name = 'dcim/device/frontports.html' template_name = 'dcim/device/frontports.html'
tab = ViewTab( tab = ViewTab(
label=_('Front Ports'), label=_('Front Ports'),
badge=lambda obj: obj.frontports.count(), badge=lambda obj: obj.front_port_count,
permission='dcim.view_frontport', permission='dcim.view_frontport',
weight=530, weight=530,
hide_if_empty=True hide_if_empty=True
@ -1972,7 +1972,7 @@ class DeviceRearPortsView(DeviceComponentsView):
template_name = 'dcim/device/rearports.html' template_name = 'dcim/device/rearports.html'
tab = ViewTab( tab = ViewTab(
label=_('Rear Ports'), label=_('Rear Ports'),
badge=lambda obj: obj.rearports.count(), badge=lambda obj: obj.rear_port_count,
permission='dcim.view_rearport', permission='dcim.view_rearport',
weight=540, weight=540,
hide_if_empty=True hide_if_empty=True
@ -1987,7 +1987,7 @@ class DeviceModuleBaysView(DeviceComponentsView):
template_name = 'dcim/device/modulebays.html' template_name = 'dcim/device/modulebays.html'
tab = ViewTab( tab = ViewTab(
label=_('Module Bays'), label=_('Module Bays'),
badge=lambda obj: obj.modulebays.count(), badge=lambda obj: obj.module_bay_count,
permission='dcim.view_modulebay', permission='dcim.view_modulebay',
weight=510, weight=510,
hide_if_empty=True hide_if_empty=True
@ -2002,7 +2002,7 @@ class DeviceDeviceBaysView(DeviceComponentsView):
template_name = 'dcim/device/devicebays.html' template_name = 'dcim/device/devicebays.html'
tab = ViewTab( tab = ViewTab(
label=_('Device Bays'), label=_('Device Bays'),
badge=lambda obj: obj.devicebays.count(), badge=lambda obj: obj.device_bay_count,
permission='dcim.view_devicebay', permission='dcim.view_devicebay',
weight=500, weight=500,
hide_if_empty=True hide_if_empty=True
@ -2017,7 +2017,7 @@ class DeviceInventoryView(DeviceComponentsView):
template_name = 'dcim/device/inventory.html' template_name = 'dcim/device/inventory.html'
tab = ViewTab( tab = ViewTab(
label=_('Inventory Items'), label=_('Inventory Items'),
badge=lambda obj: obj.inventoryitems.count(), badge=lambda obj: obj.inventory_item_count,
permission='dcim.view_inventoryitem', permission='dcim.view_inventoryitem',
weight=590, weight=590,
hide_if_empty=True hide_if_empty=True
@ -2452,11 +2452,13 @@ class InterfaceView(generic.ObjectView):
queryset = Interface.objects.all() queryset = Interface.objects.all()
def get_extra_context(self, request, instance): def get_extra_context(self, request, instance):
# Get assigned VDC's # Get assigned VDCs
vdc_table = tables.VirtualDeviceContextTable( vdc_table = tables.VirtualDeviceContextTable(
data=instance.vdcs.restrict(request.user, 'view').prefetch_related('device'), data=instance.vdcs.restrict(request.user, 'view').prefetch_related('device'),
exclude=('tenant', 'tenant_group', 'primary_ip', 'primary_ip4', 'primary_ip6', 'comments', 'tags', exclude=(
'created', 'last_updated', 'actions', ), 'tenant', 'tenant_group', 'primary_ip', 'primary_ip4', 'primary_ip6', 'oob_ip', 'comments', 'tags',
'created', 'last_updated', 'actions',
),
orderable=False orderable=False
) )
@ -3225,9 +3227,7 @@ class InterfaceConnectionsListView(generic.ObjectListView):
# #
class VirtualChassisListView(generic.ObjectListView): class VirtualChassisListView(generic.ObjectListView):
queryset = VirtualChassis.objects.annotate( queryset = VirtualChassis.objects.all()
member_count=count_related(Device, 'virtual_chassis')
)
table = tables.VirtualChassisTable table = tables.VirtualChassisTable
filterset = filtersets.VirtualChassisFilterSet filterset = filtersets.VirtualChassisFilterSet
filterset_form = forms.VirtualChassisFilterForm filterset_form = forms.VirtualChassisFilterForm

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

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

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

@ -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({ raise ValidationError({
'choices': "Selection fields must specify at least one choice." 'choice_set': "Selection fields must specify a set of choices."
})
elif self.choice_set:
raise ValidationError({
'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

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

@ -98,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 = {
@ -134,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']

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
@ -219,7 +261,7 @@ class BookmarkTestCase(
def _get_url(self, action, instance=None): def _get_url(self, action, instance=None):
if action == 'list': if action == 'list':
return reverse('users:bookmarks') return reverse('account:bookmarks')
return super()._get_url(action, instance) return super()._get_url(action, instance)
def test_list_objects_anonymous(self): def test_list_objects_anonymous(self):

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

View File

@ -849,6 +849,24 @@ class IPAddress(PrimaryModel):
return self.address.version return self.address.version
return None return None
@property
def is_oob_ip(self):
if self.assigned_object:
parent = getattr(self.assigned_object, 'parent_object', None)
if parent.oob_ip_id == self.pk:
return True
return False
@property
def is_primary_ip(self):
if self.assigned_object:
parent = getattr(self.assigned_object, 'parent_object', None)
if self.family == 4 and parent.primary_ip4_id == self.pk:
return True
if self.family == 6 and parent.primary_ip6_id == self.pk:
return True
return False
def _set_mask_length(self, value): def _set_mask_length(self, value):
""" """
Expose the IPNetwork object's prefixlen attribute on the parent model so that it can be manipulated directly, Expose the IPNetwork object's prefixlen attribute on the parent model so that it can be manipulated directly,

View File

@ -52,13 +52,19 @@ def handle_prefix_deleted(instance, **kwargs):
@receiver(pre_delete, sender=IPAddress) @receiver(pre_delete, sender=IPAddress)
def clear_primary_ip(instance, **kwargs): def clear_primary_ip(instance, **kwargs):
""" """
When an IPAddress is deleted, trigger save() on any Devices/VirtualMachines for which it When an IPAddress is deleted, trigger save() on any Devices/VirtualMachines for which it was a primary IP.
was a primary IP.
""" """
field_name = f'primary_ip{instance.family}' field_name = f'primary_ip{instance.family}'
device = Device.objects.filter(**{field_name: instance}).first() if device := Device.objects.filter(**{field_name: instance}).first():
if device:
device.save() device.save()
virtualmachine = VirtualMachine.objects.filter(**{field_name: instance}).first() if virtualmachine := VirtualMachine.objects.filter(**{field_name: instance}).first():
if virtualmachine:
virtualmachine.save() virtualmachine.save()
@receiver(pre_delete, sender=IPAddress)
def clear_oob_ip(instance, **kwargs):
"""
When an IPAddress is deleted, trigger save() on any Devices for which it was a OOB IP.
"""
if device := Device.objects.filter(oob_ip=instance).first():
device.save()

View File

@ -8,6 +8,7 @@ from netbox.models.features import *
from utilities.mptt import TreeManager from utilities.mptt import TreeManager
from utilities.querysets import RestrictedQuerySet from utilities.querysets import RestrictedQuerySet
__all__ = ( __all__ = (
'ChangeLoggedModel', 'ChangeLoggedModel',
'NestedGroupModel', 'NestedGroupModel',

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,57 @@ ADMIN_MENU = Menu(
label=_('Admin'), label=_('Admin'),
icon_class='mdi mdi-account-multiple', icon_class='mdi mdi-account-multiple',
groups=( groups=(
MenuGroup(
label=_('Authentication'),
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', 'token', _('API Tokens')),
get_model_item('users', 'objectpermission', _('Permissions'), actions=['add']),
),
),
MenuGroup( MenuGroup(
label=_('Configuration'), label=_('Configuration'),
items=( items=(

View File

@ -21,6 +21,7 @@ class Registry(dict):
# Initialize the global registry # Initialize the global registry
registry = Registry({ registry = Registry({
'counter_fields': collections.defaultdict(dict),
'data_backends': dict(), 'data_backends': dict(),
'denormalized_fields': collections.defaultdict(list), 'denormalized_fields': collections.defaultdict(list),
'model_features': dict(), 'model_features': dict(),

View File

@ -469,6 +469,7 @@ EXEMPT_EXCLUDE_MODELS = (
('auth', 'group'), ('auth', 'group'),
('auth', 'user'), ('auth', 'user'),
('users', 'objectpermission'), ('users', 'objectpermission'),
('users', 'token'),
) )
# All URLs starting with a string listed here are exempt from login enforcement # All URLs starting with a string listed here are exempt from login enforcement

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

@ -32,10 +32,13 @@ _patterns = [
path('extras/', include('extras.urls')), path('extras/', include('extras.urls')),
path('ipam/', include('ipam.urls')), path('ipam/', include('ipam.urls')),
path('tenancy/', include('tenancy.urls')), path('tenancy/', include('tenancy.urls')),
path('user/', include('users.urls')), path('users/', include('users.urls')),
path('virtualization/', include('virtualization.urls')), path('virtualization/', include('virtualization.urls')),
path('wireless/', include('wireless.urls')), path('wireless/', include('wireless.urls')),
# Current user views
path('user/', include('users.account_urls')),
# HTMX views # HTMX views
path('htmx/object-selector/', htmx.ObjectSelectorView.as_view(), name='htmx_object_selector'), path('htmx/object-selector/', htmx.ObjectSelectorView.as_view(), name='htmx_object_selector'),

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]

View File

@ -239,6 +239,17 @@
{% endif %} {% endif %}
</td> </td>
</tr> </tr>
<tr>
<th scope="row">Out-of-band IP</th>
<td>
{% if object.oob_ip %}
<a href="{{ object.oob_ip.get_absolute_url }}" id="oob_ip">{{ object.oob_ip.address.ip }}</a>
{% copy_content "oob_ip" %}
{% else %}
{{ ''|placeholder }}
{% endif %}
</td>
</tr>
{% if object.cluster %} {% if object.cluster %}
<tr> <tr>
<th>Cluster</th> <th>Cluster</th>

View File

@ -68,6 +68,7 @@
{% if object.pk %} {% if object.pk %}
{% render_field form.primary_ip4 %} {% render_field form.primary_ip4 %}
{% render_field form.primary_ip6 %} {% render_field form.primary_ip6 %}
{% render_field form.oob_ip %}
{% endif %} {% endif %}
</div> </div>

View File

@ -31,6 +31,16 @@
<th scope="row">Description</th> <th scope="row">Description</th>
<td>{{ object.description|placeholder }}</td> <td>{{ object.description|placeholder }}</td>
</tr> </tr>
<tr>
<th scope="row">Members</th>
<td>
{% if object.member_count %}
<a href="{% url 'dcim:device_list' %}?virtual_chassis_id={{ object.pk }}">{{ object.member_count }}</a>
{% else %}
{{ object.member_count }}
{% endif %}
</td>
</tr>
</table> </table>
</div> </div>
</div> </div>

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>

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

@ -19,22 +19,22 @@
{% endif %} {% endif %}
</li> </li>
<li> <li>
<a class="dropdown-item" href="{% url 'users:profile' %}"> <a class="dropdown-item" href="{% url 'account:profile' %}">
<i class="mdi mdi-account"></i> Profile <i class="mdi mdi-account"></i> Profile
</a> </a>
</li> </li>
<li> <li>
<a class="dropdown-item" href="{% url 'users:bookmarks' %}"> <a class="dropdown-item" href="{% url 'account:bookmarks' %}">
<i class="mdi mdi-bookmark"></i> Bookmarks <i class="mdi mdi-bookmark"></i> Bookmarks
</a> </a>
</li> </li>
<li> <li>
<a class="dropdown-item" href="{% url 'users:preferences' %}"> <a class="dropdown-item" href="{% url 'account:preferences' %}">
<i class="mdi mdi-wrench"></i> Preferences <i class="mdi mdi-wrench"></i> Preferences
</a> </a>
</li> </li>
<li> <li>
<a class="dropdown-item" href="{% url 'users:token_list' %}"> <a class="dropdown-item" href="{% url 'account:usertoken_list' %}">
<i class="mdi mdi-key"></i> API Tokens <i class="mdi mdi-key"></i> API Tokens
</a> </a>
</li> </li>

View File

@ -96,6 +96,14 @@
{% endfor %} {% endfor %}
</td> </td>
</tr> </tr>
<tr>
<td>Primary IP</td>
<td>{% checkmark object.is_primary_ip %}</td>
</tr>
<tr>
<td>OOB IP</td>
<td>{% checkmark object.is_oob_ip %}</td>
</tr>
</table> </table>
</div> </div>
</div> </div>

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 'account: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 'account: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 'account: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 'account: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 'account:usertoken_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 %}
@ -9,7 +9,7 @@
<form method="post" class="form form-horizontal"> <form method="post" class="form form-horizontal">
{% csrf_token %} {% csrf_token %}
<input type="hidden" name="return_url" value="{% url 'users:bookmarks' %}" /> <input type="hidden" name="return_url" value="{% url 'account:bookmarks' %}" />
{# Table #} {# Table #}
<div class="row"> <div class="row">

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 %}
@ -13,7 +13,7 @@
{% render_field form.new_password2 %} {% render_field form.new_password2 %}
</div> </div>
<div class="text-end"> <div class="text-end">
<a href="{% url 'users:profile' %}" class="btn btn-outline-danger">Cancel</a> <a href="{% url 'account:profile' %}" class="btn btn-outline-danger">Cancel</a>
<button type="submit" name="_update" class="btn btn-primary">Save</button> <button type="submit" name="_update" class="btn btn-primary">Save</button>
</div> </div>
</form> </form>

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 %}
@ -79,7 +79,7 @@
</div> </div>
<div class="text-end my-3"> <div class="text-end my-3">
<a class="btn btn-outline-secondary" href="{% url 'users:preferences' %}">Cancel</a> <a class="btn btn-outline-secondary" href="{% url 'account:preferences' %}">Cancel</a>
<button type="submit" name="_update" class="btn btn-primary">Save </button> <button type="submit" name="_update" class="btn btn-primary">Save </button>
</div> </div>
</form> </form>

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,69 @@
{% extends 'generic/object.html' %}
{% load form_helpers %}
{% load helpers %}
{% load i18n %}
{% load plugins %}
{% block breadcrumbs %}
<li class="breadcrumb-item"><a href="{% url 'account:usertoken_list' %}">{% trans "My API Tokens" %}</a></li>
{% endblock breadcrumbs %}
{% block title %}{% trans "Token" %} {{ object }}{% endblock %}
{% block subtitle %}{% endblock %}
{% block content %}
<div class="row">
<div class="col col-md-12">
{% if key and not settings.ALLOW_TOKEN_RETRIEVAL %}
<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-content" data-clipboard-target="#token_id" title="Copy to clipboard">copy the token value</a> below and store it securely.
</div>
{% endif %}
<div class="card">
<h5 class="card-header">{% trans "Token" %}</h5>
<div class="card-body">
<table class="table table-hover attr-table">
<tr>
<th scope="row">{% trans "Key" %}</th>
<td>
{% if key %}
<div class="float-end">
{% copy_content "token_id" %}
</div>
<div id="token_id">{{ key }}</div>
{% else %}
{{ object.partial }}
{% endif %}
</td>
</tr>
<tr>
<th scope="row">{% trans "Description" %}</th>
<td>{{ object.description|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Write enabled" %}</th>
<td>{% checkmark object.write_enabled %}</td>
</tr>
<tr>
<th scope="row">{% trans "Created" %}</th>
<td>{{ object.created|annotated_date }}</td>
</tr>
<tr>
<th scope="row">{% trans "Expires" %}</th>
<td>{{ object.expires|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Last used" %}</th>
<td>{{ object.last_used|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Allowed IPs" %}</th>
<td>{{ object.allowed_ips|join:", "|placeholder }}</td>
</tr>
</table>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@ -1,13 +1,13 @@
{% 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 %}
{% block title %}API Tokens{% endblock %} {% block title %}My API Tokens{% endblock %}
{% block content %} {% block content %}
<div class="row"> <div class="row">
<div class="col col-md-12 text-end"> <div class="col col-md-12 text-end">
<a href="{% url 'users:token_add' %}" class="btn btn-sm btn-primary my-3"> <a href="{% url 'account:usertoken_add' %}" class="btn btn-sm btn-primary my-3">
<span class="mdi mdi-plus-thick" aria-hidden="true"></span> Add a Token <span class="mdi mdi-plus-thick" aria-hidden="true"></span> Add a Token
</a> </a>
</div> </div>

View File

@ -1,58 +0,0 @@
{% extends 'generic/object.html' %}
{% load form_helpers %}
{% load helpers %}
{% load plugins %}
{% block content %}
<div class="row">
<div class="col col-md-12">
{% if not settings.ALLOW_TOKEN_RETRIEVAL %}
<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-content" data-clipboard-target="#token_id" title="Copy to clipboard">copy the token value</a> below and store it securely.
</div>
{% endif %}
<div class="card">
<h5 class="card-header">Token</h5>
<div class="card-body">
<table class="table table-hover attr-table">
<tr>
<th scope="row">Key</th>
<td>
<div class="float-end">
{% copy_content "token_id" %}
</div>
<div id="token_id">{{ key }}</div>
</td>
</tr>
<tr>
<th scope="row">Description</th>
<td>{{ object.description|placeholder }}</td>
</tr>
<tr>
<th scope="row">User</th>
<td>{{ object.user }}</td>
</tr>
<tr>
<th scope="row">Created</th>
<td>{{ object.created|annotated_date }}</td>
</tr>
<tr>
<th scope="row">Expires</th>
<td>
{% if object.expires %}
{{ object.expires|annotated_date }}
{% else %}
<span>Never</span>
{% endif %}
</td>
</tr>
</table>
</div>
</div>
<div class="col col-md-12 text-center">
<a href="{% url 'users:token_add' %}" class="btn btn-outline-primary">Add Another</a>
<a href="{{ return_url }}" class="btn btn-outline-danger">Cancel</a>
</div>
</div>
</div>
{% endblock %}

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,56 @@
{% extends 'generic/object.html' %}
{% load i18n %}
{% load helpers %}
{% load render_table from django_tables2 %}
{% block title %}{% trans "Token" %} {{ object }}{% endblock %}
{% block subtitle %}{% endblock %}
{% block content %}
<div class="row mb-3">
<div class="col-md-6">
<div class="card">
<h5 class="card-header">{% trans "Token" %}</h5>
<div class="card-body">
<table class="table table-hover attr-table">
<tr>
<th scope="row">{% trans "Key" %}</th>
<td>{% if settings.ALLOW_TOKEN_RETRIEVAL %}{{ object.key }}{% else %}{{ object.partial }}{% endif %}</td>
</tr>
<tr>
<th scope="row">{% trans "User" %}</th>
<td>
<a href="{% url 'users:netboxuser' pk=object.user.pk %}">{{ object.user }}</a>
</td>
</tr>
<tr>
<th scope="row">{% trans "Description" %}</th>
<td>{{ object.description|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Write enabled" %}</th>
<td>{% checkmark object.write_enabled %}</td>
</tr>
<tr>
<th scope="row">{% trans "Created" %}</th>
<td>{{ object.created|annotated_date }}</td>
</tr>
<tr>
<th scope="row">{% trans "Expires" %}</th>
<td>{{ object.expires|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Last used" %}</th>
<td>{{ object.last_used|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Allowed IPs" %}</th>
<td>{{ object.allowed_ips|join:", "|placeholder }}</td>
</tr>
</table>
</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

@ -0,0 +1,18 @@
from django.urls import include, path
from utilities.urls import get_model_urls
from . import views
app_name = 'account'
urlpatterns = [
# Account views
path('profile/', views.ProfileView.as_view(), name='profile'),
path('bookmarks/', views.BookmarkListView.as_view(), name='bookmarks'),
path('preferences/', views.UserConfigView.as_view(), name='preferences'),
path('password/', views.ChangePasswordView.as_view(), name='change_password'),
path('api-tokens/', views.UserTokenListView.as_view(), name='usertoken_list'),
path('api-tokens/add/', views.UserTokenEditView.as_view(), name='usertoken_add'),
path('api-tokens/<int:pk>/', include(get_model_urls('users', 'usertoken'))),
]

View File

@ -1,11 +1,6 @@
from django.contrib import admin from django.contrib import admin
from django.contrib.auth.admin import UserAdmin as UserAdmin_
from django.contrib.auth.models import Group, User from django.contrib.auth.models import Group, User
from users.models import ObjectPermission, Token
from . import filters, forms, inlines
# #
# Users & groups # Users & groups
# #
@ -13,117 +8,3 @@ from . import filters, forms, inlines
# Unregister the built-in GroupAdmin and UserAdmin classes so that we can use our custom admin classes below # Unregister the built-in GroupAdmin and UserAdmin classes so that we can use our custom admin classes below
admin.site.unregister(Group) 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
#
@admin.register(Token)
class TokenAdmin(admin.ModelAdmin):
form = forms.TokenAdminForm
list_display = [
'key', 'user', 'created', 'expires', 'last_used', 'write_enabled', 'description', 'list_allowed_ips'
]
def list_allowed_ips(self, obj):
return obj.allowed_ips or 'Any'
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,136 +0,0 @@
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 users.constants import CONSTRAINT_TOKEN_USER, OBJECTPERMISSION_OBJECT_TYPES
from users.models import ObjectPermission, Token
from utilities.forms.fields import ContentTypeMultipleChoiceField
from utilities.permissions import qs_filter_from_constraints
__all__ = (
'GroupAdminForm',
'ObjectPermissionForm',
'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):
key = forms.CharField(
required=False,
help_text=_("If no key is provided, one will be generated automatically.")
)
class Meta:
fields = [
'user', 'key', 'write_enabled', 'expires', 'description', 'allowed_ips'
]
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

@ -10,6 +10,7 @@ from users.models import ObjectPermission, Token
__all__ = ( __all__ = (
'GroupFilterSet', 'GroupFilterSet',
'ObjectPermissionFilterSet', 'ObjectPermissionFilterSet',
'TokenFilterSet',
'UserFilterSet', 'UserFilterSet',
) )
@ -49,7 +50,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 +116,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 +162,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,130 +0,0 @@
from django import forms
from django.conf import settings
from django.contrib.auth.forms import AuthenticationForm, PasswordChangeForm as DjangoPasswordChangeForm
from django.contrib.postgres.forms import SimpleArrayField
from django.utils.html import mark_safe
from django.utils.translation import gettext as _
from ipam.formfields import IPNetworkFormField
from ipam.validators import prefix_validator
from netbox.preferences import PREFERENCES
from utilities.forms import BootstrapMixin
from utilities.forms.widgets import DateTimePicker
from utilities.utils import flatten_dict
from .models import Token, UserConfig
class LoginForm(BootstrapMixin, AuthenticationForm):
pass
class PasswordChangeForm(BootstrapMixin, DjangoPasswordChangeForm):
pass
class UserConfigFormMetaclass(forms.models.ModelFormMetaclass):
def __new__(mcs, name, bases, attrs):
# Emulate a declared field for each supported user preference
preference_fields = {}
for field_name, preference in PREFERENCES.items():
description = f'{preference.description}<br />' if preference.description else ''
help_text = f'{description}<code>{field_name}</code>'
field_kwargs = {
'label': preference.label,
'choices': preference.choices,
'help_text': mark_safe(help_text),
'coerce': preference.coerce,
'required': False,
'widget': forms.Select,
}
preference_fields[field_name] = forms.TypedChoiceField(**field_kwargs)
attrs.update(preference_fields)
return super().__new__(mcs, name, bases, attrs)
class UserConfigForm(BootstrapMixin, forms.ModelForm, metaclass=UserConfigFormMetaclass):
fieldsets = (
('User Interface', (
'pagination.per_page',
'pagination.placement',
'ui.colormode',
)),
('Miscellaneous', (
'data_format',
)),
)
# List of clearable preferences
pk = forms.MultipleChoiceField(
choices=[],
required=False
)
class Meta:
model = UserConfig
fields = ()
def __init__(self, *args, instance=None, **kwargs):
# Get initial data from UserConfig instance
initial_data = flatten_dict(instance.data)
kwargs['initial'] = initial_data
super().__init__(*args, instance=instance, **kwargs)
# Compile clearable preference choices
self.fields['pk'].choices = (
(f'tables.{table_name}', '') for table_name in instance.data.get('tables', [])
)
def save(self, *args, **kwargs):
# Set UserConfig data
for pref_name, value in self.cleaned_data.items():
if pref_name == 'pk':
continue
self.instance.set(pref_name, value, commit=False)
# Clear selected preferences
for preference in self.cleaned_data['pk']:
self.instance.clear(preference)
return super().save(*args, **kwargs)
@property
def plugin_fields(self):
return [
name for name in self.fields.keys() if name.startswith('plugins.')
]
class TokenForm(BootstrapMixin, forms.ModelForm):
key = forms.CharField(
required=False,
help_text=_("If no key is provided, one will be generated automatically.")
)
allowed_ips = SimpleArrayField(
base_field=IPNetworkFormField(validators=[prefix_validator]),
required=False,
label=_('Allowed IPs'),
help_text=_('Allowed IPv4/IPv6 networks from where the token can be used. Leave blank for no restrictions. '
'Example: <code>10.1.1.0/24,192.168.10.16/32,2001:db8:1::/64</code>'),
)
class Meta:
model = Token
fields = [
'key', 'write_enabled', 'expires', 'description', 'allowed_ips',
]
widgets = {
'expires': DateTimePicker(),
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Omit the key field if token retrieval is not permitted
if self.instance.pk and not settings.ALLOW_TOKEN_RETRIEVAL:
del self.fields['key']

View File

@ -0,0 +1,5 @@
from .authentication import *
from .bulk_edit import *
from .bulk_import import *
from .filtersets import *
from .model_forms 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

@ -0,0 +1,111 @@
from django import forms
from django.contrib.postgres.forms import SimpleArrayField
from django.utils.translation import gettext_lazy as _
from ipam.formfields import IPNetworkFormField
from ipam.validators import prefix_validator
from users.models import *
from utilities.forms import BootstrapMixin, BulkEditForm
from utilities.forms.widgets import BulkEditNullBooleanSelect, DateTimePicker
__all__ = (
'ObjectPermissionBulkEditForm',
'UserBulkEditForm',
'TokenBulkEditForm',
)
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',)
class TokenBulkEditForm(BulkEditForm):
pk = forms.ModelMultipleChoiceField(
queryset=Token.objects.all(),
widget=forms.MultipleHiddenInput
)
write_enabled = forms.NullBooleanField(
required=False,
widget=BulkEditNullBooleanSelect,
label=_('Write enabled')
)
description = forms.CharField(
max_length=200,
required=False,
label=_('Description')
)
expires = forms.DateTimeField(
required=False,
widget=DateTimePicker(),
label=_('Expires')
)
allowed_ips = SimpleArrayField(
base_field=IPNetworkFormField(validators=[prefix_validator]),
required=False,
label=_('Allowed IPs')
)
model = Token
fieldsets = (
(None, ('write_enabled', 'description', 'expires', 'allowed_ips')),
)
nullable_fields = (
'expires', 'description', 'allowed_ips',
)

View File

@ -0,0 +1,48 @@
from django import forms
from django.utils.translation import gettext as _
from users.models import *
from utilities.forms import CSVModelForm
__all__ = (
'GroupImportForm',
'UserImportForm',
'TokenImportForm',
)
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)
class TokenImportForm(CSVModelForm):
key = forms.CharField(
label=_('Key'),
required=False,
help_text=_("If no key is provided, one will be generated automatically.")
)
class Meta:
model = Token
fields = ('user', 'key', 'write_enabled', 'expires', 'description',)

View File

@ -0,0 +1,146 @@
from django import forms
from extras.forms.mixins import SavedFiltersMixin
from utilities.forms import FilterForm
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
from utilities.forms.widgets import DateTimePicker
__all__ = (
'GroupFilterForm',
'ObjectPermissionFilterForm',
'UserFilterForm',
'TokenFilterForm',
)
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'),
)
class TokenFilterForm(SavedFiltersMixin, FilterForm):
model = Token
fieldsets = (
(None, ('q', 'filter_id',)),
(_('Token'), ('user_id', 'write_enabled', 'expires', 'last_used')),
)
user_id = DynamicModelMultipleChoiceField(
queryset=get_user_model().objects.all(),
required=False,
label=_('User')
)
write_enabled = forms.NullBooleanField(
required=False,
widget=forms.Select(
choices=BOOLEAN_WITH_BLANK_CHOICES
),
label=_('Write Enabled'),
)
expires = forms.DateTimeField(
required=False,
label=_('Expires'),
widget=DateTimePicker()
)
last_used = forms.DateTimeField(
required=False,
label=_('Last Used'),
widget=DateTimePicker()
)

View File

@ -0,0 +1,403 @@
from django import forms
from django.conf import settings
from django.contrib.auth import get_user_model
from django.contrib.auth.models import Group
from django.contrib.contenttypes.models import ContentType
from django.contrib.postgres.forms import SimpleArrayField
from django.core.exceptions import FieldError
from django.utils.html import mark_safe
from django.utils.translation import gettext_lazy as _
from ipam.formfields import IPNetworkFormField
from ipam.validators import prefix_validator
from netbox.preferences import PREFERENCES
from users.constants import *
from users.models import *
from utilities.forms import BootstrapMixin
from utilities.forms.fields import ContentTypeMultipleChoiceField, DynamicModelMultipleChoiceField
from utilities.forms.widgets import DateTimePicker
from utilities.permissions import qs_filter_from_constraints
from utilities.utils import flatten_dict
__all__ = (
'UserTokenForm',
'GroupForm',
'ObjectPermissionForm',
'TokenForm',
'UserConfigForm',
'UserForm',
'TokenForm',
)
class UserConfigFormMetaclass(forms.models.ModelFormMetaclass):
def __new__(mcs, name, bases, attrs):
# Emulate a declared field for each supported user preference
preference_fields = {}
for field_name, preference in PREFERENCES.items():
description = f'{preference.description}<br />' if preference.description else ''
help_text = f'{description}<code>{field_name}</code>'
field_kwargs = {
'label': preference.label,
'choices': preference.choices,
'help_text': mark_safe(help_text),
'coerce': preference.coerce,
'required': False,
'widget': forms.Select,
}
preference_fields[field_name] = forms.TypedChoiceField(**field_kwargs)
attrs.update(preference_fields)
return super().__new__(mcs, name, bases, attrs)
class UserConfigForm(BootstrapMixin, forms.ModelForm, metaclass=UserConfigFormMetaclass):
fieldsets = (
(_('User Interface'), (
'pagination.per_page',
'pagination.placement',
'ui.colormode',
)),
(_('Miscellaneous'), (
'data_format',
)),
)
# List of clearable preferences
pk = forms.MultipleChoiceField(
label=_('Pk'),
choices=[],
required=False
)
class Meta:
model = UserConfig
fields = ()
def __init__(self, *args, instance=None, **kwargs):
# Get initial data from UserConfig instance
initial_data = flatten_dict(instance.data)
kwargs['initial'] = initial_data
super().__init__(*args, instance=instance, **kwargs)
# Compile clearable preference choices
self.fields['pk'].choices = (
(f'tables.{table_name}', '') for table_name in instance.data.get('tables', [])
)
def save(self, *args, **kwargs):
# Set UserConfig data
for pref_name, value in self.cleaned_data.items():
if pref_name == 'pk':
continue
self.instance.set(pref_name, value, commit=False)
# Clear selected preferences
for preference in self.cleaned_data['pk']:
self.instance.clear(preference)
return super().save(*args, **kwargs)
@property
def plugin_fields(self):
return [
name for name in self.fields.keys() if name.startswith('plugins.')
]
class UserTokenForm(BootstrapMixin, forms.ModelForm):
key = forms.CharField(
label=_('Key'),
required=False,
help_text=_("If no key is provided, one will be generated automatically.")
)
allowed_ips = SimpleArrayField(
base_field=IPNetworkFormField(validators=[prefix_validator]),
required=False,
label=_('Allowed IPs'),
help_text=_(
'Allowed IPv4/IPv6 networks from where the token can be used. Leave blank for no restrictions. '
'Example: <code>10.1.1.0/24,192.168.10.16/32,2001:db8:1::/64</code>'
),
)
class Meta:
model = Token
fields = [
'key', 'write_enabled', 'expires', 'description', 'allowed_ips',
]
widgets = {
'expires': DateTimePicker(),
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Omit the key field if token retrieval is not permitted
if self.instance.pk and not settings.ALLOW_TOKEN_RETRIEVAL:
del self.fields['key']
class TokenForm(UserTokenForm):
user = forms.ModelChoiceField(
queryset=get_user_model().objects.order_by(
'username'
),
required=False
)
class Meta:
model = Token
fields = [
'user', 'key', 'write_enabled', 'expires', 'description', 'allowed_ips',
]
widgets = {
'expires': DateTimePicker(),
}
class UserForm(BootstrapMixin, forms.ModelForm):
password = forms.CharField(
label=_('Password'),
widget=forms.PasswordInput(),
required=True,
)
confirm_password = forms.CharField(
label=_('Confirm password'),
widget=forms.PasswordInput(),
required=True,
help_text=_("Enter the same password as before, for verification."),
)
groups = DynamicModelMultipleChoiceField(
label=_('Groups'),
required=False,
queryset=Group.objects.all()
)
object_permissions = DynamicModelMultipleChoiceField(
required=False,
label=_('Permissions'),
queryset=ObjectPermission.objects.all(),
to_field_name='pk',
)
fieldsets = (
(_('User'), ('username', 'password', 'confirm_password', 'first_name', 'last_name', 'email')),
(_('Groups'), ('groups', )),
(_('Status'), ('is_active', 'is_staff', 'is_superuser')),
(_('Permissions'), ('object_permissions',)),
)
class Meta:
model = NetBoxUser
fields = [
'username', 'first_name', 'last_name', 'email', 'groups', 'object_permissions',
'is_active', 'is_staff', 'is_superuser',
]
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if self.instance.pk:
# Populate assigned permissions
self.fields['object_permissions'].initial = self.instance.object_permissions.values_list('id', flat=True)
# Password fields are optional for existing Users
self.fields['password'].required = False
self.fields['password'].widget.attrs.pop('required')
self.fields['confirm_password'].required = False
self.fields['confirm_password'].widget.attrs.pop('required')
def save(self, *args, **kwargs):
instance = super().save(*args, **kwargs)
# Update assigned permissions
instance.object_permissions.set(self.cleaned_data['object_permissions'])
# On edit, check if we have to save the password
if self.cleaned_data.get('password'):
instance.set_password(self.cleaned_data.get('password'))
instance.save()
return instance
def clean(self):
# Check that password confirmation matches if password is set
if self.cleaned_data['password'] and self.cleaned_data['password'] != self.cleaned_data['confirm_password']:
raise forms.ValidationError(_("Passwords do not match! Please check your input and try again."))
# TODO: Move this logic to the NetBoxUser class
def clean_username(self):
"""Reject usernames that differ only in case."""
instance = getattr(self, 'instance', None)
if instance:
qs = self._meta.model.objects.exclude(pk=instance.pk)
else:
qs = self._meta.model.objects.all()
username = self.cleaned_data.get("username")
if (
username and qs.filter(username__iexact=username).exists()
):
raise forms.ValidationError(
_("user with this username already exists")
)
return username
class GroupForm(BootstrapMixin, forms.ModelForm):
users = DynamicModelMultipleChoiceField(
label=_('Users'),
required=False,
queryset=get_user_model().objects.all()
)
object_permissions = DynamicModelMultipleChoiceField(
required=False,
label=_('Permissions'),
queryset=ObjectPermission.objects.all(),
to_field_name='pk',
)
fieldsets = (
(None, ('name', )),
(_('Users'), ('users', )),
(_('Permissions'), ('object_permissions', )),
)
class Meta:
model = NetBoxGroup
fields = [
'name', 'users', 'object_permissions',
]
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Populate assigned users and permissions
if self.instance.pk:
self.fields['users'].initial = self.instance.user_set.values_list('id', flat=True)
self.fields['object_permissions'].initial = self.instance.object_permissions.values_list('id', flat=True)
def save(self, *args, **kwargs):
instance = super().save(*args, **kwargs)
# Update assigned users and permissions
instance.user_set.set(self.cleaned_data['users'])
instance.object_permissions.set(self.cleaned_data['object_permissions'])
return instance
class ObjectPermissionForm(BootstrapMixin, forms.ModelForm):
object_types = ContentTypeMultipleChoiceField(
label=_('Object types'),
queryset=ContentType.objects.all(),
limit_choices_to=OBJECTPERMISSION_OBJECT_TYPES,
widget=forms.SelectMultiple(attrs={'size': 6})
)
can_view = forms.BooleanField(
required=False
)
can_add = forms.BooleanField(
required=False
)
can_change = forms.BooleanField(
required=False
)
can_delete = forms.BooleanField(
required=False
)
actions = SimpleArrayField(
label=_('Additional actions'),
base_field=forms.CharField(),
required=False,
help_text=_('Actions granted in addition to those listed above')
)
users = DynamicModelMultipleChoiceField(
label=_('Users'),
required=False,
queryset=get_user_model().objects.all()
)
groups = DynamicModelMultipleChoiceField(
label=_('Groups'),
required=False,
queryset=Group.objects.all()
)
fieldsets = (
(None, ('name', 'description', 'enabled',)),
(_('Actions'), ('can_view', 'can_add', 'can_change', 'can_delete', 'actions')),
(_('Objects'), ('object_types', )),
(_('Assignment'), ('groups', 'users')),
(_('Constraints'), ('constraints',))
)
class Meta:
model = ObjectPermission
fields = [
'name', 'description', 'enabled', 'object_types', 'users', 'groups', 'constraints', 'actions',
]
help_texts = {
'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.'
)
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Make the actions field optional since the 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 forms.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 forms.ValidationError({
'constraints': _('Invalid filter for {model}: {e}').format(model=model, e=e)
})

View File

@ -0,0 +1,50 @@
# Generated by Django 4.1.9 on 2023-06-06 18:15
import django.contrib.auth.models
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('auth', '0012_alter_user_first_name_max_length'),
('users', '0003_token_allowed_ips_last_used'),
]
operations = [
migrations.CreateModel(
name='NetBoxGroup',
fields=[],
options={
'verbose_name': 'Group',
'proxy': True,
'indexes': [],
'constraints': [],
},
bases=('auth.group',),
managers=[
('objects', django.contrib.auth.models.GroupManager()),
],
),
migrations.CreateModel(
name='NetBoxUser',
fields=[],
options={
'verbose_name': 'User',
'proxy': True,
'indexes': [],
'constraints': [],
},
bases=('auth.user',),
managers=[
('objects', django.contrib.auth.models.UserManager()),
],
),
migrations.AlterModelOptions(
name='netboxgroup',
options={'ordering': ('name',), 'verbose_name': 'Group'},
),
migrations.AlterModelOptions(
name='netboxuser',
options={'ordering': ('username',), 'verbose_name': 'User'},
),
]

View File

@ -0,0 +1,25 @@
# Generated by Django 4.1.10 on 2023-07-25 15:19
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('users', '0004_netboxgroup_netboxuser'),
]
operations = [
migrations.CreateModel(
name='UserToken',
fields=[
],
options={
'proxy': True,
'indexes': [],
'constraints': [],
'verbose_name': 'token',
},
bases=('users.token',),
),
]

View File

@ -2,13 +2,14 @@ import binascii
import os import os
from django.conf import settings from django.conf import settings
from django.contrib.auth.models import Group, User from django.contrib.auth.models import Group, GroupManager, User, UserManager
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.contrib.postgres.fields import ArrayField from django.contrib.postgres.fields import ArrayField
from django.core.validators import MinLengthValidator from django.core.validators import MinLengthValidator
from django.db import models from django.db import models
from django.db.models.signals import post_save from django.db.models.signals import post_save
from django.dispatch import receiver from django.dispatch import receiver
from django.urls import reverse
from django.utils import timezone from django.utils import timezone
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
from netaddr import IPNetwork from netaddr import IPNetwork
@ -20,9 +21,12 @@ from utilities.utils import flatten_dict
from .constants import * from .constants import *
__all__ = ( __all__ = (
'NetBoxGroup',
'NetBoxUser',
'ObjectPermission', 'ObjectPermission',
'Token', 'Token',
'UserConfig', 'UserConfig',
'UserToken',
) )
@ -30,6 +34,7 @@ __all__ = (
# Proxy models for admin # Proxy models for admin
# #
class AdminGroup(Group): class AdminGroup(Group):
""" """
Proxy contrib.auth.models.Group for the admin UI Proxy contrib.auth.models.Group for the admin UI
@ -48,6 +53,44 @@ class AdminUser(User):
proxy = True proxy = True
class NetBoxUserManager(UserManager.from_queryset(RestrictedQuerySet)):
pass
class NetBoxGroupManager(GroupManager.from_queryset(RestrictedQuerySet)):
pass
class NetBoxUser(User):
"""
Proxy contrib.auth.models.User for the UI
"""
objects = NetBoxUserManager()
class Meta:
verbose_name = 'User'
proxy = True
ordering = ('username',)
def get_absolute_url(self):
return reverse('users:netboxuser', args=[self.pk])
class NetBoxGroup(Group):
"""
Proxy contrib.auth.models.User for the UI
"""
objects = NetBoxGroupManager()
class Meta:
verbose_name = 'Group'
proxy = True
ordering = ('name',)
def get_absolute_url(self):
return reverse('users:netboxgroup', args=[self.pk])
# #
# User preferences # User preferences
# #
@ -231,13 +274,20 @@ class Token(models.Model):
blank=True, blank=True,
null=True, null=True,
verbose_name='Allowed IPs', verbose_name='Allowed IPs',
help_text=_('Allowed IPv4/IPv6 networks from where the token can be used. Leave blank for no restrictions. ' help_text=_(
'Ex: "10.1.1.0/24, 192.168.10.16/32, 2001:DB8:1::/64"'), 'Allowed IPv4/IPv6 networks from where the token can be used. Leave blank for no restrictions. '
'Ex: "10.1.1.0/24, 192.168.10.16/32, 2001:DB8:1::/64"'
),
) )
objects = RestrictedQuerySet.as_manager()
def __str__(self): def __str__(self):
return self.key if settings.ALLOW_TOKEN_RETRIEVAL else self.partial return self.key if settings.ALLOW_TOKEN_RETRIEVAL else self.partial
def get_absolute_url(self):
return reverse('users:token', args=[self.pk])
@property @property
def partial(self): def partial(self):
return f'**********************************{self.key[-6:]}' if self.key else '' return f'**********************************{self.key[-6:]}' if self.key else ''
@ -272,6 +322,18 @@ class Token(models.Model):
return False return False
class UserToken(Token):
"""
Proxy model for users to manage their own API tokens.
"""
class Meta:
proxy = True
verbose_name = 'token'
def get_absolute_url(self):
return reverse('account:usertoken', args=[self.pk])
# #
# Permissions # Permissions
# #
@ -325,6 +387,22 @@ class ObjectPermission(models.Model):
def __str__(self): def __str__(self):
return self.name return self.name
@property
def can_view(self):
return 'view' in self.actions
@property
def can_add(self):
return 'add' in self.actions
@property
def can_change(self):
return 'change' in self.actions
@property
def can_delete(self):
return 'delete' in self.actions
def list_constraints(self): def list_constraints(self):
""" """
Return all constraint sets as a list (even if only a single set is defined). Return all constraint sets as a list (even if only a single set is defined).
@ -332,3 +410,6 @@ class ObjectPermission(models.Model):
if type(self.constraints) is not list: if type(self.constraints) is not list:
return [self.constraints] return [self.constraints]
return self.constraints return self.constraints
def get_absolute_url(self):
return reverse('users:objectpermission', args=[self.pk])

View File

@ -1,8 +1,14 @@
from .models import Token import django_tables2 as tables
from django.utils.translation import gettext as _
from netbox.tables import NetBoxTable, columns from netbox.tables import NetBoxTable, columns
from users.models import NetBoxGroup, NetBoxUser, ObjectPermission, Token, UserToken
__all__ = ( __all__ = (
'GroupTable',
'ObjectPermissionTable',
'TokenTable', 'TokenTable',
'UserTable',
) )
@ -25,17 +31,28 @@ class TokenActionsColumn(columns.ActionsColumn):
} }
class TokenTable(NetBoxTable): class UserTokenTable(NetBoxTable):
"""
Table for users to manager their own API tokens under account views.
"""
key = columns.TemplateColumn( key = columns.TemplateColumn(
template_code=TOKEN verbose_name=_('Key'),
template_code=TOKEN,
) )
write_enabled = columns.BooleanColumn( write_enabled = columns.BooleanColumn(
verbose_name='Write' verbose_name=_('Write Enabled')
)
created = columns.DateColumn(
verbose_name=_('Created'),
)
expires = columns.DateColumn(
verbose_name=_('Expires'),
)
last_used = columns.DateTimeColumn(
verbose_name=_('Last Used'),
) )
created = columns.DateColumn()
expired = columns.DateColumn()
last_used = columns.DateTimeColumn()
allowed_ips = columns.TemplateColumn( allowed_ips = columns.TemplateColumn(
verbose_name=_('Allowed IPs'),
template_code=ALLOWED_IPS template_code=ALLOWED_IPS
) )
actions = TokenActionsColumn( actions = TokenActionsColumn(
@ -43,8 +60,93 @@ class TokenTable(NetBoxTable):
extra_buttons=COPY_BUTTON extra_buttons=COPY_BUTTON
) )
class Meta(NetBoxTable.Meta):
model = UserToken
fields = (
'pk', 'id', 'key', 'description', 'write_enabled', 'created', 'expires', 'last_used', 'allowed_ips',
)
class TokenTable(UserTokenTable):
"""
General-purpose table for API token management.
"""
user = tables.Column(
linkify=True,
verbose_name=_('User')
)
class Meta(NetBoxTable.Meta): class Meta(NetBoxTable.Meta):
model = Token model = Token
fields = ( fields = (
'pk', 'description', 'key', 'write_enabled', 'created', 'expires', 'last_used', 'allowed_ips', 'pk', 'id', 'key', 'user', 'description', 'write_enabled', 'created', 'expires', 'last_used', 'allowed_ips',
)
class UserTable(NetBoxTable):
username = tables.Column(
linkify=True
)
groups = columns.ManyToManyColumn(
linkify_item=('users:netboxgroup', {'pk': tables.A('pk')})
)
is_active = columns.BooleanColumn()
is_staff = columns.BooleanColumn()
is_superuser = columns.BooleanColumn()
actions = columns.ActionsColumn(
actions=('edit', 'delete'),
)
class Meta(NetBoxTable.Meta):
model = NetBoxUser
fields = (
'pk', 'id', 'username', 'first_name', 'last_name', 'email', 'groups', 'is_active', 'is_staff',
'is_superuser',
)
default_columns = ('pk', 'username', 'first_name', 'last_name', 'email', 'is_active')
class GroupTable(NetBoxTable):
name = tables.Column(linkify=True)
actions = columns.ActionsColumn(
actions=('edit', 'delete'),
)
class Meta(NetBoxTable.Meta):
model = NetBoxGroup
fields = (
'pk', 'id', 'name', 'users_count',
)
default_columns = ('pk', 'name', 'users_count', )
class ObjectPermissionTable(NetBoxTable):
name = tables.Column(linkify=True)
object_types = columns.ContentTypesColumn()
enabled = columns.BooleanColumn()
can_view = columns.BooleanColumn()
can_add = columns.BooleanColumn()
can_change = columns.BooleanColumn()
can_delete = columns.BooleanColumn()
custom_actions = columns.ArrayColumn(
accessor=tables.A('actions')
)
users = columns.ManyToManyColumn(
linkify_item=('users:netboxuser', {'pk': tables.A('pk')})
)
groups = columns.ManyToManyColumn(
linkify_item=('users:netboxgroup', {'pk': tables.A('pk')})
)
actions = columns.ActionsColumn(
actions=('edit', 'delete'),
)
class Meta(NetBoxTable.Meta):
model = ObjectPermission
fields = (
'pk', 'id', 'name', 'enabled', 'object_types', 'can_view', 'can_add', 'can_change', 'can_delete',
'custom_actions', 'users', 'groups', 'constraints', 'description',
)
default_columns = (
'pk', 'name', 'enabled', 'object_types', 'can_view', 'can_add', 'can_change', 'can_delete', 'description',
) )

View File

@ -10,7 +10,6 @@ from users import filtersets
from users.models import ObjectPermission, Token from users.models import ObjectPermission, Token
from utilities.testing import BaseFilterSetTests from utilities.testing import BaseFilterSetTests
User = get_user_model() User = get_user_model()
@ -34,7 +33,8 @@ class UserTestCase(TestCase, BaseFilterSetTests):
first_name='Hank', first_name='Hank',
last_name='Hill', last_name='Hill',
email='hank@stricklandpropane.com', email='hank@stricklandpropane.com',
is_staff=True is_staff=True,
is_superuser=True
), ),
User( User(
username='User2', username='User2',
@ -83,13 +83,17 @@ class UserTestCase(TestCase, BaseFilterSetTests):
params = {'email': ['hank@stricklandpropane.com', 'dale@dalesdeadbug.com']} params = {'email': ['hank@stricklandpropane.com', 'dale@dalesdeadbug.com']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_is_active(self):
params = {'is_active': True}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
def test_is_staff(self): def test_is_staff(self):
params = {'is_staff': True} params = {'is_staff': True}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
def test_is_active(self): def test_is_superuser(self):
params = {'is_active': True} params = {'is_superuser': True}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
def test_group(self): def test_group(self):
groups = Group.objects.all()[:2] groups = Group.objects.all()[:2]
@ -191,6 +195,22 @@ class ObjectPermissionTestCase(TestCase, BaseFilterSetTests):
params = {'description': ['foobar1', 'foobar2']} params = {'description': ['foobar1', 'foobar2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_can_view(self):
params = {'can_view': True}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
def test_can_add(self):
params = {'can_add': True}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
def test_can_change(self):
params = {'can_change': True}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
def test_can_delete(self):
params = {'can_delete': True}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
class TokenTestCase(TestCase, BaseFilterSetTests): class TokenTestCase(TestCase, BaseFilterSetTests):
queryset = Token.objects.all() queryset = Token.objects.all()

View File

@ -0,0 +1,201 @@
from django.contrib.auth.models import Group
from django.contrib.contenttypes.models import ContentType
from users.models import *
from utilities.testing import ViewTestCases, create_test_user
class UserTestCase(
ViewTestCases.GetObjectViewTestCase,
ViewTestCases.CreateObjectViewTestCase,
ViewTestCases.EditObjectViewTestCase,
ViewTestCases.DeleteObjectViewTestCase,
ViewTestCases.ListObjectsViewTestCase,
ViewTestCases.BulkImportObjectsViewTestCase,
ViewTestCases.BulkEditObjectsViewTestCase,
ViewTestCases.BulkDeleteObjectsViewTestCase,
):
model = NetBoxUser
maxDiff = None
validation_excluded_fields = ['password']
def _get_queryset(self):
# Omit the user attached to the test client
return self.model.objects.exclude(username='testuser')
@classmethod
def setUpTestData(cls):
users = (
NetBoxUser(username='username1', first_name='first1', last_name='last1', email='user1@foo.com', password='pass1xxx'),
NetBoxUser(username='username2', first_name='first2', last_name='last2', email='user2@foo.com', password='pass2xxx'),
NetBoxUser(username='username3', first_name='first3', last_name='last3', email='user3@foo.com', password='pass3xxx'),
)
NetBoxUser.objects.bulk_create(users)
cls.form_data = {
'username': 'usernamex',
'first_name': 'firstx',
'last_name': 'lastx',
'email': 'userx@foo.com',
'password': 'pass1xxx',
'confirm_password': 'pass1xxx',
}
cls.csv_data = (
"username,first_name,last_name,email,password",
"username4,first4,last4,email4@foo.com,pass4xxx",
"username5,first5,last5,email5@foo.com,pass5xxx",
"username6,first6,last6,email6@foo.com,pass6xxx",
)
cls.csv_update_data = (
"id,first_name,last_name",
f"{users[0].pk},first7,last7",
f"{users[1].pk},first8,last8",
f"{users[2].pk},first9,last9",
)
cls.bulk_edit_data = {
'last_name': 'newlastname',
}
class GroupTestCase(
ViewTestCases.GetObjectViewTestCase,
ViewTestCases.CreateObjectViewTestCase,
ViewTestCases.EditObjectViewTestCase,
ViewTestCases.DeleteObjectViewTestCase,
ViewTestCases.ListObjectsViewTestCase,
ViewTestCases.BulkImportObjectsViewTestCase,
ViewTestCases.BulkDeleteObjectsViewTestCase,
):
model = NetBoxGroup
maxDiff = None
@classmethod
def setUpTestData(cls):
groups = (
Group(name='group1'),
Group(name='group2'),
Group(name='group3'),
)
Group.objects.bulk_create(groups)
cls.form_data = {
'name': 'groupx',
}
cls.csv_data = (
"name",
"group4"
"group5"
"group6"
)
cls.csv_update_data = (
"id,name",
f"{groups[0].pk},group7",
f"{groups[1].pk},group8",
f"{groups[2].pk},group9",
)
class ObjectPermissionTestCase(
ViewTestCases.GetObjectViewTestCase,
ViewTestCases.CreateObjectViewTestCase,
ViewTestCases.EditObjectViewTestCase,
ViewTestCases.DeleteObjectViewTestCase,
ViewTestCases.ListObjectsViewTestCase,
ViewTestCases.BulkEditObjectsViewTestCase,
ViewTestCases.BulkDeleteObjectsViewTestCase,
):
model = ObjectPermission
maxDiff = None
@classmethod
def setUpTestData(cls):
ct = ContentType.objects.get_by_natural_key('dcim', 'site')
permissions = (
ObjectPermission(name='Permission 1', actions=['view', 'add', 'delete']),
ObjectPermission(name='Permission 2', actions=['view', 'add', 'delete']),
ObjectPermission(name='Permission 3', actions=['view', 'add', 'delete']),
)
ObjectPermission.objects.bulk_create(permissions)
cls.form_data = {
'name': 'Permission X',
'description': 'A new permission',
'object_types': [ct.pk],
'actions': 'view,edit,delete',
}
cls.csv_data = (
"name",
"permission4"
"permission5"
"permission6"
)
cls.csv_update_data = (
"id,name,actions",
f"{permissions[0].pk},permission7",
f"{permissions[1].pk},permission8",
f"{permissions[2].pk},permission9",
)
cls.bulk_edit_data = {
'description': 'New description',
}
class TokenTestCase(
ViewTestCases.GetObjectViewTestCase,
ViewTestCases.CreateObjectViewTestCase,
ViewTestCases.EditObjectViewTestCase,
ViewTestCases.DeleteObjectViewTestCase,
ViewTestCases.ListObjectsViewTestCase,
ViewTestCases.BulkImportObjectsViewTestCase,
ViewTestCases.BulkEditObjectsViewTestCase,
ViewTestCases.BulkDeleteObjectsViewTestCase,
):
model = Token
maxDiff = None
@classmethod
def setUpTestData(cls):
users = (
create_test_user('User 1'),
create_test_user('User 2'),
)
tokens = (
Token(key='123456790123456789012345678901234567890A', user=users[0]),
Token(key='123456790123456789012345678901234567890B', user=users[0]),
Token(key='123456790123456789012345678901234567890C', user=users[1]),
)
Token.objects.bulk_create(tokens)
cls.form_data = {
'user': users[0].pk,
'description': 'testdescription',
}
cls.csv_data = (
"key,user,description",
f"123456790123456789012345678901234567890D,{users[0].pk},testdescriptionD",
f"123456790123456789012345678901234567890E,{users[1].pk},testdescriptionE",
f"123456790123456789012345678901234567890F,{users[1].pk},testdescriptionF",
)
cls.csv_update_data = (
"id,description",
f"{tokens[0].pk},testdescriptionH",
f"{tokens[1].pk},testdescriptionI",
f"{tokens[2].pk},testdescriptionJ",
)
cls.bulk_edit_data = {
'description': 'newdescription',
}

View File

@ -6,15 +6,34 @@ from . import views
app_name = 'users' app_name = 'users'
urlpatterns = [ urlpatterns = [
# User # Tokens
path('profile/', views.ProfileView.as_view(), name='profile'), path('tokens/', views.TokenListView.as_view(), name='token_list'),
path('bookmarks/', views.BookmarkListView.as_view(), name='bookmarks'), path('tokens/add/', views.TokenEditView.as_view(), name='token_add'),
path('preferences/', views.UserConfigView.as_view(), name='preferences'), path('tokens/import/', views.TokenBulkImportView.as_view(), name='token_import'),
path('password/', views.ChangePasswordView.as_view(), name='change_password'), path('tokens/edit/', views.TokenBulkEditView.as_view(), name='token_bulk_edit'),
path('tokens/delete/', views.TokenBulkDeleteView.as_view(), name='token_bulk_delete'),
path('tokens/<int:pk>/', include(get_model_urls('users', 'token'))),
# API tokens # Users
path('api-tokens/', views.TokenListView.as_view(), name='token_list'), path('users/', views.UserListView.as_view(), name='netboxuser_list'),
path('api-tokens/add/', views.TokenEditView.as_view(), name='token_add'), path('users/add/', views.UserEditView.as_view(), name='netboxuser_add'),
path('api-tokens/<int:pk>/', include(get_model_urls('users', 'token'))), path('users/edit/', views.UserBulkEditView.as_view(), name='netboxuser_bulk_edit'),
path('users/import/', views.UserBulkImportView.as_view(), name='netboxuser_import'),
path('users/delete/', views.UserBulkDeleteView.as_view(), name='netboxuser_bulk_delete'),
path('users/<int:pk>/', include(get_model_urls('users', 'netboxuser'))),
# Groups
path('groups/', views.GroupListView.as_view(), name='netboxgroup_list'),
path('groups/add/', views.GroupEditView.as_view(), name='netboxgroup_add'),
path('groups/import/', views.GroupBulkImportView.as_view(), name='netboxgroup_import'),
path('groups/delete/', views.GroupBulkDeleteView.as_view(), name='netboxgroup_bulk_delete'),
path('groups/<int:pk>/', include(get_model_urls('users', 'netboxgroup'))),
# Permissions
path('permissions/', views.ObjectPermissionListView.as_view(), name='objectpermission_list'),
path('permissions/add/', views.ObjectPermissionEditView.as_view(), name='objectpermission_add'),
path('permissions/edit/', views.ObjectPermissionBulkEditView.as_view(), name='objectpermission_bulk_edit'),
path('permissions/delete/', views.ObjectPermissionBulkDeleteView.as_view(), name='objectpermission_bulk_delete'),
path('permissions/<int:pk>/', include(get_model_urls('users', 'objectpermission'))),
] ]

View File

@ -6,6 +6,7 @@ from django.contrib.auth import login as auth_login, logout as auth_logout, upda
from django.contrib.auth.mixins import LoginRequiredMixin from django.contrib.auth.mixins import LoginRequiredMixin
from django.contrib.auth.models import update_last_login from django.contrib.auth.models import update_last_login
from django.contrib.auth.signals import user_logged_in from django.contrib.auth.signals import user_logged_in
from django.db.models import Count
from django.http import HttpResponseRedirect from django.http import HttpResponseRedirect
from django.shortcuts import get_object_or_404, redirect, render, resolve_url from django.shortcuts import get_object_or_404, redirect, render, resolve_url
from django.urls import reverse from django.urls import reverse
@ -19,12 +20,11 @@ from extras.models import Bookmark, ObjectChange
from extras.tables import BookmarkTable, ObjectChangeTable from extras.tables import BookmarkTable, ObjectChangeTable
from netbox.authentication import get_auth_backend_display, get_saml_idps from netbox.authentication import get_auth_backend_display, get_saml_idps
from netbox.config import get_config from netbox.config import get_config
from netbox.views.generic import ObjectListView from netbox.views import generic
from utilities.forms import ConfirmationForm from utilities.forms import ConfirmationForm
from utilities.views import register_model_view from utilities.views import register_model_view
from .forms import LoginForm, PasswordChangeForm, TokenForm, UserConfigForm from . import filtersets, forms, tables
from .models import Token, UserConfig from .models import NetBoxGroup, NetBoxUser, ObjectPermission, Token, UserConfig, UserToken
from .tables import TokenTable
# #
@ -70,7 +70,7 @@ class LoginView(View):
return auth_backends return auth_backends
def get(self, request): def get(self, request):
form = LoginForm(request) form = forms.LoginForm(request)
if request.user.is_authenticated: if request.user.is_authenticated:
logger = logging.getLogger('netbox.auth.login') logger = logging.getLogger('netbox.auth.login')
@ -83,7 +83,7 @@ class LoginView(View):
def post(self, request): def post(self, request):
logger = logging.getLogger('netbox.auth.login') logger = logging.getLogger('netbox.auth.login')
form = LoginForm(request, data=request.POST) form = forms.LoginForm(request, data=request.POST)
if form.is_valid(): if form.is_valid():
logger.debug("Login form validation was successful") logger.debug("Login form validation was successful")
@ -155,7 +155,7 @@ class LogoutView(View):
# #
class ProfileView(LoginRequiredMixin, View): class ProfileView(LoginRequiredMixin, View):
template_name = 'users/profile.html' template_name = 'users/account/profile.html'
def get(self, request): def get(self, request):
@ -174,11 +174,11 @@ class ProfileView(LoginRequiredMixin, View):
class UserConfigView(LoginRequiredMixin, View): class UserConfigView(LoginRequiredMixin, View):
template_name = 'users/preferences.html' template_name = 'users/account/preferences.html'
def get(self, request): def get(self, request):
userconfig = request.user.config userconfig = request.user.config
form = UserConfigForm(instance=userconfig) form = forms.UserConfigForm(instance=userconfig)
return render(request, self.template_name, { return render(request, self.template_name, {
'form': form, 'form': form,
@ -187,13 +187,13 @@ class UserConfigView(LoginRequiredMixin, View):
def post(self, request): def post(self, request):
userconfig = request.user.config userconfig = request.user.config
form = UserConfigForm(request.POST, instance=userconfig) form = forms.UserConfigForm(request.POST, instance=userconfig)
if form.is_valid(): if form.is_valid():
form.save() form.save()
messages.success(request, "Your preferences have been updated.") messages.success(request, "Your preferences have been updated.")
return redirect('users:preferences') return redirect('account:preferences')
return render(request, self.template_name, { return render(request, self.template_name, {
'form': form, 'form': form,
@ -202,15 +202,15 @@ class UserConfigView(LoginRequiredMixin, View):
class ChangePasswordView(LoginRequiredMixin, View): class ChangePasswordView(LoginRequiredMixin, View):
template_name = 'users/password.html' template_name = 'users/account/password.html'
def get(self, request): def get(self, request):
# LDAP users cannot change their password here # LDAP users cannot change their password here
if getattr(request.user, 'ldap_username', None): if getattr(request.user, 'ldap_username', None):
messages.warning(request, "LDAP-authenticated user credentials cannot be changed within NetBox.") messages.warning(request, "LDAP-authenticated user credentials cannot be changed within NetBox.")
return redirect('users:profile') return redirect('account:profile')
form = PasswordChangeForm(user=request.user) form = forms.PasswordChangeForm(user=request.user)
return render(request, self.template_name, { return render(request, self.template_name, {
'form': form, 'form': form,
@ -218,12 +218,12 @@ class ChangePasswordView(LoginRequiredMixin, View):
}) })
def post(self, request): def post(self, request):
form = PasswordChangeForm(user=request.user, data=request.POST) form = forms.PasswordChangeForm(user=request.user, data=request.POST)
if form.is_valid(): if form.is_valid():
form.save() form.save()
update_session_auth_hash(request, form.user) update_session_auth_hash(request, form.user)
messages.success(request, "Your password has been changed successfully.") messages.success(request, "Your password has been changed successfully.")
return redirect('users:profile') return redirect('account:profile')
return render(request, self.template_name, { return render(request, self.template_name, {
'form': form, 'form': form,
@ -235,9 +235,9 @@ class ChangePasswordView(LoginRequiredMixin, View):
# Bookmarks # Bookmarks
# #
class BookmarkListView(LoginRequiredMixin, ObjectListView): class BookmarkListView(LoginRequiredMixin, generic.ObjectListView):
table = BookmarkTable table = BookmarkTable
template_name = 'users/bookmarks.html' template_name = 'users/account/bookmarks.html'
def get_queryset(self, request): def get_queryset(self, request):
return Bookmark.objects.filter(user=request.user) return Bookmark.objects.filter(user=request.user)
@ -249,53 +249,61 @@ class BookmarkListView(LoginRequiredMixin, ObjectListView):
# #
# API tokens # User views for token management
# #
class TokenListView(LoginRequiredMixin, View): class UserTokenListView(LoginRequiredMixin, View):
def get(self, request): def get(self, request):
tokens = UserToken.objects.filter(user=request.user)
tokens = Token.objects.filter(user=request.user) table = tables.UserTokenTable(tokens)
table = TokenTable(tokens)
table.configure(request) table.configure(request)
return render(request, 'users/api_tokens.html', { return render(request, 'users/account/token_list.html', {
'tokens': tokens, 'tokens': tokens,
'active_tab': 'api-tokens', 'active_tab': 'api-tokens',
'table': table, 'table': table,
}) })
@register_model_view(Token, 'edit') @register_model_view(UserToken)
class TokenEditView(LoginRequiredMixin, View): class UserTokenView(LoginRequiredMixin, View):
def get(self, request, pk):
token = get_object_or_404(UserToken.objects.filter(user=request.user), pk=pk)
key = token.key if settings.ALLOW_TOKEN_RETRIEVAL else None
return render(request, 'users/account/token.html', {
'object': token,
'key': key,
})
@register_model_view(UserToken, 'edit')
class UserTokenEditView(LoginRequiredMixin, View):
def get(self, request, pk=None): def get(self, request, pk=None):
if pk: if pk:
token = get_object_or_404(Token.objects.filter(user=request.user), pk=pk) token = get_object_or_404(UserToken.objects.filter(user=request.user), pk=pk)
else: else:
token = Token(user=request.user) token = UserToken(user=request.user)
form = forms.UserTokenForm(instance=token)
form = TokenForm(instance=token)
return render(request, 'generic/object_edit.html', { return render(request, 'generic/object_edit.html', {
'object': token, 'object': token,
'form': form, 'form': form,
'return_url': reverse('users:token_list'), 'return_url': reverse('account:usertoken_list'),
}) })
def post(self, request, pk=None): def post(self, request, pk=None):
if pk: if pk:
token = get_object_or_404(Token.objects.filter(user=request.user), pk=pk) token = get_object_or_404(UserToken.objects.filter(user=request.user), pk=pk)
form = TokenForm(request.POST, instance=token) form = forms.UserTokenForm(request.POST, instance=token)
else: else:
token = Token(user=request.user) token = UserToken(user=request.user)
form = TokenForm(request.POST) form = forms.UserTokenForm(request.POST)
if form.is_valid(): if form.is_valid():
token = form.save(commit=False) token = form.save(commit=False)
token.user = request.user token.user = request.user
token.save() token.save()
@ -304,7 +312,7 @@ class TokenEditView(LoginRequiredMixin, View):
messages.success(request, msg) messages.success(request, msg)
if not pk and not settings.ALLOW_TOKEN_RETRIEVAL: if not pk and not settings.ALLOW_TOKEN_RETRIEVAL:
return render(request, 'users/api_token.html', { return render(request, 'users/account/token.html', {
'object': token, 'object': token,
'key': token.key, 'key': token.key,
'return_url': reverse('users:token_list'), 'return_url': reverse('users:token_list'),
@ -312,44 +320,216 @@ class TokenEditView(LoginRequiredMixin, View):
elif '_addanother' in request.POST: elif '_addanother' in request.POST:
return redirect(request.path) return redirect(request.path)
else: else:
return redirect('users:token_list') return redirect('account:usertoken_list')
return render(request, 'generic/object_edit.html', { return render(request, 'generic/object_edit.html', {
'object': token, 'object': token,
'form': form, 'form': form,
'return_url': reverse('users:token_list'), 'return_url': reverse('account:usertoken_list'),
'disable_addanother': not settings.ALLOW_TOKEN_RETRIEVAL 'disable_addanother': not settings.ALLOW_TOKEN_RETRIEVAL
}) })
@register_model_view(Token, 'delete') @register_model_view(UserToken, 'delete')
class TokenDeleteView(LoginRequiredMixin, View): class UserTokenDeleteView(LoginRequiredMixin, View):
def get(self, request, pk): def get(self, request, pk):
token = get_object_or_404(UserToken.objects.filter(user=request.user), pk=pk)
token = get_object_or_404(Token.objects.filter(user=request.user), pk=pk)
initial_data = {
'return_url': reverse('users:token_list'),
}
form = ConfirmationForm(initial=initial_data)
return render(request, 'generic/object_delete.html', { return render(request, 'generic/object_delete.html', {
'object': token, 'object': token,
'form': form, 'form': ConfirmationForm(),
'return_url': reverse('users:token_list'), 'return_url': reverse('account:usertoken_list'),
}) })
def post(self, request, pk): def post(self, request, pk):
token = get_object_or_404(UserToken.objects.filter(user=request.user), pk=pk)
token = get_object_or_404(Token.objects.filter(user=request.user), pk=pk)
form = ConfirmationForm(request.POST) form = ConfirmationForm(request.POST)
if form.is_valid(): if form.is_valid():
token.delete() token.delete()
messages.success(request, "Token deleted") messages.success(request, "Token deleted")
return redirect('users:token_list') return redirect('account:usertoken_list')
return render(request, 'generic/object_delete.html', { return render(request, 'generic/object_delete.html', {
'object': token, 'object': token,
'form': form, 'form': form,
'return_url': reverse('users:token_list'), 'return_url': reverse('account:usertoken_list'),
}) })
#
# Tokens
#
class TokenListView(generic.ObjectListView):
queryset = Token.objects.all()
filterset = filtersets.TokenFilterSet
filterset_form = forms.TokenFilterForm
table = tables.TokenTable
@register_model_view(Token)
class TokenView(generic.ObjectView):
queryset = Token.objects.all()
@register_model_view(Token, 'edit')
class TokenEditView(generic.ObjectEditView):
queryset = Token.objects.all()
form = forms.TokenForm
@register_model_view(Token, 'delete')
class TokenDeleteView(generic.ObjectDeleteView):
queryset = Token.objects.all()
class TokenBulkImportView(generic.BulkImportView):
queryset = Token.objects.all()
model_form = forms.TokenImportForm
class TokenBulkEditView(generic.BulkEditView):
queryset = Token.objects.all()
table = tables.TokenTable
form = forms.TokenBulkEditForm
class TokenBulkDeleteView(generic.BulkDeleteView):
queryset = Token.objects.all()
table = tables.TokenTable
#
# Users
#
class UserListView(generic.ObjectListView):
queryset = NetBoxUser.objects.all()
filterset = filtersets.UserFilterSet
filterset_form = forms.UserFilterForm
table = tables.UserTable
@register_model_view(NetBoxUser)
class UserView(generic.ObjectView):
queryset = NetBoxUser.objects.all()
template_name = 'users/user.html'
def get_extra_context(self, request, instance):
changelog = ObjectChange.objects.restrict(request.user, 'view').filter(user=request.user)[:20]
changelog_table = ObjectChangeTable(changelog)
return {
'changelog_table': changelog_table,
}
@register_model_view(NetBoxUser, 'edit')
class UserEditView(generic.ObjectEditView):
queryset = NetBoxUser.objects.all()
form = forms.UserForm
@register_model_view(NetBoxUser, 'delete')
class UserDeleteView(generic.ObjectDeleteView):
queryset = NetBoxUser.objects.all()
class UserBulkEditView(generic.BulkEditView):
queryset = NetBoxUser.objects.all()
filterset = filtersets.UserFilterSet
table = tables.UserTable
form = forms.UserBulkEditForm
class UserBulkImportView(generic.BulkImportView):
queryset = NetBoxUser.objects.all()
model_form = forms.UserImportForm
class UserBulkDeleteView(generic.BulkDeleteView):
queryset = NetBoxUser.objects.all()
filterset = filtersets.UserFilterSet
table = tables.UserTable
#
# Groups
#
class GroupListView(generic.ObjectListView):
queryset = NetBoxGroup.objects.annotate(users_count=Count('user'))
filterset = filtersets.GroupFilterSet
filterset_form = forms.GroupFilterForm
table = tables.GroupTable
@register_model_view(NetBoxGroup)
class GroupView(generic.ObjectView):
queryset = NetBoxGroup.objects.all()
template_name = 'users/group.html'
@register_model_view(NetBoxGroup, 'edit')
class GroupEditView(generic.ObjectEditView):
queryset = NetBoxGroup.objects.all()
form = forms.GroupForm
@register_model_view(NetBoxGroup, 'delete')
class GroupDeleteView(generic.ObjectDeleteView):
queryset = NetBoxGroup.objects.all()
class GroupBulkImportView(generic.BulkImportView):
queryset = NetBoxGroup.objects.all()
model_form = forms.GroupImportForm
class GroupBulkDeleteView(generic.BulkDeleteView):
queryset = NetBoxGroup.objects.annotate(users_count=Count('user'))
filterset = filtersets.GroupFilterSet
table = tables.GroupTable
#
# ObjectPermissions
#
class ObjectPermissionListView(generic.ObjectListView):
queryset = ObjectPermission.objects.all()
filterset = filtersets.ObjectPermissionFilterSet
filterset_form = forms.ObjectPermissionFilterForm
table = tables.ObjectPermissionTable
@register_model_view(ObjectPermission)
class ObjectPermissionView(generic.ObjectView):
queryset = ObjectPermission.objects.all()
template_name = 'users/objectpermission.html'
@register_model_view(ObjectPermission, 'edit')
class ObjectPermissionEditView(generic.ObjectEditView):
queryset = ObjectPermission.objects.all()
form = forms.ObjectPermissionForm
@register_model_view(ObjectPermission, 'delete')
class ObjectPermissionDeleteView(generic.ObjectDeleteView):
queryset = ObjectPermission.objects.all()
class ObjectPermissionBulkEditView(generic.BulkEditView):
queryset = ObjectPermission.objects.all()
filterset = filtersets.ObjectPermissionFilterSet
table = tables.ObjectPermissionTable
form = forms.ObjectPermissionBulkEditForm
class ObjectPermissionBulkDeleteView(generic.BulkDeleteView):
queryset = ObjectPermission.objects.all()
filterset = filtersets.ObjectPermissionFilterSet
table = tables.ObjectPermissionTable

View File

@ -0,0 +1,93 @@
from django.apps import apps
from django.db.models import F
from django.db.models.signals import post_delete, post_save
from netbox.registry import registry
from .fields import CounterCacheField
def get_counters_for_model(model):
"""
Return field mappings for all counters registered to the given model.
"""
return registry['counter_fields'][model].items()
def update_counter(model, pk, counter_name, value):
"""
Increment or decrement a counter field on an object identified by its model and primary key (PK). Positive values
will increment; negative values will decrement.
"""
model.objects.filter(pk=pk).update(
**{counter_name: F(counter_name) + value}
)
#
# Signal handlers
#
def post_save_receiver(sender, instance, **kwargs):
"""
Update counter fields on related objects when a TrackingModelMixin subclass is created or modified.
"""
for field_name, counter_name in get_counters_for_model(sender):
parent_model = sender._meta.get_field(field_name).related_model
new_pk = getattr(instance, field_name, None)
old_pk = instance.tracker.get(field_name) if field_name in instance.tracker else None
# Update the counters on the old and/or new parents as needed
if old_pk is not None:
update_counter(parent_model, old_pk, counter_name, -1)
if new_pk is not None:
update_counter(parent_model, new_pk, counter_name, 1)
def post_delete_receiver(sender, instance, **kwargs):
"""
Update counter fields on related objects when a TrackingModelMixin subclass is deleted.
"""
for field_name, counter_name in get_counters_for_model(sender):
parent_model = sender._meta.get_field(field_name).related_model
parent_pk = getattr(instance, field_name, None)
# Decrement the parent's counter by one
if parent_pk is not None:
update_counter(parent_model, parent_pk, counter_name, -1)
#
# Registration
#
def connect_counters(*models):
"""
Register counter fields and connect post_save & post_delete signal handlers for the affected models.
"""
for model in models:
# Find all CounterCacheFields on the model
counter_fields = [
field for field in model._meta.get_fields() if type(field) is CounterCacheField
]
for field in counter_fields:
to_model = apps.get_model(field.to_model_name)
# Register the counter in the registry
change_tracking_fields = registry['counter_fields'][to_model]
change_tracking_fields[f"{field.to_field_name}_id"] = field.name
# Connect the post_save and post_delete handlers
post_save.connect(
post_save_receiver,
sender=to_model,
weak=False,
dispatch_uid=f'{model._meta.label}.{field.name}'
)
post_delete.connect(
post_delete_receiver,
sender=to_model,
weak=False,
dispatch_uid=f'{model._meta.label}.{field.name}'
)

View File

@ -2,6 +2,7 @@ from collections import defaultdict
from django.contrib.contenttypes.fields import GenericForeignKey from django.contrib.contenttypes.fields import GenericForeignKey
from django.db import models from django.db import models
from django.utils.translation import gettext_lazy as _
from utilities.ordering import naturalize from utilities.ordering import naturalize
from .forms.widgets import ColorSelect from .forms.widgets import ColorSelect
@ -9,6 +10,7 @@ from .validators import ColorValidator
__all__ = ( __all__ = (
'ColorField', 'ColorField',
'CounterCacheField',
'NaturalOrderingField', 'NaturalOrderingField',
'NullableCharField', 'NullableCharField',
'RestrictedGenericForeignKey', 'RestrictedGenericForeignKey',
@ -143,3 +145,43 @@ class RestrictedGenericForeignKey(GenericForeignKey):
self.name, self.name,
False, False,
) )
class CounterCacheField(models.BigIntegerField):
"""
Counter field to keep track of related model counts.
"""
def __init__(self, to_model, to_field, *args, **kwargs):
if not isinstance(to_model, str):
raise TypeError(
_("%s(%r) is invalid. to_model parameter to CounterCacheField must be "
"a string in the format 'app.model'")
% (
self.__class__.__name__,
to_model,
)
)
if not isinstance(to_field, str):
raise TypeError(
_("%s(%r) is invalid. to_field parameter to CounterCacheField must be "
"a string in the format 'field'")
% (
self.__class__.__name__,
to_field,
)
)
self.to_model_name = to_model
self.to_field_name = to_field
kwargs['default'] = kwargs.get('default', 0)
kwargs['editable'] = False
super().__init__(*args, **kwargs)
def deconstruct(self):
name, path, args, kwargs = super().deconstruct()
kwargs["to_model"] = self.to_model_name
kwargs["to_field"] = self.to_field_name
return name, path, args, kwargs

View File

@ -1,6 +1,7 @@
from django import forms from django import forms
__all__ = ( __all__ = (
'ArrayWidget',
'ClearableFileInput', 'ClearableFileInput',
'MarkdownWidget', 'MarkdownWidget',
'NumberWithOptions', 'NumberWithOptions',
@ -43,3 +44,13 @@ class SlugWidget(forms.TextInput):
Subclass TextInput and add a slug regeneration button next to the form field. Subclass TextInput and add a slug regeneration button next to the form field.
""" """
template_name = 'widgets/sluginput.html' template_name = 'widgets/sluginput.html'
class ArrayWidget(forms.Textarea):
"""
Render each item of an array on a new line within a textarea for easy editing/
"""
def format_value(self, value):
if value is None or not len(value):
return None
return '\n'.join(value)

View File

View File

@ -0,0 +1,52 @@
from collections import defaultdict
from django.core.management.base import BaseCommand
from django.db.models import Count, OuterRef, Subquery
from netbox.registry import registry
class Command(BaseCommand):
help = "Force a recalculation of all cached counter fields"
@staticmethod
def collect_models():
"""
Query the registry to find all models which have one or more counter fields. Return a mapping of counter fields
to related query names for each model.
"""
models = defaultdict(dict)
for model, field_mappings in registry['counter_fields'].items():
for field_name, counter_name in field_mappings.items():
fk_field = model._meta.get_field(field_name) # Interface.device
parent_model = fk_field.related_model # Device
related_query_name = fk_field.related_query_name() # 'interfaces'
models[parent_model][counter_name] = related_query_name
return models
def update_counts(self, model, field_name, related_query):
"""
Perform a bulk update for the given model and counter field. For example,
update_counts(Device, '_interface_count', 'interfaces')
will effectively set
Device.objects.update(_interface_count=Count('interfaces'))
"""
self.stdout.write(f'Updating {model.__name__} {field_name}...')
subquery = Subquery(
model.objects.filter(pk=OuterRef('pk')).annotate(_count=Count(related_query)).values('_count')
)
return model.objects.update(**{
field_name: subquery
})
def handle(self, *model_names, **options):
for model, mappings in self.collect_models().items():
for field_name, related_query in mappings.items():
self.update_counts(model, field_name, related_query)
self.stdout.write(self.style.SUCCESS('Finished.'))

View File

@ -18,11 +18,10 @@ def get_permission_for_model(model, action):
:param model: A model or instance :param model: A model or instance
:param action: View, add, change, or delete (string) :param action: View, add, change, or delete (string)
""" """
return '{}.{}_{}'.format( # Resolve to the "concrete" model (for proxy models)
model._meta.app_label, model = model._meta.concrete_model
action,
model._meta.model_name return f'{model._meta.app_label}.{action}_{model._meta.model_name}'
)
def resolve_permission(name): def resolve_permission(name):

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