diff --git a/frappe/boot.py b/frappe/boot.py index e3d9e5bf1a1..2befb0937b4 100644 --- a/frappe/boot.py +++ b/frappe/boot.py @@ -8,6 +8,7 @@ import frappe.defaults import frappe.desk.desk_page from frappe.core.doctype.navbar_settings.navbar_settings import get_app_logo, get_navbar_settings +from frappe.desk.doctype.changelog_feed.changelog_feed import get_changelog_feed_items from frappe.desk.doctype.form_tour.form_tour import get_onboarding_ui_tours from frappe.desk.doctype.route_history.route_history import frequently_visited_links from frappe.desk.form.load import get_meta_bundle @@ -107,6 +108,7 @@ def get_bootinfo(): bootinfo.translated_doctypes = get_translated_doctypes() bootinfo.subscription_conf = add_subscription_conf() bootinfo.marketplace_apps = get_marketplace_apps() + bootinfo.changelog_feed = get_changelog_feed_items() return bootinfo diff --git a/frappe/core/doctype/scheduled_job_type/scheduled_job_type.py b/frappe/core/doctype/scheduled_job_type/scheduled_job_type.py index 346f93ff07f..8006a43e937 100644 --- a/frappe/core/doctype/scheduled_job_type/scheduled_job_type.py +++ b/frappe/core/doctype/scheduled_job_type/scheduled_job_type.py @@ -127,7 +127,7 @@ def get_next_execution(self): next_execution = croniter(self.cron_format, last_execution).get_next(datetime) jitter = 0 - if self.frequency in ("Hourly Long", "Daily Long"): + if "Long" in self.frequency: jitter = randint(1, 600) return next_execution + timedelta(seconds=jitter) diff --git a/frappe/core/doctype/scheduled_job_type/test_scheduled_job_type.py b/frappe/core/doctype/scheduled_job_type/test_scheduled_job_type.py index 861726f6d48..968c4ee1327 100644 --- a/frappe/core/doctype/scheduled_job_type/test_scheduled_job_type.py +++ b/frappe/core/doctype/scheduled_job_type/test_scheduled_job_type.py @@ -51,7 +51,7 @@ def test_weekly_job(self): dict(method="frappe.social.doctype.energy_point_log.energy_point_log.send_weekly_summary"), ) job.db_set("last_execution", "2019-01-01 00:00:00") - self.assertTrue(job.is_event_due(get_datetime("2019-01-06 00:00:01"))) + self.assertTrue(job.is_event_due(get_datetime("2019-01-06 00:10:01"))) # +10 min because of jitter self.assertFalse(job.is_event_due(get_datetime("2019-01-02 00:00:06"))) self.assertFalse(job.is_event_due(get_datetime("2019-01-05 23:59:59"))) diff --git a/frappe/core/doctype/user/test_user.py b/frappe/core/doctype/user/test_user.py index 3cd1a192931..fa56c4f5d42 100644 --- a/frappe/core/doctype/user/test_user.py +++ b/frappe/core/doctype/user/test_user.py @@ -291,7 +291,7 @@ def test_rate_limiting_for_reset_password(self): res1 = c.session.post(url, data=data, verify=c.verify, headers=c.headers) res2 = c.session.post(url, data=data, verify=c.verify, headers=c.headers) self.assertEqual(res1.status_code, 404) - self.assertEqual(res2.status_code, 417) + self.assertEqual(res2.status_code, 429) def test_user_rename(self): old_name = "test_user_rename@example.com" diff --git a/frappe/desk/doctype/changelog_feed/__init__.py b/frappe/desk/doctype/changelog_feed/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/frappe/desk/doctype/changelog_feed/changelog_feed.js b/frappe/desk/doctype/changelog_feed/changelog_feed.js new file mode 100644 index 00000000000..44fa5ce24fc --- /dev/null +++ b/frappe/desk/doctype/changelog_feed/changelog_feed.js @@ -0,0 +1,8 @@ +// Copyright (c) 2023, Frappe Technologies and contributors +// For license information, please see license.txt + +// frappe.ui.form.on("Changelog Feed", { +// refresh(frm) { + +// }, +// }); diff --git a/frappe/desk/doctype/changelog_feed/changelog_feed.json b/frappe/desk/doctype/changelog_feed/changelog_feed.json new file mode 100644 index 00000000000..c1e8e1596fb --- /dev/null +++ b/frappe/desk/doctype/changelog_feed/changelog_feed.json @@ -0,0 +1,70 @@ +{ + "actions": [], + "allow_rename": 1, + "beta": 1, + "creation": "2023-05-16 19:37:51.047664", + "default_view": "List", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "title", + "app_name", + "link", + "posting_timestamp" + ], + "fields": [ + { + "fieldname": "title", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Title", + "reqd": 1 + }, + { + "fieldname": "app_name", + "fieldtype": "Data", + "label": "App Name" + }, + { + "fieldname": "link", + "fieldtype": "Long Text", + "in_list_view": 1, + "label": "Link", + "reqd": 1 + }, + { + "fieldname": "posting_timestamp", + "fieldtype": "Datetime", + "label": "Posting Timestamp", + "reqd": 1, + "search_index": 1 + } + ], + "in_create": 1, + "index_web_pages_for_search": 1, + "links": [], + "modified": "2024-04-08 18:36:42.203032", + "modified_by": "Administrator", + "module": "Desk", + "name": "Changelog Feed", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1, + "write": 1 + } + ], + "read_only": 1, + "sort_field": "creation", + "sort_order": "DESC", + "states": [] +} \ No newline at end of file diff --git a/frappe/desk/doctype/changelog_feed/changelog_feed.py b/frappe/desk/doctype/changelog_feed/changelog_feed.py new file mode 100644 index 00000000000..97888cd354a --- /dev/null +++ b/frappe/desk/doctype/changelog_feed/changelog_feed.py @@ -0,0 +1,93 @@ +# Copyright (c) 2023, Frappe Technologies and contributors +# For license information, please see license.txt + + +import requests + +import frappe +from frappe.model.document import Document +from frappe.utils.caching import redis_cache +from frappe.utils.data import add_to_date + + +class ChangelogFeed(Document): + # begin: auto-generated types + # This code is auto-generated. Do not modify anything in this block. + + from typing import TYPE_CHECKING + + if TYPE_CHECKING: + from frappe.types import DF + + app_name: DF.Data | None + link: DF.LongText + posting_timestamp: DF.Datetime + title: DF.Data + # end: auto-generated types + + pass + + +def fetch_changelog_feed(): + """Fetches changelog feed items from source using `get_changelog_feed` hook and stores in the db""" + since = frappe.db.get_value( + "Changelog Feed", + filters={}, + fieldname="posting_timestamp", + order_by="posting_timestamp desc", + ) or add_to_date(None, months=-1, as_datetime=True, as_string=False) + + for fn in frappe.get_hooks("get_changelog_feed"): + try: + cache_key = f"changelog_feed::{fn}" + changelog_feed = frappe.cache.get_value(cache_key, shared=True) + if changelog_feed is None: + changelog_feed = frappe.call(fn, since=since)[:20] or [] + frappe.cache.set_value( + cache_key, changelog_feed, expires_in_sec=7 * 24 * 60 * 60, shared=True + ) + + for feed_item in changelog_feed: + feed = { + "title": feed_item["title"], + "app_name": feed_item["app_name"], + "link": feed_item["link"], + "posting_timestamp": feed_item["creation"], + } + if not frappe.db.exists("Changelog Feed", feed): + frappe.new_doc("Changelog Feed").update(feed).insert() + except Exception: + frappe.log_error(f"Failed to fetch changelog from {fn}") + # don't retry if it's broken for 1 week + frappe.cache.set_value(cache_key, [], expires_in_sec=7 * 24 * 60 * 60, shared=True) + + +@redis_cache +def get_changelog_feed_items(): + """Returns a list of latest 10 changelog feed items""" + feed = frappe.get_all( + "Changelog Feed", + fields=["title", "app_name", "link", "posting_timestamp"], + # allow pubishing feed for many apps with single hook + filters={"app_name": ("in", frappe.get_installed_apps())}, + order_by="posting_timestamp desc", + limit=20, + ) + for f in feed: + f["app_title"] = _app_title(f["app_name"]) + + return feed + + +def _app_title(app_name): + try: + return frappe.get_hooks("app_title", app_name=app_name)[0] + except Exception: + return app_name + + +def get_feed(since): + """'What's New' feed implementation for Frappe""" + r = requests.get(f"https://frappe.io/api/method/changelog_feed?since={since}") + r.raise_for_status() + return r.json()["message"] diff --git a/frappe/desk/doctype/changelog_feed/test_changelog_feed.py b/frappe/desk/doctype/changelog_feed/test_changelog_feed.py new file mode 100644 index 00000000000..9427051f10c --- /dev/null +++ b/frappe/desk/doctype/changelog_feed/test_changelog_feed.py @@ -0,0 +1,9 @@ +# Copyright (c) 2023, Frappe Technologies and Contributors +# See license.txt + +# import frappe +from frappe.tests.utils import FrappeTestCase + + +class TestChangelogFeed(FrappeTestCase): + pass diff --git a/frappe/exceptions.py b/frappe/exceptions.py index f4bcb661f13..26178d0c239 100644 --- a/frappe/exceptions.py +++ b/frappe/exceptions.py @@ -121,7 +121,7 @@ class InvalidSignatureError(ValidationError): class RateLimitExceededError(ValidationError): - pass + http_status_code = 429 class CannotChangeConstantError(ValidationError): diff --git a/frappe/hooks.py b/frappe/hooks.py index b907d75fd06..6002624f360 100644 --- a/frappe/hooks.py +++ b/frappe/hooks.py @@ -259,6 +259,7 @@ "frappe.desk.form.document_follow.send_weekly_updates", "frappe.social.doctype.energy_point_log.energy_point_log.send_weekly_summary", "frappe.integrations.doctype.google_drive.google_drive.weekly_backup", + "frappe.desk.doctype.changelog_feed.changelog_feed.fetch_changelog_feed", ], "monthly": [ "frappe.email.doctype.auto_email_report.auto_email_report.send_monthly", @@ -455,6 +456,8 @@ "frappe.utils.sentry.add_bootinfo", ] +get_changelog_feed = "frappe.desk.doctype.changelog_feed.changelog_feed.get_feed" + export_python_type_annotations = True # log doctype cleanups to automatically add in log settings diff --git a/frappe/public/js/frappe/ui/notifications/notifications.js b/frappe/public/js/frappe/ui/notifications/notifications.js index 15f7de8d9f9..6755286a3a6 100644 --- a/frappe/public/js/frappe/ui/notifications/notifications.js +++ b/frappe/public/js/frappe/ui/notifications/notifications.js @@ -15,6 +15,7 @@ frappe.ui.Notifications = class Notifications { this.body = this.dropdown_list.find(".notification-list-body"); this.panel_events = this.dropdown_list.find(".panel-events"); this.panel_notifications = this.dropdown_list.find(".panel-notifications"); + this.panel_changelog_feed = this.dropdown_list.find(".panel-changelog-feed"); this.user = frappe.session.user; @@ -52,11 +53,17 @@ frappe.ui.Notifications = class Notifications { el: this.panel_notifications, }, { - label: __("Today's Events"), + label: __("Events"), id: "todays_events", view: EventsView, el: this.panel_events, }, + { + label: __("What's New"), + id: "changelog_feed", + view: ChangelogFeedView, + el: this.panel_changelog_feed, + }, ]; let get_headers_html = (item) => { @@ -439,3 +446,53 @@ class EventsView extends BaseNotificationsView { this.container.html(html); } } + +class ChangelogFeedView extends BaseNotificationsView { + make() { + this.render_changelog_feed_html(frappe.boot.changelog_feed || []); + } + + render_changelog_feed_html(changelog_feed) { + let html = ""; + if (changelog_feed.length) { + this.container.empty(); + const get_changelog_feed_html = (changelog_feed_item) => { + const timestamp = frappe.datetime.prettyDate( + changelog_feed_item.posting_timestamp + ); + const message_html = `
+
${changelog_feed_item.title}
+
+ ${changelog_feed_item.app_title} | ${timestamp} +
+
`; + + const item_html = ` +
+ ${message_html} +
+ +
`; + + return item_html; + }; + html = changelog_feed.map(get_changelog_feed_html).join(""); + } else { + html = `
+
+ Generic Empty State +
${__("Nothing New")}
+
+ ${__("There is nothing new to show you right now.")} +
+
+
+ `; + } + this.container.html(html); + } +} diff --git a/frappe/public/js/frappe/ui/toolbar/navbar.html b/frappe/public/js/frappe/ui/toolbar/navbar.html index 4e7794f26e9..081b2435048 100644 --- a/frappe/public/js/frappe/ui/toolbar/navbar.html +++ b/frappe/public/js/frappe/ui/toolbar/navbar.html @@ -60,6 +60,7 @@
+
@@ -150,4 +151,4 @@ {% endif %} - \ No newline at end of file + diff --git a/frappe/rate_limiter.py b/frappe/rate_limiter.py index 25d2fbf1730..b97af2e8e7b 100644 --- a/frappe/rate_limiter.py +++ b/frappe/rate_limiter.py @@ -146,7 +146,8 @@ def wrapper(*args, **kwargs): value = frappe.cache.incrby(cache_key, 1) if value > _limit: frappe.throw( - _("You hit the rate limit because of too many requests. Please try after sometime.") + _("You hit the rate limit because of too many requests. Please try after sometime."), + frappe.RateLimitExceededError, ) return fn(*args, **kwargs) diff --git a/frappe/tests/test_auth.py b/frappe/tests/test_auth.py index eb34f694fb1..abdff2e19e0 100644 --- a/frappe/tests/test_auth.py +++ b/frappe/tests/test_auth.py @@ -152,7 +152,7 @@ def test_login_with_email_link(self): # Rate limiting for _ in range(6): res = requests.get(_generate_temporary_login_link(user, 10)) - if res.status_code == 417: + if res.status_code == 429: break else: self.fail("Rate limting not working")