From 1e31769efa9d8ebb2487d6d02ab84b386c05b68b Mon Sep 17 00:00:00 2001 From: Narayan Gawas Date: Thu, 14 May 2026 12:13:49 +0000 Subject: [PATCH 1/3] Added ui for disabling weekly digest --- .../src/components/Settings/SettingsTab.vue | 67 ++++++++++++++++++- frontend/src/data/users.ts | 2 + gameplan/api.py | 20 +++++- gameplan/patches.txt | 1 + .../patches/add_weekly_digest_user_field.py | 18 +++++ 5 files changed, 105 insertions(+), 3 deletions(-) create mode 100644 gameplan/patches/add_weekly_digest_user_field.py diff --git a/frontend/src/components/Settings/SettingsTab.vue b/frontend/src/components/Settings/SettingsTab.vue index 8a05f860e..cce01a643 100644 --- a/frontend/src/components/Settings/SettingsTab.vue +++ b/frontend/src/components/Settings/SettingsTab.vue @@ -1,10 +1,73 @@ + + diff --git a/frontend/src/data/users.ts b/frontend/src/data/users.ts index 6d81477a4..6a46fcd33 100644 --- a/frontend/src/data/users.ts +++ b/frontend/src/data/users.ts @@ -12,6 +12,7 @@ interface UserInfo { user_image: string full_name: string user_type: string + weekly_digest: boolean user_profile: string image_background_color: string is_image_background_removed: number @@ -62,6 +63,7 @@ function getPlaceholderUser(email: string, full_name: string) { email: email, full_name: full_name, user_image: '', + weekly_digest: false, role: 'Gameplan Member', enabled: 1, user_profile: '', diff --git a/gameplan/api.py b/gameplan/api.py index 8abb64335..12a1d5a6f 100644 --- a/gameplan/api.py +++ b/gameplan/api.py @@ -22,7 +22,15 @@ def get_user_info(user=None): users = frappe.qb.get_query( "User", filters=filters, - fields=["name", "email", "enabled", "user_image", "full_name", "user_type"], + fields=[ + "name", + "email", + "enabled", + "user_image", + "full_name", + "user_type", + "weekly_digest", + ], order_by="full_name asc", distinct=True, ).run(as_dict=1) @@ -79,6 +87,16 @@ def get_user_info(user=None): return users +@frappe.whitelist() +@validate_type +def update_weekly_digest(weekly_digest: bool): + if frappe.session.user == "Guest": + frappe.throw("Authentication failed", exc=frappe.AuthenticationError) + + frappe.db.set_value("User", frappe.session.user, "weekly_digest", weekly_digest) + return weekly_digest + + @frappe.whitelist() @validate_type def change_user_role(user: str, role: str): diff --git a/gameplan/patches.txt b/gameplan/patches.txt index 7f5e73994..1ab7da9c3 100644 --- a/gameplan/patches.txt +++ b/gameplan/patches.txt @@ -30,3 +30,4 @@ gameplan.gameplan.doctype.gp_unread_record.patches.migrate_to_unread_records gameplan.gameplan.doctype.gp_pinned_project.patches.delete_pinned_projects_for_archived_spaces gameplan.gameplan.doctype.gp_discussion.patches.set_default_pin_scope gameplan.gameplan.doctype.gp_comment.patches.backfill_gp_comment_edited_at +gameplan.patches.add_weekly_digest_user_field diff --git a/gameplan/patches/add_weekly_digest_user_field.py b/gameplan/patches/add_weekly_digest_user_field.py new file mode 100644 index 000000000..3eb68ee39 --- /dev/null +++ b/gameplan/patches/add_weekly_digest_user_field.py @@ -0,0 +1,18 @@ +from frappe.custom.doctype.custom_field.custom_field import create_custom_fields + + +def execute(): + create_custom_fields( + { + "User": [ + { + "fieldname": "weekly_digest", + "label": "Weekly Digest", + "fieldtype": "Check", + "default": "0", + "insert_after": "thread_notify", + } + ] + }, + ignore_validate=True, + ) From bc86821bc1b7585795503180982d82125df233d7 Mon Sep 17 00:00:00 2001 From: Narayan Gawas Date: Fri, 15 May 2026 05:55:43 +0000 Subject: [PATCH 2/3] added email template and digest builder --- .../src/components/Settings/SettingsTab.vue | 4 +- gameplan/demo/user.py | 31 ++ gameplan/digest.py | 474 ++++++++++++++++++ gameplan/hooks.py | 1 + .../patches/add_weekly_digest_user_field.py | 4 +- 5 files changed, 510 insertions(+), 4 deletions(-) create mode 100644 gameplan/digest.py diff --git a/frontend/src/components/Settings/SettingsTab.vue b/frontend/src/components/Settings/SettingsTab.vue index cce01a643..74c811796 100644 --- a/frontend/src/components/Settings/SettingsTab.vue +++ b/frontend/src/components/Settings/SettingsTab.vue @@ -16,9 +16,9 @@
-

Weekly digest

+

Inactivity digest

- Get a weekly email summary of updates from your projects if you've been inactive for a while. + Email me a catch-up summary when I have been inactive for 7 days.

diff --git a/gameplan/demo/user.py b/gameplan/demo/user.py index f7b17702c..f367efaad 100644 --- a/gameplan/demo/user.py +++ b/gameplan/demo/user.py @@ -165,7 +165,37 @@ def generate_users_data(): "years_experience": random.randint(1, 15), } + narayan ={ + "email": "narayan.gawas@spit.ac.in", + "first_name": "Narayan", + "last_name": "Gawas", + "avatar_url": avatar_url, + "department": department, + "job_title": job_title, + "bio": bio, + "readme": readme_content, + "location": random.choice( + [ + "San Francisco, CA", + "New York, NY", + "Austin, TX", + "Seattle, WA", + "Boston, MA", + "London, UK", + "Berlin, Germany", + "Toronto, Canada", + "Remote", + "Amsterdam, Netherlands", + "Barcelona, Spain", + "Sydney, Australia", + ] + ), + "years_experience": random.randint(1, 15), + + } + users.append(user) + users.append(narayan) return users @@ -279,6 +309,7 @@ def create_user_and_profile(user_data): "last_name": user_data["last_name"], "user_image": user_data.get("avatar_url"), "send_welcome_email": 0, + "new_password": "Test@123", "user_type": "System User", "roles": [{"role": "Gameplan Member"}], } diff --git a/gameplan/digest.py b/gameplan/digest.py new file mode 100644 index 000000000..42c627db0 --- /dev/null +++ b/gameplan/digest.py @@ -0,0 +1,474 @@ +from __future__ import annotations + +import time +from random import randint +from html import escape + +import frappe +from frappe.utils import add_days, get_url, now_datetime, strip_html_tags + + +INACTIVE_DAYS = 7 +DIGEST_WINDOW_DAYS = 7 +DIGEST_LIMIT = 10 + + +def send_inactivity_digest_weekly(): + """Send a weekly catch-up digest to opted-in users inactive for 7 days.""" + try: + users = get_inactive_digest_users() + for user in users: + digest = build_user_digest(user.name) + if not has_digest_items(digest): + frappe.log_error("No digest items found", "Gameplan Digest") + print(f"No digest items found for {user.email}, skipping email") + continue + + try: + frappe.sendmail( + recipients=user.email, + subject="Your Gameplan catch-up digest", + message=render_digest_email(user, digest), + now=True, + ) + except frappe.OutgoingEmailError as e: + # If outgoing Email Account isn't configured, don't fail the whole job. + frappe.log_error( + f"Error sending inactivity digest to {user.email}: {str(e)}", + "Gameplan Digest", + ) + print(f"Skipping email for {user.email}: {str(e)}") + continue + except Exception as e: + frappe.log_error(f"Error sending inactivity digest: {str(e)}", "Gameplan Digest") + print(f"Error sending inactivity digest: {str(e)}") + except Exception as e: + frappe.log_error(f"Error sending inactivity digest: {str(e)}", "Gameplan Digest") + print(f"Error sending inactivity digest: {str(e)}") + + + + +def get_inactive_digest_users(inactive_days: int = INACTIVE_DAYS): + """Return opted-in users whose last activity is older than the inactivity threshold. + + Seven days is long enough to avoid nudging people who only missed a day or two, + and short enough that active project discussions are still fresh. + """ + cutoff = add_days(now_datetime(), -inactive_days) + return frappe.db.sql( + """ + select u.name, u.email, u.full_name, u.last_active + from `tabUser` u + where u.enabled = 1 + and u.user_type = 'Website User' + and u.weekly_digest = 1 + and ( + u.last_active <= %(cutoff)s + or (u.last_active is null and u.creation <= %(cutoff)s) + ) + and exists ( + select 1 + from `tabHas Role` role + where role.parenttype = 'User' + and role.parent = u.name + and role.role like 'Gameplan %%' + ) + order by u.last_active asc + """, + {"cutoff": cutoff}, + as_dict=True, + ) + + +def build_user_digest(user: str, window_days: int = DIGEST_WINDOW_DAYS): + window_start = add_days(now_datetime(), -window_days) + digest = { + "new_discussions": get_new_discussions_in_user_spaces(user, window_start), + "thread_replies": get_replies_in_user_threads(user, window_start), + "notifications": get_unread_notifications(user, window_start), + } + log_message = f"Built digest for {user}: {len(digest['new_discussions'])} new discussions, {len(digest['thread_replies'])} thread replies, {len(digest['notifications'])} notifications" + frappe.log_error(log_message, "Gameplan Digest") + print(log_message) + return digest + + +def get_new_discussions_in_user_spaces(user: str, window_start): + return frappe.db.sql( + """ + select + discussion.name, + discussion.title, + discussion.slug, + discussion.project, + project.title as project_title, + discussion.owner, + discussion.creation + from `tabGP Discussion` discussion + inner join `tabGP Project` project on project.name = discussion.project + where discussion.creation >= %(window_start)s + and discussion.owner != %(user)s + and project.archived_at is null + and exists ( + select 1 + from `tabGP Member` member + where member.parenttype = 'GP Project' + and member.parent = discussion.project + and member.user = %(user)s + ) + order by discussion.creation desc + limit %(limit)s + """, + {"user": user, "window_start": window_start, "limit": DIGEST_LIMIT}, + as_dict=True, + ) + + +def get_replies_in_user_threads(user: str, window_start): + return frappe.db.sql( + """ + select + comment.name, + comment.owner, + comment.creation, + discussion.name as discussion, + discussion.title, + discussion.slug, + discussion.project, + project.title as project_title + from `tabGP Comment` comment + inner join `tabGP Discussion` discussion on discussion.name = comment.reference_name + inner join `tabGP Project` project on project.name = discussion.project + where comment.reference_doctype = 'GP Discussion' + and comment.creation >= %(window_start)s + and comment.owner != %(user)s + and comment.deleted_at is null + and ( + discussion.owner = %(user)s + or exists ( + select 1 + from `tabGP Comment` user_comment + where user_comment.reference_doctype = 'GP Discussion' + and user_comment.reference_name = discussion.name + and user_comment.owner = %(user)s + and user_comment.deleted_at is null + ) + ) + order by comment.creation desc + limit %(limit)s + """, + {"user": user, "window_start": window_start, "limit": DIGEST_LIMIT}, + as_dict=True, + ) + + +def get_unread_notifications(user: str, window_start): + return frappe.get_all( + "GP Notification", + filters={ + "to_user": user, + "read": 0, + "creation": [">=", window_start], + }, + fields=["name", "type", "message", "discussion", "project", "creation", "from_user"], + order_by="creation desc", + limit=DIGEST_LIMIT, + ) + + +def has_digest_items(digest: dict) -> bool: + return any(digest.get(key) for key in ("new_discussions", "thread_replies", "notifications")) + + +# def render_digest_email(user, digest: dict) -> str: +# name = escape(user.full_name or user.name) +# return f""" +#

