Merge branch 'develop' into load-address-and-contact-display

This commit is contained in:
Raffael Meyer 2023-03-02 12:36:26 +01:00 committed by GitHub
commit 53d74f8c6e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
67 changed files with 993 additions and 179 deletions

View file

@ -148,4 +148,39 @@ context("Workspace Blocks", () => {
.should("eq", "Pending");
cy.go("back");
});
it("Number Card Block", () => {
cy.create_records([
{
doctype: "Number Card",
label: "Test Number Card",
document_type: "ToDo",
color: "#f74343",
},
]);
cy.get(".codex-editor__redactor .ce-block");
cy.get(".standard-actions .btn-secondary[data-label=Edit]").click();
cy.get(".ce-block").first().click({ force: true }).type("{enter}");
cy.get(".block-list-container .block-list-item").contains("Number Card").click();
// add number card
cy.fill_field("number_card_name", "Test Number Card", "Link");
cy.get('[data-fieldname="number_card_name"] ul li').contains("Test Number Card").click();
cy.click_modal_primary_button("Add");
cy.get(".ce-block .number-widget-box").first().as("number_card");
cy.get("@number_card").find(".widget-title").should("contain", "Test Number Card");
cy.get('.standard-actions .btn-primary[data-label="Save"]').click();
cy.get("@number_card").find(".widget-title").should("contain", "Test Number Card");
// edit number card
cy.get(".standard-actions .btn-secondary[data-label=Edit]").click();
cy.get("@number_card").realHover().find(".widget-control .edit-button").click();
cy.get_field("label", "Data").invoke("val", "ToDo Count");
cy.click_modal_primary_button("Save");
cy.get("@number_card").find(".widget-title").should("contain", "ToDo Count");
cy.get('.standard-actions .btn-primary[data-label="Save"]').click();
cy.get("@number_card").find(".widget-title").should("contain", "ToDo Count");
});
});

View file

@ -622,6 +622,7 @@ def sendmail(
header=None,
print_letterhead=False,
with_container=False,
email_read_tracker_url=None,
):
"""Send email using user's default **Email Account** or global default **Email Account**.
@ -703,6 +704,7 @@ def sendmail(
header=header,
print_letterhead=print_letterhead,
with_container=with_container,
email_read_tracker_url=email_read_tracker_url,
)
# build email queue and send the email if send_now is True.

View file

@ -77,8 +77,9 @@ def application(request: Request):
if request.method in UNSAFE_HTTP_METHODS and frappe.db and rollback:
frappe.db.rollback()
for after_request_task in frappe.get_hooks("after_request"):
frappe.call(after_request_task, response=response, request=request)
if getattr(frappe.local, "initialised", False):
for after_request_task in frappe.get_hooks("after_request"):
frappe.call(after_request_task, response=response, request=request)
log_request(request, response)
process_response(response)

View file

@ -0,0 +1,8 @@
// Copyright (c) 2023, Frappe Technologies and contributors
// For license information, please see license.txt
// frappe.ui.form.on("Reminder", {
// refresh(frm) {
// },
// });

View file

@ -0,0 +1,90 @@
{
"actions": [],
"autoname": "hash",
"creation": "2023-02-22 11:23:58.183276",
"default_view": "List",
"doctype": "DocType",
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
"user",
"remind_at",
"description",
"reminder_doctype",
"reminder_docname",
"notified"
],
"fields": [
{
"default": "__user",
"fieldname": "user",
"fieldtype": "Link",
"hidden": 1,
"label": "User",
"options": "User",
"reqd": 1,
"search_index": 1
},
{
"fieldname": "reminder_doctype",
"fieldtype": "Link",
"in_list_view": 1,
"label": "Document Type",
"options": "DocType",
"read_only": 1
},
{
"fieldname": "reminder_docname",
"fieldtype": "Dynamic Link",
"in_list_view": 1,
"label": "Document Name",
"options": "reminder_doctype",
"read_only": 1
},
{
"default": "now",
"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",
"hidden": 1,
"label": "notified"
}
],
"index_web_pages_for_search": 1,
"links": [],
"modified": "2023-02-24 13:47:50.419648",
"modified_by": "Administrator",
"module": "Automation",
"name": "Reminder",
"naming_rule": "Random",
"owner": "Administrator",
"permissions": [
{
"create": 1,
"delete": 1,
"if_owner": 1,
"read": 1,
"role": "All",
"write": 1
}
],
"quick_entry": 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 Reminder(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("Reminder")
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("Reminder")
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(
"Reminder",
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("Reminder", 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.reminder.reminder 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 TestReminder(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

@ -12,6 +12,7 @@ from frappe.utils import (
cint,
get_datetime,
get_formatted_email,
get_imaginary_pixel_response,
get_string_between,
list_to_str,
split_emails,
@ -249,18 +250,7 @@ def mark_email_as_seen(name: str = None):
frappe.log_error("Unable to mark as seen", None, "Communication", name)
finally:
frappe.response.update(
{
"type": "binary",
"filename": "imaginary_pixel.png",
"filecontent": (
b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00"
b"\x00\x01\x08\x06\x00\x00\x00\x1f\x15\xc4\x89\x00\x00\x00\r"
b"IDATx\x9cc\xf8\xff\xff?\x03\x00\x08\xfc\x02\xfe\xa7\x9a\xa0"
b"\xa0\x00\x00\x00\x00IEND\xaeB`\x82"
),
}
)
frappe.response.update(frappe.utils.get_imaginary_pixel_response())
def update_communication_as_read(name):

View file

@ -4,5 +4,6 @@
# import frappe
{base_class_import}
class {classname}({base_class}):
{custom_controller}

View file

@ -55,7 +55,7 @@ frappe.ui.form.on("DocType", {
msg += __("If you just want to customize for your site, use {0} instead.", [
customize_form_link,
]);
frm.dashboard.add_comment(msg, "yellow");
frm.dashboard.add_comment(msg, "yellow", true);
}
if (frm.is_new()) {

View file

@ -19,6 +19,8 @@ DEFAULT_LOGTYPES_RETENTION = {
"Route History": 90,
"Submission Queue": 30,
"Prepared Report": 30,
"Webhook Request Log": 30,
"Reminder": 30,
}

View file

@ -141,6 +141,8 @@ def serialize_job(job: Job) -> frappe._dict:
creation=convert_utc_to_user_timezone(job.created_at),
modified=convert_utc_to_user_timezone(modified),
_comment_count=0,
owner=job.kwargs.get("user"),
modified_by=job.kwargs.get("user"),
)

View file

@ -64,9 +64,9 @@ class Dashboard {
let title = this.dashboard_name;
if (!this.dashboard_name.toLowerCase().includes(__("dashboard"))) {
// ensure dashboard title has "dashboard"
title = __("{0} Dashboard", [title]);
title = __("{0} Dashboard", [__(title)]);
}
this.page.set_title(title);
this.page.set_title(__(title));
this.set_dropdown();
this.container.empty();
this.refresh();

View file

@ -233,7 +233,7 @@ class Database:
elif self.is_read_only_mode_error(e):
frappe.throw(
_(
"Site is running in read only mode, this action can not be performed right now. Please try again later."
"Site is running in read only mode for maintenance or site update, this action can not be performed right now. Please try again later."
),
title=_("In Read Only Mode"),
exc=frappe.InReadOnlyMode,
@ -813,7 +813,7 @@ class Database:
distinct=distinct,
limit=limit,
)
if fields == "*" and not isinstance(fields, (list, tuple)) and not isinstance(fields, Criterion):
if isinstance(fields, str) and fields == "*":
as_dict = True
return query.run(as_dict=as_dict, debug=debug, update=update, run=run, pluck=pluck)
@ -1070,13 +1070,7 @@ class Database:
if not datetime:
return FallBackDateTimeStr
if isinstance(datetime, str):
if ":" not in datetime:
datetime = datetime + " 00:00:00.000000"
else:
datetime = datetime.strftime("%Y-%m-%d %H:%M:%S.%f")
return datetime
return get_datetime(datetime).strftime("%Y-%m-%d %H:%M:%S.%f")
def get_creation_count(self, doctype, minutes):
"""Get count of records created in the last x minutes"""

View file

@ -158,14 +158,11 @@ class Workspace:
def build_workspace(self):
self.cards = {"items": self.get_links()}
self.charts = {"items": self.get_charts()}
self.shortcuts = {"items": self.get_shortcuts()}
self.onboardings = {"items": self.get_onboardings()}
self.quick_lists = {"items": self.get_quick_lists()}
self.number_cards = {"items": self.get_number_cards()}
def _doctype_contains_a_record(self, name):
exists = self.table_counts.get(name, False)
@ -332,6 +329,21 @@ class Workspace:
return steps
@handle_not_exist
def get_number_cards(self):
all_number_cards = []
if frappe.has_permission("Number Card", throw=False):
number_cards = self.doc.number_cards
for number_card in number_cards:
if frappe.has_permission("Number Card", doc=number_card.number_card_name):
# Translate label
number_card.label = (
_(number_card.label) if number_card.label else _(number_card.number_card_name)
)
all_number_cards.append(number_card)
return all_number_cards
@frappe.whitelist()
@frappe.read_only()
@ -354,6 +366,7 @@ def get_desktop_page(page):
"cards": workspace.cards,
"onboardings": workspace.onboardings,
"quick_lists": workspace.quick_lists,
"number_cards": workspace.number_cards,
}
except DoesNotExistError:
frappe.log_error("Workspace Missing")
@ -482,6 +495,10 @@ def save_new_widget(doc, page, blocks, new_widgets):
doc.shortcuts.extend(new_widget(widgets.shortcut, "Workspace Shortcut", "shortcuts"))
if widgets.quick_list:
doc.quick_lists.extend(new_widget(widgets.quick_list, "Workspace Quick List", "quick_lists"))
if widgets.number_card:
doc.number_cards.extend(
new_widget(widgets.number_card, "Workspace Number Card", "number_cards")
)
if widgets.card:
doc.build_links_table_from_card(widgets.card)
@ -511,12 +528,12 @@ def save_new_widget(doc, page, blocks, new_widgets):
def clean_up(original_page, blocks):
page_widgets = {}
for wid in ["shortcut", "card", "chart", "quick_list"]:
for wid in ["shortcut", "card", "chart", "quick_list", "number_card"]:
# get list of widget's name from blocks
page_widgets[wid] = [x["data"][wid + "_name"] for x in loads(blocks) if x["type"] == wid]
# shortcut, chart & quick_list cleanup
for wid in ["shortcut", "chart", "quick_list"]:
# shortcut, chart, quick_list & number_card cleanup
for wid in ["shortcut", "chart", "quick_list", "number_card"]:
updated_widgets = []
original_page.get(wid + "s").reverse()

View file

@ -92,4 +92,7 @@ def get_permission_query_conditions(user):
@frappe.whitelist()
def set_seen_value(value, user):
if frappe.flags.read_only:
return
frappe.db.set_value("Notification Settings", user, "seen", value, update_modified=False)

View file

@ -124,10 +124,10 @@ def get_result(doc, filters, to_date=None):
)
]
filters = frappe.parse_json(filters)
if not filters:
filters = []
elif isinstance(filters, str):
filters = frappe.parse_json(filters)
if to_date:
filters.append([doc.document_type, "creation", "<", to_date])

View file

@ -21,6 +21,8 @@
"public",
"is_hidden",
"content",
"number_cards_tab",
"number_cards",
"tab_break_2",
"charts",
"tab_break_15",
@ -181,11 +183,22 @@
"fieldname": "is_hidden",
"fieldtype": "Check",
"label": "Is Hidden"
},
{
"fieldname": "number_cards_tab",
"fieldtype": "Tab Break",
"label": "Number Cards"
},
{
"fieldname": "number_cards",
"fieldtype": "Table",
"label": "Number Cards",
"options": "Workspace Number Card"
}
],
"in_create": 1,
"links": [],
"modified": "2023-01-07 19:37:39.512482",
"modified": "2023-02-15 01:16:56.035205",
"modified_by": "Administrator",
"module": "Desk",
"name": "Workspace",
@ -208,4 +221,4 @@
"sort_field": "modified",
"sort_order": "DESC",
"states": []
}
}

View file

@ -0,0 +1,40 @@
{
"actions": [],
"allow_rename": 1,
"creation": "2023-02-15 01:16:26.216201",
"doctype": "DocType",
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
"number_card_name",
"label"
],
"fields": [
{
"fieldname": "number_card_name",
"fieldtype": "Link",
"in_list_view": 1,
"label": "Number Card Name",
"options": "Number Card",
"reqd": 1
},
{
"fieldname": "label",
"fieldtype": "Data",
"in_list_view": 1,
"label": "Label"
}
],
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2023-02-15 01:16:26.216201",
"modified_by": "Administrator",
"module": "Desk",
"name": "Workspace Number Card",
"owner": "Administrator",
"permissions": [],
"sort_field": "modified",
"sort_order": "DESC",
"states": []
}

View file

@ -0,0 +1,9 @@
# Copyright (c) 2023, Frappe Technologies and contributors
# For license information, please see license.txt
# import frappe
from frappe.model.document import Document
class WorkspaceNumberCard(Document):
pass

View file

@ -657,8 +657,6 @@ class EmailAccount(Document):
if not email_server:
return
email_server.connect()
if email_server.imap:
try:
message = safe_encode(message)

View file

@ -31,6 +31,7 @@ from frappe.utils import (
sbool,
split_emails,
)
from frappe.utils.verified_command import get_signed_params
class EmailQueue(Document):
@ -283,7 +284,9 @@ class SendMailContext:
if not message:
return ""
message = message.replace(self.message_placeholder("tracker"), self.get_tracker_str())
message = message.replace(
self.message_placeholder("tracker"), self.get_tracker_str(recipient_email)
)
message = message.replace(
self.message_placeholder("unsubscribe_url"), self.get_unsubscribe_str(recipient_email)
)
@ -294,10 +297,24 @@ class SendMailContext:
message = self.include_attachments(message)
return message
def get_tracker_str(self) -> str:
if frappe.conf.use_ssl and self.email_account_doc.track_email_status:
tracker_url_html = f'<img src="{get_url()}/api/method/frappe.core.doctype.communication.email.mark_email_as_seen?name={self.queue_doc.communication}"/>'
def get_tracker_str(self, recipient_email) -> str:
tracker_url = ""
if self.queue_doc.get("email_read_tracker_url"):
email_read_tracker_url = self.queue_doc.email_read_tracker_url
params = {
"recipient_email": recipient_email,
"reference_name": self.queue_doc.reference_name,
"reference_doctype": self.queue_doc.reference_doctype,
}
tracker_url = get_url(f"{email_read_tracker_url}?{get_signed_params(params)}")
elif frappe.conf.use_ssl and self.email_account_doc.track_email_status:
tracker_url = f"{get_url()}/api/method/frappe.core.doctype.communication.email.mark_email_as_seen?name={self.queue_doc.communication}"
if tracker_url:
tracker_url_html = f'<img src="{tracker_url}"/>'
return quopri.encodestring(tracker_url_html.encode()).decode()
return ""
def get_unsubscribe_str(self, recipient_email: str) -> str:
@ -428,6 +445,7 @@ class QueueBuilder:
header=None,
print_letterhead=False,
with_container=False,
email_read_tracker_url=None,
):
"""Add email to sending queue (Email Queue)
@ -452,6 +470,7 @@ class QueueBuilder:
:param inline_images: List of inline images as {"filename", "filecontent"}. All src properties will be replaced with random Content-Id
:param header: Append header in email (boolean)
:param with_container: Wraps email inside styled container
:param email_read_tracker_url: A URL for tracking whether an email is read by the recipient.
"""
self._unsubscribe_method = unsubscribe_method
@ -486,6 +505,7 @@ class QueueBuilder:
self.is_notification = is_notification
self.inline_images = inline_images
self.print_letterhead = print_letterhead
self.email_read_tracker_url = email_read_tracker_url
@property
def unsubscribe_method(self):
@ -723,6 +743,7 @@ class QueueBuilder:
"show_as_cc": ",".join(self.final_cc()),
"show_as_bcc": ",".join(self.bcc),
"email_account": email_account_name or None,
"email_read_tracker_url": self.email_read_tracker_url,
}
if include_recipients:

View file

@ -12,6 +12,7 @@
"column_break_3",
"total_recipients",
"column_break_12",
"total_views",
"email_sent",
"from_section",
"sender_name",
@ -228,6 +229,14 @@
{
"fieldname": "column_break_3",
"fieldtype": "Column Break"
},
{
"default": "0",
"fieldname": "total_views",
"fieldtype": "Int",
"label": "Total Views",
"no_copy": 1,
"read_only": 1
}
],
"has_web_view": 1,
@ -236,7 +245,7 @@
"index_web_pages_for_search": 1,
"is_published_field": "published",
"links": [],
"modified": "2022-03-09 01:48:16.741603",
"modified": "2023-02-23 12:53:18.478018",
"modified_by": "Administrator",
"module": "Email",
"name": "Newsletter",
@ -258,6 +267,7 @@
"route": "newsletters",
"sort_field": "modified",
"sort_order": "ASC",
"states": [],
"title_field": "subject",
"track_changes": 1
}

View file

@ -54,7 +54,7 @@ class Newsletter(WebsiteGenerator):
@frappe.whitelist()
def send_test_email(self, email):
test_emails = frappe.utils.validate_email_address(email, throw=True)
self.send_newsletter(emails=test_emails)
self.send_newsletter(emails=test_emails, test_email=True)
frappe.msgprint(_("Test email sent to {0}").format(email), alert=True)
@frappe.whitelist()
@ -162,7 +162,7 @@ class Newsletter(WebsiteGenerator):
"""Get list of attachments on current Newsletter"""
return [{"file_url": row.attachment} for row in self.attachments]
def send_newsletter(self, emails: list[str]):
def send_newsletter(self, emails: list[str], test_email: bool = False):
"""Trigger email generation for `emails` and add it in Email Queue."""
attachments = self.get_newsletter_attachments()
sender = self.send_from or frappe.utils.get_formatted_email(self.owner)
@ -186,6 +186,9 @@ class Newsletter(WebsiteGenerator):
queue_separately=True,
send_priority=0,
args=args,
email_read_tracker_url=None
if test_email
else "/api/method/frappe.email.doctype.newsletter.newsletter.newsletter_email_read",
)
frappe.db.auto_commit_on_many_writes = is_auto_commit_set
@ -197,7 +200,28 @@ class Newsletter(WebsiteGenerator):
if self.content_type == "HTML":
message = self.message_html
return frappe.render_template(message, {"doc": self.as_dict()})
html = frappe.render_template(message, {"doc": self.as_dict()})
return self.add_source(html)
def add_source(self, html: str) -> str:
"""Add source to the site links in the newsletter content."""
from bs4 import BeautifulSoup
soup = BeautifulSoup(html, "html.parser")
links = soup.find_all("a")
for link in links:
href = link.get("href")
if href and not href.startswith("#"):
if not frappe.utils.is_site_link(href):
continue
new_href = frappe.utils.add_source_to_url(
href, reference_doctype=self.doctype, reference_docname=self.name
)
link["href"] = new_href
return str(soup)
def get_recipients(self) -> list[str]:
"""Get recipients from Email Group"""
@ -347,3 +371,20 @@ def send_scheduled_email():
if not frappe.flags.in_test:
frappe.db.commit()
@frappe.whitelist(allow_guest=True)
def newsletter_email_read(recipient_email, reference_doctype, reference_name):
verify_request()
try:
doc = frappe.get_doc(reference_doctype, reference_name)
if doc.add_viewed(recipient_email, force=True, unique_views=True):
doc.db_set("total_views", frappe.utils.cint(doc.total_views) + 1, update_modified=False)
except Exception:
frappe.log_error(
f"Unable to mark as viewed for {recipient_email}", None, reference_doctype, reference_name
)
finally:
frappe.response.update(frappe.utils.get_imaginary_pixel_response())

View file

@ -222,9 +222,7 @@ class EmailServer:
self.pop.dele(m)
except Exception as e:
if self.has_login_limit_exceeded(e):
pass
else:
if not self.has_login_limit_exceeded(e):
raise
out = {"latest_messages": self.latest_messages}
@ -368,7 +366,7 @@ class EmailServer:
self.seen_status.update({uid: "UNSEEN"})
def has_login_limit_exceeded(self, e):
return "-ERR Exceeded the login limit" in strip(cstr(e.message))
return "-ERR Exceeded the login limit" in strip(cstr(e))
def is_temporary_system_problem(self, e):
messages = (

View file

@ -51,7 +51,7 @@ def create_gps_markers(coords):
for i in coords:
node = {"type": "Feature", "properties": {}, "geometry": {"type": "Point", "coordinates": None}}
node["properties"]["name"] = i.name
node["geometry"]["coordinates"] = [i.latitude, i.longitude]
node["geometry"]["coordinates"] = [i.longitude, i.latitude] # geojson needs it reverse!
geojson_dict.append(node.copy())
return geojson_dict

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.reminder.reminder.send_reminders",
],
"hourly": [
"frappe.model.utils.link_count.update_link_count",
@ -392,6 +393,7 @@ ignore_links_on_delete = [
"Document Share Key",
"Integration Request",
"Unhandled Email",
"Webhook Request Log",
]
# Request Hooks

View file

@ -422,7 +422,10 @@ def insert_event_in_google_calendar(doc, method=None):
event = (
google_calendar.events()
.insert(
calendarId=doc.google_calendar_id, body=event, conferenceDataVersion=conference_data_version
calendarId=doc.google_calendar_id,
body=event,
conferenceDataVersion=conference_data_version,
sendUpdates="all",
)
.execute()
)
@ -504,6 +507,7 @@ def update_event_in_google_calendar(doc, method=None):
eventId=doc.google_calendar_event_id,
body=event,
conferenceDataVersion=conference_data_version,
sendUpdates="all",
)
.execute()
)

View file

@ -128,32 +128,40 @@ def enqueue_webhook(doc, webhook) -> None:
)
r.raise_for_status()
frappe.logger().debug({"webhook_success": r.text})
log_request(webhook.request_url, headers, data, r)
log_request(webhook.name, doc.name, webhook.request_url, headers, data, r)
break
except requests.exceptions.ReadTimeout as e:
frappe.logger().debug({"webhook_error": e, "try": i + 1})
log_request(webhook.request_url, headers, data)
log_request(webhook.name, doc.name, webhook.request_url, headers, data)
except Exception as e:
frappe.logger().debug({"webhook_error": e, "try": i + 1})
log_request(webhook.request_url, headers, data, r)
log_request(webhook.name, doc.name, webhook.request_url, headers, data, r)
sleep(3 * i + 1)
if i != 2:
continue
else:
webhook.log_error("Webhook failed")
def log_request(url: str, headers: dict, data: dict, res: requests.Response | None = None):
def log_request(
webhook: str,
docname: str,
url: str,
headers: dict,
data: dict,
res: requests.Response | None = None,
):
request_log = frappe.get_doc(
{
"doctype": "Webhook Request Log",
"webhook": webhook,
"reference_document": docname,
"user": frappe.session.user if frappe.session.user else None,
"url": url,
"headers": frappe.as_json(headers) if headers else None,
"data": frappe.as_json(data) if data else None,
"response": frappe.as_json(res.json()) if res else None,
"response": res and res.text,
"error": frappe.get_traceback(),
}
)

View file

@ -7,11 +7,14 @@
"engine": "InnoDB",
"field_order": [
"user",
"webhook",
"reference_document",
"headers",
"data",
"column_break_4",
"url",
"response"
"response",
"error"
],
"fields": [
{
@ -51,12 +54,32 @@
"label": "User",
"options": "User",
"read_only": 1
},
{
"fieldname": "reference_document",
"fieldtype": "Data",
"in_list_view": 1,
"in_standard_filter": 1,
"label": "Reference Document",
"read_only": 1
},
{
"fieldname": "error",
"fieldtype": "Text",
"label": "Error",
"read_only": 1
},
{
"fieldname": "webhook",
"fieldtype": "Link",
"label": "Webhook",
"options": "Webhook"
}
],
"in_create": 1,
"index_web_pages_for_search": 1,
"links": [],
"modified": "2022-05-03 09:33:49.240777",
"modified": "2023-02-24 14:59:24.743552",
"modified_by": "Administrator",
"module": "Integrations",
"name": "Webhook Request Log",

View file

@ -1,9 +1,15 @@
# Copyright (c) 2021, Frappe Technologies and contributors
# License: MIT. See LICENSE
# import frappe
import frappe
from frappe.model.document import Document
class WebhookRequestLog(Document):
pass
@staticmethod
def clear_old_logs(days=30):
from frappe.query_builder import Interval
from frappe.query_builder.functions import Now
table = frappe.qb.DocType("Webhook Request Log")
frappe.db.delete(table, filters=(table.modified < (Now() - Interval(days=days))))

View file

@ -762,6 +762,7 @@ class DatabaseQuery:
value = "('')"
else:
escape = True
df = meta.get("fields", {"fieldname": f.fieldname})
df = df[0] if df else None
@ -783,6 +784,7 @@ class DatabaseQuery:
or (df and (df.fieldtype == "Date" or df.fieldtype == "Datetime"))
):
escape = False
value = get_between_date_filter(f.value, df)
fallback = f"'{FallBackDateTimeStr}'"
@ -842,7 +844,7 @@ class DatabaseQuery:
value = f"{tname}.{quote}{f.value.name}{quote}"
# escape value
elif isinstance(value, str) and f.operator.lower() != "between":
elif escape and isinstance(value, str):
value = f"{frappe.db.escape(value, percent=False)}"
if (
@ -1175,20 +1177,6 @@ def get_order_by(doctype, meta):
return order_by
def is_parent_only_filter(doctype, filters):
# check if filters contains only parent doctype
only_parent_doctype = True
if isinstance(filters, list):
for filter in filters:
if doctype not in filter:
only_parent_doctype = False
if "Between" in filter:
filter[3] = get_between_date_filter(flt[3])
return only_parent_doctype
def has_any_user_permission_for_doctype(doctype, user, applicable_for):
user_permissions = frappe.permissions.get_user_permissions(user=user)
doctype_user_permissions = user_permissions.get(doctype, [])

View file

@ -1386,16 +1386,21 @@ class Document(BaseDocument):
frappe.db.set_value(self.doctype, self.name, "_seen", json.dumps(_seen), update_modified=False)
frappe.local.flags.commit = True
def add_viewed(self, user=None):
def add_viewed(self, user=None, force=False, unique_views=False):
"""add log to communication when a user views a document"""
if not user:
user = frappe.session.user
if hasattr(self.meta, "track_views") and self.meta.track_views:
if unique_views and frappe.db.exists(
"View Log", {"reference_doctype": self.doctype, "reference_name": self.name, "viewed_by": user}
):
return
if (hasattr(self.meta, "track_views") and self.meta.track_views) or force:
view_log = frappe.get_doc(
{
"doctype": "View Log",
"viewed_by": frappe.session.user,
"viewed_by": user,
"reference_doctype": self.doctype,
"reference_name": self.name,
}
@ -1406,6 +1411,8 @@ class Document(BaseDocument):
view_log.insert(ignore_permissions=True)
frappe.local.flags.commit = True
return view_log
def log_error(self, title=None, message=None):
"""Helper function to create an Error Log"""
return frappe.log_error(

View file

@ -65,6 +65,7 @@ def sync_for(app_name, force=0, reset_permissions=False):
"workspace_chart",
"workspace_shortcut",
"workspace_quick_list",
"workspace_number_card",
"workspace",
]:
files.append(os.path.join(FRAPPE_PATH, "desk", "doctype", desk_module, f"{desk_module}.json"))

View file

@ -835,6 +835,13 @@
<path d="M16.814 13.3304L17.9274 12.6875" stroke="var(--icon-stroke)" stroke-linecap="round" stroke-linejoin="round"/>
</symbol>
<symbol viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg" id="icon-number-card">
<rect width="15" height="11" x="2.5" y="4.5" stroke="var(--icon-stroke)" rx="2.5"/>
<path stroke="var(--icon-stroke)" stroke-linecap="round" d="m9 13.2717 2.1349-2.0457 1.3641 1.3642L14.9999 10"/>
<path stroke="var(--icon-stroke)" stroke-linecap="round" d="M13.1812 10H15v1.8188"/>
<path stroke="#000" stroke-linecap="round" stroke-width=".7" d="M5.53911 7.55204h2.60407M5.25 8.83237h2.60407M6.49749 6.67866l-.82364 3.07385M7.79075 6.67866l-.82364 3.07385"/>
</symbol>
<symbol viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg" id="icon-dashboard-list">
<path d="M7.5 2.5H4.5C3.94772 2.5 3.5 2.94772 3.5 3.5V9.5C3.5 10.0523 3.94772 10.5 4.5 10.5H7.5C8.05228 10.5 8.5 10.0523 8.5 9.5V3.5C8.5 2.94772 8.05228 2.5 7.5 2.5Z" stroke="var(--icon-stroke)" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M7.5 13.5H4.5C3.94772 13.5 3.5 13.9477 3.5 14.5V16.5C3.5 17.0523 3.94772 17.5 4.5 17.5H7.5C8.05228 17.5 8.5 17.0523 8.5 16.5V14.5C8.5 13.9477 8.05228 13.5 7.5 13.5Z" stroke="var(--icon-stroke)" stroke-linecap="round" stroke-linejoin="round"/>

Before

Width:  |  Height:  |  Size: 111 KiB

After

Width:  |  Height:  |  Size: 111 KiB

View file

@ -145,7 +145,7 @@ frappe.Application = class Application {
user: frappe.session.user,
},
callback: function (r) {
if (r.message.show_alert) {
if (r.message && r.message.show_alert) {
frappe.show_alert({
indicator: "red",
message: r.message.message,

View file

@ -34,35 +34,28 @@ frappe.dom = {
},
remove_script_and_style: function (txt) {
const evil_tags = ["script", "style", "noscript", "title", "meta", "base", "head"];
const regex = new RegExp(evil_tags.map((tag) => `<${tag}>.*<\\/${tag}>`).join("|"), "s");
if (!regex.test(txt)) {
// no evil tags found, skip the DOM method entirely!
return txt;
}
const parser = new DOMParser();
const doc = parser.parseFromString(txt, "text/html");
const body = doc.body;
let found = !!doc.head.innerHTML;
var div = document.createElement("div");
div.innerHTML = txt;
var found = false;
evil_tags.forEach(function (e) {
var elements = div.getElementsByTagName(e);
i = elements.length;
while (i--) {
for (const tag of evil_tags) {
for (const element of body.getElementsByTagName(tag)) {
found = true;
elements[i].parentNode.removeChild(elements[i]);
}
});
// remove links with rel="stylesheet"
var elements = div.getElementsByTagName("link");
var i = elements.length;
while (i--) {
if (elements[i].getAttribute("rel") == "stylesheet") {
found = true;
elements[i].parentNode.removeChild(elements[i]);
element.parentNode.removeChild(element);
}
}
for (const element of body.getElementsByTagName("link")) {
const relation = element.getAttribute("rel");
if (relation && relation.toLowerCase().trim() === "stylesheet") {
found = true;
element.parentNode.removeChild(element);
}
}
if (found) {
return div.innerHTML;
return body.innerHTML;
} else {
// don't disturb
return txt;

View file

@ -906,13 +906,13 @@ frappe.ui.form.Form = class FrappeForm {
.filter((link) => link.doctype == doctype)
.map((link) => frappe.utils.get_form_link(link.doctype, link.name, true))
.join(", ");
links_text += `<li><strong>${doctype}</strong>: ${docnames}</li>`;
links_text += `<li><strong>${__(doctype)}</strong>: ${docnames}</li>`;
}
}
links_text = `<ul>${links_text}</ul>`;
let confirm_message = __("{0} {1} is linked with the following submitted documents: {2}", [
me.doc.doctype.bold(),
__(me.doc.doctype).bold(),
me.doc.name,
links_text,
]);
@ -940,7 +940,7 @@ frappe.ui.form.Form = class FrappeForm {
// if user can cancel all linked docs, add action to the dialog
if (can_cancel) {
d.set_primary_action("Cancel All", () => {
d.set_primary_action(__("Cancel All"), () => {
d.hide();
frappe.call({
method: "frappe.desk.form.linked_with.cancel_all_linked_docs",

View file

@ -477,11 +477,16 @@ export default class Grid {
this.wrapper.find(".grid-add-multiple-rows").removeClass("hidden");
}
}
} else if (this.grid_rows.length < this.grid_pagination.page_length) {
} else if (
this.grid_rows.length < this.grid_pagination.page_length &&
!this.df.allow_bulk_edit
) {
this.wrapper.find(".grid-footer").toggle(false);
}
this.wrapper.find(".grid-add-row, .grid-add-multiple-rows").toggle(this.is_editable());
this.wrapper
.find(".grid-add-row, .grid-add-multiple-rows, .grid-upload")
.toggle(this.is_editable());
}
truncate_rows() {

View file

@ -939,12 +939,17 @@ export default class GridRow {
let grid_start = inital_position_x - event.touches[0].clientX;
let grid_end = grid.clientWidth - grid_container.clientWidth + 2;
if (frappe.utils.is_rtl()) {
grid_start = -grid_start;
}
if (grid_start < 0) {
grid_start = 0;
} else if (grid_start > grid_end) {
grid_start = grid_end;
}
grid.style.left = `-${grid_start}px`;
grid.style.left = `${frappe.utils.is_rtl() ? "" : "-"}${grid_start}px`;
}
})
.on("touchend", function () {

View file

@ -0,0 +1,100 @@
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" },
{ label: __("Custom"), value: "custom" },
],
default: "1_hour",
onchange: () => {
me._update_datetime_selector();
},
},
{
fieldtype: "Column Break",
fieldname: "col_break_1",
},
{
fieldtype: "Datetime",
label: __("Remind At"),
fieldname: "remind_at",
reqd: 1,
read_only: 1,
},
{
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._update_datetime_selector();
this.dialog.show();
}
_update_datetime_selector() {
this._convert_period_to_absolute_time();
this.dialog.fields_dict.remind_at.df.read_only =
this.dialog.get_value("remind_me_in") != "custom";
this.dialog.fields_dict.remind_at.refresh();
this.dialog.fields_dict.remind_at.datepicker?.update({
minDate: frappe.datetime.str_to_obj(frappe.datetime.now_datetime()),
});
}
_convert_period_to_absolute_time() {
const period = this.dialog.get_value("remind_me_in");
if (!period || period == "custom") 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.reminder.reminder.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}", [frappe.datetime.str_to_user(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

View file

@ -102,6 +102,7 @@ frappe.ui.form.States = class FormStates {
added = true;
me.frm.page.add_action_item(__(d.action), function () {
// set the workflow_action for use in form scripts
frappe.dom.freeze();
me.frm.selected_workflow_action = d.action;
me.frm.script_manager.trigger("before_workflow_action").then(() => {
frappe
@ -114,6 +115,9 @@ frappe.ui.form.States = class FormStates {
me.frm.refresh();
me.frm.selected_workflow_action = null;
me.frm.script_manager.trigger("after_workflow_action");
})
.finally(() => {
frappe.dom.unfreeze();
});
});
});

View file

@ -51,14 +51,14 @@ class Picker {
set_values();
}
});
this.search_input.keyup((e) => {
e.preventDefault();
this.filter_icons();
});
});
this.search_input.keyup((e) => {
e.preventDefault();
this.filter_icons();
});
this.search_input.on("search", () => {
this.filter_icons();
});
this.search_input.on("search", () => {
this.filter_icons();
});
}

View file

@ -149,9 +149,13 @@ $.extend(frappe.model, {
cur_frm.doc.doctype === doc.doctype &&
cur_frm.doc.name === doc.name
) {
if (!frappe.ui.form.is_saving && data.modified != cur_frm.doc.modified) {
doc.__needs_refresh = true;
cur_frm.show_conflict_message();
if (data.modified !== cur_frm.doc.modified) {
if (!cur_frm.is_dirty()) {
cur_frm.reload_doc();
} else if (!frappe.ui.form.is_saving) {
doc.__needs_refresh = true;
cur_frm.show_conflict_message();
}
}
} else {
if (!doc.__unsaved) {

View file

@ -237,18 +237,18 @@ class NotificationsView extends BaseNotificationsView {
this.change_activity_status();
}
get_dropdown_item_html(field) {
let doc_link = this.get_item_link(field);
get_dropdown_item_html(notification_log) {
let doc_link = this.get_item_link(notification_log);
let read_class = field.read ? "" : "unread";
let message = field.subject;
let read_class = notification_log.read ? "" : "unread";
let message = notification_log.subject;
let title = message.match(/<b class="subject-title">(.*?)<\/b>/);
message = title
? message.replace(title[1], frappe.ellipsis(strip_html(title[1]), 100))
: message;
let timestamp = frappe.datetime.comment_when(field.creation);
let timestamp = frappe.datetime.comment_when(notification_log.creation);
let message_html = `<div class="message">
<div>${message}</div>
<div class="notification-timestamp text-muted">
@ -256,12 +256,12 @@ class NotificationsView extends BaseNotificationsView {
</div>
</div>`;
let user = field.from_user;
let user = notification_log.from_user;
let user_avatar = frappe.avatar(user, "avatar-medium user-avatar");
let item_html = $(`<a class="recent-item notification-item ${read_class}"
href="${doc_link}"
data-name="${field.name}"
data-name="${notification_log.name}"
>
<div class="notification-body">
${user_avatar}
@ -271,18 +271,18 @@ class NotificationsView extends BaseNotificationsView {
</div>
</a>`);
if (!field.read) {
if (!notification_log.read) {
let mark_btn = item_html.find(".mark-as-read");
mark_btn.tooltip({ delay: { show: 600, hide: 100 }, trigger: "hover" });
mark_btn.on("click", (e) => {
e.preventDefault();
e.stopImmediatePropagation();
this.mark_as_read(field.name, item_html);
this.mark_as_read(notification_log.name, item_html);
});
}
item_html.on("click", () => {
!field.read && this.mark_as_read(field.name, item_html);
!notification_log.read && this.mark_as_read(notification_log.name, item_html);
this.notifications_icon.trigger("click");
});
@ -298,8 +298,8 @@ class NotificationsView extends BaseNotificationsView {
} else {
if (this.dropdown_items.length) {
this.container.empty();
this.dropdown_items.forEach((field) => {
this.container.append(this.get_dropdown_item_html(field));
this.dropdown_items.forEach((notification_log) => {
this.container.append(this.get_dropdown_item_html(notification_log));
});
this.container.append(`<a class="list-footer"
href="/app/List/Notification Log">

View file

@ -7,7 +7,7 @@
<div class="collapse navbar-collapse justify-content-end">
<form class="form-inline fill-width justify-content-end" role="search" onsubmit="return false;">
{% if (frappe.boot.read_only) { %}
<span class="indicator-pill yellow no-indicator-dot" title="{%= __("Your site is getting upgraded.") %}">
<span class="indicator-pill yellow no-indicator-dot" title="{%= __("Your site is undergoing maintenance or being updated.") %}">
{%= __("Read Only Mode") %}
</span>
{% } %}

View file

@ -36,10 +36,9 @@
</td>
{% for col in columns %}
{% if col.name && col._id !== "_check" %}
{% var value = col.fieldname ? row[col.fieldname] : row[col.id]; %}
<td {% if row.bold == 1 %} style="font-weight: bold" {% endif %}>
{% var value = col.fieldname ? row[col.fieldname] : row[col.id] %}
{% var longest_word = cstr(value).split(' ').reduce((longest, word) => word.length > longest.length ? word : longest, ''); %}
<td {% if row.bold == 1 %} style="font-weight: bold" {% endif %} {% if longest_word.length > 45 %} class="overflow-wrap-anywhere" {% endif %}>
<span {% if col._index == 0 %} style="padding-left: {%= cint(row.indent) * 2 %}em" {% endif %}>
{% format_data = row.is_total_row && ["Currency", "Float"].includes(col.fieldtype) ? data[0] : row %}
{% if (row.is_total_row && col._index == 0) { %}

View file

@ -95,6 +95,7 @@ export default class Block {
const dialog_class = get_dialog_constructor(widget_type);
let block_name = block + "_name";
this.dialog = new dialog_class({
for_workspace: true,
label: this.label,
type: widget_type,
primary_action: (widget) => {

View file

@ -7,6 +7,7 @@ import Shortcut from "./shortcut";
import Spacer from "./spacer";
import Onboarding from "./onboarding";
import QuickList from "./quick_list";
import NumberCard from "./number_card";
// import tunes
import HeaderSize from "./header_size";
@ -22,6 +23,7 @@ frappe.workspace_block.blocks = {
spacer: Spacer,
onboarding: Onboarding,
quick_list: QuickList,
number_card: NumberCard,
};
frappe.workspace_block.tunes = {

View file

@ -0,0 +1,63 @@
import Block from "./block.js";
export default class NumberCard extends Block {
static get toolbox() {
return {
title: "Number Card",
icon: frappe.utils.icon("number-card", "sm"),
};
}
static get isReadOnlySupported() {
return true;
}
constructor({ data, api, config, readOnly, block }) {
super({ data, api, config, readOnly, block });
this.sections = {};
this.col = this.data.col ? this.data.col : "4";
this.allow_customization = !this.readOnly;
this.options = {
allow_sorting: this.allow_customization,
allow_create: this.allow_customization,
allow_delete: this.allow_customization,
allow_hiding: false,
allow_edit: true,
allow_resize: true,
for_workspace: true,
};
}
render() {
this.wrapper = document.createElement("div");
this.new("number_card");
if (this.data && this.data.number_card_name) {
let has_data = this.make("number_card", this.data.number_card_name);
if (!has_data) return;
}
if (!this.readOnly) {
$(this.wrapper).find(".widget").addClass("number_card edit-mode");
this.add_settings_button();
this.add_new_block_button();
}
return this.wrapper;
}
validate(savedData) {
if (!savedData.number_card_name) {
return false;
}
return true;
}
save() {
return {
number_card_name: this.wrapper.getAttribute("number_card_name"),
col: this.get_col(),
new: this.new_block_widget,
};
}
}

View file

@ -390,6 +390,7 @@ frappe.views.Workspace = class Workspace {
this.editor.configuration.tools.card.config.page_data = this.page_data;
this.editor.configuration.tools.onboarding.config.page_data = this.page_data;
this.editor.configuration.tools.quick_list.config.page_data = this.page_data;
this.editor.configuration.tools.number_card.config.page_data = this.page_data;
this.editor.render({ blocks: this.content || [] });
});
} else {
@ -1334,9 +1335,16 @@ frappe.views.Workspace = class Workspace {
page_data: this.page_data || [],
},
},
number_card: {
class: this.blocks["number_card"],
config: {
page_data: this.page_data || [],
},
},
spacer: this.blocks["spacer"],
HeaderSize: frappe.workspace_block.tunes["header_size"],
};
this.editor = new EditorJS({
data: {
blocks: blocks || [],
@ -1425,27 +1433,27 @@ frappe.views.Workspace = class Workspace {
}
create_page_skeleton() {
if ($(".layout-main-section").find(".workspace-skeleton").length) return;
if (this.body.find(".workspace-skeleton").length) return;
$(".layout-main-section").prepend(frappe.render_template("workspace_loading_skeleton"));
$(".layout-main-section").find(".codex-editor").addClass("hidden");
this.body.prepend(frappe.render_template("workspace_loading_skeleton"));
this.body.find(".codex-editor").addClass("hidden");
}
remove_page_skeleton() {
$(".layout-main-section").find(".codex-editor").removeClass("hidden");
$(".layout-main-section").find(".workspace-skeleton").remove();
this.body.find(".codex-editor").removeClass("hidden");
this.body.find(".workspace-skeleton").remove();
}
create_sidebar_skeleton() {
if ($(".list-sidebar").find(".workspace-sidebar-skeleton").length) return;
if (this.sidebar.find(".workspace-sidebar-skeleton").length) return;
$(".list-sidebar").prepend(frappe.render_template("workspace_sidebar_loading_skeleton"));
$(".desk-sidebar").addClass("hidden");
this.sidebar.prepend(frappe.render_template("workspace_sidebar_loading_skeleton"));
this.sidebar.find(".standard-sidebar-section").addClass("hidden");
}
remove_sidebar_skeleton() {
$(".desk-sidebar").removeClass("hidden");
$(".list-sidebar").find(".workspace-sidebar-skeleton").remove();
this.sidebar.find(".standard-sidebar-section").removeClass("hidden");
this.sidebar.find(".workspace-sidebar-skeleton").remove();
}
register_awesomebar_shortcut() {

View file

@ -131,6 +131,7 @@ export default class Widget {
const dialog_class = get_dialog_constructor(this.widget_type);
this.edit_dialog = new dialog_class({
for_workspace: this.options?.for_workspace,
label: this.label,
type: this.widget_type,
values: this.get_config(),

View file

@ -11,6 +11,7 @@ export default class NumberCardWidget extends Widget {
get_config() {
return {
name: this.name,
number_card_name: this.number_card_name,
label: this.label,
color: this.color,
hidden: this.hidden,
@ -26,12 +27,8 @@ export default class NumberCardWidget extends Widget {
this.make_card();
}
set_title() {
$(this.title_field).html(`<div class="number-label">${this.card_doc.label}</div>`);
}
make_card() {
frappe.model.with_doc("Number Card", this.name).then((card) => {
frappe.model.with_doc("Number Card", this.number_card_name || this.name).then((card) => {
if (!card) {
if (this.document_type) {
frappe.run_serially([
@ -293,6 +290,8 @@ export default class NumberCardWidget extends Widget {
}
prepare_actions() {
if (this.in_customize_mode) return;
let actions = [
{
label: __("Refresh"),
@ -305,7 +304,8 @@ export default class NumberCardWidget extends Widget {
label: __("Edit"),
action: "action-edit",
handler: () => {
frappe.set_route("Form", "Number Card", this.name);
let number_card = this.number_card_name || this.name;
frappe.set_route("Form", "Number Card", number_card);
},
},
];

View file

@ -511,6 +511,32 @@ class NumberCardDialog extends WidgetDialog {
get_fields() {
let fields;
if (this.for_workspace) {
return [
{
fieldtype: "Link",
fieldname: "number_card_name",
label: __("Number Cards"),
options: "Number Card",
reqd: 1,
get_query: () => {
return {
query: "frappe.desk.doctype.number_card.number_card.get_cards_for_user",
filters: {
document_type: this.document_type,
},
};
},
},
{
fieldtype: "Data",
fieldname: "label",
label: __("Label"),
},
];
}
fields = [
{
fieldtype: "Select",
@ -605,7 +631,7 @@ class NumberCardDialog extends WidgetDialog {
}
setup_dialog_events() {
if (!this.document_type) {
if (!this.document_type && !this.for_workspace) {
if (this.default_values && this.default_values["doctype"]) {
this.document_type = this.default_values["doctype"];
this.setup_filter(this.default_values["doctype"]);
@ -638,12 +664,16 @@ class NumberCardDialog extends WidgetDialog {
}
process_data(data) {
if (this.for_workspace) {
data.label = data.label ? data.label : data.number_card_name;
return data;
}
if (data.new_or_existing == "Existing Card") {
data.name = data.card;
}
data.stats_filter = this.filter_group && JSON.stringify(this.filter_group.get_filters());
data.document_type = this.document_type;
return data;
}
}
@ -652,10 +682,10 @@ export default function get_dialog_constructor(type) {
const widget_map = {
chart: ChartDialog,
shortcut: ShortcutDialog,
number_card: NumberCardDialog,
links: CardDialog,
onboarding: OnboardingDialog,
quick_list: QuickListDialog,
number_card: NumberCardDialog,
};
return widget_map[type] || WidgetDialog;

View file

@ -1094,6 +1094,12 @@ body {
}
// widgets
.widget.number-widget-box {
box-shadow: var(--shadow-sm);
}
.codex-editor__loader {
display: none !important;
}

View file

@ -18,6 +18,11 @@
.print-hide {
display: none !important;
}
.overflow-wrap-anywhere {
* {
word-break: break-all;
}
}
}
.action-banner {
@ -34,4 +39,10 @@
img {
margin: auto;
}
}
.overflow-wrap-anywhere {
* {
overflow-wrap: anywhere;
}
}

View file

@ -142,6 +142,33 @@ class TestFilters(FrappeTestCase):
)
)
def test_date_time(self):
# date fields
self.assertTrue(
evaluate_filters(
{"doctype": "User", "birth_date": "2023-02-28"}, [("User", "birth_date", ">", "01-04-2022")]
)
)
self.assertFalse(
evaluate_filters(
{"doctype": "User", "birth_date": "2023-02-28"}, [("User", "birth_date", "<", "28-02-2023")]
)
)
# datetime fields
self.assertTrue(
evaluate_filters(
{"doctype": "User", "last_active": "2023-02-28 15:14:56"},
[("User", "last_active", ">", "01-04-2022 00:00:00")],
)
)
self.assertFalse(
evaluate_filters(
{"doctype": "User", "last_active": "2023-02-28 15:14:56"},
[("User", "last_active", "<", "28-02-2023 00:00:00")],
)
)
class TestMoney(FrappeTestCase):
def test_money_in_words(self):

View file

@ -12,7 +12,7 @@ import typing
from code import compile_command
from enum import Enum
from typing import Any, Literal, Optional, TypeVar, Union
from urllib.parse import quote, urljoin
from urllib.parse import parse_qsl, quote, urlencode, urljoin, urlparse, urlunparse
from click import secho
@ -1714,6 +1714,7 @@ def evaluate_filters(doc, filters: dict | list | tuple):
def compare(val1: Any, condition: str, val2: Any, fieldtype: str | None = None):
if fieldtype:
val1 = cast(fieldtype, val1)
val2 = cast(fieldtype, val2)
if condition in operator_map:
return operator_map[condition](val1, val2)
@ -2166,3 +2167,32 @@ def get_job_name(key: str, doctype: str = None, doc_name: str = None) -> str:
if doc_name:
job_name += f"_{doc_name}"
return job_name
def get_imaginary_pixel_response():
return {
"type": "binary",
"filename": "imaginary_pixel.png",
"filecontent": (
b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00"
b"\x00\x01\x08\x06\x00\x00\x00\x1f\x15\xc4\x89\x00\x00\x00\r"
b"IDATx\x9cc\xf8\xff\xff?\x03\x00\x08\xfc\x02\xfe\xa7\x9a\xa0"
b"\xa0\x00\x00\x00\x00IEND\xaeB`\x82"
),
}
def is_site_link(link: str) -> bool:
if link.startswith("/"):
return True
return urlparse(link).netloc == urlparse(frappe.utils.get_url()).netloc
def add_source_to_url(url: str, reference_doctype: str, reference_docname: str) -> str:
url_parts = list(urlparse(url))
query = dict(parse_qsl(url_parts[4])) | {
"source": f"{reference_doctype} > {reference_docname}",
}
url_parts[4] = urlencode(query)
return urlunparse(url_parts)

View file

@ -12,7 +12,9 @@
"browser_version",
"is_unique",
"time_zone",
"user_agent"
"user_agent",
"visitor_id",
"source"
],
"fields": [
{
@ -53,11 +55,24 @@
"fieldname": "user_agent",
"fieldtype": "Data",
"label": "User Agent"
},
{
"fieldname": "visitor_id",
"fieldtype": "Data",
"label": "Visitor ID",
"read_only": 1,
"search_index": 1
},
{
"fieldname": "source",
"fieldtype": "Data",
"label": "Source",
"read_only": 1
}
],
"in_create": 1,
"links": [],
"modified": "2022-09-13 15:38:25.401797",
"modified": "2023-02-28 11:55:04.533663",
"modified_by": "Administrator",
"module": "Website",
"name": "Web Page View",

View file

@ -1,7 +1,10 @@
# Copyright (c) 2020, Frappe Technologies and contributors
# License: MIT. See LICENSE
from urllib.parse import urlparse
import frappe
import frappe.utils
from frappe.model.document import Document
@ -10,23 +13,39 @@ class WebPageView(Document):
@frappe.whitelist(allow_guest=True)
def make_view_log(path, referrer=None, browser=None, version=None, url=None, user_tz=None):
def make_view_log(
referrer=None,
browser=None,
version=None,
user_tz=None,
source=None,
visitor_id=None,
):
if not is_tracking_enabled():
return
# real path
path = frappe.request.headers.get("Referer")
if not frappe.utils.is_site_link(path):
return
path = urlparse(path).path
request_dict = frappe.request.__dict__
user_agent = request_dict.get("environ", {}).get("HTTP_USER_AGENT")
if referrer:
referrer = referrer.split("?", 1)[0]
is_unique = True
if referrer.startswith(url):
is_unique = False
if path != "/" and path.startswith("/"):
path = path[1:]
if path.startswith(("api/", "app/", "assets/", "private/files/")):
return
is_unique = visitor_id and not bool(frappe.db.exists("Web Page View", {"visitor_id": visitor_id}))
view = frappe.new_doc("Web Page View")
view.path = path
view.referrer = referrer
@ -35,6 +54,8 @@ def make_view_log(path, referrer=None, browser=None, version=None, url=None, use
view.time_zone = user_tz
view.user_agent = user_agent
view.is_unique = is_unique
view.source = source
view.visitor_id = visitor_id
try:
if frappe.flags.read_only:

View file

@ -31,7 +31,7 @@ class StaticPage(BaseRenderer):
return self.is_valid_file_path() and self.file_path
def is_valid_file_path(self):
extension = self.path.rsplit(".", 1)[-1]
extension = self.path.rsplit(".", 1)[-1] if "." in self.path else ""
if extension in UNSUPPORTED_STATIC_PAGE_TYPES:
return False
return True

View file

@ -28,5 +28,36 @@ frappe.query_reports["Website Analytics"] = {
default: "Daily",
reqd: 1,
},
{
fieldname: "group_by",
label: __("Group By"),
fieldtype: "Select",
options: [
{ value: "path", label: __("Path") },
{ value: "browser", label: __("Browser") },
{ value: "referrer", label: __("Referrer") },
{ value: "source", label: __("Source") },
],
default: "path",
},
],
formatter: function (value, row, column, data, default_formatter) {
if (
frappe.query_report.get_filter_value("group_by") === "source" &&
column.id === "source"
) {
if (value) {
try {
let doctype = value.split(">")[0].trim();
let name = value.split(">")[1].trim();
return frappe.utils.get_form_link(doctype, name, true, value);
} catch (e) {
// skip and return with default formatter
}
} else {
return `<i>${__("Unknown")}</i>`;
}
}
return default_formatter(value, row, column, data);
},
};

View file

@ -28,6 +28,7 @@ class WebsiteAnalytics:
self.filters.to_date = frappe.utils.add_days(self.filters.to_date, 1)
self.query_filters = {"creation": ["between", [self.filters.from_date, self.filters.to_date]]}
self.group_by = self.filters.group_by
def run(self):
columns = self.get_columns()
@ -38,8 +39,16 @@ class WebsiteAnalytics:
return columns, data[:250], None, chart, summary
def get_columns(self):
meta = frappe.get_meta("Web Page View")
group_by = meta.get_field(self.group_by)
return [
{"fieldname": "path", "label": "Page", "fieldtype": "Data", "width": 300},
{
"fieldname": group_by.fieldname,
"label": group_by.label,
"fieldtype": "Data",
"width": 500,
"align": "left",
},
{"fieldname": "count", "label": "Page Views", "fieldtype": "Int", "width": 150},
{"fieldname": "unique_count", "label": "Unique Visitors", "fieldtype": "Int", "width": 150},
]
@ -52,12 +61,12 @@ class WebsiteAnalytics:
return (
frappe.qb.from_(WebPageView)
.select("path", count_all, count_is_unique)
.select(self.group_by, count_all, count_is_unique)
.where(
Coalesce(WebPageView.creation, "0001-01-01")[self.filters.from_date : self.filters.to_date]
)
.groupby(WebPageView.path)
.orderby("count", Order=frappe.qb.desc)
.groupby(self.group_by)
.orderby("count", order=frappe.qb.desc)
).run()
def _get_query_for_mariadb(self):

View file

@ -20,13 +20,21 @@ ga('send', 'pageview');
if (navigator.doNotTrack != 1 && !window.is_404) {
frappe.ready(() => {
let browser = frappe.utils.get_browser();
frappe.call("frappe.website.doctype.web_page_view.web_page_view.make_view_log", {
path: location.pathname,
referrer: document.referrer,
browser: browser.name,
version: browser.version,
url: location.origin,
user_tz: Intl.DateTimeFormat().resolvedOptions().timeZone
let query_params = frappe.utils.get_query_params();
// Get visitor ID based on browser uniqueness
import('https://openfpcdn.io/fingerprintjs/v3')
.then(fingerprint_js => fingerprint_js.load())
.then(fp => fp.get())
.then(result => {
frappe.call("frappe.website.doctype.web_page_view.web_page_view.make_view_log", {
referrer: document.referrer,
browser: browser.name,
version: browser.version,
user_tz: Intl.DateTimeFormat().resolvedOptions().timeZone,
source: query_params.source,
visitor_id: result.visitorId
})
})
})
}