diff --git a/frappe/automation/doctype/docreminder/__init__.py b/frappe/automation/doctype/docreminder/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/frappe/automation/doctype/docreminder/docreminder.js b/frappe/automation/doctype/docreminder/docreminder.js new file mode 100644 index 0000000000..a3f08c2c5e --- /dev/null +++ b/frappe/automation/doctype/docreminder/docreminder.js @@ -0,0 +1,8 @@ +// Copyright (c) 2023, Frappe Technologies and contributors +// For license information, please see license.txt + +// frappe.ui.form.on("DocReminder", { +// refresh(frm) { + +// }, +// }); diff --git a/frappe/automation/doctype/docreminder/docreminder.json b/frappe/automation/doctype/docreminder/docreminder.json new file mode 100644 index 0000000000..38b0f681ca --- /dev/null +++ b/frappe/automation/doctype/docreminder/docreminder.json @@ -0,0 +1,85 @@ +{ + "actions": [], + "autoname": "hash", + "creation": "2023-02-22 11:23:58.183276", + "default_view": "List", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "user", + "reminder_doctype", + "reminder_docname", + "remind_at", + "description", + "notified" + ], + "fields": [ + { + "fieldname": "user", + "fieldtype": "Link", + "label": "User", + "options": "User", + "reqd": 1, + "search_index": 1 + }, + { + "fieldname": "reminder_doctype", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Document Type", + "options": "DocType" + }, + { + "fieldname": "reminder_docname", + "fieldtype": "Dynamic Link", + "in_list_view": 1, + "label": "Document Name", + "options": "reminder_doctype" + }, + { + "fieldname": "remind_at", + "fieldtype": "Datetime", + "in_list_view": 1, + "label": "Remind At", + "reqd": 1, + "search_index": 1 + }, + { + "fieldname": "description", + "fieldtype": "Small Text", + "label": "description", + "reqd": 1 + }, + { + "default": "0", + "fieldname": "notified", + "fieldtype": "Check", + "label": "notified" + } + ], + "in_create": 1, + "index_web_pages_for_search": 1, + "links": [], + "modified": "2023-02-24 12:28:00.642813", + "modified_by": "Administrator", + "module": "Automation", + "name": "DocReminder", + "naming_rule": "Random", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "if_owner": 1, + "read": 1, + "role": "All", + "write": 1 + } + ], + "read_only": 1, + "sort_field": "modified", + "sort_order": "DESC", + "states": [], + "title_field": "description" +} \ No newline at end of file diff --git a/frappe/automation/doctype/docreminder/docreminder.py b/frappe/automation/doctype/docreminder/docreminder.py new file mode 100644 index 0000000000..61832fa2a9 --- /dev/null +++ b/frappe/automation/doctype/docreminder/docreminder.py @@ -0,0 +1,78 @@ +# Copyright (c) 2023, Frappe Technologies and contributors +# For license information, please see license.txt + +import frappe +from frappe import _ +from frappe.model.document import Document +from frappe.utils import cint +from frappe.utils.data import add_to_date, get_datetime, now_datetime + + +class DocReminder(Document): + @staticmethod + def clear_old_logs(days=30): + from frappe.query_builder import Interval + from frappe.query_builder.functions import Now + + table = frappe.qb.DocType("DocReminder") + frappe.db.delete(table, filters=(table.remind_at < (Now() - Interval(days=days)))) + + def validate(self): + self.user = frappe.session.user + if get_datetime(self.remind_at) < now_datetime(): + frappe.throw(_("Reminder cannot be created in past.")) + + def send_reminder(self): + if self.notified: + return + + self.db_set("notified", 1, update_modified=False) + + try: + notification = frappe.new_doc("Notification Log") + notification.for_user = self.user + notification.set("type", "Alert") + notification.document_type = self.reminder_doctype + notification.document_name = self.reminder_docname + notification.subject = self.description + notification.insert() + except Exception: + self.log_error("Failed to send reminder") + + +@frappe.whitelist() +def create_new_reminder( + remind_at: str, + description: str, + reminder_doctype: str | None = None, + reminder_docname: str | None = None, +): + reminder = frappe.new_doc("DocReminder") + + reminder.description = description + reminder.remind_at = remind_at + reminder.reminder_doctype = reminder_doctype + reminder.reminder_docname = reminder_docname + + return reminder.insert() + + +def send_reminders(): + # Ensure that we send all reminders that might be before next job execution. + job_freq = cint(frappe.get_conf().scheduler_interval) or 240 + upper_threshold = add_to_date(now_datetime(), seconds=job_freq, as_string=True, as_datetime=True) + + lower_threshold = add_to_date(now_datetime(), hours=-8, as_string=True, as_datetime=True) + + pending_reminders = frappe.get_all( + "DocReminder", + filters=[ + ("remind_at", "<=", upper_threshold), + ("remind_at", ">=", lower_threshold), # dont send too old reminders if failed to send + ("notified", "=", 0), + ], + pluck="name", + ) + + for reminder in pending_reminders: + frappe.get_doc("DocReminder", reminder).send_reminder() diff --git a/frappe/automation/doctype/docreminder/test_docreminder.py b/frappe/automation/doctype/docreminder/test_docreminder.py new file mode 100644 index 0000000000..54cf47fc2e --- /dev/null +++ b/frappe/automation/doctype/docreminder/test_docreminder.py @@ -0,0 +1,28 @@ +# Copyright (c) 2023, Frappe Technologies and Contributors +# See license.txt + +import frappe +from frappe.automation.doctype.docreminder.docreminder import create_new_reminder, send_reminders +from frappe.desk.doctype.notification_log.notification_log import get_notification_logs +from frappe.tests.utils import FrappeTestCase +from frappe.utils import add_to_date, now_datetime + + +class TestDocReminder(FrappeTestCase): + def test_reminder(self): + + description = "TEST_REMINDER" + + create_new_reminder( + remind_at=add_to_date(now_datetime(), minutes=1, as_datetime=True, as_string=True), + description=description, + ) + + send_reminders() + + notifications = get_notification_logs()["notification_logs"] + self.assertIn( + description, + [n.subject for n in notifications], + msg=f"Failed to find reminder notification \n{notifications}", + ) diff --git a/frappe/core/doctype/log_settings/log_settings.py b/frappe/core/doctype/log_settings/log_settings.py index d0e639dfa1..eb617d2ee2 100644 --- a/frappe/core/doctype/log_settings/log_settings.py +++ b/frappe/core/doctype/log_settings/log_settings.py @@ -20,6 +20,7 @@ DEFAULT_LOGTYPES_RETENTION = { "Submission Queue": 30, "Prepared Report": 30, "Webhook Request Log": 30, + "DocReminder": 30, } diff --git a/frappe/hooks.py b/frappe/hooks.py index 317439c358..aaed06124f 100644 --- a/frappe/hooks.py +++ b/frappe/hooks.py @@ -194,6 +194,7 @@ scheduler_events = { "frappe.email.doctype.email_account.email_account.notify_unreplied", "frappe.utils.global_search.sync_global_search", "frappe.monitor.flush", + "frappe.automation.doctype.docreminder.docreminder.send_reminders", ], "hourly": [ "frappe.model.utils.link_count.update_link_count", diff --git a/frappe/public/js/frappe/form/reminders.js b/frappe/public/js/frappe/form/reminders.js new file mode 100644 index 0000000000..17499ecfe9 --- /dev/null +++ b/frappe/public/js/frappe/form/reminders.js @@ -0,0 +1,93 @@ +export class ReminderManager { + constructor({ frm }) { + this.frm = frm; // can be optional if not setting for document. + } + + show() { + let me = this; + this.dialog = new frappe.ui.Dialog({ + title: __("Create a Reminder"), + fields: [ + { + fieldtype: "Select", + label: __("Remind Me In"), + fieldname: "remind_me_in", + options: [ + { label: __("30 minutes"), value: "30_minutes" }, + { label: __("1 hour"), value: "1_hour" }, + { label: __("4 hours"), value: "4_hours" }, + { label: __("1 Day"), value: "1_day" }, + ], + onchange: () => { + me.convert_period_to_absolute_time(); + }, + }, + { + fieldtype: "Column Break", + fieldname: "col_break_1", + }, + { + fieldtype: "Datetime", + label: __("Remind At"), + fieldname: "remind_at", + reqd: 1, + onchange: () => { + // TODO: reset remind_me_in only if user modified time. + // me.dialog.set_value("remind_me_in", ""); + }, + }, + { + fieldtype: "Section Break", + fieldname: "divider_between_message_and_time", + }, + { + fieldtype: "Small Text", + label: __("Description"), + fieldname: "description", + reqd: 1, + }, + ], + primary_action_label: __("Create"), + primary_action: () => { + this.create_reminder(); + this.dialog.hide(); + }, + secondary_action_label: __("Cancel"), + secondary_action: () => { + this.dialog.hide(); + }, + }); + + this.dialog.fields_dict.remind_at.datepicker?.update({ + minDate: frappe.datetime.str_to_obj(frappe.datetime.now_datetime()), + }); + + this.dialog.show(); + } + + convert_period_to_absolute_time() { + const period = this.dialog.get_value("remind_me_in"); + if (!period) return; + + const now_time = frappe.datetime.str_to_obj(frappe.datetime.now_datetime()); + let [magnitude, unit] = period.split("_"); + + let time_to_set = moment(now_time) + .add(magnitude, unit) + .format(frappe.defaultDatetimeFormat); + this.dialog.set_value("remind_at", time_to_set); + } + + create_reminder() { + frappe + .xcall("frappe.automation.doctype.docreminder.docreminder.create_new_reminder", { + remind_at: this.dialog.get_value("remind_at"), + description: this.dialog.get_value("description"), + reminder_doctype: this.frm?.doc.doctype, + reminder_docname: this.frm?.doc.name, + }) + .then((reminder) => { + frappe.show_alert(__("Reminder set at {0}", [reminder.remind_at])); + }); + } +} diff --git a/frappe/public/js/frappe/form/toolbar.js b/frappe/public/js/frappe/form/toolbar.js index 0052ccf5c2..497ca68cd3 100644 --- a/frappe/public/js/frappe/form/toolbar.js +++ b/frappe/public/js/frappe/form/toolbar.js @@ -2,6 +2,7 @@ // MIT License. See license.txt import "./linked_with"; import "./form_viewers"; +import { ReminderManager } from "./reminders"; frappe.ui.form.Toolbar = class Toolbar { constructor(opts) { @@ -436,6 +437,19 @@ frappe.ui.form.Toolbar = class Toolbar { ); } + this.page.add_menu_item( + __("Remind Me"), + () => { + let reminder_maanger = new ReminderManager({ frm: this.frm }); + reminder_maanger.show(); + }, + true, + { + shortcut: "Shift+R", + condition: () => !this.frm.is_new(), + } + ); + this.make_customize_buttons(); // Auto Repeat