Repository: netbox-community/netbox

Issue:
CustomField of the type DateTime
### NetBox version

v3.2.9

### Feature type

New functionality

### Proposed functionality

Create a new Custom Field of the type 'DateTime'

### Use case

I need to store date and time values in a custom field. Currently this is only possible in a text field, but then the sorting is off.
A date-time fieldtype could be very useful,


### Database changes

_No response_

### External dependencies

_No response_


Discussion:
Closing this as it might involve working with timezones again. /s

This is probably doable if we normalize to an ISO timestamp. I assume we'd store values using the system's timezone and adjust on display to the user.
since NetBox is in only one timezone (and not per user), it would be good to store the UTC timestamp and convert it to the timezone of NetBox during the display.

This issue has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs. NetBox is governed by a small group of core maintainers which means not all opened issues may receive direct feedback. **Do not** attempt to circumvent this process by "bumping" the issue; doing so will result in its immediate closure and you may be barred from participating in any future discussions. Please see our [contributing guide](https://github.com/netbox-community/netbox/blob/develop/CONTRIBUTING.md).

Resolving patch:
diff --git a/netbox/extras/choices.py b/netbox/extras/choices.py
--- a/netbox/extras/choices.py
+++ b/netbox/extras/choices.py
@@ -13,6 +13,7 @@ class CustomFieldTypeChoices(ChoiceSet):
     TYPE_DECIMAL = 'decimal'
     TYPE_BOOLEAN = 'boolean'
     TYPE_DATE = 'date'
+    TYPE_DATETIME = 'datetime'
     TYPE_URL = 'url'
     TYPE_JSON = 'json'
     TYPE_SELECT = 'select'
@@ -27,6 +28,7 @@ class CustomFieldTypeChoices(ChoiceSet):
         (TYPE_DECIMAL, 'Decimal'),
         (TYPE_BOOLEAN, 'Boolean (true/false)'),
         (TYPE_DATE, 'Date'),
+        (TYPE_DATETIME, 'Date & time'),
         (TYPE_URL, 'URL'),
         (TYPE_JSON, 'JSON'),
         (TYPE_SELECT, 'Selection'),
diff --git a/netbox/extras/models/customfields.py b/netbox/extras/models/customfields.py
--- a/netbox/extras/models/customfields.py
+++ b/netbox/extras/models/customfields.py
@@ -25,7 +25,7 @@
     DynamicModelMultipleChoiceField, JSONField, LaxURLField,
 )
 from utilities.forms.utils import add_blank_choice
-from utilities.forms.widgets import DatePicker
+from utilities.forms.widgets import DatePicker, DateTimePicker
 from utilities.querysets import RestrictedQuerySet
 from utilities.validators import validate_regex
 
