mirror of
				https://github.com/paperless-ngx/paperless-ngx.git
				synced 2025-11-03 03:16:10 -06:00 
			
		
		
		
	Enhancement: use stable unique IDs for custom field select options (#8299)
This commit is contained in:
		@@ -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'
 | 
			
		||||
    )
 | 
			
		||||
  })
 | 
			
		||||
})
 | 
			
		||||
 
 | 
			
		||||
@@ -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 {
 | 
			
		||||
 
 | 
			
		||||
@@ -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()"
 | 
			
		||||
@@ -99,6 +101,8 @@
 | 
			
		||||
        <ng-select
 | 
			
		||||
          class="paperless-input-select rounded-end"
 | 
			
		||||
          [items]="getSelectOptionsForField(atom.field)"
 | 
			
		||||
          bindLabel="label"
 | 
			
		||||
          bindValue="id"
 | 
			
		||||
          [(ngModel)]="atom.value"
 | 
			
		||||
          [disabled]="disabled"
 | 
			
		||||
          [multiple]="true"
 | 
			
		||||
 
 | 
			
		||||
@@ -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)
 | 
			
		||||
 
 | 
			
		||||
@@ -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']
 | 
			
		||||
 
 | 
			
		||||
@@ -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>
 | 
			
		||||
            }
 | 
			
		||||
 
 | 
			
		||||
@@ -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()
 | 
			
		||||
@@ -94,6 +98,10 @@ describe('CustomFieldEditDialogComponent', () => {
 | 
			
		||||
    component.dialogMode = EditDialogMode.CREATE
 | 
			
		||||
    fixture.detectChanges()
 | 
			
		||||
    component.ngOnInit()
 | 
			
		||||
    expect(
 | 
			
		||||
      component.objectForm.get('extra_data').get('select_options').value.length
 | 
			
		||||
    ).toBe(0)
 | 
			
		||||
    component.addSelectOption()
 | 
			
		||||
    expect(
 | 
			
		||||
      component.objectForm.get('extra_data').get('select_options').value.length
 | 
			
		||||
    ).toBe(1)
 | 
			
		||||
@@ -101,14 +109,10 @@ describe('CustomFieldEditDialogComponent', () => {
 | 
			
		||||
    expect(
 | 
			
		||||
      component.objectForm.get('extra_data').get('select_options').value.length
 | 
			
		||||
    ).toBe(2)
 | 
			
		||||
    component.addSelectOption()
 | 
			
		||||
    expect(
 | 
			
		||||
      component.objectForm.get('extra_data').get('select_options').value.length
 | 
			
		||||
    ).toBe(3)
 | 
			
		||||
    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', () => {
 | 
			
		||||
 
 | 
			
		||||
@@ -57,8 +57,15 @@ 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) {
 | 
			
		||||
 
 | 
			
		||||
@@ -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' },
 | 
			
		||||
    ])
 | 
			
		||||
  })
 | 
			
		||||
})
 | 
			
		||||
 
 | 
			
		||||
@@ -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)
 | 
			
		||||
 
 | 
			
		||||
@@ -190,7 +190,8 @@
 | 
			
		||||
                    @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"
 | 
			
		||||
 
 | 
			
		||||
@@ -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
 | 
			
		||||
 
 | 
			
		||||
@@ -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,18 +195,20 @@ 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.
 | 
			
		||||
        # If the supplied value is the option label instead of the ID
 | 
			
		||||
        try:
 | 
			
		||||
                data = self._options.index(data)
 | 
			
		||||
            except ValueError:
 | 
			
		||||
            data = next(
 | 
			
		||||
                option.get("id")
 | 
			
		||||
                for option in self._options
 | 
			
		||||
                if option.get("label") == data
 | 
			
		||||
            )
 | 
			
		||||
        except StopIteration:
 | 
			
		||||
            pass
 | 
			
		||||
        return super().to_internal_value(data)
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -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,
 | 
			
		||||
            ),
 | 
			
		||||
        ):
 | 
			
		||||
 
 | 
			
		||||
@@ -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,
 | 
			
		||||
        ),
 | 
			
		||||
    ]
 | 
			
		||||
@@ -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
 | 
			
		||||
 
 | 
			
		||||
@@ -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"},
 | 
			
		||||
                )
 | 
			
		||||
            # 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"]
 | 
			
		||||
 
 | 
			
		||||
@@ -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")
 | 
			
		||||
 
 | 
			
		||||
@@ -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:
 | 
			
		||||
 
 | 
			
		||||
@@ -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",
 | 
			
		||||
 
 | 
			
		||||
@@ -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")
 | 
			
		||||
 
 | 
			
		||||
@@ -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):
 | 
			
		||||
 
 | 
			
		||||
@@ -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(
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										87
									
								
								src/documents/tests/test_migration_custom_field_selects.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										87
									
								
								src/documents/tests/test_migration_custom_field_selects.py
									
									
									
									
									
										Normal file
									
								
							@@ -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,
 | 
			
		||||
        )
 | 
			
		||||
		Reference in New Issue
	
	Block a user