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:
Ankush Menat 2023-02-22 11:01:52 +05:30
parent 0925453adb
commit 73cd823a0a
9 changed files with 308 additions and 0 deletions

View 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) {
// },
// });

View 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"
}

View 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()

View 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}",
)

View file

@ -20,6 +20,7 @@ DEFAULT_LOGTYPES_RETENTION = {
"Submission Queue": 30,
"Prepared Report": 30,
"Webhook Request Log": 30,
"DocReminder": 30,
}

View file

@ -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",

View 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]));
});
}
}

View file

@ -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