From 6bc72109c1004f54595a2179f408055e667881ab Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Mon, 20 Sep 2021 13:19:53 -0400 Subject: [PATCH 01/37] PRVB --- netbox/netbox/settings.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index a720eb484..6481299f8 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -16,7 +16,7 @@ from django.core.validators import URLValidator # Environment setup # -VERSION = '3.0.3' +VERSION = '3.0.4-dev' # Hostname HOSTNAME = platform.node() From 41dfdc0aaa8d0cf39321fa4b31e23f7c958c39ad Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 21 Sep 2021 09:13:26 -0400 Subject: [PATCH 02/37] Fixes #7324: Fix TypeError exception in web UI when filtering objects using single-choice filters --- docs/release-notes/version-3.0.md | 6 ++++++ netbox/utilities/forms/utils.py | 2 ++ 2 files changed, 8 insertions(+) diff --git a/docs/release-notes/version-3.0.md b/docs/release-notes/version-3.0.md index 522a026e3..45c05d4b5 100644 --- a/docs/release-notes/version-3.0.md +++ b/docs/release-notes/version-3.0.md @@ -1,5 +1,11 @@ # NetBox v3.0 +## v3.0.4 (FUTURE) + +### Bug Fixes + +* [#7324](https://github.com/netbox-community/netbox/issues/7324) - Fix TypeError exception in web UI when filtering objects using single-choice filters + ## v3.0.3 (2021-09-20) ### Enhancements diff --git a/netbox/utilities/forms/utils.py b/netbox/utilities/forms/utils.py index 7d2b79f02..343fdb8a3 100644 --- a/netbox/utilities/forms/utils.py +++ b/netbox/utilities/forms/utils.py @@ -137,6 +137,8 @@ def get_selected_values(form, field_name): else: # Static selection field choices = unpack_grouped_choices(field.choices) + if type(filter_data) not in (list, tuple): + filter_data = [filter_data] # Ensure filter data is iterable values = [ label for value, label in choices if str(value) in filter_data or None in filter_data ] From 0db409226632d23b49e2d2a51ae11406301010bb Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 21 Sep 2021 13:41:33 -0400 Subject: [PATCH 03/37] Fixes #7321: Don't overwrite multi-select custom fields during bulk edit --- docs/release-notes/version-3.0.md | 1 + netbox/netbox/views/generic.py | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/release-notes/version-3.0.md b/docs/release-notes/version-3.0.md index 45c05d4b5..d07f4e149 100644 --- a/docs/release-notes/version-3.0.md +++ b/docs/release-notes/version-3.0.md @@ -4,6 +4,7 @@ ### Bug Fixes +* [#7321](https://github.com/netbox-community/netbox/issues/7321) - Don't overwrite multi-select custom fields during bulk edit * [#7324](https://github.com/netbox-community/netbox/issues/7324) - Fix TypeError exception in web UI when filtering objects using single-choice filters ## v3.0.3 (2021-09-20) diff --git a/netbox/netbox/views/generic.py b/netbox/netbox/views/generic.py index b05033128..43b83da2f 100644 --- a/netbox/netbox/views/generic.py +++ b/netbox/netbox/views/generic.py @@ -824,14 +824,14 @@ class BulkEditView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): if form.cleaned_data[name]: getattr(obj, name).set(form.cleaned_data[name]) # Normal fields - elif form.cleaned_data[name] not in (None, '', []): + elif name in form.changed_data: setattr(obj, name, form.cleaned_data[name]) # Update custom fields for name in custom_fields: if name in form.nullable_fields and name in nullified_fields: obj.custom_field_data[name] = None - elif form.cleaned_data.get(name) not in (None, ''): + elif name in form.changed_data: obj.custom_field_data[name] = form.cleaned_data[name] obj.full_clean() From 2a1718bfc8aee271381e6ee1fc69817dd767e540 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 21 Sep 2021 13:53:11 -0400 Subject: [PATCH 04/37] Closes #7323: Add serial filter field for racks & devices --- docs/release-notes/version-3.0.md | 4 ++++ netbox/dcim/forms.py | 10 ++++++++-- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/docs/release-notes/version-3.0.md b/docs/release-notes/version-3.0.md index d07f4e149..ac39b2bf1 100644 --- a/docs/release-notes/version-3.0.md +++ b/docs/release-notes/version-3.0.md @@ -2,6 +2,10 @@ ## v3.0.4 (FUTURE) +### Enhancements + +* [#7323](https://github.com/netbox-community/netbox/issues/7323) - Add serial filter field for racks & devices + ### Bug Fixes * [#7321](https://github.com/netbox-community/netbox/issues/7321) - Don't overwrite multi-select custom fields during bulk edit diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index 56c0f046b..233d45220 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -938,7 +938,7 @@ class RackFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilterFo ['q', 'tag'], ['region_id', 'site_id', 'location_id'], ['status', 'role_id'], - ['type', 'width', 'asset_tag'], + ['type', 'width', 'serial', 'asset_tag'], ['tenant_group_id', 'tenant_id'], ] q = forms.CharField( @@ -993,6 +993,9 @@ class RackFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilterFo label=_('Role'), fetch_trigger='open' ) + serial = forms.CharField( + required=False + ) asset_tag = forms.CharField( required=False ) @@ -2590,7 +2593,7 @@ class DeviceFilterForm(BootstrapMixin, LocalConfigContextFilterForm, TenancyFilt field_groups = [ ['q', 'tag'], ['region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id'], - ['status', 'role_id', 'asset_tag', 'mac_address'], + ['status', 'role_id', 'serial', 'asset_tag', 'mac_address'], ['manufacturer_id', 'device_type_id', 'platform_id'], ['tenant_group_id', 'tenant_id'], [ @@ -2679,6 +2682,9 @@ class DeviceFilterForm(BootstrapMixin, LocalConfigContextFilterForm, TenancyFilt required=False, widget=StaticSelectMultiple() ) + serial = forms.CharField( + required=False + ) asset_tag = forms.CharField( required=False ) From 3cf1d6baf4b5c05e85d4635ed70aa8641944e673 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 21 Sep 2021 14:04:47 -0400 Subject: [PATCH 05/37] Closes #7118: Render URL custom fields as hyperlinks in object tables --- docs/release-notes/version-3.0.md | 1 + netbox/utilities/tables.py | 4 ++++ 2 files changed, 5 insertions(+) diff --git a/docs/release-notes/version-3.0.md b/docs/release-notes/version-3.0.md index ac39b2bf1..d74643f6c 100644 --- a/docs/release-notes/version-3.0.md +++ b/docs/release-notes/version-3.0.md @@ -4,6 +4,7 @@ ### Enhancements +* [#7118](https://github.com/netbox-community/netbox/issues/7118) - Render URL custom fields as hyperlinks in object tables * [#7323](https://github.com/netbox-community/netbox/issues/7323) - Add serial filter field for racks & devices ### Bug Fixes diff --git a/netbox/utilities/tables.py b/netbox/utilities/tables.py index 4d8a60114..0a8e8c3ba 100644 --- a/netbox/utilities/tables.py +++ b/netbox/utilities/tables.py @@ -11,6 +11,7 @@ from django_tables2 import RequestConfig from django_tables2.data import TableQuerysetData from django_tables2.utils import Accessor +from extras.choices import CustomFieldTypeChoices from extras.models import CustomField from .utils import content_type_name from .paginator import EnhancedPaginator, get_paginate_count @@ -355,6 +356,9 @@ class CustomFieldColumn(tables.Column): def render(self, value): if isinstance(value, list): return ', '.join(v for v in value) + elif self.customfield.type == CustomFieldTypeChoices.TYPE_URL: + # Linkify custom URLs + return mark_safe(f'{value}') return value or self.default From 6bccb6d90b7c8b95f68330b71cc024255261d963 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 21 Sep 2021 14:24:23 -0400 Subject: [PATCH 06/37] Fixes #7333: Prevent inadvertent deletion of prior change records when deleting objects --- docs/release-notes/version-3.0.md | 1 + netbox/extras/graphql/mixins.py | 10 +++++++++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/docs/release-notes/version-3.0.md b/docs/release-notes/version-3.0.md index d74643f6c..7f9eafc47 100644 --- a/docs/release-notes/version-3.0.md +++ b/docs/release-notes/version-3.0.md @@ -11,6 +11,7 @@ * [#7321](https://github.com/netbox-community/netbox/issues/7321) - Don't overwrite multi-select custom fields during bulk edit * [#7324](https://github.com/netbox-community/netbox/issues/7324) - Fix TypeError exception in web UI when filtering objects using single-choice filters +* [#7333](https://github.com/netbox-community/netbox/issues/7333) - Prevent inadvertent deletion of prior change records when deleting objects ## v3.0.3 (2021-09-20) diff --git a/netbox/extras/graphql/mixins.py b/netbox/extras/graphql/mixins.py index 3cf12896b..462ba721f 100644 --- a/netbox/extras/graphql/mixins.py +++ b/netbox/extras/graphql/mixins.py @@ -1,6 +1,9 @@ import graphene +from django.contrib.contenttypes.models import ContentType from graphene.types.generic import GenericScalar +from extras.models import ObjectChange + __all__ = ( 'ChangelogMixin', 'ConfigContextMixin', @@ -15,7 +18,12 @@ class ChangelogMixin: changelog = graphene.List('extras.graphql.types.ObjectChangeType') def resolve_changelog(self, info): - return self.object_changes.restrict(info.context.user, 'view') + content_type = ContentType.objects.get_for_model(self) + object_changes = ObjectChange.objects.filter( + changed_object_type=content_type, + changed_object_id=self.pk + ) + return object_changes.restrict(info.context.user, 'view') class ConfigContextMixin: From 38172b793b3fa51025187901a8302d5503d64c33 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 21 Sep 2021 15:04:37 -0400 Subject: [PATCH 07/37] Fixes #7294: Fix SVG rendering for cable traces ending at unoccupied front ports --- docs/release-notes/version-3.0.md | 1 + netbox/dcim/models/device_components.py | 9 ++++----- netbox/dcim/svg.py | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/release-notes/version-3.0.md b/docs/release-notes/version-3.0.md index 7f9eafc47..545db1049 100644 --- a/docs/release-notes/version-3.0.md +++ b/docs/release-notes/version-3.0.md @@ -9,6 +9,7 @@ ### Bug Fixes +* [#7294](https://github.com/netbox-community/netbox/issues/7294) - Fix SVG rendering for cable traces ending at unoccupied front ports * [#7321](https://github.com/netbox-community/netbox/issues/7321) - Don't overwrite multi-select custom fields during bulk edit * [#7324](https://github.com/netbox-community/netbox/issues/7324) - Fix TypeError exception in web UI when filtering objects using single-choice filters * [#7333](https://github.com/netbox-community/netbox/issues/7333) - Prevent inadvertent deletion of prior change records when deleting objects diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index 6a81e2cf1..a321c8059 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -185,11 +185,10 @@ class PathEndpoint(models.Model): # Construct the complete path path = [self, *self._path.get_path()] - if self._path.destination: - path.append(self._path.destination) - while len(path) % 3: - # Pad to ensure we have complete three-tuples (e.g. for paths that end at a RearPort) - path.insert(-1, None) + while (len(path) + 1) % 3: + # Pad to ensure we have complete three-tuples (e.g. for paths that end at a non-connected FrontPort) + path.append(None) + path.append(self._path.destination) # Return the path as a list of three-tuples (A termination, cable, B termination) return list(zip(*[iter(path)] * 3)) diff --git a/netbox/dcim/svg.py b/netbox/dcim/svg.py index 4789dd2d6..2064734ad 100644 --- a/netbox/dcim/svg.py +++ b/netbox/dcim/svg.py @@ -482,7 +482,7 @@ class CableTraceSVG: ) parent_objects.append(parent_object) - else: + elif far_end: # Attachment attachment = self._draw_attachment() From 7ec6b4ebb7478c7f1e52d3653b3f6f44a19a3fee Mon Sep 17 00:00:00 2001 From: Daniel Sheppard Date: Wed, 22 Sep 2021 11:48:22 -0500 Subject: [PATCH 08/37] Fixes #7341 - Corrects url in Circuit breadcrumb --- docs/release-notes/version-3.0.md | 1 + netbox/templates/circuits/circuit.html | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/release-notes/version-3.0.md b/docs/release-notes/version-3.0.md index 545db1049..1fb46b106 100644 --- a/docs/release-notes/version-3.0.md +++ b/docs/release-notes/version-3.0.md @@ -13,6 +13,7 @@ * [#7321](https://github.com/netbox-community/netbox/issues/7321) - Don't overwrite multi-select custom fields during bulk edit * [#7324](https://github.com/netbox-community/netbox/issues/7324) - Fix TypeError exception in web UI when filtering objects using single-choice filters * [#7333](https://github.com/netbox-community/netbox/issues/7333) - Prevent inadvertent deletion of prior change records when deleting objects +* [#7341](https://github.com/netbox-community/netbox/issues/7341) - Fix incorrect url in Circuit breadcrumbs ## v3.0.3 (2021-09-20) diff --git a/netbox/templates/circuits/circuit.html b/netbox/templates/circuits/circuit.html index bf26d7fe3..e68465c82 100644 --- a/netbox/templates/circuits/circuit.html +++ b/netbox/templates/circuits/circuit.html @@ -4,7 +4,7 @@ {% block breadcrumbs %} {{ block.super }} - + {% endblock %} {% block content %} From 8523ad166ef91eab519bd9c93957e25153aad138 Mon Sep 17 00:00:00 2001 From: royreznik Date: Mon, 20 Sep 2021 11:39:48 -0700 Subject: [PATCH 09/37] Feature-6917 make ip assigned checkmark link to interface --- netbox/ipam/tables/ip.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/netbox/ipam/tables/ip.py b/netbox/ipam/tables/ip.py index 2e59a681c..57adbb1b8 100644 --- a/netbox/ipam/tables/ip.py +++ b/netbox/ipam/tables/ip.py @@ -318,7 +318,8 @@ class IPAddressTable(BaseTable): verbose_name='NAT (Inside)' ) assigned = BooleanColumn( - accessor='assigned_object_id', + accessor='assigned_object', + linkify=True, verbose_name='Assigned' ) tags = TagColumn( From 694bd231e33e072f69e790da39e80a99778df6c2 Mon Sep 17 00:00:00 2001 From: royreznik Date: Wed, 22 Sep 2021 06:20:59 -0700 Subject: [PATCH 10/37] Update changelog with #6917 --- docs/release-notes/version-3.0.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/release-notes/version-3.0.md b/docs/release-notes/version-3.0.md index 1fb46b106..508178a0c 100644 --- a/docs/release-notes/version-3.0.md +++ b/docs/release-notes/version-3.0.md @@ -4,6 +4,7 @@ ### Enhancements +* [#6917](https://github.com/netbox-community/netbox/issues/6917) - Make ip assigned checkmark in ip table link to interface * [#7118](https://github.com/netbox-community/netbox/issues/7118) - Render URL custom fields as hyperlinks in object tables * [#7323](https://github.com/netbox-community/netbox/issues/7323) - Add serial filter field for racks & devices From d5142906885f3e9284a4d942eafa1b3e0197eff2 Mon Sep 17 00:00:00 2001 From: Shuichiro MAKIGAKI Date: Wed, 22 Sep 2021 12:49:34 +0900 Subject: [PATCH 11/37] Fix #7365: Optimize calculation of prefix utilization --- netbox/ipam/models/ip.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/netbox/ipam/models/ip.py b/netbox/ipam/models/ip.py index 3e2e671ca..b5360a7b7 100644 --- a/netbox/ipam/models/ip.py +++ b/netbox/ipam/models/ip.py @@ -487,11 +487,9 @@ class Prefix(PrimaryModel): utilization = int(float(child_prefixes.size) / self.prefix.size * 100) else: # Compile an IPSet to avoid counting duplicate IPs - child_ips = netaddr.IPSet() - for iprange in self.get_child_ranges(): - child_ips.add(iprange.range) - for ip in self.get_child_ips(): - child_ips.add(ip.address.ip) + child_ips = netaddr.IPSet( + [_.range for _ in self.get_child_ranges()] + [_.address.ip for _ in self.get_child_ips()] + ) prefix_size = self.prefix.size if self.prefix.version == 4 and self.prefix.prefixlen < 31 and not self.is_pool: From e443d719d40a0cca2bb7e2a7a43214a8bcee9c5c Mon Sep 17 00:00:00 2001 From: maximumG Date: Mon, 27 Sep 2021 10:59:23 +0200 Subject: [PATCH 12/37] feat: reports within a module can now be ordered --- netbox/extras/reports.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/netbox/extras/reports.py b/netbox/extras/reports.py index 64fbffb46..cc623b37c 100644 --- a/netbox/extras/reports.py +++ b/netbox/extras/reports.py @@ -59,8 +59,10 @@ def get_reports(): # defined. for importer, module_name, _ in pkgutil.iter_modules([settings.REPORTS_ROOT]): module = importer.find_module(module_name).load_module(module_name) - report_list = [cls() for _, cls in inspect.getmembers(module, is_report)] - module_list.append((module_name, report_list)) + report_order = getattr(module, "report_order", ()) + ordered_reports = [cls() for cls in report_order if is_report(cls)] + unordered_reports = [cls() for _, cls in inspect.getmembers(module, is_report) if cls not in report_order] + module_list.append((module_name, [*ordered_reports, *unordered_reports])) return module_list From 0214c388ae612d97120365366e2a16a37cfbc967 Mon Sep 17 00:00:00 2001 From: maximumG Date: Mon, 27 Sep 2021 11:00:23 +0200 Subject: [PATCH 13/37] add: document how to order reports --- docs/customization/reports.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/docs/customization/reports.md b/docs/customization/reports.md index 2fead68ec..a227f3851 100644 --- a/docs/customization/reports.md +++ b/docs/customization/reports.md @@ -97,6 +97,21 @@ The recording of one or more failure messages will automatically flag a report a To perform additional tasks, such as sending an email or calling a webhook, after a report has been run, extend the `post_run()` method. The status of the report is available as `self.failed` and the results object is `self.result`. +By default, reports within a module are unordered and 'randomly' displayed in the reports list page. If you want to order reports, you can defined the `report_order` variable at the end +of your module. The `report_order` variable is a tuple which contains each Report class in a specific order. + +``` +from extras.reports import Report + +class DeviceConnectionsReport(Report) + pass + +class DeviceIPsReport(Report) + pass + +report_order = (DeviceIPsReport, DeviceConnectionsReport) +``` + Once you have created a report, it will appear in the reports list. Initially, reports will have no results associated with them. To generate results, run the report. ## Running Reports From aaba4b534f3499076a037032156fda085fba8e40 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Mon, 27 Sep 2021 09:58:03 -0400 Subject: [PATCH 14/37] Fixes #7360: Correct redirection URL after removing child device from device bay --- docs/release-notes/version-3.0.md | 3 ++- netbox/dcim/views.py | 5 +++-- netbox/templates/dcim/devicebay_populate.html | 4 ++-- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/docs/release-notes/version-3.0.md b/docs/release-notes/version-3.0.md index 508178a0c..cd62f4171 100644 --- a/docs/release-notes/version-3.0.md +++ b/docs/release-notes/version-3.0.md @@ -14,7 +14,8 @@ * [#7321](https://github.com/netbox-community/netbox/issues/7321) - Don't overwrite multi-select custom fields during bulk edit * [#7324](https://github.com/netbox-community/netbox/issues/7324) - Fix TypeError exception in web UI when filtering objects using single-choice filters * [#7333](https://github.com/netbox-community/netbox/issues/7333) - Prevent inadvertent deletion of prior change records when deleting objects -* [#7341](https://github.com/netbox-community/netbox/issues/7341) - Fix incorrect url in Circuit breadcrumbs +* [#7341](https://github.com/netbox-community/netbox/issues/7341) - Fix incorrect URL in circuit breadcrumbs +* [#7360](https://github.com/netbox-community/netbox/issues/7360) - Correct redirection URL after removing child device from device bay ## v3.0.3 (2021-09-20) diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 63f2be5c8..acdbfba65 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -2169,9 +2169,10 @@ class DeviceBayDepopulateView(generic.ObjectEditView): removed_device = device_bay.installed_device device_bay.installed_device = None device_bay.save() - messages.success(request, "{} has been removed from {}.".format(removed_device, device_bay)) + messages.success(request, f"{removed_device} has been removed from {device_bay}.") + return_url = self.get_return_url(request, device_bay.device) - return redirect('dcim:device', pk=device_bay.device.pk) + return redirect(return_url) return render(request, 'dcim/devicebay_depopulate.html', { 'device_bay': device_bay, diff --git a/netbox/templates/dcim/devicebay_populate.html b/netbox/templates/dcim/devicebay_populate.html index 3e3d898f2..d0f47921a 100644 --- a/netbox/templates/dcim/devicebay_populate.html +++ b/netbox/templates/dcim/devicebay_populate.html @@ -12,13 +12,13 @@
{% block title %}Populate {{ device_bay }}{% endblock %}
- +
- +
From 8fda08a1b58d771e83bb23a79c7054fcd19f6f6f Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Mon, 27 Sep 2021 10:09:32 -0400 Subject: [PATCH 15/37] Fixes #7356: Fix display of model documentation when adding device components --- docs/release-notes/version-3.0.md | 1 + netbox/netbox/views/generic.py | 1 + 2 files changed, 2 insertions(+) diff --git a/docs/release-notes/version-3.0.md b/docs/release-notes/version-3.0.md index cd62f4171..d4e0a603f 100644 --- a/docs/release-notes/version-3.0.md +++ b/docs/release-notes/version-3.0.md @@ -15,6 +15,7 @@ * [#7324](https://github.com/netbox-community/netbox/issues/7324) - Fix TypeError exception in web UI when filtering objects using single-choice filters * [#7333](https://github.com/netbox-community/netbox/issues/7333) - Prevent inadvertent deletion of prior change records when deleting objects * [#7341](https://github.com/netbox-community/netbox/issues/7341) - Fix incorrect URL in circuit breadcrumbs +* [#7356](https://github.com/netbox-community/netbox/issues/7356) - Fix display of model documentation when adding device components * [#7360](https://github.com/netbox-community/netbox/issues/7360) - Correct redirection URL after removing child device from device bay ## v3.0.3 (2021-09-20) diff --git a/netbox/netbox/views/generic.py b/netbox/netbox/views/generic.py index 43b83da2f..41a8cee25 100644 --- a/netbox/netbox/views/generic.py +++ b/netbox/netbox/views/generic.py @@ -1100,6 +1100,7 @@ class ComponentCreateView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View form = self.form(initial=request.GET) return render(request, self.template_name, { + 'obj': self.queryset.model(), 'obj_type': self.queryset.model._meta.verbose_name, 'form': form, 'return_url': self.get_return_url(request), From 68b1234388b98d14ce2b40746ac34acfdfcaa239 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Mon, 27 Sep 2021 10:36:01 -0400 Subject: [PATCH 16/37] Fixes #7353: Fix bulk creation of device/VM components via list view --- docs/release-notes/version-3.0.md | 1 + netbox/templates/dcim/device_list.html | 129 +++++++++--------- .../virtualization/virtualmachine_list.html | 24 ++-- netbox/virtualization/views.py | 1 + 4 files changed, 81 insertions(+), 74 deletions(-) diff --git a/docs/release-notes/version-3.0.md b/docs/release-notes/version-3.0.md index d4e0a603f..ed8a722ec 100644 --- a/docs/release-notes/version-3.0.md +++ b/docs/release-notes/version-3.0.md @@ -15,6 +15,7 @@ * [#7324](https://github.com/netbox-community/netbox/issues/7324) - Fix TypeError exception in web UI when filtering objects using single-choice filters * [#7333](https://github.com/netbox-community/netbox/issues/7333) - Prevent inadvertent deletion of prior change records when deleting objects * [#7341](https://github.com/netbox-community/netbox/issues/7341) - Fix incorrect URL in circuit breadcrumbs +* [#7353](https://github.com/netbox-community/netbox/issues/7353) - Fix bulk creation of device/VM components via list view * [#7356](https://github.com/netbox-community/netbox/issues/7356) - Fix display of model documentation when adding device components * [#7360](https://github.com/netbox-community/netbox/issues/7360) - Correct redirection URL after removing child device from device bay diff --git a/netbox/templates/dcim/device_list.html b/netbox/templates/dcim/device_list.html index 0a6f1f051..177a0fb36 100644 --- a/netbox/templates/dcim/device_list.html +++ b/netbox/templates/dcim/device_list.html @@ -1,70 +1,69 @@ {% extends 'generic/object_list.html' %} {% block bulk_buttons %} - {% if perms.dcim.change_device %} - - {% endif %} + + {% endif %} + {% if perms.dcim.add_consoleserverport %} +
  • + +
  • + {% endif %} + {% if perms.dcim.add_powerport %} +
  • + +
  • + {% endif %} + {% if perms.dcim.add_poweroutlet %} +
  • + +
  • + {% endif %} + {% if perms.dcim.add_interface %} +
  • + +
  • + {% endif %} + {% if perms.dcim.add_rearport %} +
  • + +
  • + {% endif %} + {% if perms.dcim.add_devicebay %} +
  • + +
  • + {% endif %} + {% if perms.dcim.add_inventoryitem %} +
  • + +
  • + {% endif %} + +
    + {% endif %} {% endblock %} diff --git a/netbox/templates/virtualization/virtualmachine_list.html b/netbox/templates/virtualization/virtualmachine_list.html index 245e55092..90c784f31 100644 --- a/netbox/templates/virtualization/virtualmachine_list.html +++ b/netbox/templates/virtualization/virtualmachine_list.html @@ -1,14 +1,20 @@ {% extends 'generic/object_list.html' %} {% block bulk_buttons %} - {% if perms.virtualization.change_virtualmachine %} -
    - +
    - {% endif %} + + {% endif %} + +
    + {% endif %} {% endblock %} diff --git a/netbox/virtualization/views.py b/netbox/virtualization/views.py index a8b2b8f1f..2294d2c38 100644 --- a/netbox/virtualization/views.py +++ b/netbox/virtualization/views.py @@ -506,6 +506,7 @@ class VirtualMachineBulkAddInterfaceView(generic.BulkComponentCreateView): model_form = forms.VMInterfaceForm filterset = filtersets.VirtualMachineFilterSet table = tables.VirtualMachineTable + default_return_url = 'virtualization:virtualmachine_list' def get_required_permission(self): return f'virtualization.add_vminterface' From d87d860a57f0319142a0c5ca2adee09e3a88c51e Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Mon, 27 Sep 2021 11:41:05 -0400 Subject: [PATCH 17/37] Remove obsolete TS for a.formaction --- netbox/project-static/dist/netbox.js | Bin 322742 -> 322492 bytes netbox/project-static/dist/netbox.js.map | Bin 311063 -> 310799 bytes netbox/project-static/src/forms/actions.ts | 28 --------------------- netbox/project-static/src/forms/index.ts | 9 +------ 4 files changed, 1 insertion(+), 36 deletions(-) delete mode 100644 netbox/project-static/src/forms/actions.ts diff --git a/netbox/project-static/dist/netbox.js b/netbox/project-static/dist/netbox.js index 0b93ac20f577abe745b0007fbc1c7c9e549acf64..d4be77e327333507d80c1cb7ffe48fee86fc8dae 100644 GIT binary patch delta 23810 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 delta 24031 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 diff --git a/netbox/project-static/dist/netbox.js.map b/netbox/project-static/dist/netbox.js.map index 0faacc7df1d7ccaa9baefd54e3d77171eaccfc2e..aadc192730cc44bd310fce4829ca501a9b533bfe 100644 GIT binary patch delta 85 zcmbRKPpJQo(1s`G&Hv5Y|C=)cG1K<{=FG=>S#pw0owjc|#{7+&+0-*-`oz5~;tI}= gj?Oynj*i(*ATk$5dOA8Ay6Sj3`)z-AiP`WC033B80ssI2 delta 252 zcmeDGBQ*V=(1s`G?1{-GnfZCe%@@quFPJj|G1K-7=FGQx#mo~;opd4{9krcwTpb11;q3R5rI01j*f{S z!5n8DM@L6b9Z!%VUyuw?Q!tS0473Bt^oFPmc61B{azTPX?cO@!j)7hvnM`Mpbf@VPma&MlI+r<>IB#Efl=&Mszp-ZuNRJWN!5Ple#rLwPPXBO& OnVm6d`@M_IhHn7(element, 'form'); - const href = element.getAttribute('href'); - if (form !== null && isTruthy(href)) { - form.setAttribute('action', href); - form.submit(); - } - } -} - -/** - * Initialize bulk form action links. - */ -export function initFormActions(): void { - for (const element of getElements('a.formaction')) { - element.addEventListener('click', handleFormActionClick); - } -} diff --git a/netbox/project-static/src/forms/index.ts b/netbox/project-static/src/forms/index.ts index 2c409dd76..1ef8540fd 100644 --- a/netbox/project-static/src/forms/index.ts +++ b/netbox/project-static/src/forms/index.ts @@ -1,17 +1,10 @@ -import { initFormActions } from './actions'; import { initFormElements } from './elements'; import { initSpeedSelector } from './speedSelector'; import { initScopeSelector } from './scopeSelector'; import { initVlanTags } from './vlanTags'; export function initForms(): void { - for (const func of [ - initFormActions, - initFormElements, - initSpeedSelector, - initScopeSelector, - initVlanTags, - ]) { + for (const func of [initFormElements, initSpeedSelector, initScopeSelector, initVlanTags]) { func(); } } From c7523ffc67e32e6987463a3b747c6051a0988491 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Mon, 27 Sep 2021 12:28:52 -0400 Subject: [PATCH 18/37] Fixes #7358: Add missing choices column to custom field CSV import form --- docs/release-notes/version-3.0.md | 1 + netbox/extras/forms.py | 8 +++++++- netbox/extras/tests/test_views.py | 8 ++++---- netbox/templates/extras/customfield.html | 8 +++++++- 4 files changed, 19 insertions(+), 6 deletions(-) diff --git a/docs/release-notes/version-3.0.md b/docs/release-notes/version-3.0.md index ed8a722ec..9fcaa712d 100644 --- a/docs/release-notes/version-3.0.md +++ b/docs/release-notes/version-3.0.md @@ -17,6 +17,7 @@ * [#7341](https://github.com/netbox-community/netbox/issues/7341) - Fix incorrect URL in circuit breadcrumbs * [#7353](https://github.com/netbox-community/netbox/issues/7353) - Fix bulk creation of device/VM components via list view * [#7356](https://github.com/netbox-community/netbox/issues/7356) - Fix display of model documentation when adding device components +* [#7358](https://github.com/netbox-community/netbox/issues/7358) - Add missing `choices` column to custom field CSV import form * [#7360](https://github.com/netbox-community/netbox/issues/7360) - Correct redirection URL after removing child device from device bay ## v3.0.3 (2021-09-20) diff --git a/netbox/extras/forms.py b/netbox/extras/forms.py index 4a4f95213..fe98d9ca3 100644 --- a/netbox/extras/forms.py +++ b/netbox/extras/forms.py @@ -1,6 +1,7 @@ from django import forms from django.contrib.auth.models import User from django.contrib.contenttypes.models import ContentType +from django.contrib.postgres.forms import SimpleArrayField from django.utils.safestring import mark_safe from django.utils.translation import gettext as _ @@ -46,12 +47,17 @@ class CustomFieldCSVForm(CSVModelForm): limit_choices_to=FeatureQuery('custom_fields'), help_text="One or more assigned object types" ) + choices = SimpleArrayField( + base_field=forms.CharField(), + required=False, + help_text='Comma-separated list of field choices' + ) class Meta: model = CustomField fields = ( 'name', 'label', 'type', 'content_types', 'required', 'description', 'weight', 'filter_logic', 'default', - 'weight', + 'choices', 'weight', ) diff --git a/netbox/extras/tests/test_views.py b/netbox/extras/tests/test_views.py index 7f2aa41a8..72d965fd0 100644 --- a/netbox/extras/tests/test_views.py +++ b/netbox/extras/tests/test_views.py @@ -39,10 +39,10 @@ class CustomFieldTestCase(ViewTestCases.PrimaryObjectViewTestCase): } cls.csv_data = ( - "name,label,type,content_types,weight,filter_logic", - "field4,Field 4,text,dcim.site,100,exact", - "field5,Field 5,text,dcim.site,100,exact", - "field6,Field 6,text,dcim.site,100,exact", + 'name,label,type,content_types,weight,filter_logic,choices', + 'field4,Field 4,text,dcim.site,100,exact,', + 'field5,Field 5,integer,dcim.site,100,exact,', + 'field6,Field 6,select,dcim.site,100,exact,"A,B,C"', ) cls.bulk_edit_data = { diff --git a/netbox/templates/extras/customfield.html b/netbox/templates/extras/customfield.html index fd5576c2a..bf79059b8 100644 --- a/netbox/templates/extras/customfield.html +++ b/netbox/templates/extras/customfield.html @@ -56,7 +56,13 @@ Choices - {{ object.choices|placeholder }} + + {% if object.choices %} + {{ object.choices|join:", " }} + {% else %} + + {% endif %} + Filter Logic From b5aecfeb91261b1b68da31cdb6dfd8533d590df5 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Mon, 27 Sep 2021 15:23:12 -0400 Subject: [PATCH 19/37] Refactor circuits forms --- netbox/circuits/forms.py | 513 --------------------------- netbox/circuits/forms/__init__.py | 4 + netbox/circuits/forms/bulk_edit.py | 135 +++++++ netbox/circuits/forms/bulk_import.py | 77 ++++ netbox/circuits/forms/filtersets.py | 159 +++++++++ netbox/circuits/forms/models.py | 168 +++++++++ 6 files changed, 543 insertions(+), 513 deletions(-) delete mode 100644 netbox/circuits/forms.py create mode 100644 netbox/circuits/forms/__init__.py create mode 100644 netbox/circuits/forms/bulk_edit.py create mode 100644 netbox/circuits/forms/bulk_import.py create mode 100644 netbox/circuits/forms/filtersets.py create mode 100644 netbox/circuits/forms/models.py diff --git a/netbox/circuits/forms.py b/netbox/circuits/forms.py deleted file mode 100644 index f43a3cfff..000000000 --- a/netbox/circuits/forms.py +++ /dev/null @@ -1,513 +0,0 @@ -from django import forms -from django.utils.translation import gettext as _ - -from dcim.models import Region, Site, SiteGroup -from extras.forms import ( - AddRemoveTagsForm, CustomFieldModelBulkEditForm, CustomFieldModelFilterForm, CustomFieldModelForm, CustomFieldModelCSVForm, -) -from extras.models import Tag -from tenancy.forms import TenancyFilterForm, TenancyForm -from tenancy.models import Tenant -from utilities.forms import ( - add_blank_choice, BootstrapMixin, CommentField, CSVChoiceField, CSVModelChoiceField, DatePicker, - DynamicModelChoiceField, DynamicModelMultipleChoiceField, SelectSpeedWidget, SmallTextarea, SlugField, - StaticSelect, StaticSelectMultiple, TagFilterField, -) -from .choices import CircuitStatusChoices -from .models import * - - -# -# Providers -# - -class ProviderForm(BootstrapMixin, CustomFieldModelForm): - slug = SlugField() - comments = CommentField() - tags = DynamicModelMultipleChoiceField( - queryset=Tag.objects.all(), - required=False - ) - - class Meta: - model = Provider - fields = [ - 'name', 'slug', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'comments', 'tags', - ] - fieldsets = ( - ('Provider', ('name', 'slug', 'asn', 'tags')), - ('Support Info', ('account', 'portal_url', 'noc_contact', 'admin_contact')), - ) - widgets = { - 'noc_contact': SmallTextarea( - attrs={'rows': 5} - ), - 'admin_contact': SmallTextarea( - attrs={'rows': 5} - ), - } - help_texts = { - 'name': "Full name of the provider", - 'asn': "BGP autonomous system number (if applicable)", - 'portal_url': "URL of the provider's customer support portal", - 'noc_contact': "NOC email address and phone number", - 'admin_contact': "Administrative contact email address and phone number", - } - - -class ProviderCSVForm(CustomFieldModelCSVForm): - slug = SlugField() - - class Meta: - model = Provider - fields = ( - 'name', 'slug', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'comments', - ) - - -class ProviderBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm): - pk = forms.ModelMultipleChoiceField( - queryset=Provider.objects.all(), - widget=forms.MultipleHiddenInput - ) - asn = forms.IntegerField( - required=False, - label='ASN' - ) - account = forms.CharField( - max_length=30, - required=False, - label='Account number' - ) - portal_url = forms.URLField( - required=False, - label='Portal' - ) - noc_contact = forms.CharField( - required=False, - widget=SmallTextarea, - label='NOC contact' - ) - admin_contact = forms.CharField( - required=False, - widget=SmallTextarea, - label='Admin contact' - ) - comments = CommentField( - widget=SmallTextarea, - label='Comments' - ) - - class Meta: - nullable_fields = [ - 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'comments', - ] - - -class ProviderFilterForm(BootstrapMixin, CustomFieldModelFilterForm): - model = Provider - field_groups = [ - ['q', 'tag'], - ['region_id', 'site_group_id', 'site_id'], - ['asn'], - ] - q = forms.CharField( - required=False, - widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), - label=_('Search') - ) - region_id = DynamicModelMultipleChoiceField( - queryset=Region.objects.all(), - required=False, - label=_('Region'), - fetch_trigger='open' - ) - site_group_id = DynamicModelMultipleChoiceField( - queryset=SiteGroup.objects.all(), - required=False, - label=_('Site group'), - fetch_trigger='open' - ) - site_id = DynamicModelMultipleChoiceField( - queryset=Site.objects.all(), - required=False, - query_params={ - 'region_id': '$region_id', - 'site_group_id': '$site_group_id', - }, - label=_('Site'), - fetch_trigger='open' - ) - asn = forms.IntegerField( - required=False, - label=_('ASN') - ) - tag = TagFilterField(model) - - -# -# Provider networks -# - -class ProviderNetworkForm(BootstrapMixin, CustomFieldModelForm): - provider = DynamicModelChoiceField( - queryset=Provider.objects.all() - ) - comments = CommentField() - tags = DynamicModelMultipleChoiceField( - queryset=Tag.objects.all(), - required=False - ) - - class Meta: - model = ProviderNetwork - fields = [ - 'provider', 'name', 'description', 'comments', 'tags', - ] - fieldsets = ( - ('Provider Network', ('provider', 'name', 'description', 'tags')), - ) - - -class ProviderNetworkCSVForm(CustomFieldModelCSVForm): - provider = CSVModelChoiceField( - queryset=Provider.objects.all(), - to_field_name='name', - help_text='Assigned provider' - ) - - class Meta: - model = ProviderNetwork - fields = [ - 'provider', 'name', 'description', 'comments', - ] - - -class ProviderNetworkBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm): - pk = forms.ModelMultipleChoiceField( - queryset=ProviderNetwork.objects.all(), - widget=forms.MultipleHiddenInput - ) - provider = DynamicModelChoiceField( - queryset=Provider.objects.all(), - required=False - ) - description = forms.CharField( - max_length=100, - required=False - ) - comments = CommentField( - widget=SmallTextarea, - label='Comments' - ) - - class Meta: - nullable_fields = [ - 'description', 'comments', - ] - - -class ProviderNetworkFilterForm(BootstrapMixin, CustomFieldModelFilterForm): - model = ProviderNetwork - field_groups = ( - ('q', 'tag'), - ('provider_id',), - ) - q = forms.CharField( - required=False, - widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), - label=_('Search') - ) - provider_id = DynamicModelMultipleChoiceField( - queryset=Provider.objects.all(), - required=False, - label=_('Provider'), - fetch_trigger='open' - ) - tag = TagFilterField(model) - - -# -# Circuit types -# - -class CircuitTypeForm(BootstrapMixin, CustomFieldModelForm): - slug = SlugField() - - class Meta: - model = CircuitType - fields = [ - 'name', 'slug', 'description', - ] - - -class CircuitTypeBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm): - pk = forms.ModelMultipleChoiceField( - queryset=CircuitType.objects.all(), - widget=forms.MultipleHiddenInput - ) - description = forms.CharField( - max_length=200, - required=False - ) - - class Meta: - nullable_fields = ['description'] - - -class CircuitTypeCSVForm(CustomFieldModelCSVForm): - slug = SlugField() - - class Meta: - model = CircuitType - fields = ('name', 'slug', 'description') - help_texts = { - 'name': 'Name of circuit type', - } - - -class CircuitTypeFilterForm(BootstrapMixin, CustomFieldModelFilterForm): - model = CircuitType - field_groups = [ - ['q'], - ] - q = forms.CharField( - required=False, - widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), - label=_('Search') - ) - - -# -# Circuits -# - -class CircuitForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): - provider = DynamicModelChoiceField( - queryset=Provider.objects.all() - ) - type = DynamicModelChoiceField( - queryset=CircuitType.objects.all() - ) - comments = CommentField() - tags = DynamicModelMultipleChoiceField( - queryset=Tag.objects.all(), - required=False - ) - - class Meta: - model = Circuit - fields = [ - 'cid', 'type', 'provider', 'status', 'install_date', 'commit_rate', 'description', 'tenant_group', 'tenant', - 'comments', 'tags', - ] - fieldsets = ( - ('Circuit', ('provider', 'cid', 'type', 'status', 'install_date', 'commit_rate', 'description', 'tags')), - ('Tenancy', ('tenant_group', 'tenant')), - ) - help_texts = { - 'cid': "Unique circuit ID", - 'commit_rate': "Committed rate", - } - widgets = { - 'status': StaticSelect(), - 'install_date': DatePicker(), - 'commit_rate': SelectSpeedWidget(), - } - - -class CircuitCSVForm(CustomFieldModelCSVForm): - provider = CSVModelChoiceField( - queryset=Provider.objects.all(), - to_field_name='name', - help_text='Assigned provider' - ) - type = CSVModelChoiceField( - queryset=CircuitType.objects.all(), - to_field_name='name', - help_text='Type of circuit' - ) - status = CSVChoiceField( - choices=CircuitStatusChoices, - required=False, - help_text='Operational status' - ) - tenant = CSVModelChoiceField( - queryset=Tenant.objects.all(), - required=False, - to_field_name='name', - help_text='Assigned tenant' - ) - - class Meta: - model = Circuit - fields = [ - 'cid', 'provider', 'type', 'status', 'tenant', 'install_date', 'commit_rate', 'description', 'comments', - ] - - -class CircuitBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm): - pk = forms.ModelMultipleChoiceField( - queryset=Circuit.objects.all(), - widget=forms.MultipleHiddenInput - ) - type = DynamicModelChoiceField( - queryset=CircuitType.objects.all(), - required=False - ) - provider = DynamicModelChoiceField( - queryset=Provider.objects.all(), - required=False - ) - status = forms.ChoiceField( - choices=add_blank_choice(CircuitStatusChoices), - required=False, - initial='', - widget=StaticSelect() - ) - tenant = DynamicModelChoiceField( - queryset=Tenant.objects.all(), - required=False - ) - commit_rate = forms.IntegerField( - required=False, - label='Commit rate (Kbps)' - ) - description = forms.CharField( - max_length=100, - required=False - ) - comments = CommentField( - widget=SmallTextarea, - label='Comments' - ) - - class Meta: - nullable_fields = [ - 'tenant', 'commit_rate', 'description', 'comments', - ] - - -class CircuitFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilterForm): - model = Circuit - field_groups = [ - ['q', 'tag'], - ['provider_id', 'provider_network_id'], - ['type_id', 'status', 'commit_rate'], - ['region_id', 'site_group_id', 'site_id'], - ['tenant_group_id', 'tenant_id'], - ] - q = forms.CharField( - required=False, - widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), - label=_('Search') - ) - type_id = DynamicModelMultipleChoiceField( - queryset=CircuitType.objects.all(), - required=False, - label=_('Type'), - fetch_trigger='open' - ) - provider_id = DynamicModelMultipleChoiceField( - queryset=Provider.objects.all(), - required=False, - label=_('Provider'), - fetch_trigger='open' - ) - provider_network_id = DynamicModelMultipleChoiceField( - queryset=ProviderNetwork.objects.all(), - required=False, - query_params={ - 'provider_id': '$provider_id' - }, - label=_('Provider network'), - fetch_trigger='open' - ) - status = forms.MultipleChoiceField( - choices=CircuitStatusChoices, - required=False, - widget=StaticSelectMultiple() - ) - region_id = DynamicModelMultipleChoiceField( - queryset=Region.objects.all(), - required=False, - label=_('Region'), - fetch_trigger='open' - ) - site_group_id = DynamicModelMultipleChoiceField( - queryset=SiteGroup.objects.all(), - required=False, - label=_('Site group'), - fetch_trigger='open' - ) - site_id = DynamicModelMultipleChoiceField( - queryset=Site.objects.all(), - required=False, - query_params={ - 'region_id': '$region_id', - 'site_group_id': '$site_group_id', - }, - label=_('Site'), - fetch_trigger='open' - ) - commit_rate = forms.IntegerField( - required=False, - min_value=0, - label=_('Commit rate (Kbps)') - ) - tag = TagFilterField(model) - - -# -# Circuit terminations -# - -class CircuitTerminationForm(BootstrapMixin, forms.ModelForm): - region = DynamicModelChoiceField( - queryset=Region.objects.all(), - required=False, - initial_params={ - 'sites': '$site' - } - ) - site_group = DynamicModelChoiceField( - queryset=SiteGroup.objects.all(), - required=False, - initial_params={ - 'sites': '$site' - } - ) - site = DynamicModelChoiceField( - queryset=Site.objects.all(), - query_params={ - 'region_id': '$region', - 'group_id': '$site_group', - }, - required=False - ) - provider_network = DynamicModelChoiceField( - queryset=ProviderNetwork.objects.all(), - required=False - ) - - class Meta: - model = CircuitTermination - fields = [ - 'term_side', 'region', 'site_group', 'site', 'provider_network', 'mark_connected', 'port_speed', - 'upstream_speed', 'xconnect_id', 'pp_info', 'description', - ] - help_texts = { - 'port_speed': "Physical circuit speed", - 'xconnect_id': "ID of the local cross-connect", - 'pp_info': "Patch panel ID and port number(s)" - } - widgets = { - 'term_side': forms.HiddenInput(), - 'port_speed': SelectSpeedWidget(), - 'upstream_speed': SelectSpeedWidget(), - } - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - self.fields['provider_network'].widget.add_query_param('provider_id', self.instance.circuit.provider_id) diff --git a/netbox/circuits/forms/__init__.py b/netbox/circuits/forms/__init__.py new file mode 100644 index 000000000..5c23f833a --- /dev/null +++ b/netbox/circuits/forms/__init__.py @@ -0,0 +1,4 @@ +from .bulk_edit import * +from .bulk_import import * +from .filtersets import * +from .models import * diff --git a/netbox/circuits/forms/bulk_edit.py b/netbox/circuits/forms/bulk_edit.py new file mode 100644 index 000000000..638426a5e --- /dev/null +++ b/netbox/circuits/forms/bulk_edit.py @@ -0,0 +1,135 @@ +from django import forms + +from circuits.choices import CircuitStatusChoices +from circuits.models import * +from extras.forms import AddRemoveTagsForm, CustomFieldModelBulkEditForm +from tenancy.models import Tenant +from utilities.forms import ( + add_blank_choice, BootstrapMixin, CommentField, DynamicModelChoiceField, SmallTextarea, StaticSelect, +) + +__all__ = ( + 'CircuitBulkEditForm', + 'CircuitTypeBulkEditForm', + 'ProviderBulkEditForm', + 'ProviderNetworkBulkEditForm', +) + + +class ProviderBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm): + pk = forms.ModelMultipleChoiceField( + queryset=Provider.objects.all(), + widget=forms.MultipleHiddenInput + ) + asn = forms.IntegerField( + required=False, + label='ASN' + ) + account = forms.CharField( + max_length=30, + required=False, + label='Account number' + ) + portal_url = forms.URLField( + required=False, + label='Portal' + ) + noc_contact = forms.CharField( + required=False, + widget=SmallTextarea, + label='NOC contact' + ) + admin_contact = forms.CharField( + required=False, + widget=SmallTextarea, + label='Admin contact' + ) + comments = CommentField( + widget=SmallTextarea, + label='Comments' + ) + + class Meta: + nullable_fields = [ + 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'comments', + ] + + +class ProviderNetworkBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm): + pk = forms.ModelMultipleChoiceField( + queryset=ProviderNetwork.objects.all(), + widget=forms.MultipleHiddenInput + ) + provider = DynamicModelChoiceField( + queryset=Provider.objects.all(), + required=False + ) + description = forms.CharField( + max_length=100, + required=False + ) + comments = CommentField( + widget=SmallTextarea, + label='Comments' + ) + + class Meta: + nullable_fields = [ + 'description', 'comments', + ] + + +class CircuitTypeBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm): + pk = forms.ModelMultipleChoiceField( + queryset=CircuitType.objects.all(), + widget=forms.MultipleHiddenInput + ) + description = forms.CharField( + max_length=200, + required=False + ) + + class Meta: + nullable_fields = ['description'] + + +class CircuitBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm): + pk = forms.ModelMultipleChoiceField( + queryset=Circuit.objects.all(), + widget=forms.MultipleHiddenInput + ) + type = DynamicModelChoiceField( + queryset=CircuitType.objects.all(), + required=False + ) + provider = DynamicModelChoiceField( + queryset=Provider.objects.all(), + required=False + ) + status = forms.ChoiceField( + choices=add_blank_choice(CircuitStatusChoices), + required=False, + initial='', + widget=StaticSelect() + ) + tenant = DynamicModelChoiceField( + queryset=Tenant.objects.all(), + required=False + ) + commit_rate = forms.IntegerField( + required=False, + label='Commit rate (Kbps)' + ) + description = forms.CharField( + max_length=100, + required=False + ) + comments = CommentField( + widget=SmallTextarea, + label='Comments' + ) + + class Meta: + nullable_fields = [ + 'tenant', 'commit_rate', 'description', 'comments', + ] diff --git a/netbox/circuits/forms/bulk_import.py b/netbox/circuits/forms/bulk_import.py new file mode 100644 index 000000000..41ee7281a --- /dev/null +++ b/netbox/circuits/forms/bulk_import.py @@ -0,0 +1,77 @@ +from circuits.choices import CircuitStatusChoices +from circuits.models import * +from extras.forms import CustomFieldModelCSVForm +from tenancy.models import Tenant +from utilities.forms import CSVChoiceField, CSVModelChoiceField, SlugField + +__all__ = ( + 'CircuitCSVForm', + 'CircuitTypeCSVForm', + 'ProviderCSVForm', + 'ProviderNetworkCSVForm', +) + + +class ProviderCSVForm(CustomFieldModelCSVForm): + slug = SlugField() + + class Meta: + model = Provider + fields = ( + 'name', 'slug', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'comments', + ) + + +class ProviderNetworkCSVForm(CustomFieldModelCSVForm): + provider = CSVModelChoiceField( + queryset=Provider.objects.all(), + to_field_name='name', + help_text='Assigned provider' + ) + + class Meta: + model = ProviderNetwork + fields = [ + 'provider', 'name', 'description', 'comments', + ] + + +class CircuitTypeCSVForm(CustomFieldModelCSVForm): + slug = SlugField() + + class Meta: + model = CircuitType + fields = ('name', 'slug', 'description') + help_texts = { + 'name': 'Name of circuit type', + } + + +class CircuitCSVForm(CustomFieldModelCSVForm): + provider = CSVModelChoiceField( + queryset=Provider.objects.all(), + to_field_name='name', + help_text='Assigned provider' + ) + type = CSVModelChoiceField( + queryset=CircuitType.objects.all(), + to_field_name='name', + help_text='Type of circuit' + ) + status = CSVChoiceField( + choices=CircuitStatusChoices, + required=False, + help_text='Operational status' + ) + tenant = CSVModelChoiceField( + queryset=Tenant.objects.all(), + required=False, + to_field_name='name', + help_text='Assigned tenant' + ) + + class Meta: + model = Circuit + fields = [ + 'cid', 'provider', 'type', 'status', 'tenant', 'install_date', 'commit_rate', 'description', 'comments', + ] diff --git a/netbox/circuits/forms/filtersets.py b/netbox/circuits/forms/filtersets.py new file mode 100644 index 000000000..63b654148 --- /dev/null +++ b/netbox/circuits/forms/filtersets.py @@ -0,0 +1,159 @@ +from django import forms +from django.utils.translation import gettext as _ + +from circuits.choices import CircuitStatusChoices +from circuits.models import * +from dcim.models import Region, Site, SiteGroup +from extras.forms import CustomFieldModelFilterForm +from tenancy.forms import TenancyFilterForm +from utilities.forms import BootstrapMixin, DynamicModelMultipleChoiceField, StaticSelectMultiple, TagFilterField + +__all__ = ( + 'CircuitFilterForm', + 'CircuitTypeFilterForm', + 'ProviderFilterForm', + 'ProviderNetworkFilterForm', +) + + +class ProviderFilterForm(BootstrapMixin, CustomFieldModelFilterForm): + model = Provider + field_groups = [ + ['q', 'tag'], + ['region_id', 'site_group_id', 'site_id'], + ['asn'], + ] + q = forms.CharField( + required=False, + widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), + label=_('Search') + ) + region_id = DynamicModelMultipleChoiceField( + queryset=Region.objects.all(), + required=False, + label=_('Region'), + fetch_trigger='open' + ) + site_group_id = DynamicModelMultipleChoiceField( + queryset=SiteGroup.objects.all(), + required=False, + label=_('Site group'), + fetch_trigger='open' + ) + site_id = DynamicModelMultipleChoiceField( + queryset=Site.objects.all(), + required=False, + query_params={ + 'region_id': '$region_id', + 'site_group_id': '$site_group_id', + }, + label=_('Site'), + fetch_trigger='open' + ) + asn = forms.IntegerField( + required=False, + label=_('ASN') + ) + tag = TagFilterField(model) + + +class ProviderNetworkFilterForm(BootstrapMixin, CustomFieldModelFilterForm): + model = ProviderNetwork + field_groups = ( + ('q', 'tag'), + ('provider_id',), + ) + q = forms.CharField( + required=False, + widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), + label=_('Search') + ) + provider_id = DynamicModelMultipleChoiceField( + queryset=Provider.objects.all(), + required=False, + label=_('Provider'), + fetch_trigger='open' + ) + tag = TagFilterField(model) + + +class CircuitTypeFilterForm(BootstrapMixin, CustomFieldModelFilterForm): + model = CircuitType + field_groups = [ + ['q'], + ] + q = forms.CharField( + required=False, + widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), + label=_('Search') + ) + + +class CircuitFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilterForm): + model = Circuit + field_groups = [ + ['q', 'tag'], + ['provider_id', 'provider_network_id'], + ['type_id', 'status', 'commit_rate'], + ['region_id', 'site_group_id', 'site_id'], + ['tenant_group_id', 'tenant_id'], + ] + q = forms.CharField( + required=False, + widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), + label=_('Search') + ) + type_id = DynamicModelMultipleChoiceField( + queryset=CircuitType.objects.all(), + required=False, + label=_('Type'), + fetch_trigger='open' + ) + provider_id = DynamicModelMultipleChoiceField( + queryset=Provider.objects.all(), + required=False, + label=_('Provider'), + fetch_trigger='open' + ) + provider_network_id = DynamicModelMultipleChoiceField( + queryset=ProviderNetwork.objects.all(), + required=False, + query_params={ + 'provider_id': '$provider_id' + }, + label=_('Provider network'), + fetch_trigger='open' + ) + status = forms.MultipleChoiceField( + choices=CircuitStatusChoices, + required=False, + widget=StaticSelectMultiple() + ) + region_id = DynamicModelMultipleChoiceField( + queryset=Region.objects.all(), + required=False, + label=_('Region'), + fetch_trigger='open' + ) + site_group_id = DynamicModelMultipleChoiceField( + queryset=SiteGroup.objects.all(), + required=False, + label=_('Site group'), + fetch_trigger='open' + ) + site_id = DynamicModelMultipleChoiceField( + queryset=Site.objects.all(), + required=False, + query_params={ + 'region_id': '$region_id', + 'site_group_id': '$site_group_id', + }, + label=_('Site'), + fetch_trigger='open' + ) + commit_rate = forms.IntegerField( + required=False, + min_value=0, + label=_('Commit rate (Kbps)') + ) + tag = TagFilterField(model) diff --git a/netbox/circuits/forms/models.py b/netbox/circuits/forms/models.py new file mode 100644 index 000000000..659939293 --- /dev/null +++ b/netbox/circuits/forms/models.py @@ -0,0 +1,168 @@ +from django import forms + +from circuits.models import * +from dcim.models import Region, Site, SiteGroup +from extras.forms import CustomFieldModelForm +from extras.models import Tag +from tenancy.forms import TenancyForm +from utilities.forms import ( + BootstrapMixin, CommentField, DatePicker, DynamicModelChoiceField, DynamicModelMultipleChoiceField, + SelectSpeedWidget, SmallTextarea, SlugField, StaticSelect, +) + +__all__ = ( + 'CircuitForm', + 'CircuitTerminationForm', + 'CircuitTypeForm', + 'ProviderForm', + 'ProviderNetworkForm', +) + + +class ProviderForm(BootstrapMixin, CustomFieldModelForm): + slug = SlugField() + comments = CommentField() + tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), + required=False + ) + + class Meta: + model = Provider + fields = [ + 'name', 'slug', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'comments', 'tags', + ] + fieldsets = ( + ('Provider', ('name', 'slug', 'asn', 'tags')), + ('Support Info', ('account', 'portal_url', 'noc_contact', 'admin_contact')), + ) + widgets = { + 'noc_contact': SmallTextarea( + attrs={'rows': 5} + ), + 'admin_contact': SmallTextarea( + attrs={'rows': 5} + ), + } + help_texts = { + 'name': "Full name of the provider", + 'asn': "BGP autonomous system number (if applicable)", + 'portal_url': "URL of the provider's customer support portal", + 'noc_contact': "NOC email address and phone number", + 'admin_contact': "Administrative contact email address and phone number", + } + + +class ProviderNetworkForm(BootstrapMixin, CustomFieldModelForm): + provider = DynamicModelChoiceField( + queryset=Provider.objects.all() + ) + comments = CommentField() + tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), + required=False + ) + + class Meta: + model = ProviderNetwork + fields = [ + 'provider', 'name', 'description', 'comments', 'tags', + ] + fieldsets = ( + ('Provider Network', ('provider', 'name', 'description', 'tags')), + ) + + +class CircuitTypeForm(BootstrapMixin, CustomFieldModelForm): + slug = SlugField() + + class Meta: + model = CircuitType + fields = [ + 'name', 'slug', 'description', + ] + + +class CircuitForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): + provider = DynamicModelChoiceField( + queryset=Provider.objects.all() + ) + type = DynamicModelChoiceField( + queryset=CircuitType.objects.all() + ) + comments = CommentField() + tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), + required=False + ) + + class Meta: + model = Circuit + fields = [ + 'cid', 'type', 'provider', 'status', 'install_date', 'commit_rate', 'description', 'tenant_group', 'tenant', + 'comments', 'tags', + ] + fieldsets = ( + ('Circuit', ('provider', 'cid', 'type', 'status', 'install_date', 'commit_rate', 'description', 'tags')), + ('Tenancy', ('tenant_group', 'tenant')), + ) + help_texts = { + 'cid': "Unique circuit ID", + 'commit_rate': "Committed rate", + } + widgets = { + 'status': StaticSelect(), + 'install_date': DatePicker(), + 'commit_rate': SelectSpeedWidget(), + } + + +class CircuitTerminationForm(BootstrapMixin, forms.ModelForm): + region = DynamicModelChoiceField( + queryset=Region.objects.all(), + required=False, + initial_params={ + 'sites': '$site' + } + ) + site_group = DynamicModelChoiceField( + queryset=SiteGroup.objects.all(), + required=False, + initial_params={ + 'sites': '$site' + } + ) + site = DynamicModelChoiceField( + queryset=Site.objects.all(), + query_params={ + 'region_id': '$region', + 'group_id': '$site_group', + }, + required=False + ) + provider_network = DynamicModelChoiceField( + queryset=ProviderNetwork.objects.all(), + required=False + ) + + class Meta: + model = CircuitTermination + fields = [ + 'term_side', 'region', 'site_group', 'site', 'provider_network', 'mark_connected', 'port_speed', + 'upstream_speed', 'xconnect_id', 'pp_info', 'description', + ] + help_texts = { + 'port_speed': "Physical circuit speed", + 'xconnect_id': "ID of the local cross-connect", + 'pp_info': "Patch panel ID and port number(s)" + } + widgets = { + 'term_side': forms.HiddenInput(), + 'port_speed': SelectSpeedWidget(), + 'upstream_speed': SelectSpeedWidget(), + } + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + self.fields['provider_network'].widget.add_query_param('provider_id', self.instance.circuit.provider_id) From 9e2364b246fd059c915ecea3e8aa426454166bc4 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Mon, 27 Sep 2021 17:00:13 -0400 Subject: [PATCH 20/37] Refactor DCIM forms --- netbox/dcim/forms.py | 5533 ---------------------------- netbox/dcim/forms/__init__.py | 10 + netbox/dcim/forms/bulk_create.py | 111 + netbox/dcim/forms/bulk_edit.py | 1090 ++++++ netbox/dcim/forms/bulk_import.py | 976 +++++ netbox/dcim/forms/common.py | 49 + netbox/dcim/forms/connections.py | 289 ++ netbox/dcim/forms/fields.py | 25 + netbox/dcim/forms/filtersets.py | 1143 ++++++ netbox/dcim/forms/formsets.py | 21 + netbox/dcim/forms/models.py | 1232 +++++++ netbox/dcim/forms/object_create.py | 614 +++ netbox/dcim/forms/object_import.py | 148 + netbox/dcim/tests/test_forms.py | 1 + netbox/dcim/views.py | 3 +- netbox/virtualization/forms.py | 10 +- 16 files changed, 5718 insertions(+), 5537 deletions(-) delete mode 100644 netbox/dcim/forms.py create mode 100644 netbox/dcim/forms/__init__.py create mode 100644 netbox/dcim/forms/bulk_create.py create mode 100644 netbox/dcim/forms/bulk_edit.py create mode 100644 netbox/dcim/forms/bulk_import.py create mode 100644 netbox/dcim/forms/common.py create mode 100644 netbox/dcim/forms/connections.py create mode 100644 netbox/dcim/forms/fields.py create mode 100644 netbox/dcim/forms/filtersets.py create mode 100644 netbox/dcim/forms/formsets.py create mode 100644 netbox/dcim/forms/models.py create mode 100644 netbox/dcim/forms/object_create.py create mode 100644 netbox/dcim/forms/object_import.py diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py deleted file mode 100644 index 233d45220..000000000 --- a/netbox/dcim/forms.py +++ /dev/null @@ -1,5533 +0,0 @@ -import re - -from django import forms -from django.contrib.auth.models import User -from django.contrib.contenttypes.models import ContentType -from django.contrib.postgres.forms.array import SimpleArrayField -from django.core.exceptions import ObjectDoesNotExist -from django.utils.safestring import mark_safe -from django.utils.translation import gettext as _ -from netaddr import EUI -from netaddr.core import AddrFormatError -from timezone_field import TimeZoneFormField - -from circuits.models import Circuit, CircuitTermination, Provider -from extras.forms import ( - AddRemoveTagsForm, CustomFieldModelBulkEditForm, CustomFieldModelCSVForm, CustomFieldModelFilterForm, CustomFieldModelForm, - CustomFieldsMixin, LocalConfigContextFilterForm, -) -from extras.models import Tag -from ipam.constants import BGP_ASN_MAX, BGP_ASN_MIN -from ipam.models import IPAddress, VLAN, VLANGroup -from tenancy.forms import TenancyFilterForm, TenancyForm -from tenancy.models import Tenant -from utilities.forms import ( - APISelect, APISelectMultiple, add_blank_choice, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect, - ClearableFileInput, ColorField, CommentField, CSVChoiceField, CSVContentTypeField, CSVModelChoiceField, - CSVTypedChoiceField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, ExpandableNameField, form_from_model, - JSONField, NumericArrayField, SelectWithPK, SmallTextarea, SlugField, StaticSelect, StaticSelectMultiple, - TagFilterField, BOOLEAN_WITH_BLANK_CHOICES, -) -from virtualization.models import Cluster, ClusterGroup -from .choices import * -from .constants import * -from .models import * - -DEVICE_BY_PK_RE = r'{\d+\}' - -INTERFACE_MODE_HELP_TEXT = """ -Access: One untagged VLAN
    -Tagged: One untagged VLAN and/or one or more tagged VLANs
    -Tagged (All): Implies all VLANs are available (w/optional untagged VLAN) -""" - - -def get_device_by_name_or_pk(name): - """ - Attempt to retrieve a device by either its name or primary key ('{pk}'). - """ - if re.match(DEVICE_BY_PK_RE, name): - pk = name.strip('{}') - device = Device.objects.get(pk=pk) - else: - device = Device.objects.get(name=name) - return device - - -class DeviceComponentFilterForm(BootstrapMixin, CustomFieldModelFilterForm): - field_order = [ - 'q', 'name', 'label', 'region_id', 'site_group_id', 'site_id', - ] - q = forms.CharField( - required=False, - widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), - label=_('Search') - ) - name = forms.CharField( - required=False - ) - label = forms.CharField( - required=False - ) - region_id = DynamicModelMultipleChoiceField( - queryset=Region.objects.all(), - required=False, - label=_('Region'), - fetch_trigger='open' - ) - site_group_id = DynamicModelMultipleChoiceField( - queryset=SiteGroup.objects.all(), - required=False, - label=_('Site group'), - fetch_trigger='open' - ) - site_id = DynamicModelMultipleChoiceField( - queryset=Site.objects.all(), - required=False, - query_params={ - 'region_id': '$region_id', - 'group_id': '$site_group_id', - }, - label=_('Site'), - fetch_trigger='open' - ) - location_id = DynamicModelMultipleChoiceField( - queryset=Location.objects.all(), - required=False, - query_params={ - 'site_id': '$site_id', - }, - label=_('Location'), - fetch_trigger='open' - ) - device_id = DynamicModelMultipleChoiceField( - queryset=Device.objects.all(), - required=False, - query_params={ - 'site_id': '$site_id', - 'location_id': '$location_id', - }, - label=_('Device'), - fetch_trigger='open' - ) - - -class InterfaceCommonForm(forms.Form): - mac_address = forms.CharField( - empty_value=None, - required=False, - label='MAC address' - ) - mtu = forms.IntegerField( - required=False, - min_value=INTERFACE_MTU_MIN, - max_value=INTERFACE_MTU_MAX, - label='MTU' - ) - - def clean(self): - super().clean() - - parent_field = 'device' if 'device' in self.cleaned_data else 'virtual_machine' - tagged_vlans = self.cleaned_data.get('tagged_vlans') - - # Untagged interfaces cannot be assigned tagged VLANs - if self.cleaned_data['mode'] == InterfaceModeChoices.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'] == InterfaceModeChoices.MODE_TAGGED_ALL: - self.cleaned_data['tagged_vlans'] = [] - - # Validate tagged VLANs; must be a global VLAN or in the same site - elif self.cleaned_data['mode'] == InterfaceModeChoices.MODE_TAGGED and tagged_vlans: - valid_sites = [None, self.cleaned_data[parent_field].site] - invalid_vlans = [str(v) for v in tagged_vlans if v.site not in valid_sites] - - if invalid_vlans: - raise forms.ValidationError({ - 'tagged_vlans': f"The tagged VLANs ({', '.join(invalid_vlans)}) must belong to the same site as " - f"the interface's parent device/VM, or they must be global" - }) - - -class ComponentForm(forms.Form): - """ - Subclass this form when facilitating the creation of one or more device component or component templates based on - a name pattern. - """ - name_pattern = ExpandableNameField( - label='Name' - ) - label_pattern = ExpandableNameField( - label='Label', - required=False, - help_text='Alphanumeric ranges are supported. (Must match the number of names being created.)' - ) - - def clean(self): - super().clean() - - # Validate that the number of components being created from both the name_pattern and label_pattern are equal - if self.cleaned_data['label_pattern']: - name_pattern_count = len(self.cleaned_data['name_pattern']) - label_pattern_count = len(self.cleaned_data['label_pattern']) - if name_pattern_count != label_pattern_count: - raise forms.ValidationError({ - 'label_pattern': f'The provided name pattern will create {name_pattern_count} components, however ' - f'{label_pattern_count} labels will be generated. These counts must match.' - }, code='label_pattern_mismatch') - - -# -# 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 -# - -class RegionForm(BootstrapMixin, CustomFieldModelForm): - parent = DynamicModelChoiceField( - queryset=Region.objects.all(), - required=False - ) - slug = SlugField() - - class Meta: - model = Region - fields = ( - 'parent', 'name', 'slug', 'description', - ) - - -class RegionCSVForm(CustomFieldModelCSVForm): - parent = CSVModelChoiceField( - queryset=Region.objects.all(), - required=False, - to_field_name='name', - help_text='Name of parent region' - ) - - class Meta: - model = Region - fields = ('name', 'slug', 'parent', 'description') - - -class RegionBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm): - pk = forms.ModelMultipleChoiceField( - queryset=Region.objects.all(), - widget=forms.MultipleHiddenInput - ) - parent = DynamicModelChoiceField( - queryset=Region.objects.all(), - required=False - ) - description = forms.CharField( - max_length=200, - required=False - ) - - class Meta: - nullable_fields = ['parent', 'description'] - - -class RegionFilterForm(BootstrapMixin, CustomFieldModelFilterForm): - model = Region - field_groups = [ - ['q'], - ['parent_id'], - ] - q = forms.CharField( - required=False, - widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), - label=_('Search') - ) - parent_id = DynamicModelMultipleChoiceField( - queryset=Region.objects.all(), - required=False, - label=_('Parent region'), - fetch_trigger='open' - ) - - -# -# Site groups -# - -class SiteGroupForm(BootstrapMixin, CustomFieldModelForm): - parent = DynamicModelChoiceField( - queryset=SiteGroup.objects.all(), - required=False - ) - slug = SlugField() - - class Meta: - model = SiteGroup - fields = ( - 'parent', 'name', 'slug', 'description', - ) - - -class SiteGroupCSVForm(CustomFieldModelCSVForm): - parent = CSVModelChoiceField( - queryset=SiteGroup.objects.all(), - required=False, - to_field_name='name', - help_text='Name of parent site group' - ) - - class Meta: - model = SiteGroup - fields = ('name', 'slug', 'parent', 'description') - - -class SiteGroupBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm): - pk = forms.ModelMultipleChoiceField( - queryset=SiteGroup.objects.all(), - widget=forms.MultipleHiddenInput - ) - parent = DynamicModelChoiceField( - queryset=SiteGroup.objects.all(), - required=False - ) - description = forms.CharField( - max_length=200, - required=False - ) - - class Meta: - nullable_fields = ['parent', 'description'] - - -class SiteGroupFilterForm(BootstrapMixin, CustomFieldModelFilterForm): - model = SiteGroup - field_groups = [ - ['q'], - ['parent_id'], - ] - q = forms.CharField( - required=False, - widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), - label=_('Search') - ) - parent_id = DynamicModelMultipleChoiceField( - queryset=SiteGroup.objects.all(), - required=False, - label=_('Parent group'), - fetch_trigger='open' - ) - - -# -# Sites -# - -class SiteForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): - region = DynamicModelChoiceField( - queryset=Region.objects.all(), - required=False - ) - group = DynamicModelChoiceField( - queryset=SiteGroup.objects.all(), - required=False - ) - slug = SlugField() - time_zone = TimeZoneFormField( - choices=add_blank_choice(TimeZoneFormField().choices), - required=False, - widget=StaticSelect() - ) - comments = CommentField() - tags = DynamicModelMultipleChoiceField( - queryset=Tag.objects.all(), - required=False - ) - - class Meta: - model = Site - fields = [ - 'name', 'slug', 'status', 'region', 'group', 'tenant_group', 'tenant', 'facility', 'asn', 'time_zone', - 'description', 'physical_address', 'shipping_address', 'latitude', 'longitude', 'contact_name', - 'contact_phone', 'contact_email', 'comments', 'tags', - ] - fieldsets = ( - ('Site', ( - 'name', 'slug', 'status', 'region', 'group', 'facility', 'asn', 'time_zone', 'description', 'tags', - )), - ('Tenancy', ('tenant_group', 'tenant')), - ('Contact Info', ( - 'physical_address', 'shipping_address', 'latitude', 'longitude', 'contact_name', 'contact_phone', - 'contact_email', - )), - ) - widgets = { - 'physical_address': SmallTextarea( - attrs={ - 'rows': 3, - } - ), - 'shipping_address': SmallTextarea( - attrs={ - 'rows': 3, - } - ), - 'status': StaticSelect(), - 'time_zone': StaticSelect(), - } - help_texts = { - 'name': "Full name of the site", - 'facility': "Data center provider and facility (e.g. Equinix NY7)", - 'asn': "BGP autonomous system number", - 'time_zone': "Local time zone", - 'description': "Short description (will appear in sites list)", - 'physical_address': "Physical location of the building (e.g. for GPS)", - 'shipping_address': "If different from the physical address", - 'latitude': "Latitude in decimal format (xx.yyyyyy)", - 'longitude': "Longitude in decimal format (xx.yyyyyy)" - } - - -class SiteCSVForm(CustomFieldModelCSVForm): - status = CSVChoiceField( - choices=SiteStatusChoices, - required=False, - help_text='Operational status' - ) - region = CSVModelChoiceField( - queryset=Region.objects.all(), - required=False, - to_field_name='name', - help_text='Assigned region' - ) - group = CSVModelChoiceField( - queryset=SiteGroup.objects.all(), - required=False, - to_field_name='name', - help_text='Assigned group' - ) - tenant = CSVModelChoiceField( - queryset=Tenant.objects.all(), - required=False, - to_field_name='name', - help_text='Assigned tenant' - ) - - class Meta: - model = Site - fields = ( - 'name', 'slug', 'status', 'region', 'group', 'tenant', 'facility', 'asn', 'time_zone', 'description', - 'physical_address', 'shipping_address', 'latitude', 'longitude', 'contact_name', 'contact_phone', - 'contact_email', 'comments', - ) - help_texts = { - 'time_zone': mark_safe( - 'Time zone (available options)' - ) - } - - -class SiteBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm): - pk = forms.ModelMultipleChoiceField( - queryset=Site.objects.all(), - widget=forms.MultipleHiddenInput - ) - status = forms.ChoiceField( - choices=add_blank_choice(SiteStatusChoices), - required=False, - initial='', - widget=StaticSelect() - ) - region = DynamicModelChoiceField( - queryset=Region.objects.all(), - required=False - ) - group = DynamicModelChoiceField( - queryset=SiteGroup.objects.all(), - required=False - ) - tenant = DynamicModelChoiceField( - queryset=Tenant.objects.all(), - required=False - ) - asn = forms.IntegerField( - min_value=BGP_ASN_MIN, - max_value=BGP_ASN_MAX, - required=False, - label='ASN' - ) - description = forms.CharField( - max_length=100, - required=False - ) - time_zone = TimeZoneFormField( - choices=add_blank_choice(TimeZoneFormField().choices), - required=False, - widget=StaticSelect() - ) - - class Meta: - nullable_fields = [ - 'region', 'group', 'tenant', 'asn', 'description', 'time_zone', - ] - - -class SiteFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilterForm): - model = Site - field_order = ['q', 'status', 'region_id', 'tenant_group_id', 'tenant_id'] - field_groups = [ - ['q', 'tag'], - ['status', 'region_id', 'group_id'], - ['tenant_group_id', 'tenant_id'], - ] - q = forms.CharField( - required=False, - widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), - label=_('Search') - ) - status = forms.MultipleChoiceField( - choices=SiteStatusChoices, - required=False, - widget=StaticSelectMultiple(), - ) - region_id = DynamicModelMultipleChoiceField( - queryset=Region.objects.all(), - required=False, - label=_('Region'), - fetch_trigger='open' - ) - group_id = DynamicModelMultipleChoiceField( - queryset=SiteGroup.objects.all(), - required=False, - label=_('Site group'), - fetch_trigger='open' - ) - tag = TagFilterField(model) - - -# -# Locations -# - -class LocationForm(BootstrapMixin, CustomFieldModelForm): - region = DynamicModelChoiceField( - queryset=Region.objects.all(), - required=False, - initial_params={ - 'sites': '$site' - } - ) - site_group = DynamicModelChoiceField( - queryset=SiteGroup.objects.all(), - required=False, - initial_params={ - 'sites': '$site' - } - ) - site = DynamicModelChoiceField( - queryset=Site.objects.all(), - query_params={ - 'region_id': '$region', - 'group_id': '$site_group', - } - ) - parent = DynamicModelChoiceField( - queryset=Location.objects.all(), - required=False, - query_params={ - 'site_id': '$site' - } - ) - slug = SlugField() - - class Meta: - model = Location - fields = ( - 'region', 'site_group', 'site', 'parent', 'name', 'slug', 'description', - ) - - -class LocationCSVForm(CustomFieldModelCSVForm): - site = CSVModelChoiceField( - queryset=Site.objects.all(), - to_field_name='name', - help_text='Assigned site' - ) - parent = CSVModelChoiceField( - queryset=Location.objects.all(), - required=False, - to_field_name='name', - help_text='Parent location', - error_messages={ - 'invalid_choice': 'Location not found.', - } - ) - - class Meta: - model = Location - fields = ('site', 'parent', 'name', 'slug', 'description') - - -class LocationBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm): - pk = forms.ModelMultipleChoiceField( - queryset=Location.objects.all(), - widget=forms.MultipleHiddenInput - ) - site = DynamicModelChoiceField( - queryset=Site.objects.all(), - required=False - ) - parent = DynamicModelChoiceField( - queryset=Location.objects.all(), - required=False, - query_params={ - 'site_id': '$site' - } - ) - description = forms.CharField( - max_length=200, - required=False - ) - - class Meta: - nullable_fields = ['parent', 'description'] - - -class LocationFilterForm(BootstrapMixin, CustomFieldModelFilterForm): - model = Location - q = forms.CharField( - required=False, - widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), - label=_('Search') - ) - region_id = DynamicModelMultipleChoiceField( - queryset=Region.objects.all(), - required=False, - label=_('Region'), - fetch_trigger='open' - ) - site_group_id = DynamicModelMultipleChoiceField( - queryset=SiteGroup.objects.all(), - required=False, - label=_('Site group'), - fetch_trigger='open' - ) - site_id = DynamicModelMultipleChoiceField( - queryset=Site.objects.all(), - required=False, - query_params={ - 'region_id': '$region_id', - 'group_id': '$site_group_id', - }, - label=_('Site'), - fetch_trigger='open' - ) - parent_id = DynamicModelMultipleChoiceField( - queryset=Location.objects.all(), - required=False, - query_params={ - 'region_id': '$region_id', - 'site_id': '$site_id', - }, - label=_('Parent'), - fetch_trigger='open' - ) - - -# -# Rack roles -# - -class RackRoleForm(BootstrapMixin, CustomFieldModelForm): - slug = SlugField() - - class Meta: - model = RackRole - fields = [ - 'name', 'slug', 'color', 'description', - ] - - -class RackRoleCSVForm(CustomFieldModelCSVForm): - slug = SlugField() - - class Meta: - model = RackRole - fields = ('name', 'slug', 'color', 'description') - help_texts = { - 'color': mark_safe('RGB color in hexadecimal (e.g. 00ff00)'), - } - - -class RackRoleBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm): - pk = forms.ModelMultipleChoiceField( - queryset=RackRole.objects.all(), - widget=forms.MultipleHiddenInput - ) - color = ColorField( - required=False - ) - description = forms.CharField( - max_length=200, - required=False - ) - - class Meta: - nullable_fields = ['color', 'description'] - - -class RackRoleFilterForm(BootstrapMixin, CustomFieldModelFilterForm): - model = RackRole - field_groups = [ - ['q'], - ] - q = forms.CharField( - required=False, - widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), - label=_('Search') - ) - - -# -# Racks -# - -class RackForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): - region = DynamicModelChoiceField( - queryset=Region.objects.all(), - required=False, - initial_params={ - 'sites': '$site' - } - ) - site_group = DynamicModelChoiceField( - queryset=SiteGroup.objects.all(), - required=False, - initial_params={ - 'sites': '$site' - } - ) - site = DynamicModelChoiceField( - queryset=Site.objects.all(), - query_params={ - 'region_id': '$region', - 'group_id': '$site_group', - } - ) - location = DynamicModelChoiceField( - queryset=Location.objects.all(), - required=False, - query_params={ - 'site_id': '$site' - } - ) - role = DynamicModelChoiceField( - queryset=RackRole.objects.all(), - required=False - ) - comments = CommentField() - tags = DynamicModelMultipleChoiceField( - queryset=Tag.objects.all(), - required=False - ) - - class Meta: - model = Rack - fields = [ - 'region', 'site_group', 'site', 'location', 'name', 'facility_id', 'tenant_group', 'tenant', 'status', - 'role', 'serial', 'asset_tag', 'type', 'width', 'u_height', 'desc_units', 'outer_width', 'outer_depth', - 'outer_unit', 'comments', 'tags', - ] - help_texts = { - 'site': "The site at which the rack exists", - 'name': "Organizational rack name", - 'facility_id': "The unique rack ID assigned by the facility", - 'u_height': "Height in rack units", - } - widgets = { - 'status': StaticSelect(), - 'type': StaticSelect(), - 'width': StaticSelect(), - 'outer_unit': StaticSelect(), - } - - -class RackCSVForm(CustomFieldModelCSVForm): - site = CSVModelChoiceField( - queryset=Site.objects.all(), - to_field_name='name' - ) - location = CSVModelChoiceField( - queryset=Location.objects.all(), - required=False, - to_field_name='name' - ) - tenant = CSVModelChoiceField( - queryset=Tenant.objects.all(), - required=False, - to_field_name='name', - help_text='Name of assigned tenant' - ) - status = CSVChoiceField( - choices=RackStatusChoices, - required=False, - help_text='Operational status' - ) - role = CSVModelChoiceField( - queryset=RackRole.objects.all(), - required=False, - to_field_name='name', - help_text='Name of assigned role' - ) - type = CSVChoiceField( - choices=RackTypeChoices, - required=False, - help_text='Rack type' - ) - width = forms.ChoiceField( - choices=RackWidthChoices, - help_text='Rail-to-rail width (in inches)' - ) - outer_unit = CSVChoiceField( - choices=RackDimensionUnitChoices, - required=False, - help_text='Unit for outer dimensions' - ) - - class Meta: - model = Rack - fields = ( - 'site', 'location', 'name', 'facility_id', 'tenant', 'status', 'role', 'type', 'serial', 'asset_tag', - 'width', 'u_height', 'desc_units', 'outer_width', 'outer_depth', 'outer_unit', 'comments', - ) - - def __init__(self, data=None, *args, **kwargs): - super().__init__(data, *args, **kwargs) - - if data: - - # Limit location queryset by assigned site - params = {f"site__{self.fields['site'].to_field_name}": data.get('site')} - self.fields['location'].queryset = self.fields['location'].queryset.filter(**params) - - -class RackBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm): - pk = forms.ModelMultipleChoiceField( - queryset=Rack.objects.all(), - widget=forms.MultipleHiddenInput - ) - region = DynamicModelChoiceField( - queryset=Region.objects.all(), - required=False, - initial_params={ - 'sites': '$site' - } - ) - site_group = DynamicModelChoiceField( - queryset=SiteGroup.objects.all(), - required=False, - initial_params={ - 'sites': '$site' - } - ) - site = DynamicModelChoiceField( - queryset=Site.objects.all(), - required=False, - query_params={ - 'region_id': '$region', - 'group_id': '$site_group', - } - ) - location = DynamicModelChoiceField( - queryset=Location.objects.all(), - required=False, - query_params={ - 'site_id': '$site' - } - ) - tenant = DynamicModelChoiceField( - queryset=Tenant.objects.all(), - required=False - ) - status = forms.ChoiceField( - choices=add_blank_choice(RackStatusChoices), - required=False, - initial='', - widget=StaticSelect() - ) - role = DynamicModelChoiceField( - queryset=RackRole.objects.all(), - required=False - ) - serial = forms.CharField( - max_length=50, - required=False, - label='Serial Number' - ) - asset_tag = forms.CharField( - max_length=50, - required=False - ) - type = forms.ChoiceField( - choices=add_blank_choice(RackTypeChoices), - required=False, - widget=StaticSelect() - ) - width = forms.ChoiceField( - choices=add_blank_choice(RackWidthChoices), - required=False, - widget=StaticSelect() - ) - u_height = forms.IntegerField( - required=False, - label='Height (U)' - ) - desc_units = forms.NullBooleanField( - required=False, - widget=BulkEditNullBooleanSelect, - label='Descending units' - ) - outer_width = forms.IntegerField( - required=False, - min_value=1 - ) - outer_depth = forms.IntegerField( - required=False, - min_value=1 - ) - outer_unit = forms.ChoiceField( - choices=add_blank_choice(RackDimensionUnitChoices), - required=False, - widget=StaticSelect() - ) - comments = CommentField( - widget=SmallTextarea, - label='Comments' - ) - - class Meta: - nullable_fields = [ - 'location', 'tenant', 'role', 'serial', 'asset_tag', 'outer_width', 'outer_depth', 'outer_unit', 'comments', - ] - - -class RackFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilterForm): - model = Rack - field_order = ['q', 'region_id', 'site_id', 'location_id', 'status', 'role_id', 'tenant_group_id', 'tenant_id'] - field_groups = [ - ['q', 'tag'], - ['region_id', 'site_id', 'location_id'], - ['status', 'role_id'], - ['type', 'width', 'serial', 'asset_tag'], - ['tenant_group_id', 'tenant_id'], - ] - q = forms.CharField( - required=False, - widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), - label=_('Search') - ) - region_id = DynamicModelMultipleChoiceField( - queryset=Region.objects.all(), - required=False, - label=_('Region'), - fetch_trigger='open' - ) - site_id = DynamicModelMultipleChoiceField( - queryset=Site.objects.all(), - required=False, - query_params={ - 'region_id': '$region_id' - }, - label=_('Site'), - fetch_trigger='open' - ) - location_id = DynamicModelMultipleChoiceField( - queryset=Location.objects.all(), - required=False, - null_option='None', - query_params={ - 'site_id': '$site_id' - }, - label=_('Location'), - fetch_trigger='open' - ) - status = forms.MultipleChoiceField( - choices=RackStatusChoices, - required=False, - widget=StaticSelectMultiple() - ) - type = forms.MultipleChoiceField( - choices=RackTypeChoices, - required=False, - widget=StaticSelectMultiple() - ) - width = forms.MultipleChoiceField( - choices=RackWidthChoices, - required=False, - widget=StaticSelectMultiple() - ) - role_id = DynamicModelMultipleChoiceField( - queryset=RackRole.objects.all(), - required=False, - null_option='None', - label=_('Role'), - fetch_trigger='open' - ) - serial = forms.CharField( - required=False - ) - asset_tag = forms.CharField( - required=False - ) - tag = TagFilterField(model) - - -# -# Rack elevations -# - -class RackElevationFilterForm(RackFilterForm): - field_order = [ - 'q', 'region_id', 'site_id', 'location_id', 'id', 'status', 'role_id', 'tenant_group_id', - 'tenant_id', - ] - id = DynamicModelMultipleChoiceField( - queryset=Rack.objects.all(), - label=_('Rack'), - required=False, - query_params={ - 'site_id': '$site_id', - 'location_id': '$location_id', - }, - fetch_trigger='open' - ) - - -# -# Rack reservations -# - -class RackReservationForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): - region = DynamicModelChoiceField( - queryset=Region.objects.all(), - required=False, - initial_params={ - 'sites': '$site' - }, - fetch_trigger='open' - ) - site_group = DynamicModelChoiceField( - queryset=SiteGroup.objects.all(), - required=False, - initial_params={ - 'sites': '$site' - }, - fetch_trigger='open' - ) - site = DynamicModelChoiceField( - queryset=Site.objects.all(), - required=False, - query_params={ - 'region_id': '$region', - 'group_id': '$site_group', - }, - fetch_trigger='open' - ) - location = DynamicModelChoiceField( - queryset=Location.objects.all(), - required=False, - query_params={ - 'site_id': '$site' - }, - fetch_trigger='open' - ) - rack = DynamicModelChoiceField( - queryset=Rack.objects.all(), - query_params={ - 'site_id': '$site', - 'location_id': '$location', - }, - fetch_trigger='open' - ) - units = NumericArrayField( - base_field=forms.IntegerField(), - help_text="Comma-separated list of numeric unit IDs. A range may be specified using a hyphen." - ) - user = forms.ModelChoiceField( - queryset=User.objects.order_by( - 'username' - ), - widget=StaticSelect() - ) - tags = DynamicModelMultipleChoiceField( - queryset=Tag.objects.all(), - required=False, - fetch_trigger='open' - ) - - class Meta: - model = RackReservation - fields = [ - 'region', 'site_group', 'site', 'location', 'rack', 'units', 'user', 'tenant_group', 'tenant', - 'description', 'tags', - ] - fieldsets = ( - ('Reservation', ('region', 'site', 'location', 'rack', 'units', 'user', 'description', 'tags')), - ('Tenancy', ('tenant_group', 'tenant')), - ) - - -class RackReservationCSVForm(CustomFieldModelCSVForm): - site = CSVModelChoiceField( - queryset=Site.objects.all(), - to_field_name='name', - help_text='Parent site' - ) - location = CSVModelChoiceField( - queryset=Location.objects.all(), - to_field_name='name', - required=False, - help_text="Rack's location (if any)" - ) - rack = CSVModelChoiceField( - queryset=Rack.objects.all(), - to_field_name='name', - help_text='Rack' - ) - units = SimpleArrayField( - base_field=forms.IntegerField(), - required=True, - help_text='Comma-separated list of individual unit numbers' - ) - tenant = CSVModelChoiceField( - queryset=Tenant.objects.all(), - required=False, - to_field_name='name', - help_text='Assigned tenant' - ) - - class Meta: - model = RackReservation - fields = ('site', 'location', 'rack', 'units', 'tenant', 'description') - - def __init__(self, data=None, *args, **kwargs): - super().__init__(data, *args, **kwargs) - - if data: - - # Limit location queryset by assigned site - params = {f"site__{self.fields['site'].to_field_name}": data.get('site')} - self.fields['location'].queryset = self.fields['location'].queryset.filter(**params) - - # Limit rack queryset by assigned site and group - params = { - f"site__{self.fields['site'].to_field_name}": data.get('site'), - f"location__{self.fields['location'].to_field_name}": data.get('location'), - } - self.fields['rack'].queryset = self.fields['rack'].queryset.filter(**params) - - -class RackReservationBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm): - pk = forms.ModelMultipleChoiceField( - queryset=RackReservation.objects.all(), - widget=forms.MultipleHiddenInput() - ) - user = forms.ModelChoiceField( - queryset=User.objects.order_by( - 'username' - ), - required=False, - widget=StaticSelect() - ) - tenant = DynamicModelChoiceField( - queryset=Tenant.objects.all(), - required=False - ) - description = forms.CharField( - max_length=100, - required=False - ) - - class Meta: - nullable_fields = [] - - -class RackReservationFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilterForm): - model = RackReservation - field_order = ['q', 'region_id', 'site_id', 'location_id', 'user_id', 'tenant_group_id', 'tenant_id'] - field_groups = [ - ['q', 'tag'], - ['user_id'], - ['region_id', 'site_id', 'location_id'], - ['tenant_group_id', 'tenant_id'], - ] - q = forms.CharField( - required=False, - widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), - label=_('Search') - ) - region_id = DynamicModelMultipleChoiceField( - queryset=Region.objects.all(), - required=False, - label=_('Region'), - fetch_trigger='open' - ) - site_id = DynamicModelMultipleChoiceField( - queryset=Site.objects.all(), - required=False, - query_params={ - 'region_id': '$region_id' - }, - label=_('Site'), - fetch_trigger='open' - ) - location_id = DynamicModelMultipleChoiceField( - queryset=Location.objects.prefetch_related('site'), - required=False, - label=_('Location'), - null_option='None', - fetch_trigger='open' - ) - user_id = DynamicModelMultipleChoiceField( - queryset=User.objects.all(), - required=False, - label=_('User'), - widget=APISelectMultiple( - api_url='/api/users/users/', - ), - fetch_trigger='open' - ) - tag = TagFilterField(model) - - -# -# Manufacturers -# - -class ManufacturerForm(BootstrapMixin, CustomFieldModelForm): - slug = SlugField() - - class Meta: - model = Manufacturer - fields = [ - 'name', 'slug', 'description', - ] - - -class ManufacturerCSVForm(CustomFieldModelCSVForm): - - class Meta: - model = Manufacturer - fields = ('name', 'slug', 'description') - - -class ManufacturerBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm): - pk = forms.ModelMultipleChoiceField( - queryset=Manufacturer.objects.all(), - widget=forms.MultipleHiddenInput - ) - description = forms.CharField( - max_length=200, - required=False - ) - - class Meta: - nullable_fields = ['description'] - - -class ManufacturerFilterForm(BootstrapMixin, CustomFieldModelFilterForm): - model = Manufacturer - field_groups = [ - ['q'], - ] - q = forms.CharField( - required=False, - widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), - label=_('Search') - ) - - -# -# Device types -# - -class DeviceTypeForm(BootstrapMixin, CustomFieldModelForm): - manufacturer = DynamicModelChoiceField( - queryset=Manufacturer.objects.all() - ) - slug = SlugField( - slug_source='model' - ) - comments = CommentField() - tags = DynamicModelMultipleChoiceField( - queryset=Tag.objects.all(), - required=False - ) - - class Meta: - model = DeviceType - fields = [ - 'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'subdevice_role', - 'front_image', 'rear_image', 'comments', 'tags', - ] - fieldsets = ( - ('Device Type', ( - 'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'subdevice_role', 'tags', - )), - ('Images', ('front_image', 'rear_image')), - ) - widgets = { - 'subdevice_role': StaticSelect(), - 'front_image': ClearableFileInput(attrs={ - 'accept': DEVICETYPE_IMAGE_FORMATS - }), - 'rear_image': ClearableFileInput(attrs={ - 'accept': DEVICETYPE_IMAGE_FORMATS - }) - } - - -class DeviceTypeImportForm(BootstrapMixin, forms.ModelForm): - manufacturer = forms.ModelChoiceField( - queryset=Manufacturer.objects.all(), - to_field_name='name' - ) - - class Meta: - model = DeviceType - fields = [ - 'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'subdevice_role', - 'comments', - ] - - -class DeviceTypeBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm): - pk = forms.ModelMultipleChoiceField( - queryset=DeviceType.objects.all(), - widget=forms.MultipleHiddenInput() - ) - manufacturer = DynamicModelChoiceField( - queryset=Manufacturer.objects.all(), - required=False - ) - u_height = forms.IntegerField( - min_value=1, - required=False - ) - is_full_depth = forms.NullBooleanField( - required=False, - widget=BulkEditNullBooleanSelect(), - label='Is full depth' - ) - - class Meta: - nullable_fields = [] - - -class DeviceTypeFilterForm(BootstrapMixin, CustomFieldModelFilterForm): - model = DeviceType - field_groups = [ - ['q', 'tag'], - ['manufacturer_id', 'subdevice_role'], - ['console_ports', 'console_server_ports', 'power_ports', 'power_outlets', 'interfaces', 'pass_through_ports'], - ] - q = forms.CharField( - required=False, - widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), - label=_('Search') - ) - manufacturer_id = DynamicModelMultipleChoiceField( - queryset=Manufacturer.objects.all(), - required=False, - label=_('Manufacturer'), - fetch_trigger='open' - ) - subdevice_role = forms.MultipleChoiceField( - choices=add_blank_choice(SubdeviceRoleChoices), - required=False, - widget=StaticSelectMultiple() - ) - console_ports = forms.NullBooleanField( - required=False, - label='Has console ports', - widget=StaticSelect( - choices=BOOLEAN_WITH_BLANK_CHOICES - ) - ) - console_server_ports = forms.NullBooleanField( - required=False, - label='Has console server ports', - widget=StaticSelect( - choices=BOOLEAN_WITH_BLANK_CHOICES - ) - ) - power_ports = forms.NullBooleanField( - required=False, - label='Has power ports', - widget=StaticSelect( - choices=BOOLEAN_WITH_BLANK_CHOICES - ) - ) - power_outlets = forms.NullBooleanField( - required=False, - label='Has power outlets', - widget=StaticSelect( - choices=BOOLEAN_WITH_BLANK_CHOICES - ) - ) - interfaces = forms.NullBooleanField( - required=False, - label='Has interfaces', - widget=StaticSelect( - choices=BOOLEAN_WITH_BLANK_CHOICES - ) - ) - pass_through_ports = forms.NullBooleanField( - required=False, - label='Has pass-through ports', - widget=StaticSelect( - choices=BOOLEAN_WITH_BLANK_CHOICES - ) - ) - tag = TagFilterField(model) - - -# -# Device component templates -# - -class ComponentTemplateCreateForm(BootstrapMixin, ComponentForm): - """ - Base form for the creation of device component templates (subclassed from ComponentTemplateModel). - """ - manufacturer = DynamicModelChoiceField( - queryset=Manufacturer.objects.all(), - required=False, - initial_params={ - 'device_types': 'device_type' - } - ) - device_type = DynamicModelChoiceField( - queryset=DeviceType.objects.all(), - query_params={ - 'manufacturer_id': '$manufacturer' - } - ) - description = forms.CharField( - required=False - ) - - -class ConsolePortTemplateForm(BootstrapMixin, forms.ModelForm): - - class Meta: - model = ConsolePortTemplate - fields = [ - 'device_type', 'name', 'label', 'type', 'description', - ] - widgets = { - 'device_type': forms.HiddenInput(), - } - - -class ConsolePortTemplateCreateForm(ComponentTemplateCreateForm): - type = forms.ChoiceField( - choices=add_blank_choice(ConsolePortTypeChoices), - widget=StaticSelect() - ) - field_order = ('manufacturer', 'device_type', 'name_pattern', 'label_pattern', 'type', 'description') - - -class ConsolePortTemplateBulkEditForm(BootstrapMixin, BulkEditForm): - pk = forms.ModelMultipleChoiceField( - queryset=ConsolePortTemplate.objects.all(), - widget=forms.MultipleHiddenInput() - ) - label = forms.CharField( - max_length=64, - required=False - ) - type = forms.ChoiceField( - choices=add_blank_choice(ConsolePortTypeChoices), - required=False, - widget=StaticSelect() - ) - - class Meta: - nullable_fields = ('label', 'type', 'description') - - -class ConsoleServerPortTemplateForm(BootstrapMixin, forms.ModelForm): - - class Meta: - model = ConsoleServerPortTemplate - fields = [ - 'device_type', 'name', 'label', 'type', 'description', - ] - widgets = { - 'device_type': forms.HiddenInput(), - } - - -class ConsoleServerPortTemplateCreateForm(ComponentTemplateCreateForm): - type = forms.ChoiceField( - choices=add_blank_choice(ConsolePortTypeChoices), - widget=StaticSelect() - ) - field_order = ('manufacturer', 'device_type', 'name_pattern', 'label_pattern', 'type', 'description') - - -class ConsoleServerPortTemplateBulkEditForm(BootstrapMixin, BulkEditForm): - pk = forms.ModelMultipleChoiceField( - queryset=ConsoleServerPortTemplate.objects.all(), - widget=forms.MultipleHiddenInput() - ) - label = forms.CharField( - max_length=64, - required=False - ) - type = forms.ChoiceField( - choices=add_blank_choice(ConsolePortTypeChoices), - required=False, - widget=StaticSelect() - ) - description = forms.CharField( - required=False - ) - - class Meta: - nullable_fields = ('label', 'type', 'description') - - -class PowerPortTemplateForm(BootstrapMixin, forms.ModelForm): - - class Meta: - model = PowerPortTemplate - fields = [ - 'device_type', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description', - ] - widgets = { - 'device_type': forms.HiddenInput(), - } - - -class PowerPortTemplateCreateForm(ComponentTemplateCreateForm): - type = forms.ChoiceField( - choices=add_blank_choice(PowerPortTypeChoices), - required=False - ) - maximum_draw = forms.IntegerField( - min_value=1, - required=False, - help_text="Maximum power draw (watts)" - ) - allocated_draw = forms.IntegerField( - min_value=1, - required=False, - help_text="Allocated power draw (watts)" - ) - field_order = ( - 'manufacturer', 'device_type', 'name_pattern', 'label_pattern', 'type', 'maximum_draw', 'allocated_draw', - 'description', - ) - - -class PowerPortTemplateBulkEditForm(BootstrapMixin, BulkEditForm): - pk = forms.ModelMultipleChoiceField( - queryset=PowerPortTemplate.objects.all(), - widget=forms.MultipleHiddenInput() - ) - label = forms.CharField( - max_length=64, - required=False - ) - type = forms.ChoiceField( - choices=add_blank_choice(PowerPortTypeChoices), - required=False, - widget=StaticSelect() - ) - maximum_draw = forms.IntegerField( - min_value=1, - required=False, - help_text="Maximum power draw (watts)" - ) - allocated_draw = forms.IntegerField( - min_value=1, - required=False, - help_text="Allocated power draw (watts)" - ) - description = forms.CharField( - required=False - ) - - class Meta: - nullable_fields = ('label', 'type', 'maximum_draw', 'allocated_draw', 'description') - - -class PowerOutletTemplateForm(BootstrapMixin, forms.ModelForm): - - class Meta: - model = PowerOutletTemplate - fields = [ - 'device_type', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description', - ] - widgets = { - 'device_type': forms.HiddenInput(), - } - - def __init__(self, *args, **kwargs): - - super().__init__(*args, **kwargs) - - # Limit power_port choices to current DeviceType - if hasattr(self.instance, 'device_type'): - self.fields['power_port'].queryset = PowerPortTemplate.objects.filter( - device_type=self.instance.device_type - ) - - -class PowerOutletTemplateCreateForm(ComponentTemplateCreateForm): - type = forms.ChoiceField( - choices=add_blank_choice(PowerOutletTypeChoices), - required=False - ) - power_port = forms.ModelChoiceField( - queryset=PowerPortTemplate.objects.all(), - required=False - ) - feed_leg = forms.ChoiceField( - choices=add_blank_choice(PowerOutletFeedLegChoices), - required=False, - widget=StaticSelect() - ) - field_order = ( - 'manufacturer', 'device_type', 'name_pattern', 'label_pattern', 'type', 'power_port', 'feed_leg', - 'description', - ) - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - # Limit power_port choices to current DeviceType - device_type = DeviceType.objects.get( - pk=self.initial.get('device_type') or self.data.get('device_type') - ) - self.fields['power_port'].queryset = PowerPortTemplate.objects.filter( - device_type=device_type - ) - - -class PowerOutletTemplateBulkEditForm(BootstrapMixin, BulkEditForm): - pk = forms.ModelMultipleChoiceField( - queryset=PowerOutletTemplate.objects.all(), - widget=forms.MultipleHiddenInput() - ) - device_type = forms.ModelChoiceField( - queryset=DeviceType.objects.all(), - required=False, - disabled=True, - widget=forms.HiddenInput() - ) - label = forms.CharField( - max_length=64, - required=False - ) - type = forms.ChoiceField( - choices=add_blank_choice(PowerOutletTypeChoices), - required=False, - widget=StaticSelect() - ) - power_port = forms.ModelChoiceField( - queryset=PowerPortTemplate.objects.all(), - required=False - ) - feed_leg = forms.ChoiceField( - choices=add_blank_choice(PowerOutletFeedLegChoices), - required=False, - widget=StaticSelect() - ) - description = forms.CharField( - required=False - ) - - class Meta: - nullable_fields = ('label', 'type', 'power_port', 'feed_leg', 'description') - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - # Limit power_port queryset to PowerPortTemplates which belong to the parent DeviceType - if 'device_type' in self.initial: - device_type = DeviceType.objects.filter(pk=self.initial['device_type']).first() - self.fields['power_port'].queryset = PowerPortTemplate.objects.filter(device_type=device_type) - else: - self.fields['power_port'].choices = () - self.fields['power_port'].widget.attrs['disabled'] = True - - -class InterfaceTemplateForm(BootstrapMixin, forms.ModelForm): - - class Meta: - model = InterfaceTemplate - fields = [ - 'device_type', 'name', 'label', 'type', 'mgmt_only', 'description', - ] - widgets = { - 'device_type': forms.HiddenInput(), - 'type': StaticSelect(), - } - - -class InterfaceTemplateCreateForm(ComponentTemplateCreateForm): - type = forms.ChoiceField( - choices=InterfaceTypeChoices, - widget=StaticSelect() - ) - mgmt_only = forms.BooleanField( - required=False, - label='Management only' - ) - field_order = ('manufacturer', 'device_type', 'name_pattern', 'label_pattern', 'type', 'mgmt_only', 'description') - - -class InterfaceTemplateBulkEditForm(BootstrapMixin, BulkEditForm): - pk = forms.ModelMultipleChoiceField( - queryset=InterfaceTemplate.objects.all(), - widget=forms.MultipleHiddenInput() - ) - label = forms.CharField( - max_length=64, - required=False - ) - type = forms.ChoiceField( - choices=add_blank_choice(InterfaceTypeChoices), - required=False, - widget=StaticSelect() - ) - mgmt_only = forms.NullBooleanField( - required=False, - widget=BulkEditNullBooleanSelect, - label='Management only' - ) - description = forms.CharField( - required=False - ) - - class Meta: - nullable_fields = ('label', 'description') - - -class FrontPortTemplateForm(BootstrapMixin, forms.ModelForm): - - class Meta: - model = FrontPortTemplate - fields = [ - 'device_type', 'name', 'label', 'type', 'color', 'rear_port', 'rear_port_position', 'description', - ] - widgets = { - 'device_type': forms.HiddenInput(), - 'rear_port': StaticSelect(), - } - - def __init__(self, *args, **kwargs): - - super().__init__(*args, **kwargs) - - # Limit rear_port choices to current DeviceType - if hasattr(self.instance, 'device_type'): - self.fields['rear_port'].queryset = RearPortTemplate.objects.filter( - device_type=self.instance.device_type - ) - - -class FrontPortTemplateCreateForm(ComponentTemplateCreateForm): - type = forms.ChoiceField( - choices=PortTypeChoices, - widget=StaticSelect() - ) - color = ColorField( - required=False - ) - rear_port_set = forms.MultipleChoiceField( - choices=[], - label='Rear ports', - help_text='Select one rear port assignment for each front port being created.', - ) - field_order = ( - 'manufacturer', 'device_type', 'name_pattern', 'label_pattern', 'type', 'color', 'rear_port_set', 'description', - ) - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - device_type = DeviceType.objects.get( - pk=self.initial.get('device_type') or self.data.get('device_type') - ) - - # Determine which rear port positions are occupied. These will be excluded from the list of available mappings. - occupied_port_positions = [ - (front_port.rear_port_id, front_port.rear_port_position) - for front_port in device_type.frontporttemplates.all() - ] - - # Populate rear port choices - choices = [] - rear_ports = RearPortTemplate.objects.filter(device_type=device_type) - for rear_port in rear_ports: - for i in range(1, rear_port.positions + 1): - if (rear_port.pk, i) not in occupied_port_positions: - choices.append( - ('{}:{}'.format(rear_port.pk, i), '{}:{}'.format(rear_port.name, i)) - ) - self.fields['rear_port_set'].choices = choices - - def clean(self): - super().clean() - - # Validate that the number of ports being created equals the number of selected (rear port, position) tuples - front_port_count = len(self.cleaned_data['name_pattern']) - rear_port_count = len(self.cleaned_data['rear_port_set']) - if front_port_count != rear_port_count: - raise forms.ValidationError({ - 'rear_port_set': 'The provided name pattern will create {} ports, however {} rear port assignments ' - 'were selected. These counts must match.'.format(front_port_count, rear_port_count) - }) - - def get_iterative_data(self, iteration): - - # Assign rear port and position from selected set - rear_port, position = self.cleaned_data['rear_port_set'][iteration].split(':') - - return { - 'rear_port': int(rear_port), - 'rear_port_position': int(position), - } - - -class FrontPortTemplateBulkEditForm(BootstrapMixin, BulkEditForm): - pk = forms.ModelMultipleChoiceField( - queryset=FrontPortTemplate.objects.all(), - widget=forms.MultipleHiddenInput() - ) - label = forms.CharField( - max_length=64, - required=False - ) - type = forms.ChoiceField( - choices=add_blank_choice(PortTypeChoices), - required=False, - widget=StaticSelect() - ) - color = ColorField( - required=False - ) - description = forms.CharField( - required=False - ) - - class Meta: - nullable_fields = ('description',) - - -class RearPortTemplateForm(BootstrapMixin, forms.ModelForm): - - class Meta: - model = RearPortTemplate - fields = [ - 'device_type', 'name', 'label', 'type', 'color', 'positions', 'description', - ] - widgets = { - 'device_type': forms.HiddenInput(), - 'type': StaticSelect(), - } - - -class RearPortTemplateCreateForm(ComponentTemplateCreateForm): - type = forms.ChoiceField( - choices=PortTypeChoices, - widget=StaticSelect(), - ) - color = ColorField( - required=False - ) - positions = forms.IntegerField( - min_value=REARPORT_POSITIONS_MIN, - max_value=REARPORT_POSITIONS_MAX, - initial=1, - help_text='The number of front ports which may be mapped to each rear port' - ) - field_order = ( - 'manufacturer', 'device_type', 'name_pattern', 'label_pattern', 'type', 'color', 'positions', 'description', - ) - - -class RearPortTemplateBulkEditForm(BootstrapMixin, BulkEditForm): - pk = forms.ModelMultipleChoiceField( - queryset=RearPortTemplate.objects.all(), - widget=forms.MultipleHiddenInput() - ) - label = forms.CharField( - max_length=64, - required=False - ) - type = forms.ChoiceField( - choices=add_blank_choice(PortTypeChoices), - required=False, - widget=StaticSelect() - ) - color = ColorField( - required=False - ) - description = forms.CharField( - required=False - ) - - class Meta: - nullable_fields = ('description',) - - -class DeviceBayTemplateForm(BootstrapMixin, forms.ModelForm): - - class Meta: - model = DeviceBayTemplate - fields = [ - 'device_type', 'name', 'label', 'description', - ] - widgets = { - 'device_type': forms.HiddenInput(), - } - - -class DeviceBayTemplateCreateForm(ComponentTemplateCreateForm): - field_order = ('manufacturer', 'device_type', 'name_pattern', 'label_pattern', 'description') - - -class DeviceBayTemplateBulkEditForm(BootstrapMixin, BulkEditForm): - pk = forms.ModelMultipleChoiceField( - queryset=DeviceBayTemplate.objects.all(), - widget=forms.MultipleHiddenInput() - ) - label = forms.CharField( - max_length=64, - required=False - ) - description = forms.CharField( - required=False - ) - - class Meta: - nullable_fields = ('label', 'description') - - -# -# Component template import forms -# - -class ComponentTemplateImportForm(BootstrapMixin, forms.ModelForm): - - def __init__(self, device_type, data=None, *args, **kwargs): - - # Must pass the parent DeviceType on form initialization - data.update({ - 'device_type': device_type.pk, - }) - - super().__init__(data, *args, **kwargs) - - def clean_device_type(self): - - data = self.cleaned_data['device_type'] - - # Limit fields referencing other components to the parent DeviceType - for field_name, field in self.fields.items(): - if isinstance(field, forms.ModelChoiceField) and field_name != 'device_type': - field.queryset = field.queryset.filter(device_type=data) - - return data - - -class ConsolePortTemplateImportForm(ComponentTemplateImportForm): - - class Meta: - model = ConsolePortTemplate - fields = [ - 'device_type', 'name', 'label', 'type', 'description', - ] - - -class ConsoleServerPortTemplateImportForm(ComponentTemplateImportForm): - - class Meta: - model = ConsoleServerPortTemplate - fields = [ - 'device_type', 'name', 'label', 'type', 'description', - ] - - -class PowerPortTemplateImportForm(ComponentTemplateImportForm): - - class Meta: - model = PowerPortTemplate - fields = [ - 'device_type', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description', - ] - - -class PowerOutletTemplateImportForm(ComponentTemplateImportForm): - power_port = forms.ModelChoiceField( - queryset=PowerPortTemplate.objects.all(), - to_field_name='name', - required=False - ) - - class Meta: - model = PowerOutletTemplate - fields = [ - 'device_type', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description', - ] - - -class InterfaceTemplateImportForm(ComponentTemplateImportForm): - type = forms.ChoiceField( - choices=InterfaceTypeChoices.CHOICES - ) - - class Meta: - model = InterfaceTemplate - fields = [ - 'device_type', 'name', 'label', 'type', 'mgmt_only', 'description', - ] - - -class FrontPortTemplateImportForm(ComponentTemplateImportForm): - type = forms.ChoiceField( - choices=PortTypeChoices.CHOICES - ) - rear_port = forms.ModelChoiceField( - queryset=RearPortTemplate.objects.all(), - to_field_name='name' - ) - - class Meta: - model = FrontPortTemplate - fields = [ - 'device_type', 'name', 'type', 'rear_port', 'rear_port_position', 'label', 'description', - ] - - -class RearPortTemplateImportForm(ComponentTemplateImportForm): - type = forms.ChoiceField( - choices=PortTypeChoices.CHOICES - ) - - class Meta: - model = RearPortTemplate - fields = [ - 'device_type', 'name', 'type', 'positions', 'label', 'description', - ] - - -class DeviceBayTemplateImportForm(ComponentTemplateImportForm): - - class Meta: - model = DeviceBayTemplate - fields = [ - 'device_type', 'name', 'label', 'description', - ] - - -# -# Device roles -# - -class DeviceRoleForm(BootstrapMixin, CustomFieldModelForm): - slug = SlugField() - - class Meta: - model = DeviceRole - fields = [ - 'name', 'slug', 'color', 'vm_role', 'description', - ] - - -class DeviceRoleCSVForm(CustomFieldModelCSVForm): - slug = SlugField() - - class Meta: - model = DeviceRole - fields = ('name', 'slug', 'color', 'vm_role', 'description') - help_texts = { - 'color': mark_safe('RGB color in hexadecimal (e.g. 00ff00)'), - } - - -class DeviceRoleBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm): - pk = forms.ModelMultipleChoiceField( - queryset=DeviceRole.objects.all(), - widget=forms.MultipleHiddenInput - ) - color = ColorField( - required=False - ) - vm_role = forms.NullBooleanField( - required=False, - widget=BulkEditNullBooleanSelect, - label='VM role' - ) - description = forms.CharField( - max_length=200, - required=False - ) - - class Meta: - nullable_fields = ['color', 'description'] - - -class DeviceRoleFilterForm(BootstrapMixin, CustomFieldModelFilterForm): - model = DeviceRole - field_groups = [ - ['q'], - ] - q = forms.CharField( - required=False, - widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), - label=_('Search') - ) - - -# -# Platforms -# - -class PlatformForm(BootstrapMixin, CustomFieldModelForm): - manufacturer = DynamicModelChoiceField( - queryset=Manufacturer.objects.all(), - required=False - ) - slug = SlugField( - max_length=64 - ) - - class Meta: - model = Platform - fields = [ - 'name', 'slug', 'manufacturer', 'napalm_driver', 'napalm_args', 'description', - ] - widgets = { - 'napalm_args': SmallTextarea(), - } - - -class PlatformCSVForm(CustomFieldModelCSVForm): - slug = SlugField() - manufacturer = CSVModelChoiceField( - queryset=Manufacturer.objects.all(), - required=False, - to_field_name='name', - help_text='Limit platform assignments to this manufacturer' - ) - - class Meta: - model = Platform - fields = ('name', 'slug', 'manufacturer', 'napalm_driver', 'napalm_args', 'description') - - -class PlatformBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm): - pk = forms.ModelMultipleChoiceField( - queryset=Platform.objects.all(), - widget=forms.MultipleHiddenInput - ) - manufacturer = DynamicModelChoiceField( - queryset=Manufacturer.objects.all(), - required=False - ) - napalm_driver = forms.CharField( - max_length=50, - required=False - ) - # TODO: Bulk edit support for napalm_args - description = forms.CharField( - max_length=200, - required=False - ) - - class Meta: - nullable_fields = ['manufacturer', 'napalm_driver', 'description'] - - -class PlatformFilterForm(BootstrapMixin, CustomFieldModelFilterForm): - model = Platform - q = forms.CharField( - required=False, - widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), - label=_('Search') - ) - manufacturer_id = DynamicModelMultipleChoiceField( - queryset=Manufacturer.objects.all(), - required=False, - label=_('Manufacturer'), - fetch_trigger='open' - ) - - -# -# Devices -# - -class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): - region = DynamicModelChoiceField( - queryset=Region.objects.all(), - required=False, - initial_params={ - 'sites': '$site' - } - ) - site_group = DynamicModelChoiceField( - queryset=SiteGroup.objects.all(), - required=False, - initial_params={ - 'sites': '$site' - } - ) - site = DynamicModelChoiceField( - queryset=Site.objects.all(), - query_params={ - 'region_id': '$region', - 'group_id': '$site_group', - } - ) - location = DynamicModelChoiceField( - queryset=Location.objects.all(), - required=False, - query_params={ - 'site_id': '$site' - }, - initial_params={ - 'racks': '$rack' - } - ) - rack = DynamicModelChoiceField( - queryset=Rack.objects.all(), - required=False, - query_params={ - 'site_id': '$site', - 'location_id': '$location', - } - ) - position = forms.IntegerField( - required=False, - help_text="The lowest-numbered unit occupied by the device", - widget=APISelect( - api_url='/api/dcim/racks/{{rack}}/elevation/', - attrs={ - 'disabled-indicator': 'device', - 'data-query-param-face': "[\"$face\"]" - } - ) - ) - manufacturer = DynamicModelChoiceField( - queryset=Manufacturer.objects.all(), - required=False, - initial_params={ - 'device_types': '$device_type' - } - ) - device_type = DynamicModelChoiceField( - queryset=DeviceType.objects.all(), - query_params={ - 'manufacturer_id': '$manufacturer' - } - ) - device_role = DynamicModelChoiceField( - queryset=DeviceRole.objects.all() - ) - platform = DynamicModelChoiceField( - queryset=Platform.objects.all(), - required=False, - query_params={ - 'manufacturer_id': ['$manufacturer', 'null'] - } - ) - cluster_group = DynamicModelChoiceField( - queryset=ClusterGroup.objects.all(), - required=False, - null_option='None', - initial_params={ - 'clusters': '$cluster' - } - ) - cluster = DynamicModelChoiceField( - queryset=Cluster.objects.all(), - required=False, - query_params={ - 'group_id': '$cluster_group' - } - ) - comments = CommentField() - local_context_data = JSONField( - required=False, - label='' - ) - tags = DynamicModelMultipleChoiceField( - queryset=Tag.objects.all(), - required=False - ) - - class Meta: - model = Device - fields = [ - 'name', 'device_role', 'device_type', 'serial', 'asset_tag', 'region', 'site_group', 'site', 'rack', - 'location', 'position', 'face', 'status', 'platform', 'primary_ip4', 'primary_ip6', 'cluster_group', - 'cluster', 'tenant_group', 'tenant', 'comments', 'tags', 'local_context_data' - ] - help_texts = { - 'device_role': "The function this device serves", - 'serial': "Chassis serial number", - 'local_context_data': "Local config context data overwrites all source contexts in the final rendered " - "config context", - } - widgets = { - 'face': StaticSelect(), - 'status': StaticSelect(), - 'primary_ip4': StaticSelect(), - 'primary_ip6': StaticSelect(), - } - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - if self.instance.pk: - - # Compile list of choices for primary IPv4 and IPv6 addresses - for family in [4, 6]: - ip_choices = [(None, '---------')] - - # Gather PKs of all interfaces belonging to this Device or a peer VirtualChassis member - interface_ids = self.instance.vc_interfaces(if_master=False).values_list('pk', flat=True) - - # Collect interface IPs - interface_ips = IPAddress.objects.filter( - address__family=family, - assigned_object_type=ContentType.objects.get_for_model(Interface), - assigned_object_id__in=interface_ids - ).prefetch_related('assigned_object') - if interface_ips: - ip_list = [(ip.id, f'{ip.address} ({ip.assigned_object})') for ip in interface_ips] - ip_choices.append(('Interface IPs', ip_list)) - # Collect NAT IPs - nat_ips = IPAddress.objects.prefetch_related('nat_inside').filter( - address__family=family, - nat_inside__assigned_object_type=ContentType.objects.get_for_model(Interface), - nat_inside__assigned_object_id__in=interface_ids - ).prefetch_related('assigned_object') - if nat_ips: - ip_list = [(ip.id, f'{ip.address} (NAT)') for ip in nat_ips] - ip_choices.append(('NAT IPs', ip_list)) - self.fields['primary_ip{}'.format(family)].choices = ip_choices - - # If editing an existing device, exclude it from the list of occupied rack units. This ensures that a device - # can be flipped from one face to another. - self.fields['position'].widget.add_query_param('exclude', self.instance.pk) - - # Limit platform by manufacturer - self.fields['platform'].queryset = Platform.objects.filter( - Q(manufacturer__isnull=True) | Q(manufacturer=self.instance.device_type.manufacturer) - ) - - # Disable rack assignment if this is a child device installed in a parent device - if self.instance.device_type.is_child_device and hasattr(self.instance, 'parent_bay'): - self.fields['site'].disabled = True - self.fields['rack'].disabled = True - self.initial['site'] = self.instance.parent_bay.device.site_id - self.initial['rack'] = self.instance.parent_bay.device.rack_id - - else: - - # An object that doesn't exist yet can't have any IPs assigned to it - self.fields['primary_ip4'].choices = [] - self.fields['primary_ip4'].widget.attrs['readonly'] = True - self.fields['primary_ip6'].choices = [] - self.fields['primary_ip6'].widget.attrs['readonly'] = True - - # Rack position - position = self.data.get('position') or self.initial.get('position') - if position: - self.fields['position'].widget.choices = [(position, f'U{position}')] - - -class BaseDeviceCSVForm(CustomFieldModelCSVForm): - device_role = CSVModelChoiceField( - queryset=DeviceRole.objects.all(), - to_field_name='name', - help_text='Assigned role' - ) - tenant = CSVModelChoiceField( - queryset=Tenant.objects.all(), - required=False, - to_field_name='name', - help_text='Assigned tenant' - ) - manufacturer = CSVModelChoiceField( - queryset=Manufacturer.objects.all(), - to_field_name='name', - help_text='Device type manufacturer' - ) - device_type = CSVModelChoiceField( - queryset=DeviceType.objects.all(), - to_field_name='model', - help_text='Device type model' - ) - platform = CSVModelChoiceField( - queryset=Platform.objects.all(), - required=False, - to_field_name='name', - help_text='Assigned platform' - ) - status = CSVChoiceField( - choices=DeviceStatusChoices, - help_text='Operational status' - ) - virtual_chassis = CSVModelChoiceField( - queryset=VirtualChassis.objects.all(), - to_field_name='name', - required=False, - help_text='Virtual chassis' - ) - cluster = CSVModelChoiceField( - queryset=Cluster.objects.all(), - to_field_name='name', - required=False, - help_text='Virtualization cluster' - ) - - class Meta: - fields = [] - model = Device - help_texts = { - 'vc_position': 'Virtual chassis position', - 'vc_priority': 'Virtual chassis priority', - } - - def __init__(self, data=None, *args, **kwargs): - super().__init__(data, *args, **kwargs) - - if data: - - # Limit device type queryset by manufacturer - params = {f"manufacturer__{self.fields['manufacturer'].to_field_name}": data.get('manufacturer')} - self.fields['device_type'].queryset = self.fields['device_type'].queryset.filter(**params) - - -class DeviceCSVForm(BaseDeviceCSVForm): - site = CSVModelChoiceField( - queryset=Site.objects.all(), - to_field_name='name', - help_text='Assigned site' - ) - location = CSVModelChoiceField( - queryset=Location.objects.all(), - to_field_name='name', - required=False, - help_text="Assigned location (if any)" - ) - rack = CSVModelChoiceField( - queryset=Rack.objects.all(), - to_field_name='name', - required=False, - help_text="Assigned rack (if any)" - ) - face = CSVChoiceField( - choices=DeviceFaceChoices, - required=False, - help_text='Mounted rack face' - ) - - class Meta(BaseDeviceCSVForm.Meta): - fields = [ - 'name', 'device_role', 'tenant', 'manufacturer', 'device_type', 'platform', 'serial', 'asset_tag', 'status', - 'site', 'location', 'rack', 'position', 'face', 'virtual_chassis', 'vc_position', 'vc_priority', 'cluster', - 'comments', - ] - - def __init__(self, data=None, *args, **kwargs): - super().__init__(data, *args, **kwargs) - - if data: - - # Limit location queryset by assigned site - params = {f"site__{self.fields['site'].to_field_name}": data.get('site')} - self.fields['location'].queryset = self.fields['location'].queryset.filter(**params) - - # Limit rack queryset by assigned site and group - params = { - f"site__{self.fields['site'].to_field_name}": data.get('site'), - f"location__{self.fields['location'].to_field_name}": data.get('location'), - } - self.fields['rack'].queryset = self.fields['rack'].queryset.filter(**params) - - -class ChildDeviceCSVForm(BaseDeviceCSVForm): - parent = CSVModelChoiceField( - queryset=Device.objects.all(), - to_field_name='name', - help_text='Parent device' - ) - device_bay = CSVModelChoiceField( - queryset=DeviceBay.objects.all(), - to_field_name='name', - help_text='Device bay in which this device is installed' - ) - - class Meta(BaseDeviceCSVForm.Meta): - fields = [ - 'name', 'device_role', 'tenant', 'manufacturer', 'device_type', 'platform', 'serial', 'asset_tag', 'status', - 'parent', 'device_bay', 'virtual_chassis', 'vc_position', 'vc_priority', 'cluster', 'comments', - ] - - def __init__(self, data=None, *args, **kwargs): - super().__init__(data, *args, **kwargs) - - if data: - - # Limit device bay queryset by parent device - params = {f"device__{self.fields['parent'].to_field_name}": data.get('parent')} - self.fields['device_bay'].queryset = self.fields['device_bay'].queryset.filter(**params) - - def clean(self): - super().clean() - - # Set parent_bay reverse relationship - device_bay = self.cleaned_data.get('device_bay') - if device_bay: - self.instance.parent_bay = device_bay - - # Inherit site and rack from parent device - parent = self.cleaned_data.get('parent') - if parent: - self.instance.site = parent.site - self.instance.rack = parent.rack - - -class DeviceBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm): - pk = forms.ModelMultipleChoiceField( - queryset=Device.objects.all(), - widget=forms.MultipleHiddenInput() - ) - manufacturer = DynamicModelChoiceField( - queryset=Manufacturer.objects.all(), - required=False - ) - device_type = DynamicModelChoiceField( - queryset=DeviceType.objects.all(), - required=False, - query_params={ - 'manufacturer_id': '$manufacturer' - } - ) - device_role = DynamicModelChoiceField( - queryset=DeviceRole.objects.all(), - required=False - ) - site = DynamicModelChoiceField( - queryset=Site.objects.all(), - required=False - ) - location = DynamicModelChoiceField( - queryset=Location.objects.all(), - required=False, - query_params={ - 'site_id': '$site' - } - ) - tenant = DynamicModelChoiceField( - queryset=Tenant.objects.all(), - required=False - ) - platform = DynamicModelChoiceField( - queryset=Platform.objects.all(), - required=False - ) - status = forms.ChoiceField( - choices=add_blank_choice(DeviceStatusChoices), - required=False, - widget=StaticSelect() - ) - serial = forms.CharField( - max_length=50, - required=False, - label='Serial Number' - ) - - class Meta: - nullable_fields = [ - 'tenant', 'platform', 'serial', - ] - - -class DeviceFilterForm(BootstrapMixin, LocalConfigContextFilterForm, TenancyFilterForm, CustomFieldModelFilterForm): - model = Device - field_order = [ - 'q', 'region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'status', 'role_id', 'tenant_group_id', - 'tenant_id', 'manufacturer_id', 'device_type_id', 'asset_tag', 'mac_address', 'has_primary_ip', - ] - field_groups = [ - ['q', 'tag'], - ['region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id'], - ['status', 'role_id', 'serial', 'asset_tag', 'mac_address'], - ['manufacturer_id', 'device_type_id', 'platform_id'], - ['tenant_group_id', 'tenant_id'], - [ - 'has_primary_ip', 'virtual_chassis_member', 'console_ports', 'console_server_ports', 'power_ports', - 'power_outlets', 'interfaces', 'pass_through_ports', 'local_context_data', - ], - ] - q = forms.CharField( - required=False, - widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), - label=_('Search') - ) - region_id = DynamicModelMultipleChoiceField( - queryset=Region.objects.all(), - required=False, - label=_('Region'), - fetch_trigger='open' - ) - site_group_id = DynamicModelMultipleChoiceField( - queryset=SiteGroup.objects.all(), - required=False, - label=_('Site group'), - fetch_trigger='open' - ) - site_id = DynamicModelMultipleChoiceField( - queryset=Site.objects.all(), - required=False, - query_params={ - 'region_id': '$region_id', - 'group_id': '$site_group_id', - }, - label=_('Site'), - fetch_trigger='open' - ) - location_id = DynamicModelMultipleChoiceField( - queryset=Location.objects.all(), - required=False, - null_option='None', - query_params={ - 'site_id': '$site_id' - }, - label=_('Location'), - fetch_trigger='open' - ) - rack_id = DynamicModelMultipleChoiceField( - queryset=Rack.objects.all(), - required=False, - null_option='None', - query_params={ - 'site_id': '$site_id', - 'location_id': '$location_id', - }, - label=_('Rack'), - fetch_trigger='open' - ) - role_id = DynamicModelMultipleChoiceField( - queryset=DeviceRole.objects.all(), - required=False, - label=_('Role'), - fetch_trigger='open' - ) - manufacturer_id = DynamicModelMultipleChoiceField( - queryset=Manufacturer.objects.all(), - required=False, - label=_('Manufacturer'), - fetch_trigger='open' - ) - device_type_id = DynamicModelMultipleChoiceField( - queryset=DeviceType.objects.all(), - required=False, - query_params={ - 'manufacturer_id': '$manufacturer_id' - }, - label=_('Model'), - fetch_trigger='open' - ) - platform_id = DynamicModelMultipleChoiceField( - queryset=Platform.objects.all(), - required=False, - null_option='None', - label=_('Platform'), - fetch_trigger='open' - ) - status = forms.MultipleChoiceField( - choices=DeviceStatusChoices, - required=False, - widget=StaticSelectMultiple() - ) - serial = forms.CharField( - required=False - ) - asset_tag = forms.CharField( - required=False - ) - mac_address = forms.CharField( - required=False, - label='MAC address' - ) - has_primary_ip = forms.NullBooleanField( - required=False, - label='Has a primary IP', - widget=StaticSelect( - choices=BOOLEAN_WITH_BLANK_CHOICES - ) - ) - virtual_chassis_member = forms.NullBooleanField( - required=False, - label='Virtual chassis member', - widget=StaticSelect( - choices=BOOLEAN_WITH_BLANK_CHOICES - ) - ) - console_ports = forms.NullBooleanField( - required=False, - label='Has console ports', - widget=StaticSelect( - choices=BOOLEAN_WITH_BLANK_CHOICES - ) - ) - console_server_ports = forms.NullBooleanField( - required=False, - label='Has console server ports', - widget=StaticSelect( - choices=BOOLEAN_WITH_BLANK_CHOICES - ) - ) - power_ports = forms.NullBooleanField( - required=False, - label='Has power ports', - widget=StaticSelect( - choices=BOOLEAN_WITH_BLANK_CHOICES - ) - ) - power_outlets = forms.NullBooleanField( - required=False, - label='Has power outlets', - widget=StaticSelect( - choices=BOOLEAN_WITH_BLANK_CHOICES - ) - ) - interfaces = forms.NullBooleanField( - required=False, - label='Has interfaces', - widget=StaticSelect( - choices=BOOLEAN_WITH_BLANK_CHOICES - ) - ) - pass_through_ports = forms.NullBooleanField( - required=False, - label='Has pass-through ports', - widget=StaticSelect( - choices=BOOLEAN_WITH_BLANK_CHOICES - ) - ) - tag = TagFilterField(model) - - -# -# Device components -# - -class ComponentCreateForm(BootstrapMixin, CustomFieldsMixin, ComponentForm): - """ - Base form for the creation of device components (models subclassed from ComponentModel). - """ - device = DynamicModelChoiceField( - queryset=Device.objects.all() - ) - description = forms.CharField( - max_length=200, - required=False - ) - tags = DynamicModelMultipleChoiceField( - queryset=Tag.objects.all(), - required=False - ) - - -class DeviceBulkAddComponentForm(BootstrapMixin, CustomFieldsMixin, ComponentForm): - pk = forms.ModelMultipleChoiceField( - queryset=Device.objects.all(), - widget=forms.MultipleHiddenInput() - ) - description = forms.CharField( - max_length=100, - required=False - ) - tags = DynamicModelMultipleChoiceField( - queryset=Tag.objects.all(), - required=False - ) - - -# -# Console ports -# - - -class ConsolePortFilterForm(DeviceComponentFilterForm): - model = ConsolePort - field_groups = [ - ['q', 'tag'], - ['name', 'label', 'type', 'speed'], - ['region_id', 'site_group_id', 'site_id', 'location_id', 'device_id'], - ] - type = forms.MultipleChoiceField( - choices=ConsolePortTypeChoices, - required=False, - widget=StaticSelectMultiple() - ) - speed = forms.MultipleChoiceField( - choices=ConsolePortSpeedChoices, - required=False, - widget=StaticSelectMultiple() - ) - tag = TagFilterField(model) - - -class ConsolePortForm(BootstrapMixin, CustomFieldModelForm): - tags = DynamicModelMultipleChoiceField( - queryset=Tag.objects.all(), - required=False - ) - - class Meta: - model = ConsolePort - fields = [ - 'device', 'name', 'label', 'type', 'speed', 'mark_connected', 'description', 'tags', - ] - widgets = { - 'device': forms.HiddenInput(), - } - - -class ConsolePortCreateForm(ComponentCreateForm): - model = ConsolePort - type = forms.ChoiceField( - choices=add_blank_choice(ConsolePortTypeChoices), - required=False, - widget=StaticSelect() - ) - speed = forms.ChoiceField( - choices=add_blank_choice(ConsolePortSpeedChoices), - required=False, - widget=StaticSelect() - ) - field_order = ('device', 'name_pattern', 'label_pattern', 'type', 'speed', 'mark_connected', 'description', 'tags') - - -class ConsolePortBulkCreateForm( - form_from_model(ConsolePort, ['type', 'speed', 'mark_connected']), - DeviceBulkAddComponentForm -): - model = ConsolePort - field_order = ('name_pattern', 'label_pattern', 'type', 'mark_connected', 'description', 'tags') - - -class ConsolePortBulkEditForm( - form_from_model(ConsolePort, ['label', 'type', 'speed', 'mark_connected', 'description']), - BootstrapMixin, - AddRemoveTagsForm, - CustomFieldModelBulkEditForm -): - pk = forms.ModelMultipleChoiceField( - queryset=ConsolePort.objects.all(), - widget=forms.MultipleHiddenInput() - ) - mark_connected = forms.NullBooleanField( - required=False, - widget=BulkEditNullBooleanSelect - ) - - class Meta: - nullable_fields = ['label', 'description'] - - -class ConsolePortCSVForm(CustomFieldModelCSVForm): - device = CSVModelChoiceField( - queryset=Device.objects.all(), - to_field_name='name' - ) - type = CSVChoiceField( - choices=ConsolePortTypeChoices, - required=False, - help_text='Port type' - ) - speed = CSVTypedChoiceField( - choices=ConsolePortSpeedChoices, - coerce=int, - empty_value=None, - required=False, - help_text='Port speed in bps' - ) - - class Meta: - model = ConsolePort - fields = ('device', 'name', 'label', 'type', 'speed', 'mark_connected', 'description') - - -# -# Console server ports -# - - -class ConsoleServerPortFilterForm(DeviceComponentFilterForm): - model = ConsoleServerPort - field_groups = [ - ['q', 'tag'], - ['name', 'label', 'type', 'speed'], - ['region_id', 'site_group_id', 'site_id', 'location_id', 'device_id'], - ] - type = forms.MultipleChoiceField( - choices=ConsolePortTypeChoices, - required=False, - widget=StaticSelectMultiple() - ) - speed = forms.MultipleChoiceField( - choices=ConsolePortSpeedChoices, - required=False, - widget=StaticSelectMultiple() - ) - tag = TagFilterField(model) - - -class ConsoleServerPortForm(BootstrapMixin, CustomFieldModelForm): - tags = DynamicModelMultipleChoiceField( - queryset=Tag.objects.all(), - required=False - ) - - class Meta: - model = ConsoleServerPort - fields = [ - 'device', 'name', 'label', 'type', 'speed', 'mark_connected', 'description', 'tags', - ] - widgets = { - 'device': forms.HiddenInput(), - } - - -class ConsoleServerPortCreateForm(ComponentCreateForm): - model = ConsoleServerPort - type = forms.ChoiceField( - choices=add_blank_choice(ConsolePortTypeChoices), - required=False, - widget=StaticSelect() - ) - speed = forms.ChoiceField( - choices=add_blank_choice(ConsolePortSpeedChoices), - required=False, - widget=StaticSelect() - ) - field_order = ('device', 'name_pattern', 'label_pattern', 'type', 'speed', 'mark_connected', 'description', 'tags') - - -class ConsoleServerPortBulkCreateForm( - form_from_model(ConsoleServerPort, ['type', 'speed', 'mark_connected']), - DeviceBulkAddComponentForm -): - model = ConsoleServerPort - field_order = ('name_pattern', 'label_pattern', 'type', 'speed', 'description', 'tags') - - -class ConsoleServerPortBulkEditForm( - form_from_model(ConsoleServerPort, ['label', 'type', 'speed', 'mark_connected', 'description']), - BootstrapMixin, - AddRemoveTagsForm, - CustomFieldModelBulkEditForm -): - pk = forms.ModelMultipleChoiceField( - queryset=ConsoleServerPort.objects.all(), - widget=forms.MultipleHiddenInput() - ) - mark_connected = forms.NullBooleanField( - required=False, - widget=BulkEditNullBooleanSelect - ) - - class Meta: - nullable_fields = ['label', 'description'] - - -class ConsoleServerPortCSVForm(CustomFieldModelCSVForm): - device = CSVModelChoiceField( - queryset=Device.objects.all(), - to_field_name='name' - ) - type = CSVChoiceField( - choices=ConsolePortTypeChoices, - required=False, - help_text='Port type' - ) - speed = CSVTypedChoiceField( - choices=ConsolePortSpeedChoices, - coerce=int, - empty_value=None, - required=False, - help_text='Port speed in bps' - ) - - class Meta: - model = ConsoleServerPort - fields = ('device', 'name', 'label', 'type', 'speed', 'mark_connected', 'description') - - -# -# Power ports -# - - -class PowerPortFilterForm(DeviceComponentFilterForm): - model = PowerPort - field_groups = [ - ['q', 'tag'], - ['name', 'label', 'type'], - ['region_id', 'site_group_id', 'site_id', 'location_id', 'device_id'], - ] - type = forms.MultipleChoiceField( - choices=PowerPortTypeChoices, - required=False, - widget=StaticSelectMultiple() - ) - tag = TagFilterField(model) - - -class PowerPortForm(BootstrapMixin, CustomFieldModelForm): - tags = DynamicModelMultipleChoiceField( - queryset=Tag.objects.all(), - required=False - ) - - class Meta: - model = PowerPort - fields = [ - 'device', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'mark_connected', 'description', - 'tags', - ] - widgets = { - 'device': forms.HiddenInput(), - } - - -class PowerPortCreateForm(ComponentCreateForm): - model = PowerPort - type = forms.ChoiceField( - choices=add_blank_choice(PowerPortTypeChoices), - required=False, - widget=StaticSelect() - ) - maximum_draw = forms.IntegerField( - min_value=1, - required=False, - help_text="Maximum draw in watts" - ) - allocated_draw = forms.IntegerField( - min_value=1, - required=False, - help_text="Allocated draw in watts" - ) - field_order = ( - 'device', 'name_pattern', 'label_pattern', 'type', 'maximum_draw', 'allocated_draw', 'mark_connected', - 'description', 'tags', - ) - - -class PowerPortBulkCreateForm( - form_from_model(PowerPort, ['type', 'maximum_draw', 'allocated_draw', 'mark_connected']), - DeviceBulkAddComponentForm -): - model = PowerPort - field_order = ('name_pattern', 'label_pattern', 'type', 'maximum_draw', 'allocated_draw', 'description', 'tags') - - -class PowerPortBulkEditForm( - form_from_model(PowerPort, ['label', 'type', 'maximum_draw', 'allocated_draw', 'mark_connected', 'description']), - BootstrapMixin, - AddRemoveTagsForm, - CustomFieldModelBulkEditForm -): - pk = forms.ModelMultipleChoiceField( - queryset=PowerPort.objects.all(), - widget=forms.MultipleHiddenInput() - ) - mark_connected = forms.NullBooleanField( - required=False, - widget=BulkEditNullBooleanSelect - ) - - class Meta: - nullable_fields = ['label', 'description'] - - -class PowerPortCSVForm(CustomFieldModelCSVForm): - device = CSVModelChoiceField( - queryset=Device.objects.all(), - to_field_name='name' - ) - type = CSVChoiceField( - choices=PowerPortTypeChoices, - required=False, - help_text='Port type' - ) - - class Meta: - model = PowerPort - fields = ( - 'device', 'name', 'label', 'type', 'mark_connected', 'maximum_draw', 'allocated_draw', 'description', - ) - - -# -# Power outlets -# - - -class PowerOutletFilterForm(DeviceComponentFilterForm): - model = PowerOutlet - field_groups = [ - ['q', 'tag'], - ['name', 'label', 'type'], - ['region_id', 'site_group_id', 'site_id', 'location_id', 'device_id'], - ] - type = forms.MultipleChoiceField( - choices=PowerOutletTypeChoices, - required=False, - widget=StaticSelectMultiple() - ) - tag = TagFilterField(model) - - -class PowerOutletForm(BootstrapMixin, CustomFieldModelForm): - power_port = forms.ModelChoiceField( - queryset=PowerPort.objects.all(), - required=False - ) - tags = DynamicModelMultipleChoiceField( - queryset=Tag.objects.all(), - required=False - ) - - class Meta: - model = PowerOutlet - fields = [ - 'device', 'name', 'label', 'type', 'power_port', 'feed_leg', 'mark_connected', 'description', 'tags', - ] - widgets = { - 'device': forms.HiddenInput(), - } - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - # Limit power_port choices to the local device - if hasattr(self.instance, 'device'): - self.fields['power_port'].queryset = PowerPort.objects.filter( - device=self.instance.device - ) - - -class PowerOutletCreateForm(ComponentCreateForm): - model = PowerOutlet - type = forms.ChoiceField( - choices=add_blank_choice(PowerOutletTypeChoices), - required=False, - widget=StaticSelect() - ) - power_port = forms.ModelChoiceField( - queryset=PowerPort.objects.all(), - required=False - ) - feed_leg = forms.ChoiceField( - choices=add_blank_choice(PowerOutletFeedLegChoices), - required=False - ) - field_order = ( - 'device', 'name_pattern', 'label_pattern', 'type', 'power_port', 'feed_leg', 'mark_connected', 'description', - 'tags', - ) - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - # Limit power_port queryset to PowerPorts which belong to the parent Device - device = Device.objects.get( - pk=self.initial.get('device') or self.data.get('device') - ) - self.fields['power_port'].queryset = PowerPort.objects.filter(device=device) - - -class PowerOutletBulkCreateForm( - form_from_model(PowerOutlet, ['type', 'feed_leg', 'mark_connected']), - DeviceBulkAddComponentForm -): - model = PowerOutlet - field_order = ('name_pattern', 'label_pattern', 'type', 'feed_leg', 'description', 'tags') - - -class PowerOutletBulkEditForm( - form_from_model(PowerOutlet, ['label', 'type', 'feed_leg', 'power_port', 'mark_connected', 'description']), - BootstrapMixin, - AddRemoveTagsForm, - CustomFieldModelBulkEditForm -): - pk = forms.ModelMultipleChoiceField( - queryset=PowerOutlet.objects.all(), - widget=forms.MultipleHiddenInput() - ) - device = forms.ModelChoiceField( - queryset=Device.objects.all(), - required=False, - disabled=True, - widget=forms.HiddenInput() - ) - mark_connected = forms.NullBooleanField( - required=False, - widget=BulkEditNullBooleanSelect - ) - - class Meta: - nullable_fields = ['label', 'type', 'feed_leg', 'power_port', 'description'] - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - # Limit power_port queryset to PowerPorts which belong to the parent Device - if 'device' in self.initial: - device = Device.objects.filter(pk=self.initial['device']).first() - self.fields['power_port'].queryset = PowerPort.objects.filter(device=device) - else: - self.fields['power_port'].choices = () - self.fields['power_port'].widget.attrs['disabled'] = True - - -class PowerOutletCSVForm(CustomFieldModelCSVForm): - device = CSVModelChoiceField( - queryset=Device.objects.all(), - to_field_name='name' - ) - type = CSVChoiceField( - choices=PowerOutletTypeChoices, - required=False, - help_text='Outlet type' - ) - power_port = CSVModelChoiceField( - queryset=PowerPort.objects.all(), - required=False, - to_field_name='name', - help_text='Local power port which feeds this outlet' - ) - feed_leg = CSVChoiceField( - choices=PowerOutletFeedLegChoices, - required=False, - help_text='Electrical phase (for three-phase circuits)' - ) - - class Meta: - model = PowerOutlet - fields = ('device', 'name', 'label', 'type', 'mark_connected', 'power_port', 'feed_leg', 'description') - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - # Limit PowerPort choices to those belonging to this device (or VC master) - if self.is_bound: - try: - device = self.fields['device'].to_python(self.data['device']) - except forms.ValidationError: - device = None - else: - try: - device = self.instance.device - except Device.DoesNotExist: - device = None - - if device: - self.fields['power_port'].queryset = PowerPort.objects.filter( - device__in=[device, device.get_vc_master()] - ) - else: - self.fields['power_port'].queryset = PowerPort.objects.none() - - -# -# Interfaces -# - - -class InterfaceFilterForm(DeviceComponentFilterForm): - model = Interface - field_groups = [ - ['q', 'tag'], - ['name', 'label', 'type', 'enabled', 'mgmt_only', 'mac_address'], - ['region_id', 'site_group_id', 'site_id', 'location_id', 'device_id'], - ] - type = forms.MultipleChoiceField( - choices=InterfaceTypeChoices, - required=False, - widget=StaticSelectMultiple() - ) - enabled = forms.NullBooleanField( - required=False, - widget=StaticSelect( - choices=BOOLEAN_WITH_BLANK_CHOICES - ) - ) - mgmt_only = forms.NullBooleanField( - required=False, - widget=StaticSelect( - choices=BOOLEAN_WITH_BLANK_CHOICES - ) - ) - mac_address = forms.CharField( - required=False, - label='MAC address' - ) - tag = TagFilterField(model) - - -class InterfaceForm(BootstrapMixin, InterfaceCommonForm, CustomFieldModelForm): - parent = DynamicModelChoiceField( - queryset=Interface.objects.all(), - required=False, - label='Parent interface' - ) - lag = DynamicModelChoiceField( - queryset=Interface.objects.all(), - required=False, - label='LAG interface', - query_params={ - 'type': 'lag', - } - ) - vlan_group = DynamicModelChoiceField( - queryset=VLANGroup.objects.all(), - required=False, - label='VLAN group' - ) - untagged_vlan = DynamicModelChoiceField( - queryset=VLAN.objects.all(), - required=False, - label='Untagged VLAN', - query_params={ - 'group_id': '$vlan_group', - } - ) - tagged_vlans = DynamicModelMultipleChoiceField( - queryset=VLAN.objects.all(), - required=False, - label='Tagged VLANs', - query_params={ - 'group_id': '$vlan_group', - } - ) - tags = DynamicModelMultipleChoiceField( - queryset=Tag.objects.all(), - required=False - ) - - class Meta: - model = Interface - fields = [ - 'device', 'name', 'label', 'type', 'enabled', 'parent', 'lag', 'mac_address', 'mtu', 'mgmt_only', - 'mark_connected', 'description', 'mode', 'untagged_vlan', 'tagged_vlans', 'tags', - ] - widgets = { - 'device': forms.HiddenInput(), - 'type': StaticSelect(), - 'mode': StaticSelect(), - } - labels = { - 'mode': '802.1Q Mode', - } - help_texts = { - 'mode': INTERFACE_MODE_HELP_TEXT, - } - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - device = Device.objects.get(pk=self.data['device']) if self.is_bound else self.instance.device - - # Restrict parent/LAG interface assignment by device/VC - self.fields['parent'].widget.add_query_param('device_id', device.pk) - if device.virtual_chassis and device.virtual_chassis.master: - # Get available LAG interfaces by VirtualChassis master - self.fields['lag'].widget.add_query_param('device_id', device.virtual_chassis.master.pk) - else: - self.fields['lag'].widget.add_query_param('device_id', device.pk) - - # Limit VLAN choices by device - self.fields['untagged_vlan'].widget.add_query_param('available_on_device', device.pk) - self.fields['tagged_vlans'].widget.add_query_param('available_on_device', device.pk) - - -class InterfaceCreateForm(ComponentCreateForm, InterfaceCommonForm): - model = Interface - type = forms.ChoiceField( - choices=InterfaceTypeChoices, - widget=StaticSelect(), - ) - enabled = forms.BooleanField( - required=False, - initial=True - ) - parent = DynamicModelChoiceField( - queryset=Interface.objects.all(), - required=False, - query_params={ - 'device_id': '$device', - } - ) - lag = DynamicModelChoiceField( - queryset=Interface.objects.all(), - required=False, - query_params={ - 'device_id': '$device', - 'type': 'lag', - } - ) - mac_address = forms.CharField( - required=False, - label='MAC Address' - ) - mgmt_only = forms.BooleanField( - required=False, - label='Management only', - help_text='This interface is used only for out-of-band management' - ) - mode = forms.ChoiceField( - choices=add_blank_choice(InterfaceModeChoices), - required=False, - widget=StaticSelect(), - ) - untagged_vlan = DynamicModelChoiceField( - queryset=VLAN.objects.all(), - required=False - ) - tagged_vlans = DynamicModelMultipleChoiceField( - queryset=VLAN.objects.all(), - required=False - ) - field_order = ( - 'device', 'name_pattern', 'label_pattern', 'type', 'enabled', 'parent', 'lag', 'mtu', 'mac_address', - 'description', 'mgmt_only', 'mark_connected', 'mode', 'untagged_vlan', 'tagged_vlans', 'tags' - ) - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - # Limit VLAN choices by device - device_id = self.initial.get('device') or self.data.get('device') - self.fields['untagged_vlan'].widget.add_query_param('available_on_device', device_id) - self.fields['tagged_vlans'].widget.add_query_param('available_on_device', device_id) - - -class InterfaceBulkCreateForm( - form_from_model(Interface, ['type', 'enabled', 'mtu', 'mgmt_only', 'mark_connected']), - DeviceBulkAddComponentForm -): - model = Interface - field_order = ( - 'name_pattern', 'label_pattern', 'type', 'enabled', 'mtu', 'mgmt_only', 'mark_connected', 'description', 'tags', - ) - - -class InterfaceBulkEditForm( - form_from_model(Interface, [ - 'label', 'type', 'parent', 'lag', 'mac_address', 'mtu', 'mgmt_only', 'mark_connected', 'description', 'mode', - ]), - BootstrapMixin, - AddRemoveTagsForm, - CustomFieldModelBulkEditForm -): - pk = forms.ModelMultipleChoiceField( - queryset=Interface.objects.all(), - widget=forms.MultipleHiddenInput() - ) - device = forms.ModelChoiceField( - queryset=Device.objects.all(), - required=False, - disabled=True, - widget=forms.HiddenInput() - ) - enabled = forms.NullBooleanField( - required=False, - widget=BulkEditNullBooleanSelect - ) - parent = DynamicModelChoiceField( - queryset=Interface.objects.all(), - required=False - ) - lag = DynamicModelChoiceField( - queryset=Interface.objects.all(), - required=False, - query_params={ - 'type': 'lag', - } - ) - mgmt_only = forms.NullBooleanField( - required=False, - widget=BulkEditNullBooleanSelect, - label='Management only' - ) - mark_connected = forms.NullBooleanField( - required=False, - widget=BulkEditNullBooleanSelect - ) - untagged_vlan = DynamicModelChoiceField( - queryset=VLAN.objects.all(), - required=False - ) - tagged_vlans = DynamicModelMultipleChoiceField( - queryset=VLAN.objects.all(), - required=False - ) - - class Meta: - nullable_fields = [ - 'label', 'parent', 'lag', 'mac_address', 'mtu', 'description', 'mode', 'untagged_vlan', 'tagged_vlans' - ] - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - if 'device' in self.initial: - device = Device.objects.filter(pk=self.initial['device']).first() - - # Restrict parent/LAG interface assignment by device - self.fields['parent'].widget.add_query_param('device_id', device.pk) - self.fields['lag'].widget.add_query_param('device_id', device.pk) - - # Limit VLAN choices by device - self.fields['untagged_vlan'].widget.add_query_param('available_on_device', device.pk) - self.fields['tagged_vlans'].widget.add_query_param('available_on_device', device.pk) - - else: - # See #4523 - if 'pk' in self.initial: - site = None - interfaces = Interface.objects.filter(pk__in=self.initial['pk']).prefetch_related('device__site') - - # Check interface sites. First interface should set site, further interfaces will either continue the - # loop or reset back to no site and break the loop. - for interface in interfaces: - if site is None: - site = interface.device.site - elif interface.device.site is not site: - site = None - break - - if site is not None: - self.fields['untagged_vlan'].widget.add_query_param('site_id', site.pk) - self.fields['tagged_vlans'].widget.add_query_param('site_id', site.pk) - - self.fields['parent'].choices = () - self.fields['parent'].widget.attrs['disabled'] = True - self.fields['lag'].choices = () - self.fields['lag'].widget.attrs['disabled'] = True - - def clean(self): - super().clean() - - # Untagged interfaces cannot be assigned tagged VLANs - if self.cleaned_data['mode'] == InterfaceModeChoices.MODE_ACCESS and self.cleaned_data['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'] == InterfaceModeChoices.MODE_TAGGED_ALL: - self.cleaned_data['tagged_vlans'] = [] - - -class InterfaceCSVForm(CustomFieldModelCSVForm): - device = CSVModelChoiceField( - queryset=Device.objects.all(), - to_field_name='name' - ) - parent = CSVModelChoiceField( - queryset=Interface.objects.all(), - required=False, - to_field_name='name', - help_text='Parent interface' - ) - lag = CSVModelChoiceField( - queryset=Interface.objects.all(), - required=False, - to_field_name='name', - help_text='Parent LAG interface' - ) - type = CSVChoiceField( - choices=InterfaceTypeChoices, - help_text='Physical medium' - ) - mode = CSVChoiceField( - choices=InterfaceModeChoices, - required=False, - help_text='IEEE 802.1Q operational mode (for L2 interfaces)' - ) - - class Meta: - model = Interface - fields = ( - 'device', 'name', 'label', 'parent', 'lag', 'type', 'enabled', 'mark_connected', 'mac_address', 'mtu', - 'mgmt_only', 'description', 'mode', - ) - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - # Limit LAG choices to interfaces belonging to this device (or virtual chassis) - device = None - if self.is_bound and 'device' in self.data: - try: - device = self.fields['device'].to_python(self.data['device']) - except forms.ValidationError: - pass - if device and device.virtual_chassis: - self.fields['lag'].queryset = Interface.objects.filter( - Q(device=device) | Q(device__virtual_chassis=device.virtual_chassis), - type=InterfaceTypeChoices.TYPE_LAG - ) - self.fields['parent'].queryset = Interface.objects.filter( - Q(device=device) | Q(device__virtual_chassis=device.virtual_chassis) - ) - elif device: - self.fields['lag'].queryset = Interface.objects.filter( - device=device, - type=InterfaceTypeChoices.TYPE_LAG - ) - self.fields['parent'].queryset = Interface.objects.filter(device=device) - else: - self.fields['lag'].queryset = Interface.objects.none() - self.fields['parent'].queryset = Interface.objects.none() - - def clean_enabled(self): - # Make sure enabled is True when it's not included in the uploaded data - if 'enabled' not in self.data: - return True - else: - return self.cleaned_data['enabled'] - - -# -# Front pass-through ports -# - -class FrontPortFilterForm(DeviceComponentFilterForm): - field_groups = [ - ['q', 'tag'], - ['name', 'label', 'type', 'color'], - ['region_id', 'site_group_id', 'site_id', 'location_id', 'device_id'], - ] - model = FrontPort - type = forms.MultipleChoiceField( - choices=PortTypeChoices, - required=False, - widget=StaticSelectMultiple() - ) - color = ColorField( - required=False - ) - tag = TagFilterField(model) - - -class FrontPortForm(BootstrapMixin, CustomFieldModelForm): - tags = DynamicModelMultipleChoiceField( - queryset=Tag.objects.all(), - required=False - ) - - class Meta: - model = FrontPort - fields = [ - 'device', 'name', 'label', 'type', 'color', 'rear_port', 'rear_port_position', 'mark_connected', - 'description', 'tags', - ] - widgets = { - 'device': forms.HiddenInput(), - 'type': StaticSelect(), - 'rear_port': StaticSelect(), - } - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - # Limit RearPort choices to the local device - if hasattr(self.instance, 'device'): - self.fields['rear_port'].queryset = self.fields['rear_port'].queryset.filter( - device=self.instance.device - ) - - -# TODO: Merge with FrontPortTemplateCreateForm to remove duplicate logic -class FrontPortCreateForm(ComponentCreateForm): - model = FrontPort - type = forms.ChoiceField( - choices=PortTypeChoices, - widget=StaticSelect(), - ) - color = ColorField( - required=False - ) - rear_port_set = forms.MultipleChoiceField( - choices=[], - label='Rear ports', - help_text='Select one rear port assignment for each front port being created.', - ) - field_order = ( - 'device', 'name_pattern', 'label_pattern', 'type', 'color', 'rear_port_set', 'mark_connected', 'description', - 'tags', - ) - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - device = Device.objects.get( - pk=self.initial.get('device') or self.data.get('device') - ) - - # Determine which rear port positions are occupied. These will be excluded from the list of available - # mappings. - occupied_port_positions = [ - (front_port.rear_port_id, front_port.rear_port_position) - for front_port in device.frontports.all() - ] - - # Populate rear port choices - choices = [] - rear_ports = RearPort.objects.filter(device=device) - for rear_port in rear_ports: - for i in range(1, rear_port.positions + 1): - if (rear_port.pk, i) not in occupied_port_positions: - choices.append( - ('{}:{}'.format(rear_port.pk, i), '{}:{}'.format(rear_port.name, i)) - ) - self.fields['rear_port_set'].choices = choices - - def clean(self): - super().clean() - - # Validate that the number of ports being created equals the number of selected (rear port, position) tuples - front_port_count = len(self.cleaned_data['name_pattern']) - rear_port_count = len(self.cleaned_data['rear_port_set']) - if front_port_count != rear_port_count: - raise forms.ValidationError({ - 'rear_port_set': 'The provided name pattern will create {} ports, however {} rear port assignments ' - 'were selected. These counts must match.'.format(front_port_count, rear_port_count) - }) - - def get_iterative_data(self, iteration): - - # Assign rear port and position from selected set - rear_port, position = self.cleaned_data['rear_port_set'][iteration].split(':') - - return { - 'rear_port': int(rear_port), - 'rear_port_position': int(position), - } - - -# class FrontPortBulkCreateForm( -# form_from_model(FrontPort, ['label', 'type', 'description', 'tags']), -# DeviceBulkAddComponentForm -# ): -# pass - - -class FrontPortBulkEditForm( - form_from_model(FrontPort, ['label', 'type', 'color', 'mark_connected', 'description']), - BootstrapMixin, - AddRemoveTagsForm, - CustomFieldModelBulkEditForm -): - pk = forms.ModelMultipleChoiceField( - queryset=FrontPort.objects.all(), - widget=forms.MultipleHiddenInput() - ) - - class Meta: - nullable_fields = ['label', 'description'] - - -class FrontPortCSVForm(CustomFieldModelCSVForm): - device = CSVModelChoiceField( - queryset=Device.objects.all(), - to_field_name='name' - ) - rear_port = CSVModelChoiceField( - queryset=RearPort.objects.all(), - to_field_name='name', - help_text='Corresponding rear port' - ) - type = CSVChoiceField( - choices=PortTypeChoices, - help_text='Physical medium classification' - ) - - class Meta: - model = FrontPort - fields = ( - 'device', 'name', 'label', 'type', 'color', 'mark_connected', 'rear_port', 'rear_port_position', - 'description', - ) - help_texts = { - 'rear_port_position': 'Mapped position on corresponding rear port', - } - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - # Limit RearPort choices to those belonging to this device (or VC master) - if self.is_bound: - try: - device = self.fields['device'].to_python(self.data['device']) - except forms.ValidationError: - device = None - else: - try: - device = self.instance.device - except Device.DoesNotExist: - device = None - - if device: - self.fields['rear_port'].queryset = RearPort.objects.filter( - device__in=[device, device.get_vc_master()] - ) - else: - self.fields['rear_port'].queryset = RearPort.objects.none() - - -# -# Rear pass-through ports -# - -class RearPortFilterForm(DeviceComponentFilterForm): - model = RearPort - field_groups = [ - ['q', 'tag'], - ['name', 'label', 'type', 'color'], - ['region_id', 'site_group_id', 'site_id', 'location_id', 'device_id'], - ] - type = forms.MultipleChoiceField( - choices=PortTypeChoices, - required=False, - widget=StaticSelectMultiple() - ) - color = ColorField( - required=False - ) - tag = TagFilterField(model) - - -class RearPortForm(BootstrapMixin, CustomFieldModelForm): - tags = DynamicModelMultipleChoiceField( - queryset=Tag.objects.all(), - required=False - ) - - class Meta: - model = RearPort - fields = [ - 'device', 'name', 'label', 'type', 'color', 'positions', 'mark_connected', 'description', 'tags', - ] - widgets = { - 'device': forms.HiddenInput(), - 'type': StaticSelect(), - } - - -class RearPortCreateForm(ComponentCreateForm): - model = RearPort - type = forms.ChoiceField( - choices=PortTypeChoices, - widget=StaticSelect(), - ) - color = ColorField( - required=False - ) - positions = forms.IntegerField( - min_value=REARPORT_POSITIONS_MIN, - max_value=REARPORT_POSITIONS_MAX, - initial=1, - help_text='The number of front ports which may be mapped to each rear port' - ) - field_order = ( - 'device', 'name_pattern', 'label_pattern', 'type', 'color', 'positions', 'mark_connected', 'description', - 'tags', - ) - - -class RearPortBulkCreateForm( - form_from_model(RearPort, ['type', 'color', 'positions', 'mark_connected']), - DeviceBulkAddComponentForm -): - model = RearPort - field_order = ('name_pattern', 'label_pattern', 'type', 'positions', 'mark_connected', 'description', 'tags') - - -class RearPortBulkEditForm( - form_from_model(RearPort, ['label', 'type', 'color', 'mark_connected', 'description']), - BootstrapMixin, - AddRemoveTagsForm, - CustomFieldModelBulkEditForm -): - pk = forms.ModelMultipleChoiceField( - queryset=RearPort.objects.all(), - widget=forms.MultipleHiddenInput() - ) - - class Meta: - nullable_fields = ['label', 'description'] - - -class RearPortCSVForm(CustomFieldModelCSVForm): - device = CSVModelChoiceField( - queryset=Device.objects.all(), - to_field_name='name' - ) - type = CSVChoiceField( - help_text='Physical medium classification', - choices=PortTypeChoices, - ) - - class Meta: - model = RearPort - fields = ('device', 'name', 'label', 'type', 'color', 'mark_connected', 'positions', 'description') - help_texts = { - 'positions': 'Number of front ports which may be mapped' - } - - -# -# Device bays -# - -class DeviceBayFilterForm(DeviceComponentFilterForm): - model = DeviceBay - field_groups = [ - ['q', 'tag'], - ['name', 'label'], - ['region_id', 'site_group_id', 'site_id', 'location_id', 'device_id'], - ] - tag = TagFilterField(model) - - -class DeviceBayForm(BootstrapMixin, CustomFieldModelForm): - tags = DynamicModelMultipleChoiceField( - queryset=Tag.objects.all(), - required=False - ) - - class Meta: - model = DeviceBay - fields = [ - 'device', 'name', 'label', 'description', 'tags', - ] - widgets = { - 'device': forms.HiddenInput(), - } - - -class DeviceBayCreateForm(ComponentCreateForm): - model = DeviceBay - field_order = ('device', 'name_pattern', 'label_pattern', 'description', 'tags') - - -class PopulateDeviceBayForm(BootstrapMixin, forms.Form): - installed_device = forms.ModelChoiceField( - queryset=Device.objects.all(), - label='Child Device', - help_text="Child devices must first be created and assigned to the site/rack of the parent device.", - widget=StaticSelect(), - ) - - def __init__(self, device_bay, *args, **kwargs): - - super().__init__(*args, **kwargs) - - self.fields['installed_device'].queryset = Device.objects.filter( - site=device_bay.device.site, - rack=device_bay.device.rack, - parent_bay__isnull=True, - device_type__u_height=0, - device_type__subdevice_role=SubdeviceRoleChoices.ROLE_CHILD - ).exclude(pk=device_bay.device.pk) - - -class DeviceBayBulkCreateForm(DeviceBulkAddComponentForm): - model = DeviceBay - field_order = ('name_pattern', 'label_pattern', 'description', 'tags') - - -class DeviceBayBulkEditForm( - form_from_model(DeviceBay, ['label', 'description']), - BootstrapMixin, - AddRemoveTagsForm, - CustomFieldModelBulkEditForm -): - pk = forms.ModelMultipleChoiceField( - queryset=DeviceBay.objects.all(), - widget=forms.MultipleHiddenInput() - ) - - class Meta: - nullable_fields = ['label', 'description'] - - -class DeviceBayCSVForm(CustomFieldModelCSVForm): - device = CSVModelChoiceField( - queryset=Device.objects.all(), - to_field_name='name' - ) - installed_device = CSVModelChoiceField( - queryset=Device.objects.all(), - required=False, - to_field_name='name', - help_text='Child device installed within this bay', - error_messages={ - 'invalid_choice': 'Child device not found.', - } - ) - - class Meta: - model = DeviceBay - fields = ('device', 'name', 'label', 'installed_device', 'description') - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - # Limit installed device choices to devices of the correct type and location - if self.is_bound: - try: - device = self.fields['device'].to_python(self.data['device']) - except forms.ValidationError: - device = None - else: - try: - device = self.instance.device - except Device.DoesNotExist: - device = None - - if device: - self.fields['installed_device'].queryset = Device.objects.filter( - site=device.site, - rack=device.rack, - parent_bay__isnull=True, - device_type__u_height=0, - device_type__subdevice_role=SubdeviceRoleChoices.ROLE_CHILD - ).exclude(pk=device.pk) - else: - self.fields['installed_device'].queryset = Interface.objects.none() - - -# -# Inventory items -# - -class InventoryItemForm(BootstrapMixin, CustomFieldModelForm): - device = DynamicModelChoiceField( - queryset=Device.objects.all() - ) - parent = DynamicModelChoiceField( - queryset=InventoryItem.objects.all(), - required=False, - query_params={ - 'device_id': '$device' - } - ) - manufacturer = DynamicModelChoiceField( - queryset=Manufacturer.objects.all(), - required=False - ) - tags = DynamicModelMultipleChoiceField( - queryset=Tag.objects.all(), - required=False - ) - - class Meta: - model = InventoryItem - fields = [ - 'device', 'parent', 'name', 'label', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'description', - 'tags', - ] - - -class InventoryItemCreateForm(ComponentCreateForm): - model = InventoryItem - manufacturer = DynamicModelChoiceField( - queryset=Manufacturer.objects.all(), - required=False - ) - parent = DynamicModelChoiceField( - queryset=InventoryItem.objects.all(), - required=False, - query_params={ - 'device_id': '$device' - } - ) - part_id = forms.CharField( - max_length=50, - required=False, - label='Part ID' - ) - serial = forms.CharField( - max_length=50, - required=False, - ) - asset_tag = forms.CharField( - max_length=50, - required=False, - ) - field_order = ( - 'device', 'parent', 'name_pattern', 'label_pattern', 'manufacturer', 'part_id', 'serial', 'asset_tag', - 'description', 'tags', - ) - - -class InventoryItemCSVForm(CustomFieldModelCSVForm): - device = CSVModelChoiceField( - queryset=Device.objects.all(), - to_field_name='name' - ) - manufacturer = CSVModelChoiceField( - queryset=Manufacturer.objects.all(), - to_field_name='name', - required=False - ) - parent = CSVModelChoiceField( - queryset=Device.objects.all(), - to_field_name='name', - required=False, - help_text='Parent inventory item' - ) - - class Meta: - model = InventoryItem - fields = ( - 'device', 'name', 'label', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'discovered', 'description', - ) - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - # Limit parent choices to inventory items belonging to this device - device = None - if self.is_bound and 'device' in self.data: - try: - device = self.fields['device'].to_python(self.data['device']) - except forms.ValidationError: - pass - if device: - self.fields['parent'].queryset = InventoryItem.objects.filter(device=device) - else: - self.fields['parent'].queryset = InventoryItem.objects.none() - - -class InventoryItemBulkCreateForm( - form_from_model(InventoryItem, ['manufacturer', 'part_id', 'serial', 'asset_tag', 'discovered']), - DeviceBulkAddComponentForm -): - model = InventoryItem - field_order = ( - 'name_pattern', 'label_pattern', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'discovered', 'description', - 'tags', - ) - - -class InventoryItemBulkEditForm( - form_from_model(InventoryItem, ['label', 'manufacturer', 'part_id', 'description']), - BootstrapMixin, - AddRemoveTagsForm, - CustomFieldModelBulkEditForm -): - pk = forms.ModelMultipleChoiceField( - queryset=InventoryItem.objects.all(), - widget=forms.MultipleHiddenInput() - ) - manufacturer = DynamicModelChoiceField( - queryset=Manufacturer.objects.all(), - required=False - ) - - class Meta: - nullable_fields = ['label', 'manufacturer', 'part_id', 'description'] - - -class InventoryItemFilterForm(DeviceComponentFilterForm): - model = InventoryItem - field_groups = [ - ['q', 'tag'], - ['name', 'label', 'manufacturer_id', 'serial', 'asset_tag', 'discovered'], - ['region_id', 'site_group_id', 'site_id', 'location_id', 'device_id'], - ] - manufacturer_id = DynamicModelMultipleChoiceField( - queryset=Manufacturer.objects.all(), - required=False, - label=_('Manufacturer'), - fetch_trigger='open' - ) - serial = forms.CharField( - required=False - ) - asset_tag = forms.CharField( - required=False - ) - discovered = forms.NullBooleanField( - required=False, - widget=StaticSelect( - choices=BOOLEAN_WITH_BLANK_CHOICES - ) - ) - tag = TagFilterField(model) - - -# -# Cables -# - -class ConnectCableToDeviceForm(BootstrapMixin, CustomFieldModelForm): - """ - Base form for connecting a Cable to a Device component - """ - termination_b_region = DynamicModelChoiceField( - queryset=Region.objects.all(), - label='Region', - required=False - ) - termination_b_site_group = DynamicModelChoiceField( - queryset=SiteGroup.objects.all(), - label='Site group', - required=False - ) - termination_b_site = DynamicModelChoiceField( - queryset=Site.objects.all(), - label='Site', - required=False, - query_params={ - 'region_id': '$termination_b_region', - 'group_id': '$termination_b_site_group', - } - ) - termination_b_location = DynamicModelChoiceField( - queryset=Location.objects.all(), - label='Location', - required=False, - null_option='None', - query_params={ - 'site_id': '$termination_b_site' - } - ) - termination_b_rack = DynamicModelChoiceField( - queryset=Rack.objects.all(), - label='Rack', - required=False, - null_option='None', - query_params={ - 'site_id': '$termination_b_site', - 'location_id': '$termination_b_location', - } - ) - termination_b_device = DynamicModelChoiceField( - queryset=Device.objects.all(), - label='Device', - required=False, - query_params={ - 'site_id': '$termination_b_site', - 'location_id': '$termination_b_location', - 'rack_id': '$termination_b_rack', - } - ) - tags = DynamicModelMultipleChoiceField( - queryset=Tag.objects.all(), - required=False - ) - - class Meta: - model = Cable - fields = [ - 'termination_b_region', 'termination_b_site', 'termination_b_rack', 'termination_b_device', - 'termination_b_id', 'type', 'status', 'label', 'color', 'length', 'length_unit', 'tags', - ] - widgets = { - 'status': StaticSelect, - 'type': StaticSelect, - 'length_unit': StaticSelect, - } - - def clean_termination_b_id(self): - # Return the PK rather than the object - return getattr(self.cleaned_data['termination_b_id'], 'pk', None) - - -class ConnectCableToConsolePortForm(ConnectCableToDeviceForm): - termination_b_id = DynamicModelChoiceField( - queryset=ConsolePort.objects.all(), - label='Name', - disabled_indicator='_occupied', - query_params={ - 'device_id': '$termination_b_device' - } - ) - - -class ConnectCableToConsoleServerPortForm(ConnectCableToDeviceForm): - termination_b_id = DynamicModelChoiceField( - queryset=ConsoleServerPort.objects.all(), - label='Name', - disabled_indicator='_occupied', - query_params={ - 'device_id': '$termination_b_device' - } - ) - - -class ConnectCableToPowerPortForm(ConnectCableToDeviceForm): - termination_b_id = DynamicModelChoiceField( - queryset=PowerPort.objects.all(), - label='Name', - disabled_indicator='_occupied', - query_params={ - 'device_id': '$termination_b_device' - } - ) - - -class ConnectCableToPowerOutletForm(ConnectCableToDeviceForm): - termination_b_id = DynamicModelChoiceField( - queryset=PowerOutlet.objects.all(), - label='Name', - disabled_indicator='_occupied', - query_params={ - 'device_id': '$termination_b_device' - } - ) - - -class ConnectCableToInterfaceForm(ConnectCableToDeviceForm): - termination_b_id = DynamicModelChoiceField( - queryset=Interface.objects.all(), - label='Name', - disabled_indicator='_occupied', - query_params={ - 'device_id': '$termination_b_device', - 'kind': 'physical', - } - ) - - -class ConnectCableToFrontPortForm(ConnectCableToDeviceForm): - termination_b_id = DynamicModelChoiceField( - queryset=FrontPort.objects.all(), - label='Name', - disabled_indicator='_occupied', - query_params={ - 'device_id': '$termination_b_device' - } - ) - - -class ConnectCableToRearPortForm(ConnectCableToDeviceForm): - termination_b_id = DynamicModelChoiceField( - queryset=RearPort.objects.all(), - label='Name', - disabled_indicator='_occupied', - query_params={ - 'device_id': '$termination_b_device' - } - ) - - -class ConnectCableToCircuitTerminationForm(BootstrapMixin, CustomFieldModelForm): - termination_b_provider = DynamicModelChoiceField( - queryset=Provider.objects.all(), - label='Provider', - required=False - ) - termination_b_region = DynamicModelChoiceField( - queryset=Region.objects.all(), - label='Region', - required=False - ) - termination_b_site_group = DynamicModelChoiceField( - queryset=SiteGroup.objects.all(), - label='Site group', - required=False - ) - termination_b_site = DynamicModelChoiceField( - queryset=Site.objects.all(), - label='Site', - required=False, - query_params={ - 'region_id': '$termination_b_region', - 'group_id': '$termination_b_site_group', - } - ) - termination_b_circuit = DynamicModelChoiceField( - queryset=Circuit.objects.all(), - label='Circuit', - query_params={ - 'provider_id': '$termination_b_provider', - 'site_id': '$termination_b_site', - } - ) - termination_b_id = DynamicModelChoiceField( - queryset=CircuitTermination.objects.all(), - label='Side', - disabled_indicator='_occupied', - query_params={ - 'circuit_id': '$termination_b_circuit' - } - ) - tags = DynamicModelMultipleChoiceField( - queryset=Tag.objects.all(), - required=False - ) - - class Meta: - model = Cable - fields = [ - 'termination_b_provider', 'termination_b_region', 'termination_b_site', 'termination_b_circuit', - 'termination_b_id', 'type', 'status', 'label', 'color', 'length', 'length_unit', 'tags', - ] - - def clean_termination_b_id(self): - # Return the PK rather than the object - return getattr(self.cleaned_data['termination_b_id'], 'pk', None) - - -class ConnectCableToPowerFeedForm(BootstrapMixin, CustomFieldModelForm): - termination_b_region = DynamicModelChoiceField( - queryset=Region.objects.all(), - label='Region', - required=False - ) - termination_b_site_group = DynamicModelChoiceField( - queryset=SiteGroup.objects.all(), - label='Site group', - required=False - ) - termination_b_site = DynamicModelChoiceField( - queryset=Site.objects.all(), - label='Site', - required=False, - query_params={ - 'region_id': '$termination_b_region', - 'group_id': '$termination_b_site_group', - } - ) - termination_b_location = DynamicModelChoiceField( - queryset=Location.objects.all(), - label='Location', - required=False, - query_params={ - 'site_id': '$termination_b_site' - } - ) - termination_b_powerpanel = DynamicModelChoiceField( - queryset=PowerPanel.objects.all(), - label='Power Panel', - required=False, - query_params={ - 'site_id': '$termination_b_site', - 'location_id': '$termination_b_location', - } - ) - termination_b_id = DynamicModelChoiceField( - queryset=PowerFeed.objects.all(), - label='Name', - disabled_indicator='_occupied', - query_params={ - 'power_panel_id': '$termination_b_powerpanel' - } - ) - tags = DynamicModelMultipleChoiceField( - queryset=Tag.objects.all(), - required=False - ) - - class Meta: - model = Cable - fields = [ - 'termination_b_location', 'termination_b_powerpanel', 'termination_b_id', 'type', 'status', 'label', - 'color', 'length', 'length_unit', 'tags', - ] - - def clean_termination_b_id(self): - # Return the PK rather than the object - return getattr(self.cleaned_data['termination_b_id'], 'pk', None) - - -class CableForm(BootstrapMixin, CustomFieldModelForm): - tags = DynamicModelMultipleChoiceField( - queryset=Tag.objects.all(), - required=False - ) - - class Meta: - model = Cable - fields = [ - 'type', 'status', 'label', 'color', 'length', 'length_unit', 'tags', - ] - widgets = { - 'status': StaticSelect, - 'type': StaticSelect, - 'length_unit': StaticSelect, - } - error_messages = { - 'length': { - 'max_value': 'Maximum length is 32767 (any unit)' - } - } - - -class CableCSVForm(CustomFieldModelCSVForm): - # Termination A - side_a_device = CSVModelChoiceField( - queryset=Device.objects.all(), - to_field_name='name', - help_text='Side A device' - ) - side_a_type = CSVContentTypeField( - queryset=ContentType.objects.all(), - limit_choices_to=CABLE_TERMINATION_MODELS, - help_text='Side A type' - ) - side_a_name = forms.CharField( - help_text='Side A component name' - ) - - # Termination B - side_b_device = CSVModelChoiceField( - queryset=Device.objects.all(), - to_field_name='name', - help_text='Side B device' - ) - side_b_type = CSVContentTypeField( - queryset=ContentType.objects.all(), - limit_choices_to=CABLE_TERMINATION_MODELS, - help_text='Side B type' - ) - side_b_name = forms.CharField( - help_text='Side B component name' - ) - - # Cable attributes - status = CSVChoiceField( - choices=CableStatusChoices, - required=False, - help_text='Connection status' - ) - type = CSVChoiceField( - choices=CableTypeChoices, - required=False, - help_text='Physical medium classification' - ) - length_unit = CSVChoiceField( - choices=CableLengthUnitChoices, - required=False, - help_text='Length unit' - ) - - class Meta: - model = Cable - fields = [ - 'side_a_device', 'side_a_type', 'side_a_name', 'side_b_device', 'side_b_type', 'side_b_name', 'type', - 'status', 'label', 'color', 'length', 'length_unit', - ] - help_texts = { - 'color': mark_safe('RGB color in hexadecimal (e.g. 00ff00)'), - } - - def _clean_side(self, side): - """ - Derive a Cable's A/B termination objects. - - :param side: 'a' or 'b' - """ - assert side in 'ab', f"Invalid side designation: {side}" - - device = self.cleaned_data.get(f'side_{side}_device') - content_type = self.cleaned_data.get(f'side_{side}_type') - name = self.cleaned_data.get(f'side_{side}_name') - if not device or not content_type or not name: - return None - - model = content_type.model_class() - try: - termination_object = model.objects.get(device=device, name=name) - if termination_object.cable is not None: - raise forms.ValidationError(f"Side {side.upper()}: {device} {termination_object} is already connected") - except ObjectDoesNotExist: - raise forms.ValidationError(f"{side.upper()} side termination not found: {device} {name}") - - setattr(self.instance, f'termination_{side}', termination_object) - return termination_object - - def clean_side_a_name(self): - return self._clean_side('a') - - def clean_side_b_name(self): - return self._clean_side('b') - - def clean_length_unit(self): - # Avoid trying to save as NULL - length_unit = self.cleaned_data.get('length_unit', None) - return length_unit if length_unit is not None else '' - - -class CableBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm): - pk = forms.ModelMultipleChoiceField( - queryset=Cable.objects.all(), - widget=forms.MultipleHiddenInput - ) - type = forms.ChoiceField( - choices=add_blank_choice(CableTypeChoices), - required=False, - initial='', - widget=StaticSelect() - ) - status = forms.ChoiceField( - choices=add_blank_choice(CableStatusChoices), - required=False, - widget=StaticSelect(), - initial='' - ) - label = forms.CharField( - max_length=100, - required=False - ) - color = ColorField( - required=False - ) - length = forms.DecimalField( - min_value=0, - required=False - ) - length_unit = forms.ChoiceField( - choices=add_blank_choice(CableLengthUnitChoices), - required=False, - initial='', - widget=StaticSelect() - ) - - class Meta: - nullable_fields = [ - 'type', 'status', 'label', 'color', 'length', - ] - - def clean(self): - super().clean() - - # Validate length/unit - length = self.cleaned_data.get('length') - length_unit = self.cleaned_data.get('length_unit') - if length and not length_unit: - raise forms.ValidationError({ - 'length_unit': "Must specify a unit when setting length" - }) - - -class CableFilterForm(BootstrapMixin, CustomFieldModelFilterForm): - model = Cable - field_groups = [ - ['q', 'tag'], - ['site_id', 'rack_id', 'device_id'], - ['type', 'status', 'color'], - ['tenant_id'], - ] - q = forms.CharField( - required=False, - widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), - label=_('Search') - ) - region_id = DynamicModelMultipleChoiceField( - queryset=Region.objects.all(), - required=False, - label=_('Region'), - fetch_trigger='open' - ) - site_id = DynamicModelMultipleChoiceField( - queryset=Site.objects.all(), - required=False, - query_params={ - 'region_id': '$region_id' - }, - label=_('Site'), - fetch_trigger='open' - ) - tenant_id = DynamicModelMultipleChoiceField( - queryset=Tenant.objects.all(), - required=False, - label=_('Tenant'), - fetch_trigger='open' - ) - rack_id = DynamicModelMultipleChoiceField( - queryset=Rack.objects.all(), - required=False, - label=_('Rack'), - null_option='None', - query_params={ - 'site_id': '$site_id' - }, - fetch_trigger='open' - ) - type = forms.MultipleChoiceField( - choices=add_blank_choice(CableTypeChoices), - required=False, - widget=StaticSelect() - ) - status = forms.ChoiceField( - required=False, - choices=add_blank_choice(CableStatusChoices), - widget=StaticSelect() - ) - color = ColorField( - required=False - ) - device_id = DynamicModelMultipleChoiceField( - queryset=Device.objects.all(), - required=False, - query_params={ - 'site_id': '$site_id', - 'tenant_id': '$tenant_id', - 'rack_id': '$rack_id', - }, - label=_('Device'), - fetch_trigger='open' - ) - tag = TagFilterField(model) - - -# -# Connections -# - -class ConsoleConnectionFilterForm(BootstrapMixin, forms.Form): - region_id = DynamicModelMultipleChoiceField( - queryset=Region.objects.all(), - required=False, - label=_('Region'), - fetch_trigger='open' - ) - site_id = DynamicModelMultipleChoiceField( - queryset=Site.objects.all(), - required=False, - query_params={ - 'region_id': '$region_id' - }, - label=_('Site'), - fetch_trigger='open' - ) - device_id = DynamicModelMultipleChoiceField( - queryset=Device.objects.all(), - required=False, - query_params={ - 'site_id': '$site_id' - }, - label=_('Device'), - fetch_trigger='open' - ) - - -class PowerConnectionFilterForm(BootstrapMixin, forms.Form): - region_id = DynamicModelMultipleChoiceField( - queryset=Region.objects.all(), - required=False, - label=_('Region'), - fetch_trigger='open' - ) - site_id = DynamicModelMultipleChoiceField( - queryset=Site.objects.all(), - required=False, - query_params={ - 'region_id': '$region_id' - }, - label=_('Site'), - fetch_trigger='open' - ) - device_id = DynamicModelMultipleChoiceField( - queryset=Device.objects.all(), - required=False, - query_params={ - 'site_id': '$site_id' - }, - label=_('Device'), - fetch_trigger='open' - ) - - -class InterfaceConnectionFilterForm(BootstrapMixin, forms.Form): - region_id = DynamicModelMultipleChoiceField( - queryset=Region.objects.all(), - required=False, - label=_('Region'), - fetch_trigger='open' - ) - site_id = DynamicModelMultipleChoiceField( - queryset=Site.objects.all(), - required=False, - query_params={ - 'region_id': '$region_id' - }, - label=_('Site'), - fetch_trigger='open' - ) - device_id = DynamicModelMultipleChoiceField( - queryset=Device.objects.all(), - required=False, - query_params={ - 'site_id': '$site_id' - }, - label=_('Device'), - fetch_trigger='open' - ) - - -# -# Virtual chassis -# - -class DeviceSelectionForm(forms.Form): - pk = forms.ModelMultipleChoiceField( - queryset=Device.objects.all(), - widget=forms.MultipleHiddenInput() - ) - - -class VirtualChassisCreateForm(BootstrapMixin, CustomFieldModelForm): - region = DynamicModelChoiceField( - queryset=Region.objects.all(), - required=False, - initial_params={ - 'sites': '$site' - } - ) - site_group = DynamicModelChoiceField( - queryset=SiteGroup.objects.all(), - required=False, - initial_params={ - 'sites': '$site' - } - ) - site = DynamicModelChoiceField( - queryset=Site.objects.all(), - required=False, - query_params={ - 'region_id': '$region', - 'group_id': '$site_group', - } - ) - rack = DynamicModelChoiceField( - queryset=Rack.objects.all(), - required=False, - null_option='None', - query_params={ - 'site_id': '$site' - } - ) - members = DynamicModelMultipleChoiceField( - queryset=Device.objects.all(), - required=False, - query_params={ - 'site_id': '$site', - 'rack_id': '$rack', - } - ) - initial_position = forms.IntegerField( - initial=1, - required=False, - help_text='Position of the first member device. Increases by one for each additional member.' - ) - tags = DynamicModelMultipleChoiceField( - queryset=Tag.objects.all(), - required=False - ) - - class Meta: - model = VirtualChassis - fields = [ - 'name', 'domain', 'region', 'site_group', 'site', 'rack', 'members', 'initial_position', 'tags', - ] - - def save(self, *args, **kwargs): - instance = super().save(*args, **kwargs) - - # Assign VC members - if instance.pk: - initial_position = self.cleaned_data.get('initial_position') or 1 - for i, member in enumerate(self.cleaned_data['members'], start=initial_position): - member.virtual_chassis = instance - member.vc_position = i - member.save() - - return instance - - -class VirtualChassisForm(BootstrapMixin, CustomFieldModelForm): - master = forms.ModelChoiceField( - queryset=Device.objects.all(), - required=False, - ) - tags = DynamicModelMultipleChoiceField( - queryset=Tag.objects.all(), - required=False - ) - - class Meta: - model = VirtualChassis - fields = [ - 'name', 'domain', 'master', 'tags', - ] - widgets = { - 'master': SelectWithPK(), - } - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - self.fields['master'].queryset = Device.objects.filter(virtual_chassis=self.instance) - - -class BaseVCMemberFormSet(forms.BaseModelFormSet): - - def clean(self): - super().clean() - - # Check for duplicate VC position values - vc_position_list = [] - for form in self.forms: - vc_position = form.cleaned_data.get('vc_position') - if vc_position: - if vc_position in vc_position_list: - error_msg = 'A virtual chassis member already exists in position {}.'.format(vc_position) - form.add_error('vc_position', error_msg) - vc_position_list.append(vc_position) - - -class DeviceVCMembershipForm(forms.ModelForm): - - class Meta: - model = Device - fields = [ - 'vc_position', 'vc_priority', - ] - labels = { - 'vc_position': 'Position', - 'vc_priority': 'Priority', - } - - def __init__(self, validate_vc_position=False, *args, **kwargs): - super().__init__(*args, **kwargs) - - # Require VC position (only required when the Device is a VirtualChassis member) - self.fields['vc_position'].required = True - - # Add bootstrap classes to form elements. - self.fields['vc_position'].widget.attrs = {'class': 'form-control'} - self.fields['vc_priority'].widget.attrs = {'class': 'form-control'} - - # Validation of vc_position is optional. This is only required when adding a new member to an existing - # VirtualChassis. Otherwise, vc_position validation is handled by BaseVCMemberFormSet. - self.validate_vc_position = validate_vc_position - - def clean_vc_position(self): - vc_position = self.cleaned_data['vc_position'] - - if self.validate_vc_position: - conflicting_members = Device.objects.filter( - virtual_chassis=self.instance.virtual_chassis, - vc_position=vc_position - ) - if conflicting_members.exists(): - raise forms.ValidationError( - 'A virtual chassis member already exists in position {}.'.format(vc_position) - ) - - return vc_position - - -class VCMemberSelectForm(BootstrapMixin, forms.Form): - region = DynamicModelChoiceField( - queryset=Region.objects.all(), - required=False, - initial_params={ - 'sites': '$site' - } - ) - site_group = DynamicModelChoiceField( - queryset=SiteGroup.objects.all(), - required=False, - initial_params={ - 'sites': '$site' - } - ) - site = DynamicModelChoiceField( - queryset=Site.objects.all(), - required=False, - query_params={ - 'region_id': '$region', - 'group_id': '$site_group', - } - ) - rack = DynamicModelChoiceField( - queryset=Rack.objects.all(), - required=False, - null_option='None', - query_params={ - 'site_id': '$site' - } - ) - device = DynamicModelChoiceField( - queryset=Device.objects.all(), - query_params={ - 'site_id': '$site', - 'rack_id': '$rack', - 'virtual_chassis_id': 'null', - } - ) - - def clean_device(self): - device = self.cleaned_data['device'] - if device.virtual_chassis is not None: - raise forms.ValidationError( - f"Device {device} is already assigned to a virtual chassis." - ) - return device - - -class VirtualChassisBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm): - pk = forms.ModelMultipleChoiceField( - queryset=VirtualChassis.objects.all(), - widget=forms.MultipleHiddenInput() - ) - domain = forms.CharField( - max_length=30, - required=False - ) - - class Meta: - nullable_fields = ['domain'] - - -class VirtualChassisCSVForm(CustomFieldModelCSVForm): - master = CSVModelChoiceField( - queryset=Device.objects.all(), - to_field_name='name', - required=False, - help_text='Master device' - ) - - class Meta: - model = VirtualChassis - fields = ('name', 'domain', 'master') - - -class VirtualChassisFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilterForm): - model = VirtualChassis - field_order = ['q', 'region_id', 'site_group_id', 'site_id', 'tenant_group_id', 'tenant_id'] - field_groups = [ - ['q', 'tag'], - ['region_id', 'site_group_id', 'site_id'], - ['tenant_group_id', 'tenant_id'], - ] - q = forms.CharField( - required=False, - widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), - label=_('Search') - ) - region_id = DynamicModelMultipleChoiceField( - queryset=Region.objects.all(), - required=False, - label=_('Region'), - fetch_trigger='open' - ) - site_group_id = DynamicModelMultipleChoiceField( - queryset=SiteGroup.objects.all(), - required=False, - label=_('Site group'), - fetch_trigger='open' - ) - site_id = DynamicModelMultipleChoiceField( - queryset=Site.objects.all(), - required=False, - query_params={ - 'region_id': '$region_id', - 'group_id': '$site_group_id', - }, - label=_('Site'), - fetch_trigger='open' - ) - tag = TagFilterField(model) - - -# -# Power panels -# - -class PowerPanelForm(BootstrapMixin, CustomFieldModelForm): - region = DynamicModelChoiceField( - queryset=Region.objects.all(), - required=False, - initial_params={ - 'sites': '$site' - } - ) - site_group = DynamicModelChoiceField( - queryset=SiteGroup.objects.all(), - required=False, - initial_params={ - 'sites': '$site' - } - ) - site = DynamicModelChoiceField( - queryset=Site.objects.all(), - query_params={ - 'region_id': '$region', - 'group_id': '$site_group', - } - ) - location = DynamicModelChoiceField( - queryset=Location.objects.all(), - required=False, - query_params={ - 'site_id': '$site' - } - ) - tags = DynamicModelMultipleChoiceField( - queryset=Tag.objects.all(), - required=False - ) - - class Meta: - model = PowerPanel - fields = [ - 'region', 'site_group', 'site', 'location', 'name', 'tags', - ] - fieldsets = ( - ('Power Panel', ('region', 'site_group', 'site', 'location', 'name', 'tags')), - ) - - -class PowerPanelCSVForm(CustomFieldModelCSVForm): - site = CSVModelChoiceField( - queryset=Site.objects.all(), - to_field_name='name', - help_text='Name of parent site' - ) - location = CSVModelChoiceField( - queryset=Location.objects.all(), - required=False, - to_field_name='name' - ) - - class Meta: - model = PowerPanel - fields = ('site', 'location', 'name') - - def __init__(self, data=None, *args, **kwargs): - super().__init__(data, *args, **kwargs) - - if data: - - # Limit group queryset by assigned site - params = {f"site__{self.fields['site'].to_field_name}": data.get('site')} - self.fields['location'].queryset = self.fields['location'].queryset.filter(**params) - - -class PowerPanelBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm): - pk = forms.ModelMultipleChoiceField( - queryset=PowerPanel.objects.all(), - widget=forms.MultipleHiddenInput - ) - region = DynamicModelChoiceField( - queryset=Region.objects.all(), - required=False, - initial_params={ - 'sites': '$site' - } - ) - site_group = DynamicModelChoiceField( - queryset=SiteGroup.objects.all(), - required=False, - initial_params={ - 'sites': '$site' - } - ) - site = DynamicModelChoiceField( - queryset=Site.objects.all(), - required=False, - query_params={ - 'region_id': '$region', - 'group_id': '$site_group', - } - ) - location = DynamicModelChoiceField( - queryset=Location.objects.all(), - required=False, - query_params={ - 'site_id': '$site' - } - ) - - class Meta: - nullable_fields = ['location'] - - -class PowerPanelFilterForm(BootstrapMixin, CustomFieldModelFilterForm): - model = PowerPanel - field_groups = ( - ('q', 'tag'), - ('region_id', 'site_group_id', 'site_id', 'location_id') - ) - q = forms.CharField( - required=False, - widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), - label=_('Search') - ) - region_id = DynamicModelMultipleChoiceField( - queryset=Region.objects.all(), - required=False, - label=_('Region'), - fetch_trigger='open' - ) - site_group_id = DynamicModelMultipleChoiceField( - queryset=SiteGroup.objects.all(), - required=False, - label=_('Site group'), - fetch_trigger='open' - ) - site_id = DynamicModelMultipleChoiceField( - queryset=Site.objects.all(), - required=False, - query_params={ - 'region_id': '$region_id', - 'group_id': '$site_group_id', - }, - label=_('Site'), - fetch_trigger='open' - ) - location_id = DynamicModelMultipleChoiceField( - queryset=Location.objects.all(), - required=False, - null_option='None', - query_params={ - 'site_id': '$site_id' - }, - label=_('Location'), - fetch_trigger='open' - ) - tag = TagFilterField(model) - - -# -# Power feeds -# - -class PowerFeedForm(BootstrapMixin, CustomFieldModelForm): - region = DynamicModelChoiceField( - queryset=Region.objects.all(), - required=False, - initial_params={ - 'sites__powerpanel': '$power_panel' - } - ) - site_group = DynamicModelChoiceField( - queryset=SiteGroup.objects.all(), - required=False, - initial_params={ - 'sites': '$site' - } - ) - site = DynamicModelChoiceField( - queryset=Site.objects.all(), - required=False, - initial_params={ - 'powerpanel': '$power_panel' - }, - query_params={ - 'region_id': '$region', - 'group_id': '$site_group', - } - ) - power_panel = DynamicModelChoiceField( - queryset=PowerPanel.objects.all(), - query_params={ - 'site_id': '$site' - } - ) - rack = DynamicModelChoiceField( - queryset=Rack.objects.all(), - required=False, - query_params={ - 'site_id': '$site' - } - ) - comments = CommentField() - tags = DynamicModelMultipleChoiceField( - queryset=Tag.objects.all(), - required=False - ) - - class Meta: - model = PowerFeed - fields = [ - 'region', 'site_group', 'site', 'power_panel', 'rack', 'name', 'status', 'type', 'mark_connected', 'supply', - 'phase', 'voltage', 'amperage', 'max_utilization', 'comments', 'tags', - ] - fieldsets = ( - ('Power Panel', ('region', 'site', 'power_panel')), - ('Power Feed', ('rack', 'name', 'status', 'type', 'mark_connected', 'tags')), - ('Characteristics', ('supply', 'voltage', 'amperage', 'phase', 'max_utilization')), - ) - widgets = { - 'status': StaticSelect(), - 'type': StaticSelect(), - 'supply': StaticSelect(), - 'phase': StaticSelect(), - } - - -class PowerFeedCSVForm(CustomFieldModelCSVForm): - site = CSVModelChoiceField( - queryset=Site.objects.all(), - to_field_name='name', - help_text='Assigned site' - ) - power_panel = CSVModelChoiceField( - queryset=PowerPanel.objects.all(), - to_field_name='name', - help_text='Upstream power panel' - ) - location = CSVModelChoiceField( - queryset=Location.objects.all(), - to_field_name='name', - required=False, - help_text="Rack's location (if any)" - ) - rack = CSVModelChoiceField( - queryset=Rack.objects.all(), - to_field_name='name', - required=False, - help_text='Rack' - ) - status = CSVChoiceField( - choices=PowerFeedStatusChoices, - required=False, - help_text='Operational status' - ) - type = CSVChoiceField( - choices=PowerFeedTypeChoices, - required=False, - help_text='Primary or redundant' - ) - supply = CSVChoiceField( - choices=PowerFeedSupplyChoices, - required=False, - help_text='Supply type (AC/DC)' - ) - phase = CSVChoiceField( - choices=PowerFeedPhaseChoices, - required=False, - help_text='Single or three-phase' - ) - - class Meta: - model = PowerFeed - fields = ( - 'site', 'power_panel', 'location', 'rack', 'name', 'status', 'type', 'mark_connected', 'supply', 'phase', - 'voltage', 'amperage', 'max_utilization', 'comments', - ) - - def __init__(self, data=None, *args, **kwargs): - super().__init__(data, *args, **kwargs) - - if data: - - # Limit power_panel queryset by site - params = {f"site__{self.fields['site'].to_field_name}": data.get('site')} - self.fields['power_panel'].queryset = self.fields['power_panel'].queryset.filter(**params) - - # Limit location queryset by site - params = {f"site__{self.fields['site'].to_field_name}": data.get('site')} - self.fields['location'].queryset = self.fields['location'].queryset.filter(**params) - - # Limit rack queryset by site and group - params = { - f"site__{self.fields['site'].to_field_name}": data.get('site'), - f"location__{self.fields['location'].to_field_name}": data.get('location'), - } - self.fields['rack'].queryset = self.fields['rack'].queryset.filter(**params) - - -class PowerFeedBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm): - pk = forms.ModelMultipleChoiceField( - queryset=PowerFeed.objects.all(), - widget=forms.MultipleHiddenInput - ) - power_panel = DynamicModelChoiceField( - queryset=PowerPanel.objects.all(), - required=False - ) - rack = DynamicModelChoiceField( - queryset=Rack.objects.all(), - required=False, - ) - status = forms.ChoiceField( - choices=add_blank_choice(PowerFeedStatusChoices), - required=False, - initial='', - widget=StaticSelect() - ) - type = forms.ChoiceField( - choices=add_blank_choice(PowerFeedTypeChoices), - required=False, - initial='', - widget=StaticSelect() - ) - supply = forms.ChoiceField( - choices=add_blank_choice(PowerFeedSupplyChoices), - required=False, - initial='', - widget=StaticSelect() - ) - phase = forms.ChoiceField( - choices=add_blank_choice(PowerFeedPhaseChoices), - required=False, - initial='', - widget=StaticSelect() - ) - voltage = forms.IntegerField( - required=False - ) - amperage = forms.IntegerField( - required=False - ) - max_utilization = forms.IntegerField( - required=False - ) - mark_connected = forms.NullBooleanField( - required=False, - widget=BulkEditNullBooleanSelect - ) - comments = CommentField( - widget=SmallTextarea, - label='Comments' - ) - - class Meta: - nullable_fields = [ - 'location', 'comments', - ] - - -class PowerFeedFilterForm(BootstrapMixin, CustomFieldModelFilterForm): - model = PowerFeed - field_groups = [ - ['q', 'tag'], - ['region_id', 'site_group_id', 'site_id'], - ['power_panel_id', 'rack_id'], - ['status', 'type', 'supply', 'phase', 'voltage', 'amperage', 'max_utilization'], - ] - q = forms.CharField( - required=False, - widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), - label=_('Search') - ) - region_id = DynamicModelMultipleChoiceField( - queryset=Region.objects.all(), - required=False, - label=_('Region'), - fetch_trigger='open' - ) - site_group_id = DynamicModelMultipleChoiceField( - queryset=SiteGroup.objects.all(), - required=False, - label=_('Site group'), - fetch_trigger='open' - ) - site_id = DynamicModelMultipleChoiceField( - queryset=Site.objects.all(), - required=False, - query_params={ - 'region_id': '$region_id' - }, - label=_('Site'), - fetch_trigger='open' - ) - power_panel_id = DynamicModelMultipleChoiceField( - queryset=PowerPanel.objects.all(), - required=False, - null_option='None', - query_params={ - 'site_id': '$site_id' - }, - label=_('Power panel'), - fetch_trigger='open' - ) - rack_id = DynamicModelMultipleChoiceField( - queryset=Rack.objects.all(), - required=False, - null_option='None', - query_params={ - 'site_id': '$site_id' - }, - label=_('Rack'), - fetch_trigger='open' - ) - status = forms.MultipleChoiceField( - choices=PowerFeedStatusChoices, - required=False, - widget=StaticSelectMultiple() - ) - type = forms.ChoiceField( - choices=add_blank_choice(PowerFeedTypeChoices), - required=False, - widget=StaticSelect() - ) - supply = forms.ChoiceField( - choices=add_blank_choice(PowerFeedSupplyChoices), - required=False, - widget=StaticSelect() - ) - phase = forms.ChoiceField( - choices=add_blank_choice(PowerFeedPhaseChoices), - required=False, - widget=StaticSelect() - ) - voltage = forms.IntegerField( - required=False - ) - amperage = forms.IntegerField( - required=False - ) - max_utilization = forms.IntegerField( - required=False - ) - tag = TagFilterField(model) diff --git a/netbox/dcim/forms/__init__.py b/netbox/dcim/forms/__init__.py new file mode 100644 index 000000000..322abff9a --- /dev/null +++ b/netbox/dcim/forms/__init__.py @@ -0,0 +1,10 @@ +from .fields import * +from .models import * +from .filtersets import * +from .object_create import * +from .object_import import * +from .bulk_create import * +from .bulk_edit import * +from .bulk_import import * +from .connections import * +from .formsets import * diff --git a/netbox/dcim/forms/bulk_create.py b/netbox/dcim/forms/bulk_create.py new file mode 100644 index 000000000..3464280f1 --- /dev/null +++ b/netbox/dcim/forms/bulk_create.py @@ -0,0 +1,111 @@ +from django import forms + +from dcim.models import * +from extras.forms import CustomFieldsMixin +from extras.models import Tag +from utilities.forms import BootstrapMixin, DynamicModelMultipleChoiceField, form_from_model +from .object_create import ComponentForm + +__all__ = ( + 'ConsolePortBulkCreateForm', + 'ConsoleServerPortBulkCreateForm', + 'DeviceBayBulkCreateForm', + # 'FrontPortBulkCreateForm', + 'InterfaceBulkCreateForm', + 'InventoryItemBulkCreateForm', + 'PowerOutletBulkCreateForm', + 'PowerPortBulkCreateForm', + 'RearPortBulkCreateForm', +) + + +# +# Device components +# + +class DeviceBulkAddComponentForm(BootstrapMixin, CustomFieldsMixin, ComponentForm): + pk = forms.ModelMultipleChoiceField( + queryset=Device.objects.all(), + widget=forms.MultipleHiddenInput() + ) + description = forms.CharField( + max_length=100, + required=False + ) + tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), + required=False + ) + + +class ConsolePortBulkCreateForm( + form_from_model(ConsolePort, ['type', 'speed', 'mark_connected']), + DeviceBulkAddComponentForm +): + model = ConsolePort + field_order = ('name_pattern', 'label_pattern', 'type', 'mark_connected', 'description', 'tags') + + +class ConsoleServerPortBulkCreateForm( + form_from_model(ConsoleServerPort, ['type', 'speed', 'mark_connected']), + DeviceBulkAddComponentForm +): + model = ConsoleServerPort + field_order = ('name_pattern', 'label_pattern', 'type', 'speed', 'description', 'tags') + + +class PowerPortBulkCreateForm( + form_from_model(PowerPort, ['type', 'maximum_draw', 'allocated_draw', 'mark_connected']), + DeviceBulkAddComponentForm +): + model = PowerPort + field_order = ('name_pattern', 'label_pattern', 'type', 'maximum_draw', 'allocated_draw', 'description', 'tags') + + +class PowerOutletBulkCreateForm( + form_from_model(PowerOutlet, ['type', 'feed_leg', 'mark_connected']), + DeviceBulkAddComponentForm +): + model = PowerOutlet + field_order = ('name_pattern', 'label_pattern', 'type', 'feed_leg', 'description', 'tags') + + +class InterfaceBulkCreateForm( + form_from_model(Interface, ['type', 'enabled', 'mtu', 'mgmt_only', 'mark_connected']), + DeviceBulkAddComponentForm +): + model = Interface + field_order = ( + 'name_pattern', 'label_pattern', 'type', 'enabled', 'mtu', 'mgmt_only', 'mark_connected', 'description', 'tags', + ) + + +# class FrontPortBulkCreateForm( +# form_from_model(FrontPort, ['label', 'type', 'description', 'tags']), +# DeviceBulkAddComponentForm +# ): +# pass + + +class RearPortBulkCreateForm( + form_from_model(RearPort, ['type', 'color', 'positions', 'mark_connected']), + DeviceBulkAddComponentForm +): + model = RearPort + field_order = ('name_pattern', 'label_pattern', 'type', 'positions', 'mark_connected', 'description', 'tags') + + +class DeviceBayBulkCreateForm(DeviceBulkAddComponentForm): + model = DeviceBay + field_order = ('name_pattern', 'label_pattern', 'description', 'tags') + + +class InventoryItemBulkCreateForm( + form_from_model(InventoryItem, ['manufacturer', 'part_id', 'serial', 'asset_tag', 'discovered']), + DeviceBulkAddComponentForm +): + model = InventoryItem + field_order = ( + 'name_pattern', 'label_pattern', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'discovered', 'description', + 'tags', + ) diff --git a/netbox/dcim/forms/bulk_edit.py b/netbox/dcim/forms/bulk_edit.py new file mode 100644 index 000000000..c1b1bcb3a --- /dev/null +++ b/netbox/dcim/forms/bulk_edit.py @@ -0,0 +1,1090 @@ +from django import forms +from django.contrib.auth.models import User +from timezone_field import TimeZoneFormField + +from dcim.choices import * +from dcim.constants import * +from dcim.models import * +from extras.forms import AddRemoveTagsForm, CustomFieldModelBulkEditForm +from ipam.constants import BGP_ASN_MAX, BGP_ASN_MIN +from ipam.models import VLAN +from tenancy.models import Tenant +from utilities.forms import ( + add_blank_choice, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect, ColorField, CommentField, + DynamicModelChoiceField, DynamicModelMultipleChoiceField, form_from_model, SmallTextarea, StaticSelect, +) + +__all__ = ( + 'CableBulkEditForm', + 'ConsolePortBulkEditForm', + 'ConsolePortTemplateBulkEditForm', + 'ConsoleServerPortBulkEditForm', + 'ConsoleServerPortTemplateBulkEditForm', + 'DeviceBayBulkEditForm', + 'DeviceBayTemplateBulkEditForm', + 'DeviceBulkEditForm', + 'DeviceRoleBulkEditForm', + 'DeviceTypeBulkEditForm', + 'FrontPortBulkEditForm', + 'FrontPortTemplateBulkEditForm', + 'InterfaceBulkEditForm', + 'InterfaceTemplateBulkEditForm', + 'InventoryItemBulkEditForm', + 'LocationBulkEditForm', + 'ManufacturerBulkEditForm', + 'PlatformBulkEditForm', + 'PowerFeedBulkEditForm', + 'PowerOutletBulkEditForm', + 'PowerOutletTemplateBulkEditForm', + 'PowerPanelBulkEditForm', + 'PowerPortBulkEditForm', + 'PowerPortTemplateBulkEditForm', + 'RackBulkEditForm', + 'RackReservationBulkEditForm', + 'RackRoleBulkEditForm', + 'RearPortBulkEditForm', + 'RearPortTemplateBulkEditForm', + 'RegionBulkEditForm', + 'SiteBulkEditForm', + 'SiteGroupBulkEditForm', + 'VirtualChassisBulkEditForm', +) + + +class RegionBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm): + pk = forms.ModelMultipleChoiceField( + queryset=Region.objects.all(), + widget=forms.MultipleHiddenInput + ) + parent = DynamicModelChoiceField( + queryset=Region.objects.all(), + required=False + ) + description = forms.CharField( + max_length=200, + required=False + ) + + class Meta: + nullable_fields = ['parent', 'description'] + + +class SiteGroupBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm): + pk = forms.ModelMultipleChoiceField( + queryset=SiteGroup.objects.all(), + widget=forms.MultipleHiddenInput + ) + parent = DynamicModelChoiceField( + queryset=SiteGroup.objects.all(), + required=False + ) + description = forms.CharField( + max_length=200, + required=False + ) + + class Meta: + nullable_fields = ['parent', 'description'] + + +class SiteBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm): + pk = forms.ModelMultipleChoiceField( + queryset=Site.objects.all(), + widget=forms.MultipleHiddenInput + ) + status = forms.ChoiceField( + choices=add_blank_choice(SiteStatusChoices), + required=False, + initial='', + widget=StaticSelect() + ) + region = DynamicModelChoiceField( + queryset=Region.objects.all(), + required=False + ) + group = DynamicModelChoiceField( + queryset=SiteGroup.objects.all(), + required=False + ) + tenant = DynamicModelChoiceField( + queryset=Tenant.objects.all(), + required=False + ) + asn = forms.IntegerField( + min_value=BGP_ASN_MIN, + max_value=BGP_ASN_MAX, + required=False, + label='ASN' + ) + description = forms.CharField( + max_length=100, + required=False + ) + time_zone = TimeZoneFormField( + choices=add_blank_choice(TimeZoneFormField().choices), + required=False, + widget=StaticSelect() + ) + + class Meta: + nullable_fields = [ + 'region', 'group', 'tenant', 'asn', 'description', 'time_zone', + ] + + +class LocationBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm): + pk = forms.ModelMultipleChoiceField( + queryset=Location.objects.all(), + widget=forms.MultipleHiddenInput + ) + site = DynamicModelChoiceField( + queryset=Site.objects.all(), + required=False + ) + parent = DynamicModelChoiceField( + queryset=Location.objects.all(), + required=False, + query_params={ + 'site_id': '$site' + } + ) + description = forms.CharField( + max_length=200, + required=False + ) + + class Meta: + nullable_fields = ['parent', 'description'] + + +class RackRoleBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm): + pk = forms.ModelMultipleChoiceField( + queryset=RackRole.objects.all(), + widget=forms.MultipleHiddenInput + ) + color = ColorField( + required=False + ) + description = forms.CharField( + max_length=200, + required=False + ) + + class Meta: + nullable_fields = ['color', 'description'] + + +class RackBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm): + pk = forms.ModelMultipleChoiceField( + queryset=Rack.objects.all(), + widget=forms.MultipleHiddenInput + ) + region = DynamicModelChoiceField( + queryset=Region.objects.all(), + required=False, + initial_params={ + 'sites': '$site' + } + ) + site_group = DynamicModelChoiceField( + queryset=SiteGroup.objects.all(), + required=False, + initial_params={ + 'sites': '$site' + } + ) + site = DynamicModelChoiceField( + queryset=Site.objects.all(), + required=False, + query_params={ + 'region_id': '$region', + 'group_id': '$site_group', + } + ) + location = DynamicModelChoiceField( + queryset=Location.objects.all(), + required=False, + query_params={ + 'site_id': '$site' + } + ) + tenant = DynamicModelChoiceField( + queryset=Tenant.objects.all(), + required=False + ) + status = forms.ChoiceField( + choices=add_blank_choice(RackStatusChoices), + required=False, + initial='', + widget=StaticSelect() + ) + role = DynamicModelChoiceField( + queryset=RackRole.objects.all(), + required=False + ) + serial = forms.CharField( + max_length=50, + required=False, + label='Serial Number' + ) + asset_tag = forms.CharField( + max_length=50, + required=False + ) + type = forms.ChoiceField( + choices=add_blank_choice(RackTypeChoices), + required=False, + widget=StaticSelect() + ) + width = forms.ChoiceField( + choices=add_blank_choice(RackWidthChoices), + required=False, + widget=StaticSelect() + ) + u_height = forms.IntegerField( + required=False, + label='Height (U)' + ) + desc_units = forms.NullBooleanField( + required=False, + widget=BulkEditNullBooleanSelect, + label='Descending units' + ) + outer_width = forms.IntegerField( + required=False, + min_value=1 + ) + outer_depth = forms.IntegerField( + required=False, + min_value=1 + ) + outer_unit = forms.ChoiceField( + choices=add_blank_choice(RackDimensionUnitChoices), + required=False, + widget=StaticSelect() + ) + comments = CommentField( + widget=SmallTextarea, + label='Comments' + ) + + class Meta: + nullable_fields = [ + 'location', 'tenant', 'role', 'serial', 'asset_tag', 'outer_width', 'outer_depth', 'outer_unit', 'comments', + ] + + +class RackReservationBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm): + pk = forms.ModelMultipleChoiceField( + queryset=RackReservation.objects.all(), + widget=forms.MultipleHiddenInput() + ) + user = forms.ModelChoiceField( + queryset=User.objects.order_by( + 'username' + ), + required=False, + widget=StaticSelect() + ) + tenant = DynamicModelChoiceField( + queryset=Tenant.objects.all(), + required=False + ) + description = forms.CharField( + max_length=100, + required=False + ) + + class Meta: + nullable_fields = [] + + +class ManufacturerBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm): + pk = forms.ModelMultipleChoiceField( + queryset=Manufacturer.objects.all(), + widget=forms.MultipleHiddenInput + ) + description = forms.CharField( + max_length=200, + required=False + ) + + class Meta: + nullable_fields = ['description'] + + +class DeviceTypeBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm): + pk = forms.ModelMultipleChoiceField( + queryset=DeviceType.objects.all(), + widget=forms.MultipleHiddenInput() + ) + manufacturer = DynamicModelChoiceField( + queryset=Manufacturer.objects.all(), + required=False + ) + u_height = forms.IntegerField( + min_value=1, + required=False + ) + is_full_depth = forms.NullBooleanField( + required=False, + widget=BulkEditNullBooleanSelect(), + label='Is full depth' + ) + + class Meta: + nullable_fields = [] + + +class DeviceRoleBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm): + pk = forms.ModelMultipleChoiceField( + queryset=DeviceRole.objects.all(), + widget=forms.MultipleHiddenInput + ) + color = ColorField( + required=False + ) + vm_role = forms.NullBooleanField( + required=False, + widget=BulkEditNullBooleanSelect, + label='VM role' + ) + description = forms.CharField( + max_length=200, + required=False + ) + + class Meta: + nullable_fields = ['color', 'description'] + + +class PlatformBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm): + pk = forms.ModelMultipleChoiceField( + queryset=Platform.objects.all(), + widget=forms.MultipleHiddenInput + ) + manufacturer = DynamicModelChoiceField( + queryset=Manufacturer.objects.all(), + required=False + ) + napalm_driver = forms.CharField( + max_length=50, + required=False + ) + # TODO: Bulk edit support for napalm_args + description = forms.CharField( + max_length=200, + required=False + ) + + class Meta: + nullable_fields = ['manufacturer', 'napalm_driver', 'description'] + + +class DeviceBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm): + pk = forms.ModelMultipleChoiceField( + queryset=Device.objects.all(), + widget=forms.MultipleHiddenInput() + ) + manufacturer = DynamicModelChoiceField( + queryset=Manufacturer.objects.all(), + required=False + ) + device_type = DynamicModelChoiceField( + queryset=DeviceType.objects.all(), + required=False, + query_params={ + 'manufacturer_id': '$manufacturer' + } + ) + device_role = DynamicModelChoiceField( + queryset=DeviceRole.objects.all(), + required=False + ) + site = DynamicModelChoiceField( + queryset=Site.objects.all(), + required=False + ) + location = DynamicModelChoiceField( + queryset=Location.objects.all(), + required=False, + query_params={ + 'site_id': '$site' + } + ) + tenant = DynamicModelChoiceField( + queryset=Tenant.objects.all(), + required=False + ) + platform = DynamicModelChoiceField( + queryset=Platform.objects.all(), + required=False + ) + status = forms.ChoiceField( + choices=add_blank_choice(DeviceStatusChoices), + required=False, + widget=StaticSelect() + ) + serial = forms.CharField( + max_length=50, + required=False, + label='Serial Number' + ) + + class Meta: + nullable_fields = [ + 'tenant', 'platform', 'serial', + ] + + +class CableBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm): + pk = forms.ModelMultipleChoiceField( + queryset=Cable.objects.all(), + widget=forms.MultipleHiddenInput + ) + type = forms.ChoiceField( + choices=add_blank_choice(CableTypeChoices), + required=False, + initial='', + widget=StaticSelect() + ) + status = forms.ChoiceField( + choices=add_blank_choice(CableStatusChoices), + required=False, + widget=StaticSelect(), + initial='' + ) + label = forms.CharField( + max_length=100, + required=False + ) + color = ColorField( + required=False + ) + length = forms.DecimalField( + min_value=0, + required=False + ) + length_unit = forms.ChoiceField( + choices=add_blank_choice(CableLengthUnitChoices), + required=False, + initial='', + widget=StaticSelect() + ) + + class Meta: + nullable_fields = [ + 'type', 'status', 'label', 'color', 'length', + ] + + def clean(self): + super().clean() + + # Validate length/unit + length = self.cleaned_data.get('length') + length_unit = self.cleaned_data.get('length_unit') + if length and not length_unit: + raise forms.ValidationError({ + 'length_unit': "Must specify a unit when setting length" + }) + + +class VirtualChassisBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm): + pk = forms.ModelMultipleChoiceField( + queryset=VirtualChassis.objects.all(), + widget=forms.MultipleHiddenInput() + ) + domain = forms.CharField( + max_length=30, + required=False + ) + + class Meta: + nullable_fields = ['domain'] + + +class PowerPanelBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm): + pk = forms.ModelMultipleChoiceField( + queryset=PowerPanel.objects.all(), + widget=forms.MultipleHiddenInput + ) + region = DynamicModelChoiceField( + queryset=Region.objects.all(), + required=False, + initial_params={ + 'sites': '$site' + } + ) + site_group = DynamicModelChoiceField( + queryset=SiteGroup.objects.all(), + required=False, + initial_params={ + 'sites': '$site' + } + ) + site = DynamicModelChoiceField( + queryset=Site.objects.all(), + required=False, + query_params={ + 'region_id': '$region', + 'group_id': '$site_group', + } + ) + location = DynamicModelChoiceField( + queryset=Location.objects.all(), + required=False, + query_params={ + 'site_id': '$site' + } + ) + + class Meta: + nullable_fields = ['location'] + + +class PowerFeedBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm): + pk = forms.ModelMultipleChoiceField( + queryset=PowerFeed.objects.all(), + widget=forms.MultipleHiddenInput + ) + power_panel = DynamicModelChoiceField( + queryset=PowerPanel.objects.all(), + required=False + ) + rack = DynamicModelChoiceField( + queryset=Rack.objects.all(), + required=False, + ) + status = forms.ChoiceField( + choices=add_blank_choice(PowerFeedStatusChoices), + required=False, + initial='', + widget=StaticSelect() + ) + type = forms.ChoiceField( + choices=add_blank_choice(PowerFeedTypeChoices), + required=False, + initial='', + widget=StaticSelect() + ) + supply = forms.ChoiceField( + choices=add_blank_choice(PowerFeedSupplyChoices), + required=False, + initial='', + widget=StaticSelect() + ) + phase = forms.ChoiceField( + choices=add_blank_choice(PowerFeedPhaseChoices), + required=False, + initial='', + widget=StaticSelect() + ) + voltage = forms.IntegerField( + required=False + ) + amperage = forms.IntegerField( + required=False + ) + max_utilization = forms.IntegerField( + required=False + ) + mark_connected = forms.NullBooleanField( + required=False, + widget=BulkEditNullBooleanSelect + ) + comments = CommentField( + widget=SmallTextarea, + label='Comments' + ) + + class Meta: + nullable_fields = [ + 'location', 'comments', + ] + + +# +# Device component templates +# + +class ConsolePortTemplateBulkEditForm(BootstrapMixin, BulkEditForm): + pk = forms.ModelMultipleChoiceField( + queryset=ConsolePortTemplate.objects.all(), + widget=forms.MultipleHiddenInput() + ) + label = forms.CharField( + max_length=64, + required=False + ) + type = forms.ChoiceField( + choices=add_blank_choice(ConsolePortTypeChoices), + required=False, + widget=StaticSelect() + ) + + class Meta: + nullable_fields = ('label', 'type', 'description') + + +class ConsoleServerPortTemplateBulkEditForm(BootstrapMixin, BulkEditForm): + pk = forms.ModelMultipleChoiceField( + queryset=ConsoleServerPortTemplate.objects.all(), + widget=forms.MultipleHiddenInput() + ) + label = forms.CharField( + max_length=64, + required=False + ) + type = forms.ChoiceField( + choices=add_blank_choice(ConsolePortTypeChoices), + required=False, + widget=StaticSelect() + ) + description = forms.CharField( + required=False + ) + + class Meta: + nullable_fields = ('label', 'type', 'description') + + +class PowerPortTemplateBulkEditForm(BootstrapMixin, BulkEditForm): + pk = forms.ModelMultipleChoiceField( + queryset=PowerPortTemplate.objects.all(), + widget=forms.MultipleHiddenInput() + ) + label = forms.CharField( + max_length=64, + required=False + ) + type = forms.ChoiceField( + choices=add_blank_choice(PowerPortTypeChoices), + required=False, + widget=StaticSelect() + ) + maximum_draw = forms.IntegerField( + min_value=1, + required=False, + help_text="Maximum power draw (watts)" + ) + allocated_draw = forms.IntegerField( + min_value=1, + required=False, + help_text="Allocated power draw (watts)" + ) + description = forms.CharField( + required=False + ) + + class Meta: + nullable_fields = ('label', 'type', 'maximum_draw', 'allocated_draw', 'description') + + +class PowerOutletTemplateBulkEditForm(BootstrapMixin, BulkEditForm): + pk = forms.ModelMultipleChoiceField( + queryset=PowerOutletTemplate.objects.all(), + widget=forms.MultipleHiddenInput() + ) + device_type = forms.ModelChoiceField( + queryset=DeviceType.objects.all(), + required=False, + disabled=True, + widget=forms.HiddenInput() + ) + label = forms.CharField( + max_length=64, + required=False + ) + type = forms.ChoiceField( + choices=add_blank_choice(PowerOutletTypeChoices), + required=False, + widget=StaticSelect() + ) + power_port = forms.ModelChoiceField( + queryset=PowerPortTemplate.objects.all(), + required=False + ) + feed_leg = forms.ChoiceField( + choices=add_blank_choice(PowerOutletFeedLegChoices), + required=False, + widget=StaticSelect() + ) + description = forms.CharField( + required=False + ) + + class Meta: + nullable_fields = ('label', 'type', 'power_port', 'feed_leg', 'description') + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + # Limit power_port queryset to PowerPortTemplates which belong to the parent DeviceType + if 'device_type' in self.initial: + device_type = DeviceType.objects.filter(pk=self.initial['device_type']).first() + self.fields['power_port'].queryset = PowerPortTemplate.objects.filter(device_type=device_type) + else: + self.fields['power_port'].choices = () + self.fields['power_port'].widget.attrs['disabled'] = True + + +class InterfaceTemplateBulkEditForm(BootstrapMixin, BulkEditForm): + pk = forms.ModelMultipleChoiceField( + queryset=InterfaceTemplate.objects.all(), + widget=forms.MultipleHiddenInput() + ) + label = forms.CharField( + max_length=64, + required=False + ) + type = forms.ChoiceField( + choices=add_blank_choice(InterfaceTypeChoices), + required=False, + widget=StaticSelect() + ) + mgmt_only = forms.NullBooleanField( + required=False, + widget=BulkEditNullBooleanSelect, + label='Management only' + ) + description = forms.CharField( + required=False + ) + + class Meta: + nullable_fields = ('label', 'description') + + +class FrontPortTemplateBulkEditForm(BootstrapMixin, BulkEditForm): + pk = forms.ModelMultipleChoiceField( + queryset=FrontPortTemplate.objects.all(), + widget=forms.MultipleHiddenInput() + ) + label = forms.CharField( + max_length=64, + required=False + ) + type = forms.ChoiceField( + choices=add_blank_choice(PortTypeChoices), + required=False, + widget=StaticSelect() + ) + color = ColorField( + required=False + ) + description = forms.CharField( + required=False + ) + + class Meta: + nullable_fields = ('description',) + + +class RearPortTemplateBulkEditForm(BootstrapMixin, BulkEditForm): + pk = forms.ModelMultipleChoiceField( + queryset=RearPortTemplate.objects.all(), + widget=forms.MultipleHiddenInput() + ) + label = forms.CharField( + max_length=64, + required=False + ) + type = forms.ChoiceField( + choices=add_blank_choice(PortTypeChoices), + required=False, + widget=StaticSelect() + ) + color = ColorField( + required=False + ) + description = forms.CharField( + required=False + ) + + class Meta: + nullable_fields = ('description',) + + +class DeviceBayTemplateBulkEditForm(BootstrapMixin, BulkEditForm): + pk = forms.ModelMultipleChoiceField( + queryset=DeviceBayTemplate.objects.all(), + widget=forms.MultipleHiddenInput() + ) + label = forms.CharField( + max_length=64, + required=False + ) + description = forms.CharField( + required=False + ) + + class Meta: + nullable_fields = ('label', 'description') + + +# +# Device components +# + +class ConsolePortBulkEditForm( + form_from_model(ConsolePort, ['label', 'type', 'speed', 'mark_connected', 'description']), + BootstrapMixin, + AddRemoveTagsForm, + CustomFieldModelBulkEditForm +): + pk = forms.ModelMultipleChoiceField( + queryset=ConsolePort.objects.all(), + widget=forms.MultipleHiddenInput() + ) + mark_connected = forms.NullBooleanField( + required=False, + widget=BulkEditNullBooleanSelect + ) + + class Meta: + nullable_fields = ['label', 'description'] + + +class ConsoleServerPortBulkEditForm( + form_from_model(ConsoleServerPort, ['label', 'type', 'speed', 'mark_connected', 'description']), + BootstrapMixin, + AddRemoveTagsForm, + CustomFieldModelBulkEditForm +): + pk = forms.ModelMultipleChoiceField( + queryset=ConsoleServerPort.objects.all(), + widget=forms.MultipleHiddenInput() + ) + mark_connected = forms.NullBooleanField( + required=False, + widget=BulkEditNullBooleanSelect + ) + + class Meta: + nullable_fields = ['label', 'description'] + + +class PowerPortBulkEditForm( + form_from_model(PowerPort, ['label', 'type', 'maximum_draw', 'allocated_draw', 'mark_connected', 'description']), + BootstrapMixin, + AddRemoveTagsForm, + CustomFieldModelBulkEditForm +): + pk = forms.ModelMultipleChoiceField( + queryset=PowerPort.objects.all(), + widget=forms.MultipleHiddenInput() + ) + mark_connected = forms.NullBooleanField( + required=False, + widget=BulkEditNullBooleanSelect + ) + + class Meta: + nullable_fields = ['label', 'description'] + + +class PowerOutletBulkEditForm( + form_from_model(PowerOutlet, ['label', 'type', 'feed_leg', 'power_port', 'mark_connected', 'description']), + BootstrapMixin, + AddRemoveTagsForm, + CustomFieldModelBulkEditForm +): + pk = forms.ModelMultipleChoiceField( + queryset=PowerOutlet.objects.all(), + widget=forms.MultipleHiddenInput() + ) + device = forms.ModelChoiceField( + queryset=Device.objects.all(), + required=False, + disabled=True, + widget=forms.HiddenInput() + ) + mark_connected = forms.NullBooleanField( + required=False, + widget=BulkEditNullBooleanSelect + ) + + class Meta: + nullable_fields = ['label', 'type', 'feed_leg', 'power_port', 'description'] + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + # Limit power_port queryset to PowerPorts which belong to the parent Device + if 'device' in self.initial: + device = Device.objects.filter(pk=self.initial['device']).first() + self.fields['power_port'].queryset = PowerPort.objects.filter(device=device) + else: + self.fields['power_port'].choices = () + self.fields['power_port'].widget.attrs['disabled'] = True + + +class InterfaceBulkEditForm( + form_from_model(Interface, [ + 'label', 'type', 'parent', 'lag', 'mac_address', 'mtu', 'mgmt_only', 'mark_connected', 'description', 'mode', + ]), + BootstrapMixin, + AddRemoveTagsForm, + CustomFieldModelBulkEditForm +): + pk = forms.ModelMultipleChoiceField( + queryset=Interface.objects.all(), + widget=forms.MultipleHiddenInput() + ) + device = forms.ModelChoiceField( + queryset=Device.objects.all(), + required=False, + disabled=True, + widget=forms.HiddenInput() + ) + enabled = forms.NullBooleanField( + required=False, + widget=BulkEditNullBooleanSelect + ) + parent = DynamicModelChoiceField( + queryset=Interface.objects.all(), + required=False + ) + lag = DynamicModelChoiceField( + queryset=Interface.objects.all(), + required=False, + query_params={ + 'type': 'lag', + } + ) + mgmt_only = forms.NullBooleanField( + required=False, + widget=BulkEditNullBooleanSelect, + label='Management only' + ) + mark_connected = forms.NullBooleanField( + required=False, + widget=BulkEditNullBooleanSelect + ) + untagged_vlan = DynamicModelChoiceField( + queryset=VLAN.objects.all(), + required=False + ) + tagged_vlans = DynamicModelMultipleChoiceField( + queryset=VLAN.objects.all(), + required=False + ) + + class Meta: + nullable_fields = [ + 'label', 'parent', 'lag', 'mac_address', 'mtu', 'description', 'mode', 'untagged_vlan', 'tagged_vlans' + ] + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + if 'device' in self.initial: + device = Device.objects.filter(pk=self.initial['device']).first() + + # Restrict parent/LAG interface assignment by device + self.fields['parent'].widget.add_query_param('device_id', device.pk) + self.fields['lag'].widget.add_query_param('device_id', device.pk) + + # Limit VLAN choices by device + self.fields['untagged_vlan'].widget.add_query_param('available_on_device', device.pk) + self.fields['tagged_vlans'].widget.add_query_param('available_on_device', device.pk) + + else: + # See #4523 + if 'pk' in self.initial: + site = None + interfaces = Interface.objects.filter(pk__in=self.initial['pk']).prefetch_related('device__site') + + # Check interface sites. First interface should set site, further interfaces will either continue the + # loop or reset back to no site and break the loop. + for interface in interfaces: + if site is None: + site = interface.device.site + elif interface.device.site is not site: + site = None + break + + if site is not None: + self.fields['untagged_vlan'].widget.add_query_param('site_id', site.pk) + self.fields['tagged_vlans'].widget.add_query_param('site_id', site.pk) + + self.fields['parent'].choices = () + self.fields['parent'].widget.attrs['disabled'] = True + self.fields['lag'].choices = () + self.fields['lag'].widget.attrs['disabled'] = True + + def clean(self): + super().clean() + + # Untagged interfaces cannot be assigned tagged VLANs + if self.cleaned_data['mode'] == InterfaceModeChoices.MODE_ACCESS and self.cleaned_data['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'] == InterfaceModeChoices.MODE_TAGGED_ALL: + self.cleaned_data['tagged_vlans'] = [] + + +class FrontPortBulkEditForm( + form_from_model(FrontPort, ['label', 'type', 'color', 'mark_connected', 'description']), + BootstrapMixin, + AddRemoveTagsForm, + CustomFieldModelBulkEditForm +): + pk = forms.ModelMultipleChoiceField( + queryset=FrontPort.objects.all(), + widget=forms.MultipleHiddenInput() + ) + + class Meta: + nullable_fields = ['label', 'description'] + + +class RearPortBulkEditForm( + form_from_model(RearPort, ['label', 'type', 'color', 'mark_connected', 'description']), + BootstrapMixin, + AddRemoveTagsForm, + CustomFieldModelBulkEditForm +): + pk = forms.ModelMultipleChoiceField( + queryset=RearPort.objects.all(), + widget=forms.MultipleHiddenInput() + ) + + class Meta: + nullable_fields = ['label', 'description'] + + +class DeviceBayBulkEditForm( + form_from_model(DeviceBay, ['label', 'description']), + BootstrapMixin, + AddRemoveTagsForm, + CustomFieldModelBulkEditForm +): + pk = forms.ModelMultipleChoiceField( + queryset=DeviceBay.objects.all(), + widget=forms.MultipleHiddenInput() + ) + + class Meta: + nullable_fields = ['label', 'description'] + + +class InventoryItemBulkEditForm( + form_from_model(InventoryItem, ['label', 'manufacturer', 'part_id', 'description']), + BootstrapMixin, + AddRemoveTagsForm, + CustomFieldModelBulkEditForm +): + pk = forms.ModelMultipleChoiceField( + queryset=InventoryItem.objects.all(), + widget=forms.MultipleHiddenInput() + ) + manufacturer = DynamicModelChoiceField( + queryset=Manufacturer.objects.all(), + required=False + ) + + class Meta: + nullable_fields = ['label', 'manufacturer', 'part_id', 'description'] diff --git a/netbox/dcim/forms/bulk_import.py b/netbox/dcim/forms/bulk_import.py new file mode 100644 index 000000000..93f17e839 --- /dev/null +++ b/netbox/dcim/forms/bulk_import.py @@ -0,0 +1,976 @@ +from django import forms +from django.contrib.contenttypes.models import ContentType +from django.contrib.postgres.forms.array import SimpleArrayField +from django.core.exceptions import ObjectDoesNotExist +from django.utils.safestring import mark_safe + +from dcim.choices import * +from dcim.constants import * +from dcim.models import * +from extras.forms import CustomFieldModelCSVForm +from tenancy.models import Tenant +from utilities.forms import CSVChoiceField, CSVContentTypeField, CSVModelChoiceField, CSVTypedChoiceField, SlugField +from virtualization.models import Cluster + +__all__ = ( + 'CableCSVForm', + 'ChildDeviceCSVForm', + 'ConsolePortCSVForm', + 'ConsoleServerPortCSVForm', + 'DeviceBayCSVForm', + 'DeviceCSVForm', + 'DeviceRoleCSVForm', + 'FrontPortCSVForm', + 'InterfaceCSVForm', + 'InventoryItemCSVForm', + 'LocationCSVForm', + 'ManufacturerCSVForm', + 'PlatformCSVForm', + 'PowerFeedCSVForm', + 'PowerOutletCSVForm', + 'PowerPanelCSVForm', + 'PowerPortCSVForm', + 'RackCSVForm', + 'RackReservationCSVForm', + 'RackRoleCSVForm', + 'RearPortCSVForm', + 'RegionCSVForm', + 'SiteCSVForm', + 'SiteGroupCSVForm', + 'VirtualChassisCSVForm', +) + + +class RegionCSVForm(CustomFieldModelCSVForm): + parent = CSVModelChoiceField( + queryset=Region.objects.all(), + required=False, + to_field_name='name', + help_text='Name of parent region' + ) + + class Meta: + model = Region + fields = ('name', 'slug', 'parent', 'description') + + +class SiteGroupCSVForm(CustomFieldModelCSVForm): + parent = CSVModelChoiceField( + queryset=SiteGroup.objects.all(), + required=False, + to_field_name='name', + help_text='Name of parent site group' + ) + + class Meta: + model = SiteGroup + fields = ('name', 'slug', 'parent', 'description') + + +class SiteCSVForm(CustomFieldModelCSVForm): + status = CSVChoiceField( + choices=SiteStatusChoices, + required=False, + help_text='Operational status' + ) + region = CSVModelChoiceField( + queryset=Region.objects.all(), + required=False, + to_field_name='name', + help_text='Assigned region' + ) + group = CSVModelChoiceField( + queryset=SiteGroup.objects.all(), + required=False, + to_field_name='name', + help_text='Assigned group' + ) + tenant = CSVModelChoiceField( + queryset=Tenant.objects.all(), + required=False, + to_field_name='name', + help_text='Assigned tenant' + ) + + class Meta: + model = Site + fields = ( + 'name', 'slug', 'status', 'region', 'group', 'tenant', 'facility', 'asn', 'time_zone', 'description', + 'physical_address', 'shipping_address', 'latitude', 'longitude', 'contact_name', 'contact_phone', + 'contact_email', 'comments', + ) + help_texts = { + 'time_zone': mark_safe( + 'Time zone (available options)' + ) + } + + +class LocationCSVForm(CustomFieldModelCSVForm): + site = CSVModelChoiceField( + queryset=Site.objects.all(), + to_field_name='name', + help_text='Assigned site' + ) + parent = CSVModelChoiceField( + queryset=Location.objects.all(), + required=False, + to_field_name='name', + help_text='Parent location', + error_messages={ + 'invalid_choice': 'Location not found.', + } + ) + + class Meta: + model = Location + fields = ('site', 'parent', 'name', 'slug', 'description') + + +class RackRoleCSVForm(CustomFieldModelCSVForm): + slug = SlugField() + + class Meta: + model = RackRole + fields = ('name', 'slug', 'color', 'description') + help_texts = { + 'color': mark_safe('RGB color in hexadecimal (e.g. 00ff00)'), + } + + +class RackCSVForm(CustomFieldModelCSVForm): + site = CSVModelChoiceField( + queryset=Site.objects.all(), + to_field_name='name' + ) + location = CSVModelChoiceField( + queryset=Location.objects.all(), + required=False, + to_field_name='name' + ) + tenant = CSVModelChoiceField( + queryset=Tenant.objects.all(), + required=False, + to_field_name='name', + help_text='Name of assigned tenant' + ) + status = CSVChoiceField( + choices=RackStatusChoices, + required=False, + help_text='Operational status' + ) + role = CSVModelChoiceField( + queryset=RackRole.objects.all(), + required=False, + to_field_name='name', + help_text='Name of assigned role' + ) + type = CSVChoiceField( + choices=RackTypeChoices, + required=False, + help_text='Rack type' + ) + width = forms.ChoiceField( + choices=RackWidthChoices, + help_text='Rail-to-rail width (in inches)' + ) + outer_unit = CSVChoiceField( + choices=RackDimensionUnitChoices, + required=False, + help_text='Unit for outer dimensions' + ) + + class Meta: + model = Rack + fields = ( + 'site', 'location', 'name', 'facility_id', 'tenant', 'status', 'role', 'type', 'serial', 'asset_tag', + 'width', 'u_height', 'desc_units', 'outer_width', 'outer_depth', 'outer_unit', 'comments', + ) + + def __init__(self, data=None, *args, **kwargs): + super().__init__(data, *args, **kwargs) + + if data: + + # Limit location queryset by assigned site + params = {f"site__{self.fields['site'].to_field_name}": data.get('site')} + self.fields['location'].queryset = self.fields['location'].queryset.filter(**params) + + +class RackReservationCSVForm(CustomFieldModelCSVForm): + site = CSVModelChoiceField( + queryset=Site.objects.all(), + to_field_name='name', + help_text='Parent site' + ) + location = CSVModelChoiceField( + queryset=Location.objects.all(), + to_field_name='name', + required=False, + help_text="Rack's location (if any)" + ) + rack = CSVModelChoiceField( + queryset=Rack.objects.all(), + to_field_name='name', + help_text='Rack' + ) + units = SimpleArrayField( + base_field=forms.IntegerField(), + required=True, + help_text='Comma-separated list of individual unit numbers' + ) + tenant = CSVModelChoiceField( + queryset=Tenant.objects.all(), + required=False, + to_field_name='name', + help_text='Assigned tenant' + ) + + class Meta: + model = RackReservation + fields = ('site', 'location', 'rack', 'units', 'tenant', 'description') + + def __init__(self, data=None, *args, **kwargs): + super().__init__(data, *args, **kwargs) + + if data: + + # Limit location queryset by assigned site + params = {f"site__{self.fields['site'].to_field_name}": data.get('site')} + self.fields['location'].queryset = self.fields['location'].queryset.filter(**params) + + # Limit rack queryset by assigned site and group + params = { + f"site__{self.fields['site'].to_field_name}": data.get('site'), + f"location__{self.fields['location'].to_field_name}": data.get('location'), + } + self.fields['rack'].queryset = self.fields['rack'].queryset.filter(**params) + + +class ManufacturerCSVForm(CustomFieldModelCSVForm): + + class Meta: + model = Manufacturer + fields = ('name', 'slug', 'description') + + +class DeviceRoleCSVForm(CustomFieldModelCSVForm): + slug = SlugField() + + class Meta: + model = DeviceRole + fields = ('name', 'slug', 'color', 'vm_role', 'description') + help_texts = { + 'color': mark_safe('RGB color in hexadecimal (e.g. 00ff00)'), + } + + +class PlatformCSVForm(CustomFieldModelCSVForm): + slug = SlugField() + manufacturer = CSVModelChoiceField( + queryset=Manufacturer.objects.all(), + required=False, + to_field_name='name', + help_text='Limit platform assignments to this manufacturer' + ) + + class Meta: + model = Platform + fields = ('name', 'slug', 'manufacturer', 'napalm_driver', 'napalm_args', 'description') + + +class BaseDeviceCSVForm(CustomFieldModelCSVForm): + device_role = CSVModelChoiceField( + queryset=DeviceRole.objects.all(), + to_field_name='name', + help_text='Assigned role' + ) + tenant = CSVModelChoiceField( + queryset=Tenant.objects.all(), + required=False, + to_field_name='name', + help_text='Assigned tenant' + ) + manufacturer = CSVModelChoiceField( + queryset=Manufacturer.objects.all(), + to_field_name='name', + help_text='Device type manufacturer' + ) + device_type = CSVModelChoiceField( + queryset=DeviceType.objects.all(), + to_field_name='model', + help_text='Device type model' + ) + platform = CSVModelChoiceField( + queryset=Platform.objects.all(), + required=False, + to_field_name='name', + help_text='Assigned platform' + ) + status = CSVChoiceField( + choices=DeviceStatusChoices, + help_text='Operational status' + ) + virtual_chassis = CSVModelChoiceField( + queryset=VirtualChassis.objects.all(), + to_field_name='name', + required=False, + help_text='Virtual chassis' + ) + cluster = CSVModelChoiceField( + queryset=Cluster.objects.all(), + to_field_name='name', + required=False, + help_text='Virtualization cluster' + ) + + class Meta: + fields = [] + model = Device + help_texts = { + 'vc_position': 'Virtual chassis position', + 'vc_priority': 'Virtual chassis priority', + } + + def __init__(self, data=None, *args, **kwargs): + super().__init__(data, *args, **kwargs) + + if data: + + # Limit device type queryset by manufacturer + params = {f"manufacturer__{self.fields['manufacturer'].to_field_name}": data.get('manufacturer')} + self.fields['device_type'].queryset = self.fields['device_type'].queryset.filter(**params) + + +class DeviceCSVForm(BaseDeviceCSVForm): + site = CSVModelChoiceField( + queryset=Site.objects.all(), + to_field_name='name', + help_text='Assigned site' + ) + location = CSVModelChoiceField( + queryset=Location.objects.all(), + to_field_name='name', + required=False, + help_text="Assigned location (if any)" + ) + rack = CSVModelChoiceField( + queryset=Rack.objects.all(), + to_field_name='name', + required=False, + help_text="Assigned rack (if any)" + ) + face = CSVChoiceField( + choices=DeviceFaceChoices, + required=False, + help_text='Mounted rack face' + ) + + class Meta(BaseDeviceCSVForm.Meta): + fields = [ + 'name', 'device_role', 'tenant', 'manufacturer', 'device_type', 'platform', 'serial', 'asset_tag', 'status', + 'site', 'location', 'rack', 'position', 'face', 'virtual_chassis', 'vc_position', 'vc_priority', 'cluster', + 'comments', + ] + + def __init__(self, data=None, *args, **kwargs): + super().__init__(data, *args, **kwargs) + + if data: + + # Limit location queryset by assigned site + params = {f"site__{self.fields['site'].to_field_name}": data.get('site')} + self.fields['location'].queryset = self.fields['location'].queryset.filter(**params) + + # Limit rack queryset by assigned site and group + params = { + f"site__{self.fields['site'].to_field_name}": data.get('site'), + f"location__{self.fields['location'].to_field_name}": data.get('location'), + } + self.fields['rack'].queryset = self.fields['rack'].queryset.filter(**params) + + +class ChildDeviceCSVForm(BaseDeviceCSVForm): + parent = CSVModelChoiceField( + queryset=Device.objects.all(), + to_field_name='name', + help_text='Parent device' + ) + device_bay = CSVModelChoiceField( + queryset=DeviceBay.objects.all(), + to_field_name='name', + help_text='Device bay in which this device is installed' + ) + + class Meta(BaseDeviceCSVForm.Meta): + fields = [ + 'name', 'device_role', 'tenant', 'manufacturer', 'device_type', 'platform', 'serial', 'asset_tag', 'status', + 'parent', 'device_bay', 'virtual_chassis', 'vc_position', 'vc_priority', 'cluster', 'comments', + ] + + def __init__(self, data=None, *args, **kwargs): + super().__init__(data, *args, **kwargs) + + if data: + + # Limit device bay queryset by parent device + params = {f"device__{self.fields['parent'].to_field_name}": data.get('parent')} + self.fields['device_bay'].queryset = self.fields['device_bay'].queryset.filter(**params) + + def clean(self): + super().clean() + + # Set parent_bay reverse relationship + device_bay = self.cleaned_data.get('device_bay') + if device_bay: + self.instance.parent_bay = device_bay + + # Inherit site and rack from parent device + parent = self.cleaned_data.get('parent') + if parent: + self.instance.site = parent.site + self.instance.rack = parent.rack + + +# +# Device components +# + +class ConsolePortCSVForm(CustomFieldModelCSVForm): + device = CSVModelChoiceField( + queryset=Device.objects.all(), + to_field_name='name' + ) + type = CSVChoiceField( + choices=ConsolePortTypeChoices, + required=False, + help_text='Port type' + ) + speed = CSVTypedChoiceField( + choices=ConsolePortSpeedChoices, + coerce=int, + empty_value=None, + required=False, + help_text='Port speed in bps' + ) + + class Meta: + model = ConsolePort + fields = ('device', 'name', 'label', 'type', 'speed', 'mark_connected', 'description') + + +class ConsoleServerPortCSVForm(CustomFieldModelCSVForm): + device = CSVModelChoiceField( + queryset=Device.objects.all(), + to_field_name='name' + ) + type = CSVChoiceField( + choices=ConsolePortTypeChoices, + required=False, + help_text='Port type' + ) + speed = CSVTypedChoiceField( + choices=ConsolePortSpeedChoices, + coerce=int, + empty_value=None, + required=False, + help_text='Port speed in bps' + ) + + class Meta: + model = ConsoleServerPort + fields = ('device', 'name', 'label', 'type', 'speed', 'mark_connected', 'description') + + +class PowerPortCSVForm(CustomFieldModelCSVForm): + device = CSVModelChoiceField( + queryset=Device.objects.all(), + to_field_name='name' + ) + type = CSVChoiceField( + choices=PowerPortTypeChoices, + required=False, + help_text='Port type' + ) + + class Meta: + model = PowerPort + fields = ( + 'device', 'name', 'label', 'type', 'mark_connected', 'maximum_draw', 'allocated_draw', 'description', + ) + + +class PowerOutletCSVForm(CustomFieldModelCSVForm): + device = CSVModelChoiceField( + queryset=Device.objects.all(), + to_field_name='name' + ) + type = CSVChoiceField( + choices=PowerOutletTypeChoices, + required=False, + help_text='Outlet type' + ) + power_port = CSVModelChoiceField( + queryset=PowerPort.objects.all(), + required=False, + to_field_name='name', + help_text='Local power port which feeds this outlet' + ) + feed_leg = CSVChoiceField( + choices=PowerOutletFeedLegChoices, + required=False, + help_text='Electrical phase (for three-phase circuits)' + ) + + class Meta: + model = PowerOutlet + fields = ('device', 'name', 'label', 'type', 'mark_connected', 'power_port', 'feed_leg', 'description') + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + # Limit PowerPort choices to those belonging to this device (or VC master) + if self.is_bound: + try: + device = self.fields['device'].to_python(self.data['device']) + except forms.ValidationError: + device = None + else: + try: + device = self.instance.device + except Device.DoesNotExist: + device = None + + if device: + self.fields['power_port'].queryset = PowerPort.objects.filter( + device__in=[device, device.get_vc_master()] + ) + else: + self.fields['power_port'].queryset = PowerPort.objects.none() + + +class InterfaceCSVForm(CustomFieldModelCSVForm): + device = CSVModelChoiceField( + queryset=Device.objects.all(), + to_field_name='name' + ) + parent = CSVModelChoiceField( + queryset=Interface.objects.all(), + required=False, + to_field_name='name', + help_text='Parent interface' + ) + lag = CSVModelChoiceField( + queryset=Interface.objects.all(), + required=False, + to_field_name='name', + help_text='Parent LAG interface' + ) + type = CSVChoiceField( + choices=InterfaceTypeChoices, + help_text='Physical medium' + ) + mode = CSVChoiceField( + choices=InterfaceModeChoices, + required=False, + help_text='IEEE 802.1Q operational mode (for L2 interfaces)' + ) + + class Meta: + model = Interface + fields = ( + 'device', 'name', 'label', 'parent', 'lag', 'type', 'enabled', 'mark_connected', 'mac_address', 'mtu', + 'mgmt_only', 'description', 'mode', + ) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + # Limit LAG choices to interfaces belonging to this device (or virtual chassis) + device = None + if self.is_bound and 'device' in self.data: + try: + device = self.fields['device'].to_python(self.data['device']) + except forms.ValidationError: + pass + if device and device.virtual_chassis: + self.fields['lag'].queryset = Interface.objects.filter( + Q(device=device) | Q(device__virtual_chassis=device.virtual_chassis), + type=InterfaceTypeChoices.TYPE_LAG + ) + self.fields['parent'].queryset = Interface.objects.filter( + Q(device=device) | Q(device__virtual_chassis=device.virtual_chassis) + ) + elif device: + self.fields['lag'].queryset = Interface.objects.filter( + device=device, + type=InterfaceTypeChoices.TYPE_LAG + ) + self.fields['parent'].queryset = Interface.objects.filter(device=device) + else: + self.fields['lag'].queryset = Interface.objects.none() + self.fields['parent'].queryset = Interface.objects.none() + + def clean_enabled(self): + # Make sure enabled is True when it's not included in the uploaded data + if 'enabled' not in self.data: + return True + else: + return self.cleaned_data['enabled'] + + +class FrontPortCSVForm(CustomFieldModelCSVForm): + device = CSVModelChoiceField( + queryset=Device.objects.all(), + to_field_name='name' + ) + rear_port = CSVModelChoiceField( + queryset=RearPort.objects.all(), + to_field_name='name', + help_text='Corresponding rear port' + ) + type = CSVChoiceField( + choices=PortTypeChoices, + help_text='Physical medium classification' + ) + + class Meta: + model = FrontPort + fields = ( + 'device', 'name', 'label', 'type', 'color', 'mark_connected', 'rear_port', 'rear_port_position', + 'description', + ) + help_texts = { + 'rear_port_position': 'Mapped position on corresponding rear port', + } + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + # Limit RearPort choices to those belonging to this device (or VC master) + if self.is_bound: + try: + device = self.fields['device'].to_python(self.data['device']) + except forms.ValidationError: + device = None + else: + try: + device = self.instance.device + except Device.DoesNotExist: + device = None + + if device: + self.fields['rear_port'].queryset = RearPort.objects.filter( + device__in=[device, device.get_vc_master()] + ) + else: + self.fields['rear_port'].queryset = RearPort.objects.none() + + +class RearPortCSVForm(CustomFieldModelCSVForm): + device = CSVModelChoiceField( + queryset=Device.objects.all(), + to_field_name='name' + ) + type = CSVChoiceField( + help_text='Physical medium classification', + choices=PortTypeChoices, + ) + + class Meta: + model = RearPort + fields = ('device', 'name', 'label', 'type', 'color', 'mark_connected', 'positions', 'description') + help_texts = { + 'positions': 'Number of front ports which may be mapped' + } + + +class DeviceBayCSVForm(CustomFieldModelCSVForm): + device = CSVModelChoiceField( + queryset=Device.objects.all(), + to_field_name='name' + ) + installed_device = CSVModelChoiceField( + queryset=Device.objects.all(), + required=False, + to_field_name='name', + help_text='Child device installed within this bay', + error_messages={ + 'invalid_choice': 'Child device not found.', + } + ) + + class Meta: + model = DeviceBay + fields = ('device', 'name', 'label', 'installed_device', 'description') + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + # Limit installed device choices to devices of the correct type and location + if self.is_bound: + try: + device = self.fields['device'].to_python(self.data['device']) + except forms.ValidationError: + device = None + else: + try: + device = self.instance.device + except Device.DoesNotExist: + device = None + + if device: + self.fields['installed_device'].queryset = Device.objects.filter( + site=device.site, + rack=device.rack, + parent_bay__isnull=True, + device_type__u_height=0, + device_type__subdevice_role=SubdeviceRoleChoices.ROLE_CHILD + ).exclude(pk=device.pk) + else: + self.fields['installed_device'].queryset = Interface.objects.none() + + +class InventoryItemCSVForm(CustomFieldModelCSVForm): + device = CSVModelChoiceField( + queryset=Device.objects.all(), + to_field_name='name' + ) + manufacturer = CSVModelChoiceField( + queryset=Manufacturer.objects.all(), + to_field_name='name', + required=False + ) + parent = CSVModelChoiceField( + queryset=Device.objects.all(), + to_field_name='name', + required=False, + help_text='Parent inventory item' + ) + + class Meta: + model = InventoryItem + fields = ( + 'device', 'name', 'label', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'discovered', 'description', + ) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + # Limit parent choices to inventory items belonging to this device + device = None + if self.is_bound and 'device' in self.data: + try: + device = self.fields['device'].to_python(self.data['device']) + except forms.ValidationError: + pass + if device: + self.fields['parent'].queryset = InventoryItem.objects.filter(device=device) + else: + self.fields['parent'].queryset = InventoryItem.objects.none() + + +class CableCSVForm(CustomFieldModelCSVForm): + # Termination A + side_a_device = CSVModelChoiceField( + queryset=Device.objects.all(), + to_field_name='name', + help_text='Side A device' + ) + side_a_type = CSVContentTypeField( + queryset=ContentType.objects.all(), + limit_choices_to=CABLE_TERMINATION_MODELS, + help_text='Side A type' + ) + side_a_name = forms.CharField( + help_text='Side A component name' + ) + + # Termination B + side_b_device = CSVModelChoiceField( + queryset=Device.objects.all(), + to_field_name='name', + help_text='Side B device' + ) + side_b_type = CSVContentTypeField( + queryset=ContentType.objects.all(), + limit_choices_to=CABLE_TERMINATION_MODELS, + help_text='Side B type' + ) + side_b_name = forms.CharField( + help_text='Side B component name' + ) + + # Cable attributes + status = CSVChoiceField( + choices=CableStatusChoices, + required=False, + help_text='Connection status' + ) + type = CSVChoiceField( + choices=CableTypeChoices, + required=False, + help_text='Physical medium classification' + ) + length_unit = CSVChoiceField( + choices=CableLengthUnitChoices, + required=False, + help_text='Length unit' + ) + + class Meta: + model = Cable + fields = [ + 'side_a_device', 'side_a_type', 'side_a_name', 'side_b_device', 'side_b_type', 'side_b_name', 'type', + 'status', 'label', 'color', 'length', 'length_unit', + ] + help_texts = { + 'color': mark_safe('RGB color in hexadecimal (e.g. 00ff00)'), + } + + def _clean_side(self, side): + """ + Derive a Cable's A/B termination objects. + + :param side: 'a' or 'b' + """ + assert side in 'ab', f"Invalid side designation: {side}" + + device = self.cleaned_data.get(f'side_{side}_device') + content_type = self.cleaned_data.get(f'side_{side}_type') + name = self.cleaned_data.get(f'side_{side}_name') + if not device or not content_type or not name: + return None + + model = content_type.model_class() + try: + termination_object = model.objects.get(device=device, name=name) + if termination_object.cable is not None: + raise forms.ValidationError(f"Side {side.upper()}: {device} {termination_object} is already connected") + except ObjectDoesNotExist: + raise forms.ValidationError(f"{side.upper()} side termination not found: {device} {name}") + + setattr(self.instance, f'termination_{side}', termination_object) + return termination_object + + def clean_side_a_name(self): + return self._clean_side('a') + + def clean_side_b_name(self): + return self._clean_side('b') + + def clean_length_unit(self): + # Avoid trying to save as NULL + length_unit = self.cleaned_data.get('length_unit', None) + return length_unit if length_unit is not None else '' + + +class VirtualChassisCSVForm(CustomFieldModelCSVForm): + master = CSVModelChoiceField( + queryset=Device.objects.all(), + to_field_name='name', + required=False, + help_text='Master device' + ) + + class Meta: + model = VirtualChassis + fields = ('name', 'domain', 'master') + + +class PowerPanelCSVForm(CustomFieldModelCSVForm): + site = CSVModelChoiceField( + queryset=Site.objects.all(), + to_field_name='name', + help_text='Name of parent site' + ) + location = CSVModelChoiceField( + queryset=Location.objects.all(), + required=False, + to_field_name='name' + ) + + class Meta: + model = PowerPanel + fields = ('site', 'location', 'name') + + def __init__(self, data=None, *args, **kwargs): + super().__init__(data, *args, **kwargs) + + if data: + + # Limit group queryset by assigned site + params = {f"site__{self.fields['site'].to_field_name}": data.get('site')} + self.fields['location'].queryset = self.fields['location'].queryset.filter(**params) + + +class PowerFeedCSVForm(CustomFieldModelCSVForm): + site = CSVModelChoiceField( + queryset=Site.objects.all(), + to_field_name='name', + help_text='Assigned site' + ) + power_panel = CSVModelChoiceField( + queryset=PowerPanel.objects.all(), + to_field_name='name', + help_text='Upstream power panel' + ) + location = CSVModelChoiceField( + queryset=Location.objects.all(), + to_field_name='name', + required=False, + help_text="Rack's location (if any)" + ) + rack = CSVModelChoiceField( + queryset=Rack.objects.all(), + to_field_name='name', + required=False, + help_text='Rack' + ) + status = CSVChoiceField( + choices=PowerFeedStatusChoices, + required=False, + help_text='Operational status' + ) + type = CSVChoiceField( + choices=PowerFeedTypeChoices, + required=False, + help_text='Primary or redundant' + ) + supply = CSVChoiceField( + choices=PowerFeedSupplyChoices, + required=False, + help_text='Supply type (AC/DC)' + ) + phase = CSVChoiceField( + choices=PowerFeedPhaseChoices, + required=False, + help_text='Single or three-phase' + ) + + class Meta: + model = PowerFeed + fields = ( + 'site', 'power_panel', 'location', 'rack', 'name', 'status', 'type', 'mark_connected', 'supply', 'phase', + 'voltage', 'amperage', 'max_utilization', 'comments', + ) + + def __init__(self, data=None, *args, **kwargs): + super().__init__(data, *args, **kwargs) + + if data: + + # Limit power_panel queryset by site + params = {f"site__{self.fields['site'].to_field_name}": data.get('site')} + self.fields['power_panel'].queryset = self.fields['power_panel'].queryset.filter(**params) + + # Limit location queryset by site + params = {f"site__{self.fields['site'].to_field_name}": data.get('site')} + self.fields['location'].queryset = self.fields['location'].queryset.filter(**params) + + # Limit rack queryset by site and group + params = { + f"site__{self.fields['site'].to_field_name}": data.get('site'), + f"location__{self.fields['location'].to_field_name}": data.get('location'), + } + self.fields['rack'].queryset = self.fields['rack'].queryset.filter(**params) diff --git a/netbox/dcim/forms/common.py b/netbox/dcim/forms/common.py new file mode 100644 index 000000000..f484b48e1 --- /dev/null +++ b/netbox/dcim/forms/common.py @@ -0,0 +1,49 @@ +from django import forms + +from dcim.choices import * +from dcim.constants import * + +__all__ = ( + 'InterfaceCommonForm', +) + + +class InterfaceCommonForm(forms.Form): + mac_address = forms.CharField( + empty_value=None, + required=False, + label='MAC address' + ) + mtu = forms.IntegerField( + required=False, + min_value=INTERFACE_MTU_MIN, + max_value=INTERFACE_MTU_MAX, + label='MTU' + ) + + def clean(self): + super().clean() + + parent_field = 'device' if 'device' in self.cleaned_data else 'virtual_machine' + tagged_vlans = self.cleaned_data.get('tagged_vlans') + + # Untagged interfaces cannot be assigned tagged VLANs + if self.cleaned_data['mode'] == InterfaceModeChoices.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'] == InterfaceModeChoices.MODE_TAGGED_ALL: + self.cleaned_data['tagged_vlans'] = [] + + # Validate tagged VLANs; must be a global VLAN or in the same site + elif self.cleaned_data['mode'] == InterfaceModeChoices.MODE_TAGGED and tagged_vlans: + valid_sites = [None, self.cleaned_data[parent_field].site] + invalid_vlans = [str(v) for v in tagged_vlans if v.site not in valid_sites] + + if invalid_vlans: + raise forms.ValidationError({ + 'tagged_vlans': f"The tagged VLANs ({', '.join(invalid_vlans)}) must belong to the same site as " + f"the interface's parent device/VM, or they must be global" + }) diff --git a/netbox/dcim/forms/connections.py b/netbox/dcim/forms/connections.py new file mode 100644 index 000000000..a2ceea6cf --- /dev/null +++ b/netbox/dcim/forms/connections.py @@ -0,0 +1,289 @@ +from circuits.models import Circuit, CircuitTermination, Provider +from dcim.models import * +from extras.forms import CustomFieldModelForm +from extras.models import Tag +from utilities.forms import BootstrapMixin, DynamicModelChoiceField, DynamicModelMultipleChoiceField, StaticSelect + +__all__ = ( + 'ConnectCableToCircuitTerminationForm', + 'ConnectCableToConsolePortForm', + 'ConnectCableToConsoleServerPortForm', + 'ConnectCableToFrontPortForm', + 'ConnectCableToInterfaceForm', + 'ConnectCableToPowerFeedForm', + 'ConnectCableToPowerPortForm', + 'ConnectCableToPowerOutletForm', + 'ConnectCableToRearPortForm', +) + + +class ConnectCableToDeviceForm(BootstrapMixin, CustomFieldModelForm): + """ + Base form for connecting a Cable to a Device component + """ + termination_b_region = DynamicModelChoiceField( + queryset=Region.objects.all(), + label='Region', + required=False + ) + termination_b_site_group = DynamicModelChoiceField( + queryset=SiteGroup.objects.all(), + label='Site group', + required=False + ) + termination_b_site = DynamicModelChoiceField( + queryset=Site.objects.all(), + label='Site', + required=False, + query_params={ + 'region_id': '$termination_b_region', + 'group_id': '$termination_b_site_group', + } + ) + termination_b_location = DynamicModelChoiceField( + queryset=Location.objects.all(), + label='Location', + required=False, + null_option='None', + query_params={ + 'site_id': '$termination_b_site' + } + ) + termination_b_rack = DynamicModelChoiceField( + queryset=Rack.objects.all(), + label='Rack', + required=False, + null_option='None', + query_params={ + 'site_id': '$termination_b_site', + 'location_id': '$termination_b_location', + } + ) + termination_b_device = DynamicModelChoiceField( + queryset=Device.objects.all(), + label='Device', + required=False, + query_params={ + 'site_id': '$termination_b_site', + 'location_id': '$termination_b_location', + 'rack_id': '$termination_b_rack', + } + ) + tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), + required=False + ) + + class Meta: + model = Cable + fields = [ + 'termination_b_region', 'termination_b_site', 'termination_b_rack', 'termination_b_device', + 'termination_b_id', 'type', 'status', 'label', 'color', 'length', 'length_unit', 'tags', + ] + widgets = { + 'status': StaticSelect, + 'type': StaticSelect, + 'length_unit': StaticSelect, + } + + def clean_termination_b_id(self): + # Return the PK rather than the object + return getattr(self.cleaned_data['termination_b_id'], 'pk', None) + + +class ConnectCableToConsolePortForm(ConnectCableToDeviceForm): + termination_b_id = DynamicModelChoiceField( + queryset=ConsolePort.objects.all(), + label='Name', + disabled_indicator='_occupied', + query_params={ + 'device_id': '$termination_b_device' + } + ) + + +class ConnectCableToConsoleServerPortForm(ConnectCableToDeviceForm): + termination_b_id = DynamicModelChoiceField( + queryset=ConsoleServerPort.objects.all(), + label='Name', + disabled_indicator='_occupied', + query_params={ + 'device_id': '$termination_b_device' + } + ) + + +class ConnectCableToPowerPortForm(ConnectCableToDeviceForm): + termination_b_id = DynamicModelChoiceField( + queryset=PowerPort.objects.all(), + label='Name', + disabled_indicator='_occupied', + query_params={ + 'device_id': '$termination_b_device' + } + ) + + +class ConnectCableToPowerOutletForm(ConnectCableToDeviceForm): + termination_b_id = DynamicModelChoiceField( + queryset=PowerOutlet.objects.all(), + label='Name', + disabled_indicator='_occupied', + query_params={ + 'device_id': '$termination_b_device' + } + ) + + +class ConnectCableToInterfaceForm(ConnectCableToDeviceForm): + termination_b_id = DynamicModelChoiceField( + queryset=Interface.objects.all(), + label='Name', + disabled_indicator='_occupied', + query_params={ + 'device_id': '$termination_b_device', + 'kind': 'physical', + } + ) + + +class ConnectCableToFrontPortForm(ConnectCableToDeviceForm): + termination_b_id = DynamicModelChoiceField( + queryset=FrontPort.objects.all(), + label='Name', + disabled_indicator='_occupied', + query_params={ + 'device_id': '$termination_b_device' + } + ) + + +class ConnectCableToRearPortForm(ConnectCableToDeviceForm): + termination_b_id = DynamicModelChoiceField( + queryset=RearPort.objects.all(), + label='Name', + disabled_indicator='_occupied', + query_params={ + 'device_id': '$termination_b_device' + } + ) + + +class ConnectCableToCircuitTerminationForm(BootstrapMixin, CustomFieldModelForm): + termination_b_provider = DynamicModelChoiceField( + queryset=Provider.objects.all(), + label='Provider', + required=False + ) + termination_b_region = DynamicModelChoiceField( + queryset=Region.objects.all(), + label='Region', + required=False + ) + termination_b_site_group = DynamicModelChoiceField( + queryset=SiteGroup.objects.all(), + label='Site group', + required=False + ) + termination_b_site = DynamicModelChoiceField( + queryset=Site.objects.all(), + label='Site', + required=False, + query_params={ + 'region_id': '$termination_b_region', + 'group_id': '$termination_b_site_group', + } + ) + termination_b_circuit = DynamicModelChoiceField( + queryset=Circuit.objects.all(), + label='Circuit', + query_params={ + 'provider_id': '$termination_b_provider', + 'site_id': '$termination_b_site', + } + ) + termination_b_id = DynamicModelChoiceField( + queryset=CircuitTermination.objects.all(), + label='Side', + disabled_indicator='_occupied', + query_params={ + 'circuit_id': '$termination_b_circuit' + } + ) + tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), + required=False + ) + + class Meta: + model = Cable + fields = [ + 'termination_b_provider', 'termination_b_region', 'termination_b_site', 'termination_b_circuit', + 'termination_b_id', 'type', 'status', 'label', 'color', 'length', 'length_unit', 'tags', + ] + + def clean_termination_b_id(self): + # Return the PK rather than the object + return getattr(self.cleaned_data['termination_b_id'], 'pk', None) + + +class ConnectCableToPowerFeedForm(BootstrapMixin, CustomFieldModelForm): + termination_b_region = DynamicModelChoiceField( + queryset=Region.objects.all(), + label='Region', + required=False + ) + termination_b_site_group = DynamicModelChoiceField( + queryset=SiteGroup.objects.all(), + label='Site group', + required=False + ) + termination_b_site = DynamicModelChoiceField( + queryset=Site.objects.all(), + label='Site', + required=False, + query_params={ + 'region_id': '$termination_b_region', + 'group_id': '$termination_b_site_group', + } + ) + termination_b_location = DynamicModelChoiceField( + queryset=Location.objects.all(), + label='Location', + required=False, + query_params={ + 'site_id': '$termination_b_site' + } + ) + termination_b_powerpanel = DynamicModelChoiceField( + queryset=PowerPanel.objects.all(), + label='Power Panel', + required=False, + query_params={ + 'site_id': '$termination_b_site', + 'location_id': '$termination_b_location', + } + ) + termination_b_id = DynamicModelChoiceField( + queryset=PowerFeed.objects.all(), + label='Name', + disabled_indicator='_occupied', + query_params={ + 'power_panel_id': '$termination_b_powerpanel' + } + ) + tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), + required=False + ) + + class Meta: + model = Cable + fields = [ + 'termination_b_location', 'termination_b_powerpanel', 'termination_b_id', 'type', 'status', 'label', + 'color', 'length', 'length_unit', 'tags', + ] + + def clean_termination_b_id(self): + # Return the PK rather than the object + return getattr(self.cleaned_data['termination_b_id'], 'pk', None) diff --git a/netbox/dcim/forms/fields.py b/netbox/dcim/forms/fields.py new file mode 100644 index 000000000..25a20667b --- /dev/null +++ b/netbox/dcim/forms/fields.py @@ -0,0 +1,25 @@ +from django import forms +from netaddr import EUI +from netaddr.core import AddrFormatError + +__all__ = ( + 'MACAddressField', +) + + +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 diff --git a/netbox/dcim/forms/filtersets.py b/netbox/dcim/forms/filtersets.py new file mode 100644 index 000000000..95ff9aa3d --- /dev/null +++ b/netbox/dcim/forms/filtersets.py @@ -0,0 +1,1143 @@ +from django import forms +from django.contrib.auth.models import User +from django.utils.translation import gettext as _ + +from dcim.choices import * +from dcim.constants import * +from dcim.models import * +from extras.forms import CustomFieldModelFilterForm, LocalConfigContextFilterForm +from tenancy.forms import TenancyFilterForm +from tenancy.models import Tenant +from utilities.forms import ( + APISelectMultiple, add_blank_choice, BootstrapMixin, ColorField, DynamicModelMultipleChoiceField, StaticSelect, + StaticSelectMultiple, TagFilterField, BOOLEAN_WITH_BLANK_CHOICES, +) + +__all__ = ( + 'CableFilterForm', + 'ConsoleConnectionFilterForm', + 'ConsolePortFilterForm', + 'ConsoleServerPortFilterForm', + 'DeviceBayFilterForm', + 'DeviceFilterForm', + 'DeviceRoleFilterForm', + 'DeviceTypeFilterForm', + 'FrontPortFilterForm', + 'InterfaceConnectionFilterForm', + 'InterfaceFilterForm', + 'InventoryItemFilterForm', + 'LocationFilterForm', + 'ManufacturerFilterForm', + 'PlatformFilterForm', + 'PowerConnectionFilterForm', + 'PowerFeedFilterForm', + 'PowerOutletFilterForm', + 'PowerPanelFilterForm', + 'PowerPortFilterForm', + 'RackFilterForm', + 'RackElevationFilterForm', + 'RackReservationFilterForm', + 'RackRoleFilterForm', + 'RearPortFilterForm', + 'RegionFilterForm', + 'SiteFilterForm', + 'SiteGroupFilterForm', + 'VirtualChassisFilterForm', +) + + +class DeviceComponentFilterForm(BootstrapMixin, CustomFieldModelFilterForm): + field_order = [ + 'q', 'name', 'label', 'region_id', 'site_group_id', 'site_id', + ] + q = forms.CharField( + required=False, + widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), + label=_('Search') + ) + name = forms.CharField( + required=False + ) + label = forms.CharField( + required=False + ) + region_id = DynamicModelMultipleChoiceField( + queryset=Region.objects.all(), + required=False, + label=_('Region'), + fetch_trigger='open' + ) + site_group_id = DynamicModelMultipleChoiceField( + queryset=SiteGroup.objects.all(), + required=False, + label=_('Site group'), + fetch_trigger='open' + ) + site_id = DynamicModelMultipleChoiceField( + queryset=Site.objects.all(), + required=False, + query_params={ + 'region_id': '$region_id', + 'group_id': '$site_group_id', + }, + label=_('Site'), + fetch_trigger='open' + ) + location_id = DynamicModelMultipleChoiceField( + queryset=Location.objects.all(), + required=False, + query_params={ + 'site_id': '$site_id', + }, + label=_('Location'), + fetch_trigger='open' + ) + device_id = DynamicModelMultipleChoiceField( + queryset=Device.objects.all(), + required=False, + query_params={ + 'site_id': '$site_id', + 'location_id': '$location_id', + }, + label=_('Device'), + fetch_trigger='open' + ) + + +class RegionFilterForm(BootstrapMixin, CustomFieldModelFilterForm): + model = Region + field_groups = [ + ['q'], + ['parent_id'], + ] + q = forms.CharField( + required=False, + widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), + label=_('Search') + ) + parent_id = DynamicModelMultipleChoiceField( + queryset=Region.objects.all(), + required=False, + label=_('Parent region'), + fetch_trigger='open' + ) + + +class SiteGroupFilterForm(BootstrapMixin, CustomFieldModelFilterForm): + model = SiteGroup + field_groups = [ + ['q'], + ['parent_id'], + ] + q = forms.CharField( + required=False, + widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), + label=_('Search') + ) + parent_id = DynamicModelMultipleChoiceField( + queryset=SiteGroup.objects.all(), + required=False, + label=_('Parent group'), + fetch_trigger='open' + ) + + +class SiteFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilterForm): + model = Site + field_order = ['q', 'status', 'region_id', 'tenant_group_id', 'tenant_id'] + field_groups = [ + ['q', 'tag'], + ['status', 'region_id', 'group_id'], + ['tenant_group_id', 'tenant_id'], + ] + q = forms.CharField( + required=False, + widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), + label=_('Search') + ) + status = forms.MultipleChoiceField( + choices=SiteStatusChoices, + required=False, + widget=StaticSelectMultiple(), + ) + region_id = DynamicModelMultipleChoiceField( + queryset=Region.objects.all(), + required=False, + label=_('Region'), + fetch_trigger='open' + ) + group_id = DynamicModelMultipleChoiceField( + queryset=SiteGroup.objects.all(), + required=False, + label=_('Site group'), + fetch_trigger='open' + ) + tag = TagFilterField(model) + + +class LocationFilterForm(BootstrapMixin, CustomFieldModelFilterForm): + model = Location + q = forms.CharField( + required=False, + widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), + label=_('Search') + ) + region_id = DynamicModelMultipleChoiceField( + queryset=Region.objects.all(), + required=False, + label=_('Region'), + fetch_trigger='open' + ) + site_group_id = DynamicModelMultipleChoiceField( + queryset=SiteGroup.objects.all(), + required=False, + label=_('Site group'), + fetch_trigger='open' + ) + site_id = DynamicModelMultipleChoiceField( + queryset=Site.objects.all(), + required=False, + query_params={ + 'region_id': '$region_id', + 'group_id': '$site_group_id', + }, + label=_('Site'), + fetch_trigger='open' + ) + parent_id = DynamicModelMultipleChoiceField( + queryset=Location.objects.all(), + required=False, + query_params={ + 'region_id': '$region_id', + 'site_id': '$site_id', + }, + label=_('Parent'), + fetch_trigger='open' + ) + + +class RackRoleFilterForm(BootstrapMixin, CustomFieldModelFilterForm): + model = RackRole + field_groups = [ + ['q'], + ] + q = forms.CharField( + required=False, + widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), + label=_('Search') + ) + + +class RackFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilterForm): + model = Rack + field_order = ['q', 'region_id', 'site_id', 'location_id', 'status', 'role_id', 'tenant_group_id', 'tenant_id'] + field_groups = [ + ['q', 'tag'], + ['region_id', 'site_id', 'location_id'], + ['status', 'role_id'], + ['type', 'width', 'serial', 'asset_tag'], + ['tenant_group_id', 'tenant_id'], + ] + q = forms.CharField( + required=False, + widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), + label=_('Search') + ) + region_id = DynamicModelMultipleChoiceField( + queryset=Region.objects.all(), + required=False, + label=_('Region'), + fetch_trigger='open' + ) + site_id = DynamicModelMultipleChoiceField( + queryset=Site.objects.all(), + required=False, + query_params={ + 'region_id': '$region_id' + }, + label=_('Site'), + fetch_trigger='open' + ) + location_id = DynamicModelMultipleChoiceField( + queryset=Location.objects.all(), + required=False, + null_option='None', + query_params={ + 'site_id': '$site_id' + }, + label=_('Location'), + fetch_trigger='open' + ) + status = forms.MultipleChoiceField( + choices=RackStatusChoices, + required=False, + widget=StaticSelectMultiple() + ) + type = forms.MultipleChoiceField( + choices=RackTypeChoices, + required=False, + widget=StaticSelectMultiple() + ) + width = forms.MultipleChoiceField( + choices=RackWidthChoices, + required=False, + widget=StaticSelectMultiple() + ) + role_id = DynamicModelMultipleChoiceField( + queryset=RackRole.objects.all(), + required=False, + null_option='None', + label=_('Role'), + fetch_trigger='open' + ) + serial = forms.CharField( + required=False + ) + asset_tag = forms.CharField( + required=False + ) + tag = TagFilterField(model) + + +class RackElevationFilterForm(RackFilterForm): + field_order = [ + 'q', 'region_id', 'site_id', 'location_id', 'id', 'status', 'role_id', 'tenant_group_id', + 'tenant_id', + ] + id = DynamicModelMultipleChoiceField( + queryset=Rack.objects.all(), + label=_('Rack'), + required=False, + query_params={ + 'site_id': '$site_id', + 'location_id': '$location_id', + }, + fetch_trigger='open' + ) + + +class RackReservationFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilterForm): + model = RackReservation + field_order = ['q', 'region_id', 'site_id', 'location_id', 'user_id', 'tenant_group_id', 'tenant_id'] + field_groups = [ + ['q', 'tag'], + ['user_id'], + ['region_id', 'site_id', 'location_id'], + ['tenant_group_id', 'tenant_id'], + ] + q = forms.CharField( + required=False, + widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), + label=_('Search') + ) + region_id = DynamicModelMultipleChoiceField( + queryset=Region.objects.all(), + required=False, + label=_('Region'), + fetch_trigger='open' + ) + site_id = DynamicModelMultipleChoiceField( + queryset=Site.objects.all(), + required=False, + query_params={ + 'region_id': '$region_id' + }, + label=_('Site'), + fetch_trigger='open' + ) + location_id = DynamicModelMultipleChoiceField( + queryset=Location.objects.prefetch_related('site'), + required=False, + label=_('Location'), + null_option='None', + fetch_trigger='open' + ) + user_id = DynamicModelMultipleChoiceField( + queryset=User.objects.all(), + required=False, + label=_('User'), + widget=APISelectMultiple( + api_url='/api/users/users/', + ), + fetch_trigger='open' + ) + tag = TagFilterField(model) + + +class ManufacturerFilterForm(BootstrapMixin, CustomFieldModelFilterForm): + model = Manufacturer + field_groups = [ + ['q'], + ] + q = forms.CharField( + required=False, + widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), + label=_('Search') + ) + + +class DeviceTypeFilterForm(BootstrapMixin, CustomFieldModelFilterForm): + model = DeviceType + field_groups = [ + ['q', 'tag'], + ['manufacturer_id', 'subdevice_role'], + ['console_ports', 'console_server_ports', 'power_ports', 'power_outlets', 'interfaces', 'pass_through_ports'], + ] + q = forms.CharField( + required=False, + widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), + label=_('Search') + ) + manufacturer_id = DynamicModelMultipleChoiceField( + queryset=Manufacturer.objects.all(), + required=False, + label=_('Manufacturer'), + fetch_trigger='open' + ) + subdevice_role = forms.MultipleChoiceField( + choices=add_blank_choice(SubdeviceRoleChoices), + required=False, + widget=StaticSelectMultiple() + ) + console_ports = forms.NullBooleanField( + required=False, + label='Has console ports', + widget=StaticSelect( + choices=BOOLEAN_WITH_BLANK_CHOICES + ) + ) + console_server_ports = forms.NullBooleanField( + required=False, + label='Has console server ports', + widget=StaticSelect( + choices=BOOLEAN_WITH_BLANK_CHOICES + ) + ) + power_ports = forms.NullBooleanField( + required=False, + label='Has power ports', + widget=StaticSelect( + choices=BOOLEAN_WITH_BLANK_CHOICES + ) + ) + power_outlets = forms.NullBooleanField( + required=False, + label='Has power outlets', + widget=StaticSelect( + choices=BOOLEAN_WITH_BLANK_CHOICES + ) + ) + interfaces = forms.NullBooleanField( + required=False, + label='Has interfaces', + widget=StaticSelect( + choices=BOOLEAN_WITH_BLANK_CHOICES + ) + ) + pass_through_ports = forms.NullBooleanField( + required=False, + label='Has pass-through ports', + widget=StaticSelect( + choices=BOOLEAN_WITH_BLANK_CHOICES + ) + ) + tag = TagFilterField(model) + + +class DeviceRoleFilterForm(BootstrapMixin, CustomFieldModelFilterForm): + model = DeviceRole + field_groups = [ + ['q'], + ] + q = forms.CharField( + required=False, + widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), + label=_('Search') + ) + + +class PlatformFilterForm(BootstrapMixin, CustomFieldModelFilterForm): + model = Platform + q = forms.CharField( + required=False, + widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), + label=_('Search') + ) + manufacturer_id = DynamicModelMultipleChoiceField( + queryset=Manufacturer.objects.all(), + required=False, + label=_('Manufacturer'), + fetch_trigger='open' + ) + + +class DeviceFilterForm(BootstrapMixin, LocalConfigContextFilterForm, TenancyFilterForm, CustomFieldModelFilterForm): + model = Device + field_order = [ + 'q', 'region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'status', 'role_id', 'tenant_group_id', + 'tenant_id', 'manufacturer_id', 'device_type_id', 'asset_tag', 'mac_address', 'has_primary_ip', + ] + field_groups = [ + ['q', 'tag'], + ['region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id'], + ['status', 'role_id', 'serial', 'asset_tag', 'mac_address'], + ['manufacturer_id', 'device_type_id', 'platform_id'], + ['tenant_group_id', 'tenant_id'], + [ + 'has_primary_ip', 'virtual_chassis_member', 'console_ports', 'console_server_ports', 'power_ports', + 'power_outlets', 'interfaces', 'pass_through_ports', 'local_context_data', + ], + ] + q = forms.CharField( + required=False, + widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), + label=_('Search') + ) + region_id = DynamicModelMultipleChoiceField( + queryset=Region.objects.all(), + required=False, + label=_('Region'), + fetch_trigger='open' + ) + site_group_id = DynamicModelMultipleChoiceField( + queryset=SiteGroup.objects.all(), + required=False, + label=_('Site group'), + fetch_trigger='open' + ) + site_id = DynamicModelMultipleChoiceField( + queryset=Site.objects.all(), + required=False, + query_params={ + 'region_id': '$region_id', + 'group_id': '$site_group_id', + }, + label=_('Site'), + fetch_trigger='open' + ) + location_id = DynamicModelMultipleChoiceField( + queryset=Location.objects.all(), + required=False, + null_option='None', + query_params={ + 'site_id': '$site_id' + }, + label=_('Location'), + fetch_trigger='open' + ) + rack_id = DynamicModelMultipleChoiceField( + queryset=Rack.objects.all(), + required=False, + null_option='None', + query_params={ + 'site_id': '$site_id', + 'location_id': '$location_id', + }, + label=_('Rack'), + fetch_trigger='open' + ) + role_id = DynamicModelMultipleChoiceField( + queryset=DeviceRole.objects.all(), + required=False, + label=_('Role'), + fetch_trigger='open' + ) + manufacturer_id = DynamicModelMultipleChoiceField( + queryset=Manufacturer.objects.all(), + required=False, + label=_('Manufacturer'), + fetch_trigger='open' + ) + device_type_id = DynamicModelMultipleChoiceField( + queryset=DeviceType.objects.all(), + required=False, + query_params={ + 'manufacturer_id': '$manufacturer_id' + }, + label=_('Model'), + fetch_trigger='open' + ) + platform_id = DynamicModelMultipleChoiceField( + queryset=Platform.objects.all(), + required=False, + null_option='None', + label=_('Platform'), + fetch_trigger='open' + ) + status = forms.MultipleChoiceField( + choices=DeviceStatusChoices, + required=False, + widget=StaticSelectMultiple() + ) + serial = forms.CharField( + required=False + ) + asset_tag = forms.CharField( + required=False + ) + mac_address = forms.CharField( + required=False, + label='MAC address' + ) + has_primary_ip = forms.NullBooleanField( + required=False, + label='Has a primary IP', + widget=StaticSelect( + choices=BOOLEAN_WITH_BLANK_CHOICES + ) + ) + virtual_chassis_member = forms.NullBooleanField( + required=False, + label='Virtual chassis member', + widget=StaticSelect( + choices=BOOLEAN_WITH_BLANK_CHOICES + ) + ) + console_ports = forms.NullBooleanField( + required=False, + label='Has console ports', + widget=StaticSelect( + choices=BOOLEAN_WITH_BLANK_CHOICES + ) + ) + console_server_ports = forms.NullBooleanField( + required=False, + label='Has console server ports', + widget=StaticSelect( + choices=BOOLEAN_WITH_BLANK_CHOICES + ) + ) + power_ports = forms.NullBooleanField( + required=False, + label='Has power ports', + widget=StaticSelect( + choices=BOOLEAN_WITH_BLANK_CHOICES + ) + ) + power_outlets = forms.NullBooleanField( + required=False, + label='Has power outlets', + widget=StaticSelect( + choices=BOOLEAN_WITH_BLANK_CHOICES + ) + ) + interfaces = forms.NullBooleanField( + required=False, + label='Has interfaces', + widget=StaticSelect( + choices=BOOLEAN_WITH_BLANK_CHOICES + ) + ) + pass_through_ports = forms.NullBooleanField( + required=False, + label='Has pass-through ports', + widget=StaticSelect( + choices=BOOLEAN_WITH_BLANK_CHOICES + ) + ) + tag = TagFilterField(model) + + +class VirtualChassisFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilterForm): + model = VirtualChassis + field_order = ['q', 'region_id', 'site_group_id', 'site_id', 'tenant_group_id', 'tenant_id'] + field_groups = [ + ['q', 'tag'], + ['region_id', 'site_group_id', 'site_id'], + ['tenant_group_id', 'tenant_id'], + ] + q = forms.CharField( + required=False, + widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), + label=_('Search') + ) + region_id = DynamicModelMultipleChoiceField( + queryset=Region.objects.all(), + required=False, + label=_('Region'), + fetch_trigger='open' + ) + site_group_id = DynamicModelMultipleChoiceField( + queryset=SiteGroup.objects.all(), + required=False, + label=_('Site group'), + fetch_trigger='open' + ) + site_id = DynamicModelMultipleChoiceField( + queryset=Site.objects.all(), + required=False, + query_params={ + 'region_id': '$region_id', + 'group_id': '$site_group_id', + }, + label=_('Site'), + fetch_trigger='open' + ) + tag = TagFilterField(model) + + +class CableFilterForm(BootstrapMixin, CustomFieldModelFilterForm): + model = Cable + field_groups = [ + ['q', 'tag'], + ['site_id', 'rack_id', 'device_id'], + ['type', 'status', 'color'], + ['tenant_id'], + ] + q = forms.CharField( + required=False, + widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), + label=_('Search') + ) + region_id = DynamicModelMultipleChoiceField( + queryset=Region.objects.all(), + required=False, + label=_('Region'), + fetch_trigger='open' + ) + site_id = DynamicModelMultipleChoiceField( + queryset=Site.objects.all(), + required=False, + query_params={ + 'region_id': '$region_id' + }, + label=_('Site'), + fetch_trigger='open' + ) + tenant_id = DynamicModelMultipleChoiceField( + queryset=Tenant.objects.all(), + required=False, + label=_('Tenant'), + fetch_trigger='open' + ) + rack_id = DynamicModelMultipleChoiceField( + queryset=Rack.objects.all(), + required=False, + label=_('Rack'), + null_option='None', + query_params={ + 'site_id': '$site_id' + }, + fetch_trigger='open' + ) + type = forms.MultipleChoiceField( + choices=add_blank_choice(CableTypeChoices), + required=False, + widget=StaticSelect() + ) + status = forms.ChoiceField( + required=False, + choices=add_blank_choice(CableStatusChoices), + widget=StaticSelect() + ) + color = ColorField( + required=False + ) + device_id = DynamicModelMultipleChoiceField( + queryset=Device.objects.all(), + required=False, + query_params={ + 'site_id': '$site_id', + 'tenant_id': '$tenant_id', + 'rack_id': '$rack_id', + }, + label=_('Device'), + fetch_trigger='open' + ) + tag = TagFilterField(model) + + +class PowerPanelFilterForm(BootstrapMixin, CustomFieldModelFilterForm): + model = PowerPanel + field_groups = ( + ('q', 'tag'), + ('region_id', 'site_group_id', 'site_id', 'location_id') + ) + q = forms.CharField( + required=False, + widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), + label=_('Search') + ) + region_id = DynamicModelMultipleChoiceField( + queryset=Region.objects.all(), + required=False, + label=_('Region'), + fetch_trigger='open' + ) + site_group_id = DynamicModelMultipleChoiceField( + queryset=SiteGroup.objects.all(), + required=False, + label=_('Site group'), + fetch_trigger='open' + ) + site_id = DynamicModelMultipleChoiceField( + queryset=Site.objects.all(), + required=False, + query_params={ + 'region_id': '$region_id', + 'group_id': '$site_group_id', + }, + label=_('Site'), + fetch_trigger='open' + ) + location_id = DynamicModelMultipleChoiceField( + queryset=Location.objects.all(), + required=False, + null_option='None', + query_params={ + 'site_id': '$site_id' + }, + label=_('Location'), + fetch_trigger='open' + ) + tag = TagFilterField(model) + + +class PowerFeedFilterForm(BootstrapMixin, CustomFieldModelFilterForm): + model = PowerFeed + field_groups = [ + ['q', 'tag'], + ['region_id', 'site_group_id', 'site_id'], + ['power_panel_id', 'rack_id'], + ['status', 'type', 'supply', 'phase', 'voltage', 'amperage', 'max_utilization'], + ] + q = forms.CharField( + required=False, + widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), + label=_('Search') + ) + region_id = DynamicModelMultipleChoiceField( + queryset=Region.objects.all(), + required=False, + label=_('Region'), + fetch_trigger='open' + ) + site_group_id = DynamicModelMultipleChoiceField( + queryset=SiteGroup.objects.all(), + required=False, + label=_('Site group'), + fetch_trigger='open' + ) + site_id = DynamicModelMultipleChoiceField( + queryset=Site.objects.all(), + required=False, + query_params={ + 'region_id': '$region_id' + }, + label=_('Site'), + fetch_trigger='open' + ) + power_panel_id = DynamicModelMultipleChoiceField( + queryset=PowerPanel.objects.all(), + required=False, + null_option='None', + query_params={ + 'site_id': '$site_id' + }, + label=_('Power panel'), + fetch_trigger='open' + ) + rack_id = DynamicModelMultipleChoiceField( + queryset=Rack.objects.all(), + required=False, + null_option='None', + query_params={ + 'site_id': '$site_id' + }, + label=_('Rack'), + fetch_trigger='open' + ) + status = forms.MultipleChoiceField( + choices=PowerFeedStatusChoices, + required=False, + widget=StaticSelectMultiple() + ) + type = forms.ChoiceField( + choices=add_blank_choice(PowerFeedTypeChoices), + required=False, + widget=StaticSelect() + ) + supply = forms.ChoiceField( + choices=add_blank_choice(PowerFeedSupplyChoices), + required=False, + widget=StaticSelect() + ) + phase = forms.ChoiceField( + choices=add_blank_choice(PowerFeedPhaseChoices), + required=False, + widget=StaticSelect() + ) + voltage = forms.IntegerField( + required=False + ) + amperage = forms.IntegerField( + required=False + ) + max_utilization = forms.IntegerField( + required=False + ) + tag = TagFilterField(model) + + +# +# Device components +# + +class ConsolePortFilterForm(DeviceComponentFilterForm): + model = ConsolePort + field_groups = [ + ['q', 'tag'], + ['name', 'label', 'type', 'speed'], + ['region_id', 'site_group_id', 'site_id', 'location_id', 'device_id'], + ] + type = forms.MultipleChoiceField( + choices=ConsolePortTypeChoices, + required=False, + widget=StaticSelectMultiple() + ) + speed = forms.MultipleChoiceField( + choices=ConsolePortSpeedChoices, + required=False, + widget=StaticSelectMultiple() + ) + tag = TagFilterField(model) + + +class ConsoleServerPortFilterForm(DeviceComponentFilterForm): + model = ConsoleServerPort + field_groups = [ + ['q', 'tag'], + ['name', 'label', 'type', 'speed'], + ['region_id', 'site_group_id', 'site_id', 'location_id', 'device_id'], + ] + type = forms.MultipleChoiceField( + choices=ConsolePortTypeChoices, + required=False, + widget=StaticSelectMultiple() + ) + speed = forms.MultipleChoiceField( + choices=ConsolePortSpeedChoices, + required=False, + widget=StaticSelectMultiple() + ) + tag = TagFilterField(model) + + +class PowerPortFilterForm(DeviceComponentFilterForm): + model = PowerPort + field_groups = [ + ['q', 'tag'], + ['name', 'label', 'type'], + ['region_id', 'site_group_id', 'site_id', 'location_id', 'device_id'], + ] + type = forms.MultipleChoiceField( + choices=PowerPortTypeChoices, + required=False, + widget=StaticSelectMultiple() + ) + tag = TagFilterField(model) + + +class PowerOutletFilterForm(DeviceComponentFilterForm): + model = PowerOutlet + field_groups = [ + ['q', 'tag'], + ['name', 'label', 'type'], + ['region_id', 'site_group_id', 'site_id', 'location_id', 'device_id'], + ] + type = forms.MultipleChoiceField( + choices=PowerOutletTypeChoices, + required=False, + widget=StaticSelectMultiple() + ) + tag = TagFilterField(model) + + +class InterfaceFilterForm(DeviceComponentFilterForm): + model = Interface + field_groups = [ + ['q', 'tag'], + ['name', 'label', 'type', 'enabled', 'mgmt_only', 'mac_address'], + ['region_id', 'site_group_id', 'site_id', 'location_id', 'device_id'], + ] + type = forms.MultipleChoiceField( + choices=InterfaceTypeChoices, + required=False, + widget=StaticSelectMultiple() + ) + enabled = forms.NullBooleanField( + required=False, + widget=StaticSelect( + choices=BOOLEAN_WITH_BLANK_CHOICES + ) + ) + mgmt_only = forms.NullBooleanField( + required=False, + widget=StaticSelect( + choices=BOOLEAN_WITH_BLANK_CHOICES + ) + ) + mac_address = forms.CharField( + required=False, + label='MAC address' + ) + tag = TagFilterField(model) + + +class FrontPortFilterForm(DeviceComponentFilterForm): + field_groups = [ + ['q', 'tag'], + ['name', 'label', 'type', 'color'], + ['region_id', 'site_group_id', 'site_id', 'location_id', 'device_id'], + ] + model = FrontPort + type = forms.MultipleChoiceField( + choices=PortTypeChoices, + required=False, + widget=StaticSelectMultiple() + ) + color = ColorField( + required=False + ) + tag = TagFilterField(model) + + +class RearPortFilterForm(DeviceComponentFilterForm): + model = RearPort + field_groups = [ + ['q', 'tag'], + ['name', 'label', 'type', 'color'], + ['region_id', 'site_group_id', 'site_id', 'location_id', 'device_id'], + ] + type = forms.MultipleChoiceField( + choices=PortTypeChoices, + required=False, + widget=StaticSelectMultiple() + ) + color = ColorField( + required=False + ) + tag = TagFilterField(model) + + +class DeviceBayFilterForm(DeviceComponentFilterForm): + model = DeviceBay + field_groups = [ + ['q', 'tag'], + ['name', 'label'], + ['region_id', 'site_group_id', 'site_id', 'location_id', 'device_id'], + ] + tag = TagFilterField(model) + + +class InventoryItemFilterForm(DeviceComponentFilterForm): + model = InventoryItem + field_groups = [ + ['q', 'tag'], + ['name', 'label', 'manufacturer_id', 'serial', 'asset_tag', 'discovered'], + ['region_id', 'site_group_id', 'site_id', 'location_id', 'device_id'], + ] + manufacturer_id = DynamicModelMultipleChoiceField( + queryset=Manufacturer.objects.all(), + required=False, + label=_('Manufacturer'), + fetch_trigger='open' + ) + serial = forms.CharField( + required=False + ) + asset_tag = forms.CharField( + required=False + ) + discovered = forms.NullBooleanField( + required=False, + widget=StaticSelect( + choices=BOOLEAN_WITH_BLANK_CHOICES + ) + ) + tag = TagFilterField(model) + + +# +# Connections +# + +class ConsoleConnectionFilterForm(BootstrapMixin, forms.Form): + region_id = DynamicModelMultipleChoiceField( + queryset=Region.objects.all(), + required=False, + label=_('Region'), + fetch_trigger='open' + ) + site_id = DynamicModelMultipleChoiceField( + queryset=Site.objects.all(), + required=False, + query_params={ + 'region_id': '$region_id' + }, + label=_('Site'), + fetch_trigger='open' + ) + device_id = DynamicModelMultipleChoiceField( + queryset=Device.objects.all(), + required=False, + query_params={ + 'site_id': '$site_id' + }, + label=_('Device'), + fetch_trigger='open' + ) + + +class PowerConnectionFilterForm(BootstrapMixin, forms.Form): + region_id = DynamicModelMultipleChoiceField( + queryset=Region.objects.all(), + required=False, + label=_('Region'), + fetch_trigger='open' + ) + site_id = DynamicModelMultipleChoiceField( + queryset=Site.objects.all(), + required=False, + query_params={ + 'region_id': '$region_id' + }, + label=_('Site'), + fetch_trigger='open' + ) + device_id = DynamicModelMultipleChoiceField( + queryset=Device.objects.all(), + required=False, + query_params={ + 'site_id': '$site_id' + }, + label=_('Device'), + fetch_trigger='open' + ) + + +class InterfaceConnectionFilterForm(BootstrapMixin, forms.Form): + region_id = DynamicModelMultipleChoiceField( + queryset=Region.objects.all(), + required=False, + label=_('Region'), + fetch_trigger='open' + ) + site_id = DynamicModelMultipleChoiceField( + queryset=Site.objects.all(), + required=False, + query_params={ + 'region_id': '$region_id' + }, + label=_('Site'), + fetch_trigger='open' + ) + device_id = DynamicModelMultipleChoiceField( + queryset=Device.objects.all(), + required=False, + query_params={ + 'site_id': '$site_id' + }, + label=_('Device'), + fetch_trigger='open' + ) diff --git a/netbox/dcim/forms/formsets.py b/netbox/dcim/forms/formsets.py new file mode 100644 index 000000000..6109a1575 --- /dev/null +++ b/netbox/dcim/forms/formsets.py @@ -0,0 +1,21 @@ +from django import forms + +__all__ = ( + 'BaseVCMemberFormSet', +) + + +class BaseVCMemberFormSet(forms.BaseModelFormSet): + + def clean(self): + super().clean() + + # Check for duplicate VC position values + vc_position_list = [] + for form in self.forms: + vc_position = form.cleaned_data.get('vc_position') + if vc_position: + if vc_position in vc_position_list: + error_msg = f"A virtual chassis member already exists in position {vc_position}." + form.add_error('vc_position', error_msg) + vc_position_list.append(vc_position) diff --git a/netbox/dcim/forms/models.py b/netbox/dcim/forms/models.py new file mode 100644 index 000000000..83fa00e33 --- /dev/null +++ b/netbox/dcim/forms/models.py @@ -0,0 +1,1232 @@ +from django import forms +from django.contrib.auth.models import User +from django.contrib.contenttypes.models import ContentType +from timezone_field import TimeZoneFormField + +from dcim.choices import * +from dcim.constants import * +from dcim.models import * +from extras.forms import CustomFieldModelForm +from extras.models import Tag +from ipam.models import IPAddress, VLAN, VLANGroup +from tenancy.forms import TenancyForm +from utilities.forms import ( + APISelect, add_blank_choice, BootstrapMixin, ClearableFileInput, CommentField, DynamicModelChoiceField, + DynamicModelMultipleChoiceField, JSONField, NumericArrayField, SelectWithPK, SmallTextarea, + SlugField, StaticSelect, +) +from virtualization.models import Cluster, ClusterGroup +from .common import InterfaceCommonForm + +__all__ = ( + 'CableForm', + 'ConsolePortForm', + 'ConsolePortTemplateForm', + 'ConsoleServerPortForm', + 'ConsoleServerPortTemplateForm', + 'DeviceBayForm', + 'DeviceBayTemplateForm', + 'DeviceForm', + 'DeviceRoleForm', + 'DeviceTypeForm', + 'DeviceVCMembershipForm', + 'FrontPortForm', + 'FrontPortTemplateForm', + 'InterfaceForm', + 'InterfaceTemplateForm', + 'InventoryItemForm', + 'LocationForm', + 'ManufacturerForm', + 'PlatformForm', + 'PowerFeedForm', + 'PowerOutletForm', + 'PowerOutletTemplateForm', + 'PowerPanelForm', + 'PowerPortForm', + 'PowerPortTemplateForm', + 'RackForm', + 'RackReservationForm', + 'RackRoleForm', + 'RearPortForm', + 'RearPortTemplateForm', + 'RegionForm', + 'SiteForm', + 'SiteGroupForm', + 'VirtualChassisForm', +) + +INTERFACE_MODE_HELP_TEXT = """ +Access: One untagged VLAN
    +Tagged: One untagged VLAN and/or one or more tagged VLANs
    +Tagged (All): Implies all VLANs are available (w/optional untagged VLAN) +""" + + +class RegionForm(BootstrapMixin, CustomFieldModelForm): + parent = DynamicModelChoiceField( + queryset=Region.objects.all(), + required=False + ) + slug = SlugField() + + class Meta: + model = Region + fields = ( + 'parent', 'name', 'slug', 'description', + ) + + +class SiteGroupForm(BootstrapMixin, CustomFieldModelForm): + parent = DynamicModelChoiceField( + queryset=SiteGroup.objects.all(), + required=False + ) + slug = SlugField() + + class Meta: + model = SiteGroup + fields = ( + 'parent', 'name', 'slug', 'description', + ) + + +class SiteForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): + region = DynamicModelChoiceField( + queryset=Region.objects.all(), + required=False + ) + group = DynamicModelChoiceField( + queryset=SiteGroup.objects.all(), + required=False + ) + slug = SlugField() + time_zone = TimeZoneFormField( + choices=add_blank_choice(TimeZoneFormField().choices), + required=False, + widget=StaticSelect() + ) + comments = CommentField() + tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), + required=False + ) + + class Meta: + model = Site + fields = [ + 'name', 'slug', 'status', 'region', 'group', 'tenant_group', 'tenant', 'facility', 'asn', 'time_zone', + 'description', 'physical_address', 'shipping_address', 'latitude', 'longitude', 'contact_name', + 'contact_phone', 'contact_email', 'comments', 'tags', + ] + fieldsets = ( + ('Site', ( + 'name', 'slug', 'status', 'region', 'group', 'facility', 'asn', 'time_zone', 'description', 'tags', + )), + ('Tenancy', ('tenant_group', 'tenant')), + ('Contact Info', ( + 'physical_address', 'shipping_address', 'latitude', 'longitude', 'contact_name', 'contact_phone', + 'contact_email', + )), + ) + widgets = { + 'physical_address': SmallTextarea( + attrs={ + 'rows': 3, + } + ), + 'shipping_address': SmallTextarea( + attrs={ + 'rows': 3, + } + ), + 'status': StaticSelect(), + 'time_zone': StaticSelect(), + } + help_texts = { + 'name': "Full name of the site", + 'facility': "Data center provider and facility (e.g. Equinix NY7)", + 'asn': "BGP autonomous system number", + 'time_zone': "Local time zone", + 'description': "Short description (will appear in sites list)", + 'physical_address': "Physical location of the building (e.g. for GPS)", + 'shipping_address': "If different from the physical address", + 'latitude': "Latitude in decimal format (xx.yyyyyy)", + 'longitude': "Longitude in decimal format (xx.yyyyyy)" + } + + +class LocationForm(BootstrapMixin, CustomFieldModelForm): + region = DynamicModelChoiceField( + queryset=Region.objects.all(), + required=False, + initial_params={ + 'sites': '$site' + } + ) + site_group = DynamicModelChoiceField( + queryset=SiteGroup.objects.all(), + required=False, + initial_params={ + 'sites': '$site' + } + ) + site = DynamicModelChoiceField( + queryset=Site.objects.all(), + query_params={ + 'region_id': '$region', + 'group_id': '$site_group', + } + ) + parent = DynamicModelChoiceField( + queryset=Location.objects.all(), + required=False, + query_params={ + 'site_id': '$site' + } + ) + slug = SlugField() + + class Meta: + model = Location + fields = ( + 'region', 'site_group', 'site', 'parent', 'name', 'slug', 'description', + ) + + +class RackRoleForm(BootstrapMixin, CustomFieldModelForm): + slug = SlugField() + + class Meta: + model = RackRole + fields = [ + 'name', 'slug', 'color', 'description', + ] + + +class RackForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): + region = DynamicModelChoiceField( + queryset=Region.objects.all(), + required=False, + initial_params={ + 'sites': '$site' + } + ) + site_group = DynamicModelChoiceField( + queryset=SiteGroup.objects.all(), + required=False, + initial_params={ + 'sites': '$site' + } + ) + site = DynamicModelChoiceField( + queryset=Site.objects.all(), + query_params={ + 'region_id': '$region', + 'group_id': '$site_group', + } + ) + location = DynamicModelChoiceField( + queryset=Location.objects.all(), + required=False, + query_params={ + 'site_id': '$site' + } + ) + role = DynamicModelChoiceField( + queryset=RackRole.objects.all(), + required=False + ) + comments = CommentField() + tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), + required=False + ) + + class Meta: + model = Rack + fields = [ + 'region', 'site_group', 'site', 'location', 'name', 'facility_id', 'tenant_group', 'tenant', 'status', + 'role', 'serial', 'asset_tag', 'type', 'width', 'u_height', 'desc_units', 'outer_width', 'outer_depth', + 'outer_unit', 'comments', 'tags', + ] + help_texts = { + 'site': "The site at which the rack exists", + 'name': "Organizational rack name", + 'facility_id': "The unique rack ID assigned by the facility", + 'u_height': "Height in rack units", + } + widgets = { + 'status': StaticSelect(), + 'type': StaticSelect(), + 'width': StaticSelect(), + 'outer_unit': StaticSelect(), + } + + +class RackReservationForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): + region = DynamicModelChoiceField( + queryset=Region.objects.all(), + required=False, + initial_params={ + 'sites': '$site' + }, + fetch_trigger='open' + ) + site_group = DynamicModelChoiceField( + queryset=SiteGroup.objects.all(), + required=False, + initial_params={ + 'sites': '$site' + }, + fetch_trigger='open' + ) + site = DynamicModelChoiceField( + queryset=Site.objects.all(), + required=False, + query_params={ + 'region_id': '$region', + 'group_id': '$site_group', + }, + fetch_trigger='open' + ) + location = DynamicModelChoiceField( + queryset=Location.objects.all(), + required=False, + query_params={ + 'site_id': '$site' + }, + fetch_trigger='open' + ) + rack = DynamicModelChoiceField( + queryset=Rack.objects.all(), + query_params={ + 'site_id': '$site', + 'location_id': '$location', + }, + fetch_trigger='open' + ) + units = NumericArrayField( + base_field=forms.IntegerField(), + help_text="Comma-separated list of numeric unit IDs. A range may be specified using a hyphen." + ) + user = forms.ModelChoiceField( + queryset=User.objects.order_by( + 'username' + ), + widget=StaticSelect() + ) + tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), + required=False, + fetch_trigger='open' + ) + + class Meta: + model = RackReservation + fields = [ + 'region', 'site_group', 'site', 'location', 'rack', 'units', 'user', 'tenant_group', 'tenant', + 'description', 'tags', + ] + fieldsets = ( + ('Reservation', ('region', 'site', 'location', 'rack', 'units', 'user', 'description', 'tags')), + ('Tenancy', ('tenant_group', 'tenant')), + ) + + +class ManufacturerForm(BootstrapMixin, CustomFieldModelForm): + slug = SlugField() + + class Meta: + model = Manufacturer + fields = [ + 'name', 'slug', 'description', + ] + + +class DeviceTypeForm(BootstrapMixin, CustomFieldModelForm): + manufacturer = DynamicModelChoiceField( + queryset=Manufacturer.objects.all() + ) + slug = SlugField( + slug_source='model' + ) + comments = CommentField() + tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), + required=False + ) + + class Meta: + model = DeviceType + fields = [ + 'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'subdevice_role', + 'front_image', 'rear_image', 'comments', 'tags', + ] + fieldsets = ( + ('Device Type', ( + 'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'subdevice_role', 'tags', + )), + ('Images', ('front_image', 'rear_image')), + ) + widgets = { + 'subdevice_role': StaticSelect(), + 'front_image': ClearableFileInput(attrs={ + 'accept': DEVICETYPE_IMAGE_FORMATS + }), + 'rear_image': ClearableFileInput(attrs={ + 'accept': DEVICETYPE_IMAGE_FORMATS + }) + } + + +class DeviceRoleForm(BootstrapMixin, CustomFieldModelForm): + slug = SlugField() + + class Meta: + model = DeviceRole + fields = [ + 'name', 'slug', 'color', 'vm_role', 'description', + ] + + +class PlatformForm(BootstrapMixin, CustomFieldModelForm): + manufacturer = DynamicModelChoiceField( + queryset=Manufacturer.objects.all(), + required=False + ) + slug = SlugField( + max_length=64 + ) + + class Meta: + model = Platform + fields = [ + 'name', 'slug', 'manufacturer', 'napalm_driver', 'napalm_args', 'description', + ] + widgets = { + 'napalm_args': SmallTextarea(), + } + + +class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): + region = DynamicModelChoiceField( + queryset=Region.objects.all(), + required=False, + initial_params={ + 'sites': '$site' + } + ) + site_group = DynamicModelChoiceField( + queryset=SiteGroup.objects.all(), + required=False, + initial_params={ + 'sites': '$site' + } + ) + site = DynamicModelChoiceField( + queryset=Site.objects.all(), + query_params={ + 'region_id': '$region', + 'group_id': '$site_group', + } + ) + location = DynamicModelChoiceField( + queryset=Location.objects.all(), + required=False, + query_params={ + 'site_id': '$site' + }, + initial_params={ + 'racks': '$rack' + } + ) + rack = DynamicModelChoiceField( + queryset=Rack.objects.all(), + required=False, + query_params={ + 'site_id': '$site', + 'location_id': '$location', + } + ) + position = forms.IntegerField( + required=False, + help_text="The lowest-numbered unit occupied by the device", + widget=APISelect( + api_url='/api/dcim/racks/{{rack}}/elevation/', + attrs={ + 'disabled-indicator': 'device', + 'data-query-param-face': "[\"$face\"]" + } + ) + ) + manufacturer = DynamicModelChoiceField( + queryset=Manufacturer.objects.all(), + required=False, + initial_params={ + 'device_types': '$device_type' + } + ) + device_type = DynamicModelChoiceField( + queryset=DeviceType.objects.all(), + query_params={ + 'manufacturer_id': '$manufacturer' + } + ) + device_role = DynamicModelChoiceField( + queryset=DeviceRole.objects.all() + ) + platform = DynamicModelChoiceField( + queryset=Platform.objects.all(), + required=False, + query_params={ + 'manufacturer_id': ['$manufacturer', 'null'] + } + ) + cluster_group = DynamicModelChoiceField( + queryset=ClusterGroup.objects.all(), + required=False, + null_option='None', + initial_params={ + 'clusters': '$cluster' + } + ) + cluster = DynamicModelChoiceField( + queryset=Cluster.objects.all(), + required=False, + query_params={ + 'group_id': '$cluster_group' + } + ) + comments = CommentField() + local_context_data = JSONField( + required=False, + label='' + ) + tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), + required=False + ) + + class Meta: + model = Device + fields = [ + 'name', 'device_role', 'device_type', 'serial', 'asset_tag', 'region', 'site_group', 'site', 'rack', + 'location', 'position', 'face', 'status', 'platform', 'primary_ip4', 'primary_ip6', 'cluster_group', + 'cluster', 'tenant_group', 'tenant', 'comments', 'tags', 'local_context_data' + ] + help_texts = { + 'device_role': "The function this device serves", + 'serial': "Chassis serial number", + 'local_context_data': "Local config context data overwrites all source contexts in the final rendered " + "config context", + } + widgets = { + 'face': StaticSelect(), + 'status': StaticSelect(), + 'primary_ip4': StaticSelect(), + 'primary_ip6': StaticSelect(), + } + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + if self.instance.pk: + + # Compile list of choices for primary IPv4 and IPv6 addresses + for family in [4, 6]: + ip_choices = [(None, '---------')] + + # Gather PKs of all interfaces belonging to this Device or a peer VirtualChassis member + interface_ids = self.instance.vc_interfaces(if_master=False).values_list('pk', flat=True) + + # Collect interface IPs + interface_ips = IPAddress.objects.filter( + address__family=family, + assigned_object_type=ContentType.objects.get_for_model(Interface), + assigned_object_id__in=interface_ids + ).prefetch_related('assigned_object') + if interface_ips: + ip_list = [(ip.id, f'{ip.address} ({ip.assigned_object})') for ip in interface_ips] + ip_choices.append(('Interface IPs', ip_list)) + # Collect NAT IPs + nat_ips = IPAddress.objects.prefetch_related('nat_inside').filter( + address__family=family, + nat_inside__assigned_object_type=ContentType.objects.get_for_model(Interface), + nat_inside__assigned_object_id__in=interface_ids + ).prefetch_related('assigned_object') + if nat_ips: + ip_list = [(ip.id, f'{ip.address} (NAT)') for ip in nat_ips] + ip_choices.append(('NAT IPs', ip_list)) + self.fields['primary_ip{}'.format(family)].choices = ip_choices + + # If editing an existing device, exclude it from the list of occupied rack units. This ensures that a device + # can be flipped from one face to another. + self.fields['position'].widget.add_query_param('exclude', self.instance.pk) + + # Limit platform by manufacturer + self.fields['platform'].queryset = Platform.objects.filter( + Q(manufacturer__isnull=True) | Q(manufacturer=self.instance.device_type.manufacturer) + ) + + # Disable rack assignment if this is a child device installed in a parent device + if self.instance.device_type.is_child_device and hasattr(self.instance, 'parent_bay'): + self.fields['site'].disabled = True + self.fields['rack'].disabled = True + self.initial['site'] = self.instance.parent_bay.device.site_id + self.initial['rack'] = self.instance.parent_bay.device.rack_id + + else: + + # An object that doesn't exist yet can't have any IPs assigned to it + self.fields['primary_ip4'].choices = [] + self.fields['primary_ip4'].widget.attrs['readonly'] = True + self.fields['primary_ip6'].choices = [] + self.fields['primary_ip6'].widget.attrs['readonly'] = True + + # Rack position + position = self.data.get('position') or self.initial.get('position') + if position: + self.fields['position'].widget.choices = [(position, f'U{position}')] + + +class CableForm(BootstrapMixin, CustomFieldModelForm): + tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), + required=False + ) + + class Meta: + model = Cable + fields = [ + 'type', 'status', 'label', 'color', 'length', 'length_unit', 'tags', + ] + widgets = { + 'status': StaticSelect, + 'type': StaticSelect, + 'length_unit': StaticSelect, + } + error_messages = { + 'length': { + 'max_value': 'Maximum length is 32767 (any unit)' + } + } + + +class PowerPanelForm(BootstrapMixin, CustomFieldModelForm): + region = DynamicModelChoiceField( + queryset=Region.objects.all(), + required=False, + initial_params={ + 'sites': '$site' + } + ) + site_group = DynamicModelChoiceField( + queryset=SiteGroup.objects.all(), + required=False, + initial_params={ + 'sites': '$site' + } + ) + site = DynamicModelChoiceField( + queryset=Site.objects.all(), + query_params={ + 'region_id': '$region', + 'group_id': '$site_group', + } + ) + location = DynamicModelChoiceField( + queryset=Location.objects.all(), + required=False, + query_params={ + 'site_id': '$site' + } + ) + tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), + required=False + ) + + class Meta: + model = PowerPanel + fields = [ + 'region', 'site_group', 'site', 'location', 'name', 'tags', + ] + fieldsets = ( + ('Power Panel', ('region', 'site_group', 'site', 'location', 'name', 'tags')), + ) + + +class PowerFeedForm(BootstrapMixin, CustomFieldModelForm): + region = DynamicModelChoiceField( + queryset=Region.objects.all(), + required=False, + initial_params={ + 'sites__powerpanel': '$power_panel' + } + ) + site_group = DynamicModelChoiceField( + queryset=SiteGroup.objects.all(), + required=False, + initial_params={ + 'sites': '$site' + } + ) + site = DynamicModelChoiceField( + queryset=Site.objects.all(), + required=False, + initial_params={ + 'powerpanel': '$power_panel' + }, + query_params={ + 'region_id': '$region', + 'group_id': '$site_group', + } + ) + power_panel = DynamicModelChoiceField( + queryset=PowerPanel.objects.all(), + query_params={ + 'site_id': '$site' + } + ) + rack = DynamicModelChoiceField( + queryset=Rack.objects.all(), + required=False, + query_params={ + 'site_id': '$site' + } + ) + comments = CommentField() + tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), + required=False + ) + + class Meta: + model = PowerFeed + fields = [ + 'region', 'site_group', 'site', 'power_panel', 'rack', 'name', 'status', 'type', 'mark_connected', 'supply', + 'phase', 'voltage', 'amperage', 'max_utilization', 'comments', 'tags', + ] + fieldsets = ( + ('Power Panel', ('region', 'site', 'power_panel')), + ('Power Feed', ('rack', 'name', 'status', 'type', 'mark_connected', 'tags')), + ('Characteristics', ('supply', 'voltage', 'amperage', 'phase', 'max_utilization')), + ) + widgets = { + 'status': StaticSelect(), + 'type': StaticSelect(), + 'supply': StaticSelect(), + 'phase': StaticSelect(), + } + + +# +# Virtual chassis +# + +class VirtualChassisForm(BootstrapMixin, CustomFieldModelForm): + master = forms.ModelChoiceField( + queryset=Device.objects.all(), + required=False, + ) + tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), + required=False + ) + + class Meta: + model = VirtualChassis + fields = [ + 'name', 'domain', 'master', 'tags', + ] + widgets = { + 'master': SelectWithPK(), + } + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + self.fields['master'].queryset = Device.objects.filter(virtual_chassis=self.instance) + + +class DeviceVCMembershipForm(forms.ModelForm): + + class Meta: + model = Device + fields = [ + 'vc_position', 'vc_priority', + ] + labels = { + 'vc_position': 'Position', + 'vc_priority': 'Priority', + } + + def __init__(self, validate_vc_position=False, *args, **kwargs): + super().__init__(*args, **kwargs) + + # Require VC position (only required when the Device is a VirtualChassis member) + self.fields['vc_position'].required = True + + # Add bootstrap classes to form elements. + self.fields['vc_position'].widget.attrs = {'class': 'form-control'} + self.fields['vc_priority'].widget.attrs = {'class': 'form-control'} + + # Validation of vc_position is optional. This is only required when adding a new member to an existing + # VirtualChassis. Otherwise, vc_position validation is handled by BaseVCMemberFormSet. + self.validate_vc_position = validate_vc_position + + def clean_vc_position(self): + vc_position = self.cleaned_data['vc_position'] + + if self.validate_vc_position: + conflicting_members = Device.objects.filter( + virtual_chassis=self.instance.virtual_chassis, + vc_position=vc_position + ) + if conflicting_members.exists(): + raise forms.ValidationError( + 'A virtual chassis member already exists in position {}.'.format(vc_position) + ) + + return vc_position + + +class VCMemberSelectForm(BootstrapMixin, forms.Form): + region = DynamicModelChoiceField( + queryset=Region.objects.all(), + required=False, + initial_params={ + 'sites': '$site' + } + ) + site_group = DynamicModelChoiceField( + queryset=SiteGroup.objects.all(), + required=False, + initial_params={ + 'sites': '$site' + } + ) + site = DynamicModelChoiceField( + queryset=Site.objects.all(), + required=False, + query_params={ + 'region_id': '$region', + 'group_id': '$site_group', + } + ) + rack = DynamicModelChoiceField( + queryset=Rack.objects.all(), + required=False, + null_option='None', + query_params={ + 'site_id': '$site' + } + ) + device = DynamicModelChoiceField( + queryset=Device.objects.all(), + query_params={ + 'site_id': '$site', + 'rack_id': '$rack', + 'virtual_chassis_id': 'null', + } + ) + + def clean_device(self): + device = self.cleaned_data['device'] + if device.virtual_chassis is not None: + raise forms.ValidationError( + f"Device {device} is already assigned to a virtual chassis." + ) + return device + + +# +# Device component templates +# + + +class ConsolePortTemplateForm(BootstrapMixin, forms.ModelForm): + + class Meta: + model = ConsolePortTemplate + fields = [ + 'device_type', 'name', 'label', 'type', 'description', + ] + widgets = { + 'device_type': forms.HiddenInput(), + } + + +class ConsoleServerPortTemplateForm(BootstrapMixin, forms.ModelForm): + + class Meta: + model = ConsoleServerPortTemplate + fields = [ + 'device_type', 'name', 'label', 'type', 'description', + ] + widgets = { + 'device_type': forms.HiddenInput(), + } + + +class PowerPortTemplateForm(BootstrapMixin, forms.ModelForm): + + class Meta: + model = PowerPortTemplate + fields = [ + 'device_type', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description', + ] + widgets = { + 'device_type': forms.HiddenInput(), + } + + +class PowerOutletTemplateForm(BootstrapMixin, forms.ModelForm): + + class Meta: + model = PowerOutletTemplate + fields = [ + 'device_type', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description', + ] + widgets = { + 'device_type': forms.HiddenInput(), + } + + def __init__(self, *args, **kwargs): + + super().__init__(*args, **kwargs) + + # Limit power_port choices to current DeviceType + if hasattr(self.instance, 'device_type'): + self.fields['power_port'].queryset = PowerPortTemplate.objects.filter( + device_type=self.instance.device_type + ) + + +class InterfaceTemplateForm(BootstrapMixin, forms.ModelForm): + + class Meta: + model = InterfaceTemplate + fields = [ + 'device_type', 'name', 'label', 'type', 'mgmt_only', 'description', + ] + widgets = { + 'device_type': forms.HiddenInput(), + 'type': StaticSelect(), + } + + +class FrontPortTemplateForm(BootstrapMixin, forms.ModelForm): + + class Meta: + model = FrontPortTemplate + fields = [ + 'device_type', 'name', 'label', 'type', 'color', 'rear_port', 'rear_port_position', 'description', + ] + widgets = { + 'device_type': forms.HiddenInput(), + 'rear_port': StaticSelect(), + } + + def __init__(self, *args, **kwargs): + + super().__init__(*args, **kwargs) + + # Limit rear_port choices to current DeviceType + if hasattr(self.instance, 'device_type'): + self.fields['rear_port'].queryset = RearPortTemplate.objects.filter( + device_type=self.instance.device_type + ) + + +class RearPortTemplateForm(BootstrapMixin, forms.ModelForm): + + class Meta: + model = RearPortTemplate + fields = [ + 'device_type', 'name', 'label', 'type', 'color', 'positions', 'description', + ] + widgets = { + 'device_type': forms.HiddenInput(), + 'type': StaticSelect(), + } + + +class DeviceBayTemplateForm(BootstrapMixin, forms.ModelForm): + + class Meta: + model = DeviceBayTemplate + fields = [ + 'device_type', 'name', 'label', 'description', + ] + widgets = { + 'device_type': forms.HiddenInput(), + } + + +# +# Device components +# + +class ConsolePortForm(BootstrapMixin, CustomFieldModelForm): + tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), + required=False + ) + + class Meta: + model = ConsolePort + fields = [ + 'device', 'name', 'label', 'type', 'speed', 'mark_connected', 'description', 'tags', + ] + widgets = { + 'device': forms.HiddenInput(), + } + + +class ConsoleServerPortForm(BootstrapMixin, CustomFieldModelForm): + tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), + required=False + ) + + class Meta: + model = ConsoleServerPort + fields = [ + 'device', 'name', 'label', 'type', 'speed', 'mark_connected', 'description', 'tags', + ] + widgets = { + 'device': forms.HiddenInput(), + } + + +class PowerPortForm(BootstrapMixin, CustomFieldModelForm): + tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), + required=False + ) + + class Meta: + model = PowerPort + fields = [ + 'device', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'mark_connected', 'description', + 'tags', + ] + widgets = { + 'device': forms.HiddenInput(), + } + + +class PowerOutletForm(BootstrapMixin, CustomFieldModelForm): + power_port = forms.ModelChoiceField( + queryset=PowerPort.objects.all(), + required=False + ) + tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), + required=False + ) + + class Meta: + model = PowerOutlet + fields = [ + 'device', 'name', 'label', 'type', 'power_port', 'feed_leg', 'mark_connected', 'description', 'tags', + ] + widgets = { + 'device': forms.HiddenInput(), + } + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + # Limit power_port choices to the local device + if hasattr(self.instance, 'device'): + self.fields['power_port'].queryset = PowerPort.objects.filter( + device=self.instance.device + ) + + +class InterfaceForm(BootstrapMixin, InterfaceCommonForm, CustomFieldModelForm): + parent = DynamicModelChoiceField( + queryset=Interface.objects.all(), + required=False, + label='Parent interface' + ) + lag = DynamicModelChoiceField( + queryset=Interface.objects.all(), + required=False, + label='LAG interface', + query_params={ + 'type': 'lag', + } + ) + vlan_group = DynamicModelChoiceField( + queryset=VLANGroup.objects.all(), + required=False, + label='VLAN group' + ) + untagged_vlan = DynamicModelChoiceField( + queryset=VLAN.objects.all(), + required=False, + label='Untagged VLAN', + query_params={ + 'group_id': '$vlan_group', + } + ) + tagged_vlans = DynamicModelMultipleChoiceField( + queryset=VLAN.objects.all(), + required=False, + label='Tagged VLANs', + query_params={ + 'group_id': '$vlan_group', + } + ) + tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), + required=False + ) + + class Meta: + model = Interface + fields = [ + 'device', 'name', 'label', 'type', 'enabled', 'parent', 'lag', 'mac_address', 'mtu', 'mgmt_only', + 'mark_connected', 'description', 'mode', 'untagged_vlan', 'tagged_vlans', 'tags', + ] + widgets = { + 'device': forms.HiddenInput(), + 'type': StaticSelect(), + 'mode': StaticSelect(), + } + labels = { + 'mode': '802.1Q Mode', + } + help_texts = { + 'mode': INTERFACE_MODE_HELP_TEXT, + } + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + device = Device.objects.get(pk=self.data['device']) if self.is_bound else self.instance.device + + # Restrict parent/LAG interface assignment by device/VC + self.fields['parent'].widget.add_query_param('device_id', device.pk) + if device.virtual_chassis and device.virtual_chassis.master: + # Get available LAG interfaces by VirtualChassis master + self.fields['lag'].widget.add_query_param('device_id', device.virtual_chassis.master.pk) + else: + self.fields['lag'].widget.add_query_param('device_id', device.pk) + + # Limit VLAN choices by device + self.fields['untagged_vlan'].widget.add_query_param('available_on_device', device.pk) + self.fields['tagged_vlans'].widget.add_query_param('available_on_device', device.pk) + + +class FrontPortForm(BootstrapMixin, CustomFieldModelForm): + tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), + required=False + ) + + class Meta: + model = FrontPort + fields = [ + 'device', 'name', 'label', 'type', 'color', 'rear_port', 'rear_port_position', 'mark_connected', + 'description', 'tags', + ] + widgets = { + 'device': forms.HiddenInput(), + 'type': StaticSelect(), + 'rear_port': StaticSelect(), + } + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + # Limit RearPort choices to the local device + if hasattr(self.instance, 'device'): + self.fields['rear_port'].queryset = self.fields['rear_port'].queryset.filter( + device=self.instance.device + ) + + +class RearPortForm(BootstrapMixin, CustomFieldModelForm): + tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), + required=False + ) + + class Meta: + model = RearPort + fields = [ + 'device', 'name', 'label', 'type', 'color', 'positions', 'mark_connected', 'description', 'tags', + ] + widgets = { + 'device': forms.HiddenInput(), + 'type': StaticSelect(), + } + + +class DeviceBayForm(BootstrapMixin, CustomFieldModelForm): + tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), + required=False + ) + + class Meta: + model = DeviceBay + fields = [ + 'device', 'name', 'label', 'description', 'tags', + ] + widgets = { + 'device': forms.HiddenInput(), + } + + +class PopulateDeviceBayForm(BootstrapMixin, forms.Form): + installed_device = forms.ModelChoiceField( + queryset=Device.objects.all(), + label='Child Device', + help_text="Child devices must first be created and assigned to the site/rack of the parent device.", + widget=StaticSelect(), + ) + + def __init__(self, device_bay, *args, **kwargs): + + super().__init__(*args, **kwargs) + + self.fields['installed_device'].queryset = Device.objects.filter( + site=device_bay.device.site, + rack=device_bay.device.rack, + parent_bay__isnull=True, + device_type__u_height=0, + device_type__subdevice_role=SubdeviceRoleChoices.ROLE_CHILD + ).exclude(pk=device_bay.device.pk) + + +class InventoryItemForm(BootstrapMixin, CustomFieldModelForm): + device = DynamicModelChoiceField( + queryset=Device.objects.all() + ) + parent = DynamicModelChoiceField( + queryset=InventoryItem.objects.all(), + required=False, + query_params={ + 'device_id': '$device' + } + ) + manufacturer = DynamicModelChoiceField( + queryset=Manufacturer.objects.all(), + required=False + ) + tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), + required=False + ) + + class Meta: + model = InventoryItem + fields = [ + 'device', 'parent', 'name', 'label', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'description', + 'tags', + ] diff --git a/netbox/dcim/forms/object_create.py b/netbox/dcim/forms/object_create.py new file mode 100644 index 000000000..7577ad355 --- /dev/null +++ b/netbox/dcim/forms/object_create.py @@ -0,0 +1,614 @@ +from django import forms + +from dcim.choices import * +from dcim.constants import * +from dcim.models import * +from extras.forms import CustomFieldModelForm, CustomFieldsMixin +from extras.models import Tag +from ipam.models import VLAN +from utilities.forms import ( + add_blank_choice, BootstrapMixin, ColorField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, + ExpandableNameField, StaticSelect, +) +from .common import InterfaceCommonForm + +__all__ = ( + 'ConsolePortCreateForm', + 'ConsolePortTemplateCreateForm', + 'ConsoleServerPortCreateForm', + 'ConsoleServerPortTemplateCreateForm', + 'DeviceBayCreateForm', + 'DeviceBayTemplateCreateForm', + 'FrontPortCreateForm', + 'FrontPortTemplateCreateForm', + 'InterfaceCreateForm', + 'InterfaceTemplateCreateForm', + 'InventoryItemCreateForm', + 'PowerOutletCreateForm', + 'PowerOutletTemplateCreateForm', + 'PowerPortCreateForm', + 'PowerPortTemplateCreateForm', + 'RearPortCreateForm', + 'RearPortTemplateCreateForm', + 'VirtualChassisCreateForm', +) + + +class ComponentForm(forms.Form): + """ + Subclass this form when facilitating the creation of one or more device component or component templates based on + a name pattern. + """ + name_pattern = ExpandableNameField( + label='Name' + ) + label_pattern = ExpandableNameField( + label='Label', + required=False, + help_text='Alphanumeric ranges are supported. (Must match the number of names being created.)' + ) + + def clean(self): + super().clean() + + # Validate that the number of components being created from both the name_pattern and label_pattern are equal + if self.cleaned_data['label_pattern']: + name_pattern_count = len(self.cleaned_data['name_pattern']) + label_pattern_count = len(self.cleaned_data['label_pattern']) + if name_pattern_count != label_pattern_count: + raise forms.ValidationError({ + 'label_pattern': f'The provided name pattern will create {name_pattern_count} components, however ' + f'{label_pattern_count} labels will be generated. These counts must match.' + }, code='label_pattern_mismatch') + + +class VirtualChassisCreateForm(BootstrapMixin, CustomFieldModelForm): + region = DynamicModelChoiceField( + queryset=Region.objects.all(), + required=False, + initial_params={ + 'sites': '$site' + } + ) + site_group = DynamicModelChoiceField( + queryset=SiteGroup.objects.all(), + required=False, + initial_params={ + 'sites': '$site' + } + ) + site = DynamicModelChoiceField( + queryset=Site.objects.all(), + required=False, + query_params={ + 'region_id': '$region', + 'group_id': '$site_group', + } + ) + rack = DynamicModelChoiceField( + queryset=Rack.objects.all(), + required=False, + null_option='None', + query_params={ + 'site_id': '$site' + } + ) + members = DynamicModelMultipleChoiceField( + queryset=Device.objects.all(), + required=False, + query_params={ + 'site_id': '$site', + 'rack_id': '$rack', + } + ) + initial_position = forms.IntegerField( + initial=1, + required=False, + help_text='Position of the first member device. Increases by one for each additional member.' + ) + tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), + required=False + ) + + class Meta: + model = VirtualChassis + fields = [ + 'name', 'domain', 'region', 'site_group', 'site', 'rack', 'members', 'initial_position', 'tags', + ] + + def save(self, *args, **kwargs): + instance = super().save(*args, **kwargs) + + # Assign VC members + if instance.pk: + initial_position = self.cleaned_data.get('initial_position') or 1 + for i, member in enumerate(self.cleaned_data['members'], start=initial_position): + member.virtual_chassis = instance + member.vc_position = i + member.save() + + return instance + + +# +# Component templates +# + +class ComponentTemplateCreateForm(BootstrapMixin, ComponentForm): + """ + Base form for the creation of device component templates (subclassed from ComponentTemplateModel). + """ + manufacturer = DynamicModelChoiceField( + queryset=Manufacturer.objects.all(), + required=False, + initial_params={ + 'device_types': 'device_type' + } + ) + device_type = DynamicModelChoiceField( + queryset=DeviceType.objects.all(), + query_params={ + 'manufacturer_id': '$manufacturer' + } + ) + description = forms.CharField( + required=False + ) + + +class ConsolePortTemplateCreateForm(ComponentTemplateCreateForm): + type = forms.ChoiceField( + choices=add_blank_choice(ConsolePortTypeChoices), + widget=StaticSelect() + ) + field_order = ('manufacturer', 'device_type', 'name_pattern', 'label_pattern', 'type', 'description') + + +class ConsoleServerPortTemplateCreateForm(ComponentTemplateCreateForm): + type = forms.ChoiceField( + choices=add_blank_choice(ConsolePortTypeChoices), + widget=StaticSelect() + ) + field_order = ('manufacturer', 'device_type', 'name_pattern', 'label_pattern', 'type', 'description') + + +class PowerPortTemplateCreateForm(ComponentTemplateCreateForm): + type = forms.ChoiceField( + choices=add_blank_choice(PowerPortTypeChoices), + required=False + ) + maximum_draw = forms.IntegerField( + min_value=1, + required=False, + help_text="Maximum power draw (watts)" + ) + allocated_draw = forms.IntegerField( + min_value=1, + required=False, + help_text="Allocated power draw (watts)" + ) + field_order = ( + 'manufacturer', 'device_type', 'name_pattern', 'label_pattern', 'type', 'maximum_draw', 'allocated_draw', + 'description', + ) + + +class PowerOutletTemplateCreateForm(ComponentTemplateCreateForm): + type = forms.ChoiceField( + choices=add_blank_choice(PowerOutletTypeChoices), + required=False + ) + power_port = forms.ModelChoiceField( + queryset=PowerPortTemplate.objects.all(), + required=False + ) + feed_leg = forms.ChoiceField( + choices=add_blank_choice(PowerOutletFeedLegChoices), + required=False, + widget=StaticSelect() + ) + field_order = ( + 'manufacturer', 'device_type', 'name_pattern', 'label_pattern', 'type', 'power_port', 'feed_leg', + 'description', + ) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + # Limit power_port choices to current DeviceType + device_type = DeviceType.objects.get( + pk=self.initial.get('device_type') or self.data.get('device_type') + ) + self.fields['power_port'].queryset = PowerPortTemplate.objects.filter( + device_type=device_type + ) + + +class InterfaceTemplateCreateForm(ComponentTemplateCreateForm): + type = forms.ChoiceField( + choices=InterfaceTypeChoices, + widget=StaticSelect() + ) + mgmt_only = forms.BooleanField( + required=False, + label='Management only' + ) + field_order = ('manufacturer', 'device_type', 'name_pattern', 'label_pattern', 'type', 'mgmt_only', 'description') + + +class FrontPortTemplateCreateForm(ComponentTemplateCreateForm): + type = forms.ChoiceField( + choices=PortTypeChoices, + widget=StaticSelect() + ) + color = ColorField( + required=False + ) + rear_port_set = forms.MultipleChoiceField( + choices=[], + label='Rear ports', + help_text='Select one rear port assignment for each front port being created.', + ) + field_order = ( + 'manufacturer', 'device_type', 'name_pattern', 'label_pattern', 'type', 'color', 'rear_port_set', 'description', + ) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + device_type = DeviceType.objects.get( + pk=self.initial.get('device_type') or self.data.get('device_type') + ) + + # Determine which rear port positions are occupied. These will be excluded from the list of available mappings. + occupied_port_positions = [ + (front_port.rear_port_id, front_port.rear_port_position) + for front_port in device_type.frontporttemplates.all() + ] + + # Populate rear port choices + choices = [] + rear_ports = RearPortTemplate.objects.filter(device_type=device_type) + for rear_port in rear_ports: + for i in range(1, rear_port.positions + 1): + if (rear_port.pk, i) not in occupied_port_positions: + choices.append( + ('{}:{}'.format(rear_port.pk, i), '{}:{}'.format(rear_port.name, i)) + ) + self.fields['rear_port_set'].choices = choices + + def clean(self): + super().clean() + + # Validate that the number of ports being created equals the number of selected (rear port, position) tuples + front_port_count = len(self.cleaned_data['name_pattern']) + rear_port_count = len(self.cleaned_data['rear_port_set']) + if front_port_count != rear_port_count: + raise forms.ValidationError({ + 'rear_port_set': 'The provided name pattern will create {} ports, however {} rear port assignments ' + 'were selected. These counts must match.'.format(front_port_count, rear_port_count) + }) + + def get_iterative_data(self, iteration): + + # Assign rear port and position from selected set + rear_port, position = self.cleaned_data['rear_port_set'][iteration].split(':') + + return { + 'rear_port': int(rear_port), + 'rear_port_position': int(position), + } + + +class RearPortTemplateCreateForm(ComponentTemplateCreateForm): + type = forms.ChoiceField( + choices=PortTypeChoices, + widget=StaticSelect(), + ) + color = ColorField( + required=False + ) + positions = forms.IntegerField( + min_value=REARPORT_POSITIONS_MIN, + max_value=REARPORT_POSITIONS_MAX, + initial=1, + help_text='The number of front ports which may be mapped to each rear port' + ) + field_order = ( + 'manufacturer', 'device_type', 'name_pattern', 'label_pattern', 'type', 'color', 'positions', 'description', + ) + + +class DeviceBayTemplateCreateForm(ComponentTemplateCreateForm): + field_order = ('manufacturer', 'device_type', 'name_pattern', 'label_pattern', 'description') + + +# +# Device components +# + +class ComponentCreateForm(BootstrapMixin, CustomFieldsMixin, ComponentForm): + """ + Base form for the creation of device components (models subclassed from ComponentModel). + """ + device = DynamicModelChoiceField( + queryset=Device.objects.all() + ) + description = forms.CharField( + max_length=200, + required=False + ) + tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), + required=False + ) + + +class ConsolePortCreateForm(ComponentCreateForm): + model = ConsolePort + type = forms.ChoiceField( + choices=add_blank_choice(ConsolePortTypeChoices), + required=False, + widget=StaticSelect() + ) + speed = forms.ChoiceField( + choices=add_blank_choice(ConsolePortSpeedChoices), + required=False, + widget=StaticSelect() + ) + field_order = ('device', 'name_pattern', 'label_pattern', 'type', 'speed', 'mark_connected', 'description', 'tags') + + +class ConsoleServerPortCreateForm(ComponentCreateForm): + model = ConsoleServerPort + type = forms.ChoiceField( + choices=add_blank_choice(ConsolePortTypeChoices), + required=False, + widget=StaticSelect() + ) + speed = forms.ChoiceField( + choices=add_blank_choice(ConsolePortSpeedChoices), + required=False, + widget=StaticSelect() + ) + field_order = ('device', 'name_pattern', 'label_pattern', 'type', 'speed', 'mark_connected', 'description', 'tags') + + +class PowerPortCreateForm(ComponentCreateForm): + model = PowerPort + type = forms.ChoiceField( + choices=add_blank_choice(PowerPortTypeChoices), + required=False, + widget=StaticSelect() + ) + maximum_draw = forms.IntegerField( + min_value=1, + required=False, + help_text="Maximum draw in watts" + ) + allocated_draw = forms.IntegerField( + min_value=1, + required=False, + help_text="Allocated draw in watts" + ) + field_order = ( + 'device', 'name_pattern', 'label_pattern', 'type', 'maximum_draw', 'allocated_draw', 'mark_connected', + 'description', 'tags', + ) + + +class PowerOutletCreateForm(ComponentCreateForm): + model = PowerOutlet + type = forms.ChoiceField( + choices=add_blank_choice(PowerOutletTypeChoices), + required=False, + widget=StaticSelect() + ) + power_port = forms.ModelChoiceField( + queryset=PowerPort.objects.all(), + required=False + ) + feed_leg = forms.ChoiceField( + choices=add_blank_choice(PowerOutletFeedLegChoices), + required=False + ) + field_order = ( + 'device', 'name_pattern', 'label_pattern', 'type', 'power_port', 'feed_leg', 'mark_connected', 'description', + 'tags', + ) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + # Limit power_port queryset to PowerPorts which belong to the parent Device + device = Device.objects.get( + pk=self.initial.get('device') or self.data.get('device') + ) + self.fields['power_port'].queryset = PowerPort.objects.filter(device=device) + + +class InterfaceCreateForm(ComponentCreateForm, InterfaceCommonForm): + model = Interface + type = forms.ChoiceField( + choices=InterfaceTypeChoices, + widget=StaticSelect(), + ) + enabled = forms.BooleanField( + required=False, + initial=True + ) + parent = DynamicModelChoiceField( + queryset=Interface.objects.all(), + required=False, + query_params={ + 'device_id': '$device', + } + ) + lag = DynamicModelChoiceField( + queryset=Interface.objects.all(), + required=False, + query_params={ + 'device_id': '$device', + 'type': 'lag', + } + ) + mac_address = forms.CharField( + required=False, + label='MAC Address' + ) + mgmt_only = forms.BooleanField( + required=False, + label='Management only', + help_text='This interface is used only for out-of-band management' + ) + mode = forms.ChoiceField( + choices=add_blank_choice(InterfaceModeChoices), + required=False, + widget=StaticSelect(), + ) + untagged_vlan = DynamicModelChoiceField( + queryset=VLAN.objects.all(), + required=False + ) + tagged_vlans = DynamicModelMultipleChoiceField( + queryset=VLAN.objects.all(), + required=False + ) + field_order = ( + 'device', 'name_pattern', 'label_pattern', 'type', 'enabled', 'parent', 'lag', 'mtu', 'mac_address', + 'description', 'mgmt_only', 'mark_connected', 'mode', 'untagged_vlan', 'tagged_vlans', 'tags' + ) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + # Limit VLAN choices by device + device_id = self.initial.get('device') or self.data.get('device') + self.fields['untagged_vlan'].widget.add_query_param('available_on_device', device_id) + self.fields['tagged_vlans'].widget.add_query_param('available_on_device', device_id) + + +class FrontPortCreateForm(ComponentCreateForm): + model = FrontPort + type = forms.ChoiceField( + choices=PortTypeChoices, + widget=StaticSelect(), + ) + color = ColorField( + required=False + ) + rear_port_set = forms.MultipleChoiceField( + choices=[], + label='Rear ports', + help_text='Select one rear port assignment for each front port being created.', + ) + field_order = ( + 'device', 'name_pattern', 'label_pattern', 'type', 'color', 'rear_port_set', 'mark_connected', 'description', + 'tags', + ) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + device = Device.objects.get( + pk=self.initial.get('device') or self.data.get('device') + ) + + # Determine which rear port positions are occupied. These will be excluded from the list of available + # mappings. + occupied_port_positions = [ + (front_port.rear_port_id, front_port.rear_port_position) + for front_port in device.frontports.all() + ] + + # Populate rear port choices + choices = [] + rear_ports = RearPort.objects.filter(device=device) + for rear_port in rear_ports: + for i in range(1, rear_port.positions + 1): + if (rear_port.pk, i) not in occupied_port_positions: + choices.append( + ('{}:{}'.format(rear_port.pk, i), '{}:{}'.format(rear_port.name, i)) + ) + self.fields['rear_port_set'].choices = choices + + def clean(self): + super().clean() + + # Validate that the number of ports being created equals the number of selected (rear port, position) tuples + front_port_count = len(self.cleaned_data['name_pattern']) + rear_port_count = len(self.cleaned_data['rear_port_set']) + if front_port_count != rear_port_count: + raise forms.ValidationError({ + 'rear_port_set': 'The provided name pattern will create {} ports, however {} rear port assignments ' + 'were selected. These counts must match.'.format(front_port_count, rear_port_count) + }) + + def get_iterative_data(self, iteration): + + # Assign rear port and position from selected set + rear_port, position = self.cleaned_data['rear_port_set'][iteration].split(':') + + return { + 'rear_port': int(rear_port), + 'rear_port_position': int(position), + } + + +class RearPortCreateForm(ComponentCreateForm): + model = RearPort + type = forms.ChoiceField( + choices=PortTypeChoices, + widget=StaticSelect(), + ) + color = ColorField( + required=False + ) + positions = forms.IntegerField( + min_value=REARPORT_POSITIONS_MIN, + max_value=REARPORT_POSITIONS_MAX, + initial=1, + help_text='The number of front ports which may be mapped to each rear port' + ) + field_order = ( + 'device', 'name_pattern', 'label_pattern', 'type', 'color', 'positions', 'mark_connected', 'description', + 'tags', + ) + + +class DeviceBayCreateForm(ComponentCreateForm): + model = DeviceBay + field_order = ('device', 'name_pattern', 'label_pattern', 'description', 'tags') + + +class InventoryItemCreateForm(ComponentCreateForm): + model = InventoryItem + manufacturer = DynamicModelChoiceField( + queryset=Manufacturer.objects.all(), + required=False + ) + parent = DynamicModelChoiceField( + queryset=InventoryItem.objects.all(), + required=False, + query_params={ + 'device_id': '$device' + } + ) + part_id = forms.CharField( + max_length=50, + required=False, + label='Part ID' + ) + serial = forms.CharField( + max_length=50, + required=False, + ) + asset_tag = forms.CharField( + max_length=50, + required=False, + ) + field_order = ( + 'device', 'parent', 'name_pattern', 'label_pattern', 'manufacturer', 'part_id', 'serial', 'asset_tag', + 'description', 'tags', + ) diff --git a/netbox/dcim/forms/object_import.py b/netbox/dcim/forms/object_import.py new file mode 100644 index 000000000..0596261a6 --- /dev/null +++ b/netbox/dcim/forms/object_import.py @@ -0,0 +1,148 @@ +from django import forms + +from dcim.choices import InterfaceTypeChoices, PortTypeChoices +from dcim.models import * +from utilities.forms import BootstrapMixin + +__all__ = ( + 'ConsolePortTemplateImportForm', + 'ConsoleServerPortTemplateImportForm', + 'DeviceBayTemplateImportForm', + 'DeviceTypeImportForm', + 'FrontPortTemplateImportForm', + 'InterfaceTemplateImportForm', + 'PowerOutletTemplateImportForm', + 'PowerPortTemplateImportForm', + 'RearPortTemplateImportForm', +) + + +class DeviceTypeImportForm(BootstrapMixin, forms.ModelForm): + manufacturer = forms.ModelChoiceField( + queryset=Manufacturer.objects.all(), + to_field_name='name' + ) + + class Meta: + model = DeviceType + fields = [ + 'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'subdevice_role', + 'comments', + ] + + +# +# Component template import forms +# + +class ComponentTemplateImportForm(BootstrapMixin, forms.ModelForm): + + def __init__(self, device_type, data=None, *args, **kwargs): + + # Must pass the parent DeviceType on form initialization + data.update({ + 'device_type': device_type.pk, + }) + + super().__init__(data, *args, **kwargs) + + def clean_device_type(self): + + data = self.cleaned_data['device_type'] + + # Limit fields referencing other components to the parent DeviceType + for field_name, field in self.fields.items(): + if isinstance(field, forms.ModelChoiceField) and field_name != 'device_type': + field.queryset = field.queryset.filter(device_type=data) + + return data + + +class ConsolePortTemplateImportForm(ComponentTemplateImportForm): + + class Meta: + model = ConsolePortTemplate + fields = [ + 'device_type', 'name', 'label', 'type', 'description', + ] + + +class ConsoleServerPortTemplateImportForm(ComponentTemplateImportForm): + + class Meta: + model = ConsoleServerPortTemplate + fields = [ + 'device_type', 'name', 'label', 'type', 'description', + ] + + +class PowerPortTemplateImportForm(ComponentTemplateImportForm): + + class Meta: + model = PowerPortTemplate + fields = [ + 'device_type', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description', + ] + + +class PowerOutletTemplateImportForm(ComponentTemplateImportForm): + power_port = forms.ModelChoiceField( + queryset=PowerPortTemplate.objects.all(), + to_field_name='name', + required=False + ) + + class Meta: + model = PowerOutletTemplate + fields = [ + 'device_type', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description', + ] + + +class InterfaceTemplateImportForm(ComponentTemplateImportForm): + type = forms.ChoiceField( + choices=InterfaceTypeChoices.CHOICES + ) + + class Meta: + model = InterfaceTemplate + fields = [ + 'device_type', 'name', 'label', 'type', 'mgmt_only', 'description', + ] + + +class FrontPortTemplateImportForm(ComponentTemplateImportForm): + type = forms.ChoiceField( + choices=PortTypeChoices.CHOICES + ) + rear_port = forms.ModelChoiceField( + queryset=RearPortTemplate.objects.all(), + to_field_name='name' + ) + + class Meta: + model = FrontPortTemplate + fields = [ + 'device_type', 'name', 'type', 'rear_port', 'rear_port_position', 'label', 'description', + ] + + +class RearPortTemplateImportForm(ComponentTemplateImportForm): + type = forms.ChoiceField( + choices=PortTypeChoices.CHOICES + ) + + class Meta: + model = RearPortTemplate + fields = [ + 'device_type', 'name', 'type', 'positions', 'label', 'description', + ] + + +class DeviceBayTemplateImportForm(ComponentTemplateImportForm): + + class Meta: + model = DeviceBayTemplate + fields = [ + 'device_type', 'name', 'label', 'description', + ] diff --git a/netbox/dcim/tests/test_forms.py b/netbox/dcim/tests/test_forms.py index 6eeffbc96..3b2a9eff0 100644 --- a/netbox/dcim/tests/test_forms.py +++ b/netbox/dcim/tests/test_forms.py @@ -1,5 +1,6 @@ from django.test import TestCase +from dcim.choices import DeviceFaceChoices, DeviceStatusChoices, InterfaceTypeChoices from dcim.forms import * from dcim.models import * from virtualization.models import Cluster, ClusterGroup, ClusterType diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index acdbfba65..a82d7dadf 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -1,11 +1,10 @@ -import logging from collections import OrderedDict from django.contrib import messages from django.contrib.contenttypes.models import ContentType from django.core.paginator import EmptyPage, PageNotAnInteger from django.db import transaction -from django.db.models import F, Prefetch +from django.db.models import Prefetch from django.forms import ModelMultipleChoiceField, MultipleHiddenInput, modelformset_factory from django.shortcuts import get_object_or_404, redirect, render from django.urls import reverse diff --git a/netbox/virtualization/forms.py b/netbox/virtualization/forms.py index bf5dec00c..74bf32e54 100644 --- a/netbox/virtualization/forms.py +++ b/netbox/virtualization/forms.py @@ -5,7 +5,8 @@ from django.utils.translation import gettext as _ from dcim.choices import InterfaceModeChoices from dcim.constants import INTERFACE_MTU_MAX, INTERFACE_MTU_MIN -from dcim.forms import InterfaceCommonForm, INTERFACE_MODE_HELP_TEXT +from dcim.forms.models import INTERFACE_MODE_HELP_TEXT +from dcim.forms.common import InterfaceCommonForm from dcim.models import Device, DeviceRole, Platform, Rack, Region, Site, SiteGroup from extras.forms import ( AddRemoveTagsForm, CustomFieldModelBulkEditForm, CustomFieldModelCSVForm, CustomFieldModelForm, @@ -569,7 +570,12 @@ class VirtualMachineBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldM ] -class VirtualMachineFilterForm(BootstrapMixin, LocalConfigContextFilterForm, TenancyFilterForm, CustomFieldModelFilterForm): +class VirtualMachineFilterForm( + BootstrapMixin, + LocalConfigContextFilterForm, + TenancyFilterForm, + CustomFieldModelFilterForm +): model = VirtualMachine field_groups = [ ['q', 'tag'], From dba9602c75f98a6ee1f18d6ef0129491f31f7059 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Mon, 27 Sep 2021 17:19:05 -0400 Subject: [PATCH 21/37] Refactor tenancy forms --- netbox/tenancy/forms.py | 196 ---------------------------- netbox/tenancy/forms/__init__.py | 5 + netbox/tenancy/forms/bulk_edit.py | 44 +++++++ netbox/tenancy/forms/bulk_import.py | 36 +++++ netbox/tenancy/forms/filtersets.py | 42 ++++++ netbox/tenancy/forms/forms.py | 48 +++++++ netbox/tenancy/forms/models.py | 47 +++++++ 7 files changed, 222 insertions(+), 196 deletions(-) delete mode 100644 netbox/tenancy/forms.py create mode 100644 netbox/tenancy/forms/__init__.py create mode 100644 netbox/tenancy/forms/bulk_edit.py create mode 100644 netbox/tenancy/forms/bulk_import.py create mode 100644 netbox/tenancy/forms/filtersets.py create mode 100644 netbox/tenancy/forms/forms.py create mode 100644 netbox/tenancy/forms/models.py diff --git a/netbox/tenancy/forms.py b/netbox/tenancy/forms.py deleted file mode 100644 index 63dcdd468..000000000 --- a/netbox/tenancy/forms.py +++ /dev/null @@ -1,196 +0,0 @@ -from django import forms -from django.utils.translation import gettext as _ - -from extras.forms import ( - AddRemoveTagsForm, CustomFieldModelForm, CustomFieldModelBulkEditForm, CustomFieldModelFilterForm, CustomFieldModelCSVForm, -) -from extras.models import Tag -from utilities.forms import ( - BootstrapMixin, CommentField, CSVModelChoiceField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, - SlugField, TagFilterField, -) -from .models import Tenant, TenantGroup - - -# -# Tenant groups -# - -class TenantGroupForm(BootstrapMixin, CustomFieldModelForm): - parent = DynamicModelChoiceField( - queryset=TenantGroup.objects.all(), - required=False - ) - slug = SlugField() - - class Meta: - model = TenantGroup - fields = [ - 'parent', 'name', 'slug', 'description', - ] - - -class TenantGroupCSVForm(CustomFieldModelCSVForm): - parent = CSVModelChoiceField( - queryset=TenantGroup.objects.all(), - required=False, - to_field_name='name', - help_text='Parent group' - ) - slug = SlugField() - - class Meta: - model = TenantGroup - fields = ('name', 'slug', 'parent', 'description') - - -class TenantGroupBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm): - pk = forms.ModelMultipleChoiceField( - queryset=TenantGroup.objects.all(), - widget=forms.MultipleHiddenInput - ) - parent = DynamicModelChoiceField( - queryset=TenantGroup.objects.all(), - required=False - ) - description = forms.CharField( - max_length=200, - required=False - ) - - class Meta: - nullable_fields = ['parent', 'description'] - - -class TenantGroupFilterForm(BootstrapMixin, CustomFieldModelFilterForm): - model = TenantGroup - q = forms.CharField( - required=False, - widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), - label=_('Search') - ) - parent_id = DynamicModelMultipleChoiceField( - queryset=TenantGroup.objects.all(), - required=False, - label=_('Parent group'), - fetch_trigger='open' - ) - - -# -# Tenants -# - -class TenantForm(BootstrapMixin, CustomFieldModelForm): - slug = SlugField() - group = DynamicModelChoiceField( - queryset=TenantGroup.objects.all(), - required=False - ) - comments = CommentField() - tags = DynamicModelMultipleChoiceField( - queryset=Tag.objects.all(), - required=False - ) - - class Meta: - model = Tenant - fields = ( - 'name', 'slug', 'group', 'description', 'comments', 'tags', - ) - fieldsets = ( - ('Tenant', ('name', 'slug', 'group', 'description', 'tags')), - ) - - -class TenantCSVForm(CustomFieldModelCSVForm): - slug = SlugField() - group = CSVModelChoiceField( - queryset=TenantGroup.objects.all(), - required=False, - to_field_name='name', - help_text='Assigned group' - ) - - class Meta: - model = Tenant - fields = ('name', 'slug', 'group', 'description', 'comments') - - -class TenantBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm): - pk = forms.ModelMultipleChoiceField( - queryset=Tenant.objects.all(), - widget=forms.MultipleHiddenInput() - ) - group = DynamicModelChoiceField( - queryset=TenantGroup.objects.all(), - required=False - ) - - class Meta: - nullable_fields = [ - 'group', - ] - - -class TenantFilterForm(BootstrapMixin, CustomFieldModelFilterForm): - model = Tenant - field_groups = ( - ('q', 'tag'), - ('group_id',), - ) - q = forms.CharField( - required=False, - widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), - label=_('Search') - ) - group_id = DynamicModelMultipleChoiceField( - queryset=TenantGroup.objects.all(), - required=False, - null_option='None', - label=_('Group'), - fetch_trigger='open' - ) - tag = TagFilterField(model) - - -# -# Form extensions -# - -class TenancyForm(forms.Form): - tenant_group = DynamicModelChoiceField( - queryset=TenantGroup.objects.all(), - required=False, - null_option='None', - initial_params={ - 'tenants': '$tenant' - } - ) - tenant = DynamicModelChoiceField( - queryset=Tenant.objects.all(), - required=False, - query_params={ - 'group_id': '$tenant_group' - } - ) - - -class TenancyFilterForm(forms.Form): - tenant_group_id = DynamicModelMultipleChoiceField( - queryset=TenantGroup.objects.all(), - required=False, - null_option='None', - label=_('Tenant group'), - fetch_trigger='open' - ) - tenant_id = DynamicModelMultipleChoiceField( - queryset=Tenant.objects.all(), - required=False, - null_option='None', - query_params={ - 'group_id': '$tenant_group_id' - }, - label=_('Tenant'), - fetch_trigger='open' - ) diff --git a/netbox/tenancy/forms/__init__.py b/netbox/tenancy/forms/__init__.py new file mode 100644 index 000000000..61f0bc961 --- /dev/null +++ b/netbox/tenancy/forms/__init__.py @@ -0,0 +1,5 @@ +from .forms import * +from .models import * +from .filtersets import * +from .bulk_edit import * +from .bulk_import import * diff --git a/netbox/tenancy/forms/bulk_edit.py b/netbox/tenancy/forms/bulk_edit.py new file mode 100644 index 000000000..b2fc7dafd --- /dev/null +++ b/netbox/tenancy/forms/bulk_edit.py @@ -0,0 +1,44 @@ +from django import forms + +from extras.forms import AddRemoveTagsForm, CustomFieldModelBulkEditForm +from tenancy.models import Tenant, TenantGroup +from utilities.forms import BootstrapMixin, DynamicModelChoiceField + +__all__ = ( + 'TenantBulkEditForm', + 'TenantGroupBulkEditForm', +) + + +class TenantGroupBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm): + pk = forms.ModelMultipleChoiceField( + queryset=TenantGroup.objects.all(), + widget=forms.MultipleHiddenInput + ) + parent = DynamicModelChoiceField( + queryset=TenantGroup.objects.all(), + required=False + ) + description = forms.CharField( + max_length=200, + required=False + ) + + class Meta: + nullable_fields = ['parent', 'description'] + + +class TenantBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm): + pk = forms.ModelMultipleChoiceField( + queryset=Tenant.objects.all(), + widget=forms.MultipleHiddenInput() + ) + group = DynamicModelChoiceField( + queryset=TenantGroup.objects.all(), + required=False + ) + + class Meta: + nullable_fields = [ + 'group', + ] diff --git a/netbox/tenancy/forms/bulk_import.py b/netbox/tenancy/forms/bulk_import.py new file mode 100644 index 000000000..335d71ef6 --- /dev/null +++ b/netbox/tenancy/forms/bulk_import.py @@ -0,0 +1,36 @@ +from extras.forms import CustomFieldModelCSVForm +from tenancy.models import Tenant, TenantGroup +from utilities.forms import CSVModelChoiceField, SlugField + +__all__ = ( + 'TenantCSVForm', + 'TenantGroupCSVForm', +) + + +class TenantGroupCSVForm(CustomFieldModelCSVForm): + parent = CSVModelChoiceField( + queryset=TenantGroup.objects.all(), + required=False, + to_field_name='name', + help_text='Parent group' + ) + slug = SlugField() + + class Meta: + model = TenantGroup + fields = ('name', 'slug', 'parent', 'description') + + +class TenantCSVForm(CustomFieldModelCSVForm): + slug = SlugField() + group = CSVModelChoiceField( + queryset=TenantGroup.objects.all(), + required=False, + to_field_name='name', + help_text='Assigned group' + ) + + class Meta: + model = Tenant + fields = ('name', 'slug', 'group', 'description', 'comments') diff --git a/netbox/tenancy/forms/filtersets.py b/netbox/tenancy/forms/filtersets.py new file mode 100644 index 000000000..6e2eb7fd1 --- /dev/null +++ b/netbox/tenancy/forms/filtersets.py @@ -0,0 +1,42 @@ +from django import forms +from django.utils.translation import gettext as _ + +from extras.forms import CustomFieldModelFilterForm +from tenancy.models import Tenant, TenantGroup +from utilities.forms import BootstrapMixin, DynamicModelMultipleChoiceField, TagFilterField + + +class TenantGroupFilterForm(BootstrapMixin, CustomFieldModelFilterForm): + model = TenantGroup + q = forms.CharField( + required=False, + widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), + label=_('Search') + ) + parent_id = DynamicModelMultipleChoiceField( + queryset=TenantGroup.objects.all(), + required=False, + label=_('Parent group'), + fetch_trigger='open' + ) + + +class TenantFilterForm(BootstrapMixin, CustomFieldModelFilterForm): + model = Tenant + field_groups = ( + ('q', 'tag'), + ('group_id',), + ) + q = forms.CharField( + required=False, + widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), + label=_('Search') + ) + group_id = DynamicModelMultipleChoiceField( + queryset=TenantGroup.objects.all(), + required=False, + null_option='None', + label=_('Group'), + fetch_trigger='open' + ) + tag = TagFilterField(model) diff --git a/netbox/tenancy/forms/forms.py b/netbox/tenancy/forms/forms.py new file mode 100644 index 000000000..cad63c1a6 --- /dev/null +++ b/netbox/tenancy/forms/forms.py @@ -0,0 +1,48 @@ +from django import forms +from django.utils.translation import gettext as _ + +from tenancy.models import Tenant, TenantGroup +from utilities.forms import DynamicModelChoiceField, DynamicModelMultipleChoiceField + +__all__ = ( + 'TenancyForm', + 'TenancyFilterForm', +) + + +class TenancyForm(forms.Form): + tenant_group = DynamicModelChoiceField( + queryset=TenantGroup.objects.all(), + required=False, + null_option='None', + initial_params={ + 'tenants': '$tenant' + } + ) + tenant = DynamicModelChoiceField( + queryset=Tenant.objects.all(), + required=False, + query_params={ + 'group_id': '$tenant_group' + } + ) + + +class TenancyFilterForm(forms.Form): + tenant_group_id = DynamicModelMultipleChoiceField( + queryset=TenantGroup.objects.all(), + required=False, + null_option='None', + label=_('Tenant group'), + fetch_trigger='open' + ) + tenant_id = DynamicModelMultipleChoiceField( + queryset=Tenant.objects.all(), + required=False, + null_option='None', + query_params={ + 'group_id': '$tenant_group_id' + }, + label=_('Tenant'), + fetch_trigger='open' + ) diff --git a/netbox/tenancy/forms/models.py b/netbox/tenancy/forms/models.py new file mode 100644 index 000000000..de3a9e515 --- /dev/null +++ b/netbox/tenancy/forms/models.py @@ -0,0 +1,47 @@ +from extras.forms import CustomFieldModelForm +from extras.models import Tag +from tenancy.models import Tenant, TenantGroup +from utilities.forms import ( + BootstrapMixin, CommentField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, SlugField, +) + +__all__ = ( + 'TenantForm', + 'TenantGroupForm', +) + + +class TenantGroupForm(BootstrapMixin, CustomFieldModelForm): + parent = DynamicModelChoiceField( + queryset=TenantGroup.objects.all(), + required=False + ) + slug = SlugField() + + class Meta: + model = TenantGroup + fields = [ + 'parent', 'name', 'slug', 'description', + ] + + +class TenantForm(BootstrapMixin, CustomFieldModelForm): + slug = SlugField() + group = DynamicModelChoiceField( + queryset=TenantGroup.objects.all(), + required=False + ) + comments = CommentField() + tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), + required=False + ) + + class Meta: + model = Tenant + fields = ( + 'name', 'slug', 'group', 'description', 'comments', 'tags', + ) + fieldsets = ( + ('Tenant', ('name', 'slug', 'group', 'description', 'tags')), + ) From 8e849566d5cf3f7b321c5653b45d05b4c5d0c800 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 28 Sep 2021 09:25:12 -0400 Subject: [PATCH 22/37] Refactored IPAM forms --- netbox/ipam/forms.py | 1881 ------------------------------ netbox/ipam/forms/__init__.py | 5 + netbox/ipam/forms/bulk_create.py | 13 + netbox/ipam/forms/bulk_edit.py | 378 ++++++ netbox/ipam/forms/bulk_import.py | 362 ++++++ netbox/ipam/forms/filtersets.py | 486 ++++++++ netbox/ipam/forms/models.py | 691 +++++++++++ 7 files changed, 1935 insertions(+), 1881 deletions(-) delete mode 100644 netbox/ipam/forms.py create mode 100644 netbox/ipam/forms/__init__.py create mode 100644 netbox/ipam/forms/bulk_create.py create mode 100644 netbox/ipam/forms/bulk_edit.py create mode 100644 netbox/ipam/forms/bulk_import.py create mode 100644 netbox/ipam/forms/filtersets.py create mode 100644 netbox/ipam/forms/models.py diff --git a/netbox/ipam/forms.py b/netbox/ipam/forms.py deleted file mode 100644 index c72884b3c..000000000 --- a/netbox/ipam/forms.py +++ /dev/null @@ -1,1881 +0,0 @@ -from django import forms -from django.contrib.contenttypes.models import ContentType -from django.utils.translation import gettext as _ - -from dcim.models import Device, Interface, Location, Rack, Region, Site, SiteGroup -from extras.forms import ( - AddRemoveTagsForm, CustomFieldModelBulkEditForm, CustomFieldModelCSVForm, CustomFieldModelForm, - CustomFieldModelFilterForm, -) -from extras.models import Tag -from tenancy.forms import TenancyFilterForm, TenancyForm -from tenancy.models import Tenant -from utilities.forms import ( - add_blank_choice, BootstrapMixin, BulkEditNullBooleanSelect, ContentTypeChoiceField, CSVChoiceField, - CSVContentTypeField, CSVModelChoiceField, DatePicker, DynamicModelChoiceField, DynamicModelMultipleChoiceField, - ExpandableIPAddressField, NumericArrayField, SlugField, StaticSelect, StaticSelectMultiple, TagFilterField, - BOOLEAN_WITH_BLANK_CHOICES, -) -from virtualization.models import Cluster, ClusterGroup, VirtualMachine, VMInterface -from .choices import * -from .constants import * -from .models import * - -PREFIX_MASK_LENGTH_CHOICES = add_blank_choice([ - (i, i) for i in range(PREFIX_LENGTH_MIN, PREFIX_LENGTH_MAX + 1) -]) - -IPADDRESS_MASK_LENGTH_CHOICES = add_blank_choice([ - (i, i) for i in range(IPADDRESS_MASK_LENGTH_MIN, IPADDRESS_MASK_LENGTH_MAX + 1) -]) - - -# -# VRFs -# - -class VRFForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): - import_targets = DynamicModelMultipleChoiceField( - queryset=RouteTarget.objects.all(), - required=False - ) - export_targets = DynamicModelMultipleChoiceField( - queryset=RouteTarget.objects.all(), - required=False - ) - tags = DynamicModelMultipleChoiceField( - queryset=Tag.objects.all(), - required=False - ) - - class Meta: - model = VRF - fields = [ - 'name', 'rd', 'enforce_unique', 'description', 'import_targets', 'export_targets', 'tenant_group', 'tenant', - 'tags', - ] - fieldsets = ( - ('VRF', ('name', 'rd', 'enforce_unique', 'description', 'tags')), - ('Route Targets', ('import_targets', 'export_targets')), - ('Tenancy', ('tenant_group', 'tenant')), - ) - labels = { - 'rd': "RD", - } - help_texts = { - 'rd': "Route distinguisher in any format", - } - - -class VRFCSVForm(CustomFieldModelCSVForm): - tenant = CSVModelChoiceField( - queryset=Tenant.objects.all(), - required=False, - to_field_name='name', - help_text='Assigned tenant' - ) - - class Meta: - model = VRF - fields = ('name', 'rd', 'tenant', 'enforce_unique', 'description') - - -class VRFBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm): - pk = forms.ModelMultipleChoiceField( - queryset=VRF.objects.all(), - widget=forms.MultipleHiddenInput() - ) - tenant = DynamicModelChoiceField( - queryset=Tenant.objects.all(), - required=False - ) - enforce_unique = forms.NullBooleanField( - required=False, - widget=BulkEditNullBooleanSelect(), - label='Enforce unique space' - ) - description = forms.CharField( - max_length=100, - required=False - ) - - class Meta: - nullable_fields = [ - 'tenant', 'description', - ] - - -class VRFFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilterForm): - model = VRF - field_groups = [ - ['q', 'tag'], - ['import_target_id', 'export_target_id'], - ['tenant_group_id', 'tenant_id'], - ] - q = forms.CharField( - required=False, - widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), - label=_('Search') - ) - import_target_id = DynamicModelMultipleChoiceField( - queryset=RouteTarget.objects.all(), - required=False, - label=_('Import targets'), - fetch_trigger='open' - ) - export_target_id = DynamicModelMultipleChoiceField( - queryset=RouteTarget.objects.all(), - required=False, - label=_('Export targets'), - fetch_trigger='open' - ) - tag = TagFilterField(model) - - -# -# Route targets -# - -class RouteTargetForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): - tags = DynamicModelMultipleChoiceField( - queryset=Tag.objects.all(), - required=False - ) - - class Meta: - model = RouteTarget - fields = [ - 'name', 'description', 'tenant_group', 'tenant', 'tags', - ] - fieldsets = ( - ('Route Target', ('name', 'description', 'tags')), - ('Tenancy', ('tenant_group', 'tenant')), - ) - - -class RouteTargetCSVForm(CustomFieldModelCSVForm): - tenant = CSVModelChoiceField( - queryset=Tenant.objects.all(), - required=False, - to_field_name='name', - help_text='Assigned tenant' - ) - - class Meta: - model = RouteTarget - fields = ('name', 'description', 'tenant') - - -class RouteTargetBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm): - pk = forms.ModelMultipleChoiceField( - queryset=RouteTarget.objects.all(), - widget=forms.MultipleHiddenInput() - ) - tenant = DynamicModelChoiceField( - queryset=Tenant.objects.all(), - required=False - ) - description = forms.CharField( - max_length=200, - required=False - ) - - class Meta: - nullable_fields = [ - 'tenant', 'description', - ] - - -class RouteTargetFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilterForm): - model = RouteTarget - field_groups = [ - ['q', 'tag'], - ['importing_vrf_id', 'exporting_vrf_id'], - ['tenant_group_id', 'tenant_id'], - ] - q = forms.CharField( - required=False, - widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), - label=_('Search') - ) - importing_vrf_id = DynamicModelMultipleChoiceField( - queryset=VRF.objects.all(), - required=False, - label=_('Imported by VRF'), - fetch_trigger='open' - ) - exporting_vrf_id = DynamicModelMultipleChoiceField( - queryset=VRF.objects.all(), - required=False, - label=_('Exported by VRF'), - fetch_trigger='open' - ) - tag = TagFilterField(model) - - -# -# RIRs -# - -class RIRForm(BootstrapMixin, CustomFieldModelForm): - slug = SlugField() - - class Meta: - model = RIR - fields = [ - 'name', 'slug', 'is_private', 'description', - ] - - -class RIRCSVForm(CustomFieldModelCSVForm): - slug = SlugField() - - class Meta: - model = RIR - fields = ('name', 'slug', 'is_private', 'description') - help_texts = { - 'name': 'RIR name', - } - - -class RIRBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm): - pk = forms.ModelMultipleChoiceField( - queryset=RIR.objects.all(), - widget=forms.MultipleHiddenInput - ) - is_private = forms.NullBooleanField( - required=False, - widget=BulkEditNullBooleanSelect - ) - description = forms.CharField( - max_length=200, - required=False - ) - - class Meta: - nullable_fields = ['is_private', 'description'] - - -class RIRFilterForm(BootstrapMixin, CustomFieldModelFilterForm): - model = RIR - field_groups = [ - ['q'], - ['is_private'], - ] - q = forms.CharField( - required=False, - widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), - label=_('Search') - ) - is_private = forms.NullBooleanField( - required=False, - label=_('Private'), - widget=StaticSelect( - choices=BOOLEAN_WITH_BLANK_CHOICES - ) - ) - - -# -# Aggregates -# - -class AggregateForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): - rir = DynamicModelChoiceField( - queryset=RIR.objects.all(), - label='RIR' - ) - tags = DynamicModelMultipleChoiceField( - queryset=Tag.objects.all(), - required=False - ) - - class Meta: - model = Aggregate - fields = [ - 'prefix', 'rir', 'date_added', 'description', 'tenant_group', 'tenant', 'tags', - ] - fieldsets = ( - ('Aggregate', ('prefix', 'rir', 'date_added', 'description', 'tags')), - ('Tenancy', ('tenant_group', 'tenant')), - ) - help_texts = { - 'prefix': "IPv4 or IPv6 network", - 'rir': "Regional Internet Registry responsible for this prefix", - } - widgets = { - 'date_added': DatePicker(), - } - - -class AggregateCSVForm(CustomFieldModelCSVForm): - rir = CSVModelChoiceField( - queryset=RIR.objects.all(), - to_field_name='name', - help_text='Assigned RIR' - ) - tenant = CSVModelChoiceField( - queryset=Tenant.objects.all(), - required=False, - to_field_name='name', - help_text='Assigned tenant' - ) - - class Meta: - model = Aggregate - fields = ('prefix', 'rir', 'tenant', 'date_added', 'description') - - -class AggregateBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm): - pk = forms.ModelMultipleChoiceField( - queryset=Aggregate.objects.all(), - widget=forms.MultipleHiddenInput() - ) - rir = DynamicModelChoiceField( - queryset=RIR.objects.all(), - required=False, - label='RIR' - ) - tenant = DynamicModelChoiceField( - queryset=Tenant.objects.all(), - required=False - ) - date_added = forms.DateField( - required=False - ) - description = forms.CharField( - max_length=100, - required=False - ) - - class Meta: - nullable_fields = [ - 'date_added', 'description', - ] - widgets = { - 'date_added': DatePicker(), - } - - -class AggregateFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilterForm): - model = Aggregate - field_groups = [ - ['q', 'tag'], - ['family', 'rir_id'], - ['tenant_group_id', 'tenant_id'] - ] - q = forms.CharField( - required=False, - widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), - label=_('Search') - ) - family = forms.ChoiceField( - required=False, - choices=add_blank_choice(IPAddressFamilyChoices), - label=_('Address family'), - widget=StaticSelect() - ) - rir_id = DynamicModelMultipleChoiceField( - queryset=RIR.objects.all(), - required=False, - label=_('RIR'), - fetch_trigger='open' - ) - tag = TagFilterField(model) - - -# -# Roles -# - -class RoleForm(BootstrapMixin, CustomFieldModelForm): - slug = SlugField() - - class Meta: - model = Role - fields = [ - 'name', 'slug', 'weight', 'description', - ] - - -class RoleCSVForm(CustomFieldModelCSVForm): - slug = SlugField() - - class Meta: - model = Role - fields = ('name', 'slug', 'weight', 'description') - - -class RoleBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm): - pk = forms.ModelMultipleChoiceField( - queryset=Role.objects.all(), - widget=forms.MultipleHiddenInput - ) - weight = forms.IntegerField( - required=False - ) - description = forms.CharField( - max_length=200, - required=False - ) - - class Meta: - nullable_fields = ['description'] - - -class RoleFilterForm(BootstrapMixin, CustomFieldModelFilterForm): - model = Role - field_groups = [ - ['q'], - ] - q = forms.CharField( - required=False, - widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), - label=_('Search') - ) - - -# -# Prefixes -# - -class PrefixForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): - vrf = DynamicModelChoiceField( - queryset=VRF.objects.all(), - required=False, - label='VRF' - ) - region = DynamicModelChoiceField( - queryset=Region.objects.all(), - required=False, - initial_params={ - 'sites': '$site' - } - ) - site_group = DynamicModelChoiceField( - queryset=SiteGroup.objects.all(), - required=False, - initial_params={ - 'sites': '$site' - } - ) - site = DynamicModelChoiceField( - queryset=Site.objects.all(), - required=False, - null_option='None', - query_params={ - 'region_id': '$region', - 'group_id': '$site_group', - } - ) - vlan_group = DynamicModelChoiceField( - queryset=VLANGroup.objects.all(), - required=False, - label='VLAN group', - null_option='None', - query_params={ - 'site_id': '$site' - }, - initial_params={ - 'vlans': '$vlan' - } - ) - vlan = DynamicModelChoiceField( - queryset=VLAN.objects.all(), - required=False, - label='VLAN', - query_params={ - 'site_id': '$site', - 'group_id': '$vlan_group', - } - ) - role = DynamicModelChoiceField( - queryset=Role.objects.all(), - required=False - ) - tags = DynamicModelMultipleChoiceField( - queryset=Tag.objects.all(), - required=False - ) - - class Meta: - model = Prefix - fields = [ - 'prefix', 'vrf', 'site', 'vlan', 'status', 'role', 'is_pool', 'mark_utilized', 'description', - 'tenant_group', 'tenant', 'tags', - ] - fieldsets = ( - ('Prefix', ('prefix', 'status', 'vrf', 'role', 'is_pool', 'mark_utilized', 'description', 'tags')), - ('Site/VLAN Assignment', ('region', 'site_group', 'site', 'vlan_group', 'vlan')), - ('Tenancy', ('tenant_group', 'tenant')), - ) - widgets = { - 'status': StaticSelect(), - } - - -class PrefixCSVForm(CustomFieldModelCSVForm): - vrf = CSVModelChoiceField( - queryset=VRF.objects.all(), - to_field_name='name', - required=False, - help_text='Assigned VRF' - ) - tenant = CSVModelChoiceField( - queryset=Tenant.objects.all(), - required=False, - to_field_name='name', - help_text='Assigned tenant' - ) - site = CSVModelChoiceField( - queryset=Site.objects.all(), - required=False, - to_field_name='name', - help_text='Assigned site' - ) - vlan_group = CSVModelChoiceField( - queryset=VLANGroup.objects.all(), - required=False, - to_field_name='name', - help_text="VLAN's group (if any)" - ) - vlan = CSVModelChoiceField( - queryset=VLAN.objects.all(), - required=False, - to_field_name='vid', - help_text="Assigned VLAN" - ) - status = CSVChoiceField( - choices=PrefixStatusChoices, - help_text='Operational status' - ) - role = CSVModelChoiceField( - queryset=Role.objects.all(), - required=False, - to_field_name='name', - help_text='Functional role' - ) - - class Meta: - model = Prefix - fields = ( - 'prefix', 'vrf', 'tenant', 'site', 'vlan_group', 'vlan', 'status', 'role', 'is_pool', 'mark_utilized', - 'description', - ) - - def __init__(self, data=None, *args, **kwargs): - super().__init__(data, *args, **kwargs) - - if data: - - # Limit VLAN queryset by assigned site and/or group (if specified) - params = {} - if data.get('site'): - params[f"site__{self.fields['site'].to_field_name}"] = data.get('site') - if data.get('vlan_group'): - params[f"group__{self.fields['vlan_group'].to_field_name}"] = data.get('vlan_group') - if params: - self.fields['vlan'].queryset = self.fields['vlan'].queryset.filter(**params) - - -class PrefixBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm): - pk = forms.ModelMultipleChoiceField( - queryset=Prefix.objects.all(), - widget=forms.MultipleHiddenInput() - ) - region = DynamicModelChoiceField( - queryset=Region.objects.all(), - required=False - ) - site_group = DynamicModelChoiceField( - queryset=SiteGroup.objects.all(), - required=False - ) - site = DynamicModelChoiceField( - queryset=Site.objects.all(), - required=False, - query_params={ - 'region_id': '$region', - 'group_id': '$site_group', - } - ) - vrf = DynamicModelChoiceField( - queryset=VRF.objects.all(), - required=False, - label='VRF' - ) - prefix_length = forms.IntegerField( - min_value=PREFIX_LENGTH_MIN, - max_value=PREFIX_LENGTH_MAX, - required=False - ) - tenant = DynamicModelChoiceField( - queryset=Tenant.objects.all(), - required=False - ) - status = forms.ChoiceField( - choices=add_blank_choice(PrefixStatusChoices), - required=False, - widget=StaticSelect() - ) - role = DynamicModelChoiceField( - queryset=Role.objects.all(), - required=False - ) - is_pool = forms.NullBooleanField( - required=False, - widget=BulkEditNullBooleanSelect(), - label='Is a pool' - ) - mark_utilized = forms.NullBooleanField( - required=False, - widget=BulkEditNullBooleanSelect(), - label='Treat as 100% utilized' - ) - description = forms.CharField( - max_length=100, - required=False - ) - - class Meta: - nullable_fields = [ - 'site', 'vrf', 'tenant', 'role', 'description', - ] - - -class PrefixFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilterForm): - model = Prefix - field_groups = [ - ['q', 'tag'], - ['within_include', 'family', 'status', 'role_id', 'mask_length', 'is_pool', 'mark_utilized'], - ['vrf_id', 'present_in_vrf_id'], - ['region_id', 'site_group_id', 'site_id'], - ['tenant_group_id', 'tenant_id'] - ] - q = forms.CharField( - required=False, - widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), - label=_('Search') - ) - mask_length__lte = forms.IntegerField( - widget=forms.HiddenInput() - ) - within_include = forms.CharField( - required=False, - widget=forms.TextInput( - attrs={ - 'placeholder': 'Prefix', - } - ), - label=_('Search within') - ) - family = forms.ChoiceField( - required=False, - choices=add_blank_choice(IPAddressFamilyChoices), - label=_('Address family'), - widget=StaticSelect() - ) - mask_length = forms.MultipleChoiceField( - required=False, - choices=PREFIX_MASK_LENGTH_CHOICES, - label=_('Mask length'), - widget=StaticSelectMultiple() - ) - vrf_id = DynamicModelMultipleChoiceField( - queryset=VRF.objects.all(), - required=False, - label=_('Assigned VRF'), - null_option='Global', - fetch_trigger='open' - ) - present_in_vrf_id = DynamicModelChoiceField( - queryset=VRF.objects.all(), - required=False, - label=_('Present in VRF'), - fetch_trigger='open' - ) - status = forms.MultipleChoiceField( - choices=PrefixStatusChoices, - required=False, - widget=StaticSelectMultiple() - ) - region_id = DynamicModelMultipleChoiceField( - queryset=Region.objects.all(), - required=False, - label=_('Region'), - fetch_trigger='open' - ) - site_group_id = DynamicModelMultipleChoiceField( - queryset=SiteGroup.objects.all(), - required=False, - label=_('Site group'), - fetch_trigger='open' - ) - site_id = DynamicModelMultipleChoiceField( - queryset=Site.objects.all(), - required=False, - null_option='None', - query_params={ - 'region_id': '$region_id' - }, - label=_('Site'), - fetch_trigger='open' - ) - role_id = DynamicModelMultipleChoiceField( - queryset=Role.objects.all(), - required=False, - null_option='None', - label=_('Role'), - fetch_trigger='open' - ) - is_pool = forms.NullBooleanField( - required=False, - label=_('Is a pool'), - widget=StaticSelect( - choices=BOOLEAN_WITH_BLANK_CHOICES - ) - ) - mark_utilized = forms.NullBooleanField( - required=False, - label=_('Marked as 100% utilized'), - widget=StaticSelect( - choices=BOOLEAN_WITH_BLANK_CHOICES - ) - ) - tag = TagFilterField(model) - - -# -# IP ranges -# - -class IPRangeForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): - vrf = DynamicModelChoiceField( - queryset=VRF.objects.all(), - required=False, - label='VRF' - ) - role = DynamicModelChoiceField( - queryset=Role.objects.all(), - required=False - ) - tags = DynamicModelMultipleChoiceField( - queryset=Tag.objects.all(), - required=False - ) - - class Meta: - model = IPRange - fields = [ - 'vrf', 'start_address', 'end_address', 'status', 'role', 'description', 'tenant_group', 'tenant', 'tags', - ] - fieldsets = ( - ('IP Range', ('vrf', 'start_address', 'end_address', 'role', 'status', 'description', 'tags')), - ('Tenancy', ('tenant_group', 'tenant')), - ) - widgets = { - 'status': StaticSelect(), - } - - -class IPRangeCSVForm(CustomFieldModelCSVForm): - vrf = CSVModelChoiceField( - queryset=VRF.objects.all(), - to_field_name='name', - required=False, - help_text='Assigned VRF' - ) - tenant = CSVModelChoiceField( - queryset=Tenant.objects.all(), - required=False, - to_field_name='name', - help_text='Assigned tenant' - ) - status = CSVChoiceField( - choices=IPRangeStatusChoices, - help_text='Operational status' - ) - role = CSVModelChoiceField( - queryset=Role.objects.all(), - required=False, - to_field_name='name', - help_text='Functional role' - ) - - class Meta: - model = IPRange - fields = ( - 'start_address', 'end_address', 'vrf', 'tenant', 'status', 'role', 'description', - ) - - -class IPRangeBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm): - pk = forms.ModelMultipleChoiceField( - queryset=IPRange.objects.all(), - widget=forms.MultipleHiddenInput() - ) - vrf = DynamicModelChoiceField( - queryset=VRF.objects.all(), - required=False, - label='VRF' - ) - tenant = DynamicModelChoiceField( - queryset=Tenant.objects.all(), - required=False - ) - status = forms.ChoiceField( - choices=add_blank_choice(IPRangeStatusChoices), - required=False, - widget=StaticSelect() - ) - role = DynamicModelChoiceField( - queryset=Role.objects.all(), - required=False - ) - description = forms.CharField( - max_length=100, - required=False - ) - - class Meta: - nullable_fields = [ - 'vrf', 'tenant', 'role', 'description', - ] - - -class IPRangeFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilterForm): - model = IPRange - field_groups = [ - ['q', 'tag'], - ['family', 'vrf_id', 'status', 'role_id'], - ['tenant_group_id', 'tenant_id'], - ] - q = forms.CharField( - required=False, - widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), - label=_('Search') - ) - family = forms.ChoiceField( - required=False, - choices=add_blank_choice(IPAddressFamilyChoices), - label=_('Address family'), - widget=StaticSelect() - ) - vrf_id = DynamicModelMultipleChoiceField( - queryset=VRF.objects.all(), - required=False, - label=_('Assigned VRF'), - null_option='Global', - fetch_trigger='open' - ) - status = forms.MultipleChoiceField( - choices=PrefixStatusChoices, - required=False, - widget=StaticSelectMultiple() - ) - role_id = DynamicModelMultipleChoiceField( - queryset=Role.objects.all(), - required=False, - null_option='None', - label=_('Role'), - fetch_trigger='open' - ) - tag = TagFilterField(model) - - -# -# IP addresses -# - -class IPAddressForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): - device = DynamicModelChoiceField( - queryset=Device.objects.all(), - required=False, - initial_params={ - 'interfaces': '$interface' - } - ) - interface = DynamicModelChoiceField( - queryset=Interface.objects.all(), - required=False, - query_params={ - 'device_id': '$device' - } - ) - virtual_machine = DynamicModelChoiceField( - queryset=VirtualMachine.objects.all(), - required=False, - initial_params={ - 'interfaces': '$vminterface' - } - ) - vminterface = DynamicModelChoiceField( - queryset=VMInterface.objects.all(), - required=False, - label='Interface', - query_params={ - 'virtual_machine_id': '$virtual_machine' - } - ) - vrf = DynamicModelChoiceField( - queryset=VRF.objects.all(), - required=False, - label='VRF' - ) - nat_region = DynamicModelChoiceField( - queryset=Region.objects.all(), - required=False, - label='Region', - initial_params={ - 'sites': '$nat_site' - } - ) - nat_site_group = DynamicModelChoiceField( - queryset=SiteGroup.objects.all(), - required=False, - label='Site group', - initial_params={ - 'sites': '$nat_site' - } - ) - nat_site = DynamicModelChoiceField( - queryset=Site.objects.all(), - required=False, - label='Site', - query_params={ - 'region_id': '$nat_region', - 'group_id': '$nat_site_group', - } - ) - nat_rack = DynamicModelChoiceField( - queryset=Rack.objects.all(), - required=False, - label='Rack', - null_option='None', - query_params={ - 'site_id': '$site' - } - ) - nat_device = DynamicModelChoiceField( - queryset=Device.objects.all(), - required=False, - label='Device', - query_params={ - 'site_id': '$site', - 'rack_id': '$nat_rack', - } - ) - nat_cluster = DynamicModelChoiceField( - queryset=Cluster.objects.all(), - required=False, - label='Cluster' - ) - nat_virtual_machine = DynamicModelChoiceField( - queryset=VirtualMachine.objects.all(), - required=False, - label='Virtual Machine', - query_params={ - 'cluster_id': '$nat_cluster', - } - ) - nat_vrf = DynamicModelChoiceField( - queryset=VRF.objects.all(), - required=False, - label='VRF' - ) - nat_inside = DynamicModelChoiceField( - queryset=IPAddress.objects.all(), - required=False, - label='IP Address', - query_params={ - 'device_id': '$nat_device', - 'virtual_machine_id': '$nat_virtual_machine', - 'vrf_id': '$nat_vrf', - } - ) - primary_for_parent = forms.BooleanField( - required=False, - label='Make this the primary IP for the device/VM' - ) - tags = DynamicModelMultipleChoiceField( - queryset=Tag.objects.all(), - required=False - ) - - class Meta: - model = IPAddress - fields = [ - 'address', 'vrf', 'status', 'role', 'dns_name', 'description', 'primary_for_parent', 'nat_site', 'nat_rack', - 'nat_device', 'nat_cluster', 'nat_virtual_machine', 'nat_vrf', 'nat_inside', 'tenant_group', 'tenant', - 'tags', - ] - widgets = { - 'status': StaticSelect(), - 'role': StaticSelect(), - } - - def __init__(self, *args, **kwargs): - - # Initialize helper selectors - instance = kwargs.get('instance') - initial = kwargs.get('initial', {}).copy() - if instance: - if type(instance.assigned_object) is Interface: - initial['interface'] = instance.assigned_object - elif type(instance.assigned_object) is VMInterface: - initial['vminterface'] = instance.assigned_object - if instance.nat_inside: - nat_inside_parent = instance.nat_inside.assigned_object - if type(nat_inside_parent) is Interface: - initial['nat_site'] = nat_inside_parent.device.site.pk - if nat_inside_parent.device.rack: - initial['nat_rack'] = nat_inside_parent.device.rack.pk - initial['nat_device'] = nat_inside_parent.device.pk - elif type(nat_inside_parent) is VMInterface: - initial['nat_cluster'] = nat_inside_parent.virtual_machine.cluster.pk - initial['nat_virtual_machine'] = nat_inside_parent.virtual_machine.pk - kwargs['initial'] = initial - - super().__init__(*args, **kwargs) - - # Initialize primary_for_parent if IP address is already assigned - if self.instance.pk and self.instance.assigned_object: - parent = self.instance.assigned_object.parent_object - if ( - self.instance.address.version == 4 and parent.primary_ip4_id == self.instance.pk or - self.instance.address.version == 6 and parent.primary_ip6_id == self.instance.pk - ): - self.initial['primary_for_parent'] = True - - def clean(self): - super().clean() - - # Cannot select both a device interface and a VM interface - if self.cleaned_data.get('interface') and self.cleaned_data.get('vminterface'): - raise forms.ValidationError("Cannot select both a device interface and a virtual machine interface") - self.instance.assigned_object = self.cleaned_data.get('interface') or self.cleaned_data.get('vminterface') - - # Primary IP assignment is only available if an interface has been assigned. - interface = self.cleaned_data.get('interface') or self.cleaned_data.get('vminterface') - if self.cleaned_data.get('primary_for_parent') and not interface: - self.add_error( - 'primary_for_parent', "Only IP addresses assigned to an interface can be designated as primary IPs." - ) - - def save(self, *args, **kwargs): - ipaddress = super().save(*args, **kwargs) - - # Assign/clear this IPAddress as the primary for the associated Device/VirtualMachine. - interface = self.instance.assigned_object - if interface: - parent = interface.parent_object - if self.cleaned_data['primary_for_parent']: - if ipaddress.address.version == 4: - parent.primary_ip4 = ipaddress - else: - parent.primary_ip6 = ipaddress - parent.save() - elif ipaddress.address.version == 4 and parent.primary_ip4 == ipaddress: - parent.primary_ip4 = None - parent.save() - elif ipaddress.address.version == 6 and parent.primary_ip6 == ipaddress: - parent.primary_ip6 = None - parent.save() - - return ipaddress - - -class IPAddressBulkCreateForm(BootstrapMixin, forms.Form): - pattern = ExpandableIPAddressField( - label='Address pattern' - ) - - -class IPAddressBulkAddForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): - vrf = DynamicModelChoiceField( - queryset=VRF.objects.all(), - required=False, - label='VRF' - ) - tags = DynamicModelMultipleChoiceField( - queryset=Tag.objects.all(), - required=False - ) - - class Meta: - model = IPAddress - fields = [ - 'address', 'vrf', 'status', 'role', 'dns_name', 'description', 'tenant_group', 'tenant', 'tags', - ] - widgets = { - 'status': StaticSelect(), - 'role': StaticSelect(), - } - - -class IPAddressCSVForm(CustomFieldModelCSVForm): - vrf = CSVModelChoiceField( - queryset=VRF.objects.all(), - to_field_name='name', - required=False, - help_text='Assigned VRF' - ) - tenant = CSVModelChoiceField( - queryset=Tenant.objects.all(), - to_field_name='name', - required=False, - help_text='Assigned tenant' - ) - status = CSVChoiceField( - choices=IPAddressStatusChoices, - required=False, - help_text='Operational status' - ) - role = CSVChoiceField( - choices=IPAddressRoleChoices, - required=False, - help_text='Functional role' - ) - device = CSVModelChoiceField( - queryset=Device.objects.all(), - required=False, - to_field_name='name', - help_text='Parent device of assigned interface (if any)' - ) - virtual_machine = CSVModelChoiceField( - queryset=VirtualMachine.objects.all(), - required=False, - to_field_name='name', - help_text='Parent VM of assigned interface (if any)' - ) - interface = CSVModelChoiceField( - queryset=Interface.objects.none(), # Can also refer to VMInterface - required=False, - to_field_name='name', - help_text='Assigned interface' - ) - is_primary = forms.BooleanField( - help_text='Make this the primary IP for the assigned device', - required=False - ) - - class Meta: - model = IPAddress - fields = [ - 'address', 'vrf', 'tenant', 'status', 'role', 'device', 'virtual_machine', 'interface', 'is_primary', - 'dns_name', 'description', - ] - - def __init__(self, data=None, *args, **kwargs): - super().__init__(data, *args, **kwargs) - - if data: - - # Limit interface queryset by assigned device - if data.get('device'): - self.fields['interface'].queryset = Interface.objects.filter( - **{f"device__{self.fields['device'].to_field_name}": data['device']} - ) - - # Limit interface queryset by assigned device - elif data.get('virtual_machine'): - self.fields['interface'].queryset = VMInterface.objects.filter( - **{f"virtual_machine__{self.fields['virtual_machine'].to_field_name}": data['virtual_machine']} - ) - - def clean(self): - super().clean() - - device = self.cleaned_data.get('device') - virtual_machine = self.cleaned_data.get('virtual_machine') - is_primary = self.cleaned_data.get('is_primary') - - # Validate is_primary - if is_primary and not device and not virtual_machine: - raise forms.ValidationError("No device or virtual machine specified; cannot set as primary IP") - - def save(self, *args, **kwargs): - - # Set interface assignment - if self.cleaned_data['interface']: - self.instance.assigned_object = self.cleaned_data['interface'] - - ipaddress = super().save(*args, **kwargs) - - # Set as primary for device/VM - if self.cleaned_data['is_primary']: - parent = self.cleaned_data['device'] or self.cleaned_data['virtual_machine'] - if self.instance.address.version == 4: - parent.primary_ip4 = ipaddress - elif self.instance.address.version == 6: - parent.primary_ip6 = ipaddress - parent.save() - - return ipaddress - - -class IPAddressBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm): - pk = forms.ModelMultipleChoiceField( - queryset=IPAddress.objects.all(), - widget=forms.MultipleHiddenInput() - ) - vrf = DynamicModelChoiceField( - queryset=VRF.objects.all(), - required=False, - label='VRF' - ) - mask_length = forms.IntegerField( - min_value=IPADDRESS_MASK_LENGTH_MIN, - max_value=IPADDRESS_MASK_LENGTH_MAX, - required=False - ) - tenant = DynamicModelChoiceField( - queryset=Tenant.objects.all(), - required=False - ) - status = forms.ChoiceField( - choices=add_blank_choice(IPAddressStatusChoices), - required=False, - widget=StaticSelect() - ) - role = forms.ChoiceField( - choices=add_blank_choice(IPAddressRoleChoices), - required=False, - widget=StaticSelect() - ) - dns_name = forms.CharField( - max_length=255, - required=False - ) - description = forms.CharField( - max_length=100, - required=False - ) - - class Meta: - nullable_fields = [ - 'vrf', 'role', 'tenant', 'dns_name', 'description', - ] - - -class IPAddressAssignForm(BootstrapMixin, forms.Form): - vrf_id = DynamicModelChoiceField( - queryset=VRF.objects.all(), - required=False, - label='VRF' - ) - q = forms.CharField( - required=False, - label='Search', - ) - - -class IPAddressFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilterForm): - model = IPAddress - field_order = [ - 'q', 'parent', 'family', 'mask_length', 'vrf_id', 'present_in_vrf_id', 'status', 'role', - 'assigned_to_interface', 'tenant_group_id', 'tenant_id', - ] - field_groups = [ - ['q', 'tag'], - ['parent', 'family', 'status', 'role', 'mask_length', 'assigned_to_interface'], - ['vrf_id', 'present_in_vrf_id'], - ['tenant_group_id', 'tenant_id'], - ] - q = forms.CharField( - required=False, - widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), - label=_('Search') - ) - parent = forms.CharField( - required=False, - widget=forms.TextInput( - attrs={ - 'placeholder': 'Prefix', - } - ), - label='Parent Prefix' - ) - family = forms.ChoiceField( - required=False, - choices=add_blank_choice(IPAddressFamilyChoices), - label=_('Address family'), - widget=StaticSelect() - ) - mask_length = forms.ChoiceField( - required=False, - choices=IPADDRESS_MASK_LENGTH_CHOICES, - label=_('Mask length'), - widget=StaticSelect() - ) - vrf_id = DynamicModelMultipleChoiceField( - queryset=VRF.objects.all(), - required=False, - label=_('Assigned VRF'), - null_option='Global', - fetch_trigger='open' - ) - present_in_vrf_id = DynamicModelChoiceField( - queryset=VRF.objects.all(), - required=False, - label=_('Present in VRF'), - fetch_trigger='open' - ) - status = forms.MultipleChoiceField( - choices=IPAddressStatusChoices, - required=False, - widget=StaticSelectMultiple() - ) - role = forms.MultipleChoiceField( - choices=IPAddressRoleChoices, - required=False, - widget=StaticSelectMultiple() - ) - assigned_to_interface = forms.NullBooleanField( - required=False, - label=_('Assigned to an interface'), - widget=StaticSelect( - choices=BOOLEAN_WITH_BLANK_CHOICES - ) - ) - tag = TagFilterField(model) - - -# -# VLAN groups -# - -class VLANGroupForm(BootstrapMixin, CustomFieldModelForm): - scope_type = ContentTypeChoiceField( - queryset=ContentType.objects.filter(model__in=VLANGROUP_SCOPE_TYPES), - required=False, - widget=StaticSelect - ) - region = DynamicModelChoiceField( - queryset=Region.objects.all(), - required=False, - initial_params={ - 'sites': '$site' - } - ) - sitegroup = DynamicModelChoiceField( - queryset=SiteGroup.objects.all(), - required=False, - initial_params={ - 'sites': '$site' - }, - label='Site group' - ) - site = DynamicModelChoiceField( - queryset=Site.objects.all(), - required=False, - initial_params={ - 'locations': '$location' - }, - query_params={ - 'region_id': '$region', - 'group_id': '$sitegroup', - } - ) - location = DynamicModelChoiceField( - queryset=Location.objects.all(), - required=False, - initial_params={ - 'racks': '$rack' - }, - query_params={ - 'site_id': '$site', - } - ) - rack = DynamicModelChoiceField( - queryset=Rack.objects.all(), - required=False, - query_params={ - 'site_id': '$site', - 'location_id': '$location', - } - ) - clustergroup = DynamicModelChoiceField( - queryset=ClusterGroup.objects.all(), - required=False, - initial_params={ - 'clusters': '$cluster' - }, - label='Cluster group' - ) - cluster = DynamicModelChoiceField( - queryset=Cluster.objects.all(), - required=False, - query_params={ - 'group_id': '$clustergroup', - } - ) - slug = SlugField() - - class Meta: - model = VLANGroup - fields = [ - 'name', 'slug', 'description', 'scope_type', 'region', 'sitegroup', 'site', 'location', 'rack', - 'clustergroup', 'cluster', - ] - fieldsets = ( - ('VLAN Group', ('name', 'slug', 'description')), - ('Scope', ('scope_type', 'region', 'sitegroup', 'site', 'location', 'rack', 'clustergroup', 'cluster')), - ) - widgets = { - 'scope_type': StaticSelect, - } - - def __init__(self, *args, **kwargs): - instance = kwargs.get('instance') - initial = kwargs.get('initial', {}) - - if instance is not None and instance.scope: - initial[instance.scope_type.model] = instance.scope - - kwargs['initial'] = initial - - super().__init__(*args, **kwargs) - - def clean(self): - super().clean() - - # Assign scope based on scope_type - if self.cleaned_data.get('scope_type'): - scope_field = self.cleaned_data['scope_type'].model - self.instance.scope = self.cleaned_data.get(scope_field) - else: - self.instance.scope_id = None - - -class VLANGroupCSVForm(CustomFieldModelCSVForm): - slug = SlugField() - scope_type = CSVContentTypeField( - queryset=ContentType.objects.filter(model__in=VLANGROUP_SCOPE_TYPES), - required=False, - label='Scope type (app & model)' - ) - - class Meta: - model = VLANGroup - fields = ('name', 'slug', 'scope_type', 'scope_id', 'description') - labels = { - 'scope_id': 'Scope ID', - } - - -class VLANGroupBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm): - pk = forms.ModelMultipleChoiceField( - queryset=VLANGroup.objects.all(), - widget=forms.MultipleHiddenInput - ) - site = DynamicModelChoiceField( - queryset=Site.objects.all(), - required=False - ) - description = forms.CharField( - max_length=200, - required=False - ) - - class Meta: - nullable_fields = ['site', 'description'] - - -class VLANGroupFilterForm(BootstrapMixin, CustomFieldModelFilterForm): - field_groups = [ - ['q'], - ['region', 'sitegroup', 'site', 'location', 'rack'] - ] - model = VLANGroup - q = forms.CharField( - required=False, - widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), - label=_('Search') - ) - region = DynamicModelMultipleChoiceField( - queryset=Region.objects.all(), - required=False, - label=_('Region'), - fetch_trigger='open' - ) - sitegroup = DynamicModelMultipleChoiceField( - queryset=SiteGroup.objects.all(), - required=False, - label=_('Site group'), - fetch_trigger='open' - ) - site = DynamicModelMultipleChoiceField( - queryset=Site.objects.all(), - required=False, - label=_('Site'), - fetch_trigger='open' - ) - location = DynamicModelMultipleChoiceField( - queryset=Location.objects.all(), - required=False, - label=_('Location'), - fetch_trigger='open' - ) - rack = DynamicModelMultipleChoiceField( - queryset=Rack.objects.all(), - required=False, - label=_('Rack'), - fetch_trigger='open' - ) - - -# -# VLANs -# - -class VLANForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): - # VLANGroup assignment fields - scope_type = forms.ChoiceField( - choices=( - ('', ''), - ('dcim.region', 'Region'), - ('dcim.sitegroup', 'Site group'), - ('dcim.site', 'Site'), - ('dcim.location', 'Location'), - ('dcim.rack', 'Rack'), - ('virtualization.clustergroup', 'Cluster group'), - ('virtualization.cluster', 'Cluster'), - ), - required=False, - widget=StaticSelect, - label='Group scope' - ) - group = DynamicModelChoiceField( - queryset=VLANGroup.objects.all(), - required=False, - query_params={ - 'scope_type': '$scope_type', - }, - label='VLAN Group' - ) - - # Site assignment fields - region = DynamicModelChoiceField( - queryset=Region.objects.all(), - required=False, - initial_params={ - 'sites': '$site' - }, - label='Region' - ) - sitegroup = DynamicModelChoiceField( - queryset=SiteGroup.objects.all(), - required=False, - initial_params={ - 'sites': '$site' - }, - label='Site group' - ) - site = DynamicModelChoiceField( - queryset=Site.objects.all(), - required=False, - null_option='None', - query_params={ - 'region_id': '$region', - 'group_id': '$sitegroup', - } - ) - - # Other fields - role = DynamicModelChoiceField( - queryset=Role.objects.all(), - required=False - ) - tags = DynamicModelMultipleChoiceField( - queryset=Tag.objects.all(), - required=False - ) - - class Meta: - model = VLAN - fields = [ - 'site', 'group', 'vid', 'name', 'status', 'role', 'description', 'tenant_group', 'tenant', 'tags', - ] - help_texts = { - 'site': "Leave blank if this VLAN spans multiple sites", - 'group': "VLAN group (optional)", - 'vid': "Configured VLAN ID", - 'name': "Configured VLAN name", - 'status': "Operational status of this VLAN", - 'role': "The primary function of this VLAN", - } - widgets = { - 'status': StaticSelect(), - } - - -class VLANCSVForm(CustomFieldModelCSVForm): - site = CSVModelChoiceField( - queryset=Site.objects.all(), - required=False, - to_field_name='name', - help_text='Assigned site' - ) - group = CSVModelChoiceField( - queryset=VLANGroup.objects.all(), - required=False, - to_field_name='name', - help_text='Assigned VLAN group' - ) - tenant = CSVModelChoiceField( - queryset=Tenant.objects.all(), - to_field_name='name', - required=False, - help_text='Assigned tenant' - ) - status = CSVChoiceField( - choices=VLANStatusChoices, - help_text='Operational status' - ) - role = CSVModelChoiceField( - queryset=Role.objects.all(), - required=False, - to_field_name='name', - help_text='Functional role' - ) - - class Meta: - model = VLAN - fields = ('site', 'group', 'vid', 'name', 'tenant', 'status', 'role', 'description') - help_texts = { - 'vid': 'Numeric VLAN ID (1-4095)', - 'name': 'VLAN name', - } - - -class VLANBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm): - pk = forms.ModelMultipleChoiceField( - queryset=VLAN.objects.all(), - widget=forms.MultipleHiddenInput() - ) - region = DynamicModelChoiceField( - queryset=Region.objects.all(), - required=False - ) - site_group = DynamicModelChoiceField( - queryset=SiteGroup.objects.all(), - required=False - ) - site = DynamicModelChoiceField( - queryset=Site.objects.all(), - required=False, - query_params={ - 'region_id': '$region', - 'group_id': '$site_group', - } - ) - group = DynamicModelChoiceField( - queryset=VLANGroup.objects.all(), - required=False, - query_params={ - 'site_id': '$site' - } - ) - tenant = DynamicModelChoiceField( - queryset=Tenant.objects.all(), - required=False - ) - status = forms.ChoiceField( - choices=add_blank_choice(VLANStatusChoices), - required=False, - widget=StaticSelect() - ) - role = DynamicModelChoiceField( - queryset=Role.objects.all(), - required=False - ) - description = forms.CharField( - max_length=100, - required=False - ) - - class Meta: - nullable_fields = [ - 'site', 'group', 'tenant', 'role', 'description', - ] - - -class VLANFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilterForm): - model = VLAN - field_groups = [ - ['q', 'tag'], - ['region_id', 'site_group_id', 'site_id'], - ['group_id', 'status', 'role_id'], - ['tenant_group_id', 'tenant_id'], - ] - q = forms.CharField( - required=False, - widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), - label=_('Search') - ) - region_id = DynamicModelMultipleChoiceField( - queryset=Region.objects.all(), - required=False, - label=_('Region'), - fetch_trigger='open' - ) - site_group_id = DynamicModelMultipleChoiceField( - queryset=SiteGroup.objects.all(), - required=False, - label=_('Site group'), - fetch_trigger='open' - ) - site_id = DynamicModelMultipleChoiceField( - queryset=Site.objects.all(), - required=False, - null_option='None', - query_params={ - 'region': '$region' - }, - label=_('Site'), - fetch_trigger='open' - ) - group_id = DynamicModelMultipleChoiceField( - queryset=VLANGroup.objects.all(), - required=False, - null_option='None', - query_params={ - 'region': '$region' - }, - label=_('VLAN group'), - fetch_trigger='open' - ) - status = forms.MultipleChoiceField( - choices=VLANStatusChoices, - required=False, - widget=StaticSelectMultiple() - ) - role_id = DynamicModelMultipleChoiceField( - queryset=Role.objects.all(), - required=False, - null_option='None', - label=_('Role'), - fetch_trigger='open' - ) - tag = TagFilterField(model) - - -# -# Services -# - -class ServiceForm(BootstrapMixin, CustomFieldModelForm): - ports = NumericArrayField( - base_field=forms.IntegerField( - min_value=SERVICE_PORT_MIN, - max_value=SERVICE_PORT_MAX - ), - help_text="Comma-separated list of one or more port numbers. A range may be specified using a hyphen." - ) - tags = DynamicModelMultipleChoiceField( - queryset=Tag.objects.all(), - required=False - ) - - class Meta: - model = Service - fields = [ - 'name', 'protocol', 'ports', 'ipaddresses', 'description', 'tags', - ] - help_texts = { - 'ipaddresses': "IP address assignment is optional. If no IPs are selected, the service is assumed to be " - "reachable via all IPs assigned to the device.", - } - widgets = { - 'protocol': StaticSelect(), - 'ipaddresses': StaticSelectMultiple(), - } - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - # Limit IP address choices to those assigned to interfaces of the parent device/VM - if self.instance.device: - self.fields['ipaddresses'].queryset = IPAddress.objects.filter( - interface__in=self.instance.device.vc_interfaces().values_list('id', flat=True) - ) - elif self.instance.virtual_machine: - self.fields['ipaddresses'].queryset = IPAddress.objects.filter( - vminterface__in=self.instance.virtual_machine.interfaces.values_list('id', flat=True) - ) - else: - self.fields['ipaddresses'].choices = [] - - -class ServiceFilterForm(BootstrapMixin, CustomFieldModelFilterForm): - model = Service - field_groups = ( - ('q', 'tag'), - ('protocol', 'port'), - ) - q = forms.CharField( - required=False, - widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), - label=_('Search') - ) - protocol = forms.ChoiceField( - choices=add_blank_choice(ServiceProtocolChoices), - required=False, - widget=StaticSelectMultiple() - ) - port = forms.IntegerField( - required=False, - ) - tag = TagFilterField(model) - - -class ServiceCSVForm(CustomFieldModelCSVForm): - device = CSVModelChoiceField( - queryset=Device.objects.all(), - required=False, - to_field_name='name', - help_text='Required if not assigned to a VM' - ) - virtual_machine = CSVModelChoiceField( - queryset=VirtualMachine.objects.all(), - required=False, - to_field_name='name', - help_text='Required if not assigned to a device' - ) - protocol = CSVChoiceField( - choices=ServiceProtocolChoices, - help_text='IP protocol' - ) - - class Meta: - model = Service - fields = ('device', 'virtual_machine', 'name', 'protocol', 'ports', 'description') - - -class ServiceBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm): - pk = forms.ModelMultipleChoiceField( - queryset=Service.objects.all(), - widget=forms.MultipleHiddenInput() - ) - protocol = forms.ChoiceField( - choices=add_blank_choice(ServiceProtocolChoices), - required=False, - widget=StaticSelect() - ) - ports = NumericArrayField( - base_field=forms.IntegerField( - min_value=SERVICE_PORT_MIN, - max_value=SERVICE_PORT_MAX - ), - required=False - ) - description = forms.CharField( - max_length=100, - required=False - ) - - class Meta: - nullable_fields = [ - 'description', - ] diff --git a/netbox/ipam/forms/__init__.py b/netbox/ipam/forms/__init__.py new file mode 100644 index 000000000..fc3352358 --- /dev/null +++ b/netbox/ipam/forms/__init__.py @@ -0,0 +1,5 @@ +from .models import * +from .filtersets import * +from .bulk_create import * +from .bulk_edit import * +from .bulk_import import * diff --git a/netbox/ipam/forms/bulk_create.py b/netbox/ipam/forms/bulk_create.py new file mode 100644 index 000000000..790474c6e --- /dev/null +++ b/netbox/ipam/forms/bulk_create.py @@ -0,0 +1,13 @@ +from django import forms + +from utilities.forms import BootstrapMixin, ExpandableIPAddressField + +__all__ = ( + 'IPAddressBulkCreateForm', +) + + +class IPAddressBulkCreateForm(BootstrapMixin, forms.Form): + pattern = ExpandableIPAddressField( + label='Address pattern' + ) diff --git a/netbox/ipam/forms/bulk_edit.py b/netbox/ipam/forms/bulk_edit.py new file mode 100644 index 000000000..895dbe200 --- /dev/null +++ b/netbox/ipam/forms/bulk_edit.py @@ -0,0 +1,378 @@ +from django import forms + +from dcim.models import Region, Site, SiteGroup +from extras.forms import AddRemoveTagsForm, CustomFieldModelBulkEditForm +from ipam.choices import * +from ipam.constants import * +from ipam.models import * +from tenancy.models import Tenant +from utilities.forms import ( + add_blank_choice, BootstrapMixin, BulkEditNullBooleanSelect, DatePicker, DynamicModelChoiceField, NumericArrayField, + StaticSelect, +) + +__all__ = ( + 'AggregateBulkEditForm', + 'IPAddressBulkEditForm', + 'IPRangeBulkEditForm', + 'PrefixBulkEditForm', + 'RIRBulkEditForm', + 'RoleBulkEditForm', + 'RouteTargetBulkEditForm', + 'ServiceBulkEditForm', + 'VLANBulkEditForm', + 'VLANGroupBulkEditForm', + 'VRFBulkEditForm', +) + + +class VRFBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm): + pk = forms.ModelMultipleChoiceField( + queryset=VRF.objects.all(), + widget=forms.MultipleHiddenInput() + ) + tenant = DynamicModelChoiceField( + queryset=Tenant.objects.all(), + required=False + ) + enforce_unique = forms.NullBooleanField( + required=False, + widget=BulkEditNullBooleanSelect(), + label='Enforce unique space' + ) + description = forms.CharField( + max_length=100, + required=False + ) + + class Meta: + nullable_fields = [ + 'tenant', 'description', + ] + + +class RouteTargetBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm): + pk = forms.ModelMultipleChoiceField( + queryset=RouteTarget.objects.all(), + widget=forms.MultipleHiddenInput() + ) + tenant = DynamicModelChoiceField( + queryset=Tenant.objects.all(), + required=False + ) + description = forms.CharField( + max_length=200, + required=False + ) + + class Meta: + nullable_fields = [ + 'tenant', 'description', + ] + + +class RIRBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm): + pk = forms.ModelMultipleChoiceField( + queryset=RIR.objects.all(), + widget=forms.MultipleHiddenInput + ) + is_private = forms.NullBooleanField( + required=False, + widget=BulkEditNullBooleanSelect + ) + description = forms.CharField( + max_length=200, + required=False + ) + + class Meta: + nullable_fields = ['is_private', 'description'] + + +class AggregateBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm): + pk = forms.ModelMultipleChoiceField( + queryset=Aggregate.objects.all(), + widget=forms.MultipleHiddenInput() + ) + rir = DynamicModelChoiceField( + queryset=RIR.objects.all(), + required=False, + label='RIR' + ) + tenant = DynamicModelChoiceField( + queryset=Tenant.objects.all(), + required=False + ) + date_added = forms.DateField( + required=False + ) + description = forms.CharField( + max_length=100, + required=False + ) + + class Meta: + nullable_fields = [ + 'date_added', 'description', + ] + widgets = { + 'date_added': DatePicker(), + } + + +class RoleBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm): + pk = forms.ModelMultipleChoiceField( + queryset=Role.objects.all(), + widget=forms.MultipleHiddenInput + ) + weight = forms.IntegerField( + required=False + ) + description = forms.CharField( + max_length=200, + required=False + ) + + class Meta: + nullable_fields = ['description'] + + +class PrefixBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm): + pk = forms.ModelMultipleChoiceField( + queryset=Prefix.objects.all(), + widget=forms.MultipleHiddenInput() + ) + region = DynamicModelChoiceField( + queryset=Region.objects.all(), + required=False + ) + site_group = DynamicModelChoiceField( + queryset=SiteGroup.objects.all(), + required=False + ) + site = DynamicModelChoiceField( + queryset=Site.objects.all(), + required=False, + query_params={ + 'region_id': '$region', + 'group_id': '$site_group', + } + ) + vrf = DynamicModelChoiceField( + queryset=VRF.objects.all(), + required=False, + label='VRF' + ) + prefix_length = forms.IntegerField( + min_value=PREFIX_LENGTH_MIN, + max_value=PREFIX_LENGTH_MAX, + required=False + ) + tenant = DynamicModelChoiceField( + queryset=Tenant.objects.all(), + required=False + ) + status = forms.ChoiceField( + choices=add_blank_choice(PrefixStatusChoices), + required=False, + widget=StaticSelect() + ) + role = DynamicModelChoiceField( + queryset=Role.objects.all(), + required=False + ) + is_pool = forms.NullBooleanField( + required=False, + widget=BulkEditNullBooleanSelect(), + label='Is a pool' + ) + mark_utilized = forms.NullBooleanField( + required=False, + widget=BulkEditNullBooleanSelect(), + label='Treat as 100% utilized' + ) + description = forms.CharField( + max_length=100, + required=False + ) + + class Meta: + nullable_fields = [ + 'site', 'vrf', 'tenant', 'role', 'description', + ] + + +class IPRangeBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm): + pk = forms.ModelMultipleChoiceField( + queryset=IPRange.objects.all(), + widget=forms.MultipleHiddenInput() + ) + vrf = DynamicModelChoiceField( + queryset=VRF.objects.all(), + required=False, + label='VRF' + ) + tenant = DynamicModelChoiceField( + queryset=Tenant.objects.all(), + required=False + ) + status = forms.ChoiceField( + choices=add_blank_choice(IPRangeStatusChoices), + required=False, + widget=StaticSelect() + ) + role = DynamicModelChoiceField( + queryset=Role.objects.all(), + required=False + ) + description = forms.CharField( + max_length=100, + required=False + ) + + class Meta: + nullable_fields = [ + 'vrf', 'tenant', 'role', 'description', + ] + + +class IPAddressBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm): + pk = forms.ModelMultipleChoiceField( + queryset=IPAddress.objects.all(), + widget=forms.MultipleHiddenInput() + ) + vrf = DynamicModelChoiceField( + queryset=VRF.objects.all(), + required=False, + label='VRF' + ) + mask_length = forms.IntegerField( + min_value=IPADDRESS_MASK_LENGTH_MIN, + max_value=IPADDRESS_MASK_LENGTH_MAX, + required=False + ) + tenant = DynamicModelChoiceField( + queryset=Tenant.objects.all(), + required=False + ) + status = forms.ChoiceField( + choices=add_blank_choice(IPAddressStatusChoices), + required=False, + widget=StaticSelect() + ) + role = forms.ChoiceField( + choices=add_blank_choice(IPAddressRoleChoices), + required=False, + widget=StaticSelect() + ) + dns_name = forms.CharField( + max_length=255, + required=False + ) + description = forms.CharField( + max_length=100, + required=False + ) + + class Meta: + nullable_fields = [ + 'vrf', 'role', 'tenant', 'dns_name', 'description', + ] + + +class VLANGroupBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm): + pk = forms.ModelMultipleChoiceField( + queryset=VLANGroup.objects.all(), + widget=forms.MultipleHiddenInput + ) + site = DynamicModelChoiceField( + queryset=Site.objects.all(), + required=False + ) + description = forms.CharField( + max_length=200, + required=False + ) + + class Meta: + nullable_fields = ['site', 'description'] + + +class VLANBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm): + pk = forms.ModelMultipleChoiceField( + queryset=VLAN.objects.all(), + widget=forms.MultipleHiddenInput() + ) + region = DynamicModelChoiceField( + queryset=Region.objects.all(), + required=False + ) + site_group = DynamicModelChoiceField( + queryset=SiteGroup.objects.all(), + required=False + ) + site = DynamicModelChoiceField( + queryset=Site.objects.all(), + required=False, + query_params={ + 'region_id': '$region', + 'group_id': '$site_group', + } + ) + group = DynamicModelChoiceField( + queryset=VLANGroup.objects.all(), + required=False, + query_params={ + 'site_id': '$site' + } + ) + tenant = DynamicModelChoiceField( + queryset=Tenant.objects.all(), + required=False + ) + status = forms.ChoiceField( + choices=add_blank_choice(VLANStatusChoices), + required=False, + widget=StaticSelect() + ) + role = DynamicModelChoiceField( + queryset=Role.objects.all(), + required=False + ) + description = forms.CharField( + max_length=100, + required=False + ) + + class Meta: + nullable_fields = [ + 'site', 'group', 'tenant', 'role', 'description', + ] + + +class ServiceBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm): + pk = forms.ModelMultipleChoiceField( + queryset=Service.objects.all(), + widget=forms.MultipleHiddenInput() + ) + protocol = forms.ChoiceField( + choices=add_blank_choice(ServiceProtocolChoices), + required=False, + widget=StaticSelect() + ) + ports = NumericArrayField( + base_field=forms.IntegerField( + min_value=SERVICE_PORT_MIN, + max_value=SERVICE_PORT_MAX + ), + required=False + ) + description = forms.CharField( + max_length=100, + required=False + ) + + class Meta: + nullable_fields = [ + 'description', + ] diff --git a/netbox/ipam/forms/bulk_import.py b/netbox/ipam/forms/bulk_import.py new file mode 100644 index 000000000..ef5759748 --- /dev/null +++ b/netbox/ipam/forms/bulk_import.py @@ -0,0 +1,362 @@ +from django import forms +from django.contrib.contenttypes.models import ContentType + +from dcim.models import Device, Interface, Site +from extras.forms import CustomFieldModelCSVForm +from ipam.choices import * +from ipam.constants import * +from ipam.models import * +from tenancy.models import Tenant +from utilities.forms import CSVChoiceField, CSVContentTypeField, CSVModelChoiceField, SlugField +from virtualization.models import VirtualMachine, VMInterface + +__all__ = ( + 'AggregateCSVForm', + 'IPAddressCSVForm', + 'IPRangeCSVForm', + 'PrefixCSVForm', + 'RIRCSVForm', + 'RoleCSVForm', + 'RouteTargetCSVForm', + 'ServiceCSVForm', + 'VLANCSVForm', + 'VLANGroupCSVForm', + 'VRFCSVForm', +) + + +class VRFCSVForm(CustomFieldModelCSVForm): + tenant = CSVModelChoiceField( + queryset=Tenant.objects.all(), + required=False, + to_field_name='name', + help_text='Assigned tenant' + ) + + class Meta: + model = VRF + fields = ('name', 'rd', 'tenant', 'enforce_unique', 'description') + + +class RouteTargetCSVForm(CustomFieldModelCSVForm): + tenant = CSVModelChoiceField( + queryset=Tenant.objects.all(), + required=False, + to_field_name='name', + help_text='Assigned tenant' + ) + + class Meta: + model = RouteTarget + fields = ('name', 'description', 'tenant') + + +class RIRCSVForm(CustomFieldModelCSVForm): + slug = SlugField() + + class Meta: + model = RIR + fields = ('name', 'slug', 'is_private', 'description') + help_texts = { + 'name': 'RIR name', + } + + +class AggregateCSVForm(CustomFieldModelCSVForm): + rir = CSVModelChoiceField( + queryset=RIR.objects.all(), + to_field_name='name', + help_text='Assigned RIR' + ) + tenant = CSVModelChoiceField( + queryset=Tenant.objects.all(), + required=False, + to_field_name='name', + help_text='Assigned tenant' + ) + + class Meta: + model = Aggregate + fields = ('prefix', 'rir', 'tenant', 'date_added', 'description') + + +class RoleCSVForm(CustomFieldModelCSVForm): + slug = SlugField() + + class Meta: + model = Role + fields = ('name', 'slug', 'weight', 'description') + + +class PrefixCSVForm(CustomFieldModelCSVForm): + vrf = CSVModelChoiceField( + queryset=VRF.objects.all(), + to_field_name='name', + required=False, + help_text='Assigned VRF' + ) + tenant = CSVModelChoiceField( + queryset=Tenant.objects.all(), + required=False, + to_field_name='name', + help_text='Assigned tenant' + ) + site = CSVModelChoiceField( + queryset=Site.objects.all(), + required=False, + to_field_name='name', + help_text='Assigned site' + ) + vlan_group = CSVModelChoiceField( + queryset=VLANGroup.objects.all(), + required=False, + to_field_name='name', + help_text="VLAN's group (if any)" + ) + vlan = CSVModelChoiceField( + queryset=VLAN.objects.all(), + required=False, + to_field_name='vid', + help_text="Assigned VLAN" + ) + status = CSVChoiceField( + choices=PrefixStatusChoices, + help_text='Operational status' + ) + role = CSVModelChoiceField( + queryset=Role.objects.all(), + required=False, + to_field_name='name', + help_text='Functional role' + ) + + class Meta: + model = Prefix + fields = ( + 'prefix', 'vrf', 'tenant', 'site', 'vlan_group', 'vlan', 'status', 'role', 'is_pool', 'mark_utilized', + 'description', + ) + + def __init__(self, data=None, *args, **kwargs): + super().__init__(data, *args, **kwargs) + + if data: + + # Limit VLAN queryset by assigned site and/or group (if specified) + params = {} + if data.get('site'): + params[f"site__{self.fields['site'].to_field_name}"] = data.get('site') + if data.get('vlan_group'): + params[f"group__{self.fields['vlan_group'].to_field_name}"] = data.get('vlan_group') + if params: + self.fields['vlan'].queryset = self.fields['vlan'].queryset.filter(**params) + + +class IPRangeCSVForm(CustomFieldModelCSVForm): + vrf = CSVModelChoiceField( + queryset=VRF.objects.all(), + to_field_name='name', + required=False, + help_text='Assigned VRF' + ) + tenant = CSVModelChoiceField( + queryset=Tenant.objects.all(), + required=False, + to_field_name='name', + help_text='Assigned tenant' + ) + status = CSVChoiceField( + choices=IPRangeStatusChoices, + help_text='Operational status' + ) + role = CSVModelChoiceField( + queryset=Role.objects.all(), + required=False, + to_field_name='name', + help_text='Functional role' + ) + + class Meta: + model = IPRange + fields = ( + 'start_address', 'end_address', 'vrf', 'tenant', 'status', 'role', 'description', + ) + + +class IPAddressCSVForm(CustomFieldModelCSVForm): + vrf = CSVModelChoiceField( + queryset=VRF.objects.all(), + to_field_name='name', + required=False, + help_text='Assigned VRF' + ) + tenant = CSVModelChoiceField( + queryset=Tenant.objects.all(), + to_field_name='name', + required=False, + help_text='Assigned tenant' + ) + status = CSVChoiceField( + choices=IPAddressStatusChoices, + required=False, + help_text='Operational status' + ) + role = CSVChoiceField( + choices=IPAddressRoleChoices, + required=False, + help_text='Functional role' + ) + device = CSVModelChoiceField( + queryset=Device.objects.all(), + required=False, + to_field_name='name', + help_text='Parent device of assigned interface (if any)' + ) + virtual_machine = CSVModelChoiceField( + queryset=VirtualMachine.objects.all(), + required=False, + to_field_name='name', + help_text='Parent VM of assigned interface (if any)' + ) + interface = CSVModelChoiceField( + queryset=Interface.objects.none(), # Can also refer to VMInterface + required=False, + to_field_name='name', + help_text='Assigned interface' + ) + is_primary = forms.BooleanField( + help_text='Make this the primary IP for the assigned device', + required=False + ) + + class Meta: + model = IPAddress + fields = [ + 'address', 'vrf', 'tenant', 'status', 'role', 'device', 'virtual_machine', 'interface', 'is_primary', + 'dns_name', 'description', + ] + + def __init__(self, data=None, *args, **kwargs): + super().__init__(data, *args, **kwargs) + + if data: + + # Limit interface queryset by assigned device + if data.get('device'): + self.fields['interface'].queryset = Interface.objects.filter( + **{f"device__{self.fields['device'].to_field_name}": data['device']} + ) + + # Limit interface queryset by assigned device + elif data.get('virtual_machine'): + self.fields['interface'].queryset = VMInterface.objects.filter( + **{f"virtual_machine__{self.fields['virtual_machine'].to_field_name}": data['virtual_machine']} + ) + + def clean(self): + super().clean() + + device = self.cleaned_data.get('device') + virtual_machine = self.cleaned_data.get('virtual_machine') + is_primary = self.cleaned_data.get('is_primary') + + # Validate is_primary + if is_primary and not device and not virtual_machine: + raise forms.ValidationError("No device or virtual machine specified; cannot set as primary IP") + + def save(self, *args, **kwargs): + + # Set interface assignment + if self.cleaned_data['interface']: + self.instance.assigned_object = self.cleaned_data['interface'] + + ipaddress = super().save(*args, **kwargs) + + # Set as primary for device/VM + if self.cleaned_data['is_primary']: + parent = self.cleaned_data['device'] or self.cleaned_data['virtual_machine'] + if self.instance.address.version == 4: + parent.primary_ip4 = ipaddress + elif self.instance.address.version == 6: + parent.primary_ip6 = ipaddress + parent.save() + + return ipaddress + + +class VLANGroupCSVForm(CustomFieldModelCSVForm): + slug = SlugField() + scope_type = CSVContentTypeField( + queryset=ContentType.objects.filter(model__in=VLANGROUP_SCOPE_TYPES), + required=False, + label='Scope type (app & model)' + ) + + class Meta: + model = VLANGroup + fields = ('name', 'slug', 'scope_type', 'scope_id', 'description') + labels = { + 'scope_id': 'Scope ID', + } + + +class VLANCSVForm(CustomFieldModelCSVForm): + site = CSVModelChoiceField( + queryset=Site.objects.all(), + required=False, + to_field_name='name', + help_text='Assigned site' + ) + group = CSVModelChoiceField( + queryset=VLANGroup.objects.all(), + required=False, + to_field_name='name', + help_text='Assigned VLAN group' + ) + tenant = CSVModelChoiceField( + queryset=Tenant.objects.all(), + to_field_name='name', + required=False, + help_text='Assigned tenant' + ) + status = CSVChoiceField( + choices=VLANStatusChoices, + help_text='Operational status' + ) + role = CSVModelChoiceField( + queryset=Role.objects.all(), + required=False, + to_field_name='name', + help_text='Functional role' + ) + + class Meta: + model = VLAN + fields = ('site', 'group', 'vid', 'name', 'tenant', 'status', 'role', 'description') + help_texts = { + 'vid': 'Numeric VLAN ID (1-4095)', + 'name': 'VLAN name', + } + + +class ServiceCSVForm(CustomFieldModelCSVForm): + device = CSVModelChoiceField( + queryset=Device.objects.all(), + required=False, + to_field_name='name', + help_text='Required if not assigned to a VM' + ) + virtual_machine = CSVModelChoiceField( + queryset=VirtualMachine.objects.all(), + required=False, + to_field_name='name', + help_text='Required if not assigned to a device' + ) + protocol = CSVChoiceField( + choices=ServiceProtocolChoices, + help_text='IP protocol' + ) + + class Meta: + model = Service + fields = ('device', 'virtual_machine', 'name', 'protocol', 'ports', 'description') diff --git a/netbox/ipam/forms/filtersets.py b/netbox/ipam/forms/filtersets.py new file mode 100644 index 000000000..8bc0f10fb --- /dev/null +++ b/netbox/ipam/forms/filtersets.py @@ -0,0 +1,486 @@ +from django import forms +from django.utils.translation import gettext as _ + +from dcim.models import Location, Rack, Region, Site, SiteGroup +from extras.forms import CustomFieldModelFilterForm +from ipam.choices import * +from ipam.constants import * +from ipam.models import * +from tenancy.forms import TenancyFilterForm +from utilities.forms import ( + add_blank_choice, BootstrapMixin, DynamicModelChoiceField, DynamicModelMultipleChoiceField, StaticSelect, + StaticSelectMultiple, TagFilterField, BOOLEAN_WITH_BLANK_CHOICES, +) + +__all__ = ( + 'AggregateFilterForm', + 'IPAddressFilterForm', + 'IPRangeFilterForm', + 'PrefixFilterForm', + 'RIRFilterForm', + 'RoleFilterForm', + 'RouteTargetFilterForm', + 'ServiceFilterForm', + 'VLANFilterForm', + 'VLANGroupFilterForm', + 'VRFFilterForm', +) + +PREFIX_MASK_LENGTH_CHOICES = add_blank_choice([ + (i, i) for i in range(PREFIX_LENGTH_MIN, PREFIX_LENGTH_MAX + 1) +]) + +IPADDRESS_MASK_LENGTH_CHOICES = add_blank_choice([ + (i, i) for i in range(IPADDRESS_MASK_LENGTH_MIN, IPADDRESS_MASK_LENGTH_MAX + 1) +]) + + +class VRFFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilterForm): + model = VRF + field_groups = [ + ['q', 'tag'], + ['import_target_id', 'export_target_id'], + ['tenant_group_id', 'tenant_id'], + ] + q = forms.CharField( + required=False, + widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), + label=_('Search') + ) + import_target_id = DynamicModelMultipleChoiceField( + queryset=RouteTarget.objects.all(), + required=False, + label=_('Import targets'), + fetch_trigger='open' + ) + export_target_id = DynamicModelMultipleChoiceField( + queryset=RouteTarget.objects.all(), + required=False, + label=_('Export targets'), + fetch_trigger='open' + ) + tag = TagFilterField(model) + + +class RouteTargetFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilterForm): + model = RouteTarget + field_groups = [ + ['q', 'tag'], + ['importing_vrf_id', 'exporting_vrf_id'], + ['tenant_group_id', 'tenant_id'], + ] + q = forms.CharField( + required=False, + widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), + label=_('Search') + ) + importing_vrf_id = DynamicModelMultipleChoiceField( + queryset=VRF.objects.all(), + required=False, + label=_('Imported by VRF'), + fetch_trigger='open' + ) + exporting_vrf_id = DynamicModelMultipleChoiceField( + queryset=VRF.objects.all(), + required=False, + label=_('Exported by VRF'), + fetch_trigger='open' + ) + tag = TagFilterField(model) + + +class RIRFilterForm(BootstrapMixin, CustomFieldModelFilterForm): + model = RIR + field_groups = [ + ['q'], + ['is_private'], + ] + q = forms.CharField( + required=False, + widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), + label=_('Search') + ) + is_private = forms.NullBooleanField( + required=False, + label=_('Private'), + widget=StaticSelect( + choices=BOOLEAN_WITH_BLANK_CHOICES + ) + ) + + +class AggregateFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilterForm): + model = Aggregate + field_groups = [ + ['q', 'tag'], + ['family', 'rir_id'], + ['tenant_group_id', 'tenant_id'] + ] + q = forms.CharField( + required=False, + widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), + label=_('Search') + ) + family = forms.ChoiceField( + required=False, + choices=add_blank_choice(IPAddressFamilyChoices), + label=_('Address family'), + widget=StaticSelect() + ) + rir_id = DynamicModelMultipleChoiceField( + queryset=RIR.objects.all(), + required=False, + label=_('RIR'), + fetch_trigger='open' + ) + tag = TagFilterField(model) + + +class RoleFilterForm(BootstrapMixin, CustomFieldModelFilterForm): + model = Role + field_groups = [ + ['q'], + ] + q = forms.CharField( + required=False, + widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), + label=_('Search') + ) + + +class PrefixFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilterForm): + model = Prefix + field_groups = [ + ['q', 'tag'], + ['within_include', 'family', 'status', 'role_id', 'mask_length', 'is_pool', 'mark_utilized'], + ['vrf_id', 'present_in_vrf_id'], + ['region_id', 'site_group_id', 'site_id'], + ['tenant_group_id', 'tenant_id'] + ] + q = forms.CharField( + required=False, + widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), + label=_('Search') + ) + mask_length__lte = forms.IntegerField( + widget=forms.HiddenInput() + ) + within_include = forms.CharField( + required=False, + widget=forms.TextInput( + attrs={ + 'placeholder': 'Prefix', + } + ), + label=_('Search within') + ) + family = forms.ChoiceField( + required=False, + choices=add_blank_choice(IPAddressFamilyChoices), + label=_('Address family'), + widget=StaticSelect() + ) + mask_length = forms.MultipleChoiceField( + required=False, + choices=PREFIX_MASK_LENGTH_CHOICES, + label=_('Mask length'), + widget=StaticSelectMultiple() + ) + vrf_id = DynamicModelMultipleChoiceField( + queryset=VRF.objects.all(), + required=False, + label=_('Assigned VRF'), + null_option='Global', + fetch_trigger='open' + ) + present_in_vrf_id = DynamicModelChoiceField( + queryset=VRF.objects.all(), + required=False, + label=_('Present in VRF'), + fetch_trigger='open' + ) + status = forms.MultipleChoiceField( + choices=PrefixStatusChoices, + required=False, + widget=StaticSelectMultiple() + ) + region_id = DynamicModelMultipleChoiceField( + queryset=Region.objects.all(), + required=False, + label=_('Region'), + fetch_trigger='open' + ) + site_group_id = DynamicModelMultipleChoiceField( + queryset=SiteGroup.objects.all(), + required=False, + label=_('Site group'), + fetch_trigger='open' + ) + site_id = DynamicModelMultipleChoiceField( + queryset=Site.objects.all(), + required=False, + null_option='None', + query_params={ + 'region_id': '$region_id' + }, + label=_('Site'), + fetch_trigger='open' + ) + role_id = DynamicModelMultipleChoiceField( + queryset=Role.objects.all(), + required=False, + null_option='None', + label=_('Role'), + fetch_trigger='open' + ) + is_pool = forms.NullBooleanField( + required=False, + label=_('Is a pool'), + widget=StaticSelect( + choices=BOOLEAN_WITH_BLANK_CHOICES + ) + ) + mark_utilized = forms.NullBooleanField( + required=False, + label=_('Marked as 100% utilized'), + widget=StaticSelect( + choices=BOOLEAN_WITH_BLANK_CHOICES + ) + ) + tag = TagFilterField(model) + + +class IPRangeFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilterForm): + model = IPRange + field_groups = [ + ['q', 'tag'], + ['family', 'vrf_id', 'status', 'role_id'], + ['tenant_group_id', 'tenant_id'], + ] + q = forms.CharField( + required=False, + widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), + label=_('Search') + ) + family = forms.ChoiceField( + required=False, + choices=add_blank_choice(IPAddressFamilyChoices), + label=_('Address family'), + widget=StaticSelect() + ) + vrf_id = DynamicModelMultipleChoiceField( + queryset=VRF.objects.all(), + required=False, + label=_('Assigned VRF'), + null_option='Global', + fetch_trigger='open' + ) + status = forms.MultipleChoiceField( + choices=PrefixStatusChoices, + required=False, + widget=StaticSelectMultiple() + ) + role_id = DynamicModelMultipleChoiceField( + queryset=Role.objects.all(), + required=False, + null_option='None', + label=_('Role'), + fetch_trigger='open' + ) + tag = TagFilterField(model) + + +class IPAddressFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilterForm): + model = IPAddress + field_order = [ + 'q', 'parent', 'family', 'mask_length', 'vrf_id', 'present_in_vrf_id', 'status', 'role', + 'assigned_to_interface', 'tenant_group_id', 'tenant_id', + ] + field_groups = [ + ['q', 'tag'], + ['parent', 'family', 'status', 'role', 'mask_length', 'assigned_to_interface'], + ['vrf_id', 'present_in_vrf_id'], + ['tenant_group_id', 'tenant_id'], + ] + q = forms.CharField( + required=False, + widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), + label=_('Search') + ) + parent = forms.CharField( + required=False, + widget=forms.TextInput( + attrs={ + 'placeholder': 'Prefix', + } + ), + label='Parent Prefix' + ) + family = forms.ChoiceField( + required=False, + choices=add_blank_choice(IPAddressFamilyChoices), + label=_('Address family'), + widget=StaticSelect() + ) + mask_length = forms.ChoiceField( + required=False, + choices=IPADDRESS_MASK_LENGTH_CHOICES, + label=_('Mask length'), + widget=StaticSelect() + ) + vrf_id = DynamicModelMultipleChoiceField( + queryset=VRF.objects.all(), + required=False, + label=_('Assigned VRF'), + null_option='Global', + fetch_trigger='open' + ) + present_in_vrf_id = DynamicModelChoiceField( + queryset=VRF.objects.all(), + required=False, + label=_('Present in VRF'), + fetch_trigger='open' + ) + status = forms.MultipleChoiceField( + choices=IPAddressStatusChoices, + required=False, + widget=StaticSelectMultiple() + ) + role = forms.MultipleChoiceField( + choices=IPAddressRoleChoices, + required=False, + widget=StaticSelectMultiple() + ) + assigned_to_interface = forms.NullBooleanField( + required=False, + label=_('Assigned to an interface'), + widget=StaticSelect( + choices=BOOLEAN_WITH_BLANK_CHOICES + ) + ) + tag = TagFilterField(model) + + +class VLANGroupFilterForm(BootstrapMixin, CustomFieldModelFilterForm): + field_groups = [ + ['q'], + ['region', 'sitegroup', 'site', 'location', 'rack'] + ] + model = VLANGroup + q = forms.CharField( + required=False, + widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), + label=_('Search') + ) + region = DynamicModelMultipleChoiceField( + queryset=Region.objects.all(), + required=False, + label=_('Region'), + fetch_trigger='open' + ) + sitegroup = DynamicModelMultipleChoiceField( + queryset=SiteGroup.objects.all(), + required=False, + label=_('Site group'), + fetch_trigger='open' + ) + site = DynamicModelMultipleChoiceField( + queryset=Site.objects.all(), + required=False, + label=_('Site'), + fetch_trigger='open' + ) + location = DynamicModelMultipleChoiceField( + queryset=Location.objects.all(), + required=False, + label=_('Location'), + fetch_trigger='open' + ) + rack = DynamicModelMultipleChoiceField( + queryset=Rack.objects.all(), + required=False, + label=_('Rack'), + fetch_trigger='open' + ) + + +class VLANFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilterForm): + model = VLAN + field_groups = [ + ['q', 'tag'], + ['region_id', 'site_group_id', 'site_id'], + ['group_id', 'status', 'role_id'], + ['tenant_group_id', 'tenant_id'], + ] + q = forms.CharField( + required=False, + widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), + label=_('Search') + ) + region_id = DynamicModelMultipleChoiceField( + queryset=Region.objects.all(), + required=False, + label=_('Region'), + fetch_trigger='open' + ) + site_group_id = DynamicModelMultipleChoiceField( + queryset=SiteGroup.objects.all(), + required=False, + label=_('Site group'), + fetch_trigger='open' + ) + site_id = DynamicModelMultipleChoiceField( + queryset=Site.objects.all(), + required=False, + null_option='None', + query_params={ + 'region': '$region' + }, + label=_('Site'), + fetch_trigger='open' + ) + group_id = DynamicModelMultipleChoiceField( + queryset=VLANGroup.objects.all(), + required=False, + null_option='None', + query_params={ + 'region': '$region' + }, + label=_('VLAN group'), + fetch_trigger='open' + ) + status = forms.MultipleChoiceField( + choices=VLANStatusChoices, + required=False, + widget=StaticSelectMultiple() + ) + role_id = DynamicModelMultipleChoiceField( + queryset=Role.objects.all(), + required=False, + null_option='None', + label=_('Role'), + fetch_trigger='open' + ) + tag = TagFilterField(model) + + +class ServiceFilterForm(BootstrapMixin, CustomFieldModelFilterForm): + model = Service + field_groups = ( + ('q', 'tag'), + ('protocol', 'port'), + ) + q = forms.CharField( + required=False, + widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), + label=_('Search') + ) + protocol = forms.ChoiceField( + choices=add_blank_choice(ServiceProtocolChoices), + required=False, + widget=StaticSelectMultiple() + ) + port = forms.IntegerField( + required=False, + ) + tag = TagFilterField(model) diff --git a/netbox/ipam/forms/models.py b/netbox/ipam/forms/models.py new file mode 100644 index 000000000..d28f7b3ae --- /dev/null +++ b/netbox/ipam/forms/models.py @@ -0,0 +1,691 @@ +from django import forms +from django.contrib.contenttypes.models import ContentType + +from dcim.models import Device, Interface, Location, Rack, Region, Site, SiteGroup +from extras.forms import CustomFieldModelForm +from extras.models import Tag +from ipam.constants import * +from ipam.models import * +from tenancy.forms import TenancyForm +from utilities.forms import ( + BootstrapMixin, ContentTypeChoiceField, DatePicker, DynamicModelChoiceField, DynamicModelMultipleChoiceField, + NumericArrayField, SlugField, StaticSelect, StaticSelectMultiple, +) +from virtualization.models import Cluster, ClusterGroup, VirtualMachine, VMInterface + +__all__ = ( + 'AggregateForm', + 'IPAddressAssignForm', + 'IPAddressBulkAddForm', + 'IPAddressForm', + 'IPRangeForm', + 'PrefixForm', + 'RIRForm', + 'RoleForm', + 'RouteTargetForm', + 'ServiceForm', + 'VLANForm', + 'VLANGroupForm', + 'VRFForm', +) + + +class VRFForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): + import_targets = DynamicModelMultipleChoiceField( + queryset=RouteTarget.objects.all(), + required=False + ) + export_targets = DynamicModelMultipleChoiceField( + queryset=RouteTarget.objects.all(), + required=False + ) + tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), + required=False + ) + + class Meta: + model = VRF + fields = [ + 'name', 'rd', 'enforce_unique', 'description', 'import_targets', 'export_targets', 'tenant_group', 'tenant', + 'tags', + ] + fieldsets = ( + ('VRF', ('name', 'rd', 'enforce_unique', 'description', 'tags')), + ('Route Targets', ('import_targets', 'export_targets')), + ('Tenancy', ('tenant_group', 'tenant')), + ) + labels = { + 'rd': "RD", + } + help_texts = { + 'rd': "Route distinguisher in any format", + } + + +class RouteTargetForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): + tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), + required=False + ) + + class Meta: + model = RouteTarget + fields = [ + 'name', 'description', 'tenant_group', 'tenant', 'tags', + ] + fieldsets = ( + ('Route Target', ('name', 'description', 'tags')), + ('Tenancy', ('tenant_group', 'tenant')), + ) + + +class RIRForm(BootstrapMixin, CustomFieldModelForm): + slug = SlugField() + + class Meta: + model = RIR + fields = [ + 'name', 'slug', 'is_private', 'description', + ] + + +class AggregateForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): + rir = DynamicModelChoiceField( + queryset=RIR.objects.all(), + label='RIR' + ) + tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), + required=False + ) + + class Meta: + model = Aggregate + fields = [ + 'prefix', 'rir', 'date_added', 'description', 'tenant_group', 'tenant', 'tags', + ] + fieldsets = ( + ('Aggregate', ('prefix', 'rir', 'date_added', 'description', 'tags')), + ('Tenancy', ('tenant_group', 'tenant')), + ) + help_texts = { + 'prefix': "IPv4 or IPv6 network", + 'rir': "Regional Internet Registry responsible for this prefix", + } + widgets = { + 'date_added': DatePicker(), + } + + +class RoleForm(BootstrapMixin, CustomFieldModelForm): + slug = SlugField() + + class Meta: + model = Role + fields = [ + 'name', 'slug', 'weight', 'description', + ] + + +class PrefixForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): + vrf = DynamicModelChoiceField( + queryset=VRF.objects.all(), + required=False, + label='VRF' + ) + region = DynamicModelChoiceField( + queryset=Region.objects.all(), + required=False, + initial_params={ + 'sites': '$site' + } + ) + site_group = DynamicModelChoiceField( + queryset=SiteGroup.objects.all(), + required=False, + initial_params={ + 'sites': '$site' + } + ) + site = DynamicModelChoiceField( + queryset=Site.objects.all(), + required=False, + null_option='None', + query_params={ + 'region_id': '$region', + 'group_id': '$site_group', + } + ) + vlan_group = DynamicModelChoiceField( + queryset=VLANGroup.objects.all(), + required=False, + label='VLAN group', + null_option='None', + query_params={ + 'site_id': '$site' + }, + initial_params={ + 'vlans': '$vlan' + } + ) + vlan = DynamicModelChoiceField( + queryset=VLAN.objects.all(), + required=False, + label='VLAN', + query_params={ + 'site_id': '$site', + 'group_id': '$vlan_group', + } + ) + role = DynamicModelChoiceField( + queryset=Role.objects.all(), + required=False + ) + tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), + required=False + ) + + class Meta: + model = Prefix + fields = [ + 'prefix', 'vrf', 'site', 'vlan', 'status', 'role', 'is_pool', 'mark_utilized', 'description', + 'tenant_group', 'tenant', 'tags', + ] + fieldsets = ( + ('Prefix', ('prefix', 'status', 'vrf', 'role', 'is_pool', 'mark_utilized', 'description', 'tags')), + ('Site/VLAN Assignment', ('region', 'site_group', 'site', 'vlan_group', 'vlan')), + ('Tenancy', ('tenant_group', 'tenant')), + ) + widgets = { + 'status': StaticSelect(), + } + + +class IPRangeForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): + vrf = DynamicModelChoiceField( + queryset=VRF.objects.all(), + required=False, + label='VRF' + ) + role = DynamicModelChoiceField( + queryset=Role.objects.all(), + required=False + ) + tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), + required=False + ) + + class Meta: + model = IPRange + fields = [ + 'vrf', 'start_address', 'end_address', 'status', 'role', 'description', 'tenant_group', 'tenant', 'tags', + ] + fieldsets = ( + ('IP Range', ('vrf', 'start_address', 'end_address', 'role', 'status', 'description', 'tags')), + ('Tenancy', ('tenant_group', 'tenant')), + ) + widgets = { + 'status': StaticSelect(), + } + + +class IPAddressForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): + device = DynamicModelChoiceField( + queryset=Device.objects.all(), + required=False, + initial_params={ + 'interfaces': '$interface' + } + ) + interface = DynamicModelChoiceField( + queryset=Interface.objects.all(), + required=False, + query_params={ + 'device_id': '$device' + } + ) + virtual_machine = DynamicModelChoiceField( + queryset=VirtualMachine.objects.all(), + required=False, + initial_params={ + 'interfaces': '$vminterface' + } + ) + vminterface = DynamicModelChoiceField( + queryset=VMInterface.objects.all(), + required=False, + label='Interface', + query_params={ + 'virtual_machine_id': '$virtual_machine' + } + ) + vrf = DynamicModelChoiceField( + queryset=VRF.objects.all(), + required=False, + label='VRF' + ) + nat_region = DynamicModelChoiceField( + queryset=Region.objects.all(), + required=False, + label='Region', + initial_params={ + 'sites': '$nat_site' + } + ) + nat_site_group = DynamicModelChoiceField( + queryset=SiteGroup.objects.all(), + required=False, + label='Site group', + initial_params={ + 'sites': '$nat_site' + } + ) + nat_site = DynamicModelChoiceField( + queryset=Site.objects.all(), + required=False, + label='Site', + query_params={ + 'region_id': '$nat_region', + 'group_id': '$nat_site_group', + } + ) + nat_rack = DynamicModelChoiceField( + queryset=Rack.objects.all(), + required=False, + label='Rack', + null_option='None', + query_params={ + 'site_id': '$site' + } + ) + nat_device = DynamicModelChoiceField( + queryset=Device.objects.all(), + required=False, + label='Device', + query_params={ + 'site_id': '$site', + 'rack_id': '$nat_rack', + } + ) + nat_cluster = DynamicModelChoiceField( + queryset=Cluster.objects.all(), + required=False, + label='Cluster' + ) + nat_virtual_machine = DynamicModelChoiceField( + queryset=VirtualMachine.objects.all(), + required=False, + label='Virtual Machine', + query_params={ + 'cluster_id': '$nat_cluster', + } + ) + nat_vrf = DynamicModelChoiceField( + queryset=VRF.objects.all(), + required=False, + label='VRF' + ) + nat_inside = DynamicModelChoiceField( + queryset=IPAddress.objects.all(), + required=False, + label='IP Address', + query_params={ + 'device_id': '$nat_device', + 'virtual_machine_id': '$nat_virtual_machine', + 'vrf_id': '$nat_vrf', + } + ) + primary_for_parent = forms.BooleanField( + required=False, + label='Make this the primary IP for the device/VM' + ) + tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), + required=False + ) + + class Meta: + model = IPAddress + fields = [ + 'address', 'vrf', 'status', 'role', 'dns_name', 'description', 'primary_for_parent', 'nat_site', 'nat_rack', + 'nat_device', 'nat_cluster', 'nat_virtual_machine', 'nat_vrf', 'nat_inside', 'tenant_group', 'tenant', + 'tags', + ] + widgets = { + 'status': StaticSelect(), + 'role': StaticSelect(), + } + + def __init__(self, *args, **kwargs): + + # Initialize helper selectors + instance = kwargs.get('instance') + initial = kwargs.get('initial', {}).copy() + if instance: + if type(instance.assigned_object) is Interface: + initial['interface'] = instance.assigned_object + elif type(instance.assigned_object) is VMInterface: + initial['vminterface'] = instance.assigned_object + if instance.nat_inside: + nat_inside_parent = instance.nat_inside.assigned_object + if type(nat_inside_parent) is Interface: + initial['nat_site'] = nat_inside_parent.device.site.pk + if nat_inside_parent.device.rack: + initial['nat_rack'] = nat_inside_parent.device.rack.pk + initial['nat_device'] = nat_inside_parent.device.pk + elif type(nat_inside_parent) is VMInterface: + initial['nat_cluster'] = nat_inside_parent.virtual_machine.cluster.pk + initial['nat_virtual_machine'] = nat_inside_parent.virtual_machine.pk + kwargs['initial'] = initial + + super().__init__(*args, **kwargs) + + # Initialize primary_for_parent if IP address is already assigned + if self.instance.pk and self.instance.assigned_object: + parent = self.instance.assigned_object.parent_object + if ( + self.instance.address.version == 4 and parent.primary_ip4_id == self.instance.pk or + self.instance.address.version == 6 and parent.primary_ip6_id == self.instance.pk + ): + self.initial['primary_for_parent'] = True + + def clean(self): + super().clean() + + # Cannot select both a device interface and a VM interface + if self.cleaned_data.get('interface') and self.cleaned_data.get('vminterface'): + raise forms.ValidationError("Cannot select both a device interface and a virtual machine interface") + self.instance.assigned_object = self.cleaned_data.get('interface') or self.cleaned_data.get('vminterface') + + # Primary IP assignment is only available if an interface has been assigned. + interface = self.cleaned_data.get('interface') or self.cleaned_data.get('vminterface') + if self.cleaned_data.get('primary_for_parent') and not interface: + self.add_error( + 'primary_for_parent', "Only IP addresses assigned to an interface can be designated as primary IPs." + ) + + def save(self, *args, **kwargs): + ipaddress = super().save(*args, **kwargs) + + # Assign/clear this IPAddress as the primary for the associated Device/VirtualMachine. + interface = self.instance.assigned_object + if interface: + parent = interface.parent_object + if self.cleaned_data['primary_for_parent']: + if ipaddress.address.version == 4: + parent.primary_ip4 = ipaddress + else: + parent.primary_ip6 = ipaddress + parent.save() + elif ipaddress.address.version == 4 and parent.primary_ip4 == ipaddress: + parent.primary_ip4 = None + parent.save() + elif ipaddress.address.version == 6 and parent.primary_ip6 == ipaddress: + parent.primary_ip6 = None + parent.save() + + return ipaddress + + +class IPAddressBulkAddForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): + vrf = DynamicModelChoiceField( + queryset=VRF.objects.all(), + required=False, + label='VRF' + ) + tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), + required=False + ) + + class Meta: + model = IPAddress + fields = [ + 'address', 'vrf', 'status', 'role', 'dns_name', 'description', 'tenant_group', 'tenant', 'tags', + ] + widgets = { + 'status': StaticSelect(), + 'role': StaticSelect(), + } + + +class IPAddressAssignForm(BootstrapMixin, forms.Form): + vrf_id = DynamicModelChoiceField( + queryset=VRF.objects.all(), + required=False, + label='VRF' + ) + q = forms.CharField( + required=False, + label='Search', + ) + + +class VLANGroupForm(BootstrapMixin, CustomFieldModelForm): + scope_type = ContentTypeChoiceField( + queryset=ContentType.objects.filter(model__in=VLANGROUP_SCOPE_TYPES), + required=False, + widget=StaticSelect + ) + region = DynamicModelChoiceField( + queryset=Region.objects.all(), + required=False, + initial_params={ + 'sites': '$site' + } + ) + sitegroup = DynamicModelChoiceField( + queryset=SiteGroup.objects.all(), + required=False, + initial_params={ + 'sites': '$site' + }, + label='Site group' + ) + site = DynamicModelChoiceField( + queryset=Site.objects.all(), + required=False, + initial_params={ + 'locations': '$location' + }, + query_params={ + 'region_id': '$region', + 'group_id': '$sitegroup', + } + ) + location = DynamicModelChoiceField( + queryset=Location.objects.all(), + required=False, + initial_params={ + 'racks': '$rack' + }, + query_params={ + 'site_id': '$site', + } + ) + rack = DynamicModelChoiceField( + queryset=Rack.objects.all(), + required=False, + query_params={ + 'site_id': '$site', + 'location_id': '$location', + } + ) + clustergroup = DynamicModelChoiceField( + queryset=ClusterGroup.objects.all(), + required=False, + initial_params={ + 'clusters': '$cluster' + }, + label='Cluster group' + ) + cluster = DynamicModelChoiceField( + queryset=Cluster.objects.all(), + required=False, + query_params={ + 'group_id': '$clustergroup', + } + ) + slug = SlugField() + + class Meta: + model = VLANGroup + fields = [ + 'name', 'slug', 'description', 'scope_type', 'region', 'sitegroup', 'site', 'location', 'rack', + 'clustergroup', 'cluster', + ] + fieldsets = ( + ('VLAN Group', ('name', 'slug', 'description')), + ('Scope', ('scope_type', 'region', 'sitegroup', 'site', 'location', 'rack', 'clustergroup', 'cluster')), + ) + widgets = { + 'scope_type': StaticSelect, + } + + def __init__(self, *args, **kwargs): + instance = kwargs.get('instance') + initial = kwargs.get('initial', {}) + + if instance is not None and instance.scope: + initial[instance.scope_type.model] = instance.scope + + kwargs['initial'] = initial + + super().__init__(*args, **kwargs) + + def clean(self): + super().clean() + + # Assign scope based on scope_type + if self.cleaned_data.get('scope_type'): + scope_field = self.cleaned_data['scope_type'].model + self.instance.scope = self.cleaned_data.get(scope_field) + else: + self.instance.scope_id = None + + +class VLANForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): + # VLANGroup assignment fields + scope_type = forms.ChoiceField( + choices=( + ('', ''), + ('dcim.region', 'Region'), + ('dcim.sitegroup', 'Site group'), + ('dcim.site', 'Site'), + ('dcim.location', 'Location'), + ('dcim.rack', 'Rack'), + ('virtualization.clustergroup', 'Cluster group'), + ('virtualization.cluster', 'Cluster'), + ), + required=False, + widget=StaticSelect, + label='Group scope' + ) + group = DynamicModelChoiceField( + queryset=VLANGroup.objects.all(), + required=False, + query_params={ + 'scope_type': '$scope_type', + }, + label='VLAN Group' + ) + + # Site assignment fields + region = DynamicModelChoiceField( + queryset=Region.objects.all(), + required=False, + initial_params={ + 'sites': '$site' + }, + label='Region' + ) + sitegroup = DynamicModelChoiceField( + queryset=SiteGroup.objects.all(), + required=False, + initial_params={ + 'sites': '$site' + }, + label='Site group' + ) + site = DynamicModelChoiceField( + queryset=Site.objects.all(), + required=False, + null_option='None', + query_params={ + 'region_id': '$region', + 'group_id': '$sitegroup', + } + ) + + # Other fields + role = DynamicModelChoiceField( + queryset=Role.objects.all(), + required=False + ) + tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), + required=False + ) + + class Meta: + model = VLAN + fields = [ + 'site', 'group', 'vid', 'name', 'status', 'role', 'description', 'tenant_group', 'tenant', 'tags', + ] + help_texts = { + 'site': "Leave blank if this VLAN spans multiple sites", + 'group': "VLAN group (optional)", + 'vid': "Configured VLAN ID", + 'name': "Configured VLAN name", + 'status': "Operational status of this VLAN", + 'role': "The primary function of this VLAN", + } + widgets = { + 'status': StaticSelect(), + } + + +class ServiceForm(BootstrapMixin, CustomFieldModelForm): + ports = NumericArrayField( + base_field=forms.IntegerField( + min_value=SERVICE_PORT_MIN, + max_value=SERVICE_PORT_MAX + ), + help_text="Comma-separated list of one or more port numbers. A range may be specified using a hyphen." + ) + tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), + required=False + ) + + class Meta: + model = Service + fields = [ + 'name', 'protocol', 'ports', 'ipaddresses', 'description', 'tags', + ] + help_texts = { + 'ipaddresses': "IP address assignment is optional. If no IPs are selected, the service is assumed to be " + "reachable via all IPs assigned to the device.", + } + widgets = { + 'protocol': StaticSelect(), + 'ipaddresses': StaticSelectMultiple(), + } + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + # Limit IP address choices to those assigned to interfaces of the parent device/VM + if self.instance.device: + self.fields['ipaddresses'].queryset = IPAddress.objects.filter( + interface__in=self.instance.device.vc_interfaces().values_list('id', flat=True) + ) + elif self.instance.virtual_machine: + self.fields['ipaddresses'].queryset = IPAddress.objects.filter( + vminterface__in=self.instance.virtual_machine.interfaces.values_list('id', flat=True) + ) + else: + self.fields['ipaddresses'].choices = [] From df8b76127e20276b534af72d73f7b9951a7c68eb Mon Sep 17 00:00:00 2001 From: Daniel Sheppard Date: Tue, 28 Sep 2021 09:06:35 -0500 Subject: [PATCH 23/37] Fixes #7374 - Adds query param on position for rack face --- netbox/dcim/forms.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index 233d45220..50b960e9d 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -2241,7 +2241,7 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): api_url='/api/dcim/racks/{{rack}}/elevation/', attrs={ 'disabled-indicator': 'device', - 'data-query-param-face': "[\"$face\"]" + 'data-dynamic-params': '[{"fieldName":"face","queryParam":"face"}]' } ) ) From db522f96be87ee2d4a77bc1bbab5e12e2e7146b7 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 28 Sep 2021 10:25:07 -0400 Subject: [PATCH 24/37] Refactor virtualization forms --- netbox/virtualization/forms.py | 971 ------------------- netbox/virtualization/forms/__init__.py | 6 + netbox/virtualization/forms/bulk_create.py | 30 + netbox/virtualization/forms/bulk_edit.py | 239 +++++ netbox/virtualization/forms/bulk_import.py | 125 +++ netbox/virtualization/forms/filtersets.py | 237 +++++ netbox/virtualization/forms/models.py | 324 +++++++ netbox/virtualization/forms/object_create.py | 74 ++ 8 files changed, 1035 insertions(+), 971 deletions(-) delete mode 100644 netbox/virtualization/forms.py create mode 100644 netbox/virtualization/forms/__init__.py create mode 100644 netbox/virtualization/forms/bulk_create.py create mode 100644 netbox/virtualization/forms/bulk_edit.py create mode 100644 netbox/virtualization/forms/bulk_import.py create mode 100644 netbox/virtualization/forms/filtersets.py create mode 100644 netbox/virtualization/forms/models.py create mode 100644 netbox/virtualization/forms/object_create.py diff --git a/netbox/virtualization/forms.py b/netbox/virtualization/forms.py deleted file mode 100644 index 74bf32e54..000000000 --- a/netbox/virtualization/forms.py +++ /dev/null @@ -1,971 +0,0 @@ -from django import forms -from django.contrib.contenttypes.models import ContentType -from django.core.exceptions import ValidationError -from django.utils.translation import gettext as _ - -from dcim.choices import InterfaceModeChoices -from dcim.constants import INTERFACE_MTU_MAX, INTERFACE_MTU_MIN -from dcim.forms.models import INTERFACE_MODE_HELP_TEXT -from dcim.forms.common import InterfaceCommonForm -from dcim.models import Device, DeviceRole, Platform, Rack, Region, Site, SiteGroup -from extras.forms import ( - AddRemoveTagsForm, CustomFieldModelBulkEditForm, CustomFieldModelCSVForm, CustomFieldModelForm, - CustomFieldModelFilterForm, CustomFieldsMixin, LocalConfigContextFilterForm, -) -from extras.models import Tag -from ipam.models import IPAddress, VLAN, VLANGroup -from tenancy.forms import TenancyFilterForm, TenancyForm -from tenancy.models import Tenant -from utilities.forms import ( - add_blank_choice, BootstrapMixin, BulkEditNullBooleanSelect, BulkRenameForm, CommentField, ConfirmationForm, - CSVChoiceField, CSVModelChoiceField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, ExpandableNameField, - form_from_model, JSONField, SlugField, SmallTextarea, StaticSelect, StaticSelectMultiple, TagFilterField, - BOOLEAN_WITH_BLANK_CHOICES, -) -from .choices import * -from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface - - -# -# Cluster types -# - -class ClusterTypeForm(BootstrapMixin, CustomFieldModelForm): - slug = SlugField() - - class Meta: - model = ClusterType - fields = [ - 'name', 'slug', 'description', - ] - - -class ClusterTypeCSVForm(CustomFieldModelCSVForm): - slug = SlugField() - - class Meta: - model = ClusterType - fields = ('name', 'slug', 'description') - - -class ClusterTypeBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm): - pk = forms.ModelMultipleChoiceField( - queryset=ClusterType.objects.all(), - widget=forms.MultipleHiddenInput - ) - description = forms.CharField( - max_length=200, - required=False - ) - - class Meta: - nullable_fields = ['description'] - - -class ClusterTypeFilterForm(BootstrapMixin, CustomFieldModelFilterForm): - model = ClusterType - field_groups = [ - ['q'], - ] - q = forms.CharField( - required=False, - widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), - label=_('Search') - ) - - -# -# Cluster groups -# - -class ClusterGroupForm(BootstrapMixin, CustomFieldModelForm): - slug = SlugField() - - class Meta: - model = ClusterGroup - fields = [ - 'name', 'slug', 'description', - ] - - -class ClusterGroupCSVForm(CustomFieldModelCSVForm): - slug = SlugField() - - class Meta: - model = ClusterGroup - fields = ('name', 'slug', 'description') - - -class ClusterGroupBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm): - pk = forms.ModelMultipleChoiceField( - queryset=ClusterGroup.objects.all(), - widget=forms.MultipleHiddenInput - ) - description = forms.CharField( - max_length=200, - required=False - ) - - class Meta: - nullable_fields = ['description'] - - -class ClusterGroupFilterForm(BootstrapMixin, CustomFieldModelFilterForm): - model = ClusterGroup - field_groups = [ - ['q'], - ] - q = forms.CharField( - required=False, - widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), - label=_('Search') - ) - - -# -# Clusters -# - -class ClusterForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): - type = DynamicModelChoiceField( - queryset=ClusterType.objects.all() - ) - group = DynamicModelChoiceField( - queryset=ClusterGroup.objects.all(), - required=False - ) - region = DynamicModelChoiceField( - queryset=Region.objects.all(), - required=False, - initial_params={ - 'sites': '$site' - } - ) - site_group = DynamicModelChoiceField( - queryset=SiteGroup.objects.all(), - required=False, - initial_params={ - 'sites': '$site' - } - ) - site = DynamicModelChoiceField( - queryset=Site.objects.all(), - required=False, - query_params={ - 'region_id': '$region', - 'group_id': '$site_group', - } - ) - comments = CommentField() - tags = DynamicModelMultipleChoiceField( - queryset=Tag.objects.all(), - required=False - ) - - class Meta: - model = Cluster - fields = ( - 'name', 'type', 'group', 'tenant', 'region', 'site_group', 'site', 'comments', 'tags', - ) - fieldsets = ( - ('Cluster', ('name', 'type', 'group', 'region', 'site_group', 'site', 'tags')), - ('Tenancy', ('tenant_group', 'tenant')), - ) - - -class ClusterCSVForm(CustomFieldModelCSVForm): - type = CSVModelChoiceField( - queryset=ClusterType.objects.all(), - to_field_name='name', - help_text='Type of cluster' - ) - group = CSVModelChoiceField( - queryset=ClusterGroup.objects.all(), - to_field_name='name', - required=False, - help_text='Assigned cluster group' - ) - site = CSVModelChoiceField( - queryset=Site.objects.all(), - to_field_name='name', - required=False, - help_text='Assigned site' - ) - tenant = CSVModelChoiceField( - queryset=Tenant.objects.all(), - to_field_name='name', - required=False, - help_text='Assigned tenant' - ) - - class Meta: - model = Cluster - fields = ('name', 'type', 'group', 'site', 'comments') - - -class ClusterBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm): - pk = forms.ModelMultipleChoiceField( - queryset=Cluster.objects.all(), - widget=forms.MultipleHiddenInput() - ) - type = DynamicModelChoiceField( - queryset=ClusterType.objects.all(), - required=False - ) - group = DynamicModelChoiceField( - queryset=ClusterGroup.objects.all(), - required=False - ) - tenant = DynamicModelChoiceField( - queryset=Tenant.objects.all(), - required=False - ) - region = DynamicModelChoiceField( - queryset=Region.objects.all(), - required=False, - ) - site_group = DynamicModelChoiceField( - queryset=SiteGroup.objects.all(), - required=False, - ) - site = DynamicModelChoiceField( - queryset=Site.objects.all(), - required=False, - query_params={ - 'region_id': '$region', - 'group_id': '$site_group', - } - ) - comments = CommentField( - widget=SmallTextarea, - label='Comments' - ) - - class Meta: - nullable_fields = [ - 'group', 'site', 'comments', 'tenant', - ] - - -class ClusterFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilterForm): - model = Cluster - field_order = [ - 'q', 'type_id', 'region_id', 'site_id', 'group_id', 'tenant_group_id', 'tenant_id', - ] - field_groups = [ - ['q', 'tag'], - ['group_id', 'type_id'], - ['region_id', 'site_group_id', 'site_id'], - ['tenant_group_id', 'tenant_id'], - ] - q = forms.CharField( - required=False, - widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), - label=_('Search') - ) - type_id = DynamicModelMultipleChoiceField( - queryset=ClusterType.objects.all(), - required=False, - label=_('Type'), - fetch_trigger='open' - ) - region_id = DynamicModelMultipleChoiceField( - queryset=Region.objects.all(), - required=False, - label=_('Region'), - fetch_trigger='open' - ) - site_group_id = DynamicModelMultipleChoiceField( - queryset=SiteGroup.objects.all(), - required=False, - label=_('Site group'), - fetch_trigger='open' - ) - site_id = DynamicModelMultipleChoiceField( - queryset=Site.objects.all(), - required=False, - null_option='None', - query_params={ - 'region_id': '$region_id', - 'site_group_id': '$site_group_id', - }, - label=_('Site'), - fetch_trigger='open' - ) - group_id = DynamicModelMultipleChoiceField( - queryset=ClusterGroup.objects.all(), - required=False, - null_option='None', - label=_('Group'), - fetch_trigger='open' - ) - tag = TagFilterField(model) - - -class ClusterAddDevicesForm(BootstrapMixin, forms.Form): - region = DynamicModelChoiceField( - queryset=Region.objects.all(), - required=False, - null_option='None' - ) - site_group = DynamicModelChoiceField( - queryset=SiteGroup.objects.all(), - required=False, - null_option='None' - ) - site = DynamicModelChoiceField( - queryset=Site.objects.all(), - required=False, - query_params={ - 'region_id': '$region', - 'group_id': '$site_group', - } - ) - rack = DynamicModelChoiceField( - queryset=Rack.objects.all(), - required=False, - null_option='None', - query_params={ - 'site_id': '$site' - } - ) - devices = DynamicModelMultipleChoiceField( - queryset=Device.objects.all(), - query_params={ - 'site_id': '$site', - 'rack_id': '$rack', - 'cluster_id': 'null', - } - ) - - class Meta: - fields = [ - 'region', 'site', 'rack', 'devices', - ] - - def __init__(self, cluster, *args, **kwargs): - - self.cluster = cluster - - super().__init__(*args, **kwargs) - - self.fields['devices'].choices = [] - - def clean(self): - super().clean() - - # If the Cluster is assigned to a Site, all Devices must be assigned to that Site. - if self.cluster.site is not None: - for device in self.cleaned_data.get('devices', []): - if device.site != self.cluster.site: - raise ValidationError({ - 'devices': "{} belongs to a different site ({}) than the cluster ({})".format( - device, device.site, self.cluster.site - ) - }) - - -class ClusterRemoveDevicesForm(ConfirmationForm): - pk = forms.ModelMultipleChoiceField( - queryset=Device.objects.all(), - widget=forms.MultipleHiddenInput() - ) - - -# -# Virtual Machines -# - -class VirtualMachineForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): - cluster_group = DynamicModelChoiceField( - queryset=ClusterGroup.objects.all(), - required=False, - null_option='None', - initial_params={ - 'clusters': '$cluster' - } - ) - cluster = DynamicModelChoiceField( - queryset=Cluster.objects.all(), - query_params={ - 'group_id': '$cluster_group' - } - ) - role = DynamicModelChoiceField( - queryset=DeviceRole.objects.all(), - required=False, - query_params={ - "vm_role": "True" - } - ) - platform = DynamicModelChoiceField( - queryset=Platform.objects.all(), - required=False - ) - local_context_data = JSONField( - required=False, - label='' - ) - tags = DynamicModelMultipleChoiceField( - queryset=Tag.objects.all(), - required=False - ) - - class Meta: - model = VirtualMachine - fields = [ - 'name', 'status', 'cluster_group', 'cluster', 'role', 'tenant_group', 'tenant', 'platform', 'primary_ip4', - 'primary_ip6', 'vcpus', 'memory', 'disk', 'comments', 'tags', 'local_context_data', - ] - fieldsets = ( - ('Virtual Machine', ('name', 'role', 'status', 'tags')), - ('Cluster', ('cluster_group', 'cluster')), - ('Tenancy', ('tenant_group', 'tenant')), - ('Management', ('platform', 'primary_ip4', 'primary_ip6')), - ('Resources', ('vcpus', 'memory', 'disk')), - ('Config Context', ('local_context_data',)), - ) - help_texts = { - 'local_context_data': "Local config context data overwrites all sources contexts in the final rendered " - "config context", - } - widgets = { - "status": StaticSelect(), - 'primary_ip4': StaticSelect(), - 'primary_ip6': StaticSelect(), - } - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - if self.instance.pk: - - # Compile list of choices for primary IPv4 and IPv6 addresses - for family in [4, 6]: - ip_choices = [(None, '---------')] - - # Gather PKs of all interfaces belonging to this VM - interface_ids = self.instance.interfaces.values_list('pk', flat=True) - - # Collect interface IPs - interface_ips = IPAddress.objects.filter( - address__family=family, - assigned_object_type=ContentType.objects.get_for_model(VMInterface), - assigned_object_id__in=interface_ids - ) - if interface_ips: - ip_list = [(ip.id, f'{ip.address} ({ip.assigned_object})') for ip in interface_ips] - ip_choices.append(('Interface IPs', ip_list)) - # Collect NAT IPs - nat_ips = IPAddress.objects.prefetch_related('nat_inside').filter( - address__family=family, - nat_inside__assigned_object_type=ContentType.objects.get_for_model(VMInterface), - nat_inside__assigned_object_id__in=interface_ids - ) - if nat_ips: - ip_list = [(ip.id, f'{ip.address} (NAT)') for ip in nat_ips] - ip_choices.append(('NAT IPs', ip_list)) - self.fields['primary_ip{}'.format(family)].choices = ip_choices - - else: - - # An object that doesn't exist yet can't have any IPs assigned to it - self.fields['primary_ip4'].choices = [] - self.fields['primary_ip4'].widget.attrs['readonly'] = True - self.fields['primary_ip6'].choices = [] - self.fields['primary_ip6'].widget.attrs['readonly'] = True - - -class VirtualMachineCSVForm(CustomFieldModelCSVForm): - status = CSVChoiceField( - choices=VirtualMachineStatusChoices, - required=False, - help_text='Operational status of device' - ) - cluster = CSVModelChoiceField( - queryset=Cluster.objects.all(), - to_field_name='name', - help_text='Assigned cluster' - ) - role = CSVModelChoiceField( - queryset=DeviceRole.objects.filter( - vm_role=True - ), - required=False, - to_field_name='name', - help_text='Functional role' - ) - tenant = CSVModelChoiceField( - queryset=Tenant.objects.all(), - required=False, - to_field_name='name', - help_text='Assigned tenant' - ) - platform = CSVModelChoiceField( - queryset=Platform.objects.all(), - required=False, - to_field_name='name', - help_text='Assigned platform' - ) - - class Meta: - model = VirtualMachine - fields = ( - 'name', 'status', 'role', 'cluster', 'tenant', 'platform', 'vcpus', 'memory', 'disk', 'comments', - ) - - -class VirtualMachineBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm): - pk = forms.ModelMultipleChoiceField( - queryset=VirtualMachine.objects.all(), - widget=forms.MultipleHiddenInput() - ) - status = forms.ChoiceField( - choices=add_blank_choice(VirtualMachineStatusChoices), - required=False, - initial='', - widget=StaticSelect(), - ) - cluster = DynamicModelChoiceField( - queryset=Cluster.objects.all(), - required=False - ) - role = DynamicModelChoiceField( - queryset=DeviceRole.objects.filter( - vm_role=True - ), - required=False, - query_params={ - "vm_role": "True" - } - ) - tenant = DynamicModelChoiceField( - queryset=Tenant.objects.all(), - required=False - ) - platform = DynamicModelChoiceField( - queryset=Platform.objects.all(), - required=False - ) - vcpus = forms.IntegerField( - required=False, - label='vCPUs' - ) - memory = forms.IntegerField( - required=False, - label='Memory (MB)' - ) - disk = forms.IntegerField( - required=False, - label='Disk (GB)' - ) - comments = CommentField( - widget=SmallTextarea, - label='Comments' - ) - - class Meta: - nullable_fields = [ - 'role', 'tenant', 'platform', 'vcpus', 'memory', 'disk', 'comments', - ] - - -class VirtualMachineFilterForm( - BootstrapMixin, - LocalConfigContextFilterForm, - TenancyFilterForm, - CustomFieldModelFilterForm -): - model = VirtualMachine - field_groups = [ - ['q', 'tag'], - ['cluster_group_id', 'cluster_type_id', 'cluster_id'], - ['region_id', 'site_group_id', 'site_id'], - ['status', 'role_id', 'platform_id', 'mac_address', 'has_primary_ip', 'local_context_data'], - ['tenant_group_id', 'tenant_id'], - ] - q = forms.CharField( - required=False, - widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), - label=_('Search') - ) - cluster_group_id = DynamicModelMultipleChoiceField( - queryset=ClusterGroup.objects.all(), - required=False, - null_option='None', - label=_('Cluster group'), - fetch_trigger='open' - ) - cluster_type_id = DynamicModelMultipleChoiceField( - queryset=ClusterType.objects.all(), - required=False, - null_option='None', - label=_('Cluster type'), - fetch_trigger='open' - ) - cluster_id = DynamicModelMultipleChoiceField( - queryset=Cluster.objects.all(), - required=False, - label=_('Cluster'), - fetch_trigger='open' - ) - region_id = DynamicModelMultipleChoiceField( - queryset=Region.objects.all(), - required=False, - label=_('Region'), - fetch_trigger='open' - ) - site_group_id = DynamicModelMultipleChoiceField( - queryset=SiteGroup.objects.all(), - required=False, - label=_('Site group'), - fetch_trigger='open' - ) - site_id = DynamicModelMultipleChoiceField( - queryset=Site.objects.all(), - required=False, - null_option='None', - query_params={ - 'region_id': '$region_id', - 'group_id': '$site_group_id', - }, - label=_('Site'), - fetch_trigger='open' - ) - role_id = DynamicModelMultipleChoiceField( - queryset=DeviceRole.objects.all(), - required=False, - null_option='None', - query_params={ - 'vm_role': "True" - }, - label=_('Role'), - fetch_trigger='open' - ) - status = forms.MultipleChoiceField( - choices=VirtualMachineStatusChoices, - required=False, - widget=StaticSelectMultiple() - ) - platform_id = DynamicModelMultipleChoiceField( - queryset=Platform.objects.all(), - required=False, - null_option='None', - label=_('Platform'), - fetch_trigger='open' - ) - mac_address = forms.CharField( - required=False, - label='MAC address' - ) - has_primary_ip = forms.NullBooleanField( - required=False, - label='Has a primary IP', - widget=StaticSelect( - choices=BOOLEAN_WITH_BLANK_CHOICES - ) - ) - tag = TagFilterField(model) - - -# -# VM interfaces -# - -class VMInterfaceForm(BootstrapMixin, InterfaceCommonForm, CustomFieldModelForm): - parent = DynamicModelChoiceField( - queryset=VMInterface.objects.all(), - required=False, - label='Parent interface' - ) - vlan_group = DynamicModelChoiceField( - queryset=VLANGroup.objects.all(), - required=False, - label='VLAN group' - ) - untagged_vlan = DynamicModelChoiceField( - queryset=VLAN.objects.all(), - required=False, - label='Untagged VLAN', - query_params={ - 'group_id': '$vlan_group', - } - ) - tagged_vlans = DynamicModelMultipleChoiceField( - queryset=VLAN.objects.all(), - required=False, - label='Tagged VLANs', - query_params={ - 'group_id': '$vlan_group', - } - ) - tags = DynamicModelMultipleChoiceField( - queryset=Tag.objects.all(), - required=False - ) - - class Meta: - model = VMInterface - fields = [ - 'virtual_machine', 'name', 'enabled', 'parent', 'mac_address', 'mtu', 'description', 'mode', 'tags', - 'untagged_vlan', 'tagged_vlans', - ] - widgets = { - 'virtual_machine': forms.HiddenInput(), - 'mode': StaticSelect() - } - labels = { - 'mode': '802.1Q Mode', - } - help_texts = { - 'mode': INTERFACE_MODE_HELP_TEXT, - } - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - vm_id = self.initial.get('virtual_machine') or self.data.get('virtual_machine') - - # Restrict parent interface assignment by VM - self.fields['parent'].widget.add_query_param('virtual_machine_id', vm_id) - - # Limit VLAN choices by virtual machine - self.fields['untagged_vlan'].widget.add_query_param('available_on_virtualmachine', vm_id) - self.fields['tagged_vlans'].widget.add_query_param('available_on_virtualmachine', vm_id) - - -class VMInterfaceCreateForm(BootstrapMixin, CustomFieldsMixin, InterfaceCommonForm): - model = VMInterface - virtual_machine = DynamicModelChoiceField( - queryset=VirtualMachine.objects.all() - ) - name_pattern = ExpandableNameField( - label='Name' - ) - enabled = forms.BooleanField( - required=False, - initial=True - ) - parent = DynamicModelChoiceField( - queryset=VMInterface.objects.all(), - required=False, - query_params={ - 'virtual_machine_id': '$virtual_machine', - } - ) - mac_address = forms.CharField( - required=False, - label='MAC Address' - ) - description = forms.CharField( - max_length=200, - required=False - ) - mode = forms.ChoiceField( - choices=add_blank_choice(InterfaceModeChoices), - required=False, - widget=StaticSelect(), - ) - untagged_vlan = DynamicModelChoiceField( - queryset=VLAN.objects.all(), - required=False - ) - tagged_vlans = DynamicModelMultipleChoiceField( - queryset=VLAN.objects.all(), - required=False - ) - tags = DynamicModelMultipleChoiceField( - queryset=Tag.objects.all(), - required=False - ) - field_order = ( - 'virtual_machine', 'name_pattern', 'enabled', 'parent', 'mtu', 'mac_address', 'description', 'mode', - 'untagged_vlan', 'tagged_vlans', 'tags' - ) - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - vm_id = self.initial.get('virtual_machine') or self.data.get('virtual_machine') - - # Limit VLAN choices by virtual machine - self.fields['untagged_vlan'].widget.add_query_param('available_on_virtualmachine', vm_id) - self.fields['tagged_vlans'].widget.add_query_param('available_on_virtualmachine', vm_id) - - -class VMInterfaceCSVForm(CustomFieldModelCSVForm): - virtual_machine = CSVModelChoiceField( - queryset=VirtualMachine.objects.all(), - to_field_name='name' - ) - mode = CSVChoiceField( - choices=InterfaceModeChoices, - required=False, - help_text='IEEE 802.1Q operational mode (for L2 interfaces)' - ) - - class Meta: - model = VMInterface - fields = ( - 'virtual_machine', 'name', 'enabled', 'mac_address', 'mtu', 'description', 'mode', - ) - - def clean_enabled(self): - # Make sure enabled is True when it's not included in the uploaded data - if 'enabled' not in self.data: - return True - else: - return self.cleaned_data['enabled'] - - -class VMInterfaceBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm): - pk = forms.ModelMultipleChoiceField( - queryset=VMInterface.objects.all(), - widget=forms.MultipleHiddenInput() - ) - virtual_machine = forms.ModelChoiceField( - queryset=VirtualMachine.objects.all(), - required=False, - disabled=True, - widget=forms.HiddenInput() - ) - parent = DynamicModelChoiceField( - queryset=VMInterface.objects.all(), - required=False - ) - enabled = forms.NullBooleanField( - required=False, - widget=BulkEditNullBooleanSelect() - ) - mtu = forms.IntegerField( - required=False, - min_value=INTERFACE_MTU_MIN, - max_value=INTERFACE_MTU_MAX, - label='MTU' - ) - description = forms.CharField( - max_length=100, - required=False - ) - mode = forms.ChoiceField( - choices=add_blank_choice(InterfaceModeChoices), - required=False, - widget=StaticSelect() - ) - untagged_vlan = DynamicModelChoiceField( - queryset=VLAN.objects.all(), - required=False - ) - tagged_vlans = DynamicModelMultipleChoiceField( - queryset=VLAN.objects.all(), - required=False - ) - - class Meta: - nullable_fields = [ - 'parent', 'mtu', 'description', - ] - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - if 'virtual_machine' in self.initial: - vm_id = self.initial.get('virtual_machine') - - # Restrict parent interface assignment by VM - self.fields['parent'].widget.add_query_param('virtual_machine_id', vm_id) - - # Limit VLAN choices by virtual machine - self.fields['untagged_vlan'].widget.add_query_param('available_on_virtualmachine', vm_id) - self.fields['tagged_vlans'].widget.add_query_param('available_on_virtualmachine', vm_id) - - else: - # See 5643 - if 'pk' in self.initial: - site = None - interfaces = VMInterface.objects.filter(pk__in=self.initial['pk']).prefetch_related( - 'virtual_machine__cluster__site' - ) - - # Check interface sites. First interface should set site, further interfaces will either continue the - # loop or reset back to no site and break the loop. - for interface in interfaces: - if site is None: - site = interface.virtual_machine.cluster.site - elif interface.virtual_machine.cluster.site is not site: - site = None - break - - if site is not None: - self.fields['untagged_vlan'].widget.add_query_param('site_id', site.pk) - self.fields['tagged_vlans'].widget.add_query_param('site_id', site.pk) - - -class VMInterfaceBulkRenameForm(BulkRenameForm): - pk = forms.ModelMultipleChoiceField( - queryset=VMInterface.objects.all(), - widget=forms.MultipleHiddenInput() - ) - - -class VMInterfaceFilterForm(BootstrapMixin, forms.Form): - model = VMInterface - field_groups = [ - ['q', 'tag'], - ['cluster_id', 'virtual_machine_id'], - ['enabled', 'mac_address'], - ] - q = forms.CharField( - required=False, - widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), - label=_('Search') - ) - cluster_id = DynamicModelMultipleChoiceField( - queryset=Cluster.objects.all(), - required=False, - label=_('Cluster'), - fetch_trigger='open' - ) - virtual_machine_id = DynamicModelMultipleChoiceField( - queryset=VirtualMachine.objects.all(), - required=False, - query_params={ - 'cluster_id': '$cluster_id' - }, - label=_('Virtual machine'), - fetch_trigger='open' - ) - enabled = forms.NullBooleanField( - required=False, - widget=StaticSelect( - choices=BOOLEAN_WITH_BLANK_CHOICES - ) - ) - mac_address = forms.CharField( - required=False, - label='MAC address' - ) - tag = TagFilterField(model) - - -# -# Bulk VirtualMachine component creation -# - -class VirtualMachineBulkAddComponentForm(BootstrapMixin, forms.Form): - pk = forms.ModelMultipleChoiceField( - queryset=VirtualMachine.objects.all(), - widget=forms.MultipleHiddenInput() - ) - name_pattern = ExpandableNameField( - label='Name' - ) - - def clean_tags(self): - # Because we're feeding TagField data (on the bulk edit form) to another TagField (on the model form), we - # must first convert the list of tags to a string. - return ','.join(self.cleaned_data.get('tags')) - - -class VMInterfaceBulkCreateForm( - form_from_model(VMInterface, ['enabled', 'mtu', 'description', 'tags']), - VirtualMachineBulkAddComponentForm -): - pass diff --git a/netbox/virtualization/forms/__init__.py b/netbox/virtualization/forms/__init__.py new file mode 100644 index 000000000..00f28b852 --- /dev/null +++ b/netbox/virtualization/forms/__init__.py @@ -0,0 +1,6 @@ +from .models import * +from .filtersets import * +from .object_create import * +from .bulk_create import * +from .bulk_edit import * +from .bulk_import import * diff --git a/netbox/virtualization/forms/bulk_create.py b/netbox/virtualization/forms/bulk_create.py new file mode 100644 index 000000000..6cf7c0d7c --- /dev/null +++ b/netbox/virtualization/forms/bulk_create.py @@ -0,0 +1,30 @@ +from django import forms + +from utilities.forms import BootstrapMixin, ExpandableNameField, form_from_model +from virtualization.models import VMInterface, VirtualMachine + +__all__ = ( + 'VMInterfaceBulkCreateForm', +) + + +class VirtualMachineBulkAddComponentForm(BootstrapMixin, forms.Form): + pk = forms.ModelMultipleChoiceField( + queryset=VirtualMachine.objects.all(), + widget=forms.MultipleHiddenInput() + ) + name_pattern = ExpandableNameField( + label='Name' + ) + + def clean_tags(self): + # Because we're feeding TagField data (on the bulk edit form) to another TagField (on the model form), we + # must first convert the list of tags to a string. + return ','.join(self.cleaned_data.get('tags')) + + +class VMInterfaceBulkCreateForm( + form_from_model(VMInterface, ['enabled', 'mtu', 'description', 'tags']), + VirtualMachineBulkAddComponentForm +): + pass diff --git a/netbox/virtualization/forms/bulk_edit.py b/netbox/virtualization/forms/bulk_edit.py new file mode 100644 index 000000000..c140fbc73 --- /dev/null +++ b/netbox/virtualization/forms/bulk_edit.py @@ -0,0 +1,239 @@ +from django import forms + +from dcim.choices import InterfaceModeChoices +from dcim.constants import INTERFACE_MTU_MAX, INTERFACE_MTU_MIN +from dcim.models import DeviceRole, Platform, Region, Site, SiteGroup +from extras.forms import AddRemoveTagsForm, CustomFieldModelBulkEditForm +from ipam.models import VLAN +from tenancy.models import Tenant +from utilities.forms import ( + add_blank_choice, BootstrapMixin, BulkEditNullBooleanSelect, BulkRenameForm, CommentField, DynamicModelChoiceField, + DynamicModelMultipleChoiceField, SmallTextarea, StaticSelect +) +from virtualization.choices import * +from virtualization.models import * + +__all__ = ( + 'ClusterBulkEditForm', + 'ClusterGroupBulkEditForm', + 'ClusterTypeBulkEditForm', + 'VirtualMachineBulkEditForm', + 'VMInterfaceBulkEditForm', + 'VMInterfaceBulkRenameForm', +) + + +class ClusterTypeBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm): + pk = forms.ModelMultipleChoiceField( + queryset=ClusterType.objects.all(), + widget=forms.MultipleHiddenInput + ) + description = forms.CharField( + max_length=200, + required=False + ) + + class Meta: + nullable_fields = ['description'] + + +class ClusterGroupBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm): + pk = forms.ModelMultipleChoiceField( + queryset=ClusterGroup.objects.all(), + widget=forms.MultipleHiddenInput + ) + description = forms.CharField( + max_length=200, + required=False + ) + + class Meta: + nullable_fields = ['description'] + + +class ClusterBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm): + pk = forms.ModelMultipleChoiceField( + queryset=Cluster.objects.all(), + widget=forms.MultipleHiddenInput() + ) + type = DynamicModelChoiceField( + queryset=ClusterType.objects.all(), + required=False + ) + group = DynamicModelChoiceField( + queryset=ClusterGroup.objects.all(), + required=False + ) + tenant = DynamicModelChoiceField( + queryset=Tenant.objects.all(), + required=False + ) + region = DynamicModelChoiceField( + queryset=Region.objects.all(), + required=False, + ) + site_group = DynamicModelChoiceField( + queryset=SiteGroup.objects.all(), + required=False, + ) + site = DynamicModelChoiceField( + queryset=Site.objects.all(), + required=False, + query_params={ + 'region_id': '$region', + 'group_id': '$site_group', + } + ) + comments = CommentField( + widget=SmallTextarea, + label='Comments' + ) + + class Meta: + nullable_fields = [ + 'group', 'site', 'comments', 'tenant', + ] + + +class VirtualMachineBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm): + pk = forms.ModelMultipleChoiceField( + queryset=VirtualMachine.objects.all(), + widget=forms.MultipleHiddenInput() + ) + status = forms.ChoiceField( + choices=add_blank_choice(VirtualMachineStatusChoices), + required=False, + initial='', + widget=StaticSelect(), + ) + cluster = DynamicModelChoiceField( + queryset=Cluster.objects.all(), + required=False + ) + role = DynamicModelChoiceField( + queryset=DeviceRole.objects.filter( + vm_role=True + ), + required=False, + query_params={ + "vm_role": "True" + } + ) + tenant = DynamicModelChoiceField( + queryset=Tenant.objects.all(), + required=False + ) + platform = DynamicModelChoiceField( + queryset=Platform.objects.all(), + required=False + ) + vcpus = forms.IntegerField( + required=False, + label='vCPUs' + ) + memory = forms.IntegerField( + required=False, + label='Memory (MB)' + ) + disk = forms.IntegerField( + required=False, + label='Disk (GB)' + ) + comments = CommentField( + widget=SmallTextarea, + label='Comments' + ) + + class Meta: + nullable_fields = [ + 'role', 'tenant', 'platform', 'vcpus', 'memory', 'disk', 'comments', + ] + + +class VMInterfaceBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm): + pk = forms.ModelMultipleChoiceField( + queryset=VMInterface.objects.all(), + widget=forms.MultipleHiddenInput() + ) + virtual_machine = forms.ModelChoiceField( + queryset=VirtualMachine.objects.all(), + required=False, + disabled=True, + widget=forms.HiddenInput() + ) + parent = DynamicModelChoiceField( + queryset=VMInterface.objects.all(), + required=False + ) + enabled = forms.NullBooleanField( + required=False, + widget=BulkEditNullBooleanSelect() + ) + mtu = forms.IntegerField( + required=False, + min_value=INTERFACE_MTU_MIN, + max_value=INTERFACE_MTU_MAX, + label='MTU' + ) + description = forms.CharField( + max_length=100, + required=False + ) + mode = forms.ChoiceField( + choices=add_blank_choice(InterfaceModeChoices), + required=False, + widget=StaticSelect() + ) + untagged_vlan = DynamicModelChoiceField( + queryset=VLAN.objects.all(), + required=False + ) + tagged_vlans = DynamicModelMultipleChoiceField( + queryset=VLAN.objects.all(), + required=False + ) + + class Meta: + nullable_fields = [ + 'parent', 'mtu', 'description', + ] + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + if 'virtual_machine' in self.initial: + vm_id = self.initial.get('virtual_machine') + + # Restrict parent interface assignment by VM + self.fields['parent'].widget.add_query_param('virtual_machine_id', vm_id) + + # Limit VLAN choices by virtual machine + self.fields['untagged_vlan'].widget.add_query_param('available_on_virtualmachine', vm_id) + self.fields['tagged_vlans'].widget.add_query_param('available_on_virtualmachine', vm_id) + + else: + # See 5643 + if 'pk' in self.initial: + site = None + interfaces = VMInterface.objects.filter(pk__in=self.initial['pk']).prefetch_related( + 'virtual_machine__cluster__site' + ) + + # Check interface sites. First interface should set site, further interfaces will either continue the + # loop or reset back to no site and break the loop. + for interface in interfaces: + if site is None: + site = interface.virtual_machine.cluster.site + elif interface.virtual_machine.cluster.site is not site: + site = None + break + + if site is not None: + self.fields['untagged_vlan'].widget.add_query_param('site_id', site.pk) + self.fields['tagged_vlans'].widget.add_query_param('site_id', site.pk) + + +class VMInterfaceBulkRenameForm(BulkRenameForm): + pk = forms.ModelMultipleChoiceField( + queryset=VMInterface.objects.all(), + widget=forms.MultipleHiddenInput() + ) diff --git a/netbox/virtualization/forms/bulk_import.py b/netbox/virtualization/forms/bulk_import.py new file mode 100644 index 000000000..1f0496b7c --- /dev/null +++ b/netbox/virtualization/forms/bulk_import.py @@ -0,0 +1,125 @@ +from dcim.choices import InterfaceModeChoices +from dcim.models import DeviceRole, Platform, Site +from extras.forms import CustomFieldModelCSVForm +from tenancy.models import Tenant +from utilities.forms import CSVChoiceField, CSVModelChoiceField, SlugField +from virtualization.choices import * +from virtualization.models import * + +__all__ = ( + 'ClusterCSVForm', + 'ClusterGroupCSVForm', + 'ClusterTypeCSVForm', + 'VirtualMachineCSVForm', + 'VMInterfaceCSVForm', +) + + +class ClusterTypeCSVForm(CustomFieldModelCSVForm): + slug = SlugField() + + class Meta: + model = ClusterType + fields = ('name', 'slug', 'description') + + +class ClusterGroupCSVForm(CustomFieldModelCSVForm): + slug = SlugField() + + class Meta: + model = ClusterGroup + fields = ('name', 'slug', 'description') + + +class ClusterCSVForm(CustomFieldModelCSVForm): + type = CSVModelChoiceField( + queryset=ClusterType.objects.all(), + to_field_name='name', + help_text='Type of cluster' + ) + group = CSVModelChoiceField( + queryset=ClusterGroup.objects.all(), + to_field_name='name', + required=False, + help_text='Assigned cluster group' + ) + site = CSVModelChoiceField( + queryset=Site.objects.all(), + to_field_name='name', + required=False, + help_text='Assigned site' + ) + tenant = CSVModelChoiceField( + queryset=Tenant.objects.all(), + to_field_name='name', + required=False, + help_text='Assigned tenant' + ) + + class Meta: + model = Cluster + fields = ('name', 'type', 'group', 'site', 'comments') + + +class VirtualMachineCSVForm(CustomFieldModelCSVForm): + status = CSVChoiceField( + choices=VirtualMachineStatusChoices, + required=False, + help_text='Operational status of device' + ) + cluster = CSVModelChoiceField( + queryset=Cluster.objects.all(), + to_field_name='name', + help_text='Assigned cluster' + ) + role = CSVModelChoiceField( + queryset=DeviceRole.objects.filter( + vm_role=True + ), + required=False, + to_field_name='name', + help_text='Functional role' + ) + tenant = CSVModelChoiceField( + queryset=Tenant.objects.all(), + required=False, + to_field_name='name', + help_text='Assigned tenant' + ) + platform = CSVModelChoiceField( + queryset=Platform.objects.all(), + required=False, + to_field_name='name', + help_text='Assigned platform' + ) + + class Meta: + model = VirtualMachine + fields = ( + 'name', 'status', 'role', 'cluster', 'tenant', 'platform', 'vcpus', 'memory', 'disk', 'comments', + ) + + +class VMInterfaceCSVForm(CustomFieldModelCSVForm): + virtual_machine = CSVModelChoiceField( + queryset=VirtualMachine.objects.all(), + to_field_name='name' + ) + mode = CSVChoiceField( + choices=InterfaceModeChoices, + required=False, + help_text='IEEE 802.1Q operational mode (for L2 interfaces)' + ) + + class Meta: + model = VMInterface + fields = ( + 'virtual_machine', 'name', 'enabled', 'mac_address', 'mtu', 'description', 'mode', + ) + + def clean_enabled(self): + # Make sure enabled is True when it's not included in the uploaded data + if 'enabled' not in self.data: + return True + else: + return self.cleaned_data['enabled'] diff --git a/netbox/virtualization/forms/filtersets.py b/netbox/virtualization/forms/filtersets.py new file mode 100644 index 000000000..0bb5c2bd7 --- /dev/null +++ b/netbox/virtualization/forms/filtersets.py @@ -0,0 +1,237 @@ +from django import forms +from django.utils.translation import gettext as _ + +from dcim.models import DeviceRole, Platform, Region, Site, SiteGroup +from extras.forms import CustomFieldModelFilterForm, LocalConfigContextFilterForm +from tenancy.forms import TenancyFilterForm +from utilities.forms import ( + BootstrapMixin, DynamicModelMultipleChoiceField, StaticSelect, StaticSelectMultiple, TagFilterField, + BOOLEAN_WITH_BLANK_CHOICES, +) +from virtualization.choices import * +from virtualization.models import * + +__all__ = ( + 'ClusterFilterForm', + 'ClusterGroupFilterForm', + 'ClusterTypeFilterForm', + 'VirtualMachineFilterForm', + 'VMInterfaceFilterForm', +) + + +class ClusterTypeFilterForm(BootstrapMixin, CustomFieldModelFilterForm): + model = ClusterType + field_groups = [ + ['q'], + ] + q = forms.CharField( + required=False, + widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), + label=_('Search') + ) + + +class ClusterGroupFilterForm(BootstrapMixin, CustomFieldModelFilterForm): + model = ClusterGroup + field_groups = [ + ['q'], + ] + q = forms.CharField( + required=False, + widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), + label=_('Search') + ) + + +class ClusterFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilterForm): + model = Cluster + field_order = [ + 'q', 'type_id', 'region_id', 'site_id', 'group_id', 'tenant_group_id', 'tenant_id', + ] + field_groups = [ + ['q', 'tag'], + ['group_id', 'type_id'], + ['region_id', 'site_group_id', 'site_id'], + ['tenant_group_id', 'tenant_id'], + ] + q = forms.CharField( + required=False, + widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), + label=_('Search') + ) + type_id = DynamicModelMultipleChoiceField( + queryset=ClusterType.objects.all(), + required=False, + label=_('Type'), + fetch_trigger='open' + ) + region_id = DynamicModelMultipleChoiceField( + queryset=Region.objects.all(), + required=False, + label=_('Region'), + fetch_trigger='open' + ) + site_group_id = DynamicModelMultipleChoiceField( + queryset=SiteGroup.objects.all(), + required=False, + label=_('Site group'), + fetch_trigger='open' + ) + site_id = DynamicModelMultipleChoiceField( + queryset=Site.objects.all(), + required=False, + null_option='None', + query_params={ + 'region_id': '$region_id', + 'site_group_id': '$site_group_id', + }, + label=_('Site'), + fetch_trigger='open' + ) + group_id = DynamicModelMultipleChoiceField( + queryset=ClusterGroup.objects.all(), + required=False, + null_option='None', + label=_('Group'), + fetch_trigger='open' + ) + tag = TagFilterField(model) + + +class VirtualMachineFilterForm( + BootstrapMixin, + LocalConfigContextFilterForm, + TenancyFilterForm, + CustomFieldModelFilterForm +): + model = VirtualMachine + field_groups = [ + ['q', 'tag'], + ['cluster_group_id', 'cluster_type_id', 'cluster_id'], + ['region_id', 'site_group_id', 'site_id'], + ['status', 'role_id', 'platform_id', 'mac_address', 'has_primary_ip', 'local_context_data'], + ['tenant_group_id', 'tenant_id'], + ] + q = forms.CharField( + required=False, + widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), + label=_('Search') + ) + cluster_group_id = DynamicModelMultipleChoiceField( + queryset=ClusterGroup.objects.all(), + required=False, + null_option='None', + label=_('Cluster group'), + fetch_trigger='open' + ) + cluster_type_id = DynamicModelMultipleChoiceField( + queryset=ClusterType.objects.all(), + required=False, + null_option='None', + label=_('Cluster type'), + fetch_trigger='open' + ) + cluster_id = DynamicModelMultipleChoiceField( + queryset=Cluster.objects.all(), + required=False, + label=_('Cluster'), + fetch_trigger='open' + ) + region_id = DynamicModelMultipleChoiceField( + queryset=Region.objects.all(), + required=False, + label=_('Region'), + fetch_trigger='open' + ) + site_group_id = DynamicModelMultipleChoiceField( + queryset=SiteGroup.objects.all(), + required=False, + label=_('Site group'), + fetch_trigger='open' + ) + site_id = DynamicModelMultipleChoiceField( + queryset=Site.objects.all(), + required=False, + null_option='None', + query_params={ + 'region_id': '$region_id', + 'group_id': '$site_group_id', + }, + label=_('Site'), + fetch_trigger='open' + ) + role_id = DynamicModelMultipleChoiceField( + queryset=DeviceRole.objects.all(), + required=False, + null_option='None', + query_params={ + 'vm_role': "True" + }, + label=_('Role'), + fetch_trigger='open' + ) + status = forms.MultipleChoiceField( + choices=VirtualMachineStatusChoices, + required=False, + widget=StaticSelectMultiple() + ) + platform_id = DynamicModelMultipleChoiceField( + queryset=Platform.objects.all(), + required=False, + null_option='None', + label=_('Platform'), + fetch_trigger='open' + ) + mac_address = forms.CharField( + required=False, + label='MAC address' + ) + has_primary_ip = forms.NullBooleanField( + required=False, + label='Has a primary IP', + widget=StaticSelect( + choices=BOOLEAN_WITH_BLANK_CHOICES + ) + ) + tag = TagFilterField(model) + + +class VMInterfaceFilterForm(BootstrapMixin, forms.Form): + model = VMInterface + field_groups = [ + ['q', 'tag'], + ['cluster_id', 'virtual_machine_id'], + ['enabled', 'mac_address'], + ] + q = forms.CharField( + required=False, + widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), + label=_('Search') + ) + cluster_id = DynamicModelMultipleChoiceField( + queryset=Cluster.objects.all(), + required=False, + label=_('Cluster'), + fetch_trigger='open' + ) + virtual_machine_id = DynamicModelMultipleChoiceField( + queryset=VirtualMachine.objects.all(), + required=False, + query_params={ + 'cluster_id': '$cluster_id' + }, + label=_('Virtual machine'), + fetch_trigger='open' + ) + enabled = forms.NullBooleanField( + required=False, + widget=StaticSelect( + choices=BOOLEAN_WITH_BLANK_CHOICES + ) + ) + mac_address = forms.CharField( + required=False, + label='MAC address' + ) + tag = TagFilterField(model) diff --git a/netbox/virtualization/forms/models.py b/netbox/virtualization/forms/models.py new file mode 100644 index 000000000..d66bc9f1f --- /dev/null +++ b/netbox/virtualization/forms/models.py @@ -0,0 +1,324 @@ +from django import forms +from django.contrib.contenttypes.models import ContentType +from django.core.exceptions import ValidationError + +from dcim.forms.common import InterfaceCommonForm +from dcim.forms.models import INTERFACE_MODE_HELP_TEXT +from dcim.models import Device, DeviceRole, Platform, Rack, Region, Site, SiteGroup +from extras.forms import CustomFieldModelForm +from extras.models import Tag +from ipam.models import IPAddress, VLAN, VLANGroup +from tenancy.forms import TenancyForm +from utilities.forms import ( + BootstrapMixin, CommentField, ConfirmationForm, DynamicModelChoiceField, DynamicModelMultipleChoiceField, + JSONField, SlugField, StaticSelect, +) +from virtualization.models import * + +__all__ = ( + 'ClusterAddDevicesForm', + 'ClusterForm', + 'ClusterGroupForm', + 'ClusterRemoveDevicesForm', + 'ClusterTypeForm', + 'VirtualMachineForm', + 'VMInterfaceForm', +) + + +class ClusterTypeForm(BootstrapMixin, CustomFieldModelForm): + slug = SlugField() + + class Meta: + model = ClusterType + fields = [ + 'name', 'slug', 'description', + ] + + +class ClusterGroupForm(BootstrapMixin, CustomFieldModelForm): + slug = SlugField() + + class Meta: + model = ClusterGroup + fields = [ + 'name', 'slug', 'description', + ] + + +class ClusterForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): + type = DynamicModelChoiceField( + queryset=ClusterType.objects.all() + ) + group = DynamicModelChoiceField( + queryset=ClusterGroup.objects.all(), + required=False + ) + region = DynamicModelChoiceField( + queryset=Region.objects.all(), + required=False, + initial_params={ + 'sites': '$site' + } + ) + site_group = DynamicModelChoiceField( + queryset=SiteGroup.objects.all(), + required=False, + initial_params={ + 'sites': '$site' + } + ) + site = DynamicModelChoiceField( + queryset=Site.objects.all(), + required=False, + query_params={ + 'region_id': '$region', + 'group_id': '$site_group', + } + ) + comments = CommentField() + tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), + required=False + ) + + class Meta: + model = Cluster + fields = ( + 'name', 'type', 'group', 'tenant', 'region', 'site_group', 'site', 'comments', 'tags', + ) + fieldsets = ( + ('Cluster', ('name', 'type', 'group', 'region', 'site_group', 'site', 'tags')), + ('Tenancy', ('tenant_group', 'tenant')), + ) + + +class ClusterAddDevicesForm(BootstrapMixin, forms.Form): + region = DynamicModelChoiceField( + queryset=Region.objects.all(), + required=False, + null_option='None' + ) + site_group = DynamicModelChoiceField( + queryset=SiteGroup.objects.all(), + required=False, + null_option='None' + ) + site = DynamicModelChoiceField( + queryset=Site.objects.all(), + required=False, + query_params={ + 'region_id': '$region', + 'group_id': '$site_group', + } + ) + rack = DynamicModelChoiceField( + queryset=Rack.objects.all(), + required=False, + null_option='None', + query_params={ + 'site_id': '$site' + } + ) + devices = DynamicModelMultipleChoiceField( + queryset=Device.objects.all(), + query_params={ + 'site_id': '$site', + 'rack_id': '$rack', + 'cluster_id': 'null', + } + ) + + class Meta: + fields = [ + 'region', 'site', 'rack', 'devices', + ] + + def __init__(self, cluster, *args, **kwargs): + + self.cluster = cluster + + super().__init__(*args, **kwargs) + + self.fields['devices'].choices = [] + + def clean(self): + super().clean() + + # If the Cluster is assigned to a Site, all Devices must be assigned to that Site. + if self.cluster.site is not None: + for device in self.cleaned_data.get('devices', []): + if device.site != self.cluster.site: + raise ValidationError({ + 'devices': "{} belongs to a different site ({}) than the cluster ({})".format( + device, device.site, self.cluster.site + ) + }) + + +class ClusterRemoveDevicesForm(ConfirmationForm): + pk = forms.ModelMultipleChoiceField( + queryset=Device.objects.all(), + widget=forms.MultipleHiddenInput() + ) + + +class VirtualMachineForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): + cluster_group = DynamicModelChoiceField( + queryset=ClusterGroup.objects.all(), + required=False, + null_option='None', + initial_params={ + 'clusters': '$cluster' + } + ) + cluster = DynamicModelChoiceField( + queryset=Cluster.objects.all(), + query_params={ + 'group_id': '$cluster_group' + } + ) + role = DynamicModelChoiceField( + queryset=DeviceRole.objects.all(), + required=False, + query_params={ + "vm_role": "True" + } + ) + platform = DynamicModelChoiceField( + queryset=Platform.objects.all(), + required=False + ) + local_context_data = JSONField( + required=False, + label='' + ) + tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), + required=False + ) + + class Meta: + model = VirtualMachine + fields = [ + 'name', 'status', 'cluster_group', 'cluster', 'role', 'tenant_group', 'tenant', 'platform', 'primary_ip4', + 'primary_ip6', 'vcpus', 'memory', 'disk', 'comments', 'tags', 'local_context_data', + ] + fieldsets = ( + ('Virtual Machine', ('name', 'role', 'status', 'tags')), + ('Cluster', ('cluster_group', 'cluster')), + ('Tenancy', ('tenant_group', 'tenant')), + ('Management', ('platform', 'primary_ip4', 'primary_ip6')), + ('Resources', ('vcpus', 'memory', 'disk')), + ('Config Context', ('local_context_data',)), + ) + help_texts = { + 'local_context_data': "Local config context data overwrites all sources contexts in the final rendered " + "config context", + } + widgets = { + "status": StaticSelect(), + 'primary_ip4': StaticSelect(), + 'primary_ip6': StaticSelect(), + } + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + if self.instance.pk: + + # Compile list of choices for primary IPv4 and IPv6 addresses + for family in [4, 6]: + ip_choices = [(None, '---------')] + + # Gather PKs of all interfaces belonging to this VM + interface_ids = self.instance.interfaces.values_list('pk', flat=True) + + # Collect interface IPs + interface_ips = IPAddress.objects.filter( + address__family=family, + assigned_object_type=ContentType.objects.get_for_model(VMInterface), + assigned_object_id__in=interface_ids + ) + if interface_ips: + ip_list = [(ip.id, f'{ip.address} ({ip.assigned_object})') for ip in interface_ips] + ip_choices.append(('Interface IPs', ip_list)) + # Collect NAT IPs + nat_ips = IPAddress.objects.prefetch_related('nat_inside').filter( + address__family=family, + nat_inside__assigned_object_type=ContentType.objects.get_for_model(VMInterface), + nat_inside__assigned_object_id__in=interface_ids + ) + if nat_ips: + ip_list = [(ip.id, f'{ip.address} (NAT)') for ip in nat_ips] + ip_choices.append(('NAT IPs', ip_list)) + self.fields['primary_ip{}'.format(family)].choices = ip_choices + + else: + + # An object that doesn't exist yet can't have any IPs assigned to it + self.fields['primary_ip4'].choices = [] + self.fields['primary_ip4'].widget.attrs['readonly'] = True + self.fields['primary_ip6'].choices = [] + self.fields['primary_ip6'].widget.attrs['readonly'] = True + + +class VMInterfaceForm(BootstrapMixin, InterfaceCommonForm, CustomFieldModelForm): + parent = DynamicModelChoiceField( + queryset=VMInterface.objects.all(), + required=False, + label='Parent interface' + ) + vlan_group = DynamicModelChoiceField( + queryset=VLANGroup.objects.all(), + required=False, + label='VLAN group' + ) + untagged_vlan = DynamicModelChoiceField( + queryset=VLAN.objects.all(), + required=False, + label='Untagged VLAN', + query_params={ + 'group_id': '$vlan_group', + } + ) + tagged_vlans = DynamicModelMultipleChoiceField( + queryset=VLAN.objects.all(), + required=False, + label='Tagged VLANs', + query_params={ + 'group_id': '$vlan_group', + } + ) + tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), + required=False + ) + + class Meta: + model = VMInterface + fields = [ + 'virtual_machine', 'name', 'enabled', 'parent', 'mac_address', 'mtu', 'description', 'mode', 'tags', + 'untagged_vlan', 'tagged_vlans', + ] + widgets = { + 'virtual_machine': forms.HiddenInput(), + 'mode': StaticSelect() + } + labels = { + 'mode': '802.1Q Mode', + } + help_texts = { + 'mode': INTERFACE_MODE_HELP_TEXT, + } + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + vm_id = self.initial.get('virtual_machine') or self.data.get('virtual_machine') + + # Restrict parent interface assignment by VM + self.fields['parent'].widget.add_query_param('virtual_machine_id', vm_id) + + # Limit VLAN choices by virtual machine + self.fields['untagged_vlan'].widget.add_query_param('available_on_virtualmachine', vm_id) + self.fields['tagged_vlans'].widget.add_query_param('available_on_virtualmachine', vm_id) diff --git a/netbox/virtualization/forms/object_create.py b/netbox/virtualization/forms/object_create.py new file mode 100644 index 000000000..b58fb51f8 --- /dev/null +++ b/netbox/virtualization/forms/object_create.py @@ -0,0 +1,74 @@ +from django import forms + +from dcim.choices import InterfaceModeChoices +from dcim.forms.common import InterfaceCommonForm +from extras.forms import CustomFieldsMixin +from extras.models import Tag +from ipam.models import VLAN +from utilities.forms import ( + add_blank_choice, BootstrapMixin, DynamicModelChoiceField, DynamicModelMultipleChoiceField, ExpandableNameField, + StaticSelect, +) +from virtualization.models import VMInterface, VirtualMachine + +__all__ = ( + 'VMInterfaceCreateForm', +) + + +class VMInterfaceCreateForm(BootstrapMixin, CustomFieldsMixin, InterfaceCommonForm): + model = VMInterface + virtual_machine = DynamicModelChoiceField( + queryset=VirtualMachine.objects.all() + ) + name_pattern = ExpandableNameField( + label='Name' + ) + enabled = forms.BooleanField( + required=False, + initial=True + ) + parent = DynamicModelChoiceField( + queryset=VMInterface.objects.all(), + required=False, + query_params={ + 'virtual_machine_id': '$virtual_machine', + } + ) + mac_address = forms.CharField( + required=False, + label='MAC Address' + ) + description = forms.CharField( + max_length=200, + required=False + ) + mode = forms.ChoiceField( + choices=add_blank_choice(InterfaceModeChoices), + required=False, + widget=StaticSelect(), + ) + untagged_vlan = DynamicModelChoiceField( + queryset=VLAN.objects.all(), + required=False + ) + tagged_vlans = DynamicModelMultipleChoiceField( + queryset=VLAN.objects.all(), + required=False + ) + tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), + required=False + ) + field_order = ( + 'virtual_machine', 'name_pattern', 'enabled', 'parent', 'mtu', 'mac_address', 'description', 'mode', + 'untagged_vlan', 'tagged_vlans', 'tags' + ) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + vm_id = self.initial.get('virtual_machine') or self.data.get('virtual_machine') + + # Limit VLAN choices by virtual machine + self.fields['untagged_vlan'].widget.add_query_param('available_on_virtualmachine', vm_id) + self.fields['tagged_vlans'].widget.add_query_param('available_on_virtualmachine', vm_id) From 833acc3618cf24dbd5a365c619698eed6bc19a79 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 28 Sep 2021 10:44:53 -0400 Subject: [PATCH 25/37] Refactor extras forms --- netbox/extras/forms.py | 988 ---------------------------- netbox/extras/forms/__init__.py | 6 + netbox/extras/forms/bulk_edit.py | 199 ++++++ netbox/extras/forms/bulk_import.py | 91 +++ netbox/extras/forms/customfields.py | 123 ++++ netbox/extras/forms/filtersets.py | 364 ++++++++++ netbox/extras/forms/models.py | 223 +++++++ netbox/extras/forms/scripts.py | 30 + 8 files changed, 1036 insertions(+), 988 deletions(-) delete mode 100644 netbox/extras/forms.py create mode 100644 netbox/extras/forms/__init__.py create mode 100644 netbox/extras/forms/bulk_edit.py create mode 100644 netbox/extras/forms/bulk_import.py create mode 100644 netbox/extras/forms/customfields.py create mode 100644 netbox/extras/forms/filtersets.py create mode 100644 netbox/extras/forms/models.py create mode 100644 netbox/extras/forms/scripts.py diff --git a/netbox/extras/forms.py b/netbox/extras/forms.py deleted file mode 100644 index fe98d9ca3..000000000 --- a/netbox/extras/forms.py +++ /dev/null @@ -1,988 +0,0 @@ -from django import forms -from django.contrib.auth.models import User -from django.contrib.contenttypes.models import ContentType -from django.contrib.postgres.forms import SimpleArrayField -from django.utils.safestring import mark_safe -from django.utils.translation import gettext as _ - -from dcim.models import DeviceRole, DeviceType, Platform, Region, Site, SiteGroup -from tenancy.models import Tenant, TenantGroup -from utilities.forms import ( - add_blank_choice, APISelectMultiple, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect, ColorField, - CommentField, ContentTypeChoiceField, ContentTypeMultipleChoiceField, CSVContentTypeField, CSVModelForm, - CSVMultipleContentTypeField, DateTimePicker, DynamicModelMultipleChoiceField, JSONField, SlugField, StaticSelect, - StaticSelectMultiple, BOOLEAN_WITH_BLANK_CHOICES, -) -from virtualization.models import Cluster, ClusterGroup -from .choices import * -from .models import * -from .utils import FeatureQuery - - -# -# Custom fields -# - -class CustomFieldForm(BootstrapMixin, forms.ModelForm): - content_types = ContentTypeMultipleChoiceField( - queryset=ContentType.objects.all(), - limit_choices_to=FeatureQuery('custom_fields') - ) - - class Meta: - model = CustomField - fields = '__all__' - fieldsets = ( - ('Custom Field', ('name', 'label', 'type', 'weight', 'required', 'description')), - ('Assigned Models', ('content_types',)), - ('Behavior', ('filter_logic',)), - ('Values', ('default', 'choices')), - ('Validation', ('validation_minimum', 'validation_maximum', 'validation_regex')), - ) - - -class CustomFieldCSVForm(CSVModelForm): - content_types = CSVMultipleContentTypeField( - queryset=ContentType.objects.all(), - limit_choices_to=FeatureQuery('custom_fields'), - help_text="One or more assigned object types" - ) - choices = SimpleArrayField( - base_field=forms.CharField(), - required=False, - help_text='Comma-separated list of field choices' - ) - - class Meta: - model = CustomField - fields = ( - 'name', 'label', 'type', 'content_types', 'required', 'description', 'weight', 'filter_logic', 'default', - 'choices', 'weight', - ) - - -class CustomFieldBulkEditForm(BootstrapMixin, BulkEditForm): - pk = forms.ModelMultipleChoiceField( - queryset=CustomField.objects.all(), - widget=forms.MultipleHiddenInput - ) - description = forms.CharField( - required=False - ) - required = forms.NullBooleanField( - required=False, - widget=BulkEditNullBooleanSelect() - ) - weight = forms.IntegerField( - required=False - ) - - class Meta: - nullable_fields = [] - - -class CustomFieldFilterForm(BootstrapMixin, forms.Form): - field_groups = [ - ['q'], - ['type', 'content_types'], - ['weight', 'required'], - ] - q = forms.CharField( - required=False, - widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), - label=_('Search') - ) - content_types = ContentTypeMultipleChoiceField( - queryset=ContentType.objects.all(), - limit_choices_to=FeatureQuery('custom_fields'), - required=False - ) - type = forms.MultipleChoiceField( - choices=CustomFieldTypeChoices, - required=False, - widget=StaticSelectMultiple(), - label=_('Field type') - ) - weight = forms.IntegerField( - required=False - ) - required = forms.NullBooleanField( - required=False, - widget=StaticSelect( - choices=BOOLEAN_WITH_BLANK_CHOICES - ) - ) - - -# -# Custom links -# - -class CustomLinkForm(BootstrapMixin, forms.ModelForm): - content_type = ContentTypeChoiceField( - queryset=ContentType.objects.all(), - limit_choices_to=FeatureQuery('custom_links') - ) - - class Meta: - model = CustomLink - fields = '__all__' - fieldsets = ( - ('Custom Link', ('name', 'content_type', 'weight', 'group_name', 'button_class', 'new_window')), - ('Templates', ('link_text', 'link_url')), - ) - widgets = { - 'link_text': forms.Textarea(attrs={'class': 'font-monospace'}), - 'link_url': forms.Textarea(attrs={'class': 'font-monospace'}), - } - help_texts = { - 'link_text': 'Jinja2 template code for the link text. Reference the object as {{ obj }}. ' - 'Links which render as empty text will not be displayed.', - 'link_url': 'Jinja2 template code for the link URL. Reference the object as {{ obj }}.', - } - - -class CustomLinkCSVForm(CSVModelForm): - content_type = CSVContentTypeField( - queryset=ContentType.objects.all(), - limit_choices_to=FeatureQuery('custom_links'), - help_text="Assigned object type" - ) - - class Meta: - model = CustomLink - fields = ( - 'name', 'content_type', 'weight', 'group_name', 'button_class', 'new_window', 'link_text', 'link_url', - ) - - -class CustomLinkBulkEditForm(BootstrapMixin, BulkEditForm): - pk = forms.ModelMultipleChoiceField( - queryset=CustomLink.objects.all(), - widget=forms.MultipleHiddenInput - ) - content_type = ContentTypeChoiceField( - queryset=ContentType.objects.all(), - limit_choices_to=FeatureQuery('custom_fields'), - required=False - ) - new_window = forms.NullBooleanField( - required=False, - widget=BulkEditNullBooleanSelect() - ) - weight = forms.IntegerField( - required=False - ) - button_class = forms.ChoiceField( - choices=CustomLinkButtonClassChoices, - required=False, - widget=StaticSelect() - ) - - class Meta: - nullable_fields = [] - - -class CustomLinkFilterForm(BootstrapMixin, forms.Form): - field_groups = [ - ['q'], - ['content_type', 'weight', 'new_window'], - ] - q = forms.CharField( - required=False, - widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), - label=_('Search') - ) - content_type = ContentTypeChoiceField( - queryset=ContentType.objects.all(), - limit_choices_to=FeatureQuery('custom_fields'), - required=False - ) - weight = forms.IntegerField( - required=False - ) - new_window = forms.NullBooleanField( - required=False, - widget=StaticSelect( - choices=BOOLEAN_WITH_BLANK_CHOICES - ) - ) - - -# -# Export templates -# - -class ExportTemplateForm(BootstrapMixin, forms.ModelForm): - content_type = ContentTypeChoiceField( - queryset=ContentType.objects.all(), - limit_choices_to=FeatureQuery('custom_links') - ) - - class Meta: - model = ExportTemplate - fields = '__all__' - fieldsets = ( - ('Custom Link', ('name', 'content_type', 'description')), - ('Template', ('template_code',)), - ('Rendering', ('mime_type', 'file_extension', 'as_attachment')), - ) - widgets = { - 'template_code': forms.Textarea(attrs={'class': 'font-monospace'}), - } - - -class ExportTemplateCSVForm(CSVModelForm): - content_type = CSVContentTypeField( - queryset=ContentType.objects.all(), - limit_choices_to=FeatureQuery('export_templates'), - help_text="Assigned object type" - ) - - class Meta: - model = ExportTemplate - fields = ( - 'name', 'content_type', 'description', 'mime_type', 'file_extension', 'as_attachment', 'template_code', - ) - - -class ExportTemplateBulkEditForm(BootstrapMixin, BulkEditForm): - pk = forms.ModelMultipleChoiceField( - queryset=ExportTemplate.objects.all(), - widget=forms.MultipleHiddenInput - ) - content_type = ContentTypeChoiceField( - queryset=ContentType.objects.all(), - limit_choices_to=FeatureQuery('custom_fields'), - required=False - ) - description = forms.CharField( - max_length=200, - required=False - ) - mime_type = forms.CharField( - max_length=50, - required=False - ) - file_extension = forms.CharField( - max_length=15, - required=False - ) - as_attachment = forms.NullBooleanField( - required=False, - widget=BulkEditNullBooleanSelect() - ) - - class Meta: - nullable_fields = ['description', 'mime_type', 'file_extension'] - - -class ExportTemplateFilterForm(BootstrapMixin, forms.Form): - field_groups = [ - ['q'], - ['content_type', 'mime_type', 'file_extension', 'as_attachment'], - ] - q = forms.CharField( - required=False, - widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), - label=_('Search') - ) - content_type = ContentTypeChoiceField( - queryset=ContentType.objects.all(), - limit_choices_to=FeatureQuery('custom_fields'), - required=False - ) - mime_type = forms.CharField( - required=False, - label=_('MIME type') - ) - file_extension = forms.CharField( - required=False - ) - as_attachment = forms.NullBooleanField( - required=False, - widget=StaticSelect( - choices=BOOLEAN_WITH_BLANK_CHOICES - ) - ) - - -# -# Webhooks -# - -class WebhookForm(BootstrapMixin, forms.ModelForm): - content_types = ContentTypeMultipleChoiceField( - queryset=ContentType.objects.all(), - limit_choices_to=FeatureQuery('webhooks') - ) - - class Meta: - model = Webhook - fields = '__all__' - fieldsets = ( - ('Webhook', ('name', 'enabled')), - ('Assigned Models', ('content_types',)), - ('Events', ('type_create', 'type_update', 'type_delete')), - ('HTTP Request', ( - 'payload_url', 'http_method', 'http_content_type', 'additional_headers', 'body_template', 'secret', - )), - ('SSL', ('ssl_verification', 'ca_file_path')), - ) - widgets = { - 'additional_headers': forms.Textarea(attrs={'class': 'font-monospace'}), - 'body_template': forms.Textarea(attrs={'class': 'font-monospace'}), - } - - -class WebhookCSVForm(CSVModelForm): - content_types = CSVMultipleContentTypeField( - queryset=ContentType.objects.all(), - limit_choices_to=FeatureQuery('webhooks'), - help_text="One or more assigned object types" - ) - - class Meta: - model = Webhook - fields = ( - 'name', 'enabled', 'content_types', 'type_create', 'type_update', 'type_delete', 'payload_url', - 'http_method', 'http_content_type', 'additional_headers', 'body_template', 'secret', 'ssl_verification', - 'ca_file_path' - ) - - -class WebhookBulkEditForm(BootstrapMixin, BulkEditForm): - pk = forms.ModelMultipleChoiceField( - queryset=Webhook.objects.all(), - widget=forms.MultipleHiddenInput - ) - enabled = forms.NullBooleanField( - required=False, - widget=BulkEditNullBooleanSelect() - ) - type_create = forms.NullBooleanField( - required=False, - widget=BulkEditNullBooleanSelect() - ) - type_update = forms.NullBooleanField( - required=False, - widget=BulkEditNullBooleanSelect() - ) - type_delete = forms.NullBooleanField( - required=False, - widget=BulkEditNullBooleanSelect() - ) - http_method = forms.ChoiceField( - choices=WebhookHttpMethodChoices, - required=False - ) - payload_url = forms.CharField( - required=False - ) - ssl_verification = forms.NullBooleanField( - required=False, - widget=BulkEditNullBooleanSelect() - ) - secret = forms.CharField( - required=False - ) - ca_file_path = forms.CharField( - required=False - ) - - class Meta: - nullable_fields = ['secret', 'ca_file_path'] - - -class WebhookFilterForm(BootstrapMixin, forms.Form): - field_groups = [ - ['q'], - ['content_types', 'http_method', 'enabled'], - ['type_create', 'type_update', 'type_delete'], - ] - q = forms.CharField( - required=False, - widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), - label=_('Search') - ) - content_types = ContentTypeMultipleChoiceField( - queryset=ContentType.objects.all(), - limit_choices_to=FeatureQuery('custom_fields'), - required=False - ) - http_method = forms.MultipleChoiceField( - choices=WebhookHttpMethodChoices, - required=False, - widget=StaticSelectMultiple(), - label=_('HTTP method') - ) - enabled = forms.NullBooleanField( - required=False, - widget=StaticSelect( - choices=BOOLEAN_WITH_BLANK_CHOICES - ) - ) - type_create = forms.NullBooleanField( - required=False, - widget=StaticSelect( - choices=BOOLEAN_WITH_BLANK_CHOICES - ) - ) - type_update = forms.NullBooleanField( - required=False, - widget=StaticSelect( - choices=BOOLEAN_WITH_BLANK_CHOICES - ) - ) - type_delete = forms.NullBooleanField( - required=False, - widget=StaticSelect( - choices=BOOLEAN_WITH_BLANK_CHOICES - ) - ) - - -# -# Custom field models -# - -class CustomFieldsMixin: - """ - Extend a Form to include custom field support. - """ - def __init__(self, *args, **kwargs): - self.custom_fields = [] - - super().__init__(*args, **kwargs) - - self._append_customfield_fields() - - def _get_content_type(self): - """ - Return the ContentType of the form's model. - """ - if not hasattr(self, 'model'): - raise NotImplementedError(f"{self.__class__.__name__} must specify a model class.") - return ContentType.objects.get_for_model(self.model) - - def _get_form_field(self, customfield): - return customfield.to_form_field() - - def _append_customfield_fields(self): - """ - Append form fields for all CustomFields assigned to this object type. - """ - content_type = self._get_content_type() - - # Append form fields; assign initial values if modifying and existing object - for customfield in CustomField.objects.filter(content_types=content_type): - field_name = f'cf_{customfield.name}' - self.fields[field_name] = self._get_form_field(customfield) - - # Annotate the field in the list of CustomField form fields - self.custom_fields.append(field_name) - - -class CustomFieldModelForm(CustomFieldsMixin, forms.ModelForm): - """ - Extend ModelForm to include custom field support. - """ - def _get_content_type(self): - return ContentType.objects.get_for_model(self._meta.model) - - def _get_form_field(self, customfield): - if self.instance.pk: - form_field = customfield.to_form_field(set_initial=False) - form_field.initial = self.instance.custom_field_data.get(customfield.name, None) - return form_field - - return customfield.to_form_field() - - def clean(self): - - # Save custom field data on instance - for cf_name in self.custom_fields: - key = cf_name[3:] # Strip "cf_" from field name - value = self.cleaned_data.get(cf_name) - empty_values = self.fields[cf_name].empty_values - # Convert "empty" values to null - self.instance.custom_field_data[key] = value if value not in empty_values else None - - return super().clean() - - -class CustomFieldModelCSVForm(CSVModelForm, CustomFieldModelForm): - - def _get_form_field(self, customfield): - return customfield.to_form_field(for_csv_import=True) - - -class CustomFieldModelBulkEditForm(BulkEditForm): - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - self.custom_fields = [] - self.obj_type = ContentType.objects.get_for_model(self.model) - - # Add all applicable CustomFields to the form - custom_fields = CustomField.objects.filter(content_types=self.obj_type) - for cf in custom_fields: - # Annotate non-required custom fields as nullable - if not cf.required: - self.nullable_fields.append(cf.name) - self.fields[cf.name] = cf.to_form_field(set_initial=False, enforce_required=False) - # Annotate this as a custom field - self.custom_fields.append(cf.name) - - -class CustomFieldModelFilterForm(forms.Form): - - def __init__(self, *args, **kwargs): - - self.obj_type = ContentType.objects.get_for_model(self.model) - - super().__init__(*args, **kwargs) - - # Add all applicable CustomFields to the form - self.custom_field_filters = [] - custom_fields = CustomField.objects.filter(content_types=self.obj_type).exclude( - filter_logic=CustomFieldFilterLogicChoices.FILTER_DISABLED - ) - for cf in custom_fields: - field_name = 'cf_{}'.format(cf.name) - self.fields[field_name] = cf.to_form_field(set_initial=True, enforce_required=False) - self.custom_field_filters.append(field_name) - - -# -# Tags -# - -class TagForm(BootstrapMixin, forms.ModelForm): - slug = SlugField() - - class Meta: - model = Tag - fields = [ - 'name', 'slug', 'color', 'description' - ] - fieldsets = ( - ('Tag', ('name', 'slug', 'color', 'description')), - ) - - -class TagCSVForm(CSVModelForm): - slug = SlugField() - - class Meta: - model = Tag - fields = ('name', 'slug', 'color', 'description') - help_texts = { - 'color': mark_safe('RGB color in hexadecimal (e.g. 00ff00)'), - } - - -class AddRemoveTagsForm(forms.Form): - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - # Add add/remove tags fields - self.fields['add_tags'] = DynamicModelMultipleChoiceField( - queryset=Tag.objects.all(), - required=False - ) - self.fields['remove_tags'] = DynamicModelMultipleChoiceField( - queryset=Tag.objects.all(), - required=False - ) - - -class TagFilterForm(BootstrapMixin, forms.Form): - model = Tag - q = forms.CharField( - required=False, - label=_('Search') - ) - content_type_id = ContentTypeMultipleChoiceField( - queryset=ContentType.objects.filter(FeatureQuery('tags').get_query()), - required=False, - label=_('Tagged object type') - ) - - -class TagBulkEditForm(BootstrapMixin, BulkEditForm): - pk = forms.ModelMultipleChoiceField( - queryset=Tag.objects.all(), - widget=forms.MultipleHiddenInput - ) - color = ColorField( - required=False - ) - description = forms.CharField( - max_length=200, - required=False - ) - - class Meta: - nullable_fields = ['description'] - - -# -# Config contexts -# - -class ConfigContextForm(BootstrapMixin, forms.ModelForm): - regions = DynamicModelMultipleChoiceField( - queryset=Region.objects.all(), - required=False - ) - site_groups = DynamicModelMultipleChoiceField( - queryset=SiteGroup.objects.all(), - required=False - ) - sites = DynamicModelMultipleChoiceField( - queryset=Site.objects.all(), - required=False - ) - device_types = DynamicModelMultipleChoiceField( - queryset=DeviceType.objects.all(), - required=False - ) - roles = DynamicModelMultipleChoiceField( - queryset=DeviceRole.objects.all(), - required=False - ) - platforms = DynamicModelMultipleChoiceField( - queryset=Platform.objects.all(), - required=False - ) - cluster_groups = DynamicModelMultipleChoiceField( - queryset=ClusterGroup.objects.all(), - required=False - ) - clusters = DynamicModelMultipleChoiceField( - queryset=Cluster.objects.all(), - required=False - ) - tenant_groups = DynamicModelMultipleChoiceField( - queryset=TenantGroup.objects.all(), - required=False - ) - tenants = DynamicModelMultipleChoiceField( - queryset=Tenant.objects.all(), - required=False - ) - tags = DynamicModelMultipleChoiceField( - queryset=Tag.objects.all(), - required=False - ) - data = JSONField( - label='' - ) - - class Meta: - model = ConfigContext - fields = ( - 'name', 'weight', 'description', 'is_active', 'regions', 'site_groups', 'sites', 'roles', 'device_types', - 'platforms', 'cluster_groups', 'clusters', 'tenant_groups', 'tenants', 'tags', 'data', - ) - - -class ConfigContextBulkEditForm(BootstrapMixin, BulkEditForm): - pk = forms.ModelMultipleChoiceField( - queryset=ConfigContext.objects.all(), - widget=forms.MultipleHiddenInput - ) - weight = forms.IntegerField( - required=False, - min_value=0 - ) - is_active = forms.NullBooleanField( - required=False, - widget=BulkEditNullBooleanSelect() - ) - description = forms.CharField( - required=False, - max_length=100 - ) - - class Meta: - nullable_fields = [ - 'description', - ] - - -class ConfigContextFilterForm(BootstrapMixin, forms.Form): - field_groups = [ - ['q', 'tag'], - ['region_id', 'site_group_id', 'site_id'], - ['device_type_id', 'platform_id', 'role_id'], - ['cluster_group_id', 'cluster_id'], - ['tenant_group_id', 'tenant_id'] - ] - q = forms.CharField( - required=False, - widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), - label=_('Search') - ) - region_id = DynamicModelMultipleChoiceField( - queryset=Region.objects.all(), - required=False, - label=_('Regions'), - fetch_trigger='open' - ) - site_group_id = DynamicModelMultipleChoiceField( - queryset=SiteGroup.objects.all(), - required=False, - label=_('Site groups'), - fetch_trigger='open' - ) - site_id = DynamicModelMultipleChoiceField( - queryset=Site.objects.all(), - required=False, - label=_('Sites'), - fetch_trigger='open' - ) - device_type_id = DynamicModelMultipleChoiceField( - queryset=DeviceType.objects.all(), - required=False, - label=_('Device types'), - fetch_trigger='open' - ) - role_id = DynamicModelMultipleChoiceField( - queryset=DeviceRole.objects.all(), - required=False, - label=_('Roles'), - fetch_trigger='open' - ) - platform_id = DynamicModelMultipleChoiceField( - queryset=Platform.objects.all(), - required=False, - label=_('Platforms'), - fetch_trigger='open' - ) - cluster_group_id = DynamicModelMultipleChoiceField( - queryset=ClusterGroup.objects.all(), - required=False, - label=_('Cluster groups'), - fetch_trigger='open' - ) - cluster_id = DynamicModelMultipleChoiceField( - queryset=Cluster.objects.all(), - required=False, - label=_('Clusters'), - fetch_trigger='open' - ) - tenant_group_id = DynamicModelMultipleChoiceField( - queryset=TenantGroup.objects.all(), - required=False, - label=_('Tenant groups'), - fetch_trigger='open' - ) - tenant_id = DynamicModelMultipleChoiceField( - queryset=Tenant.objects.all(), - required=False, - label=_('Tenant'), - fetch_trigger='open' - ) - tag = DynamicModelMultipleChoiceField( - queryset=Tag.objects.all(), - to_field_name='slug', - required=False, - label=_('Tags'), - fetch_trigger='open' - ) - - -# -# 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=StaticSelect( - choices=BOOLEAN_WITH_BLANK_CHOICES - ) - ) - - -# -# Image attachments -# - -class ImageAttachmentForm(BootstrapMixin, forms.ModelForm): - - class Meta: - model = ImageAttachment - fields = [ - 'name', 'image', - ] - - -# -# Journal entries -# - -class JournalEntryForm(BootstrapMixin, forms.ModelForm): - comments = CommentField() - - kind = forms.ChoiceField( - choices=add_blank_choice(JournalEntryKindChoices), - required=False, - widget=StaticSelect() - ) - - class Meta: - model = JournalEntry - fields = ['assigned_object_type', 'assigned_object_id', 'kind', 'comments'] - widgets = { - 'assigned_object_type': forms.HiddenInput, - 'assigned_object_id': forms.HiddenInput, - } - - -class JournalEntryBulkEditForm(BootstrapMixin, BulkEditForm): - pk = forms.ModelMultipleChoiceField( - queryset=JournalEntry.objects.all(), - widget=forms.MultipleHiddenInput - ) - kind = forms.ChoiceField( - choices=JournalEntryKindChoices, - required=False - ) - comments = forms.CharField( - required=False, - widget=forms.Textarea() - ) - - class Meta: - nullable_fields = [] - - -class JournalEntryFilterForm(BootstrapMixin, forms.Form): - model = JournalEntry - field_groups = [ - ['q'], - ['created_before', 'created_after', 'created_by_id'], - ['assigned_object_type_id', 'kind'] - ] - q = forms.CharField( - required=False, - widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), - label=_('Search') - ) - created_after = forms.DateTimeField( - required=False, - label=_('After'), - widget=DateTimePicker() - ) - created_before = forms.DateTimeField( - required=False, - label=_('Before'), - widget=DateTimePicker() - ) - created_by_id = DynamicModelMultipleChoiceField( - queryset=User.objects.all(), - required=False, - label=_('User'), - widget=APISelectMultiple( - api_url='/api/users/users/', - ), - fetch_trigger='open' - ) - assigned_object_type_id = DynamicModelMultipleChoiceField( - queryset=ContentType.objects.all(), - required=False, - label=_('Object Type'), - widget=APISelectMultiple( - api_url='/api/extras/content-types/', - ), - fetch_trigger='open' - ) - kind = forms.ChoiceField( - choices=add_blank_choice(JournalEntryKindChoices), - required=False, - widget=StaticSelect() - ) - - -# -# Change logging -# - -class ObjectChangeFilterForm(BootstrapMixin, forms.Form): - model = ObjectChange - field_groups = [ - ['q'], - ['time_before', 'time_after', 'action'], - ['user_id', 'changed_object_type_id'], - ] - q = forms.CharField( - required=False, - widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), - label=_('Search') - ) - time_after = forms.DateTimeField( - required=False, - label=_('After'), - widget=DateTimePicker() - ) - time_before = forms.DateTimeField( - required=False, - label=_('Before'), - widget=DateTimePicker() - ) - action = forms.ChoiceField( - choices=add_blank_choice(ObjectChangeActionChoices), - required=False, - widget=StaticSelect() - ) - user_id = DynamicModelMultipleChoiceField( - queryset=User.objects.all(), - required=False, - label=_('User'), - widget=APISelectMultiple( - api_url='/api/users/users/', - ), - fetch_trigger='open' - ) - changed_object_type_id = DynamicModelMultipleChoiceField( - queryset=ContentType.objects.all(), - required=False, - label=_('Object Type'), - widget=APISelectMultiple( - api_url='/api/extras/content-types/', - ), - fetch_trigger='open' - ) - - -# -# 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, *args, **kwargs): - super().__init__(*args, **kwargs) - - # Move _commit to the end of the form - commit = self.fields.pop('_commit') - self.fields['_commit'] = commit - - @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/forms/__init__.py b/netbox/extras/forms/__init__.py new file mode 100644 index 000000000..1584e2f51 --- /dev/null +++ b/netbox/extras/forms/__init__.py @@ -0,0 +1,6 @@ +from .models import * +from .filtersets import * +from .bulk_edit import * +from .bulk_import import * +from .customfields import * +from .scripts import * diff --git a/netbox/extras/forms/bulk_edit.py b/netbox/extras/forms/bulk_edit.py new file mode 100644 index 000000000..b85a74a5b --- /dev/null +++ b/netbox/extras/forms/bulk_edit.py @@ -0,0 +1,199 @@ +from django import forms +from django.contrib.contenttypes.models import ContentType + +from extras.choices import * +from extras.models import * +from extras.utils import FeatureQuery +from utilities.forms import ( + BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect, ColorField, ContentTypeChoiceField, StaticSelect, +) + +__all__ = ( + 'ConfigContextBulkEditForm', + 'CustomFieldBulkEditForm', + 'CustomLinkBulkEditForm', + 'ExportTemplateBulkEditForm', + 'JournalEntryBulkEditForm', + 'TagBulkEditForm', + 'WebhookBulkEditForm', +) + + +class CustomFieldBulkEditForm(BootstrapMixin, BulkEditForm): + pk = forms.ModelMultipleChoiceField( + queryset=CustomField.objects.all(), + widget=forms.MultipleHiddenInput + ) + description = forms.CharField( + required=False + ) + required = forms.NullBooleanField( + required=False, + widget=BulkEditNullBooleanSelect() + ) + weight = forms.IntegerField( + required=False + ) + + class Meta: + nullable_fields = [] + + +class CustomLinkBulkEditForm(BootstrapMixin, BulkEditForm): + pk = forms.ModelMultipleChoiceField( + queryset=CustomLink.objects.all(), + widget=forms.MultipleHiddenInput + ) + content_type = ContentTypeChoiceField( + queryset=ContentType.objects.all(), + limit_choices_to=FeatureQuery('custom_fields'), + required=False + ) + new_window = forms.NullBooleanField( + required=False, + widget=BulkEditNullBooleanSelect() + ) + weight = forms.IntegerField( + required=False + ) + button_class = forms.ChoiceField( + choices=CustomLinkButtonClassChoices, + required=False, + widget=StaticSelect() + ) + + class Meta: + nullable_fields = [] + + +class ExportTemplateBulkEditForm(BootstrapMixin, BulkEditForm): + pk = forms.ModelMultipleChoiceField( + queryset=ExportTemplate.objects.all(), + widget=forms.MultipleHiddenInput + ) + content_type = ContentTypeChoiceField( + queryset=ContentType.objects.all(), + limit_choices_to=FeatureQuery('custom_fields'), + required=False + ) + description = forms.CharField( + max_length=200, + required=False + ) + mime_type = forms.CharField( + max_length=50, + required=False + ) + file_extension = forms.CharField( + max_length=15, + required=False + ) + as_attachment = forms.NullBooleanField( + required=False, + widget=BulkEditNullBooleanSelect() + ) + + class Meta: + nullable_fields = ['description', 'mime_type', 'file_extension'] + + +class WebhookBulkEditForm(BootstrapMixin, BulkEditForm): + pk = forms.ModelMultipleChoiceField( + queryset=Webhook.objects.all(), + widget=forms.MultipleHiddenInput + ) + enabled = forms.NullBooleanField( + required=False, + widget=BulkEditNullBooleanSelect() + ) + type_create = forms.NullBooleanField( + required=False, + widget=BulkEditNullBooleanSelect() + ) + type_update = forms.NullBooleanField( + required=False, + widget=BulkEditNullBooleanSelect() + ) + type_delete = forms.NullBooleanField( + required=False, + widget=BulkEditNullBooleanSelect() + ) + http_method = forms.ChoiceField( + choices=WebhookHttpMethodChoices, + required=False + ) + payload_url = forms.CharField( + required=False + ) + ssl_verification = forms.NullBooleanField( + required=False, + widget=BulkEditNullBooleanSelect() + ) + secret = forms.CharField( + required=False + ) + ca_file_path = forms.CharField( + required=False + ) + + class Meta: + nullable_fields = ['secret', 'ca_file_path'] + + +class TagBulkEditForm(BootstrapMixin, BulkEditForm): + pk = forms.ModelMultipleChoiceField( + queryset=Tag.objects.all(), + widget=forms.MultipleHiddenInput + ) + color = ColorField( + required=False + ) + description = forms.CharField( + max_length=200, + required=False + ) + + class Meta: + nullable_fields = ['description'] + + +class ConfigContextBulkEditForm(BootstrapMixin, BulkEditForm): + pk = forms.ModelMultipleChoiceField( + queryset=ConfigContext.objects.all(), + widget=forms.MultipleHiddenInput + ) + weight = forms.IntegerField( + required=False, + min_value=0 + ) + is_active = forms.NullBooleanField( + required=False, + widget=BulkEditNullBooleanSelect() + ) + description = forms.CharField( + required=False, + max_length=100 + ) + + class Meta: + nullable_fields = [ + 'description', + ] + + +class JournalEntryBulkEditForm(BootstrapMixin, BulkEditForm): + pk = forms.ModelMultipleChoiceField( + queryset=JournalEntry.objects.all(), + widget=forms.MultipleHiddenInput + ) + kind = forms.ChoiceField( + choices=JournalEntryKindChoices, + required=False + ) + comments = forms.CharField( + required=False, + widget=forms.Textarea() + ) + + class Meta: + nullable_fields = [] diff --git a/netbox/extras/forms/bulk_import.py b/netbox/extras/forms/bulk_import.py new file mode 100644 index 000000000..fb8cf53e8 --- /dev/null +++ b/netbox/extras/forms/bulk_import.py @@ -0,0 +1,91 @@ +from django import forms +from django.contrib.contenttypes.models import ContentType +from django.contrib.postgres.forms import SimpleArrayField +from django.utils.safestring import mark_safe + +from extras.models import * +from extras.utils import FeatureQuery +from utilities.forms import CSVContentTypeField, CSVModelForm, CSVMultipleContentTypeField, SlugField + +__all__ = ( + 'CustomFieldCSVForm', + 'CustomLinkCSVForm', + 'ExportTemplateCSVForm', + 'TagCSVForm', + 'WebhookCSVForm', +) + + +class CustomFieldCSVForm(CSVModelForm): + content_types = CSVMultipleContentTypeField( + queryset=ContentType.objects.all(), + limit_choices_to=FeatureQuery('custom_fields'), + help_text="One or more assigned object types" + ) + choices = SimpleArrayField( + base_field=forms.CharField(), + required=False, + help_text='Comma-separated list of field choices' + ) + + class Meta: + model = CustomField + fields = ( + 'name', 'label', 'type', 'content_types', 'required', 'description', 'weight', 'filter_logic', 'default', + 'choices', 'weight', + ) + + +class CustomLinkCSVForm(CSVModelForm): + content_type = CSVContentTypeField( + queryset=ContentType.objects.all(), + limit_choices_to=FeatureQuery('custom_links'), + help_text="Assigned object type" + ) + + class Meta: + model = CustomLink + fields = ( + 'name', 'content_type', 'weight', 'group_name', 'button_class', 'new_window', 'link_text', 'link_url', + ) + + +class ExportTemplateCSVForm(CSVModelForm): + content_type = CSVContentTypeField( + queryset=ContentType.objects.all(), + limit_choices_to=FeatureQuery('export_templates'), + help_text="Assigned object type" + ) + + class Meta: + model = ExportTemplate + fields = ( + 'name', 'content_type', 'description', 'mime_type', 'file_extension', 'as_attachment', 'template_code', + ) + + +class WebhookCSVForm(CSVModelForm): + content_types = CSVMultipleContentTypeField( + queryset=ContentType.objects.all(), + limit_choices_to=FeatureQuery('webhooks'), + help_text="One or more assigned object types" + ) + + class Meta: + model = Webhook + fields = ( + 'name', 'enabled', 'content_types', 'type_create', 'type_update', 'type_delete', 'payload_url', + 'http_method', 'http_content_type', 'additional_headers', 'body_template', 'secret', 'ssl_verification', + 'ca_file_path' + ) + + +class TagCSVForm(CSVModelForm): + slug = SlugField() + + class Meta: + model = Tag + fields = ('name', 'slug', 'color', 'description') + help_texts = { + 'color': mark_safe('RGB color in hexadecimal (e.g. 00ff00)'), + } diff --git a/netbox/extras/forms/customfields.py b/netbox/extras/forms/customfields.py new file mode 100644 index 000000000..9f68467fa --- /dev/null +++ b/netbox/extras/forms/customfields.py @@ -0,0 +1,123 @@ +from django import forms +from django.contrib.contenttypes.models import ContentType + +from extras.choices import * +from extras.models import * +from utilities.forms import BulkEditForm, CSVModelForm + +__all__ = ( + 'CustomFieldModelCSVForm', + 'CustomFieldModelBulkEditForm', + 'CustomFieldModelFilterForm', + 'CustomFieldModelForm', + 'CustomFieldsMixin', +) + + +class CustomFieldsMixin: + """ + Extend a Form to include custom field support. + """ + def __init__(self, *args, **kwargs): + self.custom_fields = [] + + super().__init__(*args, **kwargs) + + self._append_customfield_fields() + + def _get_content_type(self): + """ + Return the ContentType of the form's model. + """ + if not hasattr(self, 'model'): + raise NotImplementedError(f"{self.__class__.__name__} must specify a model class.") + return ContentType.objects.get_for_model(self.model) + + def _get_form_field(self, customfield): + return customfield.to_form_field() + + def _append_customfield_fields(self): + """ + Append form fields for all CustomFields assigned to this object type. + """ + content_type = self._get_content_type() + + # Append form fields; assign initial values if modifying and existing object + for customfield in CustomField.objects.filter(content_types=content_type): + field_name = f'cf_{customfield.name}' + self.fields[field_name] = self._get_form_field(customfield) + + # Annotate the field in the list of CustomField form fields + self.custom_fields.append(field_name) + + +class CustomFieldModelForm(CustomFieldsMixin, forms.ModelForm): + """ + Extend ModelForm to include custom field support. + """ + def _get_content_type(self): + return ContentType.objects.get_for_model(self._meta.model) + + def _get_form_field(self, customfield): + if self.instance.pk: + form_field = customfield.to_form_field(set_initial=False) + form_field.initial = self.instance.custom_field_data.get(customfield.name, None) + return form_field + + return customfield.to_form_field() + + def clean(self): + + # Save custom field data on instance + for cf_name in self.custom_fields: + key = cf_name[3:] # Strip "cf_" from field name + value = self.cleaned_data.get(cf_name) + empty_values = self.fields[cf_name].empty_values + # Convert "empty" values to null + self.instance.custom_field_data[key] = value if value not in empty_values else None + + return super().clean() + + +class CustomFieldModelCSVForm(CSVModelForm, CustomFieldModelForm): + + def _get_form_field(self, customfield): + return customfield.to_form_field(for_csv_import=True) + + +class CustomFieldModelBulkEditForm(BulkEditForm): + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + self.custom_fields = [] + self.obj_type = ContentType.objects.get_for_model(self.model) + + # Add all applicable CustomFields to the form + custom_fields = CustomField.objects.filter(content_types=self.obj_type) + for cf in custom_fields: + # Annotate non-required custom fields as nullable + if not cf.required: + self.nullable_fields.append(cf.name) + self.fields[cf.name] = cf.to_form_field(set_initial=False, enforce_required=False) + # Annotate this as a custom field + self.custom_fields.append(cf.name) + + +class CustomFieldModelFilterForm(forms.Form): + + def __init__(self, *args, **kwargs): + + self.obj_type = ContentType.objects.get_for_model(self.model) + + super().__init__(*args, **kwargs) + + # Add all applicable CustomFields to the form + self.custom_field_filters = [] + custom_fields = CustomField.objects.filter(content_types=self.obj_type).exclude( + filter_logic=CustomFieldFilterLogicChoices.FILTER_DISABLED + ) + for cf in custom_fields: + field_name = 'cf_{}'.format(cf.name) + self.fields[field_name] = cf.to_form_field(set_initial=True, enforce_required=False) + self.custom_field_filters.append(field_name) diff --git a/netbox/extras/forms/filtersets.py b/netbox/extras/forms/filtersets.py new file mode 100644 index 000000000..6196ba8da --- /dev/null +++ b/netbox/extras/forms/filtersets.py @@ -0,0 +1,364 @@ +from django import forms +from django.contrib.auth.models import User +from django.contrib.contenttypes.models import ContentType +from django.utils.translation import gettext as _ + +from dcim.models import DeviceRole, DeviceType, Platform, Region, Site, SiteGroup +from extras.choices import * +from extras.models import * +from extras.utils import FeatureQuery +from tenancy.models import Tenant, TenantGroup +from utilities.forms import ( + add_blank_choice, APISelectMultiple, BootstrapMixin, ContentTypeChoiceField, + ContentTypeMultipleChoiceField, DateTimePicker, DynamicModelMultipleChoiceField, StaticSelect, + StaticSelectMultiple, BOOLEAN_WITH_BLANK_CHOICES, +) +from virtualization.models import Cluster, ClusterGroup + +__all__ = ( + 'ConfigContextFilterForm', + 'CustomFieldFilterForm', + 'CustomLinkFilterForm', + 'ExportTemplateFilterForm', + 'JournalEntryFilterForm', + 'LocalConfigContextFilterForm', + 'ObjectChangeFilterForm', + 'TagFilterForm', + 'WebhookFilterForm', +) + + +class CustomFieldFilterForm(BootstrapMixin, forms.Form): + field_groups = [ + ['q'], + ['type', 'content_types'], + ['weight', 'required'], + ] + q = forms.CharField( + required=False, + widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), + label=_('Search') + ) + content_types = ContentTypeMultipleChoiceField( + queryset=ContentType.objects.all(), + limit_choices_to=FeatureQuery('custom_fields'), + required=False + ) + type = forms.MultipleChoiceField( + choices=CustomFieldTypeChoices, + required=False, + widget=StaticSelectMultiple(), + label=_('Field type') + ) + weight = forms.IntegerField( + required=False + ) + required = forms.NullBooleanField( + required=False, + widget=StaticSelect( + choices=BOOLEAN_WITH_BLANK_CHOICES + ) + ) + + +class CustomLinkFilterForm(BootstrapMixin, forms.Form): + field_groups = [ + ['q'], + ['content_type', 'weight', 'new_window'], + ] + q = forms.CharField( + required=False, + widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), + label=_('Search') + ) + content_type = ContentTypeChoiceField( + queryset=ContentType.objects.all(), + limit_choices_to=FeatureQuery('custom_fields'), + required=False + ) + weight = forms.IntegerField( + required=False + ) + new_window = forms.NullBooleanField( + required=False, + widget=StaticSelect( + choices=BOOLEAN_WITH_BLANK_CHOICES + ) + ) + + +class ExportTemplateFilterForm(BootstrapMixin, forms.Form): + field_groups = [ + ['q'], + ['content_type', 'mime_type', 'file_extension', 'as_attachment'], + ] + q = forms.CharField( + required=False, + widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), + label=_('Search') + ) + content_type = ContentTypeChoiceField( + queryset=ContentType.objects.all(), + limit_choices_to=FeatureQuery('custom_fields'), + required=False + ) + mime_type = forms.CharField( + required=False, + label=_('MIME type') + ) + file_extension = forms.CharField( + required=False + ) + as_attachment = forms.NullBooleanField( + required=False, + widget=StaticSelect( + choices=BOOLEAN_WITH_BLANK_CHOICES + ) + ) + + +class WebhookFilterForm(BootstrapMixin, forms.Form): + field_groups = [ + ['q'], + ['content_types', 'http_method', 'enabled'], + ['type_create', 'type_update', 'type_delete'], + ] + q = forms.CharField( + required=False, + widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), + label=_('Search') + ) + content_types = ContentTypeMultipleChoiceField( + queryset=ContentType.objects.all(), + limit_choices_to=FeatureQuery('custom_fields'), + required=False + ) + http_method = forms.MultipleChoiceField( + choices=WebhookHttpMethodChoices, + required=False, + widget=StaticSelectMultiple(), + label=_('HTTP method') + ) + enabled = forms.NullBooleanField( + required=False, + widget=StaticSelect( + choices=BOOLEAN_WITH_BLANK_CHOICES + ) + ) + type_create = forms.NullBooleanField( + required=False, + widget=StaticSelect( + choices=BOOLEAN_WITH_BLANK_CHOICES + ) + ) + type_update = forms.NullBooleanField( + required=False, + widget=StaticSelect( + choices=BOOLEAN_WITH_BLANK_CHOICES + ) + ) + type_delete = forms.NullBooleanField( + required=False, + widget=StaticSelect( + choices=BOOLEAN_WITH_BLANK_CHOICES + ) + ) + + +class TagFilterForm(BootstrapMixin, forms.Form): + model = Tag + q = forms.CharField( + required=False, + label=_('Search') + ) + content_type_id = ContentTypeMultipleChoiceField( + queryset=ContentType.objects.filter(FeatureQuery('tags').get_query()), + required=False, + label=_('Tagged object type') + ) + + +class ConfigContextFilterForm(BootstrapMixin, forms.Form): + field_groups = [ + ['q', 'tag'], + ['region_id', 'site_group_id', 'site_id'], + ['device_type_id', 'platform_id', 'role_id'], + ['cluster_group_id', 'cluster_id'], + ['tenant_group_id', 'tenant_id'] + ] + q = forms.CharField( + required=False, + widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), + label=_('Search') + ) + region_id = DynamicModelMultipleChoiceField( + queryset=Region.objects.all(), + required=False, + label=_('Regions'), + fetch_trigger='open' + ) + site_group_id = DynamicModelMultipleChoiceField( + queryset=SiteGroup.objects.all(), + required=False, + label=_('Site groups'), + fetch_trigger='open' + ) + site_id = DynamicModelMultipleChoiceField( + queryset=Site.objects.all(), + required=False, + label=_('Sites'), + fetch_trigger='open' + ) + device_type_id = DynamicModelMultipleChoiceField( + queryset=DeviceType.objects.all(), + required=False, + label=_('Device types'), + fetch_trigger='open' + ) + role_id = DynamicModelMultipleChoiceField( + queryset=DeviceRole.objects.all(), + required=False, + label=_('Roles'), + fetch_trigger='open' + ) + platform_id = DynamicModelMultipleChoiceField( + queryset=Platform.objects.all(), + required=False, + label=_('Platforms'), + fetch_trigger='open' + ) + cluster_group_id = DynamicModelMultipleChoiceField( + queryset=ClusterGroup.objects.all(), + required=False, + label=_('Cluster groups'), + fetch_trigger='open' + ) + cluster_id = DynamicModelMultipleChoiceField( + queryset=Cluster.objects.all(), + required=False, + label=_('Clusters'), + fetch_trigger='open' + ) + tenant_group_id = DynamicModelMultipleChoiceField( + queryset=TenantGroup.objects.all(), + required=False, + label=_('Tenant groups'), + fetch_trigger='open' + ) + tenant_id = DynamicModelMultipleChoiceField( + queryset=Tenant.objects.all(), + required=False, + label=_('Tenant'), + fetch_trigger='open' + ) + tag = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), + to_field_name='slug', + required=False, + label=_('Tags'), + fetch_trigger='open' + ) + + +class LocalConfigContextFilterForm(forms.Form): + local_context_data = forms.NullBooleanField( + required=False, + label=_('Has local config context data'), + widget=StaticSelect( + choices=BOOLEAN_WITH_BLANK_CHOICES + ) + ) + + +class JournalEntryFilterForm(BootstrapMixin, forms.Form): + model = JournalEntry + field_groups = [ + ['q'], + ['created_before', 'created_after', 'created_by_id'], + ['assigned_object_type_id', 'kind'] + ] + q = forms.CharField( + required=False, + widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), + label=_('Search') + ) + created_after = forms.DateTimeField( + required=False, + label=_('After'), + widget=DateTimePicker() + ) + created_before = forms.DateTimeField( + required=False, + label=_('Before'), + widget=DateTimePicker() + ) + created_by_id = DynamicModelMultipleChoiceField( + queryset=User.objects.all(), + required=False, + label=_('User'), + widget=APISelectMultiple( + api_url='/api/users/users/', + ), + fetch_trigger='open' + ) + assigned_object_type_id = DynamicModelMultipleChoiceField( + queryset=ContentType.objects.all(), + required=False, + label=_('Object Type'), + widget=APISelectMultiple( + api_url='/api/extras/content-types/', + ), + fetch_trigger='open' + ) + kind = forms.ChoiceField( + choices=add_blank_choice(JournalEntryKindChoices), + required=False, + widget=StaticSelect() + ) + + +class ObjectChangeFilterForm(BootstrapMixin, forms.Form): + model = ObjectChange + field_groups = [ + ['q'], + ['time_before', 'time_after', 'action'], + ['user_id', 'changed_object_type_id'], + ] + q = forms.CharField( + required=False, + widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), + label=_('Search') + ) + time_after = forms.DateTimeField( + required=False, + label=_('After'), + widget=DateTimePicker() + ) + time_before = forms.DateTimeField( + required=False, + label=_('Before'), + widget=DateTimePicker() + ) + action = forms.ChoiceField( + choices=add_blank_choice(ObjectChangeActionChoices), + required=False, + widget=StaticSelect() + ) + user_id = DynamicModelMultipleChoiceField( + queryset=User.objects.all(), + required=False, + label=_('User'), + widget=APISelectMultiple( + api_url='/api/users/users/', + ), + fetch_trigger='open' + ) + changed_object_type_id = DynamicModelMultipleChoiceField( + queryset=ContentType.objects.all(), + required=False, + label=_('Object Type'), + widget=APISelectMultiple( + api_url='/api/extras/content-types/', + ), + fetch_trigger='open' + ) diff --git a/netbox/extras/forms/models.py b/netbox/extras/forms/models.py new file mode 100644 index 000000000..7e462e62b --- /dev/null +++ b/netbox/extras/forms/models.py @@ -0,0 +1,223 @@ +from django import forms +from django.contrib.contenttypes.models import ContentType + +from dcim.models import DeviceRole, DeviceType, Platform, Region, Site, SiteGroup +from extras.choices import * +from extras.models import * +from extras.utils import FeatureQuery +from tenancy.models import Tenant, TenantGroup +from utilities.forms import ( + add_blank_choice, BootstrapMixin, CommentField, ContentTypeChoiceField, + ContentTypeMultipleChoiceField, DynamicModelMultipleChoiceField, JSONField, SlugField, StaticSelect, +) +from virtualization.models import Cluster, ClusterGroup + +__all__ = ( + 'AddRemoveTagsForm', + 'ConfigContextForm', + 'CustomFieldForm', + 'CustomLinkForm', + 'ExportTemplateForm', + 'ImageAttachmentForm', + 'JournalEntryForm', + 'TagForm', + 'WebhookForm', +) + + +class CustomFieldForm(BootstrapMixin, forms.ModelForm): + content_types = ContentTypeMultipleChoiceField( + queryset=ContentType.objects.all(), + limit_choices_to=FeatureQuery('custom_fields') + ) + + class Meta: + model = CustomField + fields = '__all__' + fieldsets = ( + ('Custom Field', ('name', 'label', 'type', 'weight', 'required', 'description')), + ('Assigned Models', ('content_types',)), + ('Behavior', ('filter_logic',)), + ('Values', ('default', 'choices')), + ('Validation', ('validation_minimum', 'validation_maximum', 'validation_regex')), + ) + + +class CustomLinkForm(BootstrapMixin, forms.ModelForm): + content_type = ContentTypeChoiceField( + queryset=ContentType.objects.all(), + limit_choices_to=FeatureQuery('custom_links') + ) + + class Meta: + model = CustomLink + fields = '__all__' + fieldsets = ( + ('Custom Link', ('name', 'content_type', 'weight', 'group_name', 'button_class', 'new_window')), + ('Templates', ('link_text', 'link_url')), + ) + widgets = { + 'link_text': forms.Textarea(attrs={'class': 'font-monospace'}), + 'link_url': forms.Textarea(attrs={'class': 'font-monospace'}), + } + help_texts = { + 'link_text': 'Jinja2 template code for the link text. Reference the object as {{ obj }}. ' + 'Links which render as empty text will not be displayed.', + 'link_url': 'Jinja2 template code for the link URL. Reference the object as {{ obj }}.', + } + + +class ExportTemplateForm(BootstrapMixin, forms.ModelForm): + content_type = ContentTypeChoiceField( + queryset=ContentType.objects.all(), + limit_choices_to=FeatureQuery('custom_links') + ) + + class Meta: + model = ExportTemplate + fields = '__all__' + fieldsets = ( + ('Custom Link', ('name', 'content_type', 'description')), + ('Template', ('template_code',)), + ('Rendering', ('mime_type', 'file_extension', 'as_attachment')), + ) + widgets = { + 'template_code': forms.Textarea(attrs={'class': 'font-monospace'}), + } + + +class WebhookForm(BootstrapMixin, forms.ModelForm): + content_types = ContentTypeMultipleChoiceField( + queryset=ContentType.objects.all(), + limit_choices_to=FeatureQuery('webhooks') + ) + + class Meta: + model = Webhook + fields = '__all__' + fieldsets = ( + ('Webhook', ('name', 'enabled')), + ('Assigned Models', ('content_types',)), + ('Events', ('type_create', 'type_update', 'type_delete')), + ('HTTP Request', ( + 'payload_url', 'http_method', 'http_content_type', 'additional_headers', 'body_template', 'secret', + )), + ('SSL', ('ssl_verification', 'ca_file_path')), + ) + widgets = { + 'additional_headers': forms.Textarea(attrs={'class': 'font-monospace'}), + 'body_template': forms.Textarea(attrs={'class': 'font-monospace'}), + } + + +class TagForm(BootstrapMixin, forms.ModelForm): + slug = SlugField() + + class Meta: + model = Tag + fields = [ + 'name', 'slug', 'color', 'description' + ] + fieldsets = ( + ('Tag', ('name', 'slug', 'color', 'description')), + ) + + +class AddRemoveTagsForm(forms.Form): + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + # Add add/remove tags fields + self.fields['add_tags'] = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), + required=False + ) + self.fields['remove_tags'] = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), + required=False + ) + + +class ConfigContextForm(BootstrapMixin, forms.ModelForm): + regions = DynamicModelMultipleChoiceField( + queryset=Region.objects.all(), + required=False + ) + site_groups = DynamicModelMultipleChoiceField( + queryset=SiteGroup.objects.all(), + required=False + ) + sites = DynamicModelMultipleChoiceField( + queryset=Site.objects.all(), + required=False + ) + device_types = DynamicModelMultipleChoiceField( + queryset=DeviceType.objects.all(), + required=False + ) + roles = DynamicModelMultipleChoiceField( + queryset=DeviceRole.objects.all(), + required=False + ) + platforms = DynamicModelMultipleChoiceField( + queryset=Platform.objects.all(), + required=False + ) + cluster_groups = DynamicModelMultipleChoiceField( + queryset=ClusterGroup.objects.all(), + required=False + ) + clusters = DynamicModelMultipleChoiceField( + queryset=Cluster.objects.all(), + required=False + ) + tenant_groups = DynamicModelMultipleChoiceField( + queryset=TenantGroup.objects.all(), + required=False + ) + tenants = DynamicModelMultipleChoiceField( + queryset=Tenant.objects.all(), + required=False + ) + tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), + required=False + ) + data = JSONField( + label='' + ) + + class Meta: + model = ConfigContext + fields = ( + 'name', 'weight', 'description', 'is_active', 'regions', 'site_groups', 'sites', 'roles', 'device_types', + 'platforms', 'cluster_groups', 'clusters', 'tenant_groups', 'tenants', 'tags', 'data', + ) + + +class ImageAttachmentForm(BootstrapMixin, forms.ModelForm): + + class Meta: + model = ImageAttachment + fields = [ + 'name', 'image', + ] + + +class JournalEntryForm(BootstrapMixin, forms.ModelForm): + comments = CommentField() + + kind = forms.ChoiceField( + choices=add_blank_choice(JournalEntryKindChoices), + required=False, + widget=StaticSelect() + ) + + class Meta: + model = JournalEntry + fields = ['assigned_object_type', 'assigned_object_id', 'kind', 'comments'] + widgets = { + 'assigned_object_type': forms.HiddenInput, + 'assigned_object_id': forms.HiddenInput, + } diff --git a/netbox/extras/forms/scripts.py b/netbox/extras/forms/scripts.py new file mode 100644 index 000000000..380b4364c --- /dev/null +++ b/netbox/extras/forms/scripts.py @@ -0,0 +1,30 @@ +from django import forms + +from utilities.forms import BootstrapMixin + +__all__ = ( + 'ScriptForm', +) + + +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, *args, **kwargs): + super().__init__(*args, **kwargs) + + # Move _commit to the end of the form + commit = self.fields.pop('_commit') + self.fields['_commit'] = commit + + @property + def requires_input(self): + """ + A boolean indicating whether the form requires user input (ignore the _commit field). + """ + return bool(len(self.fields) > 1) From abb72868f21dda8913ff59df3958120dc1349bac Mon Sep 17 00:00:00 2001 From: Daniel Sheppard Date: Tue, 28 Sep 2021 09:50:23 -0500 Subject: [PATCH 26/37] Update changelog for #7374 --- docs/release-notes/version-3.0.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/release-notes/version-3.0.md b/docs/release-notes/version-3.0.md index 9fcaa712d..2a5a47b5a 100644 --- a/docs/release-notes/version-3.0.md +++ b/docs/release-notes/version-3.0.md @@ -19,6 +19,7 @@ * [#7356](https://github.com/netbox-community/netbox/issues/7356) - Fix display of model documentation when adding device components * [#7358](https://github.com/netbox-community/netbox/issues/7358) - Add missing `choices` column to custom field CSV import form * [#7360](https://github.com/netbox-community/netbox/issues/7360) - Correct redirection URL after removing child device from device bay +* [#7374](https://github.com/netbox-community/netbox/issues/7374) - Add missing `face` parameter to API elevations request when editing device ## v3.0.3 (2021-09-20) From 71449b3414ec0b89b314f13f35b26913112239f8 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 28 Sep 2021 12:04:54 -0400 Subject: [PATCH 27/37] Fixes #7304: Require explicit values for all required choice fields during CSV import --- docs/release-notes/version-3.0.md | 1 + netbox/circuits/forms/bulk_import.py | 1 - netbox/circuits/tests/test_views.py | 8 ++++---- netbox/dcim/forms/bulk_import.py | 6 ------ netbox/dcim/tests/test_views.py | 16 ++++++++-------- netbox/extras/tests/test_customfields.py | 8 ++++---- netbox/ipam/forms/bulk_import.py | 1 - netbox/virtualization/forms/bulk_import.py | 1 - netbox/virtualization/tests/test_views.py | 8 ++++---- 9 files changed, 21 insertions(+), 29 deletions(-) diff --git a/docs/release-notes/version-3.0.md b/docs/release-notes/version-3.0.md index 2a5a47b5a..796f9562f 100644 --- a/docs/release-notes/version-3.0.md +++ b/docs/release-notes/version-3.0.md @@ -11,6 +11,7 @@ ### Bug Fixes * [#7294](https://github.com/netbox-community/netbox/issues/7294) - Fix SVG rendering for cable traces ending at unoccupied front ports +* [#7304](https://github.com/netbox-community/netbox/issues/7304) - Require explicit values for all required choice fields during CSV import * [#7321](https://github.com/netbox-community/netbox/issues/7321) - Don't overwrite multi-select custom fields during bulk edit * [#7324](https://github.com/netbox-community/netbox/issues/7324) - Fix TypeError exception in web UI when filtering objects using single-choice filters * [#7333](https://github.com/netbox-community/netbox/issues/7333) - Prevent inadvertent deletion of prior change records when deleting objects diff --git a/netbox/circuits/forms/bulk_import.py b/netbox/circuits/forms/bulk_import.py index 41ee7281a..af5ec4425 100644 --- a/netbox/circuits/forms/bulk_import.py +++ b/netbox/circuits/forms/bulk_import.py @@ -60,7 +60,6 @@ class CircuitCSVForm(CustomFieldModelCSVForm): ) status = CSVChoiceField( choices=CircuitStatusChoices, - required=False, help_text='Operational status' ) tenant = CSVModelChoiceField( diff --git a/netbox/circuits/tests/test_views.py b/netbox/circuits/tests/test_views.py index b6401b2fa..ccb4a869a 100644 --- a/netbox/circuits/tests/test_views.py +++ b/netbox/circuits/tests/test_views.py @@ -122,10 +122,10 @@ class CircuitTestCase(ViewTestCases.PrimaryObjectViewTestCase): } cls.csv_data = ( - "cid,provider,type", - "Circuit 4,Provider 1,Circuit Type 1", - "Circuit 5,Provider 1,Circuit Type 1", - "Circuit 6,Provider 1,Circuit Type 1", + "cid,provider,type,status", + "Circuit 4,Provider 1,Circuit Type 1,active", + "Circuit 5,Provider 1,Circuit Type 1,active", + "Circuit 6,Provider 1,Circuit Type 1,active", ) cls.bulk_edit_data = { diff --git a/netbox/dcim/forms/bulk_import.py b/netbox/dcim/forms/bulk_import.py index 93f17e839..072cdf0e0 100644 --- a/netbox/dcim/forms/bulk_import.py +++ b/netbox/dcim/forms/bulk_import.py @@ -70,7 +70,6 @@ class SiteGroupCSVForm(CustomFieldModelCSVForm): class SiteCSVForm(CustomFieldModelCSVForm): status = CSVChoiceField( choices=SiteStatusChoices, - required=False, help_text='Operational status' ) region = CSVModelChoiceField( @@ -156,7 +155,6 @@ class RackCSVForm(CustomFieldModelCSVForm): ) status = CSVChoiceField( choices=RackStatusChoices, - required=False, help_text='Operational status' ) role = CSVModelChoiceField( @@ -929,22 +927,18 @@ class PowerFeedCSVForm(CustomFieldModelCSVForm): ) status = CSVChoiceField( choices=PowerFeedStatusChoices, - required=False, help_text='Operational status' ) type = CSVChoiceField( choices=PowerFeedTypeChoices, - required=False, help_text='Primary or redundant' ) supply = CSVChoiceField( choices=PowerFeedSupplyChoices, - required=False, help_text='Supply type (AC/DC)' ) phase = CSVChoiceField( choices=PowerFeedPhaseChoices, - required=False, help_text='Single or three-phase' ) diff --git a/netbox/dcim/tests/test_views.py b/netbox/dcim/tests/test_views.py index c38dc4ea7..18eaeec3b 100644 --- a/netbox/dcim/tests/test_views.py +++ b/netbox/dcim/tests/test_views.py @@ -322,10 +322,10 @@ class RackTestCase(ViewTestCases.PrimaryObjectViewTestCase): } cls.csv_data = ( - "site,location,name,width,u_height", - "Site 1,,Rack 4,19,42", - "Site 1,Location 1,Rack 5,19,42", - "Site 2,Location 2,Rack 6,19,42", + "site,location,name,status,width,u_height", + "Site 1,,Rack 4,active,19,42", + "Site 1,Location 1,Rack 5,active,19,42", + "Site 2,Location 2,Rack 6,active,19,42", ) cls.bulk_edit_data = { @@ -1991,10 +1991,10 @@ class PowerFeedTestCase(ViewTestCases.PrimaryObjectViewTestCase): } cls.csv_data = ( - "site,power_panel,name,voltage,amperage,max_utilization", - "Site 1,Power Panel 1,Power Feed 4,120,20,80", - "Site 1,Power Panel 1,Power Feed 5,120,20,80", - "Site 1,Power Panel 1,Power Feed 6,120,20,80", + "site,power_panel,name,status,type,supply,phase,voltage,amperage,max_utilization", + "Site 1,Power Panel 1,Power Feed 4,active,primary,ac,single-phase,120,20,80", + "Site 1,Power Panel 1,Power Feed 5,active,primary,ac,single-phase,120,20,80", + "Site 1,Power Panel 1,Power Feed 6,active,primary,ac,single-phase,120,20,80", ) cls.bulk_edit_data = { diff --git a/netbox/extras/tests/test_customfields.py b/netbox/extras/tests/test_customfields.py index c2a2da3dc..32c473678 100644 --- a/netbox/extras/tests/test_customfields.py +++ b/netbox/extras/tests/test_customfields.py @@ -506,10 +506,10 @@ class CustomFieldImportTest(TestCase): Import a Site in CSV format, including a value for each CustomField. """ data = ( - ('name', 'slug', 'cf_text', 'cf_integer', 'cf_boolean', 'cf_date', 'cf_url', 'cf_select'), - ('Site 1', 'site-1', 'ABC', '123', 'True', '2020-01-01', 'http://example.com/1', 'Choice A'), - ('Site 2', 'site-2', 'DEF', '456', 'False', '2020-01-02', 'http://example.com/2', 'Choice B'), - ('Site 3', 'site-3', '', '', '', '', '', ''), + ('name', 'slug', 'status', 'cf_text', 'cf_integer', 'cf_boolean', 'cf_date', 'cf_url', 'cf_select'), + ('Site 1', 'site-1', 'active', 'ABC', '123', 'True', '2020-01-01', 'http://example.com/1', 'Choice A'), + ('Site 2', 'site-2', 'active', 'DEF', '456', 'False', '2020-01-02', 'http://example.com/2', 'Choice B'), + ('Site 3', 'site-3', 'active', '', '', '', '', '', ''), ) csv_data = '\n'.join(','.join(row) for row in data) diff --git a/netbox/ipam/forms/bulk_import.py b/netbox/ipam/forms/bulk_import.py index ef5759748..49d5014f9 100644 --- a/netbox/ipam/forms/bulk_import.py +++ b/netbox/ipam/forms/bulk_import.py @@ -198,7 +198,6 @@ class IPAddressCSVForm(CustomFieldModelCSVForm): ) status = CSVChoiceField( choices=IPAddressStatusChoices, - required=False, help_text='Operational status' ) role = CSVChoiceField( diff --git a/netbox/virtualization/forms/bulk_import.py b/netbox/virtualization/forms/bulk_import.py index 1f0496b7c..d01418aa0 100644 --- a/netbox/virtualization/forms/bulk_import.py +++ b/netbox/virtualization/forms/bulk_import.py @@ -64,7 +64,6 @@ class ClusterCSVForm(CustomFieldModelCSVForm): class VirtualMachineCSVForm(CustomFieldModelCSVForm): status = CSVChoiceField( choices=VirtualMachineStatusChoices, - required=False, help_text='Operational status of device' ) cluster = CSVModelChoiceField( diff --git a/netbox/virtualization/tests/test_views.py b/netbox/virtualization/tests/test_views.py index 86be5159f..020c9ebc5 100644 --- a/netbox/virtualization/tests/test_views.py +++ b/netbox/virtualization/tests/test_views.py @@ -194,10 +194,10 @@ class VirtualMachineTestCase(ViewTestCases.PrimaryObjectViewTestCase): } cls.csv_data = ( - "name,cluster", - "Virtual Machine 4,Cluster 1", - "Virtual Machine 5,Cluster 1", - "Virtual Machine 6,Cluster 1", + "name,status,cluster", + "Virtual Machine 4,active,Cluster 1", + "Virtual Machine 5,active,Cluster 1", + "Virtual Machine 6,active,Cluster 1", ) cls.bulk_edit_data = { From 3ec0fe5519cbd174b91a0916b459c5615cb04d74 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 28 Sep 2021 13:23:57 -0400 Subject: [PATCH 28/37] Closes #7372: Link to local docs for model from object add/edit views --- docs/release-notes/version-3.0.md | 1 + netbox/templates/generic/object_edit.html | 12 +++--------- netbox/templates/inc/modal.html | 15 --------------- netbox/utilities/templatetags/helpers.py | 22 +++------------------- 4 files changed, 7 insertions(+), 43 deletions(-) delete mode 100644 netbox/templates/inc/modal.html diff --git a/docs/release-notes/version-3.0.md b/docs/release-notes/version-3.0.md index 796f9562f..55fc78bb0 100644 --- a/docs/release-notes/version-3.0.md +++ b/docs/release-notes/version-3.0.md @@ -7,6 +7,7 @@ * [#6917](https://github.com/netbox-community/netbox/issues/6917) - Make ip assigned checkmark in ip table link to interface * [#7118](https://github.com/netbox-community/netbox/issues/7118) - Render URL custom fields as hyperlinks in object tables * [#7323](https://github.com/netbox-community/netbox/issues/7323) - Add serial filter field for racks & devices +* [#7372](https://github.com/netbox-community/netbox/issues/7372) - Link to local docs for model from object add/edit views ### Bug Fixes diff --git a/netbox/templates/generic/object_edit.html b/netbox/templates/generic/object_edit.html index 7ee4c4f94..bd3f6059b 100644 --- a/netbox/templates/generic/object_edit.html +++ b/netbox/templates/generic/object_edit.html @@ -7,12 +7,12 @@ {% endblock title %} {% block controls %} - {% if settings.DOCS_ROOT %} + {% if obj and settings.DOCS_ROOT %} {% endif %} @@ -84,7 +84,6 @@
    {% block buttons %} Cancel - {% if obj.pk %} {% endif %} - {% endblock buttons %}
    - {% if obj and settings.DOCS_ROOT %} - {% include 'inc/modal.html' with name='docs' content=obj|get_docs %} - {% endif %} - {% endblock content-wrapper %} diff --git a/netbox/templates/inc/modal.html b/netbox/templates/inc/modal.html deleted file mode 100644 index 054e94541..000000000 --- a/netbox/templates/inc/modal.html +++ /dev/null @@ -1,15 +0,0 @@ - diff --git a/netbox/utilities/templatetags/helpers.py b/netbox/utilities/templatetags/helpers.py index 615595f0f..532eea19b 100644 --- a/netbox/utilities/templatetags/helpers.py +++ b/netbox/utilities/templatetags/helpers.py @@ -216,27 +216,11 @@ def percentage(x, y): @register.filter() -def get_docs(model): +def get_docs_url(model): """ - Render and return documentation for the specified model. + Return the documentation URL for the specified model. """ - path = '{}/models/{}/{}.md'.format( - settings.DOCS_ROOT, - model._meta.app_label, - model._meta.model_name - ) - try: - with open(path, encoding='utf-8') as docfile: - content = docfile.read() - except FileNotFoundError: - return "Unable to load documentation, file not found: {}".format(path) - except IOError: - return "Unable to load documentation, error reading file: {}".format(path) - - # Render Markdown with the admonition extension - content = markdown(content, extensions=['admonition', 'fenced_code', 'tables']) - - return mark_safe(content) + return f'{settings.STATIC_URL}docs/models/{model._meta.app_label}/{model._meta.model_name}/' @register.filter() From fd180e480a6ab8d87a0f540ae570aa569db3414b Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 28 Sep 2021 13:40:24 -0400 Subject: [PATCH 29/37] Changelog & docs cleanup for #6973 --- docs/customization/reports.md | 3 +-- docs/release-notes/version-3.0.md | 3 ++- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/customization/reports.md b/docs/customization/reports.md index a227f3851..ed4faf371 100644 --- a/docs/customization/reports.md +++ b/docs/customization/reports.md @@ -97,8 +97,7 @@ The recording of one or more failure messages will automatically flag a report a To perform additional tasks, such as sending an email or calling a webhook, after a report has been run, extend the `post_run()` method. The status of the report is available as `self.failed` and the results object is `self.result`. -By default, reports within a module are unordered and 'randomly' displayed in the reports list page. If you want to order reports, you can defined the `report_order` variable at the end -of your module. The `report_order` variable is a tuple which contains each Report class in a specific order. +By default, reports within a module are ordered alphabetically in the reports list page. To return reports in a specific order, you can define the `report_order` variable at the end of your module. The `report_order` variable is a tuple which contains each Report class in the desired order. Any reports that are omitted from this list will be listed last. ``` from extras.reports import Report diff --git a/docs/release-notes/version-3.0.md b/docs/release-notes/version-3.0.md index 55fc78bb0..019d018a6 100644 --- a/docs/release-notes/version-3.0.md +++ b/docs/release-notes/version-3.0.md @@ -4,7 +4,8 @@ ### Enhancements -* [#6917](https://github.com/netbox-community/netbox/issues/6917) - Make ip assigned checkmark in ip table link to interface +* [#6917](https://github.com/netbox-community/netbox/issues/6917) - Make IP assigned checkmark in IP table link to interface +* [#6973](https://github.com/netbox-community/netbox/issues/6973) - Enable custom ordering of reports * [#7118](https://github.com/netbox-community/netbox/issues/7118) - Render URL custom fields as hyperlinks in object tables * [#7323](https://github.com/netbox-community/netbox/issues/7323) - Add serial filter field for racks & devices * [#7372](https://github.com/netbox-community/netbox/issues/7372) - Link to local docs for model from object add/edit views From bfb37d6283dff7c3f4b3a1d088aa02da57aa50ed Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 28 Sep 2021 13:46:54 -0400 Subject: [PATCH 30/37] Closes #7022: Add ITA type C (CEE 7/16) power port type --- docs/release-notes/version-3.0.md | 1 + netbox/dcim/choices.py | 2 ++ 2 files changed, 3 insertions(+) diff --git a/docs/release-notes/version-3.0.md b/docs/release-notes/version-3.0.md index 019d018a6..9018d651b 100644 --- a/docs/release-notes/version-3.0.md +++ b/docs/release-notes/version-3.0.md @@ -6,6 +6,7 @@ * [#6917](https://github.com/netbox-community/netbox/issues/6917) - Make IP assigned checkmark in IP table link to interface * [#6973](https://github.com/netbox-community/netbox/issues/6973) - Enable custom ordering of reports +* [#7022](https://github.com/netbox-community/netbox/issues/7022) - Add ITA type C (CEE 7/16) power port type * [#7118](https://github.com/netbox-community/netbox/issues/7118) - Render URL custom fields as hyperlinks in object tables * [#7323](https://github.com/netbox-community/netbox/issues/7323) - Add serial filter field for racks & devices * [#7372](https://github.com/netbox-community/netbox/issues/7372) - Link to local docs for model from object add/edit views diff --git a/netbox/dcim/choices.py b/netbox/dcim/choices.py index df0b1651b..0bfd987f7 100644 --- a/netbox/dcim/choices.py +++ b/netbox/dcim/choices.py @@ -316,6 +316,7 @@ class PowerPortTypeChoices(ChoiceSet): TYPE_CS8365C = 'cs8365c' TYPE_CS8465C = 'cs8465c' # ITA/international + TYPE_ITA_C = 'ita-c' TYPE_ITA_E = 'ita-e' TYPE_ITA_F = 'ita-f' TYPE_ITA_EF = 'ita-ef' @@ -421,6 +422,7 @@ class PowerPortTypeChoices(ChoiceSet): (TYPE_CS8465C, 'CS8465C'), )), ('International/ITA', ( + (TYPE_ITA_C, 'ITA Type C (CEE 7/16)'), (TYPE_ITA_E, 'ITA Type E (CEE 7/5)'), (TYPE_ITA_F, 'ITA Type F (CEE 7/4)'), (TYPE_ITA_EF, 'ITA Type E/F (CEE 7/7)'), From ad65e06d0a9fd03ea6edeb53200609ec2a100663 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 28 Sep 2021 15:44:41 -0400 Subject: [PATCH 31/37] Closes #7252: Validate IP range size does not exceed max supported value --- docs/models/ipam/iprange.md | 3 +++ docs/release-notes/version-3.0.md | 1 + netbox/ipam/models/ip.py | 5 +++++ 3 files changed, 9 insertions(+) diff --git a/docs/models/ipam/iprange.md b/docs/models/ipam/iprange.md index ab712e5b2..7b0457f27 100644 --- a/docs/models/ipam/iprange.md +++ b/docs/models/ipam/iprange.md @@ -9,3 +9,6 @@ IP also ranges share the same functional roles as prefixes and VLANs, although t * Deprecated - No longer in use The status of a range does _not_ have any impact on its member IP addresses, which may have their statuses modified separately. + +!!! note + The maximum supported size of an IP range is 2^32 - 1. diff --git a/docs/release-notes/version-3.0.md b/docs/release-notes/version-3.0.md index 9018d651b..2a316bfed 100644 --- a/docs/release-notes/version-3.0.md +++ b/docs/release-notes/version-3.0.md @@ -13,6 +13,7 @@ ### Bug Fixes +* [#7252](https://github.com/netbox-community/netbox/issues/7252) - Validate IP range size does not exceed max supported value * [#7294](https://github.com/netbox-community/netbox/issues/7294) - Fix SVG rendering for cable traces ending at unoccupied front ports * [#7304](https://github.com/netbox-community/netbox/issues/7304) - Require explicit values for all required choice fields during CSV import * [#7321](https://github.com/netbox-community/netbox/issues/7321) - Don't overwrite multi-select custom fields during bulk edit diff --git a/netbox/ipam/models/ip.py b/netbox/ipam/models/ip.py index 3e2e671ca..8d5a27686 100644 --- a/netbox/ipam/models/ip.py +++ b/netbox/ipam/models/ip.py @@ -600,6 +600,11 @@ class IPRange(PrimaryModel): if overlapping_range: raise ValidationError(f"Defined addresses overlap with range {overlapping_range} in VRF {self.vrf}") + # Validate maximum size + MAX_SIZE = 2 ** 32 - 1 + if int(self.end_address.ip - self.start_address.ip) + 1 > MAX_SIZE: + raise ValidationError(f"Defined range exceeds maximum supported size ({MAX_SIZE})") + def save(self, *args, **kwargs): # Record the range's size (number of IP addresses) From 8dc0767cdfce755f8341301f8a2108ddea2d90bf Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 28 Sep 2021 16:22:28 -0400 Subject: [PATCH 32/37] Changelog for #7365 --- docs/release-notes/version-3.0.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/release-notes/version-3.0.md b/docs/release-notes/version-3.0.md index 2a316bfed..6a6b23f19 100644 --- a/docs/release-notes/version-3.0.md +++ b/docs/release-notes/version-3.0.md @@ -24,6 +24,7 @@ * [#7356](https://github.com/netbox-community/netbox/issues/7356) - Fix display of model documentation when adding device components * [#7358](https://github.com/netbox-community/netbox/issues/7358) - Add missing `choices` column to custom field CSV import form * [#7360](https://github.com/netbox-community/netbox/issues/7360) - Correct redirection URL after removing child device from device bay +* [#7365](https://github.com/netbox-community/netbox/issues/7365) - Optimize performance when calculating prefix utilization * [#7374](https://github.com/netbox-community/netbox/issues/7374) - Add missing `face` parameter to API elevations request when editing device ## v3.0.3 (2021-09-20) From 047425daddda19a7cbcc071bc3ff32f33a7f3cc4 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 28 Sep 2021 16:28:11 -0400 Subject: [PATCH 33/37] Closes #7389: Linkify tenant group in tenants list --- docs/release-notes/version-3.0.md | 1 + netbox/tenancy/tables.py | 3 +++ 2 files changed, 4 insertions(+) diff --git a/docs/release-notes/version-3.0.md b/docs/release-notes/version-3.0.md index 6a6b23f19..eafd1bcdc 100644 --- a/docs/release-notes/version-3.0.md +++ b/docs/release-notes/version-3.0.md @@ -10,6 +10,7 @@ * [#7118](https://github.com/netbox-community/netbox/issues/7118) - Render URL custom fields as hyperlinks in object tables * [#7323](https://github.com/netbox-community/netbox/issues/7323) - Add serial filter field for racks & devices * [#7372](https://github.com/netbox-community/netbox/issues/7372) - Link to local docs for model from object add/edit views +* [#7389](https://github.com/netbox-community/netbox/issues/7389) - Linkify tenant group in tenants list ### Bug Fixes diff --git a/netbox/tenancy/tables.py b/netbox/tenancy/tables.py index c62c641d1..f39ca1b18 100644 --- a/netbox/tenancy/tables.py +++ b/netbox/tenancy/tables.py @@ -68,6 +68,9 @@ class TenantTable(BaseTable): name = tables.Column( linkify=True ) + group = tables.Column( + linkify=True + ) comments = MarkdownColumn() tags = TagColumn( url_name='tenancy:tenant_list' From 854121b6ecb43c205a38ec5090e754863975f8b3 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 28 Sep 2021 16:42:54 -0400 Subject: [PATCH 34/37] Closes #7314: Add SMA 905/906 fiber port types --- docs/release-notes/version-3.0.md | 1 + netbox/dcim/choices.py | 4 ++++ 2 files changed, 5 insertions(+) diff --git a/docs/release-notes/version-3.0.md b/docs/release-notes/version-3.0.md index eafd1bcdc..2e06cb6b8 100644 --- a/docs/release-notes/version-3.0.md +++ b/docs/release-notes/version-3.0.md @@ -8,6 +8,7 @@ * [#6973](https://github.com/netbox-community/netbox/issues/6973) - Enable custom ordering of reports * [#7022](https://github.com/netbox-community/netbox/issues/7022) - Add ITA type C (CEE 7/16) power port type * [#7118](https://github.com/netbox-community/netbox/issues/7118) - Render URL custom fields as hyperlinks in object tables +* [#7314](https://github.com/netbox-community/netbox/issues/7314) - Add SMA 905/906 fiber port types * [#7323](https://github.com/netbox-community/netbox/issues/7323) - Add serial filter field for racks & devices * [#7372](https://github.com/netbox-community/netbox/issues/7372) - Link to local docs for model from object add/edit views * [#7389](https://github.com/netbox-community/netbox/issues/7389) - Linkify tenant group in tenants list diff --git a/netbox/dcim/choices.py b/netbox/dcim/choices.py index 0bfd987f7..f8fbab86b 100644 --- a/netbox/dcim/choices.py +++ b/netbox/dcim/choices.py @@ -969,6 +969,8 @@ class PortTypeChoices(ChoiceSet): TYPE_SPLICE = 'splice' TYPE_CS = 'cs' TYPE_SN = 'sn' + TYPE_SMA_905 = 'sma-905' + TYPE_SMA_906 = 'sma-906' TYPE_URM_P2 = 'urm-p2' TYPE_URM_P4 = 'urm-p4' TYPE_URM_P8 = 'urm-p8' @@ -1012,6 +1014,8 @@ class PortTypeChoices(ChoiceSet): (TYPE_ST, 'ST'), (TYPE_CS, 'CS'), (TYPE_SN, 'SN'), + (TYPE_SMA_905, 'SMA 905'), + (TYPE_SMA_906, 'SMA 906'), (TYPE_URM_P2, 'URM-P2'), (TYPE_URM_P4, 'URM-P4'), (TYPE_URM_P8, 'URM-P8'), From 6b3e0d32295cbfdf193c90a543c6d66531d031b3 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 28 Sep 2021 17:07:15 -0400 Subject: [PATCH 35/37] Drop pycryptodome as a Python dependency --- base_requirements.txt | 4 ---- requirements.txt | 1 - 2 files changed, 5 deletions(-) diff --git a/base_requirements.txt b/base_requirements.txt index acd8d22c8..11ddac634 100644 --- a/base_requirements.txt +++ b/base_requirements.txt @@ -94,10 +94,6 @@ Pillow # https://github.com/psycopg/psycopg2 psycopg2-binary -# Extensive cryptographic library (fork of pycrypto) -# https://github.com/Legrandin/pycryptodome -pycryptodome - # YAML rendering library # https://github.com/yaml/pyyaml PyYAML diff --git a/requirements.txt b/requirements.txt index 83873a55a..4d378956c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -22,7 +22,6 @@ mkdocs-material==7.2.6 netaddr==0.8.0 Pillow==8.3.2 psycopg2-binary==2.9.1 -pycryptodome==3.10.1 PyYAML==5.4.1 svgwrite==1.4.1 tablib==3.0.0 From 38f34ddb28f3cf67511e890de5786c1213a08005 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 29 Sep 2021 09:09:10 -0400 Subject: [PATCH 36/37] Fixes #7392: Fix "help" links for custom fields, other models --- docs/additional-features/webhooks.md | 83 +------------------------- docs/customization/custom-fields.md | 42 +------------ docs/customization/custom-links.md | 58 +----------------- docs/customization/export-templates.md | 38 +----------- docs/models/extras/customfield.md | 41 +++++++++++++ docs/models/extras/customlink.md | 57 ++++++++++++++++++ docs/models/extras/exporttemplate.md | 37 ++++++++++++ docs/models/extras/webhook.md | 82 +++++++++++++++++++++++++ docs/release-notes/version-3.0.md | 1 + 9 files changed, 222 insertions(+), 217 deletions(-) create mode 100644 docs/models/extras/customfield.md create mode 100644 docs/models/extras/customlink.md create mode 100644 docs/models/extras/exporttemplate.md create mode 100644 docs/models/extras/webhook.md diff --git a/docs/additional-features/webhooks.md b/docs/additional-features/webhooks.md index 19133adb1..3f10cb9e6 100644 --- a/docs/additional-features/webhooks.md +++ b/docs/additional-features/webhooks.md @@ -1,85 +1,4 @@ -# Webhooks - -A webhook is a mechanism for conveying to some external system a change that took place in NetBox. For example, you may want to notify a monitoring system whenever the status of a device is updated in NetBox. This can be done by creating a webhook for the device model in NetBox and identifying the webhook receiver. When NetBox detects a change to a device, an HTTP request containing the details of the change and who made it be sent to the specified receiver. Webhooks are managed under Logging > Webhooks. - -!!! warning - Webhooks support the inclusion of user-submitted code to generate custom headers and payloads, which may pose security risks under certain conditions. Only grant permission to create or modify webhooks to trusted users. - -## Configuration - -* **Name** - A unique name for the webhook. The name is not included with outbound messages. -* **Object type(s)** - The type or types of NetBox object that will trigger the webhook. -* **Enabled** - If unchecked, the webhook will be inactive. -* **Events** - A webhook may trigger on any combination of create, update, and delete events. At least one event type must be selected. -* **HTTP method** - The type of HTTP request to send. Options include `GET`, `POST`, `PUT`, `PATCH`, and `DELETE`. -* **URL** - The fuly-qualified URL of the request to be sent. This may specify a destination port number if needed. -* **HTTP content type** - The value of the request's `Content-Type` header. (Defaults to `application/json`) -* **Additional headers** - Any additional headers to include with the request (optional). Add one header per line in the format `Name: Value`. Jinja2 templating is supported for this field (see below). -* **Body template** - The content of the request being sent (optional). Jinja2 templating is supported for this field (see below). If blank, NetBox will populate the request body with a raw dump of the webhook context. (If the HTTP cotent type is set to `application/json`, this will be formatted as a JSON object.) -* **Secret** - A secret string used to prove authenticity of the request (optional). This will append a `X-Hook-Signature` header to the request, consisting of a HMAC (SHA-512) hex digest of the request body using the secret as the key. -* **SSL verification** - Uncheck this option to disable validation of the receiver's SSL certificate. (Disable with caution!) -* **CA file path** - The file path to a particular certificate authority (CA) file to use when validating the receiver's SSL certificate (optional). - -## Jinja2 Template Support - -[Jinja2 templating](https://jinja.palletsprojects.com/) is supported for the `additional_headers` and `body_template` fields. This enables the user to convey object data in the request headers as well as to craft a customized request body. Request content can be crafted to enable the direct interaction with external systems by ensuring the outgoing message is in a format the receiver expects and understands. - -For example, you might create a NetBox webhook to [trigger a Slack message](https://api.slack.com/messaging/webhooks) any time an IP address is created. You can accomplish this using the following configuration: - -* Object type: IPAM > IP address -* HTTP method: `POST` -* URL: Slack incoming webhook URL -* HTTP content type: `application/json` -* Body template: `{"text": "IP address {{ data['address'] }} was created by {{ username }}!"}` - -### Available Context - -The following data is available as context for Jinja2 templates: - -* `event` - The type of event which triggered the webhook: created, updated, or deleted. -* `model` - The NetBox model which triggered the change. -* `timestamp` - The time at which the event occurred (in [ISO 8601](https://en.wikipedia.org/wiki/ISO_8601) format). -* `username` - The name of the user account associated with the change. -* `request_id` - The unique request ID. This may be used to correlate multiple changes associated with a single request. -* `data` - A detailed representation of the object in its current state. This is typically equivalent to the model's representation in NetBox's REST API. -* `snapshots` - Minimal "snapshots" of the object state both before and after the change was made; provided ass a dictionary with keys named `prechange` and `postchange`. These are not as extensive as the fully serialized representation, but contain enough information to convey what has changed. - -### Default Request Body - -If no body template is specified, the request body will be populated with a JSON object containing the context data. For example, a newly created site might appear as follows: - -```no-highlight -{ - "event": "created", - "timestamp": "2021-03-09 17:55:33.968016+00:00", - "model": "site", - "username": "jstretch", - "request_id": "fdbca812-3142-4783-b364-2e2bd5c16c6a", - "data": { - "id": 19, - "name": "Site 1", - "slug": "site-1", - "status": - "value": "active", - "label": "Active", - "id": 1 - }, - "region": null, - ... - }, - "snapshots": { - "prechange": null, - "postchange": { - "created": "2021-03-09", - "last_updated": "2021-03-09T17:55:33.851Z", - "name": "Site 1", - "slug": "site-1", - "status": "active", - ... - } - } -} -``` +{!models/extras/webhook.md!} ## Webhook Processing diff --git a/docs/customization/custom-fields.md b/docs/customization/custom-fields.md index a9acfb3f7..757416626 100644 --- a/docs/customization/custom-fields.md +++ b/docs/customization/custom-fields.md @@ -1,44 +1,4 @@ -# Custom Fields - -Each model in NetBox is represented in the database as a discrete table, and each attribute of a model exists as a column within its table. For example, sites are stored in the `dcim_site` table, which has columns named `name`, `facility`, `physical_address`, and so on. As new attributes are added to objects throughout the development of NetBox, tables are expanded to include new rows. - -However, some users might want to store additional object attributes that are somewhat esoteric in nature, and that would not make sense to include in the core NetBox database schema. For instance, suppose your organization needs to associate each device with a ticket number correlating it with an internal support system record. This is certainly a legitimate use for NetBox, but it's not a common enough need to warrant including a field for _every_ NetBox installation. Instead, you can create a custom field to hold this data. - -Within the database, custom fields are stored as JSON data directly alongside each object. This alleviates the need for complex queries when retrieving objects. - -## Creating Custom Fields - -Custom fields may be created by navigating to Customization > Custom Fields. NetBox supports six types of custom field: - -* Text: Free-form text (up to 255 characters) -* Integer: A whole number (positive or negative) -* Boolean: True or false -* Date: A date in ISO 8601 format (YYYY-MM-DD) -* URL: This will be presented as a link in the web UI -* Selection: A selection of one of several pre-defined custom choices -* Multiple selection: A selection field which supports the assignment of multiple values - -Each custom field must have a name; this should be a simple database-friendly string, e.g. `tps_report`. You may also assign a corresponding human-friendly label (e.g. "TPS report"); the label will be displayed on web forms. A weight is also required: Higher-weight fields will be ordered lower within a form. (The default weight is 100.) If a description is provided, it will appear beneath the field in a form. - -Marking a field as required will force the user to provide a value for the field when creating a new object or when saving an existing object. A default value for the field may also be provided. Use "true" or "false" for boolean fields, or the exact value of a choice for selection fields. - -The filter logic controls how values are matched when filtering objects by the custom field. Loose filtering (the default) matches on a partial value, whereas exact matching requires a complete match of the given string to a field's value. For example, exact filtering with the string "red" will only match the exact value "red", whereas loose filtering will match on the values "red", "red-orange", or "bored". Setting the filter logic to "disabled" disables filtering by the field entirely. - -A custom field must be assigned to one or more object types, or models, in NetBox. Once created, custom fields will automatically appear as part of these models in the web UI and REST API. Note that not all models support custom fields. - -### Custom Field Validation - -NetBox supports limited custom validation for custom field values. Following are the types of validation enforced for each field type: - -* Text: Regular expression (optional) -* Integer: Minimum and/or maximum value (optional) -* Selection: Must exactly match one of the prescribed choices - -### Custom Selection Fields - -Each custom selection field must have at least two choices. These are specified as a comma-separated list. Choices appear in forms in the order they are listed. Note that choice values are saved exactly as they appear, so it's best to avoid superfluous punctuation or symbols where possible. - -If a default value is specified for a selection field, it must exactly match one of the provided choices. The value of a multiple selection field will always return a list, even if only one value is selected. +{!models/extras/customfield.md!} ## Custom Fields in Templates diff --git a/docs/customization/custom-links.md b/docs/customization/custom-links.md index 44c8f403f..1ee366cfd 100644 --- a/docs/customization/custom-links.md +++ b/docs/customization/custom-links.md @@ -1,57 +1 @@ -# Custom Links - -Custom links allow users to display arbitrary hyperlinks to external content within NetBox object views. These are helpful for cross-referencing related records in systems outside NetBox. 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 by navigating to Customization > Custom Links. 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: - -```no-highlight -View NMS -``` - -Custom links appear as buttons in the top right corner of the page. Numeric weighting can be used to influence the ordering of links. - -!!! warning - Custom links rely on user-created code to generate arbitrary HTML output, which may be dangerous. Only grant permission to create or modify custom links to trusted users. - -## Context Data - -The following context data is available within the template when rendering a custom link's text or URL. - -| Variable | Description | -|----------|-------------| -| `obj` | The NetBox object being displayed | -| `debug` | A boolean indicating whether debugging is enabled | -| `request` | The current WSGI request | -| `user` | The current user (if authenticated) | -| `perms` | The [permissions](https://docs.djangoproject.com/en/stable/topics/auth/default/#permissions) assigned to the user | - -## 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 - -```jinja2 -{% if obj.status == 'active' %}View NMS{% endif %} -``` - -The link will not appear when viewing a device with any status other than "active." - -As another example, if you wanted to show only devices belonging to a certain manufacturer, you could do something like this: - -```jinja2 -{% if obj.device_type.manufacturer.name == 'Cisco' %}View NMS{% endif %} -``` - -The link will only appear when viewing a device with a manufacturer name of "Cisco." - -## Link Groups - -Group names can be specified to organize links into groups. Links with the same group name will render as a dropdown menu beneath a single button bearing the name of the group. +{!models/extras/customlink.md!} diff --git a/docs/customization/export-templates.md b/docs/customization/export-templates.md index c6097c552..affd39aae 100644 --- a/docs/customization/export-templates.md +++ b/docs/customization/export-templates.md @@ -1,40 +1,4 @@ -# Export Templates - -NetBox allows users to define custom templates that can be used when exporting objects. To create an export template, navigate to Customization > Export Templates. - -Each export template is associated with a certain type of object. For instance, if you create an export template for VLANs, your custom template will appear under the "Export" button on the VLANs list. Each export template must have a name, and may optionally designate a specific export [MIME type](https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/MIME_types) and/or file extension. - -Export templates must be written in [Jinja2](https://jinja.palletsprojects.com/). - -!!! note - The name `table` is reserved for internal use. - -!!! warning - Export templates are rendered using user-submitted code, which may pose security risks under certain conditions. Only grant permission to create or modify export templates to trusted users. - -The list of objects returned from the database when rendering an export template is stored in the `queryset` variable, which you'll typically want to iterate through using a `for` loop. Object properties can be access by name. For example: - -```jinja2 -{% for rack in queryset %} -Rack: {{ rack.name }} -Site: {{ rack.site.name }} -Height: {{ rack.u_height }}U -{% endfor %} -``` - -To access custom fields of an object within a template, use the `cf` attribute. For example, `{{ obj.cf.color }}` will return the value (if any) for a custom field named `color` on `obj`. - -If you need to use the config context data in an export template, you'll should use the function `get_config_context` to get all the config context data. For example: -``` -{% for server in queryset %} -{% set data = server.get_config_context() %} -{{ data.syslog }} -{% endfor %} -``` - -The `as_attachment` attribute of an export template controls its behavior when rendered. If true, the rendered content will be returned to the user as a downloadable file. If false, it will be displayed within the browser. (This may be handy e.g. for generating HTML content.) - -A MIME type and file extension can optionally be defined for each export template. The default MIME type is `text/plain`. +{!models/extras/exporttemplate.md!} ## REST API Integration diff --git a/docs/models/extras/customfield.md b/docs/models/extras/customfield.md new file mode 100644 index 000000000..52b8bab1e --- /dev/null +++ b/docs/models/extras/customfield.md @@ -0,0 +1,41 @@ +# Custom Fields + +Each model in NetBox is represented in the database as a discrete table, and each attribute of a model exists as a column within its table. For example, sites are stored in the `dcim_site` table, which has columns named `name`, `facility`, `physical_address`, and so on. As new attributes are added to objects throughout the development of NetBox, tables are expanded to include new rows. + +However, some users might want to store additional object attributes that are somewhat esoteric in nature, and that would not make sense to include in the core NetBox database schema. For instance, suppose your organization needs to associate each device with a ticket number correlating it with an internal support system record. This is certainly a legitimate use for NetBox, but it's not a common enough need to warrant including a field for _every_ NetBox installation. Instead, you can create a custom field to hold this data. + +Within the database, custom fields are stored as JSON data directly alongside each object. This alleviates the need for complex queries when retrieving objects. + +## Creating Custom Fields + +Custom fields may be created by navigating to Customization > Custom Fields. NetBox supports six types of custom field: + +* Text: Free-form text (up to 255 characters) +* Integer: A whole number (positive or negative) +* Boolean: True or false +* Date: A date in ISO 8601 format (YYYY-MM-DD) +* URL: This will be presented as a link in the web UI +* Selection: A selection of one of several pre-defined custom choices +* Multiple selection: A selection field which supports the assignment of multiple values + +Each custom field must have a name; this should be a simple database-friendly string, e.g. `tps_report`. You may also assign a corresponding human-friendly label (e.g. "TPS report"); the label will be displayed on web forms. A weight is also required: Higher-weight fields will be ordered lower within a form. (The default weight is 100.) If a description is provided, it will appear beneath the field in a form. + +Marking a field as required will force the user to provide a value for the field when creating a new object or when saving an existing object. A default value for the field may also be provided. Use "true" or "false" for boolean fields, or the exact value of a choice for selection fields. + +The filter logic controls how values are matched when filtering objects by the custom field. Loose filtering (the default) matches on a partial value, whereas exact matching requires a complete match of the given string to a field's value. For example, exact filtering with the string "red" will only match the exact value "red", whereas loose filtering will match on the values "red", "red-orange", or "bored". Setting the filter logic to "disabled" disables filtering by the field entirely. + +A custom field must be assigned to one or more object types, or models, in NetBox. Once created, custom fields will automatically appear as part of these models in the web UI and REST API. Note that not all models support custom fields. + +### Custom Field Validation + +NetBox supports limited custom validation for custom field values. Following are the types of validation enforced for each field type: + +* Text: Regular expression (optional) +* Integer: Minimum and/or maximum value (optional) +* Selection: Must exactly match one of the prescribed choices + +### Custom Selection Fields + +Each custom selection field must have at least two choices. These are specified as a comma-separated list. Choices appear in forms in the order they are listed. Note that choice values are saved exactly as they appear, so it's best to avoid superfluous punctuation or symbols where possible. + +If a default value is specified for a selection field, it must exactly match one of the provided choices. The value of a multiple selection field will always return a list, even if only one value is selected. diff --git a/docs/models/extras/customlink.md b/docs/models/extras/customlink.md new file mode 100644 index 000000000..44c8f403f --- /dev/null +++ b/docs/models/extras/customlink.md @@ -0,0 +1,57 @@ +# Custom Links + +Custom links allow users to display arbitrary hyperlinks to external content within NetBox object views. These are helpful for cross-referencing related records in systems outside NetBox. 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 by navigating to Customization > Custom Links. 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: + +```no-highlight +View NMS +``` + +Custom links appear as buttons in the top right corner of the page. Numeric weighting can be used to influence the ordering of links. + +!!! warning + Custom links rely on user-created code to generate arbitrary HTML output, which may be dangerous. Only grant permission to create or modify custom links to trusted users. + +## Context Data + +The following context data is available within the template when rendering a custom link's text or URL. + +| Variable | Description | +|----------|-------------| +| `obj` | The NetBox object being displayed | +| `debug` | A boolean indicating whether debugging is enabled | +| `request` | The current WSGI request | +| `user` | The current user (if authenticated) | +| `perms` | The [permissions](https://docs.djangoproject.com/en/stable/topics/auth/default/#permissions) assigned to the user | + +## 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 + +```jinja2 +{% if obj.status == 'active' %}View NMS{% endif %} +``` + +The link will not appear when viewing a device with any status other than "active." + +As another example, if you wanted to show only devices belonging to a certain manufacturer, you could do something like this: + +```jinja2 +{% if obj.device_type.manufacturer.name == 'Cisco' %}View NMS{% endif %} +``` + +The link will only appear when viewing a device with a manufacturer name of "Cisco." + +## Link Groups + +Group names can be specified to organize links into groups. Links with the same group name will render as a dropdown menu beneath a single button bearing the name of the group. diff --git a/docs/models/extras/exporttemplate.md b/docs/models/extras/exporttemplate.md new file mode 100644 index 000000000..e76a3ad47 --- /dev/null +++ b/docs/models/extras/exporttemplate.md @@ -0,0 +1,37 @@ +# Export Templates + +NetBox allows users to define custom templates that can be used when exporting objects. To create an export template, navigate to Customization > Export Templates. + +Each export template is associated with a certain type of object. For instance, if you create an export template for VLANs, your custom template will appear under the "Export" button on the VLANs list. Each export template must have a name, and may optionally designate a specific export [MIME type](https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/MIME_types) and/or file extension. + +Export templates must be written in [Jinja2](https://jinja.palletsprojects.com/). + +!!! note + The name `table` is reserved for internal use. + +!!! warning + Export templates are rendered using user-submitted code, which may pose security risks under certain conditions. Only grant permission to create or modify export templates to trusted users. + +The list of objects returned from the database when rendering an export template is stored in the `queryset` variable, which you'll typically want to iterate through using a `for` loop. Object properties can be access by name. For example: + +```jinja2 +{% for rack in queryset %} +Rack: {{ rack.name }} +Site: {{ rack.site.name }} +Height: {{ rack.u_height }}U +{% endfor %} +``` + +To access custom fields of an object within a template, use the `cf` attribute. For example, `{{ obj.cf.color }}` will return the value (if any) for a custom field named `color` on `obj`. + +If you need to use the config context data in an export template, you'll should use the function `get_config_context` to get all the config context data. For example: +``` +{% for server in queryset %} +{% set data = server.get_config_context() %} +{{ data.syslog }} +{% endfor %} +``` + +The `as_attachment` attribute of an export template controls its behavior when rendered. If true, the rendered content will be returned to the user as a downloadable file. If false, it will be displayed within the browser. (This may be handy e.g. for generating HTML content.) + +A MIME type and file extension can optionally be defined for each export template. The default MIME type is `text/plain`. diff --git a/docs/models/extras/webhook.md b/docs/models/extras/webhook.md new file mode 100644 index 000000000..ee5e9d059 --- /dev/null +++ b/docs/models/extras/webhook.md @@ -0,0 +1,82 @@ +# Webhooks + +A webhook is a mechanism for conveying to some external system a change that took place in NetBox. For example, you may want to notify a monitoring system whenever the status of a device is updated in NetBox. This can be done by creating a webhook for the device model in NetBox and identifying the webhook receiver. When NetBox detects a change to a device, an HTTP request containing the details of the change and who made it be sent to the specified receiver. Webhooks are managed under Logging > Webhooks. + +!!! warning + Webhooks support the inclusion of user-submitted code to generate custom headers and payloads, which may pose security risks under certain conditions. Only grant permission to create or modify webhooks to trusted users. + +## Configuration + +* **Name** - A unique name for the webhook. The name is not included with outbound messages. +* **Object type(s)** - The type or types of NetBox object that will trigger the webhook. +* **Enabled** - If unchecked, the webhook will be inactive. +* **Events** - A webhook may trigger on any combination of create, update, and delete events. At least one event type must be selected. +* **HTTP method** - The type of HTTP request to send. Options include `GET`, `POST`, `PUT`, `PATCH`, and `DELETE`. +* **URL** - The fuly-qualified URL of the request to be sent. This may specify a destination port number if needed. +* **HTTP content type** - The value of the request's `Content-Type` header. (Defaults to `application/json`) +* **Additional headers** - Any additional headers to include with the request (optional). Add one header per line in the format `Name: Value`. Jinja2 templating is supported for this field (see below). +* **Body template** - The content of the request being sent (optional). Jinja2 templating is supported for this field (see below). If blank, NetBox will populate the request body with a raw dump of the webhook context. (If the HTTP cotent type is set to `application/json`, this will be formatted as a JSON object.) +* **Secret** - A secret string used to prove authenticity of the request (optional). This will append a `X-Hook-Signature` header to the request, consisting of a HMAC (SHA-512) hex digest of the request body using the secret as the key. +* **SSL verification** - Uncheck this option to disable validation of the receiver's SSL certificate. (Disable with caution!) +* **CA file path** - The file path to a particular certificate authority (CA) file to use when validating the receiver's SSL certificate (optional). + +## Jinja2 Template Support + +[Jinja2 templating](https://jinja.palletsprojects.com/) is supported for the `additional_headers` and `body_template` fields. This enables the user to convey object data in the request headers as well as to craft a customized request body. Request content can be crafted to enable the direct interaction with external systems by ensuring the outgoing message is in a format the receiver expects and understands. + +For example, you might create a NetBox webhook to [trigger a Slack message](https://api.slack.com/messaging/webhooks) any time an IP address is created. You can accomplish this using the following configuration: + +* Object type: IPAM > IP address +* HTTP method: `POST` +* URL: Slack incoming webhook URL +* HTTP content type: `application/json` +* Body template: `{"text": "IP address {{ data['address'] }} was created by {{ username }}!"}` + +### Available Context + +The following data is available as context for Jinja2 templates: + +* `event` - The type of event which triggered the webhook: created, updated, or deleted. +* `model` - The NetBox model which triggered the change. +* `timestamp` - The time at which the event occurred (in [ISO 8601](https://en.wikipedia.org/wiki/ISO_8601) format). +* `username` - The name of the user account associated with the change. +* `request_id` - The unique request ID. This may be used to correlate multiple changes associated with a single request. +* `data` - A detailed representation of the object in its current state. This is typically equivalent to the model's representation in NetBox's REST API. +* `snapshots` - Minimal "snapshots" of the object state both before and after the change was made; provided ass a dictionary with keys named `prechange` and `postchange`. These are not as extensive as the fully serialized representation, but contain enough information to convey what has changed. + +### Default Request Body + +If no body template is specified, the request body will be populated with a JSON object containing the context data. For example, a newly created site might appear as follows: + +```json +{ + "event": "created", + "timestamp": "2021-03-09 17:55:33.968016+00:00", + "model": "site", + "username": "jstretch", + "request_id": "fdbca812-3142-4783-b364-2e2bd5c16c6a", + "data": { + "id": 19, + "name": "Site 1", + "slug": "site-1", + "status": + "value": "active", + "label": "Active", + "id": 1 + }, + "region": null, + ... + }, + "snapshots": { + "prechange": null, + "postchange": { + "created": "2021-03-09", + "last_updated": "2021-03-09T17:55:33.851Z", + "name": "Site 1", + "slug": "site-1", + "status": "active", + ... + } + } +} +``` diff --git a/docs/release-notes/version-3.0.md b/docs/release-notes/version-3.0.md index 2e06cb6b8..3a1a866d5 100644 --- a/docs/release-notes/version-3.0.md +++ b/docs/release-notes/version-3.0.md @@ -28,6 +28,7 @@ * [#7360](https://github.com/netbox-community/netbox/issues/7360) - Correct redirection URL after removing child device from device bay * [#7365](https://github.com/netbox-community/netbox/issues/7365) - Optimize performance when calculating prefix utilization * [#7374](https://github.com/netbox-community/netbox/issues/7374) - Add missing `face` parameter to API elevations request when editing device +* [#7392](https://github.com/netbox-community/netbox/issues/7392) - Fix "help" links for custom fields, other models ## v3.0.3 (2021-09-20) From 965ba3aca198dafd084ab65579b902953e2563bf Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 29 Sep 2021 09:18:57 -0400 Subject: [PATCH 37/37] Release v3.0.4 --- .github/ISSUE_TEMPLATE/bug_report.yaml | 8 ++++---- .github/ISSUE_TEMPLATE/feature_request.yaml | 2 +- docs/release-notes/version-3.0.md | 2 +- netbox/netbox/settings.py | 2 +- requirements.txt | 8 ++++---- 5 files changed, 11 insertions(+), 11 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/bug_report.yaml b/.github/ISSUE_TEMPLATE/bug_report.yaml index b3558959f..f63a8f170 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yaml +++ b/.github/ISSUE_TEMPLATE/bug_report.yaml @@ -17,7 +17,7 @@ body: What version of NetBox are you currently running? (If you don't have access to the most recent NetBox release, consider testing on our [demo instance](https://demo.netbox.dev/) before opening a bug report to see if your issue has already been addressed.) - placeholder: v3.0.3 + placeholder: v3.0.4 validations: required: true - type: dropdown @@ -25,9 +25,9 @@ body: label: Python version description: What version of Python are you currently running? options: - - 3.7 - - 3.8 - - 3.9 + - "3.7" + - "3.8" + - "3.9" validations: required: true - type: textarea diff --git a/.github/ISSUE_TEMPLATE/feature_request.yaml b/.github/ISSUE_TEMPLATE/feature_request.yaml index 522c328fa..d39c2210e 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yaml +++ b/.github/ISSUE_TEMPLATE/feature_request.yaml @@ -14,7 +14,7 @@ body: attributes: label: NetBox version description: What version of NetBox are you currently running? - placeholder: v3.0.3 + placeholder: v3.0.4 validations: required: true - type: dropdown diff --git a/docs/release-notes/version-3.0.md b/docs/release-notes/version-3.0.md index 3a1a866d5..bd3817986 100644 --- a/docs/release-notes/version-3.0.md +++ b/docs/release-notes/version-3.0.md @@ -1,6 +1,6 @@ # NetBox v3.0 -## v3.0.4 (FUTURE) +## v3.0.4 (2021-09-29) ### Enhancements diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 6481299f8..15680bea0 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -16,7 +16,7 @@ from django.core.validators import URLValidator # Environment setup # -VERSION = '3.0.4-dev' +VERSION = '3.0.4' # Hostname HOSTNAME = platform.node() diff --git a/requirements.txt b/requirements.txt index 4d378956c..5b9c10e87 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,9 +1,9 @@ Django==3.2.7 -django-cors-headers==3.8.0 +django-cors-headers==3.9.0 django-debug-toolbar==3.2.2 -django-filter==2.4.0 +django-filter==21.1 django-graphiql-debug-toolbar==0.2.0 -django-mptt==0.13.3 +django-mptt==0.13.4 django-pglocks==1.0.4 django-prometheus==2.1.0 django-redis==5.0.0 @@ -18,7 +18,7 @@ gunicorn==20.1.0 Jinja2==3.0.1 Markdown==3.3.4 markdown-include==0.6.0 -mkdocs-material==7.2.6 +mkdocs-material==7.3.0 netaddr==0.8.0 Pillow==8.3.2 psycopg2-binary==2.9.1