Repository: nautobot/nautobot

Issue:
Updating a tag via API change the associated content_types
<!--
    NOTE: IF YOUR ISSUE DOES NOT FOLLOW THIS TEMPLATE, IT WILL BE CLOSED.

    This form is only for reporting reproducible bugs. If you need assistance
    with Nautobot installation, or if you have a general question, please start a
    discussion instead: https://github.com/nautobot/nautobot/discussions

    Please describe the environment in which you are running Nautobot. Be sure
    that you are running an unmodified instance of the latest stable release
    before submitting a bug report, and that any plugins have been disabled.
-->
### Environment
* Python version: 3.6.8
* Nautobot version: 1.3.4

<!--
    Describe in detail the exact steps that someone else can take to reproduce
    this bug using the current stable release of Nautobot. Begin with the
    creation of any necessary database objects and call out every operation
    being performed explicitly. If reporting a bug in the REST API, be sure to
    reconstruct the raw HTTP request(s) being made: Don't rely on a client
    library such as pynautobot.
-->
Updating a TAG via the api without specifying the content_types list set the content_types to all types.
### Steps to Reproduce
1. Create a tag with only one content type
2. Edit tag color via API
```python
nautobot = pynautobot.api(url, token, api_version="1.3")
nautobot.extras.tags.create(name="tag", content_types=["dcim.interface"], slug="tag").update({"color": "000000"})
pprint.pprint(dict(nautobot.extras.tags.get(name="tag")))
```

<!-- What did you expect to happen? -->
### Expected Behavior
Only the updated values should change: content type list should not changed.
```
{'color': '000000',
 'content_types': ['dcim.interface'],
 'created': '2022-07-22',
 'custom_fields': {},
 'description': '',
 'display': 'tag',
 'id': '3d85f9e8-1001-45c8-8b89-c9e7958e201f',
 'last_updated': '2022-07-22T14:47:05.513291Z',
 'name': 'tag',
 'slug': 'tag',
 'tagged_items': 0,
 'url': '...'}
```

<!-- What happened instead? -->
### Observed Behavior
All the content type are selected.
```python
{'color': '000000',
 'content_types': ['dcim.device',
                   'dcim.site',
                   'dcim.rack',
                   'dcim.cable',
                   'dcim.powerfeed',
                   'circuits.circuit',
                   'ipam.prefix',
                   'ipam.ipaddress',
                   'ipam.vlan',
                   'virtualization.virtualmachine',
                   'extras.job',
                   'circuits.circuittermination',
                   'circuits.provider',
                   'circuits.providernetwork',
                   'dcim.consoleport',
                   'dcim.consoleserverport',
                   'dcim.devicebay',
                   'dcim.devicetype',
                   'dcim.frontport',
                   'dcim.interface',
                   'dcim.inventoryitem',
                   'dcim.poweroutlet',
                   'dcim.powerpanel',
                   'dcim.powerport',
                   'dcim.rackreservation',
                   'dcim.rearport',
                   'dcim.virtualchassis',
                   'ipam.aggregate',
                   'ipam.routetarget',
                   'ipam.vrf',
                   'ipam.service',
                   'extras.gitrepository',
                   'extras.secret',
                   'tenancy.tenant',
                   'virtualization.cluster',
                   'virtualization.vminterface',
                   'nautobot_golden_config.compliancefeature',
                   'nautobot_golden_config.compliancerule',
                   'nautobot_golden_config.goldenconfigsetting',
                   'nautobot_golden_config.goldenconfig',
                   'nautobot_golden_config.configreplace',
                   'nautobot_golden_config.configremove',
                   'nautobot_golden_config.configcompliance'],
 'created': '2022-07-22',
 'custom_fields': {},
 'description': '',
 'display': 'tag',
 'id': '3d85f9e8-1001-45c8-8b89-c9e7958e201f',
 'last_updated': '2022-07-22T14:47:05.513291Z',
 'name': 'tag',
 'slug': 'tag',
 'tagged_items': 0,
 'url': '...'}


Discussion:

Resolving patch:
diff --git a/nautobot/extras/api/serializers.py b/nautobot/extras/api/serializers.py
--- a/nautobot/extras/api/serializers.py
+++ b/nautobot/extras/api/serializers.py
@@ -1234,13 +1234,13 @@ class Meta:
     def validate(self, data):
         data = super().validate(data)
 
-        # All relevant content_types should be assigned to tag for API Version <1.3
-        if not data.get("content_types"):
+        # All relevant content_types should be assigned to newly created tag for API Version <1.3
+        if (self.instance is None or not self.instance.present_in_database) and "content_types" not in data:
             data["content_types"] = TaggableClassesQuery().as_queryset
 
         # check if tag is assigned to any of the removed content_types
-        if self.instance is not None and self.instance.present_in_database:
-            content_types_id = [content_type.id for content_type in data.get("content_types")]
+        if self.instance is not None and self.instance.present_in_database and "content_types" in data:
+            content_types_id = [content_type.id for content_type in data["content_types"]]
             errors = self.instance.validate_content_types_removal(content_types_id)
 
             if errors:


Test patch:
diff --git a/nautobot/extras/tests/test_api.py b/nautobot/extras/tests/test_api.py
--- a/nautobot/extras/tests/test_api.py
+++ b/nautobot/extras/tests/test_api.py
@@ -59,6 +59,7 @@
 from nautobot.extras.utils import TaggableClassesQuery
 from nautobot.ipam.models import VLANGroup
 from nautobot.users.models import ObjectPermission
+from nautobot.utilities.choices import ColorChoices
 from nautobot.utilities.testing import APITestCase, APIViewTestCases
 from nautobot.utilities.testing.utils import disable_warnings
 from nautobot.utilities.utils import slugify_dashes_to_underscores
@@ -3224,6 +3225,26 @@ def test_update_tags_remove_content_type(self):
             str(response.data["content_types"][0]), "Unable to remove dcim.site. Dependent objects were found."
         )
 
+    def test_update_tag_content_type_unchanged(self):
+        """Test updating a tag without changing its content-types."""
+        self.add_permissions("extras.change_tag")
+
+        tag = Tag.objects.get(slug="tag-1")
+        url = self._get_detail_url(tag)
+        data = {"color": ColorChoices.COLOR_LIME}
+
+        response = self.client.patch(url, data, format="json", **self.header)
+        self.assertHttpStatus(response, status.HTTP_200_OK)
+        self.assertEqual(response.data["color"], ColorChoices.COLOR_LIME)
+        self.assertEqual(sorted(response.data["content_types"]), ["dcim.device", "dcim.site"])
+
+        tag.refresh_from_db()
+        self.assertEqual(tag.color, ColorChoices.COLOR_LIME)
+        tag_content_types = tag.content_types.all()
+        self.assertIn(ContentType.objects.get_for_model(Device), tag_content_types)
+        self.assertIn(ContentType.objects.get_for_model(Site), tag_content_types)
+        self.assertNotIn(ContentType.objects.get_for_model(Rack), tag_content_types)
+
 
 class WebhookTest(APIViewTestCases.APIViewTestCase):
     model = Webhook