diff --git a/.gitignore b/.gitignore index e02e72710..38154baaa 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 5216d32a7..70feb952e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,4 @@ +<<<<<<< HEAD v2.7.0 (FUTURE) ## Housekeeping @@ -6,11 +7,34 @@ v2.7.0 (FUTURE) --- -v2.6.3 (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 --- 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/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 af5ccae4a..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 @@ -587,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') @@ -601,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/filters.py b/netbox/dcim/filters.py index a063a6b83..be1b02703 100644 --- a/netbox/dcim/filters.py +++ b/netbox/dcim/filters.py @@ -160,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', ] @@ -519,6 +522,9 @@ class DeviceFilter(TenancyFilterSet, CustomFieldFilterSet): 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', @@ -560,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(): @@ -847,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 db3b78dd3..4abbcdd71 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -632,7 +632,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'), ), @@ -745,7 +745,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( @@ -1391,14 +1391,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: @@ -1710,7 +1710,7 @@ class DeviceFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm) ) ) rack_group_id = FilterChoiceField( - queryset=RackGroup.objects.select_related( + queryset=RackGroup.objects.prefetch_related( 'site' ), label='Rack group', @@ -1749,7 +1749,7 @@ class DeviceFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm) ) ) device_type_id = FilterChoiceField( - queryset=DeviceType.objects.select_related( + queryset=DeviceType.objects.prefetch_related( 'manufacturer' ), label='Model', diff --git a/netbox/dcim/models.py b/netbox/dcim/models.py index fb8ab1dc5..31c27e82b 100644 --- a/netbox/dcim/models.py +++ b/netbox/dcim/models.py @@ -37,18 +37,14 @@ class ComponentTemplateModel(models.Model): """ raise NotImplementedError() - def log_change(self, user, request_id, action): - """ - Log an ObjectChange including the parent DeviceType. - """ - ObjectChange( - user=user, - request_id=request_id, + 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): @@ -60,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): @@ -607,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 ( @@ -664,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)\ @@ -697,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)) @@ -1722,7 +1719,11 @@ class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel): ) # 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 ( @@ -2304,27 +2305,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() + ) @property def connected_endpoint(self): 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 de4a2dec5..250173d79 100644 --- a/netbox/dcim/tables.py +++ b/netbox/dcim/tables.py @@ -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/views.py b/netbox/dcim/views.py index ef7666153..39ffd499d 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(), @@ -244,7 +244,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 @@ -253,7 +253,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' @@ -265,7 +265,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 @@ -292,7 +292,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' @@ -340,10 +340,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') ) @@ -361,11 +359,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() @@ -400,15 +394,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, @@ -449,7 +446,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 @@ -458,7 +455,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' @@ -470,7 +467,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 @@ -506,7 +503,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 @@ -515,7 +512,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' @@ -567,7 +564,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 @@ -664,7 +661,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 @@ -673,7 +670,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' @@ -905,7 +902,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 @@ -919,7 +916,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) @@ -932,32 +929,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() @@ -970,7 +966,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] @@ -1003,10 +999,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', { @@ -1035,7 +1029,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' ) @@ -1112,7 +1106,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 @@ -1121,7 +1115,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' @@ -1308,7 +1302,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 ) @@ -1317,7 +1311,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( @@ -1840,7 +1834,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 @@ -1871,7 +1865,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 @@ -1902,7 +1896,7 @@ class PowerConnectionsListView(PermissionRequiredMixin, ObjectListView): class InterfaceConnectionsListView(PermissionRequiredMixin, ObjectListView): permission_required = 'dcim.view_interface' - queryset = Interface.objects.select_related( + queryset = Interface.objects.prefetch_related( 'device', 'cable', '_connected_interface__device' ).filter( # Avoid duplicate connections by only selecting the lower PK in a connected pair @@ -1945,7 +1939,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 @@ -1980,7 +1974,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 @@ -1989,7 +1983,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' @@ -2001,7 +1995,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 @@ -2021,7 +2015,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, @@ -2075,7 +2069,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 @@ -2096,7 +2090,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 @@ -2112,7 +2106,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() @@ -2213,11 +2210,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) @@ -2237,7 +2235,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') @@ -2253,9 +2251,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'] @@ -2292,7 +2290,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') @@ -2308,7 +2306,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 @@ -2322,7 +2320,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, @@ -2356,7 +2354,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 @@ -2365,7 +2363,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 cbc851c4d..a6b678522 100644 --- a/netbox/extras/api/views.py +++ b/netbox/extras/api/views.py @@ -230,6 +230,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/forms.py b/netbox/extras/forms.py index 261822d28..b06d53423 100644 --- a/netbox/extras/forms.py +++ b/netbox/extras/forms.py @@ -8,9 +8,10 @@ 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, ) from .constants import ( CF_FILTER_DISABLED, CF_TYPE_BOOLEAN, CF_TYPE_DATE, CF_TYPE_INTEGER, CF_TYPE_SELECT, CF_TYPE_URL, @@ -111,8 +112,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 +123,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 +220,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 # @@ -380,3 +400,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 68889dc33..777eea457 100644 --- a/netbox/extras/models.py +++ b/netbox/extras/models.py @@ -675,6 +675,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 # @@ -716,7 +731,8 @@ class ObjectChange(models.Model): """ time = models.DateTimeField( auto_now_add=True, - editable=False + editable=False, + db_index=True ) user = models.ForeignKey( to=User, @@ -787,8 +803,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