@@ -306,8 +306,9 @@ def serialize(self, value):
         """
         if value is None:
             return value
-        if self.type == CustomFieldTypeChoices.TYPE_DATE and type(value) is date:
-            return value.isoformat()
+        if self.type in (CustomFieldTypeChoices.TYPE_DATE, CustomFieldTypeChoices.TYPE_DATETIME):
+            if type(value) in (date, datetime):
+                return value.isoformat()
         if self.type == CustomFieldTypeChoices.TYPE_OBJECT:
             return value.pk
         if self.type == CustomFieldTypeChoices.TYPE_MULTIOBJECT:
@@ -325,6 +326,11 @@ def deserialize(self, value):
                 return date.fromisoformat(value)
             except ValueError:
                 return value
+        if self.type == CustomFieldTypeChoices.TYPE_DATETIME:
+            try:
+                return datetime.fromisoformat(value)
+            except ValueError:
+                return value
         if self.type == CustomFieldTypeChoices.TYPE_OBJECT:
             model = self.object_type.model_class()
             return model.objects.filter(pk=value).first()
@@ -380,6 +386,10 @@ def to_form_field(self, set_initial=True, enforce_required=True, enforce_visibil
         elif self.type == CustomFieldTypeChoices.TYPE_DATE:
             field = forms.DateField(required=required, initial=initial, widget=DatePicker())
 
+        # Date & time
+        elif self.type == CustomFieldTypeChoices.TYPE_DATETIME:
+            field = forms.DateTimeField(required=required, initial=initial, widget=DateTimePicker())
+
         # Select
         elif self.type in (CustomFieldTypeChoices.TYPE_SELECT, CustomFieldTypeChoices.TYPE_MULTISELECT):
             choices = [(c, c) for c in self.choices]
@@ -490,6 +500,10 @@ def to_filter(self, lookup_expr=None):
         elif self.type == CustomFieldTypeChoices.TYPE_DATE:
             filter_class = filters.MultiValueDateFilter
 
+        # Date & time
+        elif self.type == CustomFieldTypeChoices.TYPE_DATETIME:
+            filter_class = filters.MultiValueDateTimeFilter
+
         # Select
         elif self.type == CustomFieldTypeChoices.TYPE_SELECT:
             filter_class = filters.MultiValueCharFilter
@@ -558,9 +572,17 @@ def validate(self, value):
             elif self.type == CustomFieldTypeChoices.TYPE_DATE:
                 if type(value) is not date:
                     try:
-                        datetime.strptime(value, '%Y-%m-%d')
+                        date.fromisoformat(value)
+                    except ValueError:
+                        raise ValidationError("Date values must be in ISO 8601 format (YYYY-MM-DD).")
+
+            # Validate date & time
+            elif self.type == CustomFieldTypeChoices.TYPE_DATETIME:
+                if type(value) is not datetime:
+                    try:
+                        datetime.fromisoformat(value)
                     except ValueError:
-                        raise ValidationError("Date values must be in the format YYYY-MM-DD.")
+                        raise ValidationError("Date and time values must be in ISO 8601 format (YYYY-MM-DD HH:MM:SS).")
 
             # Validate selected choice
             elif self.type == CustomFieldTypeChoices.TYPE_SELECT:


Test patch:
diff --git a/netbox/extras/tests/test_customfields.py b/netbox/extras/tests/test_customfields.py
--- a/netbox/extras/tests/test_customfields.py
+++ b/netbox/extras/tests/test_customfields.py
@@ -1,3 +1,4 @@
+import datetime
 from decimal import Decimal
 
 from django.contrib.contenttypes.models import ContentType
@@ -157,12 +158,12 @@ def test_boolean_field(self):
             self.assertIsNone(instance.custom_field_data.get(cf.name))
 
     def test_date_field(self):
-        value = '2016-06-23'
+        value = datetime.date(2016, 6, 23)
 
         # Create a custom field & check that initial value is null
         cf = CustomField.objects.create(
             name='date_field',
-            type=CustomFieldTypeChoices.TYPE_TEXT,
+            type=CustomFieldTypeChoices.TYPE_DATE,
             required=False
         )
         cf.content_types.set([self.object_type])
@@ -170,10 +171,35 @@ def test_date_field(self):
         self.assertIsNone(instance.custom_field_data[cf.name])
 
         # Assign a value and check that it is saved
-        instance.custom_field_data[cf.name] = value
+        instance.custom_field_data[cf.name] = cf.serialize(value)
         instance.save()
         instance.refresh_from_db()
-        self.assertEqual(instance.custom_field_data[cf.name], value)
+        self.assertEqual(instance.cf[cf.name], value)
+
+        # Delete the stored value and check that it is now null
+        instance.custom_field_data.pop(cf.name)
+        instance.save()
+        instance.refresh_from_db()
+        self.assertIsNone(instance.custom_field_data.get(cf.name))
+
+    def test_datetime_field(self):
+        value = datetime.datetime(2016, 6, 23, 9, 45, 0)
+
+        # Create a custom field & check that initial value is null
+        cf = CustomField.objects.create(
+            name='date_field',
+            type=CustomFieldTypeChoices.TYPE_DATETIME,
+            required=False
+        )
+        cf.content_types.set([self.object_type])
+        instance = Site.objects.first()
+        self.assertIsNone(instance.custom_field_data[cf.name])
+
+        # Assign a value and check that it is saved
+        instance.custom_field_data[cf.name] = cf.serialize(value)
+        instance.save()
+        instance.refresh_from_db()
+        self.assertEqual(instance.cf[cf.name], value)
 
         # Delete the stored value and check that it is now null
         instance.custom_field_data.pop(cf.name)
@@ -408,6 +434,7 @@ def setUpTestData(cls):
             CustomField(type=CustomFieldTypeChoices.TYPE_DECIMAL, name='decimal_field', default=123.45),
             CustomField(type=CustomFieldTypeChoices.TYPE_BOOLEAN, name='boolean_field', default=False),
             CustomField(type=CustomFieldTypeChoices.TYPE_DATE, name='date_field', default='2020-01-01'),
+            CustomField(type=CustomFieldTypeChoices.TYPE_DATETIME, name='datetime_field', default='2020-01-01T01:23:45'),
             CustomField(type=CustomFieldTypeChoices.TYPE_URL, name='url_field', default='http://example.com/1'),
             CustomField(type=CustomFieldTypeChoices.TYPE_JSON, name='json_field', default='{"x": "y"}'),
             CustomField(
@@ -459,12 +486,13 @@ def setUpTestData(cls):
             custom_fields[3].name: Decimal('456.78'),
             custom_fields[4].name: True,
             custom_fields[5].name: '2020-01-02',
-            custom_fields[6].name: 'http://example.com/2',
-            custom_fields[7].name: '{"foo": 1, "bar": 2}',
-            custom_fields[8].name: 'Bar',
-            custom_fields[9].name: ['Bar', 'Baz'],
-            custom_fields[10].name: vlans[1].pk,
-            custom_fields[11].name: [vlans[2].pk, vlans[3].pk],
+            custom_fields[6].name: '2020-01-02 12:00:00',
+            custom_fields[7].name: 'http://example.com/2',
+            custom_fields[8].name: '{"foo": 1, "bar": 2}',
+            custom_fields[9].name: 'Bar',
+            custom_fields[10].name: ['Bar', 'Baz'],
+            custom_fields[11].name: vlans[1].pk,
+            custom_fields[12].name: [vlans[2].pk, vlans[3].pk],
         }
         sites[1].save()
 
@@ -476,6 +504,7 @@ def test_get_custom_fields(self):
             CustomFieldTypeChoices.TYPE_DECIMAL: 'decimal',
             CustomFieldTypeChoices.TYPE_BOOLEAN: 'boolean',
             CustomFieldTypeChoices.TYPE_DATE: 'string',
+            CustomFieldTypeChoices.TYPE_DATETIME: 'string',
             CustomFieldTypeChoices.TYPE_URL: 'string',
             CustomFieldTypeChoices.TYPE_JSON: 'object',
             CustomFieldTypeChoices.TYPE_SELECT: 'string',
@@ -511,6 +540,7 @@ def test_get_single_object_without_custom_field_data(self):
             'decimal_field': None,
             'boolean_field': None,
             'date_field': None,
+            'datetime_field': None,
             'url_field': None,
             'json_field': None,
             'select_field': None,
@@ -536,6 +566,7 @@ def test_get_single_object_with_custom_field_data(self):
         self.assertEqual(response.data['custom_fields']['decimal_field'], site2_cfvs['decimal_field'])
         self.assertEqual(response.data['custom_fields']['boolean_field'], site2_cfvs['boolean_field'])
         self.assertEqual(response.data['custom_fields']['date_field'], site2_cfvs['date_field'])
+        self.assertEqual(response.data['custom_fields']['datetime_field'], site2_cfvs['datetime_field'])
         self.assertEqual(response.data['custom_fields']['url_field'], site2_cfvs['url_field'])
         self.assertEqual(response.data['custom_fields']['json_field'], site2_cfvs['json_field'])
         self.assertEqual(response.data['custom_fields']['select_field'], site2_cfvs['select_field'])
@@ -571,6 +602,7 @@ def test_create_single_object_with_defaults(self):
         self.assertEqual(response_cf['decimal_field'], cf_defaults['decimal_field'])
         self.assertEqual(response_cf['boolean_field'], cf_defaults['boolean_field'])
         self.assertEqual(response_cf['date_field'].isoformat(), cf_defaults['date_field'])
+        self.assertEqual(response_cf['datetime_field'].isoformat(), cf_defaults['datetime_field'])
         self.assertEqual(response_cf['url_field'], cf_defaults['url_field'])
         self.assertEqual(response_cf['json_field'], cf_defaults['json_field'])
         self.assertEqual(response_cf['select_field'], cf_defaults['select_field'])
@@ -588,7 +620,8 @@ def test_create_single_object_with_defaults(self):
         self.assertEqual(site.custom_field_data['integer_field'], cf_defaults['integer_field'])
         self.assertEqual(site.custom_field_data['decimal_field'], cf_defaults['decimal_field'])
         self.assertEqual(site.custom_field_data['boolean_field'], cf_defaults['boolean_field'])
-        self.assertEqual(str(site.custom_field_data['date_field']), cf_defaults['date_field'])
+        self.assertEqual(site.custom_field_data['date_field'], cf_defaults['date_field'])
+        self.assertEqual(site.custom_field_data['datetime_field'], cf_defaults['datetime_field'])
         self.assertEqual(site.custom_field_data['url_field'], cf_defaults['url_field'])
         self.assertEqual(site.custom_field_data['json_field'], cf_defaults['json_field'])
         self.assertEqual(site.custom_field_data['select_field'], cf_defaults['select_field'])
@@ -609,7 +642,8 @@ def test_create_single_object_with_values(self):
                 'integer_field': 456,
                 'decimal_field': 456.78,
                 'boolean_field': True,
-                'date_field': '2020-01-02',
+                'date_field': datetime.date(2020, 1, 2),
+                'datetime_field': datetime.datetime(2020, 1, 2, 12, 0, 0),
                 'url_field': 'http://example.com/2',
                 'json_field': '{"foo": 1, "bar": 2}',
                 'select_field': 'Bar',
@@ -632,7 +666,8 @@ def test_create_single_object_with_values(self):
         self.assertEqual(response_cf['integer_field'], data_cf['integer_field'])
         self.assertEqual(response_cf['decimal_field'], data_cf['decimal_field'])
         self.assertEqual(response_cf['boolean_field'], data_cf['boolean_field'])
-        self.assertEqual(response_cf['date_field'].isoformat(), data_cf['date_field'])
+        self.assertEqual(response_cf['date_field'], data_cf['date_field'])
+        self.assertEqual(response_cf['datetime_field'], data_cf['datetime_field'])
         self.assertEqual(response_cf['url_field'], data_cf['url_field'])
         self.assertEqual(response_cf['json_field'], data_cf['json_field'])
         self.assertEqual(response_cf['select_field'], data_cf['select_field'])
@@ -650,7 +685,8 @@ def test_create_single_object_with_values(self):
         self.assertEqual(site.custom_field_data['integer_field'], data_cf['integer_field'])
         self.assertEqual(site.custom_field_data['decimal_field'], data_cf['decimal_field'])
         self.assertEqual(site.custom_field_data['boolean_field'], data_cf['boolean_field'])
-        self.assertEqual(str(site.custom_field_data['date_field']), data_cf['date_field'])
+        self.assertEqual(site.cf['date_field'], data_cf['date_field'])
+        self.assertEqual(site.cf['datetime_field'], data_cf['datetime_field'])
         self.assertEqual(site.custom_field_data['url_field'], data_cf['url_field'])
         self.assertEqual(site.custom_field_data['json_field'], data_cf['json_field'])
         self.assertEqual(site.custom_field_data['select_field'], data_cf['select_field'])
@@ -697,6 +733,7 @@ def test_create_multiple_objects_with_defaults(self):
             self.assertEqual(response_cf['decimal_field'], cf_defaults['decimal_field'])
             self.assertEqual(response_cf['boolean_field'], cf_defaults['boolean_field'])
             self.assertEqual(response_cf['date_field'].isoformat(), cf_defaults['date_field'])
+            self.assertEqual(response_cf['datetime_field'].isoformat(), cf_defaults['datetime_field'])
             self.assertEqual(response_cf['url_field'], cf_defaults['url_field'])
             self.assertEqual(response_cf['json_field'], cf_defaults['json_field'])
             self.assertEqual(response_cf['select_field'], cf_defaults['select_field'])
@@ -714,7 +751,8 @@ def test_create_multiple_objects_with_defaults(self):
             self.assertEqual(site.custom_field_data['integer_field'], cf_defaults['integer_field'])
             self.assertEqual(site.custom_field_data['decimal_field'], cf_defaults['decimal_field'])
             self.assertEqual(site.custom_field_data['boolean_field'], cf_defaults['boolean_field'])
-            self.assertEqual(str(site.custom_field_data['date_field']), cf_defaults['date_field'])
+            self.assertEqual(site.custom_field_data['date_field'], cf_defaults['date_field'])
+            self.assertEqual(site.custom_field_data['datetime_field'], cf_defaults['datetime_field'])
             self.assertEqual(site.custom_field_data['url_field'], cf_defaults['url_field'])
             self.assertEqual(site.custom_field_data['json_field'], cf_defaults['json_field'])
             self.assertEqual(site.custom_field_data['select_field'], cf_defaults['select_field'])
@@ -732,7 +770,8 @@ def test_create_multiple_objects_with_values(self):
             'integer_field': 456,
             'decimal_field': 456.78,
             'boolean_field': True,
-            'date_field': '2020-01-02',
+            'date_field': datetime.date(2020, 1, 2),
+            'datetime_field': datetime.datetime(2020, 1, 2, 12, 0, 0),
             'url_field': 'http://example.com/2',
             'json_field': '{"foo": 1, "bar": 2}',
             'select_field': 'Bar',
@@ -773,7 +812,8 @@ def test_create_multiple_objects_with_values(self):
             self.assertEqual(response_cf['integer_field'], custom_field_data['integer_field'])
             self.assertEqual(response_cf['decimal_field'], custom_field_data['decimal_field'])
             self.assertEqual(response_cf['boolean_field'], custom_field_data['boolean_field'])
-            self.assertEqual(response_cf['date_field'].isoformat(), custom_field_data['date_field'])
+            self.assertEqual(response_cf['date_field'], custom_field_data['date_field'])
+            self.assertEqual(response_cf['datetime_field'], custom_field_data['datetime_field'])
             self.assertEqual(response_cf['url_field'], custom_field_data['url_field'])
             self.assertEqual(response_cf['json_field'], custom_field_data['json_field'])
             self.assertEqual(response_cf['select_field'], custom_field_data['select_field'])
@@ -791,7 +831,8 @@ def test_create_multiple_objects_with_values(self):
             self.assertEqual(site.custom_field_data['integer_field'], custom_field_data['integer_field'])
             self.assertEqual(site.custom_field_data['decimal_field'], custom_field_data['decimal_field'])
             self.assertEqual(site.custom_field_data['boolean_field'], custom_field_data['boolean_field'])
-            self.assertEqual(str(site.custom_field_data['date_field']), custom_field_data['date_field'])
+            self.assertEqual(site.cf['date_field'], custom_field_data['date_field'])
+            self.assertEqual(site.cf['datetime_field'], custom_field_data['datetime_field'])
             self.assertEqual(site.custom_field_data['url_field'], custom_field_data['url_field'])
             self.assertEqual(site.custom_field_data['json_field'], custom_field_data['json_field'])
             self.assertEqual(site.custom_field_data['select_field'], custom_field_data['select_field'])
@@ -826,6 +867,7 @@ def test_update_single_object_with_values(self):
         self.assertEqual(response_cf['decimal_field'], original_cfvs['decimal_field'])
         self.assertEqual(response_cf['boolean_field'], original_cfvs['boolean_field'])
         self.assertEqual(response_cf['date_field'], original_cfvs['date_field'])
+        self.assertEqual(response_cf['datetime_field'], original_cfvs['datetime_field'])
         self.assertEqual(response_cf['url_field'], original_cfvs['url_field'])
         self.assertEqual(response_cf['json_field'], original_cfvs['json_field'])
         self.assertEqual(response_cf['select_field'], original_cfvs['select_field'])
@@ -844,6 +886,7 @@ def test_update_single_object_with_values(self):
         self.assertEqual(site2.cf['decimal_field'], original_cfvs['decimal_field'])
         self.assertEqual(site2.cf['boolean_field'], original_cfvs['boolean_field'])
         self.assertEqual(site2.cf['date_field'], original_cfvs['date_field'])
+        self.assertEqual(site2.cf['datetime_field'], original_cfvs['datetime_field'])
         self.assertEqual(site2.cf['url_field'], original_cfvs['url_field'])
         self.assertEqual(site2.cf['json_field'], original_cfvs['json_field'])
         self.assertEqual(site2.cf['select_field'], original_cfvs['select_field'])
@@ -977,6 +1020,7 @@ def setUpTestData(cls):
             CustomField(name='decimal', type=CustomFieldTypeChoices.TYPE_DECIMAL),
             CustomField(name='boolean', type=CustomFieldTypeChoices.TYPE_BOOLEAN),
             CustomField(name='date', type=CustomFieldTypeChoices.TYPE_DATE),
+            CustomField(name='datetime', type=CustomFieldTypeChoices.TYPE_DATETIME),
             CustomField(name='url', type=CustomFieldTypeChoices.TYPE_URL),
             CustomField(name='json', type=CustomFieldTypeChoices.TYPE_JSON),
             CustomField(name='select', type=CustomFieldTypeChoices.TYPE_SELECT, choices=[
@@ -995,10 +1039,10 @@ def test_import(self):
         Import a Site in CSV format, including a value for each CustomField.
         """
         data = (
-            ('name', 'slug', 'status', 'cf_text', 'cf_longtext', 'cf_integer', 'cf_decimal', 'cf_boolean', 'cf_date', 'cf_url', 'cf_json', 'cf_select', 'cf_multiselect'),
-            ('Site 1', 'site-1', 'active', 'ABC', 'Foo', '123', '123.45', 'True', '2020-01-01', 'http://example.com/1', '{"foo": 123}', 'Choice A', '"Choice A,Choice B"'),
-            ('Site 2', 'site-2', 'active', 'DEF', 'Bar', '456', '456.78', 'False', '2020-01-02', 'http://example.com/2', '{"bar": 456}', 'Choice B', '"Choice B,Choice C"'),
-            ('Site 3', 'site-3', 'active', '', '', '', '', '', '', '', '', '', ''),
+            ('name', 'slug', 'status', 'cf_text', 'cf_longtext', 'cf_integer', 'cf_decimal', 'cf_boolean', 'cf_date', 'cf_datetime', 'cf_url', 'cf_json', 'cf_select', 'cf_multiselect'),
+            ('Site 1', 'site-1', 'active', 'ABC', 'Foo', '123', '123.45', 'True', '2020-01-01', '2020-01-01 12:00:00', 'http://example.com/1', '{"foo": 123}', 'Choice A', '"Choice A,Choice B"'),
+            ('Site 2', 'site-2', 'active', 'DEF', 'Bar', '456', '456.78', 'False', '2020-01-02', '2020-01-02 12:00:00', 'http://example.com/2', '{"bar": 456}', 'Choice B', '"Choice B,Choice C"'),
+            ('Site 3', 'site-3', 'active', '', '', '', '', '', '', '', '', '', '', ''),
         )
         csv_data = '\n'.join(','.join(row) for row in data)
 
