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

Add contact assignments to models

jeremystretch 4 лет назад
Родитель
Сommit
f193f0d3f9

+ 10 - 0
netbox/circuits/models.py

@@ -62,6 +62,11 @@ class Provider(PrimaryModel):
         blank=True
         blank=True
     )
     )
 
 
+    # Generic relations
+    contacts = GenericRelation(
+        to='tenancy.ContactAssignment'
+    )
+
     objects = RestrictedQuerySet.as_manager()
     objects = RestrictedQuerySet.as_manager()
 
 
     clone_fields = [
     clone_fields = [
@@ -203,6 +208,11 @@ class Circuit(PrimaryModel):
     comments = models.TextField(
     comments = models.TextField(
         blank=True
         blank=True
     )
     )
+
+    # Generic relations
+    contacts = GenericRelation(
+        to='tenancy.ContactAssignment'
+    )
     images = GenericRelation(
     images = GenericRelation(
         to='extras.ImageAttachment'
         to='extras.ImageAttachment'
     )
     )

+ 10 - 0
netbox/dcim/models/devices.py

@@ -54,6 +54,11 @@ class Manufacturer(OrganizationalModel):
         blank=True
         blank=True
     )
     )
 
 
+    # Generic relations
+    contacts = GenericRelation(
+        to='tenancy.ContactAssignment'
+    )
+
     objects = RestrictedQuerySet.as_manager()
     objects = RestrictedQuerySet.as_manager()
 
 
     class Meta:
     class Meta:
@@ -584,6 +589,11 @@ class Device(PrimaryModel, ConfigContextModel):
     comments = models.TextField(
     comments = models.TextField(
         blank=True
         blank=True
     )
     )
+
+    # Generic relations
+    contacts = GenericRelation(
+        to='tenancy.ContactAssignment'
+    )
     images = GenericRelation(
     images = GenericRelation(
         to='extras.ImageAttachment'
         to='extras.ImageAttachment'
     )
     )

+ 5 - 0
netbox/dcim/models/power.py

@@ -40,6 +40,11 @@ class PowerPanel(PrimaryModel):
     name = models.CharField(
     name = models.CharField(
         max_length=100
         max_length=100
     )
     )
+
+    # Generic relations
+    contacts = GenericRelation(
+        to='tenancy.ContactAssignment'
+    )
     images = GenericRelation(
     images = GenericRelation(
         to='extras.ImageAttachment'
         to='extras.ImageAttachment'
     )
     )

+ 5 - 0
netbox/dcim/models/racks.py

@@ -175,12 +175,17 @@ class Rack(PrimaryModel):
     comments = models.TextField(
     comments = models.TextField(
         blank=True
         blank=True
     )
     )
+
+    # Generic relations
     vlan_groups = GenericRelation(
     vlan_groups = GenericRelation(
         to='ipam.VLANGroup',
         to='ipam.VLANGroup',
         content_type_field='scope_type',
         content_type_field='scope_type',
         object_id_field='scope_id',
         object_id_field='scope_id',
         related_query_name='rack'
         related_query_name='rack'
     )
     )
+    contacts = GenericRelation(
+        to='tenancy.ContactAssignment'
+    )
     images = GenericRelation(
     images = GenericRelation(
         to='extras.ImageAttachment'
         to='extras.ImageAttachment'
     )
     )

+ 20 - 0
netbox/dcim/models/sites.py

@@ -52,12 +52,17 @@ class Region(NestedGroupModel):
         max_length=200,
         max_length=200,
         blank=True
         blank=True
     )
     )
+
+    # Generic relations
     vlan_groups = GenericRelation(
     vlan_groups = GenericRelation(
         to='ipam.VLANGroup',
         to='ipam.VLANGroup',
         content_type_field='scope_type',
         content_type_field='scope_type',
         object_id_field='scope_id',
         object_id_field='scope_id',
         related_query_name='region'
         related_query_name='region'
     )
     )
+    contacts = GenericRelation(
+        to='tenancy.ContactAssignment'
+    )
 
 
     def get_absolute_url(self):
     def get_absolute_url(self):
         return reverse('dcim:region', args=[self.pk])
         return reverse('dcim:region', args=[self.pk])
@@ -100,12 +105,17 @@ class SiteGroup(NestedGroupModel):
         max_length=200,
         max_length=200,
         blank=True
         blank=True
     )
     )
