This is very interesting. I looked into the code and found the root cause, specifically this lines in the implementation for ModelSerializer:
for field_name in field_names:
# If the field is explicitly declared on the class then use that.
if field_name in declared_fields:
fields[field_name] = declared_fields[field_name]
continue
....
Here was my script for the investigation
from django.db import models
from rest_framework import serializers
class MyModel(models.Model):
xero_contact_id = models.UUIDField(unique=True)
name = models.CharField(max_length=255, default="Some name")
class Meta:
db_table = "my_model"
class MySerializer(serializers.ModelSerializer):
owner_email = serializers.CharField()
breed_name = serializers.CharField(max_length=255)
class Meta:
model = MyModel
fields = '__all__'
read_only_fields = ["breed_name", "owner_email", "xero_contact_id"]
serializer = MySerializer()
print(repr(serializer))
I added some prints and here is what I saw:
>>> print(repr(serializer))
field_names ['id', 'owner_email', 'breed_name', 'xero_contact_id', 'name']
declared_fields OrderedDict([('owner_email', CharField()), ('breed_name', CharField(max_length=255))])
extra_kwargs {'breed_name': {'read_only': True}, 'owner_email': {'read_only': True}, 'xero_contact_id': {'read_only': True}}
MySerializer():
id = IntegerField(label='ID', read_only=True)
owner_email = CharField()
breed_name = CharField(max_length=255)
xero_contact_id = UUIDField(read_only=True)
name = CharField(max_length=255, required=False)
As you can see, the read_only argument is in the extra_kwargs. The problem is that for all the fields that are only declared in the ModelSerializer itself (visible from declared_fields) and not in the model class, they don't read from the extra_kwargs, they just read what was set in the field itself as visible in the code snippet above fields[field_name] = declared_fields[field_name] then performs a continue. Thus, the option for read_only was ignored.
I fixed it by modifying the implementation of ModelSerializer to also consider the extra_kwargs even for non-model fields
for field_name in field_names:
# If the field is explicitly declared on the class then use that.
if field_name in declared_fields:
field_class = type(declared_fields[field_name])
declared_field_args = declared_fields[field_name].__dict__['_args']
declared_field_kwargs = declared_fields[field_name].__dict__['_kwargs']
extra_field_kwargs = extra_kwargs.get(field_name, {})
# Old implementation doesn't take into account the extra_kwargs
# fields[field_name] = declared_fields[field_name]
# New implementation takes into account the extra_kwargs
fields[field_name] = field_class(*declared_field_args, **declared_field_kwargs, **extra_field_kwargs)
continue
....
Now, read_only was correctly set to the target fields, including non-model fields:
>>> print(repr(serializer))
field_names ['id', 'owner_email', 'breed_name', 'xero_contact_id', 'name']
declared_fields OrderedDict([('owner_email', CharField()), ('breed_name', CharField(max_length=255))])
extra_kwargs {'breed_name': {'read_only': True}, 'owner_email': {'read_only': True}, 'xero_contact_id': {'read_only': True}}
MySerializer():
id = IntegerField(label='ID', read_only=True)
owner_email = CharField(read_only=True)
breed_name = CharField(max_length=255, read_only=True)
xero_contact_id = UUIDField(read_only=True)
name = CharField(max_length=255, required=False)
This doesn't seem to be in the DRF docs. Sounds like a feature we can request to DRF :) So the solution for the meantime is as what @JPG pointed out, use read_only=True explicitly in the extra non-model fields.