Skip to content

Commit

Permalink
Add GraphQL query form field to the "Generate Intended Config" view (#…
Browse files Browse the repository at this point in the history
  • Loading branch information
gsnider2195 authored Dec 9, 2024
1 parent 49caca7 commit 1e1fda6
Show file tree
Hide file tree
Showing 9 changed files with 140 additions and 15 deletions.
1 change: 1 addition & 0 deletions changes/841.added
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Added GraphQL query form field to the "Generate Intended Config" view.
Binary file modified docs/images/generate-intended-config-ui-dark.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/images/generate-intended-config-ui.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
29 changes: 26 additions & 3 deletions nautobot_golden_config/api/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
from nautobot.dcim.models import Device
from nautobot.extras.api.views import NautobotModelViewSet, NotesViewSetMixin
from nautobot.extras.datasources.git import ensure_git_repository
from nautobot.extras.models import GraphQLQuery
from nautobot_plugin_nornir.constants import NORNIR_SETTINGS
from nornir import InitNornir
from nornir_nautobot.plugins.tasks.dispatcher import dispatcher
Expand Down Expand Up @@ -232,19 +233,41 @@ def _get_jinja_template_path(self, settings, device, git_repository):
type=OpenApiTypes.UUID,
location=OpenApiParameter.QUERY,
),
OpenApiParameter(
name="graphql_query_id",
required=False,
type=OpenApiTypes.UUID,
location=OpenApiParameter.QUERY,
),
]
)
def get(self, request, *args, **kwargs):
"""Generate intended configuration for a Device."""
device = self._get_object(request, Device, "device_id")
graphql_query = None
graphql_query_id_param = request.query_params.get("graphql_query_id")
if graphql_query_id_param:
try:
graphql_query = GraphQLQuery.objects.get(pk=request.query_params.get("graphql_query_id"))
except GraphQLQuery.DoesNotExist as exc:
raise GenerateIntendedConfigException(
f"GraphQLQuery with id '{graphql_query_id_param}' not found"
) from exc
settings = models.GoldenConfigSetting.objects.get_for_device(device)
if not settings:
raise GenerateIntendedConfigException("No Golden Config settings found for this device")
if not settings.sot_agg_query:
raise GenerateIntendedConfigException("Golden Config settings sot_agg_query not set")
if not settings.jinja_repository:
raise GenerateIntendedConfigException("Golden Config settings jinja_repository not set")

if graphql_query is None:
if settings.sot_agg_query is not None:
graphql_query = settings.sot_agg_query
else:
raise GenerateIntendedConfigException("Golden Config settings sot_agg_query not set")

if "device_id" not in graphql_query.variables:
raise GenerateIntendedConfigException("The selected GraphQL query is missing a 'device_id' variable")

try:
git_repository = settings.jinja_repository
ensure_git_repository(git_repository)
Expand All @@ -253,7 +276,7 @@ def get(self, request, *args, **kwargs):

filesystem_path = self._get_jinja_template_path(settings, device, git_repository)