Hi {name},

+#

You have been away from Gameplan for at least {INACTIVE_DAYS} days. Here is what changed in the last {DIGEST_WINDOW_DAYS} days.

+# {render_discussion_list("New discussions in your spaces", digest["new_discussions"])} +# {render_reply_list("Replies on threads you are in", digest["thread_replies"])} +# {render_notification_list("Unread mentions and direct notifications", digest["notifications"])} +#

Open Gameplan

+# """ + + +```python +def render_digest_email(user, digest: dict) -> str: + name = escape(user.full_name or user.name) + + new_discussions = digest.get("new_discussions", []) + thread_replies = digest.get("thread_replies", []) + notifications = digest.get("notifications", []) + + return f""" + + +
+ + +
+ {len(new_discussions)} new discussions, + {len(thread_replies)} replies, + and {len(notifications)} notifications waiting for you. +
+ + +
+ + +
+

+ Gameplan Digest +

+ +

+ Hi {name}, +

+ Hereโ€™s what changed in the last + {DIGEST_WINDOW_DAYS} days. +

+
+ + +
+ {render_stat_card('๐Ÿ†• Discussions', len(new_discussions))} + {render_stat_card('๐Ÿ’ฌ Replies', len(thread_replies))} + {render_stat_card('๐Ÿ”” Notifications', len(notifications))} +
+ + + { + render_empty_digest() + if not new_discussions + and not thread_replies + and not notifications + else "" + } + + + { + render_discussion_list( + "๐Ÿ†• New discussions in your spaces", + new_discussions, + ) + if new_discussions + else "" + } + + { + render_reply_list( + "๐Ÿ’ฌ Replies on threads you are in", + thread_replies, + ) + if thread_replies + else "" + } + + { + render_notification_list( + "๐Ÿ”” Unread mentions and notifications", + notifications, + ) + if notifications + else "" + } + + + + +
+ + +

+ Youโ€™re receiving this digest because you were inactive recently. +

+ +
+ + + """ + + +def render_empty_digest(): + return """ +
+
+ โœจ +
+ +

+ Youโ€™re all caught up +

+ +

+ No new discussions, replies, or notifications + were found for this digest period. +

+
+ """ +``` + + +def render_discussion_list(title: str, rows: list) -> str: + if not rows: + return "" + items = "\n".join( + f'
  • {escape(row.title)} in {escape(row.project_title or "a space")}
  • ' + for row in rows + ) + return f"

    {escape(title)}

      {items}
    " + + +def render_reply_list(title: str, rows: list) -> str: + if not rows: + return "" + items = "\n".join( + f'
  • {escape(row.title)} in {escape(row.project_title or "a space")}
  • ' + for row in rows + ) + return f"

    {escape(title)}

      {items}
    " + + +def render_notification_list(title: str, rows: list) -> str: + if not rows: + return "" + items = "\n".join( + f'
  • {escape(row.type)}: {escape(strip_html_tags(row.message or ""))}
  • ' + for row in rows + ) + return f"

    {escape(title)}

      {items}
    " + + +def discussion_url(row) -> str: + slug = f"/{row.slug}" if row.get("slug") else "" + return get_url(f"/g/space/{row.project}/discussion/{row.get('discussion') or row.name}{slug}") + + +def notification_url(row) -> str: + if row.get("discussion") and row.get("project"): + return get_url(f"/g/space/{row.project}/discussion/{row.discussion}") + return get_url("/g/notifications") + + +def log_inactive_digest_users(inactive_days: int = INACTIVE_DAYS): + users = get_inactive_digest_users(inactive_days) + usernames = [user.name for user in users] + message = f"Inactive digest users ({inactive_days}+ days): {', '.join(usernames) if usernames else 'none'}" + frappe.logger("gameplan.digest").info(message) + print(message) + return users + + +def run_inactivity_digest_dev_loop(interval_seconds: int = 5, max_runs: int = 0): + """Print inactive digest users every 5 seconds for local development. + + Frappe scheduler cron is minute-granularity, so this helper gives a true 5-second loop + without changing production scheduler behavior. Set max_runs=0 to run until interrupted. + """ + run_count = 0 + while True: + log_inactive_digest_users() + run_count += 1 + if max_runs and run_count >= max_runs: + break + time.sleep(interval_seconds) + + +def backdate_user_login_for_tests(users: list[str], update_last_active: bool = True): + """Move users' login timestamps 8 days back for local digest testing.""" + days_back = 8 + login_at = add_days(now_datetime(), -days_back) + results = [] + + for user in users: + fields = {"last_login": login_at} + + if update_last_active and frappe.get_meta("User").has_field("last_active"): + fields["last_active"] = login_at + + frappe.db.set_value("User", user, fields, update_modified=False) + + print(f"Updated {user}: {', '.join(fields)} = {login_at} ({days_back} days back)") + results.append({"user": user, "days_back": days_back, "updated_fields": fields}) + + frappe.db.commit() + return results diff --git a/gameplan/hooks.py b/gameplan/hooks.py index 0b5e89442..ae8fb7f5e 100644 --- a/gameplan/hooks.py +++ b/gameplan/hooks.py @@ -160,6 +160,7 @@ scheduler_events = { "hourly": ["gameplan.gameplan.doctype.gp_invitation.gp_invitation.expire_invitations"], "daily": ["gameplan.demo.demo.generate_data_daily"], + "weekly": ["gameplan.digest.send_inactivity_digest_weekly"], } # scheduler_events = { diff --git a/gameplan/patches/add_weekly_digest_user_field.py b/gameplan/patches/add_weekly_digest_user_field.py index 3eb68ee39..4645ce2a9 100644 --- a/gameplan/patches/add_weekly_digest_user_field.py +++ b/gameplan/patches/add_weekly_digest_user_field.py @@ -7,9 +7,9 @@ def execute(): "User": [ { "fieldname": "weekly_digest", - "label": "Weekly Digest", + "label": "Inactivity Digest", "fieldtype": "Check", - "default": "0", + "default": "1", "insert_after": "thread_notify", } ] From 0abca4320699bed1a5aa821b446251d303318f41 Mon Sep 17 00:00:00 2001 From: Narayan Gawas Date: Fri, 15 May 2026 07:10:20 +0000 Subject: [PATCH 3/3] Refactored email template UI --- gameplan/digest.py | 236 +++++++++++++++++++++++++++++++++++++++------ 1 file changed, 207 insertions(+), 29 deletions(-) diff --git a/gameplan/digest.py b/gameplan/digest.py index 42c627db0..589f50a8e 100644 --- a/gameplan/digest.py +++ b/gameplan/digest.py @@ -47,8 +47,22 @@ def send_inactivity_digest_weekly(): print(f"Error sending inactivity digest: {str(e)}") +from werkzeug.wrappers import Response +@frappe.whitelist(allow_guest=True) +def preview_digest(user="narayan.gawas@spit.ac.in"): + digest = build_user_digest(user) + + user_doc = frappe.get_doc("User", user) + + html = render_digest_email(user_doc, digest) + + return Response( + html, + content_type="text/html; charset=utf-8", + ) + def get_inactive_digest_users(inactive_days: int = INACTIVE_DAYS): """Return opted-in users whose last activity is older than the inactivity threshold. @@ -94,6 +108,37 @@ def build_user_digest(user: str, window_days: int = DIGEST_WINDOW_DAYS): return digest +# def get_new_discussions_in_user_spaces(user: str, window_start): +# return frappe.db.sql( +# """ +# select +# discussion.name, +# discussion.title, +# discussion.slug, +# discussion.project, +# project.title as project_title, +# discussion.owner, +# discussion.creation +# from `tabGP Discussion` discussion +# inner join `tabGP Project` project on project.name = discussion.project +# where discussion.creation >= %(window_start)s +# and discussion.owner != %(user)s +# and project.archived_at is null +# and exists ( +# select 1 +# from `tabGP Member` member +# where member.parenttype = 'GP Project' +# and member.parent = discussion.project +# and member.user = %(user)s +# ) +# order by discussion.creation desc +# limit %(limit)s +# """, +# {"user": user, "window_start": window_start, "limit": DIGEST_LIMIT}, +# as_dict=True, +# ) + + def get_new_discussions_in_user_spaces(user: str, window_start): return frappe.db.sql( """ @@ -106,21 +151,19 @@ def get_new_discussions_in_user_spaces(user: str, window_start): discussion.owner, discussion.creation from `tabGP Discussion` discussion - inner join `tabGP Project` project on project.name = discussion.project + inner join `tabGP Project` project + on project.name = discussion.project where discussion.creation >= %(window_start)s and discussion.owner != %(user)s and project.archived_at is null - and exists ( - select 1 - from `tabGP Member` member - where member.parenttype = 'GP Project' - and member.parent = discussion.project - and member.user = %(user)s - ) order by discussion.creation desc limit %(limit)s """, - {"user": user, "window_start": window_start, "limit": DIGEST_LIMIT}, + { + "user": user, + "window_start": window_start, + "limit": DIGEST_LIMIT, + }, as_dict=True, ) @@ -181,19 +224,8 @@ def has_digest_items(digest: dict) -> bool: return any(digest.get(key) for key in ("new_discussions", "thread_replies", "notifications")) -# def render_digest_email(user, digest: dict) -> str: -# name = escape(user.full_name or user.name) -# return f""" -#

    Hi {name},

    -#

    You have been away from Gameplan for at least {INACTIVE_DAYS} days. Here is what changed in the last {DIGEST_WINDOW_DAYS} days.

    -# {render_discussion_list("New discussions in your spaces", digest["new_discussions"])} -# {render_reply_list("Replies on threads you are in", digest["thread_replies"])} -# {render_notification_list("Unread mentions and direct notifications", digest["notifications"])} -#

    Open Gameplan

    -# """ -```python def render_digest_email(user, digest: dict) -> str: name = escape(user.full_name or user.name) @@ -255,7 +287,9 @@ def render_digest_email(user, digest: dict) -> str: "> Hi {name},

    - Hereโ€™s what changed in the last + You've been away from Gameplan for at least + {INACTIVE_DAYS} days. + Here's what changed in the last {DIGEST_WINDOW_DAYS} days.

    @@ -281,7 +315,7 @@ def render_digest_email(user, digest: dict) -> str: else "" } - + { render_discussion_list( "๐Ÿ†• New discussions in your spaces", @@ -291,6 +325,7 @@ def render_digest_email(user, digest: dict) -> str: else "" } + { render_reply_list( "๐Ÿ’ฌ Replies on threads you are in", @@ -300,6 +335,7 @@ def render_digest_email(user, digest: dict) -> str: else "" } + { render_notification_list( "๐Ÿ”” Unread mentions and notifications", @@ -349,6 +385,35 @@ def render_digest_email(user, digest: dict) -> str: """ +def render_stat_card(label, value): + return f""" +
    +
    + {label} +
    + +
    + {value} +
    +
    + """ + + def render_empty_digest(): return """
    str: + return f""" +
    + +

    + {escape(title)} +

    + + {content} + +
    + """ def render_discussion_list(title: str, rows: list) -> str: if not rows: return "" + items = "\n".join( - f'
  • {escape(row.title)} in {escape(row.project_title or "a space")}
  • ' + f''' + +
    + {escape(row.title)} +
    + +
    + in {escape(row.project_title or "a space")} +
    +
    + ''' for row in rows ) - return f"

    {escape(title)}

      {items}
    " + + return render_section(title, items) def render_reply_list(title: str, rows: list) -> str: if not rows: return "" + items = "\n".join( - f'
  • {escape(row.title)} in {escape(row.project_title or "a space")}
  • ' + f''' + +
    + {escape(row.title)} +
    + +
    + New reply in {escape(row.project_title or "a space")} +
    +
    + ''' for row in rows ) - return f"

    {escape(title)}

      {items}
    " + + return render_section(title, items) def render_notification_list(title: str, rows: list) -> str: if not rows: return "" + items = "\n".join( - f'
  • {escape(row.type)}: {escape(strip_html_tags(row.message or ""))}
  • ' + f''' + +
    + {escape(row.type or "Notification")} +
    + +
    + {escape(strip_html_tags(row.message or ""))} +
    +
    + ''' for row in rows ) - return f"

    {escape(title)}

      {items}
    " + return render_section(title, items) def discussion_url(row) -> str: slug = f"/{row.slug}" if row.get("slug") else ""