Merge branch 'develop' into feature/force-web-capture-setting

This commit is contained in:
Dirk van der Laarse 2023-06-25 07:21:58 +02:00 committed by GitHub
commit bffc1af985
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
91 changed files with 781 additions and 308 deletions

View file

@ -54,6 +54,8 @@ fi
echo "Starting Bench..."
export FRAPPE_TUNE_GC=True
bench start &> ~/frappe-bench/bench_start.log &
if [ "$TYPE" == "server" ]

View file

@ -62,9 +62,11 @@ jobs:
fi
- name: Setup Python
uses: "gabrielfalcao/pyenv-action@v10"
uses: actions/setup-python@v4
with:
versions: 3.10:latest, 3.7:latest
python-version: |
3.7
3.10
- name: Setup Node
uses: actions/setup-node@v3
@ -100,7 +102,6 @@ jobs:
run: |
bash ${GITHUB_WORKSPACE}/.github/helper/install_dependencies.sh
pip install frappe-bench
pyenv global $(pyenv versions | grep '3.10')
bash ${GITHUB_WORKSPACE}/.github/helper/install.sh
env:
BEFORE: ${{ env.GITHUB_EVENT_PATH.before }}
@ -120,25 +121,25 @@ jobs:
function update_to_version() {
version=$1
py=$2
branch_name="version-$version-hotfix"
echo "Updating to v$version"
git fetch --depth 1 upstream $branch_name:$branch_name
git checkout -q -f $branch_name
pip install -U frappe-bench
pgrep honcho | xargs kill
rm -rf ~/frappe-bench/env
bench -v setup env
bench -v setup env --python $py
bench start &> ~/frappe-bench/bench_start.log &
bench --site test_site migrate
}
pyenv global $(pyenv versions | grep '3.7')
update_to_version 12
update_to_version 13
update_to_version 12 python3.7
update_to_version 13 python3.7
pyenv global $(pyenv versions | grep '3.10')
update_to_version 14
update_to_version 14 python3.10
echo "Updating to last commit"
rm -rf ~/frappe-bench/env

0
.semgrepignore Normal file
View file

View file

@ -8,6 +8,8 @@ module.exports = defineConfig({
pageLoadTimeout: 15000,
video: true,
videoUploadOnPasses: false,
viewportHeight: 960,
viewportWidth: 1400,
retries: {
runMode: 2,
openMode: 2,

View file

@ -7,50 +7,41 @@ context("Awesome Bar", () => {
beforeEach(() => {
cy.get(".navbar .navbar-home").click();
cy.findByPlaceholderText("Search or type a command (Ctrl + G)").clear();
cy.findByPlaceholderText("Search or type a command (Ctrl + G)").as("awesome_bar");
cy.get("@awesome_bar").type("{selectall}");
});
it("navigates to doctype list", () => {
cy.findByPlaceholderText("Search or type a command (Ctrl + G)").type("todo", {
delay: 700,
});
cy.get("@awesome_bar").type("todo");
cy.wait(100);
cy.get(".awesomplete").findByRole("listbox").should("be.visible");
cy.findByPlaceholderText("Search or type a command (Ctrl + G)").type("{enter}", {
delay: 700,
});
cy.get("@awesome_bar").type("{enter}");
cy.get(".title-text").should("contain", "To Do");
cy.location("pathname").should("eq", "/app/todo");
});
it("find text in doctype list", () => {
cy.findByPlaceholderText("Search or type a command (Ctrl + G)").type(
"test in todo{enter}",
{ delay: 700 }
);
cy.get("@awesome_bar").type("test in todo");
cy.wait(100);
cy.get("@awesome_bar").type("{enter}");
cy.get(".title-text").should("contain", "To Do");
cy.findByPlaceholderText("ID").should("have.value", "%test%");
cy.wait(200);
const name_filter = cy.findByPlaceholderText("ID");
name_filter.should("have.value", "%test%");
cy.clear_filters();
});
it("navigates to new form", () => {
cy.findByPlaceholderText("Search or type a command (Ctrl + G)").type(
"new blog post{enter}",
{ delay: 700 }
);
cy.get("@awesome_bar").type("new blog post");
cy.wait(100);
cy.get("@awesome_bar").type("{enter}");
cy.get(".title-text:visible").should("have.text", "New Blog Post");
});
it("calculates math expressions", () => {
cy.findByPlaceholderText("Search or type a command (Ctrl + G)").type(
"55 + 32{downarrow}{enter}",
{ delay: 700 }
);
cy.get("@awesome_bar").type("55 + 32");
cy.wait(100);
cy.get("@awesome_bar").type("{downarrow}{enter}");
cy.get(".modal-title").should("contain", "Result");
cy.get(".msgprint").should("contain", "55 + 32 = 87");
});

View file

@ -42,14 +42,14 @@ context("Control Icon", () => {
it("search for icon and clear search input", () => {
let search_text = "ed";
cy.get(".icon-picker").findByRole("searchbox").click().type(search_text);
cy.get(".icon-picker").get(".search-icons > input").click().type(search_text);
cy.get(".icon-section .icon-wrapper:not(.hidden)").then((i) => {
cy.get(`.icon-section .icon-wrapper[id*='${search_text}']`).then((icons) => {
expect(i.length).to.equal(icons.length);
});
});
cy.get(".icon-picker").findByRole("searchbox").clear().blur();
cy.get(".icon-picker").get(".search-icons > input").clear().blur();
cy.get(".icon-section .icon-wrapper").should("not.have.class", "hidden");
});
});

View file

@ -133,8 +133,7 @@ context("Control Link", () => {
true
);
cy.clear_cache();
cy.wait(500);
cy.reload();
get_dialog_with_link().as("dialog");
cy.window()
@ -177,7 +176,7 @@ context("Control Link", () => {
cy.intercept("POST", "/api/method/frappe.client.validate_link").as("validate_link");
cy.get(".frappe-control[data-fieldname=assigned_by] input").focus().as("input");
cy.get("@input").type(cy.config("testUser"), { delay: 100 }).blur();
cy.get("@input").clear().type(cy.config("testUser"), { delay: 300 }).blur();
cy.wait("@validate_link");
cy.get(".frappe-control[data-fieldname=assigned_by_full_name] .control-value").should(
"contain",

View file

@ -47,7 +47,7 @@ context("Control Phone", () => {
it("case insensitive search for country and clear search", () => {
let search_text = "india";
cy.get(".selected-phone").click().first();
cy.get(".phone-picker").findByRole("searchbox").click().type(search_text);
cy.get(".phone-picker").get(".search-phones").click().type(search_text);
cy.get(".phone-section .phone-wrapper:not(.hidden)").then((i) => {
cy.get(`.phone-section .phone-wrapper[id*="${search_text.toLowerCase()}"]`).then(
(countries) => {
@ -56,7 +56,7 @@ context("Control Phone", () => {
);
});
cy.get(".phone-picker").findByRole("searchbox").clear().blur();
cy.get(".phone-picker").get(".search-phones").clear();
cy.get(".phone-section .phone-wrapper").should("not.have.class", "hidden");
});

View file

@ -11,9 +11,9 @@ context("Folder Navigation", () => {
cy.click_filter_button();
cy.get(".filter-action-buttons > .text-muted").findByText("+ Add a Filter").click();
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}");
cy.get(".filter-field > .form-group > .link-field > .awesomplete > .input-with-feedback")
.first()
.type("Home{enter}");
cy.get(".filter-action-buttons > div > .btn-primary").findByText("Apply Filters").click();
//Adding folder (Test Folder)
@ -24,6 +24,7 @@ context("Folder Navigation", () => {
it("Navigating the nested folders, checking if the URL formed is correct, checking if the added content in the child folder is correct", () => {
//Navigating inside the Attachments folder
cy.clear_filters();
cy.wait(500);
cy.get('[title="Attachments"] > span').click();

View file

@ -100,15 +100,15 @@ context("Kanban Board", () => {
it("Checks if Kanban Board edits are blocked for non-System Manager and non-owner of the Board", () => {
cy.switch_to_user("Administrator");
const noSystemManager = "nosysmanager@example.com";
const not_system_manager = "nosysmanager@example.com";
cy.call("frappe.tests.ui_test_helpers.create_test_user", {
username: noSystemManager,
username: not_system_manager,
});
cy.remove_role(noSystemManager, "System Manager");
cy.remove_role(not_system_manager, "System Manager");
cy.call("frappe.tests.ui_test_helpers.create_todo", { description: "Frappe User ToDo" });
cy.call("frappe.tests.ui_test_helpers.create_admin_kanban");
cy.switch_to_user(noSystemManager);
cy.switch_to_user(not_system_manager);
cy.visit("/app/todo/view/kanban/Admin Kanban");
@ -125,7 +125,7 @@ context("Kanban Board", () => {
cy.get(".kanban .column-options").should("have.length", 0);
cy.switch_to_user("Administrator");
cy.call("frappe.client.delete", { doctype: "User", name: noSystemManager });
cy.call("frappe.client.delete", { doctype: "User", name: not_system_manager });
});
after(() => {

View file

@ -1,21 +1,26 @@
context("Navigation", () => {
before(() => {
cy.visit("/login");
cy.login();
cy.visit("/app/website");
});
it("Navigate to route with hash in document name", () => {
cy.insert_doc("ToDo", {
__newname: "ABC#123",
description: "Test this",
ignore_duplicate: true,
});
cy.visit("/app/todo/ABC#123");
cy.insert_doc(
"ToDo",
{
__newname: "ABC#123",
description: "Test this",
},
true
);
cy.visit(`/app/todo/${encodeURIComponent("ABC#123")}`);
cy.title().should("eq", "Test this - ABC#123");
cy.get_field("description", "Text Editor").contains("Test this");
cy.go("back");
cy.title().should("eq", "Website");
});
it.only("Navigate to previous page after login", () => {
it("Navigate to previous page after login", () => {
cy.visit("/app/todo");
cy.get(".page-head").findByTitle("To Do").should("be.visible");
cy.clear_filters();

View file

@ -34,7 +34,7 @@ Cypress.Commands.add("login", (email, password) => {
if (!password) {
password = Cypress.env("adminPassword");
}
cy.request({
return cy.request({
url: "/api/method/login",
method: "POST",
body: {
@ -373,7 +373,9 @@ Cypress.Commands.add("update_doc", (doctype, docname, args) => {
Cypress.Commands.add("switch_to_user", (user) => {
cy.call("logout");
cy.wait(200);
cy.login(user);
cy.reload();
});
Cypress.Commands.add("add_role", (user, role) => {

View file

@ -89,7 +89,7 @@ execute()
.then(() => RUN_BUILD_COMMAND && run_build_command_for_apps(APPS))
.catch((e) => {
console.error(e);
throw e;
process.exit(1);
});
if (WATCH_MODE) {

View file

@ -11,6 +11,7 @@ be used to build database driven apps.
Read the documentation: https://frappeframework.com/docs
"""
import functools
import gc
import importlib
import inspect
import json
@ -57,6 +58,7 @@ re._MAXCACHE = (
50 # reduced from default 512 given we are already maintaining this on parent worker
)
_tune_gc = bool(os.environ.get("FRAPPE_TUNE_GC", False))
if _dev_server:
warnings.simplefilter("always", DeprecationWarning)
@ -2418,4 +2420,30 @@ def mock(type, size=1, locale="en"):
return squashify(results)
from frappe.desk.search import validate_and_sanitize_search_inputs # noqa
def validate_and_sanitize_search_inputs(fn):
@functools.wraps(fn)
def wrapper(*args, **kwargs):
from frappe.desk.search import sanitize_searchfield
from frappe.utils import cint
kwargs.update(dict(zip(fn.__code__.co_varnames, args)))
sanitize_searchfield(kwargs["searchfield"])
kwargs["start"] = cint(kwargs["start"])
kwargs["page_len"] = cint(kwargs["page_len"])
if kwargs["doctype"] and not db.exists("DocType", kwargs["doctype"]):
return []
return fn(**kwargs)
return wrapper
if _tune_gc:
# generational GC gets triggered after certain allocs (g0) which is 700 by default.
# This number is quite small for frappe where a single query can potentially create 700+
# objects easily.
# Bump this number higher, this will make GC less aggressive but that improves performance of
# everything else.
g0, g1, g2 = gc.get_threshold() # defaults are 700, 10, 10.
gc.set_threshold(g0 * 10, g1 * 2, g2 * 2)

View file

@ -1,6 +1,7 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
# License: MIT. See LICENSE
import gc
import logging
import os
@ -30,6 +31,30 @@ _site = None
_sites_path = os.environ.get("SITES_PATH", ".")
# If gc.freeze is done then importing modules before forking allows us to share the memory
if frappe._tune_gc:
import frappe.boot
import frappe.client
import frappe.core.doctype.user.user
import frappe.database.mariadb.database # Load database related utils
import frappe.database.query
import frappe.desk.desktop # workspace
import frappe.model.db_query
import frappe.query_builder
import frappe.utils.background_jobs # Enqueue is very common
import frappe.utils.data # common utils
import frappe.utils.jinja # web page rendering
import frappe.utils.jinja_globals
import frappe.utils.redis_wrapper # Exact redis_wrapper
import frappe.utils.safe_exec
import frappe.utils.typing_validations # any whitelisted method uses this
import frappe.website.path_resolver # all the page types and resolver
import frappe.website.router # Website router
import frappe.website.website_generator # web page doctypes
# end: module pre-loading
@local_manager.middleware
@Request.application
def application(request: Request):
@ -394,3 +419,17 @@ def serve(
use_evalex=not in_test_env,
threaded=not no_threading,
)
# Both Gunicorn and RQ use forking to spawn workers. In an ideal world, the fork should be sharing
# most of the memory if there are no writes made to data because of Copy on Write, however,
# python's GC is not CoW friendly and writes to data even if user-code doesn't. Specifically, the
# generational GC which stores and mutates every python object: `PyGC_Head`
#
# Calling gc.freeze() moves all the objects imported so far into permanant generation and hence
# doesn't mutate `PyGC_Head`
#
# Refer to issue for more info: https://github.com/frappe/frappe/issues/18927
if frappe._tune_gc:
gc.collect() # clean up any garbage created so far before freeze
gc.freeze()

View file

@ -21,7 +21,6 @@ from frappe.social.doctype.energy_point_log.energy_point_log import get_energy_p
from frappe.social.doctype.energy_point_settings.energy_point_settings import (
is_energy_point_enabled,
)
from frappe.translate import get_lang_dict, get_messages_for_boot, get_translated_doctypes
from frappe.utils import add_user_info, cstr, get_system_timezone
from frappe.utils.change_log import get_versions
from frappe.website.doctype.web_page_view.web_page_view import is_tracking_enabled
@ -29,6 +28,8 @@ from frappe.website.doctype.web_page_view.web_page_view import is_tracking_enabl
def get_bootinfo():
"""build and return boot info"""
from frappe.translate import get_lang_dict, get_translated_doctypes
frappe.set_user_lang(frappe.session.user)
bootinfo = frappe._dict()
hooks = frappe.get_hooks()
@ -257,6 +258,8 @@ def get_user_pages_or_reports(parent, cache=False):
def load_translations(bootinfo):
from frappe.translate import get_messages_for_boot
bootinfo["lang"] = frappe.lang
bootinfo["__messages"] = get_messages_for_boot()

View file

@ -9,9 +9,6 @@ from tempfile import mkdtemp, mktemp
from urllib.parse import urlparse
import click
import psutil
from requests import head
from requests.exceptions import HTTPError
from semantic_version import Version
import frappe
@ -27,7 +24,7 @@ class AssetsNotDownloadedError(Exception):
pass
class AssetsDontExistError(HTTPError):
class AssetsDontExistError(Exception):
pass
@ -78,6 +75,8 @@ def build_missing_files():
def get_assets_link(frappe_head) -> str:
import requests
tag = getoutput(
r"cd ../apps/frappe && git show-ref --tags -d | grep %s | sed -e 's,.*"
r" refs/tags/,,' -e 's/\^{}//'" % frappe_head
@ -89,7 +88,7 @@ def get_assets_link(frappe_head) -> str:
else:
url = f"http://assets.frappeframework.com/{frappe_head}.tar.gz"
if not head(url):
if not requests.head(url):
reference = f"Release {tag}" if tag else f"Commit {frappe_head}"
raise AssetsDontExistError(f"Assets for {reference} don't exist")
@ -288,6 +287,8 @@ def get_node_env():
def get_safe_max_old_space_size():
import psutil
safe_max_old_space_size = 0
try:
total_memory = psutil.virtual_memory().total / (1024 * 1024)

View file

@ -1,10 +1,7 @@
# Copyright (c) 2018, Frappe Technologies Pvt. Ltd. and Contributors
# License: MIT. See LICENSE
import json
import frappe
from frappe.desk.notifications import clear_notifications, delete_notification_count_for
common_default_keys = ["__default", "__global"]
@ -79,6 +76,8 @@ doctype_cache_keys = (
def clear_user_cache(user=None):
from frappe.desk.notifications import clear_notifications
# this will automatically reload the global cache
# so it is important to clear this first
clear_notifications(user)
@ -128,6 +127,8 @@ def clear_doctype_cache(doctype=None):
def _clear_doctype_cache_form_redis(doctype: str | None = None):
from frappe.desk.notifications import delete_notification_count_for
for key in ("is_table", "doctype_modules"):
frappe.cache.delete_value(key)

View file

@ -3,7 +3,6 @@ import os
import click
import frappe
from frappe.installer import update_site_config
from frappe.utils.redis_queue import RedisQueue
@ -23,6 +22,8 @@ def create_rq_users(set_admin_password=False, use_rq_auth=False):
acl config file will be used by redis server while starting the server
and app config is used by app while connecting to redis server.
"""
from frappe.installer import update_site_config
acl_file_path = os.path.abspath("../config/redis_queue.acl")
with frappe.init_site():

View file

@ -9,7 +9,6 @@ import click
# imports - module imports
import frappe
from frappe.commands import get_site, pass_context
from frappe.core.doctype.log_settings.log_settings import LOG_DOCTYPES
from frappe.exceptions import SiteNotSpecifiedError
@ -1199,11 +1198,12 @@ def build_search_index(context):
@click.command("clear-log-table")
@click.option("--doctype", required=True, type=click.Choice(LOG_DOCTYPES), help="Log DocType")
@click.option("--doctype", required=True, type=str, help="Log DocType")
@click.option("--days", type=int, help="Keep records for days")
@click.option("--no-backup", is_flag=True, default=False, help="Do not backup the table")
@pass_context
def clear_log_table(context, doctype, days, no_backup):
"""If any logtype table grows too large then clearing it with DELETE query
is not feasible in reasonable time. This command copies recent data to new
table and replaces current table with new smaller table.
@ -1211,6 +1211,7 @@ def clear_log_table(context, doctype, days, no_backup):
ref: https://mariadb.com/kb/en/big-deletes/#deleting-more-than-half-a-table
"""
from frappe.core.doctype.log_settings.log_settings import LOG_DOCTYPES
from frappe.core.doctype.log_settings.log_settings import clear_log_table as clear_logs
from frappe.utils.backups import scheduled_backup

View file

@ -102,10 +102,28 @@ def import_translations(context, lang, path):
frappe.destroy()
@click.command("migrate-translations")
@click.argument("source-app")
@click.argument("target-app")
@pass_context
def migrate_translations(context, source_app, target_app):
"Migrate target-app-specific translations from source-app to target-app"
import frappe.translate
site = get_site(context)
try:
frappe.init(site=site)
frappe.connect()
frappe.translate.migrate_translations(source_app, target_app)
finally:
frappe.destroy()
commands = [
build_message_files,
get_untranslated,
import_translations,
new_language,
update_translations,
migrate_translations,
]

View file

@ -386,7 +386,14 @@ def import_doc(context, path, force=False):
@click.command("data-import")
@click.option(
"--file", "file_path", type=click.Path(), required=True, help="Path to import file (.csv, .xlsx)"
"--file",
"file_path",
type=click.Path(exists=True, dir_okay=False, resolve_path=True),
required=True,
help=(
"Path to import file (.csv, .xlsx)."
"Consider that relative paths will resolve from 'sites' directory"
),
)
@click.option("--doctype", type=str, required=True)
@click.option(

View file

@ -0,0 +1,44 @@
{
"actions": [],
"allow_rename": 1,
"creation": "2023-06-16 17:57:36.604672",
"doctype": "DocType",
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
"document_type",
"action"
],
"fields": [
{
"default": "Amend Counter",
"fieldname": "action",
"fieldtype": "Select",
"in_list_view": 1,
"label": "Action",
"options": "Amend Counter\nDefault Naming",
"reqd": 1
},
{
"fieldname": "document_type",
"fieldtype": "Link",
"in_list_view": 1,
"label": "DocType",
"options": "DocType",
"reqd": 1,
"unique": 1
}
],
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2023-06-16 18:26:16.247475",
"modified_by": "Administrator",
"module": "Core",
"name": "Amended Document Naming Settings",
"owner": "Administrator",
"permissions": [],
"sort_field": "modified",
"sort_order": "DESC",
"states": []
}

View file

@ -0,0 +1,9 @@
# Copyright (c) 2023, Frappe Technologies and contributors
# For license information, please see license.txt
# import frappe
from frappe.model.document import Document
class AmendedDocumentNamingSettings(Document):
pass

View file

@ -579,6 +579,10 @@ class ImportFile:
file_content = None
if self.console:
file_content = frappe.read_file(file_path, True)
return file_content, extn
file_name = frappe.db.get_value("File", {"file_url": file_path})
if file_name:
file = frappe.get_doc("File", file_name)
@ -690,7 +694,7 @@ class Row:
df = col.df
if df.fieldtype == "Select":
select_options = get_select_options(df)
if select_options and value not in select_options:
if select_options and cstr(value) not in select_options:
options_string = ", ".join(frappe.bold(d) for d in select_options)
msg = _("Value must be one of {0}").format(options_string)
self.warnings.append(

View file

@ -67,7 +67,8 @@
"default": "0",
"fieldname": "everyone",
"fieldtype": "Check",
"label": "Everyone"
"label": "Everyone",
"search_index": 1
},
{
"default": "1",
@ -85,10 +86,11 @@
],
"in_create": 1,
"links": [],
"modified": "2021-04-04 11:38:50.813312",
"modified": "2023-06-15 18:02:51.877533",
"modified_by": "Administrator",
"module": "Core",
"name": "DocShare",
"naming_rule": "Random",
"owner": "Administrator",
"permissions": [
{
@ -106,5 +108,6 @@
"read_only": 1,
"sort_field": "modified",
"sort_order": "DESC",
"states": [],
"track_changes": 1
}

View file

@ -2,6 +2,16 @@
// For license information, please see license.txt
frappe.ui.form.on("Document Naming Settings", {
setup: function (frm) {
frm.set_query("document_type", "amend_naming_override", () => {
return {
filters: {
is_submittable: 1,
},
};
});
},
refresh: function (frm) {
frm.trigger("setup_transaction_autocomplete");
frm.disable_save();

View file

@ -18,7 +18,11 @@
"update_series",
"prefix",
"current_value",
"update_series_start"
"update_series_start",
"amended_documents_section",
"default_amend_naming",
"amend_naming_override",
"update_amendment_naming"
],
"fields": [
{
@ -105,13 +109,41 @@
"fieldtype": "Text",
"label": "Preview of generated names",
"read_only": 1
},
{
"collapsible": 1,
"description": "Configure how amended documents will be named.<br>\n\nDefault behaviour is to follow an amend counter which adds a number to the end of the original name indicating the amended version. <br>\n\nDefault Naming will make the amended document to behave same as new documents.",
"fieldname": "amended_documents_section",
"fieldtype": "Section Break",
"label": "Amended Documents"
},
{
"default": "Amend Counter",
"fieldname": "default_amend_naming",
"fieldtype": "Select",
"in_list_view": 1,
"label": "Default Amendment Naming",
"options": "Amend Counter\nDefault Naming",
"reqd": 1
},
{
"fieldname": "amend_naming_override",
"fieldtype": "Table",
"label": "Amendment Naming Override",
"options": "Amended Document Naming Settings"
},
{
"fieldname": "update_amendment_naming",
"fieldtype": "Button",
"label": "Update Amendment Naming",
"options": "update_amendment_rule"
}
],
"hide_toolbar": 1,
"icon": "fa fa-sort-by-order",
"issingle": 1,
"links": [],
"modified": "2023-02-20 13:11:56.662100",
"modified": "2023-06-20 17:47:52.204139",
"modified_by": "Administrator",
"module": "Core",
"name": "Document Naming Settings",

View file

@ -169,6 +169,23 @@ class DocumentNamingSettings(Document):
self.current_value = NamingSeries(self.prefix).get_current_value()
return self.current_value
@frappe.whitelist()
def update_amendment_rule(self):
self.db_set("default_amend_naming", self.default_amend_naming)
existing_overrides = frappe.db.get_all(
"Amended Document Naming Settings",
filters={"name": ["not in", [d.name for d in self.amend_naming_override]]},
pluck="name",
)
for override in existing_overrides:
frappe.delete_doc("Amended Document Naming Settings", override)
for row in self.amend_naming_override:
row.save()
frappe.msgprint(_("Amendment naming rules updated."), indicator="green", alert=True)
@frappe.whitelist()
def update_series_start(self):
frappe.only_for("System Manager")

View file

@ -26,6 +26,7 @@ class TestNamingSeries(FrappeTestCase):
}
],
autoname="naming_series:",
is_submittable=1,
)
.insert()
.name
@ -82,3 +83,36 @@ class TestNamingSeries(FrappeTestCase):
self.dns.update_series_start()
self.assertEqual(self.dns.get_current(), new_count, f"Incorrect update for {series}")
def test_amended_naming(self):
self.dns.amend_naming_override = []
self.dns.default_amend_naming = "Amend Counter"
self.dns.update_amendment_rule()
submittable_doc = frappe.get_doc(
dict(doctype=self.ns_doctype, some_fieldname="test doc with submit")
).submit()
submittable_doc.cancel()
amended_doc = frappe.get_doc(
dict(
doctype=self.ns_doctype,
some_fieldname="test doc with submit",
amended_from=submittable_doc.name,
)
).insert()
self.assertIn(submittable_doc.name, amended_doc.name)
amended_doc.delete()
self.dns.default_amend_naming = "Default Naming"
self.dns.update_amendment_rule()
new_amended_doc = frappe.get_doc(
dict(
doctype=self.ns_doctype,
some_fieldname="test doc with submit",
amended_from=submittable_doc.name,
)
).insert()
self.assertNotIn(submittable_doc.name, new_amended_doc.name)

View file

@ -1,7 +1,10 @@
# Copyright (c) 2015, Frappe Technologies and Contributors
# License: MIT. See LICENSE
from ldap3.core.exceptions import LDAPException, LDAPInappropriateAuthenticationResult
import frappe
from frappe.tests.utils import FrappeTestCase
from frappe.utils.error import _is_ldap_exception
# test_records = frappe.get_test_records('Error Log')
@ -12,3 +15,9 @@ class TestErrorLog(FrappeTestCase):
doc = frappe.new_doc("Error Log")
error = doc.log_error("This is an error")
self.assertEqual(error.doctype, "Error Log")
def test_ldap_exceptions(self):
exc = [LDAPException, LDAPInappropriateAuthenticationResult]
for e in exc:
self.assertTrue(_is_ldap_exception(e()))

View file

@ -124,6 +124,20 @@ class TestRQJob(FrappeTestCase):
frappe.db.commit()
self.assertIsNone(get_job_status(job_id))
@timeout(20)
def test_memory_usage(self):
job = frappe.enqueue("frappe.utils.data._get_rss_memory_usage")
self.check_status(job, "finished")
rss = job.latest_result().return_value
msg = """Memory usage of simple background job increased. Potential root cause can be a newly added python module import. Check and move them to approriate file/function to avoid loading the module by default."""
# If this starts failing analyze memory usage using memray or some equivalent tool to find
# offending imports/function calls.
# Refer this PR: https://github.com/frappe/frappe/pull/21467
LAST_MEASURED_USAGE = 40
self.assertLessEqual(rss, LAST_MEASURED_USAGE * 1.05, msg)
def test_func(fail=False, sleep=0):
if fail:

View file

@ -5,14 +5,13 @@ import frappe
from frappe import _
from frappe.model import no_value_fields
from frappe.model.document import Document
from frappe.translate import set_default_language
from frappe.twofactor import toggle_two_factor_auth
from frappe.utils import cint, today
from frappe.utils.momentjs import get_all_timezones
class SystemSettings(Document):
def validate(self):
from frappe.twofactor import toggle_two_factor_auth
enable_password_policy = cint(self.enable_password_policy) and True or False
minimum_password_score = cint(getattr(self, "minimum_password_score", 0)) or 0
if enable_password_policy and minimum_password_score <= 0:
@ -71,6 +70,8 @@ class SystemSettings(Document):
update_last_reset_password_date()
def set_defaults(self):
from frappe.translate import set_default_language
for df in self.meta.get("fields"):
if df.fieldtype not in no_value_fields and self.has_value_changed(df.fieldname):
frappe.db.set_default(df.fieldname, self.get(df.fieldname))
@ -92,6 +93,8 @@ def update_last_reset_password_date():
@frappe.whitelist()
def load():
from frappe.utils.momentjs import get_all_timezones
if not "System Manager" in frappe.get_roles():
frappe.throw(_("Not permitted"), frappe.PermissionError)

View file

@ -123,8 +123,15 @@ def update(doctype, role, permlevel, ptype, value=None):
Returns:
str: Refresh flag is permission is updated successfully
"""
def clear_cache():
frappe.clear_cache(doctype=doctype)
frappe.only_for("System Manager")
out = update_permission_property(doctype, role, permlevel, ptype, value)
frappe.db.after_commit.add(clear_cache)
return "refresh" if out else None

View file

@ -40,8 +40,9 @@ class CustomField(Document):
# remove special characters from fieldname
self.fieldname = "".join(
filter(lambda x: x.isdigit() or x.isalpha() or "_", cstr(label).replace(" ", "_"))
[c for c in cstr(label).replace(" ", "_") if c.isdigit() or c.isalpha() or c == "_"]
)
self.fieldname = f"custom_{self.fieldname}"
# fieldnames should be lowercase
self.fieldname = self.fieldname.lower()

View file

@ -14,10 +14,11 @@ test_dependencies = ["Custom Field", "Property Setter"]
class TestCustomizeForm(FrappeTestCase):
def insert_custom_field(self):
frappe.delete_doc_if_exists("Custom Field", "Event-test_custom_field")
frappe.get_doc(
frappe.delete_doc_if_exists("Custom Field", "Event-custom_test_field")
self.field = frappe.get_doc(
{
"doctype": "Custom Field",
"fieldname": "custom_test_field",
"dt": "Event",
"label": "Test Custom Field",
"description": "A Custom Field for Testing",
@ -36,7 +37,7 @@ class TestCustomizeForm(FrappeTestCase):
frappe.clear_cache(doctype="Event")
def tearDown(self):
frappe.delete_doc("Custom Field", "Event-test_custom_field")
frappe.delete_doc("Custom Field", self.field.name)
frappe.db.commit()
frappe.clear_cache(doctype="Event")
@ -60,7 +61,7 @@ class TestCustomizeForm(FrappeTestCase):
self.assertEqual(d.doc_type, "Event")
self.assertEqual(len(d.get("fields")), len(frappe.get_doc("DocType", d.doc_type).fields) + 1)
self.assertEqual(d.get("fields")[-1].fieldname, "test_custom_field")
self.assertEqual(d.get("fields")[-1].fieldname, self.field.fieldname)
self.assertEqual(d.get("fields", {"fieldname": "event_type"})[0].in_list_view, 1)
return d
@ -129,21 +130,21 @@ class TestCustomizeForm(FrappeTestCase):
def test_save_customization_custom_field_property(self):
d = self.get_customize_form("Event")
self.assertEqual(frappe.db.get_value("Custom Field", "Event-test_custom_field", "reqd"), 0)
self.assertEqual(frappe.db.get_value("Custom Field", self.field.name, "reqd"), 0)
custom_field = d.get("fields", {"fieldname": "test_custom_field"})[0]
custom_field = d.get("fields", {"fieldname": self.field.fieldname})[0]
custom_field.reqd = 1
custom_field.no_copy = 1
d.run_method("save_customization")
self.assertEqual(frappe.db.get_value("Custom Field", "Event-test_custom_field", "reqd"), 1)
self.assertEqual(frappe.db.get_value("Custom Field", "Event-test_custom_field", "no_copy"), 1)
self.assertEqual(frappe.db.get_value("Custom Field", self.field.name, "reqd"), 1)
self.assertEqual(frappe.db.get_value("Custom Field", self.field.name, "no_copy"), 1)
custom_field = d.get("fields", {"is_custom_field": True})[0]
custom_field.reqd = 0
custom_field.no_copy = 0
d.run_method("save_customization")
self.assertEqual(frappe.db.get_value("Custom Field", "Event-test_custom_field", "reqd"), 0)
self.assertEqual(frappe.db.get_value("Custom Field", "Event-test_custom_field", "no_copy"), 0)
self.assertEqual(frappe.db.get_value("Custom Field", self.field.name, "reqd"), 0)
self.assertEqual(frappe.db.get_value("Custom Field", self.field.name, "no_copy"), 0)
def test_save_customization_new_field(self):
d = self.get_customize_form("Event")
@ -157,28 +158,24 @@ class TestCustomizeForm(FrappeTestCase):
},
)
d.run_method("save_customization")
custom_field_name = "Event-custom_test_add_custom_field_via_customize_form"
self.assertEqual(
frappe.db.get_value(
"Custom Field", "Event-test_add_custom_field_via_customize_form", "fieldtype"
),
frappe.db.get_value("Custom Field", custom_field_name, "fieldtype"),
"Data",
)
self.assertEqual(
frappe.db.get_value(
"Custom Field", "Event-test_add_custom_field_via_customize_form", "insert_after"
),
frappe.db.get_value("Custom Field", custom_field_name, "insert_after"),
last_fieldname,
)
frappe.delete_doc("Custom Field", "Event-test_add_custom_field_via_customize_form")
self.assertEqual(
frappe.db.get_value("Custom Field", "Event-test_add_custom_field_via_customize_form"), None
)
frappe.delete_doc("Custom Field", custom_field_name)
self.assertEqual(frappe.db.get_value("Custom Field", custom_field_name), None)
def test_save_customization_remove_field(self):
d = self.get_customize_form("Event")
custom_field = d.get("fields", {"fieldname": "test_custom_field"})[0]
custom_field = d.get("fields", {"fieldname": self.field.fieldname})[0]
d.get("fields").remove(custom_field)
d.run_method("save_customization")
@ -200,7 +197,7 @@ class TestCustomizeForm(FrappeTestCase):
def test_set_allow_on_submit(self):
d = self.get_customize_form("Event")
d.get("fields", {"fieldname": "subject"})[0].allow_on_submit = 1
d.get("fields", {"fieldname": "test_custom_field"})[0].allow_on_submit = 1
d.get("fields", {"fieldname": "custom_test_field"})[0].allow_on_submit = 1
d.run_method("save_customization")
d = self.get_customize_form("Event")
@ -209,7 +206,7 @@ class TestCustomizeForm(FrappeTestCase):
self.assertEqual(d.get("fields", {"fieldname": "subject"})[0].allow_on_submit or 0, 0)
# allow for custom field
self.assertEqual(d.get("fields", {"fieldname": "test_custom_field"})[0].allow_on_submit, 1)
self.assertEqual(d.get("fields", {"fieldname": "custom_test_field"})[0].allow_on_submit, 1)
def test_title_field_pattern(self):
d = self.get_customize_form("Web Form")
@ -406,7 +403,7 @@ class TestCustomizeForm(FrappeTestCase):
def test_system_generated_fields(self):
doctype = "Event"
custom_field_name = "test_custom_field"
custom_field_name = "custom_test_field"
custom_field = frappe.get_doc("Custom Field", {"dt": doctype, "fieldname": custom_field_name})
custom_field.is_system_generated = 1

View file

@ -17,7 +17,6 @@ from pypika.terms import Criterion, NullValue
import frappe
import frappe.defaults
import frappe.model.meta
from frappe import _
from frappe.database.utils import (
DefaultOrderBy,

View file

@ -3,7 +3,6 @@
import frappe
from frappe.cache_manager import clear_defaults_cache, common_default_keys
from frappe.desk.notifications import clear_notifications
from frappe.query_builder import DocType
# Note: DefaultValue records are identified by parent (e.g. __default, __global)

View file

@ -13,6 +13,10 @@ frappe.ui.form.on("Module Onboarding", {
if (!frappe.boot.developer_mode) {
frm.trigger("disable_form");
}
frm.add_custom_button(__("Reset"), () => {
frm.call("reset_progress");
});
},
disable_form: function (frm) {

View file

@ -2,6 +2,7 @@
# License: MIT. See LICENSE
import frappe
from frappe import _
from frappe.model.document import Document
from frappe.modules.export_file import export_to_files
@ -37,6 +38,16 @@ class ModuleOnboarding(Document):
return False
@frappe.whitelist()
def reset_progress(self):
self.db_set("is_complete", 0)
for step in self.get_steps():
step.db_set("is_complete", 0)
step.db_set("is_skipped", 0)
frappe.msgprint(_("Module onboarding progress reset"), alert=True)
def before_export(self, doc):
doc.is_complete = 0

View file

@ -29,7 +29,8 @@
"fieldtype": "Link",
"hidden": 1,
"label": "For User",
"options": "User"
"options": "User",
"search_index": 1
},
{
"fieldname": "type",
@ -64,8 +65,7 @@
"fieldtype": "Link",
"hidden": 1,
"label": "From User",
"options": "User",
"search_index": 1
"options": "User"
},
{
"default": "0",
@ -96,7 +96,7 @@
"hide_toolbar": 1,
"in_create": 1,
"links": [],
"modified": "2022-09-13 16:08:48.153934",
"modified": "2023-06-14 21:20:51.197943",
"modified_by": "Administrator",
"module": "Desk",
"name": "Notification Log",

View file

@ -9,7 +9,6 @@ from frappe.build import scrub_html_template
from frappe.model.meta import Meta
from frappe.model.utils import render_include
from frappe.modules import get_module_path, load_doctype_module, scrub
from frappe.translate import extract_messages_from_code, make_dict_from_messages
from frappe.utils import get_html_format
from frappe.utils.data import get_link_to_form
@ -36,7 +35,7 @@ ASSET_KEYS = (
def get_meta(doctype, cached=True) -> "FormMeta":
# don't cache for developer mode as js files, templates may be edited
cached = cached and not frappe._dev_server
cached = cached and not frappe.conf.developer_mode
if cached:
meta = frappe.cache.hget("doctype_form_meta", doctype)
if not meta:
@ -260,6 +259,8 @@ class FormMeta(Meta):
self.set("__form_grid_templates", templates)
def set_translations(self, lang):
from frappe.translate import extract_messages_from_code, make_dict_from_messages
self.set("__messages", frappe.get_lang_dict("doctype", self.name))
# set translations for grid templates

View file

@ -16,7 +16,7 @@ from frappe.utils.telemetry import capture_doc
def savedocs(doc, action):
"""save / submit / update doclist"""
doc = frappe.get_doc(json.loads(doc))
capture_doc(doc)
capture_doc(doc, action)
set_local_name(doc)
# action
@ -47,6 +47,8 @@ def savedocs(doc, action):
def cancel(doctype=None, name=None, workflow_state_fieldname=None, workflow_state=None):
"""cancel a doclist"""
doc = frappe.get_doc(doctype, name)
capture_doc(doc, "Cancel")
if workflow_state_fieldname and workflow_state:
doc.set(workflow_state_fieldname, workflow_state)
doc.cancel()

View file

@ -349,6 +349,13 @@ def build_xlsx_data(data, visible_idx, include_indentation, ignore_visible_idx=F
datetime.timedelta,
)
if len(visible_idx) == len(data.result):
# It's not possible to have same length and different content.
ignore_visible_idx = True
else:
# Note: converted for faster lookups
visible_idx = set(visible_idx)
result = [[]]
column_widths = []

View file

@ -479,6 +479,7 @@ def delete_items():
def delete_bulk(doctype, items):
undeleted_items = []
for i, d in enumerate(items):
try:
frappe.delete_doc(doctype, d)
@ -493,7 +494,11 @@ def delete_bulk(doctype, items):
except Exception:
# rollback if any record failed to delete
# if not rollbacked, queries get committed on after_request method in app.py
undeleted_items.append(d)
frappe.db.rollback()
if undeleted_items and len(items) != len(undeleted_items):
frappe.clear_messages()
delete_bulk(doctype, undeleted_items)
@frappe.whitelist()

View file

@ -6,7 +6,9 @@ import json
import re
import frappe
from frappe import _, is_whitelisted
# Backward compatbility
from frappe import _, is_whitelisted, validate_and_sanitize_search_inputs
from frappe.database.schema import SPECIAL_CHAR_PATTERN
from frappe.permissions import has_permission
from frappe.utils import cint, cstr, unique
@ -293,22 +295,6 @@ def relevance_sorter(key, query, as_dict):
return (cstr(value).casefold().startswith(query.casefold()) is not True, value)
def validate_and_sanitize_search_inputs(fn):
@functools.wraps(fn)
def wrapper(*args, **kwargs):
kwargs.update(dict(zip(fn.__code__.co_varnames, args)))
sanitize_searchfield(kwargs["searchfield"])
kwargs["start"] = cint(kwargs["start"])
kwargs["page_len"] = cint(kwargs["page_len"])
if kwargs["doctype"] and not frappe.db.exists("DocType", kwargs["doctype"]):
return []
return fn(**kwargs)
return wrapper
@frappe.whitelist()
def get_names_for_mentions(search_term):
users_for_mentions = frappe.cache.get_value("users_for_mentions", get_users_for_mentions)

View file

@ -4,8 +4,6 @@ FrappeClient is a library that helps you connect with other frappe systems
import base64
import json
import requests
import frappe
from frappe.utils.data import cstr
@ -37,6 +35,8 @@ class FrappeClient:
api_secret=None,
frappe_authorization_source=None,
):
import requests
self.headers = {
"Accept": "application/json",
"content-type": "application/x-www-form-urlencoded",
@ -390,42 +390,13 @@ class FrappeClient:
class FrappeOAuth2Client(FrappeClient):
def __init__(self, url, access_token, verify=True):
import requests
self.access_token = access_token
self.headers = {
"Authorization": "Bearer " + access_token,
"content-type": "application/x-www-form-urlencoded",
}
self.verify = verify
self.session = OAuth2Session(self.headers)
self.session = requests.session()
self.url = url
def get_request(self, params):
res = requests.get(
self.url, params=self.preprocess(params), headers=self.headers, verify=self.verify
)
res = self.post_process(res)
return res
def post_request(self, data):
res = requests.post(
self.url, data=self.preprocess(data), headers=self.headers, verify=self.verify
)
res = self.post_process(res)
return res
class OAuth2Session:
def __init__(self, headers):
self.headers = headers
def get(self, url, params, verify):
res = requests.get(url, params=params, headers=self.headers, verify=verify)
return res
def post(self, url, data, verify):
res = requests.post(url, data=data, headers=self.headers, verify=verify)
return res
def put(self, url, data, verify):
res = requests.put(url, data=data, headers=self.headers, verify=verify)
return res

View file

@ -3,6 +3,9 @@
import json
from contextlib import contextmanager
import responses
from responses.matchers import json_params_matcher
import frappe
from frappe.integrations.doctype.webhook.webhook import (
enqueue_webhook,
@ -94,9 +97,15 @@ class TestWebhook(FrappeTestCase):
self.test_user.email = "user1@integration.webhooks.test.com"
self.test_user.first_name = "user1"
self.responses = responses.RequestsMock()
self.responses.start()
def tearDown(self) -> None:
self.user.delete()
self.test_user.delete()
self.responses.stop()
self.responses.reset()
super().tearDown()
def test_webhook_trigger_with_enabled_webhooks(self):
@ -172,6 +181,13 @@ class TestWebhook(FrappeTestCase):
self.assertEqual(data, {"name": self.user.name})
def test_webhook_req_log_creation(self):
self.responses.add(
responses.POST,
"https://httpbin.org/post",
status=200,
json={},
)
if not frappe.db.get_value("User", "user2@integration.webhooks.test.com"):
user = frappe.get_doc(
{"doctype": "User", "email": "user2@integration.webhooks.test.com", "first_name": "user2"}
@ -185,6 +201,7 @@ class TestWebhook(FrappeTestCase):
self.assertTrue(frappe.get_all("Webhook Request Log", pluck="name"))
def test_webhook_with_array_body(self):
"""Check if array request body are supported."""
wh_config = {
"doctype": "Webhook",
@ -194,7 +211,7 @@ class TestWebhook(FrappeTestCase):
"request_url": "https://httpbin.org/post",
"request_method": "POST",
"request_structure": "JSON",
"webhook_json": '[\r\n{% for n in range(3) %}\r\n {\r\n "title": "{{ doc.title }}",\r\n "n": {{ n }}\r\n }\r\n {%- if not loop.last -%}\r\n , \r\n {%endif%}\r\n{%endfor%}\r\n]',
"webhook_json": '[\r\n{% for n in range(3) %}\r\n {\r\n "title": "{{ doc.title }}" }\r\n {%- if not loop.last -%}\r\n , \r\n {%endif%}\r\n{%endfor%}\r\n]',
"meets_condition": "Yes",
"webhook_headers": [
{
@ -204,13 +221,22 @@ class TestWebhook(FrappeTestCase):
],
}
with get_test_webhook(wh_config) as wh:
doc = frappe.new_doc("Note")
doc.title = "Test Webhook Note"
doc = frappe.new_doc("Note")
doc.title = "Test Webhook Note"
expected_req = [{"title": doc.title} for _ in range(3)]
self.responses.add(
responses.POST,
"https://httpbin.org/post",
status=200,
json=expected_req,
match=[json_params_matcher(expected_req)],
)
with get_test_webhook(wh_config) as wh:
enqueue_webhook(doc, wh)
log = frappe.get_last_doc("Webhook Request Log")
self.assertEqual(len(json.loads(log.response)["json"]), 3)
self.assertEqual(len(json.loads(log.response)), 3)
def test_webhook_with_dynamic_url_enabled(self):
wh_config = {
@ -232,12 +258,16 @@ class TestWebhook(FrappeTestCase):
],
}
self.responses.add(
responses.POST,
"https://httpbin.org/anything/Note",
status=200,
)
with get_test_webhook(wh_config) as wh:
doc = frappe.new_doc("Note")
doc.title = "Test Webhook Note"
enqueue_webhook(doc, wh)
log = frappe.get_last_doc("Webhook Request Log")
self.assertEqual(json.loads(log.response)["url"], "https://httpbin.org/anything/Note")
def test_webhook_with_dynamic_url_disabled(self):
wh_config = {
@ -259,11 +289,13 @@ class TestWebhook(FrappeTestCase):
],
}
self.responses.add(
responses.POST,
"https://httpbin.org/anything/{{doc.doctype}}",
status=200,
)
with get_test_webhook(wh_config) as wh:
doc = frappe.new_doc("Note")
doc.title = "Test Webhook Note"
enqueue_webhook(doc, wh)
log = frappe.get_last_doc("Webhook Request Log")
self.assertEqual(
json.loads(log.response)["url"], "https://httpbin.org/anything/{{doc.doctype}}"
)

View file

@ -17,6 +17,7 @@
"html_condition",
"sb_webhook",
"request_url",
"timeout",
"is_dynamic_url",
"cb_webhook",
"request_method",
@ -204,6 +205,14 @@
"fieldname": "is_dynamic_url",
"fieldtype": "Check",
"label": "Is Dynamic URL?"
},
{
"default": "5",
"description": "The number of seconds until the request expires",
"fieldname": "timeout",
"fieldtype": "Int",
"label": "Request Timeout",
"reqd": 1
}
],
"links": [
@ -212,7 +221,7 @@
"link_fieldname": "webhook"
}
],
"modified": "2023-06-02 17:25:12.598232",
"modified": "2023-06-16 10:21:00.971833",
"modified_by": "Administrator",
"module": "Integrations",
"name": "Webhook",

View file

@ -129,7 +129,7 @@ def enqueue_webhook(doc, webhook) -> None:
url=request_url,
data=json.dumps(data, default=str),
headers=headers,
timeout=5,
timeout=webhook.timeout or 5,
)
r.raise_for_status()
frappe.logger().debug({"webhook_success": r.text})

View file

@ -151,7 +151,8 @@ def set_new_name(doc):
if getattr(doc, "amended_from", None):
_set_amended_name(doc)
return
if doc.name:
return
elif getattr(doc.meta, "issingle", False):
doc.name = doc.doctype
@ -506,6 +507,17 @@ def append_number_if_name_exists(doctype, value, fieldname="name", separator="-"
def _set_amended_name(doc):
amend_naming_rule = frappe.db.get_value(
"Amended Document Naming Settings", {"document_type": doc.doctype}, "action", cache=True
)
if not amend_naming_rule:
amend_naming_rule = frappe.db.get_single_value(
"Document Naming Settings", "default_amend_naming", cache=True
)
if amend_naming_rule == "Default Naming":
return
am_id = 1
am_prefix = doc.amended_from
if frappe.db.get_value(doc.doctype, doc.amended_from, "amended_from"):

View file

@ -53,6 +53,7 @@ def update_document_title(
# handle bad API usages
merge = sbool(merge)
enqueue = sbool(enqueue)
action_enqueued = enqueue and not is_scheduler_inactive()
doc = frappe.get_doc(doctype, docname)
doc.check_permission(permtype="write")
@ -65,7 +66,7 @@ def update_document_title(
name_updated = updated_name and (updated_name != doc.name)
if name_updated:
if enqueue and not is_scheduler_inactive():
if action_enqueued:
current_name = doc.name
# before_name hook may have DocType specific validations or transformations
@ -90,18 +91,27 @@ def update_document_title(
doc.rename(updated_name, merge=merge)
if title_updated:
try:
setattr(doc, title_field, updated_title)
doc.save()
frappe.msgprint(_("Saved"), alert=True, indicator="green")
except Exception as e:
if frappe.db.is_duplicate_entry(e):
frappe.throw(
_("{0} {1} already exists").format(doctype, frappe.bold(docname)),
title=_("Duplicate Name"),
exc=frappe.DuplicateEntryError,
)
raise
if action_enqueued and name_updated:
frappe.enqueue(
"frappe.client.set_value",
doctype=doc.doctype,
name=updated_name,
fieldname=title_field,
value=updated_title,
)
else:
try:
setattr(doc, title_field, updated_title)
doc.save()
frappe.msgprint(_("Saved"), alert=True, indicator="green")
except Exception as e:
if frappe.db.is_duplicate_entry(e):
frappe.throw(
_("{0} {1} already exists").format(doctype, frappe.bold(docname)),
title=_("Duplicate Name"),
exc=frappe.DuplicateEntryError,
)
raise
return doc.name

View file

@ -34,6 +34,7 @@ ignore_values = {
"Print Style": ["disabled"],
"Module Onboarding": ["is_complete"],
"Onboarding Step": ["is_complete", "is_skipped"],
"Workspace": ["is_hidden"],
}
ignore_doctypes = [""]

View file

@ -226,3 +226,4 @@ frappe.patches.v14_0.remove_manage_subscriptions_from_navbar
frappe.patches.v15_0.remove_background_jobs_from_dropdown
frappe.desk.doctype.form_tour.patches.introduce_ui_tours
execute:frappe.delete_doc_if_exists("Workspace", "Customization")
execute:frappe.db.set_single_value("Document Naming Settings", "default_amend_naming", "Amend Counter")

View file

@ -532,7 +532,7 @@ def update_permission_property(doctype, role, permlevel, ptype, value=None, vali
out = setup_custom_perms(doctype)
name = frappe.get_value("Custom DocPerm", dict(parent=doctype, role=role, permlevel=permlevel))
name = frappe.db.get_value("Custom DocPerm", dict(parent=doctype, role=role, permlevel=permlevel))
table = DocType("Custom DocPerm")
frappe.qb.update(table).set(ptype, value).where(table.name == name).run()

View file

@ -84,13 +84,15 @@
</symbol>
<symbol viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg" id="icon-arrow-up-right">
<path d="M2.5 9.5L9.5 2.5" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M9.50002 8V2.5H4.00002" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M2.5 9.5L9.5 2.5M9.50002 8V2.5H4.00002" stroke-linecap="round" stroke-linejoin="round"/>
</symbol>
<symbol viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg" id="icon-arrow-down-left">
<path d="M9.5 2.5L2.5 9.5" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M2.49999 4L2.49998 9.5L7.99998 9.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M9.5 2.5L2.5 9.5M2.49999 4L2.49998 9.5L7.99998 9.5" stroke-linecap="round" stroke-linejoin="round"/>
</symbol>
<symbol viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg" id="icon-arrow-down-right">
<path d="M2.5 2.5L9.5 9.5M4 9.5h5.5v-5.5" stroke-linecap="round" stroke-linejoin="round"/>
</symbol>
<symbol viewBox="0 0 12 12" xmlns="http://www.w3.org/2000/svg" id="icon-expand">

Before

Width:  |  Height:  |  Size: 113 KiB

After

Width:  |  Height:  |  Size: 113 KiB

View file

@ -60,7 +60,11 @@ frappe.ui.form.ControlGeolocation = class ControlGeolocation extends frappe.ui.f
this.clear_editable_layers();
const data_layers = new L.FeatureGroup().addLayer(
L.geoJson(JSON.parse(value), { pointToLayer: this.point_to_layer })
L.geoJson(JSON.parse(value), {
pointToLayer: this.point_to_layer,
style: this.set_style,
onEachFeature: this.on_each_feature,
})
);
this.add_non_group_layers(data_layers, this.editableLayers);
this.editableLayers.addTo(this.map);
@ -70,6 +74,8 @@ frappe.ui.form.ControlGeolocation = class ControlGeolocation extends frappe.ui.f
/**
* Defines custom rules for how geoJSON data is rendered on the map.
*
* Can be inherited in custom map controllers.
*
* @param {Object} geoJsonPoint - The geoJSON object to be rendered on the map.
* @param {Object} latlng - The latitude and longitude where the geoJSON data should be rendered on the map.
* @returns {Object} - Returns the Leaflet layer object to be rendered on the map.
@ -85,6 +91,28 @@ frappe.ui.form.ControlGeolocation = class ControlGeolocation extends frappe.ui.f
}
}
/**
* Defines custom styles for how geoJSON Line and LineString data is rendered on the map.
*
* Can be inherited in custom map controllers.
*
* @param {Object} geoJsonFeature - The geoJSON object to be rendered on the map.
* @returns {Object} - Returns the style object for the geoJSON object.
*/
set_style(geoJsonFeature) {
return {};
}
/**
* Is called after each feature is rendered and styles, can be used to attache popups, tooltips and other events
*
* Can be inherited in custom map controllers.
*
* @param {Object} feature - The leaflet object representing a geojson feature.
* @param {Object} layer - The leaflet layer object.
*/
on_each_feature(feature, layer) {}
bind_leaflet_map() {
const circleToGeoJSON = L.Circle.prototype.toGeoJSON;
L.Circle.include({

View file

@ -267,15 +267,17 @@ frappe.ui.form.ControlLink = class ControlLink extends frappe.ui.form.ControlDat
r.results = me.merge_duplicates(r.results);
// show filter description in awesomplete
if (args.filters) {
let filter_string = me.get_filter_description(args.filters);
if (filter_string) {
r.results.push({
html: `<span class="text-muted" style="line-height: 1.5">${filter_string}</span>`,
value: "",
action: () => {},
});
}
let filter_string = me.df.filter_description
? me.df.filter_description
: args.filters
? me.get_filter_description(args.filters)
: null;
if (filter_string) {
r.results.push({
html: `<span class="text-muted" style="line-height: 1.5">${filter_string}</span>`,
value: "",
action: () => {},
});
}
if (!me.df.only_select) {

View file

@ -113,7 +113,7 @@ frappe.ui.form.QuickEntryForm = class QuickEntryForm {
this.mandatory = [
{
fieldname: "__newname",
label: __("{0} Name", [this.meta.name]),
label: __("{0} Name", [__(this.meta.name)]),
reqd: 1,
fieldtype: "Data",
},

View file

@ -84,7 +84,7 @@ export default class Section {
add_field(fieldobj) {
this.fields_list.push(fieldobj);
this.fields_dict[fieldobj.fieldname] = fieldobj;
this.fields_dict[fieldobj.df.fieldname] = fieldobj;
fieldobj.section = this;
}

View file

@ -109,6 +109,7 @@ frappe.ui.form.Toolbar = class Toolbar {
}
let rename_document = () => {
if (input_name != docname) frappe.socketio.doctype_subscribe(doctype, input_name);
return frappe
.xcall("frappe.model.rename_doc.update_document_title", {
doctype,
@ -129,9 +130,8 @@ frappe.ui.form.Toolbar = class Toolbar {
};
// handle document renaming queued action
if (input_name && new_docname == docname) {
frappe.socketio.doc_subscribe(doctype, input_name);
frappe.realtime.on("doc_update", (data) => {
if (input_name != docname) {
frappe.realtime.on("list_update", (data) => {
if (data.doctype == doctype && data.name == input_name) {
reload_form(input_name);
frappe.show_alert({

View file

@ -456,22 +456,20 @@ frappe.views.ListView = class ListView extends frappe.views.BaseList {
get_no_result_message() {
let help_link = this.get_documentation_link();
let filters = this.filter_area && this.filter_area.get();
let no_result_message =
filters && filters.length
? __("No {0} found", [__(this.doctype)])
: __("You haven't created a {0} yet", [__(this.doctype)]);
let new_button_label =
filters && filters.length
? __(
"Create a new {0}",
[__(this.doctype)],
"Create a new document from list view"
)
: __(
"Create your first {0}",
[__(this.doctype)],
"Create a new document from list view"
);
let has_filters_set = filters && filters.length;
let no_result_message = has_filters_set
? __("No {0} found with matching filters. Clear filters to see all {0}.", [
__(this.doctype),
])
: __("You haven't created a {0} yet", [__(this.doctype)]);
let new_button_label = has_filters_set
? __("Create a new {0}", [__(this.doctype)], "Create a new document from list view")
: __(
"Create your first {0}",
[__(this.doctype)],
"Create a new document from list view"
);
let empty_state_image =
this.settings.empty_state_image ||
"/assets/frappe/images/ui-states/list-empty-state.svg";

View file

@ -163,7 +163,8 @@ $.extend(frappe.model, {
if (!user_default) {
user_default = frappe.defaults.get_user_default(df.fieldname);
} else if (
}
if (
!user_default &&
df.remember_last_selected_value &&
frappe.boot.user.last_selected_values

View file

@ -836,9 +836,9 @@ $.extend(frappe.model, {
}
if (
(frm.doc.fields.find((i) => i.fieldname === "latitude") &&
frm.doc.fields.find((i) => i.fieldname === "longitude")) ||
frm.doc.fields.find(
(frm.doc.fields?.find((i) => i.fieldname === "latitude") &&
frm.doc.fields?.find((i) => i.fieldname === "longitude")) ||
frm.doc.fields?.find(
(i) => i.fieldname === "location" && i.fieldtype == "Geolocation"
)
) {

View file

@ -324,9 +324,9 @@ frappe.ui.GroupBy = class {
);
if (this.aggregate_function === "sum") {
docfield.label = __("Sum of {0}", [docfield.label]);
docfield.label = __("Sum of {0}", [__(docfield.label)]);
} else {
docfield.label = __("Average of {0}", [docfield.label]);
docfield.label = __("Average of {0}", [__(docfield.label)]);
}
}
@ -367,7 +367,9 @@ frappe.ui.GroupBy = class {
["Select", "Link", "Data", "Int", "Check"].includes(f.fieldtype)
);
const tag_field = { fieldname: "_user_tags", fieldtype: "Data", label: __("Tags") };
this.group_by_fields[this.doctype] = fields.concat(tag_field);
this.group_by_fields[this.doctype] = fields
.concat(tag_field)
.sort((a, b) => __(a.label).localeCompare(__(b.label)));
this.all_fields[this.doctype] = this.report_view.meta.fields;
const standard_fields_filter = (df) =>
@ -379,7 +381,8 @@ frappe.ui.GroupBy = class {
const cdt = df.options;
const child_table_fields = frappe.meta
.get_docfields(cdt)
.filter(standard_fields_filter);
.filter(standard_fields_filter)
.sort((a, b) => __(a.label).localeCompare(__(b.label)));
this.group_by_fields[cdt] = child_table_fields;
this.all_fields[cdt] = child_table_fields;
});

View file

@ -376,6 +376,7 @@ frappe.show_progress = (title, count, total = 100, description, hide_on_completi
// timeout to avoid abrupt hide
setTimeout(frappe.hide_progress, 500);
}
frappe.cur_progress.$wrapper.css("z-index", 2000);
return dialog;
};

View file

@ -102,7 +102,7 @@ frappe.views.KanbanView = class KanbanView extends frappe.views.ListView {
this.menu_items.push({
label: __("Delete Kanban Board"),
action: () => {
frappe.confirm("Are you sure you want to proceed?", () => {
frappe.confirm(__("Are you sure you want to proceed?"), () => {
frappe.db.delete_doc("Kanban Board", this.board_name).then(() => {
frappe.show_alert(`Kanban Board ${this.board_name} deleted.`);
frappe.set_route("List", this.doctype, "List");

View file

@ -128,7 +128,7 @@ frappe.report_utils = {
return frappe.after_ajax(() => {
if (
frappe.query_reports[report_name] &&
!frappe.query_reports[report_name].filter &&
!frappe.query_reports[report_name].filters &&
r.filters
) {
return (frappe.query_reports[report_name].filters = r.filters);

View file

@ -238,7 +238,7 @@ export default class NumberCardWidget extends Widget {
color_class = "green-stat";
} else {
caret_html = `<span class="indicator-pill-round red">
${frappe.utils.icon("arrow-down-left", "xs")}
${frappe.utils.icon("arrow-down-right", "xs")}
</span>`;
color_class = "red-stat";
}

View file

@ -106,27 +106,28 @@ def patch_query_execute():
def prepare_query(query):
import inspect
from frappe.utils.safe_exec import check_safe_sql_query
param_collector = NamedParameterWrapper()
query = query.get_sql(param_wrapper=param_collector)
if frappe.flags.in_safe_exec and not check_safe_sql_query(query, throw=False):
callstack = inspect.stack()
if len(callstack) >= 3 and ".py" in callstack[2].filename:
# ignore any query builder methods called from python files
# assumption is that those functions are whitelisted already.
if frappe.flags.in_safe_exec:
from frappe.utils.safe_exec import check_safe_sql_query
# since query objects are patched everywhere any query.run()
# will have callstack like this:
# frame0: this function prepare_query()
# frame1: execute_query()
# frame2: frame that called `query.run()`
#
# if frame2 is server script <serverscript> is set as the filename
# it shouldn't be allowed.
pass
else:
raise frappe.PermissionError("Only SELECT SQL allowed in scripting")
if not check_safe_sql_query(query, throw=False):
callstack = inspect.stack()
if len(callstack) >= 3 and ".py" in callstack[2].filename:
# ignore any query builder methods called from python files
# assumption is that those functions are whitelisted already.
# since query objects are patched everywhere any query.run()
# will have callstack like this:
# frame0: this function prepare_query()
# frame1: execute_query()
# frame2: frame that called `query.run()`
#
# if frame2 is server script <serverscript> is set as the filename
# it shouldn't be allowed.
pass
else:
raise frappe.PermissionError("Only SELECT SQL allowed in scripting")
return query, param_collector.get_parameters()
builder_class = frappe.qb._BuilderClasss

View file

@ -161,7 +161,7 @@ def get_shared(doctype, user=None, rights=None):
or_filters += [["everyone", "=", 1]]
shared_docs = frappe.get_all(
"DocShare", fields=["share_name"], filters=filters, or_filters=or_filters
"DocShare", fields=["share_name"], filters=filters, or_filters=or_filters, order_by=None
)
return [doc.share_name for doc in shared_docs]

View file

@ -4,7 +4,6 @@
from typing import TYPE_CHECKING
from urllib.parse import parse_qs, urljoin, urlparse
import jwt
import requests
from werkzeug.test import TestResponse
@ -362,6 +361,8 @@ class TestOAuth20(FrappeRequestTestCase):
self.assertTrue(payload.get("nonce") == nonce)
def decode_id_token(self, id_token):
import jwt
return jwt.decode(
id_token,
audience=self.client_id,

View file

@ -20,7 +20,7 @@ from PIL import Image
import frappe
from frappe.installer import parse_app_name
from frappe.model.document import Document
from frappe.tests.utils import FrappeTestCase, change_settings
from frappe.tests.utils import FrappeTestCase, MockedRequestTestCase, change_settings
from frappe.utils import (
ceil,
dict_to_str,
@ -815,8 +815,14 @@ class TestLinkTitle(FrappeTestCase):
prop_setter.delete()
class TestAppParser(FrappeTestCase):
class TestAppParser(MockedRequestTestCase):
def test_app_name_parser(self):
self.responses.add(
"HEAD",
"https://api.github.com/repos/frappe/healthcare",
status=200,
json={},
)
bench_path = get_bench_path()
frappe_app = os.path.join(bench_path, "apps", "frappe")
self.assertEqual("frappe", parse_app_name(frappe_app))
@ -1096,6 +1102,8 @@ class TestRounding(FrappeTestCase):
rounding_method = "Banker's Rounding"
self.assertEqual(rounded(0, 0, rounding_method=rounding_method), 0)
self.assertEqual(rounded(5.551115123125783e-17, 2, rounding_method=rounding_method), 0.0)
self.assertEqual(flt("0.5", 0, rounding_method=rounding_method), 0)
self.assertEqual(flt("0.3", rounding_method=rounding_method), 0.3)

View file

@ -93,6 +93,19 @@ class FrappeTestCase(unittest.TestCase):
frappe.db.sql = orig_sql
class MockedRequestTestCase(FrappeTestCase):
def setUp(self):
import responses
self.responses = responses.RequestsMock()
self.responses.start()
self.addCleanup(self.responses.stop)
self.addCleanup(self.responses.reset)
return super().setUp()
def _commit_watcher():
import traceback

View file

@ -15,10 +15,8 @@ import operator
import os
import re
from contextlib import contextmanager
from csv import reader
from csv import reader, writer
from babel.messages.extract import extract_python
from babel.messages.jslexer import Token, tokenize, unquote_string
from pypika.terms import PseudoColumn
import frappe
@ -737,6 +735,7 @@ def get_messages_from_file(path: str) -> list[tuple[str, str, str | None, int]]:
def extract_messages_from_python_code(code: str) -> list[tuple[int, str, str | None]]:
"""Extracts translatable strings from Python code using babel."""
from babel.messages.extract import extract_python
messages = []
@ -809,6 +808,8 @@ def extract_javascript(code, keywords=("__",), options=None):
* `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 = {}
@ -997,7 +998,6 @@ def write_csv_file(path, app_messages, lang_dict):
:param lang_dict: Full translated dict.
"""
app_messages.sort(key=lambda x: x[1])
from csv import writer
with open(path, "w", newline="") as msgfile:
w = writer(msgfile, lineterminator="\n")
@ -1118,6 +1118,50 @@ def import_translations(lang, path):
write_translations_file(app, lang, full_dict)
def migrate_translations(source_app, target_app):
"""Migrate target-app-specific translations from source-app to target-app"""
clear_cache()
strings_in_source_app = [m[1] for m in frappe.translate.get_messages_for_app(source_app)]
strings_in_target_app = [m[1] for m in frappe.translate.get_messages_for_app(target_app)]
strings_in_target_app_but_not_in_source_app = list(
set(strings_in_target_app) - set(strings_in_source_app)
)
languages = frappe.translate.get_all_languages()
source_app_translations_dir = os.path.join(frappe.get_pymodule_path(source_app), "translations")
target_app_translations_dir = os.path.join(frappe.get_pymodule_path(target_app), "translations")
if not os.path.exists(target_app_translations_dir):
os.makedirs(target_app_translations_dir)
for lang in languages:
source_csv = os.path.join(source_app_translations_dir, lang + ".csv")
if not os.path.exists(source_csv):
continue
target_csv = os.path.join(target_app_translations_dir, lang + ".csv")
temp_csv = os.path.join(source_app_translations_dir, "_temp.csv")
with open(source_csv) as s, open(target_csv, "a+") as t, open(temp_csv, "a+") as temp:
source_reader = reader(s, lineterminator="\n")
target_writer = writer(t, lineterminator="\n")
temp_writer = writer(temp, lineterminator="\n")
for row in source_reader:
if row[0] in strings_in_target_app_but_not_in_source_app:
target_writer.writerow(row)
else:
temp_writer.writerow(row)
if not os.path.getsize(target_csv):
os.remove(target_csv)
os.remove(source_csv)
os.rename(temp_csv, source_csv)
def rebuild_all_translation_files():
"""Rebuild all translation files: `[app]/translations/[lang].csv`."""
for lang in get_all_languages():

View file

@ -1208,6 +1208,7 @@ Google Calendar ID,Google Kalender-ID,
Google Font,Google Font,
Google Services,Google-Dienste,
Grant Type,Grant Typ,
Group By {0},Gruppieren nach {0},
Group Name,Gruppenname,
Group name cannot be empty.,Der Gruppenname darf nicht leer sein.,
Groups of DocTypes,Gruppen von DocTypes,
@ -3238,6 +3239,7 @@ Change User,Benutzer wechseln,
Check the Error Log for more information: {0},Überprüfen Sie das Fehlerprotokoll auf weitere Informationen: {0},
Clear Cache and Reload,Cache leeren und neu laden,
Clear Filters,Filter löschen,
Clear all filters,Alle Filter löschen,
Click on <b>Authorize Google Drive Access</b> to authorize Google Drive Access.,"Klicken Sie auf <b>Google Drive Access autorisieren, um Google Drive Access</b> zu autorisieren.",
Click on a file to select it.,"Klicken Sie auf eine Datei, um sie auszuwählen.",
Click on the link below to approve the request,"Klicken Sie auf den folgenden Link, um die Anfrage zu genehmigen",
@ -3559,7 +3561,7 @@ Select Field...,Feld auswählen ...,
Select Filters,Wählen Sie Filter,
Select Google Calendar to which event should be synced.,"Wählen Sie Google Kalender aus, mit dem das Ereignis synchronisiert werden soll.",
Select Google Contacts to which contact should be synced.,"Wählen Sie Google-Kontakte aus, mit denen der Kontakt synchronisiert werden soll.",
Select Group By...,Wählen Sie Gruppieren nach ...,
Select Group By...,Gruppieren nach ...,
Select Mandatory,Verpflichtende auswählen,
Select atleast 2 actions,Wählen Sie mindestens 2 Aktionen aus,
Select list item,Listenelement auswählen,

1 A4 A4
1208 Google Font Google Font
1209 Google Services Google-Dienste
1210 Grant Type Grant Typ
1211 Group By {0} Gruppieren nach {0}
1212 Group Name Gruppenname
1213 Group name cannot be empty. Der Gruppenname darf nicht leer sein.
1214 Groups of DocTypes Gruppen von DocTypes
3239 Check the Error Log for more information: {0} Überprüfen Sie das Fehlerprotokoll auf weitere Informationen: {0}
3240 Clear Cache and Reload Cache leeren und neu laden
3241 Clear Filters Filter löschen
3242 Clear all filters Alle Filter löschen
3243 Click on <b>Authorize Google Drive Access</b> to authorize Google Drive Access. Klicken Sie auf <b>Google Drive Access autorisieren, um Google Drive Access</b> zu autorisieren.
3244 Click on a file to select it. Klicken Sie auf eine Datei, um sie auszuwählen.
3245 Click on the link below to approve the request Klicken Sie auf den folgenden Link, um die Anfrage zu genehmigen
3561 Select Filters Wählen Sie Filter
3562 Select Google Calendar to which event should be synced. Wählen Sie Google Kalender aus, mit dem das Ereignis synchronisiert werden soll.
3563 Select Google Contacts to which contact should be synced. Wählen Sie Google-Kontakte aus, mit denen der Kontakt synchronisiert werden soll.
3564 Select Group By... Wählen Sie Gruppieren nach ... Gruppieren nach ...
3565 Select Mandatory Verpflichtende auswählen
3566 Select atleast 2 actions Wählen Sie mindestens 2 Aktionen aus
3567 Select list item Listenelement auswählen

View file

@ -5,7 +5,6 @@ from base64 import b32encode, b64encode
from io import BytesIO
import pyotp
from pyqrcode import create as qrcreate
import frappe
import frappe.defaults
@ -387,6 +386,8 @@ def send_token_via_email(user, token, otp_secret, otp_issuer, subject=None, mess
def get_qr_svg_code(totp_uri):
"""Get SVG code to display Qrcode for OTP."""
from pyqrcode import create as qrcreate
url = qrcreate(totp_uri)
svg = ""
stream = BytesIO()

View file

@ -20,7 +20,6 @@ from collections.abc import (
)
from email.header import decode_header, make_header
from email.utils import formataddr, parseaddr
from gzip import GzipFile
from typing import Any, Callable, Literal
from urllib.parse import quote, urlparse
@ -873,6 +872,8 @@ def gzip_compress(data, compresslevel=9):
"""Compress data in one shot and return the compressed string.
Optional argument is the compression level, in range of 0-9.
"""
from gzip import GzipFile
buf = io.BytesIO()
with GzipFile(fileobj=buf, mode="wb", compresslevel=compresslevel) as f:
f.write(data)
@ -883,6 +884,8 @@ def gzip_decompress(data):
"""Decompress a gzip compressed string in one shot.
Return the decompressed string.
"""
from gzip import GzipFile
with GzipFile(fileobj=io.BytesIO(data)) as f:
return f.read()

View file

@ -1,3 +1,4 @@
import gc
import os
import socket
import time
@ -234,6 +235,10 @@ def start_worker(
"""Wrapper to start rq worker. Connects to redis and monitors these queues."""
DEQUEUE_STRATEGIES = {"round_robin": RoundRobinWorker, "random": RandomWorker}
if frappe._tune_gc:
gc.collect()
gc.freeze()
with frappe.init_site():
# empty init is required to get redis_queue from common_site_config.json
redis_connection = get_redis_conn(username=rq_username, password=rq_password)

View file

@ -298,14 +298,13 @@ __version__ = '0.0.1'
"""
hooks_template = """from . import __version__ as app_version
app_name = "{app_name}"
hooks_template = """app_name = "{app_name}"
app_title = "{app_title}"
app_publisher = "{app_publisher}"
app_description = "{app_description}"
app_email = "{app_email}"
app_license = "{app_license}"
# required_apps = []
# Includes in <head>
# ------------------

View file

@ -5,7 +5,6 @@ import json
import os
import subprocess # nosec
import requests
from semantic_version import Version
import frappe
@ -231,6 +230,7 @@ def check_release_on_github(app: str):
organization name, if the application exists, otherwise None.
"""
import requests
from giturlparse import parse
from giturlparse.parser import ParserError

View file

@ -15,6 +15,9 @@ from typing import Any, Literal, Optional, TypeVar, Union
from urllib.parse import parse_qsl, quote, urlencode, urljoin, urlparse, urlunparse
from click import secho
from dateutil import parser
from dateutil.parser import ParserError
from dateutil.relativedelta import relativedelta
import frappe
from frappe.desk.utils import slug
@ -80,9 +83,6 @@ def getdate(
Converts string date (yyyy-mm-dd) to datetime.date object.
If no input is provided, current date is returned.
"""
from dateutil import parser
from dateutil.parser._parser import ParserError
if not string_date:
return get_datetime().date()
if isinstance(string_date, datetime.datetime):
@ -105,7 +105,6 @@ def getdate(
def get_datetime(
datetime_str: Optional["DateTimeLikeObject"] = None,
) -> datetime.datetime | None:
from dateutil import parser
if datetime_str is None:
return now_datetime()
@ -141,9 +140,6 @@ def get_timedelta(time: str | None = None) -> datetime.timedelta | None:
Returns:
datetime.timedelta: Timedelta object equivalent of the passed `time` string
"""
from dateutil import parser
from dateutil.parser import ParserError
time = time or "0:0:0"
try:
@ -161,8 +157,6 @@ def get_timedelta(time: str | None = None) -> datetime.timedelta | None:
def to_timedelta(time_str: str | datetime.time) -> datetime.timedelta:
from dateutil import parser
if isinstance(time_str, datetime.time):
time_str = str(time_str)
@ -237,9 +231,6 @@ def add_to_date(
as_datetime=False,
) -> DateTimeLikeObject:
"""Adds `days` to the given date"""
from dateutil import parser
from dateutil.parser._parser import ParserError
from dateutil.relativedelta import relativedelta
if date is None:
date = now_datetime()
@ -500,9 +491,6 @@ def get_year_ending(date) -> datetime.date:
def get_time(time_str: str) -> datetime.time:
from dateutil import parser
from dateutil.parser import ParserError
if isinstance(time_str, datetime.datetime):
return time_str.time()
elif isinstance(time_str, datetime.time):
@ -1116,12 +1104,12 @@ def _round_away_from_zero(num, precision):
def _bankers_rounding(num, precision):
if num == 0:
return 0.0
multiplier = 10**precision
num = round(num * multiplier, 12)
if num == 0:
return 0.0
floor_num = math.floor(num)
decimal_part = num - floor_num
@ -2234,3 +2222,11 @@ def add_trackers_to_url(url: str, source: str, campaign: str, medium: str = "ema
url_parts[4] = urlencode(query)
return urlunparse(url_parts)
# This is used in test to count memory overhead of default imports.
def _get_rss_memory_usage():
import psutil
rss = psutil.Process().memory_info().rss // (1024 * 1024)
return rss

View file

@ -7,12 +7,9 @@ import inspect
import json
import linecache
import os
import pydoc
import sys
import traceback
from ldap3.core.exceptions import LDAPException
import frappe
from frappe.utils import cstr, encode
@ -20,16 +17,31 @@ EXCLUDE_EXCEPTIONS = (
frappe.AuthenticationError,
frappe.CSRFTokenError, # CSRF covers OAuth too
frappe.SecurityException,
LDAPException,
frappe.InReadOnlyMode,
)
LDAP_BASE_EXCEPTION = "LDAPException"
def _is_ldap_exception(e):
"""Check if exception is from LDAP library.
This is a hack but ensures that LDAP is not imported unless it's required. This is tested in
unittests in case the exception changes in future.
"""
for t in type(e).__mro__:
if t.__name__ == LDAP_BASE_EXCEPTION:
return True
return False
def make_error_snapshot(exception):
if frappe.conf.disable_error_snapshot:
return
if isinstance(exception, EXCLUDE_EXCEPTIONS):
if isinstance(exception, EXCLUDE_EXCEPTIONS) or _is_ldap_exception(exception):
return
logger = frappe.logger(with_more_info=True)
@ -56,6 +68,8 @@ def make_error_snapshot(exception):
def get_snapshot(exception, context=10):
import pydoc
"""
Return a dict describing a given traceback (based on cgitb.text)
"""

View file

@ -5,8 +5,6 @@ import base64
import json
from typing import TYPE_CHECKING, Callable
import jwt
import frappe
import frappe.utils
from frappe import _
@ -126,6 +124,9 @@ def login_via_oauth2_id_token(
def get_info_via_oauth(
provider: str, code: str, decoder: Callable | None = None, id_token: bool = False
):
import jwt
flow = get_oauth2_flow(provider)
oauth2_providers = get_oauth2_providers()

View file

@ -195,7 +195,13 @@ def decrypt(txt, encryption_key=None):
return cstr(cipher_suite.decrypt(encode(txt)))
except InvalidToken:
# encryption_key in site_config is changed and not valid
frappe.throw(_("Encryption key is invalid! Please check site_config.json"))
frappe.throw(
_("Encryption key is invalid! Please check site_config.json")
+ "<br>"
+ _(
"If you have recently restored the site you may need to copy the site config contaning original Encryption Key."
)
)
def get_encryption_key():

View file

@ -15,7 +15,6 @@ from typing import NoReturn
# imports - module imports
import frappe
from frappe.installer import update_site_config
from frappe.utils import cint, get_datetime, get_sites, now_datetime
from frappe.utils.background_jobs import get_jobs
@ -176,6 +175,8 @@ def _get_last_modified_timestamp(doctype):
@frappe.whitelist()
def activate_scheduler():
from frappe.installer import update_site_config
frappe.only_for("Administrator")
if frappe.local.conf.maintenance_mode:

View file

@ -5,12 +5,13 @@ removed without any warning.
"""
from contextlib import suppress
from posthog import Posthog
import frappe
from frappe.utils import getdate
from frappe.utils.caching import site_cache
from posthog import Posthog # isort: skip
POSTHOG_PROJECT_FIELD = "posthog_project_id"
POSTHOG_HOST_FIELD = "posthog_host"
@ -59,11 +60,13 @@ def capture(event, app, **kwargs):
ph and ph.capture(distinct_id=frappe.local.site, event=f"{app}_{event}", **kwargs)
def capture_doc(doc):
def capture_doc(doc, action):
with suppress(Exception):
age = site_age()
if not age or age > 15:
return
if doc.get("__islocal") or not doc.get("name"):
capture("document_created", "frappe", properties={"doctype": doc.doctype})
capture("document_created", "frappe", properties={"doctype": doc.doctype, "action": "Insert"})
else:
capture("document_modified", "frappe", properties={"doctype": doc.doctype, "action": action})

View file

@ -5,7 +5,6 @@ from urllib.parse import quote
import frappe
from frappe import _
from frappe.integrations.google_oauth import GoogleOAuth
from frappe.model.document import Document
from frappe.utils import encode, get_request_site_address
from frappe.website.utils import get_boot_data
@ -100,6 +99,8 @@ class WebsiteSettings(Document):
frappe.clear_cache()
def get_access_token(self):
from frappe.integrations.google_oauth import GoogleOAuth
if not self.indexing_refresh_token:
button_label = frappe.bold(_("Allow API Indexing Access"))
raise frappe.ValidationError(_("Click on {0} to generate Refresh Token.").format(button_label))

View file

@ -5,7 +5,6 @@ import frappe
import frappe.utils
from frappe import _
from frappe.auth import LoginManager
from frappe.integrations.doctype.ldap_settings.ldap_settings import LDAPSettings
from frappe.rate_limiter import rate_limit
from frappe.utils import cint, get_url
from frappe.utils.data import escape_html
@ -85,7 +84,10 @@ def get_context(context):
)
context["social_login"] = True
context["ldap_settings"] = LDAPSettings.get_ldap_client_settings()
if cint(frappe.db.get_value("LDAP Settings", "LDAP Settings", "enabled")):
from frappe.integrations.doctype.ldap_settings.ldap_settings import LDAPSettings
context["ldap_settings"] = LDAPSettings.get_ldap_client_settings()
login_label = [_("Email")]

View file

@ -105,3 +105,4 @@ pyngrok = "~=6.0.0"
unittest-xml-reporting = "~=3.2.0"
watchdog = "~=3.0.0"
hypothesis = "~=6.77.0"
responses = "~=0.23.1"