Search code examples
pythondjangotimezoneambiguous

Handle Django Util Timezone Ambiguous Time Error without directly modifying the Django Package


I know to fix make_aware I can intercept the exception and set it forward an hour, however the issue is that Django is running the make_aware on dates as they're being discovered in a queryset, I am not calling the function in the code, is it happening in Django library code, which I can't edit without having to include the modified version in every version of the codebase. Is there a work around for this?

PLEASE NOTE: AGAIN, FOR CLARITY, I AM NOT CALLING make_aware MANUALLY, IT IS BEING RUN ON OBJECTS IN A QUERYSET WHEN IT IS BEING EVALUATED


Solution

  • I had the same issue and the solution does not seem to be documented anywhere, but I got one that works in a few lines of code. The answer comes in a custom Django DB backend. What we will do is extend the existing MySQL DB backend, and replace the piece of code that makes the MySQL datetime timezone aware.

    First, let me describe the problem that I faced. On November 6th, 2021, PST timezone had a DST transition, with the clock being rolled back to 1AM, after 1:59AM, instead of incrementing to 2AM, like it would happen under normal circumstances. As a result, anything with the 1AM timestamp on November 6th 2021, is ambiguous to Django, since it's not sure if it's the 1AM before the rollback or after it (i.e. the one that was "supposed to be 2AM, under normal circumstances"). The issue happens for every minute and second of 1AM, from 1:00:00 to 1:59:59.

    I dug into the error and noticed that the problem was in the following piece of code, that is called by the Django ORM when converting DateTimeField value from the database:

    # in file django.db.backends.mysql.operations
    
    class DatabaseOperations(BaseDatabaseOperations):
        # other code here
        
        def convert_datetimefield_value(self, value, expression, connection):
            if value is not None:
                value = timezone.make_aware(value, self.connection.timezone)
            return value
    

    in particular, the issue is in the following line:

    value = timezone.make_aware(value, self.connection.timezone)
    

    Since it's not passing the is_dst parameter to the timezone.make_aware call, we are bound to get the AmbiguousTimeError under the certain conditions. One simple solution is to pass is_dst=True to the call (or False, the choice is arbitrary), but that would mean modifying Django's code... or would it? You don't need to modify the Django's code, you can simply extend the default MySQL DB backend and override that function with your own implementation! (I already spoiled that at the beginning, but you should still act surprised for an extra dramatic effect).

    The Solution For Django 2, 3 and 4

    1. Extend the default Django MySQL DB backend, and override the part of code that does the DateTime field conversion. In order to do that, create a new package in the root of your Django project, and include a file named base.py in it. In that file our custom Django DB backend code will live. I called my database backend illyadbengine, so I will have the following folder structure:
    my-django-app
       my-django-app
          - settings.py
       
       illyadbengine
          - __init__.py
          - base.py
    

    and put the following code in base.py:

    """
    A custom MySQL DB engine that solves the ambiguous time error during DST transition, by assuming that it is DST
    in case of an error.
    """
    from django.db.backends.mysql import base
    from django.utils import timezone
    from django.db.backends.mysql.operations import DatabaseOperations
    from pytz.exceptions import AmbiguousTimeError
    
    
    class MySQLOperationsWithDSTConflictResolutionOperations(DatabaseOperations):
        def convert_datetimefield_value(self, value, expression, connection):
            try:
                # attempt at performing the default conversion, and only fallback to DST conflict resolution if it fails
                return super().convert_datetimefield_value(value, expression, connection)
            except AmbiguousTimeError:
                if value is not None:
                    # NOTE: this can cause an error by 1 hour, since it always assumes DST timezone in case of conflict
                    # if a precise time conversion is important for your use case, you should write that custom logic here
                    value = timezone.make_aware(value, self.connection.timezone, is_dst=True)
                return value
    
    
    class DatabaseWrapper(base.DatabaseWrapper):
        ops_class = MySQLOperationsWithDSTConflictResolutionOperations
    
    
    1. Instruct your DB to use your custom engine, in settings.py. You can do it in various ways, depending on how you have your database connection defined in settings. What you are looking at doing is setting the database connection's ENGINE parameter to illyadbengine.

    So, if you are using a connection string for you DB, you can do something like:

    import environ
    env = environ.Env()
    
    DATABASES = {
        "default": env.db_url("DATABASE_URL"),
    }
    
    DATABASES["default"]["ENGINE"] = "illyadbengine"
    

    or if you are defining the connection elements with an inline dict, you will have something like:

    DATABASES = {
        'default': {
            # other settings here
            'ENGINE': 'illyadbengine',
        }
    }
    

    Please note that this solution assumes that DST time is in effect during conflict, so you may end up an hour off the real time during the hour of the summer/winter time transition. If it is critical in your application for that information to be precise, you should include logic for that in your implementation of the overriden convert_datetimefield_value.

    This solution will work for Django 2, Django 3 and Django 4.

    I found some documentation on extending a database engine in Django's 3 Documentation, and while it's absent from Django 2, I tested it myself in Django 2.2 and it works. You should have not problems in Django 4, since that documentation is present there as well.