Repository: nautobot/nautobot

Issue:
time_zone attribute of the Site object emits Null if not set.
<!--
    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.
-->
GET vs PUT/PATCH/POST endpoint for `/api/dcim/site/` does not represent the `time_zone` attribute in the same fashion. GET will represent an unset time_zone as `null` but attempting to create/edit that field via the API it does not allow for the field to be `null` only `""` or not part fo the payload.

### Environment
* Python version: 3.7
* Nautobot version: develop

<!--
    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.
-->
### Steps to Reproduce
1. GET to `/api/dcim/sites/<UUID>/` 
2. Return payload `{"name": "Foo", "slug": "foo", "time_zone": null}` (omitted for brevity)

<!-- What did you expect to happen? -->
### Expected Behavior
1. GET to `/api/dcim/sites/<UUID>/` 
2. Return payload `{"name": "Foo", "slug": "foo", "time_zone": ""}` (omitted for brevity)


<!-- What happened instead? -->
### Observed Behavior
The field within the database does not allow null but allows blank, this can lead to confusion in the API if attempting to POST/PUT/PATCH the same payload back to Nautobot the API will not allow `{"time_zone": null}`. 
time_zone attribute of the Site object emits Null if not set.
<!--
    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.
-->
GET vs PUT/PATCH/POST endpoint for `/api/dcim/site/` does not represent the `time_zone` attribute in the same fashion. GET will represent an unset time_zone as `null` but attempting to create/edit that field via the API it does not allow for the field to be `null` only `""` or not part fo the payload.

### Environment
* Python version: 3.7
* Nautobot version: develop

<!--
    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.
-->
### Steps to Reproduce
1. GET to `/api/dcim/sites/<UUID>/` 
2. Return payload `{"name": "Foo", "slug": "foo", "time_zone": null}` (omitted for brevity)

<!-- What did you expect to happen? -->
### Expected Behavior
1. GET to `/api/dcim/sites/<UUID>/` 
2. Return payload `{"name": "Foo", "slug": "foo", "time_zone": ""}` (omitted for brevity)


<!-- What happened instead? -->
### Observed Behavior
The field within the database does not allow null but allows blank, this can lead to confusion in the API if attempting to POST/PUT/PATCH the same payload back to Nautobot the API will not allow `{"time_zone": null}`. 


Discussion:
@jathanism please assign to me.
Thanks for reporting this @whitej6. Confirmed that `Site.time_zone` is a non-nullable char field. The API should emit an unset `time_zone` as an empty string, not `null`.
I am a comment. Shh
@jathanism please assign to me.
Thanks for reporting this @whitej6. Confirmed that `Site.time_zone` is a non-nullable char field. The API should emit an unset `time_zone` as an empty string, not `null`.
I am a comment. Shh

Resolving patch:
diff --git a/nautobot/core/api/__init__.py b/nautobot/core/api/__init__.py
--- a/nautobot/core/api/__init__.py
+++ b/nautobot/core/api/__init__.py
@@ -2,7 +2,6 @@
     ChoiceField,
     ContentTypeField,
     SerializedPKRelatedField,
-    TimeZoneField,
 )
 from .routers import OrderedDefaultRouter
 from .serializers import (
@@ -20,7 +19,6 @@
     "ContentTypeField",
     "OrderedDefaultRouter",
     "SerializedPKRelatedField",
-    "TimeZoneField",
     "ValidatedModelSerializer",
     "WritableNestedSerializer",
 )
diff --git a/nautobot/core/api/fields.py b/nautobot/core/api/fields.py
--- a/nautobot/core/api/fields.py
+++ b/nautobot/core/api/fields.py
@@ -102,22 +102,6 @@ def to_representation(self, obj):
         return f"{obj.app_label}.{obj.model}"
 
 
