diff --git a/src/documents/migrations/1050_customfield_extra_data_and_more.py b/src/documents/migrations/1050_customfield_extra_data_and_more.py new file mode 100644 index 000000000..51a0d3dbd --- /dev/null +++ b/src/documents/migrations/1050_customfield_extra_data_and_more.py @@ -0,0 +1,48 @@ +# Generated by Django 4.2.13 on 2024-07-04 01:02 + +from django.db import migrations +from django.db import models + + +class Migration(migrations.Migration): + dependencies = [ + ("documents", "1049_document_deleted_at_document_restored_at"), + ] + + operations = [ + migrations.AddField( + model_name="customfield", + name="extra_data", + field=models.JSONField( + blank=True, + help_text="Extra data for the custom field, such as select options", + null=True, + verbose_name="extra data", + ), + ), + migrations.AddField( + model_name="customfieldinstance", + name="value_select", + field=models.IntegerField(null=True), + ), + migrations.AlterField( + model_name="customfield", + name="data_type", + field=models.CharField( + choices=[ + ("string", "String"), + ("url", "URL"), + ("date", "Date"), + ("boolean", "Boolean"), + ("integer", "Integer"), + ("float", "Float"), + ("monetary", "Monetary"), + ("documentlink", "Document Link"), + ("select", "Select"), + ], + editable=False, + max_length=50, + verbose_name="data type", + ), + ), + ] diff --git a/src/documents/models.py b/src/documents/models.py index 8ce038600..2de520054 100644 --- a/src/documents/models.py +++ b/src/documents/models.py @@ -808,6 +808,7 @@ class CustomField(models.Model): FLOAT = ("float", _("Float")) MONETARY = ("monetary", _("Monetary")) DOCUMENTLINK = ("documentlink", _("Document Link")) + SELECT = ("select", _("Select")) created = models.DateTimeField( _("created"), @@ -825,6 +826,15 @@ class CustomField(models.Model): editable=False, ) + extra_data = models.JSONField( + _("extra data"), + null=True, + blank=True, + help_text=_( + "Extra data for the custom field, such as select options", + ), + ) + class Meta: ordering = ("created",) verbose_name = _("custom field") @@ -888,6 +898,8 @@ class CustomFieldInstance(models.Model): value_document_ids = models.JSONField(null=True) + value_select = models.IntegerField(null=True) + class Meta: ordering = ("created",) verbose_name = _("custom field instance") @@ -924,6 +936,8 @@ class CustomFieldInstance(models.Model): return self.value_monetary elif self.field.data_type == CustomField.FieldDataType.DOCUMENTLINK: return self.value_document_ids + elif self.field.data_type == CustomField.FieldDataType.SELECT: + return self.value_select raise NotImplementedError(self.field.data_type) diff --git a/src/documents/serialisers.py b/src/documents/serialisers.py index 546a8d8e7..4da4bf6d6 100644 --- a/src/documents/serialisers.py +++ b/src/documents/serialisers.py @@ -455,6 +455,7 @@ class CustomFieldSerializer(serializers.ModelSerializer): "id", "name", "data_type", + "extra_data", ] def validate(self, attrs): @@ -476,6 +477,14 @@ class CustomFieldSerializer(serializers.ModelSerializer): raise serializers.ValidationError( {"error": "Object violates name unique constraint"}, ) + if ( + "data_type" in attrs + and attrs["data_type"] == CustomField.FieldDataType.SELECT + and ("extra_data" not in attrs or not isinstance(attrs["extra_data"], list)) + ): + raise serializers.ValidationError( + {"error": "extra_data must be a list"}, + ) return super().validate(attrs) @@ -507,6 +516,7 @@ class CustomFieldInstanceSerializer(serializers.ModelSerializer): CustomField.FieldDataType.FLOAT: "value_float", CustomField.FieldDataType.MONETARY: "value_monetary", CustomField.FieldDataType.DOCUMENTLINK: "value_document_ids", + CustomField.FieldDataType.SELECT: "value_select", } # An instance is attached to a document document: Document = validated_data["document"] @@ -563,6 +573,13 @@ class CustomFieldInstanceSerializer(serializers.ModelSerializer): )(data["value"]) elif field.data_type == CustomField.FieldDataType.STRING: MaxLengthValidator(limit_value=128)(data["value"]) + elif field.data_type == CustomField.FieldDataType.SELECT: + try: + field.extra_data[data["value"]] + except Exception: + raise serializers.ValidationError( + f"Value must be index of an element in {field.extra_data}", + ) return data diff --git a/src/documents/tests/test_api_custom_fields.py b/src/documents/tests/test_api_custom_fields.py index 0b2f99b35..df0631366 100644 --- a/src/documents/tests/test_api_custom_fields.py +++ b/src/documents/tests/test_api_custom_fields.py @@ -1,3 +1,4 @@ +import json from datetime import date from django.contrib.auth.models import User @@ -49,10 +50,26 @@ class TestCustomFieldsAPI(DirectoriesMixin, APITestCase): data = resp.json() - self.assertEqual(len(data), 3) self.assertEqual(data["name"], name) self.assertEqual(data["data_type"], field_type) + resp = self.client.post( + self.ENDPOINT, + json.dumps( + { + "data_type": "select", + "name": "Select Field", + "extra_data": ["Option 1", "Option 2"], + }, + ), + content_type="application/json", + ) + self.assertEqual(resp.status_code, status.HTTP_201_CREATED) + + data = resp.json() + + self.assertCountEqual(data["extra_data"], ["Option 1", "Option 2"]) + def test_create_custom_field_nonunique_name(self): """ GIVEN: @@ -135,6 +152,11 @@ class TestCustomFieldsAPI(DirectoriesMixin, APITestCase): name="Test Custom Field Doc Link", data_type=CustomField.FieldDataType.DOCUMENTLINK, ) + custom_field_select = CustomField.objects.create( + name="Test Custom Field Select", + data_type=CustomField.FieldDataType.SELECT, + extra_data=["Option 1", "Option 2"], + ) date_value = date.today() @@ -178,6 +200,10 @@ class TestCustomFieldsAPI(DirectoriesMixin, APITestCase): "field": custom_field_documentlink.id, "value": [doc2.id], }, + { + "field": custom_field_select.id, + "value": 0, + }, ], }, format="json", @@ -199,11 +225,12 @@ class TestCustomFieldsAPI(DirectoriesMixin, APITestCase): {"field": custom_field_monetary.id, "value": "EUR11.10"}, {"field": custom_field_monetary2.id, "value": "11.1"}, {"field": custom_field_documentlink.id, "value": [doc2.id]}, + {"field": custom_field_select.id, "value": 0}, ], ) doc.refresh_from_db() - self.assertEqual(len(doc.custom_fields.all()), 9) + self.assertEqual(len(doc.custom_fields.all()), 10) def test_change_custom_field_instance_value(self): """ @@ -568,6 +595,42 @@ class TestCustomFieldsAPI(DirectoriesMixin, APITestCase): self.assertEqual(CustomFieldInstance.objects.count(), 0) self.assertEqual(len(doc.custom_fields.all()), 0) + def test_custom_field_value_select_validation(self): + """ + GIVEN: + - Document & custom field exist + WHEN: + - API request to set a field value to something not in the select options + THEN: + - HTTP 400 is returned + - No field instance is created or attached to the document + """ + doc = Document.objects.create( + title="WOW", + content="the content", + checksum="123", + mime_type="application/pdf", + ) + custom_field_select = CustomField.objects.create( + name="Test Custom Field SELECT", + data_type=CustomField.FieldDataType.SELECT, + extra_data=["Option 1", "Option 2"], + ) + + resp = self.client.patch( + f"/api/documents/{doc.id}/", + data={ + "custom_fields": [ + {"field": custom_field_select.id, "value": 3}, + ], + }, + format="json", + ) + + self.assertEqual(resp.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual(CustomFieldInstance.objects.count(), 0) + self.assertEqual(len(doc.custom_fields.all()), 0) + def test_custom_field_not_null(self): """ GIVEN: