Repository: netbox-community/netbox
Pull request: Fixes #6454 - Adds warning for prerequisite models
<!--
    Thank you for your interest in contributing to NetBox! Please note
    that our contribution policy requires that a feature request or bug
    report be opened for approval prior to filing a pull request. This
    helps avoid wasting time and effort on something that we might not
    be able to accept.

    Please indicate the relevant feature request or bug report below.
    IF YOUR PULL REQUEST DOES NOT REFERENCE AN ACCEPTED BUG REPORT OR
    FEATURE REQUEST, IT WILL BE MARKED AS INVALID AND CLOSED.
-->
### Fixes: #6454
<!--
    Please include a summary of the proposed changes below.
-->
Adds warnings to list and detail view screens if pre-requisite models have not been created.  Fox example, if you try to create a device without having any site objects it will warn you that you first need to create at least one site and provides the Add button to go there (see screenshot):

![screencap-aug-22](https://user-images.githubusercontent.com/99642/185958644-16b2b7cd-b3a1-465d-8da8-cf167fdd67ff.png)


Problem statement:
Include contextual help in UI for new installations
### NetBox version

v2.11.3

### Feature type

New functionality

### Proposed functionality

Provide some contextual help within the NetBox UI to better assist users with initial population of objects. For example, the view to add a new device type should check that at least one manufacturer has been created first; if not, display a warning and relevant link to the user.

### Use case

This would help the initial setup go a bit smoother for new deployments, especially for users who are new to NetBox.

### Database changes

_No response_

### External dependencies

_No response_

Hints from issue discussion:
This issue has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs. NetBox is governed by a small group of core maintainers which means not all opened issues may receive direct feedback. Please see our [contributing guide](https://github.com/netbox-community/netbox/blob/develop/CONTRIBUTING.md).
I'm going to take a stab at this during the v3.0 beta period. If it proves to be more effort than it's worth, we can bail on this.
Doesn't look like I'll have time to take this on any time soon, unfortunately, but I'll leave it open for volunteers. Happy to assist however I can.
This issue has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs. NetBox is governed by a small group of core maintainers which means not all opened issues may receive direct feedback. Please see our [contributing guide](https://github.com/netbox-community/netbox/blob/develop/CONTRIBUTING.md).

Commit message: #6454 changes from PR review

Patch to review:
diff --git a/netbox/circuits/models/circuits.py b/netbox/circuits/models/circuits.py
index c78ea81c78a..c08b5473a5f 100644
--- a/netbox/circuits/models/circuits.py
+++ b/netbox/circuits/models/circuits.py
@@ -1,3 +1,4 @@
+from django.apps import apps
 from django.contrib.contenttypes.fields import GenericRelation
 from django.core.exceptions import ValidationError
 from django.db import models
@@ -136,6 +137,10 @@ class Meta:
     def __str__(self):
         return self.cid
 
+    @classmethod
+    def get_prerequisite_models(cls):
+        return [apps.get_model('circuits.Provider'), CircuitType]
+
     def get_absolute_url(self):
         return reverse('circuits:circuit', args=[self.pk])
 
diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py
index 136fcf6cf66..092df3a0e8c 100644
--- a/netbox/dcim/models/devices.py
+++ b/netbox/dcim/models/devices.py
@@ -1,6 +1,8 @@
 import decimal
 
 import yaml
+
+from django.apps import apps
 from django.contrib.contenttypes.fields import GenericRelation
 from django.core.exceptions import ValidationError
 from django.core.validators import MaxValueValidator, MinValueValidator
@@ -159,6 +161,10 @@ def __init__(self, *args, **kwargs):
         self._original_front_image = self.front_image
         self._original_rear_image = self.rear_image
 
+    @classmethod
+    def get_prerequisite_models(cls):
+        return [Manufacturer, ]
+
     def get_absolute_url(self):
         return reverse('dcim:devicetype', args=[self.pk])
 
@@ -338,6 +344,10 @@ class Meta:
     def __str__(self):
         return self.model
 
+    @classmethod
+    def get_prerequisite_models(cls):
+        return [Manufacturer, ]
+
     def get_absolute_url(self):
         return reverse('dcim:moduletype', args=[self.pk])
 
@@ -658,6 +668,10 @@ def __str__(self):
             return f'{self.device_type.manufacturer} {self.device_type.model} ({self.pk})'
         return super().__str__()
 
+    @classmethod
+    def get_prerequisite_models(cls):
+        return [apps.get_model('dcim.Site'), DeviceRole, DeviceType, ]
+
     def get_absolute_url(self):
         return reverse('dcim:device', args=[self.pk])
 
diff --git a/netbox/dcim/models/power.py b/netbox/dcim/models/power.py
index c275691c0d1..83eead67fe8 100644
--- a/netbox/dcim/models/power.py
+++ b/netbox/dcim/models/power.py
@@ -1,3 +1,4 @@
+from django.apps import apps
 from django.contrib.contenttypes.fields import GenericRelation
 from django.core.exceptions import ValidationError
 from django.core.validators import MaxValueValidator, MinValueValidator
@@ -54,6 +55,10 @@ class Meta:
     def __str__(self):
         return self.name
 
+    @classmethod
+    def get_prerequisite_models(cls):
+        return [apps.get_model('dcim.Site'), ]
+
     def get_absolute_url(self):
         return reverse('dcim:powerpanel', args=[self.pk])
 
@@ -138,6 +143,10 @@ class Meta:
     def __str__(self):
         return self.name
 
+    @classmethod
+    def get_prerequisite_models(cls):
+        return [PowerPanel, ]
+
     def get_absolute_url(self):
         return reverse('dcim:powerfeed', args=[self.pk])
 
diff --git a/netbox/dcim/models/racks.py b/netbox/dcim/models/racks.py
index 50c91b52e8f..22fca8cf676 100644
--- a/netbox/dcim/models/racks.py
+++ b/netbox/dcim/models/racks.py
@@ -1,5 +1,6 @@
 import decimal
 
+from django.apps import apps
 from django.contrib.auth.models import User
 from django.contrib.contenttypes.fields import GenericRelation
 from django.contrib.contenttypes.models import ContentType
@@ -201,6 +202,10 @@ def __str__(self):
             return f'{self.name} ({self.facility_id})'
         return self.name
 
+    @classmethod
+    def get_prerequisite_models(cls):
+        return [apps.get_model('dcim.Site'), ]
+
     def get_absolute_url(self):
         return reverse('dcim:rack', args=[self.pk])
 
@@ -477,6 +482,10 @@ class Meta:
     def __str__(self):
         return "Reservation for rack {}".format(self.rack)
 
+    @classmethod
+    def get_prerequisite_models(cls):
+        return [apps.get_model('dcim.Site'), Rack, ]
+
     def get_absolute_url(self):
         return reverse('dcim:rackreservation', args=[self.pk])
 
diff --git a/netbox/dcim/models/sites.py b/netbox/dcim/models/sites.py
index 67bcc6e4c1b..f5c8e6d9d76 100644
--- a/netbox/dcim/models/sites.py
+++ b/netbox/dcim/models/sites.py
@@ -411,6 +411,10 @@ def validate_unique(self, exclude=None):
 
         super().validate_unique(exclude=exclude)
 
+    @classmethod
+    def get_prerequisite_models(cls):
+        return [Site, ]
+
     def get_absolute_url(self):
         return reverse('dcim:location', args=[self.pk])
 
diff --git a/netbox/ipam/models/ip.py b/netbox/ipam/models/ip.py
index 26cee8100cc..2d3f4d291fc 100644
--- a/netbox/ipam/models/ip.py
+++ b/netbox/ipam/models/ip.py
@@ -124,6 +124,10 @@ class Meta:
     def __str__(self):
         return f'AS{self.asn_with_asdot}'
 
+    @classmethod
+    def get_prerequisite_models(cls):
+        return [RIR, ]
+
     def get_absolute_url(self):
         return reverse('ipam:asn', args=[self.pk])
 
@@ -185,6 +189,10 @@ class Meta:
     def __str__(self):
         return str(self.prefix)
 
+    @classmethod
+    def get_prerequisite_models(cls):
+        return [RIR, ]
+
     def get_absolute_url(self):
         return reverse('ipam:aggregate', args=[self.pk])
 
diff --git a/netbox/ipam/models/l2vpn.py b/netbox/ipam/models/l2vpn.py
index 0e948b18ec9..80900703339 100644
--- a/netbox/ipam/models/l2vpn.py
+++ b/netbox/ipam/models/l2vpn.py
@@ -1,3 +1,4 @@
+from django.apps import apps
 from django.contrib.contenttypes.fields import GenericRelation, GenericForeignKey
 from django.contrib.contenttypes.models import ContentType
 from django.core.exceptions import ValidationError
@@ -103,6 +104,10 @@ def __str__(self):
             return f'{self.assigned_object} <> {self.l2vpn}'
         return super().__str__()
 
+    @classmethod
+    def get_prerequisite_models(cls):
+        return [apps.get_model('ipam.L2VPN'), ]
+
     def get_absolute_url(self):
         return reverse('ipam:l2vpntermination', args=[self.pk])
 
diff --git a/netbox/netbox/models/__init__.py b/netbox/netbox/models/__init__.py
index b9d5859524b..4c65094ca7e 100644
--- a/netbox/netbox/models/__init__.py
+++ b/netbox/netbox/models/__init__.py
@@ -28,6 +28,14 @@ class NetBoxFeatureSet(
     class Meta:
         abstract = True
 
+    @classmethod
+    def get_prerequisite_models(cls):
+        """
+        Return a list of model types that are required to create this model or empty list if none.  This is used for
+        showing prequisite warnings in the UI on the list and detail views.
+        """
+        return []
+
 
 #
 # Base model classes
diff --git a/netbox/netbox/views/generic/bulk_views.py b/netbox/netbox/views/generic/bulk_views.py
index 60ad4a2cc66..8fe0ad51874 100644
--- a/netbox/netbox/views/generic/bulk_views.py
+++ b/netbox/netbox/views/generic/bulk_views.py
@@ -26,6 +26,7 @@
 from utilities.views import GetReturnURLMixin
 from .base import BaseMultiObjectView
 from .mixins import ActionsMixin, TableMixin
+from .utils import get_prerequisite_model
 
 __all__ = (
     'BulkComponentCreateView',
@@ -123,6 +124,7 @@ def get(self, request):
         """
         model = self.queryset.model
         content_type = ContentType.objects.get_for_model(model)
+        requirement = get_prerequisite_model(self.queryset)
 
         if self.filterset:
             self.queryset = self.filterset(request.GET, self.queryset).qs
@@ -165,13 +167,16 @@ def get(self, request):
                 'table': table,
             })
 
-        return render(request, self.template_name, {
+        context = {
             'model': model,
             'table': table,
             'actions': actions,
             'filter_form': self.filterset_form(request.GET, label_suffix='') if self.filterset_form else None,
+            'prerequisite_model': requirement if requirement else None,
             **self.get_extra_context(request),
-        })
+        }
+
+        return render(request, self.template_name, context)
 
 
 class BulkCreateView(GetReturnURLMixin, BaseMultiObjectView):
diff --git a/netbox/netbox/views/generic/object_views.py b/netbox/netbox/views/generic/object_views.py
index 5ff0cfdff36..c9ff738d713 100644
--- a/netbox/netbox/views/generic/object_views.py
+++ b/netbox/netbox/views/generic/object_views.py
@@ -21,6 +21,7 @@
 from utilities.views import GetReturnURLMixin
 from .base import BaseObjectView
 from .mixins import ActionsMixin, TableMixin
+from .utils import get_prerequisite_model
 
 __all__ = (
     'ComponentCreateView',
@@ -340,15 +341,19 @@ def get(self, request, *args, **kwargs):
         """
         obj = self.get_object(**kwargs)
         obj = self.alter_object(obj, request, args, kwargs)
+        model = self.queryset.model
 
         initial_data = normalize_querydict(request.GET)
         form = self.form(instance=obj, initial=initial_data)
         restrict_form_fields(form, request.user)
 
+        requirement = get_prerequisite_model(self.queryset)
         return render(request, self.template_name, {
+            'model': model,
             'object': obj,
             'form': form,
             'return_url': self.get_return_url(request, obj),
+            'prerequisite_model': requirement if requirement else None,
             **self.get_extra_context(request, obj),
         })
 
diff --git a/netbox/netbox/views/generic/utils.py b/netbox/netbox/views/generic/utils.py
new file mode 100644
index 00000000000..61c6dc24291
--- /dev/null
+++ b/netbox/netbox/views/generic/utils.py
@@ -0,0 +1,12 @@
+def get_prerequisite_model(queryset):
+    model = queryset.model
+
+    if not queryset.exists():
+        if hasattr(model, 'get_prerequisite_models'):
+            prerequisites = model.get_prerequisite_models()
+            if prerequisites:
+                for prereq in prerequisites:
+                    if not prereq.objects.exists():
+                        return prereq
+
+    return None
diff --git a/netbox/templates/generic/object_edit.html b/netbox/templates/generic/object_edit.html
index 892c7d2b1a2..8047dc59d58 100644
--- a/netbox/templates/generic/object_edit.html
+++ b/netbox/templates/generic/object_edit.html
@@ -40,6 +40,10 @@
         </div>
       {% endif %}
 
+      {% if prerequisite_model %}
+        {% include 'inc/missing_prerequisites.html' %}
+      {% endif %}
+
       <form action="" method="post" enctype="multipart/form-data" class="form-object-edit mt-5">
         {% csrf_token %}
 
diff --git a/netbox/templates/generic/object_list.html b/netbox/templates/generic/object_list.html
index 1e2ae796f34..9d3952a284d 100644
--- a/netbox/templates/generic/object_list.html
+++ b/netbox/templates/generic/object_list.html
@@ -100,6 +100,12 @@
         <input type="hidden" name="return_url" value="{% if return_url %}{{ return_url }}{% else %}{{ request.path }}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}{% endif %}" />
 
         {# Object table #}
+
+            {% if prerequisite_model %}
+              {% include 'inc/missing_prerequisites.html' %}
+            {% endif %}
+
+
         <div class="card">
           <div class="card-body" id="object_list">
             {% include 'htmx/table.html' %}
diff --git a/netbox/templates/inc/missing_prerequisites.html b/netbox/templates/inc/missing_prerequisites.html
new file mode 100644
index 00000000000..5814b72eb5a
--- /dev/null
+++ b/netbox/templates/inc/missing_prerequisites.html
@@ -0,0 +1,6 @@
+{% load buttons %}
+
+<div class="alert alert-warning" role="alert">
+  <i class="mdi mdi-alert"></i> Before you can add a {{ model|meta:"verbose_name" }} you must first create a
+    <strong>{{ prerequisite_model|meta:"verbose_name"|title }}</strong> which can be added here: {% add_button prerequisite_model %}
+</div>
diff --git a/netbox/virtualization/models.py b/netbox/virtualization/models.py
index 21bc799be6c..b8131c1ce2e 100644
--- a/netbox/virtualization/models.py
+++ b/netbox/virtualization/models.py
@@ -167,6 +167,10 @@ class Meta:
     def __str__(self):
         return self.name
 
+    @classmethod
+    def get_prerequisite_models(cls):
+        return [ClusterType, ]
+
     def get_absolute_url(self):
         return reverse('virtualization:cluster', args=[self.pk])
 
@@ -312,6 +316,10 @@ class Meta:
     def __str__(self):
         return self.name
 
+    @classmethod
+    def get_prerequisite_models(cls):
+        return [Cluster, ]
+
     def get_absolute_url(self):
         return reverse('virtualization:virtualmachine', args=[self.pk])
 
diff --git a/netbox/wireless/models.py b/netbox/wireless/models.py
index 0540e9c45f5..36410b83b4e 100644
--- a/netbox/wireless/models.py
+++ b/netbox/wireless/models.py
@@ -1,3 +1,4 @@
+from django.apps import apps
 from django.core.exceptions import ValidationError
 from django.db import models
 from django.urls import reverse
@@ -190,6 +191,10 @@ class Meta:
     def __str__(self):
         return f'#{self.pk}'
 
+    @classmethod
+    def get_prerequisite_models(cls):
+        return [apps.get_model('dcim.Interface'), ]
+
     def get_absolute_url(self):
         return reverse('wireless:wirelesslink', args=[self.pk])
 


Code review comments:
diff_hunk: @@ -165,13 +167,16 @@ def get(self, request):
                 'table': table,
             })
 
-        return render(request, self.template_name, {
+        context = {
             'model': model,
             'table': table,
             'actions': actions,
             'filter_form': self.filterset_form(request.GET, label_suffix='') if self.filterset_form else None,
+            'prerequisite_model': requirement if requirement else None,
path: netbox/netbox/views/generic/bulk_views.py
text: @user1:
```suggestion
            'prerequisite_model': get_prerequisite_model(self.queryset),
```

diff_hunk: @@ -100,6 +100,12 @@
         <input type="hidden" name="return_url" value="{% if return_url %}{{ return_url }}{% else %}{{ request.path }}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}{% endif %}" />
 
         {# Object table #}
+
+            {% if prerequisite_model %}
+              {% include 'inc/missing_prerequisites.html' %}
+            {% endif %}
+
+
path: netbox/templates/generic/object_list.html
text: @user1:
```suggestion
```

diff_hunk: @@ -340,15 +341,19 @@ def get(self, request, *args, **kwargs):
         """
         obj = self.get_object(**kwargs)
         obj = self.alter_object(obj, request, args, kwargs)
+        model = self.queryset.model
 
         initial_data = normalize_querydict(request.GET)
         form = self.form(instance=obj, initial=initial_data)
         restrict_form_fields(form, request.user)
 
+        requirement = get_prerequisite_model(self.queryset)
         return render(request, self.template_name, {
+            'model': model,
             'object': obj,
             'form': form,
             'return_url': self.get_return_url(request, obj),
+            'prerequisite_model': requirement if requirement else None,
path: netbox/netbox/views/generic/object_views.py
text: @user1:
```suggestion
            'prerequisite_model': get_prerequisite_model(self.queryset),
```

diff_hunk: @@ -123,6 +124,7 @@ def get(self, request):
         """
         model = self.queryset.model
         content_type = ContentType.objects.get_for_model(model)
+        requirement = get_prerequisite_model(self.queryset)
path: netbox/netbox/views/generic/bulk_views.py
text: @user1:
```suggestion
```

diff_hunk: @@ -340,15 +341,19 @@ def get(self, request, *args, **kwargs):
         """
         obj = self.get_object(**kwargs)
         obj = self.alter_object(obj, request, args, kwargs)
+        model = self.queryset.model
 
         initial_data = normalize_querydict(request.GET)
         form = self.form(instance=obj, initial=initial_data)
         restrict_form_fields(form, request.user)
 
+        requirement = get_prerequisite_model(self.queryset)
path: netbox/netbox/views/generic/object_views.py
text: @user1:
```suggestion
```