-class TimeZoneField(serializers.Field):
-    """
-    Represent a pytz time zone.
-    """
-
-    def to_representation(self, obj):
-        return obj.zone if obj else None
-
-    def to_internal_value(self, data):
-        if not data:
-            return ""
-        if data not in pytz.common_timezones:
-            raise ValidationError('Unknown time zone "{}" (see pytz.common_timezones for all options)'.format(data))
-        return pytz.timezone(data)
-
-
 class SerializedPKRelatedField(PrimaryKeyRelatedField):
     """
     Extends PrimaryKeyRelatedField to return a serialized object on read. This is useful for representing related
diff --git a/nautobot/dcim/api/serializers.py b/nautobot/dcim/api/serializers.py
--- a/nautobot/dcim/api/serializers.py
+++ b/nautobot/dcim/api/serializers.py
@@ -3,12 +3,12 @@
 from drf_yasg.utils import swagger_serializer_method
 from rest_framework import serializers
 from rest_framework.validators import UniqueTogetherValidator
+from timezone_field.rest_framework import TimeZoneSerializerField
 
 from nautobot.core.api import (
     ChoiceField,
     ContentTypeField,
     SerializedPKRelatedField,
-    TimeZoneField,
     ValidatedModelSerializer,
     WritableNestedSerializer,
 )
@@ -147,7 +147,7 @@ class SiteSerializer(TaggedObjectSerializer, StatusModelSerializerMixin, CustomF
     url = serializers.HyperlinkedIdentityField(view_name="dcim-api:site-detail")
     region = NestedRegionSerializer(required=False, allow_null=True)
     tenant = NestedTenantSerializer(required=False, allow_null=True)
-    time_zone = TimeZoneField(required=False)
+    time_zone = TimeZoneSerializerField(required=False, allow_null=True)
     circuit_count = serializers.IntegerField(read_only=True)
     device_count = serializers.IntegerField(read_only=True)
     prefix_count = serializers.IntegerField(read_only=True)


Test patch:
diff --git a/nautobot/dcim/tests/test_api.py b/nautobot/dcim/tests/test_api.py
--- a/nautobot/dcim/tests/test_api.py
+++ b/nautobot/dcim/tests/test_api.py
@@ -168,6 +168,85 @@ def setUpTestData(cls):
             },
         ]
 
+    def test_time_zone_field_post_null(self):
+        """
+        Test allow_null to time_zone field on site.
+
+        See: https://github.com/nautobot/nautobot/issues/342
+        """
+        self.add_permissions("dcim.add_site")
+        url = reverse("dcim-api:site-list")
+        site = {"name": "foo", "slug": "foo", "status": "active", "time_zone": None}
+
+        # Attempt to create new site with null time_zone attr.
+        response = self.client.post(url, **self.header, data=site, format="json")
+        self.assertEqual(response.status_code, status.HTTP_201_CREATED)
+        self.assertEqual(response.json()["time_zone"], None)
+
+    def test_time_zone_field_post_blank(self):
+        """
+        Test disallowed blank time_zone field on site.
+
+        See: https://github.com/nautobot/nautobot/issues/342
+        """
+        self.add_permissions("dcim.add_site")
+        url = reverse("dcim-api:site-list")
+        site = {"name": "foo", "slug": "foo", "status": "active", "time_zone": ""}
+
+        # Attempt to create new site with blank time_zone attr.
+        response = self.client.post(url, **self.header, data=site, format="json")
+        self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
+        self.assertEqual(response.json()["time_zone"], ["A valid timezone is required."])
+
+    def test_time_zone_field_post_valid(self):
+        """
+        Test valid time_zone field on site.
+
+        See: https://github.com/nautobot/nautobot/issues/342
+        """
+        self.add_permissions("dcim.add_site")
+        url = reverse("dcim-api:site-list")
+        time_zone = "UTC"
+        site = {"name": "foo", "slug": "foo", "status": "active", "time_zone": time_zone}
+
+        # Attempt to create new site with valid time_zone attr.
+        response = self.client.post(url, **self.header, data=site, format="json")
+        self.assertEqual(response.status_code, status.HTTP_201_CREATED)
+        self.assertEqual(response.json()["time_zone"], time_zone)
+
+    def test_time_zone_field_post_invalid(self):
+        """
+        Test invalid time_zone field on site.
+
+        See: https://github.com/nautobot/nautobot/issues/342
+        """
+        self.add_permissions("dcim.add_site")
+        url = reverse("dcim-api:site-list")
+        time_zone = "IDONOTEXIST"
+        site = {"name": "foo", "slug": "foo", "status": "active", "time_zone": time_zone}
+
+        # Attempt to create new site with invalid time_zone attr.
+        response = self.client.post(url, **self.header, data=site, format="json")
+        self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
+        self.assertEqual(
+            response.json()["time_zone"],
+            ["A valid timezone is required."],
+        )
+
+    def test_time_zone_field_get_blank(self):
+        """
+        Test that a site's time_zone field defaults to null.
+
+        See: https://github.com/nautobot/nautobot/issues/342
+        """
+
+        self.add_permissions("dcim.view_site")
+        site = Site.objects.get(slug="site-1")
+        url = reverse("dcim-api:site-detail", kwargs={"pk": site.pk})
+        response = self.client.get(url, **self.header)
+        self.assertEqual(response.status_code, status.HTTP_200_OK)
+        self.assertEqual(response.json()["time_zone"], None)
+
 
 class RackGroupTest(APIViewTestCases.APIViewTestCase):
     model = RackGroup