Merge remote-tracking branch 'upstream/develop' into feat/link-preview-in-tree-view

This commit is contained in:
barredterra 2024-01-11 14:13:13 +01:00
commit d3f16b7ccb
219 changed files with 695659 additions and 304636 deletions

1
.gitignore vendored
View file

@ -169,6 +169,7 @@ typings/
# Optional npm cache directory
.npm
.yarn
# Optional eslint cache
.eslintcache

9
babel_extractors.csv Normal file
View file

@ -0,0 +1,9 @@
hooks.py,frappe.gettext.extractors.navbar.extract
**/doctype/*/*.json,frappe.gettext.extractors.doctype.extract
**/workspace/*/*.json,frappe.gettext.extractors.workspace.extract
**/onboarding_step/*/*.json,frappe.gettext.extractors.onboarding_step.extract
**/module_onboarding/*/*.json,frappe.gettext.extractors.module_onboarding.extract
**/report/*/*.json,frappe.gettext.extractors.report.extract
**.py,frappe.gettext.extractors.python.extract
**.js,frappe.gettext.extractors.javascript.extract
**.html,frappe.gettext.extractors.jinja2.extract
1 hooks.py frappe.gettext.extractors.navbar.extract
2 **/doctype/*/*.json frappe.gettext.extractors.doctype.extract
3 **/workspace/*/*.json frappe.gettext.extractors.workspace.extract
4 **/onboarding_step/*/*.json frappe.gettext.extractors.onboarding_step.extract
5 **/module_onboarding/*/*.json frappe.gettext.extractors.module_onboarding.extract
6 **/report/*/*.json frappe.gettext.extractors.report.extract
7 **.py frappe.gettext.extractors.python.extract
8 **.js frappe.gettext.extractors.javascript.extract
9 **.html frappe.gettext.extractors.jinja2.extract

3
crowdin.yml Normal file
View file

@ -0,0 +1,3 @@
files:
- source: /frappe/locale/main.pot
translation: /frappe/locale/%two_letters_code%.po

View file

@ -7,7 +7,7 @@ const jump_to_field = (field_label) => {
.type("{enter}")
.wait(200)
.type("{enter}")
.wait(500);
.wait(1000);
};
const type_value = (value) => {

View file

@ -8,6 +8,7 @@ const test_queries = [
`?date=%5B">"%2C"2022-06-01"%5D`,
`?name=%5B"like"%2C"%2542%25"%5D`,
`?status=%5B"not%20in"%2C%5B"Open"%2C"Closed"%5D%5D`,
`?status=%5B%22%21%3D%22%2C%22Closed%22%5D&status=%5B%22%21%3D%22%2C%22Cancelled%22%5D`,
];
describe("SPA Routing", { scrollBehavior: false }, () => {

View file

@ -224,8 +224,8 @@ context("View", () => {
});
});
it("Route to Settings Workspace", () => {
cy.visit("/app/settings");
cy.get(".title-text").should("contain", "Settings");
it("Route to Website Workspace", () => {
cy.visit("/app/website");
cy.get(".title-text").should("contain", "Website");
});
});

View file

@ -7,8 +7,8 @@ context("Workspace 2.0", () => {
it("Navigate to page from sidebar", () => {
cy.visit("/app/build");
cy.get(".codex-editor__redactor .ce-block");
cy.get('.sidebar-item-container[item-name="Settings"]').first().click();
cy.location("pathname").should("eq", "/app/settings");
cy.get('.sidebar-item-container[item-name="Website"]').first().click();
cy.location("pathname").should("eq", "/app/website");
});
it("Create Private Page", () => {

View file

@ -449,27 +449,8 @@ Cypress.Commands.add("click_menu_button", (name) => {
});
Cypress.Commands.add("clear_filters", () => {
let has_filter = false;
cy.intercept({
method: "POST",
url: "api/method/frappe.model.utils.user_settings.save",
}).as("filter-saved");
cy.get(".filter-section .filter-button").click({ force: true });
cy.wait(300);
cy.get(".filter-popover").should("exist");
cy.get(".filter-popover").then((popover) => {
if (popover.find("input.input-with-feedback")[0].value != "") {
has_filter = true;
}
});
cy.get(".filter-popover").find(".clear-filters").click();
cy.get(".filter-section .filter-button").click();
cy.window()
.its("cur_list")
.then((cur_list) => {
cur_list && cur_list.filter_area && cur_list.filter_area.clear();
has_filter && cy.wait("@filter-saved");
});
cy.get(".filter-x-button").click({ force: true });
cy.wait(500);
});
Cypress.Commands.add("click_modal_primary_button", (btn_name) => {

View file

@ -118,6 +118,23 @@ def _(msg: str, lang: str | None = None, context: str | None = None) -> str:
return translated_string or non_translated_string
def _lt(msg: str, lang: str | None = None, context: str | None = None):
"""Lazily translate a string.
This function returns a "lazy string" which when casted to string via some operation applies
translation first before casting.
This is only useful for translating strings in global scope or anything that potentially runs
before `frappe.init()`
Note: Result is not guaranteed to equivalent to pure strings for all operations.
"""
from frappe.translate import LazyTranslate
return LazyTranslate(msg, lang, context)
def as_unicode(text, encoding: str = "utf-8") -> str:
"""Convert to unicode if required."""
if isinstance(text, str):
@ -975,6 +992,7 @@ def has_permission(
throw=False,
*,
parent_doctype=None,
debug=False,
):
"""
Return True if the user has permission `ptype` for given `doctype` or `doc`.
@ -997,24 +1015,17 @@ def has_permission(
ptype,
doc=doc,
user=user,
raise_exception=throw,
print_logs=throw,
parent_doctype=parent_doctype,
debug=debug,
)
if throw and not out:
# mimics frappe.throw
document_label = (
f"{_(doctype)} {doc if isinstance(doc, str) else doc.name}" if doc else _(doctype)
)
msgprint(
_("No permission for {0}").format(document_label),
raise_exception=ValidationError,
title=None,
indicator="red",
is_minimizable=None,
wide=None,
as_list=False,
)
frappe.flags.error_message = _("No permission for {0}").format(document_label)
raise frappe.PermissionError
return out

View file

@ -237,7 +237,7 @@ def get_user_pages_or_reports(parent, cache=False):
has_role[p.name] = {"modified": p.modified, "title": p.title}
elif parent == "Report":
if not has_permission("Report", raise_exception=False):
if not has_permission("Report", print_logs=False):
return {}
reports = frappe.get_list(
@ -270,9 +270,6 @@ def get_user_info():
user_info = frappe._dict()
add_user_info(frappe.session.user, user_info)
if frappe.session.user == "Administrator" and user_info.Administrator.email:
user_info[user_info.Administrator.email] = user_info.Administrator
return user_info

View file

@ -105,6 +105,7 @@ def call_command(cmd, context):
def get_commands():
# prevent circular imports
from .gettext import commands as gettext_commands
from .redis_utils import commands as redis_commands
from .scheduler import commands as scheduler_commands
from .site import commands as site_commands
@ -113,7 +114,12 @@ def get_commands():
clickable_link = "https://frappeframework.com/docs"
all_commands = (
scheduler_commands + site_commands + translate_commands + utils_commands + redis_commands
scheduler_commands
+ site_commands
+ translate_commands
+ gettext_commands
+ utils_commands
+ redis_commands
)
for command in all_commands:

View file

@ -0,0 +1,98 @@
import click
from frappe.commands import pass_context
from frappe.exceptions import SiteNotSpecifiedError
@click.command("generate-pot-file", help="Translation: generate POT file")
@click.option("--app", help="Only generate for this app. eg: frappe")
@pass_context
def generate_pot_file(context, app: str | None = None):
from frappe.gettext.translate import generate_pot
if not app:
connect_to_site(context.sites[0] if context.sites else None)
generate_pot(app)
@click.command("compile-po-to-mo", help="Translation: compile PO files to MO files")
@click.option("--app", help="Only compile for this app. eg: frappe")
@click.option(
"--force",
is_flag=True,
default=False,
help="Force compile even if there are no changes to PO files",
)
@click.option("--locale", help="Compile transaltions only for this locale. eg: de")
@pass_context
def compile_translations(context, app: str | None = None, locale: str = None, force=False):
from frappe.gettext.translate import compile_translations as _compile_translations
if not app:
connect_to_site(context.sites[0] if context.sites else None)
_compile_translations(app, locale, force=force)
@click.command(
"migrate-csv-to-po", help="Translation: migrate from CSV files (old) to PO files (new)"
)
@click.option("--app", help="Only migrate for this app. eg: frappe")
@click.option("--locale", help="Compile translations only for this locale. eg: de")
@pass_context
def csv_to_po(context, app: str | None = None, locale: str = None):
from frappe.gettext.translate import migrate
if not app:
connect_to_site(context.sites[0] if context.sites else None)
migrate(app, locale)
@click.command(
"update-po-files",
help="""Translation: sync PO files with POT file.
You might want to run generate-pot-file first.""",
)
@click.option("--app", help="Only update for this app. eg: frappe")
@pass_context
def update_po_files(context, app: str | None = None):
from frappe.gettext.translate import update_po
if not app:
connect_to_site(context.sites[0] if context.sites else None)
update_po(app)
@click.command("create-po-file", help="Translation: create a new PO file for a locale")
@click.argument("locale", nargs=1)
@click.option("--app", help="Only create for this app. eg: frappe")
@pass_context
def create_po_file(context, locale: str, app: str | None = None):
"""Create PO file for lang code"""
from frappe.gettext.translate import new_po
if not app:
connect_to_site(context.sites[0] if context.sites else None)
new_po(locale, app)
def connect_to_site(site):
from frappe import connect
if not site:
raise SiteNotSpecifiedError
connect(site=site)
commands = [
generate_pot_file,
compile_translations,
csv_to_po,
update_po_files,
create_po_file,
]

View file

@ -260,8 +260,28 @@ def restore_backup(
admin_password,
force,
):
from pathlib import Path
from frappe.installer import _new_site, is_downgrade, is_partial, validate_database_sql
# Check for the backup file in the backup directory, as well as the main bench directory
dirs = (f"{site}/private/backups", "..")
# Try to resolve path to the file if we can't find it directly
if not Path(sql_file_path).exists():
click.secho(
f"File {sql_file_path} not found. Trying to check in alternative directories.", fg="yellow"
)
for dir in dirs:
potential_path = Path(dir) / Path(sql_file_path)
if potential_path.exists():
sql_file_path = str(potential_path.resolve())
click.secho(f"File {sql_file_path} found.", fg="green")
break
else:
click.secho(f"File {sql_file_path} not found.", fg="red")
sys.exit(1)
if is_partial(sql_file_path):
click.secho(
"Partial Backup file detected. You cannot use a partial file to restore a Frappe site.",

View file

@ -47,6 +47,7 @@ def build(
):
"Compile JS and CSS source files"
from frappe.build import bundle, download_frappe_assets
from frappe.gettext.translate import compile_translations
from frappe.utils.synchronization import filelock
frappe.init("")
@ -77,6 +78,16 @@ def build(
save_metafiles=save_metafiles,
)
if apps and isinstance(apps, str):
apps = apps.split(",")
if not apps:
apps = frappe.get_all_apps()
for app in apps:
print("Compiling translations for", app)
compile_translations(app, force=force)
@click.command("watch")
@click.option("--apps", help="Watch assets for specific apps")
@ -93,14 +104,12 @@ def watch(apps=None):
def clear_cache(context):
"Clear cache, doctype cache and defaults"
import frappe.sessions
from frappe.desk.notifications import clear_notifications
from frappe.website.utils import clear_website_cache
for site in context.sites:
try:
frappe.connect(site)
frappe.clear_cache()
clear_notifications()
clear_website_cache()
finally:
frappe.destroy()

View file

@ -267,6 +267,7 @@ frappe.ui.form.on("Communication", {
$.extend(args, {
subject: __("Re: {0}", [frm.doc.subject]),
recipients: frm.doc.sender,
is_a_reply: true,
});
new frappe.views.CommunicationComposer(args);
@ -278,6 +279,7 @@ frappe.ui.form.on("Communication", {
subject: __("Res: {0}", [frm.doc.subject]),
recipients: frm.doc.sender,
cc: frm.doc.cc,
is_a_reply: true,
});
new frappe.views.CommunicationComposer(args);
},
@ -287,6 +289,7 @@ frappe.ui.form.on("Communication", {
$.extend(args, {
forward: true,
subject: __("Fw: {0}", [frm.doc.subject]),
is_a_reply: true,
});
new frappe.views.CommunicationComposer(args);

View file

@ -306,7 +306,7 @@ class Communication(Document, CommunicationEmailMixin):
emails = split_emails(emails) if isinstance(emails, str) else (emails or [])
if exclude_displayname:
return [email.lower() for email in {parse_addr(email)[1] for email in emails} if email]
return [email.lower() for email in set(emails) if email]
return [email for email in set(emails) if email]
def to_list(self, exclude_displayname=True):
"""Return `to` list."""
@ -501,14 +501,17 @@ def on_doctype_update():
frappe.db.add_index("Communication", ["message_id(140)"])
def has_permission(doc, ptype, user):
def has_permission(doc, ptype, user=None, debug=False):
if ptype == "read":
if doc.reference_doctype == "Communication" and doc.reference_name == doc.name:
return
return True
if doc.reference_doctype and doc.reference_name:
if frappe.has_permission(doc.reference_doctype, ptype="read", doc=doc.reference_name):
return True
return frappe.has_permission(
doc.reference_doctype, ptype="read", doc=doc.reference_name, user=user, debug=debug
)
return True
def get_permission_query_conditions_for_communication(user):

View file

@ -145,6 +145,12 @@ const get_doctypes = (parentdt) => {
const add_doctype_field_multicheck_control = (doctype, parent_wrapper) => {
const fields = get_fields(doctype);
frappe.model.std_fields
.filter((df) => ["owner", "creation"].includes(df.fieldname))
.forEach((df) => {
fields.push(df);
});
const options = fields.map((df) => {
return {
label: df.label,

View file

@ -212,8 +212,23 @@ class DataExporter:
# build list of valid docfields
tablecolumns = []
table_name = "tab" + dt
for f in frappe.db.get_table_columns_description(table_name):
field = meta.get_field(f.name)
if f.name in ["owner", "creation"]:
std_field = next((x for x in frappe.model.std_fields if x["fieldname"] == f.name), None)
if std_field:
field = frappe._dict(
{
"fieldname": std_field.get("fieldname"),
"label": std_field.get("label"),
"fieldtype": std_field.get("fieldtype"),
"options": std_field.get("options"),
"idx": 0,
"parent": dt,
}
)
if field and (
(self.select_columns and f.name in self.select_columns[dt]) or not self.select_columns
):
@ -404,7 +419,6 @@ class DataExporter:
)
for ci, child in enumerate(data_row.run(as_dict=True)):
self.add_data_row(rows, c["doctype"], c["parentfield"], child, ci)
for row in rows:
self.writer.writerow(row)

View file

@ -88,8 +88,8 @@ class TestDataExporter(FrappeTestCase):
self.assertEqual(frappe.response["type"], "csv")
self.assertEqual(frappe.response["doctype"], self.doctype_name)
self.assertTrue(frappe.response["result"])
self.assertIn('Child Title 1",50', frappe.response["result"])
self.assertIn('Child Title 2",51', frappe.response["result"])
self.assertRegex(frappe.response["result"], r"Child Title 1.*?,50")
self.assertRegex(frappe.response["result"], r"Child Title 2.*?,51")
def test_export_type(self):
for type in ["csv", "Excel"]:

View file

@ -234,6 +234,7 @@ class DocType(Document):
"DocPerm",
"Custom Field",
"Customize Form Field",
"Web Form Field",
"DocField",
]

View file

@ -778,11 +778,11 @@ def on_doctype_update():
frappe.db.add_index("File", ["attached_to_doctype", "attached_to_name"])
def has_permission(doc, ptype=None, user=None):
def has_permission(doc, ptype=None, user=None, debug=False):
user = user or frappe.session.user
if ptype == "create":
return frappe.has_permission("File", "create", user=user)
return frappe.has_permission("File", "create", user=user, debug=debug)
if not doc.is_private or (user != "Guest" and doc.owner == user) or user == "Administrator":
return True
@ -798,9 +798,9 @@ def has_permission(doc, ptype=None, user=None):
return False
if ptype in ["write", "create", "delete"]:
return ref_doc.has_permission("write")
return ref_doc.has_permission("write", debug=debug, user=user)
else:
return ref_doc.has_permission("read")
return ref_doc.has_permission("read", debug=debug, user=user)
return False

View file

@ -0,0 +1,24 @@
// Copyright (c) 2024, Frappe Technologies and contributors
// For license information, please see license.txt
const call_debug = (frm) => {
frm.trigger("debug");
};
frappe.ui.form.on("Permission Debugger", {
refresh(frm) {
frm.disable_save();
},
docname: call_debug,
ref_doctype(frm) {
frm.doc.docname = ""; // Usually doctype change invalidates docname
call_debug(frm);
},
user: call_debug,
permission_type: call_debug,
debug(frm) {
if (frm.doc.ref_doctype && frm.doc.user) {
frm.call("debug");
}
},
});

View file

@ -0,0 +1,90 @@
{
"actions": [],
"allow_rename": 1,
"beta": 1,
"creation": "2024-01-03 17:43:27.257317",
"doctype": "DocType",
"engine": "InnoDB",
"field_order": [
"ref_doctype",
"column_break_mcqo",
"docname",
"column_break_xbrd",
"user",
"column_break_nvaa",
"permission_type",
"section_break_hkjp",
"output"
],
"fields": [
{
"fieldname": "ref_doctype",
"fieldtype": "Link",
"in_list_view": 1,
"label": "DocType",
"options": "DocType",
"reqd": 1
},
{
"fieldname": "docname",
"fieldtype": "Dynamic Link",
"in_list_view": 1,
"label": "Document",
"options": "ref_doctype"
},
{
"fieldname": "column_break_mcqo",
"fieldtype": "Column Break"
},
{
"fieldname": "column_break_xbrd",
"fieldtype": "Column Break"
},
{
"fieldname": "user",
"fieldtype": "Link",
"label": "User",
"options": "User",
"reqd": 1
},
{
"fieldname": "section_break_hkjp",
"fieldtype": "Section Break"
},
{
"fieldname": "output",
"fieldtype": "Code",
"label": "Output",
"read_only": 1
},
{
"fieldname": "column_break_nvaa",
"fieldtype": "Column Break"
},
{
"fieldname": "permission_type",
"fieldtype": "Select",
"label": "Permission Type",
"options": "read\nwrite\ncreate\ndelete\nsubmit\ncancel\nselect\namend\nprint\nemail\nreport\nimport\nexport\nshare"
}
],
"index_web_pages_for_search": 1,
"is_virtual": 1,
"issingle": 1,
"links": [],
"modified": "2024-01-10 14:17:49.722593",
"modified_by": "Administrator",
"module": "Core",
"name": "Permission Debugger",
"owner": "Administrator",
"permissions": [
{
"read": 1,
"role": "System Manager",
"write": 1
}
],
"sort_field": "modified",
"sort_order": "DESC",
"states": []
}

View file

@ -0,0 +1,75 @@
# Copyright (c) 2024, Frappe Technologies and contributors
# For license information, please see license.txt
import frappe
from frappe.model.document import Document
from frappe.permissions import _pop_debug_log, has_permission
class PermissionDebugger(Document):
# begin: auto-generated types
# This code is auto-generated. Do not modify anything in this block.
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from frappe.types import DF
docname: DF.DynamicLink | None
output: DF.Code | None
permission_type: DF.Literal[
"read",
"write",
"create",
"delete",
"submit",
"cancel",
"select",
"amend",
"print",
"email",
"report",
"import",
"export",
"share",
]
ref_doctype: DF.Link
user: DF.Link
# end: auto-generated types
@frappe.whitelist()
def debug(self):
if not (self.ref_doctype and self.user):
return
result = has_permission(
self.ref_doctype, ptype=self.permission_type, doc=self.docname, user=self.user, debug=True
)
self.output = "\n==============================\n".join(_pop_debug_log())
self.output += "\n\n" + f"Ouput of has_permission: {result}"
# None of these apply, overriden for sanity.
def load_from_db(self):
super(Document, self).__init__({"modified": None, "permission_type": "read"})
def db_insert(self, *args, **kwargs):
...
def db_update(self):
...
@staticmethod
def get_list(args):
...
@staticmethod
def get_count(args):
...
@staticmethod
def get_stats(args):
...
def delete(self):
...

View file

@ -0,0 +1,9 @@
# Copyright (c) 2024, Frappe Technologies and Contributors
# See license.txt
# import frappe
from frappe.tests.utils import FrappeTestCase
class TestPermissionDebugger(FrappeTestCase):
pass

View file

@ -14,6 +14,7 @@
"cmd",
"time",
"duration",
"event_type",
"section_break_1skt",
"request_headers",
"section_break_sgro",
@ -30,6 +31,7 @@
"label": "Path"
},
{
"depends_on": "eval:doc.event_type==\"HTTP Request\"",
"fieldname": "cmd",
"fieldtype": "Data",
"in_standard_filter": 1,
@ -67,6 +69,7 @@
"fieldtype": "Section Break"
},
{
"depends_on": "eval:doc.event_type==\"HTTP Request\"",
"fieldname": "request_headers",
"fieldtype": "Code",
"label": "Request Headers"
@ -76,11 +79,13 @@
"fieldtype": "Section Break"
},
{
"depends_on": "eval:doc.event_type==\"HTTP Request\"",
"fieldname": "form_dict",
"fieldtype": "Code",
"label": "Form Dict"
},
{
"depends_on": "eval:doc.event_type==\"HTTP Request\"",
"fieldname": "method",
"fieldtype": "Select",
"in_standard_filter": 1,
@ -96,6 +101,12 @@
{
"fieldname": "section_break_9jhm",
"fieldtype": "Section Break"
},
{
"fieldname": "event_type",
"fieldtype": "Data",
"hidden": 1,
"label": "Event Type"
}
],
"hide_toolbar": 1,
@ -103,7 +114,7 @@
"index_web_pages_for_search": 1,
"is_virtual": 1,
"links": [],
"modified": "2023-08-10 12:01:03.456643",
"modified": "2024-01-03 16:45:47.110048",
"modified_by": "Administrator",
"module": "Core",
"name": "Recorder",

View file

@ -19,6 +19,7 @@ class Recorder(Document):
cmd: DF.Data | None
duration: DF.Float
event_type: DF.Data | None
form_dict: DF.Code | None
method: DF.Literal["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"]
number_of_queries: DF.Int
@ -27,7 +28,6 @@ class Recorder(Document):
sql_queries: DF.Table[RecorderQuery]
time: DF.Datetime | None
time_in_queries: DF.Float
# end: auto-generated types
def load_from_db(self):

View file

@ -3,7 +3,6 @@
import frappe
from frappe import _
from frappe.tests.utils import FrappeTestCase
from frappe.translate import clear_cache
class TestTranslation(FrappeTestCase):
@ -12,6 +11,8 @@ class TestTranslation(FrappeTestCase):
def tearDown(self):
frappe.local.lang = "en"
from frappe.translate import clear_cache
clear_cache()
def test_doctype(self):

View file

@ -1157,6 +1157,7 @@ def has_permission(doc, user):
if (user != "Administrator") and (doc.name in STANDARD_USERS):
# dont allow non Administrator user to view / edit Administrator user
return False
return True
def notify_admin_access_to_system_manager(login_manager=None):

View file

@ -362,7 +362,8 @@ def rename_fieldname(custom_field: str, fieldname: str):
frappe.msgprint(_("Old and new fieldnames are same."), alert=True)
return
frappe.db.rename_column(parent_doctype, old_fieldname, new_fieldname)
if frappe.db.has_column(field.dt, old_fieldname):
frappe.db.rename_column(parent_doctype, old_fieldname, new_fieldname)
# Update in DB after alter column is successful, alter column will implicitly commit, so it's
# best to commit change on field too to avoid any possible mismatch between two.

View file

@ -57,7 +57,7 @@ class DbManager:
from frappe.database import get_command
from frappe.utils import execute_in_shell
command = []
command = ["set -o pipefail;"]
if source.endswith(".gz"):
if gzip := which("gzip"):

View file

@ -417,8 +417,11 @@ def get_workspace_sidebar_items():
blocked_modules = frappe.get_doc("User", frappe.session.user).get_blocked_modules()
blocked_modules.append("Dummy Module")
# adding None to allowed_domains to include pages without domain restriction
allowed_domains = [None] + frappe.get_active_domains()
filters = {
"restrict_to_domain": ["in", frappe.get_active_domains()],
"restrict_to_domain": ["in", allowed_domains],
"module": ["not in", blocked_modules],
}

View file

@ -109,7 +109,7 @@ def get(
refresh=None,
):
if chart_name:
chart = frappe.get_doc("Dashboard Chart", chart_name)
chart: DashboardChart = frappe.get_doc("Dashboard Chart", chart_name)
else:
chart = frappe._dict(frappe.parse_json(chart))
@ -207,13 +207,14 @@ def get_chart_config(chart, filters, timespan, timegrain, from_date, to_date):
filters.append([doctype, datefield, ">=", from_date, False])
filters.append([doctype, datefield, "<=", to_date, False])
data = frappe.db.get_list(
data = frappe.get_list(
doctype,
fields=[datefield, f"SUM({value_field})", "COUNT(*)"],
filters=filters,
group_by=datefield,
order_by=datefield,
as_list=True,
parent_doctype=chart.parent_document_type,
)
result = get_result(data, timegrain, from_date, to_date, chart.chart_type)

View file

@ -57,4 +57,4 @@ def get_permission_query_conditions(user):
def has_permission(doc, user):
return doc.public or doc.owner == user
return bool(doc.public or doc.owner == user)

View file

@ -29,6 +29,7 @@ class NotificationLog(Document):
read: DF.Check
subject: DF.Text | None
type: DF.Literal["Mention", "Energy Point", "Assignment", "Share", "Alert"]
# end: auto-generated types
def after_insert(self):
frappe.publish_realtime("notification", after_commit=True, user=self.for_user)
@ -115,18 +116,17 @@ def _get_user_ids(user_emails):
return [user for user in user_names if is_notifications_enabled(user)]
def send_notification_email(doc):
def send_notification_email(doc: NotificationLog):
if doc.type == "Energy Point" and doc.email_content is None:
return
from frappe.utils import get_url_to_form, strip_html
email = frappe.db.get_value("User", doc.for_user, "email")
if not email:
user = frappe.db.get_value("User", doc.for_user, fieldname=["email", "language"], as_dict=True)
if not user:
return
header = get_email_header(doc)
header = get_email_header(doc, user.language)
email_subject = strip_html(doc.subject)
args = {
"body_content": doc.subject,
@ -140,7 +140,7 @@ def send_notification_email(doc):
args["doc_link"] = get_url_to_form(doc.document_type, doc.document_name)
frappe.sendmail(
recipients=email,
recipients=user.email,
subject=email_subject,
template="new_notification",
args=args,
@ -149,14 +149,14 @@ def send_notification_email(doc):
)
def get_email_header(doc):
def get_email_header(doc, language: str | None = None):
docname = doc.document_name
header_map = {
"Default": _("New Notification"),
"Mention": _("New Mention on {0}").format(docname),
"Assignment": _("Assignment Update on {0}").format(docname),
"Share": _("New Document Shared {0}").format(docname),
"Energy Point": _("Energy Point Update on {0}").format(docname),
"Default": _("New Notification", lang=language),
"Mention": _("New Mention on {0}", lang=language).format(docname),
"Assignment": _("Assignment Update on {0}", lang=language).format(docname),
"Share": _("New Document Shared {0}", lang=language).format(docname),
"Energy Point": _("Energy Point Update on {0}", lang=language).format(docname),
}
return header_map[doc.type or "Default"]

View file

@ -10,7 +10,7 @@ from frappe.model.naming import append_number_if_name_exists
from frappe.modules.export_file import export_to_files
from frappe.query_builder import Criterion
from frappe.query_builder.utils import DocType
from frappe.utils import cint
from frappe.utils import cint, flt
class NumberCard(Document):
@ -165,7 +165,7 @@ def get_result(doc, filters, to_date=None):
)
number = res[0]["result"] if res else 0
return cint(number)
return flt(number)
@frappe.whitelist()

View file

@ -109,7 +109,7 @@ class DocTags:
tags = ""
else:
tl = unique(filter(lambda x: x, tl))
tags = "," + ",".join(tl)
tags = ",".join(tl)
try:
frappe.db.sql(
"update `tab{}` set _user_tags={} where name={}".format(self.dt, "%s", "%s"), (tags, dn)

View file

@ -253,8 +253,10 @@ def notify_assignment(
if not (assigned_by and allocated_to and doc_type and doc_name):
return
assigned_user = frappe.db.get_value("User", allocated_to, ["language", "enabled"], as_dict=True)
# return if self assigned or user disabled
if assigned_by == allocated_to or not frappe.db.get_value("User", allocated_to, "enabled"):
if assigned_by == allocated_to or not assigned_user.enabled:
return
# Search for email address in description -- i.e. assignee
@ -263,14 +265,16 @@ def notify_assignment(
description_html = f"<div>{description}</div>" if description else None
if action == "CLOSE":
subject = _("Your assignment on {0} {1} has been removed by {2}").format(
frappe.bold(_(doc_type)), get_title_html(title), frappe.bold(user_name)
)
subject = _(
"Your assignment on {0} {1} has been removed by {2}", lang=assigned_user.language
).format(frappe.bold(_(doc_type)), get_title_html(title), frappe.bold(user_name))
else:
user_name = frappe.bold(user_name)
document_type = frappe.bold(_(doc_type))
document_type = frappe.bold(_(doc_type, lang=assigned_user.language))
title = get_title_html(title)
subject = _("{0} assigned a new task {1} {2} to you").format(user_name, document_type, title)
subject = _("{0} assigned a new task {1} {2} to you", lang=assigned_user.language).format(
user_name, document_type, title
)
notification_doc = {
"type": "Assignment",

View file

@ -7,7 +7,7 @@ import frappe
from frappe import _
from frappe.geo.country_info import get_country_info
from frappe.permissions import AUTOMATIC_ROLES
from frappe.translate import get_messages_for_boot, send_translations, set_default_language
from frappe.translate import send_translations, set_default_language
from frappe.utils import cint, now, strip
from frappe.utils.password import update_password
@ -304,6 +304,8 @@ def disable_future_access():
def load_messages(language):
"""Load translation messages for given language from all `setup_wizard_requires`
javascript files"""
from frappe.translate import get_messages_for_boot
frappe.clear_cache()
set_default_language(get_language_code(language))
frappe.db.commit()

View file

@ -268,15 +268,15 @@ class EmailAccount(Document):
if not in_receive and self.use_imap:
email_server.imap.logout()
# reset failed attempts count
self.set_failed_attempts_count(0)
return email_server
def check_email_server_connection(self, email_server, in_receive):
# tries to connect to email server and handles failure
try:
email_server.connect()
# reset failed attempts count - do it after succesful connection
self.set_failed_attempts_count(0)
except (error_proto, imaplib.IMAP4.error) as e:
message = cstr(e).lower().replace(" ", "")
auth_error_codes = [
@ -294,6 +294,8 @@ class EmailAccount(Document):
error_message = _(
"Authentication failed while receiving emails from Email Account: {0}."
).format(self.name)
error_message = _("Email Account Disabled.") + " " + error_message
error_message += "<br>" + _("Message from server: {0}").format(cstr(e))
self.handle_incoming_connect_error(description=error_message)
return None
@ -489,31 +491,35 @@ class EmailAccount(Document):
state.pop("_smtp_server_instance", None)
def handle_incoming_connect_error(self, description):
if self.get_failed_attempts_count() > 2:
self.db_set("enable_incoming", 0)
for user in get_system_managers(only_name=True):
try:
assign_to.add(
{
"assign_to": user,
"doctype": self.doctype,
"name": self.name,
"description": description,
"priority": "High",
"notify": 1,
}
)
except assign_to.DuplicateToDoError:
frappe.clear_last_message()
if self.get_failed_attempts_count() > 5:
# This is done in background to avoid committing here.
frappe.enqueue(self._disable_broken_incoming_account, description=description)
else:
self.set_failed_attempts_count(self.get_failed_attempts_count() + 1)
def _disable_broken_incoming_account(self, description):
self.db_set("enable_incoming", 0)
for user in get_system_managers(only_name=True):
try:
assign_to.add(
{
"assign_to": [user],
"doctype": self.doctype,
"name": self.name,
"description": description,
"priority": "High",
"notify": 1,
}
)
except assign_to.DuplicateToDoError:
pass
def set_failed_attempts_count(self, value):
frappe.cache.set(f"{self.name}:email-account-failed-attempts", value)
frappe.cache.set_value(f"{self.name}:email-account-failed-attempts", value)
def get_failed_attempts_count(self):
return cint(frappe.cache.get(f"{self.name}:email-account-failed-attempts"))
return cint(frappe.cache.get_value(f"{self.name}:email-account-failed-attempts"))
def receive(self):
"""Called by scheduler to receive emails from this EMail account using POP3/IMAP."""

View file

@ -6,7 +6,7 @@ import quopri
import traceback
from contextlib import suppress
from email.parser import Parser
from email.policy import SMTPUTF8, default
from email.policy import SMTP
import frappe
from frappe import _, safe_encode, task
@ -169,7 +169,9 @@ class EmailQueue(Document):
else:
if not frappe.flags.in_test or frappe.flags.testing_email:
ctx.smtp_server.session.sendmail(
from_addr=self.sender, to_addrs=recipient.recipient, msg=message
from_addr=self.sender,
to_addrs=recipient.recipient,
msg=message.decode("utf-8").encode(),
)
ctx.update_recipient_status_to_sent(recipient)
@ -264,7 +266,7 @@ class SendMailContext:
@savepoint(catch=Exception)
def notify_failed_email(self):
# Parse the email body to extract the subject
subject = Parser(policy=default).parsestr(self.queue_doc.message)["Subject"]
subject = Parser(policy=SMTP).parsestr(self.queue_doc.message)["Subject"]
# Construct the notification
notification = frappe.new_doc("Notification Log")
@ -281,7 +283,7 @@ class SendMailContext:
recipient.update_db(status="Sent", commit=True)
def get_message_object(self, message):
return Parser(policy=SMTPUTF8).parsestr(message)
return Parser(policy=SMTP).parsestr(message)
def message_placeholder(self, placeholder_key):
# sourcery skip: avoid-builtin-shadow
@ -293,9 +295,10 @@ class SendMailContext:
}
return map.get(placeholder_key)
def build_message(self, recipient_email):
def build_message(self, recipient_email) -> bytes:
"""Build message specific to the recipient."""
message = self.queue_doc.message
if not message:
return ""

View file

@ -366,7 +366,9 @@ def get_context(context):
# For sending messages to specified role
if recipient.receiver_by_role:
receiver_list += get_info_based_on_role(recipient.receiver_by_role, "mobile_no")
receiver_list += get_info_based_on_role(
recipient.receiver_by_role, "mobile_no", ignore_permissions=True
)
return receiver_list

View file

@ -1,6 +1,5 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
# License: MIT. See LICENSE
import email.utils
import os
import re
@ -136,8 +135,8 @@ class EMail:
self.subject = subject
self.expose_recipients = expose_recipients
self.msg_root = MIMEMultipart("mixed", policy=policy.SMTPUTF8)
self.msg_alternative = MIMEMultipart("alternative", policy=policy.SMTPUTF8)
self.msg_root = MIMEMultipart("mixed", policy=policy.SMTP)
self.msg_alternative = MIMEMultipart("alternative", policy=policy.SMTP)
self.msg_root.attach(self.msg_alternative)
self.cc = cc or []
self.bcc = bcc or []
@ -186,7 +185,7 @@ class EMail:
"""
from email.mime.text import MIMEText
part = MIMEText(message, "plain", "utf-8", policy=policy.SMTPUTF8)
part = MIMEText(message, "plain", "utf-8", policy=policy.SMTP)
self.msg_alternative.attach(part)
def set_part_html(self, message, inline_images):
@ -199,9 +198,9 @@ class EMail:
message, _inline_images = replace_filename_with_cid(message)
# prepare parts
msg_related = MIMEMultipart("related", policy=policy.SMTPUTF8)
msg_related = MIMEMultipart("related", policy=policy.SMTP)
html_part = MIMEText(message, "html", "utf-8", policy=policy.SMTPUTF8)
html_part = MIMEText(message, "html", "utf-8", policy=policy.SMTP)
msg_related.attach(html_part)
for image in _inline_images:
@ -215,7 +214,7 @@ class EMail:
self.msg_alternative.attach(msg_related)
else:
self.msg_alternative.attach(MIMEText(message, "html", "utf-8", policy=policy.SMTPUTF8))
self.msg_alternative.attach(MIMEText(message, "html", "utf-8", policy=policy.SMTP))
def set_html_as_text(self, html):
"""Set plain text from HTML"""
@ -228,7 +227,7 @@ class EMail:
from email.mime.text import MIMEText
maintype, subtype = mime_type.split("/")
part = MIMEText(message, _subtype=subtype, policy=policy.SMTPUTF8)
part = MIMEText(message, _subtype=subtype, policy=policy.SMTP)
if as_attachment:
part.add_header("Content-Disposition", "attachment", filename=filename)
@ -342,7 +341,7 @@ class EMail:
"""validate, build message and convert to string"""
self.validate()
self.make()
return self.msg_root.as_string(policy=policy.SMTPUTF8)
return self.msg_root.as_string(policy=policy.SMTP)
def get_formatted_html(

View file

@ -0,0 +1,2 @@
Extractors should run on source files only.
They should not depend on an acitive web server or database connection.

View file

@ -0,0 +1,60 @@
import json
def extract(fileobj, *args, **kwargs):
"""
Extract messages from DocType JSON files. To be used to babel extractor
:param fileobj: the file-like object the messages should be extracted from
:rtype: `iterator`
"""
data = json.load(fileobj)
if isinstance(data, list):
return
doctype = data.get("name")
yield None, "_", doctype, ["Name of a DocType"]
messages = []
fields = data.get("fields", [])
links = data.get("links", [])
for field in fields:
fieldtype = field.get("fieldtype")
if label := field.get("label"):
messages.append((label, f"Label of a {fieldtype} field in DocType '{doctype}'"))
if description := field.get("description"):
messages.append((description, f"Description of a {fieldtype} field in DocType '{doctype}'"))
if message := field.get("options"):
if fieldtype == "Select":
select_options = [option for option in message.split("\n") if option and not option.isdigit()]
if select_options and "icon" in select_options[0]:
continue
messages.extend(
(option, f"Option for a Select field in DocType '{doctype}'") for option in select_options
)
elif fieldtype == "HTML":
messages.append((message, f"Content of an HTML field in DocType '{doctype}'"))
for link in links:
if group := link.get("group"):
messages.append((group, f"Group in {doctype}'s connections"))
if link_doctype := link.get("link_doctype"):
messages.append((link_doctype, f"Linked DocType in {doctype}'s connections"))
# By using "pgettext" as the function name we can supply the doctype as context
yield from ((None, "pgettext", (doctype, message), [comment]) for message, comment in messages)
# Role names do not get context because they are used with multiple doctypes
yield from (
(None, "_", perm["role"], ["Name of a role"])
for perm in data.get("permissions", [])
if "role" in perm
)

View file

@ -0,0 +1,163 @@
from io import BufferedReader
def extract(fileobj: BufferedReader, keywords: str, comment_tags: tuple, options: dict):
code = fileobj.read().decode("utf-8")
for lineno, funcname, messages in extract_javascript(code, "__", options):
if not messages or not messages[0]:
continue
# `funcname` here will be `__` which is our translation function. We
# have to convert it back to usual function names
funcname = "gettext"
if isinstance(messages, tuple):
if len(messages) == 3 and messages[2]:
funcname = "pgettext"
messages = (messages[2], messages[0])
else:
messages = messages[0]
yield lineno, funcname, messages, []
def extract_javascript(code, keywords=("__",), options=None):
"""Extract messages from JavaScript source code.
This is a modified version of babel's JS parser. Reused under BSD license.
License: https://github.com/python-babel/babel/blob/master/LICENSE
Changes from upstream:
- Preserve arguments, babel's parser flattened all values in args,
we need order because we use different syntax for translation
which can contain 2nd arg which is array of many values. If
argument is non-primitive type then value is NOT returned in
args.
E.g. __("0", ["1", "2"], "3") -> ("0", None, "3")
- remove comments support
- changed signature to accept string directly.
:param code: code as string
:param keywords: a list of keywords (i.e. function names) that should be
recognized as translation functions
:param options: a dictionary of additional options (optional)
Supported options are:
* `template_string` -- set to false to disable ES6
template string support.
"""
from babel.messages.jslexer import Token, tokenize, unquote_string
if options is None:
options = {}
funcname = message_lineno = None
messages = []
last_argument = None
concatenate_next = False
last_token = None
call_stack = -1
# Tree level = depth inside function call tree
# Example: __("0", ["1", "2"], "3")
# Depth __()
# / | \
# 0 "0" [...] "3" <- only 0th level strings matter
# / \
# 1 "1" "2"
tree_level = 0
opening_operators = {"[", "{"}
closing_operators = {"]", "}"}
all_container_operators = opening_operators.union(closing_operators)
dotted = any("." in kw for kw in keywords)
for token in tokenize(
code,
jsx=True,
template_string=options.get("template_string", True),
dotted=dotted,
):
if ( # Turn keyword`foo` expressions into keyword("foo") calls:
funcname
and (last_token and last_token.type == "name") # have a keyword...
and token.type # we've seen nothing after the keyword...
== "template_string" # this is a template string
):
message_lineno = token.lineno
messages = [unquote_string(token.value)]
call_stack = 0
tree_level = 0
token = Token("operator", ")", token.lineno)
if token.type == "operator" and token.value == "(":
if funcname:
message_lineno = token.lineno
call_stack += 1
elif call_stack >= 0 and token.type == "operator" and token.value in all_container_operators:
if token.value in opening_operators:
tree_level += 1
if token.value in closing_operators:
tree_level -= 1
elif call_stack == -1 and token.type == "linecomment" or token.type == "multilinecomment":
pass # ignore comments
elif funcname and call_stack == 0:
if token.type == "operator" and token.value == ")":
if last_argument is not None:
messages.append(last_argument)
if len(messages) > 1:
messages = tuple(messages)
elif messages:
messages = messages[0]
else:
messages = None
if messages is not None:
yield (message_lineno, funcname, messages)
funcname = message_lineno = last_argument = None
concatenate_next = False
messages = []
call_stack = -1
tree_level = 0
elif token.type in ("string", "template_string"):
new_value = unquote_string(token.value)
if tree_level > 0:
pass
elif concatenate_next:
last_argument = (last_argument or "") + new_value
concatenate_next = False
else:
last_argument = new_value
elif token.type == "operator":
if token.value == ",":
if last_argument is not None:
messages.append(last_argument)
last_argument = None
else:
if tree_level == 0:
messages.append(None)
concatenate_next = False
elif token.value == "+":
concatenate_next = True
elif call_stack > 0 and token.type == "operator" and token.value == ")":
call_stack -= 1
tree_level = 0
elif funcname and call_stack == -1:
funcname = None
elif (
call_stack == -1
and token.type == "name"
and token.value in keywords
and (last_token is None or last_token.type != "name" or last_token.value != "function")
):
funcname = token.value
last_token = token

View file

@ -0,0 +1,11 @@
from jinja2.ext import babel_extract
def extract(*args, **kwargs):
"""Reuse the babel_extract function from jinja2.ext, but handle our own implementation of `_()`"""
for lineno, funcname, messages, comments in babel_extract(*args, **kwargs):
if funcname == "_" and isinstance(messages, tuple) and len(messages) > 1:
funcname = "pgettext"
messages = (messages[-1], messages[0]) # (context, message)
yield lineno, funcname, messages, comments

View file

@ -0,0 +1,30 @@
import json
def extract(fileobj, *args, **kwargs):
"""
Extract messages from Module Onboarding JSON files.
:param fileobj: the file-like object the messages should be extracted from
:rtype: `iterator`
"""
data = json.load(fileobj)
if isinstance(data, list):
return
if data.get("doctype") != "Module Onboarding":
return
onboarding_name = data.get("name")
if title := data.get("title"):
yield None, "_", title, [f"Title of the Module Onboarding '{onboarding_name}'"]
if subtitle := data.get("subtitle"):
yield None, "_", subtitle, [f"Subtitle of the Module Onboarding '{onboarding_name}'"]
if success_message := data.get("success_message"):
yield None, "_", success_message, [
f"Success message of the Module Onboarding '{onboarding_name}'"
]

View file

@ -0,0 +1,41 @@
import importlib
from pathlib import Path
from frappe.utils import get_bench_path
def extract(fileobj, *args, **kwargs):
"""Extract standard navbar and help items from a python file.
:param fileobj: file-like object to extract messages from. Should be a
python file containing two global variables `standard_navbar_items` and
`standard_help_items` which are lists of dicts.
"""
module = get_module(fileobj.name)
if hasattr(module, "standard_navbar_items"):
standard_navbar_items = getattr(module, "standard_navbar_items")
for nav_item in standard_navbar_items:
if label := nav_item.get("item_label"):
item_type = nav_item.get("item_type")
yield None, "_", label, [
"Label of a standard navbar item",
f"Type: {item_type}",
]
if hasattr(module, "standard_help_items"):
standard_help_items = getattr(module, "standard_help_items")
for help_item in standard_help_items:
if label := help_item.get("item_label"):
item_type = nav_item.get("item_type")
yield None, "_", label, [
"Label of a standard help item",
f"Type: {item_type}",
]
def get_module(path):
_path = Path(path)
rel_path = _path.relative_to(get_bench_path())
import_path = ".".join(rel_path.parts[2:]).rstrip(".py")
return importlib.import_module(import_path)

View file

@ -0,0 +1,32 @@
import json
def extract(fileobj, *args, **kwargs):
"""
Extract messages from Onboarding Step JSON files.
:param fileobj: the file-like object the messages should be extracted from
:rtype: `iterator`
"""
data = json.load(fileobj)
if isinstance(data, list):
return
if data.get("doctype") != "Onboarding Step":
return
step_title = data.get("title")
yield None, "_", step_title, ["Title of an Onboarding Step"]
if action_label := data.get("action_label"):
yield None, "_", action_label, [f"Label of an action in the Onboarding Step '{step_title}'"]
if description := data.get("description"):
yield None, "_", description, [f"Description of the Onboarding Step '{step_title}'"]
if report_description := data.get("report_description"):
yield None, "_", report_description, [
f"Description of a report in the Onboarding Step '{step_title}'"
]

View file

@ -0,0 +1,13 @@
from babel.messages.extract import extract_python
def extract(*args, **kwargs):
"""
Wrapper around babel's `extract_python`, handling our own implementation of `_()`
"""
for lineno, funcname, messages, comments in extract_python(*args, **kwargs):
if funcname == "_" and isinstance(messages, tuple) and len(messages) > 1:
funcname = "pgettext"
messages = (messages[-1], messages[0]) # (context, message)
yield lineno, funcname, messages, comments

View file

@ -0,0 +1,18 @@
import json
def extract(fileobj, *args, **kwargs):
"""
Extract messages from report JSON files. To be used to babel extractor
:param fileobj: the file-like object the messages should be extracted from
:rtype: `iterator`
"""
data = json.load(fileobj)
if isinstance(data, list):
return
if data.get("doctype") != "Report":
return
yield None, "_", data.get("report_name"), ["Name of a report"]

View file

@ -0,0 +1,42 @@
import json
def extract(fileobj, *args, **kwargs):
"""
Extract messages from DocType JSON files. To be used to babel extractor
:param fileobj: the file-like object the messages should be extracted from
:rtype: `iterator`
"""
data = json.load(fileobj)
if isinstance(data, list):
return
if data.get("doctype") != "Workspace":
return
workspace_name = data.get("label")
yield None, "_", workspace_name, ["Name of a Workspace"]
yield from (
(None, "_", chart.get("label"), [f"Label of a chart in the {workspace_name} Workspace"])
for chart in data.get("charts", [])
)
yield from (
(
None,
"pgettext",
(link.get("link_to") if link.get("link_type") == "DocType" else None, link.get("label")),
[f"Label of a {link.get('type')} in the {workspace_name} Workspace"],
)
for link in data.get("links", [])
)
yield from (
(
None,
"pgettext",
(shortcut.get("link_to") if shortcut.get("type") == "DocType" else None, shortcut.get("label")),
[f"Label of a shortcut in the {workspace_name} Workspace"],
)
for shortcut in data.get("shortcuts", [])
)

View file

@ -0,0 +1,64 @@
from frappe.gettext.translate import (
generate_pot,
get_method_map,
get_mo_path,
get_po_path,
get_pot_path,
new_catalog,
new_po,
write_binary,
write_catalog,
)
from frappe.tests.utils import FrappeTestCase
class TestTranslate(FrappeTestCase):
def setUp(self):
pass
def tearDown(self):
pass
def test_generate_pot(self):
pot_path = get_pot_path("frappe")
pot_path.unlink(missing_ok=True)
generate_pot("frappe")
self.assertTrue(pot_path.exists())
self.assertIn("msgid", pot_path.read_text())
def test_write_catalog(self):
po_path = get_po_path("frappe", "test")
po_path.unlink(missing_ok=True)
catalog = new_catalog("frappe", "test")
write_catalog("frappe", catalog, "test")
self.assertTrue(po_path.exists())
self.assertIn("msgid", po_path.read_text())
def test_write_binary(self):
mo_path = get_mo_path("frappe", "test")
mo_path.unlink(missing_ok=True)
catalog = new_catalog("frappe", "test")
write_binary("frappe", catalog, "test")
self.assertTrue(mo_path.exists())
def test_get_method_map(self):
method_map = get_method_map("frappe")
self.assertTrue(len(method_map) > 0)
self.assertTrue(len(method_map[0]) == 2)
self.assertTrue(isinstance(method_map[0][0], str))
self.assertTrue(isinstance(method_map[0][1], str))
def test_new_po(self):
po_path = get_po_path("frappe", "test")
po_path.unlink(missing_ok=True)
new_po("test", target_app="frappe")
self.assertTrue(po_path.exists())
self.assertIn("msgid", po_path.read_text())

311
frappe/gettext/translate.py Normal file
View file

@ -0,0 +1,311 @@
import csv
import gettext
import multiprocessing
import os
from collections import defaultdict
from datetime import datetime
from pathlib import Path
from babel.messages.catalog import Catalog
from babel.messages.extract import DEFAULT_KEYWORDS, extract_from_dir
from babel.messages.mofile import read_mo, write_mo
from babel.messages.pofile import read_po, write_po
import frappe
from frappe.utils import get_bench_path
PO_DIR = "locale" # po and pot files go into [app]/locale
POT_FILE = "main.pot" # the app's pot file is always main.pot
def new_catalog(app: str, locale: str | None = None) -> Catalog:
def get_hook(hook, app):
return frappe.get_hooks(hook, [None], app)[0]
app_email = get_hook("app_email", app)
return Catalog(
locale=locale,
domain="messages",
msgid_bugs_address=app_email,
language_team=app_email,
copyright_holder=get_hook("app_publisher", app),
last_translator=app_email,
project=get_hook("app_title", app),
creation_date=datetime.now(),
revision_date=datetime.now(),
fuzzy=False,
)
def get_po_dir(app: str) -> Path:
return Path(frappe.get_app_path(app)) / PO_DIR
def get_locale_dir() -> Path:
return Path(get_bench_path()) / "sites" / "assets" / "locale"
def get_locales(app: str) -> list[str]:
po_dir = get_po_dir(app)
if not po_dir.exists():
return []
return [locale.stem for locale in po_dir.iterdir() if locale.suffix == ".po"]
def get_po_path(app: str, locale: str | None = None) -> Path:
return get_po_dir(app) / f"{locale}.po"
def get_mo_path(app: str, locale: str | None = None) -> Path:
return get_locale_dir() / locale / "LC_MESSAGES" / f"{app}.mo"
def get_pot_path(app: str) -> Path:
return get_po_dir(app) / POT_FILE
def get_catalog(app: str, locale: str | None = None) -> Catalog:
"""Returns a catatalog for the given app and locale"""
po_path = get_po_path(app, locale) if locale else get_pot_path(app)
if not po_path.exists():
return new_catalog(app, locale)
with open(po_path, "rb") as f:
return read_po(f)
def write_catalog(app: str, catalog: Catalog, locale: str | None = None) -> Path:
"""Writes a catalog to the given app and locale"""
po_path = get_po_path(app, locale) if locale else get_pot_path(app)
if not po_path.parent.exists():
po_path.parent.mkdir(parents=True)
with open(po_path, "wb") as f:
write_po(f, catalog, sort_output=True, ignore_obsolete=True)
return po_path
def write_binary(app: str, catalog: Catalog, locale: str) -> Path:
mo_path = get_mo_path(app, locale)
if not mo_path.parent.exists():
mo_path.parent.mkdir(parents=True)
with open(mo_path, "wb") as mo_file:
write_mo(mo_file, catalog)
return mo_path
def get_method_map(app: str):
file_path = Path(frappe.get_app_path(app)).parent / "babel_extractors.csv"
if file_path.exists():
with open(file_path) as f:
reader = csv.reader(f)
return [(row[0], row[1]) for row in reader]
return []
def generate_pot(target_app: str | None = None):
"""
Generate a POT (PO template) file. This file will contain only messages IDs.
https://en.wikipedia.org/wiki/Gettext
:param target_app: If specified, limit to `app`
"""
def directory_filter(dirpath: str | os.PathLike[str]) -> bool:
if "public/dist" in dirpath:
return False
subdir = os.path.basename(dirpath)
return not (subdir.startswith(".") or subdir.startswith("_"))
apps = [target_app] if target_app else frappe.get_all_apps(True)
default_method_map = get_method_map("frappe")
keywords = DEFAULT_KEYWORDS.copy()
keywords["_lt"] = None
for app in apps:
app_path = frappe.get_pymodule_path(app)
catalog = get_catalog(app)
# Each file will only be processed by the first method that matches,
# so more specific methods should come first.
method_map = [] if app == "frappe" else get_method_map(app)
method_map.extend(default_method_map)
for filename, lineno, message, comments, context in extract_from_dir(
app_path, method_map, directory_filter=directory_filter, keywords=keywords
):
if not message:
continue
catalog.add(message, locations=[(filename, lineno)], auto_comments=comments, context=context)
pot_path = write_catalog(app, catalog)
print(f"POT file created at {pot_path}")
def new_po(locale, target_app: str | None = None):
apps = [target_app] if target_app else frappe.get_all_apps(True)
for app in apps:
po_path = get_po_path(app, locale)
if os.path.exists(po_path):
print(f"{po_path} exists. Skipping")
continue
pot_catalog = get_catalog(app)
pot_catalog.locale = locale
po_path = write_catalog(app, pot_catalog, locale)
print(f"PO file created_at {po_path}")
print(
"You will need to add the language in frappe/geo/languages.json, if you haven't done it already."
)
def compile_translations(target_app: str | None = None, locale: str | None = None, force=False):
apps = [target_app] if target_app else frappe.get_all_apps(True)
tasks = []
for app in apps:
locales = [locale] if locale else get_locales(app)
for current_locale in locales:
tasks.append((app, current_locale, force))
# Execute all tasks, doing this sequentially is quite slow hence use processpool of 4
# processes.
executer = multiprocessing.Pool(processes=4)
executer.starmap(_compile_translation, tasks)
executer.close()
executer.join()
def _compile_translation(app, locale, force=False):
po_path = get_po_path(app, locale)
mo_path = get_mo_path(app, locale)
if not po_path.exists():
return
if mo_path.exists() and po_path.stat().st_mtime < mo_path.stat().st_mtime and not force:
print(f"MO file already up to date at {mo_path}")
return
with open(po_path, "rb") as f:
catalog = read_po(f)
mo_path = write_binary(app, catalog, locale)
print(f"MO file created at {mo_path}")
def update_po(target_app: str | None = None, locale: str | None = None):
"""
Add keys to available PO files, from POT file. This could be used to keep
track of available keys, and missing translations
:param target_app: Limit operation to `app`, if specified
"""
apps = [target_app] if target_app else frappe.get_all_apps(True)
for app in apps:
locales = [locale] if locale else get_locales(app)
pot_catalog = get_catalog(app)
for locale in locales:
po_catalog = get_catalog(app, locale)
po_catalog.update(pot_catalog)
po_path = write_catalog(app, po_catalog, locale)
print(f"PO file modified at {po_path}")
def migrate(app: str | None = None, locale: str | None = None):
apps = [app] if app else frappe.get_all_apps(True)
for app in apps:
if locale:
csv_to_po(app, locale)
else:
app_path = Path(frappe.get_app_path(app))
for filename in (app_path / "translations").iterdir():
if filename.suffix != ".csv":
continue
csv_to_po(app, filename.stem)
def csv_to_po(app: str, locale: str):
csv_file = Path(frappe.get_app_path(app)) / "translations" / f"{locale.replace('_', '-')}.csv"
locale = locale.replace("-", "_")
if not csv_file.exists():
return
catalog: Catalog = get_catalog(app)
msgid_context_map = defaultdict(list)
for message in catalog:
msgid_context_map[message.id].append(message.context)
with open(csv_file) as f:
for row in csv.reader(f):
if len(row) < 2:
continue
msgid = escape_percent(row[0])
msgstr = escape_percent(row[1])
msgctxt = row[2] if len(row) >= 3 else None
if not msgctxt:
# if old context is not defined, add msgstr to all contexts
for context in msgid_context_map.get(msgid, []):
if message := catalog.get(msgid, context):
message.string = msgstr
elif message := catalog.get(msgid, msgctxt):
message.string = msgstr
po_path = write_catalog(app, catalog, locale)
print(f"PO file created at {po_path}")
def get_translations_from_mo(lang, app):
"""Get translations from MO files.
For dialects (i.e. es_GT), take translations from the base language (i.e. es)
and then update with specific translations from the dialect (i.e. es_GT).
If we only have a translation with context, also use it as a translation
without context. This way we can provide the context for each source string
but don't have to create a translation for each context.
"""
if not lang or not app:
return {}
translations = {}
lang = lang.replace("-", "_") # Frappe uses dash, babel uses underscore.
locale_dir = get_locale_dir()
mo_file = gettext.find(app, locale_dir, (lang,))
if not mo_file:
return translations
with open(mo_file, "rb") as f:
catalog = read_mo(f)
for m in catalog:
if not m.id:
continue
key = m.id
if m.context:
context = m.context.decode() # context is encoded as bytes
translations[f"{key}:{context}"] = m.string
if m.id not in translations:
# better a translation with context than no translation
translations[m.id] = m.string
else:
translations[m.id] = m.string
return translations
def escape_percent(s: str):
return s.replace("%", "&#37;")

View file

@ -428,6 +428,7 @@ before_request = [
# Background Job Hooks
before_job = [
"frappe.recorder.record",
"frappe.monitor.start",
]
@ -438,6 +439,7 @@ if os.getenv("FRAPPE_SENTRY_DSN") and (
before_job.append("frappe.utils.sentry.set_sentry_context")
after_job = [
"frappe.recorder.dump",
"frappe.monitor.stop",
"frappe.utils.file_lock.release_document_locks",
"frappe.utils.telemetry.flush",
@ -451,6 +453,83 @@ extend_bootinfo = [
export_python_type_annotations = True
standard_navbar_items = [
{
"item_label": "My Profile",
"item_type": "Route",
"route": "/app/user-profile",
"is_standard": 1,
},
{
"item_label": "My Settings",
"item_type": "Action",
"action": "frappe.ui.toolbar.route_to_user()",
"is_standard": 1,
},
{
"item_label": "Session Defaults",
"item_type": "Action",
"action": "frappe.ui.toolbar.setup_session_defaults()",
"is_standard": 1,
},
{
"item_label": "Reload",
"item_type": "Action",
"action": "frappe.ui.toolbar.clear_cache()",
"is_standard": 1,
},
{
"item_label": "View Website",
"item_type": "Action",
"action": "frappe.ui.toolbar.view_website()",
"is_standard": 1,
},
{
"item_label": "Toggle Full Width",
"item_type": "Action",
"action": "frappe.ui.toolbar.toggle_full_width()",
"is_standard": 1,
},
{
"item_label": "Toggle Theme",
"item_type": "Action",
"action": "new frappe.ui.ThemeSwitcher().show()",
"is_standard": 1,
},
{
"item_type": "Separator",
"is_standard": 1,
"item_label": "",
},
{
"item_label": "Log out",
"item_type": "Action",
"action": "frappe.app.logout()",
"is_standard": 1,
},
]
standard_help_items = [
{
"item_label": "About",
"item_type": "Action",
"action": "frappe.ui.toolbar.show_about()",
"is_standard": 1,
},
{
"item_label": "Keyboard Shortcuts",
"item_type": "Action",
"action": "frappe.ui.toolbar.show_shortcuts(event)",
"is_standard": 1,
},
{
"item_label": "Frappe Support",
"item_type": "Route",
"route": "https://frappe.io/support",
"is_standard": 1,
},
]
# log doctype cleanups to automatically add in log settings
default_log_clearing_doctypes = {
"Error Log": 30,

View file

@ -757,8 +757,8 @@ def is_downgrade(sql_file_path, verbose=False):
if backup_version is None:
# This is likely an older backup, so try to extract another way
header = get_db_dump_header(sql_file_path).split("\n")
if "Version" in header[0]:
backup_version = header[0].split(":")[-1].strip()
if match := re.search(r"Frappe (\d+\.\d+\.\d+)", header[0]):
backup_version = match.group(1)
# Assume it's not a downgrade if we can't determine backup version
if backup_version is None:

View file

@ -153,15 +153,14 @@ def get_context(doc):
def enqueue_webhook(doc, webhook) -> None:
request_url = headers = data = None
try:
webhook: Webhook = frappe.get_doc("Webhook", webhook.get("name"))
headers = get_webhook_headers(doc, webhook)
data = get_webhook_data(doc, webhook)
request_url = webhook.request_url
if webhook.is_dynamic_url:
request_url = frappe.render_template(webhook.request_url, get_context(doc))
else:
request_url = webhook.request_url
headers = get_webhook_headers(doc, webhook)
data = get_webhook_data(doc, webhook)
except Exception as e:
frappe.logger().debug({"enqueue_webhook_error": e})

38588
frappe/locale/af.po Normal file

File diff suppressed because it is too large Load diff

38437
frappe/locale/ar.po Normal file

File diff suppressed because it is too large Load diff

38728
frappe/locale/de.po Normal file

File diff suppressed because it is too large Load diff

38706
frappe/locale/es.po Normal file

File diff suppressed because it is too large Load diff

38525
frappe/locale/fi.po Normal file

File diff suppressed because it is too large Load diff

38767
frappe/locale/fr.po Normal file

File diff suppressed because it is too large Load diff

38569
frappe/locale/id.po Normal file

File diff suppressed because it is too large Load diff

38671
frappe/locale/it.po Normal file

File diff suppressed because it is too large Load diff

38194
frappe/locale/main.pot Normal file

File diff suppressed because it is too large Load diff

38615
frappe/locale/nl.po Normal file

File diff suppressed because it is too large Load diff

38598
frappe/locale/pl.po Normal file

File diff suppressed because it is too large Load diff

38604
frappe/locale/pt.po Normal file

File diff suppressed because it is too large Load diff

38601
frappe/locale/pt_BR.po Normal file

File diff suppressed because it is too large Load diff

38639
frappe/locale/ru.po Normal file

File diff suppressed because it is too large Load diff

38510
frappe/locale/tr.po Normal file

File diff suppressed because it is too large Load diff

38527
frappe/locale/vi.po Normal file

File diff suppressed because it is too large Load diff

38207
frappe/locale/zh.po Normal file

File diff suppressed because it is too large Load diff

38196
frappe/locale/zh_TW.po Normal file

File diff suppressed because it is too large Load diff

View file

@ -3,6 +3,7 @@
# model __init__.py
import frappe
from frappe import _, _lt
data_fieldtypes = (
"Currency",
@ -132,6 +133,25 @@ log_types = (
"Console Log",
)
std_fields = [
{"fieldname": "name", "fieldtype": "Link", "label": _lt("ID")},
{"fieldname": "owner", "fieldtype": "Link", "label": _lt("Created By"), "options": "User"},
{"fieldname": "idx", "fieldtype": "Int", "label": _lt("Index")},
{"fieldname": "creation", "fieldtype": "Datetime", "label": _lt("Created On")},
{"fieldname": "modified", "fieldtype": "Datetime", "label": _lt("Last Updated On")},
{
"fieldname": "modified_by",
"fieldtype": "Link",
"label": _lt("Last Updated By"),
"options": "User",
},
{"fieldname": "_user_tags", "fieldtype": "Data", "label": _lt("Tags")},
{"fieldname": "_liked_by", "fieldtype": "Data", "label": _lt("Liked By")},
{"fieldname": "_comments", "fieldtype": "Text", "label": _lt("Comments")},
{"fieldname": "_assign", "fieldtype": "Text", "label": _lt("Assigned To")},
{"fieldname": "docstatus", "fieldtype": "Int", "label": _lt("Document Status")},
]
def delete_fields(args_dict, delete=0):
"""

View file

@ -521,14 +521,13 @@ class DatabaseQuery:
def _set_permission_map(self, doctype: str, parent_doctype: str | None = None):
ptype = "select" if frappe.only_has_select_perm(doctype) else "read"
val = frappe.has_permission(
frappe.has_permission(
doctype,
ptype=ptype,
parent_doctype=parent_doctype or self.doctype,
throw=True,
user=self.user,
)
if not val:
frappe.flags.error_message = _("Insufficient Permission for {0}").format(frappe.bold(doctype))
raise frappe.PermissionError(doctype)
self.permission_map[doctype] = ptype
def set_field_tables(self):

View file

@ -214,7 +214,7 @@ class Document(BaseDocument):
if not self.has_permission(permtype):
self.raise_no_permission_to(permtype)
def has_permission(self, permtype="read") -> bool:
def has_permission(self, permtype="read", *, debug=False, user=None) -> bool:
"""
Call `frappe.permissions.has_permission` if `ignore_permissions` flag isn't truthy
@ -226,7 +226,7 @@ class Document(BaseDocument):
import frappe.permissions
return frappe.permissions.has_permission(self.doctype, permtype, self)
return frappe.permissions.has_permission(self.doctype, permtype, self, debug=debug, user=user)
def raise_no_permission_to(self, perm_type):
"""Raise `frappe.PermissionError`."""
@ -420,36 +420,35 @@ class Document(BaseDocument):
def update_child_table(self, fieldname: str, df: Optional["DocField"] = None):
"""sync child table for given fieldname"""
rows = []
df: "DocField" = df or self.meta.get_field(fieldname)
for d in self.get(df.fieldname):
d: Document
d.db_update()
rows.append(d.name)
if (
df.options in (self.flags.ignore_children_type or [])
or frappe.get_meta(df.options).is_virtual == 1
):
# do not delete rows for this because of flags
# hack for docperm :(
return
all_rows = self.get(df.fieldname)
# delete rows that do not match the ones in the document
tbl = frappe.qb.DocType(df.options)
qry = (
frappe.qb.from_(tbl)
.where(tbl.parent == self.name)
.where(tbl.parenttype == self.doctype)
.where(tbl.parentfield == fieldname)
.delete()
)
# if the doctype isn't in ignore_children_type flag and isn't virtual
if not (
df.options in (self.flags.ignore_children_type or ())
or frappe.get_meta(df.options).is_virtual == 1
):
existing_row_names = [row.name for row in all_rows if row.name and not row.is_new()]
if rows:
qry = qry.where(tbl.name.notin(rows))
tbl = frappe.qb.DocType(df.options)
qry = (
frappe.qb.from_(tbl)
.where(tbl.parent == self.name)
.where(tbl.parenttype == self.doctype)
.where(tbl.parentfield == fieldname)
.delete()
)
qry.run()
if existing_row_names:
qry = qry.where(tbl.name.notin(existing_row_names))
qry.run()
# update / insert
for d in all_rows:
d: Document
d.db_update()
def get_doc_before_save(self) -> "Document":
return getattr(self, "_doc_before_save", None)

View file

@ -16,12 +16,13 @@ Example:
"""
import json
import os
import typing
from datetime import datetime
import click
import frappe
from frappe import _
from frappe import _, _lt
from frappe.model import (
child_table_fields,
data_fieldtypes,
@ -41,21 +42,21 @@ from frappe.modules import load_doctype_module
from frappe.utils import cast, cint, cstr
DEFAULT_FIELD_LABELS = {
"name": lambda: _("ID"),
"creation": lambda: _("Created On"),
"docstatus": lambda: _("Document Status"),
"idx": lambda: _("Index"),
"modified": lambda: _("Last Updated On"),
"modified_by": lambda: _("Last Updated By"),
"owner": lambda: _("Created By"),
"_user_tags": lambda: _("Tags"),
"_liked_by": lambda: _("Liked By"),
"_comments": lambda: _("Comments"),
"_assign": lambda: _("Assigned To"),
"name": _lt("ID"),
"creation": _lt("Created On"),
"docstatus": _lt("Document Status"),
"idx": _lt("Index"),
"modified": _lt("Last Updated On"),
"modified_by": _lt("Last Updated By"),
"owner": _lt("Created By"),
"_user_tags": _lt("Tags"),
"_liked_by": _lt("Liked By"),
"_comments": _lt("Comments"),
"_assign": _lt("Assigned To"),
}
def get_meta(doctype, cached=True) -> "Meta":
def get_meta(doctype, cached=True) -> "_Meta":
cached = cached and isinstance(doctype, str)
if cached and (meta := frappe.cache.hget("doctype_meta", doctype)):
return meta
@ -248,7 +249,7 @@ class Meta(Document):
return df.get("label")
if fieldname in DEFAULT_FIELD_LABELS:
return DEFAULT_FIELD_LABELS[fieldname]()
return str(DEFAULT_FIELD_LABELS[fieldname])
return "No Label"
@ -895,3 +896,12 @@ def _update_field_order_based_on_insert_after(field_order, insert_after_map):
# insert_after is an invalid fieldname, add these fields to the end
for fields in insert_after_map.values():
field_order.extend(fields)
if typing.TYPE_CHECKING:
# This is DX hack to add all fields from DocType to meta for autocompletions.
# Meta is technically doctype + special fields on meta.
from frappe.core.doctype.doctype.doctype import DocType
class _Meta(Meta, DocType):
pass

View file

@ -4,6 +4,7 @@ from types import NoneType
from typing import TYPE_CHECKING
import frappe
import frappe.permissions
from frappe import _, bold
from frappe.model.document import Document
from frappe.model.dynamic_links import get_dynamic_link_map
@ -379,7 +380,7 @@ def validate_rename(
frappe.throw(_("Another {0} with name {1} exists, select another name").format(doctype, new))
if not (
ignore_permissions or frappe.permissions.has_permission(doctype, "write", raise_exception=False)
ignore_permissions or frappe.permissions.has_permission(doctype, "write", print_logs=False)
):
frappe.throw(_("You need write permission to rename"))

View file

@ -150,12 +150,13 @@ def apply_workflow(doc, action):
@frappe.whitelist()
def can_cancel_document(doctype):
workflow = get_workflow(doctype)
for state_doc in workflow.states:
if state_doc.doc_status == "2":
for transition in workflow.transitions:
if transition.next_state == state_doc.state:
return False
return True
cancelling_states = [s.state for s in workflow.states if s.doc_status == "2"]
if not cancelling_states:
return True
for transition in workflow.transitions:
if transition.next_state in cancelling_states:
return False
return True

View file

@ -1,6 +1,7 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
# License: MIT. See LICENSE
import copy
import functools
import frappe
import frappe.share
@ -37,32 +38,52 @@ AUTOMATIC_ROLES = (GUEST_ROLE, ALL_USER_ROLE, SYSTEM_USER_ROLE, ADMIN_ROLE)
def print_has_permission_check_logs(func):
@functools.wraps(func)
def inner(*args, **kwargs):
frappe.flags["has_permission_check_logs"] = []
result = func(*args, **kwargs)
print_logs = kwargs.get("print_logs", True)
self_perm_check = True if not kwargs.get("user") else kwargs.get("user") == frappe.session.user
raise_exception = kwargs.get("raise_exception", True)
if print_logs:
frappe.flags["has_permission_check_logs"] = []
result = func(*args, **kwargs)
# print only if access denied
# and if user is checking his own permission
if not result and self_perm_check and raise_exception:
if not result and self_perm_check and print_logs:
msgprint(("<br>").join(frappe.flags.get("has_permission_check_logs", [])))
frappe.flags.pop("has_permission_check_logs", None)
if print_logs:
frappe.flags.pop("has_permission_check_logs", None)
return result
return inner
def _debug_log(log: str):
if not hasattr(frappe.local, "permission_debug_log"):
frappe.local.permission_debug_log = []
frappe.local.permission_debug_log.append(log)
def _pop_debug_log() -> list[str]:
if log := getattr(frappe.local, "permission_debug_log", None):
del frappe.local.permission_debug_log
return log
return []
@print_has_permission_check_logs
def has_permission(
doctype,
ptype="read",
doc=None,
user=None,
raise_exception=True,
*,
parent_doctype=None,
):
print_logs=True,
debug=False,
) -> bool:
"""Return True if user has permission `ptype` for given `doctype`.
If `doc` is passed, also check user, share and owner permissions.
@ -70,11 +91,8 @@ def has_permission(
:param ptype: Permission Type to check
:param doc: Check User Permissions for specified document.
:param user: User to check permission for. Defaults to current user.
:param raise_exception:
DOES NOT raise an exception.
If not False, will display a message using frappe.msgprint
:param print_logs: If True, will display a message using frappe.msgprint
which explains why the permission check failed.
:param parent_doctype:
Required when checking permission for a child DocType (unless doc is specified)
"""
@ -83,9 +101,13 @@ def has_permission(
user = frappe.session.user
if user == "Administrator":
debug and _debug_log("Allowed everything because user is Administrator")
return True
if ptype == "share" and frappe.get_system_settings("disable_document_sharing"):
debug and _debug_log(
"User can't share because sharing is disabled globally from system settings"
)
return False
if not doc and hasattr(doctype, "doctype"):
@ -94,71 +116,90 @@ def has_permission(
doctype = doc.doctype
if frappe.is_table(doctype):
return has_child_permission(doctype, ptype, doc, user, raise_exception, parent_doctype)
return has_child_permission(
doctype,
ptype,
doc,
user,
parent_doctype,
debug=debug,
print_logs=print_logs,
)
meta = frappe.get_meta(doctype)
if doc:
if isinstance(doc, (str, int)):
doc = frappe.get_doc(meta.name, doc)
perm = get_doc_permissions(doc, user=user, ptype=ptype).get(ptype)
perm = get_doc_permissions(doc, user=user, ptype=ptype, debug=debug).get(ptype)
if not perm:
debug and _debug_log(
"Permission check failed from role permission system. Check if user's role grant them permission to the document."
)
msg = _("User {0} does not have access to this document").format(frappe.bold(user))
if frappe.has_permission(doc.doctype):
msg += f": {_(doc.doctype)} - {doc.name}"
push_perm_check_log(msg)
push_perm_check_log(msg, debug=debug)
else:
if ptype == "submit" and not cint(meta.is_submittable):
push_perm_check_log(_("Document Type is not submittable"))
push_perm_check_log(_("Document Type is not submittable"), debug=debug)
return False
if ptype == "import" and not cint(meta.allow_import):
push_perm_check_log(_("Document Type is not importable"))
push_perm_check_log(_("Document Type is not importable"), debug=debug)
return False
role_permissions = get_role_permissions(meta, user=user)
role_permissions = get_role_permissions(meta, user=user, debug=debug)
debug and _debug_log(
"User has following permissions using role permission system: "
+ frappe.as_json(role_permissions, indent=8)
)
perm = role_permissions.get(ptype)
if not perm:
push_perm_check_log(
_("User {0} does not have doctype access via role permission for document {1}").format(
frappe.bold(user), frappe.bold(doctype)
)
),
debug=debug,
)
def false_if_not_shared():
if ptype in ("read", "write", "share", "submit", "email", "print"):
if ptype not in ("read", "write", "share", "submit", "email", "print"):
debug and _debug_log(f"Permission type {ptype} can not be shared")
return False
rights = ["read" if ptype in ("email", "print") else ptype]
rights = ["read" if ptype in ("email", "print") else ptype]
if doc:
doc_name = get_doc_name(doc)
shared = frappe.share.get_shared(
doctype,
user,
rights=rights,
filters=[["share_name", "=", doc_name]],
limit=1,
)
if doc:
doc_name = get_doc_name(doc)
shared = frappe.share.get_shared(
doctype,
user,
rights=rights,
filters=[["share_name", "=", doc_name]],
limit=1,
)
debug and _debug_log(f"Document is shared with user for {ptype}? {bool(shared)}")
return bool(shared)
if shared:
if ptype in ("read", "write", "share", "submit") or meta.permissions[0].get(ptype):
return True
elif frappe.share.get_shared(doctype, user, rights=rights, limit=1):
# if atleast one shared doc of that type, then return True
# this is used in db_query to check if permission on DocType
return True
elif frappe.share.get_shared(doctype, user, rights=rights, limit=1):
# if atleast one shared doc of that type, then return True
# this is used in db_query to check if permission on DocType
debug and _debug_log(f"At least one document is shared with user with perm: {rights}")
return True
return False
if not perm:
debug and _debug_log("Checking if document/doctype is explicitly shared with user")
perm = false_if_not_shared()
return bool(perm)
def get_doc_permissions(doc, user=None, ptype=None):
def get_doc_permissions(doc, user=None, ptype=None, debug=False):
"""Return a dict of evaluated permissions for given `doc` like `{"read":1, "write":1}`"""
if not user:
user = frappe.session.user
@ -168,11 +209,18 @@ def get_doc_permissions(doc, user=None, ptype=None):
def is_user_owner():
return (doc.get("owner") or "").lower() == user.lower()
if has_controller_permissions(doc, ptype, user=user) is False:
push_perm_check_log(_("Not allowed via controller permission check"))
if not has_controller_permissions(doc, ptype, user=user, debug=debug):
push_perm_check_log(_("Not allowed via controller permission check"), debug=debug)
return {ptype: 0}
permissions = copy.deepcopy(get_role_permissions(meta, user=user, is_owner=is_user_owner()))
permissions = copy.deepcopy(
get_role_permissions(meta, user=user, is_owner=is_user_owner(), debug=debug)
)
debug and _debug_log(
"User has following permissions using role permission system: "
+ frappe.as_json(permissions, indent=8)
)
if not cint(meta.is_submittable):
permissions["submit"] = 0
@ -186,20 +234,29 @@ def get_doc_permissions(doc, user=None, ptype=None):
# some access might be only for the owner
# eg. everyone might have read access but only owner can delete
permissions.update(permissions.get("if_owner", {}))
debug and _debug_log(
"User is owner of document, so permissions are updated to: " + frappe.as_json(permissions)
)
if not has_user_permission(doc, user):
if not has_user_permission(doc, user, debug=debug):
if is_user_owner():
# replace with owner permissions
permissions = permissions.get("if_owner", {})
# if_owner does not come with create rights...
permissions["create"] = 0
debug and _debug_log("User has only 'If owner' permissions because of User Permissions")
else:
debug and _debug_log("User has no permissions because of User Permissions")
permissions = {}
debug and _debug_log(
"Final applicable permissions after evaluating user permissions: "
+ frappe.as_json(permissions, indent=8)
)
return permissions
def get_role_permissions(doctype_meta, user=None, is_owner=None):
def get_role_permissions(doctype_meta, user=None, is_owner=None, debug=False):
"""
Return dict of evaluated role permissions like:
{
@ -222,12 +279,14 @@ def get_role_permissions(doctype_meta, user=None, is_owner=None):
cache_key = (doctype_meta.name, user, bool(is_owner))
if user == "Administrator":
debug and _debug_log("all permissions granted because user is Administrator")
return allow_everything()
if not frappe.local.role_permissions.get(cache_key):
if not frappe.local.role_permissions.get(cache_key) or debug:
perms = frappe._dict(if_owner={})
roles = frappe.get_roles(user)
debug and _debug_log("User has following roles: " + str(roles))
def is_perm_applicable(perm):
return perm.role in roles and cint(perm.permlevel) == 0
@ -268,7 +327,7 @@ def get_user_permissions(user):
return get_user_permissions(user)
def has_user_permission(doc, user=None):
def has_user_permission(doc, user=None, debug=False):
"""Return True if User is allowed to view considering User Permissions."""
from frappe.core.doctype.user_permission.user_permission import get_user_permissions
@ -276,13 +335,17 @@ def has_user_permission(doc, user=None):
if not user_permissions:
# no user permission rules specified for this doctype
debug and _debug_log("User is not affected by any user permissions")
return True
# user can create own role permissions, so nothing applies
if get_role_permissions("User Permission", user=user).get("write"):
debug and _debug_log("User permission bypassed because user can modify user permissions.")
return True
apply_strict_user_permissions = frappe.get_system_settings("apply_strict_user_permissions")
if apply_strict_user_permissions:
debug and _debug_log("Strict user permissions will be applied")
doctype = doc.get("doctype")
docname = doc.get("name")
@ -297,8 +360,14 @@ def has_user_permission(doc, user=None):
# only check if allowed_docs is not empty
if allowed_docs and docname not in allowed_docs:
# no user permissions for this doc specified
push_perm_check_log(_("Not allowed for {0}: {1}").format(_(doctype), docname))
debug and _debug_log(
"User doesn't have access to this document because of User Permissions, allowed documents: "
+ str(allowed_docs)
)
push_perm_check_log(_("Not allowed for {0}: {1}").format(_(doctype), docname), debug=debug)
return False
else:
debug and _debug_log(f"User Has access to {docname} via User Permissions.")
# STEP 2: ---------------------------------
# check user permissions in all link fields
@ -354,7 +423,7 @@ def has_user_permission(doc, user=None):
_(field.label) if field.label else field.fieldname,
)
push_perm_check_log(msg)
push_perm_check_log(msg, debug=debug)
return False
@ -370,23 +439,26 @@ def has_user_permission(doc, user=None):
return True
def has_controller_permissions(doc, ptype, user=None):
"""Return controller permissions if defined, None if not defined."""
def has_controller_permissions(doc, ptype, user=None, debug=False) -> bool:
"""Return controller permissions if denied, True if not defined.
Controllers can only deny permission, they can not explicitly grant any permission that wasn't
already present."""
if not user:
user = frappe.session.user
methods = frappe.get_hooks("has_permission").get(doc.doctype, [])
if not methods:
return None
return True
for method in reversed(methods):
controller_permission = frappe.call(frappe.get_attr(method), doc=doc, ptype=ptype, user=user)
if controller_permission is not None:
return controller_permission
controller_permission = frappe.call(method, doc=doc, ptype=ptype, user=user, debug=debug)
debug and _debug_log(f"Controller permission check from {method}: {controller_permission}")
if not controller_permission:
return bool(controller_permission)
# controller permissions could not decide on True or False
return None
return True
def get_doctypes_with_read():
@ -675,7 +747,8 @@ def filter_allowed_docs_for_doctype(user_permissions, doctype, with_default_doc=
return (allowed_doc, default_doc) if with_default_doc else allowed_doc
def push_perm_check_log(log):
def push_perm_check_log(log, debug=False):
debug and _debug_log(log)
if frappe.flags.get("has_permission_check_logs") is None:
return
@ -687,9 +760,12 @@ def has_child_permission(
ptype="read",
child_doc=None,
user=None,
raise_exception=True,
parent_doctype=None,
):
*,
debug=False,
print_logs=True,
) -> bool:
debug and _debug_log("This doctype is a child table, permissions will be checked on parent.")
if isinstance(child_doc, str):
child_doc = frappe.db.get_value(
child_doctype,
@ -703,7 +779,8 @@ def has_child_permission(
if not parent_doctype:
push_perm_check_log(
_("Please specify a valid parent DocType for {0}").format(frappe.bold(child_doctype))
_("Please specify a valid parent DocType for {0}").format(frappe.bold(child_doctype)),
debug=debug,
)
return False
@ -717,7 +794,8 @@ def has_child_permission(
push_perm_check_log(
_("{0} is not a valid parent DocType for {1}").format(
frappe.bold(parent_doctype), frappe.bold(child_doctype)
)
),
debug=debug,
)
return False
@ -727,7 +805,8 @@ def has_child_permission(
push_perm_check_log(
_("Parentfield not specified in {0}: {1}").format(
frappe.bold(child_doctype), frappe.bold(child_doc.name)
)
),
debug=debug,
)
return False
@ -735,14 +814,19 @@ def has_child_permission(
push_perm_check_log(
_("{0} is not a valid parentfield for {1}").format(
frappe.bold(parentfield), frappe.bold(child_doctype)
)
),
debug=debug,
)
return False
permlevel = parent_meta.get_field(parentfield).permlevel
if permlevel > 0 and permlevel not in parent_meta.get_permlevel_access(ptype, user=user):
accessible_permlevels = parent_meta.get_permlevel_access(ptype, user=user)
if permlevel > 0 and permlevel not in accessible_permlevels:
push_perm_check_log(
_("Insufficient Permission Level for {0}").format(frappe.bold(parent_doctype))
_("Insufficient Permission Level for {0}").format(frappe.bold(parent_doctype)), debug=debug
)
debug and _debug_log(
f"This table is perm level {permlevel} but user only has access to {accessible_permlevels}"
)
return False
@ -751,7 +835,8 @@ def has_child_permission(
ptype=ptype,
doc=child_doc and getattr(child_doc, "parent_doc", child_doc.parent),
user=user,
raise_exception=raise_exception,
print_logs=print_logs,
debug=debug,
)

View file

@ -473,7 +473,7 @@ function check_restrictions(file) {
return is_correct_type && valid_file_size;
}
function upload_files() {
function upload_files(dialog) {
if (show_file_browser.value) {
return upload_via_file_browser();
}
@ -483,6 +483,14 @@ function upload_files() {
if (props.as_dataurl) {
return return_as_dataurl();
}
if (!files.value.length) {
frappe.msgprint(__("Please select a file first."));
return Promise.reject();
}
dialog?.get_primary_btn().prop("disabled", true);
dialog?.get_secondary_btn().prop("disabled", true);
return frappe.run_serially(files.value.map((file, i) => () => upload_file(file, i)));
}
function upload_via_file_browser() {

View file

@ -113,9 +113,7 @@ class FileUploader {
}
upload_files() {
this.dialog && this.dialog.get_primary_btn().prop("disabled", true);
this.dialog && this.dialog.get_secondary_btn().prop("disabled", true);
return this.uploader.upload_files();
return this.uploader.upload_files(this.dialog);
}
make_dialog(title) {

View file

@ -85,33 +85,15 @@ frappe.ui.form.ControlAutocomplete = class ControlAutoComplete extends frappe.ui
};
}
init_option_cache() {
if (!this.$input.cache) {
this.$input.cache = {};
}
if (!this.$input.cache[this.doctype]) {
this.$input.cache[this.doctype] = {};
}
if (!this.$input.cache[this.doctype][this.df.fieldname]) {
this.$input.cache[this.doctype][this.df.fieldname] = {};
}
}
setup_awesomplete() {
this.awesomplete = new Awesomplete(this.input, this.get_awesomplete_settings());
$(this.input_area).find(".awesomplete ul").css("min-width", "100%");
this.init_option_cache();
this.$input.on(
"input",
frappe.utils.debounce((e) => {
const cached_options =
this.$input.cache[this.doctype][this.df.fieldname][e.target.value];
if (cached_options && cached_options.length) {
this.set_data(cached_options);
} else if (this.get_query || this.df.get_query) {
if (this.get_query || this.df.get_query) {
this.execute_query_if_exists(e.target.value);
} else {
this.awesomplete.list = this.get_data();
@ -245,7 +227,6 @@ frappe.ui.form.ControlAutocomplete = class ControlAutoComplete extends frappe.ui
if (!this.$input.is(":focus")) {
return;
}
this.$input.cache[this.doctype][this.df.fieldname][term] = message;
this.set_data(message);
},
});

View file

@ -16,7 +16,7 @@ frappe.ui.form.ControlDatetime = class ControlDatetime extends frappe.ui.form.Co
}
get_start_date() {
this.value = this.value == null ? undefined : this.value;
this.value = this.value == null || this.value == "" ? undefined : this.value;
let value = frappe.datetime.convert_to_user_tz(this.value);
return frappe.datetime.str_to_obj(value);
}

View file

@ -1456,9 +1456,28 @@ frappe.ui.form.Form = class FrappeForm {
this.custom_buttons = {};
}
//Remove specific custom button by button Label
// Remove specific custom button by button Label
remove_custom_button(label, group) {
this.page.remove_inner_button(label, group);
// Remove actions from menu
delete this.custom_buttons[label];
let menu_item_label = group ? `${group} > ${label}` : label;
let $linkBody = this.page
.is_in_group_button_dropdown(
this.page.menu,
"li > a.grey-link > span",
menu_item_label
)
.parent()
.parent();
if ($linkBody) {
// If last button, remove divider too
let $divider = $linkBody.next(".dropdown-divider");
if ($divider) $divider.remove();
$linkBody.remove();
}
}
scroll_to_element() {

View file

@ -429,10 +429,10 @@ export default class GridRow {
$(`
<div class='form-group'>
<div class='row' style='margin:0px; margin-bottom:10px;'>
<div class='col-md-8'>
<div class='col-6 col-md-8'>
${__("Fieldname").bold()}
</div>
<div class='col-md-4' style='padding-left:5px;'>
<div class='col-6 col-md-4' style='padding-left:5px;'>
${__("Column Width").bold()}
</div>
</div>
@ -522,13 +522,13 @@ export default class GridRow {
data-label='${docfield.label}' data-type='${docfield.fieldtype}'>
<div class='row'>
<div class='col-md-1' style='padding-top: 4px;'>
<div class='col-1' style='padding-top: 4px;'>
<a style='cursor: grabbing;'>${frappe.utils.icon("drag", "xs")}</a>
</div>
<div class='col-md-8' style='padding-right:0px; padding-top: 5px;'>
<div class='col-6 col-md-8' style='padding-right:0px; padding-top: 5px;'>
${__(docfield.label)}
</div>
<div class='col-md-2' style='padding-left:0px; padding-top: 2px; margin-top:-2px;' title='${__(
<div class='col-3 col-md-2' style='padding-left:0px; padding-top: 2px; margin-top:-2px;' title='${__(
"Columns"
)}'>
<input class='form-control column-width my-1 input-xs text-right'
@ -536,7 +536,7 @@ export default class GridRow {
value='${docfield.columns || cint(d.columns)}'
data-fieldname='${docfield.fieldname}' style='background-color: var(--modal-bg); display: inline'>
</div>
<div class='col-md-1' style='padding-top: 3px;'>
<div class='col-1' style='padding-top: 3px;'>
<a class='text-muted remove-field' data-fieldname='${docfield.fieldname}'>
<i class='fa fa-trash-o' aria-hidden='true'></i>
</a>

View file

@ -114,13 +114,13 @@ export default class ListSettings {
data-label="${me.fields[idx].label}" data-type="${me.fields[idx].type}">
<div class="row">
<div class="col-md-1">
<div class="col-1">
${frappe.utils.icon("drag", "xs", "", "", "sortable-handle " + show_sortable_handle)}
</div>
<div class="col-md-10" style="padding-left:0px;">
<div class="col-10" style="padding-left:0px;">
${me.fields[idx].label}
</div>
<div class="col-md-1 ${can_remove}">
<div class="col-1 ${can_remove}">
<a class="text-muted remove-field" data-fieldname="${me.fields[idx].fieldname}">
${frappe.utils.icon("delete", "xs")}
</a>

View file

@ -2016,9 +2016,13 @@ frappe.views.ListView = class ListView extends frappe.views.BaseList {
parse_filters_from_route_options() {
const filters = [];
for (let field in frappe.route_options) {
let params = new URLSearchParams(window.location.search);
if (!params.toString() && frappe.route_options) {
params = new Map(Object.entries(frappe.route_options));
}
params.forEach((value, field) => {
let doctype = null;
let value = frappe.route_options[field];
let value_array;
if ($.isArray(value) && value[0].startsWith("[") && value[0].endsWith("]")) {
@ -2060,7 +2064,7 @@ frappe.views.ListView = class ListView extends frappe.views.BaseList {
filters.push([doctype, field, "=", value]);
}
}
}
});
return filters;
}

View file

@ -37,6 +37,10 @@ $("body").on("click", "a", function (e) {
const href = target_element.getAttribute("href");
const is_on_same_host = target_element.hostname === window.location.hostname;
if (target_element.getAttribute("target") === "_blank") {
return;
}
const override = (route) => {
e.preventDefault();
frappe.set_route(route);

View file

@ -52,7 +52,9 @@ frappe.ui.Filter = class {
"Markdown Editor": ["Between", "Timespan", ">", "<", ">=", "<=", "in", "not in"],
Password: ["Between", "Timespan", ">", "<", ">=", "<=", "in", "not in"],
Rating: ["like", "not like", "Between", "in", "not in", "Timespan"],
Int: ["like", "not like", "Between", "in", "not in", "Timespan"],
Float: ["like", "not like", "Between", "in", "not in", "Timespan"],
Percent: ["like", "not like", "Between", "in", "not in", "Timespan"],
};
}

View file

@ -230,6 +230,10 @@ frappe.views.CommunicationComposer = class {
if (!this.forward && !this.recipients && this.last_email) {
this.recipients = this.last_email.sender;
// If same user replies to their own email, set recipients to last email recipients
if (this.last_email.sender == this.sender) {
this.recipients = this.last_email.recipients;
}
this.cc = this.last_email.cc;
this.bcc = this.last_email.bcc;
}

View file

@ -111,10 +111,9 @@ frappe.views.ReportView = class ReportView extends frappe.views.ListView {
//Setup groupby for reports
this.group_by_control = new frappe.ui.GroupBy(this);
if (this.report_doc && this.report_doc.json.group_by) {
if (this.report_doc?.json?.group_by) {
this.group_by_control.apply_settings(this.report_doc.json.group_by);
}
if (this.view_user_settings && this.view_user_settings.group_by) {
} else if (this.view_user_settings?.group_by) {
this.group_by_control.apply_settings(this.view_user_settings.group_by);
}
}

View file

@ -200,9 +200,13 @@ frappe.views.TreeView = class TreeView {
if (use_value == null) {
use_value = use_label;
}
this.args["include_disabled"] = this.page.inner_toolbar
.find("input[type='checkbox']")
.prop("checked");
if (this.page?.inner_toolbar) {
this.args["include_disabled"] = this.page.inner_toolbar
.find("input[type='checkbox']")
.prop("checked");
}
this.tree = new frappe.ui.Tree({
parent: this.body,
label: use_label,
@ -235,7 +239,6 @@ frappe.views.TreeView = class TreeView {
method: "frappe.utils.nestedset.rebuild_tree",
args: {
doctype: me.doctype,
parent_field: "parent_" + me.doctype.toLowerCase().replace(/ /g, "_"),
},
callback: function (r) {
if (!r.exc) {

View file

@ -32,7 +32,7 @@ export default class Card extends Block {
if (this.data && this.data.card_name) {
let has_data = this.make("card", this.data.card_name, "links");
if (!has_data) return;
if (!has_data) return this.wrapper;
}
if (!this.readOnly) {

Some files were not shown because too many files have changed in this diff Show more