@@ -1008,13 +1052,14 @@ def test_import(self):
 
         # Validate data for site 1
         site1 = Site.objects.get(name='Site 1')
-        self.assertEqual(len(site1.custom_field_data), 10)
+        self.assertEqual(len(site1.custom_field_data), 11)
         self.assertEqual(site1.custom_field_data['text'], 'ABC')
         self.assertEqual(site1.custom_field_data['longtext'], 'Foo')
         self.assertEqual(site1.custom_field_data['integer'], 123)
         self.assertEqual(site1.custom_field_data['decimal'], 123.45)
         self.assertEqual(site1.custom_field_data['boolean'], True)
-        self.assertEqual(site1.custom_field_data['date'], '2020-01-01')
+        self.assertEqual(site1.cf['date'].isoformat(), '2020-01-01')
+        self.assertEqual(site1.cf['datetime'].isoformat(), '2020-01-01T12:00:00+00:00')
         self.assertEqual(site1.custom_field_data['url'], 'http://example.com/1')
         self.assertEqual(site1.custom_field_data['json'], {"foo": 123})
         self.assertEqual(site1.custom_field_data['select'], 'Choice A')
@@ -1022,13 +1067,14 @@ def test_import(self):
 
         # Validate data for site 2
         site2 = Site.objects.get(name='Site 2')
