feat: Document Reminders
There is `Notification` doctype but it doesn't work so well for small one-off reminders. Imagine these scenarios: 1. Remind me to follow up on this lead in 5 days. 2. Remind me to revoke temporary access I am giving to this user. For such scenarios, I am proposing a simple reminder system built into framework. All it does is: 1. For any document you can set a reminder with time and message. 2. When time comes you'll get a system notification with message and link to the document. Permissions: 1. Users can only see their own set reminders.
This commit is contained in:
parent
0925453adb
commit
73cd823a0a
9 changed files with 308 additions and 0 deletions
0
frappe/automation/doctype/docreminder/__init__.py
Normal file
0
frappe/automation/doctype/docreminder/__init__.py
Normal file
8
frappe/automation/doctype/docreminder/docreminder.js
Normal file
8
frappe/automation/doctype/docreminder/docreminder.js
Normal file
|
|
@ -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) {
|
||||
|
||||
// },
|
||||
// });
|
||||
85
frappe/automation/doctype/docreminder/docreminder.json
Normal file
85
frappe/automation/doctype/docreminder/docreminder.json
Normal file
|
|
@ -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"
|
||||
}
|
||||
78
frappe/automation/doctype/docreminder/docreminder.py
Normal file
78
frappe/automation/doctype/docreminder/docreminder.py
Normal file
|
|
@ -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()
|
||||
28
frappe/automation/doctype/docreminder/test_docreminder.py
Normal file
28
frappe/automation/doctype/docreminder/test_docreminder.py
Normal file
|
|
@ -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}",
|
||||
)
|
||||
|
|
@ -20,6 +20,7 @@ DEFAULT_LOGTYPES_RETENTION = {
|
|||
"Submission Queue": 30,
|
||||
"Prepared Report": 30,
|
||||
"Webhook Request Log": 30,
|
||||
"DocReminder": 30,
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
93
frappe/public/js/frappe/form/reminders.js
Normal file
93
frappe/public/js/frappe/form/reminders.js
Normal file
|
|
@ -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]));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue