Encode custom models to Django’s JSONField

Problem

Say you have a Class like this:

class Device():
 def __init__()
 ref = 333
 name = "sweet one" 
 things = { ... of things... }
 when = datetime.now()

and then you want to serialise it, say as a ‘snapshot’ for rendering or logging, and you want to store that in another model:

class LogContext(model.Model):
    snapshot = model.JSONField() # store Device/s in here

We want to be able to say:

d1 = Device()
d1.ref = 1
d2 = Device()
d2.things = { ... }
l = LogContext()
l.snapshot = [ d1, d2 ]

A normal JSONEncoder doesn’t know about “Device” so you get Device type not JSON serializable.

Specific Solution (Encode Only)

Resources

https://docs.python.org/3/library/json.html#json.JSONEncoder

https://docs.djangoproject.com/en/5.0/topics/serialization/#django.core.serializers.json.DjangoJSONEncoder

https://docs.djangoproject.com/en/5.0/ref/models/fields/#django.db.models.JSONField

Options

Maybe use orson or jsonpickle or pickle? Let’s keep it simple and secure.

Solution (Encode Only)

  • Start with DjangoJSONEncoder – it encodes datetime and timedeltas
  • Extend this class to handle custom types of the app
  • Reuse existing “Serializers” from DRF which we need anyway.

class CustomJSONEncoder(DjangoJSONEncoder):    
    """
    Use the DRF Rest Framework serializers to serialize phisaver objects.
    Return strings for miscellenous objects like Path and timedelta, which can be rendered in the template.
    """

    def default(self, obj):

        if isinstance(obj, Grogu):
            return GroguSerializer(obj).data
        elif isinstance(obj, PhiUser):
            return UserSerializer(obj).data
        elif isinstance(obj, Fleet):
            return FleetSerializer(obj).data
        elif isinstance(obj, Device):
            return DeviceSerializer(obj).data
        elif isinstance(obj, Path):
            return str(obj)
       
        # Will raise a TypeError if obj is not a valid JSON type in DjangoJSONEncoder
        return super().default(obj)
  • Then use the normal JSONField of Django and specify this encoder
class Logger(models.Model):
    ...
    context = models.JSONField(encoder=CustomJSONEncoder, default=dict)

Observations

The default=dict creates a *new* {} as the default of the field. You want that to avoid sharing {}

DRF Serializers are convenient but couple you to DRF. That’s okay for me.

You could catch TypeErros from return super().default(obj), and just return str(obj) for un-encodable types.

This doesn’t handle decoding. For ‘read-only’ situation (e.g. snapshots of objects, logs, context for rendering) this is fine.

You can encode Django models too. That is, to store a snapshot of a model in another.

Solution (Encode and Decode)

If you need to decode too, it’s harder as you can’t just write strings since you can’t know their types to re-create them. A limited solution is to write matching Encoder and Decode with a ‘_type’ attribute to solve this.

This is limited and verbose but good if you really must decode types.

This example has the two JSON class and a convenience Field which uses them.

"""
This CustomJSONField class, a Model field, extends the Django JSONField and provides custom 
encoding and decoding for datetime and timedelta objects. 

Example usage:

    class MyModel(models.Model):
        data = CustomJSONField()

    MyModel.objects.create(data={'start':datetime.now(),'duration':timedelta(days=1)})        

"""

import json
from datetime import datetime, timedelta

from django.db.models import JSONField


class CustomJSONEncoder(json.JSONEncoder):
    def default(self, obj):
        if isinstance(obj, datetime):
            return {"_type": "datetime", "value": obj.isoformat()}
        elif isinstance(obj, timedelta):
            return {"_type": "timedelta", "value": obj.total_seconds()}
        
        return super().default(obj)


class CustomJSONDecoder(json.JSONDecoder):
    def __init__(self, *args, **kwargs):
        super().__init__(object_hook=self.object_hook, *args, **kwargs)

    def object_hook(self, dct):
        if "_type" not in dct:
            return dct
        if dct["_type"] == "datetime":
            return datetime.fromisoformat(dct["value"])
        elif dct["_type"] == "timedelta":
            return timedelta(seconds=dct["value"])
        return dct


class CustomJSONField(JSONField):
    def __init__(self, *args, **kwargs):
        kwargs["encoder"] = CustomJSONEncoder
        kwargs["decoder"] = CustomJSONDecoder
        super().__init__(*args, **kwargs)