-        self.assertEqual(len(site2.custom_field_data), 10)
+        self.assertEqual(len(site2.custom_field_data), 11)
         self.assertEqual(site2.custom_field_data['text'], 'DEF')
         self.assertEqual(site2.custom_field_data['longtext'], 'Bar')
         self.assertEqual(site2.custom_field_data['integer'], 456)
         self.assertEqual(site2.custom_field_data['decimal'], 456.78)
         self.assertEqual(site2.custom_field_data['boolean'], False)
-        self.assertEqual(site2.custom_field_data['date'], '2020-01-02')
+        self.assertEqual(site2.cf['date'].isoformat(), '2020-01-02')
+        self.assertEqual(site2.cf['datetime'].isoformat(), '2020-01-02T12:00:00+00:00')
         self.assertEqual(site2.custom_field_data['url'], 'http://example.com/2')
         self.assertEqual(site2.custom_field_data['json'], {"bar": 456})
         self.assertEqual(site2.custom_field_data['select'], 'Choice B')
diff --git a/netbox/extras/tests/test_forms.py b/netbox/extras/tests/test_forms.py
--- a/netbox/extras/tests/test_forms.py
+++ b/netbox/extras/tests/test_forms.py
@@ -32,6 +32,9 @@ def setUpTestData(cls):
         cf_date = CustomField.objects.create(name='date', type=CustomFieldTypeChoices.TYPE_DATE)
         cf_date.content_types.set([obj_type])
 
+        cf_datetime = CustomField.objects.create(name='datetime', type=CustomFieldTypeChoices.TYPE_DATETIME)
+        cf_datetime.content_types.set([obj_type])
+
         cf_url = CustomField.objects.create(name='url', type=CustomFieldTypeChoices.TYPE_URL)
         cf_url.content_types.set([obj_type])