Merge branch 'develop' into load-address-and-contact-display
This commit is contained in:
commit
53d74f8c6e
67 changed files with 993 additions and 179 deletions
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
0
frappe/automation/doctype/reminder/__init__.py
Normal file
0
frappe/automation/doctype/reminder/__init__.py
Normal file
8
frappe/automation/doctype/reminder/reminder.js
Normal file
8
frappe/automation/doctype/reminder/reminder.js
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
// Copyright (c) 2023, Frappe Technologies and contributors
|
||||
// For license information, please see license.txt
|
||||
|
||||
// frappe.ui.form.on("Reminder", {
|
||||
// refresh(frm) {
|
||||
|
||||
// },
|
||||
// });
|
||||
90
frappe/automation/doctype/reminder/reminder.json
Normal file
90
frappe/automation/doctype/reminder/reminder.json
Normal 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"
|
||||
}
|
||||
78
frappe/automation/doctype/reminder/reminder.py
Normal file
78
frappe/automation/doctype/reminder/reminder.py
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
# Copyright (c) 2023, Frappe Technologies and contributors
|
||||
# For license information, please see license.txt
|
||||
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.model.document import Document
|
||||
from frappe.utils import cint
|
||||
from frappe.utils.data import add_to_date, get_datetime, now_datetime
|
||||
|
||||
|
||||
class 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()
|
||||
28
frappe/automation/doctype/reminder/test_reminder.py
Normal file
28
frappe/automation/doctype/reminder/test_reminder.py
Normal 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}",
|
||||
)
|
||||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -4,5 +4,6 @@
|
|||
# import frappe
|
||||
{base_class_import}
|
||||
|
||||
|
||||
class {classname}({base_class}):
|
||||
{custom_controller}
|
||||
|
|
|
|||
|
|
@ -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()) {
|
||||
|
|
|
|||
|
|
@ -19,6 +19,8 @@ DEFAULT_LOGTYPES_RETENTION = {
|
|||
"Route History": 90,
|
||||
"Submission Queue": 30,
|
||||
"Prepared Report": 30,
|
||||
"Webhook Request Log": 30,
|
||||
"Reminder": 30,
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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"""
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
|
|
|
|||
|
|
@ -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": []
|
||||
}
|
||||
}
|
||||
|
|
|
|||
0
frappe/desk/doctype/workspace_number_card/__init__.py
Normal file
0
frappe/desk/doctype/workspace_number_card/__init__.py
Normal 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": []
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -657,8 +657,6 @@ class EmailAccount(Document):
|
|||
if not email_server:
|
||||
return
|
||||
|
||||
email_server.connect()
|
||||
|
||||
if email_server.imap:
|
||||
try:
|
||||
message = safe_encode(message)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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 = (
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
}
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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))))
|
||||
|
|
|
|||
|
|
@ -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, [])
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
|
|
|
|||
|
|
@ -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 |
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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 () {
|
||||
|
|
|
|||
100
frappe/public/js/frappe/form/reminders.js
Normal file
100
frappe/public/js/frappe/form/reminders.js
Normal 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)])
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
{% } %}
|
||||
|
|
|
|||
|
|
@ -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) { %}
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -1094,6 +1094,12 @@ body {
|
|||
}
|
||||
|
||||
|
||||
// widgets
|
||||
.widget.number-widget-box {
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
|
||||
.codex-editor__loader {
|
||||
display: none !important;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue