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' %}
diff --git a/netbox/templates/dcim/location.html b/netbox/templates/dcim/location.html index cd0f2a92a..459880ca8 100644 --- a/netbox/templates/dcim/location.html +++ b/netbox/templates/dcim/location.html @@ -72,6 +72,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/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 }} - - -
- -
-
- Contact Info -
-
- - - - - - - - - - - - -
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' %} +
+
Contact Info
+
+ {% 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 %} + +
+
Contacts
+
+ {% with contacts=object.contacts.all %} + {% if contacts.exists %} + + + + + + + + {% for contact in contacts %} + + + + + + + {% endfor %} +
NameRolePriority
+ {{ contact.contact }} + {{ contact.role|placeholder }}{{ contact.get_priority_display|placeholder }} + {% if perms.tenancy.change_contactassignment %} + + + + {% endif %} + {% if perms.tenancy.delete_contactassignment %} + + + + {% endif %} +
+ {% 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 = [