From 1887825ce5c6fe2fbd5366ab52e4c73de24d23fa Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Fri, 17 Jul 2026 21:24:08 +0530 Subject: [PATCH] fix: clear linked comments, versions and attachments with old logs Repost Item Valuation and BOM Update Log cleared old logs with a raw delete on the parent table, orphaning timeline comments, versions, attachments and other reference records. Fixes #57237 --- .../doctype/bom_update_log/bom_update_log.py | 15 +++++----- .../repost_item_valuation.py | 29 ++++++++++++------- .../test_repost_item_valuation.py | 18 ++++++++++++ erpnext/utilities/__init__.py | 29 ++++++++++++++++++- 4 files changed, 73 insertions(+), 18 deletions(-) diff --git a/erpnext/manufacturing/doctype/bom_update_log/bom_update_log.py b/erpnext/manufacturing/doctype/bom_update_log/bom_update_log.py index dd5fe0f0645..18e7aaddc1d 100644 --- a/erpnext/manufacturing/doctype/bom_update_log/bom_update_log.py +++ b/erpnext/manufacturing/doctype/bom_update_log/bom_update_log.py @@ -6,9 +6,7 @@ from typing import Any import frappe from frappe import _ from frappe.model.document import Document -from frappe.query_builder import DocType, Interval -from frappe.query_builder.functions import Now -from frappe.utils import cint, cstr, date_diff, today +from frappe.utils import add_days, cint, cstr, date_diff, now, today from erpnext.manufacturing.doctype.bom_update_log.bom_updation_utils import ( get_leaf_boms, @@ -17,6 +15,7 @@ from erpnext.manufacturing.doctype.bom_update_log.bom_updation_utils import ( replace_bom, set_values_in_log, ) +from erpnext.utilities import clear_logs_with_references class BOMMissingError(frappe.ValidationError): @@ -48,10 +47,12 @@ class BOMUpdateLog(Document): @staticmethod def clear_old_logs(days=None): days = days or 90 - table = DocType("BOM Update Log") - frappe.db.delete( - table, - filters=((table.creation < (Now() - Interval(days=days))) & (table.update_type == "Update Cost")), + clear_logs_with_references( + "BOM Update Log", + { + "creation": ("<", add_days(now(), -days)), + "update_type": "Update Cost", + }, ) def validate(self): diff --git a/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.py b/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.py index 7cac83467c4..9993250bc87 100644 --- a/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.py +++ b/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.py @@ -6,9 +6,18 @@ from frappe import _ from frappe.desk.form.load import get_attachments from frappe.exceptions import QueryDeadlockError, QueryTimeoutError from frappe.model.document import Document -from frappe.query_builder import DocType, Interval -from frappe.query_builder.functions import CombineDatetime, Max, Now -from frappe.utils import cint, get_datetime, get_link_to_form, get_weekday, getdate, now, nowtime +from frappe.query_builder import DocType +from frappe.query_builder.functions import CombineDatetime, Max +from frappe.utils import ( + add_days, + cint, + get_datetime, + get_link_to_form, + get_weekday, + getdate, + now, + nowtime, +) from frappe.utils.user import get_users_with_role from rq.timeouts import JobTimeoutException @@ -22,6 +31,7 @@ from erpnext.stock.stock_ledger import ( repost_future_sle, ) from erpnext.stock.utils import get_combine_datetime +from erpnext.utilities import clear_logs_with_references RecoverableErrors = (JobTimeoutException, QueryDeadlockError, QueryTimeoutError) @@ -65,13 +75,12 @@ class RepostItemValuation(Document): @staticmethod def clear_old_logs(days=None): days = days or 90 - table = DocType("Repost Item Valuation") - frappe.db.delete( - table, - filters=( - (table.creation < (Now() - Interval(days=days))) - & (table.status.isin(["Completed", "Skipped"])) - ), + clear_logs_with_references( + "Repost Item Valuation", + { + "creation": ("<", add_days(now(), -days)), + "status": ("in", ["Completed", "Skipped"]), + }, ) def on_discard(self): diff --git a/erpnext/stock/doctype/repost_item_valuation/test_repost_item_valuation.py b/erpnext/stock/doctype/repost_item_valuation/test_repost_item_valuation.py index 08db5cdd8fb..725f9d9c9da 100644 --- a/erpnext/stock/doctype/repost_item_valuation/test_repost_item_valuation.py +++ b/erpnext/stock/doctype/repost_item_valuation/test_repost_item_valuation.py @@ -104,6 +104,15 @@ class TestRepostItemValuation(ERPNextTestSuite, StockTestMixin): repost_doc.creation = add_days(now(), days=-i * 10) repost_doc.db_update_all() + repost_doc.add_comment("Comment", "test comment") + frappe.new_doc( + "File", + file_name="test_clear_old_logs.txt", + content="test", + attached_to_doctype=repost_doc.doctype, + attached_to_name=repost_doc.name, + ).insert(ignore_permissions=True) + logs = frappe.get_all("Repost Item Valuation", filters={"status": "Skipped"}) self.assertGreater(len(logs), 10) @@ -114,6 +123,15 @@ class TestRepostItemValuation(ERPNextTestSuite, StockTestMixin): logs = frappe.get_all("Repost Item Valuation", filters={"status": "Skipped"}) self.assertEqual(len(logs), 0) + orphan_reference = {"reference_doctype": repost_doc.doctype, "reference_name": repost_doc.name} + self.assertFalse(frappe.get_all("Comment", filters=orphan_reference)) + self.assertFalse( + frappe.get_all( + "File", + filters={"attached_to_doctype": repost_doc.doctype, "attached_to_name": repost_doc.name}, + ) + ) + def test_create_item_wise_repost_item_valuation_entries(self): pr = make_purchase_receipt( company="_Test Company with perpetual inventory", diff --git a/erpnext/utilities/__init__.py b/erpnext/utilities/__init__.py index 9684ae7fe80..fbcfcd19e8d 100644 --- a/erpnext/utilities/__init__.py +++ b/erpnext/utilities/__init__.py @@ -4,10 +4,37 @@ from contextlib import contextmanager import frappe from frappe import _ -from frappe.utils import cstr +from frappe.utils import create_batch, cstr from erpnext.utilities.activation import get_level +LOG_REFERENCE_FIELDS = { + "Comment": ("reference_doctype", "reference_name"), + "Version": ("ref_doctype", "docname"), + "ToDo": ("reference_type", "reference_name"), + "DocShare": ("share_doctype", "share_name"), + "View Log": ("reference_doctype", "reference_name"), + "Document Follow": ("ref_doctype", "ref_docname"), + "Notification Log": ("document_type", "document_name"), +} + + +def clear_logs_with_references(doctype, filters): + names = frappe.get_all(doctype, filters=filters, pluck="name") + for batch in create_batch(names, 1000): + attached_files = frappe.get_all( + "File", + filters={"attached_to_doctype": doctype, "attached_to_name": ("in", batch)}, + pluck="name", + ) + if attached_files: + frappe.delete_doc("File", attached_files, ignore_permissions=True) + + for reference_doctype, (doctype_field, name_field) in LOG_REFERENCE_FIELDS.items(): + frappe.db.delete(reference_doctype, {doctype_field: doctype, name_field: ("in", batch)}) + + frappe.db.delete(doctype, {"name": ("in", batch)}) + def update_doctypes(): df = frappe.qb.DocType("DocField")