Repository: nautobot/nautobot

Issue:
Static Groups
As a Network Automation Engineer, I want the ability to create and manage static groups that function much like dynamic groups, except their membership is statically managed.


This is to support grouping use cases in which dynamic membership is problematic or otherwise unwanted. These static groups will also be used to underpin a refactor of the way dynamic groups operate, to solve critical performance issues.


AC:
- Groups are defined per content type
- Membership is based upon being explicitly related to the group
- **Not** implemented as a TreeModel to start


Discussion:
StaticGroup model and StaticGroupAssociation model. 

Model Fields:
StaticGroup: name, contenttype, description, tags
StaticGroupAssociation: FK to StaticGroup, and generic FK to members of the Group.

Design Considerations:
1. It will be very similar to ContactAssociation. 
2. Need the ability to `FeatureQuery` for relevant content types. e.g. exclude ObjectChanges
3. Need a standardized pattern for related object tables in the UI
4. StaticGroup should only contain ONE content type
5. The UI has to mirror that of DynamicGroup (get_table_for_model lookup)
6. From any object List view, it would be nice to have a bulk assign operation button to add objects to a StaticGroup (next to bulk edit and bulk delete)
7. Add to a static group and remove from a static group in all bulk edit forms (mixin to add to the base class)
8. ConfigContext filter (Is this a requirement for 2.3?)
9. `get_groups_for_model()` API is something we should add to the BaseModel class.
10. GraphQL support for `object.static_groups`
11. `BaseFilterSet` enhancement: filter based on StaticGroup membership


Questions to explore:
Any mixins or abstract base classes that can be shared between ContactAssociation and StaticGroupAssociation? 

Stretch goal:
Adding a linked count column to the `BaseTable`.
Retrofit some of these design considerations for the Contact/Team feature.
After #5631 and #5668, remaining items are:

- ConfigContext filter (Is this a requirement for 2.3?)
- GraphQL support for object.static_groups
- (stretch) Retrofit some of these design considerations for the Contact/Team feature.

Resolving patch:
diff --git a/nautobot/core/graphql/schema.py b/nautobot/core/graphql/schema.py
--- a/nautobot/core/graphql/schema.py
+++ b/nautobot/core/graphql/schema.py
@@ -41,7 +41,7 @@
     RearPortType,
 )
 from nautobot.extras.choices import CustomFieldTypeChoices, RelationshipSideChoices
-from nautobot.extras.graphql.types import DynamicGroupType, TagType
+from nautobot.extras.graphql.types import ContactAssociationType, DynamicGroupType, StaticGroupType, TagType
 from nautobot.extras.models import ComputedField, CustomField, Relationship
 from nautobot.extras.registry import registry
 from nautobot.extras.utils import check_if_key_is_graphql_safe
@@ -67,8 +67,10 @@
 registry["graphql_types"]["dcim.rack"] = RackType
 registry["graphql_types"]["dcim.rearport"] = RearPortType
 registry["graphql_types"]["dcim.location"] = LocationType
-registry["graphql_types"]["extras.tag"] = TagType
+registry["graphql_types"]["extras.contactassociation"] = ContactAssociationType
 registry["graphql_types"]["extras.dynamicgroup"] = DynamicGroupType
+registry["graphql_types"]["extras.staticgroup"] = StaticGroupType
+registry["graphql_types"]["extras.tag"] = TagType
 registry["graphql_types"]["ipam.ipaddress"] = IPAddressType
 registry["graphql_types"]["ipam.prefix"] = PrefixType
 registry["graphql_types"]["ipam.vlan"] = VLANType
@@ -126,6 +128,11 @@ def extend_schema_type(schema_type):
     #
     schema_type = extend_schema_type_config_context(schema_type, model)
 
+    #
+    # Global features (contacts, teams, static groups)
+    #
+    schema_type = extend_schema_type_global_features(schema_type, model)
+
     #
     # Relationships
     #
@@ -363,6 +370,29 @@ def resolve_config_context(self, args):
     return schema_type
 
 
+def extend_schema_type_global_features(schema_type, model):
+    """
+    Extend schema_type object to have attributes and resolvers for global features (contacts, static groups, etc.).
+    """
+    if getattr(model, "is_contact_associable_model", False):
+
+        def resolve_associated_contacts(self, args):
+            return self.associated_contacts.all()
+
+        setattr(schema_type, "resolve_associated_contacts", resolve_associated_contacts)
+        schema_type._meta.fields["associated_contacts"] = graphene.Field.mounted(graphene.List(ContactAssociationType))
+
+    if getattr(model, "is_static_group_associable_model", False):
+
+        def resolve_static_groups(self, args):
+            return self.static_groups
+
+        setattr(schema_type, "resolve_static_groups", resolve_static_groups)
+        schema_type._meta.fields["static_groups"] = graphene.Field.mounted(graphene.List(StaticGroupType))
+
+    return schema_type
+
+
 def extend_schema_type_relationships(schema_type, model):
     """Extend the schema type with attributes and resolvers corresponding
     to the relationships associated with this model."""
diff --git a/nautobot/extras/apps.py b/nautobot/extras/apps.py
--- a/nautobot/extras/apps.py
+++ b/nautobot/extras/apps.py
@@ -31,12 +31,11 @@ def ready(self):
         from graphene_django.converter import convert_django_field
 
         from nautobot.core.models.fields import TagsField
-        from nautobot.extras.graphql.types import TagType
 
         @convert_django_field.register(TagsField)
         def convert_field_to_list_tags(field, registry=None):
             """Convert TagsField to List of Tags."""
-            return graphene.List(TagType)
+            return graphene.List("nautobot.extras.graphql.types.TagType")
 
         from nautobot.extras.plugins.validators import wrap_model_clean_methods
 
diff --git a/nautobot/extras/graphql/types.py b/nautobot/extras/graphql/types.py
--- a/nautobot/extras/graphql/types.py
+++ b/nautobot/extras/graphql/types.py
@@ -1,14 +1,36 @@
 from nautobot.core.graphql.types import OptimizedNautobotObjectType
-from nautobot.extras.filters import DynamicGroupFilterSet, StatusFilterSet, TagFilterSet
-from nautobot.extras.models import DynamicGroup, Status, Tag
+from nautobot.extras.filters import (
+    ContactAssociationFilterSet,
+    DynamicGroupFilterSet,
+    StaticGroupFilterSet,
+    StatusFilterSet,
+    TagFilterSet,
+)
+from nautobot.extras.models import ContactAssociation, DynamicGroup, StaticGroup, Status, Tag
 
 
-class TagType(OptimizedNautobotObjectType):
-    """Graphql Type Object for Tag model."""
+class ContactAssociationType(OptimizedNautobotObjectType):
+    """GraphQL Type object for `ContactAssociation` model."""
 
     class Meta:
-        model = Tag
-        filterset_class = TagFilterSet
+        model = ContactAssociation
+        filterset_class = ContactAssociationFilterSet
+
+
+class DynamicGroupType(OptimizedNautobotObjectType):
+    """Graphql Type object for `DynamicGroup` model."""
+
+    class Meta:
+        model = DynamicGroup
+        filterset_class = DynamicGroupFilterSet
+
+
+class StaticGroupType(OptimizedNautobotObjectType):
+    """GraphQL Type object for `StaticGroup` model."""
+
+    class Meta:
+        model = StaticGroup
+        filterset_class = StaticGroupFilterSet
 
 
 class StatusType(OptimizedNautobotObjectType):
@@ -19,9 +41,9 @@ class Meta:
         filterset_class = StatusFilterSet
 
 
-class DynamicGroupType(OptimizedNautobotObjectType):
-    """Graphql Type object for `DynamicGroup` model."""
+class TagType(OptimizedNautobotObjectType):
+    """Graphql Type Object for Tag model."""
 
     class Meta:
-        model = DynamicGroup
-        filterset_class = DynamicGroupFilterSet
+        model = Tag
+        filterset_class = TagFilterSet


Test patch: