Просмотр исходного кода

Closes #5858: Implement a quick-add UI widget for related objects (#18016)

* WIP

* Misc cleanup

* Add warning re: nested quick-adds
Jeremy Stretch 1 год назад
Родитель
Сommit
b4f15092db

+ 10 - 4
netbox/circuits/forms/model_forms.py

@@ -50,7 +50,9 @@ class ProviderForm(NetBoxModelForm):
 class ProviderAccountForm(NetBoxModelForm):
     provider = DynamicModelChoiceField(
         label=_('Provider'),
-        queryset=Provider.objects.all()
+        queryset=Provider.objects.all(),
+        selector=True,
+        quick_add=True
     )
     comments = CommentField()
 
@@ -64,7 +66,9 @@ class ProviderAccountForm(NetBoxModelForm):
 class ProviderNetworkForm(NetBoxModelForm):
     provider = DynamicModelChoiceField(
         label=_('Provider'),
-        queryset=Provider.objects.all()
+        queryset=Provider.objects.all(),
+        selector=True,
+        quick_add=True
     )
     comments = CommentField()
 
@@ -97,7 +101,8 @@ class CircuitForm(TenancyForm, NetBoxModelForm):
     provider = DynamicModelChoiceField(
         label=_('Provider'),
         queryset=Provider.objects.all(),
-        selector=True
+        selector=True,
+        quick_add=True
     )
     provider_account = DynamicModelChoiceField(
         label=_('Provider account'),
@@ -108,7 +113,8 @@ class CircuitForm(TenancyForm, NetBoxModelForm):
         }
     )
     type = DynamicModelChoiceField(
-        queryset=CircuitType.objects.all()
+        queryset=CircuitType.objects.all(),
+        quick_add=True
     )
     comments = CommentField()
 

+ 14 - 7
netbox/dcim/forms/model_forms.py

