From 00485138f9e8aad5076c8804905b38961c882114 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 1 Dec 2024 18:56:54 -0800 Subject: [PATCH 1/2] Chore(deps-dev): Bump the development group with 4 updates (#8352) Bumps the development group with 4 updates: [ruff](https://github.com/astral-sh/ruff), [pytest-httpx](https://github.com/Colin-b/pytest_httpx), [pytest-rerunfailures](https://github.com/pytest-dev/pytest-rerunfailures) and [mkdocs-material](https://github.com/squidfunk/mkdocs-material). Updates `ruff` from 0.7.3 to 0.8.0 - [Release notes](https://github.com/astral-sh/ruff/releases) - [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md) - [Commits](https://github.com/astral-sh/ruff/compare/0.7.3...0.8.0) Updates `pytest-httpx` from 0.33.0 to 0.34.0 - [Release notes](https://github.com/Colin-b/pytest_httpx/releases) - [Changelog](https://github.com/Colin-b/pytest_httpx/blob/develop/CHANGELOG.md) - [Commits](https://github.com/Colin-b/pytest_httpx/compare/v0.33.0...v0.34.0) Updates `pytest-rerunfailures` from 14.0 to 15.0 - [Changelog](https://github.com/pytest-dev/pytest-rerunfailures/blob/master/CHANGES.rst) - [Commits](https://github.com/pytest-dev/pytest-rerunfailures/compare/14.0...15.0) Updates `mkdocs-material` from 9.5.44 to 9.5.46 - [Release notes](https://github.com/squidfunk/mkdocs-material/releases) - [Changelog](https://github.com/squidfunk/mkdocs-material/blob/master/CHANGELOG) - [Commits](https://github.com/squidfunk/mkdocs-material/compare/9.5.44...9.5.46) --- updated-dependencies: - dependency-name: ruff dependency-type: direct:development update-type: version-update:semver-minor dependency-group: development - dependency-name: pytest-httpx dependency-type: direct:development update-type: version-update:semver-minor dependency-group: development - dependency-name: pytest-rerunfailures dependency-type: direct:development update-type: version-update:semver-major dependency-group: development - dependency-name: mkdocs-material dependency-type: direct:development update-type: version-update:semver-patch dependency-group: development ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 2 +- Pipfile.lock | 135 +++++++++--------- .../management/commands/document_consumer.py | 6 +- .../management/commands/document_exporter.py | 8 +- src/documents/serialisers.py | 2 +- src/paperless/__init__.py | 4 +- src/paperless_tesseract/__init__.py | 2 +- 7 files changed, 77 insertions(+), 82 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index cd8e47ae9..df90b225c 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -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 diff --git a/Pipfile.lock b/Pipfile.lock index 765748dd1..0870e9d6b 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -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": [ diff --git a/src/documents/management/commands/document_consumer.py b/src/documents/management/commands/document_consumer.py index 1eb2f6541..6b2706733 100644 --- a/src/documents/management/commands/document_consumer.py +++ b/src/documents/management/commands/document_consumer.py @@ -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 diff --git a/src/documents/management/commands/document_exporter.py b/src/documents/management/commands/document_exporter.py index 84275507d..2f85ad8f8 100644 --- a/src/documents/management/commands/document_exporter.py +++ b/src/documents/management/commands/document_exporter.py @@ -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, diff --git a/src/documents/serialisers.py b/src/documents/serialisers.py index 8c7973f96..74b705af3 100644 --- a/src/documents/serialisers.py +++ b/src/documents/serialisers.py @@ -160,7 +160,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) diff --git a/src/paperless/__init__.py b/src/paperless/__init__.py index 54ff3cb79..ac8326935 100644 --- a/src/paperless/__init__.py +++ b/src/paperless/__init__.py @@ -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", ] diff --git a/src/paperless_tesseract/__init__.py b/src/paperless_tesseract/__init__.py index 9976fb403..cc0b886aa 100644 --- a/src/paperless_tesseract/__init__.py +++ b/src/paperless_tesseract/__init__.py @@ -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"] From 0fc1860d4ccc77366a05b89e69a975bc48847cde Mon Sep 17 00:00:00 2001 From: shamoon <4887959+shamoon@users.noreply.github.com> Date: Sun, 1 Dec 2024 20:15:38 -0800 Subject: [PATCH 2/2] Enhancement: use stable unique IDs for custom field select options (#8299) --- .../custom-field-display.component.spec.ts | 10 +- .../custom-field-display.component.ts | 4 +- ...ustom-fields-query-dropdown.component.html | 4 + ...om-fields-query-dropdown.component.spec.ts | 19 ++- .../custom-fields-query-dropdown.component.ts | 4 +- .../custom-field-edit-dialog.component.html | 5 +- ...custom-field-edit-dialog.component.spec.ts | 16 +- .../custom-field-edit-dialog.component.ts | 19 ++- .../input/select/select.component.spec.ts | 8 - .../common/input/select/select.component.ts | 5 - .../document-detail.component.html | 3 +- src-ui/src/app/data/custom-field.ts | 2 +- src/documents/filters.py | 26 +-- .../management/commands/document_importer.py | 4 +- ..._alter_customfieldinstance_value_select.py | 79 +++++++++ src/documents/models.py | 8 +- src/documents/serialisers.py | 29 +++- src/documents/signals/handlers.py | 43 +++-- src/documents/templating/filepath.py | 6 +- src/documents/tests/test_api_custom_fields.py | 154 +++++++++++++++++- src/documents/tests/test_api_documents.py | 7 +- .../tests/test_api_filter_by_custom_fields.py | 26 +-- src/documents/tests/test_file_handling.py | 27 ++- .../test_migration_custom_field_selects.py | 87 ++++++++++ 24 files changed, 494 insertions(+), 101 deletions(-) create mode 100644 src/documents/migrations/1059_alter_customfieldinstance_value_select.py create mode 100644 src/documents/tests/test_migration_custom_field_selects.py diff --git a/src-ui/src/app/components/common/custom-field-display/custom-field-display.component.spec.ts b/src-ui/src/app/components/common/custom-field-display/custom-field-display.component.spec.ts index ea60034e4..824e1e05b 100644 --- a/src-ui/src/app/components/common/custom-field-display/custom-field-display.component.spec.ts +++ b/src-ui/src/app/components/common/custom-field-display/custom-field-display.component.spec.ts @@ -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' + ) }) }) diff --git a/src-ui/src/app/components/common/custom-field-display/custom-field-display.component.ts b/src-ui/src/app/components/common/custom-field-display/custom-field-display.component.ts index f541f0e47..1ab831f46 100644 --- a/src-ui/src/app/components/common/custom-field-display/custom-field-display.component.ts +++ b/src-ui/src/app/components/common/custom-field-display/custom-field-display.component.ts @@ -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 { diff --git a/src-ui/src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.html b/src-ui/src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.html index 9cc095d7d..768a79af5 100644 --- a/src-ui/src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.html +++ b/src-ui/src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.html @@ -44,6 +44,8 @@ { 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) diff --git a/src-ui/src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.ts b/src-ui/src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.ts index b0d446dd0..2233fc5c4 100644 --- a/src-ui/src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.ts +++ b/src-ui/src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.ts @@ -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'] diff --git a/src-ui/src/app/components/common/edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component.html b/src-ui/src/app/components/common/edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component.html index d48c0788b..b4216e41c 100644 --- a/src-ui/src/app/components/common/edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component.html +++ b/src-ui/src/app/components/common/edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component.html @@ -21,8 +21,9 @@
@for (option of objectForm.controls.extra_data.controls.select_options.controls; track option; let i = $index) { -
- +
+ +
} diff --git a/src-ui/src/app/components/common/edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component.spec.ts b/src-ui/src/app/components/common/edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component.spec.ts index 2de17577f..6ecf72b5d 100644 --- a/src-ui/src/app/components/common/edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component.spec.ts +++ b/src-ui/src/app/components/common/edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component.spec.ts @@ -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', () => { diff --git a/src-ui/src/app/components/common/edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component.ts b/src-ui/src/app/components/common/edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component.ts index b27ec9fcd..e39e27edd 100644 --- a/src-ui/src/app/components/common/edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component.ts +++ b/src-ui/src/app/components/common/edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component.ts @@ -57,9 +57,16 @@ export class CustomFieldEditDialogComponent } if (this.object?.data_type === CustomFieldDataType.Select) { this.selectOptions.clear() - this.object.extra_data.select_options.forEach((option) => - this.selectOptions.push(new FormControl(option)) - ) + this.object.extra_data.select_options + .filter((option) => option) + .forEach((option) => + this.selectOptions.push( + new FormGroup({ + label: new FormControl(option.label), + id: new FormControl(option.id), + }) + ) + ) } } @@ -89,7 +96,7 @@ export class CustomFieldEditDialogComponent name: new FormControl(null), data_type: new FormControl(null), extra_data: new FormGroup({ - select_options: new FormArray([new FormControl(null)]), + select_options: new FormArray([]), default_currency: new FormControl(null), }), }) @@ -104,7 +111,9 @@ export class CustomFieldEditDialogComponent } public addSelectOption() { - this.selectOptions.push(new FormControl('')) + this.selectOptions.push( + new FormGroup({ label: new FormControl(null), id: new FormControl(null) }) + ) } public removeSelectOption(index: number) { diff --git a/src-ui/src/app/components/common/input/select/select.component.spec.ts b/src-ui/src/app/components/common/input/select/select.component.spec.ts index 2c39035a2..79eec16e8 100644 --- a/src-ui/src/app/components/common/input/select/select.component.spec.ts +++ b/src-ui/src/app/components/common/input/select/select.component.spec.ts @@ -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' }, - ]) - }) }) diff --git a/src-ui/src/app/components/common/input/select/select.component.ts b/src-ui/src/app/components/common/input/select/select.component.ts index d9976698e..19f6375ad 100644 --- a/src-ui/src/app/components/common/input/select/select.component.ts +++ b/src-ui/src/app/components/common/input/select/select.component.ts @@ -34,11 +34,6 @@ export class SelectComponent extends AbstractInputComponent { 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) diff --git a/src-ui/src/app/components/document-detail/document-detail.component.html b/src-ui/src/app/components/document-detail/document-detail.component.html index 486277c21..86767b6e7 100644 --- a/src-ui/src/app/components/document-detail/document-detail.component.html +++ b/src-ui/src/app/components/document-detail/document-detail.component.html @@ -190,7 +190,8 @@ @case (CustomFieldDataType.Select) { default_currency?: string } document_count?: number diff --git a/src/documents/filters.py b/src/documents/filters.py index e8065c472..237973b6f 100644 --- a/src/documents/filters.py +++ b/src/documents/filters.py @@ -176,9 +176,9 @@ class CustomFieldsFilter(Filter): if fields_with_matching_selects.count() > 0: for field in fields_with_matching_selects: options = field.extra_data.get("select_options", []) - for index, option in enumerate(options): - if option.lower().find(value.lower()) != -1: - option_ids.extend([index]) + for _, option in enumerate(options): + if option.get("label").lower().find(value.lower()) != -1: + option_ids.extend([option.get("id")]) return ( qs.filter(custom_fields__field__name__icontains=value) | qs.filter(custom_fields__value_text__icontains=value) @@ -195,19 +195,21 @@ class CustomFieldsFilter(Filter): return qs -class SelectField(serializers.IntegerField): +class SelectField(serializers.CharField): def __init__(self, custom_field: CustomField): self._options = custom_field.extra_data["select_options"] - super().__init__(min_value=0, max_value=len(self._options)) + super().__init__(max_length=16) def to_internal_value(self, data): - if not isinstance(data, int): - # If the supplied value is not an integer, - # we will try to map it to an option index. - try: - data = self._options.index(data) - except ValueError: - pass + # If the supplied value is the option label instead of the ID + try: + data = next( + option.get("id") + for option in self._options + if option.get("label") == data + ) + except StopIteration: + pass return super().to_internal_value(data) diff --git a/src/documents/management/commands/document_importer.py b/src/documents/management/commands/document_importer.py index 22c626eba..f56159c81 100644 --- a/src/documents/management/commands/document_importer.py +++ b/src/documents/management/commands/document_importer.py @@ -34,7 +34,7 @@ from documents.settings import EXPORTER_ARCHIVE_NAME from documents.settings import EXPORTER_CRYPTO_SETTINGS_NAME from documents.settings import EXPORTER_FILE_NAME from documents.settings import EXPORTER_THUMBNAIL_NAME -from documents.signals.handlers import update_cf_instance_documents +from documents.signals.handlers import check_paths_and_prune_custom_fields from documents.signals.handlers import update_filename_and_move_files from documents.utils import copy_file_with_basic_stats from paperless import version @@ -262,7 +262,7 @@ class Command(CryptMixin, BaseCommand): ), disable_signal( post_save, - receiver=update_cf_instance_documents, + receiver=check_paths_and_prune_custom_fields, sender=CustomField, ), ): diff --git a/src/documents/migrations/1059_alter_customfieldinstance_value_select.py b/src/documents/migrations/1059_alter_customfieldinstance_value_select.py new file mode 100644 index 000000000..00ab11f65 --- /dev/null +++ b/src/documents/migrations/1059_alter_customfieldinstance_value_select.py @@ -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, + ), + ] diff --git a/src/documents/models.py b/src/documents/models.py index 6ba63a7e4..2eb5d817c 100644 --- a/src/documents/models.py +++ b/src/documents/models.py @@ -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 diff --git a/src/documents/serialisers.py b/src/documents/serialisers.py index 74b705af3..9ab9bf40e 100644 --- a/src/documents/serialisers.py +++ b/src/documents/serialisers.py @@ -533,20 +533,27 @@ class CustomFieldSerializer(serializers.ModelSerializer): if ( "data_type" in attrs and attrs["data_type"] == CustomField.FieldDataType.SELECT - and ( + ) or ( + self.instance + and self.instance.data_type == CustomField.FieldDataType.SELECT + ): + if ( "extra_data" not in attrs or "select_options" not in attrs["extra_data"] or not isinstance(attrs["extra_data"]["select_options"], list) or len(attrs["extra_data"]["select_options"]) == 0 or not all( - isinstance(option, str) and len(option) > 0 + len(option.get("label", "")) > 0 for option in attrs["extra_data"]["select_options"] ) - ) - ): - raise serializers.ValidationError( - {"error": "extra_data.select_options must be a valid list"}, - ) + ): + raise serializers.ValidationError( + {"error": "extra_data.select_options must be a valid list"}, + ) + # labels are valid, generate ids if not present + for option in attrs["extra_data"]["select_options"]: + if option.get("id") is None: + option["id"] = get_random_string(length=16) elif ( "data_type" in attrs and attrs["data_type"] == CustomField.FieldDataType.MONETARY @@ -646,10 +653,14 @@ class CustomFieldInstanceSerializer(serializers.ModelSerializer): elif field.data_type == CustomField.FieldDataType.SELECT: select_options = field.extra_data["select_options"] try: - select_options[data["value"]] + next( + option + for option in select_options + if option["id"] == data["value"] + ) except Exception: raise serializers.ValidationError( - f"Value must be index of an element in {select_options}", + f"Value must be an id of an element in {select_options}", ) elif field.data_type == CustomField.FieldDataType.DOCUMENTLINK: doc_ids = data["value"] diff --git a/src/documents/signals/handlers.py b/src/documents/signals/handlers.py index c6d6c4090..853acdc15 100644 --- a/src/documents/signals/handlers.py +++ b/src/documents/signals/handlers.py @@ -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") diff --git a/src/documents/templating/filepath.py b/src/documents/templating/filepath.py index 108ad0c81..cbe621d77 100644 --- a/src/documents/templating/filepath.py +++ b/src/documents/templating/filepath.py @@ -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: diff --git a/src/documents/tests/test_api_custom_fields.py b/src/documents/tests/test_api_custom_fields.py index 02e856c27..11911f6ab 100644 --- a/src/documents/tests/test_api_custom_fields.py +++ b/src/documents/tests/test_api_custom_fields.py @@ -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", diff --git a/src/documents/tests/test_api_documents.py b/src/documents/tests/test_api_documents.py index 08d86d24e..8307d6c4c 100644 --- a/src/documents/tests/test_api_documents.py +++ b/src/documents/tests/test_api_documents.py @@ -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") diff --git a/src/documents/tests/test_api_filter_by_custom_fields.py b/src/documents/tests/test_api_filter_by_custom_fields.py index 4cba29152..c7e9092ed 100644 --- a/src/documents/tests/test_api_filter_by_custom_fields.py +++ b/src/documents/tests/test_api_filter_by_custom_fields.py @@ -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): diff --git a/src/documents/tests/test_file_handling.py b/src/documents/tests/test_file_handling.py index 476068a51..2ec388501 100644 --- a/src/documents/tests/test_file_handling.py +++ b/src/documents/tests/test_file_handling.py @@ -544,7 +544,11 @@ class TestFileHandling(DirectoriesMixin, FileSystemAssertsMixin, TestCase): name="test", data_type=CustomField.FieldDataType.SELECT, extra_data={ - "select_options": ["apple", "banana", "cherry"], + "select_options": [ + {"label": "apple", "id": "abc123"}, + {"label": "banana", "id": "def456"}, + {"label": "cherry", "id": "ghi789"}, + ], }, ) doc = Document.objects.create( @@ -555,14 +559,22 @@ class TestFileHandling(DirectoriesMixin, FileSystemAssertsMixin, TestCase): archive_checksum="B", mime_type="application/pdf", ) - CustomFieldInstance.objects.create(field=cf, document=doc, value_select=0) + CustomFieldInstance.objects.create( + field=cf, + document=doc, + value_select="abc123", + ) self.assertEqual(generate_filename(doc), "document_apple.pdf") # handler should not have been called self.assertEqual(m.call_count, 0) cf.extra_data = { - "select_options": ["aubergine", "banana", "cherry"], + "select_options": [ + {"label": "aubergine", "id": "abc123"}, + {"label": "banana", "id": "def456"}, + {"label": "cherry", "id": "ghi789"}, + ], } cf.save() self.assertEqual(generate_filename(doc), "document_aubergine.pdf") @@ -1373,13 +1385,18 @@ class TestFilenameGeneration(DirectoriesMixin, TestCase): cf2 = CustomField.objects.create( name="Select Field", data_type=CustomField.FieldDataType.SELECT, - extra_data={"select_options": ["ChoiceOne", "ChoiceTwo"]}, + extra_data={ + "select_options": [ + {"label": "ChoiceOne", "id": "abc=123"}, + {"label": "ChoiceTwo", "id": "def-456"}, + ], + }, ) cfi1 = CustomFieldInstance.objects.create( document=doc_a, field=cf2, - value_select=0, + value_select="abc=123", ) cfi = CustomFieldInstance.objects.create( diff --git a/src/documents/tests/test_migration_custom_field_selects.py b/src/documents/tests/test_migration_custom_field_selects.py new file mode 100644 index 000000000..b172bf7e8 --- /dev/null +++ b/src/documents/tests/test_migration_custom_field_selects.py @@ -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, + )