diff --git a/netbox/circuits/models.py b/netbox/circuits/models.py
index bc7dcc219..3d213b48d 100644
--- a/netbox/circuits/models.py
+++ b/netbox/circuits/models.py
@@ -62,6 +62,11 @@ class Provider(PrimaryModel):
blank=True
)
+ # Generic relations
+ contacts = GenericRelation(
+ to='tenancy.ContactAssignment'
+ )
+
objects = RestrictedQuerySet.as_manager()
clone_fields = [
@@ -203,6 +208,11 @@ class Circuit(PrimaryModel):
comments = models.TextField(
blank=True
)
+
+ # Generic relations
+ contacts = GenericRelation(
+ to='tenancy.ContactAssignment'
+ )
images = GenericRelation(
to='extras.ImageAttachment'
)
diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py
index 669f5cfbd..308a094c3 100644
--- a/netbox/dcim/models/devices.py
+++ b/netbox/dcim/models/devices.py
@@ -54,6 +54,11 @@ class Manufacturer(OrganizationalModel):
blank=True
)
+ # Generic relations
+ contacts = GenericRelation(
+ to='tenancy.ContactAssignment'
+ )
+
objects = RestrictedQuerySet.as_manager()
class Meta:
@@ -584,6 +589,11 @@ class Device(PrimaryModel, ConfigContextModel):
comments = models.TextField(
blank=True
)
+
+ # Generic relations
+ contacts = GenericRelation(
+ to='tenancy.ContactAssignment'
+ )
images = GenericRelation(
to='extras.ImageAttachment'
)
diff --git a/netbox/dcim/models/power.py b/netbox/dcim/models/power.py
index 0e9520b36..f4d0ce8df 100644
--- a/netbox/dcim/models/power.py
+++ b/netbox/dcim/models/power.py
@@ -40,6 +40,11 @@ class PowerPanel(PrimaryModel):
name = models.CharField(
max_length=100
)
+
+ # Generic relations
+ contacts = GenericRelation(
+ to='tenancy.ContactAssignment'
+ )
images = GenericRelation(
to='extras.ImageAttachment'
)
diff --git a/netbox/dcim/models/racks.py b/netbox/dcim/models/racks.py
index c287d7d6c..47fcd42e4 100644
--- a/netbox/dcim/models/racks.py
+++ b/netbox/dcim/models/racks.py
@@ -175,12 +175,17 @@ class Rack(PrimaryModel):
comments = models.TextField(
blank=True
)
+
+ # Generic relations
vlan_groups = GenericRelation(
to='ipam.VLANGroup',
content_type_field='scope_type',
object_id_field='scope_id',
related_query_name='rack'
)
+ contacts = GenericRelation(
+ to='tenancy.ContactAssignment'
+ )
images = GenericRelation(
to='extras.ImageAttachment'
)
diff --git a/netbox/dcim/models/sites.py b/netbox/dcim/models/sites.py
index b343f61f2..0d9816b0b 100644
--- a/netbox/dcim/models/sites.py
+++ b/netbox/dcim/models/sites.py
@@ -52,12 +52,17 @@ class Region(NestedGroupModel):
max_length=200,
blank=True
)
+
+ # Generic relations
vlan_groups = GenericRelation(
to='ipam.VLANGroup',
content_type_field='scope_type',
object_id_field='scope_id',
related_query_name='region'
)
+ contacts = GenericRelation(
+ to='tenancy.ContactAssignment'
+ )
def get_absolute_url(self):
return reverse('dcim:region', args=[self.pk])
@@ -100,12 +105,17 @@ class SiteGroup(NestedGroupModel):
max_length=200,
blank=True
)
+
+ # Generic relations
vlan_groups = GenericRelation(
to='ipam.VLANGroup',
content_type_field='scope_type',
object_id_field='scope_id',
related_query_name='site_group'
)
+ contacts = GenericRelation(
+ to='tenancy.ContactAssignment'
+ )
def get_absolute_url(self):
return reverse('dcim:sitegroup', args=[self.pk])
@@ -221,12 +231,17 @@ class Site(PrimaryModel):
comments = models.TextField(
blank=True
)
+
+ # Generic relations
vlan_groups = GenericRelation(
to='ipam.VLANGroup',
content_type_field='scope_type',
object_id_field='scope_id',
related_query_name='site'
)
+ contacts = GenericRelation(
+ to='tenancy.ContactAssignment'
+ )
images = GenericRelation(
to='extras.ImageAttachment'
)
@@ -291,12 +306,17 @@ class Location(NestedGroupModel):
max_length=200,
blank=True
)
+
+ # Generic relations
vlan_groups = GenericRelation(
to='ipam.VLANGroup',
content_type_field='scope_type',
object_id_field='scope_id',
related_query_name='location'
)
+ contacts = GenericRelation(
+ to='tenancy.ContactAssignment'
+ )
images = GenericRelation(
to='extras.ImageAttachment'
)
diff --git a/netbox/templates/circuits/circuit.html b/netbox/templates/circuits/circuit.html
index b863a8a0e..3a8096351 100644
--- a/netbox/templates/circuits/circuit.html
+++ b/netbox/templates/circuits/circuit.html
@@ -70,11 +70,12 @@
{% plugin_left_page object %}
- {% include 'circuits/inc/circuit_termination.html' with termination=object.termination_a side='A' %}
- {% include 'circuits/inc/circuit_termination.html' with termination=object.termination_z side='Z' %}
- {% include 'inc/image_attachments_panel.html' %}
- {% plugin_right_page object %}
-
+ {% include 'circuits/inc/circuit_termination.html' with termination=object.termination_a side='A' %}
+ {% include 'circuits/inc/circuit_termination.html' with termination=object.termination_z side='Z' %}
+ {% include 'inc/contacts_panel.html' %}
+ {% include 'inc/image_attachments_panel.html' %}
+ {% plugin_right_page object %}
+
diff --git a/netbox/templates/circuits/provider.html b/netbox/templates/circuits/provider.html
index 4d35da0e6..af883e56f 100644
--- a/netbox/templates/circuits/provider.html
+++ b/netbox/templates/circuits/provider.html
@@ -47,12 +47,13 @@
+ {% include 'extras/inc/tags_panel.html' with tags=object.tags.all url='circuits:provider_list' %}
{% plugin_left_page object %}
{% include 'inc/custom_fields_panel.html' %}
- {% include 'extras/inc/tags_panel.html' with tags=object.tags.all url='circuits:provider_list' %}
{% include 'inc/comments_panel.html' %}
+ {% include 'inc/contacts_panel.html' %}
{% plugin_right_page object %}
diff --git a/netbox/templates/dcim/device.html b/netbox/templates/dcim/device.html
index ec1ea3fa1..9ae9df7d4 100644
--- a/netbox/templates/dcim/device.html
+++ b/netbox/templates/dcim/device.html
@@ -296,6 +296,7 @@
{% endif %}
+ {% include 'inc/contacts_panel.html' %}
{% include 'inc/image_attachments_panel.html' %}
{% include 'inc/custom_fields_panel.html' %}
+ {% include 'inc/contacts_panel.html' %}
{% include 'inc/image_attachments_panel.html' %}
{% plugin_right_page object %}
diff --git a/netbox/templates/dcim/manufacturer.html b/netbox/templates/dcim/manufacturer.html
index 85d76f14f..2a56b57cc 100644
--- a/netbox/templates/dcim/manufacturer.html
+++ b/netbox/templates/dcim/manufacturer.html
@@ -38,6 +38,7 @@
{% include 'inc/custom_fields_panel.html' %}
+ {% include 'inc/contacts_panel.html' %}
{% plugin_right_page object %}
diff --git a/netbox/templates/dcim/powerpanel.html b/netbox/templates/dcim/powerpanel.html
index b1367aa1e..10975fa1b 100644
--- a/netbox/templates/dcim/powerpanel.html
+++ b/netbox/templates/dcim/powerpanel.html
@@ -44,6 +44,7 @@
{% include 'inc/custom_fields_panel.html' %}
+ {% include 'inc/contacts_panel.html' %}
{% include 'inc/image_attachments_panel.html' %}
{% plugin_right_page object %}
diff --git a/netbox/templates/dcim/rack.html b/netbox/templates/dcim/rack.html
index 5d44e2125..0196a9a18 100644
--- a/netbox/templates/dcim/rack.html
+++ b/netbox/templates/dcim/rack.html
@@ -332,6 +332,7 @@
{% endif %}
+ {% include 'inc/contacts_panel.html' %}
{% plugin_right_page object %}
diff --git a/netbox/templates/dcim/region.html b/netbox/templates/dcim/region.html
index b46c905c3..1ee21a60e 100644
--- a/netbox/templates/dcim/region.html
+++ b/netbox/templates/dcim/region.html
@@ -46,6 +46,7 @@
{% include 'inc/custom_fields_panel.html' %}
+ {% include 'inc/contacts_panel.html' %}
{% plugin_left_page object %}
diff --git a/netbox/templates/dcim/site.html b/netbox/templates/dcim/site.html
index 1ee8cfce0..92023f8d6 100644
--- a/netbox/templates/dcim/site.html
+++ b/netbox/templates/dcim/site.html
@@ -76,6 +76,10 @@
Facility |
{{ object.facility|placeholder }} |
+
+ Description |
+ {{ object.description|placeholder }} |
+
AS Number |
{{ object.asn|placeholder }} |
@@ -91,19 +95,6 @@
{% endif %}
-
- Description |
- {{ object.description|placeholder }} |
-
-
-
-
-
-
-
-
Physical Address |
@@ -138,33 +129,57 @@
{% endif %}
|
-
- Contact Name |
- {{ object.contact_name|placeholder }} |
-
-
- Contact Phone |
-
- {% if object.contact_phone %}
- {{ object.contact_phone }}
- {% else %}
- —
- {% endif %}
- |
-
-
- Contact E-Mail |
-
- {% if object.contact_email %}
- {{ object.contact_email }}
- {% else %}
- —
- {% endif %}
- |
-
+ {% include 'inc/contacts_panel.html' %}
+
+
+
+ {% with deprecation_warning="This field will be removed in a future release. Please migrate this data to contact objects." %}
+
+
+ Contact Name |
+
+ {% if object.contact_name %}
+
+
+
+ {% endif %}
+ {{ object.contact_name|placeholder }}
+ |
+
+
+ Contact Phone |
+
+ {% if object.contact_phone %}
+
+
+
+ {{ object.contact_phone }}
+ {% else %}
+ —
+ {% endif %}
+ |
+
+
+ Contact E-Mail |
+
+ {% if object.contact_email %}
+
+
+
+ {{ object.contact_email }}
+ {% else %}
+ —
+ {% endif %}
+ |
+
+
+ {% endwith %}
+
+
{% include 'inc/custom_fields_panel.html' %}
{% include 'extras/inc/tags_panel.html' with tags=object.tags.all url='dcim:site_list' %}
{% include 'inc/comments_panel.html' %}
diff --git a/netbox/templates/dcim/sitegroup.html b/netbox/templates/dcim/sitegroup.html
index 856a86d64..610917078 100644
--- a/netbox/templates/dcim/sitegroup.html
+++ b/netbox/templates/dcim/sitegroup.html
@@ -46,6 +46,7 @@
{% include 'inc/custom_fields_panel.html' %}
+ {% include 'inc/contacts_panel.html' %}
{% plugin_left_page object %}
diff --git a/netbox/templates/inc/contacts_panel.html b/netbox/templates/inc/contacts_panel.html
new file mode 100644
index 000000000..33788a561
--- /dev/null
+++ b/netbox/templates/inc/contacts_panel.html
@@ -0,0 +1,49 @@
+{% load helpers %}
+
+
+
+
+ {% with contacts=object.contacts.all %}
+ {% if contacts.exists %}
+
+
+ Name |
+ Role |
+ Priority |
+ |
+
+ {% for contact in contacts %}
+
+
+ {{ contact.contact }}
+ |
+ {{ contact.role|placeholder }} |
+ {{ contact.get_priority_display|placeholder }} |
+
+ {% if perms.tenancy.change_contactassignment %}
+
+
+
+ {% endif %}
+ {% if perms.tenancy.delete_contactassignment %}
+
+
+
+ {% endif %}
+ |
+
+ {% endfor %}
+
+ {% else %}
+
None
+ {% endif %}
+ {% endwith %}
+
+ {% if perms.tenancy.add_contactassignment %}
+
+ {% endif %}
+
diff --git a/netbox/templates/tenancy/tenant.html b/netbox/templates/tenancy/tenant.html
index dee7f7ce7..54b29e946 100644
--- a/netbox/templates/tenancy/tenant.html
+++ b/netbox/templates/tenancy/tenant.html
@@ -38,6 +38,7 @@
{% include 'inc/custom_fields_panel.html' %}
{% include 'extras/inc/tags_panel.html' with tags=object.tags.all url='tenancy:tenant_list' %}
{% include 'inc/comments_panel.html' %}
+ {% include 'inc/contacts_panel.html' %}
{% plugin_left_page object %}
diff --git a/netbox/templates/virtualization/cluster.html b/netbox/templates/virtualization/cluster.html
index 769ae431f..fa8cad039 100644
--- a/netbox/templates/virtualization/cluster.html
+++ b/netbox/templates/virtualization/cluster.html
@@ -62,6 +62,7 @@
{% include 'inc/custom_fields_panel.html' %}
{% include 'extras/inc/tags_panel.html' with tags=object.tags.all url='virtualization:cluster_list' %}
+ {% include 'inc/contacts_panel.html' %}
{% plugin_right_page object %}
diff --git a/netbox/templates/virtualization/clustergroup.html b/netbox/templates/virtualization/clustergroup.html
index f7e8cbe5b..fd83c10f3 100644
--- a/netbox/templates/virtualization/clustergroup.html
+++ b/netbox/templates/virtualization/clustergroup.html
@@ -32,6 +32,7 @@
{% include 'inc/custom_fields_panel.html' %}
+ {% include 'inc/contacts_panel.html' %}
{% plugin_right_page object %}
diff --git a/netbox/templates/virtualization/virtualmachine.html b/netbox/templates/virtualization/virtualmachine.html
index 249ef91e4..0ef590112 100644
--- a/netbox/templates/virtualization/virtualmachine.html
+++ b/netbox/templates/virtualization/virtualmachine.html
@@ -173,6 +173,7 @@
{% endif %}
+ {% include 'inc/contacts_panel.html' %}
{% plugin_right_page object %}
diff --git a/netbox/tenancy/forms/models.py b/netbox/tenancy/forms/models.py
index 6c0640d53..c0aec0aa8 100644
--- a/netbox/tenancy/forms/models.py
+++ b/netbox/tenancy/forms/models.py
@@ -1,11 +1,15 @@
+from django import forms
+
from extras.forms import CustomFieldModelForm
from extras.models import Tag
from tenancy.models import *
from utilities.forms import (
BootstrapMixin, CommentField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, SlugField, SmallTextarea,
+ StaticSelect,
)
__all__ = (
+ 'ContactAssignmentForm',
'ContactForm',
'ContactGroupForm',
'ContactRoleForm',
@@ -100,3 +104,25 @@ class ContactForm(BootstrapMixin, CustomFieldModelForm):
widgets = {
'address': SmallTextarea(attrs={'rows': 3}),
}
+
+
+class ContactAssignmentForm(BootstrapMixin, forms.ModelForm):
+ group = DynamicModelChoiceField(
+ queryset=ContactGroup.objects.all(),
+ required=False
+ )
+ contact = DynamicModelChoiceField(
+ queryset=Contact.objects.all()
+ )
+ role = DynamicModelChoiceField(
+ queryset=ContactRole.objects.all()
+ )
+
+ class Meta:
+ model = ContactAssignment
+ fields = (
+ 'group', 'contact', 'role', 'priority',
+ )
+ widgets = {
+ 'priority': StaticSelect(),
+ }
diff --git a/netbox/tenancy/models.py b/netbox/tenancy/models.py
index f5e66b753..f416d55b5 100644
--- a/netbox/tenancy/models.py
+++ b/netbox/tenancy/models.py
@@ -1,4 +1,4 @@
-from django.contrib.contenttypes.fields import GenericForeignKey
+from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation
from django.contrib.contenttypes.models import ContentType
from django.db import models
from django.urls import reverse
@@ -86,6 +86,11 @@ class Tenant(PrimaryModel):
blank=True
)
+ # Generic relations
+ contacts = GenericRelation(
+ to='tenancy.ContactAssignment'
+ )
+
objects = RestrictedQuerySet.as_manager()
clone_fields = [
diff --git a/netbox/tenancy/urls.py b/netbox/tenancy/urls.py
index 807af161e..14047603d 100644
--- a/netbox/tenancy/urls.py
+++ b/netbox/tenancy/urls.py
@@ -67,4 +67,9 @@ urlpatterns = [
path('contacts//changelog/', ObjectChangeLogView.as_view(), name='contact_changelog', kwargs={'model': Contact}),
path('contacts//journal/', ObjectJournalView.as_view(), name='contact_journal', kwargs={'model': Contact}),
+ # Contact assignments
+ path('contact-assignments/add/', views.ContactAssignmentEditView.as_view(), name='contactassignment_add'),
+ path('contact-assignments//edit/', views.ContactAssignmentEditView.as_view(), name='contactassignment_edit'),
+ path('contact-assignments//delete/', views.ContactAssignmentDeleteView.as_view(), name='contactassignment_delete'),
+
]
diff --git a/netbox/tenancy/views.py b/netbox/tenancy/views.py
index f4772b288..e7034ed5f 100644
--- a/netbox/tenancy/views.py
+++ b/netbox/tenancy/views.py
@@ -1,9 +1,12 @@
+from django.contrib.contenttypes.models import ContentType
+from django.http import Http404
+from django.shortcuts import get_object_or_404
+
from circuits.models import Circuit
from dcim.models import Site, Rack, Device, RackReservation
from ipam.models import Aggregate, IPAddress, Prefix, VLAN, VRF
from netbox.views import generic
from utilities.tables import paginate_table
-from utilities.utils import count_related
from virtualization.models import VirtualMachine, Cluster
from . import filtersets, forms, tables
from .models import *
@@ -309,3 +312,33 @@ class ContactBulkDeleteView(generic.BulkDeleteView):
queryset = Contact.objects.prefetch_related('group')
filterset = filtersets.ContactFilterSet
table = tables.ContactTable
+
+
+#
+# Contact assignments
+#
+
+class ContactAssignmentEditView(generic.ObjectEditView):
+ queryset = ContactAssignment.objects.all()
+ model_form = forms.ContactAssignmentForm
+
+ def alter_obj(self, instance, request, args, kwargs):
+ if not instance.pk:
+ # Assign the object based on URL kwargs
+ try:
+ app_label, model = request.GET.get('content_type').split('.')
+ except (AttributeError, ValueError):
+ raise Http404("Content type not specified")
+ content_type = get_object_or_404(ContentType, app_label=app_label, model=model)
+ instance.object = get_object_or_404(content_type.model_class(), pk=request.GET.get('object_id'))
+ return instance
+
+ def get_return_url(self, request, obj=None):
+ return obj.object.get_absolute_url() if obj else super().get_return_url(request)
+
+
+class ContactAssignmentDeleteView(generic.ObjectDeleteView):
+ queryset = ContactAssignment.objects.all()
+
+ def get_return_url(self, request, obj=None):
+ return obj.object.get_absolute_url() if obj else super().get_return_url(request)
diff --git a/netbox/virtualization/models.py b/netbox/virtualization/models.py
index 3408cedbc..d91a39549 100644
--- a/netbox/virtualization/models.py
+++ b/netbox/virtualization/models.py
@@ -81,12 +81,17 @@ class ClusterGroup(OrganizationalModel):
max_length=200,
blank=True
)
+
+ # Generic relations
vlan_groups = GenericRelation(
to='ipam.VLANGroup',
content_type_field='scope_type',
object_id_field='scope_id',
related_query_name='cluster_group'
)
+ contacts = GenericRelation(
+ to='tenancy.ContactAssignment'
+ )
objects = RestrictedQuerySet.as_manager()
@@ -142,12 +147,17 @@ class Cluster(PrimaryModel):
comments = models.TextField(
blank=True
)
+
+ # Generic relations
vlan_groups = GenericRelation(
to='ipam.VLANGroup',
content_type_field='scope_type',
object_id_field='scope_id',
related_query_name='cluster'
)
+ contacts = GenericRelation(
+ to='tenancy.ContactAssignment'
+ )
objects = RestrictedQuerySet.as_manager()
@@ -268,6 +278,11 @@ class VirtualMachine(PrimaryModel, ConfigContextModel):
blank=True
)
+ # Generic relation
+ contacts = GenericRelation(
+ to='tenancy.ContactAssignment'
+ )
+
objects = ConfigContextModelQuerySet.as_manager()
clone_fields = [