diff --git a/.gitignore b/.gitignore index d859bad28..36c6d3fa8 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,8 @@ /netbox/netbox/ldap_config.py /netbox/reports/* !/netbox/reports/__init__.py +/netbox/scripts/* +!/netbox/scripts/__init__.py /netbox/static .idea /*.sh diff --git a/CHANGELOG.md b/CHANGELOG.md index 115b5306b..dff8168c2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,13 +1,56 @@ -v2.6.2 (FUTURE) +v2.6.3 (2019-09-04) + +## New Features + +### Custom Scripts ([#3415](https://github.com/netbox-community/netbox/issues/3415)) + +Custom scripts allow for the execution of arbitrary code via the NetBox UI. They can be used to automatically create, manipulate, or clean up objects or perform other tasks within NetBox. Scripts are defined as Python files which contain one or more subclasses of `extras.scripts.Script`. Variable fields can be defined within scripts, which render as form fields within the web UI to prompt the user for input data. Scripts are executed and information is logged via the web UI. Please see [the docs](https://netbox.readthedocs.io/en/stable/additional-features/custom-scripts/) for more detail. + +Note: There are currently no API endpoints for this feature. These are planned for the upcoming v2.7 release. + +## Bug Fixes + +* [#3392](https://github.com/netbox-community/netbox/issues/3392) - Add database index for ObjectChange time +* [#3420](https://github.com/netbox-community/netbox/issues/3420) - Serial number filter for racks, devices, and inventory items is now case-insensitive +* [#3428](https://github.com/netbox-community/netbox/issues/3428) - Fixed cache invalidation issues ([#3300](https://github.com/netbox-community/netbox/issues/3300), [#3363](https://github.com/netbox-community/netbox/issues/3363), [#3379](https://github.com/netbox-community/netbox/issues/3379), [#3382](https://github.com/netbox-community/netbox/issues/3382)) by switching to `prefetch_related()` instead of `select_related()` and removing use of `update()` +* [#3421](https://github.com/netbox-community/netbox/issues/3421) - Fix exception when ordering power connections list by PDU +* [#3424](https://github.com/netbox-community/netbox/issues/3424) - Fix tag coloring for non-linked tags +* [#3426](https://github.com/netbox-community/netbox/issues/3426) - Improve API error handling for ChoiceFields + +## Enhancements + +* [#3386](https://github.com/netbox-community/netbox/issues/3386) - Add `mac_address` filter for virtual machines +* [#3391](https://github.com/netbox-community/netbox/issues/3391) - Update Bootstrap CSS to v3.4.1 +* [#3405](https://github.com/netbox-community/netbox/issues/3405) - Fix population of power port/outlet details on device creation +* [#3422](https://github.com/netbox-community/netbox/issues/3422) - Prevent navigation menu from overlapping page content +* [#3430](https://github.com/netbox-community/netbox/issues/3430) - Linkify platform field on device view +* [#3454](https://github.com/netbox-community/netbox/issues/3454) - Enable filtering circuits by region +* [#3456](https://github.com/netbox-community/netbox/issues/3456) - Enable bulk editing of tag color + +--- + +v2.6.2 (2019-08-02) ## Enhancements * [#984](https://github.com/netbox-community/netbox/issues/984) - Allow ordering circuits by A/Z side +* [#3307](https://github.com/netbox-community/netbox/issues/3307) - Add power panels count to home page +* [#3314](https://github.com/netbox-community/netbox/issues/3314) - Paginate object changelog entries +* [#3367](https://github.com/netbox-community/netbox/issues/3367) - Add BNC port type and coaxial cable type +* [#3368](https://github.com/netbox-community/netbox/issues/3368) - Indicate indefinite changelog retention when applicable +* [#3370](https://github.com/netbox-community/netbox/issues/3370) - Add filter class to VirtualChassis API ## Bug Fixes +* [#3018](https://github.com/netbox-community/netbox/issues/3018) - Components connected via a cable must have an equal number of positions +* [#3289](https://github.com/netbox-community/netbox/issues/3289) - Prevent position from being nullified when moving a device to a new rack +* [#3293](https://github.com/netbox-community/netbox/issues/3293) - Enable filtering device components by multiple device IDs +* [#3315](https://github.com/netbox-community/netbox/issues/3315) - Enable filtering devices/interfaces by multiple MAC addresses * [#3317](https://github.com/netbox-community/netbox/issues/3317) - Fix permissions for ConfigContextBulkDeleteView +* [#3323](https://github.com/netbox-community/netbox/issues/3323) - Fix permission evaluation for interface connections view * [#3342](https://github.com/netbox-community/netbox/issues/3342) - Fix cluster delete button +* [#3384](https://github.com/netbox-community/netbox/issues/3384) - Maximum and allocated draw fields should be included on power port template creation form +* [#3385](https://github.com/netbox-community/netbox/issues/3385) - Fix power panels list when bulk editing power feeds --- diff --git a/docs/additional-features/custom-links.md b/docs/additional-features/custom-links.md new file mode 100644 index 000000000..91dd06e30 --- /dev/null +++ b/docs/additional-features/custom-links.md @@ -0,0 +1,35 @@ +# Custom Links + +Custom links allow users to place arbitrary hyperlinks within NetBox views. These are helpful for cross-referencing related records in external systems. For example, you might create a custom link on the device view which links to the current device in a network monitoring system. + +Custom links are created under the admin UI. Each link is associated with a particular NetBox object type (site, device, prefix, etc.) and will be displayed on relevant views. Each link is assigned text and a URL, both of which support Jinja2 templating. The text and URL are rendered with the context variable `obj` representing the current object. + +For example, you might define a link like this: + +* Text: `View NMS` +* URL: `https://nms.example.com/nodes/?name={{ obj.name }}` + +When viewing a device named Router4, this link would render as: + +``` +View NMS +``` + +Custom links appear as buttons at the top right corner of the page. Numeric weighting can be used to influence the ordering of links. + +## Conditional Rendering + +Only links which render with non-empty text are included on the page. You can employ conditional Jinja2 logic to control the conditions under which a link gets rendered. + +For example, if you only want to display a link for active devices, you could set the link text to + +``` +{% if device.status == 1 %}View NMS{% endif %} +``` + +The link will not appear when viewing a device with any status other than "active." + +## Link Groups + +You can specify a group name to organize links into related sets. Grouped links will render as a dropdown menu beneath a +single button bearing the name of the group. diff --git a/docs/additional-features/custom-scripts.md b/docs/additional-features/custom-scripts.md new file mode 100644 index 000000000..fb8b70d67 --- /dev/null +++ b/docs/additional-features/custom-scripts.md @@ -0,0 +1,213 @@ +# Custom Scripts + +Custom scripting was introduced to provide a way for users to execute custom logic from within the NetBox UI. Custom scripts enable the user to directly and conveniently manipulate NetBox data in a prescribed fashion. They can be used to accomplish myriad tasks, such as: + +* Automatically populate new devices and cables in preparation for a new site deployment +* Create a range of new reserved prefixes or IP addresses +* Fetch data from an external source and import it to NetBox + +Custom scripts are Python code and exist outside of the official NetBox code base, so they can be updated and changed without interfering with the core NetBox installation. And because they're written from scratch, a custom script can be used to accomplish just about anything. + +## Writing Custom Scripts + +All custom scripts must inherit from the `extras.scripts.Script` base class. This class provides the functionality necessary to generate forms and log activity. + +``` +from extras.scripts import Script + +class MyScript(Script): + .. +``` + +Scripts comprise two core components: variables and a `run()` method. Variables allow your script to accept user input via the NetBox UI. The `run()` method is where your script's execution logic lives. (Note that your script can have as many methods as needed: this is merely the point of invocation for NetBox.) + +``` +class MyScript(Script): + var1 = StringVar(...) + var2 = IntegerVar(...) + var3 = ObjectVar(...) + + def run(self, data): + ... +``` + +The `run()` method is passed a single argument: a dictionary containing all of the variable data passed via the web form. Your script can reference this data during execution. + +Defining variables is optional: You may create a script with only a `run()` method if no user input is needed. + +Returning output from your script is optional. Any raw output generated by the script will be displayed under the "output" tab in the UI. + +## Module Attributes + +### `name` + +You can define `name` within a script module (the Python file which contains one or more scripts) to set the module name. If `name` is not defined, the filename will be used. + +## Script Attributes + +Script attributes are defined under a class named `Meta` within the script. These are optional, but encouraged. + +### `name` + +This is the human-friendly names of your script. If omitted, the class name will be used. + +### `description` + +A human-friendly description of what your script does. + +### `field_order` + +A list of field names indicating the order in which the form fields should appear. This is optional, however on Python 3.5 and earlier the fields will appear in random order. (Declarative ordering is preserved on Python 3.6 and above.) For example: + +``` +field_order = ['var1', 'var2', 'var3'] +``` + +## Reading Data from Files + +The Script class provides two convenience methods for reading data from files: + +* `load_yaml` +* `load_json` + +These two methods will load data in YAML or JSON format, respectively, from files within the local path (i.e. `SCRIPTS_ROOT`). + +## Logging + +The Script object provides a set of convenient functions for recording messages at different severity levels: + +* `log_debug` +* `log_success` +* `log_info` +* `log_warning` +* `log_failure` + +Log messages are returned to the user upon execution of the script. Markdown rendering is supported for log messages. + +## Variable Reference + +### StringVar + +Stores a string of characters (i.e. a line of text). Options include: + +* `min_length` - Minimum number of characters +* `max_length` - Maximum number of characters +* `regex` - A regular expression against which the provided value must match + +Note: `min_length` and `max_length` can be set to the same number to effect a fixed-length field. + +### TextVar + +Arbitrary text of any length. Renders as multi-line text input field. + +### IntegerVar + +Stored a numeric integer. Options include: + +* `min_value:` - Minimum value +* `max_value` - Maximum value + +### BooleanVar + +A true/false flag. This field has no options beyond the defaults. + +### ObjectVar + +A NetBox object. The list of available objects is defined by the queryset parameter. Each instance of this variable is limited to a single object type. + +* `queryset` - A [Django queryset](https://docs.djangoproject.com/en/stable/topics/db/queries/) + +### FileVar + +An uploaded file. Note that uploaded files are present in memory only for the duration of the script's execution: They will not be save for future use. + +### IPNetworkVar + +An IPv4 or IPv6 network with a mask. + +### Default Options + +All variables support the following default options: + +* `label` - The name of the form field +* `description` - A brief description of the field +* `default` - The field's default value +* `required` - Indicates whether the field is mandatory (default: true) + +## Example + +Below is an example script that creates new objects for a planned site. The user is prompted for three variables: + +* The name of the new site +* The device model (a filtered list of defined device types) +* The number of access switches to create + +These variables are presented as a web form to be completed by the user. Once submitted, the script's `run()` method is called to create the appropriate objects. + +``` +from django.utils.text import slugify + +from dcim.constants import * +from dcim.models import Device, DeviceRole, DeviceType, Site +from extras.scripts import * + + +class NewBranchScript(Script): + + class Meta: + name = "New Branch" + description = "Provision a new branch site" + fields = ['site_name', 'switch_count', 'switch_model'] + + site_name = StringVar( + description="Name of the new site" + ) + switch_count = IntegerVar( + description="Number of access switches to create" + ) + switch_model = ObjectVar( + description="Access switch model", + queryset = DeviceType.objects.filter( + manufacturer__name='Cisco', + model__in=['Catalyst 3560X-48T', 'Catalyst 3750X-48T'] + ) + ) + + def run(self, data): + + # Create the new site + site = Site( + name=data['site_name'], + slug=slugify(data['site_name']), + status=SITE_STATUS_PLANNED + ) + site.save() + self.log_success("Created new site: {}".format(site)) + + # Create access switches + switch_role = DeviceRole.objects.get(name='Access Switch') + for i in range(1, data['switch_count'] + 1): + switch = Device( + device_type=data['switch_model'], + name='{}-switch{}'.format(site.slug, i), + site=site, + status=DEVICE_STATUS_PLANNED, + device_role=switch_role + ) + switch.save() + self.log_success("Created new switch: {}".format(switch)) + + # Generate a CSV table of new devices + output = [ + 'name,make,model' + ] + for switch in Device.objects.filter(site=site): + attrs = [ + switch.name, + switch.device_type.manufacturer.name, + switch.device_type.model + ] + output.append(','.join(attrs)) + + return '\n'.join(output) +``` diff --git a/docs/additional-features/reports.md b/docs/additional-features/reports.md index 33c3d95ae..4b8b77840 100644 --- a/docs/additional-features/reports.md +++ b/docs/additional-features/reports.md @@ -43,7 +43,7 @@ class DeviceConnectionsReport(Report): def test_console_connection(self): # Check that every console port for every active device has a connection defined. - for console_port in ConsolePort.objects.select_related('device').filter(device__status=DEVICE_STATUS_ACTIVE): + for console_port in ConsolePort.objects.prefetch_related('device').filter(device__status=DEVICE_STATUS_ACTIVE): if console_port.connected_endpoint is None: self.log_failure( console_port.device, diff --git a/docs/configuration/optional-settings.md b/docs/configuration/optional-settings.md index 4ebb56290..b532c9757 100644 --- a/docs/configuration/optional-settings.md +++ b/docs/configuration/optional-settings.md @@ -277,6 +277,14 @@ The file path to the location where custom reports will be kept. By default, thi --- +## SCRIPTS_ROOT + +Default: $BASE_DIR/netbox/scripts/ + +The file path to the location where custom scripts will be kept. By default, this is the `netbox/scripts/` directory within the base NetBox installation path. + +--- + ## SESSION_FILE_PATH Default: None diff --git a/docs/core-functionality/devices.md b/docs/core-functionality/devices.md index 51486f7c1..d170b374e 100644 --- a/docs/core-functionality/devices.md +++ b/docs/core-functionality/devices.md @@ -95,7 +95,7 @@ Pass-through ports can also be used to model "bump in the wire" devices, such as ### Device Bays -Device bays represent the ability of a device to house child devices. For example, you might install four blade servers into a 2U chassis. The chassis would appear in the rack elevation as a 2U device with four device bays. Each server within it would be defined as a 0U device installed in one of the device bays. Child devices do not appear within rack elevations, but they are included in the "Non-Racked Devices" list within the rack view. +Device bays represent the ability of a device to house child devices. For example, you might install four blade servers into a 2U chassis. The chassis would appear in the rack elevation as a 2U device with four device bays. Each server within it would be defined as a 0U device installed in one of the device bays. Child devices do not appear within rack elevations or the "Non-Racked Devices" list within the rack view. Child devices are first-class Devices in their own right: that is, fully independent managed entities which don't share any control plane with the parent. Just like normal devices, child devices have their own platform (OS), role, tags, and interfaces. You cannot create a LAG between interfaces in different child devices. diff --git a/docs/development/extending-models.md b/docs/development/extending-models.md index 1fde8067b..0070c5545 100644 --- a/docs/development/extending-models.md +++ b/docs/development/extending-models.md @@ -38,7 +38,7 @@ Add the name of the new field to `csv_headers` and included a CSV-friendly repre ### 4. Update relevant querysets -If you're adding a relational field (e.g. `ForeignKey`) and intend to include the data when retreiving a list of objects, be sure to include the field using `select_related()` or `prefetch_related()` as appropriate. This will optimize the view and avoid excessive database lookups. +If you're adding a relational field (e.g. `ForeignKey`) and intend to include the data when retreiving a list of objects, be sure to include the field using `prefetch_related()` as appropriate. This will optimize the view and avoid excessive database lookups. ### 5. Update API serializer diff --git a/netbox/circuits/api/views.py b/netbox/circuits/api/views.py index ad48174e6..dc1133c5e 100644 --- a/netbox/circuits/api/views.py +++ b/netbox/circuits/api/views.py @@ -62,7 +62,7 @@ class CircuitTypeViewSet(ModelViewSet): # class CircuitViewSet(CustomFieldModelViewSet): - queryset = Circuit.objects.select_related('type', 'tenant', 'provider').prefetch_related('tags') + queryset = Circuit.objects.prefetch_related('type', 'tenant', 'provider').prefetch_related('tags') serializer_class = serializers.CircuitSerializer filterset_class = filters.CircuitFilter @@ -72,7 +72,7 @@ class CircuitViewSet(CustomFieldModelViewSet): # class CircuitTerminationViewSet(ModelViewSet): - queryset = CircuitTermination.objects.select_related( + queryset = CircuitTermination.objects.prefetch_related( 'circuit', 'site', 'connected_endpoint__device', 'cable' ) serializer_class = serializers.CircuitTerminationSerializer diff --git a/netbox/circuits/filters.py b/netbox/circuits/filters.py index 4323feafc..33005b830 100644 --- a/netbox/circuits/filters.py +++ b/netbox/circuits/filters.py @@ -1,10 +1,10 @@ import django_filters from django.db.models import Q -from dcim.models import Site +from dcim.models import Region, Site from extras.filters import CustomFieldFilterSet from tenancy.filtersets import TenancyFilterSet -from utilities.filters import NameSlugSearchFilterSet, NumericInFilter, TagFilter +from utilities.filters import NameSlugSearchFilterSet, NumericInFilter, TagFilter, TreeNodeMultipleChoiceFilter from .constants import CIRCUIT_STATUS_CHOICES from .models import Provider, Circuit, CircuitTermination, CircuitType @@ -98,6 +98,17 @@ class CircuitFilter(CustomFieldFilterSet, TenancyFilterSet): to_field_name='slug', label='Site (slug)', ) + region_id = TreeNodeMultipleChoiceFilter( + queryset=Region.objects.all(), + field_name='terminations__site__region__in', + label='Region (ID)', + ) + region = TreeNodeMultipleChoiceFilter( + queryset=Region.objects.all(), + field_name='terminations__site__region__in', + to_field_name='slug', + label='Region (slug)', + ) tag = TagFilter() class Meta: diff --git a/netbox/circuits/models.py b/netbox/circuits/models.py index 0297790b5..bf06959b0 100644 --- a/netbox/circuits/models.py +++ b/netbox/circuits/models.py @@ -270,23 +270,21 @@ class CircuitTermination(CableTermination): def __str__(self): return 'Side {}'.format(self.get_term_side_display()) - def log_change(self, user, request_id, action): - """ - Reference the parent circuit when recording the change. - """ + def to_objectchange(self, action): + # Annotate the parent Circuit try: related_object = self.circuit except Circuit.DoesNotExist: # Parent circuit has been deleted related_object = None - ObjectChange( - user=user, - request_id=request_id, + + return ObjectChange( changed_object=self, - related_object=related_object, + object_repr=str(self), action=action, + related_object=related_object, object_data=serialize_object(self) - ).save() + ) @property def parent(self): @@ -295,6 +293,6 @@ class CircuitTermination(CableTermination): def get_peer_termination(self): peer_side = 'Z' if self.term_side == 'A' else 'A' try: - return CircuitTermination.objects.select_related('site').get(circuit=self.circuit, term_side=peer_side) + return CircuitTermination.objects.prefetch_related('site').get(circuit=self.circuit, term_side=peer_side) except CircuitTermination.DoesNotExist: return None diff --git a/netbox/circuits/signals.py b/netbox/circuits/signals.py index bdfe8c0b6..86db21400 100644 --- a/netbox/circuits/signals.py +++ b/netbox/circuits/signals.py @@ -10,4 +10,8 @@ def update_circuit(instance, **kwargs): """ When a CircuitTermination has been modified, update the last_updated time of its parent Circuit. """ - Circuit.objects.filter(pk=instance.circuit_id).update(last_updated=timezone.now()) + circuits = Circuit.objects.filter(pk=instance.circuit_id) + time = timezone.now() + for circuit in circuits: + circuit.last_updated = time + circuit.save() diff --git a/netbox/circuits/views.py b/netbox/circuits/views.py index 2f3881818..655b714d7 100644 --- a/netbox/circuits/views.py +++ b/netbox/circuits/views.py @@ -35,11 +35,7 @@ class ProviderView(PermissionRequiredMixin, View): def get(self, request, slug): provider = get_object_or_404(Provider, slug=slug) - circuits = Circuit.objects.filter(provider=provider).select_related( - 'type', 'tenant' - ).prefetch_related( - 'terminations__site' - ) + circuits = Circuit.objects.filter(provider=provider).prefetch_related('type', 'tenant', 'terminations__site') show_graphs = Graph.objects.filter(type=GRAPH_TYPE_PROVIDER).exists() return render(request, 'circuits/provider.html', { @@ -134,10 +130,8 @@ class CircuitTypeBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): class CircuitListView(PermissionRequiredMixin, ObjectListView): permission_required = 'circuits.view_circuit' _terminations = CircuitTermination.objects.filter(circuit=OuterRef('pk')) - queryset = Circuit.objects.select_related( - 'provider', 'type', 'tenant' - ).prefetch_related( - 'terminations__site' + queryset = Circuit.objects.prefetch_related( + 'provider', 'type', 'tenant', 'terminations__site' ).annotate( a_side=Subquery(_terminations.filter(term_side='A').values('site__name')[:1]), z_side=Subquery(_terminations.filter(term_side='Z').values('site__name')[:1]), @@ -153,13 +147,13 @@ class CircuitView(PermissionRequiredMixin, View): def get(self, request, pk): - circuit = get_object_or_404(Circuit.objects.select_related('provider', 'type', 'tenant__group'), pk=pk) - termination_a = CircuitTermination.objects.select_related( + circuit = get_object_or_404(Circuit.objects.prefetch_related('provider', 'type', 'tenant__group'), pk=pk) + termination_a = CircuitTermination.objects.prefetch_related( 'site__region', 'connected_endpoint__device' ).filter( circuit=circuit, term_side=TERM_SIDE_A ).first() - termination_z = CircuitTermination.objects.select_related( + termination_z = CircuitTermination.objects.prefetch_related( 'site__region', 'connected_endpoint__device' ).filter( circuit=circuit, term_side=TERM_SIDE_Z @@ -199,7 +193,7 @@ class CircuitBulkImportView(PermissionRequiredMixin, BulkImportView): class CircuitBulkEditView(PermissionRequiredMixin, BulkEditView): permission_required = 'circuits.change_circuit' - queryset = Circuit.objects.select_related('provider', 'type', 'tenant').prefetch_related('terminations__site') + queryset = Circuit.objects.prefetch_related('provider', 'type', 'tenant').prefetch_related('terminations__site') filter = filters.CircuitFilter table = tables.CircuitTable form = forms.CircuitBulkEditForm @@ -208,7 +202,7 @@ class CircuitBulkEditView(PermissionRequiredMixin, BulkEditView): class CircuitBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'circuits.delete_circuit' - queryset = Circuit.objects.select_related('provider', 'type', 'tenant').prefetch_related('terminations__site') + queryset = Circuit.objects.prefetch_related('provider', 'type', 'tenant').prefetch_related('terminations__site') filter = filters.CircuitFilter table = tables.CircuitTable default_return_url = 'circuits:circuit_list' diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index 05ca76479..4ddee7337 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -109,10 +109,8 @@ class RegionViewSet(ModelViewSet): # class SiteViewSet(CustomFieldModelViewSet): - queryset = Site.objects.select_related( - 'region', 'tenant' - ).prefetch_related( - 'tags' + queryset = Site.objects.prefetch_related( + 'region', 'tenant', 'tags' ).annotate( device_count=get_subquery(Device, 'site'), rack_count=get_subquery(Rack, 'site'), @@ -140,7 +138,7 @@ class SiteViewSet(CustomFieldModelViewSet): # class RackGroupViewSet(ModelViewSet): - queryset = RackGroup.objects.select_related('site').annotate( + queryset = RackGroup.objects.prefetch_related('site').annotate( rack_count=Count('racks') ) serializer_class = serializers.RackGroupSerializer @@ -164,10 +162,8 @@ class RackRoleViewSet(ModelViewSet): # class RackViewSet(CustomFieldModelViewSet): - queryset = Rack.objects.select_related( - 'site', 'group__site', 'role', 'tenant' - ).prefetch_related( - 'tags' + queryset = Rack.objects.prefetch_related( + 'site', 'group__site', 'role', 'tenant', 'tags' ).annotate( device_count=get_subquery(Device, 'rack'), powerfeed_count=get_subquery(PowerFeed, 'rack') @@ -206,7 +202,7 @@ class RackViewSet(CustomFieldModelViewSet): # class RackReservationViewSet(ModelViewSet): - queryset = RackReservation.objects.select_related('rack', 'user', 'tenant') + queryset = RackReservation.objects.prefetch_related('rack', 'user', 'tenant') serializer_class = serializers.RackReservationSerializer filterset_class = filters.RackReservationFilter @@ -234,7 +230,7 @@ class ManufacturerViewSet(ModelViewSet): # class DeviceTypeViewSet(CustomFieldModelViewSet): - queryset = DeviceType.objects.select_related('manufacturer').prefetch_related('tags').annotate( + queryset = DeviceType.objects.prefetch_related('manufacturer').prefetch_related('tags').annotate( device_count=Count('instances') ) serializer_class = serializers.DeviceTypeSerializer @@ -246,49 +242,49 @@ class DeviceTypeViewSet(CustomFieldModelViewSet): # class ConsolePortTemplateViewSet(ModelViewSet): - queryset = ConsolePortTemplate.objects.select_related('device_type__manufacturer') + queryset = ConsolePortTemplate.objects.prefetch_related('device_type__manufacturer') serializer_class = serializers.ConsolePortTemplateSerializer filterset_class = filters.ConsolePortTemplateFilter class ConsoleServerPortTemplateViewSet(ModelViewSet): - queryset = ConsoleServerPortTemplate.objects.select_related('device_type__manufacturer') + queryset = ConsoleServerPortTemplate.objects.prefetch_related('device_type__manufacturer') serializer_class = serializers.ConsoleServerPortTemplateSerializer filterset_class = filters.ConsoleServerPortTemplateFilter class PowerPortTemplateViewSet(ModelViewSet): - queryset = PowerPortTemplate.objects.select_related('device_type__manufacturer') + queryset = PowerPortTemplate.objects.prefetch_related('device_type__manufacturer') serializer_class = serializers.PowerPortTemplateSerializer filterset_class = filters.PowerPortTemplateFilter class PowerOutletTemplateViewSet(ModelViewSet): - queryset = PowerOutletTemplate.objects.select_related('device_type__manufacturer') + queryset = PowerOutletTemplate.objects.prefetch_related('device_type__manufacturer') serializer_class = serializers.PowerOutletTemplateSerializer filterset_class = filters.PowerOutletTemplateFilter class InterfaceTemplateViewSet(ModelViewSet): - queryset = InterfaceTemplate.objects.select_related('device_type__manufacturer') + queryset = InterfaceTemplate.objects.prefetch_related('device_type__manufacturer') serializer_class = serializers.InterfaceTemplateSerializer filterset_class = filters.InterfaceTemplateFilter class FrontPortTemplateViewSet(ModelViewSet): - queryset = FrontPortTemplate.objects.select_related('device_type__manufacturer') + queryset = FrontPortTemplate.objects.prefetch_related('device_type__manufacturer') serializer_class = serializers.FrontPortTemplateSerializer filterset_class = filters.FrontPortTemplateFilter class RearPortTemplateViewSet(ModelViewSet): - queryset = RearPortTemplate.objects.select_related('device_type__manufacturer') + queryset = RearPortTemplate.objects.prefetch_related('device_type__manufacturer') serializer_class = serializers.RearPortTemplateSerializer filterset_class = filters.RearPortTemplateFilter class DeviceBayTemplateViewSet(ModelViewSet): - queryset = DeviceBayTemplate.objects.select_related('device_type__manufacturer') + queryset = DeviceBayTemplate.objects.prefetch_related('device_type__manufacturer') serializer_class = serializers.DeviceBayTemplateSerializer filterset_class = filters.DeviceBayTemplateFilter @@ -324,11 +320,9 @@ class PlatformViewSet(ModelViewSet): # class DeviceViewSet(CustomFieldModelViewSet): - queryset = Device.objects.select_related( + queryset = Device.objects.prefetch_related( 'device_type__manufacturer', 'device_role', 'tenant', 'platform', 'site', 'rack', 'parent_bay', - 'virtual_chassis__master', - ).prefetch_related( - 'primary_ip4__nat_outside', 'primary_ip6__nat_outside', 'tags', + 'virtual_chassis__master', 'primary_ip4__nat_outside', 'primary_ip6__nat_outside', 'tags', ) filterset_class = filters.DeviceFilter @@ -429,52 +423,36 @@ class DeviceViewSet(CustomFieldModelViewSet): # class ConsolePortViewSet(CableTraceMixin, ModelViewSet): - queryset = ConsolePort.objects.select_related( - 'device', 'connected_endpoint__device', 'cable' - ).prefetch_related( - 'tags' - ) + queryset = ConsolePort.objects.prefetch_related('device', 'connected_endpoint__device', 'cable', 'tags') serializer_class = serializers.ConsolePortSerializer filterset_class = filters.ConsolePortFilter class ConsoleServerPortViewSet(CableTraceMixin, ModelViewSet): - queryset = ConsoleServerPort.objects.select_related( - 'device', 'connected_endpoint__device', 'cable' - ).prefetch_related( - 'tags' - ) + queryset = ConsoleServerPort.objects.prefetch_related('device', 'connected_endpoint__device', 'cable', 'tags') serializer_class = serializers.ConsoleServerPortSerializer filterset_class = filters.ConsoleServerPortFilter class PowerPortViewSet(CableTraceMixin, ModelViewSet): - queryset = PowerPort.objects.select_related( - 'device', '_connected_poweroutlet__device', '_connected_powerfeed', 'cable' - ).prefetch_related( - 'tags' + queryset = PowerPort.objects.prefetch_related( + 'device', '_connected_poweroutlet__device', '_connected_powerfeed', 'cable', 'tags' ) serializer_class = serializers.PowerPortSerializer filterset_class = filters.PowerPortFilter class PowerOutletViewSet(CableTraceMixin, ModelViewSet): - queryset = PowerOutlet.objects.select_related( - 'device', 'connected_endpoint__device', 'cable' - ).prefetch_related( - 'tags' - ) + queryset = PowerOutlet.objects.prefetch_related('device', 'connected_endpoint__device', 'cable', 'tags') serializer_class = serializers.PowerOutletSerializer filterset_class = filters.PowerOutletFilter class InterfaceViewSet(CableTraceMixin, ModelViewSet): - queryset = Interface.objects.filter( + queryset = Interface.objects.prefetch_related( + 'device', '_connected_interface', '_connected_circuittermination', 'cable', 'ip_addresses', 'tags' + ).filter( device__isnull=False - ).select_related( - 'device', '_connected_interface', '_connected_circuittermination', 'cable' - ).prefetch_related( - 'ip_addresses', 'tags' ) serializer_class = serializers.InterfaceSerializer filterset_class = filters.InterfaceFilter @@ -491,33 +469,25 @@ class InterfaceViewSet(CableTraceMixin, ModelViewSet): class FrontPortViewSet(ModelViewSet): - queryset = FrontPort.objects.select_related( - 'device__device_type__manufacturer', 'rear_port', 'cable' - ).prefetch_related( - 'tags' - ) + queryset = FrontPort.objects.prefetch_related('device__device_type__manufacturer', 'rear_port', 'cable', 'tags') serializer_class = serializers.FrontPortSerializer filterset_class = filters.FrontPortFilter class RearPortViewSet(ModelViewSet): - queryset = RearPort.objects.select_related( - 'device__device_type__manufacturer', 'cable' - ).prefetch_related( - 'tags' - ) + queryset = RearPort.objects.prefetch_related('device__device_type__manufacturer', 'cable', 'tags') serializer_class = serializers.RearPortSerializer filterset_class = filters.RearPortFilter class DeviceBayViewSet(ModelViewSet): - queryset = DeviceBay.objects.select_related('installed_device').prefetch_related('tags') + queryset = DeviceBay.objects.prefetch_related('installed_device').prefetch_related('tags') serializer_class = serializers.DeviceBaySerializer filterset_class = filters.DeviceBayFilter class InventoryItemViewSet(ModelViewSet): - queryset = InventoryItem.objects.select_related('device', 'manufacturer').prefetch_related('tags') + queryset = InventoryItem.objects.prefetch_related('device', 'manufacturer').prefetch_related('tags') serializer_class = serializers.InventoryItemSerializer filterset_class = filters.InventoryItemFilter @@ -527,7 +497,7 @@ class InventoryItemViewSet(ModelViewSet): # class ConsoleConnectionViewSet(ListModelMixin, GenericViewSet): - queryset = ConsolePort.objects.select_related( + queryset = ConsolePort.objects.prefetch_related( 'device', 'connected_endpoint__device' ).filter( connected_endpoint__isnull=False @@ -537,7 +507,7 @@ class ConsoleConnectionViewSet(ListModelMixin, GenericViewSet): class PowerConnectionViewSet(ListModelMixin, GenericViewSet): - queryset = PowerPort.objects.select_related( + queryset = PowerPort.objects.prefetch_related( 'device', 'connected_endpoint__device' ).filter( _connected_poweroutlet__isnull=False @@ -547,7 +517,7 @@ class PowerConnectionViewSet(ListModelMixin, GenericViewSet): class InterfaceConnectionViewSet(ListModelMixin, GenericViewSet): - queryset = Interface.objects.select_related( + queryset = Interface.objects.prefetch_related( 'device', '_connected_interface__device' ).filter( # Avoid duplicate connections by only selecting the lower PK in a connected pair @@ -579,6 +549,7 @@ class VirtualChassisViewSet(ModelViewSet): member_count=Count('members') ) serializer_class = serializers.VirtualChassisSerializer + filterset_class = filters.VirtualChassisFilter # @@ -586,7 +557,7 @@ class VirtualChassisViewSet(ModelViewSet): # class PowerPanelViewSet(ModelViewSet): - queryset = PowerPanel.objects.select_related( + queryset = PowerPanel.objects.prefetch_related( 'site', 'rack_group' ).annotate( powerfeed_count=Count('powerfeeds') @@ -600,11 +571,7 @@ class PowerPanelViewSet(ModelViewSet): # class PowerFeedViewSet(CustomFieldModelViewSet): - queryset = PowerFeed.objects.select_related( - 'power_panel', 'rack' - ).prefetch_related( - 'tags' - ) + queryset = PowerFeed.objects.prefetch_related('power_panel', 'rack', 'tags') serializer_class = serializers.PowerFeedSerializer filterset_class = filters.PowerFeedFilter diff --git a/netbox/dcim/constants.py b/netbox/dcim/constants.py index 8ffc249bd..58df29914 100644 --- a/netbox/dcim/constants.py +++ b/netbox/dcim/constants.py @@ -280,6 +280,7 @@ IFACE_MODE_CHOICES = [ # Pass-through port types PORT_TYPE_8P8C = 1000 PORT_TYPE_110_PUNCH = 1100 +PORT_TYPE_BNC = 1200 PORT_TYPE_ST = 2000 PORT_TYPE_SC = 2100 PORT_TYPE_SC_APC = 2110 @@ -296,6 +297,7 @@ PORT_TYPE_CHOICES = [ [ [PORT_TYPE_8P8C, '8P8C'], [PORT_TYPE_110_PUNCH, '110 Punch'], + [PORT_TYPE_BNC, 'BNC'], ], ], [ @@ -376,6 +378,7 @@ CABLE_TYPE_CAT6A = 1610 CABLE_TYPE_CAT7 = 1700 CABLE_TYPE_DAC_ACTIVE = 1800 CABLE_TYPE_DAC_PASSIVE = 1810 +CABLE_TYPE_COAXIAL = 1900 CABLE_TYPE_MMF = 3000 CABLE_TYPE_MMF_OM1 = 3010 CABLE_TYPE_MMF_OM2 = 3020 @@ -397,6 +400,7 @@ CABLE_TYPE_CHOICES = ( (CABLE_TYPE_CAT7, 'CAT7'), (CABLE_TYPE_DAC_ACTIVE, 'Direct Attach Copper (Active)'), (CABLE_TYPE_DAC_PASSIVE, 'Direct Attach Copper (Passive)'), + (CABLE_TYPE_COAXIAL, 'Coaxial'), ), ), ( diff --git a/netbox/dcim/filters.py b/netbox/dcim/filters.py index 6312fd0d5..0499dcd59 100644 --- a/netbox/dcim/filters.py +++ b/netbox/dcim/filters.py @@ -2,14 +2,15 @@ import django_filters from django.contrib.auth.models import User from django.core.exceptions import ObjectDoesNotExist from django.db.models import Q -from netaddr import EUI -from netaddr.core import AddrFormatError -from extras.filters import CustomFieldFilterSet +from extras.filters import CustomFieldFilterSet, LocalConfigContextFilter from tenancy.filtersets import TenancyFilterSet from tenancy.models import Tenant from utilities.constants import COLOR_CHOICES -from utilities.filters import NameSlugSearchFilterSet, NumericInFilter, TagFilter, TreeNodeMultipleChoiceFilter +from utilities.filters import ( + MultiValueMACAddressFilter, MultiValueNumberFilter, NameSlugSearchFilterSet, NumericInFilter, TagFilter, + TreeNodeMultipleChoiceFilter, +) from virtualization.models import Cluster from .constants import * from .models import ( @@ -159,12 +160,15 @@ class RackFilter(TenancyFilterSet, CustomFieldFilterSet): to_field_name='slug', label='Role (slug)', ) + serial = django_filters.CharFilter( + lookup_expr='iexact' + ) tag = TagFilter() class Meta: model = Rack fields = [ - 'id', 'name', 'facility_id', 'serial', 'asset_tag', 'type', 'width', 'u_height', 'desc_units', + 'id', 'name', 'facility_id', 'asset_tag', 'type', 'width', 'u_height', 'desc_units', 'outer_width', 'outer_depth', 'outer_unit', ] @@ -420,7 +424,7 @@ class PlatformFilter(NameSlugSearchFilterSet): fields = ['id', 'name', 'slug', 'napalm_driver'] -class DeviceFilter(TenancyFilterSet, CustomFieldFilterSet): +class DeviceFilter(LocalConfigContextFilter, TenancyFilterSet, CustomFieldFilterSet): id__in = NumericInFilter( field_name='id', lookup_expr='in' @@ -514,10 +518,13 @@ class DeviceFilter(TenancyFilterSet, CustomFieldFilterSet): field_name='device_type__is_full_depth', label='Is full depth', ) - mac_address = django_filters.CharFilter( - method='_mac_address', + mac_address = MultiValueMACAddressFilter( + field_name='interfaces__mac_address', label='MAC address', ) + serial = django_filters.CharFilter( + lookup_expr='iexact' + ) has_primary_ip = django_filters.BooleanFilter( method='_has_primary_ip', label='Has a primary IP', @@ -559,7 +566,7 @@ class DeviceFilter(TenancyFilterSet, CustomFieldFilterSet): class Meta: model = Device - fields = ['id', 'name', 'serial', 'asset_tag', 'face', 'position', 'vc_position', 'vc_priority'] + fields = ['id', 'name', 'asset_tag', 'face', 'position', 'vc_position', 'vc_priority'] def search(self, queryset, name, value): if not value.strip(): @@ -572,16 +579,6 @@ class DeviceFilter(TenancyFilterSet, CustomFieldFilterSet): Q(comments__icontains=value) ).distinct() - def _mac_address(self, queryset, name, value): - value = value.strip() - if not value: - return queryset - try: - mac = EUI(value.strip()) - return queryset.filter(interfaces__mac_address=mac).distinct() - except AddrFormatError: - return queryset.none() - def _has_primary_ip(self, queryset, name, value): if value: return queryset.filter( @@ -624,7 +621,7 @@ class DeviceComponentFilterSet(django_filters.FilterSet): method='search', label='Search', ) - device_id = django_filters.ModelChoiceFilter( + device_id = django_filters.ModelMultipleChoiceFilter( queryset=Device.objects.all(), label='Device (ID)', ) @@ -705,8 +702,8 @@ class InterfaceFilter(django_filters.FilterSet): field_name='name', label='Device', ) - device_id = django_filters.NumberFilter( - method='filter_device', + device_id = MultiValueNumberFilter( + method='filter_device_id', field_name='pk', label='Device (ID)', ) @@ -724,10 +721,7 @@ class InterfaceFilter(django_filters.FilterSet): queryset=Interface.objects.all(), label='LAG interface (ID)', ) - mac_address = django_filters.CharFilter( - method='_mac_address', - label='MAC address', - ) + mac_address = MultiValueMACAddressFilter() tag = TagFilter() vlan_id = django_filters.CharFilter( method='filter_vlan_id', @@ -762,6 +756,17 @@ class InterfaceFilter(django_filters.FilterSet): except Device.DoesNotExist: return queryset.none() + def filter_device_id(self, queryset, name, id_list): + # Include interfaces belonging to peer virtual chassis members + vc_interface_ids = [] + try: + devices = Device.objects.filter(pk__in=id_list) + for device in devices: + vc_interface_ids += device.vc_interfaces.values_list('id', flat=True) + return queryset.filter(pk__in=vc_interface_ids) + except Device.DoesNotExist: + return queryset.none() + def filter_vlan_id(self, queryset, name, value): value = value.strip() if not value: @@ -788,16 +793,6 @@ class InterfaceFilter(django_filters.FilterSet): 'wireless': queryset.filter(type__in=WIRELESS_IFACE_TYPES), }.get(value, queryset.none()) - def _mac_address(self, queryset, name, value): - value = value.strip() - if not value: - return queryset - try: - mac = EUI(value.strip()) - return queryset.filter(mac_address=mac) - except AddrFormatError: - return queryset.none() - class FrontPortFilter(DeviceComponentFilterSet): cabled = django_filters.BooleanFilter( @@ -858,10 +853,13 @@ class InventoryItemFilter(DeviceComponentFilterSet): to_field_name='slug', label='Manufacturer (slug)', ) + serial = django_filters.CharFilter( + lookup_expr='iexact' + ) class Meta: model = InventoryItem - fields = ['id', 'name', 'part_id', 'serial', 'asset_tag', 'discovered'] + fields = ['id', 'name', 'part_id', 'asset_tag', 'discovered'] def search(self, queryset, name, value): if not value.strip(): diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index 9b9debb0c..774b69741 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -7,11 +7,15 @@ from django.contrib.postgres.forms.array import SimpleArrayField from django.core.exceptions import ObjectDoesNotExist from django.db.models import Q from mptt.forms import TreeNodeChoiceField +from netaddr import EUI +from netaddr.core import AddrFormatError from taggit.forms import TagField from timezone_field import TimeZoneFormField from circuits.models import Circuit, Provider -from extras.forms import AddRemoveTagsForm, CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm +from extras.forms import ( + AddRemoveTagsForm, CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm, LocalConfigContextFilterForm +) from ipam.models import IPAddress, VLAN, VLANGroup from tenancy.forms import TenancyForm from tenancy.forms import TenancyFilterForm @@ -52,6 +56,25 @@ def get_device_by_name_or_pk(name): return device +class InterfaceCommonForm: + def clean(self): + + super().clean() + + # Validate VLAN assignments + tagged_vlans = self.cleaned_data['tagged_vlans'] + + # Untagged interfaces cannot be assigned tagged VLANs + if self.cleaned_data['mode'] == IFACE_MODE_ACCESS and tagged_vlans: + raise forms.ValidationError({ + 'mode': "An access interface cannot have tagged VLANs assigned." + }) + + # Remove all tagged VLAN assignments from "tagged all" interfaces + elif self.cleaned_data['mode'] == IFACE_MODE_TAGGED_ALL: + self.cleaned_data['tagged_vlans'] = [] + + class BulkRenameForm(forms.Form): """ An extendable form to be used for renaming device components in bulk. @@ -76,6 +99,28 @@ class BulkRenameForm(forms.Form): }) +# +# Fields +# + +class MACAddressField(forms.Field): + widget = forms.CharField + default_error_messages = { + 'invalid': 'MAC address must be in EUI-48 format', + } + + def to_python(self, value): + value = super().to_python(value) + + # Validate MAC address format + try: + value = EUI(value.strip()) + except AddrFormatError: + raise forms.ValidationError(self.error_messages['invalid'], code='invalid') + + return value + + # # Regions # @@ -608,7 +653,7 @@ class RackFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm): ) group_id = ChainedModelChoiceField( label='Rack group', - queryset=RackGroup.objects.select_related('site'), + queryset=RackGroup.objects.prefetch_related('site'), chains=( ('site', 'site'), ), @@ -721,7 +766,7 @@ class RackReservationFilterForm(BootstrapMixin, TenancyFilterForm): ) ) group_id = FilterChoiceField( - queryset=RackGroup.objects.select_related('site'), + queryset=RackGroup.objects.prefetch_related('site'), label='Rack group', null_label='-- None --', widget=APISelectMultiple( @@ -954,6 +999,16 @@ class PowerPortTemplateCreateForm(ComponentForm): name_pattern = ExpandableNameField( label='Name' ) + maximum_draw = forms.IntegerField( + min_value=1, + required=False, + help_text="Maximum current draw (watts)" + ) + allocated_draw = forms.IntegerField( + min_value=1, + required=False, + help_text="Allocated current draw (watts)" + ) class PowerOutletTemplateForm(BootstrapMixin, forms.ModelForm): @@ -1246,7 +1301,7 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldForm): required=False, widget=APISelect( api_url='/api/dcim/racks/', - display_field='display_name', + display_field='display_name' ) ) position = forms.TypedChoiceField( @@ -1359,14 +1414,14 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldForm): interface_ids = self.instance.vc_interfaces.values('pk') # Collect interface IPs - interface_ips = IPAddress.objects.select_related('interface').filter( + interface_ips = IPAddress.objects.prefetch_related('interface').filter( family=family, interface_id__in=interface_ids ) if interface_ips: ip_list = [(ip.id, '{} ({})'.format(ip.address, ip.interface)) for ip in interface_ips] ip_choices.append(('Interface IPs', ip_list)) # Collect NAT IPs - nat_ips = IPAddress.objects.select_related('nat_inside').filter( + nat_ips = IPAddress.objects.prefetch_related('nat_inside').filter( family=family, nat_inside__interface__in=interface_ids ) if nat_ips: @@ -1643,7 +1698,7 @@ class DeviceBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditF ] -class DeviceFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm): +class DeviceFilterForm(BootstrapMixin, LocalConfigContextFilterForm, TenancyFilterForm, CustomFieldFilterForm): model = Device field_order = [ 'q', 'region', 'site', 'rack_group_id', 'rack_id', 'status', 'role', 'tenant_group', 'tenant', @@ -1678,7 +1733,7 @@ class DeviceFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm) ) ) rack_group_id = FilterChoiceField( - queryset=RackGroup.objects.select_related( + queryset=RackGroup.objects.prefetch_related( 'site' ), label='Rack group', @@ -1717,7 +1772,7 @@ class DeviceFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm) ) ) device_type_id = FilterChoiceField( - queryset=DeviceType.objects.select_related( + queryset=DeviceType.objects.prefetch_related( 'manufacturer' ), label='Model', @@ -2076,7 +2131,26 @@ class PowerOutletBulkDisconnectForm(ConfirmationForm): # Interfaces # -class InterfaceForm(BootstrapMixin, forms.ModelForm): +class InterfaceForm(InterfaceCommonForm, BootstrapMixin, forms.ModelForm): + untagged_vlan = forms.ModelChoiceField( + queryset=VLAN.objects.all(), + required=False, + widget=APISelect( + api_url="/api/ipam/vlans/", + display_field='display_name', + full=True + ) + ) + tagged_vlans = forms.ModelMultipleChoiceField( + queryset=VLAN.objects.all(), + required=False, + widget=APISelectMultiple( + api_url="/api/ipam/vlans/", + display_field='display_name', + full=True + ) + ) + tags = TagField( required=False ) @@ -2115,112 +2189,8 @@ class InterfaceForm(BootstrapMixin, forms.ModelForm): device__in=[self.instance.device, self.instance.device.get_vc_master()], type=IFACE_TYPE_LAG ) - def clean(self): - super().clean() - - # Validate VLAN assignments - tagged_vlans = self.cleaned_data['tagged_vlans'] - - # Untagged interfaces cannot be assigned tagged VLANs - if self.cleaned_data['mode'] == IFACE_MODE_ACCESS and tagged_vlans: - raise forms.ValidationError({ - 'mode': "An access interface cannot have tagged VLANs assigned." - }) - - # Remove all tagged VLAN assignments from "tagged all" interfaces - elif self.cleaned_data['mode'] == IFACE_MODE_TAGGED_ALL: - self.cleaned_data['tagged_vlans'] = [] - - -class InterfaceAssignVLANsForm(BootstrapMixin, forms.ModelForm): - vlans = forms.MultipleChoiceField( - choices=[], - label='VLANs', - widget=StaticSelect2Multiple( - attrs={ - 'size': 20, - } - ) - ) - tagged = forms.BooleanField( - required=False, - initial=True - ) - - class Meta: - model = Interface - fields = [] - - def __init__(self, *args, **kwargs): - - super().__init__(*args, **kwargs) - - if self.instance.mode == IFACE_MODE_ACCESS: - self.initial['tagged'] = False - - # Find all VLANs already assigned to the interface for exclusion from the list - assigned_vlans = [v.pk for v in self.instance.tagged_vlans.all()] - if self.instance.untagged_vlan is not None: - assigned_vlans.append(self.instance.untagged_vlan.pk) - - # Compile VLAN choices - vlan_choices = [] - - # Add non-grouped global VLANs - global_vlans = VLAN.objects.filter(site=None, group=None).exclude(pk__in=assigned_vlans) - vlan_choices.append( - ('Global', [(vlan.pk, vlan) for vlan in global_vlans]) - ) - - # Add grouped global VLANs - for group in VLANGroup.objects.filter(site=None): - global_group_vlans = VLAN.objects.filter(group=group).exclude(pk__in=assigned_vlans) - vlan_choices.append( - (group.name, [(vlan.pk, vlan) for vlan in global_group_vlans]) - ) - - site = getattr(self.instance.parent, 'site', None) - if site is not None: - - # Add non-grouped site VLANs - site_vlans = VLAN.objects.filter(site=site, group=None).exclude(pk__in=assigned_vlans) - vlan_choices.append((site.name, [(vlan.pk, vlan) for vlan in site_vlans])) - - # Add grouped site VLANs - for group in VLANGroup.objects.filter(site=site): - site_group_vlans = VLAN.objects.filter(group=group).exclude(pk__in=assigned_vlans) - vlan_choices.append(( - '{} / {}'.format(group.site.name, group.name), - [(vlan.pk, vlan) for vlan in site_group_vlans] - )) - - self.fields['vlans'].choices = vlan_choices - - def clean(self): - - super().clean() - - # Only untagged VLANs permitted on an access interface - if self.instance.mode == IFACE_MODE_ACCESS and len(self.cleaned_data['vlans']) > 1: - raise forms.ValidationError("Only one VLAN may be assigned to an access interface.") - - # 'tagged' is required if more than one VLAN is selected - if not self.cleaned_data['tagged'] and len(self.cleaned_data['vlans']) > 1: - raise forms.ValidationError("Only one untagged VLAN may be selected.") - - def save(self, *args, **kwargs): - - if self.cleaned_data['tagged']: - for vlan in self.cleaned_data['vlans']: - self.instance.tagged_vlans.add(vlan) - else: - self.instance.untagged_vlan_id = self.cleaned_data['vlans'][0] - - return super().save(*args, **kwargs) - - -class InterfaceCreateForm(ComponentForm, forms.Form): +class InterfaceCreateForm(InterfaceCommonForm, ComponentForm, forms.Form): name_pattern = ExpandableNameField( label='Name' ) @@ -2264,6 +2234,24 @@ class InterfaceCreateForm(ComponentForm, forms.Form): tags = TagField( required=False ) + untagged_vlan = forms.ModelChoiceField( + queryset=VLAN.objects.all(), + required=False, + widget=APISelect( + api_url="/api/ipam/vlans/", + display_field='display_name', + full=True + ) + ) + tagged_vlans = forms.ModelMultipleChoiceField( + queryset=VLAN.objects.all(), + required=False, + widget=APISelectMultiple( + api_url="/api/ipam/vlans/", + display_field='display_name', + full=True + ) + ) def __init__(self, *args, **kwargs): @@ -2282,7 +2270,7 @@ class InterfaceCreateForm(ComponentForm, forms.Form): self.fields['lag'].queryset = Interface.objects.none() -class InterfaceBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm): +class InterfaceBulkEditForm(InterfaceCommonForm, BootstrapMixin, AddRemoveTagsForm, BulkEditForm): pk = forms.ModelMultipleChoiceField( queryset=Interface.objects.all(), widget=forms.MultipleHiddenInput() @@ -2326,10 +2314,28 @@ class InterfaceBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm): required=False, widget=StaticSelect2() ) + untagged_vlan = forms.ModelChoiceField( + queryset=VLAN.objects.all(), + required=False, + widget=APISelect( + api_url="/api/ipam/vlans/", + display_field='display_name', + full=True + ) + ) + tagged_vlans = forms.ModelMultipleChoiceField( + queryset=VLAN.objects.all(), + required=False, + widget=APISelectMultiple( + api_url="/api/ipam/vlans/", + display_field='display_name', + full=True + ) + ) class Meta: nullable_fields = [ - 'lag', 'mac_address', 'mtu', 'description', 'mode', + 'lag', 'mac_address', 'mtu', 'description', 'mode', 'untagged_vlan', 'tagged_vlans' ] def __init__(self, *args, **kwargs): @@ -3616,7 +3622,7 @@ class PowerFeedBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEd queryset=PowerPanel.objects.all(), required=False, widget=APISelect( - api_url="/api/dcim/sites", + api_url="/api/dcim/power-panels/", filter_for={ 'rackgroup': 'site_id', } diff --git a/netbox/dcim/models.py b/netbox/dcim/models.py index 65f4474ba..29384abcd 100644 --- a/netbox/dcim/models.py +++ b/netbox/dcim/models.py @@ -31,18 +31,20 @@ class ComponentTemplateModel(models.Model): class Meta: abstract = True - def log_change(self, user, request_id, action): + def instantiate(self, device): """ - Log an ObjectChange including the parent DeviceType. + Instantiate a new component on the specified Device. """ - ObjectChange( - user=user, - request_id=request_id, + raise NotImplementedError() + + def to_objectchange(self, action): + return ObjectChange( changed_object=self, - related_object=self.device_type, + object_repr=str(self), action=action, + related_object=self.device_type, object_data=serialize_object(self) - ).save() + ) class ComponentModel(models.Model): @@ -54,23 +56,21 @@ class ComponentModel(models.Model): class Meta: abstract = True - def log_change(self, user, request_id, action): - """ - Log an ObjectChange including the parent Device/VM. - """ + def to_objectchange(self, action): + # Annotate the parent Device/VM try: parent = getattr(self, 'device', None) or getattr(self, 'virtual_machine', None) except ObjectDoesNotExist: # The parent device/VM has already been deleted parent = None - ObjectChange( - user=user, - request_id=request_id, + + return ObjectChange( changed_object=self, - related_object=parent, + object_repr=str(self), action=action, + related_object=parent, object_data=serialize_object(self) - ).save() + ) @property def parent(self): @@ -601,7 +601,10 @@ class Rack(ChangeLoggedModel, CustomFieldModel): # Update racked devices if the assigned Site has been changed. if _site_id is not None and self.site_id != _site_id: - Device.objects.filter(rack=self).update(site_id=self.site.pk) + devices = Device.objects.filter(rack=self) + for device in devices: + device.site = self.site + device.save() def to_csv(self): return ( @@ -658,7 +661,7 @@ class Rack(ChangeLoggedModel, CustomFieldModel): # Add devices to rack units list if self.pk: - for device in Device.objects.select_related('device_type__manufacturer', 'device_role')\ + for device in Device.objects.prefetch_related('device_type__manufacturer', 'device_role')\ .annotate(devicebay_count=Count('device_bays'))\ .exclude(pk=exclude)\ .filter(rack=self, position__gt=0)\ @@ -691,7 +694,7 @@ class Rack(ChangeLoggedModel, CustomFieldModel): """ # Gather all devices which consume U space within the rack - devices = self.devices.select_related('device_type').filter(position__gte=1).exclude(pk__in=exclude) + devices = self.devices.prefetch_related('device_type').filter(position__gte=1).exclude(pk__in=exclude) # Initialize the rack unit skeleton units = list(range(1, self.u_height + 1)) @@ -1010,6 +1013,12 @@ class ConsolePortTemplate(ComponentTemplateModel): def __str__(self): return self.name + def instantiate(self, device): + return ConsolePort( + device=device, + name=self.name + ) + class ConsoleServerPortTemplate(ComponentTemplateModel): """ @@ -1033,6 +1042,12 @@ class ConsoleServerPortTemplate(ComponentTemplateModel): def __str__(self): return self.name + def instantiate(self, device): + return ConsoleServerPort( + device=device, + name=self.name + ) + class PowerPortTemplate(ComponentTemplateModel): """ @@ -1068,6 +1083,14 @@ class PowerPortTemplate(ComponentTemplateModel): def __str__(self): return self.name + def instantiate(self, device): + return PowerPort( + device=device, + name=self.name, + maximum_draw=self.maximum_draw, + allocated_draw=self.allocated_draw + ) + class PowerOutletTemplate(ComponentTemplateModel): """ @@ -1112,6 +1135,18 @@ class PowerOutletTemplate(ComponentTemplateModel): "Parent power port ({}) must belong to the same device type".format(self.power_port) ) + def instantiate(self, device): + if self.power_port: + power_port = PowerPort.objects.get(device=device, name=self.power_port.name) + else: + power_port = None + return PowerOutlet( + device=device, + name=self.name, + power_port=power_port, + feed_leg=self.feed_leg + ) + class InterfaceTemplate(ComponentTemplateModel): """ @@ -1159,6 +1194,14 @@ class InterfaceTemplate(ComponentTemplateModel): """ self.type = value + def instantiate(self, device): + return Interface( + device=device, + name=self.name, + type=self.type, + mgmt_only=self.mgmt_only + ) + class FrontPortTemplate(ComponentTemplateModel): """ @@ -1213,6 +1256,19 @@ class FrontPortTemplate(ComponentTemplateModel): ) ) + def instantiate(self, device): + if self.rear_port: + rear_port = RearPort.objects.get(device=device, name=self.rear_port.name) + else: + rear_port = None + return FrontPort( + device=device, + name=self.name, + type=self.type, + rear_port=rear_port, + rear_port_position=self.rear_port_position + ) + class RearPortTemplate(ComponentTemplateModel): """ @@ -1243,6 +1299,14 @@ class RearPortTemplate(ComponentTemplateModel): def __str__(self): return self.name + def instantiate(self, device): + return RearPort( + device=device, + name=self.name, + type=self.type, + positions=self.positions + ) + class DeviceBayTemplate(ComponentTemplateModel): """ @@ -1266,6 +1330,12 @@ class DeviceBayTemplate(ComponentTemplateModel): def __str__(self): return self.name + def instantiate(self, device): + return DeviceBay( + device=device, + name=self.name + ) + # # Devices @@ -1641,49 +1711,36 @@ class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel): # If this is a new Device, instantiate all of the related components per the DeviceType definition if is_new: ConsolePort.objects.bulk_create( - [ConsolePort(device=self, name=template.name) for template in - self.device_type.consoleport_templates.all()] + [x.instantiate(self) for x in self.device_type.consoleport_templates.all()] ) ConsoleServerPort.objects.bulk_create( - [ConsoleServerPort(device=self, name=template.name) for template in - self.device_type.consoleserverport_templates.all()] + [x.instantiate(self) for x in self.device_type.consoleserverport_templates.all()] ) PowerPort.objects.bulk_create( - [PowerPort(device=self, name=template.name) for template in - self.device_type.powerport_templates.all()] + [x.instantiate(self) for x in self.device_type.powerport_templates.all()] ) PowerOutlet.objects.bulk_create( - [PowerOutlet(device=self, name=template.name) for template in - self.device_type.poweroutlet_templates.all()] + [x.instantiate(self) for x in self.device_type.poweroutlet_templates.all()] ) Interface.objects.bulk_create( - [Interface(device=self, name=template.name, type=template.type, - mgmt_only=template.mgmt_only) for template in self.device_type.interface_templates.all()] + [x.instantiate(self) for x in self.device_type.interface_templates.all()] + ) + RearPort.objects.bulk_create( + [x.instantiate(self) for x in self.device_type.rearport_templates.all()] + ) + FrontPort.objects.bulk_create( + [x.instantiate(self) for x in self.device_type.frontport_templates.all()] ) - RearPort.objects.bulk_create([ - RearPort( - device=self, - name=template.name, - type=template.type, - positions=template.positions - ) for template in self.device_type.rearport_templates.all() - ]) - FrontPort.objects.bulk_create([ - FrontPort( - device=self, - name=template.name, - type=template.type, - rear_port=RearPort.objects.get(device=self, name=template.rear_port.name), - rear_port_position=template.rear_port_position, - ) for template in self.device_type.frontport_templates.all() - ]) DeviceBay.objects.bulk_create( - [DeviceBay(device=self, name=template.name) for template in - self.device_type.device_bay_templates.all()] + [x.instantiate(self) for x in self.device_type.device_bay_templates.all()] ) # Update Site and Rack assignment for any child Devices - Device.objects.filter(parent_bay__device=self).update(site=self.site, rack=self.rack) + devices = Device.objects.filter(parent_bay__device=self) + for device in devices: + device.site = self.site + device.rack = self.rack + device.save() def to_csv(self): return ( @@ -2265,27 +2322,20 @@ class Interface(CableTermination, ComponentModel): return super().save(*args, **kwargs) - def log_change(self, user, request_id, action): - """ - Include the connected Interface (if any). - """ - - # It's possible that an Interface can be deleted _after_ its parent Device/VM, in which case trying to resolve - # the component parent will raise DoesNotExist. For more discussion, see - # https://github.com/netbox-community/netbox/issues/2323 + def to_objectchange(self, action): + # Annotate the parent Device/VM try: parent_obj = self.device or self.virtual_machine except ObjectDoesNotExist: parent_obj = None - ObjectChange( - user=user, - request_id=request_id, + return ObjectChange( changed_object=self, - related_object=parent_obj, + object_repr=str(self), action=action, + related_object=parent_obj, object_data=serialize_object(self) - ).save() + ) # TODO: Remove in v2.7 @property @@ -2773,6 +2823,16 @@ class Cable(ChangeLoggedModel): self.termination_a_type, self.termination_b_type )) + # A component with multiple positions must be connected to a component with an equal number of positions + term_a_positions = getattr(self.termination_a, 'positions', 1) + term_b_positions = getattr(self.termination_b, 'positions', 1) + if term_a_positions != term_b_positions: + raise ValidationError( + "{} has {} positions and {} has {}. Both terminations must have the same number of positions.".format( + self.termination_a, term_a_positions, self.termination_b, term_b_positions + ) + ) + # A termination point cannot be connected to itself if self.termination_a == self.termination_b: raise ValidationError("Cannot connect {} to itself".format(self.termination_a_type)) diff --git a/netbox/dcim/signals.py b/netbox/dcim/signals.py index 67479262b..c1aabf64d 100644 --- a/netbox/dcim/signals.py +++ b/netbox/dcim/signals.py @@ -10,7 +10,11 @@ def assign_virtualchassis_master(instance, created, **kwargs): When a VirtualChassis is created, automatically assign its master device to the VC. """ if created: - Device.objects.filter(pk=instance.master.pk).update(virtual_chassis=instance, vc_position=None) + devices = Device.objects.filter(pk=instance.master.pk) + for device in devices: + device.virtual_chassis = instance + device.vc_position = None + device.save() @receiver(pre_delete, sender=VirtualChassis) @@ -18,7 +22,11 @@ def clear_virtualchassis_members(instance, **kwargs): """ When a VirtualChassis is deleted, nullify the vc_position and vc_priority fields of its prior members. """ - Device.objects.filter(virtual_chassis=instance.pk).update(vc_position=None, vc_priority=None) + devices = Device.objects.filter(virtual_chassis=instance.pk) + for device in devices: + device.vc_position = None + device.vc_priority = None + device.save() @receiver(post_save, sender=Cable) diff --git a/netbox/dcim/tables.py b/netbox/dcim/tables.py index 3958e1326..250173d79 100644 --- a/netbox/dcim/tables.py +++ b/netbox/dcim/tables.py @@ -424,7 +424,7 @@ class PowerPortTemplateTable(BaseTable): class Meta(BaseTable.Meta): model = PowerPortTemplate - fields = ('pk', 'name') + fields = ('pk', 'name', 'maximum_draw', 'allocated_draw') empty_text = "None" @@ -729,6 +729,7 @@ class PowerConnectionTable(BaseTable): viewname='dcim:device', accessor=Accessor('connected_endpoint.device'), args=[Accessor('connected_endpoint.device.pk')], + order_by='_connected_poweroutlet__device', verbose_name='PDU' ) outlet = tables.Column( diff --git a/netbox/dcim/tests/test_models.py b/netbox/dcim/tests/test_models.py index e0af86b20..2135aba66 100644 --- a/netbox/dcim/tests/test_models.py +++ b/netbox/dcim/tests/test_models.py @@ -1,6 +1,5 @@ from django.test import TestCase -from dcim.constants import * from dcim.models import * @@ -152,6 +151,137 @@ class RackTestCase(TestCase): self.assertTrue(pdu) +class DeviceTestCase(TestCase): + + def setUp(self): + + self.site = Site.objects.create(name='Test Site 1', slug='test-site-1') + manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1') + self.device_type = DeviceType.objects.create( + manufacturer=manufacturer, model='Test Device Type 1', slug='test-device-type-1' + ) + self.device_role = DeviceRole.objects.create( + name='Test Device Role 1', slug='test-device-role-1', color='ff0000' + ) + + # Create DeviceType components + ConsolePortTemplate( + device_type=self.device_type, + name='Console Port 1' + ).save() + + ConsoleServerPortTemplate( + device_type=self.device_type, + name='Console Server Port 1' + ).save() + + ppt = PowerPortTemplate( + device_type=self.device_type, + name='Power Port 1', + maximum_draw=1000, + allocated_draw=500 + ) + ppt.save() + + PowerOutletTemplate( + device_type=self.device_type, + name='Power Outlet 1', + power_port=ppt, + feed_leg=POWERFEED_LEG_A + ).save() + + InterfaceTemplate( + device_type=self.device_type, + name='Interface 1', + type=IFACE_TYPE_1GE_FIXED, + mgmt_only=True + ).save() + + rpt = RearPortTemplate( + device_type=self.device_type, + name='Rear Port 1', + type=PORT_TYPE_8P8C, + positions=8 + ) + rpt.save() + + FrontPortTemplate( + device_type=self.device_type, + name='Front Port 1', + type=PORT_TYPE_8P8C, + rear_port=rpt, + rear_port_position=2 + ).save() + + DeviceBayTemplate( + device_type=self.device_type, + name='Device Bay 1' + ).save() + + def test_device_creation(self): + """ + Ensure that all Device components are copied automatically from the DeviceType. + """ + d = Device( + site=self.site, + device_type=self.device_type, + device_role=self.device_role, + name='Test Device 1' + ) + d.save() + + ConsolePort.objects.get( + device=d, + name='Console Port 1' + ) + + ConsoleServerPort.objects.get( + device=d, + name='Console Server Port 1' + ) + + pp = PowerPort.objects.get( + device=d, + name='Power Port 1', + maximum_draw=1000, + allocated_draw=500 + ) + + PowerOutlet.objects.get( + device=d, + name='Power Outlet 1', + power_port=pp, + feed_leg=POWERFEED_LEG_A + ) + + Interface.objects.get( + device=d, + name='Interface 1', + type=IFACE_TYPE_1GE_FIXED, + mgmt_only=True + ) + + rp = RearPort.objects.get( + device=d, + name='Rear Port 1', + type=PORT_TYPE_8P8C, + positions=8 + ) + + FrontPort.objects.get( + device=d, + name='Front Port 1', + type=PORT_TYPE_8P8C, + rear_port=rp, + rear_port_position=2 + ) + + DeviceBay.objects.get( + device=d, + name='Device Bay 1' + ) + + class CableTestCase(TestCase): def setUp(self): diff --git a/netbox/dcim/urls.py b/netbox/dcim/urls.py index ae1f05757..43316baf4 100644 --- a/netbox/dcim/urls.py +++ b/netbox/dcim/urls.py @@ -209,7 +209,6 @@ urlpatterns = [ path(r'interfaces//connect//', views.CableCreateView.as_view(), name='interface_connect', kwargs={'termination_a_type': Interface}), path(r'interfaces//', views.InterfaceView.as_view(), name='interface'), path(r'interfaces//edit/', views.InterfaceEditView.as_view(), name='interface_edit'), - path(r'interfaces//assign-vlans/', views.InterfaceAssignVLANsView.as_view(), name='interface_assign_vlans'), path(r'interfaces//delete/', views.InterfaceDeleteView.as_view(), name='interface_delete'), path(r'interfaces//changelog/', ObjectChangeLogView.as_view(), name='interface_changelog', kwargs={'model': Interface}), path(r'interfaces//trace/', views.CableTraceView.as_view(), name='interface_trace', kwargs={'model': Interface}), diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index cf152e646..f953f95c2 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -185,7 +185,7 @@ class RegionBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): class SiteListView(PermissionRequiredMixin, ObjectListView): permission_required = 'dcim.view_site' - queryset = Site.objects.select_related('region', 'tenant') + queryset = Site.objects.prefetch_related('region', 'tenant') filter = filters.SiteFilter filter_form = forms.SiteFilterForm table = tables.SiteTable @@ -197,7 +197,7 @@ class SiteView(PermissionRequiredMixin, View): def get(self, request, slug): - site = get_object_or_404(Site.objects.select_related('region', 'tenant__group'), slug=slug) + site = get_object_or_404(Site.objects.prefetch_related('region', 'tenant__group'), slug=slug) stats = { 'rack_count': Rack.objects.filter(site=site).count(), 'device_count': Device.objects.filter(site=site).count(), @@ -246,7 +246,7 @@ class SiteBulkImportView(PermissionRequiredMixin, BulkImportView): class SiteBulkEditView(PermissionRequiredMixin, BulkEditView): permission_required = 'dcim.change_site' - queryset = Site.objects.select_related('region', 'tenant') + queryset = Site.objects.prefetch_related('region', 'tenant') filter = filters.SiteFilter table = tables.SiteTable form = forms.SiteBulkEditForm @@ -255,7 +255,7 @@ class SiteBulkEditView(PermissionRequiredMixin, BulkEditView): class SiteBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'dcim.delete_site' - queryset = Site.objects.select_related('region', 'tenant') + queryset = Site.objects.prefetch_related('region', 'tenant') filter = filters.SiteFilter table = tables.SiteTable default_return_url = 'dcim:site_list' @@ -267,7 +267,7 @@ class SiteBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): class RackGroupListView(PermissionRequiredMixin, ObjectListView): permission_required = 'dcim.view_rackgroup' - queryset = RackGroup.objects.select_related('site').annotate(rack_count=Count('racks')) + queryset = RackGroup.objects.prefetch_related('site').annotate(rack_count=Count('racks')) filter = filters.RackGroupFilter filter_form = forms.RackGroupFilterForm table = tables.RackGroupTable @@ -294,7 +294,7 @@ class RackGroupBulkImportView(PermissionRequiredMixin, BulkImportView): class RackGroupBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'dcim.delete_rackgroup' - queryset = RackGroup.objects.select_related('site').annotate(rack_count=Count('racks')) + queryset = RackGroup.objects.prefetch_related('site').annotate(rack_count=Count('racks')) filter = filters.RackGroupFilter table = tables.RackGroupTable default_return_url = 'dcim:rackgroup_list' @@ -342,10 +342,8 @@ class RackRoleBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): class RackListView(PermissionRequiredMixin, ObjectListView): permission_required = 'dcim.view_rack' - queryset = Rack.objects.select_related( - 'site', 'group', 'tenant', 'role' - ).prefetch_related( - 'devices__device_type' + queryset = Rack.objects.prefetch_related( + 'site', 'group', 'tenant', 'role', 'devices__device_type' ).annotate( device_count=Count('devices') ) @@ -363,11 +361,7 @@ class RackElevationListView(PermissionRequiredMixin, View): def get(self, request): - racks = Rack.objects.select_related( - 'site', 'group', 'tenant', 'role' - ).prefetch_related( - 'devices__device_type' - ) + racks = Rack.objects.prefetch_related('site', 'group', 'tenant', 'role', 'devices__device_type') racks = filters.RackFilter(request.GET, racks).qs total_count = racks.count() @@ -402,15 +396,18 @@ class RackView(PermissionRequiredMixin, View): def get(self, request, pk): - rack = get_object_or_404(Rack.objects.select_related('site__region', 'tenant__group', 'group', 'role'), pk=pk) + rack = get_object_or_404(Rack.objects.prefetch_related('site__region', 'tenant__group', 'group', 'role'), pk=pk) - nonracked_devices = Device.objects.filter(rack=rack, position__isnull=True, parent_bay__isnull=True) \ - .select_related('device_type__manufacturer') + nonracked_devices = Device.objects.filter( + rack=rack, + position__isnull=True, + parent_bay__isnull=True + ).prefetch_related('device_type__manufacturer') next_rack = Rack.objects.filter(site=rack.site, name__gt=rack.name).order_by('name').first() prev_rack = Rack.objects.filter(site=rack.site, name__lt=rack.name).order_by('-name').first() reservations = RackReservation.objects.filter(rack=rack) - power_feeds = PowerFeed.objects.filter(rack=rack).select_related('power_panel') + power_feeds = PowerFeed.objects.filter(rack=rack).prefetch_related('power_panel') return render(request, 'dcim/rack.html', { 'rack': rack, @@ -451,7 +448,7 @@ class RackBulkImportView(PermissionRequiredMixin, BulkImportView): class RackBulkEditView(PermissionRequiredMixin, BulkEditView): permission_required = 'dcim.change_rack' - queryset = Rack.objects.select_related('site', 'group', 'tenant', 'role') + queryset = Rack.objects.prefetch_related('site', 'group', 'tenant', 'role') filter = filters.RackFilter table = tables.RackTable form = forms.RackBulkEditForm @@ -460,7 +457,7 @@ class RackBulkEditView(PermissionRequiredMixin, BulkEditView): class RackBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'dcim.delete_rack' - queryset = Rack.objects.select_related('site', 'group', 'tenant', 'role') + queryset = Rack.objects.prefetch_related('site', 'group', 'tenant', 'role') filter = filters.RackFilter table = tables.RackTable default_return_url = 'dcim:rack_list' @@ -472,7 +469,7 @@ class RackBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): class RackReservationListView(PermissionRequiredMixin, ObjectListView): permission_required = 'dcim.view_rackreservation' - queryset = RackReservation.objects.select_related('rack__site') + queryset = RackReservation.objects.prefetch_related('rack__site') filter = filters.RackReservationFilter filter_form = forms.RackReservationFilterForm table = tables.RackReservationTable @@ -508,7 +505,7 @@ class RackReservationDeleteView(PermissionRequiredMixin, ObjectDeleteView): class RackReservationBulkEditView(PermissionRequiredMixin, BulkEditView): permission_required = 'dcim.change_rackreservation' - queryset = RackReservation.objects.select_related('rack', 'user') + queryset = RackReservation.objects.prefetch_related('rack', 'user') filter = filters.RackReservationFilter table = tables.RackReservationTable form = forms.RackReservationBulkEditForm @@ -517,7 +514,7 @@ class RackReservationBulkEditView(PermissionRequiredMixin, BulkEditView): class RackReservationBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'dcim.delete_rackreservation' - queryset = RackReservation.objects.select_related('rack', 'user') + queryset = RackReservation.objects.prefetch_related('rack', 'user') filter = filters.RackReservationFilter table = tables.RackReservationTable default_return_url = 'dcim:rackreservation_list' @@ -569,7 +566,7 @@ class ManufacturerBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): class DeviceTypeListView(PermissionRequiredMixin, ObjectListView): permission_required = 'dcim.view_devicetype' - queryset = DeviceType.objects.select_related('manufacturer').annotate(instance_count=Count('instances')) + queryset = DeviceType.objects.prefetch_related('manufacturer').annotate(instance_count=Count('instances')) filter = filters.DeviceTypeFilter filter_form = forms.DeviceTypeFilterForm table = tables.DeviceTypeTable @@ -666,7 +663,7 @@ class DeviceTypeBulkImportView(PermissionRequiredMixin, BulkImportView): class DeviceTypeBulkEditView(PermissionRequiredMixin, BulkEditView): permission_required = 'dcim.change_devicetype' - queryset = DeviceType.objects.select_related('manufacturer').annotate(instance_count=Count('instances')) + queryset = DeviceType.objects.prefetch_related('manufacturer').annotate(instance_count=Count('instances')) filter = filters.DeviceTypeFilter table = tables.DeviceTypeTable form = forms.DeviceTypeBulkEditForm @@ -675,7 +672,7 @@ class DeviceTypeBulkEditView(PermissionRequiredMixin, BulkEditView): class DeviceTypeBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'dcim.delete_devicetype' - queryset = DeviceType.objects.select_related('manufacturer').annotate(instance_count=Count('instances')) + queryset = DeviceType.objects.prefetch_related('manufacturer').annotate(instance_count=Count('instances')) filter = filters.DeviceTypeFilter table = tables.DeviceTypeTable default_return_url = 'dcim:devicetype_list' @@ -907,7 +904,7 @@ class PlatformBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): class DeviceListView(PermissionRequiredMixin, ObjectListView): permission_required = 'dcim.view_device' - queryset = Device.objects.select_related( + queryset = Device.objects.prefetch_related( 'device_type__manufacturer', 'device_role', 'tenant', 'site', 'rack', 'primary_ip4', 'primary_ip6' ) filter = filters.DeviceFilter @@ -921,7 +918,7 @@ class DeviceView(PermissionRequiredMixin, View): def get(self, request, pk): - device = get_object_or_404(Device.objects.select_related( + device = get_object_or_404(Device.objects.prefetch_related( 'site__region', 'rack__group', 'tenant__group', 'device_role', 'platform' ), pk=pk) @@ -934,32 +931,31 @@ class DeviceView(PermissionRequiredMixin, View): vc_members = [] # Console ports - console_ports = device.consoleports.select_related('connected_endpoint__device', 'cable') + console_ports = device.consoleports.prefetch_related('connected_endpoint__device', 'cable') # Console server ports - consoleserverports = device.consoleserverports.select_related('connected_endpoint__device', 'cable') + consoleserverports = device.consoleserverports.prefetch_related('connected_endpoint__device', 'cable') # Power ports - power_ports = device.powerports.select_related('_connected_poweroutlet__device', 'cable') + power_ports = device.powerports.prefetch_related('_connected_poweroutlet__device', 'cable') # Power outlets - poweroutlets = device.poweroutlets.select_related('connected_endpoint__device', 'cable', 'power_port') + poweroutlets = device.poweroutlets.prefetch_related('connected_endpoint__device', 'cable', 'power_port') # Interfaces - interfaces = device.vc_interfaces.select_related( - 'lag', '_connected_interface__device', '_connected_circuittermination__circuit', 'cable' - ).prefetch_related( + interfaces = device.vc_interfaces.prefetch_related( + 'lag', '_connected_interface__device', '_connected_circuittermination__circuit', 'cable', 'cable__termination_a', 'cable__termination_b', 'ip_addresses', 'tags' ) # Front ports - front_ports = device.frontports.select_related('rear_port', 'cable') + front_ports = device.frontports.prefetch_related('rear_port', 'cable') # Rear ports - rear_ports = device.rearports.select_related('cable') + rear_ports = device.rearports.prefetch_related('cable') # Device bays - device_bays = device.device_bays.select_related('installed_device__device_type__manufacturer') + device_bays = device.device_bays.prefetch_related('installed_device__device_type__manufacturer') # Services services = device.services.all() @@ -972,7 +968,7 @@ class DeviceView(PermissionRequiredMixin, View): site=device.site, device_role=device.device_role ).exclude( pk=device.pk - ).select_related( + ).prefetch_related( 'rack', 'device_type__manufacturer' )[:10] @@ -1005,10 +1001,8 @@ class DeviceInventoryView(PermissionRequiredMixin, View): device = get_object_or_404(Device, pk=pk) inventory_items = InventoryItem.objects.filter( device=device, parent=None - ).select_related( - 'manufacturer' ).prefetch_related( - 'child_items' + 'manufacturer', 'child_items' ) return render(request, 'dcim/device_inventory.html', { @@ -1037,7 +1031,7 @@ class DeviceLLDPNeighborsView(PermissionRequiredMixin, View): def get(self, request, pk): device = get_object_or_404(Device, pk=pk) - interfaces = device.vc_interfaces.connectable().select_related( + interfaces = device.vc_interfaces.connectable().prefetch_related( '_connected_interface__device' ) @@ -1114,7 +1108,7 @@ class ChildDeviceBulkImportView(PermissionRequiredMixin, BulkImportView): class DeviceBulkEditView(PermissionRequiredMixin, BulkEditView): permission_required = 'dcim.change_device' - queryset = Device.objects.select_related('tenant', 'site', 'rack', 'device_role', 'device_type__manufacturer') + queryset = Device.objects.prefetch_related('tenant', 'site', 'rack', 'device_role', 'device_type__manufacturer') filter = filters.DeviceFilter table = tables.DeviceTable form = forms.DeviceBulkEditForm @@ -1123,7 +1117,7 @@ class DeviceBulkEditView(PermissionRequiredMixin, BulkEditView): class DeviceBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'dcim.delete_device' - queryset = Device.objects.select_related('tenant', 'site', 'rack', 'device_role', 'device_type__manufacturer') + queryset = Device.objects.prefetch_related('tenant', 'site', 'rack', 'device_role', 'device_type__manufacturer') filter = filters.DeviceFilter table = tables.DeviceTable default_return_url = 'dcim:device_list' @@ -1310,7 +1304,7 @@ class InterfaceView(PermissionRequiredMixin, View): # Get assigned IP addresses ipaddress_table = InterfaceIPAddressTable( - data=interface.ip_addresses.select_related('vrf', 'tenant'), + data=interface.ip_addresses.prefetch_related('vrf', 'tenant'), orderable=False ) @@ -1319,7 +1313,7 @@ class InterfaceView(PermissionRequiredMixin, View): if interface.untagged_vlan is not None: vlans.append(interface.untagged_vlan) vlans[0].tagged = False - for vlan in interface.tagged_vlans.select_related('site', 'group', 'tenant', 'role'): + for vlan in interface.tagged_vlans.prefetch_related('site', 'group', 'tenant', 'role'): vlan.tagged = True vlans.append(vlan) vlan_table = InterfaceVLANTable( @@ -1354,12 +1348,6 @@ class InterfaceEditView(PermissionRequiredMixin, ObjectEditView): template_name = 'dcim/interface_edit.html' -class InterfaceAssignVLANsView(PermissionRequiredMixin, ObjectEditView): - permission_required = 'dcim.change_interface' - model = Interface - model_form = forms.InterfaceAssignVLANsForm - - class InterfaceDeleteView(PermissionRequiredMixin, ObjectDeleteView): permission_required = 'dcim.delete_interface' model = Interface @@ -1842,7 +1830,7 @@ class CableBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): class ConsoleConnectionsListView(PermissionRequiredMixin, ObjectListView): permission_required = ('dcim.view_consoleport', 'dcim.view_consoleserverport') - queryset = ConsolePort.objects.select_related( + queryset = ConsolePort.objects.prefetch_related( 'device', 'connected_endpoint__device' ).filter( connected_endpoint__isnull=False @@ -1873,7 +1861,7 @@ class ConsoleConnectionsListView(PermissionRequiredMixin, ObjectListView): class PowerConnectionsListView(PermissionRequiredMixin, ObjectListView): permission_required = ('dcim.view_powerport', 'dcim.view_poweroutlet') - queryset = PowerPort.objects.select_related( + queryset = PowerPort.objects.prefetch_related( 'device', '_connected_poweroutlet__device' ).filter( _connected_poweroutlet__isnull=False @@ -1903,8 +1891,8 @@ class PowerConnectionsListView(PermissionRequiredMixin, ObjectListView): class InterfaceConnectionsListView(PermissionRequiredMixin, ObjectListView): - permission_required = 'dcim.interface' - queryset = Interface.objects.select_related( + permission_required = 'dcim.view_interface' + queryset = Interface.objects.prefetch_related( 'device', 'cable', '_connected_interface__device' ).filter( # Avoid duplicate connections by only selecting the lower PK in a connected pair @@ -1947,7 +1935,7 @@ class InterfaceConnectionsListView(PermissionRequiredMixin, ObjectListView): class InventoryItemListView(PermissionRequiredMixin, ObjectListView): permission_required = 'dcim.view_inventoryitem' - queryset = InventoryItem.objects.select_related('device', 'manufacturer') + queryset = InventoryItem.objects.prefetch_related('device', 'manufacturer') filter = filters.InventoryItemFilter filter_form = forms.InventoryItemFilterForm table = tables.InventoryItemTable @@ -1982,7 +1970,7 @@ class InventoryItemBulkImportView(PermissionRequiredMixin, BulkImportView): class InventoryItemBulkEditView(PermissionRequiredMixin, BulkEditView): permission_required = 'dcim.change_inventoryitem' - queryset = InventoryItem.objects.select_related('device', 'manufacturer') + queryset = InventoryItem.objects.prefetch_related('device', 'manufacturer') filter = filters.InventoryItemFilter table = tables.InventoryItemTable form = forms.InventoryItemBulkEditForm @@ -1991,7 +1979,7 @@ class InventoryItemBulkEditView(PermissionRequiredMixin, BulkEditView): class InventoryItemBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'dcim.delete_inventoryitem' - queryset = InventoryItem.objects.select_related('device', 'manufacturer') + queryset = InventoryItem.objects.prefetch_related('device', 'manufacturer') table = tables.InventoryItemTable template_name = 'dcim/inventoryitem_bulk_delete.html' default_return_url = 'dcim:inventoryitem_list' @@ -2003,7 +1991,7 @@ class InventoryItemBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): class VirtualChassisListView(PermissionRequiredMixin, ObjectListView): permission_required = 'dcim.view_virtualchassis' - queryset = VirtualChassis.objects.select_related('master').annotate(member_count=Count('members')) + queryset = VirtualChassis.objects.prefetch_related('master').annotate(member_count=Count('members')) table = tables.VirtualChassisTable filter = filters.VirtualChassisFilter filter_form = forms.VirtualChassisFilterForm @@ -2023,7 +2011,7 @@ class VirtualChassisCreateView(PermissionRequiredMixin, View): return redirect('dcim:device_list') device_queryset = Device.objects.filter( pk__in=pk_form.cleaned_data.get('pk') - ).select_related('rack').order_by('vc_position') + ).prefetch_related('rack').order_by('vc_position') VCMemberFormSet = modelformset_factory( model=Device, @@ -2077,7 +2065,7 @@ class VirtualChassisEditView(PermissionRequiredMixin, GetReturnURLMixin, View): formset=forms.BaseVCMemberFormSet, extra=0 ) - members_queryset = virtual_chassis.members.select_related('rack').order_by('vc_position') + members_queryset = virtual_chassis.members.prefetch_related('rack').order_by('vc_position') vc_form = forms.VirtualChassisForm(instance=virtual_chassis) vc_form.fields['master'].queryset = members_queryset @@ -2098,7 +2086,7 @@ class VirtualChassisEditView(PermissionRequiredMixin, GetReturnURLMixin, View): formset=forms.BaseVCMemberFormSet, extra=0 ) - members_queryset = virtual_chassis.members.select_related('rack').order_by('vc_position') + members_queryset = virtual_chassis.members.prefetch_related('rack').order_by('vc_position') vc_form = forms.VirtualChassisForm(request.POST, instance=virtual_chassis) vc_form.fields['master'].queryset = members_queryset @@ -2114,7 +2102,10 @@ class VirtualChassisEditView(PermissionRequiredMixin, GetReturnURLMixin, View): # Nullify the vc_position of each member first to allow reordering without raising an IntegrityError on # duplicate positions. Then save each member instance. members = formset.save(commit=False) - Device.objects.filter(pk__in=[m.pk for m in members]).update(vc_position=None) + devices = Device.objects.filter(pk__in=[m.pk for m in members]) + for device in devices: + device.vc_position = None + device.save() for member in members: member.save() @@ -2215,11 +2206,12 @@ class VirtualChassisRemoveMemberView(PermissionRequiredMixin, GetReturnURLMixin, if form.is_valid(): - Device.objects.filter(pk=device.pk).update( - virtual_chassis=None, - vc_position=None, - vc_priority=None - ) + devices = Device.objects.filter(pk=device.pk) + for device in devices: + device.virtual_chassis = None + device.vc_position = None + device.vc_priority = None + device.save() msg = 'Removed {} from virtual chassis {}'.format(device, device.virtual_chassis) messages.success(request, msg) @@ -2239,7 +2231,7 @@ class VirtualChassisRemoveMemberView(PermissionRequiredMixin, GetReturnURLMixin, class PowerPanelListView(PermissionRequiredMixin, ObjectListView): permission_required = 'dcim.view_powerpanel' - queryset = PowerPanel.objects.select_related( + queryset = PowerPanel.objects.prefetch_related( 'site', 'rack_group' ).annotate( powerfeed_count=Count('powerfeeds') @@ -2255,9 +2247,9 @@ class PowerPanelView(PermissionRequiredMixin, View): def get(self, request, pk): - powerpanel = get_object_or_404(PowerPanel.objects.select_related('site', 'rack_group'), pk=pk) + powerpanel = get_object_or_404(PowerPanel.objects.prefetch_related('site', 'rack_group'), pk=pk) powerfeed_table = tables.PowerFeedTable( - data=PowerFeed.objects.filter(power_panel=powerpanel).select_related('rack'), + data=PowerFeed.objects.filter(power_panel=powerpanel).prefetch_related('rack'), orderable=False ) powerfeed_table.exclude = ['power_panel'] @@ -2294,7 +2286,7 @@ class PowerPanelBulkImportView(PermissionRequiredMixin, BulkImportView): class PowerPanelBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'dcim.delete_powerpanel' - queryset = PowerPanel.objects.select_related( + queryset = PowerPanel.objects.prefetch_related( 'site', 'rack_group' ).annotate( rack_count=Count('powerfeeds') @@ -2310,7 +2302,7 @@ class PowerPanelBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): class PowerFeedListView(PermissionRequiredMixin, ObjectListView): permission_required = 'dcim.view_powerfeed' - queryset = PowerFeed.objects.select_related( + queryset = PowerFeed.objects.prefetch_related( 'power_panel', 'rack' ) filter = filters.PowerFeedFilter @@ -2324,7 +2316,7 @@ class PowerFeedView(PermissionRequiredMixin, View): def get(self, request, pk): - powerfeed = get_object_or_404(PowerFeed.objects.select_related('power_panel', 'rack'), pk=pk) + powerfeed = get_object_or_404(PowerFeed.objects.prefetch_related('power_panel', 'rack'), pk=pk) return render(request, 'dcim/powerfeed.html', { 'powerfeed': powerfeed, @@ -2358,7 +2350,7 @@ class PowerFeedBulkImportView(PermissionRequiredMixin, BulkImportView): class PowerFeedBulkEditView(PermissionRequiredMixin, BulkEditView): permission_required = 'dcim.change_powerfeed' - queryset = PowerFeed.objects.select_related('power_panel', 'rack') + queryset = PowerFeed.objects.prefetch_related('power_panel', 'rack') filter = filters.PowerFeedFilter table = tables.PowerFeedTable form = forms.PowerFeedBulkEditForm @@ -2367,7 +2359,7 @@ class PowerFeedBulkEditView(PermissionRequiredMixin, BulkEditView): class PowerFeedBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'dcim.delete_powerfeed' - queryset = PowerFeed.objects.select_related('power_panel', 'rack') + queryset = PowerFeed.objects.prefetch_related('power_panel', 'rack') filter = filters.PowerFeedFilter table = tables.PowerFeedTable default_return_url = 'dcim:powerfeed_list' diff --git a/netbox/extras/api/views.py b/netbox/extras/api/views.py index 44e010cd2..526db20a2 100644 --- a/netbox/extras/api/views.py +++ b/netbox/extras/api/views.py @@ -120,7 +120,7 @@ class ExportTemplateViewSet(ModelViewSet): # class TopologyMapViewSet(ModelViewSet): - queryset = TopologyMap.objects.select_related('site') + queryset = TopologyMap.objects.prefetch_related('site') serializer_class = serializers.TopologyMapSerializer filterset_class = filters.TopologyMapFilter @@ -260,6 +260,6 @@ class ObjectChangeViewSet(ReadOnlyModelViewSet): """ Retrieve a list of recent changes. """ - queryset = ObjectChange.objects.select_related('user') + queryset = ObjectChange.objects.prefetch_related('user') serializer_class = serializers.ObjectChangeSerializer filterset_class = filters.ObjectChangeFilter diff --git a/netbox/extras/filters.py b/netbox/extras/filters.py index 49e879fe4..b31271230 100644 --- a/netbox/extras/filters.py +++ b/netbox/extras/filters.py @@ -207,6 +207,20 @@ class ConfigContextFilter(django_filters.FilterSet): ) +# +# Filter for Local Config Context Data +# + +class LocalConfigContextFilter(django_filters.FilterSet): + local_context_data = django_filters.BooleanFilter( + method='_local_context_data', + label='Has local config context data', + ) + + def _local_context_data(self, queryset, name, value): + return queryset.exclude(local_context_data__isnull=value) + + class ObjectChangeFilter(django_filters.FilterSet): q = django_filters.CharFilter( method='search', diff --git a/netbox/extras/forms.py b/netbox/extras/forms.py index 261822d28..19f55c345 100644 --- a/netbox/extras/forms.py +++ b/netbox/extras/forms.py @@ -8,9 +8,11 @@ from taggit.forms import TagField from dcim.models import DeviceRole, Platform, Region, Site from tenancy.models import Tenant, TenantGroup +from utilities.constants import COLOR_CHOICES from utilities.forms import ( - add_blank_choice, APISelectMultiple, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect, CommentField, - ContentTypeSelect, FilterChoiceField, LaxURLField, JSONField, SlugField, + add_blank_choice, APISelectMultiple, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect, ColorSelect, + CommentField, ContentTypeSelect, FilterChoiceField, LaxURLField, JSONField, SlugField, StaticSelect2, + BOOLEAN_WITH_BLANK_CHOICES, ) from .constants import ( CF_FILTER_DISABLED, CF_TYPE_BOOLEAN, CF_TYPE_DATE, CF_TYPE_INTEGER, CF_TYPE_SELECT, CF_TYPE_URL, @@ -111,8 +113,10 @@ class CustomFieldForm(forms.ModelForm): # If editing an existing object, initialize values for all custom fields if self.instance.pk: - existing_values = CustomFieldValue.objects.filter(obj_type=self.obj_type, obj_id=self.instance.pk)\ - .select_related('field') + existing_values = CustomFieldValue.objects.filter( + obj_type=self.obj_type, + obj_id=self.instance.pk + ).prefetch_related('field') for cfv in existing_values: self.initial['cf_{}'.format(str(cfv.field.name))] = cfv.serialized_value @@ -120,9 +124,11 @@ class CustomFieldForm(forms.ModelForm): for field_name in self.custom_fields: try: - cfv = CustomFieldValue.objects.select_related('field').get(field=self.fields[field_name].model, - obj_type=self.obj_type, - obj_id=self.instance.pk) + cfv = CustomFieldValue.objects.prefetch_related('field').get( + field=self.fields[field_name].model, + obj_type=self.obj_type, + obj_id=self.instance.pk + ) except CustomFieldValue.DoesNotExist: # Skip this field if none exists already and its value is empty if self.cleaned_data[field_name] in [None, '']: @@ -215,6 +221,21 @@ class TagFilterForm(BootstrapMixin, forms.Form): ) +class TagBulkEditForm(BootstrapMixin, BulkEditForm): + pk = forms.ModelMultipleChoiceField( + queryset=Tag.objects.all(), + widget=forms.MultipleHiddenInput + ) + color = forms.CharField( + max_length=6, + required=False, + widget=ColorSelect() + ) + + class Meta: + nullable_fields = [] + + # # Config contexts # @@ -329,6 +350,20 @@ class ConfigContextFilterForm(BootstrapMixin, forms.Form): ) +# +# Filter form for local config context data +# + +class LocalConfigContextFilterForm(forms.Form): + local_context_data = forms.NullBooleanField( + required=False, + label='Has local config context data', + widget=StaticSelect2( + choices=BOOLEAN_WITH_BLANK_CHOICES + ) + ) + + # # Image attachments # @@ -380,3 +415,34 @@ class ObjectChangeFilterForm(BootstrapMixin, forms.Form): widget=ContentTypeSelect(), label='Object Type' ) + + +# +# Scripts +# + +class ScriptForm(BootstrapMixin, forms.Form): + _commit = forms.BooleanField( + required=False, + initial=True, + label="Commit changes", + help_text="Commit changes to the database (uncheck for a dry-run)" + ) + + def __init__(self, vars, *args, **kwargs): + + super().__init__(*args, **kwargs) + + # Dynamically populate fields for variables + for name, var in vars.items(): + self.fields[name] = var.as_field() + + # Move _commit to the end of the form + self.fields.move_to_end('_commit', True) + + @property + def requires_input(self): + """ + A boolean indicating whether the form requires user input (ignore the _commit field). + """ + return bool(len(self.fields) > 1) diff --git a/netbox/extras/management/commands/nbshell.py b/netbox/extras/management/commands/nbshell.py index 18c0d0a0a..9c9c329e3 100644 --- a/netbox/extras/management/commands/nbshell.py +++ b/netbox/extras/management/commands/nbshell.py @@ -5,6 +5,7 @@ import sys from django import get_version from django.apps import apps from django.conf import settings +from django.contrib.auth.models import User from django.core.management.base import BaseCommand APPS = ['circuits', 'dcim', 'extras', 'ipam', 'secrets', 'tenancy', 'users', 'virtualization'] @@ -50,6 +51,9 @@ class Command(BaseCommand): except KeyError: pass + # Additional objects to include + namespace['User'] = User + # Load convenience commands namespace.update({ 'lsmodels': self._lsmodels, diff --git a/netbox/extras/middleware.py b/netbox/extras/middleware.py index b0b5a014d..35a6f96b0 100644 --- a/netbox/extras/middleware.py +++ b/netbox/extras/middleware.py @@ -6,39 +6,46 @@ from datetime import timedelta from django.conf import settings from django.db.models.signals import post_delete, post_save from django.utils import timezone -from django.utils.functional import curry from django_prometheus.models import model_deletes, model_inserts, model_updates -from extras.webhooks import enqueue_webhooks from .constants import ( OBJECTCHANGE_ACTION_CREATE, OBJECTCHANGE_ACTION_DELETE, OBJECTCHANGE_ACTION_UPDATE, ) from .models import ObjectChange +from .signals import purge_changelog +from .webhooks import enqueue_webhooks _thread_locals = threading.local() -def cache_changed_object(instance, **kwargs): - - action = OBJECTCHANGE_ACTION_CREATE if kwargs['created'] else OBJECTCHANGE_ACTION_UPDATE - - # Cache the object for further processing was the response has completed. - _thread_locals.changed_objects.append( - (instance, action) - ) +def cache_changed_object(sender, instance, **kwargs): + """ + Cache an object being created or updated for the changelog. + """ + if hasattr(instance, 'to_objectchange'): + action = OBJECTCHANGE_ACTION_CREATE if kwargs['created'] else OBJECTCHANGE_ACTION_UPDATE + objectchange = instance.to_objectchange(action) + _thread_locals.changed_objects.append( + (instance, objectchange) + ) -def _record_object_deleted(request, instance, **kwargs): +def cache_deleted_object(sender, instance, **kwargs): + """ + Cache an object being deleted for the changelog. + """ + if hasattr(instance, 'to_objectchange'): + objectchange = instance.to_objectchange(OBJECTCHANGE_ACTION_DELETE) + _thread_locals.changed_objects.append( + (instance, objectchange) + ) - # Record that the object was deleted - if hasattr(instance, 'log_change'): - instance.log_change(request.user, request.id, OBJECTCHANGE_ACTION_DELETE) - # Enqueue webhooks - enqueue_webhooks(instance, request.user, request.id, OBJECTCHANGE_ACTION_DELETE) - - # Increment metric counters - model_deletes.labels(instance._meta.model_name).inc() +def purge_objectchange_cache(sender, **kwargs): + """ + Delete any queued object changes waiting to be written. + """ + _thread_locals.changed_objects = [] class ObjectChangeMiddleware(object): @@ -67,34 +74,42 @@ class ObjectChangeMiddleware(object): # the same request. request.id = uuid.uuid4() - # Signals don't include the request context, so we're currying it into the post_delete function ahead of time. - record_object_deleted = curry(_record_object_deleted, request) - # Connect our receivers to the post_save and post_delete signals. - post_save.connect(cache_changed_object, dispatch_uid='record_object_saved') - post_delete.connect(record_object_deleted, dispatch_uid='record_object_deleted') + post_save.connect(cache_changed_object, dispatch_uid='cache_changed_object') + post_delete.connect(cache_deleted_object, dispatch_uid='cache_deleted_object') + + # Provide a hook for purging the change cache + purge_changelog.connect(purge_objectchange_cache) # Process the request response = self.get_response(request) + # If the change cache is empty, there's nothing more we need to do. + if not _thread_locals.changed_objects: + return response + # Create records for any cached objects that were created/updated. - for obj, action in _thread_locals.changed_objects: + for obj, objectchange in _thread_locals.changed_objects: # Record the change - if hasattr(obj, 'log_change'): - obj.log_change(request.user, request.id, action) + objectchange.user = request.user + objectchange.request_id = request.id + objectchange.save() # Enqueue webhooks - enqueue_webhooks(obj, request.user, request.id, action) + enqueue_webhooks(obj, request.user, request.id, objectchange.action) # Increment metric counters - if action == OBJECTCHANGE_ACTION_CREATE: + if objectchange.action == OBJECTCHANGE_ACTION_CREATE: model_inserts.labels(obj._meta.model_name).inc() - elif action == OBJECTCHANGE_ACTION_UPDATE: + elif objectchange.action == OBJECTCHANGE_ACTION_UPDATE: model_updates.labels(obj._meta.model_name).inc() + elif objectchange.action == OBJECTCHANGE_ACTION_DELETE: + model_deletes.labels(obj._meta.model_name).inc() - # Housekeeping: 1% chance of clearing out expired ObjectChanges - if _thread_locals.changed_objects and settings.CHANGELOG_RETENTION and random.randint(1, 100) == 1: + # Housekeeping: 1% chance of clearing out expired ObjectChanges. This applies only to requests which result in + # one or more changes being logged. + if settings.CHANGELOG_RETENTION and random.randint(1, 100) == 1: cutoff = timezone.now() - timedelta(days=settings.CHANGELOG_RETENTION) purged_count, _ = ObjectChange.objects.filter( time__lt=cutoff diff --git a/netbox/extras/migrations/0024_scripts.py b/netbox/extras/migrations/0024_scripts.py new file mode 100644 index 000000000..82d0afdc9 --- /dev/null +++ b/netbox/extras/migrations/0024_scripts.py @@ -0,0 +1,23 @@ +# Generated by Django 2.2 on 2019-08-12 15:28 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('extras', '0023_fix_tag_sequences'), + ] + + operations = [ + migrations.CreateModel( + name='Script', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False)), + ], + options={ + 'permissions': (('run_script', 'Can run script'),), + 'managed': False, + }, + ), + ] diff --git a/netbox/extras/migrations/0025_objectchange_time_index.py b/netbox/extras/migrations/0025_objectchange_time_index.py new file mode 100644 index 000000000..64e74658e --- /dev/null +++ b/netbox/extras/migrations/0025_objectchange_time_index.py @@ -0,0 +1,18 @@ +# Generated by Django 2.2 on 2019-08-28 14:45 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('extras', '0024_scripts'), + ] + + operations = [ + migrations.AlterField( + model_name='objectchange', + name='time', + field=models.DateTimeField(auto_now_add=True, db_index=True), + ), + ] diff --git a/netbox/extras/models.py b/netbox/extras/models.py index c5df5c2e5..d764e3d31 100644 --- a/netbox/extras/models.py +++ b/netbox/extras/models.py @@ -569,7 +569,7 @@ class TopologyMap(models.Model): # Add each device to the graph devices = [] for query in device_set.strip(';').split(';'): # Split regexes on semicolons - devices += Device.objects.filter(name__regex=query).select_related('device_role') + devices += Device.objects.filter(name__regex=query).prefetch_related('device_role') # Remove duplicate devices devices = [d for d in devices if d.id not in seen] seen.update([d.id for d in devices]) @@ -607,7 +607,7 @@ class TopologyMap(models.Model): from dcim.models import Interface # Add all interface connections to the graph - connected_interfaces = Interface.objects.select_related( + connected_interfaces = Interface.objects.prefetch_related( '_connected_interface__device' ).filter( Q(device__in=devices) | Q(_connected_interface__device__in=devices), @@ -826,6 +826,21 @@ class ConfigContextModel(models.Model): return data +# +# Custom scripts +# + +class Script(models.Model): + """ + Dummy model used to generate permissions for custom scripts. Does not exist in the database. + """ + class Meta: + managed = False + permissions = ( + ('run_script', 'Can run script'), + ) + + # # Report results # @@ -867,7 +882,8 @@ class ObjectChange(models.Model): """ time = models.DateTimeField( auto_now_add=True, - editable=False + editable=False, + db_index=True ) user = models.ForeignKey( to=User, @@ -938,8 +954,10 @@ class ObjectChange(models.Model): def save(self, *args, **kwargs): # Record the user's name and the object's representation as static strings - self.user_name = self.user.username - self.object_repr = str(self.changed_object) + if not self.user_name: + self.user_name = self.user.username + if not self.object_repr: + self.object_repr = str(self.changed_object) return super().save(*args, **kwargs) diff --git a/netbox/extras/scripts.py b/netbox/extras/scripts.py new file mode 100644 index 000000000..9462ee5bd --- /dev/null +++ b/netbox/extras/scripts.py @@ -0,0 +1,343 @@ +from collections import OrderedDict +import inspect +import json +import os +import pkgutil +import time +import traceback +import yaml + +from django import forms +from django.conf import settings +from django.core.validators import RegexValidator +from django.db import transaction +from mptt.forms import TreeNodeChoiceField +from mptt.models import MPTTModel + +from ipam.formfields import IPFormField +from utilities.exceptions import AbortTransaction +from .constants import LOG_DEFAULT, LOG_FAILURE, LOG_INFO, LOG_SUCCESS, LOG_WARNING +from .forms import ScriptForm +from .signals import purge_changelog + + +__all__ = [ + 'BaseScript', + 'BooleanVar', + 'FileVar', + 'IntegerVar', + 'IPNetworkVar', + 'ObjectVar', + 'Script', + 'StringVar', + 'TextVar', +] + + +# +# Script variables +# + +class ScriptVariable: + """ + Base model for script variables + """ + form_field = forms.CharField + + def __init__(self, label='', description='', default=None, required=True): + + # Default field attributes + self.field_attrs = { + 'help_text': description, + 'required': required + } + if label: + self.field_attrs['label'] = label + if default: + self.field_attrs['initial'] = default + + def as_field(self): + """ + Render the variable as a Django form field. + """ + form_field = self.form_field(**self.field_attrs) + form_field.widget.attrs['class'] = 'form-control' + + return form_field + + +class StringVar(ScriptVariable): + """ + Character string representation. Can enforce minimum/maximum length and/or regex validation. + """ + def __init__(self, min_length=None, max_length=None, regex=None, *args, **kwargs): + super().__init__(*args, **kwargs) + + # Optional minimum/maximum lengths + if min_length: + self.field_attrs['min_length'] = min_length + if max_length: + self.field_attrs['max_length'] = max_length + + # Optional regular expression validation + if regex: + self.field_attrs['validators'] = [ + RegexValidator( + regex=regex, + message='Invalid value. Must match regex: {}'.format(regex), + code='invalid' + ) + ] + + +class TextVar(ScriptVariable): + """ + Free-form text data. Renders as a