Merge branch 'develop' into 32489-role-perm-based-masking
This commit is contained in:
commit
16058b92af
105 changed files with 14525 additions and 12551 deletions
|
|
@ -61,3 +61,6 @@ e9bbe03354079cfcef65a77b0c33f57b047a7c93
|
|||
|
||||
# replace `frappe.flags.in_test` with `frappe.in_test`
|
||||
653c80b8483cc41aef25cd7d66b9b6bb188bf5f8
|
||||
|
||||
# another ruff update
|
||||
6ca4d4d167a1a009d99062747711de7a994aa633
|
||||
|
|
|
|||
2
.github/workflows/run-indinvidual-tests.yml
vendored
2
.github/workflows/run-indinvidual-tests.yml
vendored
|
|
@ -108,7 +108,7 @@ jobs:
|
|||
|
||||
- name: Get yarn cache directory path
|
||||
id: yarn-cache-dir-path
|
||||
run: echo "::set-output name=dir::$(yarn cache dir)"
|
||||
run: echo "dir=$(yarn cache dir)" >> $GITHUB_OUTPUT
|
||||
|
||||
- uses: actions/cache@v4
|
||||
id: yarn-cache
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ repos:
|
|||
exclude: ^frappe/tests/classes/context_managers\.py$
|
||||
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: v0.8.1
|
||||
rev: v0.13.2
|
||||
hooks:
|
||||
- id: ruff
|
||||
name: "Run ruff import sorter"
|
||||
|
|
|
|||
|
|
@ -321,7 +321,10 @@ def set_authenticate_headers(response: Response):
|
|||
def make_form_dict(request: Request):
|
||||
request_data = request.get_data(as_text=True)
|
||||
if request_data and request.is_json:
|
||||
args = orjson.loads(request_data)
|
||||
try:
|
||||
args = orjson.loads(request_data)
|
||||
except orjson.JSONDecodeError:
|
||||
frappe.throw(_("Invalid request body"), frappe.DataError)
|
||||
else:
|
||||
args = {}
|
||||
args.update(request.args or {})
|
||||
|
|
|
|||
|
|
@ -66,6 +66,9 @@ class HTTPRequest:
|
|||
elif frappe.get_request_header("REMOTE_ADDR"):
|
||||
frappe.local.request_ip = frappe.get_request_header("REMOTE_ADDR")
|
||||
|
||||
elif frappe.request and getattr(frappe.request, "remote_addr", None):
|
||||
frappe.local.request_ip = frappe.request.remote_addr
|
||||
|
||||
else:
|
||||
frappe.local.request_ip = "127.0.0.1"
|
||||
|
||||
|
|
@ -666,7 +669,7 @@ def validate_oauth(authorization_header):
|
|||
required_scopes = frappe.db.get_value("OAuth Bearer Token", token, "scopes").split(
|
||||
get_url_delimiter()
|
||||
)
|
||||
valid, oauthlib_request = get_oauth_server().verify_request(
|
||||
valid, _oauthlib_request = get_oauth_server().verify_request(
|
||||
uri, http_method, body, headers, required_scopes
|
||||
)
|
||||
if valid:
|
||||
|
|
|
|||
|
|
@ -67,7 +67,7 @@
|
|||
"label": "Assignment Rules"
|
||||
},
|
||||
{
|
||||
"description": "Simple Python Expression, Example: status == 'Open' and type == 'Bug'",
|
||||
"description": "Simple Python Expression, Example: <code class=\"language-python\">status == 'Open' and issue_type == 'Bug'</code>",
|
||||
"fieldname": "assign_condition",
|
||||
"fieldtype": "Code",
|
||||
"in_list_view": 1,
|
||||
|
|
@ -80,7 +80,7 @@
|
|||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"description": "Simple Python Expression, Example: Status in (\"Closed\", \"Cancelled\")",
|
||||
"description": "Simple Python Expression, Example: <code class=\"language-python\">status in (\"Closed\", \"Cancelled\")</code>",
|
||||
"fieldname": "unassign_condition",
|
||||
"fieldtype": "Code",
|
||||
"label": "Unassign Condition",
|
||||
|
|
@ -119,7 +119,7 @@
|
|||
"fieldtype": "Section Break"
|
||||
},
|
||||
{
|
||||
"description": "Simple Python Expression, Example: Status in (\"Invalid\")",
|
||||
"description": "Simple Python Expression, Example: <code class=\"language-python\">status == \"Invalid\"</code>",
|
||||
"fieldname": "close_condition",
|
||||
"fieldtype": "Code",
|
||||
"label": "Close Condition",
|
||||
|
|
@ -152,9 +152,10 @@
|
|||
"mandatory_depends_on": "eval: doc.rule == 'Based on Field'"
|
||||
}
|
||||
],
|
||||
"grid_page_length": 50,
|
||||
"index_web_pages_for_search": 1,
|
||||
"links": [],
|
||||
"modified": "2024-03-23 16:01:27.590910",
|
||||
"modified": "2025-08-25 17:09:11.644603",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Automation",
|
||||
"name": "Assignment Rule",
|
||||
|
|
@ -174,8 +175,9 @@
|
|||
"write": 1
|
||||
}
|
||||
],
|
||||
"row_format": "Dynamic",
|
||||
"sort_field": "creation",
|
||||
"sort_order": "DESC",
|
||||
"states": [],
|
||||
"track_changes": 1
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -61,7 +61,7 @@ def create_rq_users(set_admin_password=False, use_rq_auth=False):
|
|||
)
|
||||
click.secho(f"`export {env_key}={user_credentials['default'][1]}`")
|
||||
click.secho(
|
||||
"NOTE: Please save the admin password as you " "can not access redis server without the password",
|
||||
"NOTE: Please save the admin password as you can not access redis server without the password",
|
||||
fg="yellow",
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -336,7 +336,7 @@ def restore_backup(
|
|||
# Check if the backup is of an older version of frappe and the user hasn't specified force
|
||||
if is_downgrade(sql_file_path, verbose=True) and not force:
|
||||
warn_message = (
|
||||
"This is not recommended and may lead to unexpected behaviour. " "Do you want to continue anyway?"
|
||||
"This is not recommended and may lead to unexpected behaviour. Do you want to continue anyway?"
|
||||
)
|
||||
click.confirm(warn_message, abort=True)
|
||||
|
||||
|
|
|
|||
|
|
@ -297,7 +297,7 @@ class TestCommands(BaseTestCommands):
|
|||
self.execute("bench --site {test_site} backup --exclude 'ToDo'", site_data)
|
||||
site_data.update({"kw": "\"{'partial':True}\""})
|
||||
self.execute(
|
||||
"bench --site {test_site} execute" " frappe.utils.backups.fetch_latest_backups --kwargs {kw}",
|
||||
"bench --site {test_site} execute frappe.utils.backups.fetch_latest_backups --kwargs {kw}",
|
||||
site_data,
|
||||
)
|
||||
site_data.update({"database": json.loads(self.stdout)["database"]})
|
||||
|
|
|
|||
|
|
@ -435,8 +435,7 @@ def import_doc(context: CliCtxObj, path, force=False):
|
|||
type=click.Path(exists=True, dir_okay=False, resolve_path=True),
|
||||
required=True,
|
||||
help=(
|
||||
"Path to import file (.csv, .xlsx)."
|
||||
"Consider that relative paths will resolve from 'sites' directory"
|
||||
"Path to import file (.csv, .xlsx). Consider that relative paths will resolve from 'sites' directory"
|
||||
),
|
||||
)
|
||||
@click.option("--doctype", type=str, required=True)
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ class TestAuditTrail(IntegrationTestCase):
|
|||
re_amended_doc = amend_document(amended_doc, changed_fields, {}, 1)
|
||||
|
||||
comparator = create_comparator_doc("Test Custom Doctype for Doc Comparator", re_amended_doc.name)
|
||||
documents, results = comparator.compare_document()
|
||||
_documents, results = comparator.compare_document()
|
||||
|
||||
test_field_values = results["changed"]["Field"]
|
||||
self.check_expected_values(test_field_values, ["first value", "second value", "third value"])
|
||||
|
|
@ -41,7 +41,7 @@ class TestAuditTrail(IntegrationTestCase):
|
|||
amended_doc = amend_document(doc, {}, rows_updated, 1)
|
||||
|
||||
comparator = create_comparator_doc("Test Custom Doctype for Doc Comparator", amended_doc.name)
|
||||
documents, results = comparator.compare_document()
|
||||
_documents, results = comparator.compare_document()
|
||||
|
||||
results = frappe._dict(results)
|
||||
self.check_rows_updated(results.row_changed)
|
||||
|
|
|
|||
|
|
@ -565,11 +565,11 @@ def parse_email(email_strings):
|
|||
|
||||
for email in email_string.split(","):
|
||||
local_part = email.split("@", 1)[0].strip('"')
|
||||
user, detail = None, None
|
||||
_user, detail = None, None
|
||||
if "+" in local_part:
|
||||
user, detail = local_part.split("+", 1)
|
||||
_user, detail = local_part.split("+", 1)
|
||||
elif "--" in local_part:
|
||||
detail, user = local_part.rsplit("--", 1)
|
||||
detail, _user = local_part.rsplit("--", 1)
|
||||
|
||||
if not detail:
|
||||
continue
|
||||
|
|
|
|||
|
|
@ -11,8 +11,8 @@
|
|||
"label",
|
||||
"fieldtype",
|
||||
"fieldname",
|
||||
"precision",
|
||||
"length",
|
||||
"precision",
|
||||
"non_negative",
|
||||
"hide_days",
|
||||
"hide_seconds",
|
||||
|
|
@ -136,7 +136,7 @@
|
|||
},
|
||||
{
|
||||
"depends_on": "eval:in_list([\"Float\", \"Currency\", \"Percent\"], doc.fieldtype)",
|
||||
"description": "Set non-standard precision for a Float or Currency field",
|
||||
"description": "Set non-standard precision for a Float, Currency or Percent field",
|
||||
"fieldname": "precision",
|
||||
"fieldtype": "Select",
|
||||
"label": "Precision",
|
||||
|
|
@ -144,7 +144,7 @@
|
|||
"print_hide": 1
|
||||
},
|
||||
{
|
||||
"depends_on": "eval:in_list(['Data', 'Link', 'Dynamic Link', 'Password', 'Select', 'Read Only', 'Attach', 'Attach Image', 'Int'], doc.fieldtype)",
|
||||
"depends_on": "eval:in_list(['Data', 'Link', 'Dynamic Link', 'Password', 'Select', 'Read Only', 'Attach', 'Attach Image', 'Int', 'Float', 'Currency', 'Percent'], doc.fieldtype)",
|
||||
"fieldname": "length",
|
||||
"fieldtype": "Int",
|
||||
"label": "Length"
|
||||
|
|
@ -622,7 +622,7 @@
|
|||
"index_web_pages_for_search": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2025-08-26 22:08:20.940308",
|
||||
"modified": "2025-09-17 13:20:57.852396",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Core",
|
||||
"name": "DocField",
|
||||
|
|
|
|||
|
|
@ -702,7 +702,7 @@
|
|||
"label": "Protect Attached Files"
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"default": "20",
|
||||
"depends_on": "istable",
|
||||
"fieldname": "rows_threshold_for_grid_search",
|
||||
"fieldtype": "Int",
|
||||
|
|
@ -792,7 +792,7 @@
|
|||
"link_fieldname": "document_type"
|
||||
}
|
||||
],
|
||||
"modified": "2025-07-19 12:23:16.296416",
|
||||
"modified": "2025-09-23 06:48:13.555017",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Core",
|
||||
"name": "DocType",
|
||||
|
|
|
|||
|
|
@ -1660,6 +1660,18 @@ def validate_fields(meta: Meta):
|
|||
if docfield.options and (int(docfield.options) > 10 or int(docfield.options) < 3):
|
||||
frappe.throw(_("Options for Rating field can range from 3 to 10"))
|
||||
|
||||
def check_decimal_config(docfield):
|
||||
if docfield.fieldtype not in ("Currency", "Float", "Percent"):
|
||||
return
|
||||
|
||||
if docfield.length and docfield.precision:
|
||||
if cint(docfield.precision) > cint(docfield.length):
|
||||
frappe.throw(
|
||||
_("Precision ({0}) for {1} cannot be greater than its length ({2}).").format(
|
||||
docfield.precision, frappe.bold(docfield.label), docfield.length
|
||||
)
|
||||
)
|
||||
|
||||
fields = meta.get("fields")
|
||||
fieldname_list = [d.fieldname for d in fields]
|
||||
|
||||
|
|
@ -1682,6 +1694,7 @@ def validate_fields(meta: Meta):
|
|||
scrub_options_in_select(d)
|
||||
validate_fetch_from(d)
|
||||
validate_data_field_type(d)
|
||||
check_decimal_config(d)
|
||||
|
||||
if not frappe.flags.in_migrate or in_ci:
|
||||
check_unique_fieldname(meta.get("name"), d.fieldname)
|
||||
|
|
|
|||
|
|
@ -827,6 +827,35 @@ class TestDocType(IntegrationTestCase):
|
|||
self.assertEqual(get_format(compressed_dt), "COMPRESSED")
|
||||
self.assertEqual(get_format(dynamic_dt), "DYNAMIC")
|
||||
|
||||
def test_decimal_field_configuration(self):
|
||||
doctype = new_doctype(
|
||||
"Test Decimal Config",
|
||||
fields=[
|
||||
{
|
||||
"fieldname": "decimal_field",
|
||||
"fieldtype": "Currency",
|
||||
"length": 30,
|
||||
"precision": 3,
|
||||
}
|
||||
],
|
||||
).insert(ignore_if_duplicate=True)
|
||||
decimal_field_type = frappe.db.get_column_type(doctype.name, "decimal_field")
|
||||
self.assertIn("(30,3)", decimal_field_type.lower())
|
||||
|
||||
def test_decimal_field_precision_exceeds_length(self):
|
||||
doctype = new_doctype(
|
||||
"Test Decimal Config 2",
|
||||
fields=[
|
||||
{
|
||||
"fieldname": "decimal_field",
|
||||
"fieldtype": "Currency",
|
||||
"length": 10,
|
||||
"precision": 11,
|
||||
}
|
||||
],
|
||||
)
|
||||
self.assertRaises(frappe.ValidationError, doctype.insert)
|
||||
|
||||
|
||||
def new_doctype(
|
||||
name: str | None = None,
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ from frappe.utils import (
|
|||
get_url,
|
||||
)
|
||||
from frappe.utils.file_manager import is_safe_path
|
||||
from frappe.utils.html_utils import escape_html
|
||||
from frappe.utils.image import optimize_image, strip_exif_data
|
||||
from frappe.utils.pdf import pdf_contains_js
|
||||
|
||||
|
|
@ -37,7 +38,9 @@ from .exceptions import (
|
|||
from .utils import *
|
||||
|
||||
exclude_from_linked_with = True
|
||||
ImageFile.LOAD_TRUNCATED_IMAGES = True
|
||||
|
||||
ImageFile.LOAD_TRUNCATED_IMAGES = True # nosemgrep
|
||||
|
||||
URL_PREFIXES = ("http://", "https://", "/api/method/")
|
||||
FILE_ENCODING_OPTIONS = ("utf-8-sig", "utf-8", "windows-1250", "windows-1252")
|
||||
|
||||
|
|
@ -139,7 +142,6 @@ class File(Document):
|
|||
self.validate_file_url()
|
||||
self.validate_file_on_disk()
|
||||
self.file_size = frappe.form_dict.file_size or self.file_size
|
||||
self.check_content()
|
||||
|
||||
def validate_attachment_references(self):
|
||||
if not self.attached_to_doctype:
|
||||
|
|
@ -434,6 +436,9 @@ class File(Document):
|
|||
else:
|
||||
self.file_name = re.sub(r"/", "", self.file_name)
|
||||
|
||||
# Escape HTML characters in file name
|
||||
self.file_name = escape_html(self.file_name)
|
||||
|
||||
def generate_content_hash(self):
|
||||
if self.content_hash or not self.file_url or self.is_remote_file:
|
||||
return
|
||||
|
|
@ -783,7 +788,7 @@ class File(Document):
|
|||
def create_attachment_record(self):
|
||||
icon = ' <i class="fa fa-lock text-warning"></i>' if self.is_private else ""
|
||||
file_url = quote(frappe.safe_encode(self.file_url), safe="/:") if self.file_url else self.file_name
|
||||
file_name = self.file_name or self.file_url
|
||||
file_name = escape_html(self.file_name or self.file_url)
|
||||
|
||||
self.add_comment_in_reference_doc(
|
||||
"Attachment",
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@
|
|||
"apply_strict_user_permissions",
|
||||
"column_break_21",
|
||||
"allow_older_web_view_links",
|
||||
"show_external_link_warning",
|
||||
"security_tab",
|
||||
"security",
|
||||
"session_expiry",
|
||||
|
|
@ -744,12 +745,19 @@
|
|||
"fieldtype": "Int",
|
||||
"label": "Max signups allowed per hour",
|
||||
"non_negative": 1
|
||||
},
|
||||
{
|
||||
"default": "Never",
|
||||
"fieldname": "show_external_link_warning",
|
||||
"fieldtype": "Select",
|
||||
"label": "Show External Link Warning",
|
||||
"options": "Never\nAsk\nAlways"
|
||||
}
|
||||
],
|
||||
"icon": "fa fa-cog",
|
||||
"issingle": 1,
|
||||
"links": [],
|
||||
"modified": "2025-09-03 10:52:38.096662",
|
||||
"modified": "2025-09-24 16:04:02.016562",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Core",
|
||||
"name": "System Settings",
|
||||
|
|
|
|||
|
|
@ -97,6 +97,7 @@ class SystemSettings(Document):
|
|||
session_expiry: DF.Data | None
|
||||
setup_complete: DF.Check
|
||||
show_absolute_datetime_in_timeline: DF.Check
|
||||
show_external_link_warning: DF.Literal["Never", "Ask", "Always"]
|
||||
store_attached_pdf_document: DF.Check
|
||||
strip_exif_metadata_from_uploaded_images: DF.Check
|
||||
time_format: DF.Literal["HH:mm:ss", "HH:mm"]
|
||||
|
|
|
|||
|
|
@ -37,18 +37,6 @@ frappe.ui.form.on("User", {
|
|||
}
|
||||
},
|
||||
|
||||
role_profiles: function (frm) {
|
||||
if (frm.doc.role_profiles && frm.doc.role_profiles.length) {
|
||||
frm.roles_editor.disable = 1;
|
||||
frm.call("populate_role_profile_roles").then(() => {
|
||||
frm.roles_editor.show();
|
||||
});
|
||||
} else {
|
||||
frm.roles_editor.disable = 0;
|
||||
frm.roles_editor.show();
|
||||
}
|
||||
},
|
||||
|
||||
module_profile: function (frm) {
|
||||
if (frm.doc.module_profile) {
|
||||
frappe.call({
|
||||
|
|
@ -431,6 +419,25 @@ frappe.ui.form.on("User Email", {
|
|||
},
|
||||
});
|
||||
|
||||
frappe.ui.form.on("User Role Profile", {
|
||||
role_profiles_add: function (frm) {
|
||||
if (frm.doc.role_profiles.length > 0) {
|
||||
frm.roles_editor.disable = 1;
|
||||
frm.call("populate_role_profile_roles").then(() => {
|
||||
frm.roles_editor.show();
|
||||
});
|
||||
$(".deselect-all, .select-all").prop("disabled", true);
|
||||
}
|
||||
},
|
||||
role_profiles_remove: function (frm) {
|
||||
if (frm.doc.role_profiles.length == 0) {
|
||||
frm.roles_editor.disable = 0;
|
||||
frm.roles_editor.show();
|
||||
$(".deselect-all, .select-all").prop("disabled", false);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
function has_access_to_edit_user() {
|
||||
return has_common(frappe.user_roles, get_roles_for_editing_user());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@
|
|||
"label",
|
||||
"search_fields",
|
||||
"grid_page_length",
|
||||
"rows_threshold_for_grid_search",
|
||||
"link_filters",
|
||||
"column_break_5",
|
||||
"istable",
|
||||
|
|
@ -43,6 +44,7 @@
|
|||
"force_re_route_to_default_view",
|
||||
"column_break_29",
|
||||
"show_preview_popup",
|
||||
"show_name_in_global_search",
|
||||
"email_settings_section",
|
||||
"default_email_template",
|
||||
"column_break_26",
|
||||
|
|
@ -422,6 +424,19 @@
|
|||
"fieldname": "recipient_account_field",
|
||||
"fieldtype": "Data",
|
||||
"label": "Recipient Account Field"
|
||||
},
|
||||
{
|
||||
"depends_on": "istable",
|
||||
"fieldname": "rows_threshold_for_grid_search",
|
||||
"fieldtype": "Int",
|
||||
"label": "Rows Threshold for Grid Search",
|
||||
"non_negative": 1
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"fieldname": "show_name_in_global_search",
|
||||
"fieldtype": "Check",
|
||||
"label": "Make \"name\" searchable in Global Search"
|
||||
}
|
||||
],
|
||||
"hide_toolbar": 1,
|
||||
|
|
@ -430,7 +445,7 @@
|
|||
"index_web_pages_for_search": 1,
|
||||
"issingle": 1,
|
||||
"links": [],
|
||||
"modified": "2025-07-19 12:23:41.564203",
|
||||
"modified": "2025-09-23 07:13:52.631903",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Custom",
|
||||
"name": "Customize Form",
|
||||
|
|
|
|||
|
|
@ -75,9 +75,11 @@ class CustomizeForm(Document):
|
|||
queue_in_background: DF.Check
|
||||
quick_entry: DF.Check
|
||||
recipient_account_field: DF.Data | None
|
||||
rows_threshold_for_grid_search: DF.Int
|
||||
search_fields: DF.Data | None
|
||||
sender_field: DF.Data | None
|
||||
sender_name_field: DF.Data | None
|
||||
show_name_in_global_search: DF.Check
|
||||
show_preview_popup: DF.Check
|
||||
show_title_field_in_link: DF.Check
|
||||
sort_field: DF.Literal[None]
|
||||
|
|
@ -306,6 +308,8 @@ class CustomizeForm(Document):
|
|||
)
|
||||
|
||||
def set_property_setters_for_doctype(self, meta):
|
||||
if self.get("show_name_in_global_search") != meta.get("show_name_in_global_search"):
|
||||
self.flags.rebuild_doctype_for_global_search = True
|
||||
for prop, prop_type in doctype_properties.items():
|
||||
if self.get(prop) != meta.get(prop):
|
||||
self.make_property_setter(prop, self.get(prop), prop_type)
|
||||
|
|
@ -735,6 +739,7 @@ doctype_properties = {
|
|||
"track_views": "Check",
|
||||
"allow_auto_repeat": "Check",
|
||||
"allow_import": "Check",
|
||||
"show_name_in_global_search": "Check",
|
||||
"show_preview_popup": "Check",
|
||||
"default_email_template": "Data",
|
||||
"email_append_to": "Check",
|
||||
|
|
@ -748,6 +753,7 @@ doctype_properties = {
|
|||
"force_re_route_to_default_view": "Check",
|
||||
"translated_doctype": "Check",
|
||||
"grid_page_length": "Int",
|
||||
"rows_threshold_for_grid_search": "Int",
|
||||
}
|
||||
|
||||
docfield_properties = {
|
||||
|
|
|
|||
|
|
@ -147,7 +147,7 @@ class PostgresTable(DBTable):
|
|||
if isinstance(default, str):
|
||||
default = frappe.db.escape(default)
|
||||
change_nullability.append(
|
||||
f"ALTER COLUMN \"{col.fieldname}\" {'SET' if col.not_nullable else 'DROP'} NOT NULL"
|
||||
f'ALTER COLUMN "{col.fieldname}" {"SET" if col.not_nullable else "DROP"} NOT NULL'
|
||||
)
|
||||
change_nullability.append(f'ALTER COLUMN "{col.fieldname}" SET DEFAULT {default}')
|
||||
|
||||
|
|
|
|||
|
|
@ -10,6 +10,10 @@ SPECIAL_CHAR_PATTERN = re.compile(r"[\W]", flags=re.UNICODE)
|
|||
|
||||
VARCHAR_CAST_PATTERN = re.compile(r"varchar\(([\d]+)\)")
|
||||
|
||||
CONFIGURABLE_DECIMAL_TYPES = ("Currency", "Float", "Percent")
|
||||
DEFAULT_DECIMAL_LENGTH = 21
|
||||
DEFAULT_DECIMAL_PRECISION = 9
|
||||
|
||||
|
||||
class InvalidColumnName(frappe.ValidationError):
|
||||
pass
|
||||
|
|
@ -429,10 +433,13 @@ def get_definition(fieldtype, precision=None, length=None, *, options=None):
|
|||
size = d[1] if d[1] else None
|
||||
|
||||
if size:
|
||||
# This check needs to exist for backward compatibility.
|
||||
# Till V13, default size used for float, currency and percent are (18, 6).
|
||||
if fieldtype in ["Float", "Currency", "Percent"] and cint(precision) > 6:
|
||||
size = "21,9"
|
||||
if fieldtype in CONFIGURABLE_DECIMAL_TYPES:
|
||||
width = length if length else DEFAULT_DECIMAL_LENGTH
|
||||
precision_is_set = precision not in (None, "")
|
||||
precision = precision if precision_is_set else DEFAULT_DECIMAL_PRECISION
|
||||
if cint(precision) > cint(width):
|
||||
precision = width
|
||||
size = f"{cint(width)},{cint(precision)}"
|
||||
|
||||
if length:
|
||||
if coltype == "varchar":
|
||||
|
|
|
|||
|
|
@ -899,7 +899,7 @@ def tests_utils_get_dependencies(doctype):
|
|||
import frappe
|
||||
from frappe.tests.utils.generators import get_modules
|
||||
|
||||
module, test_module = get_modules(doctype)
|
||||
_module, test_module = get_modules(doctype)
|
||||
meta = frappe.get_meta(doctype)
|
||||
link_fields = meta.get_link_fields()
|
||||
|
||||
|
|
|
|||
|
|
@ -448,7 +448,11 @@ frappe.ui.form.on("Number Card", {
|
|||
let document_type = frm.doc.document_type;
|
||||
let doc_is_table =
|
||||
document_type &&
|
||||
(await frappe.db.get_value("DocType", document_type, "istable")).message.istable;
|
||||
(await new Promise((resolve) => {
|
||||
frappe.model.with_doctype(document_type, () => {
|
||||
resolve(frappe.get_meta(document_type).istable);
|
||||
});
|
||||
}));
|
||||
|
||||
frm.set_df_property("parent_document_type", "hidden", !doc_is_table);
|
||||
|
||||
|
|
|
|||
|
|
@ -622,8 +622,7 @@ def get_linked_fields(doctype, without_ignore_user_permissions_enabled=False):
|
|||
"DocField", fields=["parent", "options"], filters=child_filters, as_list=1
|
||||
):
|
||||
ret[parent] = {"child_doctype": options, "fieldname": links_dict[options]}
|
||||
if options in ret:
|
||||
del ret[options]
|
||||
ret.pop(options, None)
|
||||
|
||||
virtual_doctypes = frappe.get_all("DocType", {"is_virtual": 1}, pluck="name")
|
||||
for dt in virtual_doctypes:
|
||||
|
|
|
|||
|
|
@ -83,7 +83,7 @@ def get_group_by_count(doctype: str, current_filters: str, field: str) -> list[d
|
|||
break
|
||||
|
||||
if owner_idx:
|
||||
data = [data.pop(owner_idx)] + data[0:49]
|
||||
data = [data.pop(owner_idx), *data[0:49]]
|
||||
else:
|
||||
data = data[0:50]
|
||||
else:
|
||||
|
|
|
|||
|
|
@ -548,7 +548,7 @@ def get_field_info(fields, doctype):
|
|||
if parenttype != doctype:
|
||||
# If the column is from a child table, append the child doctype.
|
||||
# For example, "Item Code (Sales Invoice Item)".
|
||||
label += f" ({ _(parenttype) })"
|
||||
label += f" ({_(parenttype)})"
|
||||
|
||||
field_info.append(
|
||||
{"name": name, "label": label, "fieldtype": fieldtype, "translatable": translatable}
|
||||
|
|
|
|||
|
|
@ -117,6 +117,12 @@ def search_widget(
|
|||
|
||||
meta = frappe.get_meta(doctype)
|
||||
|
||||
include_disabled = False
|
||||
if filters and "include_disabled" in filters:
|
||||
if filters["include_disabled"] == 1:
|
||||
include_disabled = True
|
||||
filters.pop("include_disabled")
|
||||
|
||||
if isinstance(filters, dict):
|
||||
filters = [make_filter_tuple(doctype, key, value) for key, value in filters.items()]
|
||||
elif filters is None:
|
||||
|
|
@ -147,10 +153,11 @@ def search_widget(
|
|||
if not meta.translated_doctype and (f == "name" or (fmeta and fmeta.fieldtype in field_types)):
|
||||
or_filters.append([doctype, f.strip(), "like", f"%{txt}%"])
|
||||
|
||||
if meta.get("fields", {"fieldname": "enabled", "fieldtype": "Check"}):
|
||||
filters.append([doctype, "enabled", "=", 1])
|
||||
if meta.get("fields", {"fieldname": "disabled", "fieldtype": "Check"}):
|
||||
filters.append([doctype, "disabled", "!=", 1])
|
||||
if not include_disabled:
|
||||
if meta.get("fields", {"fieldname": "enabled", "fieldtype": "Check"}):
|
||||
filters.append([doctype, "enabled", "=", 1])
|
||||
if meta.get("fields", {"fieldname": "disabled", "fieldtype": "Check"}):
|
||||
filters.append([doctype, "disabled", "!=", 1])
|
||||
|
||||
# format a list of fields combining search fields and filter fields
|
||||
fields = get_std_fields_list(meta, searchfield or "name")
|
||||
|
|
|
|||
|
|
@ -9,8 +9,7 @@ from frappe import _
|
|||
def get_all_nodes(doctype, label, parent, tree_method, **filters):
|
||||
"""Recursively gets all data from tree nodes"""
|
||||
|
||||
if "cmd" in filters:
|
||||
del filters["cmd"]
|
||||
filters.pop("cmd", None)
|
||||
filters.pop("data", None)
|
||||
|
||||
tree_method = frappe.get_attr(tree_method)
|
||||
|
|
@ -20,8 +19,7 @@ def get_all_nodes(doctype, label, parent, tree_method, **filters):
|
|||
data = tree_method(doctype, parent, **filters)
|
||||
out = [dict(parent=label, data=data)]
|
||||
|
||||
if "is_root" in filters:
|
||||
del filters["is_root"]
|
||||
filters.pop("is_root", None)
|
||||
to_check = [d.get("value") for d in data if d.get("expandable")]
|
||||
|
||||
while to_check:
|
||||
|
|
|
|||
|
|
@ -54,7 +54,7 @@ class TestEmailQueue(IntegrationTestCase):
|
|||
Subject: {subject}
|
||||
From: Test <test@example.com>
|
||||
To: <!--recipient-->
|
||||
Date: {frappe.utils.now_datetime().strftime('%a, %d %b %Y %H:%M:%S %z')}
|
||||
Date: {frappe.utils.now_datetime().strftime("%a, %d %b %Y %H:%M:%S %z")}
|
||||
Reply-To: test@example.com
|
||||
X-Frappe-Site: {frappe.local.site}
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -352,7 +352,9 @@ def get_context(context):
|
|||
To queue a notification from a server script:
|
||||
|
||||
```python
|
||||
notification = frappe.get_doc("Notification", "My Notification", ignore_permissions=True)
|
||||
notification = frappe.get_doc(
|
||||
"Notification", "My Notification", ignore_permissions=True
|
||||
)
|
||||
notification.queue_send(customer)
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -237,7 +237,7 @@ class EMail:
|
|||
"""Append the message with MIME content to the root node (as attachment)"""
|
||||
from email.mime.text import MIMEText
|
||||
|
||||
maintype, subtype = mime_type.split("/")
|
||||
_maintype, subtype = mime_type.split("/")
|
||||
part = MIMEText(message, _subtype=subtype, policy=policy.SMTP)
|
||||
|
||||
if as_attachment:
|
||||
|
|
@ -445,7 +445,7 @@ def add_attachment(fname, fcontent, content_type=None, parent=None, content_id=N
|
|||
from email.mime.text import MIMEText
|
||||
|
||||
if not content_type:
|
||||
content_type, encoding = mimetypes.guess_type(fname)
|
||||
content_type, _encoding = mimetypes.guess_type(fname)
|
||||
|
||||
if not parent:
|
||||
return
|
||||
|
|
@ -597,7 +597,7 @@ def get_header(header=None):
|
|||
if not title:
|
||||
title = frappe.get_hooks("app_title")[-1]
|
||||
|
||||
email_header, text = get_email_from_template(
|
||||
email_header, _text = get_email_from_template(
|
||||
"email_header", {"header_title": title, "indicator": indicator}
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -205,7 +205,7 @@ class EmailServer:
|
|||
readonly = self.settings.email_sync_rule != "UNSEEN"
|
||||
|
||||
self.imap.select(folder, readonly=readonly)
|
||||
response, message = self.imap.uid("search", None, self.settings.email_sync_rule)
|
||||
_response, message = self.imap.uid("search", None, self.settings.email_sync_rule)
|
||||
if message[0]:
|
||||
email_list = message[0].split()
|
||||
else:
|
||||
|
|
@ -217,7 +217,7 @@ class EmailServer:
|
|||
# compare the UIDVALIDITY of email account and imap server
|
||||
uid_validity = self.settings.uid_validity
|
||||
|
||||
response, message = self.imap.status(folder, "(UIDVALIDITY UIDNEXT)")
|
||||
_response, message = self.imap.status(folder, "(UIDVALIDITY UIDNEXT)")
|
||||
current_uid_validity = self.parse_imap_response("UIDVALIDITY", message[0]) or 0
|
||||
|
||||
uidnext = int(self.parse_imap_response("UIDNEXT", message[0]) or "1")
|
||||
|
|
@ -270,7 +270,7 @@ class EmailServer:
|
|||
def retrieve_message(self, uid, msg_num, folder):
|
||||
try:
|
||||
if cint(self.settings.use_imap):
|
||||
status, message = self.imap.uid("fetch", uid, "(BODY.PEEK[] BODY.PEEK[HEADER] FLAGS)")
|
||||
_status, message = self.imap.uid("fetch", uid, "(BODY.PEEK[] BODY.PEEK[HEADER] FLAGS)")
|
||||
raw = message[0]
|
||||
|
||||
self.get_email_seen_status(uid, raw[0])
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ class Here:
|
|||
"label": address["label"],
|
||||
"value": json.dumps(
|
||||
{
|
||||
"address_line1": f'{address.get("street", "")} {address.get("houseNumber", "")}'.strip(),
|
||||
"address_line1": f"{address.get('street', '')} {address.get('houseNumber', '')}".strip(),
|
||||
"city": address.get("city", ""),
|
||||
"state": address.get("state", ""),
|
||||
"pincode": address.get("postalCode", ""),
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ class Nomatim:
|
|||
"label": result["display_name"],
|
||||
"value": json.dumps(
|
||||
{
|
||||
"address_line1": f'{address.get("road")} {address.get("house_number", "")}'.strip(),
|
||||
"address_line1": f"{address.get('road')} {address.get('house_number', '')}".strip(),
|
||||
"city": address.get("city") or address.get("town") or address.get("village"),
|
||||
"state": address.get("state"),
|
||||
"pincode": address.get("postcode"),
|
||||
|
|
|
|||
|
|
@ -240,7 +240,7 @@ class LDAP_TestCase:
|
|||
function_return = self.test_class.connect_to_ldap(
|
||||
base_dn=self.base_dn, password=self.base_password
|
||||
)
|
||||
args, kwargs = ldap3_connection_method.call_args
|
||||
_args, kwargs = ldap3_connection_method.call_args
|
||||
|
||||
for connection_arg in kwargs:
|
||||
if (
|
||||
|
|
@ -305,7 +305,7 @@ class LDAP_TestCase:
|
|||
base_dn=self.base_dn, password=self.base_password, read_only=False
|
||||
)
|
||||
|
||||
args, kwargs = ldap3_connection_method.call_args
|
||||
_args, kwargs = ldap3_connection_method.call_args
|
||||
|
||||
self.assertFalse(
|
||||
kwargs["read_only"],
|
||||
|
|
|
|||
|
|
@ -72,7 +72,7 @@ def approve(*args, **kwargs):
|
|||
frappe.flags.oauth_credentials,
|
||||
) = get_oauth_server().validate_authorization_request(r.url, r.method, r.get_data(), r.headers)
|
||||
|
||||
headers, body, status = get_oauth_server().create_authorization_response(
|
||||
headers, _body, _status = get_oauth_server().create_authorization_response(
|
||||
uri=frappe.flags.oauth_credentials["redirect_uri"],
|
||||
body=r.get_data(),
|
||||
headers=r.headers,
|
||||
|
|
@ -144,7 +144,7 @@ def authorize(**kwargs):
|
|||
def get_token(*args, **kwargs):
|
||||
try:
|
||||
r = frappe.request
|
||||
headers, body, status = get_oauth_server().create_token_response(
|
||||
_headers, body, _status = get_oauth_server().create_token_response(
|
||||
r.url, r.method, r.form, r.headers, frappe.flags.oauth_credentials
|
||||
)
|
||||
body = frappe._dict(json.loads(body))
|
||||
|
|
@ -165,7 +165,7 @@ def get_token(*args, **kwargs):
|
|||
def revoke_token(*args, **kwargs):
|
||||
try:
|
||||
r = frappe.request
|
||||
headers, body, status = get_oauth_server().create_revocation_response(
|
||||
_headers, _body, status = get_oauth_server().create_revocation_response(
|
||||
r.url,
|
||||
headers=r.headers,
|
||||
body=r.form,
|
||||
|
|
@ -184,7 +184,7 @@ def revoke_token(*args, **kwargs):
|
|||
def openid_profile(*args, **kwargs):
|
||||
try:
|
||||
r = frappe.request
|
||||
headers, body, status = get_oauth_server().create_userinfo_response(
|
||||
_headers, body, _status = get_oauth_server().create_userinfo_response(
|
||||
r.url,
|
||||
headers=r.headers,
|
||||
body=r.form,
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
3026
frappe/locale/nb.po
3026
frappe/locale/nb.po
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -443,8 +443,6 @@ from {tables}
|
|||
"concat",
|
||||
"concat_ws",
|
||||
"if",
|
||||
"ifnull",
|
||||
"nullif",
|
||||
"coalesce",
|
||||
"connection_id",
|
||||
"current_user",
|
||||
|
|
@ -474,16 +472,19 @@ from {tables}
|
|||
if SUB_QUERY_PATTERN.match(field):
|
||||
# Check for subquery anywhere in the field, not just at the beginning
|
||||
if "(" in lower_field:
|
||||
location = lower_field.index("(")
|
||||
subquery_token = lower_field[location + 1 :].lstrip().split(" ", 1)[0]
|
||||
if any(keyword in subquery_token for keyword in blacklisted_keywords):
|
||||
_raise_exception()
|
||||
|
||||
function = lower_field.split("(", 1)[0].rstrip()
|
||||
if function in blacklisted_functions:
|
||||
frappe.throw(
|
||||
_("Use of function {0} in field is restricted").format(function), exc=frappe.DataError
|
||||
)
|
||||
# Check all parentheses pairs, not just the first one
|
||||
paren_start = 0
|
||||
while True:
|
||||
location = lower_field.find("(", paren_start)
|
||||
if location == -1:
|
||||
break
|
||||
token = lower_field[location + 1 :].lstrip().split(" ", 1)[0]
|
||||
if any(
|
||||
re.search(r"\b" + re.escape(keyword) + r"\b", token)
|
||||
for keyword in blacklisted_keywords + blacklisted_functions
|
||||
):
|
||||
_raise_exception()
|
||||
paren_start = location + 1
|
||||
|
||||
if "@" in lower_field:
|
||||
# prevent access to global variables
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
|
||||
import os
|
||||
import shutil
|
||||
from typing import Any
|
||||
|
||||
import frappe
|
||||
import frappe.defaults
|
||||
|
|
@ -20,19 +21,58 @@ from frappe.utils.password import delete_all_passwords_for
|
|||
|
||||
|
||||
def delete_doc(
|
||||
doctype=None,
|
||||
name=None,
|
||||
force=0,
|
||||
ignore_doctypes=None,
|
||||
for_reload=False,
|
||||
ignore_permissions=False,
|
||||
flags=None,
|
||||
ignore_on_trash=False,
|
||||
ignore_missing=True,
|
||||
delete_permanently=False,
|
||||
):
|
||||
doctype: str | None = None,
|
||||
name: str | int | list[str | int] | None = None,
|
||||
force: int | bool = 0,
|
||||
ignore_doctypes: list[str] | None = None,
|
||||
for_reload: bool = False,
|
||||
ignore_permissions: bool = False,
|
||||
flags: dict[str, Any] | None = None,
|
||||
ignore_on_trash: bool = False,
|
||||
ignore_missing: bool = True,
|
||||
delete_permanently: bool = False,
|
||||
) -> bool | None:
|
||||
"""
|
||||
Deletes a doc(dt, dn) and validates if it is not submitted and not linked in a live record
|
||||
Deletes a document and validates if it is not submitted and not linked in a live record.
|
||||
|
||||
Args:
|
||||
doctype (str, optional): The document type to delete. If not provided,
|
||||
retrieved from frappe.form_dict.get("dt"). Defaults to None.
|
||||
name (str | int | list, optional): The name/ID of the document(s) to delete.
|
||||
Can be a single name or a list of names. If not provided,
|
||||
retrieved from frappe.form_dict.get("dn"). Defaults to None.
|
||||
force (bool, optional): When True, bypasses link existence checks and allows
|
||||
deletion of documents that are linked to other records. Also allows
|
||||
deletion of standard DocTypes. Defaults to 0 (False).
|
||||
ignore_doctypes (list, optional): A list of child doctypes to ignore when
|
||||
deleting child table records associated with the document. Defaults to None.
|
||||
for_reload (bool, optional): When True, indicates the deletion is for reloading
|
||||
purposes (like during doctype updates). Skips certain validations like
|
||||
permissions and on_trash methods, and automatically sets delete_permanently=True.
|
||||
Defaults to False.
|
||||
ignore_permissions (bool, optional): When True, bypasses permission checks
|
||||
during deletion. Useful for system operations. Defaults to False.
|
||||
flags (dict, optional): Additional flags to set on the document during the
|
||||
deletion process. These flags affect document behavior during deletion.
|
||||
Defaults to None.
|
||||
ignore_on_trash (bool, optional): When True, skips calling the document's
|
||||
on_trash method, which typically contains cleanup logic. Defaults to False.
|
||||
ignore_missing (bool, optional): When True, doesn't raise an error if the
|
||||
document doesn't exist and returns False. When False, raises
|
||||
frappe.DoesNotExistError if document is missing. Defaults to True.
|
||||
delete_permanently (bool, optional): When True, permanently deletes the document
|
||||
without adding it to the "Deleted Document" table for recovery purposes.
|
||||
When False, the document is soft-deleted and can be recovered. Defaults to False.
|
||||
|
||||
Raises:
|
||||
frappe.DoesNotExistError: When document doesn't exist and ignore_missing=False.
|
||||
frappe.LinkExistsError: When document is linked to other records and force=False.
|
||||
frappe.PermissionError: When user doesn't have delete permissions and ignore_permissions=False.
|
||||
frappe.ValidationError: When trying to delete a submitted document.
|
||||
frappe.QueryTimeoutError: When document is locked by another user.
|
||||
|
||||
Returns:
|
||||
bool: False if document doesn't exist and ignore_missing=True, otherwise None.
|
||||
"""
|
||||
if not ignore_doctypes:
|
||||
ignore_doctypes = []
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ dynamic_link_queries = [
|
|||
`tabDocField`.fieldname, `tabDocField`.options
|
||||
from `tabDocField`, `tabDocType`
|
||||
where `tabDocField`.fieldtype='Dynamic Link' and
|
||||
`tabDocType`.`name`=`tabDocField`.parent and `tabDocType`.is_virtual = 0
|
||||
`tabDocType`.`name`=`tabDocField`.parent and `tabDocType`.is_virtual = 0 and `tabDocField`.is_virtual = 0
|
||||
order by `tabDocType`.read_only, `tabDocType`.in_create""",
|
||||
"""select `tabCustom Field`.dt as parent,
|
||||
`tabDocType`.read_only, `tabDocType`.in_create,
|
||||
|
|
|
|||
|
|
@ -345,7 +345,7 @@ def _bulk_workflow_action(docnames, doctype, action):
|
|||
frappe.message_log.pop()
|
||||
message_dict = {"docname": docname, "message": message.get("message")}
|
||||
|
||||
if message.get("raise_exception", False):
|
||||
if message.get("raise_exception", False) or "Error" in message.get("message", ""):
|
||||
failed_transactions[docname].append(message_dict)
|
||||
else:
|
||||
successful_transactions[docname].append(message_dict)
|
||||
|
|
|
|||
|
|
@ -144,7 +144,7 @@ onMounted(() => store.fetch());
|
|||
}
|
||||
|
||||
.editable {
|
||||
input,
|
||||
input:not([type="checkbox"]),
|
||||
textarea,
|
||||
select,
|
||||
.ace_editor,
|
||||
|
|
@ -258,7 +258,7 @@ onMounted(() => store.fetch());
|
|||
border-color: transparent;
|
||||
}
|
||||
|
||||
input,
|
||||
input:not([type="checkbox"]),
|
||||
textarea,
|
||||
select,
|
||||
.ace_editor,
|
||||
|
|
@ -269,10 +269,6 @@ onMounted(() => store.fetch());
|
|||
.ql-editor {
|
||||
background-color: var(--control-bg) !important;
|
||||
}
|
||||
|
||||
input[type="checkbox"] {
|
||||
background-color: var(--fg-bg) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.form-main > :deep(div:first-child:not(.tab-header)) {
|
||||
|
|
|
|||
|
|
@ -1,8 +1,18 @@
|
|||
<script setup>
|
||||
import { useSlots } from "vue";
|
||||
import { useSlots, computed } from "vue";
|
||||
|
||||
const props = defineProps(["df", "value", "read_only"]);
|
||||
let slots = useSlots();
|
||||
|
||||
// Get the display value considering both current value and default
|
||||
let display_checked = computed(() => {
|
||||
// Use current value if explicitly set, otherwise fall back to default
|
||||
const value =
|
||||
props.value !== undefined && props.value !== null ? props.value : props.df.default;
|
||||
|
||||
// Frappe checkboxes use "1"/"0" strings or 1/0 numbers
|
||||
return value === "1" || value === 1;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
|
@ -10,7 +20,7 @@ let slots = useSlots();
|
|||
<!-- checkbox -->
|
||||
<label v-if="slots.label" class="field-controls">
|
||||
<div class="checkbox">
|
||||
<input type="checkbox" disabled />
|
||||
<input type="checkbox" :checked="display_checked" disabled />
|
||||
<slot name="label" />
|
||||
</div>
|
||||
<slot name="actions" />
|
||||
|
|
@ -18,7 +28,7 @@ let slots = useSlots();
|
|||
<label v-else>
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="value"
|
||||
:checked="display_checked"
|
||||
:disabled="read_only"
|
||||
@change="(event) => $emit('update:modelValue', event.target.checked)"
|
||||
/>
|
||||
|
|
@ -42,7 +52,6 @@ label .checkbox {
|
|||
align-items: center;
|
||||
|
||||
input {
|
||||
background-color: var(--fg-color);
|
||||
box-shadow: none;
|
||||
border: 1px solid var(--gray-400);
|
||||
pointer-events: none;
|
||||
|
|
|
|||
|
|
@ -67,10 +67,20 @@ let select_control = computed(() => {
|
|||
});
|
||||
|
||||
let content = computed({
|
||||
get: () => props.modelValue,
|
||||
get: () => props.modelValue ?? props.df.default,
|
||||
set: (value) => emit("update:modelValue", value),
|
||||
});
|
||||
|
||||
// Get the display label for the current selected value
|
||||
let display_value = computed(() => {
|
||||
const current_value = content.value;
|
||||
if (!current_value) return "";
|
||||
|
||||
const options = get_options();
|
||||
const selected_option = options?.find((opt) => opt.value === current_value);
|
||||
return selected_option ? selected_option.label : current_value;
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
if (select.value) select_control.value;
|
||||
});
|
||||
|
|
@ -101,7 +111,7 @@ watch(
|
|||
|
||||
<!-- select input -->
|
||||
<div class="select-input">
|
||||
<input class="form-control" readonly />
|
||||
<input class="form-control" readonly :value="display_value" />
|
||||
<div class="select-icon" v-html="frappe.utils.icon('select', 'sm')"></div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -416,7 +416,12 @@ function format_content_for_timeline(content) {
|
|||
|
||||
function get_user_link(user) {
|
||||
const user_display_text = frappe.user_info(user).fullname || "";
|
||||
return frappe.utils.get_form_link("User", user, true, user_display_text);
|
||||
return frappe.utils.get_form_link(
|
||||
"User",
|
||||
user,
|
||||
true,
|
||||
frappe.utils.xss_sanitise(user_display_text)
|
||||
);
|
||||
}
|
||||
|
||||
function get_user_message(user, message_self, message_other) {
|
||||
|
|
|
|||
|
|
@ -8,11 +8,12 @@ export default class GridRow {
|
|||
this.set_docfields();
|
||||
this.columns = {};
|
||||
this.columns_list = [];
|
||||
this.depandant_fields = {
|
||||
this.dependent_fields = {
|
||||
mandatory: [],
|
||||
read_only: [],
|
||||
};
|
||||
this.row_check_html = '<input type="checkbox" class="grid-row-check" tabIndex="-1">';
|
||||
this.default_rows_threshold_for_grid_search = 20;
|
||||
this.make();
|
||||
}
|
||||
make() {
|
||||
|
|
@ -160,7 +161,7 @@ export default class GridRow {
|
|||
this.grid.add_new_row(idx, null, show, copy_doc);
|
||||
}
|
||||
move() {
|
||||
// promopt the user where they want to move this row
|
||||
// prompt the user where they want to move this row
|
||||
var me = this;
|
||||
frappe.prompt(
|
||||
{
|
||||
|
|
@ -802,7 +803,7 @@ export default class GridRow {
|
|||
this.evaluate_depends_on_value(df.mandatory_depends_on)
|
||||
) {
|
||||
df.reqd = 1;
|
||||
this.depandant_fields["mandatory"].push(df);
|
||||
this.dependent_fields["mandatory"].push(df);
|
||||
}
|
||||
|
||||
if (
|
||||
|
|
@ -811,16 +812,16 @@ export default class GridRow {
|
|||
this.evaluate_depends_on_value(df.read_only_depends_on)
|
||||
) {
|
||||
df.read_only = 1;
|
||||
this.depandant_fields["read_only"].push(df);
|
||||
this.dependent_fields["read_only"].push(df);
|
||||
}
|
||||
}
|
||||
|
||||
refresh_depedency() {
|
||||
this.depandant_fields["read_only"].forEach((df) => {
|
||||
refresh_dependency() {
|
||||
this.dependent_fields["read_only"].forEach((df) => {
|
||||
df.read_only = 0;
|
||||
this.set_dependant_property(df);
|
||||
});
|
||||
this.depandant_fields["mandatory"].forEach((df) => {
|
||||
this.dependent_fields["mandatory"].forEach((df) => {
|
||||
df.reqd = 0;
|
||||
this.set_dependant_property(df);
|
||||
});
|
||||
|
|
@ -871,7 +872,7 @@ export default class GridRow {
|
|||
let show_length =
|
||||
this.grid?.meta?.rows_threshold_for_grid_search > 0
|
||||
? this.grid.meta.rows_threshold_for_grid_search
|
||||
: 20;
|
||||
: this.default_rows_threshold_for_grid_search;
|
||||
this.show_search =
|
||||
this.show_search &&
|
||||
(this.grid?.data?.length >= show_length || this.grid.filter_applied);
|
||||
|
|
@ -1016,7 +1017,7 @@ export default class GridRow {
|
|||
}
|
||||
}
|
||||
|
||||
// Delay date_picker widget to prevent temparary layout shift (UX).
|
||||
// Delay date_picker widget to prevent temporary layout shift (UX).
|
||||
function handle_date_picker() {
|
||||
let date_time_picker = document.querySelectorAll(".datepicker.active")[0];
|
||||
|
||||
|
|
@ -1184,7 +1185,7 @@ export default class GridRow {
|
|||
// df.onchange is common for all rows in grid
|
||||
let field_on_change_function = df.onchange;
|
||||
field.df.change = (e) => {
|
||||
this.refresh_depedency();
|
||||
this.refresh_dependency();
|
||||
// trigger onchange with current grid row field as "this"
|
||||
field_on_change_function && field_on_change_function.apply(field, [e]);
|
||||
me.refresh_field(field.df.fieldname);
|
||||
|
|
|
|||
|
|
@ -126,7 +126,7 @@ frappe.ui.form.Attachments = class Attachments {
|
|||
<a href="${file_url}" target="_blank" title="${frappe.utils.escape_html(file_name)}"
|
||||
class="ellipsis attachment-file-label"
|
||||
>
|
||||
<span>${file_name}</span>
|
||||
<span>${frappe.utils.xss_sanitise(file_name)}</span>
|
||||
</a>`;
|
||||
|
||||
let remove_action = null;
|
||||
|
|
|
|||
|
|
@ -336,7 +336,7 @@ frappe.ui.form.Toolbar = class Toolbar {
|
|||
) {
|
||||
this.page.add_menu_item(
|
||||
__("Discard"),
|
||||
function () {
|
||||
() => {
|
||||
this.frm._discard();
|
||||
},
|
||||
true
|
||||
|
|
|
|||
|
|
@ -482,7 +482,9 @@ frappe.views.ListView = class ListView extends frappe.views.BaseList {
|
|||
|
||||
get_documentation_link() {
|
||||
if (this.meta.documentation) {
|
||||
return `<a href="${this.meta.documentation}" target="blank" class="meta-description small text-muted">Need Help?</a>`;
|
||||
return `<a href="${
|
||||
this.meta.documentation
|
||||
}" target="blank" class="meta-description small text-muted">${__("Need Help?")}</a>`;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -54,6 +54,8 @@ frappe.RoleEditor = class {
|
|||
this.make_perm_dialog();
|
||||
}
|
||||
$(this.perm_dialog.body).empty();
|
||||
let is_dark = document.documentElement.getAttribute("data-theme") === "dark";
|
||||
let header_bg_color = is_dark ? "bg-dark text-white" : "bg-light";
|
||||
return frappe
|
||||
.xcall("frappe.core.doctype.user.user.get_perm_info", { role })
|
||||
.then((permissions) => {
|
||||
|
|
@ -64,17 +66,26 @@ frappe.RoleEditor = class {
|
|||
</div>`);
|
||||
} else {
|
||||
$body.append(`
|
||||
<table class="user-perm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th> ${__("Document Type")} </th>
|
||||
<th> ${__("Level")} </th>
|
||||
<th> ${__("If Owner")} </th>
|
||||
${frappe.perm.rights.map((p) => `<th> ${__(frappe.unscrub(p))}</th>`).join("")}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
<div style="max-height:calc(100vh - 200px); overflow-y:auto;">
|
||||
<table class="user-perm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="sticky-top ${header_bg_color}"> ${__("Document Type")} </th>
|
||||
<th class="sticky-top ${header_bg_color}"> ${__("Level")} </th>
|
||||
<th class="sticky-top ${header_bg_color}"> ${__("If Owner")} </th>
|
||||
${frappe.perm.rights
|
||||
.map(
|
||||
(p) =>
|
||||
`<th class="sticky-top ${header_bg_color}">${__(
|
||||
frappe.unscrub(p)
|
||||
)}</th>`
|
||||
)
|
||||
.join("")}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
</div>
|
||||
`);
|
||||
permissions.forEach((perm) => {
|
||||
$body.find("tbody").append(`
|
||||
|
|
|
|||
|
|
@ -28,6 +28,11 @@ $("body").on("click", "a", function (e) {
|
|||
const href = target_element.getAttribute("href");
|
||||
const is_on_same_host = target_element.hostname === window.location.hostname;
|
||||
|
||||
if (frappe.router.show_external_link_warning_if_needed(target_element)) {
|
||||
e.preventDefault();
|
||||
return; // warning shown
|
||||
}
|
||||
|
||||
if (target_element.getAttribute("target") === "_blank") {
|
||||
return;
|
||||
}
|
||||
|
|
@ -570,6 +575,105 @@ frappe.router = {
|
|||
slug(name) {
|
||||
return name.toLowerCase().replace(/ /g, "-");
|
||||
},
|
||||
|
||||
show_external_link_warning_if_needed(/** @type {HTMLAnchorElement} */ aElement) {
|
||||
try {
|
||||
if (!aElement?.href) {
|
||||
return false; // not a true link
|
||||
}
|
||||
|
||||
// Get the external link handling type
|
||||
/** @type {'Always' | 'Ask' | 'Never' | null} */
|
||||
const showWarningWhen = frappe.boot.show_external_link_warning || "Never";
|
||||
if (showWarningWhen == "Never") {
|
||||
return false; // the feature is disabled
|
||||
}
|
||||
|
||||
// Check that the origin is external (does not prevent self-clickjacking on GET endpoints)
|
||||
const url = new URL(aElement.href);
|
||||
const hostname = url.hostname;
|
||||
if (hostname === window.location.hostname) {
|
||||
return false; // self-linking is allowed
|
||||
}
|
||||
|
||||
// Check if the origin was ignored by the user
|
||||
const localStorageKey = `skip-external-link-warning:${hostname}`;
|
||||
if (showWarningWhen == "Ask" && localStorage.getItem(localStorageKey)) {
|
||||
return false; // user chose to skip warning forever
|
||||
}
|
||||
|
||||
// Check if the link if inside the confirmation popup
|
||||
const incominSkipToken = aElement.getAttribute("data-skip-link-warning");
|
||||
if (incominSkipToken && sessionStorage.getItem(incominSkipToken) == "1") {
|
||||
return false; // anchor is the confirmation itself
|
||||
}
|
||||
|
||||
// Finally, show the warning
|
||||
const dialog = new frappe.ui.Dialog({
|
||||
title: __("Warning"),
|
||||
primary_action: null,
|
||||
fields: [
|
||||
{
|
||||
fieldname: "warning_html",
|
||||
fieldtype: "HTML",
|
||||
},
|
||||
{
|
||||
fieldname: "confirm_checkbox",
|
||||
fieldtype: "Check",
|
||||
label: __("Do not warn me again about {0}", [
|
||||
frappe.utils.escape_html(hostname).bold(),
|
||||
]),
|
||||
default: 0,
|
||||
hidden: showWarningWhen == "Always",
|
||||
change() {
|
||||
if (dialog.get_value("confirm_checkbox")) {
|
||||
localStorage.setItem(localStorageKey, "1");
|
||||
} else {
|
||||
localStorage.removeItem(localStorageKey);
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const warningElement = dialog.fields_dict.warning_html.$wrapper.get(0);
|
||||
|
||||
const introElement = document.createElement("p");
|
||||
introElement.textContent = __(
|
||||
"You are about to open an external link. To confirm, click the link again."
|
||||
);
|
||||
warningElement.appendChild(introElement);
|
||||
|
||||
const boxElement = document.createElement("div");
|
||||
boxElement.classList.add("border", "rounded-lg", "p-3", "mt-6", "mb-6", "text-center");
|
||||
warningElement.appendChild(boxElement);
|
||||
|
||||
const hintElement = document.createElement("p");
|
||||
hintElement.classList.add("text-sm", "mb-1");
|
||||
hintElement.textContent = __("You will be redirected to:");
|
||||
boxElement.appendChild(hintElement);
|
||||
|
||||
const confirmElement = document.createElement("a");
|
||||
confirmElement.classList.add("text-sm", "font-mono");
|
||||
confirmElement.style.wordBreak = "break-all";
|
||||
confirmElement.textContent = aElement.href;
|
||||
confirmElement.href = aElement.href;
|
||||
confirmElement.target = aElement.target;
|
||||
confirmElement.addEventListener("click", () => dialog.hide(), { capture: true });
|
||||
|
||||
// Add a token to skip the warning when clicking inside the confirmation dialog
|
||||
const skipToken = frappe.utils.get_random(16);
|
||||
confirmElement.setAttribute("data-skip-link-warning", skipToken);
|
||||
sessionStorage.setItem(skipToken, "1");
|
||||
boxElement.appendChild(confirmElement);
|
||||
|
||||
dialog.show();
|
||||
return true; // prevent default handling
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
return false;
|
||||
},
|
||||
};
|
||||
|
||||
// global functions for backward compatibility
|
||||
|
|
|
|||
|
|
@ -85,6 +85,9 @@ frappe.ui.Scanner = class Scanner {
|
|||
on_hide: () => {
|
||||
this.stop_scan();
|
||||
},
|
||||
minimizable: this.options.minimizable,
|
||||
primary_action_label: this.options.primary_action_label,
|
||||
primary_action: this.options.primary_action,
|
||||
});
|
||||
return dialog;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ frappe.ui.Dialog = class Dialog extends frappe.ui.FieldGroup {
|
|||
this.is_dialog = true;
|
||||
this.last_focus = null;
|
||||
|
||||
$.extend(this, { animate: true, size: null, auto_make: true }, opts);
|
||||
$.extend(this, { animate: true, size: null, auto_make: true, centered: false }, opts);
|
||||
if (this.auto_make) {
|
||||
this.make();
|
||||
}
|
||||
|
|
@ -34,6 +34,7 @@ frappe.ui.Dialog = class Dialog extends frappe.ui.FieldGroup {
|
|||
if (!this.size) this.set_modal_size();
|
||||
|
||||
this.wrapper = this.$wrapper.find(".modal-dialog").get(0);
|
||||
if (this.centered) $(this.wrapper).addClass("modal-dialog-centered");
|
||||
if (this.size == "small") $(this.wrapper).addClass("modal-sm");
|
||||
else if (this.size == "large") $(this.wrapper).addClass("modal-lg");
|
||||
else if (this.size == "extra-large") $(this.wrapper).addClass("modal-xl");
|
||||
|
|
|
|||
|
|
@ -31,7 +31,17 @@ frappe.views.KanbanView = class KanbanView extends frappe.views.ListView {
|
|||
if (!kanbans.length) {
|
||||
return frappe.views.KanbanView.show_kanban_dialog(this.doctype, true);
|
||||
} else if (kanbans.length && frappe.get_route().length !== 4) {
|
||||
return frappe.views.KanbanView.show_kanban_dialog(this.doctype, true);
|
||||
// Try to use the last board the user used, else default to the first available board
|
||||
const last_board = frappe.get_user_settings(this.doctype)["Kanban"]
|
||||
?.last_kanban_board;
|
||||
if (last_board && kanbans.includes(last_board)) {
|
||||
frappe.set_route("List", this.doctype, "Kanban", last_board);
|
||||
return;
|
||||
} else {
|
||||
const first_board = kanbans[0];
|
||||
frappe.set_route("List", this.doctype, "Kanban", first_board.name);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
this.kanbans = kanbans;
|
||||
|
||||
|
|
@ -120,6 +130,10 @@ frappe.views.KanbanView = class KanbanView extends frappe.views.ListView {
|
|||
// pass
|
||||
}
|
||||
|
||||
set_result_height() {
|
||||
// pass
|
||||
}
|
||||
|
||||
toggle_result_area() {
|
||||
this.$result.toggle(this.data.length > 0);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -720,6 +720,14 @@ frappe.views.QueryReport = class QueryReport extends frappe.views.BaseList {
|
|||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const js_filters = (frappe.query_reports[this.report_name]?.filters || [])
|
||||
.filter(
|
||||
(filter) => filter.fieldtype === "Link" && filters[filter.fieldname] !== ""
|
||||
)
|
||||
.map(({ fieldname, fieldtype, options }) => ({ fieldname, fieldtype, options }));
|
||||
|
||||
console.log(js_filters, "js_filters");
|
||||
|
||||
this.last_ajax = frappe.call({
|
||||
method: "frappe.desk.query_report.run",
|
||||
type: "GET",
|
||||
|
|
@ -730,7 +738,7 @@ frappe.views.QueryReport = class QueryReport extends frappe.views.BaseList {
|
|||
is_tree: this.report_settings.tree,
|
||||
parent_field: this.report_settings.parent_field,
|
||||
are_default_filters: are_default_filters,
|
||||
js_filters: frappe.query_reports[this.report_name]?.filters,
|
||||
js_filters: js_filters,
|
||||
},
|
||||
callback: resolve,
|
||||
always: () => this.page.btn_secondary.prop("disabled", false),
|
||||
|
|
|
|||
|
|
@ -355,7 +355,7 @@ frappe.views.TreeView = class TreeView {
|
|||
var node = me.tree.get_selected_node();
|
||||
|
||||
if (!(node && node.expandable)) {
|
||||
frappe.msgprint(__("Select a group node first."));
|
||||
frappe.msgprint(__("Select a group {0} first.", [__(me.doctype)]));
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -415,8 +415,10 @@ frappe.views.TreeView = class TreeView {
|
|||
{
|
||||
fieldtype: "Check",
|
||||
fieldname: "is_group",
|
||||
label: __("Group Node"),
|
||||
description: __("Further nodes can be only created under 'Group' type nodes"),
|
||||
label: __("Is Group"),
|
||||
description: __(
|
||||
"Further sub-groups can only be created under records marked as 'Group'"
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -345,3 +345,13 @@ body.modal-open[style^="padding-right"] {
|
|||
}
|
||||
}
|
||||
}
|
||||
.modal-dialog-centered {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: calc(100% - 1rem);
|
||||
}
|
||||
@media (min-width: 576px) {
|
||||
.modal-dialog-centered {
|
||||
min-height: calc(100% - 3.5rem);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -49,10 +49,6 @@
|
|||
align-items: unset;
|
||||
}
|
||||
|
||||
.input-area {
|
||||
margin-top: 0.2rem;
|
||||
}
|
||||
|
||||
.label-area {
|
||||
white-space: unset;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ table.user-perm {
|
|||
margin-bottom: var(--margin-sm);
|
||||
label {
|
||||
position: relative;
|
||||
align-items: center;
|
||||
}
|
||||
input[type="checkbox"] {
|
||||
margin-left: 0;
|
||||
|
|
|
|||
|
|
@ -181,6 +181,7 @@ def get():
|
|||
bootinfo["user"]["impersonated_by"] = frappe.session.data.get("impersonated_by")
|
||||
bootinfo["navbar_settings"] = frappe.client_cache.get_doc("Navbar Settings")
|
||||
bootinfo.has_app_updates = has_app_update_notifications()
|
||||
bootinfo.show_external_link_warning = frappe.get_system_settings("show_external_link_warning")
|
||||
|
||||
return bootinfo
|
||||
|
||||
|
|
|
|||
|
|
@ -471,7 +471,7 @@ class TestResponse(FrappeAPITestCase):
|
|||
}
|
||||
|
||||
for redirect, expected_redirect in expected_redirects.items():
|
||||
response = self.get(f"/login?{urlencode({'redirect-to':redirect})}", {"sid": self.sid})
|
||||
response = self.get(f"/login?{urlencode({'redirect-to': redirect})}", {"sid": self.sid})
|
||||
self.assertEqual(response.location, expected_redirect)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -205,7 +205,7 @@ def custom_has_permission(doc, ptype, user):
|
|||
|
||||
|
||||
def custom_auth():
|
||||
auth_type, token = frappe.get_request_header("Authorization", "Bearer ").split(" ")
|
||||
_auth_type, token = frappe.get_request_header("Authorization", "Bearer ").split(" ")
|
||||
if token == "set_test_example_user":
|
||||
frappe.set_user("test@example.com")
|
||||
|
||||
|
|
|
|||
|
|
@ -121,7 +121,7 @@ class TestPatchReader(IntegrationTestCase):
|
|||
|
||||
@patch("builtins.open", new_callable=mock_open, read_data=EDGE_CASES)
|
||||
def test_new_style_edge_cases(self, _file):
|
||||
all, pre, post = self.get_patches()
|
||||
_all, pre, _post = self.get_patches()
|
||||
self.assertEqual(
|
||||
pre,
|
||||
[
|
||||
|
|
@ -134,7 +134,7 @@ class TestPatchReader(IntegrationTestCase):
|
|||
|
||||
@patch("builtins.open", new_callable=mock_open, read_data=COMMENTED_OUT)
|
||||
def test_ignore_comments(self, _file):
|
||||
all, pre, post = self.get_patches()
|
||||
_all, pre, _post = self.get_patches()
|
||||
self.assertEqual(pre, ["app.module.patch1", "app.module.patch3"])
|
||||
|
||||
def test_verify_patch_txt(self):
|
||||
|
|
|
|||
|
|
@ -53,7 +53,7 @@ class TestQueryReport(IntegrationTestCase):
|
|||
visible_idx = [0, 2, 3]
|
||||
|
||||
# Build the result
|
||||
xlsx_data, column_widths = build_xlsx_data(
|
||||
xlsx_data, _column_widths = build_xlsx_data(
|
||||
data, visible_idx, include_indentation=False, include_filters=True
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -59,6 +59,7 @@ from frappe.utils.data import (
|
|||
cint,
|
||||
comma_and,
|
||||
comma_or,
|
||||
compare,
|
||||
cstr,
|
||||
duration_to_seconds,
|
||||
evaluate_filters,
|
||||
|
|
@ -236,6 +237,111 @@ class TestFilters(IntegrationTestCase):
|
|||
}
|
||||
self.assertFalse(evaluate_filters(doc, [("last_password_reset_date", "Timespan", "today")]))
|
||||
|
||||
def test_is_operator(self):
|
||||
"""Test 'is' operator for checking if values are set or not set."""
|
||||
# Test "is set" with different fieldtypes and values
|
||||
self.assertTrue(compare("1", "is", "set", "Int"))
|
||||
self.assertTrue(compare(1, "is", "set", "Int"))
|
||||
self.assertTrue(compare(0, "is", "set", "Int")) # 0 is considered "set"
|
||||
self.assertTrue(compare("hello", "is", "set", "Data"))
|
||||
self.assertTrue(compare(0.0, "is", "set", "Float"))
|
||||
|
||||
# Test "is set" with unset values - None should always be "not set" regardless of fieldtype
|
||||
self.assertFalse(compare(None, "is", "set", "Int"))
|
||||
self.assertFalse(compare(None, "is", "set", "Float"))
|
||||
self.assertFalse(compare(None, "is", "set", "Check"))
|
||||
self.assertFalse(compare(None, "is", "set", "Data"))
|
||||
self.assertFalse(compare("", "is", "set"))
|
||||
self.assertFalse(compare("", "is", "set", "Data"))
|
||||
self.assertFalse(compare(None, "is", "set"))
|
||||
|
||||
# Test "is not set" with set values
|
||||
self.assertFalse(compare("1", "is", "not set", "Int"))
|
||||
self.assertFalse(compare(1, "is", "not set", "Int"))
|
||||
self.assertFalse(compare(0, "is", "not set", "Int"))
|
||||
self.assertFalse(compare("hello", "is", "not set", "Data"))
|
||||
self.assertFalse(compare(0.0, "is", "not set", "Float"))
|
||||
|
||||
# Test "is not set" with unset values - None should always be "not set" regardless of fieldtype
|
||||
self.assertTrue(compare(None, "is", "not set", "Int"))
|
||||
self.assertTrue(compare(None, "is", "not set", "Float"))
|
||||
self.assertTrue(compare(None, "is", "not set", "Check"))
|
||||
self.assertTrue(compare(None, "is", "not set", "Data"))
|
||||
self.assertTrue(compare("", "is", "not set"))
|
||||
self.assertTrue(compare("", "is", "not set", "Data"))
|
||||
self.assertTrue(compare(None, "is", "not set"))
|
||||
|
||||
def test_in_operators(self):
|
||||
"""Test 'in' and 'not in' operators with and without fieldtype casting."""
|
||||
test_list = ["a", "b", "c"]
|
||||
|
||||
# Test "in" operator without fieldtype
|
||||
self.assertTrue(compare("a", "in", test_list))
|
||||
self.assertFalse(compare("", "in", test_list))
|
||||
self.assertFalse(compare("d", "in", test_list))
|
||||
self.assertFalse(compare(None, "in", test_list))
|
||||
|
||||
# Test "not in" operator without fieldtype
|
||||
self.assertFalse(compare("a", "not in", test_list))
|
||||
self.assertTrue(compare("", "not in", test_list))
|
||||
self.assertTrue(compare("d", "not in", test_list))
|
||||
self.assertTrue(compare(None, "not in", test_list))
|
||||
|
||||
# Test "in" operator with fieldtype casting - only first value should be cast
|
||||
string_list = ["1", "2", "3"]
|
||||
self.assertTrue(compare(1, "in", string_list, "Data"))
|
||||
self.assertTrue(compare("2", "in", string_list, "Data"))
|
||||
self.assertFalse(compare(4, "in", string_list, "Data"))
|
||||
|
||||
# Test type mismatch: Int fieldtype with string list (val2 is NOT cast)
|
||||
mixed_list = ["1", "2", "3"]
|
||||
self.assertFalse(compare("1", "in", mixed_list, "Int"))
|
||||
self.assertFalse(compare(1, "in", mixed_list, "Int"))
|
||||
|
||||
# Test with matching types: Int fieldtype with int list
|
||||
int_list = [1, 2, 3]
|
||||
self.assertTrue(compare("1", "in", int_list, "Int"))
|
||||
self.assertTrue(compare(2, "in", int_list, "Int"))
|
||||
self.assertFalse(compare("4", "in", int_list, "Int"))
|
||||
|
||||
# Test "not in" operator with fieldtype casting
|
||||
self.assertFalse(compare(1, "not in", string_list, "Data"))
|
||||
self.assertFalse(compare("2", "not in", string_list, "Data"))
|
||||
self.assertTrue(compare(4, "not in", string_list, "Data"))
|
||||
|
||||
# Test "not in" with type mismatch
|
||||
self.assertTrue(compare("1", "not in", mixed_list, "Int"))
|
||||
self.assertFalse(compare("1", "not in", int_list, "Int"))
|
||||
|
||||
# Test with Float fieldtype
|
||||
float_list = [1.5, 2.5, 3.5]
|
||||
self.assertTrue(compare("1.5", "in", float_list, "Float"))
|
||||
self.assertFalse(compare("4.5", "in", float_list, "Float"))
|
||||
|
||||
# Test None with "in"/"not in" operators - None should not be cast
|
||||
self.assertFalse(compare(None, "in", [""], "Data"))
|
||||
self.assertFalse(compare(None, "in", [0], "Int"))
|
||||
self.assertFalse(compare(None, "in", [0.0], "Float"))
|
||||
self.assertFalse(compare(None, "in", ["", "test"], "Data"))
|
||||
self.assertTrue(compare(None, "in", [None, "test"], "Data"))
|
||||
|
||||
# Test "not in" with None
|
||||
self.assertTrue(compare(None, "not in", [""], "Data"))
|
||||
self.assertTrue(compare(None, "not in", [0], "Int"))
|
||||
self.assertTrue(compare(None, "not in", [0.0], "Float"))
|
||||
self.assertTrue(compare(None, "not in", ["", "test"], "Data"))
|
||||
self.assertFalse(compare(None, "not in", [None, "test"], "Data"))
|
||||
|
||||
def test_is_operator_case_insensitive(self):
|
||||
"""Test that 'is' operator patterns are case insensitive."""
|
||||
self.assertTrue(compare("value", "is", "SET"))
|
||||
self.assertTrue(compare("value", "is", "Set"))
|
||||
self.assertTrue(compare("value", "is", "set"))
|
||||
|
||||
self.assertTrue(compare(None, "is", "NOT SET"))
|
||||
self.assertTrue(compare(None, "is", "Not Set"))
|
||||
self.assertTrue(compare(None, "is", "not set"))
|
||||
|
||||
|
||||
class TestMoney(IntegrationTestCase):
|
||||
def test_money_in_words(self):
|
||||
|
|
@ -362,7 +468,7 @@ class TestMathUtils(IntegrationTestCase):
|
|||
self.assertEqual(floor(22.7330), 22)
|
||||
self.assertEqual(floor("24.7"), 24)
|
||||
self.assertEqual(floor("26.7"), 26)
|
||||
self.assertEqual(floor(Decimal(29.45)), 29)
|
||||
self.assertEqual(floor(Decimal("29.45")), 29)
|
||||
|
||||
def test_ceil(self):
|
||||
from decimal import Decimal
|
||||
|
|
@ -372,7 +478,7 @@ class TestMathUtils(IntegrationTestCase):
|
|||
self.assertEqual(ceil(22.7330), 23)
|
||||
self.assertEqual(ceil("24.7"), 25)
|
||||
self.assertEqual(ceil("26.7"), 27)
|
||||
self.assertEqual(ceil(Decimal(29.45)), 30)
|
||||
self.assertEqual(ceil(Decimal("29.45")), 30)
|
||||
|
||||
|
||||
class TestHTMLUtils(IntegrationTestCase):
|
||||
|
|
@ -800,7 +906,7 @@ class TestResponse(IntegrationTestCase):
|
|||
timedelta(days=10, hours=12, minutes=120, seconds=10),
|
||||
],
|
||||
"float": [
|
||||
Decimal(29.21),
|
||||
Decimal("29.21"),
|
||||
],
|
||||
"doc": [
|
||||
frappe.get_doc("System Settings"),
|
||||
|
|
@ -1071,7 +1177,7 @@ class TestMiscUtils(IntegrationTestCase):
|
|||
self.assertIsInstance(get_file_timestamp(__file__), str)
|
||||
|
||||
def test_execute_in_shell(self):
|
||||
err, out = execute_in_shell("ls")
|
||||
_err, out = execute_in_shell("ls")
|
||||
self.assertIn("apps", cstr(out))
|
||||
|
||||
def test_get_all_sites(self):
|
||||
|
|
|
|||
|
|
@ -63,7 +63,7 @@ def get_missing_records_doctypes(doctype, visited=None) -> list[str]:
|
|||
# Mark as visited
|
||||
visited.add(doctype)
|
||||
|
||||
module, test_module = get_modules(doctype)
|
||||
_module, test_module = get_modules(doctype)
|
||||
meta = frappe.get_meta(doctype)
|
||||
link_fields = meta.get_link_fields()
|
||||
|
||||
|
|
@ -158,12 +158,11 @@ def _generate_records_for(
|
|||
index_doctype: str, reset: bool = False, commit: bool = False, initial_doctype: str | None = None
|
||||
) -> Generator[tuple[str, "Document"], None, None]:
|
||||
"""Create and yield test records for a specific doctype."""
|
||||
module: str
|
||||
test_module: ModuleType
|
||||
|
||||
logstr = f" {index_doctype} via {initial_doctype}"
|
||||
|
||||
module, test_module = get_modules(index_doctype)
|
||||
_module, test_module = get_modules(index_doctype)
|
||||
|
||||
# First prioriry: module's _make_test_records as an escape hatch
|
||||
# to completely bypass the standard loading and create test records
|
||||
|
|
|
|||
|
|
@ -695,9 +695,9 @@ def write_csv_file(path, app_messages, lang_dict):
|
|||
if len(app_message) == 2:
|
||||
path, message = app_message
|
||||
elif len(app_message) == 3:
|
||||
path, message, lineno = app_message
|
||||
path, message, _lineno = app_message
|
||||
elif len(app_message) == 4:
|
||||
path, message, context, lineno = app_message
|
||||
path, message, context, _lineno = app_message
|
||||
else:
|
||||
continue
|
||||
|
||||
|
|
|
|||
|
|
@ -610,7 +610,7 @@ def get_disk_usage():
|
|||
files_path = get_files_path()
|
||||
if not os.path.exists(files_path):
|
||||
return 0
|
||||
err, out = execute_in_shell(f"du -hsm {files_path}")
|
||||
_err, out = execute_in_shell(f"du -hsm {files_path}")
|
||||
return cint(out.split("\n")[-2].split("\t")[0])
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -592,7 +592,7 @@ def get_redis_conn(username=None, password=None):
|
|||
return RedisQueue.get_connection(**cred)
|
||||
except redis.exceptions.AuthenticationError:
|
||||
log(
|
||||
f'Wrong credentials used for {cred.username or "default user"}. '
|
||||
f"Wrong credentials used for {cred.username or 'default user'}. "
|
||||
"You can reset credentials using `bench create-rq-users` CLI and restart the server",
|
||||
colour="red",
|
||||
)
|
||||
|
|
|
|||
|
|
@ -298,7 +298,7 @@ class BackupGenerator:
|
|||
def zip_files(self):
|
||||
# For backwards compatibility - pre v13
|
||||
click.secho(
|
||||
"BackupGenerator.zip_files has been deprecated in favour of" " BackupGenerator.backup_files",
|
||||
"BackupGenerator.zip_files has been deprecated in favour of BackupGenerator.backup_files",
|
||||
fg="yellow",
|
||||
)
|
||||
return self.backup_files()
|
||||
|
|
|
|||
|
|
@ -1633,7 +1633,7 @@ def get_thumbnail_base64_for_image(src: str) -> dict[str, str] | None:
|
|||
return
|
||||
|
||||
try:
|
||||
image, unused_filename, extn = get_local_image(src)
|
||||
image, _unused_filename, extn = get_local_image(src)
|
||||
except OSError:
|
||||
return
|
||||
|
||||
|
|
@ -2008,7 +2008,7 @@ def get_url_to_report_with_filters(name, filters, report_type=None, doctype=None
|
|||
|
||||
|
||||
def sql_like(value: str, pattern: str) -> bool:
|
||||
if not isinstance(pattern, str) and isinstance(value, str):
|
||||
if not (isinstance(pattern, str) and isinstance(value, str)):
|
||||
return False
|
||||
if pattern.startswith("%") and pattern.endswith("%"):
|
||||
return pattern.strip("%") in value
|
||||
|
|
@ -2021,7 +2021,7 @@ def sql_like(value: str, pattern: str) -> bool:
|
|||
return pattern in value
|
||||
|
||||
|
||||
def filter_operator_is(value: str, pattern: str) -> bool:
|
||||
def filter_operator_is(value: str | None, pattern: str) -> bool:
|
||||
"""Operator `is` can have two values: 'set' or 'not set'."""
|
||||
pattern = pattern.lower()
|
||||
|
||||
|
|
@ -2082,11 +2082,37 @@ def evaluate_filters(doc: "Mapping", filters: FilterSignature):
|
|||
return True
|
||||
|
||||
|
||||
def compare(val1: Any, condition: str, val2: Any, fieldtype: str | None = None):
|
||||
def compare(val1: Any, condition: str, val2: Any, fieldtype: str | None = None) -> bool:
|
||||
"""Compare two values using the specified operator with optional fieldtype casting.
|
||||
|
||||
Args:
|
||||
val1: The left operand value to compare
|
||||
condition: The comparison operator (e.g., "=", ">", "is", "in", "like")
|
||||
val2: The right operand value to compare against
|
||||
fieldtype: Optional fieldtype for casting val1 (and val2 for most operators)
|
||||
|
||||
Returns:
|
||||
bool: True if the comparison evaluates to True, False otherwise
|
||||
|
||||
Note:
|
||||
- For "is" operator: No casting is performed to preserve None values
|
||||
- For "in"/"not in" operators: Only val1 is cast (if not None), val2 remains unchanged
|
||||
- For "Timespan" operator: No casting is performed
|
||||
- For other operators: Both val1 and val2 are cast to the specified fieldtype
|
||||
"""
|
||||
if fieldtype:
|
||||
val1 = cast(fieldtype, val1)
|
||||
if condition != "Timespan":
|
||||
if condition in {"is", "Timespan"}:
|
||||
# No casting to preserve original values
|
||||
pass
|
||||
elif condition in {"in", "not in"}:
|
||||
# Cast only val1 (if not None), preserve val2 container
|
||||
if val1 is not None:
|
||||
val1 = cast(fieldtype, val1)
|
||||
else:
|
||||
# Cast both values for comparison operators (=, !=, >, <, >=, <=, like, etc.)
|
||||
val1 = cast(fieldtype, val1)
|
||||
val2 = cast(fieldtype, val2)
|
||||
|
||||
if condition in operator_map:
|
||||
return operator_map[condition](val1, val2)
|
||||
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue