Enhancement: use stable unique IDs for custom field select options (#8299)

This commit is contained in:
shamoon 2024-12-01 20:15:38 -08:00 committed by GitHub
parent 00485138f9
commit 0fc1860d4c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
24 changed files with 494 additions and 101 deletions

View File

@ -17,7 +17,11 @@ const customFields: CustomField[] = [
name: 'Field 4', name: 'Field 4',
data_type: CustomFieldDataType.Select, data_type: CustomFieldDataType.Select,
extra_data: { 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', () => { it('should show select value', () => {
expect(component.getSelectValue(customFields[3], 2)).toEqual('Option 3') expect(component.getSelectValue(customFields[3], 'ghi-789')).toEqual(
'Option 3'
)
}) })
}) })

View File

@ -117,8 +117,8 @@ export class CustomFieldDisplayComponent implements OnInit, OnDestroy {
return this.docLinkDocuments?.find((d) => d.id === docId)?.title return this.docLinkDocuments?.find((d) => d.id === docId)?.title
} }
public getSelectValue(field: CustomField, index: number): string { public getSelectValue(field: CustomField, id: string): string {
return field.extra_data.select_options[index] return field.extra_data.select_options?.find((o) => o.id === id)?.label
} }
ngOnDestroy(): void { ngOnDestroy(): void {

View File

@ -44,6 +44,8 @@
<ng-select #fieldSelects <ng-select #fieldSelects
class="paperless-input-select rounded-end" class="paperless-input-select rounded-end"
[items]="getSelectOptionsForField(atom.field)" [items]="getSelectOptionsForField(atom.field)"
bindLabel="label"
bindValue="id"
[(ngModel)]="atom.value" [(ngModel)]="atom.value"
[disabled]="disabled" [disabled]="disabled"
(mousedown)="$event.stopImmediatePropagation()" (mousedown)="$event.stopImmediatePropagation()"
@ -99,6 +101,8 @@
<ng-select <ng-select
class="paperless-input-select rounded-end" class="paperless-input-select rounded-end"
[items]="getSelectOptionsForField(atom.field)" [items]="getSelectOptionsForField(atom.field)"
bindLabel="label"
bindValue="id"
[(ngModel)]="atom.value" [(ngModel)]="atom.value"
[disabled]="disabled" [disabled]="disabled"
[multiple]="true" [multiple]="true"

View File

@ -39,7 +39,12 @@ const customFields = [
id: 2, id: 2,
name: 'Test Select Field', name: 'Test Select Field',
data_type: CustomFieldDataType.Select, 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, id: 1,
name: 'Test Field', name: 'Test Field',
data_type: CustomFieldDataType.Select, 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] component.customFields = [field]
const options = component.getSelectOptionsForField(1) 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 // Fallback to empty array if field is not found
const options2 = component.getSelectOptionsForField(2) const options2 = component.getSelectOptionsForField(2)

View 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) const field = this.customFields.find((field) => field.id === fieldID)
if (field) { if (field) {
return field.extra_data['select_options'] return field.extra_data['select_options']

View File

@ -21,8 +21,9 @@
</button> </button>
<div formArrayName="select_options"> <div formArrayName="select_options">
@for (option of objectForm.controls.extra_data.controls.select_options.controls; track option; let i = $index) { @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"> <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" [formControl]="option" autocomplete="off"> <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> <button type="button" class="btn btn-outline-danger" (click)="removeSelectOption(i)" i18n>Delete</button>
</div> </div>
} }

View File

@ -80,7 +80,11 @@ describe('CustomFieldEditDialogComponent', () => {
name: 'Field 1', name: 'Field 1',
data_type: CustomFieldDataType.Select, data_type: CustomFieldDataType.Select,
extra_data: { 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() fixture.detectChanges()
@ -94,6 +98,10 @@ describe('CustomFieldEditDialogComponent', () => {
component.dialogMode = EditDialogMode.CREATE component.dialogMode = EditDialogMode.CREATE
fixture.detectChanges() fixture.detectChanges()
component.ngOnInit() component.ngOnInit()
expect(
component.objectForm.get('extra_data').get('select_options').value.length
).toBe(0)
component.addSelectOption()
expect( expect(
component.objectForm.get('extra_data').get('select_options').value.length component.objectForm.get('extra_data').get('select_options').value.length
).toBe(1) ).toBe(1)
@ -101,14 +109,10 @@ describe('CustomFieldEditDialogComponent', () => {
expect( expect(
component.objectForm.get('extra_data').get('select_options').value.length component.objectForm.get('extra_data').get('select_options').value.length
).toBe(2) ).toBe(2)
component.addSelectOption()
expect(
component.objectForm.get('extra_data').get('select_options').value.length
).toBe(3)
component.removeSelectOption(0) component.removeSelectOption(0)
expect( expect(
component.objectForm.get('extra_data').get('select_options').value.length component.objectForm.get('extra_data').get('select_options').value.length
).toBe(2) ).toBe(1)
}) })
it('should focus on last select option input', () => { it('should focus on last select option input', () => {

View File

@ -57,8 +57,15 @@ export class CustomFieldEditDialogComponent
} }
if (this.object?.data_type === CustomFieldDataType.Select) { if (this.object?.data_type === CustomFieldDataType.Select) {
this.selectOptions.clear() this.selectOptions.clear()
this.object.extra_data.select_options.forEach((option) => this.object.extra_data.select_options
this.selectOptions.push(new FormControl(option)) .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), name: new FormControl(null),
data_type: new FormControl(null), data_type: new FormControl(null),
extra_data: new FormGroup({ extra_data: new FormGroup({
select_options: new FormArray([new FormControl(null)]), select_options: new FormArray([]),
default_currency: new FormControl(null), default_currency: new FormControl(null),
}), }),
}) })
@ -104,7 +111,9 @@ export class CustomFieldEditDialogComponent
} }
public addSelectOption() { public addSelectOption() {
this.selectOptions.push(new FormControl('')) this.selectOptions.push(
new FormGroup({ label: new FormControl(null), id: new FormControl(null) })
)
} }
public removeSelectOption(index: number) { public removeSelectOption(index: number) {

View File

@ -132,12 +132,4 @@ describe('SelectComponent', () => {
const expectedTitle = `Filter documents with this ${component.title}` const expectedTitle = `Filter documents with this ${component.title}`
expect(component.filterButtonTitle).toEqual(expectedTitle) 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' },
])
})
}) })

