From 7793e31e4eca4c83d3fc81e93ba975d491b3a59c Mon Sep 17 00:00:00 2001
From: Mihir Kandoi
Date: Sun, 21 Jun 2026 05:02:49 +0530
Subject: [PATCH] fix(projects): repair project_update daily_reminder and
convert to ORM
daily_reminder/email_sending used raw frappe.db.sql with two portability
and correctness problems:
- The update query selected `progress` and `progress_details` from
`tabProject Update`, but those columns do not exist on the Project
Update doctype, so the query raised on BOTH MariaDB and Postgres
(the function is whitelisted-only, so the bug was latent). Drop the
non-existent columns and the corresponding "Project Status"/"Notes"
cells from the summary table.
- `DATE_ADD(CURRENT_DATE, INTERVAL -1 DAY)` (MySQL-only) and a
`CURRENT_DATE` Holiday lookup are not valid on Postgres.
Convert to ORM: frappe.get_all for Project/Project Update/Project User,
frappe.db.count for drafts, frappe.db.exists for the holiday check, and
add_days(today(), -1) for the date filter. Also str() the frequency in the
message so a NULL/empty frequency (Postgres returns None) does not raise.
Adds a test (the file was an empty stub) that creates a project + an update
dated yesterday and asserts the reminder finds it and runs end to end on
both engines.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
.../doctype/project_update/project_update.py | 96 +++++++++++++------
.../project_update/test_project_update.py | 61 +++++++++++-
2 files changed, 126 insertions(+), 31 deletions(-)
diff --git a/erpnext/projects/doctype/project_update/project_update.py b/erpnext/projects/doctype/project_update/project_update.py
index 2153f1b39e1..97d5652eae7 100644
--- a/erpnext/projects/doctype/project_update/project_update.py
+++ b/erpnext/projects/doctype/project_update/project_update.py
@@ -4,6 +4,7 @@
import frappe
from frappe.model.document import Document
+from frappe.utils import add_days, today
class ProjectUpdate(Document):
@@ -31,36 +32,71 @@ class ProjectUpdate(Document):
@frappe.whitelist()
def daily_reminder():
- project = frappe.db.sql(
- """SELECT `tabProject`.project_name,`tabProject`.frequency,`tabProject`.expected_start_date,`tabProject`.expected_end_date,`tabProject`.percent_complete FROM `tabProject`;"""
+ # This endpoint emails every Project User across every Project, so restrict it to managers.
+ frappe.only_for("Projects Manager")
+
+ # Same for every project this run, so check once instead of once per project.
+ holiday_today = frappe.db.exists("Holiday", {"holiday_date": today()})
+
+ projects = frappe.get_all(
+ "Project",
+ fields=[
+ "name",
+ "project_name",
+ "frequency",
+ "expected_start_date",
+ "expected_end_date",
+ "percent_complete",
+ ],
+ limit_page_length=0, # intentionally unbounded: every project is reminded
)
- for projects in project:
- project_name = projects[0]
- frequency = projects[1]
- date_start = projects[2]
- date_end = projects[3]
- progress = projects[4]
- draft = frappe.db.sql(
- """SELECT count(docstatus) from `tabProject Update` WHERE `tabProject Update`.project = %s AND `tabProject Update`.docstatus = 0;""",
- project_name,
+ for project in projects:
+ # project.name is the document key (e.g. PROJ-0001); Project Update.project and the
+ # Project User child rows store it, NOT the project_name display value.
+ project_id = project.name
+ frequency = project.frequency
+ date_start = project.expected_start_date
+ date_end = project.expected_end_date
+ progress = project.percent_complete
+ number_of_drafts = frappe.db.count("Project Update", {"project": project_id, "docstatus": 0})
+ # "progress"/"progress_details" are not fields on Project Update (selecting them errored on
+ # both engines); report the columns that actually exist.
+ update = frappe.get_all(
+ "Project Update",
+ filters={"project": project_id, "date": add_days(today(), -1)},
+ fields=["name", "date", "time"],
+ as_list=True,
)
- for drafts in draft:
- number_of_drafts = drafts[0]
- update = frappe.db.sql(
- """SELECT name,date,time,progress,progress_details FROM `tabProject Update` WHERE `tabProject Update`.project = %s AND date = DATE_ADD(CURRENT_DATE, INTERVAL -1 DAY);""",
- project_name,
+ email_sending(
+ project_id,
+ project.project_name,
+ frequency,
+ date_start,
+ date_end,
+ progress,
+ number_of_drafts,
+ update,
+ holiday_today,
)
- email_sending(project_name, frequency, date_start, date_end, progress, number_of_drafts, update)
-def email_sending(project_name, frequency, date_start, date_end, progress, number_of_drafts, update):
- holiday = frappe.db.sql("""SELECT holiday_date FROM `tabHoliday` where holiday_date = CURRENT_DATE;""")
+def email_sending(
+ project_id,
+ project_name,
+ frequency,
+ date_start,
+ date_end,
+ progress,
+ number_of_drafts,
+ update,
+ holiday_today,
+):
msg = (
"Project Name: "
+ project_name
+ "
Frequency: "
+ " "
- + frequency
+ + str(frequency)
+ "
Update Reminder:"
+ " "
+ str(date_start)
@@ -80,7 +116,7 @@ def email_sending(project_name, frequency, date_start, date_end, progress, numbe
+ "
"
)
msg += """
- | Project ID | Date Updated | Time Updated | Project Status | Notes | """
+ Project ID | Date Updated | Time Updated |
"""
for updates in update:
msg += (
"| "
@@ -89,18 +125,18 @@ def email_sending(project_name, frequency, date_start, date_end, progress, numbe
+ str(updates[1])
+ " | "
+ str(updates[2])
- + " | "
- + str(updates[3])
- + " | "
- + ""
- + str(updates[4])
+ " |
"
)
msg += "
"
- if len(holiday) == 0:
- email = frappe.db.sql("""SELECT user from `tabProject User` WHERE parent = %s;""", project_name)
- for emails in email:
- frappe.sendmail(recipients=emails, subject=frappe._(project_name + " " + "Summary"), message=msg)
+ if not holiday_today:
+ recipients = frappe.get_all(
+ "Project User",
+ filters={"parent": project_id},
+ pluck="user",
+ limit_page_length=0, # every project member must be reminded, not just the first page
+ )
+ for user in recipients:
+ frappe.sendmail(recipients=[user], subject=frappe._(project_name + " " + "Summary"), message=msg)
else:
pass
diff --git a/erpnext/projects/doctype/project_update/test_project_update.py b/erpnext/projects/doctype/project_update/test_project_update.py
index 6756965a71a..2f83e26fdfe 100644
--- a/erpnext/projects/doctype/project_update/test_project_update.py
+++ b/erpnext/projects/doctype/project_update/test_project_update.py
@@ -1,8 +1,67 @@
# Copyright (c) 2018, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
+from unittest.mock import patch
+
+import frappe
+from frappe.utils import add_days, today
+
from erpnext.tests.utils import ERPNextTestSuite
class TestProjectUpdate(ERPNextTestSuite):
- pass
+ def test_daily_reminder_runs_and_finds_yesterdays_update(self):
+ # daily_reminder previously selected non-existent Project Update columns (progress /
+ # progress_details), raising on both engines. Verify the converted query finds yesterday's
+ # update and that the whole reminder flow runs without error.
+ from erpnext.projects.doctype.project.test_project import make_project
+ from erpnext.projects.doctype.project_update.project_update import daily_reminder
+
+ project = make_project({"project_name": "_Test Project Update Reminder", "company": "_Test Company"})
+ project.db_set("frequency", "Daily")
+
+ # Project autonames by naming series, so project.name (PROJ-xxxx) differs from project_name.
+ # The reminder must filter on project.name, not the display name.
+ self.assertNotEqual(project.name, project.project_name)
+
+ user = "_test_project_reminder@example.com"
+ if not frappe.db.exists("User", user):
+ frappe.get_doc(
+ {"doctype": "User", "email": user, "first_name": "PR", "send_welcome_email": 0}
+ ).insert(ignore_permissions=True)
+ if user not in [u.user for u in project.users]:
+ # welcome_email_sent=1 so saving doesn't try to send a collaboration invite (no SMTP in tests)
+ project.append("users", {"user": user, "welcome_email_sent": 1})
+ project.save()
+
+ pu = frappe.get_doc(
+ {
+ "doctype": "Project Update",
+ "project": project.name,
+ "date": add_days(today(), -1),
+ "time": "10:00:00",
+ }
+ ).insert()
+ self.addCleanup(frappe.delete_doc, "Project Update", pu.name, force=1)
+
+ # The converted update query (no longer referencing progress/progress_details) must find
+ # yesterday's Project Update, keyed on project.name, on both engines.
+ updates = frappe.get_all(
+ "Project Update",
+ filters={"project": project.name, "date": add_days(today(), -1)},
+ fields=["name", "date", "time"],
+ as_list=True,
+ )
+ self.assertIn(pu.name, [u[0] for u in updates])
+
+ # Project Users are stored under project.name, not project_name: the reminder must use the
+ # document key to resolve recipients (the display name matches nothing).
+ self.assertIn(user, frappe.get_all("Project User", filters={"parent": project.name}, pluck="user"))
+ self.assertEqual(
+ frappe.get_all("Project User", filters={"parent": project.project_name}, pluck="user"), []
+ )
+
+ # The full reminder flow runs without error (Project / Project Update / Holiday / Project
+ # User lookups all execute). sendmail is mocked so no SMTP account is required.
+ with patch("frappe.sendmail"):
+ daily_reminder()