mirror of
https://github.com/netbox-community/netbox.git
synced 2025-08-25 00:36:11 -06:00
Merge branch 'feature' into feat/13170-rf_role
This commit is contained in:
commit
eb66dc1e8f
@ -60,7 +60,7 @@ NetBox supports limited custom validation for custom field values. Following are
|
||||
|
||||
### 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.
|
||||
|
||||
|
@ -8,6 +8,10 @@ The registry can be inspected by importing `registry` from `extras.registry`.
|
||||
|
||||
## Stores
|
||||
|
||||
### `counter_fields`
|
||||
|
||||
A dictionary mapping of models to foreign keys with which cached counter fields are associated.
|
||||
|
||||
### `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).
|
||||
|
@ -87,6 +87,10 @@ Each device may designate one primary IPv4 address and/or one primary IPv6 addre
|
||||
!!! tip
|
||||
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
|
||||
|
||||
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.)
|
||||
|
@ -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.
|
||||
|
||||
### 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
|
||||
|
||||
|
17
docs/models/extras/customfieldchoiceset.md
Normal file
17
docs/models/extras/customfieldchoiceset.md
Normal 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.
|
@ -7,16 +7,47 @@
|
||||
* 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.
|
||||
|
||||
### 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
|
||||
|
||||
* [#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
|
||||
* [#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
|
||||
|
||||
* [#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
|
||||
* [#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
|
||||
* [#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
|
||||
|
@ -327,12 +327,28 @@ class DeviceTypeSerializer(NetBoxModelSerializer):
|
||||
weight_unit = ChoiceField(choices=WeightUnitChoices, allow_blank=True, required=False, allow_null=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:
|
||||
model = DeviceType
|
||||
fields = [
|
||||
'id', 'url', 'display', 'manufacturer', 'default_platform', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth',
|
||||
'subdevice_role', 'airflow', 'weight', 'weight_unit', 'front_image', 'rear_image', 'description',
|
||||
'comments', 'tags', 'custom_fields', 'created', 'last_updated', 'device_count',
|
||||
'id', 'url', 'display', 'manufacturer', 'default_platform', 'model', 'slug', 'part_number', 'u_height',
|
||||
'is_full_depth', 'subdevice_role', 'airflow', 'weight', 'weight_unit', 'front_image', 'rear_image',
|
||||
'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_ip4 = 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()
|
||||
cluster = NestedClusterSerializer(required=False, allow_null=True)
|
||||
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)
|
||||
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:
|
||||
model = Device
|
||||
fields = [
|
||||
'id', 'url', 'display', 'name', 'device_type', 'device_role', 'tenant', 'platform', 'serial', 'asset_tag',
|
||||
'site', 'location', 'rack', 'position', 'face', 'latitude', 'longitude', 'parent_device', 'status', 'airflow',
|
||||
'primary_ip', 'primary_ip4', 'primary_ip6', 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority',
|
||||
'description', 'comments', 'config_template', 'local_context_data', 'tags', 'custom_fields', 'created',
|
||||
'last_updated',
|
||||
'site', 'location', 'rack', 'position', 'face', 'latitude', 'longitude', 'parent_device', 'status',
|
||||
'airflow', 'primary_ip', 'primary_ip4', 'primary_ip6', 'oob_ip', 'cluster', 'virtual_chassis',
|
||||
'vc_position', 'vc_priority', 'description', 'comments', 'config_template', 'local_context_data', 'tags',
|
||||
'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)
|
||||
@ -704,9 +735,11 @@ class DeviceWithConfigContextSerializer(DeviceSerializer):
|
||||
fields = [
|
||||
'id', 'url', 'display', 'name', 'device_type', 'device_role', 'tenant', 'platform', 'serial', 'asset_tag',
|
||||
'site', 'location', 'rack', 'position', 'face', 'parent_device', 'status', 'airflow', 'primary_ip',
|
||||
'primary_ip4', 'primary_ip6', 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'description',
|
||||
'comments', 'local_context_data', 'tags', 'custom_fields', 'config_context', 'config_template',
|
||||
'created', 'last_updated',
|
||||
'primary_ip4', 'primary_ip6', 'oob_ip', 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority',
|
||||
'description', 'comments', 'local_context_data', 'tags', 'custom_fields', 'config_context',
|
||||
'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))
|
||||
@ -1145,13 +1178,15 @@ class CablePathSerializer(serializers.ModelSerializer):
|
||||
class VirtualChassisSerializer(NetBoxModelSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:virtualchassis-detail')
|
||||
master = NestedDeviceSerializer(required=False, allow_null=True, default=None)
|
||||
|
||||
# Counter fields
|
||||
member_count = serializers.IntegerField(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = VirtualChassis
|
||||
fields = [
|
||||
'id', 'url', 'display', 'name', 'domain', 'master', 'description', 'comments', 'tags', 'custom_fields',
|
||||
'member_count', 'created', 'last_updated',
|
||||
'created', 'last_updated', 'member_count',
|
||||
]
|
||||
|
||||
|
||||
|
@ -579,9 +579,7 @@ class CableTerminationViewSet(NetBoxModelViewSet):
|
||||
#
|
||||
|
||||
class VirtualChassisViewSet(NetBoxModelViewSet):
|
||||
queryset = VirtualChassis.objects.prefetch_related('tags').annotate(
|
||||
member_count=count_related(Device, 'virtual_chassis')
|
||||
)
|
||||
queryset = VirtualChassis.objects.prefetch_related('tags')
|
||||
serializer_class = serializers.VirtualChassisSerializer
|
||||
filterset_class = filtersets.VirtualChassisFilterSet
|
||||
brief_prefetch_fields = ['master']
|
||||
|
@ -9,7 +9,8 @@ class DCIMConfig(AppConfig):
|
||||
|
||||
def ready(self):
|
||||
from . import signals, search
|
||||
from .models import CableTermination
|
||||
from .models import CableTermination, Device, DeviceType, VirtualChassis
|
||||
from utilities.counters import connect_counters
|
||||
|
||||
# Register denormalized fields
|
||||
denormalized.register(CableTermination, '_device', {
|
||||
@ -24,3 +25,6 @@ class DCIMConfig(AppConfig):
|
||||
denormalized.register(CableTermination, '_location', {
|
||||
'_site': 'site',
|
||||
})
|
||||
|
||||
# Register counters
|
||||
connect_counters(Device, DeviceType, VirtualChassis)
|
||||
|
@ -944,6 +944,10 @@ class DeviceFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilter
|
||||
method='_has_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(
|
||||
field_name='virtual_chassis',
|
||||
queryset=VirtualChassis.objects.all(),
|
||||
@ -999,6 +1003,11 @@ class DeviceFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilter
|
||||
queryset=IPAddress.objects.all(),
|
||||
label=_('Primary IPv6 (ID)'),
|
||||
)
|
||||
oob_ip_id = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='oob_ip',
|
||||
queryset=IPAddress.objects.all(),
|
||||
label=_('OOB IP (ID)'),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Device
|
||||
@ -1023,6 +1032,12 @@ class DeviceFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilter
|
||||
return queryset.filter(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):
|
||||
return queryset.exclude(virtual_chassis__isnull=value)
|
||||
|
||||
|
@ -629,7 +629,7 @@ class DeviceFilterForm(
|
||||
('Components', (
|
||||
'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(
|
||||
queryset=Region.objects.all(),
|
||||
@ -723,6 +723,13 @@ class DeviceFilterForm(
|
||||
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(
|
||||
required=False,
|
||||
label='Virtual chassis member',
|
||||
|
@ -449,9 +449,9 @@ class DeviceForm(TenancyForm, NetBoxModelForm):
|
||||
model = Device
|
||||
fields = [
|
||||
'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',
|
||||
'comments', 'tags', 'local_context_data'
|
||||
'comments', 'tags', 'local_context_data',
|
||||
]
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
@ -460,6 +460,7 @@ class DeviceForm(TenancyForm, NetBoxModelForm):
|
||||
if self.instance.pk:
|
||||
|
||||
# Compile list of choices for primary IPv4 and IPv6 addresses
|
||||
oob_ip_choices = [(None, '---------')]
|
||||
for family in [4, 6]:
|
||||
ip_choices = [(None, '---------')]
|
||||
|
||||
@ -475,6 +476,7 @@ class DeviceForm(TenancyForm, NetBoxModelForm):
|
||||
if interface_ips:
|
||||
ip_list = [(ip.id, f'{ip.address} ({ip.assigned_object})') for ip in interface_ips]
|
||||
ip_choices.append(('Interface IPs', ip_list))
|
||||
oob_ip_choices.extend(ip_list)
|
||||
# Collect NAT IPs
|
||||
nat_ips = IPAddress.objects.prefetch_related('nat_inside').filter(
|
||||
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_choices.append(('NAT IPs', ip_list))
|
||||
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
|
||||
# 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_ip6'].choices = []
|
||||
self.fields['primary_ip6'].widget.attrs['readonly'] = True
|
||||
self.fields['oob_ip'].choices = []
|
||||
self.fields['oob_ip'].widget.attrs['readonly'] = True
|
||||
|
||||
# Rack position
|
||||
position = self.data.get('position') or self.initial.get('position')
|
||||
|
25
netbox/dcim/migrations/0175_device_oob_ip.py
Normal file
25
netbox/dcim/migrations/0175_device_oob_ip.py
Normal 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',
|
||||
),
|
||||
),
|
||||
]
|
108
netbox/dcim/migrations/0176_device_component_counters.py
Normal file
108
netbox/dcim/migrations/0176_device_component_counters.py
Normal 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
|
||||
),
|
||||
]
|
108
netbox/dcim/migrations/0177_devicetype_component_counters.py
Normal file
108
netbox/dcim/migrations/0177_devicetype_component_counters.py
Normal 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
|
||||
),
|
||||
]
|
@ -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
|
||||
),
|
||||
]
|
@ -12,6 +12,7 @@ from netbox.models import ChangeLoggedModel
|
||||
from utilities.fields import ColorField, NaturalOrderingField
|
||||
from utilities.mptt import TreeManager
|
||||
from utilities.ordering import naturalize_interface
|
||||
from utilities.tracking import TrackingModelMixin
|
||||
from wireless.choices import WirelessRoleChoices
|
||||
from .device_components import (
|
||||
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(
|
||||
to='dcim.DeviceType',
|
||||
on_delete=models.CASCADE,
|
||||
|
@ -19,6 +19,7 @@ from utilities.fields import ColorField, NaturalOrderingField
|
||||
from utilities.mptt import TreeManager
|
||||
from utilities.ordering import naturalize_interface
|
||||
from utilities.query_functions import CollateAsChar
|
||||
from utilities.tracking import TrackingModelMixin
|
||||
from wireless.choices import *
|
||||
from wireless.utils import get_channel_attr
|
||||
|
||||
@ -269,7 +270,7 @@ class PathEndpoint(models.Model):
|
||||
# Console components
|
||||
#
|
||||
|
||||
class ConsolePort(ModularComponentModel, CabledObjectModel, PathEndpoint):
|
||||
class ConsolePort(ModularComponentModel, CabledObjectModel, PathEndpoint, TrackingModelMixin):
|
||||
"""
|
||||
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})
|
||||
|
||||
|
||||
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.
|
||||
"""
|
||||
@ -319,7 +320,7 @@ class ConsoleServerPort(ModularComponentModel, CabledObjectModel, PathEndpoint):
|
||||
# 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.
|
||||
"""
|
||||
@ -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.
|
||||
"""
|
||||
@ -537,7 +538,7 @@ class BaseInterface(models.Model):
|
||||
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.
|
||||
"""
|
||||
@ -888,7 +889,7 @@ class Interface(ModularComponentModel, BaseInterface, CabledObjectModel, PathEnd
|
||||
# Pass-through ports
|
||||
#
|
||||
|
||||
class FrontPort(ModularComponentModel, CabledObjectModel):
|
||||
class FrontPort(ModularComponentModel, CabledObjectModel, TrackingModelMixin):
|
||||
"""
|
||||
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.
|
||||
"""
|
||||
@ -990,7 +991,7 @@ class RearPort(ModularComponentModel, CabledObjectModel):
|
||||
# Bays
|
||||
#
|
||||
|
||||
class ModuleBay(ComponentModel):
|
||||
class ModuleBay(ComponentModel, TrackingModelMixin):
|
||||
"""
|
||||
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})
|
||||
|
||||
|
||||
class DeviceBay(ComponentModel):
|
||||
class DeviceBay(ComponentModel, TrackingModelMixin):
|
||||
"""
|
||||
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])
|
||||
|
||||
|
||||
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.
|
||||
InventoryItems are used only for inventory purposes.
|
||||
|
@ -21,7 +21,8 @@ from extras.querysets import ConfigContextModelQuerySet
|
||||
from netbox.config import ConfigItem
|
||||
from netbox.models import OrganizationalModel, PrimaryModel
|
||||
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 .mixins import WeightMixin
|
||||
|
||||
@ -128,6 +129,48 @@ class DeviceType(PrimaryModel, WeightMixin):
|
||||
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(
|
||||
to='extras.ImageAttachment'
|
||||
)
|
||||
@ -469,7 +512,7 @@ def update_interface_bridges(device, interface_templates, module=None):
|
||||
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,
|
||||
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,
|
||||
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(
|
||||
to='virtualization.Cluster',
|
||||
on_delete=models.SET_NULL,
|
||||
@ -639,6 +690,48 @@ class Device(PrimaryModel, ConfigContextModel):
|
||||
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
|
||||
contacts = GenericRelation(
|
||||
to='tenancy.ContactAssignment'
|
||||
@ -774,7 +867,7 @@ class Device(PrimaryModel, ConfigContextModel):
|
||||
except DeviceType.DoesNotExist:
|
||||
pass
|
||||
|
||||
# Validate primary IP addresses
|
||||
# Validate primary & OOB IP addresses
|
||||
vc_interfaces = self.vc_interfaces(if_master=False)
|
||||
if self.primary_ip4:
|
||||
if self.primary_ip4.family != 4:
|
||||
@ -802,6 +895,15 @@ class Device(PrimaryModel, ConfigContextModel):
|
||||
raise ValidationError({
|
||||
'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
|
||||
if hasattr(self, 'device_type') and self.platform:
|
||||
@ -1147,6 +1249,12 @@ class VirtualChassis(PrimaryModel):
|
||||
blank=True
|
||||
)
|
||||
|
||||
# Counter fields
|
||||
member_count = CounterCacheField(
|
||||
to_model='dcim.Device',
|
||||
to_field='virtual_chassis'
|
||||
)
|
||||
|
||||
class Meta:
|
||||
ordering = ['name']
|
||||
verbose_name_plural = 'virtual chassis'
|
||||
|
@ -1,10 +1,10 @@
|
||||
import django_tables2 as tables
|
||||
from dcim import models
|
||||
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 tenancy.tables import ContactsColumnMixin, TenancyColumnsMixin
|
||||
from .template_code import *
|
||||
|
||||
__all__ = (
|
||||
@ -201,6 +201,10 @@ class DeviceTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
|
||||
linkify=True,
|
||||
verbose_name='IPv6 Address'
|
||||
)
|
||||
oob_ip = tables.Column(
|
||||
linkify=True,
|
||||
verbose_name='OOB IP'
|
||||
)
|
||||
cluster = tables.Column(
|
||||
linkify=True
|
||||
)
|
||||
@ -230,6 +234,36 @@ class DeviceTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
|
||||
tags = columns.TagColumn(
|
||||
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):
|
||||
model = models.Device
|
||||
@ -237,8 +271,8 @@ class DeviceTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
|
||||
'pk', 'id', 'name', 'status', 'tenant', 'tenant_group', 'device_role', 'manufacturer', 'device_type',
|
||||
'platform', 'serial', 'asset_tag', 'region', 'site_group', 'site', 'location', 'rack', 'parent_device',
|
||||
'device_bay_position', 'position', 'face', 'latitude', 'longitude', 'airflow', 'primary_ip', 'primary_ip4',
|
||||
'primary_ip6', 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'description', 'config_template',
|
||||
'comments', 'contacts', 'tags', 'created', 'last_updated',
|
||||
'primary_ip6', 'oob_ip', 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'description',
|
||||
'config_template', 'comments', 'contacts', 'tags', 'created', 'last_updated',
|
||||
)
|
||||
default_columns = (
|
||||
'pk', 'name', 'status', 'tenant', 'site', 'location', 'rack', 'device_role', 'manufacturer', 'device_type',
|
||||
|
@ -1,4 +1,5 @@
|
||||
import django_tables2 as tables
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from dcim import models
|
||||
from netbox.tables import NetBoxTable, columns
|
||||
@ -83,11 +84,6 @@ class DeviceTypeTable(NetBoxTable):
|
||||
is_full_depth = columns.BooleanColumn(
|
||||
verbose_name='Full Depth'
|
||||
)
|
||||
instance_count = columns.LinkedCountColumn(
|
||||
viewname='dcim:device_list',
|
||||
url_params={'device_type_id': 'pk'},
|
||||
verbose_name='Instances'
|
||||
)
|
||||
comments = columns.MarkdownColumn()
|
||||
tags = columns.TagColumn(
|
||||
url_name='dcim:devicetype_list'
|
||||
@ -99,12 +95,48 @@ class DeviceTypeTable(NetBoxTable):
|
||||
template_code=WEIGHT,
|
||||
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):
|
||||
model = models.DeviceType
|
||||
fields = (
|
||||
'pk', 'id', 'model', 'manufacturer', 'default_platform', 'slug', 'part_number', 'u_height', 'is_full_depth', 'subdevice_role',
|
||||
'airflow', 'weight', 'description', 'comments', 'instance_count', 'tags', 'created', 'last_updated',
|
||||
'pk', 'id', 'model', 'manufacturer', 'default_platform', 'slug', 'part_number', 'u_height', 'is_full_depth',
|
||||
'subdevice_role', 'airflow', 'weight', 'description', 'comments', 'instance_count', 'tags', 'created',
|
||||
'last_updated',
|
||||
)
|
||||
default_columns = (
|
||||
'pk', 'model', 'manufacturer', 'part_number', 'u_height', 'is_full_depth', 'instance_count',
|
||||
|
@ -951,7 +951,7 @@ class DeviceTypeConsolePortsView(DeviceTypeComponentsView):
|
||||
viewname = 'dcim:devicetype_consoleports'
|
||||
tab = ViewTab(
|
||||
label=_('Console Ports'),
|
||||
badge=lambda obj: obj.consoleporttemplates.count(),
|
||||
badge=lambda obj: obj.console_port_template_count,
|
||||
permission='dcim.view_consoleporttemplate',
|
||||
weight=550,
|
||||
hide_if_empty=True
|
||||
@ -966,7 +966,7 @@ class DeviceTypeConsoleServerPortsView(DeviceTypeComponentsView):
|
||||
viewname = 'dcim:devicetype_consoleserverports'
|
||||
tab = ViewTab(
|
||||
label=_('Console Server Ports'),
|
||||
badge=lambda obj: obj.consoleserverporttemplates.count(),
|
||||
badge=lambda obj: obj.console_server_port_template_count,
|
||||
permission='dcim.view_consoleserverporttemplate',
|
||||
weight=560,
|
||||
hide_if_empty=True
|
||||
@ -981,7 +981,7 @@ class DeviceTypePowerPortsView(DeviceTypeComponentsView):
|
||||
viewname = 'dcim:devicetype_powerports'
|
||||
tab = ViewTab(
|
||||
label=_('Power Ports'),
|
||||
badge=lambda obj: obj.powerporttemplates.count(),
|
||||
badge=lambda obj: obj.power_port_template_count,
|
||||
permission='dcim.view_powerporttemplate',
|
||||
weight=570,
|
||||
hide_if_empty=True
|
||||
@ -996,7 +996,7 @@ class DeviceTypePowerOutletsView(DeviceTypeComponentsView):
|
||||
viewname = 'dcim:devicetype_poweroutlets'
|
||||
tab = ViewTab(
|
||||
label=_('Power Outlets'),
|
||||
badge=lambda obj: obj.poweroutlettemplates.count(),
|
||||
badge=lambda obj: obj.power_outlet_template_count,
|
||||
permission='dcim.view_poweroutlettemplate',
|
||||
weight=580,
|
||||
hide_if_empty=True
|
||||
@ -1011,7 +1011,7 @@ class DeviceTypeInterfacesView(DeviceTypeComponentsView):
|
||||
viewname = 'dcim:devicetype_interfaces'
|
||||
tab = ViewTab(
|
||||
label=_('Interfaces'),
|
||||
badge=lambda obj: obj.interfacetemplates.count(),
|
||||
badge=lambda obj: obj.interface_template_count,
|
||||
permission='dcim.view_interfacetemplate',
|
||||
weight=520,
|
||||
hide_if_empty=True
|
||||
@ -1026,7 +1026,7 @@ class DeviceTypeFrontPortsView(DeviceTypeComponentsView):
|
||||
viewname = 'dcim:devicetype_frontports'
|
||||
tab = ViewTab(
|
||||
label=_('Front Ports'),
|
||||
badge=lambda obj: obj.frontporttemplates.count(),
|
||||
badge=lambda obj: obj.front_port_template_count,
|
||||
permission='dcim.view_frontporttemplate',
|
||||
weight=530,
|
||||
hide_if_empty=True
|
||||
@ -1041,7 +1041,7 @@ class DeviceTypeRearPortsView(DeviceTypeComponentsView):
|
||||
viewname = 'dcim:devicetype_rearports'
|
||||
tab = ViewTab(
|
||||
label=_('Rear Ports'),
|
||||
badge=lambda obj: obj.rearporttemplates.count(),
|
||||
badge=lambda obj: obj.rear_port_template_count,
|
||||
permission='dcim.view_rearporttemplate',
|
||||
weight=540,
|
||||
hide_if_empty=True
|
||||
@ -1056,7 +1056,7 @@ class DeviceTypeModuleBaysView(DeviceTypeComponentsView):
|
||||
viewname = 'dcim:devicetype_modulebays'
|
||||
tab = ViewTab(
|
||||
label=_('Module Bays'),
|
||||
badge=lambda obj: obj.modulebaytemplates.count(),
|
||||
badge=lambda obj: obj.module_bay_template_count,
|
||||
permission='dcim.view_modulebaytemplate',
|
||||
weight=510,
|
||||
hide_if_empty=True
|
||||
@ -1071,7 +1071,7 @@ class DeviceTypeDeviceBaysView(DeviceTypeComponentsView):
|
||||
viewname = 'dcim:devicetype_devicebays'
|
||||
tab = ViewTab(
|
||||
label=_('Device Bays'),
|
||||
badge=lambda obj: obj.devicebaytemplates.count(),
|
||||
badge=lambda obj: obj.device_bay_template_count,
|
||||
permission='dcim.view_devicebaytemplate',
|
||||
weight=500,
|
||||
hide_if_empty=True
|
||||
@ -1086,7 +1086,7 @@ class DeviceTypeInventoryItemsView(DeviceTypeComponentsView):
|
||||
viewname = 'dcim:devicetype_inventoryitems'
|
||||
tab = ViewTab(
|
||||
label=_('Inventory Items'),
|
||||
badge=lambda obj: obj.inventoryitemtemplates.count(),
|
||||
badge=lambda obj: obj.inventory_item_template_count,
|
||||
permission='dcim.view_invenotryitemtemplate',
|
||||
weight=590,
|
||||
hide_if_empty=True
|
||||
@ -1876,7 +1876,7 @@ class DeviceConsolePortsView(DeviceComponentsView):
|
||||
template_name = 'dcim/device/consoleports.html',
|
||||
tab = ViewTab(
|
||||
label=_('Console Ports'),
|
||||
badge=lambda obj: obj.consoleports.count(),
|
||||
badge=lambda obj: obj.console_port_count,
|
||||
permission='dcim.view_consoleport',
|
||||
weight=550,
|
||||
hide_if_empty=True
|
||||
@ -1891,7 +1891,7 @@ class DeviceConsoleServerPortsView(DeviceComponentsView):
|
||||
template_name = 'dcim/device/consoleserverports.html'
|
||||
tab = ViewTab(
|
||||
label=_('Console Server Ports'),
|
||||
badge=lambda obj: obj.consoleserverports.count(),
|
||||
badge=lambda obj: obj.console_server_port_count,
|
||||
permission='dcim.view_consoleserverport',
|
||||
weight=560,
|
||||
hide_if_empty=True
|
||||
@ -1906,7 +1906,7 @@ class DevicePowerPortsView(DeviceComponentsView):
|
||||
template_name = 'dcim/device/powerports.html'
|
||||
tab = ViewTab(
|
||||
label=_('Power Ports'),
|
||||
badge=lambda obj: obj.powerports.count(),
|
||||
badge=lambda obj: obj.power_port_count,
|
||||
permission='dcim.view_powerport',
|
||||
weight=570,
|
||||
hide_if_empty=True
|
||||
@ -1921,7 +1921,7 @@ class DevicePowerOutletsView(DeviceComponentsView):
|
||||
template_name = 'dcim/device/poweroutlets.html'
|
||||
tab = ViewTab(
|
||||
label=_('Power Outlets'),
|
||||
badge=lambda obj: obj.poweroutlets.count(),
|
||||
badge=lambda obj: obj.power_outlet_count,
|
||||
permission='dcim.view_poweroutlet',
|
||||
weight=580,
|
||||
hide_if_empty=True
|
||||
@ -1957,7 +1957,7 @@ class DeviceFrontPortsView(DeviceComponentsView):
|
||||
template_name = 'dcim/device/frontports.html'
|
||||
tab = ViewTab(
|
||||
label=_('Front Ports'),
|
||||
badge=lambda obj: obj.frontports.count(),
|
||||
badge=lambda obj: obj.front_port_count,
|
||||
permission='dcim.view_frontport',
|
||||
weight=530,
|
||||
hide_if_empty=True
|
||||
@ -1972,7 +1972,7 @@ class DeviceRearPortsView(DeviceComponentsView):
|
||||
template_name = 'dcim/device/rearports.html'
|
||||
tab = ViewTab(
|
||||
label=_('Rear Ports'),
|
||||
badge=lambda obj: obj.rearports.count(),
|
||||
badge=lambda obj: obj.rear_port_count,
|
||||
permission='dcim.view_rearport',
|
||||
weight=540,
|
||||
hide_if_empty=True
|
||||
@ -1987,7 +1987,7 @@ class DeviceModuleBaysView(DeviceComponentsView):
|
||||
template_name = 'dcim/device/modulebays.html'
|
||||
tab = ViewTab(
|
||||
label=_('Module Bays'),
|
||||
badge=lambda obj: obj.modulebays.count(),
|
||||
badge=lambda obj: obj.module_bay_count,
|
||||
permission='dcim.view_modulebay',
|
||||
weight=510,
|
||||
hide_if_empty=True
|
||||
@ -2002,7 +2002,7 @@ class DeviceDeviceBaysView(DeviceComponentsView):
|
||||
template_name = 'dcim/device/devicebays.html'
|
||||
tab = ViewTab(
|
||||
label=_('Device Bays'),
|
||||
badge=lambda obj: obj.devicebays.count(),
|
||||
badge=lambda obj: obj.device_bay_count,
|
||||
permission='dcim.view_devicebay',
|
||||
weight=500,
|
||||
hide_if_empty=True
|
||||
@ -2017,7 +2017,7 @@ class DeviceInventoryView(DeviceComponentsView):
|
||||
template_name = 'dcim/device/inventory.html'
|
||||
tab = ViewTab(
|
||||
label=_('Inventory Items'),
|
||||
badge=lambda obj: obj.inventoryitems.count(),
|
||||
badge=lambda obj: obj.inventory_item_count,
|
||||
permission='dcim.view_inventoryitem',
|
||||
weight=590,
|
||||
hide_if_empty=True
|
||||
@ -2452,11 +2452,13 @@ class InterfaceView(generic.ObjectView):
|
||||
queryset = Interface.objects.all()
|
||||
|
||||
def get_extra_context(self, request, instance):
|
||||
# Get assigned VDC's
|
||||
# Get assigned VDCs
|
||||
vdc_table = tables.VirtualDeviceContextTable(
|
||||
data=instance.vdcs.restrict(request.user, 'view').prefetch_related('device'),
|
||||
exclude=('tenant', 'tenant_group', 'primary_ip', 'primary_ip4', 'primary_ip6', 'comments', 'tags',
|
||||
'created', 'last_updated', 'actions', ),
|
||||
exclude=(
|
||||
'tenant', 'tenant_group', 'primary_ip', 'primary_ip4', 'primary_ip6', 'oob_ip', 'comments', 'tags',
|
||||
'created', 'last_updated', 'actions',
|
||||
),
|
||||
orderable=False
|
||||
)
|
||||
|
||||
@ -3225,9 +3227,7 @@ class InterfaceConnectionsListView(generic.ObjectListView):
|
||||
#
|
||||
|
||||
class VirtualChassisListView(generic.ObjectListView):
|
||||
queryset = VirtualChassis.objects.annotate(
|
||||
member_count=count_related(Device, 'virtual_chassis')
|
||||
)
|
||||
queryset = VirtualChassis.objects.all()
|
||||
table = tables.VirtualChassisTable
|
||||
filterset = filtersets.VirtualChassisFilterSet
|
||||
filterset_form = forms.VirtualChassisFilterForm
|
||||
|
@ -7,6 +7,7 @@ __all__ = [
|
||||
'NestedBookmarkSerializer',
|
||||
'NestedConfigContextSerializer',
|
||||
'NestedConfigTemplateSerializer',
|
||||
'NestedCustomFieldChoiceSetSerializer',
|
||||
'NestedCustomFieldSerializer',
|
||||
'NestedCustomLinkSerializer',
|
||||
'NestedExportTemplateSerializer',
|
||||
@ -34,6 +35,14 @@ class NestedCustomFieldSerializer(WritableNestedSerializer):
|
||||
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):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='extras-api:customlink-detail')
|
||||
|
||||
|
@ -35,6 +35,7 @@ __all__ = (
|
||||
'ConfigContextSerializer',
|
||||
'ConfigTemplateSerializer',
|
||||
'ContentTypeSerializer',
|
||||
'CustomFieldChoiceSetSerializer',
|
||||
'CustomFieldSerializer',
|
||||
'CustomLinkSerializer',
|
||||
'DashboardSerializer',
|
||||
@ -94,6 +95,7 @@ class CustomFieldSerializer(ValidatedModelSerializer):
|
||||
)
|
||||
filter_logic = ChoiceField(choices=CustomFieldFilterLogicChoices, required=False)
|
||||
data_type = serializers.SerializerMethodField()
|
||||
choice_set = NestedCustomFieldChoiceSetSerializer(required=False)
|
||||
ui_visibility = ChoiceField(choices=CustomFieldVisibilityChoices, required=False)
|
||||
|
||||
class Meta:
|
||||
@ -101,7 +103,7 @@ class CustomFieldSerializer(ValidatedModelSerializer):
|
||||
fields = [
|
||||
'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',
|
||||
'weight', 'validation_minimum', 'validation_maximum', 'validation_regex', 'choices', 'created',
|
||||
'weight', 'validation_minimum', 'validation_maximum', 'validation_regex', 'choice_set', 'created',
|
||||
'last_updated',
|
||||
]
|
||||
|
||||
@ -127,6 +129,17 @@ class CustomFieldSerializer(ValidatedModelSerializer):
|
||||
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
|
||||
#
|
||||
|
@ -9,6 +9,7 @@ router.APIRootView = views.ExtrasRootView
|
||||
|
||||
router.register('webhooks', views.WebhookViewSet)
|
||||
router.register('custom-fields', views.CustomFieldViewSet)
|
||||
router.register('custom-field-choices', views.CustomFieldChoiceSetViewSet)
|
||||
router.register('custom-links', views.CustomLinkViewSet)
|
||||
router.register('export-templates', views.ExportTemplateViewSet)
|
||||
router.register('saved-filters', views.SavedFilterViewSet)
|
||||
|
@ -1,6 +1,5 @@
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.http import Http404
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django_rq.queues import get_connection
|
||||
from rest_framework import status
|
||||
from rest_framework.decorators import action
|
||||
@ -55,11 +54,17 @@ class WebhookViewSet(NetBoxModelViewSet):
|
||||
|
||||
class CustomFieldViewSet(NetBoxModelViewSet):
|
||||
metadata_class = ContentTypeMetadata
|
||||
queryset = CustomField.objects.all()
|
||||
queryset = CustomField.objects.select_related('choice_set')
|
||||
serializer_class = serializers.CustomFieldSerializer
|
||||
filterset_class = filtersets.CustomFieldFilterSet
|
||||
|
||||
|
||||
class CustomFieldChoiceSetViewSet(NetBoxModelViewSet):
|
||||
queryset = CustomFieldChoiceSet.objects.all()
|
||||
serializer_class = serializers.CustomFieldChoiceSetSerializer
|
||||
filterset_class = filtersets.CustomFieldChoiceSetFilterSet
|
||||
|
||||
|
||||
#
|
||||
# Custom links
|
||||
#
|
||||
|
@ -20,6 +20,7 @@ __all__ = (
|
||||
'ConfigRevisionFilterSet',
|
||||
'ConfigTemplateFilterSet',
|
||||
'ContentTypeFilterSet',
|
||||
'CustomFieldChoiceSetFilterSet',
|
||||
'CustomFieldFilterSet',
|
||||
'CustomLinkFilterSet',
|
||||
'ExportTemplateFilterSet',
|
||||
@ -74,6 +75,14 @@ class CustomFieldFilterSet(BaseFilterSet):
|
||||
field_name='content_types__id'
|
||||
)
|
||||
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:
|
||||
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):
|
||||
q = django_filters.CharFilter(
|
||||
method='search',
|
||||
|
@ -4,13 +4,14 @@ from django.utils.translation import gettext as _
|
||||
from extras.choices import *
|
||||
from extras.models import *
|
||||
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
|
||||
|
||||
__all__ = (
|
||||
'ConfigContextBulkEditForm',
|
||||
'ConfigTemplateBulkEditForm',
|
||||
'CustomFieldBulkEditForm',
|
||||
'CustomFieldChoiceSetBulkEditForm',
|
||||
'CustomLinkBulkEditForm',
|
||||
'ExportTemplateBulkEditForm',
|
||||
'JournalEntryBulkEditForm',
|
||||
@ -38,6 +39,10 @@ class CustomFieldBulkEditForm(BulkEditForm):
|
||||
weight = forms.IntegerField(
|
||||
required=False
|
||||
)
|
||||
choice_set = DynamicModelChoiceField(
|
||||
queryset=CustomFieldChoiceSet.objects.all(),
|
||||
required=False
|
||||
)
|
||||
ui_visibility = forms.ChoiceField(
|
||||
label=_("UI visibility"),
|
||||
choices=add_blank_choice(CustomFieldVisibilityChoices),
|
||||
@ -49,7 +54,23 @@ class CustomFieldBulkEditForm(BulkEditForm):
|
||||
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):
|
||||
|
@ -9,10 +9,13 @@ from extras.models import *
|
||||
from extras.utils import FeatureQuery
|
||||
from netbox.forms import NetBoxModelImportForm
|
||||
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__ = (
|
||||
'ConfigTemplateImportForm',
|
||||
'CustomFieldChoiceSetImportForm',
|
||||
'CustomFieldImportForm',
|
||||
'CustomLinkImportForm',
|
||||
'ExportTemplateImportForm',
|
||||
@ -39,10 +42,11 @@ class CustomFieldImportForm(CSVModelForm):
|
||||
required=False,
|
||||
help_text=_("Object type (for object or multi-object fields)")
|
||||
)
|
||||
choices = SimpleArrayField(
|
||||
base_field=forms.CharField(),
|
||||
choice_set = CSVModelChoiceField(
|
||||
queryset=CustomFieldChoiceSet.objects.all(),
|
||||
to_field_name='name',
|
||||
required=False,
|
||||
help_text=_('Comma-separated list of field choices')
|
||||
help_text=_('Choice set (for selection fields)')
|
||||
)
|
||||
ui_visibility = CSVChoiceField(
|
||||
choices=CustomFieldVisibilityChoices,
|
||||
@ -53,8 +57,22 @@ class CustomFieldImportForm(CSVModelForm):
|
||||
model = CustomField
|
||||
fields = (
|
||||
'name', 'label', 'group_name', 'type', 'content_types', 'object_type', 'required', 'description',
|
||||
'search_weight', 'filter_logic', 'default', 'choices', 'weight', 'validation_minimum', 'validation_maximum',
|
||||
'validation_regex', 'ui_visibility', 'is_cloneable',
|
||||
'search_weight', 'filter_logic', 'default', 'choice_set', 'weight', 'validation_minimum',
|
||||
'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',
|
||||
)
|
||||
|
||||
|
||||
|
@ -20,6 +20,7 @@ __all__ = (
|
||||
'ConfigContextFilterForm',
|
||||
'ConfigRevisionFilterForm',
|
||||
'ConfigTemplateFilterForm',
|
||||
'CustomFieldChoiceSetFilterForm',
|
||||
'CustomFieldFilterForm',
|
||||
'CustomLinkFilterForm',
|
||||
'ExportTemplateFilterForm',
|
||||
@ -37,7 +38,8 @@ class CustomFieldFilterForm(SavedFiltersMixin, FilterForm):
|
||||
fieldsets = (
|
||||
(None, ('q', 'filter_id')),
|
||||
('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(
|
||||
@ -62,6 +64,11 @@ class CustomFieldFilterForm(SavedFiltersMixin, FilterForm):
|
||||
choices=BOOLEAN_WITH_BLANK_CHOICES
|
||||
)
|
||||
)
|
||||
choice_set_id = DynamicModelMultipleChoiceField(
|
||||
queryset=CustomFieldChoiceSet.objects.all(),
|
||||
required=False,
|
||||
label=_('Choice set')
|
||||
)
|
||||
ui_visibility = forms.ChoiceField(
|
||||
choices=add_blank_choice(CustomFieldVisibilityChoices),
|
||||
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):
|
||||
fieldsets = (
|
||||
(None, ('q', 'filter_id')),
|
||||
('Attributes', ('content_types', 'enabled', 'new_window', 'weight')),
|
||||
(_('Attributes'), ('content_types', 'enabled', 'new_window', 'weight')),
|
||||
)
|
||||
content_types = ContentTypeMultipleChoiceField(
|
||||
queryset=ContentType.objects.filter(FeatureQuery('custom_links').get_query()),
|
||||
|
@ -16,9 +16,10 @@ from netbox.forms import NetBoxModelForm
|
||||
from tenancy.models import Tenant, TenantGroup
|
||||
from utilities.forms import BootstrapMixin, add_blank_choice
|
||||
from utilities.forms.fields import (
|
||||
CommentField, ContentTypeChoiceField, ContentTypeMultipleChoiceField, DynamicModelMultipleChoiceField, JSONField,
|
||||
SlugField,
|
||||
CommentField, ContentTypeChoiceField, ContentTypeMultipleChoiceField, DynamicModelChoiceField,
|
||||
DynamicModelMultipleChoiceField, JSONField, SlugField,
|
||||
)
|
||||
from utilities.forms.widgets import ArrayWidget
|
||||
from virtualization.models import Cluster, ClusterGroup, ClusterType
|
||||
|
||||
|
||||
@ -27,6 +28,7 @@ __all__ = (
|
||||
'ConfigContextForm',
|
||||
'ConfigRevisionForm',
|
||||
'ConfigTemplateForm',
|
||||
'CustomFieldChoiceSetForm',
|
||||
'CustomFieldForm',
|
||||
'CustomLinkForm',
|
||||
'ExportTemplateForm',
|
||||
@ -50,13 +52,17 @@ class CustomFieldForm(BootstrapMixin, forms.ModelForm):
|
||||
required=False,
|
||||
help_text=_("Type of the related object (for object/multi-object fields only)")
|
||||
)
|
||||
choice_set = DynamicModelChoiceField(
|
||||
queryset=CustomFieldChoiceSet.objects.all(),
|
||||
required=False
|
||||
)
|
||||
|
||||
fieldsets = (
|
||||
('Custom Field', (
|
||||
'content_types', 'name', 'label', 'group_name', 'type', 'object_type', 'required', 'description',
|
||||
)),
|
||||
('Behavior', ('search_weight', 'filter_logic', 'ui_visibility', 'weight', 'is_cloneable')),
|
||||
('Values', ('default', 'choices')),
|
||||
('Values', ('default', 'choice_set')),
|
||||
('Validation', ('validation_minimum', 'validation_maximum', 'validation_regex')),
|
||||
)
|
||||
|
||||
@ -78,6 +84,20 @@ class CustomFieldForm(BootstrapMixin, forms.ModelForm):
|
||||
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):
|
||||
content_types = ContentTypeMultipleChoiceField(
|
||||
queryset=ContentType.objects.all(),
|
||||
|
@ -25,6 +25,12 @@ class ExtrasQuery(graphene.ObjectType):
|
||||
def resolve_custom_field_list(root, info, **kwargs):
|
||||
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_list = ObjectListField(CustomLinkType)
|
||||
|
||||
|
@ -5,6 +5,7 @@ from netbox.graphql.types import BaseObjectType, ObjectType
|
||||
__all__ = (
|
||||
'ConfigContextType',
|
||||
'ConfigTemplateType',
|
||||
'CustomFieldChoiceSetType',
|
||||
'CustomFieldType',
|
||||
'CustomLinkType',
|
||||
'ExportTemplateType',
|
||||
@ -41,6 +42,14 @@ class CustomFieldType(ObjectType):
|
||||
filterset_class = filtersets.CustomFieldFilterSet
|
||||
|
||||
|
||||
class CustomFieldChoiceSetType(ObjectType):
|
||||
|
||||
class Meta:
|
||||
model = models.CustomFieldChoiceSet
|
||||
fields = '__all__'
|
||||
filterset_class = filtersets.CustomFieldChoiceSetFilterSet
|
||||
|
||||
|
||||
class CustomLinkType(ObjectType):
|
||||
|
||||
class Meta:
|
||||
|
61
netbox/extras/migrations/0096_customfieldchoiceset.py
Normal file
61
netbox/extras/migrations/0096_customfieldchoiceset.py
Normal 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
|
||||
),
|
||||
]
|
17
netbox/extras/migrations/0097_customfield_remove_choices.py
Normal file
17
netbox/extras/migrations/0097_customfield_remove_choices.py
Normal 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',
|
||||
),
|
||||
]
|
@ -1,6 +1,6 @@
|
||||
from .change_logging import *
|
||||
from .configs import *
|
||||
from .customfields import CustomField
|
||||
from .customfields import *
|
||||
from .dashboard import *
|
||||
from .models import *
|
||||
from .reports import *
|
||||
|
@ -31,6 +31,7 @@ from utilities.validators import validate_regex
|
||||
|
||||
__all__ = (
|
||||
'CustomField',
|
||||
'CustomFieldChoiceSet',
|
||||
'CustomFieldManager',
|
||||
)
|
||||
|
||||
@ -158,11 +159,12 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
|
||||
'example, <code>^[A-Z]{3}$</code> will limit values to exactly three uppercase letters.'
|
||||
)
|
||||
)
|
||||
choices = ArrayField(
|
||||
base_field=models.CharField(max_length=100),
|
||||
choice_set = models.ForeignKey(
|
||||
to='CustomFieldChoiceSet',
|
||||
on_delete=models.PROTECT,
|
||||
related_name='choices_for',
|
||||
blank=True,
|
||||
null=True,
|
||||
help_text=_('Comma-separated list of available choices (for selection fields)')
|
||||
null=True
|
||||
)
|
||||
ui_visibility = models.CharField(
|
||||
max_length=50,
|
||||
@ -181,8 +183,8 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
|
||||
|
||||
clone_fields = (
|
||||
'content_types', 'type', 'object_type', 'group_name', 'description', 'required', 'search_weight',
|
||||
'filter_logic', 'default', 'weight', 'validation_minimum', 'validation_maximum', 'validation_regex', 'choices',
|
||||
'ui_visibility', 'is_cloneable',
|
||||
'filter_logic', 'default', 'weight', 'validation_minimum', 'validation_maximum', 'validation_regex',
|
||||
'choice_set', 'ui_visibility', 'is_cloneable',
|
||||
)
|
||||
|
||||
class Meta:
|
||||
@ -208,6 +210,12 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
|
||||
def search_type(self):
|
||||
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):
|
||||
"""
|
||||
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"
|
||||
})
|
||||
|
||||
# Choices can be set 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
|
||||
# Choice set must be set on selection fields, and *only* on selection fields
|
||||
if self.type in (
|
||||
CustomFieldTypeChoices.TYPE_SELECT,
|
||||
CustomFieldTypeChoices.TYPE_MULTISELECT
|
||||
) and not self.choices:
|
||||
):
|
||||
if not self.choice_set:
|
||||
raise ValidationError({
|
||||
'choice_set': "Selection fields must specify a set of choices."
|
||||
})
|
||||
elif self.choice_set:
|
||||
raise ValidationError({
|
||||
'choices': "Selection fields must specify at least one choice."
|
||||
'choice_set': "Choices may be set only on selection fields."
|
||||
})
|
||||
|
||||
# A selection field's default (if any) must be present in its available choices
|
||||
@ -627,3 +631,52 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
|
||||
|
||||
elif self.required:
|
||||
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)
|
||||
|
@ -2,6 +2,7 @@ import json
|
||||
|
||||
import django_tables2 as tables
|
||||
from django.conf import settings
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from extras.models import *
|
||||
from netbox.tables import NetBoxTable, columns
|
||||
@ -12,6 +13,7 @@ __all__ = (
|
||||
'ConfigContextTable',
|
||||
'ConfigRevisionTable',
|
||||
'ConfigTemplateTable',
|
||||
'CustomFieldChoiceSetTable',
|
||||
'CustomFieldTable',
|
||||
'CustomLinkTable',
|
||||
'ExportTemplateTable',
|
||||
@ -64,6 +66,11 @@ class CustomFieldTable(NetBoxTable):
|
||||
required = columns.BooleanColumn()
|
||||
ui_visibility = columns.ChoiceFieldColumn(verbose_name="UI visibility")
|
||||
description = columns.MarkdownColumn()
|
||||
choices = columns.ArrayColumn(
|
||||
max_items=10,
|
||||
orderable=False,
|
||||
verbose_name=_('Choices')
|
||||
)
|
||||
is_cloneable = columns.BooleanColumn()
|
||||
|
||||
class Meta(NetBoxTable.Meta):
|
||||
@ -76,6 +83,33 @@ class CustomFieldTable(NetBoxTable):
|
||||
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):
|
||||
name = tables.Column(
|
||||
linkify=True
|
||||
|
@ -98,8 +98,7 @@ class CustomFieldTest(APIViewTestCases.APIViewTestCase):
|
||||
{
|
||||
'content_types': ['dcim.site'],
|
||||
'name': 'cf6',
|
||||
'type': 'select',
|
||||
'choices': ['A', 'B', 'C']
|
||||
'type': 'text',
|
||||
},
|
||||
]
|
||||
bulk_update_data = {
|
||||
@ -134,6 +133,42 @@ class CustomFieldTest(APIViewTestCases.APIViewTestCase):
|
||||
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):
|
||||
model = CustomLink
|
||||
brief_fields = ['display', 'id', 'name', 'url']
|
||||
|
@ -5,7 +5,7 @@ from rest_framework import status
|
||||
from dcim.choices import SiteStatusChoices
|
||||
from dcim.models import Site
|
||||
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.utils import create_tags, post_data
|
||||
from utilities.testing.views import ModelViewTestCase
|
||||
@ -16,12 +16,16 @@ class ChangeLogViewTest(ModelViewTestCase):
|
||||
|
||||
@classmethod
|
||||
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
|
||||
ct = ContentType.objects.get_for_model(Site)
|
||||
cf = CustomField(
|
||||
type=CustomFieldTypeChoices.TYPE_TEXT,
|
||||
name='my_field',
|
||||
name='cf1',
|
||||
required=False
|
||||
)
|
||||
cf.save()
|
||||
@ -30,9 +34,9 @@ class ChangeLogViewTest(ModelViewTestCase):
|
||||
# Create a select custom field on the Site model
|
||||
cf_select = CustomField(
|
||||
type=CustomFieldTypeChoices.TYPE_SELECT,
|
||||
name='my_field_select',
|
||||
name='cf2',
|
||||
required=False,
|
||||
choices=['Bar', 'Foo']
|
||||
choice_set=choice_set
|
||||
)
|
||||
cf_select.save()
|
||||
cf_select.content_types.set([ct])
|
||||
@ -43,8 +47,8 @@ class ChangeLogViewTest(ModelViewTestCase):
|
||||
'name': 'Site 1',
|
||||
'slug': 'site-1',
|
||||
'status': SiteStatusChoices.STATUS_ACTIVE,
|
||||
'cf_my_field': 'ABC',
|
||||
'cf_my_field_select': 'Bar',
|
||||
'cf_cf1': 'ABC',
|
||||
'cf_cf2': 'Bar',
|
||||
'tags': [tag.pk for tag in tags],
|
||||
}
|
||||
|
||||
@ -65,8 +69,8 @@ class ChangeLogViewTest(ModelViewTestCase):
|
||||
self.assertEqual(oc.changed_object, site)
|
||||
self.assertEqual(oc.action, ObjectChangeActionChoices.ACTION_CREATE)
|
||||
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']['my_field_select'], form_data['cf_my_field_select'])
|
||||
self.assertEqual(oc.postchange_data['custom_fields']['cf1'], form_data['cf_cf1'])
|
||||
self.assertEqual(oc.postchange_data['custom_fields']['cf2'], form_data['cf_cf2'])
|
||||
self.assertEqual(oc.postchange_data['tags'], ['Tag 1', 'Tag 2'])
|
||||
|
||||
def test_update_object(self):
|
||||
@ -79,8 +83,8 @@ class ChangeLogViewTest(ModelViewTestCase):
|
||||
'name': 'Site X',
|
||||
'slug': 'site-x',
|
||||
'status': SiteStatusChoices.STATUS_PLANNED,
|
||||
'cf_my_field': 'DEF',
|
||||
'cf_my_field_select': 'Foo',
|
||||
'cf_cf1': 'DEF',
|
||||
'cf_cf2': 'Foo',
|
||||
'tags': [tags[2].pk],
|
||||
}
|
||||
|
||||
@ -102,8 +106,8 @@ class ChangeLogViewTest(ModelViewTestCase):
|
||||
self.assertEqual(oc.action, ObjectChangeActionChoices.ACTION_UPDATE)
|
||||
self.assertEqual(oc.prechange_data['name'], 'Site 1')
|
||||
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']['my_field_select'], form_data['cf_my_field_select'])
|
||||
self.assertEqual(oc.postchange_data['custom_fields']['cf1'], form_data['cf_cf1'])
|
||||
self.assertEqual(oc.postchange_data['custom_fields']['cf2'], form_data['cf_cf2'])
|
||||
self.assertEqual(oc.postchange_data['tags'], ['Tag 3'])
|
||||
|
||||
def test_delete_object(self):
|
||||
@ -111,8 +115,8 @@ class ChangeLogViewTest(ModelViewTestCase):
|
||||
name='Site 1',
|
||||
slug='site-1',
|
||||
custom_field_data={
|
||||
'my_field': 'ABC',
|
||||
'my_field_select': 'Bar'
|
||||
'cf1': 'ABC',
|
||||
'cf2': 'Bar'
|
||||
}
|
||||
)
|
||||
site.save()
|
||||
@ -131,8 +135,8 @@ class ChangeLogViewTest(ModelViewTestCase):
|
||||
self.assertEqual(oc.changed_object, None)
|
||||
self.assertEqual(oc.object_repr, site.name)
|
||||
self.assertEqual(oc.action, ObjectChangeActionChoices.ACTION_DELETE)
|
||||
self.assertEqual(oc.prechange_data['custom_fields']['my_field'], 'ABC')
|
||||
self.assertEqual(oc.prechange_data['custom_fields']['my_field_select'], 'Bar')
|
||||
self.assertEqual(oc.prechange_data['custom_fields']['cf1'], 'ABC')
|
||||
self.assertEqual(oc.prechange_data['custom_fields']['cf2'], 'Bar')
|
||||
self.assertEqual(oc.prechange_data['tags'], ['Tag 1', 'Tag 2'])
|
||||
self.assertEqual(oc.postchange_data, None)
|
||||
|
||||
@ -213,18 +217,22 @@ class ChangeLogAPITest(APITestCase):
|
||||
ct = ContentType.objects.get_for_model(Site)
|
||||
cf = CustomField(
|
||||
type=CustomFieldTypeChoices.TYPE_TEXT,
|
||||
name='my_field',
|
||||
name='cf1',
|
||||
required=False
|
||||
)
|
||||
cf.save()
|
||||
cf.content_types.set([ct])
|
||||
|
||||
# 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(
|
||||
type=CustomFieldTypeChoices.TYPE_SELECT,
|
||||
name='my_field_select',
|
||||
name='cf2',
|
||||
required=False,
|
||||
choices=['Bar', 'Foo']
|
||||
choice_set=choice_set
|
||||
)
|
||||
cf_select.save()
|
||||
cf_select.content_types.set([ct])
|
||||
@ -242,8 +250,8 @@ class ChangeLogAPITest(APITestCase):
|
||||
'name': 'Site 1',
|
||||
'slug': 'site-1',
|
||||
'custom_fields': {
|
||||
'my_field': 'ABC',
|
||||
'my_field_select': 'Bar',
|
||||
'cf1': 'ABC',
|
||||
'cf2': 'Bar',
|
||||
},
|
||||
'tags': [
|
||||
{'name': 'Tag 1'},
|
||||
@ -276,8 +284,8 @@ class ChangeLogAPITest(APITestCase):
|
||||
'name': 'Site X',
|
||||
'slug': 'site-x',
|
||||
'custom_fields': {
|
||||
'my_field': 'DEF',
|
||||
'my_field_select': 'Foo',
|
||||
'cf1': 'DEF',
|
||||
'cf2': 'Foo',
|
||||
},
|
||||
'tags': [
|
||||
{'name': 'Tag 3'}
|
||||
@ -305,8 +313,8 @@ class ChangeLogAPITest(APITestCase):
|
||||
name='Site 1',
|
||||
slug='site-1',
|
||||
custom_field_data={
|
||||
'my_field': 'ABC',
|
||||
'my_field_select': 'Bar'
|
||||
'cf1': 'ABC',
|
||||
'cf2': 'Bar'
|
||||
}
|
||||
)
|
||||
site.save()
|
||||
@ -323,8 +331,8 @@ class ChangeLogAPITest(APITestCase):
|
||||
self.assertEqual(oc.changed_object, None)
|
||||
self.assertEqual(oc.object_repr, site.name)
|
||||
self.assertEqual(oc.action, ObjectChangeActionChoices.ACTION_DELETE)
|
||||
self.assertEqual(oc.prechange_data['custom_fields']['my_field'], 'ABC')
|
||||
self.assertEqual(oc.prechange_data['custom_fields']['my_field_select'], 'Bar')
|
||||
self.assertEqual(oc.prechange_data['custom_fields']['cf1'], 'ABC')
|
||||
self.assertEqual(oc.prechange_data['custom_fields']['cf2'], 'Bar')
|
||||
self.assertEqual(oc.prechange_data['tags'], ['Tag 1', 'Tag 2'])
|
||||
self.assertEqual(oc.postchange_data, None)
|
||||
|
||||
|
@ -10,7 +10,7 @@ from dcim.filtersets import SiteFilterSet
|
||||
from dcim.forms import SiteImportForm
|
||||
from dcim.models import Manufacturer, Rack, Site
|
||||
from extras.choices import *
|
||||
from extras.models import CustomField
|
||||
from extras.models import CustomField, CustomFieldChoiceSet
|
||||
from ipam.models import VLAN
|
||||
from utilities.testing import APITestCase, TestCase
|
||||
from virtualization.models import VirtualMachine
|
||||
@ -272,12 +272,18 @@ class CustomFieldTest(TestCase):
|
||||
CHOICES = ('Option A', 'Option B', 'Option C')
|
||||
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
|
||||
cf = CustomField.objects.create(
|
||||
name='select_field',
|
||||
type=CustomFieldTypeChoices.TYPE_SELECT,
|
||||
required=False,
|
||||
choices=CHOICES
|
||||
choice_set=choice_set
|
||||
)
|
||||
cf.content_types.set([self.object_type])
|
||||
instance = Site.objects.first()
|
||||
@ -299,12 +305,18 @@ class CustomFieldTest(TestCase):
|
||||
CHOICES = ['Option A', 'Option B', 'Option C']
|
||||
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
|
||||
cf = CustomField.objects.create(
|
||||
name='multiselect_field',
|
||||
type=CustomFieldTypeChoices.TYPE_MULTISELECT,
|
||||
required=False,
|
||||
choices=CHOICES
|
||||
choice_set=choice_set
|
||||
)
|
||||
cf.content_types.set([self.object_type])
|
||||
instance = Site.objects.first()
|
||||
@ -438,6 +450,12 @@ class CustomFieldAPITest(APITestCase):
|
||||
)
|
||||
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 = (
|
||||
CustomField(type=CustomFieldTypeChoices.TYPE_TEXT, name='text_field', default='foo'),
|
||||
CustomField(type=CustomFieldTypeChoices.TYPE_LONGTEXT, name='longtext_field', default='ABC'),
|
||||
@ -452,17 +470,13 @@ class CustomFieldAPITest(APITestCase):
|
||||
type=CustomFieldTypeChoices.TYPE_SELECT,
|
||||
name='select_field',
|
||||
default='Foo',
|
||||
choices=(
|
||||
'Foo', 'Bar', 'Baz'
|
||||
)
|
||||
choice_set=choice_set
|
||||
),
|
||||
CustomField(
|
||||
type=CustomFieldTypeChoices.TYPE_MULTISELECT,
|
||||
name='multiselect_field',
|
||||
default=['Foo'],
|
||||
choices=(
|
||||
'Foo', 'Bar', 'Baz'
|
||||
)
|
||||
choice_set=choice_set
|
||||
),
|
||||
CustomField(
|
||||
type=CustomFieldTypeChoices.TYPE_OBJECT,
|
||||
@ -1024,6 +1038,12 @@ class CustomFieldImportTest(TestCase):
|
||||
@classmethod
|
||||
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 = (
|
||||
CustomField(name='text', type=CustomFieldTypeChoices.TYPE_TEXT),
|
||||
CustomField(name='longtext', type=CustomFieldTypeChoices.TYPE_LONGTEXT),
|
||||
@ -1034,12 +1054,8 @@ class CustomFieldImportTest(TestCase):
|
||||
CustomField(name='datetime', type=CustomFieldTypeChoices.TYPE_DATETIME),
|
||||
CustomField(name='url', type=CustomFieldTypeChoices.TYPE_URL),
|
||||
CustomField(name='json', type=CustomFieldTypeChoices.TYPE_JSON),
|
||||
CustomField(name='select', type=CustomFieldTypeChoices.TYPE_SELECT, choices=[
|
||||
'Choice A', 'Choice B', 'Choice C',
|
||||
]),
|
||||
CustomField(name='multiselect', type=CustomFieldTypeChoices.TYPE_MULTISELECT, choices=[
|
||||
'Choice A', 'Choice B', 'Choice C',
|
||||
]),
|
||||
CustomField(name='select', type=CustomFieldTypeChoices.TYPE_SELECT, choice_set=choice_set),
|
||||
CustomField(name='multiselect', type=CustomFieldTypeChoices.TYPE_MULTISELECT, choice_set=choice_set),
|
||||
)
|
||||
for cf in custom_fields:
|
||||
cf.save()
|
||||
@ -1203,6 +1219,11 @@ class CustomFieldModelFilterTest(TestCase):
|
||||
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
|
||||
cf = CustomField(name='cf1', type=CustomFieldTypeChoices.TYPE_INTEGER)
|
||||
cf.save()
|
||||
@ -1263,7 +1284,7 @@ class CustomFieldModelFilterTest(TestCase):
|
||||
cf = CustomField(
|
||||
name='cf9',
|
||||
type=CustomFieldTypeChoices.TYPE_SELECT,
|
||||
choices=['Foo', 'Bar', 'Baz']
|
||||
choice_set=choice_set
|
||||
)
|
||||
cf.save()
|
||||
cf.content_types.set([obj_type])
|
||||
@ -1272,7 +1293,7 @@ class CustomFieldModelFilterTest(TestCase):
|
||||
cf = CustomField(
|
||||
name='cf10',
|
||||
type=CustomFieldTypeChoices.TYPE_MULTISELECT,
|
||||
choices=['A', 'B', 'C', 'X']
|
||||
choice_set=choice_set
|
||||
)
|
||||
cf.save()
|
||||
cf.content_types.set([obj_type])
|
||||
@ -1305,7 +1326,7 @@ class CustomFieldModelFilterTest(TestCase):
|
||||
'cf6': '2016-06-26',
|
||||
'cf7': 'http://a.example.com',
|
||||
'cf8': 'http://a.example.com',
|
||||
'cf9': 'Foo',
|
||||
'cf9': 'A',
|
||||
'cf10': ['A', 'X'],
|
||||
'cf11': manufacturers[0].pk,
|
||||
'cf12': [manufacturers[0].pk, manufacturers[3].pk],
|
||||
@ -1319,7 +1340,7 @@ class CustomFieldModelFilterTest(TestCase):
|
||||
'cf6': '2016-06-27',
|
||||
'cf7': 'http://b.example.com',
|
||||
'cf8': 'http://b.example.com',
|
||||
'cf9': 'Bar',
|
||||
'cf9': 'B',
|
||||
'cf10': ['B', 'X'],
|
||||
'cf11': manufacturers[1].pk,
|
||||
'cf12': [manufacturers[1].pk, manufacturers[3].pk],
|
||||
@ -1333,7 +1354,7 @@ class CustomFieldModelFilterTest(TestCase):
|
||||
'cf6': '2016-06-28',
|
||||
'cf7': 'http://c.example.com',
|
||||
'cf8': 'http://c.example.com',
|
||||
'cf9': 'Baz',
|
||||
'cf9': 'C',
|
||||
'cf10': ['C', 'X'],
|
||||
'cf11': manufacturers[2].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)
|
||||
|
||||
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):
|
||||
self.assertEqual(self.filterset({'cf_cf10': ['A', 'B']}, self.queryset).qs.count(), 2)
|
||||
|
@ -27,7 +27,11 @@ class CustomFieldTestCase(TestCase, BaseFilterSetTests):
|
||||
|
||||
@classmethod
|
||||
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 = (
|
||||
CustomField(
|
||||
@ -54,11 +58,31 @@ class CustomFieldTestCase(TestCase, BaseFilterSetTests):
|
||||
filter_logic=CustomFieldFilterLogicChoices.FILTER_DISABLED,
|
||||
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)
|
||||
custom_fields[0].content_types.add(content_types[0])
|
||||
custom_fields[1].content_types.add(content_types[1])
|
||||
custom_fields[2].content_types.add(content_types[2])
|
||||
custom_fields[0].content_types.add(ContentType.objects.get_by_natural_key('dcim', 'site'))
|
||||
custom_fields[1].content_types.add(ContentType.objects.get_by_natural_key('dcim', 'rack'))
|
||||
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):
|
||||
params = {'name': ['Custom Field 1', 'Custom Field 2']}
|
||||
@ -67,7 +91,7 @@ class CustomFieldTestCase(TestCase, BaseFilterSetTests):
|
||||
def test_content_types(self):
|
||||
params = {'content_types': 'dcim.site'}
|
||||
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)
|
||||
|
||||
def test_required(self):
|
||||
@ -86,6 +110,34 @@ class CustomFieldTestCase(TestCase, BaseFilterSetTests):
|
||||
params = {'ui_visibility': CustomFieldVisibilityChoices.VISIBILITY_READ_WRITE}
|
||||
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):
|
||||
queryset = Webhook.objects.all()
|
||||
|
@ -5,7 +5,7 @@ from dcim.forms import SiteForm
|
||||
from dcim.models import Site
|
||||
from extras.choices import CustomFieldTypeChoices
|
||||
from extras.forms import SavedFilterForm
|
||||
from extras.models import CustomField
|
||||
from extras.models import CustomField, CustomFieldChoiceSet
|
||||
|
||||
|
||||
class CustomFieldModelFormTest(TestCase):
|
||||
@ -13,7 +13,10 @@ class CustomFieldModelFormTest(TestCase):
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
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.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.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_multiselect = CustomField.objects.create(
|
||||
name='multiselect',
|
||||
type=CustomFieldTypeChoices.TYPE_MULTISELECT,
|
||||
choices=CHOICES
|
||||
choice_set=choice_set
|
||||
)
|
||||
cf_multiselect.content_types.set([obj_type])
|
||||
|
||||
|
@ -21,6 +21,11 @@ class CustomFieldTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
||||
def setUpTestData(cls):
|
||||
|
||||
site_ct = ContentType.objects.get_for_model(Site)
|
||||
CustomFieldChoiceSet.objects.create(
|
||||
name='Choice Set 1',
|
||||
extra_choices=('A', 'B', 'C')
|
||||
)
|
||||
|
||||
custom_fields = (
|
||||
CustomField(name='field1', label='Field 1', 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 = (
|
||||
'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',
|
||||
'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',
|
||||
)
|
||||
|
||||
@ -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):
|
||||
model = CustomLink
|
||||
|
||||
@ -219,7 +261,7 @@ class BookmarkTestCase(
|
||||
|
||||
def _get_url(self, action, instance=None):
|
||||
if action == 'list':
|
||||
return reverse('users:bookmarks')
|
||||
return reverse('account:bookmarks')
|
||||
return super()._get_url(action, instance)
|
||||
|
||||
def test_list_objects_anonymous(self):
|
||||
|
@ -15,6 +15,14 @@ urlpatterns = [
|
||||
path('custom-fields/delete/', views.CustomFieldBulkDeleteView.as_view(), name='customfield_bulk_delete'),
|
||||
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
|
||||
path('custom-links/', views.CustomLinkListView.as_view(), name='customlink_list'),
|
||||
path('custom-links/add/', views.CustomLinkEditView.as_view(), name='customlink_add'),
|
||||
|
@ -34,7 +34,7 @@ from .scripts import run_script
|
||||
#
|
||||
|
||||
class CustomFieldListView(generic.ObjectListView):
|
||||
queryset = CustomField.objects.all()
|
||||
queryset = CustomField.objects.select_related('choice_set')
|
||||
filterset = filtersets.CustomFieldFilterSet
|
||||
filterset_form = forms.CustomFieldFilterForm
|
||||
table = tables.CustomFieldTable
|
||||
@ -42,38 +42,83 @@ class CustomFieldListView(generic.ObjectListView):
|
||||
|
||||
@register_model_view(CustomField)
|
||||
class CustomFieldView(generic.ObjectView):
|
||||
queryset = CustomField.objects.all()
|
||||
queryset = CustomField.objects.select_related('choice_set')
|
||||
|
||||
|
||||
@register_model_view(CustomField, 'edit')
|
||||
class CustomFieldEditView(generic.ObjectEditView):
|
||||
queryset = CustomField.objects.all()
|
||||
queryset = CustomField.objects.select_related('choice_set')
|
||||
form = forms.CustomFieldForm
|
||||
|
||||
|
||||
@register_model_view(CustomField, 'delete')
|
||||
class CustomFieldDeleteView(generic.ObjectDeleteView):
|
||||
queryset = CustomField.objects.all()
|
||||
queryset = CustomField.objects.select_related('choice_set')
|
||||
|
||||
|
||||
class CustomFieldBulkImportView(generic.BulkImportView):
|
||||
queryset = CustomField.objects.all()
|
||||
queryset = CustomField.objects.select_related('choice_set')
|
||||
model_form = forms.CustomFieldImportForm
|
||||
|
||||
|
||||
class CustomFieldBulkEditView(generic.BulkEditView):
|
||||
queryset = CustomField.objects.all()
|
||||
queryset = CustomField.objects.select_related('choice_set')
|
||||
filterset = filtersets.CustomFieldFilterSet
|
||||
table = tables.CustomFieldTable
|
||||
form = forms.CustomFieldBulkEditForm
|
||||
|
||||
|
||||
class CustomFieldBulkDeleteView(generic.BulkDeleteView):
|
||||
queryset = CustomField.objects.all()
|
||||
queryset = CustomField.objects.select_related('choice_set')
|
||||
filterset = filtersets.CustomFieldFilterSet
|
||||
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
|
||||
#
|
||||
|
@ -849,6 +849,24 @@ class IPAddress(PrimaryModel):
|
||||
return self.address.version
|
||||
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):
|
||||
"""
|
||||
Expose the IPNetwork object's prefixlen attribute on the parent model so that it can be manipulated directly,
|
||||
|
@ -52,13 +52,19 @@ def handle_prefix_deleted(instance, **kwargs):
|
||||
@receiver(pre_delete, sender=IPAddress)
|
||||
def clear_primary_ip(instance, **kwargs):
|
||||
"""
|
||||
When an IPAddress is deleted, trigger save() on any Devices/VirtualMachines for which it
|
||||
was a primary IP.
|
||||
When an IPAddress is deleted, trigger save() on any Devices/VirtualMachines for which it was a primary IP.
|
||||
"""
|
||||
field_name = f'primary_ip{instance.family}'
|
||||
device = Device.objects.filter(**{field_name: instance}).first()
|
||||
if device:
|
||||
if device := Device.objects.filter(**{field_name: instance}).first():
|
||||
device.save()
|
||||
virtualmachine = VirtualMachine.objects.filter(**{field_name: instance}).first()
|
||||
if virtualmachine:
|
||||
if virtualmachine := VirtualMachine.objects.filter(**{field_name: instance}).first():
|
||||
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()
|
||||
|
@ -8,6 +8,7 @@ from netbox.models.features import *
|
||||
from utilities.mptt import TreeManager
|
||||
from utilities.querysets import RestrictedQuerySet
|
||||
|
||||
|
||||
__all__ = (
|
||||
'ChangeLoggedModel',
|
||||
'NestedGroupModel',
|
||||
|
@ -1,6 +1,7 @@
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from netbox.registry import registry
|
||||
from utilities.choices import ButtonColorChoices
|
||||
from . import *
|
||||
|
||||
#
|
||||
@ -288,6 +289,7 @@ CUSTOMIZATION_MENU = Menu(
|
||||
label=_('Customization'),
|
||||
items=(
|
||||
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', 'exporttemplate', _('Export Templates')),
|
||||
get_model_item('extras', 'savedfilter', _('Saved Filters')),
|
||||
@ -350,6 +352,57 @@ ADMIN_MENU = Menu(
|
||||
label=_('Admin'),
|
||||
icon_class='mdi mdi-account-multiple',
|
||||
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(
|
||||
label=_('Configuration'),
|
||||
items=(
|
||||
|
@ -21,6 +21,7 @@ class Registry(dict):
|
||||
|
||||
# Initialize the global registry
|
||||
registry = Registry({
|
||||
'counter_fields': collections.defaultdict(dict),
|
||||
'data_backends': dict(),
|
||||
'denormalized_fields': collections.defaultdict(list),
|
||||
'model_features': dict(),
|
||||
|
@ -469,6 +469,7 @@ EXEMPT_EXCLUDE_MODELS = (
|
||||
('auth', 'group'),
|
||||
('auth', 'user'),
|
||||
('users', 'objectpermission'),
|
||||
('users', 'token'),
|
||||
)
|
||||
|
||||
# All URLs starting with a string listed here are exempt from login enforcement
|
||||
|
@ -21,6 +21,7 @@ from utilities.utils import content_type_identifier, content_type_name, get_view
|
||||
|
||||
__all__ = (
|
||||
'ActionsColumn',
|
||||
'ArrayColumn',
|
||||
'BooleanColumn',
|
||||
'ChoiceFieldColumn',
|
||||
'ColorColumn',
|
||||
@ -591,3 +592,22 @@ class MarkdownColumn(tables.TemplateColumn):
|
||||
|
||||
def value(self, 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)
|
||||
|
@ -32,10 +32,13 @@ _patterns = [
|
||||
path('extras/', include('extras.urls')),
|
||||
path('ipam/', include('ipam.urls')),
|
||||
path('tenancy/', include('tenancy.urls')),
|
||||
path('user/', include('users.urls')),
|
||||
path('users/', include('users.urls')),
|
||||
path('virtualization/', include('virtualization.urls')),
|
||||
path('wireless/', include('wireless.urls')),
|
||||
|
||||
# Current user views
|
||||
path('user/', include('users.account_urls')),
|
||||
|
||||
# HTMX views
|
||||
path('htmx/object-selector/', htmx.ObjectSelectorView.as_view(), name='htmx_object_selector'),
|
||||
|
||||
|
@ -22,6 +22,7 @@ class ActionsMixin:
|
||||
Return a tuple of actions for which the given user is permitted to do.
|
||||
"""
|
||||
model = model or self.queryset.model
|
||||
|
||||
return [
|
||||
action for action in self.actions if user.has_perms([
|
||||
get_permission_for_model(model, name) for name in self.action_perms[action]
|
||||
|
@ -239,6 +239,17 @@
|
||||
{% endif %}
|
||||
</td>
|
||||
</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 %}
|
||||
<tr>
|
||||
<th>Cluster</th>
|
||||
|
@ -68,6 +68,7 @@
|
||||
{% if object.pk %}
|
||||
{% render_field form.primary_ip4 %}
|
||||
{% render_field form.primary_ip6 %}
|
||||
{% render_field form.oob_ip %}
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
|
@ -31,6 +31,16 @@
|
||||
<th scope="row">Description</th>
|
||||
<td>{{ object.description|placeholder }}</td>
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -15,14 +15,6 @@
|
||||
<th scope="row">Name</th>
|
||||
<td>{{ object.name }}</td>
|
||||
</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>
|
||||
<th scope="row">Type</th>
|
||||
<td>
|
||||
@ -30,6 +22,14 @@
|
||||
{% if object.object_type %}({{ object.object_type.model|bettertitle }}){% endif %}
|
||||
</td>
|
||||
</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>
|
||||
<th scope="row">Description</th>
|
||||
<td>{{ object.description|markdown|placeholder }}</td>
|
||||
@ -38,6 +38,27 @@
|
||||
<th scope="row">Required</th>
|
||||
<td>{% checkmark object.required %}</td>
|
||||
</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>
|
||||
<th scope="row">Search Weight</th>
|
||||
<td>
|
||||
@ -60,33 +81,6 @@
|
||||
<th scope="row">UI Visibility</th>
|
||||
<td>{{ object.get_ui_visibility_display }}</td>
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
@ -94,9 +88,7 @@
|
||||
</div>
|
||||
<div class="col col-md-6">
|
||||
<div class="card">
|
||||
<h5 class="card-header">
|
||||
Assigned Models
|
||||
</h5>
|
||||
<h5 class="card-header">Object Types</h5>
|
||||
<div class="card-body">
|
||||
<table class="table table-hover attr-table">
|
||||
{% for ct in object.content_types.all %}
|
||||
@ -108,9 +100,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h5 class="card-header">
|
||||
Validation Rules
|
||||
</h5>
|
||||
<h5 class="card-header">Validation Rules</h5>
|
||||
<div class="card-body">
|
||||
<table class="table table-hover attr-table">
|
||||
<tr>
|
||||
@ -138,8 +128,8 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col col-md-12">
|
||||
{% plugin_full_width_page object %}
|
||||
</div>
|
||||
<div class="col col-md-12">
|
||||
{% plugin_full_width_page object %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
64
netbox/templates/extras/customfieldchoiceset.html
Normal file
64
netbox/templates/extras/customfieldchoiceset.html
Normal 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 %}
|
@ -19,22 +19,22 @@
|
||||
{% endif %}
|
||||
</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
|
||||
</a>
|
||||
</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
|
||||
</a>
|
||||
</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
|
||||
</a>
|
||||
</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
|
||||
</a>
|
||||
</li>
|
||||
|
@ -96,6 +96,14 @@
|
||||
{% endfor %}
|
||||
</td>
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -1,23 +1,24 @@
|
||||
{% extends 'base/layout.html' %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block tabs %}
|
||||
<ul class="nav nav-tabs px-3">
|
||||
<li role="presentation" class="nav-item">
|
||||
<a class="nav-link{% if active_tab == 'profile' %} active{% endif %}" href="{% url 'users:profile' %}">Profile</a>
|
||||
<a class="nav-link{% if active_tab == 'profile' %} active{% endif %}" href="{% url 'account:profile' %}">{% trans "Profile" %}</a>
|
||||
</li>
|
||||
<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 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>
|
||||
{% if not request.user.ldap_username %}
|
||||
<li role="presentation" class="nav-item">
|
||||
<a class="nav-link{% if active_tab == 'password' %} active{% endif %}" href="{% url 'users:change_password' %}">Password</a>
|
||||
<a class="nav-link{% if active_tab == 'password' %} active{% endif %}" href="{% url 'account:change_password' %}">{% trans "Password" %}</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
<li role="presentation" class="nav-item">
|
||||
<a class="nav-link{% if active_tab == 'api-tokens' %} active{% endif %}" href="{% url 'users:token_list' %}">API Tokens</a>
|
||||
<a class="nav-link{% if active_tab == 'api-tokens' %} active{% endif %}" href="{% url 'account:usertoken_list' %}">{% trans "API Tokens" %}</a>
|
||||
</li>
|
||||
</ul>
|
||||
{% endblock %}
|
@ -1,4 +1,4 @@
|
||||
{% extends 'users/base.html' %}
|
||||
{% extends 'users/account/base.html' %}
|
||||
{% load buttons %}
|
||||
{% load helpers %}
|
||||
{% load render_table from django_tables2 %}
|
||||
@ -9,7 +9,7 @@
|
||||
|
||||
<form method="post" class="form form-horizontal">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="return_url" value="{% url 'users:bookmarks' %}" />
|
||||
<input type="hidden" name="return_url" value="{% url 'account:bookmarks' %}" />
|
||||
|
||||
{# Table #}
|
||||
<div class="row">
|
@ -1,4 +1,4 @@
|
||||
{% extends 'users/base.html' %}
|
||||
{% extends 'users/account/base.html' %}
|
||||
{% load form_helpers %}
|
||||
|
||||
{% block title %}Change Password{% endblock %}
|
||||
@ -13,7 +13,7 @@
|
||||
{% render_field form.new_password2 %}
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
</form>
|
@ -1,4 +1,4 @@
|
||||
{% extends 'users/base.html' %}
|
||||
{% extends 'users/account/base.html' %}
|
||||
{% load helpers %}
|
||||
{% load form_helpers %}
|
||||
|
||||
@ -79,7 +79,7 @@
|
||||
</div>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
</form>
|
@ -1,4 +1,4 @@
|
||||
{% extends 'users/base.html' %}
|
||||
{% extends 'users/account/base.html' %}
|
||||
{% load helpers %}
|
||||
{% load render_table from django_tables2 %}
|
||||
|
69
netbox/templates/users/account/token.html
Normal file
69
netbox/templates/users/account/token.html
Normal 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 %}
|
@ -1,13 +1,13 @@
|
||||
{% extends 'users/base.html' %}
|
||||
{% extends 'users/account/base.html' %}
|
||||
{% load helpers %}
|
||||
{% load render_table from django_tables2 %}
|
||||
|
||||
{% block title %}API Tokens{% endblock %}
|
||||
{% block title %}My API Tokens{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row">
|
||||
<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
|
||||
</a>
|
||||
</div>
|
@ -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 %}
|
48
netbox/templates/users/group.html
Normal file
48
netbox/templates/users/group.html
Normal 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 %}
|
97
netbox/templates/users/objectpermission.html
Normal file
97
netbox/templates/users/objectpermission.html
Normal 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 %}
|
56
netbox/templates/users/token.html
Normal file
56
netbox/templates/users/token.html
Normal 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 %}
|
84
netbox/templates/users/user.html
Normal file
84
netbox/templates/users/user.html
Normal 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 %}
|
18
netbox/users/account_urls.py
Normal file
18
netbox/users/account_urls.py
Normal 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'))),
|
||||
|
||||
]
|
@ -1,11 +1,6 @@
|
||||
from django.contrib import admin
|
||||
from django.contrib.auth.admin import UserAdmin as UserAdmin_
|
||||
from django.contrib.auth.models import Group, User
|
||||
|
||||
from users.models import ObjectPermission, Token
|
||||
from . import filters, forms, inlines
|
||||
|
||||
|
||||
#
|
||||
# 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
|
||||
admin.site.unregister(Group)
|
||||
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")
|
||||
|
@ -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}'
|
||||
})
|
@ -10,6 +10,7 @@ from users.models import ObjectPermission, Token
|
||||
__all__ = (
|
||||
'GroupFilterSet',
|
||||
'ObjectPermissionFilterSet',
|
||||
'TokenFilterSet',
|
||||
'UserFilterSet',
|
||||
)
|
||||
|
||||
@ -49,7 +50,7 @@ class UserFilterSet(BaseFilterSet):
|
||||
|
||||
class Meta:
|
||||
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):
|
||||
if not value.strip():
|
||||
@ -115,6 +116,18 @@ class ObjectPermissionFilterSet(BaseFilterSet):
|
||||
method='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(
|
||||
field_name='users',
|
||||
queryset=get_user_model().objects.all(),
|
||||
@ -149,3 +162,10 @@ class ObjectPermissionFilterSet(BaseFilterSet):
|
||||
Q(name__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])
|
||||
|
@ -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']
|
5
netbox/users/forms/__init__.py
Normal file
5
netbox/users/forms/__init__.py
Normal file
@ -0,0 +1,5 @@
|
||||
from .authentication import *
|
||||
from .bulk_edit import *
|
||||
from .bulk_import import *
|
||||
from .filtersets import *
|
||||
from .model_forms import *
|
25
netbox/users/forms/authentication.py
Normal file
25
netbox/users/forms/authentication.py
Normal 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
|
111
netbox/users/forms/bulk_edit.py
Normal file
111
netbox/users/forms/bulk_edit.py
Normal 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',
|
||||
)
|
48
netbox/users/forms/bulk_import.py
Normal file
48
netbox/users/forms/bulk_import.py
Normal 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',)
|
146
netbox/users/forms/filtersets.py
Normal file
146
netbox/users/forms/filtersets.py
Normal 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()
|
||||
)
|
403
netbox/users/forms/model_forms.py
Normal file
403
netbox/users/forms/model_forms.py
Normal 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)
|
||||
})
|
50
netbox/users/migrations/0004_netboxgroup_netboxuser.py
Normal file
50
netbox/users/migrations/0004_netboxgroup_netboxuser.py
Normal 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'},
|
||||
),
|
||||
]
|
25
netbox/users/migrations/0005_usertoken.py
Normal file
25
netbox/users/migrations/0005_usertoken.py
Normal 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',),
|
||||
),
|
||||
]
|
@ -2,13 +2,14 @@ import binascii
|
||||
import os
|
||||
|
||||
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.postgres.fields import ArrayField
|
||||
from django.core.validators import MinLengthValidator
|
||||
from django.db import models
|
||||
from django.db.models.signals import post_save
|
||||
from django.dispatch import receiver
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from django.utils.translation import gettext as _
|
||||
from netaddr import IPNetwork
|
||||
@ -20,9 +21,12 @@ from utilities.utils import flatten_dict
|
||||
from .constants import *
|
||||
|
||||
__all__ = (
|
||||
'NetBoxGroup',
|
||||
'NetBoxUser',
|
||||
'ObjectPermission',
|
||||
'Token',
|
||||
'UserConfig',
|
||||
'UserToken',
|
||||
)
|
||||
|
||||
|
||||
@ -30,6 +34,7 @@ __all__ = (
|
||||
# Proxy models for admin
|
||||
#
|
||||
|
||||
|
||||
class AdminGroup(Group):
|
||||
"""
|
||||
Proxy contrib.auth.models.Group for the admin UI
|
||||
@ -48,6 +53,44 @@ class AdminUser(User):
|
||||
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
|
||||
#
|
||||
@ -231,13 +274,20 @@ class Token(models.Model):
|
||||
blank=True,
|
||||
null=True,
|
||||
verbose_name='Allowed IPs',
|
||||
help_text=_('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"'),
|
||||
help_text=_(
|
||||
'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):
|
||||
return self.key if settings.ALLOW_TOKEN_RETRIEVAL else self.partial
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse('users:token', args=[self.pk])
|
||||
|
||||
@property
|
||||
def partial(self):
|
||||
return f'**********************************{self.key[-6:]}' if self.key else ''
|
||||
@ -272,6 +322,18 @@ class Token(models.Model):
|
||||
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
|
||||
#
|
||||
@ -325,6 +387,22 @@ class ObjectPermission(models.Model):
|
||||
def __str__(self):
|
||||
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):
|
||||
"""
|
||||
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:
|
||||
return [self.constraints]
|
||||
return self.constraints
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse('users:objectpermission', args=[self.pk])
|
||||
|
@ -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 users.models import NetBoxGroup, NetBoxUser, ObjectPermission, Token, UserToken
|
||||
|
||||
__all__ = (
|
||||
'GroupTable',
|
||||
'ObjectPermissionTable',
|
||||
'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(
|
||||
template_code=TOKEN
|
||||
verbose_name=_('Key'),
|
||||
template_code=TOKEN,
|
||||
)
|
||||
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(
|
||||
verbose_name=_('Allowed IPs'),
|
||||
template_code=ALLOWED_IPS
|
||||
)
|
||||
actions = TokenActionsColumn(
|
||||
@ -43,8 +60,93 @@ class TokenTable(NetBoxTable):
|
||||
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):
|
||||
model = Token
|
||||
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',
|
||||
)
|
||||
|
@ -10,7 +10,6 @@ from users import filtersets
|
||||
from users.models import ObjectPermission, Token
|
||||
from utilities.testing import BaseFilterSetTests
|
||||
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
@ -34,7 +33,8 @@ class UserTestCase(TestCase, BaseFilterSetTests):
|
||||
first_name='Hank',
|
||||
last_name='Hill',
|
||||
email='hank@stricklandpropane.com',
|
||||
is_staff=True
|
||||
is_staff=True,
|
||||
is_superuser=True
|
||||
),
|
||||
User(
|
||||
username='User2',
|
||||
@ -83,13 +83,17 @@ class UserTestCase(TestCase, BaseFilterSetTests):
|
||||
params = {'email': ['hank@stricklandpropane.com', 'dale@dalesdeadbug.com']}
|
||||
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):
|
||||
params = {'is_staff': True}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
|
||||
|
||||
def test_is_active(self):
|
||||
params = {'is_active': True}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
|
||||
def test_is_superuser(self):
|
||||
params = {'is_superuser': True}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
|
||||
|
||||
def test_group(self):
|
||||
groups = Group.objects.all()[:2]
|
||||
@ -191,6 +195,22 @@ class ObjectPermissionTestCase(TestCase, BaseFilterSetTests):
|
||||
params = {'description': ['foobar1', 'foobar2']}
|
||||
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):
|
||||
queryset = Token.objects.all()
|
||||
|
201
netbox/users/tests/test_views.py
Normal file
201
netbox/users/tests/test_views.py
Normal 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',
|
||||
}
|
@ -6,15 +6,34 @@ from . import views
|
||||
app_name = 'users'
|
||||
urlpatterns = [
|
||||
|
||||
# User
|
||||
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'),
|
||||
# Tokens
|
||||
path('tokens/', views.TokenListView.as_view(), name='token_list'),
|
||||
path('tokens/add/', views.TokenEditView.as_view(), name='token_add'),
|
||||
path('tokens/import/', views.TokenBulkImportView.as_view(), name='token_import'),
|
||||
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
|
||||
path('api-tokens/', views.TokenListView.as_view(), name='token_list'),
|
||||
path('api-tokens/add/', views.TokenEditView.as_view(), name='token_add'),
|
||||
path('api-tokens/<int:pk>/', include(get_model_urls('users', 'token'))),
|
||||
# Users
|
||||
path('users/', views.UserListView.as_view(), name='netboxuser_list'),
|
||||
path('users/add/', views.UserEditView.as_view(), name='netboxuser_add'),
|
||||
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'))),
|
||||
|
||||
]
|
||||
|
@ -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.models import update_last_login
|
||||
from django.contrib.auth.signals import user_logged_in
|
||||
from django.db.models import Count
|
||||
from django.http import HttpResponseRedirect
|
||||
from django.shortcuts import get_object_or_404, redirect, render, resolve_url
|
||||
from django.urls import reverse
|
||||
@ -19,12 +20,11 @@ from extras.models import Bookmark, ObjectChange
|
||||
from extras.tables import BookmarkTable, ObjectChangeTable
|
||||
from netbox.authentication import get_auth_backend_display, get_saml_idps
|
||||
from netbox.config import get_config
|
||||
from netbox.views.generic import ObjectListView
|
||||
from netbox.views import generic
|
||||
from utilities.forms import ConfirmationForm
|
||||
from utilities.views import register_model_view
|
||||
from .forms import LoginForm, PasswordChangeForm, TokenForm, UserConfigForm
|
||||
from .models import Token, UserConfig
|
||||
from .tables import TokenTable
|
||||
from . import filtersets, forms, tables
|
||||
from .models import NetBoxGroup, NetBoxUser, ObjectPermission, Token, UserConfig, UserToken
|
||||
|
||||
|
||||
#
|
||||
@ -70,7 +70,7 @@ class LoginView(View):
|
||||
return auth_backends
|
||||
|
||||
def get(self, request):
|
||||
form = LoginForm(request)
|
||||
form = forms.LoginForm(request)
|
||||
|
||||
if request.user.is_authenticated:
|
||||
logger = logging.getLogger('netbox.auth.login')
|
||||
@ -83,7 +83,7 @@ class LoginView(View):
|
||||
|
||||
def post(self, request):
|
||||
logger = logging.getLogger('netbox.auth.login')
|
||||
form = LoginForm(request, data=request.POST)
|
||||
form = forms.LoginForm(request, data=request.POST)
|
||||
|
||||
if form.is_valid():
|
||||
logger.debug("Login form validation was successful")
|
||||
@ -155,7 +155,7 @@ class LogoutView(View):
|
||||
#
|
||||
|
||||
class ProfileView(LoginRequiredMixin, View):
|
||||
template_name = 'users/profile.html'
|
||||
template_name = 'users/account/profile.html'
|
||||
|
||||
def get(self, request):
|
||||
|
||||
@ -174,11 +174,11 @@ class ProfileView(LoginRequiredMixin, View):
|
||||
|
||||
|
||||
class UserConfigView(LoginRequiredMixin, View):
|
||||
template_name = 'users/preferences.html'
|
||||
template_name = 'users/account/preferences.html'
|
||||
|
||||
def get(self, request):
|
||||
userconfig = request.user.config
|
||||
form = UserConfigForm(instance=userconfig)
|
||||
form = forms.UserConfigForm(instance=userconfig)
|
||||
|
||||
return render(request, self.template_name, {
|
||||
'form': form,
|
||||
@ -187,13 +187,13 @@ class UserConfigView(LoginRequiredMixin, View):
|
||||
|
||||
def post(self, request):
|
||||
userconfig = request.user.config
|
||||
form = UserConfigForm(request.POST, instance=userconfig)
|
||||
form = forms.UserConfigForm(request.POST, instance=userconfig)
|
||||
|
||||
if form.is_valid():
|
||||
form.save()
|
||||
|
||||
messages.success(request, "Your preferences have been updated.")
|
||||
return redirect('users:preferences')
|
||||
return redirect('account:preferences')
|
||||
|
||||
return render(request, self.template_name, {
|
||||
'form': form,
|
||||
@ -202,15 +202,15 @@ class UserConfigView(LoginRequiredMixin, View):
|
||||
|
||||
|
||||
class ChangePasswordView(LoginRequiredMixin, View):
|
||||
template_name = 'users/password.html'
|
||||
template_name = 'users/account/password.html'
|
||||
|
||||
def get(self, request):
|
||||
# LDAP users cannot change their password here
|
||||
if getattr(request.user, 'ldap_username', None):
|
||||
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, {
|
||||
'form': form,
|
||||
@ -218,12 +218,12 @@ class ChangePasswordView(LoginRequiredMixin, View):
|
||||
})
|
||||
|
||||
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():
|
||||
form.save()
|
||||
update_session_auth_hash(request, form.user)
|
||||
messages.success(request, "Your password has been changed successfully.")
|
||||
return redirect('users:profile')
|
||||
return redirect('account:profile')
|
||||
|
||||
return render(request, self.template_name, {
|
||||
'form': form,
|
||||
@ -235,9 +235,9 @@ class ChangePasswordView(LoginRequiredMixin, View):
|
||||
# Bookmarks
|
||||
#
|
||||
|
||||
class BookmarkListView(LoginRequiredMixin, ObjectListView):
|
||||
class BookmarkListView(LoginRequiredMixin, generic.ObjectListView):
|
||||
table = BookmarkTable
|
||||
template_name = 'users/bookmarks.html'
|
||||
template_name = 'users/account/bookmarks.html'
|
||||
|
||||
def get_queryset(self, request):
|
||||
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):
|
||||
|
||||
tokens = Token.objects.filter(user=request.user)
|
||||
table = TokenTable(tokens)
|
||||
tokens = UserToken.objects.filter(user=request.user)
|
||||
table = tables.UserTokenTable(tokens)
|
||||
table.configure(request)
|
||||
|
||||
return render(request, 'users/api_tokens.html', {
|
||||
return render(request, 'users/account/token_list.html', {
|
||||
'tokens': tokens,
|
||||
'active_tab': 'api-tokens',
|
||||
'table': table,
|
||||
})
|
||||
|
||||
|
||||
@register_model_view(Token, 'edit')
|
||||
class TokenEditView(LoginRequiredMixin, View):
|
||||
@register_model_view(UserToken)
|
||||
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):
|
||||
|
||||
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:
|
||||
token = Token(user=request.user)
|
||||
|
||||
form = TokenForm(instance=token)
|
||||
token = UserToken(user=request.user)
|
||||
form = forms.UserTokenForm(instance=token)
|
||||
|
||||
return render(request, 'generic/object_edit.html', {
|
||||
'object': token,
|
||||
'form': form,
|
||||
'return_url': reverse('users:token_list'),
|
||||
'return_url': reverse('account:usertoken_list'),
|
||||
})
|
||||
|
||||
def post(self, request, pk=None):
|
||||
|
||||
if pk:
|
||||
token = get_object_or_404(Token.objects.filter(user=request.user), pk=pk)
|
||||
form = TokenForm(request.POST, instance=token)
|
||||
token = get_object_or_404(UserToken.objects.filter(user=request.user), pk=pk)
|
||||
form = forms.UserTokenForm(request.POST, instance=token)
|
||||
else:
|
||||
token = Token(user=request.user)
|
||||
form = TokenForm(request.POST)
|
||||
token = UserToken(user=request.user)
|
||||
form = forms.UserTokenForm(request.POST)
|
||||
|
||||
if form.is_valid():
|
||||
|
||||
token = form.save(commit=False)
|
||||
token.user = request.user
|
||||
token.save()
|
||||
@ -304,7 +312,7 @@ class TokenEditView(LoginRequiredMixin, View):
|
||||
messages.success(request, msg)
|
||||
|
||||
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,
|
||||
'key': token.key,
|
||||
'return_url': reverse('users:token_list'),
|
||||
@ -312,44 +320,216 @@ class TokenEditView(LoginRequiredMixin, View):
|
||||
elif '_addanother' in request.POST:
|
||||
return redirect(request.path)
|
||||
else:
|
||||
return redirect('users:token_list')
|
||||
return redirect('account:usertoken_list')
|
||||
|
||||
return render(request, 'generic/object_edit.html', {
|
||||
'object': token,
|
||||
'form': form,
|
||||
'return_url': reverse('users:token_list'),
|
||||
'return_url': reverse('account:usertoken_list'),
|
||||
'disable_addanother': not settings.ALLOW_TOKEN_RETRIEVAL
|
||||
})
|
||||
|
||||
|
||||
@register_model_view(Token, 'delete')
|
||||
class TokenDeleteView(LoginRequiredMixin, View):
|
||||
@register_model_view(UserToken, 'delete')
|
||||
class UserTokenDeleteView(LoginRequiredMixin, View):
|
||||
|
||||
def get(self, request, 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)
|
||||
token = get_object_or_404(UserToken.objects.filter(user=request.user), pk=pk)
|
||||
|
||||
return render(request, 'generic/object_delete.html', {
|
||||
'object': token,
|
||||
'form': form,
|
||||
'return_url': reverse('users:token_list'),
|
||||
'form': ConfirmationForm(),
|
||||
'return_url': reverse('account:usertoken_list'),
|
||||
})
|
||||
|
||||
def post(self, request, 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 = ConfirmationForm(request.POST)
|
||||
|
||||
if form.is_valid():
|
||||
token.delete()
|
||||
messages.success(request, "Token deleted")
|
||||
return redirect('users:token_list')
|
||||
return redirect('account:usertoken_list')
|
||||
|
||||
return render(request, 'generic/object_delete.html', {
|
||||
'object': token,
|
||||
'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
|
||||
|
93
netbox/utilities/counters.py
Normal file
93
netbox/utilities/counters.py
Normal 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}'
|
||||
)
|
@ -2,6 +2,7 @@ from collections import defaultdict
|
||||
|
||||
from django.contrib.contenttypes.fields import GenericForeignKey
|
||||
from django.db import models
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from utilities.ordering import naturalize
|
||||
from .forms.widgets import ColorSelect
|
||||
@ -9,6 +10,7 @@ from .validators import ColorValidator
|
||||
|
||||
__all__ = (
|
||||
'ColorField',
|
||||
'CounterCacheField',
|
||||
'NaturalOrderingField',
|
||||
'NullableCharField',
|
||||
'RestrictedGenericForeignKey',
|
||||
@ -143,3 +145,43 @@ class RestrictedGenericForeignKey(GenericForeignKey):
|
||||
self.name,
|
||||
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
|
||||
|
@ -1,6 +1,7 @@
|
||||
from django import forms
|
||||
|
||||
__all__ = (
|
||||
'ArrayWidget',
|
||||
'ClearableFileInput',
|
||||
'MarkdownWidget',
|
||||
'NumberWithOptions',
|
||||
@ -43,3 +44,13 @@ class SlugWidget(forms.TextInput):
|
||||
Subclass TextInput and add a slug regeneration button next to the form field.
|
||||
"""
|
||||
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)
|
||||
|
0
netbox/utilities/management/__init__.py
Normal file
0
netbox/utilities/management/__init__.py
Normal file
0
netbox/utilities/management/commands/__init__.py
Normal file
0
netbox/utilities/management/commands/__init__.py
Normal 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.'))
|
@ -18,11 +18,10 @@ def get_permission_for_model(model, action):
|
||||
:param model: A model or instance
|
||||
:param action: View, add, change, or delete (string)
|
||||
"""
|
||||
return '{}.{}_{}'.format(
|
||||
model._meta.app_label,
|
||||
action,
|
||||
model._meta.model_name
|
||||
)
|
||||
# Resolve to the "concrete" model (for proxy models)
|
||||
model = model._meta.concrete_model
|
||||
|
||||
return f'{model._meta.app_label}.{action}_{model._meta.model_name}'
|
||||
|
||||
|
||||
def resolve_permission(name):
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user