Add contact assignments to models

This commit is contained in:
jeremystretch 2021-10-18 15:09:57 -04:00
parent 2e78568d4d
commit f193f0d3f9
25 changed files with 256 additions and 45 deletions

View File

@ -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'
) )

View File

@ -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'
) )

View File

@ -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'
) )

View File

@ -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'
) )

View File

@ -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'
) )

View File

@ -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_a side='A' %}
{% include 'circuits/inc/circuit_termination.html' with termination=object.termination_z side='Z' %} {% include 'circuits/inc/circuit_termination.html' with termination=object.termination_z side='Z' %}
{% include 'inc/image_attachments_panel.html' %} {% include 'inc/contacts_panel.html' %}
{% plugin_right_page object %} {% include 'inc/image_attachments_panel.html' %}
</div> {% 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">

View File

@ -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">

View File

@ -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">

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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">

View File

@ -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' %}

View File

@ -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">

View File

@ -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>

View File

@ -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">

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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(),
}

View File

@ -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 = [

View File

@ -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'),
] ]

View File

@ -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)

View File

@ -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 = [