Merge remote-tracking branch 'upstream' into fix-image-compression

This commit is contained in:
Suraj Shetty 2023-08-02 10:13:52 +05:30
commit a7be8ccff2
69 changed files with 714 additions and 223 deletions

View file

@ -4,6 +4,7 @@ set -e
echo "Setting Up System Dependencies..."
sudo apt update
sudo apt remove mysql-server mysql-client
sudo apt install libcups2-dev redis-server mariadb-client-10.6
install_wkhtmltopdf() {

View file

@ -4,9 +4,3 @@
# the repo. Unless a later match takes precedence,
* @frappe/frappe-review-team
templates/ @surajshetty3416
www/ @surajshetty3416
patches/ @surajshetty3416
data_import* @netchampfaris
core/ @surajshetty3416
workspace @shariquerik

View file

@ -92,4 +92,47 @@ context("Attach Control", () => {
cy.get('.actions-btn-group > .dropdown-menu [data-label="Delete"]').click();
cy.click_modal_primary_button("Yes");
});
it('Checking that "Camera" button in the "Attach" fieldtype does show if camera is available', () => {
//Navigating to the new form for the newly created doctype
let doctype = "Test Attach Control";
let dt_in_route = doctype.toLowerCase().replace(/ /g, "-");
cy.visit(`/app/${dt_in_route}/new`, {
onBeforeLoad(win) {
// Mock "window.navigator.mediaDevices" property
// to return mock mediaDevices object
win.navigator.mediaDevices = {
ondevicechange: null,
};
},
});
cy.get("body").should("have.attr", "data-route", `Form/${doctype}/new-${dt_in_route}-1`);
cy.get("body").should("have.attr", "data-ajax-state", "complete");
//Clicking on the attach button which is displayed as part of creating a doctype with "Attach" fieldtype
cy.findByRole("button", { name: "Attach" }).click();
//Clicking on "Camera" button
cy.findByRole("button", { name: "Camera" }).should("exist");
});
it('Checking that "Camera" button in the "Attach" fieldtype does not show if no camera is available', () => {
//Navigating to the new form for the newly created doctype
let doctype = "Test Attach Control";
let dt_in_route = doctype.toLowerCase().replace(/ /g, "-");
cy.visit(`/app/${dt_in_route}/new`, {
onBeforeLoad(win) {
// Delete "window.navigator.mediaDevices" property
delete win.navigator.mediaDevices;
},
});
cy.get("body").should("have.attr", "data-route", `Form/${doctype}/new-${dt_in_route}-1`);
cy.get("body").should("have.attr", "data-ajax-state", "complete");
//Clicking on the attach button which is displayed as part of creating a doctype with "Attach" fieldtype
cy.findByRole("button", { name: "Attach" }).click();
//Clicking on "Camera" button
cy.findByRole("button", { name: "Camera" }).should("not.exist");
});
});

View file

@ -26,7 +26,7 @@ context("Control Color", () => {
//Checking if the css attribute is correct
cy.get(".color-map").should("have.css", "color", "rgb(79, 157, 217)");
cy.get(".hue-map").should("have.css", "color", "rgb(0, 144, 255)");
cy.get(".hue-map").should("have.css", "color", "rgb(0, 145, 255)");
//Checking if the correct color is being selected
cy.get("@dialog").then((dialog) => {

View file

@ -89,12 +89,10 @@ const NODE_PATHS = [].concat(
app_list.map((app) => path.resolve(get_app_path(app), "..")).filter(fs.existsSync)
);
execute()
.then(() => RUN_BUILD_COMMAND && run_build_command_for_apps(APPS))
.catch((e) => {
console.error(e);
process.exit(1);
});
execute().catch((e) => {
console.error(e);
process.exit(1);
});
if (WATCH_MODE) {
// listen for open files in editor event
@ -127,6 +125,10 @@ async function execute() {
for (const result of results) {
await write_assets_json(result.metafile);
}
RUN_BUILD_COMMAND && run_build_command_for_apps(APPS);
if (!WATCH_MODE) {
process.exit(0);
}
}
function build_assets_for_apps(apps, files) {
@ -418,18 +420,17 @@ async function write_assets_json(metafile) {
};
}
function update_assets_json_in_cache() {
async function update_assets_json_in_cache() {
// update assets_json cache in redis, so that it can be read directly by python
return new Promise((resolve) => {
let client = get_redis_subscriber("redis_cache");
// handle error event to avoid printing stack traces
client.on("error", (_) => {
log_warn("Cannot connect to redis_cache to update assets_json");
});
client.del("assets_json", (err) => {
client.unref();
resolve();
});
let client = get_redis_subscriber("redis_cache");
// handle error event to avoid printing stack traces
try {
await client.connect();
} catch (e) {
log_warn("Cannot connect to redis_cache to update assets_json");
}
client.del("assets_json", (err) => {
client.unref();
});
}
@ -458,9 +459,11 @@ function run_build_command_for_apps(apps) {
async function notify_redis({ error, success, changed_files }) {
// notify redis which in turns tells socketio to publish this to browser
let subscriber = get_redis_subscriber("redis_queue");
subscriber.on("error", (_) => {
try {
await subscriber.connect();
} catch (e) {
log_warn("Cannot connect to redis_queue for browser events");
});
}
let payload = null;
if (error) {
@ -483,7 +486,7 @@ async function notify_redis({ error, success, changed_files }) {
};
}
subscriber.publish(
await subscriber.publish(
"events",
JSON.stringify({
event: "build_event",
@ -492,21 +495,20 @@ async function notify_redis({ error, success, changed_files }) {
);
}
function open_in_editor() {
async function open_in_editor() {
let subscriber = get_redis_subscriber("redis_queue");
subscriber.on("error", (_) => {
try {
await subscriber.connect();
} catch (e) {
log_warn("Cannot connect to redis_queue for open_in_editor events");
}
subscriber.subscribe("open_in_editor", (file) => {
file = JSON.parse(file);
let file_path = path.resolve(file.file);
log("Opening file in editor:", file_path);
let launch = require("launch-editor");
launch(`${file_path}:${file.line}:${file.column}`);
});
subscriber.on("message", (event, file) => {
if (event === "open_in_editor") {
file = JSON.parse(file);
let file_path = path.resolve(file.file);
log("Opening file in editor:", file_path);
let launch = require("launch-editor");
launch(`${file_path}:${file.line}:${file.column}`);
}
});
subscriber.subscribe("open_in_editor");
}
function get_rebuilt_assets(prev_assets, new_assets) {

View file

@ -53,12 +53,8 @@ local = Local()
cache = None
STANDARD_USERS = ("Guest", "Administrator")
_dev_server = int(sbool(os.environ.get("DEV_SERVER", False)))
_qb_patched = {}
re._MAXCACHE = (
50 # reduced from default 512 given we are already maintaining this on parent worker
)
_dev_server = int(sbool(os.environ.get("DEV_SERVER", False)))
_tune_gc = bool(sbool(os.environ.get("FRAPPE_TUNE_GC", True)))
if _dev_server:
@ -1464,13 +1460,11 @@ def get_all_apps(with_internal_apps=True, sites_path=None):
@request_cache
def get_installed_apps(*, _ensure_on_bench=False):
def get_installed_apps(*, _ensure_on_bench=False) -> list[str]:
"""
Get list of installed apps in current site.
:param sort: [DEPRECATED] Sort installed apps based on the sequence in sites/apps.txt
:param frappe_last: [DEPRECATED] Keep frappe last. Do not use this, reverse the app list instead.
:param ensure_on_bench: Only return apps that are present on bench.
:param _ensure_on_bench: Only return apps that are present on bench.
"""
if getattr(flags, "in_install_db", True):
return []
@ -2450,3 +2444,6 @@ if _tune_gc:
# everything else.
g0, g1, g2 = gc.get_threshold() # defaults are 700, 10, 10.
gc.set_threshold(g0 * 10, g1 * 2, g2 * 2)
# Remove references to pattern that are pre-compiled and loaded to global scopes.
re.purge()

View file

@ -4,6 +4,7 @@
import gc
import logging
import os
import re
from werkzeug.exceptions import HTTPException, NotFound
from werkzeug.local import LocalManager
@ -428,6 +429,9 @@ def serve(
)
# Remove references to pattern that are pre-compiled and loaded to global scopes.
re.purge()
# Both Gunicorn and RQ use forking to spawn workers. In an ideal world, the fork should be sharing
# most of the memory if there are no writes made to data because of Copy on Write, however,
# python's GC is not CoW friendly and writes to data even if user-code doesn't. Specifically, the

View file

@ -49,11 +49,13 @@ class TestContact(FrappeTestCase):
# First time from database
results = get_contact_list("_Test Supplier")
self.assertEqual(results[0].label, "test_contact@example.com")
self.assertEqual(results[0].value, "test_contact@example.com")
self.assertEqual(results[0].description, "_Test Contact For _Test Supplier")
# Second time from cache
results = get_contact_list("_Test Supplier")
self.assertEqual(results[0].label, "test_contact@example.com")
self.assertEqual(results[0].value, "test_contact@example.com")
self.assertEqual(results[0].description, "_Test Contact For _Test Supplier")

View file

@ -2,5 +2,8 @@
// For license information, please see license.txt
frappe.ui.form.on("Activity Log", {
refresh: function () {},
refresh: function (frm) {
// Nothing in this form is supposed to be editable.
frm.disable_form();
},
});

View file

@ -13,6 +13,7 @@
"column_break_5",
"additional_info",
"communication_date",
"ip_address",
"column_break_7",
"operation",
"status",
@ -148,12 +149,17 @@
"fieldtype": "Data",
"in_list_view": 1,
"label": "Full Name"
},
{
"fieldname": "ip_address",
"fieldtype": "Data",
"label": "IP Address"
}
],
"icon": "fa fa-comment",
"index_web_pages_for_search": 1,
"links": [],
"modified": "2022-09-13 15:19:42.474114",
"modified": "2023-07-28 13:26:32.281278",
"modified_by": "Administrator",
"module": "Core",
"name": "Activity Log",

View file

@ -21,6 +21,7 @@ class ActivityLog(Document):
communication_date: DF.Datetime | None
content: DF.TextEditor | None
full_name: DF.Data | None
ip_address: DF.Data | None
link_doctype: DF.Link | None
link_name: DF.DynamicLink | None
operation: DF.Literal["", "Login", "Logout"]
@ -40,6 +41,7 @@ class ActivityLog(Document):
def validate(self):
self.set_status()
set_timeline_doc(self)
self.set_ip_address()
def set_status(self):
if not self.is_new():
@ -48,6 +50,10 @@ class ActivityLog(Document):
if self.reference_doctype and self.reference_name:
self.status = "Linked"
def set_ip_address(self):
if self.operation in ("Login", "Logout"):
self.ip_address = getattr(frappe.local, "request_ip")
@staticmethod
def clear_old_logs(days=None):
if not days:

View file

@ -240,9 +240,7 @@ class DocType(Document):
controller = Document
available_objects = {x for x in dir(controller) if isinstance(x, str)}
property_set = {
x for x in available_objects if isinstance(getattr(controller, x, None), property)
}
property_set = {x for x in available_objects if is_a_property(getattr(controller, x, None))}
method_set = {
x for x in available_objects if x not in property_set and callable(getattr(controller, x, None))
}
@ -1795,13 +1793,18 @@ def make_module_and_roles(doc, perm_fieldname="permissions"):
raise
def is_a_property(x) -> bool:
"""Get properties (@property, @cached_property) in a controller class"""
from functools import cached_property
return isinstance(x, (property, cached_property))
def check_fieldname_conflicts(docfield):
"""Checks if fieldname conflicts with methods or properties"""
doc = frappe.get_doc({"doctype": docfield.dt})
available_objects = [x for x in dir(doc) if isinstance(x, str)]
property_list = [
x for x in available_objects if isinstance(getattr(type(doc), x, None), property)
]
property_list = [x for x in available_objects if is_a_property(getattr(type(doc), x, None))]
method_list = [
x for x in available_objects if x not in property_list and callable(getattr(doc, x))
]

View file

@ -1,7 +1,9 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
# License: MIT. See LICENSE
import os
import random
import string
import unittest
from unittest.mock import patch
import frappe
@ -172,6 +174,9 @@ class TestDocType(FrappeTestCase):
if condition:
self.assertFalse(re.match(pattern, condition))
@unittest.skipUnless(
os.access(frappe.get_app_path("frappe"), os.W_OK), "Only run if frappe app paths is writable"
)
def test_sync_field_order(self):
import os
@ -648,6 +653,9 @@ class TestDocType(FrappeTestCase):
def test_no_delete_doc(self):
self.assertRaises(frappe.ValidationError, frappe.delete_doc, "DocType", "Address")
@unittest.skipUnless(
os.access(frappe.get_app_path("frappe"), os.W_OK), "Only run if frappe app paths is writable"
)
@patch.dict(frappe.conf, {"developer_mode": 1})
def test_export_types(self):
"""Export python types."""
@ -686,6 +694,9 @@ class TestDocType(FrappeTestCase):
doctype.delete()
frappe.db.commit()
@unittest.skipUnless(
os.access(frappe.get_app_path("frappe"), os.W_OK), "Only run if frappe app paths is writable"
)
@patch.dict(frappe.conf, {"developer_mode": 1})
def test_custom_field_deletion(self):
"""Custom child tables whose doctype doesn't exist should be auto deleted."""
@ -698,6 +709,9 @@ class TestDocType(FrappeTestCase):
frappe.delete_doc("DocType", child)
self.assertFalse(frappe.get_meta(doctype).get_field(field))
@unittest.skipUnless(
os.access(frappe.get_app_path("frappe"), os.W_OK), "Only run if frappe app paths is writable"
)
@patch.dict(frappe.conf, {"developer_mode": 1})
def test_delete_doctype_with_customization(self):
from frappe.custom.doctype.property_setter.property_setter import make_property_setter

View file

@ -1,10 +1,12 @@
# Copyright (c) 2015, Frappe Technologies and Contributors
# License: MIT. See LICENSE
from unittest.mock import patch
from ldap3.core.exceptions import LDAPException, LDAPInappropriateAuthenticationResult
import frappe
from frappe.tests.utils import FrappeTestCase
from frappe.utils.error import _is_ldap_exception
from frappe.utils.error import _is_ldap_exception, guess_exception_source
# test_records = frappe.get_test_records('Error Log')
@ -21,3 +23,44 @@ class TestErrorLog(FrappeTestCase):
for e in exc:
self.assertTrue(_is_ldap_exception(e()))
_RAW_EXC = """
File "apps/frappe/frappe/model/document.py", line 1284, in runner
add_to_return_value(self, fn(self, *args, **kwargs))
^^^^^^^^^^^^^^^^^^^^^^^^^
File "apps/frappe/frappe/model/document.py", line 933, in fn
return method_object(*args, **kwargs)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "apps/erpnext/erpnext/selling/doctype/sales_order/sales_order.py", line 58, in onload
raise Exception("what")
Exception: what
"""
_THROW_EXC = """
File "apps/frappe/frappe/model/document.py", line 933, in fn
return method_object(*args, **kwargs)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "apps/erpnext/erpnext/selling/doctype/sales_order/sales_order.py", line 58, in onload
frappe.throw("what")
File "apps/frappe/frappe/__init__.py", line 550, in throw
msgprint(
File "apps/frappe/frappe/__init__.py", line 518, in msgprint
_raise_exception()
File "apps/frappe/frappe/__init__.py", line 467, in _raise_exception
raise raise_exception(msg)
frappe.exceptions.ValidationError: what
"""
TEST_EXCEPTIONS = {
"erpnext (app)": _RAW_EXC,
"erpnext (app)": _THROW_EXC,
}
class TestExceptionSourceGuessing(FrappeTestCase):
@patch.object(frappe, "get_installed_apps", return_value=["frappe", "erpnext", "3pa"])
def test_exc_source_guessing(self, _installed_apps):
for source, exc in TEST_EXCEPTIONS.items():
result = guess_exception_source(exc)
self.assertEqual(result, source)

View file

@ -6,6 +6,7 @@ import os
import frappe
from frappe.model.document import Document
from frappe.modules.export_file import delete_folder
class ModuleDef(Document):
@ -22,6 +23,7 @@ class ModuleDef(Document):
module_name: DF.Data
package: DF.Link | None
restrict_to_domain: DF.Link | None
# end: auto-generated types
def on_update(self):
"""If in `developer_mode`, create folder for module and
@ -64,6 +66,7 @@ class ModuleDef(Document):
modules = None
if frappe.local.module_app.get(frappe.scrub(self.name)):
delete_folder(self.module_name, "Module Def", self.name)
with open(frappe.get_app_path(self.app_name, "modules.txt")) as f:
content = f.read()
if self.name in content.splitlines():

View file

@ -411,9 +411,7 @@ def get_group_by_column_label(args, meta):
else:
sql_fn_map = {"avg": "Average", "sum": "Sum"}
aggregate_on_label = meta.get_label(args.aggregate_on)
label = _("{function} of {fieldlabel}").format(
function=sql_fn_map[args.aggregate_function], fieldlabel=aggregate_on_label
)
label = _("{0} of {1}").format(_(sql_fn_map[args.aggregate_function]), _(aggregate_on_label))
return label

View file

@ -33,6 +33,7 @@
"apply_strict_user_permissions",
"column_break_21",
"allow_guests_to_upload_files",
"force_web_capture_mode_for_uploads",
"security",
"session_expiry",
"document_share_key_expiry",
@ -563,12 +564,19 @@
"fieldtype": "Link",
"label": "Reset Password Template",
"options": "Email Template"
},
{
"default": "0",
"description": "When uploading files, force the use of the web-based image capture. If this is unchecked, the default behavior is to use the mobile native camera when use from a mobile is detected.",
"fieldname": "force_web_capture_mode_for_uploads",
"fieldtype": "Check",
"label": "Force Web Capture Mode for Uploads"
}
],
"icon": "fa fa-cog",
"issingle": 1,
"links": [],
"modified": "2023-05-25 13:02:54.808773",
"modified": "2023-07-30 17:34:08.292152",
"modified_by": "Administrator",
"module": "Core",
"name": "System Settings",

View file

@ -0,0 +1,28 @@
{
"charts": [],
"content": "[{\"id\":\"2eyXSHwMTE\",\"type\":\"header\",\"data\":{\"text\":\"<span class=\\\"h2\\\">Hi,</span>\",\"col\":12}},{\"id\":\"ZusKvFOXgu\",\"type\":\"paragraph\",\"data\":{\"text\":\"I guess you don't have access to any workspace yet, but you can create one just for yourself. Click on the <b>Create Workspace</b> button to create one.<br>\",\"col\":12}}]",
"creation": "2023-07-28 17:14:28.608321",
"custom_blocks": [],
"docstatus": 0,
"doctype": "Workspace",
"for_user": "",
"hide_custom": 0,
"icon": "image-view",
"idx": 1,
"is_hidden": 0,
"label": "Welcome Workspace",
"links": [],
"modified": "2023-07-28 20:15:32.222029",
"modified_by": "Administrator",
"module": "Core",
"name": "Welcome Workspace",
"number_cards": [],
"owner": "Administrator",
"parent_page": "",
"public": 1,
"quick_lists": [],
"roles": [],
"sequence_id": 22.0,
"shortcuts": [],
"title": "Welcome Workspace"
}

View file

@ -314,7 +314,7 @@ class Database:
if frappe.conf.logging == 2:
_query = _query or str(mogrified_query)
frappe.log(f"<<<< query\n{_query}\n>>>>")
frappe.log(f"#### query\n{_query}\n####")
if unmogrified_query and is_query_type(
unmogrified_query, ("alter", "drop", "create", "truncate", "rename")

View file

@ -453,7 +453,7 @@ def get_workspace_sidebar_items():
try:
workspace = Workspace(page, True)
if has_access or workspace.is_permitted():
if page.public and (has_access or not page.is_hidden):
if page.public and (has_access or not page.is_hidden) and page.title != "Welcome Workspace":
pages.append(page)
elif page.for_user == frappe.session.user:
private_pages.append(page)
@ -463,6 +463,10 @@ def get_workspace_sidebar_items():
if private_pages:
pages.extend(private_pages)
if len(pages) == 0:
pages = [frappe.get_doc("Workspace", "Welcome Workspace").as_dict()]
pages[0]["label"] = _("Welcome Workspace")
return {"pages": pages, "has_access": has_access}

View file

@ -33,6 +33,7 @@ class SystemConsole(Document):
elif self.type == "SQL":
self.output = frappe.as_json(read_sql(self.console, as_dict=1))
except Exception:
self.commit = False
self.output = frappe.get_traceback()
if self.commit:

View file

@ -16,3 +16,16 @@ class TestSystemConsole(FrappeTestCase):
system_console.run()
self.assertEqual(system_console.output, "Core")
def test_system_console_sql(self):
system_console = frappe.get_doc("System Console")
system_console.type = "SQL"
system_console.console = "select 'test'"
system_console.run()
self.assertIn("test", system_console.output)
system_console.console = "update `tabDocType` set is_virtual = 1 where name = 'xyz'"
system_console.run()
self.assertIn("PermissionError", system_console.output)

View file

@ -9,6 +9,7 @@
"link_to",
"url",
"doc_view",
"kanban_board",
"column_break_4",
"label",
"icon",
@ -43,7 +44,7 @@
"fieldtype": "Select",
"in_list_view": 1,
"label": "DocType View",
"options": "\nList\nReport Builder\nDashboard\nTree\nNew\nCalendar"
"options": "\nList\nReport Builder\nDashboard\nTree\nNew\nCalendar\nKanban"
},
{
"fieldname": "column_break_4",
@ -103,12 +104,19 @@
"in_list_view": 1,
"label": "URL",
"options": "URL"
},
{
"depends_on": "eval:doc.doc_view == \"Kanban\"",
"fieldname": "kanban_board",
"fieldtype": "Link",
"label": "Kanban Board",
"options": "Kanban Board"
}
],
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2023-04-19 13:32:31.005443",
"modified": "2023-07-18 16:12:53.546430",
"modified_by": "Administrator",
"module": "Desk",
"name": "Workspace Shortcut",
@ -117,5 +125,6 @@
"quick_entry": 1,
"sort_field": "modified",
"sort_order": "DESC",
"states": [],
"track_changes": 1
}

View file

@ -15,9 +15,12 @@ class WorkspaceShortcut(Document):
from frappe.types import DF
color: DF.Color | None
doc_view: DF.Literal["", "List", "Report Builder", "Dashboard", "Tree", "New", "Calendar"]
doc_view: DF.Literal[
"", "List", "Report Builder", "Dashboard", "Tree", "New", "Calendar", "Kanban"
]
format: DF.Data | None
icon: DF.Data | None
kanban_board: DF.Link | None
label: DF.Data
link_to: DF.DynamicLink | None
parent: DF.Data

View file

@ -638,9 +638,15 @@ frappe.setup.utils = {
},
};
// https://github.com/eggert/tz/blob/main/backward add more if required.
const TZ_BACKWARD_COMPATBILITY_MAP = {
"Asia/Calcutta": "Asia/Kolkata",
};
function guess_country(country_info) {
try {
const system_timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
let system_timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
system_timezone = TZ_BACKWARD_COMPATBILITY_MAP[system_timezone] || system_timezone;
for (let [country, info] of Object.entries(country_info)) {
let possible_timezones = (info.timezones || []).filter((t) => t == system_timezone);

View file

@ -16,7 +16,7 @@ def get_contact_list(txt, page_length=20) -> list[dict]:
if cached_contacts := get_cached_contacts(txt):
return cached_contacts[:page_length]
fields = ["name", "first_name", "middle_name", "last_name", "company_name"]
fields = ["first_name", "middle_name", "last_name", "company_name"]
contacts = frappe.get_list(
"Contact",
fields=fields + ["`tabContact Email`.email_id"],
@ -33,7 +33,7 @@ def get_contact_list(txt, page_length=20) -> list[dict]:
# https://github.com/frappe/frappe/blob/6c6a89bcdd9454060a1333e23b855d0505c9ebc2/frappe/public/js/frappe/form/controls/autocomplete.js#L29-L35
result = [
frappe._dict(
value=d.name,
value=d.email_id,
label=d.email_id,
description=get_full_name(d.first_name, d.middle_name, d.last_name, d.company_name),
)

View file

@ -33,3 +33,8 @@ class EmailQueueRecipient(Document):
frappe.db.set_value(self.DOCTYPE, self.name, kwargs)
if commit:
frappe.db.commit()
def on_doctype_update():
"""Index required for log clearing, modified is not indexed on child table by default"""
frappe.db.add_index("Email Queue Recipient", ["modified"])

View file

@ -411,7 +411,9 @@ def send_scheduled_email():
@frappe.whitelist(allow_guest=True)
def newsletter_email_read(recipient_email, reference_doctype, reference_name):
def newsletter_email_read(recipient_email=None, reference_doctype=None, reference_name=None):
if not (recipient_email and reference_name):
return
verify_request()
try:
doc = frappe.get_cached_doc("Newsletter", reference_name)

View file

@ -103,7 +103,7 @@ def authorize(**kwargs):
else:
if "openid" in scopes:
scopes.remove("openid")
scopes.extend(["First Name", "Last Name", "Email", "Password", "User Image", "Roles"])
scopes.extend(["Full Name", "Email", "User Image", "Roles"])
# Show Allow/Deny screen.
response_html_params = frappe._dict(

View file

@ -5,6 +5,7 @@
import copy
import json
import re
from collections import Counter
from datetime import datetime
import frappe
@ -61,6 +62,8 @@ class DatabaseQuery:
self.doctype = doctype
self.tables = []
self.link_tables = []
self.linked_table_aliases = {}
self.linked_table_counter = Counter()
self.conditions = []
self.or_conditions = []
self.fields = None
@ -80,7 +83,7 @@ class DatabaseQuery:
@property
def query_tables(self):
return self.tables + [d.table_name for d in self.link_tables]
return self.tables + [d.table_alias for d in self.link_tables]
def execute(
self,
@ -269,7 +272,7 @@ class DatabaseQuery:
# left join link tables
for link in self.link_tables:
args.tables += f" {self.join} `tab{link.doctype}` on (`tab{link.doctype}`.`name` = {self.tables[0]}.`{link.fieldname}`)"
args.tables += f" {self.join} {link.table_name} {link.table_alias} on ({link.table_alias}.`name` = {self.tables[0]}.`{link.fieldname}`)"
if self.grouped_or_conditions:
self.conditions.append(f"({' or '.join(self.grouped_or_conditions)})")
@ -358,8 +361,10 @@ class DatabaseQuery:
continue
linked_doctype = linked_field.options
if linked_field.fieldtype == "Link":
self.append_link_table(linked_doctype, linked_fieldname)
field = f"`tab{linked_doctype}`.`{fieldname}`"
linked_table = self.append_link_table(linked_doctype, linked_fieldname)
field = f"{linked_table.table_alias}.`{fieldname}`"
else:
field = f"`tab{linked_doctype}`.`{fieldname}`"
if alias:
field = f"{field} as {alias}"
self.fields[self.fields.index(original_field)] = field
@ -469,11 +474,19 @@ class DatabaseQuery:
table_name = field.split(".", 1)[0]
# Check if table_name is a linked_table alias
for linked_table in self.link_tables:
if linked_table.table_alias == table_name:
table_name = linked_table.table_name
break
if table_name.lower().startswith("group_concat("):
table_name = table_name[13:]
if not table_name[0] == "`":
table_name = f"`{table_name}`"
if table_name not in self.query_tables:
if (
table_name not in self.query_tables and table_name not in self.linked_table_aliases.values()
):
self.append_table(table_name)
def append_table(self, table_name):
@ -482,14 +495,21 @@ class DatabaseQuery:
self.check_read_permission(doctype)
def append_link_table(self, doctype, fieldname):
for d in self.link_tables:
if d.doctype == doctype and d.fieldname == fieldname:
return
for linked_table in self.link_tables:
if linked_table.doctype == doctype and linked_table.fieldname == fieldname:
return linked_table
self.check_read_permission(doctype)
self.link_tables.append(
frappe._dict(doctype=doctype, fieldname=fieldname, table_name=f"`tab{doctype}`")
self.linked_table_counter.update((doctype,))
linked_table = frappe._dict(
doctype=doctype,
fieldname=fieldname,
table_name=f"`tab{doctype}`",
table_alias=f"`tab{doctype}_{self.linked_table_counter[doctype]}`",
)
self.linked_table_aliases[linked_table.table_alias.replace("`", "")] = linked_table.table_name
self.link_tables.append(linked_table)
return linked_table
def check_read_permission(self, doctype: str, parent_doctype: str | None = None):
if self.flags.ignore_permissions:
@ -653,7 +673,12 @@ class DatabaseQuery:
# handle child / joined table fields
elif "." in field:
table, column = column.split(".", 1)
ch_doctype = table.replace("`", "").replace("tab", "", 1)
ch_doctype = table
if ch_doctype in self.linked_table_aliases:
ch_doctype = self.linked_table_aliases[ch_doctype]
ch_doctype = ch_doctype.replace("`", "").replace("tab", "", 1)
if wrap_grave_quotes(table) in self.query_tables:
permitted_child_table_fields = get_permitted_fields(
@ -815,14 +840,19 @@ class DatabaseQuery:
elif f.operator.lower() == "is":
if f.value == "set":
f.operator = "!="
# Value can technically be null, but comparing with null will always be falsy
# Not using coalesce here is faster because indexes can be used.
# null != '' -> null ~ falsy
# '' != '' -> false
can_be_null = False
elif f.value == "not set":
f.operator = "="
fallback = "''"
can_be_null = True
value = ""
fallback = "''"
can_be_null = True
if "ifnull" not in column_name.lower():
if can_be_null and "ifnull" not in column_name.lower():
column_name = f"ifnull({column_name}, {fallback})"
elif df and df.fieldtype == "Date":

View file

@ -1134,7 +1134,11 @@ class Document(BaseDocument):
def reset_seen(self):
"""Clear _seen property and set current user as seen"""
if getattr(self.meta, "track_seen", False) and not getattr(self.meta, "issingle", False):
if (
getattr(self.meta, "track_seen", False)
and not getattr(self.meta, "issingle", False)
and not self.is_new()
):
frappe.db.set_value(
self.doctype, self.name, "_seen", json.dumps([frappe.session.user]), update_modified=False
)

View file

@ -89,7 +89,7 @@ def has_permission(
meta = frappe.get_meta(doctype)
if doc:
if isinstance(doc, str):
if isinstance(doc, (str, int)):
doc = frappe.get_doc(meta.name, doc)
perm = get_doc_permissions(doc, user=user, ptype=ptype).get(ptype)
if not perm:

View file

@ -2,6 +2,7 @@
# License: MIT. See LICENSE
import os
import re
import unittest
from typing import TYPE_CHECKING
import frappe
@ -36,6 +37,9 @@ class TestPrintFormat(FrappeTestCase):
print_html = self.test_print_user("Classic")
self.assertTrue("/* classic format: for-test */" in print_html)
@unittest.skipUnless(
os.access(frappe.get_app_path("frappe"), os.W_OK), "Only run if frappe app paths is writable"
)
def test_export_doc(self):
doc: "PrintFormat" = frappe.get_doc("Print Format", test_records[0]["name"])

View file

@ -6,7 +6,7 @@ let slots = useSlots();
</script>
<template>
<div class="control checkbox" :class="{ editable: slots.label }">
<div class="control frappe-control checkbox" :class="{ editable: slots.label }">
<!-- checkbox -->
<label v-if="slots.label" class="field-controls">
<div class="checkbox">

View file

@ -32,7 +32,7 @@ if (props.df.fieldtype === "Icon") {
<slot name="label" />
<slot name="actions" />
</div>
<div v-else class="label" :class="{ reqd: df.reqd }">{{ df.label }}</div>
<div v-else class="control-label label" :class="{ reqd: df.reqd }">{{ df.label }}</div>
<!-- data input -->
<input

View file

@ -26,12 +26,16 @@ function get_options() {
if (props.df.fieldname == "fieldtype") {
if (!in_list(frappe.model.layout_fields, props.modelValue)) {
options = options && options.filter(opt => !in_list(frappe.model.layout_fields, opt.label));
options = options && options.filter(opt => !in_list(frappe.model.layout_fields, opt.value));
} else {
options = [{ label: __(props.modelValue), value: props.modelValue }];
}
}
if (props.df.sort_options) {
options.sort((a, b) => a.label.localeCompare(b.label));
}
return options;
}
@ -83,7 +87,7 @@ watch(() => props.df.options, () => {
</script>
<template>
<div v-if="slots.label" class="control" :class="{ editable: slots.label }">
<div v-if="slots.label" class="control frappe-control" :class="{ editable: slots.label }">
<!-- label -->
<div class="field-controls">
<slot name="label" />

View file

@ -21,7 +21,7 @@ let height = computed(() => {
<slot name="label" />
<slot name="actions" />
</div>
<div v-else class="label">{{ df.label }}</div>
<div v-else class="control-label label">{{ df.label }}</div>
<!-- textarea input -->
<textarea

View file

@ -1,4 +1,4 @@
frappe.provide("frappe.utils.utils");
frappe.provide("frappe.utils");
frappe.ui.form.ControlGeolocation = class ControlGeolocation extends frappe.ui.form.ControlData {
static horizontal = false;
@ -35,6 +35,7 @@ frappe.ui.form.ControlGeolocation = class ControlGeolocation extends frappe.ui.f
}
make_map(value) {
this.customize_draw_controls();
this.bind_leaflet_map();
if (this.disabled) {
this.map.dragging.disable();
@ -113,7 +114,7 @@ frappe.ui.form.ControlGeolocation = class ControlGeolocation extends frappe.ui.f
*/
on_each_feature(feature, layer) {}
bind_leaflet_map() {
customize_draw_controls() {
const circleToGeoJSON = L.Circle.prototype.toGeoJSON;
L.Circle.include({
toGeoJSON: function () {
@ -137,7 +138,10 @@ frappe.ui.form.ControlGeolocation = class ControlGeolocation extends frappe.ui.f
},
});
L.Icon.Default.imagePath = "/assets/frappe/images/leaflet/";
L.Icon.Default.imagePath = frappe.utils.map_defaults.image_path;
}
bind_leaflet_map() {
this.map = L.map(this.map_id);
this.map.setView(frappe.utils.map_defaults.center, frappe.utils.map_defaults.zoom);

View file

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

View file

@ -604,7 +604,14 @@ frappe.request.report_error = function (xhr, request_opts) {
let parts = strip(exc).split("\n");
frappe.error_dialog.$body.html(parts[parts.length - 1]);
let dialog_html = parts[parts.length - 1];
if (data._exc_source) {
dialog_html += "<br>";
dialog_html += `Possible source of error: ${data._exc_source.bold()} `;
}
frappe.error_dialog.$body.html(dialog_html);
frappe.error_dialog.show();
}
};

View file

@ -177,8 +177,18 @@ frappe.router = {
if (frappe.workspaces[route[0]]) {
// public workspace
route = ["Workspaces", frappe.workspaces[route[0]].title];
} else if (route[0] == "private" && frappe.workspaces[private_workspace]) {
} else if (route[0] == "private") {
// private workspace
if (!frappe.workspaces[private_workspace] && localStorage.new_workspace) {
let new_workspace = JSON.parse(localStorage.new_workspace);
if (frappe.router.slug(new_workspace.title) === route[1]) {
frappe.workspaces[private_workspace] = new_workspace;
}
}
if (!frappe.workspaces[private_workspace]) {
frappe.msgprint(__("Workspace <b>{0}</b> does not exist", [route[1]]));
return ["Workspaces"];
}
route = ["Workspaces", "private", frappe.workspaces[private_workspace].title];
} else if (this.routes[route[0]]) {
// route
@ -445,11 +455,17 @@ frappe.router = {
}
}).join("/");
let private_home = frappe.workspaces[`home-${frappe.user.name.toLowerCase()}`];
let default_page = private_home
? "private/home"
: frappe.workspaces["home"]
? "home"
: Object.keys(frappe.workspaces)[0];
let workspace_name = private_home || frappe.workspaces["home"] ? "home" : "";
let is_private = !!private_home;
let first_workspace = Object.keys(frappe.workspaces)[0];
if (!workspace_name && first_workspace) {
workspace_name = frappe.workspaces[first_workspace].title;
is_private = !frappe.workspaces[first_workspace].public;
}
let default_page = (is_private ? "private/" : "") + frappe.router.slug(workspace_name);
return "/app/" + (path_string || default_page);
},

View file

@ -76,7 +76,9 @@ frappe.ui.Capture = class {
show() {
this.build_dialog();
if (frappe.is_mobile()) {
if (cint(frappe.boot.sysdefaults.force_web_capture_mode_for_uploads)) {
this.show_for_desktop();
} else if (frappe.is_mobile()) {
this.show_for_mobile();
} else {
this.show_for_desktop();

View file

@ -1203,6 +1203,7 @@ Object.assign(frappe.utils, {
attribution:
'&copy; <a href="http://osm.org/copyright">OpenStreetMap</a> contributors',
},
image_path: "/assets/frappe/images/leaflet/",
},
icon(icon_name, size = "sm", icon_class = "", icon_style = "", svg_class = "") {
@ -1291,6 +1292,9 @@ Object.assign(frappe.utils, {
break;
case "Kanban":
route = `${doctype_slug}/view/kanban`;
if (item.kanban_board) {
route += `/${item.kanban_board}`;
}
break;
default:
route = doctype_slug;

View file

@ -1,7 +1,7 @@
/**
* frappe.views.MapView
*/
frappe.provide("frappe.utils.utils");
frappe.provide("frappe.utils");
frappe.provide("frappe.views");
frappe.views.MapView = class MapView extends frappe.views.ListView {
@ -32,7 +32,7 @@ frappe.views.MapView = class MapView extends frappe.views.ListView {
this.$result.html(`<div id="${this.map_id}" class="map-view-container"></div>`);
L.Icon.Default.imagePath = "/assets/frappe/images/leaflet/";
L.Icon.Default.imagePath = frappe.utils.map_defaults.image_path;
this.map = L.map(this.map_id).setView(
frappe.utils.map_defaults.center,
frappe.utils.map_defaults.zoom

View file

@ -65,7 +65,10 @@ frappe.views.Workspace = class Workspace {
if (this.all_pages) {
frappe.workspaces = {};
for (let page of this.all_pages) {
frappe.workspaces[frappe.router.slug(page.name)] = { title: page.title };
frappe.workspaces[frappe.router.slug(page.name)] = {
title: page.title,
public: page.public,
};
}
this.make_sidebar();
reload && this.show();
@ -326,7 +329,7 @@ frappe.views.Workspace = class Workspace {
public: localStorage.is_current_page_public == "true",
};
} else if (Object.keys(this.all_pages).length !== 0) {
default_page = { name: this.all_pages[0].title, public: true };
default_page = { name: this.all_pages[0].title, public: this.all_pages[0].public };
} else {
default_page = { name: "Build", public: true };
}
@ -344,10 +347,11 @@ frappe.views.Workspace = class Workspace {
`).appendTo(this.body);
}
if (this.all_pages) {
if (this.all_pages.length) {
this.create_page_skeleton();
let pages = page.public ? this.public_pages : this.private_pages;
let pages =
page.public && this.public_pages.length ? this.public_pages : this.private_pages;
let current_page = pages.filter((p) => p.title == page.name)[0];
this.content = current_page && JSON.parse(current_page.content);
@ -1209,6 +1213,7 @@ frappe.views.Workspace = class Workspace {
this.make_sidebar();
this.show_sidebar_actions();
localStorage.setItem("new_workspace", JSON.stringify(new_page));
});
},
});

View file

@ -21,6 +21,7 @@ export default class ShortcutWidget extends Widget {
stats_filter: this.stats_filter,
type: this.type,
url: this.url,
kanban_board: this.kanban_board,
};
}
@ -35,6 +36,7 @@ export default class ShortcutWidget extends Widget {
is_query_report: this.is_query_report,
doctype: this.ref_doctype,
doc_view: this.doc_view,
kanban_board: this.kanban_board,
});
let filters = frappe.utils.get_filter_from_json(this.stats_filter);

View file

@ -384,7 +384,7 @@ class ShortcutDialog extends WidgetDialog {
onchange: () => {
const doctype = this.dialog.get_value("link_to");
if (doctype && this.dialog.get_value("type") == "DocType") {
frappe.model.with_doctype(doctype, () => {
frappe.model.with_doctype(doctype, async () => {
let meta = frappe.get_meta(doctype);
if (doctype && frappe.boot.single_types.includes(doctype)) {
@ -398,6 +398,13 @@ class ShortcutDialog extends WidgetDialog {
if (meta.is_tree === "Tree") views.push("Tree");
if (frappe.boot.calendars.includes(doctype)) views.push("Calendar");
const response = await frappe.db.get_value(
"Kanban Board",
{ reference_doctype: doctype },
"name"
);
if (response?.message?.name) views.push("Kanban");
this.dialog.set_df_property("doc_view", "options", views.join("\n"));
});
} else {
@ -429,11 +436,38 @@ class ShortcutDialog extends WidgetDialog {
if (this.dialog) {
let doctype = this.dialog.get_value("link_to");
let is_single = frappe.boot.single_types.includes(doctype);
return state.type == "DocType" && !is_single;
return doctype && state.type == "DocType" && !is_single;
}
return false;
},
onchange: () => {
if (this.dialog.get_value("doc_view") == "Kanban") {
this.dialog.fields_dict.kanban_board.get_query = () => {
return {
filters: {
reference_doctype: this.dialog.get_value("link_to"),
},
};
};
} else {
this.dialog.fields_dict.link_to.get_query = null;
}
},
},
{
fieldtype: "Link",
fieldname: "kanban_board",
label: "Kanban Board",
options: "Kanban Board",
depends_on: () => {
let doc_view = this.dialog?.get_value("doc_view");
return doc_view == "Kanban";
},
mandatory_depends_on: () => {
let doc_view = this.dialog?.get_value("doc_view");
return doc_view == "Kanban";
},
},
{
fieldtype: "Section Break",

View file

@ -341,7 +341,7 @@ body {
&.onboarding-widget-box {
margin-bottom: var(--margin-2xl);
padding: var(--padding-lg) !important;
background-color: var(--bg-color);
background-color: var(--bg-light-gray);
box-shadow: var(--card-shadow) ;
&.edit-mode:hover {
@ -415,6 +415,7 @@ body {
padding: 8px;
font-size: var(--text-md);
max-width: 350px;
text-decoration: none;
&.pending {
.step-index.step-pending {
@ -462,7 +463,7 @@ body {
height: 20px;
width: 20px;
color: var(--text-on-light-gray);
background-color: var(--fg-color);
background-color: var(--bg-light-gray);
margin-right: var(--margin-sm);
border-radius: var(--border-radius-full);

View file

@ -45,7 +45,7 @@ def get_current_stack_frames():
current = inspect.currentframe()
frames = inspect.getouterframes(current, context=10)
for frame, filename, lineno, function, context, index in list(reversed(frames))[:-2]:
if "/apps/" in filename:
if "/apps/" in filename or "<serverscript>" in filename:
yield {
"filename": TRACEBACK_PATH_PATTERN.sub("", filename),
"lineno": lineno,

View file

@ -75,6 +75,10 @@ button#update {
margin-top: 30px;
}
.page-card p:empty {
display: none;
}
.page-card p {
font-size: 14px;
}

View file

@ -111,6 +111,9 @@ class TestBoilerPlate(unittest.TestCase):
def test_valid_ci_yaml(self):
yaml.safe_load(github_workflow_template.format(**self.default_hooks))
@unittest.skipUnless(
os.access(frappe.get_app_path("frappe"), os.W_OK), "Only run if frappe app paths is writable"
)
def test_create_app(self):
app_name = "test_app"
@ -142,6 +145,9 @@ class TestBoilerPlate(unittest.TestCase):
self.assertEqual(parse_as_configfile(patches_file), [])
@unittest.skipUnless(
os.access(frappe.get_app_path("frappe"), os.W_OK), "Only run if frappe app paths is writable"
)
def test_create_app_without_git_init(self):
app_name = "test_app_no_git"
@ -192,6 +198,9 @@ class TestBoilerPlate(unittest.TestCase):
except Exception as e:
self.fail(f"Can't parse python file in new app: {python_file}\n" + str(e))
@unittest.skipUnless(
os.access(frappe.get_app_path("frappe"), os.W_OK), "Only run if frappe app paths is writable"
)
def test_new_patch_util(self):
user_inputs = {
"app_name": "frappe",

View file

@ -704,6 +704,11 @@ class TestDBQuery(FrappeTestCase):
self.assertTrue({"name": "Prepared Report"} in res)
self.assertFalse({"name": "Property Setter"} in res)
frappe.db.set_value("DocType", "Property Setter", "autoname", None, update_modified=False)
res = DatabaseQuery("DocType").execute(filters={"autoname": ["is", "set"]})
self.assertFalse({"name": "Property Setter"} in res)
def test_set_field_tables(self):
# Tests _in_standard_sql_methods method in test_set_field_tables
# The following query will break if the above method is broken
@ -987,6 +992,75 @@ class TestDBQuery(FrappeTestCase):
self.assertIn("ifnull", frappe.get_all("User", {"name": ("not in", [])}, run=0))
self.assertIn("ifnull", frappe.get_all("User", {"name": ("not in", [""])}, run=0))
def test_ambiguous_linked_tables(self):
from frappe.desk.reportview import get
if not frappe.db.exists("DocType", "Related Todos"):
frappe.get_doc(
{
"doctype": "DocType",
"custom": 1,
"module": "Custom",
"name": "Related Todos",
"naming_rule": "Random",
"autoname": "hash",
"fields": [
{
"label": "Todo One",
"fieldname": "todo_one",
"fieldtype": "Link",
"options": "ToDo",
"reqd": 1,
},
{
"label": "Todo Two",
"fieldname": "todo_two",
"fieldtype": "Link",
"options": "ToDo",
"reqd": 1,
},
],
}
).insert()
else:
frappe.db.delete("Related Todos")
todo_one = frappe.get_doc(
{
"doctype": "ToDo",
"description": "Todo One",
}
).insert()
todo_two = frappe.get_doc(
{
"doctype": "ToDo",
"description": "Todo Two",
}
).insert()
frappe.get_doc(
{
"doctype": "Related Todos",
"todo_one": todo_one.name,
"todo_two": todo_two.name,
}
).insert()
frappe.form_dict.doctype = "Related Todos"
frappe.form_dict.fields = [
"`tabRelated Todos`.`name`",
"`tabRelated Todos`.`todo_one`",
"`tabRelated Todos`.`todo_two`",
# because ToDo.show_title_as_field_link = 1
"todo_one.description as todo_one_description",
"todo_two.description as todo_two_description",
]
# Shouldn't raise pymysql.err.OperationalError: (1066, "Not unique table/alias: 'tabToDo'")
data = get()
self.assertEqual(len(data["values"]), 1)
class TestReportView(FrappeTestCase):
def test_get_count(self):

View file

@ -1,5 +1,6 @@
import os
import shutil
import unittest
import frappe
from frappe import scrub
@ -82,6 +83,9 @@ class TestUtils(FrappeTestCase):
doc = frappe.get_last_doc("DocType")
self.assertIsNone(export_module_json(doc=doc, is_standard=True, module=doc.module))
@unittest.skipUnless(
os.access(frappe.get_app_path("frappe"), os.W_OK), "Only run if frappe app paths is writable"
)
def test_export_module_json(self):
doc = frappe.get_last_doc("DocType", {"issingle": 0, "custom": 0})
export_doc_path = os.path.join(
@ -107,12 +111,18 @@ class TestUtils(FrappeTestCase):
self.assertTrue(last_modified_after > last_modified_before)
@unittest.skipUnless(
os.access(frappe.get_app_path("frappe"), os.W_OK), "Only run if frappe app paths is writable"
)
def test_export_customizations(self):
file_path = export_customizations(module="Custom", doctype="Note")
self.addCleanup(delete_file, path=file_path)
self.assertTrue(file_path.endswith("/custom/custom/note.json"))
self.assertTrue(os.path.exists(file_path))
@unittest.skipUnless(
os.access(frappe.get_app_path("frappe"), os.W_OK), "Only run if frappe app paths is writable"
)
def test_sync_customizations(self):
custom_field = frappe.get_doc(
"Custom Field", {"dt": "Note", "fieldname": "test_export_customizations_field"}
@ -157,6 +167,9 @@ class TestUtils(FrappeTestCase):
)
self.assertTrue(frappe.db.get_value("DocType", "Note", "migration_hash"))
@unittest.skipUnless(
os.access(frappe.get_app_path("frappe"), os.W_OK), "Only run if frappe app paths is writable"
)
def test_export_doc(self):
exported_doc_path = frappe.get_app_path(
"frappe", "desk", "note", self.note.name, f"{self.note.name}.json"
@ -166,6 +179,9 @@ class TestUtils(FrappeTestCase):
self.addCleanup(delete_path, path=folder_path)
self.assertTrue(os.path.exists(exported_doc_path))
@unittest.skipUnless(
os.access(frappe.get_app_path("frappe"), os.W_OK), "Only run if frappe app paths is writable"
)
def test_make_boilerplate(self):
scrubbed = frappe.scrub(self.doctype.name)
self.assertFalse(

View file

@ -158,3 +158,8 @@ class TestPerformance(FrappeTestCase):
frappe.get_list("User")
with self.assertQueryCount(1):
frappe.get_list("User")
def test_no_ifnull_checks(self):
query = frappe.get_all("DocType", {"autoname": ("is", "set")}, run=0).lower()
self.assertNotIn("coalesce", query)
self.assertNotIn("ifnull", query)

View file

@ -509,7 +509,9 @@ def get_files_path(*path, **kwargs):
def get_bench_path():
return os.path.realpath(os.path.join(os.path.dirname(frappe.__file__), "..", "..", ".."))
return os.environ.get("FRAPPE_BENCH_ROOT") or os.path.realpath(
os.path.join(os.path.dirname(frappe.__file__), "..", "..", "..")
)
def get_bench_id():

View file

@ -21,7 +21,7 @@ from tenacity import retry, retry_if_exception_type, stop_after_attempt, wait_fi
import frappe
import frappe.monitor
from frappe import _
from frappe.utils import cstr, get_bench_id
from frappe.utils import cint, cstr, get_bench_id
from frappe.utils.commands import log
from frappe.utils.deprecations import deprecation_warning
from frappe.utils.redis_queue import RedisQueue
@ -268,6 +268,8 @@ def start_worker(
if os.environ.get("CI"):
setup_loghandlers("ERROR")
set_niceness()
logging_level = "INFO"
if quiet:
logging_level = "WARNING"
@ -305,6 +307,7 @@ def start_worker_pool(
if os.environ.get("CI"):
setup_loghandlers("ERROR")
set_niceness()
logging_level = "INFO"
if quiet:
logging_level = "WARNING"
@ -519,3 +522,24 @@ def get_job(job_id: str) -> Job:
return Job.fetch(create_job_id(job_id), connection=get_redis_conn())
except NoSuchJobError:
return None
BACKGROUND_PROCESS_NICENESS = 10
def set_niceness():
"""Background processes should have slightly lower priority than web processes.
Calling this function increments the niceness of process by configured value or default.
Note: This function should be called only once in process' lifetime.
"""
conf = frappe.get_conf()
nice_increment = BACKGROUND_PROCESS_NICENESS
configured_niceness = conf.get("background_process_niceness")
if configured_niceness is not None:
nice_increment = cint(configured_niceness)
os.nice(nice_increment)

View file

@ -1353,12 +1353,12 @@ def money_in_words(
# 0.00
if main == "0" and fraction in ["00", "000"]:
out = "{} {}".format(main_currency, _("Zero"))
out = _(main_currency, context="Currency") + " " + _("Zero")
# 0.XX
elif main == "0":
out = _(in_words(fraction, in_million).title()) + " " + fraction_currency
else:
out = main_currency + " " + _(in_words(main, in_million).title())
out = _(main_currency, context="Currency") + " " + _(in_words(main, in_million).title())
if cint(fraction):
out = (
out

View file

@ -3,6 +3,9 @@
import functools
import inspect
import re
from collections import Counter
from contextlib import suppress
import frappe
@ -126,3 +129,33 @@ def raise_error_on_no_output(error_message, error_type=None, keep_quiet=None):
return wrapper_raise_error_on_no_output
return decorator_raise_error_on_no_output
def guess_exception_source(exception: str) -> str | None:
"""Attempts to guess source of error based on traceback.
E.g.
- For unhandled exception last python file from apps folder is responsible.
- For frappe.throws the exception source is possibly present after skipping frappe.throw frames
- For server script the file name is `<serverscript>`
"""
with suppress(Exception):
installed_apps = frappe.get_installed_apps()
app_priority = {app: installed_apps.index(app) for app in installed_apps}
APP_NAME_REGEX = re.compile(r".*File.*apps/(?P<app_name>\w+)/\1/")
SERVER_SCRIPT_FRAME = re.compile(r".*<serverscript>")
apps = Counter()
for line in reversed(exception.splitlines()):
if SERVER_SCRIPT_FRAME.match(line):
return "Server Script"
if matches := APP_NAME_REGEX.match(line):
app_name = matches.group("app_name")
apps[app_name] += app_priority.get(app_name, 0)
if probably_source := apps.most_common(1):
return f"{probably_source[0][0]} (app)"

View file

@ -25,7 +25,7 @@ def clean_html(html):
return bleach.clean(
clean_script_and_style(html),
tags=[
tags={
"div",
"p",
"br",
@ -42,9 +42,8 @@ def clean_html(html):
"tbody",
"td",
"tr",
],
},
attributes=[],
styles=["color", "border", "border-color"],
strip=True,
strip_comments=True,
)
@ -52,44 +51,13 @@ def clean_html(html):
def clean_email_html(html):
import bleach
from bleach.css_sanitizer import CSSSanitizer
if not isinstance(html, str):
return html
return bleach.clean(
clean_script_and_style(html),
tags=[
"div",
"p",
"br",
"ul",
"ol",
"li",
"strong",
"b",
"em",
"i",
"u",
"a",
"table",
"thead",
"tbody",
"td",
"tr",
"th",
"pre",
"code",
"h1",
"h2",
"h3",
"h4",
"h5",
"h6",
"button",
"img",
],
attributes=["border", "colspan", "rowspan", "src", "href", "style", "id"],
styles=[
css_sanitizer = CSSSanitizer(
allowed_css_properties=[
"color",
"border-color",
"width",
@ -121,7 +89,43 @@ def clean_email_html(html):
"text-align",
"vertical-align",
"display",
],
]
)
return bleach.clean(
clean_script_and_style(html),
tags={
"div",
"p",
"br",
"ul",
"ol",
"li",
"strong",
"b",
"em",
"i",
"u",
"a",
"table",
"thead",
"tbody",
"td",
"tr",
"th",
"pre",
"code",
"h1",
"h2",
"h3",
"h4",
"h5",
"h6",
"button",
"img",
},
attributes=["border", "colspan", "rowspan", "src", "href", "style", "id"],
css_sanitizer=css_sanitizer,
protocols=["cid", "http", "https", "mailto", "data"],
strip=True,
strip_comments=True,
@ -146,6 +150,7 @@ def sanitize_html(html, linkify=False):
Does not sanitize JSON, as it could lead to future problems
"""
import bleach
from bleach.css_sanitizer import CSSSanitizer
from bs4 import BeautifulSoup
if not isinstance(html, str):
@ -170,17 +175,16 @@ def sanitize_html(html, linkify=False):
return name in acceptable_attributes
attributes = {"*": attributes_filter, "svg": svg_attributes}
styles = bleach_allowlist.all_styles
strip_comments = False
css_sanitizer = CSSSanitizer(allowed_css_properties=bleach_allowlist.all_styles)
# returns html with escaped tags, escaped orphan >, <, etc.
escaped_html = bleach.clean(
html,
tags=tags,
attributes=attributes,
styles=styles,
strip_comments=strip_comments,
protocols=["cid", "http", "https", "mailto"],
css_sanitizer=css_sanitizer,
strip_comments=False,
protocols={"cid", "http", "https", "mailto"},
)
return escaped_html

View file

@ -133,10 +133,14 @@ def as_binary():
def make_logs(response=None):
"""make strings for msgprint and errprint"""
from frappe.utils.error import guess_exception_source
if not response:
response = frappe.local.response
if frappe.error_log:
if source := guess_exception_source(frappe.local.error_log and frappe.local.error_log[0]["exc"]):
response["_exc_source"] = source
response["exc"] = json.dumps([frappe.utils.cstr(d["exc"]) for d in frappe.local.error_log])
if frappe.local.message_log:

View file

@ -330,8 +330,7 @@ def get_hooks(hook=None, default=None, app_name=None):
def read_sql(query, *args, **kwargs):
"""a wrapper for frappe.db.sql to allow reads"""
query = str(query)
if frappe.flags.in_safe_exec:
check_safe_sql_query(query)
check_safe_sql_query(query)
return frappe.db.sql(query, *args, **kwargs)

View file

@ -16,7 +16,7 @@ from typing import NoReturn
# imports - module imports
import frappe
from frappe.utils import cint, get_datetime, get_sites, now_datetime
from frappe.utils.background_jobs import get_jobs
from frappe.utils.background_jobs import set_niceness
DATETIME_FORMAT = "%Y-%m-%d %H:%M:%S"
@ -35,6 +35,7 @@ def start_scheduler() -> NoReturn:
Specify scheduler_interval in seconds in common_site_config.json"""
tick = cint(frappe.get_conf().scheduler_tick_interval) or 60
set_niceness()
while True:
time.sleep(tick)

View file

@ -14,6 +14,7 @@
{% endblock %}
{% macro header_buttons() %}
{% block header_buttons %}
{% if allow_edit and in_view_mode %}
<!-- edit button -->
<a href="/{{ route }}/{{ doc_name }}/edit" class="edit-button btn btn-default btn-sm">{{ _("Edit Response", null, "Button in web form") }}</a>
@ -26,9 +27,11 @@
<svg class="icon icon-sm"><use href="#icon-printer"></use></svg>
</a>
{% endif %}
{% endblock %}
{% endmacro %}
{% macro action_buttons() %}
{% block actions_buttons %}
<div class="left-area"></div>
<div class="center-area paging"></div>
<div class="right-area">
@ -41,6 +44,7 @@
<button type="submit" class="submit-btn btn btn-primary btn-sm ml-2">{{ button_label or _("Submit", null, "Button in web form") }}</button>
{% endif %}
</div>
{% endblock %}
{% endmacro %}
{% block page_content %}

View file

@ -26,7 +26,9 @@ html, body {
</h5>
<div class="page-card-body">
{% block message_body %}
<p>{{ message or "" }}</p>
{% if message %}
<p>{{ message }}</p>
{% endif %}
{% if primary_action %}
<div><a href='{{ primary_action or "/" }}' class='btn btn-primary btn-sm btn-block'>
{{ primary_label or _("Home") }}</a></div>

View file

@ -1,8 +1,14 @@
const fs = require("fs");
const path = require("path");
const redis = require("redis");
const redis = require("@redis/client");
const bench_path = path.resolve(__dirname, "..", "..");
const dns = require("dns");
// Since node17, node resolves to ipv6 unless system is configured otherwise.
// In Frappe context using ipv4 - 127.0.0.1 is fine.
dns.setDefaultResultOrder("ipv4first");
function get_conf() {
// defaults
var conf = {

View file

@ -22,6 +22,7 @@
"dependencies": {
"@editorjs/editorjs": "^2.26.3",
"@frappe/esbuild-plugin-postcss2": "^0.1.3",
"@redis/client": "^1.5.8",
"@vue-flow/background": "^1.1.0",
"@vue-flow/core": "^1.16.2",
"@vue/component-compiler": "^4.2.4",
@ -63,7 +64,6 @@
"quill-image-resize": "^3.0.9",
"quill-magic-url": "^3.0.0",
"qz-tray": "^2.0.8",
"redis": "^3.1.1",
"rtlcss": "^4.0.0",
"sass": "^1.63.0",
"showdown": "^2.1.0",

View file

@ -28,7 +28,7 @@ dependencies = [
"Whoosh~=2.7.4",
"beautifulsoup4~=4.12.2",
"bleach-allowlist~=1.0.3",
"bleach~=3.3.0",
"bleach[css]~=6.0.0",
"cairocffi==1.5.1",
"chardet~=5.1.0",
"croniter~=1.3.15",
@ -74,7 +74,7 @@ dependencies = [
"markdownify~=0.11.6",
# integration dependencies
"boto3~=1.18.49",
"boto3~=1.28.10",
"dropbox~=11.36.0",
"google-api-python-client~=2.2.0",
"google-auth-oauthlib~=0.4.4",
@ -105,4 +105,4 @@ pyngrok = "~=6.0.0"
unittest-xml-reporting = "~=3.2.0"
watchdog = "~=3.0.0"
hypothesis = "~=6.77.0"
responses = "~=0.23.1"
responses = "==0.23.1"

View file

@ -9,6 +9,7 @@ let io = new Server({
origin: true,
credentials: true,
},
cleanupEmptyChildNamespaces: true,
});
// Multitenancy implementation.
@ -27,7 +28,8 @@ function on_connection(socket) {
frappe_handlers(realtime, socket);
// ESBUild "open in editor" on error
socket.on("open_in_editor", (data) => {
socket.on("open_in_editor", async (data) => {
await subscriber.connect();
subscriber.publish("open_in_editor", JSON.stringify(data));
});
}
@ -38,19 +40,19 @@ realtime.on("connection", on_connection);
// Consume events sent from python via redis pub-sub channel.
const subscriber = get_redis_subscriber();
subscriber.on("message", function (_channel, message) {
message = JSON.parse(message);
let namespace = "/" + message.namespace;
if (message.room) {
io.of(namespace).to(message.room).emit(message.event, message.message);
} else {
// publish to ALL sites only used for things like build event.
realtime.emit(message.event, message.message);
}
});
subscriber.subscribe("events");
(async () => {
await subscriber.connect();
subscriber.subscribe("events", (message) => {
message = JSON.parse(message);
let namespace = "/" + message.namespace;
if (message.room) {
io.of(namespace).to(message.room).emit(message.event, message.message);
} else {
// publish to ALL sites only used for things like build event.
realtime.emit(message.event, message.message);
}
});
})();
// =======================
let port = conf.socketio_port;

View file

@ -81,6 +81,15 @@
"@nodelib/fs.scandir" "2.1.5"
fastq "^1.6.0"
"@redis/client@^1.5.8":
version "1.5.8"
resolved "https://registry.yarnpkg.com/@redis/client/-/client-1.5.8.tgz#a375ba7861825bd0d2dc512282b8bff7b98dbcb1"
integrity sha512-xzElwHIO6rBAqzPeVnCzgvrnBEcFL1P0w8P65VNLRkdVW8rOE58f52hdj0BDgmsdOm4f1EoXPZtH4Fh7M/qUpw==
dependencies:
cluster-key-slot "1.1.2"
generic-pool "3.9.0"
yallist "4.0.0"
"@socket.io/component-emitter@~3.1.0":
version "3.1.0"
resolved "https://registry.yarnpkg.com/@socket.io/component-emitter/-/component-emitter-3.1.0.tgz#96116f2a912e0c02817345b3c10751069920d553"
@ -592,6 +601,11 @@ clone@^2.1.1, clone@^2.1.2:
resolved "https://registry.yarnpkg.com/clone/-/clone-2.1.2.tgz#1b7f4b9f591f1e8f83670401600345a02887435f"
integrity sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==
cluster-key-slot@1.1.2:
version "1.1.2"
resolved "https://registry.yarnpkg.com/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz#88ddaa46906e303b5de30d3153b7d9fe0a0c19ac"
integrity sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==
color-convert@^1.9.0:
version "1.9.3"
resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-1.9.3.tgz#bb71850690e1f136567de629d2d5471deda4c1e8"
@ -973,11 +987,6 @@ delayed-stream@~1.0.0:
resolved "https://registry.yarnpkg.com/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619"
integrity sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==
denque@^1.5.0:
version "1.5.1"
resolved "https://registry.yarnpkg.com/denque/-/denque-1.5.1.tgz#07f670e29c9a78f8faecb2566a1e2c11929c5cbf"
integrity sha512-XwE+iZ4D6ZUB7mfYRMb5wByE8L74HCn30FBN7sWnXksWc1LO1bPDl67pBR9o/kC4z/xSNAwkMYcGgqDV3BE3Hw==
dezalgo@^1.0.4:
version "1.0.4"
resolved "https://registry.yarnpkg.com/dezalgo/-/dezalgo-1.0.4.tgz#751235260469084c132157dfa857f386d4c33d81"
@ -1457,6 +1466,11 @@ generic-names@^4.0.0:
dependencies:
loader-utils "^3.2.0"
generic-pool@3.9.0:
version "3.9.0"
resolved "https://registry.yarnpkg.com/generic-pool/-/generic-pool-3.9.0.tgz#36f4a678e963f4fdb8707eab050823abc4e8f5e4"
integrity sha512-hymDOu5B53XvN4QT9dBmZxPX4CWhBPPLguTZ9MMFeFa/Kg0xWVfylOVNlJji/E7yTZWFd/q9GO5TxDLq156D7g==
get-caller-file@^2.0.5:
version "2.0.5"
resolved "https://registry.yarnpkg.com/get-caller-file/-/get-caller-file-2.0.5.tgz#4f94412a82db32f36e3b0b9741f8a97feb031f7e"
@ -2874,33 +2888,6 @@ readdirp@~3.6.0:
dependencies:
picomatch "^2.2.1"
redis-commands@^1.7.0:
version "1.7.0"
resolved "https://registry.yarnpkg.com/redis-commands/-/redis-commands-1.7.0.tgz#15a6fea2d58281e27b1cd1acfb4b293e278c3a89"
integrity sha512-nJWqw3bTFy21hX/CPKHth6sfhZbdiHP6bTawSgQBlKOVRG7EZkfHbbHwQJnrE4vsQf0CMNE+3gJ4Fmm16vdVlQ==
redis-errors@^1.0.0, redis-errors@^1.2.0:
version "1.2.0"
resolved "https://registry.yarnpkg.com/redis-errors/-/redis-errors-1.2.0.tgz#eb62d2adb15e4eaf4610c04afe1529384250abad"
integrity sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==
redis-parser@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/redis-parser/-/redis-parser-3.0.0.tgz#b66d828cdcafe6b4b8a428a7def4c6bcac31c8b4"
integrity sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==
dependencies:
redis-errors "^1.0.0"
redis@^3.1.1:
version "3.1.2"
resolved "https://registry.yarnpkg.com/redis/-/redis-3.1.2.tgz#766851117e80653d23e0ed536254677ab647638c"
integrity sha512-grn5KoZLr/qrRQVwoSkmzdbw6pwF+/rwODtrOr6vuBRiR/f3rjSTGupbF90Zpqm2oenix8Do6RV7pYEkGwlKkw==
dependencies:
denque "^1.5.0"
redis-commands "^1.7.0"
redis-errors "^1.2.0"
redis-parser "^3.0.0"
regexp.prototype.flags@^1.2.0, regexp.prototype.flags@^1.5.0:
version "1.5.0"
resolved "https://registry.yarnpkg.com/regexp.prototype.flags/-/regexp.prototype.flags-1.5.0.tgz#fe7ce25e7e4cca8db37b6634c8a2c7009199b9cb"
@ -3479,16 +3466,16 @@ y18n@^5.0.5:
resolved "https://registry.yarnpkg.com/y18n/-/y18n-5.0.8.tgz#7f4934d0f7ca8c56f95314939ddcd2dd91ce1d55"
integrity sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==
yallist@4.0.0, yallist@^4.0.0:
version "4.0.0"
resolved "https://registry.yarnpkg.com/yallist/-/yallist-4.0.0.tgz#9bb92790d9c0effec63be73519e11a35019a3a72"
integrity sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==
yallist@^2.1.2:
version "2.1.2"
resolved "https://registry.yarnpkg.com/yallist/-/yallist-2.1.2.tgz#1c11f9218f076089a47dd512f93c6699a6a81d52"
integrity sha512-ncTzHV7NvsQZkYe1DW7cbDLm0YpzHmZF5r/iyP3ZnQtMiJ+pjzisCiMNI+Sj+xQF5pXhSHxSB3uDbsBTzY/c2A==
yallist@^4.0.0:
version "4.0.0"
resolved "https://registry.yarnpkg.com/yallist/-/yallist-4.0.0.tgz#9bb92790d9c0effec63be73519e11a35019a3a72"
integrity sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==
yaml@^1.10.2:
version "1.10.2"
resolved "https://registry.yarnpkg.com/yaml/-/yaml-1.10.2.tgz#2301c5ffbf12b467de8da2333a459e29e7920e4b"