+
+    # Generic relations
     vlan_groups = GenericRelation(
     vlan_groups = GenericRelation(
         to='ipam.VLANGroup',
         to='ipam.VLANGroup',
         content_type_field='scope_type',
         content_type_field='scope_type',
         object_id_field='scope_id',
         object_id_field='scope_id',
         related_query_name='site_group'
         related_query_name='site_group'
     )
     )
+    contacts = GenericRelation(
+        to='tenancy.ContactAssignment'
+    )
 
 
     def get_absolute_url(self):
     def get_absolute_url(self):
         return reverse('dcim:sitegroup', args=[self.pk])
         return reverse('dcim:sitegroup', args=[self.pk])
@@ -221,12 +231,17 @@ class Site(PrimaryModel):
     comments = models.TextField(
     comments = models.TextField(
         blank=True
         blank=True
     )
     )
+
+    # Generic relations
     vlan_groups = GenericRelation(
     vlan_groups = GenericRelation(
         to='ipam.VLANGroup',
         to='ipam.VLANGroup',
         content_type_field='scope_type',
         content_type_field='scope_type',
         object_id_field='scope_id',
         object_id_field='scope_id',
         related_query_name='site'
         related_query_name='site'
     )
     )
+    contacts = GenericRelation(
+        to='tenancy.ContactAssignment'
+    )
     images = GenericRelation(
     images = GenericRelation(
         to='extras.ImageAttachment'
         to='extras.ImageAttachment'
     )
     )
@@ -291,12 +306,17 @@ class Location(NestedGroupModel):
         max_length=200,
         max_length=200,
         blank=True
         blank=True
     )
     )
+
+    # Generic relations
     vlan_groups = GenericRelation(
     vlan_groups = GenericRelation(
         to='ipam.VLANGroup',
         to='ipam.VLANGroup',
         content_type_field='scope_type',
         content_type_field='scope_type',
         object_id_field='scope_id',
         object_id_field='scope_id',
         related_query_name='location'
         related_query_name='location'
     )
     )
+    contacts = GenericRelation(
+        to='tenancy.ContactAssignment'
+    )
     images = GenericRelation(
     images = GenericRelation(
         to='extras.ImageAttachment'
         to='extras.ImageAttachment'
     )
     )

+ 6 - 5
netbox/templates/circuits/circuit.html

@@ -70,11 +70,12 @@
         {% plugin_left_page object %}
         {% plugin_left_page object %}
 	</div>
 	</div>
 	<div class="col col-md-6">
 	<div class="col col-md-6">
-        {% 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 %}
-    </div>
+    {% 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 %}
+  </div>
 </div>
 </div>
 <div class="row">
 <div class="row">
     <div class="col col-md-12">
     <div class="col col-md-12">

+ 2 - 1
netbox/templates/circuits/provider.html

@@ -47,12 +47,13 @@
                 </table>
                 </table>
             </div>
             </div>
         </div>
         </div>
