Repository: netbox-community/netbox

Issue:
Batch editing 'date' type 'custom field' for multiple devices failed after upgrading to 3.4.x
### NetBox version

v3.4.2

### Python version

3.8

### Steps to Reproduce

1. Add a custom field with the name `testdate` to the `Circuit` model with the type `Date`
2. Add a circuit `testcircuit`
3. Mark `testcircuit` in the circuit list and press `Edit Selected`

### Expected Behavior

changes applied successfully

### Observed Behavior

following error message 

```
<class 'TypeError'>

fromisoformat: argument must be str

Python version: 3.8.10
NetBox version: 3.4.2
```


Discussion:
Relevant stack trace:

```
Traceback (most recent call last):
  File "/home/main/devel/repos/netbox/venv/lib/python3.10/site-packages/django/core/handlers/exception.py", line 55, in inner
    response = get_response(request)
  File "/home/main/devel/repos/netbox/venv/lib/python3.10/site-packages/django/core/handlers/base.py", line 197, in _get_response
    response = wrapped_callback(request, *callback_args, **callback_kwargs)
  File "/home/main/devel/repos/netbox/venv/lib/python3.10/site-packages/django/views/generic/base.py", line 103, in view
    return self.dispatch(request, *args, **kwargs)
  File "/home/main/devel/repos/netbox/netbox/netbox/views/generic/base.py", line 77, in dispatch
    return super().dispatch(request, *args, **kwargs)
  File "/home/main/devel/repos/netbox/netbox/utilities/views.py", line 99, in dispatch
    return super().dispatch(request, *args, **kwargs)
  File "/home/main/devel/repos/netbox/venv/lib/python3.10/site-packages/django/views/generic/base.py", line 142, in dispatch
    return handler(request, *args, **kwargs)
  File "/home/main/devel/repos/netbox/netbox/netbox/views/generic/bulk_views.py", line 595, in post
    updated_objects = self._update_objects(form, request)
  File "/home/main/devel/repos/netbox/netbox/netbox/views/generic/bulk_views.py", line 544, in _update_objects
    obj.save()
  File "/home/main/devel/repos/netbox/venv/lib/python3.10/site-packages/django/db/models/base.py", line 812, in save
    self.save_base(
  File "/home/main/devel/repos/netbox/venv/lib/python3.10/site-packages/django/db/models/base.py", line 878, in save_base
    post_save.send(
  File "/home/main/devel/repos/netbox/venv/lib/python3.10/site-packages/django/dispatch/dispatcher.py", line 176, in send
    return [
  File "/home/main/devel/repos/netbox/venv/lib/python3.10/site-packages/django/dispatch/dispatcher.py", line 177, in <listcomp>
    (receiver, receiver(signal=self, sender=sender, **named))
  File "/home/main/devel/repos/netbox/netbox/extras/signals.py", line 87, in handle_changed_object
    enqueue_object(queue, instance, request.user, request.id, action)
  File "/home/main/devel/repos/netbox/netbox/extras/webhooks.py", line 72, in enqueue_object
    'data': serialize_for_webhook(instance),
  File "/home/main/devel/repos/netbox/netbox/extras/webhooks.py", line 27, in serialize_for_webhook
    return serializer.data
  File "/home/main/devel/repos/netbox/venv/lib/python3.10/site-packages/rest_framework/serializers.py", line 555, in data
    ret = super().data
  File "/home/main/devel/repos/netbox/venv/lib/python3.10/site-packages/rest_framework/serializers.py", line 253, in data
    self._data = self.to_representation(self.instance)
  File "/home/main/devel/repos/netbox/venv/lib/python3.10/site-packages/rest_framework/serializers.py", line 522, in to_representation
    ret[field.field_name] = field.to_representation(attribute)
  File "/home/main/devel/repos/netbox/netbox/extras/api/customfields.py", line 55, in to_representation
    value = cf.deserialize(obj.get(cf.name))
  File "/home/main/devel/repos/netbox/netbox/extras/models/customfields.py", line 321, in deserialize
    return date.fromisoformat(value)
```

The issue occurs when the custom field is deserialized when sent to the webhooks handler (no webhooks need to be registered). For some reason when saving the object normally the cf arrives at `deserialize` as a string, but when bulk editing it arrives as a Date object. Not sure what the proper fix is here - @jeremystretch or @arthanson can one of you guys take a look at it?

Adding `TypeError` to the exceptions here fixes it:

https://github.com/netbox-community/netbox/blob/develop/netbox/extras/models/customfields.py#L322

But I'm sure that's not the correct solution.
I believe I've tracked this down to a caveat in how BulkEditView handles saving custom field data as compared to ObjectEditView: It's applying the native field value (in this case, a `datetime` object) rather than its serialized value. Fixing this appears to resolve the issue, but I also want to be sure I haven't inadvertently broken anything that relies on this nonstandard behavior.

Resolving patch:
diff --git a/netbox/netbox/views/generic/bulk_views.py b/netbox/netbox/views/generic/bulk_views.py
--- a/netbox/netbox/views/generic/bulk_views.py
+++ b/netbox/netbox/views/generic/bulk_views.py
@@ -494,7 +494,7 @@ def get_required_permission(self):
         return get_permission_for_model(self.queryset.model, 'change')
 
     def _update_objects(self, form, request):
-        custom_fields = getattr(form, 'custom_fields', [])
+        custom_fields = getattr(form, 'custom_fields', {})
         standard_fields = [
             field for field in form.fields if field not in list(custom_fields) + ['pk']
         ]
@@ -532,13 +532,13 @@ def _update_objects(self, form, request):
                     setattr(obj, name, form.cleaned_data[name])
 
             # Update custom fields
-            for name in custom_fields:
+            for name, customfield in custom_fields.items():
                 assert name.startswith('cf_')
                 cf_name = name[3:]  # Strip cf_ prefix
                 if name in form.nullable_fields and name in nullified_fields:
                     obj.custom_field_data[cf_name] = None
                 elif name in form.changed_data:
-                    obj.custom_field_data[cf_name] = form.fields[name].prepare_value(form.cleaned_data[name])
+                    obj.custom_field_data[cf_name] = customfield.serialize(form.cleaned_data[name])
 
             obj.full_clean()
             obj.save()


Test patch: