Repository: nautobot/nautobot

Issue:
Custom field date-type objects AssertionError in GraphQL
<!--
    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
* Nautobot version (Docker tag too if applicable): 1.5.17b1, also tested in 1.5.8
* Python version: 3.9
* Database platform, version: psql 13.10
* Middleware(s): None

<!--
    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. Create a [custom field object](https://demo.nautobot.com/extras/custom-fields/last_backup/?tab=main) for the Device type template with the type as Date
2. Set a date on a [device](https://demo.nautobot.com/dcim/devices/4d7aecac-addd-4f3c-8efc-e102872b54e5/?tab=main) in YYYY-MM-DD iso8601 format
3. You have to restart nautobot-server; we may need a post_save signal event for this as well, which would be a separate issue if so
4. Using GraphQL attempt to retrieve the value of this field from a device
`
{
  devices (name: "ams01-dist-01") {
    name,
    cf_last_backup
  }
}
`

<!-- What did you expect to happen? -->
### Expected Behavior
Query to return the value of the Date

<!-- What happened instead? -->
### Observed Behavior
* GrapQL returns `"message": "Received not compatible date \"'2023-04-26'\""`
* stdout from nautobot-server gets an exception

`[27/Apr/2023 17:20:01] "POST /graphql/ HTTP/1.1" 200 4437611
Traceback (most recent call last):
  File "/home/meganerd/.cache/pypoetry/virtualenvs/nautobot-Sgl-QJQc-py3.9/lib/python3.9/site-packages/graphql/execution/executor.py", line 480, in complete_value_catching_error
    completed = complete_value(
  File "/home/meganerd/.cache/pypoetry/virtualenvs/nautobot-Sgl-QJQc-py3.9/lib/python3.9/site-packages/graphql/execution/executor.py", line 563, in complete_value
    return complete_leaf_value(return_type, path, result)
  File "/home/meganerd/.cache/pypoetry/virtualenvs/nautobot-Sgl-QJQc-py3.9/lib/python3.9/site-packages/graphql/execution/executor.py", line 626, in complete_leaf_value
    serialized_result = return_type.serialize(result)
  File "/home/meganerd/.cache/pypoetry/virtualenvs/nautobot-Sgl-QJQc-py3.9/lib/python3.9/site-packages/graphene/types/datetime.py", line 23, in serialize
    assert isinstance(
AssertionError: Received not compatible date "'2023-04-26'"`




Discussion:
> 3. You have to restart nautobot-server; we may need a post_save signal event for this as well, which would be a separate issue if so

This is a known issue: https://github.com/nautobot/nautobot/issues/446 https://github.com/nautobot/nautobot/issues/2127
@MeganerdDev I confirmed this bug. As a workaround, you should be able to access the value via `_custom_field_data`: `{ devices (name: "ams01-dist-01") { name, _custom_field_data } }` and inside you will find the data you're looking for.
@bryanculver The mitigation is working however I was wondering if this can be looked at again for a bugfix? I did some investigation I think might be useful for context

In the serialize method of the Data class in `graphene==2.1.9` ./graphene/types/datetime.py
The serializer expects a date object, and is getting a string instead

Not sure if we wanted to apply a monkey-patch with something like the following, or alternatively ensure the TYPE_DATE custom field object is converted before this serialization?
```python
if not isinstance(date, datetime.date) and isinstance(date, str):
    date = datetime.datetime.strptime(date, "%Y-%m-%d").date()
```

The monkey-patched method
```python
@staticmethod
def serialize(date):
    if isinstance(date, datetime.datetime):
        date = date.date()
    if not isinstance(date, datetime.date) and isinstance(date, str):
        date = datetime.datetime.strptime(date, "%Y-%m-%d").date()
    assert isinstance(
        date, datetime.date
    ), 'Received not compatible date "{}"'.format(repr(date))
    return date.isoformat()
```


Screenshot of testing -> result: working as expected
![image](https://github.com/nautobot/nautobot/assets/114895043/475474a5-e320-43fc-b8d8-cef7af14cc79)

Hey team! Please [add your planning poker estimate](https://app.zenhub.com/workspaces/Nautobot-Core-Product-64a8783b7a3f4b1225973a0c/issues/nautobot/nautobot/3664?planning-poker) with Zenhub @bryanculver @gsnider2195 @HanlinMiao @jathanism
Need to backport to 1.6

Resolving patch:
diff --git a/nautobot/core/graphql/schema.py b/nautobot/core/graphql/schema.py
--- a/nautobot/core/graphql/schema.py
+++ b/nautobot/core/graphql/schema.py
@@ -23,7 +23,7 @@
     generate_schema_type,
     generate_null_choices_resolver,
 )
-from nautobot.core.graphql.types import ContentTypeType
+from nautobot.core.graphql.types import ContentTypeType, DateType
 from nautobot.dcim.graphql.types import (
     CableType,
     CablePathType,
@@ -81,7 +81,7 @@
     CustomFieldTypeChoices.TYPE_INTEGER: graphene.Int(),
     CustomFieldTypeChoices.TYPE_TEXT: graphene.String(),
     CustomFieldTypeChoices.TYPE_BOOLEAN: graphene.Boolean(),
-    CustomFieldTypeChoices.TYPE_DATE: graphene.Date(),
+    CustomFieldTypeChoices.TYPE_DATE: DateType(),
     CustomFieldTypeChoices.TYPE_URL: graphene.String(),
     CustomFieldTypeChoices.TYPE_SELECT: graphene.String(),
 }
diff --git a/nautobot/core/graphql/types.py b/nautobot/core/graphql/types.py
--- a/nautobot/core/graphql/types.py
+++ b/nautobot/core/graphql/types.py
@@ -1,4 +1,7 @@
+import datetime
+
 from django.contrib.contenttypes.models import ContentType
+from graphql import GraphQLError
 from rest_framework.reverse import NoReverseMatch, reverse
 import graphene
 import graphene_django_optimizer as gql_optimizer
@@ -34,3 +37,22 @@ class ContentTypeType(OptimizedNautobotObjectType):
 
     class Meta:
         model = ContentType
+
+
+class DateType(graphene.Date):
+    """
+    Overriding the default serialize method from https://github.com/graphql-python/graphene/blob/master/graphene/types/datetime.py
+    to handle the case where the date object is passed as a str object.
+    """
+
+    @staticmethod
+    def serialize(date):
+        if isinstance(date, datetime.datetime):
+            date = date.date()
+            return date.isoformat()
+        elif isinstance(date, datetime.date):
+            return date.isoformat()
+        elif isinstance(date, str):
+            return date
+        else:
+            raise GraphQLError(f'Received not compatible date "{date!r}"')


Test patch:
diff --git a/nautobot/core/tests/test_graphql.py b/nautobot/core/tests/test_graphql.py
--- a/nautobot/core/tests/test_graphql.py
+++ b/nautobot/core/tests/test_graphql.py
@@ -1,6 +1,7 @@
+import datetime
 import random
 import types
-from unittest import skip
+from unittest import skip, TestCase as UnitTestTestCase
 import uuid
 
 from django.apps import apps
@@ -32,7 +33,7 @@
     extend_schema_type_relationships,
     extend_schema_type_null_field_choice,
 )
-from nautobot.core.graphql.types import OptimizedNautobotObjectType
+from nautobot.core.graphql.types import DateType, OptimizedNautobotObjectType
 from nautobot.core.graphql.utils import str_to_var_name
 from nautobot.dcim.choices import InterfaceTypeChoices, InterfaceModeChoices, PortTypeChoices, ConsolePortTypeChoices
 from nautobot.dcim.filters import DeviceFilterSet, SiteFilterSet
@@ -118,6 +119,22 @@ def test_execute_saved_query_with_variable(self):
         resp = execute_saved_query("gql-2", user=self.user, variables={"name": "site-1"}).to_dict()
         self.assertFalse(resp["data"].get("error"))
 
+    @skip("Works in isolation, fails as part of the overall test suite due to issue #446")
+    @override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
+    def test_execute_query_with_custom_field_type_date(self):
+        """Test Custom Field with Date type returns valid Date object and not string. Fix for bug #3664"""
+        custom_field = CustomField(
+            type=CustomFieldTypeChoices.TYPE_DATE, label="custom_date_field", key="custom_date_field"
+        )
+        custom_field.validated_save()
+        custom_field.content_types.set([ContentType.objects.get_for_model(Site)])
+        custom_field_data = {"custom_date_field": "2023-01-23"}
+        self.sites[0]._custom_field_data = custom_field_data
+        self.sites[0].save()
+        query = "query ($name: [String!]) { sites(name:$name) {name, _custom_field_data, cf_custom_date_field} }"
+        resp = execute_query(query, user=self.user, variables={"name": "Site-1"}).to_dict()
+        self.assertEqual(resp["data"]["sites"]["cf_custom_date_field"], custom_field_data)
+
     def test_graphql_types_registry(self):
         """Ensure models with graphql feature are registered in the graphene_django registry."""
         graphene_django_registry = get_global_registry()
@@ -2056,3 +2073,17 @@ def test_query_with_nested_onetoone(self):
         self.assertNotIn("error", str(result))
         expected_interfaces_first = {"ip_addresses": [{"primary_ip4_for": {"id": str(self.device1.id)}}]}
         self.assertEqual(result.data["device"]["interfaces"][0], expected_interfaces_first)
+
+
+class GraphQLTypeTestCase(UnitTestTestCase):
+    def test_date_type(self):
+        date_obj = datetime.date.today()
+        date_time_obj = datetime.datetime.today()
+        str_obj = date_obj.isoformat()
+        obj_not_accepted = False
+        self.assertEqual(DateType.serialize(date_obj), str_obj)
+        self.assertEqual(DateType.serialize(date_time_obj), str_obj)
+        self.assertEqual(DateType.serialize(str_obj), str_obj)
+        with self.assertRaises(GraphQLError) as cm:
+            DateType.serialize(obj_not_accepted)
+        self.assertIn("Received not compatible date", str(cm.exception))