Merge remote-tracking branch 'upstream/develop' into feat/link-preview-in-tree-view
This commit is contained in:
commit
d3f16b7ccb
219 changed files with 695659 additions and 304636 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -169,6 +169,7 @@ typings/
|
|||
|
||||
# Optional npm cache directory
|
||||
.npm
|
||||
.yarn
|
||||
|
||||
# Optional eslint cache
|
||||
.eslintcache
|
||||
|
|
|
|||
9
babel_extractors.csv
Normal file
9
babel_extractors.csv
Normal 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
|
||||
|
3
crowdin.yml
Normal file
3
crowdin.yml
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
files:
|
||||
- source: /frappe/locale/main.pot
|
||||
translation: /frappe/locale/%two_letters_code%.po
|
||||
|
|
@ -7,7 +7,7 @@ const jump_to_field = (field_label) => {
|
|||
.type("{enter}")
|
||||
.wait(200)
|
||||
.type("{enter}")
|
||||
.wait(500);
|
||||
.wait(1000);
|
||||
};
|
||||
|
||||
const type_value = (value) => {
|
||||
|
|
|
|||
|
|
@ -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 }, () => {
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
98
frappe/commands/gettext.py
Normal file
98
frappe/commands/gettext.py
Normal 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,
|
||||
]
|
||||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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"]:
|
||||
|
|
|
|||
|
|
@ -234,6 +234,7 @@ class DocType(Document):
|
|||
"DocPerm",
|
||||
"Custom Field",
|
||||
"Customize Form Field",
|
||||
"Web Form Field",
|
||||
"DocField",
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
},
|
||||
});
|
||||
|
|
@ -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": []
|
||||
}
|
||||
|
|
@ -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):
|
||||
...
|
||||
|
|
@ -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
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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"):
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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 ""
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
2
frappe/gettext/extractors/README.md
Normal file
2
frappe/gettext/extractors/README.md
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
Extractors should run on source files only.
|
||||
They should not depend on an acitive web server or database connection.
|
||||
60
frappe/gettext/extractors/doctype.py
Normal file
60
frappe/gettext/extractors/doctype.py
Normal 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
|
||||
)
|
||||
163
frappe/gettext/extractors/javascript.py
Normal file
163
frappe/gettext/extractors/javascript.py
Normal 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
|
||||
11
frappe/gettext/extractors/jinja2.py
Normal file
11
frappe/gettext/extractors/jinja2.py
Normal 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
|
||||
30
frappe/gettext/extractors/module_onboarding.py
Normal file
30
frappe/gettext/extractors/module_onboarding.py
Normal 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}'"
|
||||
]
|
||||
41
frappe/gettext/extractors/navbar.py
Normal file
41
frappe/gettext/extractors/navbar.py
Normal 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)
|
||||
32
frappe/gettext/extractors/onboarding_step.py
Normal file
32
frappe/gettext/extractors/onboarding_step.py
Normal 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}'"
|
||||
]
|
||||
13
frappe/gettext/extractors/python.py
Normal file
13
frappe/gettext/extractors/python.py
Normal 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
|
||||
18
frappe/gettext/extractors/report.py
Normal file
18
frappe/gettext/extractors/report.py
Normal 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"]
|
||||
42
frappe/gettext/extractors/workspace.py
Normal file
42
frappe/gettext/extractors/workspace.py
Normal 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", [])
|
||||
)
|
||||
64
frappe/gettext/test_translate.py
Normal file
64
frappe/gettext/test_translate.py
Normal 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
311
frappe/gettext/translate.py
Normal 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("%", "%")
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
38588
frappe/locale/af.po
Normal file
File diff suppressed because it is too large
Load diff
38437
frappe/locale/ar.po
Normal file
38437
frappe/locale/ar.po
Normal file
File diff suppressed because it is too large
Load diff
38728
frappe/locale/de.po
Normal file
38728
frappe/locale/de.po
Normal file
File diff suppressed because it is too large
Load diff
38706
frappe/locale/es.po
Normal file
38706
frappe/locale/es.po
Normal file
File diff suppressed because it is too large
Load diff
38525
frappe/locale/fi.po
Normal file
38525
frappe/locale/fi.po
Normal file
File diff suppressed because it is too large
Load diff
38767
frappe/locale/fr.po
Normal file
38767
frappe/locale/fr.po
Normal file
File diff suppressed because it is too large
Load diff
38569
frappe/locale/id.po
Normal file
38569
frappe/locale/id.po
Normal file
File diff suppressed because it is too large
Load diff
38671
frappe/locale/it.po
Normal file
38671
frappe/locale/it.po
Normal file
File diff suppressed because it is too large
Load diff
38194
frappe/locale/main.pot
Normal file
38194
frappe/locale/main.pot
Normal file
File diff suppressed because it is too large
Load diff
38615
frappe/locale/nl.po
Normal file
38615
frappe/locale/nl.po
Normal file
File diff suppressed because it is too large
Load diff
38598
frappe/locale/pl.po
Normal file
38598
frappe/locale/pl.po
Normal file
File diff suppressed because it is too large
Load diff
38604
frappe/locale/pt.po
Normal file
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
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
38639
frappe/locale/ru.po
Normal file
File diff suppressed because it is too large
Load diff
38510
frappe/locale/tr.po
Normal file
38510
frappe/locale/tr.po
Normal file
File diff suppressed because it is too large
Load diff
38527
frappe/locale/vi.po
Normal file
38527
frappe/locale/vi.po
Normal file
File diff suppressed because it is too large
Load diff
38207
frappe/locale/zh.po
Normal file
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
38196
frappe/locale/zh_TW.po
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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):
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Add table
Reference in a new issue