From b4c58e51ce66d40158f5ed32dfe73124360e7051 Mon Sep 17 00:00:00 2001 From: Dirk van der Laarse Date: Mon, 5 Oct 2026 19:52:06 +0000 Subject: [PATCH] fix: keep Repost Item Valuation files on local disk --- cloud_storage/cloud_storage/overrides/file.py | 9 +++- cloud_storage/tests/test_file.py | 49 ++++++++++++++++++- 2 files changed, 56 insertions(+), 2 deletions(-) diff --git a/cloud_storage/cloud_storage/overrides/file.py b/cloud_storage/cloud_storage/overrides/file.py index 0e8808c..95cd756 100644 --- a/cloud_storage/cloud_storage/overrides/file.py +++ b/cloud_storage/cloud_storage/overrides/file.py @@ -32,6 +32,8 @@ FILE_URL = "/api/method/retrieve?key={path}" URL_PREFIXES = ("http://", "https://", "/api/method/retrieve") +LOCAL_ONLY_DOCTYPES = ("Data Import", "Repost Item Valuation") + class CloudStorageFile(File): @File.is_remote_file.getter @@ -716,7 +718,7 @@ def write_file(file: File, remove_spaces_in_file_name: bool = True) -> File: file.save_file_on_filesystem() return file - if file.attached_to_doctype == "Data Import": + if file.attached_to_doctype in LOCAL_ONLY_DOCTYPES: file.save_file_on_filesystem() return file @@ -769,6 +771,11 @@ def delete_file(file: File, **kwargs) -> File: if file.is_folder: return file + # files kept on disk (see LOCAL_ONLY_DOCTYPES) have nothing in the bucket to delete + if file.file_url and not file.is_remote_file: + file.delete_file_from_filesystem() + return file + if file.file_url and "?key=" in file.file_url: key = file.file_url.split("?key=")[1] if key: diff --git a/cloud_storage/tests/test_file.py b/cloud_storage/tests/test_file.py index 62264af..c13d9af 100644 --- a/cloud_storage/tests/test_file.py +++ b/cloud_storage/tests/test_file.py @@ -10,7 +10,13 @@ from moto import mock_s3 from werkzeug.datastructures import FileMultiDict -from cloud_storage.cloud_storage.overrides.file import CloudStorageFile, retrieve +from cloud_storage.cloud_storage.overrides.file import ( + LOCAL_ONLY_DOCTYPES, + CloudStorageFile, + delete_file, + retrieve, + write_file, +) from cloud_storage.migration import migrate_files @@ -496,3 +502,44 @@ def paste(file_name, attached_to_name): key = second.file_url.split("?key=")[1] assert frappe.db.exists("File", {"s3_key": key}) assert second.unique_url == f"{second.file_url}&fid={first.name}" + + +@pytest.mark.parametrize("doctype", LOCAL_ONLY_DOCTYPES) +def test_local_only_doctype_file_stays_on_filesystem(doctype): + """ + Files attached to LOCAL_ONLY_DOCTYPES must bypass S3 entirely: core code (e.g. ERPNext's + create_json_gz_file for Repost Item Valuation) rewrites them in place via + get_full_path() + open(), which crashes with FileNotFoundError on a retrieve URL. + """ + frappe.set_user("Administrator") + file = frappe.get_doc( + { + "doctype": "File", + "file_name": f"{frappe.scrub(doctype)}-test.json.gz", + "attached_to_doctype": doctype, + "attached_to_name": "TEST-0001", + "attached_to_field": "reposting_data_file", + "is_private": 1, + } + ) + file._content = b"\x1f\x8b" + b"x" * 32 + + with patch("cloud_storage.cloud_storage.overrides.file.get_cloud_storage_client") as client: + result = write_file(file) + + client.assert_not_called() + assert result.file_url.startswith("/private/files/") + assert not result.is_remote_file + + # get_full_path() must be a real path that core can reopen for writing on the next step + path = result.get_full_path() + assert Path(path).is_file() + with open(path, "wb") as f: + f.write(b"rewritten") + assert Path(path).read_bytes() == b"rewritten" + + # the delete hook must clean the local file instead of trying to delete a bucket object + with patch("cloud_storage.cloud_storage.overrides.file.get_cloud_storage_client") as client: + delete_file(result) + client.assert_not_called() + assert not Path(path).exists()