Merge branch 'develop' into test_address_template

This commit is contained in:
Raffael Meyer 2023-01-29 19:34:13 +01:00 committed by GitHub
commit 0df4bd27fb
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
125 changed files with 1966 additions and 1319 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

@ -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

@ -23,7 +23,7 @@ import click
from werkzeug.local import Local, release_local
from frappe.query_builder import (
get_qb_engine,
get_query,
get_query_builder,
patch_query_aggregation,
patch_query_execute,
@ -238,13 +238,12 @@ 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()
local.dev_server = _dev_server
local.qb = get_query_builder(local.conf.db_type or "mariadb")
local.qb.engine = get_qb_engine()
local.qb.get_query = get_query
setup_module_map()
if not _qb_patched.get(local.conf.db_type):
@ -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")
@ -1469,14 +1445,12 @@ def _load_app_hooks(app_name: str | None = None):
for app in apps:
try:
app_hooks = get_module(f"{app}.hooks")
except ImportError:
except ImportError as e:
if local.flags.in_install_app:
# if app is not installed while restoring
# ignore it
pass
print(f'Could not find app "{app}"')
if not request:
raise SystemExit
print(f'Could not find app "{app}": \n{e}')
raise
def _is_valid_hook(obj):
@ -1592,7 +1566,7 @@ def read_file(path, raise_not_found=False):
def get_attr(method_string: str) -> Any:
"""Get python method object from its name."""
app_name = method_string.split(".")[0]
app_name = method_string.split(".", 1)[0]
if (
not local.flags.in_uninstall
and not local.flags.in_install

View file

@ -55,7 +55,9 @@ class HTTPRequest:
def set_request_ip(self):
if frappe.get_request_header("X-Forwarded-For"):
frappe.local.request_ip = (frappe.get_request_header("X-Forwarded-For").split(",")[0]).strip()
frappe.local.request_ip = (
frappe.get_request_header("X-Forwarded-For").split(",", 1)[0]
).strip()
elif frappe.get_request_header("REMOTE_ADDR"):
frappe.local.request_ip = frappe.get_request_header("REMOTE_ADDR")

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)
@ -160,11 +158,10 @@ def clear_doctype_cache(doctype=None):
def clear_controller_cache(doctype=None):
if not doctype:
del frappe.controllers
frappe.controllers = {}
frappe.controllers.pop(frappe.local.site, None)
return
for site_controllers in frappe.controllers.values():
if site_controllers := frappe.controllers.get(frappe.local.site):
site_controllers.pop(doctype, None)

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")
@ -562,7 +574,7 @@ def _psql():
def jupyter(context):
"""Start an interactive jupyter notebook"""
installed_packages = (
r.split("==")[0]
r.split("==", 1)[0]
for r in subprocess.check_output([sys.executable, "-m", "pip", "freeze"], encoding="utf8")
)
@ -1001,7 +1013,7 @@ def request(context, args=None, path=None):
frappe.local.form_dict = frappe._dict()
if args.startswith("/api/method"):
frappe.local.form_dict.cmd = args.split("?")[0].split("/")[-1]
frappe.local.form_dict.cmd = args.split("?", 1)[0].split("/")[-1]
elif path:
with open(os.path.join("..", path)) as f:
args = json.loads(f.read())
@ -1030,6 +1042,16 @@ def make_app(destination, app_name, no_git=False):
make_boilerplate(destination, app_name, no_git=no_git)
@click.command("create-patch")
def create_patch():
"Creates a new patch interactively"
from frappe.utils.boilerplate import PatchCreator
pc = PatchCreator()
pc.fetch_user_inputs()
pc.create_patch_file()
@click.command("set-config")
@click.argument("key")
@click.argument("value")
@ -1061,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)
@ -1176,6 +1200,7 @@ commands = [
data_import,
import_doc,
make_app,
create_patch,
mariadb,
postgres,
request,

View file

@ -499,7 +499,7 @@ def parse_email(communication, email_strings):
if email_string:
for email in email_string.split(","):
if delimiter in email:
email = email.split("@")[0]
email = email.split("@", 1)[0]
email_local_parts = email.split(delimiter)
if not len(email_local_parts) == 3:
continue
@ -521,7 +521,7 @@ def get_email_without_link(email):
try:
_email = email.split("@")
email_id = _email[0].split("+")[0]
email_id = _email[0].split("+", 1)[0]
email_host = _email[1]
except IndexError:
return email

View file

@ -604,6 +604,7 @@
{
"default": "0",
"depends_on": "eval: doc.is_submittable",
"description": "Enabling this will submit documents in background",
"fieldname": "queue_in_background",
"fieldtype": "Check",
"label": "Queue in Background"
@ -707,7 +708,7 @@
"link_fieldname": "reference_doctype"
}
],
"modified": "2022-12-14 09:47:27.315351",
"modified": "2023-01-04 17:23:09.206018",
"modified_by": "Administrator",
"module": "Core",
"name": "DocType",
@ -744,4 +745,4 @@
"states": [],
"track_changes": 1,
"translated_doctype": 1
}
}

View file

