]> git.ipfire.org Git - thirdparty/paperless-ngx.git/commitdiff
Enhancement: use stable unique IDs for custom field select options (#8299)
authorshamoon <4887959+shamoon@users.noreply.github.com>
Mon, 2 Dec 2024 04:15:38 +0000 (20:15 -0800)
committerGitHub <noreply@github.com>
Mon, 2 Dec 2024 04:15:38 +0000 (04:15 +0000)
24 files changed:
src-ui/src/app/components/common/custom-field-display/custom-field-display.component.spec.ts
src-ui/src/app/components/common/custom-field-display/custom-field-display.component.ts
src-ui/src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.html
src-ui/src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.spec.ts
src-ui/src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.ts
src-ui/src/app/components/common/edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component.html
src-ui/src/app/components/common/edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component.spec.ts
src-ui/src/app/components/common/edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component.ts
src-ui/src/app/components/common/input/select/select.component.spec.ts
src-ui/src/app/components/common/input/select/select.component.ts
src-ui/src/app/components/document-detail/document-detail.component.html
src-ui/src/app/data/custom-field.ts
src/documents/filters.py
src/documents/management/commands/document_importer.py
src/documents/migrations/1059_alter_customfieldinstance_value_select.py [new file with mode: 0644]
src/documents/models.py
src/documents/serialisers.py
src/documents/signals/handlers.py
src/documents/templating/filepath.py
src/documents/tests/test_api_custom_fields.py
src/documents/tests/test_api_documents.py
src/documents/tests/test_api_filter_by_custom_fields.py
src/documents/tests/test_file_handling.py
src/documents/tests/test_migration_custom_field_selects.py [new file with mode: 0644]

index ea60034e493dd59d99c63bc54fd61bc472270a6a..824e1e05bbfdb889a4f9d5ef4013d5c0c7868f0d 100644 (file)
@@ -17,7 +17,11 @@ const customFields: CustomField[] = [
     name: 'Field 4',
     data_type: CustomFieldDataType.Select,
     extra_data: {
-      select_options: ['Option 1', 'Option 2', 'Option 3'],
+      select_options: [
+        { label: 'Option 1', id: 'abc-123' },
+        { label: 'Option 2', id: 'def-456' },
+        { label: 'Option 3', id: 'ghi-789' },
+      ],
     },
   },
   {
@@ -131,6 +135,8 @@ describe('CustomFieldDisplayComponent', () => {
   })
 
   it('should show select value', () => {
-    expect(component.getSelectValue(customFields[3], 2)).toEqual('Option 3')
+    expect(component.getSelectValue(customFields[3], 'ghi-789')).toEqual(
+      'Option 3'
+    )
   })
 })
index f541f0e47188f2ecc6752a711ed59ec30bcba831..1ab831f4610f9e8f4c4862d172b15c8820e390f2 100644 (file)
@@ -117,8 +117,8 @@ export class CustomFieldDisplayComponent implements OnInit, OnDestroy {
     return this.docLinkDocuments?.find((d) => d.id === docId)?.title
   }
 
-  public getSelectValue(field: CustomField, index: number): string {
-    return field.extra_data.select_options[index]
+  public getSelectValue(field: CustomField, id: string): string {
+    return field.extra_data.select_options?.find((o) => o.id === id)?.label
   }
 
   ngOnDestroy(): void {
index 9cc095d7d5cc7525f64eb22fe6cfd06c3711c2c5..768a79af5e75b396847eb90fef9c973da5df1494 100644 (file)
@@ -44,6 +44,8 @@
     <ng-select #fieldSelects
       class="paperless-input-select rounded-end"
       [items]="getSelectOptionsForField(atom.field)"
+      bindLabel="label"
+      bindValue="id"
       [(ngModel)]="atom.value"
       [disabled]="disabled"
       (mousedown)="$event.stopImmediatePropagation()"
         <ng-select
           class="paperless-input-select rounded-end"
           [items]="getSelectOptionsForField(atom.field)"
+          bindLabel="label"
+          bindValue="id"
           [(ngModel)]="atom.value"
           [disabled]="disabled"
           [multiple]="true"
index 9b42ff4e6075e5bd649bb5d44e245cac288121e7..dc915c24d3fa1595ee23b55d8b87604da41b88b1 100644 (file)
@@ -39,7 +39,12 @@ const customFields = [
     id: 2,
     name: 'Test Select Field',
     data_type: CustomFieldDataType.Select,
-    extra_data: { select_options: ['Option 1', 'Option 2'] },
+    extra_data: {
+      select_options: [
+        { label: 'Option 1', id: 'abc-123' },
+        { label: 'Option 2', id: 'def-456' },
+      ],
+    },
   },
 ]
 
@@ -128,11 +133,19 @@ describe('CustomFieldsQueryDropdownComponent', () => {
       id: 1,
       name: 'Test Field',
       data_type: CustomFieldDataType.Select,
-      extra_data: { select_options: ['Option 1', 'Option 2'] },
+      extra_data: {
+        select_options: [
+          { label: 'Option 1', id: 'abc-123' },
+          { label: 'Option 2', id: 'def-456' },
+        ],
+      },
     }
     component.customFields = [field]
     const options = component.getSelectOptionsForField(1)
-    expect(options).toEqual(['Option 1', 'Option 2'])
+    expect(options).toEqual([
+      { label: 'Option 1', id: 'abc-123' },
+      { label: 'Option 2', id: 'def-456' },
+    ])
 
     // Fallback to empty array if field is not found
     const options2 = component.getSelectOptionsForField(2)
index b0d446dd0b829c7ee5be98f103f9d46b04b627d1..2233fc5c48eb952ff9501f160b30325acd2e923f 100644 (file)
@@ -311,7 +311,9 @@ export class CustomFieldsQueryDropdownComponent implements OnDestroy {
     }))
   }
 