@@ -112,12 +112,14 @@ class SiteForm(TenancyForm, NetBoxModelForm):
     region = DynamicModelChoiceField(
         label=_('Region'),
         queryset=Region.objects.all(),
-        required=False
+        required=False,
+        quick_add=True
     )
     group = DynamicModelChoiceField(
         label=_('Group'),
         queryset=SiteGroup.objects.all(),
-        required=False
+        required=False,
+        quick_add=True
     )
     asns = DynamicModelMultipleChoiceField(
         queryset=ASN.objects.all(),
@@ -206,7 +208,8 @@ class RackRoleForm(NetBoxModelForm):
 class RackTypeForm(NetBoxModelForm):
     manufacturer = DynamicModelChoiceField(
         label=_('Manufacturer'),
-        queryset=Manufacturer.objects.all()
+        queryset=Manufacturer.objects.all(),
+        quick_add=True
     )
     comments = CommentField()
     slug = SlugField(
@@ -348,7 +351,8 @@ class ManufacturerForm(NetBoxModelForm):
 class DeviceTypeForm(NetBoxModelForm):
     manufacturer = DynamicModelChoiceField(
         label=_('Manufacturer'),
-        queryset=Manufacturer.objects.all()
+        queryset=Manufacturer.objects.all(),
+        quick_add=True
     )
     default_platform = DynamicModelChoiceField(
         label=_('Default platform'),
@@ -436,7 +440,8 @@ class PlatformForm(NetBoxModelForm):
     manufacturer = DynamicModelChoiceField(
         label=_('Manufacturer'),
         queryset=Manufacturer.objects.all(),
-        required=False
+        required=False,
+        quick_add=True
     )
     config_template = DynamicModelChoiceField(
         label=_('Config template'),
@@ -508,7 +513,8 @@ class DeviceForm(TenancyForm, NetBoxModelForm):
     )
     role = DynamicModelChoiceField(
         label=_('Device role'),
-        queryset=DeviceRole.objects.all()
+        queryset=DeviceRole.objects.all(),
+        quick_add=True
     )
     platform = DynamicModelChoiceField(
         label=_('Platform'),
@@ -750,7 +756,8 @@ class PowerFeedForm(TenancyForm, NetBoxModelForm):
     power_panel = DynamicModelChoiceField(
         label=_('Power panel'),
         queryset=PowerPanel.objects.all(),
-        selector=True
+        selector=True,
+        quick_add=True
     )
     rack = DynamicModelChoiceField(
         label=_('Rack'),

+ 10 - 4
netbox/ipam/forms/model_forms.py

@@ -109,7 +109,8 @@ class RIRForm(NetBoxModelForm):
 class AggregateForm(TenancyForm, NetBoxModelForm):
     rir = DynamicModelChoiceField(
         queryset=RIR.objects.all(),
-        label=_('RIR')
+        label=_('RIR'),
+        quick_add=True
     )
     comments = CommentField()
 
@@ -132,6 +133,7 @@ class ASNRangeForm(TenancyForm, NetBoxModelForm):
     rir = DynamicModelChoiceField(
         queryset=RIR.objects.all(),
         label=_('RIR'),
+        quick_add=True
     )
     slug = SlugField()
     fieldsets = (
@@ -150,6 +152,7 @@ class ASNForm(TenancyForm, NetBoxModelForm):
     rir = DynamicModelChoiceField(
         queryset=RIR.objects.all(),
         label=_('RIR'),
+        quick_add=True
     )
     sites = DynamicModelMultipleChoiceField(
         queryset=Site.objects.all(),
@@ -216,7 +219,8 @@ class PrefixForm(TenancyForm, ScopedForm, NetBoxModelForm):
     role = DynamicModelChoiceField(
         label=_('Role'),
         queryset=Role.objects.all(),
-        required=False
+        required=False,
+        quick_add=True
     )
     comments = CommentField()
 
@@ -246,7 +250,8 @@ class IPRangeForm(TenancyForm, NetBoxModelForm):
     role = DynamicModelChoiceField(
         label=_('Role'),
         queryset=Role.objects.all(),
-        required=False
+        required=False,
+        quick_add=True
     )
     comments = CommentField()
 
@@ -639,7 +644,8 @@ class VLANForm(TenancyForm, NetBoxModelForm):
     role = DynamicModelChoiceField(
         label=_('Role'),
         queryset=Role.objects.all(),
-        required=False
+        required=False,
+        quick_add=True
     )
     qinq_svlan = DynamicModelChoiceField(
         label=_('Q-in-Q SVLAN'),

+ 29 - 10
netbox/netbox/views/generic/object_views.py

@@ -233,18 +233,23 @@ class ObjectEditView(GetReturnURLMixin, BaseObjectView):
         form = self.form(instance=obj, initial=initial_data)
         restrict_form_fields(form, request.user)
 
+        context = {
+            'model': model,
+            'object': obj,
+            'form': form,
+        }
+
+        # If the form is being displayed within a "quick add" widget,
+        # use the appropriate template
+        if request.GET.get('_quickadd'):
+            return render(request, 'htmx/quick_add.html', context)
+
         # If this is an HTMX request, return only the rendered form HTML
         if htmx_partial(request):
-            return render(request, self.htmx_template_name, {
-                'model': model,
-                'object': obj,
-                'form': form,
-            })
+            return render(request, self.htmx_template_name, context)
 
         return render(request, self.template_name, {
-            'model': model,
-            'object': obj,
-            'form': form,
+            **context,
             'return_url': self.get_return_url(request, obj),
             'prerequisite_model': get_prerequisite_model(self.queryset),
             **self.get_extra_context(request, obj),
@@ -259,6 +264,7 @@ class ObjectEditView(GetReturnURLMixin, BaseObjectView):
         """
         logger = logging.getLogger('netbox.views.ObjectEditView')
         obj = self.get_object(**kwargs)
+        model = self.queryset.model
 
         # Take a snapshot for change logging (if editing an existing object)
         if obj.pk and hasattr(obj, 'snapshot'):
@@ -292,6 +298,12 @@ class ObjectEditView(GetReturnURLMixin, BaseObjectView):
                     msg = f'{msg} {obj}'
                 messages.success(request, msg)
 
+                # Object was created via "quick add" modal
+                if '_quickadd' in request.POST:
+                    return render(request, 'htmx/quick_add_created.html', {
+                        'object': obj,
+                    })
+
                 # If adding another object, redirect back to the edit form
                 if '_addanother' in request.POST:
                     redirect_url = request.path
@@ -324,12 +336,19 @@ class ObjectEditView(GetReturnURLMixin, BaseObjectView):
         else:
             logger.debug("Form validation failed")
 
-        return render(request, self.template_name, {
+        context = {
+            'model': model,
             'object': obj,
             'form': form,
             'return_url': self.get_return_url(request, obj),
             **self.get_extra_context(request, obj),
-        })
+        }
+
+        # Form was submitted via a "quick add" widget
+        if '_quickadd' in request.POST:
+            return render(request, 'htmx/quick_add.html', context)
+
+        return render(request, self.template_name, context)
 
 
 class ObjectDeleteView(GetReturnURLMixin, BaseObjectView):

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
netbox/project-static/dist/netbox.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
netbox/project-static/dist/netbox.js.map


+ 23 - 25
netbox/project-static/src/buttons/reslug.ts

@@ -1,3 +1,5 @@
+import { getElements } from '../util';
+
 /**
  * Create a slug from any input string.
  *
@@ -15,34 +17,30 @@ function slugify(slug: string, chars: number): string {
 }
 
 /**
- * If a slug field exists, add event listeners to handle automatically generating its value.
+ * For any slug fields, add event listeners to handle automatically generating slug values.
  */
 export function initReslug(): void {
-  const slugField = document.getElementById('id_slug') as HTMLInputElement;
-  const slugButton = document.getElementById('reslug') as HTMLButtonElement;
-  if (slugField === null || slugButton === null) {
-    return;
-  }
-  const sourceId = slugField.getAttribute('slug-source');
-  const sourceField = document.getElementById(`id_${sourceId}`) as HTMLInputElement;
-
-  if (sourceField === null) {
-    console.error('Unable to find field for slug field.');
-    return;
-  }
+  for (const slugButton of getElements<HTMLButtonElement>('button#reslug')) {
+    const form = slugButton.form;
+    if (form == null) continue;
+    const slugField = form.querySelector('#id_slug') as HTMLInputElement;
+    if (slugField == null) continue;
+    const sourceId = slugField.getAttribute('slug-source');
+    const sourceField = form.querySelector(`#id_${sourceId}`) as HTMLInputElement;
 
-  const slugLengthAttr = slugField.getAttribute('maxlength');
-  let slugLength = 50;
+    const slugLengthAttr = slugField.getAttribute('maxlength');
+    let slugLength = 50;
 
-  if (slugLengthAttr) {
-    slugLength = Number(slugLengthAttr);
-  }
-  sourceField.addEventListener('blur', () => {
-    if (!slugField.value) {
-      slugField.value = slugify(sourceField.value, slugLength);
+    if (slugLengthAttr) {
+      slugLength = Number(slugLengthAttr);
     }
-  });
-  slugButton.addEventListener('click', () => {
-    slugField.value = slugify(sourceField.value, slugLength);
-  });
+    sourceField.addEventListener('blur', () => {
+      if (!slugField.value) {
+        slugField.value = slugify(sourceField.value, slugLength);
+      }
+    });
+    slugButton.addEventListener('click', () => {
+      slugField.value = slugify(sourceField.value, slugLength);
+    });
+  }
 }

+ 8 - 3
netbox/project-static/src/htmx.ts

@@ -4,11 +4,16 @@ import { initSelects } from './select';
 import { initObjectSelector } from './objectSelector';
 import { initBootstrap } from './bs';
 import { initMessages } from './messages';
+import { initQuickAdd } from './quickAdd';
 
 function initDepedencies(): void {
-  for (const init of [initButtons, initClipboard, initSelects, initObjectSelector, initBootstrap, initMessages]) {
-    init();
-  }
+  initButtons();
+  initClipboard();
+  initSelects();
+  initObjectSelector();
+  initQuickAdd();
+  initBootstrap();
+  initMessages();
 }
 
 /**

+ 39 - 0
netbox/project-static/src/quickAdd.ts

@@ -0,0 +1,39 @@
+import { Modal } from 'bootstrap';
+
+function handleQuickAddObject(): void {
+  const quick_add = document.getElementById('quick-add-object');
+  if (quick_add == null) return;
+
+  const object_id = quick_add.getAttribute('data-object-id');
+  if (object_id == null) return;
+  const object_repr = quick_add.getAttribute('data-object-repr');
+  if (object_repr == null) return;
+
+  const target_id = quick_add.getAttribute('data-target-id');
+  if (target_id == null) return;
+  const target = document.getElementById(target_id);
+  if (target == null) return;
+
+  //@ts-expect-error tomselect added on init
+  target.tomselect.addOption({
+    id: object_id,
+    display: object_repr,
+  });
+  //@ts-expect-error tomselect added on init
+  target.tomselect.addItem(object_id);
+
+  const modal_element = document.getElementById('htmx-modal');
+  if (modal_element) {
+    const modal = Modal.getInstance(modal_element);
+    if (modal) {
+      modal.hide();
+    }
+  }
+}
+
+export function initQuickAdd(): void {
+  const quick_add_modal = document.getElementById('htmx-modal-content');
+  if (quick_add_modal) {
+    quick_add_modal.addEventListener('htmx:afterSwap', () => handleQuickAddObject());
+  }
+}

+ 28 - 0
netbox/templates/htmx/quick_add.html

@@ -0,0 +1,28 @@
+{% load form_helpers %}
+{% load helpers %}
+{% load i18n %}
+
+<div class="modal-header">
+  <h2 class="modal-title">
+    {% trans "Quick Add" %} {{ model|meta:"verbose_name"|bettertitle }}
+  </h2>
+  <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
+</div>
+<div class="modal-body row">
+  <form
+      hx-post="{% url model|viewname:"add" %}?_quickadd=True&target={{ request.GET.target }}"
+      hx-target="#htmx-modal-content"
+      enctype="multipart/form-data"
+  >
+    {% csrf_token %}
+    {% include 'htmx/form.html' %}
+    <div class="text-end">
+      <button type="button" class="btn btn-outline-secondary btn-float" data-bs-dismiss="modal" aria-label="Cancel">
+        {% trans "Cancel" %}
+      </button>
+      <button type="submit" name="_quickadd" class="btn btn-primary">
+        {% trans "Create" %}
+      </button>
+    </div>
+  </form>
+</div>

+ 22 - 0
netbox/templates/htmx/quick_add_created.html

@@ -0,0 +1,22 @@
+{% load form_helpers %}
+{% load helpers %}
+{% load i18n %}
+
+<div class="modal-header">
+  <h2 class="modal-title">
+    {{ object|meta:"verbose_name"|bettertitle }} {% trans "Created" %}
+  </h2>
+  <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
+</div>
+<div class="modal-body row">
+  {# This content is intended to be scraped and populated in the targeted selection field. #}
+  <p id="quick-add-object"
+      data-object-repr="{{ object }}"
+      data-object-id="{{ object.pk }}"
+      data-target-id="{{ request.GET.target }}"
+  >
+    {% blocktrans with object=object|linkify object_type=object|meta:"verbose_name" %}
+      Created {{ object_type }} {{ object }}
+    {% endblocktrans %}
+  </p>
+</div>

+ 1 - 0
netbox/tenancy/forms/forms.py

@@ -25,6 +25,7 @@ class TenancyForm(forms.Form):
         label=_('Tenant'),
         queryset=Tenant.objects.all(),
         required=False,
+        quick_add=True,
         query_params={
             'group_id': '$tenant_group'
         }

+ 11 - 1
netbox/utilities/forms/fields/dynamic.py

@@ -2,7 +2,7 @@ import django_filters
 from django import forms
 from django.conf import settings
 from django.forms import BoundField
-from django.urls import reverse
+from django.urls import reverse, reverse_lazy
 
 from utilities.forms import widgets
 from utilities.views import get_viewname
@@ -66,6 +66,8 @@ class DynamicModelChoiceMixin:
             choice (DEPRECATED: pass `context={'disabled': '$fieldname'}` instead)
         context: A mapping of <option> template variables to their API data keys (optional; see below)
         selector: Include an advanced object selection widget to assist the user in identifying the desired object
+        quick_add: Include a widget to quickly create a new related object for assignment. NOTE: Nested usage of
+            quick-add fields is not currently supported.
 
     Context keys:
         value: The name of the attribute which contains the option's value (default: 'id')
@@ -90,6 +92,7 @@ class DynamicModelChoiceMixin:
             disabled_indicator=None,
             context=None,
             selector=False,
+            quick_add=False,
             **kwargs
     ):
         self.model = queryset.model
@@ -99,6 +102,7 @@ class DynamicModelChoiceMixin:
         self.disabled_indicator = disabled_indicator
         self.context = context or {}
         self.selector = selector
+        self.quick_add = quick_add
 
         super().__init__(queryset, **kwargs)
 
@@ -121,6 +125,12 @@ class DynamicModelChoiceMixin:
         if self.selector:
             attrs['selector'] = self.model._meta.label_lower
 
+        # Include quick add?
+        if self.quick_add:
+            app_label = self.model._meta.app_label
+            model_name = self.model._meta.model_name
+            attrs['quick_add'] = reverse_lazy(f'{app_label}:{model_name}_add')
+
         return attrs
 
     def get_bound_field(self, form, field_name):

+ 20 - 7
netbox/utilities/templates/widgets/apiselect.html

@@ -1,7 +1,8 @@
 {% load i18n %}
-{% if widget.attrs.selector and not widget.attrs.disabled %}
-  <div class="d-flex">
-    {% include 'django/forms/widgets/select.html' %}
+<div class="d-flex">
+  {% include 'django/forms/widgets/select.html' %}
+  {% if widget.attrs.selector and not widget.attrs.disabled %}
+    {# Opens the object selector modal #}
     <button
       type="button"
       title="{% trans "Open selector" %}"
@@ -13,7 +14,19 @@
     >
       <i class="mdi mdi-database-search-outline"></i>
     </button>
-  </div>
-{% else %}
-  {% include 'django/forms/widgets/select.html' %}
-{% endif %}
+  {% endif %}
+  {% if widget.attrs.quick_add and not widget.attrs.disabled %}
+    {# Opens the quick add modal #}
+    <button
+      type="button"
+      title="{% trans "Quick add" %}"
+      class="btn btn-outline-secondary ms-1"
+      data-bs-toggle="modal"
+      data-bs-target="#htmx-modal"
+      hx-get="{{ widget.attrs.quick_add }}?_quickadd=True&target={{ widget.attrs.id }}"
+      hx-target="#htmx-modal-content"
+    >
+      <i class="mdi mdi-plus-circle"></i>
+    </button>
+  {% endif %}
+</div>

+ 4 - 2
netbox/virtualization/forms/model_forms.py

@@ -62,12 +62,14 @@ class ClusterGroupForm(NetBoxModelForm):
 class ClusterForm(TenancyForm, ScopedForm, NetBoxModelForm):
     type = DynamicModelChoiceField(
         label=_('Type'),
-        queryset=ClusterType.objects.all()
+        queryset=ClusterType.objects.all(),
+        quick_add=True
     )
     group = DynamicModelChoiceField(
         label=_('Group'),
         queryset=ClusterGroup.objects.all(),
-        required=False
+        required=False,
+        quick_add=True
     )
     comments = CommentField()
 

+ 6 - 3
netbox/vpn/forms/model_forms.py

@@ -47,7 +47,8 @@ class TunnelForm(TenancyForm, NetBoxModelForm):
     group = DynamicModelChoiceField(
         queryset=TunnelGroup.objects.all(),
         label=_('Tunnel Group'),
-        required=False
+        required=False,
+        quick_add=True
     )
     ipsec_profile = DynamicModelChoiceField(
         queryset=IPSecProfile.objects.all(),
@@ -313,7 +314,8 @@ class IKEProposalForm(NetBoxModelForm):
 class IKEPolicyForm(NetBoxModelForm):
     proposals = DynamicModelMultipleChoiceField(
         queryset=IKEProposal.objects.all(),
-        label=_('Proposals')
+        label=_('Proposals'),
+        quick_add=True
     )
 
     fieldsets = (
@@ -349,7 +351,8 @@ class IPSecProposalForm(NetBoxModelForm):
 class IPSecPolicyForm(NetBoxModelForm):
     proposals = DynamicModelMultipleChoiceField(
         queryset=IPSecProposal.objects.all(),
-        label=_('Proposals')
+        label=_('Proposals'),
+        quick_add=True
     )
 
     fieldsets = (

+ 2 - 1
netbox/wireless/forms/model_forms.py

@@ -40,7 +40,8 @@ class WirelessLANForm(ScopedForm, TenancyForm, NetBoxModelForm):
     group = DynamicModelChoiceField(
         label=_('Group'),
         queryset=WirelessLANGroup.objects.all(),
-        required=False
+        required=False,
+        quick_add=True
     )
     vlan = DynamicModelChoiceField(
         queryset=VLAN.objects.all(),

Некоторые файлы не были показаны из-за большого количества измененных файлов