status_code, graphql_data = graph_ql_query(request, device, settings.sot_agg_query.query)
status_code, graphql_data = graph_ql_query(request, device, graphql_query.query)
if status_code == status.HTTP_200_OK:
try:
intended_config = self._render_config_nornir_serial(
Expand Down
30 changes: 30 additions & 0 deletions nautobot_golden_config/filter_extensions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
"""FilterSet and FilterForm extensions for the Golden Config app."""

from django.db.models import Q
from nautobot.apps.filters import FilterExtension, MultiValueCharFilter


def filter_graphql_query_variables(queryset, name, value): # pylint: disable=unused-argument
"""Filter the queryset based on the presence of variables."""
query = Q()
for variable_name in value:
query |= Q(**{f"variables__{variable_name}__isnull": True})
return queryset.exclude(query)


class GraphQLQueryFilterExtension(FilterExtension):
"""Filter extensions for the extras.GraphQLQuery model."""

model = "extras.graphqlquery"

filterset_fields = {
"nautobot_golden_config_graphql_query_variables": MultiValueCharFilter(
field_name="variables",
lookup_expr="exact",
method=filter_graphql_query_variables,
label="Variable key(s) exist",
),
}


filter_extensions = [GraphQLQueryFilterExtension]
21 changes: 21 additions & 0 deletions nautobot_golden_config/filters.py
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,27 @@ class Meta:
class GoldenConfigSettingFilterSet(NautobotFilterSet):
"""Inherits Base Class NautobotFilterSet."""

device_id = django_filters.ModelMultipleChoiceFilter(
queryset=Device.objects.all(),
label="Device (ID)",
method="filter_device_id",
)

def filter_device_id(self, queryset, name, value): # pylint: disable=unused-argument
"""Filter by Device ID."""
if not value:
return queryset
golden_config_setting_ids = []
for instance in value:
if isinstance(instance, Device):
device = instance
else:
device = Device.objects.get(id=instance)
golden_config_setting = models.GoldenConfigSetting.objects.get_for_device(device)
if golden_config_setting is not None:
golden_config_setting_ids.append(golden_config_setting.id)
return queryset.filter(id__in=golden_config_setting_ids)

class Meta:
"""Boilerplate filter Meta data for Config Remove."""

Expand Down
8 changes: 7 additions & 1 deletion nautobot_golden_config/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
from nautobot.apps import forms
from nautobot.dcim.models import Device, DeviceType, Location, Manufacturer, Platform, Rack, RackGroup
from nautobot.extras.forms import NautobotBulkEditForm, NautobotFilterForm, NautobotModelForm
from nautobot.extras.models import DynamicGroup, GitRepository, JobResult, Role, Status, Tag
from nautobot.extras.models import DynamicGroup, GitRepository, GraphQLQuery, JobResult, Role, Status, Tag
from nautobot.tenancy.models import Tenant, TenantGroup

from nautobot_golden_config import models
Expand Down Expand Up @@ -608,3 +608,9 @@ class GenerateIntendedConfigForm(django_forms.Form):
required=True,
label="Device",
)
graphql_query = forms.DynamicModelChoiceField(
queryset=GraphQLQuery.objects.all(),
required=True,
label="GraphQL Query",
query_params={"nautobot_golden_config_graphql_query_variables": "device_id"},
)
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@
This will fetch the latest templates from the Golden Config Jinja template repository.
</p>
{% render_field form.device %}
{% render_field form.git_repository %}
{% render_field form.graphql_query %}
</div>
</div>
<div class="button-container text-right">
Expand All @@ -74,10 +74,14 @@
</ul>
<div class="tab-content" id="id_rendered_config_tabs">
<div class="tab-pane active" id="id_intended_config_tab_content">
<pre><code id="id_rendered_config_code_block"></code></pre>
{% comment %}
The attributes `contenteditable="true"` and `onbeforeinput="return false"` are used
to let the <code> block be selectable but not editable, emulating a textarea but supporting nested html tags.
{% endcomment %}
<pre><code id="id_rendered_config_code_block" contenteditable="true" onbeforeinput="return false"></code></pre>
</div>
<div class="tab-pane" id="id_graphql_data_tab_content">
<pre><code class="language-json" id="id_graphql_data_code_block"></code></pre>
<pre><code class="language-json" id="id_graphql_data_code_block" contenteditable="true" onbeforeinput="return false"></code></pre>
</div>
</div>
</div>
Expand All @@ -90,16 +94,55 @@
{% block javascript %}
{{ block.super }}
<script>
// When a device is selected, populate the GraphQL query field with the default query for that device
async function handleDeviceFieldSelect(event) {
const device_id = event.params.data.id;
const goldenconfigsettings_url = "{% url 'plugins-api:nautobot_golden_config-api:goldenconfigsetting-list' %}";

// fetch the golden config settings for the device
const data = {device_id: device_id, depth: 1};
const query_params = new URLSearchParams(data).toString();
const response = await fetch(goldenconfigsettings_url + "?" + query_params, {
method: "GET",
headers: {"Content-Type": "application/json"}
});
const responseData = await response.json();

// set the graphql query field to the default query for the device
if (response.ok && responseData.count > 0) {
const graphql_query = responseData.results[0]?.sot_agg_query;

// Check if the option for the GraphQL query already exists
if ($("#id_graphql_query").find("option[value='" + graphql_query.id + "']").length) {
$("#id_graphql_query").val(graphql_query.id).trigger("change");

// Otherwise create a new Option and select it
} else {
var newOption = new Option(graphql_query.display, graphql_query.id, true, true);
$("#id_graphql_query").append(newOption).trigger("change");
}
}
}

// jQuery used here because it is required for select2 events
$("#id_device").on("select2:select", handleDeviceFieldSelect);

// Initialize the copy to clipboard button
new ClipboardJS('.copy-rendered-config');

// Sanitize a string for display in a code block by replacing HTML tags with character references
const sanitize = function(string) {
return string.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
};

// Custom form submission handling
async function handleFormSubmit(event) {
event.preventDefault(); // Prevent default form submission

try {
const rendered_config_code_block = document.getElementById("id_rendered_config_code_block");
const device = document.getElementById("id_device").value;
const device_id = document.getElementById("id_device").value;
const graphql_query_id = document.getElementById("id_graphql_query").value;
const url = "{% url 'plugins-api:nautobot_golden_config-api:generate_intended_config' %}";
const graphql_data_code_block = document.getElementById("id_graphql_data_code_block");
const graphql_data_tab = document.getElementById("id_graphql_data_tab");
Expand All @@ -112,7 +155,7 @@
graphql_data_tab.classList.remove("active");

// fetch the intended config
const data = {device_id: device};
const data = {device_id: device_id, graphql_query_id: graphql_query_id};
const query_params = new URLSearchParams(data).toString();
const response = await fetch(url + "?" + query_params, {
method: "GET",
Expand Down
13 changes: 7 additions & 6 deletions nautobot_golden_config/tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -567,9 +567,10 @@ def _generate_config(task, *args, **kwargs):
self.assertTrue("detail" in response.data)
self.assertEqual("Error trying to sync git repository", response.data["detail"])

# test jinja_repository not set
self.golden_config_setting.jinja_repository = None
# test no sot_agg_query on GoldenConfigSetting
self.golden_config_setting.sot_agg_query = None
self.golden_config_setting.save()

response = self.client.get(
reverse("plugins-api:nautobot_golden_config-api:generate_intended_config"),
data={"device_id": self.device.pk},
Expand All @@ -578,10 +579,10 @@ def _generate_config(task, *args, **kwargs):

self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
self.assertTrue("detail" in response.data)
self.assertEqual(response.data["detail"], "Golden Config settings jinja_repository not set")
self.assertEqual("Golden Config settings sot_agg_query not set", response.data["detail"])

# test no sot_agg_query on GoldenConfigSetting
self.golden_config_setting.sot_agg_query = None
# test jinja_repository not set
self.golden_config_setting.jinja_repository = None
self.golden_config_setting.save()

response = self.client.get(
Expand All @@ -592,7 +593,7 @@ def _generate_config(task, *args, **kwargs):

self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
self.assertTrue("detail" in response.data)
self.assertEqual("Golden Config settings sot_agg_query not set", response.data["detail"])
self.assertEqual(response.data["detail"], "Golden Config settings jinja_repository not set")

# test no GoldenConfigSetting found for device
GoldenConfigSetting.objects.all().delete()
Expand Down

0 comments on commit 1e1fda6

Please sign in to comment.