-  getSelectOptionsForField(fieldID: number): string[] {
+  getSelectOptionsForField(
+    fieldID: number
+  ): Array<{ label: string; id: string }> {
     const field = this.customFields.find((field) => field.id === fieldID)
     if (field) {
       return field.extra_data['select_options']
index d48c0788bdbb6e26a1ce99ef26bfec602c8731ac..b4216e41c6ad99dc07e17d4bfab3d1e1e9f1f47d 100644 (file)
@@ -21,8 +21,9 @@
           </button>
           <div formArrayName="select_options">
             @for (option of objectForm.controls.extra_data.controls.select_options.controls; track option; let i = $index) {
-              <div class="input-group input-group-sm my-2">
-                <input #selectOption type="text" class="form-control" [formControl]="option" autocomplete="off">
+              <div class="input-group input-group-sm my-2" [formGroup]="objectForm.controls.extra_data.controls.select_options.controls[i]">
+                <input #selectOption type="text" class="form-control" formControlName="label" autocomplete="off">
+                <input type="hidden" formControlName="id">
                 <button type="button" class="btn btn-outline-danger" (click)="removeSelectOption(i)" i18n>Delete</button>
               </div>
             }
index 2de17577f32d5ba68fdd0aa9881391c9f8a2dce1..6ecf72b5d1a5ad63d22cf32574fc97e7521ca641 100644 (file)
@@ -80,7 +80,11 @@ describe('CustomFieldEditDialogComponent', () => {
       name: 'Field 1',
       data_type: CustomFieldDataType.Select,
       extra_data: {
-        select_options: ['Option 1', 'Option 2', 'Option 3'],
+        select_options: [
+          { label: 'Option 1', id: '123-xyz' },
+          { label: 'Option 2', id: '456-abc' },
+          { label: 'Option 3', id: '789-123' },
+        ],
       },
     }
     fixture.detectChanges()
@@ -96,19 +100,19 @@ describe('CustomFieldEditDialogComponent', () => {
     component.ngOnInit()
     expect(
       component.objectForm.get('extra_data').get('select_options').value.length
-    ).toBe(1)
+    ).toBe(0)
     component.addSelectOption()
     expect(
       component.objectForm.get('extra_data').get('select_options').value.length
-    ).toBe(2)
+    ).toBe(1)
     component.addSelectOption()
     expect(
       component.objectForm.get('extra_data').get('select_options').value.length
-    ).toBe(3)
+    ).toBe(2)
     component.removeSelectOption(0)
     expect(
       component.objectForm.get('extra_data').get('select_options').value.length
-    ).toBe(2)
+    ).toBe(1)
   })
 
   it('should focus on last select option input', () => {
index b27ec9fcd314452fc2cf4eb1dce5edccbccd6114..e39e27edda57af74fd5d086d1c83fcedb1a81c02 100644 (file)
@@ -57,9 +57,16 @@ export class CustomFieldEditDialogComponent
     }
     if (this.object?.data_type === CustomFieldDataType.Select) {
       this.selectOptions.clear()
-      this.object.extra_data.select_options.forEach((option) =>
-        this.selectOptions.push(new FormControl(option))
-      )
+      this.object.extra_data.select_options
+        .filter((option) => option)
+        .forEach((option) =>
+          this.selectOptions.push(
+            new FormGroup({
+              label: new FormControl(option.label),
+              id: new FormControl(option.id),
+            })
+          )
+        )
     }
   }
 
@@ -89,7 +96,7 @@ export class CustomFieldEditDialogComponent
       name: new FormControl(null),
       data_type: new FormControl(null),
       extra_data: new FormGroup({
-        select_options: new FormArray([new FormControl(null)]),
+        select_options: new FormArray([]),
         default_currency: new FormControl(null),
       }),
     })
