diff --git a/docs/core-functionality/contacts.md b/docs/core-functionality/contacts.md
new file mode 100644
index 00000000000..76a005fc062
--- /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 00000000000..9d81e2d858c
--- /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 00000000000..ea566c58a4c
--- /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 00000000000..23642ca03ed
--- /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 7244c36d6f2..72750d6f517 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 bc7dcc2197c..3d213b48d2c 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 669f5cfbd39..308a094c3b9 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 0e9520b36dc..f4d0ce8df60 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 c287d7d6c73..47fcd42e446 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 b343f61f297..0d9816b0b2c 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 a3978f16e35..de2c170a373 100644
--- a/netbox/netbox/navigation_menu.py
+++ b/netbox/netbox/navigation_menu.py
@@ -120,6 +120,14 @@ def get_model_buttons(app_label, model_name, actions=('add', 'import')):
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 b863a8a0ef2..3a8096351d5 100644
--- a/netbox/templates/circuits/circuit.html
+++ b/netbox/templates/circuits/circuit.html
@@ -70,11 +70,12 @@