@ -366,8 +366,10 @@ class DocType(Document):
d.fieldname = d.fieldname + "_column"
elif d.fieldtype == "Tab Break":
d.fieldname = d.fieldname + "_tab"
else:
elif d.fieldtype in ("Section Break", "Column Break", "Tab Break"):
d.fieldname = d.fieldtype.lower().replace(" ", "_") + "_" + str(random_string(4))
else:
frappe.throw(_("Row #{}: Fieldname is required").format(d.idx), title="Missing Fieldname")
else:
if d.fieldname in restricted:
frappe.throw(_("Fieldname {0} is restricted").format(d.fieldname), InvalidFieldNameError)
@ -883,7 +885,7 @@ def validate_series(dt, autoname=None, name=None):
if not autoname and dt.get("fields", {"fieldname": "naming_series"}):
dt.autoname = "naming_series:"
elif dt.autoname and dt.autoname.startswith("naming_series:"):
fieldname = dt.autoname.split("naming_series:")[0] or "naming_series"
fieldname = dt.autoname.split("naming_series:", 1)[0] or "naming_series"
if not dt.get("fields", {"fieldname": fieldname}):
frappe.throw(
_("Fieldname called {0} must exist to enable autonaming").format(frappe.bold(fieldname)),
@ -911,7 +913,7 @@ def validate_series(dt, autoname=None, name=None):
and (not autoname.startswith("format:"))
):
prefix = autoname.split(".")[0]
prefix = autoname.split(".", 1)[0]
doctype = frappe.qb.DocType("DocType")
used_in = (
frappe.qb.from_(doctype)
@ -1133,7 +1135,7 @@ def validate_fields(meta):
d.options = options
def check_hidden_and_mandatory(docname, d):
if d.hidden and d.reqd and not d.default:
if d.hidden and d.reqd and not d.default and not frappe.flags.in_migrate:
frappe.throw(
_("{0}: Field {1} in row {2} cannot be hidden and mandatory without default").format(
docname, d.label, d.idx
@ -1346,7 +1348,7 @@ def validate_fields(meta):
if meta.sort_field:
sort_fields = [meta.sort_field]
if "," in meta.sort_field:
sort_fields = [d.split()[0] for d in meta.sort_field.split(",")]
sort_fields = [d.split(maxsplit=1)[0] for d in meta.sort_field.split(",")]
for fieldname in sort_fields:
if fieldname not in (fieldname_list + list(default_fields) + list(child_table_fields)):

View file

@ -329,7 +329,11 @@ class File(Document):
self.file_url = duplicate_file.file_url
def set_file_name(self):
if not self.file_name and self.file_url:
if not self.file_name and not self.file_url:
frappe.throw(
_("Fields `file_name` or `file_url` must be set for File"), exc=frappe.MandatoryError
)
elif not self.file_name and self.file_url:
self.file_name = self.file_url.split("/")[-1]
else:
self.file_name = re.sub(r"/", "", self.file_name)

View file

@ -225,7 +225,7 @@ def extract_images_from_html(doc: "Document", content: str, is_private: bool = F
def _save_file(match):
data = match.group(1).split("data:")[1]
headers, content = data.split(",")
mtype = headers.split(";")[0]
mtype = headers.split(";", 1)[0]
if isinstance(content, str):
content = content.encode("utf-8")
@ -237,7 +237,7 @@ def extract_images_from_html(doc: "Document", content: str, is_private: bool = F
if "filename=" in headers:
filename = headers.split("filename=")[-1]
filename = safe_decode(filename).split(";")[0]
filename = safe_decode(filename).split(";", 1)[0]
else:
filename = get_random_filename(content_type=mtype)

View file

@ -2,6 +2,66 @@
// For license information, please see license.txt
frappe.ui.form.on("Installed Applications", {
// refresh: function(frm) {
// }
refresh: function (frm) {
frm.add_custom_button(__("Update Hooks Resolution Order"), () => {
frm.trigger("show_update_order_dialog");
});
},
show_update_order_dialog() {
const dialog = new frappe.ui.Dialog({
title: __("Update Hooks Resolution Order"),
fields: [
{
fieldname: "apps",
fieldtype: "Table",
label: __("Installed Apps"),
cannot_add_rows: true,
cannot_delete_rows: true,
in_place_edit: true,
data: [],
fields: [
{
fieldtype: "Data",
fieldname: "app_name",
label: __("App Name"),
in_list_view: 1,
read_only: 1,
},
],
},
],
primary_action: function () {
const new_order = this.get_values()["apps"].map((row) => row.app_name);
frappe.call({
method: "frappe.core.doctype.installed_applications.installed_applications.update_installed_apps_order",
freeze: true,
args: {
new_order: new_order,
},
});
this.hide();
},
primary_action_label: __("Update Order"),
});
frappe
.xcall(
"frappe.core.doctype.installed_applications.installed_applications.get_installed_app_order"
)
.then((data) => {
data.forEach((app) => {
dialog.fields_dict.apps.df.data.push({
app_name: app,
});
});
dialog.fields_dict.apps.grid.refresh();
// hack: change checkboxes to drag handles.
let grid = $(dialog.fields_dict.apps.grid.parent);
grid.find(".grid-row-check:first").remove() &&
grid.find(".grid-row-check").replaceWith(frappe.utils.icon("menu"));
dialog.show();
});
},
});

View file

@ -1,10 +1,17 @@
# Copyright (c) 2020, Frappe Technologies and contributors
# License: MIT. See LICENSE
import json
import frappe
from frappe import _
from frappe.model.document import Document
class InvalidAppOrder(frappe.ValidationError):
pass
class InstalledApplications(Document):
def update_versions(self):
self.delete_key("installed_applications")
@ -18,3 +25,51 @@ class InstalledApplications(Document):
},
)
self.save()
@frappe.whitelist()
def update_installed_apps_order(new_order: list[str] | str):
"""Change the ordering of `installed_apps` global
This list is used to resolve hooks and by default it's order of installation on site.
Sometimes it might not be the ordering you want, so thie function is provided to override it.
"""
frappe.only_for("System Manager")
if isinstance(new_order, str):
new_order = json.loads(new_order)
frappe.local.request_cache and frappe.local.request_cache.clear()
existing_order = frappe.get_installed_apps(_ensure_on_bench=True)
if set(existing_order) != set(new_order) or not isinstance(new_order, list):
frappe.throw(
_("You are only allowed to update order, do not remove or add apps."), exc=InvalidAppOrder
)
# Ensure frappe is always first regardless of user's preference.
if "frappe" in new_order:
new_order.remove("frappe")
new_order.insert(0, "frappe")
frappe.db.set_global("installed_apps", json.dumps(new_order))
_create_version_log_for_change(existing_order, new_order)
def _create_version_log_for_change(old, new):
version = frappe.new_doc("Version")
version.ref_doctype = "DefaultValue"
version.docname = "installed_apps"
version.data = frappe.as_json({"changed": [["current", json.dumps(old), json.dumps(new)]]})
version.flags.ignore_links = True # This is a fake doctype
version.flags.ignore_permissions = True
version.insert()
@frappe.whitelist()
def get_installed_app_order() -> list[str]:
frappe.only_for("System Manager")
return frappe.get_installed_apps(_ensure_on_bench=True)

View file

@ -1,8 +1,16 @@
# Copyright (c) 2020, Frappe Technologies and Contributors
# License: MIT. See LICENSE
# import frappe
import frappe
from frappe.core.doctype.installed_applications.installed_applications import (
InvalidAppOrder,
update_installed_apps_order,
)
from frappe.tests.utils import FrappeTestCase
class TestInstalledApplications(FrappeTestCase):
pass
def test_order_change(self):
update_installed_apps_order(["frappe"])
self.assertRaises(InvalidAppOrder, update_installed_apps_order, [])
self.assertRaises(InvalidAppOrder, update_installed_apps_order, ["frappe", "deepmind"])

View file

@ -26,7 +26,7 @@ class PackageImport(Document):
attachment = attachment[0]
# get package_name from file (package_name-0.0.0.tar.gz)
package_name = attachment.file_name.split(".")[0].rsplit("-", 1)[0]
package_name = attachment.file_name.split(".", 1)[0].rsplit("-", 1)[0]
if not os.path.exists(frappe.get_site_path("packages")):
os.makedirs(frappe.get_site_path("packages"))

View file

@ -1,6 +1,6 @@
{
"actions": [],
"autoname": "PATCHLOG.#####",
"autoname": "hash",
"creation": "2013-01-17 11:36:45",
"description": "List of patches executed",
"doctype": "DocType",
@ -20,11 +20,11 @@
"icon": "fa fa-cog",
"idx": 1,
"links": [],
"modified": "2022-06-13 05:34:37.845368",
"modified": "2023-01-17 15:35:11.688615",
"modified_by": "Administrator",
"module": "Core",
"name": "Patch Log",
"naming_rule": "Expression (old style)",
"naming_rule": "Random",
"owner": "Administrator",
"permissions": [
{

View file

@ -3,11 +3,17 @@
frappe.ui.form.on("Submission Queue", {
refresh: function (frm) {
if (frm.doc.status === "Queued" && frm.doc.job_id) {
if (frm.doc.status === "Queued" && frappe.boot.user.roles.includes("System Manager")) {
frm.add_custom_button(__("Unlock Reference Document"), () => {
frappe.confirm(__("Are you sure you want to go ahead with this action?"), () => {
frm.call("unlock_doc");
});
frappe.confirm(
`
Are you sure you want to go ahead with this action?
Doing this could unlock other submissions of this document which are in queue (if present)
and could lead to non-ideal conditions.`,
() => {
frm.call("unlock_doc");
}
);
});
}
},

View file

@ -20,8 +20,9 @@
"fields": [
{
"fieldname": "job_id",
"fieldtype": "Data",
"fieldtype": "Link",
"label": "Job Id",
"options": "RQ Job",
"read_only": 1
},
{
@ -80,14 +81,14 @@
},
{
"fieldname": "exception",
"fieldtype": "Text",
"fieldtype": "Long Text",
"label": "Exception",
"read_only": 1
}
],
"index_web_pages_for_search": 1,
"links": [],
"modified": "2022-11-12 16:48:37.797232",
"modified": "2023-01-23 12:45:53.997708",
"modified_by": "Administrator",
"module": "Core",
"name": "Submission Queue",
@ -102,6 +103,11 @@
"report": 1,
"role": "System Manager",
"share": 1
},
{
"if_owner": 1,
"read": 1,
"role": "All"
}
],
"sort_field": "modified",

View file

@ -4,8 +4,6 @@
from urllib.parse import quote
from rq import get_current_job
from rq.exceptions import NoSuchJobError
from rq.job import Job
import frappe
from frappe import _
@ -13,7 +11,6 @@ from frappe.desk.doctype.notification_log.notification_log import enqueue_create
from frappe.model.document import Document
from frappe.monitor import add_data_to_monitor
from frappe.utils import now, time_diff_in_seconds
from frappe.utils.background_jobs import get_redis_conn
from frappe.utils.data import cint
@ -39,6 +36,7 @@ class SubmissionQueue(Document):
frappe.db.delete(table, filters=(table.modified < (Now() - Interval(days=days))))
def insert(self, to_be_queued_doc: Document, action: str):
self.status = "Queued"
self.to_be_queued_doc = to_be_queued_doc
self.action_for_queuing = action
super().insert(ignore_permissions=True)
@ -70,6 +68,7 @@ class SubmissionQueue(Document):
def background_submission(self, to_be_queued_doc: Document, action_for_queuing: str):
# Set the job id for that submission doctype
self.update_job_id(get_current_job().id)
_action = action_for_queuing.lower()
if _action == "update":
_action = "submit"
@ -85,7 +84,7 @@ class SubmissionQueue(Document):
)
values = {"status": "Finished"}
except Exception:
values = {"status": "Failed", "exception": frappe.get_traceback()}
values = {"status": "Failed", "exception": frappe.get_traceback(with_context=True)}
frappe.db.rollback()
values["ended_at"] = now()
@ -96,22 +95,27 @@ class SubmissionQueue(Document):
if submission_status == "Failed":
doctype = self.doctype
docname = self.name
message = _("Submission of {0} {1} with action {2} failed")
message = _("Action {0} failed on {1} {2}. View it {3}")
else:
doctype = self.ref_doctype
docname = self.ref_docname
message = _("Submission of {0} {1} with action {2} completed successfully")
message = _("Action {0} completed successfully on {1} {2}. View it {3}")
message = message.format(
frappe.bold(str(self.ref_doctype)), frappe.bold(self.ref_docname), frappe.bold(action)
message_replacements = (
frappe.bold(action),
frappe.bold(str(self.ref_doctype)),
frappe.bold(str(self.ref_docname)),
)
time_diff = time_diff_in_seconds(now(), self.created_at)
if cint(time_diff) <= 60:
frappe.publish_realtime(
"msgprint",
{
"message": message
+ f". View it <a href='/app/{quote(doctype.lower().replace(' ', '-'))}/{quote(docname)}'><b>here</b></a>",
"message": message.format(
*message_replacements,
f"<a href='/app/{quote(doctype.lower().replace(' ', '-'))}/{quote(docname)}'><b>here</b></a>",
),
"alert": True,
"indicator": "red" if submission_status == "Failed" else "green",
},
@ -122,50 +126,27 @@ class SubmissionQueue(Document):
"type": "Alert",
"document_type": doctype,
"document_name": docname,
"subject": message,
"subject": message.format(*message_replacements, "here"),
}
notify_to = frappe.db.get_value("User", self.enqueued_by, fieldname="email")
enqueue_create_notification([notify_to], notification_doc)
def _unlock_reference_doc(self):
"""
Only execute if self.job_id is defined.
"""
try:
job = Job.fetch(self.job_id, connection=get_redis_conn())
status = job.get_status(refresh=True)
exc = job.exc_info
except NoSuchJobError:
exc = None
status = "failed"
if status in ("queued", "started"):
frappe.msgprint(_("Document in queue for execution!"))
return
self.queued_doc.unlock()
values = (
{"status": "Finished"} if status == "finished" else {"status": "Failed", "exception": exc}
)
frappe.db.set_value(self.doctype, self.name, values, update_modified=False)
frappe.msgprint(_("Document Unlocked"))
@frappe.whitelist()
def unlock_doc(self):
# NOTE: this can lead to some weird unlocking/locking behaviours.
# for example: hitting unlock on a submission could lead to unlocking of another submission
# of the same reference document.
if self.status != "Queued" and not self.job_id:
if self.status != "Queued":
return
self._unlock_reference_doc()
self.queued_doc.unlock()
frappe.msgprint(_("Document Unlocked"))
def queue_submission(doc: Document, action: str, alert: bool = True):
queue = frappe.new_doc("Submission Queue")
queue.state = "Queued"
queue.ref_doctype = doc.doctype
queue.ref_docname = doc.name
queue.insert(doc, action)
@ -185,9 +166,25 @@ def get_latest_submissions(doctype, docname):
# NOTE: not used creation as orderby intentianlly as we have used update_modified=False everywhere
# hence assuming modified will be equal to creation for submission queue documents
dt = "Submission Queue"
filters = {"ref_doctype": doctype, "ref_docname": docname}
return {
"latest_submission": frappe.db.get_value(dt, filters),
"latest_failed_submission": frappe.db.get_value(dt, filters | {"status": "Failed"}),
}
latest_submission = frappe.db.get_value(
"Submission Queue",
filters={"ref_doctype": doctype, "ref_docname": docname},
fieldname=["name", "exception", "status"],
)
out = None
if latest_submission:
out = {
"latest_submission": latest_submission[0],
"exc": format_tb(latest_submission[1]),
"status": latest_submission[2],
}
return out
def format_tb(traceback: str | None = None):
if not traceback:
return
return traceback.strip().split("\n")[-1]

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

@ -277,7 +277,7 @@ def create_user(email, *roles):
user = frappe.new_doc("User")
user.email = email
user.first_name = email.split("@")[0]
user.first_name = email.split("@", 1)[0]
if not roles:
roles = ("System Manager",)

View file

@ -287,7 +287,7 @@ def user_linked_with_permission_on_doctype(doc, user):
def apply_permissions_for_non_standard_user_type(doc, method=None):
"""Create user permission for the non standard user type"""
if not frappe.db.table_exists("User Type"):
if not frappe.db.table_exists("User Type") or frappe.flags.in_migrate:
return
user_types = frappe.cache().get_value(

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

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-25 15:03:31.263337",
"modified_by": "Administrator",
"module": "Custom",
"name": "Audit System Hooks",
"owner": "Administrator",
"prepared_report": 0,
"query": "",
"ref_doctype": "System Settings",
"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

@ -622,7 +622,7 @@ class Database:
return [map(values.get, fields)]
else:
r = frappe.qb.engine.get_query(
r = frappe.qb.get_query(
"Singles",
filters={"field": ("in", tuple(fields)), "doctype": doctype},
fields=["field", "value"],
@ -655,7 +655,7 @@ class Database:
# Get coulmn and value of the single doctype Accounts Settings
account_settings = frappe.db.get_singles_dict("Accounts Settings")
"""
queried_result = frappe.qb.engine.get_query(
queried_result = frappe.qb.get_query(
"Singles",
filters={"doctype": doctype},
fields=["field", "value"],
@ -761,7 +761,7 @@ class Database:
if cache and fieldname in self.value_cache[doctype]:
return self.value_cache[doctype][fieldname]
val = frappe.qb.engine.get_query(
val = frappe.qb.get_query(
table="Singles",
filters={"doctype": doctype, "field": fieldname},
fields="value",
@ -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)
@ -801,10 +803,10 @@ class Database:
distinct=False,
limit=None,
):
query = frappe.qb.engine.get_query(
query = frappe.qb.get_query(
table=doctype,
filters=filters,
orderby=order_by,
order_by=order_by,
for_update=for_update,
fields=fields,
distinct=distinct,
@ -830,15 +832,14 @@ class Database:
as_dict=False,
):
if names := list(filter(None, names)):
return frappe.qb.engine.get_query(
return frappe.qb.get_query(
doctype,
fields=field,
filters=names,
order_by=order_by,
pluck=pluck,
distinct=distinct,
limit=limit,
).run(debug=debug, run=run, as_dict=as_dict)
).run(debug=debug, run=run, as_dict=as_dict, pluck=pluck)
return {}
def set_value(
@ -887,7 +888,7 @@ class Database:
field, val, modified=modified, modified_by=modified_by, update_modified=update_modified
)
query = frappe.qb.engine.build_conditions(table=dt, filters=dn, update=True)
query = frappe.qb.get_query(table=dt, filters=dn, update=True)
if isinstance(dn, str):
frappe.clear_document_cache(dt, dn)
@ -1052,9 +1053,9 @@ class Database:
cache_count = frappe.cache().get_value(f"doctype:count:{dt}")
if cache_count is not None:
return cache_count
count = frappe.qb.engine.get_query(
table=dt, filters=filters, fields=Count("*"), distinct=distinct
).run(debug=debug)[0][0]
count = frappe.qb.get_query(table=dt, filters=filters, fields=Count("*"), distinct=distinct).run(
debug=debug
)[0][0]
if not filters and cache:
frappe.cache().set_value(f"doctype:count:{dt}", count, expires_in_sec=86400)
return count
@ -1195,7 +1196,7 @@ class Database:
Doctype name can be passed directly, it will be pre-pended with `tab`.
"""
filters = filters or kwargs.get("conditions")
query = frappe.qb.engine.build_conditions(table=doctype, filters=filters).delete()
query = frappe.qb.get_query(table=doctype, filters=filters, delete=True)
if "debug" not in kwargs:
kwargs["debug"] = debug
return query.run(**kwargs)

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

@ -19,7 +19,7 @@ def get_mariadb_version(version_string: str = ""):
# MariaDB classifies their versions as Major (1st and 2nd number), and Minor (3rd number)
# Example: Version 10.3.13 is Major Version = 10.3, Minor Version = 13
version_string = version_string or get_mariadb_variables().get("version")
version = version_string.split("-")[0]
version = version_string.split("-", 1)[0]
return version.rsplit(".", 1)

View file

@ -0,0 +1,138 @@
# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and Contributors
# MIT License. See license.txt
import operator
from typing import Callable
import frappe
from frappe.database.utils import NestedSetHierarchy
from frappe.model.db_query import get_timespan_date_range
from frappe.query_builder import Field
def like(key: Field, value: str) -> frappe.qb:
"""Wrapper method for `LIKE`
Args:
key (str): field
value (str): criterion
Returns:
frappe.qb: `frappe.qb object with `LIKE`
"""
return key.like(value)
def func_in(key: Field, value: list | tuple) -> frappe.qb:
"""Wrapper method for `IN`
Args:
key (str): field
value (Union[int, str]): criterion
Returns:
frappe.qb: `frappe.qb object with `IN`
"""
if isinstance(value, str):
value = value.split(",")
return key.isin(value)
def not_like(key: Field, value: str) -> frappe.qb:
"""Wrapper method for `NOT LIKE`
Args:
key (str): field
value (str): criterion
Returns:
frappe.qb: `frappe.qb object with `NOT LIKE`
"""
return key.not_like(value)
def func_not_in(key: Field, value: list | tuple | str):
"""Wrapper method for `NOT IN`
Args:
key (str): field
value (Union[int, str]): criterion
Returns:
frappe.qb: `frappe.qb object with `NOT IN`
"""
if isinstance(value, str):
value = value.split(",")
return key.notin(value)
def func_regex(key: Field, value: str) -> frappe.qb:
"""Wrapper method for `REGEX`
Args:
key (str): field
value (str): criterion
Returns:
frappe.qb: `frappe.qb object with `REGEX`
"""
return key.regex(value)
def func_between(key: Field, value: list | tuple) -> frappe.qb:
"""Wrapper method for `BETWEEN`
Args:
key (str): field
value (Union[int, str]): criterion
Returns:
frappe.qb: `frappe.qb object with `BETWEEN`
"""
return key[slice(*value)]
def func_is(key, value):
"Wrapper for IS"
return key.isnotnull() if value.lower() == "set" else key.isnull()
def func_timespan(key: Field, value: str) -> frappe.qb:
"""Wrapper method for `TIMESPAN`
Args:
key (str): field
value (str): criterion
Returns:
frappe.qb: `frappe.qb object with `TIMESPAN`
"""
return func_between(key, get_timespan_date_range(value))
# default operators
OPERATOR_MAP: dict[str, Callable] = {
"+": operator.add,
"=": operator.eq,
"-": operator.sub,
"!=": operator.ne,
"<": operator.lt,
">": operator.gt,
"<=": operator.le,
"=<": operator.le,
">=": operator.ge,
"=>": operator.ge,
"/": operator.truediv,
"*": operator.mul,
"in": func_in,
"not in": func_not_in,
"like": like,
"not like": not_like,
"regex": func_regex,
"between": func_between,
"is": func_is,
"timespan": func_timespan,
"nested_set": NestedSetHierarchy,
# TODO: Add support for custom operators (WIP) - via filters_config hooks
}

File diff suppressed because it is too large Load diff

View file

@ -34,6 +34,14 @@ def is_pypika_function_object(field: str) -> bool:
return getattr(field, "__module__", None) == "pypika.functions" or isinstance(field, Function)
def get_doctype_name(table_name: str) -> str:
if table_name.startswith(("tab", "`tab", '"tab')):
table_name = table_name.replace("tab", "", 1)
table_name = table_name.replace("`", "")
table_name = table_name.replace('"', "")
return table_name
class LazyString:
def _setup(self) -> None:
raise NotImplementedError

View file

@ -306,8 +306,8 @@ def get_events(start, end, user=None, for_reminder=False, filters=None) -> list[
)
# process recurring events
start = start.split(" ")[0]
end = end.split(" ")[0]
start = start.split(" ", 1)[0]
end = end.split(" ", 1)[0]
add_events = []
remove_events = []
@ -315,7 +315,7 @@ def get_events(start, end, user=None, for_reminder=False, filters=None) -> list[
new_event = e.copy()
enddate = (
add_days(date, int(date_diff(e.ends_on.split(" ")[0], e.starts_on.split(" ")[0])))
add_days(date, int(date_diff(e.ends_on.split(" ", 1)[0], e.starts_on.split(" ", 1)[0])))
if (e.starts_on and e.ends_on)
else date
)
@ -337,8 +337,8 @@ def get_events(start, end, user=None, for_reminder=False, filters=None) -> list[
repeat = "3000-01-01" if cstr(e.repeat_till) == "" else e.repeat_till
if e.repeat_on == "Yearly":
start_year = cint(start.split("-")[0])
end_year = cint(end.split("-")[0])
start_year = cint(start.split("-", 1)[0])
end_year = cint(end.split("-", 1)[0])
# creates a string with date (27) and month (07) eg: 07-27
event_start = "-".join(event_start.split("-")[1:])
@ -357,7 +357,8 @@ def get_events(start, end, user=None, for_reminder=False, filters=None) -> list[
if e.repeat_on == "Monthly":
# creates a string with date (27) and month (07) and year (2019) eg: 2019-07-27
date = start.split("-")[0] + "-" + start.split("-")[1] + "-" + event_start.split("-")[2]
year, month = start.split("-", maxsplit=2)[:2]
date = f"{year}-{month}-" + event_start.split("-", maxsplit=3)[2]
# last day of month issue, start from prev month!
try:

View file

@ -200,7 +200,7 @@ def get_cards_for_user(doctype, txt, searchfield, start, page_len, filters):
if txt:
search_conditions = [numberCard[field].like(f"%{txt}%") for field in searchfields]
condition_query = frappe.qb.engine.build_conditions(doctype, filters)
condition_query = frappe.qb.get_query(doctype, filters=filters)
return (
condition_query.select(numberCard.name, numberCard.label, numberCard.document_type)

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,8 @@ 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

@ -4,12 +4,14 @@ import io
import os
import frappe
from frappe import _
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
ASSET_KEYS = (
"__js",
@ -50,7 +52,7 @@ def get_meta(doctype, cached=True):
class FormMeta(Meta):
def __init__(self, doctype):
super().__init__(doctype)
self.__dict__.update(frappe.get_meta(doctype).__dict__)
self.load_assets()
def load_assets(self):
@ -132,7 +134,7 @@ class FormMeta(Meta):
for fname in os.listdir(path):
if fname.endswith(".html"):
with open(os.path.join(path, fname), encoding="utf-8") as f:
templates[fname.split(".")[0]] = scrub_html_template(f.read())
templates[fname.split(".", 1)[0]] = scrub_html_template(f.read())
self.set("__templates", templates or None)
@ -184,19 +186,40 @@ class FormMeta(Meta):
"""add search fields found in the doctypes indicated by link fields' options"""
for df in self.get("fields", {"fieldtype": "Link", "options": ["!=", "[Select]"]}):
if df.options:
search_fields = frappe.get_meta(df.options).search_fields
try:
search_fields = frappe.get_meta(df.options).search_fields
except frappe.DoesNotExistError:
self._show_missing_doctype_msg(df)
if search_fields:
search_fields = search_fields.split(",")
df.search_fields = [sf.strip() for sf in search_fields]
def _show_missing_doctype_msg(self, df):
# A link field is referring to non-existing doctype, this usually happens when
# customizations are removed or some custom app is removed but hasn't cleaned
# up after itself.
frappe.clear_last_message()
msg = _("Field {0} is referring to non-existing doctype {1}.").format(
frappe.bold(df.fieldname), frappe.bold(df.options)
)
if df.get("is_custom_field"):
custom_field_link = get_link_to_form("Custom Field", df.name)
msg += " " + _("Please delete the field from {2} or add the required doctype.").format(
custom_field_link
)
frappe.throw(msg, title=_("Missing DocType"))
def add_linked_document_type(self):
for df in self.get("fields", {"fieldtype": "Link"}):
if df.options:
try:
df.linked_document_type = frappe.get_meta(df.options).document_type
except frappe.DoesNotExistError:
# edge case where options="[Select]"
pass
self._show_missing_doctype_msg(df)
def load_print_formats(self):
print_formats = frappe.db.sql(
@ -226,7 +249,7 @@ class FormMeta(Meta):
def load_templates(self):
if not self.custom:
module = load_doctype_module(self.name)
app = module.__name__.split(".")[0]
app = module.__name__.split(".", 1)[0]
templates = {}
if hasattr(module, "form_grid_templates"):
for key, path in module.form_grid_templates.items():

View file

@ -36,7 +36,7 @@ def get_group_by_count(doctype: str, current_filters: str, field: str) -> list[d
ToDo = DocType("ToDo")
User = DocType("User")
count = Count("*").as_("count")
filtered_records = frappe.qb.engine.build_conditions(doctype, current_filters).select("name")
filtered_records = frappe.qb.get_query(doctype, filters=current_filters, fields=["name"])
return (
frappe.qb.from_(ToDo)

View file

@ -428,7 +428,7 @@ def add_total_row(result, columns, meta=None, is_tree=False, parent_field=None):
if isinstance(columns[0], str):
first_col = columns[0].split(":")
if len(first_col) > 1:
first_col_fieldtype = first_col[1].split("/")[0]
first_col_fieldtype = first_col[1].split("/", 1)[0]
else:
first_col_fieldtype = columns[0].get("fieldtype")

View file

@ -185,7 +185,7 @@ def extract_fieldname(field):
fieldname = field
for sep in (" as ", " AS "):
if sep in fieldname:
fieldname = fieldname.split(sep)[0]
fieldname = fieldname.split(sep, 1)[0]
# certain functions allowed, extract the fieldname from the function
if fieldname.startswith("count(") or fieldname.startswith("sum(") or fieldname.startswith("avg("):
@ -456,13 +456,14 @@ def handle_duration_fieldtype_values(doctype, data, fields):
def parse_field(field: str) -> tuple[str | None, str]:
"""Parse a field into parenttype and fieldname."""
key = field.split(" as ")[0]
key = field.split(" as ", 1)[0]
if key.startswith(("count(", "sum(", "avg(")):
raise ValueError
if "." in key:
return key.split(".")[0][4:-1], key.split(".")[1].strip("`")
table, column = key.split(".", 2)[:2]
return table[4:-1], column.strip("`")
return None, key.strip("`")

View file

@ -76,7 +76,7 @@ def search_widget(
standard_queries = frappe.get_hooks().standard_queries or {}
if query and query.split()[0].lower() != "select":
if query and query.split(maxsplit=1)[0].lower() != "select":
# by method
try:
is_whitelisted(frappe.get_attr(query))

View file

@ -6,6 +6,7 @@ import frappe
import frappe.utils
from frappe import _
from frappe.email.doctype.email_group.email_group import add_subscribers
from frappe.rate_limiter import rate_limit
from frappe.utils.safe_exec import is_job_queued
from frappe.utils.verified_command import get_signed_params, verify_request
from frappe.website.website_generator import WebsiteGenerator
@ -227,7 +228,6 @@ class Newsletter(WebsiteGenerator):
)
@frappe.whitelist(allow_guest=True)
def confirmed_unsubscribe(email, group):
"""unsubscribe the email(user) from the mailing list(email_group)"""
frappe.flags.ignore_permissions = True
@ -238,9 +238,13 @@ def confirmed_unsubscribe(email, group):
@frappe.whitelist(allow_guest=True)
def subscribe(email, email_group=_("Website")): # noqa
@rate_limit(limit=10, seconds=60 * 60)
def subscribe(email, email_group=None): # noqa
"""API endpoint to subscribe an email to a particular email group. Triggers a confirmation email."""
if email_group is None:
email_group = _("Website")
# build subscription confirmation URL
api_endpoint = frappe.utils.get_url(
"/api/method/frappe.email.doctype.newsletter.newsletter.confirm_subscription"

View file

@ -45,6 +45,7 @@ class Notification(Document):
frappe.cache().hdel("notifications", self.document_type)
def on_update(self):
frappe.cache().hdel("notifications", self.document_type)
path = export_module_json(self, self.is_standard, self.module)
if path:
# js

View file

@ -288,7 +288,11 @@ class FrappeClient:
if doctype != "User" and not frappe.db.exists("User", doc.get("owner")):
frappe.get_doc(
{"doctype": "User", "email": doc.get("owner"), "first_name": doc.get("owner").split("@")[0]}
{
"doctype": "User",
"email": doc.get("owner"),
"first_name": doc.get("owner").split("@", 1)[0],
}
).insert()
if update:

View file

@ -242,7 +242,7 @@ def parse_app_name(name: str) -> str:
_repo = name.split(":")[1].rsplit("/", 1)[1]
else:
_repo = name.rsplit("/", 2)[2]
repo = _repo.split(".")[0]
repo = _repo.split(".", 1)[0]
else:
_, repo, _ = fetch_details_from_tag(name)
return repo
@ -271,7 +271,7 @@ def install_app(name, verbose=False, set_as_patched=True, force=False):
frappe.clear_cache()
if name not in frappe.get_all_apps():
raise Exception("App not in apps.txt")
raise Exception(f"App {name} not in apps.txt")
if not force and name in installed_apps:
click.secho(f"App {name} already installed", fg="yellow")
@ -785,7 +785,7 @@ def is_downgrade(sql_file_path, verbose=False):
for app in all_apps:
app_name = app[0]
app_version = app[1].split(" ")[0]
app_version = app[1].split(" ", 1)[0]
if app_name == "frappe":
try:

View file

@ -88,8 +88,7 @@
"fieldtype": "Link",
"label": "Default User Role",
"mandatory_depends_on": "eval: doc.default_user_type == \"System User\"",
"options": "Role",
"reqd": 1
"options": "Role"
},
{
"description": "Must be enclosed in '()' and include '{0}', which is a placeholder for the user/login name. i.e. (&(objectclass=user)(uid={0}))",
@ -302,7 +301,7 @@
"in_create": 1,
"issingle": 1,
"links": [],
"modified": "2022-12-05 21:52:31.146035",
"modified": "2023-01-24 11:20:06.049708",
"modified_by": "Administrator",
"module": "Integrations",
"name": "LDAP Settings",

View file

@ -26,7 +26,7 @@ if TYPE_CHECKING:
class LDAPSettings(Document):
def validate(self):
self.default_user_type = self.default_user_type or "System User"
self.default_user_type = self.default_user_type or "Website User"
if not self.enabled:
return

View file

@ -173,7 +173,6 @@ class LDAP_TestCase:
"ldap_username_field",
"ldap_first_name_field",
"require_trusted_certificate",
"default_role",
] # fields that are required to have ldap functioning need to be mandatory
for mandatory_field in mandatory_fields:

View file

@ -35,54 +35,60 @@ DOCTYPES_FOR_DOCTYPE = {"DocType", *TABLE_DOCTYPES_FOR_DOCTYPE.values()}
def get_controller(doctype):
"""Returns the **class** object of the given DocType.
"""
Returns the locally cached **class** object of the given DocType.
For `custom` type, returns `frappe.model.document.Document`.
:param doctype: DocType name as string."""
def _get_controller():
from frappe.model.document import Document
from frappe.utils.nestedset import NestedSet
module_name, custom = frappe.db.get_value(
"DocType", doctype, ("module", "custom"), cache=True
) or ("Core", False)
if custom:
is_tree = frappe.db.get_value("DocType", doctype, "is_tree", ignore=True, cache=True)
_class = NestedSet if is_tree else Document
else:
class_overrides = frappe.get_hooks("override_doctype_class")
if class_overrides and class_overrides.get(doctype):
import_path = class_overrides[doctype][-1]
module_path, classname = import_path.rsplit(".", 1)
module = frappe.get_module(module_path)
if not hasattr(module, classname):
raise ImportError(f"{doctype}: {classname} does not exist in module {module_path}")
else:
module = load_doctype_module(doctype, module_name)
classname = doctype.replace(" ", "").replace("-", "")
if hasattr(module, classname):
_class = getattr(module, classname)
if issubclass(_class, BaseDocument):
_class = getattr(module, classname)
else:
raise ImportError(doctype)
else:
raise ImportError(doctype)
return _class
:param doctype: DocType name as string.
"""
if frappe.local.dev_server:
return _get_controller()
return import_controller(doctype)
site_controllers = frappe.controllers.setdefault(frappe.local.site, {})
if doctype not in site_controllers:
site_controllers[doctype] = _get_controller()
site_controllers[doctype] = import_controller(doctype)
return site_controllers[doctype]
def import_controller(doctype):
from frappe.model.document import Document
from frappe.utils.nestedset import NestedSet
module_name = "Core"
if doctype not in DOCTYPES_FOR_DOCTYPE:
meta = frappe.get_meta(doctype)
if meta.custom:
return NestedSet if meta.get("is_tree") else Document
module_name = meta.module
module_path = None
class_overrides = frappe.get_hooks("override_doctype_class")
if class_overrides and class_overrides.get(doctype):
import_path = class_overrides[doctype][-1]
module_path, classname = import_path.rsplit(".", 1)
module = frappe.get_module(module_path)
else:
module = load_doctype_module(doctype, module_name)
classname = doctype.replace(" ", "").replace("-", "")
class_ = getattr(module, classname, None)
if class_ is None:
raise ImportError(
doctype
if module_path is None
else f"{doctype}: {classname} does not exist in module {module_path}"
)
if not issubclass(class_, BaseDocument):
raise ImportError(f"{doctype}: {classname} is not a subclass of BaseDocument")
return class_
class BaseDocument:
_reserved_keywords = {
"doctype",
@ -1062,7 +1068,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

@ -115,7 +115,7 @@ def get_static_default_value(df, doctype_user_permissions, allowed_records):
return df.default
elif df.fieldtype == "Select" and df.options and df.options not in ("[Select]", "Loading..."):
return df.options.split("\n")[0]
return df.options.split("\n", 1)[0]
def validate_value_via_user_permissions(

View file

@ -435,7 +435,7 @@ class DatabaseQuery:
if not ("tab" in field and "." in field) or any(x for x in sql_functions if x in field):
continue
table_name = field.split(".")[0]
table_name = field.split(".", 1)[0]
if table_name.lower().startswith("group_concat("):
table_name = table_name[13:]
@ -897,8 +897,9 @@ class DatabaseQuery:
# will covert to
# `tabItem`.`idx` desc, `tabItem`.`modified` desc
args.order_by = ", ".join(
f"`tab{self.doctype}`.`{f.split()[0].strip()}` {f.split()[1].strip()}"
f"`tab{self.doctype}`.`{f_split[0].strip()}` {f_split[1].strip()}"
for f in meta.sort_field.split(",")
if (f_split := f.split(maxsplit=2))
)
else:
sort_field = meta.sort_field or "modified"
@ -1029,8 +1030,9 @@ def get_order_by(doctype, meta):
# will covert to
# `tabItem`.`idx` desc, `tabItem`.`modified` desc
order_by = ", ".join(
f"`tab{doctype}`.`{f.split()[0].strip()}` {f.split()[1].strip()}"
f"`tab{doctype}`.`{f_split[0].strip()}` {f_split[1].strip()}"
for f in meta.sort_field.split(",")
if (f_split := f.split(maxsplit=2))
)
else:

View file

@ -176,7 +176,7 @@ def update_naming_series(doc):
if doc.meta.autoname.startswith("naming_series:") and getattr(doc, "naming_series", None):
revert_series_if_last(doc.naming_series, doc.name, doc)
elif doc.meta.autoname.split(":")[0] not in ("Prompt", "field", "hash", "autoincrement"):
elif doc.meta.autoname.split(":", 1)[0] not in ("Prompt", "field", "hash", "autoincrement"):
revert_series_if_last(doc.meta.autoname, doc.name, doc)

View file

@ -59,8 +59,8 @@ class NamingSeries:
if not NAMING_SERIES_PATTERN.match(self.series):
frappe.throw(
_(
'Special Characters except "-", "#", ".", "/", "{" and "}" not allowed in naming series',
),
"Special Characters except '-', '#', '.', '/', '{{' and '}}' not allowed in naming series {0}"
).format(frappe.bold(self.series)),
exc=InvalidNamingSeriesError,
)

View file

@ -27,7 +27,7 @@ def rename_field(doctype, old_fieldname, new_fieldname):
frappe.db.sql(
"""update `tab%s` set parentfield=%s
where parentfield=%s"""
% (new_field.options.split("\n")[0], "%s", "%s"),
% (new_field.options.split("\n", 1)[0], "%s", "%s"),
(new_fieldname, old_fieldname),
)

View file

@ -252,7 +252,7 @@ def load_code_properties(doc, path):
if hasattr(doc, "get_code_fields"):
dirname, filename = os.path.split(path)
for key, extn in doc.get_code_fields().items():
codefile = os.path.join(dirname, filename.split(".")[0] + "." + extn)
codefile = os.path.join(dirname, filename.split(".", 1)[0] + "." + extn)
if os.path.exists(codefile):
with open(codefile) as txtfile:
doc.set(key, txtfile.read())

View file

@ -152,7 +152,7 @@ def run_single(patchmodule=None, method=None, methodargs=None, force=False):
return True
def execute_patch(patchmodule, method=None, methodargs=None):
def execute_patch(patchmodule: str, method=None, methodargs=None):
"""execute the patch"""
_patch_mode(True)
@ -162,7 +162,7 @@ def execute_patch(patchmodule, method=None, methodargs=None):
docstring = ""
else:
has_patch_file = True
patch = f"{patchmodule.split()[0]}.execute"
patch = f"{patchmodule.split(maxsplit=1)[0]}.execute"
_patch = frappe.get_attr(patch)
docstring = _patch.__doc__ or ""

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,4 @@ 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
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

@ -4,7 +4,7 @@ import Field from "./Field.vue";
import EditableInput from "./EditableInput.vue";
import { ref } from "vue";
import { useStore } from "../store";
import { move_children_to_parent } from "../utils";
import { move_children_to_parent, confirm_dialog } from "../utils";
let props = defineProps(["section", "column"]);
let store = useStore();
@ -24,32 +24,61 @@ function remove_column() {
if (store.is_customize_form && props.column.df.is_custom_field == 0) {
frappe.msgprint(__("Cannot delete standard field. You can hide it if you want"));
throw "cannot delete standard field";
} else if (props.column.fields.length == 0 || store.has_standard_field(props.column)) {
delete_column();
} else {
confirm_dialog(
__("Delete Column", null, "Title of confirmation dialog"),
__("Are you sure you want to delete the column? All the fields in the column will be moved to the previous column.", null, "Confirmation dialog message"),
() => delete_column(),
__("Delete column", null, "Button text"),
() => delete_column(true),
__("Delete entire column with fields", null, "Button text")
);
}
}
function delete_column(with_children) {
// move all fields to previous column
let columns = props.section.columns;
let index = columns.indexOf(props.column);
if (index > 0) {
let prev_column = columns[index - 1];
prev_column.fields = [...prev_column.fields, ...props.column.fields];
} else {
if (props.column.fields.length != 0) {
// create a new column if current column has fields and push fields to it
columns.unshift({
df: store.get_df("Column Break"),
fields: props.column.fields,
is_first: true,
});
index++;
if (with_children && index == 0 && columns.length == 1) {
if (props.column.fields.length == 0) {
frappe.msgprint(__("Section must have at least one column"));
throw "section must have at least one column";
}
columns.unshift({
df: store.get_df("Column Break"),
fields: [],
is_first: true,
});
index++;
}
if (!with_children) {
if (index > 0) {
let prev_column = columns[index - 1];
prev_column.fields = [...prev_column.fields, ...props.column.fields];
} else {
// set next column as first column
let next_column = columns[index + 1];
if (next_column) {
next_column.is_first = true;
if (props.column.fields.length == 0) {
// set next column as first column
let next_column = columns[index + 1];
if (next_column) {
next_column.is_first = true;
} else {
frappe.msgprint(__("Section must have at least one column"));
throw "section must have at least one column";
}
} else {
frappe.msgprint(__("Section must have at least one column"));
throw "section must have at least one column";
// create a new column if current column has fields and push fields to it
columns.unshift({
df: store.get_df("Column Break"),
fields: props.column.fields,
is_first: true,
});
index++;
}
}
}

View file

@ -32,10 +32,21 @@ function move_fields_to_column() {
function duplicate_field() {
let duplicate_field = clone_field(props.field);
if (store.is_customize_form) {
duplicate_field.df.is_custom_field = 1;
}
if (duplicate_field.df.label) {
duplicate_field.df.label = duplicate_field.df.label + " Copy";
}
duplicate_field.df.fieldname = "";
duplicate_field.df.__islocal = 1;
duplicate_field.df.__unsaved = 1;
duplicate_field.df.owner = frappe.session.user;
delete duplicate_field.df.creation;
delete duplicate_field.df.modified;
delete duplicate_field.df.modified_by;
// push duplicate_field after props.field in the same column
let index = props.column.fields.indexOf(props.field);

View file

@ -109,6 +109,12 @@ onMounted(() => {
box-shadow: var(--card-shadow);
background-color: var(--card-bg);
:deep(.section-columns.has-one-column .field) {
input.form-control, .signature-field {
width: calc(50% - 19px);
}
}
:deep(.column-container .field.sortable-chosen) {
background-color: var(--bg-light-gray);
border-radius: var(--border-radius-sm);
@ -191,6 +197,8 @@ onMounted(() => {
}
:deep(.preview) {
--field-placeholder-color: var(--fg-bg-color);
.tab, .column, .field, [data-is-custom="1"] {
background-color: var(--fg-color);
}
@ -221,6 +229,12 @@ onMounted(() => {
.section-columns {
margin-top: 8px;
&.has-one-column .field {
input.form-control, .signature-field {
width: calc(50% - 15px);
}
}
.section-columns-container {
.column {
padding-left: 15px;

View file

@ -4,7 +4,7 @@ import Column from "./Column.vue";
import EditableInput from "./EditableInput.vue";
import { ref } from "vue";
import { useStore } from "../store";
import { section_boilerplate, move_children_to_parent } from "../utils";
import { section_boilerplate, move_children_to_parent, confirm_dialog } from "../utils";
let props = defineProps(["tab", "section"]);
let store = useStore();
@ -27,25 +27,42 @@ function remove_section() {
if (store.is_customize_form && props.section.df.is_custom_field == 0) {
frappe.msgprint(__("Cannot delete standard field. You can hide it if you want"));
throw "cannot delete standard field";
} else if (store.has_standard_field(props.section)) {
delete_section();
} else if (is_section_empty()) {
delete_section(true);
} else {
confirm_dialog(
__("Delete Section", null, "Title of confirmation dialog"),
__("Are you sure you want to delete the section? All the columns along with fields in the section will be moved to the previous section.", null, "Confirmation dialog message"),
() => delete_section(),
__("Delete section", null, "Button text"),
() => delete_section(true),
__("Delete entire section with columns", null, "Button text")
);
}
}
function delete_section(with_children) {
let sections = props.tab.sections;
let index = sections.indexOf(props.section);
if (index > 0) {
let prev_section = sections[index - 1];
if (!is_section_empty()) {
// move all columns from current section to previous section
prev_section.columns = [...prev_section.columns, ...props.section.columns];
if (!with_children) {
if (index > 0) {
let prev_section = sections[index - 1];
if (!is_section_empty()) {
// move all columns from current section to previous section
prev_section.columns = [...prev_section.columns, ...props.section.columns];
}
} else if (index == 0 && !is_section_empty()) {
// create a new section and push columns to it
sections.unshift({
df: store.get_df("Section Break"),
columns: props.section.columns,
is_first: true,
});
index++;
}
} else if (index == 0 && !is_section_empty()) {
// create a new section and push columns to it
sections.unshift({
df: store.get_df("Section Break"),
columns: props.section.columns,
is_first: true,
});
index++;
}
// remove section
@ -130,7 +147,13 @@ function move_sections_to_tab() {
</div>
</div>
<div v-if="section.df.description" class="section-description">{{ section.df.description }}</div>
<div class="section-columns" :class="{ hidden: section.df.collapsible && collapsed }">
<div
class="section-columns"
:class="{
hidden: section.df.collapsible && collapsed,
'has-one-column': section.columns.length === 1
}"
>
<draggable
class="section-columns-container"
:style="{

View file

@ -3,7 +3,7 @@ import Section from "./Section.vue";
import EditableInput from "./EditableInput.vue";
import draggable from "vuedraggable";
import { useStore } from "../store";
import { section_boilerplate } from "../utils";
import { section_boilerplate, confirm_dialog } from "../utils";
import { ref, computed, nextTick } from "vue";
let store = useStore();
@ -51,44 +51,51 @@ function add_new_section() {
function is_current_tab_empty() {
// check if sections have columns and it contains fields
return !store.current_tab.sections.some(section => {
// if section doesnt have fields remove the section
let has_fields = section.columns.some(column => column.fields.length);
if (!has_fields) {
// remove section if empty
let index = store.current_tab.sections.indexOf(section);
store.current_tab.sections.splice(index, 1);
has_fields = true;
}
return has_fields;
});
return !store.current_tab.sections.some(
section => section.columns.some(column => column.fields.length)
);
}
function remove_tab() {
if (store.is_customize_form && store.current_tab.df.is_custom_field == 0) {
frappe.msgprint(__("Cannot delete standard field. You can hide it if you want"));
throw "cannot delete standard field";
} else if (store.has_standard_field(store.current_tab)) {
delete_tab();
} else if (is_current_tab_empty()) {
delete_tab(true);
} else {
confirm_dialog(
__("Delete Tab", null, "Title of confirmation dialog"),
__("Are you sure you want to delete the tab? All the sections along with fields in the tab will be moved to the previous tab.", null, "Confirmation dialog message"),
() => delete_tab(),
__("Delete tab", null, "Button text"),
() => delete_tab(true),
__("Delete entire tab with sections", null, "Button text")
);
}
}
function delete_tab(with_children) {
let tabs = layout.value.tabs;
let index = tabs.indexOf(store.current_tab);
if (index > 0) {
let prev_tab = tabs[index - 1];
if (!is_current_tab_empty()) {
// move all sections from current tab to previous tab
prev_tab.sections = [...prev_tab.sections, ...store.current_tab.sections];
if (!with_children) {
if (index > 0) {
let prev_tab = tabs[index - 1];
if (!is_current_tab_empty()) {
// move all sections from current tab to previous tab
prev_tab.sections = [...prev_tab.sections, ...store.current_tab.sections];
}
} else {
// create a new tab and push sections to it
tabs.unshift({
df: store.get_df("Tab Break", "", __("Details")),
sections: store.current_tab.sections,
is_first: true,
});
index++;
}
} else {
// create a new tab and push sections to it
tabs.unshift({
df: store.get_df("Tab Break", "", __("Details")),
sections: store.current_tab.sections,
is_first: true,
});
index++;
}
// remove tab
@ -185,7 +192,7 @@ function remove_tab() {
</template>
</draggable>
<div class="empty-tab" :hidden="store.read_only">
<div>{{ __("Drag & Drop a section here") }}</div>
<div>{{ __("Drag & Drop a section here from another tab") }}</div>
<div>{{ __("OR") }}</div>
<button class="btn btn-default btn-sm" @click="add_new_section">
{{ __("Add a new section") }}

View file

@ -182,8 +182,10 @@ export const useStore = defineStore("form-builder-store", {
} else {
this.doc.fields = this.get_updated_fields();
this.validate_fields(this.doc.fields, this.doc.istable);
await frappe.call("frappe.client.save", { doc: this.doc });
frappe.toast("Fields Table Updated");
await frappe.call({
method: "frappe.desk.form.save.savedocs",
args: { doc: this.doc, action: "Save" },
});
}
this.fetch();
} catch (e) {

View file

@ -324,3 +324,28 @@ export function clone_field(field) {
cloned_field.df.name = frappe.utils.get_random(8);
return cloned_field;
}
export function confirm_dialog(
title,
message,
primary_action,
primary_action_label,
secondary_action,
secondary_action_label
) {
let d = new frappe.ui.Dialog({
title: title,
primary_action_label: primary_action_label || __("Yes"),
primary_action: () => {
primary_action && primary_action();
d.hide();
},
secondary_action_label: secondary_action_label || __("No"),
secondary_action: () => {
secondary_action && secondary_action();
d.hide();
},
});
d.show();
d.set_message(message);
}

View file

@ -7,22 +7,49 @@ frappe.ui.form.ControlPassword = class ControlPassword extends frappe.ui.form.Co
make_input() {
var me = this;
super.make_input();
this.$input
.parent()
.append($('<span class="password-strength-indicator indicator"></span>'));
this.$wrapper
.find(".control-input-wrapper")
.append($('<p class="password-strength-message text-muted small hidden"></p>'));
this.indicator = this.$wrapper.find(".password-strength-indicator");
this.indicator = $(
`<div class="password-strength-indicator hidden">
<div class="progress-text"></div>
<div class="progress">
<div class="progress-bar" role="progressbar"
aria-valuenow="0"
aria-valuemin="0" aria-valuemax="100">
</div>
</div>
</div>`
).insertAfter(this.$input);
this.progress_text = this.indicator.find(".progress-text");
this.progress_bar = this.indicator.find(".progress-bar");
this.message = this.$wrapper.find(".help-box");
this.$input.on("keyup", () => {
clearTimeout(this.check_password_timeout);
this.check_password_timeout = setTimeout(() => {
this.$input.on(
"keyup",
frappe.utils.debounce(() => {
let hide_icon = me.$input.val() && !me.$input.val().includes("*");
me.toggle_password.toggleClass("hidden", !hide_icon);
me.get_password_strength(me.$input.val());
}, 500);
}, 500)
);
this.toggle_password = $(`
<div class="toggle-password hidden">
${frappe.utils.icon("unhide", "sm")}
</div>
`).insertAfter(this.$input);
this.toggle_password.on("click", () => {
if (this.$input.attr("type") === "password") {
this.$input.attr("type", "text");
this.toggle_password.html(frappe.utils.icon("hide", "sm"));
} else {
this.$input.attr("type", "password");
this.toggle_password.html(frappe.utils.icon("unhide", "sm"));
}
});
!this.value && this.toggle_password.removeClass("hidden");
}
disable_password_checks() {
@ -33,6 +60,13 @@ frappe.ui.form.ControlPassword = class ControlPassword extends frappe.ui.form.Co
if (!this.enable_password_checks) {
return;
}
if (!value) {
this.indicator.addClass("hidden");
this.message.addClass("hidden");
return;
}
var me = this;
frappe.call({
type: "POST",
@ -43,15 +77,34 @@ frappe.ui.form.ControlPassword = class ControlPassword extends frappe.ui.form.Co
callback: function (r) {
if (r.message) {
let score = r.message.score;
var indicators = ["red", "red", "orange", "yellow", "green"];
var indicators = ["red", "red", "orange", "blue", "green"];
me.set_strength_indicator(indicators[score]);
}
},
});
}
set_strength_indicator(color) {
var message = __("Include symbols, numbers and capital letters in the password");
this.indicator.removeClass().addClass("password-strength-indicator indicator " + color);
let strength = {
red: [__("Weak"), "danger", 25],
orange: [__("Average"), "warning", 50],
blue: [__("Strong"), "info", 75],
green: [__("Excellent"), "success", 100],
};
let progress_text = strength[color][0];
let progress_color = strength[color][1];
let progress_percent = strength[color][2];
this.indicator.removeClass("hidden");
this.progress_text.html(progress_text).css("color", `var(--${color}-500)`);
this.progress_bar
.css("width", progress_percent + "%")
.attr("aria-valuenow", progress_percent)
.removeClass()
.addClass("progress-bar progress-bar-" + progress_color);
let message = __("Include symbols, numbers and capital letters in the password");
this.message.html(message).toggleClass("hidden", color == "green");
}
};

View file

@ -621,10 +621,6 @@ frappe.ui.form.Form = class FrappeForm {
this.$wrapper.trigger("render_complete");
if (!this.hidden) {
this.layout.show_empty_form_message();
}
frappe.after_ajax(() => {
$(document).ready(() => {
this.scroll_to_element();
@ -1520,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);
@ -2051,16 +2047,12 @@ frappe.ui.form.Form = class FrappeForm {
this.doc.docstatus === 0
)
) {
if (wrapper.length) {
wrapper.hide();
wrapper.html("");
}
wrapper.length && wrapper.remove();
return;
}
if (!wrapper.length) {
wrapper = $('<div class="submission-queue-banner form-message yellow">');
wrapper = $('<div class="submission-queue-banner form-message">');
this.layout.wrapper.prepend(wrapper);
}
@ -2070,49 +2062,40 @@ frappe.ui.form.Form = class FrappeForm {
args: { doctype: this.doctype, docname: this.docname },
})
.then((r) => {
if (r.message.latest_submission) {
if (r.message?.latest_submission) {
// if we are here that means some submission(s) were queued and are in queued/failed state
let col_width = 4;
let failed_link = "";
let submission_label = __("Previous Submission");
let secondary = "";
let div_class = "col-md-12";
if (r.message.latest_failed_submission) {
if (r.message.latest_failed_submission !== r.message.latest_submission) {
col_width = 3;
failed_link = `<div class="col-md-3">
<a href='/app/submission-queue/${r.message.latest_failed_submission}'>${__(
"Previous Falied Submission"
)}</a>
</div>`;
} else {
submission_label = __("Previous Falied Submission");
}
if (r.message.exc) {
secondary = `: <span>${r.message.exc}</span>`;
} else {
div_class = "col-md-6";
secondary = `
</div>
<div class="col-md-6">
<a href='/app/submission-queue?ref_doctype=${encodeURIComponent(
this.doctype
)}&ref_docname=${encodeURIComponent(this.docname)}'>${__(
"All Submissions"
)}</a>
`;
}
let html = `
<div class="row">
<div class="col-md-${col_width}">
<strong>${__("Submission Status:")}</strong>
<div class="row">
<div class="${div_class}">
<a href='/app/submission-queue/${r.message.latest_submission}'>${submission_label} (${r.message.status})</a>${secondary}
</div>
</div>
<div class="col-md-${col_width}">
<a href='/app/submission-queue/${r.message.latest_submission}'>${submission_label}</a>
</div>
${failed_link}
<div class="col-md-${col_width}">
<a href='/app/submission-queue?ref_doctype=${encodeURIComponent(
this.doctype
)}&ref_docname=${encodeURIComponent(this.docname)}'>${__(
"All Submissions"
)}</a>
</div>
</div>
`;
`;
wrapper.show();
wrapper.removeClass("red").removeClass("yellow");
wrapper.addClass(r.message.status == "Failed" ? "red" : "yellow");
wrapper.html(html);
} else {
wrapper.hide();
wrapper.html("");
wrapper.remove();
}
});
}

View file

@ -52,17 +52,6 @@ frappe.ui.form.Layout = class Layout {
this.setup_events();
}
show_empty_form_message() {
if (
!(
this.wrapper.find(".frappe-control:visible").length ||
this.wrapper.find(".section-head.collapsed").length
)
) {
this.show_message(__("This form does not have any input"));
}
}
get_doctype_fields() {
let fields = [this.get_new_name_field()];
if (this.doctype_layout) {

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);
}
@ -896,7 +898,7 @@ frappe.views.ListView = class ListView extends frappe.views.BaseList {
const comment_count = `<span class="comment-count">
${frappe.utils.icon("small-message")}
${doc._comment_count > 99 ? "99+" : doc._comment_count}
${doc._comment_count > 99 ? "99+" : doc._comment_count || 0}
</span>`;
html += `

View file

@ -112,9 +112,14 @@ $.extend(frappe.model, {
{ fieldname: "name", fieldtype: "Link", label: __("ID") },
{ fieldname: "owner", fieldtype: "Link", label: __("Created By"), options: "User" },
{ fieldname: "idx", fieldtype: "Int", label: __("Index") },
{ fieldname: "creation", fieldtype: "Date", label: __("Created On") },
{ fieldname: "modified", fieldtype: "Date", label: __("Last Updated On") },
{ fieldname: "modified_by", fieldtype: "Data", label: __("Last Updated By") },
{ fieldname: "creation", fieldtype: "Datetime", label: __("Created On") },
{ fieldname: "modified", fieldtype: "Datetime", label: __("Last Updated On") },
{
fieldname: "modified_by",
fieldtype: "Link",
label: __("Last Updated By"),
options: "User",
},
{ fieldname: "_user_tags", fieldtype: "Data", label: __("Tags") },
{ fieldname: "_liked_by", fieldtype: "Data", label: __("Liked By") },
{ fieldname: "_comments", fieldtype: "Text", label: __("Comments") },

View file

@ -195,7 +195,9 @@ $.extend(frappe.perm, {
}
if (!perm) {
return df && (cint(df.hidden) || cint(df.hidden_due_to_dependency)) ? "None" : "Write";
let is_hidden = df && (cint(df.hidden) || cint(df.hidden_due_to_dependency));
let is_read_only = df && cint(df.read_only);
return is_hidden ? "None" : is_read_only ? "Read" : "Write";
}
if (!df.permlevel) df.permlevel = 0;

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

@ -39,13 +39,16 @@ frappe.ui.Filter = class {
this.invalid_condition_map = {
Date: ["like", "not like"],
Datetime: ["like", "not like"],
Datetime: ["like", "not like", "in", "not in", "=", "!="],
Data: ["Between", "Timespan"],
Select: ["like", "not like", "Between", "Timespan"],
Link: ["Between", "Timespan", ">", "<", ">=", "<="],
Currency: ["Between", "Timespan"],
Color: ["Between", "Timespan"],
Check: this.conditions.map((c) => c[0]).filter((c) => c !== "="),
Code: ["Between", "Timespan", ">", "<", ">=", "<=", "in", "not in"],
Password: ["Between", "Timespan", ">", "<", ">=", "<=", "in", "not in"],
Rating: ["like", "not like", "Between", "in", "not in", "Timespan"],
};
}
@ -497,10 +500,14 @@ frappe.ui.filter_utils = {
"Small Text",
"Text Editor",
"Code",
"Attach",
"Attach Image",
"Markdown Editor",
"HTML Editor",
"Tag",
"Phone",
"Comments",
"Barcode",
"Dynamic Link",
"Read Only",
"Assign",

View file

@ -381,6 +381,8 @@ export default class WebForm extends frappe.ui.FieldGroup {
web_form: this.name,
for_payment,
},
btn: $("btn-primary"),
freeze: true,
callback: (response) => {
// Check for any exception in response
if (!response.exc) {

View file

@ -5,20 +5,43 @@
@import "phone_picker";
// password
.form-control[data-fieldtype="Password"] {
position: inherit;
}
.frappe-control[data-fieldtype="Password"] {
.control-input-wrapper {
position: relative;
.password-strength-indicator {
// TODO: Review
float: right;
padding: 15px;
margin-top: -41px;
margin-right: -7px;
}
.form-control[data-fieldtype="Password"] {
position: inherit;
}
.password-strength-message {
margin-top: -10px;
.password-strength-indicator {
display: flex;
align-items: center;
position: absolute;
gap: 5px;
top: -20px;
right: 0px;
.progress-text {
font-size: var(--text-xs);
font-weight: 600;
}
.progress {
background-color: var(--bg-light-gray);
width: 100px;
height: 5px;
}
}
.toggle-password {
position: absolute;
top: 4px;
right: 8px;
padding: 3px;
z-index: 3;
cursor: pointer;
}
}
}
// select
@ -232,6 +255,10 @@ a.progress-small {
background-color: var(--red-500);
}
.progress-bar-info {
background-color: var(--blue-500);
}
.progress-bar-warning {
background-color: var(--orange-500);
}

View file

@ -163,10 +163,12 @@ $input-height: 28px !default;
--bg-green: var(--dark-green-50);
--bg-yellow: var(--yellow-50);
--bg-orange: var(--orange-50);
--bg-red: var(--red-50);
--bg-red: var(--red-100);
--bg-gray: var(--gray-200);
--bg-grey: var(--gray-200);
--bg-light-gray: var(--gray-100);
--bg-dark-gray: var(--gray-900);
--bg-dark-gray: var(--gray-400);
--bg-darkgrey: var(--gray-400);
--bg-purple: var(--purple-100);
--bg-pink: var(--pink-50);
--bg-cyan: var(--cyan-50);
@ -186,12 +188,15 @@ $input-height: 28px !default;
--text-on-dark-blue: var(--blue-800);
--text-on-green: var(--dark-green-700);
--text-on-yellow: var(--yellow-700);
--text-on-orange: var(--orange-600);
--text-on-orange: var(--orange-700);
--text-on-red: var(--red-600);
--text-on-gray: var(--gray-600);
--text-on-gray: var(--gray-700);
--text-on-grey: var(--gray-700);
--text-on-darkgrey: var(--gray-800);
--text-on-dark-gray: var(--gray-800);
--text-on-light-gray: var(--gray-800);
--text-on-purple: var(--purple-700);
--text-on-pink: var(--pink-600);
--text-on-pink: var(--pink-700);
--text-on-cyan: var(--cyan-800);
// alert colors

View file

@ -43,16 +43,18 @@
}
}
// hide row index in 6 column child tables
.form-column.col-sm-6 .form-grid {
.row-index {
display: none;
}
.btn-open-row {
.edit-grid-row {
// hide row index in 6/4 column child tables
.form-column.col-sm-6, .form-column.col-sm-4 {
.form-grid {
.row-index {
display: none;
}
.btn-open-row {
.edit-grid-row {
display: none;
}
}
}
}

View file

@ -1,19 +1,3 @@
@mixin indicator-pill-color($color) {
background: var(--bg-#{$color});
color: var(--text-on-#{$color});
&::before,
&::after {
background: var(--text-on-#{$color});
}
}
@mixin indicator-color($color) {
&::before,
&::after {
background: var(--text-on-#{$color});
}
}
.indicator,
.indicator-pill,
.indicator-pill-right,
@ -67,111 +51,30 @@
margin: 0 0 0 4px;
}
.indicator.green {
@include indicator-color('green');
$indicator-colors: green, cyan, blue, orange, yellow, gray, grey, red, pink, darkgrey, purple, light-blue;
@each $color in $indicator-colors {
.indicator.#{"" + $color} {
&::before,
&::after {
background: var(--indicator-dot-#{$color});
}
}
.indicator-pill.#{"" + $color},
.indicator-pill-right.#{"" + $color},
.indicator-pill-round.#{"" + $color} {
background: var(--bg-#{$color});
color: var(--text-on-#{$color});
&::before,
&::after {
background: var(--text-on-#{$color});
}
}
.indicator {
--indicator-dot-#{"" + $color}: var(--text-on-#{$color});
}
}
.indicator-pill.green,
.indicator-pill-right.green,
.indicator-pill-round.green {
@include indicator-pill-color('green');
}
.indicator.cyan {
@include indicator-color('cyan');
}
.indicator-pill.cyan,
.indicator-pill-right.cyan,
.indicator-pill-round.cyan {
@include indicator-pill-color('cyan');
}
.indicator.blue {
@include indicator-color('blue');
}
.indicator-pill.blue,
.indicator-pill-right.blue,
.indicator-pill-round.blue {
@include indicator-pill-color('blue');
}
.indicator.orange {
@include indicator-color('orange');
}
.indicator-pill.orange,
.indicator-pill-right.orange
.indicator-pill-round.orange {
@include indicator-pill-color('orange');
}
.indicator.yellow {
@include indicator-color('yellow');
}
.indicator-pill.yellow,
.indicator-pill-right.yellow
.indicator-pill-round.yellow {
@include indicator-pill-color('yellow');
}
.indicator.gray,
.indicator.grey {
@include indicator-color('gray');
}
.indicator-pill.gray,
.indicator-pill-right.gray,
.indicator-pill-round.gray,
.indicator-pill.grey,
.indicator-pill-right.grey,
.indicator-pill-round.grey {
@include indicator-pill-color('light-gray');
}
.indicator.red {
@include indicator-color('red');
}
.indicator-pill.red,
.indicator-pill-right.red,
.indicator-pill-round.red {
@include indicator-pill-color('red');
}
.indicator.pink {
@include indicator-color('pink');
}
.indicator-pill.pink,
.indicator-pill-right.pink,
.indicator-pill-round.pink {
@include indicator-pill-color('pink');
}
.indicator-pill.darkgrey,
.indicator-pill-right.darkgrey,
.indicator-pill-round.darkgrey {
@include indicator-pill-color('gray');
}
.indicator-pill.purple,
.indicator-pill-right.purple,
.indicator-pill-round.purple {
@include indicator-pill-color('purple');
}
.indicator.light-blue {
@include indicator-color('light-blue');
}
.indicator-pill.light-blue,
.indicator-pill-right.light-blue,
.indicator-pill-round.light-blue {
@include indicator-pill-color('light-blue');
}
.indicator.blink {
animation: blink 1s linear infinite;

View file

@ -47,27 +47,36 @@
// Background Text Color Pairs
--bg-blue: var(--blue-600);
--bg-light-blue: var(--blue-400);
--bg-light-blue: var(--blue-600);
--bg-dark-blue: var(--blue-900);
--bg-green: var(--dark-green-500);
--bg-yellow: var(--yellow-500);
--bg-orange: var(--orange-500);
--bg-red: var(--red-500);
--bg-gray: var(--gray-600);
--bg-green: var(--green-800);
--bg-yellow: var(--yellow-700);
--bg-orange: var(--orange-700);
--bg-red: var(--red-600);
--bg-gray: var(--gray-400);
--bg-grey: var(--gray-400);
--bg-darkgrey: var(--gray-600);
--bg-dark-gray: var(--gray-600);
--bg-light-gray: var(--gray-700);
--bg-dark-gray: var(--gray-300);
--bg-purple: var(--purple-600);
--bg-purple: var(--purple-700);
--bg-pink: var(--pink-700);
--bg-cyan: var(--cyan-800);
--text-on-blue: var(--blue-50);
--text-on-light-blue: var(--blue-100);
--text-on-light-blue: var(--blue-50);
--text-on-dark-blue: var(--blue-300);
--text-on-green: var(--dark-green-50);
--text-on-yellow: var(--yellow-50);
--text-on-orange: var(--orange-100);
--text-on-red: var(--red-50);
--text-on-gray: var(--gray-300);
--text-on-gray: var(--gray-50);
--text-on-grey: var(--gray-50);
--text-on-darkgrey: var(--gray-200);
--text-on-dark-gray: var(--gray-200);
--text-on-light-gray: var(--gray-100);
--text-on-purple: var(--purple-100);
--text-on-pink: var(--pink-100);
--text-on-cyan: var(--cyan-100);
// alert colors
--alert-text-danger: var(--red-300);
@ -190,4 +199,11 @@
color: var(--text-color);
background: var(--gray-500);
}
$indicator-colors: green, cyan, blue, orange, yellow, gray, grey, red, pink, darkgrey, purple, light-blue;
@each $color in $indicator-colors {
.indicator {
--indicator-dot-#{"" + $color}: var(--bg-#{$color});
}
}
}

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

@ -7,7 +7,7 @@ from frappe.query_builder.terms import ParameterizedFunction, ParameterizedValue
from frappe.query_builder.utils import (
Column,
DocType,
get_qb_engine,
get_query,
get_query_builder,
patch_query_aggregation,
patch_query_execute,

View file

@ -119,9 +119,9 @@ class Cast_(Function):
def _aggregate(function, dt, fieldname, filters, **kwargs):
return (
frappe.qb.engine.build_conditions(dt, filters)
.select(function(PseudoColumn(fieldname)))
.run(**kwargs)[0][0]
frappe.qb.get_query(dt, filters=filters, fields=[function(PseudoColumn(fieldname))]).run(
**kwargs
)[0][0]
or 0
)

View file

@ -2,8 +2,7 @@ from enum import Enum
from importlib import import_module
from typing import Any, Callable, get_type_hints
from pypika import Query
from pypika.queries import Column
from pypika.queries import Column, QueryBuilder
from pypika.terms import PseudoColumn
import frappe
@ -55,10 +54,10 @@ def get_query_builder(type_of_db: str) -> Postgres | MariaDB:
return picks[db]
def get_qb_engine():
def get_query(*args, **kwargs) -> QueryBuilder:
from frappe.database.query import Engine
return Engine()
return Engine().get_query(*args, **kwargs)
def get_attr(method_string):

View file

@ -123,7 +123,7 @@ def get_static_pages_from_all_apps():
files_to_index = glob(path_to_index + "/**/*.html", recursive=True)
files_to_index.extend(glob(path_to_index + "/**/*.md", recursive=True))
for file in files_to_index:
route = os.path.relpath(file, path_to_index).split(".")[0]
route = os.path.relpath(file, path_to_index).split(".", maxsplit=1)[0]
if route.endswith("index"):
route = route.rsplit("index", 1)[0]
routes_to_index.append(route)

View file

@ -54,9 +54,12 @@ def add_comment(comment, comment_email, comment_by, reference_doctype, reference
pass
else:
# notify creator
creator_email = frappe.db.get_value("User", doc.owner, "email") or doc.owner
subject = _("New Comment on {0}: {1}").format(doc.doctype, doc.get_title())
frappe.sendmail(
recipients=frappe.db.get_value("User", doc.owner, "email") or doc.owner,
subject=_("New Comment on {0}: {1}").format(doc.doctype, doc.name),
recipients=creator_email,
subject=subject,
message=content,
reference_doctype=doc.doctype,
reference_name=doc.name,

View file

@ -2,22 +2,25 @@ import ast
import copy
import glob
import os
import pathlib
import shutil
import unittest
from io import StringIO
from unittest.mock import patch
import yaml
import frappe
from frappe.tests.utils import FrappeTestCase
from frappe.modules.patch_handler import get_all_patches
from frappe.utils.boilerplate import (
PatchCreator,
_create_app_boilerplate,
_get_user_inputs,
github_workflow_template,
)
class TestBoilerPlate(FrappeTestCase):
class TestBoilerPlate(unittest.TestCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
@ -180,3 +183,30 @@ class TestBoilerPlate(FrappeTestCase):
ast.parse(p.read())
except Exception as e:
self.fail(f"Can't parse python file in new app: {python_file}\n" + str(e))
def test_new_patch_util(self):
user_inputs = {
"app_name": "frappe",
"doctype": "User",
"docstring": "Delete all users",
"file_name": "", # Accept default
"patch_folder_confirmation": "Y",
}
patches_txt = pathlib.Path(pathlib.Path(frappe.get_app_path("frappe", "patches.txt")))
original_patches = patches_txt.read_text()
with patch("sys.stdin", self.get_user_input_stream(user_inputs)):
patch_creator = PatchCreator()
patch_creator.fetch_user_inputs()
patch_creator.create_patch_file()
patches = get_all_patches()
expected_patch = "frappe.core.doctype.user.patches.delete_all_users"
self.assertIn(expected_patch, patches)
self.assertTrue(patch_creator.patch_file.exists())
# Cleanup
shutil.rmtree(patch_creator.patch_file.parents[0])
patches_txt.write_text(original_patches)

View file

@ -330,7 +330,7 @@ class TestCommands(BaseTestCommands):
# test 2: bare functionality for single site
self.execute("bench --site {site} list-apps")
self.assertEqual(self.returncode, 0)
list_apps = {_x.split()[0] for _x in self.stdout.split("\n")}
list_apps = {_x.split(maxsplit=1)[0] for _x in self.stdout.split("\n")}
doctype = frappe.get_single("Installed Applications").installed_applications
if doctype:
installed_apps = {x.app_name for x in doctype}

View file

@ -545,7 +545,7 @@ class TestDB(FrappeTestCase):
self.assertEqual((frappe.db.count("Note")), 2)
# simple filters
self.assertEqual((frappe.db.count("Note", ["title", "=", "note1"])), 1)
self.assertEqual((frappe.db.count("Note", [["title", "=", "note1"]])), 1)
frappe.get_doc(doctype="Note", title="note3", content="something other").insert()

View file

@ -229,9 +229,7 @@ class TestReportview(FrappeTestCase):
)
def test_none_filter(self):
query = frappe.qb.engine.get_query(
"DocType", fields="name", filters={"restrict_to_domain": None}
)
query = frappe.qb.get_query("DocType", fields="name", filters={"restrict_to_domain": None})
sql = str(query).replace("`", "").replace('"', "")
condition = "restrict_to_domain IS NULL"
self.assertIn(condition, sql)

View file

@ -0,0 +1,18 @@
import random
from string import printable
from time import time
from unittest import TestCase
from frappe.utils.password_strength import test_password_strength
class TestPasswordStrength(TestCase):
def test_long_password(self):
password = "".join(random.choice(printable) for _ in range(600))
start_second = time()
result = test_password_strength(password)
end_second = time()
self.assertLess(end_second - start_second, 10)
self.assertIn("feedback", result)

View file

@ -59,7 +59,7 @@ class TestPatches(FrappeTestCase):
else:
if patchmodule.startswith("finally:"):
patchmodule = patchmodule.split("finally:")[-1]
self.assertTrue(frappe.get_attr(patchmodule.split()[0] + ".execute"))
self.assertTrue(frappe.get_attr(patchmodule.split(maxsplit=1)[0] + ".execute"))
frappe.flags.in_install = False
@ -149,7 +149,7 @@ def check_patch_files(app):
patch_dir = Path(frappe.get_app_path(app)) / "patches"
app_patches = [p.split()[0] for p in patch_handler.get_patches_from_app(app)]
app_patches = [p.split(maxsplit=1)[0] for p in patch_handler.get_patches_from_app(app)]
missing_patches = []

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