+        {% include 'extras/inc/tags_panel.html' with tags=object.tags.all url='circuits:provider_list' %}
         {% plugin_left_page object %}
         {% plugin_left_page object %}
     </div>
     </div>
     <div class="col col-md-6">
     <div class="col col-md-6">
         {% include 'inc/custom_fields_panel.html' %}
         {% 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/comments_panel.html' %}
+        {% include 'inc/contacts_panel.html' %}
         {% plugin_right_page object %}
         {% plugin_right_page object %}
     </div>
     </div>
     <div class="col col-md-12">
     <div class="col col-md-12">

+ 1 - 0
netbox/templates/dcim/device.html

@@ -296,6 +296,7 @@
                 </div>
                 </div>
                 {% endif %}
                 {% endif %}
             </div>
             </div>
+            {% include 'inc/contacts_panel.html' %}
             {% include 'inc/image_attachments_panel.html' %}
             {% include 'inc/image_attachments_panel.html' %}
             <div class="card noprint">
             <div class="card noprint">
                 <h5 class="card-header">
                 <h5 class="card-header">

+ 1 - 0
netbox/templates/dcim/location.html

@@ -72,6 +72,7 @@
   </div>
   </div>
 	<div class="col col-md-6">
 	<div class="col col-md-6">
     {% include 'inc/custom_fields_panel.html' %}
     {% include 'inc/custom_fields_panel.html' %}
+    {% include 'inc/contacts_panel.html' %}
     {% include 'inc/image_attachments_panel.html' %}
     {% include 'inc/image_attachments_panel.html' %}
     {% plugin_right_page object %}
     {% plugin_right_page object %}
 	</div>
 	</div>

+ 1 - 0
netbox/templates/dcim/manufacturer.html

@@ -38,6 +38,7 @@
 	</div>
 	</div>
 	<div class="col col-md-6">
 	<div class="col col-md-6">
     {% include 'inc/custom_fields_panel.html' %}
     {% include 'inc/custom_fields_panel.html' %}
+    {% include 'inc/contacts_panel.html' %}
     {% plugin_right_page object %}
     {% plugin_right_page object %}
   </div>
   </div>
 </div>
 </div>

+ 1 - 0
netbox/templates/dcim/powerpanel.html

@@ -44,6 +44,7 @@
     </div>
     </div>
 	<div class="col col-md-6">
 	<div class="col col-md-6">
         {% include 'inc/custom_fields_panel.html' %}
         {% include 'inc/custom_fields_panel.html' %}
+        {% include 'inc/contacts_panel.html' %}
         {% include 'inc/image_attachments_panel.html' %}
         {% include 'inc/image_attachments_panel.html' %}
         {% plugin_right_page object %}
         {% plugin_right_page object %}
     </div>
     </div>

+ 1 - 0
netbox/templates/dcim/rack.html

@@ -332,6 +332,7 @@
                 </div>
                 </div>
             {% endif %}
             {% endif %}
         </div>
         </div>
+        {% include 'inc/contacts_panel.html' %}
         {% plugin_right_page object %}
         {% plugin_right_page object %}
     </div>
     </div>
   </div>
   </div>

+ 1 - 0
netbox/templates/dcim/region.html

@@ -46,6 +46,7 @@
       </div>
       </div>
     </div>
     </div>
     {% include 'inc/custom_fields_panel.html' %}
     {% include 'inc/custom_fields_panel.html' %}
+    {% include 'inc/contacts_panel.html' %}
     {% plugin_left_page object %}
     {% plugin_left_page object %}
   </div>
   </div>
 	<div class="col col-md-6">
 	<div class="col col-md-6">

+ 52 - 37
netbox/templates/dcim/site.html

@@ -76,6 +76,10 @@
                         <th scope="row">Facility</th>
                         <th scope="row">Facility</th>
                         <td>{{ object.facility|placeholder }}</td>
                         <td>{{ object.facility|placeholder }}</td>
                     </tr>
                     </tr>
+                    <tr>
+                        <th scope="row">Description</th>
+                        <td>{{ object.description|placeholder }}</td>
+                    </tr>
                     <tr>
                     <tr>
                         <th scope="row">AS Number</th>
                         <th scope="row">AS Number</th>
                         <td>{{ object.asn|placeholder }}</td>
                         <td>{{ object.asn|placeholder }}</td>
@@ -91,19 +95,6 @@
                             {% endif %}
                             {% endif %}
                         </td>
                         </td>
                     </tr>
                     </tr>
-                    <tr>
-                        <th scope="row">Description</th>
-                        <td>{{ object.description|placeholder }}</td>
-                    </tr>
-                </table>
-            </div>
-        </div>
-        <div class="card">
-            <h5 class="card-header">
-                Contact Info
-            </h5>
-            <div class="card-body">
-                <table class="table table-hover attr-table">
                     <tr>
                     <tr>
                         <th scope="row">Physical Address</th>
                         <th scope="row">Physical Address</th>
                         <td>
                         <td>
@@ -138,33 +129,57 @@
                             {% endif %}
                             {% endif %}
                         </td>
                         </td>
                     </tr>
                     </tr>
-                    <tr>
-                        <th scope="row">Contact Name</th>
-                        <td>{{ object.contact_name|placeholder }}</td>
-                    </tr>
-                    <tr>
-                        <th scope="row">Contact Phone</th>
-                        <td>
-                            {% if object.contact_phone %}
-                                <a href="tel:{{ object.contact_phone }}">{{ object.contact_phone }}</a>
-                            {% else %}
-                                <span class="text-muted">&mdash;</span>
-                            {% endif %}
-                        </td>
-                    </tr>
-                    <tr>
-                        <th scope="row">Contact E-Mail</th>
-                        <td>
-                            {% if object.contact_email %}
-                                <a href="mailto:{{ object.contact_email }}">{{ object.contact_email }}</a>
-                            {% else %}
-                                <span class="text-muted">&mdash;</span>
-                            {% endif %}
-                        </td>
-                    </tr>
                 </table>
                 </table>
             </div>
             </div>
         </div>
         </div>
+        {% include 'inc/contacts_panel.html' %}
+        <div class="card">
+            <h5 class="card-header">Contact Info</h5>
+            <div class="card-body">
+              {% with deprecation_warning="This field will be removed in a future release. Please migrate this data to contact objects." %}
+                <table class="table table-hover attr-table">
+                  <tr>
+                    <th scope="row">Contact Name</th>
+                    <td>
+                      {% if object.contact_name %}
+                        <div class="float-end text-warning">
+                          <i class="mdi mdi-alert" title="{{ deprecation_warning }}"></i>
+                        </div>
+                      {% endif %}
+                      {{ object.contact_name|placeholder }}
+                    </td>
+                  </tr>
+                  <tr>
+                    <th scope="row">Contact Phone</th>
+                    <td>
+                      {% if object.contact_phone %}
+                        <div class="float-end text-warning">
+                          <i class="mdi mdi-alert" title="{{ deprecation_warning }}"></i>
+                        </div>
+                        <a href="tel:{{ object.contact_phone }}">{{ object.contact_phone }}</a>
+                      {% else %}
+                        <span class="text-muted">&mdash;</span>
+                      {% endif %}
+                    </td>
+                  </tr>
+                  <tr>
+                    <th scope="row">Contact E-Mail</th>
+                    <td>
+                      {% if object.contact_email %}
+                        <div class="float-end text-warning">
+                          <i class="mdi mdi-alert" title="{{ deprecation_warning }}"></i>
+                        </div>
+                        <a href="tel:{{ object.contact_
+                        <a href="mailto:{{ object.contact_email }}">{{ object.contact_email }}</a>
+                      {% else %}
+                        <span class="text-muted">&mdash;</span>
+                      {% endif %}
+                    </td>
+                  </tr>
+                </table>
+              {% endwith %}
+            </div>
+        </div>
         {% include 'inc/custom_fields_panel.html' %}
         {% include 'inc/custom_fields_panel.html' %}
         {% include 'extras/inc/tags_panel.html' with tags=object.tags.all url='dcim:site_list' %}
         {% include 'extras/inc/tags_panel.html' with tags=object.tags.all url='dcim:site_list' %}
         {% include 'inc/comments_panel.html' %}
         {% include 'inc/comments_panel.html' %}

+ 1 - 0
netbox/templates/dcim/sitegroup.html

@@ -46,6 +46,7 @@
       </div>
       </div>
     </div>
     </div>
     {% include 'inc/custom_fields_panel.html' %}
     {% include 'inc/custom_fields_panel.html' %}
+    {% include 'inc/contacts_panel.html' %}
     {% plugin_left_page object %}
     {% plugin_left_page object %}
   </div>
   </div>
 	<div class="col col-md-6">
 	<div class="col col-md-6">

+ 49 - 0
netbox/templates/inc/contacts_panel.html

@@ -0,0 +1,49 @@
+{% load helpers %}
+
+<div class="card">
+  <h5 class="card-header">Contacts</h5>
+  <div class="card-body">
+    {% with contacts=object.contacts.all %}
+      {% if contacts.exists %}
+        <table class="table table-hover">
+          <tr>
+            <th>Name</th>
+            <th>Role</th>
+            <th>Priority</th>
+            <th></th>
+          </tr>
+          {% for contact in contacts %}
+            <tr>
+              <td>
+                <a href="{{ contact.contact.get_absolute_url }}">{{ contact.contact }}</a>
+              </td>
+              <td>{{ contact.role|placeholder }}</td>
+              <td>{{ contact.get_priority_display|placeholder }}</td>
+              <td class="text-end noprint">
+                {% if perms.tenancy.change_contactassignment %}
+                  <a href="{% url 'tenancy:contactassignment_edit' pk=contact.pk %}" class="btn btn-warning btn-sm lh-1" title="Edit">
+                    <i class="mdi mdi-pencil" aria-hidden="true"></i>
+                  </a>
+                {% endif %}
+                {% if perms.tenancy.delete_contactassignment %}
+                  <a href="{% url 'extras:imageattachment_delete' pk=contact.pk %}" class="btn btn-danger btn-sm lh-1" title="Delete">
+                    <i class="mdi mdi-trash-can-outline" aria-hidden="true"></i>
+                  </a>
+                {% endif %}
+              </td>
+            </tr>
+          {% endfor %}
+        </table>
+      {% else %}
+        <div class="text-muted">None</div>
+      {% endif %}
+    {% endwith %}
+  </div>
+  {% if perms.tenancy.add_contactassignment %}
+    <div class="card-footer text-end noprint">
+      <a href="{% url 'tenancy:contactassignment_add' %}?content_type={{ object|meta:"app_label" }}.{{ object|meta:"model_name" }}&object_id={{ object.pk }}" class="btn btn-primary btn-sm">
+        <i class="mdi mdi-plus-thick" aria-hidden="true"></i> Add a contact
+      </a>
+    </div>
+  {% endif %}
+</div>

+ 1 - 0
netbox/templates/tenancy/tenant.html

@@ -38,6 +38,7 @@
         {% include 'inc/custom_fields_panel.html' %}
         {% include 'inc/custom_fields_panel.html' %}
         {% include 'extras/inc/tags_panel.html' with tags=object.tags.all url='tenancy:tenant_list' %}
         {% include 'extras/inc/tags_panel.html' with tags=object.tags.all url='tenancy:tenant_list' %}
         {% include 'inc/comments_panel.html' %}
         {% include 'inc/comments_panel.html' %}
+        {% include 'inc/contacts_panel.html' %}
         {% plugin_left_page object %}
         {% plugin_left_page object %}
 	</div>
 	</div>
 	<div class="col col-md-5">
 	<div class="col col-md-5">

+ 1 - 0
netbox/templates/virtualization/cluster.html

@@ -62,6 +62,7 @@
   <div class="col col-md-6">
   <div class="col col-md-6">
     {% include 'inc/custom_fields_panel.html' %}
     {% include 'inc/custom_fields_panel.html' %}
     {% include 'extras/inc/tags_panel.html' with tags=object.tags.all url='virtualization:cluster_list' %}
     {% include 'extras/inc/tags_panel.html' with tags=object.tags.all url='virtualization:cluster_list' %}
+    {% include 'inc/contacts_panel.html' %}
     {% plugin_right_page object %}
     {% plugin_right_page object %}
   </div>
   </div>
 </div>
 </div>

+ 1 - 0
netbox/templates/virtualization/clustergroup.html

@@ -32,6 +32,7 @@
 	</div>
 	</div>
 	<div class="col col-md-6">
 	<div class="col col-md-6">
     {% include 'inc/custom_fields_panel.html' %}
     {% include 'inc/custom_fields_panel.html' %}
+    {% include 'inc/contacts_panel.html' %}
     {% plugin_right_page object %}
     {% plugin_right_page object %}
   </div>
   </div>
 </div>
 </div>

+ 1 - 0
netbox/templates/virtualization/virtualmachine.html

@@ -173,6 +173,7 @@
                 </div>
                 </div>
             {% endif %}
             {% endif %}
         </div>
         </div>
+        {% include 'inc/contacts_panel.html' %}
         {% plugin_right_page object %}
         {% plugin_right_page object %}
     </div>
     </div>
 </div>
 </div>

+ 26 - 0
netbox/tenancy/forms/models.py

@@ -1,11 +1,15 @@
+from django import forms
+
 from extras.forms import CustomFieldModelForm
 from extras.forms import CustomFieldModelForm
 from extras.models import Tag
 from extras.models import Tag
 from tenancy.models import *
 from tenancy.models import *
 from utilities.forms import (
 from utilities.forms import (
     BootstrapMixin, CommentField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, SlugField, SmallTextarea,
     BootstrapMixin, CommentField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, SlugField, SmallTextarea,
+    StaticSelect,
 )
 )
 
 
 __all__ = (
 __all__ = (
+    'ContactAssignmentForm',
     'ContactForm',
     'ContactForm',
     'ContactGroupForm',
     'ContactGroupForm',
     'ContactRoleForm',
     'ContactRoleForm',
@@ -100,3 +104,25 @@ class ContactForm(BootstrapMixin, CustomFieldModelForm):
         widgets = {
         widgets = {
             'address': SmallTextarea(attrs={'rows': 3}),
             '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(),
+        }

+ 6 - 1
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.contrib.contenttypes.models import ContentType
 from django.db import models
 from django.db import models
 from django.urls import reverse
 from django.urls import reverse
@@ -86,6 +86,11 @@ class Tenant(PrimaryModel):
         blank=True
         blank=True
     )
     )
 
 
+    # Generic relations
+    contacts = GenericRelation(
+        to='tenancy.ContactAssignment'
+    )
+
     objects = RestrictedQuerySet.as_manager()
     objects = RestrictedQuerySet.as_manager()
 
 
     clone_fields = [
     clone_fields = [

+ 5 - 0
netbox/tenancy/urls.py

@@ -67,4 +67,9 @@ urlpatterns = [
     path('contacts/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='contact_changelog', kwargs={'model': Contact}),
     path('contacts/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='contact_changelog', kwargs={'model': Contact}),
     path('contacts/<int:pk>/journal/', ObjectJournalView.as_view(), name='contact_journal', kwargs={'model': Contact}),
     path('contacts/<int:pk>/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/<int:pk>/edit/', views.ContactAssignmentEditView.as_view(), name='contactassignment_edit'),
+    path('contact-assignments/<int:pk>/delete/', views.ContactAssignmentDeleteView.as_view(), name='contactassignment_delete'),
+
 ]
 ]

+ 34 - 1
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 circuits.models import Circuit
 from dcim.models import Site, Rack, Device, RackReservation
 from dcim.models import Site, Rack, Device, RackReservation
 from ipam.models import Aggregate, IPAddress, Prefix, VLAN, VRF
 from ipam.models import Aggregate, IPAddress, Prefix, VLAN, VRF
 from netbox.views import generic
 from netbox.views import generic
 from utilities.tables import paginate_table
 from utilities.tables import paginate_table
-from utilities.utils import count_related
 from virtualization.models import VirtualMachine, Cluster
 from virtualization.models import VirtualMachine, Cluster
 from . import filtersets, forms, tables
 from . import filtersets, forms, tables
 from .models import *
 from .models import *
@@ -309,3 +312,33 @@ class ContactBulkDeleteView(generic.BulkDeleteView):
     queryset = Contact.objects.prefetch_related('group')
     queryset = Contact.objects.prefetch_related('group')
     filterset = filtersets.ContactFilterSet
     filterset = filtersets.ContactFilterSet
     table = tables.ContactTable
     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)

+ 15 - 0
netbox/virtualization/models.py

@@ -81,12 +81,17 @@ class ClusterGroup(OrganizationalModel):
         max_length=200,
         max_length=200,
         blank=True
         blank=True
     )
     )
+
+    # Generic relations
     vlan_groups = GenericRelation(
     vlan_groups = GenericRelation(
         to='ipam.VLANGroup',
         to='ipam.VLANGroup',
         content_type_field='scope_type',
         content_type_field='scope_type',
         object_id_field='scope_id',
         object_id_field='scope_id',
         related_query_name='cluster_group'
         related_query_name='cluster_group'
     )
     )
+    contacts = GenericRelation(
+        to='tenancy.ContactAssignment'
+    )
 
 
     objects = RestrictedQuerySet.as_manager()
     objects = RestrictedQuerySet.as_manager()
 
 
@@ -142,12 +147,17 @@ class Cluster(PrimaryModel):
     comments = models.TextField(
     comments = models.TextField(
         blank=True
         blank=True
     )
     )
+
+    # Generic relations
     vlan_groups = GenericRelation(
     vlan_groups = GenericRelation(
         to='ipam.VLANGroup',
         to='ipam.VLANGroup',
         content_type_field='scope_type',
         content_type_field='scope_type',
         object_id_field='scope_id',
         object_id_field='scope_id',
         related_query_name='cluster'
         related_query_name='cluster'
     )
     )
+    contacts = GenericRelation(
+        to='tenancy.ContactAssignment'
+    )
 
 
     objects = RestrictedQuerySet.as_manager()
     objects = RestrictedQuerySet.as_manager()
 
 
@@ -268,6 +278,11 @@ class VirtualMachine(PrimaryModel, ConfigContextModel):
         blank=True
         blank=True
     )
     )
 
 
+    # Generic relation
+    contacts = GenericRelation(
+        to='tenancy.ContactAssignment'
+    )
+
     objects = ConfigContextModelQuerySet.as_manager()
     objects = ConfigContextModelQuerySet.as_manager()
 
 
     clone_fields = [
     clone_fields = [