@@ -104,7 +111,9 @@ export class CustomFieldEditDialogComponent
   }
 
   public addSelectOption() {
-    this.selectOptions.push(new FormControl(''))
+    this.selectOptions.push(
+      new FormGroup({ label: new FormControl(null), id: new FormControl(null) })
+    )
   }
 
   public removeSelectOption(index: number) {
index 2c39035a25facebf68df67a01583489aaa183d0a..79eec16e86e15ee14121dc8b538e2c3140edafc9 100644 (file)
@@ -132,12 +132,4 @@ describe('SelectComponent', () => {
     const expectedTitle = `Filter documents with this ${component.title}`
     expect(component.filterButtonTitle).toEqual(expectedTitle)
   })
-
-  it('should support setting items as a plain array', () => {
-    component.itemsArray = ['foo', 'bar']
-    expect(component.items).toEqual([
-      { id: 0, name: 'foo' },
-      { id: 1, name: 'bar' },
-    ])
-  })
 })
index d9976698e52a50b1fd45d31f34d617a2f4b8ed3e..19f6375ad1b60c298cf4f0975c88d3a958f2ac1a 100644 (file)
@@ -34,11 +34,6 @@ export class SelectComponent extends AbstractInputComponent<number> {
     if (items && this.value) this.checkForPrivateItems(this.value)
   }
 
