Merge branch 'develop' into permlevel-apis

This commit is contained in:
Gavin D'souza 2023-01-31 17:47:59 +05:30
commit 7ce0c4c8b3
59 changed files with 614 additions and 783 deletions

View file

@ -80,7 +80,20 @@ jobs:
- uses: actions/setup-python@v4
with:
python-version: '3.10'
- uses: actions/checkout@v3
- name: Cache pip
uses: actions/cache@v3
with:
path: ~/.cache/pip
key: ${{ runner.os }}-pip-${{ hashFiles('**/*requirements.txt', '**/pyproject.toml', '**/setup.py') }}
restore-keys: |
${{ runner.os }}-pip-
${{ runner.os }}-
- run: |
pip install pip-audit
pip-audit ${GITHUB_WORKSPACE}
cd ${GITHUB_WORKSPACE}
sed -i '/dropbox/d' pyproject.toml # Remove dropbox temporarily https://github.com/dropbox/dropbox-sdk-python/pull/456
pip-audit .

View file

@ -48,8 +48,8 @@ repos:
)$
- repo: https://github.com/timothycrosley/isort
rev: 5.9.1
- repo: https://github.com/PyCQA/isort
rev: 5.12.0
hooks:
- id: isort

View file

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

View file

@ -229,19 +229,15 @@ context("Control Link", () => {
);
cy.reload();
cy.new_form("ToDo");
cy.fill_field("description", "new", "Text Editor");
cy.intercept("POST", "/api/method/frappe.desk.form.save.savedocs").as("save_form");
cy.findByRole("button", { name: "Save" }).click();
cy.wait("@save_form");
cy.fill_field("description", "new", "Text Editor").wait(200);
cy.save();
cy.get(".frappe-control[data-fieldname=assigned_by_full_name] .control-value").should(
"contain",
"Administrator"
);
// if user clears default value explicitly, system should not reset default again
cy.get_field("assigned_by").clear().blur();
cy.intercept("POST", "/api/method/frappe.desk.form.save.savedocs").as("save_form");
cy.findByRole("button", { name: "Save" }).click();
cy.wait("@save_form");
cy.save();
cy.get_field("assigned_by").should("have.value", "");
cy.get(".frappe-control[data-fieldname=assigned_by_full_name] .control-value").should(
"contain",

View file

@ -10,7 +10,7 @@ context("Folder Navigation", () => {
cy.get(".filter-selector > .btn").findByText("1 filter").click();
cy.findByRole("button", { name: "Clear Filters" }).click();
cy.get(".filter-action-buttons > .text-muted").findByText("+ Add a Filter").click();
cy.get(".fieldname-select-area > .awesomplete > .form-control").type("Fol{enter}");
cy.get(".fieldname-select-area > .awesomplete > .form-control:last").type("Fol{enter}");
cy.get(
".filter-field > .form-group > .link-field > .awesomplete > .input-with-feedback"
).type("Home{enter}");

View file

@ -26,6 +26,11 @@ context("Form", () => {
});
});
beforeEach(() => {
cy.login();
cy.visit("/app/website");
});
it("create a new form", () => {
cy.visit("/app/todo/new");
cy.get_field("description", "Text Editor")
@ -172,4 +177,57 @@ context("Form", () => {
send_welcome_email: 0,
});
});
it("update docfield property using set_df_property in child table", () => {
cy.visit("/app/contact/Test Form Contact 1");
cy.window()
.its("cur_frm")
.then((frm) => {
cy.get('.frappe-control[data-fieldname="phone_nos"]').as("table");
// set property before form_render event of child table
cy.get("@table")
.find('[data-idx="1"]')
.invoke("attr", "data-name")
.then((cdn) => {
frm.set_df_property(
"phone_nos",
"hidden",
1,
"Contact Phone",
"is_primary_phone",
cdn
);
});
cy.get("@table").find('[data-idx="1"] .edit-grid-row').click();
cy.get(".grid-row-open").as("table-form");
cy.get("@table-form")
.find('.frappe-control[data-fieldname="is_primary_phone"]')
.should("be.hidden");
cy.get("@table-form").find(".grid-footer-toolbar").click();
// set property on form_render event of child table
cy.get("@table").find('[data-idx="1"] .edit-grid-row').click();
cy.get("@table")
.find('[data-idx="1"]')
.invoke("attr", "data-name")
.then((cdn) => {
frm.set_df_property(
"phone_nos",
"hidden",
0,
"Contact Phone",
"is_primary_phone",
cdn
);
});
cy.get(".grid-row-open").as("table-form");
cy.get("@table-form")
.find('.frappe-control[data-fieldname="is_primary_phone"]')
.should("be.visible");
cy.get("@table-form").find(".grid-footer-toolbar").click();
});
});
});

View file

