Fix: use api version > 7 for new custom field select format

This commit is contained in:
shamoon 2025-01-25 18:13:51 -08:00
parent 8311313e6e
commit 5066070b73
7 changed files with 131 additions and 27 deletions

View File

@ -573,3 +573,8 @@ Initial API version.
#### Version 6 #### Version 6
- Moved acknowledge tasks endpoint to be under `/api/tasks/acknowledge/`. - Moved acknowledge tasks endpoint to be under `/api/tasks/acknowledge/`.
#### Version 7
- The format of select type custom fields has changed to return the options
as an array of objects with `id` and `label` fields.

View File

@ -3,7 +3,7 @@ const base_url = new URL(document.baseURI)
export const environment = { export const environment = {
production: true, production: true,
apiBaseUrl: document.baseURI + 'api/', apiBaseUrl: document.baseURI + 'api/',
apiVersion: '6', apiVersion: '7',
appTitle: 'Paperless-ngx', appTitle: 'Paperless-ngx',
version: '2.14.5', version: '2.14.5',
webSocketHost: window.location.host, webSocketHost: window.location.host,

View File

@ -5,7 +5,7 @@
export const environment = { export const environment = {
production: false, production: false,
apiBaseUrl: 'http://localhost:8000/api/', apiBaseUrl: 'http://localhost:8000/api/',
apiVersion: '6', apiVersion: '7',
appTitle: 'Paperless-ngx', appTitle: 'Paperless-ngx',
version: 'DEVELOPMENT', version: 'DEVELOPMENT',
webSocketHost: 'localhost:8000', webSocketHost: 'localhost:8000',

View File

@ -495,7 +495,27 @@ class StoragePathField(serializers.PrimaryKeyRelatedField):
return StoragePath.objects.all() return StoragePath.objects.all()
class ReadWriteSerializerMethodField(serializers.SerializerMethodField):
"""
Based on https://stackoverflow.com/a/62579804
"""
def __init__(self, method_name=None, *args, **kwargs):
self.method_name = method_name
kwargs["source"] = "*"
super(serializers.SerializerMethodField, self).__init__(*args, **kwargs)
def to_internal_value(self, data):
return {self.field_name: data}
class CustomFieldSerializer(serializers.ModelSerializer): class CustomFieldSerializer(serializers.ModelSerializer):
def __init__(self, *args, **kwargs):
self.api_version = int(
kwargs.pop("api_version", settings.REST_FRAMEWORK["ALLOWED_VERSIONS"][-1]),
)
super().__init__(*args, **kwargs)
data_type = serializers.ChoiceField( data_type = serializers.ChoiceField(
choices=CustomField.FieldDataType, choices=CustomField.FieldDataType,
read_only=False, read_only=False,
@ -503,6 +523,8 @@ class CustomFieldSerializer(serializers.ModelSerializer):
document_count = serializers.IntegerField(read_only=True) document_count = serializers.IntegerField(read_only=True)
extra_data = ReadWriteSerializerMethodField(required=False)
class Meta: class Meta:
model = CustomField model = CustomField
fields = [ fields = [
@ -544,18 +566,39 @@ class CustomFieldSerializer(serializers.ModelSerializer):
or "select_options" not in attrs["extra_data"] or "select_options" not in attrs["extra_data"]
or not isinstance(attrs["extra_data"]["select_options"], list) or not isinstance(attrs["extra_data"]["select_options"], list)
or len(attrs["extra_data"]["select_options"]) == 0 or len(attrs["extra_data"]["select_options"]) == 0
or not all( or (
len(option.get("label", "")) > 0 # version 6 and below require a list of strings
for option in attrs["extra_data"]["select_options"] self.api_version < 7
and not all(
len(option) > 0
for option in attrs["extra_data"]["select_options"]
)
)
or (
# version 7 and above require a list of objects with labels
self.api_version >= 7
and not all(
len(option.get("label", "")) > 0
for option in attrs["extra_data"]["select_options"]
)
) )
): ):
raise serializers.ValidationError( raise serializers.ValidationError(
{"error": "extra_data.select_options must be a valid list"}, {"error": "extra_data.select_options must be a valid list"},
) )
# labels are valid, generate ids if not present # labels are valid, generate ids if not present
for option in attrs["extra_data"]["select_options"]: if self.api_version < 7:
if option.get("id") is None: attrs["extra_data"]["select_options"] = [
option["id"] = get_random_string(length=16) {
"label": option,
"id": get_random_string(length=16),
}
for option in attrs["extra_data"]["select_options"]
]
else:
for option in attrs["extra_data"]["select_options"]:
if option.get("id") is None:
option["id"] = get_random_string(length=16)
elif ( elif (
"data_type" in attrs "data_type" in attrs
and attrs["data_type"] == CustomField.FieldDataType.MONETARY and attrs["data_type"] == CustomField.FieldDataType.MONETARY
@ -575,19 +618,15 @@ class CustomFieldSerializer(serializers.ModelSerializer):
) )
return super().validate(attrs) return super().validate(attrs)
def get_extra_data(self, obj):
class ReadWriteSerializerMethodField(serializers.SerializerMethodField): extra_data = obj.extra_data
""" if self.api_version < 7 and obj.data_type == CustomField.FieldDataType.SELECT:
Based on https://stackoverflow.com/a/62579804 # Convert the select options with ids to a list of strings
""" extra_data["select_options"] = [
option["label"] for option in extra_data["select_options"]
def __init__(self, method_name=None, *args, **kwargs): ]
self.method_name = method_name field = serializers.JSONField()
kwargs["source"] = "*" return field.to_representation(extra_data)
super(serializers.SerializerMethodField, self).__init__(*args, **kwargs)
def to_internal_value(self, data):
return {self.field_name: data}
class CustomFieldInstanceSerializer(serializers.ModelSerializer): class CustomFieldInstanceSerializer(serializers.ModelSerializer):

View File

@ -43,10 +43,13 @@ class TestCustomFieldsAPI(DirectoriesMixin, APITestCase):
]: ]:
resp = self.client.post( resp = self.client.post(
self.ENDPOINT, self.ENDPOINT,
data={ data=json.dumps(
"data_type": field_type, {
"name": name, "data_type": field_type,
}, "name": name,
},
),
content_type="application/json",
) )
self.assertEqual(resp.status_code, status.HTTP_201_CREATED) self.assertEqual(resp.status_code, status.HTTP_201_CREATED)
@ -272,6 +275,59 @@ class TestCustomFieldsAPI(DirectoriesMixin, APITestCase):
doc.refresh_from_db() doc.refresh_from_db()
self.assertEqual(doc.custom_fields.first().value, None) self.assertEqual(doc.custom_fields.first().value, None)
def test_custom_field_select_old_version(self):
"""
GIVEN:
- Select custom field exists with old version of select options
WHEN:
- API post request is made for custom fields with api version header < 7
- API get request is made for custom fields with api version header < 7
THEN:
- The select options are returned in the old format
"""
resp = self.client.post(
self.ENDPOINT,
headers={"Accept": "application/json; version=6"},
data=json.dumps(
{
"data_type": "select",
"name": "Select Field",
"extra_data": {
"select_options": [
"Option 1",
"Option 2",
],
},
},
),
content_type="application/json",
)
self.assertEqual(resp.status_code, status.HTTP_201_CREATED)
field = CustomField.objects.get(name="Select Field")
self.assertEqual(
field.extra_data["select_options"],
[
{"label": "Option 1", "id": ANY},
{"label": "Option 2", "id": ANY},
],
)
resp = self.client.get(
f"{self.ENDPOINT}{field.id}/",
headers={"Accept": "application/json; version=6"},
)
self.assertEqual(resp.status_code, status.HTTP_200_OK)
data = resp.json()
self.assertEqual(
data["extra_data"]["select_options"],
[
"Option 1",
"Option 2",
],
)
def test_create_custom_field_monetary_validation(self): def test_create_custom_field_monetary_validation(self):
""" """
GIVEN: GIVEN:

View File

@ -2065,6 +2065,10 @@ class CustomFieldViewSet(ModelViewSet):
) )
) )
def get_serializer(self, *args, **kwargs):
kwargs.setdefault("api_version", self.request.version)
return super().get_serializer(*args, **kwargs)
class SystemStatusView(PassUserMixin): class SystemStatusView(PassUserMixin):
permission_classes = (IsAuthenticated,) permission_classes = (IsAuthenticated,)

View File

@ -341,10 +341,10 @@ REST_FRAMEWORK = {
"rest_framework.authentication.SessionAuthentication", "rest_framework.authentication.SessionAuthentication",
], ],
"DEFAULT_VERSIONING_CLASS": "rest_framework.versioning.AcceptHeaderVersioning", "DEFAULT_VERSIONING_CLASS": "rest_framework.versioning.AcceptHeaderVersioning",
"DEFAULT_VERSION": "1", "DEFAULT_VERSION": "7",
# Make sure these are ordered and that the most recent version appears # Make sure these are ordered and that the most recent version appears
# last # last
"ALLOWED_VERSIONS": ["1", "2", "3", "4", "5", "6"], "ALLOWED_VERSIONS": ["1", "2", "3", "4", "5", "6", "7"],
} }
if DEBUG: if DEBUG: