+ @switch (objectForm.get('data_type').value) {
+ @case (CustomFieldDataType.Select) {
+
+
+ @for (option of objectForm.controls.extra_data.controls.select_options.controls; track option; let i = $index) {
+
+
+
+
+ }
+
+ }
+ }
+
}
diff --git a/src-ui/src/app/data/custom-field.ts b/src-ui/src/app/data/custom-field.ts
index e858f06be..a60c5ac2a 100644
--- a/src-ui/src/app/data/custom-field.ts
+++ b/src-ui/src/app/data/custom-field.ts
@@ -9,6 +9,7 @@ export enum CustomFieldDataType {
Float = 'float',
Monetary = 'monetary',
DocumentLink = 'documentlink',
+ Select = 'select',
}
export const DATA_TYPE_LABELS = [
@@ -44,10 +45,17 @@ export const DATA_TYPE_LABELS = [
id: CustomFieldDataType.DocumentLink,
name: $localize`Document Link`,
},
+ {
+ id: CustomFieldDataType.Select,
+ name: $localize`Select`,
+ },
]
export interface CustomField extends ObjectWithId {
data_type: CustomFieldDataType
name: string
created?: Date
+ extra_data?: {
+ select_options?: string[]
+ }
}
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..0c6a77ccc
--- /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.PositiveSmallIntegerField(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..0e6de5360 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.PositiveSmallIntegerField(null=True)
+
class Meta:
ordering = ("created",)
verbose_name = _("custom field instance")
@@ -900,7 +912,12 @@ class CustomFieldInstance(models.Model):
]
def __str__(self) -> str:
- return str(self.field.name) + f" : {self.value}"
+ value = (
+ self.field.extra_data["select_options"][self.value_select]
+ if self.field.data_type == CustomField.FieldDataType.SELECT
+ else self.value
+ )
+ return str(self.field.name) + f" : {value}"
@property
def value(self):
@@ -924,6 +941,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..2f6a19f49 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,23 @@ 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 "select_options" not in attrs["extra_data"]
+ or not isinstance(attrs["extra_data"]["select_options"], list)
+ or len(attrs["extra_data"]["select_options"]) == 0
+ or not all(
+ isinstance(option, str) and len(option) > 0
+ for option in attrs["extra_data"]["select_options"]
+ )
+ )
+ ):
+ raise serializers.ValidationError(
+ {"error": "extra_data.select_options must be a valid list"},
+ )
return super().validate(attrs)
@@ -507,6 +525,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 +582,14 @@ 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:
+ select_options = field.extra_data["select_options"]
+ try:
+ select_options[data["value"]]
+ except Exception:
+ raise serializers.ValidationError(
+ f"Value must be index of an element in {select_options}",
+ )
return data
diff --git a/src/documents/tests/test_api_custom_fields.py b/src/documents/tests/test_api_custom_fields.py
index 0b2f99b35..edebf7f3c 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,31 @@ 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": {
+ "select_options": ["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"]["select_options"],
+ ["Option 1", "Option 2"],
+ )
+
def test_create_custom_field_nonunique_name(self):
"""
GIVEN:
@@ -76,6 +98,45 @@ class TestCustomFieldsAPI(DirectoriesMixin, APITestCase):
)
self.assertEqual(resp.status_code, status.HTTP_400_BAD_REQUEST)
+ def test_create_custom_field_select_invalid_options(self):
+ """
+ GIVEN:
+ - Custom field does not exist
+ WHEN:
+ - API request to create custom field with invalid select options
+ THEN:
+ - HTTP 400 is returned
+ """
+
+ # Not a list
+ resp = self.client.post(
+ self.ENDPOINT,
+ json.dumps(
+ {
+ "data_type": "select",
+ "name": "Select Field",
+ "extra_data": {
+ "select_options": "not a list",
+ },
+ },
+ ),
+ content_type="application/json",
+ )
+ self.assertEqual(resp.status_code, status.HTTP_400_BAD_REQUEST)
+
+ # No options
+ resp = self.client.post(
+ self.ENDPOINT,
+ json.dumps(
+ {
+ "data_type": "select",
+ "name": "Select Field",
+ },
+ ),
+ content_type="application/json",
+ )
+ self.assertEqual(resp.status_code, status.HTTP_400_BAD_REQUEST)
+
def test_create_custom_field_instance(self):
"""
GIVEN:
@@ -135,6 +196,13 @@ 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={
+ "select_options": ["Option 1", "Option 2"],
+ },
+ )
date_value = date.today()
@@ -178,6 +246,10 @@ class TestCustomFieldsAPI(DirectoriesMixin, APITestCase):
"field": custom_field_documentlink.id,
"value": [doc2.id],
},
+ {
+ "field": custom_field_select.id,
+ "value": 0,
+ },
],
},
format="json",
@@ -199,11 +271,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 +641,44 @@ 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={
+ "select_options": ["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: