{% include 'extras/inc/job_label.html' with result=script.result %}
diff --git a/netbox/templates/extras/tag.html b/netbox/templates/extras/tag.html
index ff54a4800..2ad7cf814 100644
--- a/netbox/templates/extras/tag.html
+++ b/netbox/templates/extras/tag.html
@@ -6,7 +6,7 @@
Description
- {{ tag.description|placeholder }}
+ {{ object.description|placeholder }}
diff --git a/netbox/templates/extras/tag_edit.html b/netbox/templates/extras/tag_edit.html
index 87b9a2e53..1516bde39 100644
--- a/netbox/templates/extras/tag_edit.html
+++ b/netbox/templates/extras/tag_edit.html
@@ -1,4 +1,4 @@
-{% extends 'utilities/obj_edit.html' %}
+{% extends 'generic/object_edit.html' %}
{% load form_helpers %}
{% block form %}
diff --git a/netbox/templates/utilities/obj_bulk_add_component.html b/netbox/templates/generic/object_bulk_add_component.html
similarity index 100%
rename from netbox/templates/utilities/obj_bulk_add_component.html
rename to netbox/templates/generic/object_bulk_add_component.html
diff --git a/netbox/templates/utilities/obj_bulk_delete.html b/netbox/templates/generic/object_bulk_delete.html
similarity index 100%
rename from netbox/templates/utilities/obj_bulk_delete.html
rename to netbox/templates/generic/object_bulk_delete.html
diff --git a/netbox/templates/utilities/obj_bulk_edit.html b/netbox/templates/generic/object_bulk_edit.html
similarity index 100%
rename from netbox/templates/utilities/obj_bulk_edit.html
rename to netbox/templates/generic/object_bulk_edit.html
diff --git a/netbox/templates/utilities/obj_bulk_import.html b/netbox/templates/generic/object_bulk_import.html
similarity index 83%
rename from netbox/templates/utilities/obj_bulk_import.html
rename to netbox/templates/generic/object_bulk_import.html
index 9d63788ba..170cf3665 100644
--- a/netbox/templates/utilities/obj_bulk_import.html
+++ b/netbox/templates/generic/object_bulk_import.html
@@ -53,7 +53,7 @@
{% if field.required %}
-
+
{% else %}
—
{% endif %}
@@ -66,9 +66,9 @@
{% endif %}
- {% if field.choice_values %}
-
-
+ {% if field.STATIC_CHOICES %}
+
+
@@ -77,9 +77,12 @@
×
{{ name }}
Choices
-
-
{% for value, label in field.choices %}{% if value %}{{ value }} {% endif %}{% endfor %}
-
+
+ Import Value Label
+ {% for value, label in field.choices %}
+ {% if value %}{{ value }} {{ label }} {% endif %}
+ {% endfor %}
+
@@ -100,11 +103,11 @@
- Required fields must be specified for all
+ Required fields must be specified for all
objects.
- Related objects may be referenced by any unique attribute.
+ Related objects may be referenced by any unique attribute.
For example, vrf.rd
would identify a VRF by its route distinguisher.
{% endif %}
diff --git a/netbox/templates/utilities/obj_bulk_remove.html b/netbox/templates/generic/object_bulk_remove.html
similarity index 100%
rename from netbox/templates/utilities/obj_bulk_remove.html
rename to netbox/templates/generic/object_bulk_remove.html
diff --git a/netbox/templates/utilities/obj_bulk_rename.html b/netbox/templates/generic/object_bulk_rename.html
similarity index 100%
rename from netbox/templates/utilities/obj_bulk_rename.html
rename to netbox/templates/generic/object_bulk_rename.html
diff --git a/netbox/templates/utilities/obj_delete.html b/netbox/templates/generic/object_delete.html
similarity index 100%
rename from netbox/templates/utilities/obj_delete.html
rename to netbox/templates/generic/object_delete.html
diff --git a/netbox/templates/utilities/obj_edit.html b/netbox/templates/generic/object_edit.html
similarity index 93%
rename from netbox/templates/utilities/obj_edit.html
rename to netbox/templates/generic/object_edit.html
index 0bd051161..965749a6d 100644
--- a/netbox/templates/utilities/obj_edit.html
+++ b/netbox/templates/generic/object_edit.html
@@ -13,7 +13,9 @@
{% if settings.DOCS_ROOT %}
-
+
+
+
{% endif %}
{% block title %}{% if obj.pk %}Editing {{ obj_type }} {{ obj }}{% else %}Add a new {{ obj_type }}{% endif %}{% endblock %}
diff --git a/netbox/templates/utilities/obj_import.html b/netbox/templates/generic/object_import.html
similarity index 100%
rename from netbox/templates/utilities/obj_import.html
rename to netbox/templates/generic/object_import.html
diff --git a/netbox/templates/utilities/obj_list.html b/netbox/templates/generic/object_list.html
similarity index 80%
rename from netbox/templates/utilities/obj_list.html
rename to netbox/templates/generic/object_list.html
index 47f11e1c1..738cbca49 100644
--- a/netbox/templates/utilities/obj_list.html
+++ b/netbox/templates/generic/object_list.html
@@ -1,12 +1,13 @@
{% extends 'base.html' %}
{% load buttons %}
{% load helpers %}
+{% load static %}
{% block content %}
{% block buttons %}{% endblock %}
{% if request.user.is_authenticated and table_config_form %}
- Configure
+ Configure
{% endif %}
{% if permissions.add and 'add' in action_buttons %}
{% add_button content_type.model_class|validated_viewname:"add" %}
@@ -20,7 +21,13 @@
{% block title %}{{ content_type.model_class|meta:"verbose_name_plural"|bettertitle }}{% endblock %}
-
- {% if filter_form %}
-
- {% include 'inc/search_panel.html' %}
- {% block sidebar %}{% endblock %}
-
- {% endif %}
+{% table_config_form table table_name="ObjectTable" %}
+{% endblock %}
+
+{% block javascript %}
+
{% endblock %}
diff --git a/netbox/templates/home.html b/netbox/templates/home.html
index e06b67469..47e0e8a1a 100644
--- a/netbox/templates/home.html
+++ b/netbox/templates/home.html
@@ -6,7 +6,7 @@
{% if new_release %}
{# new_release is set only if the current user is a superuser or staff member #}
@@ -28,7 +28,7 @@
{{ stats.site_count }}
{% else %}
-
+
Sites
{% endif %}
Geographic locations
@@ -38,7 +38,7 @@
{{ stats.tenant_count }}
{% else %}
-
+
Tenants
{% endif %}
Customers or departments
@@ -55,7 +55,7 @@
{{ stats.rack_count }}
{% else %}
-
+
Racks
{% endif %}
Equipment racks, optionally organized by group
@@ -65,7 +65,7 @@
{{ stats.devicetype_count }}
{% else %}
-
+
Device Types
{% endif %}
Physical hardware models by manufacturer
@@ -75,7 +75,7 @@
{{ stats.device_count }}
{% else %}
-
+
Devices
{% endif %}
Rack-mounted network equipment, servers, and other devices
@@ -86,28 +86,28 @@
{{ stats.cable_count }}
Cables
{% else %}
-
+
Cables
{% endif %}
{% if perms.dcim.view_interface %}
{{ stats.interface_connections_count }}
Interfaces
{% else %}
-
+
Interfaces
{% endif %}
{% if perms.dcim.view_consoleport and perms.dcim.view_consoleserverport %}
{{ stats.console_connections_count }}
Console
{% else %}
-
+
Console
{% endif %}
{% if perms.dcim.view_powerport and perms.dcim.view_poweroutlet %}
{{ stats.power_connections_count }}
Power
{% else %}
-
+
Power
{% endif %}
@@ -123,7 +123,7 @@
{{ stats.powerfeed_count }}
{% else %}
-
+
Power Feeds
{% endif %}
Electrical circuits delivering power from panels
@@ -133,7 +133,7 @@
{{ stats.powerpanel_count }}
{% else %}
-
+
Power Panels
{% endif %}
Electrical panels receiving utility power
@@ -152,7 +152,7 @@
{{ stats.vrf_count }}
{% else %}
-
+
VRFs
{% endif %}
Virtual routing and forwarding tables
@@ -162,7 +162,7 @@
{{ stats.aggregate_count }}
{% else %}
-
+
Aggregates
{% endif %}
Top-level IP allocations
@@ -172,7 +172,7 @@
{{ stats.prefix_count }}
{% else %}
-
+
Prefixes
{% endif %}
IPv4 and IPv6 network assignments
@@ -182,7 +182,7 @@
{{ stats.ipaddress_count }}
{% else %}
-
+
IP Addresses
{% endif %}
Individual IPv4 and IPv6 addresses
@@ -192,7 +192,7 @@
{{ stats.vlan_count }}
{% else %}
-
+
VLANs
{% endif %}
Layer two domains, identified by VLAN ID
@@ -209,7 +209,7 @@
{{ stats.provider_count }}
{% else %}
-
+
Providers
{% endif %}
Organizations which provide circuit connectivity
@@ -219,7 +219,7 @@
{{ stats.circuit_count }}
{% else %}
-
+
Circuits
{% endif %}
Communication links for Internet transit, peering, and other services
@@ -236,7 +236,7 @@
{{ stats.cluster_count }}
{% else %}
-
+
Clusters
{% endif %}
Clusters of physical hosts in which VMs reside
@@ -246,7 +246,7 @@
{{ stats.virtualmachine_count }}
{% else %}
-
+
Virtual Machines
{% endif %}
Virtual compute instances running inside clusters
@@ -265,7 +265,7 @@
{{ stats.secret_count }}
{% else %}
-
+
Secrets
{% endif %}
Cryptographically secured secret data
@@ -291,7 +291,7 @@
{% else %}
- No permission
+ No permission
{% endif %}
@@ -305,11 +305,11 @@
{% with action=change.get_action_display|lower %}
{% if action == 'created' %}
-
+ Created
{% elif action == 'updated' %}
-
+ Modified
{% elif action == 'deleted' %}
-
+ Deleted
{% endif %}
{{ change.changed_object_type.name|bettertitle }}
{% if change.changed_object.get_absolute_url %}
@@ -337,7 +337,7 @@
{% else %}
- No permission
+ No permission
{% endif %}
diff --git a/netbox/templates/import_success.html b/netbox/templates/import_success.html
index da9958bdb..b427e450e 100644
--- a/netbox/templates/import_success.html
+++ b/netbox/templates/import_success.html
@@ -4,7 +4,7 @@
{% block title %}Import Completed{% endblock %}
{% include 'responsive_table.html' %}
-
+
Import more
{% if return_url %}
diff --git a/netbox/templates/inc/created_updated.html b/netbox/templates/inc/created_updated.html
index 001bb6b85..58be428c4 100644
--- a/netbox/templates/inc/created_updated.html
+++ b/netbox/templates/inc/created_updated.html
@@ -1,3 +1,3 @@
- Created {{ obj.created }} · Updated {{ obj.last_updated|timesince }} ago
+ Created {{ object.created }} · Updated {{ object.last_updated|timesince }} ago
diff --git a/netbox/templates/inc/custom_fields_panel.html b/netbox/templates/inc/custom_fields_panel.html
index 8c1872273..d5f858f15 100644
--- a/netbox/templates/inc/custom_fields_panel.html
+++ b/netbox/templates/inc/custom_fields_panel.html
@@ -1,4 +1,4 @@
-{% with custom_fields=obj.get_custom_fields %}
+{% with custom_fields=object.get_custom_fields %}
{% if custom_fields %}
@@ -10,9 +10,9 @@
{{ field }}
{% if field.type == 'boolean' and value == True %}
-
+
{% elif field.type == 'boolean' and value == False %}
-
+
{% elif field.type == 'url' and value %}
{{ value|truncatechars:70 }}
{% elif value is not None %}
diff --git a/netbox/templates/inc/image_attachments.html b/netbox/templates/inc/image_attachments.html
index 2fee4dc78..38be9924d 100644
--- a/netbox/templates/inc/image_attachments.html
+++ b/netbox/templates/inc/image_attachments.html
@@ -9,7 +9,7 @@
{% for attachment in images %}
-
+
{{ attachment }}
{{ attachment.size|filesizeformat }}
@@ -17,12 +17,12 @@
{% if perms.extras.change_imageattachment %}
-
+
{% endif %}
{% if perms.extras.delete_imageattachment %}
-
+
{% endif %}
diff --git a/netbox/templates/inc/nav_menu.html b/netbox/templates/inc/nav_menu.html
index bf3d349cc..74a0aa35d 100644
--- a/netbox/templates/inc/nav_menu.html
+++ b/netbox/templates/inc/nav_menu.html
@@ -1,7 +1,7 @@
{% load static %}
{% load helpers %}
-
+
diff --git a/netbox/templates/inc/paginator.html b/netbox/templates/inc/paginator.html
index 50d7e06d3..37c3b06df 100644
--- a/netbox/templates/inc/paginator.html
+++ b/netbox/templates/inc/paginator.html
@@ -5,7 +5,7 @@
diff --git a/netbox/templates/inc/search_panel.html b/netbox/templates/inc/search_panel.html
index 5302f71b9..62ea51328 100644
--- a/netbox/templates/inc/search_panel.html
+++ b/netbox/templates/inc/search_panel.html
@@ -2,7 +2,7 @@
-
+
Search
@@ -17,7 +17,7 @@
-
+
@@ -31,10 +31,10 @@
{% endfor %}
- Apply
+ Apply
- Clear
+ Clear
diff --git a/netbox/templates/ipam/aggregate.html b/netbox/templates/ipam/aggregate.html
index 998eb2066..ce8cb0a19 100644
--- a/netbox/templates/ipam/aggregate.html
+++ b/netbox/templates/ipam/aggregate.html
@@ -9,8 +9,8 @@
@@ -19,7 +19,7 @@
-
+
@@ -27,30 +27,30 @@
- {% plugin_buttons aggregate %}
+ {% plugin_buttons object %}
{% if perms.ipam.add_aggregate %}
- {% clone_button aggregate %}
+ {% clone_button object %}
{% endif %}
{% if perms.ipam.change_aggregate %}
- {% edit_button aggregate %}
+ {% edit_button object %}
{% endif %}
{% if perms.ipam.delete_aggregate %}
- {% delete_button aggregate %}
+ {% delete_button object %}
{% endif %}
-
{% block title %}{{ aggregate }}{% endblock %}
- {% include 'inc/created_updated.html' with obj=aggregate %}
+
{% block title %}{{ object }}{% endblock %}
+ {% include 'inc/created_updated.html' %}
{% include 'ipam/inc/toggle_available.html' %}
- {% custom_links aggregate %}
+ {% custom_links object %}
@@ -66,41 +66,54 @@
Family
- IPv{{ aggregate.family }}
+ IPv{{ object.family }}
RIR
- {{ aggregate.rir }}
+ {{ object.rir }}
Utilization
- {{ aggregate.get_utilization }}%
+ {{ object.get_utilization }}%
+
+
+
+ Tenant
+
+ {% if object.tenant %}
+ {% if prefix.object.group %}
+ {{ object.tenant.group }} /
+ {% endif %}
+ {{ object.tenant }}
+ {% else %}
+ None
+ {% endif %}
Date Added
- {{ aggregate.date_added|placeholder }}
+ {{ object.date_added|placeholder }}
Description
- {{ aggregate.description|placeholder }}
+ {{ object.description|placeholder }}
- {% plugin_left_page aggregate %}
+ {% plugin_left_page object %}
- {% include 'inc/custom_fields_panel.html' with obj=aggregate %}
- {% include 'extras/inc/tags_panel.html' with tags=aggregate.tags.all url='ipam:aggregate_list' %}
- {% plugin_right_page aggregate %}
+ {% include 'inc/custom_fields_panel.html' %}
+ {% include 'extras/inc/tags_panel.html' with tags=object.tags.all url='ipam:aggregate_list' %}
+ {% plugin_right_page object %}
- {% plugin_full_width_page aggregate %}
+ {% plugin_full_width_page object %}
diff --git a/netbox/templates/ipam/aggregate_edit.html b/netbox/templates/ipam/aggregate_edit.html
index 3cb83ab54..f27abd663 100644
--- a/netbox/templates/ipam/aggregate_edit.html
+++ b/netbox/templates/ipam/aggregate_edit.html
@@ -1,4 +1,4 @@
-{% extends 'utilities/obj_edit.html' %}
+{% extends 'generic/object_edit.html' %}
{% load form_helpers %}
{% block form %}
@@ -11,6 +11,13 @@
{% render_field form.description %}
+
+
Tenancy
+
+ {% render_field form.tenant_group %}
+ {% render_field form.tenant %}
+
+
{% if form.custom_fields %}
Custom Fields
diff --git a/netbox/templates/ipam/aggregate_list.html b/netbox/templates/ipam/aggregate_list.html
index 85a2bd36d..c60646a71 100644
--- a/netbox/templates/ipam/aggregate_list.html
+++ b/netbox/templates/ipam/aggregate_list.html
@@ -1,10 +1,10 @@
-{% extends 'utilities/obj_list.html' %}
+{% extends 'generic/object_list.html' %}
{% load humanize %}
{% block sidebar %}
- Statistics
+ Statistics
Total IPv4 IPs {{ ipv4_total|intcomma }}
diff --git a/netbox/templates/ipam/inc/service.html b/netbox/templates/ipam/inc/service.html
index 9611be175..826fa99ad 100644
--- a/netbox/templates/ipam/inc/service.html
+++ b/netbox/templates/ipam/inc/service.html
@@ -1,13 +1,10 @@
-
- {{ service.name }}
-
-
- {{ service.get_protocol_display }}/{{ service.port }}
-
+ {{ service.name }}
+ {{ service.get_protocol_display }}
+ {{ service.port_list }}
{% for ip in service.ipaddresses.all %}
- {{ ip.address.ip }}
+ {{ ip.address.ip }}
{% empty %}
All IPs
{% endfor %}
@@ -15,16 +12,16 @@
{{ service.description }}
-
+
{% if perms.ipam.change_service %}
-
-
+
+
{% endif %}
{% if perms.ipam.delete_service %}
-
+
{% endif %}
diff --git a/netbox/templates/ipam/inc/toggle_available.html b/netbox/templates/ipam/inc/toggle_available.html
index 21d734d2d..161f6b788 100644
--- a/netbox/templates/ipam/inc/toggle_available.html
+++ b/netbox/templates/ipam/inc/toggle_available.html
@@ -2,8 +2,12 @@
{% if show_available is not None %}
{% endif %}
diff --git a/netbox/templates/ipam/inc/vlangroup_header.html b/netbox/templates/ipam/inc/vlangroup_header.html
index 221f41994..2507a749f 100644
--- a/netbox/templates/ipam/inc/vlangroup_header.html
+++ b/netbox/templates/ipam/inc/vlangroup_header.html
@@ -1,14 +1,14 @@
-{{ vlan_group }}
+{{ object }}
diff --git a/netbox/templates/ipam/ipaddress.html b/netbox/templates/ipam/ipaddress.html
index f213a275a..8fe98d4da 100644
--- a/netbox/templates/ipam/ipaddress.html
+++ b/netbox/templates/ipam/ipaddress.html
@@ -10,10 +10,10 @@
@@ -22,7 +22,7 @@
-
+
@@ -30,29 +30,29 @@
- {% plugin_buttons ipaddress %}
+ {% plugin_buttons object %}
{% if perms.ipam.add_ipaddress %}
- {% clone_button ipaddress %}
+ {% clone_button object %}
{% endif %}
{% if perms.ipam.change_ipaddress %}
- {%edit_button ipaddress %}
+ {%edit_button object %}
{% endif %}
{% if perms.ipam.delete_ipaddress %}
- {% delete_button ipaddress %}
+ {% delete_button object %}
{% endif %}
- {% block title %}{{ ipaddress }}{% endblock %}
- {% include 'inc/created_updated.html' with obj=ipaddress %}
+ {% block title %}{{ object }}{% endblock %}
+ {% include 'inc/created_updated.html' %}
- {% custom_links ipaddress %}
+ {% custom_links object %}
@@ -68,13 +68,13 @@
Family
- IPv{{ ipaddress.family }}
+ IPv{{ object.family }}
VRF
- {% if ipaddress.vrf %}
- {{ ipaddress.vrf }}
+ {% if object.vrf %}
+ {{ object.vrf }}
{% else %}
Global
{% endif %}
@@ -83,12 +83,11 @@
Tenant
- {% if ipaddress.tenant %}
- {% if ipaddress.tenant.group %}
- {{ ipaddress.tenant.group }}
-
+ {% if object.tenant %}
+ {% if object.tenant.group %}
+ {{ object.tenant.group }} /
{% endif %}
- {{ ipaddress.tenant }}
+ {{ object.tenant }}
{% else %}
None
{% endif %}
@@ -97,14 +96,14 @@
Status
- {{ ipaddress.get_status_display }}
+ {{ object.get_status_display }}
Role
- {% if ipaddress.role %}
- {{ ipaddress.get_role_display }}
+ {% if object.role %}
+ {{ object.get_role_display }}
{% else %}
None
{% endif %}
@@ -112,17 +111,17 @@
DNS Name
- {{ ipaddress.dns_name|placeholder }}
+ {{ object.dns_name|placeholder }}
Description
- {{ ipaddress.description|placeholder }}
+ {{ object.description|placeholder }}
Assignment
- {% if ipaddress.assigned_object %}
- {{ ipaddress.assigned_object.parent }} ({{ ipaddress.assigned_object }})
+ {% if object.assigned_object %}
+ {{ object.assigned_object.parent }} ({{ object.assigned_object }})
{% else %}
—
{% endif %}
@@ -131,10 +130,10 @@
NAT (inside)
- {% if ipaddress.nat_inside %}
- {{ ipaddress.nat_inside }}
- {% if ipaddress.nat_inside.assigned_object %}
- ({{ ipaddress.nat_inside.assigned_object.parent }} )
+ {% if object.nat_inside %}
+ {{ object.nat_inside }}
+ {% if object.nat_inside.assigned_object %}
+ ({{ object.nat_inside.assigned_object.parent }} )
{% endif %}
{% else %}
None
@@ -144,8 +143,8 @@
NAT (outside)
- {% if ipaddress.nat_outside %}
- {{ ipaddress.nat_outside }}
+ {% if object.nat_outside %}
+ {{ object.nat_outside }}
{% else %}
None
{% endif %}
@@ -153,9 +152,9 @@
- {% include 'inc/custom_fields_panel.html' with obj=ipaddress %}
- {% include 'extras/inc/tags_panel.html' with tags=ipaddress.tags.all url='ipam:ipaddress_list' %}
- {% plugin_left_page ipaddress %}
+ {% include 'inc/custom_fields_panel.html' %}
+ {% include 'extras/inc/tags_panel.html' with tags=object.tags.all url='ipam:ipaddress_list' %}
+ {% plugin_left_page object %}
{% include 'panel_table.html' with table=parent_prefixes_table heading='Parent Prefixes' %}
@@ -167,10 +166,10 @@
{% if more_duplicate_ips %}
@@ -180,12 +179,12 @@
{% endif %}
{% include 'utilities/obj_table.html' with table=related_ips_table table_template='panel_table.html' heading='Related IP Addresses' panel_class='default noprint' %}
- {% plugin_right_page ipaddress %}
+ {% plugin_right_page object %}
- {% plugin_full_width_page ipaddress %}
+ {% plugin_full_width_page object %}
{% endblock %}
diff --git a/netbox/templates/ipam/ipaddress_assign.html b/netbox/templates/ipam/ipaddress_assign.html
index ab163533f..913b6c4b4 100644
--- a/netbox/templates/ipam/ipaddress_assign.html
+++ b/netbox/templates/ipam/ipaddress_assign.html
@@ -1,4 +1,4 @@
-{% extends 'utilities/obj_edit.html' %}
+{% extends 'generic/object_edit.html' %}
{% load static %}
{% load form_helpers %}
{% load helpers %}
diff --git a/netbox/templates/ipam/ipaddress_bulk_add.html b/netbox/templates/ipam/ipaddress_bulk_add.html
index bbb179fc8..f019d48b8 100644
--- a/netbox/templates/ipam/ipaddress_bulk_add.html
+++ b/netbox/templates/ipam/ipaddress_bulk_add.html
@@ -1,4 +1,4 @@
-{% extends 'utilities/obj_edit.html' %}
+{% extends 'generic/object_edit.html' %}
{% load static %}
{% load form_helpers %}
diff --git a/netbox/templates/ipam/ipaddress_edit.html b/netbox/templates/ipam/ipaddress_edit.html
index 92093dea4..eb15a3059 100644
--- a/netbox/templates/ipam/ipaddress_edit.html
+++ b/netbox/templates/ipam/ipaddress_edit.html
@@ -1,4 +1,4 @@
-{% extends 'utilities/obj_edit.html' %}
+{% extends 'generic/object_edit.html' %}
{% load static %}
{% load form_helpers %}
{% load helpers %}
diff --git a/netbox/templates/ipam/prefix.html b/netbox/templates/ipam/prefix.html
index aa8242a89..18a4defd7 100644
--- a/netbox/templates/ipam/prefix.html
+++ b/netbox/templates/ipam/prefix.html
@@ -9,10 +9,10 @@
@@ -21,7 +21,7 @@
-
+
@@ -29,49 +29,49 @@
- {% plugin_buttons prefix %}
+ {% plugin_buttons object %}
{% if perms.ipam.add_prefix and active_tab == 'prefixes' and first_available_prefix %}
-
- Add Child Prefix
+
+ Add Child Prefix
{% endif %}
{% if perms.ipam.add_ipaddress and active_tab == 'ip-addresses' and first_available_ip %}
-
-
+
+
Add an IP Address
{% endif %}
{% if perms.ipam.add_prefix %}
- {% clone_button prefix %}
+ {% clone_button object %}
{% endif %}
{% if perms.ipam.change_prefix %}
- {% edit_button prefix %}
+ {% edit_button object %}
{% endif %}
{% if perms.ipam.delete_prefix %}
- {% delete_button prefix %}
+ {% delete_button object %}
{% endif %}
- {% block title %}{{ prefix }}{% endblock %}
- {% include 'inc/created_updated.html' with obj=prefix %}
+ {% block title %}{{ object }}{% endblock %}
+ {% include 'inc/created_updated.html' %}
{% include 'ipam/inc/toggle_available.html' %}
- {% custom_links prefix %}
+ {% custom_links object %}
@@ -87,13 +87,13 @@
Family
- IPv{{ prefix.family }}
+ IPv{{ object.family }}
VRF
- {% if prefix.vrf %}
- {{ prefix.vrf }} ({{ prefix.vrf.rd }})
+ {% if object.vrf %}
+ {{ object.vrf }} ({{ object.vrf.rd }})
{% else %}
Global
{% endif %}
@@ -102,12 +102,11 @@
Tenant
- {% if prefix.tenant %}
- {% if prefix.tenant.group %}
- {{ prefix.tenant.group }}
-
+ {% if object.tenant %}
+ {% if object.tenant.group %}
+ {{ object.tenant.group }} /
{% endif %}
- {{ prefix.tenant }}
+ {{ object.tenant }}
{% else %}
None
{% endif %}
@@ -126,12 +125,11 @@
Site
- {% if prefix.site %}
- {% if prefix.site.region %}
- {{ prefix.site.region }}
-
+ {% if object.site %}
+ {% if object.site.region %}
+ {{ object.site.region }} /
{% endif %}
- {{ prefix.site }}
+ {{ object.site }}
{% else %}
None
{% endif %}
@@ -140,12 +138,11 @@
VLAN
- {% if prefix.vlan %}
- {% if prefix.vlan.group %}
- {{ prefix.vlan.group }}
-
+ {% if object.vlan %}
+ {% if object.vlan.group %}
+ {{ object.vlan.group }} /
{% endif %}
- {{ prefix.vlan.display_name }}
+ {{ object.vlan.display_name }}
{% else %}
None
{% endif %}
@@ -154,14 +151,14 @@
Status
- {{ prefix.get_status_display }}
+ {{ object.get_status_display }}
Role
- {% if prefix.role %}
- {{ prefix.role }}
+ {% if object.role %}
+ {{ object.role }}
{% else %}
None
{% endif %}
@@ -169,15 +166,15 @@
Description
- {{ prefix.description|placeholder }}
+ {{ object.description|placeholder }}
Is a pool
- {% if prefix.is_pool %}
-
+ {% if object.is_pool %}
+
{% else %}
-
+
{% endif %}
@@ -187,21 +184,21 @@
- {% include 'inc/custom_fields_panel.html' with obj=prefix %}
- {% include 'extras/inc/tags_panel.html' with tags=prefix.tags.all url='ipam:prefix_list' %}
- {% plugin_left_page prefix %}
+ {% include 'inc/custom_fields_panel.html' %}
+ {% include 'extras/inc/tags_panel.html' with tags=object.tags.all url='ipam:prefix_list' %}
+ {% plugin_left_page object %}
{% if duplicate_prefix_table.rows %}
{% include 'panel_table.html' with table=duplicate_prefix_table heading='Duplicate Prefixes' panel_class='danger' %}
{% endif %}
{% include 'panel_table.html' with table=parent_prefix_table heading='Parent Prefixes' panel_class='default' %}
- {% plugin_right_page prefix %}
+ {% plugin_right_page object %}
- {% plugin_full_width_page prefix %}
+ {% plugin_full_width_page object %}
{% endblock %}
diff --git a/netbox/templates/ipam/prefix_delete.html b/netbox/templates/ipam/prefix_delete.html
index 5ea39dc4c..eb7a22d3c 100644
--- a/netbox/templates/ipam/prefix_delete.html
+++ b/netbox/templates/ipam/prefix_delete.html
@@ -1,4 +1,4 @@
-{% extends 'utilities/obj_delete.html' %}
+{% extends 'generic/object_delete.html' %}
{% block message_extra %}
Note: This will not delete any child prefixes or IP addresses.
diff --git a/netbox/templates/ipam/prefix_edit.html b/netbox/templates/ipam/prefix_edit.html
index 126593074..3505c373b 100644
--- a/netbox/templates/ipam/prefix_edit.html
+++ b/netbox/templates/ipam/prefix_edit.html
@@ -1,4 +1,4 @@
-{% extends 'utilities/obj_edit.html' %}
+{% extends 'generic/object_edit.html' %}
{% load form_helpers %}
{% block form %}
diff --git a/netbox/templates/ipam/prefix_list.html b/netbox/templates/ipam/prefix_list.html
index c4c6ea157..fc3fadaee 100644
--- a/netbox/templates/ipam/prefix_list.html
+++ b/netbox/templates/ipam/prefix_list.html
@@ -1,4 +1,4 @@
-{% extends 'utilities/obj_list.html' %}
+{% extends 'generic/object_list.html' %}
{% load helpers %}
{% block buttons %}
@@ -11,7 +11,7 @@
diff --git a/netbox/templates/ipam/rir_list.html b/netbox/templates/ipam/rir_list.html
index 02f01fc7c..89d31ae7d 100644
--- a/netbox/templates/ipam/rir_list.html
+++ b/netbox/templates/ipam/rir_list.html
@@ -1,14 +1,14 @@
-{% extends 'utilities/obj_list.html' %}
+{% extends 'generic/object_list.html' %}
{% block buttons %}
{% if request.GET.family == '6' %}
-
+
IPv4 Stats
{% else %}
-
+
IPv6 Stats
{% endif %}
@@ -17,7 +17,7 @@
{% block sidebar %}
{% if request.GET.family == '6' %}
- Numbers shown indicate /64 prefixes.
+ Numbers shown indicate /64 prefixes.
{% endif %}
{% endblock %}
diff --git a/netbox/templates/ipam/routetarget.html b/netbox/templates/ipam/routetarget.html
new file mode 100644
index 000000000..3443d0bf4
--- /dev/null
+++ b/netbox/templates/ipam/routetarget.html
@@ -0,0 +1,100 @@
+{% extends 'base.html' %}
+{% load buttons %}
+{% load custom_links %}
+{% load helpers %}
+{% load plugins %}
+
+{% block header %}
+
+
+ {% plugin_buttons object %}
+ {% if perms.ipam.add_routetarget %}
+ {% clone_button object %}
+ {% endif %}
+ {% if perms.ipam.change_routetarget %}
+ {% edit_button object %}
+ {% endif %}
+ {% if perms.ipam.delete_routetarget %}
+ {% delete_button object %}
+ {% endif %}
+
+ {% block title %}Route target {{ object }}{% endblock %}
+ {% include 'inc/created_updated.html' %}
+
+ {% custom_links object %}
+
+
+{% endblock %}
+
+{% block content %}
+
+
+
+
+ Route Target
+
+
+
+ Name
+ {{ object.name }}
+
+
+ Tenant
+
+ {% if object.tenant %}
+ {{ object.tenant }}
+ {% else %}
+ None
+ {% endif %}
+
+
+
+ Description
+ {{ object.description|placeholder }}
+
+
+
+ {% include 'extras/inc/tags_panel.html' with tags=object.tags.all url='ipam:routetarget_list' %}
+ {% include 'inc/custom_fields_panel.html' %}
+ {% plugin_left_page object %}
+
+
+ {% include 'panel_table.html' with table=importing_vrfs_table heading="Importing VRFs" %}
+ {% include 'panel_table.html' with table=exporting_vrfs_table heading="Exporting VRFs" %}
+ {% plugin_right_page object %}
+
+
+
+
+ {% plugin_full_width_page object %}
+
+
+{% endblock %}
diff --git a/netbox/templates/ipam/service.html b/netbox/templates/ipam/service.html
index b16e99aa3..d3e94b90d 100644
--- a/netbox/templates/ipam/service.html
+++ b/netbox/templates/ipam/service.html
@@ -9,8 +9,8 @@
@@ -19,7 +19,7 @@
-
+
@@ -27,18 +27,18 @@
- {% plugin_buttons service %}
+ {% plugin_buttons object %}
{% if perms.dcim.change_service %}
- {% edit_button service %}
+ {% edit_button object %}
{% endif %}
{% if perms.dcim.delete_service %}
- {% delete_button service %}
+ {% delete_button object %}
{% endif %}
-{% block title %}{{ service }}{% endblock %}
-{% include 'inc/created_updated.html' with obj=service %}
+{% block title %}{{ object }}{% endblock %}
+{% include 'inc/created_updated.html' %}
- {% custom_links service %}
+ {% custom_links object %}
@@ -49,26 +49,26 @@
Name
- {{ service.name }}
+ {{ object.name }}
Parent
- {{ service.parent }}
+ {{ object.parent }}
Protocol
- {{ service.get_protocol_display }}
+ {{ object.get_protocol_display }}
- Port
- {{ service.port }}
+ Ports
+ {{ object.port_list }}
IP Addresses
- {% for ipaddress in service.ipaddresses.all %}
+ {% for ipaddress in object.ipaddresses.all %}
{{ ipaddress }}
{% empty %}
None
@@ -77,21 +77,21 @@
Description
- {{ service.description|placeholder }}
+ {{ object.description|placeholder }}
- {% include 'inc/custom_fields_panel.html' with obj=service %}
- {% include 'extras/inc/tags_panel.html' with tags=service.tags.all url='ipam:service_list' %}
- {% plugin_left_page service %}
+ {% include 'inc/custom_fields_panel.html' %}
+ {% include 'extras/inc/tags_panel.html' with tags=object.tags.all url='ipam:service_list' %}
+ {% plugin_left_page object %}
- {% plugin_right_page service %}
+ {% plugin_right_page object %}
- {% plugin_full_width_page service %}
+ {% plugin_full_width_page object %}
{% endblock %}
diff --git a/netbox/templates/ipam/service_edit.html b/netbox/templates/ipam/service_edit.html
index 521aec137..8d6fde9e9 100644
--- a/netbox/templates/ipam/service_edit.html
+++ b/netbox/templates/ipam/service_edit.html
@@ -1,4 +1,4 @@
-{% extends 'utilities/obj_edit.html' %}
+{% extends 'generic/object_edit.html' %}
{% load form_helpers %}
{% block form %}
@@ -22,10 +22,11 @@
{% endif %}
{% render_field form.name %}
{% render_field form.ipaddresses %}
diff --git a/netbox/templates/ipam/vlan.html b/netbox/templates/ipam/vlan.html
index d9cb0c66c..d2967ca56 100644
--- a/netbox/templates/ipam/vlan.html
+++ b/netbox/templates/ipam/vlan.html
@@ -9,13 +9,13 @@
@@ -24,7 +24,7 @@
-
+
@@ -32,35 +32,35 @@
- {% plugin_buttons vlan %}
+ {% plugin_buttons object %}
{% if perms.ipam.add_vlan %}
- {% clone_button vlan %}
+ {% clone_button object %}
{% endif %}
{% if perms.ipam.change_vlan %}
- {% edit_button vlan %}
+ {% edit_button object %}
{% endif %}
{% if perms.ipam.delete_vlan %}
- {% delete_button vlan %}
+ {% delete_button object %}
{% endif %}
- {% block title %}VLAN {{ vlan.display_name }}{% endblock %}
- {% include 'inc/created_updated.html' with obj=vlan %}
+ {% block title %}VLAN {{ object.display_name }}{% endblock %}
+ {% include 'inc/created_updated.html' %}
- {% custom_links vlan %}
+ {% custom_links object %}
@@ -77,12 +77,11 @@
Site
- {% if vlan.site %}
- {% if vlan.site.region %}
- {{ vlan.site.region }}
-
+ {% if object.site %}
+ {% if object.site.region %}
+ {{ object.site.region }} /
{% endif %}
- {{ vlan.site }}
+ {{ object.site }}
{% else %}
None
{% endif %}
@@ -91,8 +90,8 @@
Group
- {% if vlan.group %}
- {{ vlan.group }}
+ {% if object.group %}
+ {{ object.group }}
{% else %}
None
{% endif %}
@@ -100,21 +99,20 @@
VLAN ID
- {{ vlan.vid }}
+ {{ object.vid }}
Name
- {{ vlan.name }}
+ {{ object.name }}
Tenant
- {% if vlan.tenant %}
- {% if vlan.tenant.group %}
- {{ vlan.tenant.group }}
-
+ {% if object.tenant %}
+ {% if object.tenant.group %}
+ {{ object.tenant.group }} /
{% endif %}
- {{ vlan.tenant }}
+ {{ object.tenant }}
{% else %}
None
{% endif %}
@@ -123,14 +121,14 @@
Status
- {{ vlan.get_status_display }}
+ {{ object.get_status_display }}
Role
- {% if vlan.role %}
- {{ vlan.role }}
+ {% if object.role %}
+ {{ object.role }}
{% else %}
None
{% endif %}
@@ -138,13 +136,13 @@
Description
- {{ vlan.description|placeholder }}
+ {{ object.description|placeholder }}
- {% include 'inc/custom_fields_panel.html' with obj=vlan %}
- {% include 'extras/inc/tags_panel.html' with tags=vlan.tags.all url='ipam:vlan_list' %}
- {% plugin_left_page vlan %}
+ {% include 'inc/custom_fields_panel.html' %}
+ {% include 'extras/inc/tags_panel.html' with tags=object.tags.all url='ipam:vlan_list' %}
+ {% plugin_left_page object %}
@@ -154,19 +152,19 @@
{% include 'responsive_table.html' with table=prefix_table %}
{% if perms.ipam.add_prefix %}
{% endif %}
- {% plugin_right_page vlan %}
+ {% plugin_right_page object %}
- {% plugin_full_width_page vlan %}
+ {% plugin_full_width_page object %}
{% endblock %}
diff --git a/netbox/templates/ipam/vlan_edit.html b/netbox/templates/ipam/vlan_edit.html
index 9ea3cdabb..54dcf727a 100644
--- a/netbox/templates/ipam/vlan_edit.html
+++ b/netbox/templates/ipam/vlan_edit.html
@@ -1,4 +1,4 @@
-{% extends 'utilities/obj_edit.html' %}
+{% extends 'generic/object_edit.html' %}
{% load form_helpers %}
{% block form %}
diff --git a/netbox/templates/ipam/vlangroup_vlans.html b/netbox/templates/ipam/vlangroup_vlans.html
index 7f8ac2044..490b7ab2c 100644
--- a/netbox/templates/ipam/vlangroup_vlans.html
+++ b/netbox/templates/ipam/vlangroup_vlans.html
@@ -1,16 +1,16 @@
{% extends 'base.html' %}
-{% block title %}{{ vlan_group }} - VLANs{% endblock %}
+{% block title %}{{ object }} - VLANs{% endblock %}
{% block content %}
diff --git a/netbox/templates/ipam/vrf.html b/netbox/templates/ipam/vrf.html
index 6fb6d725f..d17f8e9b9 100644
--- a/netbox/templates/ipam/vrf.html
+++ b/netbox/templates/ipam/vrf.html
@@ -9,7 +9,7 @@
VRFs
- {{ vrf }}
+ {{ object }}
@@ -18,7 +18,7 @@
-
+
@@ -26,29 +26,29 @@
- {% plugin_buttons vrf %}
+ {% plugin_buttons object %}
{% if perms.ipam.add_vrf %}
- {% clone_button vrf %}
+ {% clone_button object %}
{% endif %}
{% if perms.ipam.change_vrf %}
- {% edit_button vrf %}
+ {% edit_button object %}
{% endif %}
{% if perms.ipam.delete_vrf %}
- {% delete_button vrf %}
+ {% delete_button object %}
{% endif %}
- {% block title %}VRF {{ vrf }}{% endblock %}
- {% include 'inc/created_updated.html' with obj=vrf %}
+ {% block title %}VRF {{ object }}{% endblock %}
+ {% include 'inc/created_updated.html' %}
- {% custom_links vrf %}
+ {% custom_links object %}
@@ -64,13 +64,13 @@
Route Distinguisher
- {{ vrf.rd }}
+ {{ object.rd }}
Tenant
- {% if vrf.tenant %}
- {{ vrf.tenant }}
+ {% if object.tenant %}
+ {{ object.tenant }}
{% else %}
None
{% endif %}
@@ -79,36 +79,38 @@
Unique IP Space
- {% if vrf.enforce_unique %}
-
+ {% if object.enforce_unique %}
+
{% else %}
-
+
{% endif %}
Description
- {{ vrf.description|placeholder }}
+ {{ object.description|placeholder }}
Prefixes
- {{ prefix_count }}
+ {{ prefix_count }}
- {% include 'extras/inc/tags_panel.html' with tags=vrf.tags.all url='ipam:vrf_list' %}
- {% plugin_left_page vrf %}
+ {% include 'extras/inc/tags_panel.html' with tags=object.tags.all url='ipam:vrf_list' %}
+ {% include 'inc/custom_fields_panel.html' %}
+ {% plugin_left_page object %}
- {% include 'inc/custom_fields_panel.html' with obj=vrf %}
- {% plugin_right_page vrf %}
+ {% include 'panel_table.html' with table=import_targets_table heading="Import Route Targets" %}
+ {% include 'panel_table.html' with table=export_targets_table heading="Export Route Targets" %}
+ {% plugin_right_page object %}
- {% plugin_full_width_page vrf %}
+ {% plugin_full_width_page object %}
{% endblock %}
diff --git a/netbox/templates/ipam/vrf_edit.html b/netbox/templates/ipam/vrf_edit.html
index a2ff51d9b..189b9c129 100644
--- a/netbox/templates/ipam/vrf_edit.html
+++ b/netbox/templates/ipam/vrf_edit.html
@@ -1,4 +1,4 @@
-{% extends 'utilities/obj_edit.html' %}
+{% extends 'generic/object_edit.html' %}
{% load form_helpers %}
{% block form %}
@@ -11,6 +11,13 @@
{% render_field form.description %}
+
+
Route Targets
+
+ {% render_field form.import_targets %}
+ {% render_field form.export_targets %}
+
+
diff --git a/netbox/templates/secrets/inc/assigned_secrets.html b/netbox/templates/secrets/inc/assigned_secrets.html
new file mode 100644
index 000000000..594ab43f3
--- /dev/null
+++ b/netbox/templates/secrets/inc/assigned_secrets.html
@@ -0,0 +1,29 @@
+{% if secrets %}
+
+
+ {% for secret in secrets %}
+
+ {{ secret.role }}
+ {{ secret.name }}
+ ********
+
+
+ Unlock
+
+
+ Copy
+
+
+ Lock
+
+
+
+ {% endfor %}
+
+{% else %}
+
+ None found
+
+{% endif %}
diff --git a/netbox/templates/secrets/inc/private_key_modal.html b/netbox/templates/secrets/inc/private_key_modal.html
index f165df400..5b1d4550b 100644
--- a/netbox/templates/secrets/inc/private_key_modal.html
+++ b/netbox/templates/secrets/inc/private_key_modal.html
@@ -4,7 +4,7 @@
diff --git a/netbox/templates/secrets/inc/secret_tr.html b/netbox/templates/secrets/inc/secret_tr.html
deleted file mode 100644
index 2af609289..000000000
--- a/netbox/templates/secrets/inc/secret_tr.html
+++ /dev/null
@@ -1,16 +0,0 @@
-
- {{ secret.role }}
- {{ secret.name }}
- ********
-
-
- Unlock
-
-
- Copy
-
-
- Lock
-
-
-
diff --git a/netbox/templates/secrets/secret.html b/netbox/templates/secrets/secret.html
index 841d9843a..8921c5c65 100644
--- a/netbox/templates/secrets/secret.html
+++ b/netbox/templates/secrets/secret.html
@@ -10,32 +10,33 @@
- {% plugin_buttons secret %}
+ {% plugin_buttons object %}
{% if perms.secrets.change_secret %}
- {% edit_button secret %}
+ {% edit_button object %}
{% endif %}
{% if perms.secrets.delete_secret %}
- {% delete_button secret %}
+ {% delete_button object %}
{% endif %}
- {% block title %}{{ secret }}{% endblock %}
- {% include 'inc/created_updated.html' with obj=secret %}
+ {% block title %}{{ object }}{% endblock %}
+ {% include 'inc/created_updated.html' %}
- {% custom_links secret %}
+ {% custom_links object %}
@@ -50,23 +51,23 @@
- Device
+ Assigned object
- {{ secret.device }}
+ {{ object.assigned_object }}
Role
- {{ secret.role }}
+ {{ object.role }}
Name
- {{ secret.name|placeholder }}
+ {{ object.name|placeholder }}
- {% include 'inc/custom_fields_panel.html' with obj=secret %}
- {% plugin_left_page secret %}
+ {% include 'inc/custom_fields_panel.html' %}
+ {% plugin_left_page object %}
@@ -79,28 +80,28 @@
Secret
-
********
+
********
-
- Unlock
+
+ Unlock
-
- Copy
+
+ Copy
-
- Lock
+
+ Lock
- {% include 'extras/inc/tags_panel.html' with tags=secret.tags.all url='secrets:secret_list' %}
- {% plugin_right_page secret %}
+ {% include 'extras/inc/tags_panel.html' with tags=object.tags.all url='secrets:secret_list' %}
+ {% plugin_right_page object %}
- {% plugin_full_width_page secret %}
+ {% plugin_full_width_page object %}
diff --git a/netbox/templates/secrets/secret_edit.html b/netbox/templates/secrets/secret_edit.html
index 0cb1eefef..4e2d78042 100644
--- a/netbox/templates/secrets/secret_edit.html
+++ b/netbox/templates/secrets/secret_edit.html
@@ -18,9 +18,24 @@
{% endif %}
-
Secret Attributes
+
+ Secret Assignment
+
- {% render_field form.device %}
+ {% with vm_tab_active=form.initial.virtual_machine %}
+
+
+
+ {% render_field form.device %}
+
+
+ {% render_field form.virtual_machine %}
+
+
+ {% endwith %}
{% render_field form.role %}
{% render_field form.name %}
{% render_field form.userkeys %}
@@ -37,10 +52,10 @@
- Unlock
+ Unlock
- Lock
+ Lock
diff --git a/netbox/templates/secrets/secret_import.html b/netbox/templates/secrets/secret_import.html
index bf2f06ae9..0e540eb94 100644
--- a/netbox/templates/secrets/secret_import.html
+++ b/netbox/templates/secrets/secret_import.html
@@ -1,4 +1,4 @@
-{% extends 'utilities/obj_bulk_import.html' %}
+{% extends 'generic/object_bulk_import.html' %}
{% load static %}
{% block content %}
diff --git a/netbox/templates/tenancy/tenant.html b/netbox/templates/tenancy/tenant.html
index a9cf67398..e2e66e6b2 100644
--- a/netbox/templates/tenancy/tenant.html
+++ b/netbox/templates/tenancy/tenant.html
@@ -9,10 +9,10 @@
@@ -21,7 +21,7 @@
-
+
@@ -29,29 +29,29 @@
- {% plugin_buttons tenant %}
+ {% plugin_buttons object %}
{% if perms.tenancy.add_tenant %}
- {% clone_button tenant %}
+ {% clone_button object %}
{% endif %}
{% if perms.tenancy.change_tenant %}
- {% edit_button tenant %}
+ {% edit_button object %}
{% endif %}
{% if perms.tenancy.delete_tenant %}
- {% delete_button tenant %}
+ {% delete_button object %}
{% endif %}
- {% block title %}{{ tenant }}{% endblock %}
- {% include 'inc/created_updated.html' with obj=tenant %}
+ {% block title %}{{ object }}{% endblock %}
+ {% include 'inc/created_updated.html' %}
- {% custom_links tenant %}
+ {% custom_links object %}
@@ -68,8 +68,8 @@
Group
- {% if tenant.group %}
- {{ tenant.group }}
+ {% if object.group %}
+ {{ object.group }}
{% else %}
None
{% endif %}
@@ -77,25 +77,25 @@
Description
- {{ tenant.description|placeholder }}
+ {{ object.description|placeholder }}
- {% include 'inc/custom_fields_panel.html' with obj=tenant %}
- {% include 'extras/inc/tags_panel.html' with tags=tenant.tags.all url='tenancy:tenant_list' %}
+ {% include 'inc/custom_fields_panel.html' %}
+ {% include 'extras/inc/tags_panel.html' with tags=object.tags.all url='tenancy:tenant_list' %}
Comments
- {% if tenant.comments %}
- {{ tenant.comments|render_markdown }}
+ {% if object.comments %}
+ {{ object.comments|render_markdown }}
{% else %}
None
{% endif %}
- {% plugin_left_page tenant %}
+ {% plugin_left_page object %}
- {% plugin_right_page tenant %}
+ {% plugin_right_page object %}
- {% plugin_full_width_page tenant %}
+ {% plugin_full_width_page object %}
{% endblock %}
diff --git a/netbox/templates/tenancy/tenant_edit.html b/netbox/templates/tenancy/tenant_edit.html
index 6f58bb450..8a1d1d1b6 100644
--- a/netbox/templates/tenancy/tenant_edit.html
+++ b/netbox/templates/tenancy/tenant_edit.html
@@ -1,4 +1,4 @@
-{% extends 'utilities/obj_edit.html' %}
+{% extends 'generic/object_edit.html' %}
{% load static %}
{% load form_helpers %}
diff --git a/netbox/templates/users/api_tokens.html b/netbox/templates/users/api_tokens.html
index 690a966b0..04e7cb23d 100644
--- a/netbox/templates/users/api_tokens.html
+++ b/netbox/templates/users/api_tokens.html
@@ -18,7 +18,7 @@
Delete
{% endif %}
-
+
{{ token.key }}
{% if token.is_expired %}
Expired
@@ -57,7 +57,7 @@
{% endfor %}
{% if perms.users.add_token %}
-
+
Add a token
{% else %}
diff --git a/netbox/templates/users/preferences.html b/netbox/templates/users/preferences.html
index 8b3c4bcc4..1c20e8aaf 100644
--- a/netbox/templates/users/preferences.html
+++ b/netbox/templates/users/preferences.html
@@ -26,7 +26,7 @@
- Clear Selected
+ Clear Selected
{% else %}
diff --git a/netbox/templates/users/sessionkey_delete.html b/netbox/templates/users/sessionkey_delete.html
index c91956b79..e3e8a7efc 100644
--- a/netbox/templates/users/sessionkey_delete.html
+++ b/netbox/templates/users/sessionkey_delete.html
@@ -1,4 +1,4 @@
-{% extends 'utilities/obj_delete.html' %}
+{% extends 'generic/object_delete.html' %}
{% block message %}
Are you sure you want to delete your session key?
diff --git a/netbox/templates/users/userkey.html b/netbox/templates/users/userkey.html
index 2861d187e..09b5bde5b 100644
--- a/netbox/templates/users/userkey.html
+++ b/netbox/templates/users/userkey.html
@@ -3,39 +3,39 @@
{% block title %}User Key{% endblock %}
{% block usercontent %}
- {% if userkey %}
+ {% if object %}
Your user key is:
- {% if userkey.is_active %}
+ {% if object.is_active %}
Active
{% else %}
Inactive
{% endif %}
- {% include 'inc/created_updated.html' with obj=userkey %}
- {% if not userkey.is_active %}
+ {% include 'inc/created_updated.html' %}
+ {% if not object.is_active %}
-
+
Your user key is inactive. Ask an administrator to enable it for you.
{% endif %}
- {{ userkey.public_key }}
+ {{ object.public_key }}
- {% if userkey.session_key %}
+ {% if object.session_key %}
Session key: Active
- Created {{ userkey.session_key.created }}
+ Created {{ object.session_key.created }}
{% else %}
No active session key
{% endif %}
@@ -43,7 +43,7 @@
You don't have a user key on file.
-
+
Create a User Key
diff --git a/netbox/templates/users/userkey_edit.html b/netbox/templates/users/userkey_edit.html
index 0715f9038..a7aaa720c 100644
--- a/netbox/templates/users/userkey_edit.html
+++ b/netbox/templates/users/userkey_edit.html
@@ -5,7 +5,7 @@
{% block title %}User Key{% endblock %}
{% block usercontent %}
- {% if userkey.is_active %}
+ {% if object.is_active %}
Warning: Changing your public key will require your user key to be re-activated by another
user. You will be unable to retrieve any secrets until your key has been reactivated.
diff --git a/netbox/templates/utilities/obj_table.html b/netbox/templates/utilities/obj_table.html
index 1307ccb69..b1ea58668 100644
--- a/netbox/templates/utilities/obj_table.html
+++ b/netbox/templates/utilities/obj_table.html
@@ -15,12 +15,12 @@
{% if bulk_edit_url and permissions.change %}
- Edit All
+ Edit All
{% endif %}
{% if bulk_delete_url and permissions.delete %}
- Delete All
+ Delete All
{% endif %}
@@ -32,12 +32,12 @@
{% block extra_actions %}{% endblock %}
{% if bulk_edit_url and permissions.change %}
- Edit Selected
+ Edit Selected
{% endif %}
{% if bulk_delete_url and permissions.delete %}
- Delete Selected
+ Delete Selected
{% endif %}
diff --git a/netbox/templates/inc/table_config_form.html b/netbox/templates/utilities/templatetags/table_config_form.html
similarity index 66%
rename from netbox/templates/inc/table_config_form.html
rename to netbox/templates/utilities/templatetags/table_config_form.html
index 66844c7ca..c92adaee1 100644
--- a/netbox/templates/inc/table_config_form.html
+++ b/netbox/templates/utilities/templatetags/table_config_form.html
@@ -1,5 +1,5 @@
{% load form_helpers %}
-
+
diff --git a/netbox/templates/virtualization/cluster.html b/netbox/templates/virtualization/cluster.html
index 0ff5e78f4..2f1a50883 100644
--- a/netbox/templates/virtualization/cluster.html
+++ b/netbox/templates/virtualization/cluster.html
@@ -8,11 +8,11 @@
- {% plugin_buttons cluster %}
+ {% plugin_buttons object %}
{% if perms.virtualization.add_cluster %}
- {% clone_button cluster %}
+ {% clone_button object %}
{% endif %}
{% if perms.virtualization.change_cluster %}
- {% edit_button cluster %}
+ {% edit_button object %}
{% endif %}
{% if perms.virtualization.delete_cluster %}
- {% delete_button cluster %}
+ {% delete_button object %}
{% endif %}
-
{% block title %}{{ cluster }}{% endblock %}
- {% include 'inc/created_updated.html' with obj=cluster %}
+
{% block title %}{{ object }}{% endblock %}
+ {% include 'inc/created_updated.html' %}
- {% custom_links cluster %}
+ {% custom_links object %}
@@ -67,17 +67,17 @@
Name
- {{ cluster.name }}
+ {{ object.name }}
Type
- {{ cluster.type }}
+ {{ object.type }}
Group
- {% if cluster.group %}
- {{ cluster.group }}
+ {% if object.group %}
+ {{ object.group }}
{% else %}
None
{% endif %}
@@ -86,8 +86,8 @@
Tenant
- {% if cluster.tenant %}
- {{ cluster.tenant }}
+ {% if object.tenant %}
+ {{ object.tenant }}
{% else %}
None
{% endif %}
@@ -96,8 +96,8 @@
Site
- {% if cluster.site %}
- {{ cluster.site }}
+ {% if object.site %}
+ {{ object.site }}
{% else %}
None
{% endif %}
@@ -105,25 +105,25 @@
Virtual Machines
- {{ cluster.virtual_machines.count }}
+ {{ object.virtual_machines.count }}
- {% include 'inc/custom_fields_panel.html' with obj=cluster %}
- {% include 'extras/inc/tags_panel.html' with tags=cluster.tags.all url='virtualization:cluster_list' %}
+ {% include 'inc/custom_fields_panel.html' %}
+ {% include 'extras/inc/tags_panel.html' with tags=object.tags.all url='virtualization:cluster_list' %}
Comments
- {% if cluster.comments %}
- {{ cluster.comments|render_markdown }}
+ {% if object.comments %}
+ {{ object.comments|render_markdown }}
{% else %}
None
{% endif %}
- {% plugin_left_page cluster %}
+ {% plugin_left_page object %}
@@ -131,32 +131,32 @@
Host Devices
{% if perms.virtualization.change_cluster %}
-
{% endif %}
- {% plugin_right_page cluster %}
+ {% plugin_right_page object %}
- {% plugin_full_width_page cluster %}
+ {% plugin_full_width_page object %}
{% endblock %}
diff --git a/netbox/templates/virtualization/cluster_edit.html b/netbox/templates/virtualization/cluster_edit.html
index 5ad7c53e1..f43fc717f 100644
--- a/netbox/templates/virtualization/cluster_edit.html
+++ b/netbox/templates/virtualization/cluster_edit.html
@@ -1,4 +1,4 @@
-{% extends 'utilities/obj_edit.html' %}
+{% extends 'generic/object_edit.html' %}
{% load form_helpers %}
{% block form %}
diff --git a/netbox/templates/virtualization/inc/vminterface.html b/netbox/templates/virtualization/inc/vminterface.html
deleted file mode 100644
index 9ee1fa396..000000000
--- a/netbox/templates/virtualization/inc/vminterface.html
+++ /dev/null
@@ -1,141 +0,0 @@
-{% load helpers %}
-
-
- {# Checkbox #}
- {% if perms.virtualization.change_vminterface or perms.virtualization.delete_vminterface %}
-
-
-
- {% endif %}
-
- {# Name #}
-
- {{ iface }}
-
-
- {# MAC address #}
-
- {{ iface.mac_address|default:"—" }}
-
-
- {# MTU #}
- {{ iface.mtu|default:"—" }}
-
- {# 802.1Q mode #}
- {{ iface.get_mode_display|default:"—" }}
-
- {# Description/tags #}
-
- {% if iface.description %}
- {{ iface.description }}
- {% endif %}
- {% for tag in iface.tags.all %}
- {% tag tag %}
- {% empty %}
- {% if not iface.description %}—{% endif %}
- {% endfor %}
-
-
- {# Buttons #}
-
- {% if show_interface_graphs %}
-
-
-
- {% endif %}
- {% if perms.ipam.add_ipaddress %}
-
-
-
- {% endif %}
- {% if perms.virtualization.change_vminterface %}
-
-
-
- {% endif %}
- {% if perms.virtualization.delete_vminterface %}
-
-
-
- {% endif %}
-
-
-
-{% with ipaddresses=iface.ip_addresses.all %}
- {% if ipaddresses %}
-
- {# Placeholder #}
- {% if perms.virtualization.change_vminterface or perms.virtualization.delete_vminterface %}
-
- {% endif %}
-
- {# IP addresses table #}
-
-
-
-
- IP Address
- Status/Role
- VRF
- Description
-
-
-
- {% for ip in iface.ip_addresses.all %}
-
-
- {# IP address #}
-
- {{ ip }}
-
-
- {# Primary/status/role #}
-
- {% if virtualmachine.primary_ip4 == ip or virtualmachine.primary_ip6 == ip %}
- Primary
- {% endif %}
- {{ ip.get_status_display }}
- {% if ip.role %}
- {{ ip.get_role_display }}
- {% endif %}
-
-
- {# VRF #}
-
- {% if ip.vrf %}
- {{ ip.vrf.name }}
- {% else %}
- Global
- {% endif %}
-
-
- {# Description #}
-
- {% if ip.description %}
- {{ ip.description }}
- {% else %}
- —
- {% endif %}
-
-
- {# Buttons #}
-
- {% if perms.ipam.change_ipaddress %}
-
-
-
- {% endif %}
- {% if perms.ipam.delete_ipaddress %}
-
-
-
- {% endif %}
-
-
-
- {% endfor %}
-
-
-
- {% endif %}
-{% endwith %}
diff --git a/netbox/templates/virtualization/virtualmachine.html b/netbox/templates/virtualization/virtualmachine.html
index ea33aa460..8baec6956 100644
--- a/netbox/templates/virtualization/virtualmachine.html
+++ b/netbox/templates/virtualization/virtualmachine.html
@@ -9,10 +9,10 @@
{% if perms.virtualization.add_vminterface %}
-
- Add Interfaces
+
+ Add Interfaces
{% endif %}
- {% plugin_buttons virtualmachine %}
+ {% plugin_buttons object %}
{% if perms.virtualization.add_virtualmachine %}
- {% clone_button virtualmachine %}
+ {% clone_button object %}
{% endif %}
{% if perms.virtualization.change_virtualmachine %}
- {% edit_button virtualmachine %}
+ {% edit_button object %}
{% endif %}
{% if perms.virtualization.delete_virtualmachine %}
- {% delete_button virtualmachine %}
+ {% delete_button object %}
{% endif %}
- {% block title %}{{ virtualmachine }}{% endblock %}
- {% include 'inc/created_updated.html' with obj=virtualmachine %}
+ {% block title %}{{ object }}{% endblock %}
+ {% include 'inc/created_updated.html' %}
- {% custom_links virtualmachine %}
+ {% custom_links object %}
@@ -77,19 +77,19 @@
Name
- {{ virtualmachine }}
+ {{ object }}
Status
- {{ virtualmachine.get_status_display }}
+ {{ object.get_status_display }}
Role
- {% if virtualmachine.role %}
- {{ virtualmachine.role }}
+ {% if object.role %}
+ {{ object.role }}
{% else %}
None
{% endif %}
@@ -98,8 +98,8 @@
Platform
- {% if virtualmachine.platform %}
- {{ virtualmachine.platform }}
+ {% if object.platform %}
+ {{ object.platform }}
{% else %}
None
{% endif %}
@@ -108,12 +108,11 @@
Tenant
- {% if virtualmachine.tenant %}
- {% if virtualmachine.tenant.group %}
- {{ virtualmachine.tenant.group }}
-
+ {% if object.tenant %}
+ {% if object.tenant.group %}
+ {{ object.tenant.group }} /
{% endif %}
- {{ virtualmachine.tenant }}
+ {{ object.tenant }}
{% else %}
None
{% endif %}
@@ -122,12 +121,12 @@
Primary IPv4
- {% if virtualmachine.primary_ip4 %}
- {{ virtualmachine.primary_ip4.address.ip }}
- {% if virtualmachine.primary_ip4.nat_inside %}
- (NAT for {{ virtualmachine.primary_ip4.nat_inside.address.ip }})
- {% elif virtualmachine.primary_ip4.nat_outside %}
- (NAT: {{ virtualmachine.primary_ip4.nat_outside.address.ip }})
+ {% if object.primary_ip4 %}
+ {{ object.primary_ip4.address.ip }}
+ {% if object.primary_ip4.nat_inside %}
+ (NAT for {{ object.primary_ip4.nat_inside.address.ip }})
+ {% elif object.primary_ip4.nat_outside %}
+ (NAT: {{ object.primary_ip4.nat_outside.address.ip }})
{% endif %}
{% else %}
—
@@ -137,12 +136,12 @@
Primary IPv6
- {% if virtualmachine.primary_ip6 %}
- {{ virtualmachine.primary_ip6.address.ip }}
- {% if virtualmachine.primary_ip6.nat_inside %}
- (NAT for {{ virtualmachine.primary_ip6.nat_inside.address.ip }})
- {% elif virtualmachine.primary_ip6.nat_outside %}
- (NAT: {{ virtualmachine.primary_ip6.nat_outside.address.ip }})
+ {% if object.primary_ip6 %}
+ {{ object.primary_ip6.address.ip }}
+ {% if object.primary_ip6.nat_inside %}
+ (NAT for {{ object.primary_ip6.nat_inside.address.ip }})
+ {% elif object.primary_ip6.nat_outside %}
+ (NAT: {{ object.primary_ip6.nat_outside.address.ip }})
{% endif %}
{% else %}
—
@@ -151,21 +150,21 @@
- {% include 'inc/custom_fields_panel.html' with obj=virtualmachine %}
- {% include 'extras/inc/tags_panel.html' with tags=virtualmachine.tags.all url='virtualization:virtualmachine_list' %}
+ {% include 'inc/custom_fields_panel.html' %}
+ {% include 'extras/inc/tags_panel.html' with tags=object.tags.all url='virtualization:virtualmachine_list' %}
Comments
- {% if virtualmachine.comments %}
- {{ virtualmachine.comments|render_markdown }}
+ {% if object.comments %}
+ {{ object.comments|render_markdown }}
{% else %}
None
{% endif %}
- {% plugin_left_page virtualmachine %}
+ {% plugin_left_page object %}
- Virtual CPUs
- {{ virtualmachine.vcpus|placeholder }}
+ Virtual CPUs
+ {{ object.vcpus|placeholder }}
- Memory
+ Memory
- {% if virtualmachine.memory %}
- {{ virtualmachine.memory }} MB
+ {% if object.memory %}
+ {{ object.memory }} MB
{% else %}
—
{% endif %}
- Disk Space
+ Disk Space
- {% if virtualmachine.disk %}
- {{ virtualmachine.disk }} GB
+ {% if object.disk %}
+ {{ object.disk }} GB
{% else %}
—
{% endif %}
@@ -220,6 +218,21 @@
+ {% if perms.secrets.view_secret %}
+
+
+ Secrets
+
+ {% include 'secrets/inc/assigned_secrets.html' %}
+ {% if perms.secrets.add_secret %}
+
+ {% endif %}
+
+ {% endif %}
Services
@@ -237,96 +250,73 @@
{% endif %}
{% if perms.ipam.add_service %}
{% endif %}
- {% plugin_right_page virtualmachine %}
+ {% plugin_right_page object %}
- {% plugin_full_width_page virtualmachine %}
+ {% plugin_full_width_page object %}
- {% if perms.virtualization.change_vminterface or perms.virtualization.delete_vminterface %}
-
- {% endif %}
+
+
+
+
+
+ {% include 'responsive_table.html' with table=vminterface_table %}
+ {% if perms.virtualization.add_vminterface or perms.virtualization.delete_vminterface %}
+
+ {% endif %}
+
+
+ {% table_config_form vminterface_table %}
+{% include 'secrets/inc/private_key_modal.html' %}
{% endblock %}
{% block javascript %}
-
+
+
+
{% endblock %}
diff --git a/netbox/templates/virtualization/virtualmachine_edit.html b/netbox/templates/virtualization/virtualmachine_edit.html
index 3be462c4d..6bffabadd 100644
--- a/netbox/templates/virtualization/virtualmachine_edit.html
+++ b/netbox/templates/virtualization/virtualmachine_edit.html
@@ -1,4 +1,4 @@
-{% extends 'utilities/obj_edit.html' %}
+{% extends 'generic/object_edit.html' %}
{% load form_helpers %}
{% block form %}
diff --git a/netbox/templates/virtualization/virtualmachine_list.html b/netbox/templates/virtualization/virtualmachine_list.html
index f8ee77626..9d8c1720f 100644
--- a/netbox/templates/virtualization/virtualmachine_list.html
+++ b/netbox/templates/virtualization/virtualmachine_list.html
@@ -1,10 +1,10 @@
-{% extends 'utilities/obj_list.html' %}
+{% extends 'generic/object_list.html' %}
{% block bulk_buttons %}
{% if perms.virtualization.change_virtualmachine %}
- Add Components
+ Add Components
+ {% plugin_left_page object %}
- {% include 'extras/inc/tags_panel.html' with tags=vminterface.tags.all %}
+ {% include 'extras/inc/tags_panel.html' with tags=object.tags.all %}
+ {% plugin_right_page object %}
@@ -97,4 +101,9 @@
{% include 'panel_table.html' with table=vlan_table heading="VLANs" %}
+
+
+ {% plugin_full_width_page object %}
+
+
{% endblock %}
diff --git a/netbox/templates/virtualization/vminterface_edit.html b/netbox/templates/virtualization/vminterface_edit.html
index 12018ba5d..d1fe6d104 100644
--- a/netbox/templates/virtualization/vminterface_edit.html
+++ b/netbox/templates/virtualization/vminterface_edit.html
@@ -1,4 +1,4 @@
-{% extends 'utilities/obj_edit.html' %}
+{% extends 'generic/object_edit.html' %}
{% load form_helpers %}
{% block form %}
diff --git a/netbox/tenancy/api/nested_serializers.py b/netbox/tenancy/api/nested_serializers.py
index 369d5eb1b..7b227c123 100644
--- a/netbox/tenancy/api/nested_serializers.py
+++ b/netbox/tenancy/api/nested_serializers.py
@@ -1,7 +1,7 @@
from rest_framework import serializers
+from netbox.api import WritableNestedSerializer
from tenancy.models import Tenant, TenantGroup
-from utilities.api import WritableNestedSerializer
__all__ = [
'NestedTenantGroupSerializer',
diff --git a/netbox/tenancy/api/serializers.py b/netbox/tenancy/api/serializers.py
index 4467b050b..05e83853e 100644
--- a/netbox/tenancy/api/serializers.py
+++ b/netbox/tenancy/api/serializers.py
@@ -2,8 +2,8 @@ from rest_framework import serializers
from extras.api.customfields import CustomFieldModelSerializer
from extras.api.serializers import TaggedObjectSerializer
+from netbox.api import ValidatedModelSerializer
from tenancy.models import Tenant, TenantGroup
-from utilities.api import ValidatedModelSerializer
from .nested_serializers import *
diff --git a/netbox/tenancy/api/urls.py b/netbox/tenancy/api/urls.py
index ad4424005..32540879d 100644
--- a/netbox/tenancy/api/urls.py
+++ b/netbox/tenancy/api/urls.py
@@ -1,4 +1,4 @@
-from utilities.api import OrderedDefaultRouter
+from netbox.api import OrderedDefaultRouter
from . import views
diff --git a/netbox/tenancy/api/views.py b/netbox/tenancy/api/views.py
index 065d3a9f3..2b7ae8365 100644
--- a/netbox/tenancy/api/views.py
+++ b/netbox/tenancy/api/views.py
@@ -4,10 +4,10 @@ from circuits.models import Circuit
from dcim.models import Device, Rack, Site
from extras.api.views import CustomFieldModelViewSet
from ipam.models import IPAddress, Prefix, VLAN, VRF
+from netbox.api.views import ModelViewSet
from tenancy import filters
from tenancy.models import Tenant, TenantGroup
-from utilities.api import ModelViewSet
-from utilities.utils import get_subquery
+from utilities.utils import count_related
from virtualization.models import VirtualMachine
from . import serializers
@@ -44,15 +44,15 @@ class TenantViewSet(CustomFieldModelViewSet):
queryset = Tenant.objects.prefetch_related(
'group', 'tags'
).annotate(
- circuit_count=get_subquery(Circuit, 'tenant'),
- device_count=get_subquery(Device, 'tenant'),
- ipaddress_count=get_subquery(IPAddress, 'tenant'),
- prefix_count=get_subquery(Prefix, 'tenant'),
- rack_count=get_subquery(Rack, 'tenant'),
- site_count=get_subquery(Site, 'tenant'),
- virtualmachine_count=get_subquery(VirtualMachine, 'tenant'),
- vlan_count=get_subquery(VLAN, 'tenant'),
- vrf_count=get_subquery(VRF, 'tenant')
+ circuit_count=count_related(Circuit, 'tenant'),
+ device_count=count_related(Device, 'tenant'),
+ ipaddress_count=count_related(IPAddress, 'tenant'),
+ prefix_count=count_related(Prefix, 'tenant'),
+ rack_count=count_related(Rack, 'tenant'),
+ site_count=count_related(Site, 'tenant'),
+ virtualmachine_count=count_related(VirtualMachine, 'tenant'),
+ vlan_count=count_related(VLAN, 'tenant'),
+ vrf_count=count_related(VRF, 'tenant')
)
serializer_class = serializers.TenantSerializer
filterset_class = filters.TenantFilterSet
diff --git a/netbox/tenancy/filters.py b/netbox/tenancy/filters.py
index af5ee0b2c..d61081de4 100644
--- a/netbox/tenancy/filters.py
+++ b/netbox/tenancy/filters.py
@@ -1,7 +1,7 @@
import django_filters
from django.db.models import Q
-from extras.filters import CustomFieldFilterSet, CreatedUpdatedFilterSet
+from extras.filters import CustomFieldModelFilterSet, CreatedUpdatedFilterSet
from utilities.filters import BaseFilterSet, NameSlugSearchFilterSet, TagFilter, TreeNodeMultipleChoiceFilter
from .models import Tenant, TenantGroup
@@ -30,7 +30,7 @@ class TenantGroupFilterSet(BaseFilterSet, NameSlugSearchFilterSet):
fields = ['id', 'name', 'slug', 'description']
-class TenantFilterSet(BaseFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet):
+class TenantFilterSet(BaseFilterSet, CustomFieldModelFilterSet, CreatedUpdatedFilterSet):
q = django_filters.CharFilter(
method='search',
label='Search',
diff --git a/netbox/tenancy/migrations/0010_custom_field_data.py b/netbox/tenancy/migrations/0010_custom_field_data.py
new file mode 100644
index 000000000..ec05be0ff
--- /dev/null
+++ b/netbox/tenancy/migrations/0010_custom_field_data.py
@@ -0,0 +1,17 @@
+import django.core.serializers.json
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('tenancy', '0009_standardize_description'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='tenant',
+ name='custom_field_data',
+ field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder),
+ ),
+ ]
diff --git a/netbox/tenancy/migrations/0011_standardize_name_length.py b/netbox/tenancy/migrations/0011_standardize_name_length.py
new file mode 100644
index 000000000..1e29a0f5e
--- /dev/null
+++ b/netbox/tenancy/migrations/0011_standardize_name_length.py
@@ -0,0 +1,33 @@
+# Generated by Django 3.1 on 2020-10-15 19:33
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('tenancy', '0010_custom_field_data'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='tenant',
+ name='name',
+ field=models.CharField(max_length=100, unique=True),
+ ),
+ migrations.AlterField(
+ model_name='tenant',
+ name='slug',
+ field=models.SlugField(max_length=100, unique=True),
+ ),
+ migrations.AlterField(
+ model_name='tenantgroup',
+ name='name',
+ field=models.CharField(max_length=100, unique=True),
+ ),
+ migrations.AlterField(
+ model_name='tenantgroup',
+ name='slug',
+ field=models.SlugField(max_length=100, unique=True),
+ ),
+ ]
diff --git a/netbox/tenancy/models.py b/netbox/tenancy/models.py
index cc3abf19a..3ba644c09 100644
--- a/netbox/tenancy/models.py
+++ b/netbox/tenancy/models.py
@@ -1,4 +1,3 @@
-from django.contrib.contenttypes.fields import GenericRelation
from django.db import models
from django.urls import reverse
from mptt.models import MPTTModel, TreeForeignKey
@@ -22,10 +21,11 @@ class TenantGroup(MPTTModel, ChangeLoggedModel):
An arbitrary collection of Tenants.
"""
name = models.CharField(
- max_length=50,
+ max_length=100,
unique=True
)
slug = models.SlugField(
+ max_length=100,
unique=True
)
parent = TreeForeignKey(
@@ -82,10 +82,11 @@ class Tenant(ChangeLoggedModel, CustomFieldModel):
department.
"""
name = models.CharField(
- max_length=30,
+ max_length=100,
unique=True
)
slug = models.SlugField(
+ max_length=100,
unique=True
)
group = models.ForeignKey(
@@ -102,11 +103,6 @@ class Tenant(ChangeLoggedModel, CustomFieldModel):
comments = models.TextField(
blank=True
)
- custom_field_values = GenericRelation(
- to='extras.CustomFieldValue',
- content_type_field='obj_type',
- object_id_field='obj_id'
- )
tags = TaggableManager(through=TaggedItem)
objects = RestrictedQuerySet.as_manager()
diff --git a/netbox/tenancy/tables.py b/netbox/tenancy/tables.py
index dc96b839c..7a3d20e12 100644
--- a/netbox/tenancy/tables.py
+++ b/netbox/tenancy/tables.py
@@ -1,16 +1,13 @@
import django_tables2 as tables
-from utilities.tables import BaseTable, ButtonsColumn, TagColumn, ToggleColumn
+from utilities.tables import BaseTable, ButtonsColumn, LinkedCountColumn, TagColumn, ToggleColumn
from .models import Tenant, TenantGroup
MPTT_LINK = """
-{% if record.get_children %}
-
-{% else %}
-
-{% endif %}
- {{ record.name }}
-
+{% for i in record.get_ancestors %}
+
+{% endfor %}
+{{ record.name }}
"""
COL_TENANT = """
@@ -32,7 +29,9 @@ class TenantGroupTable(BaseTable):
template_code=MPTT_LINK,
orderable=False
)
- tenant_count = tables.Column(
+ tenant_count = LinkedCountColumn(
+ viewname='tenancy:tenant_list',
+ url_params={'group': 'slug'},
verbose_name='Tenants'
)
actions = ButtonsColumn(TenantGroup, pk_field='slug')
diff --git a/netbox/tenancy/tests/test_api.py b/netbox/tenancy/tests/test_api.py
index f04b2a7ce..7af3c8d79 100644
--- a/netbox/tenancy/tests/test_api.py
+++ b/netbox/tenancy/tests/test_api.py
@@ -17,6 +17,9 @@ class AppTest(APITestCase):
class TenantGroupTest(APIViewTestCases.APIViewTestCase):
model = TenantGroup
brief_fields = ['_depth', 'id', 'name', 'slug', 'tenant_count', 'url']
+ bulk_update_data = {
+ 'description': 'New description',
+ }
@classmethod
def setUpTestData(cls):
@@ -52,6 +55,9 @@ class TenantGroupTest(APIViewTestCases.APIViewTestCase):
class TenantTest(APIViewTestCases.APIViewTestCase):
model = Tenant
brief_fields = ['id', 'name', 'slug', 'url']
+ bulk_update_data = {
+ 'description': 'New description',
+ }
@classmethod
def setUpTestData(cls):
diff --git a/netbox/tenancy/views.py b/netbox/tenancy/views.py
index b129177eb..9fd77d88e 100644
--- a/netbox/tenancy/views.py
+++ b/netbox/tenancy/views.py
@@ -1,12 +1,9 @@
-from django.db.models import Count
from django.shortcuts import get_object_or_404, render
from circuits.models import Circuit
from dcim.models import Site, Rack, Device, RackReservation
from ipam.models import IPAddress, Prefix, VLAN, VRF
-from utilities.views import (
- BulkDeleteView, BulkEditView, BulkImportView, ObjectView, ObjectDeleteView, ObjectEditView, ObjectListView,
-)
+from netbox.views import generic
from virtualization.models import VirtualMachine, Cluster
from . import filters, forms, tables
from .models import Tenant, TenantGroup
@@ -16,7 +13,7 @@ from .models import Tenant, TenantGroup
# Tenant groups
#
-class TenantGroupListView(ObjectListView):
+class TenantGroupListView(generic.ObjectListView):
queryset = TenantGroup.objects.add_related_count(
TenantGroup.objects.all(),
Tenant,
@@ -27,22 +24,22 @@ class TenantGroupListView(ObjectListView):
table = tables.TenantGroupTable
-class TenantGroupEditView(ObjectEditView):
+class TenantGroupEditView(generic.ObjectEditView):
queryset = TenantGroup.objects.all()
model_form = forms.TenantGroupForm
-class TenantGroupDeleteView(ObjectDeleteView):
+class TenantGroupDeleteView(generic.ObjectDeleteView):
queryset = TenantGroup.objects.all()
-class TenantGroupBulkImportView(BulkImportView):
+class TenantGroupBulkImportView(generic.BulkImportView):
queryset = TenantGroup.objects.all()
model_form = forms.TenantGroupCSVForm
table = tables.TenantGroupTable
-class TenantGroupBulkDeleteView(BulkDeleteView):
+class TenantGroupBulkDeleteView(generic.BulkDeleteView):
queryset = TenantGroup.objects.add_related_count(
TenantGroup.objects.all(),
Tenant,
@@ -57,63 +54,60 @@ class TenantGroupBulkDeleteView(BulkDeleteView):
# Tenants
#
-class TenantListView(ObjectListView):
- queryset = Tenant.objects.prefetch_related('group')
+class TenantListView(generic.ObjectListView):
+ queryset = Tenant.objects.all()
filterset = filters.TenantFilterSet
filterset_form = forms.TenantFilterForm
table = tables.TenantTable
-class TenantView(ObjectView):
+class TenantView(generic.ObjectView):
queryset = Tenant.objects.prefetch_related('group')
- def get(self, request, slug):
-
- tenant = get_object_or_404(self.queryset, slug=slug)
+ def get_extra_context(self, request, instance):
stats = {
- 'site_count': Site.objects.restrict(request.user, 'view').filter(tenant=tenant).count(),
- 'rack_count': Rack.objects.restrict(request.user, 'view').filter(tenant=tenant).count(),
- 'rackreservation_count': RackReservation.objects.restrict(request.user, 'view').filter(tenant=tenant).count(),
- 'device_count': Device.objects.restrict(request.user, 'view').filter(tenant=tenant).count(),
- 'vrf_count': VRF.objects.restrict(request.user, 'view').filter(tenant=tenant).count(),
- 'prefix_count': Prefix.objects.restrict(request.user, 'view').filter(tenant=tenant).count(),
- 'ipaddress_count': IPAddress.objects.restrict(request.user, 'view').filter(tenant=tenant).count(),
- 'vlan_count': VLAN.objects.restrict(request.user, 'view').filter(tenant=tenant).count(),
- 'circuit_count': Circuit.objects.restrict(request.user, 'view').filter(tenant=tenant).count(),
- 'virtualmachine_count': VirtualMachine.objects.restrict(request.user, 'view').filter(tenant=tenant).count(),
- 'cluster_count': Cluster.objects.restrict(request.user, 'view').filter(tenant=tenant).count(),
+ 'site_count': Site.objects.restrict(request.user, 'view').filter(tenant=instance).count(),
+ 'rack_count': Rack.objects.restrict(request.user, 'view').filter(tenant=instance).count(),
+ 'rackreservation_count': RackReservation.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(),
+ '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(),
+ 'virtualmachine_count': VirtualMachine.objects.restrict(request.user, 'view').filter(tenant=instance).count(),
+ 'cluster_count': Cluster.objects.restrict(request.user, 'view').filter(tenant=instance).count(),
}
- return render(request, 'tenancy/tenant.html', {
- 'tenant': tenant,
+ return {
'stats': stats,
- })
+ }
-class TenantEditView(ObjectEditView):
+class TenantEditView(generic.ObjectEditView):
queryset = Tenant.objects.all()
model_form = forms.TenantForm
template_name = 'tenancy/tenant_edit.html'
-class TenantDeleteView(ObjectDeleteView):
+class TenantDeleteView(generic.ObjectDeleteView):
queryset = Tenant.objects.all()
-class TenantBulkImportView(BulkImportView):
+class TenantBulkImportView(generic.BulkImportView):
queryset = Tenant.objects.all()
model_form = forms.TenantCSVForm
table = tables.TenantTable
-class TenantBulkEditView(BulkEditView):
+class TenantBulkEditView(generic.BulkEditView):
queryset = Tenant.objects.prefetch_related('group')
filterset = filters.TenantFilterSet
table = tables.TenantTable
form = forms.TenantBulkEditForm
-class TenantBulkDeleteView(BulkDeleteView):
+class TenantBulkDeleteView(generic.BulkDeleteView):
queryset = Tenant.objects.prefetch_related('group')
filterset = filters.TenantFilterSet
table = tables.TenantTable
diff --git a/netbox/users/api/nested_serializers.py b/netbox/users/api/nested_serializers.py
index f1bcf3b37..3b43ca7c9 100644
--- a/netbox/users/api/nested_serializers.py
+++ b/netbox/users/api/nested_serializers.py
@@ -2,8 +2,8 @@ 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, WritableNestedSerializer
from users.models import ObjectPermission
-from utilities.api import ContentTypeField, WritableNestedSerializer
__all__ = [
'NestedGroupSerializer',
diff --git a/netbox/users/api/serializers.py b/netbox/users/api/serializers.py
index 1f338d6e4..eed0bd80e 100644
--- a/netbox/users/api/serializers.py
+++ b/netbox/users/api/serializers.py
@@ -2,8 +2,8 @@ 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 users.models import ObjectPermission
-from utilities.api import ContentTypeField, SerializedPKRelatedField, ValidatedModelSerializer
from .nested_serializers import *
@@ -19,9 +19,23 @@ class UserSerializer(ValidatedModelSerializer):
class Meta:
model = User
fields = (
- 'id', 'url', 'username', 'first_name', 'last_name', 'email', 'is_staff', 'is_active', 'date_joined',
- 'groups',
+ 'id', 'url', 'username', 'password', 'first_name', 'last_name', 'email', 'is_staff', 'is_active',
+ 'date_joined', 'groups',
)
+ extra_kwargs = {
+ 'password': {'write_only': True}
+ }
+
+ def create(self, validated_data):
+ """
+ Extract the password from validated data and set it separately to ensure proper hash generation.
+ """
+ password = validated_data.pop('password')
+ user = super().create(validated_data)
+ user.set_password(password)
+ user.save()
+
+ return user
class GroupSerializer(ValidatedModelSerializer):
diff --git a/netbox/users/api/urls.py b/netbox/users/api/urls.py
index c52c6c87f..df2e8c25a 100644
--- a/netbox/users/api/urls.py
+++ b/netbox/users/api/urls.py
@@ -1,4 +1,4 @@
-from utilities.api import OrderedDefaultRouter
+from netbox.api import OrderedDefaultRouter
from . import views
@@ -12,5 +12,8 @@ router.register('groups', views.GroupViewSet)
# Permissions
router.register('permissions', views.ObjectPermissionViewSet)
+# User preferences
+router.register('config', views.UserConfigViewSet, basename='userconfig')
+
app_name = 'users-api'
urlpatterns = router.urls
diff --git a/netbox/users/api/views.py b/netbox/users/api/views.py
index a3536e960..7773e54f4 100644
--- a/netbox/users/api/views.py
+++ b/netbox/users/api/views.py
@@ -1,11 +1,15 @@
from django.contrib.auth.models import Group, User
from django.db.models import Count
+from rest_framework.permissions import IsAuthenticated
+from rest_framework.response import Response
from rest_framework.routers import APIRootView
+from rest_framework.viewsets import ViewSet
+from netbox.api.views import ModelViewSet
from users import filters
-from users.models import ObjectPermission
-from utilities.api import ModelViewSet
+from users.models import ObjectPermission, UserConfig
from utilities.querysets import RestrictedQuerySet
+from utilities.utils import deepmerge
from . import serializers
@@ -41,3 +45,36 @@ class ObjectPermissionViewSet(ModelViewSet):
queryset = ObjectPermission.objects.prefetch_related('object_types', 'groups', 'users')
serializer_class = serializers.ObjectPermissionSerializer
filterset_class = filters.ObjectPermissionFilterSet
+
+
+#
+# User preferences
+#
+
+class UserConfigViewSet(ViewSet):
+ """
+ An API endpoint via which a user can update his or her own UserConfig data (but no one else's).
+ """
+ permission_classes = [IsAuthenticated]
+
+ def get_queryset(self):
+ return UserConfig.objects.filter(user=self.request.user)
+
+ def list(self, request):
+ """
+ Return the UserConfig for the currently authenticated User.
+ """
+ userconfig = self.get_queryset().first()
+
+ return Response(userconfig.data)
+
+ def patch(self, request):
+ """
+ Update the UserConfig for the currently authenticated User.
+ """
+ # TODO: How can we validate this data?
+ userconfig = self.get_queryset().first()
+ userconfig.data = deepmerge(userconfig.data, request.data)
+ userconfig.save()
+
+ return Response(userconfig.data)
diff --git a/netbox/users/tests/test_api.py b/netbox/users/tests/test_api.py
index c4229bff9..11d4e58cd 100644
--- a/netbox/users/tests/test_api.py
+++ b/netbox/users/tests/test_api.py
@@ -1,11 +1,10 @@
from django.contrib.auth.models import Group, User
from django.contrib.contenttypes.models import ContentType
-from django.test import override_settings
from django.urls import reverse
-from rest_framework import status
from users.models import ObjectPermission
-from utilities.testing import APIViewTestCases, APITestCase, disable_warnings
+from utilities.testing import APIViewTestCases, APITestCase
+from utilities.utils import deepmerge
class AppTest(APITestCase):
@@ -22,15 +21,19 @@ class UserTest(APIViewTestCases.APIViewTestCase):
model = User
view_namespace = 'users'
brief_fields = ['id', 'url', 'username']
+ validation_excluded_fields = ['password']
create_data = [
{
'username': 'User_4',
+ 'password': 'password4',
},
{
'username': 'User_5',
+ 'password': 'password5',
},
{
'username': 'User_6',
+ 'password': 'password6',
},
]
@@ -95,7 +98,7 @@ class ObjectPermissionTest(APIViewTestCases.APIViewTestCase):
object_type = ContentType.objects.get(app_label='dcim', model='device')
- for i in range(0, 3):
+ for i in range(3):
objectpermission = ObjectPermission(
name=f'Permission {i+1}',
actions=['view', 'add', 'change', 'delete'],
@@ -132,3 +135,60 @@ class ObjectPermissionTest(APIViewTestCases.APIViewTestCase):
'constraints': {'name': 'TEST6'},
},
]
+
+ cls.bulk_update_data = {
+ 'description': 'New description',
+ }
+
+
+class UserConfigTest(APITestCase):
+
+ def test_get(self):
+ """
+ Retrieve user configuration via GET request.
+ """
+ userconfig = self.user.config
+ url = reverse('users-api:userconfig-list')
+
+ response = self.client.get(url, **self.header)
+ self.assertEqual(response.data, {})
+
+ data = {
+ "a": 123,
+ "b": 456,
+ "c": 789,
+ }
+ userconfig.data = data
+ userconfig.save()
+ response = self.client.get(url, **self.header)
+ self.assertEqual(response.data, data)
+
+ def test_patch(self):
+ """
+ Set user config via PATCH requests.
+ """
+ userconfig = self.user.config
+ url = reverse('users-api:userconfig-list')
+
+ data = {
+ "a": {
+ "a1": "X",
+ "a2": "Y",
+ },
+ "b": {
+ "b1": "Z",
+ }
+ }
+ response = self.client.patch(url, data=data, format='json', **self.header)
+ self.assertDictEqual(response.data, data)
+ userconfig.refresh_from_db()
+ self.assertDictEqual(userconfig.data, data)
+
+ update_data = {
+ "c": 123
+ }
+ response = self.client.patch(url, data=update_data, format='json', **self.header)
+ new_data = deepmerge(data, update_data)
+ self.assertDictEqual(response.data, new_data)
+ userconfig.refresh_from_db()
+ self.assertDictEqual(userconfig.data, new_data)
diff --git a/netbox/users/views.py b/netbox/users/views.py
index 46221f649..a6d28ecd2 100644
--- a/netbox/users/views.py
+++ b/netbox/users/views.py
@@ -185,7 +185,7 @@ class UserKeyView(LoginRequiredMixin, View):
userkey = None
return render(request, self.template_name, {
- 'userkey': userkey,
+ 'object': userkey,
'active_tab': 'userkey',
})
@@ -205,7 +205,7 @@ class UserKeyEditView(LoginRequiredMixin, View):
form = UserKeyForm(instance=self.userkey)
return render(request, self.template_name, {
- 'userkey': self.userkey,
+ 'object': self.userkey,
'form': form,
'active_tab': 'userkey',
})
@@ -293,7 +293,7 @@ class TokenEditView(LoginRequiredMixin, View):
form = TokenForm(instance=token)
- return render(request, 'utilities/obj_edit.html', {
+ return render(request, 'generic/object_edit.html', {
'obj': token,
'obj_type': token._meta.verbose_name,
'form': form,
@@ -322,7 +322,7 @@ class TokenEditView(LoginRequiredMixin, View):
else:
return redirect('user:token_list')
- return render(request, 'utilities/obj_edit.html', {
+ return render(request, 'generic/object_edit.html', {
'obj': token,
'obj_type': token._meta.verbose_name,
'form': form,
@@ -340,7 +340,7 @@ class TokenDeleteView(LoginRequiredMixin, View):
}
form = ConfirmationForm(initial=initial_data)
- return render(request, 'utilities/obj_delete.html', {
+ return render(request, 'generic/object_delete.html', {
'obj': token,
'obj_type': token._meta.verbose_name,
'form': form,
@@ -356,7 +356,7 @@ class TokenDeleteView(LoginRequiredMixin, View):
messages.success(request, "Token deleted")
return redirect('user:token_list')
- return render(request, 'utilities/obj_delete.html', {
+ return render(request, 'generic/object_delete.html', {
'obj': token,
'obj_type': token._meta.verbose_name,
'form': form,
diff --git a/netbox/utilities/api.py b/netbox/utilities/api.py
index cc9789161..09cc7004b 100644
--- a/netbox/utilities/api.py
+++ b/netbox/utilities/api.py
@@ -1,41 +1,14 @@
-import logging
-from collections import OrderedDict
+import platform
+import sys
-import pytz
from django.conf import settings
-from django.contrib.contenttypes.models import ContentType
-from django.core.exceptions import FieldError, MultipleObjectsReturned, ObjectDoesNotExist, PermissionDenied
-from django.db import transaction
-from django.db.models import ManyToManyField, ProtectedError
+from django.http import JsonResponse
from django.urls import reverse
-from rest_framework import serializers
-from rest_framework.exceptions import APIException, ValidationError
-from rest_framework.permissions import BasePermission
-from rest_framework.relations import PrimaryKeyRelatedField, RelatedField
-from rest_framework.response import Response
-from rest_framework.routers import DefaultRouter
-from rest_framework.viewsets import ModelViewSet as _ModelViewSet
+from rest_framework import status
+from rest_framework.utils import formatting
-from .utils import dict_to_filter_params, dynamic_import
-
-HTTP_ACTIONS = {
- 'GET': 'view',
- 'OPTIONS': None,
- 'HEAD': 'view',
- 'POST': 'add',
- 'PUT': 'change',
- 'PATCH': 'change',
- 'DELETE': 'delete',
-}
-
-
-class ServiceUnavailable(APIException):
- status_code = 503
- default_detail = "Service temporarily unavailable, please try again later."
-
-
-class SerializerNotFound(Exception):
- pass
+from netbox.api.exceptions import SerializerNotFound
+from .utils import dynamic_import
def get_serializer_for_model(model, prefix=''):
@@ -63,358 +36,37 @@ def is_api_request(request):
return request.path_info.startswith(api_path)
-#
-# Authentication
-#
-
-class IsAuthenticatedOrLoginNotRequired(BasePermission):
+def get_view_name(view, suffix=None):
"""
- Returns True if the user is authenticated or LOGIN_REQUIRED is False.
+ Derive the view name from its associated model, if it has one. Fall back to DRF's built-in `get_view_name`.
"""
- def has_permission(self, request, view):
- if not settings.LOGIN_REQUIRED:
- return True
- return request.user.is_authenticated
+ if hasattr(view, 'queryset'):
+ # Determine the model name from the queryset.
+ name = view.queryset.model._meta.verbose_name
+ name = ' '.join([w[0].upper() + w[1:] for w in name.split()]) # Capitalize each word
+
+ else:
+ # Replicate DRF's built-in behavior.
+ name = view.__class__.__name__
+ name = formatting.remove_trailing_string(name, 'View')
+ name = formatting.remove_trailing_string(name, 'ViewSet')
+ name = formatting.camelcase_to_spaces(name)
+
+ if suffix:
+ name += ' ' + suffix
+
+ return name
-#
-# Fields
-#
-
-class ChoiceField(serializers.Field):
+def rest_api_server_error(request, *args, **kwargs):
"""
- Represent a ChoiceField as {'value': , 'label': }. Accepts a single value on write.
-
- :param choices: An iterable of choices in the form (value, key).
- :param allow_blank: Allow blank values in addition to the listed choices.
+ Handle exceptions and return a useful error message for REST API requests.
"""
- def __init__(self, choices, allow_blank=False, **kwargs):
- self.choiceset = choices
- self.allow_blank = allow_blank
- self._choices = dict()
-
- # Unpack grouped choices
- for k, v in choices:
- if type(v) in [list, tuple]:
- for k2, v2 in v:
- self._choices[k2] = v2
- else:
- self._choices[k] = v
-
- super().__init__(**kwargs)
-
- def validate_empty_values(self, data):
- # Convert null to an empty string unless allow_null == True
- if data is None:
- if self.allow_null:
- return True, None
- else:
- data = ''
- return super().validate_empty_values(data)
-
- def to_representation(self, obj):
- if obj is '':
- return None
- return OrderedDict([
- ('value', obj),
- ('label', self._choices[obj])
- ])
-
- def to_internal_value(self, data):
- if data is '':
- if self.allow_blank:
- return data
- raise ValidationError("This field may not be blank.")
-
- # Provide an explicit error message if the request is trying to write a dict or list
- if isinstance(data, (dict, list)):
- raise ValidationError('Value must be passed directly (e.g. "foo": 123); do not use a dictionary or list.')
-
- # Check for string representations of boolean/integer values
- if hasattr(data, 'lower'):
- if data.lower() == 'true':
- data = True
- elif data.lower() == 'false':
- data = False
- else:
- try:
- data = int(data)
- except ValueError:
- pass
-
- try:
- if data in self._choices:
- return data
- except TypeError: # Input is an unhashable type
- pass
-
- raise ValidationError(f"{data} is not a valid choice.")
-
- @property
- def choices(self):
- return self._choices
-
-
-class ContentTypeField(RelatedField):
- """
- Represent a ContentType as '.'
- """
- default_error_messages = {
- "does_not_exist": "Invalid content type: {content_type}",
- "invalid": "Invalid value. Specify a content type as '.'.",
+ type_, error, traceback = sys.exc_info()
+ data = {
+ 'error': str(error),
+ 'exception': type_.__name__,
+ 'netbox_version': settings.VERSION,
+ 'python_version': platform.python_version(),
}
-
- def to_internal_value(self, data):
- try:
- app_label, model = data.split('.')
- return ContentType.objects.get_by_natural_key(app_label=app_label, model=model)
- except ObjectDoesNotExist:
- self.fail('does_not_exist', content_type=data)
- except (TypeError, ValueError):
- self.fail('invalid')
-
- def to_representation(self, obj):
- return "{}.{}".format(obj.app_label, obj.model)
-
-
-class TimeZoneField(serializers.Field):
- """
- Represent a pytz time zone.
- """
- def to_representation(self, obj):
- return obj.zone if obj else None
-
- def to_internal_value(self, data):
- if not data:
- return ""
- if data not in pytz.common_timezones:
- raise ValidationError('Unknown time zone "{}" (see pytz.common_timezones for all options)'.format(data))
- return pytz.timezone(data)
-
-
-class SerializedPKRelatedField(PrimaryKeyRelatedField):
- """
- Extends PrimaryKeyRelatedField to return a serialized object on read. This is useful for representing related
- objects in a ManyToManyField while still allowing a set of primary keys to be written.
- """
- def __init__(self, serializer, **kwargs):
- self.serializer = serializer
- self.pk_field = kwargs.pop('pk_field', None)
- super().__init__(**kwargs)
-
- def to_representation(self, value):
- return self.serializer(value, context={'request': self.context['request']}).data
-
-
-#
-# Serializers
-#
-
-# TODO: We should probably take a fresh look at exactly what we're doing with this. There might be a more elegant
-# way to enforce model validation on the serializer.
-class ValidatedModelSerializer(serializers.ModelSerializer):
- """
- Extends the built-in ModelSerializer to enforce calling clean() on the associated model during validation.
- """
- def validate(self, data):
-
- # Remove custom fields data and tags (if any) prior to model validation
- attrs = data.copy()
- attrs.pop('custom_fields', None)
- attrs.pop('tags', None)
-
- # Skip ManyToManyFields
- for field in self.Meta.model._meta.get_fields():
- if isinstance(field, ManyToManyField):
- attrs.pop(field.name, None)
-
- # Run clean() on an instance of the model
- if self.instance is None:
- instance = self.Meta.model(**attrs)
- else:
- instance = self.instance
- for k, v in attrs.items():
- setattr(instance, k, v)
- instance.clean()
- instance.validate_unique()
-
- return data
-
-
-class WritableNestedSerializer(serializers.ModelSerializer):
- """
- Returns a nested representation of an object on read, but accepts only a primary key on write.
- """
-
- def to_internal_value(self, data):
-
- if data is None:
- return None
-
- # Dictionary of related object attributes
- if isinstance(data, dict):
- params = dict_to_filter_params(data)
- queryset = self.Meta.model.objects
- try:
- return queryset.get(**params)
- except ObjectDoesNotExist:
- raise ValidationError(
- "Related object not found using the provided attributes: {}".format(params)
- )
- except MultipleObjectsReturned:
- raise ValidationError(
- "Multiple objects match the provided attributes: {}".format(params)
- )
- except FieldError as e:
- raise ValidationError(e)
-
- # Integer PK of related object
- if isinstance(data, int):
- pk = data
- else:
- try:
- # PK might have been mistakenly passed as a string
- pk = int(data)
- except (TypeError, ValueError):
- raise ValidationError(
- "Related objects must be referenced by numeric ID or by dictionary of attributes. Received an "
- "unrecognized value: {}".format(data)
- )
-
- # Look up object by PK
- queryset = self.Meta.model.objects
- try:
- return queryset.get(pk=int(data))
- except ObjectDoesNotExist:
- raise ValidationError(
- "Related object not found using the provided numeric ID: {}".format(pk)
- )
-
-
-#
-# Viewsets
-#
-
-class ModelViewSet(_ModelViewSet):
- """
- Accept either a single object or a list of objects to create.
- """
- def get_serializer(self, *args, **kwargs):
-
- # If a list of objects has been provided, initialize the serializer with many=True
- if isinstance(kwargs.get('data', {}), list):
- kwargs['many'] = True
-
- return super().get_serializer(*args, **kwargs)
-
- def get_serializer_class(self):
- logger = logging.getLogger('netbox.api.views.ModelViewSet')
-
- # If 'brief' has been passed as a query param, find and return the nested serializer for this model, if one
- # exists
- request = self.get_serializer_context()['request']
- if request.query_params.get('brief'):
- logger.debug("Request is for 'brief' format; initializing nested serializer")
- try:
- serializer = get_serializer_for_model(self.queryset.model, prefix='Nested')
- logger.debug(f"Using serializer {serializer}")
- return serializer
- except SerializerNotFound:
- pass
-
- # Fall back to the hard-coded serializer class
- logger.debug(f"Using serializer {self.serializer_class}")
- return self.serializer_class
-
- def initial(self, request, *args, **kwargs):
- super().initial(request, *args, **kwargs)
-
- if not request.user.is_authenticated:
- return
-
- # Restrict the view's QuerySet to allow only the permitted objects
- action = HTTP_ACTIONS[request.method]
- if action:
- self.queryset = self.queryset.restrict(request.user, action)
-
- def dispatch(self, request, *args, **kwargs):
- logger = logging.getLogger('netbox.api.views.ModelViewSet')
-
- try:
- return super().dispatch(request, *args, **kwargs)
- except ProtectedError as e:
- protected_objects = list(e.protected_objects)
- msg = f'Unable to delete object. {len(protected_objects)} dependent objects were found: '
- msg += ', '.join([f'{obj} ({obj.pk})' for obj in protected_objects])
- logger.warning(msg)
- return self.finalize_response(
- request,
- Response({'detail': msg}, status=409),
- *args,
- **kwargs
- )
-
- def _validate_objects(self, instance):
- """
- Check that the provided instance or list of instances are matched by the current queryset. This confirms that
- any newly created or modified objects abide by the attributes granted by any applicable ObjectPermissions.
- """
- if type(instance) is list:
- # Check that all instances are still included in the view's queryset
- conforming_count = self.queryset.filter(pk__in=[obj.pk for obj in instance]).count()
- if conforming_count != len(instance):
- raise ObjectDoesNotExist
- else:
- # Check that the instance is matched by the view's queryset
- self.queryset.get(pk=instance.pk)
-
- def perform_create(self, serializer):
- model = self.queryset.model
- logger = logging.getLogger('netbox.api.views.ModelViewSet')
- logger.info(f"Creating new {model._meta.verbose_name}")
-
- # Enforce object-level permissions on save()
- try:
- with transaction.atomic():
- instance = serializer.save()
- self._validate_objects(instance)
- except ObjectDoesNotExist:
- raise PermissionDenied()
-
- def perform_update(self, serializer):
- model = self.queryset.model
- logger = logging.getLogger('netbox.api.views.ModelViewSet')
- logger.info(f"Updating {model._meta.verbose_name} {serializer.instance} (PK: {serializer.instance.pk})")
-
- # Enforce object-level permissions on save()
- try:
- with transaction.atomic():
- instance = serializer.save()
- self._validate_objects(instance)
- except ObjectDoesNotExist:
- raise PermissionDenied()
-
- def perform_destroy(self, instance):
- model = self.queryset.model
- logger = logging.getLogger('netbox.api.views.ModelViewSet')
- logger.info(f"Deleting {model._meta.verbose_name} {instance} (PK: {instance.pk})")
-
- return super().perform_destroy(instance)
-
-
-#
-# Routers
-#
-
-class OrderedDefaultRouter(DefaultRouter):
-
- def get_api_root_view(self, api_urls=None):
- """
- Wrap DRF's DefaultRouter to return an alphabetized list of endpoints.
- """
- api_root_dict = OrderedDict()
- list_name = self.routes[0].name
- for prefix, viewset, basename in sorted(self.registry, key=lambda x: x[0]):
- api_root_dict[prefix] = list_name.format(basename=basename)
-
- return self.APIRootView.as_view(api_root_dict=api_root_dict)
+ return JsonResponse(data, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
diff --git a/netbox/utilities/custom_inspectors.py b/netbox/utilities/custom_inspectors.py
index 38297838d..95c647fb8 100644
--- a/netbox/utilities/custom_inspectors.py
+++ b/netbox/utilities/custom_inspectors.py
@@ -5,13 +5,25 @@ from drf_yasg.utils import get_serializer_ref_name
from rest_framework.fields import ChoiceField
from rest_framework.relations import ManyRelatedField
-from extras.api.customfields import CustomFieldsSerializer
-from utilities.api import ChoiceField, SerializedPKRelatedField, WritableNestedSerializer
+from extras.api.customfields import CustomFieldsDataField
+from netbox.api import ChoiceField, SerializedPKRelatedField, WritableNestedSerializer
class NetBoxSwaggerAutoSchema(SwaggerAutoSchema):
writable_serializers = {}
+ def get_operation_id(self, operation_keys=None):
+ operation_keys = operation_keys or self.operation_keys
+ operation_id = self.overrides.get('operation_id', '')
+ if not operation_id:
+ # Overwrite the action for bulk update/bulk delete views to ensure they get an operation ID that's
+ # unique from their single-object counterparts (see #3436)
+ if operation_keys[-1] in ('delete', 'partial_update', 'update') and not self.view.detail:
+ operation_keys[-1] = f'bulk_{operation_keys[-1]}'
+ operation_id = '_'.join(operation_keys)
+
+ return operation_id
+
def get_request_serializer(self):
serializer = super().get_request_serializer()
@@ -49,7 +61,7 @@ class SerializedPKRelatedFieldInspector(FieldInspector):
return NotHandled
-class CustomChoiceFieldInspector(FieldInspector):
+class ChoiceFieldInspector(FieldInspector):
def field_to_swagger_object(self, field, swagger_object_type, use_references, **kwargs):
# this returns a callable which extracts title, description and other stuff
# https://drf-yasg.readthedocs.io/en/stable/_modules/drf_yasg/inspectors/base.html#FieldInspector._get_partial_types
@@ -62,8 +74,8 @@ class CustomChoiceFieldInspector(FieldInspector):
value_schema = openapi.Schema(type=openapi.TYPE_STRING, enum=choice_value)
if set([None] + choice_value) == {None, True, False}:
- # DeviceType.subdevice_role, Device.face and InterfaceConnection.connection_status all need to be
- # differentiated since they each have subtly different values in their choice keys.
+ # DeviceType.subdevice_role and Device.face need to be differentiated since they each have
+ # subtly different values in their choice keys.
# - subdevice_role and connection_status are booleans, although subdevice_role includes None
# - face is an integer set {0, 1} which is easily confused with {False, True}
schema_type = openapi.TYPE_STRING
@@ -83,10 +95,6 @@ class CustomChoiceFieldInspector(FieldInspector):
return schema
- elif isinstance(field, CustomFieldsSerializer):
- schema = SwaggerType(type=openapi.TYPE_OBJECT)
- return schema
-
return NotHandled
@@ -102,6 +110,17 @@ class NullableBooleanFieldInspector(FieldInspector):
return result
+class CustomFieldsDataFieldInspector(FieldInspector):
+
+ def field_to_swagger_object(self, field, swagger_object_type, use_references, **kwargs):
+ SwaggerType, ChildSwaggerType = self._get_partial_types(field, swagger_object_type, use_references, **kwargs)
+
+ if isinstance(field, CustomFieldsDataField) and swagger_object_type == openapi.Schema:
+ return SwaggerType(type=openapi.TYPE_OBJECT)
+
+ return NotHandled
+
+
class JSONFieldInspector(FieldInspector):
"""Required because by default, Swagger sees a JSONField as a string and not dict
"""
diff --git a/netbox/utilities/filters.py b/netbox/utilities/filters.py
index f628ca917..6305c0bba 100644
--- a/netbox/utilities/filters.py
+++ b/netbox/utilities/filters.py
@@ -1,4 +1,5 @@
import django_filters
+from django_filters.constants import EMPTY_VALUES
from copy import deepcopy
from dcim.forms import MACAddressField
from django import forms
@@ -68,11 +69,10 @@ class TreeNodeMultipleChoiceFilter(django_filters.ModelMultipleChoiceFilter):
"""
Filters for a set of Models, including all descendant models within a Tree. Example: [,]
"""
-
def get_filter_predicate(self, v):
- # null value filtering
+ # Null value filtering
if v is None:
- return {self.field_name.replace('in', 'isnull'): True}
+ return {f"{self.field_name}__isnull": True}
return super().get_filter_predicate(v)
def filter(self, qs, value):
@@ -84,7 +84,6 @@ class NullableCharFieldFilter(django_filters.CharFilter):
"""
Allow matching on null field values by passing a special string used to signify NULL.
"""
-
def filter(self, qs, value):
if value != settings.FILTERS_NULL_CHOICE_VALUE:
return super().filter(qs, value)
@@ -107,6 +106,36 @@ class TagFilter(django_filters.ModelMultipleChoiceFilter):
super().__init__(*args, **kwargs)
+class NumericArrayFilter(django_filters.NumberFilter):
+ """
+ Filter based on the presence of an integer within an ArrayField.
+ """
+ def filter(self, qs, value):
+ if value:
+ value = [value]
+ return super().filter(qs, value)
+
+
+class ContentTypeFilter(django_filters.CharFilter):
+ """
+ Allow specifying a ContentType by . (e.g. "dcim.site").
+ """
+ def filter(self, qs, value):
+ if value in EMPTY_VALUES:
+ return qs
+
+ try:
+ app_label, model = value.lower().split('.')
+ except ValueError:
+ return qs.none()
+ return qs.filter(
+ **{
+ f'{self.field_name}__app_label': app_label,
+ f'{self.field_name}__model': model
+ }
+ )
+
+
#
# FilterSets
#
diff --git a/netbox/utilities/forms/fields.py b/netbox/utilities/forms/fields.py
index 92e40e7bb..a5b76fd8d 100644
--- a/netbox/utilities/forms/fields.py
+++ b/netbox/utilities/forms/fields.py
@@ -6,12 +6,11 @@ from io import StringIO
import django_filters
from django import forms
from django.forms.fields import JSONField as _JSONField, InvalidJSONInput
-from django.core.exceptions import MultipleObjectsReturned
+from django.core.exceptions import MultipleObjectsReturned, ObjectDoesNotExist
from django.db.models import Count
from django.forms import BoundField
from django.urls import reverse
-from utilities.api import get_serializer_for_model
from utilities.choices import unpack_grouped_choices
from utilities.validators import EnhancedURLValidator
from . import widgets
@@ -21,6 +20,7 @@ from .utils import expand_alphanumeric_pattern, expand_ipaddress_pattern
__all__ = (
'CommentField',
'CSVChoiceField',
+ 'CSVContentTypeField',
'CSVDataField',
'CSVModelChoiceField',
'DynamicModelChoiceField',
@@ -117,18 +117,11 @@ class CSVChoiceField(forms.ChoiceField):
"""
Invert the provided set of choices to take the human-friendly label as input, and return the database value.
"""
- def __init__(self, choices, *args, **kwargs):
- super().__init__(choices=choices, *args, **kwargs)
- self.choices = [(label, label) for value, label in unpack_grouped_choices(choices)]
- self.choice_values = {label: value for value, label in unpack_grouped_choices(choices)}
+ STATIC_CHOICES = True
- def clean(self, value):
- value = super().clean(value)
- if not value:
- return ''
- if value not in self.choice_values:
- raise forms.ValidationError("Invalid choice: {}".format(value))
- return self.choice_values[value]
+ def __init__(self, *, choices=(), **kwargs):
+ super().__init__(choices=choices, **kwargs)
+ self.choices = unpack_grouped_choices(choices)
class CSVModelChoiceField(forms.ModelChoiceField):
@@ -142,12 +135,32 @@ class CSVModelChoiceField(forms.ModelChoiceField):
def to_python(self, value):
try:
return super().to_python(value)
- except MultipleObjectsReturned as e:
+ except MultipleObjectsReturned:
raise forms.ValidationError(
f'"{value}" is not a unique value for this field; multiple objects were found'
)
+class CSVContentTypeField(CSVModelChoiceField):
+ """
+ Reference a ContentType in the form .
+ """
+ STATIC_CHOICES = True
+
+ def prepare_value(self, value):
+ return f'{value.app_label}.{value.model}'
+
+ def to_python(self, value):
+ try:
+ app_label, model = value.split('.')
+ except ValueError:
+ raise forms.ValidationError(f'Object type must be specified as "."')
+ try:
+ return self.queryset.get(app_label=app_label, model=model)
+ except ObjectDoesNotExist:
+ raise forms.ValidationError(f'Invalid object type')
+
+
class ExpandableNameField(forms.CharField):
"""
A field which allows for numeric range expansion
@@ -200,7 +213,7 @@ class CommentField(forms.CharField):
widget = forms.Textarea
default_label = ''
# TODO: Port Markdown cheat sheet to internal documentation
- default_helptext = ' '\
+ default_helptext = ' '\
''\
'Markdown syntax is supported'
diff --git a/netbox/utilities/forms/forms.py b/netbox/utilities/forms/forms.py
index bc68f29f6..f259f6b6d 100644
--- a/netbox/utilities/forms/forms.py
+++ b/netbox/utilities/forms/forms.py
@@ -161,6 +161,7 @@ class TableConfigForm(BootstrapMixin, forms.Form):
"""
columns = forms.MultipleChoiceField(
choices=[],
+ required=False,
widget=forms.SelectMultiple(
attrs={'size': 10}
),
@@ -168,8 +169,14 @@ class TableConfigForm(BootstrapMixin, forms.Form):
)
def __init__(self, table, *args, **kwargs):
+ self.table = table
+
super().__init__(*args, **kwargs)
# Initialize columns field based on table attributes
self.fields['columns'].choices = table.configurable_columns
self.fields['columns'].initial = table.visible_columns
+
+ @property
+ def table_name(self):
+ return self.table.__class__.__name__
diff --git a/netbox/utilities/metadata.py b/netbox/utilities/metadata.py
deleted file mode 100644
index 8fd664d5a..000000000
--- a/netbox/utilities/metadata.py
+++ /dev/null
@@ -1,19 +0,0 @@
-from rest_framework.metadata import SimpleMetadata
-from django.utils.encoding import force_str
-from utilities.api import ContentTypeField
-
-
-class ContentTypeMetadata(SimpleMetadata):
-
- def get_field_info(self, field):
- field_info = super().get_field_info(field)
- if hasattr(field, 'queryset') and not field_info.get('read_only') and isinstance(field, ContentTypeField):
- field_info['choices'] = [
- {
- 'value': choice_value,
- 'display_name': force_str(choice_name, strings_only=True)
- }
- for choice_value, choice_name in field.choices.items()
- ]
- field_info['choices'].sort(key=lambda item: item['display_name'])
- return field_info
diff --git a/netbox/utilities/tables.py b/netbox/utilities/tables.py
index 5dddf24ed..bf087f2c9 100644
--- a/netbox/utilities/tables.py
+++ b/netbox/utilities/tables.py
@@ -1,7 +1,9 @@
import django_tables2 as tables
+from django.contrib.auth.models import AnonymousUser
from django.contrib.contenttypes.fields import GenericForeignKey
from django.core.exceptions import FieldDoesNotExist
from django.db.models.fields.related import RelatedField
+from django.urls import reverse
from django.utils.safestring import mark_safe
from django_tables2.data import TableQuerysetData
@@ -10,18 +12,14 @@ class BaseTable(tables.Table):
"""
Default table for object lists
- :param add_prefetch: By default, modify the queryset passed to the table upon initialization to automatically
- prefetch related data. Set this to False if it's necessary to avoid modifying the queryset (e.g. to
- accommodate PrefixQuerySet.annotate_depth()).
+ :param user: Personalize table display for the given user (optional). Has no effect if AnonymousUser is passed.
"""
- add_prefetch = True
-
class Meta:
attrs = {
'class': 'table table-hover table-headings',
}
- def __init__(self, *args, columns=None, **kwargs):
+ def __init__(self, *args, user=None, **kwargs):
super().__init__(*args, **kwargs)
# Set default empty_text if none was provided
@@ -35,39 +33,51 @@ class BaseTable(tables.Table):
if column.name not in default_columns:
self.columns.hide(column.name)
- # Apply custom column ordering
- if columns is not None:
- pk = self.base_columns.pop('pk', None)
- actions = self.base_columns.pop('actions', None)
+ # Apply custom column ordering for user
+ if user is not None and not isinstance(user, AnonymousUser):
+ columns = user.config.get(f"tables.{self.__class__.__name__}.columns")
+ if columns:
+ pk = self.base_columns.pop('pk', None)
+ actions = self.base_columns.pop('actions', None)
- for name, column in self.base_columns.items():
- if name in columns:
- self.columns.show(name)
- else:
- self.columns.hide(name)
- self.sequence = [c for c in columns if c in self.base_columns]
+ for name, column in self.base_columns.items():
+ if name in columns:
+ self.columns.show(name)
+ else:
+ self.columns.hide(name)
+ self.sequence = [c for c in columns if c in self.base_columns]
- # Always include PK and actions column, if defined on the table
- if pk:
- self.base_columns['pk'] = pk
- self.sequence.insert(0, 'pk')
- if actions:
- self.base_columns['actions'] = actions
- self.sequence.append('actions')
+ # Always include PK and actions column, if defined on the table
+ if pk:
+ self.base_columns['pk'] = pk
+ self.sequence.insert(0, 'pk')
+ if actions:
+ self.base_columns['actions'] = actions
+ self.sequence.append('actions')
# Dynamically update the table's QuerySet to ensure related fields are pre-fetched
- if self.add_prefetch and isinstance(self.data, TableQuerysetData):
- model = getattr(self.Meta, 'model')
+ if isinstance(self.data, TableQuerysetData):
prefetch_fields = []
for column in self.columns:
if column.visible:
- field_path = column.accessor.split('.')
- try:
- model_field = model._meta.get_field(field_path[0])
- if isinstance(model_field, (RelatedField, GenericForeignKey)):
- prefetch_fields.append('__'.join(field_path))
- except FieldDoesNotExist:
- pass
+ model = getattr(self.Meta, 'model')
+ accessor = column.accessor
+ prefetch_path = []
+ for field_name in accessor.split(accessor.SEPARATOR):
+ try:
+ field = model._meta.get_field(field_name)
+ except FieldDoesNotExist:
+ break
+ if isinstance(field, RelatedField):
+ # Follow ForeignKeys to the related model
+ prefetch_path.append(field_name)
+ model = field.remote_field.model
+ elif isinstance(field, GenericForeignKey):
+ # Can't prefetch beyond a GenericForeignKey
+ prefetch_path.append(field_name)
+ break
+ if prefetch_path:
+ prefetch_fields.append('__'.join(prefetch_path))
self.data.data = self.data.data.prefetch_related(None).prefetch_related(*prefetch_fields)
@property
@@ -116,11 +126,11 @@ class BooleanColumn(tables.Column):
"""
def render(self, value):
if value:
- rendered = ' '
+ rendered = ' '
elif value is None:
rendered = '— '
else:
- rendered = ' '
+ rendered = ' '
return mark_safe(rendered)
@@ -138,17 +148,17 @@ class ButtonsColumn(tables.TemplateColumn):
template_code = """
{{% if "changelog" in buttons %}}
-
+
{{% endif %}}
{{% if "edit" in buttons and perms.{app_label}.change_{model_name} %}}
-
+
{{% endif %}}
{{% if "delete" in buttons and perms.{app_label}.delete_{model_name} %}}
-
+
{{% endif %}}
"""
@@ -178,13 +188,29 @@ class ButtonsColumn(tables.TemplateColumn):
return ''
+class ChoiceFieldColumn(tables.Column):
+ """
+ Render a ChoiceField value inside a indicating a particular CSS class. This is useful for displaying colored
+ choices. The CSS class is derived by calling .get_FOO_class() on the row record.
+ """
+ def render(self, record, bound_column, value):
+ if value:
+ name = bound_column.name
+ css_class = getattr(record, f'get_{name}_class')()
+ label = getattr(record, f'get_{name}_display')()
+ return mark_safe(
+ f'{label} '
+ )
+ return self.default
+
+
class ColorColumn(tables.Column):
"""
Display a color (#RRGGBB).
"""
def render(self, value):
return mark_safe(
- ' '.format(value)
+ f' '
)
@@ -201,6 +227,29 @@ class ColoredLabelColumn(tables.TemplateColumn):
super().__init__(template_code=self.template_code, *args, **kwargs)
+class LinkedCountColumn(tables.Column):
+ """
+ Render a count of related objects linked to a filtered URL.
+
+ :param viewname: The view name to use for URL resolution
+ :param view_kwargs: Additional kwargs to pass for URL resolution (optional)
+ :param url_params: A dict of query parameters to append to the URL (e.g. ?foo=bar) (optional)
+ """
+ def __init__(self, viewname, *args, view_kwargs=None, url_params=None, default=0, **kwargs):
+ self.viewname = viewname
+ self.view_kwargs = view_kwargs or {}
+ self.url_params = url_params
+ super().__init__(*args, default=default, **kwargs)
+
+ def render(self, record, value):
+ if value:
+ url = reverse(self.viewname, kwargs=self.view_kwargs)
+ if self.url_params:
+ url += '?' + '&'.join([f'{k}={getattr(record, v)}' for k, v in self.url_params.items()])
+ return mark_safe(f'{value} ')
+ return value
+
+
class TagColumn(tables.TemplateColumn):
"""
Display a list of tags assigned to the object.
diff --git a/netbox/utilities/templates/buttons/add.html b/netbox/utilities/templates/buttons/add.html
index 9ba2626a9..784bd0536 100644
--- a/netbox/utilities/templates/buttons/add.html
+++ b/netbox/utilities/templates/buttons/add.html
@@ -1,3 +1,3 @@
- Add
+ Add
diff --git a/netbox/utilities/templates/buttons/clone.html b/netbox/utilities/templates/buttons/clone.html
index b8f3638c4..b2d4c875a 100644
--- a/netbox/utilities/templates/buttons/clone.html
+++ b/netbox/utilities/templates/buttons/clone.html
@@ -1,3 +1,3 @@
- Clone
+ Clone
diff --git a/netbox/utilities/templates/buttons/delete.html b/netbox/utilities/templates/buttons/delete.html
index 6d621701f..95bf3cb61 100644
--- a/netbox/utilities/templates/buttons/delete.html
+++ b/netbox/utilities/templates/buttons/delete.html
@@ -1,3 +1,3 @@
- Delete
+ Delete
diff --git a/netbox/utilities/templates/buttons/edit.html b/netbox/utilities/templates/buttons/edit.html
index 14de5c86d..7950d7ed5 100644
--- a/netbox/utilities/templates/buttons/edit.html
+++ b/netbox/utilities/templates/buttons/edit.html
@@ -1,3 +1,3 @@
- Edit
+ Edit
diff --git a/netbox/utilities/templates/buttons/export.html b/netbox/utilities/templates/buttons/export.html
index 15520c77b..a6ec52d1f 100644
--- a/netbox/utilities/templates/buttons/export.html
+++ b/netbox/utilities/templates/buttons/export.html
@@ -1,7 +1,7 @@
{% if export_templates %}
-
+
Export
{% else %}
- Export
+ Export
{% endif %}
\ No newline at end of file
diff --git a/netbox/utilities/templates/buttons/import.html b/netbox/utilities/templates/buttons/import.html
index 67be77871..1a9ecdfce 100644
--- a/netbox/utilities/templates/buttons/import.html
+++ b/netbox/utilities/templates/buttons/import.html
@@ -1,3 +1,3 @@
- Import
+ Import
diff --git a/netbox/utilities/templates/widgets/sluginput.html b/netbox/utilities/templates/widgets/sluginput.html
index 76de7f35a..0bfe8b201 100644
--- a/netbox/utilities/templates/widgets/sluginput.html
+++ b/netbox/utilities/templates/widgets/sluginput.html
@@ -2,7 +2,7 @@
{% include "django/forms/widgets/input.html" %}
-
+
diff --git a/netbox/utilities/templatetags/helpers.py b/netbox/utilities/templatetags/helpers.py
index 4ad42b97a..fe5849cb6 100644
--- a/netbox/utilities/templatetags/helpers.py
+++ b/netbox/utilities/templatetags/helpers.py
@@ -10,6 +10,7 @@ from django.utils.html import strip_tags
from django.utils.safestring import mark_safe
from markdown import markdown
+from utilities.forms import TableConfigForm
from utilities.utils import foreground_color
register = template.Library()
@@ -207,6 +208,18 @@ def split(string, sep=','):
return string.split(sep)
+@register.filter()
+def as_range(n):
+ """
+ Return a range of n items.
+ """
+ try:
+ int(n)
+ except TypeError:
+ return list()
+ return range(n)
+
+
#
# Tags
#
@@ -269,3 +282,11 @@ def badge(value, show_empty=False):
'value': value,
'show_empty': show_empty,
}
+
+
+@register.inclusion_tag('utilities/templatetags/table_config_form.html')
+def table_config_form(table, table_name=None):
+ return {
+ 'table_name': table_name or table.__class__.__name__,
+ 'table_config_form': TableConfigForm(table=table),
+ }
diff --git a/netbox/utilities/testing/api.py b/netbox/utilities/testing/api.py
index 2f3e2aa19..f4f4ffefe 100644
--- a/netbox/utilities/testing/api.py
+++ b/netbox/utilities/testing/api.py
@@ -93,6 +93,7 @@ class APIViewTestCases:
# Add object-level permission
obj_perm = ObjectPermission(
+ name='Test permission',
constraints={'pk': instance1.pk},
actions=['view']
)
@@ -108,6 +109,15 @@ class APIViewTestCases:
url = self._get_detail_url(instance2)
self.assertHttpStatus(self.client.get(url, **self.header), status.HTTP_404_NOT_FOUND)
+ @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
+ def test_options_object(self):
+ """
+ Make an OPTIONS request for a single object.
+ """
+ url = self._get_detail_url(self._get_queryset().first())
+ response = self.client.options(url, **self.header)
+ self.assertHttpStatus(response, status.HTTP_200_OK)
+
class ListObjectsViewTestCase(APITestCase):
brief_fields = []
@@ -160,6 +170,7 @@ class APIViewTestCases:
# Add object-level permission
obj_perm = ObjectPermission(
+ name='Test permission',
constraints={'pk__in': [instance1.pk, instance2.pk]},
actions=['view']
)
@@ -172,8 +183,17 @@ class APIViewTestCases:
self.assertHttpStatus(response, status.HTTP_200_OK)
self.assertEqual(len(response.data['results']), 2)
+ @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
+ def test_options_objects(self):
+ """
+ Make an OPTIONS request for a list endpoint.
+ """
+ response = self.client.options(self._get_list_url(), **self.header)
+ self.assertHttpStatus(response, status.HTTP_200_OK)
+
class CreateObjectViewTestCase(APITestCase):
create_data = []
+ validation_excluded_fields = []
def test_create_object_without_permission(self):
"""
@@ -192,6 +212,7 @@ class APIViewTestCases:
"""
# Add object-level permission
obj_perm = ObjectPermission(
+ name='Test permission',
actions=['add']
)
obj_perm.save()
@@ -205,6 +226,7 @@ class APIViewTestCases:
self.assertInstanceEqual(
self._get_queryset().get(pk=response.data['id']),
self.create_data[0],
+ exclude=self.validation_excluded_fields,
api=True
)
@@ -214,6 +236,7 @@ class APIViewTestCases:
"""
# Add object-level permission
obj_perm = ObjectPermission(
+ name='Test permission',
actions=['add']
)
obj_perm.save()
@@ -225,15 +248,22 @@ class APIViewTestCases:
self.assertHttpStatus(response, status.HTTP_201_CREATED)
self.assertEqual(len(response.data), len(self.create_data))
self.assertEqual(self._get_queryset().count(), initial_count + len(self.create_data))
+ for i, obj in enumerate(response.data):
+ for field in self.create_data[i]:
+ if field not in self.validation_excluded_fields:
+ self.assertIn(field, obj, f"Bulk create field '{field}' missing from object {i} in response")
for i, obj in enumerate(response.data):
self.assertInstanceEqual(
self._get_queryset().get(pk=obj['id']),
self.create_data[i],
+ exclude=self.validation_excluded_fields,
api=True
)
class UpdateObjectViewTestCase(APITestCase):
update_data = {}
+ bulk_update_data = None
+ validation_excluded_fields = []
def test_update_object_without_permission(self):
"""
@@ -257,6 +287,7 @@ class APIViewTestCases:
# Add object-level permission
obj_perm = ObjectPermission(
+ name='Test permission',
actions=['change']
)
obj_perm.save()
@@ -266,7 +297,42 @@ class APIViewTestCases:
response = self.client.patch(url, update_data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
instance.refresh_from_db()
- self.assertInstanceEqual(instance, update_data, api=True)
+ self.assertInstanceEqual(
+ instance,
+ update_data,
+ exclude=self.validation_excluded_fields,
+ api=True
+ )
+
+ def test_bulk_update_objects(self):
+ """
+ PATCH a set of objects in a single request.
+ """
+ if self.bulk_update_data is None:
+ self.skipTest("Bulk update data not set")
+
+ # Add object-level permission
+ obj_perm = ObjectPermission(
+ name='Test permission',
+ actions=['change']
+ )
+ obj_perm.save()
+ obj_perm.users.add(self.user)
+ obj_perm.object_types.add(ContentType.objects.get_for_model(self.model))
+
+ id_list = self._get_queryset().values_list('id', flat=True)[:3]
+ self.assertEqual(len(id_list), 3, "Insufficient number of objects to test bulk update")
+ data = [
+ {'id': id, **self.bulk_update_data} for id in id_list
+ ]
+
+ response = self.client.patch(self._get_list_url(), data, format='json', **self.header)
+ self.assertHttpStatus(response, status.HTTP_200_OK)
+ for i, obj in enumerate(response.data):
+ for field in self.bulk_update_data:
+ self.assertIn(field, obj, f"Bulk update field '{field}' missing from object {i} in response")
+ for instance in self._get_queryset().filter(pk__in=id_list):
+ self.assertInstanceEqual(instance, self.bulk_update_data, api=True)
class DeleteObjectViewTestCase(APITestCase):
@@ -290,6 +356,7 @@ class APIViewTestCases:
# Add object-level permission
obj_perm = ObjectPermission(
+ name='Test permission',
actions=['delete']
)
obj_perm.save()
@@ -300,6 +367,30 @@ class APIViewTestCases:
self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
self.assertFalse(self._get_queryset().filter(pk=instance.pk).exists())
+ def test_bulk_delete_objects(self):
+ """
+ DELETE a set of objects in a single request.
+ """
+ # Add object-level permission
+ obj_perm = ObjectPermission(
+ name='Test permission',
+ actions=['delete']
+ )
+ obj_perm.save()
+ obj_perm.users.add(self.user)
+ obj_perm.object_types.add(ContentType.objects.get_for_model(self.model))
+
+ # Target the three most recently created objects to avoid triggering recursive deletions
+ # (e.g. with MPTT objects)
+ id_list = self._get_queryset().order_by('-id').values_list('id', flat=True)[:3]
+ self.assertEqual(len(id_list), 3, "Insufficient number of objects to test bulk deletion")
+ data = [{"id": id} for id in id_list]
+
+ initial_count = self._get_queryset().count()
+ response = self.client.delete(self._get_list_url(), data, format='json', **self.header)
+ self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
+ self.assertEqual(self._get_queryset().count(), initial_count - 3)
+
class APIViewTestCase(
GetObjectViewTestCase,
ListObjectsViewTestCase,
diff --git a/netbox/utilities/testing/views.py b/netbox/utilities/testing/views.py
index afbcbc057..d380996b7 100644
--- a/netbox/utilities/testing/views.py
+++ b/netbox/utilities/testing/views.py
@@ -100,7 +100,7 @@ class TestCase(_TestCase):
"""
for name in names:
ct, action = resolve_permission_ct(name)
- obj_perm = ObjectPermission(actions=[action])
+ obj_perm = ObjectPermission(name=name, actions=[action])
obj_perm.save()
obj_perm.users.add(self.user)
obj_perm.object_types.add(ct)
@@ -126,20 +126,25 @@ class TestCase(_TestCase):
err_message = f"Expected HTTP status {expected_status}; received {response.status_code}: {err}"
self.assertEqual(response.status_code, expected_status, err_message)
- def assertInstanceEqual(self, instance, data, api=False):
+ def assertInstanceEqual(self, instance, data, exclude=None, api=False):
"""
Compare a model instance to a dictionary, checking that its attribute values match those specified
in the dictionary.
- :instance: Python object instance
- :data: Dictionary of test data used to define the instance
- :api: Set to True is the data is a JSON representation of the instance
+ :param instance: Python object instance
+ :param data: Dictionary of test data used to define the instance
+ :param exclude: List of fields to exclude from comparison (e.g. passwords, which get hashed)
+ :param api: Set to True is the data is a JSON representation of the instance
"""
- model_dict = self.model_to_dict(instance, fields=data.keys(), api=api)
+ if exclude is None:
+ exclude = []
- # Omit any dictionary keys which are not instance attributes
+ fields = [k for k in data.keys() if k not in exclude]
+ model_dict = self.model_to_dict(instance, fields=fields, api=api)
+
+ # Omit any dictionary keys which are not instance attributes or have been excluded
relevant_data = {
- k: v for k, v in data.items() if hasattr(instance, k)
+ k: v for k, v in data.items() if hasattr(instance, k) and k not in exclude
}
self.assertDictEqual(model_dict, relevant_data)
@@ -240,6 +245,7 @@ class ViewTestCases:
# Add model-level permission
obj_perm = ObjectPermission(
+ name='Test permission',
actions=['view']
)
obj_perm.save()
@@ -255,6 +261,7 @@ class ViewTestCases:
# Add object-level permission
obj_perm = ObjectPermission(
+ name='Test permission',
constraints={'pk': instance1.pk},
actions=['view']
)
@@ -307,6 +314,7 @@ class ViewTestCases:
# Assign unconstrained permission
obj_perm = ObjectPermission(
+ name='Test permission',
actions=['add']
)
obj_perm.save()
@@ -331,6 +339,7 @@ class ViewTestCases:
# Assign constrained permission
obj_perm = ObjectPermission(
+ name='Test permission',
constraints={'pk': 0}, # Dummy permission to deny all
actions=['add']
)
@@ -391,6 +400,7 @@ class ViewTestCases:
# Assign model-level permission
obj_perm = ObjectPermission(
+ name='Test permission',
actions=['change']
)
obj_perm.save()
@@ -414,6 +424,7 @@ class ViewTestCases:
# Assign constrained permission
obj_perm = ObjectPermission(
+ name='Test permission',
constraints={'pk': instance1.pk},
actions=['change']
)
@@ -467,6 +478,7 @@ class ViewTestCases:
# Assign model-level permission
obj_perm = ObjectPermission(
+ name='Test permission',
actions=['delete']
)
obj_perm.save()
@@ -491,6 +503,7 @@ class ViewTestCases:
# Assign object-level permission
obj_perm = ObjectPermission(
+ name='Test permission',
constraints={'pk': instance1.pk},
actions=['delete']
)
@@ -544,6 +557,7 @@ class ViewTestCases:
# Add model-level permission
obj_perm = ObjectPermission(
+ name='Test permission',
actions=['view']
)
obj_perm.save()
@@ -565,6 +579,7 @@ class ViewTestCases:
# Add object-level permission
obj_perm = ObjectPermission(
+ name='Test permission',
constraints={'pk': instance1.pk},
actions=['view']
)
@@ -614,6 +629,7 @@ class ViewTestCases:
# Assign non-constrained permission
obj_perm = ObjectPermission(
+ name='Test permission',
actions=['add'],
)
obj_perm.save()
@@ -637,6 +653,7 @@ class ViewTestCases:
# Assign constrained permission
obj_perm = ObjectPermission(
+ name='Test permission',
actions=['add'],
constraints={'pk': 0} # Dummy constraint to deny all
)
@@ -692,6 +709,7 @@ class ViewTestCases:
# Assign model-level permission
obj_perm = ObjectPermission(
+ name='Test permission',
actions=['add']
)
obj_perm.save()
@@ -714,6 +732,7 @@ class ViewTestCases:
# Assign constrained permission
obj_perm = ObjectPermission(
+ name='Test permission',
constraints={'pk': 0}, # Dummy permission to deny all
actions=['add']
)
@@ -770,6 +789,7 @@ class ViewTestCases:
# Assign model-level permission
obj_perm = ObjectPermission(
+ name='Test permission',
actions=['change']
)
obj_perm.save()
@@ -799,6 +819,7 @@ class ViewTestCases:
# Assign constrained permission
obj_perm = ObjectPermission(
+ name='Test permission',
constraints={attr_name: value},
actions=['change']
)
@@ -851,6 +872,7 @@ class ViewTestCases:
# Assign unconstrained permission
obj_perm = ObjectPermission(
+ name='Test permission',
actions=['delete']
)
obj_perm.save()
@@ -873,6 +895,7 @@ class ViewTestCases:
# Assign constrained permission
obj_perm = ObjectPermission(
+ name='Test permission',
constraints={'pk': 0}, # Dummy permission to deny all
actions=['delete']
)
@@ -930,6 +953,7 @@ class ViewTestCases:
# Assign model-level permission
obj_perm = ObjectPermission(
+ name='Test permission',
actions=['change']
)
obj_perm.save()
@@ -953,6 +977,7 @@ class ViewTestCases:
# Assign constrained permission
obj_perm = ObjectPermission(
+ name='Test permission',
constraints={'name__regex': '[^X]$'},
actions=['change']
)
diff --git a/netbox/utilities/tests/test_api.py b/netbox/utilities/tests/test_api.py
index 01d4ab8f3..2cc9accaa 100644
--- a/netbox/utilities/tests/test_api.py
+++ b/netbox/utilities/tests/test_api.py
@@ -131,7 +131,7 @@ class APIDocsTestCase(TestCase):
content_type = ContentType.objects.get_for_model(Site)
self.cf_text = CustomField(type=CustomFieldTypeChoices.TYPE_TEXT, name='test')
self.cf_text.save()
- self.cf_text.obj_type.set([content_type])
+ self.cf_text.content_types.set([content_type])
self.cf_text.save()
def test_api_docs(self):
diff --git a/netbox/utilities/tests/test_filters.py b/netbox/utilities/tests/test_filters.py
index f70d7e1db..56eaabd4c 100644
--- a/netbox/utilities/tests/test_filters.py
+++ b/netbox/utilities/tests/test_filters.py
@@ -23,7 +23,8 @@ class TreeNodeMultipleChoiceFilterTest(TestCase):
class SiteFilterSet(django_filters.FilterSet):
region = TreeNodeMultipleChoiceFilter(
queryset=Region.objects.all(),
- field_name='region__in',
+ field_name='region',
+ lookup_expr='in',
to_field_name='slug',
)
diff --git a/netbox/utilities/utils.py b/netbox/utilities/utils.py
index 81baadb7a..d76b469b2 100644
--- a/netbox/utilities/utils.py
+++ b/netbox/utilities/utils.py
@@ -1,9 +1,11 @@
import datetime
import json
from collections import OrderedDict
+from itertools import count, groupby
from django.core.serializers import serialize
from django.db.models import Count, OuterRef, Subquery
+from django.db.models.functions import Coalesce
from jinja2 import Environment
from dcim.choices import CableLengthUnitChoices
@@ -64,7 +66,7 @@ def dynamic_import(name):
return mod
-def get_subquery(model, field):
+def count_related(model, field):
"""
Return a Subquery suitable for annotating a child object count.
"""
@@ -78,7 +80,7 @@ def get_subquery(model, field):
).values('c')
)
- return subquery
+ return Coalesce(subquery, 0)
def serialize_object(obj, extra=None, exclude=None):
@@ -91,11 +93,9 @@ def serialize_object(obj, extra=None, exclude=None):
json_str = serialize('json', [obj])
data = json.loads(json_str)[0]['fields']
- # Include any custom fields
- if hasattr(obj, 'get_custom_fields'):
- data['custom_fields'] = {
- field: str(value) for field, value in obj.cf.items()
- }
+ # Include custom_field_data as "custom_fields"
+ if hasattr(obj, 'custom_field_data'):
+ data['custom_fields'] = data.pop('custom_field_data')
# Include any tags. Check for tags cached on the instance; fall back to using the manager.
if is_taggable(obj):
@@ -284,6 +284,16 @@ def curry(_curried_func, *args, **kwargs):
return _curried
+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)
+
+
#
# Fake request object
#
diff --git a/netbox/utilities/validators.py b/netbox/utilities/validators.py
index 517a567a9..b087b0867 100644
--- a/netbox/utilities/validators.py
+++ b/netbox/utilities/validators.py
@@ -1,6 +1,7 @@
import re
from django.conf import settings
+from django.core.exceptions import ValidationError
from django.core.validators import _lazy_re_compile, BaseValidator, URLValidator
@@ -29,3 +30,14 @@ class ExclusionValidator(BaseValidator):
def compare(self, a, b):
return a in b
+
+
+def validate_regex(value):
+ """
+ Checks that the value is a valid regular expression. (Don't confuse this with RegexValidator, which *uses* a regex
+ to validate a value.)
+ """
+ try:
+ re.compile(value)
+ except re.error:
+ raise ValidationError(f"{value} is not a valid regular expression.")
diff --git a/netbox/utilities/views.py b/netbox/utilities/views.py
index df8982755..c291a3cf2 100644
--- a/netbox/utilities/views.py
+++ b/netbox/utilities/views.py
@@ -1,47 +1,14 @@
-import logging
-import platform
-import re
-import sys
-from copy import deepcopy
-
-from django.conf import settings
-from django.contrib import messages
-from django.contrib.auth.decorators import login_required
-from django.contrib.contenttypes.models import ContentType
from django.contrib.auth.mixins import AccessMixin
-from django.core.exceptions import FieldDoesNotExist, ImproperlyConfigured, ObjectDoesNotExist, ValidationError
-from django.db import transaction, IntegrityError
-from django.db.models import ManyToManyField, ProtectedError
-from django.forms import Form, ModelMultipleChoiceField, MultipleHiddenInput, Textarea
-from django.http import HttpResponse, HttpResponseServerError, JsonResponse
-from django.shortcuts import get_object_or_404, redirect, render
-from django.template import loader
-from django.template.exceptions import TemplateDoesNotExist
+from django.core.exceptions import ImproperlyConfigured
from django.urls import reverse
from django.urls.exceptions import NoReverseMatch
-from django.utils.decorators import method_decorator
-from django.utils.html import escape
from django.utils.http import is_safe_url
-from django.utils.safestring import mark_safe
-from django.views.decorators.csrf import requires_csrf_token
-from django.views.defaults import ERROR_500_TEMPLATE_NAME
-from django.views.generic import View
-from django_tables2 import RequestConfig
-from rest_framework import status
-from extras.models import CustomField, CustomFieldValue, ExportTemplate
-from extras.querysets import CustomFieldQueryset
-from utilities.exceptions import AbortTransaction
-from utilities.forms import BootstrapMixin, BulkRenameForm, CSVDataField, TableConfigForm, restrict_form_fields
-from utilities.permissions import get_permission_for_model, resolve_permission
-from utilities.utils import csv_format, normalize_querydict, prepare_cloned_fields
-from .error_handlers import handle_protectederror
-from .forms import ConfirmationForm, ImportForm
-from .paginator import EnhancedPaginator, get_paginate_count
+from .permissions import resolve_permission
#
-# Mixins
+# View Mixins
#
class ContentTypePermissionRequiredMixin(AccessMixin):
@@ -156,1290 +123,3 @@ class GetReturnURLMixin:
# If all else fails, return home. Ideally this should never happen.
return reverse('home')
-
-
-#
-# Generic views
-#
-
-class ObjectView(ObjectPermissionRequiredMixin, View):
- """
- Retrieve a single object for display.
-
- queryset: The base queryset for retrieving the object.
- """
- queryset = None
-
- def get_required_permission(self):
- return get_permission_for_model(self.queryset.model, 'view')
-
- def get_template_name(self):
- """
- Return self.template_name if set. Otherwise, resolve the template path by model app_label and name.
- """
- if hasattr(self, 'template_name'):
- return self.template_name
- model_opts = self.queryset.model._meta
- return f'{model_opts.app_label}/{model_opts.model_name}.html'
-
- def get(self, request, pk):
- """
- Generic GET handler for accessing an object by PK
- """
- instance = get_object_or_404(self.queryset, pk=pk)
-
- return render(request, self.get_template_name(), {
- 'instance': instance,
- })
-
-
-class ObjectListView(ObjectPermissionRequiredMixin, View):
- """
- List a series of objects.
-
- queryset: The queryset of objects to display
- filter: A django-filter FilterSet that is applied to the queryset
- filter_form: The form used to render filter options
- table: The django-tables2 Table used to render the objects list
- template_name: The name of the template
- """
- queryset = None
- filterset = None
- filterset_form = None
- table = None
- template_name = 'utilities/obj_list.html'
- action_buttons = ('add', 'import', 'export')
-
- def get_required_permission(self):
- return get_permission_for_model(self.queryset.model, 'view')
-
- def queryset_to_yaml(self):
- """
- Export the queryset of objects as concatenated YAML documents.
- """
- yaml_data = [obj.to_yaml() for obj in self.queryset]
-
- return '---\n'.join(yaml_data)
-
- def queryset_to_csv(self):
- """
- Export the queryset of objects as comma-separated value (CSV), using the model's to_csv() method.
- """
- csv_data = []
- custom_fields = []
-
- # Start with the column headers
- headers = self.queryset.model.csv_headers.copy()
-
- # Add custom field headers, if any
- if hasattr(self.queryset.model, 'get_custom_fields'):
- for custom_field in self.queryset.model().get_custom_fields():
- headers.append(custom_field.name)
- custom_fields.append(custom_field.name)
-
- csv_data.append(','.join(headers))
-
- # Iterate through the queryset appending each object
- for obj in self.queryset:
- data = obj.to_csv()
-
- for custom_field in custom_fields:
- data += (obj.cf.get(custom_field, ''),)
-
- csv_data.append(csv_format(data))
-
- return '\n'.join(csv_data)
-
- def get(self, request):
-
- model = self.queryset.model
- content_type = ContentType.objects.get_for_model(model)
-
- if self.filterset:
- self.queryset = self.filterset(request.GET, self.queryset).qs
-
- # If this type of object has one or more custom fields, prefetch any relevant custom field values
- custom_fields = CustomField.objects.filter(
- obj_type=ContentType.objects.get_for_model(model)
- ).prefetch_related('choices')
- if custom_fields:
- self.queryset = self.queryset.prefetch_related('custom_field_values')
-
- # Check for export template rendering
- if request.GET.get('export'):
- et = get_object_or_404(ExportTemplate, content_type=content_type, name=request.GET.get('export'))
- queryset = CustomFieldQueryset(self.queryset, custom_fields) if custom_fields else self.queryset
- try:
- return et.render_to_response(queryset)
- except Exception as e:
- messages.error(
- request,
- "There was an error rendering the selected export template ({}): {}".format(
- et.name, e
- )
- )
-
- # Check for YAML export support
- elif 'export' in request.GET and hasattr(model, 'to_yaml'):
- response = HttpResponse(self.queryset_to_yaml(), content_type='text/yaml')
- filename = 'netbox_{}.yaml'.format(self.queryset.model._meta.verbose_name_plural)
- response['Content-Disposition'] = 'attachment; filename="{}"'.format(filename)
- return response
-
- # Fall back to built-in CSV formatting if export requested but no template specified
- elif 'export' in request.GET and hasattr(model, 'to_csv'):
- response = HttpResponse(self.queryset_to_csv(), content_type='text/csv')
- filename = 'netbox_{}.csv'.format(self.queryset.model._meta.verbose_name_plural)
- response['Content-Disposition'] = 'attachment; filename="{}"'.format(filename)
- return response
-
- # Provide a hook to tweak the queryset based on the request immediately prior to rendering the object list
- self.queryset = self.alter_queryset(request)
-
- # Compile a dictionary indicating which permissions are available to the current user for this model
- permissions = {}
- for action in ('add', 'change', 'delete', 'view'):
- perm_name = get_permission_for_model(model, action)
- permissions[action] = request.user.has_perm(perm_name)
-
- # Construct the table based on the user's permissions
- if request.user.is_authenticated:
- columns = request.user.config.get(f"tables.{self.table.__name__}.columns")
- else:
- columns = None
- table = self.table(self.queryset, columns=columns)
- if 'pk' in table.base_columns and (permissions['change'] or permissions['delete']):
- table.columns.show('pk')
-
- # Apply the request context
- paginate = {
- 'paginator_class': EnhancedPaginator,
- 'per_page': get_paginate_count(request)
- }
- RequestConfig(request, paginate).configure(table)
-
- context = {
- 'content_type': content_type,
- 'table': table,
- 'permissions': permissions,
- 'action_buttons': self.action_buttons,
- 'table_config_form': TableConfigForm(table=table),
- 'filter_form': self.filterset_form(request.GET, label_suffix='') if self.filterset_form else None,
- }
- context.update(self.extra_context())
-
- return render(request, self.template_name, context)
-
- @method_decorator(login_required)
- def post(self, request):
-
- # Update the user's table configuration
- table = self.table(self.queryset)
- form = TableConfigForm(table=table, data=request.POST)
- preference_name = f"tables.{self.table.__name__}.columns"
-
- if form.is_valid():
- if 'set' in request.POST:
- request.user.config.set(preference_name, form.cleaned_data['columns'], commit=True)
- elif 'clear' in request.POST:
- request.user.config.clear(preference_name, commit=True)
- messages.success(request, "Your preferences have been updated.")
-
- return redirect(request.get_full_path())
-
- def alter_queryset(self, request):
- # .all() is necessary to avoid caching queries
- return self.queryset.all()
-
- def extra_context(self):
- return {}
-
-
-class ObjectEditView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
- """
- Create or edit a single object.
-
- queryset: The base queryset for the object being modified
- model_form: The form used to create or edit the object
- template_name: The name of the template
- """
- queryset = None
- model_form = None
- template_name = 'utilities/obj_edit.html'
-
- def get_required_permission(self):
- # self._permission_action is set by dispatch() to either "add" or "change" depending on whether
- # we are modifying an existing object or creating a new one.
- return get_permission_for_model(self.queryset.model, self._permission_action)
-
- def get_object(self, kwargs):
- # Look up an existing object by slug or PK, if provided.
- if 'slug' in kwargs:
- return get_object_or_404(self.queryset, slug=kwargs['slug'])
- elif 'pk' in kwargs:
- return get_object_or_404(self.queryset, pk=kwargs['pk'])
- # Otherwise, return a new instance.
- return self.queryset.model()
-
- def alter_obj(self, obj, request, url_args, url_kwargs):
- # Allow views to add extra info to an object before it is processed. For example, a parent object can be defined
- # given some parameter from the request URL.
- return obj
-
- def dispatch(self, request, *args, **kwargs):
- # Determine required permission based on whether we are editing an existing object
- self._permission_action = 'change' if kwargs else 'add'
-
- return super().dispatch(request, *args, **kwargs)
-
- def get(self, request, *args, **kwargs):
- obj = self.alter_obj(self.get_object(kwargs), request, args, kwargs)
-
- initial_data = normalize_querydict(request.GET)
- form = self.model_form(instance=obj, initial=initial_data)
- restrict_form_fields(form, request.user)
-
- return render(request, self.template_name, {
- 'obj': obj,
- 'obj_type': self.queryset.model._meta.verbose_name,
- 'form': form,
- 'return_url': self.get_return_url(request, obj),
- })
-
- def post(self, request, *args, **kwargs):
- logger = logging.getLogger('netbox.views.ObjectEditView')
- obj = self.alter_obj(self.get_object(kwargs), request, args, kwargs)
- form = self.model_form(
- data=request.POST,
- files=request.FILES,
- instance=obj
- )
- restrict_form_fields(form, request.user)
-
- if form.is_valid():
- logger.debug("Form validation was successful")
-
- try:
- with transaction.atomic():
- object_created = form.instance.pk is None
- obj = form.save()
-
- # Check that the new object conforms with any assigned object-level permissions
- self.queryset.get(pk=obj.pk)
-
- msg = '{} {}'.format(
- 'Created' if object_created else 'Modified',
- self.queryset.model._meta.verbose_name
- )
- logger.info(f"{msg} {obj} (PK: {obj.pk})")
- if hasattr(obj, 'get_absolute_url'):
- msg = '{} {} '.format(msg, obj.get_absolute_url(), escape(obj))
- else:
- msg = '{} {}'.format(msg, escape(obj))
- messages.success(request, mark_safe(msg))
-
- if '_addanother' in request.POST:
-
- # If the object has clone_fields, pre-populate a new instance of the form
- if hasattr(obj, 'clone_fields'):
- url = '{}?{}'.format(request.path, prepare_cloned_fields(obj))
- return redirect(url)
-
- return redirect(request.get_full_path())
-
- return_url = form.cleaned_data.get('return_url')
- if return_url is not None and is_safe_url(url=return_url, allowed_hosts=request.get_host()):
- return redirect(return_url)
- else:
- return redirect(self.get_return_url(request, obj))
-
- except ObjectDoesNotExist:
- msg = "Object save failed due to object-level permissions violation"
- logger.debug(msg)
- form.add_error(None, msg)
-
- else:
- logger.debug("Form validation failed")
-
- return render(request, self.template_name, {
- 'obj': obj,
- 'obj_type': self.queryset.model._meta.verbose_name,
- 'form': form,
- 'return_url': self.get_return_url(request, obj),
- })
-
-
-class ObjectDeleteView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
- """
- Delete a single object.
-
- queryset: The base queryset for the object being deleted
- template_name: The name of the template
- """
- queryset = None
- template_name = 'utilities/obj_delete.html'
-
- def get_required_permission(self):
- return get_permission_for_model(self.queryset.model, 'delete')
-
- def get_object(self, kwargs):
- # Look up object by slug if one has been provided. Otherwise, use PK.
- if 'slug' in kwargs:
- return get_object_or_404(self.queryset, slug=kwargs['slug'])
- else:
- return get_object_or_404(self.queryset, pk=kwargs['pk'])
-
- def get(self, request, **kwargs):
- obj = self.get_object(kwargs)
- form = ConfirmationForm(initial=request.GET)
-
- return render(request, self.template_name, {
- 'obj': obj,
- 'form': form,
- 'obj_type': self.queryset.model._meta.verbose_name,
- 'return_url': self.get_return_url(request, obj),
- })
-
- def post(self, request, **kwargs):
- logger = logging.getLogger('netbox.views.ObjectDeleteView')
- obj = self.get_object(kwargs)
- form = ConfirmationForm(request.POST)
-
- if form.is_valid():
- logger.debug("Form validation was successful")
-
- try:
- obj.delete()
- except ProtectedError as e:
- logger.info("Caught ProtectedError while attempting to delete object")
- handle_protectederror([obj], request, e)
- return redirect(obj.get_absolute_url())
-
- msg = 'Deleted {} {}'.format(self.queryset.model._meta.verbose_name, obj)
- logger.info(msg)
- messages.success(request, msg)
-
- return_url = form.cleaned_data.get('return_url')
- if return_url is not None and is_safe_url(url=return_url, allowed_hosts=request.get_host()):
- return redirect(return_url)
- else:
- return redirect(self.get_return_url(request, obj))
-
- else:
- logger.debug("Form validation failed")
-
- return render(request, self.template_name, {
- 'obj': obj,
- 'form': form,
- 'obj_type': self.queryset.model._meta.verbose_name,
- 'return_url': self.get_return_url(request, obj),
- })
-
-
-class BulkCreateView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
- """
- Create new objects in bulk.
-
- queryset: Base queryset for the objects being created
- form: Form class which provides the `pattern` field
- model_form: The ModelForm used to create individual objects
- pattern_target: Name of the field to be evaluated as a pattern (if any)
- template_name: The name of the template
- """
- queryset = None
- form = None
- model_form = None
- pattern_target = ''
- template_name = None
-
- def get_required_permission(self):
- return get_permission_for_model(self.queryset.model, 'add')
-
- def get(self, request):
- # Set initial values for visible form fields from query args
- initial = {}
- for field in getattr(self.model_form._meta, 'fields', []):
- if request.GET.get(field):
- initial[field] = request.GET[field]
-
- form = self.form()
- model_form = self.model_form(initial=initial)
-
- return render(request, self.template_name, {
- 'obj_type': self.model_form._meta.model._meta.verbose_name,
- 'form': form,
- 'model_form': model_form,
- 'return_url': self.get_return_url(request),
- })
-
- def post(self, request):
- logger = logging.getLogger('netbox.views.BulkCreateView')
- model = self.queryset.model
- form = self.form(request.POST)
- model_form = self.model_form(request.POST)
-
- if form.is_valid():
- logger.debug("Form validation was successful")
- pattern = form.cleaned_data['pattern']
- new_objs = []
-
- try:
- with transaction.atomic():
-
- # Create objects from the expanded. Abort the transaction on the first validation error.
- for value in pattern:
-
- # Reinstantiate the model form each time to avoid overwriting the same instance. Use a mutable
- # copy of the POST QueryDict so that we can update the target field value.
- model_form = self.model_form(request.POST.copy())
- model_form.data[self.pattern_target] = value
-
- # Validate each new object independently.
- if model_form.is_valid():
- obj = model_form.save()
- logger.debug(f"Created {obj} (PK: {obj.pk})")
- new_objs.append(obj)
- else:
- # Copy any errors on the pattern target field to the pattern form.
- errors = model_form.errors.as_data()
- if errors.get(self.pattern_target):
- form.add_error('pattern', errors[self.pattern_target])
- # Raise an IntegrityError to break the for loop and abort the transaction.
- raise IntegrityError()
-
- # Enforce object-level permissions
- if self.queryset.filter(pk__in=[obj.pk for obj in new_objs]).count() != len(new_objs):
- raise ObjectDoesNotExist
-
- # If we make it to this point, validation has succeeded on all new objects.
- msg = "Added {} {}".format(len(new_objs), model._meta.verbose_name_plural)
- logger.info(msg)
- messages.success(request, msg)
-
- if '_addanother' in request.POST:
- return redirect(request.path)
- return redirect(self.get_return_url(request))
-
- except IntegrityError:
- pass
-
- except ObjectDoesNotExist:
- msg = "Object creation failed due to object-level permissions violation"
- logger.debug(msg)
- form.add_error(None, msg)
-
- else:
- logger.debug("Form validation failed")
-
- return render(request, self.template_name, {
- 'form': form,
- 'model_form': model_form,
- 'obj_type': model._meta.verbose_name,
- 'return_url': self.get_return_url(request),
- })
-
-
-class ObjectImportView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
- """
- Import a single object (YAML or JSON format).
-
- queryset: Base queryset for the objects being created
- model_form: The ModelForm used to create individual objects
- related_object_forms: A dictionary mapping of forms to be used for the creation of related (child) objects
- template_name: The name of the template
- """
- queryset = None
- model_form = None
- related_object_forms = dict()
- template_name = 'utilities/obj_import.html'
-
- def get_required_permission(self):
- return get_permission_for_model(self.queryset.model, 'add')
-
- def get(self, request):
- form = ImportForm()
-
- return render(request, self.template_name, {
- 'form': form,
- 'obj_type': self.queryset.model._meta.verbose_name,
- 'return_url': self.get_return_url(request),
- })
-
- def post(self, request):
- logger = logging.getLogger('netbox.views.ObjectImportView')
- form = ImportForm(request.POST)
-
- if form.is_valid():
- logger.debug("Import form validation was successful")
-
- # Initialize model form
- data = form.cleaned_data['data']
- model_form = self.model_form(data)
- restrict_form_fields(model_form, request.user)
-
- # Assign default values for any fields which were not specified. We have to do this manually because passing
- # 'initial=' to the form on initialization merely sets default values for the widgets. Since widgets are not
- # used for YAML/JSON import, we first bind the imported data normally, then update the form's data with the
- # applicable field defaults as needed prior to form validation.
- for field_name, field in model_form.fields.items():
- if field_name not in data and hasattr(field, 'initial'):
- model_form.data[field_name] = field.initial
-
- if model_form.is_valid():
-
- try:
- with transaction.atomic():
-
- # Save the primary object
- obj = model_form.save()
-
- # Enforce object-level permissions
- self.queryset.get(pk=obj.pk)
-
- logger.debug(f"Created {obj} (PK: {obj.pk})")
-
- # Iterate through the related object forms (if any), validating and saving each instance.
- for field_name, related_object_form in self.related_object_forms.items():
- logger.debug("Processing form for related objects: {related_object_form}")
-
- related_obj_pks = []
- for i, rel_obj_data in enumerate(data.get(field_name, list())):
-
- f = related_object_form(obj, rel_obj_data)
-
- for subfield_name, field in f.fields.items():
- if subfield_name not in rel_obj_data and hasattr(field, 'initial'):
- f.data[subfield_name] = field.initial
-
- if f.is_valid():
- related_obj = f.save()
- related_obj_pks.append(related_obj.pk)
- else:
- # Replicate errors on the related object form to the primary form for display
- for subfield_name, errors in f.errors.items():
- for err in errors:
- err_msg = "{}[{}] {}: {}".format(field_name, i, subfield_name, err)
- model_form.add_error(None, err_msg)
- raise AbortTransaction()
-
- # Enforce object-level permissions on related objects
- model = related_object_form.Meta.model
- if model.objects.filter(pk__in=related_obj_pks).count() != len(related_obj_pks):
- raise ObjectDoesNotExist
-
- except AbortTransaction:
- pass
-
- except ObjectDoesNotExist:
- msg = "Object creation failed due to object-level permissions violation"
- logger.debug(msg)
- form.add_error(None, msg)
-
- if not model_form.errors:
- logger.info(f"Import object {obj} (PK: {obj.pk})")
- messages.success(request, mark_safe('Imported object: {} '.format(
- obj.get_absolute_url(), obj
- )))
-
- if '_addanother' in request.POST:
- return redirect(request.get_full_path())
-
- return_url = form.cleaned_data.get('return_url')
- if return_url is not None and is_safe_url(url=return_url, allowed_hosts=request.get_host()):
- return redirect(return_url)
- else:
- return redirect(self.get_return_url(request, obj))
-
- else:
- logger.debug("Model form validation failed")
-
- # Replicate model form errors for display
- for field, errors in model_form.errors.items():
- for err in errors:
- if field == '__all__':
- form.add_error(None, err)
- else:
- form.add_error(None, "{}: {}".format(field, err))
-
- else:
- logger.debug("Import form validation failed")
-
- return render(request, self.template_name, {
- 'form': form,
- 'obj_type': self.queryset.model._meta.verbose_name,
- 'return_url': self.get_return_url(request),
- })
-
-
-class BulkImportView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
- """
- Import objects in bulk (CSV format).
-
- queryset: Base queryset for the model
- model_form: The form used to create each imported object
- table: The django-tables2 Table used to render the list of imported objects
- template_name: The name of the template
- widget_attrs: A dict of attributes to apply to the import widget (e.g. to require a session key)
- """
- queryset = None
- model_form = None
- table = None
- template_name = 'utilities/obj_bulk_import.html'
- widget_attrs = {}
-
- def _import_form(self, *args, **kwargs):
-
- class ImportForm(BootstrapMixin, Form):
- csv = CSVDataField(
- from_form=self.model_form,
- widget=Textarea(attrs=self.widget_attrs)
- )
-
- return ImportForm(*args, **kwargs)
-
- def _save_obj(self, obj_form, request):
- """
- Provide a hook to modify the object immediately before saving it (e.g. to encrypt secret data).
- """
- return obj_form.save()
-
- def get_required_permission(self):
- return get_permission_for_model(self.queryset.model, 'add')
-
- def get(self, request):
-
- return render(request, self.template_name, {
- 'form': self._import_form(),
- 'fields': self.model_form().fields,
- 'obj_type': self.model_form._meta.model._meta.verbose_name,
- 'return_url': self.get_return_url(request),
- })
-
- def post(self, request):
- logger = logging.getLogger('netbox.views.BulkImportView')
- new_objs = []
- form = self._import_form(request.POST)
-
- if form.is_valid():
- logger.debug("Form validation was successful")
-
- try:
- # Iterate through CSV data and bind each row to a new model form instance.
- with transaction.atomic():
- headers, records = form.cleaned_data['csv']
- for row, data in enumerate(records, start=1):
- obj_form = self.model_form(data, headers=headers)
- restrict_form_fields(obj_form, request.user)
-
- if obj_form.is_valid():
- obj = self._save_obj(obj_form, request)
- new_objs.append(obj)
- else:
- for field, err in obj_form.errors.items():
- form.add_error('csv', "Row {} {}: {}".format(row, field, err[0]))
- raise ValidationError("")
-
- # Enforce object-level permissions
- if self.queryset.filter(pk__in=[obj.pk for obj in new_objs]).count() != len(new_objs):
- raise ObjectDoesNotExist
-
- # Compile a table containing the imported objects
- obj_table = self.table(new_objs)
-
- if new_objs:
- msg = 'Imported {} {}'.format(len(new_objs), new_objs[0]._meta.verbose_name_plural)
- logger.info(msg)
- messages.success(request, msg)
-
- return render(request, "import_success.html", {
- 'table': obj_table,
- 'return_url': self.get_return_url(request),
- })
-
- except ValidationError:
- pass
-
- except ObjectDoesNotExist:
- msg = "Object import failed due to object-level permissions violation"
- logger.debug(msg)
- form.add_error(None, msg)
-
- else:
- logger.debug("Form validation failed")
-
- return render(request, self.template_name, {
- 'form': form,
- 'fields': self.model_form().fields,
- 'obj_type': self.model_form._meta.model._meta.verbose_name,
- 'return_url': self.get_return_url(request),
- })
-
-
-class BulkEditView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
- """
- Edit objects in bulk.
-
- queryset: Custom queryset to use when retrieving objects (e.g. to select related objects)
- filter: FilterSet to apply when deleting by QuerySet
- table: The table used to display devices being edited
- form: The form class used to edit objects in bulk
- template_name: The name of the template
- """
- queryset = None
- filterset = None
- table = None
- form = None
- template_name = 'utilities/obj_bulk_edit.html'
-
- def get_required_permission(self):
- return get_permission_for_model(self.queryset.model, 'change')
-
- def get(self, request):
- return redirect(self.get_return_url(request))
-
- def post(self, request, **kwargs):
- logger = logging.getLogger('netbox.views.BulkEditView')
- model = self.queryset.model
-
- # If we are editing *all* objects in the queryset, replace the PK list with all matched objects.
- if request.POST.get('_all') and self.filterset is not None:
- pk_list = [
- obj.pk for obj in self.filterset(request.GET, self.queryset.only('pk')).qs
- ]
- else:
- pk_list = request.POST.getlist('pk')
-
- if '_apply' in request.POST:
- form = self.form(model, request.POST)
- restrict_form_fields(form, request.user)
-
- if form.is_valid():
- logger.debug("Form validation was successful")
- custom_fields = form.custom_fields if hasattr(form, 'custom_fields') else []
- standard_fields = [
- field for field in form.fields if field not in custom_fields + ['pk']
- ]
- nullified_fields = request.POST.getlist('_nullify')
-
- try:
-
- with transaction.atomic():
-
- updated_objects = []
- for obj in self.queryset.filter(pk__in=form.cleaned_data['pk']):
-
- # Update standard fields. If a field is listed in _nullify, delete its value.
- for name in standard_fields:
-
- try:
- model_field = model._meta.get_field(name)
- except FieldDoesNotExist:
- # This form field is used to modify a field rather than set its value directly
- model_field = None
-
- # Handle nullification
- if name in form.nullable_fields and name in nullified_fields:
- if isinstance(model_field, ManyToManyField):
- getattr(obj, name).set([])
- else:
- setattr(obj, name, None if model_field.null else '')
-
- # ManyToManyFields
- elif isinstance(model_field, ManyToManyField):
- if form.cleaned_data[name]:
- getattr(obj, name).set(form.cleaned_data[name])
- # Normal fields
- elif form.cleaned_data[name] not in (None, ''):
- setattr(obj, name, form.cleaned_data[name])
-
- # Cache custom fields on instance prior to save()
- if custom_fields:
- obj._cf = {
- name: form.cleaned_data[name] for name in custom_fields
- }
-
- obj.full_clean()
- obj.save()
- updated_objects.append(obj)
- logger.debug(f"Saved {obj} (PK: {obj.pk})")
-
- # Update custom fields
- obj_type = ContentType.objects.get_for_model(model)
- for name in custom_fields:
- field = form.fields[name].model
- if name in form.nullable_fields and name in nullified_fields:
- CustomFieldValue.objects.filter(
- field=field, obj_type=obj_type, obj_id=obj.pk
- ).delete()
- elif form.cleaned_data[name] not in [None, '']:
- try:
- cfv = CustomFieldValue.objects.get(
- field=field, obj_type=obj_type, obj_id=obj.pk
- )
- except CustomFieldValue.DoesNotExist:
- cfv = CustomFieldValue(
- field=field, obj_type=obj_type, obj_id=obj.pk
- )
- cfv.value = form.cleaned_data[name]
- cfv.save()
- logger.debug(f"Saved custom fields for {obj} (PK: {obj.pk})")
-
- # Add/remove tags
- if form.cleaned_data.get('add_tags', None):
- obj.tags.add(*form.cleaned_data['add_tags'])
- if form.cleaned_data.get('remove_tags', None):
- obj.tags.remove(*form.cleaned_data['remove_tags'])
-
- # Enforce object-level permissions
- if self.queryset.filter(pk__in=[obj.pk for obj in updated_objects]).count() != len(updated_objects):
- raise ObjectDoesNotExist
-
- if updated_objects:
- msg = 'Updated {} {}'.format(len(updated_objects), model._meta.verbose_name_plural)
- logger.info(msg)
- messages.success(self.request, msg)
-
- return redirect(self.get_return_url(request))
-
- except ValidationError as e:
- messages.error(self.request, "{} failed validation: {}".format(obj, e))
-
- except ObjectDoesNotExist:
- msg = "Object update failed due to object-level permissions violation"
- logger.debug(msg)
- form.add_error(None, msg)
-
- else:
- logger.debug("Form validation failed")
-
- else:
- # Include the PK list as initial data for the form
- initial_data = {'pk': pk_list}
-
- # Check for other contextual data needed for the form. We avoid passing all of request.GET because the
- # filter values will conflict with the bulk edit form fields.
- # TODO: Find a better way to accomplish this
- if 'device' in request.GET:
- initial_data['device'] = request.GET.get('device')
- elif 'device_type' in request.GET:
- initial_data['device_type'] = request.GET.get('device_type')
-
- form = self.form(model, initial=initial_data)
- restrict_form_fields(form, request.user)
-
- # Retrieve objects being edited
- table = self.table(self.queryset.filter(pk__in=pk_list), orderable=False)
- if not table.rows:
- messages.warning(request, "No {} were selected.".format(model._meta.verbose_name_plural))
- return redirect(self.get_return_url(request))
-
- return render(request, self.template_name, {
- 'form': form,
- 'table': table,
- 'obj_type_plural': model._meta.verbose_name_plural,
- 'return_url': self.get_return_url(request),
- })
-
-
-class BulkRenameView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
- """
- An extendable view for renaming objects in bulk.
- """
- queryset = None
- template_name = 'utilities/obj_bulk_rename.html'
-
- def __init__(self, *args, **kwargs):
- super().__init__(*args, **kwargs)
-
- # Create a new Form class from BulkRenameForm
- class _Form(BulkRenameForm):
- pk = ModelMultipleChoiceField(
- queryset=self.queryset,
- widget=MultipleHiddenInput()
- )
-
- self.form = _Form
-
- def get_required_permission(self):
- return get_permission_for_model(self.queryset.model, 'change')
-
- def post(self, request):
- logger = logging.getLogger('netbox.views.BulkRenameView')
-
- if '_preview' in request.POST or '_apply' in request.POST:
- form = self.form(request.POST, initial={'pk': request.POST.getlist('pk')})
- selected_objects = self.queryset.filter(pk__in=form.initial['pk'])
-
- if form.is_valid():
- try:
- with transaction.atomic():
- renamed_pks = []
- for obj in selected_objects:
- find = form.cleaned_data['find']
- replace = form.cleaned_data['replace']
- if form.cleaned_data['use_regex']:
- try:
- obj.new_name = re.sub(find, replace, obj.name)
- # Catch regex group reference errors
- except re.error:
- obj.new_name = obj.name
- else:
- obj.new_name = obj.name.replace(find, replace)
- renamed_pks.append(obj.pk)
-
- if '_apply' in request.POST:
- for obj in selected_objects:
- obj.name = obj.new_name
- obj.save()
-
- # Enforce constrained permissions
- if self.queryset.filter(pk__in=renamed_pks).count() != len(selected_objects):
- raise ObjectDoesNotExist
-
- messages.success(request, "Renamed {} {}".format(
- len(selected_objects),
- self.queryset.model._meta.verbose_name_plural
- ))
- return redirect(self.get_return_url(request))
-
- except ObjectDoesNotExist:
- msg = "Object update failed due to object-level permissions violation"
- logger.debug(msg)
- form.add_error(None, msg)
-
- else:
- form = self.form(initial={'pk': request.POST.getlist('pk')})
- selected_objects = self.queryset.filter(pk__in=form.initial['pk'])
-
- return render(request, self.template_name, {
- 'form': form,
- 'obj_type_plural': self.queryset.model._meta.verbose_name_plural,
- 'selected_objects': selected_objects,
- 'return_url': self.get_return_url(request),
- })
-
-
-class BulkDeleteView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
- """
- Delete objects in bulk.
-
- queryset: Custom queryset to use when retrieving objects (e.g. to select related objects)
- filter: FilterSet to apply when deleting by QuerySet
- table: The table used to display devices being deleted
- form: The form class used to delete objects in bulk
- template_name: The name of the template
- """
- queryset = None
- filterset = None
- table = None
- form = None
- template_name = 'utilities/obj_bulk_delete.html'
-
- def get_required_permission(self):
- return get_permission_for_model(self.queryset.model, 'delete')
-
- def get(self, request):
- return redirect(self.get_return_url(request))
-
- def post(self, request, **kwargs):
- logger = logging.getLogger('netbox.views.BulkDeleteView')
- model = self.queryset.model
-
- # Are we deleting *all* objects in the queryset or just a selected subset?
- if request.POST.get('_all'):
- if self.filterset is not None:
- pk_list = [obj.pk for obj in self.filterset(request.GET, model.objects.only('pk')).qs]
- else:
- pk_list = model.objects.values_list('pk', flat=True)
- else:
- pk_list = [int(pk) for pk in request.POST.getlist('pk')]
-
- form_cls = self.get_form()
-
- if '_confirm' in request.POST:
- form = form_cls(request.POST)
- if form.is_valid():
- logger.debug("Form validation was successful")
-
- # Delete objects
- queryset = self.queryset.filter(pk__in=pk_list)
- try:
- deleted_count = queryset.delete()[1][model._meta.label]
- except ProtectedError as e:
- logger.info("Caught ProtectedError while attempting to delete objects")
- handle_protectederror(queryset, request, e)
- return redirect(self.get_return_url(request))
-
- msg = 'Deleted {} {}'.format(deleted_count, model._meta.verbose_name_plural)
- logger.info(msg)
- messages.success(request, msg)
- return redirect(self.get_return_url(request))
-
- else:
- logger.debug("Form validation failed")
-
- else:
- form = form_cls(initial={
- 'pk': pk_list,
- 'return_url': self.get_return_url(request),
- })
-
- # Retrieve objects being deleted
- table = self.table(self.queryset.filter(pk__in=pk_list), orderable=False)
- if not table.rows:
- messages.warning(request, "No {} were selected for deletion.".format(model._meta.verbose_name_plural))
- return redirect(self.get_return_url(request))
-
- return render(request, self.template_name, {
- 'form': form,
- 'obj_type_plural': model._meta.verbose_name_plural,
- 'table': table,
- 'return_url': self.get_return_url(request),
- })
-
- def get_form(self):
- """
- Provide a standard bulk delete form if none has been specified for the view
- """
- class BulkDeleteForm(ConfirmationForm):
- pk = ModelMultipleChoiceField(queryset=self.queryset, widget=MultipleHiddenInput)
-
- if self.form:
- return self.form
-
- return BulkDeleteForm
-
-
-#
-# Device/VirtualMachine components
-#
-
-# TODO: Replace with BulkCreateView
-class ComponentCreateView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
- """
- Add one or more components (e.g. interfaces, console ports, etc.) to a Device or VirtualMachine.
- """
- queryset = None
- form = None
- model_form = None
- template_name = None
-
- def get_required_permission(self):
- return get_permission_for_model(self.queryset.model, 'add')
-
- def get(self, request):
-
- form = self.form(initial=request.GET)
-
- return render(request, self.template_name, {
- 'component_type': self.queryset.model._meta.verbose_name,
- 'form': form,
- 'return_url': self.get_return_url(request),
- })
-
- def post(self, request):
- logger = logging.getLogger('netbox.views.ComponentCreateView')
- form = self.form(request.POST, initial=request.GET)
-
- if form.is_valid():
-
- new_components = []
- data = deepcopy(request.POST)
-
- names = form.cleaned_data['name_pattern']
- labels = form.cleaned_data.get('label_pattern')
- for i, name in enumerate(names):
- label = labels[i] if labels else None
- # Initialize the individual component form
- data['name'] = name
- data['label'] = label
- if hasattr(form, 'get_iterative_data'):
- data.update(form.get_iterative_data(i))
- component_form = self.model_form(data)
-
- if component_form.is_valid():
- new_components.append(component_form)
- else:
- for field, errors in component_form.errors.as_data().items():
- # Assign errors on the child form's name/label field to name_pattern/label_pattern on the parent form
- if field == 'name':
- field = 'name_pattern'
- elif field == 'label':
- field = 'label_pattern'
- for e in errors:
- form.add_error(field, '{}: {}'.format(name, ', '.join(e)))
-
- if not form.errors:
-
- try:
-
- with transaction.atomic():
-
- # Create the new components
- new_objs = []
- for component_form in new_components:
- obj = component_form.save()
- new_objs.append(obj)
-
- # Enforce object-level permissions
- if self.queryset.filter(pk__in=[obj.pk for obj in new_objs]).count() != len(new_objs):
- raise ObjectDoesNotExist
-
- messages.success(request, "Added {} {}".format(
- len(new_components), self.queryset.model._meta.verbose_name_plural
- ))
- if '_addanother' in request.POST:
- return redirect(request.get_full_path())
- else:
- return redirect(self.get_return_url(request))
-
- except ObjectDoesNotExist:
- msg = "Component creation failed due to object-level permissions violation"
- logger.debug(msg)
- form.add_error(None, msg)
-
- return render(request, self.template_name, {
- 'component_type': self.queryset.model._meta.verbose_name,
- 'form': form,
- 'return_url': self.get_return_url(request),
- })
-
-
-class BulkComponentCreateView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
- """
- Add one or more components (e.g. interfaces, console ports, etc.) to a set of Devices or VirtualMachines.
- """
- parent_model = None
- parent_field = None
- form = None
- queryset = None
- model_form = None
- filterset = None
- table = None
- template_name = 'utilities/obj_bulk_add_component.html'
-
- def get_required_permission(self):
- return f'dcim.add_{self.queryset.model._meta.model_name}'
-
- def post(self, request):
- logger = logging.getLogger('netbox.views.BulkComponentCreateView')
- parent_model_name = self.parent_model._meta.verbose_name_plural
- model_name = self.queryset.model._meta.verbose_name_plural
-
- # Are we editing *all* objects in the queryset or just a selected subset?
- if request.POST.get('_all') and self.filterset is not None:
- pk_list = [obj.pk for obj in self.filterset(request.GET, self.parent_model.objects.only('pk')).qs]
- else:
- pk_list = [int(pk) for pk in request.POST.getlist('pk')]
-
- selected_objects = self.parent_model.objects.filter(pk__in=pk_list)
- if not selected_objects:
- messages.warning(request, "No {} were selected.".format(self.parent_model._meta.verbose_name_plural))
- return redirect(self.get_return_url(request))
- table = self.table(selected_objects)
-
- if '_create' in request.POST:
- form = self.form(request.POST)
-
- if form.is_valid():
- logger.debug("Form validation was successful")
-
- new_components = []
- data = deepcopy(form.cleaned_data)
-
- try:
- with transaction.atomic():
-
- for obj in data['pk']:
-
- names = data['name_pattern']
- labels = data['label_pattern'] if 'label_pattern' in data else None
- for i, name in enumerate(names):
- label = labels[i] if labels else None
-
- component_data = {
- self.parent_field: obj.pk,
- 'name': name,
- 'label': label
- }
- component_data.update(data)
- component_form = self.model_form(component_data)
- if component_form.is_valid():
- instance = component_form.save()
- logger.debug(f"Created {instance} on {instance.parent}")
- new_components.append(instance)
- else:
- for field, errors in component_form.errors.as_data().items():
- for e in errors:
- form.add_error(field, '{} {}: {}'.format(obj, name, ', '.join(e)))
-
- # Enforce object-level permissions
- if self.queryset.filter(pk__in=[obj.pk for obj in new_components]).count() != len(new_components):
- raise ObjectDoesNotExist
-
- except IntegrityError:
- pass
-
- except ObjectDoesNotExist:
- msg = "Component creation failed due to object-level permissions violation"
- logger.debug(msg)
- form.add_error(None, msg)
-
- if not form.errors:
- msg = "Added {} {} to {} {}.".format(
- len(new_components),
- model_name,
- len(form.cleaned_data['pk']),
- parent_model_name
- )
- logger.info(msg)
- messages.success(request, msg)
-
- return redirect(self.get_return_url(request))
-
- else:
- logger.debug("Form validation failed")
-
- else:
- form = self.form(initial={'pk': pk_list})
-
- return render(request, self.template_name, {
- 'form': form,
- 'parent_model_name': parent_model_name,
- 'model_name': model_name,
- 'table': table,
- 'return_url': self.get_return_url(request),
- })
-
-
-@requires_csrf_token
-def server_error(request, template_name=ERROR_500_TEMPLATE_NAME):
- """
- Custom 500 handler to provide additional context when rendering 500.html.
- """
- try:
- template = loader.get_template(template_name)
- except TemplateDoesNotExist:
- return HttpResponseServerError('Server Error (500) ', content_type='text/html')
- type_, error, traceback = sys.exc_info()
-
- return HttpResponseServerError(template.render({
- 'error': error,
- 'exception': str(type_),
- 'netbox_version': settings.VERSION,
- 'python_version': platform.python_version(),
- }))
-
-
-def rest_api_server_error(request, *args, **kwargs):
- """
- Handle exceptions and return a useful error message for REST API requests.
- """
- type_, error, traceback = sys.exc_info()
- data = {
- 'error': str(error),
- 'exception': type_.__name__,
- 'netbox_version': settings.VERSION,
- 'python_version': platform.python_version(),
- }
- return JsonResponse(data, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
diff --git a/netbox/virtualization/api/nested_serializers.py b/netbox/virtualization/api/nested_serializers.py
index de56e6e6a..7763f0ef4 100644
--- a/netbox/virtualization/api/nested_serializers.py
+++ b/netbox/virtualization/api/nested_serializers.py
@@ -1,7 +1,7 @@
from rest_framework import serializers
from dcim.models import Interface
-from utilities.api import WritableNestedSerializer
+from netbox.api import WritableNestedSerializer
from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine
__all__ = [
diff --git a/netbox/virtualization/api/serializers.py b/netbox/virtualization/api/serializers.py
index 711e1359e..518b7086c 100644
--- a/netbox/virtualization/api/serializers.py
+++ b/netbox/virtualization/api/serializers.py
@@ -7,8 +7,8 @@ from extras.api.customfields import CustomFieldModelSerializer
from extras.api.serializers import TaggedObjectSerializer
from ipam.api.nested_serializers import NestedIPAddressSerializer, NestedVLANSerializer
from ipam.models import VLAN
+from netbox.api import ChoiceField, SerializedPKRelatedField, ValidatedModelSerializer
from tenancy.api.nested_serializers import NestedTenantSerializer
-from utilities.api import ChoiceField, SerializedPKRelatedField, ValidatedModelSerializer
from virtualization.choices import *
from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface
from .nested_serializers import *
@@ -116,3 +116,16 @@ class VMInterfaceSerializer(TaggedObjectSerializer, ValidatedModelSerializer):
'id', 'url', 'virtual_machine', 'name', 'enabled', 'mtu', 'mac_address', 'description', 'mode',
'untagged_vlan', 'tagged_vlans', 'tags',
]
+
+ def validate(self, data):
+
+ # Validate many-to-many VLAN assignments
+ virtual_machine = self.instance.virtual_machine if self.instance else data.get('virtual_machine')
+ for vlan in data.get('tagged_vlans', []):
+ if vlan.site not in [virtual_machine.site, None]:
+ raise serializers.ValidationError({
+ 'tagged_vlans': f"VLAN {vlan} must belong to the same site as the interface's parent virtual "
+ f"machine, or it must be global."
+ })
+
+ return super().validate(data)
diff --git a/netbox/virtualization/api/urls.py b/netbox/virtualization/api/urls.py
index c40202a7d..d9df2fcfe 100644
--- a/netbox/virtualization/api/urls.py
+++ b/netbox/virtualization/api/urls.py
@@ -1,4 +1,4 @@
-from utilities.api import OrderedDefaultRouter
+from netbox.api import OrderedDefaultRouter
from . import views
diff --git a/netbox/virtualization/api/views.py b/netbox/virtualization/api/views.py
index 55393b110..ce5cb9f2c 100644
--- a/netbox/virtualization/api/views.py
+++ b/netbox/virtualization/api/views.py
@@ -1,15 +1,8 @@
-from django.db.models import Count
-from django.shortcuts import get_object_or_404
-from rest_framework.decorators import action
-from rest_framework.response import Response
from rest_framework.routers import APIRootView
from dcim.models import Device
-from extras.api.serializers import RenderedGraphSerializer
-from extras.api.views import ConfigContextQuerySetMixin, CustomFieldModelViewSet
-from extras.models import Graph
-from utilities.api import ModelViewSet
-from utilities.utils import get_subquery
+from extras.api.views import ConfigContextQuerySetMixin, CustomFieldModelViewSet, ModelViewSet
+from utilities.utils import count_related
from virtualization import filters
from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface
from . import serializers
@@ -29,16 +22,16 @@ class VirtualizationRootView(APIRootView):
class ClusterTypeViewSet(ModelViewSet):
queryset = ClusterType.objects.annotate(
- cluster_count=Count('clusters')
- ).order_by(*ClusterType._meta.ordering)
+ cluster_count=count_related(Cluster, 'type')
+ )
serializer_class = serializers.ClusterTypeSerializer
filterset_class = filters.ClusterTypeFilterSet
class ClusterGroupViewSet(ModelViewSet):
queryset = ClusterGroup.objects.annotate(
- cluster_count=Count('clusters')
- ).order_by(*ClusterGroup._meta.ordering)
+ cluster_count=count_related(Cluster, 'group')
+ )
serializer_class = serializers.ClusterGroupSerializer
filterset_class = filters.ClusterGroupFilterSet
@@ -47,9 +40,9 @@ class ClusterViewSet(CustomFieldModelViewSet):
queryset = Cluster.objects.prefetch_related(
'type', 'group', 'tenant', 'site', 'tags'
).annotate(
- device_count=get_subquery(Device, 'cluster'),
- virtualmachine_count=get_subquery(VirtualMachine, 'cluster')
- ).order_by(*Cluster._meta.ordering)
+ device_count=count_related(Device, 'cluster'),
+ virtualmachine_count=count_related(VirtualMachine, 'cluster')
+ )
serializer_class = serializers.ClusterSerializer
filterset_class = filters.ClusterFilterSet
@@ -58,7 +51,7 @@ class ClusterViewSet(CustomFieldModelViewSet):
# Virtual machines
#
-class VirtualMachineViewSet(CustomFieldModelViewSet, ConfigContextQuerySetMixin):
+class VirtualMachineViewSet(ConfigContextQuerySetMixin, CustomFieldModelViewSet):
queryset = VirtualMachine.objects.prefetch_related(
'cluster__site', 'role', 'tenant', 'platform', 'primary_ip4', 'primary_ip6', 'tags'
)
@@ -91,13 +84,3 @@ class VMInterfaceViewSet(ModelViewSet):
)
serializer_class = serializers.VMInterfaceSerializer
filterset_class = filters.VMInterfaceFilterSet
-
- @action(detail=True)
- def graphs(self, request, pk):
- """
- A convenience method for rendering graphs for a particular VM interface.
- """
- vminterface = get_object_or_404(self.queryset, pk=pk)
- queryset = Graph.objects.restrict(request.user).filter(type__model='vminterface')
- serializer = RenderedGraphSerializer(queryset, many=True, context={'graphed_object': vminterface})
- return Response(serializer.data)
diff --git a/netbox/virtualization/choices.py b/netbox/virtualization/choices.py
index 083470bd9..9c4eb6cd5 100644
--- a/netbox/virtualization/choices.py
+++ b/netbox/virtualization/choices.py
@@ -22,3 +22,12 @@ class VirtualMachineStatusChoices(ChoiceSet):
(STATUS_FAILED, 'Failed'),
(STATUS_DECOMMISSIONING, 'Decommissioning'),
)
+
+ CSS_CLASSES = {
+ STATUS_OFFLINE: 'warning',
+ STATUS_ACTIVE: 'success',
+ STATUS_PLANNED: 'info',
+ STATUS_STAGED: 'primary',
+ STATUS_FAILED: 'danger',
+ STATUS_DECOMMISSIONING: 'warning',
+ }
diff --git a/netbox/virtualization/filters.py b/netbox/virtualization/filters.py
index 886b5484b..af53afad5 100644
--- a/netbox/virtualization/filters.py
+++ b/netbox/virtualization/filters.py
@@ -2,7 +2,7 @@ import django_filters
from django.db.models import Q
from dcim.models import DeviceRole, Platform, Region, Site
-from extras.filters import CustomFieldFilterSet, CreatedUpdatedFilterSet, LocalConfigContextFilterSet
+from extras.filters import CustomFieldModelFilterSet, CreatedUpdatedFilterSet, LocalConfigContextFilterSet
from tenancy.filters import TenancyFilterSet
from utilities.filters import (
BaseFilterSet, MultiValueMACAddressFilter, NameSlugSearchFilterSet, TagFilter,
@@ -34,7 +34,7 @@ class ClusterGroupFilterSet(BaseFilterSet, NameSlugSearchFilterSet):
fields = ['id', 'name', 'slug', 'description']
-class ClusterFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet):
+class ClusterFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldModelFilterSet, CreatedUpdatedFilterSet):
q = django_filters.CharFilter(
method='search',
label='Search',
@@ -101,7 +101,7 @@ class VirtualMachineFilterSet(
BaseFilterSet,
LocalConfigContextFilterSet,
TenancyFilterSet,
- CustomFieldFilterSet,
+ CustomFieldModelFilterSet,
CreatedUpdatedFilterSet
):
q = django_filters.CharFilter(
diff --git a/netbox/virtualization/forms.py b/netbox/virtualization/forms.py
index ce4ca3e3c..edacb3e07 100644
--- a/netbox/virtualization/forms.py
+++ b/netbox/virtualization/forms.py
@@ -4,7 +4,7 @@ from django.core.exceptions import ValidationError
from dcim.choices import InterfaceModeChoices
from dcim.constants import INTERFACE_MTU_MAX, INTERFACE_MTU_MIN
-from dcim.forms import INTERFACE_MODE_HELP_TEXT
+from dcim.forms import InterfaceCommonForm, INTERFACE_MODE_HELP_TEXT
from dcim.models import Device, DeviceRole, Platform, Rack, Region, Site
from extras.forms import (
AddRemoveTagsForm, CustomFieldBulkEditForm, CustomFieldModelCSVForm, CustomFieldModelForm, CustomFieldFilterForm,
@@ -543,10 +543,11 @@ class VirtualMachineFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFil
# VM interfaces
#
-class VMInterfaceForm(BootstrapMixin, forms.ModelForm):
+class VMInterfaceForm(BootstrapMixin, InterfaceCommonForm, forms.ModelForm):
untagged_vlan = DynamicModelChoiceField(
queryset=VLAN.objects.all(),
required=False,
+ label='Untagged VLAN',
display_field='display_name',
brief_mode=False,
query_params={
@@ -556,6 +557,7 @@ class VMInterfaceForm(BootstrapMixin, forms.ModelForm):
tagged_vlans = DynamicModelMultipleChoiceField(
queryset=VLAN.objects.all(),
required=False,
+ label='Tagged VLANs',
display_field='display_name',
brief_mode=False,
query_params={
@@ -597,24 +599,8 @@ class VMInterfaceForm(BootstrapMixin, forms.ModelForm):
self.fields['untagged_vlan'].widget.add_query_param('site_id', site.pk)
self.fields['tagged_vlans'].widget.add_query_param('site_id', site.pk)
- def clean(self):
- super().clean()
- # Validate VLAN assignments
- tagged_vlans = self.cleaned_data['tagged_vlans']
-
- # Untagged interfaces cannot be assigned tagged VLANs
- if self.cleaned_data['mode'] == InterfaceModeChoices.MODE_ACCESS and tagged_vlans:
- raise forms.ValidationError({
- 'mode': "An access interface cannot have tagged VLANs assigned."
- })
-
- # Remove all tagged VLAN assignments from "tagged all" interfaces
- elif self.cleaned_data['mode'] == InterfaceModeChoices.MODE_TAGGED_ALL:
- self.cleaned_data['tagged_vlans'] = []
-
-
-class VMInterfaceCreateForm(BootstrapMixin, forms.Form):
+class VMInterfaceCreateForm(BootstrapMixin, InterfaceCommonForm):
virtual_machine = DynamicModelChoiceField(
queryset=VirtualMachine.objects.all()
)
diff --git a/netbox/virtualization/migrations/0018_custom_field_data.py b/netbox/virtualization/migrations/0018_custom_field_data.py
new file mode 100644
index 000000000..9a120406a
--- /dev/null
+++ b/netbox/virtualization/migrations/0018_custom_field_data.py
@@ -0,0 +1,22 @@
+import django.core.serializers.json
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('virtualization', '0017_update_jsonfield'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='cluster',
+ name='custom_field_data',
+ field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder),
+ ),
+ migrations.AddField(
+ model_name='virtualmachine',
+ name='custom_field_data',
+ field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder),
+ ),
+ ]
diff --git a/netbox/virtualization/migrations/0019_standardize_name_length.py b/netbox/virtualization/migrations/0019_standardize_name_length.py
new file mode 100644
index 000000000..d6820640d
--- /dev/null
+++ b/netbox/virtualization/migrations/0019_standardize_name_length.py
@@ -0,0 +1,33 @@
+# Generated by Django 3.1 on 2020-10-15 19:33
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('virtualization', '0018_custom_field_data'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='clustergroup',
+ name='name',
+ field=models.CharField(max_length=100, unique=True),
+ ),
+ migrations.AlterField(
+ model_name='clustergroup',
+ name='slug',
+ field=models.SlugField(max_length=100, unique=True),
+ ),
+ migrations.AlterField(
+ model_name='clustertype',
+ name='name',
+ field=models.CharField(max_length=100, unique=True),
+ ),
+ migrations.AlterField(
+ model_name='clustertype',
+ name='slug',
+ field=models.SlugField(max_length=100, unique=True),
+ ),
+ ]
diff --git a/netbox/virtualization/models.py b/netbox/virtualization/models.py
index 39f579e30..cf7737340 100644
--- a/netbox/virtualization/models.py
+++ b/netbox/virtualization/models.py
@@ -5,7 +5,6 @@ from django.db import models
from django.urls import reverse
from taggit.managers import TaggableManager
-from dcim.choices import InterfaceModeChoices
from dcim.models import BaseInterface, Device
from extras.models import ChangeLoggedModel, ConfigContextModel, CustomFieldModel, ObjectChange, TaggedItem
from extras.querysets import ConfigContextModelQuerySet
@@ -36,10 +35,11 @@ class ClusterType(ChangeLoggedModel):
A type of Cluster.
"""
name = models.CharField(
- max_length=50,
+ max_length=100,
unique=True
)
slug = models.SlugField(
+ max_length=100,
unique=True
)
description = models.CharField(
@@ -77,10 +77,11 @@ class ClusterGroup(ChangeLoggedModel):
An organizational group of Clusters.
"""
name = models.CharField(
- max_length=50,
+ max_length=100,
unique=True
)
slug = models.SlugField(
+ max_length=100,
unique=True
)
description = models.CharField(
@@ -151,11 +152,6 @@ class Cluster(ChangeLoggedModel, CustomFieldModel):
comments = models.TextField(
blank=True
)
- custom_field_values = GenericRelation(
- to='extras.CustomFieldValue',
- content_type_field='obj_type',
- object_id_field='obj_id'
- )
tags = TaggableManager(through=TaggedItem)
objects = RestrictedQuerySet.as_manager()
@@ -175,6 +171,7 @@ class Cluster(ChangeLoggedModel, CustomFieldModel):
return reverse('virtualization:cluster', args=[self.pk])
def clean(self):
+ super().clean()
# If the Cluster is assigned to a Site, verify that all host Devices belong to that Site.
if self.pk and self.site:
@@ -276,10 +273,11 @@ class VirtualMachine(ChangeLoggedModel, ConfigContextModel, CustomFieldModel):
comments = models.TextField(
blank=True
)
- custom_field_values = GenericRelation(
- to='extras.CustomFieldValue',
- content_type_field='obj_type',
- object_id_field='obj_id'
+ secrets = GenericRelation(
+ to='secrets.Secret',
+ content_type_field='assigned_object_type',
+ object_id_field='assigned_object_id',
+ related_query_name='virtual_machine'
)
tags = TaggableManager(through=TaggedItem)
@@ -292,15 +290,6 @@ class VirtualMachine(ChangeLoggedModel, ConfigContextModel, CustomFieldModel):
'cluster', 'tenant', 'platform', 'status', 'role', 'vcpus', 'memory', 'disk',
]
- STATUS_CLASS_MAP = {
- VirtualMachineStatusChoices.STATUS_OFFLINE: 'warning',
- VirtualMachineStatusChoices.STATUS_ACTIVE: 'success',
- VirtualMachineStatusChoices.STATUS_PLANNED: 'info',
- VirtualMachineStatusChoices.STATUS_STAGED: 'primary',
- VirtualMachineStatusChoices.STATUS_FAILED: 'danger',
- VirtualMachineStatusChoices.STATUS_DECOMMISSIONING: 'warning',
- }
-
class Meta:
ordering = ('name', 'pk') # Name may be non-unique
unique_together = [
@@ -319,16 +308,15 @@ class VirtualMachine(ChangeLoggedModel, ConfigContextModel, CustomFieldModel):
# because Django does not consider two NULL fields to be equal, and thus will not trigger a violation
# of the uniqueness constraint without manual intervention.
if self.tenant is None and VirtualMachine.objects.exclude(pk=self.pk).filter(
- name=self.name, tenant__isnull=True
+ name=self.name, cluster=self.cluster, tenant__isnull=True
):
raise ValidationError({
- 'name': 'A virtual machine with this name already exists.'
+ 'name': 'A virtual machine with this name already exists in the assigned cluster.'
})
super().validate_unique(exclude)
def clean(self):
-
super().clean()
# Validate primary IP addresses
@@ -360,7 +348,7 @@ class VirtualMachine(ChangeLoggedModel, ConfigContextModel, CustomFieldModel):
)
def get_status_class(self):
- return self.STATUS_CLASS_MAP.get(self.status)
+ return VirtualMachineStatusChoices.CSS_CLASSES.get(self.status)
@property
def primary_ip(self):
@@ -382,7 +370,7 @@ class VirtualMachine(ChangeLoggedModel, ConfigContextModel, CustomFieldModel):
# Interfaces
#
-@extras_features('graphs', 'export_templates', 'webhooks')
+@extras_features('export_templates', 'webhooks')
class VMInterface(BaseInterface):
virtual_machine = models.ForeignKey(
to='virtualization.VirtualMachine',
@@ -460,22 +448,10 @@ class VMInterface(BaseInterface):
# Validate untagged VLAN
if self.untagged_vlan and self.untagged_vlan.site not in [self.virtual_machine.site, None]:
raise ValidationError({
- 'untagged_vlan': "The untagged VLAN ({}) must belong to the same site as the interface's parent "
- "virtual machine, or it must be global".format(self.untagged_vlan)
+ 'untagged_vlan': f"The untagged VLAN ({self.untagged_vlan}) must belong to the same site as the "
+ f"interface's parent virtual machine, or it must be global"
})
- def save(self, *args, **kwargs):
-
- # Remove untagged VLAN assignment for non-802.1Q interfaces
- if self.mode is None:
- self.untagged_vlan = None
-
- # Only "tagged" interfaces may have tagged VLANs assigned. ("tagged all" implies all VLANs are assigned.)
- if self.pk and self.mode != InterfaceModeChoices.MODE_TAGGED:
- self.tagged_vlans.clear()
-
- return super().save(*args, **kwargs)
-
def to_objectchange(self, action):
# Annotate the parent VirtualMachine
return ObjectChange(
diff --git a/netbox/virtualization/tables.py b/netbox/virtualization/tables.py
index 5f5b9326d..34a070623 100644
--- a/netbox/virtualization/tables.py
+++ b/netbox/virtualization/tables.py
@@ -1,27 +1,28 @@
import django_tables2 as tables
-from django_tables2.utils import Accessor
-from dcim.tables import BaseInterfaceTable
+from dcim.tables.devices import BaseInterfaceTable
from tenancy.tables import COL_TENANT
-from utilities.tables import BaseTable, ButtonsColumn, ColoredLabelColumn, TagColumn, ToggleColumn
+from utilities.tables import (
+ BaseTable, ButtonsColumn, ChoiceFieldColumn, ColoredLabelColumn, LinkedCountColumn, TagColumn, ToggleColumn,
+)
from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface
-VIRTUALMACHINE_STATUS = """
-{{ record.get_status_display }}
-"""
+__all__ = (
+ 'ClusterTable',
+ 'ClusterGroupTable',
+ 'ClusterTypeTable',
+ 'VirtualMachineDetailTable',
+ 'VirtualMachineTable',
+ 'VirtualMachineVMInterfaceTable',
+ 'VMInterfaceTable',
+)
-VIRTUALMACHINE_PRIMARY_IP = """
-{{ record.primary_ip6.address.ip|default:"" }}
-{% if record.primary_ip6 and record.primary_ip4 %} {% endif %}
-{{ record.primary_ip4.address.ip|default:"" }}
-"""
-
-DEVICE_COUNT = """
-{{ value|default:0 }}
-"""
-
-VM_COUNT = """
-{{ value|default:0 }}
+VMINTERFACE_BUTTONS = """
+{% if perms.ipam.add_ipaddress %}
+
+
+
+{% endif %}
"""
@@ -74,12 +75,14 @@ class ClusterTable(BaseTable):
site = tables.Column(
linkify=True
)
- device_count = tables.TemplateColumn(
- template_code=DEVICE_COUNT,
+ device_count = LinkedCountColumn(
+ viewname='dcim:device_list',
+ url_params={'cluster_id': 'pk'},
verbose_name='Devices'
)
- vm_count = tables.TemplateColumn(
- template_code=VM_COUNT,
+ vm_count = LinkedCountColumn(
+ viewname='virtualization:virtualmachine_list',
+ url_params={'cluster_id': 'pk'},
verbose_name='VMs'
)
tags = TagColumn(
@@ -99,9 +102,7 @@ class ClusterTable(BaseTable):
class VirtualMachineTable(BaseTable):
pk = ToggleColumn()
name = tables.LinkColumn()
- status = tables.TemplateColumn(
- template_code=VIRTUALMACHINE_STATUS
- )
+ status = ChoiceFieldColumn()
cluster = tables.Column(
linkify=True
)
@@ -124,10 +125,9 @@ class VirtualMachineDetailTable(VirtualMachineTable):
linkify=True,
verbose_name='IPv6 Address'
)
- primary_ip = tables.TemplateColumn(
- orderable=False,
- verbose_name='IP Address',
- template_code=VIRTUALMACHINE_PRIMARY_IP
+ primary_ip = tables.Column(
+ linkify=True,
+ verbose_name='IP Address'
)
tags = TagColumn(
url_name='virtualization:virtualmachine_list'
@@ -161,7 +161,28 @@ class VMInterfaceTable(BaseInterfaceTable):
class Meta(BaseTable.Meta):
model = VMInterface
fields = (
- 'pk', 'virtual_machine', 'name', 'enabled', 'mac_address', 'mtu', 'description', 'tags', 'ip_addresses',
- 'untagged_vlan', 'tagged_vlans',
+ 'pk', 'virtual_machine', 'name', 'enabled', 'mac_address', 'mtu', 'mode', 'description', 'tags',
+ 'ip_addresses', 'untagged_vlan', 'tagged_vlans',
)
default_columns = ('pk', 'virtual_machine', 'name', 'enabled', 'description')
+
+
+class VirtualMachineVMInterfaceTable(VMInterfaceTable):
+ actions = ButtonsColumn(
+ model=VMInterface,
+ buttons=('edit', 'delete'),
+ prepend_template=VMINTERFACE_BUTTONS
+ )
+
+ class Meta(BaseTable.Meta):
+ model = VMInterface
+ fields = (
+ 'pk', 'name', 'enabled', 'mac_address', 'mtu', 'mode', 'description', 'tags', 'ip_addresses',
+ 'untagged_vlan', 'tagged_vlans', 'actions',
+ )
+ default_columns = (
+ 'pk', 'name', 'enabled', 'mac_address', 'mtu', 'mode', 'description', 'ip_addresses', 'actions',
+ )
+ row_attrs = {
+ 'data-name': lambda record: record.name,
+ }
diff --git a/netbox/virtualization/tests/test_api.py b/netbox/virtualization/tests/test_api.py
index 6ddcdf2ef..716b6af4d 100644
--- a/netbox/virtualization/tests/test_api.py
+++ b/netbox/virtualization/tests/test_api.py
@@ -1,10 +1,7 @@
-from django.contrib.contenttypes.models import ContentType
-from django.test import override_settings
from django.urls import reverse
from rest_framework import status
from dcim.choices import InterfaceModeChoices
-from extras.models import Graph
from ipam.models import VLAN
from utilities.testing import APITestCase, APIViewTestCases
from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface
@@ -37,6 +34,9 @@ class ClusterTypeTest(APIViewTestCases.APIViewTestCase):
'slug': 'cluster-type-6',
},
]
+ bulk_update_data = {
+ 'description': 'New description',
+ }
@classmethod
def setUpTestData(cls):
@@ -66,6 +66,9 @@ class ClusterGroupTest(APIViewTestCases.APIViewTestCase):
'slug': 'cluster-type-6',
},
]
+ bulk_update_data = {
+ 'description': 'New description',
+ }
@classmethod
def setUpTestData(cls):
@@ -81,6 +84,9 @@ class ClusterGroupTest(APIViewTestCases.APIViewTestCase):
class ClusterTest(APIViewTestCases.APIViewTestCase):
model = Cluster
brief_fields = ['id', 'name', 'url', 'virtualmachine_count']
+ bulk_update_data = {
+ 'comments': 'New comment',
+ }
@classmethod
def setUpTestData(cls):
@@ -126,6 +132,9 @@ class ClusterTest(APIViewTestCases.APIViewTestCase):
class VirtualMachineTest(APIViewTestCases.APIViewTestCase):
model = VirtualMachine
brief_fields = ['id', 'name', 'url']
+ bulk_update_data = {
+ 'status': 'staged',
+ }
@classmethod
def setUpTestData(cls):
@@ -199,6 +208,9 @@ class VirtualMachineTest(APIViewTestCases.APIViewTestCase):
class VMInterfaceTest(APIViewTestCases.APIViewTestCase):
model = VMInterface
brief_fields = ['id', 'name', 'url', 'virtual_machine']
+ bulk_update_data = {
+ 'description': 'New description',
+ }
@classmethod
def setUpTestData(cls):
@@ -244,25 +256,3 @@ class VMInterfaceTest(APIViewTestCases.APIViewTestCase):
'untagged_vlan': vlans[2].pk,
},
]
-
- @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
- def test_get_vminterface_graphs(self):
- """
- Test retrieval of Graphs assigned to VM interfaces.
- """
- ct = ContentType.objects.get_for_model(VMInterface)
- graphs = (
- Graph(type=ct, name='Graph 1', source='http://example.com/graphs.py?interface={{ obj.name }}&foo=1'),
- Graph(type=ct, name='Graph 2', source='http://example.com/graphs.py?interface={{ obj.name }}&foo=2'),
- Graph(type=ct, name='Graph 3', source='http://example.com/graphs.py?interface={{ obj.name }}&foo=3'),
- )
- Graph.objects.bulk_create(graphs)
-
- self.add_permissions('virtualization.view_vminterface')
- url = reverse('virtualization-api:vminterface-graphs', kwargs={
- 'pk': VMInterface.objects.first().pk
- })
- response = self.client.get(url, **self.header)
-
- self.assertEqual(len(response.data), 3)
- self.assertEqual(response.data[0]['embed_url'], 'http://example.com/graphs.py?interface=Interface 1&foo=1')
diff --git a/netbox/virtualization/views.py b/netbox/virtualization/views.py
index 4cabfedeb..9ef4a0863 100644
--- a/netbox/virtualization/views.py
+++ b/netbox/virtualization/views.py
@@ -1,6 +1,6 @@
from django.contrib import messages
from django.db import transaction
-from django.db.models import Count, Prefetch
+from django.db.models import Prefetch
from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse
@@ -9,11 +9,9 @@ from dcim.tables import DeviceTable
from extras.views import ObjectConfigContextView
from ipam.models import IPAddress, Service
from ipam.tables import InterfaceIPAddressTable, InterfaceVLANTable
-from utilities.utils import get_subquery
-from utilities.views import (
- BulkComponentCreateView, BulkDeleteView, BulkEditView, BulkImportView, BulkRenameView, ComponentCreateView,
- ObjectView, ObjectDeleteView, ObjectEditView, ObjectListView,
-)
+from netbox.views import generic
+from secrets.models import Secret
+from utilities.utils import count_related
from . import filters, forms, tables
from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface
@@ -22,28 +20,32 @@ from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterf
# Cluster types
#
-class ClusterTypeListView(ObjectListView):
- queryset = ClusterType.objects.annotate(cluster_count=Count('clusters')).order_by(*ClusterType._meta.ordering)
+class ClusterTypeListView(generic.ObjectListView):
+ queryset = ClusterType.objects.annotate(
+ cluster_count=count_related(Cluster, 'type')
+ )
table = tables.ClusterTypeTable
-class ClusterTypeEditView(ObjectEditView):
+class ClusterTypeEditView(generic.ObjectEditView):
queryset = ClusterType.objects.all()
model_form = forms.ClusterTypeForm
-class ClusterTypeDeleteView(ObjectDeleteView):
+class ClusterTypeDeleteView(generic.ObjectDeleteView):
queryset = ClusterType.objects.all()
-class ClusterTypeBulkImportView(BulkImportView):
+class ClusterTypeBulkImportView(generic.BulkImportView):
queryset = ClusterType.objects.all()
model_form = forms.ClusterTypeCSVForm
table = tables.ClusterTypeTable
-class ClusterTypeBulkDeleteView(BulkDeleteView):
- queryset = ClusterType.objects.annotate(cluster_count=Count('clusters')).order_by(*ClusterType._meta.ordering)
+class ClusterTypeBulkDeleteView(generic.BulkDeleteView):
+ queryset = ClusterType.objects.annotate(
+ cluster_count=count_related(Cluster, 'type')
+ )
table = tables.ClusterTypeTable
@@ -51,28 +53,32 @@ class ClusterTypeBulkDeleteView(BulkDeleteView):
# Cluster groups
#
-class ClusterGroupListView(ObjectListView):
- queryset = ClusterGroup.objects.annotate(cluster_count=Count('clusters')).order_by(*ClusterGroup._meta.ordering)
+class ClusterGroupListView(generic.ObjectListView):
+ queryset = ClusterGroup.objects.annotate(
+ cluster_count=count_related(Cluster, 'group')
+ )
table = tables.ClusterGroupTable
-class ClusterGroupEditView(ObjectEditView):
+class ClusterGroupEditView(generic.ObjectEditView):
queryset = ClusterGroup.objects.all()
model_form = forms.ClusterGroupForm
-class ClusterGroupDeleteView(ObjectDeleteView):
+class ClusterGroupDeleteView(generic.ObjectDeleteView):
queryset = ClusterGroup.objects.all()
-class ClusterGroupBulkImportView(BulkImportView):
+class ClusterGroupBulkImportView(generic.BulkImportView):
queryset = ClusterGroup.objects.all()
model_form = forms.ClusterGroupCSVForm
table = tables.ClusterGroupTable
-class ClusterGroupBulkDeleteView(BulkDeleteView):
- queryset = ClusterGroup.objects.annotate(cluster_count=Count('clusters')).order_by(*ClusterGroup._meta.ordering)
+class ClusterGroupBulkDeleteView(generic.BulkDeleteView):
+ queryset = ClusterGroup.objects.annotate(
+ cluster_count=count_related(Cluster, 'group')
+ )
table = tables.ClusterGroupTable
@@ -80,69 +86,63 @@ class ClusterGroupBulkDeleteView(BulkDeleteView):
# Clusters
#
-class ClusterListView(ObjectListView):
+class ClusterListView(generic.ObjectListView):
permission_required = 'virtualization.view_cluster'
- queryset = Cluster.objects.prefetch_related('type', 'group', 'site', 'tenant').annotate(
- device_count=get_subquery(Device, 'cluster'),
- vm_count=get_subquery(VirtualMachine, 'cluster')
+ queryset = Cluster.objects.annotate(
+ device_count=count_related(Device, 'cluster'),
+ vm_count=count_related(VirtualMachine, 'cluster')
)
table = tables.ClusterTable
filterset = filters.ClusterFilterSet
filterset_form = forms.ClusterFilterForm
-class ClusterView(ObjectView):
+class ClusterView(generic.ObjectView):
queryset = Cluster.objects.all()
- def get(self, request, pk):
- self.queryset = self.queryset.prefetch_related(
- Prefetch('virtual_machines', queryset=VirtualMachine.objects.restrict(request.user))
- )
-
- cluster = get_object_or_404(self.queryset, pk=pk)
- devices = Device.objects.restrict(request.user, 'view').filter(cluster=cluster).prefetch_related(
+ def get_extra_context(self, request, instance):
+ devices = Device.objects.restrict(request.user, 'view').filter(cluster=instance).prefetch_related(
'site', 'rack', 'tenant', 'device_type__manufacturer'
)
device_table = DeviceTable(list(devices), orderable=False)
if request.user.has_perm('virtualization.change_cluster'):
device_table.columns.show('pk')
- return render(request, 'virtualization/cluster.html', {
- 'cluster': cluster,
+ return {
'device_table': device_table,
- })
+ }
-class ClusterEditView(ObjectEditView):
+class ClusterEditView(generic.ObjectEditView):
template_name = 'virtualization/cluster_edit.html'
queryset = Cluster.objects.all()
model_form = forms.ClusterForm
-class ClusterDeleteView(ObjectDeleteView):
+class ClusterDeleteView(generic.ObjectDeleteView):
queryset = Cluster.objects.all()
-class ClusterBulkImportView(BulkImportView):
+class ClusterBulkImportView(generic.BulkImportView):
queryset = Cluster.objects.all()
model_form = forms.ClusterCSVForm
table = tables.ClusterTable
-class ClusterBulkEditView(BulkEditView):
+class ClusterBulkEditView(generic.BulkEditView):
queryset = Cluster.objects.prefetch_related('type', 'group', 'site')
filterset = filters.ClusterFilterSet
table = tables.ClusterTable
form = forms.ClusterBulkEditForm
-class ClusterBulkDeleteView(BulkDeleteView):
+class ClusterBulkDeleteView(generic.BulkDeleteView):
queryset = Cluster.objects.prefetch_related('type', 'group', 'site')
filterset = filters.ClusterFilterSet
table = tables.ClusterTable
-class ClusterAddDevicesView(ObjectEditView):
+class ClusterAddDevicesView(generic.ObjectEditView):
queryset = Cluster.objects.all()
form = forms.ClusterAddDevicesForm
template_name = 'virtualization/cluster_add_devices.html'
@@ -183,10 +183,10 @@ class ClusterAddDevicesView(ObjectEditView):
})
-class ClusterRemoveDevicesView(ObjectEditView):
+class ClusterRemoveDevicesView(generic.ObjectEditView):
queryset = Cluster.objects.all()
form = forms.ClusterRemoveDevicesForm
- template_name = 'utilities/obj_bulk_remove.html'
+ template_name = 'generic/object_bulk_remove.html'
def post(self, request, pk):
@@ -228,36 +228,44 @@ class ClusterRemoveDevicesView(ObjectEditView):
# Virtual machines
#
-class VirtualMachineListView(ObjectListView):
- queryset = VirtualMachine.objects.prefetch_related('cluster', 'tenant', 'role', 'primary_ip4', 'primary_ip6')
+class VirtualMachineListView(generic.ObjectListView):
+ queryset = VirtualMachine.objects.all()
filterset = filters.VirtualMachineFilterSet
filterset_form = forms.VirtualMachineFilterForm
table = tables.VirtualMachineDetailTable
template_name = 'virtualization/virtualmachine_list.html'
-class VirtualMachineView(ObjectView):
+class VirtualMachineView(generic.ObjectView):
queryset = VirtualMachine.objects.prefetch_related('tenant__group')
- def get(self, request, pk):
-
- virtualmachine = get_object_or_404(self.queryset, pk=pk)
- interfaces = VMInterface.objects.restrict(request.user, 'view').filter(
- virtual_machine=virtualmachine
+ def get_extra_context(self, request, instance):
+ # Interfaces
+ vminterfaces = VMInterface.objects.restrict(request.user, 'view').filter(
+ virtual_machine=instance
).prefetch_related(
Prefetch('ip_addresses', queryset=IPAddress.objects.restrict(request.user))
)
+ vminterface_table = tables.VirtualMachineVMInterfaceTable(vminterfaces, user=request.user, orderable=False)
+ if request.user.has_perm('virtualization.change_vminterface') or \
+ request.user.has_perm('virtualization.delete_vminterface'):
+ vminterface_table.columns.show('pk')
+
+ # Services
services = Service.objects.restrict(request.user, 'view').filter(
- virtual_machine=virtualmachine
+ virtual_machine=instance
).prefetch_related(
Prefetch('ipaddresses', queryset=IPAddress.objects.restrict(request.user))
)
- return render(request, 'virtualization/virtualmachine.html', {
- 'virtualmachine': virtualmachine,
- 'interfaces': interfaces,
+ # Secrets
+ secrets = Secret.objects.restrict(request.user, 'view').filter(virtual_machine=instance)
+
+ return {
+ 'vminterface_table': vminterface_table,
'services': services,
- })
+ 'secrets': secrets,
+ }
class VirtualMachineConfigContextView(ObjectConfigContextView):
@@ -265,30 +273,30 @@ class VirtualMachineConfigContextView(ObjectConfigContextView):
base_template = 'virtualization/virtualmachine.html'
-class VirtualMachineEditView(ObjectEditView):
+class VirtualMachineEditView(generic.ObjectEditView):
queryset = VirtualMachine.objects.all()
model_form = forms.VirtualMachineForm
template_name = 'virtualization/virtualmachine_edit.html'
-class VirtualMachineDeleteView(ObjectDeleteView):
+class VirtualMachineDeleteView(generic.ObjectDeleteView):
queryset = VirtualMachine.objects.all()
-class VirtualMachineBulkImportView(BulkImportView):
+class VirtualMachineBulkImportView(generic.BulkImportView):
queryset = VirtualMachine.objects.all()
model_form = forms.VirtualMachineCSVForm
table = tables.VirtualMachineTable
-class VirtualMachineBulkEditView(BulkEditView):
+class VirtualMachineBulkEditView(generic.BulkEditView):
queryset = VirtualMachine.objects.prefetch_related('cluster', 'tenant', 'role')
filterset = filters.VirtualMachineFilterSet
table = tables.VirtualMachineTable
form = forms.VirtualMachineBulkEditForm
-class VirtualMachineBulkDeleteView(BulkDeleteView):
+class VirtualMachineBulkDeleteView(generic.BulkDeleteView):
queryset = VirtualMachine.objects.prefetch_related('cluster', 'tenant', 'role')
filterset = filters.VirtualMachineFilterSet
table = tables.VirtualMachineTable
@@ -298,84 +306,80 @@ class VirtualMachineBulkDeleteView(BulkDeleteView):
# VM interfaces
#
-class VMInterfaceListView(ObjectListView):
- queryset = VMInterface.objects.prefetch_related('virtual_machine')
+class VMInterfaceListView(generic.ObjectListView):
+ queryset = VMInterface.objects.all()
filterset = filters.VMInterfaceFilterSet
filterset_form = forms.VMInterfaceFilterForm
table = tables.VMInterfaceTable
action_buttons = ('export',)
-class VMInterfaceView(ObjectView):
+class VMInterfaceView(generic.ObjectView):
queryset = VMInterface.objects.all()
- def get(self, request, pk):
-
- vminterface = get_object_or_404(self.queryset, pk=pk)
-
+ def get_extra_context(self, request, instance):
# Get assigned IP addresses
ipaddress_table = InterfaceIPAddressTable(
- data=vminterface.ip_addresses.restrict(request.user, 'view').prefetch_related('vrf', 'tenant'),
+ data=instance.ip_addresses.restrict(request.user, 'view').prefetch_related('vrf', 'tenant'),
orderable=False
)
# Get assigned VLANs and annotate whether each is tagged or untagged
vlans = []
- if vminterface.untagged_vlan is not None:
- vlans.append(vminterface.untagged_vlan)
+ if instance.untagged_vlan is not None:
+ vlans.append(instance.untagged_vlan)
vlans[0].tagged = False
- for vlan in vminterface.tagged_vlans.restrict(request.user).prefetch_related('site', 'group', 'tenant', 'role'):
+ for vlan in instance.tagged_vlans.restrict(request.user).prefetch_related('site', 'group', 'tenant', 'role'):
vlan.tagged = True
vlans.append(vlan)
vlan_table = InterfaceVLANTable(
- interface=vminterface,
+ interface=instance,
data=vlans,
orderable=False
)
- return render(request, 'virtualization/vminterface.html', {
- 'vminterface': vminterface,
+ return {
'ipaddress_table': ipaddress_table,
'vlan_table': vlan_table,
- })
+ }
# TODO: This should not use ComponentCreateView
-class VMInterfaceCreateView(ComponentCreateView):
+class VMInterfaceCreateView(generic.ComponentCreateView):
queryset = VMInterface.objects.all()
form = forms.VMInterfaceCreateForm
model_form = forms.VMInterfaceForm
template_name = 'virtualization/virtualmachine_component_add.html'
-class VMInterfaceEditView(ObjectEditView):
+class VMInterfaceEditView(generic.ObjectEditView):
queryset = VMInterface.objects.all()
model_form = forms.VMInterfaceForm
template_name = 'virtualization/vminterface_edit.html'
-class VMInterfaceDeleteView(ObjectDeleteView):
+class VMInterfaceDeleteView(generic.ObjectDeleteView):
queryset = VMInterface.objects.all()
-class VMInterfaceBulkImportView(BulkImportView):
+class VMInterfaceBulkImportView(generic.BulkImportView):
queryset = VMInterface.objects.all()
model_form = forms.VMInterfaceCSVForm
table = tables.VMInterfaceTable
-class VMInterfaceBulkEditView(BulkEditView):
+class VMInterfaceBulkEditView(generic.BulkEditView):
queryset = VMInterface.objects.all()
table = tables.VMInterfaceTable
form = forms.VMInterfaceBulkEditForm
-class VMInterfaceBulkRenameView(BulkRenameView):
+class VMInterfaceBulkRenameView(generic.BulkRenameView):
queryset = VMInterface.objects.all()
form = forms.VMInterfaceBulkRenameForm
-class VMInterfaceBulkDeleteView(BulkDeleteView):
+class VMInterfaceBulkDeleteView(generic.BulkDeleteView):
queryset = VMInterface.objects.all()
table = tables.VMInterfaceTable
@@ -384,7 +388,7 @@ class VMInterfaceBulkDeleteView(BulkDeleteView):
# Bulk Device component creation
#
-class VirtualMachineBulkAddInterfaceView(BulkComponentCreateView):
+class VirtualMachineBulkAddInterfaceView(generic.BulkComponentCreateView):
parent_model = VirtualMachine
parent_field = 'virtual_machine'
form = forms.VMInterfaceBulkCreateForm
diff --git a/requirements.txt b/requirements.txt
index f26d44471..18ce530fd 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,23 +1,23 @@
-Django==3.1
-django-cacheops==5.1.0
-django-cors-headers==3.4.0
-django-debug-toolbar==2.2
-django-filter==2.3.0
+Django==3.1.3
+django-cacheops==5.1
+django-cors-headers==3.5.0
+django-debug-toolbar==3.1.1
+django-filter==2.4.0
django-mptt==0.11.0
django-pglocks==1.0.4
-django-prometheus==2.0.0
-django-rq==2.3.2
-django-tables2==2.3.1
+django-prometheus==2.1.0
+django-rq==2.4.0
+django-tables2==2.3.3
django-taggit==1.3.0
django-timezone-field==4.0
-djangorestframework==3.11.0
-drf-yasg[validation]==1.17.1
+djangorestframework==3.12.2
+drf-yasg[validation]==1.20.0
gunicorn==20.0.4
Jinja2==2.11.2
-Markdown==3.2.2
+Markdown==3.3.3
netaddr==0.8.0
-Pillow==7.2.0
-psycopg2-binary==2.8.5
-pycryptodome==3.9.8
+Pillow==8.0.1
+psycopg2-binary==2.8.6
+pycryptodome==3.9.9
PyYAML==5.3.1
svgwrite==1.4
diff --git a/scripts/cibuild.sh b/scripts/cibuild.sh
deleted file mode 100755
index 6a0422308..000000000
--- a/scripts/cibuild.sh
+++ /dev/null
@@ -1,60 +0,0 @@
-#!/bin/bash
-
-# Exit code starts at 0 but is modified if any checks fail
-EXIT=0
-
-# Output a line prefixed with a timestamp
-info()
-{
- echo "$(date +'%F %T') |"
-}
-
-# Track number of seconds required to run script
-START=$(date +%s)
-echo "$(info) starting build checks."
-
-# Syntax check all python source files
-SYNTAX=$(find . -name "*.py" -type f -exec python -m py_compile {} \; 2>&1)
-if [[ ! -z $SYNTAX ]]; then
- echo -e "$SYNTAX"
- echo -e "\n$(info) detected one or more syntax errors, failing build."
- EXIT=1
-fi
-
-# Check all python source files for PEP 8 compliance, but explicitly
-# ignore:
-# - W504: line break after binary operator
-# - E501: line greater than 80 characters in length
-pycodestyle \
- --ignore=W504,E501 \
- netbox/
-RC=$?
-if [[ $RC != 0 ]]; then
- echo -e "\n$(info) one or more PEP 8 errors detected, failing build."
- EXIT=$RC
-fi
-
-# Point to the testing configuration file for use in CI
-ln -s configuration.testing.py netbox/netbox/configuration.py
-
-# Run NetBox tests
-coverage run --source="netbox/" netbox/manage.py test netbox/
-RC=$?
-if [[ $RC != 0 ]]; then
- echo -e "\n$(info) one or more tests failed, failing build."
- EXIT=$RC
-fi
-
-# Show code coverage report
-coverage report --skip-covered --omit *migrations*
-RC=$?
-if [[ $RC != 0 ]]; then
- echo -e "\n$(info) failed to generate code coverage report."
- EXIT=$RC
-fi
-
-# Show build duration
-END=$(date +%s)
-echo "$(info) exiting with code $EXIT after $(($END - $START)) seconds."
-
-exit $EXIT
diff --git a/upgrade.sh b/upgrade.sh
index 66ba7b39f..2e35a02f9 100755
--- a/upgrade.sh
+++ b/upgrade.sh
@@ -55,6 +55,11 @@ COMMAND="python3 netbox/manage.py migrate"
echo "Applying database migrations ($COMMAND)..."
eval $COMMAND || exit 1
+# Trace any missing cable paths (not typically needed)
+COMMAND="python3 netbox/manage.py trace_paths --no-input"
+echo "Checking for missing cable paths ($COMMAND)..."
+eval $COMMAND || exit 1
+
# Collect static files
COMMAND="python3 netbox/manage.py collectstatic --no-input"
echo "Collecting static files ($COMMAND)..."
@@ -79,7 +84,8 @@ if [ -v WARN_MISSING_VENV ]; then
echo "--------------------------------------------------------------------"
echo "WARNING: No existing virtual environment was detected. A new one has"
echo "been created. Update your systemd service files to reflect the new"
- echo "Python and gunicorn executables."
+ echo "Python and gunicorn executables. (If this is a new installation,"
+ echo "this warning can be ignored.)"
echo ""
echo "netbox.service ExecStart:"
echo " ${VIRTUALENV}/bin/gunicorn"