@ -18,6 +18,7 @@ context("Navigation", () => {
it.only("Navigate to previous page after login", () => {
cy.visit("/app/todo");
cy.get(".page-head").findByTitle("To Do").should("be.visible");
cy.clear_filters();
cy.request("/api/method/logout");
cy.reload().as("reload");
cy.get("@reload").get(".page-card .btn-primary").contains("Login").click();

View file

@ -103,8 +103,9 @@ context("View", () => {
});
it("Route to File View", () => {
cy.intercept("POST", "/api/method/frappe.desk.reportview.get").as("list_loaded");
cy.visit("app/file");
cy.wait(500);
cy.wait("@list_loaded");
cy.window()
.its("cur_list")
.then((list) => {
@ -113,7 +114,7 @@ context("View", () => {
});
cy.visit("app/file/view/home/Attachments");
cy.wait(500);
cy.wait("@list_loaded");
cy.window()
.its("cur_list")
.then((list) => {

View file

@ -285,7 +285,7 @@ Cypress.Commands.add("get_open_dialog", () => {
Cypress.Commands.add("save", () => {
cy.intercept("/api/method/frappe.desk.form.save.savedocs").as("save_call");
cy.get(`button[data-label="Save"]:visible`).click({ scrollBehavior: "top", force: true });
cy.get(`.page-container:visible button[data-label="Save"]`).click({ force: true });
cy.wait("@save_call");
});
Cypress.Commands.add("hide_dialog", () => {

View file

@ -238,7 +238,6 @@ def init(site: str, sites_path: str = ".", new_site: bool = False) -> None:
local.jenv = None
local.jloader = None
local.cache = {}
local.document_cache = {}
local.form_dict = _dict()
local.preload_assets = {"style": [], "script": []}
local.session = _dict()
@ -1075,25 +1074,10 @@ def set_value(doctype, docname, fieldname, value=None):
def get_cached_doc(*args, **kwargs) -> "Document":
def _respond(doc, from_redis=False):
if isinstance(doc, dict):
local.document_cache[key] = doc = get_doc(doc)
elif from_redis:
local.document_cache[key] = doc
if (key := can_cache_doc(args)) and (doc := cache().hget("document_cache", key)):
return doc
if key := can_cache_doc(args):
# local cache - has "ready" `Document` objects
if doc := local.document_cache.get(key):
return _respond(doc)
# redis cache
if doc := cache().hget("document_cache", key):
return _respond(doc, True)
# Not found in local/redis, fetch from DB
# Not found in cache, fetch from DB
doc = get_doc(*args, **kwargs)
# Store in cache
@ -1106,14 +1090,7 @@ def get_cached_doc(*args, **kwargs) -> "Document":
def _set_document_in_cache(key: str, doc: "Document") -> None:
local.document_cache[key] = doc
# Avoid setting in local.cache since we're already using local.document_cache above
# Try pickling the doc object as-is first, else fallback to doc.as_dict()
try:
cache().hset("document_cache", key, doc, cache_locally=False)
except Exception:
cache().hset("document_cache", key, doc.as_dict(), cache_locally=False)
cache().hset("document_cache", key, doc)
def can_cache_doc(args) -> str | None:
@ -1139,12 +1116,11 @@ def get_document_cache_key(doctype: str, name: str):
def clear_document_cache(doctype, name):
cache().hdel("last_modified", doctype)
key = get_document_cache_key(doctype, name)
if key in local.document_cache:
del local.document_cache[key]
cache().hdel("document_cache", key)
cache().hdel("document_cache", get_document_cache_key(doctype, name))
if doctype == "System Settings" and hasattr(local, "system_settings"):
delattr(local, "system_settings")
if doctype == "Website Settings" and hasattr(local, "website_settings"):
delattr(local, "website_settings")

View file

@ -3,15 +3,16 @@
"""
bootstrap client session
"""
import frappe
import frappe.defaults
import frappe.desk.desk_page
from frappe.core.doctype.navbar_settings.navbar_settings import get_app_logo, get_navbar_settings
from frappe.database.utils import Query
from frappe.desk.doctype.route_history.route_history import frequently_visited_links
from frappe.desk.form.load import get_meta_bundle
from frappe.email.inbox import get_email_accounts
from frappe.model.base_document import get_controller
from frappe.model.db_query import DatabaseQuery
from frappe.query_builder import DocType
from frappe.query_builder.functions import Count
from frappe.query_builder.terms import ParameterizedValueWrapper, SubQuery
@ -169,6 +170,7 @@ def get_user_pages_or_reports(parent, cache=False):
parentTable = DocType(parent)
# get pages or reports set on custom role
# must end in a WHERE clause for `_run_with_permission_query`
pages_with_custom_roles = (
frappe.qb.from_(customRole)
.from_(hasRole)
@ -182,7 +184,8 @@ def get_user_pages_or_reports(parent, cache=False):
& (customRole[parent.lower()].isnotnull())
& (hasRole.role.isin(roles))
)
).run(as_dict=True)
)
pages_with_custom_roles = _run_with_permission_query(pages_with_custom_roles, parent)
for p in pages_with_custom_roles:
has_role[p.name] = {"modified": p.modified, "title": p.title, "ref_doctype": p.ref_doctype}
@ -193,6 +196,7 @@ def get_user_pages_or_reports(parent, cache=False):
.where(customRole[parent.lower()].isnotnull())
)
# must end in a WHERE clause for `_run_with_permission_query`
pages_with_standard_roles = (
frappe.qb.from_(hasRole)
.from_(parentTable)
@ -208,7 +212,7 @@ def get_user_pages_or_reports(parent, cache=False):
if parent == "Report":
pages_with_standard_roles = pages_with_standard_roles.where(report.disabled == 0)
pages_with_standard_roles = pages_with_standard_roles.run(as_dict=True)
pages_with_standard_roles = _run_with_permission_query(pages_with_standard_roles, parent)
for p in pages_with_standard_roles:
if p.name not in has_role:
@ -222,12 +226,13 @@ def get_user_pages_or_reports(parent, cache=False):
# pages with no role are allowed
if parent == "Page":
# must end in a WHERE clause for `_run_with_permission_query`
pages_with_no_roles = (
frappe.qb.from_(parentTable)
.select(parentTable.name, parentTable.modified, *columns)
.where(no_of_roles == 0)
).run(as_dict=True)
)
pages_with_no_roles = _run_with_permission_query(pages_with_no_roles, parent)
for p in pages_with_no_roles:
if p.name not in has_role:
@ -248,6 +253,17 @@ def get_user_pages_or_reports(parent, cache=False):
return has_role
def _run_with_permission_query(query: "Query", doctype: str) -> list[dict]:
"""
Adds Permission Query (Server Script) conditions and runs/executes modified query
Note: Works only if 'WHERE' is the last clause in the query
"""
permission_query = DatabaseQuery(doctype, frappe.session.user).get_permission_query_conditions()
if permission_query and frappe.session.user != "Administrator":
return frappe.db.sql(f"{query} AND {permission_query}", as_dict=True)
return query.run(as_dict=True)
def load_translations(bootinfo):
bootinfo["lang"] = frappe.lang
bootinfo["__messages"] = get_messages_for_boot()

View file

@ -127,8 +127,6 @@ def clear_doctype_cache(doctype=None):
for key in ("is_table", "doctype_modules", "document_cache"):
cache.delete_value(key)
frappe.local.document_cache = {}
def clear_single(dt):
for name in doctype_cache_keys:
cache.hdel(name, dt)

View file

@ -17,6 +17,7 @@ DATA_IMPORT_DEPRECATION = (
"[DEPRECATED] The `import-csv` command used 'Data Import Legacy' which has been deprecated.\n"
"Use `data-import` command instead to import data via 'Data Import'."
)
EXTRA_ARGS_CTX = {"ignore_unknown_options": True, "allow_extra_args": True}
@click.command("build")
@ -485,9 +486,10 @@ def bulk_rename(context, doctype, path):
frappe.destroy()
@click.command("db-console")
@click.command("db-console", context_settings=EXTRA_ARGS_CTX)
@click.argument("extra_args", nargs=-1)
@pass_context
def database(context):
def database(context, extra_args):
"""
Enter into the Database console for given site.
"""
@ -496,14 +498,18 @@ def database(context):
raise SiteNotSpecifiedError
frappe.init(site=site)
if not frappe.conf.db_type or frappe.conf.db_type == "mariadb":
_mariadb()
_mariadb(extra_args=extra_args)
elif frappe.conf.db_type == "postgres":
_psql()
_psql(extra_args=extra_args)
@click.command("mariadb")
@click.command(
"mariadb",
context_settings=EXTRA_ARGS_CTX,
)
@click.argument("extra_args", nargs=-1)
@pass_context
def mariadb(context):
def mariadb(context, extra_args):
"""
Enter into mariadb console for a given site.
"""
@ -511,21 +517,22 @@ def mariadb(context):
if not site:
raise SiteNotSpecifiedError
frappe.init(site=site)
_mariadb()
_mariadb(extra_args=extra_args)
@click.command("postgres")
@click.command("postgres", context_settings=EXTRA_ARGS_CTX)
@click.argument("extra_args", nargs=-1)
@pass_context
def postgres(context):
def postgres(context, extra_args):
"""
Enter into postgres console for a given site.
"""
site = get_site(context)
frappe.init(site=site)
_psql()
_psql(extra_args=extra_args)
def _mariadb():
def _mariadb(extra_args=None):
from frappe.database.mariadb.database import MariaDBDatabase
mysql = which("mysql")
@ -543,10 +550,12 @@ def _mariadb():
"--safe-updates",
"-A",
]
if extra_args:
command += list(extra_args)
os.execv(mysql, command)
def _psql():
def _psql(extra_args=None):
psql = which("psql")
host = frappe.conf.db_host or "127.0.0.1"
@ -554,7 +563,10 @@ def _psql():
env = os.environ.copy()
env["PGPASSWORD"] = frappe.conf.db_password
conn_string = f"postgresql://{frappe.conf.db_name}@{host}:{port}/{frappe.conf.db_name}"
subprocess.run([psql, conn_string], check=True, env=env)
psql_cmd = [psql, conn_string]
if extra_args:
psql_cmd = psql_cmd + list(extra_args)
subprocess.run(psql_cmd, check=True, env=env)
@click.command("jupyter")
@ -1071,6 +1083,8 @@ def set_config(context, key, value, global_=False, parse=False, as_dict=False):
common_site_config_path = os.path.join(sites_path, "common_site_config.json")
update_site_config(key, value, validate=False, site_config_path=common_site_config_path)
else:
if not context.sites:
raise SiteNotSpecifiedError
for site in context.sites:
frappe.init(site=site)
update_site_config(key, value, validate=False)

View file

@ -0,0 +1,10 @@
{{ address_line1 }}<br>
{% if address_line2 %}{{ address_line2 }}<br>{% endif -%}
{{ city }}<br>
{% if state %}{{ state }}<br>{% endif -%}
{% if pincode %}{{ pincode }}<br>{% endif -%}
{{ country }}<br>
<br>
{% if phone %}{{ _("Phone") }}: {{ phone }}<br>{% endif -%}
{% if fax %}{{ _("Fax") }}: {{ fax }}<br>{% endif -%}
{% if email_id %}{{ _("Email") }}: {{ email_id }}<br>{% endif -%}

View file

@ -4,52 +4,36 @@
import frappe
from frappe import _
from frappe.model.document import Document
from frappe.utils import cint
from frappe.utils.jinja import validate_template
class AddressTemplate(Document):
def validate(self):
validate_template(self.template)
if not self.template:
self.template = get_default_address_template()
self.defaults = frappe.db.get_values(
"Address Template", {"is_default": 1, "name": ("!=", self.name)}
)
if not self.is_default:
if not self.defaults:
self.is_default = 1
if cint(frappe.db.get_single_value("System Settings", "setup_complete")):
frappe.msgprint(_("Setting this Address Template as default as there is no other default"))
validate_template(self.template)
if not self.is_default and not self._get_previous_default():
self.is_default = 1
if frappe.db.get_single_value("System Settings", "setup_complete"):
frappe.msgprint(_("Setting this Address Template as default as there is no other default"))
def on_update(self):
if self.is_default and self.defaults:
for d in self.defaults:
frappe.db.set_value("Address Template", d[0], "is_default", 0)
if self.is_default and (previous_default := self._get_previous_default()):
frappe.db.set_value("Address Template", previous_default, "is_default", 0)
def on_trash(self):
if self.is_default:
frappe.throw(_("Default Address Template cannot be deleted"))
def _get_previous_default(self) -> str | None:
return frappe.db.get_value("Address Template", {"is_default": 1, "name": ("!=", self.name)})
@frappe.whitelist()
def get_default_address_template():
"""Get default address template (translated)"""
return (
"""{{ address_line1 }}<br>{% if address_line2 %}{{ address_line2 }}<br>{% endif -%}\
{{ city }}<br>
{% if state %}{{ state }}<br>{% endif -%}
{% if pincode %}{{ pincode }}<br>{% endif -%}
{{ country }}<br>
{% if phone %}"""
+ _("Phone")
+ """: {{ phone }}<br>{% endif -%}
{% if fax %}"""
+ _("Fax")
+ """: {{ fax }}<br>{% endif -%}
{% if email_id %}"""
+ _("Email")
+ """: {{ email_id }}<br>{% endif -%}"""
)
def get_default_address_template() -> str:
"""Return the default address template."""
from pathlib import Path
return (Path(__file__).parent / "address_template.jinja").read_text()

View file

@ -1,39 +1,39 @@
# Copyright (c) 2015, Frappe Technologies and Contributors
# License: MIT. See LICENSE
import frappe
from frappe.contacts.doctype.address_template.address_template import get_default_address_template
from frappe.tests.utils import FrappeTestCase
from frappe.utils.jinja import validate_template
class TestAddressTemplate(FrappeTestCase):
def setUp(self):
self.make_default_address_template()
def setUp(self) -> None:
frappe.db.delete("Address Template", {"country": "India"})
frappe.db.delete("Address Template", {"country": "Brazil"})
def test_default_address_template(self):
validate_template(get_default_address_template())
def test_default_is_unset(self):
a = frappe.get_doc("Address Template", "India")
a.is_default = 1
a.save()
frappe.get_doc({"doctype": "Address Template", "country": "India", "is_default": 1}).insert()
b = frappe.get_doc("Address Template", "Brazil")
b.is_default = 1
b.save()
self.assertEqual(frappe.db.get_value("Address Template", "India", "is_default"), 1)
frappe.get_doc({"doctype": "Address Template", "country": "Brazil", "is_default": 1}).insert()
self.assertEqual(frappe.db.get_value("Address Template", "India", "is_default"), 0)
self.assertEqual(frappe.db.get_value("Address Template", "Brazil", "is_default"), 1)
def tearDown(self):
a = frappe.get_doc("Address Template", "India")
a.is_default = 1
a.save()
def test_delete_address_template(self):
india = frappe.get_doc(
{"doctype": "Address Template", "country": "India", "is_default": 0}
).insert()
@classmethod
def make_default_address_template(self):
template = """{{ address_line1 }}<br>{% if address_line2 %}{{ address_line2 }}<br>{% endif -%}{{ city }}<br>{% if state %}{{ state }}<br>{% endif -%}{% if pincode %}{{ pincode }}<br>{% endif -%}{{ country }}<br>{% if phone %}Phone: {{ phone }}<br>{% endif -%}{% if fax %}Fax: {{ fax }}<br>{% endif -%}{% if email_id %}Email: {{ email_id }}<br>{% endif -%}"""
brazil = frappe.get_doc(
{"doctype": "Address Template", "country": "Brazil", "is_default": 1}
).insert()
if not frappe.db.exists("Address Template", "India"):
frappe.get_doc(
{"doctype": "Address Template", "country": "India", "is_default": 1, "template": template}
).insert()
india.reload() # might have been modified by the second template
india.delete() # should not raise an error
if not frappe.db.exists("Address Template", "Brazil"):
frappe.get_doc(
{"doctype": "Address Template", "country": "Brazil", "template": template}
).insert()
self.assertRaises(frappe.ValidationError, brazil.delete)

View file

@ -125,3 +125,17 @@ class TestDocShare(FrappeTestCase):
)
frappe.share.remove(doctype, submittable_doc.name, self.user)
def test_share_int_pk(self):
test_doc = frappe.new_doc("Console Log")
test_doc.insert()
frappe.share.add("Console Log", test_doc.name, self.user)
frappe.set_user(self.user)
self.assertIn(
str(test_doc.name), [str(name) for name in frappe.get_list("Console Log", pluck="name")]
)
test_doc.reload()
self.assertTrue(test_doc.has_permission("read"))

View file

@ -1,9 +1,11 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
# License: MIT. See LICENSE
from frappe.tests.utils import FrappeTestCase
from frappe.utils.logger import sanitized_dict
# test_records = frappe.get_test_records('Error Snapshot')
class TestErrorSnapshot(FrappeTestCase):
pass
def test_form_dict_sanitization(self):
self.assertNotEqual(sanitized_dict({"pwd": "SECRET", "usr": "WHAT"}).get("pwd"), "SECRET")

View file

@ -30,13 +30,6 @@ frappe.ui.form.on("System Settings", {
frm.set_value("bypass_restrict_ip_check_if_2fa_enabled", 0);
}
},
enable_prepared_report_auto_deletion: function (frm) {
if (frm.doc.enable_prepared_report_auto_deletion) {
if (!frm.doc.prepared_report_expiry_period) {
frm.set_value("prepared_report_expiry_period", 7);
}
}
},
on_update: function (frm) {
if (frappe.boot.time_zone && frappe.boot.time_zone.system !== frm.doc.time_zone) {
// Clear cache after saving to refresh the values of boot.

View file

@ -142,7 +142,7 @@ def get_fields_label(doctype=None):
return frappe.msgprint(_("Custom Fields can only be added to a standard DocType."))
return [
{"value": df.fieldname or "", "label": _(df.label or "")}
{"value": df.fieldname or "", "label": _(df.label) if df.label else ""}
for df in frappe.get_meta(doctype).get("fields")
]

View file

@ -0,0 +1,7 @@
// Copyright (c) 2023, Frappe Technologies and contributors
// For license information, please see license.txt
/* eslint-disable */
frappe.query_reports["Audit System Hooks"] = {
filters: [],
};

View file

@ -0,0 +1,27 @@
{
"add_total_row": 0,
"columns": [],
"creation": "2023-01-25 15:02:21.896117",
"disabled": 0,
"docstatus": 0,
"doctype": "Report",
"filters": [],
"idx": 0,
"is_standard": "Yes",
"letter_head": "",
"modified": "2023-01-31 14:53:37.778576",
"modified_by": "Administrator",
"module": "Custom",
"name": "Audit System Hooks",
"owner": "Administrator",
"prepared_report": 0,
"query": "",
"ref_doctype": "Property Setter",
"report_name": "Audit System Hooks",
"report_type": "Script Report",
"roles": [
{
"role": "System Manager"
}
]
}

View file

@ -0,0 +1,70 @@
# Copyright (c) 2023, Frappe Technologies and contributors
# For license information, please see license.txt
import frappe
def execute(filters=None):
return get_columns(), get_data()
def get_columns():
values_field_type = "Data" # TODO: better text wrapping in reportview
columns = [
{"label": "Hook name", "fieldname": "hook_name", "fieldtype": "Data", "width": 200},
{"label": "Hook key (optional)", "fieldname": "hook_key", "fieldtype": "Data", "width": 200},
{"label": "Hook Values (resolved)", "fieldname": "hook_values", "fieldtype": values_field_type},
]
# Each app is shown in order as a column
installed_apps = frappe.get_installed_apps(_ensure_on_bench=True)
columns += [
{"label": app, "fieldname": app, "fieldtype": values_field_type} for app in installed_apps
]
return columns
def get_data():
hooks = frappe.get_hooks()
installed_apps = frappe.get_installed_apps(_ensure_on_bench=True)
def fmt_hook_values(v):
"""Improve readability by discarding falsy values and removing containers when only 1
value is in container"""
if not v:
return ""
v = delist(v)
if isinstance(v, (dict, list)):
try:
return frappe.as_json(v)
except Exception:
pass
return str(v)
data = []
for hook, values in hooks.items():
if isinstance(values, dict):
for k, v in values.items():
row = {"hook_name": hook, "hook_key": fmt_hook_values(k), "hook_values": fmt_hook_values(v)}
for app in installed_apps:
if app_hooks := delist(frappe.get_hooks(hook, app_name=app)):
row[app] = fmt_hook_values(app_hooks.get(k))
data.append(row)
else:
row = {"hook_name": hook, "hook_values": fmt_hook_values(values)}
for app in installed_apps:
row[app] = fmt_hook_values(frappe.get_hooks(hook, app_name=app))
data.append(row)
return data
def delist(val):
if isinstance(val, list) and len(val) == 1:
return val[0]
return val

View file

@ -0,0 +1,17 @@
# Copyright (c) 2022, Frappe Technologies and contributors
# For license information, please see license.txt
from frappe.custom.report.audit_system_hooks.audit_system_hooks import execute
from frappe.tests.utils import FrappeTestCase
class TestAuditSystemHooksReport(FrappeTestCase):
def test_basic_query(self):
_, data = execute()
for row in data:
if row.get("hook_name") == "app_name":
self.assertEqual(row.get("hook_values"), "frappe")
break
else:
self.fail("Failed to generate hooks report")

View file

@ -772,7 +772,9 @@ class Database:
if not df:
frappe.throw(
_("Invalid field name: {0}").format(frappe.bold(fieldname)), self.InvalidColumnName
_("Field {0} does not exist on {1}").format(
frappe.bold(fieldname), frappe.bold(doctype), self.InvalidColumnName
)
)
val = cast_fieldtype(df.fieldtype, val)

View file

@ -94,7 +94,7 @@ class MariaDBTable(DBTable):
if not frappe.db.get_column_index(self.table_name, col.fieldname, unique=False):
add_index_query.append(f"ADD INDEX `{col.fieldname}_index`(`{col.fieldname}`)")
for col in self.drop_index + self.drop_unique:
for col in {*self.drop_index, *self.drop_unique}:
if col.fieldname == "name":
continue

View file

@ -28,14 +28,10 @@ def getdoc(doctype, name, user=None):
if not (doctype and name):
raise Exception("doctype and name required!")
if not name:
name = doctype
if not is_virtual_doctype(doctype) and not frappe.db.exists(doctype, name):
return []
doc = frappe.get_doc(doctype, name)
run_onload(doc)
if not doc.has_permission("read"):
frappe.flags.error_message = _("Insufficient Permission for {0}").format(
@ -43,6 +39,7 @@ def getdoc(doctype, name, user=None):
)
raise frappe.PermissionError(("read", doctype, name))
run_onload(doc)
doc.apply_fieldlevel_read_permissions()
# add file list

View file

@ -1,37 +0,0 @@
.translation-item {
font-size: 12px;
padding: 12px 15px;
min-height: 40px;
cursor: pointer;
overflow: hidden;
}
.translation-item:hover {
background-color: #fafbfc;
}
.translation-item.active {
background-color: #fffce7;
}
.translation-edit-section {
height: 100%;
overflow-y: scroll;
padding: 0px;
}
.translation-tool {
display: flex;
width: 100%;
padding: 0;
height: 72vh;
}
.left-side {
padding: 0px;
height: 100%;
overflow-y: scroll;
}
.contributed-translation {
padding: 0.5rem 0;
}

View file

@ -1,20 +0,0 @@
<div class="translation-tool">
<div class="col-sm-5 border-right left-side">
<div class="level list-row list-row-head text-muted small">
<div class="list-row-col ellipsis list-subject level">
<span class="level-item">{%= __("Contributed Translations") %}</span>
</div>
</div>
<div class="translation-item-tr"></div>
<div class="level list-row list-row-head text-muted small border-top">
<div class="list-row-col ellipsis list-subject level">
<span class="level-item">{%= __("Source Text") %}</span>
</div>
</div>
<div class="translation-item-container"></div>
</div>
<div class="translation-edit-section col-sm-7">
<div class="translation-edit-form"></div>
<div class="other-contributions padding"></div>
</div>
</div>

View file

@ -1,473 +0,0 @@
frappe.pages["translation-tool"].on_page_load = function (wrapper) {
var page = frappe.ui.make_app_page({
parent: wrapper,
title: __("Translation Tool"),
single_column: true,
card_layout: true,
});
frappe.translation_tool = new TranslationTool(page);
};
class TranslationTool {
constructor(page) {
this.page = page;
this.wrapper = $(page.body);
this.wrapper.append(frappe.render_template("translation_tool"));
frappe.utils.bind_actions_with_object(this.wrapper, this);
this.active_translation = null;
this.edited_translations = {};
this.setup_search_box();
this.setup_language_filter();
this.page.set_primary_action(
__("Contribute Translations"),
this.show_confirmation_dialog.bind(this)
);
this.page.set_secondary_action(__("Refresh"), this.fetch_messages_then_render.bind(this));
this.update_header();
}
setup_language_filter() {
let languages = Object.keys(frappe.boot.lang_dict).map((language_label) => {
let value = frappe.boot.lang_dict[language_label];
return {
label: `${language_label} (${value})`,
value: value,
};
});
let language_selector = this.page.add_field({
fieldname: "language",
fieldtype: "Select",
options: languages,
change: () => {
let language = language_selector.get_value();
localStorage.setItem("translation_language", language);
this.language = language;
this.fetch_messages_then_render();
},
});
let translation_language = localStorage.getItem("translation_language");
if (translation_language || frappe.boot.lang !== "en") {
language_selector.set_value(translation_language || frappe.boot.lang);
} else {
frappe.prompt(
{
label: __("Please select target language for translation"),
fieldname: "language",
fieldtype: "Select",
options: languages,
reqd: 1,
},
(values) => {
language_selector.set_value(values.language);
},
__("Select Language")
);
}
}
setup_search_box() {
let search_box = this.page.add_field({
fieldname: "search",
fieldtype: "Data",
label: __("Search Source Text"),
change: () => {
this.search_text = search_box.get_value();
this.fetch_messages_then_render();
},
});
}
fetch_messages_then_render() {
this.fetch_messages().then((messages) => {
this.messages = messages;
this.render_messages(messages);
});
this.setup_local_contributions();
}
fetch_messages() {
frappe.dom.freeze(__("Fetching..."));
return frappe
.xcall("frappe.translate.get_messages", {
language: this.language,
search_text: this.search_text,
})
.then((messages) => {
return messages;
})
.finally(() => {
frappe.dom.unfreeze();
});
}
render_messages(messages) {
let template = (message) => `
<div
class="translation-item"
data-message-id="${encodeURIComponent(message.id)}"
data-action="on_translation_click">
<div class="bold ellipsis">
<span class="indicator ${this.get_indicator_color(message)}">
<span>${frappe.utils.escape_html(message.source_text)}</span>
</span>
</div>
</div>
`;
let html = messages.map(template).join("");
this.wrapper.find(".translation-item-container").html(html);
}
on_translation_click(e, $el) {
let message_id = decodeURIComponent($el.data("message-id"));
this.wrapper.find(".translation-item").removeClass("active");
$el.addClass("active");
this.active_translation = this.messages.find((m) => m.id === message_id);
this.edit_translation(this.active_translation);
}
edit_translation(translation) {
if (this.form) {
this.form.set_values({});
}
this.get_additional_info(translation.id).then((data) => {
this.make_edit_form(translation, data);
});
}
get_additional_info(source_id) {
frappe.dom.freeze("Fetching...");
return frappe
.xcall("frappe.translate.get_source_additional_info", {
source: source_id,
language: this.page.fields_dict["language"].get_value(),
})
.finally(frappe.dom.unfreeze);
}
make_edit_form(translation, { contributions, positions }) {
if (!this.form) {
this.form = new frappe.ui.FieldGroup({
fields: [
{
fieldtype: "HTML",
fieldname: "header",
read_only: 1,
},
{
fieldtype: "Data",
fieldname: "id",
hidden: 1,
},
{
label: "Source Text",
fieldtype: "Code",
fieldname: "source_text",
read_only: 1,
enable_copy_button: 1,
},
{
label: "Context",
fieldtype: "Code",
fieldname: "context",
read_only: 1,
},
{
label: "DocType",
fieldtype: "Data",
fieldname: "doctype",
read_only: 1,
},
{
label: "Translated Text",
fieldtype: "Small Text",
fieldname: "translated_text",
},
{
label: "Suggest",
fieldtype: "Button",
click: () => {
let { id, translated_text, source_text } = this.form.get_values();
let existing_value = this.form.translation_dict.translated_text;
if (is_null(translated_text) || existing_value === translated_text) {
delete this.edited_translations[id];
} else if (existing_value !== translated_text) {
this.edited_translations[id] = {
id,
translated_text,
source_text,
};
}
this.update_header();
},
},
{
fieldtype: "Section Break",
fieldname: "contributed_translations_section",
label: "Contributed Translations",
},
{
fieldtype: "HTML",
fieldname: "contributed_translations",
},
{
fieldtype: "Section Break",
collapsible: 1,
label: "Occurences in source code",
},
{
fieldtype: "HTML",
fieldname: "positions",
},
],
body: this.wrapper.find(".translation-edit-form"),
});
this.form.make();
this.setup_header();
}
this.form.set_values(translation);
this.form.translation_dict = translation;
this.form.set_df_property("doctype", "hidden", !translation.doctype);
this.form.set_df_property("context", "hidden", !translation.context);
this.set_status(translation);
this.setup_contributions(contributions);
this.setup_positions(positions);
}
setup_header() {
this.form.get_field("header").$wrapper.html(`<div>
<span class="translation-status"></span>
</div>`);
}
set_status(translation) {
this.form.get_field("header").$wrapper.find(".translation-status").html(`
<span class="indicator-pill ${this.get_indicator_color(translation)}">
${this.get_indicator_status_text(translation)}
</span>
`);
}
setup_positions(positions) {
let position_dom = "";
if (positions && positions.length) {
position_dom = positions
.map((position) => {
if (position.path.startsWith("DocType: ")) {
return `<div>
<span class="text-muted">${position.path}</span>
</div>`;
} else {
return `<div>
<a
class="text-muted"
target="_blank"
href="${this.get_code_url(position.path, position.line_no, position.app)}">
${position.path}
</a>
</div>`;
}
})
.join("");
}
this.form.get_field("positions").$wrapper.html(position_dom);
}
setup_contributions(contributions) {
const contributions_exists = contributions && contributions.length;
if (contributions_exists) {
let contributions_html = contributions.map((c) => {
return `
<div class="contributed-translation flex justify-between align-center">
<div class="ellipsis">${c.translated}</div>
<div class="text-muted small">
${comment_when(c.creation)}
</div>
</div>
`;
});
this.form.get_field("contributed_translations").html(contributions_html);
}
this.form.set_df_property(
"contributed_translations_section",
"hidden",
!contributions_exists
);
}
show_confirmation_dialog() {
this.confirmation_dialog = new frappe.ui.Dialog({
fields: [
{
label: __("Language"),
fieldname: "language",
fieldtype: "Data",
read_only: 1,
bold: 1,
default: this.language,
},
{
fieldtype: "HTML",
fieldname: "edited_translations",
},
],
title: __("Confirm Translations"),
no_submit_on_enter: true,
primary_action_label: __("Submit"),
primary_action: (values) => {
this.create_translations(values).then(this.confirmation_dialog.hide());
},
});
this.confirmation_dialog.get_field("edited_translations").html(`
<table class="table table-bordered">
<tr>
<th>${__("Source Text")}</th>
<th>${__("Translated Text")}</th>
</tr>
${Object.values(this.edited_translations)
.map(
(t) => `
<tr>
<td>${t.source_text}</td>
<td>${t.translated_text}</td>
</tr>
`
)
.join("")}
</table>
`);
this.confirmation_dialog.show();
}
create_translations() {
frappe.dom.freeze(__("Submitting..."));
return frappe
.xcall("frappe.core.doctype.translation.translation.create_translations", {
translation_map: this.edited_translations,
language: this.language,
})
.then(() => {
frappe.dom.unfreeze();
frappe.show_alert({
message: __("Successfully Submitted!"),
indicator: "success",
});
this.edited_translations = {};
this.update_header();
this.fetch_messages_then_render();
})
.finally(() => frappe.dom.unfreeze());
}
setup_local_contributions() {
// TODO: Refactor
frappe
.xcall("frappe.translate.get_contributions", {
language: this.language,
})
.then((messages) => {
let template = (message) => `
<div
class="translation-item"
data-message-id="${encodeURIComponent(message.name)}"
data-action="show_translation_status_modal">
<div class="bold ellipsis">
<span class="indicator ${this.get_contribution_indicator_color(message)}">
<span>${frappe.utils.escape_html(message.source_text)}</span>
</span>
</div>
</div>
`;
let html = messages.map(template).join("");
this.wrapper.find(".translation-item-tr").html(html);
});
}
show_translation_status_modal(e, $el) {
let message_id = decodeURIComponent($el.data("message-id"));
frappe.xcall("frappe.translate.get_contribution_status", { message_id }).then((doc) => {
let d = new frappe.ui.Dialog({
title: __("Contribution Status"),
fields: [
{
fieldname: "source_message",
label: __("Source Message"),
fieldtype: "Data",
read_only: 1,
},
{
fieldname: "translated",
label: __("Translated Message"),
fieldtype: "Data",
read_only: 1,
},
{
fieldname: "contribution_status",
label: __("Contribution Status"),
fieldtype: "Data",
read_only: 1,
},
{
fieldname: "modified_by",
label: __("Verified By"),
fieldtype: "Data",
read_only: 1,
depends_on: (doc) => {
return doc.contribution_status == "Verified";
},
},
],
});
d.set_values(doc);
d.show();
});
}
update_header() {
let edited_translations_count = Object.keys(this.edited_translations).length;
if (edited_translations_count) {
let message = "";
if (edited_translations_count == 1) {
message = __("{0} translation pending", [edited_translations_count]);
} else {
message = __("{0} translations pending", [edited_translations_count]);
}
this.page.set_indicator(message, "orange");
} else {
this.page.set_indicator("");
}
this.page.btn_primary.prop("disabled", !edited_translations_count);
}
get_indicator_color(message_obj) {
return !message_obj.translated
? "red"
: message_obj.translated_by_google
? "orange"
: "blue";
}
get_indicator_status_text(message_obj) {
if (!message_obj.translated) {
return __("Untranslated");
} else if (message_obj.translated_by_google) {
return __("Google Translation");
} else {
return __("Community Contribution");
}
}
get_contribution_indicator_color(message_obj) {
return message_obj.contribution_status == "Pending" ? "orange" : "green";
}
get_code_url(path, line_no, app) {
const code_path = path.substring(`apps/${app}`.length);
return `https://github.com/frappe/${app}/blob/develop/${code_path}#L${line_no}`;
}
}

View file

@ -1,26 +0,0 @@
{
"content": null,
"creation": "2020-01-30 15:16:12.136323",
"docstatus": 0,
"doctype": "Page",
"idx": 0,
"modified": "2020-01-30 15:16:23.273733",
"modified_by": "Administrator",
"module": "Desk",
"name": "translation-tool",
"owner": "Administrator",
"page_name": "Translation Tool",
"roles": [
{
"role": "System Manager"
},
{
"role": "Translator"
}
],
"script": null,
"standard": "Yes",
"style": null,
"system_page": 1,
"title": "Translation Tool"
}

View file

@ -370,13 +370,15 @@ def login():
args = frappe.form_dict
ldap: LDAPSettings = frappe.get_doc("LDAP Settings")
user = ldap.authenticate(frappe.as_unicode(args.usr), frappe.as_unicode(args.pop("pwd", None)))
user = ldap.authenticate(frappe.as_unicode(args.usr), frappe.as_unicode(args.pwd))
frappe.local.login_manager.user = user.name
if should_run_2fa(user.name):
authenticate_for_2factor(user.name)
if not confirm_otp_token(frappe.local.login_manager):
return False
frappe.form_dict.pop("pwd", None)
frappe.local.login_manager.post_login()
# because of a GET request!

View file

@ -1074,7 +1074,7 @@ class BaseDocument:
def is_dummy_password(self, pwd):
return "".join(set(pwd)) == "*"
def precision(self, fieldname, parentfield=None):
def precision(self, fieldname, parentfield=None) -> int | None:
"""Returns float precision for a particular field (or get global default).
:param fieldname: Fieldname for which precision is required.

View file

@ -328,13 +328,16 @@ class DatabaseQuery:
# convert child_table.fieldname to `tabChild DocType`.`fieldname`
for field in self.fields:
if "." in field and "tab" not in field:
if "." in field:
original_field = field
alias = None
if " as " in field:
field, alias = field.split(" as ")
linked_fieldname, fieldname = field.split(".")
linked_field = self.doctype_meta.get_field(linked_fieldname)
field, alias = field.split(" as ", 1)
linked_fieldname, fieldname = field.split(".", 1)
linked_field = frappe.get_meta(self.doctype).get_field(linked_fieldname)
# this is not a link field
if not linked_field:
continue
linked_doctype = linked_field.options
if linked_field.fieldtype == "Link":
self.append_link_table(linked_doctype, linked_fieldname)
@ -872,7 +875,7 @@ class DatabaseQuery:
# share is an OR condition, if there is a role permission
if not only_if_shared and self.shared and conditions:
conditions = f"({conditions}) or ({self.get_share_condition()})"
conditions = f"(({conditions}) or ({self.get_share_condition()}))"
return conditions

View file

@ -196,7 +196,6 @@ frappe.patches.v14_0.setup_likes_from_feedback
frappe.patches.v14_0.update_webforms
frappe.patches.v14_0.delete_payment_gateways
frappe.patches.v15_0.remove_event_streaming
frappe.patches.v15_0.remove_prepared_report_settings_from_system_settings
frappe.patches.v15_0.copy_disable_prepared_report_to_prepared_report
[post_model_sync]
@ -221,3 +220,5 @@ frappe.patches.v14_0.update_attachment_comment
frappe.patches.v15_0.set_contact_full_name
execute:frappe.delete_doc("Page", "activity", force=1)
frappe.patches.v14_0.disable_email_accounts_with_oauth
execute:frappe.delete_doc("Page", "translation-tool", force=1)
frappe.patches.v15_0.remove_prepared_report_settings_from_system_settings

View file

@ -4,12 +4,6 @@ from frappe.utils import cint
def execute():
expiry_period = (
cint(frappe.db.get_single_value("System Settings", "prepared_report_expiry_period")) or 30
cint(frappe.db.get_singles_dict("System Settings").get("prepared_report_expiry_period")) or 30
)
frappe.get_single("Log Settings").register_doctype("Prepared Report", expiry_period)
singles = frappe.qb.DocType("Singles")
frappe.qb.from_(singles).delete().where(
(singles.doctype == "System Settings")
& (singles.field.isin(["enable_prepared_report_auto_deletion", "prepared_report_expiry_period"]))
).run()

View file

@ -637,7 +637,7 @@ def get_linked_doctypes(dt: str) -> list:
def get_doc_name(doc):
if not doc:
return None
return doc if isinstance(doc, str) else doc.name
return doc if isinstance(doc, str) else str(doc.name)
def allow_everything():

View file

@ -39,7 +39,7 @@ frappe.ui.form.on("Print Format", {
} else if (frm.doc.custom_format && !frm.doc.raw_printing) {
frm.set_df_property("html", "reqd", 1);
}
if (frappe.model.can_read(frm.doc.doc_type)) {
if (frappe.model.can_write("Customize Form")) {
frappe.db.get_value("DocType", frm.doc.doc_type, "default_print_format", (r) => {
if (r.default_print_format != frm.doc.name) {
frm.add_custom_button(__("Set as Default"), function () {

View file

@ -136,3 +136,44 @@ import "air-datepicker/dist/js/i18n/datepicker.zh.js";
firstDay: 1,
};
})(jQuery);
(function ($) {
$.fn.datepicker.language["tr"] = {
days: ["Pazar", "Pazartesi", "Salı", "Çarşamba", "Perşembe", "Cuma", "Cumartesi"],
daysShort: ["Paz", "Pzt", "Sal", "Çar", "Per", "Cum", "Cmt"],
daysMin: ["Pz", "Pt", "Sa", "Ça", "Pe", "Cu", "Ct"],
months: [
"Ocak",
"Şubat",
"Mart",
"Nisan",
"Mayıs",
"Haziran",
"Temmuz",
"Ağustos",
"Eylül",
"Ekim",
"Kasım",
"Aralık",
],
monthsShort: [
"Oca",
"Şub",
"Mar",
"Nis",
"May",
"Haz",
"Tem",
"Ağu",
"Eyl",
"Eki",
"Kas",
"Ara",
],
today: "Bugün",
clear: "Temizle",
dateFormat: "dd.mm.yyyy",
timeFormat: "hh:ii",
firstDay: 1,
};
})(jQuery);

View file

@ -1516,7 +1516,7 @@ frappe.ui.form.Form = class FrappeForm {
if (this.fields_dict[fieldname].grid.grid_rows_by_docname[table_row_name]) {
this.fields_dict[fieldname].grid.grid_rows_by_docname[
table_row_name
].refresh_field(fieldname);
].refresh_field(table_field);
}
} else {
this.refresh_field(fieldname);

View file

@ -580,9 +580,9 @@ export default class Grid {
}
get_filtered_data() {
if (!this.frm) return;
let all_data = this.frm ? this.frm.doc[this.df.fieldname] : this.df.data;
let all_data = this.frm.doc[this.df.fieldname];
if (!all_data) return;
for (const field in this.filter) {
all_data = all_data.filter((data) => {

View file

@ -12,7 +12,8 @@ export default class GridRow {
this.make();
}
make() {
var me = this;
let me = this;
let render_row = true;
this.wrapper = $('<div class="grid-row"></div>');
this.row = $('<div class="data-row row"></div>')
@ -36,9 +37,11 @@ export default class GridRow {
if (this.grid.template && !this.grid.meta.editable_grid) {
this.render_template();
} else {
this.render_row();
render_row = this.render_row();
}
if (!this.render_row) return;
this.set_data();
this.wrapper.appendTo(this.parent);
}
@ -312,6 +315,8 @@ export default class GridRow {
if (this.frm && this.doc) {
$(this.frm.wrapper).trigger("grid-row-render", [this]);
}
return true;
}
make_editable() {
@ -757,11 +762,7 @@ export default class GridRow {
show_search_row() {
// show or remove search columns based on grid rows
this.show_search =
this.frm &&
this.frm.doc &&
this.frm.doc[this.grid.df.fieldname] &&
this.frm.doc[this.grid.df.fieldname].length >= 20;
this.show_search = this.show_search && this.grid?.data?.length >= 20;
!this.show_search && this.wrapper.remove();
return this.show_search;
}

View file

@ -146,6 +146,10 @@ export default class BulkOperations {
.call({
method: "frappe.desk.reportview.delete_items",
freeze: true,
freeze_message:
docnames.length <= 10
? __("Deleting {0} records...", [docnames.length])
: null,
args: {
items: docnames,
doctype: this.doctype,

View file

@ -584,7 +584,9 @@ frappe.views.ListView = class ListView extends frappe.views.BaseList {
</div>
`);
this.setup_new_doc_event();
this.list_sidebar && this.list_sidebar.reload_stats();
if (this.list_view_settings && !this.list_view_settings.disable_sidebar_stats) {
this.list_sidebar && this.list_sidebar.reload_stats();
}
this.toggle_paging && this.$paging_area.toggle(true);
}

View file

@ -28,6 +28,8 @@ frappe.ui.Dialog = class Dialog extends frappe.ui.FieldGroup {
this.get_close_btn().hide();
}
if (!this.size) this.set_modal_size();
this.wrapper = this.$wrapper.find(".modal-dialog").get(0);
if (this.size == "small") $(this.wrapper).addClass("modal-sm");
else if (this.size == "large") $(this.wrapper).addClass("modal-lg");
@ -123,6 +125,31 @@ frappe.ui.Dialog = class Dialog extends frappe.ui.FieldGroup {
});
}
set_modal_size() {
if (!this.fields) {
this.size = "";
return;
}
let col_brk = 0;
let cur_col_brk = 0;
// if fields have more than 2 Column Breaks before encountering Section Break, make it large
this.fields.forEach((field) => {
if (field.fieldtype == "Column Break") {
cur_col_brk++;
if (cur_col_brk > col_brk) {
col_brk = cur_col_brk;
}
} else if (field.fieldtype == "Section Break") {
cur_col_brk = 0;
}
});
this.size = col_brk >= 4 ? "extra-large" : col_brk >= 2 ? "large" : "";
}
get_primary_btn() {
return this.standard_actions.find(".btn-primary");
}

View file

@ -424,26 +424,19 @@ kbd {
background-color: var(--bg-color);
.freeze-message-container {
inset: 0;
padding: 3rem;
background-color: var(--bg-light-gray);
color: var(--text-on-light-gray);
font-size: larger;
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
display: grid;
place-content: center;
}
.freeze-message {
color: var(--text-color) !important;
}
}
#freeze.dark {
background-color: var(--gray-900);
}
#freeze.in {
opacity: 0.5;
opacity: 0.8;
}
.msg-box {

View file

@ -1,5 +1,5 @@
import frappe
from frappe.boot import get_unseen_notes
from frappe.boot import get_unseen_notes, get_user_pages_or_reports
from frappe.desk.doctype.note.note import mark_as_seen
from frappe.tests.utils import FrappeTestCase
@ -26,3 +26,47 @@ class TestBootData(FrappeTestCase):
mark_as_seen(note.name)
unseen_notes = [d.title for d in get_unseen_notes()]
self.assertListEqual(unseen_notes, [])
def test_get_user_pages_or_reports_with_permission_query(self):
# Create a ToDo custom report with admin user
frappe.set_user("Administrator")
frappe.get_doc(
{
"doctype": "Report",
"ref_doctype": "ToDo",
"report_name": "Test Admin Report",
"report_type": "Report Builder",
"is_standard": "No",
}
).insert()
# Add permission query such that each user can only see their own custom reports
frappe.get_doc(
dict(
doctype="Server Script",
name="test_report_permission_query",
script_type="Permission Query",
reference_doctype="Report",
script="""conditions = f"(`tabReport`.is_standard = 'Yes' or `tabReport`.owner = '{frappe.session.user}')"
""",
)
).insert()
# Create a ToDo custom report with test user
frappe.set_user("test@example.com")
frappe.get_doc(
{
"doctype": "Report",
"ref_doctype": "ToDo",
"report_name": "Test User Report",
"report_type": "Report Builder",
"is_standard": "No",
}
).insert(ignore_permissions=True)
get_user_pages_or_reports("Report")
allowed_reports = frappe.cache().get_value("has_role:Report", user=frappe.session.user)
# Test user must not see admin user's report
self.assertNotIn("Test Admin Report", allowed_reports)
self.assertIn("Test User Report", allowed_reports)

View file

@ -960,3 +960,16 @@ class TestTypingValidations(FrappeTestCase):
report.toggle_disable(changed_value)
report.toggle_disable(current_value)
class TestTBSanitization(FrappeTestCase):
def test_traceback_sanitzation(self):
try:
password = "42"
args = {"password": "42", "pwd": "42", "safe": "safe_value"}
raise Exception
except Exception:
traceback = frappe.get_traceback(with_context=True)
self.assertNotIn("42", traceback)
self.assertIn("********", traceback)
self.assertIn("safe_value", traceback)

View file

@ -55,12 +55,14 @@ class FrappeTestCase(unittest.TestCase):
else:
self._compare_field(value, actual.get(field), actual, field)
def _compare_field(self, expected, actual, doc, field):
def _compare_field(self, expected, actual, doc: BaseDocument, field: str):
msg = f"{field} should be same."
if isinstance(expected, float):
precision = doc.precision(field)
self.assertAlmostEqual(expected, actual, f"{field} should be same to {precision} digits")
self.assertAlmostEqual(
expected, actual, places=precision, msg=f"{field} should be same to {precision} digits"
)
elif isinstance(expected, (bool, int)):
self.assertEqual(expected, cint(actual), msg=msg)
elif isinstance(expected, datetime_like_types):

View file

@ -24,7 +24,6 @@ from typing import Any, Literal
from urllib.parse import quote, urlparse
from redis.exceptions import ConnectionError
from traceback_with_variables import iter_exc_lines
from werkzeug.test import Client
import frappe
@ -298,13 +297,15 @@ def get_traceback(with_context=False) -> str:
"""
Returns the traceback of the Exception
"""
from traceback_with_variables import iter_exc_lines
exc_type, exc_value, exc_tb = sys.exc_info()
if not any([exc_type, exc_value, exc_tb]):
return ""
if with_context:
trace_list = iter_exc_lines()
trace_list = iter_exc_lines(fmt=_get_traceback_sanitizer())
tb = "\n".join(trace_list)
else:
trace_list = traceback.format_exception(exc_type, exc_value, exc_tb)
@ -314,6 +315,44 @@ def get_traceback(with_context=False) -> str:
return tb.replace(bench_path, "")
@functools.lru_cache(maxsize=1)
def _get_traceback_sanitizer():
from traceback_with_variables import Format
blocklist = [
"password",
"passwd",
"secret",
"token",
"key",
"pwd",
]
placeholder = "********"
def dict_printer(v: dict) -> str:
from copy import deepcopy
v = deepcopy(v)
for key in blocklist:
if key in v:
v[key] = placeholder
return str(v)
# Adapted from https://github.com/andy-landy/traceback_with_variables/blob/master/examples/format_customized.py
# Reused under MIT license: https://github.com/andy-landy/traceback_with_variables/blob/master/LICENSE
return Format(
custom_var_printers=[
# redact variables
*[(variable_name, lambda: placeholder) for variable_name in blocklist],
# redact dictionary keys
(["_secret", dict, lambda *a, **kw: False], dict_printer),
],
)
def log(event, details):
frappe.logger(event).info(details)

View file

@ -12,7 +12,7 @@ import pydoc
import sys
import traceback
from ldap3.core.exceptions import LDAPInvalidCredentialsResult
from ldap3.core.exceptions import LDAPException
import frappe
from frappe.utils import cstr, encode
@ -21,7 +21,7 @@ EXCLUDE_EXCEPTIONS = (
frappe.AuthenticationError,
frappe.CSRFTokenError, # CSRF covers OAuth too
frappe.SecurityException,
LDAPInvalidCredentialsResult,
LDAPException,
)

View file

@ -1,6 +1,7 @@
# imports - standard imports
import logging
import os
from copy import deepcopy
from logging.handlers import RotatingFileHandler
from typing import Literal
@ -91,7 +92,7 @@ class SiteContextFilter(logging.Filter):
def filter(self, record) -> bool:
if "Form Dict" not in str(record.msg):
site = getattr(frappe.local, "site", None)
form_dict = getattr(frappe.local, "form_dict", None)
form_dict = sanitized_dict(getattr(frappe.local, "form_dict", None))
record.msg = str(record.msg) + f"\nSite: {site}\nForm Dict: {form_dict}"
return True
@ -100,3 +101,25 @@ def set_log_level(level: Literal["ERROR", "WARNING", "WARN", "INFO", "DEBUG"]) -
"""Use this method to set log level to something other than the default DEBUG"""
frappe.log_level = getattr(logging, (level or "").upper(), None) or default_log_level
frappe.loggers = {}
def sanitized_dict(form_dict):
if not isinstance(form_dict, dict):
return form_dict
sanitized_dict = deepcopy(form_dict)
blocklist = [
"password",
"passwd",
"secret",
"token",
"key",
"pwd",
]
for k in sanitized_dict:
for secret_kw in blocklist:
if secret_kw in k:
sanitized_dict[k] = "********"
return sanitized_dict

View file

@ -45,7 +45,7 @@ class RedisWrapper(redis.Redis):
return f"{frappe.conf.db_name}|{key}".encode()
def set_value(self, key, val, user=None, expires_in_sec=None, shared=False, cache_locally=True):
def set_value(self, key, val, user=None, expires_in_sec=None, shared=False):
"""Sets cache value.
:param key: Cache key
@ -55,7 +55,7 @@ class RedisWrapper(redis.Redis):
"""
key = self.make_key(key, user, shared)
if not expires_in_sec and cache_locally:
if not expires_in_sec:
frappe.local.cache[key] = val
try:
@ -169,7 +169,6 @@ class RedisWrapper(redis.Redis):
key: str,
value,
shared: bool = False,
cache_locally: bool = True,
*args,
**kwargs,
):
@ -179,8 +178,7 @@ class RedisWrapper(redis.Redis):
_name = self.make_key(name, shared=shared)
# set in local
if cache_locally:
frappe.local.cache.setdefault(_name, {})[key] = value
frappe.local.cache.setdefault(_name, {})[key] = value
# set in redis
try:

View file

@ -1,40 +1,54 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
# License: MIT. See LICENSE
import os
from contextlib import contextmanager
import frappe
from frappe.tests.utils import FrappeTestCase
from frappe.website.doctype.website_theme.website_theme import (
after_migrate,
get_active_theme,
get_scss_paths,
)
from .website_theme import get_scss_paths
@contextmanager
def website_theme_fixture(**theme):
test_theme = "test-theme"
frappe.delete_doc_if_exists("Website Theme", test_theme)
theme = frappe.get_doc(doctype="Website Theme", theme=test_theme, **theme)
theme.insert()
yield theme
frappe.db.set_single_value("Website Settings", "website_theme", "Standard")
theme.delete()
class TestWebsiteTheme(FrappeTestCase):
def test_website_theme(self):
frappe.delete_doc_if_exists("Website Theme", "test-theme")
theme = frappe.get_doc(
dict(
doctype="Website Theme",
theme="test-theme",
google_font="Inter",
custom_scss="body { font-size: 16.5px; }", # this will get minified!
)
).insert()
with website_theme_fixture(
google_font="Inter",
custom_scss="body { font-size: 16.5px; }", # this will get minified!
) as theme:
theme_path = frappe.get_site_path("public", theme.theme_url[1:])
with open(theme_path) as theme_file:
css = theme_file.read()
theme_path = frappe.get_site_path("public", theme.theme_url[1:])
with open(theme_path) as theme_file:
css = theme_file.read()
self.assertTrue("body{font-size:16.5px}" in css)
self.assertTrue("fonts.googleapis.com" in css)
self.assertTrue("body{font-size:16.5px}" in css)
self.assertTrue("fonts.googleapis.com" in css)
def test_get_scss_paths(self):
self.assertIn("frappe/public/scss/website.bundle", get_scss_paths())
def test_imports_to_ignore(self):
frappe.delete_doc_if_exists("Website Theme", "test-theme")
theme = frappe.get_doc(
dict(doctype="Website Theme", theme="test-theme", ignored_apps=[{"app": "frappe"}])
).insert()
with website_theme_fixture(ignored_apps=[{"app": "frappe"}]) as theme:
self.assertTrue('@import "frappe/public/scss/website"' not in theme.theme_scss)
self.assertTrue('@import "frappe/public/scss/website"' not in theme.theme_scss)
def test_after_migrate_hook(self):
with website_theme_fixture(google_font="Inter") as theme:
theme.set_as_default()
before = get_active_theme().theme_url
after_migrate()
after = get_active_theme().theme_url
self.assertNotEqual(before, after)

View file

@ -99,18 +99,8 @@ class WebsiteTheme(Document):
if fname.startswith(frappe.scrub(self.name) + "_") and fname.endswith(".css"):
os.remove(os.path.join(folder_path, fname))
def generate_theme_if_not_exist(self):
bench_path = frappe.utils.get_bench_path()
if self.theme_url:
theme_path = join_path(bench_path, "sites", self.theme_url[1:])
if not path_exists(theme_path):
self.generate_bootstrap_theme()
else:
self.generate_bootstrap_theme()
@frappe.whitelist()
def set_as_default(self):
self.generate_bootstrap_theme()
self.save()
website_settings = frappe.get_doc("Website Settings")
website_settings.website_theme = self.name
@ -133,6 +123,7 @@ def get_active_theme() -> Optional["WebsiteTheme"]:
try:
return frappe.get_cached_doc("Website Theme", website_theme)
except frappe.DoesNotExistError:
frappe.clear_last_message()
pass
@ -187,5 +178,4 @@ def after_migrate():
return
doc = frappe.get_doc("Website Theme", website_theme)
doc.generate_bootstrap_theme()
doc.save()
doc.save() # Just re-saving re-generates the theme.

View file

@ -41,7 +41,7 @@
"fast-deep-equal": "^2.0.1",
"fast-glob": "^3.2.5",
"frappe-charts": "2.0.0-rc22",
"frappe-datatable": "^1.16.4",
"frappe-datatable": "^1.17.1",
"frappe-gantt": "^0.6.0",
"highlight.js": "^10.4.1",
"html5-qrcode": "^2.0.11",

View file

@ -1435,10 +1435,10 @@ frappe-charts@2.0.0-rc22:
resolved "https://registry.yarnpkg.com/frappe-charts/-/frappe-charts-2.0.0-rc22.tgz#9a5a747febdc381a1d4d7af96e89cf519dfba8c0"
integrity sha512-N7f/8979wJCKjusOinaUYfMxB80YnfuVLrSkjpj4LtyqS0BGS6SuJxUnb7Jl4RWUFEIs7zEhideIKnyLeFZF4Q==
frappe-datatable@^1.16.4:
version "1.16.4"
resolved "https://registry.yarnpkg.com/frappe-datatable/-/frappe-datatable-1.16.4.tgz#cb26f197c3cd404a5b13f016ef81c394e06f56fe"
integrity sha512-VoiTLnkuObMa3FxITrvP32UYN9v4WQ0j4qlCiDuqdXha9/BVSxwDt2BTK+cvaRloGcds5G2Hm9IRbltRRGGhxA==
frappe-datatable@^1.17.1:
version "1.17.1"
resolved "https://registry.yarnpkg.com/frappe-datatable/-/frappe-datatable-1.17.1.tgz#795ee79a420df07b963b7decf489045d5993cc0b"
integrity sha512-qqvmsaYbQUwCAtGnhmTN8jrdvXW6YfRLTZS6ufb3b1ibFEMUbE04rEFJF7TJRd2ugSk80seS2OPGTZGw+V2b0A==
dependencies:
hyperlist "^1.0.0-beta"
lodash "^4.17.5"