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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -76,6 +76,10 @@
<th scope="row">Facility</th>
<td>{{ object.facility|placeholder }}</td>
</tr>
<tr>
<th scope="row">Description</th>
<td>{{ object.description|placeholder }}</td>
</tr>
<tr>
<th scope="row">AS Number</th>
<td>{{ object.asn|placeholder }}</td>
@ -91,19 +95,6 @@
{% endif %}
</td>
</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>
<th scope="row">Physical Address</th>
<td>
@ -138,33 +129,57 @@
{% endif %}
</td>
</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>
</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 'extras/inc/tags_panel.html' with tags=object.tags.all url='dcim:site_list' %}
{% include 'inc/comments_panel.html' %}

View File

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

View File

@ -62,6 +62,7 @@
<div class="col col-md-6">
{% 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 %}
</div>
</div>

View File

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

View File

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

View File

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

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

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

View File

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