mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2025-05-01 11:19:32 -05:00
Merge branch 'dev' into feature-notification-wf-action
This commit is contained in:
commit
5d3966bd84
@ -48,7 +48,7 @@ repos:
|
||||
exclude: "(^Pipfile\\.lock$)"
|
||||
# Python hooks
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: 'v0.7.3'
|
||||
rev: 'v0.8.0'
|
||||
hooks:
|
||||
- id: ruff
|
||||
- id: ruff-format
|
||||
|
135
Pipfile.lock
generated
135
Pipfile.lock
generated
@ -1,7 +1,7 @@
|
||||
{
|
||||
"_meta": {
|
||||
"hash": {
|
||||
"sha256": "a194c6834fba6a14712ba36eb0b896f18d7ef4393523e5d55ccb103104e99ddb"
|
||||
"sha256": "e4cb2328c49829f56793ef25780dcc73ea8e4838e6e9bc25d1b6feb74eb3befe"
|
||||
},
|
||||
"pipfile-spec": 6,
|
||||
"requires": {},
|
||||
@ -3242,11 +3242,11 @@
|
||||
},
|
||||
"httpcore": {
|
||||
"hashes": [
|
||||
"sha256:27b59625743b85577a8c0e10e55b50b5368a4f2cfe8cc7bcfa9cf00829c2682f",
|
||||
"sha256:73f6dbd6eb8c21bbf7ef8efad555481853f5f6acdeaff1edb0694289269ee17f"
|
||||
"sha256:8551cb62a169ec7162ac7be8d4817d561f60e08eaa485234898414bb5a8a0b4c",
|
||||
"sha256:a3fff8f43dc260d5bd363d9f9cf1830fa3a458b332856f34282de498ed420edd"
|
||||
],
|
||||
"markers": "python_version >= '3.8'",
|
||||
"version": "==1.0.6"
|
||||
"version": "==1.0.7"
|
||||
},
|
||||
"httpx": {
|
||||
"extras": [
|
||||
@ -3311,7 +3311,6 @@
|
||||
"sha256:4a3aee7acbbe7303aede8e9648d13b8bf88a429282aa6122a993f0ac800cb369",
|
||||
"sha256:bc5dd2abb727a5319567b7a813e6a2e7318c39f4f487cfe6c89c6f9c7d25197d"
|
||||
],
|
||||
"index": "pypi",
|
||||
"markers": "python_version >= '3.7'",
|
||||
"version": "==3.1.4"
|
||||
},
|
||||
@ -3424,12 +3423,12 @@
|
||||
},
|
||||
"mkdocs-material": {
|
||||
"hashes": [
|
||||
"sha256:47015f9c167d58a5ff5e682da37441fc4d66a1c79334bfc08d774763cacf69ca",
|
||||
"sha256:f3a6c968e524166b3f3ed1fb97d3ed3e0091183b0545cedf7156a2a6804c56c0"
|
||||
"sha256:98f0a2039c62e551a68aad0791a8d41324ff90c03a6e6cea381a384b84908b83",
|
||||
"sha256:ae2043f4238e572f9a40e0b577f50400d6fc31e2fef8ea141800aebf3bd273d7"
|
||||
],
|
||||
"index": "pypi",
|
||||
"markers": "python_version >= '3.8'",
|
||||
"version": "==9.5.44"
|
||||
"version": "==9.5.46"
|
||||
},
|
||||
"mkdocs-material-extensions": {
|
||||
"hashes": [
|
||||
@ -3723,12 +3722,12 @@
|
||||
},
|
||||
"pytest-httpx": {
|
||||
"hashes": [
|
||||
"sha256:4af9ab0dae5e9c14cb1e27d18af3db1f627b2cf3b11c02b34ddf26aff6b0a24c",
|
||||
"sha256:bdd1b00a846cfe857194e4d3ba72dc08ba0d163154a4404269c9b971f357c05d"
|
||||
"sha256:3ca4b0975c0f93b985f17df19e76430c1086b5b0cce32b1af082d8901296a735",
|
||||
"sha256:42cf0a66f7b71b9111db2897e8b38a903abd33a27b11c48aff4a3c7650313af2"
|
||||
],
|
||||
"index": "pypi",
|
||||
"markers": "python_version >= '3.9'",
|
||||
"version": "==0.33.0"
|
||||
"version": "==0.34.0"
|
||||
},
|
||||
"pytest-mock": {
|
||||
"hashes": [
|
||||
@ -3741,12 +3740,12 @@
|
||||
},
|
||||
"pytest-rerunfailures": {
|
||||
"hashes": [
|
||||
"sha256:4197bdd2eaeffdbf50b5ea6e7236f47ff0e44d1def8dae08e409f536d84e7b32",
|
||||
"sha256:4a400bcbcd3c7a4ad151ab8afac123d90eca3abe27f98725dc4d9702887d2e92"
|
||||
"sha256:2d9ac7baf59f4c13ac730b47f6fa80e755d1ba0581da45ce30b72fb3542b4474",
|
||||
"sha256:dd150c4795c229ef44320adc9a0c0532c51b78bb7a6843a8c53556b9a611df1a"
|
||||
],
|
||||
"index": "pypi",
|
||||
"markers": "python_version >= '3.8'",
|
||||
"version": "==14.0"
|
||||
"markers": "python_version >= '3.9'",
|
||||
"version": "==15.0"
|
||||
},
|
||||
"pytest-sugar": {
|
||||
"hashes": [
|
||||
@ -3770,8 +3769,7 @@
|
||||
"sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3",
|
||||
"sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427"
|
||||
],
|
||||
"index": "pypi",
|
||||
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2'",
|
||||
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
|
||||
"version": "==2.9.0.post0"
|
||||
},
|
||||
"pywavelets": {
|
||||
@ -3993,28 +3991,28 @@
|
||||
},
|
||||
"ruff": {
|
||||
"hashes": [
|
||||
"sha256:10ebce7696afe4644e8c1a23b3cf8c0f2193a310c18387c06e583ae9ef284de2",
|
||||
"sha256:1713e2c5545863cdbfe2cbce21f69ffaf37b813bfd1fb3b90dc9a6f1963f5a8c",
|
||||
"sha256:34f2339dc22687ec7e7002792d1f50712bf84a13d5152e75712ac08be565d344",
|
||||
"sha256:37d0b619546103274e7f62643d14e1adcbccb242efda4e4bdb9544d7764782e9",
|
||||
"sha256:3f36d56326b3aef8eeee150b700e519880d1aab92f471eefdef656fd57492aa2",
|
||||
"sha256:44eb93c2499a169d49fafd07bc62ac89b1bc800b197e50ff4633aed212569299",
|
||||
"sha256:4ba81a5f0c5478aa61674c5a2194de8b02652f17addf8dfc40c8937e6e7d79fc",
|
||||
"sha256:588a9ff2fecf01025ed065fe28809cd5a53b43505f48b69a1ac7707b1b7e4088",
|
||||
"sha256:5d024301109a0007b78d57ab0ba190087b43dce852e552734ebf0b0b85e4fb16",
|
||||
"sha256:5d59f0c3ee4d1a6787614e7135b72e21024875266101142a09a61439cb6e38a5",
|
||||
"sha256:61b46049d6edc0e4317fb14b33bd693245281a3007288b68a3f5b74a22a0746d",
|
||||
"sha256:6b6224af8b5e09772c2ecb8dc9f3f344c1aa48201c7f07e7315367f6dd90ac29",
|
||||
"sha256:6d0242ce53f3a576c35ee32d907475a8d569944c0407f91d207c8af5be5dae4e",
|
||||
"sha256:7f3eff9961b5d2644bcf1616c606e93baa2d6b349e8aa8b035f654df252c8c67",
|
||||
"sha256:b8963cab06d130c4df2fd52c84e9f10d297826d2e8169ae0c798b6221be1d1d2",
|
||||
"sha256:c50f95a82b94421c964fae4c27c0242890a20fe67d203d127e84fbb8013855f5",
|
||||
"sha256:e1d1ba2e40b6e71a61b063354d04be669ab0d39c352461f3d789cac68b54a313",
|
||||
"sha256:fb397332a1879b9764a3455a0bb1087bda876c2db8aca3a3cbb67b3dbce8cda0"
|
||||
"sha256:0e55cce9aa93c5d0d4e3937e47b169035c7e91c8655b0974e61bb79cf398d49c",
|
||||
"sha256:295bb4c02d58ff2ef4378a1870c20af30723013f441c9d1637a008baaf928c8b",
|
||||
"sha256:2dabfd05b96b7b8f2da00d53c514eea842bff83e41e1cceb08ae1966254a51df",
|
||||
"sha256:3f4cd64916d8e732ce6b87f3f5296a8942d285bbbc161acee7fe561134af64f9",
|
||||
"sha256:582891c57b96228d146725975fbb942e1f30a0c4ba19722e692ca3eb25cc9b4f",
|
||||
"sha256:5fdb6efecc3eb60bba5819679466471fd7d13c53487df7248d6e27146e985468",
|
||||
"sha256:780d5d8523c04202184405e60c98d7595bdb498c3c6abba3b6d4cdf2ca2af426",
|
||||
"sha256:7b1f1c76b47c18fa92ee78b60d2d20d7e866c55ee603e7d19c1e991fad933a9a",
|
||||
"sha256:812e2052121634cf13cd6fddf0c1871d0ead1aad40a1a258753c04c18bb71bbd",
|
||||
"sha256:83a55679c4cb449fa527b8497cadf54f076603cc36779b2170b24f704171ce70",
|
||||
"sha256:85e654f0ded7befe2d61eeaf3d3b1e4ef3894469cd664ffa85006c7720f1e4a2",
|
||||
"sha256:87a8e86bae0dbd749c815211ca11e3a7bd559b9710746c559ed63106d382bd9c",
|
||||
"sha256:a7ccfe6331bf8c8dad715753e157457faf7351c2b69f62f32c165c2dbcbacd44",
|
||||
"sha256:ba93e6294e9a737cd726b74b09a6972e36bb511f9a102f1d9a7e1ce94dd206a6",
|
||||
"sha256:c5c1466be2a2ebdf7c5450dd5d980cc87c8ba6976fb82582fea18823da6fa362",
|
||||
"sha256:eb0d4f250a7711b67ad513fde67e8870109e5ce590a801c3722580fe98c33a99",
|
||||
"sha256:facebdfe5a5af6b1588a1d26d170635ead6892d0e314477e80256ef4a8470cf3",
|
||||
"sha256:fcb1bf2cc6706adae9d79c8d86478677e3bbd4ced796ccad106fd4776d395fea"
|
||||
],
|
||||
"index": "pypi",
|
||||
"markers": "python_version >= '3.7'",
|
||||
"version": "==0.7.3"
|
||||
"version": "==0.8.0"
|
||||
},
|
||||
"scipy": {
|
||||
"hashes": [
|
||||
@ -4076,7 +4074,7 @@
|
||||
"sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926",
|
||||
"sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"
|
||||
],
|
||||
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2'",
|
||||
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
|
||||
"version": "==1.16.0"
|
||||
},
|
||||
"sniffio": {
|
||||
@ -4148,40 +4146,39 @@
|
||||
},
|
||||
"watchdog": {
|
||||
"hashes": [
|
||||
"sha256:0f9332243355643d567697c3e3fa07330a1d1abf981611654a1f2bf2175612b7",
|
||||
"sha256:1021223c08ba8d2d38d71ec1704496471ffd7be42cfb26b87cd5059323a389a1",
|
||||
"sha256:108f42a7f0345042a854d4d0ad0834b741d421330d5f575b81cb27b883500176",
|
||||
"sha256:1e9679245e3ea6498494b3028b90c7b25dbb2abe65c7d07423ecfc2d6218ff7c",
|
||||
"sha256:223160bb359281bb8e31c8f1068bf71a6b16a8ad3d9524ca6f523ac666bb6a1e",
|
||||
"sha256:26dd201857d702bdf9d78c273cafcab5871dd29343748524695cecffa44a8d97",
|
||||
"sha256:294b7a598974b8e2c6123d19ef15de9abcd282b0fbbdbc4d23dfa812959a9e05",
|
||||
"sha256:349c9488e1d85d0a58e8cb14222d2c51cbc801ce11ac3936ab4c3af986536926",
|
||||
"sha256:49f4d36cb315c25ea0d946e018c01bb028048023b9e103d3d3943f58e109dd45",
|
||||
"sha256:53a3f10b62c2d569e260f96e8d966463dec1a50fa4f1b22aec69e3f91025060e",
|
||||
"sha256:53adf73dcdc0ef04f7735066b4a57a4cd3e49ef135daae41d77395f0b5b692cb",
|
||||
"sha256:560135542c91eaa74247a2e8430cf83c4342b29e8ad4f520ae14f0c8a19cfb5b",
|
||||
"sha256:720ef9d3a4f9ca575a780af283c8fd3a0674b307651c1976714745090da5a9e8",
|
||||
"sha256:752fb40efc7cc8d88ebc332b8f4bcbe2b5cc7e881bccfeb8e25054c00c994ee3",
|
||||
"sha256:78864cc8f23dbee55be34cc1494632a7ba30263951b5b2e8fc8286b95845f82c",
|
||||
"sha256:85527b882f3facda0579bce9d743ff7f10c3e1e0db0a0d0e28170a7d0e5ce2ea",
|
||||
"sha256:90a67d7857adb1d985aca232cc9905dd5bc4803ed85cfcdcfcf707e52049eda7",
|
||||
"sha256:91b522adc25614cdeaf91f7897800b82c13b4b8ac68a42ca959f992f6990c490",
|
||||
"sha256:9413384f26b5d050b6978e6fcd0c1e7f0539be7a4f1a885061473c5deaa57221",
|
||||
"sha256:94d11b07c64f63f49876e0ab8042ae034674c8653bfcdaa8c4b32e71cfff87e8",
|
||||
"sha256:950f531ec6e03696a2414b6308f5c6ff9dab7821a768c9d5788b1314e9a46ca7",
|
||||
"sha256:a2e8f3f955d68471fa37b0e3add18500790d129cc7efe89971b8a4cc6fdeb0b2",
|
||||
"sha256:ae6deb336cba5d71476caa029ceb6e88047fc1dc74b62b7c4012639c0b563906",
|
||||
"sha256:b8ca4d854adcf480bdfd80f46fdd6fb49f91dd020ae11c89b3a79e19454ec627",
|
||||
"sha256:c66f80ee5b602a9c7ab66e3c9f36026590a0902db3aea414d59a2f55188c1f49",
|
||||
"sha256:d52db5beb5e476e6853da2e2d24dbbbed6797b449c8bf7ea118a4ee0d2c9040e",
|
||||
"sha256:dd021efa85970bd4824acacbb922066159d0f9e546389a4743d56919b6758b91",
|
||||
"sha256:e25adddab85f674acac303cf1f5835951345a56c5f7f582987d266679979c75b",
|
||||
"sha256:f00b4cf737f568be9665563347a910f8bdc76f88c2970121c86243c8cfdf90e9",
|
||||
"sha256:f01f4a3565a387080dc49bdd1fefe4ecc77f894991b88ef927edbfa45eb10818"
|
||||
"sha256:07df1fdd701c5d4c8e55ef6cf55b8f0120fe1aef7ef39a1c6fc6bc2e606d517a",
|
||||
"sha256:20ffe5b202af80ab4266dcd3e91aae72bf2da48c0d33bdb15c66658e685e94e2",
|
||||
"sha256:212ac9b8bf1161dc91bd09c048048a95ca3a4c4f5e5d4a7d1b1a7d5752a7f96f",
|
||||
"sha256:2cce7cfc2008eb51feb6aab51251fd79b85d9894e98ba847408f662b3395ca3c",
|
||||
"sha256:490ab2ef84f11129844c23fb14ecf30ef3d8a6abafd3754a6f75ca1e6654136c",
|
||||
"sha256:6eb11feb5a0d452ee41f824e271ca311a09e250441c262ca2fd7ebcf2461a06c",
|
||||
"sha256:6f10cb2d5902447c7d0da897e2c6768bca89174d0c6e1e30abec5421af97a5b0",
|
||||
"sha256:7607498efa04a3542ae3e05e64da8202e58159aa1fa4acddf7678d34a35d4f13",
|
||||
"sha256:76aae96b00ae814b181bb25b1b98076d5fc84e8a53cd8885a318b42b6d3a5134",
|
||||
"sha256:7a0e56874cfbc4b9b05c60c8a1926fedf56324bb08cfbc188969777940aef3aa",
|
||||
"sha256:82dc3e3143c7e38ec49d61af98d6558288c415eac98486a5c581726e0737c00e",
|
||||
"sha256:9041567ee8953024c83343288ccc458fd0a2d811d6a0fd68c4c22609e3490379",
|
||||
"sha256:90c8e78f3b94014f7aaae121e6b909674df5b46ec24d6bebc45c44c56729af2a",
|
||||
"sha256:9513f27a1a582d9808cf21a07dae516f0fab1cf2d7683a742c498b93eedabb11",
|
||||
"sha256:9ddf7c82fda3ae8e24decda1338ede66e1c99883db93711d8fb941eaa2d8c282",
|
||||
"sha256:a175f755fc2279e0b7312c0035d52e27211a5bc39719dd529625b1930917345b",
|
||||
"sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f",
|
||||
"sha256:afd0fe1b2270917c5e23c2a65ce50c2a4abb63daafb0d419fde368e272a76b7c",
|
||||
"sha256:bc64ab3bdb6a04d69d4023b29422170b74681784ffb9463ed4870cf2f3e66112",
|
||||
"sha256:bdd4e6f14b8b18c334febb9c4425a878a2ac20efd1e0b231978e7b150f92a948",
|
||||
"sha256:c7ac31a19f4545dd92fc25d200694098f42c9a8e391bc00bdd362c5736dbf881",
|
||||
"sha256:c7c15dda13c4eb00d6fb6fc508b3c0ed88b9d5d374056b239c4ad1611125c860",
|
||||
"sha256:c897ac1b55c5a1461e16dae288d22bb2e412ba9807df8397a635d88f671d36c3",
|
||||
"sha256:cbafb470cf848d93b5d013e2ecb245d4aa1c8fd0504e863ccefa32445359d680",
|
||||
"sha256:d1cdb490583ebd691c012b3d6dae011000fe42edb7a82ece80965b42abd61f26",
|
||||
"sha256:e3df4cbb9a450c6d49318f6d14f4bbc80d763fa587ba46ec86f99f9e6876bb26",
|
||||
"sha256:e6439e374fc012255b4ec786ae3c4bc838cd7309a540e5fe0952d03687d8804e",
|
||||
"sha256:e6f0e77c9417e7cd62af82529b10563db3423625c5fce018430b249bf977f9e8",
|
||||
"sha256:e7631a77ffb1f7d2eefa4445ebbee491c720a5661ddf6df3498ebecae5ed375c",
|
||||
"sha256:ef810fbf7b781a5a593894e4f439773830bdecb885e6880d957d5b9382a960d2"
|
||||
],
|
||||
"index": "pypi",
|
||||
"markers": "python_version >= '3.9'",
|
||||
"version": "==5.0.3"
|
||||
"version": "==6.0.0"
|
||||
},
|
||||
"zope-interface": {
|
||||
"hashes": [
|
||||
|
@ -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)
|
||||
|
||||
|
@ -317,10 +317,8 @@ class Command(BaseCommand):
|
||||
|
||||
# Check the files against the timeout
|
||||
still_waiting = {}
|
||||
for filepath in notified_files:
|
||||
# Time of the last inotify event for this file
|
||||
last_event_time = notified_files[filepath]
|
||||
|
||||
# last_event_time is time of the last inotify event for this file
|
||||
for filepath, last_event_time in notified_files.items():
|
||||
# Current time - last time over the configured timeout
|
||||
waited_long_enough = (
|
||||
monotonic() - last_event_time
|
||||
|
@ -294,9 +294,9 @@ class Command(CryptMixin, BaseCommand):
|
||||
manifest_dict = {}
|
||||
|
||||
# Build an overall manifest
|
||||
for key in manifest_key_to_object_query:
|
||||
for key, object_query in manifest_key_to_object_query.items():
|
||||
manifest_dict[key] = json.loads(
|
||||
serializers.serialize("json", manifest_key_to_object_query[key]),
|
||||
serializers.serialize("json", object_query),
|
||||
)
|
||||
|
||||
self.encrypt_secret_fields(manifest_dict)
|
||||
@ -370,8 +370,8 @@ class Command(CryptMixin, BaseCommand):
|
||||
|
||||
# 4.1 write primary manifest to target folder
|
||||
manifest = []
|
||||
for key in manifest_dict:
|
||||
manifest.extend(manifest_dict[key])
|
||||
for key, item in manifest_dict.items():
|
||||
manifest.extend(item)
|
||||
manifest_path = (self.target / "manifest.json").resolve()
|
||||
self.check_and_write_json(
|
||||
manifest,
|
||||
|
@ -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
|
||||
|
@ -162,7 +162,7 @@ class SetPermissionsMixin:
|
||||
},
|
||||
}
|
||||
if set_permissions is not None:
|
||||
for action in permissions_dict:
|
||||
for action, _ in permissions_dict.items():
|
||||
if action in set_permissions:
|
||||
users = set_permissions[action]["users"]
|
||||
permissions_dict[action]["users"] = self._validate_user_ids(users)
|
||||
@ -535,20 +535,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
|
||||
@ -648,10 +655,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"]
|
||||
|
@ -372,21 +372,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)
|
||||
@ -525,6 +510,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,
|
||||
)
|
@ -5,9 +5,9 @@ from paperless.checks import paths_check
|
||||
from paperless.checks import settings_values_check
|
||||
|
||||
__all__ = [
|
||||
"celery_app",
|
||||
"audit_log_check",
|
||||
"binaries_check",
|
||||
"celery_app",
|
||||
"paths_check",
|
||||
"settings_values_check",
|
||||
"audit_log_check",
|
||||
]
|
||||
|
@ -2,4 +2,4 @@
|
||||
from paperless_tesseract.checks import check_default_language_available
|
||||
from paperless_tesseract.checks import get_tesseract_langs
|
||||
|
||||
__all__ = ["get_tesseract_langs", "check_default_language_available"]
|
||||
__all__ = ["check_default_language_available", "get_tesseract_langs"]
|
||||
|
Loading…
x
Reference in New Issue
Block a user