diff --git a/docs/core-functionality/contacts.md b/docs/core-functionality/contacts.md
new file mode 100644
index 000000000..76a005fc0
--- /dev/null
+++ b/docs/core-functionality/contacts.md
@@ -0,0 +1,5 @@
+# Contacts
+
+{!models/tenancy/contact.md!}
+{!models/tenancy/contactgroup.md!}
+{!models/tenancy/contactrole.md!}
diff --git a/docs/models/tenancy/contact.md b/docs/models/tenancy/contact.md
new file mode 100644
index 000000000..9d81e2d85
--- /dev/null
+++ b/docs/models/tenancy/contact.md
@@ -0,0 +1,31 @@
+# Contacts
+
+A contact represent an individual or group that has been associated with an object in NetBox for administrative reasons. For example, you might assign one or more operational contacts to each site. Contacts can be arranged within nested contact groups.
+
+Each contact must include a name, which is unique to its parent group (if any). The following optional descriptors are also available:
+
+* Title
+* Phone
+* Email
+* Address
+
+## Contact Assignment
+
+Each contact can be assigned to one or more objects, allowing for the efficient reuse of contact information. When assigning a contact to an object, the user may optionally specify a role and/or priority (primary, secondary, tertiary, or inactive) to better convey the nature of the contact's relationship to the assigned object.
+
+The following models support the assignment of contacts:
+
+* circuits.Circuit
+* circuits.Provider
+* dcim.Device
+* dcim.Location
+* dcim.Manufacturer
+* dcim.PowerPanel
+* dcim.Rack
+* dcim.Region
+* dcim.Site
+* dcim.SiteGroup
+* tenancy.Tenant
+* virtualization.Cluster
+* virtualization.ClusterGroup
+* virtualization.VirtualMachine
diff --git a/docs/models/tenancy/contactgroup.md b/docs/models/tenancy/contactgroup.md
new file mode 100644
index 000000000..ea566c58a
--- /dev/null
+++ b/docs/models/tenancy/contactgroup.md
@@ -0,0 +1,3 @@
+# Contact Groups
+
+Contacts can be organized into arbitrary groups. These groups can be recursively nested for convenience. Each contact within a group must have a unique name, but other attributes can be repeated.
diff --git a/docs/models/tenancy/contactrole.md b/docs/models/tenancy/contactrole.md
new file mode 100644
index 000000000..23642ca03
--- /dev/null
+++ b/docs/models/tenancy/contactrole.md
@@ -0,0 +1,3 @@
+# Contact Roles
+
+Contacts can be organized by functional roles, which are fully customizable by the user. For example, you might create roles for administrative, operational, or emergency contacts.
diff --git a/mkdocs.yml b/mkdocs.yml
index 7244c36d6..72750d6f5 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -62,6 +62,7 @@ nav:
- Circuits: 'core-functionality/circuits.md'
- Power Tracking: 'core-functionality/power.md'
- Tenancy: 'core-functionality/tenancy.md'
+ - Contacts: 'core-functionality/contacts.md'
- Customization:
- Custom Fields: 'customization/custom-fields.md'
- Custom Validation: 'customization/custom-validation.md'
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/netbox/navigation_menu.py b/netbox/netbox/navigation_menu.py
index a3978f16e..de2c170a3 100644
--- a/netbox/netbox/navigation_menu.py
+++ b/netbox/netbox/navigation_menu.py
@@ -120,6 +120,14 @@ ORGANIZATION_MENU = Menu(
get_model_item('tenancy', 'tenantgroup', 'Tenant Groups'),
),
),
+ MenuGroup(
+ label='Contacts',
+ items=(
+ get_model_item('tenancy', 'contact', 'Contacts'),
+ get_model_item('tenancy', 'contactgroup', 'Contact Groups'),
+ get_model_item('tenancy', 'contactrole', 'Contact Roles'),
+ ),
+ ),
),
)
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/contact.html b/netbox/templates/tenancy/contact.html
new file mode 100644
index 000000000..ca46fdb31
--- /dev/null
+++ b/netbox/templates/tenancy/contact.html
@@ -0,0 +1,79 @@
+{% extends 'generic/object.html' %}
+{% load helpers %}
+{% load plugins %}
+
+{% block breadcrumbs %}
+ {{ block.super }}
+ {% if object.group %}
+
{{ object.group }}
+ {% endif %}
+{% endblock breadcrumbs %}
+
+{% block content %}
+
+
+
+
+
+
+
+ Group |
+
+ {% if object.group %}
+ {{ object.group }}
+ {% else %}
+ None
+ {% endif %}
+ |
+
+
+ Name |
+ {{ object.name }} |
+
+
+ Title |
+ {{ object.tile|placeholder }} |
+
+
+ Phone |
+ {{ object.phone|placeholder }} |
+
+
+ Email |
+ {{ object.email|placeholder }} |
+
+
+ Address |
+ {{ object.address|linebreaksbr|placeholder }} |
+
+
+ Assignments |
+
+ {{ assignment_count }}
+ |
+
+
+
+
+ {% include 'inc/comments_panel.html' %}
+ {% plugin_left_page object %}
+
+
+ {% include 'inc/custom_fields_panel.html' %}
+ {% include 'extras/inc/tags_panel.html' with tags=object.tags.all url='tenancy:tenant_list' %}
+ {% plugin_right_page object %}
+
+
+
+
+
+
+
+ {% include 'inc/table.html' with table=contacts_table %}
+
+
+ {% include 'inc/paginator.html' with paginator=contacts_table.paginator page=contacts_table.page %}
+ {% plugin_full_width_page object %}
+
+
+{% endblock %}
diff --git a/netbox/templates/tenancy/contactgroup.html b/netbox/templates/tenancy/contactgroup.html
new file mode 100644
index 000000000..1511565c3
--- /dev/null
+++ b/netbox/templates/tenancy/contactgroup.html
@@ -0,0 +1,76 @@
+{% extends 'generic/object.html' %}
+{% load helpers %}
+{% load plugins %}
+
+{% block breadcrumbs %}
+ {{ block.super }}
+ {% for contactgroup in object.get_ancestors %}
+
{{ contactgroup }}
+ {% endfor %}
+{% endblock %}
+
+{% block content %}
+
+
+
+ {% plugin_left_page object %}
+
+
+ {% include 'inc/custom_fields_panel.html' %}
+ {% plugin_right_page object %}
+
+
+
+
+
+
+
+ {% include 'inc/table.html' with table=contacts_table %}
+
+ {% if perms.tenancy.add_contact %}
+
+ {% endif %}
+
+ {% include 'inc/paginator.html' with paginator=contacts_table.paginator page=contacts_table.page %}
+ {% plugin_full_width_page object %}
+
+
+{% endblock %}
diff --git a/netbox/templates/tenancy/contactrole.html b/netbox/templates/tenancy/contactrole.html
new file mode 100644
index 000000000..f081afc34
--- /dev/null
+++ b/netbox/templates/tenancy/contactrole.html
@@ -0,0 +1,52 @@
+{% extends 'generic/object.html' %}
+{% load helpers %}
+{% load plugins %}
+
+{% block breadcrumbs %}
+
Contact Roles
+{% endblock %}
+
+{% block content %}
+
+
+
+
+
+
+
+ Name |
+ {{ object.name }} |
+
+
+ Description |
+ {{ object.description|placeholder }} |
+
+
+ Assignments |
+
+ {{ assignment_count }}
+ |
+
+
+
+
+ {% plugin_left_page object %}
+
+
+ {% include 'inc/custom_fields_panel.html' %}
+ {% plugin_right_page object %}
+
+
+
+
+
+
+
+ {% include 'inc/table.html' with table=contacts_table %}
+
+
+ {% include 'inc/paginator.html' with paginator=contacts_table.paginator page=contacts_table.page %}
+ {% plugin_full_width_page object %}
+
+
+{% endblock %}
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/api/nested_serializers.py b/netbox/tenancy/api/nested_serializers.py
index 11225fa7a..a072331f5 100644
--- a/netbox/tenancy/api/nested_serializers.py
+++ b/netbox/tenancy/api/nested_serializers.py
@@ -1,9 +1,12 @@
from rest_framework import serializers
from netbox.api import WritableNestedSerializer
-from tenancy.models import Tenant, TenantGroup
+from tenancy.models import *
__all__ = [
+ 'NestedContactSerializer',
+ 'NestedContactGroupSerializer',
+ 'NestedContactRoleSerializer',
'NestedTenantGroupSerializer',
'NestedTenantSerializer',
]
@@ -29,3 +32,33 @@ class NestedTenantSerializer(WritableNestedSerializer):
class Meta:
model = Tenant
fields = ['id', 'url', 'display', 'name', 'slug']
+
+
+#
+# Contacts
+#
+
+class NestedContactGroupSerializer(WritableNestedSerializer):
+ url = serializers.HyperlinkedIdentityField(view_name='tenancy-api:contactgroup-detail')
+ contact_count = serializers.IntegerField(read_only=True)
+ _depth = serializers.IntegerField(source='level', read_only=True)
+
+ class Meta:
+ model = ContactGroup
+ fields = ['id', 'url', 'display', 'name', 'slug', 'contact_count', '_depth']
+
+
+class NestedContactRoleSerializer(WritableNestedSerializer):
+ url = serializers.HyperlinkedIdentityField(view_name='tenancy-api:contactrole-detail')
+
+ class Meta:
+ model = ContactRole
+ fields = ['id', 'url', 'display', 'name', 'slug']
+
+
+class NestedContactSerializer(WritableNestedSerializer):
+ url = serializers.HyperlinkedIdentityField(view_name='tenancy-api:contact-detail')
+
+ class Meta:
+ model = Contact
+ fields = ['id', 'url', 'display', 'name']
diff --git a/netbox/tenancy/api/serializers.py b/netbox/tenancy/api/serializers.py
index 3136c811c..27a14b350 100644
--- a/netbox/tenancy/api/serializers.py
+++ b/netbox/tenancy/api/serializers.py
@@ -1,7 +1,10 @@
+from django.contrib.auth.models import ContentType
from rest_framework import serializers
-from netbox.api.serializers import NestedGroupModelSerializer, PrimaryModelSerializer
-from tenancy.models import Tenant, TenantGroup
+from netbox.api import ChoiceField, ContentTypeField
+from netbox.api.serializers import NestedGroupModelSerializer, OrganizationalModelSerializer, PrimaryModelSerializer
+from tenancy.choices import ContactPriorityChoices
+from tenancy.models import *
from .nested_serializers import *
@@ -43,3 +46,59 @@ class TenantSerializer(PrimaryModelSerializer):
'created', 'last_updated', 'circuit_count', 'device_count', 'ipaddress_count', 'prefix_count', 'rack_count',
'site_count', 'virtualmachine_count', 'vlan_count', 'vrf_count', 'cluster_count',
]
+
+
+#
+# Contacts
+#
+
+class ContactGroupSerializer(NestedGroupModelSerializer):
+ url = serializers.HyperlinkedIdentityField(view_name='tenancy-api:contactgroup-detail')
+ parent = NestedContactGroupSerializer(required=False, allow_null=True)
+ contact_count = serializers.IntegerField(read_only=True)
+
+ class Meta:
+ model = ContactGroup
+ fields = [
+ 'id', 'url', 'display', 'name', 'slug', 'parent', 'description', 'custom_fields', 'created', 'last_updated',
+ 'contact_count', '_depth',
+ ]
+
+
+class ContactRoleSerializer(OrganizationalModelSerializer):
+ url = serializers.HyperlinkedIdentityField(view_name='tenancy-api:contactrole-detail')
+
+ class Meta:
+ model = ContactRole
+ fields = [
+ 'id', 'url', 'display', 'name', 'slug', 'description', 'custom_fields', 'created', 'last_updated',
+ ]
+
+
+class ContactSerializer(PrimaryModelSerializer):
+ url = serializers.HyperlinkedIdentityField(view_name='tenancy-api:contact-detail')
+ group = NestedContactGroupSerializer(required=False, allow_null=True)
+
+ class Meta:
+ model = Contact
+ fields = [
+ 'id', 'url', 'display', 'group', 'name', 'title', 'phone', 'email', 'address', 'comments', 'tags',
+ 'custom_fields', 'created', 'last_updated',
+ ]
+
+
+class ContactAssignmentSerializer(PrimaryModelSerializer):
+ url = serializers.HyperlinkedIdentityField(view_name='tenancy-api:contactassignment-detail')
+ content_type = ContentTypeField(
+ queryset=ContentType.objects.all()
+ )
+ contact = NestedContactSerializer()
+ role = NestedContactRoleSerializer(required=False, allow_null=True)
+ priority = ChoiceField(choices=ContactPriorityChoices, required=False)
+
+ class Meta:
+ model = ContactAssignment
+ fields = [
+ 'id', 'url', 'display', 'content_type', 'object_id', 'contact', 'role', 'priority', 'created',
+ 'last_updated',
+ ]
diff --git a/netbox/tenancy/api/urls.py b/netbox/tenancy/api/urls.py
index 32540879d..00e1a6469 100644
--- a/netbox/tenancy/api/urls.py
+++ b/netbox/tenancy/api/urls.py
@@ -9,5 +9,11 @@ router.APIRootView = views.TenancyRootView
router.register('tenant-groups', views.TenantGroupViewSet)
router.register('tenants', views.TenantViewSet)
+# Contacts
+router.register('contact-groups', views.ContactGroupViewSet)
+router.register('contact-roles', views.ContactRoleViewSet)
+router.register('contacts', views.ContactViewSet)
+router.register('contact-assignments', views.ContactAssignmentViewSet)
+
app_name = 'tenancy-api'
urlpatterns = router.urls
diff --git a/netbox/tenancy/api/views.py b/netbox/tenancy/api/views.py
index 2e049135d..7ce16c143 100644
--- a/netbox/tenancy/api/views.py
+++ b/netbox/tenancy/api/views.py
@@ -5,7 +5,7 @@ from dcim.models import Device, Rack, Site
from extras.api.views import CustomFieldModelViewSet
from ipam.models import IPAddress, Prefix, VLAN, VRF
from tenancy import filtersets
-from tenancy.models import Tenant, TenantGroup
+from tenancy.models import *
from utilities.utils import count_related
from virtualization.models import VirtualMachine
from . import serializers
@@ -20,7 +20,7 @@ class TenancyRootView(APIRootView):
#
-# Tenant Groups
+# Tenants
#
class TenantGroupViewSet(CustomFieldModelViewSet):
@@ -35,10 +35,6 @@ class TenantGroupViewSet(CustomFieldModelViewSet):
filterset_class = filtersets.TenantGroupFilterSet
-#
-# Tenants
-#
-
class TenantViewSet(CustomFieldModelViewSet):
queryset = Tenant.objects.prefetch_related(
'group', 'tags'
@@ -55,3 +51,41 @@ class TenantViewSet(CustomFieldModelViewSet):
)
serializer_class = serializers.TenantSerializer
filterset_class = filtersets.TenantFilterSet
+
+
+#
+# Contacts
+#
+
+class ContactGroupViewSet(CustomFieldModelViewSet):
+ queryset = ContactGroup.objects.add_related_count(
+ ContactGroup.objects.all(),
+ Contact,
+ 'group',
+ 'contact_count',
+ cumulative=True
+ )
+ serializer_class = serializers.ContactGroupSerializer
+ filterset_class = filtersets.ContactGroupFilterSet
+
+
+class ContactRoleViewSet(CustomFieldModelViewSet):
+ queryset = ContactRole.objects.all()
+ serializer_class = serializers.ContactRoleSerializer
+ filterset_class = filtersets.ContactRoleFilterSet
+
+
+class ContactViewSet(CustomFieldModelViewSet):
+ queryset = Contact.objects.prefetch_related(
+ 'group', 'tags'
+ )
+ serializer_class = serializers.ContactSerializer
+ filterset_class = filtersets.ContactFilterSet
+
+
+class ContactAssignmentViewSet(CustomFieldModelViewSet):
+ queryset = ContactAssignment.objects.prefetch_related(
+ 'contact', 'role'
+ )
+ serializer_class = serializers.ContactAssignmentSerializer
+ filterset_class = filtersets.ContactAssignmentFilterSet
diff --git a/netbox/tenancy/choices.py b/netbox/tenancy/choices.py
new file mode 100644
index 000000000..b59d2050d
--- /dev/null
+++ b/netbox/tenancy/choices.py
@@ -0,0 +1,19 @@
+from utilities.choices import ChoiceSet
+
+
+#
+# Contacts
+#
+
+class ContactPriorityChoices(ChoiceSet):
+ PRIORITY_PRIMARY = 'primary'
+ PRIORITY_SECONDARY = 'secondary'
+ PRIORITY_TERTIARY = 'tertiary'
+ PRIORITY_INACTIVE = 'inactive'
+
+ CHOICES = (
+ (PRIORITY_PRIMARY, 'Primary'),
+ (PRIORITY_SECONDARY, 'Secondary'),
+ (PRIORITY_TERTIARY, 'Tertiary'),
+ (PRIORITY_INACTIVE, 'Inactive'),
+ )
diff --git a/netbox/tenancy/filtersets.py b/netbox/tenancy/filtersets.py
index d00b78629..f6d0ac72e 100644
--- a/netbox/tenancy/filtersets.py
+++ b/netbox/tenancy/filtersets.py
@@ -2,18 +2,26 @@ import django_filters
from django.db.models import Q
from extras.filters import TagFilter
-from netbox.filtersets import OrganizationalModelFilterSet, PrimaryModelFilterSet
-from utilities.filters import TreeNodeMultipleChoiceFilter
-from .models import Tenant, TenantGroup
+from netbox.filtersets import ChangeLoggedModelFilterSet, OrganizationalModelFilterSet, PrimaryModelFilterSet
+from utilities.filters import ContentTypeFilter, TreeNodeMultipleChoiceFilter
+from .models import *
__all__ = (
+ 'ContactAssignmentFilterSet',
+ 'ContactFilterSet',
+ 'ContactGroupFilterSet',
+ 'ContactRoleFilterSet',
'TenancyFilterSet',
'TenantFilterSet',
'TenantGroupFilterSet',
)
+#
+# Tenancy
+#
+
class TenantGroupFilterSet(OrganizationalModelFilterSet):
parent_id = django_filters.ModelMultipleChoiceFilter(
queryset=TenantGroup.objects.all(),
@@ -23,7 +31,7 @@ class TenantGroupFilterSet(OrganizationalModelFilterSet):
field_name='parent__slug',
queryset=TenantGroup.objects.all(),
to_field_name='slug',
- label='Tenant group group (slug)',
+ label='Tenant group (slug)',
)
class Meta:
@@ -93,3 +101,90 @@ class TenancyFilterSet(django_filters.FilterSet):
to_field_name='slug',
label='Tenant (slug)',
)
+
+
+#
+# Contacts
+#
+
+class ContactGroupFilterSet(OrganizationalModelFilterSet):
+ parent_id = django_filters.ModelMultipleChoiceFilter(
+ queryset=ContactGroup.objects.all(),
+ label='Contact group (ID)',
+ )
+ parent = django_filters.ModelMultipleChoiceFilter(
+ field_name='parent__slug',
+ queryset=ContactGroup.objects.all(),
+ to_field_name='slug',
+ label='Contact group (slug)',
+ )
+
+ class Meta:
+ model = ContactGroup
+ fields = ['id', 'name', 'slug', 'description']
+
+
+class ContactRoleFilterSet(OrganizationalModelFilterSet):
+
+ class Meta:
+ model = ContactRole
+ fields = ['id', 'name', 'slug']
+
+
+class ContactFilterSet(PrimaryModelFilterSet):
+ q = django_filters.CharFilter(
+ method='search',
+ label='Search',
+ )
+ group_id = TreeNodeMultipleChoiceFilter(
+ queryset=ContactGroup.objects.all(),
+ field_name='group',
+ lookup_expr='in',
+ label='Contact group (ID)',
+ )
+ group = TreeNodeMultipleChoiceFilter(
+ queryset=ContactGroup.objects.all(),
+ field_name='group',
+ lookup_expr='in',
+ to_field_name='slug',
+ label='Contact group (slug)',
+ )
+ tag = TagFilter()
+
+ class Meta:
+ model = Contact
+ fields = ['id', 'name', 'title', 'phone', 'email', 'address']
+
+ def search(self, queryset, name, value):
+ if not value.strip():
+ return queryset
+ return queryset.filter(
+ Q(name__icontains=value) |
+ Q(title__icontains=value) |
+ Q(phone__icontains=value) |
+ Q(email__icontains=value) |
+ Q(address__icontains=value) |
+ Q(comments__icontains=value)
+ )
+
+
+class ContactAssignmentFilterSet(ChangeLoggedModelFilterSet):
+ content_type = ContentTypeFilter()
+ contact_id = django_filters.ModelMultipleChoiceFilter(
+ queryset=Contact.objects.all(),
+ label='Contact (ID)',
+ )
+ role_id = django_filters.ModelMultipleChoiceFilter(
+ queryset=ContactRole.objects.all(),
+ label='Contact role (ID)',
+ )
+ role = django_filters.ModelMultipleChoiceFilter(
+ field_name='role__slug',
+ queryset=ContactRole.objects.all(),
+ to_field_name='slug',
+ label='Contact role (slug)',
+ )
+
+ class Meta:
+ model = ContactAssignment
+ fields = ['id', 'content_type_id', 'priority']
diff --git a/netbox/tenancy/forms/bulk_edit.py b/netbox/tenancy/forms/bulk_edit.py
index b2fc7dafd..a34b8def1 100644
--- a/netbox/tenancy/forms/bulk_edit.py
+++ b/netbox/tenancy/forms/bulk_edit.py
@@ -1,15 +1,22 @@
from django import forms
from extras.forms import AddRemoveTagsForm, CustomFieldModelBulkEditForm
-from tenancy.models import Tenant, TenantGroup
+from tenancy.models import *
from utilities.forms import BootstrapMixin, DynamicModelChoiceField
__all__ = (
+ 'ContactBulkEditForm',
+ 'ContactGroupBulkEditForm',
+ 'ContactRoleBulkEditForm',
'TenantBulkEditForm',
'TenantGroupBulkEditForm',
)
+#
+# Tenants
+#
+
class TenantGroupBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm):
pk = forms.ModelMultipleChoiceField(
queryset=TenantGroup.objects.all(),
@@ -42,3 +49,68 @@ class TenantBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulk
nullable_fields = [
'group',
]
+
+
+#
+# Contacts
+#
+
+class ContactGroupBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm):
+ pk = forms.ModelMultipleChoiceField(
+ queryset=ContactGroup.objects.all(),
+ widget=forms.MultipleHiddenInput
+ )
+ parent = DynamicModelChoiceField(
+ queryset=ContactGroup.objects.all(),
+ required=False
+ )
+ description = forms.CharField(
+ max_length=200,
+ required=False
+ )
+
+ class Meta:
+ nullable_fields = ['parent', 'description']
+
+
+class ContactRoleBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm):
+ pk = forms.ModelMultipleChoiceField(
+ queryset=ContactRole.objects.all(),
+ widget=forms.MultipleHiddenInput
+ )
+ description = forms.CharField(
+ max_length=200,
+ required=False
+ )
+
+ class Meta:
+ nullable_fields = ['description']
+
+
+class ContactBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm):
+ pk = forms.ModelMultipleChoiceField(
+ queryset=Contact.objects.all(),
+ widget=forms.MultipleHiddenInput()
+ )
+ group = DynamicModelChoiceField(
+ queryset=ContactGroup.objects.all(),
+ required=False
+ )
+ title = forms.CharField(
+ max_length=100,
+ required=False
+ )
+ phone = forms.CharField(
+ max_length=50,
+ required=False
+ )
+ email = forms.EmailField(
+ required=False
+ )
+ address = forms.CharField(
+ max_length=200,
+ required=False
+ )
+
+ class Meta:
+ nullable_fields = ['group', 'title', 'phone', 'email', 'address', 'comments']
diff --git a/netbox/tenancy/forms/bulk_import.py b/netbox/tenancy/forms/bulk_import.py
index 335d71ef6..73e152a29 100644
--- a/netbox/tenancy/forms/bulk_import.py
+++ b/netbox/tenancy/forms/bulk_import.py
@@ -1,13 +1,20 @@
from extras.forms import CustomFieldModelCSVForm
-from tenancy.models import Tenant, TenantGroup
+from tenancy.models import *
from utilities.forms import CSVModelChoiceField, SlugField
__all__ = (
+ 'ContactCSVForm',
+ 'ContactGroupCSVForm',
+ 'ContactRoleCSVForm',
'TenantCSVForm',
'TenantGroupCSVForm',
)
+#
+# Tenants
+#
+
class TenantGroupCSVForm(CustomFieldModelCSVForm):
parent = CSVModelChoiceField(
queryset=TenantGroup.objects.all(),
@@ -34,3 +41,43 @@ class TenantCSVForm(CustomFieldModelCSVForm):
class Meta:
model = Tenant
fields = ('name', 'slug', 'group', 'description', 'comments')
+
+
+#
+# Contacts
+#
+
+class ContactGroupCSVForm(CustomFieldModelCSVForm):
+ parent = CSVModelChoiceField(
+ queryset=ContactGroup.objects.all(),
+ required=False,
+ to_field_name='name',
+ help_text='Parent group'
+ )
+ slug = SlugField()
+
+ class Meta:
+ model = ContactGroup
+ fields = ('name', 'slug', 'parent', 'description')
+
+
+class ContactRoleCSVForm(CustomFieldModelCSVForm):
+ slug = SlugField()
+
+ class Meta:
+ model = ContactRole
+ fields = ('name', 'slug', 'description')
+
+
+class ContactCSVForm(CustomFieldModelCSVForm):
+ slug = SlugField()
+ group = CSVModelChoiceField(
+ queryset=ContactGroup.objects.all(),
+ required=False,
+ to_field_name='name',
+ help_text='Assigned group'
+ )
+
+ class Meta:
+ model = Contact
+ fields = ('name', 'title', 'phone', 'email', 'address', 'group', 'comments')
diff --git a/netbox/tenancy/forms/filtersets.py b/netbox/tenancy/forms/filtersets.py
index 6e2eb7fd1..69941701f 100644
--- a/netbox/tenancy/forms/filtersets.py
+++ b/netbox/tenancy/forms/filtersets.py
@@ -2,9 +2,21 @@ from django import forms
from django.utils.translation import gettext as _
from extras.forms import CustomFieldModelFilterForm
-from tenancy.models import Tenant, TenantGroup
+from tenancy.models import *
from utilities.forms import BootstrapMixin, DynamicModelMultipleChoiceField, TagFilterField
+__all__ = (
+ 'ContactFilterForm',
+ 'ContactGroupFilterForm',
+ 'ContactRoleFilterForm',
+ 'TenantFilterForm',
+ 'TenantGroupFilterForm',
+)
+
+
+#
+# Tenants
+#
class TenantGroupFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
model = TenantGroup
@@ -40,3 +52,55 @@ class TenantFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
fetch_trigger='open'
)
tag = TagFilterField(model)
+
+
+#
+# Contacts
+#
+
+class ContactGroupFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
+ model = ContactGroup
+ q = forms.CharField(
+ required=False,
+ widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
+ label=_('Search')
+ )
+ parent_id = DynamicModelMultipleChoiceField(
+ queryset=ContactGroup.objects.all(),
+ required=False,
+ label=_('Parent group'),
+ fetch_trigger='open'
+ )
+
+
+class ContactRoleFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
+ model = ContactRole
+ field_groups = [
+ ['q'],
+ ]
+ q = forms.CharField(
+ required=False,
+ widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
+ label=_('Search')
+ )
+
+
+class ContactFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
+ model = Contact
+ field_groups = (
+ ('q', 'tag'),
+ ('group_id',),
+ )
+ q = forms.CharField(
+ required=False,
+ widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
+ label=_('Search')
+ )
+ group_id = DynamicModelMultipleChoiceField(
+ queryset=ContactGroup.objects.all(),
+ required=False,
+ null_option='None',
+ label=_('Group'),
+ fetch_trigger='open'
+ )
+ tag = TagFilterField(model)
diff --git a/netbox/tenancy/forms/models.py b/netbox/tenancy/forms/models.py
index de3a9e515..b15065705 100644
--- a/netbox/tenancy/forms/models.py
+++ b/netbox/tenancy/forms/models.py
@@ -1,16 +1,27 @@
+from django import forms
+
from extras.forms import CustomFieldModelForm
from extras.models import Tag
-from tenancy.models import Tenant, TenantGroup
+from tenancy.models import *
from utilities.forms import (
- BootstrapMixin, CommentField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, SlugField,
+ BootstrapMixin, CommentField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, SlugField, SmallTextarea,
+ StaticSelect,
)
__all__ = (
+ 'ContactAssignmentForm',
+ 'ContactForm',
+ 'ContactGroupForm',
+ 'ContactRoleForm',
'TenantForm',
'TenantGroupForm',
)
+#
+# Tenants
+#
+
class TenantGroupForm(BootstrapMixin, CustomFieldModelForm):
parent = DynamicModelChoiceField(
queryset=TenantGroup.objects.all(),
@@ -45,3 +56,79 @@ class TenantForm(BootstrapMixin, CustomFieldModelForm):
fieldsets = (
('Tenant', ('name', 'slug', 'group', 'description', 'tags')),
)
+
+
+#
+# Contacts
+#
+
+class ContactGroupForm(BootstrapMixin, CustomFieldModelForm):
+ parent = DynamicModelChoiceField(
+ queryset=ContactGroup.objects.all(),
+ required=False
+ )
+ slug = SlugField()
+
+ class Meta:
+ model = ContactGroup
+ fields = ['parent', 'name', 'slug', 'description']
+
+
+class ContactRoleForm(BootstrapMixin, CustomFieldModelForm):
+ slug = SlugField()
+
+ class Meta:
+ model = ContactRole
+ fields = ['name', 'slug', 'description']
+
+
+class ContactForm(BootstrapMixin, CustomFieldModelForm):
+ group = DynamicModelChoiceField(
+ queryset=ContactGroup.objects.all(),
+ required=False
+ )
+ comments = CommentField()
+ tags = DynamicModelMultipleChoiceField(
+ queryset=Tag.objects.all(),
+ required=False
+ )
+
+ class Meta:
+ model = Contact
+ fields = (
+ 'group', 'name', 'title', 'phone', 'email', 'address', 'comments', 'tags',
+ )
+ fieldsets = (
+ ('Contact', ('group', 'name', 'title', 'phone', 'email', 'address', 'tags')),
+ )
+ widgets = {
+ 'address': SmallTextarea(attrs={'rows': 3}),
+ }
+
+
+class ContactAssignmentForm(BootstrapMixin, forms.ModelForm):
+ group = DynamicModelChoiceField(
+ queryset=ContactGroup.objects.all(),
+ required=False,
+ initial_params={
+ 'contacts': '$contact'
+ }
+ )
+ contact = DynamicModelChoiceField(
+ queryset=Contact.objects.all(),
+ query_params={
+ 'group_id': '$group'
+ }
+ )
+ role = DynamicModelChoiceField(
+ queryset=ContactRole.objects.all()
+ )
+
+ class Meta:
+ model = ContactAssignment
+ fields = (
+ 'group', 'contact', 'role', 'priority',
+ )
+ widgets = {
+ 'priority': StaticSelect(),
+ }
diff --git a/netbox/tenancy/graphql/schema.py b/netbox/tenancy/graphql/schema.py
index f420eb787..de0a1781a 100644
--- a/netbox/tenancy/graphql/schema.py
+++ b/netbox/tenancy/graphql/schema.py
@@ -10,3 +10,15 @@ class TenancyQuery(graphene.ObjectType):
tenant_group = ObjectField(TenantGroupType)
tenant_group_list = ObjectListField(TenantGroupType)
+
+ contact = ObjectField(ContactType)
+ contact_list = ObjectListField(ContactType)
+
+ contact_role = ObjectField(ContactRoleType)
+ contact_role_list = ObjectListField(ContactRoleType)
+
+ contact_group = ObjectField(ContactGroupType)
+ contact_group_list = ObjectListField(ContactGroupType)
+
+ contact_assignment = ObjectField(ContactAssignmentType)
+ contact_assignment_list = ObjectListField(ContactAssignmentType)
diff --git a/netbox/tenancy/graphql/types.py b/netbox/tenancy/graphql/types.py
index 6f1e27274..a16d51081 100644
--- a/netbox/tenancy/graphql/types.py
+++ b/netbox/tenancy/graphql/types.py
@@ -1,12 +1,29 @@
+import graphene
+
from tenancy import filtersets, models
from netbox.graphql.types import OrganizationalObjectType, PrimaryObjectType
__all__ = (
+ 'ContactAssignmentType',
+ 'ContactGroupType',
+ 'ContactRoleType',
+ 'ContactType',
'TenantType',
'TenantGroupType',
)
+class ContactAssignmentsMixin:
+ assignments = graphene.List('tenancy.graphql.types.ContactAssignmentType')
+
+ def resolve_assignments(self, info):
+ return self.assignments.restrict(info.context.user, 'view')
+
+
+#
+# Tenants
+#
+
class TenantType(PrimaryObjectType):
class Meta:
@@ -21,3 +38,39 @@ class TenantGroupType(OrganizationalObjectType):
model = models.TenantGroup
fields = '__all__'
filterset_class = filtersets.TenantGroupFilterSet
+
+
+#
+# Contacts
+#
+
+class ContactType(ContactAssignmentsMixin, PrimaryObjectType):
+
+ class Meta:
+ model = models.Contact
+ fields = '__all__'
+ filterset_class = filtersets.ContactFilterSet
+
+
+class ContactRoleType(ContactAssignmentsMixin, OrganizationalObjectType):
+
+ class Meta:
+ model = models.ContactRole
+ fields = '__all__'
+ filterset_class = filtersets.ContactRoleFilterSet
+
+
+class ContactGroupType(OrganizationalObjectType):
+
+ class Meta:
+ model = models.ContactGroup
+ fields = '__all__'
+ filterset_class = filtersets.ContactGroupFilterSet
+
+
+class ContactAssignmentType(OrganizationalObjectType):
+
+ class Meta:
+ model = models.ContactAssignment
+ fields = '__all__'
+ filterset_class = filtersets.ContactAssignmentFilterSet
diff --git a/netbox/tenancy/migrations/0003_contacts.py b/netbox/tenancy/migrations/0003_contacts.py
new file mode 100644
index 000000000..35e568ab1
--- /dev/null
+++ b/netbox/tenancy/migrations/0003_contacts.py
@@ -0,0 +1,91 @@
+import django.core.serializers.json
+from django.db import migrations, models
+import django.db.models.deletion
+import mptt.fields
+import taggit.managers
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('extras', '0062_clear_secrets_changelog'),
+ ('contenttypes', '0002_remove_content_type_name'),
+ ('tenancy', '0002_tenant_ordering'),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='ContactRole',
+ fields=[
+ ('created', models.DateField(auto_now_add=True, null=True)),
+ ('last_updated', models.DateTimeField(auto_now=True, null=True)),
+ ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)),
+ ('id', models.BigAutoField(primary_key=True, serialize=False)),
+ ('name', models.CharField(max_length=100, unique=True)),
+ ('slug', models.SlugField(max_length=100, unique=True)),
+ ('description', models.CharField(blank=True, max_length=200)),
+ ],
+ options={
+ 'ordering': ['name'],
+ },
+ ),
+ migrations.CreateModel(
+ name='ContactGroup',
+ fields=[
+ ('created', models.DateField(auto_now_add=True, null=True)),
+ ('last_updated', models.DateTimeField(auto_now=True, null=True)),
+ ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)),
+ ('id', models.BigAutoField(primary_key=True, serialize=False)),
+ ('name', models.CharField(max_length=100)),
+ ('slug', models.SlugField(max_length=100)),
+ ('description', models.CharField(blank=True, max_length=200)),
+ ('lft', models.PositiveIntegerField(editable=False)),
+ ('rght', models.PositiveIntegerField(editable=False)),
+ ('tree_id', models.PositiveIntegerField(db_index=True, editable=False)),
+ ('level', models.PositiveIntegerField(editable=False)),
+ ('parent', mptt.fields.TreeForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='children', to='tenancy.contactgroup')),
+ ],
+ options={
+ 'ordering': ['name'],
+ 'unique_together': {('parent', 'name')},
+ },
+ ),
+ migrations.CreateModel(
+ name='Contact',
+ fields=[
+ ('created', models.DateField(auto_now_add=True, null=True)),
+ ('last_updated', models.DateTimeField(auto_now=True, null=True)),
+ ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)),
+ ('id', models.BigAutoField(primary_key=True, serialize=False)),
+ ('name', models.CharField(max_length=100)),
+ ('title', models.CharField(blank=True, max_length=100)),
+ ('phone', models.CharField(blank=True, max_length=50)),
+ ('email', models.EmailField(blank=True, max_length=254)),
+ ('address', models.CharField(blank=True, max_length=200)),
+ ('comments', models.TextField(blank=True)),
+ ('group', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='contacts', to='tenancy.contactgroup')),
+ ('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')),
+ ],
+ options={
+ 'ordering': ['name'],
+ 'unique_together': {('group', 'name')},
+ },
+ ),
+ migrations.CreateModel(
+ name='ContactAssignment',
+ fields=[
+ ('created', models.DateField(auto_now_add=True, null=True)),
+ ('last_updated', models.DateTimeField(auto_now=True, null=True)),
+ ('id', models.BigAutoField(primary_key=True, serialize=False)),
+ ('object_id', models.PositiveIntegerField()),
+ ('priority', models.CharField(blank=True, max_length=50)),
+ ('contact', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='assignments', to='tenancy.contact')),
+ ('content_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='contenttypes.contenttype')),
+ ('role', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='assignments', to='tenancy.contactrole')),
+ ],
+ options={
+ 'ordering': ('priority', 'contact'),
+ 'unique_together': {('content_type', 'object_id', 'contact', 'role', 'priority')},
+ },
+ ),
+ ]
diff --git a/netbox/tenancy/models.py b/netbox/tenancy/models.py
index 4a5b1967e..c709236e2 100644
--- a/netbox/tenancy/models.py
+++ b/netbox/tenancy/models.py
@@ -1,19 +1,29 @@
-from django.core.exceptions import ValidationError
+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
from mptt.models import MPTTModel, TreeForeignKey
from extras.utils import extras_features
-from netbox.models import NestedGroupModel, PrimaryModel
+from netbox.models import ChangeLoggedModel, NestedGroupModel, OrganizationalModel, PrimaryModel
from utilities.querysets import RestrictedQuerySet
+from .choices import *
__all__ = (
+ 'ContactAssignment',
+ 'Contact',
+ 'ContactGroup',
+ 'ContactRole',
'Tenant',
'TenantGroup',
)
+#
+# Tenants
+#
+
@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
class TenantGroup(NestedGroupModel):
"""
@@ -76,6 +86,11 @@ class Tenant(PrimaryModel):
blank=True
)
+ # Generic relations
+ contacts = GenericRelation(
+ to='tenancy.ContactAssignment'
+ )
+
objects = RestrictedQuerySet.as_manager()
clone_fields = [
@@ -90,3 +105,163 @@ class Tenant(PrimaryModel):
def get_absolute_url(self):
return reverse('tenancy:tenant', args=[self.pk])
+
+
+#
+# Contacts
+#
+
+@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
+class ContactGroup(NestedGroupModel):
+ """
+ An arbitrary collection of Contacts.
+ """
+ name = models.CharField(
+ max_length=100
+ )
+ slug = models.SlugField(
+ max_length=100
+ )
+ parent = TreeForeignKey(
+ to='self',
+ on_delete=models.CASCADE,
+ related_name='children',
+ blank=True,
+ null=True,
+ db_index=True
+ )
+ description = models.CharField(
+ max_length=200,
+ blank=True
+ )
+
+ class Meta:
+ ordering = ['name']
+ unique_together = (
+ ('parent', 'name')
+ )
+
+ def get_absolute_url(self):
+ return reverse('tenancy:contactgroup', args=[self.pk])
+
+
+@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
+class ContactRole(OrganizationalModel):
+ """
+ Functional role for a Contact assigned to an object.
+ """
+ name = models.CharField(
+ max_length=100,
+ unique=True
+ )
+ slug = models.SlugField(
+ max_length=100,
+ unique=True
+ )
+ description = models.CharField(
+ max_length=200,
+ blank=True,
+ )
+
+ objects = RestrictedQuerySet.as_manager()
+
+ class Meta:
+ ordering = ['name']
+
+ def __str__(self):
+ return self.name
+
+ def get_absolute_url(self):
+ return reverse('tenancy:contactrole', args=[self.pk])
+
+
+@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
+class Contact(PrimaryModel):
+ """
+ Contact information for a particular object(s) in NetBox.
+ """
+ group = models.ForeignKey(
+ to='tenancy.ContactGroup',
+ on_delete=models.SET_NULL,
+ related_name='contacts',
+ blank=True,
+ null=True
+ )
+ name = models.CharField(
+ max_length=100
+ )
+ title = models.CharField(
+ max_length=100,
+ blank=True
+ )
+ phone = models.CharField(
+ max_length=50,
+ blank=True
+ )
+ email = models.EmailField(
+ blank=True
+ )
+ address = models.CharField(
+ max_length=200,
+ blank=True
+ )
+ comments = models.TextField(
+ blank=True
+ )
+
+ objects = RestrictedQuerySet.as_manager()
+
+ clone_fields = [
+ 'group',
+ ]
+
+ class Meta:
+ ordering = ['name']
+ unique_together = (
+ ('group', 'name')
+ )
+
+ def __str__(self):
+ return self.name
+
+ def get_absolute_url(self):
+ return reverse('tenancy:contact', args=[self.pk])
+
+
+@extras_features('webhooks')
+class ContactAssignment(ChangeLoggedModel):
+ content_type = models.ForeignKey(
+ to=ContentType,
+ on_delete=models.CASCADE
+ )
+ object_id = models.PositiveIntegerField()
+ object = GenericForeignKey(
+ ct_field='content_type',
+ fk_field='object_id'
+ )
+ contact = models.ForeignKey(
+ to='tenancy.Contact',
+ on_delete=models.PROTECT,
+ related_name='assignments'
+ )
+ role = models.ForeignKey(
+ to='tenancy.ContactRole',
+ on_delete=models.PROTECT,
+ related_name='assignments'
+ )
+ priority = models.CharField(
+ max_length=50,
+ choices=ContactPriorityChoices,
+ blank=True
+ )
+
+ objects = RestrictedQuerySet.as_manager()
+
+ class Meta:
+ ordering = ('priority', 'contact')
+ unique_together = ('content_type', 'object_id', 'contact', 'role', 'priority')
+
+ def __str__(self):
+ if self.priority:
+ return f"{self.contact} ({self.get_priority_display()})"
+ return str(self.contact)
diff --git a/netbox/tenancy/tables.py b/netbox/tenancy/tables.py
index f39ca1b18..5b254842b 100644
--- a/netbox/tenancy/tables.py
+++ b/netbox/tenancy/tables.py
@@ -1,11 +1,15 @@
import django_tables2 as tables
from utilities.tables import (
- BaseTable, ButtonsColumn, LinkedCountColumn, MarkdownColumn, MPTTColumn, TagColumn, ToggleColumn,
+ BaseTable, ButtonsColumn, ContentTypeColumn, LinkedCountColumn, MarkdownColumn, MPTTColumn, TagColumn, ToggleColumn,
)
-from .models import Tenant, TenantGroup
+from .models import *
__all__ = (
+ 'ContactAssignmentTable',
+ 'ContactGroupTable',
+ 'ContactRoleTable',
+ 'ContactTable',
'TenantColumn',
'TenantGroupTable',
'TenantTable',
@@ -38,7 +42,7 @@ class TenantColumn(tables.TemplateColumn):
#
-# Tenant groups
+# Tenants
#
class TenantGroupTable(BaseTable):
@@ -59,10 +63,6 @@ class TenantGroupTable(BaseTable):
default_columns = ('pk', 'name', 'tenant_count', 'description', 'actions')
-#
-# Tenants
-#
-
class TenantTable(BaseTable):
pk = ToggleColumn()
name = tables.Column(
@@ -80,3 +80,82 @@ class TenantTable(BaseTable):
model = Tenant
fields = ('pk', 'name', 'slug', 'group', 'description', 'comments', 'tags')
default_columns = ('pk', 'name', 'group', 'description')
+
+
+#
+# Contacts
+#
+
+class ContactGroupTable(BaseTable):
+ pk = ToggleColumn()
+ name = MPTTColumn(
+ linkify=True
+ )
+ contact_count = LinkedCountColumn(
+ viewname='tenancy:contact_list',
+ url_params={'role_id': 'pk'},
+ verbose_name='Contacts'
+ )
+ actions = ButtonsColumn(ContactGroup)
+
+ class Meta(BaseTable.Meta):
+ model = ContactGroup
+ fields = ('pk', 'name', 'contact_count', 'description', 'slug', 'actions')
+ default_columns = ('pk', 'name', 'contact_count', 'description', 'actions')
+
+
+class ContactRoleTable(BaseTable):
+ pk = ToggleColumn()
+ name = tables.Column(
+ linkify=True
+ )
+ actions = ButtonsColumn(ContactRole)
+
+ class Meta(BaseTable.Meta):
+ model = ContactRole
+ fields = ('pk', 'name', 'description', 'slug', 'actions')
+ default_columns = ('pk', 'name', 'description', 'actions')
+
+
+class ContactTable(BaseTable):
+ pk = ToggleColumn()
+ name = tables.Column(
+ linkify=True
+ )
+ group = tables.Column(
+ linkify=True
+ )
+ comments = MarkdownColumn()
+ assignment_count = tables.Column(
+ verbose_name='Assignments'
+ )
+ tags = TagColumn(
+ url_name='tenancy:tenant_list'
+ )
+
+ class Meta(BaseTable.Meta):
+ model = Contact
+ fields = ('pk', 'name', 'group', 'title', 'phone', 'email', 'address', 'comments', 'assignment_count', 'tags')
+ default_columns = ('pk', 'name', 'group', 'assignment_count', 'title', 'phone', 'email')
+
+
+class ContactAssignmentTable(BaseTable):
+ pk = ToggleColumn()
+ content_type = ContentTypeColumn(
+ verbose_name='Object Type'
+ )
+ object = tables.Column(
+ linkify=True,
+ orderable=False
+ )
+ contact = tables.Column(
+ linkify=True
+ )
+ role = tables.Column(
+ linkify=True
+ )
+
+ class Meta(BaseTable.Meta):
+ model = ContactAssignment
+ fields = ('pk', 'content_type', 'object', 'contact', 'role', 'priority')
+ default_columns = ('pk', 'object', 'contact', 'role', 'priority')
diff --git a/netbox/tenancy/tests/test_api.py b/netbox/tenancy/tests/test_api.py
index 5a3c2c1b0..c7c6cf846 100644
--- a/netbox/tenancy/tests/test_api.py
+++ b/netbox/tenancy/tests/test_api.py
@@ -1,6 +1,6 @@
from django.urls import reverse
-from tenancy.models import Tenant, TenantGroup
+from tenancy.models import *
from utilities.testing import APITestCase, APIViewTestCases
@@ -92,3 +92,112 @@ class TenantTest(APIViewTestCases.APIViewTestCase):
'group': tenant_groups[1].pk,
},
]
+
+
+class ContactGroupTest(APIViewTestCases.APIViewTestCase):
+ model = ContactGroup
+ brief_fields = ['_depth', 'contact_count', 'display', 'id', 'name', 'slug', 'url']
+ bulk_update_data = {
+ 'description': 'New description',
+ }
+
+ @classmethod
+ def setUpTestData(cls):
+
+ parent_contact_groups = (
+ ContactGroup.objects.create(name='Parent Contact Group 1', slug='parent-contact-group-1'),
+ ContactGroup.objects.create(name='Parent Contact Group 2', slug='parent-contact-group-2'),
+ )
+
+ ContactGroup.objects.create(name='Contact Group 1', slug='contact-group-1', parent=parent_contact_groups[0])
+ ContactGroup.objects.create(name='Contact Group 2', slug='contact-group-2', parent=parent_contact_groups[0])
+ ContactGroup.objects.create(name='Contact Group 3', slug='contact-group-3', parent=parent_contact_groups[0])
+
+ cls.create_data = [
+ {
+ 'name': 'Contact Group 4',
+ 'slug': 'contact-group-4',
+ 'parent': parent_contact_groups[1].pk,
+ },
+ {
+ 'name': 'Contact Group 5',
+ 'slug': 'contact-group-5',
+ 'parent': parent_contact_groups[1].pk,
+ },
+ {
+ 'name': 'Contact Group 6',
+ 'slug': 'contact-group-6',
+ 'parent': parent_contact_groups[1].pk,
+ },
+ ]
+
+
+class ContactRoleTest(APIViewTestCases.APIViewTestCase):
+ model = ContactRole
+ brief_fields = ['display', 'id', 'name', 'slug', 'url']
+ create_data = [
+ {
+ 'name': 'Contact Role 4',
+ 'slug': 'contact-role-4',
+ },
+ {
+ 'name': 'Contact Role 5',
+ 'slug': 'contact-role-5',
+ },
+ {
+ 'name': 'Contact Role 6',
+ 'slug': 'contact-role-6',
+ },
+ ]
+ bulk_update_data = {
+ 'description': 'New description',
+ }
+
+ @classmethod
+ def setUpTestData(cls):
+
+ contact_roles = (
+ ContactRole(name='Contact Role 1', slug='contact-role-1'),
+ ContactRole(name='Contact Role 2', slug='contact-role-2'),
+ ContactRole(name='Contact Role 3', slug='contact-role-3'),
+ )
+ ContactRole.objects.bulk_create(contact_roles)
+
+
+class ContactTest(APIViewTestCases.APIViewTestCase):
+ model = Contact
+ brief_fields = ['display', 'id', 'name', 'url']
+ bulk_update_data = {
+ 'group': None,
+ 'comments': 'New comments',
+ }
+
+ @classmethod
+ def setUpTestData(cls):
+
+ contact_groups = (
+ ContactGroup.objects.create(name='Contact Group 1', slug='contact-group-1'),
+ ContactGroup.objects.create(name='Contact Group 2', slug='contact-group-2'),
+ )
+
+ contacts = (
+ Contact(name='Contact 1', group=contact_groups[0]),
+ Contact(name='Contact 2', group=contact_groups[0]),
+ Contact(name='Contact 3', group=contact_groups[0]),
+ )
+ Contact.objects.bulk_create(contacts)
+
+ cls.create_data = [
+ {
+ 'name': 'Contact 4',
+ 'group': contact_groups[1].pk,
+ },
+ {
+ 'name': 'Contact 5',
+ 'group': contact_groups[1].pk,
+ },
+ {
+ 'name': 'Contact 6',
+ 'group': contact_groups[1].pk,
+ },
+ ]
diff --git a/netbox/tenancy/tests/test_filtersets.py b/netbox/tenancy/tests/test_filtersets.py
index fd4a0bd76..86170734c 100644
--- a/netbox/tenancy/tests/test_filtersets.py
+++ b/netbox/tenancy/tests/test_filtersets.py
@@ -1,7 +1,7 @@
from django.test import TestCase
from tenancy.filtersets import *
-from tenancy.models import Tenant, TenantGroup
+from tenancy.models import *
from utilities.testing import ChangeLoggedFilterSetTests
@@ -84,3 +84,103 @@ class TenantTestCase(TestCase, ChangeLoggedFilterSetTests):
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'group': [group[0].slug, group[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+
+class ContactGroupTestCase(TestCase, ChangeLoggedFilterSetTests):
+ queryset = ContactGroup.objects.all()
+ filterset = ContactGroupFilterSet
+
+ @classmethod
+ def setUpTestData(cls):
+
+ parent_contact_groups = (
+ ContactGroup(name='Parent Contact Group 1', slug='parent-contact-group-1'),
+ ContactGroup(name='Parent Contact Group 2', slug='parent-contact-group-2'),
+ ContactGroup(name='Parent Contact Group 3', slug='parent-contact-group-3'),
+ )
+ for contactgroup in parent_contact_groups:
+ contactgroup.save()
+
+ contact_groups = (
+ ContactGroup(name='Contact Group 1', slug='contact-group-1', parent=parent_contact_groups[0], description='A'),
+ ContactGroup(name='Contact Group 2', slug='contact-group-2', parent=parent_contact_groups[1], description='B'),
+ ContactGroup(name='Contact Group 3', slug='contact-group-3', parent=parent_contact_groups[2], description='C'),
+ )
+ for contactgroup in contact_groups:
+ contactgroup.save()
+
+ def test_name(self):
+ params = {'name': ['Contact Group 1', 'Contact Group 2']}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+ def test_slug(self):
+ params = {'slug': ['contact-group-1', 'contact-group-2']}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+ def test_description(self):
+ params = {'description': ['A', 'B']}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+ def test_parent(self):
+ parent_groups = ContactGroup.objects.filter(parent__isnull=True)[:2]
+ params = {'parent_id': [parent_groups[0].pk, parent_groups[1].pk]}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+ params = {'parent': [parent_groups[0].slug, parent_groups[1].slug]}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+
+class ContactRoleTestCase(TestCase, ChangeLoggedFilterSetTests):
+ queryset = ContactRole.objects.all()
+ filterset = ContactRoleFilterSet
+
+ @classmethod
+ def setUpTestData(cls):
+
+ contact_roles = (
+ ContactRole(name='Contact Role 1', slug='contact-role-1'),
+ ContactRole(name='Contact Role 2', slug='contact-role-2'),
+ ContactRole(name='Contact Role 3', slug='contact-role-3'),
+ )
+ ContactRole.objects.bulk_create(contact_roles)
+
+ def test_name(self):
+ params = {'name': ['Contact Role 1', 'Contact Role 2']}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+ def test_slug(self):
+ params = {'slug': ['contact-role-1', 'contact-role-2']}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+
+class ContactTestCase(TestCase, ChangeLoggedFilterSetTests):
+ queryset = Contact.objects.all()
+ filterset = ContactFilterSet
+
+ @classmethod
+ def setUpTestData(cls):
+
+ contact_groups = (
+ ContactGroup(name='Contact Group 1', slug='contact-group-1'),
+ ContactGroup(name='Contact Group 2', slug='contact-group-2'),
+ ContactGroup(name='Contact Group 3', slug='contact-group-3'),
+ )
+ for contactgroup in contact_groups:
+ contactgroup.save()
+
+ contacts = (
+ Contact(name='Contact 1', group=contact_groups[0]),
+ Contact(name='Contact 2', group=contact_groups[1]),
+ Contact(name='Contact 3', group=contact_groups[2]),
+ )
+ Contact.objects.bulk_create(contacts)
+
+ def test_name(self):
+ params = {'name': ['Contact 1', 'Contact 2']}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+ def test_group(self):
+ group = ContactGroup.objects.all()[:2]
+ params = {'group_id': [group[0].pk, group[1].pk]}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+ params = {'group': [group[0].slug, group[1].slug]}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
diff --git a/netbox/tenancy/tests/test_views.py b/netbox/tenancy/tests/test_views.py
index f45afc302..fb7ff3ce3 100644
--- a/netbox/tenancy/tests/test_views.py
+++ b/netbox/tenancy/tests/test_views.py
@@ -1,4 +1,4 @@
-from tenancy.models import Tenant, TenantGroup
+from tenancy.models import *
from utilities.testing import ViewTestCases, create_tags
@@ -74,3 +74,105 @@ class TenantTestCase(ViewTestCases.PrimaryObjectViewTestCase):
cls.bulk_edit_data = {
'group': tenant_groups[1].pk,
}
+
+
+class ContactGroupTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
+ model = ContactGroup
+
+ @classmethod
+ def setUpTestData(cls):
+
+ contact_groups = (
+ ContactGroup(name='Contact Group 1', slug='contact-group-1'),
+ ContactGroup(name='Contact Group 2', slug='contact-group-2'),
+ ContactGroup(name='Contact Group 3', slug='contact-group-3'),
+ )
+ for tenanantgroup in contact_groups:
+ tenanantgroup.save()
+
+ cls.form_data = {
+ 'name': 'Contact Group X',
+ 'slug': 'contact-group-x',
+ 'description': 'A new contact group',
+ }
+
+ cls.csv_data = (
+ "name,slug,description",
+ "Contact Group 4,contact-group-4,Fourth contact group",
+ "Contact Group 5,contact-group-5,Fifth contact group",
+ "Contact Group 6,contact-group-6,Sixth contact group",
+ )
+
+ cls.bulk_edit_data = {
+ 'description': 'New description',
+ }
+
+
+class ContactRoleTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
+ model = ContactRole
+
+ @classmethod
+ def setUpTestData(cls):
+
+ ContactRole.objects.bulk_create([
+ ContactRole(name='Contact Role 1', slug='contact-role-1'),
+ ContactRole(name='Contact Role 2', slug='contact-role-2'),
+ ContactRole(name='Contact Role 3', slug='contact-role-3'),
+ ])
+
+ cls.form_data = {
+ 'name': 'Devie Role X',
+ 'slug': 'contact-role-x',
+ 'description': 'New contact role',
+ }
+
+ cls.csv_data = (
+ "name,slug",
+ "Contact Role 4,contact-role-4",
+ "Contact Role 5,contact-role-5",
+ "Contact Role 6,contact-role-6",
+ )
+
+ cls.bulk_edit_data = {
+ 'description': 'New description',
+ }
+
+
+class ContactTestCase(ViewTestCases.PrimaryObjectViewTestCase):
+ model = Contact
+
+ @classmethod
+ def setUpTestData(cls):
+
+ contact_groups = (
+ ContactGroup(name='Contact Group 1', slug='contact-group-1'),
+ ContactGroup(name='Contact Group 2', slug='contact-group-2'),
+ )
+ for contactgroup in contact_groups:
+ contactgroup.save()
+
+ Contact.objects.bulk_create([
+ Contact(name='Contact 1', group=contact_groups[0]),
+ Contact(name='Contact 2', group=contact_groups[0]),
+ Contact(name='Contact 3', group=contact_groups[0]),
+ ])
+
+ tags = create_tags('Alpha', 'Bravo', 'Charlie')
+
+ cls.form_data = {
+ 'name': 'Contact X',
+ 'group': contact_groups[1].pk,
+ 'comments': 'Some comments',
+ 'tags': [t.pk for t in tags],
+ }
+
+ cls.csv_data = (
+ "name,slug",
+ "Contact 4,contact-4",
+ "Contact 5,contact-5",
+ "Contact 6,contact-6",
+ )
+
+ cls.bulk_edit_data = {
+ 'group': contact_groups[1].pk,
+ }
diff --git a/netbox/tenancy/urls.py b/netbox/tenancy/urls.py
index a1f46c7ec..14047603d 100644
--- a/netbox/tenancy/urls.py
+++ b/netbox/tenancy/urls.py
@@ -3,7 +3,7 @@ from django.urls import path
from extras.views import ObjectChangeLogView, ObjectJournalView
from utilities.views import SlugRedirectView
from . import views
-from .models import Tenant, TenantGroup
+from .models import *
app_name = 'tenancy'
urlpatterns = [
@@ -32,4 +32,44 @@ urlpatterns = [
path('tenants//changelog/', ObjectChangeLogView.as_view(), name='tenant_changelog', kwargs={'model': Tenant}),
path('tenants//journal/', ObjectJournalView.as_view(), name='tenant_journal', kwargs={'model': Tenant}),
+ # Contact groups
+ path('contact-groups/', views.ContactGroupListView.as_view(), name='contactgroup_list'),
+ path('contact-groups/add/', views.ContactGroupEditView.as_view(), name='contactgroup_add'),
+ path('contact-groups/import/', views.ContactGroupBulkImportView.as_view(), name='contactgroup_import'),
+ path('contact-groups/edit/', views.ContactGroupBulkEditView.as_view(), name='contactgroup_bulk_edit'),
+ path('contact-groups/delete/', views.ContactGroupBulkDeleteView.as_view(), name='contactgroup_bulk_delete'),
+ path('contact-groups//', views.ContactGroupView.as_view(), name='contactgroup'),
+ path('contact-groups//edit/', views.ContactGroupEditView.as_view(), name='contactgroup_edit'),
+ path('contact-groups//delete/', views.ContactGroupDeleteView.as_view(), name='contactgroup_delete'),
+ path('contact-groups//changelog/', ObjectChangeLogView.as_view(), name='contactgroup_changelog', kwargs={'model': ContactGroup}),
+
+ # Contact roles
+ path('contact-roles/', views.ContactRoleListView.as_view(), name='contactrole_list'),
+ path('contact-roles/add/', views.ContactRoleEditView.as_view(), name='contactrole_add'),
+ path('contact-roles/import/', views.ContactRoleBulkImportView.as_view(), name='contactrole_import'),
+ path('contact-roles/edit/', views.ContactRoleBulkEditView.as_view(), name='contactrole_bulk_edit'),
+ path('contact-roles/delete/', views.ContactRoleBulkDeleteView.as_view(), name='contactrole_bulk_delete'),
+ path('contact-roles//', views.ContactRoleView.as_view(), name='contactrole'),
+ path('contact-roles//edit/', views.ContactRoleEditView.as_view(), name='contactrole_edit'),
+ path('contact-roles//delete/', views.ContactRoleDeleteView.as_view(), name='contactrole_delete'),
+ path('contact-roles//changelog/', ObjectChangeLogView.as_view(), name='contactrole_changelog', kwargs={'model': ContactRole}),
+
+ # Contacts
+ path('contacts/', views.ContactListView.as_view(), name='contact_list'),
+ path('contacts/add/', views.ContactEditView.as_view(), name='contact_add'),
+ path('contacts/import/', views.ContactBulkImportView.as_view(), name='contact_import'),
+ path('contacts/edit/', views.ContactBulkEditView.as_view(), name='contact_bulk_edit'),
+ path('contacts/delete/', views.ContactBulkDeleteView.as_view(), name='contact_bulk_delete'),
+ path('contacts//', views.ContactView.as_view(), name='contact'),
+ path('contacts//', SlugRedirectView.as_view(), kwargs={'model': Contact}),
+ path('contacts//edit/', views.ContactEditView.as_view(), name='contact_edit'),
+ path('contacts//delete/', views.ContactDeleteView.as_view(), name='contact_delete'),
+ 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 0b28a62d2..cdbaebdb1 100644
--- a/netbox/tenancy/views.py
+++ b/netbox/tenancy/views.py
@@ -1,11 +1,16 @@
+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 Tenant, TenantGroup
+from .models import *
#
@@ -140,3 +145,217 @@ class TenantBulkDeleteView(generic.BulkDeleteView):
queryset = Tenant.objects.prefetch_related('group')
filterset = filtersets.TenantFilterSet
table = tables.TenantTable
+
+
+#
+# Contact groups
+#
+
+class ContactGroupListView(generic.ObjectListView):
+ queryset = ContactGroup.objects.add_related_count(
+ ContactGroup.objects.all(),
+ Contact,
+ 'group',
+ 'contact_count',
+ cumulative=True
+ )
+ filterset = filtersets.ContactGroupFilterSet
+ filterset_form = forms.ContactGroupFilterForm
+ table = tables.ContactGroupTable
+
+
+class ContactGroupView(generic.ObjectView):
+ queryset = ContactGroup.objects.all()
+
+ def get_extra_context(self, request, instance):
+ contacts = Contact.objects.restrict(request.user, 'view').filter(
+ group=instance
+ )
+ contacts_table = tables.ContactTable(contacts, exclude=('group',))
+ paginate_table(contacts_table, request)
+
+ return {
+ 'contacts_table': contacts_table,
+ }
+
+
+class ContactGroupEditView(generic.ObjectEditView):
+ queryset = ContactGroup.objects.all()
+ model_form = forms.ContactGroupForm
+
+
+class ContactGroupDeleteView(generic.ObjectDeleteView):
+ queryset = ContactGroup.objects.all()
+
+
+class ContactGroupBulkImportView(generic.BulkImportView):
+ queryset = ContactGroup.objects.all()
+ model_form = forms.ContactGroupCSVForm
+ table = tables.ContactGroupTable
+
+
+class ContactGroupBulkEditView(generic.BulkEditView):
+ queryset = ContactGroup.objects.add_related_count(
+ ContactGroup.objects.all(),
+ Contact,
+ 'group',
+ 'contact_count',
+ cumulative=True
+ )
+ filterset = filtersets.ContactGroupFilterSet
+ table = tables.ContactGroupTable
+ form = forms.ContactGroupBulkEditForm
+
+
+class ContactGroupBulkDeleteView(generic.BulkDeleteView):
+ queryset = ContactGroup.objects.add_related_count(
+ ContactGroup.objects.all(),
+ Contact,
+ 'group',
+ 'contact_count',
+ cumulative=True
+ )
+ table = tables.ContactGroupTable
+
+
+#
+# Contact roles
+#
+
+class ContactRoleListView(generic.ObjectListView):
+ queryset = ContactRole.objects.all()
+ filterset = filtersets.ContactRoleFilterSet
+ filterset_form = forms.ContactRoleFilterForm
+ table = tables.ContactRoleTable
+
+
+class ContactRoleView(generic.ObjectView):
+ queryset = ContactRole.objects.all()
+
+ def get_extra_context(self, request, instance):
+ contact_assignments = ContactAssignment.objects.restrict(request.user, 'view').filter(
+ role=instance
+ )
+ contacts_table = tables.ContactAssignmentTable(contact_assignments)
+ contacts_table.columns.hide('role')
+ paginate_table(contacts_table, request)
+
+ return {
+ 'contacts_table': contacts_table,
+ 'assignment_count': ContactAssignment.objects.filter(role=instance).count(),
+ }
+
+
+class ContactRoleEditView(generic.ObjectEditView):
+ queryset = ContactRole.objects.all()
+ model_form = forms.ContactRoleForm
+
+
+class ContactRoleDeleteView(generic.ObjectDeleteView):
+ queryset = ContactRole.objects.all()
+
+
+class ContactRoleBulkImportView(generic.BulkImportView):
+ queryset = ContactRole.objects.all()
+ model_form = forms.ContactRoleCSVForm
+ table = tables.ContactRoleTable
+
+
+class ContactRoleBulkEditView(generic.BulkEditView):
+ queryset = ContactRole.objects.all()
+ filterset = filtersets.ContactRoleFilterSet
+ table = tables.ContactRoleTable
+ form = forms.ContactRoleBulkEditForm
+
+
+class ContactRoleBulkDeleteView(generic.BulkDeleteView):
+ queryset = ContactRole.objects.all()
+ table = tables.ContactRoleTable
+
+
+#
+# Contacts
+#
+
+class ContactListView(generic.ObjectListView):
+ queryset = Contact.objects.annotate(
+ assignment_count=count_related(ContactAssignment, 'contact')
+ )
+ filterset = filtersets.ContactFilterSet
+ filterset_form = forms.ContactFilterForm
+ table = tables.ContactTable
+
+
+class ContactView(generic.ObjectView):
+ queryset = Contact.objects.all()
+
+ def get_extra_context(self, request, instance):
+ contact_assignments = ContactAssignment.objects.restrict(request.user, 'view').filter(
+ contact=instance
+ )
+ contacts_table = tables.ContactAssignmentTable(contact_assignments)
+ contacts_table.columns.hide('contact')
+ paginate_table(contacts_table, request)
+
+ return {
+ 'contacts_table': contacts_table,
+ 'assignment_count': ContactAssignment.objects.filter(contact=instance).count(),
+ }
+
+
+class ContactEditView(generic.ObjectEditView):
+ queryset = Contact.objects.all()
+ model_form = forms.ContactForm
+
+
+class ContactDeleteView(generic.ObjectDeleteView):
+ queryset = Contact.objects.all()
+
+
+class ContactBulkImportView(generic.BulkImportView):
+ queryset = Contact.objects.all()
+ model_form = forms.ContactCSVForm
+ table = tables.ContactTable
+
+
+class ContactBulkEditView(generic.BulkEditView):
+ queryset = Contact.objects.prefetch_related('group')
+ filterset = filtersets.ContactFilterSet
+ table = tables.ContactTable
+ form = forms.ContactBulkEditForm
+
+
+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 = [