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 serializab
le.
Specific Solution (Encode Only)
Resources
https://docs.python.org/3/library/json.html#json.JSONEncoder
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)