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/core/views/generic.py b/nautobot/core/views/generic.py
--- a/nautobot/core/views/generic.py
+++ b/nautobot/core/views/generic.py
@@ -417,6 +417,9 @@ def post(self, request, *args, **kwargs):
                     # Check that the new object conforms with any assigned object-level permissions
                     self.queryset.get(pk=obj.pk)
 
+                if hasattr(form, "save_note") and callable(form.save_note):
+                    form.save_note(instance=obj, user=request.user)
+
                 msg = "{} {}".format(
                     "Created" if object_created else "Modified",
                     self.queryset.model._meta.verbose_name,
@@ -966,7 +969,9 @@ def post(self, request, **kwargs):
                 form_custom_fields = getattr(form, "custom_fields", [])
                 form_relationships = getattr(form, "relationships", [])
                 standard_fields = [
-                    field for field in form.fields if field not in form_custom_fields + form_relationships + ["pk"]
+                    field
+                    for field in form.fields
+                    if field not in form_custom_fields + form_relationships + ["pk"] + ["object_note"]
                 ]
                 nullified_fields = request.POST.getlist("_nullify")
 
@@ -1029,6 +1034,9 @@ def post(self, request, **kwargs):
                                 # Add/remove relationship associations
                                 form.save_relationships(instance=obj, nullified_fields=nullified_fields)
 
+                            if hasattr(form, "save_note") and callable(form.save_note):
+                                form.save_note(instance=obj, user=request.user)
+
                         # Enforce object-level permissions
                         if self.queryset.filter(pk__in=[obj.pk for obj in updated_objects]).count() != len(
                             updated_objects
diff --git a/nautobot/extras/forms/base.py b/nautobot/extras/forms/base.py
--- a/nautobot/extras/forms/base.py
+++ b/nautobot/extras/forms/base.py
@@ -3,6 +3,8 @@
     CustomFieldModelBulkEditFormMixin,
     CustomFieldModelFilterFormMixin,
     CustomFieldModelFormMixin,
+    NoteModelBulkEditFormMixin,
+    NoteModelFormMixin,
     RelationshipModelBulkEditFormMixin,
     RelationshipModelFilterFormMixin,
     RelationshipModelFormMixin,
@@ -21,11 +23,11 @@
 #
 
 
-class NautobotModelForm(BootstrapMixin, CustomFieldModelFormMixin, RelationshipModelFormMixin):
+class NautobotModelForm(BootstrapMixin, CustomFieldModelFormMixin, RelationshipModelFormMixin, NoteModelFormMixin):
     """
     This class exists to combine common functionality and is used to inherit from throughout the
-    codebase where all three of BootstrapMixin, CustomFieldModelFormMixin and RelationshipModelFormMixin are
-    needed.
+    codebase where all of BootstrapMixin, CustomFieldModelFormMixin, RelationshipModelFormMixin, and
+    NoteModelFormMixin are needed.
     """
 
 
@@ -37,5 +39,7 @@ class NautobotFilterForm(BootstrapMixin, CustomFieldModelFilterFormMixin, Relati
     """
 
 
-class NautobotBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditFormMixin, RelationshipModelBulkEditFormMixin):
-    """Base class for bulk-edit forms for models that support relationships and custom fields."""
+class NautobotBulkEditForm(
+    BootstrapMixin, CustomFieldModelBulkEditFormMixin, RelationshipModelBulkEditFormMixin, NoteModelBulkEditFormMixin
+):
+    """Base class for bulk-edit forms for models that support relationships, custom fields and notes."""
diff --git a/nautobot/extras/forms/mixins.py b/nautobot/extras/forms/mixins.py
--- a/nautobot/extras/forms/mixins.py
+++ b/nautobot/extras/forms/mixins.py
@@ -12,6 +12,7 @@
 )
 from nautobot.extras.models import (
     CustomField,
+    Note,
     Relationship,
     RelationshipAssociation,
     Status,
@@ -20,6 +21,7 @@
 from nautobot.utilities.deprecation import class_deprecated_in_favor_of
 from nautobot.utilities.forms import (
     BulkEditForm,
+    CommentField,
     CSVModelChoiceField,
     CSVModelForm,
     DynamicModelChoiceField,
@@ -33,6 +35,8 @@
     "CustomFieldModelBulkEditFormMixin",
     "CustomFieldModelFilterFormMixin",
     "CustomFieldModelFormMixin",
+    "NoteModelBulkEditFormMixin",
+    "NoteModelFormMixin",
     "RelationshipModelBulkEditFormMixin",
     "RelationshipModelFilterFormMixin",
     "RelationshipModelFormMixin",
@@ -137,6 +141,38 @@ def __init__(self, *args, **kwargs):
             self.custom_fields.append(field_name)
 
 
+class NoteFormBase(forms.Form):
+    """Base fore the NoteModelFormMixin and NoteModelBulkEditFormMixin."""
+
+    object_note = CommentField(label="Note")
+
+    def save_note(self, *, instance, user):
+        value = self.cleaned_data.get("object_note", "").strip()
+        if value:
+            note = Note.objects.create(
+                note=value,
+                assigned_object_type=self.obj_type,
+                assigned_object_id=instance.pk,
+                user=user,
+            )
+            logger.debug("Created %s", note)
+
+
+class NoteModelBulkEditFormMixin(BulkEditForm, NoteFormBase):
+    """Bulk-edit form mixin for models that support Notes."""
+
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+        self.obj_type = ContentType.objects.get_for_model(self.model)
+
+
+class NoteModelFormMixin(forms.ModelForm, NoteFormBase):
+    def __init__(self, *args, **kwargs):
+        self.obj_type = ContentType.objects.get_for_model(self._meta.model)
+
+        super().__init__(*args, **kwargs)
+
+
 class RelationshipModelBulkEditFormMixin(BulkEditForm):
     """Bulk-edit form mixin for models that support Relationships."""
 


Test patch:
diff --git a/nautobot/extras/tests/test_forms.py b/nautobot/extras/tests/test_forms.py
--- a/nautobot/extras/tests/test_forms.py
+++ b/nautobot/extras/tests/test_forms.py
@@ -2,10 +2,11 @@
 import warnings
 
 from django.contrib.contenttypes.models import ContentType
+from django.contrib.auth import get_user_model
 from django.db.models import Q
 from django.test import TestCase
 
-from nautobot.dcim.forms import DeviceForm, SiteBulkEditForm
+from nautobot.dcim.forms import DeviceForm, SiteBulkEditForm, SiteForm
 import nautobot.dcim.models as dcim_models
 from nautobot.extras.choices import RelationshipTypeChoices
 from nautobot.extras.forms import (
@@ -20,11 +21,15 @@
     TagsBulkEditFormMixin,
     WebhookForm,
 )
-from nautobot.extras.models import Job, JobHook, Relationship, RelationshipAssociation, Status, Webhook
+from nautobot.extras.models import Job, JobHook, Note, Relationship, RelationshipAssociation, Status, Webhook
 from nautobot.ipam.forms import IPAddressForm, IPAddressBulkEditForm, VLANGroupForm
 import nautobot.ipam.models as ipam_models
 
 
+# Use the proper swappable User model
+User = get_user_model()
+
+
 class JobHookFormTestCase(TestCase):
     @classmethod
     def setUpTestData(cls):
@@ -144,6 +149,71 @@ def test_create_job_hooks_with_same_job_common_action_same_content_type(self):
         )
 
 
+class NoteModelFormTestCase(TestCase):
+    """
+    TestNoteModelForm validation and saving.
+    """
+
+    @classmethod
+    def setUpTestData(cls):
+        active = Status.objects.get(slug="active")
+        cls.user = User.objects.create(username="formuser1")
+
+        cls.site_form_base_data = {
+            "name": "Site 1",
+            "slug": "site-1",
+            "status": active.pk,
+        }
+
+    def test_note_object_edit_form(self):
+
+        form = SiteForm(data=dict(**self.site_form_base_data, **{"object_note": "This is a test."}))
+        self.assertTrue(form.is_valid())
+        obj = form.save()
+        form.save_note(
+            instance=obj,
+            user=self.user,
+        )
+        note = Note.objects.first()
+        self.assertEqual(1, Note.objects.count())
+        self.assertEqual("This is a test.", note.note)
+        self.assertEqual(obj, note.assigned_object)
+        self.assertEqual(self.user, note.user)
+
+
+class NoteModelBulkEditFormMixinTestCase(TestCase):
+    """
+    TestNoteModelForm validation and saving.
+    """
+
+    @classmethod
+    def setUpTestData(cls):
+        active = Status.objects.get(slug="active")
+        cls.sites = [
+            dcim_models.Site.objects.create(name="Site 1", slug="site-1", status=active),
+            dcim_models.Site.objects.create(name="Site 2", slug="site-2", status=active),
+        ]
+        cls.user = User.objects.create(username="formuser1")
+
+    def test_note_bulk_edit(self):
+        form = SiteBulkEditForm(
+            model=dcim_models.Site, data={"pks": [site.pk for site in self.sites], "object_note": "Test"}
+        )
+        form.is_valid()
+        form.save_note(
+            instance=self.sites[0],
+            user=self.user,
+        )
+        form.save_note(
+            instance=self.sites[1],
+            user=self.user,
+        )
+        notes = Note.objects.all()
+        self.assertEqual(2, Note.objects.count())
+        self.assertEqual("Test", notes[0].note)
+        self.assertEqual("Test", notes[1].note)
+
+
 class RelationshipModelFormTestCase(TestCase):
     """
     Test RelationshipModelForm validation and saving.