-  @Input()
-  set itemsArray(items: any[]) {
-    this._items = items.map((item, index) => ({ id: index, name: item }))
-  }
-
   writeValue(newValue: any): void {
     if (newValue && this._items) {
       this.checkForPrivateItems(newValue)
index 486277c21d9dfa5bc8a33793d74b644410603fd7..86767b6e789272acc4f5fcbce7f2fb85ebf778ab 100644 (file)
                     @case (CustomFieldDataType.Select) {
                       <pngx-input-select formControlName="value"
                       [title]="getCustomFieldFromInstance(fieldInstance)?.name"
-                      [itemsArray]="getCustomFieldFromInstance(fieldInstance)?.extra_data.select_options"
+                      [items]="getCustomFieldFromInstance(fieldInstance)?.extra_data.select_options"
+                      bindLabel="label"
                       [allowNull]="true"
                       [horizontal]="true"
                       [removable]="userIsOwner"
index bca77dd515dace7782f4f71ee15f94cd750e6855..c5130a756ff338048184557973c41d1c6a332885 100644 (file)
@@ -56,7 +56,7 @@ export interface CustomField extends ObjectWithId {
   name: string
   created?: Date
   extra_data?: {
-    select_options?: string[]
+    select_options?: Array<{ label: string; id: string }>
     default_currency?: string
   }
   document_count?: number
index e8065c472a1c704cab51005aa78ddfca0131006e..237973b6f049d1675996d75986d548f45302a69d 100644 (file)
@@ -176,9 +176,9 @@ class CustomFieldsFilter(Filter):
             if fields_with_matching_selects.count() > 0:
                 for field in fields_with_matching_selects:
                     options = field.extra_data.get("select_options", [])
-                    for index, option in enumerate(options):
-                        if option.lower().find(value.lower()) != -1:
-                            option_ids.extend([index])
+                    for _, option in enumerate(options):
+                        if option.get("label").lower().find(value.lower()) != -1:
+                            option_ids.extend([option.get("id")])
             return (
                 qs.filter(custom_fields__field__name__icontains=value)
                 | qs.filter(custom_fields__value_text__icontains=value)
@@ -195,19 +195,21 @@ class CustomFieldsFilter(Filter):
             return qs
 
 
-class SelectField(serializers.IntegerField):
+class SelectField(serializers.CharField):
     def __init__(self, custom_field: CustomField):
         self._options = custom_field.extra_data["select_options"]
-        super().__init__(min_value=0, max_value=len(self._options))
+        super().__init__(max_length=16)
 
     def to_internal_value(self, data):
-        if not isinstance(data, int):
-            # If the supplied value is not an integer,
-            # we will try to map it to an option index.
-            try:
-                data = self._options.index(data)
-            except ValueError:
-                pass
+        # If the supplied value is the option label instead of the ID
+        try:
+            data = next(
+                option.get("id")
+                for option in self._options
+                if option.get("label") == data
+            )
+        except StopIteration:
+            pass
         return super().to_internal_value(data)
 
 
index 22c626eba1a6c54f8b7b776a030aaed20b63adb5..f56159c811c4169486de148d3b5150e8a1865157 100644 (file)
@@ -34,7 +34,7 @@ from documents.settings import EXPORTER_ARCHIVE_NAME
 from documents.settings import EXPORTER_CRYPTO_SETTINGS_NAME
 from documents.settings import EXPORTER_FILE_NAME
 from documents.settings import EXPORTER_THUMBNAIL_NAME
-from documents.signals.handlers import update_cf_instance_documents
+from documents.signals.handlers import check_paths_and_prune_custom_fields
 from documents.signals.handlers import update_filename_and_move_files
 from documents.utils import copy_file_with_basic_stats
 from paperless import version
@@ -262,7 +262,7 @@ class Command(CryptMixin, BaseCommand):
             ),
             disable_signal(
                 post_save,
-                receiver=update_cf_instance_documents,
+                receiver=check_paths_and_prune_custom_fields,
                 sender=CustomField,
             ),
         ):
diff --git a/src/documents/migrations/1059_alter_customfieldinstance_value_select.py b/src/documents/migrations/1059_alter_customfieldinstance_value_select.py
new file mode 100644 (file)
index 0000000..00ab11f
--- /dev/null
@@ -0,0 +1,79 @@
+# Generated by Django 5.1.1 on 2024-11-13 05:14
+
+from django.db import migrations
+from django.db import models
+from django.db import transaction
+from django.utils.crypto import get_random_string
+
+
+def migrate_customfield_selects(apps, schema_editor):
+    """
+    Migrate the custom field selects from a simple list of strings to a list of dictionaries with
+    label and id. Then update all instances of the custom field to use the new format.
+    """
+    CustomFieldInstance = apps.get_model("documents", "CustomFieldInstance")
+    CustomField = apps.get_model("documents", "CustomField")
+
+    with transaction.atomic():
+        for custom_field in CustomField.objects.filter(
+            data_type="select",
+        ):  # CustomField.FieldDataType.SELECT
+            old_select_options = custom_field.extra_data["select_options"]
+            custom_field.extra_data["select_options"] = [
+                {"id": get_random_string(16), "label": value}
+                for value in old_select_options
+            ]
+            custom_field.save()
+
+            for instance in CustomFieldInstance.objects.filter(field=custom_field):
+                if instance.value_select:
+                    instance.value_select = custom_field.extra_data["select_options"][
+                        int(instance.value_select)
+                    ]["id"]
+                    instance.save()
+
+
+def reverse_migrate_customfield_selects(apps, schema_editor):
+    """
+    Reverse the migration of the custom field selects from a list of dictionaries with label and id
+    to a simple list of strings. Then update all instances of the custom field to use the old format,
+    which is just the index of the selected option.
+    """
+    CustomFieldInstance = apps.get_model("documents", "CustomFieldInstance")
+    CustomField = apps.get_model("documents", "CustomField")
+
+    with transaction.atomic():
+        for custom_field in CustomField.objects.all():
+            if custom_field.data_type == "select":  # CustomField.FieldDataType.SELECT
+                old_select_options = custom_field.extra_data["select_options"]
+                custom_field.extra_data["select_options"] = [
+                    option["label"]
+                    for option in custom_field.extra_data["select_options"]
+                ]
+                custom_field.save()
+
+                for instance in CustomFieldInstance.objects.filter(field=custom_field):
+                    instance.value_select = next(
+                        index
+                        for index, option in enumerate(old_select_options)
+                        if option.get("id") == instance.value_select
+                    )
+                    instance.save()
+
+
+class Migration(migrations.Migration):
+    dependencies = [
+        ("documents", "1058_workflowtrigger_schedule_date_custom_field_and_more"),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name="customfieldinstance",
+            name="value_select",
+            field=models.CharField(max_length=16, null=True),
+        ),
+        migrations.RunPython(
+            migrate_customfield_selects,
+            reverse_migrate_customfield_selects,
+        ),
+    ]
index 6ba63a7e423eaf261b2beddafaf4cf4e2cdbb795..2eb5d817c7edc497520136901319783219243b38 100644 (file)
@@ -947,7 +947,7 @@ class CustomFieldInstance(SoftDeleteModel):
 
     value_document_ids = models.JSONField(null=True)
 
-    value_select = models.PositiveSmallIntegerField(null=True)
+    value_select = models.CharField(null=True, max_length=16)
 
     class Meta:
         ordering = ("created",)
@@ -962,7 +962,11 @@ class CustomFieldInstance(SoftDeleteModel):
 
     def __str__(self) -> str:
         value = (
-            self.field.extra_data["select_options"][self.value_select]
+            next(
+                option.get("label")
+                for option in self.field.extra_data["select_options"]
+                if option.get("id") == self.value_select
+            )
             if (
                 self.field.data_type == CustomField.FieldDataType.SELECT
                 and self.value_select is not None
index 74b705af309c00bcecad1b62342df61e890e735d..9ab9bf40eca4234c03657db150ffd7d24c6af916 100644 (file)
@@ -533,20 +533,27 @@ class CustomFieldSerializer(serializers.ModelSerializer):
         if (
             "data_type" in attrs
             and attrs["data_type"] == CustomField.FieldDataType.SELECT
-            and (
+        ) or (
+            self.instance
+            and self.instance.data_type == CustomField.FieldDataType.SELECT
+        ):
+            if (
                 "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
+                    len(option.get("label", "")) > 0
                     for option in attrs["extra_data"]["select_options"]
                 )
-            )
-        ):
-            raise serializers.ValidationError(
-                {"error": "extra_data.select_options must be a valid list"},
-            )
+            ):
+                raise serializers.ValidationError(
+                    {"error": "extra_data.select_options must be a valid list"},
+                )
+            # labels are valid, generate ids if not present
+            for option in attrs["extra_data"]["select_options"]:
+                if option.get("id") is None:
+                    option["id"] = get_random_string(length=16)
         elif (
             "data_type" in attrs
             and attrs["data_type"] == CustomField.FieldDataType.MONETARY
@@ -646,10 +653,14 @@ class CustomFieldInstanceSerializer(serializers.ModelSerializer):
             elif field.data_type == CustomField.FieldDataType.SELECT:
                 select_options = field.extra_data["select_options"]
                 try:
-                    select_options[data["value"]]
+                    next(
+                        option
+                        for option in select_options
+                        if option["id"] == data["value"]
+                    )
                 except Exception:
                     raise serializers.ValidationError(
-                        f"Value must be index of an element in {select_options}",
+                        f"Value must be an id of an element in {select_options}",
                     )
             elif field.data_type == CustomField.FieldDataType.DOCUMENTLINK:
                 doc_ids = data["value"]
index c6d6c40909826800e6ab4f9e9ee6a78d622614f3..853acdc15ba38cc06f151477018361eda35cbc1e 100644 (file)
@@ -368,21 +368,6 @@ class CannotMoveFilesException(Exception):
     pass
 
 
-# should be disabled in /src/documents/management/commands/document_importer.py handle
-@receiver(models.signals.post_save, sender=CustomField)
-def update_cf_instance_documents(sender, instance: CustomField, **kwargs):
-    """
-    'Select' custom field instances get their end-user value (e.g. in file names) from the select_options in extra_data,
-    which is contained in the custom field itself. So when the field is changed, we (may) need to update the file names
-    of all documents that have this custom field.
-    """
-    if (
-        instance.data_type == CustomField.FieldDataType.SELECT
-    ):  # Only select fields, for now
-        for cf_instance in instance.fields.all():
-            update_filename_and_move_files(sender, cf_instance)
-
-
 # should be disabled in /src/documents/management/commands/document_importer.py handle
 @receiver(models.signals.post_save, sender=CustomFieldInstance)
 @receiver(models.signals.m2m_changed, sender=Document.tags.through)
@@ -521,6 +506,34 @@ def update_filename_and_move_files(
             )
 
 
+# should be disabled in /src/documents/management/commands/document_importer.py handle
+@receiver(models.signals.post_save, sender=CustomField)
+def check_paths_and_prune_custom_fields(sender, instance: CustomField, **kwargs):
+    """
+    When a custom field is updated:
+    1. 'Select' custom field instances get their end-user value (e.g. in file names) from the select_options in extra_data,
+    which is contained in the custom field itself. So when the field is changed, we (may) need to update the file names
+    of all documents that have this custom field.
+    2. If a 'Select' field option was removed, we need to nullify the custom field instances that have the option.
+    """
+    if (
+        instance.data_type == CustomField.FieldDataType.SELECT
+    ):  # Only select fields, for now
+        for cf_instance in instance.fields.all():
+            options = instance.extra_data.get("select_options", [])
+            try:
+                next(
+                    option["label"]
+                    for option in options
+                    if option["id"] == cf_instance.value
+                )
+            except StopIteration:
+                # The value of this custom field instance is not in the select options anymore
+                cf_instance.value_select = None
+                cf_instance.save()
+            update_filename_and_move_files(sender, cf_instance)
+
+
 def set_log_entry(sender, document: Document, logging_group=None, **kwargs):
     ct = ContentType.objects.get(model="document")
     user = User.objects.get(username="consumer")
index 108ad0c814a54df7725cf24b49adc25e9949ff9e..cbe621d77a9e87721eefa91871734b8f98e2380e 100644 (file)
@@ -253,7 +253,11 @@ def get_custom_fields_context(
         ):
             options = field_instance.field.extra_data["select_options"]
             value = pathvalidate.sanitize_filename(
-                options[int(field_instance.value)],
+                next(
+                    option["label"]
+                    for option in options
+                    if option["id"] == field_instance.value
+                ),
                 replacement_text="-",
             )
         else:
index 02e856c2754ee1eff1246773adb58bfc232a5e2f..11911f6abfb563d0192c426c2a590cfb32511fe5 100644 (file)
@@ -1,5 +1,6 @@
 import json
 from datetime import date
+from unittest.mock import ANY
 
 from django.contrib.auth.models import Permission
 from django.contrib.auth.models import User
@@ -61,7 +62,10 @@ class TestCustomFieldsAPI(DirectoriesMixin, APITestCase):
                     "data_type": "select",
                     "name": "Select Field",
                     "extra_data": {
-                        "select_options": ["Option 1", "Option 2"],
+                        "select_options": [
+                            {"label": "Option 1", "id": "abc-123"},
+                            {"label": "Option 2", "id": "def-456"},
+                        ],
                     },
                 },
             ),
@@ -73,7 +77,10 @@ class TestCustomFieldsAPI(DirectoriesMixin, APITestCase):
 
         self.assertCountEqual(
             data["extra_data"]["select_options"],
-            ["Option 1", "Option 2"],
+            [
+                {"label": "Option 1", "id": "abc-123"},
+                {"label": "Option 2", "id": "def-456"},
+            ],
         )
 
     def test_create_custom_field_nonunique_name(self):
@@ -138,6 +145,133 @@ class TestCustomFieldsAPI(DirectoriesMixin, APITestCase):
         )
         self.assertEqual(resp.status_code, status.HTTP_400_BAD_REQUEST)
 
