}>(
+ { element: null },
+ { persist: false },
+);
diff --git a/netbox/project-static/styles/rack-elevation.scss b/netbox/project-static/styles/rack-elevation.scss
index bf8063110..bc02995dd 100644
--- a/netbox/project-static/styles/rack-elevation.scss
+++ b/netbox/project-static/styles/rack-elevation.scss
@@ -81,17 +81,6 @@ svg {
opacity: 1;
}
- // When a reserved slot is hovered, use a more readable color for the 'Add Device' text.
- &.reserved:hover[class] + .add-device {
- fill: $black;
- }
-
- // Reserved rack unit background color.
- &.reserved[class],
- &.reserved:hover[class] {
- fill: url(#reserved);
- }
-
// Occupied rack unit background color.
&.occupied[class],
&.occupied:hover[class] {
@@ -108,4 +97,9 @@ svg {
opacity: 0;
}
}
+
+ // Reservation background color.
+ .reservation[class] {
+ fill: url(#reserved);
+ }
}
diff --git a/netbox/templates/circuits/circuit.html b/netbox/templates/circuits/circuit.html
index 881b6cca6..a4c41f871 100644
--- a/netbox/templates/circuits/circuit.html
+++ b/netbox/templates/circuits/circuit.html
@@ -45,6 +45,10 @@
Install Date |
{{ object.install_date|annotated_date|placeholder }} |
+
+ Termination Date |
+ {{ object.termination_date|annotated_date|placeholder }} |
+
Commit Rate |
{{ object.commit_rate|humanize_speed|placeholder }} |
diff --git a/netbox/templates/circuits/circuit_terminations_swap.html b/netbox/templates/circuits/circuit_terminations_swap.html
index 27eebb3d8..b2b30d635 100644
--- a/netbox/templates/circuits/circuit_terminations_swap.html
+++ b/netbox/templates/circuits/circuit_terminations_swap.html
@@ -10,7 +10,7 @@
{% if termination_a %}
{{ termination_a.site }} {% if termination_a.interface %}- {{ termination_a.interface.device }} {{ termination_a.interface }}{% endif %}
{% else %}
- None
+ {{ ''|placeholder }}
{% endif %}
@@ -18,7 +18,7 @@
{% if termination_z %}
{{ termination_z.site }} {% if termination_z.interface %}- {{ termination_z.interface.device }} {{ termination_z.interface }}{% endif %}
{% else %}
- None
+ {{ ''|placeholder }}
{% endif %}
diff --git a/netbox/templates/circuits/inc/circuit_termination.html b/netbox/templates/circuits/inc/circuit_termination.html
index fdb01e803..b673cd4a3 100644
--- a/netbox/templates/circuits/inc/circuit_termination.html
+++ b/netbox/templates/circuits/inc/circuit_termination.html
@@ -94,7 +94,7 @@
{% elif termination.port_speed %}
{{ termination.port_speed|humanize_speed }}
{% else %}
- —
+ {{ ''|placeholder }}
{% endif %}
diff --git a/netbox/templates/circuits/provider.html b/netbox/templates/circuits/provider.html
index 1bf63f2d5..60bf8cfbc 100644
--- a/netbox/templates/circuits/provider.html
+++ b/netbox/templates/circuits/provider.html
@@ -50,7 +50,7 @@
{% if object.portal_url %}
{{ object.portal_url }}
{% else %}
- —
+ {{ ''|placeholder }}
{% endif %}
diff --git a/netbox/templates/dcim/cable.html b/netbox/templates/dcim/cable.html
index f1cf986e6..cd171cbb3 100644
--- a/netbox/templates/dcim/cable.html
+++ b/netbox/templates/dcim/cable.html
@@ -40,7 +40,7 @@
{% if object.color %}
{% else %}
- —
+ {{ ''|placeholder }}
{% endif %}
@@ -50,7 +50,7 @@
{% if object.length %}
{{ object.length|floatformat }} {{ object.get_length_unit_display }}
{% else %}
- —
+ {{ ''|placeholder }}
{% endif %}
diff --git a/netbox/templates/dcim/device.html b/netbox/templates/dcim/device.html
index d075a801d..d3d6f03dc 100644
--- a/netbox/templates/dcim/device.html
+++ b/netbox/templates/dcim/device.html
@@ -23,7 +23,7 @@
{% endfor %}
{{ object.site.region|linkify }}
{% else %}
- None
+ {{ ''|placeholder }}
{% endif %}
@@ -40,7 +40,7 @@
{% endfor %}
{{ object.location|linkify }}
{% else %}
- None
+ {{ ''|placeholder }}
{% endif %}
@@ -50,7 +50,7 @@
{% if object.rack %}
{{ object.rack }}
{% else %}
- None
+ {{ ''|placeholder }}
{% endif %}
@@ -69,7 +69,7 @@
{% elif object.rack and object.device_type.u_height %}
Not racked
{% else %}
- —
+ {{ ''|placeholder }}
{% endif %}
@@ -180,7 +180,7 @@
(NAT: {{ object.primary_ip4.nat_outside.address.ip|linkify }})
{% endif %}
{% else %}
- —
+ {{ ''|placeholder }}
{% endif %}
@@ -195,7 +195,7 @@
(NAT: {{ object.primary_ip6.nat_outside.address.ip|linkify }})
{% endif %}
{% else %}
- —
+ {{ ''|placeholder }}
{% endif %}
diff --git a/netbox/templates/dcim/devicerole.html b/netbox/templates/dcim/devicerole.html
index 288101c08..610c53071 100644
--- a/netbox/templates/dcim/devicerole.html
+++ b/netbox/templates/dcim/devicerole.html
@@ -54,7 +54,7 @@
{% if object.vm_role %}
{{ virtualmachine_count }}
{% else %}
- —
+ {{ ''|placeholder }}
{% endif %}
diff --git a/netbox/templates/dcim/devicetype.html b/netbox/templates/dcim/devicetype.html
index e717a48aa..bb3ec9d2e 100644
--- a/netbox/templates/dcim/devicetype.html
+++ b/netbox/templates/dcim/devicetype.html
@@ -55,7 +55,7 @@
{% else %}
- —
+ {{ ''|placeholder }}
{% endif %}
@@ -67,7 +67,7 @@
{% else %}
- —
+ {{ ''|placeholder }}
{% endif %}
diff --git a/netbox/templates/dcim/interface.html b/netbox/templates/dcim/interface.html
index 358922730..e98750518 100644
--- a/netbox/templates/dcim/interface.html
+++ b/netbox/templates/dcim/interface.html
@@ -69,6 +69,14 @@
Description |
{{ object.description|placeholder }} |
+
+ PoE Mode |
+ {{ object.get_poe_mode_display|placeholder }} |
+
+
+ PoE Mode |
+ {{ object.get_poe_type_display|placeholder }} |
+
802.1Q Mode |
{{ object.get_mode_display|placeholder }} |
@@ -321,7 +329,7 @@
{% if object.rf_channel_frequency %}
{{ object.rf_channel_frequency|simplify_decimal }} MHz
{% else %}
- —
+ {{ ''|placeholder }}
{% endif %}
{% if peer %}
@@ -329,7 +337,7 @@
{% if peer.rf_channel_frequency %}
{{ peer.rf_channel_frequency|simplify_decimal }} MHz
{% else %}
- —
+ {{ ''|placeholder }}
{% endif %}
{% endif %}
@@ -340,7 +348,7 @@
{% if object.rf_channel_width %}
{{ object.rf_channel_width|simplify_decimal }} MHz
{% else %}
- —
+ {{ ''|placeholder }}
{% endif %}
{% if peer %}
@@ -348,7 +356,7 @@
{% if peer.rf_channel_width %}
{{ peer.rf_channel_width|simplify_decimal }} MHz
{% else %}
- —
+ {{ ''|placeholder }}
{% endif %}
{% endif %}
diff --git a/netbox/templates/dcim/location.html b/netbox/templates/dcim/location.html
index b2b2bc4cd..f0335036f 100644
--- a/netbox/templates/dcim/location.html
+++ b/netbox/templates/dcim/location.html
@@ -43,6 +43,10 @@
Parent |
{{ object.parent|linkify|placeholder }} |
+
+ Status |
+ {% badge object.get_status_display bg_color=object.get_status_color %} |
+
Tenant |
diff --git a/netbox/templates/dcim/module.html b/netbox/templates/dcim/module.html
index 130cd046f..f2dac38f2 100644
--- a/netbox/templates/dcim/module.html
+++ b/netbox/templates/dcim/module.html
@@ -18,25 +18,25 @@
diff --git a/netbox/templates/dcim/powerfeed.html b/netbox/templates/dcim/powerfeed.html
index 777af5563..ed1f9a1cd 100644
--- a/netbox/templates/dcim/powerfeed.html
+++ b/netbox/templates/dcim/powerfeed.html
@@ -44,7 +44,7 @@
{% if object.connected_endpoint %}
{{ object.connected_endpoint.device|linkify }} ({{ object.connected_endpoint }})
{% else %}
- None
+ {{ ''|placeholder }}
{% endif %}
|
diff --git a/netbox/templates/dcim/poweroutlet.html b/netbox/templates/dcim/poweroutlet.html
index 6408bc759..c312bee03 100644
--- a/netbox/templates/dcim/poweroutlet.html
+++ b/netbox/templates/dcim/poweroutlet.html
@@ -44,7 +44,7 @@
Power Port |
- {{ object.power_port }} |
+ {{ object.power_port|linkify|placeholder }} |
Feed Leg |
diff --git a/netbox/templates/dcim/rack.html b/netbox/templates/dcim/rack.html
index 6574e9b74..42f6a8e99 100644
--- a/netbox/templates/dcim/rack.html
+++ b/netbox/templates/dcim/rack.html
@@ -53,7 +53,7 @@
{% endfor %}
{{ object.location|linkify }}
{% else %}
- None
+ {{ ''|placeholder }}
{% endif %}
@@ -115,7 +115,7 @@
{% if object.type %}
{{ object.get_type_display }}
{% else %}
- None
+ {{ ''|placeholder }}
{% endif %}
@@ -133,7 +133,7 @@
{% if object.outer_width %}
{{ object.outer_width }} {{ object.get_outer_unit_display }}
{% else %}
- —
+ {{ ''|placeholder }}
{% endif %}
@@ -143,7 +143,7 @@
{% if object.outer_depth %}
{{ object.outer_depth }} {{ object.get_outer_unit_display }}
{% else %}
- —
+ {{ ''|placeholder }}
{% endif %}
diff --git a/netbox/templates/dcim/site.html b/netbox/templates/dcim/site.html
index c15cab468..ab04ea018 100644
--- a/netbox/templates/dcim/site.html
+++ b/netbox/templates/dcim/site.html
@@ -34,7 +34,7 @@
{% endfor %}
{{ object.region|linkify }}
{% else %}
- None
+ {{ ''|placeholder }}
{% endif %}
@@ -47,7 +47,7 @@
{% endfor %}
{{ object.group|linkify }}
{% else %}
- None
+ {{ ''|placeholder }}
{% endif %}
@@ -79,7 +79,7 @@
{{ object.time_zone }} (UTC {{ object.time_zone|tzoffset }})
Site time: {% timezone object.time_zone %}{% annotated_now %}{% endtimezone %}
{% else %}
- —
+ {{ ''|placeholder }}
{% endif %}
@@ -94,7 +94,7 @@
{{ object.physical_address|linebreaksbr }}
{% else %}
- —
+ {{ ''|placeholder }}
{% endif %}
@@ -113,7 +113,7 @@
{{ object.latitude }}, {{ object.longitude }}
{% else %}
- —
+ {{ ''|placeholder }}
{% endif %}
diff --git a/netbox/templates/dcim/virtualchassis.html b/netbox/templates/dcim/virtualchassis.html
index 4683b775b..1ff9f2e9a 100644
--- a/netbox/templates/dcim/virtualchassis.html
+++ b/netbox/templates/dcim/virtualchassis.html
@@ -15,74 +15,70 @@
{% block content %}
-
-
-
-
-
- Domain |
- {{ object.domain|placeholder }} |
-
-
- Master |
- {{ object.master|linkify }} |
-
-
-
-
- {% include 'inc/panels/custom_fields.html' %}
- {% include 'inc/panels/tags.html' %}
- {% plugin_left_page object %}
+
+
+
+
+
+ Domain |
+ {{ object.domain|placeholder }} |
+
+
+ Master |
+ {{ object.master|linkify }} |
+
+
+
+
+ {% include 'inc/panels/custom_fields.html' %}
+ {% include 'inc/panels/tags.html' %}
+ {% plugin_left_page object %}
-
-
-
-
-
- Device |
- Position |
- Master |
- Priority |
-
- {% for vc_member in members %}
-
-
- {{ vc_member|linkify }}
- |
-
- {% badge vc_member.vc_position show_empty=True %}
- |
-
- {% if object.master == vc_member %}
- {% checkmark True %}
- {% endif %}
- |
-
- {{ vc_member.vc_priority|placeholder }}
- |
-
- {% endfor %}
-
-
- {% if perms.dcim.change_virtualchassis %}
-
- {% endif %}
+
+
+
+
+
+ Device |
+ Position |
+ Master |
+ Priority |
+
+ {% for vc_member in members %}
+
+
+ {{ vc_member|linkify }}
+ |
+
+ {% badge vc_member.vc_position show_empty=True %}
+ |
+
+ {% if object.master == vc_member %}
+ {% checkmark True %}
+ {% endif %}
+ |
+
+ {{ vc_member.vc_priority|placeholder }}
+ |
+
+ {% endfor %}
+
- {% plugin_right_page object %}
+ {% if perms.dcim.change_virtualchassis %}
+
+ {% endif %}
+
+ {% plugin_right_page object %}
-
- {% plugin_full_width_page object %}
-
+
+ {% plugin_full_width_page object %}
+
{% endblock %}
diff --git a/netbox/templates/dcim/virtualchassis_edit.html b/netbox/templates/dcim/virtualchassis_edit.html
index 327f20531..275391c61 100644
--- a/netbox/templates/dcim/virtualchassis_edit.html
+++ b/netbox/templates/dcim/virtualchassis_edit.html
@@ -57,7 +57,7 @@
{% if device.rack %}
{{ device.rack }} / {{ device.position }}
{% else %}
-
—
+ {{ ''|placeholder }}
{% endif %}
{{ device.serial|placeholder }} |
diff --git a/netbox/templates/extras/configcontext_edit.html b/netbox/templates/extras/configcontext_edit.html
deleted file mode 100644
index 7b37a69c6..000000000
--- a/netbox/templates/extras/configcontext_edit.html
+++ /dev/null
@@ -1,37 +0,0 @@
-{% extends 'generic/object_edit.html' %}
-{% load form_helpers %}
-
-{% block form %}
-
-
-
- {% render_field form.name %}
- {% render_field form.weight %}
- {% render_field form.description %}
- {% render_field form.is_active %}
-
-
-
-
-
- {% render_field form.regions %}
- {% render_field form.site_groups %}
- {% render_field form.sites %}
- {% render_field form.device_types %}
- {% render_field form.roles %}
- {% render_field form.platforms %}
- {% render_field form.cluster_types %}
- {% render_field form.cluster_groups %}
- {% render_field form.clusters %}
- {% render_field form.tenant_groups %}
- {% render_field form.tenants %}
- {% render_field form.tags %}
-
-
-
-
-
- {% render_field form.data %}
-
-
-{% endblock %}
diff --git a/netbox/templates/extras/customfield.html b/netbox/templates/extras/customfield.html
index e8c3df460..ff4e6e08c 100644
--- a/netbox/templates/extras/customfield.html
+++ b/netbox/templates/extras/customfield.html
@@ -19,6 +19,10 @@
Label |
{{ object.label|placeholder }} |
+
+ Group Name |
+ {{ object.group_name|placeholder }} |
+
Type |
@@ -38,6 +42,14 @@
| Weight |
{{ object.weight }} |
+
+ Filter Logic |
+ {{ object.get_filter_logic_display }} |
+
+
+ UI Visibility |
+ {{ object.get_ui_visibility_display }} |
+
@@ -57,14 +69,10 @@
{% if object.choices %}
{{ object.choices|join:", " }}
{% else %}
- —
+ {{ ''|placeholder }}
{% endif %}
-
- Filter Logic |
- {{ object.get_filter_logic_display }} |
-
@@ -105,7 +113,7 @@
{% if object.validation_regex %}
{{ object.validation_regex }}
{% else %}
- —
+ {{ ''|placeholder }}
{% endif %}
diff --git a/netbox/templates/extras/htmx/report_result.html b/netbox/templates/extras/htmx/report_result.html
index 9b3e9db5f..c20bf5fe2 100644
--- a/netbox/templates/extras/htmx/report_result.html
+++ b/netbox/templates/extras/htmx/report_result.html
@@ -57,7 +57,7 @@
{% elif obj %}
{{ obj }}
{% else %}
- —
+ {{ ''|placeholder }}
{% endif %}
{{ message|markdown }} |
diff --git a/netbox/templates/generic/bulk_import.html b/netbox/templates/generic/bulk_import.html
index 43e078826..1a85c3a21 100644
--- a/netbox/templates/generic/bulk_import.html
+++ b/netbox/templates/generic/bulk_import.html
@@ -76,14 +76,14 @@ Context:
{% if field.required %}
{% checkmark True true="Required" %}
{% else %}
- —
+ {{ ''|placeholder }}
{% endif %}
{% if field.to_field_name %}
{{ field.to_field_name }}
{% else %}
- —
+ {{ ''|placeholder }}
{% endif %}
|
diff --git a/netbox/templates/inc/panels/custom_fields.html b/netbox/templates/inc/panels/custom_fields.html
index 32e586d3a..b18d44030 100644
--- a/netbox/templates/inc/panels/custom_fields.html
+++ b/netbox/templates/inc/panels/custom_fields.html
@@ -1,49 +1,54 @@
{% load helpers %}
-{% with custom_fields=object.get_custom_fields %}
- {% if custom_fields %}
-
-
-
-
- {% for field, value in custom_fields.items %}
-
-
- {{ field }}
- |
-
- {% if field.type == 'integer' and value is not None %}
- {{ value }}
- {% elif field.type == 'longtext' and value %}
- {{ value|markdown }}
- {% elif field.type == 'boolean' and value == True %}
- {% checkmark value true="True" %}
- {% elif field.type == 'boolean' and value == False %}
- {% checkmark value false="False" %}
- {% elif field.type == 'url' and value %}
- {{ value|truncatechars:70 }}
- {% elif field.type == 'json' and value %}
- {{ value|json }}
- {% elif field.type == 'multiselect' and value %}
- {{ value|join:", " }}
- {% elif field.type == 'object' and value %}
- {{ value|linkify }}
- {% elif field.type == 'multiobject' and value %}
- {% for obj in value %}
- {{ obj|linkify }}{% if not forloop.last %} {% endif %}
- {% endfor %}
- {% elif value %}
- {{ value }}
- {% elif field.required %}
- Not defined
- {% else %}
- —
- {% endif %}
- |
-
+{% with custom_fields=object.get_custom_fields_by_group %}
+ {% if custom_fields %}
+
+
+
+ {% for group_name, fields in custom_fields.items %}
+ {% if group_name %}
+ {{ group_name }}
+ {% endif %}
+
+ {% for field, value in fields.items %}
+
+
+ {{ field }}
+ |
+
+ {% if field.type == 'integer' and value is not None %}
+ {{ value }}
+ {% elif field.type == 'longtext' and value %}
+ {{ value|markdown }}
+ {% elif field.type == 'boolean' and value == True %}
+ {% checkmark value true="True" %}
+ {% elif field.type == 'boolean' and value == False %}
+ {% checkmark value false="False" %}
+ {% elif field.type == 'url' and value %}
+ {{ value|truncatechars:70 }}
+ {% elif field.type == 'json' and value %}
+ {{ value|json }}
+ {% elif field.type == 'multiselect' and value %}
+ {{ value|join:", " }}
+ {% elif field.type == 'object' and value %}
+ {{ value|linkify }}
+ {% elif field.type == 'multiobject' and value %}
+ {% for obj in value %}
+ {{ obj|linkify }}{% if not forloop.last %} {% endif %}
{% endfor %}
- |
-
-
- {% endif %}
+ {% elif value %}
+ {{ value }}
+ {% elif field.required %}
+ Not defined
+ {% else %}
+ {{ ''|placeholder }}
+ {% endif %}
+
+
+ {% endfor %}
+
+ {% endfor %}
+
+
+ {% endif %}
{% endwith %}
diff --git a/netbox/templates/ipam/ipaddress.html b/netbox/templates/ipam/ipaddress.html
index 7867e829b..8b628c2f7 100644
--- a/netbox/templates/ipam/ipaddress.html
+++ b/netbox/templates/ipam/ipaddress.html
@@ -52,7 +52,7 @@
{% if object.role %}
{{ object.get_role_display }}
{% else %}
- None
+ {{ ''|placeholder }}
{% endif %}
|
@@ -73,7 +73,7 @@
{% endif %}
{{ object.assigned_object|linkify }}
{% else %}
- —
+ {{ ''|placeholder }}
{% endif %}
@@ -86,13 +86,19 @@
({{ object.nat_inside.assigned_object.parent_object|linkify }})
{% endif %}
{% else %}
- None
+ {{ ''|placeholder }}
{% endif %}
- NAT (outside) |
- {{ object.nat_outside|linkify|placeholder }} |
+ Outside NAT IPs |
+
+ {% for ip in object.nat_outside.all %}
+ {{ ip|linkify }}
+ {% empty %}
+ {{ ''|placeholder }}
+ {% endfor %}
+ |
@@ -128,6 +134,24 @@
{% include 'inc/panel_table.html' with table=related_ips_table heading='Related IP Addresses' %}
+
+
+
+ {% if services %}
+
+ {% for service in services %}
+ {% include 'ipam/inc/service.html' %}
+ {% endfor %}
+
+ {% else %}
+
+ None
+
+ {% endif %}
+
+
{% plugin_right_page object %}
diff --git a/netbox/templates/ipam/prefix.html b/netbox/templates/ipam/prefix.html
index e2ba76a82..a47566ff7 100644
--- a/netbox/templates/ipam/prefix.html
+++ b/netbox/templates/ipam/prefix.html
@@ -39,7 +39,7 @@
{% if aggregate %}
{{ aggregate.prefix }} ({{ aggregate.rir }})
{% else %}
- None
+ {{ ''|placeholder }}
{% endif %}
@@ -52,7 +52,7 @@
{% endif %}
{{ object.site|linkify }}
{% else %}
- None
+ {{ ''|placeholder }}
{% endif %}
@@ -65,7 +65,7 @@
{% endif %}
{{ object.vlan|linkify }}
{% else %}
- None
+ {{ ''|placeholder }}
{% endif %}
@@ -138,7 +138,7 @@
{{ first_available_ip }}
{% endif %}
{% else %}
- None
+ {{ ''|placeholder }}
{% endif %}
{% endwith %}
diff --git a/netbox/templates/ipam/role.html b/netbox/templates/ipam/role.html
index 49570099d..a6ef2c6d4 100644
--- a/netbox/templates/ipam/role.html
+++ b/netbox/templates/ipam/role.html
@@ -45,7 +45,7 @@
{% if ipranges_count %}
{{ ipranges_count }}
{% else %}
- —
+ {{ ''|placeholder }}
{% endif %}
{% endwith %}
@@ -57,7 +57,7 @@
{% if vlans_count %}
{{ vlans_count }}
{% else %}
- —
+ {{ ''|placeholder }}
{% endif %}
{% endwith %}
diff --git a/netbox/templates/ipam/service.html b/netbox/templates/ipam/service.html
index 71ea20fa5..47ae70dc9 100644
--- a/netbox/templates/ipam/service.html
+++ b/netbox/templates/ipam/service.html
@@ -44,7 +44,7 @@
{% for ipaddress in object.ipaddresses.all %}
{{ ipaddress|linkify }}
{% empty %}
- None
+ {{ ''|placeholder }}
{% endfor %}
diff --git a/netbox/templates/ipam/vlan.html b/netbox/templates/ipam/vlan.html
index f74149ad6..fd0ba36a3 100644
--- a/netbox/templates/ipam/vlan.html
+++ b/netbox/templates/ipam/vlan.html
@@ -21,7 +21,7 @@
{% endif %}
{{ object.site|linkify }}
{% else %}
- None
+ {{ ''|placeholder }}
{% endif %}
@@ -56,7 +56,7 @@
{% if object.role %}
{{ object.role }}
{% else %}
- None
+ {{ ''|placeholder }}
{% endif %}
diff --git a/netbox/templates/tenancy/contact.html b/netbox/templates/tenancy/contact.html
index f55e87895..8e71628e9 100644
--- a/netbox/templates/tenancy/contact.html
+++ b/netbox/templates/tenancy/contact.html
@@ -35,7 +35,7 @@
{% if object.phone %}
{{ object.phone }}
{% else %}
- None
+ {{ ''|placeholder }}
{% endif %}
@@ -45,7 +45,7 @@
{% if object.email %}
{{ object.email }}
{% else %}
- None
+ {{ ''|placeholder }}
{% endif %}
diff --git a/netbox/templates/tenancy/tenant.html b/netbox/templates/tenancy/tenant.html
index e4c1db006..52c13e1aa 100644
--- a/netbox/templates/tenancy/tenant.html
+++ b/netbox/templates/tenancy/tenant.html
@@ -77,6 +77,10 @@
Prefixes
+
IP addresses
diff --git a/netbox/templates/users/api_tokens.html b/netbox/templates/users/api_tokens.html
index a019cbd1f..24b32cc9b 100644
--- a/netbox/templates/users/api_tokens.html
+++ b/netbox/templates/users/api_tokens.html
@@ -50,7 +50,14 @@
Disabled
{% endif %}
-
+
+ Allowed Source IPs
+ {% if token.allowed_ips %}
+ {{ token.allowed_ips|join:', ' }}
+ {% else %}
+ Any
+ {% endif %}
+
{% if token.description %}
{{ token.description }}
{% endif %}
diff --git a/netbox/templates/users/profile.html b/netbox/templates/users/profile.html
index 112603126..913784c94 100644
--- a/netbox/templates/users/profile.html
+++ b/netbox/templates/users/profile.html
@@ -21,7 +21,7 @@
{% if request.user.first_name or request.user.last_name %}
{{ request.user.first_name }} {{ request.user.last_name }}
{% else %}
- —
+ {{ ''|placeholder }}
{% endif %}
diff --git a/netbox/templates/virtualization/virtualmachine.html b/netbox/templates/virtualization/virtualmachine.html
index 0dec4968c..f62da6fed 100644
--- a/netbox/templates/virtualization/virtualmachine.html
+++ b/netbox/templates/virtualization/virtualmachine.html
@@ -49,7 +49,7 @@
(NAT: {{ object.primary_ip4.nat_outside.address.ip }})
{% endif %}
{% else %}
- —
+ {{ ''|placeholder }}
{% endif %}
@@ -64,7 +64,7 @@
(NAT: {{ object.primary_ip6.nat_outside.address.ip }})
{% endif %}
{% else %}
- —
+ {{ ''|placeholder }}
{% endif %}
@@ -78,31 +78,39 @@
-
+
+
+ Site |
+
+ {{ object.site|linkify|placeholder }}
+ |
+
Cluster |
{% if object.cluster.group %}
{{ object.cluster.group|linkify }} /
{% endif %}
- {{ object.cluster|linkify }}
+ {{ object.cluster|linkify|placeholder }}
|
Cluster Type |
{{ object.cluster.type }} |
+
+ Device |
+
+ {{ object.device|linkify|placeholder }}
+ |
+
-
+
@@ -115,7 +123,7 @@
{% if object.memory %}
{{ object.memory|humanize_megabytes }}
{% else %}
- —
+ {{ ''|placeholder }}
{% endif %}
@@ -125,7 +133,7 @@
{% if object.disk %}
{{ object.disk }} GB
{% else %}
- —
+ {{ ''|placeholder }}
{% endif %}
diff --git a/netbox/templates/wireless/inc/wirelesslink_interface.html b/netbox/templates/wireless/inc/wirelesslink_interface.html
index db4f84f0a..7732816a7 100644
--- a/netbox/templates/wireless/inc/wirelesslink_interface.html
+++ b/netbox/templates/wireless/inc/wirelesslink_interface.html
@@ -33,7 +33,7 @@
{% if interface.rf_channel_frequency %}
{{ interface.rf_channel_frequency|simplify_decimal }} MHz
{% else %}
- —
+ {{ ''|placeholder }}
{% endif %}
@@ -43,7 +43,7 @@
{% if interface.rf_channel_width %}
{{ interface.rf_channel_width|simplify_decimal }} MHz
{% else %}
- —
+ {{ ''|placeholder }}
{% endif %}
diff --git a/netbox/tenancy/filtersets.py b/netbox/tenancy/filtersets.py
index 8ca4ae29c..dd14a412b 100644
--- a/netbox/tenancy/filtersets.py
+++ b/netbox/tenancy/filtersets.py
@@ -112,6 +112,12 @@ class ContactModelFilterSet(django_filters.FilterSet):
queryset=ContactRole.objects.all(),
label='Contact Role'
)
+ contact_group = TreeNodeMultipleChoiceFilter(
+ queryset=ContactGroup.objects.all(),
+ field_name='contacts__contact__group',
+ lookup_expr='in',
+ label='Contact group',
+ )
#
diff --git a/netbox/tenancy/forms/filtersets.py b/netbox/tenancy/forms/filtersets.py
index 15d7773b7..02589d733 100644
--- a/netbox/tenancy/forms/filtersets.py
+++ b/netbox/tenancy/forms/filtersets.py
@@ -32,7 +32,7 @@ class TenantFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm):
model = Tenant
fieldsets = (
(None, ('q', 'tag', 'group_id')),
- ('Contacts', ('contact', 'contact_role'))
+ ('Contacts', ('contact', 'contact_role', 'contact_group'))
)
group_id = DynamicModelMultipleChoiceField(
queryset=TenantGroup.objects.all(),
diff --git a/netbox/tenancy/forms/forms.py b/netbox/tenancy/forms/forms.py
index 5dcad1d43..5e78bc540 100644
--- a/netbox/tenancy/forms/forms.py
+++ b/netbox/tenancy/forms/forms.py
@@ -58,3 +58,8 @@ class ContactModelFilterForm(forms.Form):
required=False,
label=_('Contact Role')
)
+ contact_group = DynamicModelMultipleChoiceField(
+ queryset=ContactGroup.objects.all(),
+ required=False,
+ label=_('Contact Group')
+ )
diff --git a/netbox/tenancy/tables/contacts.py b/netbox/tenancy/tables/contacts.py
index 17abc5a5b..234dc2ad7 100644
--- a/netbox/tenancy/tables/contacts.py
+++ b/netbox/tenancy/tables/contacts.py
@@ -18,7 +18,7 @@ class ContactGroupTable(NetBoxTable):
)
contact_count = columns.LinkedCountColumn(
viewname='tenancy:contact_list',
- url_params={'role_id': 'pk'},
+ url_params={'group_id': 'pk'},
verbose_name='Contacts'
)
tags = columns.TagColumn(
diff --git a/netbox/tenancy/views.py b/netbox/tenancy/views.py
index 58ad98e8f..f6f95b123 100644
--- a/netbox/tenancy/views.py
+++ b/netbox/tenancy/views.py
@@ -3,7 +3,7 @@ from django.shortcuts import get_object_or_404
from circuits.models import Circuit
from dcim.models import Cable, Device, Location, Rack, RackReservation, Site
-from ipam.models import Aggregate, IPAddress, Prefix, VLAN, VRF, ASN
+from ipam.models import Aggregate, IPAddress, IPRange, Prefix, VLAN, VRF, ASN
from netbox.views import generic
from utilities.utils import count_related
from virtualization.models import VirtualMachine, Cluster
@@ -104,8 +104,9 @@ class TenantView(generic.ObjectView):
'location_count': Location.objects.restrict(request.user, 'view').filter(tenant=instance).count(),
'device_count': Device.objects.restrict(request.user, 'view').filter(tenant=instance).count(),
'vrf_count': VRF.objects.restrict(request.user, 'view').filter(tenant=instance).count(),
- 'prefix_count': Prefix.objects.restrict(request.user, 'view').filter(tenant=instance).count(),
'aggregate_count': Aggregate.objects.restrict(request.user, 'view').filter(tenant=instance).count(),
+ 'prefix_count': Prefix.objects.restrict(request.user, 'view').filter(tenant=instance).count(),
+ 'iprange_count': IPRange.objects.restrict(request.user, 'view').filter(tenant=instance).count(),
'ipaddress_count': IPAddress.objects.restrict(request.user, 'view').filter(tenant=instance).count(),
'vlan_count': VLAN.objects.restrict(request.user, 'view').filter(tenant=instance).count(),
'circuit_count': Circuit.objects.restrict(request.user, 'view').filter(tenant=instance).count(),
diff --git a/netbox/users/admin/__init__.py b/netbox/users/admin/__init__.py
index 320c28df2..2db822cfe 100644
--- a/netbox/users/admin/__init__.py
+++ b/netbox/users/admin/__init__.py
@@ -58,9 +58,13 @@ class UserAdmin(UserAdmin_):
class TokenAdmin(admin.ModelAdmin):
form = forms.TokenAdminForm
list_display = [
- 'key', 'user', 'created', 'expires', 'last_used', 'write_enabled', 'description'
+ 'key', 'user', 'created', 'expires', 'last_used', 'write_enabled', 'description', 'list_allowed_ips'
]
+ def list_allowed_ips(self, obj):
+ return obj.allowed_ips or 'Any'
+ list_allowed_ips.short_description = "Allowed IPs"
+
#
# Permissions
diff --git a/netbox/users/admin/forms.py b/netbox/users/admin/forms.py
index 7d0212441..bc3d44862 100644
--- a/netbox/users/admin/forms.py
+++ b/netbox/users/admin/forms.py
@@ -51,7 +51,7 @@ class TokenAdminForm(forms.ModelForm):
class Meta:
fields = [
- 'user', 'key', 'write_enabled', 'expires', 'description'
+ 'user', 'key', 'write_enabled', 'expires', 'description', 'allowed_ips'
]
model = Token
diff --git a/netbox/users/api/serializers.py b/netbox/users/api/serializers.py
index 059bb0bd7..177cce39c 100644
--- a/netbox/users/api/serializers.py
+++ b/netbox/users/api/serializers.py
@@ -2,7 +2,7 @@ from django.contrib.auth.models import Group, User
from django.contrib.contenttypes.models import ContentType
from rest_framework import serializers
-from netbox.api import ContentTypeField, SerializedPKRelatedField, ValidatedModelSerializer
+from netbox.api import ContentTypeField, IPNetworkSerializer, SerializedPKRelatedField, ValidatedModelSerializer
from users.models import ObjectPermission, Token
from .nested_serializers import *
@@ -64,10 +64,19 @@ class TokenSerializer(ValidatedModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='users-api:token-detail')
key = serializers.CharField(min_length=40, max_length=40, allow_blank=True, required=False)
user = NestedUserSerializer()
+ allowed_ips = serializers.ListField(
+ child=IPNetworkSerializer(),
+ required=False,
+ allow_empty=True,
+ default=[]
+ )
class Meta:
model = Token
- fields = ('id', 'url', 'display', 'user', 'created', 'expires', 'key', 'write_enabled', 'description')
+ fields = (
+ 'id', 'url', 'display', 'user', 'created', 'expires', 'key', 'write_enabled', 'description',
+ 'allowed_ips',
+ )
def to_internal_value(self, data):
if 'key' not in data:
diff --git a/netbox/users/forms.py b/netbox/users/forms.py
index d5e6218e5..8692eb050 100644
--- a/netbox/users/forms.py
+++ b/netbox/users/forms.py
@@ -1,7 +1,9 @@
from django import forms
from django.contrib.auth.forms import AuthenticationForm, PasswordChangeForm as DjangoPasswordChangeForm
+from django.contrib.postgres.forms import SimpleArrayField
from django.utils.html import mark_safe
+from ipam.formfields import IPNetworkFormField
from netbox.preferences import PREFERENCES
from utilities.forms import BootstrapMixin, DateTimePicker, StaticSelect
from utilities.utils import flatten_dict
@@ -99,11 +101,18 @@ class TokenForm(BootstrapMixin, forms.ModelForm):
required=False,
help_text="If no key is provided, one will be generated automatically."
)
+ allowed_ips = SimpleArrayField(
+ base_field=IPNetworkFormField(),
+ required=False,
+ label='Allowed IPs',
+ help_text='Allowed IPv4/IPv6 networks from where the token can be used. Leave blank for no restrictions. '
+ 'Ex: "10.1.1.0/24, 192.168.10.16/32, 2001:DB8:1::/64"',
+ )
class Meta:
model = Token
fields = [
- 'key', 'write_enabled', 'expires', 'description',
+ 'key', 'write_enabled', 'expires', 'description', 'allowed_ips',
]
widgets = {
'expires': DateTimePicker(),
diff --git a/netbox/users/migrations/0003_token_allowed_ips.py b/netbox/users/migrations/0003_token_allowed_ips.py
new file mode 100644
index 000000000..f4eaa9f96
--- /dev/null
+++ b/netbox/users/migrations/0003_token_allowed_ips.py
@@ -0,0 +1,20 @@
+# Generated by Django 3.2.12 on 2022-04-19 12:37
+
+import django.contrib.postgres.fields
+from django.db import migrations
+import ipam.fields
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('users', '0002_standardize_id_fields'),
+ ]
+
+ operations = [
+ 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),
+ ),
+ ]
diff --git a/netbox/users/models.py b/netbox/users/models.py
index a0055914b..4ee4dce6b 100644
--- a/netbox/users/models.py
+++ b/netbox/users/models.py
@@ -9,13 +9,14 @@ from django.db import models
from django.db.models.signals import post_save
from django.dispatch import receiver
from django.utils import timezone
+from netaddr import IPNetwork
+from ipam.fields import IPNetworkField
from netbox.config import get_config
from utilities.querysets import RestrictedQuerySet
from utilities.utils import flatten_dict
from .constants import *
-
__all__ = (
'ObjectPermission',
'Token',
@@ -220,6 +221,14 @@ class Token(models.Model):
max_length=200,
blank=True
)
+ allowed_ips = ArrayField(
+ base_field=IPNetworkField(),
+ blank=True,
+ null=True,
+ verbose_name='Allowed IPs',
+ help_text='Allowed IPv4/IPv6 networks from where the token can be used. Leave blank for no restrictions. '
+ 'Ex: "10.1.1.0/24, 192.168.10.16/32, 2001:DB8:1::/64"',
+ )
class Meta:
pass
@@ -244,6 +253,19 @@ class Token(models.Model):
return False
return True
+ def validate_client_ip(self, client_ip):
+ """
+ Validate the API client IP address against the source IP restrictions (if any) set on the token.
+ """
+ if not self.allowed_ips:
+ return True
+
+ for ip_network in self.allowed_ips:
+ if client_ip in IPNetwork(ip_network):
+ return True
+
+ return False
+
#
# Permissions
diff --git a/netbox/utilities/forms/fields/dynamic.py b/netbox/utilities/forms/fields/dynamic.py
index f83fc6a7c..68e71610c 100644
--- a/netbox/utilities/forms/fields/dynamic.py
+++ b/netbox/utilities/forms/fields/dynamic.py
@@ -88,7 +88,12 @@ class DynamicModelChoiceMixin:
# Modify the QuerySet of the field before we return it. Limit choices to any data already bound: Options
# will be populated on-demand via the APISelect widget.
data = bound_field.value()
+
if data:
+ # When the field is multiple choice pass the data as a list if it's not already
+ if isinstance(bound_field.field, DynamicModelMultipleChoiceField) and not type(data) is list:
+ data = [data]
+
field_name = getattr(self, 'to_field_name') or 'pk'
filter = self.filter(field_name=field_name)
try:
@@ -130,11 +135,12 @@ class DynamicModelMultipleChoiceField(DynamicModelChoiceMixin, forms.ModelMultip
widget = widgets.APISelectMultiple
def clean(self, value):
- """
- When null option is enabled and "None" is sent as part of a form to be submitted, it is sent as the
- string 'null'. This will check for that condition and gracefully handle the conversion to a NoneType.
- """
+ value = value or []
+
+ # When null option is enabled and "None" is sent as part of a form to be submitted, it is sent as the
+ # string 'null'. This will check for that condition and gracefully handle the conversion to a NoneType.
if self.null_option is not None and settings.FILTERS_NULL_CHOICE_VALUE in value:
value = [v for v in value if v != settings.FILTERS_NULL_CHOICE_VALUE]
return [None, *value]
+
return super().clean(value)
diff --git a/netbox/utilities/forms/fields/fields.py b/netbox/utilities/forms/fields/fields.py
index 0d09d2ac7..9168189a1 100644
--- a/netbox/utilities/forms/fields/fields.py
+++ b/netbox/utilities/forms/fields/fields.py
@@ -3,6 +3,7 @@ import json
from django import forms
from django.db.models import Count
from django.forms.fields import JSONField as _JSONField, InvalidJSONInput
+from django.templatetags.static import static
from netaddr import AddrFormatError, EUI
from utilities.forms import widgets
@@ -26,10 +27,9 @@ class CommentField(forms.CharField):
A textarea with support for Markdown rendering. Exists mostly just to add a standard `help_text`.
"""
widget = forms.Textarea
- # TODO: Port Markdown cheat sheet to internal documentation
- help_text = """
+ help_text = f"""
-
+
Markdown syntax is supported
"""
diff --git a/netbox/utilities/forms/utils.py b/netbox/utilities/forms/utils.py
index 9a4b011e0..a6f037e0b 100644
--- a/netbox/utilities/forms/utils.py
+++ b/netbox/utilities/forms/utils.py
@@ -1,7 +1,6 @@
import re
from django import forms
-from django.conf import settings
from django.forms.models import fields_for_model
from utilities.choices import unpack_grouped_choices
diff --git a/netbox/utilities/request.py b/netbox/utilities/request.py
new file mode 100644
index 000000000..3b8e1edde
--- /dev/null
+++ b/netbox/utilities/request.py
@@ -0,0 +1,27 @@
+from netaddr import IPAddress
+
+__all__ = (
+ 'get_client_ip',
+)
+
+
+def get_client_ip(request, additional_headers=()):
+ """
+ Return the client (source) IP address of the given request.
+ """
+ HTTP_HEADERS = (
+ 'HTTP_X_REAL_IP',
+ 'HTTP_X_FORWARDED_FOR',
+ 'REMOTE_ADDR',
+ *additional_headers
+ )
+ for header in HTTP_HEADERS:
+ if header in request.META:
+ client_ip = request.META[header].split(',')[0]
+ try:
+ return IPAddress(client_ip)
+ except ValueError:
+ raise ValueError(f"Invalid IP address set for {header}: {client_ip}")
+
+ # Could not determine the client IP address from request headers
+ return None
diff --git a/netbox/utilities/templatetags/builtins/filters.py b/netbox/utilities/templatetags/builtins/filters.py
index 44ad5ac47..738dc0e00 100644
--- a/netbox/utilities/templatetags/builtins/filters.py
+++ b/netbox/utilities/templatetags/builtins/filters.py
@@ -11,7 +11,7 @@ from markdown import markdown
from netbox.config import get_config
from utilities.markdown import StrikethroughExtension
-from utilities.utils import foreground_color
+from utilities.utils import clean_html, foreground_color
register = template.Library()
@@ -144,18 +144,6 @@ def render_markdown(value):
{{ md_source_text|markdown }}
"""
- schemes = '|'.join(get_config().ALLOWED_URL_SCHEMES)
-
- # Strip HTML tags
- value = strip_tags(value)
-
- # Sanitize Markdown links
- pattern = fr'\[([^\]]+)\]\(\s*(?!({schemes})).*:(.+)\)'
- value = re.sub(pattern, '[\\1](\\3)', value, flags=re.IGNORECASE)
-
- # Sanitize Markdown reference links
- pattern = fr'\[([^\]]+)\]:\s*(?!({schemes}))\w*:(.+)'
- value = re.sub(pattern, '[\\1]: \\3', value, flags=re.IGNORECASE)
# Render Markdown
html = markdown(value, extensions=['def_list', 'fenced_code', 'tables', StrikethroughExtension()])
@@ -164,6 +152,11 @@ def render_markdown(value):
if html:
html = f'{html}
'
+ schemes = get_config().ALLOWED_URL_SCHEMES
+
+ # Sanitize HTML
+ html = clean_html(html, schemes)
+
return mark_safe(html)
diff --git a/netbox/utilities/testing/utils.py b/netbox/utilities/testing/utils.py
index 466b5e22b..52ccd002d 100644
--- a/netbox/utilities/testing/utils.py
+++ b/netbox/utilities/testing/utils.py
@@ -34,15 +34,16 @@ def post_data(data):
return ret
-def create_test_device(name):
+def create_test_device(name, site=None, **attrs):
"""
Convenience method for creating a Device (e.g. for component testing).
"""
- site, _ = Site.objects.get_or_create(name='Site 1', slug='site-1')
+ if site is None:
+ site, _ = Site.objects.get_or_create(name='Site 1', slug='site-1')
manufacturer, _ = Manufacturer.objects.get_or_create(name='Manufacturer 1', slug='manufacturer-1')
devicetype, _ = DeviceType.objects.get_or_create(model='Device Type 1', manufacturer=manufacturer)
devicerole, _ = DeviceRole.objects.get_or_create(name='Device Role 1', slug='device-role-1')
- device = Device.objects.create(name=name, site=site, device_type=devicetype, device_role=devicerole)
+ device = Device.objects.create(name=name, site=site, device_type=devicetype, device_role=devicerole, **attrs)
return device
diff --git a/netbox/utilities/tests/test_api.py b/netbox/utilities/tests/test_api.py
index 1171bd496..e341442be 100644
--- a/netbox/utilities/tests/test_api.py
+++ b/netbox/utilities/tests/test_api.py
@@ -176,6 +176,64 @@ class APIPaginationTestCase(APITestCase):
self.assertEqual(len(response.data['results']), 100)
+class APIOrderingTestCase(APITestCase):
+ user_permissions = ('dcim.view_site',)
+
+ @classmethod
+ def setUpTestData(cls):
+ cls.url = reverse('dcim-api:site-list')
+
+ sites = (
+ Site(name='Site 1', slug='site-1', facility='C', description='Z'),
+ Site(name='Site 2', slug='site-2', facility='C', description='Y'),
+ Site(name='Site 3', slug='site-3', facility='B', description='X'),
+ Site(name='Site 4', slug='site-4', facility='B', description='W'),
+ Site(name='Site 5', slug='site-5', facility='A', description='V'),
+ Site(name='Site 6', slug='site-6', facility='A', description='U'),
+ )
+ Site.objects.bulk_create(sites)
+
+ def test_default_order(self):
+ response = self.client.get(self.url, format='json', **self.header)
+
+ self.assertHttpStatus(response, status.HTTP_200_OK)
+ self.assertEqual(response.data['count'], 6)
+ self.assertListEqual(
+ [s['name'] for s in response.data['results']],
+ ['Site 1', 'Site 2', 'Site 3', 'Site 4', 'Site 5', 'Site 6']
+ )
+
+ def test_order_single_field(self):
+ response = self.client.get(f'{self.url}?ordering=description', format='json', **self.header)
+
+ self.assertHttpStatus(response, status.HTTP_200_OK)
+ self.assertEqual(response.data['count'], 6)
+ self.assertListEqual(
+ [s['name'] for s in response.data['results']],
+ ['Site 6', 'Site 5', 'Site 4', 'Site 3', 'Site 2', 'Site 1']
+ )
+
+ def test_order_reversed(self):
+ response = self.client.get(f'{self.url}?ordering=-name', format='json', **self.header)
+
+ self.assertHttpStatus(response, status.HTTP_200_OK)
+ self.assertEqual(response.data['count'], 6)
+ self.assertListEqual(
+ [s['name'] for s in response.data['results']],
+ ['Site 6', 'Site 5', 'Site 4', 'Site 3', 'Site 2', 'Site 1']
+ )
+
+ def test_order_multiple_fields(self):
+ response = self.client.get(f'{self.url}?ordering=facility,name', format='json', **self.header)
+
+ self.assertHttpStatus(response, status.HTTP_200_OK)
+ self.assertEqual(response.data['count'], 6)
+ self.assertListEqual(
+ [s['name'] for s in response.data['results']],
+ ['Site 5', 'Site 6', 'Site 3', 'Site 4', 'Site 1', 'Site 2']
+ )
+
+
class APIDocsTestCase(TestCase):
def setUp(self):
diff --git a/netbox/utilities/utils.py b/netbox/utilities/utils.py
index 7b37c0b70..97ab165fe 100644
--- a/netbox/utilities/utils.py
+++ b/netbox/utilities/utils.py
@@ -1,9 +1,11 @@
import datetime
+import decimal
import json
from collections import OrderedDict
from decimal import Decimal
from itertools import count, groupby
+import bleach
from django.core.serializers import serialize
from django.db.models import Count, OuterRef, Subquery
from django.db.models.functions import Coalesce
@@ -14,6 +16,7 @@ from mptt.models import MPTTModel
from dcim.choices import CableLengthUnitChoices
from extras.plugins import PluginConfig
from extras.utils import is_taggable
+from netbox.config import get_config
from utilities.constants import HTTP_REQUEST_META_SAFE_COPY
@@ -224,6 +227,21 @@ def deepmerge(original, new):
return merged
+def drange(start, end, step=decimal.Decimal(1)):
+ """
+ Decimal-compatible implementation of Python's range()
+ """
+ start, end, step = decimal.Decimal(start), decimal.Decimal(end), decimal.Decimal(step)
+ if start < end:
+ while start < end:
+ yield start
+ start += step
+ else:
+ while start > end:
+ yield start
+ start += step
+
+
def to_meters(length, unit):
"""
Convert the given length to meters.
@@ -257,7 +275,9 @@ def render_jinja2(template_code, context):
"""
Render a Jinja2 template with the provided context. Return the rendered content.
"""
- return SandboxedEnvironment().from_string(source=template_code).render(**context)
+ environment = SandboxedEnvironment()
+ environment.filters.update(get_config().JINJA2_FILTERS)
+ return environment.from_string(source=template_code).render(**context)
def prepare_cloned_fields(instance):
@@ -321,14 +341,34 @@ def flatten_dict(d, prefix='', separator='.'):
return ret
+def array_to_ranges(array):
+ """
+ Convert an arbitrary array of integers to a list of consecutive values. Nonconsecutive values are returned as
+ single-item tuples. For example:
+ [0, 1, 2, 10, 14, 15, 16] => [(0, 2), (10,), (14, 16)]"
+ """
+ group = (
+ list(x) for _, x in groupby(sorted(array), lambda x, c=count(): next(c) - x)
+ )
+ return [
+ (g[0], g[-1])[:len(g)] for g in group
+ ]
+
+
def array_to_string(array):
"""
Generate an efficient, human-friendly string from a set of integers. Intended for use with ArrayField.
For example:
[0, 1, 2, 10, 14, 15, 16] => "0-2, 10, 14-16"
"""
- group = (list(x) for _, x in groupby(sorted(array), lambda x, c=count(): next(c) - x))
- return ', '.join('-'.join(map(str, (g[0], g[-1])[:len(g)])) for g in group)
+ ret = []
+ ranges = array_to_ranges(array)
+ for value in ranges:
+ if len(value) == 1:
+ ret.append(str(value[0]))
+ else:
+ ret.append(f'{value[0]}-{value[1]}')
+ return ', '.join(ret)
def content_type_name(ct):
@@ -382,3 +422,33 @@ def copy_safe_request(request):
'path': request.path,
'id': getattr(request, 'id', None), # UUID assigned by middleware
})
+
+
+def clean_html(html, schemes):
+ """
+ Sanitizes HTML based on a whitelist of allowed tags and attributes.
+ Also takes a list of allowed URI schemes.
+ """
+
+ ALLOWED_TAGS = [
+ "div", "pre", "code", "blockquote", "del",
+ "hr", "h1", "h2", "h3", "h4", "h5", "h6",
+ "ul", "ol", "li", "p", "br",
+ "strong", "em", "a", "b", "i", "img",
+ "table", "thead", "tbody", "tr", "th", "td",
+ "dl", "dt", "dd",
+ ]
+
+ ALLOWED_ATTRIBUTES = {
+ "div": ['class'],
+ "h1": ["id"], "h2": ["id"], "h3": ["id"], "h4": ["id"], "h5": ["id"], "h6": ["id"],
+ "a": ["href", "title"],
+ "img": ["src", "title", "alt"],
+ }
+
+ return bleach.clean(
+ html,
+ tags=ALLOWED_TAGS,
+ attributes=ALLOWED_ATTRIBUTES,
+ protocols=schemes
+ )
diff --git a/netbox/virtualization/api/serializers.py b/netbox/virtualization/api/serializers.py
index afdf50b96..bd01b5533 100644
--- a/netbox/virtualization/api/serializers.py
+++ b/netbox/virtualization/api/serializers.py
@@ -1,7 +1,9 @@
from drf_yasg.utils import swagger_serializer_method
from rest_framework import serializers
-from dcim.api.nested_serializers import NestedDeviceRoleSerializer, NestedPlatformSerializer, NestedSiteSerializer
+from dcim.api.nested_serializers import (
+ NestedDeviceSerializer, NestedDeviceRoleSerializer, NestedPlatformSerializer, NestedSiteSerializer,
+)
from dcim.choices import InterfaceModeChoices
from ipam.api.nested_serializers import NestedIPAddressSerializer, NestedVLANSerializer, NestedVRFSerializer
from ipam.models import VLAN
@@ -45,6 +47,7 @@ class ClusterSerializer(NetBoxModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:cluster-detail')
type = NestedClusterTypeSerializer()
group = NestedClusterGroupSerializer(required=False, allow_null=True, default=None)
+ status = ChoiceField(choices=ClusterStatusChoices, required=False)
tenant = NestedTenantSerializer(required=False, allow_null=True)
site = NestedSiteSerializer(required=False, allow_null=True, default=None)
device_count = serializers.IntegerField(read_only=True)
@@ -53,8 +56,8 @@ class ClusterSerializer(NetBoxModelSerializer):
class Meta:
model = Cluster
fields = [
- 'id', 'url', 'display', 'name', 'type', 'group', 'tenant', 'site', 'comments', 'tags', 'custom_fields',
- 'created', 'last_updated', 'device_count', 'virtualmachine_count',
+ 'id', 'url', 'display', 'name', 'type', 'group', 'status', 'tenant', 'site', 'comments', 'tags',
+ 'custom_fields', 'created', 'last_updated', 'device_count', 'virtualmachine_count',
]
@@ -65,8 +68,9 @@ class ClusterSerializer(NetBoxModelSerializer):
class VirtualMachineSerializer(NetBoxModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:virtualmachine-detail')
status = ChoiceField(choices=VirtualMachineStatusChoices, required=False)
- site = NestedSiteSerializer(read_only=True)
- cluster = NestedClusterSerializer()
+ site = NestedSiteSerializer(required=False, allow_null=True)
+ cluster = NestedClusterSerializer(required=False, allow_null=True)
+ device = NestedDeviceSerializer(required=False, allow_null=True)
role = NestedDeviceRoleSerializer(required=False, allow_null=True)
tenant = NestedTenantSerializer(required=False, allow_null=True)
platform = NestedPlatformSerializer(required=False, allow_null=True)
@@ -77,9 +81,9 @@ class VirtualMachineSerializer(NetBoxModelSerializer):
class Meta:
model = VirtualMachine
fields = [
- 'id', 'url', 'display', 'name', 'status', 'site', 'cluster', 'role', 'tenant', 'platform', 'primary_ip',
- 'primary_ip4', 'primary_ip6', 'vcpus', 'memory', 'disk', 'comments', 'local_context_data', 'tags',
- 'custom_fields', 'created', 'last_updated',
+ 'id', 'url', 'display', 'name', 'status', 'site', 'cluster', 'device', 'role', 'tenant', 'platform',
+ 'primary_ip', 'primary_ip4', 'primary_ip6', 'vcpus', 'memory', 'disk', 'comments', 'local_context_data',
+ 'tags', 'custom_fields', 'created', 'last_updated',
]
validators = []
@@ -89,9 +93,9 @@ class VirtualMachineWithConfigContextSerializer(VirtualMachineSerializer):
class Meta(VirtualMachineSerializer.Meta):
fields = [
- 'id', 'url', 'display', 'name', 'status', 'site', 'cluster', 'role', 'tenant', 'platform', 'primary_ip',
- 'primary_ip4', 'primary_ip6', 'vcpus', 'memory', 'disk', 'comments', 'local_context_data', 'tags',
- 'custom_fields', 'config_context', 'created', 'last_updated',
+ 'id', 'url', 'display', 'name', 'status', 'site', 'cluster', 'device', 'role', 'tenant', 'platform',
+ 'primary_ip', 'primary_ip4', 'primary_ip6', 'vcpus', 'memory', 'disk', 'comments', 'local_context_data',
+ 'tags', 'custom_fields', 'config_context', 'created', 'last_updated',
]
@swagger_serializer_method(serializer_or_field=serializers.DictField)
diff --git a/netbox/virtualization/api/views.py b/netbox/virtualization/api/views.py
index 665114881..d2a90ae34 100644
--- a/netbox/virtualization/api/views.py
+++ b/netbox/virtualization/api/views.py
@@ -54,7 +54,7 @@ class ClusterViewSet(NetBoxModelViewSet):
class VirtualMachineViewSet(ConfigContextQuerySetMixin, NetBoxModelViewSet):
queryset = VirtualMachine.objects.prefetch_related(
- 'cluster__site', 'role', 'tenant', 'platform', 'primary_ip4', 'primary_ip6', 'tags'
+ 'site', 'cluster', 'device', 'role', 'tenant', 'platform', 'primary_ip4', 'primary_ip6', 'tags'
)
filterset_class = filtersets.VirtualMachineFilterSet
diff --git a/netbox/virtualization/choices.py b/netbox/virtualization/choices.py
index 693e53df6..2cf6357e1 100644
--- a/netbox/virtualization/choices.py
+++ b/netbox/virtualization/choices.py
@@ -1,6 +1,28 @@
from utilities.choices import ChoiceSet
+#
+# Clusters
+#
+
+class ClusterStatusChoices(ChoiceSet):
+ key = 'Cluster.status'
+
+ STATUS_PLANNED = 'planned'
+ STATUS_STAGING = 'staging'
+ STATUS_ACTIVE = 'active'
+ STATUS_DECOMMISSIONING = 'decommissioning'
+ STATUS_OFFLINE = 'offline'
+
+ CHOICES = [
+ (STATUS_PLANNED, 'Planned', 'cyan'),
+ (STATUS_STAGING, 'Staging', 'blue'),
+ (STATUS_ACTIVE, 'Active', 'green'),
+ (STATUS_DECOMMISSIONING, 'Decommissioning', 'yellow'),
+ (STATUS_OFFLINE, 'Offline', 'red'),
+ ]
+
+
#
# VirtualMachines
#
diff --git a/netbox/virtualization/filtersets.py b/netbox/virtualization/filtersets.py
index 5a2aa8b42..00d3e2313 100644
--- a/netbox/virtualization/filtersets.py
+++ b/netbox/virtualization/filtersets.py
@@ -1,7 +1,7 @@
import django_filters
from django.db.models import Q
-from dcim.models import DeviceRole, Platform, Region, Site, SiteGroup
+from dcim.models import Device, DeviceRole, Platform, Region, Site, SiteGroup
from extras.filtersets import LocalConfigContextFilterSet
from ipam.models import VRF
from netbox.filtersets import OrganizationalModelFilterSet, NetBoxModelFilterSet
@@ -90,6 +90,10 @@ class ClusterFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilte
to_field_name='slug',
label='Cluster type (slug)',
)
+ status = django_filters.MultipleChoiceFilter(
+ choices=ClusterStatusChoices,
+ null_value=None
+ )
class Meta:
model = Cluster
@@ -146,39 +150,48 @@ class VirtualMachineFilterSet(
to_field_name='name',
label='Cluster',
)
+ device_id = django_filters.ModelMultipleChoiceFilter(
+ queryset=Device.objects.all(),
+ label='Device (ID)',
+ )
+ device = django_filters.ModelMultipleChoiceFilter(
+ field_name='device__name',
+ queryset=Device.objects.all(),
+ to_field_name='name',
+ label='Device',
+ )
region_id = TreeNodeMultipleChoiceFilter(
queryset=Region.objects.all(),
- field_name='cluster__site__region',
+ field_name='site__region',
lookup_expr='in',
label='Region (ID)',
)
region = TreeNodeMultipleChoiceFilter(
queryset=Region.objects.all(),
- field_name='cluster__site__region',
+ field_name='site__region',
lookup_expr='in',
to_field_name='slug',
label='Region (slug)',
)
site_group_id = TreeNodeMultipleChoiceFilter(
queryset=SiteGroup.objects.all(),
- field_name='cluster__site__group',
+ field_name='site__group',
lookup_expr='in',
label='Site group (ID)',
)
site_group = TreeNodeMultipleChoiceFilter(
queryset=SiteGroup.objects.all(),
- field_name='cluster__site__group',
+ field_name='site__group',
lookup_expr='in',
to_field_name='slug',
label='Site group (slug)',
)
site_id = django_filters.ModelMultipleChoiceFilter(
- field_name='cluster__site',
queryset=Site.objects.all(),
label='Site (ID)',
)
site = django_filters.ModelMultipleChoiceFilter(
- field_name='cluster__site__slug',
+ field_name='site__slug',
queryset=Site.objects.all(),
to_field_name='slug',
label='Site (slug)',
diff --git a/netbox/virtualization/forms/bulk_edit.py b/netbox/virtualization/forms/bulk_edit.py
index d5d33df2a..b2429744b 100644
--- a/netbox/virtualization/forms/bulk_edit.py
+++ b/netbox/virtualization/forms/bulk_edit.py
@@ -2,8 +2,8 @@ from django import forms
from dcim.choices import InterfaceModeChoices
from dcim.constants import INTERFACE_MTU_MAX, INTERFACE_MTU_MIN
-from dcim.models import DeviceRole, Platform, Region, Site, SiteGroup
-from ipam.models import VLAN, VRF
+from dcim.models import Device, DeviceRole, Platform, Region, Site, SiteGroup
+from ipam.models import VLAN, VLANGroup, VRF
from netbox.forms import NetBoxModelBulkEditForm
from tenancy.models import Tenant
from utilities.forms import (
@@ -58,6 +58,12 @@ class ClusterBulkEditForm(NetBoxModelBulkEditForm):
queryset=ClusterGroup.objects.all(),
required=False
)
+ status = forms.ChoiceField(
+ choices=add_blank_choice(ClusterStatusChoices),
+ required=False,
+ initial='',
+ widget=StaticSelect()
+ )
tenant = DynamicModelChoiceField(
queryset=Tenant.objects.all(),
required=False
@@ -85,7 +91,7 @@ class ClusterBulkEditForm(NetBoxModelBulkEditForm):
model = Cluster
fieldsets = (
- (None, ('type', 'group', 'tenant',)),
+ (None, ('type', 'group', 'status', 'tenant',)),
('Site', ('region', 'site_group', 'site',)),
)
nullable_fields = (
@@ -100,9 +106,23 @@ class VirtualMachineBulkEditForm(NetBoxModelBulkEditForm):
initial='',
widget=StaticSelect(),
)
+ site = DynamicModelChoiceField(
+ queryset=Site.objects.all(),
+ required=False
+ )
cluster = DynamicModelChoiceField(
queryset=Cluster.objects.all(),
- required=False
+ required=False,
+ query_params={
+ 'site_id': '$site'
+ }
+ )
+ device = DynamicModelChoiceField(
+ queryset=Device.objects.all(),
+ required=False,
+ query_params={
+ 'cluster_id': '$cluster'
+ }
)
role = DynamicModelChoiceField(
queryset=DeviceRole.objects.filter(
@@ -140,11 +160,11 @@ class VirtualMachineBulkEditForm(NetBoxModelBulkEditForm):
model = VirtualMachine
fieldsets = (
- (None, ('cluster', 'status', 'role', 'tenant', 'platform')),
+ (None, ('site', 'cluster', 'device', 'status', 'role', 'tenant', 'platform')),
('Resources', ('vcpus', 'memory', 'disk'))
)
nullable_fields = (
- 'role', 'tenant', 'platform', 'vcpus', 'memory', 'disk', 'comments',
+ 'site', 'cluster', 'device', 'role', 'tenant', 'platform', 'vcpus', 'memory', 'disk', 'comments',
)
@@ -182,13 +202,26 @@ class VMInterfaceBulkEditForm(NetBoxModelBulkEditForm):
required=False,
widget=StaticSelect()
)
+ vlan_group = DynamicModelChoiceField(
+ queryset=VLANGroup.objects.all(),
+ required=False,
+ label='VLAN group'
+ )
untagged_vlan = DynamicModelChoiceField(
queryset=VLAN.objects.all(),
- required=False
+ required=False,
+ query_params={
+ 'group_id': '$vlan_group',
+ },
+ label='Untagged VLAN'
)
tagged_vlans = DynamicModelMultipleChoiceField(
queryset=VLAN.objects.all(),
- required=False
+ required=False,
+ query_params={
+ 'group_id': '$vlan_group',
+ },
+ label='Tagged VLANs'
)
vrf = DynamicModelChoiceField(
queryset=VRF.objects.all(),
@@ -200,7 +233,7 @@ class VMInterfaceBulkEditForm(NetBoxModelBulkEditForm):
fieldsets = (
(None, ('mtu', 'enabled', 'vrf', 'description')),
('Related Interfaces', ('parent', 'bridge')),
- ('802.1Q Switching', ('mode', 'untagged_vlan', 'tagged_vlans')),
+ ('802.1Q Switching', ('mode', 'vlan_group', 'untagged_vlan', 'tagged_vlans')),
)
nullable_fields = (
'parent', 'bridge', 'mtu', 'vrf', 'description',
@@ -223,8 +256,10 @@ class VMInterfaceBulkEditForm(NetBoxModelBulkEditForm):
# See 5643
if 'pk' in self.initial:
site = None
- interfaces = VMInterface.objects.filter(pk__in=self.initial['pk']).prefetch_related(
- 'virtual_machine__cluster__site'
+ interfaces = VMInterface.objects.filter(
+ pk__in=self.initial['pk']
+ ).prefetch_related(
+ 'virtual_machine__site'
)
# Check interface sites. First interface should set site, further interfaces will either continue the
diff --git a/netbox/virtualization/forms/bulk_import.py b/netbox/virtualization/forms/bulk_import.py
index eab6fc9e7..2d7ee52e2 100644
--- a/netbox/virtualization/forms/bulk_import.py
+++ b/netbox/virtualization/forms/bulk_import.py
@@ -1,5 +1,5 @@
from dcim.choices import InterfaceModeChoices
-from dcim.models import DeviceRole, Platform, Site
+from dcim.models import Device, DeviceRole, Platform, Site
from ipam.models import VRF
from netbox.forms import NetBoxModelCSVForm
from tenancy.models import Tenant
@@ -44,6 +44,10 @@ class ClusterCSVForm(NetBoxModelCSVForm):
required=False,
help_text='Assigned cluster group'
)
+ status = CSVChoiceField(
+ choices=ClusterStatusChoices,
+ help_text='Operational status'
+ )
site = CSVModelChoiceField(
queryset=Site.objects.all(),
to_field_name='name',
@@ -59,7 +63,7 @@ class ClusterCSVForm(NetBoxModelCSVForm):
class Meta:
model = Cluster
- fields = ('name', 'type', 'group', 'site', 'comments')
+ fields = ('name', 'type', 'group', 'status', 'site', 'comments')
class VirtualMachineCSVForm(NetBoxModelCSVForm):
@@ -67,11 +71,24 @@ class VirtualMachineCSVForm(NetBoxModelCSVForm):
choices=VirtualMachineStatusChoices,
help_text='Operational status'
)
+ site = CSVModelChoiceField(
+ queryset=Site.objects.all(),
+ to_field_name='name',
+ required=False,
+ help_text='Assigned site'
+ )
cluster = CSVModelChoiceField(
queryset=Cluster.objects.all(),
to_field_name='name',
+ required=False,
help_text='Assigned cluster'
)
+ device = CSVModelChoiceField(
+ queryset=Device.objects.all(),
+ to_field_name='name',
+ required=False,
+ help_text='Assigned device within cluster'
+ )
role = CSVModelChoiceField(
queryset=DeviceRole.objects.filter(
vm_role=True
@@ -96,7 +113,8 @@ class VirtualMachineCSVForm(NetBoxModelCSVForm):
class Meta:
model = VirtualMachine
fields = (
- 'name', 'status', 'role', 'cluster', 'tenant', 'platform', 'vcpus', 'memory', 'disk', 'comments',
+ 'name', 'status', 'role', 'site', 'cluster', 'device', 'tenant', 'platform', 'vcpus', 'memory', 'disk',
+ 'comments',
)
diff --git a/netbox/virtualization/forms/filtersets.py b/netbox/virtualization/forms/filtersets.py
index 2f386e889..e15a76a43 100644
--- a/netbox/virtualization/forms/filtersets.py
+++ b/netbox/virtualization/forms/filtersets.py
@@ -1,7 +1,7 @@
from django import forms
from django.utils.translation import gettext as _
-from dcim.models import DeviceRole, Platform, Region, Site, SiteGroup
+from dcim.models import Device, DeviceRole, Platform, Region, Site, SiteGroup
from extras.forms import LocalConfigContextFilterForm
from ipam.models import VRF
from netbox.forms import NetBoxModelFilterSetForm
@@ -29,16 +29,20 @@ class ClusterTypeFilterForm(NetBoxModelFilterSetForm):
class ClusterGroupFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm):
model = ClusterGroup
tag = TagFilterField(model)
+ fieldsets = (
+ (None, ('q', 'tag')),
+ ('Contacts', ('contact', 'contact_role', 'contact_group')),
+ )
class ClusterFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilterSetForm):
model = Cluster
fieldsets = (
(None, ('q', 'tag')),
- ('Attributes', ('group_id', 'type_id')),
+ ('Attributes', ('group_id', 'type_id', 'status')),
('Location', ('region_id', 'site_group_id', 'site_id')),
('Tenant', ('tenant_group_id', 'tenant_id')),
- ('Contacts', ('contact', 'contact_role')),
+ ('Contacts', ('contact', 'contact_role', 'contact_group')),
)
type_id = DynamicModelMultipleChoiceField(
queryset=ClusterType.objects.all(),
@@ -50,6 +54,10 @@ class ClusterFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFi
required=False,
label=_('Region')
)
+ status = MultipleChoiceField(
+ choices=ClusterStatusChoices,
+ required=False
+ )
site_group_id = DynamicModelMultipleChoiceField(
queryset=SiteGroup.objects.all(),
required=False,
@@ -83,11 +91,11 @@ class VirtualMachineFilterForm(
model = VirtualMachine
fieldsets = (
(None, ('q', 'tag')),
- ('Cluster', ('cluster_group_id', 'cluster_type_id', 'cluster_id')),
+ ('Cluster', ('cluster_group_id', 'cluster_type_id', 'cluster_id', 'device_id')),
('Location', ('region_id', 'site_group_id', 'site_id')),
('Attriubtes', ('status', 'role_id', 'platform_id', 'mac_address', 'has_primary_ip', 'local_context_data')),
('Tenant', ('tenant_group_id', 'tenant_id')),
- ('Contacts', ('contact', 'contact_role')),
+ ('Contacts', ('contact', 'contact_role', 'contact_group')),
)
cluster_group_id = DynamicModelMultipleChoiceField(
queryset=ClusterGroup.objects.all(),
@@ -106,6 +114,11 @@ class VirtualMachineFilterForm(
required=False,
label=_('Cluster')
)
+ device_id = DynamicModelMultipleChoiceField(
+ queryset=Device.objects.all(),
+ required=False,
+ label=_('Device')
+ )
region_id = DynamicModelMultipleChoiceField(
queryset=Region.objects.all(),
required=False,
diff --git a/netbox/virtualization/forms/models.py b/netbox/virtualization/forms/models.py
index 314b0bddf..018b50c99 100644
--- a/netbox/virtualization/forms/models.py
+++ b/netbox/virtualization/forms/models.py
@@ -79,15 +79,19 @@ class ClusterForm(TenancyForm, NetBoxModelForm):
comments = CommentField()
fieldsets = (
- ('Cluster', ('name', 'type', 'group', 'region', 'site_group', 'site', 'tags')),
+ ('Cluster', ('name', 'type', 'group', 'status', 'tags')),
+ ('Site', ('region', 'site_group', 'site')),
('Tenancy', ('tenant_group', 'tenant')),
)
class Meta:
model = Cluster
fields = (
- 'name', 'type', 'group', 'tenant', 'region', 'site_group', 'site', 'comments', 'tags',
+ 'name', 'type', 'group', 'status', 'tenant', 'region', 'site_group', 'site', 'comments', 'tags',
)
+ widgets = {
+ 'status': StaticSelect(),
+ }
class ClusterAddDevicesForm(BootstrapMixin, forms.Form):
@@ -161,6 +165,9 @@ class ClusterRemoveDevicesForm(ConfirmationForm):
class VirtualMachineForm(TenancyForm, NetBoxModelForm):
+ site = DynamicModelChoiceField(
+ queryset=Site.objects.all()
+ )
cluster_group = DynamicModelChoiceField(
queryset=ClusterGroup.objects.all(),
required=False,
@@ -172,7 +179,15 @@ class VirtualMachineForm(TenancyForm, NetBoxModelForm):
cluster = DynamicModelChoiceField(
queryset=Cluster.objects.all(),
query_params={
- 'group_id': '$cluster_group'
+ 'site_id': '$site',
+ 'group_id': '$cluster_group',
+ }
+ )
+ device = DynamicModelChoiceField(
+ queryset=Device.objects.all(),
+ required=False,
+ query_params={
+ 'cluster_id': '$cluster'
}
)
role = DynamicModelChoiceField(
@@ -193,7 +208,7 @@ class VirtualMachineForm(TenancyForm, NetBoxModelForm):
fieldsets = (
('Virtual Machine', ('name', 'role', 'status', 'tags')),
- ('Cluster', ('cluster_group', 'cluster')),
+ ('Cluster', ('site', 'cluster_group', 'cluster', 'device')),
('Tenancy', ('tenant_group', 'tenant')),
('Management', ('platform', 'primary_ip4', 'primary_ip6')),
('Resources', ('vcpus', 'memory', 'disk')),
@@ -203,8 +218,9 @@ class VirtualMachineForm(TenancyForm, NetBoxModelForm):
class Meta:
model = VirtualMachine
fields = [
- 'name', 'status', 'cluster_group', 'cluster', 'role', 'tenant_group', 'tenant', 'platform', 'primary_ip4',
- 'primary_ip6', 'vcpus', 'memory', 'disk', 'comments', 'tags', 'local_context_data',
+ 'name', 'status', 'site', 'cluster_group', 'cluster', 'device', 'role', 'tenant_group', 'tenant',
+ 'platform', 'primary_ip4', 'primary_ip6', 'vcpus', 'memory', 'disk', 'comments', 'tags',
+ 'local_context_data',
]
help_texts = {
'local_context_data': "Local config context data overwrites all sources contexts in the final rendered "
@@ -307,7 +323,7 @@ class VMInterfaceForm(InterfaceCommonForm, NetBoxModelForm):
model = VMInterface
fields = [
'virtual_machine', 'name', 'parent', 'bridge', 'enabled', 'mac_address', 'mtu', 'description', 'mode',
- 'untagged_vlan', 'tagged_vlans', 'vrf', 'tags',
+ 'vlan_group', 'untagged_vlan', 'tagged_vlans', 'vrf', 'tags',
]
widgets = {
'virtual_machine': forms.HiddenInput(),
diff --git a/netbox/virtualization/migrations/0030_cluster_status.py b/netbox/virtualization/migrations/0030_cluster_status.py
new file mode 100644
index 000000000..e836bb914
--- /dev/null
+++ b/netbox/virtualization/migrations/0030_cluster_status.py
@@ -0,0 +1,18 @@
+# Generated by Django 4.0.4 on 2022-05-19 19:36
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('virtualization', '0029_created_datetimefield'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='cluster',
+ name='status',
+ field=models.CharField(default='active', max_length=50),
+ ),
+ ]
diff --git a/netbox/virtualization/migrations/0031_virtualmachine_site_device.py b/netbox/virtualization/migrations/0031_virtualmachine_site_device.py
new file mode 100644
index 000000000..85ea24455
--- /dev/null
+++ b/netbox/virtualization/migrations/0031_virtualmachine_site_device.py
@@ -0,0 +1,28 @@
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('dcim', '0153_created_datetimefield'),
+ ('virtualization', '0030_cluster_status'),
+ ]
+
+ operations = [
+ 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'),
+ ),
+ 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'),
+ ),
+ 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'),
+ ),
+ ]
diff --git a/netbox/virtualization/migrations/0032_virtualmachine_update_sites.py b/netbox/virtualization/migrations/0032_virtualmachine_update_sites.py
new file mode 100644
index 000000000..e9c52bfde
--- /dev/null
+++ b/netbox/virtualization/migrations/0032_virtualmachine_update_sites.py
@@ -0,0 +1,27 @@
+from django.db import migrations
+
+
+def update_virtualmachines_site(apps, schema_editor):
+ """
+ Automatically set the site for all virtual machines.
+ """
+ VirtualMachine = apps.get_model('virtualization', 'VirtualMachine')
+
+ virtual_machines = VirtualMachine.objects.filter(cluster__site__isnull=False)
+ for vm in virtual_machines:
+ vm.site = vm.cluster.site
+ VirtualMachine.objects.bulk_update(virtual_machines, ['site'])
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('virtualization', '0031_virtualmachine_site_device'),
+ ]
+
+ operations = [
+ migrations.RunPython(
+ code=update_virtualmachines_site,
+ reverse_code=migrations.RunPython.noop
+ ),
+ ]
diff --git a/netbox/virtualization/models.py b/netbox/virtualization/models.py
index 586bb8a9e..02560a962 100644
--- a/netbox/virtualization/models.py
+++ b/netbox/virtualization/models.py
@@ -119,6 +119,11 @@ class Cluster(NetBoxModel):
blank=True,
null=True
)
+ status = models.CharField(
+ max_length=50,
+ choices=ClusterStatusChoices,
+ default=ClusterStatusChoices.STATUS_ACTIVE
+ )
tenant = models.ForeignKey(
to='tenancy.Tenant',
on_delete=models.PROTECT,
@@ -165,6 +170,9 @@ class Cluster(NetBoxModel):
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()
@@ -187,10 +195,26 @@ class VirtualMachine(NetBoxModel, ConfigContextModel):
"""
A virtual machine which runs inside a Cluster.
"""
+ site = models.ForeignKey(
+ to='dcim.Site',
+ on_delete=models.PROTECT,
+ related_name='virtual_machines',
+ blank=True,
+ null=True
+ )
cluster = models.ForeignKey(
to='virtualization.Cluster',
on_delete=models.PROTECT,
- related_name='virtual_machines'
+ related_name='virtual_machines',
+ blank=True,
+ null=True
+ )
+ device = models.ForeignKey(
+ to='dcim.Device',
+ on_delete=models.PROTECT,
+ related_name='virtual_machines',
+ blank=True,
+ null=True
)
tenant = models.ForeignKey(
to='tenancy.Tenant',
@@ -276,7 +300,7 @@ class VirtualMachine(NetBoxModel, ConfigContextModel):
objects = ConfigContextModelQuerySet.as_manager()
clone_fields = [
- 'cluster', 'tenant', 'platform', 'status', 'role', 'vcpus', 'memory', 'disk',
+ 'site', 'cluster', 'device', 'tenant', 'platform', 'status', 'role', 'vcpus', 'memory', 'disk',
]
class Meta:
@@ -308,6 +332,28 @@ class VirtualMachine(NetBoxModel, ConfigContextModel):
def clean(self):
super().clean()
+ # Must be assigned to a site and/or cluster
+ if not self.site and not self.cluster:
+ raise ValidationError({
+ 'cluster': f'A virtual machine must be assigned to a site and/or cluster.'
+ })
+
+ # Validate site for cluster & device
+ if self.cluster and self.cluster.site != self.site:
+ raise ValidationError({
+ 'cluster': f'The selected cluster ({self.cluster} is not assigned to this site ({self.site}).'
+ })
+ if self.device and self.device.site != self.site:
+ raise ValidationError({
+ 'device': f'The selected device ({self.device} is not assigned to this site ({self.site}).'
+ })
+
+ # Validate assigned cluster device
+ if self.device and self.device not in self.cluster.devices.all():
+ raise ValidationError({
+ 'device': f'The selected device ({self.device} is not assigned to this cluster ({self.cluster}).'
+ })
+
# Validate primary IP addresses
interfaces = self.interfaces.all()
for field in ['primary_ip4', 'primary_ip6']:
@@ -336,10 +382,6 @@ class VirtualMachine(NetBoxModel, ConfigContextModel):
else:
return None
- @property
- def site(self):
- return self.cluster.site
-
#
# Interfaces
diff --git a/netbox/virtualization/tables/clusters.py b/netbox/virtualization/tables/clusters.py
index a0c98425a..dfcae052a 100644
--- a/netbox/virtualization/tables/clusters.py
+++ b/netbox/virtualization/tables/clusters.py
@@ -66,6 +66,7 @@ class ClusterTable(NetBoxTable):
group = tables.Column(
linkify=True
)
+ status = columns.ChoiceFieldColumn()
tenant = tables.Column(
linkify=True
)
@@ -93,7 +94,7 @@ class ClusterTable(NetBoxTable):
class Meta(NetBoxTable.Meta):
model = Cluster
fields = (
- 'pk', 'id', 'name', 'type', 'group', 'tenant', 'site', 'comments', 'device_count', 'vm_count', 'contacts',
- 'tags', 'created', 'last_updated',
+ 'pk', 'id', 'name', 'type', 'group', 'status', 'tenant', 'site', 'comments', 'device_count', 'vm_count',
+ 'contacts', 'tags', 'created', 'last_updated',
)
- default_columns = ('pk', 'name', 'type', 'group', 'tenant', 'site', 'device_count', 'vm_count')
+ default_columns = ('pk', 'name', 'type', 'group', 'status', 'tenant', 'site', 'device_count', 'vm_count')
diff --git a/netbox/virtualization/tables/virtualmachines.py b/netbox/virtualization/tables/virtualmachines.py
index 89dbdf901..0fe2571b1 100644
--- a/netbox/virtualization/tables/virtualmachines.py
+++ b/netbox/virtualization/tables/virtualmachines.py
@@ -30,9 +30,15 @@ class VirtualMachineTable(NetBoxTable):
linkify=True
)
status = columns.ChoiceFieldColumn()
+ site = tables.Column(
+ linkify=True
+ )
cluster = tables.Column(
linkify=True
)
+ device = tables.Column(
+ linkify=True
+ )
role = columns.ColoredLabelColumn()
tenant = TenantColumn()
comments = columns.MarkdownColumn()
@@ -56,11 +62,11 @@ class VirtualMachineTable(NetBoxTable):
class Meta(NetBoxTable.Meta):
model = VirtualMachine
fields = (
- 'pk', 'id', 'name', 'status', 'cluster', 'role', 'tenant', 'platform', 'vcpus', 'memory', 'disk',
- 'primary_ip4', 'primary_ip6', 'primary_ip', 'comments', 'tags', 'created', 'last_updated',
+ 'pk', 'id', 'name', 'status', 'site', 'cluster', 'device', 'role', 'tenant', 'platform', 'vcpus', 'memory',
+ 'disk', 'primary_ip4', 'primary_ip6', 'primary_ip', 'comments', 'tags', 'created', 'last_updated',
)
default_columns = (
- 'pk', 'name', 'status', 'cluster', 'role', 'tenant', 'vcpus', 'memory', 'disk', 'primary_ip',
+ 'pk', 'name', 'status', 'site', 'cluster', 'role', 'tenant', 'vcpus', 'memory', 'disk', 'primary_ip',
)
diff --git a/netbox/virtualization/tests/test_api.py b/netbox/virtualization/tests/test_api.py
index f6c07fa54..b2ae68860 100644
--- a/netbox/virtualization/tests/test_api.py
+++ b/netbox/virtualization/tests/test_api.py
@@ -2,8 +2,10 @@ from django.urls import reverse
from rest_framework import status
from dcim.choices import InterfaceModeChoices
+from dcim.models import Site
from ipam.models import VLAN, VRF
-from utilities.testing import APITestCase, APIViewTestCases
+from utilities.testing import APITestCase, APIViewTestCases, create_test_device
+from virtualization.choices import *
from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface
@@ -85,6 +87,7 @@ class ClusterTest(APIViewTestCases.APIViewTestCase):
model = Cluster
brief_fields = ['display', 'id', 'name', 'url', 'virtualmachine_count']
bulk_update_data = {
+ 'status': 'offline',
'comments': 'New comment',
}
@@ -104,9 +107,9 @@ class ClusterTest(APIViewTestCases.APIViewTestCase):
ClusterGroup.objects.bulk_create(cluster_groups)
clusters = (
- Cluster(name='Cluster 1', type=cluster_types[0], group=cluster_groups[0]),
- Cluster(name='Cluster 2', type=cluster_types[0], group=cluster_groups[0]),
- Cluster(name='Cluster 3', type=cluster_types[0], group=cluster_groups[0]),
+ 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)
@@ -115,16 +118,19 @@ class ClusterTest(APIViewTestCases.APIViewTestCase):
'name': 'Cluster 4',
'type': cluster_types[1].pk,
'group': cluster_groups[1].pk,
+ 'status': ClusterStatusChoices.STATUS_STAGING,
},
{
'name': 'Cluster 5',
'type': cluster_types[1].pk,
'group': cluster_groups[1].pk,
+ 'status': ClusterStatusChoices.STATUS_STAGING,
},
{
'name': 'Cluster 6',
'type': cluster_types[1].pk,
'group': cluster_groups[1].pk,
+ 'status': ClusterStatusChoices.STATUS_STAGING,
},
]
@@ -141,31 +147,49 @@ class VirtualMachineTest(APIViewTestCases.APIViewTestCase):
clustertype = ClusterType.objects.create(name='Cluster Type 1', slug='cluster-type-1')
clustergroup = ClusterGroup.objects.create(name='Cluster Group 1', slug='cluster-group-1')
+ sites = (
+ Site(name='Site 1', slug='site-1'),
+ Site(name='Site 2', slug='site-2'),
+ Site(name='Site 3', slug='site-3'),
+ )
+ Site.objects.bulk_create(sites)
+
clusters = (
- Cluster(name='Cluster 1', type=clustertype, group=clustergroup),
- Cluster(name='Cluster 2', type=clustertype, group=clustergroup),
+ 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 3', type=clustertype),
)
Cluster.objects.bulk_create(clusters)
+ 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', cluster=clusters[0], local_context_data={'A': 1}),
- VirtualMachine(name='Virtual Machine 2', cluster=clusters[0], local_context_data={'B': 2}),
- VirtualMachine(name='Virtual Machine 3', 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)
cls.create_data = [
{
'name': 'Virtual Machine 4',
+ 'site': sites[1].pk,
'cluster': clusters[1].pk,
+ 'device': device2.pk,
},
{
'name': 'Virtual Machine 5',
+ 'site': sites[1].pk,
'cluster': clusters[1].pk,
},
{
'name': 'Virtual Machine 6',
- 'cluster': clusters[1].pk,
+ 'site': sites[1].pk,
+ },
+ {
+ 'name': 'Virtual Machine 7',
+ 'cluster': clusters[2].pk,
},
]
diff --git a/netbox/virtualization/tests/test_filtersets.py b/netbox/virtualization/tests/test_filtersets.py
index 9e264ac5c..d3ff12887 100644
--- a/netbox/virtualization/tests/test_filtersets.py
+++ b/netbox/virtualization/tests/test_filtersets.py
@@ -1,9 +1,9 @@
from django.test import TestCase
-from dcim.models import DeviceRole, Platform, Region, Site, SiteGroup
+from dcim.models import Device, DeviceRole, Platform, Region, Site, SiteGroup
from ipam.models import IPAddress, VRF
from tenancy.models import Tenant, TenantGroup
-from utilities.testing import ChangeLoggedFilterSetTests
+from utilities.testing import ChangeLoggedFilterSetTests, create_test_device
from virtualization.choices import *
from virtualization.filtersets import *
from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface
@@ -123,9 +123,9 @@ class ClusterTestCase(TestCase, ChangeLoggedFilterSetTests):
Tenant.objects.bulk_create(tenants)
clusters = (
- Cluster(name='Cluster 1', type=cluster_types[0], group=cluster_groups[0], site=sites[0], tenant=tenants[0]),
- Cluster(name='Cluster 2', type=cluster_types[1], group=cluster_groups[1], site=sites[1], tenant=tenants[1]),
- Cluster(name='Cluster 3', type=cluster_types[2], group=cluster_groups[2], site=sites[2], tenant=tenants[2]),
+ Cluster(name='Cluster 1', type=cluster_types[0], group=cluster_groups[0], status=ClusterStatusChoices.STATUS_PLANNED, site=sites[0], tenant=tenants[0]),
+ Cluster(name='Cluster 2', type=cluster_types[1], group=cluster_groups[1], status=ClusterStatusChoices.STATUS_STAGING, site=sites[1], tenant=tenants[1]),
+ Cluster(name='Cluster 3', type=cluster_types[2], group=cluster_groups[2], status=ClusterStatusChoices.STATUS_ACTIVE, site=sites[2], tenant=tenants[2]),
)
Cluster.objects.bulk_create(clusters)
@@ -161,6 +161,10 @@ class ClusterTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'group': [groups[0].slug, groups[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+ def test_status(self):
+ params = {'status': [ClusterStatusChoices.STATUS_PLANNED, ClusterStatusChoices.STATUS_STAGING]}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
def test_type(self):
types = ClusterType.objects.all()[:2]
params = {'type_id': [types[0].pk, types[1].pk]}
@@ -221,9 +225,9 @@ class VirtualMachineTestCase(TestCase, ChangeLoggedFilterSetTests):
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(name='Site 1', slug='site-1', region=regions[0], group=site_groups[0]),
+ Site(name='Site 2', slug='site-2', region=regions[1], group=site_groups[1]),
+ Site(name='Site 3', slug='site-3', region=regions[2], group=site_groups[2]),
)
Site.objects.bulk_create(sites)
@@ -248,6 +252,12 @@ class VirtualMachineTestCase(TestCase, ChangeLoggedFilterSetTests):
)
DeviceRole.objects.bulk_create(roles)
+ devices = (
+ create_test_device('device1', cluster=clusters[0]),
+ create_test_device('device2', cluster=clusters[1]),
+ create_test_device('device3', cluster=clusters[2]),
+ )
+
tenant_groups = (
TenantGroup(name='Tenant group 1', slug='tenant-group-1'),
TenantGroup(name='Tenant group 2', slug='tenant-group-2'),
@@ -264,9 +274,9 @@ class VirtualMachineTestCase(TestCase, ChangeLoggedFilterSetTests):
Tenant.objects.bulk_create(tenants)
vms = (
- VirtualMachine(name='Virtual Machine 1', cluster=clusters[0], platform=platforms[0], role=roles[0], tenant=tenants[0], status=VirtualMachineStatusChoices.STATUS_ACTIVE, vcpus=1, memory=1, disk=1, local_context_data={"foo": 123}),
- VirtualMachine(name='Virtual Machine 2', cluster=clusters[1], platform=platforms[1], role=roles[1], tenant=tenants[1], status=VirtualMachineStatusChoices.STATUS_STAGED, vcpus=2, memory=2, disk=2),
- VirtualMachine(name='Virtual Machine 3', cluster=clusters[2], platform=platforms[2], role=roles[2], tenant=tenants[2], status=VirtualMachineStatusChoices.STATUS_OFFLINE, vcpus=3, memory=3, disk=3),
+ VirtualMachine(name='Virtual Machine 1', site=sites[0], cluster=clusters[0], device=devices[0], platform=platforms[0], role=roles[0], tenant=tenants[0], status=VirtualMachineStatusChoices.STATUS_ACTIVE, vcpus=1, memory=1, disk=1, local_context_data={"foo": 123}),
+ VirtualMachine(name='Virtual Machine 2', site=sites[1], cluster=clusters[1], device=devices[1], platform=platforms[1], role=roles[1], tenant=tenants[1], status=VirtualMachineStatusChoices.STATUS_STAGED, vcpus=2, memory=2, disk=2),
+ VirtualMachine(name='Virtual Machine 3', site=sites[2], cluster=clusters[2], device=devices[2], platform=platforms[2], role=roles[2], tenant=tenants[2], status=VirtualMachineStatusChoices.STATUS_OFFLINE, vcpus=3, memory=3, disk=3),
)
VirtualMachine.objects.bulk_create(vms)
@@ -327,6 +337,13 @@ class VirtualMachineTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'cluster': [clusters[0].name, clusters[1].name]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+ def test_device(self):
+ devices = Device.objects.all()[:2]
+ params = {'device_id': [devices[0].pk, devices[1].pk]}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+ params = {'device': [devices[0].name, devices[1].name]}
+ 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]}
diff --git a/netbox/virtualization/tests/test_models.py b/netbox/virtualization/tests/test_models.py
index 3b4d73a30..df5816efa 100644
--- a/netbox/virtualization/tests/test_models.py
+++ b/netbox/virtualization/tests/test_models.py
@@ -1,21 +1,19 @@
from django.core.exceptions import ValidationError
from django.test import TestCase
+from dcim.models import Site
from virtualization.models import *
from tenancy.models import Tenant
class VirtualMachineTestCase(TestCase):
- def setUp(self):
-
- cluster_type = ClusterType.objects.create(name='Test Cluster Type 1', slug='Test Cluster Type 1')
- self.cluster = Cluster.objects.create(name='Test Cluster 1', type=cluster_type)
-
def test_vm_duplicate_name_per_cluster(self):
+ cluster_type = ClusterType.objects.create(name='Cluster Type 1', slug='cluster-type-1')
+ cluster = Cluster.objects.create(name='Cluster 1', type=cluster_type)
vm1 = VirtualMachine(
- cluster=self.cluster,
+ cluster=cluster,
name='Test VM 1'
)
vm1.save()
@@ -43,3 +41,33 @@ class VirtualMachineTestCase(TestCase):
# Two VMs assigned to the same Cluster and different Tenants should pass validation
vm2.full_clean()
vm2.save()
+
+ def test_vm_mismatched_site_cluster(self):
+ cluster_type = ClusterType.objects.create(name='Cluster Type 1', slug='cluster-type-1')
+
+ sites = (
+ Site(name='Site 1', slug='site-1'),
+ Site(name='Site 2', slug='site-2'),
+ )
+ 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.objects.bulk_create(clusters)
+
+ # VM with site only should pass
+ VirtualMachine(name='vm1', site=sites[0]).full_clean()
+
+ # VM with non-site cluster only should pass
+ VirtualMachine(name='vm1', cluster=clusters[2]).full_clean()
+
+ # VM with mismatched site & cluster should fail
+ with self.assertRaises(ValidationError):
+ VirtualMachine(name='vm1', site=sites[0], cluster=clusters[1]).full_clean()
+
+ # VM with cluster site but no direct site should fail
+ with self.assertRaises(ValidationError):
+ VirtualMachine(name='vm1', site=None, cluster=clusters[0]).full_clean()
diff --git a/netbox/virtualization/tests/test_views.py b/netbox/virtualization/tests/test_views.py
index 8edc14f00..01d4394f3 100644
--- a/netbox/virtualization/tests/test_views.py
+++ b/netbox/virtualization/tests/test_views.py
@@ -5,7 +5,7 @@ from netaddr import EUI
from dcim.choices import InterfaceModeChoices
from dcim.models import DeviceRole, Platform, Site
from ipam.models import VLAN, VRF
-from utilities.testing import ViewTestCases, create_tags
+from utilities.testing import ViewTestCases, create_tags, create_test_device
from virtualization.choices import *
from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface
@@ -101,9 +101,9 @@ class ClusterTestCase(ViewTestCases.PrimaryObjectViewTestCase):
ClusterType.objects.bulk_create(clustertypes)
Cluster.objects.bulk_create([
- Cluster(name='Cluster 1', group=clustergroups[0], type=clustertypes[0], site=sites[0]),
- Cluster(name='Cluster 2', group=clustergroups[0], type=clustertypes[0], site=sites[0]),
- Cluster(name='Cluster 3', group=clustergroups[0], type=clustertypes[0], site=sites[0]),
+ 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]),
])
tags = create_tags('Alpha', 'Bravo', 'Charlie')
@@ -112,6 +112,7 @@ class ClusterTestCase(ViewTestCases.PrimaryObjectViewTestCase):
'name': 'Cluster X',
'group': clustergroups[1].pk,
'type': clustertypes[1].pk,
+ 'status': ClusterStatusChoices.STATUS_OFFLINE,
'tenant': None,
'site': sites[1].pk,
'comments': 'Some comments',
@@ -119,15 +120,16 @@ class ClusterTestCase(ViewTestCases.PrimaryObjectViewTestCase):
}
cls.csv_data = (
- "name,type",
- "Cluster 4,Cluster Type 1",
- "Cluster 5,Cluster Type 1",
- "Cluster 6,Cluster Type 1",
+ "name,type,status",
+ "Cluster 4,Cluster Type 1,active",
+ "Cluster 5,Cluster Type 1,active",
+ "Cluster 6,Cluster Type 1,active",
)
cls.bulk_edit_data = {
'group': clustergroups[1].pk,
'type': clustertypes[1].pk,
+ 'status': ClusterStatusChoices.STATUS_OFFLINE,
'tenant': None,
'site': sites[1].pk,
'comments': 'New comments',
@@ -166,24 +168,37 @@ class VirtualMachineTestCase(ViewTestCases.PrimaryObjectViewTestCase):
)
Platform.objects.bulk_create(platforms)
+ sites = (
+ Site(name='Site 1', slug='site-1'),
+ Site(name='Site 2', slug='site-2'),
+ )
+ Site.objects.bulk_create(sites)
+
clustertype = ClusterType.objects.create(name='Cluster Type 1', slug='cluster-type-1')
clusters = (
- Cluster(name='Cluster 1', type=clustertype),
- Cluster(name='Cluster 2', type=clustertype),
+ Cluster(name='Cluster 1', type=clustertype, site=sites[0]),
+ Cluster(name='Cluster 2', type=clustertype, site=sites[1]),
)
Cluster.objects.bulk_create(clusters)
+ devices = (
+ create_test_device('device1', site=sites[0], cluster=clusters[0]),
+ create_test_device('device2', site=sites[1], cluster=clusters[1]),
+ )
+
VirtualMachine.objects.bulk_create([
- VirtualMachine(name='Virtual Machine 1', cluster=clusters[0], role=deviceroles[0], platform=platforms[0]),
- VirtualMachine(name='Virtual Machine 2', cluster=clusters[0], role=deviceroles[0], platform=platforms[0]),
- VirtualMachine(name='Virtual Machine 3', cluster=clusters[0], role=deviceroles[0], platform=platforms[0]),
+ VirtualMachine(name='Virtual Machine 1', site=sites[0], cluster=clusters[0], device=devices[0], role=deviceroles[0], platform=platforms[0]),
+ VirtualMachine(name='Virtual Machine 2', site=sites[0], cluster=clusters[0], device=devices[0], role=deviceroles[0], platform=platforms[0]),
+ VirtualMachine(name='Virtual Machine 3', site=sites[0], cluster=clusters[0], device=devices[0], role=deviceroles[0], platform=platforms[0]),
])
tags = create_tags('Alpha', 'Bravo', 'Charlie')
cls.form_data = {
'cluster': clusters[1].pk,
+ 'device': devices[1].pk,
+ 'site': sites[1].pk,
'tenant': None,
'platform': platforms[1].pk,
'name': 'Virtual Machine X',
@@ -200,14 +215,16 @@ class VirtualMachineTestCase(ViewTestCases.PrimaryObjectViewTestCase):
}
cls.csv_data = (
- "name,status,cluster",
- "Virtual Machine 4,active,Cluster 1",
- "Virtual Machine 5,active,Cluster 1",
- "Virtual Machine 6,active,Cluster 1",
+ "name,status,site,cluster,device",
+ "Virtual Machine 4,active,Site 1,Cluster 1,device1",
+ "Virtual Machine 5,active,Site 1,Cluster 1,device1",
+ "Virtual Machine 6,active,Site 1,Cluster 1,",
)
cls.bulk_edit_data = {
+ 'site': sites[1].pk,
'cluster': clusters[1].pk,
+ 'device': devices[1].pk,
'tenant': None,
'platform': platforms[1].pk,
'status': VirtualMachineStatusChoices.STATUS_STAGED,
@@ -243,8 +260,8 @@ class VMInterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase):
clustertype = ClusterType.objects.create(name='Cluster Type 1', slug='cluster-type-1')
cluster = Cluster.objects.create(name='Cluster 1', type=clustertype, site=site)
virtualmachines = (
- VirtualMachine(name='Virtual Machine 1', cluster=cluster, role=devicerole),
- VirtualMachine(name='Virtual Machine 2', cluster=cluster, role=devicerole),
+ VirtualMachine(name='Virtual Machine 1', site=site, cluster=cluster, role=devicerole),
+ VirtualMachine(name='Virtual Machine 2', site=site, cluster=cluster, role=devicerole),
)
VirtualMachine.objects.bulk_create(virtualmachines)
diff --git a/requirements.txt b/requirements.txt
index 0a15fcf20..d5b86eafa 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,12 +1,14 @@
-Django==4.0.4
-django-cors-headers==3.12.0
-django-debug-toolbar==3.2.4
-django-filter==21.1
+bleach==5.0.0
+Django==4.0.5
+django-cors-headers==3.13.0
+django-debug-toolbar==3.4.0
+django-filter==22.1
django-graphiql-debug-toolbar==0.2.0
django-mptt==0.13.4
django-pglocks==1.0.4
django-prometheus==2.2.0
django-redis==5.2.0
+django-rich==1.4.0
django-rq==2.5.1
django-tables2==2.4.1
django-taggit==2.1.0
@@ -18,15 +20,15 @@ gunicorn==20.1.0
Jinja2==3.1.2
Markdown==3.3.7
markdown-include==0.6.0
-mkdocs-material==8.2.14
-mkdocstrings[python-legacy]==0.18.1
+mkdocs-material==8.3.6
+mkdocstrings[python-legacy]==0.19.0
netaddr==0.8.0
-Pillow==9.1.0
+Pillow==9.1.1
psycopg2-binary==2.9.3
PyYAML==6.0
sentry-sdk==1.5.12
social-auth-app-django==5.0.0
-social-auth-core==4.2.0
+social-auth-core==4.3.0
svgwrite==1.4.2
tablib==3.2.1
tzdata==2022.1