View File

@ -34,11 +34,6 @@ export class SelectComponent extends AbstractInputComponent<number> {
if (items && this.value) this.checkForPrivateItems(this.value) 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 { writeValue(newValue: any): void {
if (newValue && this._items) { if (newValue && this._items) {
this.checkForPrivateItems(newValue) this.checkForPrivateItems(newValue)

View File

@ -190,7 +190,8 @@
@case (CustomFieldDataType.Select) { @case (CustomFieldDataType.Select) {
<pngx-input-select formControlName="value" <pngx-input-select formControlName="value"
[title]="getCustomFieldFromInstance(fieldInstance)?.name" [title]="getCustomFieldFromInstance(fieldInstance)?.name"
[itemsArray]="getCustomFieldFromInstance(fieldInstance)?.extra_data.select_options" [items]="getCustomFieldFromInstance(fieldInstance)?.extra_data.select_options"
bindLabel="label"
[allowNull]="true" [allowNull]="true"
[horizontal]="true" [horizontal]="true"
[removable]="userIsOwner" [removable]="userIsOwner"

View File

@ -56,7 +56,7 @@ export interface CustomField extends ObjectWithId {
name: string name: string
created?: Date created?: Date
extra_data?: { extra_data?: {
select_options?: string[] select_options?: Array<{ label: string; id: string }>
default_currency?: string default_currency?: string
} }
document_count?: number document_count?: number

View File

@ -176,9 +176,9 @@ class CustomFieldsFilter(Filter):
if fields_with_matching_selects.count() > 0: if fields_with_matching_selects.count() > 0:
for field in fields_with_matching_selects: for field in fields_with_matching_selects:
options = field.extra_data.get("select_options", []) options = field.extra_data.get("select_options", [])
for index, option in enumerate(options): for _, option in enumerate(options):
if option.lower().find(value.lower()) != -1: if option.get("label").lower().find(value.lower()) != -1:
option_ids.extend([index]) option_ids.extend([option.get("id")])
return ( return (
qs.filter(custom_fields__field__name__icontains=value) qs.filter(custom_fields__field__name__icontains=value)
| qs.filter(custom_fields__value_text__icontains=value) | qs.filter(custom_fields__value_text__icontains=value)
@ -195,18 +195,20 @@ class CustomFieldsFilter(Filter):
return qs return qs
class SelectField(serializers.IntegerField): class SelectField(serializers.CharField):
def __init__(self, custom_field: CustomField): def __init__(self, custom_field: CustomField):
self._options = custom_field.extra_data["select_options"] 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): def to_internal_value(self, data):
if not isinstance(data, int): # If the supplied value is the option label instead of the ID
# If the supplied value is not an integer,
# we will try to map it to an option index.
try: try:
data = self._options.index(data) data = next(
except ValueError: option.get("id")
for option in self._options
if option.get("label") == data
)
except StopIteration:
pass pass
return super().to_internal_value(data) return super().to_internal_value(data)

View 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_CRYPTO_SETTINGS_NAME
from documents.settings import EXPORTER_FILE_NAME from documents.settings import EXPORTER_FILE_NAME
from documents.settings import EXPORTER_THUMBNAIL_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.signals.handlers import update_filename_and_move_files
from documents.utils import copy_file_with_basic_stats from documents.utils import copy_file_with_basic_stats
from paperless import version from paperless import version
@ -262,7 +262,7 @@ class Command(CryptMixin, BaseCommand):
), ),
disable_signal( disable_signal(
post_save, post_save,
receiver=update_cf_instance_documents, receiver=check_paths_and_prune_custom_fields,
sender=CustomField, sender=CustomField,
), ),
): ):

View File

@ -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,
),
]

View File

@ -947,7 +947,7 @@ class CustomFieldInstance(SoftDeleteModel):
value_document_ids = models.JSONField(null=True) value_document_ids = models.JSONField(null=True)
value_select = models.PositiveSmallIntegerField(null=True) value_select = models.CharField(null=True, max_length=16)
class Meta: class Meta:
ordering = ("created",) ordering = ("created",)
@ -962,7 +962,11 @@ class CustomFieldInstance(SoftDeleteModel):
def __str__(self) -> str: def __str__(self) -> str:
value = ( 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 ( if (
self.field.data_type == CustomField.FieldDataType.SELECT self.field.data_type == CustomField.FieldDataType.SELECT
and self.value_select is not None and self.value_select is not None

View File

@ -533,20 +533,27 @@ class CustomFieldSerializer(serializers.ModelSerializer):
if ( if (
"data_type" in attrs "data_type" in attrs
and attrs["data_type"] == CustomField.FieldDataType.SELECT 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 "extra_data" not in attrs
or "select_options" not in attrs["extra_data"] or "select_options" not in attrs["extra_data"]
or not isinstance(attrs["extra_data"]["select_options"], list) or not isinstance(attrs["extra_data"]["select_options"], list)
or len(attrs["extra_data"]["select_options"]) == 0 or len(attrs["extra_data"]["select_options"]) == 0
or not all( or not all(
isinstance(option, str) and len(option) > 0 len(option.get("label", "")) > 0
for option in attrs["extra_data"]["select_options"] for option in attrs["extra_data"]["select_options"]
) )
)
): ):
raise serializers.ValidationError( raise serializers.ValidationError(
{"error": "extra_data.select_options must be a valid list"}, {"error": "extra_data.select_options must be a valid list"},
) )
# labels are valid, generate ids if not present
for option in attrs["extra_data"]["select_options"]:
if option.get("id") is None:
option["id"] = get_random_string(length=16)
elif ( elif (
"data_type" in attrs "data_type" in attrs
and attrs["data_type"] == CustomField.FieldDataType.MONETARY and attrs["data_type"] == CustomField.FieldDataType.MONETARY
@ -646,10 +653,14 @@ class CustomFieldInstanceSerializer(serializers.ModelSerializer):
elif field.data_type == CustomField.FieldDataType.SELECT: elif field.data_type == CustomField.FieldDataType.SELECT:
select_options = field.extra_data["select_options"] select_options = field.extra_data["select_options"]
try: try:
select_options[data["value"]] next(
option
for option in select_options
if option["id"] == data["value"]
)
except Exception: except Exception:
raise serializers.ValidationError( 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: elif field.data_type == CustomField.FieldDataType.DOCUMENTLINK:
doc_ids = data["value"] doc_ids = data["value"]

View File

@ -368,21 +368,6 @@ class CannotMoveFilesException(Exception):
pass 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 # should be disabled in /src/documents/management/commands/document_importer.py handle
@receiver(models.signals.post_save, sender=CustomFieldInstance) @receiver(models.signals.post_save, sender=CustomFieldInstance)
@receiver(models.signals.m2m_changed, sender=Document.tags.through) @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): def set_log_entry(sender, document: Document, logging_group=None, **kwargs):
ct = ContentType.objects.get(model="document") ct = ContentType.objects.get(model="document")
user = User.objects.get(username="consumer") user = User.objects.get(username="consumer")

View File

@ -253,7 +253,11 @@ def get_custom_fields_context(
): ):
options = field_instance.field.extra_data["select_options"] options = field_instance.field.extra_data["select_options"]
value = pathvalidate.sanitize_filename( value = pathvalidate.sanitize_filename(
options[int(field_instance.value)], next(
option["label"]
for option in options
if option["id"] == field_instance.value
),
replacement_text="-", replacement_text="-",
) )
else: else:

View File

@ -1,5 +1,6 @@
import json import json
from datetime import date from datetime import date
from unittest.mock import ANY
from django.contrib.auth.models import Permission from django.contrib.auth.models import Permission
from django.contrib.auth.models import User from django.contrib.auth.models import User
@ -61,7 +62,10 @@ class TestCustomFieldsAPI(DirectoriesMixin, APITestCase):
"data_type": "select", "data_type": "select",
"name": "Select Field", "name": "Select Field",
"extra_data": { "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( self.assertCountEqual(
data["extra_data"]["select_options"], 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): 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) 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): def test_create_custom_field_monetary_validation(self):
""" """
GIVEN: GIVEN:
@ -261,7 +395,10 @@ class TestCustomFieldsAPI(DirectoriesMixin, APITestCase):
name="Test Custom Field Select", name="Test Custom Field Select",
data_type=CustomField.FieldDataType.SELECT, data_type=CustomField.FieldDataType.SELECT,
extra_data={ 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, "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_monetary.id, "value": "EUR11.10"},
{"field": custom_field_monetary2.id, "value": "11.1"}, {"field": custom_field_monetary2.id, "value": "11.1"},
{"field": custom_field_documentlink.id, "value": [doc2.id]}, {"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", name="Test Custom Field SELECT",
data_type=CustomField.FieldDataType.SELECT, data_type=CustomField.FieldDataType.SELECT,
extra_data={ 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}/", f"/api/documents/{doc.id}/",
data={ data={
"custom_fields": [ "custom_fields": [
{"field": custom_field_select.id, "value": 3}, {"field": custom_field_select.id, "value": "not an option"},
], ],
}, },
format="json", format="json",

View File

@ -657,13 +657,16 @@ class TestDocumentApi(DirectoriesMixin, DocumentConsumeDelayMixin, APITestCase):
name="Test Custom Field Select", name="Test Custom Field Select",
data_type=CustomField.FieldDataType.SELECT, data_type=CustomField.FieldDataType.SELECT,
extra_data={ extra_data={
"select_options": ["Option 1", "Choice 2"], "select_options": [
{"label": "Option 1", "id": "abc123"},
{"label": "Choice 2", "id": "def456"},
],
}, },
) )
CustomFieldInstance.objects.create( CustomFieldInstance.objects.create(
document=doc1, document=doc1,
field=custom_field_select, field=custom_field_select,
value_select=1, value_select="def456",
) )
r = self.client.get("/api/documents/?custom_fields__icontains=choice") r = self.client.get("/api/documents/?custom_fields__icontains=choice")

View File

@ -46,7 +46,13 @@ class TestCustomFieldsSearch(DirectoriesMixin, APITestCase):
# Add some options to the select_field # Add some options to the select_field
select = self.custom_fields["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() select.save()
# Now we will create some test documents # Now we will create some test documents
@ -122,9 +128,9 @@ class TestCustomFieldsSearch(DirectoriesMixin, APITestCase):
# CustomField.FieldDataType.SELECT # CustomField.FieldDataType.SELECT
self._create_document(select_field=None) self._create_document(select_field=None)
self._create_document(select_field=0) self._create_document(select_field="abc-123")
self._create_document(select_field=1) self._create_document(select_field="def-456")
self._create_document(select_field=2) self._create_document(select_field="ghi-789")
def _create_document(self, **kwargs): def _create_document(self, **kwargs):
title = str(kwargs) title = str(kwargs)
@ -296,18 +302,18 @@ class TestCustomFieldsSearch(DirectoriesMixin, APITestCase):
) )
def test_select(self): 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. # or the name of the option. They function exactly the same.
self._assert_query_match_predicate( self._assert_query_match_predicate(
["select_field", "exact", 1], ["select_field", "exact", "def-456"],
lambda document: "select_field" in document lambda document: "select_field" in document
and document["select_field"] == 1, and document["select_field"] == "def-456",
) )
# This is the same as: # This is the same as:
self._assert_query_match_predicate( self._assert_query_match_predicate(
["select_field", "exact", "B"], ["select_field", "exact", "B"],
lambda document: "select_field" in document 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): def test_invalid_value(self):
self._assert_validation_error( self._assert_validation_error(
json.dumps(["select_field", "exact", "not an option"]), json.dumps(["select_field", "exact", []]),
["custom_field_query", "2"], ["custom_field_query", "2"],
"integer", "string",
) )
def test_invalid_logical_operator(self): def test_invalid_logical_operator(self):

View File

@ -544,7 +544,11 @@ class TestFileHandling(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
name="test", name="test",
data_type=CustomField.FieldDataType.SELECT, data_type=CustomField.FieldDataType.SELECT,
extra_data={ 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( doc = Document.objects.create(
@ -555,14 +559,22 @@ class TestFileHandling(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
archive_checksum="B", archive_checksum="B",
mime_type="application/pdf", 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") self.assertEqual(generate_filename(doc), "document_apple.pdf")
# handler should not have been called # handler should not have been called
self.assertEqual(m.call_count, 0) self.assertEqual(m.call_count, 0)
cf.extra_data = { cf.extra_data = {
"select_options": ["aubergine", "banana", "cherry"], "select_options": [
{"label": "aubergine", "id": "abc123"},
{"label": "banana", "id": "def456"},
{"label": "cherry", "id": "ghi789"},
],
} }
cf.save() cf.save()
self.assertEqual(generate_filename(doc), "document_aubergine.pdf") self.assertEqual(generate_filename(doc), "document_aubergine.pdf")
@ -1373,13 +1385,18 @@ class TestFilenameGeneration(DirectoriesMixin, TestCase):
cf2 = CustomField.objects.create( cf2 = CustomField.objects.create(
name="Select Field", name="Select Field",
data_type=CustomField.FieldDataType.SELECT, 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( cfi1 = CustomFieldInstance.objects.create(
document=doc_a, document=doc_a,
field=cf2, field=cf2,
value_select=0, value_select="abc=123",
) )
cfi = CustomFieldInstance.objects.create( cfi = CustomFieldInstance.objects.create(

View 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,
)