('button#reslug')) {
+ const form = slugButton.form;
+ if (form == null) continue;
+ const slugField = form.querySelector('#id_slug') as HTMLInputElement;
+ if (slugField == null) continue;
+ const sourceId = slugField.getAttribute('slug-source');
+ const sourceField = form.querySelector(`#id_${sourceId}`) as HTMLInputElement;
- if (sourceField === null) {
- console.error('Unable to find field for slug field.');
- return;
- }
+ const slugLengthAttr = slugField.getAttribute('maxlength');
+ let slugLength = 50;
- const slugLengthAttr = slugField.getAttribute('maxlength');
- let slugLength = 50;
-
- if (slugLengthAttr) {
- slugLength = Number(slugLengthAttr);
- }
- sourceField.addEventListener('blur', () => {
- if (!slugField.value) {
- slugField.value = slugify(sourceField.value, slugLength);
+ if (slugLengthAttr) {
+ slugLength = Number(slugLengthAttr);
}
- });
- slugButton.addEventListener('click', () => {
- slugField.value = slugify(sourceField.value, slugLength);
- });
+ sourceField.addEventListener('blur', () => {
+ if (!slugField.value) {
+ slugField.value = slugify(sourceField.value, slugLength);
+ }
+ });
+ slugButton.addEventListener('click', () => {
+ slugField.value = slugify(sourceField.value, slugLength);
+ });
+ }
}
diff --git a/netbox/project-static/src/htmx.ts b/netbox/project-static/src/htmx.ts
index f4092036b..6a772011b 100644
--- a/netbox/project-static/src/htmx.ts
+++ b/netbox/project-static/src/htmx.ts
@@ -4,11 +4,16 @@ import { initSelects } from './select';
import { initObjectSelector } from './objectSelector';
import { initBootstrap } from './bs';
import { initMessages } from './messages';
+import { initQuickAdd } from './quickAdd';
function initDepedencies(): void {
- for (const init of [initButtons, initClipboard, initSelects, initObjectSelector, initBootstrap, initMessages]) {
- init();
- }
+ initButtons();
+ initClipboard();
+ initSelects();
+ initObjectSelector();
+ initQuickAdd();
+ initBootstrap();
+ initMessages();
}
/**
diff --git a/netbox/project-static/src/quickAdd.ts b/netbox/project-static/src/quickAdd.ts
new file mode 100644
index 000000000..e038f5d19
--- /dev/null
+++ b/netbox/project-static/src/quickAdd.ts
@@ -0,0 +1,39 @@
+import { Modal } from 'bootstrap';
+
+function handleQuickAddObject(): void {
+ const quick_add = document.getElementById('quick-add-object');
+ if (quick_add == null) return;
+
+ const object_id = quick_add.getAttribute('data-object-id');
+ if (object_id == null) return;
+ const object_repr = quick_add.getAttribute('data-object-repr');
+ if (object_repr == null) return;
+
+ const target_id = quick_add.getAttribute('data-target-id');
+ if (target_id == null) return;
+ const target = document.getElementById(target_id);
+ if (target == null) return;
+
+ //@ts-expect-error tomselect added on init
+ target.tomselect.addOption({
+ id: object_id,
+ display: object_repr,
+ });
+ //@ts-expect-error tomselect added on init
+ target.tomselect.addItem(object_id);
+
+ const modal_element = document.getElementById('htmx-modal');
+ if (modal_element) {
+ const modal = Modal.getInstance(modal_element);
+ if (modal) {
+ modal.hide();
+ }
+ }
+}
+
+export function initQuickAdd(): void {
+ const quick_add_modal = document.getElementById('htmx-modal-content');
+ if (quick_add_modal) {
+ quick_add_modal.addEventListener('htmx:afterSwap', () => handleQuickAddObject());
+ }
+}
diff --git a/netbox/release.yaml b/netbox/release.yaml
index 7126342db..c7919b91e 100644
--- a/netbox/release.yaml
+++ b/netbox/release.yaml
@@ -1,3 +1,3 @@
-version: "4.1.11"
+version: "4.2.0"
edition: "Community"
published: "2025-01-06"
diff --git a/netbox/templates/circuits/circuit.html b/netbox/templates/circuits/circuit.html
index 5c2442b90..d47b380fd 100644
--- a/netbox/templates/circuits/circuit.html
+++ b/netbox/templates/circuits/circuit.html
@@ -34,6 +34,16 @@
{% trans "Status" %}
{% badge object.get_status_display bg_color=object.get_status_color %}
+
+ {% trans "Distance" %}
+
+ {% if object.distance is not None %}
+ {{ object.distance|floatformat }} {{ object.get_distance_unit_display }}
+ {% else %}
+ {{ ''|placeholder }}
+ {% endif %}
+
+
{% trans "Tenant" %}
@@ -66,7 +76,7 @@
{% trans "Group Assignments" %}
{% if perms.circuits.add_circuitgroupassignment %}
diff --git a/netbox/templates/circuits/circuitgroupassignment.html b/netbox/templates/circuits/circuitgroupassignment.html
index bd277a5fc..f1bb9f5d4 100644
--- a/netbox/templates/circuits/circuitgroupassignment.html
+++ b/netbox/templates/circuits/circuitgroupassignment.html
@@ -22,9 +22,13 @@
{% trans "Group" %}
{{ object.group|linkify }}
+
+ {% trans "Provider" %}
+ {{ object.member.provider|linkify }}
+
{% trans "Circuit" %}
- {{ object.circuit|linkify }}
+ {{ object.member|linkify }}
{% trans "Priority" %}
diff --git a/netbox/templates/circuits/inc/circuit_termination_fields.html b/netbox/templates/circuits/inc/circuit_termination_fields.html
index 97d194f24..94c4599b0 100644
--- a/netbox/templates/circuits/inc/circuit_termination_fields.html
+++ b/netbox/templates/circuits/inc/circuit_termination_fields.html
@@ -1,18 +1,19 @@
{% load helpers %}
{% load i18n %}
-{% if termination.site %}
- {% trans "Site" %}
-
- {% if termination.site.region %}
- {{ termination.site.region|linkify }} /
- {% endif %}
- {{ termination.site|linkify }}
-
+ {% trans "Termination point" %}
+ {% if termination.termination %}
+
+ {{ termination.termination|linkify }}
+ {% trans termination.termination_type.name|bettertitle %}
+
+ {% else %}
+ {{ ''|placeholder }}
+ {% endif %}
- {% trans "Termination" %}
+ {% trans "Connection" %}
{% if termination.mark_connected %}
@@ -57,12 +58,6 @@
{% endif %}
-{% else %}
-
- {% trans "Provider Network" %}
- {{ termination.provider_network.provider|linkify }} / {{ termination.provider_network|linkify }}
-
-{% endif %}
{% trans "Speed" %}
diff --git a/netbox/templates/circuits/providernetwork.html b/netbox/templates/circuits/providernetwork.html
index 000548734..5fd92615d 100644
--- a/netbox/templates/circuits/providernetwork.html
+++ b/netbox/templates/circuits/providernetwork.html
@@ -50,6 +50,19 @@
{% htmx_table 'circuits:circuit_list' provider_network_id=object.pk %}
+
+
+ {% htmx_table 'circuits:virtualcircuit_list' provider_network_id=object.pk %}
+
{% plugin_full_width_page object %}
diff --git a/netbox/templates/circuits/virtualcircuit.html b/netbox/templates/circuits/virtualcircuit.html
new file mode 100644
index 000000000..8fac4a04e
--- /dev/null
+++ b/netbox/templates/circuits/virtualcircuit.html
@@ -0,0 +1,101 @@
+{% extends 'generic/object.html' %}
+{% load helpers %}
+{% load plugins %}
+{% load i18n %}
+
+{% block breadcrumbs %}
+ {{ block.super }}
+
+ {{ object.provider }}
+
+
+ {{ object.provider_network }}
+
+{% endblock %}
+
+{% block content %}
+
+
+
+
+
+
+ {% trans "Provider" %}
+ {{ object.provider|linkify }}
+
+
+ {% trans "Provider Network" %}
+ {{ object.provider_network|linkify }}
+
+
+ {% trans "Provider account" %}
+ {{ object.provider_account|linkify|placeholder }}
+
+
+ {% trans "Circuit ID" %}
+ {{ object.cid }}
+
+
+ {% trans "Type" %}
+ {{ object.type|linkify }}
+
+
+ {% trans "Status" %}
+ {% badge object.get_status_display bg_color=object.get_status_color %}
+
+
+ {% trans "Tenant" %}
+
+ {% if object.tenant.group %}
+ {{ object.tenant.group|linkify }} /
+ {% endif %}
+ {{ object.tenant|linkify|placeholder }}
+
+
+
+ {% trans "Description" %}
+ {{ object.description|placeholder }}
+
+
+
+ {% include 'inc/panels/tags.html' %}
+ {% plugin_left_page object %}
+
+
+ {% include 'inc/panels/custom_fields.html' %}
+ {% include 'inc/panels/comments.html' %}
+
+
+ {% htmx_table 'circuits:circuitgroupassignment_list' virtual_circuit_id=object.pk %}
+
+ {% plugin_right_page object %}
+
+
+
+
+
+
+ {% htmx_table 'circuits:virtualcircuittermination_list' virtual_circuit_id=object.pk %}
+
+ {% plugin_full_width_page object %}
+
+
+{% endblock %}
diff --git a/netbox/templates/circuits/virtualcircuittermination.html b/netbox/templates/circuits/virtualcircuittermination.html
new file mode 100644
index 000000000..c08e3c604
--- /dev/null
+++ b/netbox/templates/circuits/virtualcircuittermination.html
@@ -0,0 +1,81 @@
+{% extends 'generic/object.html' %}
+{% load helpers %}
+{% load plugins %}
+{% load i18n %}
+
+{% block breadcrumbs %}
+ {{ block.super }}
+
+ {{ object.virtual_circuit.provider }}
+
+
+ {{ object.virtual_circuit.provider_network }}
+
+
+ {{ object.virtual_circuit }}
+
+{% endblock %}
+
+{% block content %}
+
+
+
+
+
+
+ {% trans "Provider" %}
+ {{ object.virtual_circuit.provider|linkify }}
+
+
+ {% trans "Provider Network" %}
+ {{ object.virtual_circuit.provider_network|linkify }}
+
+
+ {% trans "Provider account" %}
+ {{ object.virtual_circuit.provider_account|linkify|placeholder }}
+
+
+ {% trans "Virtual circuit" %}
+ {{ object.virtual_circuit|linkify }}
+
+
+ {% trans "Role" %}
+ {% badge object.get_role_display bg_color=object.get_role_color %}
+
+
+
+ {% include 'inc/panels/tags.html' %}
+ {% include 'inc/panels/custom_fields.html' %}
+ {% plugin_left_page object %}
+
+
+
+
+
+
+ {% trans "Device" %}
+ {{ object.interface.device|linkify }}
+
+
+ {% trans "Interface" %}
+ {{ object.interface|linkify }}
+
+
+ {% trans "Type" %}
+ {{ object.interface.get_type_display }}
+
+
+ {% trans "Description" %}
+ {{ object.interface.description|placeholder }}
+
+
+
+ {% plugin_right_page object %}
+
+
+
+
+ {% plugin_full_width_page object %}
+
+
+{% endblock %}
diff --git a/netbox/templates/circuits/virtualcircuittype.html b/netbox/templates/circuits/virtualcircuittype.html
new file mode 100644
index 000000000..594d9ef22
--- /dev/null
+++ b/netbox/templates/circuits/virtualcircuittype.html
@@ -0,0 +1,55 @@
+{% extends 'generic/object.html' %}
+{% load helpers %}
+{% load plugins %}
+{% load render_table from django_tables2 %}
+{% load i18n %}
+
+{% block extra_controls %}
+ {% if perms.circuits.add_virtualcircuit %}
+
+ {% trans "Add Virtual Circuit" %}
+
+ {% endif %}
+{% endblock extra_controls %}
+
+{% block content %}
+
+
+
+
+
+
+ {% trans "Name" %}
+ {{ object.name }}
+
+
+ {% trans "Description" %}
+ {{ object.description|placeholder }}
+
+
+ {% trans "Color" %}
+
+ {% if object.color %}
+
+ {% else %}
+ {{ ''|placeholder }}
+ {% endif %}
+
+
+
+
+ {% include 'inc/panels/tags.html' %}
+ {% plugin_left_page object %}
+
+
+ {% include 'inc/panels/related_objects.html' %}
+ {% include 'inc/panels/custom_fields.html' %}
+ {% plugin_right_page object %}
+
+
+
+
+ {% plugin_full_width_page object %}
+
+
+{% endblock %}
diff --git a/netbox/templates/dcim/interface.html b/netbox/templates/dcim/interface.html
index 016a6c890..510780dd9 100644
--- a/netbox/templates/dcim/interface.html
+++ b/netbox/templates/dcim/interface.html
@@ -81,6 +81,12 @@
{% trans "802.1Q Mode" %}
{{ object.get_mode_display|placeholder }}
+ {% if object.mode == 'q-in-q' %}
+
+ {% trans "Q-in-Q SVLAN" %}
+ {{ object.qinq_svlan|linkify|placeholder }}
+
+ {% endif %}
{% trans "Transmit power (dBm)" %}
{{ object.tx_power|placeholder }}
@@ -123,19 +129,70 @@
{% trans "MAC Address" %}
- {{ object.mac_address|placeholder }}
+
+ {% if object.primary_mac_address %}
+ {{ object.primary_mac_address|linkify }}
+ {% trans "Primary" %}
+ {% else %}
+ {{ ''|placeholder }}
+ {% endif %}
+
{% trans "WWN" %}
- {{ object.wwn|placeholder }}
+
+ {% if object.wwn %}
+ {{ object.wwn }}
+ {% else %}
+ {{ ''|placeholder }}
+ {% endif %}
+
{% trans "VRF" %}
{{ object.vrf|linkify|placeholder }}
+
+ {% trans "VLAN Translation" %}
+ {{ object.vlan_translation_policy|linkify|placeholder }}
+
- {% if not object.is_virtual %}
+ {% if object.is_virtual and object.virtual_circuit_termination %}
+
+
+
+
+ {% trans "Provider" %}
+ {{ object.virtual_circuit_termination.virtual_circuit.provider|linkify }}
+
+
+ {% trans "Provider Network" %}
+ {{ object.virtual_circuit_termination.virtual_circuit.provider_network|linkify }}
+
+
+ {% trans "Circuit ID" %}
+ {{ object.virtual_circuit_termination.virtual_circuit|linkify }}
+
+
+ {% trans "Role" %}
+ {{ object.virtual_circuit_termination.get_role_display }}
+
+
+ {% trans "Connections" %}
+
+ {% for termination in object.virtual_circuit_termination.peer_terminations %}
+ {{ termination.interface.parent_object }}
+
+ {{ termination.interface }}
+ ({{ termination.get_role_display }})
+ {% if not forloop.last %} {% endif %}
+ {% endfor %}
+
+
+
+
+ {% elif not object.is_virtual %}
{% if object.mark_connected %}
@@ -346,7 +403,23 @@
{% endif %}
{% htmx_table 'ipam:ipaddress_list' interface_id=object.pk %}
-
+
+
+
+
+
+
+
+ {% htmx_table 'dcim:macaddress_list' interface_id=object.pk %}
@@ -355,6 +428,13 @@
{% include 'inc/panel_table.html' with table=vlan_table heading="VLANs" %}
+ {% if object.vlan_translation_policy %}
+
+
+ {% include 'inc/panel_table.html' with table=vlan_translation_table heading="VLAN Translation" %}
+
+
+ {% endif %}
{% if object.is_bridge %}
diff --git a/netbox/templates/dcim/inventoryitem.html b/netbox/templates/dcim/inventoryitem.html
index 44648d53e..fd8ea42eb 100644
--- a/netbox/templates/dcim/inventoryitem.html
+++ b/netbox/templates/dcim/inventoryitem.html
@@ -32,6 +32,10 @@
{% trans "Label" %}
{{ object.label|placeholder }}
+
+ {% trans "Status" %}
+ {% badge object.get_status_display bg_color=object.get_status_color %}
+
{% trans "Role" %}
{{ object.role|linkify|placeholder }}
diff --git a/netbox/templates/dcim/macaddress.html b/netbox/templates/dcim/macaddress.html
new file mode 100644
index 000000000..6d7532e6d
--- /dev/null
+++ b/netbox/templates/dcim/macaddress.html
@@ -0,0 +1,55 @@
+{% extends 'generic/object.html' %}
+{% load helpers %}
+{% load plugins %}
+{% load render_table from django_tables2 %}
+{% load i18n %}
+
+{% block content %}
+
+
+
+
+
+
+ {% trans "MAC Address" %}
+
+ {{ object.mac_address|placeholder }}
+ {% copy_content object.pk prefix="macaddress_" %}
+
+
+
+ {% trans "Description" %}
+ {{ object.description|placeholder }}
+
+
+ {% trans "Assignment" %}
+
+ {% if object.assigned_object %}
+ {{ object.assigned_object.parent_object|linkify }} /
+ {{ object.assigned_object|linkify }}
+ {% else %}
+ {{ ''|placeholder }}
+ {% endif %}
+
+
+
+ {% trans "Primary for interface" %}
+ {% checkmark object.is_primary %}
+
+
+
+ {% include 'inc/panels/tags.html' %}
+ {% include 'inc/panels/custom_fields.html' %}
+ {% plugin_left_page object %}
+
+
+ {% include 'inc/panels/comments.html' %}
+ {% plugin_right_page object %}
+
+
+
+
+ {% plugin_full_width_page object %}
+
+
+{% endblock %}
diff --git a/netbox/templates/dcim/poweroutlet.html b/netbox/templates/dcim/poweroutlet.html
index ef02ec52a..146f6d580 100644
--- a/netbox/templates/dcim/poweroutlet.html
+++ b/netbox/templates/dcim/poweroutlet.html
@@ -40,6 +40,16 @@
{% trans "Description" %}
{{ object.description|placeholder }}
+
+ {% trans "Color" %}
+
+ {% if object.color %}
+
+ {% else %}
+ {{ ''|placeholder }}
+ {% endif %}
+
+
{% trans "Power Port" %}
{{ object.power_port|linkify|placeholder }}
diff --git a/netbox/templates/django/forms/widgets/checkbox.html b/netbox/templates/django/forms/widgets/checkbox.html
index f769fce96..8a1e1d23a 100644
--- a/netbox/templates/django/forms/widgets/checkbox.html
+++ b/netbox/templates/django/forms/widgets/checkbox.html
@@ -1,7 +1,6 @@
{% comment %}
Include a hidden field of the same name to ensure that unchecked checkboxes
- are always included in the submitted form data. Omit fields names
- _selected_action to avoid breaking the admin UI.
+ are always included in the submitted form data.
{% endcomment %}
-{% if widget.name != '_selected_action' %} {% endif %}
+
diff --git a/netbox/templates/extras/dashboard/widgets/rssfeed.html b/netbox/templates/extras/dashboard/widgets/rssfeed.html
index 4420783fd..fa602a112 100644
--- a/netbox/templates/extras/dashboard/widgets/rssfeed.html
+++ b/netbox/templates/extras/dashboard/widgets/rssfeed.html
@@ -12,6 +12,10 @@
{% trans "No content found" %}
{% endfor %}
+{% elif isolated_deployment %}
+
+ {% trans "This RSS feed requires an external connection. Check the ISOLATED_DEPLOYMENT setting." %}
+
{% else %}
{# There was an error retrieving/parsing the feed #}
diff --git a/netbox/templates/generic/object_list.html b/netbox/templates/generic/object_list.html
index fdd3cd3d8..e6d5505a4 100644
--- a/netbox/templates/generic/object_list.html
+++ b/netbox/templates/generic/object_list.html
@@ -34,7 +34,7 @@ Context:
{% if 'add' in actions %}
{% add_button model %}
{% endif %}
- {% if 'import' in actions %}
+ {% if 'bulk_import' in actions %}
{% import_button model %}
{% endif %}
{% if 'export' in actions %}
diff --git a/netbox/templates/htmx/quick_add.html b/netbox/templates/htmx/quick_add.html
new file mode 100644
index 000000000..9473e14a1
--- /dev/null
+++ b/netbox/templates/htmx/quick_add.html
@@ -0,0 +1,28 @@
+{% load form_helpers %}
+{% load helpers %}
+{% load i18n %}
+
+
+
diff --git a/netbox/templates/htmx/quick_add_created.html b/netbox/templates/htmx/quick_add_created.html
new file mode 100644
index 000000000..3b1a24c48
--- /dev/null
+++ b/netbox/templates/htmx/quick_add_created.html
@@ -0,0 +1,22 @@
+{% load form_helpers %}
+{% load helpers %}
+{% load i18n %}
+
+
+
+ {# This content is intended to be scraped and populated in the targeted selection field. #}
+
+ {% blocktrans with object=object|linkify object_type=object|meta:"verbose_name" %}
+ Created {{ object_type }} {{ object }}
+ {% endblocktrans %}
+
+
diff --git a/netbox/templates/inc/user_menu.html b/netbox/templates/inc/user_menu.html
index 1b6757416..e27be3323 100644
--- a/netbox/templates/inc/user_menu.html
+++ b/netbox/templates/inc/user_menu.html
@@ -36,11 +36,6 @@
- {% if object.site.region %}
-
- {% trans "Region" %}
-
- {% nested_tree object.site.region %}
-
-
- {% endif %}
- {% trans "Site" %}
- {{ object.site|linkify|placeholder }}
+ {% trans "Scope" %}
+ {% if object.scope %}
+ {{ object.scope|linkify }} ({% trans object.scope_type.name %})
+ {% else %}
+ {{ ''|placeholder }}
+ {% endif %}
{% trans "VLAN" %}
diff --git a/netbox/templates/ipam/vlan.html b/netbox/templates/ipam/vlan.html
index 95a4f7856..fa480f2f6 100644
--- a/netbox/templates/ipam/vlan.html
+++ b/netbox/templates/ipam/vlan.html
@@ -62,6 +62,22 @@
{% trans "Description" %}
{{ object.description|placeholder }}
+
+ {% trans "Q-in-Q Role" %}
+
+ {% if object.qinq_role %}
+ {% badge object.get_qinq_role_display bg_color=object.get_qinq_role_color %}
+ {% else %}
+ {{ ''|placeholder }}
+ {% endif %}
+
+
+ {% if object.qinq_role == 'cvlan' %}
+
+ {% trans "Q-in-Q SVLAN" %}
+ {{ object.qinq_svlan|linkify|placeholder }}
+
+ {% endif %}
{% trans "L2VPN" %}
{{ object.l2vpn_termination.l2vpn|linkify|placeholder }}
@@ -92,6 +108,21 @@
{% htmx_table 'ipam:prefix_list' vlan_id=object.pk %}
+ {% if object.qinq_role == 'svlan' %}
+
+
+ {% htmx_table 'ipam:vlan_list' qinq_svlan_id=object.pk %}
+
+ {% endif %}
{% plugin_full_width_page object %}
diff --git a/netbox/templates/ipam/vlan_edit.html b/netbox/templates/ipam/vlan_edit.html
index 814fc6b78..885844580 100644
--- a/netbox/templates/ipam/vlan_edit.html
+++ b/netbox/templates/ipam/vlan_edit.html
@@ -17,6 +17,14 @@
{% render_field form.tags %}
+
+
+
{% trans "Q-in-Q (802.1ad)" %}
+
+ {% render_field form.qinq_role %}
+ {% render_field form.qinq_svlan %}
+
+
{% trans "Tenancy" %}
diff --git a/netbox/templates/ipam/vlantranslationpolicy.html b/netbox/templates/ipam/vlantranslationpolicy.html
new file mode 100644
index 000000000..58a1201d4
--- /dev/null
+++ b/netbox/templates/ipam/vlantranslationpolicy.html
@@ -0,0 +1,65 @@
+{% extends 'generic/object.html' %}
+{% load helpers %}
+{% load plugins %}
+{% load render_table from django_tables2 %}
+{% load i18n %}
+
+{% block content %}
+
+
+
+
+
+
+ {% trans "Name" %}
+ {{ object.name|placeholder }}
+
+
+ {% trans "Description" %}
+ {{ object.description|placeholder }}
+
+
+ {% trans "Rules" %}
+
+ {% if object.rules.count %}
+ {{ object.rules.count }}
+ {% else %}
+ 0
+ {% endif %}
+
+
+
+
+ {% plugin_left_page object %}
+
+
+ {% include 'inc/panels/tags.html' %}
+ {% include 'inc/panels/custom_fields.html' %}
+ {% include 'inc/panels/comments.html' %}
+ {% plugin_right_page object %}
+
+
+
+
+
+
+ {% htmx_table 'ipam:vlantranslationrule_list' policy_id=object.pk %}
+
+
+
+
+
+ {% plugin_full_width_page object %}
+
+
+{% endblock %}
diff --git a/netbox/templates/ipam/vlantranslationrule.html b/netbox/templates/ipam/vlantranslationrule.html
new file mode 100644
index 000000000..7f3aad2ad
--- /dev/null
+++ b/netbox/templates/ipam/vlantranslationrule.html
@@ -0,0 +1,45 @@
+{% extends 'generic/object.html' %}
+{% load helpers %}
+{% load plugins %}
+{% load render_table from django_tables2 %}
+{% load i18n %}
+
+{% block content %}
+
+
+
+
+
+
+ {% trans "Policy" %}
+ {{ object.policy|linkify }}
+
+
+ {% trans "Local VID" %}
+ {{ object.local_vid }}
+
+
+ {% trans "Remote VID" %}
+ {{ object.remote_vid }}
+
+
+ {% trans "Description" %}
+ {{ object.description }}
+
+
+
+ {% plugin_left_page object %}
+
+
+ {% include 'inc/panels/tags.html' %}
+ {% include 'inc/panels/custom_fields.html' %}
+ {% include 'inc/panels/comments.html' %}
+ {% plugin_right_page object %}
+
+
+
+
+ {% plugin_full_width_page object %}
+
+
+{% endblock %}
diff --git a/netbox/templates/virtualization/cluster.html b/netbox/templates/virtualization/cluster.html
index d79d8075c..4155dacb2 100644
--- a/netbox/templates/virtualization/cluster.html
+++ b/netbox/templates/virtualization/cluster.html
@@ -39,8 +39,12 @@
- {% trans "Site" %}
- {{ object.site|linkify|placeholder }}
+ {% trans "Scope" %}
+ {% if object.scope %}
+ {{ object.scope|linkify }} ({% trans object.scope_type.name %})
+ {% else %}
+ {{ ''|placeholder }}
+ {% endif %}
diff --git a/netbox/templates/virtualization/vminterface.html b/netbox/templates/virtualization/vminterface.html
index 0d679680d..88c9379cf 100644
--- a/netbox/templates/virtualization/vminterface.html
+++ b/netbox/templates/virtualization/vminterface.html
@@ -14,69 +14,85 @@
{% block content %}
-
-
-
-
- {% trans "Virtual Machine" %}
- {{ object.virtual_machine|linkify }}
-
-
- {% trans "Name" %}
- {{ object.name }}
-
-
- {% trans "Enabled" %}
-
- {% if object.enabled %}
-
- {% else %}
-
- {% endif %}
-
-
-
- {% trans "Parent" %}
- {{ object.parent|linkify|placeholder }}
-
-
- {% trans "Bridge" %}
- {{ object.bridge|linkify|placeholder }}
-
-
- {% trans "VRF" %}
- {{ object.vrf|linkify|placeholder }}
-
-
- {% trans "Description" %}
- {{ object.description|placeholder }}
-
-
- {% trans "MTU" %}
- {{ object.mtu|placeholder }}
-
-
- {% trans "MAC Address" %}
- {{ object.mac_address|placeholder }}
-
-
- {% trans "802.1Q Mode" %}
- {{ object.get_mode_display|placeholder }}
-
-
- {% trans "Tunnel" %}
- {{ object.tunnel_termination.tunnel|linkify|placeholder }}
-
-
-
- {% include 'inc/panels/tags.html' %}
- {% plugin_left_page object %}
+
+
+
+
+ {% trans "Virtual Machine" %}
+ {{ object.virtual_machine|linkify }}
+
+
+ {% trans "Name" %}
+ {{ object.name }}
+
+
+ {% trans "Enabled" %}
+
+ {% if object.enabled %}
+
+ {% else %}
+
+ {% endif %}
+
+
+
+ {% trans "Parent" %}
+ {{ object.parent|linkify|placeholder }}
+
+
+ {% trans "Bridge" %}
+ {{ object.bridge|linkify|placeholder }}
+
+
+ {% trans "Description" %}
+ {{ object.description|placeholder }}
+
+
+ {% trans "MTU" %}
+ {{ object.mtu|placeholder }}
+
+
+ {% trans "802.1Q Mode" %}
+ {{ object.get_mode_display|placeholder }}
+
+
+ {% trans "Tunnel" %}
+ {{ object.tunnel_termination.tunnel|linkify|placeholder }}
+
+
-
- {% include 'inc/panels/custom_fields.html' %}
- {% include 'ipam/inc/panels/fhrp_groups.html' %}
- {% plugin_right_page object %}
+ {% include 'inc/panels/tags.html' %}
+ {% plugin_left_page object %}
+
+
+ {% include 'inc/panels/custom_fields.html' %}
+
+
+
+
+ {% trans "MAC Address" %}
+
+ {% if object.mac_address %}
+ {{ object.mac_address }}
+ {% trans "Primary" %}
+ {% else %}
+ {{ ''|placeholder }}
+ {% endif %}
+
+
+
+ {% trans "VRF" %}
+ {{ object.vrf|linkify|placeholder }}
+
+
+ {% trans "VLAN Translation" %}
+ {{ object.vlan_translation_policy|linkify|placeholder }}
+
+
+ {% include 'ipam/inc/panels/fhrp_groups.html' %}
+ {% plugin_right_page object %}
+
+
+
+
+
+ {% htmx_table 'dcim:macaddress_list' vminterface_id=object.pk %}
+
+
+
{% include 'inc/panel_table.html' with table=vlan_table heading="VLANs" %}
+{% if object.vlan_translation_policy %}
+
+
+ {% include 'inc/panel_table.html' with table=vlan_translation_table heading="VLAN Translation" %}
+
+
+{% endif %}
{% include 'inc/panel_table.html' with table=child_interfaces_table heading="Child Interfaces" %}
diff --git a/netbox/templates/wireless/wirelesslan.html b/netbox/templates/wireless/wirelesslan.html
index 493c36132..54473ea54 100644
--- a/netbox/templates/wireless/wirelesslan.html
+++ b/netbox/templates/wireless/wirelesslan.html
@@ -22,6 +22,14 @@
{% trans "Status" %}
{% badge object.get_status_display bg_color=object.get_status_color %}
+
+ {% trans "Scope" %}
+ {% if object.scope %}
+ {{ object.scope|linkify }} ({% trans object.scope_type.name %})
+ {% else %}
+ {{ ''|placeholder }}
+ {% endif %}
+
{% trans "Description" %}
{{ object.description|placeholder }}
diff --git a/netbox/tenancy/api/nested_serializers.py b/netbox/tenancy/api/nested_serializers.py
deleted file mode 100644
index 5adb78863..000000000
--- a/netbox/tenancy/api/nested_serializers.py
+++ /dev/null
@@ -1,58 +0,0 @@
-import warnings
-
-from netbox.api.serializers import WritableNestedSerializer
-from serializers_.nested import NestedContactGroupSerializer, NestedTenantGroupSerializer
-from tenancy.models import *
-
-__all__ = [
- 'NestedContactSerializer',
- 'NestedContactAssignmentSerializer',
- 'NestedContactGroupSerializer',
- 'NestedContactRoleSerializer',
- 'NestedTenantGroupSerializer',
- 'NestedTenantSerializer',
-]
-
-# TODO: Remove in v4.2
-warnings.warn(
- "Dedicated nested serializers will be removed in NetBox v4.2. Use Serializer(nested=True) instead.",
- DeprecationWarning
-)
-
-
-#
-# Tenants
-#
-
-class NestedTenantSerializer(WritableNestedSerializer):
-
- class Meta:
- model = Tenant
- fields = ['id', 'url', 'display_url', 'display', 'name', 'slug']
-
-
-#
-# Contacts
-#
-
-class NestedContactRoleSerializer(WritableNestedSerializer):
-
- class Meta:
- model = ContactRole
- fields = ['id', 'url', 'display_url', 'display', 'name', 'slug']
-
-
-class NestedContactSerializer(WritableNestedSerializer):
-
- class Meta:
- model = Contact
- fields = ['id', 'url', 'display_url', 'display', 'name']
-
-
-class NestedContactAssignmentSerializer(WritableNestedSerializer):
- contact = NestedContactSerializer()
- role = NestedContactRoleSerializer
-
- class Meta:
- model = ContactAssignment
- fields = ['id', 'url', 'display', 'contact', 'role', 'priority']
diff --git a/netbox/tenancy/forms/forms.py b/netbox/tenancy/forms/forms.py
index 114253e7a..0edb36348 100644
--- a/netbox/tenancy/forms/forms.py
+++ b/netbox/tenancy/forms/forms.py
@@ -25,6 +25,7 @@ class TenancyForm(forms.Form):
label=_('Tenant'),
queryset=Tenant.objects.all(),
required=False,
+ quick_add=True,
query_params={
'group_id': '$tenant_group'
}
diff --git a/netbox/tenancy/migrations/0001_squashed_0012.py b/netbox/tenancy/migrations/0001_squashed_0012.py
index e8a028a92..8f3f74d9f 100644
--- a/netbox/tenancy/migrations/0001_squashed_0012.py
+++ b/netbox/tenancy/migrations/0001_squashed_0012.py
@@ -6,7 +6,6 @@ import taggit.managers
class Migration(migrations.Migration):
-
initial = True
dependencies = [
@@ -43,7 +42,16 @@ class Migration(migrations.Migration):
('rght', models.PositiveIntegerField(editable=False)),
('tree_id', models.PositiveIntegerField(db_index=True, editable=False)),
('level', models.PositiveIntegerField(editable=False)),
- ('parent', mptt.fields.TreeForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='children', to='tenancy.tenantgroup')),
+ (
+ 'parent',
+ mptt.fields.TreeForeignKey(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name='children',
+ to='tenancy.tenantgroup',
+ ),
+ ),
],
options={
'ordering': ['name'],
@@ -60,7 +68,16 @@ class Migration(migrations.Migration):
('slug', models.SlugField(max_length=100, unique=True)),
('description', models.CharField(blank=True, max_length=200)),
('comments', models.TextField(blank=True)),
- ('group', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='tenants', to='tenancy.tenantgroup')),
+ (
+ 'group',
+ models.ForeignKey(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.SET_NULL,
+ related_name='tenants',
+ to='tenancy.tenantgroup',
+ ),
+ ),
('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')),
],
options={
diff --git a/netbox/tenancy/migrations/0002_squashed_0011.py b/netbox/tenancy/migrations/0002_squashed_0011.py
index 8accd1da9..cfdcb58dd 100644
--- a/netbox/tenancy/migrations/0002_squashed_0011.py
+++ b/netbox/tenancy/migrations/0002_squashed_0011.py
@@ -7,7 +7,6 @@ import utilities.json
class Migration(migrations.Migration):
-
replaces = [
('tenancy', '0002_tenant_ordering'),
('tenancy', '0003_contacts'),
@@ -18,7 +17,7 @@ class Migration(migrations.Migration):
('tenancy', '0008_unique_constraints'),
('tenancy', '0009_standardize_description_comments'),
('tenancy', '0010_tenant_relax_uniqueness'),
- ('tenancy', '0011_contactassignment_tags')
+ ('tenancy', '0011_contactassignment_tags'),
]
dependencies = [
@@ -37,7 +36,10 @@ class Migration(migrations.Migration):
fields=[
('created', models.DateTimeField(auto_now_add=True, null=True)),
('last_updated', models.DateTimeField(auto_now=True, null=True)),
- ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=utilities.json.CustomFieldJSONEncoder)),
+ (
+ 'custom_field_data',
+ models.JSONField(blank=True, default=dict, encoder=utilities.json.CustomFieldJSONEncoder),
+ ),
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
('name', models.CharField(max_length=100, unique=True)),
('slug', models.SlugField(max_length=100, unique=True)),
@@ -53,7 +55,10 @@ class Migration(migrations.Migration):
fields=[
('created', models.DateTimeField(auto_now_add=True, null=True)),
('last_updated', models.DateTimeField(auto_now=True, null=True)),
- ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=utilities.json.CustomFieldJSONEncoder)),
+ (
+ 'custom_field_data',
+ models.JSONField(blank=True, default=dict, encoder=utilities.json.CustomFieldJSONEncoder),
+ ),
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
('name', models.CharField(max_length=100)),
('slug', models.SlugField(max_length=100)),
@@ -62,7 +67,16 @@ class Migration(migrations.Migration):
('rght', models.PositiveIntegerField(editable=False)),
('tree_id', models.PositiveIntegerField(db_index=True, editable=False)),
('level', models.PositiveIntegerField(editable=False)),
- ('parent', mptt.fields.TreeForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='children', to='tenancy.contactgroup')),
+ (
+ 'parent',
+ mptt.fields.TreeForeignKey(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name='children',
+ to='tenancy.contactgroup',
+ ),
+ ),
('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')),
],
options={
@@ -75,7 +89,10 @@ class Migration(migrations.Migration):
fields=[
('created', models.DateTimeField(auto_now_add=True, null=True)),
('last_updated', models.DateTimeField(auto_now=True, null=True)),
- ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=utilities.json.CustomFieldJSONEncoder)),
+ (
+ 'custom_field_data',
+ models.JSONField(blank=True, default=dict, encoder=utilities.json.CustomFieldJSONEncoder),
+ ),
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
('name', models.CharField(max_length=100)),
('title', models.CharField(blank=True, max_length=100)),
@@ -83,7 +100,16 @@ class Migration(migrations.Migration):
('email', models.EmailField(blank=True, max_length=254)),
('address', models.CharField(blank=True, max_length=200)),
('comments', models.TextField(blank=True)),
- ('group', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='contacts', to='tenancy.contactgroup')),
+ (
+ 'group',
+ models.ForeignKey(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.SET_NULL,
+ related_name='contacts',
+ to='tenancy.contactgroup',
+ ),
+ ),
('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')),
('link', models.URLField(blank=True)),
],
@@ -125,9 +151,24 @@ class Migration(migrations.Migration):
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
('object_id', models.PositiveBigIntegerField()),
('priority', models.CharField(blank=True, max_length=50)),
- ('contact', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='assignments', to='tenancy.contact')),
- ('content_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='contenttypes.contenttype')),
- ('role', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='assignments', to='tenancy.contactrole')),
+ (
+ 'contact',
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.PROTECT, related_name='assignments', to='tenancy.contact'
+ ),
+ ),
+ (
+ 'content_type',
+ models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='contenttypes.contenttype'),
+ ),
+ (
+ 'role',
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.PROTECT,
+ related_name='assignments',
+ to='tenancy.contactrole',
+ ),
+ ),
],
options={
'ordering': ('priority', 'contact'),
@@ -140,11 +181,16 @@ class Migration(migrations.Migration):
),
migrations.AddConstraint(
model_name='contactassignment',
- constraint=models.UniqueConstraint(fields=('content_type', 'object_id', 'contact', 'role'), name='tenancy_contactassignment_unique_object_contact_role'),
+ constraint=models.UniqueConstraint(
+ fields=('content_type', 'object_id', 'contact', 'role'),
+ name='tenancy_contactassignment_unique_object_contact_role',
+ ),
),
migrations.AddConstraint(
model_name='contactgroup',
- constraint=models.UniqueConstraint(fields=('parent', 'name'), name='tenancy_contactgroup_unique_parent_name'),
+ constraint=models.UniqueConstraint(
+ fields=('parent', 'name'), name='tenancy_contactgroup_unique_parent_name'
+ ),
),
migrations.AddField(
model_name='contact',
@@ -163,19 +209,31 @@ class Migration(migrations.Migration):
),
migrations.AddConstraint(
model_name='tenant',
- constraint=models.UniqueConstraint(fields=('group', 'name'), name='tenancy_tenant_unique_group_name', violation_error_message='Tenant name must be unique per group.'),
+ constraint=models.UniqueConstraint(
+ fields=('group', 'name'),
+ name='tenancy_tenant_unique_group_name',
+ violation_error_message='Tenant name must be unique per group.',
+ ),
),
migrations.AddConstraint(
model_name='tenant',
- constraint=models.UniqueConstraint(condition=models.Q(('group__isnull', True)), fields=('name',), name='tenancy_tenant_unique_name'),
+ constraint=models.UniqueConstraint(
+ condition=models.Q(('group__isnull', True)), fields=('name',), name='tenancy_tenant_unique_name'
+ ),
),
migrations.AddConstraint(
model_name='tenant',
- constraint=models.UniqueConstraint(fields=('group', 'slug'), name='tenancy_tenant_unique_group_slug', violation_error_message='Tenant slug must be unique per group.'),
+ constraint=models.UniqueConstraint(
+ fields=('group', 'slug'),
+ name='tenancy_tenant_unique_group_slug',
+ violation_error_message='Tenant slug must be unique per group.',
+ ),
),
migrations.AddConstraint(
model_name='tenant',
- constraint=models.UniqueConstraint(condition=models.Q(('group__isnull', True)), fields=('slug',), name='tenancy_tenant_unique_slug'),
+ constraint=models.UniqueConstraint(
+ condition=models.Q(('group__isnull', True)), fields=('slug',), name='tenancy_tenant_unique_slug'
+ ),
),
migrations.AddField(
model_name='contactassignment',
diff --git a/netbox/tenancy/migrations/0012_contactassignment_custom_fields.py b/netbox/tenancy/migrations/0012_contactassignment_custom_fields.py
index ee6726822..7f681fd91 100644
--- a/netbox/tenancy/migrations/0012_contactassignment_custom_fields.py
+++ b/netbox/tenancy/migrations/0012_contactassignment_custom_fields.py
@@ -5,7 +5,6 @@ import utilities.json
class Migration(migrations.Migration):
-
dependencies = [
('tenancy', '0011_contactassignment_tags'),
]
diff --git a/netbox/tenancy/migrations/0013_gfk_indexes.py b/netbox/tenancy/migrations/0013_gfk_indexes.py
index dd23cefbb..9d58c8932 100644
--- a/netbox/tenancy/migrations/0013_gfk_indexes.py
+++ b/netbox/tenancy/migrations/0013_gfk_indexes.py
@@ -4,7 +4,6 @@ from django.db import migrations, models
class Migration(migrations.Migration):
-
dependencies = [
('tenancy', '0012_contactassignment_custom_fields'),
]
diff --git a/netbox/tenancy/migrations/0014_contactassignment_ordering.py b/netbox/tenancy/migrations/0014_contactassignment_ordering.py
index 66f08aa2a..5e2c39311 100644
--- a/netbox/tenancy/migrations/0014_contactassignment_ordering.py
+++ b/netbox/tenancy/migrations/0014_contactassignment_ordering.py
@@ -4,7 +4,6 @@ from django.db import migrations
class Migration(migrations.Migration):
-
dependencies = [
('tenancy', '0013_gfk_indexes'),
]
diff --git a/netbox/tenancy/migrations/0015_contactassignment_rename_content_type.py b/netbox/tenancy/migrations/0015_contactassignment_rename_content_type.py
index 58b14e10f..f2c1ce190 100644
--- a/netbox/tenancy/migrations/0015_contactassignment_rename_content_type.py
+++ b/netbox/tenancy/migrations/0015_contactassignment_rename_content_type.py
@@ -2,7 +2,6 @@ from django.db import migrations, models
class Migration(migrations.Migration):
-
dependencies = [
('contenttypes', '0002_remove_content_type_name'),
('extras', '0111_rename_content_types'),
@@ -25,16 +24,13 @@ class Migration(migrations.Migration):
),
migrations.AddIndex(
model_name='contactassignment',
- index=models.Index(
- fields=['object_type', 'object_id'],
- name='tenancy_con_object__6f20f7_idx'
- ),
+ index=models.Index(fields=['object_type', 'object_id'], name='tenancy_con_object__6f20f7_idx'),
),
migrations.AddConstraint(
model_name='contactassignment',
constraint=models.UniqueConstraint(
fields=('object_type', 'object_id', 'contact', 'role'),
- name='tenancy_contactassignment_unique_object_contact_role'
+ name='tenancy_contactassignment_unique_object_contact_role',
),
),
]
diff --git a/netbox/tenancy/migrations/0016_charfield_null_choices.py b/netbox/tenancy/migrations/0016_charfield_null_choices.py
new file mode 100644
index 000000000..9f5016a13
--- /dev/null
+++ b/netbox/tenancy/migrations/0016_charfield_null_choices.py
@@ -0,0 +1,25 @@
+from django.db import migrations, models
+
+
+def set_null_values(apps, schema_editor):
+ """
+ Replace empty strings with null values.
+ """
+ ContactAssignment = apps.get_model('tenancy', 'ContactAssignment')
+
+ ContactAssignment.objects.filter(priority='').update(priority=None)
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ('tenancy', '0015_contactassignment_rename_content_type'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='contactassignment',
+ name='priority',
+ field=models.CharField(blank=True, max_length=50, null=True),
+ ),
+ migrations.RunPython(code=set_null_values, reverse_code=migrations.RunPython.noop),
+ ]
diff --git a/netbox/tenancy/migrations/0017_natural_ordering.py b/netbox/tenancy/migrations/0017_natural_ordering.py
new file mode 100644
index 000000000..beb98d634
--- /dev/null
+++ b/netbox/tenancy/migrations/0017_natural_ordering.py
@@ -0,0 +1,26 @@
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ('tenancy', '0016_charfield_null_choices'),
+ ('dcim', '0197_natural_sort_collation'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='contact',
+ name='name',
+ field=models.CharField(db_collation='natural_sort', max_length=100),
+ ),
+ migrations.AlterField(
+ model_name='tenant',
+ name='name',
+ field=models.CharField(db_collation='natural_sort', max_length=100),
+ ),
+ migrations.AlterField(
+ model_name='tenantgroup',
+ name='name',
+ field=models.CharField(db_collation='natural_sort', max_length=100, unique=True),
+ ),
+ ]
diff --git a/netbox/tenancy/models/contacts.py b/netbox/tenancy/models/contacts.py
index e31330657..3969c8317 100644
--- a/netbox/tenancy/models/contacts.py
+++ b/netbox/tenancy/models/contacts.py
@@ -32,17 +32,11 @@ class ContactGroup(NestedGroupModel):
verbose_name = _('contact group')
verbose_name_plural = _('contact groups')
- def get_absolute_url(self):
- return reverse('tenancy:contactgroup', args=[self.pk])
-
class ContactRole(OrganizationalModel):
"""
Functional role for a Contact assigned to an object.
"""
- def get_absolute_url(self):
- return reverse('tenancy:contactrole', args=[self.pk])
-
class Meta:
ordering = ('name',)
verbose_name = _('contact role')
@@ -62,7 +56,8 @@ class Contact(PrimaryModel):
)
name = models.CharField(
verbose_name=_('name'),
- max_length=100
+ max_length=100,
+ db_collation="natural_sort"
)
title = models.CharField(
verbose_name=_('title'),
@@ -106,9 +101,6 @@ class Contact(PrimaryModel):
def __str__(self):
return self.name
- def get_absolute_url(self):
- return reverse('tenancy:contact', args=[self.pk])
-
class ContactAssignment(CustomFieldsMixin, ExportTemplatesMixin, TagsMixin, ChangeLoggedModel):
object_type = models.ForeignKey(
@@ -134,7 +126,8 @@ class ContactAssignment(CustomFieldsMixin, ExportTemplatesMixin, TagsMixin, Chan
verbose_name=_('priority'),
max_length=50,
choices=ContactPriorityChoices,
- blank=True
+ blank=True,
+ null=True
)
clone_fields = ('object_type', 'object_id', 'role', 'priority')
diff --git a/netbox/tenancy/models/tenants.py b/netbox/tenancy/models/tenants.py
index dfc144dd2..55f0c5933 100644
--- a/netbox/tenancy/models/tenants.py
+++ b/netbox/tenancy/models/tenants.py
@@ -1,6 +1,5 @@
from django.db import models
from django.db.models import Q
-from django.urls import reverse
from django.utils.translation import gettext_lazy as _
from netbox.models import NestedGroupModel, PrimaryModel
@@ -19,7 +18,8 @@ class TenantGroup(NestedGroupModel):
name = models.CharField(
verbose_name=_('name'),
max_length=100,
- unique=True
+ unique=True,
+ db_collation="natural_sort"
)
slug = models.SlugField(
verbose_name=_('slug'),
@@ -32,9 +32,6 @@ class TenantGroup(NestedGroupModel):
verbose_name = _('tenant group')
verbose_name_plural = _('tenant groups')
- def get_absolute_url(self):
- return reverse('tenancy:tenantgroup', args=[self.pk])
-
class Tenant(ContactsMixin, PrimaryModel):
"""
@@ -43,7 +40,8 @@ class Tenant(ContactsMixin, PrimaryModel):
"""
name = models.CharField(
verbose_name=_('name'),
- max_length=100
+ max_length=100,
+ db_collation="natural_sort"
)
slug = models.SlugField(
verbose_name=_('slug'),
@@ -90,6 +88,3 @@ class Tenant(ContactsMixin, PrimaryModel):
def __str__(self):
return self.name
-
- def get_absolute_url(self):
- return reverse('tenancy:tenant', args=[self.pk])
diff --git a/netbox/tenancy/tables/columns.py b/netbox/tenancy/tables/columns.py
index ec73cac4a..005bcf737 100644
--- a/netbox/tenancy/tables/columns.py
+++ b/netbox/tenancy/tables/columns.py
@@ -2,6 +2,7 @@ from django.utils.translation import gettext_lazy as _
import django_tables2 as tables
from netbox.tables import columns
+from .template_code import *
__all__ = (
'ContactsColumnMixin',
@@ -15,15 +16,7 @@ class TenantColumn(tables.TemplateColumn):
"""
Include the tenant description.
"""
- template_code = """
- {% if record.tenant %}
- {{ record.tenant }}
- {% elif record.vrf.tenant %}
- {{ record.vrf.tenant }} *
- {% else %}
- —
- {% endif %}
- """
+ template_code = TENANT_COLUMN
def __init__(self, *args, **kwargs):
super().__init__(template_code=self.template_code, *args, **kwargs)
@@ -36,15 +29,7 @@ class TenantGroupColumn(tables.TemplateColumn):
"""
Include the tenant group description.
"""
- template_code = """
- {% if record.tenant and record.tenant.group %}
- {{ record.tenant.group }}
- {% elif record.vrf.tenant and record.vrf.tenant.group %}
- {{ record.vrf.tenant.group }} *
- {% else %}
- —
- {% endif %}
- """
+ template_code = TENANT_GROUP_COLUMN
def __init__(self, accessor=tables.A('tenant__group'), *args, **kwargs):
if 'verbose_name' not in kwargs:
diff --git a/netbox/tenancy/tables/template_code.py b/netbox/tenancy/tables/template_code.py
new file mode 100644
index 000000000..1d15a8708
--- /dev/null
+++ b/netbox/tenancy/tables/template_code.py
@@ -0,0 +1,19 @@
+TENANT_COLUMN = """
+{% if record.tenant %}
+ {{ record.tenant }}
+{% elif record.vrf.tenant %}
+ {{ record.vrf.tenant }} *
+{% else %}
+ —
+{% endif %}
+"""
+
+TENANT_GROUP_COLUMN = """
+{% if record.tenant and record.tenant.group %}
+ {{ record.tenant.group }}
+{% elif record.vrf.tenant and record.vrf.tenant.group %}
+ {{ record.vrf.tenant.group }} *
+{% else %}
+ —
+{% endif %}
+"""
diff --git a/netbox/tenancy/tests/test_api.py b/netbox/tenancy/tests/test_api.py
index 5a6fe0453..c32ad3826 100644
--- a/netbox/tenancy/tests/test_api.py
+++ b/netbox/tenancy/tests/test_api.py
@@ -239,9 +239,24 @@ class ContactAssignmentTest(APIViewTestCases.APIViewTestCase):
ContactRole.objects.bulk_create(contact_roles)
contact_assignments = (
- ContactAssignment(object=sites[0], contact=contacts[0], role=contact_roles[0], priority=ContactPriorityChoices.PRIORITY_PRIMARY),
- ContactAssignment(object=sites[0], contact=contacts[1], role=contact_roles[1], priority=ContactPriorityChoices.PRIORITY_SECONDARY),
- ContactAssignment(object=sites[0], contact=contacts[2], role=contact_roles[2], priority=ContactPriorityChoices.PRIORITY_TERTIARY),
+ ContactAssignment(
+ object=sites[0],
+ contact=contacts[0],
+ role=contact_roles[0],
+ priority=ContactPriorityChoices.PRIORITY_PRIMARY,
+ ),
+ ContactAssignment(
+ object=sites[0],
+ contact=contacts[1],
+ role=contact_roles[1],
+ priority=ContactPriorityChoices.PRIORITY_SECONDARY,
+ ),
+ ContactAssignment(
+ object=sites[0],
+ contact=contacts[2],
+ role=contact_roles[2],
+ priority=ContactPriorityChoices.PRIORITY_TERTIARY,
+ ),
)
ContactAssignment.objects.bulk_create(contact_assignments)
diff --git a/netbox/tenancy/urls.py b/netbox/tenancy/urls.py
index ad9908c62..cd0caabdc 100644
--- a/netbox/tenancy/urls.py
+++ b/netbox/tenancy/urls.py
@@ -1,57 +1,27 @@
from django.urls import include, path
from utilities.urls import get_model_urls
-from . import views
+from . import views # noqa F401
app_name = 'tenancy'
urlpatterns = [
- # Tenant groups
- path('tenant-groups/', views.TenantGroupListView.as_view(), name='tenantgroup_list'),
- path('tenant-groups/add/', views.TenantGroupEditView.as_view(), name='tenantgroup_add'),
- path('tenant-groups/import/', views.TenantGroupBulkImportView.as_view(), name='tenantgroup_import'),
- path('tenant-groups/edit/', views.TenantGroupBulkEditView.as_view(), name='tenantgroup_bulk_edit'),
- path('tenant-groups/delete/', views.TenantGroupBulkDeleteView.as_view(), name='tenantgroup_bulk_delete'),
+ path('tenant-groups/', include(get_model_urls('tenancy', 'tenantgroup', detail=False))),
path('tenant-groups//', include(get_model_urls('tenancy', 'tenantgroup'))),
- # Tenants
- path('tenants/', views.TenantListView.as_view(), name='tenant_list'),
- path('tenants/add/', views.TenantEditView.as_view(), name='tenant_add'),
- path('tenants/import/', views.TenantBulkImportView.as_view(), name='tenant_import'),
- path('tenants/edit/', views.TenantBulkEditView.as_view(), name='tenant_bulk_edit'),
- path('tenants/delete/', views.TenantBulkDeleteView.as_view(), name='tenant_bulk_delete'),
+ path('tenants/', include(get_model_urls('tenancy', 'tenant', detail=False))),
path('tenants//', include(get_model_urls('tenancy', 'tenant'))),
- # Contact groups
- path('contact-groups/', views.ContactGroupListView.as_view(), name='contactgroup_list'),
- path('contact-groups/add/', views.ContactGroupEditView.as_view(), name='contactgroup_add'),
- path('contact-groups/import/', views.ContactGroupBulkImportView.as_view(), name='contactgroup_import'),
- path('contact-groups/edit/', views.ContactGroupBulkEditView.as_view(), name='contactgroup_bulk_edit'),
- path('contact-groups/delete/', views.ContactGroupBulkDeleteView.as_view(), name='contactgroup_bulk_delete'),
+ path('contact-groups/', include(get_model_urls('tenancy', 'contactgroup', detail=False))),
path('contact-groups//', include(get_model_urls('tenancy', 'contactgroup'))),
- # Contact roles
- path('contact-roles/', views.ContactRoleListView.as_view(), name='contactrole_list'),
- path('contact-roles/add/', views.ContactRoleEditView.as_view(), name='contactrole_add'),
- path('contact-roles/import/', views.ContactRoleBulkImportView.as_view(), name='contactrole_import'),
- path('contact-roles/edit/', views.ContactRoleBulkEditView.as_view(), name='contactrole_bulk_edit'),
- path('contact-roles/delete/', views.ContactRoleBulkDeleteView.as_view(), name='contactrole_bulk_delete'),
+ path('contact-roles/', include(get_model_urls('tenancy', 'contactrole', detail=False))),
path('contact-roles//', include(get_model_urls('tenancy', 'contactrole'))),
- # Contacts
- path('contacts/', views.ContactListView.as_view(), name='contact_list'),
- path('contacts/add/', views.ContactEditView.as_view(), name='contact_add'),
- path('contacts/import/', views.ContactBulkImportView.as_view(), name='contact_import'),
- path('contacts/edit/', views.ContactBulkEditView.as_view(), name='contact_bulk_edit'),
- path('contacts/delete/', views.ContactBulkDeleteView.as_view(), name='contact_bulk_delete'),
+ path('contacts/', include(get_model_urls('tenancy', 'contact', detail=False))),
path('contacts//', include(get_model_urls('tenancy', 'contact'))),
- # Contact assignments
- path('contact-assignments/', views.ContactAssignmentListView.as_view(), name='contactassignment_list'),
- path('contact-assignments/add/', views.ContactAssignmentEditView.as_view(), name='contactassignment_add'),
- path('contact-assignments/import/', views.ContactAssignmentBulkImportView.as_view(), name='contactassignment_import'),
- path('contact-assignments/edit/', views.ContactAssignmentBulkEditView.as_view(), name='contactassignment_bulk_edit'),
- path('contact-assignments/delete/', views.ContactAssignmentBulkDeleteView.as_view(), name='contactassignment_bulk_delete'),
+ path('contact-assignments/', include(get_model_urls('tenancy', 'contactassignment', detail=False))),
path('contact-assignments//', include(get_model_urls('tenancy', 'contactassignment'))),
]
diff --git a/netbox/tenancy/views.py b/netbox/tenancy/views.py
index 96b2cb071..0988d2e65 100644
--- a/netbox/tenancy/views.py
+++ b/netbox/tenancy/views.py
@@ -37,11 +37,12 @@ class ObjectContactsView(generic.ObjectChildrenView):
return table
+
#
# Tenant groups
#
-
+@register_model_view(TenantGroup, 'list', path='', detail=False)
class TenantGroupListView(generic.ObjectListView):
queryset = TenantGroup.objects.add_related_count(
TenantGroup.objects.all(),
@@ -67,6 +68,7 @@ class TenantGroupView(GetRelatedModelsMixin, generic.ObjectView):
}
+@register_model_view(TenantGroup, 'add', detail=False)
@register_model_view(TenantGroup, 'edit')
class TenantGroupEditView(generic.ObjectEditView):
queryset = TenantGroup.objects.all()
@@ -78,11 +80,13 @@ class TenantGroupDeleteView(generic.ObjectDeleteView):
queryset = TenantGroup.objects.all()
+@register_model_view(TenantGroup, 'bulk_import', detail=False)
class TenantGroupBulkImportView(generic.BulkImportView):
queryset = TenantGroup.objects.all()
model_form = forms.TenantGroupImportForm
+@register_model_view(TenantGroup, 'bulk_edit', path='edit', detail=False)
class TenantGroupBulkEditView(generic.BulkEditView):
queryset = TenantGroup.objects.add_related_count(
TenantGroup.objects.all(),
@@ -96,6 +100,7 @@ class TenantGroupBulkEditView(generic.BulkEditView):
form = forms.TenantGroupBulkEditForm
+@register_model_view(TenantGroup, 'bulk_delete', path='delete', detail=False)
class TenantGroupBulkDeleteView(generic.BulkDeleteView):
queryset = TenantGroup.objects.add_related_count(
TenantGroup.objects.all(),
@@ -112,6 +117,7 @@ class TenantGroupBulkDeleteView(generic.BulkDeleteView):
# Tenants
#
+@register_model_view(Tenant, 'list', path='', detail=False)
class TenantListView(generic.ObjectListView):
queryset = Tenant.objects.all()
filterset = filtersets.TenantFilterSet
@@ -129,6 +135,7 @@ class TenantView(GetRelatedModelsMixin, generic.ObjectView):
}
+@register_model_view(Tenant, 'add', detail=False)
@register_model_view(Tenant, 'edit')
class TenantEditView(generic.ObjectEditView):
queryset = Tenant.objects.all()
@@ -140,11 +147,13 @@ class TenantDeleteView(generic.ObjectDeleteView):
queryset = Tenant.objects.all()
+@register_model_view(Tenant, 'bulk_import', detail=False)
class TenantBulkImportView(generic.BulkImportView):
queryset = Tenant.objects.all()
model_form = forms.TenantImportForm
+@register_model_view(Tenant, 'bulk_edit', path='edit', detail=False)
class TenantBulkEditView(generic.BulkEditView):
queryset = Tenant.objects.all()
filterset = filtersets.TenantFilterSet
@@ -152,6 +161,7 @@ class TenantBulkEditView(generic.BulkEditView):
form = forms.TenantBulkEditForm
+@register_model_view(Tenant, 'bulk_delete', path='delete', detail=False)
class TenantBulkDeleteView(generic.BulkDeleteView):
queryset = Tenant.objects.all()
filterset = filtersets.TenantFilterSet
@@ -167,6 +177,7 @@ class TenantContactsView(ObjectContactsView):
# Contact groups
#
+@register_model_view(ContactGroup, 'list', path='', detail=False)
class ContactGroupListView(generic.ObjectListView):
queryset = ContactGroup.objects.add_related_count(
ContactGroup.objects.all(),
@@ -192,6 +203,7 @@ class ContactGroupView(GetRelatedModelsMixin, generic.ObjectView):
}
+@register_model_view(ContactGroup, 'add', detail=False)
@register_model_view(ContactGroup, 'edit')
class ContactGroupEditView(generic.ObjectEditView):
queryset = ContactGroup.objects.all()
@@ -203,11 +215,13 @@ class ContactGroupDeleteView(generic.ObjectDeleteView):
queryset = ContactGroup.objects.all()
+@register_model_view(ContactGroup, 'bulk_import', detail=False)
class ContactGroupBulkImportView(generic.BulkImportView):
queryset = ContactGroup.objects.all()
model_form = forms.ContactGroupImportForm
+@register_model_view(ContactGroup, 'bulk_edit', path='edit', detail=False)
class ContactGroupBulkEditView(generic.BulkEditView):
queryset = ContactGroup.objects.add_related_count(
ContactGroup.objects.all(),
@@ -221,6 +235,7 @@ class ContactGroupBulkEditView(generic.BulkEditView):
form = forms.ContactGroupBulkEditForm
+@register_model_view(ContactGroup, 'bulk_delete', path='delete', detail=False)
class ContactGroupBulkDeleteView(generic.BulkDeleteView):
queryset = ContactGroup.objects.add_related_count(
ContactGroup.objects.all(),
@@ -237,6 +252,7 @@ class ContactGroupBulkDeleteView(generic.BulkDeleteView):
# Contact roles
#
+@register_model_view(ContactRole, 'list', path='', detail=False)
class ContactRoleListView(generic.ObjectListView):
queryset = ContactRole.objects.all()
filterset = filtersets.ContactRoleFilterSet
@@ -254,6 +270,7 @@ class ContactRoleView(GetRelatedModelsMixin, generic.ObjectView):
}
+@register_model_view(ContactRole, 'add', detail=False)
@register_model_view(ContactRole, 'edit')
class ContactRoleEditView(generic.ObjectEditView):
queryset = ContactRole.objects.all()
@@ -265,11 +282,13 @@ class ContactRoleDeleteView(generic.ObjectDeleteView):
queryset = ContactRole.objects.all()
+@register_model_view(ContactRole, 'bulk_import', detail=False)
class ContactRoleBulkImportView(generic.BulkImportView):
queryset = ContactRole.objects.all()
model_form = forms.ContactRoleImportForm
+@register_model_view(ContactRole, 'bulk_edit', path='edit', detail=False)
class ContactRoleBulkEditView(generic.BulkEditView):
queryset = ContactRole.objects.all()
filterset = filtersets.ContactRoleFilterSet
@@ -277,6 +296,7 @@ class ContactRoleBulkEditView(generic.BulkEditView):
form = forms.ContactRoleBulkEditForm
+@register_model_view(ContactRole, 'bulk_delete', path='delete', detail=False)
class ContactRoleBulkDeleteView(generic.BulkDeleteView):
queryset = ContactRole.objects.all()
filterset = filtersets.ContactRoleFilterSet
@@ -287,6 +307,7 @@ class ContactRoleBulkDeleteView(generic.BulkDeleteView):
# Contacts
#
+@register_model_view(Contact, 'list', path='', detail=False)
class ContactListView(generic.ObjectListView):
queryset = Contact.objects.annotate(
assignment_count=count_related(ContactAssignment, 'contact')
@@ -301,6 +322,7 @@ class ContactView(generic.ObjectView):
queryset = Contact.objects.all()
+@register_model_view(Contact, 'add', detail=False)
@register_model_view(Contact, 'edit')
class ContactEditView(generic.ObjectEditView):
queryset = Contact.objects.all()
@@ -312,11 +334,13 @@ class ContactDeleteView(generic.ObjectDeleteView):
queryset = Contact.objects.all()
+@register_model_view(Contact, 'bulk_import', detail=False)
class ContactBulkImportView(generic.BulkImportView):
queryset = Contact.objects.all()
model_form = forms.ContactImportForm
+@register_model_view(Contact, 'bulk_edit', path='edit', detail=False)
class ContactBulkEditView(generic.BulkEditView):
queryset = Contact.objects.annotate(
assignment_count=count_related(ContactAssignment, 'contact')
@@ -326,6 +350,7 @@ class ContactBulkEditView(generic.BulkEditView):
form = forms.ContactBulkEditForm
+@register_model_view(Contact, 'bulk_delete', path='delete', detail=False)
class ContactBulkDeleteView(generic.BulkDeleteView):
queryset = Contact.objects.annotate(
assignment_count=count_related(ContactAssignment, 'contact')
@@ -333,24 +358,26 @@ class ContactBulkDeleteView(generic.BulkDeleteView):
filterset = filtersets.ContactFilterSet
table = tables.ContactTable
+
#
# Contact assignments
#
-
+@register_model_view(ContactAssignment, 'list', path='', detail=False)
class ContactAssignmentListView(generic.ObjectListView):
queryset = ContactAssignment.objects.all()
filterset = filtersets.ContactAssignmentFilterSet
filterset_form = forms.ContactAssignmentFilterForm
table = tables.ContactAssignmentTable
actions = {
- 'import': {'add'},
'export': {'view'},
+ 'bulk_import': {'add'},
'bulk_edit': {'change'},
'bulk_delete': {'delete'},
}
+@register_model_view(ContactAssignment, 'add', detail=False)
@register_model_view(ContactAssignment, 'edit')
class ContactAssignmentEditView(generic.ObjectEditView):
queryset = ContactAssignment.objects.all()
@@ -370,6 +397,13 @@ class ContactAssignmentEditView(generic.ObjectEditView):
}
+@register_model_view(ContactAssignment, 'bulk_import', detail=False)
+class ContactAssignmentBulkImportView(generic.BulkImportView):
+ queryset = ContactAssignment.objects.all()
+ model_form = forms.ContactAssignmentImportForm
+
+
+@register_model_view(ContactAssignment, 'bulk_edit', path='edit', detail=False)
class ContactAssignmentBulkEditView(generic.BulkEditView):
queryset = ContactAssignment.objects.all()
filterset = filtersets.ContactAssignmentFilterSet
@@ -377,11 +411,7 @@ class ContactAssignmentBulkEditView(generic.BulkEditView):
form = forms.ContactAssignmentBulkEditForm
-class ContactAssignmentBulkImportView(generic.BulkImportView):
- queryset = ContactAssignment.objects.all()
- model_form = forms.ContactAssignmentImportForm
-
-
+@register_model_view(ContactAssignment, 'bulk_delete', path='delete', detail=False)
class ContactAssignmentBulkDeleteView(generic.BulkDeleteView):
queryset = ContactAssignment.objects.all()
filterset = filtersets.ContactAssignmentFilterSet
diff --git a/netbox/users/admin.py b/netbox/users/admin.py
deleted file mode 100644
index 0fa7e0ca2..000000000
--- a/netbox/users/admin.py
+++ /dev/null
@@ -1,5 +0,0 @@
-from django.contrib import admin
-from django.contrib.auth.models import Group as DjangoGroup
-
-# Prevent the stock Django Group model from appearing in the admin UI (if enabled)
-admin.site.unregister(DjangoGroup)
diff --git a/netbox/users/api/nested_serializers.py b/netbox/users/api/nested_serializers.py
deleted file mode 100644
index 201e38901..000000000
--- a/netbox/users/api/nested_serializers.py
+++ /dev/null
@@ -1,53 +0,0 @@
-import warnings
-
-from drf_spectacular.utils import extend_schema_field
-from rest_framework import serializers
-
-from core.models import ObjectType
-from netbox.api.fields import ContentTypeField
-from netbox.api.serializers import WritableNestedSerializer
-from serializers_.nested import NestedGroupSerializer, NestedUserSerializer
-from users.models import ObjectPermission, Token
-
-__all__ = [
- 'NestedGroupSerializer',
- 'NestedObjectPermissionSerializer',
- 'NestedTokenSerializer',
- 'NestedUserSerializer',
-]
-
-# TODO: Remove in v4.2
-warnings.warn(
- "Dedicated nested serializers will be removed in NetBox v4.2. Use Serializer(nested=True) instead.",
- DeprecationWarning
-)
-
-
-class NestedTokenSerializer(WritableNestedSerializer):
-
- class Meta:
- model = Token
- fields = ['id', 'url', 'display_url', 'display', 'key', 'write_enabled']
-
-
-class NestedObjectPermissionSerializer(WritableNestedSerializer):
- object_types = ContentTypeField(
- queryset=ObjectType.objects.all(),
- many=True
- )
- groups = serializers.SerializerMethodField(read_only=True)
- users = serializers.SerializerMethodField(read_only=True)
-
- class Meta:
- model = ObjectPermission
- fields = [
- 'id', 'url', 'display_url', 'display', 'name', 'enabled', 'object_types', 'groups', 'users', 'actions'
- ]
-
- @extend_schema_field(serializers.ListField)
- def get_groups(self, obj):
- return [g.name for g in obj.groups.all()]
-
- @extend_schema_field(serializers.ListField)
- def get_users(self, obj):
- return [u.username for u in obj.users.all()]
diff --git a/netbox/users/migrations/0001_squashed_0011.py b/netbox/users/migrations/0001_squashed_0011.py
index cad84201c..263604d34 100644
--- a/netbox/users/migrations/0001_squashed_0011.py
+++ b/netbox/users/migrations/0001_squashed_0011.py
@@ -8,7 +8,6 @@ import users.models
class Migration(migrations.Migration):
-
initial = True
dependencies = [
@@ -39,15 +38,33 @@ class Migration(migrations.Migration):
('password', models.CharField(max_length=128)),
('last_login', models.DateTimeField(blank=True, null=True)),
('is_superuser', models.BooleanField(default=False)),
- ('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()])),
+ (
+ 'username',
+ models.CharField(
+ error_messages={'unique': 'A user with that username already exists.'},
+ max_length=150,
+ unique=True,
+ validators=[django.contrib.auth.validators.UnicodeUsernameValidator()],
+ ),
+ ),
('first_name', models.CharField(blank=True, max_length=150)),
('last_name', models.CharField(blank=True, max_length=150)),
('email', models.EmailField(blank=True, max_length=254)),
('is_staff', models.BooleanField(default=False)),
('is_active', models.BooleanField(default=True)),
('date_joined', models.DateTimeField(default=django.utils.timezone.now)),
- ('groups', models.ManyToManyField(blank=True, related_name='user_set', related_query_name='user', to='auth.group')),
- ('user_permissions', models.ManyToManyField(blank=True, related_name='user_set', related_query_name='user', to='auth.permission')),
+ (
+ 'groups',
+ models.ManyToManyField(
+ blank=True, related_name='user_set', related_query_name='user', to='auth.group'
+ ),
+ ),
+ (
+ 'user_permissions',
+ models.ManyToManyField(
+ blank=True, related_name='user_set', related_query_name='user', to='auth.permission'
+ ),
+ ),
],
options={
'verbose_name': 'user',
@@ -64,7 +81,12 @@ class Migration(migrations.Migration):
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False)),
('data', models.JSONField(default=dict)),
- ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='config', to=settings.AUTH_USER_MODEL)),
+ (
+ 'user',
+ models.OneToOneField(
+ on_delete=django.db.models.deletion.CASCADE, related_name='config', to=settings.AUTH_USER_MODEL
+ ),
+ ),
],
options={
'verbose_name': 'User Preferences',
@@ -78,10 +100,20 @@ class Migration(migrations.Migration):
('id', models.BigAutoField(primary_key=True, serialize=False)),
('created', models.DateTimeField(auto_now_add=True)),
('expires', models.DateTimeField(blank=True, null=True)),
- ('key', models.CharField(max_length=40, unique=True, validators=[django.core.validators.MinLengthValidator(40)])),
+ (
+ 'key',
+ models.CharField(
+ max_length=40, unique=True, validators=[django.core.validators.MinLengthValidator(40)]
+ ),
+ ),
('write_enabled', models.BooleanField(default=True)),
('description', models.CharField(blank=True, max_length=200)),
- ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='tokens', to=settings.AUTH_USER_MODEL)),
+ (
+ 'user',
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE, related_name='tokens', to=settings.AUTH_USER_MODEL
+ ),
+ ),
],
),
migrations.CreateModel(
@@ -91,11 +123,37 @@ class Migration(migrations.Migration):
('name', models.CharField(max_length=100)),
('description', models.CharField(blank=True, max_length=200)),
('enabled', models.BooleanField(default=True)),
- ('actions', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=30), size=None)),
+ (
+ 'actions',
+ django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=30), size=None),
+ ),
('constraints', models.JSONField(blank=True, null=True)),
('groups', models.ManyToManyField(blank=True, related_name='object_permissions', to='auth.Group')),
- ('object_types', models.ManyToManyField(limit_choices_to=models.Q(models.Q(models.Q(('app_label__in', ['account', 'admin', 'auth', 'contenttypes', 'sessions', 'taggit', 'users']), _negated=True), models.Q(('app_label', 'auth'), ('model__in', ['group', 'user'])), models.Q(('app_label', 'users'), ('model__in', ['objectpermission', 'token'])), _connector='OR')), related_name='object_permissions', to='contenttypes.ContentType')),
- ('users', models.ManyToManyField(blank=True, related_name='object_permissions', to=settings.AUTH_USER_MODEL)),
+ (
+ 'object_types',
+ models.ManyToManyField(
+ limit_choices_to=models.Q(
+ models.Q(
+ models.Q(
+ (
+ 'app_label__in',
+ ['account', 'admin', 'auth', 'contenttypes', 'sessions', 'taggit', 'users'],
+ ),
+ _negated=True,
+ ),
+ models.Q(('app_label', 'auth'), ('model__in', ['group', 'user'])),
+ models.Q(('app_label', 'users'), ('model__in', ['objectpermission', 'token'])),
+ _connector='OR',
+ )
+ ),
+ related_name='object_permissions',
+ to='contenttypes.ContentType',
+ ),
+ ),
+ (
+ 'users',
+ models.ManyToManyField(blank=True, related_name='object_permissions', to=settings.AUTH_USER_MODEL),
+ ),
],
options={
'verbose_name': 'permission',
diff --git a/netbox/users/migrations/0002_squashed_0004.py b/netbox/users/migrations/0002_squashed_0004.py
index 078721c48..275d7a7a9 100644
--- a/netbox/users/migrations/0002_squashed_0004.py
+++ b/netbox/users/migrations/0002_squashed_0004.py
@@ -5,11 +5,10 @@ import ipam.fields
class Migration(migrations.Migration):
-
replaces = [
('users', '0002_standardize_id_fields'),
('users', '0003_token_allowed_ips_last_used'),
- ('users', '0004_netboxgroup_netboxuser')
+ ('users', '0004_netboxgroup_netboxuser'),
]
dependencies = [
@@ -36,7 +35,9 @@ class Migration(migrations.Migration):
migrations.AddField(
model_name='token',
name='allowed_ips',
- field=django.contrib.postgres.fields.ArrayField(base_field=ipam.fields.IPNetworkField(), blank=True, null=True, size=None),
+ field=django.contrib.postgres.fields.ArrayField(
+ base_field=ipam.fields.IPNetworkField(), blank=True, null=True, size=None
+ ),
),
migrations.AddField(
model_name='token',
@@ -45,8 +46,7 @@ class Migration(migrations.Migration):
),
migrations.CreateModel(
name='NetBoxGroup',
- fields=[
- ],
+ fields=[],
options={
'verbose_name': 'Group',
'proxy': True,
diff --git a/netbox/users/migrations/0005_alter_user_table.py b/netbox/users/migrations/0005_alter_user_table.py
index 1163da0ae..2e9f699b3 100644
--- a/netbox/users/migrations/0005_alter_user_table.py
+++ b/netbox/users/migrations/0005_alter_user_table.py
@@ -19,7 +19,6 @@ def update_content_types(apps, schema_editor):
class Migration(migrations.Migration):
-
dependencies = [
('users', '0002_squashed_0004'),
('extras', '0113_customfield_rename_object_type'),
@@ -33,24 +32,17 @@ class Migration(migrations.Migration):
name='user',
table=None,
),
-
# Convert the `id` column to a 64-bit integer (BigAutoField is implied by DEFAULT_AUTO_FIELD)
- migrations.RunSQL("ALTER TABLE users_user ALTER COLUMN id TYPE bigint"),
-
+ migrations.RunSQL('ALTER TABLE users_user ALTER COLUMN id TYPE bigint'),
# Rename auth_user_* sequences
- migrations.RunSQL("ALTER TABLE auth_user_groups_id_seq RENAME TO users_user_groups_id_seq"),
- migrations.RunSQL("ALTER TABLE auth_user_id_seq RENAME TO users_user_id_seq"),
- migrations.RunSQL("ALTER TABLE auth_user_user_permissions_id_seq RENAME TO users_user_user_permissions_id_seq"),
-
+ migrations.RunSQL('ALTER TABLE auth_user_groups_id_seq RENAME TO users_user_groups_id_seq'),
+ migrations.RunSQL('ALTER TABLE auth_user_id_seq RENAME TO users_user_id_seq'),
+ migrations.RunSQL('ALTER TABLE auth_user_user_permissions_id_seq RENAME TO users_user_user_permissions_id_seq'),
# Rename auth_user_* indexes
- migrations.RunSQL("ALTER INDEX auth_user_pkey RENAME TO users_user_pkey"),
+ migrations.RunSQL('ALTER INDEX auth_user_pkey RENAME TO users_user_pkey'),
# Hash is deterministic; generated via schema_editor._create_index_name()
- migrations.RunSQL("ALTER INDEX auth_user_username_6821ab7c_like RENAME TO users_user_username_06e46fe6_like"),
- migrations.RunSQL("ALTER INDEX auth_user_username_key RENAME TO users_user_username_key"),
-
+ migrations.RunSQL('ALTER INDEX auth_user_username_6821ab7c_like RENAME TO users_user_username_06e46fe6_like'),
+ migrations.RunSQL('ALTER INDEX auth_user_username_key RENAME TO users_user_username_key'),
# Update ContentTypes
- migrations.RunPython(
- code=update_content_types,
- reverse_code=migrations.RunPython.noop
- ),
+ migrations.RunPython(code=update_content_types, reverse_code=migrations.RunPython.noop),
]
diff --git a/netbox/users/migrations/0006_custom_group_model.py b/netbox/users/migrations/0006_custom_group_model.py
index f958d242a..f70c1d58d 100644
--- a/netbox/users/migrations/0006_custom_group_model.py
+++ b/netbox/users/migrations/0006_custom_group_model.py
@@ -16,7 +16,6 @@ def update_custom_fields(apps, schema_editor):
class Migration(migrations.Migration):
-
dependencies = [
('users', '0005_alter_user_table'),
]
@@ -29,7 +28,12 @@ class Migration(migrations.Migration):
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
('name', models.CharField(max_length=150, unique=True)),
('description', models.CharField(blank=True, max_length=200)),
- ('permissions', models.ManyToManyField(blank=True, related_name='groups', related_query_name='group', to='auth.permission')),
+ (
+ 'permissions',
+ models.ManyToManyField(
+ blank=True, related_name='groups', related_query_name='group', to='auth.permission'
+ ),
+ ),
],
options={
'ordering': ('name',),
@@ -40,17 +44,10 @@ class Migration(migrations.Migration):
('objects', users.models.GroupManager()),
],
),
-
# Copy existing groups from the old table into the new one
- migrations.RunSQL(
- "INSERT INTO users_group (SELECT id, name, '' AS description FROM auth_group)"
- ),
-
+ migrations.RunSQL("INSERT INTO users_group (SELECT id, name, '' AS description FROM auth_group)"),
# Update the sequence for group ID values
- migrations.RunSQL(
- "SELECT setval('users_group_id_seq', (SELECT MAX(id) FROM users_group))"
- ),
-
+ migrations.RunSQL("SELECT setval('users_group_id_seq', (SELECT MAX(id) FROM users_group))"),
# Update the "groups" M2M fields on User & ObjectPermission
migrations.AlterField(
model_name='user',
@@ -62,23 +59,12 @@ class Migration(migrations.Migration):
name='groups',
field=models.ManyToManyField(blank=True, related_name='object_permissions', to='users.group'),
),
-
# Delete any lingering group assignments for legacy permissions (from before NetBox v2.9)
- migrations.RunSQL(
- "DELETE from auth_group_permissions"
- ),
-
+ migrations.RunSQL('DELETE from auth_group_permissions'),
# Delete groups from the old table
- migrations.RunSQL(
- "DELETE from auth_group"
- ),
-
+ migrations.RunSQL('DELETE from auth_group'),
# Update custom fields
- migrations.RunPython(
- code=update_custom_fields,
- reverse_code=migrations.RunPython.noop
- ),
-
+ migrations.RunPython(code=update_custom_fields, reverse_code=migrations.RunPython.noop),
# Delete the proxy model
migrations.DeleteModel(
name='NetBoxGroup',
diff --git a/netbox/users/migrations/0007_objectpermission_update_object_types.py b/netbox/users/migrations/0007_objectpermission_update_object_types.py
index d3018a602..598b00b92 100644
--- a/netbox/users/migrations/0007_objectpermission_update_object_types.py
+++ b/netbox/users/migrations/0007_objectpermission_update_object_types.py
@@ -4,7 +4,6 @@ from django.db import migrations, models
class Migration(migrations.Migration):
-
dependencies = [
('core', '0010_gfk_indexes'),
('users', '0006_custom_group_model'),
@@ -14,6 +13,23 @@ class Migration(migrations.Migration):
migrations.AlterField(
model_name='objectpermission',
name='object_types',
- field=models.ManyToManyField(limit_choices_to=models.Q(models.Q(models.Q(('app_label__in', ['account', 'admin', 'auth', 'contenttypes', 'sessions', 'taggit', 'users']), _negated=True), models.Q(('app_label', 'auth'), ('model__in', ['group', 'user'])), models.Q(('app_label', 'users'), ('model__in', ['objectpermission', 'token'])), _connector='OR')), related_name='object_permissions', to='core.objecttype'),
+ field=models.ManyToManyField(
+ limit_choices_to=models.Q(
+ models.Q(
+ models.Q(
+ (
+ 'app_label__in',
+ ['account', 'admin', 'auth', 'contenttypes', 'sessions', 'taggit', 'users'],
+ ),
+ _negated=True,
+ ),
+ models.Q(('app_label', 'auth'), ('model__in', ['group', 'user'])),
+ models.Q(('app_label', 'users'), ('model__in', ['objectpermission', 'token'])),
+ _connector='OR',
+ )
+ ),
+ related_name='object_permissions',
+ to='core.objecttype',
+ ),
),
]
diff --git a/netbox/users/migrations/0008_flip_objectpermission_assignments.py b/netbox/users/migrations/0008_flip_objectpermission_assignments.py
index c61c8b124..11dea5819 100644
--- a/netbox/users/migrations/0008_flip_objectpermission_assignments.py
+++ b/netbox/users/migrations/0008_flip_objectpermission_assignments.py
@@ -2,7 +2,6 @@ from django.db import migrations, models
class Migration(migrations.Migration):
-
dependencies = [
('users', '0007_objectpermission_update_object_types'),
]
@@ -24,52 +23,47 @@ class Migration(migrations.Migration):
database_operations=[
# Rename table
migrations.RunSQL(
- "ALTER TABLE users_objectpermission_groups"
- " RENAME TO users_group_object_permissions"
+ 'ALTER TABLE users_objectpermission_groups' ' RENAME TO users_group_object_permissions'
),
migrations.RunSQL(
- "ALTER TABLE users_objectpermission_groups_id_seq"
- " RENAME TO users_group_object_permissions_id_seq"
+ 'ALTER TABLE users_objectpermission_groups_id_seq'
+ ' RENAME TO users_group_object_permissions_id_seq'
),
-
# Rename constraints
migrations.RunSQL(
- "ALTER TABLE users_group_object_permissions RENAME CONSTRAINT "
- "users_objectpermissi_group_id_fb7ba6e0_fk_users_gro TO "
- "users_group_object_p_group_id_90dd183a_fk_users_gro"
+ 'ALTER TABLE users_group_object_permissions RENAME CONSTRAINT '
+ 'users_objectpermissi_group_id_fb7ba6e0_fk_users_gro TO '
+ 'users_group_object_p_group_id_90dd183a_fk_users_gro'
),
# Fix for #15698: Drop & recreate constraint which may not exist
migrations.RunSQL(
- "ALTER TABLE users_group_object_permissions DROP CONSTRAINT IF EXISTS "
- "users_objectpermissi_objectpermission_id_2f7cc117_fk_users_obj"
+ 'ALTER TABLE users_group_object_permissions DROP CONSTRAINT IF EXISTS '
+ 'users_objectpermissi_objectpermission_id_2f7cc117_fk_users_obj'
),
migrations.RunSQL(
- "ALTER TABLE users_group_object_permissions ADD CONSTRAINT "
- "users_group_object_p_objectpermission_id_dd489dc4_fk_users_obj "
- "FOREIGN KEY (objectpermission_id) REFERENCES users_objectpermission(id) "
- "DEFERRABLE INITIALLY DEFERRED"
+ 'ALTER TABLE users_group_object_permissions ADD CONSTRAINT '
+ 'users_group_object_p_objectpermission_id_dd489dc4_fk_users_obj '
+ 'FOREIGN KEY (objectpermission_id) REFERENCES users_objectpermission(id) '
+ 'DEFERRABLE INITIALLY DEFERRED'
),
-
# Rename indexes
migrations.RunSQL(
- "ALTER INDEX users_objectpermission_groups_pkey "
- " RENAME TO users_group_object_permissions_pkey"
+ 'ALTER INDEX users_objectpermission_groups_pkey ' ' RENAME TO users_group_object_permissions_pkey'
),
migrations.RunSQL(
- "ALTER INDEX users_objectpermission_g_objectpermission_id_grou_3b62a39c_uniq "
- " RENAME TO users_group_object_permi_group_id_objectpermissio_db1f8cbe_uniq"
+ 'ALTER INDEX users_objectpermission_g_objectpermission_id_grou_3b62a39c_uniq '
+ ' RENAME TO users_group_object_permi_group_id_objectpermissio_db1f8cbe_uniq'
),
migrations.RunSQL(
- "ALTER INDEX users_objectpermission_groups_group_id_fb7ba6e0"
- " RENAME TO users_group_object_permissions_group_id_90dd183a"
+ 'ALTER INDEX users_objectpermission_groups_group_id_fb7ba6e0'
+ ' RENAME TO users_group_object_permissions_group_id_90dd183a'
),
migrations.RunSQL(
- "ALTER INDEX users_objectpermission_groups_objectpermission_id_2f7cc117"
- " RENAME TO users_group_object_permissions_objectpermission_id_dd489dc4"
+ 'ALTER INDEX users_objectpermission_groups_objectpermission_id_2f7cc117'
+ ' RENAME TO users_group_object_permissions_objectpermission_id_dd489dc4'
),
- ]
+ ],
),
-
# Flip M2M assignments for ObjectPermission to Users
migrations.SeparateDatabaseAndState(
state_operations=[
@@ -86,49 +80,44 @@ class Migration(migrations.Migration):
database_operations=[
# Rename table
migrations.RunSQL(
- "ALTER TABLE users_objectpermission_users"
- " RENAME TO users_user_object_permissions"
+ 'ALTER TABLE users_objectpermission_users' ' RENAME TO users_user_object_permissions'
),
migrations.RunSQL(
- "ALTER TABLE users_objectpermission_users_id_seq"
- " RENAME TO users_user_object_permissions_id_seq"
+ 'ALTER TABLE users_objectpermission_users_id_seq' ' RENAME TO users_user_object_permissions_id_seq'
),
-
# Rename constraints
migrations.RunSQL(
- "ALTER TABLE users_user_object_permissions RENAME CONSTRAINT "
- "users_objectpermission_users_user_id_16c0905d_fk_auth_user_id TO "
- "users_user_object_permissions_user_id_9d647aac_fk_users_user_id"
+ 'ALTER TABLE users_user_object_permissions RENAME CONSTRAINT '
+ 'users_objectpermission_users_user_id_16c0905d_fk_auth_user_id TO '
+ 'users_user_object_permissions_user_id_9d647aac_fk_users_user_id'
),
# Fix for #15698: Drop & recreate constraint which may not exist
migrations.RunSQL(
- "ALTER TABLE users_user_object_permissions DROP CONSTRAINT IF EXISTS "
- "users_objectpermissi_objectpermission_id_78a9c2e6_fk_users_obj"
+ 'ALTER TABLE users_user_object_permissions DROP CONSTRAINT IF EXISTS '
+ 'users_objectpermissi_objectpermission_id_78a9c2e6_fk_users_obj'
),
migrations.RunSQL(
- "ALTER TABLE users_user_object_permissions ADD CONSTRAINT "
- "users_user_object_pe_objectpermission_id_29b431b4_fk_users_obj "
- "FOREIGN KEY (objectpermission_id) REFERENCES users_objectpermission(id) "
- "DEFERRABLE INITIALLY DEFERRED"
+ 'ALTER TABLE users_user_object_permissions ADD CONSTRAINT '
+ 'users_user_object_pe_objectpermission_id_29b431b4_fk_users_obj '
+ 'FOREIGN KEY (objectpermission_id) REFERENCES users_objectpermission(id) '
+ 'DEFERRABLE INITIALLY DEFERRED'
),
-
# Rename indexes
migrations.RunSQL(
- "ALTER INDEX users_objectpermission_users_pkey "
- " RENAME TO users_user_object_permissions_pkey"
+ 'ALTER INDEX users_objectpermission_users_pkey ' ' RENAME TO users_user_object_permissions_pkey'
),
migrations.RunSQL(
- "ALTER INDEX users_objectpermission_u_objectpermission_id_user_3a7db108_uniq "
- " RENAME TO users_user_object_permis_user_id_objectpermission_0a98550e_uniq"
+ 'ALTER INDEX users_objectpermission_u_objectpermission_id_user_3a7db108_uniq '
+ ' RENAME TO users_user_object_permis_user_id_objectpermission_0a98550e_uniq'
),
migrations.RunSQL(
- "ALTER INDEX users_objectpermission_users_user_id_16c0905d"
- " RENAME TO users_user_object_permissions_user_id_9d647aac"
+ 'ALTER INDEX users_objectpermission_users_user_id_16c0905d'
+ ' RENAME TO users_user_object_permissions_user_id_9d647aac'
),
migrations.RunSQL(
- "ALTER INDEX users_objectpermission_users_objectpermission_id_78a9c2e6"
- " RENAME TO users_user_object_permissions_objectpermission_id_29b431b4"
+ 'ALTER INDEX users_objectpermission_users_objectpermission_id_78a9c2e6'
+ ' RENAME TO users_user_object_permissions_objectpermission_id_29b431b4'
),
- ]
+ ],
),
]
diff --git a/netbox/users/migrations/0009_update_group_perms.py b/netbox/users/migrations/0009_update_group_perms.py
index f3b197492..7698fd1e7 100644
--- a/netbox/users/migrations/0009_update_group_perms.py
+++ b/netbox/users/migrations/0009_update_group_perms.py
@@ -18,17 +18,13 @@ def update_content_types(apps, schema_editor):
class Migration(migrations.Migration):
-
dependencies = [
('users', '0008_flip_objectpermission_assignments'),
]
operations = [
# Update ContentTypes
- migrations.RunPython(
- code=update_content_types,
- reverse_code=migrations.RunPython.noop
- ),
+ migrations.RunPython(code=update_content_types, reverse_code=migrations.RunPython.noop),
migrations.AlterField(
model_name='objectpermission',
name='object_types',
diff --git a/netbox/users/tests/test_filtersets.py b/netbox/users/tests/test_filtersets.py
index fdf25d970..8b683b346 100644
--- a/netbox/users/tests/test_filtersets.py
+++ b/netbox/users/tests/test_filtersets.py
@@ -286,9 +286,15 @@ class TokenTestCase(TestCase, BaseFilterSetTests):
future_date = make_aware(datetime.datetime(3000, 1, 1))
past_date = make_aware(datetime.datetime(2000, 1, 1))
tokens = (
- Token(user=users[0], key=Token.generate_key(), expires=future_date, write_enabled=True, description='foobar1'),
- Token(user=users[1], key=Token.generate_key(), expires=future_date, write_enabled=True, description='foobar2'),
- Token(user=users[2], key=Token.generate_key(), expires=past_date, write_enabled=False),
+ Token(
+ user=users[0], key=Token.generate_key(), expires=future_date, write_enabled=True, description='foobar1'
+ ),
+ Token(
+ user=users[1], key=Token.generate_key(), expires=future_date, write_enabled=True, description='foobar2'
+ ),
+ Token(
+ user=users[2], key=Token.generate_key(), expires=past_date, write_enabled=False
+ ),
)
Token.objects.bulk_create(tokens)
diff --git a/netbox/users/tests/test_views.py b/netbox/users/tests/test_views.py
index 8386364dd..8226a8be9 100644
--- a/netbox/users/tests/test_views.py
+++ b/netbox/users/tests/test_views.py
@@ -23,11 +23,16 @@ class UserTestCase(
@classmethod
def setUpTestData(cls):
-
users = (
- User(username='username1', first_name='first1', last_name='last1', email='user1@foo.com', password='pass1xxx'),
- User(username='username2', first_name='first2', last_name='last2', email='user2@foo.com', password='pass2xxx'),
- User(username='username3', first_name='first3', last_name='last3', email='user3@foo.com', password='pass3xxx'),
+ User(
+ username='username1', first_name='first1', last_name='last1', email='user1@foo.com', password='pass1xxx'
+ ),
+ User(
+ username='username2', first_name='first2', last_name='last2', email='user2@foo.com', password='pass2xxx'
+ ),
+ User(
+ username='username3', first_name='first3', last_name='last3', email='user3@foo.com', password='pass3xxx'
+ ),
)
User.objects.bulk_create(users)
diff --git a/netbox/users/urls.py b/netbox/users/urls.py
index 0540eae1f..83f120702 100644
--- a/netbox/users/urls.py
+++ b/netbox/users/urls.py
@@ -1,40 +1,21 @@
from django.urls import include, path
from utilities.urls import get_model_urls
-from . import views
+from . import views # noqa F401
app_name = 'users'
urlpatterns = [
- # Tokens
- path('tokens/', views.TokenListView.as_view(), name='token_list'),
- path('tokens/add/', views.TokenEditView.as_view(), name='token_add'),
- path('tokens/import/', views.TokenBulkImportView.as_view(), name='token_import'),
- path('tokens/edit/', views.TokenBulkEditView.as_view(), name='token_bulk_edit'),
- path('tokens/delete/', views.TokenBulkDeleteView.as_view(), name='token_bulk_delete'),
+ path('tokens/', include(get_model_urls('users', 'token', detail=False))),
path('tokens//', include(get_model_urls('users', 'token'))),
- # Users
- path('users/', views.UserListView.as_view(), name='user_list'),
- path('users/add/', views.UserEditView.as_view(), name='user_add'),
- path('users/edit/', views.UserBulkEditView.as_view(), name='user_bulk_edit'),
- path('users/import/', views.UserBulkImportView.as_view(), name='user_import'),
- path('users/delete/', views.UserBulkDeleteView.as_view(), name='user_bulk_delete'),
+ path('users/', include(get_model_urls('users', 'user', detail=False))),
path('users//', include(get_model_urls('users', 'user'))),
- # Groups
- path('groups/', views.GroupListView.as_view(), name='group_list'),
- path('groups/add/', views.GroupEditView.as_view(), name='group_add'),
- path('groups/edit/', views.GroupBulkEditView.as_view(), name='group_bulk_edit'),
- path('groups/import/', views.GroupBulkImportView.as_view(), name='group_import'),
- path('groups/delete/', views.GroupBulkDeleteView.as_view(), name='group_bulk_delete'),
+ path('groups/', include(get_model_urls('users', 'group', detail=False))),
path('groups//', include(get_model_urls('users', 'group'))),
- # Permissions
- path('permissions/', views.ObjectPermissionListView.as_view(), name='objectpermission_list'),
- path('permissions/add/', views.ObjectPermissionEditView.as_view(), name='objectpermission_add'),
- path('permissions/edit/', views.ObjectPermissionBulkEditView.as_view(), name='objectpermission_bulk_edit'),
- path('permissions/delete/', views.ObjectPermissionBulkDeleteView.as_view(), name='objectpermission_bulk_delete'),
+ path('permissions/', include(get_model_urls('users', 'objectpermission', detail=False))),
path('permissions//', include(get_model_urls('users', 'objectpermission'))),
]
diff --git a/netbox/users/views.py b/netbox/users/views.py
index b2f9a8d04..ca928e582 100644
--- a/netbox/users/views.py
+++ b/netbox/users/views.py
@@ -12,6 +12,7 @@ from .models import Group, User, ObjectPermission, Token
# Tokens
#
+@register_model_view(Token, 'list', path='', detail=False)
class TokenListView(generic.ObjectListView):
queryset = Token.objects.all()
filterset = filtersets.TokenFilterSet
@@ -24,6 +25,7 @@ class TokenView(generic.ObjectView):
queryset = Token.objects.all()
+@register_model_view(Token, 'add', detail=False)
@register_model_view(Token, 'edit')
class TokenEditView(generic.ObjectEditView):
queryset = Token.objects.all()
@@ -36,17 +38,20 @@ class TokenDeleteView(generic.ObjectDeleteView):
queryset = Token.objects.all()
+@register_model_view(Token, 'bulk_import', detail=False)
class TokenBulkImportView(generic.BulkImportView):
queryset = Token.objects.all()
model_form = forms.TokenImportForm
+@register_model_view(Token, 'bulk_edit', path='edit', detail=False)
class TokenBulkEditView(generic.BulkEditView):
queryset = Token.objects.all()
table = tables.TokenTable
form = forms.TokenBulkEditForm
+@register_model_view(Token, 'bulk_delete', path='delete', detail=False)
class TokenBulkDeleteView(generic.BulkDeleteView):
queryset = Token.objects.all()
table = tables.TokenTable
@@ -56,6 +61,7 @@ class TokenBulkDeleteView(generic.BulkDeleteView):
# Users
#
+@register_model_view(User, 'list', path='', detail=False)
class UserListView(generic.ObjectListView):
queryset = User.objects.all()
filterset = filtersets.UserFilterSet
@@ -77,6 +83,7 @@ class UserView(generic.ObjectView):
}
+@register_model_view(User, 'add', detail=False)
@register_model_view(User, 'edit')
class UserEditView(generic.ObjectEditView):
queryset = User.objects.all()
@@ -88,6 +95,13 @@ class UserDeleteView(generic.ObjectDeleteView):
queryset = User.objects.all()
+@register_model_view(User, 'bulk_import', detail=False)
+class UserBulkImportView(generic.BulkImportView):
+ queryset = User.objects.all()
+ model_form = forms.UserImportForm
+
+
+@register_model_view(User, 'bulk_edit', path='edit', detail=False)
class UserBulkEditView(generic.BulkEditView):
queryset = User.objects.all()
filterset = filtersets.UserFilterSet
@@ -95,11 +109,7 @@ class UserBulkEditView(generic.BulkEditView):
form = forms.UserBulkEditForm
-class UserBulkImportView(generic.BulkImportView):
- queryset = User.objects.all()
- model_form = forms.UserImportForm
-
-
+@register_model_view(User, 'bulk_delete', path='delete', detail=False)
class UserBulkDeleteView(generic.BulkDeleteView):
queryset = User.objects.all()
filterset = filtersets.UserFilterSet
@@ -110,6 +120,7 @@ class UserBulkDeleteView(generic.BulkDeleteView):
# Groups
#
+@register_model_view(Group, 'list', path='', detail=False)
class GroupListView(generic.ObjectListView):
queryset = Group.objects.annotate(users_count=Count('user')).order_by('name')
filterset = filtersets.GroupFilterSet
@@ -123,6 +134,7 @@ class GroupView(generic.ObjectView):
template_name = 'users/group.html'
+@register_model_view(Group, 'add', detail=False)
@register_model_view(Group, 'edit')
class GroupEditView(generic.ObjectEditView):
queryset = Group.objects.all()
@@ -134,11 +146,13 @@ class GroupDeleteView(generic.ObjectDeleteView):
queryset = Group.objects.all()
+@register_model_view(Group, 'bulk_import', detail=False)
class GroupBulkImportView(generic.BulkImportView):
queryset = Group.objects.all()
model_form = forms.GroupImportForm
+@register_model_view(Group, 'bulk_edit', path='edit', detail=False)
class GroupBulkEditView(generic.BulkEditView):
queryset = Group.objects.all()
filterset = filtersets.GroupFilterSet
@@ -146,6 +160,7 @@ class GroupBulkEditView(generic.BulkEditView):
form = forms.GroupBulkEditForm
+@register_model_view(Group, 'bulk_delete', path='delete', detail=False)
class GroupBulkDeleteView(generic.BulkDeleteView):
queryset = Group.objects.annotate(users_count=Count('user')).order_by('name')
filterset = filtersets.GroupFilterSet
@@ -156,6 +171,7 @@ class GroupBulkDeleteView(generic.BulkDeleteView):
# ObjectPermissions
#
+@register_model_view(ObjectPermission, 'list', path='', detail=False)
class ObjectPermissionListView(generic.ObjectListView):
queryset = ObjectPermission.objects.all()
filterset = filtersets.ObjectPermissionFilterSet
@@ -169,6 +185,7 @@ class ObjectPermissionView(generic.ObjectView):
template_name = 'users/objectpermission.html'
+@register_model_view(ObjectPermission, 'add', detail=False)
@register_model_view(ObjectPermission, 'edit')
class ObjectPermissionEditView(generic.ObjectEditView):
queryset = ObjectPermission.objects.all()
@@ -180,6 +197,7 @@ class ObjectPermissionDeleteView(generic.ObjectDeleteView):
queryset = ObjectPermission.objects.all()
+@register_model_view(ObjectPermission, 'bulk_edit', path='edit', detail=False)
class ObjectPermissionBulkEditView(generic.BulkEditView):
queryset = ObjectPermission.objects.all()
filterset = filtersets.ObjectPermissionFilterSet
@@ -187,6 +205,7 @@ class ObjectPermissionBulkEditView(generic.BulkEditView):
form = forms.ObjectPermissionBulkEditForm
+@register_model_view(ObjectPermission, 'bulk_delete', path='delete', detail=False)
class ObjectPermissionBulkDeleteView(generic.BulkDeleteView):
queryset = ObjectPermission.objects.all()
filterset = filtersets.ObjectPermissionFilterSet
diff --git a/netbox/utilities/api.py b/netbox/utilities/api.py
index 11b914811..6793c0526 100644
--- a/netbox/utilities/api.py
+++ b/netbox/utilities/api.py
@@ -129,7 +129,7 @@ def get_annotations_for_serializer(serializer_class, fields_to_include=None):
for field_name, field in serializer_class._declared_fields.items():
if field_name in fields_to_include and type(field) is RelatedObjectCountField:
- related_field = model._meta.get_field(field.relation).field
+ related_field = getattr(model, field.relation).field
annotations[field_name] = count_related(related_field.model, related_field.name)
return annotations
diff --git a/netbox/utilities/conversion.py b/netbox/utilities/conversion.py
index 07e57d96e..6ce32212a 100644
--- a/netbox/utilities/conversion.py
+++ b/netbox/utilities/conversion.py
@@ -2,7 +2,8 @@ from decimal import Decimal
from django.utils.translation import gettext as _
-from dcim.choices import CableLengthUnitChoices, WeightUnitChoices
+from dcim.choices import CableLengthUnitChoices
+from netbox.choices import WeightUnitChoices
__all__ = (
'to_grams',
diff --git a/netbox/utilities/error_handlers.py b/netbox/utilities/error_handlers.py
index 5d2a46424..397098ded 100644
--- a/netbox/utilities/error_handlers.py
+++ b/netbox/utilities/error_handlers.py
@@ -49,7 +49,7 @@ def handle_rest_api_exception(request, *args, **kwargs):
"""
Handle exceptions and return a useful error message for REST API requests.
"""
- type_, error, traceback = sys.exc_info()
+ type_, error = sys.exc_info()[:2]
data = {
'error': str(error),
'exception': type_.__name__,
diff --git a/netbox/utilities/fields.py b/netbox/utilities/fields.py
index ee71223cb..1d16a1d3f 100644
--- a/netbox/utilities/fields.py
+++ b/netbox/utilities/fields.py
@@ -5,7 +5,6 @@ from django.db import models
from django.utils.safestring import mark_safe
from django.utils.translation import gettext_lazy as _
-from utilities.ordering import naturalize
from .forms.widgets import ColorSelect
from .validators import ColorValidator
@@ -40,7 +39,7 @@ class NaturalOrderingField(models.CharField):
"""
description = "Stores a representation of its target field suitable for natural ordering"
- def __init__(self, target_field, naturalize_function=naturalize, *args, **kwargs):
+ def __init__(self, target_field, naturalize_function, *args, **kwargs):
self.target_field = target_field
self.naturalize_function = naturalize_function
super().__init__(*args, **kwargs)
diff --git a/netbox/utilities/forms/fields/dynamic.py b/netbox/utilities/forms/fields/dynamic.py
index 6666c0e4d..793494b4b 100644
--- a/netbox/utilities/forms/fields/dynamic.py
+++ b/netbox/utilities/forms/fields/dynamic.py
@@ -2,7 +2,7 @@ import django_filters
from django import forms
from django.conf import settings
from django.forms import BoundField
-from django.urls import reverse
+from django.urls import reverse, reverse_lazy
from utilities.forms import widgets
from utilities.views import get_viewname
@@ -66,6 +66,10 @@ class DynamicModelChoiceMixin:
choice (DEPRECATED: pass `context={'disabled': '$fieldname'}` instead)
context: A mapping of template variables to their API data keys (optional; see below)
selector: Include an advanced object selection widget to assist the user in identifying the desired object
+ quick_add: Include a widget to quickly create a new related object for assignment. NOTE: Nested usage of
+ quick-add fields is not currently supported.
+ quick_add_params: A dictionary of initial data to include when launching the quick-add form (optional). The
+ token string "$pk" will be replaced with the primary key of the form's instance, if any.
Context keys:
value: The name of the attribute which contains the option's value (default: 'id')
@@ -90,6 +94,8 @@ class DynamicModelChoiceMixin:
disabled_indicator=None,
context=None,
selector=False,
+ quick_add=False,
+ quick_add_params=None,
**kwargs
):
self.model = queryset.model
@@ -99,6 +105,8 @@ class DynamicModelChoiceMixin:
self.disabled_indicator = disabled_indicator
self.context = context or {}
self.selector = selector
+ self.quick_add = quick_add
+ self.quick_add_params = quick_add_params or {}
super().__init__(queryset, **kwargs)
@@ -161,6 +169,22 @@ class DynamicModelChoiceMixin:
viewname = get_viewname(self.queryset.model, action='list', rest_api=True)
widget.attrs['data-url'] = reverse(viewname)
+ # Include quick add?
+ if self.quick_add:
+ app_label = self.model._meta.app_label
+ model_name = self.model._meta.model_name
+ widget.quick_add_context = {
+ 'url': reverse_lazy(f'{app_label}:{model_name}_add'),
+ 'params': {},
+ }
+ for k, v in self.quick_add_params.items():
+ if v == '$pk':
+ # Replace "$pk" token with the primary key of the form's instance (if any)
+ if getattr(form.instance, 'pk', None):
+ widget.quick_add_context['params'][k] = form.instance.pk
+ else:
+ widget.quick_add_context['params'][k] = v
+
return bound_field
diff --git a/netbox/utilities/forms/widgets/apiselect.py b/netbox/utilities/forms/widgets/apiselect.py
index 278371de6..7e9122922 100644
--- a/netbox/utilities/forms/widgets/apiselect.py
+++ b/netbox/utilities/forms/widgets/apiselect.py
@@ -22,6 +22,15 @@ class APISelect(forms.Select):
dynamic_params: Dict[str, str]
static_params: Dict[str, List[str]]
+ def get_context(self, name, value, attrs):
+ context = super().get_context(name, value, attrs)
+
+ # Add quick-add context data, if enabled for the widget
+ if hasattr(self, 'quick_add_context'):
+ context['quick_add'] = self.quick_add_context
+
+ return context
+
def __init__(self, api_url=None, full=False, *args, **kwargs):
super().__init__(*args, **kwargs)
diff --git a/netbox/utilities/socks.py b/netbox/utilities/socks.py
index bb0b6b250..6b62e8fc7 100644
--- a/netbox/utilities/socks.py
+++ b/netbox/utilities/socks.py
@@ -26,7 +26,10 @@ class ProxyHTTPConnection(HTTPConnection):
try:
from python_socks.sync import Proxy
except ModuleNotFoundError as e:
- logger.info("Configuring an HTTP proxy using SOCKS requires the python_socks library. Check that it has been installed.")
+ logger.info(
+ "Configuring an HTTP proxy using SOCKS requires the python_socks library. Check that it has been "
+ "installed."
+ )
raise e
proxy = Proxy.from_url(self._proxy_url, rdns=self.use_rdns)
diff --git a/netbox/utilities/templates/widgets/apiselect.html b/netbox/utilities/templates/widgets/apiselect.html
index d6ddf3a11..2d5c9e493 100644
--- a/netbox/utilities/templates/widgets/apiselect.html
+++ b/netbox/utilities/templates/widgets/apiselect.html
@@ -1,7 +1,8 @@
{% load i18n %}
-{% if widget.attrs.selector and not widget.attrs.disabled %}
-
- {% include 'django/forms/widgets/select.html' %}
+
+ {% include 'django/forms/widgets/select.html' %}
+ {% if widget.attrs.selector and not widget.attrs.disabled %}
+ {# Opens the object selector modal #}
-
-{% else %}
- {% include 'django/forms/widgets/select.html' %}
-{% endif %}
+ {% endif %}
+ {% if quick_add and not widget.attrs.disabled %}
+ {# Opens the quick add modal #}
+
+
+
+ {% endif %}
+
diff --git a/netbox/utilities/templatetags/buttons.py b/netbox/utilities/templatetags/buttons.py
index 675fa98eb..d38c8863f 100644
--- a/netbox/utilities/templatetags/buttons.py
+++ b/netbox/utilities/templatetags/buttons.py
@@ -158,7 +158,7 @@ def add_button(model, action='add'):
@register.inclusion_tag('buttons/import.html')
-def import_button(model, action='import'):
+def import_button(model, action='bulk_import'):
try:
url = reverse(get_viewname(model, action))
except NoReverseMatch:
diff --git a/netbox/utilities/testing/api.py b/netbox/utilities/testing/api.py
index 0471b1cfd..12e38a27f 100644
--- a/netbox/utilities/testing/api.py
+++ b/netbox/utilities/testing/api.py
@@ -443,7 +443,10 @@ class APIViewTestCases:
# Compile list of fields to include
fields_string = ''
- file_fields = (strawberry_django.fields.types.DjangoFileType, strawberry_django.fields.types.DjangoImageType)
+ file_fields = (
+ strawberry_django.fields.types.DjangoFileType,
+ strawberry_django.fields.types.DjangoImageType,
+ )
for field in type_class.__strawberry_definition__.fields:
if (
field.type in file_fields or (
diff --git a/netbox/utilities/testing/base.py b/netbox/utilities/testing/base.py
index e197a3f6a..6d17fa1ec 100644
--- a/netbox/utilities/testing/base.py
+++ b/netbox/utilities/testing/base.py
@@ -1,5 +1,6 @@
import json
+from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentType
from django.contrib.postgres.fields import ArrayField, RangeField
from django.core.exceptions import FieldDoesNotExist
@@ -120,6 +121,10 @@ class ModelTestCase(TestCase):
else:
model_dict[key] = sorted([obj.pk for obj in value])
+ # Handle GenericForeignKeys
+ elif value and type(field) is GenericForeignKey:
+ model_dict[key] = value.pk
+
elif api:
# Replace ContentType numeric IDs with .
diff --git a/netbox/utilities/testing/views.py b/netbox/utilities/testing/views.py
index 18c767bd0..c451649ff 100644
--- a/netbox/utilities/testing/views.py
+++ b/netbox/utilities/testing/views.py
@@ -594,10 +594,10 @@ class ViewTestCases:
# Test GET without permission
with disable_warnings('django.request'):
- self.assertHttpStatus(self.client.get(self._get_url('import')), 403)
+ self.assertHttpStatus(self.client.get(self._get_url('bulk_import')), 403)
# Try POST without permission
- response = self.client.post(self._get_url('import'), data)
+ response = self.client.post(self._get_url('bulk_import'), data)
with disable_warnings('django.request'):
self.assertHttpStatus(response, 403)
@@ -620,10 +620,10 @@ class ViewTestCases:
obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model))
# Try GET with model-level permission
- self.assertHttpStatus(self.client.get(self._get_url('import')), 200)
+ self.assertHttpStatus(self.client.get(self._get_url('bulk_import')), 200)
# Test POST with permission
- self.assertHttpStatus(self.client.post(self._get_url('import'), data), 302)
+ self.assertHttpStatus(self.client.post(self._get_url('bulk_import'), data), 302)
self.assertEqual(self._get_queryset().count(), initial_count + len(self.csv_data) - 1)
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
@@ -649,7 +649,7 @@ class ViewTestCases:
obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model))
# Test POST with permission
- self.assertHttpStatus(self.client.post(self._get_url('import'), data), 302)
+ self.assertHttpStatus(self.client.post(self._get_url('bulk_import'), data), 302)
self.assertEqual(initial_count, self._get_queryset().count())
reader = csv.DictReader(array, delimiter=',')
@@ -684,7 +684,7 @@ class ViewTestCases:
obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model))
# Attempt to import non-permitted objects
- self.assertHttpStatus(self.client.post(self._get_url('import'), data), 200)
+ self.assertHttpStatus(self.client.post(self._get_url('bulk_import'), data), 200)
self.assertEqual(self._get_queryset().count(), initial_count)
# Update permission constraints
@@ -692,7 +692,7 @@ class ViewTestCases:
obj_perm.save()
# Import permitted objects
- self.assertHttpStatus(self.client.post(self._get_url('import'), data), 302)
+ self.assertHttpStatus(self.client.post(self._get_url('bulk_import'), data), 302)
self.assertEqual(self._get_queryset().count(), initial_count + len(self.csv_data) - 1)
class BulkEditObjectsViewTestCase(ModelViewTestCase):
diff --git a/netbox/utilities/tests/test_counters.py b/netbox/utilities/tests/test_counters.py
index 45823065e..668965e8a 100644
--- a/netbox/utilities/tests/test_counters.py
+++ b/netbox/utilities/tests/test_counters.py
@@ -83,7 +83,7 @@ class CountersTest(TestCase):
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
def test_mptt_child_delete(self):
- device1, device2 = Device.objects.all()
+ device1 = Device.objects.first()
inventory_item1 = InventoryItem.objects.create(device=device1, name='Inventory Item 1')
InventoryItem.objects.create(device=device1, name='Inventory Item 2', parent=inventory_item1)
device1.refresh_from_db()
diff --git a/netbox/utilities/tests/test_filters.py b/netbox/utilities/tests/test_filters.py
index 53e6eb985..6956396d2 100644
--- a/netbox/utilities/tests/test_filters.py
+++ b/netbox/utilities/tests/test_filters.py
@@ -9,7 +9,7 @@ from dcim.choices import *
from dcim.fields import MACAddressField
from dcim.filtersets import DeviceFilterSet, SiteFilterSet, InterfaceFilterSet
from dcim.models import (
- Device, DeviceRole, DeviceType, Interface, Manufacturer, Platform, Rack, Region, Site
+ Device, DeviceRole, DeviceType, Interface, MACAddress, Manufacturer, Platform, Rack, Region, Site
)
from extras.filters import TagFilter
from extras.models import TaggedItem
@@ -427,22 +427,76 @@ class DynamicFilterLookupExpressionTest(TestCase):
Rack.objects.bulk_create(racks)
devices = (
- Device(name='Device 1', device_type=device_types[0], role=roles[0], platform=platforms[0], serial='ABC', asset_tag='1001', site=sites[0], rack=racks[0], position=1, face=DeviceFaceChoices.FACE_FRONT, status=DeviceStatusChoices.STATUS_ACTIVE, local_context_data={"foo": 123}),
- Device(name='Device 2', device_type=device_types[1], role=roles[1], platform=platforms[1], serial='DEF', asset_tag='1002', site=sites[1], rack=racks[1], position=2, face=DeviceFaceChoices.FACE_FRONT, status=DeviceStatusChoices.STATUS_STAGED),
- Device(name='Device 3', device_type=device_types[2], role=roles[2], platform=platforms[2], serial='GHI', asset_tag='1003', site=sites[2], rack=racks[2], position=3, face=DeviceFaceChoices.FACE_REAR, status=DeviceStatusChoices.STATUS_FAILED),
+ Device(
+ name='Device 1',
+ device_type=device_types[0],
+ role=roles[0],
+ platform=platforms[0],
+ serial='ABC',
+ asset_tag='1001',
+ site=sites[0],
+ rack=racks[0],
+ position=1,
+ face=DeviceFaceChoices.FACE_FRONT,
+ status=DeviceStatusChoices.STATUS_ACTIVE,
+ local_context_data={'foo': 123},
+ ),
+ Device(
+ name='Device 2',
+ device_type=device_types[1],
+ role=roles[1],
+ platform=platforms[1],
+ serial='DEF',
+ asset_tag='1002',
+ site=sites[1],
+ rack=racks[1],
+ position=2,
+ face=DeviceFaceChoices.FACE_FRONT,
+ status=DeviceStatusChoices.STATUS_STAGED,
+ ),
+ Device(
+ name='Device 3',
+ device_type=device_types[2],
+ role=roles[2],
+ platform=platforms[2],
+ serial='GHI',
+ asset_tag='1003',
+ site=sites[2],
+ rack=racks[2],
+ position=3,
+ face=DeviceFaceChoices.FACE_REAR,
+ status=DeviceStatusChoices.STATUS_FAILED,
+ ),
)
Device.objects.bulk_create(devices)
+ mac_addresses = (
+ MACAddress(mac_address='00-00-00-00-00-01'),
+ MACAddress(mac_address='aa-00-00-00-00-01'),
+ MACAddress(mac_address='00-00-00-00-00-02'),
+ MACAddress(mac_address='bb-00-00-00-00-02'),
+ MACAddress(mac_address='00-00-00-00-00-03'),
+ MACAddress(mac_address='cc-00-00-00-00-03'),
+ )
+ MACAddress.objects.bulk_create(mac_addresses)
+
interfaces = (
- Interface(device=devices[0], name='Interface 1', mac_address='00-00-00-00-00-01'),
- Interface(device=devices[0], name='Interface 2', mac_address='aa-00-00-00-00-01'),
- Interface(device=devices[1], name='Interface 3', mac_address='00-00-00-00-00-02'),
- Interface(device=devices[1], name='Interface 4', mac_address='bb-00-00-00-00-02'),
- Interface(device=devices[2], name='Interface 5', mac_address='00-00-00-00-00-03'),
- Interface(device=devices[2], name='Interface 6', mac_address='cc-00-00-00-00-03', rf_role=WirelessRoleChoices.ROLE_AP),
+ Interface(device=devices[0], name='Interface 1'),
+ Interface(device=devices[0], name='Interface 2'),
+ Interface(device=devices[1], name='Interface 3'),
+ Interface(device=devices[1], name='Interface 4'),
+ Interface(device=devices[2], name='Interface 5'),
+ Interface(device=devices[2], name='Interface 6', rf_role=WirelessRoleChoices.ROLE_AP),
)
Interface.objects.bulk_create(interfaces)
+ interfaces[0].mac_addresses.set([mac_addresses[0]])
+ interfaces[1].mac_addresses.set([mac_addresses[1]])
+ interfaces[2].mac_addresses.set([mac_addresses[2]])
+ interfaces[3].mac_addresses.set([mac_addresses[3]])
+ interfaces[4].mac_addresses.set([mac_addresses[4]])
+ interfaces[5].mac_addresses.set([mac_addresses[5]])
+
def test_site_name_negation(self):
params = {'name__n': ['Site 1']}
self.assertEqual(SiteFilterSet(params, Site.objects.all()).qs.count(), 2)
diff --git a/netbox/utilities/urls.py b/netbox/utilities/urls.py
index a1132d81c..77968eb87 100644
--- a/netbox/utilities/urls.py
+++ b/netbox/utilities/urls.py
@@ -9,22 +9,27 @@ __all__ = (
)
-def get_model_urls(app_label, model_name):
+def get_model_urls(app_label, model_name, detail=True):
"""
Return a list of URL paths for detail views registered to the given model.
Args:
app_label: App/plugin name
model_name: Model name
+ detail: If True (default), return only URL views for an individual object.
+ Otherwise, return only list views.
"""
paths = []
# Retrieve registered views for this model
try:
- views = registry['views'][app_label][model_name]
+ views = [
+ view for view in registry['views'][app_label][model_name]
+ if view['detail'] == detail
+ ]
except KeyError:
# No views have been registered for this model
- views = []
+ return []
for config in views:
# Import the view class or function
diff --git a/netbox/utilities/views.py b/netbox/utilities/views.py
index f7181ea92..b3334ca87 100644
--- a/netbox/utilities/views.py
+++ b/netbox/utilities/views.py
@@ -272,7 +272,7 @@ def get_viewname(model, action=None, rest_api=False):
return viewname
-def register_model_view(model, name='', path=None, kwargs=None):
+def register_model_view(model, name='', path=None, detail=True, kwargs=None):
"""
This decorator can be used to "attach" a view to any model in NetBox. This is typically used to inject
additional tabs within a model's detail view. For example, to add a custom tab to NetBox's dcim.Site model:
@@ -289,6 +289,7 @@ def register_model_view(model, name='', path=None, kwargs=None):
name: The string used to form the view's name for URL resolution (e.g. via `reverse()`). This will be appended
to the name of the base view for the model using an underscore. If blank, the model name will be used.
path: The URL path by which the view can be reached (optional). If not provided, `name` will be used.
+ detail: True if the path applied to an individual object; False if it attaches to the base (list) path.
kwargs: A dictionary of keyword arguments for the view to include when registering its URL path (optional).
"""
def _wrapper(cls):
@@ -301,7 +302,8 @@ def register_model_view(model, name='', path=None, kwargs=None):
registry['views'][app_label][model_name].append({
'name': name,
'view': cls,
- 'path': path or name,
+ 'path': path if path is not None else name,
+ 'detail': detail,
'kwargs': kwargs or {},
})
diff --git a/netbox/virtualization/api/nested_serializers.py b/netbox/virtualization/api/nested_serializers.py
deleted file mode 100644
index 59016f74d..000000000
--- a/netbox/virtualization/api/nested_serializers.py
+++ /dev/null
@@ -1,72 +0,0 @@
-import warnings
-
-from drf_spectacular.utils import extend_schema_serializer
-
-from netbox.api.fields import RelatedObjectCountField
-from netbox.api.serializers import WritableNestedSerializer
-from virtualization.models import *
-from .serializers_.nested import NestedVirtualMachineSerializer, NestedVMInterfaceSerializer
-
-__all__ = [
- 'NestedClusterGroupSerializer',
- 'NestedClusterSerializer',
- 'NestedClusterTypeSerializer',
- 'NestedVirtualDiskSerializer',
- 'NestedVMInterfaceSerializer',
- 'NestedVirtualMachineSerializer',
-]
-
-# TODO: Remove in v4.2
-warnings.warn(
- "Dedicated nested serializers will be removed in NetBox v4.2. Use Serializer(nested=True) instead.",
- DeprecationWarning
-)
-
-
-#
-# Clusters
-#
-
-@extend_schema_serializer(
- exclude_fields=('cluster_count',),
-)
-class NestedClusterTypeSerializer(WritableNestedSerializer):
- cluster_count = RelatedObjectCountField('clusters')
-
- class Meta:
- model = ClusterType
- fields = ['id', 'url', 'display_url', 'display', 'name', 'slug', 'cluster_count']
-
-
-@extend_schema_serializer(
- exclude_fields=('cluster_count',),
-)
-class NestedClusterGroupSerializer(WritableNestedSerializer):
- cluster_count = RelatedObjectCountField('clusters')
-
- class Meta:
- model = ClusterGroup
- fields = ['id', 'url', 'display_url', 'display', 'name', 'slug', 'cluster_count']
-
-
-@extend_schema_serializer(
- exclude_fields=('virtualmachine_count',),
-)
-class NestedClusterSerializer(WritableNestedSerializer):
- virtualmachine_count = RelatedObjectCountField('virtual_machines')
-
- class Meta:
- model = Cluster
- fields = ['id', 'url', 'display_url', 'display', 'name', 'virtualmachine_count']
-
-
-#
-# Virtual machines
-#
-
-class NestedVirtualDiskSerializer(WritableNestedSerializer):
- virtual_machine = NestedVirtualMachineSerializer(read_only=True)
-
- class Meta:
- model = VirtualDisk
- fields = ['id', 'url', 'display_url', 'display', 'virtual_machine', 'name', 'size']
diff --git a/netbox/virtualization/api/serializers_/clusters.py b/netbox/virtualization/api/serializers_/clusters.py
index b64b6e7ba..ff64db1cf 100644
--- a/netbox/virtualization/api/serializers_/clusters.py
+++ b/netbox/virtualization/api/serializers_/clusters.py
@@ -1,9 +1,13 @@
-from dcim.api.serializers_.sites import SiteSerializer
-from netbox.api.fields import ChoiceField, RelatedObjectCountField
+from dcim.constants import LOCATION_SCOPE_TYPES
+from django.contrib.contenttypes.models import ContentType
+from drf_spectacular.utils import extend_schema_field
+from rest_framework import serializers
+from netbox.api.fields import ChoiceField, ContentTypeField, RelatedObjectCountField
from netbox.api.serializers import NetBoxModelSerializer
from tenancy.api.serializers_.tenants import TenantSerializer
from virtualization.choices import *
from virtualization.models import Cluster, ClusterGroup, ClusterType
+from utilities.api import get_serializer_for_model
__all__ = (
'ClusterGroupSerializer',
@@ -45,7 +49,24 @@ class ClusterSerializer(NetBoxModelSerializer):
group = ClusterGroupSerializer(nested=True, required=False, allow_null=True, default=None)
status = ChoiceField(choices=ClusterStatusChoices, required=False)
tenant = TenantSerializer(nested=True, required=False, allow_null=True)
- site = SiteSerializer(nested=True, required=False, allow_null=True, default=None)
+ scope_type = ContentTypeField(
+ queryset=ContentType.objects.filter(
+ model__in=LOCATION_SCOPE_TYPES
+ ),
+ allow_null=True,
+ required=False,
+ default=None
+ )
+ scope_id = serializers.IntegerField(allow_null=True, required=False, default=None)
+ scope = serializers.SerializerMethodField(read_only=True)
+ allocated_vcpus = serializers.DecimalField(
+ read_only=True,
+ max_digits=8,
+ decimal_places=2,
+
+ )
+ allocated_memory = serializers.IntegerField(read_only=True)
+ allocated_disk = serializers.IntegerField(read_only=True)
# Related object counts
device_count = RelatedObjectCountField('devices')
@@ -54,8 +75,16 @@ class ClusterSerializer(NetBoxModelSerializer):
class Meta:
model = Cluster
fields = [
- 'id', 'url', 'display_url', 'display', 'name', 'type', 'group', 'status', 'tenant', 'site',
- 'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', 'device_count',
- 'virtualmachine_count',
+ 'id', 'url', 'display_url', 'display', 'name', 'type', 'group', 'status', 'tenant', 'scope_type',
+ 'scope_id', 'scope', 'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated',
+ 'device_count', 'virtualmachine_count', 'allocated_vcpus', 'allocated_memory', 'allocated_disk'
]
brief_fields = ('id', 'url', 'display', 'name', 'description', 'virtualmachine_count')
+
+ @extend_schema_field(serializers.JSONField(allow_null=True))
+ def get_scope(self, obj):
+ if obj.scope_id is None:
+ return None
+ serializer = get_serializer_for_model(obj.scope)
+ context = {'request': self.context['request']}
+ return serializer(obj.scope, nested=True, context=context).data
diff --git a/netbox/virtualization/api/serializers_/virtualmachines.py b/netbox/virtualization/api/serializers_/virtualmachines.py
index 1b224c16a..05fa2e427 100644
--- a/netbox/virtualization/api/serializers_/virtualmachines.py
+++ b/netbox/virtualization/api/serializers_/virtualmachines.py
@@ -2,13 +2,14 @@ from drf_spectacular.utils import extend_schema_field
from rest_framework import serializers
from dcim.api.serializers_.devices import DeviceSerializer
+from dcim.api.serializers_.device_components import MACAddressSerializer
from dcim.api.serializers_.platforms import PlatformSerializer
from dcim.api.serializers_.roles import DeviceRoleSerializer
from dcim.api.serializers_.sites import SiteSerializer
from dcim.choices import InterfaceModeChoices
from extras.api.serializers_.configtemplates import ConfigTemplateSerializer
from ipam.api.serializers_.ip import IPAddressSerializer
-from ipam.api.serializers_.vlans import VLANSerializer
+from ipam.api.serializers_.vlans import VLANSerializer, VLANTranslationPolicySerializer
from ipam.api.serializers_.vrfs import VRFSerializer
from ipam.models import VLAN
from netbox.api.fields import ChoiceField, SerializedPKRelatedField
@@ -48,8 +49,8 @@ class VirtualMachineSerializer(NetBoxModelSerializer):
class Meta:
model = VirtualMachine
fields = [
- 'id', 'url', 'display_url', 'display', 'name', 'status', 'site', 'cluster', 'device', 'serial', 'role', 'tenant',
- 'platform', 'primary_ip', 'primary_ip4', 'primary_ip6', 'vcpus', 'memory', 'disk', 'description',
+ 'id', 'url', 'display_url', 'display', 'name', 'status', 'site', 'cluster', 'device', 'serial', 'role',
+ 'tenant', 'platform', 'primary_ip', 'primary_ip4', 'primary_ip6', 'vcpus', 'memory', 'disk', 'description',
'comments', 'config_template', 'local_context_data', 'tags', 'custom_fields', 'created', 'last_updated',
'interface_count', 'virtual_disk_count',
]
@@ -61,8 +62,8 @@ class VirtualMachineWithConfigContextSerializer(VirtualMachineSerializer):
class Meta(VirtualMachineSerializer.Meta):
fields = [
- 'id', 'url', 'display_url', 'display', 'name', 'status', 'site', 'cluster', 'device', 'serial', 'role', 'tenant',
- 'platform', 'primary_ip', 'primary_ip4', 'primary_ip6', 'vcpus', 'memory', 'disk', 'description',
+ 'id', 'url', 'display_url', 'display', 'name', 'status', 'site', 'cluster', 'device', 'serial', 'role',
+ 'tenant', 'platform', 'primary_ip', 'primary_ip4', 'primary_ip6', 'vcpus', 'memory', 'disk', 'description',
'comments', 'config_template', 'local_context_data', 'tags', 'custom_fields', 'config_context', 'created',
'last_updated', 'interface_count', 'virtual_disk_count',
]
@@ -89,22 +90,24 @@ class VMInterfaceSerializer(NetBoxModelSerializer):
required=False,
many=True
)
+ qinq_svlan = VLANSerializer(nested=True, required=False, allow_null=True)
+ vlan_translation_policy = VLANTranslationPolicySerializer(nested=True, required=False, allow_null=True)
vrf = VRFSerializer(nested=True, required=False, allow_null=True)
l2vpn_termination = L2VPNTerminationSerializer(nested=True, read_only=True, allow_null=True)
count_ipaddresses = serializers.IntegerField(read_only=True)
count_fhrp_groups = serializers.IntegerField(read_only=True)
- mac_address = serializers.CharField(
- required=False,
- default=None,
- allow_null=True
- )
+ # Maintains backward compatibility with NetBox Annotated[Union[
+ Annotated["LocationType", strawberry.lazy('dcim.graphql.types')],
+ Annotated["RegionType", strawberry.lazy('dcim.graphql.types')],
+ Annotated["SiteGroupType", strawberry.lazy('dcim.graphql.types')],
+ Annotated["SiteType", strawberry.lazy('dcim.graphql.types')],
+ ], strawberry.union("ClusterScopeType")] | None:
+ return self.scope
+
@strawberry_django.type(
models.ClusterGroup,
@@ -70,7 +76,6 @@ class ClusterTypeType(OrganizationalObjectType):
filters=VirtualMachineFilter
)
class VirtualMachineType(ConfigContextMixin, ContactsMixin, NetBoxObjectType):
- _name: str
interface_count: BigInt
virtual_disk_count: BigInt
interface_count: BigInt
@@ -95,15 +100,20 @@ class VirtualMachineType(ConfigContextMixin, ContactsMixin, NetBoxObjectType):
filters=VMInterfaceFilter
)
class VMInterfaceType(IPAddressesMixin, ComponentType):
+ _name: str
mac_address: str | None
parent: Annotated["VMInterfaceType", strawberry.lazy('virtualization.graphql.types')] | None
bridge: Annotated["VMInterfaceType", strawberry.lazy('virtualization.graphql.types')] | None
untagged_vlan: Annotated["VLANType", strawberry.lazy('ipam.graphql.types')] | None
vrf: Annotated["VRFType", strawberry.lazy('ipam.graphql.types')] | None
+ primary_mac_address: Annotated["MACAddressType", strawberry.lazy('dcim.graphql.types')] | None
+ qinq_svlan: Annotated["VLANType", strawberry.lazy('ipam.graphql.types')] | None
+ vlan_translation_policy: Annotated["VLANTranslationPolicyType", strawberry.lazy('ipam.graphql.types')] | None
tagged_vlans: List[Annotated["VLANType", strawberry.lazy('ipam.graphql.types')]]
bridge_interfaces: List[Annotated["VMInterfaceType", strawberry.lazy('virtualization.graphql.types')]]
child_interfaces: List[Annotated["VMInterfaceType", strawberry.lazy('virtualization.graphql.types')]]
+ mac_addresses: List[Annotated["MACAddressType", strawberry.lazy('dcim.graphql.types')]]
@strawberry_django.type(
diff --git a/netbox/virtualization/migrations/0001_squashed_0022.py b/netbox/virtualization/migrations/0001_squashed_0022.py
index 2a7894737..c7aa35ec7 100644
--- a/netbox/virtualization/migrations/0001_squashed_0022.py
+++ b/netbox/virtualization/migrations/0001_squashed_0022.py
@@ -10,7 +10,6 @@ import utilities.query_functions
class Migration(migrations.Migration):
-
initial = True
dependencies = [
@@ -100,17 +99,79 @@ class Migration(migrations.Migration):
('local_context_data', models.JSONField(blank=True, null=True)),
('name', models.CharField(max_length=64)),
('status', models.CharField(default='active', max_length=50)),
- ('vcpus', models.DecimalField(blank=True, decimal_places=2, max_digits=6, null=True, validators=[django.core.validators.MinValueValidator(0.01)])),
+ (
+ 'vcpus',
+ models.DecimalField(
+ blank=True,
+ decimal_places=2,
+ max_digits=6,
+ null=True,
+ validators=[django.core.validators.MinValueValidator(0.01)],
+ ),
+ ),
('memory', models.PositiveIntegerField(blank=True, null=True)),
('disk', models.PositiveIntegerField(blank=True, null=True)),
('comments', models.TextField(blank=True)),
- ('cluster', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='virtual_machines', to='virtualization.cluster')),
- ('platform', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='virtual_machines', to='dcim.platform')),
- ('primary_ip4', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='ipam.ipaddress')),
- ('primary_ip6', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='ipam.ipaddress')),
- ('role', models.ForeignKey(blank=True, limit_choices_to={'vm_role': True}, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='virtual_machines', to='dcim.devicerole')),
+ (
+ 'cluster',
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.PROTECT,
+ related_name='virtual_machines',
+ to='virtualization.cluster',
+ ),
+ ),
+ (
+ 'platform',
+ models.ForeignKey(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.SET_NULL,
+ related_name='virtual_machines',
+ to='dcim.platform',
+ ),
+ ),
+ (
+ 'primary_ip4',
+ models.OneToOneField(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.SET_NULL,
+ related_name='+',
+ to='ipam.ipaddress',
+ ),
+ ),
+ (
+ 'primary_ip6',
+ models.OneToOneField(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.SET_NULL,
+ related_name='+',
+ to='ipam.ipaddress',
+ ),
+ ),
+ (
+ 'role',
+ models.ForeignKey(
+ blank=True,
+ limit_choices_to={'vm_role': True},
+ null=True,
+ on_delete=django.db.models.deletion.PROTECT,
+ related_name='virtual_machines',
+ to='dcim.devicerole',
+ ),
+ ),
('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')),
- ('tenant', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='virtual_machines', to='tenancy.tenant')),
+ (
+ 'tenant',
+ models.ForeignKey(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.PROTECT,
+ related_name='virtual_machines',
+ to='tenancy.tenant',
+ ),
+ ),
],
options={
'ordering': ('name', 'pk'),
@@ -120,12 +181,24 @@ class Migration(migrations.Migration):
migrations.AddField(
model_name='cluster',
name='group',
- field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='clusters', to='virtualization.clustergroup'),
+ field=models.ForeignKey(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.PROTECT,
+ related_name='clusters',
+ to='virtualization.clustergroup',
+ ),
),
migrations.AddField(
model_name='cluster',
name='site',
- field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='clusters', to='dcim.site'),
+ field=models.ForeignKey(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.PROTECT,
+ related_name='clusters',
+ to='dcim.site',
+ ),
),
migrations.AddField(
model_name='cluster',
@@ -135,12 +208,20 @@ class Migration(migrations.Migration):
migrations.AddField(
model_name='cluster',
name='tenant',
- field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='clusters', to='tenancy.tenant'),
+ field=models.ForeignKey(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.PROTECT,
+ related_name='clusters',
+ to='tenancy.tenant',
+ ),
),
migrations.AddField(
model_name='cluster',
name='type',
- field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='clusters', to='virtualization.clustertype'),
+ field=models.ForeignKey(
+ on_delete=django.db.models.deletion.PROTECT, related_name='clusters', to='virtualization.clustertype'
+ ),
),
migrations.CreateModel(
name='VMInterface',
@@ -151,16 +232,59 @@ class Migration(migrations.Migration):
('id', models.BigAutoField(primary_key=True, serialize=False)),
('enabled', models.BooleanField(default=True)),
('mac_address', dcim.fields.MACAddressField(blank=True, null=True)),
- ('mtu', models.PositiveIntegerField(blank=True, null=True, validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(65536)])),
+ (
+ 'mtu',
+ models.PositiveIntegerField(
+ blank=True,
+ null=True,
+ validators=[
+ django.core.validators.MinValueValidator(1),
+ django.core.validators.MaxValueValidator(65536),
+ ],
+ ),
+ ),
('mode', models.CharField(blank=True, max_length=50)),
('name', models.CharField(max_length=64)),
- ('_name', utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize_interface)),
+ (
+ '_name',
+ utilities.fields.NaturalOrderingField(
+ 'name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize_interface
+ ),
+ ),
('description', models.CharField(blank=True, max_length=200)),
- ('parent', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='child_interfaces', to='virtualization.vminterface')),
- ('tagged_vlans', models.ManyToManyField(blank=True, related_name='vminterfaces_as_tagged', to='ipam.VLAN')),
+ (
+ 'parent',
+ models.ForeignKey(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.SET_NULL,
+ related_name='child_interfaces',
+ to='virtualization.vminterface',
+ ),
+ ),
+ (
+ 'tagged_vlans',
+ models.ManyToManyField(blank=True, related_name='vminterfaces_as_tagged', to='ipam.VLAN'),
+ ),
('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')),
- ('untagged_vlan', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='vminterfaces_as_untagged', to='ipam.vlan')),
- ('virtual_machine', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='interfaces', to='virtualization.virtualmachine')),
+ (
+ 'untagged_vlan',
+ models.ForeignKey(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.SET_NULL,
+ related_name='vminterfaces_as_untagged',
+ to='ipam.vlan',
+ ),
+ ),
+ (
+ 'virtual_machine',
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name='interfaces',
+ to='virtualization.virtualmachine',
+ ),
+ ),
],
options={
'verbose_name': 'interface',
diff --git a/netbox/virtualization/migrations/0023_squashed_0036.py b/netbox/virtualization/migrations/0023_squashed_0036.py
index bbfb62b39..0665aaab6 100644
--- a/netbox/virtualization/migrations/0023_squashed_0036.py
+++ b/netbox/virtualization/migrations/0023_squashed_0036.py
@@ -7,7 +7,6 @@ import utilities.ordering
class Migration(migrations.Migration):
-
replaces = [
('virtualization', '0023_virtualmachine_natural_ordering'),
('virtualization', '0024_cluster_relax_uniqueness'),
@@ -22,7 +21,7 @@ class Migration(migrations.Migration):
('virtualization', '0033_unique_constraints'),
('virtualization', '0034_standardize_description_comments'),
('virtualization', '0035_virtualmachine_interface_count'),
- ('virtualization', '0036_virtualmachine_config_template')
+ ('virtualization', '0036_virtualmachine_config_template'),
]
dependencies = [
@@ -40,7 +39,9 @@ class Migration(migrations.Migration):
migrations.AddField(
model_name='virtualmachine',
name='_name',
- field=utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize),
+ field=utilities.fields.NaturalOrderingField(
+ 'name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize
+ ),
),
migrations.AlterField(
model_name='cluster',
@@ -64,7 +65,13 @@ class Migration(migrations.Migration):
migrations.AddField(
model_name='vminterface',
name='bridge',
- field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='bridge_interfaces', to='virtualization.vminterface'),
+ field=models.ForeignKey(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.SET_NULL,
+ related_name='bridge_interfaces',
+ to='virtualization.vminterface',
+ ),
),
migrations.AlterField(
model_name='cluster',
@@ -94,7 +101,13 @@ class Migration(migrations.Migration):
migrations.AddField(
model_name='vminterface',
name='vrf',
- field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='vminterfaces', to='ipam.vrf'),
+ field=models.ForeignKey(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.SET_NULL,
+ related_name='vminterfaces',
+ to='ipam.vrf',
+ ),
),
migrations.AlterField(
model_name='cluster',
@@ -129,17 +142,35 @@ class Migration(migrations.Migration):
migrations.AddField(
model_name='virtualmachine',
name='site',
- field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='virtual_machines', to='dcim.site'),
+ field=models.ForeignKey(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.PROTECT,
+ related_name='virtual_machines',
+ to='dcim.site',
+ ),
),
migrations.AddField(
model_name='virtualmachine',
name='device',
- field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='virtual_machines', to='dcim.device'),
+ field=models.ForeignKey(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.PROTECT,
+ related_name='virtual_machines',
+ to='dcim.device',
+ ),
),
migrations.AlterField(
model_name='virtualmachine',
name='cluster',
- field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='virtual_machines', to='virtualization.cluster'),
+ field=models.ForeignKey(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.PROTECT,
+ related_name='virtual_machines',
+ to='virtualization.cluster',
+ ),
),
migrations.AlterUniqueTogether(
name='cluster',
@@ -155,7 +186,9 @@ class Migration(migrations.Migration):
),
migrations.AddConstraint(
model_name='cluster',
- constraint=models.UniqueConstraint(fields=('group', 'name'), name='virtualization_cluster_unique_group_name'),
+ constraint=models.UniqueConstraint(
+ fields=('group', 'name'), name='virtualization_cluster_unique_group_name'
+ ),
),
migrations.AddConstraint(
model_name='cluster',
@@ -163,15 +196,28 @@ class Migration(migrations.Migration):
),
migrations.AddConstraint(
model_name='virtualmachine',
- constraint=models.UniqueConstraint(django.db.models.functions.text.Lower('name'), models.F('cluster'), models.F('tenant'), name='virtualization_virtualmachine_unique_name_cluster_tenant'),
+ constraint=models.UniqueConstraint(
+ django.db.models.functions.text.Lower('name'),
+ models.F('cluster'),
+ models.F('tenant'),
+ name='virtualization_virtualmachine_unique_name_cluster_tenant',
+ ),
),
migrations.AddConstraint(
model_name='virtualmachine',
- constraint=models.UniqueConstraint(django.db.models.functions.text.Lower('name'), models.F('cluster'), condition=models.Q(('tenant__isnull', True)), name='virtualization_virtualmachine_unique_name_cluster', violation_error_message='Virtual machine name must be unique per cluster.'),
+ constraint=models.UniqueConstraint(
+ django.db.models.functions.text.Lower('name'),
+ models.F('cluster'),
+ condition=models.Q(('tenant__isnull', True)),
+ name='virtualization_virtualmachine_unique_name_cluster',
+ violation_error_message='Virtual machine name must be unique per cluster.',
+ ),
),
migrations.AddConstraint(
model_name='vminterface',
- constraint=models.UniqueConstraint(fields=('virtual_machine', 'name'), name='virtualization_vminterface_unique_virtual_machine_name'),
+ constraint=models.UniqueConstraint(
+ fields=('virtual_machine', 'name'), name='virtualization_vminterface_unique_virtual_machine_name'
+ ),
),
migrations.AddField(
model_name='cluster',
@@ -186,11 +232,19 @@ class Migration(migrations.Migration):
migrations.AddField(
model_name='virtualmachine',
name='interface_count',
- field=utilities.fields.CounterCacheField(default=0, editable=False, to_field='virtual_machine', to_model='virtualization.VMInterface'),
+ field=utilities.fields.CounterCacheField(
+ default=0, editable=False, to_field='virtual_machine', to_model='virtualization.VMInterface'
+ ),
),
migrations.AddField(
model_name='virtualmachine',
name='config_template',
- field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='%(class)ss', to='extras.configtemplate'),
+ field=models.ForeignKey(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.PROTECT,
+ related_name='%(class)ss',
+ to='extras.configtemplate',
+ ),
),
]
diff --git a/netbox/virtualization/migrations/0037_protect_child_interfaces.py b/netbox/virtualization/migrations/0037_protect_child_interfaces.py
index ab6cf0cb3..a9d2075c1 100644
--- a/netbox/virtualization/migrations/0037_protect_child_interfaces.py
+++ b/netbox/virtualization/migrations/0037_protect_child_interfaces.py
@@ -5,7 +5,6 @@ import django.db.models.deletion
class Migration(migrations.Migration):
-
dependencies = [
('virtualization', '0036_virtualmachine_config_template'),
]
@@ -14,6 +13,12 @@ class Migration(migrations.Migration):
migrations.AlterField(
model_name='vminterface',
name='parent',
- field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.RESTRICT, related_name='child_interfaces', to='virtualization.vminterface'),
+ field=models.ForeignKey(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.RESTRICT,
+ related_name='child_interfaces',
+ to='virtualization.vminterface',
+ ),
),
]
diff --git a/netbox/virtualization/migrations/0038_virtualdisk.py b/netbox/virtualization/migrations/0038_virtualdisk.py
index 59d45c975..2f7824121 100644
--- a/netbox/virtualization/migrations/0038_virtualdisk.py
+++ b/netbox/virtualization/migrations/0038_virtualdisk.py
@@ -9,7 +9,6 @@ import utilities.tracking
class Migration(migrations.Migration):
-
dependencies = [
('extras', '0099_cachedvalue_ordering'),
('virtualization', '0037_protect_child_interfaces'),
@@ -19,7 +18,9 @@ class Migration(migrations.Migration):
migrations.AddField(
model_name='virtualmachine',
name='virtual_disk_count',
- field=utilities.fields.CounterCacheField(default=0, editable=False, to_field='virtual_machine', to_model='virtualization.VirtualDisk'),
+ field=utilities.fields.CounterCacheField(
+ default=0, editable=False, to_field='virtual_machine', to_model='virtualization.VirtualDisk'
+ ),
),
migrations.CreateModel(
name='VirtualDisk',
@@ -27,13 +28,28 @@ class Migration(migrations.Migration):
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
('created', models.DateTimeField(auto_now_add=True, null=True)),
('last_updated', models.DateTimeField(auto_now=True, null=True)),
- ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=utilities.json.CustomFieldJSONEncoder)),
+ (
+ 'custom_field_data',
+ models.JSONField(blank=True, default=dict, encoder=utilities.json.CustomFieldJSONEncoder),
+ ),
('name', models.CharField(max_length=64)),
- ('_name', utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize_interface)),
+ (
+ '_name',
+ utilities.fields.NaturalOrderingField(
+ 'name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize_interface
+ ),
+ ),
('description', models.CharField(blank=True, max_length=200)),
('size', models.PositiveIntegerField()),
('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')),
- ('virtual_machine', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='virtualization.virtualmachine')),
+ (
+ 'virtual_machine',
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name='%(class)ss',
+ to='virtualization.virtualmachine',
+ ),
+ ),
],
options={
'verbose_name': 'virtual disk',
@@ -45,6 +61,8 @@ class Migration(migrations.Migration):
),
migrations.AddConstraint(
model_name='virtualdisk',
- constraint=models.UniqueConstraint(fields=('virtual_machine', 'name'), name='virtualization_virtualdisk_unique_virtual_machine_name'),
+ constraint=models.UniqueConstraint(
+ fields=('virtual_machine', 'name'), name='virtualization_virtualdisk_unique_virtual_machine_name'
+ ),
),
]
diff --git a/netbox/virtualization/migrations/0039_virtualmachine_serial_number.py b/netbox/virtualization/migrations/0039_virtualmachine_serial_number.py
index 15b58fa22..758c21edc 100644
--- a/netbox/virtualization/migrations/0039_virtualmachine_serial_number.py
+++ b/netbox/virtualization/migrations/0039_virtualmachine_serial_number.py
@@ -2,7 +2,6 @@ from django.db import migrations, models
class Migration(migrations.Migration):
-
dependencies = [
('virtualization', '0038_virtualdisk'),
]
diff --git a/netbox/virtualization/migrations/0040_convert_disk_size.py b/netbox/virtualization/migrations/0040_convert_disk_size.py
index 6471a0908..4b0aec7bd 100644
--- a/netbox/virtualization/migrations/0040_convert_disk_size.py
+++ b/netbox/virtualization/migrations/0040_convert_disk_size.py
@@ -18,14 +18,10 @@ def convert_disk_size(apps, schema_editor):
class Migration(migrations.Migration):
-
dependencies = [
('virtualization', '0039_virtualmachine_serial_number'),
]
operations = [
- migrations.RunPython(
- code=convert_disk_size,
- reverse_code=migrations.RunPython.noop
- ),
+ migrations.RunPython(code=convert_disk_size, reverse_code=migrations.RunPython.noop),
]
diff --git a/netbox/virtualization/migrations/0041_charfield_null_choices.py b/netbox/virtualization/migrations/0041_charfield_null_choices.py
new file mode 100644
index 000000000..22eb9955a
--- /dev/null
+++ b/netbox/virtualization/migrations/0041_charfield_null_choices.py
@@ -0,0 +1,25 @@
+from django.db import migrations, models
+
+
+def set_null_values(apps, schema_editor):
+ """
+ Replace empty strings with null values.
+ """
+ VMInterface = apps.get_model('virtualization', 'VMInterface')
+
+ VMInterface.objects.filter(mode='').update(mode=None)
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ('virtualization', '0040_convert_disk_size'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='vminterface',
+ name='mode',
+ field=models.CharField(blank=True, max_length=50, null=True),
+ ),
+ migrations.RunPython(code=set_null_values, reverse_code=migrations.RunPython.noop),
+ ]
diff --git a/netbox/virtualization/migrations/0042_vminterface_vlan_translation_policy.py b/netbox/virtualization/migrations/0042_vminterface_vlan_translation_policy.py
new file mode 100644
index 000000000..ad93a751f
--- /dev/null
+++ b/netbox/virtualization/migrations/0042_vminterface_vlan_translation_policy.py
@@ -0,0 +1,19 @@
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ('ipam', '0074_vlantranslationpolicy_vlantranslationrule'),
+ ('virtualization', '0041_charfield_null_choices'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='vminterface',
+ name='vlan_translation_policy',
+ field=models.ForeignKey(
+ blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='ipam.vlantranslationpolicy'
+ ),
+ ),
+ ]
diff --git a/netbox/virtualization/migrations/0043_qinq_svlan.py b/netbox/virtualization/migrations/0043_qinq_svlan.py
new file mode 100644
index 000000000..b407facce
--- /dev/null
+++ b/netbox/virtualization/migrations/0043_qinq_svlan.py
@@ -0,0 +1,39 @@
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ('ipam', '0075_vlan_qinq'),
+ ('virtualization', '0042_vminterface_vlan_translation_policy'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='vminterface',
+ name='qinq_svlan',
+ field=models.ForeignKey(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.SET_NULL,
+ related_name='%(class)ss_svlan',
+ to='ipam.vlan',
+ ),
+ ),
+ migrations.AlterField(
+ model_name='vminterface',
+ name='tagged_vlans',
+ field=models.ManyToManyField(blank=True, related_name='%(class)ss_as_tagged', to='ipam.vlan'),
+ ),
+ migrations.AlterField(
+ model_name='vminterface',
+ name='untagged_vlan',
+ field=models.ForeignKey(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.SET_NULL,
+ related_name='%(class)ss_as_untagged',
+ to='ipam.vlan',
+ ),
+ ),
+ ]
diff --git a/netbox/virtualization/migrations/0044_cluster_scope.py b/netbox/virtualization/migrations/0044_cluster_scope.py
new file mode 100644
index 000000000..521db1877
--- /dev/null
+++ b/netbox/virtualization/migrations/0044_cluster_scope.py
@@ -0,0 +1,44 @@
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+def copy_site_assignments(apps, schema_editor):
+ """
+ Copy site ForeignKey values to the scope GFK.
+ """
+ ContentType = apps.get_model('contenttypes', 'ContentType')
+ Cluster = apps.get_model('virtualization', 'Cluster')
+ Site = apps.get_model('dcim', 'Site')
+
+ Cluster.objects.filter(site__isnull=False).update(
+ scope_type=ContentType.objects.get_for_model(Site), scope_id=models.F('site_id')
+ )
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ('contenttypes', '0002_remove_content_type_name'),
+ ('virtualization', '0043_qinq_svlan'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='cluster',
+ name='scope_id',
+ field=models.PositiveBigIntegerField(blank=True, null=True),
+ ),
+ migrations.AddField(
+ model_name='cluster',
+ name='scope_type',
+ field=models.ForeignKey(
+ blank=True,
+ limit_choices_to=models.Q(('model__in', ('region', 'sitegroup', 'site', 'location'))),
+ null=True,
+ on_delete=django.db.models.deletion.PROTECT,
+ related_name='+',
+ to='contenttypes.contenttype',
+ ),
+ ),
+ # Copy over existing site assignments
+ migrations.RunPython(code=copy_site_assignments, reverse_code=migrations.RunPython.noop),
+ ]
diff --git a/netbox/virtualization/migrations/0045_clusters_cached_relations.py b/netbox/virtualization/migrations/0045_clusters_cached_relations.py
new file mode 100644
index 000000000..6d0c8ff33
--- /dev/null
+++ b/netbox/virtualization/migrations/0045_clusters_cached_relations.py
@@ -0,0 +1,88 @@
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+def populate_denormalized_fields(apps, schema_editor):
+ """
+ Copy the denormalized fields for _region, _site_group and _site from existing site field.
+ """
+ Cluster = apps.get_model('virtualization', 'Cluster')
+
+ clusters = Cluster.objects.filter(site__isnull=False).prefetch_related('site')
+ for cluster in clusters:
+ cluster._region_id = cluster.site.region_id
+ cluster._site_group_id = cluster.site.group_id
+ cluster._site_id = cluster.site_id
+ # Note: Location cannot be set prior to migration
+
+ Cluster.objects.bulk_update(clusters, ['_region', '_site_group', '_site'])
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ('virtualization', '0044_cluster_scope'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='cluster',
+ name='_location',
+ field=models.ForeignKey(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name='_%(class)ss',
+ to='dcim.location',
+ ),
+ ),
+ migrations.AddField(
+ model_name='cluster',
+ name='_region',
+ field=models.ForeignKey(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name='_%(class)ss',
+ to='dcim.region',
+ ),
+ ),
+ migrations.AddField(
+ model_name='cluster',
+ name='_site',
+ field=models.ForeignKey(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name='_%(class)ss',
+ to='dcim.site',
+ ),
+ ),
+ migrations.AddField(
+ model_name='cluster',
+ name='_site_group',
+ field=models.ForeignKey(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name='_%(class)ss',
+ to='dcim.sitegroup',
+ ),
+ ),
+ # Populate denormalized FK values
+ migrations.RunPython(code=populate_denormalized_fields, reverse_code=migrations.RunPython.noop),
+ migrations.RemoveConstraint(
+ model_name='cluster',
+ name='virtualization_cluster_unique_site_name',
+ ),
+ # Delete the site ForeignKey
+ migrations.RemoveField(
+ model_name='cluster',
+ name='site',
+ ),
+ migrations.AddConstraint(
+ model_name='cluster',
+ constraint=models.UniqueConstraint(
+ fields=('_site', 'name'), name='virtualization_cluster_unique__site_name'
+ ),
+ ),
+ ]
diff --git a/netbox/virtualization/migrations/0046_alter_cluster__location_alter_cluster__region_and_more.py b/netbox/virtualization/migrations/0046_alter_cluster__location_alter_cluster__region_and_more.py
new file mode 100644
index 000000000..75c806382
--- /dev/null
+++ b/netbox/virtualization/migrations/0046_alter_cluster__location_alter_cluster__region_and_more.py
@@ -0,0 +1,40 @@
+# Generated by Django 5.0.9 on 2024-11-14 19:04
+
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ('dcim', '0196_qinq_svlan'),
+ ('virtualization', '0045_clusters_cached_relations'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='cluster',
+ name='_location',
+ field=models.ForeignKey(
+ blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='dcim.location'
+ ),
+ ),
+ migrations.AlterField(
+ model_name='cluster',
+ name='_region',
+ field=models.ForeignKey(
+ blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='dcim.region'
+ ),
+ ),
+ migrations.AlterField(
+ model_name='cluster',
+ name='_site',
+ field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='dcim.site'),
+ ),
+ migrations.AlterField(
+ model_name='cluster',
+ name='_site_group',
+ field=models.ForeignKey(
+ blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='dcim.sitegroup'
+ ),
+ ),
+ ]
diff --git a/netbox/virtualization/migrations/0047_natural_ordering.py b/netbox/virtualization/migrations/0047_natural_ordering.py
new file mode 100644
index 000000000..4ce5b8370
--- /dev/null
+++ b/netbox/virtualization/migrations/0047_natural_ordering.py
@@ -0,0 +1,42 @@
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ('virtualization', '0046_alter_cluster__location_alter_cluster__region_and_more'),
+ ('dcim', '0197_natural_sort_collation'),
+ ]
+
+ operations = [
+ migrations.AlterModelOptions(
+ name='virtualmachine',
+ options={'ordering': ('name', 'pk')},
+ ),
+ migrations.AlterModelOptions(
+ name='virtualdisk',
+ options={'ordering': ('virtual_machine', 'name')},
+ ),
+ migrations.RemoveField(
+ model_name='virtualmachine',
+ name='_name',
+ ),
+ migrations.AlterField(
+ model_name='virtualdisk',
+ name='name',
+ field=models.CharField(db_collation='natural_sort', max_length=64),
+ ),
+ migrations.AlterField(
+ model_name='virtualmachine',
+ name='name',
+ field=models.CharField(db_collation='natural_sort', max_length=64),
+ ),
+ migrations.AlterField(
+ model_name='cluster',
+ name='name',
+ field=models.CharField(db_collation='natural_sort', max_length=100),
+ ),
+ migrations.RemoveField(
+ model_name='virtualdisk',
+ name='_name',
+ ),
+ ]
diff --git a/netbox/virtualization/migrations/0048_populate_mac_addresses.py b/netbox/virtualization/migrations/0048_populate_mac_addresses.py
new file mode 100644
index 000000000..a4be1e2be
--- /dev/null
+++ b/netbox/virtualization/migrations/0048_populate_mac_addresses.py
@@ -0,0 +1,47 @@
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+def populate_mac_addresses(apps, schema_editor):
+ ContentType = apps.get_model('contenttypes', 'ContentType')
+ VMInterface = apps.get_model('virtualization', 'VMInterface')
+ MACAddress = apps.get_model('dcim', 'MACAddress')
+ vminterface_ct = ContentType.objects.get_for_model(VMInterface)
+
+ mac_addresses = [
+ MACAddress(
+ mac_address=vminterface.mac_address, assigned_object_type=vminterface_ct, assigned_object_id=vminterface.pk
+ )
+ for vminterface in VMInterface.objects.filter(mac_address__isnull=False)
+ ]
+ MACAddress.objects.bulk_create(mac_addresses, batch_size=100)
+
+ # TODO: Optimize interface updates
+ for mac_address in mac_addresses:
+ VMInterface.objects.filter(pk=mac_address.assigned_object_id).update(primary_mac_address=mac_address)
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ('dcim', '0199_macaddress'),
+ ('virtualization', '0047_natural_ordering'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='vminterface',
+ name='primary_mac_address',
+ field=models.OneToOneField(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.SET_NULL,
+ related_name='+',
+ to='dcim.macaddress',
+ ),
+ ),
+ migrations.RunPython(code=populate_mac_addresses, reverse_code=migrations.RunPython.noop),
+ migrations.RemoveField(
+ model_name='vminterface',
+ name='mac_address',
+ ),
+ ]
diff --git a/netbox/virtualization/models/clusters.py b/netbox/virtualization/models/clusters.py
index 7f00d72a7..9f7b97e29 100644
--- a/netbox/virtualization/models/clusters.py
+++ b/netbox/virtualization/models/clusters.py
@@ -1,10 +1,11 @@
+from django.apps import apps
from django.contrib.contenttypes.fields import GenericRelation
from django.core.exceptions import ValidationError
from django.db import models
-from django.urls import reverse
from django.utils.translation import gettext_lazy as _
from dcim.models import Device
+from dcim.models.mixins import CachedScopeMixin
from netbox.models import OrganizationalModel, PrimaryModel
from netbox.models.features import ContactsMixin
from virtualization.choices import *
@@ -25,9 +26,6 @@ class ClusterType(OrganizationalModel):
verbose_name = _('cluster type')
verbose_name_plural = _('cluster types')
- def get_absolute_url(self):
- return reverse('virtualization:clustertype', args=[self.pk])
-
class ClusterGroup(ContactsMixin, OrganizationalModel):
"""
@@ -45,17 +43,15 @@ class ClusterGroup(ContactsMixin, OrganizationalModel):
verbose_name = _('cluster group')
verbose_name_plural = _('cluster groups')
- def get_absolute_url(self):
- return reverse('virtualization:clustergroup', args=[self.pk])
-
-class Cluster(ContactsMixin, PrimaryModel):
+class Cluster(ContactsMixin, CachedScopeMixin, PrimaryModel):
"""
A cluster of VirtualMachines. Each Cluster may optionally be associated with one or more Devices.
"""
name = models.CharField(
verbose_name=_('name'),
- max_length=100
+ max_length=100,
+ db_collation="natural_sort"
)
type = models.ForeignKey(
verbose_name=_('type'),
@@ -83,13 +79,6 @@ class Cluster(ContactsMixin, PrimaryModel):
blank=True,
null=True
)
- site = models.ForeignKey(
- to='dcim.Site',
- on_delete=models.PROTECT,
- related_name='clusters',
- blank=True,
- null=True
- )
# Generic relations
vlan_groups = GenericRelation(
@@ -100,7 +89,7 @@ class Cluster(ContactsMixin, PrimaryModel):
)
clone_fields = (
- 'type', 'group', 'status', 'tenant', 'site',
+ 'scope_type', 'scope_id', 'type', 'group', 'status', 'tenant',
)
prerequisite_models = (
'virtualization.ClusterType',
@@ -114,8 +103,8 @@ class Cluster(ContactsMixin, PrimaryModel):
name='%(app_label)s_%(class)s_unique_group_name'
),
models.UniqueConstraint(
- fields=('site', 'name'),
- name='%(app_label)s_%(class)s_unique_site_name'
+ fields=('_site', 'name'),
+ name='%(app_label)s_%(class)s_unique__site_name'
),
)
verbose_name = _('cluster')
@@ -124,20 +113,34 @@ class Cluster(ContactsMixin, PrimaryModel):
def __str__(self):
return self.name
- def get_absolute_url(self):
- return reverse('virtualization:cluster', args=[self.pk])
-
def get_status_color(self):
return ClusterStatusChoices.colors.get(self.status)
def clean(self):
super().clean()
+ site = location = None
+ if self.scope_type:
+ scope_type = self.scope_type.model_class()
+ if scope_type == apps.get_model('dcim', 'site'):
+ site = self.scope
+ elif scope_type == apps.get_model('dcim', 'location'):
+ location = self.scope
+ site = location.site
+
# If the Cluster is assigned to a Site, verify that all host Devices belong to that Site.
- if not self._state.adding and self.site:
- if nonsite_devices := Device.objects.filter(cluster=self).exclude(site=self.site).count():
- raise ValidationError({
- 'site': _(
- "{count} devices are assigned as hosts for this cluster but are not in site {site}"
- ).format(count=nonsite_devices, site=self.site)
- })
+ if not self._state.adding:
+ if site:
+ if nonsite_devices := Device.objects.filter(cluster=self).exclude(site=site).count():
+ raise ValidationError({
+ 'scope': _(
+ "{count} devices are assigned as hosts for this cluster but are not in site {site}"
+ ).format(count=nonsite_devices, site=site)
+ })
+ if location:
+ if nonlocation_devices := Device.objects.filter(cluster=self).exclude(location=location).count():
+ raise ValidationError({
+ 'scope': _(
+ "{count} devices are assigned as hosts for this cluster but are not in location {location}"
+ ).format(count=nonlocation_devices, location=location)
+ })
diff --git a/netbox/virtualization/models/virtualmachines.py b/netbox/virtualization/models/virtualmachines.py
index 17929f476..fab30c6f2 100644
--- a/netbox/virtualization/models/virtualmachines.py
+++ b/netbox/virtualization/models/virtualmachines.py
@@ -6,7 +6,6 @@ from django.core.validators import MinValueValidator
from django.db import models
from django.db.models import Q, Sum
from django.db.models.functions import Lower
-from django.urls import reverse
from django.utils.translation import gettext_lazy as _
from dcim.models import BaseInterface
@@ -70,12 +69,8 @@ class VirtualMachine(ContactsMixin, ImageAttachmentsMixin, RenderConfigMixin, Co
)
name = models.CharField(
verbose_name=_('name'),
- max_length=64
- )
- _name = NaturalOrderingField(
- target_field='name',
- max_length=100,
- blank=True
+ max_length=64,
+ db_collation="natural_sort"
)
status = models.CharField(
max_length=50,
@@ -153,7 +148,7 @@ class VirtualMachine(ContactsMixin, ImageAttachmentsMixin, RenderConfigMixin, Co
)
class Meta:
- ordering = ('_name', 'pk') # Name may be non-unique
+ ordering = ('name', 'pk') # Name may be non-unique
constraints = (
models.UniqueConstraint(
Lower('name'), 'cluster', 'tenant',
@@ -172,9 +167,6 @@ class VirtualMachine(ContactsMixin, ImageAttachmentsMixin, RenderConfigMixin, Co
def __str__(self):
return self.name
- def get_absolute_url(self):
- return reverse('virtualization:virtualmachine', args=[self.pk])
-
def clean(self):
super().clean()
@@ -185,7 +177,7 @@ class VirtualMachine(ContactsMixin, ImageAttachmentsMixin, RenderConfigMixin, Co
})
# Validate site for cluster & VM
- if self.cluster and self.site and self.cluster.site and self.cluster.site != self.site:
+ if self.cluster and self.site and self.cluster._site and self.cluster._site != self.site:
raise ValidationError({
'cluster': _(
'The selected cluster ({cluster}) is not assigned to this site ({site}).'
@@ -242,7 +234,7 @@ class VirtualMachine(ContactsMixin, ImageAttachmentsMixin, RenderConfigMixin, Co
# Assign site from cluster if not set
if self.cluster and not self.site:
- self.site = self.cluster.site
+ self.site = self.cluster._site
super().save(*args, **kwargs)
@@ -277,13 +269,8 @@ class ComponentModel(NetBoxModel):
)
name = models.CharField(
verbose_name=_('name'),
- max_length=64
- )
- _name = NaturalOrderingField(
- target_field='name',
- naturalize_function=naturalize_interface,
- max_length=100,
- blank=True
+ max_length=64,
+ db_collation="natural_sort"
)
description = models.CharField(
verbose_name=_('description'),
@@ -293,7 +280,6 @@ class ComponentModel(NetBoxModel):
class Meta:
abstract = True
- ordering = ('virtual_machine', CollateAsChar('_name'))
constraints = (
models.UniqueConstraint(
fields=('virtual_machine', 'name'),
@@ -315,10 +301,9 @@ class ComponentModel(NetBoxModel):
class VMInterface(ComponentModel, BaseInterface, TrackingModelMixin):
- virtual_machine = models.ForeignKey(
- to='virtualization.VirtualMachine',
- on_delete=models.CASCADE,
- related_name='interfaces' # Override ComponentModel
+ name = models.CharField(
+ verbose_name=_('name'),
+ max_length=64,
)
_name = NaturalOrderingField(
target_field='name',
@@ -326,19 +311,10 @@ class VMInterface(ComponentModel, BaseInterface, TrackingModelMixin):
max_length=100,
blank=True
)
- untagged_vlan = models.ForeignKey(
- to='ipam.VLAN',
- on_delete=models.SET_NULL,
- related_name='vminterfaces_as_untagged',
- null=True,
- blank=True,
- verbose_name=_('untagged VLAN')
- )
- tagged_vlans = models.ManyToManyField(
- to='ipam.VLAN',
- related_name='vminterfaces_as_tagged',
- blank=True,
- verbose_name=_('tagged VLANs')
+ virtual_machine = models.ForeignKey(
+ to='virtualization.VirtualMachine',
+ on_delete=models.CASCADE,
+ related_name='interfaces' # Override ComponentModel
)
ip_addresses = GenericRelation(
to='ipam.IPAddress',
@@ -372,13 +348,17 @@ class VMInterface(ComponentModel, BaseInterface, TrackingModelMixin):
object_id_field='assigned_object_id',
related_query_name='vminterface',
)
+ mac_addresses = GenericRelation(
+ to='dcim.MACAddress',
+ content_type_field='assigned_object_type',
+ object_id_field='assigned_object_id',
+ related_query_name='vminterface'
+ )
class Meta(ComponentModel.Meta):
verbose_name = _('interface')
verbose_name_plural = _('interfaces')
-
- def get_absolute_url(self):
- return reverse('virtualization:vminterface', kwargs={'pk': self.pk})
+ ordering = ('virtual_machine', CollateAsChar('_name'))
def clean(self):
super().clean()
@@ -437,6 +417,4 @@ class VirtualDisk(ComponentModel, TrackingModelMixin):
class Meta(ComponentModel.Meta):
verbose_name = _('virtual disk')
verbose_name_plural = _('virtual disks')
-
- def get_absolute_url(self):
- return reverse('virtualization:virtualdisk', args=[self.pk])
+ ordering = ('virtual_machine', 'name')
diff --git a/netbox/virtualization/search.py b/netbox/virtualization/search.py
index cdaf0c074..18cc06519 100644
--- a/netbox/virtualization/search.py
+++ b/netbox/virtualization/search.py
@@ -52,11 +52,10 @@ class VMInterfaceIndex(SearchIndex):
model = models.VMInterface
fields = (
('name', 100),
- ('mac_address', 300),
('description', 500),
('mtu', 2000),
)
- display_attrs = ('virtual_machine', 'mac_address', 'description')
+ display_attrs = ('virtual_machine', 'description')
@register_search
diff --git a/netbox/virtualization/tables/clusters.py b/netbox/virtualization/tables/clusters.py
index d3c799fb9..d07bb4519 100644
--- a/netbox/virtualization/tables/clusters.py
+++ b/netbox/virtualization/tables/clusters.py
@@ -73,8 +73,11 @@ class ClusterTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
status = columns.ChoiceFieldColumn(
verbose_name=_('Status'),
)
- site = tables.Column(
- verbose_name=_('Site'),
+ scope_type = columns.ContentTypeColumn(
+ verbose_name=_('Scope Type'),
+ )
+ scope = tables.Column(
+ verbose_name=_('Scope'),
linkify=True
)
device_count = columns.LinkedCountColumn(
@@ -97,7 +100,7 @@ class ClusterTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
class Meta(NetBoxTable.Meta):
model = Cluster
fields = (
- 'pk', 'id', 'name', 'type', 'group', 'status', 'tenant', 'tenant_group', 'site', 'description', 'comments',
- 'device_count', 'vm_count', 'contacts', 'tags', 'created', 'last_updated',
+ 'pk', 'id', 'name', 'type', 'group', 'status', 'tenant', 'tenant_group', 'scope', 'scope_type',
+ 'description', 'comments', 'device_count', 'vm_count', 'contacts', 'tags', 'created', 'last_updated',
)
default_columns = ('pk', 'name', 'type', 'group', 'status', 'tenant', 'site', 'device_count', 'vm_count')
diff --git a/netbox/virtualization/tables/template_code.py b/netbox/virtualization/tables/template_code.py
new file mode 100644
index 000000000..a6b7251f2
--- /dev/null
+++ b/netbox/virtualization/tables/template_code.py
@@ -0,0 +1,32 @@
+VMINTERFACE_BUTTONS = """
+{% if perms.virtualization.change_vminterface %}
+
+
+
+
+
+
+{% endif %}
+{% if perms.vpn.add_tunnel and not record.tunnel_termination %}
+
+
+
+{% elif perms.vpn.delete_tunneltermination and record.tunnel_termination %}
+
+
+
+{% endif %}
+"""
diff --git a/netbox/virtualization/tables/virtualmachines.py b/netbox/virtualization/tables/virtualmachines.py
index 8a2a26bb9..116051037 100644
--- a/netbox/virtualization/tables/virtualmachines.py
+++ b/netbox/virtualization/tables/virtualmachines.py
@@ -6,6 +6,7 @@ from netbox.tables import NetBoxTable, columns
from tenancy.tables import ContactsColumnMixin, TenancyColumnsMixin
from utilities.templatetags.helpers import humanize_megabytes
from virtualization.models import VirtualDisk, VirtualMachine, VMInterface
+from .template_code import *
__all__ = (
'VirtualDiskTable',
@@ -15,36 +16,6 @@ __all__ = (
'VMInterfaceTable',
)
-VMINTERFACE_BUTTONS = """
-{% if perms.virtualization.change_vminterface %}
-
-
-
-
-
-
-{% endif %}
-{% if perms.vpn.add_tunnel and not record.tunnel_termination %}
-
-
-
-{% elif perms.vpn.delete_tunneltermination and record.tunnel_termination %}
-
-
-
-{% endif %}
-"""
-
#
# Virtual machines
@@ -53,7 +24,6 @@ VMINTERFACE_BUTTONS = """
class VirtualMachineTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
name = tables.Column(
verbose_name=_('Name'),
- order_by=('_name',),
linkify=True
)
status = columns.ChoiceFieldColumn(
@@ -151,8 +121,8 @@ class VMInterfaceTable(BaseInterfaceTable):
model = VMInterface
fields = (
'pk', 'id', 'name', 'virtual_machine', 'enabled', 'mac_address', 'mtu', 'mode', 'description', 'tags',
- 'vrf', 'l2vpn', 'tunnel', 'ip_addresses', 'fhrp_groups', 'untagged_vlan', 'tagged_vlans', 'created',
- 'last_updated',
+ 'vrf', 'primary_mac_address', 'l2vpn', 'tunnel', 'ip_addresses', 'fhrp_groups', 'untagged_vlan',
+ 'tagged_vlans', 'qinq_svlan', 'created', 'last_updated',
)
default_columns = ('pk', 'name', 'virtual_machine', 'enabled', 'description')
@@ -175,7 +145,8 @@ class VirtualMachineVMInterfaceTable(VMInterfaceTable):
model = VMInterface
fields = (
'pk', 'id', 'name', 'enabled', 'parent', 'bridge', 'mac_address', 'mtu', 'mode', 'description', 'tags',
- 'vrf', 'l2vpn', 'tunnel', 'ip_addresses', 'fhrp_groups', 'untagged_vlan', 'tagged_vlans', 'actions',
+ 'vrf', 'l2vpn', 'tunnel', 'ip_addresses', 'fhrp_groups', 'untagged_vlan', 'tagged_vlans', 'qinq_svlan',
+ 'actions',
)
default_columns = ('pk', 'name', 'enabled', 'mac_address', 'mtu', 'mode', 'description', 'ip_addresses')
row_attrs = {
diff --git a/netbox/virtualization/tests/test_api.py b/netbox/virtualization/tests/test_api.py
index 69728f67c..c57b57f2e 100644
--- a/netbox/virtualization/tests/test_api.py
+++ b/netbox/virtualization/tests/test_api.py
@@ -4,6 +4,7 @@ from rest_framework import status
from dcim.choices import InterfaceModeChoices
from dcim.models import Site
from extras.models import ConfigTemplate
+from ipam.choices import VLANQinQRoleChoices
from ipam.models import VLAN, VRF
from utilities.testing import APITestCase, APIViewTestCases, create_test_device, create_test_virtualmachine
from virtualization.choices import *
@@ -108,11 +109,27 @@ class ClusterTest(APIViewTestCases.APIViewTestCase):
ClusterGroup.objects.bulk_create(cluster_groups)
clusters = (
- Cluster(name='Cluster 1', type=cluster_types[0], group=cluster_groups[0], status=ClusterStatusChoices.STATUS_PLANNED),
- Cluster(name='Cluster 2', type=cluster_types[0], group=cluster_groups[0], status=ClusterStatusChoices.STATUS_PLANNED),
- Cluster(name='Cluster 3', type=cluster_types[0], group=cluster_groups[0], status=ClusterStatusChoices.STATUS_PLANNED),
+ Cluster(
+ name='Cluster 1',
+ type=cluster_types[0],
+ group=cluster_groups[0],
+ status=ClusterStatusChoices.STATUS_PLANNED,
+ ),
+ Cluster(
+ name='Cluster 2',
+ type=cluster_types[0],
+ group=cluster_groups[0],
+ status=ClusterStatusChoices.STATUS_PLANNED,
+ ),
+ Cluster(
+ name='Cluster 3',
+ type=cluster_types[0],
+ group=cluster_groups[0],
+ status=ClusterStatusChoices.STATUS_PLANNED,
+ ),
)
- Cluster.objects.bulk_create(clusters)
+ for cluster in clusters:
+ cluster.save()
cls.create_data = [
{
@@ -156,19 +173,36 @@ class VirtualMachineTest(APIViewTestCases.APIViewTestCase):
Site.objects.bulk_create(sites)
clusters = (
- Cluster(name='Cluster 1', type=clustertype, site=sites[0], group=clustergroup),
- Cluster(name='Cluster 2', type=clustertype, site=sites[1], group=clustergroup),
+ Cluster(name='Cluster 1', type=clustertype, scope=sites[0], group=clustergroup),
+ Cluster(name='Cluster 2', type=clustertype, scope=sites[1], group=clustergroup),
Cluster(name='Cluster 3', type=clustertype),
)
- Cluster.objects.bulk_create(clusters)
+ for cluster in clusters:
+ cluster.save()
device1 = create_test_device('device1', site=sites[0], cluster=clusters[0])
device2 = create_test_device('device2', site=sites[1], cluster=clusters[1])
virtual_machines = (
- VirtualMachine(name='Virtual Machine 1', site=sites[0], cluster=clusters[0], device=device1, local_context_data={'A': 1}),
- VirtualMachine(name='Virtual Machine 2', site=sites[0], cluster=clusters[0], local_context_data={'B': 2}),
- VirtualMachine(name='Virtual Machine 3', site=sites[0], cluster=clusters[0], local_context_data={'C': 3}),
+ VirtualMachine(
+ name='Virtual Machine 1',
+ site=sites[0],
+ cluster=clusters[0],
+ device=device1,
+ local_context_data={'A': 1},
+ ),
+ VirtualMachine(
+ name='Virtual Machine 2',
+ site=sites[0],
+ cluster=clusters[0],
+ local_context_data={'B': 2
+ }),
+ VirtualMachine(
+ name='Virtual Machine 3',
+ site=sites[0],
+ cluster=clusters[0],
+ local_context_data={'C': 3}
+ ),
)
VirtualMachine.objects.bulk_create(virtual_machines)
@@ -270,6 +304,7 @@ class VMInterfaceTest(APIViewTestCases.APIViewTestCase):
VLAN(name='VLAN 1', vid=1),
VLAN(name='VLAN 2', vid=2),
VLAN(name='VLAN 3', vid=3),
+ VLAN(name='SVLAN 1', vid=1001, qinq_role=VLANQinQRoleChoices.ROLE_SERVICE),
)
VLAN.objects.bulk_create(vlans)
@@ -307,6 +342,12 @@ class VMInterfaceTest(APIViewTestCases.APIViewTestCase):
'untagged_vlan': vlans[2].pk,
'vrf': vrfs[2].pk,
},
+ {
+ 'virtual_machine': virtualmachine.pk,
+ 'name': 'Interface 7',
+ 'mode': InterfaceModeChoices.MODE_Q_IN_Q,
+ 'qinq_svlan': vlans[3].pk,
+ },
]
def test_bulk_delete_child_interfaces(self):
diff --git a/netbox/virtualization/tests/test_filtersets.py b/netbox/virtualization/tests/test_filtersets.py
index d2e6cc05f..eef5d6b52 100644
--- a/netbox/virtualization/tests/test_filtersets.py
+++ b/netbox/virtualization/tests/test_filtersets.py
@@ -1,7 +1,9 @@
from django.test import TestCase
-from dcim.models import Device, DeviceRole, Platform, Region, Site, SiteGroup
-from ipam.models import IPAddress, VRF
+from dcim.choices import InterfaceModeChoices
+from dcim.models import Device, DeviceRole, MACAddress, Platform, Region, Site, SiteGroup
+from ipam.choices import VLANQinQRoleChoices
+from ipam.models import IPAddress, VLAN, VLANTranslationPolicy, VRF
from tenancy.models import Tenant, TenantGroup
from utilities.testing import ChangeLoggedFilterSetTests, create_test_device
from virtualization.choices import *
@@ -136,7 +138,7 @@ class ClusterTestCase(TestCase, ChangeLoggedFilterSetTests):
type=cluster_types[0],
group=cluster_groups[0],
status=ClusterStatusChoices.STATUS_PLANNED,
- site=sites[0],
+ scope=sites[0],
tenant=tenants[0],
description='foobar1'
),
@@ -145,7 +147,7 @@ class ClusterTestCase(TestCase, ChangeLoggedFilterSetTests):
type=cluster_types[1],
group=cluster_groups[1],
status=ClusterStatusChoices.STATUS_STAGING,
- site=sites[1],
+ scope=sites[1],
tenant=tenants[1],
description='foobar2'
),
@@ -154,12 +156,13 @@ class ClusterTestCase(TestCase, ChangeLoggedFilterSetTests):
type=cluster_types[2],
group=cluster_groups[2],
status=ClusterStatusChoices.STATUS_ACTIVE,
- site=sites[2],
+ scope=sites[2],
tenant=tenants[2],
description='foobar3'
),
)
- Cluster.objects.bulk_create(clusters)
+ for cluster in clusters:
+ cluster.save()
def test_q(self):
params = {'q': 'foobar1'}
@@ -272,11 +275,12 @@ class VirtualMachineTestCase(TestCase, ChangeLoggedFilterSetTests):
Site.objects.bulk_create(sites)
clusters = (
- Cluster(name='Cluster 1', type=cluster_types[0], group=cluster_groups[0], site=sites[0]),
- Cluster(name='Cluster 2', type=cluster_types[1], group=cluster_groups[1], site=sites[1]),
- Cluster(name='Cluster 3', type=cluster_types[2], group=cluster_groups[2], site=sites[2]),
+ Cluster(name='Cluster 1', type=cluster_types[0], group=cluster_groups[0], scope=sites[0]),
+ Cluster(name='Cluster 2', type=cluster_types[1], group=cluster_groups[1], scope=sites[1]),
+ Cluster(name='Cluster 3', type=cluster_types[2], group=cluster_groups[2], scope=sites[2]),
)
- Cluster.objects.bulk_create(clusters)
+ for cluster in clusters:
+ cluster.save()
platforms = (
Platform(name='Platform 1', slug='platform-1'),
@@ -362,13 +366,24 @@ class VirtualMachineTestCase(TestCase, ChangeLoggedFilterSetTests):
)
VirtualMachine.objects.bulk_create(vms)
+ mac_addresses = (
+ MACAddress(mac_address='00-00-00-00-00-01'),
+ MACAddress(mac_address='00-00-00-00-00-02'),
+ MACAddress(mac_address='00-00-00-00-00-03'),
+ )
+ MACAddress.objects.bulk_create(mac_addresses)
+
interfaces = (
- VMInterface(virtual_machine=vms[0], name='Interface 1', mac_address='00-00-00-00-00-01'),
- VMInterface(virtual_machine=vms[1], name='Interface 2', mac_address='00-00-00-00-00-02'),
- VMInterface(virtual_machine=vms[2], name='Interface 3', mac_address='00-00-00-00-00-03'),
+ VMInterface(virtual_machine=vms[0], name='Interface 1'),
+ VMInterface(virtual_machine=vms[1], name='Interface 2'),
+ VMInterface(virtual_machine=vms[2], name='Interface 3'),
)
VMInterface.objects.bulk_create(interfaces)
+ interfaces[0].mac_addresses.set([mac_addresses[0]])
+ interfaces[1].mac_addresses.set([mac_addresses[1]])
+ interfaces[2].mac_addresses.set([mac_addresses[2]])
+
# Assign primary IPs for filtering
ipaddresses = (
IPAddress(address='192.0.2.1/24', assigned_object=interfaces[0]),
@@ -528,7 +543,7 @@ class VirtualMachineTestCase(TestCase, ChangeLoggedFilterSetTests):
class VMInterfaceTestCase(TestCase, ChangeLoggedFilterSetTests):
queryset = VMInterface.objects.all()
filterset = VMInterfaceFilterSet
- ignore_fields = ('tagged_vlans', 'untagged_vlan',)
+ ignore_fields = ('tagged_vlans', 'untagged_vlan', 'qinq_svlan')
@classmethod
def setUpTestData(cls):
@@ -554,6 +569,13 @@ class VMInterfaceTestCase(TestCase, ChangeLoggedFilterSetTests):
)
VRF.objects.bulk_create(vrfs)
+ vlans = (
+ VLAN(name='SVLAN 1', vid=1001, qinq_role=VLANQinQRoleChoices.ROLE_SERVICE),
+ VLAN(name='SVLAN 2', vid=1002, qinq_role=VLANQinQRoleChoices.ROLE_SERVICE),
+ VLAN(name='SVLAN 3', vid=1003, qinq_role=VLANQinQRoleChoices.ROLE_SERVICE),
+ )
+ VLAN.objects.bulk_create(vlans)
+
vms = (
VirtualMachine(name='Virtual Machine 1', cluster=clusters[0]),
VirtualMachine(name='Virtual Machine 2', cluster=clusters[1]),
@@ -561,37 +583,56 @@ class VMInterfaceTestCase(TestCase, ChangeLoggedFilterSetTests):
)
VirtualMachine.objects.bulk_create(vms)
+ vlan_translation_policies = (
+ VLANTranslationPolicy(name='Policy 1'),
+ VLANTranslationPolicy(name='Policy 2'),
+ VLANTranslationPolicy(name='Policy 3'),
+ )
+ VLANTranslationPolicy.objects.bulk_create(vlan_translation_policies)
+
+ mac_addresses = (
+ MACAddress(mac_address='00-00-00-00-00-01'),
+ MACAddress(mac_address='00-00-00-00-00-02'),
+ MACAddress(mac_address='00-00-00-00-00-03'),
+ )
+ MACAddress.objects.bulk_create(mac_addresses)
+
interfaces = (
VMInterface(
virtual_machine=vms[0],
name='Interface 1',
enabled=True,
mtu=100,
- mac_address='00-00-00-00-00-01',
vrf=vrfs[0],
- description='foobar1'
+ description='foobar1',
+ vlan_translation_policy=vlan_translation_policies[0],
),
VMInterface(
virtual_machine=vms[1],
name='Interface 2',
enabled=True,
mtu=200,
- mac_address='00-00-00-00-00-02',
vrf=vrfs[1],
- description='foobar2'
+ description='foobar2',
+ vlan_translation_policy=vlan_translation_policies[0],
),
VMInterface(
virtual_machine=vms[2],
name='Interface 3',
enabled=False,
mtu=300,
- mac_address='00-00-00-00-00-03',
vrf=vrfs[2],
- description='foobar3'
+ description='foobar3',
+ mode=InterfaceModeChoices.MODE_Q_IN_Q,
+ qinq_svlan=vlans[0]
),
)
VMInterface.objects.bulk_create(interfaces)
+ interfaces[0].mac_addresses.set([mac_addresses[0]])
+ interfaces[1].mac_addresses.set([mac_addresses[1]])
+ interfaces[2].mac_addresses.set([mac_addresses[2]])
+
def test_q(self):
params = {'q': 'foobar1'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
@@ -658,6 +699,20 @@ class VMInterfaceTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'description': ['foobar1', 'foobar2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+ def test_vlan(self):
+ vlan = VLAN.objects.filter(qinq_role=VLANQinQRoleChoices.ROLE_SERVICE).first()
+ params = {'vlan_id': vlan.pk}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
+ params = {'vlan': vlan.vid}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
+
+ def test_vlan_translation_policy(self):
+ vlan_translation_policies = VLANTranslationPolicy.objects.all()[:2]
+ params = {'vlan_translation_policy_id': [vlan_translation_policies[0].pk, vlan_translation_policies[1].pk]}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+ params = {'vlan_translation_policy': [vlan_translation_policies[0].name, vlan_translation_policies[1].name]}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
class VirtualDiskTestCase(TestCase, ChangeLoggedFilterSetTests):
queryset = VirtualDisk.objects.all()
diff --git a/netbox/virtualization/tests/test_models.py b/netbox/virtualization/tests/test_models.py
index a4e8d7947..7be423bf1 100644
--- a/netbox/virtualization/tests/test_models.py
+++ b/netbox/virtualization/tests/test_models.py
@@ -54,11 +54,12 @@ class VirtualMachineTestCase(TestCase):
Site.objects.bulk_create(sites)
clusters = (
- Cluster(name='Cluster 1', type=cluster_type, site=sites[0]),
- Cluster(name='Cluster 2', type=cluster_type, site=sites[1]),
- Cluster(name='Cluster 3', type=cluster_type, site=None),
+ Cluster(name='Cluster 1', type=cluster_type, scope=sites[0]),
+ Cluster(name='Cluster 2', type=cluster_type, scope=sites[1]),
+ Cluster(name='Cluster 3', type=cluster_type, scope=None),
)
- Cluster.objects.bulk_create(clusters)
+ for cluster in clusters:
+ cluster.save()
# VM with site only should pass
VirtualMachine(name='vm1', site=sites[0]).full_clean()
diff --git a/netbox/virtualization/tests/test_views.py b/netbox/virtualization/tests/test_views.py
index 3c6a058c9..3c8d7eadc 100644
--- a/netbox/virtualization/tests/test_views.py
+++ b/netbox/virtualization/tests/test_views.py
@@ -1,6 +1,6 @@
+from django.contrib.contenttypes.models import ContentType
from django.test import override_settings
from django.urls import reverse
-from netaddr import EUI
from dcim.choices import InterfaceModeChoices
from dcim.models import DeviceRole, Platform, Site
@@ -117,11 +117,30 @@ class ClusterTestCase(ViewTestCases.PrimaryObjectViewTestCase):
ClusterType.objects.bulk_create(clustertypes)
clusters = (
- Cluster(name='Cluster 1', group=clustergroups[0], type=clustertypes[0], status=ClusterStatusChoices.STATUS_ACTIVE, site=sites[0]),
- Cluster(name='Cluster 2', group=clustergroups[0], type=clustertypes[0], status=ClusterStatusChoices.STATUS_ACTIVE, site=sites[0]),
- Cluster(name='Cluster 3', group=clustergroups[0], type=clustertypes[0], status=ClusterStatusChoices.STATUS_ACTIVE, site=sites[0]),
+ Cluster(
+ name='Cluster 1',
+ group=clustergroups[0],
+ type=clustertypes[0],
+ status=ClusterStatusChoices.STATUS_ACTIVE,
+ scope=sites[0],
+ ),
+ Cluster(
+ name='Cluster 2',
+ group=clustergroups[0],
+ type=clustertypes[0],
+ status=ClusterStatusChoices.STATUS_ACTIVE,
+ scope=sites[0],
+ ),
+ Cluster(
+ name='Cluster 3',
+ group=clustergroups[0],
+ type=clustertypes[0],
+ status=ClusterStatusChoices.STATUS_ACTIVE,
+ scope=sites[0],
+ ),
)
- Cluster.objects.bulk_create(clusters)
+ for cluster in clusters:
+ cluster.save()
tags = create_tags('Alpha', 'Bravo', 'Charlie')
@@ -131,7 +150,8 @@ class ClusterTestCase(ViewTestCases.PrimaryObjectViewTestCase):
'type': clustertypes[1].pk,
'status': ClusterStatusChoices.STATUS_OFFLINE,
'tenant': None,
- 'site': sites[1].pk,
+ 'scope_type': ContentType.objects.get_for_model(Site).pk,
+ 'scope': sites[1].pk,
'comments': 'Some comments',
'tags': [t.pk for t in tags],
}
@@ -155,7 +175,6 @@ class ClusterTestCase(ViewTestCases.PrimaryObjectViewTestCase):
'type': clustertypes[1].pk,
'status': ClusterStatusChoices.STATUS_OFFLINE,
'tenant': None,
- 'site': sites[1].pk,
'comments': 'New comments',
}
@@ -201,10 +220,11 @@ class VirtualMachineTestCase(ViewTestCases.PrimaryObjectViewTestCase):
clustertype = ClusterType.objects.create(name='Cluster Type 1', slug='cluster-type-1')
clusters = (
- Cluster(name='Cluster 1', type=clustertype, site=sites[0]),
- Cluster(name='Cluster 2', type=clustertype, site=sites[1]),
+ Cluster(name='Cluster 1', type=clustertype, scope=sites[0]),
+ Cluster(name='Cluster 2', type=clustertype, scope=sites[1]),
)
- Cluster.objects.bulk_create(clusters)
+ for cluster in clusters:
+ cluster.save()
devices = (
create_test_device('device1', site=sites[0], cluster=clusters[0]),
@@ -212,9 +232,30 @@ class VirtualMachineTestCase(ViewTestCases.PrimaryObjectViewTestCase):
)
virtual_machines = (
- VirtualMachine(name='Virtual Machine 1', site=sites[0], cluster=clusters[0], device=devices[0], role=roles[0], platform=platforms[0]),
- VirtualMachine(name='Virtual Machine 2', site=sites[0], cluster=clusters[0], device=devices[0], role=roles[0], platform=platforms[0]),
- VirtualMachine(name='Virtual Machine 3', site=sites[0], cluster=clusters[0], device=devices[0], role=roles[0], platform=platforms[0]),
+ VirtualMachine(
+ name='Virtual Machine 1',
+ site=sites[0],
+ cluster=clusters[0],
+ device=devices[0],
+ role=roles[0],
+ platform=platforms[0],
+ ),
+ VirtualMachine(
+ name='Virtual Machine 2',
+ site=sites[0],
+ cluster=clusters[0],
+ device=devices[0],
+ role=roles[0],
+ platform=platforms[0],
+ ),
+ VirtualMachine(
+ name='Virtual Machine 3',
+ site=sites[0],
+ cluster=clusters[0],
+ device=devices[0],
+ role=roles[0],
+ platform=platforms[0],
+ ),
)
VirtualMachine.objects.bulk_create(virtual_machines)
@@ -292,7 +333,7 @@ class VMInterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase):
site = Site.objects.create(name='Site 1', slug='site-1')
role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
clustertype = ClusterType.objects.create(name='Cluster Type 1', slug='cluster-type-1')
- cluster = Cluster.objects.create(name='Cluster 1', type=clustertype, site=site)
+ cluster = Cluster.objects.create(name='Cluster 1', type=clustertype, scope=site)
virtualmachines = (
VirtualMachine(name='Virtual Machine 1', site=site, cluster=cluster, role=role),
VirtualMachine(name='Virtual Machine 2', site=site, cluster=cluster, role=role),
@@ -328,7 +369,6 @@ class VMInterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase):
'name': 'Interface X',
'enabled': False,
'bridge': interfaces[1].pk,
- 'mac_address': EUI('01-02-03-04-05-06'),
'mtu': 65000,
'description': 'New description',
'mode': InterfaceModeChoices.MODE_TAGGED,
@@ -343,7 +383,6 @@ class VMInterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase):
'name': 'Interface [4-6]',
'enabled': False,
'bridge': interfaces[3].pk,
- 'mac_address': EUI('01-02-03-04-05-06'),
'mtu': 2000,
'description': 'New description',
'mode': InterfaceModeChoices.MODE_TAGGED,
diff --git a/netbox/virtualization/urls.py b/netbox/virtualization/urls.py
index 6d90645a3..679f6f229 100644
--- a/netbox/virtualization/urls.py
+++ b/netbox/virtualization/urls.py
@@ -1,4 +1,4 @@
-from django.urls import include, path, re_path
+from django.urls import include, path
from utilities.urls import get_model_urls
from . import views
@@ -6,59 +6,31 @@ from . import views
app_name = 'virtualization'
urlpatterns = [
- # Cluster types
- path('cluster-types/', views.ClusterTypeListView.as_view(), name='clustertype_list'),
- path('cluster-types/add/', views.ClusterTypeEditView.as_view(), name='clustertype_add'),
- path('cluster-types/import/', views.ClusterTypeBulkImportView.as_view(), name='clustertype_import'),
- path('cluster-types/edit/', views.ClusterTypeBulkEditView.as_view(), name='clustertype_bulk_edit'),
- path('cluster-types/delete/', views.ClusterTypeBulkDeleteView.as_view(), name='clustertype_bulk_delete'),
+ path('cluster-types/', include(get_model_urls('virtualization', 'clustertype', detail=False))),
path('cluster-types//', include(get_model_urls('virtualization', 'clustertype'))),
- # Cluster groups
- path('cluster-groups/', views.ClusterGroupListView.as_view(), name='clustergroup_list'),
- path('cluster-groups/add/', views.ClusterGroupEditView.as_view(), name='clustergroup_add'),
- path('cluster-groups/import/', views.ClusterGroupBulkImportView.as_view(), name='clustergroup_import'),
- path('cluster-groups/edit/', views.ClusterGroupBulkEditView.as_view(), name='clustergroup_bulk_edit'),
- path('cluster-groups/delete/', views.ClusterGroupBulkDeleteView.as_view(), name='clustergroup_bulk_delete'),
+ path('cluster-groups/', include(get_model_urls('virtualization', 'clustergroup', detail=False))),
path('cluster-groups//', include(get_model_urls('virtualization', 'clustergroup'))),
- # Clusters
- path('clusters/', views.ClusterListView.as_view(), name='cluster_list'),
- path('clusters/add/', views.ClusterEditView.as_view(), name='cluster_add'),
- path('clusters/import/', views.ClusterBulkImportView.as_view(), name='cluster_import'),
- path('clusters/edit/', views.ClusterBulkEditView.as_view(), name='cluster_bulk_edit'),
- path('clusters/delete/', views.ClusterBulkDeleteView.as_view(), name='cluster_bulk_delete'),
+ path('clusters/', include(get_model_urls('virtualization', 'cluster', detail=False))),
path('clusters//', include(get_model_urls('virtualization', 'cluster'))),
- # Virtual machines
- path('virtual-machines/', views.VirtualMachineListView.as_view(), name='virtualmachine_list'),
- path('virtual-machines/add/', views.VirtualMachineEditView.as_view(), name='virtualmachine_add'),
- path('virtual-machines/import/', views.VirtualMachineBulkImportView.as_view(), name='virtualmachine_import'),
- path('virtual-machines/edit/', views.VirtualMachineBulkEditView.as_view(), name='virtualmachine_bulk_edit'),
- path('virtual-machines/delete/', views.VirtualMachineBulkDeleteView.as_view(), name='virtualmachine_bulk_delete'),
+ path('virtual-machines/', include(get_model_urls('virtualization', 'virtualmachine', detail=False))),
path('virtual-machines//', include(get_model_urls('virtualization', 'virtualmachine'))),
- # VM interfaces
- path('interfaces/', views.VMInterfaceListView.as_view(), name='vminterface_list'),
- path('interfaces/add/', views.VMInterfaceCreateView.as_view(), name='vminterface_add'),
- path('interfaces/import/', views.VMInterfaceBulkImportView.as_view(), name='vminterface_import'),
- path('interfaces/edit/', views.VMInterfaceBulkEditView.as_view(), name='vminterface_bulk_edit'),
- path('interfaces/rename/', views.VMInterfaceBulkRenameView.as_view(), name='vminterface_bulk_rename'),
- path('interfaces/delete/', views.VMInterfaceBulkDeleteView.as_view(), name='vminterface_bulk_delete'),
+ path('interfaces/', include(get_model_urls('virtualization', 'vminterface', detail=False))),
path('interfaces//', include(get_model_urls('virtualization', 'vminterface'))),
- path('virtual-machines/interfaces/add/', views.VirtualMachineBulkAddInterfaceView.as_view(), name='virtualmachine_bulk_add_vminterface'),
+ path(
+ 'virtual-machines/interfaces/add/',
+ views.VirtualMachineBulkAddInterfaceView.as_view(),
+ name='virtualmachine_bulk_add_vminterface'
+ ),
- # Virtual disks
- path('virtual-disks/', views.VirtualDiskListView.as_view(), name='virtualdisk_list'),
- path('virtual-disks/add/', views.VirtualDiskCreateView.as_view(), name='virtualdisk_add'),
- path('virtual-disks/import/', views.VirtualDiskBulkImportView.as_view(), name='virtualdisk_import'),
- path('virtual-disks/edit/', views.VirtualDiskBulkEditView.as_view(), name='virtualdisk_bulk_edit'),
- path('virtual-disks/rename/', views.VirtualDiskBulkRenameView.as_view(), name='virtualdisk_bulk_rename'),
- path('virtual-disks/delete/', views.VirtualDiskBulkDeleteView.as_view(), name='virtualdisk_bulk_delete'),
+ path('virtual-disks/', include(get_model_urls('virtualization', 'virtualdisk', detail=False))),
path('virtual-disks//', include(get_model_urls('virtualization', 'virtualdisk'))),
- path('virtual-machines/disks/add/', views.VirtualMachineBulkAddVirtualDiskView.as_view(), name='virtualmachine_bulk_add_virtualdisk'),
-
- # TODO: Remove in v4.2
- # Redirect old (pre-v4.1) URLs for VirtualDisk views
- re_path('disks/(?P[a-z0-9/-]*)', views.VirtualDiskRedirectView.as_view()),
+ path(
+ 'virtual-machines/disks/add/',
+ views.VirtualMachineBulkAddVirtualDiskView.as_view(),
+ name='virtualmachine_bulk_add_virtualdisk'
+ ),
]
diff --git a/netbox/virtualization/views.py b/netbox/virtualization/views.py
index f71d56b19..7682d0fc8 100644
--- a/netbox/virtualization/views.py
+++ b/netbox/virtualization/views.py
@@ -5,7 +5,6 @@ from django.http import HttpResponse
from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
-from django.views.generic.base import RedirectView
from jinja2.exceptions import TemplateError
from dcim.filtersets import DeviceFilterSet
@@ -14,7 +13,7 @@ from dcim.models import Device
from dcim.tables import DeviceTable
from extras.views import ObjectConfigContextView
from ipam.models import IPAddress
-from ipam.tables import InterfaceVLANTable
+from ipam.tables import InterfaceVLANTable, VLANTranslationRuleTable
from netbox.constants import DEFAULT_ACTION_PERMISSIONS
from netbox.views import generic
from tenancy.views import ObjectContactsView
@@ -29,6 +28,7 @@ from .models import *
# Cluster types
#
+@register_model_view(ClusterType, 'list', path='', detail=False)
class ClusterTypeListView(generic.ObjectListView):
queryset = ClusterType.objects.annotate(
cluster_count=count_related(Cluster, 'type')
@@ -48,6 +48,7 @@ class ClusterTypeView(GetRelatedModelsMixin, generic.ObjectView):
}
+@register_model_view(ClusterType, 'add', detail=False)
@register_model_view(ClusterType, 'edit')
class ClusterTypeEditView(generic.ObjectEditView):
queryset = ClusterType.objects.all()
@@ -59,11 +60,13 @@ class ClusterTypeDeleteView(generic.ObjectDeleteView):
queryset = ClusterType.objects.all()
+@register_model_view(ClusterType, 'bulk_import', detail=False)
class ClusterTypeBulkImportView(generic.BulkImportView):
queryset = ClusterType.objects.all()
model_form = forms.ClusterTypeImportForm
+@register_model_view(ClusterType, 'bulk_edit', path='edit', detail=False)
class ClusterTypeBulkEditView(generic.BulkEditView):
queryset = ClusterType.objects.annotate(
cluster_count=count_related(Cluster, 'type')
@@ -73,6 +76,7 @@ class ClusterTypeBulkEditView(generic.BulkEditView):
form = forms.ClusterTypeBulkEditForm
+@register_model_view(ClusterType, 'bulk_delete', path='delete', detail=False)
class ClusterTypeBulkDeleteView(generic.BulkDeleteView):
queryset = ClusterType.objects.annotate(
cluster_count=count_related(Cluster, 'type')
@@ -85,6 +89,7 @@ class ClusterTypeBulkDeleteView(generic.BulkDeleteView):
# Cluster groups
#
+@register_model_view(ClusterGroup, 'list', path='', detail=False)
class ClusterGroupListView(generic.ObjectListView):
queryset = ClusterGroup.objects.annotate(
cluster_count=count_related(Cluster, 'group')
@@ -104,6 +109,7 @@ class ClusterGroupView(GetRelatedModelsMixin, generic.ObjectView):
}
+@register_model_view(ClusterGroup, 'add', detail=False)
@register_model_view(ClusterGroup, 'edit')
class ClusterGroupEditView(generic.ObjectEditView):
queryset = ClusterGroup.objects.all()
@@ -115,6 +121,7 @@ class ClusterGroupDeleteView(generic.ObjectDeleteView):
queryset = ClusterGroup.objects.all()
+@register_model_view(ClusterGroup, 'bulk_import', detail=False)
class ClusterGroupBulkImportView(generic.BulkImportView):
queryset = ClusterGroup.objects.annotate(
cluster_count=count_related(Cluster, 'group')
@@ -122,6 +129,7 @@ class ClusterGroupBulkImportView(generic.BulkImportView):
model_form = forms.ClusterGroupImportForm
+@register_model_view(ClusterGroup, 'bulk_edit', path='edit', detail=False)
class ClusterGroupBulkEditView(generic.BulkEditView):
queryset = ClusterGroup.objects.annotate(
cluster_count=count_related(Cluster, 'group')
@@ -131,6 +139,7 @@ class ClusterGroupBulkEditView(generic.BulkEditView):
form = forms.ClusterGroupBulkEditForm
+@register_model_view(ClusterGroup, 'bulk_delete', path='delete', detail=False)
class ClusterGroupBulkDeleteView(generic.BulkDeleteView):
queryset = ClusterGroup.objects.annotate(
cluster_count=count_related(Cluster, 'group')
@@ -148,6 +157,7 @@ class ClusterGroupContactsView(ObjectContactsView):
# Clusters
#
+@register_model_view(Cluster, 'list', path='', detail=False)
class ClusterListView(generic.ObjectListView):
permission_required = 'virtualization.view_cluster'
queryset = Cluster.objects.annotate(
@@ -164,7 +174,11 @@ class ClusterView(generic.ObjectView):
queryset = Cluster.objects.all()
def get_extra_context(self, request, instance):
- return instance.virtual_machines.aggregate(vcpus_sum=Sum('vcpus'), memory_sum=Sum('memory'), disk_sum=Sum('disk'))
+ return instance.virtual_machines.aggregate(
+ vcpus_sum=Sum('vcpus'),
+ memory_sum=Sum('memory'),
+ disk_sum=Sum('disk')
+ )
@register_model_view(Cluster, 'virtualmachines', path='virtual-machines')
@@ -195,8 +209,8 @@ class ClusterDevicesView(generic.ObjectChildrenView):
template_name = 'virtualization/cluster/devices.html'
actions = {
'add': {'add'},
- 'import': {'add'},
'export': {'view'},
+ 'bulk_import': {'add'},
'bulk_edit': {'change'},
'bulk_remove_devices': {'change'},
}
@@ -211,6 +225,7 @@ class ClusterDevicesView(generic.ObjectChildrenView):
return Device.objects.restrict(request.user, 'view').filter(cluster=parent)
+@register_model_view(Cluster, 'add', detail=False)
@register_model_view(Cluster, 'edit')
class ClusterEditView(generic.ObjectEditView):
queryset = Cluster.objects.all()
@@ -222,11 +237,13 @@ class ClusterDeleteView(generic.ObjectDeleteView):
queryset = Cluster.objects.all()
+@register_model_view(Cluster, 'bulk_import', detail=False)
class ClusterBulkImportView(generic.BulkImportView):
queryset = Cluster.objects.all()
model_form = forms.ClusterImportForm
+@register_model_view(Cluster, 'bulk_edit', path='edit', detail=False)
class ClusterBulkEditView(generic.BulkEditView):
queryset = Cluster.objects.all()
filterset = filtersets.ClusterFilterSet
@@ -234,6 +251,7 @@ class ClusterBulkEditView(generic.BulkEditView):
form = forms.ClusterBulkEditForm
+@register_model_view(Cluster, 'bulk_delete', path='delete', detail=False)
class ClusterBulkDeleteView(generic.BulkDeleteView):
queryset = Cluster.objects.all()
filterset = filtersets.ClusterFilterSet
@@ -335,6 +353,7 @@ class ClusterContactsView(ObjectContactsView):
# Virtual machines
#
+@register_model_view(VirtualMachine, 'list', path='', detail=False)
class VirtualMachineListView(generic.ObjectListView):
queryset = VirtualMachine.objects.prefetch_related('primary_ip4', 'primary_ip6')
filterset = filtersets.VirtualMachineFilterSet
@@ -457,6 +476,7 @@ class VirtualMachineRenderConfigView(generic.ObjectView):
}
+@register_model_view(VirtualMachine, 'add', detail=False)
@register_model_view(VirtualMachine, 'edit')
class VirtualMachineEditView(generic.ObjectEditView):
queryset = VirtualMachine.objects.all()
@@ -468,11 +488,13 @@ class VirtualMachineDeleteView(generic.ObjectDeleteView):
queryset = VirtualMachine.objects.all()
+@register_model_view(VirtualMachine, 'bulk_import', detail=False)
class VirtualMachineBulkImportView(generic.BulkImportView):
queryset = VirtualMachine.objects.all()
model_form = forms.VirtualMachineImportForm
+@register_model_view(VirtualMachine, 'bulk_edit', path='edit', detail=False)
class VirtualMachineBulkEditView(generic.BulkEditView):
queryset = VirtualMachine.objects.prefetch_related('primary_ip4', 'primary_ip6')
filterset = filtersets.VirtualMachineFilterSet
@@ -480,6 +502,7 @@ class VirtualMachineBulkEditView(generic.BulkEditView):
form = forms.VirtualMachineBulkEditForm
+@register_model_view(VirtualMachine, 'bulk_delete', path='delete', detail=False)
class VirtualMachineBulkDeleteView(generic.BulkDeleteView):
queryset = VirtualMachine.objects.prefetch_related('primary_ip4', 'primary_ip6')
filterset = filtersets.VirtualMachineFilterSet
@@ -495,6 +518,7 @@ class VirtualMachineContactsView(ObjectContactsView):
# VM interfaces
#
+@register_model_view(VMInterface, 'list', path='', detail=False)
class VMInterfaceListView(generic.ObjectListView):
queryset = VMInterface.objects.all()
filterset = filtersets.VMInterfaceFilterSet
@@ -516,6 +540,14 @@ class VMInterfaceView(generic.ObjectView):
orderable=False
)
+ # Get VLAN translation rules
+ vlan_translation_table = None
+ if instance.vlan_translation_policy:
+ vlan_translation_table = VLANTranslationRuleTable(
+ data=instance.vlan_translation_policy.rules.all(),
+ orderable=False
+ )
+
# Get assigned VLANs and annotate whether each is tagged or untagged
vlans = []
if instance.untagged_vlan is not None:
@@ -533,9 +565,11 @@ class VMInterfaceView(generic.ObjectView):
return {
'child_interfaces_table': child_interfaces_tables,
'vlan_table': vlan_table,
+ 'vlan_translation_table': vlan_translation_table,
}
+@register_model_view(VMInterface, 'add', detail=False)
class VMInterfaceCreateView(generic.ComponentCreateView):
queryset = VMInterface.objects.all()
form = forms.VMInterfaceCreateForm
@@ -553,11 +587,13 @@ class VMInterfaceDeleteView(generic.ObjectDeleteView):
queryset = VMInterface.objects.all()
+@register_model_view(VMInterface, 'bulk_import', detail=False)
class VMInterfaceBulkImportView(generic.BulkImportView):
queryset = VMInterface.objects.all()
model_form = forms.VMInterfaceImportForm
+@register_model_view(VMInterface, 'bulk_edit', path='edit', detail=False)
class VMInterfaceBulkEditView(generic.BulkEditView):
queryset = VMInterface.objects.all()
filterset = filtersets.VMInterfaceFilterSet
@@ -565,11 +601,13 @@ class VMInterfaceBulkEditView(generic.BulkEditView):
form = forms.VMInterfaceBulkEditForm
+@register_model_view(VMInterface, 'bulk_rename', path='rename', detail=False)
class VMInterfaceBulkRenameView(generic.BulkRenameView):
queryset = VMInterface.objects.all()
form = forms.VMInterfaceBulkRenameForm
+@register_model_view(VMInterface, 'bulk_delete', path='delete', detail=False)
class VMInterfaceBulkDeleteView(generic.BulkDeleteView):
# Ensure child interfaces are deleted prior to their parents
queryset = VMInterface.objects.order_by('virtual_machine', 'parent', CollateAsChar('_name'))
@@ -581,6 +619,7 @@ class VMInterfaceBulkDeleteView(generic.BulkDeleteView):
# Virtual disks
#
+@register_model_view(VirtualDisk, 'list', path='', detail=False)
class VirtualDiskListView(generic.ObjectListView):
queryset = VirtualDisk.objects.all()
filterset = filtersets.VirtualDiskFilterSet
@@ -593,6 +632,7 @@ class VirtualDiskView(generic.ObjectView):
queryset = VirtualDisk.objects.all()
+@register_model_view(VirtualDisk, 'add', detail=False)
class VirtualDiskCreateView(generic.ComponentCreateView):
queryset = VirtualDisk.objects.all()
form = forms.VirtualDiskCreateForm
@@ -610,11 +650,13 @@ class VirtualDiskDeleteView(generic.ObjectDeleteView):
queryset = VirtualDisk.objects.all()
+@register_model_view(VirtualDisk, 'bulk_import', detail=False)
class VirtualDiskBulkImportView(generic.BulkImportView):
queryset = VirtualDisk.objects.all()
model_form = forms.VirtualDiskImportForm
+@register_model_view(VirtualDisk, 'bulk_edit', path='edit', detail=False)
class VirtualDiskBulkEditView(generic.BulkEditView):
queryset = VirtualDisk.objects.all()
filterset = filtersets.VirtualDiskFilterSet
@@ -622,26 +664,19 @@ class VirtualDiskBulkEditView(generic.BulkEditView):
form = forms.VirtualDiskBulkEditForm
+@register_model_view(VirtualDisk, 'bulk_rename', path='rename', detail=False)
class VirtualDiskBulkRenameView(generic.BulkRenameView):
queryset = VirtualDisk.objects.all()
form = forms.VirtualDiskBulkRenameForm
+@register_model_view(VirtualDisk, 'bulk_delete', path='delete', detail=False)
class VirtualDiskBulkDeleteView(generic.BulkDeleteView):
queryset = VirtualDisk.objects.all()
filterset = filtersets.VirtualDiskFilterSet
table = tables.VirtualDiskTable
-# TODO: Remove in v4.2
-class VirtualDiskRedirectView(RedirectView):
- """
- Redirect old (pre-v4.1) URLs for VirtualDisk views.
- """
- def get_redirect_url(self, path):
- return f"{reverse('virtualization:virtualdisk_list')}{path}"
-
-
#
# Bulk Device component creation
#
diff --git a/netbox/vpn/api/nested_serializers.py b/netbox/vpn/api/nested_serializers.py
deleted file mode 100644
index 59e394c2e..000000000
--- a/netbox/vpn/api/nested_serializers.py
+++ /dev/null
@@ -1,109 +0,0 @@
-import warnings
-
-from drf_spectacular.utils import extend_schema_serializer
-
-from netbox.api.fields import RelatedObjectCountField
-from netbox.api.serializers import WritableNestedSerializer
-from vpn import models
-
-__all__ = (
- 'NestedIKEPolicySerializer',
- 'NestedIKEProposalSerializer',
- 'NestedIPSecPolicySerializer',
- 'NestedIPSecProfileSerializer',
- 'NestedIPSecProposalSerializer',
- 'NestedL2VPNSerializer',
- 'NestedL2VPNTerminationSerializer',
- 'NestedTunnelGroupSerializer',
- 'NestedTunnelSerializer',
- 'NestedTunnelTerminationSerializer',
-)
-
-# TODO: Remove in v4.2
-warnings.warn(
- "Dedicated nested serializers will be removed in NetBox v4.2. Use Serializer(nested=True) instead.",
- DeprecationWarning
-)
-
-
-@extend_schema_serializer(
- exclude_fields=('tunnel_count',),
-)
-class NestedTunnelGroupSerializer(WritableNestedSerializer):
- tunnel_count = RelatedObjectCountField('tunnels')
-
- class Meta:
- model = models.TunnelGroup
- fields = ['id', 'url', 'display_url', 'display', 'name', 'slug', 'tunnel_count']
-
-
-class NestedTunnelSerializer(WritableNestedSerializer):
-
- class Meta:
- model = models.Tunnel
- fields = ('id', 'url', 'display_url', 'display', 'name')
-
-
-class NestedTunnelTerminationSerializer(WritableNestedSerializer):
-
- class Meta:
- model = models.TunnelTermination
- fields = ('id', 'url', 'display_url', 'display')
-
-
-class NestedIKEProposalSerializer(WritableNestedSerializer):
-
- class Meta:
- model = models.IKEProposal
- fields = ('id', 'url', 'display_url', 'display', 'name')
-
-
-class NestedIKEPolicySerializer(WritableNestedSerializer):
-
- class Meta:
- model = models.IKEPolicy
- fields = ('id', 'url', 'display_url', 'display', 'name')
-
-
-class NestedIPSecProposalSerializer(WritableNestedSerializer):
-
- class Meta:
- model = models.IPSecProposal
- fields = ('id', 'url', 'display_url', 'display', 'name')
-
-
-class NestedIPSecPolicySerializer(WritableNestedSerializer):
-
- class Meta:
- model = models.IPSecPolicy
- fields = ('id', 'url', 'display_url', 'display', 'name')
-
-
-class NestedIPSecProfileSerializer(WritableNestedSerializer):
-
- class Meta:
- model = models.IPSecProfile
- fields = ('id', 'url', 'display_url', 'display', 'name')
-
-
-#
-# L2VPN
-#
-
-class NestedL2VPNSerializer(WritableNestedSerializer):
-
- class Meta:
- model = models.L2VPN
- fields = [
- 'id', 'url', 'display', 'display_url', 'identifier', 'name', 'slug', 'type'
- ]
-
-
-class NestedL2VPNTerminationSerializer(WritableNestedSerializer):
- l2vpn = NestedL2VPNSerializer()
-
- class Meta:
- model = models.L2VPNTermination
- fields = [
- 'id', 'url', 'display_url', 'display', 'l2vpn'
- ]
diff --git a/netbox/vpn/api/serializers_/crypto.py b/netbox/vpn/api/serializers_/crypto.py
index dac3bc519..50085884b 100644
--- a/netbox/vpn/api/serializers_/crypto.py
+++ b/netbox/vpn/api/serializers_/crypto.py
@@ -76,7 +76,8 @@ class IPSecProposalSerializer(NetBoxModelSerializer):
model = IPSecProposal
fields = (
'id', 'url', 'display_url', 'display', 'name', 'description', 'encryption_algorithm',
- 'authentication_algorithm', 'sa_lifetime_seconds', 'sa_lifetime_data', 'comments', 'tags', 'custom_fields', 'created', 'last_updated',
+ 'authentication_algorithm', 'sa_lifetime_seconds', 'sa_lifetime_data', 'comments', 'tags', 'custom_fields',
+ 'created', 'last_updated',
)
brief_fields = ('id', 'url', 'display', 'name', 'description')
diff --git a/netbox/vpn/forms/model_forms.py b/netbox/vpn/forms/model_forms.py
index a17ca9a5e..d6d02b4f5 100644
--- a/netbox/vpn/forms/model_forms.py
+++ b/netbox/vpn/forms/model_forms.py
@@ -47,7 +47,8 @@ class TunnelForm(TenancyForm, NetBoxModelForm):
group = DynamicModelChoiceField(
queryset=TunnelGroup.objects.all(),
label=_('Tunnel Group'),
- required=False
+ required=False,
+ quick_add=True
)
ipsec_profile = DynamicModelChoiceField(
queryset=IPSecProfile.objects.all(),
@@ -258,7 +259,7 @@ class TunnelTerminationForm(NetBoxModelForm):
class Meta:
model = TunnelTermination
fields = [
- 'tunnel', 'role', 'termination', 'outside_ip', 'tags',
+ 'tunnel', 'role', 'outside_ip', 'tags',
]
def __init__(self, *args, initial=None, **kwargs):
@@ -313,7 +314,8 @@ class IKEProposalForm(NetBoxModelForm):
class IKEPolicyForm(NetBoxModelForm):
proposals = DynamicModelMultipleChoiceField(
queryset=IKEProposal.objects.all(),
- label=_('Proposals')
+ label=_('Proposals'),
+ quick_add=True
)
fieldsets = (
@@ -349,7 +351,8 @@ class IPSecProposalForm(NetBoxModelForm):
class IPSecPolicyForm(NetBoxModelForm):
proposals = DynamicModelMultipleChoiceField(
queryset=IPSecProposal.objects.all(),
- label=_('Proposals')
+ label=_('Proposals'),
+ quick_add=True
)
fieldsets = (
diff --git a/netbox/vpn/migrations/0001_initial.py b/netbox/vpn/migrations/0001_initial.py
index 681474837..b44ae3e52 100644
--- a/netbox/vpn/migrations/0001_initial.py
+++ b/netbox/vpn/migrations/0001_initial.py
@@ -5,7 +5,6 @@ import utilities.json
class Migration(migrations.Migration):
-
initial = True
dependencies = [
@@ -23,7 +22,10 @@ class Migration(migrations.Migration):
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
('created', models.DateTimeField(auto_now_add=True, null=True)),
('last_updated', models.DateTimeField(auto_now=True, null=True)),
- ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=utilities.json.CustomFieldJSONEncoder)),
+ (
+ 'custom_field_data',
+ models.JSONField(blank=True, default=dict, encoder=utilities.json.CustomFieldJSONEncoder),
+ ),
('description', models.CharField(blank=True, max_length=200)),
('comments', models.TextField(blank=True)),
('name', models.CharField(max_length=100, unique=True)),
@@ -46,7 +48,10 @@ class Migration(migrations.Migration):
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
('created', models.DateTimeField(auto_now_add=True, null=True)),
('last_updated', models.DateTimeField(auto_now=True, null=True)),
- ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=utilities.json.CustomFieldJSONEncoder)),
+ (
+ 'custom_field_data',
+ models.JSONField(blank=True, default=dict, encoder=utilities.json.CustomFieldJSONEncoder),
+ ),
('description', models.CharField(blank=True, max_length=200)),
('comments', models.TextField(blank=True)),
('name', models.CharField(max_length=100, unique=True)),
@@ -70,7 +75,6 @@ class Migration(migrations.Migration):
name='tags',
field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'),
),
-
# IPSec
migrations.CreateModel(
name='IPSecProposal',
@@ -78,7 +82,10 @@ class Migration(migrations.Migration):
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
('created', models.DateTimeField(auto_now_add=True, null=True)),
('last_updated', models.DateTimeField(auto_now=True, null=True)),
- ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=utilities.json.CustomFieldJSONEncoder)),
+ (
+ 'custom_field_data',
+ models.JSONField(blank=True, default=dict, encoder=utilities.json.CustomFieldJSONEncoder),
+ ),
('description', models.CharField(blank=True, max_length=200)),
('comments', models.TextField(blank=True)),
('name', models.CharField(max_length=100, unique=True)),
@@ -100,7 +107,10 @@ class Migration(migrations.Migration):
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
('created', models.DateTimeField(auto_now_add=True, null=True)),
('last_updated', models.DateTimeField(auto_now=True, null=True)),
- ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=utilities.json.CustomFieldJSONEncoder)),
+ (
+ 'custom_field_data',
+ models.JSONField(blank=True, default=dict, encoder=utilities.json.CustomFieldJSONEncoder),
+ ),
('description', models.CharField(blank=True, max_length=200)),
('comments', models.TextField(blank=True)),
('name', models.CharField(max_length=100, unique=True)),
@@ -128,13 +138,26 @@ class Migration(migrations.Migration):
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
('created', models.DateTimeField(auto_now_add=True, null=True)),
('last_updated', models.DateTimeField(auto_now=True, null=True)),
- ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=utilities.json.CustomFieldJSONEncoder)),
+ (
+ 'custom_field_data',
+ models.JSONField(blank=True, default=dict, encoder=utilities.json.CustomFieldJSONEncoder),
+ ),
('description', models.CharField(blank=True, max_length=200)),
('comments', models.TextField(blank=True)),
('name', models.CharField(max_length=100, unique=True)),
('mode', models.CharField()),
- ('ike_policy', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='ipsec_profiles', to='vpn.ikepolicy')),
- ('ipsec_policy', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='ipsec_profiles', to='vpn.ipsecpolicy')),
+ (
+ 'ike_policy',
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.PROTECT, related_name='ipsec_profiles', to='vpn.ikepolicy'
+ ),
+ ),
+ (
+ 'ipsec_policy',
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.PROTECT, related_name='ipsec_profiles', to='vpn.ipsecpolicy'
+ ),
+ ),
('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')),
],
options={
@@ -143,7 +166,6 @@ class Migration(migrations.Migration):
'ordering': ('name',),
},
),
-
# Tunnels
migrations.CreateModel(
name='TunnelGroup',
@@ -151,7 +173,10 @@ class Migration(migrations.Migration):
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
('created', models.DateTimeField(auto_now_add=True, null=True)),
('last_updated', models.DateTimeField(auto_now=True, null=True)),
- ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=utilities.json.CustomFieldJSONEncoder)),
+ (
+ 'custom_field_data',
+ models.JSONField(blank=True, default=dict, encoder=utilities.json.CustomFieldJSONEncoder),
+ ),
('name', models.CharField(max_length=100, unique=True)),
('slug', models.SlugField(max_length=100, unique=True)),
('description', models.CharField(blank=True, max_length=200)),
@@ -173,17 +198,47 @@ class Migration(migrations.Migration):
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
('created', models.DateTimeField(auto_now_add=True, null=True)),
('last_updated', models.DateTimeField(auto_now=True, null=True)),
- ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=utilities.json.CustomFieldJSONEncoder)),
+ (
+ 'custom_field_data',
+ models.JSONField(blank=True, default=dict, encoder=utilities.json.CustomFieldJSONEncoder),
+ ),
('description', models.CharField(blank=True, max_length=200)),
('comments', models.TextField(blank=True)),
('name', models.CharField(max_length=100, unique=True)),
('status', models.CharField(default='active', max_length=50)),
- ('group', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='tunnels', to='vpn.tunnelgroup')),
+ (
+ 'group',
+ models.ForeignKey(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.PROTECT,
+ related_name='tunnels',
+ to='vpn.tunnelgroup',
+ ),
+ ),
('encapsulation', models.CharField(max_length=50)),
('tunnel_id', models.PositiveBigIntegerField(blank=True, null=True)),
- ('ipsec_profile', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='tunnels', to='vpn.ipsecprofile')),
+ (
+ 'ipsec_profile',
+ models.ForeignKey(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.PROTECT,
+ related_name='tunnels',
+ to='vpn.ipsecprofile',
+ ),
+ ),
('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')),
- ('tenant', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='tunnels', to='tenancy.tenant')),
+ (
+ 'tenant',
+ models.ForeignKey(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.PROTECT,
+ related_name='tunnels',
+ to='tenancy.tenant',
+ ),
+ ),
],
options={
'verbose_name': 'tunnel',
@@ -197,7 +252,9 @@ class Migration(migrations.Migration):
),
migrations.AddConstraint(
model_name='tunnel',
- constraint=models.UniqueConstraint(condition=models.Q(('group__isnull', True)), fields=('name',), name='vpn_tunnel_name'),
+ constraint=models.UniqueConstraint(
+ condition=models.Q(('group__isnull', True)), fields=('name',), name='vpn_tunnel_name'
+ ),
),
migrations.CreateModel(
name='TunnelTermination',
@@ -205,13 +262,35 @@ class Migration(migrations.Migration):
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
('created', models.DateTimeField(auto_now_add=True, null=True)),
('last_updated', models.DateTimeField(auto_now=True, null=True)),
- ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=utilities.json.CustomFieldJSONEncoder)),
+ (
+ 'custom_field_data',
+ models.JSONField(blank=True, default=dict, encoder=utilities.json.CustomFieldJSONEncoder),
+ ),
('role', models.CharField(default='peer', max_length=50)),
('termination_id', models.PositiveBigIntegerField(blank=True, null=True)),
- ('termination_type', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='+', to='contenttypes.contenttype')),
- ('outside_ip', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='tunnel_termination', to='ipam.ipaddress')),
+ (
+ 'termination_type',
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.PROTECT, related_name='+', to='contenttypes.contenttype'
+ ),
+ ),
+ (
+ 'outside_ip',
+ models.OneToOneField(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.PROTECT,
+ related_name='tunnel_termination',
+ to='ipam.ipaddress',
+ ),
+ ),
('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')),
- ('tunnel', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='terminations', to='vpn.tunnel')),
+ (
+ 'tunnel',
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE, related_name='terminations', to='vpn.tunnel'
+ ),
+ ),
],
options={
'verbose_name': 'tunnel termination',
@@ -225,6 +304,10 @@ class Migration(migrations.Migration):
),
migrations.AddConstraint(
model_name='tunneltermination',
- constraint=models.UniqueConstraint(fields=('termination_type', 'termination_id'), name='vpn_tunneltermination_termination', violation_error_message='An object may be terminated to only one tunnel at a time.'),
+ constraint=models.UniqueConstraint(
+ fields=('termination_type', 'termination_id'),
+ name='vpn_tunneltermination_termination',
+ violation_error_message='An object may be terminated to only one tunnel at a time.',
+ ),
),
]
diff --git a/netbox/vpn/migrations/0002_move_l2vpn.py b/netbox/vpn/migrations/0002_move_l2vpn.py
index b83ea4655..5f1480dce 100644
--- a/netbox/vpn/migrations/0002_move_l2vpn.py
+++ b/netbox/vpn/migrations/0002_move_l2vpn.py
@@ -5,7 +5,6 @@ import utilities.json
class Migration(migrations.Migration):
-
dependencies = [
('extras', '0099_cachedvalue_ordering'),
('contenttypes', '0002_remove_content_type_name'),
@@ -23,17 +22,35 @@ class Migration(migrations.Migration):
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
('created', models.DateTimeField(auto_now_add=True, null=True)),
('last_updated', models.DateTimeField(auto_now=True, null=True)),
- ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=utilities.json.CustomFieldJSONEncoder)),
+ (
+ 'custom_field_data',
+ models.JSONField(blank=True, default=dict, encoder=utilities.json.CustomFieldJSONEncoder),
+ ),
('description', models.CharField(blank=True, max_length=200)),
('comments', models.TextField(blank=True)),
('name', models.CharField(max_length=100, unique=True)),
('slug', models.SlugField(max_length=100, unique=True)),
('type', models.CharField(max_length=50)),
('identifier', models.BigIntegerField(blank=True, null=True)),
- ('export_targets', models.ManyToManyField(blank=True, related_name='exporting_l2vpns', to='ipam.routetarget')),
- ('import_targets', models.ManyToManyField(blank=True, related_name='importing_l2vpns', to='ipam.routetarget')),
+ (
+ 'export_targets',
+ models.ManyToManyField(blank=True, related_name='exporting_l2vpns', to='ipam.routetarget'),
+ ),
+ (
+ 'import_targets',
+ models.ManyToManyField(blank=True, related_name='importing_l2vpns', to='ipam.routetarget'),
+ ),
('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')),
- ('tenant', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='l2vpns', to='tenancy.tenant')),
+ (
+ 'tenant',
+ models.ForeignKey(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.PROTECT,
+ related_name='l2vpns',
+ to='tenancy.tenant',
+ ),
+ ),
],
options={
'verbose_name': 'L2VPN',
@@ -47,10 +64,33 @@ class Migration(migrations.Migration):
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
('created', models.DateTimeField(auto_now_add=True, null=True)),
('last_updated', models.DateTimeField(auto_now=True, null=True)),
- ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=utilities.json.CustomFieldJSONEncoder)),
+ (
+ 'custom_field_data',
+ models.JSONField(blank=True, default=dict, encoder=utilities.json.CustomFieldJSONEncoder),
+ ),
('assigned_object_id', models.PositiveBigIntegerField()),
- ('assigned_object_type', models.ForeignKey(limit_choices_to=models.Q(models.Q(models.Q(('app_label', 'dcim'), ('model', 'interface')), models.Q(('app_label', 'ipam'), ('model', 'vlan')), models.Q(('app_label', 'virtualization'), ('model', 'vminterface')), _connector='OR')), on_delete=django.db.models.deletion.PROTECT, related_name='+', to='contenttypes.contenttype')),
- ('l2vpn', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='terminations', to='vpn.l2vpn')),
+ (
+ 'assigned_object_type',
+ models.ForeignKey(
+ limit_choices_to=models.Q(
+ models.Q(
+ models.Q(('app_label', 'dcim'), ('model', 'interface')),
+ models.Q(('app_label', 'ipam'), ('model', 'vlan')),
+ models.Q(('app_label', 'virtualization'), ('model', 'vminterface')),
+ _connector='OR',
+ )
+ ),
+ on_delete=django.db.models.deletion.PROTECT,
+ related_name='+',
+ to='contenttypes.contenttype',
+ ),
+ ),
+ (
+ 'l2vpn',
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE, related_name='terminations', to='vpn.l2vpn'
+ ),
+ ),
('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')),
],
options={
@@ -66,12 +106,13 @@ class Migration(migrations.Migration):
migrations.AddConstraint(
model_name='l2vpntermination',
constraint=models.UniqueConstraint(
- fields=('assigned_object_type', 'assigned_object_id'),
- name='vpn_l2vpntermination_assigned_object'
+ fields=('assigned_object_type', 'assigned_object_id'), name='vpn_l2vpntermination_assigned_object'
),
),
migrations.AddIndex(
model_name='l2vpntermination',
- index=models.Index(fields=['assigned_object_type', 'assigned_object_id'], name='vpn_l2vpnte_assigne_9c55f8_idx'),
+ index=models.Index(
+ fields=['assigned_object_type', 'assigned_object_id'], name='vpn_l2vpnte_assigne_9c55f8_idx'
+ ),
),
]
diff --git a/netbox/vpn/migrations/0003_ipaddress_multiple_tunnel_terminations.py b/netbox/vpn/migrations/0003_ipaddress_multiple_tunnel_terminations.py
index 2747669ae..ce042b4db 100644
--- a/netbox/vpn/migrations/0003_ipaddress_multiple_tunnel_terminations.py
+++ b/netbox/vpn/migrations/0003_ipaddress_multiple_tunnel_terminations.py
@@ -5,7 +5,6 @@ import django.db.models.deletion
class Migration(migrations.Migration):
-
dependencies = [
('ipam', '0069_gfk_indexes'),
('vpn', '0002_move_l2vpn'),
@@ -15,6 +14,12 @@ class Migration(migrations.Migration):
migrations.AlterField(
model_name='tunneltermination',
name='outside_ip',
- field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='tunnel_terminations', to='ipam.ipaddress'),
+ field=models.ForeignKey(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.PROTECT,
+ related_name='tunnel_terminations',
+ to='ipam.ipaddress',
+ ),
),
]
diff --git a/netbox/vpn/migrations/0004_alter_ikepolicy_mode.py b/netbox/vpn/migrations/0004_alter_ikepolicy_mode.py
index 40dd4f99e..44bf4d35b 100644
--- a/netbox/vpn/migrations/0004_alter_ikepolicy_mode.py
+++ b/netbox/vpn/migrations/0004_alter_ikepolicy_mode.py
@@ -4,7 +4,6 @@ from django.db import migrations, models
class Migration(migrations.Migration):
-
dependencies = [
('vpn', '0003_ipaddress_multiple_tunnel_terminations'),
]
diff --git a/netbox/vpn/migrations/0005_rename_indexes.py b/netbox/vpn/migrations/0005_rename_indexes.py
index 805b380cc..f24106c1f 100644
--- a/netbox/vpn/migrations/0005_rename_indexes.py
+++ b/netbox/vpn/migrations/0005_rename_indexes.py
@@ -2,43 +2,56 @@ from django.db import migrations
class Migration(migrations.Migration):
-
dependencies = [
('vpn', '0004_alter_ikepolicy_mode'),
]
operations = [
-
# Rename vpn_l2vpn constraints
- migrations.RunSQL("ALTER TABLE vpn_l2vpn RENAME CONSTRAINT ipam_l2vpn_tenant_id_bb2564a6_fk_tenancy_tenant_id TO vpn_l2vpn_tenant_id_57ec8f92_fk_tenancy_tenant_id"),
-
+ migrations.RunSQL((
+ 'ALTER TABLE vpn_l2vpn '
+ 'RENAME CONSTRAINT ipam_l2vpn_tenant_id_bb2564a6_fk_tenancy_tenant_id '
+ 'TO vpn_l2vpn_tenant_id_57ec8f92_fk_tenancy_tenant_id'
+ )),
# Rename ipam_l2vpn_* sequences
- migrations.RunSQL("ALTER TABLE ipam_l2vpn_export_targets_id_seq RENAME TO vpn_l2vpn_export_targets_id_seq"),
- migrations.RunSQL("ALTER TABLE ipam_l2vpn_id_seq RENAME TO vpn_l2vpn_id_seq"),
- migrations.RunSQL("ALTER TABLE ipam_l2vpn_import_targets_id_seq RENAME TO vpn_l2vpn_import_targets_id_seq"),
-
+ migrations.RunSQL('ALTER TABLE ipam_l2vpn_export_targets_id_seq RENAME TO vpn_l2vpn_export_targets_id_seq'),
+ migrations.RunSQL('ALTER TABLE ipam_l2vpn_id_seq RENAME TO vpn_l2vpn_id_seq'),
+ migrations.RunSQL('ALTER TABLE ipam_l2vpn_import_targets_id_seq RENAME TO vpn_l2vpn_import_targets_id_seq'),
# Rename ipam_l2vpn_* indexes
- migrations.RunSQL("ALTER INDEX ipam_l2vpn_pkey RENAME TO vpn_l2vpn_pkey"),
- migrations.RunSQL("ALTER INDEX ipam_l2vpn_name_5e1c080f_like RENAME TO vpn_l2vpn_name_8824eda5_like"),
- migrations.RunSQL("ALTER INDEX ipam_l2vpn_name_key RENAME TO vpn_l2vpn_name_key"),
- migrations.RunSQL("ALTER INDEX ipam_l2vpn_slug_24008406_like RENAME TO vpn_l2vpn_slug_76b5a174_like"),
- migrations.RunSQL("ALTER INDEX ipam_l2vpn_tenant_id_bb2564a6 RENAME TO vpn_l2vpn_tenant_id_57ec8f92"),
+ migrations.RunSQL('ALTER INDEX ipam_l2vpn_pkey RENAME TO vpn_l2vpn_pkey'),
+ migrations.RunSQL('ALTER INDEX ipam_l2vpn_name_5e1c080f_like RENAME TO vpn_l2vpn_name_8824eda5_like'),
+ migrations.RunSQL('ALTER INDEX ipam_l2vpn_name_key RENAME TO vpn_l2vpn_name_key'),
+ migrations.RunSQL('ALTER INDEX ipam_l2vpn_slug_24008406_like RENAME TO vpn_l2vpn_slug_76b5a174_like'),
+ migrations.RunSQL('ALTER INDEX ipam_l2vpn_tenant_id_bb2564a6 RENAME TO vpn_l2vpn_tenant_id_57ec8f92'),
# The unique index for L2VPN.slug may have one of two names, depending on how it was created,
# so we check for both.
- migrations.RunSQL("ALTER INDEX IF EXISTS ipam_l2vpn_slug_24008406_uniq RENAME TO vpn_l2vpn_slug_76b5a174_uniq"),
- migrations.RunSQL("ALTER INDEX IF EXISTS ipam_l2vpn_slug_key RENAME TO vpn_l2vpn_slug_key"),
-
+ migrations.RunSQL('ALTER INDEX IF EXISTS ipam_l2vpn_slug_24008406_uniq RENAME TO vpn_l2vpn_slug_76b5a174_uniq'),
+ migrations.RunSQL('ALTER INDEX IF EXISTS ipam_l2vpn_slug_key RENAME TO vpn_l2vpn_slug_key'),
# Rename vpn_l2vpntermination constraints
- migrations.RunSQL("ALTER TABLE vpn_l2vpntermination RENAME CONSTRAINT ipam_l2vpntermination_assigned_object_id_check TO vpn_l2vpntermination_assigned_object_id_check"),
- migrations.RunSQL("ALTER TABLE vpn_l2vpntermination RENAME CONSTRAINT ipam_l2vpnterminatio_assigned_object_type_3923c124_fk_django_co TO vpn_l2vpntermination_assigned_object_type_id_f063b865_fk_django_co"),
- migrations.RunSQL("ALTER TABLE vpn_l2vpntermination RENAME CONSTRAINT ipam_l2vpntermination_l2vpn_id_9e570aa1_fk_ipam_l2vpn_id TO vpn_l2vpntermination_l2vpn_id_f5367bbe_fk_vpn_l2vpn_id"),
-
+ migrations.RunSQL((
+ 'ALTER TABLE vpn_l2vpntermination '
+ 'RENAME CONSTRAINT ipam_l2vpntermination_assigned_object_id_check '
+ 'TO vpn_l2vpntermination_assigned_object_id_check'
+ )),
+ migrations.RunSQL((
+ 'ALTER TABLE vpn_l2vpntermination '
+ 'RENAME CONSTRAINT ipam_l2vpnterminatio_assigned_object_type_3923c124_fk_django_co '
+ 'TO vpn_l2vpntermination_assigned_object_type_id_f063b865_fk_django_co'
+ )),
+ migrations.RunSQL((
+ 'ALTER TABLE vpn_l2vpntermination '
+ 'RENAME CONSTRAINT ipam_l2vpntermination_l2vpn_id_9e570aa1_fk_ipam_l2vpn_id '
+ 'TO vpn_l2vpntermination_l2vpn_id_f5367bbe_fk_vpn_l2vpn_id'
+ )),
# Rename ipam_l2vpn_termination_* sequences
- migrations.RunSQL("ALTER TABLE ipam_l2vpntermination_id_seq RENAME TO vpn_l2vpntermination_id_seq"),
-
+ migrations.RunSQL('ALTER TABLE ipam_l2vpntermination_id_seq RENAME TO vpn_l2vpntermination_id_seq'),
# Rename ipam_l2vpn_* indexes
- migrations.RunSQL("ALTER INDEX ipam_l2vpntermination_pkey RENAME TO vpn_l2vpntermination_pkey"),
- migrations.RunSQL("ALTER INDEX ipam_l2vpntermination_assigned_object_type_id_3923c124 RENAME TO vpn_l2vpntermination_assigned_object_type_id_f063b865"),
- migrations.RunSQL("ALTER INDEX ipam_l2vpntermination_l2vpn_id_9e570aa1 RENAME TO vpn_l2vpntermination_l2vpn_id_f5367bbe"),
-
+ migrations.RunSQL('ALTER INDEX ipam_l2vpntermination_pkey RENAME TO vpn_l2vpntermination_pkey'),
+ migrations.RunSQL((
+ 'ALTER INDEX ipam_l2vpntermination_assigned_object_type_id_3923c124 '
+ 'RENAME TO vpn_l2vpntermination_assigned_object_type_id_f063b865'
+ )),
+ migrations.RunSQL(
+ 'ALTER INDEX ipam_l2vpntermination_l2vpn_id_9e570aa1 RENAME TO vpn_l2vpntermination_l2vpn_id_f5367bbe'
+ ),
]
diff --git a/netbox/vpn/migrations/0006_charfield_null_choices.py b/netbox/vpn/migrations/0006_charfield_null_choices.py
new file mode 100644
index 000000000..784b66d72
--- /dev/null
+++ b/netbox/vpn/migrations/0006_charfield_null_choices.py
@@ -0,0 +1,45 @@
+from django.db import migrations, models
+
+
+def set_null_values(apps, schema_editor):
+ """
+ Replace empty strings with null values.
+ """
+ IKEPolicy = apps.get_model('vpn', 'IKEPolicy')
+ IKEProposal = apps.get_model('vpn', 'IKEProposal')
+ IPSecProposal = apps.get_model('vpn', 'IPSecProposal')
+
+ IKEPolicy.objects.filter(mode='').update(mode=None)
+ IKEProposal.objects.filter(authentication_algorithm='').update(authentication_algorithm=None)
+ IPSecProposal.objects.filter(authentication_algorithm='').update(authentication_algorithm=None)
+ IPSecProposal.objects.filter(encryption_algorithm='').update(encryption_algorithm=None)
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ('vpn', '0005_rename_indexes'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='ikepolicy',
+ name='mode',
+ field=models.CharField(blank=True, null=True),
+ ),
+ migrations.AlterField(
+ model_name='ikeproposal',
+ name='authentication_algorithm',
+ field=models.CharField(blank=True, null=True),
+ ),
+ migrations.AlterField(
+ model_name='ipsecproposal',
+ name='authentication_algorithm',
+ field=models.CharField(blank=True, null=True),
+ ),
+ migrations.AlterField(
+ model_name='ipsecproposal',
+ name='encryption_algorithm',
+ field=models.CharField(blank=True, null=True),
+ ),
+ migrations.RunPython(code=set_null_values, reverse_code=migrations.RunPython.noop),
+ ]
diff --git a/netbox/vpn/migrations/0007_natural_ordering.py b/netbox/vpn/migrations/0007_natural_ordering.py
new file mode 100644
index 000000000..3eb8ab5a9
--- /dev/null
+++ b/netbox/vpn/migrations/0007_natural_ordering.py
@@ -0,0 +1,46 @@
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ('vpn', '0006_charfield_null_choices'),
+ ('dcim', '0197_natural_sort_collation'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='ikepolicy',
+ name='name',
+ field=models.CharField(db_collation='natural_sort', max_length=100, unique=True),
+ ),
+ migrations.AlterField(
+ model_name='ikeproposal',
+ name='name',
+ field=models.CharField(db_collation='natural_sort', max_length=100, unique=True),
+ ),
+ migrations.AlterField(
+ model_name='ipsecpolicy',
+ name='name',
+ field=models.CharField(db_collation='natural_sort', max_length=100, unique=True),
+ ),
+ migrations.AlterField(
+ model_name='ipsecprofile',
+ name='name',
+ field=models.CharField(db_collation='natural_sort', max_length=100, unique=True),
+ ),
+ migrations.AlterField(
+ model_name='ipsecproposal',
+ name='name',
+ field=models.CharField(db_collation='natural_sort', max_length=100, unique=True),
+ ),
+ migrations.AlterField(
+ model_name='l2vpn',
+ name='name',
+ field=models.CharField(db_collation='natural_sort', max_length=100, unique=True),
+ ),
+ migrations.AlterField(
+ model_name='tunnel',
+ name='name',
+ field=models.CharField(db_collation='natural_sort', max_length=100, unique=True),
+ ),
+ ]
diff --git a/netbox/vpn/models/crypto.py b/netbox/vpn/models/crypto.py
index 2769430fd..8e991b578 100644
--- a/netbox/vpn/models/crypto.py
+++ b/netbox/vpn/models/crypto.py
@@ -1,6 +1,5 @@
from django.core.exceptions import ValidationError
from django.db import models
-from django.urls import reverse
from django.utils.translation import gettext_lazy as _
from netbox.models import PrimaryModel
@@ -23,7 +22,8 @@ class IKEProposal(PrimaryModel):
name = models.CharField(
verbose_name=_('name'),
max_length=100,
- unique=True
+ unique=True,
+ db_collation="natural_sort"
)
authentication_method = models.CharField(
verbose_name=('authentication method'),
@@ -36,7 +36,8 @@ class IKEProposal(PrimaryModel):
authentication_algorithm = models.CharField(
verbose_name=_('authentication algorithm'),
choices=AuthenticationAlgorithmChoices,
- blank=True
+ blank=True,
+ null=True
)
group = models.PositiveSmallIntegerField(
verbose_name=_('group'),
@@ -62,15 +63,13 @@ class IKEProposal(PrimaryModel):
def __str__(self):
return self.name
- def get_absolute_url(self):
- return reverse('vpn:ikeproposal', args=[self.pk])
-
class IKEPolicy(PrimaryModel):
name = models.CharField(
verbose_name=_('name'),
max_length=100,
- unique=True
+ unique=True,
+ db_collation="natural_sort"
)
version = models.PositiveSmallIntegerField(
verbose_name=_('version'),
@@ -80,7 +79,8 @@ class IKEPolicy(PrimaryModel):
mode = models.CharField(
verbose_name=_('mode'),
choices=IKEModeChoices,
- blank=True
+ blank=True,
+ null=True
)
proposals = models.ManyToManyField(
to='vpn.IKEProposal',
@@ -107,9 +107,6 @@ class IKEPolicy(PrimaryModel):
def __str__(self):
return self.name
- def get_absolute_url(self):
- return reverse('vpn:ikepolicy', args=[self.pk])
-
def clean(self):
super().clean()
@@ -130,17 +127,20 @@ class IPSecProposal(PrimaryModel):
name = models.CharField(
verbose_name=_('name'),
max_length=100,
- unique=True
+ unique=True,
+ db_collation="natural_sort"
)
encryption_algorithm = models.CharField(
verbose_name=_('encryption'),
choices=EncryptionAlgorithmChoices,
- blank=True
+ blank=True,
+ null=True
)
authentication_algorithm = models.CharField(
verbose_name=_('authentication'),
choices=AuthenticationAlgorithmChoices,
- blank=True
+ blank=True,
+ null=True
)
sa_lifetime_seconds = models.PositiveIntegerField(
verbose_name=_('SA lifetime (seconds)'),
@@ -167,9 +167,6 @@ class IPSecProposal(PrimaryModel):
def __str__(self):
return self.name
- def get_absolute_url(self):
- return reverse('vpn:ipsecproposal', args=[self.pk])
-
def clean(self):
super().clean()
@@ -182,7 +179,8 @@ class IPSecPolicy(PrimaryModel):
name = models.CharField(
verbose_name=_('name'),
max_length=100,
- unique=True
+ unique=True,
+ db_collation="natural_sort"
)
proposals = models.ManyToManyField(
to='vpn.IPSecProposal',
@@ -212,15 +210,13 @@ class IPSecPolicy(PrimaryModel):
def __str__(self):
return self.name
- def get_absolute_url(self):
- return reverse('vpn:ipsecpolicy', args=[self.pk])
-
class IPSecProfile(PrimaryModel):
name = models.CharField(
verbose_name=_('name'),
max_length=100,
- unique=True
+ unique=True,
+ db_collation="natural_sort"
)
mode = models.CharField(
verbose_name=_('mode'),
@@ -252,6 +248,3 @@ class IPSecProfile(PrimaryModel):
def __str__(self):
return self.name
-
- def get_absolute_url(self):
- return reverse('vpn:ipsecprofile', args=[self.pk])
diff --git a/netbox/vpn/models/l2vpn.py b/netbox/vpn/models/l2vpn.py
index 39956edc8..3e562531d 100644
--- a/netbox/vpn/models/l2vpn.py
+++ b/netbox/vpn/models/l2vpn.py
@@ -1,7 +1,6 @@
from django.contrib.contenttypes.fields import GenericForeignKey
from django.core.exceptions import ValidationError
from django.db import models
-from django.urls import reverse
from django.utils.functional import cached_property
from django.utils.translation import gettext_lazy as _
@@ -21,7 +20,8 @@ class L2VPN(ContactsMixin, PrimaryModel):
name = models.CharField(
verbose_name=_('name'),
max_length=100,
- unique=True
+ unique=True,
+ db_collation="natural_sort"
)
slug = models.SlugField(
verbose_name=_('slug'),
@@ -68,9 +68,6 @@ class L2VPN(ContactsMixin, PrimaryModel):
return f'{self.name} ({self.identifier})'
return f'{self.name}'
- def get_absolute_url(self):
- return reverse('vpn:l2vpn', args=[self.pk])
-
@cached_property
def can_add_termination(self):
if self.type in L2VPNTypeChoices.P2P and self.terminations.count() >= 2:
@@ -121,9 +118,6 @@ class L2VPNTermination(NetBoxModel):
return f'{self.assigned_object} <> {self.l2vpn}'
return super().__str__()
- def get_absolute_url(self):
- return reverse('vpn:l2vpntermination', args=[self.pk])
-
def clean(self):
# Only check is assigned_object is set. Required otherwise we have an Integrity Error thrown.
if self.assigned_object:
diff --git a/netbox/vpn/models/tunnels.py b/netbox/vpn/models/tunnels.py
index 6f4fa4182..714024a81 100644
--- a/netbox/vpn/models/tunnels.py
+++ b/netbox/vpn/models/tunnels.py
@@ -26,15 +26,13 @@ class TunnelGroup(OrganizationalModel):
verbose_name = _('tunnel group')
verbose_name_plural = _('tunnel groups')
- def get_absolute_url(self):
- return reverse('vpn:tunnelgroup', args=[self.pk])
-
class Tunnel(PrimaryModel):
name = models.CharField(
verbose_name=_('name'),
max_length=100,
- unique=True
+ unique=True,
+ db_collation="natural_sort"
)
status = models.CharField(
verbose_name=_('status'),
@@ -97,9 +95,6 @@ class Tunnel(PrimaryModel):
def __str__(self):
return self.name
- def get_absolute_url(self):
- return reverse('vpn:tunnel', args=[self.pk])
-
def get_status_color(self):
return TunnelStatusChoices.colors.get(self.status)
diff --git a/netbox/vpn/urls.py b/netbox/vpn/urls.py
index 552f0e185..1169dcd15 100644
--- a/netbox/vpn/urls.py
+++ b/netbox/vpn/urls.py
@@ -1,89 +1,39 @@
from django.urls import include, path
from utilities.urls import get_model_urls
-from . import views
+from . import views # noqa F401
app_name = 'vpn'
urlpatterns = [
- # Tunnel groups
- path('tunnel-groups/', views.TunnelGroupListView.as_view(), name='tunnelgroup_list'),
- path('tunnel-groups/add/', views.TunnelGroupEditView.as_view(), name='tunnelgroup_add'),
- path('tunnel-groups/import/', views.TunnelGroupBulkImportView.as_view(), name='tunnelgroup_import'),
- path('tunnel-groups/edit/', views.TunnelGroupBulkEditView.as_view(), name='tunnelgroup_bulk_edit'),
- path('tunnel-groups/delete/', views.TunnelGroupBulkDeleteView.as_view(), name='tunnelgroup_bulk_delete'),
+ path('tunnel-groups/', include(get_model_urls('vpn', 'tunnelgroup', detail=False))),
path('tunnel-groups//', include(get_model_urls('vpn', 'tunnelgroup'))),
- # Tunnels
- path('tunnels/', views.TunnelListView.as_view(), name='tunnel_list'),
- path('tunnels/add/', views.TunnelEditView.as_view(), name='tunnel_add'),
- path('tunnels/import/', views.TunnelBulkImportView.as_view(), name='tunnel_import'),
- path('tunnels/edit/', views.TunnelBulkEditView.as_view(), name='tunnel_bulk_edit'),
- path('tunnels/delete/', views.TunnelBulkDeleteView.as_view(), name='tunnel_bulk_delete'),
+ path('tunnels/', include(get_model_urls('vpn', 'tunnel', detail=False))),
path('tunnels//', include(get_model_urls('vpn', 'tunnel'))),
- # Tunnel terminations
- path('tunnel-terminations/', views.TunnelTerminationListView.as_view(), name='tunneltermination_list'),
- path('tunnel-terminations/add/', views.TunnelTerminationEditView.as_view(), name='tunneltermination_add'),
- path('tunnel-terminations/import/', views.TunnelTerminationBulkImportView.as_view(), name='tunneltermination_import'),
- path('tunnel-terminations/edit/', views.TunnelTerminationBulkEditView.as_view(), name='tunneltermination_bulk_edit'),
- path('tunnel-terminations/delete/', views.TunnelTerminationBulkDeleteView.as_view(), name='tunneltermination_bulk_delete'),
+ path('tunnel-terminations/', include(get_model_urls('vpn', 'tunneltermination', detail=False))),
path('tunnel-terminations//', include(get_model_urls('vpn', 'tunneltermination'))),
- # IKE proposals
- path('ike-proposals/', views.IKEProposalListView.as_view(), name='ikeproposal_list'),
- path('ike-proposals/add/', views.IKEProposalEditView.as_view(), name='ikeproposal_add'),
- path('ike-proposals/import/', views.IKEProposalBulkImportView.as_view(), name='ikeproposal_import'),
- path('ike-proposals/edit/', views.IKEProposalBulkEditView.as_view(), name='ikeproposal_bulk_edit'),
- path('ike-proposals/delete/', views.IKEProposalBulkDeleteView.as_view(), name='ikeproposal_bulk_delete'),
+ path('ike-proposals/', include(get_model_urls('vpn', 'ikeproposal', detail=False))),
path('ike-proposals//', include(get_model_urls('vpn', 'ikeproposal'))),
- # IKE policies
- path('ike-policies/', views.IKEPolicyListView.as_view(), name='ikepolicy_list'),
- path('ike-policies/add/', views.IKEPolicyEditView.as_view(), name='ikepolicy_add'),
- path('ike-policies/import/', views.IKEPolicyBulkImportView.as_view(), name='ikepolicy_import'),
- path('ike-policies/edit/', views.IKEPolicyBulkEditView.as_view(), name='ikepolicy_bulk_edit'),
- path('ike-policies/delete/', views.IKEPolicyBulkDeleteView.as_view(), name='ikepolicy_bulk_delete'),
+ path('ike-policies/', include(get_model_urls('vpn', 'ikepolicy', detail=False))),
path('ike-policies//', include(get_model_urls('vpn', 'ikepolicy'))),
- # IPSec proposals
- path('ipsec-proposals/', views.IPSecProposalListView.as_view(), name='ipsecproposal_list'),
- path('ipsec-proposals/add/', views.IPSecProposalEditView.as_view(), name='ipsecproposal_add'),
- path('ipsec-proposals/import/', views.IPSecProposalBulkImportView.as_view(), name='ipsecproposal_import'),
- path('ipsec-proposals/edit/', views.IPSecProposalBulkEditView.as_view(), name='ipsecproposal_bulk_edit'),
- path('ipsec-proposals/delete/', views.IPSecProposalBulkDeleteView.as_view(), name='ipsecproposal_bulk_delete'),
+ path('ipsec-proposals/', include(get_model_urls('vpn', 'ipsecproposal', detail=False))),
path('ipsec-proposals//', include(get_model_urls('vpn', 'ipsecproposal'))),
- # IPSec policies
- path('ipsec-policies/', views.IPSecPolicyListView.as_view(), name='ipsecpolicy_list'),
- path('ipsec-policies/add/', views.IPSecPolicyEditView.as_view(), name='ipsecpolicy_add'),
- path('ipsec-policies/import/', views.IPSecPolicyBulkImportView.as_view(), name='ipsecpolicy_import'),
- path('ipsec-policies/edit/', views.IPSecPolicyBulkEditView.as_view(), name='ipsecpolicy_bulk_edit'),
- path('ipsec-policies/delete/', views.IPSecPolicyBulkDeleteView.as_view(), name='ipsecpolicy_bulk_delete'),
+ path('ipsec-policies/', include(get_model_urls('vpn', 'ipsecpolicy', detail=False))),
path('ipsec-policies//', include(get_model_urls('vpn', 'ipsecpolicy'))),
- # IPSec profiles
- path('ipsec-profiles/', views.IPSecProfileListView.as_view(), name='ipsecprofile_list'),
- path('ipsec-profiles/add/', views.IPSecProfileEditView.as_view(), name='ipsecprofile_add'),
- path('ipsec-profiles/import/', views.IPSecProfileBulkImportView.as_view(), name='ipsecprofile_import'),
- path('ipsec-profiles/edit/', views.IPSecProfileBulkEditView.as_view(), name='ipsecprofile_bulk_edit'),
- path('ipsec-profiles/delete/', views.IPSecProfileBulkDeleteView.as_view(), name='ipsecprofile_bulk_delete'),
+ path('ipsec-profiles/', include(get_model_urls('vpn', 'ipsecprofile', detail=False))),
path('ipsec-profiles//', include(get_model_urls('vpn', 'ipsecprofile'))),
- # L2VPN
- path('l2vpns/', views.L2VPNListView.as_view(), name='l2vpn_list'),
- path('l2vpns/add/', views.L2VPNEditView.as_view(), name='l2vpn_add'),
- path('l2vpns/import/', views.L2VPNBulkImportView.as_view(), name='l2vpn_import'),
- path('l2vpns/edit/', views.L2VPNBulkEditView.as_view(), name='l2vpn_bulk_edit'),
- path('l2vpns/delete/', views.L2VPNBulkDeleteView.as_view(), name='l2vpn_bulk_delete'),
+ path('l2vpns/', include(get_model_urls('vpn', 'l2vpn', detail=False))),
path('l2vpns//', include(get_model_urls('vpn', 'l2vpn'))),
- # L2VPN terminations
- path('l2vpn-terminations/', views.L2VPNTerminationListView.as_view(), name='l2vpntermination_list'),
- path('l2vpn-terminations/add/', views.L2VPNTerminationEditView.as_view(), name='l2vpntermination_add'),
- path('l2vpn-terminations/import/', views.L2VPNTerminationBulkImportView.as_view(), name='l2vpntermination_import'),
- path('l2vpn-terminations/edit/', views.L2VPNTerminationBulkEditView.as_view(), name='l2vpntermination_bulk_edit'),
- path('l2vpn-terminations/delete/', views.L2VPNTerminationBulkDeleteView.as_view(), name='l2vpntermination_bulk_delete'),
+ path('l2vpn-terminations/', include(get_model_urls('vpn', 'l2vpntermination', detail=False))),
path('l2vpn-terminations//', include(get_model_urls('vpn', 'l2vpntermination'))),
]
diff --git a/netbox/vpn/views.py b/netbox/vpn/views.py
index ac8ce3667..3372e9412 100644
--- a/netbox/vpn/views.py
+++ b/netbox/vpn/views.py
@@ -11,6 +11,7 @@ from .models import *
# Tunnel groups
#
+@register_model_view(TunnelGroup, 'list', path='', detail=False)
class TunnelGroupListView(generic.ObjectListView):
queryset = TunnelGroup.objects.annotate(
tunnel_count=count_related(Tunnel, 'group')
@@ -30,6 +31,7 @@ class TunnelGroupView(GetRelatedModelsMixin, generic.ObjectView):
}
+@register_model_view(TunnelGroup, 'add', detail=False)
@register_model_view(TunnelGroup, 'edit')
class TunnelGroupEditView(generic.ObjectEditView):
queryset = TunnelGroup.objects.all()
@@ -41,11 +43,13 @@ class TunnelGroupDeleteView(generic.ObjectDeleteView):
queryset = TunnelGroup.objects.all()
+@register_model_view(TunnelGroup, 'bulk_import', detail=False)
class TunnelGroupBulkImportView(generic.BulkImportView):
queryset = TunnelGroup.objects.all()
model_form = forms.TunnelGroupImportForm
+@register_model_view(TunnelGroup, 'bulk_edit', path='edit', detail=False)
class TunnelGroupBulkEditView(generic.BulkEditView):
queryset = TunnelGroup.objects.annotate(
tunnel_count=count_related(Tunnel, 'group')
@@ -55,6 +59,7 @@ class TunnelGroupBulkEditView(generic.BulkEditView):
form = forms.TunnelGroupBulkEditForm
+@register_model_view(TunnelGroup, 'bulk_delete', path='delete', detail=False)
class TunnelGroupBulkDeleteView(generic.BulkDeleteView):
queryset = TunnelGroup.objects.annotate(
tunnel_count=count_related(Tunnel, 'group')
@@ -67,6 +72,7 @@ class TunnelGroupBulkDeleteView(generic.BulkDeleteView):
# Tunnels
#
+@register_model_view(Tunnel, 'list', path='', detail=False)
class TunnelListView(generic.ObjectListView):
queryset = Tunnel.objects.annotate(
count_terminations=count_related(TunnelTermination, 'tunnel')
@@ -81,6 +87,7 @@ class TunnelView(generic.ObjectView):
queryset = Tunnel.objects.all()
+@register_model_view(Tunnel, 'add', detail=False)
@register_model_view(Tunnel, 'edit')
class TunnelEditView(generic.ObjectEditView):
queryset = Tunnel.objects.all()
@@ -100,11 +107,13 @@ class TunnelDeleteView(generic.ObjectDeleteView):
queryset = Tunnel.objects.all()
+@register_model_view(Tunnel, 'bulk_import', detail=False)
class TunnelBulkImportView(generic.BulkImportView):
queryset = Tunnel.objects.all()
model_form = forms.TunnelImportForm
+@register_model_view(Tunnel, 'bulk_edit', path='edit', detail=False)
class TunnelBulkEditView(generic.BulkEditView):
queryset = Tunnel.objects.annotate(
count_terminations=count_related(TunnelTermination, 'tunnel')
@@ -114,6 +123,7 @@ class TunnelBulkEditView(generic.BulkEditView):
form = forms.TunnelBulkEditForm
+@register_model_view(Tunnel, 'bulk_delete', path='delete', detail=False)
class TunnelBulkDeleteView(generic.BulkDeleteView):
queryset = Tunnel.objects.annotate(
count_terminations=count_related(TunnelTermination, 'tunnel')
@@ -126,6 +136,7 @@ class TunnelBulkDeleteView(generic.BulkDeleteView):
# Tunnel terminations
#
+@register_model_view(TunnelTermination, 'list', path='', detail=False)
class TunnelTerminationListView(generic.ObjectListView):
queryset = TunnelTermination.objects.all()
filterset = filtersets.TunnelTerminationFilterSet
@@ -138,6 +149,7 @@ class TunnelTerminationView(generic.ObjectView):
queryset = TunnelTermination.objects.all()
+@register_model_view(TunnelTermination, 'add', detail=False)
@register_model_view(TunnelTermination, 'edit')
class TunnelTerminationEditView(generic.ObjectEditView):
queryset = TunnelTermination.objects.all()
@@ -149,11 +161,13 @@ class TunnelTerminationDeleteView(generic.ObjectDeleteView):
queryset = TunnelTermination.objects.all()
+@register_model_view(TunnelTermination, 'bulk_import', detail=False)
class TunnelTerminationBulkImportView(generic.BulkImportView):
queryset = TunnelTermination.objects.all()
model_form = forms.TunnelTerminationImportForm
+@register_model_view(TunnelTermination, 'bulk_edit', path='edit', detail=False)
class TunnelTerminationBulkEditView(generic.BulkEditView):
queryset = TunnelTermination.objects.all()
filterset = filtersets.TunnelTerminationFilterSet
@@ -161,6 +175,7 @@ class TunnelTerminationBulkEditView(generic.BulkEditView):
form = forms.TunnelTerminationBulkEditForm
+@register_model_view(TunnelTermination, 'bulk_delete', path='delete', detail=False)
class TunnelTerminationBulkDeleteView(generic.BulkDeleteView):
queryset = TunnelTermination.objects.all()
filterset = filtersets.TunnelTerminationFilterSet
@@ -171,6 +186,7 @@ class TunnelTerminationBulkDeleteView(generic.BulkDeleteView):
# IKE proposals
#
+@register_model_view(IKEProposal, 'list', path='', detail=False)
class IKEProposalListView(generic.ObjectListView):
queryset = IKEProposal.objects.all()
filterset = filtersets.IKEProposalFilterSet
@@ -183,6 +199,7 @@ class IKEProposalView(generic.ObjectView):
queryset = IKEProposal.objects.all()
+@register_model_view(IKEProposal, 'add', detail=False)
@register_model_view(IKEProposal, 'edit')
class IKEProposalEditView(generic.ObjectEditView):
queryset = IKEProposal.objects.all()
@@ -194,11 +211,13 @@ class IKEProposalDeleteView(generic.ObjectDeleteView):
queryset = IKEProposal.objects.all()
+@register_model_view(IKEProposal, 'bulk_import', detail=False)
class IKEProposalBulkImportView(generic.BulkImportView):
queryset = IKEProposal.objects.all()
model_form = forms.IKEProposalImportForm
+@register_model_view(IKEProposal, 'bulk_edit', path='edit', detail=False)
class IKEProposalBulkEditView(generic.BulkEditView):
queryset = IKEProposal.objects.all()
filterset = filtersets.IKEProposalFilterSet
@@ -206,6 +225,7 @@ class IKEProposalBulkEditView(generic.BulkEditView):
form = forms.IKEProposalBulkEditForm
+@register_model_view(IKEProposal, 'bulk_delete', path='delete', detail=False)
class IKEProposalBulkDeleteView(generic.BulkDeleteView):
queryset = IKEProposal.objects.all()
filterset = filtersets.IKEProposalFilterSet
@@ -216,6 +236,7 @@ class IKEProposalBulkDeleteView(generic.BulkDeleteView):
# IKE policies
#
+@register_model_view(IKEPolicy, 'list', path='', detail=False)
class IKEPolicyListView(generic.ObjectListView):
queryset = IKEPolicy.objects.all()
filterset = filtersets.IKEPolicyFilterSet
@@ -228,6 +249,7 @@ class IKEPolicyView(generic.ObjectView):
queryset = IKEPolicy.objects.all()
+@register_model_view(IKEPolicy, 'add', detail=False)
@register_model_view(IKEPolicy, 'edit')
class IKEPolicyEditView(generic.ObjectEditView):
queryset = IKEPolicy.objects.all()
@@ -239,11 +261,13 @@ class IKEPolicyDeleteView(generic.ObjectDeleteView):
queryset = IKEPolicy.objects.all()
+@register_model_view(IKEPolicy, 'bulk_import', detail=False)
class IKEPolicyBulkImportView(generic.BulkImportView):
queryset = IKEPolicy.objects.all()
model_form = forms.IKEPolicyImportForm
+@register_model_view(IKEPolicy, 'bulk_edit', path='edit', detail=False)
class IKEPolicyBulkEditView(generic.BulkEditView):
queryset = IKEPolicy.objects.all()
filterset = filtersets.IKEPolicyFilterSet
@@ -251,6 +275,7 @@ class IKEPolicyBulkEditView(generic.BulkEditView):
form = forms.IKEPolicyBulkEditForm
+@register_model_view(IKEPolicy, 'bulk_delete', path='delete', detail=False)
class IKEPolicyBulkDeleteView(generic.BulkDeleteView):
queryset = IKEPolicy.objects.all()
filterset = filtersets.IKEPolicyFilterSet
@@ -261,6 +286,7 @@ class IKEPolicyBulkDeleteView(generic.BulkDeleteView):
# IPSec proposals
#
+@register_model_view(IPSecProposal, 'list', path='', detail=False)
class IPSecProposalListView(generic.ObjectListView):
queryset = IPSecProposal.objects.all()
filterset = filtersets.IPSecProposalFilterSet
@@ -273,6 +299,7 @@ class IPSecProposalView(generic.ObjectView):
queryset = IPSecProposal.objects.all()
+@register_model_view(IPSecProposal, 'add', detail=False)
@register_model_view(IPSecProposal, 'edit')
class IPSecProposalEditView(generic.ObjectEditView):
queryset = IPSecProposal.objects.all()
@@ -284,11 +311,13 @@ class IPSecProposalDeleteView(generic.ObjectDeleteView):
queryset = IPSecProposal.objects.all()
+@register_model_view(IPSecProposal, 'bulk_import', detail=False)
class IPSecProposalBulkImportView(generic.BulkImportView):
queryset = IPSecProposal.objects.all()
model_form = forms.IPSecProposalImportForm
+@register_model_view(IPSecProposal, 'bulk_edit', path='edit', detail=False)
class IPSecProposalBulkEditView(generic.BulkEditView):
queryset = IPSecProposal.objects.all()
filterset = filtersets.IPSecProposalFilterSet
@@ -296,6 +325,7 @@ class IPSecProposalBulkEditView(generic.BulkEditView):
form = forms.IPSecProposalBulkEditForm
+@register_model_view(IPSecProposal, 'bulk_delete', path='delete', detail=False)
class IPSecProposalBulkDeleteView(generic.BulkDeleteView):
queryset = IPSecProposal.objects.all()
filterset = filtersets.IPSecProposalFilterSet
@@ -306,6 +336,7 @@ class IPSecProposalBulkDeleteView(generic.BulkDeleteView):
# IPSec policies
#
+@register_model_view(IPSecPolicy, 'list', path='', detail=False)
class IPSecPolicyListView(generic.ObjectListView):
queryset = IPSecPolicy.objects.all()
filterset = filtersets.IPSecPolicyFilterSet
@@ -318,6 +349,7 @@ class IPSecPolicyView(generic.ObjectView):
queryset = IPSecPolicy.objects.all()
+@register_model_view(IPSecPolicy, 'add', detail=False)
@register_model_view(IPSecPolicy, 'edit')
class IPSecPolicyEditView(generic.ObjectEditView):
queryset = IPSecPolicy.objects.all()
@@ -329,11 +361,13 @@ class IPSecPolicyDeleteView(generic.ObjectDeleteView):
queryset = IPSecPolicy.objects.all()
+@register_model_view(IPSecPolicy, 'bulk_import', detail=False)
class IPSecPolicyBulkImportView(generic.BulkImportView):
queryset = IPSecPolicy.objects.all()
model_form = forms.IPSecPolicyImportForm
+@register_model_view(IPSecPolicy, 'bulk_edit', path='edit', detail=False)
class IPSecPolicyBulkEditView(generic.BulkEditView):
queryset = IPSecPolicy.objects.all()
filterset = filtersets.IPSecPolicyFilterSet
@@ -341,6 +375,7 @@ class IPSecPolicyBulkEditView(generic.BulkEditView):
form = forms.IPSecPolicyBulkEditForm
+@register_model_view(IPSecPolicy, 'bulk_delete', path='delete', detail=False)
class IPSecPolicyBulkDeleteView(generic.BulkDeleteView):
queryset = IPSecPolicy.objects.all()
filterset = filtersets.IPSecPolicyFilterSet
@@ -351,6 +386,7 @@ class IPSecPolicyBulkDeleteView(generic.BulkDeleteView):
# IPSec profiles
#
+@register_model_view(IPSecProfile, 'list', path='', detail=False)
class IPSecProfileListView(generic.ObjectListView):
queryset = IPSecProfile.objects.all()
filterset = filtersets.IPSecProfileFilterSet
@@ -363,6 +399,7 @@ class IPSecProfileView(generic.ObjectView):
queryset = IPSecProfile.objects.all()
+@register_model_view(IPSecProfile, 'add', detail=False)
@register_model_view(IPSecProfile, 'edit')
class IPSecProfileEditView(generic.ObjectEditView):
queryset = IPSecProfile.objects.all()
@@ -374,11 +411,13 @@ class IPSecProfileDeleteView(generic.ObjectDeleteView):
queryset = IPSecProfile.objects.all()
+@register_model_view(IPSecProfile, 'bulk_import', detail=False)
class IPSecProfileBulkImportView(generic.BulkImportView):
queryset = IPSecProfile.objects.all()
model_form = forms.IPSecProfileImportForm
+@register_model_view(IPSecProfile, 'bulk_edit', path='edit', detail=False)
class IPSecProfileBulkEditView(generic.BulkEditView):
queryset = IPSecProfile.objects.all()
filterset = filtersets.IPSecProfileFilterSet
@@ -386,14 +425,18 @@ class IPSecProfileBulkEditView(generic.BulkEditView):
form = forms.IPSecProfileBulkEditForm
+@register_model_view(IPSecProfile, 'bulk_delete', path='delete', detail=False)
class IPSecProfileBulkDeleteView(generic.BulkDeleteView):
queryset = IPSecProfile.objects.all()
filterset = filtersets.IPSecProfileFilterSet
table = tables.IPSecProfileTable
+#
# L2VPN
+#
+@register_model_view(L2VPN, 'list', path='', detail=False)
class L2VPNListView(generic.ObjectListView):
queryset = L2VPN.objects.all()
table = tables.L2VPNTable
@@ -421,6 +464,7 @@ class L2VPNView(generic.ObjectView):
}
+@register_model_view(L2VPN, 'add', detail=False)
@register_model_view(L2VPN, 'edit')
class L2VPNEditView(generic.ObjectEditView):
queryset = L2VPN.objects.all()
@@ -432,11 +476,13 @@ class L2VPNDeleteView(generic.ObjectDeleteView):
queryset = L2VPN.objects.all()
+@register_model_view(L2VPN, 'bulk_import', detail=False)
class L2VPNBulkImportView(generic.BulkImportView):
queryset = L2VPN.objects.all()
model_form = forms.L2VPNImportForm
+@register_model_view(L2VPN, 'bulk_edit', path='edit', detail=False)
class L2VPNBulkEditView(generic.BulkEditView):
queryset = L2VPN.objects.all()
filterset = filtersets.L2VPNFilterSet
@@ -444,6 +490,7 @@ class L2VPNBulkEditView(generic.BulkEditView):
form = forms.L2VPNBulkEditForm
+@register_model_view(L2VPN, 'bulk_delete', path='delete', detail=False)
class L2VPNBulkDeleteView(generic.BulkDeleteView):
queryset = L2VPN.objects.all()
filterset = filtersets.L2VPNFilterSet
@@ -459,6 +506,7 @@ class L2VPNContactsView(ObjectContactsView):
# L2VPN terminations
#
+@register_model_view(L2VPNTermination, 'list', path='', detail=False)
class L2VPNTerminationListView(generic.ObjectListView):
queryset = L2VPNTermination.objects.all()
table = tables.L2VPNTerminationTable
@@ -471,6 +519,7 @@ class L2VPNTerminationView(generic.ObjectView):
queryset = L2VPNTermination.objects.all()
+@register_model_view(L2VPNTermination, 'add', detail=False)
@register_model_view(L2VPNTermination, 'edit')
class L2VPNTerminationEditView(generic.ObjectEditView):
queryset = L2VPNTermination.objects.all()
@@ -482,11 +531,13 @@ class L2VPNTerminationDeleteView(generic.ObjectDeleteView):
queryset = L2VPNTermination.objects.all()
+@register_model_view(L2VPNTermination, 'bulk_import', detail=False)
class L2VPNTerminationBulkImportView(generic.BulkImportView):
queryset = L2VPNTermination.objects.all()
model_form = forms.L2VPNTerminationImportForm
+@register_model_view(L2VPNTermination, 'bulk_edit', path='edit', detail=False)
class L2VPNTerminationBulkEditView(generic.BulkEditView):
queryset = L2VPNTermination.objects.all()
filterset = filtersets.L2VPNTerminationFilterSet
@@ -494,6 +545,7 @@ class L2VPNTerminationBulkEditView(generic.BulkEditView):
form = forms.L2VPNTerminationBulkEditForm
+@register_model_view(L2VPNTermination, 'bulk_delete', path='delete', detail=False)
class L2VPNTerminationBulkDeleteView(generic.BulkDeleteView):
queryset = L2VPNTermination.objects.all()
filterset = filtersets.L2VPNTerminationFilterSet
diff --git a/netbox/wireless/api/nested_serializers.py b/netbox/wireless/api/nested_serializers.py
deleted file mode 100644
index 9b8b6c3e3..000000000
--- a/netbox/wireless/api/nested_serializers.py
+++ /dev/null
@@ -1,24 +0,0 @@
-import warnings
-
-from netbox.api.serializers import WritableNestedSerializer
-from wireless.models import *
-from .serializers_.nested import NestedWirelessLANGroupSerializer, NestedWirelessLinkSerializer
-
-__all__ = (
- 'NestedWirelessLANSerializer',
- 'NestedWirelessLANGroupSerializer',
- 'NestedWirelessLinkSerializer',
-)
-
-# TODO: Remove in v4.2
-warnings.warn(
- "Dedicated nested serializers will be removed in NetBox v4.2. Use Serializer(nested=True) instead.",
- DeprecationWarning
-)
-
-
-class NestedWirelessLANSerializer(WritableNestedSerializer):
-
- class Meta:
- model = WirelessLAN
- fields = ['id', 'url', 'display_url', 'display', 'ssid']
diff --git a/netbox/wireless/api/serializers_/wirelesslans.py b/netbox/wireless/api/serializers_/wirelesslans.py
index 6c5deeb26..68f79daf6 100644
--- a/netbox/wireless/api/serializers_/wirelesslans.py
+++ b/netbox/wireless/api/serializers_/wirelesslans.py
@@ -1,9 +1,13 @@
from rest_framework import serializers
+from dcim.constants import LOCATION_SCOPE_TYPES
+from django.contrib.contenttypes.models import ContentType
+from drf_spectacular.utils import extend_schema_field
from ipam.api.serializers_.vlans import VLANSerializer
-from netbox.api.fields import ChoiceField
+from netbox.api.fields import ChoiceField, ContentTypeField
from netbox.api.serializers import NestedGroupModelSerializer, NetBoxModelSerializer
from tenancy.api.serializers_.tenants import TenantSerializer
+from utilities.api import get_serializer_for_model
from wireless.choices import *
from wireless.models import WirelessLAN, WirelessLANGroup
from .nested import NestedWirelessLANGroupSerializer
@@ -34,12 +38,30 @@ class WirelessLANSerializer(NetBoxModelSerializer):
tenant = TenantSerializer(nested=True, required=False, allow_null=True)
auth_type = ChoiceField(choices=WirelessAuthTypeChoices, required=False, allow_blank=True)
auth_cipher = ChoiceField(choices=WirelessAuthCipherChoices, required=False, allow_blank=True)
+ scope_type = ContentTypeField(
+ queryset=ContentType.objects.filter(
+ model__in=LOCATION_SCOPE_TYPES
+ ),
+ allow_null=True,
+ required=False,
+ default=None
+ )
+ scope_id = serializers.IntegerField(allow_null=True, required=False, default=None)
+ scope = serializers.SerializerMethodField(read_only=True)
class Meta:
model = WirelessLAN
fields = [
- 'id', 'url', 'display_url', 'display', 'ssid', 'description', 'group', 'status', 'vlan', 'tenant',
- 'auth_type', 'auth_cipher', 'auth_psk', 'description', 'comments', 'tags', 'custom_fields',
- 'created', 'last_updated',
+ 'id', 'url', 'display_url', 'display', 'ssid', 'description', 'group', 'status', 'vlan', 'scope_type',
+ 'scope_id', 'scope', 'tenant', 'auth_type', 'auth_cipher', 'auth_psk', 'description', 'comments', 'tags',
+ 'custom_fields', 'created', 'last_updated',
]
brief_fields = ('id', 'url', 'display', 'ssid', 'description')
+
+ @extend_schema_field(serializers.JSONField(allow_null=True))
+ def get_scope(self, obj):
+ if obj.scope_id is None:
+ return None
+ serializer = get_serializer_for_model(obj.scope)
+ context = {'request': self.context['request']}
+ return serializer(obj.scope, nested=True, context=context).data
diff --git a/netbox/wireless/api/serializers_/wirelesslinks.py b/netbox/wireless/api/serializers_/wirelesslinks.py
index c1d8e4f10..62f9988f1 100644
--- a/netbox/wireless/api/serializers_/wirelesslinks.py
+++ b/netbox/wireless/api/serializers_/wirelesslinks.py
@@ -2,6 +2,7 @@ from dcim.api.serializers_.device_components import InterfaceSerializer
from dcim.choices import LinkStatusChoices
from netbox.api.fields import ChoiceField
from netbox.api.serializers import NetBoxModelSerializer
+from netbox.choices import *
from tenancy.api.serializers_.tenants import TenantSerializer
from wireless.choices import *
from wireless.models import WirelessLink
@@ -18,7 +19,7 @@ class WirelessLinkSerializer(NetBoxModelSerializer):
tenant = TenantSerializer(nested=True, required=False, allow_null=True)
auth_type = ChoiceField(choices=WirelessAuthTypeChoices, required=False, allow_blank=True)
auth_cipher = ChoiceField(choices=WirelessAuthCipherChoices, required=False, allow_blank=True)
- distance_unit = ChoiceField(choices=WirelessLinkDistanceUnitChoices, allow_blank=True, required=False, allow_null=True)
+ distance_unit = ChoiceField(choices=DistanceUnitChoices, allow_blank=True, required=False, allow_null=True)
class Meta:
model = WirelessLink
diff --git a/netbox/wireless/choices.py b/netbox/wireless/choices.py
index f17ea584d..710cd3a8d 100644
--- a/netbox/wireless/choices.py
+++ b/netbox/wireless/choices.py
@@ -481,21 +481,3 @@ class WirelessAuthCipherChoices(ChoiceSet):
(CIPHER_TKIP, 'TKIP'),
(CIPHER_AES, 'AES'),
)
-
-
-class WirelessLinkDistanceUnitChoices(ChoiceSet):
-
- # Metric
- UNIT_KILOMETER = 'km'
- UNIT_METER = 'm'
-
- # Imperial
- UNIT_MILE = 'mi'
- UNIT_FOOT = 'ft'
-
- CHOICES = (
- (UNIT_KILOMETER, _('Kilometers')),
- (UNIT_METER, _('Meters')),
- (UNIT_MILE, _('Miles')),
- (UNIT_FOOT, _('Feet')),
- )
diff --git a/netbox/wireless/filtersets.py b/netbox/wireless/filtersets.py
index 537b2ec5c..cc5aefbd8 100644
--- a/netbox/wireless/filtersets.py
+++ b/netbox/wireless/filtersets.py
@@ -2,6 +2,7 @@ import django_filters
from django.db.models import Q
from dcim.choices import LinkStatusChoices
+from dcim.base_filtersets import ScopedFilterSet
from dcim.models import Interface
from ipam.models import VLAN
from netbox.filtersets import OrganizationalModelFilterSet, NetBoxModelFilterSet
@@ -43,7 +44,7 @@ class WirelessLANGroupFilterSet(OrganizationalModelFilterSet):
fields = ('id', 'name', 'slug', 'description')
-class WirelessLANFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
+class WirelessLANFilterSet(NetBoxModelFilterSet, ScopedFilterSet, TenancyFilterSet):
group_id = TreeNodeMultipleChoiceFilter(
queryset=WirelessLANGroup.objects.all(),
field_name='group',
@@ -74,7 +75,7 @@ class WirelessLANFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
class Meta:
model = WirelessLAN
- fields = ('id', 'ssid', 'auth_psk', 'description')
+ fields = ('id', 'ssid', 'auth_psk', 'scope_id', 'description')
def search(self, queryset, name, value):
if not value.strip():
diff --git a/netbox/wireless/forms/bulk_edit.py b/netbox/wireless/forms/bulk_edit.py
index 64a9bfa98..5cd3a157a 100644
--- a/netbox/wireless/forms/bulk_edit.py
+++ b/netbox/wireless/forms/bulk_edit.py
@@ -2,7 +2,9 @@ from django import forms
from django.utils.translation import gettext_lazy as _
from dcim.choices import LinkStatusChoices
+from dcim.forms.mixins import ScopedBulkEditForm
from ipam.models import VLAN
+from netbox.choices import *
from netbox.forms import NetBoxModelBulkEditForm
from tenancy.models import Tenant
from utilities.forms import add_blank_choice
@@ -38,7 +40,7 @@ class WirelessLANGroupBulkEditForm(NetBoxModelBulkEditForm):
nullable_fields = ('parent', 'description')
-class WirelessLANBulkEditForm(NetBoxModelBulkEditForm):
+class WirelessLANBulkEditForm(ScopedBulkEditForm, NetBoxModelBulkEditForm):
status = forms.ChoiceField(
label=_('Status'),
choices=add_blank_choice(WirelessLANStatusChoices),
@@ -88,10 +90,11 @@ class WirelessLANBulkEditForm(NetBoxModelBulkEditForm):
model = WirelessLAN
fieldsets = (
FieldSet('group', 'ssid', 'status', 'vlan', 'tenant', 'description'),
+ FieldSet('scope_type', 'scope', name=_('Scope')),
FieldSet('auth_type', 'auth_cipher', 'auth_psk', name=_('Authentication')),
)
nullable_fields = (
- 'ssid', 'group', 'vlan', 'tenant', 'description', 'auth_type', 'auth_cipher', 'auth_psk', 'comments',
+ 'ssid', 'group', 'vlan', 'tenant', 'description', 'auth_type', 'auth_cipher', 'auth_psk', 'scope', 'comments',
)
@@ -132,7 +135,7 @@ class WirelessLinkBulkEditForm(NetBoxModelBulkEditForm):
)
distance_unit = forms.ChoiceField(
label=_('Distance unit'),
- choices=add_blank_choice(WirelessLinkDistanceUnitChoices),
+ choices=add_blank_choice(DistanceUnitChoices),
required=False,
initial=''
)
diff --git a/netbox/wireless/forms/bulk_import.py b/netbox/wireless/forms/bulk_import.py
index 878afd5c8..1fece7e46 100644
--- a/netbox/wireless/forms/bulk_import.py
+++ b/netbox/wireless/forms/bulk_import.py
@@ -1,8 +1,10 @@
from django.utils.translation import gettext_lazy as _
from dcim.choices import LinkStatusChoices
+from dcim.forms.mixins import ScopedImportForm
from dcim.models import Interface
from ipam.models import VLAN
+from netbox.choices import *
from netbox.forms import NetBoxModelImportForm
from tenancy.models import Tenant
from utilities.forms.fields import CSVChoiceField, CSVModelChoiceField, SlugField
@@ -31,7 +33,7 @@ class WirelessLANGroupImportForm(NetBoxModelImportForm):
fields = ('name', 'slug', 'parent', 'description', 'tags')
-class WirelessLANImportForm(NetBoxModelImportForm):
+class WirelessLANImportForm(ScopedImportForm, NetBoxModelImportForm):
group = CSVModelChoiceField(
label=_('Group'),
queryset=WirelessLANGroup.objects.all(),
@@ -74,9 +76,12 @@ class WirelessLANImportForm(NetBoxModelImportForm):
class Meta:
model = WirelessLAN
fields = (
- 'ssid', 'group', 'status', 'vlan', 'tenant', 'auth_type', 'auth_cipher', 'auth_psk', 'description',
- 'comments', 'tags',
+ 'ssid', 'group', 'status', 'vlan', 'tenant', 'auth_type', 'auth_cipher', 'auth_psk', 'scope_type',
+ 'scope_id', 'description', 'comments', 'tags',
)
+ labels = {
+ 'scope_id': _('Scope ID'),
+ }
class WirelessLinkImportForm(NetBoxModelImportForm):
@@ -114,7 +119,7 @@ class WirelessLinkImportForm(NetBoxModelImportForm):
)
distance_unit = CSVChoiceField(
label=_('Distance unit'),
- choices=WirelessLinkDistanceUnitChoices,
+ choices=DistanceUnitChoices,
required=False,
help_text=_('Distance unit')
)
diff --git a/netbox/wireless/forms/filtersets.py b/netbox/wireless/forms/filtersets.py
index f87cadfb9..f62a3be06 100644
--- a/netbox/wireless/forms/filtersets.py
+++ b/netbox/wireless/forms/filtersets.py
@@ -2,6 +2,8 @@ from django import forms
from django.utils.translation import gettext_lazy as _
from dcim.choices import LinkStatusChoices
+from dcim.models import Location, Region, Site, SiteGroup
+from netbox.choices import *
from netbox.forms import NetBoxModelFilterSetForm
from tenancy.forms import TenancyFilterForm
from utilities.forms import add_blank_choice
@@ -32,6 +34,7 @@ class WirelessLANFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
fieldsets = (
FieldSet('q', 'filter_id', 'tag'),
FieldSet('ssid', 'group_id', 'status', name=_('Attributes')),
+ FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', name=_('Scope')),
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
FieldSet('auth_type', 'auth_cipher', 'auth_psk', name=_('Authentication')),
)
@@ -64,6 +67,31 @@ class WirelessLANFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
label=_('Pre-shared key'),
required=False
)
+ region_id = DynamicModelMultipleChoiceField(
+ queryset=Region.objects.all(),
+ required=False,
+ label=_('Region')
+ )
+ site_group_id = DynamicModelMultipleChoiceField(
+ queryset=SiteGroup.objects.all(),
+ required=False,
+ label=_('Site group')
+ )
+ 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')
+ )
+ location_id = DynamicModelMultipleChoiceField(
+ queryset=Location.objects.all(),
+ required=False,
+ label=_('Location')
+ )
tag = TagFilterField(model)
@@ -104,7 +132,7 @@ class WirelessLinkFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
)
distance_unit = forms.ChoiceField(
label=_('Distance unit'),
- choices=add_blank_choice(WirelessLinkDistanceUnitChoices),
+ choices=add_blank_choice(DistanceUnitChoices),
required=False
)
tag = TagFilterField(model)
diff --git a/netbox/wireless/forms/model_forms.py b/netbox/wireless/forms/model_forms.py
index 7c2594271..9cfcca7ba 100644
--- a/netbox/wireless/forms/model_forms.py
+++ b/netbox/wireless/forms/model_forms.py
@@ -2,6 +2,7 @@ from django.forms import PasswordInput
from django.utils.translation import gettext_lazy as _
from dcim.models import Device, Interface, Location, Site
+from dcim.forms.mixins import ScopedForm
from ipam.models import VLAN
from netbox.forms import NetBoxModelForm
from tenancy.forms import TenancyForm
@@ -35,11 +36,12 @@ class WirelessLANGroupForm(NetBoxModelForm):
]
-class WirelessLANForm(TenancyForm, NetBoxModelForm):
+class WirelessLANForm(ScopedForm, TenancyForm, NetBoxModelForm):
group = DynamicModelChoiceField(
label=_('Group'),
queryset=WirelessLANGroup.objects.all(),
- required=False
+ required=False,
+ quick_add=True
)
vlan = DynamicModelChoiceField(
queryset=VLAN.objects.all(),
@@ -51,6 +53,7 @@ class WirelessLANForm(TenancyForm, NetBoxModelForm):
fieldsets = (
FieldSet('ssid', 'group', 'vlan', 'status', 'description', 'tags', name=_('Wireless LAN')),
+ FieldSet('scope_type', 'scope', name=_('Scope')),
FieldSet('tenant_group', 'tenant', name=_('Tenancy')),
FieldSet('auth_type', 'auth_cipher', 'auth_psk', name=_('Authentication')),
)
@@ -59,7 +62,7 @@ class WirelessLANForm(TenancyForm, NetBoxModelForm):
model = WirelessLAN
fields = [
'ssid', 'group', 'status', 'vlan', 'tenant_group', 'tenant', 'auth_type', 'auth_cipher', 'auth_psk',
- 'description', 'comments', 'tags',
+ 'scope_type', 'description', 'comments', 'tags',
]
widgets = {
'auth_psk': PasswordInput(
diff --git a/netbox/wireless/graphql/types.py b/netbox/wireless/graphql/types.py
index b24525fbe..aa44e9b9f 100644
--- a/netbox/wireless/graphql/types.py
+++ b/netbox/wireless/graphql/types.py
@@ -1,4 +1,4 @@
-from typing import Annotated, List
+from typing import Annotated, List, Union
import strawberry
import strawberry_django
@@ -28,7 +28,7 @@ class WirelessLANGroupType(OrganizationalObjectType):
@strawberry_django.type(
models.WirelessLAN,
- fields='__all__',
+ exclude=('scope_type', 'scope_id', '_location', '_region', '_site', '_site_group'),
filters=WirelessLANFilter
)
class WirelessLANType(NetBoxObjectType):
@@ -38,6 +38,15 @@ class WirelessLANType(NetBoxObjectType):
interfaces: List[Annotated["InterfaceType", strawberry.lazy('dcim.graphql.types')]]
+ @strawberry_django.field
+ def scope(self) -> Annotated[Union[
+ Annotated["LocationType", strawberry.lazy('dcim.graphql.types')],
+ Annotated["RegionType", strawberry.lazy('dcim.graphql.types')],
+ Annotated["SiteGroupType", strawberry.lazy('dcim.graphql.types')],
+ Annotated["SiteType", strawberry.lazy('dcim.graphql.types')],
+ ], strawberry.union("WirelessLANScopeType")] | None:
+ return self.scope
+
@strawberry_django.type(
models.WirelessLink,
diff --git a/netbox/wireless/migrations/0001_squashed_0008.py b/netbox/wireless/migrations/0001_squashed_0008.py
index 2326f5cf7..8886580e1 100644
--- a/netbox/wireless/migrations/0001_squashed_0008.py
+++ b/netbox/wireless/migrations/0001_squashed_0008.py
@@ -8,7 +8,6 @@ import wireless.models
class Migration(migrations.Migration):
-
replaces = [
('wireless', '0001_wireless'),
('wireless', '0002_standardize_id_fields'),
@@ -17,7 +16,7 @@ class Migration(migrations.Migration):
('wireless', '0005_wirelesslink_interface_types'),
('wireless', '0006_unique_constraints'),
('wireless', '0007_standardize_description_comments'),
- ('wireless', '0008_wirelesslan_status')
+ ('wireless', '0008_wirelesslan_status'),
]
dependencies = [
@@ -33,7 +32,10 @@ class Migration(migrations.Migration):
fields=[
('created', models.DateTimeField(auto_now_add=True, null=True)),
('last_updated', models.DateTimeField(auto_now=True, null=True)),
- ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=utilities.json.CustomFieldJSONEncoder)),
+ (
+ 'custom_field_data',
+ models.JSONField(blank=True, default=dict, encoder=utilities.json.CustomFieldJSONEncoder),
+ ),
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
('name', models.CharField(max_length=100, unique=True)),
('slug', models.SlugField(max_length=100, unique=True)),
@@ -43,7 +45,16 @@ class Migration(migrations.Migration):
('rght', models.PositiveIntegerField(editable=False)),
('tree_id', models.PositiveIntegerField(db_index=True, editable=False)),
('level', models.PositiveIntegerField(editable=False)),
- ('parent', mptt.fields.TreeForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='children', to='wireless.wirelesslangroup')),
+ (
+ 'parent',
+ mptt.fields.TreeForeignKey(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name='children',
+ to='wireless.wirelesslangroup',
+ ),
+ ),
],
options={
'ordering': ('name', 'pk'),
@@ -56,17 +67,43 @@ class Migration(migrations.Migration):
fields=[
('created', models.DateTimeField(auto_now_add=True, null=True)),
('last_updated', models.DateTimeField(auto_now=True, null=True)),
- ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=utilities.json.CustomFieldJSONEncoder)),
+ (
+ 'custom_field_data',
+ models.JSONField(blank=True, default=dict, encoder=utilities.json.CustomFieldJSONEncoder),
+ ),
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
('ssid', models.CharField(max_length=32)),
- ('group', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='wireless_lans', to='wireless.wirelesslangroup')),
+ (
+ 'group',
+ models.ForeignKey(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.SET_NULL,
+ related_name='wireless_lans',
+ to='wireless.wirelesslangroup',
+ ),
+ ),
('description', models.CharField(blank=True, max_length=200)),
('auth_cipher', models.CharField(blank=True, max_length=50)),
('auth_psk', models.CharField(blank=True, max_length=64)),
('auth_type', models.CharField(blank=True, max_length=50)),
('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')),
- ('vlan', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='ipam.vlan')),
- ('tenant', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='wireless_lans', to='tenancy.tenant')),
+ (
+ 'vlan',
+ models.ForeignKey(
+ blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='ipam.vlan'
+ ),
+ ),
+ (
+ 'tenant',
+ models.ForeignKey(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.PROTECT,
+ related_name='wireless_lans',
+ to='tenancy.tenant',
+ ),
+ ),
],
options={
'verbose_name': 'Wireless LAN',
@@ -78,7 +115,10 @@ class Migration(migrations.Migration):
fields=[
('created', models.DateTimeField(auto_now_add=True, null=True)),
('last_updated', models.DateTimeField(auto_now=True, null=True)),
- ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=utilities.json.CustomFieldJSONEncoder)),
+ (
+ 'custom_field_data',
+ models.JSONField(blank=True, default=dict, encoder=utilities.json.CustomFieldJSONEncoder),
+ ),
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
('ssid', models.CharField(blank=True, max_length=32)),
('status', models.CharField(default='connected', max_length=50)),
@@ -86,12 +126,55 @@ class Migration(migrations.Migration):
('auth_cipher', models.CharField(blank=True, max_length=50)),
('auth_psk', models.CharField(blank=True, max_length=64)),
('auth_type', models.CharField(blank=True, max_length=50)),
- ('_interface_a_device', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='+', to='dcim.device')),
- ('_interface_b_device', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='+', to='dcim.device')),
- ('interface_a', models.ForeignKey(limit_choices_to=wireless.models.get_wireless_interface_types, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='dcim.interface')),
- ('interface_b', models.ForeignKey(limit_choices_to=wireless.models.get_wireless_interface_types, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='dcim.interface')),
+ (
+ '_interface_a_device',
+ models.ForeignKey(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name='+',
+ to='dcim.device',
+ ),
+ ),
+ (
+ '_interface_b_device',
+ models.ForeignKey(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name='+',
+ to='dcim.device',
+ ),
+ ),
+ (
+ 'interface_a',
+ models.ForeignKey(
+ limit_choices_to=wireless.models.get_wireless_interface_types,
+ on_delete=django.db.models.deletion.PROTECT,
+ related_name='+',
+ to='dcim.interface',
+ ),
+ ),
+ (
+ 'interface_b',
+ models.ForeignKey(
+ limit_choices_to=wireless.models.get_wireless_interface_types,
+ on_delete=django.db.models.deletion.PROTECT,
+ related_name='+',
+ to='dcim.interface',
+ ),
+ ),
('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')),
- ('tenant', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='wireless_links', to='tenancy.tenant')),
+ (
+ 'tenant',
+ models.ForeignKey(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.PROTECT,
+ related_name='wireless_links',
+ to='tenancy.tenant',
+ ),
+ ),
],
options={
'ordering': ['pk'],
@@ -100,11 +183,15 @@ class Migration(migrations.Migration):
),
migrations.AddConstraint(
model_name='wirelesslangroup',
- constraint=models.UniqueConstraint(fields=('parent', 'name'), name='wireless_wirelesslangroup_unique_parent_name'),
+ constraint=models.UniqueConstraint(
+ fields=('parent', 'name'), name='wireless_wirelesslangroup_unique_parent_name'
+ ),
),
migrations.AddConstraint(
model_name='wirelesslink',
- constraint=models.UniqueConstraint(fields=('interface_a', 'interface_b'), name='wireless_wirelesslink_unique_interfaces'),
+ constraint=models.UniqueConstraint(
+ fields=('interface_a', 'interface_b'), name='wireless_wirelesslink_unique_interfaces'
+ ),
),
migrations.AddField(
model_name='wirelesslan',
diff --git a/netbox/wireless/migrations/0009_wirelesslink_distance.py b/netbox/wireless/migrations/0009_wirelesslink_distance.py
index 6a778ef00..6ddf4ab44 100644
--- a/netbox/wireless/migrations/0009_wirelesslink_distance.py
+++ b/netbox/wireless/migrations/0009_wirelesslink_distance.py
@@ -4,7 +4,6 @@ from django.db import migrations, models
class Migration(migrations.Migration):
-
dependencies = [
('wireless', '0001_squashed_0008'),
]
diff --git a/netbox/wireless/migrations/0010_charfield_null_choices.py b/netbox/wireless/migrations/0010_charfield_null_choices.py
new file mode 100644
index 000000000..f0394618a
--- /dev/null
+++ b/netbox/wireless/migrations/0010_charfield_null_choices.py
@@ -0,0 +1,50 @@
+from django.db import migrations, models
+
+
+def set_null_values(apps, schema_editor):
+ """
+ Replace empty strings with null values.
+ """
+ WirelessLAN = apps.get_model('wireless', 'WirelessLAN')
+ WirelessLink = apps.get_model('wireless', 'WirelessLink')
+
+ WirelessLAN.objects.filter(auth_cipher='').update(auth_cipher=None)
+ WirelessLAN.objects.filter(auth_type='').update(auth_type=None)
+ WirelessLink.objects.filter(auth_cipher='').update(auth_cipher=None)
+ WirelessLink.objects.filter(auth_type='').update(auth_type=None)
+ WirelessLink.objects.filter(distance_unit='').update(distance_unit=None)
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ('wireless', '0009_wirelesslink_distance'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='wirelesslan',
+ name='auth_cipher',
+ field=models.CharField(blank=True, max_length=50, null=True),
+ ),
+ migrations.AlterField(
+ model_name='wirelesslan',
+ name='auth_type',
+ field=models.CharField(blank=True, max_length=50, null=True),
+ ),
+ migrations.AlterField(
+ model_name='wirelesslink',
+ name='auth_cipher',
+ field=models.CharField(blank=True, max_length=50, null=True),
+ ),
+ migrations.AlterField(
+ model_name='wirelesslink',
+ name='auth_type',
+ field=models.CharField(blank=True, max_length=50, null=True),
+ ),
+ migrations.AlterField(
+ model_name='wirelesslink',
+ name='distance_unit',
+ field=models.CharField(blank=True, max_length=50, null=True),
+ ),
+ migrations.RunPython(code=set_null_values, reverse_code=migrations.RunPython.noop),
+ ]
diff --git a/netbox/wireless/migrations/0011_wirelesslan__location_wirelesslan__region_and_more.py b/netbox/wireless/migrations/0011_wirelesslan__location_wirelesslan__region_and_more.py
new file mode 100644
index 000000000..334d41bdd
--- /dev/null
+++ b/netbox/wireless/migrations/0011_wirelesslan__location_wirelesslan__region_and_more.py
@@ -0,0 +1,76 @@
+# Generated by Django 5.0.9 on 2024-11-04 16:00
+
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ('contenttypes', '0002_remove_content_type_name'),
+ ('dcim', '0196_qinq_svlan'),
+ ('wireless', '0010_charfield_null_choices'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='wirelesslan',
+ name='_location',
+ field=models.ForeignKey(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name='_%(class)ss',
+ to='dcim.location',
+ ),
+ ),
+ migrations.AddField(
+ model_name='wirelesslan',
+ name='_region',
+ field=models.ForeignKey(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name='_%(class)ss',
+ to='dcim.region',
+ ),
+ ),
+ migrations.AddField(
+ model_name='wirelesslan',
+ name='_site',
+ field=models.ForeignKey(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name='_%(class)ss',
+ to='dcim.site',
+ ),
+ ),
+ migrations.AddField(
+ model_name='wirelesslan',
+ name='_site_group',
+ field=models.ForeignKey(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name='_%(class)ss',
+ to='dcim.sitegroup',
+ ),
+ ),
+ migrations.AddField(
+ model_name='wirelesslan',
+ name='scope_id',
+ field=models.PositiveBigIntegerField(blank=True, null=True),
+ ),
+ migrations.AddField(
+ model_name='wirelesslan',
+ name='scope_type',
+ field=models.ForeignKey(
+ blank=True,
+ limit_choices_to=models.Q(('model__in', ('region', 'sitegroup', 'site', 'location'))),
+ null=True,
+ on_delete=django.db.models.deletion.PROTECT,
+ related_name='+',
+ to='contenttypes.contenttype',
+ ),
+ ),
+ ]
diff --git a/netbox/wireless/migrations/0012_alter_wirelesslan__location_and_more.py b/netbox/wireless/migrations/0012_alter_wirelesslan__location_and_more.py
new file mode 100644
index 000000000..21f118bd0
--- /dev/null
+++ b/netbox/wireless/migrations/0012_alter_wirelesslan__location_and_more.py
@@ -0,0 +1,40 @@
+# Generated by Django 5.0.9 on 2024-11-14 19:04
+
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ('dcim', '0196_qinq_svlan'),
+ ('wireless', '0011_wirelesslan__location_wirelesslan__region_and_more'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='wirelesslan',
+ name='_location',
+ field=models.ForeignKey(
+ blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='dcim.location'
+ ),
+ ),
+ migrations.AlterField(
+ model_name='wirelesslan',
+ name='_region',
+ field=models.ForeignKey(
+ blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='dcim.region'
+ ),
+ ),
+ migrations.AlterField(
+ model_name='wirelesslan',
+ name='_site',
+ field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='dcim.site'),
+ ),
+ migrations.AlterField(
+ model_name='wirelesslan',
+ name='_site_group',
+ field=models.ForeignKey(
+ blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='dcim.sitegroup'
+ ),
+ ),
+ ]
diff --git a/netbox/wireless/migrations/0013_natural_ordering.py b/netbox/wireless/migrations/0013_natural_ordering.py
new file mode 100644
index 000000000..7caede643
--- /dev/null
+++ b/netbox/wireless/migrations/0013_natural_ordering.py
@@ -0,0 +1,16 @@
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ('wireless', '0012_alter_wirelesslan__location_and_more'),
+ ('dcim', '0197_natural_sort_collation'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='wirelesslangroup',
+ name='name',
+ field=models.CharField(db_collation='natural_sort', max_length=100, unique=True),
+ ),
+ ]
diff --git a/netbox/wireless/models.py b/netbox/wireless/models.py
index 4214ac29d..61ff72bc1 100644
--- a/netbox/wireless/models.py
+++ b/netbox/wireless/models.py
@@ -1,12 +1,12 @@
from django.core.exceptions import ValidationError
from django.db import models
-from django.urls import reverse
from django.utils.translation import gettext_lazy as _
from dcim.choices import LinkStatusChoices
from dcim.constants import WIRELESS_IFACE_TYPES
+from dcim.models.mixins import CachedScopeMixin
from netbox.models import NestedGroupModel, PrimaryModel
-from utilities.conversion import to_meters
+from netbox.models.mixins import DistanceMixin
from .choices import *
from .constants import *
@@ -25,13 +25,15 @@ class WirelessAuthenticationBase(models.Model):
max_length=50,
choices=WirelessAuthTypeChoices,
blank=True,
+ null=True,
verbose_name=_("authentication type"),
)
auth_cipher = models.CharField(
verbose_name=_('authentication cipher'),
max_length=50,
choices=WirelessAuthCipherChoices,
- blank=True
+ blank=True,
+ null=True
)
auth_psk = models.CharField(
max_length=PSK_MAX_LENGTH,
@@ -50,7 +52,8 @@ class WirelessLANGroup(NestedGroupModel):
name = models.CharField(
verbose_name=_('name'),
max_length=100,
- unique=True
+ unique=True,
+ db_collation="natural_sort"
)
slug = models.SlugField(
verbose_name=_('slug'),
@@ -69,11 +72,8 @@ class WirelessLANGroup(NestedGroupModel):
verbose_name = _('wireless LAN group')
verbose_name_plural = _('wireless LAN groups')
- def get_absolute_url(self):
- return reverse('wireless:wirelesslangroup', args=[self.pk])
-
-class WirelessLAN(WirelessAuthenticationBase, PrimaryModel):
+class WirelessLAN(WirelessAuthenticationBase, CachedScopeMixin, PrimaryModel):
"""
A wireless network formed among an arbitrary number of access point and clients.
"""
@@ -109,7 +109,7 @@ class WirelessLAN(WirelessAuthenticationBase, PrimaryModel):
null=True
)
- clone_fields = ('ssid', 'group', 'tenant', 'description')
+ clone_fields = ('ssid', 'group', 'scope_type', 'scope_id', 'tenant', 'description')
class Meta:
ordering = ('ssid', 'pk')
@@ -119,9 +119,6 @@ class WirelessLAN(WirelessAuthenticationBase, PrimaryModel):
def __str__(self):
return self.ssid
- def get_absolute_url(self):
- return reverse('wireless:wirelesslan', args=[self.pk])
-
def get_status_color(self):
return WirelessLANStatusChoices.colors.get(self.status)
@@ -132,7 +129,7 @@ def get_wireless_interface_types():
return {'type__in': WIRELESS_IFACE_TYPES}
-class WirelessLink(WirelessAuthenticationBase, PrimaryModel):
+class WirelessLink(WirelessAuthenticationBase, DistanceMixin, PrimaryModel):
"""
A point-to-point connection between two wireless Interfaces.
"""
@@ -161,26 +158,6 @@ class WirelessLink(WirelessAuthenticationBase, PrimaryModel):
choices=LinkStatusChoices,
default=LinkStatusChoices.STATUS_CONNECTED
)
- distance = models.DecimalField(
- verbose_name=_('distance'),
- max_digits=8,
- decimal_places=2,
- blank=True,
- null=True
- )
- distance_unit = models.CharField(
- verbose_name=_('distance unit'),
- max_length=50,
- choices=WirelessLinkDistanceUnitChoices,
- blank=True,
- )
- # Stores the normalized distance (in meters) for database ordering
- _abs_distance = models.DecimalField(
- max_digits=10,
- decimal_places=4,
- blank=True,
- null=True
- )
tenant = models.ForeignKey(
to='tenancy.Tenant',
on_delete=models.PROTECT,
@@ -222,19 +199,12 @@ class WirelessLink(WirelessAuthenticationBase, PrimaryModel):
def __str__(self):
return self.ssid or f'#{self.pk}'
- def get_absolute_url(self):
- return reverse('wireless:wirelesslink', args=[self.pk])
-
def get_status_color(self):
return LinkStatusChoices.colors.get(self.status)
def clean(self):
super().clean()
- # Validate distance and distance_unit
- if self.distance is not None and not self.distance_unit:
- raise ValidationError(_("Must specify a unit when setting a wireless distance"))
-
# Validate interface types
if self.interface_a.type not in WIRELESS_IFACE_TYPES:
raise ValidationError({
@@ -250,16 +220,6 @@ class WirelessLink(WirelessAuthenticationBase, PrimaryModel):
})
def save(self, *args, **kwargs):
- # Store the given distance (if any) in meters for use in database ordering
- if self.distance is not None and self.distance_unit:
- self._abs_distance = to_meters(self.distance, self.distance_unit)
- else:
- self._abs_distance = None
-
- # Clear distance_unit if no distance is defined
- if self.distance is None:
- self.distance_unit = ''
-
# Store the parent Device for the A and B interfaces
self._interface_a_device = self.interface_a.device
self._interface_b_device = self.interface_b.device
diff --git a/netbox/wireless/tables/template_code.py b/netbox/wireless/tables/template_code.py
deleted file mode 100644
index 03c893c1f..000000000
--- a/netbox/wireless/tables/template_code.py
+++ /dev/null
@@ -1,4 +0,0 @@
-WIRELESS_LINK_DISTANCE = """
-{% load helpers %}
-{% if record.distance %}{{ record.distance|floatformat:"-2" }} {{ record.distance_unit }}{% endif %}
-"""
diff --git a/netbox/wireless/tables/wirelesslan.py b/netbox/wireless/tables/wirelesslan.py
index 87ad4ac51..fe9c0f5fa 100644
--- a/netbox/wireless/tables/wirelesslan.py
+++ b/netbox/wireless/tables/wirelesslan.py
@@ -51,6 +51,13 @@ class WirelessLANTable(TenancyColumnsMixin, NetBoxTable):
status = columns.ChoiceFieldColumn(
verbose_name=_('Status'),
)
+ scope_type = columns.ContentTypeColumn(
+ verbose_name=_('Scope Type'),
+ )
+ scope = tables.Column(
+ verbose_name=_('Scope'),
+ linkify=True
+ )
interface_count = tables.Column(
verbose_name=_('Interfaces')
)
@@ -65,7 +72,8 @@ class WirelessLANTable(TenancyColumnsMixin, NetBoxTable):
model = WirelessLAN
fields = (
'pk', 'ssid', 'group', 'status', 'tenant', 'tenant_group', 'vlan', 'interface_count', 'auth_type',
- 'auth_cipher', 'auth_psk', 'description', 'comments', 'tags', 'created', 'last_updated',
+ 'auth_cipher', 'auth_psk', 'scope', 'scope_type', 'description', 'comments', 'tags', 'created',
+ 'last_updated',
)
default_columns = ('pk', 'ssid', 'group', 'status', 'description', 'vlan', 'auth_type', 'interface_count')
diff --git a/netbox/wireless/tables/wirelesslink.py b/netbox/wireless/tables/wirelesslink.py
index 9d3a50848..dc8fb66db 100644
--- a/netbox/wireless/tables/wirelesslink.py
+++ b/netbox/wireless/tables/wirelesslink.py
@@ -4,7 +4,6 @@ import django_tables2 as tables
from netbox.tables import NetBoxTable, columns
from tenancy.tables import TenancyColumnsMixin
from wireless.models import *
-from .template_code import WIRELESS_LINK_DISTANCE
__all__ = (
'WirelessLinkTable',
@@ -37,10 +36,7 @@ class WirelessLinkTable(TenancyColumnsMixin, NetBoxTable):
verbose_name=_('Interface B'),
linkify=True
)
- distance = columns.TemplateColumn(
- template_code=WIRELESS_LINK_DISTANCE,
- order_by=('_abs_distance')
- )
+ distance = columns.DistanceColumn()
tags = columns.TagColumn(
url_name='wireless:wirelesslink_list'
)
diff --git a/netbox/wireless/tests/test_api.py b/netbox/wireless/tests/test_api.py
index 4b7545888..f768eafaf 100644
--- a/netbox/wireless/tests/test_api.py
+++ b/netbox/wireless/tests/test_api.py
@@ -1,7 +1,7 @@
from django.urls import reverse
from dcim.choices import InterfaceTypeChoices
-from dcim.models import Interface
+from dcim.models import Interface, Site
from tenancy.models import Tenant
from utilities.testing import APITestCase, APIViewTestCases, create_test_device
from wireless.choices import *
@@ -53,6 +53,12 @@ class WirelessLANTest(APIViewTestCases.APIViewTestCase):
@classmethod
def setUpTestData(cls):
+ sites = (
+ Site(name='Site 1', slug='site-1'),
+ Site(name='Site 2', slug='site-2'),
+ )
+ Site.objects.bulk_create(sites)
+
tenants = (
Tenant(name='Tenant 1', slug='tenant-1'),
Tenant(name='Tenant 2', slug='tenant-2'),
@@ -94,6 +100,8 @@ class WirelessLANTest(APIViewTestCases.APIViewTestCase):
'status': WirelessLANStatusChoices.STATUS_DISABLED,
'tenant': tenants[0].pk,
'auth_type': WirelessAuthTypeChoices.TYPE_WPA_ENTERPRISE,
+ 'scope_type': 'dcim.site',
+ 'scope_id': sites[1].pk,
},
]
diff --git a/netbox/wireless/tests/test_filtersets.py b/netbox/wireless/tests/test_filtersets.py
index 46eec4d7b..27aab83d8 100644
--- a/netbox/wireless/tests/test_filtersets.py
+++ b/netbox/wireless/tests/test_filtersets.py
@@ -1,8 +1,9 @@
from django.test import TestCase
from dcim.choices import InterfaceTypeChoices, LinkStatusChoices
-from dcim.models import Interface
+from dcim.models import Interface, Location, Region, Site, SiteGroup
from ipam.models import VLAN
+from netbox.choices import DistanceUnitChoices
from tenancy.models import Tenant
from wireless.choices import *
from wireless.filtersets import *
@@ -26,8 +27,18 @@ class WirelessLANGroupTestCase(TestCase, ChangeLoggedFilterSetTests):
group.save()
groups = (
- WirelessLANGroup(name='Wireless LAN Group 1A', slug='wireless-lan-group-1a', parent=parent_groups[0], description='foobar1'),
- WirelessLANGroup(name='Wireless LAN Group 1B', slug='wireless-lan-group-1b', parent=parent_groups[0], description='foobar2'),
+ WirelessLANGroup(
+ name='Wireless LAN Group 1A',
+ slug='wireless-lan-group-1a',
+ parent=parent_groups[0],
+ description='foobar1',
+ ),
+ WirelessLANGroup(
+ name='Wireless LAN Group 1B',
+ slug='wireless-lan-group-1b',
+ parent=parent_groups[0],
+ description='foobar2',
+ ),
WirelessLANGroup(name='Wireless LAN Group 2A', slug='wireless-lan-group-2a', parent=parent_groups[1]),
WirelessLANGroup(name='Wireless LAN Group 2B', slug='wireless-lan-group-2b', parent=parent_groups[1]),
WirelessLANGroup(name='Wireless LAN Group 3A', slug='wireless-lan-group-3a', parent=parent_groups[2]),
@@ -109,6 +120,36 @@ class WirelessLANTestCase(TestCase, ChangeLoggedFilterSetTests):
)
VLAN.objects.bulk_create(vlans)
+ regions = (
+ Region(name='Test Region 1', slug='test-region-1'),
+ Region(name='Test Region 2', slug='test-region-2'),
+ Region(name='Test Region 3', slug='test-region-3'),
+ )
+ for r in regions:
+ r.save()
+
+ site_groups = (
+ SiteGroup(name='Site Group 1', slug='site-group-1'),
+ SiteGroup(name='Site Group 2', slug='site-group-2'),
+ SiteGroup(name='Site Group 3', slug='site-group-3'),
+ )
+ for site_group in site_groups:
+ site_group.save()
+
+ sites = (
+ Site(name='Test Site 1', slug='test-site-1', region=regions[0], group=site_groups[0]),
+ Site(name='Test Site 2', slug='test-site-2', region=regions[1], group=site_groups[1]),
+ Site(name='Test Site 3', slug='test-site-3', region=regions[2], group=site_groups[2]),
+ )
+ Site.objects.bulk_create(sites)
+
+ locations = (
+ Location(name='Location 1', slug='location-1', site=sites[0]),
+ Location(name='Location 2', slug='location-2', site=sites[2]),
+ )
+ for location in locations:
+ location.save()
+
tenants = (
Tenant(name='Tenant 1', slug='tenant-1'),
Tenant(name='Tenant 2', slug='tenant-2'),
@@ -126,7 +167,8 @@ class WirelessLANTestCase(TestCase, ChangeLoggedFilterSetTests):
auth_type=WirelessAuthTypeChoices.TYPE_OPEN,
auth_cipher=WirelessAuthCipherChoices.CIPHER_AUTO,
auth_psk='PSK1',
- description='foobar1'
+ description='foobar1',
+ scope=sites[0]
),
WirelessLAN(
ssid='WLAN2',
@@ -137,7 +179,8 @@ class WirelessLANTestCase(TestCase, ChangeLoggedFilterSetTests):
auth_type=WirelessAuthTypeChoices.TYPE_WEP,
auth_cipher=WirelessAuthCipherChoices.CIPHER_TKIP,
auth_psk='PSK2',
- description='foobar2'
+ description='foobar2',
+ scope=locations[0]
),
WirelessLAN(
ssid='WLAN3',
@@ -148,12 +191,14 @@ class WirelessLANTestCase(TestCase, ChangeLoggedFilterSetTests):
auth_type=WirelessAuthTypeChoices.TYPE_WPA_PERSONAL,
auth_cipher=WirelessAuthCipherChoices.CIPHER_AES,
auth_psk='PSK3',
- description='foobar3'
+ description='foobar3',
+ scope=locations[1]
),
)
- WirelessLAN.objects.bulk_create(wireless_lans)
+ for wireless_lan in wireless_lans:
+ wireless_lan.save()
- device = create_test_device('Device 1')
+ device = create_test_device('Device 1', site=sites[0])
interfaces = (
Interface(device=device, name='Interface 1', type=InterfaceTypeChoices.TYPE_80211N),
Interface(device=device, name='Interface 2', type=InterfaceTypeChoices.TYPE_80211N),
@@ -216,6 +261,38 @@ class WirelessLANTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'interface_id': [interfaces[0].pk, interfaces[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+ def test_region(self):
+ regions = Region.objects.all()[:2]
+ params = {'region_id': [regions[0].pk, regions[1].pk]}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+ params = {'region': [regions[0].slug, regions[1].slug]}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+ def test_site_group(self):
+ site_groups = SiteGroup.objects.all()[:2]
+ params = {'site_group_id': [site_groups[0].pk, site_groups[1].pk]}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+ params = {'site_group': [site_groups[0].slug, site_groups[1].slug]}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+ def test_site(self):
+ sites = Site.objects.all()[:2]
+ params = {'site_id': [sites[0].pk, sites[1].pk]}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+ params = {'site': [sites[0].slug, sites[1].slug]}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+ def test_location(self):
+ locations = Location.objects.all()[:1]
+ params = {'location_id': [locations[0].pk,]}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
+ params = {'location': [locations[0].slug,]}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
+
+ def test_scope_type(self):
+ params = {'scope_type': 'dcim.location'}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
class WirelessLinkTestCase(TestCase, ChangeLoggedFilterSetTests):
queryset = WirelessLink.objects.all()
@@ -261,7 +338,7 @@ class WirelessLinkTestCase(TestCase, ChangeLoggedFilterSetTests):
auth_psk='PSK1',
tenant=tenants[0],
distance=10,
- distance_unit=WirelessLinkDistanceUnitChoices.UNIT_FOOT,
+ distance_unit=DistanceUnitChoices.UNIT_FOOT,
description='foobar1'
).save()
WirelessLink(
@@ -274,7 +351,7 @@ class WirelessLinkTestCase(TestCase, ChangeLoggedFilterSetTests):
auth_psk='PSK2',
tenant=tenants[1],
distance=20,
- distance_unit=WirelessLinkDistanceUnitChoices.UNIT_METER,
+ distance_unit=DistanceUnitChoices.UNIT_METER,
description='foobar2'
).save()
WirelessLink(
@@ -286,7 +363,7 @@ class WirelessLinkTestCase(TestCase, ChangeLoggedFilterSetTests):
auth_cipher=WirelessAuthCipherChoices.CIPHER_AES,
auth_psk='PSK3',
distance=30,
- distance_unit=WirelessLinkDistanceUnitChoices.UNIT_METER,
+ distance_unit=DistanceUnitChoices.UNIT_METER,
tenant=tenants[2],
).save()
WirelessLink(
@@ -324,7 +401,7 @@ class WirelessLinkTestCase(TestCase, ChangeLoggedFilterSetTests):
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_distance_unit(self):
- params = {'distance_unit': WirelessLinkDistanceUnitChoices.UNIT_FOOT}
+ params = {'distance_unit': DistanceUnitChoices.UNIT_FOOT}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
def test_description(self):
diff --git a/netbox/wireless/tests/test_views.py b/netbox/wireless/tests/test_views.py
index a4dc2aae5..51af37364 100644
--- a/netbox/wireless/tests/test_views.py
+++ b/netbox/wireless/tests/test_views.py
@@ -1,7 +1,9 @@
+from django.contrib.contenttypes.models import ContentType
from wireless.choices import *
from wireless.models import *
from dcim.choices import InterfaceTypeChoices, LinkStatusChoices
-from dcim.models import Interface
+from dcim.models import Interface, Site
+from netbox.choices import DistanceUnitChoices
from tenancy.models import Tenant
from utilities.testing import ViewTestCases, create_tags, create_test_device
@@ -55,6 +57,12 @@ class WirelessLANTestCase(ViewTestCases.PrimaryObjectViewTestCase):
@classmethod
def setUpTestData(cls):
+ sites = (
+ Site(name='Site 1', slug='site-1'),
+ Site(name='Site 2', slug='site-2'),
+ )
+ Site.objects.bulk_create(sites)
+
tenants = (
Tenant(name='Tenant 1', slug='tenant-1'),
Tenant(name='Tenant 2', slug='tenant-2'),
@@ -97,15 +105,28 @@ class WirelessLANTestCase(ViewTestCases.PrimaryObjectViewTestCase):
'ssid': 'WLAN2',
'group': groups[1].pk,
'status': WirelessLANStatusChoices.STATUS_DISABLED,
+ 'scope_type': ContentType.objects.get_for_model(Site).pk,
+ 'scope': sites[1].pk,
'tenant': tenants[1].pk,
'tags': [t.pk for t in tags],
}
cls.csv_data = (
- "group,ssid,status,tenant",
- f"Wireless LAN Group 2,WLAN4,{WirelessLANStatusChoices.STATUS_ACTIVE},{tenants[0].name}",
- f"Wireless LAN Group 2,WLAN5,{WirelessLANStatusChoices.STATUS_DISABLED},{tenants[1].name}",
- f"Wireless LAN Group 2,WLAN6,{WirelessLANStatusChoices.STATUS_RESERVED},{tenants[2].name}",
+ "group,ssid,status,tenant,scope_type,scope_id",
+ "Wireless LAN Group 2,WLAN4,{status},{tenant},,".format(
+ status=WirelessLANStatusChoices.STATUS_ACTIVE,
+ tenant=tenants[0].name
+ ),
+ "Wireless LAN Group 2,WLAN5,{status},{tenant},dcim.site,{site}".format(
+ status=WirelessLANStatusChoices.STATUS_DISABLED,
+ tenant=tenants[1].name,
+ site=sites[0].pk
+ ),
+ "Wireless LAN Group 2,WLAN6,{status},{tenant},dcim.site,{site}".format(
+ status=WirelessLANStatusChoices.STATUS_RESERVED,
+ tenant=tenants[2].name,
+ site=sites[1].pk
+ ),
)
cls.csv_update_data = (
@@ -147,11 +168,17 @@ class WirelessLinkTestCase(ViewTestCases.PrimaryObjectViewTestCase):
]
Interface.objects.bulk_create(interfaces)
- wirelesslink1 = WirelessLink(interface_a=interfaces[0], interface_b=interfaces[1], ssid='LINK1', tenant=tenants[0])
+ wirelesslink1 = WirelessLink(
+ interface_a=interfaces[0], interface_b=interfaces[1], ssid='LINK1', tenant=tenants[0]
+ )
wirelesslink1.save()
- wirelesslink2 = WirelessLink(interface_a=interfaces[2], interface_b=interfaces[3], ssid='LINK2', tenant=tenants[0])
+ wirelesslink2 = WirelessLink(
+ interface_a=interfaces[2], interface_b=interfaces[3], ssid='LINK2', tenant=tenants[0]
+ )
wirelesslink2.save()
- wirelesslink3 = WirelessLink(interface_a=interfaces[4], interface_b=interfaces[5], ssid='LINK3', tenant=tenants[0])
+ wirelesslink3 = WirelessLink(
+ interface_a=interfaces[4], interface_b=interfaces[5], ssid='LINK3', tenant=tenants[0]
+ )
wirelesslink3.save()
tags = create_tags('Alpha', 'Bravo', 'Charlie')
@@ -161,7 +188,7 @@ class WirelessLinkTestCase(ViewTestCases.PrimaryObjectViewTestCase):
'interface_b': interfaces[7].pk,
'status': LinkStatusChoices.STATUS_PLANNED,
'distance': 100,
- 'distance_unit': WirelessLinkDistanceUnitChoices.UNIT_FOOT,
+ 'distance_unit': DistanceUnitChoices.UNIT_FOOT,
'tenant': tenants[1].pk,
'tags': [t.pk for t in tags],
}
@@ -183,5 +210,5 @@ class WirelessLinkTestCase(ViewTestCases.PrimaryObjectViewTestCase):
cls.bulk_edit_data = {
'status': LinkStatusChoices.STATUS_PLANNED,
'distance': 50,
- 'distance_unit': WirelessLinkDistanceUnitChoices.UNIT_METER,
+ 'distance_unit': DistanceUnitChoices.UNIT_METER,
}
diff --git a/netbox/wireless/urls.py b/netbox/wireless/urls.py
index cf8ea5716..ee69c46de 100644
--- a/netbox/wireless/urls.py
+++ b/netbox/wireless/urls.py
@@ -1,33 +1,18 @@
from django.urls import include, path
from utilities.urls import get_model_urls
-from . import views
+from . import views # noqa F401
app_name = 'wireless'
urlpatterns = (
- # Wireless LAN groups
- path('wireless-lan-groups/', views.WirelessLANGroupListView.as_view(), name='wirelesslangroup_list'),
- path('wireless-lan-groups/add/', views.WirelessLANGroupEditView.as_view(), name='wirelesslangroup_add'),
- path('wireless-lan-groups/import/', views.WirelessLANGroupBulkImportView.as_view(), name='wirelesslangroup_import'),
- path('wireless-lan-groups/edit/', views.WirelessLANGroupBulkEditView.as_view(), name='wirelesslangroup_bulk_edit'),
- path('wireless-lan-groups/delete/', views.WirelessLANGroupBulkDeleteView.as_view(), name='wirelesslangroup_bulk_delete'),
+ path('wireless-lan-groups/', include(get_model_urls('wireless', 'wirelesslangroup', detail=False))),
path('wireless-lan-groups//', include(get_model_urls('wireless', 'wirelesslangroup'))),
- # Wireless LANs
- path('wireless-lans/', views.WirelessLANListView.as_view(), name='wirelesslan_list'),
- path('wireless-lans/add/', views.WirelessLANEditView.as_view(), name='wirelesslan_add'),
- path('wireless-lans/import/', views.WirelessLANBulkImportView.as_view(), name='wirelesslan_import'),
- path('wireless-lans/edit/', views.WirelessLANBulkEditView.as_view(), name='wirelesslan_bulk_edit'),
- path('wireless-lans/delete/', views.WirelessLANBulkDeleteView.as_view(), name='wirelesslan_bulk_delete'),
+ path('wireless-lans/', include(get_model_urls('wireless', 'wirelesslan', detail=False))),
path('wireless-lans//', include(get_model_urls('wireless', 'wirelesslan'))),
- # Wireless links
- path('wireless-links/', views.WirelessLinkListView.as_view(), name='wirelesslink_list'),
- path('wireless-links/add/', views.WirelessLinkEditView.as_view(), name='wirelesslink_add'),
- path('wireless-links/import/', views.WirelessLinkBulkImportView.as_view(), name='wirelesslink_import'),
- path('wireless-links/edit/', views.WirelessLinkBulkEditView.as_view(), name='wirelesslink_bulk_edit'),
- path('wireless-links/delete/', views.WirelessLinkBulkDeleteView.as_view(), name='wirelesslink_bulk_delete'),
+ path('wireless-links/', include(get_model_urls('wireless', 'wirelesslink', detail=False))),
path('wireless-links//', include(get_model_urls('wireless', 'wirelesslink'))),
)
diff --git a/netbox/wireless/views.py b/netbox/wireless/views.py
index 5063f0fee..c03564b27 100644
--- a/netbox/wireless/views.py
+++ b/netbox/wireless/views.py
@@ -10,6 +10,7 @@ from .models import *
# Wireless LAN groups
#
+@register_model_view(WirelessLANGroup, 'list', path='', detail=False)
class WirelessLANGroupListView(generic.ObjectListView):
queryset = WirelessLANGroup.objects.add_related_count(
WirelessLANGroup.objects.all(),
@@ -35,6 +36,7 @@ class WirelessLANGroupView(GetRelatedModelsMixin, generic.ObjectView):
}
+@register_model_view(WirelessLANGroup, 'add', detail=False)
@register_model_view(WirelessLANGroup, 'edit')
class WirelessLANGroupEditView(generic.ObjectEditView):
queryset = WirelessLANGroup.objects.all()
@@ -46,11 +48,13 @@ class WirelessLANGroupDeleteView(generic.ObjectDeleteView):
queryset = WirelessLANGroup.objects.all()
+@register_model_view(WirelessLANGroup, 'bulk_import', detail=False)
class WirelessLANGroupBulkImportView(generic.BulkImportView):
queryset = WirelessLANGroup.objects.all()
model_form = forms.WirelessLANGroupImportForm
+@register_model_view(WirelessLANGroup, 'bulk_edit', path='edit', detail=False)
class WirelessLANGroupBulkEditView(generic.BulkEditView):
queryset = WirelessLANGroup.objects.add_related_count(
WirelessLANGroup.objects.all(),
@@ -64,6 +68,7 @@ class WirelessLANGroupBulkEditView(generic.BulkEditView):
form = forms.WirelessLANGroupBulkEditForm
+@register_model_view(WirelessLANGroup, 'bulk_delete', path='delete', detail=False)
class WirelessLANGroupBulkDeleteView(generic.BulkDeleteView):
queryset = WirelessLANGroup.objects.add_related_count(
WirelessLANGroup.objects.all(),
@@ -80,6 +85,7 @@ class WirelessLANGroupBulkDeleteView(generic.BulkDeleteView):
# Wireless LANs
#
+@register_model_view(WirelessLAN, 'list', path='', detail=False)
class WirelessLANListView(generic.ObjectListView):
queryset = WirelessLAN.objects.annotate(
interface_count=count_related(Interface, 'wireless_lans')
@@ -105,6 +111,7 @@ class WirelessLANView(generic.ObjectView):
}
+@register_model_view(WirelessLAN, 'add', detail=False)
@register_model_view(WirelessLAN, 'edit')
class WirelessLANEditView(generic.ObjectEditView):
queryset = WirelessLAN.objects.all()
@@ -116,11 +123,13 @@ class WirelessLANDeleteView(generic.ObjectDeleteView):
queryset = WirelessLAN.objects.all()
+@register_model_view(WirelessLAN, 'bulk_import', detail=False)
class WirelessLANBulkImportView(generic.BulkImportView):
queryset = WirelessLAN.objects.all()
model_form = forms.WirelessLANImportForm
+@register_model_view(WirelessLAN, 'bulk_edit', path='edit', detail=False)
class WirelessLANBulkEditView(generic.BulkEditView):
queryset = WirelessLAN.objects.all()
filterset = filtersets.WirelessLANFilterSet
@@ -128,6 +137,7 @@ class WirelessLANBulkEditView(generic.BulkEditView):
form = forms.WirelessLANBulkEditForm
+@register_model_view(WirelessLAN, 'bulk_delete', path='delete', detail=False)
class WirelessLANBulkDeleteView(generic.BulkDeleteView):
queryset = WirelessLAN.objects.all()
filterset = filtersets.WirelessLANFilterSet
@@ -138,6 +148,7 @@ class WirelessLANBulkDeleteView(generic.BulkDeleteView):
# Wireless Links
#
+@register_model_view(WirelessLink, 'list', path='', detail=False)
class WirelessLinkListView(generic.ObjectListView):
queryset = WirelessLink.objects.all()
filterset = filtersets.WirelessLinkFilterSet
@@ -150,6 +161,7 @@ class WirelessLinkView(generic.ObjectView):
queryset = WirelessLink.objects.all()
+@register_model_view(WirelessLink, 'add', detail=False)
@register_model_view(WirelessLink, 'edit')
class WirelessLinkEditView(generic.ObjectEditView):
queryset = WirelessLink.objects.all()
@@ -161,11 +173,13 @@ class WirelessLinkDeleteView(generic.ObjectDeleteView):
queryset = WirelessLink.objects.all()
+@register_model_view(WirelessLink, 'bulk_import', detail=False)
class WirelessLinkBulkImportView(generic.BulkImportView):
queryset = WirelessLink.objects.all()
model_form = forms.WirelessLinkImportForm
+@register_model_view(WirelessLink, 'bulk_edit', path='edit', detail=False)
class WirelessLinkBulkEditView(generic.BulkEditView):
queryset = WirelessLink.objects.all()
filterset = filtersets.WirelessLinkFilterSet
@@ -173,6 +187,7 @@ class WirelessLinkBulkEditView(generic.BulkEditView):
form = forms.WirelessLinkBulkEditForm
+@register_model_view(WirelessLink, 'bulk_delete', path='delete', detail=False)
class WirelessLinkBulkDeleteView(generic.BulkDeleteView):
queryset = WirelessLink.objects.all()
filterset = filtersets.WirelessLinkFilterSet
diff --git a/requirements.txt b/requirements.txt
index 88d642d79..bd16b5d10 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,4 +1,4 @@
-Django==5.0.10
+Django==5.1.3
django-cors-headers==4.6.0
django-debug-toolbar==4.4.6
django-filter==24.3
diff --git a/ruff.toml b/ruff.toml
index 854404469..12dac331e 100644
--- a/ruff.toml
+++ b/ruff.toml
@@ -1,2 +1,15 @@
+exclude = [
+ "netbox/project-static/**"
+]
+line-length = 120
+
[lint]
-ignore = ["E501", "F403", "F405"]
+extend-select = ["E1", "E2", "E3", "E501", "W"]
+ignore = ["F403", "F405"]
+preview = true
+
+[lint.per-file-ignores]
+"template_code.py" = ["E501"]
+
+[format]
+quote-style = "single"