+    def test_custom_field_select_unique_ids(self):
+        """
+        GIVEN:
+            - Nothing
+            - Existing custom field
+        WHEN:
+            - API request to create custom field with select options without id
+        THEN:
+            - Unique ids are generated for each option
+        """
+        resp = self.client.post(
+            self.ENDPOINT,
+            json.dumps(
+                {
+                    "data_type": "select",
+                    "name": "Select Field",
+                    "extra_data": {
+                        "select_options": [
+                            {"label": "Option 1"},
+                            {"label": "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"],
+            [
+                {"label": "Option 1", "id": ANY},
+                {"label": "Option 2", "id": ANY},
+            ],
+        )
+
+        # Add a new option
+        resp = self.client.patch(
+            f"{self.ENDPOINT}{data['id']}/",
+            json.dumps(
+                {
+                    "extra_data": {
+                        "select_options": data["extra_data"]["select_options"]
+                        + [{"label": "Option 3"}],
+                    },
+                },
+            ),
+            content_type="application/json",
+        )
+        self.assertEqual(resp.status_code, status.HTTP_200_OK)
+
+        data = resp.json()
+
+        self.assertCountEqual(
+            data["extra_data"]["select_options"],
+            [
+                {"label": "Option 1", "id": ANY},
+                {"label": "Option 2", "id": ANY},
+                {"label": "Option 3", "id": ANY},
+            ],
+        )
+
+    def test_custom_field_select_options_pruned(self):
+        """
+        GIVEN:
+            - Select custom field exists and document instance with one of the options
+        WHEN:
+            - API request to remove an option from the select field
+        THEN:
+            - The option is removed from the field
+            - The option is removed from the document instance
+        """
+        custom_field_select = CustomField.objects.create(
+            name="Select Field",
+            data_type=CustomField.FieldDataType.SELECT,
+            extra_data={
+                "select_options": [
+                    {"label": "Option 1", "id": "abc-123"},
+                    {"label": "Option 2", "id": "def-456"},
+                    {"label": "Option 3", "id": "ghi-789"},
+                ],
+            },
+        )
+
+        doc = Document.objects.create(
+            title="WOW",
+            content="the content",
+            checksum="123",
+            mime_type="application/pdf",
+        )
+        CustomFieldInstance.objects.create(
+            document=doc,
+            field=custom_field_select,
+            value_text="abc-123",
+        )
+
+        resp = self.client.patch(
+            f"{self.ENDPOINT}{custom_field_select.id}/",
+            json.dumps(
+                {
+                    "extra_data": {
+                        "select_options": [
+                            {"label": "Option 1", "id": "abc-123"},
+                            {"label": "Option 3", "id": "ghi-789"},
+                        ],
+                    },
+                },
+            ),
+            content_type="application/json",
+        )
+        self.assertEqual(resp.status_code, status.HTTP_200_OK)
+
+        data = resp.json()
+
+        self.assertCountEqual(
+            data["extra_data"]["select_options"],
+            [
+                {"label": "Option 1", "id": "abc-123"},
+                {"label": "Option 3", "id": "ghi-789"},
+            ],
+        )
+
+        doc.refresh_from_db()
+        self.assertEqual(doc.custom_fields.first().value, None)
+
     def test_create_custom_field_monetary_validation(self):
         """
         GIVEN:
@@ -261,7 +395,10 @@ class TestCustomFieldsAPI(DirectoriesMixin, APITestCase):
             name="Test Custom Field Select",
             data_type=CustomField.FieldDataType.SELECT,
             extra_data={
-                "select_options": ["Option 1", "Option 2"],
+                "select_options": [
+                    {"label": "Option 1", "id": "abc-123"},
+                    {"label": "Option 2", "id": "def-456"},
+                ],
             },
         )
 
@@ -309,7 +446,7 @@ class TestCustomFieldsAPI(DirectoriesMixin, APITestCase):
                     },
                     {
                         "field": custom_field_select.id,
-                        "value": 0,
+                        "value": "abc-123",
                     },
                 ],
             },
@@ -332,7 +469,7 @@ 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},
+                {"field": custom_field_select.id, "value": "abc-123"},
             ],
         )
 
@@ -722,7 +859,10 @@ class TestCustomFieldsAPI(DirectoriesMixin, APITestCase):
             name="Test Custom Field SELECT",
             data_type=CustomField.FieldDataType.SELECT,
             extra_data={
-                "select_options": ["Option 1", "Option 2"],
+                "select_options": [
+                    {"label": "Option 1", "id": "abc-123"},
+                    {"label": "Option 2", "id": "def-456"},
+                ],
             },
         )
 
@@ -730,7 +870,7 @@ class TestCustomFieldsAPI(DirectoriesMixin, APITestCase):
             f"/api/documents/{doc.id}/",
             data={
                 "custom_fields": [
-                    {"field": custom_field_select.id, "value": 3},
+                    {"field": custom_field_select.id, "value": "not an option"},
                 ],
             },
             format="json",
index 08d86d24e977c12e29fa75d5219899e7deaca2de..8307d6c4ceade4e9d0b07b607fcef7d2311fb201 100644 (file)
@@ -657,13 +657,16 @@ class TestDocumentApi(DirectoriesMixin, DocumentConsumeDelayMixin, APITestCase):
             name="Test Custom Field Select",
             data_type=CustomField.FieldDataType.SELECT,
             extra_data={
-                "select_options": ["Option 1", "Choice 2"],
+                "select_options": [
+                    {"label": "Option 1", "id": "abc123"},
+                    {"label": "Choice 2", "id": "def456"},
+                ],
             },
         )
         CustomFieldInstance.objects.create(
             document=doc1,
             field=custom_field_select,
-            value_select=1,
+            value_select="def456",
         )
 
         r = self.client.get("/api/documents/?custom_fields__icontains=choice")
index 4cba291525d21b6c06892f526c2ab84497ca2122..c7e9092ed3d3c78ee181ab27aa9f72305b612e43 100644 (file)
@@ -46,7 +46,13 @@ class TestCustomFieldsSearch(DirectoriesMixin, APITestCase):
 
         # Add some options to the select_field
         select = self.custom_fields["select_field"]
-        select.extra_data = {"select_options": ["A", "B", "C"]}
+        select.extra_data = {
+            "select_options": [
+                {"label": "A", "id": "abc-123"},
+                {"label": "B", "id": "def-456"},
+                {"label": "C", "id": "ghi-789"},
+            ],
+        }
         select.save()
 
         # Now we will create some test documents
@@ -122,9 +128,9 @@ class TestCustomFieldsSearch(DirectoriesMixin, APITestCase):
 
         # CustomField.FieldDataType.SELECT
         self._create_document(select_field=None)
-        self._create_document(select_field=0)
-        self._create_document(select_field=1)
-        self._create_document(select_field=2)
+        self._create_document(select_field="abc-123")
+        self._create_document(select_field="def-456")
+        self._create_document(select_field="ghi-789")
 
     def _create_document(self, **kwargs):
         title = str(kwargs)
@@ -296,18 +302,18 @@ class TestCustomFieldsSearch(DirectoriesMixin, APITestCase):
         )
 
     def test_select(self):
-        # For select fields, you can either specify the index
+        # For select fields, you can either specify the id of the option
         # or the name of the option. They function exactly the same.
         self._assert_query_match_predicate(
-            ["select_field", "exact", 1],
+            ["select_field", "exact", "def-456"],
             lambda document: "select_field" in document
-            and document["select_field"] == 1,
+            and document["select_field"] == "def-456",
         )
         # This is the same as:
         self._assert_query_match_predicate(
             ["select_field", "exact", "B"],
             lambda document: "select_field" in document
-            and document["select_field"] == 1,
+            and document["select_field"] == "def-456",
         )
 
     # ==========================================================#
@@ -522,9 +528,9 @@ class TestCustomFieldsSearch(DirectoriesMixin, APITestCase):
 
     def test_invalid_value(self):
         self._assert_validation_error(
-            json.dumps(["select_field", "exact", "not an option"]),
+            json.dumps(["select_field", "exact", []]),
             ["custom_field_query", "2"],
-            "integer",
+            "string",
         )
 
     def test_invalid_logical_operator(self):
index 476068a5193e86b42eb7587614db188e695265ce..2ec388501b09ab316aef5192281aefd57a8027b8 100644 (file)
@@ -544,7 +544,11 @@ class TestFileHandling(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
             name="test",
             data_type=CustomField.FieldDataType.SELECT,
             extra_data={
-                "select_options": ["apple", "banana", "cherry"],
+                "select_options": [
+                    {"label": "apple", "id": "abc123"},
+                    {"label": "banana", "id": "def456"},
+                    {"label": "cherry", "id": "ghi789"},
+                ],
             },
         )
         doc = Document.objects.create(
@@ -555,14 +559,22 @@ class TestFileHandling(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
             archive_checksum="B",
             mime_type="application/pdf",
         )
-        CustomFieldInstance.objects.create(field=cf, document=doc, value_select=0)
+        CustomFieldInstance.objects.create(
+            field=cf,
+            document=doc,
+            value_select="abc123",
+        )
 
         self.assertEqual(generate_filename(doc), "document_apple.pdf")
 
         # handler should not have been called
         self.assertEqual(m.call_count, 0)
         cf.extra_data = {
-            "select_options": ["aubergine", "banana", "cherry"],
+            "select_options": [
+                {"label": "aubergine", "id": "abc123"},
+                {"label": "banana", "id": "def456"},
+                {"label": "cherry", "id": "ghi789"},
+            ],
         }
         cf.save()
         self.assertEqual(generate_filename(doc), "document_aubergine.pdf")
@@ -1373,13 +1385,18 @@ class TestFilenameGeneration(DirectoriesMixin, TestCase):
         cf2 = CustomField.objects.create(
             name="Select Field",
             data_type=CustomField.FieldDataType.SELECT,
-            extra_data={"select_options": ["ChoiceOne", "ChoiceTwo"]},
+            extra_data={
+                "select_options": [
+                    {"label": "ChoiceOne", "id": "abc=123"},
+                    {"label": "ChoiceTwo", "id": "def-456"},
+                ],
+            },
         )
 
         cfi1 = CustomFieldInstance.objects.create(
             document=doc_a,
             field=cf2,
-            value_select=0,
+            value_select="abc=123",
         )
 
         cfi = CustomFieldInstance.objects.create(
diff --git a/src/documents/tests/test_migration_custom_field_selects.py b/src/documents/tests/test_migration_custom_field_selects.py
new file mode 100644 (file)
index 0000000..b172bf7
--- /dev/null
@@ -0,0 +1,87 @@
+from unittest.mock import ANY
+
+from documents.tests.utils import TestMigrations
+
+
+class TestMigrateCustomFieldSelects(TestMigrations):
+    migrate_from = "1058_workflowtrigger_schedule_date_custom_field_and_more"
+    migrate_to = "1059_alter_customfieldinstance_value_select"
+
+    def setUpBeforeMigration(self, apps):
+        CustomField = apps.get_model("documents.CustomField")
+        self.old_format = CustomField.objects.create(
+            name="cf1",
+            data_type="select",
+            extra_data={"select_options": ["Option 1", "Option 2", "Option 3"]},
+        )
+        Document = apps.get_model("documents.Document")
+        doc = Document.objects.create(title="doc1")
+        CustomFieldInstance = apps.get_model("documents.CustomFieldInstance")
+        self.old_instance = CustomFieldInstance.objects.create(
+            field=self.old_format,
+            value_select=0,
+            document=doc,
+        )
+
+    def test_migrate_old_to_new_select_fields(self):
+        self.old_format.refresh_from_db()
+        self.old_instance.refresh_from_db()
+
+        self.assertEqual(
+            self.old_format.extra_data["select_options"],
+            [
+                {"label": "Option 1", "id": ANY},
+                {"label": "Option 2", "id": ANY},
+                {"label": "Option 3", "id": ANY},
+            ],
+        )
+
+        self.assertEqual(
+            self.old_instance.value_select,
+            self.old_format.extra_data["select_options"][0]["id"],
+        )
+
+
+class TestMigrationCustomFieldSelectsReverse(TestMigrations):
+    migrate_from = "1059_alter_customfieldinstance_value_select"
+    migrate_to = "1058_workflowtrigger_schedule_date_custom_field_and_more"
+
+    def setUpBeforeMigration(self, apps):
+        CustomField = apps.get_model("documents.CustomField")
+        self.new_format = CustomField.objects.create(
+            name="cf1",
+            data_type="select",
+            extra_data={
+                "select_options": [
+                    {"label": "Option 1", "id": "id1"},
+                    {"label": "Option 2", "id": "id2"},
+                    {"label": "Option 3", "id": "id3"},
+                ],
+            },
+        )
+        Document = apps.get_model("documents.Document")
+        doc = Document.objects.create(title="doc1")
+        CustomFieldInstance = apps.get_model("documents.CustomFieldInstance")
+        self.new_instance = CustomFieldInstance.objects.create(
+            field=self.new_format,
+            value_select="id1",
+            document=doc,
+        )
+
+    def test_migrate_new_to_old_select_fields(self):
+        self.new_format.refresh_from_db()
+        self.new_instance.refresh_from_db()
+
+        self.assertEqual(
+            self.new_format.extra_data["select_options"],
+            [
+                "Option 1",
+                "Option 2",
+                "Option 3",
+            ],
+        )
+
+        self.assertEqual(
+            self.new_instance.value_select,
+            0,
+        )